/* ══ THE CHAT — Backbone ═══════════════════════════════════════════════════════
   The chat, the /open/ front door and the /join/ invite share this file.

   TWO ACCENTS, AND THE SPLIT IS THE WHOLE POINT
   ---------------------------------------------
   --accent  is the TENANT's colour. app.js sets it INLINE on <html> from the bot's
             config (with a WCAG-picked --accent-fg beside it), and an inline style
             beats every stylesheet. It carries the things that are *the bot's*: its
             identity mark, the primary send button, the user's own bubble, the
             active/selected states, and the marks on the sources it cited.
   --sig     is BACKBONE's signal cyan, from public/css/backbone.css. It carries the
             SHELL: rules, borders, focus rings, mono micro-labels, the thinking
             indicator, the streaming caret. It is identical for every tenant, so
             two customers' embeds are recognisably the same product while still
             looking like theirs.
   Never write --accent where the meaning is "this is Grounded", and never write
   --sig where the meaning is "this is your bot".

   THE THREE ANSWER STATES ARE NOT ERROR STYLING
   ---------------------------------------------
   --ok          a grounded answer that cited what it used
   --ungrounded  it answered, but could not support every detail
   --warn        it refused: the question was outside what it was given
   backbone.css resolves --warn and --ungrounded to the SAME hue, so the three are
   told apart by FORM, not colour: a refusal tints its bubble and carries an inset
   bar plus a tag; an ungrounded answer leaves its bubble alone and hangs an
   outlined badge beneath it; a grounded answer gets the cited-sources rule.

   TOKENS BELOW ARE A FALLBACK, NOT THE PALETTE. public/css/backbone.css is linked
   AFTER this file on all three pages and re-values every name here in both themes
   (dark is the default). These light values are what the page renders with in the
   window before Backbone arrives, and if it ever fails to arrive. Do not add a dark
   block here — theme.css/backbone.css own the themes and would outrank it anyway.
   ========================================================================== */
:root {
  --bg: #e3eff4;
  --surface: #f7fbfd;
  --surface2: #e8f2f6;
  --border: rgba(6, 36, 48, 0.20);
  --accent: #075260;   /* signal cyan, light-read; overridden at runtime from bot config */
  --accent-fg: #ffffff;   /* readable text/icon colour ON the accent; recomputed in app.js per bot */
  --accent-soft: color-mix(in srgb, var(--accent) 11%, transparent);
  --accent-ring: color-mix(in srgb, var(--accent) 40%, transparent);
  /* THE TENANT ACCENT, USED AS TEXT. --accent-fg is the colour readable ON the accent; this is its
     dual — the colour to use when the accent IS the glyphs and the surface is behind them. They are
     different problems and only the first one was solved.
       measured, tenant accent #0F6E7E as text on the dark chat surface:  2.87:1  — fails 1.4.3
       the same accent as text on the light chat surface:                 6.9:1   — passes
     A tenant picks one hex and it is used in BOTH themes, so an accent that reads on paper can be
     two stops too dark on the dark surface — which is the default here. Everything that set the
     accent as text inherited that: the citation numerals, the source count, and the hover colour of
     all three disclosure controls.

     NOT --accent-text, which already exists and means something else. backbone.css declares it as a
     fixed pair with its OWN accent (#8ceef1 dark / #05454f light, beside --accent: #5ce1e6) — the
     shell's cyan. On this surface app.js overwrites --accent inline from the bot's config and sets
     --accent-fg beside it, but never --accent-text, so that token stays backbone cyan no matter
     whose bot is loaded. Reaching for it would have painted every tenant's citations the same
     shell colour and quietly broken the two-accent rule at the top of this file. It is derived
     rather than declared per theme for the same reason --accent-soft is: --text is white on the
     dark surface and near-black on the light one, so mixing toward it always moves AWAY from the
     background, and one declaration stays correct when the reader flips the theme. 55% keeps the
     hue unmistakably the tenant's.
       measured after the change, same accent: 6.02:1 dark, 9.17:1 light.
     Use --accent for FILLS, rules and marks — 1.4.3 does not apply there and the tenant's exact hue
     is the point. Use this wherever the accent is glyphs a person reads. */
  --accent-on-surface: color-mix(in srgb, var(--accent) 55%, var(--text));
  /* Backbone chrome. Re-declared (not just consumed) so a missing backbone.css
     degrades to a flat, readable page instead of dropping every rule that reads
     one of these. Values mirror backbone.css's LIGHT block, matching the palette
     immediately above. */
  --sig: #075260;
  --sig-ink: #ffffff;
  --sig-text: #05454f;
  --line: rgba(6, 36, 48, 0.20);
  --line-hi: rgba(6, 36, 48, 0.42);
  --control-border: rgba(6, 36, 48, 0.42);
  /* WCAG 1.4.11 wants 3:1 for the visible boundary of a control. backbone.css's
     --control-border is rgba(6,36,48,.42) in the light theme, and its comment claims
     3.4:1 on --surface; recomputed against #f7fbfd it is 2.57:1, so every input
     outline on a light chat would fail. That file belongs to another track, so the
     fix is local: derive a control edge from --muted, which flips correctly with the
     theme AND with high contrast, at a strength that clears 3:1 on both sides.
       light  #1d4655 60% over #f7fbfd  ->  3.29:1
       dark   #d8ecf4 60% over #08232f  ->  5.63:1
     Used for the boundary of every INTERACTIVE control (fields, outlined
     buttons, chips, the empty stars). --line-hi still carries decorative hairlines,
     where 1.4.11 does not apply. */
  --control-edge: color-mix(in srgb, var(--muted) 60%, var(--surface));
  --text: #062430;
  --muted: #1d4655;
  --text-muted: #1d4655;
  --faint: rgba(29, 70, 85, 0.78);
  --ok: #075260;
  --warn: #7a4e07;   /* 6.4:1 as body text on --surface — WCAG 1.4.3 */
  --ungrounded: #7a4e07;
  --err: #9c2b36;    /* 7.2:1 on --surface */
  --radius: 0px;     /* Backbone is square. The only curve is the hexagon. */
  --serif: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;   /* Backbone has no serif voice */
  --font: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
  --mono: ui-monospace, "Cascadia Mono", "SF Mono", "Segoe UI Mono", Menlo, Consolas, monospace;
  --shadow-card: 0 24px 60px rgba(6, 36, 48, 0.16);
  --backdrop: rgba(6, 36, 48, 0.42);
  --user-bubble: rgba(6, 36, 48, 0.07);
  --bot-bubble: rgba(7, 82, 96, 0.07);
  --ease: cubic-bezier(0.16, 1, 0.3, 1);
  /* ── THE MOTION SCALE ───────────────────────────────────────────────────────
     Four durations, and every transition on this surface picks one of them by
     what it MEANS, not by how it feels in isolation. Before this there was one
     token (--t-ui) and a scatter of literals — 180ms, 220ms, 260ms, 320ms — so
     two controls doing the same job moved at different speeds and the surface
     read as a set of unrelated widgets.

       --t-tap     a control acknowledging a press or a hover. Below ~120ms the
                   change reads as instant while still being interpolated.
       --t-ui      a small control state: hover tint, border, icon rotation.
       --t-state   one content state BECOMING another: a disclosure opening, the
                   working state giving way to the answer.
       --t-layout  the largest move allowed here — a box resizing around content
                   that has already arrived. Nothing on this surface is slower.

     --t-exit is not a fifth step, it is the house rule that leaving is faster
     than arriving (design-reference §house tokens): a closing panel is a result
     the reader has already asked for, so it should not be dwelt on.

     AND EXITS GET THEIR OWN CURVE. --ease is an exponential ease-OUT: it covers
     most of the distance immediately and decelerates into the destination, which
     is right for something arriving and wrong for something leaving. Measured on
     a closing source panel it collapsed 91% of its height in the first 60ms and
     then spent the remaining 110ms creeping through the last half-pixel — the
     panel looked shut and the interface still felt busy. That tail is what reads
     as lag. --ease-exit accelerates instead: slow off the mark, gone by the end,
     so the moment the motion stops is the moment it looks finished.

     Pair arrivals with --ease and departures with --ease-exit. Never plain
     `ease`, never a bounce. backbone.css re-values --ease and --t-ui in both
     themes; the four names it does not declare are owned here. ── */
  --t-tap: 110ms;
  --t-ui: 160ms;
  --t-state: 240ms;
  --t-layout: 300ms;
  --t-exit: 170ms;
  --ease-exit: cubic-bezier(0.45, 0, 0.9, 0.85);
  /* A pointy-top hexagon: the bot's identity mark, and the one curved thing here. */
  --hex-clip: polygon(50% 0%, 100% 25%, 100% 75%, 50% 100%, 0% 75%, 0% 25%);
  /* syntax-highlight token colours (light) */
  --hl-keyword: #0b4a8c;
  --hl-string: #10603f;
  --hl-number: #7a4e07;
  --hl-comment: rgba(29, 70, 85, 0.72);
  --hl-function: #075260;
  --hl-tag: #9c2b36;
  --hl-attr: #6b4a12;
  --hl-punct: rgba(29, 70, 85, 0.85);
}

/* ══ GEOMETRY, ENFORCED ════════════════════════════════════════════════════════
   Radius 0 is an identity switch, not a preference, so it has to hold across the
   WHOLE chat — including the feature modules under public/js/ (settings, uploads,
   artifacts, share/export, voice, consent, capabilities…). Each of those appends
   its own <style> to <head> at import time, i.e. AFTER both this file and
   backbone.css, so their class rules beat backbone's `* { border-radius: 0 }` on
   source order and their pills/cards would stay rounded inside a square chat.
   Re-asserting it here is the one-line fix; the alternative is editing thirteen
   modules and dragging thirteen ?v= cascades behind them.

   The allow-list carries TWO exemptions now, and they are different in kind.

   1. OBJECTS THAT ARE ROUND. `.gcb-vc-orb` is the voice-conversation sphere, a lit
      3D object whose whole read depends on being a ball. The bot's own mark is a
      hexagon, drawn with clip-path, so it needs nothing here.

   2. THE COMPOSER'S OWN CONTROLS — `.composer-action` (attach, the two capability
      menus, the mic) and `.composer-send`. This one is a deliberate, requested
      exception to the geometry rather than an object that happens to be round, so
      it is worth saying plainly what it costs: the chat now has one place where
      radius is not 0. It is bounded to that place. The buttons live INSIDE the
      composer's own square box, four of them within a few pixels of its corners,
      and small hard-edged squares nested in a larger square read as boxes drawn
      inside a box rather than as controls; a circle has no corner to argue with
      the one around it. The header's icon row looks identical, sits in open space
      with nothing to collide with, and stays square — which is the test for
      whether this exemption is being applied honestly or just spreading.
      The values are NOT here: `.input-row .composer-action` and `#send-btn`, far
      below, set them next to the rest of those controls' geometry. This list only
      says the enforcer stands down for them.

   `.gcb-auth-avatar` and `.gcb-acct-av` were on this list and are not any more:
   the two account avatars they named no longer exist. Both headers mount the one
   shared profile control (js/ui/account-menu.js), whose avatar is `.bb-avatar` —
   a TILE, square by system, the same object the admin and operator consoles show.
   An account avatar was the one round thing in a square product; it is not one now,
   so it needs no exemption from the rule that makes the product square.

   Everything named here is a CLASS on purpose. An id inside `:not()` lifts the
   whole selector to id specificity, and this rule already matches nearly every
   element on the page — raising it changes which `!important` wins in thirteen
   modules that have nothing to do with the composer.
   ------------------------------------------------------------------------- */
*:not(.bb-round, .gcb-vc-orb, .gcb-vc-orb-inner, .composer-action, .composer-send),
*:not(.bb-round, .composer-action, .composer-send)::before,
*:not(.bb-round, .composer-action, .composer-send)::after {
  border-radius: 0 !important;
}
*,
*::before,
*::after {
  box-sizing: border-box;
  margin: 0;
  padding: 0;
}
/* `hidden` must always win — many components set `display` on the same element, which would
   otherwise override the UA [hidden] rule (e.g. header buttons that hide when unavailable). */
[hidden] { display: none !important; }

/* ── Accessibility baseline ──────────────────────────────────────────────────── */

/* Skip-to-content: off-screen until focused, then pinned to the top-left over everything. Lets a
   keyboard / screen-reader user jump straight to the composer past the header + history rail. */
/* Shell, not tenant: the skip link is the same object on every bot, so it is --sig.
   Solid fill rather than an outline — it has to be unmissable the instant it lands. */
.skip-link {
  position: fixed;
  top: 8px;
  left: 8px;
  z-index: 1000;
  padding: 11px 18px;
  background: var(--sig);
  color: var(--sig-ink);
  border: 1px solid var(--sig);
  font-family: var(--mono);
  font-size: 11px;
  font-weight: 700;
  letter-spacing: 0.18em;
  text-transform: uppercase;
  text-decoration: none;
  box-shadow: var(--shadow-card);
  transform: translateY(-150%);
  opacity: 0;
  transition: transform var(--t-ui) var(--ease), opacity var(--t-ui) var(--ease);
}
.skip-link:focus,
.skip-link:focus-visible {
  transform: translateY(0);
  opacity: 1;
  outline: 2px solid var(--text);
  outline-offset: 2px;
}

/* Visually-hidden utility: kept in the accessibility tree (screen readers) but removed from the
   visual layout — the standard clip pattern. Used for speaker prefixes on chat bubbles and the
   projects reorder live region. */
.visually-hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0 0 0 0);
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}

/* Strong, theme-aware focus ring for EVERY interactive control. Individual components may still add
   their own tuned :focus-visible (offset/inset); this is the baseline so nothing is ever unreachable
   or invisible to keyboard users. Mouse clicks (which don't match :focus-visible) stay ring-free.
   SHELL, so --sig and not --accent: a tenant may pick an accent that all but vanishes against its own
   bubbles, and the keyboard ring is not a place to let a brand choice cost someone the interface. */
a:focus-visible,
button:focus-visible,
input:focus-visible,
textarea:focus-visible,
select:focus-visible,
[tabindex]:focus-visible,
[role="button"]:focus-visible {
  outline: 2px solid var(--sig);
  outline-offset: 3px;
}

/* Respect a reduced-motion preference globally: kill looping/entrance animations and transitions,
   and stop smooth-scroll jumps. Individual blocks below also opt out; this is the safety net. */
@media (prefers-reduced-motion: reduce) {
  *,
  *::before,
  *::after {
    animation-duration: 0.001ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.001ms !important;
    scroll-behavior: auto !important;
  }
}

/* ── Scrollbars: thin, square, themed ── */
* {
  scrollbar-width: thin;
  scrollbar-color: var(--line-hi) transparent;
}
*::-webkit-scrollbar {
  width: 10px;
  height: 10px;
}
*::-webkit-scrollbar-track {
  background: transparent;
}
*::-webkit-scrollbar-thumb {
  background: var(--line-hi);
  border: 3px solid transparent;   /* padding effect → slim, inset thumb */
  background-clip: padding-box;
}
*::-webkit-scrollbar-thumb:hover {
  background: var(--sig);
  background-clip: padding-box;
}
*::-webkit-scrollbar-corner {
  background: transparent;
}

html,
body {
  height: 100%;
  color: var(--text);
  font-family: var(--font);
  font-size: 1rem;
  line-height: 1.6;
}
html {
  background: var(--bg);
}
/* No gradient of our own. backbone.css paints ONE continuous gradient on <body> for
   the whole platform, and `html[data-chrome="operate"]` (which index.html carries)
   swaps it for the short, fixed product variant. Painting an accent wash here would
   only fight it — and would tint the shell with the tenant's colour, which is the
   one thing the --sig/--accent split exists to prevent. */
body {
  background-attachment: fixed;
}

/* ── Per-bot background layer ──
   A fixed, non-interactive layer BEHIND the app. Deliberately NOT `body { background: … }`: the body
   already carries the accent wash + `var(--bg)`, and painting over that shorthand would fight the
   theme tokens. It sits at z-index 0 — a NEGATIVE z-index would be painted under body's own opaque
   background and never be seen — and #app is merely made `position: relative` (deliberately with NO
   z-index, so it does not become a stacking context and the existing z-index 99/100 drawer keeps
   the exact paint order it had). Both are then in the same z-index:0/auto positioned group, where
   DOM order decides, and #app comes second.

   Two stacked layers so an 'auto' background can CROSS-FADE rather than flicker: app.js paints the
   idle one and swaps which carries `.on`.

   It paints nothing at all until app.js adds `.has-bg` — a bot with no background configured is
   completely unaffected, which is the whole point. */
#chat-bg {
  position: fixed;
  inset: 0;
  z-index: 0;
  pointer-events: none;
  overflow: hidden;
}
.chat-bg-layer {
  position: absolute;
  inset: 0;
  display: block;
  background-position: center center;
  background-size: cover;
  background-repeat: no-repeat;
  opacity: 0;
  /* DELIBERATELY OFF THE MOTION SCALE, and the only thing on this surface that is. The scale times
     STATE CHANGES — something the reader did, or the assistant did, resolving. This is ambient: a
     viewport-sized image arriving behind the conversation, once, with nothing waiting on it. At
     --t-layout it reads as a flash; slow is what makes it not an event. Nothing else may borrow
     this number. */
  transition: opacity 0.55s ease;
}
.chat-bg-layer.on {
  opacity: 1;
}
/* Contrast scrim. Text sits on tokenised surfaces, but the gaps between bubbles show the image
   through, so a wash keeps the whole column readable. Light needs a bright veil, dark a dark one —
   both selectors, so an explicit toggle beats the OS preference either way. */
#chat-bg::after {
  content: "";
  position: absolute;
  inset: 0;
  /* Tokenised against the gradient's own deepest/palest band, so the veil tracks the
     theme instead of needing a light and a dark literal. 72% keeps body text over the
     gaps between bubbles above 4.5:1 on the images this platform actually ships. */
  background: var(--chat-bg-scrim, color-mix(in srgb, var(--deep-1, #e3eff4) 72%, transparent));
  opacity: 0;
  transition: opacity 0.55s ease;
}
#chat-bg.has-bg::after {
  opacity: 1;
}
@media (prefers-reduced-motion: reduce) {
  .chat-bg-layer,
  #chat-bg::after {
    transition: none;
  }
}

/* ══ THE BOOT SKELETON ═════════════════════════════════════════════════════
   `#app-boot` in index.html. The chat's shell cannot paint until GET /api/bot answers — the gate
   decision depends on it, and revealing a composer that an access wall then covers is the flash of
   protected content `#app[hidden]` exists to prevent. So the highest-traffic page in the product
   painted a blank white document for one round trip.

   This is what it paints instead: the same three bands as the real shell, in the same places, at
   the same sizes, carrying NO bot data of any kind. It is removed in one place, `revealChat()` in
   app.js, which both outcomes (open chat, access gate) pass through.

   IT IS ALSO WHAT MAKES THE PAGE FADE POSSIBLE. A cross-document view transition begins at the
   incoming document's FIRST RENDER; with a blank first render the browser cross-fades the page the
   reader was on into nothing. That is the stated disqualifier in docs/reference/page-transitions.md
   and the reason `/` was excluded from it. With a first render that has the shape of the page, the
   exclusion no longer applies.

   The dimensions are MEASURED against the rendered shell rather than derived from its declared
   paddings, because twice the two disagreed: the header is 69px (it carries an h1 AND the standing
   sources line, not just the 28px mark) and the composer is 131px (its controls mount into the bar
   after first paint). Reading them off the live page is the only way to get a placeholder that the
   real thing lands exactly on top of.
   `--measure` is restated because it is declared on `#app`, which is not this element's ancestor. */
#app-boot {
  --measure: 39rem;
  display: flex;
  flex-direction: column;
  height: 100dvh;
  width: 100%;
  overflow: hidden;
}
.boot-header {
  flex-shrink: 0;
  display: flex;
  align-items: center;
  gap: 12px;
  /* 69px is the real header MEASURED in a browser, not derived from the padding: the bar holds an
     h1 and the standing "answers from this bot's sources" line under it, so its content box is
     taller than the 28px mark this skeleton draws. Reserving 13+32+13 = 58 left a 11px step at the
     swap. */
  min-height: 69px;
  padding: 13px max(20px, calc((100% - var(--measure)) / 2));
  border-bottom: 1px solid var(--line-hi);
  background: var(--surface);
}
.boot-mark {
  flex-shrink: 0;
  width: 28px;
  height: 32.3px;         /* 28 × 1.1547 — the hex ratio .logo-mark uses */
  clip-path: var(--hex-clip);
}
.boot-title {
  height: 15px;           /* the h1's 17px/1.15 line box, less its optical padding */
  width: min(190px, 45%);
  border-radius: 4px;
}
.boot-transcript {
  flex: 1;
  display: flex;
  flex-direction: column;
  gap: 16px;
  padding: 26px max(20px, calc((100% - var(--measure)) / 2));
  overflow: hidden;
}
.boot-line {
  height: 13px;
  border-radius: 4px;
}
.boot-composer {
  flex-shrink: 0;
  /* Measured, for the same reason: `.input-row` renders at 131px once its controls have mounted,
     not the 124px its declared paddings add up to. */
  height: 131px;
  margin: 12px max(20px, calc((100% - var(--measure)) / 2)) 14px;
  border: 1px solid var(--control-edge);
  background: var(--surface);
}
@media (max-width: 640px) {
  .boot-transcript { padding: 14px; }
  .boot-composer { margin: 10px 14px 12px; }
}
/* The shimmer comes from backbone.css's `.bb-skeleton`, which already honours both reduced-motion
   signals. Nothing here re-declares an animation, so there is nothing here to reduce. */

/* Full-viewport app: a docked left rail + a chat column that fills the rest — no centered card.
   --measure keeps the conversation itself at a comfortable reading width, centered inside the
   chat column, so collapsing the rail only recenters the content gently instead of jolting it. */
#app {
  /* 39rem = 624px, which at the transcript's 16px body is ~72ch — inside the 65-75ch band and a
     peer of the marketing long-form measure (`.doc { max-width: 74ch }`, home/home.css). It was
     46rem/736px, or ~85ch by the `ch` unit and very close to 100 actual prose characters per line,
     so the product's own reading surface was materially wider than the marketing copy selling it.
     Stated in rem, not ch, ON PURPOSE: custom properties resolve at the USE site, and this one is
     consumed by the header, composer and footer as well as the message list — all of which set
     their own font-size, so a `ch` value would compute differently in each and pull the four
     columns out of alignment. rem is font-size-independent, so the gutter maths below stays exact. */
  --measure: 39rem;
  --rail: 300px;
  display: flex;
  flex-direction: column;
  height: 100dvh;
  width: 100%;
  overflow: hidden;
  /* Paints above #chat-bg purely by DOM order (see the note there). No z-index on purpose. */
  position: relative;
}
/* The `hidden` attribute must actually hide the app — otherwise `display:flex` above wins and the
   chat renders behind an auth wall / before load. Kept so a gated visitor sees ONLY the login. */
#app[hidden] { display: none !important; }

/* Chat column: header + messages + composer. On mobile it's the sole in-flow child of #app; on
   desktop it sits to the right of the docked history/projects panel (both in the SAME flex row).
   Its inner content is centered to --measure via a fluid inline gutter. */
.chat-column {
  flex: 1;
  min-width: 0;
  min-height: 0;
  display: flex;
  flex-direction: column;
  overflow: hidden;
}

/* ── Header ── */
header {
  flex-shrink: 0;
  padding: 0;
  border-bottom: 1px solid var(--line-hi);
  background: var(--surface);
}
.header-inner {
  display: flex;
  align-items: center;
  gap: 12px;
  padding: 13px max(20px, calc((100% - var(--measure)) / 2));
  min-width: 0;
}

/* ── The bot's identity mark ───────────────────────────────────────────────────
   THE HEXAGON RULE. Backbone's only curve is a hexagon, and here it earns its
   keep: this is the one element on the page that is purely the tenant's, so it is
   the one shape that is not a rectangle. Everything else in the chat is square.
   (A circle in this system means a human face — see the geometry reset at the top
   — and a bot is not a face.)

   Sized off --mark-w so a hexagon stays a TRUE hexagon at every size: a pointy-top
   hex of width w is w × 1.1547 tall, and clipping a square with --hex-clip would
   squash it. Every caller sets --mark-w and inherits the ratio.
   --accent is inline from the bot config, so this recolours itself per tenant. */
.logo-mark {
  --mark-w: 28px;
  flex-shrink: 0;
  width: var(--mark-w);
  height: calc(var(--mark-w) * 1.1547);
  position: relative;
  clip-path: var(--hex-clip);
  background: linear-gradient(
    158deg,
    color-mix(in srgb, var(--accent) 78%, #ffffff),
    var(--accent) 52%,
    color-mix(in srgb, var(--accent) 72%, #000000)
  );
}
/* A node on the backbone: one small square in the WCAG-picked foreground app.js
   computes for this accent. Never a hard-coded white — a pale bot accent had a
   white pip disappearing into its own mark. */
.logo-mark::after {
  content: "";
  position: absolute;
  left: 50%;
  top: 50%;
  width: calc(var(--mark-w) * 0.21);
  height: calc(var(--mark-w) * 0.21);
  transform: translate(-50%, -50%);
  background: var(--accent-fg, #ffffff);
}
/* An uploaded logo takes the same silhouette, so a bot with art and a bot without
   read as the same species of object. */
.logo-img {
  --mark-w: 28px;
  flex-shrink: 0;
  width: var(--mark-w);
  height: calc(var(--mark-w) * 1.1547);
  clip-path: var(--hex-clip);
  object-fit: cover;
  background: var(--surface2);
}
.header-text {
  flex: 1;
  min-width: 0;
}
h1 {
  font-family: var(--font);
  font-size: 17px;
  font-weight: 700;
  letter-spacing: 0.01em;
  line-height: 1.15;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
/* The header bot name links to the bot's profile page (/b/); reads as text, underlines on hover. */
.bot-name-link { color: inherit; text-decoration: none; }
.bot-name-link:hover, .bot-name-link:focus-visible { text-decoration: underline; text-underline-offset: 3px; }
/* Uppercase mono eyebrow: the standing statement of what this thing is and is not.
   --muted is >= 4.5:1 on every band of the gradient in both themes (backbone.css).
   11px, not 10px: this is the one line that tells a visitor what the bot will and won't answer, so
   it has to be legible, not merely present. It still fits the mobile header budget — h1 (17 × 1.15)
   + 5px margin + 11 × 1.6 = 42.2px, under the 44px the header row is already sized to. */
.subtitle {
  font-family: var(--mono);
  font-size: 11px;
  font-weight: 600;
  letter-spacing: 0.2em;
  text-transform: uppercase;
  color: var(--muted);
  margin-top: 5px;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
/* Step 17 — the same eyebrow, carrying the assistant's own data snapshot and linking to the page
   that lists every source. Inherits the eyebrow entirely: no colour of its own (the header's one
   accent belongs to the bot), no size of its own, and the affordance is a dotted underline that
   becomes solid on hover — an eyebrow is not a body link and must not read as a button. */
.snapshot-link {
  color: inherit;
  text-decoration: none;
  border-bottom: 1px dotted currentColor;
  transition: color var(--t-ui) var(--ease);
}
.snapshot-link:hover,
.snapshot-link:focus-visible {
  color: var(--text);
  border-bottom-style: solid;
}
/* The preamble is dropped by app.js `fit()` when the line would not fit, not by a width rule — see
   the note above applyKnowledgeSnapshot for why the choice has to be measured rather than declared.
   `[hidden]` needs no help here (the span is inline, with no explicit display), but the attribute is
   the only thing toggling it, so it is stated. */
.snapshot-lead[hidden] { display: none; }

/* ── Messages ── */
main {
  flex: 1;
  display: flex;
  flex-direction: column;
  overflow: hidden;
  position: relative;   /* positioning context for the floating scroll-to-bottom button */
}

/* Scroll-to-bottom button (Feature S2.4): floats over the messages, above the composer, only when
   the visitor has scrolled up away from the latest message. */
.scroll-bottom {
  position: absolute;
  right: 18px;
  bottom: 88px;
  z-index: 5;
  width: 38px;
  height: 38px;
  border: 1px solid var(--control-edge);
  background: var(--surface);
  color: var(--text);
  display: flex;
  align-items: center;
  justify-content: center;
  cursor: pointer;
  box-shadow: var(--shadow-card);
  transition: background var(--t-ui) var(--ease), border-color var(--t-ui) var(--ease), color var(--t-ui) var(--ease);
}
.scroll-bottom:hover {
  border-color: var(--sig);
  color: var(--sig-text);
  background: var(--surface2);
}
.scroll-bottom:focus-visible {
  outline: 2px solid var(--sig);
  outline-offset: 2px;
}
.scroll-bottom[hidden] {
  display: none;
}

/* Per-message timestamp (Feature S2.4): a mono measurement caption under each bubble.
   Mono is for data and labels in this system; a timestamp is data. */
.msg-time {
  font-family: var(--mono);
  font-size: 10px;
  font-variant-numeric: tabular-nums;
  color: var(--faint);
  margin-top: 5px;
  padding: 0 1px;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  transition: opacity var(--t-ui) var(--ease);
}
#messages {
  flex: 1;
  overflow-y: auto;
  padding: 26px max(20px, calc((100% - var(--measure)) / 2));
  scroll-behavior: smooth;
  display: flex;
  flex-direction: column;
  gap: 16px;
  scroll-padding-block: 24px 110px;
}
#messages:focus-visible { outline: 3px solid var(--accent); outline-offset: -4px; }

/* ── Machine disclosure (EU AI Act Art. 50) ──
   The transcript's permanent first child. Deliberately NOT tinted with --accent: that token is
   re-valued per bot at runtime from the owner's config, and a disclosure whose contrast the
   disclosed party controls is not a disclosure. It uses the shell's own band + hairline, so it
   reads as the platform speaking rather than the bot, and it clears AA in both themes and in
   forced high contrast (--surface2/--text/--line-hi all flip with the theme).
   `flex: 0 0 auto` is load-bearing, not tidiness: #messages is a flex column, and the smallest
   frame this ever renders in is the 400x600 iframe the admin Access page hands out as the embed
   snippet. Without it the disclosure is the one child that compresses when the transcript
   overflows — squashed out of legibility in exactly the context where a visitor is least likely
   to already know what they are talking to. */
.ai-disclosure {
  flex: 0 0 auto;
  margin: 0;
  padding: 9px 12px;
  border: 1px solid var(--line-hi);
  background: var(--surface2);
  color: var(--text);
  font-size: 13px;
  line-height: 1.55;
  text-wrap: pretty;
}

.msg {
  display: flex;
  flex-direction: column;
  max-width: 80%;
  /* Load-bearing, for the same reason `.ai-disclosure` above carries it: #messages is a flex COLUMN
     with a constrained height, so every message is a flex item that the box will compress once the
     transcript overflows. It never did, only because a flex item's default `min-height: auto`
     refuses to shrink below its content — which means the moment anything sets an explicit
     min-height on a message (the pending-turn floor below does), that protection is gone and a long
     answer collapses under the messages after it. Stating `flex: 0 0 auto` makes the protection
     explicit and independent of what min-height happens to be. */
  flex: 0 0 auto;
}
.msg.user {
  align-self: flex-end;
  align-items: flex-end;
}
.msg.bot {
  align-self: flex-start;
  align-items: flex-start;
  max-width: 95%;
}

/* ── Bubbles ───────────────────────────────────────────────────────────────────
   Square and outlined. No tail, no shadow, no gradient. The only thing that varies
   between a user turn and a bot turn is which edge carries the weight: the user's
   own words are marked in the TENANT accent (their bot, their colour), the bot's
   answer is outlined in the shell hairline and then annotated by its answer state
   below. An over-decorated bubble is the failure mode this design is avoiding. */
.bubble {
  padding: 11px 15px;
  font-size: 0.9667rem;
  line-height: 1.65;
  /* `normal`, not `pre-wrap`, on BOTH sides: every bubble is markdown now, and the parser has
     already turned the line breaks into <br> and the blank lines into paragraphs. Leaving pre-wrap
     on would double-count both — a paragraph gap plus a preserved newline. */
  white-space: normal;
  word-break: break-word;
}
.msg.user .bubble {
  background: var(--user-bubble);
  border: 1px solid var(--line);
  /* The tenant's colour, on the edge nearest the person who typed it. */
  border-right: 2px solid var(--accent);
}
.msg.bot .bubble {
  background: var(--bot-bubble);
  border: 1px solid var(--line);
}

/* ── Markdown inside a bubble, either speaker ──────────────────────────────────
   These rules were `.msg.bot .bubble` while only the answer was parsed. They are scoped to
   `.bubble` now because the question is parsed too; every one of them paints from a theme token
   (--surface2, --line, --text, --accent), none from --bot-bubble, so they sit on the user's
   accented ground unchanged. What stays bot-only is below: the pending-height reservation and the
   arrival transition, which are about an answer being generated and have no user-turn meaning. */
/* MEASURED, DO NOT "FIX" THIS. `.bubble` carries `word-break: break-word`, whose defined behaviour
   is `overflow-wrap: anywhere`: it may break between ANY two characters once a line would otherwise
   overflow, ignoring every break prohibition in the text — including the U+2060 word joiner app.js
   emits to keep a citation numeral attached to the word it cites. So a citation that lands at the
   end of a line is occasionally orphaned onto the next one, as "³." under the sentence it belongs
   to. That is cosmetic and it is the price of the setting above.

   Relaxing this paragraph to `overflow-wrap: break-word` does fix the orphan and costs far more: it
   takes the long-token break out of the min-content calculation, and a 700-character token then
   sized the bubble to 6140px and put #messages into 5206px of sideways scrolling while
   documentElement.scrollWidth stayed exactly at clientWidth — so every page-level overflow check
   still passed. Same failure the `min-width: 0` notes on .retrieval-note and .conflict-note
   describe, found the same way. The orphan stays. */
.bubble p {
  margin: 0 0 0.5em;
}
.bubble p:last-child {
  margin-bottom: 0;
}
/* Inline images from answer markdown ![alt](url) — generate_image PNGs + graph_kb SVGs. */
.chat-img {
  display: block;
  max-width: 100%;
  height: auto;
  margin: 8px 0;
  border: 1px solid var(--line);
  /* Tokenised (was a hard #fff): a transparent PNG/SVG must sit on the bubble's surface, not on a
     white card, or it inverts into an unreadable slab in dark mode. */
  background: var(--surface);
}

/* ── Generated-image placeholder ──
   `.loading` is on the <img> from the moment renderMarkdown emits it and is dropped the instant the
   real bitmap fires `load` (a delegated capture listener in app.js — the element itself is recreated
   on every streamed frame, so per-element handlers cannot survive).

   While loading, `--img-ar` (a layout hint carried in the style attribute) reserves a box at the
   image's eventual shape so the answer around it does not jump when the bitmap swaps in. The hint is
   scoped to `.loading` ON PURPOSE: once loaded, the image's real intrinsic size governs again, so a
   wrong guess is never baked in.

   The width is CAPPED at `--img-w` (the image's own natural width, emitted only when the url states
   one) and not simply set to 100%. Base `.chat-img` is `max-width:100%; height:auto`, so a loaded
   image NARROWER than the bubble renders at its natural width — reserving the full bubble for it
   would snap the answer down the instant the bitmap arrives, which is the exact shift this box
   exists to prevent. With no width to go on it stays full-bleed, which is right for everything this
   platform generates (graph_kb is 820×620, generate_image 1024×1024 — both wider than the bubble).

   The fill is the "looking inside" state: three translucent radial blobs tinted from the bot's own
   accent, drifting slowly against each other on `background-position` (each layer at a different
   rate, so they never resolve into a repeating pattern) with a gentle opacity breath. Purely CSS —
   no JS ticker, nothing to leak. */
.chat-img.loading {
  width: min(100%, var(--img-w, 100%));
  aspect-ratio: var(--img-ar, 1 / 1);
  background-color: var(--surface2);
  background-image:
    radial-gradient(38% 46% at 26% 32%, color-mix(in srgb, var(--accent) 34%, transparent), transparent 70%),
    radial-gradient(44% 40% at 74% 28%, color-mix(in srgb, var(--accent) 20%, transparent), transparent 72%),
    radial-gradient(52% 56% at 52% 82%, color-mix(in srgb, var(--accent) 26%, transparent), transparent 74%);
  background-repeat: no-repeat;
  background-size: 130% 130%, 150% 150%, 165% 165%;
  background-position: 12% 20%, 84% 16%, 48% 92%;
  animation: chat-img-drift 14s ease-in-out infinite alternate;
}
@keyframes chat-img-drift {
  0% {
    background-position: 12% 20%, 84% 16%, 48% 92%;
    opacity: 0.72;
  }
  50% {
    background-position: 40% 46%, 56% 44%, 30% 60%;
    opacity: 1;
  }
  100% {
    background-position: 78% 66%, 20% 74%, 70% 26%;
    opacity: 0.78;
  }
}
/* Reduced motion: keep the soft tinted gradient, drop the drift entirely (the global blanket only
   clamps the DURATION, which would turn this into a single 0.001ms strobe). Both the OS query and
   the in-app Settings mirror, per the convention used by .tool-chip / skeleton.js. */
@media (prefers-reduced-motion: reduce) {
  .chat-img.loading {
    animation: none !important;
    opacity: 0.9;
  }
}
:root[data-a11y-motion="reduced"] .chat-img.loading {
  animation: none !important;
  opacity: 0.9;
}
/* An image that 404s keeps its alt text; just make the frame read as failed rather than pending. */
.chat-img.failed {
  border-color: color-mix(in srgb, var(--err) 40%, var(--border));
}
.bubble h3,
.bubble h4,
.bubble h5,
.bubble h6 {
  margin: 0.6em 0 0.35em;
  font-size: 15px;
  line-height: 1.3;
}
.bubble h3:first-child,
.bubble h4:first-child {
  margin-top: 0;
}
.bubble ul,
.bubble ol {
  margin: 0.4em 0 0.5em 1.4em;
  padding: 0;
}
.bubble li {
  margin-bottom: 0.25em;
}
.bubble strong {
  color: var(--text);
  font-weight: 600;
}
/* A link inside an answer points at the TENANT's material, so it should read as
   theirs. But --accent is an arbitrary customer colour, and colouring body text with
   it cannot be made safe: swept across the gamut, a near-white accent on the light
   theme scored 2.6:1 and a near-black accent on the dark theme 3.3:1. Tinting toward
   --text does not rescue it either; the mix only passes at every accent below about
   35%, which is too little colour to be worth having.

   So the identity moves OUT of the text and into the underline, which is not text and
   is not the only thing marking the link. The label keeps a 30% tint (safe at the
   theoretical worst case, where the accent equals the bubble itself: 5.6:1 light,
   7.7:1 dark) and the underline carries the accent at full strength, 2px. Colour is
   never the sole differentiator either way, which is 1.4.1 as well as 1.4.3. */
.bubble a {
  color: color-mix(in srgb, var(--accent) 30%, var(--text));
  text-decoration: underline;
  text-decoration-color: var(--accent);
  text-decoration-thickness: 2px;
  text-underline-offset: 3px;
}
.bubble a:hover,
.bubble a:focus-visible {
  color: var(--text);
  text-decoration-thickness: 3px;
}
.bubble code {
  background: var(--surface2);
  border: 1px solid var(--line);
  padding: 0 5px;
  font-family: var(--mono);
  font-size: 12.5px;
}
.bubble pre {
  background: var(--surface2);
  border: 1px solid var(--line);
  padding: 10px 12px;
  overflow-x: auto;
  margin: 0.5em 0;
}
.bubble pre code {
  background: none;
  padding: 0;
}

/* ── Fenced code blocks: per-block Copy button + syntax highlighting (Feature S2.3) ── */
.bubble .code-block {
  position: relative;
  margin: 0.4em 0;
}
.bubble .code-block pre {
  margin: 0;
}
/* ONE BOX FOR THREE LABELS. "Copy" is 48px, "✓ Copied" 78px and the failure 99px, so the button
   used to grow 30px under the pointer that had just pressed it and shrink back 1.4s later — a
   control changing size as its own feedback. The floor is the widest state (em, so it tracks the
   font size rather than pinning a pixel count), and app.js's failure label was shortened to the same
   8-character shape as the success one so the floor could stay this small. */
.code-copy {
  position: absolute;
  top: 5px;
  right: 5px;
  min-width: 8.4em;
  padding: 4px 9px;
  font-family: var(--mono);
  font-size: 9.5px;
  font-weight: 600;
  letter-spacing: 0.18em;
  text-transform: uppercase;
  line-height: 1.4;
  color: var(--muted);
  background: var(--surface);
  border: 1px solid var(--control-edge);
  cursor: pointer;
  opacity: 0;
  transition: opacity var(--t-ui) var(--ease), color var(--t-ui) var(--ease), border-color var(--t-ui) var(--ease);
}
.code-block:hover .code-copy,
.code-copy:focus-visible {
  opacity: 1;
}
.code-copy:hover {
  color: var(--sig-text);
  border-color: var(--sig);
}
.code-copy.copied {
  color: var(--ok);
  border-color: var(--ok);
  opacity: 1;
}
/* The other outcome, and it has to look like one: a clipboard write can be refused by permission
   policy, and this used to report that in the success colour. */
.code-copy.copy-failed {
  color: var(--err);
  border-color: var(--err);
  opacity: 1;
}
.code-copy:focus-visible {
  outline: 2px solid var(--sig);
  outline-offset: 2px;
}
/* Touch devices have no hover — keep the button visible. */
@media (hover: none) {
  .code-copy { opacity: 0.85; }
}
/* Syntax-highlight token colours (dark theme). Applied to already-escaped, span-wrapped code — see
   public/js/highlight.js. Readable on the --surface2 code background with comfortable contrast. */
.bubble pre .hl-keyword  { color: var(--hl-keyword); }
.bubble pre .hl-string   { color: var(--hl-string); }
.bubble pre .hl-number   { color: var(--hl-number); }
.bubble pre .hl-comment  { color: var(--hl-comment); font-style: italic; }
.bubble pre .hl-function { color: var(--hl-function); }
.bubble pre .hl-tag      { color: var(--hl-tag); }
.bubble pre .hl-attr     { color: var(--hl-attr); }
.bubble pre .hl-punct    { color: var(--hl-punct); }
/* LaTeX-subset math (public/js/mathrender.js — Unicode + CSS, no KaTeX/fonts). Escaped input in,
   fixed tags out (XSS-safe). Inline flows with the text; block is centered on its own line. */
.bubble .math-inline {
  font-family: var(--mono);
  font-style: italic;
  white-space: nowrap;
}
.bubble .math-block {
  display: block;
  margin: 0.6em 0;
  text-align: center;
  font-family: var(--mono);
  font-style: italic;
  font-size: 1.05em;
  overflow-x: auto;
}
/* Stacked fraction: numerator over a bordered denominator, vertically centered inline. */
.bubble .mfrac {
  display: inline-flex;
  flex-direction: column;
  vertical-align: middle;
  text-align: center;
  margin: 0 0.15em;
}
.bubble .mfrac-n {
  padding: 0 0.35em;
  font-size: 0.92em;
  line-height: 1.15;
}
.bubble .mfrac-d {
  padding: 0.05em 0.35em 0;
  font-size: 0.92em;
  line-height: 1.15;
  border-top: 1px solid currentColor;
}
/* Square root: the √ sign followed by the radicand under an overline. */
.bubble .msqrt {
  display: inline-flex;
  align-items: flex-start;
  white-space: nowrap;
}
.bubble .msqrt-sign {
  font-style: normal;
}
.bubble .msqrt-rad {
  border-top: 1px solid currentColor;
  padding: 0 0.15em;
  margin-top: 0.06em;
}
.bubble .msqrt-idx {
  font-size: 0.7em;
  margin-right: -0.25em;
}
.bubble .math-inline sup,
.bubble .math-block sup,
.bubble .math-inline sub,
.bubble .math-block sub {
  font-size: 0.72em;
  line-height: 0;
}
/* GFM tables + rich markdown, either speaker */
.bubble .md-table {
  position: relative; /* contains any absolutely positioned descendant so a wide answer table cannot widen the document */
  overflow-x: auto;
  margin: 0.5em 0;
  border: 1px solid var(--line);
}
.bubble table {
  border-collapse: collapse;
  width: 100%;
  font-size: 12.5px;
}
.bubble th,
.bubble td {
  padding: 7px 12px;
  border-bottom: 1px solid var(--line);
}
/* Mono uppercase column headers: density is a permission in this system, and a
   ruled table with labelled columns needs no zebra striping to stay scannable. */
.bubble thead th {
  background: var(--surface2);
  color: var(--muted);
  font-family: var(--mono);
  font-size: 10px;
  font-weight: 600;
  letter-spacing: 0.16em;
  text-transform: uppercase;
  text-align: left;
  white-space: nowrap;
}
.bubble tbody tr:last-child td { border-bottom: none; }
.bubble blockquote {
  margin: 0.5em 0;
  padding: 2px 13px;
  border-left: 2px solid var(--line-hi);
  color: var(--muted);
}
.bubble hr {
  border: none;
  border-top: 1px solid var(--line);
  margin: 0.9em 0;
}
.bubble del { opacity: 0.65; }
.bubble ul.md-tasks { list-style: none; padding-left: 0.4em; }
.bubble li.md-task { list-style: none; }
.bubble li.md-task input { margin-right: 6px; vertical-align: middle; }

/* ── Thinking: three squares, never a spinner ──────────────────────────────────
   Backbone's thinking indicator is three stepping squares. Drawn as TWO background
   layers on the one pseudo-element the markup gives us (app.js replaces the
   bubble's innerHTML on every streamed frame, so a real three-element indicator
   inside the bubble would be destroyed):
     layer 2  the track: three 7px squares at x = 0, 10, 20, held at low opacity
     layer 1  the signal: one 7px square that STEPS across those three positions
   steps(3) makes it land exactly on each square. It conveys one thing (work is
   happening) and stops the moment text arrives. */
.bubble.thinking {
  color: var(--muted);
}
.bubble.thinking::after {
  content: "";
  display: inline-block;
  width: 27px;   /* 3 × 7px + 2 × 3px gaps */
  height: 7px;
  margin-left: 9px;
  vertical-align: baseline;
  position: relative;
  bottom: 0;
  /* The track is painted at partial alpha via color-mix rather than by opacity, so
     the travelling square stacked above it stays full strength. */
  background-image:
    linear-gradient(var(--sig), var(--sig)),
    repeating-linear-gradient(90deg, color-mix(in srgb, var(--sig) 30%, transparent) 0 7px, transparent 7px 10px);
  background-size: 7px 7px, 27px 7px;
  background-position: 0 0, 0 0;
  background-repeat: no-repeat;
  animation: think-step 1.05s steps(3) infinite;
}
@keyframes think-step {
  from { background-position: 0 0, 0 0; }
  to   { background-position: 30px 0, 0 0; }
}
/* Kept for the tool-chip dot, which still pulses in place. */
@keyframes blink {
  0%,
  100% {
    opacity: 0.25;
  }
  50% {
    opacity: 1;
  }
}
/* Reduced motion: the three squares stop stepping and simply all show. Nothing is
   hidden, nothing strobes — the state is still legible, it just no longer moves. */
@media (prefers-reduced-motion: reduce) {
  .bubble.thinking::after {
    animation: none;
    background-image: repeating-linear-gradient(90deg, var(--sig) 0 7px, transparent 7px 10px);
    background-size: 27px 7px;
  }
  #messages {
    scroll-behavior: auto;
  }
}
:root[data-a11y-motion="reduced"] .bubble.thinking::after {
  animation: none;
  background-image: repeating-linear-gradient(90deg, var(--sig) 0 7px, transparent 7px 10px);
  background-size: 27px 7px;
}

/* ── Per-turn "generating" spinner ──
   A small accent-tinted ring mounted on the PENDING .msg.bot (never inside .bubble — a streamed
   re-render replaces the bubble's innerHTML wholesale). Visible from the instant the question is
   sent until the first delta / tool chip / done / error / abort. */
.gen-spinner {
  display: flex;
  align-items: center;
  margin-top: 6px;
}
/* One "still working" signal at a time: while the ring is up, drop the trailing blink from the
   "Thinking…" bubble. Progressive — a browser without :has() simply keeps the old dot. */
.msg.bot:has(> .gen-spinner) .bubble.thinking::after {
  display: none;
}
/* Backbone has no spinners. The ring becomes an indeterminate hairline: a --sig
   segment travelling a 3px track, which is the same primitive `.bb-progress` uses.
   The class name is a JS hook (app.js mounts/tears it down by it), so it keeps the
   name and only changes what it draws. */
.gen-spinner-ring {
  display: block;
  position: relative;
  width: 64px;
  height: 3px;
  border: 0;
  overflow: hidden;
  background: color-mix(in srgb, var(--sig) 16%, transparent);
}
.gen-spinner-ring::after {
  content: "";
  position: absolute;
  top: 0;
  bottom: 0;
  left: 0;
  width: 34%;
  background: var(--sig);
  animation: gen-run 1.15s var(--ease) infinite;
}
@keyframes gen-run {
  from { translate: -110% 0; }
  to   { translate: 340% 0; }
}
/* Reduced motion: the travelling segment becomes a static, half-strength full bar.
   (Letting the global blanket clamp the duration would strobe once and then freeze
   the segment off-screen, leaving no "still working" signal at all.) */
@media (prefers-reduced-motion: reduce) {
  .gen-spinner-ring::after {
    animation: none !important;
    width: 100%;
    background: color-mix(in srgb, var(--sig) 55%, transparent);
  }
}
:root[data-a11y-motion="reduced"] .gen-spinner-ring::after {
  animation: none !important;
  width: 100%;
  background: color-mix(in srgb, var(--sig) 55%, transparent);
}

/* ══ ONE TURN, ONE PLACE ═══════════════════════════════════════════════════════
   app.js already reuses the SAME node for the whole turn — the "Thinking…" bubble
   becomes the tool chips becomes the streamed answer, and `thinkMsg`/`thinkBubble`
   are never re-mounted. The continuity was therefore already true in the DOM and
   false on screen: the bubble measured 49px thinking, 72px with chips and 74px on
   the first line of text, and each handoff replaced its contents in a single frame.
   The reader saw three unrelated objects in one spot rather than one object working.

   Two rules fix it, and neither delays a single token.

   1. ANCHOR THE BOX. While the log is busy, the live answer bubble holds a floor of
      --pending-h. That is the height of the tool-chip row, which is also roughly the
      first two lines of an answer — so "Thinking…", the chips and the opening text
      all sit in a box that never resizes underneath them. The floor is released on
      `done` (aria-busy flips to false) and eased out by the min-height transition
      below, so a genuinely one-line answer settles rather than snapping shut.

      Scoped to `[aria-busy="true"] .msg.bot:last-child`, the same selector the
      streaming caret already uses: it means "the turn in flight", it needs no new
      class, and it cannot strand — app.js clears aria-busy on done, error and abort.

   2. HAND OFF, DO NOT CUT. Each state that ARRIVES eases in over --t-state from an
      already-legible starting point; each state that LEAVES fades over --t-exit
      (app.js marks it `.is-leaving` and removes it after). Nothing starts at
      opacity 0, so a dropped animation still leaves readable content.
   ------------------------------------------------------------------------- */
:root {
  /* The floor is the tallest of the three things that occupy this box in sequence — the "Thinking…"
     line, one row of tool chips, and the first line of an answer. All three land within a pixel or
     two of 3.2em, which is what makes them interchangeable without the box resizing. Set any higher
     and the reader watches an empty rectangle wait for text; any lower and the jump comes back. */
  --pending-h: 3.2em;
  /* The progress hairline's own row: its 3px height plus the 6px margin-top that separates it from
     the bubble. Named because the MESSAGE floor has to include it — see below. */
  --gen-spinner-row: 9px;
}

/* Base `0`, not `auto`: a flex item's min-height defaults to `auto`, and a transition from `auto`
   does not run. Safe only because `.msg` above declares `flex: 0 0 auto` — without it, replacing
   `auto` here is what lets a finished answer be compressed by the flex box it lives in. */
.msg.bot,
.msg.bot .bubble {
  min-height: 0;
  transition: min-height var(--t-layout) var(--ease);
}
#messages[aria-busy="true"] .msg.bot:last-child .bubble {
  min-height: var(--pending-h);
}
/* And the MESSAGE, one step out. The bubble floor alone still let the row collapse by 9px the
   moment the hairline left, because the hairline sits below the bubble, not in it — the box the
   reader was watching held still and the thing underneath it shrank. Anchoring the message at the
   bubble floor PLUS the hairline's row means the whole turn occupies one fixed area from the send
   until the answer outgrows it, and the hairline can leave without moving anything. */
#messages[aria-busy="true"] .msg.bot:last-child {
  min-height: calc(var(--pending-h) + var(--gen-spinner-row));
}

/* The first painted answer text. One-shot, added by app.js on the FIRST delta only
   and cleared on animationend — streamed tokens after it are never animated, which
   is what keeps a long answer readable while it arrives. It starts at 0.38 opacity
   rather than 0: the sentence is legible from the first frame, it just settles. */
@keyframes answer-in { from { opacity: 0.38; } }
.bubble.answer-arriving { animation: answer-in var(--t-state) var(--ease) both; }

/* The progress hairline on its way out. app.js adds .is-leaving and removes the node when the
   transition ends, so the one signal that says "the send did something" fades instead of blinking
   off the instant the first token lands.

   It collapses as well as fades. The hairline sits BELOW the bubble, so opacity alone would leave a
   9px hole for the length of the fade and then close it in one frame — a late little hop at the
   bottom of the log, which is exactly the class of shift this pass exists to remove. The explicit
   height on .gen-spinner is what makes that collapse animatable at all: `auto` does not interpolate.
   (The tool-chip row needs no equivalent. It lives INSIDE the bubble, and every path that drops it
   rewrites bubble.innerHTML in the same task, so a leaving class would never be painted.) */
.gen-spinner { height: 3px; }   /* the ring's own height — see .gen-spinner-ring */
.gen-spinner.is-leaving {
  opacity: 0;
  height: 0;
  margin-top: 0;
  overflow: hidden;
  pointer-events: none;
  transition:
    opacity var(--t-exit) var(--ease-exit),
    height var(--t-exit) var(--ease-exit),
    margin-top var(--t-exit) var(--ease-exit);
}

@media (prefers-reduced-motion: reduce) {
  .msg.bot .bubble { transition: none; }
  .bubble.answer-arriving { animation: none; }
}
:root[data-a11y-motion="reduced"] .msg.bot .bubble { transition: none; }
:root[data-a11y-motion="reduced"] .bubble.answer-arriving { animation: none; }

/* ── Message arrival ──
   A new turn settles in rather than appearing. Opacity plus 5px, --t-state, house
   curve: felt, not watched. Applied by app.js as a CLASS on the two nodes a live
   send creates, never on `.msg` itself — a replayed conversation mounts twenty
   messages at once, and animating all of them on open would be a slot machine.
   `both` holds the end state, so a message that outlives its animation is at rest. */
@keyframes msg-in { from { opacity: 0; translate: 0 5px; } }
.msg.msg-in { animation: msg-in var(--t-state) var(--ease) both; }
@media (prefers-reduced-motion: reduce) { .msg.msg-in { animation: none; } }
:root[data-a11y-motion="reduced"] .msg.msg-in { animation: none; }

/* ── Failure and limit states ──────────────────────────────────────────────────
   Three distinct things that all used to look like "something is wrong":
     .error              the turn failed. --err, and it is an error.
     .notice             the rate limiter said slow down. Temporary, self-clearing,
                         so it is a --warn advisory and NOT red.
     .notice.allowance   the bot's owner is out of monthly tokens (the 429 with
                         {upgrade:true} from functions/api/ask.js). Nothing the
                         visitor did, and waiting will not fix it — it needs the
                         OWNER to act, so it is the only one of the three that gets
                         a standing label instead of a sentence.
     .notice.policy      the asker's EMPLOYER withheld this assistant (the 403 with
                         {org_policy} from functions/_shared/orggate.js). Same
                         reading as .allowance and for the same reason — nothing the
                         employee did, and retrying cannot change it — so it carries
                         a label too, and app.js offers no Retry beside it.
   Each carries an inset bar on the leading edge so the three read apart at a glance
   even where --warn and --err are close in value (forced-colors, tritanopia). */
.bubble.error {
  color: var(--err);
  background: color-mix(in srgb, var(--err) 7%, var(--bot-bubble));
  border-color: color-mix(in srgb, var(--err) 46%, transparent);
  border-left: 3px solid var(--err);
}
.bubble.notice {
  color: var(--warn);
  background: color-mix(in srgb, var(--warn) 8%, var(--bot-bubble));
  border-color: color-mix(in srgb, var(--warn) 46%, transparent);
  border-left: 3px solid var(--warn);
}
.bubble.notice.allowance::before,
.bubble.notice.policy::before {
  content: "Allowance reached";
  display: block;
  margin-bottom: 7px;
  font-family: var(--mono);
  font-size: 9.5px;
  font-weight: 700;
  letter-spacing: 0.22em;
  text-transform: uppercase;
  color: var(--warn);
}
/* Same label mechanics, a different fact: the ceiling was not reached, the access was never given. */
.bubble.notice.policy::before { content: "Access not granted"; }

/* Tint native checkboxes/radios (Settings a11y toggles, access-code forms) to the bot accent. */
input[type="checkbox"], input[type="radio"] { accent-color: var(--accent); }

/* ══ THE THREE ANSWER STATES ═══════════════════════════════════════════════════
   A bot that cites what it used and refuses what it cannot support IS the product.
   These are first-class states, not error styling, and they are deliberately told
   apart by FORM as well as colour, because backbone.css resolves --warn and
   --ungrounded to the same hue and because a colour-only distinction is no
   distinction at all for a large share of readers:

     GROUNDED    an --ok hairline down the bubble's leading edge, and a mono SOURCES
                 block ruled off in the TENANT's accent. Solid marks: the support is
                 filled in.
     UNGROUNDED  the bubble is left completely alone (it did answer), and an outlined
                 badge with a HOLLOW square hangs beneath it. Empty mark: the support
                 is not filled in.
     REFUSED     the bubble itself is tinted, barred and tagged. The refusal is the
                 answer, so it is the bubble that changes.
   ------------------------------------------------------------------------- */

/* ── Cited sources. The tenant's own documents, so the rule and the mark are the
   tenant's --accent. Mono because a citation is a reference, not prose; sentence
   case is preserved because these are filenames and titles. ── */
.sources-line {
  position: relative;
  margin-top: 9px;
  padding: 8px 0 0 15px;
  border-top: 1px solid color-mix(in srgb, var(--accent) 40%, transparent);
  font-family: var(--mono);
  font-size: 10.5px;
  line-height: 1.7;
  letter-spacing: 0.06em;
  color: var(--muted);
  word-break: break-word;
}
.sources-line::before {
  content: "";
  position: absolute;
  left: 0;
  top: 14px;
  width: 6px;
  height: 6px;
  background: var(--accent);
}
/* The list's own disclosure. Same voice, same chevron and same rotation as the per-source toggles
   nested inside it, because it IS the same gesture one level out — the answer's evidence should not
   have two different ways of opening. Closed by default: the count is the standing statement ("this
   answer stands on three things"), and the titles are one click away for anyone who wants them. */
.sources-disclose {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  max-width: 100%;
  padding: 1px 3px;
  background: none;
  border: none;
  font: inherit;
  font-family: var(--mono);
  font-size: 10.5px;
  letter-spacing: 0.06em;
  color: var(--muted);
  text-align: left;
  cursor: pointer;
  transition: color var(--t-ui) var(--ease);
}
.sources-disclose:hover { color: var(--accent-on-surface); }
.sources-disclose:focus-visible {
  outline: 2px solid var(--sig);
  outline-offset: 1px;
}
.sources-disclose::after {
  content: "";
  flex: none;
  width: 0;
  height: 0;
  border-style: solid;
  border-width: 3px 0 3px 4px;
  border-color: transparent transparent transparent currentColor;
  opacity: 0.72;
  transition: transform var(--t-ui) var(--ease), opacity var(--t-ui) var(--ease);
}
.sources-disclose:hover::after,
.sources-disclose:focus-visible::after { opacity: 1; }
.sources-disclose[aria-expanded="true"]::after {
  transform: rotate(90deg);
  opacity: 1;
}
.sources-label {
  display: inline;
  text-transform: uppercase;
  letter-spacing: 0.18em;
  font-weight: 600;
}
/* How many, stated whether or not the list is open. Tabular so the number does not shift the
   chevron between a 1 and a 3. */
.sources-count {
  font-variant-numeric: tabular-nums;
  color: var(--accent-on-surface);
  font-weight: 700;
}
.sources-list {
  display: flex;
  flex-direction: column;
  gap: 3px;
  margin: 4px 0 0;
  padding: 0;
  list-style: none;
}
.sources-list[hidden] { display: none; }
.source-item {
  display: block;
}
.source-title {
  word-break: break-word;
}
/* A source with a matching citations[] row (P1) becomes a disclosure — expand it to see which kind of
   source it was and the excerpt the model actually cited. A plain <span> (no match: every answer
   recorded before citations existed, or a citations array shorter than sources) renders exactly as
   the flat text list did before this feature. Block-level, one per row — NOT inline in a sentence —
   so unlike .citation-chip below it DOES get the real 44px mobile hit area (see the media query). */
.source-toggle {
  display: inline-flex;
  align-items: center;
  gap: 5px;
  max-width: 100%;
  margin: 0;
  padding: 1px 3px;
  background: none;
  border: none;
  font: inherit;
  font-family: var(--mono);
  font-size: 10.5px;
  letter-spacing: 0.06em;
  color: var(--muted);
  text-align: left;
  cursor: pointer;
  transition: color var(--t-ui) var(--ease);
}
.source-toggle:hover {
  color: var(--accent-on-surface);
}
.source-toggle:focus-visible {
  outline: 2px solid var(--sig);
  outline-offset: 1px;
}
/* The disclosure mark. Rotates rather than being swapped for a different glyph, and does it on the
   same duration and the same curve as .retrieval-summary's — one chevron behaviour on this surface,
   not two that nearly match. It also picks up the toggle's hover colour, so pointing at the row
   lights the whole control instead of half of it. */
.source-toggle::after {
  content: "";
  flex: none;
  width: 0;
  height: 0;
  border-style: solid;
  border-width: 3px 0 3px 4px;
  border-color: transparent transparent transparent currentColor;
  opacity: 0.72;
  transition: transform var(--t-ui) var(--ease), opacity var(--t-ui) var(--ease);
}
.source-toggle:hover::after,
.source-toggle:focus-visible::after { opacity: 1; }
.source-toggle[aria-expanded="true"]::after {
  transform: rotate(90deg);
  opacity: 1;
}
/* Supporting evidence, opened on request — so it sits BELOW the answer in the hierarchy and is
   drawn that way. The left rule was 2px of full-strength tenant accent, which made every expanded
   source a bar-fronted card competing with the answer state bars on the bubble itself (those carry
   meaning: grounded, refused, unfinished). At 1px and mixed back it still marks the panel as
   belonging to the citation that opened it, and stops claiming to be a status. */
.source-detail {
  display: block;
  margin: 2px 0 3px 3px;
  padding: 6px 8px;
  border-left: 1px solid color-mix(in srgb, var(--accent) 60%, transparent);
  background: var(--surface2);
  font-size: 10px;
  letter-spacing: 0.02em;
  line-height: 1.55;
  color: var(--text-muted);
}
.source-detail[hidden] {
  display: none;
}
.source-detail-kind {
  display: block;
  margin-bottom: 2px;
  font-weight: 600;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--faint);
  font-size: 9px;
}
.source-detail-excerpt {
  display: block;
}

/* ── "How this was found" (Step 8) ──
   The Sources line says WHAT the answer stands on; this says WHY THOSE. A <details>, not a button +
   panel, because it is a genuine disclosure with no state of its own — the browser gives it correct
   keyboard and screen-reader semantics for free, and there is no JS to keep in sync. Collapsed by
   default: it is diagnostic, and an answer that has to explain its own machinery before it can be
   read has failed at being an answer. Sits BELOW .sources-line and shares its muted mono voice so
   the evidence area reads as one block rather than two competing footers. */
.retrieval-note {
  /* `.msg` is a flex container, and a flex item's default `min-width: auto` refuses to shrink below
     its content. Without this the `white-space: pre` query below sized this whole disclosure to the
     SQL's natural 743px, which stretched the bubble past its own `max-width: 95%` and put the entire
     message list into sideways scrolling — the document itself stayed 390px wide, so the page-level
     overflow check said everything was fine while the conversation panned under the reader's thumb.
     min-width:0 is what lets the query's own `overflow-x: auto` do the scrolling instead. */
  min-width: 0;
  max-width: 100%;
  margin-top: 6px;
  font-family: var(--mono);
  font-size: 10px;
  line-height: 1.65;
  letter-spacing: 0.05em;
  color: var(--faint);
}
/* Which effort mode answered (Step 11). The quietest line under an answer, and deliberately the same
   muted mono voice as .sources-line and .retrieval-note so the evidence area stays one block: this is
   provenance, not a status the reader has to act on. min-width:0 for the same flex reason above. */
.mode-note {
  min-width: 0;
  max-width: 100%;
  margin-top: 6px;
  font-family: var(--mono);
  font-size: 10px;
  line-height: 1.65;
  letter-spacing: 0.05em;
  color: var(--faint);
}
.retrieval-summary {
  display: inline-flex;
  align-items: center;
  gap: 5px;
  padding: 2px 3px;
  color: var(--faint);
  cursor: pointer;
  list-style: none;
  transition: color var(--t-ui) var(--ease);
}
.retrieval-summary::-webkit-details-marker {
  display: none;
}
/* Same chevron, same duration, same curve as .source-toggle::after — the two disclosures in the
   evidence block must not move differently. */
.retrieval-summary::after {
  content: "";
  flex: none;
  width: 0;
  height: 0;
  border-style: solid;
  border-width: 3px 0 3px 4px;
  border-color: transparent transparent transparent currentColor;
  opacity: 0.72;
  transition: transform var(--t-ui) var(--ease), opacity var(--t-ui) var(--ease);
}
.retrieval-summary:hover::after { opacity: 1; }
.retrieval-note[open] > .retrieval-summary::after {
  transform: rotate(90deg);
}
.retrieval-summary:hover {
  color: var(--accent-on-surface);
}
.retrieval-summary:focus-visible {
  outline: 2px solid var(--sig);
  outline-offset: 1px;
}
.retrieval-body {
  min-width: 0;
  max-width: 100%;
  margin: 3px 0 0 3px;
  padding: 6px 8px;
  /* 1px, matching .source-detail and .citation-pop — the evidence block reads as one family. */
  border-left: 1px solid color-mix(in srgb, var(--accent) 45%, transparent);
  background: var(--surface2);
  color: var(--text-muted);
}
/* Native <details> owns its own open/close, which is why this is a <details> and not a button plus
   a panel. So the reveal is animated and the close is not: the browser removes the content the
   instant `open` flips, and the only way to hold it back would be to take the state away from the
   element that is keeping it correct for the keyboard and the screen reader. Arriving eases,
   leaving is immediate — the same direction as --t-exit, at its limit. */
@keyframes disclosure-in { from { opacity: 0; translate: 0 -3px; } }
.retrieval-note[open] > .retrieval-body { animation: disclosure-in var(--t-state) var(--ease) both; }
@media (prefers-reduced-motion: reduce) {
  .retrieval-note[open] > .retrieval-body { animation: none; }
}
:root[data-a11y-motion="reduced"] .retrieval-note[open] > .retrieval-body { animation: none; }
.retrieval-lanes {
  margin: 0;
  padding: 0;
  list-style: none;
}
.retrieval-lanes > li {
  display: block;
}
/* A lane that was wanted and could not run reads as a caveat, not as a result. */
.retrieval-lane-off {
  color: var(--faint);
  font-style: italic;
}
.retrieval-note p {
  min-width: 0;
  max-width: 100%;
  margin: 4px 0 0;
}
.retrieval-terms {
  color: var(--accent-on-surface);
  word-break: break-word;
}
.retrieval-filters,
.retrieval-unmatched {
  color: var(--faint);
}
/* The executed query. Scrolls inside its own box rather than widening the bubble — a long WHERE
   clause must never be the reason a chat message can be scrolled sideways. */
.retrieval-sql {
  display: block;
  margin-top: 3px;
  padding: 4px 6px;
  background: var(--surface);
  border-left: 2px solid color-mix(in srgb, var(--accent) 55%, transparent);
  color: var(--accent-on-surface);
  font-family: var(--mono);
  font-size: 10px;
  line-height: 1.5;
  white-space: pre;
  overflow-x: auto;
}

/* ── Sources that disagree (Step 9) ──
   Deliberately NOT a <details> and deliberately NOT in the mono/10px evidence voice the two blocks
   above share. Those are an account of the machinery, and it is right that they are quiet and
   collapsed. This is a statement about the ANSWER — two of the things it is standing on give
   different numbers — so it is set in the reading face, at reading size, open, and above the
   sources line. A warning behind a disclosure triangle is a warning that does not exist.

   Both values are always shown. The one in use is marked by a left rule, a heavier weight AND the
   words "used in this answer": which value the assistant chose has to survive a monochrome screen
   and a colour-vision difference, so colour is never the only carrier.

   min-width: 0 for the same reason .retrieval-note carries it — `.msg` is a flex container and a
   flex item's default `min-width: auto` refuses to shrink below its content, which is how a wide
   value once put the whole message list into sideways scrolling while document.scrollWidth stayed
   at 390 and every page-level overflow check passed. */
.conflict-note {
  min-width: 0;
  max-width: 100%;
  margin-top: 10px;
  padding: 10px 12px;
  border: 1px solid color-mix(in srgb, var(--warn, var(--accent)) 46%, transparent);
  background: var(--surface2);
  font-size: 13px;
  line-height: 1.55;
  color: var(--text-muted);
}
.conflict-item + .conflict-item {
  margin-top: 12px;
  padding-top: 12px;
  border-top: 1px solid var(--border);
}
.conflict-head {
  margin: 0 0 8px;
  font-weight: 600;
  color: var(--text);
}
.conflict-values {
  margin: 0;
  padding: 0;
  list-style: none;
  display: grid;
  gap: 6px;
}
/* The marker is PRESENT or ABSENT, not one colour or another. Both values used to carry a 3px side
   rule differing only in hue, which made the rule itself a colour-only signal — and gave the block
   the accent-stripe-on-every-card look. The superseded value now carries no rule, which reads the
   same on a monochrome screen; weight and the words "used in this answer" carry it twice more. */
.conflict-value {
  min-width: 0;
  padding: 6px 9px;
  border-left: 2px solid transparent;
  background: var(--surface);
}
.conflict-value.is-current {
  border-left-color: var(--accent);
}
.conflict-value-num {
  font-family: var(--mono);
  font-size: 14px;
  font-weight: 700;
  color: var(--text);
  /* A long value scrolls inside its own box rather than widening the bubble. */
  display: inline-block;
  max-width: 100%;
  overflow-x: auto;
  vertical-align: bottom;
}
.conflict-value-src {
  display: block;
  margin-top: 2px;
  font-family: var(--mono);
  font-size: 10px;
  letter-spacing: 0.05em;
  color: var(--faint);
  overflow-wrap: anywhere;
}
.conflict-value-flag {
  display: inline-block;
  margin-left: 8px;
  font-family: var(--mono);
  font-size: 9px;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--accent-on-surface);
}
.conflict-why {
  margin: 8px 0 0;
  font-size: 12px;
  color: var(--faint);
}

/* ── Claim-level citations (P1): the "[n]" markers app.js turns into inline marks inside the answer
   text, each disclosing the matching citations[] row's title/kind/excerpt via .citation-pop.

   A FOOTNOTE, NOT A BADGE. These were drawn as tinted, bordered, 18px-wide boxes set on
   `vertical-align: super` — and then the blanket `button { min-height: 24px }` at the foot of this
   file silently overrode the declared 15px height, so every marker rendered 18x24: a filled control
   nearly as tall as the line it interrupted, floating a word's width away from the word it cited.
   A reader scanning the answer saw the marks before the sentence. That is backwards. The evidence is
   the product, but the ANSWER is what is being read, and a citation's job is to say "there is
   support here" quietly enough that finding out is a choice.

   So it is now what it always claimed to be in prose: a superscript numeral in the mono face, at
   0.62em, in the tenant accent at reduced strength, with no box at all. Emphasis on hover and focus
   arrives as a faint tint BEHIND the numeral and as full-strength colour — never as a size change,
   because a marker that grows on hover shoves the rest of the sentence sideways.

   `line-height: 0` on the <sup> is what stops a superscript from inflating the line box: without it
   a paragraph citing three times is set looser than the paragraph above it.

   TARGET SIZE. Still deliberately NOT grown to 44px, and now not to 24px either: an inline mark
   inside running prose is the WCAG 2.2 SC 2.5.8 "inline" exception, the same exception the 24px
   baseline rule near the bottom of this file already documents for text links — which is why that
   rule now names this control and stands down. The vertical padding keeps a ~16px tall press area
   without adding a single pixel to the line. .source-toggle above (the trailing Sources list's own
   expand control) is NOT inline text, so it keeps the real box. */
.citation-chip {
  display: inline;
  margin: 0;
  padding: 0 0.12em;
  /* The SC 2.5.8 inline opt-out, stated here so the blanket `button { min-width/min-height: 24px }`
     near the foot of this file keeps its (0,0,1) specificity. A class beats an element, so this is
     all it takes. */
  min-width: 0;
  min-height: 0;
  background: none;
  border: 0;
  color: var(--accent-on-surface);
  font-family: var(--mono);
  font-size: inherit;
  font-weight: 600;
  line-height: inherit;
  vertical-align: baseline;
  cursor: pointer;
  transition:
    color var(--t-ui) var(--ease),
    background-color var(--t-ui) var(--ease);
}
.citation-chip sup {
  font-size: 0.62em;
  line-height: 0;   /* keeps the raised numeral out of the line box */
  vertical-align: super;
  font-variant-numeric: tabular-nums;
}
/* EMPHASIS COMES FROM THE TINT, NOT FROM THE INK. The colour is the same at rest, on hover and when
   open: it is already the quietest colour that clears 4.5:1 on both surfaces, so "quieter at rest"
   would mean unreadable at rest. What escalates is the wash BEHIND the numeral — nothing, then 12%,
   then 26% — which changes nothing about the layout and reads as the mark warming up under the
   pointer. Size is deliberately not part of the ladder: a marker that grows on hover pushes the
   sentence it sits in.

   THE RUNGS ARE STATED HERE, NOT BORROWED. They used to reach for --accent-soft, which this file
   defines as 11% — and backbone.css, linked after it, re-values that token to 14% and 18% for its
   own uses. Measured in the browser: hover was landing on 18% against an open state of 20%. Two
   points apart is not a ladder, and the comment above it had been describing a value that had not
   been true since Backbone arrived. These three rungs belong to the citation; nothing else tunes
   them. */
.citation-chip:hover {
  background: color-mix(in srgb, var(--accent) 12%, transparent);
}
.citation-chip:focus-visible {
  outline: 2px solid var(--sig);
  outline-offset: 1px;
  background: color-mix(in srgb, var(--accent) 12%, transparent);
}
/* Open. The panel standing underneath is the primary signal that this citation is the one being
   read — colour is never carrying it alone — so the mark only has to stay findable while the eye is
   down in the excerpt: full-strength accent on a slightly firmer tint, still no box, still no
   reflow. */
.citation-chip[aria-expanded="true"] {
  background: color-mix(in srgb, var(--accent) 26%, transparent);
}
/* Not resolvable yet: mid-stream there is no citations map, so app.js emits an inert <span> that only
   holds the marker's place. It must not LOOK interactive either — muted, no cursor, and a dotted
   underline rather than the solid numeral, so "not ready" survives greyscale. It fills in as a real
   citation in the render after `done`. See design-reference/verification-moment.md. */
/* Both state marks are drawn on the <sup>, not on the button. The button's baseline is the
   sentence's; the numeral sits a superscript above it, so a decoration on the button drew a line
   under the TEXT baseline with the numeral floating clear of it — a stray dotted rule in the middle
   of a paragraph, attached to nothing the reader could see. */
.citation-chip.is-pending {
  color: var(--faint);
  cursor: default;
}
.citation-chip.is-pending sup {
  text-decoration: underline dotted;
  text-underline-offset: 1px;
}
/* The panel a citation discloses. Same demotion as .source-detail above and for the same reason:
   this is what the answer stands on, revealed because the reader asked — it should read as a note
   in the margin of the sentence, not as a card dropped into it. */
.citation-pop {
  display: block;
  margin: 4px 0;
  padding: 7px 9px;
  max-width: min(320px, 100%);
  background: var(--surface2);
  border-left: 1px solid color-mix(in srgb, var(--accent) 60%, transparent);
  font-family: var(--font);
  /* The quoted source behind a citation — prose, and the whole point of checking a citation is
     reading it. 12px was the smallest body text on the surface. */
  font-size: 13px;
  line-height: 1.5;
  letter-spacing: normal;
  color: var(--text);
  text-transform: none;
}
.citation-pop[hidden] {
  display: none;
}
.citation-pop-kind {
  display: block;
  margin-bottom: 2px;
  font-family: var(--mono);
  font-size: 9px;
  font-weight: 600;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--faint);
}
.citation-pop-title {
  display: block;
  font-weight: 600;
}
.citation-pop-excerpt {
  display: block;
  margin-top: 3px;
  color: var(--muted);
}

/* Where in the original document this row was printed (Step 6) — only ever present for a row of a
   table extracted from a PDF or an image. Set in the mono face because it is a LOCATION to go and
   check, not prose to read: someone acting on it is about to open page 2 and look at row 4. */
.citation-pop-prov {
  display: block;
  margin-top: 3px;
  font-family: var(--mono);
  font-size: 10px;
  color: var(--faint);
}

/* Source freshness (Step 3). A reopened answer's evidence is the text the answer ACTUALLY read,
   which may no longer be what the source says. `.citation-pop-stamp` names the version so the two
   can be told apart; `.citation-pop-note` explains it in a sentence.

   Told in COPY AND FORM, not colour alone — the same rule the verification badge follows. The
   stale marker gets a dashed underline and its panel a left rule, both of which survive a
   greyscale print and neither of which needs a hue to carry the meaning. There is deliberately no
   "still current" treatment: a reassurance printed on every citation is one nobody reads, and its
   absence is what makes the flagged ones visible. */
.citation-pop-stamp {
  display: block;
  margin-top: 4px;
  font-family: var(--mono);
  font-size: 9px;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--faint);
}
.citation-pop-note {
  display: block;
  margin-top: 4px;
  padding-top: 4px;
  border-top: 1px solid var(--line);
  color: var(--muted);
}
.citation-chip.is-stale sup {
  text-decoration: underline dashed;
  text-underline-offset: 1px;
}
.citation-pop.is-stale {
  border-left: 2px solid var(--muted);
  padding-left: 8px;
}

/* U3 — per-attachment "how the model received it" receipt. Not a citation: what the
   model was HANDED, not what it grounded on. Same shape, shell hairline, hollow
   mark, so it can never be mistaken for a source. */
.attachments-line {
  border-top-color: var(--line);
  color: var(--faint);
}
.attachments-line::before {
  background: transparent;
  border: 1px solid var(--faint);
}

/* ── Grounded: a quiet --ok hairline, and ONLY when the answer actually cited
   something. An answer with no sources gets no verified mark, because claiming
   verification we did not do is the exact failure this product exists to avoid.
   Progressive: a browser without :has() simply shows no bar. ── */
.msg.bot[data-support="supported"]:has(.sources-line:not(.attachments-line)) .bubble,
.msg.bot[data-support="verified"]:has(.sources-line:not(.attachments-line)) .bubble {
  border-left: 2px solid var(--ok);
}

/* ── Ungrounded: it answered, but could not support every detail. ── */
.badge-unverified {
  display: inline-flex;
  align-items: center;
  gap: 7px;
  margin-top: 9px;
  padding: 4px 9px;
  font-family: var(--mono);
  font-size: 9.5px;
  font-weight: 700;
  letter-spacing: 0.2em;
  text-transform: uppercase;
  color: var(--ungrounded);
  background: color-mix(in srgb, var(--ungrounded) 8%, transparent);
  border: 1px solid color-mix(in srgb, var(--ungrounded) 50%, transparent);
  width: max-content;
  max-width: 100%;
}
/* HOLLOW square: the counterpart to the solid source mark above. */
.badge-unverified::before {
  content: "";
  flex: none;
  width: 7px;
  height: 7px;
  border: 1px solid currentColor;
}
/* ── One badge, three states, told apart FIRST by the words app.js writes in (REVIEW_COPY) — the one
   signal both colour blindness and forced-colors leave intact. The mark grades the same idea as the
   block above: solid = supported, half = partly, hollow = unsupported, broken = never finished.
   Border WIDTH and STYLE, never fills, so forced-colors keeps all four. ── */
.msg.bot[data-support="missing_info"] .badge-unverified::before { border-left-width: 3px; }
.msg.bot[data-support="error"] .badge-unverified::before { border-style: dashed; }
.msg.bot[data-support="error"] .badge-unverified {
  color: var(--err);
  background: color-mix(in srgb, var(--err) 8%, transparent);
  border-color: color-mix(in srgb, var(--err) 50%, transparent);
}
/* An unfinished answer is not an answer: bar the bubble, the way a refusal is barred. */
.msg.bot[data-support="error"] .bubble { border-left: 3px solid var(--err); }

/* Step 4 marks. no_match is deliberately the QUIETEST of the new states: the search ran and the
   honest result was "nothing", which is a complete answer, not a shortfall — it gets the same
   half-mark as a partial answer and none of the error treatment. incomplete is the half-mark too
   (most of it holds). derived is dotted: worked out rather than quoted. conflicting is doubled.
   tool_error and unavailable share the error treatment because they ARE system failures — but
   their copy says so, which is what stops an owner hunting for a missing document. */
.msg.bot[data-support="no_match"] .badge-unverified::before,
.msg.bot[data-support="incomplete"] .badge-unverified::before { border-left-width: 3px; }
.msg.bot[data-support="derived"] .badge-unverified::before { border-style: dotted; }
.msg.bot[data-support="conflicting"] .badge-unverified::before { border-width: 2px; }
.msg.bot[data-support="tool_error"] .badge-unverified::before,
.msg.bot[data-support="unavailable"] .badge-unverified::before { border-style: dashed; }
.msg.bot[data-support="tool_error"] .badge-unverified,
.msg.bot[data-support="unavailable"] .badge-unverified {
  color: var(--err);
  background: color-mix(in srgb, var(--err) 8%, transparent);
  border-color: color-mix(in srgb, var(--err) 50%, transparent);
}

/* ── Refused: outside what this bot was given. The bubble IS the refusal, so the
   bubble is what changes — amber tint, amber bar, standing tag. app.js stamps
   data-support on the message from the server's support_status. ── */
.msg.bot[data-support="refused"] .bubble,
.msg.bot[data-support="out_of_scope"] .bubble {
  background: color-mix(in srgb, var(--warn) 9%, var(--bot-bubble));
  border-color: color-mix(in srgb, var(--warn) 46%, transparent);
  border-left: 3px solid var(--warn);
}
.msg.bot[data-support="refused"] .bubble::before,
.msg.bot[data-support="out_of_scope"] .bubble::before {
  content: "Outside this bot's knowledge";
  display: block;
  margin-bottom: 7px;
  font-family: var(--mono);
  font-size: 9.5px;
  font-weight: 700;
  letter-spacing: 0.22em;
  text-transform: uppercase;
  color: var(--warn);
}
/* A refusal has nothing to cite, so if a sources line does appear it is context,
   not support: drop it back to the shell hairline rather than the tenant accent. */
.msg.bot[data-support="refused"] .sources-line,
.msg.bot[data-support="out_of_scope"] .sources-line {
  border-top-color: var(--line);
}
.msg.bot[data-support="refused"] .sources-line::before,
.msg.bot[data-support="out_of_scope"] .sources-line::before {
  background: var(--warn);
}

/* ── Verification resolving. app.js adds .evidence-in on a LIVE turn only, after the stream's final
   render, so the answer is already settled and this says one thing: the evidence for what you just
   read has landed under it. Chips first, source rule 60ms behind, 240ms total. House curve, no
   bounce, no loop, nothing decorative. Why: design-reference/verification-moment.md. ── */
@keyframes evidence-in { from { opacity: 0; transform: translateY(3px); } }
.evidence-in .citation-chip,
.evidence-in .badge-unverified,
.evidence-in .sources-line,
.evidence-in .regen-btn,
.evidence-in .followup-chip { animation: evidence-in var(--t-ui) var(--ease) both; }
.evidence-in .sources-line { animation-delay: 60ms; }
/* The action row and the offered follow-ups mount AFTER this class lands (addAnswerExtras runs, then
   mountAnswerActions), so they animate on arrival for free — and they arrive last, under the
   evidence, which is where the reader's eye is by then. 120ms behind the sources rule, and the chips
   step 40ms apart: they are a list, and reading them as a list is the point. Capped at three steps so
   the total never exceeds the sources delay by more than a beat. */
.evidence-in .regen-btn { animation-delay: 120ms; }
.evidence-in .followup-chip { animation-delay: 140ms; }
.evidence-in .followup-chip:nth-child(2) { animation-delay: 180ms; }
.evidence-in .followup-chip:nth-child(n + 3) { animation-delay: 220ms; }
/* Reduced motion: the state change still READS, it just does not travel. Both rules are required. */
@media (prefers-reduced-motion: reduce) {
  .evidence-in .citation-chip,
  .evidence-in .badge-unverified,
  .evidence-in .sources-line,
  .evidence-in .regen-btn,
  .evidence-in .followup-chip { animation: none; }
}
:root[data-a11y-motion="reduced"] .evidence-in .citation-chip,
:root[data-a11y-motion="reduced"] .evidence-in .badge-unverified,
:root[data-a11y-motion="reduced"] .evidence-in .sources-line,
:root[data-a11y-motion="reduced"] .evidence-in .regen-btn,
:root[data-a11y-motion="reduced"] .evidence-in .followup-chip { animation: none; }

/* ── Streaming ─────────────────────────────────────────────────────────────────
   app.js sets aria-busy="true" on the log for the duration of a turn and clears it on done, error
   and abort alike, so the arriving text can carry a caret with no new hook and no way to strand it.
   Square, --sig, and gone the instant the turn settles — including when the visitor hits Stop.

   IT HANGS OFF THE LAST BLOCK, NOT THE BUBBLE. As a `.bubble::after` it was a pseudo-element after
   the bubble's last CHILD — and every child renderMarkdown emits is a block, so the caret dropped
   onto a line of its own underneath the answer instead of trailing the sentence being written. It
   cost a whole line box (~25px) that vanished on `done`, and it read as a detached mark rather than
   as a cursor. Attaching it to the last child puts it where the next word will be.

   AND NOT WHILE THE AGENT IS STILL LOOKING THINGS UP. `:not(:has(.tool-chips))` — the caret means
   "text is being written here". During the tool phase no text is being written, and a blinking
   cursor under a row of progress chips claims otherwise. Progressive: a browser without :has()
   shows the old behaviour, which is what it showed before. ── */
#messages[aria-busy="true"] .msg.bot:last-child .bubble:not(.thinking):not(:has(.tool-chips)) > :last-child::after {
  content: "";
  display: inline-block;
  width: 7px;
  height: 0.95em;
  margin-left: 3px;
  vertical-align: -0.14em;
  background: var(--sig);
  animation: stream-caret 0.9s steps(2, start) infinite;
}
@keyframes stream-caret {
  50% { opacity: 0; }
}
/* Reduced motion: the caret stays, it just stops blinking. It is a state marker, not
   decoration, so removing it would remove information. */
@media (prefers-reduced-motion: reduce) {
  #messages[aria-busy="true"] .msg.bot:last-child .bubble:not(.thinking):not(:has(.tool-chips)) > :last-child::after { animation: none; }
}
:root[data-a11y-motion="reduced"] #messages[aria-busy="true"] .msg.bot:last-child .bubble:not(.thinking):not(:has(.tool-chips)) > :last-child::after {
  animation: none;
}

/* ── Interrupted / failed mid-stream ── */
.stream-error-note {
  margin-top: 9px;
  padding: 7px 0 0 15px;
  position: relative;
  border-top: 1px solid color-mix(in srgb, var(--err) 40%, transparent);
  font-family: var(--mono);
  font-size: 10.5px;
  line-height: 1.7;
  letter-spacing: 0.06em;
  color: var(--err);
}
.stream-error-note::before {
  content: "";
  position: absolute;
  left: 0;
  top: 13px;
  width: 6px;
  height: 6px;
  background: var(--err);
}

/* ── Live tool-use chips (streaming) — small progress pills shown in place of the "Thinking…" bubble
   while the agent grounds its answer; they collapse the moment the first answer token arrives. ── */
/* The agent's working steps, shown as square mono tags. Shell, not tenant: these are
   Grounded's machinery, identical for every bot. */
/* The chip row replaces the word "Thinking…" in the same box. It eases in over
   --t-state so the swap reads as the same object changing what it says, and the
   height anchor above means the box does not move while it happens. */
@keyframes tool-chips-in { from { opacity: 0; translate: 0 3px; } }
.tool-chips {
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
  animation: tool-chips-in var(--t-state) var(--ease) both;
}
/* Each later step arrives on the same terms as the first, and steps DOWN from
   active to done rather than switching — the row is a running account of work, so
   watching a step hand over to the next one is the information. */
@keyframes tool-chip-in { from { opacity: 0; } }
.tool-chip {
  display: inline-flex;
  align-items: center;
  gap: 7px;
  padding: 4px 10px;
  font-family: var(--mono);
  font-size: 10px;
  font-weight: 600;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  line-height: 1.4;
  color: var(--muted);
  background: transparent;
  border: 1px solid var(--line);
  animation: tool-chip-in var(--t-ui) var(--ease) both;
  transition:
    color var(--t-ui) var(--ease),
    border-color var(--t-ui) var(--ease);
}
.tool-chip-dot {
  width: 6px;
  height: 6px;
  flex: none;
  background: var(--faint);
}
.tool-chip.active { color: var(--sig-text); border-color: color-mix(in srgb, var(--sig) 50%, transparent); }
.tool-chip.active .tool-chip-dot { background: var(--sig); animation: blink 1s infinite; }
.tool-chip-dot { transition: background-color var(--t-ui) var(--ease); }
@media (prefers-reduced-motion: reduce) {
  .tool-chip.active .tool-chip-dot { animation: none; opacity: 1; }
  .tool-chips, .tool-chip { animation: none; }
}
:root[data-a11y-motion="reduced"] .tool-chip.active .tool-chip-dot { animation: none; opacity: 1; }
:root[data-a11y-motion="reduced"] .tool-chips,
:root[data-a11y-motion="reduced"] .tool-chip { animation: none; }

/* ── The agent's plan (js/agent-plan.js) ─────────────────────────────────────────────────────────
   What the chip row above could not say. The chips are a running account of MOVES; this is the
   INTENT behind them — a goal sentence, the steps before they run, and the sources each step turns
   up while it is still thinking. It appears only on the turns the server judged multi-step (most
   turns still get the chips, unchanged), and it folds into its own header the moment the answer
   starts, so what stays with the finished answer is one line the reader can reopen.

   It is built from the chip row's own vocabulary rather than a second visual language: the same
   mono micro-caps, the same 6px mark, the same --sig for the thing happening NOW, the same square
   edges. A panel of pills and rounded cards would read as a component borrowed from another
   product, which is exactly what it must not be.

   The one new element is the dashed rule down the left of a step's work. It is doing a job no
   colour can: it says the lines under a step BELONG to it, at a glance, without indentation deep
   enough to cost a phone half its width. ── */
.agent-plan {
  /* Same flex reasoning as .retrieval-note: `.msg` is a flex container, and without min-width:0 a
     long goal or a long source title sizes this panel to its content and pans the whole log. */
  min-width: 0;
  max-width: 100%;
  margin: 0 0 8px;
  border: 1px solid var(--line);
  background: var(--surface2);
}
.agent-plan-head {
  display: flex;
  align-items: baseline;
  gap: 9px;
  width: 100%;
  min-width: 0;
  padding: 7px 10px;
  border: 0;
  background: none;
  color: var(--faint);
  font: inherit;
  text-align: left;
  cursor: pointer;
  transition: color var(--t-ui) var(--ease);
}
.agent-plan-head:hover { color: var(--accent-on-surface); }
.agent-plan-head:focus-visible { outline: 2px solid var(--sig); outline-offset: -2px; }
/* "GOAL" — the label that makes the sentence beside it mean something. Same mono micro-caps as a
   tool chip, because it is the same voice: Grounded's machinery talking, not the tenant's. */
.agent-plan-kicker {
  flex: none;
  font-family: var(--mono);
  font-size: 10px;
  font-weight: 600;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--faint);
}
.agent-plan-goal {
  flex: 1 1 auto;
  min-width: 0;
  font-size: 0.82rem;
  line-height: 1.45;
  color: var(--text-muted);
}
/* Collapsed, the goal is a receipt and must hold one line: the answer is what the reader came for,
   and a folded panel that wraps to three lines is not folded. Open, it wraps and says everything. */
.agent-plan-head[aria-expanded="false"] .agent-plan-goal {
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.agent-plan-meta {
  flex: none;
  font-family: var(--mono);
  font-size: 10px;
  letter-spacing: 0.05em;
  color: var(--faint);
}
/* The same chevron, duration and curve as .retrieval-summary::after and .source-toggle::after —
   three disclosures around one answer, and they must not move differently. */
.agent-plan-chev {
  flex: none;
  width: 0;
  height: 0;
  border-style: solid;
  border-width: 3px 0 3px 4px;
  border-color: transparent transparent transparent currentColor;
  opacity: 0.72;
  transition: transform var(--t-ui) var(--ease), opacity var(--t-ui) var(--ease);
}
.agent-plan-head:hover .agent-plan-chev { opacity: 1; }
.agent-plan-head[aria-expanded="true"] .agent-plan-chev { transform: rotate(90deg); }

.agent-plan-body {
  min-width: 0;
  padding: 2px 10px 8px;
}
/* Arriving eases from an already-legible starting point, exactly like .retrieval-body's
   disclosure-in: a dropped animation still leaves a readable panel. The height animation itself is
   revealPanel's (shared with every other disclosure in a message), not this file's. */
.agent-plan-body { animation: disclosure-in var(--t-state) var(--ease) both; }
.agent-plan-steps {
  margin: 0;
  padding: 0;
  list-style: none;
}
.agent-plan-step + .agent-plan-step { margin-top: 2px; }
.agent-plan-row {
  display: flex;
  align-items: baseline;
  gap: 8px;
  min-width: 0;
  padding: 3px 0;
}
/* The mark. A 6px square, the tool chip's dot squared off to this world's --radius: 0 — filled for
   work that happened, hollow for work that has not, and it is never the only signal: the status
   word sits at the end of the same row, so nothing here depends on telling two teals apart. */
.agent-plan-dot {
  flex: none;
  width: 6px;
  height: 6px;
  margin-top: 1px;
  border: 1px solid var(--line-hi);
  background: transparent;
  transition: background-color var(--t-ui) var(--ease), border-color var(--t-ui) var(--ease);
}
.agent-plan-title {
  flex: 1 1 auto;
  min-width: 0;
  font-size: 0.82rem;
  line-height: 1.5;
  color: var(--text-muted);
  overflow-wrap: anywhere;
}
.agent-plan-status {
  flex: none;
  font-family: var(--mono);
  font-size: 10px;
  font-weight: 600;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: var(--faint);
}

/* One state per row, and the row carries it — so a step's mark, its title and its word can never
   disagree about what is happening. */
.agent-plan-step[data-status="active"] > .agent-plan-row > .agent-plan-dot {
  background: var(--sig);
  border-color: var(--sig);
  animation: blink 1s infinite;
}
.agent-plan-step[data-status="active"] > .agent-plan-row > .agent-plan-title,
.agent-plan-step[data-status="active"] > .agent-plan-row > .agent-plan-status { color: var(--accent-on-surface); }
.agent-plan-step[data-status="done"] > .agent-plan-row > .agent-plan-dot {
  background: var(--ok);
  border-color: var(--ok);
  opacity: 0.75;
}
/* Not struck through. A finished step is settled, not deleted, and it is still the label for the
   sources listed underneath it — striking it would make the evidence read as withdrawn. */
.agent-plan-step[data-status="done"] > .agent-plan-row > .agent-plan-title { color: var(--faint); }
.agent-plan-step[data-status="failed"] > .agent-plan-row > .agent-plan-dot {
  background: var(--err);
  border-color: var(--err);
}
.agent-plan-step[data-status="failed"] > .agent-plan-row > .agent-plan-status { color: var(--err); }
/* "not needed" — the step the agent never reached. It is drawn as unfinished business rather than
   as an achievement, because that is what it is: the server marks an unreached step `skipped`, never
   `done`, and this is the visual half of the same promise. */
.agent-plan-step[data-status="skipped"] > .agent-plan-row { opacity: 0.62; }
.agent-plan-step[data-status="skipped"] > .agent-plan-row > .agent-plan-dot { border-style: dashed; }

/* The work under a step: one line per tool call, one row of tags per set of sources it found. The
   dashed rule is the connector — it ties the lines to the step above them at 11px of indent instead
   of the 24 a nested list would need. Hidden while empty so a step that finds nothing costs nothing;
   it only ever appears BEFORE the answer starts, so nothing the reader is reading moves under it. */
.agent-plan-work {
  margin: 0 0 2px 2px;
  padding: 0 0 0 11px;
  border-left: 1px dashed color-mix(in srgb, var(--line-hi) 65%, transparent);
  list-style: none;
}
.agent-plan-work:empty { display: none; }
.agent-plan-tool {
  display: flex;
  align-items: center;
  gap: 7px;
  padding: 1px 0;
  font-family: var(--mono);
  font-size: 10px;
  letter-spacing: 0.05em;
  color: var(--faint);
}
.agent-plan-tool-dot {
  flex: none;
  width: 5px;
  height: 5px;
  background: var(--line-hi);
  transition: background-color var(--t-ui) var(--ease);
}
.agent-plan-tool.active { color: var(--accent-on-surface); }
.agent-plan-tool.active .agent-plan-tool-dot { background: var(--sig); animation: blink 1s infinite; }
.agent-plan-tool-label { min-width: 0; overflow-wrap: anywhere; }

/* What the step actually FOUND, named while it is still thinking. The number is the same [n] the
   finished answer cites, which is the point: the reader watches source 2 arrive and then meets [2]
   in the prose. Tags wrap rather than scroll — a horizontal scroller inside a message is the trap
   this stylesheet has already been bitten by twice. */
.agent-plan-found {
  display: flex;
  flex-wrap: wrap;
  gap: 4px;
  padding: 2px 0 3px;
}
.agent-plan-source {
  display: inline-flex;
  align-items: baseline;
  gap: 5px;
  max-width: 100%;
  min-width: 0;
  padding: 2px 6px;
  border: 1px solid var(--line);
  font-family: var(--mono);
  font-size: 10px;
  letter-spacing: 0.04em;
  color: var(--text-muted);
}
.agent-plan-source-n {
  flex: none;
  font-weight: 700;
  color: var(--accent-on-surface);
}
.agent-plan-source-title {
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

/* Folded. The panel stops being a progress display and becomes a line of provenance, so it drops to
   the same quiet weight as .sources-line and .retrieval-note — the evidence area under and over an
   answer reads as one block, not as three competing footers. */
.agent-plan[data-state="done"] { background: transparent; }
.agent-plan[data-state="done"] .agent-plan-goal { font-size: 0.78rem; }

@media (max-width: 520px) {
  /* The step count and elapsed time are the first thing to go: at this width the goal itself needs
     the room, and the count is restated by the list the moment the panel is opened. */
  .agent-plan-meta { display: none; }
  .agent-plan-head { gap: 7px; padding: 6px 8px; }
  .agent-plan-body { padding: 2px 8px 7px; }
  .agent-plan-work { padding-left: 9px; }
}

@media (prefers-reduced-motion: reduce) {
  .agent-plan-body { animation: none; }
  .agent-plan-step[data-status="active"] > .agent-plan-row > .agent-plan-dot,
  .agent-plan-tool.active .agent-plan-tool-dot { animation: none; opacity: 1; }
}
:root[data-a11y-motion="reduced"] .agent-plan-body { animation: none; }
:root[data-a11y-motion="reduced"] .agent-plan-step[data-status="active"] > .agent-plan-row > .agent-plan-dot,
:root[data-a11y-motion="reduced"] .agent-plan-tool.active .agent-plan-tool-dot { animation: none; opacity: 1; }

/* ── Retry on an errored / interrupted turn ── */
.retry-btn {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  margin-top: 10px;
  padding: 0 14px;
  min-height: 32px;
  font-family: var(--mono);
  font-size: 10px;
  font-weight: 600;
  letter-spacing: 0.2em;
  text-transform: uppercase;
  line-height: 1;
  cursor: pointer;
  color: var(--text);
  background: transparent;
  border: 1px solid var(--control-edge);
  transition: border-color var(--t-ui) var(--ease), color var(--t-ui) var(--ease);
}
.retry-btn:hover,
.retry-btn:focus-visible { border-color: var(--sig); color: var(--sig-text); }
.retry-btn svg { display: block; }

/* ── The answer's action row ───────────────────────────────────────────────────
   Regenerate (app.js), the thumbs + Copy (js/share-export.js) and Read aloud (js/voice.js) are
   built by three different files, and each used to append its own strip to the message. A two-line
   answer ended up under three lines of chrome — taller than the answer itself, and the loudest
   thing in the whole turn was a row of emoji standing in for its least important controls. They
   share one line now (ensureActionRow in app.js), and the buttons in it share one voice: the same
   30px box, the same hairline, the same --sig hover as every other control on this surface.

   Wraps rather than clips: at 320px the row folds onto a second line. */
.answer-actions {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 6px;
  margin-top: 10px;
}
/* Inside the row the pieces carry no margin of their own — the row owns the spacing.
   ID-SCOPED, AND IT HAS TO BE. js/share-export.js declares `.gcb-fb { margin-top: 8px }` — (0,1,0),
   which a plain `.answer-actions > .gcb-fb` outranks — but js/voice.js also declares
   `.gcb-fb.gcb-fb-voiceonly { margin-top: 8px }` at (0,2,0), which TIES it, and that <style> element
   is appended after this file, so it won on source order. The bar voice.js builds when the feedback
   controls never mounted (no conversation to rate yet) therefore kept its margin and Read aloud sat
   **4px below Regenerate** — measured: regen top 389.5, read-aloud top 393.5, in a row that is
   `align-items: center`, so the offset was half the margin and looked like a rendering accident
   rather than a rule. `#messages` makes it (1,2,0) and load order stops mattering. */
#messages .answer-actions > .regen-btn,
#messages .answer-actions > .gcb-fb {
  margin-top: 0;
}
/* js/share-export.js declares .gcb-fb-btn with a 7px radius, a 13px sans face and an --accent hover:
   a second visual language for controls sitting inside an answer. Asserted here and scoped by
   #messages for the same reason #header-actions asserts the header row — that <style> element is
   appended at import time, AFTER this file, so only an id-scoped rule wins regardless of load
   order, and one declaration cannot drift the way copies in four modules did. */
#messages .gcb-fb {
  gap: 6px;
}
#messages .gcb-fb-btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 6px;
  min-height: 30px;
  padding: 0 10px;
  border-radius: 0;
  border: 1px solid var(--line);
  background: transparent;
  color: var(--muted);
  font-family: var(--mono);
  font-size: 10px;
  font-weight: 600;
  letter-spacing: 0.18em;
  text-transform: uppercase;
  line-height: 1;
  transition: border-color var(--t-ui) var(--ease), color var(--t-ui) var(--ease);
}
#messages .gcb-fb-btn:hover,
#messages .gcb-fb-btn:focus-visible {
  border-color: var(--sig);
  color: var(--sig-text);
  background: transparent;
}
/* Voted, or currently reading aloud: the tenant accent, like every other "this is on" mark here.
   --accent-on-surface rather than --accent because a tenant hex picked for a logo is regularly
   under 3:1 as GLYPHS on this background; the token mixes it toward --text until it is not. */
#messages .gcb-fb-btn.active,
#messages .gcb-fb-btn.gcb-speaking {
  border-color: var(--accent);
  color: var(--accent-on-surface);
  background: transparent;
}
/* The message's own caption, once it is in the row: hard right, and never a flex item that
   stretches to the row's height. */
.answer-actions > .msg-time {
  margin-top: 0;
  margin-left: auto;
  align-self: center;
  /* LAST in the row, whatever order the three files that fill it happened to run in. The row is
     built by app.js when the answer finishes, and the feedback bar arrives from another module on
     its own event, so DOM order here is a race and `order` is the only stable answer. Reading order
     is unaffected in any way that matters: a muted timestamp read before the controls is a caption
     read before the controls. */
  order: 1;
}

#messages .gcb-fb-btn svg {
  display: block;
}
/* The icon-only members are square; only Copy carries a word. */
#messages .gcb-fb-btn:not(.gcb-fb-copy) {
  width: 30px;
  padding: 0;
}

/* ── Regenerate answer + dynamic follow-up chips (Feature S2.2) ── */
.regen-btn {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  margin-top: 10px;
  padding: 0 12px;
  min-height: 30px;
  font-family: var(--mono);
  font-size: 10px;
  font-weight: 600;
  letter-spacing: 0.18em;
  text-transform: uppercase;
  line-height: 1;
  cursor: pointer;
  color: var(--muted);
  background: transparent;
  border: 1px solid var(--line);
  transition: border-color var(--t-ui) var(--ease), color var(--t-ui) var(--ease);
}
.regen-btn:hover,
.regen-btn:focus-visible { border-color: var(--sig); color: var(--sig-text); }
.regen-btn svg { display: block; }
.followups {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
  margin-top: 10px;
}
/* A step under .chip, still a step up from where it was — same reason: read, then choose. */
.followup-chip { font-size: 13.5px; }

/* ── Message edit + version-tree branching (ChatGPT-style) ── */
/* Pencil edit affordance on a user bubble — revealed on hover/focus of the message. */
.edit-btn {
  align-self: flex-end;
  margin-top: 4px;
  padding: 3px;
  display: inline-flex;
  cursor: pointer;
  color: var(--muted);
  background: transparent;
  border: 1px solid transparent;
  opacity: 0;
  transition: opacity var(--t-ui) var(--ease), color var(--t-ui) var(--ease), border-color var(--t-ui) var(--ease);
}
.msg.user:hover .edit-btn,
.edit-btn:focus-visible { opacity: 0.75; }
.edit-btn:hover,
.edit-btn:focus-visible { opacity: 1; color: var(--sig-text); border-color: var(--sig); }
.edit-btn svg { display: block; }
.msg.user.editing .edit-btn,
.msg.user.editing .ver-switch { display: none; }

/* Inline edit box (replaces the bubble while editing). */
.edit-box {
  display: flex;
  flex-direction: column;
  gap: 8px;
  width: min(560px, 78vw);
  align-items: stretch;
}
.edit-ta {
  width: 100%;
  min-height: 40px;
  max-height: 40vh;
  resize: none;
  overflow-y: auto;
  padding: 10px 13px;
  font-size: 14.5px;
  line-height: 1.55;
  font-family: var(--font);
  color: var(--text);
  background: var(--user-bubble);
  border: 1px solid var(--control-edge);
  border-right: 2px solid var(--accent);   /* still the visitor's own turn */
  box-sizing: border-box;
}
.edit-ta:focus-visible { outline: 2px solid var(--sig); outline-offset: 1px; border-color: var(--sig); }
.edit-actions { display: flex; justify-content: flex-end; gap: 8px; }
.edit-actions button {
  padding: 0 14px;
  min-height: 30px;   /* WCAG 2.2 target size */
  font-family: var(--mono);
  font-size: 10px;
  font-weight: 600;
  letter-spacing: 0.18em;
  text-transform: uppercase;
  line-height: 1;
  cursor: pointer;
  transition: border-color var(--t-ui) var(--ease), color var(--t-ui) var(--ease), background-color var(--t-ui) var(--ease);
}
.edit-actions button:disabled { opacity: 0.45; cursor: not-allowed; }
.edit-cancel { color: var(--muted); background: transparent; border: 1px solid var(--control-edge); }
.edit-cancel:hover:not(:disabled) { color: var(--sig-text); border-color: var(--sig); }
/* Committing an edit re-asks the bot, so it is a primary action: tenant accent, with
   the foreground app.js computed for it. */
.edit-save { color: var(--accent-fg, #fff); background: var(--accent); border: 1px solid var(--accent); }
.edit-save:hover:not(:disabled) { filter: brightness(1.12); }

/* ‹ n/m › version switcher on an edited user turn. */
.ver-switch {
  display: inline-flex;
  align-items: center;
  gap: 2px;
  margin-top: 5px;
  color: var(--muted);
  font-family: var(--mono);
  font-size: 11px;
}
.ver-nav {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 24px;    /* WCAG 2.2 ≥24×24 target size */
  height: 24px;
  padding: 0;
  font-size: 15px;
  line-height: 1;
  cursor: pointer;
  color: var(--muted);
  background: transparent;
  border: 1px solid transparent;
}
.ver-nav:hover:not(:disabled),
.ver-nav:focus-visible { color: var(--sig-text); border-color: var(--sig); }
.ver-nav:disabled { opacity: 0.35; cursor: not-allowed; }
.ver-label { min-width: 26px; text-align: center; font-variant-numeric: tabular-nums; letter-spacing: 0.08em; }

/* ── Suggested-question chips ── */
/* THE STARTERS SIT WITH THE COMPOSER, NOT IN THE MIDDLE OF THE PAGE. They were children of the
   centred empty-state hero, which put them a full viewport away from the box you answer them in:
   on a tall desktop window the hero floats at the vertical centre and the chips landed ~300px
   above the field, so choosing one meant crossing the whole transcript area. Here they are the
   last thing above the composer — read the question, act in the control directly beneath it.
   Centred rather than left-aligned because they are an offer, not a list: they line up with the
   hero above and the composer below on the same axis.
   `min-height` is the layout-shift guard. Starters arrive with GET /api/bot, one round trip after
   first paint; without a reserved line the composer and footer would jump up the page the moment
   they land. One row is reserved, wrapping rows are not — a second row is the uncommon case and
   holding permanent empty space for it is the trade browse.css already refuses for #yours-grid. */
/* A GRID OF CARDS ON THE COMPOSER'S OWN WIDTH, not a centred run of pills. As pills the four
   starters wrapped into ragged rows whose breaks moved with the length of the owner's questions —
   2/2 for one bot, 1/2/1 for the next — so the block under the greeting had no fixed shape and did
   not line up with the box beneath it. Two equal columns on the same `--measure` as `#chat-form`
   give the empty state one edge: greeting, then a block of offers, then the composer, all flush.
   One column below 640px, where two would leave each question ~150px to wrap inside.
   `min-height` reserves ONE row against the /api/bot round trip that fills this. The second row is
   not reserved for the reason browse.css gives about `#yours-grid`: a bot with two starters would
   pay a permanent empty row for a row it never renders. */
#chips {
  flex-shrink: 0;
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: 6px;
  /* Follows the card. One row is reserved against the /api/bot round trip that fills this;
     the second is not, for the reason browse.css gives about #yours-grid. */
  min-height: 32px;
  padding: 6px max(20px, calc((100% - var(--measure)) / 2)) 2px;
}
/* ONE SWIPEABLE ROW ON A PHONE, not a stack. Two columns at this width leave each question ~150px
   to wrap inside, so the obvious move is one column — and one column of four two-line cards is
   ~280px of permanently-occupied viewport. Measured on a 390x820 phone that left `#messages` 200px
   for a 193px hero plus the disclosure: the greeting did not fit in its own empty state at all,
   which is a strange thing for a greeting to do.
   A horizontal row costs one card's height instead of four. All four are still reachable, in the
   same order, by swiping — nothing is hidden and nothing is dropped, which is the difference
   between this and capping the list. Snap points make it land on a card rather than between two.
   The scroller is `#chips` itself, so the overflow is contained: the cards are wider than the
   viewport INSIDE it and the document never widens (the failure mode written up in
   docs/qa — a child escaping an overflow container and widening <html>). `scrollbar-width: none`
   because the row is snap-scrolled by touch and the bar would eat 8px of a 56px card. */
@media (max-width: 640px) {
  #chips {
    display: flex;
    flex-wrap: nowrap;
    overflow-x: auto;
    overscroll-behavior-x: contain;
    scroll-snap-type: x mandatory;
    gap: 8px;
    /* 44, not the desktop 32: below 640px this surface is a phone, the pointer is coarse, and
       the swipe row IS the touch target. */
    min-height: 44px;
    scrollbar-width: none;
    /* A snap container snaps to its SCROLLPORT edge, not to the inside of its padding, so with
       `snap-align: start` the row scrolled itself 20px left on load and the first card sat flush
       against the screen edge while a 20px gap opened on the right. `scroll-padding` is the
       property that tells snapping where the content really begins; it has to mirror the padding
       above, and it is mirrored again in the <=600px block further down where that padding is
       14px rather than 20px. */
    scroll-padding-inline: 20px;
  }
  #chips::-webkit-scrollbar { display: none; }
  #chips .chip {
    flex: 0 0 auto;
    width: min(78%, 320px);
    min-height: 44px;
    padding: 5px 12px;
    scroll-snap-align: start;
  }
  /* The desktop rule that spans a lone odd card across both columns is a grid rule; there are no
     columns here, and left in place it would stretch one card to the full row. */
  #chips .chip:last-child:nth-child(odd) {
    grid-column: auto;
  }
}
#chips:empty,
#chips[hidden] {
  min-height: 0;
  padding: 0;
}
/* An odd last card spans both columns rather than leaving a hole beside it — three starters read
   as a block with a wide base, not as a grid someone forgot to finish. */
#chips .chip:last-child:nth-child(odd) {
  grid-column: 1 / -1;
}
/* The chips enter with the hero, on the hero's own curve, so the empty state reads as one object
   arriving rather than three pieces landing at different times. */
#chips .chip {
  animation: hero-in var(--t-layout) var(--ease) both;
}
@media (prefers-reduced-motion: reduce) {
  #chips .chip { animation: none; }
}
/* THE STARTER CARDS ARE A DIFFERENT COMPONENT FROM THE FOLLOW-UP CHIPS, sharing only `.chip`'s
   border, hover and focus. Everything below is scoped by #chips, so `.chip.followup-chip` — the
   same class rendered inline under an answer, sized to sit inside a paragraph's rhythm — keeps the
   single-line pill it has always been.
   The two lines are why this is a block and not a pill: `align-items: flex-start` + `column` turns
   the flex box `.chip` already is into a stacked card, and the text goes left-aligned because two
   centred lines of unequal length have no edge to read down. `height: 100%` makes both cards in a
   row match the taller of the two, which is what stops a grid of four looking like four sizes. */
#chips .chip {
  flex-direction: column;
  align-items: flex-start;
  justify-content: center;
  gap: 0;
  height: 100%;
  /* 32px on a fine pointer, down from 46 and from 64 before that. The previous pass took the air
     out; there was none left, so this one takes type: the question drops 13px → 11.5px and the
     description to 11px, both on 1.3 leading, with 3px of padding above and below.
     THAT IS THE FLOOR, and it is worth writing down where the next person will look. A two-line
     card cannot be halved again: two lines at 11px/1.3 are 28.6px before any padding at all, so
     ~32px IS the card, and the only ways further down are dropping to one line (losing the
     description) or type small enough to fail its own purpose. The BLOCK is what actually shrank —
     124px → ~74px — because the rows shrank together with the gap.
     46 was the coarse-pointer floor and this is below it, so the override at the bottom of the file
     comes back: `#chips .chip` is an id plus a class and outranks
     `@media (pointer: coarse) { .chip { min-height: 44px } }` on either side of a media query, which
     is the trap that already broke this once. Desktop density is not a touch target. */
  min-height: 32px;
  padding: 3px 11px;
  text-align: left;
}
/* Tighter than the 1.6 body leading `.chip` sets, and set on the spans rather than the card so the
   follow-up chips that share `.chip` keep the line-height they are sized around. Two lines of a
   control are a label, and a label is read in one glance rather than down a paragraph.
   The two lines are sized apart by half a pixel of type rather than by weight: the question stays
   the thing you read first because it is on top and at full strength, and shrinking the description
   further would start to say "fine print" about a line that is the whole point of the mode. */
#chips .chip-lead {
  font-size: 11.5px;
  line-height: 1.3;
}
#chips .chip-rest {
  font-size: 11px;
  line-height: 1.3;
}
/* The question itself. ALWAYS full strength, in both shapes — this is the line a visitor reads to
   decide, and it is never the dim half of anything. A card with no description is nothing but this
   line, which is what makes a plain question look like a plain question rather than like a card
   missing its second row. */
.chip-lead {
  color: var(--text);
}
/* One-line card: centre the single line instead of leaving it top-aligned in a two-line box. The
   card keeps the SAME height as its two-line neighbours — the grid stretches every row to its
   tallest member and min-height holds the floor — so a bot that writes descriptions on only some
   of its starters gets one even grid rather than a ragged one. */
#chips .chip-solo {
  justify-content: center;
}
/* The owner's description of what the starter does, one step back — `--faint`, not `--muted`.
   `--muted` was the first choice and it does not work here: measured on this card it is 13.3:1 in
   dark against `--text`'s 16.3, and 10.0 against 15.8 in light. Both are strong, the gap between
   them is not, and a second line only reads as a second line if the eye can see the step. `--faint`
   measures 6.5:1 dark and 5.3:1 light on `--surface` — visibly lower and still clear of AA for body
   text with room — and in forced-colours theme.css maps it to `--hc-text-muted` like every other
   text token.
   The step is TONE, not scale: same size, same tracking as the question above it. Shrinking it too
   would make the card a title with a caption, and these are a control and its explanation.
   This span exists ONLY when the owner wrote a description. It is never a piece of the question
   itself — the earlier version of this cut the question in half and dimmed the tail, which greyed
   out words the owner had written as one sentence. */
.chip-rest {
  color: var(--faint);
  transition: color var(--t-ui) var(--ease);
}
/* On hover the whole sentence comes up together — the card is one target and one question, so
   half of it brightening would say the two lines are separately actionable. */
#chips .chip:hover .chip-rest,
#chips .chip:focus-visible .chip-rest {
  color: inherit;
}
/* Example questions. Sentence case (they are questions, not labels) but set in mono
   so they read as offered INPUT rather than as prose the bot has said. Shell chrome. */
.chip {
  display: inline-flex;
  align-items: center;
  min-height: 34px;
  background: var(--surface);
  border: 1px solid var(--control-edge);
  color: var(--muted);
  padding: 8px 13px;
  /* 13px, not 12px: these are questions to read and choose between, not labels to recognise. The
     drawn box barely moves — line-height 1.6 already pushed it past the 34px min-height. */
  font-size: 13px;
  font-family: var(--mono);
  letter-spacing: 0.04em;
  text-align: left;
  cursor: pointer;
  transition:
    border-color var(--t-ui) var(--ease),
    color var(--t-ui) var(--ease),
    background-color var(--t-ui) var(--ease);
}
.chip:hover,
.chip:focus-visible {
  border-color: var(--sig);
  color: var(--sig-text);
  background: color-mix(in srgb, var(--sig) 8%, var(--surface));
}
.chip:active {
  background: color-mix(in srgb, var(--sig) 16%, var(--surface));
}

/* ── Empty-state hero (Feature S2.6) ── */
/* THE GAP ABOVE THE HERO IS THIS ELEMENT, and app.js's showHero explains why it is not the
   `margin-top: auto` it replaced. Short version: an auto margin claims the free space and will not
   give it back when the content outgrows the box, so the hero overflowed off the bottom of the
   scroller with a screenful of emptiness above it. `flex: 1 1 0` grows into the same space and
   shrinks to zero the moment it is needed. `min-height: 0` is the half that does the shrinking —
   without it a flex item refuses to go below its content size, and this one has no content. */
.chat-hero-spacer {
  flex: 1 1 0;
  min-height: 0;
}
/* Centred horizontally, in normal flow vertically. The hero sits at the BOTTOM of the transcript
   rather than its middle: with the starter cards on the composer, a centred hero left one empty
   state split into two halves with a third of a viewport between them, reading as two unrelated
   things instead of one greeting. Name, tagline, offered questions, then the box you answer them
   in — one group, in the order it is used. */
.chat-hero {
  margin: 0 auto;
  display: flex;
  flex-direction: column;
  align-items: center;
  text-align: center;
  gap: 14px;
  padding: 24px 16px 4px;
  max-width: 560px;
  animation: hero-in var(--t-layout) var(--ease) both;
}
/* The empty state teaches the interface: the bot's mark at full size, its name, what
   it is scoped to, and three things worth asking. It never just says "no messages". */
.chat-hero-logo {
  width: 56px;
  height: 65px;   /* 56 × 1.1547 — a true hexagon */
}
.chat-hero-img {
  --mark-w: 56px;
  width: var(--mark-w);
  height: calc(var(--mark-w) * 1.1547);
  clip-path: var(--hex-clip);
  object-fit: cover;
  background: var(--surface2);
}
.chat-hero .logo-mark {
  --mark-w: 56px;
}
.chat-hero-name {
  font-family: var(--font);
  font-size: 24px;
  font-weight: 700;
  letter-spacing: 0.005em;
  line-height: 1.15;
  color: var(--text);
}
.chat-hero-tagline {
  font-size: 14px;
  line-height: 1.6;
  color: var(--muted);
  max-width: 46ch;
  text-wrap: pretty;
}
.chat-hero-chips {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
  justify-content: center;
  margin-top: 4px;
}
@keyframes hero-in {
  from { opacity: 0; transform: translateY(6px); }
  to   { opacity: 1; transform: none; }
}
@media (prefers-reduced-motion: reduce) {
  .chat-hero { animation: none; }
}

/* ── Input ── */
#chat-form {
  flex-shrink: 0;
  padding: 12px max(20px, calc((100% - var(--measure)) / 2)) 14px;
}
/* A COLUMN, NOT A LINE. Every capability a bot enables mounts an icon in here, and while this was
   one flex row each of them ate width from the field: uploads + voice + tools left the typing
   surface roughly a third narrower than a bot with none of them, so the composer got smaller the
   more the assistant could do. Now the textarea owns the full width and the controls have their
   own bar underneath, which costs one fixed 34px strip and takes nothing per capability.
   Still `.input-row`, and still the outer element: uploads.js drops files on it and inserts its
   attachment bar before it, `:focus-within` below rings it, and `.input-row .composer-action` down
   near the header-actions block scopes the module buttons through it. */
.input-row {
  display: flex;
  flex-direction: column;
  gap: 6px;
  background: var(--surface);
  border: 1px solid var(--control-edge);
  padding: 10px 10px 8px;
  transition: border-color var(--t-ui) var(--ease), outline-color var(--t-ui) var(--ease);
}
/* The controls. `min-height` is what makes the reservation real — the bar holds its strip whether
   or not any module has mounted, so a capability that resolves a fetch after first paint does not
   grow the box under someone already typing. */
.composer-bar {
  display: flex;
  align-items: center;
  gap: 8px;
  min-height: 34px;
}
.composer-tools {
  display: flex;
  align-items: center;
  gap: 6px;
  flex: 1;
  min-width: 0;
  flex-wrap: wrap;
}
/* The other end of the bar: what acts on the message rather than on what it carries. The mic is
   routed in here by core.js's COMPOSER_RIGHT; Send is its neighbour, a sibling of this group so
   its own rules further down still apply unchanged. */
.composer-right {
  display: flex;
  align-items: center;
  gap: 6px;
  flex: 0 0 auto;
}
/* NO `order` HERE, deliberately. The bar's sequence is decided — registration order is a race
   between capabilities.js (registers during init) and uploads.js (mounts only after the caps fetch
   says file_upload is on) — but it is decided in the DOM, by core.js's COMPOSER_ORDER, not with
   flex `order`. `order` moves the boxes and leaves the tab sequence on DOM order, which is the
   classic WCAG 2.4.3 failure: the eye reads attach → Tools → Skills and the keyboard walks
   Tools → Skills → attach. See the comment on COMPOSER_ORDER in js/core.js. */
/* Shell ring, not the tenant's: the composer must always look focusable, whatever
   colour the bot chose. Drawn as a real outline so forced-colors keeps it. */
.input-row:focus-within {
  border-color: var(--sig);
  outline: 2px solid var(--sig);
  outline-offset: -3px;
}

/* `min-height` is the whole point of the taller box: a one-line field says "type a search term",
   and this assistant is asked multi-clause questions. Three lines of room, visible before anyone
   types, is the instruction. app.js still autosizes upward from here to the 200px ceiling. */
#question {
  width: 100%;
  min-width: 0;
  background: transparent;
  border: none;
  outline: none;
  color: var(--text);
  font-size: 0.9667rem;
  font-family: var(--font);
  padding: 3px 3px 0;
  resize: none;
  line-height: 1.5;
  min-height: 66px;
  max-height: 200px;
  overflow-y: auto;
}
/* --muted, not --faint: a placeholder is body-sized text and has to clear 4.5:1. */
#question::placeholder {
  color: var(--muted);
}

/* iOS Safari auto-zooms the page when a focused text field's font-size is < 16px. The composer,
   the gate/login inputs, and the drawer rename/create inputs all use ~13–14.5px for desktop
   density, which triggers that zoom on touch. Force ≥16px on coarse-pointer / small screens so
   focusing an input never zooms the layout (desktop density is unchanged). */
@media (pointer: coarse), (max-width: 600px) {
  #question,
  .gate-input,
  .history-rename,
  .project-create-input,
  .project-rename {
    font-size: 16px;
  }
}

/* THE primary action, and therefore the tenant's. --accent-fg is the WCAG-correct
   foreground app.js computes per bot; never assume white here. */
/* Circular, with its neighbours — see the `.input-row .composer-action` note above. It is the one
   filled control on the bar and the only one carrying the tenant's colour, so it is the member the
   others are shaped to agree WITH; a square accent block beside four circles was the loudest
   disagreement on the surface. `border-radius` only — the fill, the accent and the disabled/busy
   handling below are untouched. */
#send-btn {
  background: var(--accent);
  border: 1px solid var(--accent);
  color: var(--accent-fg, #fff);
  border-radius: 999px;
  /* A circle needs equal sides, and `padding: 7px 10px` on an auto width gave it 42x40 — a very
     slightly squashed one, which is the sort of thing you see without being able to name. Explicit
     40x40 with the padding dropped; the icon is centred by the flex rules below, not by padding.
     `min-*` stays and still wins on touch, where the coarse block raises both to 44 and the circle
     simply gets bigger. */
  width: 40px;
  height: 40px;
  min-width: 40px;
  min-height: 40px;
  padding: 0;
  cursor: pointer;
  display: flex;
  align-items: center;
  justify-content: center;
  transition: filter var(--t-ui) var(--ease), opacity var(--t-ui) var(--ease);
  flex-shrink: 0;
}
#send-btn:hover:not(:disabled) {
  filter: brightness(1.12);
}
#send-btn:active:not(:disabled) {
  filter: brightness(0.9);
}
/* Loading: app.js disables the composer for the whole in-flight turn, so "disabled"
   here reads as "busy". Keep the accent, drop it back — a grey box would say
   "broken" when the truth is "working". */
#send-btn:disabled {
  background: color-mix(in srgb, var(--accent) 35%, transparent);
  border-color: color-mix(in srgb, var(--accent) 35%, transparent);
  color: var(--muted);
  cursor: progress;
}

.chip:focus-visible,
#send-btn:focus-visible,
.gate-go:focus-visible {
  outline: 2px solid var(--sig);
  outline-offset: 2px;
}
#question:focus-visible {
  outline: none;
}

/* ── Footer ── */
footer {
  flex-shrink: 0;
  padding: 0 max(20px, calc((100% - var(--measure)) / 2)) 10px;
  text-align: center;
}
/* Set as a SENTENCE, not as a label. 10px/0.1em is this system's eyebrow register, and the
   safeguard line is the smallest critical text on the surface — it is meant to be read, not merely
   recognised, and mono tracking that wide slows sentence reading further. Bumped one step and
   re-tracked; `pretty` keeps the last line from breaking to a single orphaned word. */
.footer-note {
  font-family: var(--mono);
  font-size: 11.5px;
  letter-spacing: 0.06em;
  color: var(--muted);
  text-wrap: pretty;
}
.footer-note a { color: inherit; text-underline-offset: 3px; }
.footer-note a:hover { color: var(--sig-text); }

/* ── Bot-code gate ── */
.gate {
  min-height: 100dvh;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 20px;
}
.gate-card {
  display: flex;
  flex-direction: column;
  align-items: center;
  text-align: center;
  gap: 10px;
  width: 100%;
  max-width: 420px;
  /* Cap to the viewport minus the 20px .gate / .euser-modal padding on each side, then scroll —
     on a short/landscape phone the sign-in card can be taller than the viewport and get clipped
     top+bottom without this. */
  max-height: calc(100dvh - 40px);
  overflow-y: auto;
  padding: 36px 32px;
  background: var(--surface);
  border: 1px solid var(--line-hi);
  box-shadow: var(--shadow-card);
}
.gate-logo {
  --mark-w: 44px;
  width: var(--mark-w);
  height: calc(var(--mark-w) * 1.1547);
  margin-bottom: 8px;
}
.gate-title {
  font-family: var(--font);
  font-size: 19px;
  font-weight: 700;
  letter-spacing: 0.005em;
}
.gate-sub {
  color: var(--muted);
  font-size: 13.5px;
  max-width: 36ch;
  text-wrap: pretty;
}
.gate-label {
  align-self: flex-start;
  margin-top: 12px;
  margin-bottom: 5px;
  font-family: var(--mono);
  font-size: 10px;
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.24em;
  color: var(--muted);
}
.gate-go {
  background: var(--accent);
  border: 1px solid var(--accent);
  color: var(--accent-fg, #fff);
  min-height: 44px;
  padding: 0 22px;
  font-family: var(--mono);
  font-size: 11px;
  font-weight: 700;
  letter-spacing: 0.2em;
  text-transform: uppercase;
  cursor: pointer;
  transition: filter var(--t-ui) var(--ease);
  flex-shrink: 0;
}
.gate-go:hover {
  filter: brightness(1.12);
}
.gate-go:active {
  filter: brightness(0.9);
}
.gate-go:disabled {
  opacity: 0.45;
  cursor: not-allowed;
}
.gate-error {
  display: block;
  width: 100%;
  margin-top: 10px;
  padding: 8px 11px;
  color: var(--err);
  background: color-mix(in srgb, var(--err) 8%, transparent);
  border: 1px solid color-mix(in srgb, var(--err) 46%, transparent);
  font-size: 13px;
  text-align: left;
}

/* ── End-user login gate ── */
.gate-fields {
  width: 100%;
  display: flex;
  flex-direction: column;
}
.gate-input {
  width: 100%;
  min-height: 44px;
  margin-bottom: 8px;
  background: var(--bg);
  border: 1px solid var(--control-edge);
  padding: 11px 12px;
  color: var(--text);
  font-size: 14.5px;
  font-family: var(--font);
  transition: border-color var(--t-ui) var(--ease);
}
.gate-input::placeholder { color: var(--muted); }
.gate-input:hover { border-color: var(--text); }
.gate-input:focus-visible {
  outline: 2px solid var(--sig);
  outline-offset: 1px;
  border-color: var(--sig);
}
.gate-input[readonly] { opacity: 0.72; cursor: default; }
.euser-go {
  width: 100%;
  margin-top: 10px;
}
.gate-toggle {
  margin-top: 6px;
  padding: 4px 2px;
  background: none;
  border: none;
  color: var(--muted);
  font-size: 12.5px;
  font-family: var(--font);
  text-decoration: underline;
  text-underline-offset: 4px;
  cursor: pointer;
  transition: color var(--t-ui) var(--ease);
}
.gate-toggle:hover {
  color: var(--sig-text);
}
.gate-help { color: var(--muted); font-size: 0.8rem; line-height: 1.45; text-align: left; }
/* The chat surface declares no bare-anchor colour anywhere — styles.css only sets a:focus-visible,
   and backbone.css scopes anchor colour to .bb-prose / .bb-check. Every other anchor inside the gate
   card is classed (.access-oauth-btn), so without this the help link renders in the UA default blue
   on a dark card. Same declaration auth.css and my.css already use for their own contexts. */
.gate-help a { color: var(--sig-text); text-underline-offset: 3px; }

/* ── End-user sign-in modal (private bots) ──
   A mandatory, centered dialog over the (dimmed) chat. No dismiss — the visitor must sign in,
   so there's no backdrop-click / Escape close that would reveal an unusable chat. The .gate-card
   inside provides the card chrome; this layer only handles the overlay + backdrop + entrance. */
.euser-modal {
  position: fixed;
  inset: 0;
  z-index: 200;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 20px;
}
.euser-modal[hidden] {
  display: none;
}
.euser-modal__backdrop {
  position: absolute;
  inset: 0;
  background: var(--backdrop);
  backdrop-filter: blur(2px);
  animation: euser-fade var(--t-state) var(--ease);
}
.euser-modal__card {
  position: relative; /* sit above the backdrop */
  z-index: 1;
  animation: euser-rise var(--t-state) var(--ease);
}
body.euser-modal-open {
  overflow: hidden;
}
@keyframes euser-fade {
  from { opacity: 0; }
  to { opacity: 1; }
}
@keyframes euser-rise {
  from { opacity: 0; transform: translateY(8px) scale(0.985); }
  to { opacity: 1; transform: none; }
}
/* THE SAME RISE, FOR A BOX THAT IS CENTRED BY ITS OWN TRANSFORM. `transform` is one property: a
   keyframe that ends at `none` does not "stop animating", it OVERRIDES the `translate(-50%, -50%)`
   that centres the element for the whole duration. The mobile header-overflow dialog is centred that
   way, so for 240ms it was drawn from the middle of the screen down and to the right — measured at
   390px wide: x 195 y 400 for a 350x325 box, 175px right and 162px low, snapping into place when
   the animation ended. Anything centred by transform must animate through these keyframes instead,
   which carry the centring in every frame. */
@keyframes euser-rise-centered {
  from { opacity: 0; transform: translate(-50%, calc(-50% + 8px)) scale(0.985); }
  to { opacity: 1; transform: translate(-50%, -50%); }
}
@media (prefers-reduced-motion: reduce) {
  .euser-modal__backdrop,
  .euser-modal__card {
    animation: none;
  }
}

/* ── Access overlay: conditional OAuth + access-code sections inside the gate card ── */
.access-oauth {
  width: 100%;
  display: flex;
  flex-direction: column;
  gap: 9px;
}
.access-oauth[hidden] {
  display: none;
}
.access-oauth-list {
  display: flex;
  flex-direction: column;
  gap: 9px;
}
.access-oauth-list[hidden] {
  display: none;
}
.access-oauth-btn {
  display: flex;
  align-items: center;
  gap: 11px;
  width: 100%;
  min-height: 44px;
  padding: 11px 13px;
  background: transparent;
  color: var(--text);
  border: 1px solid var(--control-edge);
  font-size: 14px;
  text-decoration: none;
  cursor: pointer;
  transition:
    border-color var(--t-ui) var(--ease),
    color var(--t-ui) var(--ease),
    background-color var(--t-ui) var(--ease);
}
.access-oauth-btn:hover {
  border-color: var(--sig);
  color: var(--sig-text);
  background: color-mix(in srgb, var(--sig) 8%, transparent);
}
.access-oauth-btn:focus-visible {
  outline: 2px solid var(--sig);
  outline-offset: 2px;
}
.access-oauth-ic {
  width: 22px;
  height: 22px;
  flex: 0 0 22px;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  border: 1px solid var(--line);
  background: var(--surface2);
  font-family: var(--mono);
  font-weight: 700;
  font-size: 12px;
}
.access-oauth-note {
  font-size: 13px;
  color: var(--muted);
  text-align: center;
  margin: 2px 0;
}

/* "or" divider between the OAuth and access-code sections (shown only when both are offered). */
.access-or {
  display: flex;
  align-items: center;
  gap: 12px;
  width: 100%;
  margin: 4px 0;
  color: var(--muted);
  font-family: var(--mono);
  font-size: 10px;
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.24em;
}
.access-or[hidden] {
  display: none;
}
.access-or::before,
.access-or::after {
  content: "";
  flex: 1;
  height: 1px;
  background: var(--line);
}

/* The access-code form section (identifier/secret + shared-code toggle). */
.access-code {
  width: 100%;
  display: flex;
  flex-direction: column;
  align-items: center;
}
.access-code[hidden] {
  display: none;
}

/* Gray-out: the chat behind the access overlay is dimmed + desaturated + non-interactive until the
   gate clears. The overlay (#euser-gate) is a SIBLING of #app, so it stays fully interactive.

   Two transitions, not one: the declaration that governs a change is the one in the state being
   moved TO, so `.access-gated` owns the wash coming down (a content state becoming another →
   --t-state) and the base rule owns the gate clearing (a departure → --t-exit/--ease-exit). The
   reader has just proved they may be here; the dim should get out of the way, not decelerate. */
#app {
  transition: filter var(--t-exit) var(--ease-exit);
}
#app.access-gated {
  filter: grayscale(0.9) opacity(0.5);
  pointer-events: none;
  user-select: none;
  transition: filter var(--t-state) var(--ease);
}
@media (prefers-reduced-motion: reduce) {
  #app {
    transition: none;
  }
}

/* ── Sign out (gated bots) ── */
.signout-btn {
  flex-shrink: 0;
  background: transparent;
  border: 1px solid var(--control-edge);
  color: var(--muted);
  font-family: var(--mono);
  font-size: 10px;
  font-weight: 600;
  letter-spacing: 0.18em;
  text-transform: uppercase;
  min-height: 34px;
  padding: 0 12px;
  cursor: pointer;
  transition:
    border-color var(--t-ui) var(--ease),
    color var(--t-ui) var(--ease);
}
.signout-btn:hover {
  border-color: var(--sig);
  color: var(--sig-text);
}

/* ── Header icon buttons (shortcuts, browse) ── */
.shortcuts-btn {
  flex-shrink: 0;
  background: transparent;
  border: 1px solid var(--control-edge);
  color: var(--muted);
  width: 34px;
  height: 34px;
  cursor: pointer;
  display: flex;
  align-items: center;
  justify-content: center;
  transition: border-color var(--t-ui) var(--ease), color var(--t-ui) var(--ease);
}
.shortcuts-btn:hover {
  border-color: var(--sig);
  color: var(--sig-text);
}

/* ── Keyboard-shortcuts overlay (dialog) ── */
.shortcuts-overlay {
  position: fixed;
  inset: 0;
  z-index: 300;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 20px;
}
.shortcuts-overlay[hidden] {
  display: none;
}
.shortcuts-backdrop {
  position: absolute;
  inset: 0;
  background: var(--backdrop);
  backdrop-filter: blur(2px);
  animation: euser-fade var(--t-state) var(--ease);
}
.shortcuts-card {
  position: relative;
  z-index: 1;
  width: 100%;
  max-width: 440px;
  max-height: calc(100dvh - 40px);
  overflow-y: auto;
  padding: 0;
  background: var(--surface);
  border: 1px solid var(--line-hi);
  box-shadow: var(--shadow-card);
  animation: euser-rise var(--t-state) var(--ease);
}
.shortcuts-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  padding: 13px 16px;
  border-bottom: 1px solid var(--line);
  background: var(--surface2);
}
.shortcuts-title {
  font-family: var(--mono);
  font-size: 10.5px;
  font-weight: 600;
  letter-spacing: 0.2em;
  text-transform: uppercase;
  color: var(--muted);
  margin: 0;
}
.shortcuts-close {
  flex-shrink: 0;
  background: none;
  border: 1px solid transparent;
  color: var(--muted);
  cursor: pointer;
  padding: 4px;
  display: flex;
  align-items: center;
  justify-content: center;
  transition: color var(--t-ui) var(--ease), border-color var(--t-ui) var(--ease);
}
.shortcuts-close:hover {
  color: var(--sig-text);
  border-color: var(--sig);
}
.shortcuts-list {
  margin: 0;
  padding: 4px 16px 16px;
  display: flex;
  flex-direction: column;
}
.shortcut-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 16px;
  padding: 11px 0;
  border-bottom: 1px solid var(--line);
}
.shortcut-row:last-child {
  border-bottom: none;
}
.shortcut-row dt {
  color: var(--text);
  font-size: 13.5px;
}
.shortcut-row dd {
  margin: 0;
  display: inline-flex;
  align-items: center;
  gap: 5px;
  flex-wrap: wrap;
  justify-content: flex-end;
  color: var(--muted);
  font-size: 12.5px;
  text-align: right;
}
.shortcut-row kbd {
  display: inline-block;
  min-width: 22px;
  padding: 3px 7px;
  font-family: var(--mono);
  font-size: 11px;
  line-height: 1.3;
  text-align: center;
  color: var(--text);
  background: var(--surface2);
  border: 1px solid var(--line-hi);
}
@media (prefers-reduced-motion: reduce) {
  .shortcuts-backdrop,
  .shortcuts-card {
    animation: none;
  }
}

/* ── Header overflow menu (mobile) ──────────────────────────────────────────────
   #header-actions holds browse/shortcuts/theme plus every action js/core.js mounts at runtime
   (share/settings/tools/voice/sign-in — registerHeaderAction, all landing there before
   #signout-btn). This is its DESKTOP/base presentation: a plain inline row, identical to what all
   of these looked like before the wrapper existed. The @media(max-width:600px) block far below is
   what turns this SAME element into a labeled dialog — nothing here changes the desktop header the
   review scored well. */
#header-actions {
  display: flex;
  align-items: center;
  gap: 12px;
}
/* ── ONE HEADER BUTTON, NOT TWO ────────────────────────────────────────────────
   The header row mixes two families that were styled by two different files and never compared
   side by side. `.history-btn`, `.shortcuts-btn` and `.bb-theme-switch` are declared here: 34px,
   transparent, a --control-edge hairline, --sig on hover. `.header-action` — share, settings, tools,
   voice, account — is declared in js/settings.js, js/share-export.js, js/voice.js and js/auth.js,
   each injecting its own identical copy into <style id="gcb-richchat">: 36px, FILLED with
   --surface2, a --border hairline, --accent on hover.

   In the dark theme --surface2 sits close enough to the header that the fill passed for nothing. In
   the light theme it is a visible pale slab, so two of the five icons in the row read as switched
   on and the other three did not. Nothing was switched on.

   Three separate faults, fixed together:
     • the FILL. Backbone separates by luminance and hairline, not by filling a resting control.
     • the HAIRLINE. --border is rgba(6,36,48,.20), which is 1.4:1 on the light header — a control
       boundary that fails 1.4.11. --control-edge is the token this file derives for exactly that,
       and every other button in the row already uses it.
     • the HOVER HUE. --accent is the TENANT's colour and these are SHELL controls; per the banner
       at the top of this file they must be --sig, like their neighbours already are.

   Asserted here rather than fixed in the four modules, and scoped by #header-actions, because those
   <style> elements are appended at import time — after this file — so a plain `.header-action` rule
   would lose on source order. An id selector wins regardless of load order, which is the same
   reason `#header-actions > .header-action` below can rescue the 44px mobile row. Fixing it in one
   module would also leave three copies to drift, which is how the row came to look like this.

   Geometry is only equalised for the icon-only actions: `.gcb-auth-btn` is the one member with a
   text label and an auto width, and js/auth.js documents the centring maths behind it. ── */
/* THE LAST CONTROL ON THIS SURFACE THAT WAS NOT ON THE HOUSE CLOCK. `.composer-action` is declared
   in four modules at `.15s ease` — 10ms and a different curve from the 160ms --ease every other
   control here uses. Measured across every interactive component on the surface, it was the only
   one that came back as `0.15s ease` instead of `0.16s cubic-bezier(0.16, 1, 0.3, 1)`. Asserted
   here rather than in the modules for the reason the header row is asserted here: those <style>
   elements load after this file, and one declaration cannot drift the way four copies did.
   `.input-row` scopes it to the composer, which is the only place a composer action appears. */
/* The same four copies also declare `border-radius: 8px` and `background: var(--surface2)` — a
   rounded, filled control on a surface where `--radius` is 0px and nothing else is filled but the
   send button. Unnoticeable while a lone paperclip floated beside the field; now that the bar can
   hold an attach button and two menus side by side it is a row of four soft grey boxes under a
   hard-edged one. Squared and unfilled here, with the header row's own edge token, so the composer
   bar and the header read as the same set of controls. */
/* ROUND, ON A SQUARE SURFACE, AND ONLY HERE. `--radius` is 0px everywhere in Backbone and that
   does not change — but a row of small hard-edged squares tucked inside the composer's own square
   box read as boxes drawn inside a box rather than as controls, and the corners of four of them
   sat a few pixels from the corner of the field. A circle has no corner to disagree with. The rule
   is scoped to `.input-row` so it cannot leak: the header's icon row, which looks identical, is a
   row of squares in open space with nothing to collide with, and stays square. */
.input-row .composer-action {
  transition: border-color var(--t-ui) var(--ease), color var(--t-ui) var(--ease);
  border-radius: 999px;
  background: transparent;
  border-color: var(--control-edge);
  color: var(--muted);
}
.input-row .composer-action:hover,
.input-row .composer-action:focus-visible {
  border-color: var(--sig);
  color: var(--sig-text);
}
.input-row .composer-action:focus-visible {
  outline: 2px solid var(--sig);
  outline-offset: 2px;
}
/* The two capability menus carry a label beside the icon — "Tools" and "Skills" are choices to
   read, not glyphs to decode, and an unlabelled second gear next to the first is a guess. Auto
   width, and the 34px height the icon-only members already stand at. */
/* The two labelled menus take the same 999px, which on a wide box is a pill rather than a circle —
   one radius family, two widths, which is what keeps them reading as members of the same row as
   the icon-only ones rather than as a different kind of control. */
.input-row .composer-action.composer-menu {
  width: auto;
  gap: 7px;
  padding: 0 13px;
}
.composer-menu-label {
  font-family: var(--mono);
  font-size: 12px;
  letter-spacing: 0.04em;
  white-space: nowrap;
}
/* Below 420px the label is what goes, not the control: two labelled menus plus attach plus the
   mic overflow the bar on a small phone, and the icon + its tooltip/aria-label still name them.
   The caret goes with it. It is the label's companion — "this word opens something" — and left in
   a 44px circle beside the icon it had nothing to disclose and nowhere to sit: the two children
   overflowed the round button and the chevron hung outside its own border. An icon-only control in
   a row of icon-only controls does not need one. */
@media (max-width: 420px) {
  .composer-menu-label,
  .composer-menu .gcbtools-caret { display: none; }
  .input-row .composer-action.composer-menu { width: 44px; padding: 0; }
}

#header-actions .header-action {
  background: transparent;
  border-color: var(--control-edge);
  color: var(--muted);
  transition: border-color var(--t-ui) var(--ease), color var(--t-ui) var(--ease);
}
/* GEOMETRY IS DESKTOP-ONLY, and it has to be — the same scoping `header .bb-theme-switch` below
   already uses, for the same collision. At <=600px this row stops being a row of icons and becomes
   the full-width labelled overflow dialog, whose `#header-actions > .header-action` rule sets
   `width: 100%; min-height: 44px`. That rule is (1,1,0) and wins on source order, but only against
   an equally specific selector: an unscoped `:not(.gcb-auth-btn)` here is (1,2,0) and would beat it,
   pinning every menu row to a 34px square and taking four touch-target tests down with it. Found by
   tests/a11y/tap-targets.spec.mjs and touch-targets-coarse.spec.mjs, which is what they are for. */
@media (min-width: 601px) {
  #header-actions .header-action {
    height: 34px;
  }
  #header-actions .header-action:not(.gcb-auth-btn) {
    width: 34px;
  }
}
#header-actions .header-action:hover,
#header-actions .header-action:focus-visible {
  border-color: var(--sig);
  color: var(--sig-text);
}
#header-actions .header-action:focus-visible {
  outline: 2px solid var(--sig);
  outline-offset: 2px;
}
/* The theme switch is the fifth control in that row and the last one still drawing a different
   edge: backbone.css gives it `--line-hi`, the DECORATIVE hairline, where its four neighbours use
   `--control-edge`, the boundary this file derives to clear 1.4.11 on both surfaces (see the token's
   own note near the top). Corrected here rather than in backbone.css for the reason that note
   already gives: that file belongs to another track and is shared by every other surface. Scoped to
   the chat header so nothing else moves. */
header .bb-theme-switch {
  border-color: var(--control-edge);
}
.header-overflow-btn,
.header-overflow-backdrop,
.header-overflow-head {
  display: none;
}
/* Icon-only everywhere by default (matches every other header control) — the <=600px block is
   what reveals these once a row is full-width inside the open menu. */
.header-action-label {
  display: none;
}
/* Wrapper around the theme switch: contributes nothing to the box model beyond the switch itself
   outside the menu — identical to the un-wrapped <span data-theme-switch> this replaces. */
.header-action-row {
  display: contents;
}
.header-overflow-title {
  font-family: var(--mono);
  font-size: 10.5px;
  font-weight: 600;
  letter-spacing: 0.2em;
  text-transform: uppercase;
  color: var(--muted);
  margin: 0;
}
.header-overflow-close {
  flex-shrink: 0;
  background: none;
  border: 1px solid transparent;
  color: var(--muted);
  cursor: pointer;
  padding: 4px;
  display: flex;
  align-items: center;
  justify-content: center;
  transition: color var(--t-ui) var(--ease), border-color var(--t-ui) var(--ease);
}
.header-overflow-close:hover {
  color: var(--sig-text);
  border-color: var(--sig);
}
@media (prefers-reduced-motion: reduce) {
  .header-overflow-backdrop,
  #header-actions.is-open {
    animation: none;
  }
}

/* ── Chat-history drawer (Feature 1) ── */
.history-btn {
  flex-shrink: 0;
  background: transparent;
  border: 1px solid var(--control-edge);
  color: var(--muted);
  width: 36px;
  height: 36px;
  cursor: pointer;
  display: flex;
  align-items: center;
  justify-content: center;
  transition:
    border-color var(--t-ui) var(--ease),
    color var(--t-ui) var(--ease);
}
.history-btn:hover {
  border-color: var(--sig);
  color: var(--sig-text);
}
/* Open: the rail is showing. A pressed control has to read as pressed. */
.history-btn[aria-expanded="true"] {
  border-color: var(--sig);
  color: var(--sig-ink);
  background: var(--sig);
}
.history-btn:focus-visible {
  outline: 2px solid var(--sig);
  outline-offset: 2px;
}

/* VISIBILITY, NOT DISPLAY. This was `display: none` → `display: block` alongside the opacity, which
   means the fade IN never ran at all: an element that was not rendered on the previous frame has no
   `opacity: 0` to start from, so the backdrop appeared at full strength and only faded on the way
   out. One direction animated and the other cut — the exact pattern this pass exists to remove.
   `visibility` is animatable in the sense that matters (it flips at a keyframe boundary, not
   gradually), so it is delayed by the exit duration on the way out and switched immediately on the
   way in: the backdrop is never a transparent sheet swallowing clicks over the chat. The docked
   tiers below still take it out with `display: none !important`, which outranks both. */
#history-backdrop {
  position: fixed;
  inset: 0;
  background: var(--backdrop);
  z-index: 99;
  opacity: 0;
  visibility: hidden;
  transition:
    opacity var(--t-exit) var(--ease-exit),
    visibility 0s linear var(--t-exit);
}
#history-backdrop.open {
  opacity: 1;
  visibility: visible;
  transition:
    opacity var(--t-state) var(--ease),
    visibility 0s;
}

/* The overlay drawer is the largest thing that moves on this surface, so it takes --t-layout in and
   leaves on the house exit pair. Each direction is declared in the state it moves TO. */
#history-drawer {
  position: fixed;
  top: 0;
  left: 0;
  width: min(360px, 100vw);
  height: 100dvh;
  background: var(--surface);
  border-right: 1px solid var(--border);
  display: flex;
  flex-direction: column;
  z-index: 100;
  transform: translateX(-100%);
  transition: transform var(--t-exit) var(--ease-exit);
  overflow: hidden;
}
#history-drawer.open {
  transform: translateX(0);
  transition: transform var(--t-layout) var(--ease);
}

.drawer-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 13px 16px;
  border-bottom: 1px solid var(--line);
  background: var(--surface2);
  flex-shrink: 0;
}
.drawer-title {
  font-family: var(--mono);
  font-size: 10.5px;
  font-weight: 600;
  letter-spacing: 0.2em;
  text-transform: uppercase;
  color: var(--muted);
  margin: 0;
}
.drawer-close {
  background: none;
  border: 1px solid transparent;
  color: var(--muted);
  cursor: pointer;
  padding: 4px;
  display: flex;
  align-items: center;
  justify-content: center;
  transition: color var(--t-ui) var(--ease), border-color var(--t-ui) var(--ease);
}
.drawer-close:hover {
  color: var(--sig-text);
  border-color: var(--sig);
}
.drawer-close:focus-visible {
  outline: 2px solid var(--sig);
  outline-offset: 2px;
}
.drawer-body {
  flex: 1;
  overflow-y: auto;
  padding: 14px;
  display: flex;
  flex-direction: column;
  gap: 14px;
}

/* Starting a new conversation with THIS bot is a tenant action, so it carries the
   tenant accent — but as an outline, not a fill, so it never out-shouts the send
   button (the one filled accent surface in the chat). */
.history-new {
  width: 100%;
  min-height: 38px;
  background: transparent;
  /* --control-edge, not the accent: a customer accent is unbounded and cannot be
     guaranteed 3:1 as the only boundary of a button. The tenant colour moves to the
     leading bar below, the same gesture the user bubble and the active row use. */
  border: 1px solid var(--control-edge);
  border-left: 2px solid var(--accent);
  color: var(--text);
  padding: 10px 12px;
  font-family: var(--mono);
  font-size: 10.5px;
  font-weight: 600;
  letter-spacing: 0.18em;
  text-transform: uppercase;
  cursor: pointer;
  transition:
    border-color var(--t-ui) var(--ease),
    background-color var(--t-ui) var(--ease);
}
.history-new:hover {
  border-color: var(--accent);
  background: var(--accent-soft);
}
.history-new:focus-visible {
  outline: 2px solid var(--sig);
  outline-offset: 2px;
}

/* Conversation search (Feature S2.4): filters the rail list by title, client-side. */
.history-search {
  width: 100%;
  box-sizing: border-box;
  margin-bottom: 8px;
  background: var(--bg);
  border: 1px solid var(--control-edge);
  padding: 8px 11px;
  color: var(--text);
  font-size: 13px;
  font-family: var(--font);
  transition: border-color var(--t-ui) var(--ease);
}
.history-search::placeholder {
  color: var(--muted);
}
.history-search:focus-visible {
  border-color: var(--sig);
  outline: 2px solid var(--sig);
  outline-offset: 1px;
}
.history-search[hidden] {
  display: none;
}

.history-list {
  display: flex;
  flex-direction: column;
  gap: 3px;
}
.history-empty {
  color: var(--muted);
  font-size: 13px;
  text-align: center;
  padding: 18px 8px;
  text-wrap: pretty;
}

.history-row {
  display: flex;
  align-items: center;
  gap: 4px;
  border: 1px solid transparent;
  border-left: 2px solid transparent;
  transition:
    background-color var(--t-ui) var(--ease),
    border-color var(--t-ui) var(--ease);
}
.history-row:hover {
  background: var(--surface2);
}
/* Selected. An active state is a tenant state (you are inside this bot), so the
   marker bar is the tenant accent. */
.history-row.active {
  background: var(--accent-soft);
  border-left-color: var(--accent);
}

.history-open {
  flex: 1;
  min-width: 0;
  display: flex;
  flex-direction: column;
  gap: 2px;
  background: none;
  border: none;
  text-align: left;
  color: var(--text);
  padding: 8px 10px;
  cursor: pointer;
  font-family: var(--font);
}
.history-open:focus-visible {
  outline: 2px solid var(--sig);
  outline-offset: -2px;
}
.history-title {
  font-size: 13.5px;
  font-weight: 500;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.history-time {
  font-family: var(--mono);
  font-size: 10px;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--faint);
}

.history-actions {
  display: flex;
  gap: 2px;
  padding-right: 4px;
  opacity: 0;
  transition: opacity var(--t-ui) var(--ease);
}
.history-row:hover .history-actions,
.history-row.active .history-actions,
.history-row:focus-within .history-actions {
  opacity: 1;
}
.history-act {
  background: none;
  border: 1px solid transparent;
  color: var(--muted);
  cursor: pointer;
  width: 28px;
  height: 28px;
  display: flex;
  align-items: center;
  justify-content: center;
  transition:
    color var(--t-ui) var(--ease),
    border-color var(--t-ui) var(--ease);
}
.history-act:hover {
  color: var(--sig-text);
  border-color: var(--sig);
}
.history-act.del:hover {
  color: var(--err);
  border-color: var(--err);
}
/* A pinned chat keeps its filled star visible even when the row isn't hovered. */
.history-act.pin.on {
  color: var(--accent);
  opacity: 1;
}
.history-act.pin.on:hover {
  color: var(--accent);
  border-color: var(--accent);
}
.history-row.pinned .history-actions {
  opacity: 1;   /* keep the star visible so a pinned row reads as pinned at a glance */
}
.history-row.pinned:not(:hover):not(.active):not(:focus-within) .history-act:not(.pin) {
  opacity: 0;   /* ...but only the star — the other actions still reveal on hover */
}
.history-act:focus-visible {
  outline: 2px solid var(--sig);
  outline-offset: 1px;
}

.history-rename {
  flex: 1;
  min-width: 0;
  margin: 4px;
  background: var(--bg);
  border: 1px solid var(--sig);
  padding: 7px 9px;
  color: var(--text);
  font-size: 13.5px;
  font-family: var(--font);
  outline: none;
}

/* Both sources, and this pair needs the explicit `none` rather than the global clamp at the top of
   the file: that clamp shortens transition-DURATION and leaves transition-DELAY alone, so the
   backdrop's `visibility 0s linear var(--t-exit)` would still hold an invisible sheet over the chat
   for 170ms after a reduced-motion close. `none` drops the delay with it. */
@media (prefers-reduced-motion: reduce) {
  #history-drawer,
  #history-backdrop {
    transition: none !important;
  }
}
:root[data-a11y-motion="reduced"] #history-drawer,
:root[data-a11y-motion="reduced"] #history-backdrop {
  transition: none !important;
}

/* ── Small screens ── */
@media (max-width: 600px) {
  .header-inner {
    flex-wrap: nowrap;
    gap: 10px;
    /* Vertical padding dropped 10px -> 6px to make room for .history-btn/.header-overflow-btn's real
       44px box below without growing the header: 6 + 44 + 6 + 1px border = 57px, the same total the
       10 + 36 + 10 + 1px border box measured before. */
    padding: 6px 12px;
    align-items: center;
  }
  /* Row 1 now stays history + bot identity + the one overflow trigger (design review P1) — it no
     longer drops the title to its own line to make room for 5-7 icons ahead of the chat. Long
     names/subtitles truncate with an ellipsis instead of wrapping onto a second row. */
  .header-text {
    min-width: 0;
    flex: 1 1 auto;
  }
  h1 {
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
  }
  .subtitle {
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
  }
  #messages {
    padding: 14px;
  }
  .msg {
    max-width: 92%;
  }
  #chips {
    padding: 10px 14px 2px;
    /* Mirrors the padding on the line above, for the reason spelled out beside the <=640px
       `scroll-padding-inline`: below 640px this is a snap scroller, and snapping measures from the
       scrollport edge unless told otherwise. Two values, because the gutter is two values. */
    scroll-padding-inline: 14px;
  }
  /* The FOLLOW-UP chips — the ones rendered inline under an answer. They keep their desktop density
     (34px drawn box, unchanged) and reach the 44px minimum target with a real box, not a hit-area
     overhang, because they wrap edge-to-edge in a gapped row where an invisible overhang would
     start overlapping its neighbours. The starter cards are no longer part of this: they are
     two-line cards in the swipe row above, well past 44px on their own. */
  .chip {
    min-height: 44px;
  }
  /* --gcb-consent-h is published on <html> by js/consent.js for as long as the storage-choice
     sheet is on screen. It belongs HERE and only here: this is the width at which that sheet
     spans the viewport and would otherwise sit on the composer, and this shorthand is the last
     #chat-form declaration in the file, so a reservation written against the base rule would be
     overwritten at exactly the widths that need it. The fallback 0px is the normal case — the
     chat page does not mount the banner today, and this keeps it correct if it ever does. */
  #chat-form {
    padding: 10px 14px calc(12px + var(--gcb-consent-h, 0px));
  }
  #history-drawer {
    width: 100vw;
    border-right: none;
  }
  .history-actions {
    opacity: 1;   /* touch: no hover, always show rename/delete */
  }
  .history-act {
    min-width: 36px;
    min-height: 36px;
  }

  /* The two icons that stay in row 1 (history + the overflow trigger) grow to a REAL 44px box —
     not a transparent ::before overhang. An overhang inflates the tested hit area but leaves
     getBoundingClientRect() (what Chrome/Lighthouse actually measure) at the old 34/36px, which is
     the exact miss this replaces; it would also overlap the 10px .header-inner gap between the two
     buttons once both grew invisible hit zones. Desktop keeps the smaller drawn box (base rules
     above, 34px/36px) — this only fires at this breakpoint. */
  .history-btn,
  .header-overflow-btn {
    width: 44px;
    height: 44px;
  }

  /* The trigger: hidden on desktop (base rule above); this is its only visible presentation. */
  .header-overflow-btn {
    display: flex;
  }
  .header-overflow-btn[aria-expanded="true"] {
    border-color: var(--sig);
    color: var(--sig-ink);
    background: var(--sig);
  }
  .header-overflow-btn:focus-visible {
    outline: 2px solid var(--sig);
    outline-offset: 2px;
  }

  /* #header-actions: closed by default at this breakpoint, becomes a centred labeled dialog when
     js/core.js adds .is-open (Escape / backdrop / ✕ close it; focus returns to the trigger). */
  #header-actions {
    display: none;
  }
  #header-actions.is-open {
    display: flex;
    flex-direction: column;
    gap: 2px;
    position: fixed;
    top: 50%;
    left: 50%;
    transform: translate(-50%, -50%);
    z-index: 300;
    width: calc(100vw - 40px);
    max-width: 360px;
    max-height: calc(100dvh - 40px);
    overflow-y: auto;
    padding: 6px;
    background: var(--surface);
    border: 1px solid var(--line-hi);
    box-shadow: var(--shadow-card);
    /* -centered, not the plain rise: this dialog is centred by `transform`, which the plain
       keyframes overwrite for their whole duration. See the note on @keyframes euser-rise. */
    animation: euser-rise-centered var(--t-state) var(--ease);
  }
  .header-overflow-backdrop.is-open {
    display: block;
    position: fixed;
    inset: 0;
    z-index: 299;
    background: var(--backdrop);
    backdrop-filter: blur(2px);
    animation: euser-fade var(--t-state) var(--ease);
  }
  .header-overflow-head {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 12px;
    padding: 7px 8px 11px;
    margin: -6px -6px 4px;
    border-bottom: 1px solid var(--line);
  }

  /* Every row inside the open menu — the static browse/shortcuts/theme items and whatever
     js/core.js mounted (share/settings/tools/voice/sign-in) — becomes a full-width, >=44px,
     labeled row instead of a bare icon square. #header-actions is an id selector so this reliably
     outranks each module's own injected <style> (settings.js / share-export.js / auth.js),
     whichever one happens to load last. */
  #header-actions > .header-action-item,
  #header-actions > .header-action,
  #header-actions > .signout-btn {
    width: 100%;
    box-sizing: border-box;
    min-height: 44px;
    height: auto;
    padding: 10px 12px;
    display: flex;
    align-items: center;
    justify-content: flex-start;
    gap: 12px;
    background: transparent;
    border: 1px solid transparent;
    border-radius: 0;
  }
  #header-actions > .header-action-item,
  #header-actions > .header-action {
    color: var(--text);
    font-size: 13.5px;
  }
  #header-actions > .header-action-item:hover,
  #header-actions > .header-action:hover,
  #header-actions > .signout-btn:hover,
  #header-actions > .header-action-item:focus-visible,
  #header-actions > .header-action:focus-visible,
  #header-actions > .signout-btn:focus-visible {
    border-color: var(--sig);
    color: var(--sig-text);
    background: var(--surface2);
  }
  #header-actions > .header-action-item:focus-visible,
  #header-actions > .header-action:focus-visible,
  #header-actions > .signout-btn:focus-visible {
    outline: 2px solid var(--sig);
    outline-offset: -2px;
  }
  #header-actions > * + * {
    border-top: 1px solid var(--line);
  }
  .header-action-label {
    display: inline;
  }
  .header-action-row {
    display: flex;
    align-items: center;
    justify-content: space-between;
    width: 100%;
    box-sizing: border-box;
    min-height: 44px;
    padding: 10px 12px;
  }

  /* Dynamically-mounted actions (js/core.js registerHeaderAction) are icon-only — there's no
     markup here to hang a <span> label on, so the visible text is generated. Purely decorative:
     each module already sets its own aria-label/title and the accessible name is never computed
     from this, so it can't create a new label-in-name mismatch. */
  #header-actions [data-gcb-action]::after {
    font-size: 13.5px;
  }
  #header-actions [data-gcb-action="gcb-settings"]::after { content: "Settings"; }
  #header-actions [data-gcb-action="gcb-share-export"]::after { content: "Share or export"; }
  #header-actions [data-gcb-action="gcb-tools"]::after { content: "Tools & grounding"; }
  #header-actions [data-gcb-action="gcb-voice-conversation"]::after { content: "Voice conversation"; }
  #header-actions [data-gcb-action="gcb-auth"].gcb-auth-signed-in::after { content: "Account"; }
  /* auth.js hides its own "Sign in" text at this same breakpoint (icon-only, to match its
     un-expanded neighbours) — reveal it again now that the row is full-width. */
  #header-actions [data-gcb-action="gcb-auth"] .gcb-auth-label {
    display: inline;
  }

  /* ── Real (not overhang) 44px touch targets for every other sub-44 control on this breakpoint.
     Icon artwork inside each button is untouched — only the button's own box grows, exactly like
     .chip/.history-btn/.header-overflow-btn above. ── */
  #send-btn {
    min-width: 44px;
    min-height: 44px;
  }
  .scroll-bottom {
    width: 44px;
    height: 44px;
  }
  .shortcuts-close,
  .header-overflow-close,
  .drawer-close,
  .proj-modal-close {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    min-width: 44px;
    min-height: 44px;
    padding: 10px;
  }
  /* Block-level row in the trailing Sources list, not inline text — unlike .citation-chip it gets
     the real 44px box. */
  .source-toggle {
    width: 100%;
    min-height: 44px;
    padding: 4px 6px;
  }
  /* Same reasoning for the "How this was found" disclosure: it is a block-level control on its own
     line, not a mark inside running prose, so the WCAG 2.5.8 inline exception does not apply to it
     and it takes the real target. */
  .retrieval-summary {
    min-height: 44px;
    padding: 4px 6px;
  }
  /* --bb-ts-size drives .bb-theme-switch's own width/height (public/css/backbone.css), so this is a
     real box, not an overhang — and its icon (sized at 50% of --bb-ts-size) grows with it. */
  header .bb-theme-switch {
    --bb-ts-size: 44px;
  }
}

/* ── Responsive rail: the chat only moves when the rail genuinely PUSHES it to make space ──
   Three tiers (placed AFTER the base overlay rules so they win source order):
   • ≥1380px — enough room for the rail to sit in the empty left gutter BESIDE the viewport-centered
     chat. The rail is out of flow (fixed) and slides over that gutter, so the chat column never
     changes size and the chat NEVER moves — not even during the slide.
   • 900–1379px — not enough gutter, so the rail docks IN FLOW and pushes the chat right to make
     space (the one allowed movement). The chat re-centers smoothly in the remaining width.
   • <900px + mobile — the rail is a slide-over overlay + backdrop; the chat stays put underneath. */

/* Tier: PUSH (make space). Rail is in-flow, so opening it shifts the chat to make room. */
@media (min-width: 900px) and (max-width: 1379px) {
  #app {
    flex-direction: row;
    align-items: stretch;
  }
  #history-backdrop {
    display: none !important;   /* docked, not modal */
  }
  #history-drawer {
    position: relative;
    inset: auto;
    transform: none;
    width: var(--rail);
    height: 100dvh;
    flex-shrink: 0;
    background: var(--surface);
    transition:
      width var(--t-layout) var(--ease),
      border-color var(--t-layout) var(--ease);
  }
  /* STATED AGAIN ON `.open`, and it has to be. This tier moves WIDTH; the base rules move TRANSFORM,
     and they declare that on `#history-drawer.open` — (1,1,0), which outranks the (1,0,0) rule right
     above. Without this line the open direction inherits a transition list containing `transform`
     only and the rail SNAPS to full width, animating on the way out and not on the way in. Measured:
     0ms open / 176ms close, against 274ms / 176ms once it is restated here. */
  #history-drawer.open {
    transition:
      width var(--t-layout) var(--ease),
      border-color var(--t-layout) var(--ease);
  }
  /* Collapsing hands the space back to the chat, which is a departure: same asymmetry the overlay
     tier and the source disclosures use, so the rail does not linger after it has visibly gone. */
  #history-drawer:not(.open) {
    width: 0;
    border-right-color: transparent;   /* collapsed → chat reclaims the space */
    transition:
      width var(--t-exit) var(--ease-exit),
      border-color var(--t-exit) var(--ease-exit);
  }
  /* Pin the inner content to the rail width (minus the 1px right border) so rows/text never
     reflow or squish while the width animates open/closed — the panel CLIPS at its right edge
     (the drawer's base overflow:hidden) instead of re-wrapping. The overlay tier keeps its own
     stretch width, and the gutter tier animates transform (which never reflows), so the pin is
     scoped to this tier only. */
  #history-drawer .drawer-head,
  #history-drawer .drawer-body {
    width: calc(var(--rail) - 1px);
  }
}

/* Tier: GUTTER (never moves). Rail stays out of flow and docks over the empty left gutter beside a
   viewport-centered chat. #app stays a column so the rail can't push the chat; the chat keeps the
   base `100%` (= full viewport) centering and holds still whether the rail is open or shut. */
@media (min-width: 1380px) {
  #history-backdrop {
    display: none !important;   /* docked, not modal */
  }
  #history-drawer {
    width: var(--rail);
    box-shadow: var(--shadow-card);   /* reads as a panel floating over the gutter */
  }
}

/* ── Projects panel (Feature 2) ── */
.projects-section {
  border-top: 1px solid var(--line);
  padding-top: 12px;
  display: flex;
  flex-direction: column;
  gap: 6px;
}
.projects-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 0 2px 2px;
}
.projects-title {
  font-family: var(--mono);
  font-size: 9.5px;
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.26em;
  color: var(--faint);
  margin: 0;
}
.project-new {
  background: none;
  border: 1px solid var(--control-edge);
  color: var(--muted);
  font-family: var(--mono);
  font-size: 9.5px;
  font-weight: 600;
  letter-spacing: 0.16em;
  text-transform: uppercase;
  padding: 5px 9px;
  cursor: pointer;
  transition:
    border-color var(--t-ui) var(--ease),
    color var(--t-ui) var(--ease);
}
.project-new:hover {
  border-color: var(--sig);
  color: var(--sig-text);
}
.project-new:focus-visible {
  outline: 2px solid var(--sig);
  outline-offset: 2px;
}

/* Recently visited (#13) — compact cross-bot cards in the history drawer. */
.recent-bot {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 7px 8px;
  text-decoration: none;
  color: var(--text);
  border: 1px solid transparent;
}
.recent-bot:hover { background: var(--surface2); border-color: var(--line); }
/* Another bot's mark: same hexagon species as the header logo, so "a bot" reads the
   same wherever it appears. The per-bot accent is set inline on this element by
   app.js, and --accent-fg travels with it — never a hard-coded white. */
.recent-bot-mark {
  --mark-w: 22px;
  width: var(--mark-w);
  height: calc(var(--mark-w) * 1.1547);
  clip-path: var(--hex-clip);
  flex: none;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  font-family: var(--mono);
  font-size: 10px;
  font-weight: 700;
  color: var(--accent-fg, #fff);
  background: var(--accent);
  text-transform: uppercase;
  overflow: hidden;
}
.recent-bot-mark img { width: 100%; height: 100%; object-fit: cover; }
.recent-bot-name { font-size: 13.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.projects-list {
  display: flex;
  flex-direction: column;
  gap: 3px;
}
.projects-empty {
  color: var(--muted);
  font-size: 12.5px;
  text-align: center;
  padding: 10px 8px;
  text-wrap: pretty;
}

.project-row {
  border: 1px solid transparent;
}
.project-head {
  position: relative;   /* anchors the ⋯ popover menu */
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 7px 8px;
  transition: background-color var(--t-ui) var(--ease);
}
.project-head:hover {
  background: var(--surface2);
}
.project-head.drop-target {
  background: color-mix(in srgb, var(--sig) 12%, transparent);
  outline: 2px dashed var(--sig);
  outline-offset: -2px;
}
/* THE FOURTH DISCLOSURE ON THIS SURFACE, and now the same one. It used to carry a text glyph that
   app.js swapped — `▸` for `▾` — while the row it sat in was rebuilt from scratch on every toggle,
   so there was nothing continuous for a rotation to happen to: one arrow vanished and a different
   arrow appeared somewhere near it. It is the same mark as .sources-disclose / .source-toggle /
   .retrieval-summary now, rotating on the same duration and the same curve, and toggleProject()
   keeps the row alive so it can. */
.project-expander {
  background: none;
  border: none;
  color: var(--muted);
  cursor: pointer;
  width: 24px;    /* WCAG 2.2 ≥24×24 target size */
  height: 24px;
  margin: 0 -4px; /* keep the row's visual 16px footprint while the hit area grows */
  display: inline-flex;
  align-items: center;
  justify-content: center;
  flex-shrink: 0;
  padding: 0;
  transition: color var(--t-ui) var(--ease);
}
.project-expander::after {
  content: "";
  flex: none;
  width: 0;
  height: 0;
  border-style: solid;
  border-width: 3px 0 3px 4px;
  border-color: transparent transparent transparent currentColor;
  opacity: 0.72;
  transition: transform var(--t-ui) var(--ease), opacity var(--t-ui) var(--ease);
}
.project-expander:hover,
.project-head:hover .project-expander { color: var(--sig-text); }
.project-expander:hover::after,
.project-expander:focus-visible::after { opacity: 1; }
.project-expander[aria-expanded="true"]::after {
  transform: rotate(90deg);
  opacity: 1;
}
/* A user-chosen project colour. Square, like every other state mark here — a circle
   in this system means a face. */
.project-chip {
  width: 10px;
  height: 10px;
  flex-shrink: 0;
}
.project-name {
  flex: 1;
  min-width: 0;
  text-align: left;
  background: none;
  border: none;
  color: var(--text);
  font-family: var(--font);
  font-size: 13.5px;
  font-weight: 500;
  cursor: pointer;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  padding: 0;
}
.project-count {
  font-family: var(--mono);
  font-size: 10px;
  font-variant-numeric: tabular-nums;
  color: var(--muted);
  border: 1px solid var(--line);
  padding: 1px 6px;
  min-width: 22px;
  text-align: center;
  flex-shrink: 0;
}
.project-menu-btn {
  background: none;
  border: 1px solid transparent;
  color: var(--muted);
  cursor: pointer;
  width: 24px;
  height: 24px;
  flex-shrink: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  transition: color var(--t-ui) var(--ease), border-color var(--t-ui) var(--ease);
}
.project-menu-btn:hover {
  color: var(--sig-text);
  border-color: var(--sig);
}
.project-expander:focus-visible,
.project-name:focus-visible,
.project-menu-btn:focus-visible {
  outline: 2px solid var(--sig);
  outline-offset: 1px;
}

.project-convs {
  /* ONE ROW HEIGHT, SHARED BY THE PLACEHOLDER AND THE THING IT STANDS IN FOR. The skeleton used to
     draw two 22px bars for a list of 30px cards, so the panel opened at 52px and grew to 68px the
     moment the fetch landed — a placeholder that lied about the shape it was holding. Both read this
     token, so they cannot drift apart again; app.js draws as many bars as the row's own
     conversation count, which it already knows before the request is made. */
  --pconv-row: 30px;
  display: flex;
  flex-direction: column;
  gap: 2px;
  padding: 2px 2px 4px 22px;
}
.pconv-skel {
  display: block;
  height: var(--pconv-row);
  border-radius: 6px;
  background: var(--surface2);
}
.pconv-skel:nth-child(even) {
  opacity: 0.55;
}
.project-empty {
  color: var(--muted);
  font-size: 12px;
  padding: 6px 4px;
}

.pconv-card {
  display: flex;
  align-items: center;
  gap: 4px;
  min-height: var(--pconv-row);   /* the height the skeleton above promised */
  border: 1px solid transparent;
  background: var(--surface2);
  /* Every other row in this drawer eases its hover — .history-row, .history-act, .project-head.
     These snapped, which is the kind of difference nobody can name and everybody feels. */
  transition: border-color var(--t-ui) var(--ease);
}
.pconv-card:hover {
  border-color: var(--line);
}
.pconv-card.dragging {
  opacity: 0.5;
}
.pconv-card.drop-target {
  border-color: var(--sig);
  box-shadow: inset 0 2px 0 var(--sig);
}
.pconv-open {
  flex: 1;
  min-width: 0;
  text-align: left;
  background: none;
  border: none;
  color: var(--text);
  font-family: var(--font);
  font-size: 12.5px;
  cursor: pointer;
  padding: 6px 8px;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.pconv-remove {
  background: none;
  border: none;
  color: var(--text-muted);
  cursor: pointer;
  width: 24px;
  height: 24px;
  flex-shrink: 0;
  font-size: 13px;
  transition: color var(--t-ui) var(--ease);
}
.pconv-remove:hover {
  color: var(--err);
}
/* Keyboard reorder handle — the focusable non-pointer equivalent of drag-to-reorder. */
.pconv-move {
  background: none;
  border: none;
  color: var(--text-muted);
  cursor: grab;
  width: 24px;
  height: 24px;
  flex-shrink: 0;
  font-size: 12px;
}
.pconv-move:hover {
  color: var(--text);
}
.pconv-open:focus-visible,
.pconv-move:focus-visible,
.pconv-remove:focus-visible {
  outline: 2px solid var(--sig);
  outline-offset: -1px;
}

.project-create {
  display: flex;
}
.project-create-input,
.project-rename {
  flex: 1;
  min-width: 0;
  margin: 2px;
  background: var(--bg);
  border: 1px solid var(--sig);
  padding: 6px 8px;
  color: var(--text);
  font-size: 13px;
  font-family: var(--font);
  outline: none;
}

/* Drag source highlight for history cards (which anchor the "Add to project" menu). */
.history-row {
  position: relative;
}
.history-row.dragging {
  opacity: 0.5;
}

/* Popover menu — the project ⋯ menu and the "Add to project" submenu. */
/* THE OTHER DROPDOWN ON THIS SURFACE. The share/export menu settles down out of its button and
   leaves on the exit pair; this one — the project row's ⋯ popover — was mounted and removed with no
   transition at all, which is exactly the inconsistency a polish pass is for. Same 4px settle, same
   durations, same curves. app.js's mountMenu/closeAnyMenu add and remove `.is-open` around it. */
.pop-menu {
  position: absolute;
  top: 100%;
  right: 4px;
  z-index: 20;
  min-width: 168px;
  max-width: 240px;
  margin-top: 4px;
  background: var(--surface);
  border: 1px solid var(--line-hi);
  box-shadow: var(--shadow-card);
  padding: 5px;
  display: flex;
  flex-direction: column;
  gap: 2px;
  opacity: 0;
  transform: translateY(-4px);
  transition:
    opacity var(--t-exit) var(--ease-exit),
    transform var(--t-exit) var(--ease-exit);
}
.pop-menu.is-open {
  opacity: 1;
  transform: none;
  transition:
    opacity var(--t-state) var(--ease),
    transform var(--t-state) var(--ease);
}
.pop-title {
  font-family: var(--mono);
  font-size: 9.5px;
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.24em;
  color: var(--faint);
  padding: 6px 9px 4px;
}
.pop-empty {
  font-size: 12px;
  color: var(--muted);
  padding: 6px 9px;
}
.pop-item {
  display: flex;
  align-items: center;
  gap: 9px;
  width: 100%;
  text-align: left;
  background: none;
  border: none;
  color: var(--text);
  font-family: var(--font);
  font-size: 13px;
  cursor: pointer;
  padding: 8px 9px;
  transition: background-color var(--t-ui) var(--ease), color var(--t-ui) var(--ease);
}
.pop-item:hover {
  background: color-mix(in srgb, var(--sig) 12%, transparent);
  color: var(--sig-text);
}
.pop-item:focus-visible {
  outline: 2px solid var(--sig);
  outline-offset: -2px;
}
.pop-item.danger:hover {
  color: var(--err);
}
.pop-item-label {
  min-width: 0;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.pop-colors {
  display: flex;
  gap: 5px;
  padding: 5px 8px;
  flex-wrap: wrap;
}
.pop-swatch {
  /* 24×24 hit area (WCAG 2.2 target size); background-clip keeps the visible square ~18px. */
  width: 24px;
  height: 24px;
  border: 3px solid transparent;
  background-clip: padding-box;
  cursor: pointer;
}
.pop-swatch:hover {
  border-color: var(--text);
}
.pop-swatch:focus-visible {
  outline: 2px solid var(--sig);
  outline-offset: 1px;
}
.addproj-menu {
  max-height: 260px;
  overflow-y: auto;
}

/* projects-depth — project instructions/reference editor modal */
.proj-modal-backdrop { position: fixed; inset: 0; background: var(--backdrop); z-index: 60; }
.proj-modal { position: fixed; z-index: 61; inset: 0; margin: auto;
  width: min(560px, calc((100vw - 32px) / var(--a11y-zoom, 1))); height: max-content;
  max-height: calc((100vh - 48px) / var(--a11y-zoom, 1)); overflow-y: auto;
  background: var(--surface); border: 1px solid var(--line-hi);
  box-shadow: var(--shadow-card); }
.proj-modal-head { display: flex; align-items: center; justify-content: space-between; gap: 12px;
  padding: 13px 16px; border-bottom: 1px solid var(--line); background: var(--surface2); }
.proj-modal-title { font-family: var(--mono); font-size: 10.5px; font-weight: 600;
  letter-spacing: 0.2em; text-transform: uppercase; color: var(--muted); margin: 0; }
.proj-modal-close { background: transparent; border: 1px solid transparent; color: var(--muted);
  font-size: 20px; line-height: 1; cursor: pointer; padding: 0 6px;
  transition: color var(--t-ui) var(--ease), border-color var(--t-ui) var(--ease); }
.proj-modal-close:hover { color: var(--sig-text); border-color: var(--sig); }
.proj-modal-body { padding: 16px; }
.proj-field { display: block; font-size: 13px; font-weight: 600; margin-bottom: 16px; }
.proj-hint { display: block; font-size: 12px; font-weight: 400; color: var(--muted); margin: 4px 0 7px; }
.proj-field textarea { width: 100%; background: var(--bg); color: var(--text);
  border: 1px solid var(--control-edge); padding: 10px 11px;
  font: 13.5px/1.5 var(--font); resize: vertical; }
.proj-field textarea:focus-visible { outline: 2px solid var(--sig); outline-offset: 1px; border-color: var(--sig); }
.proj-modal-note { font-size: 12.5px; color: var(--err); margin: 0 0 10px; }
.proj-modal-actions { display: flex; gap: 8px; }

/* Accessibility preferences (set on <html> by settings.js) — public a11y options.
   Text size uses `zoom`, not a root font-size: the UI sizes text in px (not rem), so a root
   font-size bump grew only rem-based layout metrics and left the actual text unchanged (the
   "UI got bigger but text stayed the same" bug). `zoom` scales text AND chrome uniformly.
   --a11y-zoom mirrors the factor so full-viewport overlays (modals) can divide their vh/vw caps
   by it and still fit AFTER the zoom scales them (else a 100dvh-tall modal renders 1.25× the
   viewport and clips its header/footer off-screen). */
:root { --a11y-zoom: 1; }
:root[data-a11y-size="large"]  { zoom: 1.125; --a11y-zoom: 1.125; }
:root[data-a11y-size="xlarge"] { zoom: 1.25;  --a11y-zoom: 1.25; }

/* ── High-contrast palette ──
   Declared ONCE here and expanded by BOTH consumers (the opt-in [data-a11y-contrast="high"] mode
   below and the OS `prefers-contrast: more` mapping at the end of this file). Every token the
   light `:root` defines that would stay LIGHT on a black background has to be listed — the base
   palette flipped light-first, so an unlisted `--bot-bubble: #ffffff` survives the override and
   renders `--text: #ffffff` invisibly on it. Add new light-only surface tokens here, not inline.

   RETUNED FOR BACKBONE. These values are now the SAME literals css/theme.css uses in
   its own high-contrast blocks (it reads each as `var(--hc-*, <literal>)`, so whatever
   is declared here wins on the surfaces that declare it — the chat). They used to be
   a violet-tinted bundle left over from the previous house palette, which meant the
   chat's high-contrast mode and every other surface's quietly disagreed. */
:root {
  --hc-text: #ffffff;    --hc-text-muted: #e4f4fa; --hc-border: #8fb9c8;
  --hc-border-hi: #b7dbe6;
  --hc-bg: #000000;      --hc-surface: #04121a;    --hc-surface2: #0b2531;
  --hc-bot-bubble: #04121a; --hc-user-bubble: #0e2c46;
  --hc-sig: #8ff6fa;
  --hc-hl-keyword: #b8e4ff; --hc-hl-string: #b5e88a;   --hc-hl-number: #ffc078;
  --hc-hl-comment: #c6d6de; --hc-hl-function: #8ff6fa; --hc-hl-tag: #ff9aa2;
  --hc-hl-attr: #f2d98c;    --hc-hl-punct: #d3e2e8;
}
:root[data-a11y-contrast="high"] {
  --text: var(--hc-text); --text-muted: var(--hc-text-muted); --border: var(--hc-border);
  --muted: var(--hc-text-muted); --faint: var(--hc-text-muted);
  --line: var(--hc-border); --line-hi: var(--hc-border-hi); --control-border: var(--hc-border-hi);
  --sig: var(--hc-sig); --sig-text: var(--hc-sig); --sig-ink: #000000;
  --bg: var(--hc-bg); --surface: var(--hc-surface); --surface2: var(--hc-surface2);
  --bot-bubble: var(--hc-bot-bubble); --user-bubble: var(--hc-user-bubble);
  --hl-keyword: var(--hc-hl-keyword); --hl-string: var(--hc-hl-string); --hl-number: var(--hc-hl-number);
  --hl-comment: var(--hc-hl-comment); --hl-function: var(--hc-hl-function); --hl-tag: var(--hc-hl-tag);
  --hl-attr: var(--hc-hl-attr); --hl-punct: var(--hc-hl-punct);
}
:root[data-a11y-contrast="high"] .bubble,
:root[data-a11y-contrast="high"] .header-inner,
:root[data-a11y-contrast="high"] .input-row { border-color: var(--border) !important; }
/* The HC focus ring is --sig (theme.css lifts it to #8ff6fa on black / #033a45 on
   white, both >= 12:1). It used to be a literal amber, which in this design now
   MEANS "refused" — a focus ring wearing the refusal colour is a false signal. */
:root[data-a11y-contrast="high"] :focus-visible { outline: 3px solid var(--sig) !important; outline-offset: 2px; }
/* High contrast keeps every state marker, and makes the three answer states carry a
   heavier bar so they survive the flattened palette. */
:root[data-a11y-contrast="high"] .msg.bot[data-support="refused"] .bubble,
:root[data-a11y-contrast="high"] .msg.bot[data-support="out_of_scope"] .bubble,
:root[data-a11y-contrast="high"] .bubble.notice { border-left-width: 4px; }
:root[data-a11y-contrast="high"] .bubble.error { border-left-width: 4px; }
:root[data-a11y-contrast="high"] .citation-pop,
:root[data-a11y-contrast="high"] .source-detail { border-left-width: 3px; }
:root[data-a11y-motion="reduced"] *,
:root[data-a11y-motion="reduced"] *::before,
:root[data-a11y-motion="reduced"] *::after {
  animation-duration: 0.001ms !important; animation-iteration-count: 1 !important;
  transition-duration: 0.001ms !important; scroll-behavior: auto !important;
}

/* OS-level contrast preferences (WCAG 1.4.x) — mirror the opt-in high-contrast Settings mode so
   users who set it at the OS get it without finding our Settings panel. Both expansions read the
   same `--hc-*` bundle defined near [data-a11y-contrast="high"] above, so the values can't drift;
   only this token LIST has to stay in sync with that block. */
@media (prefers-contrast: more) {
  :root:not([data-a11y-contrast]) {
    --text: var(--hc-text); --text-muted: var(--hc-text-muted); --border: var(--hc-border);
    --muted: var(--hc-text-muted); --faint: var(--hc-text-muted);
    --line: var(--hc-border); --line-hi: var(--hc-border-hi); --control-border: var(--hc-border-hi);
    --sig: var(--hc-sig); --sig-text: var(--hc-sig); --sig-ink: #000000;
    --bg: var(--hc-bg); --surface: var(--hc-surface); --surface2: var(--hc-surface2);
    --bot-bubble: var(--hc-bot-bubble); --user-bubble: var(--hc-user-bubble);
    --hl-keyword: var(--hc-hl-keyword); --hl-string: var(--hc-hl-string); --hl-number: var(--hc-hl-number);
    --hl-comment: var(--hc-hl-comment); --hl-function: var(--hc-hl-function); --hl-tag: var(--hc-hl-tag);
    --hl-attr: var(--hc-hl-attr); --hl-punct: var(--hc-hl-punct);
  }
  :root:not([data-a11y-contrast]) :focus-visible { outline: 3px solid var(--sig) !important; outline-offset: 2px; }
}
/* Windows High Contrast / forced-colors: let the UA palette through; just keep focus,
   borders and — critically — the three answer states visible. Colour is gone in this
   mode, so the states fall back entirely to FORM: the refusal keeps its bar and its
   tag, the ungrounded badge keeps its hollow square, the citation keeps its rule. */
@media (forced-colors: active) {
  :focus-visible { outline: 3px solid Highlight !important; outline-offset: 2px; }
  .bubble, .input-row, .header-inner, button, .btn { border: 1px solid ButtonText; }
  .msg.bot[data-support="refused"] .bubble,
  .msg.bot[data-support="out_of_scope"] .bubble,
  .msg.bot[data-support="error"] .bubble,
  .bubble.error,
  .bubble.notice { border-left: 4px solid ButtonText; }
  .badge-unverified { border: 1px solid ButtonText; }
  .sources-line, .stream-error-note { border-top: 1px solid ButtonText; }
  .sources-line::before, .stream-error-note::before { background: ButtonText; }
  .attachments-line::before { background: transparent; border: 1px solid ButtonText; }
  /* .source-toggle is a real <button>, so the generic `button, .btn` rule above already borders it;
     the popovers are plain <span>s and need it stated explicitly. .citation-chip opts OUT of that
     border: it is an inline footnote numeral, and a drawn box around it in the middle of a sentence
     is both ugly and wrong. The accent tint that marks it interactive is what forced-colors
     discards, so an underline takes over the job — the same substitution the mode makes for links. */
  .citation-chip { border: 0; }
  .citation-chip sup { text-decoration: underline; }
  .citation-chip[aria-expanded="true"] sup { text-decoration-thickness: 2px; }
  .citation-pop, .source-detail { border-left: 2px solid ButtonText; }
  /* The hexagon mark is a clip-path over a gradient, both of which forced-colors
     discards; give it an outline so the bot still has a mark. */
  .logo-mark, .logo-img, .recent-bot-mark { outline: 1px solid ButtonText; }
  /* Progress and thinking redrawn in the system palette rather than opted out of it. */
  .bubble.thinking::after {
    background-image: repeating-linear-gradient(90deg, ButtonText 0 7px, transparent 7px 10px) !important;
    background-size: 27px 7px !important;
    animation: none !important;
  }
  .gen-spinner-ring { background: transparent; border: 1px solid ButtonText; }
  .gen-spinner-ring::after { background: ButtonText; }
}

/* WCAG 2.2 §2.5.8 Target Size (Minimum): every pointer target ≥ 24×24 CSS px. Applied to real
   interactive controls only — inline text links are exempt per the SC.

   `.citation-chip` opts out of it — see that rule, which states its own `min-width`/`min-height` of
   0. It is exactly the exempt case: a footnote numeral inside a sentence, in the flow of the text,
   positioned by the words around it. The opt-out has to be STATED, because this blanket is an
   element selector: it lost the specificity fight on `min-width` and won it on `min-height` (which
   the citation rule never declared), and the result was a marker forced to 24px tall inside a
   25.5px line while the CSS a thousand lines up said 15px — invisible in the stylesheet, obvious
   on the page.

   And the opt-out belongs THERE, not here as `button:not(.citation-chip)`. Written that way this
   selector goes from (0,0,1) to (0,1,1), which stops it losing to every single-class rule that was
   quietly beating it — `.shortcuts-close`, `.drawer-close`, `.history-act` and the rest of the
   44px touch rules all collapsed back to 24px behind their own `width: 40px`. Ten touch-target
   tests caught it. A blanket's specificity is part of its contract; keep it at one element. */
button, .btn, [role="button"], input[type="checkbox"], input[type="radio"], select, summary {
  min-width: 24px; min-height: 24px;
}

/* The theme switch matches .shortcuts-btn, the 34px icon buttons it sits between in the header.
   Desktop only: unscoped, this sat AFTER the @media(max-width:600px) block above and beat its
   44px override on equal specificity (source order, not the media condition, breaks the tie) —
   silently reverting the mobile touch-target fix at every width, mobile included. Scoping it to
   >=601px is what the comment's own comparison already assumed (.shortcuts-btn is only 34px on
   desktop; it too grows to 44px under 600px). */
@media (min-width: 601px) {
  header .bb-theme-switch { --bb-ts-size: 34px; }
}

/* ── Coarse-pointer targets (WCAG 2.5.5) ───────────────────────────────────────────────────────
   A finger gets a 44×44 box on every control of the chat surface; a fine pointer keeps the tighter
   desktop density this was designed at. Keyed on `pointer: coarse` rather than a width because the
   @media(max-width:600px) block above misses the whole touch-tablet range — a touch-only device at
   900px was still being handed 28–38px targets.

   Real boxes only, never a transparent ::before overhang: an overhang inflates the tested hit area
   but leaves getBoundingClientRect() (what Chrome/Lighthouse actually measure) at the old size, and
   in these gapped rows it would start overlapping the neighbouring control.

   This block has to stay LAST in the file. `header .bb-theme-switch` below is the same specificity
   as the >=601px rule directly above it, so source order — not the media condition — is what breaks
   that tie; moving this up silently reverts the switch to 34px on touch tablets. ── */
@media (pointer: coarse) {
  /* The skip link is the first stop for a keyboard or switch-control user, on the surface they are
     most likely to be on. It rendered at 42px. */
  .skip-link {
    display: inline-flex;
    align-items: center;
    min-height: 44px;
  }
  /* The composer's text field. `.input-row` around it is already tall enough, but the field is the
     target — tapping the row's padding does not focus it — and it measured 32px. */
  #question {
    min-height: 44px;
  }
  /* Header icon buttons. */
  .history-btn,
  .shortcuts-btn,
  .header-overflow-btn {
    width: 44px;
    height: 44px;
  }
  /* --bb-ts-size drives .bb-theme-switch's own width/height (public/css/backbone.css), so this is a
     real box, not an overhang — and its icon (sized at 50% of --bb-ts-size) grows with it. */
  header .bb-theme-switch {
    --bb-ts-size: 44px;
  }

  /* Composer send + the scroll-to-latest button floating above it. (There is no mic on this
     surface — voice is a runtime header action mounted by js/core.js, covered by the header rules
     above and by the full-width menu rows at <=600px.) */
  #send-btn {
    min-width: 44px;
    min-height: 44px;
  }
  .scroll-bottom {
    width: 44px;
    height: 44px;
  }

  /* Suggested + follow-up questions, and the controls that hang off a bot answer. Only the box
     grows; the type and padding above are untouched. */
  .chip {
    min-height: 44px;
  }
  /* THE STARTER CARDS NEED THIS RESTATED, and the history is the argument for keeping it forever.
     `#chips .chip` is an id plus a class and beats the `.chip` rule above on either side of a media
     query, so whatever the card sets IS the touch target. When the cards were 40px pills that fact
     silently displaced this block and handed a finger a 40px target
     (touch-targets-coarse.spec.mjs, at 320px). It was then briefly safe to omit — the cards were
     64px, comfortably over — and omitting it is exactly what made the next size cut dangerous: the
     desktop card is 32px now, and without this line a finger would get 32.
     Desktop density is not a touch target. A fine pointer keeps the 32px card; a finger gets 44. */
  #chips .chip {
    min-height: 44px;
  }
  .regen-btn {
    min-height: 44px;
  }
  /* The reveal is the load-bearing half of this rule. `.edit-btn` is `opacity: 0` at base and is
     revealed by `.msg.user:hover` — a coarse pointer has no hover, so the control was already an
     INVISIBLE tap target under every user turn. Growing it to 44px without revealing it would have
     tripled the area that silently drops the message into inline edit mode, and WCAG 2.5.5 asks
     nothing of a target that cannot be perceived. Show it, then size it. */
  .edit-btn {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    min-width: 44px;
    min-height: 44px;
    opacity: 0.75;
  }
  /* Block-level row in the trailing Sources list, not inline text — unlike .citation-chip, which is
     a target inside a sentence and exempt under §2.5.8. */
  .source-toggle {
    min-height: 44px;
    padding: 4px 6px;
  }

  /* Drawer + dialog controls: close, new chat, per-row rename/delete, new project. */
  .drawer-close,
  .shortcuts-close,
  .header-overflow-close,
  .proj-modal-close {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    min-width: 44px;
    min-height: 44px;
    padding: 10px;
  }
  .history-new {
    min-height: 44px;
  }
  /* Same pairing the <=600px block already makes, for the same reason: `.history-actions` is
     `opacity: 0` until `.history-row:hover`, and there is no hover here. Without the reveal every
     history row carries three invisible 44px targets, one of which deletes the conversation. */
  .history-actions {
    opacity: 1;
  }
  .history-act {
    width: 44px;
    height: 44px;
  }
  .project-new {
    min-height: 44px;
    padding: 5px 12px;
  }

  /* Citation footnotes. They keep the inline exception — a 44px marker inside a sentence would
     wreck the line rhythm it exists to stay out of — but a thumb gets more of them to aim at.
     Measured at 9.3 x 25.5 with a fine pointer; the extra side padding takes that to ~19 x 25.5
     without adding a pixel of line height, because the height already comes from the line box and
     the padding is horizontal only. Nothing around the marker moves. */
  .citation-chip {
    padding: 0 0.34em;
  }

  /* Footer nav. These trail a sentence, so §2.5.8's inline exception would let them off — but on a
     phone they are the only way out of the chat, and a 18px-tall word between two "·" separators is
     a mis-tap waiting to happen. inline-block + min-height gives each one a real box; the note keeps
     flowing around them rather than being restructured into a row. */
  .footer-note a {
    display: inline-block;
    box-sizing: border-box;
    min-height: 44px;
    padding: 12px 10px;
  }
}

/* ── Per-sentence claim verdicts (B3) ────────────────────────────────────────────────────────────
   The answer-level badge says how much of an answer held up; these mark WHICH sentence did not, so
   a "partly verified" warning is something a reader can act on rather than a puzzle.

   Told in FORM and COPY, never colour alone — same rule as the verification badge and the source
   freshness marks. An unsupported claim gets a wavy underline (the convention every spell-checker
   has already taught) and a hover/AT label saying so; a derived one gets a dotted underline, the
   quieter mark for "worked out, not quoted". Both survive greyscale, forced-colors and a printout,
   because the underline style carries the meaning and the tint only reinforces it. */
.claim-flag {
  text-underline-offset: 3px;
  text-decoration-thickness: from-font;
}
.claim-unsupported {
  text-decoration: underline wavy;
  text-decoration-color: color-mix(in srgb, var(--err) 70%, transparent);
  background: color-mix(in srgb, var(--err) 6%, transparent);
}
.claim-derived {
  text-decoration: underline dotted;
  text-decoration-color: var(--muted);
}
@media (prefers-contrast: more) {
  .claim-unsupported { text-decoration-color: var(--err); }
  .claim-derived { text-decoration-color: currentColor; }
}

/* ── Pilot participation notice (Step 18) ────────────────────────────────────────────────────────
   Shown only when this browser arrived through a founder-issued participant link. It is a DISCLOSURE
   strip, not a promotion, so it is styled as the quietest thing on the page that is still
   unmissable: a hairline rule, muted text, one accent dot. Deliberately NOT the `.bubble.notice`
   warning treatment — nothing has gone wrong, and dressing a consent notice as an error teaches
   people to dismiss it.

   `[hidden]` is given an explicit !important because this rule sets `display:flex` and the element
   is toggled with the `hidden` attribute — the trap admin.css and b.css both hit (see grounded-rules
   "CSS: [hidden] loses to an explicit display"). */
.pilot-notice {
  display: flex;
  align-items: center;
  gap: 10px;
  flex-wrap: wrap;
  padding: 8px 14px;
  border-bottom: 1px solid var(--border, rgba(127, 127, 127, 0.22));
  background: color-mix(in srgb, var(--accent) 5%, transparent);
  font-size: 13px;
  line-height: 1.45;
}
.pilot-notice[hidden] { display: none !important; }
.pilot-notice__dot {
  width: 7px;
  height: 7px;
  flex: 0 0 auto;
  border-radius: 50%;
  background: var(--accent);
}
.pilot-notice__text {
  margin: 0;
  flex: 1 1 220px;
  color: var(--muted);
}
/* A real 44px target, because on a phone this is the participant's only way out of the test and a
   13px word between two paragraphs is a mis-tap. Same reasoning as the footer-nav rule above. */
.pilot-notice__leave {
  flex: 0 0 auto;
  min-height: 44px;
  padding: 8px 12px;
  border: 1px solid var(--border, rgba(127, 127, 127, 0.3));
  border-radius: 8px;
  background: transparent;
  color: inherit;
  font: inherit;
  font-size: 12.5px;
  cursor: pointer;
}
.pilot-notice__leave:hover { border-color: var(--accent); color: var(--accent); }
