    :root {
      /* Alma design system (2026-08-02): Apple-HIG dark graphite + "Almaty teal".
         NOTE --blue keeps its NAME (200+ usage sites, incl. JS templates) but its
         VALUE is now the teal accent — one accent everywhere, hairline borders,
         graphite surfaces. Text on accent fills must use --accent-ink, not #fff:
         the teal is light, white fails contrast on it. */
      --bg: #0B0C0F;
      --surface: #15161A;
      --surface-2: #1B1D22;      /* elevated: menus, dialogs */
      --border: #26272C;
      --blue: #2BD9C7;
      --accent: #2BD9C7;
      --accent-ink: #04342C;     /* text/icons on accent fills */
      --user-bubble: #123A36;    /* user message: deep teal tint, not solid accent */
      --user-border: #1E5A52;
      --user-text: #C8F5EE;
      --relay: #A78BFA;    /* the listening chat: relayed, spoken, never a task — LAVENDER */
      /* The main chat: the one the work in every other chat is run from. MAGENTA, on
         purpose a different purple from --relay above (issue #172) — a person has to be
         able to tell "this chat is listening" from "this chat runs the work" at a glance,
         not by checking which one it was. Was gold (#E3B341) until the busy dot (#E8B33F,
         below) turned invisible sitting on an active main-chat pill of the same colour;
         picking ANOTHER colour without a plan just moves the next collision one hue over,
         which is what the .chat-status ring further down is for. */
      --master: #D169E0;
      --master-ink: #1E0722;  /* text/icons on a --master fill — dark violet, not the old
                                  brown (#2A1D00), which was chosen for gold and reads wrong
                                  on purple. Contrast-checked: 6.1:1 against --master. */
      --text: #F4F5F7;
      --muted: #8A8F98;
    }
    * { box-sizing: border-box; margin: 0; padding: 0; }
    /* Nothing in this app scrolls the DOCUMENT — the message list scrolls itself — so the
       only thing a scrollable document ever did was let iOS scroll the whole app up to
       reveal the focused composer and then leave it there, with a band of bare html
       showing underneath. Unscrollable, iOS uses the visual-viewport offset instead,
       which --app-offset below follows. */
    html { overflow: hidden; }
    body {
      background: var(--bg);
      color: var(--text);
      font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Text', sans-serif;
      height: 100vh;
      height: 100dvh;
      /* --app-vh (set by the inline script in <head>, from the real visualViewport
         height) wins once JS has run. dvh is supposed to track the toolbar/keyboard
         live, but WebKit has repeatedly shipped it settling on a too-small value after
         a keyboard open/close cycle and never re-measuring — body then falls short of
         the real screen, and the strip below it renders in theme-color (#0B0C0F, the
         same near-black as --bg), reading as dead space glued to the composer rather
         than as "not part of the page". This is the fix, not a hardcoded margin: the
         actual container height, kept honest. */
      height: var(--app-vh, 100dvh);
      /* ...and --app-offset puts it where you can SEE it. Height alone is not enough: a
         viewport pushed up to reveal the keyboard leaves body's bottom edge above the
         visible bottom, which is the dead band. `relative` deliberately, not `transform`
         — a transformed body would become the containing block for every position:fixed
         overlay in the app (toasts, the tap zone) and quietly re-anchor all of them.
         `relative` also makes body the containing block for the floating scroll-to-bottom
         button, which is position:absolute so it hangs off the APP's bottom edge rather
         than the layout viewport's — see .scroll-bottom-btn. */
      position: relative;
      top: var(--app-offset, 0px);
      display: flex;
      flex-direction: column;
      overflow: hidden;
    }
    /* ONE row, always (corrected 2026-09-05/06: the dispatched-work line, .jobs-strip
       below, briefly sat on a row of its own above this one, which pushed the name
       pill and the three-dot button down, changed the header's own height whenever it
       appeared, and floated the strip centred over the header's full width instead of
       between the pill and the dots — exactly the bug this rule now can't reproduce).
       .hdr-row carries the row layout the whole header used to have directly; kept as
       its own class rather than folded back into .header only because it's the single
       flex row the pill, the strip and the three-dot button all sit in together. */
    .header {
      /* --app-safe-t is the measured status-bar inset MINUS whatever of it body's own
         top offset already clears (see the head script) — env() is the pre-script
         fallback only, same pattern as the composer's --app-safe-b below. */
      padding: var(--app-safe-t, env(safe-area-inset-top, 44px)) 16px 12px;
      display: flex;
      border-bottom: 0.5px solid var(--border);
      flex-shrink: 0;
      position: relative;
      z-index: 40;
    }
    .hdr-row { display: flex; align-items: center; gap: 10px; width: 100%; }
    /* Phone: float the header over the conversation instead of reserving its own row
       above it, and give it NO background of its own at all — the conversation stays
       fully visible straight through it, nothing dimmed or blurred (a frosted-glass
       version was tried and rejected: the user wants the content genuinely visible,
       not overlaid). The only things that need to stay legible over arbitrary content
       are the project/chat name (see .hdr-picker-chat/.hdr-picker-project — each gets
       its own tight pill background, not the header) and the icon buttons (see
       .hdr-more-btn — legible by their own weight/shadow, never a background plate).
       Desktop is left exactly as it was: its header sits in the column beside the
       fixed sidebar, in-flow above the message list rather than floating over it, so
       none of this applies there. */
    body:not(.desktop) .header {
      position: fixed; top: 0; left: 0; right: 0;
      background: none;
      border-bottom: none;
    }
    .project-dropdown { position: relative; flex: 1; min-width: 0; display: flex; align-items: center; gap: 8px; }
    /* The active chat's name (issue: header chat name) — claims whatever room the
       project pill beside it doesn't need. flex-basis 0 so it never grabs space by its
       own content length; a min-width floor so it's never squeezed to nothing before the
       pill gives way first. */
    .hdr-chat-name {
      flex: 1 1 0%; min-width: 28px;
      overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
      background: none; border: none; padding: 0; text-align: left;
      font-family: inherit; font-size: 15px; font-weight: 600; color: var(--text);
      cursor: pointer; -webkit-tap-highlight-color: transparent;
    }
    /* Sized to its own content and shrinks first when the row is tight — the project
       name matters less turn to turn than knowing which chat you're in — but never
       below this floor, so it always keeps a sliver of itself on screen. */
    .project-dd-wrap { position: relative; min-width: 56px; flex: 0 1 auto; }
    .project-dd-btn {
      display: flex; align-items: center; gap: 6px;
      max-width: 100%;
      padding: 7px 14px;
      border-radius: 20px;
      background: var(--surface);
      border: none;
      color: #fff;
      font-size: 14px; font-weight: 600;
      cursor: pointer;
      -webkit-tap-highlight-color: transparent;
    }
    .project-dd-btn #projectDdLabel { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
    .project-dd-chev { display: flex; color: var(--muted); flex-shrink: 0; transition: transform 0.2s; }
    .project-dd-chev svg { width: 16px; height: 16px; display: block; }
    .project-dropdown.open .project-dd-chev { transform: rotate(180deg); }
    .project-dd-menu {
      display: none; position: absolute; top: calc(100% + 6px); left: 0; z-index: 600;
      min-width: 180px; max-width: 70vw; max-height: 60vh; overflow-y: auto;
      background: var(--surface-2); border: 0.5px solid var(--border); border-radius: 14px;
      padding: 6px; box-shadow: 0 8px 24px rgba(0,0,0,0.5);
    }
    .project-dropdown.open .project-dd-menu { display: block; }
    .project-dd-item {
      display: block; width: 100%; text-align: left;
      padding: 10px 14px; border-radius: 10px;
      background: none; border: none; color: var(--text);
      font-size: 15px; font-weight: 500; cursor: pointer;
      -webkit-tap-highlight-color: transparent; white-space: nowrap;
    }
    .project-dd-item:active { background: var(--border); }
    .project-dd-item.active { background: var(--blue); color: var(--accent-ink); font-weight: 600; }
    /* Each project row: the switch target, plus its own pencil to rename it. */
    .project-dd-row { display: flex; align-items: center; gap: 2px; }
    .project-dd-rename {
      flex-shrink: 0; display: flex; align-items: center; justify-content: center;
      width: 34px; height: 34px; padding: 0; border: none; border-radius: 10px;
      background: none; color: var(--muted); cursor: pointer;
      -webkit-tap-highlight-color: transparent;
    }
    .project-dd-rename svg { width: 15px; height: 15px; }
    .project-dd-rename:active { background: var(--border); }
    /* ── Unified project+chat picker (phone only) ─────────────────────────────────
       Replaces the old #hdrChatToggle + #hdrChatName + project dropdown trio on the
       phone layout with ONE button opening ONE two-column menu (projects | chats).
       Desktop keeps the trio above exactly as it was — its sidebar is the project+
       chat switcher there, and this whole block stays hidden under body.desktop. */
    .hdr-picker-btn {
      flex: 1 1 0%; min-width: 0; min-height: 44px; display: flex; flex-direction: row;
      align-items: center; gap: 2px; text-align: left;
      background: none; border: none; padding: 2px 4px; margin: 0; box-sizing: border-box;
      cursor: pointer; -webkit-tap-highlight-color: transparent;
      position: relative;   /* anchor for the aggregate status dot */
    }
    /* The two-line text stack — chat name over project name. Still flex:1 1 0% — it
       fills the row exactly as before (a wide, easy tap target is fine; a hugely
       stretched invisible hit-area was never the bug). Changing this to hug content
       too, tried first, created a circular percentage-width reference (an ambiguous
       chain of content-hugging ancestors) that collapsed the text to near-zero width
       even for a short name — keep this one definite. align-items:flex-start is what
       actually keeps each row from stretching to the full column width: see
       .hdr-picker-chatrow below, which is how the chevron chip stays next to the name
       instead of the flex-grow here pushing it away. */
    .hdr-picker-text {
      display: flex; flex-direction: column; align-items: flex-start; justify-content: center;
      gap: 1px; min-width: 0; flex: 1 1 auto;
    }
    /* The chevron chip used to be a sibling of .hdr-picker-text at the .hdr-picker-btn
       level — harmless when the text stack hugged its own width, but once that stack
       is flex:1 1 0% (needed to dodge the collapse bug above) it eats all the leftover
       room in a wide button and strands the chevron at the button's far right edge,
       floating alone over whatever message text happens to scroll under it with a
       visible gap back to the name it belongs to (issue: header pill chevron floats
       away). Wrapping the chat name and the chevron in their own inline-flex row fixes
       it without touching the property that caused the collapse bug: this row sizes to
       its OWN content (the name plus the chevron), and .hdr-picker-text's
       align-items:flex-start above means the row is never stretched wider than that,
       whatever width the flex:1 1 0% parent grows to. No background of its own — each
       child still wears its own frosted pill (below), so nothing here changes how
       either one looks, only that they now sit touching. */
    .hdr-picker-chatrow { display: inline-flex; align-items: center; gap: 4px; min-width: 0; max-width: 100%; }
    /* The header itself carries no background (see body:not(.desktop) .header above),
       so these two names are the only header text that has to hold up against ANY
       content scrolling underneath — a tight pill sized to the text, not a bar across
       the header. Negative margin matching the padding keeps the pill's own box (and
       so the button's layout) exactly where it was with no padding at all — only the
       painted pill grows past the text, the flex layout around it doesn't move. A
       hairline edge (same tone every other menu/pill in the app draws its border in)
       makes the pill read as a real, pressable control rather than a plain label —
       and :active darkens it for a moment on tap, the same press feedback every other
       button here already gives (issue: header pill isn't discoverable as tappable). */
    /* rgba(21,22,26,·) was picked to echo --surface, but --surface IS the tone of the
       message content behind it (assistant bubbles, the page background) — an opaque
       pill in that same near-black just disappears into whatever scrolls under it, and
       the app has only this one dark theme, so there was never a lighter background to
       fall back on. A frosted, LIGHTER-than-everything chip fixes that regardless of
       what's underneath: bubbles, plain background or the teal-tinted user bubble are
       all darker than this, so the chip always reads as raised glass, not as more body
       text. The blur also keeps whatever text is scrolling behind it from reading
       through legibly. */
    .hdr-picker-chat {
      display: inline-block; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
      font-size: 15px; font-weight: 600; color: var(--text);
      background: rgba(255, 255, 255, 0.14); border: 0.5px solid rgba(255, 255, 255, 0.22);
      border-radius: 7px; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.5);
      backdrop-filter: blur(10px) saturate(160%); -webkit-backdrop-filter: blur(10px) saturate(160%);
      padding: 2px 7px; margin: -2px -7px;
      transition: background-color 0.15s;
    }
    .hdr-picker-project {
      display: inline-block; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
      font-size: 12px; font-weight: 500; color: var(--text); opacity: 0.82;
      background: rgba(255, 255, 255, 0.10); border: 0.5px solid rgba(255, 255, 255, 0.18);
      border-radius: 6px; box-shadow: 0 1px 2px rgba(0, 0, 0, 0.45);
      backdrop-filter: blur(10px) saturate(160%); -webkit-backdrop-filter: blur(10px) saturate(160%);
      padding: 1px 6px; margin: -1px -6px;
      transition: background-color 0.15s;
    }
    .hdr-picker-btn:active .hdr-picker-chat { background: rgba(255, 255, 255, 0.24); }
    .hdr-picker-btn:active .hdr-picker-project { background: rgba(255, 255, 255, 0.18); }
    /* Trailing disclosure chevron — the same universal "this opens something" mark the
       project dropdown and the model/effort pickers already wear, so the one header
       control without a visible affordance now matches the rest of the app instead of
       inventing its own. Rotates on open via the button's own aria-expanded, no extra
       JS state needed. It used to be a bare muted glyph with only a drop-shadow, which
       read as a stray mark rather than a control (issue: header pill/chevron barely
       visible) — it now wears the same small frosted chip as the name pills beside it,
       so the whole button reads as ONE tappable object, just brighter text + a plain
       glyph in a smaller chip. */
    .hdr-picker-chev {
      display: flex; align-items: center; justify-content: center; flex-shrink: 0;
      width: 22px; height: 22px; border-radius: 999px; color: var(--text);
      background: rgba(255, 255, 255, 0.14); border: 0.5px solid rgba(255, 255, 255, 0.22);
      box-shadow: 0 1px 2px rgba(0, 0, 0, 0.45);
      backdrop-filter: blur(10px) saturate(160%); -webkit-backdrop-filter: blur(10px) saturate(160%);
      transition: transform 0.2s, background-color 0.15s;
    }
    .hdr-picker-btn:active .hdr-picker-chev { background: rgba(255, 255, 255, 0.24); }
    .hdr-picker-chev svg { width: 14px; height: 14px; display: block; stroke-width: 2.75; }
    .hdr-picker-btn[aria-expanded="true"] .hdr-picker-chev { transform: rotate(180deg); }
    .hdr-picker-btn .chat-list-dots { position: absolute; top: -1px; right: -1px; }
    .picker-menu {
      display: none; position: fixed; z-index: 150;
      width: min(92vw, 420px); max-height: 65vh;
      background: var(--surface-2); border: 0.5px solid var(--border); border-radius: 14px;
      box-shadow: 0 12px 40px rgba(0,0,0,0.45);
      overflow: hidden;
    }
    .picker-menu.open { display: flex; }
    .picker-col {
      display: flex; flex-direction: column; min-width: 0;
      overflow-y: auto; -webkit-overflow-scrolling: touch;
      max-height: 65vh;
    }
    .picker-col-projects { flex: 0 0 38%; border-right: 0.5px solid var(--border); padding: 6px; }
    .picker-col-chats { flex: 1 1 62%; padding: 6px; }
    .picker-col-head {
      font-size: 11px; font-weight: 600; letter-spacing: 0.05em; text-transform: uppercase;
      color: var(--muted); padding: 6px 8px 8px; position: sticky; top: 0;
      background: var(--surface-2);
    }
    .picker-proj-row {
      display: flex; align-items: center; gap: 6px; width: 100%; text-align: left;
      padding: 8px 8px; border-radius: 10px; border: none; background: none;
      color: var(--text); font-size: 14px; font-weight: 500; cursor: pointer;
      white-space: nowrap; overflow: hidden;
      -webkit-tap-highlight-color: transparent;
    }
    .picker-proj-row .picker-row-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
    .picker-proj-row:active { background: var(--surface); }
    .picker-proj-row.selected { background: var(--blue); color: var(--accent-ink); font-weight: 600; }
    .picker-proj-row.master { color: var(--master); }
    .picker-proj-row.master.selected { color: var(--accent-ink); }
    .picker-chat-row {
      display: flex; align-items: center; gap: 8px; width: 100%; text-align: left;
      padding: 8px 8px; border-radius: 10px; border: none; background: none;
      color: var(--text); font-size: 14px; cursor: pointer;
      white-space: nowrap; -webkit-tap-highlight-color: transparent;
    }
    .picker-chat-row .picker-row-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
    .picker-chat-row:active { background: var(--surface); }
    .picker-chat-row.active { background: var(--blue); color: var(--accent-ink); font-weight: 600; }
    .picker-chat-row.master { color: var(--master); }
    .picker-chat-row.active.master { color: var(--accent-ink); }
    body.desktop .hdr-picker-btn, body.desktop .picker-menu { display: none; }
    /* Parallel-chat switcher: one pill per chat in the current project, + to add. */
    .chat-bar {
      display: flex; align-items: center; gap: 6px; flex-shrink: 0;
      padding: 6px 10px; border-bottom: 0.5px solid var(--border);
    }
    .chat-bar:empty { display: none; }
    /* Controls (list toggle + '+') stay pinned; only the pills scroll. */
    .chat-pills {
      display: flex; align-items: center; gap: 6px; flex: 1; min-width: 0;
      overflow-x: auto; -webkit-overflow-scrolling: touch; scrollbar-width: none;
    }
    .chat-pills::-webkit-scrollbar { display: none; }
    .chat-pill {
      flex-shrink: 0; display: inline-flex; align-items: center; gap: 6px;
      padding: 5px 12px; border-radius: 999px; cursor: pointer;
      background: var(--surface); border: 0.5px solid var(--border);
      color: var(--text); font-size: 14px; font-weight: 500;
      -webkit-tap-highlight-color: transparent; position: relative;
      /* A long-press must select the CHAT, not its text — without this the OS starts a
         text selection / callout and fires pointercancel, killing the hold gesture (#79). */
      user-select: none; -webkit-user-select: none; -webkit-touch-callout: none;
    }
    .chat-pill.active { background: var(--blue); border-color: var(--blue); color: var(--accent-ink); font-weight: 600; }
    /* The listening chat (replies relayed from a Claude Code session) wears its own
       violet so it never reads as an ordinary chat — nothing here was typed at the app,
       and nothing said here runs a task. */
    .chat-pill.relay { border-color: var(--relay); color: var(--relay); }
    .chat-pill.relay.active { background: var(--relay); border-color: var(--relay); color: #fff; }
    /* The main chat wears magenta and leads the strip. Colour AND crown, not colour
       alone: the pill is small, and a hue on its own is the one distinction that
       disappears at arm's length or on a washed-out screen. */
    .chat-pill.master { border-color: var(--master); color: var(--master); }
    .chat-pill.master.active { background: var(--master); border-color: var(--master); color: var(--master-ink); }
    /* One shared rule for every status dot, wherever it's drawn: the chat-bar pills, the
       chat dropdown's rows, the desktop sidebar's .sb-item, the project dropdown's
       cluster, the multi-dot cluster in both. An ancestor-scoped rule here is exactly how
       the sidebar and project-dropdown dots ended up with no size or colour at all —
       neither ".chat-pill .chat-status" nor ".chat-drow .chat-status" reaches a dot whose
       ancestor is neither. One unscoped rule reaches every one there is, and every one
       added later. */
    .chat-status { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
    /* Every FILLED state gets a hairline ring in the app's own darkest surface — not
       whatever fill happens to sit behind it, which is exactly what can (and did: issue
       #172, a yellow busy dot on the gold active main-chat pill) turn the same colour as
       the dot. --bg is close to black; every dot colour and every pill fill in this app
       sits far lighter than that, so the ring holds regardless of which two colours a
       future palette change puts on either side of it — the bug this closes cannot
       reopen just by picking a new hue. */
    .chat-status.busy { background: #E8B33F; box-shadow: 0 0 0 1.5px var(--bg); animation: chatpulse 1s ease-in-out infinite; }
    .chat-status.done { background: #30D158; box-shadow: 0 0 0 1.5px var(--bg); }
    /* .error's own ring + glow lives with its animation override below (the two other
       dot classes it's shared with need it too), not duplicated here. */
    .chat-status.error { background: #FF453A; }
    /* Work THIS chat dispatched elsewhere is still out (issue #167 part 2) — distinct
       from busy (this chat's own turn). NOT violet any more (issue #172): this dot sits
       on the main chat's own pill, which now wears magenta itself, and a violet dot on a
       magenta fill is the same bug this file just fixed once, one colour over. White
       reads on every fill the palette has. */
    .chat-status.jobsout { background: var(--text); box-shadow: 0 0 0 1.5px var(--bg); }
    .chat-status.idle { background: transparent; }
    .chat-status.flagged { background: transparent; box-shadow: inset 0 0 0 2px var(--blue), 0 0 0 1.5px var(--bg); }
    @keyframes chatpulse { 0%,100% { opacity: 1; } 50% { opacity: 0.3; } }
    /* m15: up to seven infinite animations could run at once. Honour the OS setting —
       but KEEP the recording cues (edge glow, mic pulse, rec dot): those are
       load-bearing state, not decoration. */
    @media (prefers-reduced-motion: reduce) {
      .chat-status, .chat-list-dot, .issue-status, .si-dot, .chat-playing,
      .bubble.loading-audio, .dot, .icon-spin, .tap-zone .tz-glyph,
      .hdr-more-menu, .hdr-more-menu .hdr-menu-item, .hdr-more.closing .hdr-more-menu {
        animation: none !important;
      }
      .scroll-bottom-btn, .toast { transition: none !important; }
      .hdr-more-menu, .hdr-more-menu .hdr-menu-item { opacity: 1 !important; transform: none !important; }
    }
    /* Error is STEADY red (user request 2026-08-20): a blinking dot for a state that
       needs no urgency reads as an alarm. Busy still breathes slowly, so error and busy
       differ by cadence (none vs pulse) plus the halo below — not by hue alone, which
       is hostile to colour-vision deficiency. The near-black ring (issue #172) sits
       inside the halo, closest to the dot, so a fill that happened to land near red
       still leaves the dot with a defined edge. */
    .chat-status.error, .chat-list-dot.error, .issue-status.error, .si-dot.error {
      animation: none !important;
      box-shadow: 0 0 0 1.5px var(--bg), 0 0 0 3.5px rgba(255,69,58,0.35);
    }
    /* Chat-bar multi-select (issue #79): long-press a pill to select, drag over others to
       add, then act on all via the select bar below the chat bar. */
    .chat-pill.selected { background: var(--blue); border-color: var(--blue); color: var(--accent-ink); font-weight: 600; }
    /* The selected tick is DRAWN, not typed: two borders on an empty box, rotated. A
       glyph here would render as a colour emoji on iOS and ignore the pill's colour. */
    .chat-pill.selected::after {
      content: ''; display: inline-block; flex: none; margin: 0 1px 3px 5px;
      width: 4px; height: 8px; border: solid currentColor; border-width: 0 2px 2px 0;
      transform: rotate(42deg);
    }
    .chat-select-bar {
      display: flex; align-items: center; gap: 8px; flex-shrink: 0;
      padding: 6px 10px; border-bottom: 0.5px solid var(--border); background: var(--surface);
      overflow-x: auto; scrollbar-width: none;
    }
    .chat-select-bar::-webkit-scrollbar { display: none; }
    .chat-select-bar .csb-count { font-weight: 600; font-size: 14px; white-space: nowrap; color: var(--text); }
    .chat-select-bar .csb-act {
      flex-shrink: 0; padding: 6px 12px; border-radius: 999px; cursor: pointer; white-space: nowrap;
      background: transparent; border: 0.5px solid var(--border); color: var(--text); font-size: 14px; font-weight: 500;
      -webkit-tap-highlight-color: transparent;
    }
    .chat-select-bar .csb-act.del { color: #FF453A; border-color: #FF453A; }
    .chat-select-bar .csb-done {
      margin-left: auto; flex-shrink: 0; padding: 6px 14px; border-radius: 999px; cursor: pointer; white-space: nowrap;
      background: var(--blue); border: 1px solid var(--blue); color: var(--accent-ink); font-size: 14px; font-weight: 600;
    }
    /* Speaker flag on the chat that's currently sounding (#77) — gently pulses so it
       reads as "live audio", and points the user at which chat to open to control it. */
    .chat-playing { flex-shrink: 0; line-height: 1; animation: chatpulse 1.2s ease-in-out infinite; }
    .chat-playing svg { width: 13px; height: 13px; display: block; }
    /* Per-pill delete: a small × after the name, nudged into the pill's right
       padding so it reads as part of the tab, not a separate control. */
    .chat-pill-close {
      flex-shrink: 0; margin: 0 -5px 0 1px; padding: 0;
      width: 18px; height: 18px; border-radius: 50%;
      display: inline-flex; align-items: center; justify-content: center;
      background: transparent; border: none; cursor: pointer;
      color: var(--muted); font-size: 17px; line-height: 1;
      -webkit-tap-highlight-color: transparent;
    }
    .chat-pill-close:active { background: rgba(255,69,58,0.18); color: #FF453A; }
    .chat-pill.active .chat-pill-close { color: rgba(4,52,44,0.75); }
    .chat-pill.active .chat-pill-close:active { background: rgba(4,52,44,0.18); color: var(--accent-ink); }
    .chat-add {
      flex-shrink: 0; width: 30px; height: 30px; border-radius: 50%;
      background: var(--surface); border: 0.5px solid var(--border);
      color: var(--muted); font-size: 20px; line-height: 1; cursor: pointer;
      -webkit-tap-highlight-color: transparent;
    }
    .chat-add:active { background: var(--border); }
    .chat-list-toggle {
      flex-shrink: 0; width: 44px; height: 44px; border-radius: 50%;
      background: var(--surface); border: 0.5px solid var(--border);
      color: var(--muted); cursor: pointer; -webkit-tap-highlight-color: transparent;
      display: inline-flex; align-items: center; justify-content: center;
      position: relative;   /* anchor for the aggregate status dot */
    }
    .chat-list-toggle svg { width: 22px; height: 22px; display: block; }
    .chat-list-toggle:active { background: var(--border); }
    /* Dots summarizing every chat behind this icon (issue: Status dot). The ring
       (page-bg border) lifts them off the glyph so the colours read at a glance. Only
       'busy' pulses, so ongoing work draws the eye while settled states (reply-ready,
       error) sit still.
       ONE DOT PER STATE THAT IS ACTUALLY PRESENT (#144-follow-up): a single winning dot
       hid the rest — a project with a finished reply AND a task still running showed
       green only, so "is anything still working?" was unanswerable without opening the
       list. The cluster is anchored at the top-right corner and grows leftwards, most
       urgent first, so the badge stays where the eye already looks. */
    .chat-list-toggle .chat-list-dots {
      position: absolute; top: -1px; right: -1px;
      display: flex; flex-direction: row; gap: 1px; pointer-events: none;
    }
    .chat-list-toggle .chat-list-dot {
      width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0;
      border: 2px solid var(--bg); box-sizing: border-box; pointer-events: none;
    }
    /* Three or four states at once would overhang the button, so the dots shrink
       to keep the cluster roughly inside the badge corner. */
    .chat-list-toggle .chat-list-dots.many .chat-list-dot { width: 8px; height: 8px; border-width: 1.5px; }
    .chat-list-toggle .chat-list-dot.done  { background: #30D158; }
    .chat-list-toggle .chat-list-dot.busy  { background: #E8B33F; animation: chatpulse 1s ease-in-out infinite; }
    .chat-list-toggle .chat-list-dot.error { background: #FF453A; }
    /* Neutral, not violet (issue #172) — see .chat-status.jobsout further up for why. */
    .chat-list-toggle .chat-list-dot.jobsout { background: var(--text); }
    .chat-list-toggle .chat-list-dot.flagged { background: transparent; box-shadow: inset 0 0 0 2px var(--blue); }
    /* ── Header overflow menu: everything that isn't the project/chat switcher lives
       behind the three-dots button, so the header itself never grows past two controls
       (desktop UI/UX tidy-up). Each row reuses the SAME element (same id, same onclick)
       that used to sit directly in the header, just re-laid-out as a labelled row —
       so every existing show/hide toggle (jobsBtn, artifactsBtn) and icon-set keeps
       working untouched. */
    .hdr-more { position: relative; flex-shrink: 0; }
    .hdr-more-btn {
      width: 44px; height: 44px; box-sizing: border-box;
      background: var(--surface); border: 0.5px solid var(--border); border-radius: 50%;
      color: var(--muted); cursor: pointer;
      padding: 0; flex-shrink: 0; -webkit-tap-highlight-color: transparent; line-height: 1;
      display: flex; align-items: center; justify-content: center;
    }
    .hdr-more-btn:active { color: #fff; background: var(--border); }
    .hdr-more-btn svg { width: 24px; height: 24px; display: block; }
    /* On the phone the header behind this button carries no background at all (see
       body:not(.desktop) .header above), but the button itself keeps a light circular
       frame — a hairline ring plus a barely-there tint, not the opaque plate desktop
       uses — so the glyph still reads as a proper tappable control rather than a bare
       icon floating loose, while the conversation stays clearly visible through and
       around it. Brightened to full white with a drop shadow on top of the ring, same
       "weight, contrast, a subtle shadow on the glyph" as the rest of the header.
       Tap target stays the full 44px square (border sits inside it, box-sizing:
       border-box from the base rule); :active deepens the tint so a tap still
       confirms itself. Desktop keeps its heavier plate untouched — its header never
       floats over content. */
    body:not(.desktop) .hdr-more-btn {
      background: rgba(255, 255, 255, 0.07); border: 1px solid rgba(255, 255, 255, 0.18);
      color: #fff;
      filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.7));
    }
    body:not(.desktop) .hdr-more-btn:active { background: rgba(255, 255, 255, 0.2); }
    .hdr-more-menu {
      display: none; position: absolute; top: calc(100% + 6px); right: 0; z-index: 600;
      min-width: 220px; max-width: 80vw;
      background: var(--surface-2); border: 0.5px solid var(--border); border-radius: 14px;
      padding: 6px; box-shadow: 0 8px 24px rgba(0,0,0,0.5);
      transform-origin: top right;   /* the corner nearest the button it opens from */
    }
    /* Unfolds from the button rather than snapping open — a short ease-out scale+fade
       on the panel (~180ms), each row a beat behind the last (~15ms apart). Only
       opacity/transform animate, so the menu is tappable on the very first frame —
       nothing here delays display:block or pointer-events. */
    .hdr-more.open .hdr-more-menu {
      display: block;
      animation: hdrMenuIn 0.18s cubic-bezier(0.22, 1, 0.36, 1) both;
    }
    @keyframes hdrMenuIn {
      from { opacity: 0; transform: scale(0.92) translateY(-6px); }
      to   { opacity: 1; transform: scale(1) translateY(0); }
    }
    .hdr-more-menu .hdr-menu-item { opacity: 0; }
    .hdr-more.open .hdr-more-menu .hdr-menu-item {
      animation: hdrMenuItemIn 0.12s cubic-bezier(0.22, 1, 0.36, 1) both;
    }
    .hdr-more.open .hdr-more-menu .hdr-menu-item:nth-child(1) { animation-delay: 0s; }
    .hdr-more.open .hdr-more-menu .hdr-menu-item:nth-child(2) { animation-delay: 0.015s; }
    .hdr-more.open .hdr-more-menu .hdr-menu-item:nth-child(3) { animation-delay: 0.03s; }
    .hdr-more.open .hdr-more-menu .hdr-menu-item:nth-child(4) { animation-delay: 0.045s; }
    .hdr-more.open .hdr-more-menu .hdr-menu-item:nth-child(5) { animation-delay: 0.06s; }
    .hdr-more.open .hdr-more-menu .hdr-menu-item:nth-child(6) { animation-delay: 0.075s; }
    @keyframes hdrMenuItemIn {
      from { opacity: 0; transform: translateY(-4px); }
      to   { opacity: 1; transform: translateY(0); }
    }
    /* Folds back into the button on close, same corner, same distance — the reverse
       of hdrMenuIn above, just faster and with an accelerating (not decelerating)
       curve, so it reads as "quick" not "bouncy". closeHeaderMenu() (app.js) adds
       this class instead of dropping display straight to none, and clears it once the
       animation has actually finished — see the JS comment there for why. Rows keep
       full opacity throughout: fading each one out again on its own stagger reads as
       flicker at this speed, and the panel's own fade already carries them out of
       view. */
    .hdr-more.closing .hdr-more-menu {
      display: block;
      animation: hdrMenuOut 0.14s cubic-bezier(0.4, 0, 1, 1) both;
    }
    @keyframes hdrMenuOut {
      from { opacity: 1; transform: scale(1) translateY(0); }
      to   { opacity: 0; transform: scale(0.92) translateY(-6px); }
    }
    .hdr-more.closing .hdr-more-menu .hdr-menu-item { opacity: 1; animation: none; }
    /* Same specificity trick as elsewhere in this sheet: a combined selector beats the
       lone #id/.class rule these buttons already carry from their old header life, so
       the row layout wins without having to touch (or risk) that older rule. */
    .hdr-btn.hdr-menu-item, .settings-btn.hdr-menu-item, #bigBtn.hdr-menu-item {
      display: flex; align-items: center; justify-content: flex-start; gap: 12px;
      width: 100%; min-width: 0; min-height: 0; padding: 10px 12px; border-radius: 10px;
      background: none; border: none; color: var(--text);
      font-size: 15px; font-weight: 500; cursor: pointer;
      -webkit-tap-highlight-color: transparent; text-align: left; position: relative;
    }
    .hdr-btn.hdr-menu-item:active, .settings-btn.hdr-menu-item:active, #bigBtn.hdr-menu-item:active {
      background: var(--border); color: var(--text);
    }
    .hdr-more-menu .hdr-menu-item svg, .hdr-more-menu .hdr-menu-item .ico svg { width: 20px; height: 20px; display: block; flex-shrink: 0; }
    .hdr-menu-item .hdr-menu-label { flex: 1; }
    .hdr-more-menu .jobs-count, .hdr-more-menu .art-count { position: static; margin-left: auto; }
    /* ── Chat dropdown: vertical list of the same chats, anchored under the bar ── */
    .chat-dropdown {
      position: fixed; z-index: 150; display: none;
      width: max-content; min-width: 180px; max-width: 320px;
      max-height: 60vh; overflow-y: auto;
      background: var(--surface-2); border: 0.5px solid var(--border);
      border-radius: 14px; padding: 6px;
      box-shadow: 0 12px 40px rgba(0,0,0,0.45);
      -webkit-overflow-scrolling: touch;
    }
    .chat-dropdown.open { display: block; }
    .chat-dd-head {
      font-size: 11px; font-weight: 600; letter-spacing: 0.05em; text-transform: uppercase;
      color: var(--muted); padding: 6px 10px 8px;
      display: flex; align-items: center; justify-content: space-between;
    }
    /* Search chats (item 7): moved out of Settings, onto the chat list it searches. */
    .chat-dd-search-btn {
      flex-shrink: 0; width: 24px; height: 24px; border-radius: 7px;
      display: inline-flex; align-items: center; justify-content: center;
      background: transparent; border: none; color: var(--muted); cursor: pointer;
      -webkit-tap-highlight-color: transparent;
    }
    .chat-dd-search-btn svg { width: 13px; height: 13px; display: block; }
    .chat-dd-search-btn:active { background: var(--border); color: var(--text); }
    .chat-drow {
      display: flex; align-items: center; gap: 10px;
      padding: 7px 6px 7px 12px; border-radius: 10px; cursor: pointer;
      font-size: 15px; color: var(--text); white-space: nowrap;
      -webkit-tap-highlight-color: transparent;
    }
    .chat-drow:active { background: var(--surface); }
    .chat-drow.active { background: var(--blue); color: var(--accent-ink); font-weight: 600; }
    .chat-drow { user-select: none; -webkit-user-select: none; -webkit-touch-callout: none; }  /* long-press selects the CHAT, not its text (#79) */
    .chat-drow.selected { background: var(--blue); color: var(--accent-ink); font-weight: 600; }
    #chatDropdown.chat-selecting .chat-drow-act, #chatDropdown.chat-selecting .chat-drow-new,
    #pickerChats.chat-selecting .chat-drow-act, #pickerChats.chat-selecting .chat-drow-new { display: none; }
    #chatDropdown .chat-select-bar, #pickerChats .chat-select-bar { position: sticky; bottom: 0; z-index: 1; border-radius: 0 0 12px 12px; margin: 4px -2px -2px; flex-wrap: wrap; }
    /* Sizing and colour for this row's dot come from the shared ".chat-status" rule near
       the top of the sheet (issue #172) — it used to be redefined here too, which is
       exactly how a rule for a third place (the sidebar, the project dropdown) went
       missing without anyone noticing. */
    /* Name grows and truncates so the rename/delete controls always stay reachable. */
    .chat-drow .chat-drow-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
    .chat-drow-act {
      flex-shrink: 0; width: 32px; height: 32px; border-radius: 8px;
      display: inline-flex; align-items: center; justify-content: center;
      background: transparent; border: none; color: var(--muted); cursor: pointer;
      -webkit-tap-highlight-color: transparent;
    }
    .chat-drow-act svg { width: 15px; height: 15px; display: block; }
    .chat-drow-act:active { background: var(--border); color: var(--text); }
    .chat-drow-act.del:active { background: rgba(255,69,58,0.16); color: #FF453A; }
    .chat-drow.active .chat-drow-act { color: rgba(4,52,44,0.8); }
    .chat-drow.active .chat-drow-act:active { background: rgba(4,52,44,0.16); color: var(--accent-ink); }
    /* Pin control: dim when not pinned, full-strength when pinned. */
    .chat-drow-act.pin { opacity: .35; font-size: 12px; }
    .chat-drow-act.pin.on { opacity: 1; }
    .chat-pin { margin-right: 1px; line-height: 1; }
    .chat-pin svg { width: 12px; height: 12px; display: block; }
    /* The crown marks the main chat wherever a chat is listed — pill, dropdown row,
       desktop sidebar — and keeps its magenta even on the active (filled) row, where the
       row's own colours have gone. */
    .chat-crown { margin-right: 1px; line-height: 1; color: var(--master); }
    .chat-crown svg { width: 12px; height: 12px; display: block; }
    .chat-drow.master .chat-drow-name, .sb-item.master .sb-name { color: var(--master); }
    .chat-drow.master.active .chat-drow-name, .sb-item.master.active .sb-name { color: inherit; }
    .chat-drow.active .chat-crown, .sb-item.active .chat-crown { color: inherit; }
    /* The master PROJECT wears the same magenta and the same crown, one level up: it is
       the project whose main chat runs the work in all the others. */
    .project-dd-item.master { color: var(--master); }
    .project-dd-item.master.active { color: var(--accent-ink); }
    /* Per-chat live working timer (#57). */
    .chat-drow-elapsed { font-size: 11px; color: var(--muted); margin-right: 6px; font-variant-numeric: tabular-nums; flex-shrink: 0; }
    .typing-elapsed {
      font-size: 12px; color: var(--muted); margin-left: 6px; font-variant-numeric: tabular-nums;
      flex: none; margin-top: 3px;   /* never squeezed by a long status; sits on row one */
    }
    /* The live status line (issue #145). One line that swaps its text as the work moves
       on, so it must never wrap the indicator into a paragraph — it ellipsises instead.
       Brighter than the idle "Claude is working…" because it is real information. */
    /* Up to THREE rows, then ellipsis. One row was too mean for what these lines
       actually say — a shell command or a described step is routinely longer than a
       phone is wide, and cutting it at the first row hid the useful half. Three is
       still a bounded strip that can't grow into a transcript. */
    .typing-label {
      min-width: 0; overflow: hidden; text-overflow: ellipsis;
      display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 3;
      line-height: 1.35; word-break: break-word;
    }
    .typing-label.live { color: var(--text); }
    /* Overflow (⋯) button: the ONLY trailing control on a row now, so the chat title
       always has room to read. It reveals the labelled per-row action menu below. */
    .chat-drow-act.more { color: var(--muted); }
    .chat-drow-act.more.open { background: var(--border); color: var(--text); }
    .chat-drow.active .chat-drow-act.more.open { background: rgba(4,52,44,0.16); color: var(--accent-ink); }
    /* Per-row action menu — progressive disclosure. Labelled items (not bare icons) so
       "Mark unread" is unmistakable. Only one row's menu is open at a time. */
    .chat-drow-menu {
      display: flex; flex-direction: column; gap: 2px;
      margin: 0 4px 6px 4px; padding: 4px;
      background: var(--surface); border-radius: 10px;
    }
    .chat-drow-mitem {
      display: flex; align-items: center; gap: 10px; width: 100%;
      padding: 9px 10px; border: none; border-radius: 7px; cursor: pointer;
      background: transparent; color: var(--text); font-size: 14px; text-align: left;
      -webkit-tap-highlight-color: transparent;
    }
    .chat-drow-mitem:active { background: var(--border); }
    .chat-drow-mitem .mi-ico { width: 17px; display: inline-flex; justify-content: center; flex-shrink: 0; color: var(--muted); }
    .chat-drow-mitem .mi-ico svg { width: 16px; height: 16px; display: block; }
    .chat-drow-mitem.mark .mi-dot { width: 11px; height: 11px; border-radius: 50%; background: #30D158; flex-shrink: 0; margin: 0 3px; }
    /* The archive: a folded section at the foot of the list. Its own row is dimmed like
       the New chat / Trash rows it sits beside, and the chats revealed under it are dimmed
       too — present, readable, plainly not part of what's live. */
    .chat-drow.chat-drow-arch { color: var(--muted); }
    .chat-drow.chat-drow-arch.open { color: var(--text); }
    .chat-drow.chat-drow-arch .chat-list-dots { margin-left: auto; }
    .chat-drow.archived .chat-drow-name { color: var(--muted); }
    .chat-drow-mitem.del { color: #FF453A; }
    .chat-drow-mitem.del .mi-ico { color: #FF453A; }
    .chat-drow-mitem.del:active { background: rgba(255,69,58,0.14); }
    /* Long-press a message → what you can do to it (#149). A bottom sheet, not a popup
       anchored to the bubble: a thumb reaches it on a phone, and it can't land off-screen
       for a message at the very top or bottom of a long thread. Items reuse the row-menu
       styling above so there is one look for "a list of things you can do". */
    .msg-sheet-back {
      position: fixed; inset: 0; z-index: 60; display: none;
      background: rgba(0,0,0,0.35); align-items: flex-end; justify-content: center;
    }
    .msg-sheet-back.open { display: flex; }
    .msg-sheet {
      width: 100%; max-width: 520px; margin: 0 8px 8px;
      background: var(--surface); border-radius: 14px; padding: 6px;
      padding-bottom: calc(6px + env(safe-area-inset-bottom));
      box-shadow: 0 8px 30px rgba(0,0,0,0.32);
    }
    .msg-sheet-q {
      padding: 10px 12px 9px; margin-bottom: 4px; font-size: 13px; color: var(--muted);
      white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
      border-bottom: 0.5px solid var(--border);
    }
    /* On a touch device the hold belongs to the menu, so it must not also start iOS's
       text selection. A mouse keeps selection — there is no long-press to lose there. */
    @media (hover: none) {
      .bubble.user, .bubble.assistant { -webkit-user-select: none; user-select: none; -webkit-touch-callout: none; }
    }
    /* Trailing "New chat" row — a second, clearer entry point than the bar's '+'. */
    .chat-drow-new {
      color: var(--blue); font-weight: 600;
      border-top: 0.5px solid var(--border); border-radius: 0 0 8px 8px;
      margin-top: 4px; padding-top: 12px; padding-bottom: 11px;
    }
    .chat-drow-new .chat-drow-plus {
      width: 8px; flex-shrink: 0; text-align: center; font-size: 18px; line-height: 1; color: var(--blue);
    }
    .chat-pill .chat-name.editable {
      text-decoration: underline dotted;
      text-underline-offset: 3px;
      text-decoration-color: var(--muted);
    }
    .chat-pill .chat-name.editable:active { text-decoration-color: currentColor; }
    /* ── Rename dialog: small centered popup over a dimmed backdrop ── */
    .dialog-backdrop {
      position: fixed; inset: 0; z-index: 200; padding: 24px;
      background: rgba(0,0,0,0.5); display: none;
      align-items: center; justify-content: center;
    }
    .dialog-backdrop.open { display: flex; }
    .dialog-card {
      background: var(--surface-2); border: 0.5px solid var(--border);
      border-radius: 16px; padding: 20px; width: 100%; max-width: 340px;
      box-shadow: 0 12px 40px rgba(0,0,0,0.45);
    }
    .dialog-card h2 { font-size: 17px; font-weight: 600; margin-bottom: 14px; }
    .dialog-actions { display: flex; gap: 10px; margin-top: 16px; }
    .dialog-actions button {
      flex: 1; padding: 12px; border-radius: 12px; border: none;
      font-size: 15px; font-weight: 600; font-family: inherit; cursor: pointer;
    }
    .dialog-cancel { background: var(--surface); color: var(--text); }
    .dialog-save { background: var(--blue); color: var(--accent-ink); }
    /* ── First-run wizard (issue #193): two full-screen steps, language then
       microphone, before the coach-mark tour ever starts. Centered like the
       rename dialog above rather than anchored to a control — nothing on the
       page needs pointing at yet. Big one-handed buttons, no scrolling. */
    .firstrun-backdrop {
      position: fixed; inset: 0; z-index: 500; padding: 24px;
      background: rgba(0,0,0,0.72); display: none;
      align-items: center; justify-content: center;
    }
    .firstrun-backdrop.show { display: flex; }
    .firstrun-card {
      background: var(--surface-2); border: 0.5px solid var(--border);
      border-radius: 20px; padding: 26px 22px; width: 100%; max-width: 360px;
      box-shadow: 0 12px 40px rgba(0,0,0,0.5); text-align: center;
    }
    .firstrun-ico { color: var(--muted); display: flex; justify-content: center; margin-bottom: 14px; }
    .firstrun-ico svg { width: 34px; height: 34px; }
    .firstrun-title { font-size: 19px; font-weight: 700; margin-bottom: 18px; }
    .firstrun-body { font-size: 14.5px; line-height: 1.45; color: var(--muted); margin-bottom: 20px; }
    .firstrun-lang-row { display: flex; flex-direction: column; gap: 12px; margin-bottom: 6px; }
    .firstrun-btn {
      width: 100%; padding: 15px; border-radius: 14px; border: none;
      font-size: 17px; font-weight: 600; font-family: inherit; cursor: pointer;
      background: var(--blue); color: var(--accent-ink);
    }
    .firstrun-btn.secondary { background: var(--surface); color: var(--text); border: 0.5px solid var(--border); }
    .firstrun-skip {
      display: block; width: 100%; text-align: center; margin-top: 16px;
      background: none; border: none; color: var(--muted); font-size: 14px;
      padding: 8px 4px; cursor: pointer;
    }
    /* ── Search panel ─────────────────────────────────────────────────────── */
    .search-panel { display: none; flex-direction: column; flex-shrink: 0;
      border-bottom: 0.5px solid var(--border); background: var(--bg); }
    .search-panel.open { display: flex; }
    .search-row { display: flex; align-items: center; gap: 8px; padding: 8px 10px; }
    .search-ico { color: var(--muted); display: flex; flex-shrink: 0; }
    .search-ico svg { width: 18px; height: 18px; display: block; }
    #searchInput {
      flex: 1; min-width: 0; background: var(--surface); border: 0.5px solid var(--border);
      color: var(--text); font-size: 16px; padding: 9px 12px; border-radius: 10px;
      outline: none; -webkit-appearance: none;   /* 16px: below that iOS auto-zooms on focus now that pinch-zoom is back (M10) */
    }
    #searchInput:focus { border-color: var(--blue); }
    .search-mic, .search-close {
      flex-shrink: 0; background: var(--surface); border: 0.5px solid var(--border);
      color: var(--muted); width: 38px; height: 38px; border-radius: 10px; cursor: pointer;
      display: flex; align-items: center; justify-content: center; -webkit-tap-highlight-color: transparent;
    }
    .search-mic svg, .search-close svg { width: 18px; height: 18px; display: block; }
    .search-mic.recording { background: #FF453A; border-color: #FF453A; color: #fff; animation: chatpulse 1s ease-in-out infinite; }
    .search-results { max-height: 46vh; overflow-y: auto; -webkit-overflow-scrolling: touch; }
    .search-results:empty { display: none; }
    .search-item {
      padding: 10px 14px; border-top: 0.5px solid var(--border); cursor: pointer;
      -webkit-tap-highlight-color: transparent;
    }
    .search-item:active { background: var(--surface); }
    .search-item .si-title { font-size: 14px; font-weight: 600; color: var(--text); display: flex; align-items: center; gap: 7px; }
    .search-item .si-dot { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; }
    .search-item .si-dot.busy { background: #E8B33F; }
    .search-item .si-dot.done { background: #30D158; }
    .search-item .si-snip { font-size: 12.5px; color: var(--muted); margin-top: 3px; overflow: hidden;
      text-overflow: ellipsis; white-space: nowrap; }
    .search-empty { padding: 12px 14px; font-size: 13px; color: var(--muted); }
    .settings-btn {
      background: none;
      border: none;
      color: var(--muted);
      cursor: pointer;
      padding: 4px;
      flex-shrink: 0;
      -webkit-tap-highlight-color: transparent;
      display: flex; align-items: center; justify-content: center;
    }
    .settings-btn:active { color: #fff; }
    .live-dot {
      width: 9px; height: 9px;
      border-radius: 50%;
      background: #555;
      flex-shrink: 0;
      transition: background 0.3s, box-shadow 0.3s;
    }
    .live-dot.on { background: #30D158; box-shadow: 0 0 6px #30D158; }
    .hdr-btn {
      background: none; border: none; color: var(--muted); cursor: pointer;
      padding: 4px; flex-shrink: 0; -webkit-tap-highlight-color: transparent; line-height: 1;
      display: flex; align-items: center; justify-content: center;
    }
    .hdr-btn:active { color: #fff; }
    /* The artifacts door (issue #143). The pane used to be reachable only from a chip
       on one bubble or by voice, so a chat full of artifacts looked like it had none.
       This button lives in the header, but only while THIS chat actually has some — an
       always-there control for a usually-empty pane would be clutter, not a door. */
    #artifactsBtn { color: #0A84FF; }
    .art-count {
      position: absolute; top: -2px; right: -4px; min-width: 14px; height: 14px;
      padding: 0 3px; border-radius: 7px; background: #0A84FF; color: #fff;
      font-size: 9px; font-weight: 700; line-height: 14px; text-align: center;
      pointer-events: none;
    }
    /* Developer-area button — deliberately amber and labelled DEV so it reads as a
       for-developers-only tool, visually distinct from the normal header controls. */
    .dev-btn {
      background: none; border: 1px solid #E8B33F; color: #E8B33F; cursor: pointer;
      padding: 3px 7px; flex-shrink: 0; -webkit-tap-highlight-color: transparent;
      border-radius: 6px; font-size: 11px; font-weight: 600; letter-spacing: .6px;
      display: flex; align-items: center; gap: 4px; line-height: 1;
    }
    .dev-btn:active { background: #E8B33F; color: #1a1a1a; }
    .dev-btn svg { width: 14px; height: 14px; display: block; }
    /* Dev-area "for developers only" banner + saved-version (checkpoint) cards. */
    .dev-note {
      color: #E8B33F; background: rgba(245,166,35,.10);
      border: 1px solid rgba(245,166,35,.35); border-radius: 8px;
      padding: 10px 12px; font-size: 13px; line-height: 1.45; margin-bottom: 22px;
    }
    .dev-section { font-size: 16px; font-weight: 600; color: #fff; margin: 6px 0 14px; }
    .ck-item {
      background: var(--surface); border-radius: 12px; padding: 14px 16px;
      margin-bottom: 12px; border-left: 3px solid #E8B33F;
    }
    /* A checkpoint the user declared fully stable (not just "probably"/"most likely")
       gets a green left edge instead of the default amber, so a known-good return
       point stands out in the list at a glance rather than reading like every other
       save. Driven by the `stability` field — see .ck-stability below. */
    .ck-item.ck-stable { border-left-color: #3fb950; }
    .ck-head { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; margin-bottom: 8px; }
    .ck-name { font-size: 17px; font-weight: 600; color: #fff; }
    .ck-stability {
      font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.3px;
      padding: 2px 7px; border-radius: 20px; background: rgba(255,255,255,0.08); color: var(--muted);
    }
    .ck-stability.ck-stable { background: rgba(63,185,80,0.16); color: #3fb950; }
    .ck-stability.ck-probable { background: rgba(232,179,63,0.16); color: #E8B33F; }
    .ck-date { color: var(--muted); font-size: 13px; }
    .ck-by { color: var(--muted); font-size: 13px; margin-left: auto; }
    .ck-commits { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; color: var(--muted); margin-bottom: 8px; }
    .ck-commits b { color: #9aa4ad; font-weight: 600; }
    .ck-field { font-size: 13.5px; line-height: 1.5; margin-top: 6px; color: #cdd4da; }
    .ck-field b { color: var(--blue); font-weight: 600; }
    #bigBtn {
      background: var(--surface);
      border: none;
      border-radius: 20px;
      padding: 8px 16px;
      font-size: 20px;
      color: #fff;
      cursor: pointer;
      flex-shrink: 0;
      -webkit-tap-highlight-color: transparent;
      line-height: 1;
      min-width: 44px;
      min-height: 36px;
      display: flex;
      align-items: center;
      justify-content: center;
    }
    #bigBtn:active { background: var(--blue); }
    /* SVG icon sizing — icons inherit button color via currentColor. */
    .hdr-btn svg, .settings-btn svg, #bigBtn svg { width: 22px; height: 22px; display: block; }
    .mic-btn svg { width: 22px; height: 22px; display: block; }
    .tz-play-btn svg { width: 26px; height: 26px; }
    .tz-mic-btn svg { width: 24px; height: 24px; }
    .ap-play svg { width: 16px; height: 16px; display: block; }
    .tz-glyph svg { width: 26vw; height: 26vw; }
    /* The general-purpose inline glyph. Sits on the text baseline wherever
       an emoji used to, at the size of the text around it, in the text's own colour —
       so a toast, a chip and a menu row all wear the same drawing at their own scale. */
    .ico { display: inline-flex; flex: none; align-items: center; justify-content: center; }
    .ico svg { width: 1.05em; height: 1.05em; display: block; }
    .toast { display: flex; align-items: center; gap: 8px; justify-content: center; }
    .toast .ico { opacity: 0.85; }
    .set-row-ico svg, .ss-ico svg { width: 18px; height: 18px; display: block; }
    .cmd-mic svg { width: 20px; height: 20px; display: block; margin: 0 auto; }
    .issue-solve svg, .issue-del svg, .issue-revert svg { width: 16px; height: 16px; display: block; }
    .overlay-close svg { width: 20px; height: 20px; display: block; }
    .chat-drow-plus svg { width: 15px; height: 15px; display: block; }
    .ti-btn svg { width: 14px; height: 14px; display: block; }
    .ip-remove svg { width: 11px; height: 11px; display: block; }
    .project-dd-item .ico { margin-right: 7px; vertical-align: -2px; }
    .offline-bar .ico, .auth-bar .ico, .dev-note .ico { vertical-align: -2px; margin-right: 6px; }
    .bubble > .notice-ico { vertical-align: -3px; margin-right: 7px; opacity: 0.9; }
    .file-chip .ico, .att-file .ico { vertical-align: -2px; margin-right: 5px; opacity: 0.75; }
    .copy-btn .ico { vertical-align: -2px; margin-left: 4px; }
    @keyframes spin { to { transform: rotate(360deg); } }
    .icon-spin { animation: spin 1.4s linear infinite; }
    /* Big-button "tap-anywhere" mode: a full-screen tinted overlay that turns
       the whole display into the record trigger, so the phone works like a
       physical button you don't have to look at. */
    .tap-zone {
      position: fixed; inset: 0; z-index: 500;
      display: none;
      flex-direction: column; align-items: center; justify-content: center;
      gap: 22px; text-align: center; padding: 40px;
      background: #06201D;   /* idle: deep Almaty-teal tint */
      -webkit-tap-highlight-color: transparent;
      user-select: none; -webkit-user-select: none;
      transition: background 0.25s ease;
    }
    .tap-zone.on { display: flex; }
    .tap-zone.recording { background: #3A0D0A; }
    .tap-zone.working { background: #33270A; }
    .tap-zone .tz-glyph { font-size: 26vw; line-height: 1; pointer-events: none; }
    .tap-zone.recording .tz-glyph { animation: tzpulse 1s ease-in-out infinite; }
    @keyframes tzpulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } }
    .tap-zone .tz-label {
      font-size: 26px; font-weight: 600; color: #fff;
      text-shadow: 0 1px 10px rgba(0,0,0,0.55); pointer-events: none;
    }
    /* Second line under the state: what the running turn is doing, or the turn
       still running behind a fresh dictation. Clamped — it must never grow the
       zone or push the glyph about. */
    .tap-zone .tz-sub {
      margin: -12px 0 8px; max-width: 84%;
      font-size: 15px; line-height: 1.35; color: rgba(255,255,255,0.72);
      pointer-events: none;
      display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
    }
    .tap-zone .tz-hint {
      position: absolute; left: 0; right: 0;
      bottom: calc(env(safe-area-inset-bottom, 20px) + 160px);
      padding: 0 20px; line-height: 1.6;
      font-size: 14px; color: rgba(255,255,255,0.08); pointer-events: none;
    }
    .tap-zone .tz-hint span { white-space: nowrap; }
    .tz-controls {
      position: absolute; left: 16px; right: 16px;
      bottom: calc(env(safe-area-inset-bottom, 0px) + 14px);
      display: flex; flex-direction: column; gap: 10px;
    }
    .tz-seek {
      width: 100%; accent-color: #fff; height: 6px; cursor: pointer;
    }
    .tz-transport {
      display: flex; align-items: center; justify-content: center; gap: 20px;
    }
    .tz-play-btn {
      background: rgba(255,255,255,0.2); border: none; border-radius: 50%;
      width: 58px; height: 58px; color: #fff; font-size: 20px;
      cursor: pointer; -webkit-tap-highlight-color: transparent;
      display: flex; align-items: center; justify-content: center;
    }
    .tz-skip-btn {
      background: rgba(255,255,255,0.12); border: none; border-radius: 12px;
      padding: 12px 22px; color: #fff; font-size: 15px; font-weight: 600;
      cursor: pointer; -webkit-tap-highlight-color: transparent;
    }
    .tz-skip-btn:active, .tz-play-btn:active { filter: brightness(1.5); }
    /* ── The top row: Stop (left), fast router (middle), exit (right) ──────────
       One line, one height, one offset — and that offset MUST clear the status
       bar. Stop sat at a bare top:16px, which on a phone is underneath the clock
       and the battery: the one control you press when a turn has gone wrong was
       the one printed over the system furniture. Everything on this line hangs
       off the same safe-area expression as the exit, so they cannot drift apart
       again. */
    .tz-cancel-btn, .tz-mic-btn, .tz-router-btn, .tap-exit {
      position: absolute; z-index: 510;
      top: calc(env(safe-area-inset-top, 20px) + 6px);
      height: 48px; box-sizing: border-box;
      border: none; color: #fff; cursor: pointer;
      -webkit-tap-highlight-color: transparent;
      align-items: center; justify-content: center;
    }
    .tz-cancel-btn {
      display: none; left: 16px;
      background: rgba(255,59,48,0.85); border-radius: 24px;
      padding: 0 22px; font-size: 16px; font-weight: 600;
    }
    .tz-cancel-btn:active { filter: brightness(1.3); }
    .tz-mic-btn {
      display: none; left: 16px;
      background: rgba(255,255,255,0.15); border-radius: 24px;
      padding: 0 18px; font-size: 20px; gap: 6px;
    }
    .tz-mic-btn:active { background: rgba(255,255,255,0.3); }
    /* The compass is the ONLY door into the fast router, and full screen covered
       it up along with the rest of the composer — so the eyes-free surface was
       the one place you could not speak a command. Centred, so it stays between
       Stop and the exit whether or not a turn is running. */
    .tz-router-btn {
      display: flex; left: 50%; transform: translateX(-50%);
      width: 48px; border-radius: 50%;
      background: rgba(255,255,255,0.15);
      border: 1px solid rgba(255,255,255,0.35);
    }
    .tz-router-btn svg { width: 24px; height: 24px; display: block; }
    .tz-router-btn:active { background: rgba(255,255,255,0.35); }
    /* Armed: this take is going to the router, not into the chat. */
    .tz-router-btn.armed { background: rgba(10,132,255,0.9); border-color: rgba(255,255,255,0.7); }
    /* Model name + effort picker share one centered row. The project/chat/model
       text is informational only — no pill, near-invisible against the tint, and
       (via .tz-meta's pointer-events: none, which it never overrides) untappable.
       The effort picker opts back INTO pointer events, because unlike its neighbour
       it really is a control — that contrast (one inert, one live) is the point. */
    .tz-meta {
      display: flex; align-items: center; justify-content: center; gap: 8px;
      pointer-events: none;
    }
    .tz-project {
      font-size: 14px; font-weight: 500; letter-spacing: 0.4px;
      color: rgba(255,255,255,0.08);
      white-space: nowrap;
    }
    /* Reasoning-effort picker. Resting state shows only the chosen tier — a single
       tappable row, same footprint as the old non-interactive dial item, so nothing
       around it jumps when this collapses back to it. Tapping it (or any tier once
       open) reveals the full list as an ABSOLUTELY POSITIONED overlay: .tz-dial's own
       box always stays exactly one row tall, in flow, so the flex-centred stack above
       it (glyph/label/sub) never re-centres when the list opens or closes. */
    .tz-dial {
      position: relative;
      display: flex; flex-direction: column; align-items: flex-start;
      text-align: left; user-select: none;
      height: 24px;
      pointer-events: auto;   /* opt back in — this row IS a control, unlike .tz-project beside it */
    }
    .tz-dial-item {
      display: block; width: 100%; box-sizing: border-box;
      font: inherit; font-size: 13px; font-weight: 600; letter-spacing: 0.3px;
      line-height: 24px; height: 24px; padding: 0 10px; white-space: nowrap;
      color: rgba(255,255,255,0.3); background: none; border: 0; margin: 0;
      -webkit-tap-highlight-color: transparent;
      transition: color 0.2s;
    }
    .tz-dial-item.active {
      color: #fff; background: rgba(255,255,255,0.16); border-radius: 12px;
    }
    /* The resting chip (rendered WITHOUT the "active" class — see renderEffortDial)
       must read as one more piece of plain text in the row beside .tz-project, not
       as a label: same near-invisible colour and weight, no pill, no border. It is
       still the live control (cursor + pointer-events: auto on .tz-dial, above) —
       the contrast with .tz-project is only ever behavioural, never visual. */
    .tz-dial-current {
      cursor: pointer;
      font-size: 14px; font-weight: 500; letter-spacing: 0.4px;
      color: rgba(255,255,255,0.08); background: none; border-radius: 0;
    }
    /* The open list: comfortably tappable at phone width (40px rows, well over the
       44px touch-target guideline with its own padding), floated below the collapsed
       row so it overlays rather than pushes. */
    .tz-dial-list {
      position: absolute; top: 100%; left: 50%; transform: translateX(-50%);
      margin-top: 6px; z-index: 5;
      display: flex; flex-direction: column;
      background: rgba(15,15,15,0.94); border: 1px solid rgba(255,255,255,0.14);
      border-radius: 14px; padding: 4px; box-shadow: 0 8px 24px rgba(0,0,0,0.45);
    }
    .tz-dial-list .tz-dial-item {
      height: 40px; line-height: 40px; padding: 0 18px; border-radius: 10px;
      font-size: 15px;
    }
    .tap-exit {
      display: flex; right: 14px;
      width: 48px; border-radius: 50%;
      background: rgba(0,0,0,0.45);
      border: 1px solid rgba(255,255,255,0.35);
      font-size: 26px; line-height: 1;
    }
    .toast {
      position: fixed; left: 50%;
      bottom: calc(84px + env(safe-area-inset-bottom, 0px));
      transform: translateX(-50%) translateY(10px);
      background: var(--surface-2); color: var(--text); padding: 10px 18px; border-radius: 20px;
      border: 0.5px solid var(--border);
      font-size: 14px; opacity: 0; pointer-events: none; white-space: nowrap;
      transition: opacity 0.25s, transform 0.25s; z-index: 200;
      box-shadow: 0 4px 16px rgba(0,0,0,0.45);
    }
    .toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
    /* Offline strip (UX audit C2): a persistent, glanceable "the app can't hear the
       server" surface — the live-dot's title tooltip never shows on iOS. */
    .offline-bar {
      position: fixed; left: 0; right: 0; z-index: 300; display: none;
      top: calc(env(safe-area-inset-top, 0px));
      padding: 6px 14px; text-align: center;
      background: #33270A; color: #E8B33F; border-bottom: 0.5px solid #7a6420;
      font-size: 13px; font-weight: 600; font-variant-numeric: tabular-nums;
    }
    .offline-bar.show { display: block; }
    /* Rejected-token strip (UX audit M13): auth death used to look exactly like "no
       activity" — dead dots, empty state, nothing spoken. Tappable → Settings. */
    .auth-bar {
      position: fixed; left: 0; right: 0; z-index: 301; display: none;
      top: calc(env(safe-area-inset-top, 0px));
      padding: 6px 14px; text-align: center; cursor: pointer;
      background: #3A0D0A; color: #FF6B6B; border-bottom: 0.5px solid #FF453A;
      font-size: 13px; font-weight: 600;
    }
    .auth-bar.show { display: block; }
    /* New-build strip (issue #191): was drawn at top:0 with no safe-area padding, so on
       an iPhone it rendered under the status bar / notch — present in the DOM, unreachable
       and mostly invisible. Same fixed-strip pattern as offline-bar/auth-bar above (safe-area
       top inset) instead of a bespoke floating bar, so it sits in the one place on screen a
       thumb can already reach and dismiss. */
    .update-bar {
      position: fixed; left: 0; right: 0; z-index: 302; display: none;
      top: calc(env(safe-area-inset-top, 0px));
      padding: 8px 14px; align-items: center; justify-content: center; gap: 10px;
      background: #0A2A4D; color: #cfe3ff; border-bottom: 0.5px solid #1a73e8;
      font-size: 13px; font-weight: 600;
    }
    .update-bar.show { display: flex; }
    .update-bar-btn {
      background: #fff; color: #1a73e8; border: 0; border-radius: 6px; flex: none;
      padding: 5px 12px; font-weight: 700; font-size: 13px; cursor: pointer;
    }
    .update-bar-close {
      background: none; border: 0; color: inherit; opacity: 0.75; flex: none;
      padding: 4px; margin: 0; cursor: pointer; display: flex; align-items: center;
    }
    .update-bar-close svg { width: 14px; height: 14px; display: block; }
    .update-bar-close:active { opacity: 1; }
    /* Dispatched-work line (issue #167; moved inside .header 2026-09-05, then onto the
       SAME row as the project/chat/three-dot row) — gone the moment that chat isn't a
       main chat with work out. Redesigned from a single run-on sentence into
       independent CHIPS (reopened: "3 jobs running · 1 re" chopped off mid-word — prose
       cannot survive a narrow, contested row, and never could). flex: 0 0 auto — this
       never shrinks and never wraps, so between it and the name pill on its left
       (.hdr-picker-btn, flex:1 1 0%, already ellipsizes its own text) it is always the
       NAME that gives way first, whatever length the chat/project names happen to be,
       never a count. */
    .jobs-strip {
      display: none; flex: 0 0 auto; align-items: center; gap: 6px;
    }
    .jobs-strip.show { display: flex; }
    /* Each chip carries its OWN solid-tinted fill, hairline edge and shadow — the
       header itself stays fully transparent (unchanged), so these are the one thing
       in it that has to hold up over arbitrary scrolling text on their own. flex: none
       so a chip is never asked to shrink or ellipsise; a numeral is short enough it
       never needs to. */
    .jobs-chip {
      flex: none; display: inline-flex; align-items: center; gap: 5px;
      height: 24px; padding: 0 9px; border-radius: 999px; border: 0;
      box-shadow: 0 2px 8px rgba(0, 0, 0, 0.45);
      font-size: 12px; font-weight: 700; font-variant-numeric: tabular-nums;
      cursor: pointer; -webkit-tap-highlight-color: transparent;
      white-space: nowrap;
    }
    .jobs-chip:active { filter: brightness(1.2); }
    .jobs-chip svg { width: 13px; height: 13px; flex: none; }
    /* Work in flight: ambient, low-urgency — ONLY a soft pulsing dot plus the numeral,
       tinted the same accent teal as the rest of the app's "active" state. No glyph
       fighting the dot for attention; this one just says "still going". */
    .jobs-chip.running { background: rgba(43, 217, 199, 0.16); color: var(--accent); }
    .jobs-chip.running .chip-dot {
      width: 7px; height: 7px; border-radius: 50%; background: var(--accent);
      animation: chipPulse 1.6s ease-in-out infinite;
    }
    @keyframes chipPulse {
      0%, 100% { opacity: 0.55; transform: scale(0.8); }
      50% { opacity: 1; transform: scale(1.15); }
    }
    /* Replies back: the one state actually worth interrupting for, so it gets its own
       brighter, more positive colour rather than sharing the running chip's — it is
       meant to pull the eye first. Pops for a moment when the count goes UP (a new
       reply landed), never on the way down or on first paint. */
    .jobs-chip.back { background: #4ADE80; color: #08320F; }
    .jobs-chip.back.pop { animation: chipPop 0.4s ease-out; }
    @keyframes chipPop {
      0% { transform: scale(1); }
      40% { transform: scale(1.3); }
      100% { transform: scale(1); }
    }
    /* Failed / stranded: the same red a failed turn's own chat-bar dot wears. */
    .jobs-chip.alert { background: #FF6B6B; color: #3A0D0D; }
    /* All-projects job count: a folder glyph, not the running dot — this is a COUNT of
       elsewhere, not a pulse of "still going" (that pulse belongs to work you can see
       on this screen). Violet, the same hue the header's own jobs badge already wears
       for "there is a board behind this", so the two read as the same kind of fact. */
    .jobs-chip.elsewhere { background: rgba(191, 140, 255, 0.16); color: #BF8CFF; }
    .jobs-chip.elsewhere.pop { animation: chipPop 0.4s ease-out; }
    /* The reply the jump landed on. The flash says "here it is" for a moment; this
       stays until the next tap, so after the animation there is still something on
       screen saying WHICH of several relayed replies you are being shown. */
    .bubble.relayed.jump-here {
      box-shadow: 0 0 0 2px #7B4FC0, 0 0 14px rgba(191, 140, 255, 0.28);
    }
    /* Running-jobs header badge (issue #167 widened): same violet as the strip while
       something is queued or running, the strip's red the moment anything failed —
       reachable from every chat, not only the project's main one. */
    #jobsBtn { color: #BF8CFF; }
    .jobs-count {
      position: absolute; top: -2px; right: -4px; min-width: 14px; height: 14px;
      padding: 0 3px; border-radius: 7px; background: #BF8CFF; color: #1a1023;
      font-size: 9px; font-weight: 700; line-height: 14px; text-align: center;
      pointer-events: none;
    }
    /* One row per job, reusing .trash-item's card shape. Status colours match the
       chat-bar dot's palette so "running" reads the same everywhere. Queued/running rows
       plus unacknowledged FAILURES reach THIS list (issue #167 widened yet again — a
       failure that dropped off the board entirely turned out to be unreachable, not
       just noisy). A job that came back fine has its own separate "Replies waiting"
       section instead (see .job-reply below, issue: replies badge rework) — the reply
       already having been relayed into some chat is exactly why it isn't an ordinary
       .job-item row here. */
    .trash-item.job-item { cursor: pointer; }
    /* A reply already delivered, not yet looked at. Same card shape and hover/press
       feel as .job-item, deliberately a different class — jobsForOverlay's own
       running/failed split (and everything that reads it) must never see one of these
       as a board row. */
    .trash-item.job-reply { cursor: pointer; }
    .job-status {
      flex-shrink: 0; font-size: 11px; font-weight: 700; text-transform: uppercase;
      letter-spacing: .3px; padding: 2px 7px; border-radius: 999px;
      color: var(--muted); background: var(--bg); border: 0.5px solid var(--border);
    }
    .job-status.queued { color: #E8B33F; border-color: #E8B33F; }
    .job-status.running { color: #E8B33F; border-color: #E8B33F; background: rgba(232,179,63,.12); }
    /* A usage-limit stall (build: job status truthfulness): amber like queued/running,
       never red — this job has not failed, it is waiting on the retry loop. */
    .job-status.paused { color: #E8B33F; border-color: #E8B33F; }
    .job-status.error { color: #FF6B6B; border-color: #FF453A; background: rgba(255,69,58,.12); }
    /* The honest receipt (anti-work-loss, part two): a job that talked without leaving
       a trace is red like a real failure, because it is one — the app just found out
       later than usual. A job that IS saved but hasn't been deployed yet is amber like
       queued/paused: not a failure, just not finished landing — the autodeploy timer
       clears it within the minute without anyone doing anything. Neither may ever look
       like the plain, unstyled 'done' pill above. */
    .job-status.not_saved { color: #FF6B6B; border-color: #FF453A; background: rgba(255,69,58,.12); }
    .job-status.not_deployed { color: #E8B33F; border-color: #E8B33F; }
    /* A row that looked done but never really answered (build: unfinished-job marking)
       — its own colour, orange, deliberately between the pending amber and the failure
       red: it is not still working (amber would lie about that) and the app is not
       reporting a genuine crash (red would overstate what it knows), it just never
       actually finished. Never plain/unstyled like 'done' — that is the exact
       confusion this exists to end. */
    .job-status.stalled { color: #FF9F0A; border-color: #FF9F0A; background: rgba(255,159,10,.12); }
    .job-dot.stalled { background: #FF9F0A; }
    /* Stopped on purpose (build step 2/3): muted, not red. It is not a failure and must
       never be dressed as one — the pill is the only thing telling the two apart. */
    .job-status.stopped { color: var(--muted); border-color: var(--border); }
    .job-item .ti-sub { display: flex; align-items: center; gap: 6px; }
    /* The row's own Stop (build step 3). Same 30px round control as the issue row's
       pair, so a tappable thing on a list row is the same shape wherever it appears;
       red on press only, because pressing it kills work that is genuinely running. */
    .job-stop {
      flex-shrink: 0; align-self: center;
      width: 30px; height: 30px; border-radius: 50%;
      border: 1.5px solid var(--border); background: none; color: var(--muted);
      line-height: 1; cursor: pointer; font-family: inherit;
      display: flex; align-items: center; justify-content: center;
      -webkit-tap-highlight-color: transparent;
    }
    .job-stop svg { width: 15px; height: 15px; display: block; }
    .job-stop:active { background: rgba(255,69,58,.14); border-color: #FF453A; color: #FF6B6B; }
    .job-stop[disabled] { opacity: .4; }
    /* The row's own delete, on a dead row only (build: delete jobs). Same 30px round
       control and position as Stop above — a plain tap fires at once, no arm/confirm,
       since the row is already gone and there is nothing left to lose by mis-tapping it. */
    .job-delete {
      flex-shrink: 0; align-self: center;
      width: 30px; height: 30px; border-radius: 50%;
      border: 1.5px solid var(--border); background: none; color: var(--muted);
      line-height: 1; cursor: pointer; font-family: inherit;
      display: flex; align-items: center; justify-content: center;
      -webkit-tap-highlight-color: transparent;
    }
    .job-delete svg { width: 13px; height: 13px; display: block; }
    .job-delete:active { background: rgba(255,255,255,.08); border-color: var(--muted); }
    .job-delete[disabled] { opacity: .4; }
    /* "Stop all" sits above the list rather than on a row: it is about the whole board,
       and a row-shaped control for it would be one mis-tap away from a row's own Stop. */
    .jobs-stopall {
      display: block; margin: 0 0 10px auto; padding: 7px 14px; border-radius: 999px;
      border: 1.5px solid var(--border); background: none; color: var(--muted);
      font-size: 13px; font-weight: 600; font-family: inherit; cursor: pointer;
      -webkit-tap-highlight-color: transparent;
    }
    .jobs-stopall:active { background: rgba(255,69,58,.14); border-color: #FF453A; color: #FF6B6B; }
    /* "Clear finished" (build: delete jobs) — same shape as Stop all, sits right below
       it when both are offered, muted rather than red since deleting a dead row is not
       the alarming action stopping a live one is. */
    .jobs-clearall {
      display: block; margin: 0 0 10px auto; padding: 7px 14px; border-radius: 999px;
      border: 1.5px solid var(--border); background: none; color: var(--muted);
      font-size: 13px; font-weight: 600; font-family: inherit; cursor: pointer;
      -webkit-tap-highlight-color: transparent;
    }
    .jobs-clearall:active { background: rgba(255,255,255,.08); border-color: var(--muted); }
    .jobs-clearall[disabled] { opacity: .4; }
    .job-elapsed { font-variant-numeric: tabular-nums; }
    /* The dot the failed row wears (issue #167 widened yet again) — same 8px, same red,
       as every other "this died" indicator in the app (.issue-status.error and kin,
       see the shared selector above). A running/queued row carries none: its ticking
       elapsed timer already reads as "still alive" the way a dot pulsing would. */
    .job-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
    .job-dot.error { background: #FF453A; }
    /* A job dispatched by another job (a dispatch chain, not a fresh one) — indented in
       buildJobRow to its depth, with this small corner-arrow ahead of its title so the
       whole tree a dispatch spawned reads as one shape. */
    .job-chain-icon { display: inline-flex; width: 14px; height: 14px; margin-right: 4px;
      color: var(--muted); vertical-align: -2px; flex-shrink: 0; }
    .job-chain-icon svg { width: 100%; height: 100%; }
    /* Self-healing job board (issue #167 widened yet again): why it died, in the
       muted voice of a byline, never competing with the red pill for attention. */
    .job-cause { color: var(--muted); font-size: 12px; }
    /* A paused-on-usage-limit row's own reset countdown, next to the "Paused" pill —
       same quiet byline voice as .job-cause, its own class since it isn't one. */
    .job-resets { color: var(--muted); font-size: 12px; }
    /* Stranded-branches section of the same view: its own heading (this list is no
       longer only jobs), and a row that opens the chat that stranded it — except when
       no chat could be recovered from the branch name, where it's read-only. */
    .jobs-section-title { font-size: 15px; font-weight: 600; color: var(--text); margin: 18px 0 4px; }
    .jobs-section-sub { font-size: 12px; color: var(--muted); margin-bottom: 10px; }
    .trash-item.job-stranded { cursor: pointer; }
    .trash-item.job-stranded.no-chat { cursor: default; }
    /* Resume + dismiss for a dead job stay off this row (issue #167 narrowed, then
       widened again): the row here is read-only — tap it to go to the chat, same as a
       running one — while running it again or dismissing it for good live on the ISSUE
       row instead, see .issue-resume / .issue-dismiss below. Showing the failure here
       is about being ABLE TO FIND it; deciding what to do about it is still the issue's. */
    /* Hot-mic edge glow (UX audit M11): while the mic is live the whole screen wears
       a pulsing ring — red for a message take, teal for a router take — so "am I
       being recorded?" reads from across a desk, tap-zone style, without covering
       anything. The tap zone itself sits above this (z 500). */
    #recEdge {
      position: fixed; inset: 0; pointer-events: none; z-index: 450; display: none;
      box-shadow: inset 0 0 0 4px rgba(255,69,58,0.55), inset 0 0 26px rgba(255,69,58,0.22);
    }
    html.rec-live #recEdge { display: block; animation: recEdgePulse 1.4s ease-in-out infinite; }
    html.rec-live.rec-router #recEdge {
      box-shadow: inset 0 0 0 4px rgba(43,217,199,0.55), inset 0 0 26px rgba(43,217,199,0.22);
    }
    /* Ambient command-listen is a hot mic too (privacy honesty), but it runs during
       every task wait — a calm, thin, non-pulsing teal line instead of the alarm ring. */
    html.rec-live.rec-ambient #recEdge {
      box-shadow: inset 0 0 0 2px rgba(43,217,199,0.3);
      animation: none;
    }
    @keyframes recEdgePulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } }
    .messages {
      flex: 1;
      overflow-y: auto;
      padding: 16px;
      display: flex;
      flex-direction: column;
      gap: 12px;
      -webkit-overflow-scrolling: touch;
    }
    /* The header no longer reserves its own row (see body:not(.desktop) .header above),
       so .messages now runs the full height behind it — this is what actually lets the
       first message scroll UP and under the blur. padding-top (not just a wrapper
       margin) keeps it inside the scrollable box, so scroll-padding-top can hold the
       same value: "scroll to top" or a jump-to-message lands below the header rather
       than tucked behind it, exactly like the fallback CSS pretends the header is,
       before --hdr-h has been measured once. */
    body:not(.desktop) .messages {
      padding-top: calc(var(--hdr-h, calc(env(safe-area-inset-top, 44px) + 68px)) + 8px);
      scroll-padding-top: calc(var(--hdr-h, calc(env(safe-area-inset-top, 44px) + 68px)) + 8px);
    }
    /* The dismissible hint banners sit in normal flow between the header and the thread.
       With the header out of flow they'd otherwise open flush against the top of the
       screen, partly hidden under it; give them the same clearance as the thread instead
       of teaching each one its own math. (.jobs-strip no longer needs this — it moved
       INSIDE .header, see the comment on .header above, so it gets the header's own
       padding for free and can never sit behind it.) */
    body:not(.desktop) .connect-hint {
      margin-top: var(--hdr-h, calc(env(safe-area-inset-top, 44px) + 68px));
    }
    /* Floating "jump to latest" button — shown only when scrolled up off the bottom.
       ABSOLUTE, not fixed: a fixed element anchors to the LAYOUT viewport, which the
       on-screen keyboard does not shrink — so with a keyboard up it sat the height of
       that keyboard below the app, behind it and out of reach. Absolute inside body
       anchors it to the app's own bottom edge, which IS the visible bottom (--app-vh).
       body is position:relative for exactly this. */
    .scroll-bottom-btn {
      position: absolute; right: 16px;
      bottom: calc(80px + env(safe-area-inset-bottom, 0px));
      width: 40px; height: 40px; border-radius: 50%;
      background: var(--surface); border: 0.5px solid var(--border);
      color: var(--text); z-index: 90; cursor: pointer;
      display: flex; align-items: center; justify-content: center;
      box-shadow: 0 2px 10px rgba(0,0,0,0.4);
      -webkit-tap-highlight-color: transparent;
      opacity: 0; transform: translateY(8px); pointer-events: none;
      transition: opacity 0.2s, transform 0.2s;
    }
    .scroll-bottom-btn.show { opacity: 1; transform: translateY(0); pointer-events: auto; }
    /* The step back into a chat's archive: sits at the very top of the thread, quiet
       enough not to read as a message of its own. */
    .earlier-bar {
      display: flex; align-items: center; justify-content: center; gap: 6px;
      width: 100%; margin: 4px 0 10px; padding: 8px 0;
      background: none; border: 0; color: var(--muted);
      font: inherit; font-size: 13px; cursor: pointer;
      -webkit-tap-highlight-color: transparent;
    }
    .earlier-bar:disabled { opacity: 0.5; cursor: default; }
    .earlier-bar svg { width: 15px; height: 15px; display: block; }
    .scroll-bottom-btn svg { width: 22px; height: 22px; display: block; }
    .empty-state {
      display: flex;
      flex-direction: column;
      align-items: center;
      justify-content: center;
      flex: 1;
      color: var(--muted);
      text-align: center;
      gap: 10px;
      padding: 40px;
      min-height: 200px;
    }
    .empty-state h2 { font-size: 20px; color: var(--text); }
    .empty-state p { font-size: 15px; line-height: 1.7; }
    /* Centered spinner shown while a chat's history is still being fetched, so a
       switch never looks like the history vanished. */
    .history-loading {
      display: flex; align-items: center; justify-content: center;
      flex: 1; min-height: 200px;
    }
    .history-loading .spinner {
      width: 34px; height: 34px; border-radius: 50%;
      border: 3px solid rgba(255, 255, 255, 0.15);
      border-top-color: var(--muted);
      animation: history-spin 0.8s linear infinite;
    }
    @keyframes history-spin { to { transform: rotate(360deg); } }
    .bubble {
      max-width: 85%;
      padding: 12px 16px;
      border-radius: 18px;
      font-size: 15px;
      line-height: 1.5;
      white-space: pre-wrap;
      word-break: break-word;
    }
    .bubble.tappable { cursor: pointer; -webkit-tap-highlight-color: transparent; }
    .bubble.tappable:active { opacity: .7; }
    .bubble.loading-audio { animation: bubblePulse 1s ease-in-out infinite; }
    @keyframes bubblePulse { 0%,100% { opacity: 1; } 50% { opacity: .55; } }
    /* Explicit "the audio is being generated" chip shown under a bubble during the TTS
       fetch, so switching into a chat that auto-plays its reply isn't just silence + a
       faint pulse (TTS loading issue). */
    .tts-generating {
      align-self: flex-start; display: inline-flex; align-items: center; gap: 8px;
      margin: 6px 0 2px; padding: 6px 12px; border-radius: 999px;
      background: var(--surface); border: 0.5px solid var(--border);
      color: var(--muted); font-size: 13px; font-weight: 500;
    }
    .tts-spinner {
      width: 13px; height: 13px; border-radius: 50%; flex-shrink: 0;
      border: 2px solid var(--border); border-top-color: var(--blue);
      animation: ttsSpin 0.7s linear infinite;
    }
    @keyframes ttsSpin { to { transform: rotate(360deg); } }
    .bubble.user {
      background: var(--user-bubble); border: 0.5px solid var(--user-border);
      color: var(--user-text); align-self: flex-end;
      border-radius: 18px 18px 6px 18px;
    }
    /* Said INTO a turn that was already running, not starting one of its own — worth
       showing, or a reply that answers two messages at once looks like a lost message. */
    .bubble.user.steered::before {
      content: '↪ steered'; display: block; font-size: 0.72em; opacity: 0.6; margin-bottom: 3px;
    }
    /* Spoken while that thread was busy and steering couldn't take it: it is waiting its
       turn, not sent. The label is written by JS (data-tag) rather than typed here, so it
       exists in both languages like every other string the user reads. */
    .bubble.user.queued { opacity: 0.72; border-style: dashed; }
    .bubble.user.queued::before {
      content: attr(data-tag); display: block; font-size: 0.72em; opacity: 0.75; margin-bottom: 3px;
    }
    .bubble.assistant { background: var(--surface); border: 0.5px solid var(--border); align-self: flex-start; border-radius: 18px 18px 18px 6px; }
    .bubble.assistant.relay { border-left: 3px solid var(--relay); }
    /* A reply relayed in from ANOTHER chat (the main chat runs work in the others and
       their answers arrive here). Marked down the edge like the relay bubble above,
       and headed by the chat it came from — a button, because its only job is to take
       you to where that reply actually lives. */
    .bubble.assistant.relayed, .bubble.error.relayed { border-left: 3px solid var(--relay); }
    /* The other direction: a message the MAIN chat dispatched into this one. Nobody in
       this thread said it, so it wears the main chat's magenta — the same colour its
       pill and crown wear — and is headed by the chat that sent it. This tint is
       --master's own RGB at low alpha (issue #172): it has to be typed out again by hand
       whenever --master's hex changes, same as every other rgba() literal in this file,
       so check it any time that value moves. */
    .bubble.user.relayed {
      background: rgba(209, 105, 224, 0.14); border-color: var(--master); color: var(--text);
    }
    .bubble.user.relayed .relay-from { color: var(--master); }
    .relay-from {
      display: flex; align-items: center; gap: 5px; margin: 0 0 5px;
      background: none; border: 0; padding: 0; cursor: pointer; color: var(--relay);
      font: inherit; font-size: 0.72em; opacity: 0.8; text-align: left;
    }
    .relay-from svg { width: 12px; height: 12px; flex: none; }
    /* The note left in the thread when work went OUT of it (issue #167 part 1). The
       relayed bubbles above are answers coming home; this is the record of the asking,
       kept at the point in the conversation where the user asked. Violet, like the
       strip and the master's dot — all three say the same thing, that work of this
       chat's is out there. It stays a system notice (quiet, not a turn in the thread)
       but goes full width and left-aligned, because it carries three lines. */
    .bubble.system.dispatched {
      align-self: stretch; text-align: left; background: #241A33; color: #BF8CFF;
      border: 0.5px solid #5B3E86; border-left: 3px solid #BF8CFF; border-radius: 10px;
    }
    .dispatch-to {
      display: flex; align-items: center; gap: 5px; width: 100%; color: inherit;
      background: none; border: 0; padding: 0; cursor: pointer;
      font: inherit; font-weight: 600; text-align: left;
    }
    .dispatch-to svg { width: 12px; height: 12px; flex: none; }
    /* What was actually sent — two lines of it. Enough to tell three dispatches apart
       without the note becoming a second copy of the prompt. */
    .dispatch-what {
      margin-top: 3px; opacity: 0.72; font-size: 0.92em;
      display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
    }
    /* …and what became of it, read live off the job board, so a card drawn an hour ago
       still says how that job ended rather than freezing on "sent". */
    .dispatch-state { margin-top: 4px; font-size: 0.86em; opacity: 0.9; }
    .dispatch-state.done { color: var(--accent); }
    .dispatch-state.error { color: #FF6B6B; }
    /* The honest receipt: never the same teal as a real 'done' above. */
    .dispatch-state.not_saved { color: #FF6B6B; }
    .dispatch-state.not_deployed { color: #E8B33F; }
    /* Same orange as .job-status.stalled on the jobs list — see the note there. */
    .dispatch-state.stalled { color: #FF9F0A; }
    .dispatch-state.paused { color: #E8B33F; }
    .dispatch-state.stopped { color: var(--muted); }
    /* A correction landed in this job mid-turn instead of vanishing or piling up a
       second one — the same "↪ steered" language the bubble above wears, so a job
       that absorbed one reads visibly differently from one that didn't (issue #179). */
    .steered-tag { opacity: 0.7; margin-left: 4px; }
    .job-status.steered::after { content: ' ↪'; opacity: 0.7; }
    /* Most dispatched work is an issue's work, so the card wears that issue's colour and
       carries its number (issue #167 part 5) — four cards that differ only by chat name
       are four cards nobody reads. Only the TINT follows the issue; the state line keeps
       its own colour, or a failed job would read green for sitting under a closed issue. */
    .dispatch-issue {
      margin-left: auto; font-size: 0.9em; font-weight: 700; opacity: 0.85;
      border: 0.5px solid currentColor; border-radius: 999px; padding: 0 6px;
    }
    .bubble.system.dispatched.issue-review { background: #2B2410; border-color: #7A6420; border-left-color: #E3B341; }
    .bubble.system.dispatched.issue-review .dispatch-to { color: #E3B341; }
    .bubble.system.dispatched.issue-done { background: #16241B; border-color: #2F5B3E; border-left-color: #3FB950; }
    .bubble.system.dispatched.issue-done .dispatch-to { color: #3FB950; }
    .bubble.error { background: #3a1212; align-self: flex-start; color: #ff6b6b; }
    .bubble.assistant p { margin: 0 0 8px; }
    .bubble.assistant p:last-child { margin-bottom: 0; }
    .bubble.assistant ul, .bubble.assistant ol { padding-left: 20px; margin: 0 0 8px; }
    .bubble.assistant li { margin-bottom: 4px; }
    .bubble.assistant code { background: var(--surface-2); border-radius: 4px; padding: 1px 5px; font-size: 13px; font-family: 'SF Mono', monospace; }
    .bubble.assistant pre { background: var(--surface-2); border-radius: 8px; padding: 10px 12px; overflow-x: auto; margin: 6px 0; }
    .bubble.assistant pre code { background: none; padding: 0; }
    .bubble.assistant h1, .bubble.assistant h2, .bubble.assistant h3 { margin: 8px 0 4px; font-size: 15px; }
    .bubble.assistant strong { font-weight: 600; }
    .bubble.assistant blockquote { border-left: 3px solid var(--muted); margin: 6px 0; padding-left: 10px; color: var(--muted); }
    /* Copy/Reply live in their own row below the text so they never share a line
       with the message (short user bubbles used to butt the chips against the text). */
    .bubble-actions { display: flex; gap: 6px; margin-top: 8px; }
    /* Subject labels. Their own row under the actions, quieter than the buttons: they
       are what this exchange is ABOUT, not something to do with it. Built after the
       spoken text is captured, like every other chip here, so they are never read out. */
    .label-chips { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 6px; }
    .label-chip { background: none; border: 1px solid var(--muted); color: var(--muted); font-size: 11px; line-height: 1.5; padding: 1px 8px; border-radius: 999px; cursor: pointer; display: inline-flex; align-items: center; gap: 4px; opacity: .8; -webkit-tap-highlight-color: transparent; }
    .label-chip:active { opacity: .5; }
    .label-chip .ico svg { width: 11px; height: 11px; }
    .bubble.user .label-chip { border-color: rgba(200,245,238,0.4); color: var(--user-text); }
    /* The labels list itself, on the issues page. Rows are the tag, the name, and how
       many exchanges are under it — the count is the only thing that says whether a
       label is worth keeping. */
    .label-row .ti-title { display: flex; align-items: center; gap: 7px; }
    .label-count { color: var(--muted); font-size: 12px; flex: none; margin-left: auto; padding-left: 8px; }
    .label-block { border-left: 2px solid var(--surface-2); padding: 8px 0 8px 12px; margin-bottom: 10px; cursor: pointer; }
    .label-block:active { opacity: .6; }
    .label-block .lb-where { color: var(--muted); font-size: 11px; margin-bottom: 3px; display: flex; gap: 6px; }
    .label-block .lb-ask { font-size: 13px; margin-bottom: 3px; }
    .label-block .lb-reply { color: var(--muted); font-size: 12.5px; }
    .label-back { background: none; border: 1px solid var(--muted); color: var(--muted); font-size: 12px; padding: 3px 10px; border-radius: 6px; cursor: pointer; margin-bottom: 10px; display: inline-flex; align-items: center; gap: 5px; }
    .label-merge { border: 1px solid var(--muted); border-radius: 8px; padding: 8px 10px; margin-bottom: 10px; font-size: 12.5px; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
    .label-merge .lm-do, .label-merge .lm-no { background: none; border: 1px solid var(--muted); color: var(--muted); font-size: 12px; padding: 2px 9px; border-radius: 6px; cursor: pointer; }
    .bubble.user .bubble-actions { justify-content: flex-end; }
    .copy-btn { background: none; border: 1px solid var(--muted); color: var(--muted); font-size: 12px; padding: 3px 10px; border-radius: 6px; cursor: pointer; display: inline-flex; align-items: center; gap: 5px; -webkit-tap-highlight-color: transparent; }
    .copy-btn:active { opacity: .55; }
    /* On the teal-tinted user bubble the muted grey chip is low-contrast — tint it. */
    .bubble.user .copy-btn { border-color: rgba(200,245,238,0.4); color: var(--user-text); }
    /* On the dark-red error bubble, tint the Copy chip to match so it reads clearly. */
    .bubble.error .copy-btn { border-color: rgba(255,107,107,0.5); color: #ff6b6b; }
    /* Stop/resume the usage-limited turn, sitting right under the notice that announces
       it (issue #147). Full-width and thumb-sized, not a 12px chip: this is the one
       control the user wants at the moment his chat is stuck, and it's reached one-handed. */
    .usage-act { display: flex; justify-content: center; margin: 2px 0 12px; }
    .usage-act-btn { background: rgba(255,107,107,0.14); border: 1px solid rgba(255,107,107,0.55); color: #ff6b6b;
      font-size: 15px; font-weight: 600; padding: 10px 18px; border-radius: 999px; cursor: pointer;
      -webkit-tap-highlight-color: transparent; }
    .usage-act-btn.resume { background: rgba(43,217,199,0.14); border-color: rgba(43,217,199,0.55); color: var(--accent); }
    .usage-act-btn:active { opacity: .55; }
    .usage-act-btn:disabled { opacity: .4; }
    /* The other two trailing rows share that shape, and differ only in how loudly they
       ask to be pressed. The go-ahead is the loudest control in the app — a plan that
       nobody approves is work that never happens — while Rewind is deliberately quiet:
       it sits under every finished turn, so it must read as available, not as inviting. */
    .turn-act { display: flex; justify-content: center; margin: 2px 0 12px; }
    .turn-act-btn { background: rgba(43,217,199,0.16); border: 1px solid rgba(43,217,199,0.6); color: var(--accent);
      font-size: 15px; font-weight: 600; padding: 10px 18px; border-radius: 999px; cursor: pointer;
      -webkit-tap-highlight-color: transparent; }
    .turn-act-btn.quiet { background: none; border-color: var(--muted); color: var(--muted); font-size: 13px;
      font-weight: 500; padding: 6px 14px; }
    .turn-act-btn:active { opacity: .55; }
    .turn-act-btn:disabled { opacity: .4; }
    /* Reply chip sits next to Copy; same low-key styling. */
    .reply-btn { background: none; border: 1px solid var(--muted); color: var(--muted); font-size: 12px; padding: 3px 10px; border-radius: 6px; cursor: pointer; display: inline-flex; align-items: center; gap: 5px; -webkit-tap-highlight-color: transparent; }
    .reply-btn:active { opacity: .55; }
    .bubble.user .reply-btn { border-color: rgba(200,245,238,0.4); color: var(--user-text); }
    /* Quoted message shown at the top of a reply bubble (WhatsApp-style). */
    .bubble-quote { border-left: 3px solid currentColor; padding: 2px 0 2px 8px; margin-bottom: 6px; opacity: .8; font-size: 13px; max-height: 3.4em; overflow: hidden; }
    .bubble-quote .bq-who { display: block; font-weight: 600; font-size: 11px; opacity: .85; margin-bottom: 1px; }
    .bubble-quote .bq-text { display: block; white-space: pre-wrap; overflow: hidden; text-overflow: ellipsis; }
    /* An answer that arrived out of order (issue #162): its quote is tappable and jumps
       to the question, and the bubble is marked so the eye can tell a branch from the
       next thing said. */
    .bubble-quote.tappable { cursor: pointer; }
    .bubble-quote.tappable:hover { opacity: 1; }
    .bubble.threaded { border-left: 2px solid var(--blue); }
    .bubble.flash { animation: bubbleFlash 1.2s ease-out; }
    /* The message you are answering RIGHT NOW, while the reply bar is open. The bar
       named the message but nothing on screen pointed at it, so there was no visible
       link between the two — this is that link, and it goes when the reply is sent. */
    .bubble.reply-target { outline: 2px solid var(--blue); outline-offset: 3px; }
    /* A message that has been answered. The mark sits in the bubble's own action row
       and jumps DOWN to the answer, so the link reads in both directions: the reply
       quotes its question, the question points at its reply. */
    .reply-mark { background: none; border: 1px solid var(--blue); color: var(--blue);
      border-radius: 6px; padding: 3px 8px; cursor: pointer; display: inline-flex;
      align-items: center; -webkit-tap-highlight-color: transparent; }
    .reply-mark:active { opacity: .55; }
    @keyframes bubbleFlash { 0%, 60% { background: var(--blue); } 100% { background: inherit; } }
    .audio-player {
      align-self: stretch;               /* full width → widest possible scrubber */
      display: flex;
      flex-direction: column;
      gap: 10px;
      background: var(--surface);
      border-radius: 16px;
      padding: 12px 14px;
      margin-top: -4px;
    }
    .ap-seek {
      width: 100%;
      accent-color: var(--blue);
      height: 6px;
      margin: 2px 0;
    }
    /* Transport grid: ±3 on the top row, ±10 on the bottom, Play centered and
       spanning both — separating the 3s from the 10s reduces mis-taps.
         [ -3 ] [ ▶ ] [ +3 ]
         [ -10] [ ▶ ] [ +10] */
    .ap-transport {
      display: grid;
      grid-template-columns: 1fr auto 1fr;
      grid-template-areas: "b3 play f3" "b10 play f10";
      gap: 8px;
      align-items: center;
    }
    .ap-play {
      grid-area: play;
      justify-self: center;
      background: var(--blue);
      border: none;
      color: var(--accent-ink);
      border-radius: 50%;
      width: 60px; height: 60px;
      margin: 0 10px;
      font-size: 19px;
      cursor: pointer;
      -webkit-tap-highlight-color: transparent;
      display: flex; align-items: center; justify-content: center;
    }
    .ap-skip {
      height: 46px;
      background: var(--surface-2);
      border: none;
      border-radius: 12px;
      color: #fff;
      font-size: 15px;
      font-weight: 600;
      cursor: pointer;
      -webkit-tap-highlight-color: transparent;
    }
    .ap-skip[data-skip="-3"]  { grid-area: b3; }
    .ap-skip[data-skip="3"]   { grid-area: f3; }
    .ap-skip[data-skip="-10"] { grid-area: b10; }
    .ap-skip[data-skip="10"]  { grid-area: f10; }
    .ap-skip:active, .ap-play:active { filter: brightness(1.3); }
    .ap-meta { display: flex; align-items: center; justify-content: space-between; padding: 0 2px; }
    .ap-time { color: var(--muted); font-size: 12px; }
    .ap-replay {
      background: none; border: none; color: var(--muted);
      font-size: 13px; cursor: pointer; padding: 4px 6px;
      -webkit-tap-highlight-color: transparent;
    }
    .typing {
      align-self: flex-start;
      display: flex;
      /* The status can run to three rows, so the dots and the timer sit on the FIRST
         row rather than floating to the middle of the block. */
      align-items: flex-start;
      gap: 10px;
      color: var(--muted);
      font-size: 14px;
      padding: 4px 0;
      /* The label now carries a live status of unknown length (issue #145), so the row
         has to be bounded by the screen — otherwise it grows off the right edge instead
         of ellipsising. */
      max-width: 100%;
    }
    .dots { display: flex; gap: 4px; flex: none; }
    .typing .dots { margin-top: 6px; }   /* centred on the status's first row, not the block */
    .dot {
      width: 6px; height: 6px;
      border-radius: 50%;
      background: var(--muted);
      animation: bounce 1.2s infinite;
    }
    .dot:nth-child(2) { animation-delay: 0.2s; }
    .dot:nth-child(3) { animation-delay: 0.4s; }
    @keyframes bounce {
      0%, 60%, 100% { transform: translateY(0); }
      30% { transform: translateY(-6px); }
    }

    .input-row {
      display: flex;
      flex-direction: column;
      align-items: stretch;
      padding: 10px 12px;
      /* --app-safe-b is the measured home-indicator inset MINUS whatever of it already
         falls outside body (see the head script) — so the composer keeps its comfortable
         gap above the home indicator without reserving a second, empty one. env() is the
         pre-script fallback only. The flat term below is deliberately small (was 10px,
         read as an airy second gap stacked on top of the inset) — just enough that the
         usage strip never touches the edge on a device with no inset at all. */
      padding-bottom: calc(4px + var(--app-safe-b, env(safe-area-inset-bottom, 0px)));
      gap: 8px;
      border-top: 0.5px solid var(--border);
      flex-shrink: 0;
      background: var(--bg);
    }
    /* The WHOLE composer on one line: attach, the field, the compass, send/mic. The
       buttons used to sit in a second row underneath, which cost a row of height and put
       the mic further from the thumb for no gain. flex-end keeps every button on the
       bottom edge as the field grows into its several lines. */
    .compose-line { display: flex; align-items: flex-end; gap: 8px; width: 100%; }
    /* Plus and textarea share one pill so the input looks larger. */
    .field-wrap { display: flex; align-items: flex-end; flex: 1; background: var(--surface); border-radius: 22px; min-width: 0; }
    /* The field yields the width, not the controls: without this a textarea refuses to
       shrink past its default column width and pushes the mic off a 320px screen. */
    .compose-line textarea { min-width: 0; }
    /* Narrow glass (iPhone SE and friends): three round controls plus the field on one
       line only fit if the controls give up a few pixels each. Still over the 40px
       minimum touch target. */
    @media (max-width: 380px) {
      .compose-line { gap: 6px; }
      .compose-line .mic-btn, .compose-line .send-btn, .compose-line .attach-btn { width: 40px; height: 40px; }
      .compose-line .mic-btn svg, .compose-line .send-btn svg, .compose-line .attach-btn svg { width: 20px; height: 20px; }
    }
    /* Subscription usage strip (issue #93): one compact BAND under the buttons, all on
       one horizontal line (2026-09-03, issue: usage display compaction) — pickers, the
       (at most two) bucket lines and the refresh control side by side, sharing one
       vertical centre, roughly the height of the pickers themselves. Nothing wraps to
       its own row: at the narrowest supported width the labels shrink instead (see the
       360px rule below), because a staircase of alternating-side rows is what this
       replaced. */
    .usage-strip {
      display: flex; flex-direction: row; flex-wrap: nowrap; align-items: center;
      justify-content: space-between; gap: 8px; margin-top: 8px; min-width: 0;
    }
    /* Quick model / effort pickers sit at the LEFT of the strip, so the two settings
       that change most often are one tap away from the input row. Allowed to shrink
       (never to grow) so the width goes to the bucket readout and refresh control first. */
    .us-picks { display: flex; align-items: center; gap: 8px; min-width: 0; flex: 0 1 auto; }
    /* One bucket per row (there are at most two: 5h and 7d) instead of free-wrapping —
       a compact column that sits BESIDE the pickers on the same band, not underneath
       them. Never shrinks past its own content — the reset countdown is what gives way
       first (see the 360px rule). */
    /* Both lines are stretched to the block's own width and start at the SAME left edge
       rather than being right-aligned one by one: the reset countdowns are ragged
       ("resets in 4h 20m" against "resets in 2d 6h"), so right-alignment put every line's
       bar at its own x and the two bars never sat above one another. */
    .us-buckets {
      display: flex; flex-direction: column; align-items: stretch; justify-content: center;
      gap: 2px; flex: 0 0 auto;
    }
    /* The native select arrow renders far too dark against this dark strip to read at
       all — a custom one, drawn in the same grey as the picker's own label text, takes
       its place. appearance:none clears the native glyph everywhere so the swap is
       reliable across engines, not just the ones that would have let a colour rule
       reach it. Only the GLYPH itself is enlarged and made heavier (10x6 -> 13x8 ->
       17x11, stroke 2.5 -> 3.2 -> 3.8) — the field's own min-height and max-width are
       untouched, so the pill stays exactly its original size; growing the whole field
       to a 44pt tap target was tried and reverted, the user wanted the arrow bigger,
       not the box taller (only the text-side padding shifted a couple px to give the
       now-larger glyph room without crowding the label). Every dropdown that shares
       this drawn-chevron look (currently: quickModel, quickEffort — both `.us-pick`)
       gets this for free, one rule for both, instead of a fix that only reaches
       whichever one someone pointed at. */
    .us-pick {
      font-family: inherit; font-size: 12px; line-height: 1.2; color: var(--muted);
      background-color: var(--surface); border: 1px solid var(--border); border-radius: 9px;
      padding: 6px 21px 6px 8px; min-height: 32px; max-width: 132px; outline: none;
      min-width: 0;
      appearance: none; -webkit-appearance: none; -moz-appearance: none;
      background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%238A8F98' stroke-width='3.8' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
      background-repeat: no-repeat; background-position: right 3px center; background-size: 17px 11px;
    }
    /* Each bucket line is a single row now — no internal wrapping — so a bucket can
       never itself expand past its one row. */
    .us-item { display: flex; align-items: center; gap: 5px; font-size: 11px; color: var(--muted); flex-wrap: nowrap; white-space: nowrap; }
    /* Label and percentage are fixed, right-aligned columns so the numbers land at the
       same x on both lines: "5h" is narrower than "wk", "7%" narrower than "12%", and
       without a column of their own those few pixels pushed one line out of line with
       the other. Sized in em (so it follows the type, not a hard-coded pixel) to the
       widest reading either can hold — a three-letter label, and "100%". No bar: the
       percentage figure is the only visual, colored by severity instead of filled. */
    .us-lbl { flex: none; min-width: 2.1em; text-align: right; }
    .us-pct { flex: none; min-width: 2.7em; text-align: right; font-variant-numeric: tabular-nums; }
    .us-pct.warn { color: #ff9f0a; }
    .us-pct.crit { color: #FF453A; }
    .us-reset { color: var(--muted); opacity: .75; }
    /* The refresh button + "updated N ago" line: its own small slot at the right end of
       the same band, not a row of its own. */
    .us-refresh { display: flex; align-items: center; gap: 6px; flex: 0 0 auto; }
    .us-age { font-size: 10px; color: var(--muted); opacity: .7; white-space: nowrap; }
    /* No phone this app targets (320px through the largest iPhone, ~430px) has room for
       every last detail on one line beside the pickers without wrapping or squeezing
       "Sonnet 5" down to "Son…" — the "updated N ago" text is the one thing dropped (the
       refresh button alone still works without it). The reset countdown stays: it's the
       one figure the user has to read without tapping anything, so at the narrowest width
       (see fmtResetIn, JS) it loses the "resets in" wording and shows the bare time
       instead of disappearing. Well under the ~900px desktop breakpoint, so the wide
       reading column keeps full detail. */
    @media (max-width: 480px) {
      .us-age { display: none; }
    }
    /* Even without that detail, the very narrowest glass (320px, iPhone SE) still needs
       the pickers themselves a little narrower to keep everything on one line. */
    @media (max-width: 360px) {
      .us-pick { max-width: 84px; padding: 6px 19px 6px 6px; background-position: right 2px center; }
    }
    .us-refresh-btn {
      display: inline-flex; align-items: center; justify-content: center;
      width: 22px; height: 22px; flex: none; padding: 0;
      background: transparent; border: none; border-radius: 50%; color: var(--muted); cursor: pointer;
    }
    .us-refresh-btn svg { width: 13px; height: 13px; display: block; }
    .us-refresh-btn:disabled { opacity: .5; cursor: default; }
    textarea {
      flex: 1;
      background: transparent;
      color: var(--text);
      border: none;
      border-radius: 22px;
      padding: 10px 16px 10px 4px;
      font-size: 16px;
      font-family: inherit;
      resize: none;
      outline: none;
      max-height: 120px;
      overflow-y: auto;
      -webkit-appearance: none;
      line-height: 1.4;
    }
    textarea::placeholder { color: var(--muted); }
    /* Send is a round glyph the size of the mic, because it stands in the MIC'S place:
       at rest the row ends with the mic, and the instant there is a take running or
       something to send the mic steps aside and this takes the same spot. Same circle,
       same 44px, different glyph — so it reads as one button changing its mind rather
       than two buttons trading places. The caption it used to carry ("Send" / "Stop")
       now lives in its aria-label and title, written by refreshSendBtn. */
    .send-btn {
      background: var(--blue);
      border: none;
      border-radius: 50%;
      width: 44px; height: 44px;
      padding: 0;
      color: var(--accent-ink);
      cursor: pointer;
      -webkit-tap-highlight-color: transparent;
      display: flex; align-items: center; justify-content: center;
      flex-shrink: 0;
    }
    .send-btn svg { width: 22px; height: 22px; display: block; }
    .send-btn:disabled { background: var(--surface); color: var(--muted); opacity: .55; }   /* m13 */
    .send-btn.stop { background: #FF453A; color: #fff; }
    .bubble.system { background: var(--surface-2); align-self: center; color: var(--muted); font-size: 13px; }
    /* A committed voice send still in flight (recorded, Send pressed, not yet
       delivered) — issue #169. Same shape as .system, dashed to read as provisional;
       it's replaced or removed the instant the take resolves. */
    .bubble.pending { background: var(--surface-2); align-self: center; color: var(--muted); font-size: 13px; border: 1px dashed var(--muted); opacity: .8; }
    /* Issue-context card seeded at the top of an issue's chat — reads as pinned
       context, not as something Claude said. */
    .bubble.context {
      align-self: stretch; background: #0F2724; border: 1px solid var(--user-border);
      border-left: 3px solid var(--blue); color: #C9E8E2; font-size: 13.5px;
      border-radius: 12px; line-height: 1.4;
    }
    .mic-btn {
      background: var(--blue);
      border: none;
      border-radius: 50%;
      width: 44px; height: 44px;
      color: var(--accent-ink);
      font-size: 19px;
      cursor: pointer;
      flex-shrink: 0;
      -webkit-tap-highlight-color: transparent;
      display: flex; align-items: center; justify-content: center;
    }
    .mic-btn:disabled { opacity: 0.4; }
    .attach-btn {
      background: transparent; border: none; border-radius: 0;
      width: 44px; height: 44px; color: var(--muted); cursor: pointer; flex-shrink: 0;
      -webkit-tap-highlight-color: transparent;
      display: flex; align-items: center; justify-content: center;
    }
    .attach-btn:active { color: #fff; }
    /* During recording the attach button becomes a red X that discards the take. */
    .attach-btn.is-cancel { color: #FF453A; }
    .attach-btn svg { width: 22px; height: 22px; }
    .img-preview { align-items: center; gap: 12px; padding: 10px 14px 2px; flex-wrap: wrap; overflow-x: auto; }
    .att-chip { position: relative; display: inline-flex; align-items: center; flex-shrink: 0; }
    .ip-thumb { width: 56px; height: 56px; object-fit: cover; border-radius: 10px; border: 0.5px solid var(--border); display: block; }
    .att-file {
      font-size: 12px; color: var(--text); background: var(--surface); border: 0.5px solid var(--border);
      border-radius: 10px; padding: 10px 12px; max-width: 160px; overflow: hidden;
      text-overflow: ellipsis; white-space: nowrap;
    }
    .att-chip .ip-remove {
      position: absolute; top: -7px; right: -7px; width: 20px; height: 20px; border-radius: 50%;
      border: 0.5px solid var(--border); background: #000; color: #fff; cursor: pointer; font-size: 11px;
      display: flex; align-items: center; justify-content: center; -webkit-tap-highlight-color: transparent;
    }
    /* Composer reply bar: quoted message shown above the input while composing a reply. */
    .reply-preview { display: flex; align-items: center; gap: 10px; padding: 8px 14px 2px; }
    .reply-preview .rp-body { flex: 1; min-width: 0; border-left: 3px solid var(--accent, #2563eb); padding-left: 10px; }
    .reply-preview .rp-label { display: block; font-size: 11px; font-weight: 600; color: var(--muted); }
    .reply-preview .rp-text { display: block; font-size: 13px; color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
    .reply-preview .rp-cancel { flex-shrink: 0; width: 26px; height: 26px; border-radius: 50%; border: 0.5px solid var(--border); background: var(--surface); color: var(--text); cursor: pointer; font-size: 15px; line-height: 1; -webkit-tap-highlight-color: transparent; }
    .bubble-time { font-size: 11px; opacity: .55; margin-top: 5px; text-align: right; }
    .bubble.assistant .bubble-time, .bubble.error .bubble-time { text-align: left; }
    .bubble-tokens { font-variant-numeric: tabular-nums; opacity: .9; cursor: help; }
    .bubble-atts { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 6px; }
    .bubble-img { display: block; max-width: 100%; max-height: 320px; border-radius: 10px; cursor: pointer; }
    .bubble-atts .bubble-img { max-width: 200px; max-height: 200px; object-fit: cover; }
    .file-chip {
      font-size: 12px; color: var(--text); background: rgba(255,255,255,0.08);
      border: 0.5px solid var(--border); border-radius: 8px; padding: 6px 10px; word-break: break-all;
    }
    .mic-btn.recording { background: #FF453A; color: #fff; animation: recpulse 1.4s infinite; }
    /* Dedicated fast-router mic: tinted distinct from the main mic. */
    .router-mic {
      background: var(--surface); border: 1px solid var(--blue);
      color: var(--blue); font-size: 20px;
    }
    /* While a take is hot the compass becomes a second Stop rather than vanishing
       (see setRecUI). Outlined red, not filled: the pulsing mic beside it is the
       recording itself, this is the quieter way to end it. */
    .router-mic.as-stop { border-color: #FF453A; color: #FF453A; }
    .router-mic.as-stop:active { background: rgba(255,69,58,.16); }
    @keyframes recpulse {
      0%, 100% { box-shadow: 0 0 0 0 rgba(255,59,48,0.5); }
      50% { box-shadow: 0 0 0 9px rgba(255,59,48,0); }
    }
    .rec-bar {
      display: none;
      align-items: center;
      gap: 8px;
      padding: 7px 16px;
      font-size: 13px;
      color: var(--muted);
      border-top: 0.5px solid var(--border);
      background: var(--bg);
      flex-shrink: 0;
    }
    .rec-dot { width: 10px; height: 10px; border-radius: 50%; background: #FF453A; animation: recblink 1s infinite; }
    @keyframes recblink { 0%, 100% { opacity: 1; } 50% { opacity: 0.25; } }
    .rec-hint { color: var(--muted); margin-left: auto; }   /* m13: #555 was ~2.6:1 — the ONE instruction shown while recording */
    /* Transcribing state: blue dot instead of the red recording dot, and no
       "tap to stop" hint (the mic is already released and STT is running). */
    .rec-bar.transcribing .rec-dot { background: var(--blue); }
    .rec-bar.transcribing .rec-hint { display: none; }
    /* Fast-router recording: blue dot + blue bold label so it's unmistakably a
       different mode from a normal red "Recording…" message turn. */
    .rec-bar.router .rec-dot { background: var(--blue); }
    .rec-bar.router #recLabel { color: var(--blue); font-weight: 600; }
    .overlay {
      position: fixed;
      inset: 0;
      background: var(--bg);
      z-index: 100;
      display: flex;
      flex-direction: column;
      padding: calc(env(safe-area-inset-top, 44px) + 20px) 20px 40px;
      overflow-y: auto;
      -webkit-overflow-scrolling: touch;
    }
    .overlay-close {
      position: fixed;
      top: calc(env(safe-area-inset-top, 44px) + 12px);
      right: 16px;
      width: 40px; height: 40px; border-radius: 50%;
      background: var(--surface); color: var(--text);
      border: none; font-size: 20px; line-height: 1; cursor: pointer;
      display: flex; align-items: center; justify-content: center;
      z-index: 2;
    }
    .overlay h1 { font-size: 28px; font-weight: 600; margin-bottom: 8px; }
    .overlay .subtitle { color: var(--muted); font-size: 15px; margin-bottom: 36px; }
    /* ── First-run coach-mark tour ────────────────────────────────────────────
       Five steps, once, to the first spoken exchange. The backdrop is one element
       whose box-shadow IS the cutout — a huge spread in the shadow, rather than a
       second "hole" element, so there is never a seam between the dimmed page and
       the highlighted control. The cutout itself carries pointer-events:none so a
       real gesture (step 5's mic hold) reaches the real control underneath
       untouched; only the backdrop and the card capture taps. */
    .coach-overlay { position: fixed; inset: 0; z-index: 400; display: none; }
    .coach-overlay.show { display: block; }
    .coach-cutout {
      position: fixed;
      border-radius: 14px;
      box-shadow: 0 0 0 9999px rgba(0,0,0,0.72);
      pointer-events: none;
      transition: top .18s ease, left .18s ease, width .18s ease, height .18s ease;
    }
    /* No anchor matched (or the step is the full-screen hand-off) → dim everything,
       point at nothing. */
    .coach-cutout.coach-none { box-shadow: 0 0 0 9999px rgba(0,0,0,0.72); border-radius: 0; }
    .coach-card {
      position: fixed;
      max-width: 340px;
      width: calc(100% - 32px);
      left: 16px;
      background: var(--surface-2);
      border: 1px solid var(--border);
      border-radius: 16px;
      padding: 16px 18px;
      box-shadow: 0 12px 32px rgba(0,0,0,0.45);
    }
    .coach-step { color: var(--muted); font-size: 12px; font-weight: 600; letter-spacing: .02em; margin-bottom: 6px; }
    .coach-text { font-size: 15px; line-height: 1.4; margin-bottom: 14px; }
    .coach-row { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
    .coach-skip {
      background: none; border: none; color: var(--muted); font-size: 14px;
      padding: 8px 4px; cursor: pointer;
    }
    .coach-next {
      background: var(--accent); color: var(--accent-ink); border: none;
      border-radius: 10px; padding: 9px 18px; font-size: 14px; font-weight: 600; cursor: pointer;
    }
    .coach-hint { color: var(--muted); font-size: 13px; text-align: center; }
    @media (min-width: 900px) {
      .coach-card { left: auto; max-width: 320px; }
    }
    /* ── "Connect a project" hint (docs/LAUNCH-FIRST-RUN.md "First launch") ─────
       A dismissible card, not a modal — it only appears above the composer AFTER
       the user has had at least one real exchange in the current project, never
       before and never blocking. Same visual language as the coach-card above. */
    .connect-hint {
      display: none;
      max-width: 640px;
      margin: 0 auto 8px;
      background: var(--surface-2);
      border: 1px solid var(--border);
      border-radius: 14px;
      padding: 12px 14px;
      align-items: center;
      gap: 12px;
    }
    .connect-hint.show { display: flex; }
    .connect-hint-txt { flex: 1; font-size: 13px; line-height: 1.35; color: var(--text); }
    .connect-hint-actions { display: flex; align-items: center; gap: 6px; flex-shrink: 0; }
    .connect-hint-go {
      background: var(--accent); color: var(--accent-ink); border: none;
      border-radius: 9px; padding: 7px 12px; font-size: 13px; font-weight: 600; cursor: pointer;
      white-space: nowrap;
    }
    .connect-hint-close {
      background: none; border: none; color: var(--muted); padding: 6px; cursor: pointer;
      display: flex; align-items: center; justify-content: center;
      /* Sized explicitly: an inline <svg> carrying only a viewBox lays out at 0x0
         here, so the dismiss X shipped invisible and untappable — the banner looked
         like it could only be answered by connecting (2026-08-29). */
      width: 32px; height: 32px; border-radius: 8px; flex-shrink: 0;
    }
    .connect-hint-close svg { width: 18px; height: 18px; display: block; }
    .connect-hint-close:active { background: var(--surface); color: var(--text); }
    /* ── Reply artifacts (issue #143) ───────────────────────────────────────
       A reply worth READING rather than hearing goes to the screen. It used to
       arrive as an emoji chip you had to trust and tap; now the bubble shows the
       artifact ITSELF — live, clipped to a card — and the tap only makes it bigger.
       Same component in the bubble and in the artifacts list, so nothing anywhere
       represents a table by a picture of a table. */
    .bubble-artifacts { display: flex; flex-direction: column; gap: 8px; margin-top: 10px; }
    /* A bubble carrying a card is mostly card, so it gets the width back. */
    .bubble.has-artifacts { max-width: 96%; }
    .art-card {
      display: block; width: 100%; padding: 0; overflow: hidden; text-align: left;
      background: var(--surface); border: 1px solid var(--border);
      border-radius: 14px; color: var(--text); cursor: pointer;
      -webkit-tap-highlight-color: transparent;
      transition: transform .12s ease, border-color .12s ease;
    }
    .art-card:active { transform: scale(.985); border-color: var(--accent); }
    /* The live preview. Clipped, inert (no taps reach inside), and faded out at the
       bottom edge so a long artifact reads as "there is more", not as a hard cut. */
    .art-card-view {
      position: relative; height: 186px; overflow: hidden;
      background: var(--surface); pointer-events: none;
    }
    .art-card-view.tall { height: 240px; }
    .art-card-view::after {
      content: ''; position: absolute; left: 0; right: 0; bottom: 0; height: 46px;
      background: linear-gradient(to bottom, transparent, var(--surface));
    }
    /* Prose and code shrink to what they are — a four-row table shouldn't sit in a
       fixed box with a third of it empty. Markup keeps the fixed height: the scaled
       iframe inside is sized as a percentage of it. */
    .art-card-view.fit { height: auto; max-height: 186px; }
    .art-card-view.tall.fit { height: auto; max-height: 240px; }
    .art-card-view.nofade::after { display: none; }   /* nothing is cut off, so don't imply it */
    .art-card-inner { padding: 12px 14px; font-size: 13px; line-height: 1.5; }
    /* html/svg are authored at page width, so they get scaled down to a true
       thumbnail rather than cropped to their top-left corner. */
    .art-card-scale {
      width: 250%; height: 250%; transform: scale(.4); transform-origin: top left;
      padding: 0; border: 0;
    }
    /* Two selectors deep on purpose: in the artifacts list these cards sit INSIDE
       .art-body, whose own table/pre/iframe rules would otherwise win on order. */
    .art-card .art-card-scale iframe { width: 100%; height: 100%; min-height: 0; border: 0; background: #fff; }
    .art-card .art-card-inner pre {
      margin: 0; white-space: pre-wrap; word-break: break-word;
      font-family: 'SF Mono', monospace; font-size: 12px; line-height: 1.5;
    }
    .art-card .art-card-inner table { border-collapse: collapse; width: 100%; font-size: 12px; }
    .art-card .art-card-inner th, .art-card .art-card-inner td { border: 1px solid var(--border); padding: 5px 8px; text-align: left; }
    .art-card .art-card-inner th { background: var(--surface-2); }
    .art-card .art-card-inner h1, .art-card .art-card-inner h2, .art-card .art-card-inner h3 { margin: 8px 0 4px; font-size: 15px; }
    .art-card .art-card-inner h1:first-child, .art-card .art-card-inner h2:first-child, .art-card .art-card-inner h3:first-child { margin-top: 0; }
    .art-card .art-card-inner ul, .art-card .art-card-inner ol { margin: 4px 0 4px 18px; }
    .art-card .art-card-inner p { margin: 4px 0; }
    .art-card .art-card-inner img { max-width: 100%; border-radius: 8px; }
    .art-card .art-card-inner code { background: var(--surface-2); border-radius: 4px; padding: 1px 4px; font-family: 'SF Mono', monospace; }
    /* A diff (parity step 5). Each line is its own span so the colour is the line's,
       not a highlight over the text: added green, removed red, hunk headers accent,
       file headers faint. Tinted backgrounds run the full width, which is what makes
       a diff scannable at a glance on a phone rather than something to read. */
    .art-diff { display: block; }
    .art-diff .dl { display: block; }
    .art-diff .dm { color: var(--muted); }
    .art-diff .dh { color: var(--blue); background: rgba(43, 217, 199, .08); }
    .art-diff .da { color: #7EE787; background: rgba(46, 160, 67, .16); }
    .art-diff .dd { color: #FF7B72; background: rgba(248, 81, 73, .15); }
    /* Before the content arrives — and if it never does — the card shows the type
       glyph alone, centred and faint. A drawing of a table for the half-second before
       the table itself, and no words to read in a slot that is about to be replaced. */
    .art-card-view.loading {
      display: flex; align-items: center; justify-content: center;
      min-height: 92px; color: var(--muted); opacity: .5;
    }
    .art-card-view.loading .art-glyph { width: 34px; height: 34px; }
    .art-card-view.loading::after { display: none; }   /* nothing is clipped yet — don't imply it */
    .art-card-bar {
      display: flex; align-items: baseline; gap: 8px;
      padding: 9px 13px 11px; border-top: 1px solid var(--border); background: var(--surface-2);
    }
    .art-card-bar .art-name { flex: 1; min-width: 0; font-weight: 600; font-size: 14px;
                              overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
    .art-card-bar .art-sub { color: var(--muted); font-size: 11.5px; flex: none; }
    /* The drawn glyphs. They carry no colour of their own — they take whatever they
       sit in — so a tool goes red with its own danger state and inverts inside an
       active pill, which the emoji they replaced could never do. */
    .art-icon { display: flex; align-items: center; flex: none; color: var(--muted); }
    .art-glyph { display: block; flex: none; }
    #artifactOverlay { padding-bottom: 20px; }
    /* ── One artifact, open ─────────────────────────────────────────────────
       Reading an artifact is the whole point of the pane, so when a single one is
       open it gets the entire screen: no side padding anywhere down the chain, no
       card inset, no rounded frame — the content runs edge to edge and fills the
       height, and the chrome above it shrinks to one slim line. The LIST keeps its
       comfortable padding; only `.art-one` (set by renderArtifact) is full-bleed. */
    #artifactOverlay.art-one {
      padding: 0; overflow: hidden;   /* the body scrolls, not the sheet */
    }
    #artifactOverlay.art-one .art-head {
      margin: 0 48px 0 0;
      padding: calc(env(safe-area-inset-top, 44px) + 10px) 0 6px 16px;
    }
    #artifactOverlay.art-one .art-title { font-size: 18px; }
    #artifactOverlay.art-one .art-meta { font-size: 11px; margin: 0 16px 6px; }
    #artifactOverlay.art-one .art-tools {
      gap: 8px; margin: 0 0 8px; padding: 0 16px;
      flex-wrap: nowrap; overflow-x: auto; scrollbar-width: none;
    }
    #artifactOverlay.art-one .art-tools::-webkit-scrollbar { display: none; }
    #artifactOverlay.art-one .art-tool { width: 38px; height: 38px; flex: none; }
    #artifactOverlay.art-one .art-body {
      flex: 1; min-height: 0; margin: 0; padding: 0;
      background: var(--bg); border: 0; border-radius: 0;
    }
    /* The rendered artifact itself fills whatever the chrome left over. Only the
       framed kinds become flex columns — prose is left as ordinary flow, where its
       margins collapse the way a document's should. */
    #artifactOverlay.art-one .art-render { min-height: 100%; }
    #artifactOverlay.art-one .art-render-html,
    #artifactOverlay.art-one .art-render-svg { height: 100%; display: flex; flex-direction: column; }
    #artifactOverlay.art-one .art-render-html > iframe,
    #artifactOverlay.art-one .art-render-svg > iframe { flex: 1; min-height: 0; border-radius: 0; }
    #artifactOverlay.art-one .art-render > img { max-width: 100%; border-radius: 0; }
    #artifactOverlay.art-one .art-body table { font-size: 13px; }
    /* Prose and code keep a hair of gutter — a character clipped by the screen's own
       rounded corner is unreadable, which is not what "as wide as possible" means.
       Anything visual (html, svg, an image, a drawing) gets no gutter at all. */
    #artifactOverlay.art-one .art-render-document,
    #artifactOverlay.art-one .art-render-code,
    #artifactOverlay.art-one .art-render-text,
    #artifactOverlay.art-one .art-render-json,
    #artifactOverlay.art-one .art-render-list { padding: 10px; }
    #artifactOverlay.art-one .art-edit { min-height: 100%; padding: 10px; }
    .art-head { display: flex; align-items: center; gap: 10px; margin: 0 48px 4px 0; }
    .art-title { font-size: 22px; font-weight: 600; flex: 1; min-width: 0;
                 overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
    .art-title-input {
      flex: 1; min-width: 0; font-size: 20px; font-weight: 600; color: var(--text);
      background: var(--surface); border: 1px solid var(--border);
      border-radius: 10px; padding: 6px 10px; outline: none;
    }
    .art-meta { color: var(--muted); font-size: 13px; margin-bottom: 12px; }
    /* The toolbar is glyphs alone. Each one had its word beside it and the row read as
       a sentence you had to parse before you could act; the drawings say edit, read,
       export, copy, delete on their own. Round targets, 42px, so losing the label costs
       no thumb. The word survives as the accessible name and the long-press tooltip. */
    .art-tools { display: flex; gap: 10px; flex-wrap: wrap; margin-bottom: 12px; }
    .art-tool {
      display: inline-flex; align-items: center; justify-content: center;
      width: 42px; height: 42px; padding: 0; border-radius: 50%; background: var(--surface);
      border: 1px solid var(--border); color: var(--text); font-size: 13px; cursor: pointer;
      -webkit-tap-highlight-color: transparent;
      transition: background .12s ease, color .12s ease, border-color .12s ease;
    }
    .art-tool:active { background: var(--border); }
    .art-tool.on { background: var(--accent); color: var(--accent-ink); border-color: var(--accent); }
    .art-tool.danger { color: #FF6B6B; }
    .art-tool.danger:active { background: rgba(255,69,58,0.16); }
    .art-body {
      flex: 1; background: var(--surface); border: 1px solid var(--border);
      border-radius: 14px; padding: 14px 16px; overflow: auto;
      -webkit-overflow-scrolling: touch; min-height: 38vh;
    }
    .art-body pre {
      margin: 0; white-space: pre-wrap; word-break: break-word;
      font-family: 'SF Mono', monospace; font-size: 13px; line-height: 1.55;
    }
    .art-body table { border-collapse: collapse; width: 100%; font-size: 14px; }
    .art-body th, .art-body td { border: 1px solid var(--border); padding: 7px 10px; text-align: left; }
    .art-body th { background: var(--surface-2); }
    .art-body img { max-width: 100%; border-radius: 10px; }
    .art-body iframe { width: 100%; min-height: 55vh; border: 0; background: #fff; border-radius: 10px; }
    .art-body h1, .art-body h2, .art-body h3 { margin: 14px 0 6px; font-size: 17px; }
    .art-body ul, .art-body ol { margin: 6px 0 6px 20px; }
    .art-body li { margin: 3px 0; }
    .art-body code { background: var(--surface-2); border-radius: 4px; padding: 1px 5px; font-family: 'SF Mono', monospace; }
    .art-section-on { background: rgba(43,217,199,0.16); border-radius: 4px; }
    .art-edit {
      width: 100%; min-height: 42vh; background: transparent; border: 0; color: var(--text);
      font-family: 'SF Mono', monospace; font-size: 13px; line-height: 1.55;
      resize: vertical; outline: none;
    }
    /* The list is a wall of the same live cards — you pick an artifact by seeing it. */
    .art-list { display: flex; flex-direction: column; gap: 12px; }
    .art-empty { color: var(--muted); font-size: 14px; padding: 22px 2px; }
    /* Issues / Brainstorms / Notes tab switcher on the issues page. Three pills; the
       active one is filled so which list you're on is unmistakable. Sized to fit the
       narrowest phone (issue #144): the side padding shrinks and the label ellipsises
       rather than letting "Brainstorms" wrap the row into two lines. The 10px vertical
       padding keeps each pill ~40px tall — still a comfortable thumb target. */
    .issues-tabs { display: flex; gap: 6px; margin: 4px 0 16px; }
    .issues-tab {
      flex: 1; min-width: 0; padding: 10px 8px; border-radius: 999px; cursor: pointer;
      background: var(--surface); border: 0.5px solid var(--border); color: var(--muted);
      font-size: 14px; font-weight: 600; -webkit-tap-highlight-color: transparent;
      white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
    }
    .issues-tab.active { background: var(--blue); border-color: var(--blue); color: var(--accent-ink); }
    .brainstorm-item .issue-num, .note-item .issue-num { color: var(--muted); }
    /* An opened note edits in place — no second screen to lose your thumb in. The row
       keeps its title as the handle to close by, but drops the one-line preview: the
       whole text is right underneath it, so the preview would just say it twice. */
    .note-item.open { flex-wrap: wrap; }
    .note-item.open .ti-sub { display: none; }
    .note-edit { flex-basis: 100%; margin-top: 10px; }
    /* .field-input is the same colour as the row it now sits in, so the fields need
       their own outline or an opened note looks like plain text you can't edit. */
    .note-edit .field-input {
      background: var(--bg); border: 0.5px solid var(--border);
      padding: 12px; font-size: 15px; margin-bottom: 8px;
    }
    .note-edit textarea { min-height: 130px; resize: vertical; line-height: 1.45; }
    .note-edit-row { display: flex; gap: 8px; align-items: center; }
    .note-edit-row .save-btn { margin-top: 0; flex: 1; }
    .field-label {
      color: var(--muted); font-size: 12px; font-weight: 600;
      text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 6px;
    }
    /* Settings sections (UX audit M7): real headers + iOS-style inset groups instead
       of one flat 20-control column. Controls keep their ids — layout only. */
    .set-section {
      color: var(--muted); font-size: 12px; font-weight: 600;
      text-transform: uppercase; letter-spacing: 0.06em; margin: 26px 2px 8px;
    }
    .set-group {
      background: var(--surface); border: 0.5px solid var(--border);
      border-radius: 14px; padding: 2px 14px 12px; margin-bottom: 2px;
    }
    /* Sub-pages: the root page keeps only what a new user needs; everything else
       sits one tap deeper behind a .set-row. Panes stay in the DOM when hidden, so
       every control is still readable/writable by loadSettings & voice control. */
    .set-back {
      position: fixed;
      top: calc(env(safe-area-inset-top, 44px) + 12px);
      left: 16px;
      width: 40px; height: 40px; border-radius: 50%;
      background: var(--surface); color: var(--text);
      border: none; font-size: 24px; line-height: 1; cursor: pointer;
      display: flex; align-items: center; justify-content: center;
      padding-bottom: 3px; z-index: 2;
    }
    .set-group.set-nav { padding: 0 0 2px; }
    .set-row {
      display: flex; align-items: center; gap: 12px; width: 100%;
      background: none; border: none; color: var(--text);
      padding: 13px 14px; font-size: 14px; text-align: left; cursor: pointer;
      border-top: 0.5px solid var(--border);
    }
    .set-group.set-nav > .set-row:first-child { border-top: none; }
    .set-row:active { background: var(--surface-2); }
    .set-row-ico { flex: none; width: 22px; display: flex; align-items: center; justify-content: center; }
    .set-row-txt { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
    .set-row-txt b { font-weight: 600; }
    .set-row-txt small { color: var(--muted); font-size: 12px; line-height: 1.35; }
    .set-row-arrow { color: var(--muted); font-size: 20px; flex: none; }
    .set-group .field-label { margin: 14px 0 6px; }
    .set-group .field-input { background: var(--surface-2); margin-bottom: 6px; }
    .set-group > label { margin-top: 12px; }
    /* Voice picker (2026-08-21): one row per voice — OpenAI's set plus Piper folded in
       as just another row, never a separate provider switch. Tap the row to select it,
       tap the preview button to hear it without committing. */
    .voice-picker-list { display: flex; flex-direction: column; }
    .vp-row {
      display: flex; align-items: center; gap: 10px; width: 100%;
      background: none; border: none; color: var(--text);
      padding: 11px 2px; font-size: 14px; text-align: left; cursor: pointer;
      border-top: 0.5px solid var(--border); font-family: inherit;
    }
    .voice-picker-list .vp-row:first-child { border-top: none; }
    .vp-row:active:not(.vp-disabled) { background: var(--surface-2); }
    .vp-row.vp-disabled { cursor: default; opacity: 0.5; }
    .vp-radio {
      flex: none; width: 18px; height: 18px; border-radius: 50%;
      border: 1.5px solid var(--border); display: flex; align-items: center; justify-content: center;
    }
    .vp-row.vp-selected .vp-radio { border-color: var(--blue); }
    .vp-radio-dot { width: 10px; height: 10px; border-radius: 50%; background: var(--blue); display: none; }
    .vp-row.vp-selected .vp-radio-dot { display: block; }
    .vp-txt { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 1px; }
    .vp-name { font-weight: 600; text-transform: capitalize; }
    .vp-desc { font-size: 12px; color: var(--muted); line-height: 1.35; }
    .vp-preview {
      flex: none; width: 34px; height: 34px; border-radius: 50%; border: none;
      background: var(--surface-2); color: var(--text); cursor: pointer;
      display: flex; align-items: center; justify-content: center;
    }
    .vp-preview:active { background: var(--border); }
    .vp-preview svg { width: 16px; height: 16px; }
    .vp-preview.vp-playing svg { color: var(--blue); }
    /* Settings search: one box over every sub-page. Sits under the title in normal
       flow — sticky would slide under the fixed save / back buttons. */
    .set-search { display: flex; align-items: center; gap: 8px; margin: 2px 0 6px; }
    .ss-ico { color: var(--muted); display: flex; flex: none; }
    .ss-ico svg { width: 18px; height: 18px; display: block; }
    #setSearchInput {
      flex: 1; min-width: 0; background: var(--surface); color: var(--text);
      border: 0.5px solid var(--border); border-radius: 12px;
      padding: 11px 12px; font-size: 16px; font-family: inherit; outline: none;
      -webkit-appearance: none;
    }
    #setSearchInput:focus { border-color: var(--blue); }
    .ss-clear {
      flex: none; width: 32px; height: 32px; border-radius: 50%; border: none;
      background: var(--surface); color: var(--muted); font-size: 14px; cursor: pointer;
      display: flex; align-items: center; justify-content: center;
    }
    /* Same 0x0 trap as .connect-hint-close: the button had a size, the glyph inside
       it did not, so the clear-search X was invisible (2026-08-29). */
    .ss-clear svg { width: 16px; height: 16px; display: block; }
    .set-results { margin-bottom: 20px; }
    .ss-item {
      display: block; width: 100%; text-align: left; cursor: pointer;
      background: var(--surface); border: 0.5px solid var(--border); border-radius: 12px;
      padding: 11px 13px; margin-bottom: 6px; color: var(--text); font-family: inherit;
    }
    .ss-item:active { background: var(--surface-2); }
    .ss-title { font-size: 14px; font-weight: 600; }
    .ss-where { font-size: 12px; color: var(--blue); margin-top: 2px; }
    .ss-snip { font-size: 12px; color: var(--muted); margin-top: 3px; line-height: 1.35;
      display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
    .ss-empty { font-size: 13px; color: var(--muted); padding: 10px 2px; line-height: 1.5; }
    /* Recent searches: same header rhythm as .set-section, but with a clear-all glyph
       riding the same row instead of on a line of its own. */
    .ss-rec-head {
      display: flex; align-items: center; justify-content: space-between;
      margin: 4px 2px 8px;
    }
    .ss-rec-head span {
      color: var(--muted); font-size: 12px; font-weight: 600;
      text-transform: uppercase; letter-spacing: 0.06em;
    }
    .ss-rec-clear {
      flex: none; width: 26px; height: 26px; border-radius: 8px;
      border: 0.5px solid var(--border); background: none; color: var(--muted);
      display: flex; align-items: center; justify-content: center; cursor: pointer;
    }
    .ss-rec-clear:active { color: var(--text); border-color: var(--muted); }
    .ss-item .ss-rec-ico { margin-right: 6px; color: var(--muted); }
    /* Landing on a control from a result: the row it lives in flashes once, so the eye
       knows which of a dozen switches the search meant. */
    @keyframes sethit { 0%, 60% { background: rgba(10,132,255,0.20); } 100% { background: transparent; } }
    .set-hit { animation: sethit 1.6s ease-out; border-radius: 10px; }
    .hint { font-size: 13px; color: var(--muted); margin: 4px 0 4px; line-height: 1.4; }
    details.hint-more { margin: 2px 0 6px; }
    details.hint-more > summary {
      list-style: none; cursor: pointer; font-size: 13px; color: var(--blue);
      -webkit-tap-highlight-color: transparent;
    }
    details.hint-more > summary::-webkit-details-marker { display: none; }
    details.hint-more > div { font-size: 13px; color: var(--muted); margin-top: 4px; line-height: 1.45; }
    /* Wake-word sensitivity slider (replaces the old numeric % field). */
    .wake-sens-slider { width: 100%; accent-color: var(--blue); margin: 4px 0 4px; }
    .wake-sens-labels { display: flex; justify-content: space-between; font-size: 12px; color: var(--muted); margin-bottom: 20px; }
    .field-input {
      background: var(--surface); color: var(--text);
      border: none; border-radius: 12px;
      padding: 14px; font-size: 16px; font-family: inherit;
      outline: none; width: 100%; margin-bottom: 20px;
      -webkit-appearance: none;
    }
    .field-input::placeholder { color: var(--muted); opacity: .7; }   /* m13: #555 was ~2.6:1 */
    .save-btn {
      background: var(--blue); border: none; border-radius: 14px;
      padding: 16px; color: #fff; font-size: 17px; font-weight: 600;
      cursor: pointer; width: 100%; margin-top: 8px;
    }
    .voice-ref {
      background: var(--surface); border: 0.5px solid var(--border);
      border-radius: 12px; padding: 4px 14px; margin: 4px 0 20px;
    }
    .voice-ref > summary {
      list-style: none; cursor: pointer; padding: 12px 0;
      font-size: 15px; font-weight: 600; color: var(--text);
      display: flex; align-items: center; gap: 8px;
    }
    .voice-ref > summary::-webkit-details-marker { display: none; }
    .voice-ref > summary::after { content: '▸'; margin-left: auto; color: var(--muted); transition: transform .15s; }
    .voice-ref[open] > summary::after { transform: rotate(90deg); }
    .voice-ref .vr-intro { font-size: 12.5px; color: var(--muted); line-height: 1.45; margin: 0 0 12px; }
    .voice-ref .vr-group { font-size: 11px; font-weight: 600; text-transform: uppercase;
      letter-spacing: 0.6px; color: var(--muted); margin: 16px 0 8px; }
    .voice-ref .vr-row { padding: 7px 0; border-top: 0.5px solid var(--border); }
    .voice-ref .vr-row:first-of-type { border-top: none; }
    .voice-ref .vr-say { font-size: 14px; color: var(--text); line-height: 1.4; }
    .voice-ref .vr-say b { color: var(--blue); font-weight: 600; }
    .voice-ref .vr-does { font-size: 12.5px; color: var(--muted); margin-top: 2px; line-height: 1.35; }
    .timing-cap { font-size: 11px; color: var(--muted); opacity: 0.85; text-align: center;
      font-family: ui-monospace, SFMono-Regular, Menlo, monospace; margin: 2px 0 10px; padding: 0 12px;
      word-break: break-word; }
    /* ── Trash bin overlay: recoverable deleted projects & chats ── */
    .trash-section-label {
      color: var(--muted); font-size: 12px; font-weight: 600;
      text-transform: uppercase; letter-spacing: 0.5px;
      margin: 24px 0 10px;
    }
    .trash-item {
      display: flex; align-items: center; gap: 12px;
      background: var(--surface); border: 0.5px solid var(--border);
      border-radius: 12px; padding: 12px 14px; margin-bottom: 10px;
    }
    .trash-item .ti-body { flex: 1; min-width: 0; }
    .trash-item .ti-title {
      font-size: 15px; font-weight: 600; color: var(--text);
      overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
    }
    .trash-item .ti-sub {
      font-size: 12px; color: var(--muted); margin-top: 3px;
      overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
    }
    .trash-item .ti-btn {
      flex-shrink: 0; border: none; border-radius: 10px;
      padding: 9px 14px; font-size: 14px; font-weight: 600;
      font-family: inherit; cursor: pointer;
      -webkit-tap-highlight-color: transparent;
    }
    .trash-item .ti-restore { background: var(--blue); color: var(--accent-ink); }
    .trash-item .ti-purge {
      background: none; color: var(--muted); font-size: 18px;
      padding: 9px 8px; line-height: 1;
    }
    .trash-item .ti-purge:active { color: #FF453A; }
    .trash-empty { color: var(--muted); font-size: 14px; padding: 6px 2px; }
    /* Custom commands editor */
    .cmd-add { display: flex; flex-direction: column; gap: 10px; margin-bottom: 18px; }
    /* Minimal add: fields hidden until the button is first tapped. */
    .issue-add-fields { display: flex; flex-direction: column; gap: 10px; }
    .cmd-add.collapsed .issue-add-fields { display: none; }
    .cmd-add-row { display: flex; gap: 8px; align-items: stretch; }
    .add-context { resize: vertical; min-height: 44px; line-height: 1.4; margin-bottom: 0; }  /* manual issue/brainstorm context (#72) */
    .cmd-add-row .field-input { flex: 1; margin-bottom: 0; }
    .cmd-mic {
      flex-shrink: 0; width: 48px; border: 0.5px solid var(--border);
      background: var(--surface); border-radius: 12px; font-size: 20px; line-height: 1;
      cursor: pointer; -webkit-tap-highlight-color: transparent; color: var(--text);
    }
    @keyframes cmd-mic-pulse { 0%, 100% { box-shadow: 0 0 0 0 rgba(255,69,58,0.5); } 50% { box-shadow: 0 0 0 7px rgba(255,69,58,0); } }
    .cmd-mic.recording { background: #3a1515; border-color: #FF453A; color: #FF453A; animation: cmd-mic-pulse 1.2s ease-out infinite; }
    .cmd-item .ti-sub { color: var(--blue); }
    /* Project-instructions editor: char count on the left, save state on the right. */
    .instr-meta { display: flex; justify-content: space-between; gap: 8px; font-size: 11px; color: var(--muted); margin: -4px 2px 0; }
    .instr-meta .over { color: #FF453A; }
    .cmd-auto { font-size: 0.62em; font-weight: 600; letter-spacing: 0.04em; text-transform: uppercase;
      color: var(--muted); border: 1px solid var(--border, #30363d); border-radius: 4px;
      padding: 1px 4px; margin-left: 6px; vertical-align: middle; white-space: nowrap; }
    /* Issue rows reuse .trash-item but must show the WHOLE title (a truncated
       issue is useless), so they wrap instead of ellipsizing and top-align the
       button + id badge to the first line. */
    .trash-item.issue-item { align-items: flex-start; }
    .issue-num {
      flex-shrink: 0; margin-top: 2px; font-size: 12px; font-weight: 600;
      color: var(--muted); background: var(--bg); border: 0.5px solid var(--border);
      border-radius: 999px; padding: 2px 9px;
    }
    .trash-item.issue-item .ti-title {
      overflow: visible; text-overflow: clip; white-space: normal;
      line-height: 1.35; overflow-wrap: anywhere;
    }
    .trash-item.issue-item .ti-sub { overflow: visible; white-space: normal; }
    .trash-item.issue-item { cursor: pointer; }   /* tap a row to open the issue */
    /* Round "mark solved" check on each row; taps here don't open the issue. */
    .issue-solve {
      flex-shrink: 0; align-self: flex-start; margin-top: 1px;
      width: 30px; height: 30px; border-radius: 50%;
      border: 1.5px solid var(--border); background: none; color: var(--muted);
      font-size: 15px; line-height: 1; cursor: pointer; font-family: inherit;
      display: flex; align-items: center; justify-content: center;
      -webkit-tap-highlight-color: transparent;
    }
    .issue-solve:active { background: #143d2a; border-color: #30D158; color: #30D158; }
    /* Destructive row control (UX audit M6): same dimensions as .issue-solve but reads
       red on press — a delete must never wear the safe done-check's styling. */
    .issue-del {
      flex-shrink: 0; align-self: flex-start; margin-top: 1px;
      width: 30px; height: 30px; border-radius: 50%;
      border: 1.5px solid var(--border); background: none; color: var(--muted);
      font-size: 15px; line-height: 1; cursor: pointer; font-family: inherit;
      display: flex; align-items: center; justify-content: center;
      -webkit-tap-highlight-color: transparent;
    }
    .issue-del:active { background: rgba(255,69,58,0.14); border-color: #FF453A; color: #FF453A; }
    /* Solved issues fold (m7): out of the way by default, one tap to review. */
    .solved-fold { margin-top: 10px; }
    .solved-fold > summary {
      list-style: none; cursor: pointer; padding: 10px 2px;
      font-size: 13px; font-weight: 600; color: var(--muted);
      -webkit-tap-highlight-color: transparent;
    }
    .solved-fold > summary::-webkit-details-marker { display: none; }
    .solved-fold > summary::before { content: '▸ '; }
    .solved-fold[open] > summary::before { content: '▾ '; }
    .issue-solve.solved { background: #14361f; border-color: #30D158; color: #30D158; }
    /* Revert-to-unresolved on a review row (Issue 18): amber, matching the row. */
    .issue-revert {
      flex-shrink: 0; align-self: flex-start; margin-top: 1px;
      width: 30px; height: 30px; border-radius: 50%;
      border: 1.5px solid #7a6420; background: none; color: #e3b341;
      font-size: 16px; line-height: 1; cursor: pointer; font-family: inherit;
      display: flex; align-items: center; justify-content: center;
      -webkit-tap-highlight-color: transparent;
    }
    .issue-revert:active { background: #2b2410; border-color: #e3b341; }
    /* Mark this issue's row read/unread (issue #173) — same round control as solve/
       delete, blue like the manual-unread ring so a lit dot and a lit button agree. */
    .issue-mark {
      flex-shrink: 0; align-self: flex-start; margin-top: 1px;
      width: 30px; height: 30px; border-radius: 50%;
      border: 1.5px solid var(--border); background: none; color: var(--muted);
      font-size: 15px; line-height: 1; cursor: pointer; font-family: inherit;
      display: flex; align-items: center; justify-content: center;
      -webkit-tap-highlight-color: transparent;
    }
    .issue-mark svg { width: 16px; height: 16px; display: block; }
    .issue-mark.unread { border-color: var(--blue); color: var(--blue); }
    .issue-mark:active { background: rgba(10,132,255,0.12); border-color: var(--blue); color: var(--blue); }
    /* A dead job's two controls, on the issue that owns it (issue #167 narrowed again):
       run it again, or dismiss it. Only ever present on an OPEN issue that actually has
       an unacknowledged failure on the board, so an ordinary row is unchanged. Resume
       wears the strip's violet — it starts dispatched work, the same thing the jobs
       strip and the dispatched-work card mean everywhere else they appear (the per-chat
       status DOT went neutral instead, issue #172 — it's the one place that colour could
       land on a same-coloured pill); dismiss stays muted, since it changes nothing but
       the flag that keeps this pair on screen. */
    .issue-jobactions { display: contents; }   /* a host for the pair, not a box of its own */
    .issue-jobact {
      flex-shrink: 0; align-self: flex-start; margin-top: 1px;
      width: 30px; height: 30px; border-radius: 50%;
      border: 1.5px solid var(--border); background: none; color: var(--muted);
      font-size: 15px; line-height: 1; cursor: pointer; font-family: inherit;
      display: flex; align-items: center; justify-content: center;
      -webkit-tap-highlight-color: transparent;
    }
    .issue-jobact svg { width: 16px; height: 16px; display: block; }
    .issue-resume { border-color: #6b4d9e; color: #BF8CFF; }
    .issue-resume:active { background: #251a36; border-color: #BF8CFF; }
    .issue-dismiss:active { background: var(--bg); border-color: var(--muted); }
    /* Issue lifecycle colors on the whole row: amber = Claude flagged it resolved and
       it's awaiting your review; green = you've marked it solved (it stays, dimmed). */
    .trash-item.issue-item.issue-review { background: #2b2410; border-color: #7a6420; }
    .trash-item.issue-item.issue-review .ti-sub { color: #e3b341; font-weight: 600; }
    .trash-item.issue-item.issue-done { background: #16241b; border-color: #2f5b3e; }
    .trash-item.issue-item.issue-done .ti-title { color: var(--muted); }
    .trash-item.issue-item.issue-done .ti-sub { color: #3fb950; }
    /* Drag-to-reorder (Issue 9): grip handle + lifted state while dragging. */
    .issue-grip {
      flex-shrink: 0; margin-top: 1px; color: var(--muted); font-size: 16px;
      line-height: 1; cursor: grab; touch-action: none; user-select: none;
      -webkit-user-select: none; padding: 2px 2px 6px; align-self: flex-start;
    }
    .trash-item.issue-item { touch-action: pan-y; }
    .trash-item.issue-item.dragging {
      opacity: 0.9; border-color: var(--blue);
      box-shadow: 0 6px 20px rgba(0,0,0,0.45); cursor: grabbing;
    }
    /* Working/ready dot, same states as the chat pills: pulsing amber = the
       issue's chat is working, green = a reply is ready. Top-aligned to the
       first line (rows wrap) and transparent when idle so titles stay aligned. */
    .issue-status { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; margin-top: 6px; }
    .issue-status.busy { background: #E8B33F; animation: chatpulse 1s ease-in-out infinite; }
    .issue-status.done { background: #30D158; }
    /* Red: this issue's chat stopped on the usage limit, or failed (issue #147). */
    .issue-status.error { background: #FF453A; }
    .issue-status.idle { background: transparent; }
    /* Blue ring: manually marked unread from the row menu (issue #173) — the same
       treatment the chat dots use, so the dot and the "Mark as read" menu item below
       can never disagree the way they used to. */
    .issue-status.flagged { background: transparent; box-shadow: inset 0 0 0 2px var(--blue), 0 0 0 1.5px var(--bg); }

    /* ── Custom confirm modal: native confirm() is silently suppressed on iOS
       standalone/PWA with active audio, so deletes had no visible dialog. ── */
    .confirm-backdrop {
      position: fixed; inset: 0; z-index: 400;
      background: rgba(0,0,0,0.55);
      display: flex; align-items: center; justify-content: center;
      padding: 24px; -webkit-tap-highlight-color: transparent;
    }
    .confirm-box {
      background: var(--surface); border: 0.5px solid var(--border);
      border-radius: 18px; padding: 22px 20px 18px;
      width: 100%; max-width: 340px;
      box-shadow: 0 12px 40px rgba(0,0,0,0.5);
    }
    .confirm-title { font-size: 18px; font-weight: 600; color: var(--text); margin-bottom: 8px; }
    .confirm-msg { font-size: 15px; color: var(--muted); line-height: 1.4; margin-bottom: 20px; }
    .confirm-actions { display: flex; gap: 10px; }
    .confirm-btn {
      flex: 1; border: none; border-radius: 12px; padding: 13px;
      font-size: 16px; font-weight: 600; font-family: inherit; cursor: pointer;
      -webkit-tap-highlight-color: transparent;
    }
    .confirm-cancel { background: var(--border); color: var(--text); }
    .confirm-ok { background: #FF453A; color: #fff; }
    /* While the BT ring is injecting a touch (v10, issue #108): its presses are real touch
       events, so iOS would happily select message text and pop the copy callout under a
       finger that isn't there. Inputs are exempt so a press mid-typing can't disturb the
       caret. The class is on for the length of the press only, with a 3s dead-man timer. */
    html.remote-synth *:not(input):not(textarea) {
      -webkit-user-select: none !important; user-select: none !important;
      -webkit-touch-callout: none !important;
    }
    /* v12 (issue #108): the user reported the message under a press "going darker and back,
       as if I'd tapped it" — that is .bubble.tappable:active, applied by the BROWSER from the
       touch itself, so no amount of stopPropagation can reach it. The touchstart is now
       preventDefault-ed (see synthHardBlock), which suppresses it at source; this rule is the
       belt to that braces, and also kills the grey tap flash on anything else under a press. */
    html.remote-synth * { -webkit-tap-highlight-color: transparent !important; }
    html.remote-synth .bubble:active, html.remote-synth .bubble.tappable:active { opacity: 1 !important; }
    /* ── Alma: settings checkboxes render as iOS switches (pure CSS, same inputs).
       !important overrides the inline 18px sizing on the existing labels. ── */
    .overlay input[type="checkbox"] {
      appearance: none; -webkit-appearance: none;
      width: 46px !important; height: 28px !important; border-radius: 14px;
      background: var(--border); position: relative; flex: none;
      transition: background 0.18s ease; cursor: pointer; margin: 0;
      -webkit-tap-highlight-color: transparent;
    }
    .overlay input[type="checkbox"]::after {
      content: ''; position: absolute; top: 2px; left: 2px;
      width: 24px; height: 24px; border-radius: 50%; background: #fff;
      transition: transform 0.18s ease;
      box-shadow: 0 1px 3px rgba(0,0,0,0.35);
    }
    .overlay input[type="checkbox"]:checked { background: var(--blue); }
    .overlay input[type="checkbox"]:checked::after { transform: translateX(18px); }
    /* ── Expanded hit areas (UX audit M9): the small-control tier keeps its visual
       size but gains an invisible touch pad toward the 44pt rule. inset -8px is the
       compromise for controls that sit beside other targets (a bigger pad on the
       pill's × would steal taps meant to OPEN the chat). ── */
    .chat-pill-close, .chat-add, .chat-list-toggle, .hdr-btn, .settings-btn,
    .issue-solve, .issue-revert, .issue-del, .issue-grip, .issue-jobact, .issue-mark,
    .att-chip .ip-remove, .reply-preview .rp-cancel, .chat-drow-act, .ap-replay, #bigBtn {
      position: relative;
    }
    .chat-pill-close::after, .chat-add::after, .chat-list-toggle::after, .hdr-btn::after,
    .settings-btn::after, .issue-solve::after, .issue-revert::after, .issue-del::after,
    .issue-grip::after, .issue-jobact::after, .issue-mark::after, .att-chip .ip-remove::after, .reply-preview .rp-cancel::after,
    .chat-drow-act::after, .ap-replay::after, #bigBtn::after {
      content: ''; position: absolute; inset: -8px; border-radius: inherit;
    }

    /* ── Desktop layout (desktop series, edits 1-3). Everything hangs off body.desktop,
       set by applyDesktopMode() from pointer + width; localStorage 'desktopMode'
       ('on'/'off'/'auto') is the override and kill-switch — without the class this
       block is inert and the app is exactly the mobile build. */
    .sidebar { display: none; }
    body.desktop { padding-left: 264px; }
    body.desktop .sidebar {
      display: flex; flex-direction: column; position: fixed; left: 0; top: 0; bottom: 0;
      width: 264px; background: var(--surface); border-right: 0.5px solid var(--border);
      z-index: 50; overflow-y: auto; overscroll-behavior: contain;
      padding: 10px 10px calc(12px + env(safe-area-inset-bottom, 0px)); gap: 1px;
    }
    .sb-brand { font-size: 15px; font-weight: 700; padding: 8px 10px 10px; letter-spacing: .3px; }
    .sb-head {
      display: flex; align-items: center; justify-content: space-between;
      font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: .8px;
      color: var(--muted); padding: 12px 10px 5px;
    }
    .sb-add {
      width: 22px; height: 22px; border-radius: 6px; border: 0.5px solid var(--border);
      background: none; color: var(--muted); font-size: 15px; line-height: 1; cursor: pointer;
    }
    .sb-add:hover { color: var(--text); border-color: var(--muted); }
    .sb-item {
      display: flex; align-items: center; gap: 8px; width: 100%; text-align: left;
      padding: 7px 10px; border: none; border-radius: 8px; background: none;
      color: var(--text); font-size: 14px; cursor: pointer; flex-shrink: 0;
    }
    .sb-item:hover, .sb-item.active { background: var(--surface-2); }
    .sb-item .sb-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
    .sb-item.relay .sb-name { color: var(--relay); }
    .sb-item .chat-playing { display: inline-flex; }
    .sb-close {
      display: none; border: none; background: none; color: var(--muted);
      font-size: 15px; line-height: 1; cursor: pointer; padding: 0 2px; flex-shrink: 0;
    }
    .sb-item:hover .sb-close { display: block; }
    .sb-close:hover { color: var(--text); }
    /* The sidebar IS the chat switcher on desktop; the vertical dropdown and its
       header toggle come off (the strip they replaced is gone everywhere now). The
       header's project dropdown stays — it carries the current project's name and
       the rename/trash entries the sidebar doesn't duplicate. */
    body.desktop .chat-dropdown, body.desktop #hdrChatToggle { display: none; }
    /* The phone layout is the mirror image: the unified picker button/menu above
       replaces this trio there, so they're hidden the moment body.desktop is absent —
       i.e. on the phone layout only. Desktop never gets this class, so the trio
       (#hdrChatToggle stays hidden by the rule above; #hdrChatName + the project
       dropdown stay visible) is completely untouched. */
    body:not(.desktop) #hdrChatToggle, body:not(.desktop) #hdrChatName, body:not(.desktop) .project-dd-wrap { display: none; }
    /* Centered reading column: cap the line length, keep the scrollbar at the window
       edge (padding, not max-width, so the scroll container stays full-width). */
    body.desktop .messages { padding-inline: max(24px, calc((100% - 860px) / 2)); }
    body.desktop .input-row { padding-inline: max(24px, calc((100% - 860px) / 2)); }
    body.desktop .img-preview, body.desktop .reply-preview {
      padding-inline: max(24px, calc((100% - 860px) / 2));
    }

    /* ── Desktop series, edits 4-6 ─────────────────────────────────────────── */
    /* Edit 4: hold-Space push-to-talk replaces tap-anywhere mode on desktop —
       a whole-screen touch target has no meaning under a mouse. */
    body.desktop #bigBtn { display: none; }

    /* Edit 5: artifacts dock as a right-hand pane BESIDE the chat instead of a
       full-screen sheet — the conversation stays visible and live while an
       artifact (or diff) is open, like a desktop work pane. */
    body.desktop #artifactOverlay {
      left: auto; width: min(44vw, 720px);
      border-left: 0.5px solid var(--border); background: var(--surface);
    }
    body.desktop.art-dock { padding-right: min(44vw, 720px); }

    /* Edit 6: every other overlay becomes a centered panel over a dimmed page,
       not an edge-to-edge phone sheet. The ::before is the backdrop — it also
       swallows clicks outside the panel, so a modal can't leak clicks into the
       chat behind it. Centered with inset+margin, NOT transform: a transformed
       ancestor would re-anchor the fixed backdrop to the panel box. */
    body.desktop .overlay:not(#artifactOverlay) {
      inset: 0; margin: auto; width: min(940px, calc(100vw - 80px));
      height: fit-content; min-height: 320px; max-height: 88vh;
      border: 0.5px solid var(--border); border-radius: 16px;
      background: var(--surface); padding: 20px 32px 36px;
      box-shadow: 0 24px 80px rgba(0, 0, 0, .55);
    }
    body.desktop .overlay:not(#artifactOverlay)::before {
      content: ''; position: fixed; inset: 0; z-index: -1;
      background: rgba(0, 0, 0, .45);
    }
    /* The × and ‹ are viewport-fixed for thumbs; inside a centered panel they
       belong to the panel — sticky keeps them reachable while it scrolls. */
    body.desktop .overlay:not(#artifactOverlay) .overlay-close {
      position: sticky; top: 8px; align-self: flex-end;
      margin: 0 -12px -40px 0; z-index: 5; background: var(--surface-2);
    }
    body.desktop #settingsOverlay .set-back {
      position: sticky; top: 8px; align-self: flex-start;
      margin: 0 0 -40px -12px; z-index: 5; background: var(--surface-2);
    }
    body.desktop .overlay:not(#artifactOverlay) h1 { padding-inline: 44px; }

    /* Settings: two balanced columns of the same groups on the wide panel. */
    body.desktop #settingsOverlay .set-page { column-count: 2; column-gap: 28px; }
    body.desktop #settingsOverlay .set-group,
    body.desktop #settingsOverlay .set-section { break-inside: avoid; }
    body.desktop #settingsOverlay .set-section { break-after: avoid; }

    /* Issues: add-form rail on the left, the lists on the right. On mobile the
       wrappers dissolve (display:contents) and the flow is exactly as before. */
    .issues-cols, .issues-forms, .issues-lists { display: contents; }
    body.desktop .issues-cols { display: flex; align-items: flex-start; gap: 24px; }
    body.desktop .issues-forms { display: block; flex: 0 0 320px; position: sticky; top: 0; }
    body.desktop .issues-lists { display: block; flex: 1; min-width: 0; }

    /* ── Desktop edits 7-9: hover affordances, roomier composer, drag-and-drop ── */
    /* Hover chrome (edit 7): the copy/reply/branch row and the timestamp are things a
       mouse can summon — faded out until the bubble is hovered (or holds keyboard
       focus), so the transcript reads as a conversation, not as rows of buttons.
       Opacity, not display: the rows keep their space, so bubbles don't change height
       as the pointer sweeps the list. Mobile has no hover and is untouched. */
    body.desktop .bubble-actions, body.desktop .bubble-time {
      opacity: 0; transition: opacity .12s ease;
    }
    body.desktop .bubble:hover .bubble-actions, body.desktop .bubble:focus-within .bubble-actions,
    body.desktop .bubble:hover .bubble-time,    body.desktop .bubble:focus-within .bubble-time {
      opacity: 1;
    }
    body.desktop .copy-btn:hover { color: var(--text); border-color: var(--text); }
    /* A scrollbar you can see and grab on the transcript — desktop users scan long
       chats by dragging it, and the strip-hiding rules above never applied here. */
    body.desktop .messages { scrollbar-width: thin; scrollbar-color: var(--border) transparent; }
    body.desktop .messages::-webkit-scrollbar { width: 10px; background: transparent; }
    body.desktop .messages::-webkit-scrollbar-thumb { background: var(--border); border-radius: 5px; }
    body.desktop .messages::-webkit-scrollbar-thumb:hover { background: var(--muted); }
    /* Roomier composer (edit 8): desktop users type more than they dictate. Taller
       resting field, a higher growth cap (onInput reads the same 260 number), and a
       visible border so the wide centered field doesn't dissolve into the page. */
    body.desktop #msgInput {
      min-height: 48px; max-height: 260px; padding: 12px 16px;
      border-radius: 14px; border: 0.5px solid var(--border);
    }
    /* Drag-and-drop (edit 9): while a file is over the window the whole page becomes
       the target — a full-screen veil with one instruction, pointer-events: none so
       the drop always lands on the document listeners underneath. */
    #dropHint { display: none; }
    body.dragging-file #dropHint {
      display: flex; position: fixed; inset: 0; z-index: 400;
      align-items: center; justify-content: center;
      background: rgba(0, 0, 0, 0.45); pointer-events: none;
    }
    #dropHint span {
      padding: 16px 30px; border: 2px dashed var(--muted); border-radius: 14px;
      background: var(--surface); color: var(--text); font-size: 15px; font-weight: 600;
    }

    /* ── Desktop edits 11-12: issues in the sidebar, the second chat pane ── */
    /* Issues live under the chat list because that is where the work is chosen from:
       on a phone they are a header button because there is no room, and on a wide
       screen there is. Only unresolved ones are listed, newest first, capped — the
       header row opens the full panel. */
    .sb-issues { display: none; }
    body.desktop .sb-issues { display: block; }
    .sb-count {
      font-size: 11px; font-weight: 700; color: var(--muted);
      background: var(--surface-2); border-radius: 9px; padding: 1px 7px;
    }
    .sb-issue .sb-num { font-size: 11px; color: var(--muted); flex: none; }
    .sb-issue.review .sb-name { color: #ff9f0a; }
    .sb-more {
      width: 100%; text-align: left; background: none; border: none; cursor: pointer;
      color: var(--muted); font-size: 12px; padding: 5px 10px;
    }
    .sb-more:hover { color: var(--text); }
    /* "Open beside" sits on a chat row's hover, next to its close button. */
    .sb-split {
      display: none; border: none; background: none; color: var(--muted);
      cursor: pointer; padding: 0 2px; flex-shrink: 0; line-height: 0;
    }
    .sb-item:hover .sb-split { display: block; }
    .sb-split:hover { color: var(--text); }
    .sb-split svg { width: 14px; height: 14px; }

    /* The second chat (edit 12). Same right-hand dock as the artifact pane and
       mutually exclusive with it — one side pane, whichever was asked for last. The
       transcript in it is READ-ONLY: a mirror kept current by the background poll,
       with one button to bring that chat into the main column and type in it. */
    #splitPane { display: none; }
    body.desktop #splitPane {
      display: flex; flex-direction: column; position: fixed; top: 0; right: 0; bottom: 0;
      width: min(42vw, 660px); background: var(--bg);
      border-left: 0.5px solid var(--border); z-index: 45;
    }
    body.desktop.split-open { padding-right: min(42vw, 660px); }
    .sp-head {
      display: flex; align-items: center; gap: 8px; flex: none;
      padding: 12px 14px; border-bottom: 0.5px solid var(--border);
    }
    .sp-title { flex: 1; min-width: 0; font-size: 14px; font-weight: 600;
                overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
    .sp-head button {
      border: none; background: none; color: var(--muted); cursor: pointer;
      padding: 2px; line-height: 0; flex: none;
    }
    .sp-head button:hover { color: var(--text); }
    .sp-head svg { width: 17px; height: 17px; }
    .sp-msgs {
      flex: 1; overflow-y: auto; padding: 14px; display: flex; flex-direction: column; gap: 10px;
      scrollbar-width: thin; scrollbar-color: var(--border) transparent;
    }
    /* Deliberately plainer than a real bubble: this pane is for READING a second
       thread, so it carries no buttons, no audio players and no tap-to-play. */
    .sp-bubble {
      max-width: 92%; padding: 9px 13px; border-radius: 14px; font-size: 14px; line-height: 1.45;
      white-space: pre-wrap; word-wrap: break-word; overflow-wrap: anywhere;
    }
    .sp-bubble.user { background: var(--user-bubble, #14514a); color: var(--user-text, #cbf5ee); align-self: flex-end; white-space: pre-wrap; }
    .sp-bubble.assistant { background: var(--surface); border: 0.5px solid var(--border); align-self: flex-start; white-space: normal; }
    .sp-bubble.error { background: #3a1212; color: #ff6b6b; align-self: flex-start; }
    .sp-bubble.system { color: var(--muted); font-size: 12.5px; align-self: center; text-align: center; }
    .sp-bubble p { margin: 0 0 7px; }
    .sp-bubble p:last-child { margin-bottom: 0; }
    .sp-bubble pre { background: var(--surface-2); border-radius: 7px; padding: 8px 10px; overflow-x: auto; }
    .sp-empty { color: var(--muted); font-size: 13px; text-align: center; margin-top: 22px; }
