/**
 * MDT Central — global stylesheet
 * Extends Bootstrap 5.3
 *
 * Token architecture (UI_STANDARDS.md §4):
 *   1. Brand constants  --mdt-*  — raw palette; never consumed by components.
 *   2. Semantic tokens  --ctl-*  — the ONLY layer components consume.
 *      :root carries Operator Light (the no-attribute default, so standalone
 *      pages that load this file with no theming logic get it free). Five
 *      override blocks build the other sheets, keyed on <html data-style=""
 *      data-bs-theme="">. Block ORDER is load-bearing: the single-attribute
 *      blocks come first, the style+scheme combos after, so a later block
 *      always refines an earlier one at equal-or-higher specificity.
 *   3. Bootstrap bridge — --ctl-* mapped onto Bootstrap 5.3's --bs-* variables
 *      so native components (tooltips, popovers, offcanvas, toasts, modals,
 *      dropdowns) theme correctly with zero per-component dark overrides.
 *
 * Fonts arrive from /css/fonts.css (Oswald variable 200-700, Lato 300/400/700/900).
 */

/* ═══ Layer 1 — Brand constants (components never reference these) ═══ */
:root {
    --mdt-black: #000000;
    --mdt-white: #ffffff;
    /* Grey ramp */
    --mdt-grey-050: #f8f8f8;
    --mdt-grey-100: #f5f5f5;
    --mdt-grey-200: #dcdcdc;
    --mdt-grey-300: #c4c4c4;
    --mdt-grey-400: #a0a0a0;
    --mdt-grey-500: #858585;
    --mdt-grey-600: #5c5c5c;
    --mdt-grey-700: #3a3a3a;
    --mdt-grey-800: #2a2a2a;
    --mdt-grey-900: #161616;
    /* MDT green ramp (brand accent) */
    --mdt-green-100: #e7ecd3;
    --mdt-green-400: #9fbc50;
    --mdt-green-500: #86a33d;
    --mdt-green-600: #6d8730;
    --mdt-green-700: #5a7028;
    /* Refined Blue ramp */
    --mdt-blue-200: #93b8ff;
    --mdt-blue-300: #78a5ff; /* Refined dark link/accent-text: 4.99:1 on that
                                 sheet bg-hover. #6a9bff missed by 0.02. */
    --mdt-blue-400: #4b86ff;
    --mdt-blue-500: #2f6fed;
    --mdt-blue-600: #2258c9;
    --mdt-blue-700: #1b46a0;
    /* Status */
    --mdt-red-100: #fbebe9;
    --mdt-red-400: #e05a55;
    --mdt-red-500: #b4231f;
    --mdt-red-900: #381411; /* wash dark enough for red-400 text at 4.5:1 */
    --mdt-amber-100: #fbf3e0;
    --mdt-amber-500: #c98a1a;
    --mdt-amber-600: #8a5f0d; /* warning-text on light. Was #93650f, which
                                 cleared 4.5:1 on the page surfaces but only
                                 managed 4.31:1 on a HOVERED row — the darkest
                                 light surface any text actually lands on. */
    --mdt-amber-900: #332508;
}

/* ═══ Layer 2 — Semantic tokens: sheet 1/6, Operator Light (default) ═══ */
:root {
    /* Surfaces (Operator: warm-tinted neutrals) */
    --ctl-bg: #f6f6f4;
    --ctl-bg-alt: var(--mdt-white);
    --ctl-bg-elevated: var(--mdt-white);
    --ctl-bg-inset: var(--mdt-white);
    --ctl-bg-inverse: #141414;
    /* The ink certified against bg-inverse. In every one of the six sheets
       --ctl-bg-inverse IS the opposite scheme's ground (the light sheets state
       the dark ground, the dark sheets state the light one), so the ink that
       belongs on it is this sheet's own ground turned over — the very colour
       our text is already read against, now doing the reading. Declared once
       as an alias for the same reason --ctl-row-settled-bg is: the step is one
       every sheet already owns, so re-tuning a ground re-tunes this with it,
       and no sheet needs an override. The pair measures 16.9:1 or better in
       all six sheets, which CssTokenContractTest measures rather than trusts.
       Consumed through the .ctl-bg-inverse utility below the bridge — never a
       bare `background: var(--ctl-bg-inverse)`, because the ground without
       this ink is an unreadable slab. */
    --ctl-fg-inverse: var(--ctl-bg);
    /* The hover surface, and its triplet. bg-hover is the only neutral that
       steps AWAY from the card in both schemes (darker on the light sheets,
       lighter on the dark ones), which is why it grounds the app's quiet chips
       — `.bg-secondary-subtle` since #1879 and, since #1926, `.bg-secondary`
       itself, whose Bootstrap utility composes `rgba(var(--bs-secondary-rgb), …)`
       and therefore needs the value as a TRIPLET.
       The triplet sits beside the hex rather than in the "RGB triplets" group
       below on purpose: the two are one value in two notations, and every sheet
       states its own hover colour, so keeping them adjacent is what stops a
       re-tune from moving one and leaving the other. CssTokenContractTest
       measures that they agree in all six sheets rather than trusting it. */
    --ctl-bg-hover: #ecece7;
    --ctl-bg-hover-rgb: 236, 236, 231;
    /* A settled list row (a closed/terminal ticket) sinks back to the PAGE
       GROUND: the row stops reading as card material and rejoins the surface
       the card is lifted off. That is why this is an alias rather than a new
       colour — the step is one the sheet already owns, so it lands in the
       sheet's own temperature and re-tuning the ground re-tunes this with it.
       Only Industrial Light overrides it, because that preset grounds on pure
       white by identity and would have nothing to sink TO. */
    --ctl-row-settled-bg: var(--ctl-bg);
    /* ── Navigation chrome — the left rail and the layers that ride on it ────
       THE RAIL IS DARK IN ALL SIX SHEETS. It is the one component in the app
       that does not follow the page's scheme, and that is a deliberate, reviewed
       decision rather than the pre-token accident it replaced.

       History, because the shape of this block only makes sense with it. The
       rail originally shipped three hardcoded greys (#212529 rail, #1a1e21
       header + second panel, #2a2f34 admin sheet) over rgba(255,255,255,…)
       washes — one slab of Bootstrap blue-grey in every sheet, ignoring both the
       chosen style and the scheme. The first fix (#1705) made it follow the
       sheet outright: bg-alt for the surface, --ctl-fg for the ink, so a light
       scheme got a light rail. Live review rejected that on two counts, and both
       are now requirements:
         · a light rail lost the app's persistent left-edge anchor. The rail is
           navigation, not content, and the classic dark-rail-on-light-app split
           is what makes it read as chrome at a glance.
         · in the dark sheets the rail sat only 1.06:1 off the page — technically
           a step, visually nothing.

       So the model is: the rail is a DARK chrome surface everywhere, tinted to
       the active style's own temperature, wearing its own light ink ramp. What
       changes between schemes is where it sits relative to the page:
         · LIGHT sheets — the rail is the style's own DARK-scheme elevated
           surface (Industrial #212121, Operator #222220, Refined #1a2c42). Those
           are the same darkness class as the retired #212529, which is what the
           stakeholder asked to get back, and each is already certified as that
           style's temperature: pure neutral, warm, navy.
         · DARK sheets — the rail lifts ABOVE the page instead (~1.3:1, roughly
           four times the old step), so it reads as an elevated surface floating
           over the content rather than a slightly different black. It is
           deliberately the brightest surface in those sheets — chrome on top.

       Everything else is derived off --ctl-nav-bg and --ctl-nav-fg, so a style
       only ever declares the rail tone and its ink:
         nav-raised-bg  the layers ON the rail — brand band, the Settings sheet
                        that slides up, the second panel. Mixed toward the ink,
                        i.e. always LIGHTER than the rail, which is what "raised"
                        means on a dark ground.
         nav-hover-bg   hover / open-group / active. Translucent ink so one wash
                        reads on the rail AND on the raised sheet; an opaque
                        surface can only be right on one of them. Hover and
                        active share it on purpose — the accent left-bar is the
                        "you are here" marker.
         nav-border     the rail's internal hairlines, in the rail's own family.
                        --ctl-border is a PAGE value and is invisible here.
         nav-accent     the "you are here" left-bar and the Settings sheet's
                        rule. Aliases --ctl-accent, which clears 3:1 (WCAG
                        1.4.11, non-text) on the green rails at 4.9–5.6:1 — but
                        Refined's #2f6fed lands at 2.88:1 on its own navy rail,
                        so that style alone steps the INDICATOR up a rung. The
                        button fill stays --ctl-accent; this token only ever
                        paints marks drawn ON the rail.
         nav-scrim /    both scheme-invariant, for the same reason the ink is:
         nav-shadow     the surface they fall on is dark in every sheet.
       The ink ramp is sized against the WORST nav surface (a hovered raised
       layer), not the rail — CssTokenContractTest composites and measures it.
       -subtle is certified on the RESTING surfaces only, so nothing hoverable
       may use it. */
    --ctl-nav-bg: #222220;                 /* Operator: warm dark */
    --ctl-nav-fg: #e8e8e0;
    --ctl-nav-fg-muted: #b5b4a9;
    --ctl-nav-fg-subtle: #a3a196;
    --ctl-nav-fg-rgb: 232, 232, 224;
    --ctl-nav-raised-bg: color-mix(in srgb, var(--ctl-nav-bg) 94%, var(--ctl-nav-fg));
    --ctl-nav-hover-bg: rgba(var(--ctl-nav-fg-rgb), 0.08);
    --ctl-nav-border: color-mix(in srgb, var(--ctl-nav-bg) 90%, var(--ctl-nav-fg));
    --ctl-nav-accent: var(--ctl-accent);
    --ctl-nav-scrim: rgba(0, 0, 0, 0.45);
    --ctl-nav-shadow: rgba(0, 0, 0, 0.45);
    /* Text */
    --ctl-fg: #141414;
    --ctl-fg-muted: var(--mdt-grey-600);
    --ctl-fg-subtle: #686868; /* 4.70:1 on this sheet bg-hover, the darkest light surface it lands on */
    --ctl-fg-faint: #a8a8a3; /* decorative/disabled ONLY — not AA */
    /* Borders */
    --ctl-border: #d8d8d2;
    --ctl-border-strong: #c8c8c2;
    /* The quiet end of the same hairline: --ctl-border thinned toward whatever
       surface it is drawn on, for chrome that should be FOUND rather than read
       (the dashboard's edit-mode grip/eye boxes). DERIVED, not a seventh hex
       per sheet — the substitution happens on <html>, where every sheet
       declares its own --ctl-border, so all six get their own subtle step for
       free and re-tuning a sheet's border re-tunes this with it (the
       --ctl-chart-grid / --ctl-row-settled-bg precedent). Alpha rather than a
       lighter colour because "lighter" is backwards in the dark schemes: a
       thinned border sinks toward its own background whichever way that
       background runs, so one declaration is correct in all six. */
    --ctl-border-subtle: color-mix(in srgb, var(--ctl-border) 55%, transparent);
    /* Accent (MDT green; accent-fg per §4.5: black on green in green styles) */
    --ctl-accent: var(--mdt-green-500);
    --ctl-accent-hover: var(--mdt-green-600);
    --ctl-accent-press: var(--mdt-green-700);
    --ctl-accent-fg: #000000;
    --ctl-accent-text: var(--mdt-green-700);
    --ctl-accent-rgb: 134, 163, 61;
    --ctl-link: var(--mdt-green-700);
    --ctl-link-rgb: 90, 112, 40;
    --ctl-link-hover: #47591f;
    --ctl-link-hover-rgb: 71, 89, 31;
    /* RGB triplets of scheme surfaces/text (for rgba() composition + bridge) */
    --ctl-fg-rgb: 20, 20, 20;
    --ctl-bg-rgb: 246, 246, 244;
    --ctl-bg-alt-rgb: 255, 255, 255;
    --ctl-fg-muted-rgb: 92, 92, 92;
    /* Status (§4.3) — base / contrast-safe text / background wash */
    --ctl-danger: var(--mdt-red-500);
    --ctl-danger-text: var(--mdt-red-500);
    --ctl-danger-bg: var(--mdt-red-100);
    --ctl-danger-rgb: 180, 35, 31;
    --ctl-warning: var(--mdt-amber-500);
    --ctl-warning-text: var(--mdt-amber-600);
    --ctl-warning-bg: var(--mdt-amber-100);
    --ctl-warning-rgb: 201, 138, 26;
    --ctl-success: var(--mdt-green-600);
    --ctl-success-text: var(--mdt-green-700);
    --ctl-success-bg: var(--mdt-green-100);
    --ctl-success-rgb: 109, 135, 48;
    /* info is the one status family whose "base" is not a hue at all: it is the
       muted INK, identically valued to --ctl-fg-muted in five of the six sheets.
       That is coherent — an informational note is quiet text, not a coloured
       slab — and it is why --ctl-info-rgb may never serve a `bg-` position
       (#2003). The informational GROUND is the wash below, and its triplet is
       declared beside the hex for the same reason --ctl-bg-hover-rgb is
       (#1926): two notations of one colour drift the moment one is re-tuned,
       and CssTokenContractTest measures that they agree in all six sheets
       rather than trusting it. */
    --ctl-info: var(--mdt-grey-600);
    --ctl-info-text: var(--mdt-grey-600);
    --ctl-info-bg: #f1f1f1;
    --ctl-info-bg-rgb: 241, 241, 241;
    --ctl-info-rgb: 92, 92, 92;
    /* Shadows (dark schemes raise opacity, never geometry) */
    --ctl-shadow: rgba(0, 0, 0, 0.075);
    --ctl-shadow-hover: rgba(0, 0, 0, 0.1);
    /* Card chrome. This pair used to be the compensation for a SQUARE card: the
       sharp presets bought back the missing corner cue with a crisper hairline
       (border-strong) and one step more shadow, and Refined Blue — the only
       preset that shipped a real card radius — overrode both back down.

       Cards are rounded in every preset now (--ctl-radius-card), so the
       compensation has nothing left to compensate for, and carrying it would
       leave every card in the app heavier than the four surfaces that rounded
       themselves early. Each of those four ALSO flipped to this soft pair as
       part of the same move, with the same one-line reason — so the soft pair
       is simply what a rounded card wears here, and it now says so once.

       The other consumers of --ctl-card-shadow (the portal's attention rows,
       combobox and chip menu; .record-card) all sit inside scopes that already
       re-declare this token, so in practice this value reaches .card alone. */
    --ctl-card-border: var(--ctl-border);
    /* Two-layer diffusion (§4.4 card anatomy): a tight contact shadow plus a
       wide ambient one, both off the scheme's own --ctl-shadow so light and
       dark track without a fork. Promoted from the portal layer at the
       2026-07-31 pivot — the lift reads as diffusion, not a hard drop. */
    --ctl-card-shadow: 0 1px 3px var(--ctl-shadow), 0 6px 18px var(--ctl-shadow);
    /* ── Priority = a SEVERITY RAMP, never a brand hue ──────────────────────
       Priority and workflow status sit in ADJACENT list columns, so they must
       never share a hue. Priority owns grey → amber → orange → red, in that
       order, in every one of the six sheets: severity is universal, not brand,
       so these values do NOT vary by style — only by scheme.

       This replaces the defect the design review measured: MEDIUM was #0264f4
       and ASSIGNED was #006adb, i.e. two different meanings rendered in the
       same Bootstrap blue one column apart. Blue is now absent from the
       priority ramp in all three styles.

       LOW deliberately has no token — .text-risk-low reads --ctl-fg-muted, the
       "nothing to see here" grey, so the ramp starts from the page's own quiet
       tone rather than a competing colour. CRITICAL reads --ctl-danger-text
       for the same reason: escalation should look exactly like every other
       danger signal in the app.
       Ratios vs the darkest LIGHT surface any of them lands on (#ecece7,
       Operator bg-hover): medium 4.90:1, high 4.88:1, critical 5.54:1. */
    --ctl-risk-medium: #7d6200;   /* amber  */
    --ctl-risk-high: #9e5100;     /* orange */

    /* ── Workflow status hues — brand-adjacent, per preset ───────────────────
       Sheet 1 (Operator Light) and Industrial Light share this set: both are
       MDT-green styles, so their status vocabulary is the brand's own
       olive/slate/teal rather than the retired Bootstrap blue. Refined Blue
       overrides all three in its own sheet — blue IS its identity, and there
       the job is to keep status distinguishable from links, not to remove it.

       Each hue is disjoint from the priority ramp above (no amber, orange, red
       or grey here), which is the whole point of the separation.
       Ratios on #ecece7: new 4.75:1, assigned 4.93:1, in-progress 5.21:1. */
    --ctl-status-new: #46701c;         /* olive — brand green, darkened  */
    --ctl-status-assigned: #456a7a;    /* slate — the #55808f family     */
    --ctl-status-in-progress: #0e6b7a; /* teal  — was orange, which read
                                          as priority HIGH one column over */

    /* ── Chart ramp (Support\SvgChart) ──────────────────────────────────────
       The reports charts shipped the Okabe–Ito palette — a fine general-purpose
       set, but its first hue is a library blue that belongs to no preset, so
       every chart on /reports read as a foreign object dropped into the app.
       The ramp is now derived from the ACTIVE preset: slot 1 is the preset's
       own accent family and the rest are the semantic hues this stylesheet has
       already contrast-tuned, ordered for maximum hue+lightness separation
       between neighbours. Refined Blue and both dark schemes override the whole
       ramp in their own sheets.

       SvgChart emits these as `style="fill:var(--ctl-chart-N)"` rather than a
       fill="" presentation attribute, because var() substitution is only
       reliable in a real style declaration.

       shortcut: hand-ordered for separation rather than algorithmically
       CVD-verified — the previous palette was formally colourblind-safe and
       this one trades that for brand fit. Every chart still labels its series
       in text (legend + on-mark values), so colour is never the only channel;
       revisit with a proper CVD simulation if a chart ever loses its labels. */
    --ctl-chart-1: var(--mdt-green-600); /* accent family */
    --ctl-chart-2: #9e5100;              /* orange */
    --ctl-chart-3: #0e6b7a;              /* teal   */
    --ctl-chart-4: var(--mdt-red-500);   /* red    */
    --ctl-chart-5: #456a7a;              /* slate  */
    --ctl-chart-6: #7d6200;              /* amber  */
    --ctl-chart-grid: var(--ctl-border);
    --ctl-chart-label: var(--ctl-fg-muted);
    --ctl-chart-value: var(--ctl-fg);
    --ctl-chart-empty: var(--ctl-fg-faint);

    /* ── Structure scales (§4.4) — style/scheme-invariant, radius included ── */
    --ctl-radius: 8px;        /* Controls — buttons, inputs, chips, alerts, the
                                 rich editor. Style-INVARIANT since the
                                 2026-07-31 portal-first pivot: radius left the
                                 style system (UI_STANDARDS §3/§4.4), and the
                                 8px/12px pair the portal proved became the
                                 baseline every sheet shares. px-suffixed so
                                 Bootstrap's calc() compositions of
                                 --bs-border-radius stay valid arithmetic. */
    --ctl-radius-lg: 12px;    /* Overlay containers — modals, toasts, dropdown
                                 and popover shells, floating menus. */
    --ctl-radius-full: 999px;
    /* Cards. Preset-INVARIANT, like -full and -tile below, and for the same
       reason: a card is the same object whatever style sheet is loaded
       (developer call, "round the corners on all cards").

       Why a token of its own rather than lifting --ctl-radius or -lg off zero.
       Those two carry other passengers. --ctl-radius drives CONTROLS — buttons,
       inputs, chips, alerts, the rich editor, the SLA meter tracks — and -lg
       drives modals, toasts, .ctl-widget and Bootstrap's .rounded-lg/-xl
       bridge. The ask was the cards, so the card gets the knob and nothing else
       moves; the sharp presets stay genuinely sharp everywhere a control is.

       12px is not a new number. It is what all four surfaces that had already
       rounded their own cards one at a time were using — the portal (#1465),
       the ticket detail body, the record-section cards, and, at the launcher
       tile's 10px, the dashboard home. Those scopes keep their local overrides
       (higher specificity, still authoritative); this token is what every card
       that never got a bespoke rule finally reads. Ultimately descended from
       .login-split-card's 14px, which is where the app first broke the sharp
       preset on purpose. */
    --ctl-radius-card: 12px;
    /* Launcher tiles (dashboard Shortcuts). The only rounded geometry in the
       sharp styles, and a deliberate exception: shortcuts are an ambient/hero
       affordance on the dashboard (§3 "chrome motifs … login, empty states,
       dashboards"), not a data surface, and the browser-homepage shape they
       borrow reads as rounded. One value across all three styles — a shortcut
       card is the same object whatever the style sheet (stakeholder call). */
    --ctl-radius-tile: 10px;
    /* Spacing — 8pt ramp */
    --ctl-sp-1: 4px;
    --ctl-sp-2: 8px;
    --ctl-sp-3: 12px;
    --ctl-sp-4: 16px;
    --ctl-sp-5: 24px;
    --ctl-sp-6: 32px;
    --ctl-sp-7: 48px;
    --ctl-sp-8: 64px;
    --ctl-sp-9: 96px;
    --ctl-sp-10: 128px;
    /* Z-index scale — no literal z-index elsewhere (existing literals migrate
       in Phase B component rebuilds) */
    --ctl-z-sticky: 100;
    --ctl-z-sidebar: 200;
    --ctl-z-panel: 300;
    --ctl-z-dropdown: 500;
    --ctl-z-modal-backdrop: 900;
    --ctl-z-modal: 1000;
    --ctl-z-toast: 1100;
    --ctl-z-tooltip: 1200;
    /* Motion */
    --ctl-dur-fast: 120ms;
    --ctl-dur: 180ms;
    --ctl-dur-slow: 300ms;
    /* Focus ring — accent at 35% alpha, box-shadow form (§4.4) */
    --ctl-focus-ring: 0 0 0 3px rgba(var(--ctl-accent-rgb), 0.35);
    /* Layout */
    --ctl-sidebar-width: 230px;
    /* Second nav panel (single-layer drilldown, UI_STANDARDS §7) */
    --ctl-sidebar-panel-width: 260px;
    /* Content clamps — settings/form/reading pages; data surfaces run full width */
    --ctl-content-max: 68rem;
    --ctl-content-max-narrow: 46rem;
    --ctl-content-max-wide: 100rem;
    --ctl-sidebar-collapsed: 52px;
    /* The chrome header band: the height every pane/panel header stands at, so
       adjacent panes read as one system rather than three screens stitched
       together. Already the shell's own rhythm — the nav rail header and the
       nav second-panel header both stood at this literal before it had a name.
       Headers consume it as a min-height and centre their contents in it; a
       header must never take its height from whichever control inside it
       happens to be tallest, which is precisely how /chat ended up drawing the
       same band at 58.2px, 52px and 41.8px across its three panes. */
    --ctl-pane-head-h: 3.25rem;

    /* ── Typography (§2) ── */
    --ctl-font-display: 'Oswald', 'Arial Narrow', sans-serif;
    /* Body face: system stack for UI/table legibility (stakeholder test
       2026-07-20 — Lato read poorly at data densities). Oswald remains the
       display face; Lato stays available via --ctl-font-brand for
       brand-voice surfaces if wanted. */
    --ctl-font-body: system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
    --ctl-font-brand: 'Lato', 'Helvetica Neue', Arial, sans-serif;
    /* Mono face — the fourth font token (#2013). §2 named three and the app
       drew code FIVE ways: an undefined `var(--ctl-font-mono, …)` on the portal
       kbd (an author reached for this token before it existed and it silently
       rendered its fallback), a bare `monospace` on .api-method, Bootstrap's
       compiled --bs-font-monospace on .trace-path, a component-local --k-mono
       in the KB article system, and 'Courier New' / 'SF Mono' literals inline
       in three templates. Same content, different character shapes depending on
       which call site drew it.

       The stack is --k-mono's, the best-researched of the five: ui-monospace
       first, so each OS hands over its own system mono (SF Mono on Apple,
       Cascadia on Windows) instead of us guessing at its name; then the named
       modern faces for the developers who install them; then Menlo/Consolas as
       the pre-ui-monospace floor; then the generic. Quoting normalised to the
       single quotes its three sibling font tokens use.

       Reaching the whole app takes TWO declarations, not one: this token, and
       the --bs-font-monospace bridge in Layer 3. Bootstrap's reboot styles
       `code, kbd, pre, samp` and its `.font-monospace` utility from that
       variable, so the bridge is what carries the token to every <code> in a
       rendered body (§6.5), to .thread-md-code / .thread-md-inline-code — a
       <pre> and a <code>, which is why neither needs a rule of its own — and to
       the ~40 templates that already say `class="… font-monospace"`. */
    --ctl-font-mono: ui-monospace, 'SFMono-Regular', 'JetBrains Mono', 'Cascadia Code', Menlo, Consolas, monospace;
    /* 7-step app type scale (§2.1). --ctl-fs-body is the density-varying base
       UI size (§5: 15px comfortable / 13.5px compact). */
    --ctl-fs-display-lg: 40px;
    --ctl-fs-display-md: 28px;
    --ctl-fs-display-sm: 22px;
    --ctl-fs-body-lg: 16px;
    --ctl-fs-body: 15px;
    --ctl-fs-body-sm: 12.5px;
    --ctl-fs-caption: 11px;
    --ctl-lh-tight: 1.02;
    --ctl-lh-snug: 1.15;
    --ctl-lh-normal: 1.5;
    --ctl-lh-relaxed: 1.7;

    /* ── Density (§5) — comfortable defaults; compact overrides below ── */
    --ctl-control-pad-y: 0.475rem;  /* ≈40px control at 15px/1.5 + 1px borders */
    --ctl-control-pad-x: 0.625rem;
    --ctl-fs-label: 13px;
    --ctl-table-cell-pad-y: 10px;
    --ctl-section-pad: var(--ctl-sp-5);
}

/* ═══ Sheet 2/6 — Operator Dark (keys on scheme alone so pages that set
       only data-bs-theme="dark" still theme correctly) ═══ */
html[data-bs-theme="dark"] {
    /* ── Dark re-grounding (Operator) ───────────────────────────────────────
       The old sheet grounded at #060606 with pure-white body text: cards lifted
       by 15 units of luminance (invisible), shadows had nothing to fall on, and
       #ffffff on near-black glared. Operator's documented temperature is
       "warm-tinted near-black" (UI_STANDARDS §3), so the whole ramp moves up
       into the #16–#22 class while STAYING warm — the green channel leads the
       blue in every surface value below, which is what keeps this sheet
       recognisably Operator and not Industrial.

       Elevation is now a real signal: bg #161613 → bg-alt #1c1c19 → elevated
       #222220. Body text steps off pure white to #e8e8e0 (12.94:1 on the
       lightest surface) and the muted/subtle labels are warm greys, not
       neutral ones. */
    --ctl-bg: #161613;
    --ctl-bg-alt: #1c1c19;
    --ctl-bg-elevated: #222220;
    --ctl-bg-inset: #121210;
    --ctl-bg-inverse: #f6f6f4;
    --ctl-bg-hover: #2a2a26;
    --ctl-bg-hover-rgb: 42, 42, 38;
    --ctl-fg: #e8e8e0;
    /* Ratios below are all measured against #222220, the LIGHTEST surface in
       this sheet and therefore the worst case for light-on-dark text. */
    --ctl-fg-muted: #a5a49b;  /* warm grey — 6.36:1 */
    --ctl-fg-subtle: #97958b; /* warm grey — 4.79:1 on a HOVERED row, the lightest surface here */
    --ctl-fg-faint: #6d6b63;  /* decorative/disabled only — not AA by design */
    --ctl-border: #34332d;
    --ctl-border-strong: #45443c;
    --ctl-accent-hover: var(--mdt-green-400);
    --ctl-accent-press: var(--mdt-green-600);
    --ctl-accent-text: var(--mdt-green-400);
    --ctl-link: var(--mdt-green-400);
    --ctl-link-rgb: 159, 188, 80;
    --ctl-link-hover: #b4cf6b;
    --ctl-link-hover-rgb: 180, 207, 107;
    --ctl-fg-rgb: 232, 232, 224;
    --ctl-bg-rgb: 22, 22, 19;
    --ctl-bg-alt-rgb: 28, 28, 25;
    --ctl-fg-muted-rgb: 165, 164, 155;
    /* Semantic colours re-tuned for the lifted ground. red-400 (#e05a55) was
       5.76:1 on #060606 but only 4.37:1 on #222220 — it had to brighten to stay
       compliant, not merely to look right. The amber brightens for the same
       reason (and because a dark-tuned amber is what makes MEDIUM priority read
       as caution rather than mud). The washes move up with the surfaces so a
       danger/warning callout still reads as a tinted panel, not a black hole. */
    --ctl-danger-text: #e8706b;  /* 5.28:1 on #222220 */
    --ctl-danger-bg: #3b1a17;
    --ctl-warning-text: #e0b64a; /* 8.32:1 */
    --ctl-warning-bg: #3a2c0d;
    --ctl-success-text: var(--mdt-green-400); /* 7.42:1 */
    --ctl-success-bg: #232d15;
    --ctl-info: #a5a49b;
    --ctl-info-text: #a5a49b;
    --ctl-info-bg: #26251f;
    --ctl-info-bg-rgb: 38, 37, 31;
    --ctl-info-rgb: 165, 164, 155;
    /* The rail lifts ABOVE the page in the dark sheets rather than sitting in
       it — the one thing that changes between an Operator light rail and an
       Operator dark one. The ink ramp is unchanged: the rail is dark either way,
       so it wears the same ink either way. */
    --ctl-nav-bg: #2c2c28;
    /* Shadows step up with the ground: a surface at #222220 needs a heavier
       shadow than one at #151515 for the same perceived lift. */
    --ctl-shadow: rgba(0, 0, 0, 0.5);
    --ctl-shadow-hover: rgba(0, 0, 0, 0.62);
    /* Priority ramp — same grey/amber/orange/red order as light, brightened for
       the dark ground. MEDIUM is amber here too: blue is gone from priority in
       every sheet. Ratios on #222220: medium 8.32:1, high 7.04:1. */
    --ctl-risk-medium: #e0b64a;
    --ctl-risk-high: #fb923c;
    /* Status — the same olive / slate / teal vocabulary as the light sheet,
       lifted for the dark ground and desaturated so the slate reads as slate
       rather than as a link. Ratios on #222220: 8.02 / 7.07 / 7.97:1. */
    --ctl-status-new: #b0c063;
    --ctl-status-assigned: #8fb2c2;
    --ctl-status-in-progress: #5cc6d6;
    /* Chart ramp lifted for the dark ground — same hue order as light. */
    --ctl-chart-1: var(--mdt-green-400);
    --ctl-chart-2: #fb923c;
    --ctl-chart-3: #5cc6d6;
    --ctl-chart-4: #e8706b;
    --ctl-chart-5: #8fb2c2;
    --ctl-chart-6: #e0b64a;
}

/* ═══ Sheet 3/6 — Refined Blue Light ═══ */
html[data-style="refined"] {
    --ctl-bg: #eef2f7;
    --ctl-bg-alt: var(--mdt-white);
    --ctl-bg-elevated: var(--mdt-white);
    --ctl-bg-inset: #f5f8fb;
    --ctl-bg-inverse: #0b1a2e;
    --ctl-bg-hover: #e2e9f2;
    --ctl-bg-hover-rgb: 226, 233, 242;
    --ctl-fg: #0b1a2e;
    --ctl-fg-muted: #55677c; /* 4.75:1 on this sheet bg-hover */
    --ctl-fg-subtle: #55677c; /* 4.70:1 on this sheet bg-hover, the darkest light surface it lands on */
    --ctl-fg-faint: #9fb0c2;
    --ctl-border: #dbe2ea;
    --ctl-border-strong: #d3dde8;
    --ctl-accent: var(--mdt-blue-500);
    --ctl-accent-hover: var(--mdt-blue-600);
    --ctl-accent-press: var(--mdt-blue-700);
    --ctl-accent-fg: #ffffff; /* §4.5: white-on-blue passes 4.55:1 */
    /* accent-text decoupled from the fill (same pattern as the green styles):
       blue-500 reads 4.05:1 on the tinted page bg — blue-600 clears 4.5 on
       every Refined Light surface (Phase F certification). */
    --ctl-accent-text: var(--mdt-blue-600);
    --ctl-accent-rgb: 47, 111, 237;
    --ctl-link: var(--mdt-blue-600);
    --ctl-link-rgb: 34, 88, 201;
    --ctl-link-hover: var(--mdt-blue-700);
    --ctl-link-hover-rgb: 27, 70, 160;
    --ctl-fg-rgb: 11, 26, 46;
    --ctl-bg-rgb: 238, 242, 247;
    --ctl-bg-alt-rgb: 255, 255, 255;
    --ctl-fg-muted-rgb: 90, 107, 128;
    /* --ctl-radius / --ctl-radius-lg used to be restated here (3px/6px) when
       radius was a style differentiator. The 2026-07-31 portal-first pivot
       made geometry style-invariant, so this sheet inherits the :root pair
       (8px/12px) like every other — one statement of the rule. */
    /* --ctl-card-border / --ctl-card-shadow used to be restated here, because
       Refined was the one preset with a real card radius and so the one preset
       that did not want the square-card compensation. Every preset has a real
       card radius now and the soft pair IS the :root default, so this sheet's
       copy was a verbatim duplicate and is gone — one statement of the rule.
       Both still resolve per-sheet: they are declared as var(--ctl-border) /
       var(--ctl-shadow), which cascade to Refined's own values here. */
    /* ── Status hues, Refined Blue ───────────────────────────────────────────
       Ryan's constraint outranks the mock here: blue IS this preset's identity,
       so its statuses stay in the blue family instead of adopting the green
       styles' olive/slate. What the separation rule still demands is that they
       be tellable apart from (a) the priority ramp — satisfied, that ramp is
       grey/amber/orange/red in every sheet — and (b) a plain link, which is
       --ctl-link #2258c9. Hence ASSIGNED is a much deeper steel blue and
       IN_PROGRESS moves to indigo: three distinct blues, none of which is the
       link's blue. Ratios on #eef2f7 (this sheet's page ground, the darkest
       light surface in the system): 5.18 / 7.09 / 6.28:1. */
    --ctl-status-new: #0d6f80;         /* teal   */
    --ctl-status-assigned: #00538f;    /* steel  */
    --ctl-status-in-progress: #4a4fa8; /* indigo */
    /* Nav rail — this style's own dark-scheme elevated surface, and its
       dark-scheme ink cooled to match. Refined's rail is navy in both schemes;
       that is the whole point of theming it rather than sharing one grey. */
    --ctl-nav-bg: #1a2c42;
    --ctl-nav-fg: #e6eef7;
    --ctl-nav-fg-muted: #aec4d9;
    --ctl-nav-fg-subtle: #9db4cb;
    --ctl-nav-fg-rgb: 230, 238, 247;
    /* blue-500 is 2.88:1 on this style's own navy rail — under the 3:1 that
       binds a non-text indicator. The active-page bar steps up one rung; the
       accent FILL everywhere else is untouched. */
    --ctl-nav-accent: var(--mdt-blue-400);
    /* Chart ramp leads with THIS preset's accent — blue, not green. */
    --ctl-chart-1: var(--mdt-blue-500);
    --ctl-chart-2: #9e5100;
    --ctl-chart-3: #0d6f80;
    --ctl-chart-4: var(--mdt-red-500);
    --ctl-chart-5: #4a4fa8;
    --ctl-chart-6: #7d6200;
}

/* ═══ Sheet 4/6 — Refined Blue Dark ═══ */
html[data-style="refined"][data-bs-theme="dark"] {
    /* Re-grounded like the other two dark sheets, but gently: Refined's cool
       navy already separated its surfaces better than the neutral styles did,
       so this is a lift (bg #0a1626 → #0e1c2f, elevated #0f2138 → #1a2c42), not
       a rebuild. The navy tint is preserved in every value — blue leads red in
       all of them — because "cool, navy-tinted neutrals" is this preset's
       documented temperature. Ratios vs #1a2c42, the lightest surface here. */
    --ctl-bg: #0e1c2f;
    --ctl-bg-alt: #12233a;
    --ctl-bg-elevated: #1a2c42;
    --ctl-bg-inset: #0c1a2b;
    --ctl-bg-inverse: #eef2f7;
    --ctl-bg-hover: #22374f;
    --ctl-bg-hover-rgb: 34, 55, 79;
    --ctl-fg: #e6eef7;        /* 12.10:1 */
    --ctl-fg-muted: #9fb8d2;  /* 6.92:1  */
    --ctl-fg-subtle: #8ba6c4; /* 5.63:1  */
    --ctl-fg-faint: #61809f;  /* decorative only */
    --ctl-border: #26405c;
    --ctl-border-strong: #32506f;
    --ctl-accent-hover: var(--mdt-blue-400);
    --ctl-accent-press: var(--mdt-blue-300);
    /* blue-400 was 4.15:1 on the lifted #1a2c42 — under the §4.5 rule. The
       interactive blue steps up one rung to blue-300 (5.22:1) and hover to
       blue-200. --ctl-accent (the FILL) is untouched at #2f6fed, so the accent
       still reads identically in both modes. */
    --ctl-accent-text: var(--mdt-blue-300);
    --ctl-link: var(--mdt-blue-300);
    --ctl-link-rgb: 120, 165, 255;
    --ctl-link-hover: var(--mdt-blue-200);
    --ctl-link-hover-rgb: 147, 184, 255;
    --ctl-fg-rgb: 230, 238, 247;
    --ctl-bg-rgb: 14, 28, 47;
    --ctl-bg-alt-rgb: 18, 35, 58;
    --ctl-fg-muted-rgb: 159, 184, 210;
    /* Washes re-tinted onto the navy ground (the warm-neutral ones inherited
       from the Operator dark sheet looked like dirt on this surface). */
    --ctl-danger-bg: #3a1a20;
    --ctl-warning-bg: #33290f;
    --ctl-success-bg: #1e2c1a;
    --ctl-info-bg: #1c2d40;
    --ctl-info-bg-rgb: 28, 45, 64;
    --ctl-info: #9fb8d2;
    --ctl-info-text: #9fb8d2;
    --ctl-info-rgb: 159, 184, 210;
    /* Status stays blue-family in dark too — teal / desaturated steel / violet.
       ASSIGNED is deliberately DESATURATED rather than a brighter blue: the
       obvious dark-mode choice (#6aa9ff) landed a hair from --ctl-link
       (#6a9bff), which is precisely the status-reads-as-a-link confusion this
       PR exists to remove. Ratios on #1a2c42: 6.82 / 6.51 / 5.49:1. */
    --ctl-status-new: #35c4d9;
    /* Refined dark re-checks danger + ASSIGNED against its own bg-hover
       (#22374f), which is lighter than bg-elevated and is the real worst case
       for a tinted row: the inherited #e8706b managed only 4.03:1 there.
       ASSIGNED stays a full step off --ctl-link (blue-300) while clearing 4.5. */
    --ctl-danger-text: #f08882; /* 4.95:1 on #22374f */
    --ctl-status-assigned: #6ea9e8; /* 4.92:1 */
    --ctl-status-in-progress: #a394f7;
    --ctl-chart-1: var(--mdt-blue-300);
    --ctl-chart-2: #fb923c;
    --ctl-chart-3: #35c4d9;
    --ctl-chart-4: #e8706b;
    --ctl-chart-5: #a394f7;
    --ctl-chart-6: #e0b64a;
    /* Rail lifts above this sheet's navy ground (see the :root block). */
    --ctl-nav-bg: #1f3247;
}

/* ═══ Sheet 5/6 — Industrial Light ═══ */
html[data-style="industrial"] {
    --ctl-bg: var(--mdt-white);
    --ctl-bg-alt: var(--mdt-grey-100);
    --ctl-bg-elevated: var(--mdt-white);
    --ctl-bg-inset: var(--mdt-grey-050);
    --ctl-bg-inverse: #111111;
    --ctl-bg-hover: #eeeeee;
    --ctl-bg-hover-rgb: 238, 238, 238;
    /* The one sheet where --ctl-bg IS --ctl-bg-elevated (Industrial Light
       grounds on pure white on purpose), so a settled row sinking to the ground
       would be no step at all. It takes the alt surface instead — the same
       quiet grey this sheet already uses for table headers and status bars.
       Industrial DARK inherits this alias and resolves it to its own #1a1a1a,
       which sits below its elevated #212121, so the step still recedes. */
    --ctl-row-settled-bg: var(--ctl-bg-alt);
    --ctl-fg: #111111;
    --ctl-fg-muted: var(--mdt-grey-600);
    --ctl-fg-subtle: #686868; /* 4.70:1 on this sheet bg-hover, the darkest light surface it lands on */
    --ctl-fg-faint: var(--mdt-grey-400);
    --ctl-border: var(--mdt-grey-200);
    --ctl-border-strong: var(--mdt-grey-300);
    --ctl-fg-rgb: 17, 17, 17;
    --ctl-bg-rgb: 255, 255, 255;
    --ctl-bg-alt-rgb: 245, 245, 245;
    /* Nav rail — this style's own dark-scheme elevated surface and ink, and
       PURE NEUTRAL like everything else Industrial owns (R=G=B), which is what
       tells an Industrial rail apart from an Operator one at the same
       lightness. */
    --ctl-nav-bg: #212121;
    --ctl-nav-fg: #e9e9e9;
    --ctl-nav-fg-muted: #b4b4b4;
    --ctl-nav-fg-subtle: #a2a2a2;
    --ctl-nav-fg-rgb: 233, 233, 233;
}

/* ═══ Sheet 6/6 — Industrial Dark ═══ */
html[data-style="industrial"][data-bs-theme="dark"] {
    /* Re-grounded like the other two, but in Industrial's own temperature.
       This sheet ground at absolute #000000 with pure-white text — the most
       extreme case of the problem: zero elevation headroom (a card could only
       ever be lighter, never read as lifted) and shadows that were literally
       invisible because nothing is darker than black.

       Industrial's documented differentiator is PURE NEUTRAL neutrals
       (UI_STANDARDS §3), so unlike Operator every value here has R=G=B. That,
       plus the sharp radius and the Oswald caps, is what carries the editorial
       identity — not the last twenty units of black. Ratios vs #212121, the
       lightest surface in this sheet. */
    --ctl-bg: #141414;
    --ctl-bg-alt: #1a1a1a;
    --ctl-bg-elevated: #212121;
    --ctl-bg-inset: #101010;
    --ctl-bg-inverse: var(--mdt-grey-100);
    --ctl-bg-hover: #292929;
    --ctl-bg-hover-rgb: 41, 41, 41;
    --ctl-fg: #e9e9e9;        /* 13.26:1 */
    --ctl-fg-muted: #a3a3a3;  /* 6.38:1  */
    --ctl-fg-subtle: #949494; /* 4.80:1 on a hovered row */
    --ctl-fg-faint: #6b6b6b;  /* decorative only */
    --ctl-border: #333333;
    --ctl-border-strong: #444444;
    /* accent-text no longer forks to green-500 here: on a lifted ground the
       green-400 the other dark sheets use reads better (7.42:1 vs 5.56:1) and
       one fewer divergence means the accent reads identically across modes. */
    --ctl-fg-rgb: 233, 233, 233;
    --ctl-bg-rgb: 20, 20, 20;
    --ctl-bg-alt-rgb: 26, 26, 26;
    /* Neutral washes — the Operator dark sheet's warm ones carry a green cast
       that is exactly what this preset must not have. */
    --ctl-danger-bg: #3a1614;
    --ctl-warning-bg: #33270a;
    --ctl-success-bg: #1f2a12;
    --ctl-info-bg: #242424;
    --ctl-info-bg-rgb: 36, 36, 36;
    --ctl-info: #a3a3a3;
    --ctl-info-text: #a3a3a3;
    --ctl-info-rgb: 163, 163, 163;
    /* Rail lifts above this sheet's ground (see the :root block). */
    --ctl-nav-bg: #2b2b2b;
    --ctl-fg-muted-rgb: 163, 163, 163;
}

/* ═══ Density — compact (§5). Comfortable defaults live in :root. ═══ */
html[data-density="compact"] {
    --ctl-fs-body: 13.5px;
    --ctl-control-pad-y: 0.25rem;   /* ≈30px control — today's ultra-compact */
    --ctl-control-pad-x: 0.5rem;
    --ctl-fs-label: 12px;
    --ctl-table-cell-pad-y: 4px;
    --ctl-section-pad: var(--ctl-sp-3);
}

/* ═══ Layer 3 — Bootstrap 5.3 bridge ═══
   One block, resolved per-element against the cascaded --ctl values, so all
   six sheets flow through automatically. The html[data-bs-theme="dark"]
   selector is included to out-rank Bootstrap's own [data-bs-theme=dark]
   variable block regardless of stylesheet order. */
:root,
html[data-bs-theme="dark"] {
    --bs-body-bg: var(--ctl-bg);
    --bs-body-bg-rgb: var(--ctl-bg-rgb);
    --bs-body-color: var(--ctl-fg);
    --bs-body-color-rgb: var(--ctl-fg-rgb);
    --bs-body-font-family: var(--ctl-font-body);
    /* The single highest-leverage line for the mono face (#2013): Bootstrap's
       reboot sets `code, kbd, pre, samp { font-family: var(--bs-font-monospace) }`
       and compiles `.font-monospace` from the same variable, so unbridged it was
       shipping SFMono-Regular/Menlo/Monaco/Consolas/"Liberation Mono"/"Courier New"
       to every <code> in the app AND to every .font-monospace call site. */
    --bs-font-monospace: var(--ctl-font-mono);
    --bs-body-font-size: var(--ctl-fs-body);
    --bs-emphasis-color: var(--ctl-fg);
    --bs-emphasis-color-rgb: var(--ctl-fg-rgb);
    --bs-heading-color: var(--ctl-fg);
    --bs-secondary-color: var(--ctl-fg-muted);
    --bs-secondary-bg: var(--ctl-bg-alt);
    --bs-tertiary-color: var(--ctl-fg-subtle);
    --bs-tertiary-bg: var(--ctl-bg-alt);
    --bs-border-color: var(--ctl-border);
    --bs-border-radius: var(--ctl-radius);
    --bs-border-radius-sm: var(--ctl-radius);
    --bs-border-radius-lg: var(--ctl-radius-lg);
    --bs-border-radius-xl: var(--ctl-radius-lg);
    --bs-link-color: var(--ctl-link);
    --bs-link-color-rgb: var(--ctl-link-rgb);
    --bs-link-hover-color: var(--ctl-link-hover);
    --bs-link-hover-color-rgb: var(--ctl-link-hover-rgb);
    --bs-code-color: var(--ctl-accent-text);
    --bs-focus-ring-color: rgba(var(--ctl-accent-rgb), 0.35);
    /* Theme colors + utility triplets (.bg-*, .text-*, rgba washes) */
    --bs-primary: var(--ctl-accent);
    --bs-primary-rgb: var(--ctl-accent-rgb);
    /* secondary — the third head of the same hydra as dark (#1878) and light
       (#1902), and the only one that was firing on real screens. This triplet
       was bridged to --ctl-fg-muted-rgb, an INK token, and Bootstrap's
       `.bg-secondary` composes it as a GROUND
       (`background-color: rgba(var(--bs-secondary-rgb), …)`). So all 51
       `.bg-secondary` badges plus the four `.text-bg-secondary` sites painted
       the sheet's muted TEXT colour as a chip, under Bootstrap's compiled
       `--bs-badge-color:#fff` / `.text-bg-secondary{color:#fff}`. In the light
       sheets muted ink is #5c5c5c-ish and white on it clears AA (5.5–6.7:1),
       which is exactly why it survived: the failure was dark-mode-only, where
       muted ink is a PALE grey (#a5a49b / #9fb8d2 / #a3a3a3) and white on it
       measured 2.50 / 2.05 / 2.52:1. It read as "that badge looks washed out",
       not as a theming bug.

       The family now means what those 55 call sites actually want: a quiet
       neutral CHIP. Its ground is --ctl-bg-hover, the same surface
       --bs-secondary-bg-subtle already uses (#1879) — the one neutral that
       steps away from the card in both directions, so the chip is visible on
       an elevated surface in every sheet — and its ink is the sheet's own
       --ctl-fg, certified against that surface by §4.2.1. Ground and ink ship
       together in the rule below the bridge, the way #1901's .ctl-bg-inverse
       does, because a bridged ground under Bootstrap's literal white ink is
       how this broke in the first place. Every call site is untouched; the
       badges go from a mid-grey slab to the app's quiet-chip idiom and from
       ~2.0–2.5:1 to 10.4–16.3:1.

       `.text-secondary` reads this same triplet as ink and is therefore now
       banned, exactly as `.text-light` is — the surface-as-ink half of the
       split. Bootstrap 5.3 ships the right utility for that intent already:
       `.text-body-secondary`, which reads --bs-secondary-color, bridged to
       --ctl-fg-muted three lines above. The four call sites moved onto it.

       Only the triplet is declared, as with dark and light: nothing in
       Bootstrap or in this file reads a bare var(--bs-secondary), and a
       leftover `--bs-secondary: <ink>` beside a surface triplet is precisely
       the kind of half-bridged family this whole block exists to retire. */
    --bs-secondary-rgb: var(--ctl-bg-hover-rgb);
    --bs-success: var(--ctl-success);
    --bs-success-rgb: var(--ctl-success-rgb);
    --bs-danger: var(--ctl-danger);
    --bs-danger-rgb: var(--ctl-danger-rgb);
    --bs-warning: var(--ctl-warning);
    --bs-warning-rgb: var(--ctl-warning-rgb);
    /* info — the fourth head of the same hydra as dark (#1878), light (#1902)
       and secondary (#1926), and the one the position guard those PRs built
       could not see. --bs-info-rgb was bridged to --ctl-info-rgb, which LOOKS
       like a solid (it is named after a status family, beside success/danger/
       warning) and IS, by value, the sheet's muted ink: #5c5c5c in the three
       light sheets, #a5a49b / #9fb8d2 / #a3a3a3 in the three dark ones — the
       same numbers as --ctl-fg-muted. So the app's 16 `.bg-info` badges painted
       muted TEXT colour as a chip under Bootstrap's compiled
       `--bs-badge-color:#fff`, clearing AA in light (6.69:1) and failing in all
       three dark sheets at 2.50 / 2.05 / 2.52:1 — to two decimals, secondary's
       numbers again. Dark-mode-only, so it read as "washed out".

       The family now means the INFORMATIONAL WASH it always was everywhere
       else: --ctl-info-bg is the ground .bg-info-subtle, .alert-info,
       .ctl-widget-rag--info and .api-method-post have used since the token
       system landed, and --ctl-info-text is the ink already certified against
       it (5.92–6.85:1 in the six sheets). So `.bg-info` and `.bg-info-subtle`
       converge, which is the honest outcome: "info" in this system has never
       been a solid, and the 16 call sites — INFO log levels, RUNNING workflow
       states, "Delegated", "Custom", "Informational" — are all informational
       chips. Ground and ink ship together in the rule below the bridge, the way
       #1926's .bg-secondary pair does, because a bridged ground under
       Bootstrap's literal white ink is exactly how this broke. Not one of the
       16 is edited.

       `.text-info` reads the same triplet as ink and so flips to the invisible
       half, exactly as `.text-secondary` did: its 7 call sites move to
       Bootstrap's own `.text-info-emphasis`, which reads
       --bs-info-text-emphasis → --ctl-info-text — the identical value
       `.text-info` resolves to today, so that migration is a rename with no
       visual change at all. `.border-info` follows (2 sites, both the KB
       AI-answer card) onto .border-primary-subtle, the accent hairline
       ticket-copilot.js already uses for "this came from the assistant".

       Only the triplet is declared, as with dark, light and secondary: nothing
       in Bootstrap or in this file reads a bare var(--bs-info), and a leftover
       `--bs-info: <ink>` beside a surface triplet is precisely the kind of
       half-bridged family this block exists to retire. */
    --bs-info-rgb: var(--ctl-info-bg-rgb);
    --bs-light-rgb: var(--ctl-bg-alt-rgb); /* .bg-light → bg-alt in every sheet */
    /* dark — the last family still on Bootstrap's compiled literal, and the one
       that cost the most. `.text-dark` reads THIS triplet (the utility is
       `color: rgba(var(--bs-dark-rgb), …)`), so all 61 call sites in the app
       resolved to Bootstrap's #212529 in every sheet. The common idiom is
       `.badge.bg-light.text-dark` — and .bg-light IS bridged, to --ctl-bg-alt —
       so in the three DARK sheets those badges painted near-black ink on a
       near-black ground and simply disappeared. It read as "a badge that isn't
       there" rather than as a theming bug, which is why it survived this long.
       The author's intent at every one of those sites is plainly "ink on the
       alt surface", i.e. --ctl-fg, so that is what the family now means.

       The same triplet also drives .bg-dark, which wants the opposite thing —
       which is exactly why the family was left alone until now. Resolved by
       making the intent explicit rather than by splitting the token: a
       deliberate dark slab is not "the dark colour", it is the INVERSE SURFACE,
       and that has its own token (--ctl-bg-inverse, layer 2) and its own
       utility below this block. The nine api/docs.php code blocks moved onto
       it, so nothing in the app asks .bg-dark for a ground any more, and
       CssTokenContractTest keeps it that way. Only the triplet is declared, as
       with light: Bootstrap's compiled utilities read --bs-*-rgb, and nothing
       in Bootstrap or in this file ever reads a bare var(--bs-dark). */
    --bs-dark-rgb: var(--ctl-fg-rgb);
    /* Subtle system (alerts, .text-bg-*, list-group variants) */
    --bs-success-bg-subtle: var(--ctl-success-bg);
    --bs-success-text-emphasis: var(--ctl-success-text);
    --bs-success-border-subtle: var(--ctl-success-bg);
    --bs-danger-bg-subtle: var(--ctl-danger-bg);
    --bs-danger-text-emphasis: var(--ctl-danger-text);
    --bs-danger-border-subtle: var(--ctl-danger-bg);
    --bs-warning-bg-subtle: var(--ctl-warning-bg);
    --bs-warning-text-emphasis: var(--ctl-warning-text);
    --bs-warning-border-subtle: var(--ctl-warning-bg);
    --bs-info-bg-subtle: var(--ctl-info-bg);
    --bs-info-text-emphasis: var(--ctl-info-text);
    --bs-info-border-subtle: var(--ctl-info-bg);
    /* The three NON-status subtle families. The block above bridged only the
       four status families, so every .bg-primary-subtle / .border-primary-subtle
       / .text-primary-emphasis, every .alert-light, and every
       .bg-secondary-subtle badge in the app kept Bootstrap's COMPILED defaults —
       stock blue #cfe2ff/#9ec5fe in light, #031633/#084298 in dark, the
       blue-grey slate #343a40 for alert-light in dark, #e2e3e5/#212529 for the
       secondary badges. Stock blue is not the accent in any of the six sheets
       (two of three presets are MDT green), and none of those literals move with
       the scheme, which is how the ticket Summarize panel — built entirely from
       three of these utilities — rendered as a foreign blue patch on every
       preset and a slate patch in dark. One bridge, every caller.

       primary: the accent's own soft wash, derived the way .composer-ai-seg
       derives its segment fill — color-mix INTO --ctl-bg-elevated rather than
       rgba() over whatever happens to be behind. Mixing toward the surface
       token yields an OPAQUE value that inverts on its own (pale on the three
       light sheets, dark on the three dark ones) from one declaration, so a
       primary-subtle wash reads identically on a card, a page ground or a
       hovered row — the same argument §6.3 makes for the bubble tint, and the
       reason there is no --ctl-accent-bg to alias (the *-bg washes exist only
       for the status families, and "success green" is not the accent in
       Refined Blue). Ink is the certified --ctl-accent-text (§4.5), never the
       raw accent.
       light: alerts in the "quiet notice" voice — bg-alt surface, plain border
       hairline, normal fg ink. All three are §4.2-certified pairs already.
       secondary: neutral chips and badges. Their ground is bg-HOVER, not
       bg-alt: bg-alt is #ffffff in three of the six sheets and these badges sit
       on cards, so an alt-surface chip would be an invisible chip. bg-hover is
       the one neutral that steps AWAY from the surface in both directions —
       darker on the light sheets, lighter on the dark ones — and §4.2.1 already
       certifies the text ramp against it as a text surface. */
    --bs-primary-bg-subtle: color-mix(in srgb, var(--ctl-accent) 12%, var(--ctl-bg-elevated));
    --bs-primary-text-emphasis: var(--ctl-accent-text);
    --bs-primary-border-subtle: color-mix(in srgb, var(--ctl-accent) 45%, var(--ctl-bg-elevated));
    --bs-light-bg-subtle: var(--ctl-bg-alt);
    --bs-light-text-emphasis: var(--ctl-fg);
    --bs-light-border-subtle: var(--ctl-border);
    --bs-secondary-bg-subtle: var(--ctl-bg-hover);
    --bs-secondary-text-emphasis: var(--ctl-fg);
    --bs-secondary-border-subtle: var(--ctl-border);
}

/* The inverse surface, as a utility (UI_STANDARDS §4.1 layer 2's
   --ctl-bg-inverse, which had defined itself in all six sheets since the token
   system landed and never had a single consumer). This is the sanctioned way
   to say "a slab that deliberately opposes the page" — the API-docs code
   blocks, and anything after them that wants the same voice.

   It sets BOTH halves, like Bootstrap's own .text-bg-* pairs do, because the
   ground alone is a trap: paint --ctl-bg-inverse and inherit the sheet's ink
   and you have written near-black on near-black. Ground and ink ship together
   or not at all. No !important — the Bootstrap utility it replaces needed one
   to beat component CSS; this sits on plain <pre>, and the rule that would
   otherwise reach the nested <code> (Bootstrap's `pre code{color:inherit}`)
   walks the pair down for us. */
.ctl-bg-inverse {
    background-color: var(--ctl-bg-inverse);
    color: var(--ctl-fg-inverse);
}

/* The ink half of the secondary chip (#1926). Re-pointing --bs-secondary-rgb
   at a surface is only half a fix: Bootstrap hard-codes the OTHER half as
   compiled literals — `.badge{--bs-badge-color:#fff}` and
   `.text-bg-secondary{color:#fff!important}` — so a bridged ground under an
   un-bridged white would have swapped an unreadable dark-mode chip for an
   unreadable light-mode one. Ground and ink ship together, the same rule
   .ctl-bg-inverse above follows.

   Two declarations rather than one, and deliberately narrow:
     · .bg-secondary overrides the BADGE VARIABLE, not `color`. A bare
       .bg-secondary element already inherits the sheet's --ctl-fg, which is
       the certified ink for this ground, so `color` there would be noise —
       and worse, a `color: … !important` on .bg-secondary would out-rank a
       deliberate .text-white / .text-dark sitting on the same element (equal
       specificity, and style.css loads after Bootstrap).
     · .text-bg-secondary is the pair utility, and its white is `!important`.
       Beating an !important needs an !important; this one is Bootstrap's
       fault, not ours.
   Both resolve to --ctl-fg, certified on --ctl-bg-hover in every sheet by
   §4.2.1 and measured on all six by CssTokenContractTest. */
.bg-secondary {
    --bs-badge-color: var(--ctl-fg);
}
.text-bg-secondary {
    color: var(--ctl-fg) !important;
}

/* The ink half of the info chip (#2003), same shape and same reasoning as the
   secondary pair above: a ground bridged onto a wash, under Bootstrap's
   compiled `--bs-badge-color:#fff`, would have swapped an unreadable dark-mode
   badge for an unreadable light-mode one.

   The ink is --ctl-info-text rather than --ctl-fg because the informational
   voice is the point — this family is a quiet tinted chip, not the neutral one
   — and --ctl-info-text is the ink §4.3 already certifies against
   --ctl-info-bg (5.92–6.85:1 across the six sheets). Eight of the sixteen call
   sites carry an explicit `.text-dark`; that utility sets `color` directly and
   out-ranks the badge variable, resolving to --ctl-fg on this ground at
   11.97–16.72:1, so those sites stay legible without being edited either.

   .text-bg-info gets the `!important` treatment for the same reason
   .text-bg-secondary does — Bootstrap compiles `color:#000!important` into it.
   No call site uses it today; arming it now means the trap fires on the first
   author who reaches for it rather than on a user's screen. */
.bg-info {
    --bs-badge-color: var(--ctl-info-text);
}
.text-bg-info {
    color: var(--ctl-info-text) !important;
}

/* A progress bar's fill is the one `.bg-*` position in the app that is neither
   a badge nor a chip — it is a data graphic, and Bootstrap composes its ink as
   a compiled literal on the TRACK (`.progress{--bs-progress-bar-color:#fff}`),
   which no bridge in the block above can reach. The reports page's category
   chart writes its count INSIDE the fill, so the pair has to be certified the
   way every other ground+ink pair here is: --ctl-accent-fg is exactly that ink
   (§4.5 — black on the green presets, white on Refined's blue), and it is
   already what every other accent-grounded surface in this file reads. */
.progress-bar.bg-primary {
    --bs-progress-bar-color: var(--ctl-accent-fg);
}

/* Bootstrap components whose compiled vars are literals (not global-var
   references) — re-pointed at semantic tokens once, valid for all six sheets.
   These replace the deleted body.theme-dark per-component patches. */
.dropdown-menu {
    --bs-dropdown-bg: var(--ctl-bg-elevated);
    --bs-dropdown-color: var(--ctl-fg);
    --bs-dropdown-border-color: var(--ctl-border);
    --bs-dropdown-link-color: var(--ctl-fg);
    --bs-dropdown-link-hover-color: var(--ctl-fg);
    --bs-dropdown-link-hover-bg: var(--ctl-bg-hover);
    --bs-dropdown-link-active-color: var(--ctl-accent-fg);
    --bs-dropdown-link-active-bg: var(--ctl-accent);
    --bs-dropdown-divider-bg: var(--ctl-border);
    --bs-dropdown-header-color: var(--ctl-fg-subtle);
}

.modal {
    --bs-modal-bg: var(--ctl-bg-elevated);
    --bs-modal-color: var(--ctl-fg);
    --bs-modal-border-color: var(--ctl-border);
    --bs-modal-header-border-color: var(--ctl-border);
    --bs-modal-footer-border-color: var(--ctl-border);
}

.list-group {
    --bs-list-group-bg: var(--ctl-bg-elevated);
    --bs-list-group-color: var(--ctl-fg);
    --bs-list-group-border-color: var(--ctl-border);
    --bs-list-group-action-color: var(--ctl-fg);
    --bs-list-group-action-hover-color: var(--ctl-fg);
    --bs-list-group-action-hover-bg: var(--ctl-bg-hover);
    --bs-list-group-action-active-color: var(--ctl-fg);
    --bs-list-group-action-active-bg: var(--ctl-bg-hover);
    --bs-list-group-active-bg: var(--ctl-accent);
    --bs-list-group-active-border-color: var(--ctl-accent);
    --bs-list-group-active-color: var(--ctl-accent-fg);
    --bs-list-group-disabled-color: var(--ctl-fg-faint);
    --bs-list-group-disabled-bg: var(--ctl-bg-elevated);
}

.pagination {
    --bs-pagination-bg: var(--ctl-bg-elevated);
    --bs-pagination-color: var(--ctl-fg);
    --bs-pagination-border-color: var(--ctl-border);
    --bs-pagination-hover-bg: var(--ctl-bg-hover);
    --bs-pagination-hover-color: var(--ctl-fg);
    --bs-pagination-hover-border-color: var(--ctl-border);
    --bs-pagination-focus-bg: var(--ctl-bg-hover);
    --bs-pagination-focus-color: var(--ctl-fg);
    --bs-pagination-focus-box-shadow: var(--ctl-focus-ring);
    --bs-pagination-active-bg: var(--ctl-accent);
    --bs-pagination-active-border-color: var(--ctl-accent);
    --bs-pagination-active-color: var(--ctl-accent-fg);
    --bs-pagination-disabled-bg: var(--ctl-bg-elevated);
    --bs-pagination-disabled-color: var(--ctl-fg-faint);
    --bs-pagination-disabled-border-color: var(--ctl-border);
}

.table {
    /* Transparent so card surfaces (and row-hover set on <tr>) show through;
       sticky headers and pinned columns carry their own opaque backgrounds. */
    --bs-table-bg: transparent;
    --bs-table-color: var(--ctl-fg);
    --bs-table-border-color: var(--ctl-border);
    --bs-table-striped-bg: var(--ctl-bg-alt);
    --bs-table-striped-color: var(--ctl-fg);
    --bs-table-hover-bg: var(--ctl-bg-hover);
    --bs-table-hover-color: var(--ctl-fg);
}

.table-light {
    --bs-table-bg: var(--ctl-bg-alt);
    --bs-table-color: var(--ctl-fg);
    --bs-table-border-color: var(--ctl-border);
}

.nav-tabs {
    --bs-nav-link-color: var(--ctl-fg-muted);
    --bs-nav-link-hover-color: var(--ctl-fg);
    --bs-nav-tabs-border-color: var(--ctl-border);
    --bs-nav-tabs-link-active-color: var(--ctl-accent-text);
    --bs-nav-tabs-link-active-bg: var(--ctl-bg-elevated);
    --bs-nav-tabs-link-active-border-color: var(--ctl-border) var(--ctl-border) var(--ctl-bg-elevated);
    --bs-nav-tabs-link-hover-border-color: var(--ctl-bg-hover) var(--ctl-bg-hover) var(--ctl-border);
}

/* Primary button — accent fill with contrast-verified face (§4.5: black on
   brand green 7.32:1 in Industrial/Operator; white on blue 4.55:1 in Refined). */
.btn-primary {
    --bs-btn-color: var(--ctl-accent-fg);
    --bs-btn-bg: var(--ctl-accent);
    --bs-btn-border-color: var(--ctl-accent);
    --bs-btn-hover-color: var(--ctl-accent-fg);
    --bs-btn-hover-bg: var(--ctl-accent-hover);
    --bs-btn-hover-border-color: var(--ctl-accent-hover);
    --bs-btn-active-color: var(--ctl-accent-fg);
    --bs-btn-active-bg: var(--ctl-accent-press);
    --bs-btn-active-border-color: var(--ctl-accent-press);
    --bs-btn-disabled-color: var(--ctl-accent-fg);
    --bs-btn-disabled-bg: var(--ctl-accent);
    --bs-btn-disabled-border-color: var(--ctl-accent);
    --bs-btn-focus-shadow-rgb: var(--ctl-accent-rgb);
}

.btn-outline-primary {
    --bs-btn-color: var(--ctl-accent-text);
    --bs-btn-border-color: var(--ctl-accent);
    --bs-btn-hover-color: var(--ctl-accent-fg);
    --bs-btn-hover-bg: var(--ctl-accent);
    --bs-btn-hover-border-color: var(--ctl-accent);
    --bs-btn-active-color: var(--ctl-accent-fg);
    --bs-btn-active-bg: var(--ctl-accent-press);
    --bs-btn-active-border-color: var(--ctl-accent-press);
    --bs-btn-disabled-color: var(--ctl-fg-faint);
    --bs-btn-disabled-border-color: var(--ctl-border-strong);
    --bs-btn-focus-shadow-rgb: var(--ctl-accent-rgb);
}

/* Secondary per §6: 1px border-strong outline, transparent fill */
.btn-outline-secondary {
    --bs-btn-color: var(--ctl-fg-muted);
    --bs-btn-border-color: var(--ctl-border-strong);
    --bs-btn-hover-color: var(--ctl-fg);
    --bs-btn-hover-bg: var(--ctl-bg-hover);
    --bs-btn-hover-border-color: var(--ctl-border-strong);
    --bs-btn-active-color: var(--ctl-fg);
    --bs-btn-active-bg: var(--ctl-bg-hover);
    --bs-btn-active-border-color: var(--ctl-border-strong);
    --bs-btn-disabled-color: var(--ctl-fg-faint);
    --bs-btn-disabled-border-color: var(--ctl-border);
}

.btn-outline-danger {
    --bs-btn-color: var(--ctl-danger-text);
    --bs-btn-border-color: var(--ctl-danger);
    --bs-btn-hover-color: #ffffff;
    --bs-btn-hover-bg: var(--ctl-danger);
    --bs-btn-hover-border-color: var(--ctl-danger);
    --bs-btn-active-color: #ffffff;
    --bs-btn-active-bg: var(--ctl-danger);
    --bs-btn-active-border-color: var(--ctl-danger);
    --bs-btn-disabled-color: var(--ctl-fg-faint);
    --bs-btn-disabled-border-color: var(--ctl-border-strong);
    --bs-btn-focus-shadow-rgb: var(--ctl-danger-rgb);
}

.btn-danger {
    --bs-btn-bg: var(--ctl-danger);
    --bs-btn-border-color: var(--ctl-danger);
    --bs-btn-hover-bg: color-mix(in srgb, var(--ctl-danger) 85%, #000);
    --bs-btn-hover-border-color: color-mix(in srgb, var(--ctl-danger) 85%, #000);
    --bs-btn-active-bg: color-mix(in srgb, var(--ctl-danger) 75%, #000);
    --bs-btn-active-border-color: color-mix(in srgb, var(--ctl-danger) 75%, #000);
    --bs-btn-disabled-bg: var(--ctl-danger);
    --bs-btn-disabled-border-color: var(--ctl-danger);
    --bs-btn-focus-shadow-rgb: var(--ctl-danger-rgb);
}

.btn-warning {
    --bs-btn-color: #000000;
    --bs-btn-bg: var(--ctl-warning);
    --bs-btn-border-color: var(--ctl-warning);
    --bs-btn-hover-color: #000000;
    --bs-btn-hover-bg: color-mix(in srgb, var(--ctl-warning) 85%, #000);
    --bs-btn-hover-border-color: color-mix(in srgb, var(--ctl-warning) 85%, #000);
    --bs-btn-active-color: #000000;
    --bs-btn-active-bg: color-mix(in srgb, var(--ctl-warning) 75%, #000);
    --bs-btn-active-border-color: color-mix(in srgb, var(--ctl-warning) 75%, #000);
    --bs-btn-disabled-color: #000000;
    --bs-btn-disabled-bg: var(--ctl-warning);
    --bs-btn-disabled-border-color: var(--ctl-warning);
    --bs-btn-focus-shadow-rgb: var(--ctl-warning-rgb);
}

/* ═══ Typography base (§2) ═══ */
/* Display utilities — Oswald caps. Page headers adopt these in Phase B;
   h1-h6 are deliberately NOT restyled globally here. */
.ctl-display-lg,
.ctl-display-md,
.ctl-display-sm {
    font-family: var(--ctl-font-display);
    font-weight: 700;
    line-height: var(--ctl-lh-tight);
    text-transform: uppercase;
    letter-spacing: -0.025em;
}
.ctl-display-lg { font-size: var(--ctl-fs-display-lg); }
.ctl-display-md { font-size: var(--ctl-fs-display-md); }
.ctl-display-sm { font-size: var(--ctl-fs-display-sm); }

/* Caption scale — system-stack 700 uppercase labels (the face moved off Lato
   with the 2026-07-31 revision; UI_STANDARDS §2.1) */
.ctl-caption {
    font-family: var(--ctl-font-body);
    font-size: var(--ctl-fs-caption);
    font-weight: 700;
    line-height: var(--ctl-lh-snug);
    text-transform: uppercase;
    letter-spacing: 0.08em;
}

/* Content width clamps (UI_STANDARDS §8.1). Data surfaces (lists,
   dashboards, canvases) run full width; settings/form/reading pages clamp
   so large monitors don't stretch them into unreadable sprawl. */
.ctl-page-clamp {
    max-width: var(--ctl-content-max, 68rem);
    /* width:100% for the same reason as the -wide variant below: in a
       flex-column parent (.content-wrapper detail pages), margin-inline:auto
       suppresses stretching and the clamped header wrapper collapses to its
       content width, breaking header/body alignment. Harmless in block flow. */
    width: 100%;
    margin-inline: auto;
}
.ctl-page-clamp-narrow {
    max-width: var(--ctl-content-max-narrow, 46rem);
    width: 100%;
    margin-inline: auto;
}
/* Wide variant — dense surfaces (the trash list, /reports, the analytics and
   wide-config screens) that carry more data than a single-column reading pane,
   but still clamp so ultrawide monitors don't stretch them into sprawl.
   NOT the agent ticket view: that page was the original example here and moved
   to the medium clamp above once its main column became a conversation — a
   thread across 100rem is exactly the sprawl this token exists to prevent. */
.ctl-page-clamp-wide {
    max-width: var(--ctl-content-max-wide, 100rem);
    /* width:100% so the clamp fills to its max in a flex-column parent
       (.content-wrapper). Without it, margin-inline:auto shrinks a flex child
       to its content — collapsing the narrow header wrapper while the wider
       body stayed put, so header + body no longer aligned (live-verified). */
    width: 100%;
    margin-inline: auto;
}

/* ═══ Focus visibility (§4.4 / §9) — always visible on :focus-visible ═══ */
:focus-visible {
    outline: 3px solid rgba(var(--ctl-accent-rgb), 0.35);
    outline-offset: 1px;
}

/* Controls that already render the ring as a box-shadow (accent border +
   --ctl-focus-ring) suppress the outline — replacement present, not removed. */
.form-control:focus-visible,
.form-select:focus-visible,
.ai-chat-input:focus-visible {
    outline: none;
}

/* ═══ Reduced motion (§4.4) — non-essential motion collapses to 0 ═══ */
@media (prefers-reduced-motion: reduce) {
    *,
    *::before,
    *::after {
        animation-duration: 0.01ms !important;
        animation-iteration-count: 1 !important;
        transition-duration: 0.01ms !important;
    }
}

/* Lock viewport — all scrolling handled by inner flex containers */
html, body {
    height: 100%;
    overflow: hidden;
    margin: 0;
}

body {
    background-color: var(--ctl-bg);
    color: var(--ctl-fg);
    font-family: var(--ctl-font-body);
    font-size: var(--ctl-fs-body);
}

/* Email preview frame — always-light iframe inside dark cards */
.email-preview-frame {
    background: #ffffff;
    border: 1px solid var(--ctl-border);
    border-radius: 6px;
    overflow: hidden;
    max-height: 500px;
    overflow-y: auto;
}
/* shortcut: scheme-keyed override — the frame content is pinned light (email
   renders on white), so the dark schemes need a stronger seam than tokens can
   express; revisit if the preview ever themes. */
html[data-bs-theme="dark"] .email-preview-frame {
    border-color: var(--ctl-border-strong);
    box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.05);
}

/* "Logged in as" (Login As) banner — solid danger fill, white text (≥5.9:1 in
   every sheet). The banner sits in normal flow, so in-flow chrome shifts down
   under it on its own — but .sidebar is position:fixed at top:0 and would be
   overlapped. Fix: a deterministic banner height on body.has-loginas-banner
   (main.php sets the class) that the fixed sidebar and the 100%-height
   .wrapper subtract, so the logo drops below the banner and the page bottom
   isn't pushed off-viewport. */
body.has-loginas-banner {
    --loginas-banner-h: 1.8rem;
}
.impersonation-banner {
    background: var(--ctl-danger);
    color: #fff;
    text-align: center;
    height: var(--loginas-banner-h);
    line-height: var(--loginas-banner-h);
    padding: 0 1rem;
    font-size: 0.8rem;
    overflow: hidden;
    position: sticky;
    top: 0;
    z-index: 9999; /* z literal kept: security banner must top everything, incl. Bootstrap modals and the --ctl-z scale */
}
body.has-loginas-banner .sidebar {
    top: var(--loginas-banner-h);
    height: calc(100vh - var(--loginas-banner-h));
}
body.has-loginas-banner .wrapper {
    height: calc(100% - var(--loginas-banner-h));
}
.impersonation-banner form {
    display: inline;
}
.impersonation-banner a,
.impersonation-banner .btn-link {
    color: #fff;
    text-decoration: underline;
    font-size: inherit;
    line-height: inherit;
    padding: 0;
    border: 0;
    vertical-align: baseline;
}

/* ═══ Login — split "sign-in + product showcase" card (Direction 1 · MDT
   Industrial). The login page is ANONYMOUS, so bare.php emits no theme
   attributes and the --ctl-* semantic tokens stay pinned to Operator Light.
   We therefore theme this page with prefers-color-scheme (NOT data-bs-theme)
   and drive concrete colors from the always-defined --mdt-* brand constants.
   Local --lg-* tokens hold the light defaults; the dark media query overrides
   them. In dark, the card/form surface becomes the app nav-bar grey
   (--ctl-bg-elevated resolves to --mdt-grey-900 in the shipped dark sheets).
   The product tile stays LIGHT in both schemes (studio shots are on white). */
.login-shell {
    --lg-backdrop: #ecece7;              /* Operator off-white page backdrop */
    --lg-surface: var(--mdt-white);      /* form pane surface (light) */
    --lg-fg: #111111;
    --lg-fg-muted: var(--mdt-grey-600);
    --lg-fg-subtle: #6e6e6e;
    --lg-border: var(--mdt-grey-200);
    --lg-input-bg: var(--mdt-grey-050);
    --lg-green: var(--mdt-green-500);          /* fill / accent bar */
    --lg-green-text: var(--mdt-green-600);     /* text-safe green on light */
    /* Product tile — kept light in BOTH schemes so white-bg JPEGs blend in. */
    --lg-tile: linear-gradient(155deg, #fbfbfa 0%, #ededea 55%, #e3e3df 100%);
    --lg-tile-fg: #1a1a1a;
    --lg-tile-fg-muted: #6e6e6e;
    --lg-tile-border: rgba(0, 0, 0, 0.06);

    min-height: 100vh;
    display: flex;
    align-items: center;
    justify-content: center;
    padding: 1.5rem;
    background: var(--lg-backdrop);
    font-family: var(--ctl-font-body);
}

.login-split-card {
    display: flex;
    width: 100%;
    max-width: 1040px;
    max-height: min(640px, calc(100vh - 3rem));
    overflow: hidden;
    background: var(--lg-surface);
    border-radius: 14px;
    box-shadow: 0 24px 60px rgba(0, 0, 0, 0.18), 0 2px 8px rgba(0, 0, 0, 0.08);
    color: var(--lg-fg);
}

/* ── LEFT · form pane (exact 50%) ── */
.login-form-pane {
    flex: 0 0 50%;
    max-width: 50%;
    display: flex;
    flex-direction: column;
    padding: 36px 48px;
    background: var(--lg-surface);
    overflow: hidden;   /* spacing is tuned to always fit the card height — never scroll */
}

.login-brand {
    flex-shrink: 0;
}

.login-logo {
    height: 34px;
    width: auto;
}

/* Logo swap by OS scheme (anonymous page — no data-bs-theme to key on). */
.login-logo-dark { display: none; }
.login-logo-light { display: inline-block; }

.login-core-label {
    margin-top: 0.45rem;
    font-size: 0.68rem;
    font-weight: 700;
    letter-spacing: 0.5em;
    text-indent: 0.5em;  /* re-center: compensates the trailing letter-space */
    color: var(--lg-fg-muted);
}

.login-form-body {
    flex: 1 1 auto;
    display: flex;
    flex-direction: column;
    justify-content: center;
    padding: 18px 0 0;
}

.login-eyebrow {
    margin: 0 0 0.4rem;
    font-family: var(--ctl-font-brand);
    font-size: 12px;
    font-weight: 700;
    letter-spacing: 0.14em;
    text-transform: uppercase;
    color: var(--lg-green-text);
}

.login-heading {
    margin: 0;
    font-family: var(--ctl-font-display);
    font-weight: 700;
    font-size: 40px;
    line-height: 1;
    text-transform: uppercase;
    color: var(--lg-fg);
}

.login-accent-bar {
    display: block;
    width: 48px;
    height: 4px;
    margin: 12px 0 18px;
    background: var(--lg-green);
}

.login-field-label {
    font-family: var(--ctl-font-brand);
    font-size: 11px;
    font-weight: 700;
    letter-spacing: 0.1em;
    text-transform: uppercase;
    color: var(--lg-fg-muted);
    margin-bottom: 0.35rem;
}

/* Input group — recolor to the local (scheme-aware) tokens, since --ctl-*
   is pinned light on this anonymous page. */
.login-input-group .input-group-text {
    background: var(--lg-input-bg);
    border-color: var(--lg-border);
    color: var(--lg-fg-muted);
}

.login-input-group .form-control {
    height: 48px;
    background: var(--lg-input-bg);
    border-color: var(--lg-border);
    color: var(--lg-fg);
}

.login-input-group .form-control::placeholder {
    color: var(--lg-fg-subtle);
    opacity: 0.7;
}

.login-input-group .form-control:focus {
    background: var(--lg-input-bg);
    border-color: var(--lg-green);
    box-shadow: 0 0 0 3px rgba(var(--ctl-accent-rgb), 0.3);
    color: var(--lg-fg);
}

.login-submit {
    height: 50px;
    font-family: var(--ctl-font-display);
    font-weight: 700;
    font-size: 16px;
    text-transform: uppercase;
    letter-spacing: 0.02em;
}

.login-divider {
    display: flex;
    align-items: center;
    margin: 1rem 0;
    color: var(--lg-fg-muted);
    font-size: 0.85rem;
}

.login-divider::before,
.login-divider::after {
    content: '';
    flex: 1;
    border-top: 1px solid var(--lg-border);
}

.login-divider span {
    padding: 0 0.75rem;
}

/* SSO buttons — theme-aware outline (btn-outline-dark would be invisible on
   the dark card). */
.login-sso-btn {
    height: 48px;
    border: 1px solid var(--lg-border);
    background: transparent;
    color: var(--lg-fg);
    font-weight: 500;
}

.login-sso-btn:hover {
    background: var(--lg-input-bg);
    border-color: var(--lg-border);
    color: var(--lg-fg);
}

/* ── The same SSO offer, inside the re-auth modal ──────────────────────────
   session-monitor.js builds the "Sign back in" dialog on top of the app shell,
   where the two must look like one product: same height, same neutral outline,
   same Google mark, same server-supplied wording. The login classes above
   cannot simply be reused — their whole --lg-* palette is scoped to
   .login-shell and resolves to nothing out here — so this is that design
   restated on app-shell tokens. Change one, change the other. */
#ueeReauthModal .uee-sso-btn {
    height: 48px;
    border: 1px solid var(--bs-border-color, #dee2e6);
    background: transparent;
    color: var(--bs-body-color, #212529);
    font-weight: 500;
}

#ueeReauthModal .uee-sso-btn:hover,
#ueeReauthModal .uee-sso-btn:focus-visible {
    background: var(--bs-tertiary-bg, rgba(0, 0, 0, .04));
    border-color: var(--bs-border-color, #dee2e6);
    color: var(--bs-body-color, #212529);
}

#ueeReauthModal .uee-sso-divider {
    display: flex;
    align-items: center;
    margin: 1rem 0;
    color: var(--bs-secondary-color, #6c757d);
    font-size: var(--ctl-fs-body-sm);
}

#ueeReauthModal .uee-sso-divider::before,
#ueeReauthModal .uee-sso-divider::after {
    content: '';
    flex: 1;
    border-top: 1px solid var(--bs-border-color, #dee2e6);
}

#ueeReauthModal .uee-sso-divider span {
    padding: 0 0.75rem;
}

.login-footer {
    flex-shrink: 0;
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 0.5rem;
    padding-top: 16px;
    font-size: 0.78rem;
    color: var(--lg-fg-muted);
}

/* Environment badge (Dev / Staging / Live) — keyed off APP_ENV in the template.
   Bordered chip in the comp's style; border tracks the text colour. */
.login-env-badge {
    flex-shrink: 0;
    font-family: var(--ctl-font-brand);
    font-size: 9px;
    font-weight: 700;
    letter-spacing: 0.14em;
    text-transform: uppercase;
    line-height: 1;
    padding: 3px 8px;
    border: 1px solid currentColor;
    border-radius: 3px;
}
.login-env-dev     { color: #c98a1a; }
.login-env-staging { color: #c98a1a; }
.login-env-prod    { color: var(--lg-green-text); }

/* ── RIGHT · product showcase pane (exact 50%) ── */
.login-showcase-pane {
    flex: 0 0 50%;
    max-width: 50%;
    position: relative;
    display: flex;
    flex-direction: column;
    padding: 40px 44px;
    border-left: 4px solid var(--lg-green);
    background: var(--lg-tile);
    color: var(--lg-tile-fg);
    overflow: hidden;
}

/* Faint 42px grid texture over the tile (mirrors the comp). */
.login-showcase-pane::before {
    content: '';
    position: absolute;
    inset: 0;
    background-image:
        linear-gradient(to right, rgba(0, 0, 0, 0.035) 1px, transparent 1px),
        linear-gradient(to bottom, rgba(0, 0, 0, 0.035) 1px, transparent 1px);
    background-size: 42px 42px;
    pointer-events: none;
}

.login-showcase-pane > * { position: relative; z-index: 1; }

.login-showcase-topbar {
    display: flex;
    align-items: center;
    justify-content: flex-end;
    gap: 0.4rem;
    min-height: 24px;
}

.login-showcase-caption {
    font-family: var(--ctl-font-brand);
    font-size: 11px;
    font-weight: 700;
    letter-spacing: 0.1em;
    text-transform: uppercase;
    color: var(--lg-tile-fg-muted);
    text-align: right;
}

.login-showcase-refresh {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 28px;
    height: 28px;
    flex-shrink: 0;
    padding: 0;
    border: 1px solid var(--lg-tile-border);
    border-radius: 6px;
    background: rgba(255, 255, 255, 0.6);
    color: var(--lg-tile-fg-muted);
    cursor: pointer;
    transition: color var(--ctl-dur, 180ms), border-color var(--ctl-dur, 180ms), transform var(--ctl-dur, 180ms);
}

.login-showcase-refresh:hover {
    color: var(--lg-green-text);
    border-color: var(--lg-green);
}

.login-showcase-refresh:focus-visible {
    outline: none;
    border-color: var(--lg-green);
    box-shadow: 0 0 0 3px rgba(var(--ctl-accent-rgb), 0.35);
    color: var(--lg-green-text);
}

.login-showcase-refresh:active { transform: rotate(90deg); }

.login-showcase-headline {
    /* Center the block between the topbar and the bottom-pinned image, then
       nudge it slightly above centre (relative offset — no reflow). */
    margin-top: auto;
    margin-bottom: auto;
    position: relative;
    top: -14px;
    max-width: 22em;
}

.login-showcase-eyebrow {
    margin: 0 0 0.5rem;
    font-family: var(--ctl-font-brand);
    font-size: 12px;
    font-weight: 700;
    letter-spacing: 0.14em;
    text-transform: uppercase;
    color: var(--lg-green-text);
}

.login-showcase-title {
    margin: 0;
    font-family: var(--ctl-font-display);
    font-weight: 700;
    font-size: 42px;
    line-height: 1.02;
    text-transform: uppercase;
    color: var(--lg-tile-fg);
}

.login-showcase-copy {
    margin: 14px 0 0;
    font-size: 15px;
    line-height: 1.5;
    color: var(--lg-tile-fg-muted);
}

/* Immovable image stage: a FIXED height so no product's size or aspect ratio
   can shift the caption/headline or change the card height. Pinned to the
   bottom of the pane (margin-top:auto). */
.login-showcase-figure {
    flex: 0 0 auto;
    height: 150px;
    /* Full-bleed to the card edges: negative side margins cancel the pane's
       44px horizontal padding. overflow:visible removes the top/bottom crop —
       the enlarged image may spill vertically; the pane's own overflow:hidden
       still crops the SIDES at the card edge. Negative margins don't move the
       text; the figure stays bottom-pinned via the headline's margin-bottom:auto. */
    margin: 0 -44px;
    overflow: visible;
}

.login-showcase-img {
    width: 100%;
    height: 100%;
    /* Transparent BiRefNet cutouts on the light tile. Scaled ~1.25x so the product
       bleeds off the sides and is cropped by the card edges (pane overflow).
       transform does NOT reflow, so nothing else on the screen shifts. */
    object-fit: contain;
    transform: scale(1.25);
    transition: opacity var(--ctl-dur, 180ms) ease;
}

/* Empty state — no seeded products: keep the headline, drop tile chrome. */
.login-showcase-pane[data-empty="true"] .login-showcase-headline { margin-top: 0; }

/* ── Dark scheme (OS preference; DEFAULT is light) ── */
@media (prefers-color-scheme: dark) {
    .login-shell {
        --lg-backdrop: #060606;
        --lg-surface: var(--mdt-grey-900);   /* app nav-bar grey */
        --lg-fg: var(--mdt-white);
        --lg-fg-muted: #9a9a9a;
        --lg-fg-subtle: #808080;
        --lg-border: var(--mdt-grey-800);
        --lg-input-bg: #0d0d0d;
        --lg-green-text: var(--mdt-green-400);
        /* Product tile now goes dark too — transparent cutouts read fine on it
           (the old light-tile constraint was for white-background JPEGs). The
           radial highlight sits low-centre, behind the bottom-pinned product,
           so even all-black chassis keep some separation from the background. */
        --lg-tile: radial-gradient(120% 85% at 60% 90%, #2a2a2a 0%, #161616 58%, #0d0d0d 100%);
        --lg-tile-fg: #f0f0f0;
        --lg-tile-fg-muted: #9a9a9a;
        --lg-tile-border: rgba(255, 255, 255, 0.12);
    }

    .login-logo-dark { display: inline-block; }
    .login-logo-light { display: none; }

    .login-split-card {
        box-shadow: 0 24px 60px rgba(0, 0, 0, 0.6), 0 2px 8px rgba(0, 0, 0, 0.4);
    }

    /* Grid texture: light lines on the dark tile (light mode uses black lines). */
    .login-showcase-pane::before {
        background-image:
            linear-gradient(to right, rgba(255, 255, 255, 0.045) 1px, transparent 1px),
            linear-gradient(to bottom, rgba(255, 255, 255, 0.045) 1px, transparent 1px);
    }

    /* Refresh button: subtle light-on-dark instead of the light-mode white fill. */
    .login-showcase-refresh {
        background: rgba(255, 255, 255, 0.06);
    }
}

/* ── Responsive: below ~900px hide the showcase, form takes the card ── */
@media (max-width: 900px) {
    .login-split-card {
        max-width: 440px;
        max-height: none;
    }

    .login-form-pane {
        flex-basis: 100%;
        max-width: 100%;
        padding: 36px 32px;
    }

    .login-showcase-pane { display: none; }
}

@media (max-width: 480px) {
    .login-shell { padding: 0; }
    .login-split-card { border-radius: 0; min-height: 100vh; }
    .login-form-pane { justify-content: flex-start; }
}

/* ── Login flash — the app's shared .flash-container (fixed, top-right; see
   below) carries every sign-in message: "You have been logged out.", session
   expiry, SSO failures, bad credentials, account-locked. It floats OUTSIDE the
   card, so the form column never moves when a message is present — the pane is
   `overflow:hidden` and tuned to exactly fill the card height, so an inline
   alert used to push the sign-in fields down and clip the footer.

   The login page is ANONYMOUS: bare.php emits no data-bs-theme, so Bootstrap's
   dark alert variables never engage and a stock alert would paint light-mint /
   light-red over the dark card. Re-tint through Bootstrap's own --bs-alert-*
   API so the banner belongs to the card it floats over, with the semantic
   colour carried by the text + icon instead of the fill. ── */
@media (prefers-color-scheme: dark) {
    .login-body .flash-container .alert {
        --bs-alert-bg: var(--mdt-grey-900);
        --bs-alert-border-color: var(--mdt-grey-800);
        --bs-alert-color: #f0f0f0;
        box-shadow: 0 8px 24px rgba(0, 0, 0, 0.55);
    }
    /* Variant tints come after the base rule — equal specificity, later wins. */
    .login-body .flash-container .alert-success { --bs-alert-color: var(--mdt-green-400); }
    .login-body .flash-container .alert-danger  { --bs-alert-color: #ef8b84; }
    .login-body .flash-container .border-danger { border-color: #ef8b84 !important; }
    .login-body .flash-container .btn-close     { filter: var(--bs-btn-close-white-filter); }
}

/* Respect reduced motion for the image cross-fade. */
@media (prefers-reduced-motion: reduce) {
    .login-showcase-img,
    .login-showcase-refresh { transition: none; }
}

/* Sidebar layout */
.wrapper {
    display: flex;
    width: 100%;
    height: 100%;
}

/* The rail. A dark chrome surface in every sheet, in the active style's own
   temperature (see --ctl-nav-bg). It carries no border against the content: in
   the light sheets it is already the strongest edge on the screen, and in the
   dark ones it is the lifted surface, so a hairline would only muddy the step. */
.sidebar {
    width: var(--ctl-sidebar-width);
    height: 100vh;
    background: var(--ctl-nav-bg);
    position: fixed;
    left: 0;
    top: 0;
    z-index: var(--ctl-z-sidebar);
    transition: width 0.2s ease;
    display: flex;
    flex-direction: column;
    overflow: visible;
}

/* Sidebar header with toggle + brand */
.sidebar-header {
    display: flex;
    align-items: center;
    padding: 0.75rem;
    background: var(--ctl-nav-raised-bg);
    border-bottom: 1px solid var(--ctl-nav-border);
    gap: 0.5rem;
    min-height: var(--ctl-pane-head-h);
}

.sidebar-toggle-btn {
    background: none;
    border: none;
    color: var(--ctl-nav-fg-muted);
    font-size: 1.25rem;
    padding: 0.25rem 0.4rem;
    cursor: pointer;
    border-radius: var(--ctl-radius);
    flex-shrink: 0;
    line-height: 1;
}

.sidebar-toggle-btn:hover {
    color: var(--ctl-nav-fg);
    background: var(--ctl-nav-hover-bg);
}

.sidebar-brand {
    display: flex;
    flex-direction: column;
    align-items: flex-start;
    white-space: nowrap;
    overflow: hidden;
    transition: opacity 0.2s;
    /* Now a real <a href="/dashboard"> (home button) — strip link chrome so the
       lockup looks identical to the old <span>. */
    text-decoration: none;
    color: inherit;
    cursor: pointer;
}

.sidebar-brand .brand-logo {
    height: 24px;
    width: auto;
    display: block;
}

/* Mark-only variant shown when the sidebar is collapsed */
.sidebar-brand .brand-mark {
    display: none;
}

/* No scheme fork on the lockup: mdt-logo-white.png / mdt-mark-white.png are
   pure-white-on-transparent artwork and the rail is dark in all six sheets, so
   the artwork is correct as authored everywhere. (#1705 briefly inverted it on
   the light schemes because the rail went light there; that rail is gone and so
   is the invert.) */

.sidebar-brand .brand-core {
    font-size: 0.6rem;
    font-weight: 600;
    letter-spacing: 0.45em;
    color: var(--ctl-nav-fg-subtle);
    margin-top: 0.3rem;
    line-height: 1;
}

/* Sidebar search */
.sidebar-search {
    padding: 0.5rem 0.6rem;
    border-bottom: 1px solid var(--ctl-nav-border);
}

.sidebar-search-input {
    display: flex;
    align-items: center;
    background: var(--ctl-nav-hover-bg);
    border-radius: var(--ctl-radius);
    padding: 0.3rem 0.5rem;
    gap: 0.4rem;
}

.sidebar-search-input i {
    color: var(--ctl-nav-fg-subtle);
    font-size: 0.8rem;
    flex-shrink: 0;
}

.sidebar-search-input input {
    background: none;
    border: none;
    /* no outline:none — the global :focus-visible ring is the focus indicator */
    color: var(--ctl-nav-fg);
    font-size: 0.8rem;
    width: 100%;
}

.sidebar-search-input input::placeholder {
    color: var(--ctl-nav-fg-subtle);
}

.sidebar-search-results {
    margin-top: 0.35rem;
    max-height: 250px;
    overflow-y: auto;
}

.sidebar-search-result {
    display: flex;
    align-items: center;
    gap: 0.5rem;
    padding: 0.4rem 0.5rem;
    color: var(--ctl-nav-fg-muted);
    text-decoration: none;
    font-size: 0.8rem;
    border-radius: var(--ctl-radius);
}

.sidebar-search-result:hover,
.sidebar-search-result.active {
    color: var(--ctl-nav-fg);
    background: var(--ctl-nav-hover-bg);
}

.sidebar-search-result i {
    font-size: 0.85rem;
    width: 1.1rem;
    text-align: center;
    flex-shrink: 0;
    opacity: 0.6;
}

/* nav-fg-MUTED, not -subtle: this line rides INSIDE a hoverable result row, so
   its worst surface is the hover wash rather than the rail, and the -subtle
   step is only certified on the resting surfaces. */
.sidebar-search-path {
    display: block;
    font-size: 0.65rem;
    color: var(--ctl-nav-fg-muted);
    margin-top: 0.1rem;
}

.sidebar-search-empty {
    padding: 0.5rem;
    color: var(--ctl-nav-fg-muted);
    font-size: 0.75rem;
    font-style: italic;
}

/* Hide search when sidebar collapsed */
.sidebar.collapsed .sidebar-search {
    display: none;
}

/* Sidebar menu area — the nav region that hosts the stacked rail layers.
   overflow:hidden so the admin layer is clipped while parked below and each
   rail owns its own vertical scroll. */
.sidebar-menu {
    flex: 1;
    min-height: 0;
    position: relative;
    overflow: hidden;
}

/* Sidebar navigation rail — both rails are absolute layers filling the nav
   region. The main rail sits at rest; the admin rail slides UP from the bottom
   to cover it (main stays put underneath — 'on top of', never 'replacing'). */
.sidebar-nav {
    padding: 0.5rem 0;
    margin: 0;
    position: absolute;
    inset: 0;
    overflow-y: auto;
    overflow-x: hidden;
}

/* Admin rail: parked one full height below at rest, riding above the main rail
   when raised. Reads as a SHEET sliding over the main nav (stakeholder
   direction 2026-07-21: 'on top of, not replacing'): a step-lighter solid
   background and an upward shadow separate the two layers during and after the
   slide. The accent line lives on the .sidebar-nav-head (below the close
   toggle), not the sheet's top edge — so it separates the close action from the
   options rather than capping the sheet. */
.sidebar-nav[data-nav="admin"] {
    transform: translateY(100%);
    z-index: 2;
    background: var(--ctl-nav-raised-bg);
    box-shadow: 0 -8px 20px var(--ctl-nav-shadow);
    /* Own compositor layer so the slide is pure GPU transform and never
       repaints the main rail underneath (that repaint made the still-visible
       buttons shimmer as the sheet rose). */
    will-change: transform;
}
.sidebar[data-nav-mode="admin"] .sidebar-nav[data-nav="admin"] {
    transform: translateY(0);
}

/* The covered main rail dims while the admin sheet is up — sells the layering
   and keeps the underlying nav from visually competing with the sheet. The dim
   is a composited overlay (animating opacity), NOT filter: brightness() on the
   rail: filtering the text-heavy rail forced a per-frame re-raster of every
   label, so the still-visible buttons appeared to twitch the instant Settings
   was clicked. The overlay rides above the main rail (z-index 1) and below the
   admin sheet (z-index 2); --ctl-nav-scrim is rgba black at 0.45, matching the
   old brightness(0.55) darkening, and it is scheme-INVARIANT because the
   surface it falls on is dark in every sheet. */
.sidebar-menu::after {
    content: "";
    position: absolute;
    inset: 0;
    z-index: 1;
    background: var(--ctl-nav-scrim);
    opacity: 0;
    pointer-events: none;
    transition: opacity var(--ctl-dur) ease;
}
.sidebar[data-nav-mode="admin"] .sidebar-menu::after {
    opacity: 1;
}

/* Slide only on user toggles. .nav-anim is added by JS after first paint, so a
   direct admin-URL load renders with the layer already up and no animation.
   prefers-reduced-motion (global rule) zeroes the duration → jump-cut. */
.sidebar.nav-anim .sidebar-nav {
    transition: transform var(--ctl-dur) ease;
}

/* nav-group toggles are real <button>s — reset the UA chrome so they read as
   nav-links (accessibility: buttons carry aria-expanded/aria-controls). */
.sidebar .nav-link {
    color: var(--ctl-nav-fg-muted);
    padding: 0.65rem 0.75rem;
    border-left: 3px solid transparent;
    transition: color 0.15s, background 0.15s;
    display: flex;
    align-items: center;
    white-space: nowrap;
    text-decoration: none;
    width: 100%;
    background: none;
    border-top: 0;
    border-right: 0;
    border-bottom: 0;
    text-align: left;
    font: inherit;
    cursor: pointer;
}

/* Hover / open-group = background lift ONLY. The accent left-bar is reserved for
   "you are here" (.active leaf, .group-active group) so a pointer sweeping the
   rail can never be mistaken for the current page.
   An open group's rail entry wears the hover treatment for as long
   as its panel is open (shared declarations — .nav-group-open reuses :hover
   exactly), and keeps its bar only when it is ALSO .group-active. */
.sidebar .nav-link:hover,
.nav-group.nav-group-open > .nav-group-toggle {
    color: var(--ctl-nav-fg);
    background: var(--ctl-nav-hover-bg);
}

.sidebar .nav-link.active {
    color: var(--ctl-nav-fg);
    background: var(--ctl-nav-hover-bg);
    border-left-color: var(--ctl-nav-accent);
}

.sidebar .nav-link > i:first-child {
    width: 1.5rem;
    text-align: center;
    flex-shrink: 0;
    font-size: 1.1rem;
}

/* Rail rows are flex items in a fixed-width flex COLUMN (.sidebar-nav). A row's
   min-content width (icon + nowrap label + chevron) can exceed the rail width;
   because the flex item's width defaults to that content size, a long label
   stretched the row past the rail and the overflow:hidden clipped the right
   edge — silently hiding the group disclosure chevron (which sits there via
   margin-left:auto). Pinning the item to the rail width, plus letting the label
   shrink and ellipsize, keeps the chevron in view for any label length. */
.sidebar-nav > .nav-item {
    width: 100%;
    box-sizing: border-box;
}

.nav-label {
    margin-left: 0.75rem;
    overflow: hidden;
    transition: opacity 0.2s;
    min-width: 0;             /* flex floor: allow truncation instead of stretching */
    text-overflow: ellipsis;
}

/* Group active indicator — accent on left when a child page is active */
.nav-group.group-active > .nav-group-toggle {
    border-left-color: var(--ctl-nav-accent);
    color: var(--ctl-nav-fg);
}

/* Group chevron — the "opens a panel" affordance. Sized/weighted to be
   clearly legible on the rail (stakeholder finding 2026-07-21: at
   0.65rem/0.6 it read as absent); full strength + 90deg turn while the
   group's panel is open. */
.flyout-chevron {
    font-size: 0.8rem;
    margin-left: auto;
    transition: transform 0.2s, opacity 0.2s;
    opacity: 0.85;
}
.sidebar .nav-link:hover .flyout-chevron,
.nav-group.nav-group-open > .nav-group-toggle .flyout-chevron {
    opacity: 1;
}

/* Hidden data source for drill-down — never visible */
.sidebar-flyout {
    display: none !important;
}

/* Settings "lower" toggle — the first row of the admin sheet. Sticky so it
   stays pinned at the top while the rail scrolls, and it rides up with the
   sheet on open. The down-chevron is the "click to lower" affordance. The accent
   border-bottom is the sheet's one accent line: it separates this close control
   from the options below it. */
.sidebar-nav-head {
    position: sticky;
    top: 0;
    z-index: 3;
    background: var(--ctl-nav-raised-bg); /* must match the sheet it pins to */
    margin: -0.5rem 0 0.25rem; /* cancel the rail's top padding; small gap below */
    border-bottom: 2px solid var(--ctl-nav-accent);
}

/* The close toggle is a control, not a nav target — it deliberately does NOT
   wear the active-page treatment (no accent left-bar), so the only row that
   reads as "active" is the current settings sub-page. Kept bold/bright to read
   as the section header. */
.sidebar-nav-lower.nav-link {
    color: var(--ctl-nav-fg);
    font-weight: 600;
}

.sidebar-nav-lower .sidebar-lower-chevron {
    font-size: 0.8rem;
    margin-left: auto;
    opacity: 0.85;
    transition: transform 0.15s, opacity 0.15s;
}

.sidebar-nav-lower:hover .sidebar-lower-chevron {
    opacity: 1;
    transform: translateY(2px); /* nudges down — reinforces "this lowers the bar" */
}

.sidebar.collapsed .sidebar-lower-chevron {
    opacity: 0;
    width: 0;
    margin: 0;
    overflow: hidden;
}

/* With the sheet up, the footer opener is redundant and would strand a control
   in the bottom-left — hide the whole footer so the top toggle is the only
   Settings control. */
.sidebar[data-nav-mode="admin"] .sidebar-footer {
    display: none;
}

/* Sidebar footer */
.sidebar-footer {
    border-top: 1px solid var(--ctl-nav-border);
    padding: 0.25rem 0;
}

.sidebar-footer .nav-link {
    color: var(--ctl-nav-fg-muted);
    padding: 0.65rem 0.75rem;
    display: flex;
    align-items: center;
    cursor: pointer;
}

.sidebar-footer .nav-link:hover {
    color: var(--ctl-nav-fg);
    background: var(--ctl-nav-hover-bg);
}

.sidebar-footer .nav-link i {
    width: 1.5rem;
    text-align: center;
    flex-shrink: 0;
    font-size: 1.1rem;
}

/* Collapsed state */
.sidebar.collapsed {
    width: var(--ctl-sidebar-collapsed);
}

.sidebar.collapsed .nav-label,
.sidebar.collapsed .flyout-chevron {
    opacity: 0;
    width: 0;
    margin: 0;
    overflow: hidden;
}

/* Collapsed header stacks the toggle above the MDT mark */
.sidebar.collapsed .sidebar-header {
    flex-direction: column;
    align-items: center;
    gap: 0.4rem;
    padding-left: 0.5rem;
    padding-right: 0.5rem;
}

.sidebar.collapsed .brand-logo,
.sidebar.collapsed .brand-core {
    display: none;
}

.sidebar.collapsed .brand-mark {
    display: block;
    height: 22px;
    width: auto;
}

.sidebar.collapsed .nav-link {
    justify-content: center;
    padding-left: 0;
    padding-right: 0;
    border-left-width: 0;
}

.sidebar.collapsed .sidebar-footer .nav-link {
    justify-content: center;
}

/* Main content */
.main-content {
    margin-left: var(--ctl-sidebar-width);
    width: calc(100% - var(--ctl-sidebar-width));
    height: 100%;
    display: flex;
    flex-direction: column;
    transition: margin-left 0.2s ease, width 0.2s ease;
    overflow: hidden;
    position: relative; /* positioning context for the swap-engine progress bar */
}

/* Swap-engine loading affordance (§6.1): thin accent bar pinned to the top of
   the content pane. app.js creeps it to ~75% during the request, snaps to 100%,
   then fades it out via .is-active toggling. */
.uee-nav-progress {
    position: absolute;
    top: 0;
    left: 0;
    height: 3px;
    width: 0;
    background: var(--ctl-accent);
    z-index: var(--ctl-z-sticky);
    opacity: 0;
    pointer-events: none;
    transition: width 0.2s ease, opacity 0.3s ease;
}
.uee-nav-progress.is-active {
    opacity: 1;
}
@media (prefers-reduced-motion: reduce) {
    .uee-nav-progress { transition: opacity 0.15s linear; }
}

.sidebar.collapsed ~ .main-content {
    margin-left: var(--ctl-sidebar-collapsed);
    width: calc(100% - var(--ctl-sidebar-collapsed));
}

/* The second panel is ALWAYS an overlay (UI_STANDARDS §8.2, stakeholder review
   2026-07-21): it floats over the content at every tier and never resizes or
   reflows the main page. No content-push rule at any breakpoint. On desktop the
   overlay carries no dim backdrop (persistent-chrome-adjacent) — the shadow +
   border on .sidebar-panel is the only separation; JS keeps the backdrop for
   the collapsed / tablet / phone tiers that already had it. */

.top-navbar {
    background: var(--ctl-bg-elevated);
    border-bottom: 1px solid var(--ctl-border);
    padding: 0.4rem 1rem;
    flex-shrink: 0;
    color: var(--ctl-fg);
}

/* Hierarchy breadcrumb */
.page-breadcrumb {
    display: flex;
    align-items: center;
    font-size: 0.8rem;
    gap: 0.25rem;
    min-width: 0;
    overflow: hidden;
}

.page-breadcrumb a,
.breadcrumb-item,
.breadcrumb-item a {
    color: var(--ctl-fg-muted);
    text-decoration: none;
    white-space: nowrap;
}

.page-breadcrumb a:hover {
    color: var(--ctl-accent-text);
}

.breadcrumb-sep,
.breadcrumb-separator {
    color: var(--ctl-fg-faint);
    flex-shrink: 0;
}

.breadcrumb-current {
    color: var(--ctl-fg);
    font-weight: 600;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
}

.help-link {
    color: var(--ctl-fg-muted);
    font-size: 0.85rem;
    margin-left: 0.5rem;
    text-decoration: none;
    opacity: 0.6;
    transition: opacity 0.15s, color 0.15s;
}
.help-link:hover {
    color: var(--ctl-link);
    opacity: 1;
}

.navbar-user {
    font-size: 0.8rem;
    white-space: nowrap;
}

/* The right-hand tool cluster never gives ground to the breadcrumb (which has
   min-width:0 + overflow:hidden and truncates instead). */
.navbar-tools {
    flex-shrink: 0;
}

/* Navigation history dropdown */
.nav-history-wrapper {
    position: relative;
}

.nav-history-dropdown {
    display: none;
    position: absolute;
    right: 0;
    top: 100%;
    min-width: 280px;
    max-width: min(360px, 92vw); /* §8 phone clamp — never wider than the viewport */
    background: var(--ctl-bg-elevated);
    border: 1px solid var(--ctl-border);
    border-radius: var(--ctl-radius);
    box-shadow: 0 4px 12px var(--ctl-shadow-hover);
    z-index: var(--ctl-z-dropdown);
    padding: 0.25rem 0;
}

.nav-history-wrapper.open .nav-history-dropdown {
    display: block;
}

.nav-history-title {
    font-size: 0.65rem;
    font-weight: 600;
    text-transform: uppercase;
    letter-spacing: 0.05em;
    color: var(--ctl-fg-muted);
    padding: 0.25rem 0.75rem;
}

.nav-history-item {
    display: block;
    padding: 0.3rem 0.75rem;
    text-decoration: none;
    color: var(--ctl-fg);
    font-size: 0.8rem;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
}

.nav-history-item:hover {
    background: var(--ctl-bg-hover);
    color: var(--ctl-accent-text);
}

.nav-history-crumb {
    display: block;
    overflow: hidden;
    text-overflow: ellipsis;
}

.nav-history-empty {
    display: block;
    padding: 0.5rem 0.75rem;
    color: var(--ctl-fg-muted);
    font-size: 0.8rem;
    font-style: italic;
}

/* ── Ticket Presence ── */
/* Presence rides the right-aligned header actions cluster (not a full-width
   strip above the title): a compact inline group of overlapping bubbles. */
.presence-banner {
    display: inline-flex; align-items: center; gap: 0.25rem;
    font-size: 0.78rem; color: var(--ctl-fg-muted);
}
/* Overlapping avatar stack — bubbles tuck under each other with a ring in the
   page background so they read as a group. */
.presence-avatars { display: inline-flex; align-items: center; }
.presence-avatars .uee-avatar { margin-inline-start: -0.4rem; box-shadow: 0 0 0 2px var(--ctl-bg); }
.presence-avatars .uee-avatar:first-child { margin-inline-start: 0; }
.presence-label { color: var(--ctl-fg-muted); }

/* ── Ticket "is typing…" bubble ──────────────────────────────────────────
   The presence stack above says WHO is here; this says who is mid-sentence.
   It lives in the gap between the Activity card and the composer and reads as
   a chat bubble on purpose — somebody else's message, not yet sent.

   Left-aligned always: an incoming bubble. There is deliberately no own-side
   variant, because you never need to be told that you are typing.

   Radius is a FIXED 16px rather than --ctl-radius-lg — the same deliberate
   exception chat.css makes for its own bubbles. --ctl-radius-lg is 0px on the
   Industrial sheet, and a square box does not read as a bubble; the roundness
   IS the "this is chat" signal. Colour still flows from the sheet, so all six
   presets stay in their own temperature.

   Surface: --ctl-bg-hover is the one step DOWN from the page ground that every
   sheet already declares, so the bubble sits on the ground the cards are
   lifted off without inventing a seventh token. The hairline keeps it defined
   on Industrial Light, which grounds on pure white. */
.ticket-typing { padding: 0.5rem 0 0; }
.ticket-typing-bubble {
    display: inline-flex; align-items: center; gap: 0.45rem;
    max-width: 68%;                      /* chat's bubble measure */
    padding: 0.4rem 0.75rem;
    /* 16px is already half the height of this one-line box, so the arcs meet
       and it draws as a PILL — the same shape .thread-bubble now derives for a
       single-line message. The notched bottom-left corner that used to sit here
       was borrowed from chat's GROUPED-run tail, and neither surface has runs:
       on the portal it was the one squared corner under a column of pills, and
       on the agent page (tickets/show.php, flat .comment-item rows) it pointed
       at a tail no sibling bubble ever had. Removing it costs nothing on either
       surface and makes the portal thread read as one shape family. */
    border-radius: 16px;
    background: var(--ctl-bg-hover);
    box-shadow: inset 0 0 0 1px var(--ctl-border-subtle);
    color: var(--ctl-fg-muted);
    font-size: 0.78rem; font-style: italic;
}
/* Three-dot wave — the signature that makes this legible as "typing" at a
   glance, before the name is read. */
.ticket-typing-dots { display: inline-flex; gap: 3px; font-style: normal; }
.ticket-typing-dots i {
    width: 5px; height: 5px; border-radius: 50%;
    background: var(--ctl-fg-faint);
    animation: ticket-typing-wave 1.2s ease-in-out infinite;
}
.ticket-typing-dots i:nth-child(2) { animation-delay: 0.15s; }
.ticket-typing-dots i:nth-child(3) { animation-delay: 0.30s; }
@keyframes ticket-typing-wave {
    0%, 60%, 100% { opacity: 0.35; transform: translateY(0); }
    30%           { opacity: 1;    transform: translateY(-2px); }
}
/* Motion is decoration here — the name + "is typing…" carries the whole
   message, so dropping the animation loses nothing. */
@media (prefers-reduced-motion: reduce) {
    .ticket-typing-dots i { animation: none; opacity: 0.6; }
}

/* ── User identity avatar ─────────────────────────────────────────────────
   Google / profile photo when present, else an initials bubble filled with the
   user's color (set via inline --uee-avatar-* props by templates/partials/
   avatar.php and the uee.avatar JS builder). Structural only — no palette here.

   Shape knob: --uee-avatar-radius is the single lever that reshapes EVERY
   avatar, server- or client-rendered (both emit .uee-avatar). Any radius works
   here — 25% draws a rounded square, var(--ctl-radius-full) a full circle.

   It resolves to --ctl-radius-full (999px, clamped by the UA to a perfect
   circle on a square box) because the radius rule this system already states
   puts avatars in the full-round family alongside chips, pills and badges (see
   .ctl-page-header-count) — and that token is deliberately preset-INVARIANT, so
   a face stays a face in the sharp Industrial and Operator presets too. The
   earlier 25% was the one member of that family drawing itself as a squircle.
   CssTokenContractTest pins both halves of that contract. */
:root { --uee-avatar-radius: var(--ctl-radius-full); }
.uee-avatar {
    display: inline-flex; align-items: center; justify-content: center;
    flex: 0 0 auto; vertical-align: middle; overflow: hidden; user-select: none;
    border-radius: var(--uee-avatar-radius, 50%);
    background: var(--uee-avatar-bg, var(--ctl-bg-hover));
    color: var(--uee-avatar-fg, var(--ctl-fg));
    font-family: var(--ctl-font-brand, inherit);
    font-weight: 700; line-height: 1; text-transform: uppercase; letter-spacing: 0.02em;
}
.uee-avatar-img { width: 100%; height: 100%; object-fit: cover; display: block; }
/* A non-human seat's glyph (the assistant's sparkle) in place of initials.
   Sized in `em` so ONE declaration tracks all three size classes — an icon
   font reads smaller than a capital at the same font-size, so it takes a
   multiplier to carry the same visual weight as the monogram it replaces.
   `display:flex` is load-bearing, not decoration: Bootstrap Icons puts the
   glyph in a ::before carrying `vertical-align:-.125em`, which in an inline
   context drops it off the bubble's centre; making the <i> a flex container
   turns the pseudo into a flex item, where vertical-align does not apply.
   letter-spacing is zeroed for the same reason — the parent's tracking pads a
   gap AFTER the one glyph and shoves it left. The box, the radius and the
   gutter slot are the monogram's, unchanged; only the ground differs, and the
   descriptor decides that (a seat sends bg `transparent` plus its own --ctl-*
   ink — accent for the assistant, muted for the system user) — no color
   belongs in this rule, which is why a second seat needed nothing here.

   The multiplier went 1.4 -> 1.65em when the seat lost its disc. A filled
   bubble carries its own presence and the glyph only has to sit inside it; an
   UNBACKED glyph is the entire avatar, so at 1.4em it read as a smaller
   neighbour beside the filled discs it shares a gutter with. 1.65em restores
   the optical weight without touching the slot every row aligns on. */
.uee-avatar-icon {
    display: flex; align-items: center; justify-content: center;
    font-size: 1.65em; line-height: 1; letter-spacing: 0;
}
.uee-avatar-sm { width: 1.5rem;  height: 1.5rem;  font-size: 0.6rem;  }
.uee-avatar-md { width: 1.85rem; height: 1.85rem; font-size: 0.72rem; }
.uee-avatar-lg { width: 2.5rem;  height: 2.5rem;  font-size: 0.95rem; }
/* Someone actively typing — gentle attention pulse on their bubble. */
.uee-avatar-typing { animation: uee-avatar-pulse 1.4s ease-in-out infinite; }
@keyframes uee-avatar-pulse {
    0%, 100% { box-shadow: 0 0 0 2px var(--ctl-bg); }
    50%      { box-shadow: 0 0 0 2px var(--ctl-bg), 0 0 0 4px var(--ctl-accent); }
}
@media (prefers-reduced-motion: reduce) { .uee-avatar-typing { animation: none; } }

/* ── AI Chat Panel ── */
/* z-index 1040 sits the panel above page content but BELOW Bootstrap's
   modal backdrop (1050) and modal (1055). When a modal opens it correctly
   blocks interaction with everything including the panel — which is what
   modal semantics require. AI's own confirmation flow uses inline UI
   (ConfirmationService), not Bootstrap modals, so this doesn't break the
   HIGH-risk OTP path. Closes gap.md #27.
   z literal intentionally kept — Bootstrap-stacking-order-sensitive. */
.ai-chat-panel {
    position: fixed; right: 0; top: 0; bottom: 0;
    width: var(--ai-panel-default-width, 400px);
    min-width: 300px; max-width: 80vw;
    transform: translateX(100%);
    background: var(--ctl-bg-elevated); border-left: 1px solid var(--ctl-border);
    box-shadow: -4px 0 16px var(--ctl-shadow-hover); z-index: 1040;
    display: flex; flex-direction: column;
    transition: transform 0.25s ease;
}
.ai-chat-panel.open { transform: translateX(0); }
.ai-chat-panel.resizing { transition: none; user-select: none; }

/* Mobile / narrow viewport — full-screen overlay, no resize, no pin.
   The breakpoint custom property is set inline from sys_setting ai.ui.mobile_breakpoint_px
   so admins can move it in /settings/system without a CSS rebuild. */
.ai-chat-panel.mobile-fullscreen {
    width: 100vw !important;
    max-width: 100vw !important;
    min-width: 0 !important;
    border-left: none;
    box-shadow: none;
}
.ai-chat-panel.mobile-fullscreen .ai-chat-resize,
.ai-chat-panel.mobile-fullscreen #aiChatPinBtn { display: none !important; }
.ai-chat-panel.mobile-fullscreen .ai-chat-input { font-size: 16px; /* iOS zoom-lock */ }
/* Touch targets — Apple HIG minimum 44pt / Google Material 48dp. This used to
   be the ONLY thing standing between a finger and a ~22px control, because the
   lens drew its own icon buttons. It no longer is: the buttons wear the shared
   .ctl-icon-btn / .chat-send-btn, whose coarse-pointer ::after already grows
   every one of them to ≥44px of hit area on any touch device (§9) — including
   the touch laptops and desktop-layout tablets that never match
   .mobile-fullscreen at all. What survives here is the VISIBLE size: on a phone
   there is room to draw the target at 44px rather than merely make it tappable,
   so the disc grows to meet the hit area instead of floating inside it. The old
   `padding: 0.5rem 0.75rem` is gone with the fork — the shared rules centre
   their glyph with flex and carry padding: 0, and padding inside a fixed 44px
   border-box only shrinks the content box. Closes gap.md #25; the .ai-chat-btn
   selector is deliberately LAST so AiPanelMobileDeepResponsiveTest's
   observation regex resolves against this rule. */
.ai-chat-panel.mobile-fullscreen .ai-chat-send,
.ai-chat-panel.mobile-fullscreen .ai-chat-btn {
    min-height: 44px;
    min-width: 44px;
}
/* When the chat panel is mobile-fullscreen-open, hide the underlying scroll. */
body.ai-chat-mobile-open { overflow: hidden; }

/* Resize grab handle — left edge */
.ai-chat-resize {
    position: absolute; left: -4px; top: 0; bottom: 0; width: 8px;
    cursor: col-resize; z-index: 1;
    background: transparent;
}
.ai-chat-resize::after {
    content: ''; position: absolute; left: 3px; top: 50%; transform: translateY(-50%);
    width: 3px; height: 32px; border-radius: 2px;
    background: var(--ctl-border);
    opacity: 0; transition: opacity 0.15s;
}
.ai-chat-resize:hover::after,
.ai-chat-panel.resizing .ai-chat-resize::after { opacity: 1; }

/* Pinned mode — panel pushes main content instead of overlapping.
   The BUTTON's pinned look is deliberately not here: assistant-lens.js puts
   `.is-active` on #aiChatPinBtn, so the pin lights up with the shared icon
   button's toggled treatment (accent glyph on a filled disc, tokenized, all
   four themes) alongside the bi-pin-angle → bi-pin-angle-fill glyph swap this
   app uses for every other pin. What stood here was a private
   `.ai-chat-panel.pinned .ai-chat-pin-icon { transform: rotate(45deg); … }`
   that no element ever matched — the pinned state had no affordance at all
   until #1898. Restate nothing; wear the shared class. */
.ai-chat-panel.pinned { box-shadow: none; border-left: 1px solid var(--ctl-border); }
.main-content { transition: margin-left 0.2s ease, width 0.2s ease, margin-right 0.25s ease; }

/* On the shared chrome band (§4.4). The lens docks BESIDE page content whose
   own header already stands at --ctl-pane-head-h, so the two header rules land
   side by side on one screen and any disagreement reads as misalignment with
   nothing for the viewer to name. This one used to take its height from 0.6rem
   of its own padding plus whichever icon button inside it was tallest — about
   41px against the band's 52px. Vertical padding is deliberately ZERO for the
   same reason it is on /chat's three heads: the band owns the height, so no
   control this header gains later can grow it back off the band. Only the
   inline padding stays the pane's own. */
.ai-chat-header {
    display: flex; align-items: center; justify-content: space-between;
    min-height: var(--ctl-pane-head-h);
    padding-block: 0;
    padding-inline: 0.75rem;
    border-bottom: 1px solid var(--ctl-border);
    flex-shrink: 0;
}
.ai-chat-title { font-size: 0.85rem; font-weight: 600; color: var(--ctl-accent-text); }

/* .ai-chat-btn HAS NO RULE, and that is the fix.
   It used to be the lens's private icon button — `padding: 0.2rem 0.4rem;
   border-radius: 4px` with no declared size — which made it the one control in
   the panel that ignored the preset radius (a literal 4px against
   --ctl-radius's 8px) and rendered a ~22px target that got no share of the
   shared coarse-pointer hit extension (§9). Every element wearing it now also
   wears the shared .ctl-icon-btn, which owns the whole appearance: the 32px
   disc, the icon centring, the token colours and the ≥44px touch pseudo. The
   class survives in the markup as a LENS MARKER only — the mobile-fullscreen
   rule above selects it, and AssistantLensHeaderButtonContractTest asserts the
   two are always worn together. Do not give it declarations again: it sits
   earlier in this file than .ctl-icon-btn, so anything stated here loses the
   cascade while still reading, to a maintainer, like the source of truth. */

/* ============== Assistant thread switcher ==============
   The lens's copy of /chat's floating pill (.chat-assist-switch in chat.css):
   same anatomy, same interaction, same ARIA — a labelled button over a
   role="listbox" menu — restated in the --ctl-* vocabulary this file speaks
   and sized for a ~400px panel rather than a full-width thread.

   It hovers over the transcript instead of sitting in the column, which is the
   whole point of the pattern: the panel is short, and a permanent band across
   the top of it would cost a line of conversation on every page in the app.
   Absolute inside .ai-chat-panel (position:fixed, so already the containing
   block), offset by the shared chrome-band height so it follows the header
   rather than a number copied out of it. */
.ai-chat-assist-switch {
    position: absolute;
    top: calc(var(--ctl-pane-head-h) + 0.5rem);
    left: 50%; transform: translateX(-50%);
    z-index: 8;
    display: flex; flex-direction: column; align-items: center;
    max-width: calc(100% - 1.5rem);
}
.ai-chat-assist-btn {
    display: inline-flex; align-items: center; gap: 0.35rem;
    max-width: 100%;
    min-height: 30px; padding: 0.15rem 0.7rem;
    border: 1px solid var(--ctl-border);
    border-radius: var(--ctl-radius-full, 999px);
    background: var(--ctl-bg-elevated);
    color: var(--ctl-fg);
    font-size: 0.75rem; font-weight: 600;
    cursor: pointer;
    box-shadow: 0 2px 8px var(--ctl-shadow);
}
.ai-chat-assist-btn:hover { background: var(--ctl-bg-hover); }
.ai-chat-assist-btn > i { color: var(--ctl-accent-text); flex: 0 0 auto; }
.ai-chat-assist-current { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ai-chat-assist-chevron { font-size: 0.65rem; }
.ai-chat-assist-btn[aria-expanded="true"] .ai-chat-assist-chevron { transform: rotate(180deg); }

/* Width is the panel's, not a fixed rem: the reader can drag the lens between
   300px and 80vw, and a menu that ignored that would either overhang the panel
   or float in the middle of it. */
.ai-chat-assist-menu {
    margin-top: 4px;
    width: 100%; min-width: 0;
    max-height: 16rem; overflow-y: auto;
    background: var(--ctl-bg-elevated);
    border: 1px solid var(--ctl-border);
    border-radius: var(--ctl-radius, 12px);
    box-shadow: 0 6px 18px var(--ctl-shadow-hover);
    padding: 4px;
}
.ai-chat-assist-item {
    display: flex; align-items: center; gap: 0.4rem;
    width: 100%; min-height: 34px;
    padding: 0.25rem 0.5rem;
    border: 0; background: none; cursor: pointer;
    border-radius: 8px;
    color: var(--ctl-fg);
    font-size: 0.78rem;
    text-align: start;
}
.ai-chat-assist-item:hover, .ai-chat-assist-item:focus-visible { background: var(--ctl-bg-hover); }
/* The open thread reads as accent-tinted rather than merely hovered, so the
   two states stay distinguishable while the pointer is inside the menu. Mixed
   from --ctl-accent-rgb (the sheet's own way to tint) — there is no subtle-
   accent surface token, and inventing one for a single row is not the trade. */
.ai-chat-assist-item.is-current { background: rgba(var(--ctl-accent-rgb), 0.14); font-weight: 600; }
.ai-chat-assist-item-title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ai-chat-assist-item-date { flex: 0 0 auto; font-size: 0.68rem; color: var(--ctl-fg-muted); }
/* The unread badge, restored now that the lens clears what it counts (#1917).
   /chat's twin is .chat-assist-item-unread in chat.css; this is the same pill in
   the --ctl-* vocabulary and a notch smaller for a 400px panel. */
.ai-chat-assist-item-unread {
    flex: 0 0 auto;
    min-width: 17px; height: 17px; padding: 0 5px;
    border-radius: var(--ctl-radius-full, 999px);
    background: var(--ctl-accent); color: var(--ctl-accent-fg, #fff);
    font-size: 0.62rem; font-weight: 700;
    display: grid; place-items: center;
}
.ai-chat-assist-empty { padding: 0.45rem 0.55rem; font-size: 0.75rem; color: var(--ctl-fg-muted); }

/* ============== Proactive nudge bell + feed ============== */
.ai-chat-header-actions { position: relative; }

.ai-nudge-btn { position: relative; }
.ai-nudge-badge {
    position: absolute; top: -2px; right: -2px;
    background: var(--ctl-danger); color: #fff;
    border-radius: 999px; padding: 0 5px;
    font-size: 0.6rem; font-weight: 700; line-height: 1.4;
    min-width: 16px; text-align: center; pointer-events: none;
}

.ai-nudge-feed {
    /* Hangs off the BOTTOM of the lens header. Its containing block is
       .ai-chat-panel (position: fixed), not the header, so this offset has to
       equal the header's height — it was a hand-added calc(0.6rem + 1.8rem)
       ≈ 38px approximating the old padding-derived head, which now overlaps it.
       Anchored to the band by name so the two can never disagree again. */
    position: absolute; top: var(--ctl-pane-head-h); right: 0.5rem;
    width: min(340px, 92vw); max-height: 60vh; overflow-y: auto; /* §8 phone clamp */
    background: var(--ctl-bg-elevated);
    border: 1px solid var(--ctl-border);
    border-radius: var(--ctl-radius);
    box-shadow: 0 8px 24px var(--ctl-shadow-hover);
    z-index: var(--ctl-z-dropdown); /* local to the panel's stacking context — just needs to top panel content */
}
.ai-nudge-feed-header {
    display: flex; justify-content: space-between; align-items: center;
    padding: 0.5rem 0.75rem;
    border-bottom: 1px solid var(--ctl-border);
    font-weight: 600; font-size: 0.85rem;
}
.ai-nudge-feed-list { padding: 0.25rem 0; }
.ai-nudge-feed-empty { padding: 1.5rem; text-align: center; }
.ai-nudge-feed-empty i { font-size: 2rem; color: var(--ctl-success-text); }

.ai-nudge-item {
    position: relative;
    padding: 0.6rem 0.75rem;
    border-bottom: 1px solid var(--ctl-border);
    border-left: 3px solid transparent;
}
.ai-nudge-item:hover { background: var(--ctl-bg-hover); }
.ai-nudge-item:last-child { border-bottom: none; }
.ai-nudge-item-header {
    display: flex; align-items: flex-start; gap: 0.4rem;
}
.ai-nudge-item-title {
    flex: 1; font-weight: 600; font-size: 0.82rem; line-height: 1.3;
}
.ai-nudge-item-title a { color: inherit; text-decoration: none; }
.ai-nudge-item-title a:hover { text-decoration: underline; }
.ai-nudge-item-body {
    font-size: 0.78rem; line-height: 1.4;
    color: var(--ctl-fg-muted); margin-top: 0.25rem;
}
.ai-nudge-item-meta {
    font-size: 0.7rem; color: var(--ctl-fg-muted); margin-top: 0.25rem;
}
.ai-nudge-sev-badge {
    font-size: 0.6rem; font-weight: 700; text-transform: uppercase;
    padding: 0.1rem 0.4rem; border-radius: var(--ctl-radius-full); flex-shrink: 0;
    align-self: center;
}
.ai-nudge-sev-info     { border-left-color: var(--ctl-info); }
.ai-nudge-sev-info     .ai-nudge-sev-badge { background: var(--ctl-info); color: var(--ctl-bg); }
.ai-nudge-sev-warning  { border-left-color: var(--ctl-warning); }
.ai-nudge-sev-warning  .ai-nudge-sev-badge { background: var(--ctl-warning); color: #000; }
.ai-nudge-sev-critical { border-left-color: var(--ctl-danger); }
.ai-nudge-sev-critical .ai-nudge-sev-badge { background: var(--ctl-danger); color: #fff; }
.ai-nudge-dismiss {
    position: relative;
    background: none; border: none; cursor: pointer;
    color: var(--ctl-fg-muted); padding: 0.1rem 0.3rem;
    font-size: 0.75rem; flex-shrink: 0;
    z-index: 2; /* sit above .stretched-link */
}
.ai-nudge-dismiss:hover { color: var(--ctl-danger-text); }

/* ── The lens transcript ──────────────────────────────────────────────────
   The docked assistant panel draws its conversation with the SHARED
   CONVERSATION THREAD KIT (thread-* above; builders in uee-thread.js), the
   same anatomy /chat, the portal and the agent ticket threads render — rows
   with identity avatars, the author/time meta line, run grouping, day
   dividers, pill bubbles with the eight-line clamp, bare-emoji rows and
   attachment anatomy. It was the last chat surface in the app carrying a
   private fork (flat .ai-msg bubbles: no author, no time, no grouping, no
   clamp, hand-rolled radii), and that fork is retired here.

   No `gap` on this container: kit rows own their own spacing (mb-3, closed
   inside a run by the kit's run chrome), and a container gap would hold every
   run open — the same reason .chat-message-flow declares none. */
.ai-chat-messages {
    flex: 1; overflow-y: auto; padding: 0.75rem;
    display: flex; flex-direction: column;
    /* Contain iOS rubber-band scrolling so swiping past the top/bottom
       of the conversation doesn't bounce or pull-to-refresh the page
       underneath. body.ai-chat-mobile-open also locks body overflow,
       but that doesn't stop the inner scroll bleed without this rule.
       Closes gap.md #26. */
    overscroll-behavior: contain;
}

/* A NEW conversation is one sentence in an otherwise empty column, and it used
   to sit jammed against the top of it — under the switcher pill, with the whole
   panel blank beneath. Centring it is what every assistant surface does with a
   "nothing said yet" greeting, and it is what a reader's eye expects when the
   region holds exactly one thing.

   AUTO MARGINS, not `justify-content: center` on the container. Two reasons,
   and both of them are about this box being a SCROLL area:

   1. Auto margins state the rule ON the welcome, so it applies exactly while
      the welcome is displayed and not one moment longer. .ai-chat-welcome is
      only ever visible when it is the box's sole child (assistant-lens.js
      d-nones it on the first turn and re-shows it — as the only child — on
      "New conversation"), and a `display:none` node's margins do nothing. So
      there is no state class to keep in step, no `:has()` to evaluate on a
      container that repaints on every streamed token, and no JS: the centring
      disengages the instant a message exists, by construction.
   2. `justify-content: center` on an overflowing scroll container puts the
      overflow ABOVE the scroll origin, where it cannot be reached. Auto
      margins are defined to collapse to zero when free space is negative
      (CSS Flexbox §8.1), so on a short viewport — mobile-fullscreen in
      landscape, a resized panel — the greeting simply falls back to sitting at
      the top and scrolls normally.

   Vertical only (margin-block): the horizontal arrangement is the kit empty
   state's own (text-align:center inside a stretched flex item), and inline
   auto margins would shrink-wrap the row and undo it. */
.ai-chat-welcome { margin-block: auto; }

/* The panel is a ~400px column, so its rows get more of it than a full-width
   thread does: 80% of a narrow panel is a bubble with nowhere to wrap. */
.ai-chat-messages .thread-msg-col { max-width: 88%; }
/* The assistant's name in the accent, exactly as /chat marks the same author
   on the same kit row (.chat-message-flow .thread-msg-row.is-assistant). */
.ai-chat-messages .thread-msg-row.is-assistant .thread-msg-meta > span:first-child {
    color: var(--ctl-accent-text);
    font-weight: 600;
}

/* ── Lens-specific chrome riding a kit row ────────────────────────────────
   Four things the kit has no opinion about, because they only exist where the
   other party is a model: the markdown the answer streams in as, the provider
   footnote, the answer rating and the failed-send marker. */

/* The answer's prose is NOT stated here any more. The markdown renderer moved
   into the shared kit (T.renderMarkdown) so /chat could read an assistant
   answer too (#1858), and its type scale went with it — .thread-md-* beside the
   kit's own bubble rules. Keeping the scale here would have meant the panel
   rendering an answer one way and every other surface another, which is the
   exact split the move ends.

   What survives as lens-local is DENSITY: this is a ~400px column, and the
   absolute sizes below are the ones the panel has always used. They restate the
   kit's relative defaults at the panel's own scale rather than redefining
   anything — same rules, tighter column. */
.ai-chat-messages .thread-md-table { font-size: 0.78rem; }
.ai-chat-messages .thread-md-table th { font-size: 0.75rem; }
.ai-chat-messages .thread-md-code { font-size: 0.75rem; }
.ai-chat-messages .thread-md-inline-code { font-size: 0.75rem; }
.ai-chat-messages .thread-md-h1 { font-size: 0.9rem; }
.ai-chat-messages .thread-md-h2 { font-size: 0.85rem; }
.ai-chat-messages .thread-md-h3 { font-size: 0.82rem; }

/* "via Anthropic · claude-…" — a whisper under the turn, in the same voice
   and at the same size as the kit's own .thread-edited stamp. */
.ai-msg-meta {
    margin-top: 2px;
    font-size: var(--ctl-fs-caption);
    color: var(--ctl-fg-subtle);
}
.thread-row-far .ai-msg-meta { text-align: right; }
.ai-msg-open-link { margin-top: var(--ctl-sp-2); }

/* The answer rating rides the kit's chip vocabulary (.thread-reactions /
   .thread-reaction / .is-mine); the only thing stated here is that a verdict
   has no count beside it, so the chip is square-ish rather than pill-wide. */
.ai-msg-rate-btn { padding: 0 var(--ctl-sp-2); line-height: 1; }

/* A row whose send FAILED (a 401 during reauth, a dropped connection). The
   loss has to be visible — an un-delivered message that looks delivered read
   to users as "the AI lost my conversation" — so the bubble goes dashed and
   quiet while the row keeps its seat, its face and its time. */
.ai-msg-failed .thread-bubble {
    opacity: 0.6;
    border-style: dashed;
    border-color: var(--ctl-danger-text);
}

/* The assistant is working — the SAME typing bubble the /chat page, the agent
   ticket page and the portal render (.ticket-typing above), so "the other side
   is composing" has ONE shape across every conversation surface. What the lens
   adds inside it is the tool status ("Using Ticket Search") and the elapsed
   counter, which are the honest answer to "is this stuck?" on a turn that can
   legitimately run for a minute. */
.ai-thinking-status { font-style: italic; }
.ai-thinking-elapsed {
    font-size: var(--ctl-fs-caption);
    color: var(--ctl-fg-subtle);
    font-variant-numeric: tabular-nums;
    min-width: 2em;
}

/* Voice Mode */
.ai-voice-overlay {
    position: absolute; inset: 0; z-index: 10;
    background: var(--ctl-bg-elevated);
    display: flex; flex-direction: column; align-items: center; justify-content: center;
    gap: 1.5rem; padding: 2rem;
}
.ai-voice-close {
    position: absolute; top: 0.75rem; right: 0.75rem;
    background: none; border: none; font-size: 1.2rem;
    color: var(--ctl-fg-muted); cursor: pointer;
}
.ai-voice-close:hover { color: var(--ctl-fg); }
/* Mute toggle — local-only TTS quieter for the session. Top-left mirror
   of the close button (top-right). v0.27.000 closes gap.md #15 Phase 1. */
.ai-voice-mute {
    position: absolute; top: 0.75rem; left: 0.75rem;
    background: none; border: none; font-size: 1.2rem;
    color: var(--ctl-fg-muted); cursor: pointer;
    min-height: 44px; min-width: 44px; /* mobile touch target */
}
.ai-voice-mute:hover { color: var(--ctl-fg); }
.ai-voice-mute.muted { color: var(--ctl-danger-text); }
.ai-voice-visual { position: relative; }
.ai-voice-ring {
    width: 120px; height: 120px; border-radius: 50%;
    border: 3px solid var(--ctl-border);
    display: flex; align-items: center; justify-content: center;
    cursor: pointer; transition: all 0.3s ease;
    background: var(--ctl-bg-alt);
}
.ai-voice-ring-inner {
    width: 48px; height: 48px; border-radius: 50%;
    background: var(--ctl-fg-muted);
    transition: all 0.3s ease;
}
/* Listening state */
.ai-voice-ring.listening {
    border-color: var(--ctl-danger);
    box-shadow: 0 0 0 0 rgba(var(--ctl-danger-rgb), 0.4);
    animation: voicePulse 1.5s infinite;
}
.ai-voice-ring.listening .ai-voice-ring-inner {
    background: var(--ctl-danger);
}
/* Processing state */
.ai-voice-ring.processing {
    border-color: var(--ctl-warning);
}
.ai-voice-ring.processing .ai-voice-ring-inner {
    background: var(--ctl-warning);
    animation: voiceSpin 1s linear infinite;
}
/* Speaking state */
.ai-voice-ring.speaking {
    border-color: var(--ctl-accent);
    box-shadow: 0 0 0 0 rgba(var(--ctl-accent-rgb), 0.3);
    animation: voicePulse 1s infinite;
}
.ai-voice-ring.speaking .ai-voice-ring-inner {
    background: var(--ctl-accent);
}
@keyframes voicePulse {
    0% { box-shadow: 0 0 0 0 currentColor; }
    70% { box-shadow: 0 0 0 15px transparent; }
    100% { box-shadow: 0 0 0 0 transparent; }
}
@keyframes voiceSpin {
    0% { transform: scale(1); }
    50% { transform: scale(0.8); }
    100% { transform: scale(1); }
}
.ai-voice-status {
    font-size: 0.9rem; color: var(--ctl-fg-muted);
    text-align: center; min-height: 1.4rem;
}
.ai-voice-transcript {
    font-size: 0.85rem; color: var(--ctl-fg);
    text-align: center; max-width: 90%; min-height: 2rem;
    font-style: italic; opacity: 0.8;
}

#aiChatVoiceBtn {
    color: var(--ctl-fg-muted); font-size: 0.9rem; flex-shrink: 0;
}
#aiChatVoiceBtn:hover { color: var(--ctl-accent-text); }

/* ── The lens composer ─────────────────────────────────────────────────────
   Geometry is the SHARED composer component's (.ctl-icon-btn /
   .chat-send-btn, §"Composer component" below); this region states only what
   is genuinely the lens's — its scale and its padding.

   No rule line above it. The composer used to carry a border-top against the
   transcript, which is the one thing the shared composer explicitly does NOT
   do ("a free-floating rounded shell on the reading surface — no rule line
   against the thread", chat.css). The lens was the last surface still drawing
   that hairline, so the app's most-seen chat surface was also the only one
   with a seam across it. */
.ai-chat-input-area { flex-shrink: 0; padding: 0.5rem 0.75rem; }
/* Both action buttons are the SAME disc. They are siblings in one flex row
   here (unlike /chat, where the tool buttons live inside the input shell and
   only Send sits outside it), so an unequal pair does not read as a hierarchy
   — it reads as one button that failed to line up. Equal size + a zeroed send
   margin is what puts their centres on one line under align-items: flex-end;
   the mic used to be an unsized .ai-chat-btn next to a padded rectangle and
   the two shared neither size nor centre. */
.ai-chat-input-wrapper {
    display: flex; gap: 0.5rem; align-items: flex-end;
    --ctl-icon-btn-size: 32px;
    --composer-send-size: 32px;
    --composer-send-mb: 0;
    /* PILL AT ONE LINE. Deliberately larger than half a single-line box, which
       is what makes the browser do the work: when the two vertical radii on a
       side exceed the box height, CSS scales every corner down by the same
       factor (Backgrounds §5.5), so a 20px request on a ~30px-tall box resolves
       to exactly 15px — a true stadium — with no measurement in the stylesheet
       and no JS. It self-corrects at any single-line height, which matters
       because there are two: 0.82rem here and the 16px iOS zoom-lock the
       mobile-fullscreen rule forces (~34px tall, still clamped to a pill).

       And because the value is FIXED rather than 999px, growth degrades the way
       the request asked: assistant-lens.js grows the textarea to a 100px cap,
       and past one line the clamp stops applying and the corners settle at a
       steady 20px rounded rect. A full-round 999px would instead keep both ends
       as ~50px semicircles at full height, which eats the ends of a 400px panel
       and pushes text into the curve. */
    --composer-input-radius: 1.25rem;
}
.ai-chat-input {
    flex: 1; border: 1px solid var(--ctl-border);
    border-radius: var(--composer-input-radius, var(--ctl-radius));
    /* Inline padding is the curve's, not the old rectangle's: at 0.6rem the
       first glyph sat against the end cap. --ctl-sp-3 clears it at both
       single-line heights while staying cheap in a narrow panel. */
    padding: 0.4rem var(--ctl-sp-3); font-size: 0.82rem; resize: none; max-height: 100px;
    background: var(--ctl-bg-inset); color: var(--ctl-fg);
}
.ai-chat-input:focus { outline: none; border-color: var(--ctl-accent); box-shadow: var(--ctl-focus-ring); }
/* THEMING ONLY. The button wears .chat-send-btn alongside this class, and that
   shared rule owns the disc — size, radius, padding, icon centring, the
   coarse-pointer hit extension. This rule used to restate all of it as a
   rounded RECTANGLE (var(--ctl-radius) + 0.4rem/0.6rem padding), which is how
   the lens ended up the one composer in the app whose send button was not the
   accent circle every other surface shows. Do not put geometry back here. */
.ai-chat-send {
    background: var(--ctl-accent); color: var(--ctl-accent-fg); border: none;
    cursor: pointer; font-size: 0.85rem;
}
.ai-chat-send:hover { opacity: 0.9; }
.ai-chat-send:disabled { opacity: 0.5; cursor: not-allowed; }

/* No .ai-chat-history* rules here any more (#1910). They dressed a drawer the
   lens rendered but never filled: .ai-chat-history-item matched nothing in the
   app, and the container it sat in was hidden from first paint and never
   un-hidden. The lens's conversation list is the switcher menu
   (.ai-chat-assist-menu, further down this sheet). */

/* ══ Attachment dropzone (partials/attachment_field.php) ══
   One control, every multipart form: portal submit, portal reply, agent create,
   the agent reply composer, and both side-conversation pickers.

   The native <input type="file"> is absolutely positioned across the zone at
   opacity 0 — NOT display:none and NOT visually-hidden. That single decision is
   what makes the control work with JavaScript off (click, keyboard focus, and
   even drag-and-drop are all the browser's own file-input behaviour) while
   still looking like a dropzone. The visible box is a plain <div>; the <label>
   above it names the input, so nothing here is a focus trap or an unlabelled
   control. attachment-dropzone.js only adds the drag highlight, paste, and the
   removable file list. */
.attach-zone {
    position: relative;
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    gap: 2px;
    padding: var(--ctl-sp-5) var(--ctl-sp-4);
    text-align: center;
    background: var(--ctl-bg-inset);
    border: 1px dashed var(--ctl-border-strong);
    border-radius: var(--ctl-radius);
    color: var(--ctl-fg-muted);
    transition: border-color var(--ctl-dur), background-color var(--ctl-dur);
}
.attach-field-sm .attach-zone { padding: var(--ctl-sp-4) var(--ctl-sp-3); }

/* The input covers the zone only — never the file list below it, whose remove
   buttons must stay clickable. */
.attach-input {
    position: absolute;
    inset: 0;
    width: 100%;
    height: 100%;
    opacity: 0;
    cursor: pointer;
}
.attach-zone:hover,
.attach-zone.is-dragover {
    border-color: var(--ctl-accent);
    background: var(--ctl-bg-hover);
}
.attach-zone.is-dragover { border-style: solid; }
/* Focus lives on the invisible input, so the ring has to be drawn by the box.
   :focus-within is the floor (every browser keeps a visible ring); where :has
   is supported the ring is narrowed to keyboard focus, so clicking the zone and
   cancelling the file dialog doesn't leave it looking stuck. */
.attach-zone:focus-within {
    border-color: var(--ctl-accent);
    box-shadow: var(--ctl-focus-ring);
}
@supports selector(:has(*)) {
    .attach-zone:focus-within:not(:has(.attach-input:focus-visible)) {
        border-color: var(--ctl-border-strong);
        box-shadow: none;
    }
}
/* The zone's only in-flow child, so it carries the stacking itself — the icon,
   the lead line and the paste hint are three spans inside one element. */
.attach-zone-body {
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 2px;
}
.attach-zone-icon { font-size: 1.35rem; line-height: 1; color: var(--ctl-fg-subtle); }
.attach-field-sm .attach-zone-icon { font-size: 1.1rem; }
.attach-zone-lead { font-size: var(--ctl-fs-body-sm); }
.attach-zone-browse { color: var(--ctl-accent-text); font-weight: 700; text-decoration: underline; }
.attach-zone-paste { font-size: var(--ctl-fs-caption); color: var(--ctl-fg-subtle); }

/* Visibility / scope caveat under the hint (e.g. "the requester can see these"). */
.attach-note { margin-top: 2px; color: var(--ctl-warning-text); }

.attach-list { list-style: none; margin: var(--ctl-sp-2) 0 0; padding: 0; }
.attach-item {
    display: flex;
    align-items: center;
    gap: var(--ctl-sp-2);
    padding: 3px var(--ctl-sp-2);
    font-size: var(--ctl-fs-body-sm);
    color: var(--ctl-fg-muted);
    border: 1px solid var(--ctl-border);
    border-radius: var(--ctl-radius);
    background: var(--ctl-bg-elevated);
}
.attach-item + .attach-item { margin-top: 3px; }
.attach-item-icon { color: var(--ctl-fg-subtle); }
.attach-item-name { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--ctl-fg); }
.attach-item-size { flex: 0 0 auto; font-size: var(--ctl-fs-caption); }
.attach-item-invalid { border-color: var(--ctl-danger); color: var(--ctl-danger-text); }
.attach-item-invalid .attach-item-name { color: var(--ctl-danger-text); }
/* Over the mail budget but within the hard cap: the file sends as a login-free
   link, not a real attachment (#2225). A caution, not an error — the row stays
   valid and submittable. */
.attach-item-maillink { flex: 0 0 auto; font-size: var(--ctl-fs-caption); color: var(--ctl-warning-text); }
.attach-item-remove {
    flex: 0 0 auto;
    padding: 0 2px;
    font-size: var(--ctl-fs-caption);
    line-height: 1;
    color: var(--ctl-fg-subtle);
    background: none;
    border: 0;
    cursor: pointer;
}
.attach-item-remove:hover { color: var(--ctl-danger-text); }
.attach-item-remove:focus-visible { outline: none; box-shadow: var(--ctl-focus-ring); }

/* ── Portal ──
   The requester surface. It inherits whichever sheet config pins (layout.php
   reads app.portal_style / app.portal_scheme — Operator style, OS-followed
   scheme, so this block has to hold up in BOTH light and dark), and the brand
   pass below adds
   what tokens cannot express on their own: the display-type treatment, a warm
   ground the white Industrial --ctl-bg does not provide, the pill exception for
   chips, and the softening layer documented on .portal-body. */

/* Scrolling. html/body are viewport-locked for the agent app, where inner flex
   containers scroll. The portal has no such container, so its content below the
   fold was simply unreachable — on a 750px viewport the Submit Request button
   sat past the clip. The portal body is its own scroller. */
.portal-body {
    /* Warm neutral ground (#f6f6f4) under white cards. Industrial Light sets
       --ctl-bg to pure white, which would dissolve every card edge on this
       page; the app's own Operator ground and the login backdrop are both warm,
       so this is the brand value, not a new one. */
    --portal-ground: #f6f6f4;

    /* ── The softening layer is GONE, on purpose ─────────────────────────────
       This scope carried the 8px/12px radius remap (plus the Bootstrap-bridge
       restatement and the two-layer card shadow) from the era when the sharp
       presets set --ctl-radius to 0 and the portal was the one soft surface.
       The 2026-07-31 portal-first pivot promoted every one of those values to
       :root — the portal's geometry IS the system baseline now (UI_STANDARDS
       §4.4) — so restating them here would be the duplicate-mechanism disease
       this file spent four scopes accumulating. If the portal ever needs to
       diverge from the app again, that is a standards conversation, not a
       remap. */
    background-color: var(--portal-ground);
    overflow-y: auto;
    overflow-x: hidden;
}
/* Dark: the ground is simply the SHEET's ground. The literal that used to sit
   here (#0c0c0c) was written before the dark re-grounding moved Operator Dark
   up into the warm #16–#22 class, so it was both colder and darker than the
   sheet it was supposed to belong to — cards at #222220 floated on a neutral
   near-black instead of lifting off a warm one. No dark sheet grounds on white,
   which is the only reason the light value above is a literal at all, so the
   fork has nothing to guard against here: --ctl-bg is right in all three, and
   re-tuning a dark sheet now re-tunes the portal with it. */
html[data-bs-theme="dark"] .portal-body { --portal-ground: var(--ctl-bg); }

/* The portal's cards are the app's cards — the shared .card rule
   (--ctl-radius-card + --bs-card-inner-border-radius derivation) is the one
   statement of what a card is; the per-scope override that used to live here
   retired with the softening layer above. */

/* The shared .table-responsive rule forces overflow:visible because the agent
   list's column-header dropdowns have to escape it. The portal table has no
   dropdowns, and on a narrow viewport its six columns were spilling under
   .portal-body's own overflow-x:hidden and going simply unreachable — so give
   this one its scroll back, which is what .table-responsive is FOR. */
.portal-container .table-responsive { overflow-x: auto; }

/* A table that is the LAST thing in a card paints its rows straight into the
   card's rounded bottom corners — a square hover tint or settled-row tint on
   the final row would bleed past the curve. Bootstrap rounds
   .card-footer:last-child but has no equivalent for a wrapper. The overflow
   above supplies the clip; this supplies the shape, inset by the card's own
   1px border — the relationship --bs-card-inner-border-radius expresses. */
.portal-container .card > .table-responsive:last-child {
    border-bottom-left-radius: calc(var(--ctl-radius-card) - 1px);
    border-bottom-right-radius: calc(var(--ctl-radius-card) - 1px);
}

.portal-nav { background: var(--ctl-bg-elevated); border-bottom: 1px solid var(--ctl-border); }
.portal-container { max-width: 960px; margin: 0 auto; padding: 2rem 1rem; }

/* Wordmark — Oswald caps behind a short olive bar, the login header's shape
   reduced to nav scale. */
.portal-brand {
    display: inline-flex;
    align-items: center;
    gap: var(--ctl-sp-2);
    font-family: var(--ctl-font-display);
    font-weight: 700;
    font-size: 1.15rem;
    letter-spacing: 0.01em;
    text-transform: uppercase;
    color: var(--ctl-fg);
}
.portal-brand:hover { color: var(--ctl-accent-text); }
.portal-brand-mark { width: 6px; height: 20px; background: var(--ctl-accent); }

/* Nav links — including the wordmark, which is one of them — snapped between
   link and hover ink. Bootstrap transitions .btn but not a bare <a>, and the
   portal nav is all bare anchors. */
.portal-nav a { transition: color var(--ctl-dur-fast); }

/* Page heading — the login's Oswald caps over an olive rule. */
.portal-title {
    font-family: var(--ctl-font-display);
    font-weight: 700;
    font-size: var(--ctl-fs-display-sm);
    line-height: var(--ctl-lh-snug);
    letter-spacing: -0.02em;
    text-transform: uppercase;
    color: var(--ctl-fg);
}
.portal-title::after {
    content: "";
    display: block;
    width: 44px;
    height: 3px;
    margin-top: var(--ctl-sp-2);
    background: var(--ctl-accent);
}

/* Card headers become Lato caption labels — the login's field-label voice. */
.portal-container .card-header h6 {
    font-family: var(--ctl-font-body);
    font-size: var(--ctl-fs-caption);
    font-weight: 700;
    letter-spacing: 0.08em;
    text-transform: uppercase;
    color: var(--ctl-fg-muted);
}

/* Full radius is reserved for chips/pills; structure stays square. */
.portal-chip {
    border-radius: var(--ctl-radius-full);
    padding-inline: var(--ctl-sp-3);
    /* Token-derived, not .btn-sm's rem-relative size — chips ride the scale
       (UI_STANDARDS §11.1 item 2). */
    font-size: var(--ctl-fs-body-sm);
}

.portal-card { transition: border-color var(--ctl-dur), box-shadow var(--ctl-dur); }
/* Hover lift in the same two-layer language as --ctl-card-shadow above: a tight
   contact shadow plus a wide ambient one, both off the scheme's own token, so
   the card appears to rise rather than to gain a hard drop. */
.portal-card:hover {
    border-color: var(--ctl-accent);
    box-shadow: 0 2px 6px var(--ctl-shadow-hover), 0 10px 24px var(--ctl-shadow-hover);
}

/* Row tint on the ticket list snapped on and off; the rest of the surface
   transitions, so this read as the one abrupt thing on the page. Rows only —
   not cells — so the settled-row (.list-row-closed) cell tint is untouched. */
.portal-container .table tbody tr { transition: background-color var(--ctl-dur-fast); }

/* Known-issue strip — informative, not alarming: one line, a hairline box with
   an olive edge, and a per-session dismiss. */
.portal-known-issue {
    display: flex;
    align-items: center;
    gap: var(--ctl-sp-2);
    padding: var(--ctl-sp-2) var(--ctl-sp-3);
    font-size: var(--ctl-fs-body-sm);
    color: var(--ctl-fg);
    background: var(--ctl-bg-elevated);
    border: 1px solid var(--ctl-border);
    border-left: 3px solid var(--ctl-accent);
    border-radius: var(--ctl-radius);
}
.portal-known-issue + .portal-known-issue { margin-top: var(--ctl-sp-2); }
.portal-known-issue-icon { flex: 0 0 auto; color: var(--ctl-accent-text); }
.portal-known-issue-text { flex: 1 1 auto; min-width: 0; }
.portal-known-issue-close {
    flex: 0 0 auto;
    padding: 0 2px;
    font-size: var(--ctl-fs-caption);
    line-height: 1;
    color: var(--ctl-fg-subtle);
    background: none;
    border: 0;
    cursor: pointer;
    border-radius: var(--ctl-radius);
    transition: color var(--ctl-dur-fast);
}
.portal-known-issue-close:hover { color: var(--ctl-fg); }
.portal-known-issue-close:focus-visible { outline: none; box-shadow: var(--ctl-focus-ring); }

/* ── Portal status progress strip ── */
.portal-progress { position: relative; }
.portal-progress-step { position: relative; }
/* connector line between steps (behind the dots) */
.portal-progress-step:not(:first-child)::before {
    content: "";
    position: absolute;
    top: 15px;
    left: -50%;
    width: 100%;
    height: 2px;
    background: var(--ctl-border);
    z-index: 0;
}
.portal-progress-step.done::before,
.portal-progress-step.current::before { background: var(--ctl-accent); }
.portal-progress-dot {
    position: relative;
    z-index: 1;
    width: 30px;
    height: 30px;
    border-radius: 50%;
    display: flex;
    align-items: center;
    justify-content: center;
    font-size: 0.8rem;
    font-weight: 600;
    background: var(--ctl-bg-elevated);
    border: 2px solid var(--ctl-border);
    color: var(--ctl-fg-muted);
}
.portal-progress-step.done .portal-progress-dot {
    background: var(--ctl-accent);
    border-color: var(--ctl-accent);
    color: var(--ctl-accent-fg);
}
.portal-progress-step.current .portal-progress-dot {
    border-color: var(--ctl-accent);
    color: var(--ctl-accent-text);
    box-shadow: 0 0 0 3px rgba(var(--ctl-accent-rgb), 0.15);
}
.portal-progress-step.current .portal-progress-label { font-weight: 600; color: var(--ctl-accent-text); }
.portal-progress-label { color: var(--ctl-fg-muted); }

/* ── Portal cosmetics pass (UI/UX review, Phase A) ──────────────────────────
   Type scale up, branded neutrals in place of stock Bootstrap grays, system
   rows as timeline events, and a pinned reply composer. Everything scoped to
   .portal-body / .portal-container — the agent console never sees these. */

/* The page title steps up one display size. 22px next to the login screen's
   40px treatment read as a card label, not a page identity. */
.portal-title { font-size: var(--ctl-fs-display-md); }

/* Detail-page header: the SUBJECT is the page's identity, so it gets display
   type — deliberately NOT uppercased; transforming a requester's own words to
   caps reads as shouting them back. It is the header's ONLY left-hand element;
   the ticket number moved to the rail, beneath the Details card. */
.portal-ticket-head {
    /* One diameter for every control in this header's right-hand cluster. The
       status pill and the overflow toggle sit shoulder to shoulder, so their
       heights are locked to a single value rather than each falling out of its
       own padding — which is how a full-round pill ended up beside a taller
       rounded SQUARE. Derived, not literal: the pill's own type box (small body
       size at the normal leading) plus its 2px vertical padding, floored at
       26px so the toggle clears the 24px minimum target size (WCAG 2.2 2.5.8) —
       the honest computed box is 22.75px, which does not. Any pinned sheet that
       moves the type scale moves both controls together. */
    --portal-head-ctl: max(26px, calc(var(--ctl-fs-body-sm) * var(--ctl-lh-normal) + 4px));

    display: flex;
    align-items: flex-start;
    justify-content: space-between;
    gap: var(--ctl-sp-3);
}
.portal-ticket-subject {
    font-family: var(--ctl-font-display);
    font-weight: 600;
    font-size: var(--ctl-fs-display-sm);
    line-height: var(--ctl-lh-snug);
    /* No top offset: the subject is now the top-most thing on this side, and it
       must sit level with the status pill opposite it. */
    margin: 0;
    /* `anywhere` (not `break-word`) so an unbroken subject also shrinks this
       flex item's min-content width instead of pushing the pill off the row. */
    overflow-wrap: anywhere;
}
/* The ticket number, on the rail's ground beneath the Details card. Its VOICE is
   the one it wore in the header — small body size, mono, and the global
   --bs-code-color accent on the <code> — because that is the reading the number
   earns: it is the string the requester quotes on the phone, and accent ink is
   what makes it findable at a glance on a page of prose. Only the PLACEMENT
   changed: block-level, because it owns its own line under the card now instead
   of riding a flex header, plus the margin below. The 16px foot restores the
   rail's card rhythm underneath it; the 8px above comes from the Details card's
   mb-2, so the number reads as that card's caption rather than as a rail item
   floating equidistant between two cards. It hangs off the rail's RIGHT edge
   (flex-end) rather than its left: the number is a caption to the card above,
   and the right edge is the one the rail's own cards share with nothing else on
   the page, so the caption reads as belonging to that stack instead of starting
   a new left-hand column beside the thread. Number first, copy button second —
   reading order is untouched, the button just trails at the margin. (The muted
   `color` is the row's fallback ink, carried over from the header rule — the
   number takes --bs-code-color and the button sets its own, so nothing leans on
   it today.) */
.ctl-copy-row {
    display: flex;
    align-items: center;
    justify-content: flex-end;
    gap: var(--ctl-sp-1);
    margin-bottom: var(--ctl-sp-4);
    font-size: var(--ctl-fs-body-sm);
    color: var(--ctl-fg-muted);
}
/* The <code> inside would otherwise take Bootstrap's 0.875em and land under
   the --ctl-fs-caption floor (§11.1 item 2) — the number reads at the row's
   own body-sm size. */
.ctl-copy-row code { font-size: 1em; }
.ctl-copy-btn {
    border: 0;
    background: none;
    padding: 0 2px;
    line-height: 1;
    color: var(--ctl-fg-subtle);
    cursor: pointer;
    border-radius: var(--ctl-radius);
    transition: color var(--ctl-dur-fast);
}
.ctl-copy-btn:hover { color: var(--ctl-accent-text); }
.ctl-copy-btn:focus-visible { outline: none; box-shadow: var(--ctl-focus-ring); }
/* The pill states the height it always implied. inline-block + vertical padding
   left its box at the mercy of the inherited leading, so nothing else could be
   measured against it; inline-flex with an explicit height centres the label and
   makes the value quotable by the control beside it. Falls back to auto height
   outside .portal-ticket-head, so the pill stays usable anywhere. */
.portal-status-pill {
    display: inline-flex;
    align-items: center;
    height: var(--portal-head-ctl, auto);
    padding: 2px var(--ctl-sp-3);
    border-radius: var(--ctl-radius-full);
    background: var(--ctl-bg-inset);
    font-size: var(--ctl-fs-body-sm);
    font-weight: 600;
    white-space: nowrap;
}

/* The overflow toggle, as a CIRCLE of exactly the pill's diameter. Bootstrap's
   .btn-sm padding is what drew it as a taller rounded rectangle beside a
   full-round pill — two different shapes at two different heights, reading as
   two unrelated controls. A fixed square box plus the pill's own
   --ctl-radius-full resolves it: equal sides make 999px a circle, and the
   header's two chrome pieces land in the same shape family.
   It stays a real .btn.btn-outline-secondary in the markup — the dropdown
   toggle, disabled handling and focus behaviour remain Bootstrap's — and it
   joins .portal-chip on the portal's neutral outline remap below, so it speaks
   the same quiet grey as the filter chips rather than stock Bootstrap's.

   The SHAPE is no longer restated here. This used to be a portal-private icon
   button — a second definition of the disc that agreed with the composer's by
   coincidence, with its own line in the coarse-pointer hit group (#1900). The
   button now wears the app's .ctl-icon-btn, which owns the square that rounds
   to a disc, the padding reset and the ≥44px touch pseudo; what is left here
   is the genuine delta — the portal header's control height, re-mapped through
   the shared size variable exactly the way the assistant lens re-scales it,
   and the ring. (--ctl-radius-full went with the shape: on a square box the
   shared 50% draws the identical circle.) */
.ctl-icon-btn--outline {
    --ctl-icon-btn-size: var(--portal-head-ctl, 26px);
    line-height: 1;
    /* The portal's own ring, matching .ctl-copy-btn a few lines up — the
       control it visually replaces in this header. */
    --bs-btn-focus-box-shadow: var(--ctl-focus-ring);
}

/* My Tickets table sheds its `small`/table-light admin density: body-size
   text, taller rows, and a token-voiced header in place of Bootstrap's gray. */
.portal-container .table { font-size: var(--ctl-fs-body); }
.portal-container .table > tbody > tr > td { padding-block: 0.6rem; }
.portal-container .table thead th {
    background: var(--ctl-bg-elevated);
    color: var(--ctl-fg-subtle);
    font-size: var(--ctl-fs-caption);
    font-weight: 600;
    text-transform: uppercase;
    letter-spacing: 0.06em;
    border-bottom: 1px solid var(--ctl-border);
}

/* The subject is a REAL link (keyboard focus, middle-click, context menu) —
   the delegated data-row-url click stays for the rest of the row and already
   ignores clicks that land on an anchor. max-width:0 lets the table squeeze
   the cell so CSS ellipsizes responsively instead of a server-side strimwidth. */
.portal-container .table td.portal-subject { max-width: 0; }
.portal-row-link {
    display: block;
    color: inherit;
    text-decoration: none;
    font-weight: 600;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
}
.portal-row-link:hover { color: var(--ctl-accent-text); }
.portal-row-link:focus-visible { outline: none; box-shadow: var(--ctl-focus-ring); border-radius: var(--ctl-radius); }
/* Settled requests read quieter: the closed-row tint already dims the cells,
   so the subject drops its open-row weight too. */
tr.list-row-closed .portal-row-link { font-weight: 400; }
.portal-row-csat { margin-left: var(--ctl-sp-2); font-size: var(--ctl-fs-caption); }

/* Filter chips leave Bootstrap's outline-secondary gray for token neutrals —
   selected state (btn-primary) is untouched. The ticket header's round overflow
   toggle takes the same remap: one definition of what "quiet outline control"
   means on the requester surface, stated once. */
.portal-chip.btn-outline-secondary,
.ctl-icon-btn--outline.btn-outline-secondary {
    --bs-btn-color: var(--ctl-fg-muted);
    --bs-btn-border-color: var(--ctl-border);
    --bs-btn-hover-color: var(--ctl-fg);
    --bs-btn-hover-bg: var(--ctl-bg-inset);
    --bs-btn-hover-border-color: var(--ctl-border-strong);
    --bs-btn-active-color: var(--ctl-fg);
    --bs-btn-active-bg: var(--ctl-bg-inset);
    --bs-btn-active-border-color: var(--ctl-border-strong);
}

/* Quiet informational strip — the known-issue voice generalized (hairline box,
   olive edge) for notes like "the reopen window has closed", replacing
   alert-secondary's unbranded gray. */
.portal-note {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: var(--ctl-sp-3);
    padding: var(--ctl-sp-2) var(--ctl-sp-3);
    margin-bottom: var(--ctl-sp-3);
    font-size: var(--ctl-fs-body-sm);
    color: var(--ctl-fg-muted);
    background: var(--ctl-bg-elevated);
    border: 1px solid var(--ctl-border);
    border-left: 3px solid var(--ctl-accent);
    border-radius: var(--ctl-radius);
}

/* ═══ Conversation thread kit ════════════════════════════════════════════════
   The chat "conversation kit" the portal ticket thread is built from — rows,
   bubbles, runs, bare-emoji rows, timeline events, day dividers and the fold.
   Named thread-* (the .ctl-collapse precedent for shared vocabulary) because it
   is SURFACE-AGNOSTIC: the portal renders it today, the agent ticket view's
   Activity section is next, and /chat is the long-term sweep. Server half:
   templates/partials/thread/conversation.php (+ fold_script.php); client half:
   public/js/uee-thread.js. Change a class in one, change all three.
   Genuinely portal-only chrome (hero fields, list rows, rail cards, .portal-note,
   .portal-reply, .portal-request-body) stays in the portal blocks around this. */
/* System rows ("Request reopened…") are the ticket's bookkeeping, not somebody
   speaking — they render as centered timeline events, not speech bubbles. */
.thread-event {
    display: flex;
    align-items: center;
    justify-content: center;
    gap: var(--ctl-sp-2);
    margin-bottom: var(--ctl-sp-3);
    font-size: var(--ctl-fs-body-sm);
    color: var(--ctl-fg-muted);
    text-align: center;
}
.thread-event .bi { color: var(--ctl-fg-subtle); }
/* A tool call that FAILED (#2021). Error-TONED, not an alarm: these lines are
   tombstone-class furniture, so the whole line — sentence and glyph together —
   takes the danger text hue rather than a filled badge. Paired with the warning
   glyph and the "— failed" suffix the JS applies, so the state survives a
   screenshot and reads for a colourblind viewer (UI_STANDARDS §9). */
.thread-event-failed,
.thread-event-failed .bi { color: var(--ctl-danger-text); }

/* Speech bubbles: one class, used by BOTH the server template and the
   live-append builder in uee-thread.js, so the two renders cannot
   drift. Body-size text and real padding — conversation is the page's point. */
.thread-bubble {
    /* Shape: a ONE-LINE message renders as a PILL, a wrapped one as a rounded
       block — and nothing measures anything to decide which.

       The radius is stated as half the height of a single-line bubble. At
       exactly that value the two corner arcs on each vertical side meet in the
       middle, which IS a pill; add a second line and the height grows while the
       radius does not, so the sides flatten progressively and a tall bubble
       reads as a rounded rectangle. /chat reaches the same shape with a blunter
       instrument — .chat-bubbles .chat-msg-body is a flat 16px against a ~38px
       single line, near-pill but never quite — so this is chat's shape
       language, stated honestly rather than eyeballed. Chat's other move, the
       4px corner facing a sibling in a grouped run, DOES have a counterpart
       here now that the portal groups runs too — see .thread-run-above /
       .thread-run-below below, which state it as var(--ctl-radius) rather than
       as a literal. (It had none while every portal message carried its own
       avatar and author/time line: every bubble was chat's standalone case,
       which chat rounds uniformly too.)

       What NOT to use: var(--ctl-radius-full). The UA clamps 999px to half the
       box's SHORTEST side, so a three-line bubble would come out a stadium —
       full-round ends on a tall block, the one shape this rule exists to avoid.

       The maths is --thread-line-h, declared on .thread-msg-col above: the
       border-box height of a ONE-LINE bubble (the line box, both paddings, both
       hairlines). It lives on the column rather than here because a second shape
       in the same column — .thread-emoji — is sized from the same number, and
       "as tall as one line of speech" has to be stated once or the two drift.
       line-height below is declared THROUGH the same --thread-bubble-lh that
       number is computed from, so the radius can never disagree with the text it
       is wrapped around. */
    border: 1px solid var(--ctl-border);
    border-radius: calc(var(--thread-line-h) / 2);
    /* Inline padding takes one step up the ramp now that the ends are curved:
       at --ctl-sp-3 a one-word reply sat inside the arc and read cramped. Block
       padding is untouched — it is half of what sets the pill's height. */
    padding: var(--ctl-sp-2) var(--ctl-sp-4);
    font-size: var(--ctl-fs-body);
    line-height: var(--thread-bubble-lh);
    background: var(--ctl-bg-elevated);
}
/* The VIEWER's own messages. On /portal/ticket/{id} the viewer is the
   requester, so every reply they send carries this tint and rides the
   right-hand side, exactly as /chat's .chat-msg.is-own does. (This rule used to
   be named for the AGENT and tinted the desk's replies over on the right —
   which reads as "the agent is me" to anyone who has ever used a chat app.) The
   tint is the portal's own accent token rather than chat.css's
   --chat-own-bubble because this surface is themed by the six --ctl-* sheets;
   the CONVENTION is shared, the palette is local.

   The tint rides on an OPAQUE base rather than being a translucent background
   on its own. Both bubbles used to sit on a white card, where an 8% accent over
   the card was the whole effect; on the bare portal ground (a warm #f6f6f4)
   the same translucent fill composited against the ground instead and the two
   sides drifted towards each other. Stating the surface token underneath and
   painting the tint as a background-image layer over it keeps the bubble
   reading identically whatever it is laid on — and keeps both values tokens. */
.thread-bubble-own {
    background-color: var(--ctl-bg-elevated);
    background-image: linear-gradient(
        rgba(var(--ctl-accent-rgb), 0.10),
        rgba(var(--ctl-accent-rgb), 0.10)
    );
    border-color: rgba(var(--ctl-accent-rgb), 0.25);
}

/* The message column beside the avatar. It SHRINKS to its content (no
   flex-grow) and clamps at 80%, so a two-word reply is a two-word bubble and
   the two sides visibly start at opposite edges — the whole point of a
   two-sided thread. With flex-grow-1 every bubble was exactly 80% wide
   whoever sent it, which left the sides nearly overlapping and the
   orientation legible only from the avatar. min-width:0 keeps a long
   unbreakable body wrapping inside the clamp instead of blowing it out. */
.thread-msg-col {
    max-width: 80%;
    min-width: 0;

    /* The two metrics the column's contents share.
       --thread-bubble-lh is the bubble's line-height. --thread-line-h is the
       border-box height of a bubble containing exactly one line of it: the line
       box (lh × the body size — stated as the TOKEN rather than as 1em, so it
       resolves the same on an element whose font-size is not the body size),
       both paddings, both hairlines. The compact density sheet's 13.5px body is
       therefore tracked without a fork.
       It is declared HERE, on the parent, because two different shapes are sized
       from it: .thread-bubble's pill radius (half of it) and .thread-emoji's
       type size (all of it). */
    --thread-bubble-lh: 1.55;
    --thread-line-h: calc(
        var(--thread-bubble-lh) * var(--ctl-fs-body) + var(--ctl-sp-2) * 2 + 2px
    );
}
/* Far-lane rows are packed to the right by flex-row-reverse, so the
   author/time line hugs the same edge as the bubble it labels. SIDE, not
   own-ness: which rows ride the far lane is the calling surface's role
   mapping ($threadSide — the portal seats the requester there, the agent
   page seats the whole desk), while the tinted .thread-bubble-own stays the
   VIEWER's alone. The two coincide on the portal, which is why this class
   carried the bubble's own- name until the agent surface split them. */
.thread-row-far .thread-msg-meta { justify-content: flex-end; text-align: right; }

/* ── Runs: three messages in a row are ONE turn ───────────────────────────────
   Somebody who sends three messages back to back has taken one turn, and every
   chat surface reads it that way: the first message carries the face and the
   author/time line, the follow-ons sit tight underneath wearing nothing but
   their bubbles. Which rows are follow-ons is decided in ONE place —
   templates/portal/ticket.php's pre-pass, mirrored for a live-arriving reply by
   portal-ticket-realtime.js — and lands here as two classes:

     .thread-run-above   this row continues the run above it
     .thread-run-below   a run sibling follows this one

   The chrome is still RENDERED on a follow-on and hidden here rather than
   omitted from the markup, the same contract /chat holds (.is-continuation):
   a row's run-ness is a property of the flow around it, and the flow can change
   after the row was built — a live append makes the row above it a
   .thread-run-below, a retraction can dissolve a run entirely. Hiding is a
   class toggle; un-omitting is a re-render. */

/* The FACE goes; the GUTTER stays. The avatar column's width is what keeps
   every bubble in a run starting on the same vertical line — drop the column
   and the follow-ons slide under the avatar, which reads as a different
   speaker. height:0 keeps a short bubble from being padded out to the avatar's
   height by a face nobody can see. */
.thread-run-above .thread-msg-avatar { visibility: hidden; height: 0; }

/* Vertical rhythm: a run is one turn, so its rows close up to the same 2px
   hairline .thread-media-joined uses between a bubble and its picture, while
   mb-3 still separates one turn from the next.

   The two !importants below are not a shortcut, they are the only way through:
   both of these elements wear a Bootstrap UTILITY for the property being
   overridden (.mb-3 → margin-bottom, .d-flex → display), and every Bootstrap 5
   utility ships !important of its own. No amount of specificity beats that —
   only another !important does. The alternative is emitting the utilities
   conditionally, which moves a presentation decision into two renderers (the
   template AND the live builder) to avoid one declaration in the stylesheet.
   Kept as narrow as possible: two properties, on two classes nothing else
   uses. */
.thread-run-above .thread-msg-meta { display: none !important; }
.thread-msg-row.thread-run-below { margin-bottom: 2px !important; }

/* Corner shaping, /chat's Google-style move (.chat-msg-body's 4px) restated in
   this surface's tokens. Only the corners at the OUTER ends of a run keep the
   pill; the ones facing a sibling give up their radius to var(--ctl-radius) —
   the SAME token .thread-bubble-joined hands the image seam, because it is the
   same statement ("these two shapes are one message") and it must collapse to
   square in the sharp presets with everything else.

   Which corner is the speaker's own is which lane the row is in: an own row is
   flex-row-reverse, so its bubble hugs the RIGHT edge and its right corners are
   the ones the run runs down. (A message whose tail is a picture needs no rule:
   .attachment-thumb is already drawn at var(--ctl-radius).)

   The image seam below reaches for the SAME corner on the same side at the same
   token, so a mid-run message that carries a picture asks two rules for its
   bottom-outside corner and gets one answer. Every combination composes. */
.thread-run-above:not(.thread-row-far) .thread-bubble { border-top-left-radius: var(--ctl-radius); }
.thread-run-above.thread-row-far      .thread-bubble { border-top-right-radius: var(--ctl-radius); }
.thread-run-below:not(.thread-row-far) .thread-bubble { border-bottom-left-radius: var(--ctl-radius); }
.thread-run-below.thread-row-far      .thread-bubble { border-bottom-right-radius: var(--ctl-radius); }

/* ── A reaction is not a paragraph ───────────────────────────────────────────
   A message that is nothing but one to three emoji wears no bubble at all: no
   border, no fill, no padding, no clamp. It is drawn large, straight on the
   portal's ground, the way every chat app the requester has ever used draws
   one — a hairlined, tinted, pill-cornered box around "🎉" is a paragraph's
   worth of chrome around a reaction, and it is the same argument .thread-media
   already makes for a photo sent with no caption.

   WHICH messages qualify is decided in exactly two places that must agree:
   Support\EmojiOnly (the server) and emojiOnly() in
   public/js/uee-thread.js (a reply that arrives without a reload).
   Both count grapheme CLUSTERS from one shared grammar, so a skin-toned thumb
   and a three-person ZWJ family each count as ONE emoji.

   SIZE is --thread-line-h, the same number .thread-bubble's pill radius is half
   of: the emoji stands exactly as tall as a one-line bubble, which is what makes
   it read as large-but-still-a-message rather than as a decoration, and it means
   the row takes the SAME vertical space it would have taken with a bubble — the
   run rhythm above and below is untouched. Sized from the token, so it tracks
   the density presets with everything else.

   line-height: 1 pins the box to exactly that height (the emoji font's own line
   box is taller than its ink and would otherwise pad the row out). text-align
   follows the row's lane, the same move .thread-msg-meta and .thread-media make:
   the column is only shrink-to-fit until the author/time line above makes it
   wider, and a right-hand message whose emoji sat at the left edge of its own
   column would drift away from the face that labels it. */
.thread-emoji {
    font-size: var(--thread-line-h);
    line-height: 1;
}
.thread-row-far .thread-emoji { text-align: right; }

/* The corner facing a BARE emoji keeps its pill.
   A squared corner is a seam: it exists so two bubbles read as one continuous
   shape. There is no bubble on an emoji row, so squaring the corner that faces
   one flattens an arc against nothing — the same "rendering accident" the
   .thread-bubble-joined note describes from the other direction. The run itself
   is untouched (the emoji row is still one turn with its neighbours, still drops
   its avatar and author line as a follow-on, still closes the vertical gap) —
   only the corner shaping backs off.

   The row-after case is a plain sibling selector. The row-BEFORE case needs
   :has(), and is written as an OVERRIDE of the rules above rather than as a
   :not() on them: where :has() is unsupported the declaration is simply dropped
   and that corner stays squared — today's behaviour — instead of the whole run
   losing its corner shaping. */
.thread-row-emoji + .thread-run-above:not(.thread-row-far) .thread-bubble { border-top-left-radius: calc(var(--thread-line-h) / 2); }
.thread-row-emoji + .thread-run-above.thread-row-far      .thread-bubble { border-top-right-radius: calc(var(--thread-line-h) / 2); }
.thread-run-below:has(+ .thread-row-emoji):not(.thread-row-far) .thread-bubble { border-bottom-left-radius: calc(var(--thread-line-h) / 2); }
.thread-run-below:has(+ .thread-row-emoji).thread-row-far      .thread-bubble { border-bottom-right-radius: calc(var(--thread-line-h) / 2); }

/* ── Pictures are not speech ─────────────────────────────────────────────────
   An image attached to a reply renders OUTSIDE the bubble. A bubble is the
   shape of something somebody SAID; a photo is the thing itself, and wrapping
   it in a tinted, hairlined, pill-cornered box put a frame around a frame and
   made every screenshot look like it had been quoted. Sent with a caption the
   picture hangs off the bottom edge of the bubble that carries it; sent alone
   it IS the message and no bubble is drawn at all. Non-image files stay inside
   the bubble with their download rows — a filename is text, and text belongs in
   the thing that holds text.

   The block packs to the row's own edge. Its parent column shrinks to content,
   so a lone 120px thumbnail would otherwise sit at the column's start, which on
   a far-lane (right-hand) row is its LEFT — the picture would drift away from the
   avatar and the author line that label it. */
.thread-media { margin-top: var(--ctl-sp-2); }
.thread-row-far .thread-media > div { justify-content: flex-end; }

/* The seam, when a bubble sits above the picture — and the seam has a SIDE.

   .thread-bubble's radius is HALF a single line's height, so a one-line caption
   is a pill — and a pill resting on a square-shouldered image leaves a crescent
   of bare ground at the joint, which reads as a rendering accident rather than
   as one message. So the shapes give up the radius AT the joint and keep it
   everywhere else, and the gap closes to a 2px hairline — the same "these two
   belong together" move chat surfaces make for a grouped run of messages from
   one sender.

   Which is exactly ONE of the bubble's two bottom corners. The row packs the
   bubble and the picture to the SAME edge — the right on an own row
   (flex-row-reverse), the left on the agent's — and that edge is flush to 0px
   (#1789 measured it). The other side is ragged by construction: the bubble is
   as wide as its words, the picture is a 120px thumbnail, and on a wrapping
   strip of three there is no single corner there at all. Nothing meets, so
   nothing is jointed, so nothing has to give up its shape. Squaring both
   corners stated the seam twice — once where it was true and once where it was
   not — and the result read as a generic square-bottomed box rather than as a
   message hugging its own edge.

   So: the OUTSIDE bottom corner tightens; the INSIDE one keeps the full pill.
   That asymmetry is the whole point — it is what makes the joint read as
   deliberate rather than as the bubble having been cropped, and it says which
   side of the thread the message belongs to in the shape itself.

   Only the BUBBLE needs a rule. .attachment-thumb is already drawn at
   var(--ctl-radius) (8px — the style-invariant baseline since the 2026-07-31
   pivot) on all four corners — which IS the value the bubble drops to — so
   the outside end of the joint agrees by construction, in every preset,
   without either end naming a literal.

   Composition with runs: the corner named here is the SAME bottom-outside
   corner .thread-run-below tightens, at the same token. A mid-run message that
   carries a picture therefore takes one value from two rules and no conflict,
   and the run's other corner (.thread-run-above's top-outside) is untouched by
   anything down here. The two treatments are independent in every combination
   of {far | near} × {run head | middle | tail | standalone} × {picture | no
   picture}. */
.thread-msg-row:not(.thread-row-far) .thread-bubble-joined { border-bottom-left-radius: var(--ctl-radius); }
.thread-msg-row.thread-row-far       .thread-bubble-joined { border-bottom-right-radius: var(--ctl-radius); }
.thread-media-joined { margin-top: 2px; }

/* Day dividers inside the thread. The rules are short fixed strokes hugging
   the date, NOT full-width rules: across a ~600px thread column a flex-grown
   line reads as a section break between messages rather than a quiet date
   stamp. justify-content centres date + strokes as one unit. */
.thread-day-divider {
    display: flex;
    align-items: center;
    justify-content: center;
    gap: var(--ctl-sp-3);
    margin: var(--ctl-sp-3) 0;
    font-size: var(--ctl-fs-caption);
    letter-spacing: 0.06em;
    text-transform: uppercase;
    color: var(--ctl-fg-subtle);
}
/* The meta line's markup carries Bootstrap's .small (0.875em — an off-scale
   13.125px); the token wins here by document order so both the server render
   and the uee-thread.js live-append sit on the scale (§11.1 item 2). */
.thread-msg-meta { font-size: var(--ctl-fs-body-sm); }
.thread-day-divider::before,
.thread-day-divider::after { content: ""; flex: 0 0 var(--ctl-sp-5); height: 1px; background: var(--ctl-border); }
/* The rule above states display:flex, and an author declaration beats the UA
   stylesheet's [hidden]{display:none} no matter how specific the latter is — so
   the fold-boundary divider (partials/thread/conversation.php) would render even
   while hidden. State it here or the un-folded page shows the same date twice. */
.thread-day-divider[hidden] { display: none; }

/* ── The folded head of a long thread ───────────────────────────────────────
   Everything older than the newest five MESSAGES lives in .thread-earlier
   (events and the resolution line ride along free, to a ceiling of twelve items),
   which the page script closes on load; .thread-more is the one control
   that opens it, sitting at the TOP of the conversation where the hidden
   content is. Both are rendered OPEN + [hidden] respectively, so no-JS gets the
   whole conversation and no dead control — the [hidden] rules below are stated
   for the same reason the divider's is, and because a later `display:` on
   either name must not silently un-hide a fold. */
.thread-earlier[hidden] { display: none; }
.thread-earlier:focus { outline: none; }   /* programmatic focus target, not a control */
/* ── Opening the fold is an UNCOVER ─────────────────────────────────────────
   The region used to go from [hidden] to its full height in one frame — a
   fortnight of conversation appearing between two paints. Animating that HEIGHT
   fixed the snap and introduced a worse read: the messages were laid out inside
   a box that was still growing, so they slid up the screen welded to the bottom
   of it.

   So the page script commits the whole settled layout on the click — region
   open, scroll corrected — and animates only a picture of it. The three hooks
   below are that picture, and the script owns all three for the length of one
   transition:

     .is-revealing   clips the region from the TOP — all of it at first — and
                     animates that inset back to zero in step with the page
                     lifting off it. This is the occluder: clipped pixels are not
                     painted, so no backing colour is involved and light and dark
                     need no fork. The region's own height is never touched,
                     which is why nothing here can outlive the animation as a cap
                     on a LIVE thread.
     .thread-fold-lift
                     everything that MOVED — nav, header, request card, rail —
                     starts translated back down to where the reader last saw it
                     and slides up to its settled place. Transform only: no
                     layout, so the reader can scroll through the animation and
                     it stays true.
     .thread-fold-retiring
                     the control and the fold-line date are [hidden] from the
                     first frame (that is the end state, and it is never given
                     up); this only keeps them PAINTED while the page lifts, out
                     of flow so the layout underneath is the settled one. The
                     space those two vacate is the one strip the lift cannot
                     cover, so without this they blink out and leave a hole
                     opening under the reader's cursor in the click frame; with
                     it they ride up and dissolve, and the first frame is the
                     page they clicked. The display restatements are the whole
                     trick — [hidden] stays on the element the entire time.

   DURATION IS NOT A CONSTANT HERE, and that is the one thing about this block
   worth remembering. A fold can be four short replies or a fortnight of them,
   so the page above lifts anywhere from ~350px to well past two screens — and
   at a fixed --ctl-dur-slow the long one covered 1,466px in 300ms, peaking over
   11,000 px/s. Same clock as the short one, four times the speed, and it read
   as an instant snap rather than a reveal. So the script measures the lift and
   hands every rule below ONE duration through --thread-fold-dur: one screen of
   travel per --ctl-dur-slow, never quicker than --ctl-dur-slow and never slower
   than twice it. Every mover and the clip read the same variable, so they stay
   welded however long the reveal turns out to be; the fallback keeps this
   sheet honest on its own if the script never runs.

   ease-out is the app's own voice for an entrance this large, and it is what
   front-loads the travel — which is exactly why the distance has to buy time.
   Reduced motion never gets here at all: the script branches first and takes
   the instant, scroll-anchored path (§4.4's global rule zeroes the duration as
   a second belt, but the branch is the contract). */
.thread-earlier.is-revealing {
    transition: clip-path var(--thread-fold-dur, var(--ctl-dur-slow)) ease-out;
}
.thread-fold-lift { transition: transform var(--thread-fold-dur, var(--ctl-dur-slow)) ease-out; }
/* The absolute positions are measured and set by the script; this is the
   containing block they are measured against. */
.thread-fold-host { position: relative; }
.thread-fold-retiring {
    position: absolute;
    left: 0;
    right: 0;
    /* Out of flow, so the block margins that placed these in the thread would
       now only offset them from the coordinates the script measured. The AUTO
       side margins survive, and they are what still centres the pill. */
    margin-top: 0;
    margin-bottom: 0;
    pointer-events: none;
    transition: transform var(--thread-fold-dur, var(--ctl-dur-slow)) ease-out,
                opacity calc(var(--thread-fold-dur, var(--ctl-dur-slow)) * 0.6) ease-out;
}
/* Stated per element rather than as .thread-fold-retiring[hidden], so the win
   over each one's own [hidden] rule is specificity and not source order. */
.thread-more[hidden].thread-fold-retiring,
.thread-day-divider[hidden].thread-fold-retiring { display: flex; }
/* Chip vocabulary, borrowed from .portal-filing-chip: a quiet pill on the
   thread's own ground, centred like a day divider because that is the other
   thing that punctuates this column. */
.thread-more {
    display: flex;
    align-items: center;
    gap: var(--ctl-sp-2);
    /* A block-level flex box fills its line, and auto margins on a full-width
       box centre nothing — the pill would stretch the whole thread column.
       fit-content makes it hug its own label so the auto margins can bite. */
    width: fit-content;
    margin: 0 auto var(--ctl-sp-3);
    padding: 2px var(--ctl-sp-3);
    border: 1px solid var(--ctl-border);
    border-radius: var(--ctl-radius-full);
    background: var(--ctl-bg-elevated);
    color: var(--ctl-fg-muted);
    font-size: var(--ctl-fs-body-sm);
    cursor: pointer;
}
.thread-more[hidden] { display: none; }
.thread-more:hover { border-color: var(--ctl-accent); color: var(--ctl-accent-text); }
.thread-more:focus-visible { outline: none; box-shadow: var(--ctl-focus-ring); }

/* ── Live-surface capabilities (opt-in) ──────────────────────────────────────
   Three affordances a live chat surface can put on a kit row: reaction chips,
   the hover action pill, and the "edited" stamp. Kit vocabulary — every
   surface that opts in draws them identically — but with NO server-partial
   twin yet: only uee-thread.js (reactions(), hoverActions(), paint()'s
   opts.edited) emits these classes, /chat is the first adopter, and the
   portal/agent ticket threads render byte-identically to before they existed.
   ThreadKitLiveCapabilitiesTest pins the JS↔CSS vocabulary and the opt-outs. */

/* Reaction chips: a wrapping strip under the message's anatomy, packed to the
   row's own edge like every other shape in the column. The DECORATED chip is
   the viewer's own — the same invariant the tinted bubble holds, in the same
   tint family (accent wash over the neutral ground). */
.thread-reactions {
    display: flex;
    flex-wrap: wrap;
    gap: var(--ctl-sp-1);
    margin-top: var(--ctl-sp-1);
}
.thread-row-far .thread-reactions { justify-content: flex-end; }
.thread-reaction {
    display: inline-flex;
    align-items: center;
    /* Tight to the face it counts — em-scaled, so it tracks the emoji size. */
    gap: 0.15em;
    /* The emoji reads at MESSAGE size — the same --ctl-fs-body the bubble's
       own text (and any emoji in it) is set in — so a reaction is the same
       glyph at the same weight as the thing it reacts to. The chip grows
       TALLER to hold it (min-height, with line-height:1 pinning the box to
       the ink) but no wider: the inline padding stays put, and the count
       keeps its caption size beside the larger face. */
    padding: 0 var(--ctl-sp-2);
    min-height: 26px;
    border: 1px solid var(--ctl-border);
    border-radius: var(--ctl-radius-full);
    background: var(--ctl-bg-elevated);
    font-size: var(--ctl-fs-body);
    line-height: 1;
    cursor: pointer;
}
.thread-reaction:hover { border-color: var(--ctl-accent); }
.thread-reaction:focus-visible { outline: none; box-shadow: var(--ctl-focus-ring); }
.thread-reaction.is-mine {
    background-image: linear-gradient(
        rgba(var(--ctl-accent-rgb), 0.10),
        rgba(var(--ctl-accent-rgb), 0.10)
    );
    border-color: rgba(var(--ctl-accent-rgb), 0.25);
}
.thread-reaction-count {
    font-size: var(--ctl-fs-caption);
    color: var(--ctl-fg-muted);
}
.thread-reaction.is-mine .thread-reaction-count { color: var(--ctl-accent-text); }

/* The hover action pill: invisible until the row is hovered OR holds keyboard
   focus (:focus-within is what keeps an invisible-idle affordance reachable
   without a pointer). A PILL — fully rounded, like every floating affordance
   riding a message — seated ON the message itself: it straddles the bubble's
   top edge with a few pixels of overlap, flush to the column's inner end,
   which needs the column as its containing block; position:relative with no
   offsets changes nothing else about the box (the agent surface already
   relies on exactly this, scoped, for its run-follow-on actions).

   The seat's arithmetic: the bubble starts one meta line down inside the
   column (.small type at normal leading plus its mb-1), and the pill is
   ~30px tall (24px buttons + 2px padding + hairlines) — top places the
   pill's bottom 8px past the bubble's top. A follow-on row in a run shows
   no meta line, so its bubble sits at the column's top and the pill rides
   from -22px for the same 8px overlap. A couple of px of drift across the
   density presets is invisible in an overlap. */
.thread-msg-col { position: relative; }
.thread-hover-actions {
    position: absolute;
    top: calc(0.875 * var(--ctl-fs-body) * var(--ctl-lh-normal) + var(--ctl-sp-1) + 8px - 30px);
    inset-inline-end: 0;
    display: flex;
    gap: 2px;
    padding: 2px;
    border: 1px solid var(--ctl-border);
    border-radius: var(--ctl-radius-full);
    background: var(--ctl-bg-elevated);
    box-shadow: 0 2px 6px var(--ctl-shadow);
    opacity: 0;
    pointer-events: none;
    transition: opacity var(--ctl-dur-fast) ease;
    z-index: 2;
}
.thread-run-above .thread-hover-actions { top: -22px; }
.thread-msg-row:hover .thread-hover-actions,
.thread-msg-row:focus-within .thread-hover-actions {
    opacity: 1;
    pointer-events: auto;
}
.thread-hover-action {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    min-width: 26px;
    min-height: 24px;
    padding: 0 var(--ctl-sp-1);
    border: 0;
    border-radius: var(--ctl-radius-full);
    background: none;
    color: var(--ctl-fg-muted);
    font-size: var(--ctl-fs-body-sm);
    line-height: 1;
    cursor: pointer;
}
.thread-hover-action:hover { background: var(--ctl-bg-hover); color: var(--ctl-fg); }
.thread-hover-action:focus-visible { outline: none; box-shadow: var(--ctl-focus-ring); }

/* The "edited" stamp: a whisper under the anatomy, on the row's own edge. A
   sibling of the bubble rather than a child because an edited message can be
   a bare emoji or a lone picture — no bubble to live in. */
.thread-edited {
    display: block;
    margin-top: 2px;
    font-size: var(--ctl-fs-caption);
    color: var(--ctl-fg-subtle);
}
.thread-row-far .thread-edited { text-align: right; }

/* ── "Show quoted text" — the folded mail history (#2284) ───────────────────
   An emailed reply arrives with the whole conversation quoted under it. The
   server splits the two (Support\QuotedReply) and the kit renders the history
   inside a native <details> under the new text — server partial and
   uee-thread.js's quotedBlock() emit the same markup, so a row that lands live
   folds exactly like the row a reload draws.

   The control is DELIBERATELY almost nothing: an ellipsis pill on the bubble's
   own ground, at caption size, the same quiet vocabulary .thread-more speaks in.
   It is punctuation saying "there is more here", not a button competing with
   the message above it — which is the whole point, because on a long thread
   every single row will be wearing one.

   FOUR SURFACES, ONE FAMILY (#2294). This block is declared inside the kit's
   section because the kit is where it was born, but it is NOT kit-scoped and
   must not become so: the side-conversations card and the ticket peek panel
   draw email bodies without the kit, and both emit this exact markup through the
   shared partials/thread/quoted.php. Every rule below is therefore written to
   stand on its own ground — no .thread-bubble ancestor, no inherited metric —
   and the narrow sidebar needed no variant, because the two rules that matter
   there (max-width + overflow-x on the body) were already carrying a vendor's
   900px signature table inside a speech bubble. A parallel class family for the
   card would have been a second thing to restyle and a second thing to forget. */
.thread-quoted { margin-top: var(--ctl-sp-1); }
/* The default disclosure triangle is the one piece of chrome a marker-less
   design cannot keep: list-style covers Firefox/Chromium, the -webkit- rule
   covers older Safari. Both are stated, because half the fix is a visible
   triangle beside the pill. */
.thread-quoted > summary { list-style: none; }
.thread-quoted > summary::-webkit-details-marker { display: none; }
.thread-quoted-toggle {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    cursor: pointer;
    width: fit-content;
    min-height: 16px;
    padding: 0 var(--ctl-sp-2);
    border-radius: var(--ctl-radius-full);
    background: var(--ctl-bg-hover);
    color: var(--ctl-fg-subtle);
    font-size: var(--ctl-fs-caption);
    line-height: 1.1;
    /* The glyph is U+22EF (⋯), which sits on the text baseline at a weight the
       bubble's own font already carries — no icon font, no image, nothing to
       load before the row can paint. */
    letter-spacing: 1px;
}
.thread-quoted-toggle:hover { color: var(--ctl-fg-muted); }
/* The keyboard's view of it. A <summary> is focusable by default and this is
   the only visible state that says so. */
.thread-quoted-toggle:focus-visible { outline: none; box-shadow: var(--ctl-focus-ring); }
/* The history itself: quieter than the message above it, and marked as quoted
   the way every mail client marks it — one rule down its leading edge. Logical
   properties, so it flips with the row's own direction. */
.thread-quoted-body {
    margin-top: var(--ctl-sp-2);
    padding-inline-start: var(--ctl-sp-2);
    border-inline-start: 2px solid var(--ctl-border);
    color: var(--ctl-fg-muted);
    font-size: var(--ctl-fs-body-sm);
    /* Quoted history is somebody else's markup — a table of order lines, a
       signature image, a 900px-wide layout table. It must never widen the
       bubble it is folded inside. */
    max-width: 100%;
    overflow-x: auto;
    /* …nor the 264px sidebar card, which is the narrowest place this now opens
       (#2294). overflow-x saves a wide CHILD; an unbreakable inline TOKEN — a
       base64 blob or a signed URL in a forwarded footer, which quoted mail is
       full of — has no break opportunity at any width and would still set the
       card's min-content width. Stated here rather than by joining the
       enumerated "Rendered user bodies" family: that contract governs whole
       body surfaces and owes each one a pre/code rule to match, while this is
       one nested container that already carries its own containment. */
    overflow-wrap: anywhere;
}
.thread-quoted-body img { max-width: 100%; height: auto; }

/* The kit's emoji picker — the popover a hover pill's react button opens.
   position:fixed, and public/js/ticket-reactions.js positions it in pure
   viewport coordinates to match; the two facts belong together, which is why
   position is stated HERE and nowhere else (a second declaration in a page
   stylesheet is what put /chat's two openers in different coordinate spaces
   before #1834). Body-parented, so it escapes the thread's overflow clipping.
   Deliberately NOT .chat-emoji-pop: that one is chat's, named for chat, and a
   ticket page reaching into it would make either file unsafe to change. */
.thread-emoji-pop {
    position: fixed; z-index: 25;
    background: var(--composer-pop-bg, var(--ctl-bg-elevated));
    border: 1px solid var(--composer-pop-border, var(--ctl-border));
    border-radius: 8px;
    box-shadow: 0 4px 14px var(--composer-pop-shadow, var(--ctl-shadow));
    padding: 6px; display: flex; flex-wrap: wrap; gap: 3px; max-width: 220px;
}
.thread-emoji-pop button { border: 0; background: none; font-size: 1.15rem; cursor: pointer; padding: 2px 4px; }
.thread-emoji-pop button:hover { background: var(--composer-pop-hover-bg, var(--ctl-bg-inset)); border-radius: 4px; }
.thread-emoji-pop button:focus-visible { outline: none; box-shadow: var(--ctl-focus-ring); border-radius: 4px; }

/* ── The kit's inline message editor (uee-inline-editor.js) ──────────────────
   Edit ONE message in place, on any thread-kit surface. The shapes are chat's
   (.chat-msg-editor, extracted) renamed into the kit's thread-* vocabulary, so
   the agent thread, the portal and /chat all draw one editor.

   The style popover is declared here rather than beside chat's because the
   module owns it now; like .thread-emoji-pop it states position ONCE, and the
   module places it in pure viewport coordinates to match (#1834's lesson). */
.thread-msg-editor { width: 100%; align-self: stretch; }
.thread-msg-editor .thread-rich-input { min-height: 2.5rem; }
/* An editing row needs its column at full measure — the kit column is only as
   wide as its widest bubble, which for a one-word message is no editor. */
.thread-msg-row:has(.thread-msg-editor) .thread-msg-col { width: 80%; }
.thread-editor-wrap {
    background: var(--ctl-bg-inset);
    border: 1px solid var(--ctl-border);
    border-radius: var(--ctl-radius-lg);
    padding: 0.4rem 0.6rem;
}
.thread-editor-wrap:focus-within { border-color: var(--ctl-accent); box-shadow: var(--ctl-focus-ring); }
.thread-rich-input { outline: none; white-space: pre-wrap; word-break: break-word; }
.thread-rich-input.is-empty::before {
    content: attr(data-placeholder);
    color: var(--ctl-fg-subtle);
    pointer-events: none;
}
.thread-editor-tools { display: flex; justify-content: flex-end; gap: 0; }
/* Cancel (grey X) and Save (green check disc) sit TOGETHER on the right — one
   action cluster where the eye already is, not a spread that parks the escape
   hatch a column away. The save green is the FIXED brand green like the
   own-bubble — not the theme accent — with the black-on-green foreground rule
   from UI_STANDARDS §4.5. */
.thread-editor-actions {
    display: flex; justify-content: flex-end; align-items: center; gap: 0.4rem;
    margin-top: 0.35rem;
}
.thread-editor-save {
    border: 0; cursor: pointer;
    width: 36px; height: 36px; border-radius: 50%;
    display: inline-flex; align-items: center; justify-content: center;
    background: var(--mdt-green-500); color: #000; font-size: 1.1rem;
}
.thread-editor-save:hover { background: var(--mdt-green-600); }
.thread-editor-save:disabled { opacity: 0.6; cursor: default; }
.thread-style-pop {
    position: fixed; z-index: 25;
    background: var(--composer-pop-bg, var(--ctl-bg-elevated));
    border: 1px solid var(--composer-pop-border, var(--ctl-border));
    border-radius: var(--ctl-radius-lg);
    box-shadow: 0 4px 14px var(--composer-pop-shadow, var(--ctl-shadow));
    padding: 4px; display: flex; gap: 2px;
}
.thread-style-pop button {
    border: 0; background: none; cursor: pointer;
    width: 32px; height: 32px; border-radius: var(--composer-pop-btn-radius, 6px);
    color: var(--composer-pop-fg, var(--ctl-fg)); font-size: 0.95rem;
    display: inline-flex; align-items: center; justify-content: center;
}
.thread-style-pop button:hover { background: var(--composer-pop-hover-bg, var(--ctl-bg-inset)); }
/* queryCommandState mirror — the caret's live formatting, not a toggle memory. */
.thread-style-pop button.is-active {
    background: var(--composer-active-bg, rgba(var(--ctl-accent-rgb), 0.15));
    color: var(--composer-active-fg, var(--ctl-accent-text));
}
.thread-style-pop button:focus-visible,
.thread-editor-save:focus-visible { outline: none; box-shadow: var(--ctl-focus-ring); }

/* ── Markdown inside a bubble (T.renderMarkdown) ──────────────────────────────
   The vocabulary the kit's markdown renderer emits, for the one author who
   speaks markdown: the AI assistant. It used to be lens-only chrome named
   .ai-* and scoped under .ai-chat-messages, which is precisely why the same
   answer read as prose in the docked panel and as raw `**bold**` on /chat
   (#1858) — the renderer AND its type scale both lived in one surface. Both
   belong to the kit now, so any surface that draws an assistant turn gets the
   typography with it.

   Sizing is relative (em) rather than absolute: a bubble in the 320px-wide lens
   and a bubble on the full /chat page set different base sizes, and prose that
   tracks its container reads correctly in both without either one restating the
   scale. Only the lens's tighter DENSITY is overridden, below. */
.thread-md-table { width: 100%; border-collapse: collapse; margin: 0.4rem 0; font-size: 0.95em; }
.thread-md-table th,
.thread-md-table td { padding: 0.25rem 0.5rem; border: 1px solid var(--ctl-border); text-align: left; }
.thread-md-table th { background: var(--ctl-bg-hover); font-weight: 600; }
.thread-md-list { margin: 0.3rem 0; padding-left: 1.3rem; }
.thread-md-list li { margin-bottom: 0.15rem; }
.thread-md-code {
    background: var(--ctl-bg-inset);
    color: var(--ctl-fg);
    border: 1px solid var(--ctl-border);
    padding: 0.5rem;
    border-radius: 0.4rem;
    font-size: 0.9em;
    overflow-x: auto;
    margin: 0.4rem 0;
    white-space: pre-wrap;
}
.thread-md-inline-code { background: var(--ctl-bg-hover); padding: 0.1rem 0.3rem; border-radius: 3px; font-size: 0.9em; }
/* Headings are <strong> elements, not h1–h6: the renderer keeps the DOCUMENT
   outline out of a chat bubble (a message is not a document section) and states
   the emphasis visually instead. display:block is what makes them read as
   headings rather than as bold words mid-sentence. */
.thread-md-h1 { font-size: 1.15em; display: block; margin-bottom: 0.3rem; }
.thread-md-h2 { font-size: 1.08em; display: block; margin-bottom: 0.25rem; }
.thread-md-h3 { font-size: 1.04em; display: block; margin-bottom: 0.2rem; }
/* Markdown paragraphs. The renderer splits on a blank line, so the rhythm
   between them is stated here rather than inherited from the page's default
   1rem <p> margin, which would double-space a chat answer. Carried on a CLASS
   the renderer stamps, not on `.thread-bubble p` — a bare descendant selector
   would also restyle the <p>s in a human's rich (composer- or email-authored)
   body on every surface the kit draws, which is not this fix's business. */
.thread-md-p { margin: 0 0 0.4rem 0; }
.thread-md-p:last-child { margin-bottom: 0; }
/* Links the renderer emitted. Underlined ALWAYS, not on hover: a chat bubble is
   dense prose and colour alone is not an affordance — a red/green viewer sees
   only the underline, and the assistant's links are the only clickable words in
   the transcript. Colour comes from --ctl-link so the tinted own-bubble and both
   themes are already answered by the token. break-word because a bare URL used
   as its own label is a single unbreakable token that would otherwise push the
   bubble past its clamp. */
.thread-md-link {
    color: var(--ctl-link);
    text-decoration: underline;
    text-underline-offset: 0.15em;
    overflow-wrap: break-word;
}
.thread-md-link:hover,
.thread-md-link:focus-visible { color: var(--ctl-link-hover); }

/* The conversation has NO card and NO height cap: bubbles, timeline events, day
   dividers and the typing indicator sit straight on the portal's warm ground,
   and the thread runs to its natural length with the composer following it in
   document flow.

   What was here before: a flex column capped at a height measured by a script
   in ticket.php and published as a custom property, with #portalComments as an
   inner scroller and the composer pinned to the card's foot. It bought "the
   composer is always above the fold" at the price of a scroller inside the
   page's own scroller — the
   wheel did different things over different pixels, the measurement had to be
   re-run on every resize, and any error in it left the page scrollable by
   exactly the leftover. One page, one scrollbar is the simpler contract, and
   the "land on the newest reply" script is what makes a long thread usable.

   Nothing replaces the rules: the thread is plain block flow now. Only the
   composer's own separation from the last message is worth stating. */
.portal-reply { margin-top: var(--ctl-sp-3); }

/* The original request is prose in its own card above the conversation — not a
   message, so no bubble. The metrics are declared here because the clamp
   measures LINE BOXES: .ctl-collapse's height is N × 1lh with a 1.5em fallback,
   so the line-height the budget assumes has to live beside the text it governs
   (the agent side states the same thing on .ticket-narrative). */
.portal-request-body { font-size: var(--ctl-fs-body); line-height: 1.5; }

/* ── Portal interaction pass (UI/UX review, Phase B) ───────────────────────── */

/* "Needs your reply" strip — the one state that needs the requester's action,
   in the danger voice but at note volume: a hairline card with a red edge, not
   an alarm banner. */
.portal-attention-label {
    font-size: var(--ctl-fs-caption);
    font-weight: 700;
    letter-spacing: 0.08em;
    text-transform: uppercase;
    color: var(--ctl-danger-text);
    margin-bottom: var(--ctl-sp-1);
}
.portal-attention {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: var(--ctl-sp-3);
    padding: var(--ctl-sp-2) var(--ctl-sp-3);
    background: var(--ctl-bg-elevated);
    border: 1px solid var(--ctl-border);
    border-left: 3px solid var(--ctl-danger);
    border-radius: var(--ctl-radius);
    box-shadow: var(--ctl-card-shadow);
}
.portal-attention + .portal-attention { margin-top: var(--ctl-sp-2); }
.portal-attention-text { min-width: 0; }
.portal-attention-subject {
    display: block;
    font-weight: 600;
    color: var(--ctl-fg);
    text-decoration: none;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
}
.portal-attention-subject:hover { color: var(--ctl-accent-text); }
.portal-attention-meta {
    display: block;
    font-size: var(--ctl-fs-body-sm);
    color: var(--ctl-fg-muted);
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
}

/* Chip counts — quiet numerals riding each filter pill. */
.portal-chip-count {
    margin-left: var(--ctl-sp-1);
    font-variant-numeric: tabular-nums;
    opacity: 0.75;
    font-size: 0.85em;
}

/* Searchable category picker (portal create). The native select stays in the
   DOM for no-JS; this is the enhanced face: an input that filters a listbox
   of the same options, grouped by workspace. */
.portal-combobox { position: relative; }
.portal-combobox-list {
    position: absolute;
    inset-inline: 0;
    top: calc(100% + 2px);
    z-index: 20;
    max-height: 280px;
    overflow-y: auto;
    background: var(--ctl-bg-elevated);
    border: 1px solid var(--ctl-border-strong);
    border-radius: var(--ctl-radius);
    box-shadow: var(--ctl-card-shadow);
    padding: var(--ctl-sp-1) 0;
}
.portal-combobox-group {
    padding: var(--ctl-sp-1) var(--ctl-sp-3) 0;
    font-size: var(--ctl-fs-caption);
    font-weight: 700;
    letter-spacing: 0.06em;
    text-transform: uppercase;
    color: var(--ctl-fg-subtle);
}
.portal-combobox-option {
    padding: var(--ctl-sp-1) var(--ctl-sp-3);
    font-size: var(--ctl-fs-body);
    color: var(--ctl-fg);
    cursor: pointer;
}
.portal-combobox-option[aria-selected="true"],
.portal-combobox-option:hover {
    background: rgba(var(--ctl-accent-rgb), 0.12);
    color: var(--ctl-accent-text);
}
.portal-combobox-empty {
    padding: var(--ctl-sp-2) var(--ctl-sp-3);
    font-size: var(--ctl-fs-body-sm);
    color: var(--ctl-fg-muted);
}

/* ── Portal mockup parity (UI/UX review, Phase D) ────────────────────────────
   The compositional layer the review's proposed visuals specified: hero
   omnibox, card-list ticket rows, the two-column detail grid, the describe-
   first filing card. Same scoping discipline as Phases A–C. */

/* Hero fields (omnibox, describe box): accent-edged with a soft glow — the
   mockups' "this is where you start" cue. Glow strengthens on focus. */
.portal-hero-field {
    border-color: var(--ctl-accent);
    box-shadow: 0 0 0 3px rgba(var(--ctl-accent-rgb), 0.14), var(--ctl-card-shadow);
    transition: box-shadow var(--ctl-dur-fast);
}
.portal-hero-field:focus-within {
    box-shadow: 0 0 0 3px rgba(var(--ctl-accent-rgb), 0.28), var(--ctl-card-shadow);
}
.portal-omnibox-kbd {
    font-family: var(--ctl-font-mono);
    font-size: var(--ctl-fs-caption);
    color: var(--ctl-fg-subtle);
    border: 1px solid var(--ctl-border);
    border-radius: 4px;
    padding: 0 6px;
    background: var(--ctl-bg-inset);
}
.portal-topics {
    display: flex;
    flex-wrap: wrap;
    align-items: baseline;
    gap: var(--ctl-sp-1) var(--ctl-sp-2);
    margin-top: var(--ctl-sp-2);
    font-size: var(--ctl-fs-body-sm);
    color: var(--ctl-fg-muted);
}
.portal-topic {
    color: var(--ctl-fg-muted);
    text-decoration: none;
}
.portal-topic:hover { color: var(--ctl-accent-text); }
.portal-topics-all { color: var(--ctl-accent-text); font-weight: 600; text-decoration: none; }

/* Card-list ticket rows — the mockups' replacement for the data table:
   status rail, weighted subject over a meta line, assigned-agent avatar. */
.portal-ticket-row {
    display: flex;
    align-items: center;
    gap: var(--ctl-sp-3);
    padding: var(--ctl-sp-2) var(--ctl-sp-3);
    color: var(--ctl-fg);
    text-decoration: none;
    border-top: 1px solid var(--ctl-border);
    transition: background-color var(--ctl-dur-fast);
}
.portal-ticket-row:first-child { border-top: 0; }
.portal-ticket-row:hover { background: var(--ctl-bg-inset); color: var(--ctl-fg); }
.portal-ticket-row:focus-visible { outline: none; box-shadow: inset var(--ctl-focus-ring); }
.portal-row-rail {
    flex: 0 0 auto;
    width: 6px;
    /* Tracks the row instead of sitting at a fixed 34px: rows carrying a
       preview line are taller, and a rail that stopped short of the text read
       as a stray tick rather than a status edge. min-height keeps the old look
       on the shortest rows. */
    align-self: stretch;
    min-height: 34px;
    border-radius: 3px;
    background: var(--ctl-border-strong);
}
.portal-row-rail[data-rail="open"] { background: var(--ctl-accent); }
.portal-row-rail[data-rail="waiting"] { background: var(--ctl-danger); }
.portal-row-main { flex: 1 1 auto; min-width: 0; }
.portal-row-subject {
    display: block;
    font-weight: 600;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
}
.portal-row-meta {
    display: block;
    font-size: var(--ctl-fs-body-sm);
    color: var(--ctl-fg-muted);
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
}
/* The inbox preview line — the newest customer-facing thing that happened.
   One tone below the meta line so it never competes with the subject, and
   clipped by the platform (text-overflow) on top of the server-side truncation
   that already capped what shipped. Single line, always. */
.portal-row-snippet {
    display: block;
    font-size: var(--ctl-fs-body-sm);
    color: var(--ctl-fg-subtle);
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
}
/* Settled rows read quieter — same voice as the old cell tint. */
.portal-ticket-row.list-row-closed .portal-row-subject { font-weight: 400; color: var(--ctl-fg-muted); }
.portal-row-avatar { flex: 0 0 auto; }
/* Section heading — the page title's voice one step down, for "My Tickets"
   sitting cardless above the list. Its olive rule is the page h1's alone. */
.portal-title-sm { font-size: var(--ctl-fs-display-sm); }
.portal-title-sm::after { content: none; }

/* Two-column detail: conversation owns the left, bookkeeping rides a quiet
   right rail. Stacks below 992px. */
/* The single column is stated as minmax(0, 1fr) rather than left implicit, and
   the two items carry min-width:0, for the reason .thread-msg-col already
   carries it one block down: a grid item's default `min-width: auto` floors it
   at its own MIN-CONTENT width, so one unbreakable child sets the width of the
   whole column and nothing downstream can take it back.

   The two-column rule below always said minmax(0, …), so the desktop TRACK was
   already safe and the stack trace could only paint outside its card. The
   implicit single-column track below 992px was not: measured in headless
   Chrome, .portal-detail-main resolved to 1461px inside a 900px container —
   the layout itself 561px wider than the page, not merely overflowing it.

   Belt AND braces on purpose. minmax(0, 1fr) caps the TRACK; min-width:0 caps
   the ITEM. The wrap contract above is what makes today's stack trace behave;
   these two are what make the NEXT wide child — a table, a data: URI, an
   80-column log paste — unable to widen this page again whatever it is. */
.portal-detail-grid {
    display: grid;
    grid-template-columns: minmax(0, 1fr);
    gap: var(--ctl-sp-4);
    align-items: start;
}
.portal-detail-main,
.portal-detail-rail { min-width: 0; }
@media (min-width: 992px) {
    /* The conversation takes every pixel the rail does not need. The rail is
       a fixed 264px — its content is short labels, dates and one avatar, so a
       proportional share just padded it out and squeezed the thread. */
    .portal-detail-grid { grid-template-columns: minmax(0, 1fr) 264px; }
}

/* Commitment rows: the icon rides the FIRST line of a wrapping sentence.
   align-items:start alone pins it to the top of the line BOX, which sits a
   little above the glyph's optical centre — matching the row's line-height
   makes the icon share that first line rather than float over it. */
.portal-commit-row > .bi {
    flex: 0 0 auto;
    line-height: inherit;
}

/* Vertical progress checklist (rail card) — replaces the horizontal strip. */
.portal-progress-v { display: flex; flex-direction: column; gap: var(--ctl-sp-1); }
.portal-progress-v .step {
    display: flex;
    align-items: center;
    gap: var(--ctl-sp-2);
    font-size: var(--ctl-fs-body-sm);
    color: var(--ctl-fg-muted);
}
.portal-progress-v .dot {
    flex: 0 0 auto;
    width: 14px;
    height: 14px;
    border-radius: 50%;
    border: 2px solid var(--ctl-border);
    background: var(--ctl-bg-elevated);
    display: inline-flex;
    align-items: center;
    justify-content: center;
    font-size: 8px;
    color: var(--ctl-accent-fg);
}
.portal-progress-v .step.done .dot { background: var(--ctl-accent); border-color: var(--ctl-accent); }
.portal-progress-v .step.current .dot { border-color: var(--ctl-accent); box-shadow: 0 0 0 3px rgba(var(--ctl-accent-rgb), 0.15); }
.portal-progress-v .step.current { color: var(--ctl-fg); font-weight: 600; }
.portal-progress-v .step .when { margin-left: auto; font-size: var(--ctl-fs-caption); color: var(--ctl-fg-subtle); }



/* Slim composer action row; the attach zone collapses behind the 📎 toggle
   (paste-to-attach still works while collapsed — the dropzone listens on the
   form — and the toggle auto-opens so the pasted file list is visible). */
.portal-composer-actions { display: flex; align-items: center; gap: var(--ctl-sp-2); }
.portal-composer-actions .spacer { flex: 1; }
.portal-attach-wrap[hidden] { display: none; }

/* Describe-first filing card: current answers as chips; the real controls
   sit in a disclosure that opens when a chip is tapped. */
.portal-filing-chips { display: flex; flex-wrap: wrap; gap: var(--ctl-sp-2); }
.portal-filing-chip {
    display: inline-flex;
    align-items: center;
    gap: var(--ctl-sp-1);
    border: 1px solid var(--ctl-border);
    border-radius: var(--ctl-radius-full);
    padding: 2px var(--ctl-sp-3);
    font-size: var(--ctl-fs-body-sm);
    color: var(--ctl-fg-muted);
    background: var(--ctl-bg-inset);
    cursor: pointer;
}
.portal-filing-chip strong { color: var(--ctl-fg); font-weight: 600; }
.portal-filing-chip:hover { border-color: var(--ctl-accent); color: var(--ctl-accent-text); }
.portal-filing-chip:focus-visible { outline: none; box-shadow: var(--ctl-focus-ring); }
.portal-intake-details.is-collapsed { display: none; }
.portal-filing-subject {
    display: flex;
    align-items: baseline;
    gap: var(--ctl-sp-2);
    margin-top: var(--ctl-sp-2);
    font-size: var(--ctl-fs-body-sm);
    color: var(--ctl-fg-muted);
}
.portal-filing-subject .subject-text { color: var(--ctl-fg); font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }

/* The deflection panel wears the accent voice on the portal ("this might fix
   it now") — a full accent edge rather than the quieter informational tone it
   carries on the agent form. Utilities carry !important, so the color override
   must too. The ink selector tracks the panel's own class: #2003 moved its
   heading icons off `.text-info` (which now reads the informational GROUND) and
   onto `.text-info-emphasis`, so this override follows them. */
.portal-body #kbDeflect .card { border-color: var(--ctl-border); border-left: 3px solid var(--ctl-accent); }
.portal-body #kbDeflect .text-info-emphasis { color: var(--ctl-accent-text) !important; }

/* The describe hero's inner textarea and the omnibox input sit borderless in
   an already-bordered card — kill the double focus ring. */
.portal-hero-field .form-control:focus { box-shadow: none; }

/* Inline chip dropdowns (polish round): each filing chip opens a rounded menu
   that writes into the hidden real controls — no expanding form section. One
   floating menu, positioned under the open chip by portal-intake.js; the
   card-body is its positioning context. */
#filingCard .card-body { position: relative; }
.portal-chip-menu {
    position: absolute;
    z-index: 30;
    min-width: 240px;
    max-width: min(320px, 100%);
    max-height: 300px;
    overflow-y: auto;
    background: var(--ctl-bg-elevated);
    border: 1px solid var(--ctl-border-strong);
    border-radius: var(--ctl-radius-lg);
    box-shadow: var(--ctl-card-shadow);
    padding: var(--ctl-sp-1) 0;
}
.portal-chip-menu-item {
    display: block;
    width: 100%;
    text-align: left;
    border: 0;
    background: none;
    padding: var(--ctl-sp-1) var(--ctl-sp-3);
    font-size: var(--ctl-fs-body);
    color: var(--ctl-fg);
    cursor: pointer;
}
.portal-chip-menu-item:hover,
.portal-chip-menu-item:focus-visible {
    background: rgba(var(--ctl-accent-rgb), 0.12);
    color: var(--ctl-accent-text);
    outline: none;
}
.portal-chip-menu-item.is-current { font-weight: 600; color: var(--ctl-accent-text); }
.portal-chip-menu-item .hint { color: var(--ctl-fg-muted); font-size: var(--ctl-fs-body-sm); }
.portal-chip-menu-group {
    padding: var(--ctl-sp-1) var(--ctl-sp-3) 0;
    font-size: var(--ctl-fs-caption);
    font-weight: 700;
    letter-spacing: 0.06em;
    text-transform: uppercase;
    color: var(--ctl-fg-subtle);
}
.portal-chip-menu-search { padding: var(--ctl-sp-1) var(--ctl-sp-2); }
/* Missing-answer highlight: the chip that owns the gap, and the describe
   bubble when it is the empty one. */
.portal-filing-chip.is-invalid {
    border-color: var(--ctl-danger);
    color: var(--ctl-danger-text);
    background: var(--ctl-danger-bg);
}
.portal-body .chat-textarea-wrap.is-invalid {
    border-color: var(--ctl-danger);
    box-shadow: 0 0 0 3px rgba(var(--ctl-danger-rgb), 0.2);
}
.portal-filing-subject .subject-edit { max-width: 420px; display: inline-block; }

/* ── Composer component (shared by every message-entry surface) ─────────────
   ONE definition of the message composer, used by /chat (main + thread),
   /portal/new's describe box and /portal/ticket/{id}'s reply box. It used to
   live in chat.css (chat pages only) and was mirrored by hand for the portal;
   the mirror is what drifts, so structure now lives here, app-wide.

   STRUCTURE vs THEMING. Sizes, radii, padding and layout are identical on
   every surface and are stated once below. Colors are NOT: the chat page
   pins its own reading surface and rides Bootstrap's palette, while the
   portal rides the --ctl tokens, and those genuinely differ (--ctl-bg-hover
   is a light grey; --bs-secondary-bg on the portal is pure white, which
   would have made the portal's hover circles invisible). So every color is a
   variable with the portal/app token as its default, and a surface that wants
   otherwise remaps the variable rather than restating the rule (see the
   #chatApp block in chat.css). The composer's own parts are named
   --composer-*; the shared icon button's four knobs are --ctl-icon-btn-*,
   because they are remapped by surfaces that are not composers at all (#1963,
   rationale at the .ctl-icon-btn rule below). */
.chat-composer-row { display: flex; align-items: flex-end; gap: var(--composer-row-gap, 0.4rem); }
/* min-width: 0 lets the wrap shrink inside the flex row — without it the
   input's intrinsic width forces horizontal overflow on narrow screens,
   clipping the send button off-viewport. */
.chat-textarea-wrap {
    flex: 1; position: relative; min-width: 0;
    display: flex; flex-direction: column;
    border: 1px solid var(--composer-border, var(--ctl-border));
    /* THE SHELL'S CORNER, NAMED. 22px is the composer shell's documented radius
       (UI_STANDARDS §4.4 / §6.5) and deliberately off the --ctl-radius ramp — a
       growing shell is neither a control (8px) nor an overlay (12px). §11 counts
       it as a token, which it only is while it is actually named: a bare literal
       here is exactly the un-tokenized structural value that rule bans.

       Stated as var-with-default, the idiom every other shared knob on this
       component uses (--composer-send-size, --composer-input-min-h,
       --ctl-icon-btn-size): the DEFAULT carries the value, so a surface that
       ever wants another corner remaps the variable from its own scope instead
       of restating this rule. Declaring the property on this element instead
       would defeat that — an own declaration outranks any ancestor's remap.
       Resolved value is unchanged.

       It keeps the --composer-* name after #1963 split the family: the shell is
       the composer's own anatomy, and only a composer can want a different one.
       The knobs that moved to --ctl-icon-btn-* moved because non-composers
       remap them. */
    border-radius: var(--composer-shell-radius, 22px);
    background: var(--composer-surface, var(--ctl-bg-elevated));
    padding: 0.35rem 0.5rem 0.3rem 0.6rem;
    overflow: visible;
}
.chat-textarea-wrap:focus-within {
    border-color: var(--composer-focus-border, var(--ctl-accent));
    box-shadow: var(--ctl-focus-ring, 0 0 0 3px rgba(var(--bs-primary-rgb), 0.25));
}
/* The WYSIWYG input. Chrome-free inside the shell (the shell carries the
   border + focus ring); it grows with its content, so the cap is CSS rather
   than an autoGrow(). min-height ≈ 150% of a one-liner. */
.chat-rich-input {
    min-height: var(--composer-input-min-h, 3.5rem);
    max-height: var(--composer-input-max-h, 220px);
    overflow-y: auto;
    padding: 0.35rem 0.4rem;
    border: 0; background: transparent;
    outline: none; white-space: pre-wrap;
    word-wrap: break-word; overflow-wrap: anywhere;
}
/* :empty misses the filler <br> some engines park in an emptied editable, so
   the client also stamps .is-empty from the text content. */
.chat-rich-input:empty::before,
.chat-rich-input.is-empty::before {
    content: attr(data-placeholder);
    color: var(--composer-placeholder, var(--ctl-fg-subtle));
    pointer-events: none;
}
/* Focus clears the prompt. The placeholder is a ::before on the editable
   itself, which generates an IN-FLOW inline box inside it — so in an empty
   composer the caret (offset 0 of the element) renders AFTER the prompt text.
   Focusing an empty box therefore looked like it already held a message, with
   the caret parked at the end of it. Dropping the pseudo on focus is the whole
   fix: the line goes empty, the caret snaps to the start, and blurring a still
   empty input brings the prompt straight back (the :empty/.is-empty rule above
   never changed). Nothing is lost while typing — the accessible name comes
   from each input's aria-label, not from data-placeholder.

   The second selector is not redundant. The portal's composer wears
   .chat-rich-input AND .rich-body (see the alias block below), and
   `.rich-body:empty::before` — same specificity, later in this file — would
   otherwise win the tie and re-assert `content` on the portal's two boxes. */
.chat-rich-input:focus::before,
.chat-rich-input.rich-body:focus::before { content: none; }
/* ── The same ruling for composers with a NATIVE placeholder (#1894 part b) ──
   Focusing any composer hides its prompt, app-wide. The boxes below are not
   contenteditables — they are a <textarea>/<input> carrying a `placeholder`
   attribute the browser paints as ::placeholder — so they need their own rule,
   and it deliberately does the OPPOSITE of the two above.

   The asymmetry is not a contradiction, it is the mechanism. A ::before on a
   contenteditable is an in-flow inline box INSIDE the editable, so the caret at
   offset 0 lands after it: there the ink must stop being GENERATED, and merely
   hiding it strands the caret behind an invisible box (which is why the
   contract test bans color/opacity/visibility on those two rules). A native
   ::placeholder never takes part in layout — the caret sits at the start of the
   field whether or not the prompt is painted — so the ink IS the whole thing
   and `color: transparent` is the right and complete tool. Blur needs no second
   rule: :focus simply stops matching. The `placeholder` ATTRIBUTE is untouched,
   so the accessible name these fields fall back to survives focus.

   The transition is what keeps that from reading as a glitch — the prompt fades
   out under the caret and back in on blur instead of blinking. It collapses to
   a jump-cut under the global prefers-reduced-motion rule (§4.4).

   Scope is COMPOSERS — a box you type a message into and send: the docked
   assistant lens, the in-call meeting chat line, the ticket side-conversation
   reply. Search fields (global search, /chat's conversation + people search),
   entity forms and the optional-comment boxes on approval / RSVP cards keep
   their prompt: those read as instructions about a field, not as the "type
   here" of a composer, and #1894 is a composer ruling. */
.ai-chat-input::placeholder,
.meet-chat-form input::placeholder,
.side-conv-reply-form textarea::placeholder { transition: color var(--ctl-dur-fast); }
.ai-chat-input:focus::placeholder,
.meet-chat-form input:focus::placeholder,
.side-conv-reply-form textarea:focus::placeholder { color: transparent; }
/* Inline code inside the editor. THE COMPOSER'S SMALLEST CORNER, NAMED — see
   --composer-chip-radius on the emoji hover swatch below; the two 4px ticks are
   one shape (a run of glyphs given a fill, not a pill) and now one name. */
.chat-rich-input code {
    background: var(--composer-code-bg, var(--ctl-bg-inset));
    border-radius: var(--composer-chip-radius, 4px); padding: 0 3px; font-size: 0.88em;
}

/* The tool row inside the shell — one look for styling / emoji / attach / mic.
   gap 0: the 32px hit boxes still leave ~8px of optical space between glyphs. */
.chat-input-tools { display: flex; justify-content: flex-end; gap: 0; }
/* ── The app's icon button ────────────────────────────────────────────────
   ONE definition of "a small circular control with a glyph in it": /chat's
   composer tools, /portal/new, the portal reply, the portal ticket header's
   overflow toggle, and the assistant lens's header row + composer mic.

   It was named after /chat's composer input until #1900 — the name of the
   first place it appeared, not of what it is, and that name kept regenerating
   private forks (#1456, #1885). Whoever needed a header or toolbar icon button
   read "chat input", concluded it did not apply, and wrote a fresh unsized
   22px button with no share of the hit extension below. The neutral --ctl-*
   name is the point of the rename; the declarations are unchanged.

   ITS KNOBS CARRY THE SAME NAME (#1963). The four variables this component
   exposes — --ctl-icon-btn-size / -fg / -hover-fg / -hover-bg — wore the
   composer's prefix until now, which left the portal ticket header re-scaling
   itself with a variable that said "composer" and left the next reader with
   exactly the wrong conclusion #1900 was fixing. The split line is OWNERSHIP,
   not tidiness: a knob belongs to whoever a REMAP of it belongs to. These four
   are remapped by surfaces that are not composers (the portal header's
   overflow toggle re-scales the disc; the lens header row rides the ink), so
   they are the shared button's and take its name. The knobs that stayed
   --composer-* are the composer's own anatomy — the growing shell
   (--composer-shell-radius, --composer-input-radius, --composer-input-min-h /
   -max-h), its row gap and surface ink, its popover and chip parts, and the
   send disc (--composer-send-size / -mb), which exists nowhere but at the
   bottom of a composer. Nothing was aliased: an alias would keep the
   misleading name in front of the next reader, which was the whole defect.

   margin: 0 because the attach control may be a <label> (wrapping the hidden
   file input) and would inherit Reboot's label spacing otherwise. */
.ctl-icon-btn {
    flex: 0 0 auto;
    width: var(--ctl-icon-btn-size, 32px); height: var(--ctl-icon-btn-size, 32px);
    border-radius: 50%; margin: 0; padding: 0;
    display: inline-flex; align-items: center; justify-content: center;
}
/* The BARE chrome — a glyph on nothing — which is what every wearer wants but
   one: the portal ticket header's overflow toggle is a real Bootstrap dropdown
   toggle and has to keep its border, its fill and its .show state. style.css
   loads AFTER bootstrap.min.css at equal specificity, so an unguarded
   `border: 0` here would silently strip that ring the moment the toggle joined
   the shared class. :where() contributes NO specificity, so the guard changes
   only which elements match — every existing wearer keeps the exact weight it
   had (chat.css's .is-recording still outranks this :hover, as it always did). */
.ctl-icon-btn:where(:not(.btn)) {
    border: 0; background: none; cursor: pointer;
    color: var(--ctl-icon-btn-fg, var(--ctl-fg-muted)); font-size: 1rem;
}
.ctl-icon-btn:where(:not(.btn)):hover,
.ctl-icon-btn:where(:not(.btn))[aria-expanded="true"],
.ctl-icon-btn:where(:not(.btn)).is-active {
    color: var(--ctl-icon-btn-hover-fg, var(--ctl-accent-text));
    background: var(--ctl-icon-btn-hover-bg, var(--ctl-bg-hover));
}
.ctl-icon-btn.is-busy { opacity: 0.5; pointer-events: none; }

/* Coarse-pointer hit extension (UI_STANDARDS §9 / §11.1 item 3): the small
   circular controls keep their visual size, but on touch devices an invisible
   centered pseudo-element grows each target to ≥44px. The pseudo participates
   in hit testing and bubbles to its button. Adjacent 44px targets may overlap
   by a few px at the composer's 8px gap; the topmost (later-DOM) button wins
   the sliver, which WCAG 2.5.8 tolerates — the alternative, spacing the
   buttons out, would resize the composer.

   THE LIST IS NO LONGER TRUSTED TO BE COMPLETE (#2035). It used to be four
   selectors kept in step by memory, and memory is what shipped #1456 and #1885
   — two ~22px targets written by authors who never knew this group existed.
   `CssTokenContractTest::testEveryGhostGlyphButtonJoinsTheCoarseHitExtension`
   now sweeps style.css for the ghost-button signature (`border: 0` +
   `background: none` + `cursor: pointer`) and fails any wearer that is missing
   from here, so a NEW disc/glyph button cannot ship without joining. The
   exclusions — full-width menu items, text inputs, underlined text links, and
   the popover swatch grids — are stated as commented predicates in that test,
   not here, so the boundary has exactly one edit point.

   .uee-toast-close is DELIBERATELY absent from the position list below: it is
   already `position: absolute` in the toast's corner, which is a containing
   block for its ::after. Adding it here would state a position it does not
   take. */
.ctl-icon-btn,
.chat-send-btn,
.ctl-copy-btn,
.portal-known-issue-close,
.composer-tool-btn,
.attach-item-remove,
.thread-hover-action { position: relative; }
@media (pointer: coarse) {
    .ctl-icon-btn::after,
    .chat-send-btn::after,
    .ctl-copy-btn::after,
    .portal-known-issue-close::after,
    .composer-tool-btn::after,
    .attach-item-remove::after,
    .uee-toast-close::after,
    .thread-hover-action::after {
        content: "";
        position: absolute;
        left: 50%;
        top: 50%;
        width: max(44px, 100%);
        height: max(44px, 100%);
        transform: translate(-50%, -50%);
    }
}

/* The send action is a DISC, everywhere it appears. Size is a variable for the
   same reason every colour in this component is one: a surface that needs a
   different scale re-maps the variable instead of restating the rule (the
   assistant lens is a ~400px column and runs its composer at the tool-button
   size). The defaults are the numbers this rule always carried, so /chat, the
   portal and the agent thread render byte-identically. */
.chat-send-btn {
    flex: 0 0 auto;
    width: var(--composer-send-size, 40px); height: var(--composer-send-size, 40px);
    border-radius: 50%;
    display: inline-flex; align-items: center; justify-content: center;
    padding: 0; margin-bottom: var(--composer-send-mb, 0.3rem);
}

/* Styling popover — B/I/U/S/code applied LIVE to the editor's selection.

   THE SHELL IS AN OVERLAY, AND NOW SAYS SO. It carried a bare `8px`, which
   resolves identically to --ctl-radius in all six sheets — so the literal read
   as "a control". It is not one: §4.4 classes popover/dropdown shells and
   floating menus as OVERLAY CONTAINERS and gives them --ctl-radius-lg (12px),
   the same corner the mention dropdown, the column menu and the portal's chip
   menu already take. Conforming is therefore a deliberate +4px softening of
   this popup and the emoji one below, not a no-op refactor (#1972 ruling).

   The ramp token direct, with no --composer-* alias in front of it: the point
   of the ruling is that these two popups are ordinary overlays, and an overlay
   that wants a different corner is re-deciding §4.4, not remapping a knob.
   (chat.css does exactly that for the emoji grid — 18px, documented there.)
   Body-parented by composer-tools.js / chat.js, so the scoped --ctl-radius-lg
   remaps further down this file — the ticket detail body, the home tiles —
   never reach them; these resolve 12px from the sheet, on every surface. */
.chat-style-pop {
    position: fixed; z-index: 25;
    background: var(--composer-pop-bg, var(--ctl-bg-elevated));
    border: 1px solid var(--composer-pop-border, var(--ctl-border));
    border-radius: var(--ctl-radius-lg);
    box-shadow: 0 4px 14px var(--composer-pop-shadow, var(--ctl-shadow));
    padding: 4px; display: flex; gap: 2px;
}
/* The buttons INSIDE it stay where they are. 6px is off the ramp in both
   directions — smaller than a control (8px), larger than the chip tick (4px) —
   because a 32px square nested 4px inside a 12px arc reads wrong at either
   neighbour's value. Named rather than rounded, so the value is unchanged and
   searchable, and stated as var-with-default (the --composer-* idiom, #1973):
   the DEFAULT carries it, so a surface that ever wants another corner remaps
   the variable from its own scope — an own declaration here would outrank any
   ancestor's remap and defeat that. Off-ramp today, off-ramp by inheritance
   tomorrow: point this at var(--ctl-radius) from a parent and it joins the
   ramp with no edit to this rule. */
.chat-style-pop button {
    border: 0; background: none; cursor: pointer;
    width: 32px; height: 32px; border-radius: var(--composer-pop-btn-radius, 6px);
    color: var(--composer-pop-fg, var(--ctl-fg)); font-size: 0.95rem;
    display: inline-flex; align-items: center; justify-content: center;
}
.chat-style-pop button:hover { background: var(--composer-pop-hover-bg, var(--ctl-bg-inset)); }
/* queryCommandState mirror — the caret's live formatting, not a toggle memory. */
.chat-style-pop button.is-active {
    background: var(--composer-active-bg, rgba(var(--ctl-accent-rgb), 0.15));
    color: var(--composer-active-fg, var(--ctl-accent-text));
}

/* Emoji quick-pick. The client owns placement (one vertical edge at a time) —
   a static bottom/right here would fight its top/left and squash the box.
   Same overlay ruling as the styling popover above: --ctl-radius-lg, +4px on
   what the literal used to paint. chat.css re-declares this shell at 18px for
   its five-per-row grid and loads after style.css globally, so in practice this
   corner is what the PORTAL's picker wears; the two are a step closer now. */
.chat-emoji-pop {
    position: fixed; z-index: 25;
    background: var(--composer-pop-bg, var(--ctl-bg-elevated));
    border: 1px solid var(--composer-pop-border, var(--ctl-border));
    border-radius: var(--ctl-radius-lg);
    box-shadow: 0 4px 14px var(--composer-pop-shadow, var(--ctl-shadow));
    padding: 6px; display: flex; flex-wrap: wrap; gap: 3px; max-width: 220px;
}
.chat-emoji-pop button { border: 0; background: none; font-size: 1.15rem; cursor: pointer; padding: 2px 4px; }
/* The hover swatch takes the composer's chip tick — the same 4px the inline
   code run wears, and the same 4px §4.4 gives a kbd hint. One shape ("a fill
   behind a glyph run"), one name, current value unchanged; the default keeps it
   remappable per surface exactly as the popover button above. */
.chat-emoji-pop button:hover { background: var(--composer-pop-hover-bg, var(--ctl-bg-inset)); border-radius: var(--composer-chip-radius, 4px); }

/* The portal's composer input carries BOTH .chat-rich-input and .rich-body:
   the alias is what kb-deflection.js and category-suggest.js read the draft
   through. .rich-body is the agent editor's box (120px tall, inset fill, its
   own padding and placeholder ink) and, being a later single-class rule, it
   would win the tie and re-chrome the bubble. Two classes, stated once, settle
   it — the composer keeps its own geometry wherever it wears the alias. */
.chat-rich-input.rich-body {
    min-height: var(--composer-input-min-h, 3.5rem);
    max-height: var(--composer-input-max-h, 220px);
    padding: 0.35rem 0.4rem;
    background: transparent;
    font-size: inherit;
    line-height: inherit;
}
.chat-rich-input.rich-body:empty::before,
.chat-rich-input.rich-body.is-empty::before {
    color: var(--composer-placeholder, var(--ctl-fg-subtle));
}

/* Portal surfaces: the wrap doubles as .rich-editor for the deflection and
   suggester selectors, so cancel that component's own chrome here. Same token
   as the shell rule above — this exists only to out-specify .rich-editor's
   --ctl-radius, so it must never be able to disagree about the corner. */
.portal-body .chat-textarea-wrap.rich-editor { border-radius: var(--composer-shell-radius, 22px); }
.portal-body .chat-composer-row { --composer-row-gap: var(--ctl-sp-2); }
.portal-body .chat-send-btn { --composer-send-mb: 2px; }
/* A describe box opens tall; a reply is usually a line or two. */
.portal-describe { --composer-input-min-h: 140px; --composer-input-max-h: 320px; }
/* The reply bubble opens at one line and grows to the same cap the describe
   box uses — the portal's composers scroll at the same depth. */
.portal-composer { --composer-input-min-h: 3.5rem; --composer-input-max-h: 320px; }

/* Bare attachments (portal create): the paperclip is the browse control and
   the bubble is the dropzone — selections render as thumbnail tiles INSIDE
   the bubble (below), so the partial's own text list hides too; only its size
   warnings may take space out here. Zone/label/hint/list stay in the shared
   partial's markup (for its JS contract and for no-JS, which reverts this in
   noscript). */
.attach-bare .attach-field > .form-label,
.attach-bare .attach-zone,
.attach-bare .attach-field > .form-text,
.attach-bare .attach-list { display: none; }

/* Dragging files over a composer. composer-tools.js flags the shell it will
   accept the drop on (.chat-textarea-wrap on the portal bubbles, .rich-editor
   on the agent ticket composer) — but nothing drew that flag, so until now the
   one surface that CAN take a dropped photo looked exactly like the surface
   that would embed it into the text. Same border + ring the shell already wears
   on :focus-within, because it is the same statement: this box is what your
   input is about to go into. */
.chat-textarea-wrap.is-dragover,
.rich-editor.is-dragover {
    border-color: var(--ctl-accent);
    box-shadow: var(--ctl-focus-ring);
}

/* Bubble bottom rail: thumbnails grow from the left, tools pin right. */
.portal-composer-bottom {
    display: flex;
    align-items: flex-end;
    gap: var(--ctl-sp-2);
}
.portal-attach-thumbs {
    display: flex;
    flex-wrap: wrap;
    gap: var(--ctl-sp-2);
    flex: 1 1 auto;
    min-width: 0;
    padding: 2px 0 2px 2px;
}
/* A 48px attachment tile is a CONTROL on the ramp, not a small card: it is a
   hairline box the size of an icon button, it sits on the composer's bottom
   rail beside real controls, and it is clickable furniture rather than a
   surface you read (UI_STANDARDS §4.4). So it takes --ctl-radius — 8px in all
   six sheets, which is exactly what this rule already drew, so naming it is a
   no-op edit and the tile keeps its corner. The image below is the same arc
   inset by this border's hairline; the two are one decision (#2001). */
.portal-attach-thumb {
    position: relative;
    width: 48px;
    height: 48px;
    border: 1px solid var(--ctl-border);
    border-radius: var(--ctl-radius);
    background: var(--ctl-bg-inset);
    display: inline-flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    overflow: visible; /* the × bubble overhangs the corner */
}
/* Two-class selector on purpose: the app-wide content-image rule
   (`.portal-container img { height: auto }`) has the same specificity as a
   single-class one, and a tall screenshot rendered 1840px high — clipped by
   the tile's box but still spilling out of the composer. This outranks it,
   and the fixed px sizes mean no percentage can resolve against an auto
   height either.

   The corner is DERIVED, never chosen: the tile's arc inset by the tile's own
   1px hairline, the same sentence §4.4 writes for a flush region inside a card
   (`calc(var(--ctl-radius-card) - 1px)`). It resolves to the 7px this rule used
   to state as a bare literal — zero visual change — but the pair can no longer
   drift, because re-tuning the tile corner now moves this one with it. */
.portal-attach-thumbs .portal-attach-thumb img {
    width: 46px;
    height: 46px;
    max-width: 46px;
    max-height: 46px;
    object-fit: cover;
    border-radius: calc(var(--ctl-radius) - 1px);
    display: block;
}
.portal-attach-thumb { overflow: visible; }
/* Belt and braces for the editable itself: a pasted/dropped image that ever
   lands inline must not grow the bubble without bound. */
.portal-describe .chat-rich-input img { max-height: 220px; }
.portal-attach-thumb .bi { font-size: 1.15rem; color: var(--ctl-fg-muted); line-height: 1; }
.portal-attach-thumb .ext {
    font-size: 9px;
    font-weight: 700;
    letter-spacing: 0.04em;
    color: var(--ctl-fg-subtle);
    margin-top: 2px;
}
/* Grey-outline bubble on a light fill, grey glyph — quiet until hovered,
   where it takes the danger voice. The mark is a bi-x icon (a &times; glyph
   never sat centred in an 18px circle); grid centring beats flex here because
   the icon font's own line box is what was off. */
.portal-attach-thumb-x {
    position: absolute;
    top: -7px;
    right: -7px;
    width: 18px;
    height: 18px;
    border: 1px solid var(--ctl-border-strong);
    border-radius: 50%;
    background: var(--ctl-bg-elevated);
    color: var(--ctl-fg-muted);
    display: grid;
    place-items: center;
    cursor: pointer;
    padding: 0;
    line-height: 0;
}
.portal-attach-thumb-x .bi {
    font-size: 14px;
    line-height: 1;
    display: block;
    color: inherit;
}
.portal-attach-thumb-x:hover {
    background: var(--ctl-danger);
    border-color: var(--ctl-danger);
    color: #fff;
}
.portal-attach-thumb-x:focus-visible { outline: none; box-shadow: var(--ctl-focus-ring); }

/* ── Content images — constrain to container ── */
.card img, .rich-body img, .portal-container img, .kb-article-content img {
    max-width: 100%;
    height: auto;
}

/* ── Rendered user bodies — constrain TEXT to container ─────────────────────
   The image rule above says a picture may not be wider than what holds it.
   This is the same sentence about WORDS, and it was missing: a customer pasted
   an Acumatica stack trace into a portal request and the lines ran straight out
   of the "Your request" card, under the rail's Progress / Commitment / Details
   cards. Measured in headless Chrome against this stylesheet: the ink painted
   804px past the rail's left edge.

   TWO defaults have to be answered, because a body arrives in either shape.

   1. `pre` — the paste branch. HtmlSanitizer allows <pre>/<code> (a real
      formatting affordance, kept), but the customer's block carries NO class,
      so .thread-md-code — which already states pre-wrap, and which is the
      ASSISTANT markdown renderer's own stamped class — never applies to it. An
      unclassed <pre> therefore computes the UA's `white-space: pre`: no wrap
      opportunity at any width, at all. That is the reported bug.

   2. `overflow-wrap` on the body itself — the plain-text branch. render()
      escapes and <br>s plain text into a bare div, and Chrome DOES find breaks
      inside dotted identifiers, so the stack trace itself survives here (594px
      box, no overflow — measured). What does not is a genuinely unbreakable
      token: one base64 log reference overflowed the same box by 306px. Latent,
      same class of bug, one line to close.

   THE FONT IS PRESERVED, per the ruling on this report — a stack trace is
   read by column and monospace is the affordance. `pre-wrap` keeps the
   author's own newlines and indentation and adds soft wraps; only the
   guarantee of never wrapping is given up, which is the guarantee that broke
   the page. `overflow-wrap: anywhere` (not `break-word`) so the block's
   MIN-CONTENT width shrinks too — break-word wraps the text but leaves the box
   still demanding its longest token, which is exactly the blowout below.
   max-width + overflow-x are the belt: anything that still cannot wrap (a wide
   table, a data: URI) scrolls inside its own box instead of over the page.

   Enumerated rather than hung on `.card`: these are the surfaces that render
   SANITIZED USER CONTENT, which is the thing that can be hostile. A settings
   page that deliberately shows a wide <pre> is not in this family.

   AN ENUMERATION IS A LIST SOMEONE HAS TO REMEMBER TO JOIN, so it is no longer
   only a list. The contract these two rules state is written in UI_STANDARDS
   §6.5 — where a restyle brief reads it — and RenderedBodyWrapContractTest
   parses the selector lists BELOW and holds them to its own constants, so a
   surface cannot be added here and forgotten there (or vice versa). The same
   test walks every file that renders a sanitized body and requires it to name
   a surface listed here: .ediscovery-body joined in that sweep (#1991), a chat
   legal-hold reader drawing stored body_html that had been governed by
   nothing. */
.portal-request-body,
.ticket-narrative,
.thread-bubble,
.peek-description,
.ediscovery-body,
.kb-article-content {
    overflow-wrap: anywhere;
}
.portal-request-body pre, .portal-request-body code,
.ticket-narrative pre,   .ticket-narrative code,
.thread-bubble pre,      .thread-bubble code,
.peek-description pre,   .peek-description code,
.ediscovery-body pre,    .ediscovery-body code,
.chat-msg-body pre,      .chat-msg-body code,
.rich-body pre,          .rich-body code,
.kb-article-content pre, .kb-article-content code {
    white-space: pre-wrap;
    overflow-wrap: anywhere;
    max-width: 100%;
    overflow-x: auto;
}

/* ── Global Search ── */
/* A persistent rounded field in the top navbar (it used to be an icon that
   expanded into a popup). --ctl-bg-inset equals the navbar's own background in
   some styles, so the border — not the fill — is what makes it read as a field.
   Positioning and visibility of the results panel belong to .ctl-popover. */
.global-search-wrapper { position: relative; flex-shrink: 0; }
.global-search-field {
    display: flex;
    align-items: center;
    gap: 0.35rem;
    width: 13rem;
    height: 2rem;
    padding: 0 0.6rem;
    background: var(--ctl-bg-inset);
    border: 1px solid var(--ctl-border);
    border-radius: 999px;
    transition: width 0.15s, border-color 0.15s, box-shadow 0.15s;
}
.global-search-field:focus-within {
    border-color: var(--ctl-accent);
    box-shadow: var(--ctl-focus-ring);
}
.global-search-icon {
    display: inline-flex;
    align-items: center;
    margin: 0;
    color: var(--ctl-fg-muted);
    font-size: 0.9rem;
    cursor: text; /* the label focuses the input — advertise that */
}
.global-search-input {
    flex: 1 1 auto;
    min-width: 0;
    border: 0;
    background: none;
    color: var(--ctl-fg);
    /* 14px — was 0.8rem (12.8px), the smallest text anyone was expected to TYPE
       and read back in the whole shell. 0.875rem is the size the in-list search
       box already renders at (Bootstrap's .input-group-sm control), so the two
       search fields now read alike. Set here in the shared component layer, not
       in a preset block, so all three styles x light/dark inherit it. */
    font-size: 0.875rem;
    padding: 0;
}
.global-search-input::placeholder { color: var(--ctl-fg-faint); }
.global-search-input:focus { outline: none; } /* the ring lives on the field */
/* Chrome/Safari bolt a native clear "X" onto type=search — it lands on top of
   our own chrome and ignores the tokens. */
.global-search-input::-webkit-search-cancel-button { display: none; }
/* Tablet / small laptop: keep the field, give the breadcrumb its room back.
   Below 768px it collapses to the icon entirely (see the mobile block). */
@media (max-width: 1199.98px) {
    .global-search-field { width: 9.5rem; }
}
.global-search-dropdown { width: min(380px, 92vw); padding: 0.5rem; } /* §8 phone clamp */
.global-search-results { max-height: 320px; overflow-y: auto; margin-top: 0.25rem; }
.global-search-item {
    display: block; padding: 0.4rem 0.5rem; text-decoration: none;
    color: var(--ctl-fg); border-radius: var(--ctl-radius); cursor: pointer;
}
.global-search-item:hover, .global-search-item.active { background: var(--ctl-bg-hover); color: var(--ctl-fg); }
.global-search-item .gs-title { font-size: 0.8rem; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.global-search-item .gs-subtitle { font-size: 0.7rem; color: var(--ctl-fg-muted); }
.global-search-item .gs-type { font-size: 0.65rem; text-transform: uppercase; letter-spacing: 0.03em; color: var(--ctl-accent-text); }
.global-search-footer { padding: 0.25rem 0.5rem 0; border-top: 1px solid var(--ctl-border); margin-top: 0.25rem; text-align: center; }

/* ── Notification indicator & dropdown ── */
/* The control moved into the user dropdown; the unread bubble moved onto the
   navbar avatar. Two count surfaces share one look: the corner bubble on the
   avatar and the trailing pill on the menu row. */
.notification-toggle {
    display: flex;
    align-items: center;
}

.notification-count-badge,
.notification-count-pill {
    min-width: 16px;
    height: 16px;
    padding: 0 4px;
    font-size: 0.6rem;
    font-weight: 700;
    line-height: 16px;
    text-align: center;
    color: #fff;
    background: var(--ctl-danger);
    border-radius: 8px;
}

/* Corner bubble on the avatar — TOP-LEFT, so it sits over the navbar's empty
   gutter instead of crowding the user's name to the avatar's right. The ring is
   the navbar's own background, so the bubble stays legible where it overlaps a
   dark photo or initials fill. Offsets are held inside the toggle's 0.5rem side
   padding (see .detail-tool.navbar-user), so nothing spills toward the
   neighbouring tool button at any width.

   The offsets are measured against the SHAPE, not the box. A bubble is 1.5rem
   (24px) square; on the 45 degree diagonal its edge sits r - r/sqrt(2) in from
   the box corner, which was 1.8px at the old 25% radius and is 3.5px now that
   avatars are full circles. Anchoring to the box would therefore have opened a
   ~2px gap between badge and face and left the count looking detached. Both
   offsets moved in by 2px to hold the same visual bite into the shape. */
.navbar-user-avatar { position: relative; display: inline-flex; }
.notification-count-badge {
    position: absolute;
    top: -3px;
    left: -5px;
    box-shadow: 0 0 0 2px var(--ctl-bg-elevated);
}

.notification-count-pill { margin-left: auto; }

.notification-dropdown {
    display: none;
    position: absolute;
    right: 0;
    top: 100%;
    width: min(360px, 92vw); /* §8 phone clamp */
    max-height: 480px;
    background: var(--ctl-bg-elevated);
    border: 1px solid var(--ctl-border);
    border-radius: var(--ctl-radius);
    box-shadow: 0 4px 12px var(--ctl-shadow-hover);
    z-index: var(--ctl-z-dropdown);
    display: none;
    flex-direction: column;
}

/* Fallback shell (app.js without popover.js) — the panel now hangs off the user
   menu, whose own .show class belongs to Bootstrap, so it flags .notif-open. */
.nav-user-wrapper.notif-open .notification-dropdown {
    display: flex;
}

.notification-dropdown-header {
    display: flex;
    align-items: center;
    justify-content: space-between;
    padding: 0.5rem 0.75rem;
    border-bottom: 1px solid var(--ctl-border);
}

.notification-dropdown-title {
    font-size: 0.75rem;
    font-weight: 600;
    text-transform: uppercase;
    letter-spacing: 0.05em;
    color: var(--ctl-fg-muted);
}

.notification-mark-all {
    color: var(--ctl-link);
    text-decoration: none;
    font-size: 0.85rem;
}

.notification-dropdown-body {
    flex: 1;
    overflow-y: auto;
    max-height: 380px;
}

.notification-dropdown-footer {
    padding: 0.4rem 0.75rem;
    border-top: 1px solid var(--ctl-border);
    text-align: center;
}

.notification-dropdown-footer a {
    font-size: 0.75rem;
    color: var(--ctl-link);
    text-decoration: none;
}

.notif-item {
    display: flex;
    align-items: flex-start;
    gap: 0.5rem;
    padding: 0.5rem 0.75rem;
    text-decoration: none;
    color: var(--ctl-fg);
    border-bottom: 1px solid var(--ctl-border);
    cursor: pointer;
    transition: background 0.1s;
}

.notif-item:hover {
    background: var(--ctl-bg-hover);
    color: var(--ctl-fg);
}

.notif-item.notif-unread {
    background: rgba(var(--ctl-accent-rgb), 0.06);
}

.notif-item .notif-icon {
    flex-shrink: 0;
    width: 28px;
    height: 28px;
    display: flex;
    align-items: center;
    justify-content: center;
    border-radius: 50%;
    background: var(--ctl-bg-alt);
    font-size: 0.8rem;
    color: var(--ctl-accent-text);
}

.notif-item .notif-body {
    flex: 1;
    min-width: 0;
}

.notif-item .notif-title {
    font-size: 0.8rem;
    font-weight: 500;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
}

.notif-item .notif-msg {
    font-size: 0.75rem;
    color: var(--ctl-fg-muted);
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
}

.notif-item .notif-time {
    font-size: 0.65rem;
    color: var(--ctl-fg-muted);
    white-space: nowrap;
    flex-shrink: 0;
}

/* Originating-actor bubble under the message (gap #996) — mirrors the full
   notifications list. Absent for actor-less system rows. */
.notif-item .notif-actor {
    display: flex;
    align-items: center;
    gap: 4px;
    margin-top: 2px;
}

.notif-item .notif-actor-name {
    font-size: 0.7rem;
    color: var(--ctl-fg-muted);
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
}

.notif-unread-dot {
    flex-shrink: 0;
    width: 8px;
    height: 8px;
    border-radius: 50%;
    background: var(--ctl-accent);
    margin-top: 6px;
}

.notification-empty {
    text-align: center;
    padding: 1.5rem 0.75rem;
    color: var(--ctl-fg-muted);
    font-size: 0.8rem;
}

/* ── Bulk Actions ── */
.bulk-action-bar {
    background: var(--ctl-bg-elevated);
    border: 1px solid var(--ctl-accent);
    border-radius: var(--ctl-radius);
    margin-bottom: 0.5rem;
    box-shadow: 0 2px 8px rgba(var(--ctl-accent-rgb), 0.1);
}

/* Overlay mode (/tickets): the bar floats OVER the quick-filter/search row
   instead of sitting in flow above the table, so its appearance never moves
   the rows the user is mid-way through checking. initBulkActions relocates
   the region-rendered bar into the host; pages without a .bulk-bar-host keep
   the in-flow layout above. Centered on the row and at least as tall as it,
   so no covered control peeks out. */
.bulk-bar-host {
    position: relative;
}

.bulk-bar-host .bulk-action-bar {
    position: absolute;
    top: 50%;
    left: 0;
    right: 0;
    transform: translateY(-50%);
    min-height: 100%;
    margin-bottom: 0;
    z-index: 20;
    display: flex;
    flex-direction: column;
    justify-content: center;
    /* radius-tile, not radius: the overlay is floating chrome like the
       shortcut tiles, so it keeps its rounding even in the sharp-cornered
       Industrial/Operator styles (same stakeholder call as the tiles). */
    border-radius: var(--ctl-radius-tile);
    box-shadow: 0 4px 14px rgba(var(--ctl-accent-rgb), 0.18);
}

.bulk-action-bar .bulk-count {
    color: var(--ctl-accent-text);
    font-size: 0.85rem;
}

/* Bulk-select gutter — a 36px column whose entire content is one 1em control,
   in the header and in every row of every list. It has no text to line up
   against, so it centres on the CELL.

   Two engine defaults nobody had written down were pushing it off centre, in
   opposite directions:
     • Bootstrap ships `.form-check-input { vertical-align: top }` so a checkbox
       meets the first line of its label. There is no label here, so the box
       simply parked at the top of the cell's strut — measured 4.25px above
       centre on /tickets (which centres its cells) and 10.25px above centre on
       every other list, where cells top-align AND this cell's own 0.25rem
       padding is shorter than the density padding the rest of the row carries.
       The old `margin: 0` reset here killed Bootstrap's companion 0.25em nudge
       and left the vertical-align behind, which is why the box stayed high.
     • The header <th> is `vertical-align: bottom` (Bootstrap's thead default),
       which put the same box 2.75px BELOW centre — so select-all and the boxes
       in the rows beneath it were wrong in opposite directions.

   Same mechanism as the editable cells fixed in #1216, and for the same reason:
   no percentage-height chain and no line box for an engine to resolve. `display:
   block` leaves the cell holding a single block box exactly as tall as the
   control, so the cell's own `vertical-align: middle` centres it by
   construction, at any density and however tall an avatar row has stretched.
   `margin: 0 auto` centres it across — `.text-center` on the cell aligns inline
   content and no longer reaches a block child. */
.col-bulk-check {
    padding: 0.25rem 0.5rem !important;
    width: 36px;
    min-width: 36px;
    max-width: 36px;
    vertical-align: middle;
}

.col-bulk-check .form-check-input {
    display: block;
    margin: 0 auto;
    cursor: pointer;
}

/* Full notification list page */
.notification-list-full .notification-item {
    padding: 0.6rem 0.75rem;
    border-left: 3px solid transparent;
}

.notification-list-full .notification-item.notification-unread {
    background: rgba(var(--ctl-accent-rgb), 0.06);
    border-left-color: var(--ctl-accent);
}

.notification-list-full .notification-title {
    font-size: 0.85rem;
    font-weight: 500;
}

.notification-list-full .notification-message {
    font-size: 0.8rem;
}

.notification-list-full .notification-time {
    font-size: 0.75rem;
}

.notification-list-full .notification-icon {
    color: var(--ctl-accent-text);
    font-size: 1rem;
}

.notification-list-full .notification-unread-dot {
    width: 8px;
    height: 8px;
    border-radius: 50%;
    background: var(--ctl-accent);
    flex-shrink: 0;
    margin-top: 6px;
}

.content-wrapper {
    /* Held in its own token, not read straight off --ctl-section-pad, because
       .detail-page-body has to bleed back out over exactly this much padding
       (see below) and the phone breakpoint overrides the pane inset only. */
    --ctl-pane-pad: var(--ctl-section-pad); /* §5: 24px comfortable / 12px compact */
    padding: var(--ctl-pane-pad);
    flex: 1;
    overflow-y: auto;
    overflow-x: hidden;
    /* Reserve the scrollbar lane permanently: content height changes (panels
       opening, tables expanding) must never shift centered/clamped layout
       horizontally as the scrollbar toggles (§8.2). */
    scrollbar-gutter: stable;
    display: flex;
    flex-direction: column;
    min-height: 0;
    background: var(--ctl-bg);
}

/* Pages with their own scroll regions suppress outer scroll */
.content-wrapper:has(.detail-page-body),
.content-wrapper:has(.table-scroll) {
    overflow: hidden;
}

/* Cards — radius comes from the CARD token, never a literal.

   This used to read --ctl-radius, the CONTROL token, which is 0 in Industrial
   and Operator: every card in the app rendered as a hard 90° box unless some
   scope had gone and rounded it by hand. Four had — the portal, the ticket
   detail body, the record-section cards and the dashboard home — each with its
   own local token remap and its own copy of the same paragraph explaining why.
   "Round the corners on all cards" is that decision made once, in the one place
   that reaches every card including the ones nobody has rounded yet.

   --ctl-radius-card is preset-invariant (see :root), so this stays exactly one
   .card rule for all six sheets. The four scoped overrides above/below still
   win on specificity and still express "this scope's cards follow this scope's
   container token" — which is what keeps the dashboard home's cards at the
   launcher tile's 10px next to the tiles they sit beside. */
.card {
    border: 1px solid var(--ctl-card-border);
    border-radius: var(--ctl-radius-card);
    box-shadow: var(--ctl-card-shadow);
    background: var(--ctl-bg-elevated);

    /* Corner outline, once, for every card in the app.

       Bootstrap derives --bs-card-inner-border-radius — the radius it hands to
       .card-header:first-child, .card-footer:last-child and a flush
       .list-group — from the GLOBAL --bs-border-radius, NOT from the card's own
       --bs-card-border-radius:

           --bs-card-inner-border-radius: calc(var(--bs-border-radius) - 1px)

       So every scope that rounds its cards by re-pointing the CARD token left
       the header behind at the page-wide value. A .card-header carries an
       opaque background (see the rule below) and a child's background is not
       clipped by its parent's border-radius, so a squarer header simply PAINTS
       OVER the card's own corner arc and the outline vanishes at the top two
       corners. Bottom corners go the same way via .card-footer:last-child and a
       flush .list-group. Reported on the portal's My Tickets card, and true of
       all five headed cards on that surface; the dashboard-home scope carries
       the same mistake but happens to hold no headed card today, so there it
       was latent rather than visible.

       Re-point the derivation at the card's OWN radius and the whole class of
       defect closes here, for the scopes that exist today and any added later.
       --bs-card-border-radius is restated alongside so the two provably track
       the border-radius above rather than agreeing by coincidence through the
       :root bridge. The literal 1px is the border on line one — the one
       actually painted — not var(--bs-card-border-width), which our own border
       shorthand overrode.

       This is now load-bearing for EVERY card rather than for the four scopes
       that had rounded themselves: with --ctl-radius-card at 12px app-wide, the
       page-wide --bs-border-radius Bootstrap would otherwise derive from is
       the 8px CONTROL radius (the 2026-07-31 baseline), so every headed card
       in the app — not just the portal's — would paint a 7px header corner
       over a 12px arc. One rule, whole class of defect, closed. */
    --bs-card-border-radius: var(--ctl-radius-card);
    --bs-card-inner-border-radius: calc(var(--bs-card-border-radius) - 1px);
}

.card-header {
    background: var(--ctl-bg-elevated);
    border-bottom: 1px solid var(--ctl-border);
    font-weight: 600;
}

/* Tables */
.table-responsive {
    border-radius: var(--ctl-radius);
    overflow: visible;
}

/* Scrollable table body with sticky header */
.table-scroll {
    overflow: auto;
    flex: 1;
    min-height: 0;
}

.table-scroll .table {
    margin-bottom: 0;
    width: auto;
    min-width: 0;
    /* Separate borders so box-shadow on sticky cells isn't clipped */
    border-collapse: separate;
    border-spacing: 0;
}

/* No text wrapping in table cells — overflow shows as ellipsis with tooltip */
.table-scroll .table td,
.table-scroll .table th {
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
    max-width: 300px;
}

.table-scroll .table thead th {
    position: sticky;
    top: 0;
    z-index: 10;
    border-bottom: 1px solid var(--ctl-border);
}

/* List page card fills available space — scoped via :has(.table-scroll) so
   the flex:1 + min-height:0 + overflow:hidden combo only applies to cards
   that actually wrap a scrollable table (list_table.php). Pages that stack
   multiple natural-height cards under .content-wrapper (e.g. /settings/system,
   which renders one card per setting group) would otherwise share flex:1 and
   collapse to ~2px each. The :has(.table-scroll) selector matches the same
   list-page-shaped content that .content-wrapper:has(.table-scroll) already
   keys off (line ~1679 above).

   ONE WRAPPER DEEP, too. The ticket list renders its card inside a swap region
   (<div data-tlist-region="table">, tickets/_list_region.php, #1421) so a chip
   or sort click can replace just that subtree. That put a plain block box
   between .content-wrapper and the card and silently took the card out of this
   CHILD-combinator rule: the card went back to sizing itself to its content, so
   .table-scroll — which is flex:1/min-height:0 and only becomes a scroller when
   something above it is height-constrained — grew to the full height of 25 rows
   instead, and the whole table overflowed a .content-wrapper that is
   overflow:hidden on exactly this kind of page (the :has(.table-scroll) rule
   above). An overflow:hidden box is not a user scroll target, so /tickets could
   not be wheeled AT ALL: the rows below the fold were simply unreachable.

   The wrapper joins the chain rather than the selector being loosened to a
   descendant. `.content-wrapper .card:has(.table-scroll)` would hand flex:1 +
   overflow:hidden to every ANCESTOR card as well on a page that nests them,
   which is the 2px-strip collapse this rule was scoped to avoid in the first
   place. Keyed on the data attribute, not a new class, because that attribute
   is already the region contract app.js and tickets-list-live.js swap on — a
   fresh region node from a swap carries it by construction. */
.content-wrapper > [data-tlist-region="table"] {
    flex: 1;
    display: flex;
    flex-direction: column;
    min-height: 0;
    /* No overflow:hidden here — the card below owns the clip. The region also
       hosts the column dropdowns and the inline-edit menu, and both are
       position:fixed precisely so they can escape it. */
}

.content-wrapper > .card:has(.table-scroll),
.content-wrapper > [data-tlist-region="table"] > .card:has(.table-scroll) {
    flex: 1;
    display: flex;
    flex-direction: column;
    min-height: 0;
    overflow: hidden;
}

.content-wrapper > .card:has(.table-scroll) > .card-body,
.content-wrapper > [data-tlist-region="table"] > .card:has(.table-scroll) > .card-body {
    flex: 1;
    display: flex;
    flex-direction: column;
    min-height: 0;
    overflow: hidden;
}

/* Row padding is density-driven (§5: 10px comfortable / 4px compact) */
.table > :not(caption) > * > * {
    padding-top: var(--ctl-table-cell-pad-y);
    padding-bottom: var(--ctl-table-cell-pad-y);
}

.table thead th:not(.col-header) {
    background: var(--ctl-bg-alt);
    font-weight: 500;
    text-transform: uppercase;
    font-size: var(--ctl-fs-caption);
    letter-spacing: 0.04em;
    color: var(--ctl-fg-muted);
}

.table thead th.col-header {
    background: var(--ctl-bg-alt);
}

.table tbody td {
    font-size: var(--ctl-fs-body);
    color: var(--ctl-fg);
}

.table tbody tr:hover {
    background-color: var(--ctl-bg-hover);
}

/* Vertical grid lines — shared list table only (scoped to the column-prefs
   table the list_table.php partial renders, so dashboards / detail tables
   stay borderless). Last column drops its right border. */
.table[data-pref-key] thead th,
.table[data-pref-key] tbody td,
.table[data-pref-key] tfoot td {
    border-right: 1px solid var(--ctl-border);
}

.table[data-pref-key] thead th:last-child,
.table[data-pref-key] tbody td:last-child,
.table[data-pref-key] tfoot td:last-child {
    border-right: none;
}

/* Table status bar */
.table-status-bar {
    display: flex;
    align-items: center;
    justify-content: space-between;
    padding: 0.5rem 0.75rem;
    border-top: 1px solid var(--ctl-border);
    background: var(--ctl-bg-alt);
    font-size: 0.8rem;
    color: var(--ctl-fg-muted);
    border-radius: 0 0 var(--ctl-radius) var(--ctl-radius);
    flex-wrap: wrap;
    gap: 0.5rem;
}

.table-status-bar .status-left,
.table-status-bar .status-right {
    display: flex;
    align-items: center;
    gap: 0.75rem;
}

.table-status-bar .per-page-select {
    display: flex;
    align-items: center;
    gap: 0.35rem;
}

.table-status-bar .per-page-select select {
    width: auto;
    padding: 0.15rem 1.5rem 0.15rem 0.4rem;
    font-size: 0.8rem;
    border-color: var(--ctl-border-strong);
}

/* Column header dropdowns */
.col-header {
    padding: 0 !important;
}

.col-header-wrapper {
    position: relative;
}

.col-header-btn {
    font-weight: 500;
    text-transform: uppercase;
    font-size: var(--ctl-fs-caption);
    letter-spacing: 0.04em;
    color: var(--ctl-fg-muted);
    cursor: grab;
    padding: var(--ctl-table-cell-pad-y) 0.75rem;
    width: 100%;
    display: flex;
    align-items: center;
    white-space: nowrap;
    user-select: none;
}

.col-header-btn.has-filter {
    color: var(--ctl-accent-text);
}

.col-header-label {
    flex: 1;
    display: flex;
    align-items: center;
    gap: 0.3rem;
}

.col-menu-trigger {
    cursor: pointer;
    color: var(--ctl-fg-muted);
    font-size: 0.55rem;
    padding: 0.1rem 0.2rem;
    border-radius: var(--ctl-radius);
}

.col-menu-trigger:hover {
    color: var(--ctl-accent-text);
    background: rgba(var(--ctl-accent-rgb), 0.1);
}

.col-header-indicators {
    display: flex;
    align-items: center;
    gap: 0.25rem;
    margin-left: auto;
}

.col-indicator {
    font-size: 0.65rem;
}

/* Filtered-column mark — the visible link between the "N filters" control and
   the data it acts on. The funnel inherits .has-filter's accent colour and sits
   on a faint accent tint (same language as .col-menu-trigger:hover), and the
   header gets a 2px accent underline so a filtered column reads at a glance
   across a wide table. Colour is never the only signal: the icon carries a
   "Filtered by …" title + aria-label (list_table.php). Underline via ::after on
   the positioned .col-header-wrapper, deliberately NOT box-shadow — the pinned
   columns use box-shadow for their sticky separator. */
.col-indicator-filter {
    background: rgba(var(--ctl-accent-rgb), 0.12);
    border-radius: var(--ctl-radius);
    padding: 0.05rem 0.2rem;
}

/* …and it is a button, not a glyph: clicking the mark opens the criteria that
   set it (app.js openColMenu). Reset the UA button chrome so it still reads as
   the same small funnel — the only visible difference is the pointer and the
   hover tint, which are the same language .col-menu-trigger already speaks.
   `cursor: pointer` also has to beat .col-header-btn's `cursor: grab`. */
button.col-indicator-filter {
    display: inline-flex;
    align-items: center;
    border: 0;
    color: inherit;
    line-height: 1;
    cursor: pointer;
}

button.col-indicator-filter:hover,
button.col-indicator-filter:focus-visible {
    background: rgba(var(--ctl-accent-rgb), 0.28);
    color: var(--ctl-accent-text);
}

.col-header-btn.has-filter::after {
    content: "";
    position: absolute;
    left: 0;
    right: 0;
    bottom: 0;
    height: 2px;
    background: var(--ctl-accent-text);
    pointer-events: none;
}


/* The dropdown panel — fixed position to escape table overflow and sticky headers */
.col-dropdown {
    display: none;
    position: fixed;
    z-index: var(--ctl-z-dropdown);
    min-width: 220px;
    background: var(--ctl-bg-elevated);
    border: 1px solid var(--ctl-border);
    border-radius: var(--ctl-radius);
    box-shadow: 0 4px 12px var(--ctl-shadow);
    padding: 0.375rem 0;
    color: var(--ctl-fg);
}

.col-header-wrapper.open > .col-dropdown {
    display: block;
}

/* Pin / Hide icon row at top of column dropdown */
.col-dropdown-actions {
    display: flex;
    gap: 0.25rem;
    padding: 0.3rem 0.5rem;
    justify-content: flex-end;
}

.col-dropdown-action {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 1.75rem;
    height: 1.75rem;
    border: 1px solid var(--ctl-border);
    background: var(--ctl-bg-elevated);
    color: var(--ctl-fg-muted);
    border-radius: var(--ctl-radius);
    cursor: pointer;
    font-size: 0.8rem;
}

.col-dropdown-action:hover {
    color: var(--ctl-accent-text);
    border-color: var(--ctl-accent);
    background: var(--ctl-bg-hover);
}

.col-dropdown-section {
    padding: 0.25rem 0;
}

.col-dropdown-label {
    font-size: 0.65rem;
    font-weight: 600;
    text-transform: uppercase;
    letter-spacing: 0.05em;
    color: var(--ctl-fg-muted);
    padding: 0.25rem 0.75rem;
}

.col-dropdown-divider {
    border-top: 1px solid var(--ctl-border);
    margin: 0.25rem 0;
}

/* Names the quick filter that marked this header (list_table.php) — reads as
   body text, not as another clickable item. */
.col-dropdown-note {
    padding: 0 0.75rem 0.15rem;
    font-size: 0.8rem;
    color: var(--ctl-fg);
}

.col-dropdown-item {
    display: flex;
    align-items: center;
    padding: 0.35rem 0.75rem;
    font-size: 0.85rem;
    color: var(--ctl-fg);
    text-decoration: none;
    cursor: pointer;
}

.col-dropdown-item:hover {
    background: var(--ctl-bg-hover);
    color: var(--ctl-fg);
}

.col-dropdown-item.active {
    color: var(--ctl-accent-text);
    font-weight: 500;
}

.col-dropdown-item.text-danger:hover {
    background: var(--ctl-danger-bg);
}

/* Text filter inside dropdown */
.col-filter-form {
    padding: 0.25rem 0.75rem;
}

/* Scrollable filter list inside dropdown */
.col-filter-scroll {
    max-height: 200px;
    overflow-y: auto;
}

/* Checkbox filters */
.col-dropdown-check {
    display: flex;
    align-items: center;
    gap: 0.5rem;
    padding: 0.3rem 0.75rem;
    font-size: 0.85rem;
    cursor: pointer;
    margin: 0;
}

.col-dropdown-check:hover {
    background: var(--ctl-bg-hover);
}

.col-dropdown-check input[type="checkbox"] {
    margin: 0;
}

/* Column settings panel — hidden until JS restores state to prevent jitter on reload */
.col-settings-wrapper {
    position: relative;
    visibility: hidden;
}

.col-settings-panel {
    display: none;
    position: absolute;
    right: 0;
    top: 100%;
    width: 260px;
    max-height: 70vh;
    background: var(--ctl-bg-elevated);
    border: 1px solid var(--ctl-border);
    border-radius: var(--ctl-radius);
    box-shadow: 0 4px 12px var(--ctl-shadow);
    z-index: var(--ctl-z-dropdown);
    padding: 0;
    color: var(--ctl-fg);
}

.col-settings-wrapper.open .col-settings-panel {
    display: flex;
    flex-direction: column;
}

.col-settings-header {
    display: flex;
    align-items: center;
    justify-content: space-between;
    padding: 0.35rem 0.75rem;
    border-bottom: 1px solid var(--ctl-border);
}

.col-settings-title {
    font-size: 0.7rem;
    font-weight: 600;
    text-transform: uppercase;
    letter-spacing: 0.05em;
    color: var(--ctl-fg-muted);
}

.col-settings-reset {
    background: none;
    border: none;
    color: var(--ctl-fg-muted);
    cursor: pointer;
    padding: 0.15rem;
    font-size: 0.85rem;
    line-height: 1;
}

.col-settings-reset:hover {
    color: var(--ctl-accent-text);
}

.col-settings-list {
    list-style: none;
    margin: 0;
    padding: 0.25rem 0;
    overflow-y: auto;
    flex: 1;
    min-height: 0;
}

.col-settings-item {
    display: flex;
    align-items: center;
    gap: 0.35rem;
    padding: 0.3rem 0.75rem;
    font-size: 0.8rem;
    cursor: grab;
    transition: background 0.1s;
}

.col-settings-item:active {
    cursor: grabbing;
}

.col-settings-item.dragging {
    opacity: 0.5;
    background: var(--ctl-bg-hover);
}

/* No drop-line marker here any more: the rows themselves slide out of the way
   as the grip crosses them (.is-flipping), so a 2px rule promising where the row
   *would* land is drawing something the list has already done. */

.col-settings-drag {
    color: var(--ctl-fg-faint);
    cursor: grab;
    flex-shrink: 0;
    touch-action: none;
}

.col-settings-label {
    flex: 1;
    display: flex;
    align-items: center;
    gap: 0.35rem;
    cursor: pointer;
    margin: 0;
}

.col-settings-label input[type="checkbox"] {
    margin: 0;
}

.col-settings-pin {
    background: none;
    border: none;
    color: var(--ctl-fg-faint);
    cursor: pointer;
    padding: 0.1rem;
    font-size: 0.8rem;
    line-height: 1;
    flex-shrink: 0;
}

.col-settings-pin:hover,
.col-settings-pin.active {
    color: var(--ctl-accent-text);
}

/* ── Views menu rows that carry a toggle ────────────────────────────────────
   The Views menu is also the chip row's manager: every default carries an eye
   (put the chip away / bring it back) and every saved view a pin (promote it
   into the row). The toggle sits in the SAME slot on both kinds of row, so the
   column reads as one control rather than two features that happen to be
   stacked — which is the whole reason the row is a flex line and the item keeps
   the full click target it had as a plain .dropdown-item.

   Deliberately the .col-settings-pin treatment, token for token: a quiet faint
   glyph that goes to the theme's certified accent ink when it is on or hovered.
   The gear panel's pin is the same gesture on the same kind of list, and two
   different-looking answers to it would be the drift. */
.sv-row {
    display: flex;
    align-items: center;
}

.sv-row > .dropdown-item {
    flex: 1 1 auto;
    min-width: 0;
}

.sv-row-toggle {
    flex: 0 0 auto;
    background: none;
    border: none;
    color: var(--ctl-fg-faint);
    cursor: pointer;
    padding: 0.25rem 0.6rem;
    font-size: 0.85rem;
    line-height: 1;
}

.sv-row-toggle:hover,
.sv-row-toggle.active {
    color: var(--ctl-accent-text);
}

.sv-row-toggle:focus-visible {
    outline: 2px solid var(--ctl-accent);
    outline-offset: 1px;
    border-radius: var(--ctl-radius);
}

.col-settings-footer {
    display: flex;
    gap: 0.35rem;
    padding: 0.4rem 0.75rem;
    border-top: 1px solid var(--ctl-border);
    background: var(--ctl-bg-elevated);
    justify-content: flex-end;
}

/* Pinned columns — sticky left inside single scrollable table */
.col-pinned {
    position: sticky;
    z-index: 2;
}

/* A sticky cell holds its screen position while the rest of its row scrolls out
   from under it, so it has to paint its OWN opaque surface — it cannot borrow
   the <tr>'s. Bootstrap paints every table cell from
   `.table > :not(caption) > * > * { background-color: var(--bs-table-bg) }`,
   specificity (0,1,1), which OUTRANKS a bare `.col-pinned` (0,1,0) — and this
   sheet deliberately sets `--bs-table-bg: transparent` on .table (see ~line 475)
   so card surfaces and row-hover show through ordinary rows. Net effect before
   this rule existed: pinned body cells painted nothing at all and the
   horizontally-scrolled columns slid visibly through them.

   So re-point Bootstrap's own variable ON the pinned cell instead of fighting
   its cascade with a shorthand that loses: Bootstrap's declaration then paints
   the opaque token, and the striped / hover / active state layers Bootstrap
   drives off the same variable chain keep working untouched. Both tokens are
   opaque hex in all six theme sheets (3 styles x light/dark), so this holds in
   dark mode too.

   Scoped to tbody on purpose: thead and tfoot carry .table-light, which gives
   them an opaque `--bs-table-bg: var(--ctl-bg-alt)` already, and the header
   keeps its own alt-surface tone in the rule below. */
.table tbody .col-pinned {
    --bs-table-bg: var(--ctl-bg-elevated);
    background-color: var(--ctl-bg-elevated);
}

.table-scroll .table thead th.col-pinned {
    z-index: 12 !important;
    --bs-table-bg: var(--ctl-bg-alt);
    background-color: var(--ctl-bg-alt);
}

/* Hover has to restate the surface for the same reason — and now outranks both
   Bootstrap and the base pinned rule on specificity, so no !important needed. */
.table tbody tr:hover .col-pinned {
    --bs-table-bg: var(--ctl-bg-hover);
    background-color: var(--ctl-bg-hover);
}

/* Separator on the last pinned column. `border-right` keeps !important because
   `.table[data-pref-key] tbody td` (0,2,2) sets a competing 1px border.
   No box-shadow here: Bootstrap owns `box-shadow` on table cells (it paints the
   striped/hover state layer as a 9999px inset) from a higher-specificity
   selector, so the drop shadow this rule used to declare never rendered — a
   depth cue would have to be re-done as an overlay element, not a cell shadow. */
.col-pinned-last {
    border-right: 2px solid var(--ctl-border-strong) !important;
}

/* Closed / terminal list rows (tickets: RESOLVED / CLOSED / CANCELLED) read as
   settled — dimmed, but still scannable and still hover/click-normal.
   Deliberately NOT `opacity` on the <tr>: that would make a pinned sticky cell's
   background translucent and let the scrolled-under row bleed through it. Text
   nodes take the muted colour, element children (badges, <code>, icons) take the
   fade, and every cell BACKGROUND stays fully opaque. */
tr.list-row-closed > td {
    color: var(--ctl-fg-muted);
}

tr.list-row-closed > td > * {
    opacity: 0.65;
}

/* …and the row sits on a quieter surface, because dimmed ink alone was not
   enough to make a block of settled tickets read at a glance. The tone is the
   sheet's own page ground (see --ctl-row-settled-bg): a settled row stops
   looking like card material.

   Set on the <tr>, NOT on the cells, and deliberately WITHOUT a leading
   `.table` — both facts are load-bearing:
     · `.table` sets `--bs-table-bg: transparent`, so an ordinary cell paints
       nothing and whatever the ROW carries shows through. Painting the row is
       therefore the same mechanism `.table tbody tr:hover` already uses, and it
       leaves every translucent CELL state (the keyboard-focus accent at
       `tr.is-key-focused > td`) still compositing over the top instead of being
       overpainted by an opaque cell background.
     · Specificity is (0,1,2) so `.table tbody tr:hover` (0,2,2) keeps winning.
       Adding `.table` here would tie with the hover rule and — being further
       down the file — silently beat it, leaving settled rows with no hover
       feedback at all.
   Contrast is not a concern in either direction: --ctl-bg-hover is the
   worst-case surface in all six sheets (the darkest in light, the lightest in
   dark), and the settled tone stays on the easy side of it in both — so text on
   a settled row always reads better than text on a hovered row, which is the
   case CssTokenContractTest already binds at AA. */
tbody tr.list-row-closed {
    background-color: var(--ctl-row-settled-bg);
}

/* Pinned columns are sticky, so they cannot borrow the row's background — they
   have to restate it, through --bs-table-bg for the reason documented on
   `.table tbody .col-pinned` above. (0,2,2): above the base pinned rule
   (0,2,1), below `.table tbody tr:hover .col-pinned` (0,3,2), so a hovered
   settled row still lights up across the pinned block too. */
tbody tr.list-row-closed .col-pinned {
    --bs-table-bg: var(--ctl-row-settled-bg);
    background-color: var(--ctl-row-settled-bg);
}

/* Ticket list: every cell centres its content vertically.
   A ticket row mixes one-line text (number, priority, status) with avatar
   bubbles (Requester, Assigned To) and two-tone SLA timestamps, and Bootstrap
   top-aligns table cells (`$table-cell-vertical-align: top`), so a row read as
   three staggered baselines — worst on the columns beside an avatar.

   Scoped to the ticket table by the column-prefs key it already carries rather
   than applied in partials/list_table.php: that archetype backs EVERY list in
   the app (assets, users, saved queries, the MRP workbench, report tables with
   a totals footer), and re-aligning all of them off the back of a ticket-list
   ask is a change nobody looked at. Any other list opts in by adding its pref
   key to this selector — or the selector widens to `.table[data-pref-key]` once
   someone has eyeballed the rest. */
.table-scroll .table[data-pref-key="list_columns.tickets"] > tbody > tr > td {
    vertical-align: middle;
}

/* ...and that centring only holds if the cell's own line box is honest about
   how tall its content is. An avatar chip — <span class="d-inline-flex">avatar
   + name</span> — is an inline box, so the line box positions it on the
   BASELINE, and an inline-flex box borrows its baseline from its first flex
   item. For an initials bubble that item is text, so the bubble lands beside
   the label exactly as intended. For a PHOTO bubble it is an <img>, which has
   no baseline of its own: the browser falls back to the image's bottom edge,
   the whole chip is hoisted a full descent (~4px at this font size) above
   where it belongs, and the line box grows by the same amount underneath it —
   so the chip reads high, the row is padded with dead space, and the two
   avatar columns (Requester = photo, Assigned To = initials, or the reverse)
   disagree with each other row by row. Taking the chip off the baseline fixes
   both symptoms at once and makes photo and initials chips interchangeable.
   Scoped to list tables: that is where the misalignment is visible against a
   column of neighbours, and it keeps every other avatar surface (navbar,
   chat, pickers) exactly as it renders today. */
.table-scroll .table td span:has(> .uee-avatar) {
    vertical-align: middle;
}

/* ── In-row cell editor (js/tickets-list-edit.js) ───────────────────────────
   One presentation for all five editable ticket columns (Status, Priority,
   Type, Category, Assigned To). The whole CELL is the control: it reads as text
   until you point at it — a cell must still scan as its value in a table of
   values, not as a form control — and the caret parked on the cell's right edge
   is the only permanent hint that it is actionable.

   The cell surrenders its padding to the button rather than the button trying to
   claw the padding back with negative margins: `.table-scroll .table td` is
   overflow:hidden, so anything hanging past the content box gets the avatar
   sliced off. Padding moves, so the value still lines up with the read-only
   columns beside it. */
/* The cell is the positioning context for the full-bleed layer below. It must
   NOT become a stacking context: the open editor's menu is a child of this
   cell, and a stacking context here would trap that menu's z-index inside one
   table cell, letting every later row paint straight through an opaque popup. */
.table-scroll .table > tbody > tr > td:has(> .list-edit) {
    position: relative;
    padding: 0;
}

/* Belt and braces for the cell that currently owns the menu. A cell can pick up
   a stacking context from somewhere else — `.col-pinned` makes it sticky at
   z-index 2 — and then the menu's own z-index is measured only against that
   cell's siblings, which is how an opaque popup ends up under the next pinned
   column. Lifting the OPEN cell to the dropdown layer carries whatever it holds
   with it, so the menu clears the table no matter what context the cell is in.
   Only ever one cell at a time: the JS closes the previous editor first. */
.table-scroll .table > tbody > tr > td:has(> .list-edit.open) {
    z-index: var(--ctl-z-dropdown);
}

.list-edit {
    display: flex;
    width: 100%;
    min-width: 0;
}

.list-edit-trigger {
    display: flex;
    align-items: center;
    gap: 0.35rem;
    width: 100%;
    min-width: 0;
    padding: var(--ctl-table-cell-pad-y) var(--bs-table-cell-padding-x, 0.5rem);
    border: 0;
    background: transparent;
    color: inherit;
    font: inherit;
    text-align: left;
    cursor: pointer;
}

/* The trigger deliberately does NOT stretch to the cell. It sits at its own
   content height and the cell's `vertical-align` centres it exactly like the
   read-only text beside it — one mechanism, one result, whatever the row has
   been stretched to by an avatar.
   ...which leaves the affordance needing to cover a cell the button no longer
   fills, so this pseudo IS the affordance: `inset: 0` against the cell (the
   button itself is unpositioned, so the cell is the containing block) is the
   full cell by construction — no height:100% chain to resolve, so no engine
   gets to disagree about it. An earlier fix chased that chain instead, giving
   the cell a 1px height to make the percentages resolvable; where a browser
   then honoured the cell's vertical-align by insetting the content, the
   stretched button started BELOW that inset and rode half the row's slack too
   low, which is the shift this replaces.
   It is a ::before, and the value and caret below are `position: relative`,
   because that is what settles paint order WITHOUT a z-index: positioned boxes
   paint in tree order, and ::before is the trigger's first child. A negative
   z-index would do the same job but only inside a stacking context on the
   cell — and that context is exactly what buried the open menu behind later
   rows, so this route keeps the cell transparent to stacking.
   Hit-testing is unaffected, so every pixel of the cell is still a click on
   the BUTTON — which is also what keeps app.js's row-click navigation out of
   the way (it ignores clicks on buttons), so an editable cell opens its editor
   and every other cell still opens the ticket. Nothing pokes outside the cell,
   so overflow:hidden clips nothing. */
.list-edit-trigger::before {
    content: '';
    position: absolute;
    inset: 0;
}

/* Border via inset box-shadow on the LAYER, so appearing on hover shifts
   nothing. Safe here — Bootstrap only owns box-shadow on the td itself. */
.list-edit-trigger:hover:not(:disabled)::before,
.list-edit.open .list-edit-trigger::before {
    background: var(--ctl-bg-hover);
    box-shadow: inset 0 0 0 1px var(--ctl-border);
}

.list-edit-trigger:focus-visible {
    outline: none;
}

/* Inset, not the shared --ctl-focus-ring: a spread shadow on a full-bleed
   cell child is clipped by the cell's overflow:hidden. */
.list-edit-trigger:focus-visible::before {
    box-shadow: inset 0 0 0 2px var(--ctl-accent);
}

.list-edit-trigger:disabled {
    cursor: progress;
}

/* Positioned so it paints AFTER the trigger's full-bleed ::before layer — see
   the note there. No offsets, so nothing moves. */
.list-edit-value {
    position: relative;
    min-width: 0;
    overflow: hidden;
    text-overflow: ellipsis;
}

/* margin-left:auto parks the caret on the cell's right edge, so every editable
   cell in a column shows its affordance at the SAME x — a straight column of
   chevrons instead of a ragged one tracking each value's width. */
.list-edit-caret {
    position: relative;
    flex: 0 0 auto;
    margin-left: auto;
    font-size: 0.65em;
    color: var(--ctl-fg-subtle);
}

.list-edit-trigger:hover .list-edit-caret,
.list-edit.open .list-edit-caret {
    color: var(--ctl-fg-muted);
}

/* position: fixed, placed by JS. `.table-scroll` scrolls and `.table td` is
   overflow:hidden, so an absolutely-positioned menu inside a cell would be
   clipped twice — the same reason .col-dropdown is fixed. The identity picker
   (uee-user-picker.js) shares this popup wholesale — same surface, same search
   row, same option rows, same placement escape hatch — because the in-row
   editor is the model every "pick a value for this field" surface follows, and
   the sidebar it opens in is a scroll container with the same clipping problem.
   ONE rule set, two consumers: they cannot drift apart. */
.list-edit-menu,
.uee-picker-menu {
    display: none;
    position: fixed;
    z-index: var(--ctl-z-dropdown);
    min-width: 15rem;
    max-width: min(92vw, 22rem);
    background: var(--ctl-bg-elevated);
    color: var(--ctl-fg);
    border: 1px solid var(--ctl-border);
    border-radius: var(--ctl-radius);
    box-shadow: 0 4px 12px var(--ctl-shadow-hover);
    white-space: normal;
}

.list-edit.open .list-edit-menu {
    display: block;
}

.list-edit-search,
.uee-picker-search {
    padding: 0.4rem;
    border-bottom: 1px solid var(--ctl-border);
}

/* overscroll-behavior: contain is the other half of the "scrolling the menu
   must not dismiss it" fix in js/tickets-list-edit.js. Without it, the wheel
   CHAINS the moment this list hits its top or bottom: the page or the table
   underneath starts moving, that outer scroll legitimately closes the menu, and
   an over-scroll of one notch reads as the popup vanishing mid-read. The
   browser does the containment natively — no wheel handler, no preventDefault,
   and touch/trackpad momentum is covered for free. */
.list-edit-options,
.uee-picker-options {
    max-height: 16rem;
    overflow-y: auto;
    overscroll-behavior: contain;
    padding: 0.25rem 0;
}

.list-edit-option,
.uee-picker-option {
    display: flex;
    align-items: center;
    gap: 0.5rem;
    width: 100%;
    padding: 0.3rem 0.6rem;
    border: 0;
    background: transparent;
    color: var(--ctl-fg);
    font-size: var(--ctl-fs-body);
    text-align: left;
    cursor: pointer;
}

.list-edit-option:hover,
.list-edit-option.active,
.uee-picker-option:hover,
.uee-picker-option.active {
    background: var(--ctl-bg-hover);
}

.list-edit-option-name,
.uee-picker-option-name {
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

/* The people-menus' option rows carry a SECOND line — the identity picker's
   "@username · email", the @mention menu's, and (since #2082) the ticket-list
   in-row assignee editor's, which shows the address alone because its roster
   carries no username (see the shared muted rule down in the
   .mention-item-username block, js/uee-user-picker.js metaLine, and
   js/tickets-list-edit.js agentOptions). An addition to the shared anatomy
   above rather than a fork of it: the in-row editor's OTHER fields — statuses
   and priorities — have no second line, so a meta-less option row stays a
   single .name line at the height it always had.

   Column flex blockifies the two spans so each can ellipsize on its own, and
   min-width:0 is what lets them: a flex item's default min-width is `auto`, so
   without it a long address pushes the row wide instead of truncating — and two
   of the five surfaces this component serves draw inside a width-capped popup,
   where "wide" means clipped and unreadable. */
.list-edit-option-body,
.uee-picker-option-body {
    display: flex;
    flex-direction: column;
    flex: 1 1 auto;
    min-width: 0;
}

.list-edit-option-meta,
.uee-picker-option-meta {
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

/* Aligns the clear glyph with the avatar column above it (.uee-avatar-sm). */
.list-edit-option-icon,
.uee-picker-option-icon {
    width: 1.5rem;
    text-align: center;
    color: var(--ctl-fg-muted);
}

/* Status / priority options get a tone swatch where a person option gets an
   avatar — same 1.5rem alignment column as .list-edit-option-icon, so labels
   line up whichever field's menu is open. Colour rides the option's own
   .text-status-* / .text-risk-* class through currentColor, exactly as the
   detail page's pills do. */
.list-edit-option-dot {
    flex: 0 0 auto;
    width: 1.5rem;
    text-align: center;
    line-height: 0;
}

.list-edit-option-dot::before {
    content: '';
    display: inline-block;
    width: 0.45rem;
    height: 0.45rem;
    border-radius: 50%;
    background: currentColor;
}

.list-edit-clear,
.uee-picker-option-empty {
    color: var(--ctl-fg-muted);
}

.list-edit-empty,
.uee-picker-empty {
    padding: 0.5rem 0.6rem;
    font-size: var(--ctl-fs-caption);
}

/* ── Identity picker (js/uee-user-picker.js) ────────────────────────────────
   The same editor as above, off the table: the popup, the search row and the
   option rows are literally the rules above — only the CLOSED state differs.
   A ticket row can be a bare cell that reads as text; a form field cannot, so
   the closed state here is a CHIP: one bordered button holding the avatar and
   the name of whoever is selected, with the caret parked on its right edge.

   Why a chip and not a text input: a face sitting outside the control read as
   decoration beside a text field rather than as the value the control holds,
   and a control that IS its own search box can end up displaying text that
   disagrees with the id underneath it. The search lives in the popup and is
   discarded on close, so the chip can only ever show a real selection.

   It sits directly after the .list-edit rules so its own display/anchoring
   overrides land after the shared ones. */
.uee-picker {
    position: relative;
    display: flex;
    min-width: 0;
}

.uee-picker.open > .uee-picker-menu { display: block; }

.uee-picker-chip {
    display: flex;
    align-items: center;
    gap: 0.4rem;
    flex: 1 1 auto;
    min-width: 0;
    width: 100%;
    padding: 0.2rem 0.5rem;
    border: 1px solid var(--ctl-border);
    border-radius: var(--ctl-radius);
    background-color: var(--ctl-bg-inset);
    color: var(--ctl-fg);
    font: inherit;
    text-align: start;
    cursor: pointer;
}

.uee-picker-chip:hover,
.uee-picker.open .uee-picker-chip { border-color: var(--ctl-border-strong); }

.uee-picker-chip:focus-visible {
    outline: none;
    border-color: var(--ctl-accent);
    box-shadow: var(--ctl-focus-ring);
}

/* The face slot always exists, so the name never shifts when the avatar is
   swapped for the neutral "nobody" glyph and back. */
.uee-picker-face {
    display: inline-flex;
    align-items: center;
    flex: 0 0 auto;
}

.uee-picker-chip-name {
    flex: 1 1 auto;
    min-width: 0;
    overflow: hidden;
    white-space: nowrap;
    text-overflow: ellipsis;
}

/* Nobody selected reads as a placeholder, not as a person called "Unassigned". */
.uee-picker-chip-empty {
    color: var(--ctl-fg-subtle);
    font-weight: 400;
}

/* Parked on the right edge like the in-row editor's caret, for the same reason:
   every picker in a column shows its affordance at the same x. */
.uee-picker-caret {
    flex: 0 0 auto;
    margin-left: auto;
    font-size: 0.65em;
    color: var(--ctl-fg-subtle);
}

.uee-picker-chip:hover .uee-picker-caret,
.uee-picker.open .uee-picker-caret { color: var(--ctl-fg-muted); }

/* Quiet skin — the sidebar identity rows, where the picker sits where a name
   used to simply read. It is a BUBBLE: one full-radius frame enclosing the
   face, the name and the caret, in the same pill vocabulary as the .detail-pill
   row immediately above it and the requester's email bubble beside it ("full
   radius is reserved for chips/pills; structure stays square").

   It was previously drawn borderless with only `border-bottom-color` set — a
   deliberate hairline "underline" meant to whisper *this is a control*. It read
   as a defect instead: `.detail-combo` stretched the button across the whole
   identity row, so the hairline ran a good 11rem past the end of the name and
   looked like a stray grey rule floating under the avatar (nothing to do with
   any browser default — the <button> chrome was already reset above). Drawing
   the frame on all four sides and letting the control shrink to its content
   leaves nothing that can be mistaken for a divider. */
.uee-picker-quiet .uee-picker-chip {
    /* Hug the value. The stretched button is what made the old underline read
       as a rule; a bubble that ends where the name ends cannot. */
    flex: 0 1 auto;
    width: auto;
    max-width: 100%;
    /* One 4px cap of padding on the top, the bottom and the left, so the face is
       the same distance from all three edges it touches. That equality is what
       makes the round end read as a cap rather than a rim: padding P around a
       face of size d makes the bubble d + 2P tall, so the cap's radius is
       exactly P + d/2 — the centre of the face lands on the centre of the cap.
       The first cut used 1.6px top/bottom and 2.4px left, which put 24px of
       avatar inside a 29px bubble: the face filled the bubble vertically and the
       frame looked like a ring drawn around the avatar (Ryan, 2026-07-28).
       4px is the smallest step on the ramp that reads as deliberate clearance
       and costs the identity row under 5px of height — a bubble holding a face
       is expected to sit a little taller than the plain .detail-pill above it.
       The right side keeps its 8px for the caret. Both values come from the 8pt
       spacing ramp rather than being em-derived, so the clearance around a
       pixel-sized avatar cannot drift with the chip's 0.85rem type. */
    padding: var(--ctl-sp-1) var(--ctl-sp-2) var(--ctl-sp-1) var(--ctl-sp-1);
    border-color: var(--ctl-border);
    border-radius: var(--ctl-radius-full);
    background-color: var(--ctl-bg-hover);
    font-size: 0.85rem;
    font-weight: 600;
}

/* The face is the tallest thing in the bubble, so it — not the type — sets the
   bubble's height. Pin the slot to the avatar's own 1.5rem (.uee-avatar-sm):
   unassigned draws a bi-person-dash glyph roughly a line tall where a person
   draws the avatar, and without this the bubble and the identity row holding it
   visibly shrank the moment a ticket went unassigned and sprang back on the next
   pick — the picker rewrites this slot in place, so the jump happens live. */
.uee-picker-quiet .uee-picker-face { height: 1.5rem; }

.uee-picker-quiet .uee-picker-chip:hover,
.uee-picker-quiet.open .uee-picker-chip {
    border-color: var(--ctl-border-strong);
    background-color: var(--ctl-bg-inset);
}

/* Equal-specificity `.uee-picker-quiet .uee-picker-chip` above would otherwise
   flatten the base chip's accent focus border, leaving the ring alone to carry
   focus. Restate it so keyboard focus is as loud here as on every other
   control. */
.uee-picker-quiet .uee-picker-chip:focus-visible {
    border-color: var(--ctl-accent);
    background-color: var(--ctl-bg-inset);
}

/* Column resize handle */
.col-resize-handle {
    position: absolute;
    right: 0;
    top: 0;
    bottom: 0;
    width: 5px;
    cursor: col-resize;
    z-index: 20;
}

.col-resize-handle:hover,
.col-resize-handle.active {
    background: var(--ctl-accent);
    opacity: 0.4;
}

/* Dragging column headers.
   The dragged column is dimmed IN PLACE — header and cells together — so the
   slot it currently occupies stays legible while the cursor ghost carries the
   label. NOTE: this is no longer the same read as the Shortcuts grid, which now
   empties its slot outright — a column is a stripe through every row of a table
   and emptying it would open a hole in the data; a launcher tile is one object
   in a loose grid and can simply leave.
   Opacity on the <td>, never on the <tr>: a translucent row washes out the row
   tints (settled/closed rows) that sit underneath. */
.col-header.dragging-header,
.table tbody td.col-dragging-cell,
.table tfoot td.col-dragging-cell {
    opacity: 0.3;
}

.col-drag-ghost {
    position: fixed;
    padding: 0.4rem 0.75rem;
    background: var(--ctl-accent);
    color: var(--ctl-accent-fg);
    font-size: 0.75rem;
    font-weight: 600;
    text-transform: uppercase;
    letter-spacing: 0.05em;
    border-radius: var(--ctl-radius);
    box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
    pointer-events: none;
    z-index: var(--ctl-z-tooltip); /* ephemeral cursor-following ghost — topmost app layer */
    white-space: nowrap;
}

/* Live reflow — FLIP play phase (app.js flipMove). The script writes the
   transform; this ONE rule owns the easing, so the motion itself lives in CSS.
   Deliberately element-agnostic, and now the ONLY reorder easing in the sheet:
   a <th>, a <td>, a gear panel row, a Shortcuts launcher tile, a dashboard
   widget card and a whole dashboard module group all flip through it (#1551).

   This replaces the old drop-line markers (.drag-target-left/-right on a header,
   .drag-over on a gear panel row): once the things themselves move aside, an
   insertion rule is drawing a promise the list has already kept.

   No reduced-motion twin needed: the script skips measure/invert/play entirely
   under that preference, and the global `*{transition-duration:0.01ms!important}`
   reset would neutralise this rule anyway. */
.is-flipping {
    transition: transform var(--ctl-dur) ease-out;
}

/* Fixed table layout when columns have explicit widths */
.table.col-resizable {
    table-layout: fixed;
}

.table.col-resizable th,
.table.col-resizable td {
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

/* Prevent text selection during drag/resize */
body.col-resizing,
body.col-dragging {
    user-select: none;
}

body.col-resizing {
    cursor: col-resize !important;
}

body.col-dragging {
    cursor: grabbing !important;
}



/* Detail page — compact toolbar */
.detail-toolbar {
    display: flex;
    align-items: center;
    gap: 0.25rem;
}

.detail-tool {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 2rem;
    height: 2rem;
    border: none;
    background: none;
    color: var(--ctl-fg-muted);
    border-radius: var(--ctl-radius);
    cursor: pointer;
    text-decoration: none;
    font-size: 0.95rem;
    transition: color 0.15s, background 0.15s;
}

.detail-tool:hover {
    color: var(--ctl-accent-text);
    background: var(--ctl-bg-hover);
}

/* The user-menu button carries an icon + the user's name — size it to its
   content instead of the fixed 2rem icon square, which made the label
   overflow onto whatever tool sat to its left. */
.detail-tool.navbar-user {
    width: auto;
    padding: 0 0.5rem;
}

.detail-tool.disabled {
    color: var(--ctl-fg-faint);
    pointer-events: none;
    cursor: default;
}

.detail-tool.text-warning:hover {
    background: var(--ctl-warning-bg);
}

.detail-tool.text-success:hover {
    background: var(--ctl-success-bg);
}

/* "Login As User" (user detail toolbar). The control itself is a plain
   .btn.btn-sm.btn-outline-primary — the app's existing labelled-outline action,
   already bridged to --ctl-accent / --ctl-accent-text, so the outline and the
   ink track every style and scheme without a literal here.

   The one thing added is the CORNER. --ctl-radius is 0px in the sharp presets
   (Industrial and Operator are square by identity) and 3px in Refined, so a
   button pinned to the raw token draws hard 90 degree corners in four of the
   six sheets — which is not what a deliberately rounded, labelled affordance is
   supposed to read as. The derivation is the app's own existing answer for a
   control in exactly that position (.ctl-page-header-back, .dashboard-edit-
   toggle): +3px off the preset's own value, so it stays visibly rounded
   everywhere while still tracking the style's corner language. It is
   deliberately NOT --ctl-radius-tile (card-sized ambient surfaces) and NOT
   --ctl-radius-full (chips, pills, avatars, badges). */
.login-as-btn {
    border-radius: calc(var(--ctl-radius) + 3px);
}

.detail-edit-actions {
    display: flex;
    align-items: center;
    gap: 0.375rem;
    margin-left: 0.5rem;
}

/* Detail page — scrollable body.

   THE HEADER HALF OF THIS BLOCK IS GONE (#2057). `.detail-page-header` and
   `.detail-page-title` were the pre-kit detail header; every detail page now
   renders its header through templates/partials/page_header.php and
   `.ctl-page-header--detail`, and a repo-wide grep of every .php/.js/.html
   found no markup left for either class — the rules could not be reached by
   any page. They were removed along with the mobile @media restatement that
   was labelled "tighter on mobile" and had always restated the desktop values
   byte-for-byte. The spacing pair now lives in exactly one rule, the kit's,
   which is where the still-open 8pt-ramp question (#2056) will be settled.
   CssTokenContractTest pins both selectors retired so they cannot drift back.

   Only the body rule below is live, and it is live everywhere: it is the
   scroll container the whole pane depends on (see the wheel-anywhere note
   under it), used by ~150 markup sites. It is NOT part of the retirement. */
.detail-page-body {
    flex: 1;
    overflow-y: auto;
    overflow-x: hidden;
    min-height: 0;
    scrollbar-gutter: stable; /* §8.2: no horizontal shift when content grows */
}

/* ── Wheel-anywhere: the scroll container must cover the whole pane ──────────
   .content-wrapper goes overflow:hidden whenever the page brings its own
   scroll body (rule above), and an overflow:hidden box is never a *user*
   scroll target — the browser will not wheel it, only script can. So every
   part of the pane NOT covered by .detail-page-body was dead to the wheel:
   the wrapper's padding ring, and (the loud one, on a wide monitor) the §8.1
   clamp gutters either side of a clamped body — hundreds of px of ticket-view
   whitespace where scrolling did nothing.

   Fix: the scroller spans the pane edge to edge and re-creates the inset —
   and the clamp — as its own padding. Geometry is unchanged by construction:
   the padding equals the inset + auto margin it replaces, so the content box
   keeps the same x-offset and the same width. That also means the parallel
   header wrapper (still max-width-clamped) stays aligned with the body (§8.3)
   without being touched.

   Child-combinator on purpose: .detail-page-body is reused as a plain section
   wrapper *inside* a body (tickets' custom-fields section), and a nested one
   must not bleed. Vertically the wrapper hands its bottom inset to the body
   rather than the body bleeding into it — no negative margin means no pulling
   on a following sibling. The visible trade: the scrollbar now rides the pane
   edge instead of the clamp edge. Only the fixed header band is still inert,
   which is conventional for a toolbar. */
.content-wrapper:has(> .detail-page-body) {
    padding-bottom: 0;
}

.content-wrapper > .detail-page-body {
    width: auto; /* stretch, so the negative margin covers BOTH sides
                    instead of sliding a 100%-wide box left */
    margin-inline: calc(-1 * var(--ctl-pane-pad));
    padding-inline: var(--ctl-pane-pad);
    padding-bottom: var(--ctl-pane-pad);
}

/* Clamped scroll bodies: clamp with symmetric padding, not max-width + auto
   margins. Percentages resolve against .content-wrapper's content width, so
   the half-gutter lands identically to the margin it replaces. (The clamp
   classes' own `margin-inline:auto` / `width:100%` are already outranked by
   the rule above — only max-width still needs releasing.) */
.content-wrapper > .detail-page-body.ctl-page-clamp,
.content-wrapper > .detail-page-body.ctl-page-clamp-narrow,
.content-wrapper > .detail-page-body.ctl-page-clamp-wide {
    max-width: none;
}
.content-wrapper > .detail-page-body.ctl-page-clamp {
    padding-inline: calc(var(--ctl-pane-pad) + max(0px, (100% - var(--ctl-content-max, 68rem)) / 2));
}
.content-wrapper > .detail-page-body.ctl-page-clamp-narrow {
    padding-inline: calc(var(--ctl-pane-pad) + max(0px, (100% - var(--ctl-content-max-narrow, 46rem)) / 2));
}
.content-wrapper > .detail-page-body.ctl-page-clamp-wide {
    padding-inline: calc(var(--ctl-pane-pad) + max(0px, (100% - var(--ctl-content-max-wide, 100rem)) / 2));
}

/* Add-permission dropdown (edit mode only) */
.editing .perm-add-wrapper.detail-input {
    display: inline-flex !important;
}

.perm-add-dropdown {
    display: none;
    position: absolute;
    right: 0;
    top: 100%;
    min-width: min(320px, 92vw); /* §8 phone clamp — never wider than the viewport */
    background: var(--ctl-bg-elevated);
    border: 1px solid var(--ctl-border);
    border-radius: var(--ctl-radius);
    box-shadow: 0 4px 12px var(--ctl-shadow-hover);
    z-index: var(--ctl-z-dropdown);
}

.perm-add-wrapper.open .perm-add-dropdown {
    display: block;
}

/* Detail page — horizontal field rows */
.detail-row {
    display: flex;
    align-items: baseline;
    padding: 0.15rem 0;
    border-bottom: 1px solid var(--ctl-border);
    gap: 0.5rem;
}

.detail-row:last-child {
    border-bottom: none;
}

.detail-section-title {
    font-size: 0.85rem;
    font-weight: 700;
    text-transform: uppercase;
    letter-spacing: 0.05em;
    color: var(--ctl-fg);
    border-bottom: 2px solid var(--ctl-border);
    padding-bottom: 0.25rem;
    padding-top: 0.75rem;
    margin-bottom: 0.15rem;
}

.detail-section-title:first-child {
    padding-top: 0;
}

/* Opt-out twin of the rule above, for a heading that visually leads its card
   but is NOT the DOM :first-child — typically `.card-body > form > csrf_field()`
   puts a hidden <input> ahead of it. Replaces the inline padding-top overrides.
   Equal specificity to the base rule, so it must stay AFTER it in source order. */
.detail-section-title-first {
    padding-top: 0;
}

.detail-label {
    font-size: 0.75rem;
    font-weight: 600;
    text-transform: uppercase;
    letter-spacing: 0.03em;
    color: var(--ctl-fg-subtle);
    white-space: nowrap;
    min-width: 7rem;
    flex-shrink: 0;
}

.detail-view {
    font-size: 0.85rem;
    flex: 1;
    min-width: 0;
    line-height: 1.3;
}

/* Inline colour swatch — a record's stored colour as a small round chip beside
   its hex (detail rows, small admin lists). Full-round per §4.4: chips, pills,
   avatars and badges own that geometry. The hairline keeps a swatch matching
   the card surface visible in every sheet. The background is the record's own
   value, set inline at the call site (sanctioned dynamic value, §11). */
.ctl-color-swatch {
    display: inline-block;
    width: 14px;
    height: 14px;
    border-radius: var(--ctl-radius-full);
    border: 1px solid var(--ctl-border);
    vertical-align: middle;
}

/* Edit mode */
.editing .detail-view {
    display: none !important;
}

/* Field-metadata slot (sync-ownership badges + future sibling pills). Sibling
   of .detail-view / .detail-input so it stays visible in both read AND edit
   modes — the user needs ownership context while editing, and the save-time
   confirmation prompt reads data-owner from these spans. */
.field-metadata-badges {
    flex-shrink: 0;
    font-size: 0.75rem;
    align-self: center;
}

.editing .detail-input {
    display: block !important;
    flex: 1;
    min-width: 0;
}

/* An input-group carrying .detail-input (e.g. a number field + unit addon like
   the SLA "min"/"hrs" targets) must stay flex, or the block override above
   collapses the input to Bootstrap's 1% width and drops the addon to its own
   line. Higher specificity than the rule above + !important beats it back. */
.editing .detail-input.input-group {
    display: flex !important;
}

/* Same reason as the input-group above: the identity picker's frame is a flex
   row (chip + caret), and the block override collapses it. */
.editing .detail-input.uee-picker {
    display: flex !important;
}

.editing .detail-input-only {
    display: flex !important;
}

/* Inputs inside detail rows: even tighter to match read-only row height */
.detail-row .form-control,
.detail-row .form-select {
    padding: 0.1rem 0.35rem;
    line-height: 1.3;
}

/* A section title that carries an action on its right (e.g. the ticket
   Details heading + its Mark As menu). The 2px rule moves from the title to
   the bar so it still runs the full width behind both. */
.detail-section-bar {
    display: flex;
    align-items: flex-end;
    justify-content: space-between;
    gap: 0.5rem;
    border-bottom: 2px solid var(--ctl-border);
    padding-bottom: 0.25rem;
    margin-bottom: 0.15rem;
}

.detail-section-bar > .detail-section-title {
    flex: 1 1 auto;
    min-width: 0;
    border-bottom: none;
    padding-bottom: 0;
    margin-bottom: 0;
}

/* Status dot — takes its fill from the text color beside it, so one
   .text-status-* class colors both the label and the bullet. */
.status-dot {
    display: inline-block;
    width: 0.45rem;
    height: 0.45rem;
    margin-right: 0.4rem;
    border-radius: 50%;
    background: currentColor;
    flex: 0 0 auto;
}

/* ── Detail pills — a detail field list as key | value bubbles ──────────────
   The key half is uniform; the value half is washed with the pill's OWN text
   color (the .text-status-* / .text-risk-* class the PILL carries), so a status
   color is still declared in exactly one place and the wash, the value text and
   an editable pill's caret can never disagree.
   On the ticket detail card the value half is a <select> (.detail-pill-select
   below) rather than text: the pills edit live, there is no edit MODE, and
   nothing here toggles on .editing. */
.detail-pills {
    display: flex;
    flex-wrap: wrap;
    gap: 0.35rem;
    padding: 0.4rem 0 0.3rem;
}

.detail-pill {
    display: inline-flex;
    align-items: stretch;
    max-width: 100%;
    border: 1px solid var(--ctl-border);
    border-radius: var(--ctl-radius-full);
    overflow: hidden;
    font-size: 0.78rem;
    font-weight: 500;
    line-height: 1.5;
}

.detail-pill-key {
    display: flex;
    align-items: center;
    padding: 0.1rem 0.55rem;
    background: var(--ctl-bg-hover);
    color: var(--ctl-fg-subtle);
    font-size: 0.68rem;
    font-weight: 600;
    text-transform: uppercase;
    letter-spacing: 0.03em;
    white-space: nowrap;
}

.detail-pill-value {
    display: flex;
    align-items: center;
    gap: 0.25rem;
    min-width: 0;
    padding: 0.1rem 0.6rem;
    font-weight: 500;
    border-left: 1px solid var(--ctl-border);
    /* Wash mixed from the value's own color: 12% keeps the text-on-wash ratio
       within a few percent of the same text on the bare card, in all six
       theme variants, so no pairing needs re-verifying (§4.5). */
    background: color-mix(in srgb, currentColor 12%, transparent);
}

.detail-pill-action {
    font-size: 0.7rem;
}

/* ── A pill whose value half IS the control ─────────────────────────────────
   Click the value, pick from the list. The native <select> keeps its own
   chrome stripped so it reads as the washed value half; Bootstrap's caret (the
   .form-select background-image, themed via --ctl-select-arrow) is the only
   tell that it opens, and it is also the whole widget — keyboard, type-to-jump,
   the mobile picker — which is why there is no hand-built dropdown here.
   `color: inherit` lets the pill's tone class through: .form-select's own
   `color` would otherwise win on specificity and flatten every status to body
   text. */
.detail-pill .detail-pill-select {
    /* A <select> is intrinsically as wide as its WIDEST option, so a pill
       reading "Open" was sized by "Pending Customer Response" and trailed dead
       space — and never moved when the value changed. `field-sizing: content`
       is the platform's own answer: the control sizes to the SELECTED option
       and re-sizes itself on every change, including a programmatic one (which
       is what Cancel's restoreForm and the category filter do), with no
       measuring span and no JS. min-width:0 still lets flex shrink it when a
       long category name would otherwise push the pill past the sidebar, and
       max-width caps it there.
       shortcut: Chromium + Safari ship field-sizing; a browser without it
       simply keeps today's widest-option width — degraded, never broken. Add a
       measuring-span fallback if Firefox becomes a supported target (gap). */
    field-sizing: content;
    flex: 0 1 auto;
    width: auto;
    min-width: 0;
    max-width: 100%;
    text-overflow: ellipsis;
    padding: 0.1rem 1.45rem 0.1rem 0.6rem;
    border: none;
    border-left: 1px solid var(--ctl-border);
    border-radius: 0;
    background-color: color-mix(in srgb, currentColor 12%, transparent);
    background-position: right 0.4rem center;
    background-size: 10px 8px;
    color: inherit;
    font: inherit;
    line-height: 1.5;
    box-shadow: none;
    cursor: pointer;
}

.detail-pill .detail-pill-select:hover {
    background-color: color-mix(in srgb, currentColor 22%, transparent);
}

/* Beats .form-select:focus (equal class count, but this adds .detail-pill), so
   focusing a pill deepens its own wash instead of flipping it to an input. */
.detail-pill .detail-pill-select:focus {
    background-color: color-mix(in srgb, currentColor 22%, transparent);
    border-left-color: var(--ctl-border);
    color: inherit;
    box-shadow: var(--ctl-focus-ring);
}

/* The open list is painted by the OS against the page, not against the pill,
   so its rows need real surface colors — the pill's wash is transparent. */
.detail-pill .detail-pill-select option {
    background-color: var(--ctl-bg-inset);
    color: var(--ctl-fg);
}

/* ── Identity combobox — the assignee picker in the sidebar ─────────────────
   Everything about how it LOOKS is the shared .uee-picker component (see the
   avatar section above); this is only how wide it sits in the identity row.
   The frame, the input chrome, the caret and the results list all moved there
   when the control grew to enclose the avatar as well as the name — the ticket
   detail page and the New Ticket form now draw the same control.

   Shrink-to-fit, not stretch-to-row: the quiet skin draws a bubble, and a
   bubble padded out to a fixed 11rem basis strands the caret miles from the
   name it belongs to (and used to strand a hairline underline out there with
   it). It still shrinks below its content when a long name meets a narrow
   sidebar — .uee-picker-chip-name ellipsises inside it. */
.detail-combo {
    flex: 0 1 auto;
    min-width: 0;
    max-width: 100%;
}

/* Section title without the card-header chrome (used by sidebar card headers
   that carry an action). Replaces the inline padding/border overrides. */
.detail-section-title-plain {
    padding: 0;
    border-bottom: none;
}

/* The single "+" action on an empty sidebar card's header. 44px hit area on
   coarse pointers per §9 without inflating the header on a mouse. */
.card-head-add {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    min-width: 1.5rem;
    min-height: 1.5rem;
    line-height: 1;
    text-decoration: none;
}

@media (pointer: coarse) {
    .card-head-add {
        min-width: 44px;
        min-height: 44px;
        margin: -0.6rem 0;
    }
}

/* Server-side field errors are appended to the [data-field] element; give
   them their own line instead of a squeezed third column. */
.detail-pill .field-error-msg {
    flex: 0 0 100%;
}

/* ── Autosave feedback (form[data-autosave] — the ticket Details card) ───────
   The card has no Save button: a pick IS the write. That has to be legible
   without becoming an event — a toast per field would be five interruptions in
   a minute of triage — so the FIELD reports on itself and nothing else moves.

   In flight: the control fades back. Landed: one ring, once, in the success
   token, expanding out to nothing over a beat. Both ride the pill / picker
   frame rather than the row, so the feedback is the size of the thing that
   changed. `overflow: hidden` on .detail-pill clips its children, not its own
   box-shadow, so the ring is drawn in full.

   A failure is NOT signalled here — it takes the field back to server truth and
   raises .is-invalid + the inline message, which is the app's existing
   vocabulary for "the server said no".

   The global prefers-reduced-motion rule (§4.4) collapses both to a jump-cut. */
.is-autosaving {
    opacity: 0.55;
    transition: opacity var(--ctl-dur-fast) ease;
}

.is-autosaved {
    animation: uee-autosaved var(--ctl-dur-slow) ease-out;
}

@keyframes uee-autosaved {
    from { box-shadow: 0 0 0 0 rgba(var(--ctl-success-rgb), 0.55); }
    to   { box-shadow: 0 0 0 0.35rem rgba(var(--ctl-success-rgb), 0); }
}

/* Composer tone line — which voice the reply is written in. Amber for an
   internal note, accent for a customer-facing reply (was inline-styled by JS,
   which left the default Reply state with no line at all). The width is stated
   ONCE, for both states: a rail that changes thickness when you switch tabs
   reads as a rendering fault, not as a state change. Only the colour moves. */
.composer-tone-reply,
.composer-tone-note {
    --composer-rail: 2px;
    border-top: var(--composer-rail) solid var(--ctl-accent);
}

.composer-tone-note {
    border-top-color: var(--ctl-warning);
}

/* …and the tabs that sit on it.

   Bootstrap pulls every .nav-tabs .nav-link down by the nav's border width
   (`margin-bottom: calc(-1 * var(--bs-nav-tabs-border-width))`) so the active
   tab can straddle the nav's own border-bottom and merge into the panel below
   it. This nav has NO border-bottom — the tone rail plays that part — so the
   only thing the 1px overhang could straddle was the rail itself. The active
   tab's bottom border (Bootstrap paints it in --bs-nav-tabs-link-active-bg,
   i.e. the opaque card surface) landed on the rail's first pixel, and
   Bootstrap's `isolation: isolate` on :hover/:focus lifts that tab into its
   own stacking context so it paints over the rail rather than under it. Net
   effect: the rail read 1px under the active tab and 2px either side, and it
   changed as the pointer moved. Zeroing the overhang ends the tabs exactly
   where the rail starts — 2px edge to edge, in both states, on every sheet.

   The merge is untouched: the active tab still wears the card surface and
   still sits flush on the rail, it just no longer eats a pixel of it. Cost is
   1px of card height. border-bottom lives here rather than inline on the <ul>
   so all of the tab-strip chrome is one rule. */
.composer-tabs {
    border-bottom: none;
    gap: 2px;
}

.composer-tabs .nav-link {
    margin-bottom: 0;
}

/* (The composer's attach PANEL is gone — the ticket composer stages files the
   way the portal reply box does: .attach-bare + .portal-attach-thumbs on the
   tool strip, both defined once up in the composer/attachment section. The
   rules that lived here — the panel's own spacing and the two "who sees these
   files" captions — went with the markup they styled, which nothing else on any
   surface rendered.) */

/* The tiles ride the agent composer's bottom strip rather than a bubble's own
   bottom row, and the tile's × bubble overhangs its top corner by 7px. Give it
   that back, or the × sits over the writing surface above. */
#commentEditor .composer-tools .portal-attach-thumbs {
    padding-top: 8px;
}

/* …and the focus ring on the writing surface itself. `.rich-editor:focus-within`
   draws its border and ring from the accent, which is right everywhere that
   editor is shared (ticket create, the portal reply, the canned-response
   editors) and wrong here: an Internal Note already turns the rail, the AI
   segment and the send button amber, so the green ring around the box you are
   actually typing in was the last part of the composer still claiming
   "customer-facing".

   The ring TOKEN is overridden rather than box-shadow restated, so the base
   rule stays the single place the ring is applied and only the colour moves —
   the same discipline as the rail above. The geometry is repeated because a
   custom property resolves its own var()s where it is DECLARED (:root), so
   swapping --ctl-accent-rgb this far down would not reach inside
   --ctl-focus-ring. Amber comes from the token, so all six sheets stay right. */
.composer-tone-note .rich-editor:focus-within {
    --ctl-focus-ring: 0 0 0 3px rgba(var(--ctl-warning-rgb), 0.35);
    border-color: var(--ctl-warning);
}

/* ── Ticket reply composer — action row + tool strip (/tickets/{id}) ─────────
   The reply box is the one place on this page an agent WRITES rather than
   reads, and it is deliberately shaped like the /chat message entry so the two
   composing surfaces in the product read as one family: a single rounded shell
   with the text on top and a strip of round icon buttons along its bottom
   edge, then one action row underneath.

   Geometry note. .ticket-detail-body remaps --ctl-radius-lg (containers) but
   NOT --ctl-radius (controls) — Industrial/Operator keep buttons square, which
   is right for the list this page is reached from. These few controls are the
   stated exception: the send split-button and the suggestions toggle are
   pills (--ctl-radius-full, the token already reserved for chips/pills) and
   the editor shell takes the container radius, because a square box with
   square buttons welded to its inside corner is exactly the "spreadsheet" look
   #1465 set out to fix. Everything reads a token; no literal radii here. */
.composer-actions {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 0.5rem;
}

/* Send goes RIGHT, suggestions stay LEFT. margin-left:auto rather than
   justify-content:space-between so a third control could join the left cluster
   without the row re-splitting itself. */
.composer-actions .composer-send {
    margin-left: auto;
    order: 1;
}

/* Wrapped, full-width lines under the row: the suggestion list and the
   copilot's undo line. order keeps them below the two controls regardless of
   DOM order (the panel is emitted next to its own toggle, upstream of send). */
.composer-actions #suggestReplyPanel,
.composer-actions .composer-note {
    flex: 1 0 100%;
    order: 2;
}

/* The undo line is injected already carrying .d-none and stays that way until
   a rewrite happens; a display:none flex item draws no row gap, so an unused
   note costs nothing. */
.composer-actions .composer-note { order: 3; }

/* Pill geometry. Bootstrap's own .btn-group rules zero the INNER corners of
   the split button at higher specificity than this selector, so stating all
   four here yields pill-outside / flat-seam once ticket-copilot.js appends the
   wand segment, and a plain pill while Send Reply is the group's only child. */
#suggestReplyBtn,
.composer-send > .btn {
    border-radius: var(--ctl-radius-full);
}

/* The wand segment is icon-only, so it would otherwise collapse to a sliver of
   padding; and Bootstrap's caret needs a little air from the glyph. */
.composer-send > .dropdown-toggle::after {
    margin-left: 0.2rem;
}

/* ── The AI (wand) half of the split send button ────────────────────────────
   A split button has one action and one menu; painting both halves the same
   solid fill made the control read as one over-wide button with a caret
   floating in it. So the wand half is SOFT: the state colour washed into the
   card surface, with the state's contrast-safe text colour on the glyph. Send
   keeps the solid fill and stays the thing you press; the wand is visibly the
   secondary attached to it.

   Why a wash rather than .btn-outline-primary (a transparent fill would have
   been the zero-CSS option): the group sits directly on the card, so a
   transparent segment is card-coloured — it stops reading as part of the
   control and starts reading as a hole punched in it. An opaque tinted surface
   keeps it a button.

   Why color-mix into --ctl-bg-elevated rather than rgba() over whatever is
   behind: the same reason --ctl-border-subtle is derived that way. Mixing
   toward the SURFACE token means the segment inverts correctly on its own —
   pale-tinted on the three light sheets, dark-tinted on the three dark ones —
   from one declaration, and re-tuning a sheet's accent or surface re-tunes
   this with it. There is no --ctl-accent-bg to reuse (the *-bg washes exist
   only for danger/warning/success/info, and "success green" is NOT the accent
   in Refined Blue), which is exactly why the accent side is derived here
   instead of aliased.

   The border stays the FULL state colour on both halves, so the group keeps
   one continuous outline and the seam between them is a deliberate line in the
   state colour rather than an accidental gap. Everything below is a
   --bs-btn-* override, so Bootstrap's own hover / active / disabled / focus
   machinery drives it — the busy spinner state (which only sets .disabled)
   included.

   The 12 / 20 / 28% ladder is where it is because it was measured, not
   guessed: across all six sheets, in both states, the glyph clears 4.9:1 at
   rest and 4.6:1 hovered against its own fill (worst case Industrial/Operator
   light), and 4.2:1 while held down — comfortably past the 3:1 a non-text
   glyph needs, and past AA text contrast for the two states you actually
   read. Push the mix much further and the segment stops being the light half. */
.composer-send > .composer-ai-seg {
    --seg: var(--ctl-accent);
    --seg-text: var(--ctl-accent-text);
    --bs-btn-color: var(--seg-text);
    --bs-btn-bg: color-mix(in srgb, var(--seg) 12%, var(--ctl-bg-elevated));
    --bs-btn-border-color: var(--seg);
    --bs-btn-hover-color: var(--seg-text);
    --bs-btn-hover-bg: color-mix(in srgb, var(--seg) 20%, var(--ctl-bg-elevated));
    --bs-btn-hover-border-color: var(--seg);
    --bs-btn-active-color: var(--seg-text);
    --bs-btn-active-bg: color-mix(in srgb, var(--seg) 28%, var(--ctl-bg-elevated));
    --bs-btn-active-border-color: var(--seg);
    --bs-btn-disabled-color: var(--seg-text);
    --bs-btn-disabled-bg: color-mix(in srgb, var(--seg) 12%, var(--ctl-bg-elevated));
    --bs-btn-disabled-border-color: var(--seg);
    --bs-btn-disabled-opacity: 0.75;
    --bs-btn-focus-shadow-rgb: var(--ctl-accent-rgb);
}

/* Internal Note. Two token swaps and the whole segment follows the amber
   composer — including the focus ring, which would otherwise flash green
   inside an amber control. */
.composer-send > .composer-ai-seg--note {
    --seg: var(--ctl-warning);
    --seg-text: var(--ctl-warning-text);
    --bs-btn-focus-shadow-rgb: var(--ctl-warning-rgb);
}

/* The disclosure follows the control that opened it. suggested-replies.js draws
   its result boxes with .rounded / .alert — 8px controls since the 2026-07-31
   baseline — but these are result CONTAINERS hanging off a pill, so they take
   the container step instead, by id, and nothing outside this panel is
   affected. */
#suggestReplyPanel .rounded,
#suggestReplyPanel .alert {
    border-radius: var(--ctl-radius-lg);
}

/* ── AI summary panel (ticket-copilot.js, the "Summarize" disclosure) ────────
   The panel is injected markup, so the two pieces that are NOT a plain
   Bootstrap utility are stated here instead of inline — §11 bans inline
   style="" and font sizes off the §2.1 scale, and the cached-note line used to
   carry a literal 0.7rem. Everything else the panel draws with is a bridged
   utility now that the primary/light subtle families reach the tokens.

   Suggested-next is §6.2's accent-edge strip: the accent wash plus a 3px accent
   edge. Deliberately NOT a status wash (§4.3) — those state what the ticket IS,
   and this is the assistant's recommendation, informative and never alarming. */
.copilot-summary-next {
    border-left: 3px solid var(--ctl-accent);
}

/* The provenance caption ("Cached — click refresh to regenerate."): the caption
   step, subtle ink — it is machine bookkeeping about the panel, not content. */
.copilot-summary-note {
    font-size: var(--ctl-fs-caption);
    color: var(--ctl-fg-subtle);
}

/* ── The shell ──────────────────────────────────────────────────────────────
   Scoped to the ticket reply editor by id: .rich-editor is shared with ticket
   create, the portal reply and the canned-response editors, and none of those
   have a tool strip to hang off the bottom. */
#commentEditor {
    border-radius: var(--ctl-radius-lg);
}

/* One continuous writing surface: the strip carries the body's inset ground so
   the shell reads as a single input rather than a box with a footer bolted on.
   No top border for the same reason. */
.composer-tools {
    display: flex;
    align-items: center;
    gap: 0.15rem;
    padding: 0.1rem 0.35rem 0.25rem;
    background: var(--ctl-bg-inset);
}

/* Persistent tools sit hard right — the collapsed formatting toolbar expands
   into the space on their left. */
.composer-tool-group {
    display: flex;
    align-items: center;
    gap: 0.15rem;
    margin-left: auto;
}

/* Round hit boxes, matching the chat entry's icon row. 2rem is the same box
   .rich-toolbar-btn is nudged to below, so a revealed Bold sits on exactly the
   same baseline grid as the emoji and paperclip beside it. */
.composer-tool-btn {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 2rem;
    height: 2rem;
    padding: 0;
    margin: 0;
    border: 0;
    background: none;
    color: var(--ctl-fg-muted);
    font-size: 1rem;
    line-height: 1;
    border-radius: var(--ctl-radius-full);
    cursor: pointer;
}

.composer-tool-btn:hover,
.composer-tool-btn[aria-expanded="true"] {
    background: var(--ctl-bg-hover);
    color: var(--ctl-accent-text);
}

/* Recording. The pulse is a background flash, not opacity or transform, so it
   cannot shift the strip's layout while it runs. */
.composer-tool-btn.is-recording {
    color: var(--ctl-danger);
    background: rgba(var(--ctl-danger-rgb), 0.12);
    animation: composer-rec-pulse 1.2s ease-in-out infinite;
}

.composer-tool-btn.is-busy {
    opacity: 0.5;
    pointer-events: none;
}

@keyframes composer-rec-pulse {
    50% { background: transparent; }
}

@media (prefers-reduced-motion: reduce) {
    .composer-tool-btn.is-recording { animation: none; }
}

/* The formatting toolbar now lives INSIDE the strip, so it drops the chrome it
   needed as a top bar (own background + bottom rule) and matches the strip's
   round 2rem hit boxes. */
#commentEditor .rich-toolbar {
    padding: 0;
    background: none;
    border-bottom: 0;
}

#commentEditor .rich-toolbar-btn {
    width: 2rem;
    height: 2rem;
    border-radius: var(--ctl-radius-full);
}

/* ── Emoji quick-pick ───────────────────────────────────────────────────────
   position:fixed, but parented to #commentFormWrap rather than <body>: the
   composer is inside .ticket-detail-body, and the container radius token is
   remapped THERE. A body-parented popover would silently fall back to the
   preset's square --ctl-radius-lg and stop matching the shell it belongs to.
   (.rich-editor is overflow:hidden — hence the form wrap, not the editor.) */
.composer-emoji-pop {
    position: fixed;
    z-index: 1080;
    display: flex;
    flex-wrap: wrap;
    gap: 3px;
    max-width: 14.5rem;
    padding: 0.35rem;
    background: var(--ctl-bg-elevated);
    border: 1px solid var(--ctl-border);
    border-radius: var(--ctl-radius-lg);
    box-shadow: 0 0.5rem 1rem var(--ctl-shadow);
}

.composer-emoji-pop button {
    border: 0;
    background: none;
    border-radius: var(--ctl-radius);
    font-size: 1.15rem;
    line-height: 1;
    padding: 0.15rem 0.25rem;
    cursor: pointer;
}

.composer-emoji-pop button:hover {
    background: var(--ctl-bg-hover);
}

/* ── Narrative surface — the ticket card's prose blocks (description) ───────
   Was an inline style="font-size:0.85rem; line-height:1.5" on the wrapper. It
   is a class now because the collapse below measures line boxes: the clamp
   height and the text metrics have to be declared in one place or a font-size
   tweak silently changes "15 lines" to something else. */
.ticket-narrative {
    font-size: 0.85rem;
    line-height: 1.5;
}

/* ── Collapsible long text — clamp to N lines + Show more / Show less ───────
   .is-clamped is added by JS only after it measures REAL rendered overflow
   (tickets-show.js), so an un-enhanced page renders the block in full and the
   toggle stays [hidden]. Line budget rides on --ctl-collapse-lines, which the
   JS copies off data-collapse-lines.

   The fade is a MASK, not a gradient painted in the surface colour: masking to
   transparent is correct on any card, on any of the six theme sheets, with no
   colour token to keep in sync (a gradient would need the card's own
   background, which differs per sheet and per nesting depth). */
.ctl-collapse.is-clamped > .ctl-collapse-body {
    overflow: hidden;
    /* `1lh` IS one line box — exact whatever the line-height. The em value
       above it is the fallback for engines without `lh` and assumes the 1.5
       line-height every narrative surface here uses. */
    max-height: calc(var(--ctl-collapse-lines, 15) * 1.5em);
    max-height: calc(var(--ctl-collapse-lines, 15) * 1lh);
    -webkit-mask-image: linear-gradient(to bottom, #000 82%, transparent 100%);
            mask-image: linear-gradient(to bottom, #000 82%, transparent 100%);
}

.ctl-collapse-toggle {
    font-size: 0.78rem;
    font-weight: 600;
    text-decoration: none;
    margin-top: 0.15rem;
}

.ctl-collapse-toggle:hover,
.ctl-collapse-toggle:focus-visible {
    text-decoration: underline;
}

.ctl-collapse-toggle > .bi {
    display: inline-block;
    margin-right: 0.25rem;
    transition: transform 0.15s ease;
}

/* Expanded — chevron points back up at the "Show less" it now means. */
.ctl-collapse:not(.is-clamped) > .ctl-collapse-toggle > .bi {
    transform: rotate(180deg);
}

/* ── Disclosure toggle — "Add details" style opt-in reveal ──────────────────
   A link-shaped Bootstrap collapse trigger for a control a form does not want
   to show by default (first user: the Report Error dialog's optional details
   textarea). Same reading as .ctl-collapse-toggle above, but the state comes
   from aria-expanded, which Bootstrap's collapse plugin maintains — no theme
   rule needs a JS-applied class (the .ticket-activity-toggle trick).

   No colour token here on purpose: the element is a .btn-link, and every one of
   the six style/scheme sheets already themes that. */
.ctl-disclosure {
    font-size: 0.78rem;
    font-weight: 600;
    text-decoration: none;
}

.ctl-disclosure:hover,
.ctl-disclosure:focus-visible {
    text-decoration: underline;
}

.ctl-disclosure > .bi {
    display: inline-block;
    margin-right: 0.25rem;
    transition: transform 0.15s ease;
}

.ctl-disclosure[aria-expanded="true"] > .bi {
    transform: rotate(180deg);
}

/* ── Requester identity — one caption-less line: avatar, name, email bubble ─*/
.detail-requester {
    align-items: center;
}

.detail-requester-identity {
    display: flex;
    align-items: center;
    flex-wrap: wrap;
    gap: 0.4rem;
    padding: 0.15rem 0;
}

.detail-requester-name {
    font-weight: 600;
}

.detail-requester-email {
    padding: 0.05rem 0.5rem;
    border: 1px solid var(--ctl-border);
    border-radius: var(--ctl-radius-full);
    background: var(--ctl-bg-hover);
    color: var(--ctl-fg-muted);
    font-size: 0.72rem;
    text-decoration: none;
    max-width: 100%;
    overflow-wrap: anywhere;
}

.detail-requester-email:hover,
.detail-requester-email:focus-visible {
    border-color: var(--ctl-border-strong);
    color: var(--ctl-fg);
}

/* Summary card: identity reads as a short block, not a table — the hairlines
   between two or three rows were more structure than the content needed. */
.ticket-identity .detail-row {
    border-bottom: none;
    padding: 0.1rem 0;
}

/* ── SLA meters (ticket Dates card) ─────────────────────────────────────────
   One horizontal bar per milestone: head (label + countdown), track, scale
   (opened → due). The tone is set once on the wrapper and inherited through
   --sla-tone, so the countdown text, the headline dot and the fill can never
   disagree about how the milestone is doing. Never color-only: every bar
   states its standing in words beside it (WCAG 1.4.1). */
.sla-tone-success { --sla-tone: var(--ctl-success-text); }
.sla-tone-warning { --sla-tone: var(--ctl-warning-text); }
.sla-tone-danger  { --sla-tone: var(--ctl-danger-text); }

.sla-headline {
    display: inline-flex;
    align-items: center;
    color: var(--sla-tone, var(--ctl-fg));
    font-size: 0.78rem;
    font-weight: 600;
    white-space: nowrap;
}

.sla-meter {
    padding: 0.4rem 0 0.2rem;
}

.sla-meter-head {
    display: flex;
    align-items: baseline;
    justify-content: space-between;
    gap: 0.5rem;
    font-size: 0.78rem;
}

.sla-meter-label {
    color: var(--ctl-fg);
    font-weight: 600;
}

.sla-meter-status {
    color: var(--sla-tone, var(--ctl-fg));
    font-weight: 600;
    white-space: nowrap;
}

/* Meters are structural, not chips: the track takes the PRESET radius, so the
   sharp styles read as a crisp gauge and Refined keeps its softened corner.
   --ctl-radius-full is reserved for chips/pills/avatars/badges. */
.sla-meter-track {
    height: 5px;
    margin: 0.2rem 0 0.15rem;
    border-radius: var(--ctl-radius);
    background: var(--ctl-bg-hover);
    overflow: hidden;
}

.sla-meter-fill {
    display: block;
    width: var(--sla-fill, 0%);
    height: 100%;
    border-radius: inherit;
    background: var(--sla-tone, var(--ctl-accent));
}

.sla-meter-scale {
    display: flex;
    justify-content: space-between;
    gap: 0.5rem;
    color: var(--ctl-fg-subtle);
    font-size: 0.68rem;
}

/* ── CSAT stars ─────────────────────────────────────────────────────────────
   One rule for every surface that prints a satisfaction score (agent ticket
   detail, portal ticket, portal list). The three of them each carried an inline
   `color:#ffc107` — the pre-Operator-Light Bootstrap amber, so the stars stayed
   the old brand's yellow through the retheme and missed dark mode entirely. */
.csat-stars {
    color: var(--ctl-warning-text);
    letter-spacing: 0.05em;
}

/* Clickable table rows */
.cursor-pointer {
    cursor: pointer;
}

/* Action buttons in tables */
.btn-group-sm .btn {
    padding: 0.25rem 0.5rem;
}

/* Status text colors */
.text-status-active { color: var(--ctl-success-text); font-weight: 500; }
.text-status-inactive { color: var(--ctl-fg-muted); }
.text-status-locked { color: var(--ctl-danger-text); font-weight: 500; }
.text-status-archived { color: var(--ctl-fg-muted); }
.text-status-new { color: var(--ctl-status-new); font-weight: 500; }
.text-status-assigned { color: var(--ctl-status-assigned); font-weight: 500; }
.text-status-in_progress { color: var(--ctl-status-in-progress); font-weight: 500; }
/* WAITING ("Waiting on Customer") had no rule — the ball is in the requester's
   court, same held-work reading as pending / on hold. Uncolored, it left the
   Details pill and the Mark As dot inheriting body text. */
.text-status-waiting { color: var(--ctl-fg-muted); }
.text-status-pending { color: var(--ctl-fg-muted); }
.text-status-on_hold { color: var(--ctl-fg-muted); }
.text-status-resolved { color: var(--ctl-success-text); font-weight: 500; }
.text-status-closed { color: var(--ctl-fg-muted); }
.text-status-cancelled { color: var(--ctl-danger-text); }

/* Priority / risk — the severity ramp (see the --ctl-risk-* block in the token
   sheets). Colour escalates grey → amber → orange → red and WEIGHT escalates
   with it, so the ramp survives greyscale and colour-blindness: severity is
   never carried by hue alone (WCAG 1.4.1).

   .text-risk-urgent had no rule at all. tkt_ticket has no URGENT tier, but
   pur_requisition.priority, com_announcement.priority and com_message.priority
   all do, and each renders through the same text-risk-{value} pattern — so
   their top tier was silently printing as plain body text. It is the same
   signal as CRITICAL and now reads as one. */
.text-risk-critical,
.text-risk-urgent { color: var(--ctl-danger-text); font-weight: 700; }
.text-risk-high { color: var(--ctl-risk-high); font-weight: 600; }
.text-risk-medium { color: var(--ctl-risk-medium); font-weight: 500; }
.text-risk-low { color: var(--ctl-fg-muted); font-weight: 400; }

/* ── Micro-typography: tabular figures ──────────────────────────────────────
   The body face is a proportional system stack, so by default a column of
   ticket numbers, dates, counts and SLA countdowns is set in PROPORTIONAL
   figures: '1' is narrower than '8', digits do not line up vertically, and a
   number that ticks over (an SLA countdown, a live count) visibly reflows the
   text around it. Every surface where digits are meant to column-align opts
   into tabular figures here, once, in a shared layer — the property inherits,
   so putting it on the cell covers whatever the cell renders.

   This is deliberately NOT on <body>: tabular figures are wrong for running
   prose (they open up gaps around 1s), so KB articles, descriptions and chat
   keep the proportional default. Data surfaces only.

   Style-invariant by design — all three presets share the same body face, so
   this belongs in the shared layer rather than in any one sheet. */
.table td,
.table th,
.badge,
.ctl-page-header-count,
.table-status-bar,
.sla-headline,
.sla-meter-head,
.sla-meter-scale,
code, kbd, samp {
    font-variant-numeric: tabular-nums;
}

/* Ticket numbers render as <code> (tickets/_ticket_cell_renderer.php). The
   Bootstrap bridge points --bs-code-color at --ctl-accent-text, which is right
   for real inline code in prose but makes an ID column shout in the brand
   accent — the one colour the eye is trained to read as "interactive" — in
   every list that prints a key. Inside a data table the identifier is quiet
   ink: still monospaced and still distinct, but no longer competing with the
   status and priority columns it sits beside. */
.table {
    --bs-code-color: var(--ctl-fg-muted);
}

/* Forms — density-aware (§5). Comfortable (default) ≈40px controls / 15px UI
   font; html[data-density="compact"] restores today's ultra-compact values
   (≈30px controls / 13.5px). All values flow from the density tokens in the
   token sheets — no per-density rules here. */
.form-control, .form-select {
    padding: var(--ctl-control-pad-y) var(--ctl-control-pad-x);
    font-size: var(--ctl-fs-body);
    background-color: var(--ctl-bg-inset);
    color: var(--ctl-fg);
    border-color: var(--ctl-border-strong);
}

.form-control:focus, .form-select:focus {
    background-color: var(--ctl-bg-inset);
    color: var(--ctl-fg);
    border-color: var(--ctl-accent);
    box-shadow: var(--ctl-focus-ring);
}

.form-label {
    font-weight: 500;
    font-size: var(--ctl-fs-label);
    margin-bottom: 0.15rem;
}

.form-text {
    font-size: var(--ctl-fs-body-sm);
    margin-top: 0.1rem;
}

.required::after {
    content: " *";
    color: var(--ctl-danger-text);
}

/* Pagination */
.pagination {
    margin-bottom: 0;
}

/* Alerts */
.alert {
    border: none;
    border-radius: var(--ctl-radius);
}

/* Flash messages container */
.flash-container {
    position: fixed;
    top: 1rem;
    right: 1rem;
    z-index: 1050; /* z literal kept: pinned to Bootstrap's modal-backdrop level (1050) so flashes sit with the modal stack */
    max-width: 400px;
}

.flash-container .alert {
    animation: slideIn 0.3s ease-out;
}

@keyframes slideIn {
    from {
        transform: translateX(100%);
        opacity: 0;
    }
    to {
        transform: translateX(0);
        opacity: 1;
    }
}

/* Empty state */
.empty-state {
    text-align: center;
    padding: 3rem;
    color: var(--ctl-fg-muted);
}

.empty-state i {
    font-size: 3rem;
    margin-bottom: 1rem;
}

/* Loading spinner */
.spinner-overlay {
    position: fixed;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
    background: rgba(var(--ctl-bg-rgb), 0.8);
    display: flex;
    align-items: center;
    justify-content: center;
    z-index: 9999; /* z literal kept: blocking overlay must top Bootstrap's modal stack (1055+), above the --ctl-z scale */
}

/* ── Second nav panel (single-layer drilldown, UI_STANDARDS §7) ──────────────
   The panel sits at the rail's right edge (left:100% follows the rail width,
   collapsed or not) and ALWAYS floats over the content — it never pushes or
   reflows the main page at any tier (§8.2). Desktop: overlay, no backdrop.
   Collapsed / tablet: overlay flyout + dim backdrop. Phone: covers the
   off-canvas rail (full-width layer) with the drawer's own backdrop. */
.sidebar-panel {
    display: none;
    flex-direction: column;
    position: absolute;
    top: 0;
    bottom: 0;
    left: 100%;
    width: var(--ctl-sidebar-panel-width);
    background: var(--ctl-nav-raised-bg);
    border-left: 1px solid var(--ctl-nav-border);
    /* was var(--ctl-shadow-lg, …) — a token that has never existed, so the panel
       always drew the hardcoded fallback. --ctl-nav-shadow is the nav's own
       shadow ink: scheme-invariant, because it always falls on a dark rail. */
    box-shadow: 0 8px 24px var(--ctl-nav-shadow);
    z-index: var(--ctl-z-panel);
    overflow: hidden;
}

.sidebar.panel-open .sidebar-panel {
    display: flex;
}

.sidebar-panel-head {
    display: flex;
    align-items: center;
    gap: 0.5rem;
    padding: 0.6rem 0.5rem 0.6rem 0.9rem;
    min-height: var(--ctl-pane-head-h);
    border-bottom: 1px solid var(--ctl-nav-border);
}

.sidebar-panel-title {
    flex: 1;
    color: var(--ctl-nav-fg);
    font-size: 0.72rem;
    font-weight: 600;
    text-transform: uppercase;
    letter-spacing: 0.08em;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
}

.sidebar-panel-close {
    background: none;
    border: none;
    color: var(--ctl-nav-fg-muted);
    font-size: 1rem;
    line-height: 1;
    padding: 0.35rem 0.45rem;
    cursor: pointer;
    border-radius: var(--ctl-radius);
    flex-shrink: 0;
}

.sidebar-panel-close:hover {
    color: var(--ctl-nav-fg);
    background: var(--ctl-nav-hover-bg);
}

.sidebar-panel-body {
    flex: 1;
    min-height: 0;
    overflow-y: auto;
    padding: 0.4rem 0;
}

.sidebar-panel-link {
    display: flex;
    align-items: center;
    gap: 0.75rem;
    padding: 0.6rem 0.9rem;
    color: var(--ctl-nav-fg-muted);
    text-decoration: none;
    border-left: 3px solid transparent;
    white-space: nowrap;
}

.sidebar-panel-link i {
    width: 1.25rem;
    text-align: center;
    flex-shrink: 0;
    opacity: 0.75;
}

/* Same split as the rail: hover lifts the background, the accent bar stays
   exclusive to the active page. */
.sidebar-panel-link:hover {
    color: var(--ctl-nav-fg);
    background: var(--ctl-nav-hover-bg);
}

.sidebar-panel-link.active {
    color: var(--ctl-nav-fg);
    background: var(--ctl-nav-hover-bg);
    border-left-color: var(--ctl-nav-accent);
}

/* SUBGROUP rows render as section headers INSIDE the panel (never a 3rd layer) */
.sidebar-panel-section + .sidebar-panel-section {
    margin-top: 0.35rem;
}

.sidebar-panel-section-title {
    display: flex;
    align-items: center;
    gap: 0.5rem;
    color: var(--ctl-nav-fg-subtle);
    font-size: 0.68rem;
    font-weight: 600;
    text-transform: uppercase;
    letter-spacing: 0.05em;
    padding: 0.5rem 0.9rem 0.3rem;
}

.sidebar-panel-section-title i {
    font-size: 0.75rem;
    opacity: 0.7;
}

/* Backdrop behind an overlay/flyout panel (tablet + collapsed-desktop only).
   JS un-hides it only in overlay tiers, so the presence of the attribute is
   the single source of truth for visibility. */
.sidebar-panel-backdrop {
    display: none;
    position: fixed;
    inset: 0;
    background: rgba(0, 0, 0, 0.4);
    z-index: calc(var(--ctl-z-panel) - 1);
}

.sidebar-panel-backdrop:not([hidden]) {
    display: block;
}

/* Footer nav toggle (Settings / Back) — inherits the .nav-link chrome */
.sidebar-nav-toggle .flyout-chevron {
    display: none;
}

/* Tablet (769–991px): collapsed icon rail; the second panel opens as an
   overlay flyout on tap; content never reflows (UI_STANDARDS §8). */
@media (min-width: 769px) and (max-width: 991px) {
    .sidebar {
        width: var(--ctl-sidebar-collapsed);
    }
    .sidebar .nav-label,
    .sidebar .flyout-chevron {
        opacity: 0;
        width: 0;
        margin: 0;
        overflow: hidden;
    }
    .sidebar-search {
        display: none;
    }
    .sidebar .sidebar-header {
        flex-direction: column;
        align-items: center;
        gap: 0.4rem;
        padding-left: 0.5rem;
        padding-right: 0.5rem;
    }
    .sidebar .brand-logo,
    .sidebar .brand-core {
        display: none;
    }
    .sidebar .brand-mark {
        display: block;
        height: 22px;
        width: auto;
    }
    .sidebar .nav-link {
        justify-content: center;
        padding-left: 0;
        padding-right: 0;
        border-left-width: 0;
    }
    #sidebarCollapseBtn {
        display: none;
    }
    .main-content,
    .sidebar.panel-open ~ .main-content {
        margin-left: var(--ctl-sidebar-collapsed);
        width: calc(100% - var(--ctl-sidebar-collapsed));
    }
}

/* Responsive adjustments */
@media (max-width: 768px) {
    .sidebar {
        margin-left: calc(-1 * var(--ctl-sidebar-width));
        width: var(--ctl-sidebar-width) !important;
    }

    .sidebar.mobile-open {
        margin-left: 0;
    }

    /* Dim backdrop behind the open sidebar. Sits one step below the sidebar
       (--ctl-z-sidebar); taps land on .main-content, which the existing
       outside-click handler in app.js treats as close-sidebar. */
    .sidebar.mobile-open + .main-content::before {
        content: "";
        position: fixed;
        inset: 0;
        background: rgba(0, 0, 0, 0.4);
        z-index: calc(var(--ctl-z-sidebar) - 1);
    }

    .sidebar .sidebar-brand,
    .sidebar .nav-label,
    .sidebar .flyout-chevron {
        opacity: 1 !important;
        width: auto !important;
        margin-left: 0.75rem !important;
    }

    .sidebar .nav-link {
        justify-content: flex-start !important;
        padding-left: 0.75rem !important;
        border-left-width: 3px !important;
    }

    .sidebar-footer .nav-link {
        justify-content: flex-start !important;
    }

    /* Phone: the second panel is a push-in layer covering the off-canvas rail
       (UI_STANDARDS §7). The off-canvas backdrop already dims the content. */
    .sidebar-panel {
        left: 0;
        width: 100%;
        border-left: 0;
    }

    #sidebarCollapseBtn {
        display: none;
    }

    .main-content {
        margin-left: 0 !important;
        width: 100% !important;
    }

    /* Tighten spacing on mobile — set the token, not `padding`, so the
       .detail-page-body bleed shrinks with it and stays flush. */
    .content-wrapper {
        --ctl-pane-pad: 0.75rem;
    }

    .content-wrapper .mb-4 {
        margin-bottom: 0.5rem !important;
    }

    .content-wrapper .mb-3 {
        margin-bottom: 0.25rem !important;
    }

    .content-wrapper h1.h3,
    .content-wrapper .h3 {
        font-size: 1.1rem;
    }

    .card-body {
        padding: 0.75rem;
    }

    .card-header {
        padding: 0.5rem 0.75rem;
    }

    .row.g-3 {
        --bs-gutter-y: 0.5rem;
    }

    .col-12.mt-4 {
        margin-top: 0.75rem !important;
    }

    /* Toolbar + top navbar compact */
    .top-navbar {
        padding: 0.3rem 0.5rem;
    }

    .page-breadcrumb {
        font-size: 0.75rem;
    }

    /* Only the NAME goes — the avatar button has to stay, it's the way into the
       user menu (and now into Notifications and Logout with it). This rule used
       to hide .navbar-user, i.e. the whole toggle. */
    .navbar-user-name {
        display: none;
    }

    /* No room for a 13rem field beside the breadcrumb: the search collapses to
       its magnifier and re-expands on focus. The icon is the input's <label>, so
       tapping it focuses the input and :focus-within does the rest — no JS, and
       the same single code path as desktop. */
    .global-search-field {
        width: 2rem;
        padding: 0;
        justify-content: center;
    }

    .global-search-field:focus-within {
        width: min(60vw, 13rem);
        padding: 0 0.6rem;
    }

    .global-search-field:not(:focus-within) .global-search-input {
        width: 0;
        flex: 0 0 0;
    }

    .detail-toolbar {
        margin-bottom: 0.5rem !important;
    }

    .detail-label {
        min-width: 5.5rem;
        font-size: 0.7rem;
    }

    .detail-view {
        font-size: 0.8rem;
    }

    .detail-row {
        padding: 0.2rem 0;
    }

    .detail-section-title {
        font-size: 0.75rem;
    }
}

/* Rich editor */
.rich-editor {
    border: 1px solid var(--ctl-border);
    border-radius: var(--ctl-radius);
    overflow: hidden;
}

.rich-editor:focus-within {
    border-color: var(--ctl-accent);
    box-shadow: var(--ctl-focus-ring);
}

.rich-toolbar {
    display: flex;
    align-items: center;
    gap: 0.15rem;
    padding: 0.25rem 0.4rem;
    background: var(--ctl-bg-alt);
    border-bottom: 1px solid var(--ctl-border);
    flex-wrap: wrap;
}

.rich-toolbar-btn {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 1.75rem;
    height: 1.75rem;
    border: none;
    background: none;
    color: var(--ctl-fg-muted);
    border-radius: var(--ctl-radius);
    cursor: pointer;
    font-size: 0.8rem;
}

.rich-toolbar-btn:hover {
    background: var(--ctl-bg-hover);
    color: var(--ctl-fg);
}

.rich-toolbar-btn.active {
    background: var(--ctl-border);
    color: var(--ctl-accent-text);
}

.rich-toolbar-sep {
    width: 1px;
    height: 1.25rem;
    background: var(--ctl-border);
    margin: 0 0.2rem;
}

.rich-body {
    min-height: 120px;
    max-height: 400px;
    overflow-y: auto;
    padding: 0.5rem 0.75rem;
    font-size: 0.85rem;
    line-height: 1.6;
    background: var(--ctl-bg-inset);
    color: var(--ctl-fg);
    /* outline suppressed: the parent .rich-editor:focus-within border+ring is
       the visible focus indicator for this contenteditable region. */
    outline: none;
}

.rich-body:empty::before {
    content: attr(data-placeholder);
    color: var(--ctl-fg-faint);
    pointer-events: none;
}
/* Focus clears the prompt — the agent-side half of the shared composer's rule
   (see .chat-rich-input:focus::before above). Same mechanism, same defect: the
   placeholder is a ::before on the contenteditable itself, so it generates an
   IN-FLOW inline box inside the editable, and the caret at offset 0 of an empty
   element renders AFTER it. The agent ticket reply box, the agent ticket create
   describe box and the canned-response editors all looked like they already
   held text, with the caret parked at the end of "Reply to requester...".
   `content: none` is load-bearing: hiding the ink (color/opacity/visibility)
   leaves the box in flow and the caret behind it. Blur restores the prompt —
   the :empty rule above is untouched — and nothing is lost while typing since
   the accessible name comes from each editor's label, not data-placeholder.

   Placed after :empty deliberately: same specificity, so source order decides.
   Elements wearing BOTH .chat-rich-input and .rich-body (the portal composer)
   are already covered by the two-class focus rule up in the composer block;
   this one is for the standalone .rich-body editors. */
.rich-body:focus::before { content: none; }

.rich-body img {
    max-width: 100%;
    height: auto;
    border-radius: 0.25rem;
    margin: 0.25rem 0;
}

.rich-body a {
    color: var(--ctl-link);
}

/* ── @mention chip ──────────────────────────────────────────────────────────
   One rule, two jobs. The composer inserts <span class="mention-tag"
   data-user-id data-username contenteditable="false">@username</span>, and
   HtmlSanitizer keeps exactly that shape (it strips `style`, which is why the
   styling has to live here) — so the SAME selector paints the bubble while
   typing and in the posted comment. Scoped to nothing in particular on
   purpose: it must render inside .rich-body, .comment-body, the portal
   conversation and side conversations alike. All colour comes from --ctl-*
   tokens, so every palette and both light/dark land correctly with no
   theme-specific overrides. */
.mention-tag {
    display: inline-block;
    padding: 0 0.35em;
    border-radius: var(--ctl-radius-full);
    background: rgba(var(--ctl-accent-rgb), 0.14);
    color: var(--ctl-accent-text);
    font-weight: 600;
    /* A chip is one token, so never let a line break split the name. Text
       selection is deliberately left alone: `user-select: none` would keep the
       @name out of a copied comment, and contenteditable="false" already makes
       the chip atomic inside the composer. */
    white-space: nowrap;
}

/* ── @mention typeahead menu ────────────────────────────────────────────────
   Body-parented and position:fixed (app.js) so .rich-editor's overflow:hidden
   cannot clip it. */
.mention-dropdown {
    position: fixed;
    z-index: 1080;
    display: none;
    min-width: 13rem;
    max-width: min(22rem, calc(100vw - 1rem));
    max-height: 15rem;
    overflow-y: auto;
    padding: 0.25rem 0;
    background: var(--ctl-bg-elevated);
    color: var(--ctl-fg);
    border: 1px solid var(--ctl-border);
    border-radius: var(--ctl-radius);
    box-shadow: 0 0.5rem 1rem var(--ctl-shadow);
    font-size: 0.85rem;
}

.mention-dropdown.show {
    display: block;
}

.mention-item {
    display: flex;
    align-items: center;
    gap: 0.5rem;
    padding: 0.35rem 0.75rem;
    cursor: pointer;
}

.mention-item.active,
.mention-item:hover {
    background: var(--ctl-bg-hover);
}

/* The text column of a row. A flex item's default min-width is `auto`, so
   without this an address longer than the menu pushes the row wide instead of
   letting the lines below truncate — the menu is already width-capped
   (max-width above), so the overflow would simply be clipped and unreadable. */
.mention-item-body {
    min-width: 0;
}

.mention-item-body > div {
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

/* The muted second line: "@username" on the portal, "@username · email" on
   agent surfaces, where the endpoint discloses the address (see
   public/js/uee-mentions.js rowHtml).

   ONE rule, every people-menu. The identity picker's option rows
   (js/uee-user-picker.js metaLine, .uee-picker-option-meta) and, since #2082,
   the ticket-list in-row assignee editor's (js/tickets-list-edit.js
   agentOptions, .list-edit-option-meta) answer the same question about the same
   people from the same endpoint — "why did this row match what I typed, and
   which Chris Taylor is this?" — so they wear this tone and this ramp step
   rather than a look-alike that drifts from it. --ctl-fs-body-sm is the ramp's
   "secondary text" step (UI_STANDARDS §2.1); it replaces a 0.75rem literal that
   predated the ramp. */
.mention-item-username,
.list-edit-option-meta,
.uee-picker-option-meta {
    font-size: var(--ctl-fs-body-sm);
    color: var(--ctl-fg-muted);
}

.rich-dropzone {
    display: none;
    position: absolute;
    inset: 0;
    background: rgba(var(--ctl-accent-rgb), 0.08);
    border: 2px dashed var(--ctl-accent);
    border-radius: var(--ctl-radius);
    z-index: 5;
    align-items: center;
    justify-content: center;
    font-size: 0.85rem;
    color: var(--ctl-accent-text);
    font-weight: 500;
    pointer-events: none;
}

.rich-editor.dragging .rich-dropzone {
    display: flex;
}

.rich-attachments {
    padding: 0.35rem 0.5rem;
    border-top: 1px solid var(--ctl-border);
    display: flex;
    flex-wrap: wrap;
    gap: 0.35rem;
}

/* Nothing staged yet. Without this, EVERY rich editor in the app (ticket
   composer, ticket create, portal reply, canned responses) renders an empty
   bordered band under the body — a hairline plus ~11px of dead strip that reads
   as an unexplained divider. Fixed at the shared rule rather than per surface. */
.rich-attachments:empty {
    display: none;
}

.rich-attachment {
    display: inline-flex;
    align-items: center;
    gap: 0.3rem;
    padding: 0.2rem 0.5rem;
    background: var(--ctl-bg-alt);
    border-radius: var(--ctl-radius);
    font-size: 0.75rem;
    color: var(--ctl-fg-muted);
}

.rich-attachment-remove {
    cursor: pointer;
    color: var(--ctl-danger-text);
    font-size: 0.65rem;
}

.rich-attachment-remove:hover {
    color: var(--ctl-danger);
}

.rich-uploading {
    opacity: 0.5;
    font-style: italic;
}

/* Timeline attachment thumbnails + lightbox */
.attachment-thumb-wrap {
    width: 120px;
}

.attachment-thumb {
    width: 120px;
    height: 90px;
    object-fit: cover;
    border: 1px solid var(--ctl-border);
    border-radius: var(--ctl-radius);
    background: var(--ctl-bg-alt);
    cursor: zoom-in;
}

.attachment-thumb:hover {
    box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25);
}

.attachment-thumb-caption {
    display: flex;
    align-items: center;
    max-width: 120px;
    font-size: 0.7rem;
}

.attachment-thumb-caption .text-truncate {
    flex: 1 1 auto;
    min-width: 0;
}

.attachment-lightbox-img {
    max-width: 100%;
    max-height: 80vh;
}

/* Rich-text embedded attachment images: constrained + clickable (lightbox),
   except inside the editor where a click is caret placement. */
img[src^="/attachments/"] {
    max-width: 100%;
    cursor: zoom-in;
}

.rich-body img[src^="/attachments/"] {
    cursor: text;
}

/* API method labels */
.api-method {
    display: inline-block;
    font-size: 0.65rem;
    font-weight: 700;
    padding: 0.1rem 0.35rem;
    border-radius: var(--ctl-radius-full);
    text-transform: uppercase;
    letter-spacing: 0.03em;
    font-family: var(--ctl-font-mono);
}

.api-method-get { background: var(--ctl-success-bg); color: var(--ctl-success-text); }
.api-method-post { background: var(--ctl-info-bg); color: var(--ctl-info-text); }
.api-method-patch { background: var(--ctl-warning-bg); color: var(--ctl-warning-text); }
.api-method-delete { background: var(--ctl-danger-bg); color: var(--ctl-danger-text); }

.api-endpoint-row:hover {
    background: var(--ctl-bg-hover);
}

/* ── Ticket detail — card geometry (/tickets/{id}) ───────────────────────────
   This page pioneered softening on the agent console (#1465 lineage): a
   per-scope radius remap plus a per-scope .card override, both retired at the
   2026-07-31 portal-first pivot. The :root baseline and the shared .card rule
   now state everything this scope used to opt into; what remains below is the
   page's own flush-corner arithmetic, which no shared rule can know about. */

/* Flush children. A .card-body.p-0 runs its contents edge to edge, so a child
   that PAINTS — the last comment's tint (amber for an internal note, the alt
   ground for a system note or a tombstone) or a side-conversation row's
   hairline — crosses the card's new bottom curve as a square corner. Bootstrap
   rounds .card-header:first-child and .card-footer:last-child but has no
   equivalent for a flush body, so shape these two by hand, inset by the card's
   own 1px border — the relationship --bs-card-inner-border-radius expresses.
   Same fix the portal applies to a flush .table-responsive.

   Named individually rather than matched on .card-body.p-0, because the
   COMPOSER body is .p-0 too and its mention typeahead, canned-response list and
   Copilot panels have to escape the box — a blanket overflow:hidden there would
   clip them, and its own contents are padded (px-2/pt-2) so they never touch a
   corner anyway. .ticket-comments needs no overflow of its own: it already
   scrolls, and overflow-y:auto is a clip context in both axes. */
.ticket-detail-body .ticket-comments,
.ticket-detail-body #sideConvList {
    border-bottom-left-radius: calc(var(--ctl-radius-card) - 1px);
    border-bottom-right-radius: calc(var(--ctl-radius-card) - 1px);
}
.ticket-detail-body #sideConvList { overflow: hidden; }

/* The card whose header is the whole card. Bootstrap ships a top-corner
   restatement (.card-header:first-child) and a bottom-corner one for footers
   (.card-footer:last-child), and nothing for this shape: :first-child pins the
   bottom pair to `0 0`, so the header's own background paints a square corner
   over the card's new 12px curve — hiding the border arc and leaving a nub of
   card colour outside it. Right while a body follows, wrong when none does. So
   state the missing pair, inset by the card's 1px border exactly like the flush
   children above, and drop the border-bottom with it: a rule that exists to
   divide the header from a body divides nothing here, and it was landing on the
   card's own bottom hairline and reading as a 2px edge.

   Two ways a header ends up alone, both real on this page:

   :last-child — the Meetings card emits header + nothing when the ticket has no
   linked meetings and the viewer may create one, by design
   (partials/related_meetings.php). This is the reported defect.

   …:has(+ .collapse:not(.show)) — the five collapsed sidebar cards (Tags,
   Linked Tickets, KB Articles, Affected Assets, Parent Ticket) and History keep
   their editor in the DOM inside a shut .collapse so the delegated AJAX can
   still resolve it, so the header is the last thing PAINTED without being the
   last child. Matching on the shut state rather than on the wrapper means the
   corners square themselves the instant the collapse opens and a body appears —
   Bootstrap swaps .collapse for .collapsing at the start of the reveal and back
   at the end of the hide, which is exactly when the geometry should change.

   Both are the same Bootstrap trap, not a per-partial bug, so it is closed once
   here for every headed card in the scope. */
.ticket-detail-body .card-header:last-child,
.ticket-detail-body .card-header:has(+ .collapse:not(.show):last-child) {
    border-bottom-left-radius: calc(var(--ctl-radius-card) - 1px);
    border-bottom-right-radius: calc(var(--ctl-radius-card) - 1px);
    border-bottom: 0;
}

/* ═══ Agent additions to the conversation thread kit ═════════════════════════
   /tickets/{id}'s Activity thread renders through the SAME kit the portal
   thread does (partials/thread/conversation.php + uee-thread.js) — the rows,
   bubbles, runs, emoji, pictures, events and fold above are shared verbatim.
   Everything in THIS block is agent-only and scoped under .thread-agent (the
   container class #commentsContainer carries), so none of it can reach the
   portal's rendering of the same classes.

   (The old .ticket-comment / .ticket-activity card anatomy that lived here —
   including the 50vh internal scroller — was retired with the rebuild: the
   thread sits on the page ground at natural height now, portal-fashion.) */

/* Internal notes: in-thread, desk side, amber where the viewer's own tint
   would be blue — the same wash + hairline structure as .thread-bubble-own
   (opaque elevated base, translucent tint painted over it) so the two read as
   one family, in the warning hue the old .comment-internal treatment already
   taught agents to read as "the requester never sees this". Row-scoped, and
   AFTER the own tint in every cascade sense: an internal note the viewer
   wrote is amber, not blue — internal-ness is the fact that matters. */
.thread-agent .thread-row-internal .thread-bubble {
    background-color: var(--ctl-bg-elevated);
    background-image: linear-gradient(
        rgba(var(--ctl-warning-rgb), 0.12),
        rgba(var(--ctl-warning-rgb), 0.12)
    );
    border-color: rgba(var(--ctl-warning-rgb), 0.35);
}
/* The lock flag on the meta line — quiet, amber, self-explaining on hover
   (the title attribute carries the sentence). */
.thread-agent .thread-internal-flag {
    display: inline-flex;
    align-items: center;
    gap: 0.25em;
    font-size: 0.7rem;
    color: var(--ctl-warning-text);
    white-space: nowrap;
}
.thread-agent .thread-meta-time { white-space: nowrap; font-size: 0.7rem; }

/* ── "View all" — the verbose audit trail is opt-in ─────────────────────────
   The always-visible thread carries the conversation plus the same derived
   status/assignment lines the requester sees. The FULL audit trail (priority,
   category, SLA stamps — Support\AgentTimelineEvent's lines) renders too, as
   .thread-event.thread-event-system, and hides here until the heading's
   "View all" control adds .show-system-activity to the container
   (tickets-show.js). Because this is a descendant rule on the container,
   a verbose line appended later is governed the moment it lands. */
.thread-agent:not(.show-system-activity) .thread-event-system {
    display: none;
}

/* A ticket whose whole recorded history is automation would otherwise render
   as blank ground under the heading. The server prints the note only when
   nothing visible-by-default exists; it retires the moment the toggle opens
   the audit lines it is pointing at. */
.thread-agent.show-system-activity > [data-activity-hidden-note] {
    display: none;
}

/* ── Per-row actions on run follow-ons ──────────────────────────────────────
   The kit hides a follow-on row's whole meta line (display:none !important on
   .thread-run-above .thread-msg-meta) — correct chrome for a run, but on THIS
   surface the meta line also carries the row's ⋯ retract menu, and a message
   must not become un-retractable just because its author sent two in five
   minutes. So on follow-on rows that HAVE actions, the meta line stays in the
   render as a floating chip above the bubble's top corner — present for the
   Tab order, invisible until the row is hovered or anything in it holds
   focus. Opacity, not display/visibility: both of those would drop the ⋯
   button from the tab order and keyboard users could never reveal it.
   :has() gating means a browser without :has simply keeps the kit's hidden
   meta — degraded to "retract from the run head", never broken layout. */
.thread-agent .thread-msg-col { position: relative; }
.thread-agent .thread-run-above .thread-msg-meta:has(.thread-msg-actions) {
    display: flex !important;   /* the kit's hide is !important too */
    position: absolute;
    top: -0.35rem;
    transform: translateY(-100%);
    z-index: 3;
    margin: 0;
    padding: 0.1rem 0.5rem;
    background: var(--ctl-bg-elevated);
    border: 1px solid var(--ctl-border);
    border-radius: var(--ctl-radius);
    box-shadow: var(--ctl-shadow-sm, 0 1px 2px rgba(0, 0, 0, 0.1));
    opacity: 0;
    pointer-events: none;
    transition: opacity 0.12s ease-in-out;
}
.thread-agent .thread-run-above:not(.thread-row-far) .thread-msg-meta:has(.thread-msg-actions) { left: 0; }
.thread-agent .thread-run-above.thread-row-far .thread-msg-meta:has(.thread-msg-actions) { right: 0; }
.thread-agent .thread-run-above:hover .thread-msg-meta:has(.thread-msg-actions),
.thread-agent .thread-run-above .thread-msg-meta:focus-within {
    opacity: 1;
    pointer-events: auto;
}

/* The ⋯ trigger itself stays out of the way until its row is hovered or the
   button holds keyboard focus — a destructive action shouldn't be the loudest
   thing in a thread, and must still be reachable by Tab. (On follow-ons the
   chip above already gates visibility; this covers run heads and standalone
   rows, whose meta line is always on screen.) */
.thread-agent .thread-msg-actions .btn {
    opacity: 0;
    transition: opacity 0.12s ease-in-out;
}
.thread-agent .thread-msg-row:hover .thread-msg-actions .btn,
.thread-agent .thread-msg-actions .btn:focus-visible,
.thread-agent .thread-msg-actions.show .btn {
    opacity: 1;
}

/* ── Participants card roster ───────────────────────────────────────────────
   One row shape for every kind of person on a ticket (assignee, watcher,
   requester, external address), so the list reads as one cast rather than four
   lists that happen to be stacked. The type is the only thing that changes
   between them, and it never carries meaning by color alone — the word IS the
   label (WCAG 1.4.1). */
.ticket-party {
    display: flex;
    align-items: center;
    gap: 0.3rem;
    margin-bottom: 0.25rem;
    min-width: 0;
}

.ticket-party-icon {
    /* Stands in for the avatar an account-less address has no way to draw, at
       the same width so the names below it stay on one left edge. */
    width: 1.5rem;
    text-align: center;
    flex-shrink: 0;
}

.ticket-party-name {
    min-width: 0;
    /* Shrink to the name, so the type caption sits against it rather than being
       pushed to the far edge. */
    flex: 0 1 auto;
}

/* Plain muted text immediately after the name — a caption on the person, not a
   badge. As a tinted bubble on the right edge it read as an action button, and
   it competed with the name it was describing. */
.ticket-party-type {
    flex-shrink: 0;
    font-size: var(--ctl-fs-caption);
    line-height: 1.5;
    white-space: nowrap;
    /* The token, not an opacity: --ctl-fg-muted is contrast-tuned per theme
       (the ratios are commented at each definition) and an opacity on top would
       quietly undercut every one of them. */
    color: var(--ctl-fg-muted, inherit);
}

.ticket-party-remove {
    /* The one thing still anchored right: it acts on the row, it does not
       describe it. */
    margin-inline-start: auto;
    flex-shrink: 0;
    border: none;
    background: none;
    font-size: var(--ctl-fs-caption);
    line-height: 1;
}

/* The add control: one text input with the Add menu parked inside its right
   edge. A second input for "email" was the old shape, and it turned "add this
   person" into a question about which box to type in. */
.ticket-party-input {
    padding-right: 4.25rem;
}

/* The staged person, in chat's Add-people chip vocabulary (chat.css
   .chat-new-chip) — a removable identity token should read the same wherever
   the app asks you to line somebody up before committing. */
.ticket-party-staged {
    display: inline-flex;
    align-items: center;
    gap: 0.25rem;
    max-width: 100%;
    padding: 0.1rem 0.25rem 0.1rem 0.35rem;
    border-radius: var(--ctl-radius-full, 999px);
    background: var(--bs-secondary-bg);
    font-size: var(--ctl-fs-caption);
}

.ticket-party-staged-name {
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

.ticket-party-staged button {
    border: 0;
    background: none;
    padding: 0 2px;
    line-height: 1;
    color: var(--bs-secondary-color);
}

.ticket-party-staged button:hover {
    color: var(--bs-danger);
}

.ticket-party-role {
    position: absolute;
    top: 50%;
    right: 0.25rem;
    transform: translateY(-50%);
    z-index: 3;
}

.ticket-party-role-btn {
    border-radius: var(--ctl-radius-full, 999px);
    padding: 0.05rem 0.5rem;
    font-size: var(--ctl-fs-caption);
    line-height: 1.6;
    background: var(--ctl-surface-2, rgba(127, 127, 127, 0.12));
    color: var(--ctl-fg, inherit);
    border: 1px solid var(--ctl-border, rgba(127, 127, 127, 0.3));
}

.ticket-party-role-btn:hover,
.ticket-party-role-btn:focus-visible {
    border-color: var(--ctl-border-strong, rgba(127, 127, 127, 0.6));
}

/* Ticket tags */
.ticket-tag {
    display: inline-flex;
    align-items: center;
    gap: 0.3rem;
    padding: 0.15rem 0.5rem;
    border-radius: 999px;
    font-size: 0.72rem;
    font-weight: 500;
    line-height: 1.4;
    white-space: nowrap;
}

.ticket-tag-dot {
    display: inline-block;
    width: 0.45rem;
    height: 0.45rem;
    border-radius: 50%;
    flex-shrink: 0;
}

.ticket-tag-remove {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    border: none;
    background: none;
    color: inherit;
    font-size: 0.85rem;
    line-height: 1;
    padding: 0;
    margin-left: 0.1rem;
    cursor: pointer;
    opacity: 0.6;
}

.ticket-tag-remove:hover {
    opacity: 1;
}

/* Print styles */
@media print {
    .sidebar,
    .top-navbar,
    .btn,
    .pagination {
        display: none !important;
    }

    .main-content {
        margin-left: 0;
        width: 100%;
    }
}


/* ═══════════════════════════════════════════════════════════════════════════
   MRP PLANNER WORKBENCH
   ═══════════════════════════════════════════════════════════════════════════ */

/* ─── Time-Phased Grid ─────────────────────────────────────────────────── */
.mrp-grid { border-collapse: separate; border-spacing: 0; }
.mrp-grid th, .mrp-grid td { padding: 0.25rem 0.5rem; text-align: right; white-space: nowrap; font-size: 0.8rem; border-bottom: 1px solid var(--ctl-border); }
.mrp-grid .mrp-grid-label { text-align: left; font-weight: 500; position: sticky; left: 0; z-index: 5; background: var(--ctl-bg-elevated); min-width: 180px; }
.mrp-grid .mrp-grid-header-label { text-align: left; position: sticky; left: 0; z-index: 11; background: var(--ctl-bg-alt); min-width: 180px; }
.mrp-grid .mrp-grid-header { text-align: center; font-size: 0.7rem; text-transform: uppercase; color: var(--ctl-fg-muted); font-weight: 500; min-width: 80px; }
.mrp-grid .mrp-grid-row-demand td { background: rgba(var(--bs-primary-rgb), 0.03); }
.mrp-grid .mrp-grid-row-supply td { background: rgba(var(--bs-success-rgb), 0.03); }
/* The neutral member of the demand/supply/planned trio. It names the ink
   triplet directly rather than a Bootstrap family because that is what it has
   always RENDERED: --bs-info-rgb resolved to the muted ink until #2003
   re-bridged the family onto the informational wash, and a 3% wash of a wash is
   no wash at all. The two rows above stay on their families because those
   really are solids. */
.mrp-grid .mrp-grid-row-planned td { background: rgba(var(--ctl-fg-muted-rgb), 0.03); }
.mrp-grid .mrp-grid-row-projected td { border-top: 2px solid var(--ctl-border); }
.mrp-grid .mrp-grid-subrow td { font-size: 0.75rem; color: var(--ctl-fg-muted); }
.mrp-grid .mrp-grid-subrow .mrp-grid-label { font-weight: 400; }
.mrp-grid-cell.negative { color: var(--ctl-danger-text) !important; font-weight: 700; }
.mrp-grid-cell.has-planned { color: var(--ctl-accent-text); font-weight: 600; }

/* Planning fence indicators */
.mrp-fence-dtf { border-left: 2px dashed var(--ctl-warning); }
.mrp-fence-ptf { border-left: 2px dashed var(--ctl-info); }

/* ─── Demand Trace Path ────────────────────────────────────────────────── */
.trace-path { font-family: var(--ctl-font-mono); font-size: 0.8rem; }
.trace-path-segment { color: var(--ctl-link); cursor: pointer; text-decoration: none; }
.trace-path-segment:hover { text-decoration: underline; }
.trace-path-separator { color: var(--ctl-fg-muted); margin: 0 0.25rem; }
.trace-path-phantom { color: var(--ctl-warning-text); font-style: italic; }

/* ─── Demand Trace Tree ────────────────────────────────────────────────── */
.trace-tree-node { padding: 0.2rem 0; border-bottom: 1px solid var(--ctl-border); }
.trace-tree-toggle { cursor: pointer; user-select: none; }
.trace-tree-qty { font-weight: 600; min-width: 60px; display: inline-block; text-align: right; margin-right: 0.5rem; }
.trace-tree-doc { color: var(--ctl-fg-muted); font-size: 0.8rem; }

/* ─── MRP Exception Severity ──────────────────────────────────────────── */
.mrp-severity-critical { color: var(--ctl-danger-text); font-weight: 700; }
.mrp-severity-warning { color: var(--ctl-warning-text); font-weight: 600; }
.mrp-severity-informational { color: var(--ctl-fg-muted); }

/* ─── Safety Stock Delta Colors ────────────────────────────────────────── */
.ss-delta-increase { color: var(--ctl-danger-text); }
.ss-delta-decrease { color: var(--ctl-success-text); }
.ss-delta-zero { color: var(--ctl-fg-muted); }

/* ============================================================
   UEE Branded Toast + Modal — replaces native alert() / confirm()
   See window.uee.toast() and window.uee.confirm() in app.js.
   ============================================================ */

.uee-toast-container {
    position: fixed;
    top: 1rem;
    right: 1rem;
    z-index: var(--ctl-z-toast);
    display: flex;
    flex-direction: column;
    gap: 0.5rem;
    max-width: 420px;
    pointer-events: none;
}
.uee-toast {
    pointer-events: auto;
    padding: 0.75rem 2.5rem 0.75rem 1rem;
    border-radius: var(--ctl-radius-lg);
    background: var(--ctl-bg-elevated);
    color: var(--ctl-fg);
    border: 1px solid var(--ctl-border);
    border-left: 4px solid var(--ctl-accent);
    box-shadow: 0 4px 16px var(--ctl-shadow-hover);
    font-size: 0.875rem;
    line-height: 1.4;
    position: relative;
    cursor: pointer;
    animation: uee-toast-in 0.2s ease-out;
    word-wrap: break-word;
}
.uee-toast.is-leaving { animation: uee-toast-out 0.18s ease-in forwards; }
.uee-toast.success  { border-left-color: var(--ctl-success); }
.uee-toast.error    { border-left-color: var(--ctl-danger); }
.uee-toast.warning  { border-left-color: var(--ctl-warning); }
.uee-toast.info     { border-left-color: var(--ctl-info); }
.uee-toast-title {
    font-weight: 600;
    margin-bottom: 0.15rem;
    color: var(--ctl-fg);
}
.uee-toast-close {
    position: absolute;
    top: 0.5rem;
    right: 0.5rem;
    background: none;
    border: 0;
    font-size: 1.1rem;
    line-height: 1;
    color: var(--ctl-fg-muted);
    padding: 0.15rem 0.4rem;
    cursor: pointer;
}
.uee-toast-close:hover { color: var(--ctl-fg); }

/* Undo affordance. It is a link, not a button fill: the toast is already an
   interruption, and a second solid control in it would compete with whatever the
   user was actually doing. Underlined rather than colour-only so it reads as
   actionable without relying on hue (WCAG 1.4.1) — the accent-TEXT token is the
   contrast-verified one for this (§4.5). */
.uee-toast-action {
    display: inline-block;
    margin-top: 0.35rem;
    padding: 0.1rem 0;
    background: none;
    border: 0;
    font: inherit;
    font-weight: 600;
    color: var(--ctl-accent-text);
    text-decoration: underline;
    text-underline-offset: 0.15em;
    cursor: pointer;
}
.uee-toast-action:hover { color: var(--ctl-fg); }
.uee-toast-action:focus-visible {
    outline: none;
    box-shadow: var(--ctl-focus-ring);
    border-radius: var(--ctl-radius);
}

@keyframes uee-toast-in  { from { transform: translateX(110%); opacity: 0; } to { transform: translateX(0); opacity: 1; } }
@keyframes uee-toast-out { to   { transform: translateX(110%); opacity: 0; } }

.uee-modal-backdrop {
    position: fixed; inset: 0;
    background: rgba(0, 0, 0, 0.45);
    z-index: 1090; /* z literal kept: must top the AI chat panel (1040) and Bootstrap's modal stack */
    display: flex; align-items: center; justify-content: center;
    animation: uee-modal-fade-in 0.15s ease-out;
}
.uee-modal {
    background: var(--ctl-bg-elevated);
    border-radius: var(--ctl-radius-lg);
    box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3);
    max-width: 460px;
    width: 90%;
    overflow: hidden;
    animation: uee-modal-pop-in 0.18s ease-out;
}
.uee-modal-header {
    padding: 0.85rem 1.25rem;
    border-bottom: 1px solid var(--ctl-border);
    font-weight: 600;
    color: var(--ctl-fg);
}
.uee-modal-body {
    padding: 1.25rem;
    color: var(--ctl-fg);
}
.uee-modal-footer {
    padding: 0.75rem 1.25rem;
    background: var(--ctl-bg-alt);
    border-top: 1px solid var(--ctl-border);
    display: flex;
    justify-content: flex-end;
    gap: 0.5rem;
}
/* A uee-modal that holds a LIST rather than a sentence (the bulk-merge parent
   picker). Same shell, wider box, and the body scrolls instead of the page. */
.uee-modal.uee-modal-wide { max-width: 720px; }
.uee-modal-wide .uee-modal-body { max-height: 60vh; overflow-y: auto; }

/* Bulk merge parent picker — one radio row per selected ticket. Number, subject
   and requester are the three facts an agent tells duplicates apart by, so they
   get their own columns and the subject takes the slack. */
.bulk-merge-list {
    border: 1px solid var(--ctl-border);
    border-radius: var(--ctl-radius);
    overflow: hidden;
}
.bulk-merge-option {
    display: flex;
    align-items: center;
    gap: 0.75rem;
    width: 100%;
    margin: 0;
    padding: 0.5rem 0.75rem;
    cursor: pointer;
    border-top: 1px solid var(--ctl-border);
}
.bulk-merge-option:first-child { border-top: 0; }
.bulk-merge-option:hover { background: var(--ctl-bg-alt); }
.bulk-merge-option:has(input:checked) { background: var(--ctl-bg-alt); }
.bulk-merge-option input { flex: 0 0 auto; margin: 0; }
.bulk-merge-fields {
    display: flex;
    align-items: center;
    gap: 0.75rem;
    min-width: 0;
    flex: 1 1 auto;
}
.bulk-merge-number { flex: 0 0 auto; }
.bulk-merge-subject {
    flex: 1 1 auto;
    min-width: 0;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}
.bulk-merge-requester {
    flex: 0 0 auto;
    max-width: 12rem;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
    font-size: var(--ctl-fs-body-sm);
}

@keyframes uee-modal-fade-in { from { opacity: 0; } to { opacity: 1; } }
@keyframes uee-modal-pop-in  { from { opacity: 0; transform: scale(0.96); } to { opacity: 1; transform: scale(1); } }

/* === Section cards (record template) === */
/* Detail + create pages: schema sections render as cards packed into two
   CSS columns on lg+ (vertical fill — no row-alignment gaps; a grid was
   tried first and left holes under short cards). Full-width cards span both
   columns. Each card carries a hover-revealed visibility toggle top-right;
   a hidden card collapses to a dimmed title-only stub that keeps its slot.
   See partials/record_sections.php and js/record-sections.js. */

.record-cards {
    column-gap: 1rem;
    /* The softened-geometry remap this scope pioneered (rounded cards AND
       rounded controls, #1465 lineage) retired at the 2026-07-31 pivot —
       the :root baseline is now exactly what this scope asked for. */
}

@media (min-width: 992px) {
    .record-cards {
        columns: 2;
    }
    .record-cards > .record-card-full {
        column-span: all;
    }
}

.record-card {
    border: 1px solid var(--ctl-card-border);
    border-radius: var(--ctl-radius-lg);
    background: var(--ctl-bg-elevated);
    box-shadow: var(--ctl-card-shadow);
    padding: 0.75rem 1rem 0.9rem;
    min-width: 0;
    margin-bottom: 1rem;
    /* Multicol must never split a card across columns. */
    break-inside: avoid;
}

/* Inside a card the field rows read as one surface — the hairline drops to
   the subtle step and the label column widens so values start on a shared
   axis instead of hugging each label's tail. */
.record-card .detail-row {
    border-bottom-color: var(--ctl-border-subtle);
    gap: 1rem;
}

.record-card .detail-label {
    min-width: 10rem;
}

/* Head reuses .detail-section-bar (title + right-side control over one
   full-width rule). The whole head — title through chevron — is the
   collapse/expand click target (record-sections.js); required and disabled
   cards render no chevron, so :has() leaves their heads inert-looking. */
.record-card-head:has([data-card-toggle]) {
    cursor: pointer;
}

/* The chevron: light grey, hover-revealed while expanded, always visible
   while collapsed (the open cue). */
.record-card-toggle {
    border: none;
    background: none;
    color: var(--ctl-fg-faint);
    padding: 0 0.25rem;
    line-height: 1;
    font-size: 0.85rem;
    border-radius: var(--ctl-radius);
    cursor: pointer;
    opacity: 0;
    transition: opacity 0.15s ease;
}

.record-card:hover .record-card-toggle,
.record-card-toggle:focus-visible {
    opacity: 1;
}

.record-card-toggle:hover {
    color: var(--ctl-fg-muted);
    background: var(--ctl-bg-hover);
}

/* Collapsed: same card chrome, title-only. The head's 2px rule would read as
   a stray underline with no content below it, so it drops. */
.record-card.is-collapsed .record-card-body {
    display: none;
}

.record-card.is-collapsed {
    padding-bottom: 0.75rem;
}

.record-card.is-collapsed .record-card-head {
    border-bottom-color: transparent;
    padding-bottom: 0;
    margin-bottom: 0;
}

.record-card.is-collapsed .record-card-toggle {
    opacity: 1;
}

.record-card-note {
    font-size: 0.72rem;
    color: var(--ctl-fg-faint);
    white-space: nowrap;
}

/* Validation error marker, injected into the card head by record-sections.js. */
.record-card-head .err-dot {
    width: 0.45rem;
    height: 0.45rem;
    border-radius: 50%;
    background: var(--ctl-danger);
    align-self: center;
    flex-shrink: 0;
}

/* Multi-line text: label above, value below full width, scroll past ~14rem.
   Applies in both display and edit/create (textarea full width). */
.detail-row.detail-row-block {
    display: block;
}

.detail-row-block .detail-label {
    display: block;
    margin-bottom: 0.2rem;
}

.detail-row-block .detail-view {
    display: block;
    white-space: pre-wrap;
    background: var(--ctl-bg-inset);
    border: 1px solid var(--ctl-border);
    border-radius: var(--ctl-radius);
    padding: 0.4rem 0.6rem;
    max-height: 14rem;
    overflow-y: auto;
}

.detail-row-block textarea.detail-input,
.detail-row-block textarea {
    display: block;
    width: 100%;
}

/* ── json_tree.php — recursive, typed view of arbitrary-shape JSON ──────── */
.json-tree {
    font-size: 0.85rem;
    line-height: 1.4;
}

/* Object: a stack of label / value rows. */
.json-tree-row {
    display: flex;
    align-items: baseline;
    gap: 0.6rem;
    padding: 0.15rem 0;
    border-bottom: 1px solid var(--ctl-border);
}

.json-tree-row:last-child {
    border-bottom: none;
}

.json-tree-key {
    font-size: 0.72rem;
    font-weight: 600;
    text-transform: uppercase;
    letter-spacing: 0.03em;
    color: var(--ctl-fg-muted);
    min-width: 9rem;
    flex-shrink: 0;
}

.json-tree-val {
    flex: 1;
    min-width: 0;
    word-break: break-word;
}

/* Nested objects/lists indent under their key for visual hierarchy. */
.json-tree-val > .json-tree-object,
.json-tree-val > .json-tree-list {
    border-left: 2px solid var(--ctl-border);
    padding-left: 0.6rem;
    margin-top: 0.15rem;
}

/* List of objects: index-labeled blocks. */
.json-tree-list-item {
    display: flex;
    gap: 0.5rem;
    padding: 0.2rem 0;
}

.json-tree-list-item + .json-tree-list-item {
    border-top: 1px dashed var(--ctl-border);
}

.json-tree-index {
    font-size: 0.7rem;
    font-weight: 600;
    flex-shrink: 0;
    min-width: 2rem;
}

.json-tree-list-body {
    flex: 1;
    min-width: 0;
}

/* List of scalars: wrapping badge row. */
.json-tree-inline .badge {
    font-weight: 500;
}

/* Raw JSON fallback + the collapsible citation block. */
.json-tree-raw {
    font-size: 0.78rem;
    background: var(--ctl-bg-inset);
    border: 1px solid var(--ctl-border);
    border-radius: var(--ctl-radius);
    padding: 0.5rem 0.7rem;
    max-height: 20rem;
    overflow: auto;
    white-space: pre-wrap;
    word-break: break-word;
}

.json-tree-rawtoggle summary {
    cursor: pointer;
    user-select: none;
}

/* Field help — subtle question-mark tooltip beside a field label. Rendered by
   record_sections.php when a schema column carries a 'help' string. */
.field-help {
    color: var(--ctl-fg-muted);
    font-size: 0.7rem;
    cursor: help;
}
.field-help:hover {
    color: var(--ctl-accent-text);
}

/* ═══════════════════════════════════════════════════════════════════════════
   MDT CENTRAL COMPONENT KIT (UI_STANDARDS.md §6 — Phase B contracts)
   All values from --ctl-* tokens only; every class must hold in all six
   style/scheme sheets and both densities. Markup partners live in
   templates/partials/ (page_header.php, labeled_field.php, simple_table.php,
   empty_state.php, ...). JS partner for .ctl-popover is js/popover.js.
   ═══════════════════════════════════════════════════════════════════════════ */

/* ── Page header (page_header.php — list + detail modes) ─────────────────── */
.ctl-page-header {
    display: flex;
    align-items: flex-end;
    justify-content: space-between;
    flex-wrap: wrap;
    gap: var(--ctl-sp-2) var(--ctl-sp-4);
    margin-bottom: var(--ctl-sp-4);
}

.ctl-page-header-title {
    margin: 0;
    font-family: var(--ctl-font-display);
    font-size: var(--ctl-fs-display-md);
    font-weight: 700;
    line-height: var(--ctl-lh-tight);
    text-transform: uppercase;
    letter-spacing: -0.025em;
    color: var(--ctl-fg);
    display: inline-flex;
    align-items: center;
    gap: var(--ctl-sp-2);
    min-width: 0;
}

/* ── Header lead: optional back control + title (page_header.php $backHref) ──
   The lead group keeps the back arrow glued to the title (--ctl-sp-2) instead of
   inheriting the header's wider column gap, and stays a single flex child so
   wrapping, the accent rule and the right-aligned actions cluster behave exactly
   as before. Only rendered when a page passes $backHref. */
.ctl-page-header-lead {
    display: flex;
    align-items: center;
    gap: var(--ctl-sp-2);
    min-width: 0;
}

/* Thin rounded-square outline icon button. --ctl-radius is 0px in the sharp
   styles (Industrial/Operator) and 3px in Refined, so the radius is derived —
   +3px — to stay visibly rounded in every sheet while still tracking the
   style's own corner language. Border/color are tokens, so light and dark
   schemes follow their own --ctl-border-strong / --ctl-fg-muted. */
.ctl-page-header-back {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    flex: 0 0 auto;
    position: relative;
    /* 1.5rem square (24px) — 75% of the old 2rem box. The right-hand toolbar
       icons (.detail-tool) are borderless, so an equally-sized BORDERED square
       here read as the heaviest thing in the header. Shrinking the drawn box
       while the ::after below keeps a full 2rem hit target lets the arrow sit
       as a quiet affordance beside the title without losing clickable area. */
    width: 1.5rem;
    height: 1.5rem;
    border: 1px solid var(--ctl-border-strong);
    border-radius: calc(var(--ctl-radius) + 3px);
    background: transparent;
    color: var(--ctl-fg-muted);
    font-size: 0.8rem;
    line-height: 1;
    text-decoration: none;
    transition: color var(--ctl-dur-fast), border-color var(--ctl-dur-fast), background var(--ctl-dur-fast);
}

/* Invisible hit-target skirt: the drawn box is 24px, the clickable area stays
   32px (WCAG 2.2 §2.5.8 comfortably cleared). inset resolves against the
   PADDING box (22px inside the 1px border), so -5px — not -4px — is what puts
   the skirt 4px outside the border box. The 8px --ctl-sp-2 lead gap means it
   never reaches the title, and nothing sits to its left. */
.ctl-page-header-back::after {
    content: '';
    position: absolute;
    inset: -5px;
}

.ctl-page-header-back:hover,
.ctl-page-header-back:focus-visible {
    color: var(--ctl-accent-text);
    border-color: var(--ctl-accent);
    background: var(--ctl-bg-hover);
}

.ctl-page-header-back:focus-visible {
    outline: none;
    box-shadow: var(--ctl-focus-ring);
}

/* Count badge riding inline with the title (--list mode) — body face, not
   Oswald, so the numeral doesn't inherit the display treatment. */
.ctl-page-header-title .badge,
.ctl-page-header-count {
    font-family: var(--ctl-font-body);
    font-size: var(--ctl-fs-caption);
    font-weight: 700;
    letter-spacing: 0.04em;
    line-height: var(--ctl-lh-snug);
}

/* No chip chrome on the list count — deliberate, and a reversal. #1423 gave it
   its own surface, hairline and full-round corner on the reasoning that a bare
   numeral beside a 28px Oswald title reads as a superscript. In the app it read
   as a badge competing with the title, and the muted-on-bg-alt fill drained the
   number of the presence it had. The count keeps the title's own ink and the
   shared caption typography above: same body face, same caption scale, bold,
   no box. Only the tabular-figures opt-in from #1423 survives (see the
   micro-typography layer) — that one is functional: the count is swapped live
   by tickets-list-live.js, and proportional digits made the heading reflow on
   every update. */

.ctl-page-header-sub {
    margin: var(--ctl-sp-1) 0 0;
    font-family: var(--ctl-font-body);
    font-size: var(--ctl-fs-body-sm);
    font-weight: 400;
    line-height: var(--ctl-lh-normal);
    letter-spacing: normal;
    text-transform: none;
    color: var(--ctl-fg-muted);
}

/* 48×4 accent rule — the §4.4 heavy-accent signature bar. border/opacity
   reset so an <hr> renders identically to a <div>. */
.ctl-page-header-rule {
    width: 48px;
    height: 4px;
    background: var(--ctl-accent);
    border: 0;
    opacity: 1;
    margin: var(--ctl-sp-2) 0 0;
}

.ctl-page-header-actions {
    display: flex;
    align-items: center;
    flex-wrap: wrap;
    gap: var(--ctl-sp-2);
    margin-left: auto;
}

/* --list: page identity above a content stack. Vertically centered so the count
   badge and the actions cluster align with the title's midline.

   THE BOTTOM GAP DEFAULTS TO THE BREATHING STEP, AND THAT IS A DELIBERATE FLIP
   (#1880). List mode used to close on --ctl-sp-3 (12px), a value chosen for the
   one shape the mode was first built for: a title sitting directly on the table
   it names, where a tight bind is the whole point. In the app the mode is used
   far more widely than that — of the 44 list-mode headers, 31 sit above
   something that is NOT a table (card stacks, filter panels, stat-tile rows,
   settings forms, intro prose, the role dashboard's ambient bands). On every one
   of those, 12px bound the title to the first block at HALF the gap the content
   under it used, and the fix each time was another page-scoped override — the
   drift the §6 one-partial contract exists to prevent.

   So the common case owns the default: --ctl-sp-5 (24px), the same step the
   content stacks below already breathe on, and the table pages opt DOWN with
   .ctl-page-header--table below. Opting down is the safe direction: a page that
   forgets the modifier reads slightly loose, where the old default made a page
   that forgot an override read broken. */
.ctl-page-header--list {
    align-items: center;
    margin-bottom: var(--ctl-sp-5);
}

/* --table: the opt-DOWN. Set $onTable on page_header.php when the header sits
   directly on the table it labels — the table itself, or the chip/search
   toolbar that filters exactly that table (the /tickets, /resource and saved-
   query list archetype). The title and the grid it names are one object, so
   they bind at --ctl-sp-3 (12px) instead of standing apart.

   NOT for a card stack that happens to contain tables (per-group panels with
   their own headings, e.g. saved queries and sequences), and not for a page
   that opens with prose or a filter panel before its table — in both of those
   the header labels the stack, not a grid, and the default is correct.

   Kept at ONE class of specificity (0,1,0) rather than chained to --list, so
   it ranks by source order against the sibling modifier and the page-scoped
   .dashboard-home rule (0,2,0) still out-specifies both without !important. */
.ctl-page-header--table {
    margin-bottom: var(--ctl-sp-3);
}

/* --detail: inherited the legacy .detail-page-header's spacing so the
   fixed-header/scrollable-body detail layout kept its exact rhythm. That
   ancestor was deleted in #2057 once a repo-wide grep proved no markup could
   reach it; this rule is now the only place the detail header's spacing is
   stated.

   THE TWO NUMBERS ARE NAMED, NOT RE-RULED. #1880 put list mode on the 8pt ramp
   end to end (--ctl-sp-5 default, --ctl-sp-3 on the --table opt-down) and left
   detail mode alone on purpose: 0.35rem is 5.6px and lands on no rung, so
   MOVING it to --ctl-sp-1/--ctl-sp-2 is a visual decision about the detail
   header's rhythm — one that shifts the top of ~72 pages — not a mechanical
   substitution. That decision has not been made, and this change does not make
   it: both defaults below carry the values this rule already had, so every
   detail page resolves byte-for-byte as before.

   What changes is that the values now have names. Off-ramp BY INHERITANCE from
   the legacy .detail-page-header (since retired), not by a ruling anyone wrote
   down — and an unnamed literal is exactly the thing that made #1880 skip this
   rule and then need an issue to describe it. Named, the pair is greppable,
   the retune has one edit point, and a detail surface that ever needs its own
   rhythm remaps the variable from its own scope.

   Stated as var-with-default (the #1973 idiom, and the same shape the shared
   composer knobs use): the DEFAULT carries the value, so no :root declaration
   is added and a scoped remap is not fighting an own declaration on this
   element. When the ramp question is settled, these two defaults become
   var(--ctl-sp-N) and nothing else in the file has to move.

   #1999 pointed the legacy .detail-page-header and its mobile @media
   restatement at these SAME two tokens so a retune could not half-apply; #2057
   then established both were unreachable by any markup and deleted them, which
   makes the "one edit point" trivially true — there is one rule left.
   CssTokenContractTest pins those selectors retired so the split cannot
   silently come back. */
.ctl-page-header--detail {
    flex-shrink: 0;
    border-bottom: 1px solid var(--ctl-border);
    padding-bottom: var(--ctl-page-head-detail-pad, 0.35rem);
    margin-bottom: var(--ctl-page-head-detail-gap, 0.5rem);
}

/* ── Labeled field (labeled_field.php — the only sanctioned control wrapper) */
.ctl-field {
    margin-bottom: var(--ctl-sp-3);
}

.ctl-field-label {
    display: block;
    font-size: var(--ctl-fs-label);
    font-weight: 500;
    margin-bottom: 0.15rem;
    color: var(--ctl-fg);
}

.ctl-field-help {
    margin-top: 0.1rem;
    font-size: var(--ctl-fs-body-sm);
    color: var(--ctl-fg-muted);
}

.ctl-field-error {
    margin-top: 0.1rem;
    font-size: var(--ctl-fs-body-sm);
    color: var(--ctl-danger-text);
}

/* ── Simple table (simple_table.php — list_table minus column prefs) ─────── */
/* Wrapper owns horizontal overflow (§8: tables scroll inside their own
   container, the page never scrolls sideways). Inner .table inherits the
   global table treatment above. Row-click affordance keys on data-row-url —
   the delegated navigator in app.js — so cursor and behavior always agree. */
.ctl-table-simple {
    overflow-x: auto;
}

.ctl-table-simple > .table {
    margin-bottom: 0;
}

.ctl-table-simple tr[data-row-url] {
    cursor: pointer;
}

/* Embedded/flush tables (§6): when a table sits edge-to-edge inside a card or
   scroll wrapper, its first/last cells carry the card's content padding so
   text aligns with sibling card-body content instead of touching the edge
   (stakeholder finding 2026-07-21: notification prefs, status management,
   schema browser). */
.ctl-table-simple > .table > :not(caption) > * > *:first-child,
.table-responsive > .table > :not(caption) > * > *:first-child,
.card > .card-body.p-0 .table > :not(caption) > * > *:first-child,
.card > .table > :not(caption) > * > *:first-child {
    padding-inline-start: var(--ctl-sp-4);
}
.ctl-table-simple > .table > :not(caption) > * > *:last-child,
.table-responsive > .table > :not(caption) > * > *:last-child,
.card > .card-body.p-0 .table > :not(caption) > * > *:last-child,
.card > .table > :not(caption) > * > *:last-child {
    padding-inline-end: var(--ctl-sp-4);
}

/* ── Empty state (empty_state.php) ───────────────────────────────────────── */
.ctl-empty-state {
    text-align: center;
    padding: var(--ctl-sp-7) var(--ctl-sp-5);
    color: var(--ctl-fg-muted);
}

.ctl-empty-state-icon {
    display: block;
    font-size: 2rem;
    line-height: 1;
    color: var(--ctl-fg-faint); /* decorative — the title carries the info */
    margin-bottom: var(--ctl-sp-3);
}

.ctl-empty-state-title {
    margin: 0 0 var(--ctl-sp-2);
    font-family: var(--ctl-font-display);
    font-size: var(--ctl-fs-display-sm);
    font-weight: 700;
    line-height: var(--ctl-lh-tight);
    text-transform: uppercase;
    letter-spacing: -0.025em;
    color: var(--ctl-fg);
}

.ctl-empty-state-body {
    max-width: 46ch;
    margin: 0 auto;
    color: var(--ctl-fg-muted);
}

.ctl-empty-state-action {
    margin-top: var(--ctl-sp-4);
}

/* ── Section label ───────────────────────────────────────────────────────── */
/* THE uppercase micro-label. The audit found 8 hand-rolled copies of this
   idiom (.nav-history-title, .notification-dropdown-title, .col-settings-title,
   .detail-label, .detail-section-title, .drilldown-title, .json-tree-key,
   .login-core-label); they migrate here in the conformance task. System stack
   (not Lato) per §2 stakeholder legibility decision. */
.ctl-section-label {
    margin: 0 0 var(--ctl-sp-2);
    font-family: var(--ctl-font-body);
    font-size: var(--ctl-fs-caption);
    font-weight: 700;
    line-height: var(--ctl-lh-snug);
    text-transform: uppercase;
    letter-spacing: 0.08em;
    color: var(--ctl-fg-subtle);
}

/* ── Pagination (table_status_bar.php extended — one shared partial) ─────── */
/* Nav landmark wrapper. The inner list is Bootstrap .pagination; sizing here
   reproduces the status bar's `pagination-sm mb-0` look without the utility
   soup, and the accent override below themes the active page in all sheets. */
.ctl-pagination {
    display: flex;
    align-items: center;
}

.ctl-pagination .pagination {
    margin-bottom: 0;
    --bs-pagination-padding-y: 0.25rem;
    --bs-pagination-padding-x: 0.5rem;
    --bs-pagination-font-size: var(--ctl-fs-body-sm);
}

/* Active page was Bootstrap literal blue (#0d6efd) in every list. Accent fill
   + accent-fg glyph (§4.5: black-on-green 7.32:1, white-on-blue 4.55:1). */
.pagination {
    --bs-pagination-active-bg: var(--ctl-accent);
    --bs-pagination-active-border-color: var(--ctl-accent);
    --bs-pagination-active-color: var(--ctl-accent-fg);
    --bs-pagination-focus-box-shadow: var(--ctl-focus-ring);
}

/* ── Popover shell (js/popover.js — uee.popover.attach) ──────────────────── */
/* One anchored-panel primitive replacing the hand-rolled dropdown shells
   (.nav-history-dropdown migrated; notifications, gear panel, perm-add next).
   The panel is absolutely positioned inside a position:relative host (attach()
   ensures the host is positioned). JS toggles .open, and flips/aligns with the
   --above/--start modifiers after measuring the viewport. */
.ctl-popover {
    display: none;
    position: absolute;
    top: calc(100% + 4px);
    right: 0;
    min-width: 240px;
    max-width: min(92vw, 380px); /* §6 contract */
    background: var(--ctl-bg-elevated);
    color: var(--ctl-fg);
    border: 1px solid var(--ctl-border);
    border-radius: var(--ctl-radius);
    box-shadow: 0 4px 12px var(--ctl-shadow-hover);
    z-index: var(--ctl-z-dropdown);
}

.ctl-popover.open {
    display: block;
}

/* Shells whose body scrolls (notifications-style header/body/footer stacks)
   opt into a flex column via opts.flex. */
.ctl-popover--flex.open {
    display: flex;
    flex-direction: column;
}

.ctl-popover--above {
    top: auto;
    bottom: calc(100% + 4px);
}

.ctl-popover--start {
    right: auto;
    left: 0;
}

/* ── Form control accents (§4.5) ─────────────────────────────────────────── */
/* Bootstrap ships literal blue (#0d6efd) for checked checks/switches and a
   literal near-black select arrow. Overrides below route them through the
   accent tokens. Glyph colors can't use var() inside url(), so the SVGs are
   declared as custom properties and keyed the same way the token sheets are:
   glyph-on-accent follows the STYLE (accent-fg: black on MDT green for
   Industrial/Operator, white on blue for Refined — §4.5), neutral glyphs
   follow the SCHEME (light/dark).
   Contrast (computed 2026-07-21, WCAG 2.1):
     check/knob black on green #86a33d  7.32:1   white on blue #2f6fed 4.55:1
     select arrow #5c5c5c on light insets ≥6.27:1 (#fff 6.69, #f8f8f8 6.30,
       #f5f8fb 6.27); #9a9a9a on dark insets ≥6.41:1 (#0d0d0d 6.91,
       #0a1727 6.41, #0e0e0c 6.87)
   Accent-colored arrow was rejected: green on white is 2.87:1 (<3:1 UI
   graphics minimum), so the arrow stays neutral fg-muted-tone. The checked
   fill itself follows the primary-button precedent (§4.5): the glyph carries
   the ≥3:1 state contrast, not the fill boundary. */
:root {
    --ctl-check-svg: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3e%3cpath fill='none' stroke='%23000' stroke-linecap='round' stroke-linejoin='round' stroke-width='3' d='m6 10 3 3 6-6'/%3e%3c/svg%3e");
    --ctl-radio-svg: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='2' fill='%23000'/%3e%3c/svg%3e");
    --ctl-dash-svg: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3e%3cpath fill='none' stroke='%23000' stroke-linecap='round' stroke-linejoin='round' stroke-width='3' d='M6 10h8'/%3e%3c/svg%3e");
    --ctl-switch-knob-checked: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='%23000'/%3e%3c/svg%3e");
    --ctl-switch-knob: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='%235c5c5c'/%3e%3c/svg%3e");
    --ctl-select-arrow: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%235c5c5c' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='m2 5 6 6 6-6'/%3e%3c/svg%3e");
}

html[data-bs-theme="dark"] {
    --ctl-switch-knob: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='%239a9a9a'/%3e%3c/svg%3e");
    --ctl-select-arrow: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%239a9a9a' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='m2 5 6 6 6-6'/%3e%3c/svg%3e");
}

html[data-style="refined"] {
    --ctl-check-svg: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3e%3cpath fill='none' stroke='%23fff' stroke-linecap='round' stroke-linejoin='round' stroke-width='3' d='m6 10 3 3 6-6'/%3e%3c/svg%3e");
    --ctl-radio-svg: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='2' fill='%23fff'/%3e%3c/svg%3e");
    --ctl-dash-svg: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3e%3cpath fill='none' stroke='%23fff' stroke-linecap='round' stroke-linejoin='round' stroke-width='3' d='M6 10h8'/%3e%3c/svg%3e");
    --ctl-switch-knob-checked: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='%23fff'/%3e%3c/svg%3e");
}

.form-select {
    --bs-form-select-bg-img: var(--ctl-select-arrow);
}

.form-check-input:checked {
    background-color: var(--ctl-accent);
    border-color: var(--ctl-accent);
}

.form-check-input:focus {
    border-color: var(--ctl-accent);
    box-shadow: var(--ctl-focus-ring);
}

.form-check-input:checked[type="checkbox"] {
    --bs-form-check-bg-image: var(--ctl-check-svg);
}

.form-check-input:checked[type="radio"] {
    --bs-form-check-bg-image: var(--ctl-radio-svg);
}

.form-check-input[type="checkbox"]:indeterminate {
    background-color: var(--ctl-accent);
    border-color: var(--ctl-accent);
    --bs-form-check-bg-image: var(--ctl-dash-svg);
}

/* Switch knob: neutral when unchecked+focused (Bootstrap uses literal blue
   #86b7fe there), accent-fg when checked. Bootstrap's own dark-theme rule for
   unchecked-unfocused (higher specificity, translucent white) still applies —
   intentionally left alone, it's already neutral. */
.form-switch .form-check-input,
.form-switch .form-check-input:focus {
    --bs-form-switch-bg: var(--ctl-switch-knob);
}

.form-switch .form-check-input:checked {
    --bs-form-switch-bg: var(--ctl-switch-knob-checked);
}

/* ═══════════════════════════════════════════════════════════════════════════
   ── WIDGET KIT ──  MDT Central dashboard widgets (stakeholder-signed 2026-07-22)
   Translates the approved KPI / pipeline / alert / queue sample treatments onto
   --ctl-* tokens: elevated-surface cards, 1px borders, radius token, §4.3 status
   washes for RAG/severity, fg-subtle micro-labels, Oswald tabular numerals, and
   a single-hue sequential ramp for pipeline segments. Works across all six
   sheets + both densities. Consumed by templates/partials/widgets/*.
   ═══════════════════════════════════════════════════════════════════════════ */

/* ── Sequential ramp tokens (dataviz) ─────────────────────────────────────────
   Green ramp is the default (Operator + Industrial, light & dark); Refined
   overrides to the blue ramp. Steps 1-2 are light (dark ink), steps 3-4 are dark
   (white ink) — validated for contrast against the segment fill itself, so the
   ramp is scheme-invariant (segments carry their own background). */
:root {
    --ctl-seq-1: #e7ecd3;  --ctl-seq-1-ink: #141414;
    --ctl-seq-2: #9fbc50;  --ctl-seq-2-ink: #141414;
    --ctl-seq-3: #6d8730;  --ctl-seq-3-ink: #ffffff;
    --ctl-seq-4: #47591f;  --ctl-seq-4-ink: #ffffff;
}
html[data-style="refined"] {
    --ctl-seq-1: #d8e4fb;  --ctl-seq-1-ink: #0b1a2e;
    --ctl-seq-2: #6a9bff;  --ctl-seq-2-ink: #0b1a2e;
    --ctl-seq-3: #2258c9;  --ctl-seq-3-ink: #ffffff;
    --ctl-seq-4: #142f6b;  --ctl-seq-4-ink: #ffffff;
}

/* ── Card shell ─────────────────────────────────────────────────────────────*/
.ctl-widget {
    display: flex;
    flex-direction: column;
    gap: var(--ctl-sp-2);
    background: var(--ctl-bg-elevated);
    border: 1px solid var(--ctl-border);
    border-radius: var(--ctl-radius-lg);
    padding: var(--ctl-sp-4);
    box-shadow: 0 1px 2px var(--ctl-shadow);
    color: var(--ctl-fg);
    min-width: 0;
}
.ctl-widget--linked { position: relative; }
/* Whole-card drill-down: a stretched native anchor beneath the content. The
   [AI Ask] button (z-index above) stays clickable inside a linked card. */
.ctl-widget-drill {
    position: absolute;
    inset: 0;
    z-index: 0;
    border-radius: inherit;
}
.ctl-widget--linked:hover { border-color: var(--ctl-border-strong); box-shadow: 0 2px 6px var(--ctl-shadow-hover); }
.ctl-widget--linked:focus-within { box-shadow: var(--ctl-focus-ring); }

.ctl-widget-header {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: var(--ctl-sp-2);
    position: relative;
    z-index: 1;
    pointer-events: none; /* text falls through to a linked card's drill overlay */
}
.ctl-widget-microlabel {
    font-size: var(--ctl-fs-caption);
    font-weight: 600;
    letter-spacing: 0.06em;
    text-transform: uppercase;
    color: var(--ctl-fg-subtle);
    line-height: var(--ctl-lh-snug);
}

/* [AI Ask] — small outlined button, above the drill overlay. */
.ctl-widget-ai-ask {
    display: inline-flex;
    align-items: center;
    gap: 4px;
    font-family: var(--ctl-font-body);
    font-size: var(--ctl-fs-caption);
    line-height: 1;
    padding: 3px 8px;
    background: transparent;
    color: var(--ctl-fg-muted);
    border: 1px solid var(--ctl-border-strong);
    border-radius: var(--ctl-radius);
    cursor: pointer;
    position: relative;
    z-index: 2;
    pointer-events: auto; /* re-enable over the header's pointer-events:none */
    transition: color var(--ctl-dur-fast), border-color var(--ctl-dur-fast);
}
.ctl-widget-ai-ask:hover,
.ctl-widget-ai-ask:focus-visible {
    color: var(--ctl-accent-text);
    border-color: var(--ctl-accent);
}

.ctl-widget-empty {
    margin: 0;
    color: var(--ctl-fg-muted);
    font-size: var(--ctl-fs-body-sm);
}

/* ── RAG chip (KPI) / severity chip (alerts) — status washes ────────────────*/
.ctl-widget-rag,
.ctl-widget-sev {
    display: inline-flex;
    align-items: center;
    gap: 5px;
    font-size: var(--ctl-fs-caption);
    font-weight: 600;
    line-height: 1;
    padding: 3px 8px;
    border-radius: var(--ctl-radius-full);
    white-space: nowrap;
}
.ctl-widget-rag-dot,
.ctl-widget-sev-dot {
    width: 6px;
    height: 6px;
    border-radius: 50%;
    background: currentColor;
    flex: 0 0 auto;
}
.ctl-widget-rag--success { background: var(--ctl-success-bg); color: var(--ctl-success-text); }
.ctl-widget-rag--warning { background: var(--ctl-warning-bg); color: var(--ctl-warning-text); }
.ctl-widget-rag--danger  { background: var(--ctl-danger-bg);  color: var(--ctl-danger-text); }
.ctl-widget-rag--info    { background: var(--ctl-info-bg);    color: var(--ctl-info-text); }
.ctl-widget-sev--danger  { background: var(--ctl-danger-bg);  color: var(--ctl-danger-text); }
.ctl-widget-sev--warning { background: var(--ctl-warning-bg); color: var(--ctl-warning-text); }
.ctl-widget-sev--info    { background: var(--ctl-info-bg);    color: var(--ctl-info-text); }

/* ── KPI card ───────────────────────────────────────────────────────────────*/
.ctl-widget-kpi-value {
    display: flex;
    align-items: baseline;
    flex-wrap: wrap;
    gap: var(--ctl-sp-2);
    position: relative;
    z-index: 1;
    pointer-events: none; /* clicks fall through to the drill overlay */
}
.ctl-widget-kpi-number {
    font-family: var(--ctl-font-display);
    font-size: var(--ctl-fs-display-md);
    font-weight: 600;
    line-height: var(--ctl-lh-tight);
    font-variant-numeric: tabular-nums;
    color: var(--ctl-fg);
}
.ctl-widget-kpi-unit {
    font-size: var(--ctl-fs-body-sm);
    font-weight: 500;
    color: var(--ctl-fg-muted);
}
.ctl-widget-kpi-target {
    font-size: var(--ctl-fs-caption);
    color: var(--ctl-fg-subtle);
    position: relative;
    z-index: 1;
    pointer-events: none;
}
.ctl-widget-fill {
    height: 4px;
    border-radius: var(--ctl-radius-full);
    background: var(--ctl-bg-hover);
    overflow: hidden;
    position: relative;
    z-index: 1;
}
.ctl-widget-fill-bar {
    height: 100%;
    background: var(--ctl-accent);
    border-radius: inherit;
}

/* Sparkline — server-built inline SVG, full width, crisp 2px non-scaling line. */
.ctl-widget-spark {
    display: block;
    width: 100%;
    height: 32px;
    margin-top: var(--ctl-sp-1);
    position: relative;
    z-index: 1;
    pointer-events: none;
}
.ctl-widget-spark-line {
    stroke: var(--ctl-accent);
    stroke-width: 2;
    stroke-linejoin: round;
    stroke-linecap: round;
    vector-effect: non-scaling-stroke;
}
.ctl-widget-spark-end { fill: var(--ctl-accent); }

/* ── Pipeline ───────────────────────────────────────────────────────────────*/
.ctl-widget-pipeline-track {
    display: flex;
    align-items: stretch;
    gap: 2px;
}
.ctl-widget-seg-col {
    flex: 1 1 0;
    min-width: 44px;
    display: flex;
    flex-direction: column;
    gap: var(--ctl-sp-1);
    text-decoration: none;
    color: inherit;
}
.ctl-widget-seg {
    display: flex;
    align-items: center;
    justify-content: center;
    min-height: 34px;
    border-radius: var(--ctl-radius);
    padding: var(--ctl-sp-1) var(--ctl-sp-2);
}
.ctl-widget-seg-count {
    font-family: var(--ctl-font-display);
    font-weight: 600;
    font-variant-numeric: tabular-nums;
    line-height: 1;
}
.ctl-widget-seg-label {
    font-size: var(--ctl-fs-caption);
    color: var(--ctl-fg-muted);
    text-align: center;
    line-height: var(--ctl-lh-snug);
}
a.ctl-widget-seg-col:hover .ctl-widget-seg,
a.ctl-widget-seg-col:focus-visible .ctl-widget-seg { filter: brightness(0.94); }
a.ctl-widget-seg-col:focus-visible { outline: none; box-shadow: var(--ctl-focus-ring); border-radius: var(--ctl-radius); }
.ctl-widget-seg--1 { background: var(--ctl-seq-1); color: var(--ctl-seq-1-ink); }
.ctl-widget-seg--2 { background: var(--ctl-seq-2); color: var(--ctl-seq-2-ink); }
.ctl-widget-seg--3 { background: var(--ctl-seq-3); color: var(--ctl-seq-3-ink); }
.ctl-widget-seg--4 { background: var(--ctl-seq-4); color: var(--ctl-seq-4-ink); }

/* ── Alerts + Queues shared rows ────────────────────────────────────────────*/
.ctl-widget-alert-list,
.ctl-widget-queue-list {
    list-style: none;
    margin: 0;
    padding: 0;
    display: flex;
    flex-direction: column;
}
.ctl-widget-alert-row,
.ctl-widget-queue-row {
    display: flex;
    align-items: center;
    gap: var(--ctl-sp-2);
    padding: var(--ctl-sp-2) 0;
    border-top: 1px solid var(--ctl-border);
    text-decoration: none;
    color: var(--ctl-fg);
    font-size: var(--ctl-fs-body-sm);
}
.ctl-widget-alert-list > li:first-child .ctl-widget-alert-row,
.ctl-widget-queue-list > li:first-child .ctl-widget-queue-row { border-top: 0; }
a.ctl-widget-alert-row:hover,
a.ctl-widget-queue-row:hover { background: var(--ctl-bg-hover); }
a.ctl-widget-alert-row:focus-visible,
a.ctl-widget-queue-row:focus-visible { outline: none; box-shadow: var(--ctl-focus-ring); }
.ctl-widget-alert-text {
    flex: 1 1 auto;
    min-width: 0;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}
.ctl-widget-alert-time {
    color: var(--ctl-fg-subtle);
    font-size: var(--ctl-fs-caption);
    white-space: nowrap;
    flex: 0 0 auto;
}

/* ── Queues ─────────────────────────────────────────────────────────────────*/
.ctl-widget-queue-name { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ctl-widget-queue-sla { color: var(--ctl-fg-subtle); font-size: var(--ctl-fs-caption); white-space: nowrap; flex: 0 0 auto; }
.ctl-widget-queue-sla--bad { color: var(--ctl-danger-text); font-weight: 600; }
.ctl-widget-queue-count {
    font-family: var(--ctl-font-display);
    font-weight: 600;
    font-variant-numeric: tabular-nums;
    color: var(--ctl-fg);
    flex: 0 0 auto;
    min-width: 2ch;
    text-align: right;
}

/* ── Density: compact tightens padding + numeral scale (§5) ─────────────────*/
html[data-density="compact"] .ctl-widget { padding: var(--ctl-sp-3); gap: var(--ctl-sp-1); }
html[data-density="compact"] .ctl-widget-kpi-number { font-size: var(--ctl-fs-display-sm); }
html[data-density="compact"] .ctl-widget-seg { min-height: 28px; }

/* ═══════════════════════════════════════════════════════════════════════════
   DASHBOARD — the role-aware home (templates/dashboard.php)

   Everything in this block is scoped to .dashboard-home, the page's own clamp
   wrapper. Nothing here escapes to another screen.

   ── 1. ROUNDED CARDS ────────────────────────────────────────────────────────
   Two of the three presets are sharp by identity (Industrial and Operator both
   set --ctl-radius / --ctl-radius-lg to 0), so the home rendered as a stack of
   hard 90° boxes with ONE rounded object in the middle of it: the Shortcuts
   tile, which takes --ctl-radius-tile as a deliberate documented exception
   (see the :root note — "shortcuts are an ambient/hero affordance on the
   dashboard, not a data surface"). That exception was right and the dashboard
   simply had not been carried the rest of the way: the briefing panel, every
   role widget and the empty state are the same class of ambient surface, and
   next to a 10px launcher tile their square corners read as an oversight.

   Expressed as a TOKEN REMAP on this one element — the .portal-body precedent
   (PR #1465) — so every surface that already derives its radius from a token
   follows automatically and no component hardcodes a literal. The value is
   --ctl-radius-tile itself, not a new number: "similar to the shortcuts cards"
   means the same object, so it is the same token in all six sheets.

   Deliberately NOT remapped: --ctl-radius (controls, chips, alerts, inputs) and
   --ctl-radius-full. A rounded CARD is the ask; rounding the announcement
   banner and every button would make the home's controls disagree with the rest
   of the app, which is the opposite of consistent.

   ── 2. THE NARROW LANE ──────────────────────────────────────────────────────
   --ctl-shortcuts-max lives here rather than on .ctl-shortcuts because it is no
   longer the Shortcuts section's private number: the WHOLE PAGE shares it. The
   arithmetic it solves is unchanged and still pinned by
   ShortcutCardWidthContractTest — 836px = 4 x 200px card + 3 x 12px gap.

   The page used to run the WIDE preset (100rem) with only the briefing pulled
   into the launcher lane, which left the home with two competing left edges:
   an 836px ambient band at the top and 1600px of widget grid under it. It now
   runs ONE lane. Mechanically that is a token re-point, not an override: the
   page carries .ctl-page-clamp (its §8.1 archetype) and this rule re-declares
   that class's own --ctl-content-max on the same element, so there is no
   competing max-width anywhere and no !important. width:100% comes along for
   the reason the wide preset documents — .content-wrapper is a flex column, and
   without it margin-inline:auto shrink-wraps the clamp to its content.

   ── 3. THE TOP OFFSET ───────────────────────────────────────────────────────
   The home opens on a title and a short ambient band, not on a data surface, so
   it reads better set down the page than jammed under the navbar the way a list
   is. Asked for as "an invisible element to bring the content down"; it is
   padding instead, because a spacer <div> is a second thing to keep in sync with
   the shell's real geometry and screen readers would still walk it.

   HOW FAR DOWN — the developer's call, revised. PR #1571 first set this at a
   QUARTER of the viewport, which on a 1080p screen opened ~200px of blank above
   the greeting: the page read as empty on load rather than composed, and the
   first widget row sat at the fold. The offset is now a TENTH of the viewport —
   enough air that the home is visibly not a list screen, not so much that the
   content looks lost. Only the two numbers in the clamp changed; the mechanism
   and the arithmetic below are the ones #1571 reasoned out.

   PADDING ON THE SCROLLED CONTENT, not an offset on the container. .dashboard-
   home is a direct child of .content-wrapper and .content-wrapper is this page's
   scroll box (no .detail-page-body here, see dashboard.php), so the gap sits
   INSIDE the scroller: the first wheel notch pulls the page up over it and the
   offset costs nothing below the fold. A margin/translate on the container, or
   a sticky spacer, would have held the gap on screen forever.

   THE ARITHMETIC. 10vh is measured from the top of the VIEWPORT, but this
   padding starts below two pieces of shell chrome, so both come off it:
     • .top-navbar  = 2rem tool row (.detail-tool / .global-search-field are
                      both 2rem) + 0.4rem x 2 padding + 1px border = 45.8px.
     • .content-wrapper's own padding-top = --ctl-pane-pad, which is why it is
       subtracted as the TOKEN rather than as 24px: compact density (12px) and
       the phone breakpoint (0.75rem) re-point it and the offset follows for
       free.
   So: extra padding = 10vh - 45.8px - --ctl-pane-pad, and the page's first band
   sits on the 10% line. On a 1080p viewport that is 38px of added padding (was
   200px at 25vh).

   THE CLAMP is what makes that safe at both ends.
     • Floor 0px — the chrome above already eats 69.8px, so below ~698px of
       viewport height the term goes negative and the page renders exactly as it
       did before #1571. Landscape phones, short laptop windows and split-screen
       never get their content pushed toward the fold; they simply get no offset,
       which is the right answer when 10% of the viewport is already spent on the
       navbar.
     • Ceiling 4.5rem (72px) — true 10% holds through a ~1418px content
       viewport, i.e. every laptop and a 1440p desktop, which is the range this
       was tuned in. Past that the gap stops growing rather than tracking a 4K
       screen's 10%, on the same reasoning as the original: beyond a point the
       gap is dead space, not composition. Both bounds scale with the 25% → 10%
       change; they are not independent knobs.
   ═══════════════════════════════════════════════════════════════════════════ */
.dashboard-home {
    /* Containers — widgets (.ctl-widget), the briefing panel and the empty
       state — take the launcher tile's radius. */
    --ctl-radius-lg: var(--ctl-radius-tile);
    /* Bootstrap's bridge is declared on :root, so --bs-border-radius-lg was
       already substituted to the sharp value at the html element and inherits
       as that computed number. Redefining the ctl token further down the tree
       does not re-run that substitution, so the bridge is restated here. */
    --bs-border-radius-lg: var(--ctl-radius-lg);
    --bs-border-radius-xl: var(--ctl-radius-lg);
    /* Card chrome follows the system's own rule (see :root): the crisp
       border-strong hairline and the extra shadow step exist to buy back the
       separation a SQUARE card loses. A rounded card takes the soft pair — the
       exact chrome .ctl-shortcut-link and .ctl-widget already carry, so all
       three now read as one material. */
    --ctl-card-border: var(--ctl-border);
    --ctl-card-shadow: 0 1px 2px var(--ctl-shadow);
    /* The lane the whole home shares — briefing, Shortcuts, widget grids, and
       (via the clamp token below) the page heading itself. */
    --ctl-shortcuts-max: 52.25rem;
    /* .ctl-page-clamp reads max-width from this token, so re-pointing it here
       narrows the PAGE to the launcher lane. One number, one place. */
    --ctl-content-max: var(--ctl-shortcuts-max);
    /* Shell chrome standing between the viewport top and this element's box,
       held as one named number so the clamp below reads as the sentence it is
       (see §3 above for the derivation). */
    --ctl-dash-shell-top: calc(2rem + 0.8rem + 1px); /* .top-navbar = 45.8px */
    padding-top: clamp(0px, 10vh - var(--ctl-dash-shell-top) - var(--ctl-pane-pad), 4.5rem);
    width: 100%;
}
/* The home's cards follow the launcher tile's 10px rather than the app-wide
   card token's 12px, and this is the one of the four scoped overrides that
   still does real work after the shared .card rule started rounding everything:
   these cards sit directly beside the Shortcut tiles and have to read as the
   same material as them, not as the app's generic card.
   --bs-card-border-radius rides along so Bootstrap's inner-corner maths
   (.card-header:first-child, .card-footer:last-child) follows — which it only
   does because the shared .card rule re-points --bs-card-inner-border-radius at
   this token. Stock Bootstrap derives it from the global --bs-border-radius,
   which is NOT restated here (only the -lg / -xl bridges are, above), so it sits
   at the sharp preset's 0: a header would render square inside a 10px card and
   its background would erase the corner arc. The home's two cards are both
   card > card-body today, so that was latent rather than visible — but it is the
   same mistake the portal shipped visibly, and it is closed by the same rule. */
.dashboard-home .card {
    --bs-card-border-radius: var(--ctl-radius-lg);
    border-radius: var(--ctl-radius-lg);
}

/* ── Module groups ──────────────────────────────────────────────────────────
   Widgets are grouped under the module they belong to (Service Desk, Quality,
   Manufacturing …), named by the module's OWN sys_page group title — the same
   string the sidebar section carries, so the home and the nav never drift into
   two vocabularies for one module. The heading takes the Shortcuts label
   treatment verbatim (see .ctl-shortcuts-title below, one rule, two selectors);
   an ungrouped run renders with no heading at all rather than under an invented
   one. */
/* .dashboard-module is now every BAND on the home — the briefing and the
   Shortcuts launcher carry it too, because in edit mode they are movable and
   hideable on exactly the same terms as a widget group and there is no second
   set of rules for them. This one declaration is therefore the whole of the
   home's vertical rhythm: the sections used to space themselves with their own
   .mb-4 utilities, which is untenable once the user can put them in any order
   (whichever section landed last kept a trailing gap nobody asked for).

   `~` and not `+`: the launcher section is followed by its own <script> tag, so
   the band after it is not its ADJACENT sibling and would silently lose its
   gap. The general combinator states what is actually meant — every band except
   the first takes a top margin — and stays true however the user reorders them.
   A hidden section is display:none and contributes no box, so it cannot leave a
   double gap behind it either.

   .dashboard-empty rides the SAME declaration because the empty state is a band
   like any other — it just is not an arrangeable one (no grip, no eye, no
   data-module-key; dashboard-edit.js selects on .dashboard-module and would
   otherwise pick it up as a section it cannot save). It was the one block on the
   home outside this rule and so the one block with no gap above it: a user with
   a visit history but no role widgets got the launcher and the "no widgets"
   card fused into a single slab. Adding the selector here rather than a
   margin utility on the card keeps the home's rhythm at ONE number in ONE
   place, which is the whole point of the rule. */
.dashboard-module ~ .dashboard-module,
.dashboard-module ~ .dashboard-empty { margin-top: var(--ctl-sp-5); }

/* THE GREETING SITS ABOVE THAT STACK, and the sibling rule above cannot reach
   it. `~` only ever fires BETWEEN bands: the first band on the page has no
   .dashboard-module before it, so its gap is not the rhythm's number at all —
   it is whatever the page header leaves behind. That was
   .ctl-page-header--list's --ctl-sp-3 (12px), a value the kit had chosen for a
   header "sitting directly above a table", where a tight bind between the title
   and the grid it labels is the point. The home is not a table. It is a stack of
   ambient bands, and at half the gap every other seam on the page carried, the
   greeting read as glued to the briefing while the bands below it breathed.
   (#1880 has since flipped that default — list mode now closes on --ctl-sp-5 and
   the table pages opt down with --table — so this rule no longer rescues the
   home from a tight kit value. It still earns its place: it buys the ONE STEP
   MORE below.)

   ONE STEP ABOVE THE BAND GAP, and that is the whole point of this line.
   #1881 first set it to --ctl-sp-5, the rhythm's own token, on the reasoning
   that the header simply spends on its leading edge what a band spends on its
   trailing one. Live, that was better and still not right: at exactly the
   inter-band number the greeting read as the first band's own heading rather
   than as the page's identity line, because a seam that measures the same as
   every other seam IS one. The developer's call is that the title breathes MORE
   than the rhythm — so the gap steps up the 8pt ramp to --ctl-sp-6 (32px),
   1.33x the band gap. A page title is not a band head; the extra step is what
   says so, and it is a scale step rather than a nudge, so the ramp is still the
   only vocabulary on this page.

   NOT --ctl-sp-7 (48px). It is the ramp's next rung and it is double the band
   gap — but the home already opens with up to 4.5rem of clamped top offset ABOVE
   the greeting (see §3), and 48px below it would suspend the title in the middle
   of two large voids instead of heading the page. 32px is the largest step that
   still reads as "this title owns the stack under it". If the developer wants
   more air again, sp-7 is the one remaining honest rung and this note is why it
   was passed over the first time.

   Scoped to .dashboard-home because it is a property of THIS page's shape;
   every other screen keeps the kit's list spacing. The scope also out-specifies
   the --list modifier (0,2,0 vs 0,1,0), so no !important. */
.dashboard-home .ctl-page-header { margin-bottom: var(--ctl-sp-6); }

/* The module head IS the Shortcuts head — one rule, two names, the same call
   the shared label rule below makes for their titles. A flex row that owns the
   12px down to its grid, so the heading itself stays at margin:0 as the shared
   label rule leaves it, and every section's grip and eye sit on one baseline
   whichever band they belong to. The head exists even when the run is headless
   — it is what carries the group's drag grip in edit mode. */
.ctl-shortcuts-head,
.dashboard-module-head {
    display: flex;
    align-items: center;
    gap: var(--ctl-sp-2);
    margin-bottom: var(--ctl-sp-3);
    min-height: 1.5rem;
}

/* ── The widget grid ────────────────────────────────────────────────────────
   FOUR COLUMNS ON THE SHORTCUTS RHYTHM. Same column count and the same 12px
   gutter as .ctl-shortcuts-grid, inside the same lane — so a stat card is
   exactly one launcher tile wide and the two grids line up column for column
   down the page. That alignment is the whole reason this is a CSS grid and no
   longer a Bootstrap .row: .row's gutters are negative margins on a 12-column
   system, which can be made to LOOK like four across but never to agree with
   the launcher above it.

   Width is a property of the widget's SHAPE, set once per shape here and never
   per widget (DashboardService::SHAPES is the classifier):
     .is-stat   1 column  — one headline number; a figure is not clearer wider.
     .is-group  2 columns — a coloured per-status breakdown of one figure; it
                            needs its segments side by side and is still one
                            metric, so half the lane.
     .is-list   4 columns — rows of records; a row is label + trailing number
                            and starts truncating the moment it is narrowed.
   The tiers below step 4 -> 3 -> 2 -> 1 exactly where the launcher does, and a
   span is re-stated at each so nothing can ask for more columns than exist. */
.dashboard-widget-grid {
    display: grid;
    grid-template-columns: repeat(4, minmax(0, 1fr));
    gap: var(--ctl-sp-3);
    align-items: start;
}
.dashboard-widget-cell {
    position: relative;   /* the grip + eye hang off this box in edit mode */
    min-width: 0;
    display: flex;        /* so a card stretches to the row's height */
}
.dashboard-widget-cell > .ctl-widget { flex: 1 1 auto; min-width: 0; }
.dashboard-widget-cell.is-stat  { grid-column: span 1; }
.dashboard-widget-cell.is-group { grid-column: span 2; }
.dashboard-widget-cell.is-list  { grid-column: span 4; }

@media (min-width: 769px) and (max-width: 991px) {
    .dashboard-widget-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
    .dashboard-widget-cell.is-list { grid-column: span 3; }
}
@media (min-width: 481px) and (max-width: 768px) {
    .dashboard-widget-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
    .dashboard-widget-cell.is-list { grid-column: span 2; }
}
@media (max-width: 480px) {
    .dashboard-widget-grid { grid-template-columns: minmax(0, 1fr); }
    .dashboard-widget-cell.is-group,
    .dashboard-widget-cell.is-list { grid-column: span 1; }
}

/* ═══════════════════════════════════════════════════════════════════════════
   EDIT MODE — rearranging the home like a phone home screen
   (templates/dashboard.php + public/js/dashboard-edit.js)

   One class on the page scope, `.is-editing`, drives every rule below. The
   controls (module grip, widget grip, visibility eye) are ALWAYS in the DOM so
   no markup changes shape when the mode opens — outside edit mode they are
   display:none, aria-hidden and tabindex=-1, which is the only state in which a
   control is genuinely absent for pointer, keyboard and screen reader alike.

   HIDDEN CARDS. A card this user hid is rendered server-side and dropped by CSS,
   not omitted from the HTML: the eye has to be able to bring it back without a
   page round-trip, and a card that only exists after a reload cannot be part of
   a live arrangement. It costs the widget's queries either way (they resolve
   before the layout overlay is applied), which is the accepted price of the
   toggle being instant.
   ═══════════════════════════════════════════════════════════════════════════ */

/* The pencil. Grey and quiet — it is a mode switch on a page the user did not
   come here to configure — and it lights up while the mode is on.

   RADIUS: the +3px derivation .ctl-page-header-back documents, and for the same
   reason — --ctl-radius is 0px in the sharp presets (Industrial/Operator) and
   3px in Refined, so a small icon button pinned to the raw token draws a hard
   square in four of the six sheets. Deriving keeps every edit control visibly
   rounded while still tracking the style's own corner language, and reuses the
   app's existing answer for this exact object rather than inventing a second
   one. It is deliberately NOT --ctl-radius-tile: these are controls, and the
   dashboard scope's tile remap is for card-sized surfaces (see §1 above). */
.dashboard-edit-toggle {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 32px;   /* WCAG 2.2 SC 2.5.8 */
    height: 32px;
    padding: 0;
    border: 1px solid transparent;
    border-radius: calc(var(--ctl-radius) + 3px);
    background: transparent;
    color: var(--ctl-fg-subtle);
    font-size: var(--ctl-fs-body-lg);
    line-height: 1;
    transition: color var(--ctl-dur-fast) ease-out,
                background-color var(--ctl-dur-fast) ease-out,
                border-color var(--ctl-dur-fast) ease-out;
}
.dashboard-edit-toggle:hover {
    background: var(--ctl-bg-hover);
    color: var(--ctl-fg);
}
.dashboard-edit-toggle:focus-visible {
    outline: none;
    box-shadow: var(--ctl-focus-ring);
    border-color: var(--ctl-accent);
}
.dashboard-edit-toggle[aria-pressed="true"] {
    color: var(--ctl-accent-text);
    border-color: var(--ctl-accent);
    background: var(--ctl-bg-hover);
}

/* Grips + eyes: absent until the mode opens. */
.dashboard-module-grip,
.dashboard-widget-grip,
.dashboard-widget-eye,
.dashboard-module-eye,
.dashboard-module-title--placeholder { display: none; }

.dashboard-home.is-editing .dashboard-module-grip { display: inline-flex; }
.dashboard-home.is-editing .dashboard-widget-grip,
.dashboard-home.is-editing .dashboard-widget-eye,
.dashboard-home.is-editing .dashboard-module-eye { display: inline-flex; }
.dashboard-home.is-editing .dashboard-module-title--placeholder { display: inline; }

/* One geometry for every edit control, whatever it acts on. The corner is the
   derived +3px (see the pencil above) so the boxes stay rounded in the sharp
   presets, and the outline is --ctl-border-subtle rather than --ctl-border:
   these boxes are scaffolding that appears for the length of one gesture, and
   at 24px a full-strength hairline read heavier than the card it was sitting
   on. The subtle token thins toward its own surface, so this is one value that
   stays right in all six sheets and both schemes. */
.dashboard-module-grip,
.dashboard-module-eye,
.dashboard-widget-grip,
.dashboard-widget-eye {
    align-items: center;
    justify-content: center;
    width: 24px;   /* WCAG 2.2 SC 2.5.8 minimum target */
    height: 24px;
    padding: 0;
    border: 1px solid var(--ctl-border-subtle);
    border-radius: calc(var(--ctl-radius) + 3px);
    background: var(--ctl-bg-elevated);
    color: var(--ctl-fg-subtle);
    font-size: var(--ctl-fs-body-sm);
    line-height: 1;
}
/* A section's own controls sit in its HEAD, on the page ground rather than on a
   card, so they carry no box at all — the same call #1560 made for the module
   grip, now made once for both of that head's controls. The eye takes the far
   end of the head: the card-level eye is the last thing on the card's top edge,
   so the section-level one is the last thing on the section's. */
.dashboard-module-grip,
.dashboard-module-eye { border-color: transparent; background: transparent; }
.dashboard-module-eye { margin-left: auto; }
.dashboard-module-grip { cursor: grab; }
.dashboard-module-grip:active { cursor: grabbing; }
.dashboard-module-grip:hover,
.dashboard-module-eye:hover,
.dashboard-widget-grip:hover,
.dashboard-widget-eye:hover { background: var(--ctl-bg-hover); color: var(--ctl-fg); }
.dashboard-module-grip:focus-visible,
.dashboard-module-eye:focus-visible,
.dashboard-widget-grip:focus-visible,
.dashboard-widget-eye:focus-visible {
    outline: none;
    box-shadow: var(--ctl-focus-ring);
    border-color: var(--ctl-accent);
}

/* The card's own controls float over its top-right corner — the launcher pin's
   overhang trick, kept INSIDE the card here because a widget card carries
   content right to its edge and a bubble hanging off it would clip the
   neighbour. z-index clears the KPI card's stretched drill-down anchor. */
.dashboard-widget-grip,
.dashboard-widget-eye {
    position: absolute;
    top: var(--ctl-sp-2);
    z-index: 2;
}
.dashboard-widget-eye  { right: var(--ctl-sp-2); }
.dashboard-widget-grip { right: calc(var(--ctl-sp-2) + 28px); }

/* In edit mode the cards stop being links. Pointer events are dropped on the
   widget itself (its drill-down overlay, its [AI Ask] and its row links would
   otherwise navigate away mid-arrangement) and the CELL becomes the drag
   source; the two controls opt back in. */
.dashboard-home.is-editing .dashboard-widget-cell { cursor: grab; user-select: none; }
.dashboard-home.is-editing .dashboard-widget-cell > .ctl-widget { pointer-events: none; }
/* The same call for the two sections that are not made of cards. The briefing's
   nudges are links and the launcher's tiles are links that ALSO run their own
   pointer drag (pinned tiles reorder among themselves); while the home is being
   arranged the unit is the section, so both bodies go inert and the launcher's
   tile drag simply cannot start. That is the whole of the "section grip vs tile
   drag" problem — it is removed rather than disambiguated. Outside edit mode
   both behave exactly as they did before. */
.dashboard-home.is-editing .dashboard-briefing .card-body,
.dashboard-home.is-editing [data-shortcuts-grid] { pointer-events: none; }
.dashboard-home.is-editing .dashboard-widget-grip,
.dashboard-home.is-editing .dashboard-widget-eye,
.dashboard-home.is-editing .dashboard-module-grip,
.dashboard-home.is-editing .dashboard-module-eye { pointer-events: auto; }
.dashboard-home.is-editing .dashboard-widget-cell .ctl-widget { border-style: dashed; }

/* Hidden: gone in normal mode, ghosted in edit mode so the user can see what
   they put away and bring it back. */
.dashboard-widget-cell.is-hidden { display: none; }
.dashboard-home.is-editing .dashboard-widget-cell.is-hidden { display: flex; opacity: 0.4; }

/* A hidden SECTION is the same idea one level up, and it is expressed by
   WITHHOLDING display:none in edit mode rather than by restoring a display
   value. The three sections do not share one — a module group is a block, the
   briefing is a Bootstrap .card (display:flex) — so a rule that turned them
   back on would have to know which is which, and would break the briefing the
   day its wrapper changed. Scoping the drop to :not(.is-editing) means nothing
   ever has to be turned back on. */
.dashboard-home:not(.is-editing) .dashboard-module.is-hidden { display: none; }
.dashboard-home.is-editing .dashboard-module.is-hidden { opacity: 0.4; }

/* A module whose every card is hidden has nothing to head. :has() keeps that
   answer live as the eye is toggled, instead of duplicating the state in a
   server-rendered flag the script would then have to maintain. Guarded on
   having a widget grid at all: the briefing and the launcher are sections with
   no cards in them, and without the guard this rule would read "no visible
   cards" and drop both from every ordinary page load. */
.dashboard-home:not(.is-editing) .dashboard-module:has(.dashboard-widget-grid):not(:has(.dashboard-widget-cell:not(.is-hidden))) {
    display: none;
}

/* Drag source: dimmed in place so the slot it will leave stays legible. Edit
   mode is an arrangement mode — the whole board stays readable while a band is
   in the air — which is why this one keeps the dim where the Shortcuts grid
   went to an empty slot. */
.dashboard-widget-cell.is-dragging,
.dashboard-module.is-dragging { opacity: 0.5; }
.dashboard-home.is-editing .dashboard-module { outline: 1px dashed var(--ctl-border); outline-offset: var(--ctl-sp-2); }

/* ── Morning briefing ───────────────────────────────────────────────────────
   Clamped to the launcher lane and centred, matching Shortcuts directly below
   it. The briefing is a short list of one-line nudges: on the WIDE page clamp
   its text ran the full 100rem, so an eight-word nudge sat alone on a 1600px
   line and the two ambient sections at the top of the home had no shared edge
   to read down. */
.dashboard-briefing {
    max-width: var(--ctl-shortcuts-max);
    margin-inline: auto;
}

/* ═══════════════════════════════════════════════════════════════════════════
   SHORTCUTS — the dashboard launcher grid (templates/partials/dashboard_shortcuts.php)

   A BARE section by design: heading + cards straight on --ctl-bg, no panel, no
   border, no shadow around the group. The cards are the only surfaces; boxing
   them would make eight destinations read as one widget.

   CENTRED, ON ITS OWN CLAMP. The dashboard page clamp is the WIDE preset
   (100rem) — right for a dense widget grid, badly wrong for a launcher, because
   cards stretched across 1600px stop reading as buttons and start reading as
   billboards. The section therefore carries a much tighter clamp of its own and
   centres itself in the page. The heading is INSIDE that clamp, so the title
   stays flush with the first card instead of drifting to the far-left margin.

   That clamp is also what sets the card WIDTH — the cards are 1fr columns with
   no padding of their own, so the only number that decides how wide a launcher
   tile gets is this one, and the arithmetic is exact:

       card = (--ctl-shortcuts-max - 3 x gap) / 4      (gap = --ctl-sp-3 = 12px)

   The first cut of the section ran ~404px per card (billboards); the second cut
   set this to 38rem and landed at 143px, which was too tight — over half of the
   real sys_page titles ellipsised (#1438). 52.25rem = 836px is the value that
   solves for exactly 200px, i.e. HALF the original width: wide enough that the
   p90 title (17 characters) fits with room to spare, narrow enough that eight
   tiles still read as a launcher rather than a wall.

   Grid is 4 across, so the default eight cards land as 4 x 2, then steps down
   4 -> 3 -> 2 -> 1 at the named tiers (§8).
   ═══════════════════════════════════════════════════════════════════════════ */
.ctl-shortcuts {
    /* The one number that governs the section — declared on .dashboard-home
       above, because the briefing panel now shares the same lane. Heading and
       grid are both children of this element, so they share a left edge and
       narrow together. 836px = 4 x 200px card + 3 x 12px gap. */
    max-width: var(--ctl-shortcuts-max);
    margin-inline: auto;
}
/* .ctl-shortcuts-head is declared with .dashboard-module-head in the DASHBOARD
   block above — the two were documented as twins and are now literally one
   rule, so the launcher's heading row and a module group's cannot drift and the
   edit controls land in the same place in both. */
/* The dashboard's section label. Shortcuts named it; the widget module groups
   above take the SAME rule rather than a copy of its five values, so the two
   headings on the home can never drift apart. */
.ctl-shortcuts-title,
.dashboard-module-title {
    font-family: var(--ctl-font-display);
    font-size: var(--ctl-fs-body-lg);
    font-weight: 600;
    letter-spacing: 0.06em;
    text-transform: uppercase;
    line-height: var(--ctl-lh-snug);
    color: var(--ctl-fg-muted);
    margin: 0;
}

.ctl-shortcuts-grid {
    list-style: none;
    margin: 0;
    padding: 0;
    display: grid;
    grid-template-columns: repeat(4, minmax(0, 1fr));
    /* Also the bubble's clearance: the pin overhangs its card by 7px on two
       sides, and this gap is what keeps it off the neighbouring card. */
    gap: var(--ctl-sp-3);
}
@media (min-width: 769px) and (max-width: 991px) {
    .ctl-shortcuts-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
}
@media (min-width: 481px) and (max-width: 768px) {
    .ctl-shortcuts-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (max-width: 480px) {
    .ctl-shortcuts-grid { grid-template-columns: minmax(0, 1fr); }
}

/* ── The card ───────────────────────────────────────────────────────────────
   A tile: icon block, then the screen's nav title. The <a> is a SIBLING of
   the pin, not its parent — a button inside a link is invalid and eats its own
   clicks — so the link fills the card and the pin floats above it.

   NARROWED, NOT SHRUNK. The first cut ran ~404px wide per card, which read as a
   billboard; these are HALF of that (200px — see the clamp above). The height,
   the 12px vertical padding and the 40px icon are all unchanged — the tile kept
   its vertical proportions and gave up only the empty horizontal runway it never
   needed. Retiring the pin's reserved lane (it is a corner bubble now) is what
   paid for the label. */
.ctl-shortcut {
    position: relative;
    display: flex;
    min-width: 0;
}
.ctl-shortcut-link {
    flex: 1 1 auto;
    min-width: 0;
    display: flex;
    align-items: center;
    gap: var(--ctl-sp-2);
    /* Vertical padding is the original 12px (the height did not change). The
       horizontal pair stays at the tightened 8px even now that the card is back
       out to 200px: the 16px it saves is 16px of label lane, and at this tile
       size the icon block already supplies the optical left inset. */
    padding: var(--ctl-sp-3) var(--ctl-sp-2);
    min-height: 64px;
    /* The whole surface is a drag handle for a pinned card, so a drag must not
       start a text selection or the browser's built-in link drag. */
    user-select: none;
    -webkit-user-drag: none;
    background: var(--ctl-bg-elevated);
    border: 1px solid var(--ctl-border);
    border-radius: var(--ctl-radius-tile);
    box-shadow: 0 1px 2px var(--ctl-shadow);
    color: var(--ctl-fg);
    text-decoration: none;
    transition: border-color var(--ctl-dur-fast) ease-out,
                box-shadow var(--ctl-dur-fast) ease-out,
                background-color var(--ctl-dur-fast) ease-out;
}
.ctl-shortcut-link:hover {
    background: var(--ctl-bg-hover);
    border-color: var(--ctl-border-strong);
    box-shadow: 0 2px 6px var(--ctl-shadow-hover);
    color: var(--ctl-fg);
}
.ctl-shortcut-link:focus-visible {
    outline: none;
    box-shadow: var(--ctl-focus-ring);
    border-color: var(--ctl-accent);
}
.ctl-shortcut-icon {
    flex: 0 0 auto;
    width: 40px;
    height: 40px;
    display: flex;
    align-items: center;
    justify-content: center;
    border-radius: var(--ctl-radius-tile);
    background: var(--ctl-bg-hover);
    color: var(--ctl-accent-text);
    font-size: 1.25rem;
    line-height: 1;
}
/* The icon tile's own fill must stay distinct from the card once the card is
   hovered, or the glyph appears to float on nothing. */
.ctl-shortcut-link:hover .ctl-shortcut-icon { background: var(--ctl-bg-elevated); }
.ctl-shortcut-label {
    min-width: 0;
    flex: 1 1 auto;
    font-size: var(--ctl-fs-body);
    font-weight: 500;
    line-height: var(--ctl-lh-snug);
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

/* ── The pin bubble ─────────────────────────────────────────────────────────
   A circle overhanging the card's TOP-RIGHT corner — the same overhang trick
   the navbar's unread badge plays on the avatar (.notification-count-badge):
   sit the control outside its parent's box and ring it in the surrounding
   background, so it reads as a separate object resting on the card rather than
   a hole punched in it. Off the card entirely, the pin costs the tile no width
   at all — the reserved lane it used to sit in is part of what the label got.

   Revealed on hover and on focus-within (a keyboard user tabbing in has to be
   able to see what they reached), and permanently once pinned — the lit bubble
   IS the pinned marker. Hidden means non-interactive too, so an invisible
   bubble can never swallow a click aimed at the card's corner. */
.ctl-shortcut-pin {
    position: absolute;
    top: -7px;
    right: -7px;
    z-index: 1;
    display: flex;
    align-items: center;
    justify-content: center;
    width: 24px;   /* WCAG 2.2 SC 2.5.8 minimum target */
    height: 24px;
    padding: 0;
    border: 1px solid var(--ctl-border);
    border-radius: 50%;
    background: var(--ctl-bg-elevated);
    box-shadow: 0 0 0 2px var(--ctl-bg);
    color: var(--ctl-fg-subtle);
    font-size: var(--ctl-fs-body-sm);
    line-height: 1;
    opacity: 0;
    pointer-events: none;
    transition: opacity var(--ctl-dur-fast) ease-out,
                color var(--ctl-dur-fast) ease-out,
                background-color var(--ctl-dur-fast) ease-out;
}
.ctl-shortcut:hover .ctl-shortcut-pin,
.ctl-shortcut:focus-within .ctl-shortcut-pin,
.ctl-shortcut.is-pinned .ctl-shortcut-pin { opacity: 1; pointer-events: auto; }
.ctl-shortcut.is-pinned .ctl-shortcut-pin {
    color: var(--ctl-accent-text);
    border-color: var(--ctl-accent);
}
.ctl-shortcut-pin:hover { background: var(--ctl-bg-hover); color: var(--ctl-fg); }
.ctl-shortcut-pin:focus-visible {
    outline: none;
    box-shadow: var(--ctl-focus-ring);
    opacity: 1;
    pointer-events: auto;
}

/* ── Reorder ────────────────────────────────────────────────────────────────
   There is no grip: a pinned card is dragged by its whole surface. The cursor
   only turns into a hand once a drag is actually running, so the card keeps
   looking like the link it also is. */
.ctl-shortcuts-grid.is-reordering .ctl-shortcut.is-pinned .ctl-shortcut-link { cursor: grabbing; }

/* Dragged card: the slot goes EMPTY.
   The browser is already carrying a full-fidelity floating copy of the tile
   under the cursor, so a dimmed twin left behind in the list puts the same card
   on screen twice and the gesture reads as a copy rather than a move. What the
   user should see is the card in their hand and the hole it will drop into.

   `visibility: hidden`, deliberately, not `display: none` or `opacity: 0`:
   - it KEEPS the grid cell, and that cell IS the placeholder — the neighbours
     still FLIP around it, so the gap tracks the landing slot as the card moves;
   - it hides the whole list item, pin bubble included (visibility inherits),
     where the old rule reached only the link and left the lit pin floating over
     an empty square;
   - a hidden element is not a hit-test target, which the dragover handler was
     already written for — it has always ignored the drag source.
   No colour, so it is identical under all six token sheets, light and dark.

   Applied one tick after dragstart, never inside it: the drag image is
   snapshotted from this element after the handler returns. See the partial. */
.ctl-shortcut.is-dragging { visibility: hidden; }

/* FLIP play phase: the shared `.is-flipping` rule (see the FLIP block above)
   now owns the easing for this grid too. The launcher's private `.is-sliding`
   twin went away with the private copy of the animation itself (#1551). */

/* Touch: hover never fires, so the pin is always on, and it grows past the AA
   minimum because a finger has no corner precision (§9). */
@media (pointer: coarse) {
    .ctl-shortcut-pin {
        opacity: 1;
        pointer-events: auto;
        width: 30px;
        height: 30px;
        top: -10px;
        right: -10px;
    }
}

html[data-density="compact"] .ctl-shortcut-link { min-height: 52px; padding-top: var(--ctl-sp-2); padding-bottom: var(--ctl-sp-2); }
html[data-density="compact"] .ctl-shortcut-icon { width: 32px; height: 32px; font-size: 1rem; }

@media (prefers-reduced-motion: reduce) {
    .ctl-shortcut-link,
    .ctl-shortcut-pin { transition: none; }
}

/* ═══════════════════════════════════════════════════════════════════════════
   PHASE F — Accessibility & density: touch targets + post-audit mobile rules
   (UI_STANDARDS §5 density, §8 responsive tiers, §9 AA touch targets)
   ═══════════════════════════════════════════════════════════════════════════ */

/* ── Phone (< 768px) rules for components added after the original mobile block.
   The audit flagged these as having no phone handling. Data tables (incl. the
   MRP grid) already scroll inside their .table-scroll wrapper (§8); the toast
   stack, widget grid, and MRP grid density are addressed here. The swap-engine
   nav progress bar (.uee-nav-progress) was reviewed — a full-width 3px strip
   pinned to the top of .main-content needs no phone-specific rule. ────────── */
@media (max-width: 768px) {
    /* Toasts: inset to both edges instead of a fixed 420px lane that overflows
       narrow viewports. Stays top-anchored; container itself is click-through. */
    .uee-toast-container {
        left: 0.5rem;
        right: 0.5rem;
        max-width: none;
    }

    /* Flash messages: same treatment as the toast stack. The 400px lane plus a
       right offset pushed a wide flash off the LEFT edge of a phone viewport
       (it shrink-wraps from the right). Inset to both edges instead. Matters
       most on /login, which is the page most often opened on a phone. */
    .flash-container {
        left: 0.5rem;
        right: 0.5rem;
        max-width: none;
    }

    /* MRP workbench grid: horizontal scroll is contained by its .table-scroll
       wrapper; on phones tighten the sticky row-label gutter and cell type so
       more period columns fit before the user has to scroll sideways. */
    .mrp-grid .mrp-grid-label,
    .mrp-grid .mrp-grid-header-label {
        min-width: 120px;
    }
    .mrp-grid th,
    .mrp-grid td {
        font-size: 0.75rem;
        padding: 0.2rem 0.35rem;
    }

    /* Dashboard widget grid: the grid's own tiers (see the DASHBOARD block)
       step it 4 -> 3 -> 2 -> 1 in step with the Shortcuts launcher, so there is
       nothing left to force here. The old rule targeted Bootstrap col-* classes
       the grid no longer emits and is deliberately gone rather than left as a
       selector that matches nothing. */

    /* Check / switch controls: comfortable density is forced on phones (§5), so
       enlarge the control and give its row a ≥44px tap zone (§9). Left as
       display:block to preserve Bootstrap's float/negative-margin form-check
       layout; the min-height + label padding supply the finger target. Dense
       table checkboxes (.col-bulk-check) are excluded so grid rows stay compact. */
    .form-check:not(.col-bulk-check) {
        min-height: 44px;
        padding-top: 0.25rem;
        padding-bottom: 0.25rem;
    }
    .form-check:not(.col-bulk-check) .form-check-input {
        width: 1.25rem;
        height: 1.25rem;
    }
    .form-switch:not(.col-bulk-check) .form-check-input {
        width: 2.25rem;
        height: 1.25rem;
    }
    .form-check:not(.col-bulk-check) .form-check-label {
        padding: 0.35rem 0;
    }
}

/* ── Coarse-pointer touch targets (§9 — extends the AI panel's 44px standard
   app-wide). Keyed on (pointer: coarse) so desktop pointer:fine visuals are
   never inflated. Two techniques: grow the control to ≥44px where the extra
   size is harmless (toolbar/icon buttons, pagination, rail items), or expand
   an invisible ::after hit area where the visual must stay compact (dense
   table header trigger, the small widget [AI Ask] pill). ─────────────────── */
@media (pointer: coarse) {
    /* Detail-page toolbar icon buttons (were 2rem) */
    .detail-tool {
        min-width: 44px;
        min-height: 44px;
    }

    /* Rich-editor toolbar buttons (were 1.75rem; toolbar wraps, so growth is safe) */
    .rich-toolbar-btn {
        min-width: 44px;
        min-height: 44px;
    }

    /* Sidebar rail items (main rail on tablet tier, off-canvas rail on phone),
       footer items, and second-panel links */
    .sidebar .nav-link,
    .sidebar-footer .nav-link,
    .sidebar-panel-link {
        min-height: 44px;
    }

    /* Pagination page links */
    .page-link {
        min-width: 44px;
        min-height: 44px;
        display: flex;
        align-items: center;
        justify-content: center;
    }

    /* Column header menu/filter trigger — the glyph must stay tiny inside the
       dense header, so expand the hit area with a centered ::after overlay. */
    .col-menu-trigger {
        position: relative;
    }
    .col-menu-trigger::after {
        content: "";
        position: absolute;
        top: 50%;
        left: 50%;
        transform: translate(-50%, -50%);
        min-width: 44px;
        min-height: 44px;
    }

    /* Widget [AI Ask] — keep the small outlined pill visual; expand its tap
       target with a centered ::after (the button is already position:relative). */
    .ctl-widget-ai-ask::after {
        content: "";
        position: absolute;
        top: 50%;
        left: 50%;
        transform: translate(-50%, -50%);
        min-width: 44px;
        min-height: 44px;
    }
}

/* Skip link (§9) — visually-hidden-focusable handles the hiding; this styles
   the focused state so keyboard users see a clear, branded affordance. */
.skip-link:focus {
    position: fixed;
    top: 8px;
    left: 50%;
    transform: translateX(-50%);
    z-index: var(--ctl-z-toast);
    background: var(--ctl-accent);
    color: var(--ctl-accent-fg);
    padding: 10px 18px;
    font-weight: 700;
    text-decoration: none;
    border-radius: var(--ctl-radius);
}

/* ── Usage Flow Map (merged from master #780, tokens migrated --uee → --ctl) ──
   Read-only workflow-style node graph of the app's screens. Mirrors the
   workflow-engine canvas visual language; hover/dim state driven by classes. */
#usage-flow-wrap {
    position: relative;
    height: calc(100vh - 220px);
    min-height: 480px;
    border: 1px solid var(--ctl-border);
    border-radius: var(--ctl-radius-lg);
    background: var(--ctl-bg);
    overflow: hidden;
}
#usage-flow-canvas {
    width: 100%;
    height: 100%;
    display: block;
    background: var(--ctl-bg);
}

/* Toolbar (zoom in/out/fit) pinned top-right inside the canvas wrap. */
#usage-flow-wrap .usage-flow-toolbar {
    position: absolute;
    top: 12px;
    right: 12px;
    z-index: 5;
    display: flex;
    gap: 4px;
}

/* Empty-state overlay shown when the window has no navigation data. */
#usage-flow-wrap .usage-flow-empty {
    position: absolute;
    inset: 0;
    display: flex;
    align-items: center;
    justify-content: center;
    z-index: 4;
    pointer-events: none;
}

/* Node cards — same fill/stroke roles the WFE editor node rects use. */
.uflow-node {
    cursor: pointer;
    transition: opacity 0.12s ease;
}
.uflow-node .uflow-card {
    fill: var(--ctl-bg-elevated);
    stroke: var(--ctl-border-strong);
    stroke-width: 1.5;
    filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.12));
}
.uflow-node:hover .uflow-card {
    stroke: var(--ctl-accent);
    stroke-width: 2;
}
.uflow-t1 {
    fill: var(--ctl-fg);
    font-size: 13px;
    font-weight: 600;
    pointer-events: none;
}
.uflow-t2 {
    fill: var(--ctl-fg-muted);
    font-size: 10px;
    pointer-events: none;
}

/* Edges default translucent; hover raises the connected ones and dims the rest. */
.uflow-edge {
    opacity: 0.55;
    transition: opacity 0.12s ease;
}
.uflow-edge.uflow-hot { opacity: 1; }
.uflow-edge.uflow-dim { opacity: 0.15; }
.uflow-node.uflow-dim { opacity: 0.15; }

/* Secondary ("crossover") edges — hidden until the shuffle toolbar button
   toggles .uflow-show-secondary on the SVG. Dashed + fainter so they never
   compete with the solid primary flow. */
.uflow-edge-secondary {
    display: none;
    stroke-dasharray: 6 5;
    opacity: 0.3;
}
svg.uflow-show-secondary .uflow-edge-secondary { display: initial; }
#flow-toggle-secondary.active {
    color: var(--ctl-accent-fg);
    background-color: var(--ctl-accent);
    border-color: var(--ctl-accent);
}

/* =============================================================================
   KB rich articles — artifact-grade design system
   -----------------------------------------------------------------------------
   Ported from the two reference artifacts (chat-guide / calendar-guide) into ONE
   cohesive, theme-aware stylesheet. EVERY selector is scoped under
   `.kb-article-content` (the wrapper templates/kb/show.php renders around raw
   article HTML), so nothing here can leak into app chrome or Bootstrap.

   Colour/layout come from scoped `--k-*` tokens: surfaces bind to the app's
   `--ctl-*` semantic tokens (so the article inherits whichever sheet + light/dark
   scheme is active), while accent hues are explicit with a dark override keyed on
   `html[data-bs-theme="dark"]` — kept in lockstep with how `--ctl-*` flips. Inline
   SVGs colour themselves with the same tokens via presentation attributes, e.g.
   fill="var(--k-blue)". Motion honours prefers-reduced-motion (rest = end state).

   Article authors: use ONLY the classes below + inline SVG. No <style>, no inline
   style="", no <script>. Colour utilities (`k-c-*`, `k-c-*-soft`, `k-t-*`) replace
   every inline colour the reference artifacts used.
   ========================================================================== */

.kb-article-content {
    /* fonts */
    /* --k-mono was the app's best mono stack and the only one anybody had
       researched; it is now the app's mono TOKEN (#2013) and this stays as the
       scoped alias, so the ~20 `var(--k-mono)` rules below need no rewrite and
       the article system keeps its one-vocabulary rule. */
    --k-mono: var(--ctl-font-mono);
    --k-sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;

    /* surfaces — inherit the active app sheet (Operator/Refined/Industrial × light/dark) */
    --k-paper: var(--ctl-bg, #eef1f7);
    --k-card: var(--ctl-bg-elevated, #ffffff);
    --k-card-2: var(--ctl-bg-inset, #f7f9fc);
    --k-ink: var(--ctl-fg, #111826);
    --k-ink-soft: var(--ctl-fg-muted, #5b6678);
    --k-ink-faint: var(--ctl-fg-subtle, #8b95a7);
    --k-line: var(--ctl-border, #e1e6f0);
    --k-rule: var(--ctl-border-strong, #d7ddea);

    /* accent hues (light) */
    --k-blue: #2563ff;   --k-blue-soft: #e4ecff;   --k-blue-ink: #173a9c;
    --k-ai: #7c3aed;     --k-ai-soft: #efe7fe;     --k-ai-ink: #4c1d95;
    --k-violet: #6a4fe0; --k-violet-soft: #e8e3fb;
    --k-teal: #0f9f9a;   --k-teal-soft: #d6f3f0;
    --k-sky: #2f7fe0;    --k-sky-soft: #dbeafb;
    --k-pink: #e6558a;   --k-pink-soft: #fadfeb;
    --k-green: #0f9d58;  --k-green-soft: #dcf3e6;
    --k-amber: #c47f00;  --k-amber-soft: #fbeecd;
    --k-red: #df2f42;    --k-red-soft: #fbe1e4;

    --k-shadow: 0 1px 2px rgba(17, 24, 38, .05), 0 14px 34px rgba(17, 24, 38, .07);
    --k-shadow-lift: 0 2px 6px rgba(17, 24, 38, .06), 0 22px 48px rgba(17, 24, 38, .10);
    --k-r: 14px;

    font-family: var(--k-sans);
    line-height: 1.62;
    color: var(--k-ink);
    -webkit-font-smoothing: antialiased;
}

/* Dark accents — keyed on the app's dark attribute so they track --ctl-* surfaces. */
html[data-bs-theme="dark"] .kb-article-content {
    --k-blue: #5b8bff;   --k-blue-soft: #17233f;   --k-blue-ink: #cdddff;
    --k-ai: #a883f8;     --k-ai-soft: #241a3d;     --k-ai-ink: #e7dcff;
    --k-violet: #8f79ff; --k-violet-soft: #241f45;
    --k-teal: #3fcdc6;   --k-teal-soft: #0f2e2c;
    --k-sky: #5b9bf0;    --k-sky-soft: #132a44;
    --k-pink: #f279ab;   --k-pink-soft: #38202e;
    --k-green: #3ecf7a;  --k-green-soft: #0f2c1f;
    --k-amber: #e6a83a;  --k-amber-soft: #352a12;
    --k-red: #ff6470;    --k-red-soft: #3a1d21;
    --k-shadow: 0 1px 2px rgba(0, 0, 0, .4), 0 16px 40px rgba(0, 0, 0, .5);
    --k-shadow-lift: 0 2px 6px rgba(0, 0, 0, .5), 0 26px 56px rgba(0, 0, 0, .6);
}

.kb-article-content * { box-sizing: border-box; }
.kb-article-content h1,
.kb-article-content h2,
.kb-article-content h3,
.kb-article-content p,
.kb-article-content ul,
.kb-article-content ol { margin: 0; }
.kb-article-content a {
    color: var(--k-blue); font-weight: 650; text-decoration: none;
    border-bottom: 1.5px solid color-mix(in srgb, var(--k-blue) 30%, transparent);
}
.kb-article-content a:hover { border-bottom-color: var(--k-blue); }
.kb-article-content :focus-visible { outline: 2.5px solid var(--k-blue); outline-offset: 2px; border-radius: 4px; }

/* ── Thread / spine ──────────────────────────────────────────────────────── */
.kb-article-content .thread { position: relative; padding-left: clamp(0px, 2vw, 30px); }
.kb-article-content .wrap { max-width: 100%; margin: 0; }

/* ── Eyebrows / datelines / kickers ──────────────────────────────────────── */
.kb-article-content .eyebrow {
    font-family: var(--k-mono); font-size: .72rem; font-weight: 700; letter-spacing: .16em;
    text-transform: uppercase; color: var(--k-ink-faint); display: inline-flex; align-items: center; gap: .5em;
}
.kb-article-content .eyebrow .live { color: var(--k-green); }
.kb-article-content .dateline {
    font-family: var(--k-mono); text-transform: uppercase; letter-spacing: .16em;
    font-size: .74rem; font-weight: 700; color: var(--k-red);
    margin: 0 0 .55rem; display: flex; gap: .55rem; align-items: center; flex-wrap: wrap;
}
.kb-article-content .kicker {
    font-family: var(--k-mono); font-size: .72rem; font-weight: 700; letter-spacing: .14em;
    text-transform: uppercase; color: var(--k-blue); margin-bottom: .7em; display: block;
}
.kb-article-content .kicker.ai { color: var(--k-ai); }
.kb-article-content .lead { font-size: 1.08rem; color: var(--k-ink-soft); }

/* live presence dot (+ pulsing ring) */
.kb-article-content .dot {
    width: 8px; height: 8px; border-radius: 50%; background: var(--k-green);
    display: inline-block; position: relative;
}
.kb-article-content .dateline .dot { background: var(--k-red); }
.kb-article-content .dot.pulse::after {
    content: ""; position: absolute; inset: -4px; border-radius: 50%;
    border: 2px solid var(--k-green); animation: kb-ping 1.8s cubic-bezier(0, 0, .2, 1) infinite;
}
@keyframes kb-ping { 0% { transform: scale(.6); opacity: .9 } 80%, 100% { transform: scale(1.9); opacity: 0 } }

/* ── HERO ────────────────────────────────────────────────────────────────── */
.kb-article-content .hero {
    background: var(--k-card); border: 1px solid var(--k-line); border-radius: calc(var(--k-r) + 4px);
    box-shadow: var(--k-shadow-lift); padding: clamp(22px, 4vw, 40px); position: relative; overflow: hidden;
}
/* calendar-guide's signature top accent — only heroes with a dateline get it */
.kb-article-content .hero:has(.dateline) { border-top: 5px solid var(--k-red); }
.kb-article-content .hero h1 {
    font-size: clamp(2.1rem, 6vw, 3.5rem); line-height: 1.02; letter-spacing: -.028em;
    font-weight: 830; text-wrap: balance; margin: .32em 0 .3em;
}
.kb-article-content .hero .sub, .kb-article-content .hero > p {
    font-size: clamp(1.02rem, 2.4vw, 1.24rem); color: var(--k-ink-soft); max-width: 46ch;
}
.kb-article-content .hero .k { color: var(--k-blue); }
.kb-article-content .hero .k2 { color: var(--k-ai); }

/* catstrip (calendar hero colour bars) */
.kb-article-content .catstrip { display: flex; gap: 6px; margin-top: 20px; max-width: 360px; }
.kb-article-content .catstrip i { height: 10px; flex: 1; border-radius: 3px; background: var(--k-ink-faint); }

/* hero-art (calendar decorative corner SVG) */
.kb-article-content .hero-art { position: absolute; right: clamp(-16px, 2vw, 28px); bottom: 20px; }
/* the KB reading pane is narrow inside a wide viewport, so the decorative corner art overlaps the
   hero copy; a viewport media query can't see the pane width — hide it in-app (it's purely decorative). */
.kb-article-content .hero-art { display: none; }
@media (max-width: 720px) { .kb-article-content .hero-art { display: none; } }

/* chips row */
.kb-article-content .chips { display: flex; flex-wrap: wrap; gap: 7px; margin-top: 22px; }
.kb-article-content .chip {
    font-family: var(--k-mono); font-size: .74rem; font-weight: 600; color: var(--k-ink-soft);
    background: var(--k-card-2); border: 1px solid var(--k-line); padding: 5px 11px; border-radius: 999px;
}
.kb-article-content .chip b { color: var(--k-blue); font-weight: 700; }

/* ── Animated hero chat demo (chat-guide, carried over verbatim + scoped) ── */
.kb-article-content .demo {
    margin-top: 26px; background: var(--k-card-2); border: 1px solid var(--k-line); border-radius: var(--k-r);
    padding: 16px clamp(12px, 2.5vw, 20px) 18px; max-width: 520px;
}
.kb-article-content .demo .head {
    display: flex; align-items: center; gap: 9px; padding-bottom: 12px; margin-bottom: 14px;
    border-bottom: 1px solid var(--k-line);
}
/* The KB chat-guide article draws its own mock avatars (it is a scoped copy of a
   standalone design, so it cannot reach for .uee-avatar). It still has to AGREE
   with the real thing: a reader who sees a squircle here and a circle in the
   navbar reads the illustration as showing a different product. Full-round, same
   as every real face. */
.kb-article-content .avatar {
    width: 26px; height: 26px; border-radius: var(--ctl-radius-full); display: grid; place-items: center;
    font-family: var(--k-mono); font-weight: 700; font-size: .72rem; color: #fff; flex: 0 0 auto;
}
.kb-article-content .avatar.ai { background: var(--k-ai); }
.kb-article-content .avatar.you { background: var(--k-blue); }
.kb-article-content .avatar.sys { background: var(--k-ink-soft); }
.kb-article-content .demo .head .nm { font-weight: 700; font-size: .92rem; }
.kb-article-content .demo .head .ch { font-family: var(--k-mono); font-size: .74rem; color: var(--k-ink-faint); margin-left: auto; }
.kb-article-content .stream { display: flex; flex-direction: column; }
.kb-article-content .row { display: flex; gap: 9px; align-items: flex-end; margin-bottom: 10px; }
.kb-article-content .row.r { flex-direction: row-reverse; }
.kb-article-content .bub { padding: 9px 13px; border-radius: 15px; font-size: .95rem; max-width: 82%; box-shadow: var(--k-shadow); }
.kb-article-content .bub.you { background: var(--k-blue); color: #fff; border-bottom-right-radius: 5px; }
.kb-article-content .bub.ai { background: var(--k-ai-soft); color: var(--k-ink); border: 1px solid var(--k-line); border-bottom-left-radius: 5px; }
.kb-article-content .bub .who {
    display: block; font-family: var(--k-mono); font-size: .66rem; font-weight: 700; letter-spacing: .06em;
    text-transform: uppercase; color: var(--k-ai); margin-bottom: 2px;
}
.kb-article-content .typing { display: inline-flex; gap: 4px; padding: 12px 14px; }
.kb-article-content .typing i { width: 7px; height: 7px; border-radius: 50%; background: var(--k-ai); opacity: .5; animation: kb-blink 1.3s infinite; }
.kb-article-content .typing i:nth-child(2) { animation-delay: .18s; }
.kb-article-content .typing i:nth-child(3) { animation-delay: .36s; }
@keyframes kb-blink { 0%, 60%, 100% { opacity: .35; transform: translateY(0) } 30% { opacity: 1; transform: translateY(-3px) } }
.kb-article-content .caret::after {
    content: ""; display: inline-block; width: 2px; height: 1em; background: var(--k-ai);
    margin-left: 2px; vertical-align: -2px; animation: kb-caret 1s step-end infinite;
}
@keyframes kb-caret { 50% { opacity: 0 } }

/* in-bubble action card */
.kb-article-content .acard { margin-top: 8px; background: var(--k-card); border: 1px solid var(--k-line); border-radius: 12px; overflow: hidden; box-shadow: var(--k-shadow); }
.kb-article-content .acard .ah {
    padding: 9px 12px; font-size: .82rem; font-weight: 700; border-left: 4px solid var(--k-amber);
    display: flex; gap: 8px; align-items: center; justify-content: space-between;
}
.kb-article-content .acard .state { font-family: var(--k-mono); font-size: .66rem; font-weight: 700; letter-spacing: .08em; padding: 2px 8px; border-radius: 999px; }
.kb-article-content .st-open { background: var(--k-amber-soft); color: var(--k-amber); }
.kb-article-content .st-done { background: var(--k-green-soft); color: var(--k-green); }
.kb-article-content .acard .ab { padding: 6px 12px 11px; color: var(--k-ink-soft); font-size: .85rem; }
.kb-article-content .acard .btns { display: flex; gap: 8px; padding: 0 12px 12px; }
.kb-article-content .abtn { font-size: .82rem; font-weight: 700; padding: 6px 12px; border-radius: 8px; border: 1px solid var(--k-line); background: var(--k-card-2); color: var(--k-ink); }
.kb-article-content .abtn.pri { background: var(--k-green); color: #fff; border-color: transparent; }
.kb-article-content .resolved-note { padding: 0 12px 11px; font-size: .8rem; color: var(--k-green); font-weight: 650; display: flex; gap: 6px; align-items: center; }

/* hero playback — END state IS the resting state, so reduced-motion shows the finished conversation */
.kb-article-content .state-wrap { position: relative; min-width: 78px; height: 20px; }
.kb-article-content .state-wrap .state { position: absolute; right: 0; top: 0; }
.kb-article-content .s-open { opacity: 0; }
.kb-article-content .s-done { opacity: 1; }
.kb-article-content .resolved-note { opacity: 1; }
.kb-article-content .row.a1 { animation: kb-rise .5s .6s both; }
.kb-article-content .row.a2 { animation: kb-rise .5s 3.5s both; }
.kb-article-content .cardwrap { animation: kb-rise .5s 4.4s both; }
.kb-article-content .rowtyping { max-height: 0; opacity: 0; overflow: hidden; margin-bottom: 0; animation: kb-typingcycle 2.9s .9s both; }
@keyframes kb-rise { from { opacity: 0; transform: translateY(9px) } to { opacity: 1; transform: none } }
@keyframes kb-typingcycle {
    0% { opacity: 0; max-height: 0; margin-bottom: 0 }
    12% { opacity: 1; max-height: 52px; margin-bottom: 10px }
    72% { opacity: 1; max-height: 52px; margin-bottom: 10px }
    88%, 100% { opacity: 0; max-height: 0; margin-bottom: 0 }
}
.kb-article-content .abtn.pri { animation: kb-press .5s 5.9s; }
@keyframes kb-press { 45% { transform: scale(.93); filter: brightness(1.12) } }
.kb-article-content .s-open { animation: kb-fadeout .45s 6.1s both; }
.kb-article-content .s-done { animation: kb-fadein .45s 6.1s both; }
.kb-article-content .btns { animation: kb-fadeout .4s 6.1s both; }
.kb-article-content .resolved-note { animation: kb-fadein .5s 6.2s both; }
@keyframes kb-fadeout { from { opacity: 1 } to { opacity: 0 } }
@keyframes kb-fadein { from { opacity: 0 } to { opacity: 1 } }

/* ── Sections hang off the thread ────────────────────────────────────────── */
.kb-article-content section {
    background: var(--k-card); border: 1px solid var(--k-line); border-radius: var(--k-r);
    box-shadow: var(--k-shadow); padding: clamp(20px, 3.4vw, 34px); margin: 20px 0; position: relative;
}
.kb-article-content .thread > section::before {
    content: ""; position: absolute; left: calc(-1 * clamp(0px, 2vw, 30px) - 1px); top: 34px; bottom: -20px;
    width: 2px; background: linear-gradient(var(--k-rule), transparent); display: none;
}
@media (min-width: 760px) { .kb-article-content .thread > section::before { display: block; } }
.kb-article-content .thread > section:last-of-type::before { display: none; }
.kb-article-content .snum {
    position: absolute; left: calc(-1 * clamp(0px, 2vw, 30px) - 15px); top: 28px; width: 30px; height: 30px;
    border-radius: 9px; background: var(--k-card); border: 1px solid var(--k-line); display: none;
    place-items: center; font-family: var(--k-mono); font-weight: 700; font-size: .8rem; color: var(--k-blue);
    box-shadow: var(--k-shadow);
}
@media (min-width: 760px) { .kb-article-content .snum { display: grid; } }

.kb-article-content h2 {
    font-size: clamp(1.35rem, 3.4vw, 1.9rem); letter-spacing: -.02em; line-height: 1.12; font-weight: 790;
    text-wrap: balance; margin-bottom: .15em; display: flex; gap: .5rem; align-items: center; flex-wrap: wrap;
}
.kb-article-content h2 .emoji { font-size: 1.15em; }
.kb-article-content section p { color: var(--k-ink-soft); margin: .7em 0; max-width: 64ch; }
.kb-article-content section p strong, .kb-article-content section li strong, .kb-article-content td strong, .kb-article-content strong { color: var(--k-ink); }
.kb-article-content h3 { font-size: 1.06rem; margin: 1.35em 0 .35em; font-weight: 720; letter-spacing: -.01em; color: var(--k-ink); }
.kb-article-content section ul, .kb-article-content section ol { color: var(--k-ink-soft); padding-left: 1.25em; margin: .6em 0; }
.kb-article-content section li { margin: .35em 0; }

/* kbd / inline code */
.kb-article-content .kbd, .kb-article-content code {
    font-family: var(--k-mono); font-size: .82em; font-weight: 600; background: var(--k-card-2);
    border: 1px solid var(--k-line); border-radius: 6px; padding: .05em .45em; color: var(--k-ink);
}
.kb-article-content .kbd { border-bottom-width: 2px; }

/* ── Callouts ────────────────────────────────────────────────────────────── */
.kb-article-content .call {
    display: flex; gap: .8rem; align-items: flex-start; border-radius: 12px;
    padding: 13px 16px; margin: 16px 0; border: 1px solid transparent;
}
.kb-article-content .call .ic {
    flex: 0 0 auto; width: 24px; height: 24px; border-radius: 7px; display: grid; place-items: center;
    font-weight: 800; color: #fff; font-size: .85rem;
}
.kb-article-content .call b { display: block; margin-bottom: .12em; color: var(--k-ink); }
.kb-article-content .call p { margin: 0; font-size: .94rem; color: var(--k-ink-soft); }
.kb-article-content .tip { background: var(--k-blue-soft); }   .kb-article-content .tip .ic { background: var(--k-blue); }
.kb-article-content .moat { background: var(--k-ai-soft); }    .kb-article-content .moat .ic { background: var(--k-ai); }
.kb-article-content .warn { background: var(--k-amber-soft); } .kb-article-content .warn .ic { background: var(--k-amber); }
.kb-article-content .safe { background: var(--k-green-soft); } .kb-article-content .safe .ic { background: var(--k-green); }
.kb-article-content .did { background: var(--k-sky-soft); }    .kb-article-content .did .ic { background: var(--k-sky); }

/* ── Quick-start steps ───────────────────────────────────────────────────── */
.kb-article-content .steps { list-style: none; padding: 0; margin: 14px 0 0; display: grid; gap: 12px; }
.kb-article-content .steps li { display: flex; gap: 14px; align-items: flex-start; margin: 0; }
.kb-article-content .steps .n, .kb-article-content .steps .num {
    flex: 0 0 auto; width: 30px; height: 30px; border-radius: 9px; background: var(--k-blue); color: #fff;
    font-family: var(--k-mono); font-weight: 700; display: grid; place-items: center; font-size: .86rem;
}
.kb-article-content .steps div strong { color: var(--k-ink); }
/* calendar's dashed quick-start section variant */
.kb-article-content section.quick { background: linear-gradient(135deg, var(--k-teal-soft), var(--k-sky-soft)); border: 2px dashed var(--k-teal); }

/* ── Feature grid ────────────────────────────────────────────────────────── */
.kb-article-content .grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(210px, 1fr)); gap: 12px; margin-top: 14px; }
.kb-article-content .mini { border: 1px solid var(--k-line); border-radius: 12px; padding: 14px 15px; background: var(--k-card-2); }
.kb-article-content .mini .t, .kb-article-content .mini h4 {
    font-weight: 720; font-size: .98rem; margin: 0 0 .2em; display: flex; align-items: center; gap: .45em; color: var(--k-ink);
}
.kb-article-content .mini p { margin: 0; font-size: .9rem; max-width: none; color: var(--k-ink-soft); }
.kb-article-content .tag { font-family: var(--k-mono); font-size: .62rem; font-weight: 700; letter-spacing: .05em; padding: 2px 7px; border-radius: 5px; text-transform: uppercase; }
.kb-article-content .tag.b { background: var(--k-blue-soft); color: var(--k-blue-ink); }
.kb-article-content .tag.a { background: var(--k-ai-soft); color: var(--k-ai-ink); }

/* ── Tables ──────────────────────────────────────────────────────────────── */
.kb-article-content .tw { overflow-x: auto; border: 1px solid var(--k-line); border-radius: 12px; margin: 16px 0; }
.kb-article-content table { border-collapse: collapse; width: 100%; min-width: 440px; font-size: .92rem; }
.kb-article-content th, .kb-article-content td { padding: 11px 14px; text-align: left; border-bottom: 1px solid var(--k-line); color: var(--k-ink-soft); }
.kb-article-content th {
    background: var(--k-card-2); color: var(--k-ink); font-weight: 720; font-family: var(--k-mono);
    font-size: .74rem; letter-spacing: .05em; text-transform: uppercase;
}
.kb-article-content tr:last-child td { border-bottom: none; }

/* ── Figures / diagrams ──────────────────────────────────────────────────── */
.kb-article-content figure, .kb-article-content .figure { margin: 16px 0; background: var(--k-card-2); border: 1px solid var(--k-line); border-radius: 12px; padding: 16px; }
.kb-article-content .sc, .kb-article-content .scroll { overflow-x: auto; -webkit-overflow-scrolling: touch; }
.kb-article-content figure svg, .kb-article-content .figure svg { display: block; max-width: 100%; height: auto; }
.kb-article-content figcaption { font-size: .84rem; color: var(--k-ink-faint); text-align: center; margin-top: 10px; font-family: var(--k-mono); }
.kb-article-content .svgwide { min-width: 520px; }
.kb-article-content .svgmin { min-width: 520px; }

/* ── Inline transcript (mini-chat) ───────────────────────────────────────── */
.kb-article-content .mini-chat { display: grid; gap: 8px; margin: 14px 0; max-width: 440px; }
.kb-article-content .mini-chat .b { padding: 8px 12px; border-radius: 14px; font-size: .92rem; max-width: 85%; }
.kb-article-content .mini-chat .b.me { background: var(--k-blue); color: #fff; justify-self: end; border-bottom-right-radius: 4px; }
.kb-article-content .mini-chat .b.them { background: var(--k-card-2); border: 1px solid var(--k-line); justify-self: start; border-bottom-left-radius: 4px; color: var(--k-ink); }
.kb-article-content .mini-chat .b.aiy { background: var(--k-ai-soft); border: 1px solid var(--k-line); justify-self: start; border-bottom-left-radius: 4px; color: var(--k-ink); }
.kb-article-content .mini-chat .b .who { font-family: var(--k-mono); font-size: .64rem; font-weight: 700; text-transform: uppercase; letter-spacing: .05em; display: block; margin-bottom: 2px; opacity: .75; }

/* ── Chips / pills / presence / legend ───────────────────────────────────── */
.kb-article-content .pill {
    display: inline-flex; align-items: center; gap: .4em; font-weight: 700; font-size: .78rem;
    padding: 4px 11px; border-radius: 999px; text-transform: uppercase; letter-spacing: .4px; color: var(--k-ink);
}
.kb-article-content .presence { display: flex; flex-wrap: wrap; gap: 14px; margin: 12px 0 0; font-family: var(--k-mono); font-size: .8rem; color: var(--k-ink-soft); }
.kb-article-content .presence span { display: inline-flex; align-items: center; gap: 7px; }
.kb-article-content .legend { display: flex; gap: 14px; flex-wrap: wrap; margin: 10px 0 0; font-size: .86rem; color: var(--k-ink-soft); }
.kb-article-content .legend span { display: inline-flex; align-items: center; gap: 6px; }
/* swatch box (legend / presence colour chips) — colour comes from a k-c-* utility */
.kb-article-content .sq { width: 11px; height: 11px; border-radius: 4px; display: inline-block; background: var(--k-ink-faint); }
.kb-article-content .legend .dot { width: 12px; height: 12px; border-radius: 4px; }

/* ── FAQ (details / summary) ─────────────────────────────────────────────── */
.kb-article-content details { border: 1px solid var(--k-line); border-radius: 12px; padding: 2px 16px; margin: 10px 0; background: var(--k-card-2); }
.kb-article-content details[open] { background: var(--k-card); box-shadow: var(--k-shadow); }
.kb-article-content summary { cursor: pointer; font-weight: 700; padding: 13px 0; list-style: none; display: flex; justify-content: space-between; gap: 1rem; align-items: center; }
.kb-article-content summary::-webkit-details-marker { display: none; }
.kb-article-content summary::after { content: "+"; font-family: var(--k-mono); font-size: 1.3rem; color: var(--k-blue); font-weight: 700; transition: transform .2s; }
.kb-article-content details[open] summary::after { transform: rotate(45deg); }
.kb-article-content details p { margin: 0 0 13px; color: var(--k-ink-soft); font-size: .95rem; }

/* ── Footer ──────────────────────────────────────────────────────────────── */
.kb-article-content .foot, .kb-article-content .footer { text-align: center; color: var(--k-ink-faint); font-family: var(--k-mono); font-size: .8rem; padding: 30px 0 6px; line-height: 1.9; }
.kb-article-content .foot .r { display: inline-block; height: 3px; width: 56px; border-radius: 2px; background: linear-gradient(90deg, var(--k-blue), var(--k-ai)); margin-bottom: 14px; }

/* =============================================================================
   Colour utilities — replace every inline style="background/color:…" the
   reference artifacts used. Placed LAST so a single-class utility wins over a
   component default at equal specificity (e.g. .dot.k-c-red beats .dot).
   ========================================================================== */
/* color utilities win over element-selector defaults (e.g. .catstrip i, .legend .dot) — utility pattern */
.kb-article-content .k-c-blue { background: var(--k-blue) !important; }
.kb-article-content .k-c-ai { background: var(--k-ai) !important; }
.kb-article-content .k-c-violet { background: var(--k-violet) !important; }
.kb-article-content .k-c-teal { background: var(--k-teal) !important; }
.kb-article-content .k-c-sky { background: var(--k-sky) !important; }
.kb-article-content .k-c-pink { background: var(--k-pink) !important; }
.kb-article-content .k-c-green { background: var(--k-green) !important; }
.kb-article-content .k-c-amber { background: var(--k-amber) !important; }
.kb-article-content .k-c-red { background: var(--k-red) !important; }
.kb-article-content .k-c-ink { background: var(--k-ink) !important; }

/* soft pill fills (share-level table etc.) — soft background + readable ink */
.kb-article-content .k-c-blue-soft { background: var(--k-blue-soft); color: var(--k-ink); }
.kb-article-content .k-c-ai-soft { background: var(--k-ai-soft); color: var(--k-ink); }
.kb-article-content .k-c-violet-soft { background: var(--k-violet-soft); color: var(--k-ink); }
.kb-article-content .k-c-teal-soft { background: var(--k-teal-soft); color: var(--k-ink); }
.kb-article-content .k-c-sky-soft { background: var(--k-sky-soft); color: var(--k-ink); }
.kb-article-content .k-c-pink-soft { background: var(--k-pink-soft); color: var(--k-ink); }
.kb-article-content .k-c-green-soft { background: var(--k-green-soft); color: var(--k-ink); }
.kb-article-content .k-c-amber-soft { background: var(--k-amber-soft); color: var(--k-ink); }
.kb-article-content .k-c-red-soft { background: var(--k-red-soft); color: var(--k-ink); }

/* accent text (e.g. hero highlight @assistant) */
.kb-article-content .k-t-ai { color: var(--k-ai); }
.kb-article-content .k-t-blue { color: var(--k-blue); }

/* ── Reduced motion: rest state = the finished conversation ──────────────── */
@media (prefers-reduced-motion: reduce) {
    .kb-article-content * { animation: none !important; transition: none !important; }
    .kb-article-content .row, .kb-article-content .cardwrap { opacity: 1 !important; transform: none !important; }
    .kb-article-content .rowtyping { display: none !important; }
    .kb-article-content .s-open, .kb-article-content .btns { display: none !important; }
    .kb-article-content .s-done, .kb-article-content .resolved-note { opacity: 1 !important; }
    .kb-article-content .dot.pulse::after { display: none; }
    .kb-article-content .caret::after { display: none; }
}
/* ===== end KB rich articles ===== */

/* ==========================================================================
   Ticket list — live surface (js/tickets-list-live.js)
   ==========================================================================
   The list swaps its own regions instead of navigating, so it needs two things
   the app-wide swap engine never had to provide: a loading state that keeps the
   table's SHAPE while the rows are in flight, and a resting state that doesn't
   flicker when the swap lands inside 120ms.

   Everything here is token-only. The shimmer is a moving highlight over the
   page's own inset surface, so it reads as "this surface, loading" in all three
   styles and both schemes without a single per-theme override — Industrial's
   pure-black inset, Refined Blue's navy and Operator's warm near-black each
   supply their own base, and the highlight is the foreground colour at a low
   alpha rather than a hardcoded white.
   ========================================================================== */

.tlist-skel-row > td {
    /* Match a real row's height exactly (--ctl-table-cell-pad-y is the density
       token the live rows use) so the table does not resize under the swap. */
    padding-top: var(--ctl-table-cell-pad-y);
    padding-bottom: var(--ctl-table-cell-pad-y);
}

.tlist-skel {
    display: block;
    height: 0.85em;
    border-radius: var(--ctl-radius);
    background-color: var(--ctl-bg-inset);
    background-image: linear-gradient(
        90deg,
        rgba(var(--ctl-fg-rgb), 0) 0%,
        rgba(var(--ctl-fg-rgb), 0.09) 50%,
        rgba(var(--ctl-fg-rgb), 0) 100%
    );
    background-size: 200% 100%;
    background-repeat: no-repeat;
    animation: tlist-skel-sweep 1.15s linear infinite;
}

/* Staggered widths read as a table of DIFFERENT values rather than a barcode —
   the eye recognises it as data arriving, not as a broken layout. */
.tlist-skel-row:nth-child(3n + 1) .tlist-skel { width: 82%; }
.tlist-skel-row:nth-child(3n + 2) .tlist-skel { width: 64%; }
.tlist-skel-row:nth-child(3n) .tlist-skel     { width: 73%; }

@keyframes tlist-skel-sweep {
    from { background-position: 200% 0; }
    to   { background-position: -200% 0; }
}

/* UI_STANDARDS §4.4 — non-essential motion collapses. The block still reads as
   a placeholder because the inset surface alone is the shape of the missing
   value; only the sweep goes. */
@media (prefers-reduced-motion: reduce) {
    .tlist-skel { animation: none; background-image: none; }
}

/* A region mid-fetch keeps its rows interactive-looking but stops accepting the
   pointer, so a click can't be aimed at a row that is about to be replaced. */
[data-tlist-region][aria-busy="true"] {
    cursor: progress;
}

[data-tlist-region][aria-busy="true"] .table-status-bar,
[data-tlist-region][aria-busy="true"] tbody {
    pointer-events: none;
}

/* ── The live-feed notice strip (#newTicketsPill) ────────────────────────────
   "3 new tickets are available" / "This list may be out of date. Refresh to
   catch up." — the one place the real-time feed can admit that row-by-row
   repair has stopped being honest.

   It used to be `alert alert-info` wrapping a `btn btn-info`, and it was the
   only off-system object on the page: Bootstrap compiles its btn-* variants
   with LITERAL hex, so Refresh rendered as stock cyan (#0dcaf0, black text) in
   all six sheets regardless of style or scheme, and `.alert`'s borderless
   0.5rem slab shared no edge treatment with anything around it.

   The fix is reuse, not a new look. The element carries `.card`, so the surface
   (elevated background, --ctl-card-border hairline, --ctl-radius, the preset's
   own --ctl-card-shadow) is LITERALLY the same primitive as the table card it
   sits above and cannot drift from it; this rule only re-lays it out as a row
   and adds the accent rail. That rail is the toast's own motif (.uee-toast, 4px
   --ctl-accent left border) — this is an inline toast, so it should say so in
   the same vocabulary rather than a second one. Nothing here is a literal
   colour or radius, so all three styles and both schemes are covered by the
   tokens they already define. */
.tlist-notice {
    /* .content-wrapper is a flex column whose table card takes flex:1 — without
       this the strip would be squeezed by the very list it is describing. */
    flex: 0 0 auto;
    flex-direction: row;          /* .card is a column; a notice is a line */
    /* §8 phone: rather than let a long sentence push Refresh off the edge, the
       button drops to its own line. */
    flex-wrap: wrap;
    align-items: center;
    justify-content: space-between;
    gap: var(--ctl-sp-2) var(--ctl-sp-3);
    padding: var(--ctl-sp-2) var(--ctl-sp-3);
    margin-bottom: var(--ctl-sp-3);
    border-left: 4px solid var(--ctl-accent);
    /* Toggled by d-none/d-flex (app.js, tickets-list-live.js). Going from
       display:none to rendered restarts the animation, so the strip arrives
       rather than appearing — and never on first paint, because the page ships
       it hidden. --ctl-dur, and the global prefers-reduced-motion rule (§4.4)
       collapses it to a jump-cut for free. */
    animation: tlist-notice-in var(--ctl-dur) ease-out;
}

.tlist-notice-msg {
    display: inline-flex;
    align-items: center;
    gap: var(--ctl-sp-2);
    flex: 1 1 auto;
    min-width: 0;                 /* long messages wrap instead of shoving Refresh out */
    color: var(--ctl-fg);
}

/* The text node itself is a flex item, and a flex item's default min-width:auto
   is what would otherwise refuse to wrap and overflow the strip. */
.tlist-notice-msg > span { min-width: 0; }

/* -accent-text, not -accent: the base accent is a fill/rail value and would
   fail as a glyph on an elevated surface (§4.5). Same hue as the rail, so the
   two read as one signal. */
.tlist-notice-icon {
    color: var(--ctl-accent-text);
    flex: 0 0 auto;
}

/* Refresh must never be the loudest thing on a list whose primary action is
   New Ticket — so it is the page's own quiet small-button pattern (the same
   class the inactive preset chips wear), not a second solid fill. */
.tlist-notice .btn {
    flex: 0 0 auto;
    white-space: nowrap;
}

@keyframes tlist-notice-in {
    from { opacity: 0; transform: translateY(-4px); }
    to   { opacity: 1; transform: none; }
}

/* ── Ticket detail: the terminal-state banner (#ticketStateBanner) ───────────
   "This ticket has been resolved." / "This ticket is closed." — pinned above
   the ticket title so a finished ticket never reads as a live one.

   It IS the strip above (.card .tlist-notice): same surface primitive, same
   rail, same arrival animation, so the two can't drift. The only difference is
   whose colour the rail carries. The list strip is a notification and uses the
   page accent; this one is a STATE, and it must agree with the Status pill in
   the sidebar — so the element wears the same .text-status-* utility the pill
   does and the rail + glyph take it through currentColor. One class swap
   re-colours both when the status changes, which is exactly what the no-reload
   paths do (tickets-show.js).

   The message stays --ctl-fg at normal weight: .text-status-* sets a colour AND
   font-weight:500 for a value glyph, neither of which belongs on a sentence.
   The <strong>-free copy leans on the rail and icon for emphasis instead. */
.tdetail-state-notice {
    border-left-color: currentColor;
}

.tdetail-state-notice .tlist-notice-icon {
    /* Overrides the list strip's --ctl-accent-text: the glyph is part of the
       status signal here, not a generic bell. */
    color: currentColor;
}

.tdetail-state-notice .tlist-notice-msg {
    color: var(--ctl-fg);
    font-weight: 400;
}

/* ── In-row editor: the optimistic pending state ─────────────────────────────
   The cell already shows the value you picked; this says "not yet agreed". It
   has to be quiet enough that a run of ten edits doesn't strobe, and clear
   enough that a failure reverting the cell isn't a surprise — so it is a fade
   plus a progress cursor, not a spinner competing with the value it sits on. */
.list-edit.is-pending .list-edit-value {
    opacity: 0.55;
    transition: opacity var(--ctl-dur-fast) ease-out;
}

.list-edit.is-pending .list-edit-trigger {
    cursor: progress;
}

/* The caret is the affordance for a control that is momentarily not one. */
.list-edit.is-pending .list-edit-caret {
    visibility: hidden;
}

/* ── Ticket list: the live SLA column ────────────────────────────────────────
   A column of durations, so it has to behave like one: tabular figures, or the
   digits shuffle sideways every minute as 1s and 4s swap places and the eye
   loses the column it was scanning down.

   Tone tokens, not literals — the same danger / warning / success triad the SLA
   meters on the detail page use (§4.3), so "amber" means the same thing in both
   places and both follow the style/scheme the user picked. The `-text` variants
   are the contrast-verified ones for text on a surface (§4.5); the base values
   are for fills and rails and would fail here. */
.sla-cell {
    font-variant-numeric: tabular-nums;
    font-feature-settings: "tnum" 1;
    white-space: nowrap;
}

.sla-cell.is-ok    { color: var(--ctl-success-text); }
.sla-cell.is-warn  { color: var(--ctl-warning-text); font-weight: 600; }
.sla-cell.is-late  { color: var(--ctl-danger-text);  font-weight: 700; }

/* Paused is not a duration and must not look like one — muted, with the glyph
   carrying the meaning so the state is never colour-only (WCAG 1.4.1). */
.sla-cell.is-paused {
    color: var(--ctl-fg-muted);
    font-weight: 400;
}
.sla-cell.is-paused .bi {
    margin-right: 0.25rem;
    font-size: 0.9em;
}

/* ── Ticket list: the unread flair (subject cell) ────────────────────────────
   Two states from one flag (tkt_ticket.unread_since — see the renderer in
   templates/tickets/_ticket_cell_renderer.php):

   DOT + semibold — a brand-new ticket nobody has read or claimed. The
   mail-client idiom: a small accent dot in a fixed slot before the subject,
   subject weight up one step. The dot is accent-family (attention, not
   severity) so it can never be confused with the priority ramp one column over.

   PILL — a customer reply no agent in the workspace has read: a counting
   "N replies" capsule after the subject. Accent wash + accent text, both
   token-driven so all six style/scheme sheets tune it themselves. The count
   and the reply glyph carry the meaning; colour is never the only channel
   (WCAG 1.4.1), and both states spell themselves out in their titles. */
.ticket-unread-dot {
    display: inline-block;
    width: 8px;
    height: 8px;
    border-radius: var(--ctl-radius-full);
    background: var(--ctl-accent);
    margin-right: 0.45rem;
    vertical-align: 0.05rem;
    flex: none;
}

.ticket-unread-subject {
    font-weight: 600;
}

.ticket-unread-pill {
    display: inline-flex;
    align-items: center;
    gap: 0.3rem;
    margin-left: 0.5rem;
    padding: 0.05rem 0.5rem;
    border-radius: var(--ctl-radius-full);
    background: rgba(var(--ctl-accent-rgb), 0.14);
    color: var(--ctl-accent-text);
    font-size: 0.72rem;
    font-weight: 700;
    letter-spacing: 0.02em;
    white-space: nowrap;
    vertical-align: 0.08rem;
}

.ticket-unread-pill .bi {
    font-size: 0.85em;
}

/* ── Ticket list: the keyboard focus ring (js/tickets-list-keys.js) ──────────
   A <tr> is a poor host for `outline`: with border-collapse the browsers do not
   agree on where it goes, and the ticket table has sticky pinned cells that
   would paint over it anyway. The ring is therefore built from the cells — a
   tinted row plus a solid rail on the leading edge, which is the same 4px accent
   rail the design language already uses to mean "this one" (§3).

   The tint is the accent at 10%, so it reads on every surface ramp: Industrial's
   pure black, Refined Blue's navy and Operator's warm off-white all supply their
   own background underneath it, and none of them needs an override. */
tr.is-key-focused > td {
    background-color: rgba(var(--ctl-accent-rgb), 0.10);
}

tr.is-key-focused > td:first-child {
    box-shadow: inset 3px 0 0 0 var(--ctl-accent);
}

/* The row takes real DOM focus (tabindex=-1) so screen readers follow it, but a
   focus ring on top of the row treatment would be two rings saying one thing. */
tr.is-key-focused:focus,
tr.is-key-focused:focus-visible {
    outline: none;
}

/* Keep the ring legible on a settled row, which is deliberately dimmed. */
tr.is-key-focused.list-row-closed > td {
    color: var(--ctl-fg);
}

/* ── The shortcuts overlay (?) ─────────────────────────────────────────────── */
.shortcuts-dialog {
    max-width: min(92vw, 34rem);
}

.shortcut-grid {
    display: grid;
    grid-template-columns: minmax(5.5rem, auto) 1fr;
    gap: var(--ctl-sp-2) var(--ctl-sp-4);
    align-items: baseline;
}

.shortcut-row {
    display: contents;
}

.shortcut-row kbd {
    justify-self: end;
    background: var(--ctl-bg-inset);
    border: 1px solid var(--ctl-border-strong);
    border-radius: var(--ctl-radius);
    color: var(--ctl-fg);
    font-family: var(--ctl-font-body);
    font-size: var(--ctl-fs-caption);
    font-weight: 600;
    padding: 0.1rem 0.4rem;
    white-space: nowrap;
}

.shortcut-row span {
    color: var(--ctl-fg-muted);
    font-size: var(--ctl-fs-body-sm);
}

/* ── Ticket peek panel (js/tickets-list-peek.js) ─────────────────────────────
   The AI assistant panel's geometry, not its class: fixed to the right, slid in
   on .open, a flex column over an elevated surface. Cloned rather than reused
   because .ai-chat-panel also carries pin/resize/nudge behaviour and its own
   z-index, and the two must be able to coexist — an agent with the assistant
   open should still be able to peek at a row.

   NON-MODAL by construction: no backdrop, no page lock. The list stays
   scrollable and clickable underneath, which is the whole point — J and K keep
   walking the queue with the panel open. */
.ticket-peek-panel {
    position: fixed;
    top: 0;
    right: 0;
    bottom: 0;
    width: min(40vw, 34rem);
    max-width: 100vw;
    transform: translateX(100%);
    transition: transform var(--ctl-dur-slow) ease-out;
    background: var(--ctl-bg-elevated);
    border-left: 1px solid var(--ctl-border);
    box-shadow: -4px 0 16px var(--ctl-shadow-hover);
    z-index: var(--ctl-z-panel);
    display: flex;
    flex-direction: column;
    overflow-y: auto;
    scrollbar-gutter: stable;
}

.ticket-peek-panel.open { transform: translateX(0); }

@media (prefers-reduced-motion: reduce) {
    .ticket-peek-panel { transition: none; }
}

/* Phone tier (§8): 40% of a phone is a column of hyphens. */
@media (max-width: 767px) {
    .ticket-peek-panel { width: 100vw; }
}

/* The chrome band (§4.4) as a FLOOR, not a clamp. This head is TWO rows —
   .peek-head-top (number + actions) with .peek-subject under it — and the panel
   slides in over a list whose own header is already on the band, so it must
   never sit UNDER 52px; in the common case the subject carries it well past.
   That is why it keeps its own --ctl-sp-4 padding and takes min-height only:
   the zero-vertical-padding form the single-row heads use would collapse the
   subject against the bottom rule. A fixed height would crop it outright. */
.peek-head {
    position: sticky;
    top: 0;
    z-index: 1;
    min-height: var(--ctl-pane-head-h);
    padding: var(--ctl-sp-4);
    background: var(--ctl-bg-elevated);
    border-bottom: 1px solid var(--ctl-border);
}

.peek-head-top {
    display: flex;
    align-items: center;
    gap: var(--ctl-sp-2);
}

.peek-head-actions {
    margin-left: auto;
    display: flex;
    gap: var(--ctl-sp-2);
}

.peek-number {
    font-size: var(--ctl-fs-body-sm);
    color: var(--ctl-fg-muted);
}

.peek-subject {
    margin: var(--ctl-sp-2) 0 0;
    font-family: var(--ctl-font-display);
    font-size: var(--ctl-fs-display-sm);
    line-height: var(--ctl-lh-snug);
    color: var(--ctl-fg);
}

.peek-body {
    padding: var(--ctl-sp-4);
    display: flex;
    flex-direction: column;
    gap: var(--ctl-sp-5);
}

.peek-pills {
    display: flex;
    flex-wrap: wrap;
    gap: var(--ctl-sp-2);
}

.peek-people {
    display: flex;
    flex-direction: column;
    gap: var(--ctl-sp-2);
}

.peek-person {
    display: flex;
    align-items: center;
    gap: var(--ctl-sp-3);
}

.peek-person-key {
    flex: 0 0 6.5rem;
    font-size: var(--ctl-fs-caption);
    font-family: var(--ctl-font-brand);
    font-weight: 700;
    letter-spacing: 0.06em;
    text-transform: uppercase;
    color: var(--ctl-fg-subtle);
}

.peek-identity {
    display: inline-flex;
    align-items: center;
    gap: var(--ctl-sp-2);
    min-width: 0;
}

.peek-identity-name {
    color: var(--ctl-fg);
    font-size: var(--ctl-fs-body-sm);
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

.peek-section-title {
    display: flex;
    align-items: center;
    gap: var(--ctl-sp-2);
    margin-bottom: var(--ctl-sp-2);
    font-size: var(--ctl-fs-caption);
    font-family: var(--ctl-font-brand);
    font-weight: 700;
    letter-spacing: 0.06em;
    text-transform: uppercase;
    color: var(--ctl-fg-subtle);
}

/* The description is the sentence the row could not fit — clamped, because a
   peek that scrolls for a screen has stopped being a peek. */
.peek-description {
    font-size: var(--ctl-fs-body-sm);
    color: var(--ctl-fg);
    max-height: 11rem;
    overflow-y: auto;
    overscroll-behavior: contain;
}

.peek-description img { max-width: 100%; height: auto; }

.peek-activity {
    padding: var(--ctl-sp-2) 0;
    border-top: 1px solid var(--ctl-border);
}

.peek-activity:first-of-type { border-top: 0; }

.peek-activity-head {
    display: flex;
    align-items: baseline;
    gap: var(--ctl-sp-2);
    margin-bottom: var(--ctl-sp-1);
}

.peek-activity-author {
    font-weight: 600;
    font-size: var(--ctl-fs-body-sm);
    color: var(--ctl-fg);
}

.peek-activity-time {
    margin-left: auto;
    font-size: var(--ctl-fs-caption);
    color: var(--ctl-fg-muted);
    white-space: nowrap;
}

/* An internal note read as a customer reply is the expensive mistake this
   surface can cause, so it is marked twice: a rail down the entry and a word. */
.peek-activity.is-internal {
    border-left: 3px solid var(--ctl-warning);
    padding-left: var(--ctl-sp-3);
}

.peek-activity-tag {
    font-size: var(--ctl-fs-caption);
    font-weight: 700;
    text-transform: uppercase;
    letter-spacing: 0.06em;
    color: var(--ctl-warning-text);
}

.peek-activity-body {
    font-size: var(--ctl-fs-body-sm);
    color: var(--ctl-fg-muted);
    max-height: 7rem;
    overflow: hidden;
}

.peek-actions {
    position: sticky;
    bottom: 0;
    margin-top: auto;
    display: flex;
    gap: var(--ctl-sp-2);
    justify-content: flex-end;
    padding: var(--ctl-sp-3) var(--ctl-sp-4);
    background: var(--ctl-bg-elevated);
    border-top: 1px solid var(--ctl-border);
}

.peek-loading,
.peek-error {
    padding: var(--ctl-sp-6);
    text-align: center;
    color: var(--ctl-fg-muted);
    font-size: var(--ctl-fs-body-sm);
}

.peek-error { color: var(--ctl-danger-text); }

/* ── Detail page: Prev / Next through the originating queue ───────────────── */
.ticket-seq {
    display: inline-flex;
    align-items: center;
    gap: var(--ctl-sp-1);
}

.ticket-seq-pos {
    font-size: var(--ctl-fs-caption);
    color: var(--ctl-fg-muted);
    font-variant-numeric: tabular-nums;
    white-space: nowrap;
}

.ticket-seq .detail-tool.disabled {
    opacity: 0.4;
    pointer-events: none;
}

/* ── Workspace settings: a module card that is switched OFF ───────────────────
   /settings/workspaces/{id} is a switchboard — a stack of collapsed cards,
   five of them carrying an Active switch (Canned Responses, Ticket Bundles,
   Tags, CSAT Surveys, Time Tracking). Before this, the ONLY difference between
   an on and an off module was the position of a switch knob and one word of
   small muted text, so a desk with Tags and CSAT disabled looked identical to
   a desk with everything running until you read every row. The stakeholder's
   ask was literal: the card should say "I'm off".

   It says it in the sentence this app already speaks for a dormant object.
   `tr.list-row-closed` (further up this file) is the closed/terminal ticket
   row, and it uses exactly two channels — the ink drops to --ctl-fg-muted, and
   the surface sinks to --ctl-row-settled-bg so the thing stops reading as card
   material and rejoins the page ground it was lifted off. An off module card
   is the same statement about a bigger object, so it borrows the same token
   rather than inventing a colour. That is not just tidiness: CssTokenContract-
   Test already certifies --ctl-row-settled-bg as a correctly-directed, visible
   step below --ctl-bg-elevated (≥18 in RGB, never past --ctl-bg-hover) in ALL
   SIX sheets, so this treatment is right in Industrial / Refined / Operator ×
   light / dark for free, and re-tuning any sheet re-tunes the off state with
   it. No hex is spelled here for the same reason.

   Two directions were floated and rejected:
     · A DASHED outline. In this app a dashed border is the drop-target idiom
       (.attach-zone, .rich-dropzone) — borrowing it here would have read
       "drop a file", not "I'm off".
     · OPACITY on the card. Same lesson the settled row documents: a
       translucent container drags its own chrome and anything layered in it
       down with it, and the one control that must stay crisp lives inside
       this one. Muted ink over an opaque quieter surface needs nothing
       composited.

   Server-side the class comes from $moduleCardClass in the template; the
   switch script re-applies it from the endpoint's answer, so a flip re-reads
   the card immediately and a refused write never leaves a lying card. */
.ws-mod-card {
    transition: background-color var(--ctl-dur), border-color var(--ctl-dur),
                box-shadow var(--ctl-dur);
}

.ws-mod-card.is-off {
    background-color: var(--ctl-row-settled-bg);
    border-color: var(--ctl-border-subtle);
    /* The shadow is the "lifted off the page" cue. A card that has sunk back
       to the ground cannot still be casting one. */
    box-shadow: none;
}

/* Channel two — the head's ink. One declaration on the collapse button covers
   the chevron and the title together (both inherit it), and the summary count
   beside them is already .text-muted and lands on the same tone. That is the
   point: the whole head goes quiet at once, and the counts stay perfectly
   readable while it does, so an off card is still worth expanding.
   Specificity (0,3,0) clears .btn's own `color: var(--bs-btn-color)` AND its
   :hover restatement, so the head stays muted through hover rather than
   snapping back to full ink under the cursor. */
.ws-mod-card.is-off > .card-header .ws-mod-head {
    color: var(--ctl-fg-muted);
}

/* Expanded, the head's rule thins with the card's own border — otherwise an
   open off card is a muted head sitting under a full-strength line. */
.ws-mod-card.is-off > .card-header {
    border-bottom-color: var(--ctl-border-subtle);
}

/* …but NOT the switch. It is the way back ON, and greying out the control that
   un-greys the card is the trap in every off-state design. It keeps full ink,
   stays focusable, and keeps its hit area.

   It also has to keep its WELL. Bootstrap fills a check/switch track from
   --bs-form-check-bg → --bs-body-bg, i.e. the page ground — which is precisely
   the tone the card has just taken, so on an off card the track would dissolve
   into its own background and leave a knob floating on a hairline. Restating
   it to --ctl-bg-elevated inverts the card's move rather than following it,
   and it is the same pairing the settled-row contract already measures, so the
   control is guaranteed a visible well in all six sheets. The border steps up
   one rung with it. */
.ws-mod-card.is-off .ws-mod-toggle {
    --bs-form-check-bg: var(--ctl-bg-elevated);
    border-color: var(--ctl-border-strong);
}

/* And the state word holds its weight. Every status word in this app that
   MEANS something carries 500 (.text-status-active / -locked / -new); "Off" is
   one, and in a head that has just gone muted it would otherwise dissolve into
   the summary counts. Weight only — the colour stays .text-muted's, because
   this is a label, not an alarm. */
.ws-mod-card.is-off .ws-mod-toggle + .form-check-label {
    font-weight: 500;
}

/* ═══════════════════════════════════════════════════════════════════════════
   Service Desk settings — the two-pane admin surface (.sds-*)
   templates/settings/service_desk/

   One desk's configuration used to be a single scrolling stack of collapsed
   cards: to change one thing you scanned twelve headers, and the page's one
   Edit button opened every card at once. This surface splits that in two — a
   thin rail that answers "which desk, which section, and where do the
   site-wide settings live", beside a pane that renders exactly ONE section.

   The split is not only visual. app.js's inline-edit machinery is keyed on the
   singular ids #editForm / #editToggle / #editActions / #editCancel, so a page
   showing every section at once can only ever have one form and one Edit
   button for all of them. One section at a time gives each editable section
   its own form and its own toolbar, with no change to app.js at all.

   Tokens only — no literal colours, sizes or radii, so all six sheets and both
   densities come for free. Module sections keep wearing .ws-mod-card (further
   up this file); the only thing restated here is the one selector that block
   aims at the old collapse-button head, which this surface does not have.
   ═══════════════════════════════════════════════════════════════════════════ */

.sds-layout {
    display: grid;
    /* Fixed rail track + minmax(0,1fr) pane. Fixed because the rail's content
       is short labels, so a proportional share would pad the rail and squeeze
       the pane (§6.2's two-column rule, §8.2's "fixed grid tracks"). The 0
       floor on the pane is the min-content guard (§6.5): without it one long
       unbreakable string inside a section sets the whole column's width. */
    grid-template-columns: var(--sds-rail-w, 15.5rem) minmax(0, 1fr);
    gap: var(--ctl-sp-3);
    /* Rail is as tall as its own content — a grid item stretches by default,
       which would draw an empty card down the length of a long section. */
    align-items: start;
}

/* Phone + tablet (§8): the rail stops being a rail and becomes the block you
   scroll past on the way to the pane. One breakpoint, no re-layout of either
   half — the grid simply drops to one column. */
@media (max-width: 991px) {
    .sds-layout {
        grid-template-columns: minmax(0, 1fr);
    }
}

.sds-rail {
    margin-bottom: 0;
}

.sds-rail-body {
    display: flex;
    flex-direction: column;
    gap: var(--ctl-sp-4);
    padding: var(--ctl-sp-3);
}

/* Desk picker + the new-desk affordance share one row. */
.sds-rail-picker {
    display: flex;
    align-items: center;
    gap: var(--ctl-sp-2);
}

.sds-picker-form {
    display: flex;
    align-items: center;
    gap: var(--ctl-sp-2);
    /* min-width:0 so a long desk name shrinks the select instead of pushing
       the "+" out of the card. */
    flex: 1 1 auto;
    min-width: 0;
}

.sds-picker-form .form-select {
    min-width: 0;
    flex: 1 1 auto;
}

/* The submit button is the no-JS path: navigation here is a real GET form, and
   the script that submits on change is enhancement. Once it has run it says so
   (.has-js) and the button retires — nobody needs both. */
.sds-picker-form.has-js .sds-picker-go {
    display: none;
}

/* Wears .detail-tool (the app's 2rem icon control) — this only pins it against
   the shrinking select beside it and gives it the same settled state the nav
   items use when the create form is the open section. */
.sds-rail-add {
    flex: 0 0 auto;
}

.sds-rail-add.is-active {
    color: var(--ctl-accent-text);
    background: var(--ctl-bg-hover);
}

.sds-nav {
    display: flex;
    flex-direction: column;
    gap: var(--ctl-sp-1);
}

.sds-nav-item {
    display: flex;
    align-items: center;
    gap: var(--ctl-sp-2);
    padding: var(--ctl-sp-2) var(--ctl-sp-3);
    border-radius: var(--ctl-radius);
    color: var(--ctl-fg-muted);
    font-size: var(--ctl-fs-body-sm);
    text-decoration: none;
    transition: background-color var(--ctl-dur-fast), color var(--ctl-dur-fast);
}

.sds-nav-item:hover {
    background: var(--ctl-bg-hover);
    color: var(--ctl-fg);
}

/* The active section. Four channels, deliberately: ground, ink, weight and the
   left rail — because ground alone is one hover away from ambiguous, and the
   markup also carries aria-current so the state is never colour-only (§9).
   The rail is --ctl-accent-text rather than the raw accent: brand green as a
   3px mark on a light hovered surface measures 2.4:1, under 1.4.11's 3:1,
   while the -text variant is certified ≥4.5:1 on every sheet's worst surface. */
.sds-nav-item.is-active {
    background: var(--ctl-bg-hover);
    color: var(--ctl-fg);
    font-weight: 600;
    box-shadow: inset 2px 0 0 var(--ctl-accent-text);
}

.sds-nav-icon {
    width: 1rem;
    flex: 0 0 auto;
    text-align: center;
}

/* Site-wide settings, below a rule: they outrank any one desk, so they are
   separated from the desk's own sections rather than mixed into them. */
.sds-global {
    border-top: 1px solid var(--ctl-border);
    padding-top: var(--ctl-sp-3);
}

.sds-global-head {
    color: var(--ctl-fg-muted);
    margin-bottom: var(--ctl-sp-2);
}

.sds-pane {
    /* Grid item min-content guard (§6.5) — the pane holds section content this
       file does not author, including tables and stored descriptions. */
    min-width: 0;
}

.sds-card {
    margin-bottom: 0;
}

.sds-card-head {
    display: flex;
    align-items: center;
    gap: var(--ctl-sp-3);
    flex-wrap: wrap;
}

/* .detail-section-title carries a bottom margin for its stacked use further
   up the app; centred in a flex header it only pushes the title off the axis. */
.sds-card-head .detail-section-title {
    margin-bottom: 0;
}

/* Switch, Edit, Save/Cancel — whatever the section owns, pinned right. */
.sds-card-tools {
    margin-left: auto;
    display: flex;
    align-items: center;
    gap: var(--ctl-sp-3);
}

/* The count/summary line ("3 top-level", "80% / 95% · email"). It used to ride
   beside the card title as a muted span; promoted to a full pane it gets its
   own line and the tabular figures §4.6 asks of any digits that column-align. */
.sds-count {
    color: var(--ctl-fg-muted);
    font-size: var(--ctl-fs-body-sm);
    font-variant-numeric: tabular-nums;
    margin-bottom: var(--ctl-sp-2);
}

/* Channel two of the off-module treatment, re-aimed. The .ws-mod-card block
   mutes the head through `.card-header .ws-mod-head` — the collapse BUTTON,
   which this surface does not render because its sections are already open.
   The head here is the section title, so the same statement is made on it.
   The card's ground, border, shadow, switch well and label weight all come
   from that block unchanged. */
.ws-mod-card.is-off > .sds-card-head .detail-section-title {
    color: var(--ctl-fg-muted);
}

/* ───────────────────────────────────────────────────────────────────────────
   Global sections (phase 2a) — the site-wide pages, relocated into the pane
   templates/settings/service_desk/sections/global_*.php

   SLA Alert Defaults, CSAT and Time Tracking used to be three standalone
   pages the rail could only point AT. They now render in the pane like any
   other section, which leaves two shapes under the one "Global" heading: the
   sections that open here, and the two links (Business Hours, Holiday
   Calendars) that still leave the page. Everything below is the geometry
   those relocated bodies used to get from being a whole page — nothing here
   is a new component, and there are no colour or radius literals.
   ─────────────────────────────────────────────────────────────────────────── */

/* The links-out run, under the sections that open in this pane. A hairline and
   nothing else: the two runs are one group (same heading) doing two things, so
   the separation is the same weight as the one above the whole block, not a
   louder statement about second-class items. */
.sds-global-links {
    margin-top: var(--ctl-sp-2);
    padding-top: var(--ctl-sp-2);
    border-top: 1px solid var(--ctl-border);
}

/* Each relocated page arrived as three or four CARDS. Inside a pane whose
   section already IS a card, those would be cards in a card — so they step
   down to headings (§6.2, "section headings step down") and the rule is what
   the card border used to say. Same hairline as .sds-global for the same
   reason: one separator weight on this surface. */
.sds-subsection {
    margin-top: var(--ctl-sp-4);
    padding-top: var(--ctl-sp-4);
    border-top: 1px solid var(--ctl-border);
}

/* A sub-section head that shares its row with one control (Manage →, Send
   Test). .detail-section-title carries a bottom margin for its stacked use, so
   the row owns the spacing instead — the same trade .sds-card-head makes. */
.sds-subsection-head {
    display: flex;
    align-items: center;
    gap: var(--ctl-sp-3);
    margin-bottom: var(--ctl-sp-2);
}

.sds-subsection-head .detail-section-title {
    margin-bottom: 0;
    margin-right: auto;
}

/* Inputs whose CONTENT has a known width — a 1–99 percentage, a trigger status,
   a 3-letter currency code. The standalone pages pinned these with inline
   `style="width:100px"`; named here so the numbers are in the sheet with
   everything else, and expressed as max-width so they still shrink on a phone
   where the pane is the full column. */
.sds-input-xs {
    max-width: 7rem;
}

.sds-input-sm {
    max-width: 14rem;
}

.sds-input-upper {
    text-transform: uppercase;
}

/* The survey email preview. Wears the app's existing .email-preview-frame
   shell (further up this file, which owns the always-light ground and the
   seam); this is only the iframe's own box. Taller than the standalone page's
   480px is pointless — the frame clamps to 500px and scrolls — so the height
   matches the frame's ceiling and the width takes the whole pane, which is
   MORE than the two-column standalone layout ever gave it. */
.sds-email-preview {
    display: block;
    width: 100%;
    height: 30rem;
    border: 0;
    border-radius: var(--ctl-radius);
}

/* CSAT rating distribution: five rows of stars · number · bar · count. Fixed
   tracks for the three label columns so the bars share one left and one right
   edge — a bar chart whose baseline wanders is not a chart. The bar's own
   width is the only inline style on the row, because it is the datum. */
.sds-dist-row {
    display: flex;
    align-items: center;
    gap: var(--ctl-sp-2);
    margin-bottom: var(--ctl-sp-1);
}

.sds-dist-stars {
    flex: 0 0 5rem;
    white-space: nowrap;
}

.sds-dist-num {
    flex: 0 0 1rem;
    text-align: right;
    font-variant-numeric: tabular-nums;
}

.sds-dist-bar {
    flex: 1 1 auto;
    height: 0.875rem;
}

.sds-dist-count {
    flex: 0 0 2.5rem;
    text-align: right;
    font-variant-numeric: tabular-nums;
}

/* ───────────────────────────────────────────────────────────────────────────
   Drill-in states (phase 2b) — the sections that own a LIST as well as a form
   templates/settings/service_desk/sections/{business_hours,holiday_calendars}/

   Business Hours and Holiday Calendars are not one form each: they are a list,
   then one record, then (for calendars) a create form. That is three states in
   one pane, and the only thing the pane needs beyond what is already above is a
   way to say WHICH record is open, and a way to seat a full-width table under
   prose inside a card. No new component — the table is simple_table.php, the
   back arrow is .detail-tool, the card is .sds-card.
   ─────────────────────────────────────────────────────────────────────────── */

/* The record a list section is drilled into, named beside the section's title.
   Not a second title: the section keeps the rail's name (they must never
   disagree) and this is the answer to "which one". Body scale rather than the
   head's caption caps, because it is the record's own words — §6.2's rule for a
   detail title — and muted-to-fg so the pairing reads as one line, not two
   headings. min-width:0 + ellipsis so a long calendar name shrinks instead of
   pushing the toolbar off the card. */
.sds-card-subject {
    min-width: 0;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
    color: var(--ctl-fg);
    font-size: var(--ctl-fs-body-sm);
    font-weight: 600;
}

/* Prose above an edge-to-edge table. A .card-body would box the paragraph and
   then indent the table with it; the table wants the card's full width (its
   own first/last cells already carry the content padding, see
   .ctl-table-simple), so the prose takes the padding on its own and closes on
   the table. */
.sds-pane-intro {
    padding: var(--ctl-sp-4) var(--ctl-sp-4) 0;
    margin-bottom: var(--ctl-sp-3);
    color: var(--ctl-fg-muted);
    font-size: var(--ctl-fs-body-sm);
}

/* Column widths the relocated pages pinned with inline style="width:Npx".
   Named here because a fixed column width is a layout decision, not a datum —
   the one thing an inline style is still for (§11). rem, so they track the
   density scale the pixels never did. */
.sds-col-day    { width: 9rem; }
.sds-col-date   { width: 8rem; }
.sds-col-type   { width: 7rem; }
.sds-col-window { width: 9.5rem; }
.sds-col-flag   { width: 5.5rem; }
.sds-col-actions { width: 5rem; }

/* The destructive control, and the quotable id under it. Both are footers to a
   record rather than part of it, so they stand off the last sub-section by the
   same step the sub-sections use between themselves — and the delete form takes
   the rule as well, because "everything below this line is irreversible" is
   worth a line. */
.sds-danger-row {
    margin-top: var(--ctl-sp-4);
    padding-top: var(--ctl-sp-4);
    border-top: 1px solid var(--ctl-border);
}

.sds-record-id {
    margin-top: var(--ctl-sp-3);
}

/* ───────────────────────────────────────────────────────────────────────────
   Categories (phase 3) — the first DESK-SCOPED section with item state
   templates/settings/service_desk/sections/categories/

   Same three states as the two site-wide drill-ins, so everything above is
   already doing the work: .sds-pane-intro over the list table, .sds-subsection
   for each of the relocated cards, .sds-card-subject for the open record,
   .sds-danger-row / .sds-record-id for the footers. What is added here is the
   two things categories brought with them — a link OUT under the list, and the
   fixed column and control widths the standalone pages pinned with inline
   `style="width:Npx"` (§11: a fixed width is a layout decision, not a datum).
   No new component, no colour or radius literals.
   ─────────────────────────────────────────────────────────────────────────── */

/* The way to the standalone list, under the table. Mirrors .sds-pane-intro's
   padding so the prose above and the link below share the card's inline
   measure, and closes on the card's own bottom padding. */
.sds-pane-foot {
    padding: var(--ctl-sp-3) var(--ctl-sp-4) var(--ctl-sp-4);
    margin-bottom: 0;
}

/* List columns. Code and the two counts have known content widths, so they take
   what they need and leave the rest to Name. */
.sds-col-code  { width: 8rem; }
.sds-col-count { width: 7rem; }
/* Wider than .sds-col-flag next door because this one's HEADER is the long
   part ("Intake Form"), not its cell — a two-word head over a single glyph. */
.sds-col-form  { width: 7.5rem; }

/* Detail: the escalation ladder's fixed columns (rank, threshold, row tools) and
   the subcategory code box, all of which were inline pixel widths. */
.sds-col-rank     { width: 2.5rem; }
.sds-col-mins     { width: 8rem; }
.sds-col-rowtools { width: 6rem; }

.sds-input-code {
    max-width: 6rem;
}

/* An input-group riding a .detail-row's value column. The row is a flex line
   whose value track must not be sized by the group's intrinsic width, so it
   grows and is allowed to shrink — the `flex:1;min-width:0` the relocated page
   carried inline on five controls. */
.sds-input-grow {
    flex: 1;
    min-width: 0;
}
