/* Operator console — app-level styles on top of CDN Bootstrap 5.3.
   Keep this small: Bootstrap utilities first; only add here what Bootstrap
   can't express. Everything must be theme-aware (works in light + dark). */

/* #1333 D8/C13: the navbar-brand logo swap — a dark-ink wordmark reads on a light navbar and
   vanishes on the near-black dark one, and vice versa, so a `logo_dark` variant (when the
   manifest supplies one) shows only under [data-bs-theme="dark"]; without one, `.brand-logo-dark`
   never renders (base.html only emits it when `branding.logo_dark_url` is set), so the single
   `.brand-logo-light` element serves both themes unconditionally. */
.brand-logo-dark { display: none; }
[data-bs-theme="dark"] .brand-logo-light:has(~ .brand-logo-dark) { display: none; }
[data-bs-theme="dark"] .brand-logo-dark { display: inline-block; }

/* ── Visual foundation (v1.2 P1, EPIC #489 / design-system §3) ─────────────────
   The co-designed visual language for the v1.2 sweep — settled before the rest of
   the EPIC builds so every later phase inherits it. Two parts: OLED dark tokens (#482)
   and a moderate density step (#483). Light theme is intentionally untouched. */

/* #482 — OLED-friendly dark theme. Bootstrap 5.3's dark default is grey (body #212529);
   on OLED that's a washed, battery-hungry canvas. We drop the canvas to near-black and
   layer raised surfaces ABOVE it so elevation still reads (a pure-#000 canvas would flatten
   borders/cards). Elevation model: canvas #0a0a0a → raised surface #141416 (cards, list
   groups, table) → chrome #1c1c1e (navbar via bg-body-tertiary, dropdowns). Borders are
   lightened to #2c2c2e since faint borders vanish on near-black. All accent/state hues are
   unchanged (still WCAG-checked in §4.1 / accessibility-review). */
[data-bs-theme="dark"] {
  --bs-body-bg: #0a0a0a;
  --bs-body-bg-rgb: 10, 10, 10;
  --bs-secondary-bg: #161616;
  --bs-secondary-bg-rgb: 22, 22, 22;
  --bs-tertiary-bg: #1c1c1e;
  --bs-tertiary-bg-rgb: 28, 28, 30;
  --bs-border-color: #2c2c2e;
  --bs-border-color-translucent: rgba(255, 255, 255, 0.12);
}
/* Cards/list-groups redefine --bs-*-bg locally (= body-bg by default), so a :root token
   won't reach them — set the raised surface on the component vars directly. */
[data-bs-theme="dark"] .card { --bs-card-bg: #141416; }
[data-bs-theme="dark"] .list-group { --bs-list-group-bg: #141416; }
[data-bs-theme="dark"] .table { --bs-table-bg: #141416; }

/* EPIC #940 fix wave A2 (U6) — .btn-outline-secondary is unreadable on the OLED dark surfaces.
   Bootstrap generates the outline variants from $theme-colors and never restyles them per theme,
   so its label stays gray-600 (#6c757d) in both. MEASURED against the surfaces defined right above:
   3.92:1 on a card/list-group (#141416) and 3.63:1 on chrome (#1c1c1e) — under the 4.5:1 AA floor
   for normal text. Systemic, not local to any one surface, so the fix is the shared token and not
   an override on the button that happened to be measured.
   gray-400 (#adb5bd, already in the Bootstrap palette) takes the label to 8.87:1 / 8.20:1. The
   BORDER stays gray-600: 3.92:1 clears the 3:1 non-text floor, and lightening it too would make an
   outline-secondary control read louder than the primary actions beside it. Hover/active fill
   inverts to near-black text (10.12:1) because white on the lighter fill would not clear 4.5:1. */
[data-bs-theme="dark"] .btn-outline-secondary {
  --bs-btn-color: #adb5bd;
  --bs-btn-hover-bg: #adb5bd;
  --bs-btn-hover-border-color: #adb5bd;
  --bs-btn-hover-color: #0a0a0a;
  --bs-btn-active-bg: #adb5bd;
  --bs-btn-active-border-color: #adb5bd;
  --bs-btn-active-color: #0a0a0a;
}

/* #970 item 1 — .btn-outline-warning is 1.63:1 on white in LIGHT theme, the app's worst pairing —
   and it reaches this release's own impact-surface Reject/Withdraw controls. Bootstrap's raw
   $warning (#ffc107) is a near-white amber; used as label+border text on a plain white/near-white
   surface it fails both the 4.5:1 text floor and the 3:1 non-text (border) floor at the same
   1.63:1. Reuses Bootstrap's OWN --bs-warning-text-emphasis token (#664d03 in light mode — already
   shipped for .alert-warning / .text-bg-warning) rather than inventing a new colour: 7.99:1 on
   white, well clear of AA, and the border gets the same value so it also clears 3:1. Hover/active
   fill is untouched — it already passes (black label on #ffc107 = 12.88:1).
   Dark theme is untouched: the raw #ffc107 label already clears AA against the OLED surfaces (it
   is not in #970's KNOWN_BELOW_AA), so scoping this to light-only avoids darkening a token that
   already works there. */
[data-bs-theme="light"] .btn-outline-warning {
  --bs-btn-color: #664d03;
  --bs-btn-border-color: #664d03;
}

/* #970 item 3 — .btn-outline-danger label AND .text-danger (the app's required-field asterisks,
   e.g. `templates/prospects/_identity_card.html`) are Bootstrap's raw $danger (#dc3545) in BOTH
   places it appears, unretoned per theme. MEASURED (#968's client-tier sweep): 4.06:1 on the OLED
   raised card surface (#141416) and 4.37:1 on the OLED canvas (#0a0a0a) — both under the 4.5:1 AA
   text floor. Same fix shape as U6/#970-item-1: reuse Bootstrap's OWN --bs-danger-text-emphasis
   dark token (#ea868f, already shipped for .alert-danger / .text-bg-danger) rather than inventing a
   colour. 7.28:1 on the card surface, 7.83:1 on canvas — clear of AA on both measured surfaces.
   Hover/active fill inverts to near-black text (7.83:1) for the same reason as U6: white on the
   lighter fill would not clear 4.5:1. Light theme untouched — .text-danger/.btn-outline-danger are
   not in #968's KNOWN_BELOW_AA for light, so raw $danger already works there.
   #1273: `disabled` reads its OWN pair of Bootstrap vars (`--bs-btn-disabled-color` /
   `-disabled-border-color`, unset here until now) rather than falling through to `--bs-btn-color`
   above — so the disabled state stayed on raw $danger (4.06:1) even after the enabled fix landed.
   WCAG 1.4.3 exempts inactive controls, so this was never a conformance failure — same-class fix
   for consistency (every disabled outline-danger button, e.g. the Codex Revoke button), reusing
   the SAME already-validated token (7.28:1 / 7.83:1) rather than a new colour. */
[data-bs-theme="dark"] .btn-outline-danger {
  --bs-btn-color: #ea868f;
  --bs-btn-hover-bg: #ea868f;
  --bs-btn-hover-border-color: #ea868f;
  --bs-btn-hover-color: #0a0a0a;
  --bs-btn-active-bg: #ea868f;
  --bs-btn-active-border-color: #ea868f;
  --bs-btn-active-color: #0a0a0a;
  --bs-btn-disabled-color: #ea868f;
  --bs-btn-disabled-border-color: #ea868f;
}
[data-bs-theme="dark"] .text-danger {
  color: #ea868f !important;
}

/* #970 item 4 — .btn-outline-success label is Bootstrap's raw $success (#198754) in dark theme,
   same defect shape as items 1/3: 4.06:1 on the OLED card, 4.37:1 on OLED canvas — both under AA.
   Reuses --bs-success-text-emphasis dark (#75b798, already shipped for .alert-success /
   .text-bg-success): 7.87:1 / 8.47:1. Hover/active fill inverts to near-black text (8.47:1), same
   reasoning as U6/item 3. Light theme untouched — not in KNOWN_BELOW_AA there. */
[data-bs-theme="dark"] .btn-outline-success {
  --bs-btn-color: #75b798;
  --bs-btn-hover-bg: #75b798;
  --bs-btn-hover-border-color: #75b798;
  --bs-btn-hover-color: #0a0a0a;
  --bs-btn-active-bg: #75b798;
  --bs-btn-active-border-color: #75b798;
  --bs-btn-active-color: #0a0a0a;
}

/* #970 item 5 — .btn-outline-secondary in LIGHT theme was never measured when U6 fixed the dark
   pairing (that fix's own docstring above says as much): 4.43:1 on the tinted card-header surface
   (#f8f8f9-ish, rgb(248,248,249)) — just under the 4.5:1 floor. gray-700 (#495057, already in the
   Bootstrap palette — same family U6 used for the border) takes the label to 7.7:1. Hover/active
   fill inverts to white text (8.18:1) — mirrors U6's dark-theme inversion, applied to light's
   darker fill. Dark theme untouched — U6 already owns it. */
[data-bs-theme="light"] .btn-outline-secondary {
  --bs-btn-color: #495057;
  --bs-btn-hover-bg: #495057;
  --bs-btn-hover-border-color: #495057;
  --bs-btn-hover-color: #fff;
  --bs-btn-active-bg: #495057;
  --bs-btn-active-border-color: #495057;
  --bs-btn-active-color: #fff;
}

/* #970 item 6 — <code> (--bs-code-color, Bootstrap's raw pink #d63384, light theme) falls below AA
   on FOUR tinted surfaces the app actually renders it on: 4.27:1 on .bg-light, 4.25:1 on the tinted
   card-header, 3.50:1 on a secondary-bg panel (the worst — #968's `/admin/grounding/skill`
   `<code>'unset'</code>`), 4.06:1 on .bg-warning-subtle. No Bootstrap *-text-emphasis token exists
   for pink (it isn't a theme colour), so this is an app-defined retone: #a52d72 (darker than the
   #b02a6f first tried — that landed the worst surface at 4.80:1, a margin too thin against
   rendering/rounding variance) clears all four measured surfaces: 6.19 / 6.15 / 5.08 / 5.89:1.
   Static text — <code> carries no hover/interaction state. Dark theme untouched: --bs-code-color
   dark (#e685b5) is not in KNOWN_BELOW_AA. */
[data-bs-theme="light"] {
  --bs-code-color: #a52d72;
}

/* #970 item 7 — .btn-outline-primary label AND .text-primary (the week-calendar "today" column
   heading, `templates/partials/_week_calendar.html`) are Bootstrap's raw $primary (#0d6efd) in
   BOTH themes, unretoned per theme — same defect shape as items 3/4 but hitting both. MEASURED:
   dark 4.09:1 (OLED card) / 4.40:1 (OLED canvas) / 3.78:1 (OLED chrome, the today marker) / 3.26:1
   (inside .alert-warning's dark tint — the Engagement tab's "Run debrief" button); light 4.25:1
   (tinted card-header) / 4.27:1 (.bg-light, the today marker) / 4.06:1 (inside .alert-warning
   light). Reuses Bootstrap's OWN --bs-primary-text-emphasis token per theme (dark #6ea8fe, light
   #052c65 — already shipped for .alert-primary / .text-bg-primary): dark clears every measured
   surface at 6.07–8.20:1; light clears at 11.58–12.81:1. Dark hover/active fill inverts to
   near-black text (8.20:1), same U6/item-3/4 reasoning. Light hover/active is UNCHANGED — the
   default filled #0d6efd background with white label already clears AA (4.50:1) on its own, so
   retoning it would be scope creep onto a pairing that isn't broken.
   SCOPE DELIBERATELY NARROW: only .btn-outline-primary and .text-primary. The global --bs-primary
   variable is NOT touched — filled .btn-primary, plain links (--bs-link-color derives from the same
   raw $primary but is a DIFFERENT token BY DESIGN) and focus rings must read exactly as they did
   before this fix. Two plain-link KNOWN_BELOW_AA entries reading the SAME raw #0d6efd
   (default card-header "View all →" / "View campaign rollup →", `templates/dashboard.html`) are
   deliberately left un-retoned by this rule for that reason — see the test baseline for the one
   named exception this release fixes explicitly (the Engagement-tab "View all →" link, which the
   template now opts into this SAME token via an explicit `.text-primary` class rather than a CSS
   selector reaching into --bs-link-color). */
[data-bs-theme="dark"] .btn-outline-primary {
  --bs-btn-color: #6ea8fe;
  --bs-btn-hover-bg: #6ea8fe;
  --bs-btn-hover-border-color: #6ea8fe;
  --bs-btn-hover-color: #0a0a0a;
  --bs-btn-active-bg: #6ea8fe;
  --bs-btn-active-border-color: #6ea8fe;
  --bs-btn-active-color: #0a0a0a;
}
[data-bs-theme="light"] .btn-outline-primary {
  --bs-btn-color: #052c65;
}
/* .text-primary is Bootstrap's OWN utility, generated with `!important` off --bs-primary-rgb — an
   unqualified `color:` here would lose the cascade to it, so this needs `!important` too (same
   reasoning as the `.text-danger` override above). */
[data-bs-theme="dark"] .text-primary {
  color: #6ea8fe !important;
}
[data-bs-theme="light"] .text-primary {
  color: #052c65 !important;
}

/* #970 fold-in — the week-calendar day cell's done/total counter (#514) used `.text-body-tertiary`
   (--bs-tertiary-color, a 50%-opacity token) and measured 4.33:1 dark / 3.12:1 light on the day
   card surfaces — below AA in both themes. Not a hue problem (the token already follows the
   theme), an OPACITY problem: 50% is too faint at this text's size. `.text-body-secondary`
   (--bs-secondary-color, 75%-opacity — the same family, one step less faint) clears both measured
   surfaces at 8.3:1 dark / 6.78:1 light. Fixed in the template
   (`templates/partials/_week_calendar.html`), not here — this comment documents why the class
   changed. */

/* #483 — moderate density step. A 15px root rescales ALL rem-based type + spacing
   ~6% tighter at once (the single highest-leverage knob); table line-height + card
   padding then tighten the data surfaces a bit more. Net ≈ 15–20% more rows on screen,
   still comfortably readable. Per-surface tuning happens in later phases. */
html { font-size: 15px; }
.table { line-height: 1.35; }
.card-body { padding: 0.65rem 0.9rem; }
.card-header { padding-top: 0.5rem; padding-bottom: 0.5rem; }

/* ── Entity identity (#359 D6) ────────────────────────────────────────────
   "Same skeleton, different accent." Each entity type carries one accent hue,
   single-sourced in state/entity_meta.py and applied ONLY via the entity_header
   / entity_chip macros. The three hues are deliberately OFF the state-semantic
   palette (success=green / info=cyan / warning=yellow / danger=red / secondary=
   grey) so an entity accent is never confused with a pipeline-state meaning.
   They're used on icons / thin rules / outline chips (never as a fill behind
   body text). The hues are theme-adaptive (darker on light bg, lighter on dark
   bg) so the icon/rule/border accents clear WCAG 3:1 non-text contrast in BOTH
   themes. */
.entity-pursuit { --entity-accent: #0a58ca; } /* blue   — the deal */
.entity-person  { --entity-accent: #0f766e; } /* teal   — the human */
.entity-org     { --entity-accent: #7c3aed; } /* violet — the company */
[data-bs-theme="dark"] .entity-pursuit { --entity-accent: #6ea8fe; }
[data-bs-theme="dark"] .entity-person  { --entity-accent: #2dd4bf; }
[data-bs-theme="dark"] .entity-org     { --entity-accent: #a78bfa; }

/* #479 search surfaces two non-spine result kinds (notes, activity). They get their own accents
   on the same discipline — OFF the state palette, distinct from the 3 entity hues, theme-adaptive,
   3:1 non-text contrast — so a search section is identifiable by hue + icon + label. */
.entity-note     { --entity-accent: #b45309; } /* amber — the note */
.entity-activity { --entity-accent: #be185d; } /* rose  — the comm / event */
[data-bs-theme="dark"] .entity-note     { --entity-accent: #fbbf24; }
[data-bs-theme="dark"] .entity-activity { --entity-accent: #f472b6; }

.entity-accent-icon { color: var(--entity-accent); }

/* Thin accent rule under an entity header (pairs with .border-bottom). */
.entity-rule { border-bottom-color: var(--entity-accent) !important; border-bottom-width: 2px !important; }

/* Outline pill naming the entity type. Accent = border + icon only; the LABEL stays
   body-colored so chip text always clears 4.5:1 (the accent hues are not text-safe on
   light bg, and shouldn't carry meaning by color alone anyway). */
.entity-chip {
  display: inline-flex;
  align-items: center;
  gap: .25rem;
  color: var(--bs-secondary-color);
  border: 1px solid var(--entity-accent);
  border-radius: 999px;
  padding: .05rem .5rem;
  font-size: .75rem;
  font-weight: 600;
  line-height: 1.4;
  white-space: nowrap;
}

/* ── Theme-aware table-row flags (#359 D5) ────────────────────────────────────
   Bootstrap's contextual table variants (.table-warning/.table-danger/…) are FIXED
   light colors regardless of data-bs-theme — glaring bands in dark mode. These drive
   --bs-table-bg from the theme-ADAPTIVE *-bg-subtle tokens, so a flagged row keeps its
   semantic hue (warning/danger) AND follows the theme. Use these for row highlights
   instead of the fixed .table-* contextual classes (enforced by the token gate). */
.row-flag-warning { --bs-table-bg: var(--bs-warning-bg-subtle); }
.row-flag-danger  { --bs-table-bg: var(--bs-danger-bg-subtle); }

/* ── Calendar group disclosure (#440 UAT) ─────────────────────────────────────
   A consolidated (×N) calendar card is a <details> group: a stack glyph + ×N counter
   signal it expands. Hide the native marker; a subtle hover reinforces it's clickable. */
details.cal-group > summary::-webkit-details-marker { display: none; }
details.cal-group > summary { transition: filter .12s ease-in-out; }
details.cal-group > summary:hover { filter: brightness(1.15); }

/* ── Completed calendar card (#514) ───────────────────────────────────────────
   Done activities carry THREE signals so they read at a glance without relying on
   opacity alone (a11y: never color-only): the green check (in the macro), the
   bg-success-subtle tint (utility), and a left success accent bar (here). Theme-aware
   via the Bootstrap success token. */
.cal-done { border-inline-start: 3px solid var(--bs-success) !important; }

/* ── Campaign calendar card (#678) ────────────────────────────────────────────
   Campaign-attributed touchpoints and content releases carry a distinct info-toned
   left accent in BOTH the planned and the done lanes, so they read as "campaign" at a
   glance (alongside the megaphone glyph + campaign badge in the macro — never color-only).
   Defined AFTER .cal-done so a done campaign item shows the info accent (campaign grouping
   wins the bar); done-ness still reads via the green check + bg-success-subtle tint. */
.cal-campaign { border-inline-start: 3px solid var(--bs-info) !important; }

/* ── Tentative (planned-only) content release (#695) ──────────────────────────
   A content draft placed on the calendar by its planned_for date only (not yet scheduled
   through the approve gate) reads as tentative: a DASHED info accent (vs the solid campaign
   bar) — always paired with a "planned" text badge + the megaphone glyph, never color-only. */
.cal-tentative { border-inline-start: 3px dashed var(--bs-info) !important; }

/* ── Global search (#479; always-on since #600) — persistent input + magnifier submit + dropdown.
   The input is always visible on desktop (the nav-hub consolidation freed the space); the magnifier
   is the submit button. In the mobile hamburger it's full-width. The dropdown panel is absolutely
   positioned under the input. All colors come from theme-aware --bs-* tokens (design-system §3). */
.global-search-input {
  width: 14rem;
  padding-left: .5rem;
  padding-right: .5rem;
  opacity: 1;
  border-color: var(--bs-border-color);
}
.global-search-results {
  top: 100%;
  margin-top: .25rem;
  width: 22rem;
  max-width: 90vw;
  max-height: 70vh;
  overflow-y: auto;
  z-index: 1030;
}
.global-search-results:empty { display: none; }
.search-result:hover { background: var(--bs-tertiary-bg); }
.search-result:focus-visible {
  background: var(--bs-tertiary-bg);
  outline: 2px solid var(--bs-primary);
  outline-offset: -2px;
}

/* #479 — color-coded result sections. The group carries an entity-* accent class (sets
   --entity-accent); a 3px left rule + the accent-colored icon make each section identifiable at a
   glance. The label stays body-colored (the accent hues aren't text-contrast-safe — design-system
   §4). The count badge tints its border with the accent without using it as a fill. */
.search-group { border-left: 3px solid var(--entity-accent); padding-left: .55rem; }
.search-group-head .search-count {
  border-color: var(--entity-accent) !important;
  color: var(--bs-secondary-color);
}

/* Flex-truncation helper: a flex item defaults to min-width:auto (its content size) and so refuses
   to shrink, defeating .text-truncate. Bootstrap 5 ships no min-width:0 utility — this is it.
   Used on the pursuit-detail Back/name row (#481) so a long pursuit name ellipsises instead of
   overflowing the viewport. */
.min-w-0 { min-width: 0; }

/* Theme-aware matched-term highlight. Bootstrap's <mark> is a fixed light-yellow → unreadable on
   the OLED dark canvas; drive it off the warning subtle/emphasis token pair so it follows the theme
   and clears contrast in both. */
mark, .search-hl {
  background-color: var(--bs-warning-bg-subtle);
  color: var(--bs-warning-text-emphasis);
  padding: 0 .12em;
  border-radius: .15rem;
}

/* The /search page renders each group as a compact 2-column table for a clean name edge + aligned
   context column; category-filter checkboxes toggle a group's visibility client-side. */
.search-cat-filter input:not(:checked) ~ * { opacity: .55; }
.search-table td { vertical-align: middle; }

/* Below lg the navbar collapses to a hamburger — show the input inline (skip the icon-expand). */
@media (max-width: 991.98px) {
  .global-search { width: 100%; margin-top: .5rem; }
  .global-search-input {
    width: 100% !important;
    opacity: 1 !important;
    padding-left: .5rem;
    padding-right: .5rem;
    border-color: var(--bs-border-color);
  }
  .global-search-results { position: static !important; width: 100%; }
}

/* #1784: at collapsed widths the identity dropdown (`#nav-identity`'s `.dropdown-menu
   dropdown-menu-end`, all three `base.html` branches share this class list, so one rule covers
   them all — AC3) could render with Popper's runtime `left`/`transform` positioning clipping the
   menu against the viewport edge — the collapsed layout stacks `.dropdown` at a small x-offset
   with little room on either side, so any fixed-width, absolutely-positioned menu anchored off
   that parent is one wide render away from overflowing. A right-anchored CSS clamp fixed the
   symptom for one measured content width but not the class of bug — Bootstrap's own convention
   for a collapsed navbar dropdown is to drop Popper's positioning entirely and let the menu flow
   STATIC and full-width IN-FLOW below its toggle, which cannot overflow regardless of content
   width. `transform: none !important` kills Popper's inline `translate3d` (how Bootstrap 5.3
   positions dropdowns, not `left`/`top`) so the static flow position wins. Desktop (>=992px)
   unchanged — Popper still positions the menu normally there and this block never applies. */
@media (max-width: 991.98px) {
  #nav-identity + .dropdown-menu {
    position: static !important;
    transform: none !important;
    width: 100%;
    max-width: 100%;
    left: auto;
    right: auto;
  }
}

/* === #484 — v1.2 Phase 6 mobile responsive sweep ============================
   A deliberate mobile pass (audit + measures in EPIC #489). Three things the
   Bootstrap defaults didn't cover at ~380px:
   1. Activity note preview — a <button> keeps its content width even as a block
      element, so text-truncate (nowrap) let a long note blow out the page width
      (a latent DESKTOP bug too). Wrap + clamp to two lines instead. Not in a media
      query — the overflow is width-independent. */
.activity-note {
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
  max-width: 100%;
  white-space: normal;
}

@media (max-width: 991.98px) {
  /* 2. Wide list tables (people/orgs/pursuits) scroll inside .table-responsive;
        keep the first column (the entity name — the row's anchor) pinned so it
        stays visible while the rest scrolls. Solid bg so scrolled cells don't
        bleed through; the de-emphasised closed (table-active) rows match. */
  .table-sticky-first > thead > tr > th:first-child,
  .table-sticky-first > tbody > tr > td:first-child {
    position: sticky;
    left: 0;
    background-color: var(--bs-body-bg);
    z-index: 1;
  }
  .table-sticky-first > tbody > tr.table-active > td:first-child {
    background-color: var(--bs-secondary-bg);
  }
}

/* EPIC #940 fix wave A2 (U1) — the touch-target floor keys on VIEWPORT WIDTH **OR** A COARSE
   POINTER, in one media-query list, declared once.

   Measured defect: this rule used to live inside the `max-width: 991.98px` block above, so above
   that width there was no floor at all — the impact resolution queue's Confirm control measured
   29.2px at 1280×720 against 44px at 375×812. Width was standing in for "is this a touch device",
   and it is wrong in both directions: a tablet in landscape (wide viewport, finger) got no floor,
   which is the half that matters.

   `(pointer: coarse)` asks the real question. The width clause stays because it is what a
   narrow-window mouse user gets, and because dropping it would change behaviour on the one
   configuration this has always covered. Deliberately NOT unconditional (operator ruling): an
   unconditional floor would resize consequential controls on every desktop surface in the app for
   a mis-tap hazard a mouse does not have.

   Everything below is the SAME set the width block carried — moved wholesale, not copied, because
   two hand-maintained mirrors of one rule is how this release already shipped one defect. */
@media (max-width: 991.98px), (pointer: coarse) {
  /* 3. Touch targets — the icon-only nav controls are btn-sm (~30×29) and the
        hamburger / nav links sit under the ~44px minimum. Bump on mobile.
        #cal-type-filter (week-calendar header, btn-group-sm ~30×29) shares the
        same gap — folded in here rather than a new rule.
        .copy-attribution-btn (#812: content_detail.html's Sources & assets panel copy
        button, ~29px) shares the same gap — folded in here too.
        #asset-header .btn (#819: the asset-detail Archive / Restore / Fetch-full-text
        submits, btn-sm py-2 ~37px) shares it as well. These are consequential submits —
        one of them soft-deletes — so they are exactly the controls a mis-tap must not
        reach. Folded in rather than given a bespoke rule, per the same precedent.
        (`#global-search-toggle` was dropped here by #968's client-tier coverage gate: the
        expand/collapse search toggle it named was removed from the navbar when global search
        became an always-on input, and the selector had matched nothing since. Confirmed by a
        whole-tree search — the only remaining references were this rule and the tests using it
        as a text anchor to locate this block.) */
  #theme-toggle,
  /* #985: the demo-mode toggle sits beside #theme-toggle in the navbar, same btn-sm shape
     (~30x29) — folded in here for the same reason as its neighbour. */
  #demo-toggle,
  .navbar-toggler,
  #cal-type-filter .btn,
  .copy-attribution-btn,
  #asset-header .btn,
  /* #825: the Raindrop connector's Save / Sync now / Test selection / Clear credential
     submits (~29px as btn-sm). Same reasoning as the row above — Save writes the
     credential + config, Clear destroys it, and Sync now spawns a background import
     worker against a few-thousand-item corpus, so a mis-tap is consequential rather than
     merely annoying. A class on the button (like .copy-attribution-btn) rather than a
     container selector, because these live in four separate <form>s. */
  .connector-action,
  /* #820: the create page's MOBILE mode switcher (design §9.1's `<select>` fallback — the
     nav-tabs are `d-none d-md-flex`, so below this breakpoint the select IS the only way to
     change mode). ~29px as `form-select-sm`. The three submit buttons alongside it take
     `py-2` in the template per design §10.4 rather than a rule here, matching how the other
     `btn` rows in this block were already handled.
     #929/EPIC #940: .impact-action — the metric-definitions surface's Propose / Save / Activate /
     Deprecate submits (btn-sm py-2 ~37px, same as #asset-header .btn above). Activate freezes a
     definition's meaning and publishes it as the version every later observation resolves against;
     Deprecate withdraws it from new capture. A mis-tap on either is consequential, so they get the
     same treatment. A class rather than a container selector, like .copy-attribution-btn and
     .connector-action above, because the impact surfaces span several separate <form>s. */
  .impact-action,
  #create-tab-select,
  /* #1060: the navbar identity dropdown toggle sits beside #theme-toggle/#demo-toggle, same
     btn-sm shape (~30x29). RBAC mutations live in the route-scoped form container below: it
     covers account creation, role/membership changes, token issue, and row mutations without
     inflating compact controls in unrelated admin tables. */
  #nav-identity,
  /* #1140 5c: the chat surface's consequential controls. Send / a starter prompt / Retry each
     START A TURN — which spends model budget and holds the session's single lease — and Stop
     terminates one mid-flight. Same reasoning as the rows above, folded in here rather than
     given a bespoke rule so the floor stays declared exactly once. The launcher itself is
     already 3.25rem and needs no floor. */
  .gtm-chat-send,
  .gtm-chat-btn,
  .gtm-chat-starter,
  .gtm-chat-icon-btn,
  /* #1251: the provider/model picker select, same `form-select-sm` shape as `#create-tab-select`
     above — folded in here for the same reason: it is the only way to choose a pin before a
     session is created, so a mis-tap on a coarse pointer should not be more likely than for any
     other consequential chat control in this block. */
  .gtm-chat-picker-select,
  /* v4.1.2.2 (#1281): the Archived switch. A switch is the smallest control on the chat surface by
     default (Bootstrap sizes `.form-check-input` to the line box), and it changes what the whole
     list shows — a mis-tap here hides the operator's working set behind a lens they did not ask
     for. Folded in here for the same declared-exactly-once reason as the rows above. */
  .gtm-chat-archive-filter .form-check-input,
  /* v4.1.2.5: the BYOK Models transfer list's controls (/settings/llm). The two arrows are
     icon-only btn-sm (~23×29 measured) and change what every future chat session is offered;
     "Check compatibility" spends the operator's own money on real provider calls. Same consequential-
     mis-tap reasoning as the rows above, folded in for the same declared-exactly-once reason.
     The legend "?" joins them because it sits in the same row and would otherwise be the one
     under-sized target between two bumped ones — a 44px control beside a 29px one is a mis-tap
     generator in its own right. Measured on a coarse pointer in
     tests/client/test_1281_google_models_section.py. */
  .provider-models-add,
  .provider-models-remove,
  .provider-models-receipt,
  .provider-models-legend-toggle,
  /* #1300 review A11y-2: the #1297 reasoning-effort `<select>` shipped as a `form-select-sm`
     (~29px tall) and was the one control in this pane still under the bar. It is consequential in
     the same way its neighbours are — it changes what every future turn on that model is asked to
     spend reasoning on — so it joins them rather than being argued as a special case. */
  .provider-models-effort,
  /* v5.0: /settings/tokens' Revoke submit (btn-sm ~29px). Revoking is irreversible and instant —
     every CLI and desktop agent signed in with that token stops working on the next request — so
     it is exactly the mis-tap this block exists to make harder. A class rather than a container
     selector, like .connector-action and .impact-action above, and measured behind a login in
     tests/client/test_v50_tokens_page.py (the zero-arg sweep cannot reach a session-only page). */
  .token-revoke-btn,
  /* #1754 review: the identity dropdown's Settings/Account/Admin items measured 148x30 on a
     coarse pointer — regular `.dropdown-item` anchors, never on this floor, unlike the
     `#nav-identity` TOGGLE beside them (already listed above) and the standalone gear `btn-sm`
     these items replaced (that gear was never on this list either — the review's issue-facing
     claim otherwise is corrected here). Consequential surface: Admin/Account/Settings each open a
     different plane's controls, so a mis-tap here is the same hazard the rows above it exist
     to prevent. */
  .identity-menu-plane-link,
  /* #1419: the account-row mutations (create / role / password / disable / delete) moved to the
     platform `/accounts` page; membership, tokens and credentials stayed on `/admin/rbac`. The
     floor follows the CONTROLS, not the URL they used to sit under — both action prefixes are
     listed, and `tests/client/test_968_touch_target_floor.py` measures both pages in one sweep. */
  form[action^="/accounts"] button[type="submit"],
  form[action^="/admin/rbac/"] button[type="submit"] {
    min-width: 44px;
    min-height: 44px;
  }
  #mainnav .nav-link {
    min-height: 44px;
    display: flex;
    align-items: center;
  }
}

/* EPIC #940 fix wave A2 (U2) — the resolution queue's decided row is a FOCUS DESTINATION.
   An HTMX confirm/reject destroys the button that was clicked, so the page moves focus onto the
   replacement row (`tabindex="-1"`). Landing focus somewhere with no visible ring leaves a keyboard
   operator unable to see where they were put. `:focus-visible` rather than `:focus` on purpose: the
   ring appears for the keyboard path and stays out of the way of the mouse path, which is the same
   split the browser already applies to every other control. */
#resolution-list > li:focus-visible {
  outline: 2px solid var(--bs-primary);
  outline-offset: -2px;
}

/* === #602 — dashboard/report panel polish =====================================
   1. Panel tables. Bootstrap's .table-responsive sets overflow-x:auto, and per the
      CSS spec a non-visible overflow-x makes overflow-y compute to auto too — so a
      panel in the equal-height (align-items-stretch) dashboard row renders a spurious
      vertical scrollbar. These panels carry simple narrow tables that fit on desktop;
      scope horizontal scroll to mobile only. The flex row never sizes a card shorter
      than its content, so desktop overflow:visible clips nothing. */
.panel-scroll { overflow: visible; }
@media (max-width: 991.98px) {
  .panel-scroll { overflow-x: auto; }
}

/* 2. Channel-performance sub-panels: a hairline divider separates each channel block
      (icons + bold labels carry the rest of the distinction in-template). */
.channel-block + .channel-block { border-top: 1px solid var(--bs-border-color); }

/* 3. #1825 L3 (audit D2) — every wrapping chip/badge body across the three live sites
      (`templates/panels/_pipeline_strip.html`, `templates/panels/_pipeline_overview.html`,
      `templates/partials/_stat_tiles.html`). Bootstrap's `.flex-wrap` leaves `align-content` at
      its flex default (`normal`, ~= `stretch`/`space-between` depending on engine), which
      DISTRIBUTES wrapped rows across whatever leftover height the container has — inside a
      chip-only card that no longer stretches (item 1 above kept its own bug: a still-tall
      container from a sibling h-100 shell) two wrapped rows land far apart (the audit measured
      Committed/Maybe at the card top and Declined/Undecided ~110px below, orphaned at the floor).
      `align-content: flex-start` packs every wrapped row to the top instead — one class, applied
      at those three sites rather than an inline style per template. */
.gtm-chips { align-content: flex-start; }

/* 4. #1825 L1 (D9) — the stat tile. The WHOLE tile is one drill anchor (label above value), so
      it has to clear the 44px touch floor `tests/client/test_968_touch_target_floor.py` measures:
      a `<dl>` grid would have forced the anchor onto the value alone, i.e. a ~24px target, which
      is the exact a11y defect the audit filed. `min-height` rather than padding so a two-line
      wrapped label grows the tile instead of overflowing it, and the label line inherits no
      underline from the anchor — only the value carries the drill affordance's own
      `link-underline*` treatment. Everything else about a tile is Bootstrap utilities. */
.gtm-tile { min-height: 44px; }
/* #1833: the qualify row's companion column is a flex column so its panel card fills the shared row height. */
[data-col="qualify_side"] > .card { flex: 1 1 auto; }
a.gtm-tile > .small { text-decoration: none; }

/* 5. Active top-nav prominence. Bootstrap's .nav-link.active only recolours text to
      --bs-primary, which barely reads against the tertiary-bg navbar. Add an accent
      underline (inset box-shadow = no layout shift) + weight; theme-aware. Covers
      top-level links and active dropdown parents. */
#mainnav .nav-link.active {
  font-weight: 600;
  box-shadow: inset 0 -2px 0 0 var(--bs-primary);
}

/* Debrief markdown body (#638) — rendered via the `markdown` Jinja filter.
   All tokens are CSS custom properties (var(--bs-*)) so OLED dark + light themes work
   without any hardcoded hex. Heading levels clamped to h3–h6 by the filter; font-size
   overrides keep them from competing with the page h1/h2. */
.debrief-md { line-height: 1.45; }
.debrief-md h1, .debrief-md h2 { font-size: 1rem; font-weight: 600; margin-bottom: .4rem; }
.debrief-md h3, .debrief-md h4 { font-size: .9375rem; font-weight: 600; margin-bottom: .3rem; }
.debrief-md p { margin-bottom: .5rem; }
.debrief-md ul, .debrief-md ol { padding-left: 1.25rem; margin-bottom: .5rem; }
.debrief-md li { margin-bottom: .2rem; }
.debrief-md code { background: var(--bs-tertiary-bg); border: 1px solid var(--bs-border-color); border-radius: .25rem; padding: .1em .3em; font-size: .875em; }
.debrief-md pre { background: var(--bs-tertiary-bg); border: 1px solid var(--bs-border-color); border-radius: .375rem; padding: .65rem .9rem; overflow-x: auto; }
.debrief-md a { color: var(--bs-link-color); }
.debrief-md blockquote { border-left: 3px solid var(--bs-border-color); margin-left: 0; padding-left: .75rem; color: var(--bs-secondary-color); }
/* #1215/#1219/#1220: GFM pipe tables. The scroll boundary lives on a WRAPPER div, not the table
   box. The table is responsive: `width: auto; max-width: 100%` lets it shrink to fit the message
   column on a wide chat page (no scroll — the whole table is on screen), while `min-width: 44rem`
   (min-width beats max-width when they conflict) sets a readability floor so in the narrow
   slideover the table stays 32rem and the WRAPPER scrolls instead of crushing columns to one word
   per line. A per-cell max-width keeps a long paragraph cell wrapping at a sane width rather than
   forcing one giant column. Border + header-fill are Bootstrap theme vars, so light/dark both
   read with no per-theme override. */
.debrief-md .gtm-md-scroll { overflow-x: auto; max-width: 100%; margin-bottom: .5rem; }
.debrief-md table { width: auto; max-width: 100%; min-width: 44rem; border-collapse: collapse; margin-bottom: 0; }
.debrief-md th, .debrief-md td { border: 1px solid var(--bs-border-color); padding: .3rem .55rem; text-align: left; vertical-align: top; max-width: 20rem; }
.debrief-md thead th { background: var(--bs-tertiary-bg); font-weight: 600; }

/* Collapsible debrief card (#638): a <details> with Bootstrap's .card.
   .card sets display:flex, which defeats the native closed-state hiding —
   restore block so a collapsed card renders only its summary. The summary
   keeps its flex header layout; a rotating chevron replaces the UA marker. */
details.card { display: block; }
details.card:not([open]) > .card-body { display: none; }
details.card > summary.card-header { list-style: none; }
details.card > summary.card-header::-webkit-details-marker { display: none; }
details.card > summary.card-header::before {
  content: "▸"; font-size: .85rem; line-height: 1;
  color: var(--bs-secondary-color); transition: transform .15s ease-in-out;
}
details.card[open] > summary.card-header::before { transform: rotate(90deg); }

/* One-screen defensive backstop for the pre engagement-plan card (P0 §4.2):
   the skill keeps the plan tight, but a long plan scrolls within a bounded
   region rather than blowing past one screen mid-call. */
.debrief-plan { max-height: 60vh; overflow-y: auto; }

/* #779 Phase 4 polish — the content-draft viewer's right rail ("Sources & assets", D9)
   carries .sticky-lg-top so a short empty panel reads as a companion to the longer main
   column, not a void, once scrolled past. The navbar (templates/base.html) is ALSO
   sticky-top at top:0 — without an offset the rail would stick underneath it. Desktop
   only (lg+), matching both the sticky-lg-top utility and the col-lg-4 rail breakpoint. */
@media (min-width: 992px) {
  #panel-sources-assets.sticky-lg-top {
    top: 4.5rem;
  }
}

/* #789: row-link tables — the whole row navigates (delegated tr[data-href] handler
   in base.html), so give it the affordance it now honestly delivers. */
tr[data-href] { cursor: pointer; }

/* #905 — utility classes for the repeated inline style= clusters (class-for-style
   substitution only; each declaration block is copied byte-for-byte from the inline
   styles it replaces, no value normalization). Distinct font-size/value combos each
   keep their own class rather than collapsing onto a shared one. */
.u-cursor-pointer { cursor: pointer; }
.u-cursor-pointer-inline { display: inline-block; cursor: pointer; }
.u-cursor-pointer-nolist { cursor: pointer; list-style: none; }

.u-pre-wrap { white-space: pre-wrap; }
.u-pre-wrap-break { white-space: pre-wrap; word-break: break-word; }
.u-pre-wrap-break-085em { white-space: pre-wrap; word-break: break-word; font-size: 0.85em; }
.u-pre-wrap-owrap { white-space: pre-wrap; overflow-wrap: break-word; }
.u-pre-wrap-scroll-075rem { white-space: pre-wrap; max-height: 200px; overflow-y: auto; font-size: 0.75rem; }

.u-h-fit-content { height: fit-content; }

.u-line-clamp-3 { overflow: hidden; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; }

.u-min-w-0 { min-width: 0; }
.u-w-auto { width: auto; }
.u-w-8em { width: 8em; }
.u-min-w-320 { min-width: 320px; }
.u-min-w-7rem { min-width: 7rem; }
/* #1096 I-10: _auth_events_table.html column widths — byte-for-byte from the inline styles. */
.u-w-11rem { width: 11rem; }
.u-w-12rem { width: 12rem; }
.u-w-14rem { width: 14rem; }
.u-w-15rem { width: 15rem; }

/* #985 demo mode — CSS-only masking of entity-identifying content for
   operator-driven screen-share demos. `html.demo` is toggled by the navbar
   demo button (persisted in localStorage, same pattern as the theme switch).
   Templates mark entity-bearing output (names, prose/notes, contact details,
   message bodies) with .redact. CAUTION: real values remain in the DOM and
   network responses — this masks pixels only; never hand over the device or
   session while relying on demo mode. */
html.demo .redact {
  filter: blur(5px);
  user-select: none;
}
/* Edit-screen inputs carry real values too; blur them when marked. */
html.demo input.redact,
html.demo textarea.redact,
html.demo select.redact { filter: blur(5px); }
/* Placeholder text can embed an entity name (e.g. "Clone of <campaign>"); blur
   doesn't reliably reach ::placeholder, so hide it outright. */
html.demo .redact::placeholder { color: transparent; }
/* Unmissable indicator so masked data is never mistaken for real data. */
html.demo body::after {
  content: "DEMO MODE";
  position: fixed;
  left: 0; right: 0; bottom: 0;
  z-index: 2000;
  background: #ffc107;
  color: #000;
  text-align: center;
  font-weight: 600;
  letter-spacing: 0.05em;
  padding: 0.3rem 0.5rem;
  font-size: 0.85rem;
}

/* ═══ #1140 5c — chat surface ══════════════════════════════════════════════════
   Two layers, kept apart on purpose:
     (A) a thin `--gtm-comp-*` TOKEN layer over Bootstrap's own `--bs-*` vars, and
     (B) the chat components that consume it.
   Layer A is deliberately generic — it is the three-step treatment scale, not a chat feature —
   so a later surface can adopt `gtm-comp-artifact` without importing anything chat-shaped. */

/* ── (A) Treatment scale (design addendum item 1) ──────────────────────────────
   Every surface picks EXACTLY ONE of three ranks, and the ranks are qualitatively different
   rather than three shadow depths — that is what fixes "everything is a .card, nothing has rank":

     plain    — structural. A border, no fill, no lift. The operator's own words.
     artifact — AI-produced. Raised: a fill above the canvas plus a hairline and a small shadow.
     gate     — a decision moment, in its OWN warning family (never "artifact with more shadow").

   `gate` ships as scaffolding ONLY. v4.0 chat is read-only and no approve/reject control exists
   to wear it; the tokens are here so the first one that does inherits a settled rank instead of
   inventing a fourth. Every value derives from a Bootstrap token, so both themes follow for free
   and nothing here needs a per-theme hex. */
:root {
  --gtm-comp-plain-bg: transparent;
  --gtm-comp-plain-border: var(--bs-border-color);
  --gtm-comp-plain-radius: .5rem;

  --gtm-comp-artifact-bg: var(--bs-tertiary-bg);
  --gtm-comp-artifact-border: var(--bs-border-color-translucent);
  --gtm-comp-artifact-radius: .5rem;
  --gtm-comp-artifact-shadow: var(--bs-box-shadow-sm);

  --gtm-comp-gate-bg: var(--bs-warning-bg-subtle);
  --gtm-comp-gate-border: var(--bs-warning-border-subtle);
  --gtm-comp-gate-text: var(--bs-warning-text-emphasis);

  /* The stream hue — DISTINCT from the warning family on purpose: "the machine is producing
     output" and "you should look at this before it proceeds" must never share a colour, or the
     gate stops meaning anything. Indigo sits off the state-semantic palette (success/info/
     warning/danger) and off the three entity accents (blue/teal/violet). Used on the spine, the
     activity dot and the streaming caret — never as a fill behind body text, so it is held to the
     3:1 non-text floor rather than the 4.5:1 text floor. */
  --gtm-comp-stream: #4f46e5;
  --gtm-comp-stream-soft: rgba(79, 70, 229, .12);

  --gtm-comp-motion-fast: 120ms;
  --gtm-comp-motion-pulse: 1400ms;
}
[data-bs-theme="dark"] {
  /* The app's documented dark elevation model is canvas #0a0a0a → raised #141416 → chrome
     #1c1c1e (§#482 above). `artifact` IS the raised step — reuse that value rather than mint a
     fourth surface shade nobody else can match. */
  --gtm-comp-artifact-bg: #141416;
  --gtm-comp-artifact-shadow: 0 1px 2px rgba(0, 0, 0, .5);
  --gtm-comp-stream: #a5b4fc;
  --gtm-comp-stream-soft: rgba(165, 180, 252, .16);
}
/* Reduced motion zeroes the motion TOKENS globally (addendum item 3) — every lifecycle state
   still passes through, it simply arrives instantly. Scoping this to the tokens rather than to
   each animation is what keeps a future component from having to remember. */
@media (prefers-reduced-motion: reduce) {
  :root { --gtm-comp-motion-fast: 0ms; --gtm-comp-motion-pulse: 0ms; }
  /* #1224: `!important` (matching the `animation: none !important` beside it) because this
     block sits EARLIER in the file than `.gtm-chat-messages { scroll-behavior: smooth }`
     and carries identical specificity — the later declaration was silently winning, so the
     reduced-motion preference had no effect on the one scroller it names. Proven by trace:
     under `prefers-reduced-motion: reduce` the boot scroll still animated across ~70
     frames, identically to the no-preference run. */
  .gtm-chat-messages { scroll-behavior: auto !important; }
  .gtm-activity-dot, .gtm-chat-launcher-badge-dot { animation: none !important; }
}

.gtm-comp-plain {
  background: var(--gtm-comp-plain-bg);
  border: 1px solid var(--gtm-comp-plain-border);
  border-radius: var(--gtm-comp-plain-radius);
}
.gtm-comp-artifact {
  background: var(--gtm-comp-artifact-bg);
  border: 1px solid var(--gtm-comp-artifact-border);
  border-radius: var(--gtm-comp-artifact-radius);
  box-shadow: var(--gtm-comp-artifact-shadow);
}
.gtm-comp-gate {
  background: var(--gtm-comp-gate-bg);
  border: 1px solid var(--gtm-comp-gate-border);
  border-radius: var(--gtm-comp-artifact-radius);
  color: var(--gtm-comp-gate-text);
}

/* ── (B) Launcher ─────────────────────────────────────────────────────────────
   Bottom-right, above page content and below the #985 demo banner (z-index 2000) so a demo
   session never hides the fact that values are masked. */
.gtm-chat-launcher {
  position: fixed;
  inset-inline-end: 1.25rem;
  inset-block-end: 1.25rem;
  z-index: 1035;
  width: 3.25rem;
  height: 3.25rem;
  border-radius: 999px;
  border: 1px solid var(--bs-border-color);
  background: var(--bs-body-bg);
  color: var(--bs-body-color);
  box-shadow: var(--bs-box-shadow-lg);
  display: inline-flex;
  align-items: center;
  justify-content: center;
  transition: transform var(--gtm-comp-motion-fast) ease-in-out;
}
.gtm-chat-launcher:hover { transform: translateY(-2px); }
.gtm-chat-launcher:focus-visible { outline: 2px solid var(--bs-primary); outline-offset: 2px; }
.gtm-chat-launcher-badge {
  position: absolute;
  top: .1rem;
  inset-inline-end: .1rem;
  display: inline-flex;
}
.gtm-chat-launcher-badge[hidden] { display: none; }
.gtm-chat-launcher-badge-dot {
  width: .65rem;
  height: .65rem;
  border-radius: 999px;
  background: var(--gtm-comp-stream);
  box-shadow: 0 0 0 2px var(--bs-body-bg);
  animation: gtm-chat-pulse var(--gtm-comp-motion-pulse) ease-in-out infinite;
}
@keyframes gtm-chat-pulse { 0%, 100% { opacity: 1; } 50% { opacity: .35; } }

/* ── Slideover ────────────────────────────────────────────────────────────────
   The 3px accent spine on the inline-start edge makes the panel read as ATTACHED to the page
   rather than floating over it (addendum item 5). */
.gtm-chat-offcanvas {
  width: min(30rem, 100vw);
  border-inline-start: 3px solid var(--gtm-comp-stream);
}
/* #1194: applied only while `static/chat.js` is restoring the panel across a page navigation —
   zeroes the slide-in transition so Bootstrap's transition-duration read (which drives when
   `shown.bs.offcanvas` fires) is immediate, and the reopened pane doesn't visibly slide in on
   every single page load. Removed the instant `shown.bs.offcanvas` fires. */
.gtm-chat-offcanvas.gtm-chat-no-transition { transition: none !important; }
.gtm-chat-header { align-items: flex-start; gap: .5rem; }
.gtm-chat-header-text { min-width: 0; flex: 1 1 auto; }
/* Phase 5 review fix 10: the current-session title can be a derived one up to `TITLE_MAX_CHARS`
   (80 chars, `blueprints/_chat_view.py`) or an operator's own long manual rename — uncapped, that
   text can dominate the whole slideover header. Same clamp pattern as `.u-line-clamp-3`. */
[data-gtm-chat-current-title] {
  display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
  overflow: hidden; overflow-wrap: anywhere;
}
/* #1193: Bootstrap's OWN `.offcanvas-body` rule (static/vendor/bootstrap-5.3.3.min.css) is
   unconditionally `overflow-y: auto` — it does not vary with `data-bs-scroll`, which only toggles
   whether the page BEHIND the pane locks. Left alone, that made the offcanvas body a SECOND
   scroll container wrapping `.gtm-chat-messages`' own `overflow-y: auto` below: a reader saw two
   scrollbars nested inside one pane whenever the panel's total content (sessions nav + thread +
   composer) exceeded the pane's height. Corrected here, not by touching the vendored file: the
   body becomes a flex column that never scrolls itself, so `.gtm-chat-messages` stays the ONE
   scroller for the thread column (unchanged; sizing below). */
.gtm-chat-offcanvas .offcanvas-body {
  overflow-y: hidden;
  display: flex;
  flex-direction: column;
  min-height: 0;
}
.gtm-chat-body {
  display: flex; flex-direction: column; gap: .75rem; padding-top: .5rem;
  flex: 1 1 auto; min-height: 0; overflow: hidden;
}
/* The panel's own root (`_chat_panel.html`'s wrapping div) must pass the flex column all the way
   down to `.gtm-chat-thread`, or the thread's `flex: 1 1 auto` (below) has no ancestor chain of
   real heights to size against and `.gtm-chat-messages` falls back to its content height instead
   of the pane's available space. */
.gtm-chat-panel { display: flex; flex-direction: column; gap: .75rem; flex: 1 1 auto; min-height: 0; }
.gtm-binding-line {
  margin: .15rem 0 0;
  font-size: .8rem;
  color: var(--bs-secondary-color);
  overflow-wrap: anywhere;
}
.gtm-binding-label { font-weight: 600; }
.gtm-chat-loading { color: var(--bs-secondary-color); font-size: .875rem; }

/* ── History nav — the JournalFeed shape (addendum item 7) ────────────────────
   Reverse-chron <ol>, a left rail, kind label + <time> + one-line gist. The active row keys on
   `aria-current="page"`: the a11y state IS the visual state, so they cannot drift apart. */
.gtm-chat-sessions { border-bottom: 1px solid var(--bs-border-color); padding-bottom: .5rem; }
.gtm-chat-sessions-head { display: flex; align-items: center; justify-content: space-between; gap: .5rem; }
.gtm-chat-sessions-title { font-size: .8rem; text-transform: uppercase; letter-spacing: .04em;
  color: var(--bs-secondary-color); margin: 0; }
.gtm-chat-sessions-empty { font-size: .85rem; color: var(--bs-secondary-color); margin: .5rem 0 0; }

/* ── #1251 session provider/model picker — now inside the v4.1.2.2 NEW-CHAT COMPOSER ──
   The composer is the whole of the model choice: revealed by "New chat", it creates nothing until
   "Start chat" is pressed. A session's pin is shown afterwards as a read-only row badge. */
.gtm-chat-new-composer {
  margin: .5rem 0 0; padding: .5rem; border: 1px solid var(--bs-border-color);
  border-radius: .35rem; background: var(--bs-tertiary-bg);
}
.gtm-chat-new-composer-label { display: block; font-size: .75rem; color: var(--bs-secondary-color);
  margin-bottom: .25rem; }
.gtm-chat-new-composer-actions { display: flex; gap: .35rem; margin-top: .5rem; }
.gtm-chat-archive-filter { margin: .5rem 0 0; display: flex; align-items: center; gap: .4rem; }
.gtm-chat-archive-filter .form-check-label { font-size: .78rem; color: var(--bs-secondary-color); }
.gtm-chat-picker-select { font-size: .8rem; }
.gtm-chat-picker-hint { font-size: .75rem; color: var(--bs-secondary-color); margin: .3rem 0 0; }
.gtm-chat-pin-badge {
  display: inline-flex; align-items: center; gap: .3rem;
  font-size: .7rem; color: var(--bs-secondary-color);
  padding: 0 .4rem; white-space: nowrap;
  border: 1px solid var(--bs-border-color); border-radius: 999px;
}
.gtm-chat-archived-chip {
  border-color: var(--bs-warning-border-subtle); background: var(--bs-warning-bg-subtle);
  color: var(--bs-warning-text-emphasis);
}
/* v4.1.2.2 (#1281): an archived conversation stays readable; only new turns are refused. Warning
   colour, not danger — nothing was destroyed. */
.gtm-chat-archived-notice {
  margin: 0 0 .35rem; padding: .35rem .5rem; font-size: .78rem; border-radius: .3rem;
  border: 1px solid var(--bs-warning-border-subtle); background: var(--bs-warning-bg-subtle);
  color: var(--bs-warning-text-emphasis);
}
/* #1786 same-class sweep: the ONE unfixed sibling of the `/chat` fix at ~1305 below — the
   journal's per-row `<span class="visually-hidden" data-gtm-session-pin>` children
   (`templates/partials/_chat_sessions.html`) are Bootstrap `.visually-hidden` (`position:
   absolute`); with no positioned ancestor here they resolve against the initial containing
   block, escape this scroller's `overflow-y: auto`, and inflate `documentElement.scrollHeight`
   the same way the chat log did. `position: relative` makes this the containing block instead —
   composed unchanged by both the chat-page rail (`.gtm-chat-page-rail .gtm-journal`) and the
   slideover. */
.gtm-journal { margin: .4rem 0 0; max-height: 11rem; overflow-y: auto; position: relative; }
.gtm-journal-row { display: flex; align-items: center; gap: .25rem; }
.gtm-journal-link {
  flex: 1 1 auto;
  min-width: 0;
  display: grid;
  /* kind · gist · archived chip (absent on a live row) · time. The #1251 model pin is no longer a
     visible column (#1287): it rides the link's title tooltip + a visually-hidden span, which is
     absolutely positioned and so takes no track. */
  grid-template-columns: auto minmax(0, 1fr) auto auto;
  align-items: baseline;
  gap: .4rem;
  padding: .3rem .45rem;
  border-inline-start: 3px solid transparent;
  border-radius: .25rem;
  color: var(--bs-body-color);
  text-decoration: none;
}
.gtm-journal-link:hover { background: var(--bs-tertiary-bg); }
.gtm-journal-link:focus-visible { outline: 2px solid var(--bs-primary); outline-offset: -2px; }
.gtm-journal-link[aria-current="page"] {
  border-inline-start-color: var(--gtm-comp-stream);
  background: var(--gtm-comp-stream-soft);
  font-weight: 600;
}
.gtm-journal-kind { font-size: .72rem; text-transform: uppercase; letter-spacing: .05em;
  color: var(--bs-secondary-color); }
.gtm-journal-gist { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
  font-size: .85rem; }
.gtm-journal-time { font-size: .72rem; color: var(--bs-secondary-color); white-space: nowrap; }
.gtm-journal-rename-form { display: flex; gap: .25rem; padding: .25rem .45rem; width: 100%; }
.gtm-journal-rename-input { flex: 1 1 auto; min-width: 0; }

/* ── Thread ───────────────────────────────────────────────────────────────────
   The message list is the ONLY scroller in the panel. `scroll-behavior` lives here in CSS — never
   a JS `behavior:"smooth"`, which would stomp the reduced-motion preference. */
.gtm-chat-thread { display: flex; flex-direction: column; gap: .5rem; min-height: 0; flex: 1 1 auto; }
.gtm-chat-messages {
  margin: 0;
  padding: 0 .1rem;
  overflow-y: auto;
  scroll-behavior: smooth;
  display: flex;
  flex-direction: column;
  gap: .6rem;
  /* #1193: the slideover mount fills the flex column above (offcanvas → .gtm-chat-body →
     .gtm-chat-panel → .gtm-chat-thread) instead of an independent `vh` clamp — a fixed viewport
     fraction sized this scroller WITHOUT regard for how much the sessions nav + composer actually
     used, which is exactly what let the pane's total content outgrow the offcanvas body and force
     that second, outer scrollbar. `min-height` keeps a usable floor on a very short viewport. */
  flex: 1 1 auto;
  min-height: 8rem;
}
/* #1224 (a11y, WCAG 2.4.7): the log carries `tabindex="0"` so the keyboard can scroll it (see
   `templates/partials/_chat_thread.html`), which means it needs a visible focus ring. Inset
   (`outline-offset: -2px`) so the ring cannot be clipped by the mount that bounds it. */
.gtm-chat-messages:focus-visible { outline: 2px solid var(--bs-primary); outline-offset: -2px; }
/* #1224: BELOW the `992px` breakpoint the full-page `/chat` mount stacks (rail above thread) and
   is deliberately left in normal document flow — a narrow viewport wants the ordinary page scroll,
   and a viewport-bounded column there would fight it. This viewport-fraction clamp is that narrow
   case only. At `>= 992px` the mount is bounded like the slideover instead (see the `#1224` block
   under the `min-width: 992px` query below), which REVERSES the decision this comment used to
   record ("the page mount is NOT bounded like the slideover"): the clamp stacked on nav + head +
   binding line + composer + footer, so `62vh + fixed chrome > 100vh` and the whole page scrolled. */
.gtm-chat-page .gtm-chat-messages { flex: none; max-height: 62vh; }
.gtm-chat-turn { display: flex; flex-direction: column; gap: .35rem; scroll-margin-block-start: 4.5rem; }
.gtm-chat-day {
  text-align: center;
  font-size: .7rem;
  letter-spacing: .04em;
  text-transform: uppercase;
  color: var(--bs-secondary-color);
  border-bottom: 1px solid var(--bs-border-color);
  line-height: 0;
  margin: .5rem 0;
}
.gtm-chat-day > time { background: var(--bs-body-bg); padding: 0 .5rem; }
.gtm-msg { padding: .45rem .6rem; }
.gtm-msg-user { align-self: flex-end; max-width: 88%; }
.gtm-msg-agent { align-self: stretch; }
.gtm-msg-body { overflow-wrap: anywhere; white-space: pre-wrap; font-size: .9rem; line-height: 1.45; }
.gtm-msg-body.gtm-markdown { white-space: normal; }
.gtm-msg-body.gtm-markdown:empty { display: none; }
.gtm-msg-meta {
  display: flex; align-items: center; flex-wrap: wrap; gap: .5rem;
  margin-top: .3rem; font-size: .7rem; color: var(--bs-secondary-color);
}
.gtm-cost { font-variant-numeric: tabular-nums; }
/* #1166 AC6: rides the same muted meta-row treatment as the cost/time it sits beside — a
   navigation affordance, not a call to action, so it never competes visually with Stop/Retry.
   Phase 5 review fix 12 padded the hit area toward the app's ~44px touch-target convention
   WITHOUT growing the visible text — an invisible expansion via padding plus a matching negative
   margin, so the surrounding meta row's layout (gap, baseline) never shifts. That declaration
   landed in a second, separate `.gtm-chat-trace-link` block further down as a stash-reconciliation
   artifact; whole-branch review fix 10 folded it back here, so one selector is declared once. */
.gtm-chat-trace-link {
  color: var(--bs-secondary-color);
  display: inline-block; padding: .55rem; margin: -.55rem; line-height: 1;
}
.gtm-chat-trace-link:hover { color: var(--bs-body-color); }
/* Pre-existing gap the phase 5 review a11y lens caught while touching this selector for fix 12:
   this used to be folded into the `:hover` rule above and changed COLOUR on `:focus-visible` but
   painted no outline at all — a keyboard operator tabbing to this link got no visible indicator of
   where focus landed. Split out so the checker (and a human) can see the ring is actually declared
   on this exact rule, not merely alongside a hover-only colour change. Same treatment as
   `.gtm-chat-icon-btn:focus-visible` just below. */
.gtm-chat-trace-link:focus-visible {
  color: var(--bs-body-color); outline: 2px solid var(--bs-primary); outline-offset: 2px;
}
/* Phase 5 review fix 11: `admin/chat_trace_detail.html`'s per-turn anchor target — `:target`
   supplies the sighted highlight, the template's own inline script supplies the FOCUS (a browser
   scrolls to a fragment but never moves focus there on its own). No new colour: reuses the same
   primary-outline treatment `#resolution-list > li:focus-visible` already established for "this
   is where a navigation just landed you" elsewhere in the app. */
.gtm-trace-turn:target, .gtm-trace-turn:focus-visible {
  outline: 2px solid var(--bs-primary); outline-offset: 2px;
}
.gtm-session-cost {
  display: flex; gap: .5rem; align-items: center; justify-content: flex-end;
  margin: 0; font-size: .72rem; color: var(--bs-secondary-color);
}
.gtm-session-cost-warn { color: var(--bs-warning-text-emphasis); font-weight: 600; }
.gtm-chat-notice { margin: 0; font-size: .78rem; color: var(--bs-secondary-color); }

/* ── Streaming lifecycle (addendum item 3) ────────────────────────────────────
   ONE attribute, three states: skeleton → streaming → settled. Everything the surface animates
   keys off `[data-gtm-streaming-status]`, so there is no second source of "is this live". The
   only-the-LAST-message-streams invariant is enforced where it belongs — in the view model
   (`blueprints/_chat_view.py`), which is why a re-rendered history never animates here. */
[data-gtm-streaming-status="settled"] .gtm-activity { display: none; }
[data-gtm-streaming-status="skeleton"] .gtm-msg-body[data-gtm-answer]:empty::after,
[data-gtm-streaming-status="streaming"] .gtm-msg-body[data-gtm-answer]::after {
  content: "";
  display: inline-block;
  width: .45rem;
  height: .95em;
  vertical-align: text-bottom;
  margin-inline-start: .15rem;
  background: var(--gtm-comp-stream);
  animation: gtm-chat-pulse var(--gtm-comp-motion-pulse) ease-in-out infinite;
}
.gtm-activity {
  display: flex; align-items: center; gap: .35rem;
  margin: 0 0 .3rem; font-size: .75rem; color: var(--bs-secondary-color);
}
.gtm-activity-dot {
  width: .5rem; height: .5rem; border-radius: 999px; background: var(--gtm-comp-stream);
  animation: gtm-chat-pulse var(--gtm-comp-motion-pulse) ease-in-out infinite;
}

/* ── Tool-call chips ──────────────────────────────────────────────────────────
   Dual-channel encoding: a per-state GLYPH (shape) alongside the hue, plus the state spelled out
   in text — the chip survives greyscale and never carries meaning by colour alone. */
.gtm-tool-chips { display: flex; flex-wrap: wrap; gap: .25rem; margin: 0 0 .4rem; }
.gtm-tool-chip { display: inline-block; }
.gtm-tool-chip > summary {
  display: inline-flex; align-items: center; gap: .25rem;
  padding: .05rem .45rem; border-radius: 999px; cursor: pointer;
  border: 1px solid var(--bs-border-color); font-size: .75rem;
  color: var(--bs-secondary-color); list-style: none; white-space: nowrap;
}
.gtm-tool-chip > summary::-webkit-details-marker { display: none; }
.gtm-tool-chip > summary:focus-visible { outline: 2px solid var(--bs-primary); outline-offset: 1px; }
.gtm-tool-chip[data-gtm-tool-state="running"] > summary { border-color: var(--gtm-comp-stream); }
.gtm-tool-chip[data-gtm-tool-state="ok"] > summary { border-color: var(--bs-success); }
.gtm-tool-chip[data-gtm-tool-state="failed"] > summary { border-color: var(--bs-danger); }
.gtm-tool-name { font-family: var(--bs-font-monospace); }
.gtm-tool-state { font-size: .72rem; text-transform: uppercase; letter-spacing: .03em; }
/* #1166 AC5: stable once done, ticking (JS) while running — both share this one look so the
   number never reads as a state change of its own. Empty (no timestamp to show) collapses to
   nothing rather than reserving visible space for a number that never arrives. */
.gtm-tool-duration {
  font-variant-numeric: tabular-nums;
  color: var(--bs-secondary-color);
  font-size: .7rem;
}
.gtm-tool-duration:empty { display: none; }
.gtm-tool-detail { font-size: .7rem; color: var(--bs-secondary-color); padding: .3rem .5rem 0; }
.gtm-tool-detail-line { overflow-wrap: anywhere; }

/* #1183: the settled-tool-pill group. Same pill shape as an individual `.gtm-tool-chip` summary
   (one visual language for "this is a disclosure about tool calls") but block-level, since it
   replaces a WHOLE row of pills rather than sitting inline among them. */
.gtm-tool-chip-group { margin: 0 0 .4rem; }
.gtm-tool-chip-group > summary {
  display: inline-flex; align-items: center; gap: .3rem; cursor: pointer;
  padding: .1rem .55rem; border-radius: 999px; border: 1px solid var(--bs-border-color);
  font-size: .75rem; color: var(--bs-secondary-color); list-style: none; white-space: nowrap;
}
.gtm-tool-chip-group > summary::-webkit-details-marker { display: none; }
.gtm-tool-chip-group > summary:focus-visible { outline: 2px solid var(--bs-primary); outline-offset: 1px; }
.gtm-tool-chip-group[open] > summary { border-color: var(--gtm-comp-stream); }
.gtm-tool-chip-group > .gtm-tool-chips { margin-top: .3rem; }
.gtm-tool-chips-running { margin-bottom: .3rem; }

/* #1183: the collapsible per-message meta section — token stats + trace link, folded behind
   "Details" the same way the tool-pill group folds behind its own summary. */
.gtm-msg-meta-details { font-size: .7rem; }
.gtm-msg-meta-details > summary {
  cursor: pointer; color: var(--bs-secondary-color); list-style: none; display: inline-block;
  padding: .3rem 0; margin: -.3rem 0;
}
.gtm-msg-meta-details > summary::-webkit-details-marker { display: none; }
.gtm-msg-meta-details > summary::before { content: "▸ "; }
.gtm-msg-meta-details[open] > summary::before { content: "▾ "; }
.gtm-msg-meta-details > summary:focus-visible { outline: 2px solid var(--bs-primary); outline-offset: 1px; }
.gtm-msg-meta-details-body { display: flex; flex-wrap: wrap; gap: .5rem; margin-top: .2rem; }

/* #1183: the jump-to-bottom affordance — shown only while the reader has scrolled up (JS toggles
   `hidden`). Anchored to the bottom of the ONE scroller (`.gtm-chat-messages`), never the panel. */
.gtm-chat-jump-bottom {
  position: sticky; bottom: .25rem; align-self: center; z-index: 2;
  display: inline-flex; align-items: center; gap: .3rem;
  padding: .3rem .7rem; border-radius: 999px; border: 1px solid var(--bs-border-color);
  background: var(--bs-body-bg); color: var(--bs-body-color); font-size: .75rem;
  box-shadow: 0 1px 4px rgba(0, 0, 0, .15);
}
.gtm-chat-jump-bottom:hover { background: var(--bs-tertiary-bg); }
.gtm-chat-jump-bottom:focus-visible { outline: 2px solid var(--bs-primary); outline-offset: 1px; }

/* #1190: the rating widget. Buttons match `.gtm-chat-icon-btn`'s hit area; the active side (a
   fired rating) is dual-channel — a filled background AND `aria-pressed`, never colour alone. */
.gtm-msg-rating { display: inline-flex; align-items: center; gap: .1rem; position: relative; }
.gtm-rating-btn[aria-pressed="true"] { background: var(--bs-tertiary-bg); color: var(--bs-body-color); }
.gtm-rating-comment-toggle { font-size: .8rem; }
.gtm-rating-comment-form {
  position: absolute; top: 100%; left: 0; z-index: 3; margin-top: .25rem;
  display: flex; flex-direction: column; gap: .35rem; width: 16rem; max-width: 70vw;
  padding: .5rem; border: 1px solid var(--bs-border-color); border-radius: .375rem;
  background: var(--bs-body-bg); box-shadow: 0 2px 8px rgba(0, 0, 0, .18);
}
.gtm-rating-status { font-size: .7rem; color: var(--bs-secondary-color); }

/* ── Entity chips in agent answers (addendum item 6) ──────────────────────────
   The `density=summary` rendering of the same entity that appears as a full card elsewhere.
   Accent on the border + glyph only; the LABEL stays body-coloured, exactly as `.entity-chip`
   does, because the accent hues are not text-contrast-safe. */
/* Extends `.entity-chip` (§#359 D6) — the template applies BOTH classes, so the pill shape,
   accent border and label-contrast discipline are the app's existing component. This adds only
   what a chip inside prose needs: it is a LINK, and it sits on a text baseline. */
.gtm-entity-chip {
  text-decoration: none;
  color: var(--bs-body-color);
  font-size: .85em;
  vertical-align: baseline;
}
.gtm-entity-chip:hover { background: var(--bs-tertiary-bg); }
.gtm-entity-chip:focus-visible { outline: 2px solid var(--bs-primary); outline-offset: 1px; }
.gtm-entity-chip-glyph { color: var(--entity-accent, currentColor); flex: none; }
/* Campaigns have no `entity_meta` registry entry; they take the info-toned accent the week
   calendar already uses for campaign-attributed items, so one concept keeps one hue app-wide. */
.entity-campaign { --entity-accent: var(--bs-info); }

/* ── Agent markdown ───────────────────────────────────────────────────────────
   EXTENDS `.debrief-md` (#638) — the template applies both classes, so code spans, fenced blocks,
   blockquotes, links and heading clamps are the app's ONE agent-prose treatment. Only the density
   delta lives here: a chat bubble is tighter than a debrief card. Two independent markdown looks
   for the same kind of content is exactly the drift this audit exists to catch. */
.gtm-markdown p { margin-bottom: .45rem; }
.gtm-markdown p:last-child { margin-bottom: 0; }
.gtm-markdown ul, .gtm-markdown ol { padding-left: 1.15rem; margin-bottom: .45rem; }

/* ── Composer + buttons ───────────────────────────────────────────────────────
   The disabled state is honest by construction: the button greys out AND the note beside it says
   why (`data-gtm-composer-note`), so a dead-looking control is never unexplained. */
.gtm-chat-composer {
  display: grid;
  grid-template-columns: 1fr auto;
  gap: .35rem;
  align-items: end;
  border-top: 1px solid var(--bs-border-color);
  padding-top: .5rem;
}
/* Sizing only — `.form-control` (applied in the template) owns the border, focus ring, disabled
   fill and both themes. #1166 AC4: auto-grows (static/chat.js sets an inline `height` as the
   operator types) rather than manual drag-resize — `resize: none` so the two mechanisms never
   fight over the box's height. `max-height` is the bound the JS clamps to; past it the box scrolls
   its own content instead of growing the page. */
.gtm-chat-input { resize: none; overflow-y: hidden; min-height: 2.4rem; max-height: 9rem; }
/* Geometry only — the template applies Bootstrap's own `.btn.btn-primary`, so the colours,
   disabled treatment and per-theme contrast fixes (§#940 A2 / §#970) all come from the shared
   button component rather than a second hand-tuned copy of it. */
.gtm-chat-send {
  display: inline-flex; align-items: center; justify-content: center;
  width: 2.4rem; height: 2.4rem;
}
.gtm-chat-composer-note { grid-column: 1 / -1; margin: 0; font-size: .72rem; color: var(--bs-secondary-color); }
/* Phase 5 review fix 8: the connection-state mirror (static/chat.js `notice()`) — always on
   screen beside the composer, unlike the top-of-thread `.gtm-chat-notice` a follow-scrolled
   reader never sees. "lost" gets a distinguishing treatment (an icon + the existing warning
   token, no new hue) so it reads as a state change, not identical muted status text; "up"
   (Reconnected.) stays the plain muted look — a confirmation, not an alarm. */
.gtm-chat-connection-note:empty { display: none; }
.gtm-chat-connection-note[data-gtm-connection-state="down"] {
  color: var(--bs-warning-text-emphasis); font-weight: 600;
  display: flex; align-items: center; gap: .3rem;
}
.gtm-chat-connection-note[data-gtm-connection-state="down"]::before {
  content: ""; width: .5rem; height: .5rem; border-radius: 999px;
  background: var(--bs-warning-text-emphasis); flex: none;
}
/* Density only — these ride Bootstrap's `.btn.btn-sm.btn-outline-secondary`, which is where the
   dark-theme label-contrast fix (§#940 A2 U6) lives. Re-declaring border/background here would
   have opted the chat surface out of that fix. */
.gtm-chat-btn { font-size: .75rem; padding: .15rem .5rem; }
.gtm-chat-btn-quiet { --bs-btn-border-color: transparent; }
.gtm-chat-icon-btn {
  display: inline-flex; align-items: center; gap: .2rem;
  border: 0; background: none; color: var(--bs-secondary-color); font-size: .75rem; padding: .1rem .2rem;
  border-radius: .25rem; text-decoration: none;
}
.gtm-chat-icon-btn:hover { color: var(--bs-body-color); background: var(--bs-tertiary-bg); }
.gtm-chat-icon-btn:focus-visible { outline: 2px solid var(--bs-primary); outline-offset: 1px; }
.gtm-turn-failure {
  display: flex; flex-wrap: wrap; align-items: center; gap: .4rem;
  margin-top: .4rem; padding: .35rem .5rem; font-size: .78rem;
  border: 1px solid var(--bs-danger-border-subtle); background: var(--bs-danger-bg-subtle);
  color: var(--bs-danger-text-emphasis); border-radius: .3rem;
}
/* #1150: the detail is a SENTENCE now (the typed failure's own message), so it reads as prose;
   the raw reason code stays monospace beside it — the operator quotes it, the developer greps it. */
.gtm-turn-failure-reason { overflow-wrap: anywhere; font-size: .75rem; }
.gtm-turn-failure-code {
  overflow-wrap: anywhere; font-family: var(--bs-font-monospace); font-size: .7rem;
  opacity: .75; padding: 0 .2rem; border-radius: .2rem; background: var(--bs-body-bg);
}
/* Phase 5 review fix 7: the retryable/terminal distinction used to be scannable only by reaching
   the Retry button (present) or not (absent). Same danger-box color as its siblings — no new
   token — muted by opacity alone, matching `.gtm-turn-failure-code`'s own treatment above. */
.gtm-turn-failure-terminal { overflow-wrap: anywhere; font-size: .75rem; font-style: italic; opacity: .8; }
/* #1298 D1: sits directly under the kept-visible streamed answer, above the `.gtm-turn-failure`
   box that states why — a short, muted flag rather than another danger-colored box (the failure
   box already owns "this needs attention"; this one only needs to mark WHICH text above it is
   unsaved). */
.gtm-turn-transcript-incomplete {
  margin-top: .3rem; font-size: .72rem; font-style: italic; opacity: .75;
}

/* ── Empty state (addendum item 8) ────────────────────────────────────────────
   The purpose line says what this surface IS, and the starter prompts are the one action that
   fills it — an empty state that only apologises for being empty teaches nothing. */
.gtm-chat-empty { display: flex; flex-direction: column; gap: .5rem; padding: .5rem .25rem; }
.gtm-chat-empty-purpose { margin: 0; font-size: .85rem; color: var(--bs-secondary-color); }
.gtm-chat-starters { display: flex; flex-direction: column; gap: .3rem; margin: 0; }
.gtm-chat-starter {
  text-align: start; width: 100%; font-size: .82rem; padding: .35rem .55rem;
  border: 1px dashed var(--bs-border-color); border-radius: .4rem;
  background: none; color: var(--bs-body-color);
}
.gtm-chat-starter:hover { background: var(--bs-tertiary-bg); border-style: solid; }
.gtm-chat-starter:focus-visible { outline: 2px solid var(--bs-primary); outline-offset: 1px; }

/* ── Full-page mount ──────────────────────────────────────────────────────────
   The SAME thread partial, arranged wider: the history becomes a left rail instead of a stacked
   header block. Nothing about the thread itself changes — that is the same-partial rule. */
.gtm-chat-page-head { display: flex; align-items: baseline; gap: .75rem; flex-wrap: wrap; margin-bottom: .75rem; }
.gtm-chat-page-body { display: grid; gap: 1rem; grid-template-columns: 1fr; }
@media (min-width: 992px) {
  /* #1224: the wide mount is a viewport-bounded flex column, the same shape #1193 gave the
     slideover — not the same rules, because the chrome differs: the slideover fills a `position:
     fixed` offcanvas, while this one sits inside `base.html`'s `<main class="container py-4">`
     between a nav and a footer. `<chrome>` measured in-browser at 1280px wide: nav 76px + footer
     50.7px + main's `py-4` (22.5 + 22.5, this app's root font-size is 15px) = 171.7px. The
     subtrahend is rounded UP to `11.75rem` (176.25px) so the few px of headroom absorb a
     sub-pixel footer or a slightly taller nav rather than reintroducing a window scrollbar —
     which is the whole contract (`tests/client/test_1183_chat_scroll_contract.py`'s
     `maxScrollY == 0`). `dvh`, not `vh`, so a mobile browser's collapsing URL bar does not
     leave the composer under the fold. */
  .gtm-chat-page { display: flex; flex-direction: column; height: calc(100dvh - 11.75rem); }
  .gtm-chat-page-body {
    grid-template-columns: 16rem 1fr;
    /* `align-items: start` replaces the grid default `stretch`, which was inflating the rail
       column to match whichever track was taller. The row is pinned to the container's own
       (now definite) height instead, so an overlong transcript cannot grow the grid — it
       scrolls inside `.gtm-chat-messages`, the one scroller. */
    align-items: start;
    align-content: start;
    grid-auto-rows: minmax(0, 100%);
    flex: 1 1 auto;
    min-height: 0;
  }
  /* Re-stretched explicitly: the thread column is the one track that SHOULD fill the row, and it
     has to pass a real height down to `.gtm-chat-thread` -> `.gtm-chat-messages` or the log falls
     back to its content height and the bound is decorative. */
  .gtm-chat-page-main { align-self: stretch; display: flex; flex-direction: column; min-height: 0; }
  .gtm-chat-page .gtm-chat-messages {
    flex: 1 1 auto;
    min-height: 0;
    max-height: none;
    /* Load-bearing, and the SECOND leak behind the reported window scrollbar: the per-turn
       `.visually-hidden` speaker labels ("You:" / "Assistant:") are `position: absolute` (a
       Bootstrap utility). With no positioned ancestor they resolve against the INITIAL containing
       block, escaping this element's `overflow-y: auto` entirely and inflating
       `documentElement.scrollHeight` to the full transcript height — measured at 5261px on a
       40-turn thread at 1280x700 while the log itself was correctly clipped to 434px. Making the
       scroller a containing block keeps them inside it. Scoped to the page mount: the slideover's
       `position: fixed` offcanvas is already a containing block, so it never leaked. */
    position: relative;
  }
  .gtm-chat-page-rail .gtm-chat-sessions { border-bottom: 0; border-inline-end: 1px solid var(--bs-border-color);
    padding-inline-end: .75rem; }
  .gtm-chat-page-rail .gtm-journal { max-height: 60vh; }
  /* In the rail the row is half the slideover's width, and the single-line grid let the timestamp
     crush every title to "Pi…" / "Unti…" — the gist is the one thing the row exists to show, so
     it takes the full width and the time drops to its own line. Live-verified at 1280px. */
  .gtm-chat-page-rail .gtm-journal-link {
    grid-template-columns: auto 1fr;
    row-gap: .1rem;
  }
  .gtm-chat-page-rail .gtm-journal-time { grid-column: 1 / -1; }
}

/* ── v4.1.2.5 — the BYOK Models transfer list (/settings/llm) ─────────────────
   Monospaced rows for one reason each: a model id IS an identifier (it was <code> in the panel
   this replaced), and the status glyphs ✓ ○ ✕ – have different advance widths in the body face,
   which left the ids starting at four different x positions down a single listbox. */
.provider-models-avail, .provider-models-chosen {
  font-family: var(--bs-font-monospace);
  font-size: .8125rem;
}
/* The arrows sit beside boxes that are 8 rows tall; without this they stack against the labels
   instead of the lists they move rows between. */
.provider-models-arrows { padding-top: 1.6rem; }
