/* Ask-AI — simple and clean. Provider identity is carried by a small colour chip
   only; the rest of the page stays neutral. */

:root {
    --bg: #f2f3f5;
    --panel: #ffffff;
    --line: #d9dbe0;
    --line-strong: #b9bdc5;

    /* Deliberately dark. The first pass leaned on light greys for hierarchy and
       the whole page read as washed out — labels and secondary text were doing
       too little work. Hierarchy now comes from size and weight, not fading. */
    --text: #14161a;
    --text-dim: #454a54;
    --text-faint: #6b707a;
    /* The one deliberate exception to the paragraph above. Everywhere else,
       fading is a way of ranking two things you are meant to read, and that is
       what made the first pass look washed out. Here the fade IS the message —
       a running card's snippet has nothing to tell you yet. ~2:1 on white:
       plainly inert at a glance, still legible if you look. */
    --text-ghost: #b4b9c1;
    --accent: #2059d0;

    --ok: #15773f;
    --err: #b52d24;
    --run: #a6720a;

    --gpt: #10a37f;
    --grok: #1a1a1a;
    --gemini: #4285f4;
    --claude: #d97757;

    --radius: 8px;
    --gap: 14px;

    /* The page is for reading answers, not for filling a monitor. Uncapped, a
       response on a wide screen ran ~145 characters a line and the eye loses the
       line on the return sweep. 1300 leaves the response ~918px, and the full
       1272px once Shift+\ hides the rail.

       What that is in characters now depends on --text-size: ~100 at the 16px
       default, ~107 at 15, ~89 at 18. The cap was chosen against 16 and the
       range it produces stays inside what it exists to protect, so it does not
       need to track the setting. */
    --cap-width: 1300px;
}

* { box-sizing: border-box; }

body {
    margin: 0;
    background: var(--bg);
    color: var(--text);
    font: 15px/1.55 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
          "Helvetica Neue", Arial, sans-serif;
}

/* ── layout ─────────────────────────────────────────────────────────── */

/* Capped and centred at every focus level, level 2 included — Shift+\ hides the
   rail and gives that width to the response, it does not go full bleed. The cap
   only bites above its own width, so the single-column breakpoint below is
   untouched. */
.shell {
    display: grid;
    grid-template-columns: 340px minmax(0, 1fr);
    gap: var(--gap);
    padding: var(--gap);
    height: 100vh;
    max-width: var(--cap-width);
    margin-inline: auto;
}

/* Left rail: history on top (it takes the slack), models pinned beneath it. */
.side {
    display: flex;
    flex-direction: column;
    gap: var(--gap);
    min-height: 0;
    min-width: 0;
}

.col {
    background: var(--panel);
    border: 1px solid var(--line);
    border-radius: var(--radius);
    padding: var(--gap);
    overflow-y: auto;
    min-width: 0;
}

.col-history { flex: 1; min-height: 0; }

/* Sized to its content, but never more than half the rail — a long model list
   must not squeeze the history list out of view. */
.col-models { flex: 0 0 auto; max-height: 50vh; }

.col-title {
    margin: 0 0 12px;
    font-size: 14px;
    font-weight: 700;
    letter-spacing: .02em;
    text-transform: uppercase;
    color: var(--text);
}

.col-subtitle {
    margin: 0 0 8px;
    font-size: 13px;
    font-weight: 700;
    text-transform: uppercase;
    letter-spacing: .02em;
    color: var(--text);
}

@media (max-width: 1100px) {
    .shell { grid-template-columns: 1fr; height: auto; }
    .col { overflow-y: visible; }
    .col-models { max-height: none; }
    .col-work { order: 1; }
    .side { order: 2; }
}

.placeholder { color: var(--text-faint); font-size: 14px; margin: 4px 0; }

/* ── fields ─────────────────────────────────────────────────────────── */

/* The column's whole vertical rhythm: a .field is a head + its textarea and has
   no spacing of its own, so this is what separates one block's box from the next
   block's label. */
.field { margin-bottom: 16px; }

/* Except on the last one, where there is nothing below to separate from. That is
   the response field, which carries flex:1 above 1100px — so the margin came out
   of the height the response was given, leaving it 16px short of the column's own
   14px padding at every focus level, Shift+\ included. Below the breakpoint it
   was just trailing whitespace under the page. Keyed to :last-child rather than
   to .response-field: the fact being expressed is "nothing follows it". */
.col-work > .field:last-child { margin-bottom: 0; }

.field-head {
    display: flex;
    align-items: center;
    justify-content: space-between;
    margin-bottom: 5px;
    gap: 8px;
}

.field-head label {
    font-size: 13px;
    font-weight: 700;
    text-transform: uppercase;
    letter-spacing: .02em;
    color: var(--text);
}

.field-actions { display: flex; gap: 6px; }

/* ── presets ────────────────────────────────────────────────────────── */

/* The row's flex items are CHUNKS (1-5, 6-10, 11-15, T Q — plus m on the
   Content row), not buttons — so the row gap is the gap *between* groups and
   a narrow window wraps between them rather than through the middle of one.
   Buttons keep the tight 4px inside. */
.preset-row { display: flex; gap: 14px; margin-bottom: 6px; flex-wrap: wrap; }
.preset-chunk { display: flex; gap: 4px; }

.preset-btn {
    /* Wide enough for the two-digit "10" at the larger type size. */
    width: 27px;
    height: 24px;
    padding: 0;
    border: 1px solid var(--line-strong);
    border-radius: 4px;
    background: #fff;
    color: var(--text-dim);
    font-family: inherit;
    font-size: 12px;
    line-height: 1;
    cursor: pointer;
    transition: border-color .1s, background .1s, color .1s;
}
.preset-btn:hover { border-color: var(--text-dim); color: var(--text); }

/* A slot holding text reads darker, so empty slots are obvious at a glance. */
.preset-btn.has-content { color: var(--text); font-weight: 600; }

/* Green border = the slot currently in the textarea. */
.preset-btn.is-selected {
    border-color: var(--ok);
    border-width: 2px;
    color: var(--ok);
}

/* Grey fill = Content pinned at its floor by `m` (presets.js toggleMin). Not
   the green border: that is the active slot's marker, and `m` is a toggle,
   not a slot. Border and text stay as they are; only the ground changes. */
.preset-btn.preset-min.is-on { background: var(--line); }

/* Black background = the slot the last request was fired from. */
.preset-btn.is-last-used {
    background: #1b1c1e;
    border-color: #1b1c1e;
    color: #fff;
}
.preset-btn.is-last-used.is-selected { border-color: var(--ok); color: #fff; }

.text-area {
    width: 100%;
    padding: 9px 11px;
    border: 1px solid var(--line-strong);
    border-radius: 6px;
    background: #fff;
    color: var(--text);
    /* Prose, not code — textareas don't inherit the body font, so say so. */
    font-family: inherit;
    /* 16px is a floor, not a taste: iOS Safari zooms the viewport on focus for
       any text-entry control under 16px, and it does NOT zoom back out on blur —
       so one tap into Instruction left the page zoomed for the rest of the
       session. This was 15px and missed it by one. .search-input and
       login.html's .auth-field input are the same rule for the same reason.
       Do not "fix" this with maximum-scale=1 / user-scalable=no on the viewport:
       that kills pinch-zoom for everyone and modern iOS ignores it anyway.
       .md-rendered and .view-box track this value — see .md-rendered.

       --text-size is set per BROWSER from /settings (index.html's head script
       says why it is localStorage and why it is inline). Going below 16 there
       reinstates the zoom on that device, knowingly — the point of storing it
       per browser is that a 15px desktop cannot impose itself on the phone.
       The fallback in the var() is the real default AND the safety net: a
       cleared or malformed value leaves the property unset and lands here.
       Do NOT give this property to .search-input or login's .auth-field input.
       Those are 16px for the iOS floor alone; they are focusable and they are
       not reading surfaces. */
    font-size: var(--text-size, 16px);
    line-height: 1.65;
    resize: vertical;
}

.text-area:focus {
    outline: none;
    border-color: var(--accent);
    box-shadow: 0 0 0 3px rgba(47, 111, 235, .12);
}

/* ── prompt syntax overlay (Instruction only) ───────────────────────────
   syntax-highlight.js wraps #promptA in .syntax-overlay-wrapper and renders
   colored HTML *behind* a transparent-text textarea. The wrapper carries the
   field background (the textarea's goes transparent) and, from JS, the
   compose-only class — so viewing state hides it with the textarea it wraps.

   The overlay's font is authored HERE, not copied as computed px in JS:
   --text-size changes live when /settings is adjusted in another tab
   (invariant 30), and a frozen px would drift the caret off the visible text.
   font-size and line-height mirror .text-area above — keep them in step. */
.syntax-overlay-wrapper {
    position: relative;
    display: flex;
    flex-direction: column;
    background: #fff;           /* .text-area's background, shown through */
    border-radius: 6px;         /* .text-area's radius, so no square corners peek */
}
.syntax-overlay {
    position: absolute;
    top: 0; left: 0; right: 0; bottom: 0;
    pointer-events: none;       /* clicks, drops and focus all reach the textarea */
    overflow: hidden;
    white-space: pre-wrap;
    word-wrap: break-word;
    box-sizing: border-box;
    color: var(--text);
    z-index: 1;
    font-family: inherit;
    font-size: var(--text-size, 16px);
    line-height: 1.65;
}
.syntax-variant { color: #1a5ab8; }
/* No strikethrough, deliberately (2026-08-16, diverging from image-server's
   CSS): comments are live annotations meant to be RE-READ on every preset
   visit, not deleted text — and a line through Korean glyphs is illegible in
   a way it isn't through Latin. Red alone plus the visible !{...} wrapper
   carries "stripped before send"; the lock marker stays distinguished by
   color (amber vs red). */
.syntax-comment { color: #cc3333; }
/* Preset lock (docs/preset_lock_plan.md). The leading !{locked} marker is a
   CONTROL, not a note — no strikethrough, and the same pixels show armed
   (amber) vs disarmed (grey), which doubles as the "you may type now" signal.
   While armed, .has-lock lifts the overlay above the textarea (z-index 2) so
   the marker span alone can take the unlock click; everything else keeps
   pointer-events: none and passes through to the read-only textarea, so
   selecting locked text still works. Dropped while disarmed, so the span
   never eats a click meant to place the caret. */
.syntax-lock { color: #a16207; }
.syntax-lock.is-open { color: #999; }
.syntax-overlay.has-lock { z-index: 3; }
.syntax-overlay.has-lock .syntax-lock { pointer-events: auto; cursor: pointer; }
/* Both rules below key off the readonly ATTRIBUTE, which update() maintains as
   the lock's single source of truth — derived styling that cannot drift, no JS.
   The tint sits on the wrapper because .syntax-enabled forces the textarea
   transparent; it drops during a transient unlock (readonly attribute gone),
   so white-while-editing is a third armed/disarmed signal for free. Darker
   than .response-area's #fbfbfc on purpose: a small box needs a tint that
   SIGNALS, not one that whispers. */
.syntax-overlay-wrapper:has(> #promptA[readonly]) { background: #f3f4f6; }
/* Affordance only — pointer-events: none is not a real disable (a focused
   button still takes Enter), and doesn't need to be: pasteInto()'s readOnly
   guard stays the authoritative check and toasts on that path. A real
   disabled attribute would mean the highlighter reaching into app markup. */
body:has(#promptA[readonly]) .mini[data-paste="promptA"] {
    opacity: .45;
    pointer-events: none;
}
textarea.syntax-enabled {
    color: transparent !important;
    caret-color: var(--text);
    position: relative;
    z-index: 2;
    background: transparent !important;
    /* Kill native field rendering so the wrapper's background shows through. */
    appearance: none;
    -webkit-appearance: none;
}

.response-area { background: #fbfbfc; }

/* Response fills the leftover height rather than being a fixed 14 rows, so the
   workspace uses the window instead of leaving dead space under it. */
@media (min-width: 1101px) {
    .col-work { display: flex; flex-direction: column; }
    .response-field { display: flex; flex-direction: column; flex: 1; min-height: 220px; }
    .response-field .text-area,
    .response-field .md-rendered { flex: 1; resize: none; }
}

/* ── focus mode (full-height response) ──────────────────────────────── */

/* Everything in the workspace goes except the response block; the response
   field already carries flex:1, so it takes the reclaimed height. The viewing
   banner is not a .field, so it survives — you can still see which request you
   are on and get back out. The left rail is untouched, which is what lets you
   keep clicking history cards while focused. */
.col-work.is-focused > .field:not(.response-field),
.col-work.is-focused > .provider-row {
    display: none;
}

/* Below the 3-column breakpoint the flex rules don't apply, so give the
   response an explicit height or focus mode would do nothing there. */
@media (max-width: 1100px) {
    .col-work.is-focused .response-field .text-area,
    .col-work.is-focused .response-field .md-rendered { min-height: 70vh; }
}

/* '`' — full viewport width. Overriding the custom property rather than the
   max-width itself keeps the cap expressed in one place, on .shell. */
body.is-wide { --cap-width: none; }

/* Level 2, and the "'" rail toggle on its own: the left rail goes. is-full is
   derived in compose.js (focus level 2 OR railHidden), so these two rules are
   the only place the hiding is expressed. The grid must drop to a single
   column or hiding .side would leave a 340px hole where it used to be. */
body.is-full .side { display: none; }
body.is-full .shell { grid-template-columns: minmax(0, 1fr); }

/* ── rendered markdown ──────────────────────────────────────────────── */

/* Must out-specify `.field-head label` (0,1,1), which uppercases and bolds
   section titles — a bare `.raw-toggle` (0,1,0) loses to it and the control
   rendered as "RAW", competing with the "RESPONSE" heading beside it. */
.field-head label.raw-toggle {
    display: flex;
    align-items: center;
    gap: 4px;
    font-size: 12px;
    font-weight: 400;
    text-transform: none;
    letter-spacing: normal;
    color: var(--text-dim);
    cursor: pointer;
    user-select: none;
}
.raw-toggle input { margin: 0; cursor: pointer; }

.md-rendered {
    padding: 9px 13px;
    border: 1px solid var(--line-strong);
    border-radius: 6px;
    background: #fbfbfc;
    /* Tracks .text-area, and must. The `raw` checkbox swaps this box for the
       textarea holding the same response; a size that didn't match would make
       ticking it reflow the answer you were reading. Neither this nor .view-box
       is focusable, so 16 is inherited from that rule's constraint, not its own.
       Reading the same property is what keeps the three in step when the size
       is changed on /settings — three separate values could not. */
    font-size: var(--text-size, 16px);
    line-height: 1.65;
    overflow-y: auto;
    overflow-wrap: break-word;
    min-height: 120px;
}
.md-rendered:empty::before {
    content: 'The response appears here';
    color: var(--text-faint);
}

/* Headings are sized for a side panel, not a document — h1 at browser default
   would dwarf everything around it. */
.md-rendered h1, .md-rendered h2, .md-rendered h3,
.md-rendered h4, .md-rendered h5, .md-rendered h6 {
    margin: 1.1em 0 .45em;
    line-height: 1.3;
    font-weight: 700;
}
.md-rendered h1 { font-size: 1.32em; }
.md-rendered h2 { font-size: 1.2em; }
.md-rendered h3 { font-size: 1.08em; }
.md-rendered h4, .md-rendered h5, .md-rendered h6 { font-size: 1em; }
.md-rendered > *:first-child { margin-top: 0; }
.md-rendered > *:last-child { margin-bottom: 0; }

.md-rendered p { margin: 0 0 .8em; }

.md-rendered ul, .md-rendered ol { margin: 0 0 .8em; padding-left: 1.4em; }
.md-rendered li { margin-bottom: .25em; }
.md-rendered li > ul, .md-rendered li > ol { margin: .25em 0 0; }

.md-rendered code {
    padding: 1px 5px;
    border-radius: 4px;
    background: #e9ebef;
    font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
    font-size: .88em;
}

.md-rendered pre {
    margin: 0 0 .9em;
    padding: 10px 12px;
    border: 1px solid var(--line);
    border-radius: 6px;
    background: #f4f5f7;
    /* Long lines scroll inside the block; the panel itself must never scroll
       sideways. */
    overflow-x: auto;
}
.md-rendered pre code { padding: 0; background: none; font-size: .85em; }

.md-rendered blockquote {
    margin: 0 0 .9em;
    padding: 2px 0 2px 12px;
    border-left: 3px solid var(--line-strong);
    color: var(--text-dim);
}

.md-rendered a { color: var(--accent); }

.md-rendered hr {
    margin: 1.2em 0;
    border: 0;
    border-top: 1px solid var(--line);
}

.md-rendered table {
    display: block;
    width: max-content;
    max-width: 100%;
    margin: 0 0 .9em;
    border-collapse: collapse;
    overflow-x: auto;
    font-size: .93em;
}
.md-rendered th, .md-rendered td {
    padding: 5px 10px;
    border: 1px solid var(--line-strong);
    text-align: left;
}
.md-rendered th { background: #eceef1; font-weight: 700; }

.md-rendered img { max-width: 100%; height: auto; }

.search-wrap { position: relative; margin-bottom: 10px; }

.search-input {
    width: 100%;
    padding: 8px 26px 8px 10px;
    border: 1px solid var(--line-strong);
    border-radius: 6px;
    /* 16px floor — iOS zoom on focus. See .text-area. */
    font-size: 16px;
}
.search-input:focus { outline: none; border-color: var(--accent); }
.search-input::-webkit-search-cancel-button { display: none; }

.search-clear {
    display: none;
    position: absolute;
    right: 6px;
    top: 50%;
    transform: translateY(-50%);
    width: 18px;
    height: 18px;
    padding: 0;
    border: none;
    border-radius: 50%;
    background: var(--line);
    color: var(--text-dim);
    font-size: 14px;
    line-height: 1;
    cursor: pointer;
}
.search-clear:hover { background: var(--line-strong); color: var(--text); }

/* ── models + health ────────────────────────────────────────────────── */

.model-row { margin-bottom: 9px; }
/* An extra model row stacks under its provider's primary with no chip of its
   own (docs/model_stack_plan.md D1): pulled up so the group reads as one. */
.model-row.is-extra { margin-top: -4px; }

.model-label {
    display: flex;
    align-items: center;
    gap: 5px;
    margin-bottom: 3px;
}
.model-nokey { font-size: 11px; color: var(--err); }

.model-select {
    width: 100%;
    padding: 4px 6px;
    border: 1px solid var(--line-strong);
    border-radius: 5px;
    background: #fff;
    color: var(--text);
    font: 12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
/* The add/remove row in the open list, in the app accent — the colour that
   already means "a control, not content" everywhere else. Desktop Chrome and
   Edge honour option colour in the popup; Safari and the phones show plain
   text and the <hr> under it does the separating there
   (docs/model_stack_plan.md D14). */
.model-select option.sentinel { color: var(--accent); }
/* The open list tinted in its provider's colour, faintly, so you can tell
   whose list is open: every popup was an identical white sheet over the
   rail. On the options, not the select, so the closed box stays white — the
   chip above names it there. Desktop Chrome and Edge paint option
   backgrounds in the popup; Safari and the phones ignore them
   (docs/model_stack_plan.md D15). The text colour is left alone, so the
   accent on the add/remove row still shows. */
.select-chatgpt option { background: color-mix(in srgb, var(--gpt) 10%, #fff); }
.select-grok    option { background: color-mix(in srgb, var(--grok) 7%, #fff); }
.select-gemini  option { background: color-mix(in srgb, var(--gemini) 10%, #fff); }
.select-claude  option { background: color-mix(in srgb, var(--claude) 12%, #fff); }
.model-select:focus { outline: none; border-color: var(--accent); }
.model-select:disabled { background: #f2f3f5; color: var(--text-faint); }

/* Multi-user mode only; [hidden] holds until js/account.js confirms a session,
   so single mode never reserves the space. */
.col-account {
    display: flex;
    align-items: center;
    gap: 8px;
}
.col-account[hidden] { display: none; }
.account-name {
    font-size: 12px;
    color: var(--text-faint);
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
    /* A flex item won't shrink below its content without this, so a long display
       name would push the buttons out of the rail instead of ellipsing. */
    min-width: 0;
}
/* name (gap) [Settings][Sign out] — the buttons stay a pair at the right.
   Only the FIRST one takes the slack: with margin-left:auto on both, flexbox
   splits the free space BETWEEN them and separates the pair, which is what it
   used to do. Anchored to the id rather than :first-of-type, because these are
   an <a> and a <button> and each is first of its own type. */
.col-account .mini { flex: none; }
.col-account #settingsLink { margin-left: auto; }

.health-block { margin-top: 14px; padding-top: 12px; border-top: 1px solid var(--line); }
.health-results { margin-top: 8px; }
.health-results:empty { display: none; }

/* A chip per provider, then a row per model under it — the rail's own shape
   (docs/model_stack_plan.md D13). */
.health-provider { margin: 8px 0 3px; }
.health-provider:first-child { margin-top: 0; }
.health-row {
    display: flex;
    align-items: center;
    gap: 6px;
    flex-wrap: wrap;
    margin-bottom: 3px;
    font-size: 12px;
    color: var(--text-dim);
}
.health-model {
    min-width: 0;
    flex: 1;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
    font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
.health-state { font-weight: 700; }
.health-row.is-ok .health-state { color: var(--ok); }
.health-row.is-error .health-state { color: var(--err); }
.health-time { min-width: 34px; text-align: right; font-variant-numeric: tabular-nums; color: var(--text-faint); }
.health-error {
    flex-basis: 100%;
    font-size: 11px;
    color: var(--err);
    word-break: break-word;
}

/* Wraps rather than truncating: the pair needs 223px of the rail's 311px, so it
   fits with room to spare today, but the stamp is a date that a longer format
   would grow. Wrapping drops the stamp to its own line — what it did before —
   instead of clipping it. */
.health-head {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 10px;
    flex-wrap: wrap;
}
/* No margin-top: the flex row above owns the spacing now. */
.models-verified { font-size: 11px; color: var(--text-faint); }

/* ── history ────────────────────────────────────────────────────────── */

.col-history { display: flex; flex-direction: column; }
/* Only the card list scrolls; the title, search box and pager stay put. */
.history-list { flex: 1; overflow-y: auto; min-height: 0; margin: 0 -4px; padding: 0 4px; }

.history-card {
    margin-bottom: 7px;
    padding: 8px 10px;
    border: 1px solid var(--line);
    border-left: 3px solid var(--line-strong);
    border-radius: 6px;
    background: #fff;
    cursor: pointer;
    transition: border-color .12s, background .12s;
}
.history-card:hover { background: #fafbfc; border-color: var(--line-strong); }
.history-card:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }

.history-card.status-ok      { border-left-color: var(--ok); }
.history-card.status-error   { border-left-color: var(--err); }
.history-card.status-running { border-left-color: var(--run); }

.history-card.is-selected {
    background: #eef3fd;
    border-color: #b9cdf3;
    border-left-color: var(--accent);
}

/* Not clickable until it settles. */
.history-card.is-running { cursor: default; }
.history-card.is-running:hover { background: #fff; border-color: var(--line); }

/* Pinned tint: amber wash + warmed border, the pin pill's own family, so amber
   keeps meaning one thing (pin / preset lock). Three named border sides, never
   the shorthand — the LEFT edge belongs to the status colors above and a
   `border-color` here would clobber it or not by rule order alone.
   Hover deepens the amber instead of falling back to the grey hover, which
   would make the tint vanish exactly when the eye is on the card; the
   running-card hover reset gets the same exception. */
.history-card.is-pinned {
    background: #fdf8ea;
    border-top-color: #e7dab6;
    border-right-color: #e7dab6;
    border-bottom-color: #e7dab6;
}
.history-card.is-pinned:hover,
.history-card.is-running.is-pinned:hover {
    background: #fbf3db;
    border-top-color: #dcc794;
    border-right-color: #dcc794;
    border-bottom-color: #dcc794;
}

/* Selected wins over pinned, hover included: the blue marks "you are viewing
   this", a transient state that outranks a standing marker. Explicit rather
   than left to rule order — the pinned :hover above is more specific than
   .is-selected and would repaint a selected card amber on mouseover. */
.history-card.is-selected.is-pinned,
.history-card.is-selected.is-pinned:hover {
    background: #eef3fd;
    border-top-color: #b9cdf3;
    border-right-color: #b9cdf3;
    border-bottom-color: #b9cdf3;
}

/* Not space-between: with the unread dot there are three children, and that
   would strand the timestamp in the middle of the row. The time pushes itself
   right instead, same idiom as .card-elapsed below. */
.card-top {
    display: flex;
    align-items: center;
    gap: 6px;
    margin-bottom: 4px;
}

.chip {
    padding: 1px 7px;
    border-radius: 10px;
    color: #fff;
    font-size: 11px;
    font-weight: 600;
    letter-spacing: .02em;
    /* Never squished when the model label next to it is long. */
    flex-shrink: 0;
}
.chip-chatgpt { background: var(--gpt); }
.chip-grok    { background: var(--grok); }
.chip-gemini  { background: var(--gemini); }
.chip-claude  { background: var(--claude); }

/* The model, brand prefix stripped (history.js shortModel). In the 340px column
   a long id (haiku-4-5-20251001, 4.20-0309-reasoning) truncates here rather than
   pushing the time off the row; min-width:0 is what lets a flex item shrink below
   its content so the ellipsis can bite. Full id on the title tooltip. */
.card-model {
    min-width: 0;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
    font-size: 12px;
    color: var(--text-dim);
}

.card-time { margin-left: auto; font-size: 12px; color: var(--text-faint); white-space: nowrap; }

/* Unread marker, mail-client style: a dot plus heavier snippet text.
   Accent blue deliberately — the left border already spends green on 'ok' and
   red on 'error', and a green dot beside a green border says nothing. Sits last
   so it forms a straight column down the right edge of the list; chip and
   timestamp widths both vary, so anywhere else it would wander. */
.card-dot {
    flex: none;
    width: 7px;
    height: 7px;
    border-radius: 50%;
    background: var(--accent);
}
.history-card.is-unread .card-snippet { font-weight: 600; }

/* The snippet carries the card's lifecycle on its own: faint while running,
   bold while unread, plain once read. It's the card's largest block of text, so
   fading it is what makes a running card read as hollow next to a finished one —
   and there is nothing to read there yet anyway, seconds after you typed it.
   The chip and the pulsing dot are deliberately untouched: they're the two live
   signals, and dimming the chip would cost you the one thing that says WHICH of
   several in-flight requests this is.

   Same element and same specificity as the unread rule above, but the states are
   mutually exclusive — a running request is never unread (invariant 19) — so
   source order is not deciding anything here. */
.history-card.is-running .card-snippet { color: var(--text-ghost); }
.history-card.is-selected .card-time,
.history-card.is-selected .card-bottom { color: var(--text-dim); }

.card-snippet {
    font-size: 13px;
    line-height: 1.45;
    color: var(--text);
    word-break: break-word;
    /* Two lines max: 100 chars wraps to about that at this width. */
    display: -webkit-box;
    -webkit-line-clamp: 2;
    -webkit-box-orient: vertical;
    overflow: hidden;
}

.card-bottom {
    display: flex;
    align-items: center;
    gap: 8px;
    margin-top: 5px;
    font-size: 12px;
    color: var(--text-dim);
}
.card-status { display: flex; align-items: center; gap: 4px; }
.card-elapsed { margin-left: auto; font-variant-numeric: tabular-nums; }
.card-clip {
    padding: 0 5px;
    border: 1px solid var(--line-strong);
    border-radius: 8px;
    font-size: 11px;
    color: var(--text-dim);
}

/* The pin pill: `pin` muted (the clip badge's weight, ×12 per page has to be
   near-silent), `pinned` amber — the marker color the lock already owns,
   claimed by neither the provider chips nor the status dots. min-width fits
   "pinned" so the label change doesn't jiggle the elapsed time beside it. It
   sits last, after the clip badge, so the badge keeps its place on every card
   whether or not the request has files. */
.card-pin {
    min-width: 48px;
    padding: 0 5px;
    border: 1px solid var(--line-strong);
    border-radius: 8px;
    background: none;
    font-family: inherit;
    font-size: 11px;
    line-height: inherit;
    color: var(--text-dim);
    text-align: center;
    cursor: pointer;
    flex-shrink: 0;
}
.card-pin:hover { border-color: var(--text-faint); }
.card-pin.is-pinned {
    border-color: #ddc383;
    background: #fcf5e2;
    color: #a16207;      /* the preset-lock amber */
    font-weight: 600;
}
.card-pin.is-pinned:hover { border-color: #cbab5e; }

.dot { width: 6px; height: 6px; border-radius: 50%; background: var(--text-faint); }
.status-ok .dot      { background: var(--ok); }
.status-error .dot   { background: var(--err); }
.status-running .dot { background: var(--run); animation: pulse 1.1s ease-in-out infinite; }

@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: .25; } }

.pager {
    display: flex;
    align-items: center;
    justify-content: center;
    gap: 10px;
    padding-top: 10px;
    border-top: 1px solid var(--line);
}
.pager:empty { display: none; }
.pager-info { font-size: 12px; color: var(--text-dim); font-variant-numeric: tabular-nums; }
.pager-total { color: var(--text-faint); }

/* ── buttons ────────────────────────────────────────────────────────── */

.mini {
    padding: 3px 9px;
    border: 1px solid var(--line-strong);
    border-radius: 5px;
    background: #fff;
    color: var(--text);
    font-size: 12px;
    font-family: inherit;
    cursor: pointer;
}
.mini:hover { background: #eceef1; border-color: var(--text-faint); }
.mini:active { transform: translateY(1px); }
/* The Settings entry is an <a> wearing .mini (index.html says why). A button
   defaults to line-height:normal and no underline; an anchor does neither, so
   without these two it sits a pixel taller than Sign out beside it. */
a.mini { text-decoration: none; line-height: normal; }

/* The primary action, ruled off above it. The rule belongs to THIS row — it is
   grouping the buttons under a line, like a heading's border — so sitting closer
   to them than to whatever is above is correct, and deliberate.

   No margin-top, and that is the fix rather than an omission. It used to be
   20px, tuned when Reference files was permanently visible and the thing above
   the line was a short label strip. That section now hides itself whenever it is
   empty (invariant 29), so on most requests the neighbour is a tall textarea and
   the same margin read as a hole: 42px above the line against 19px below it.
   The 42 was three numbers, only one of them visible here — 16px of .field
   margin, this 20px, and 6px of phantom descender under the textarea, which is
   inline-block and so sits on the text baseline. And they ADDED rather than
   collapsing, because .col-work is a flex column above 1100px; as a block, on a
   phone, the same gap collapsed to 26px, so the two layouts disagreed about one
   piece of whitespace.

   At 0 the space above the line comes from the same .field rhythm as every other
   gap in the column: 22px above, 19px below, and identical on both layouts,
   since the collapse is now max(16, 0). With Reference files visible it is 16
   above, so nothing jumps when files appear. */
.provider-row {
    display: grid;
    grid-template-columns: repeat(4, 1fr);
    gap: 8px;
    margin: 0 0 22px;
    padding-top: 18px;
    border-top: 1px solid var(--line);
}

.provider-btn {
    padding: 10px 8px;
    border: 1px solid transparent;
    border-radius: 6px;
    color: #fff;
    font-size: 14px;
    font-weight: 600;
    cursor: pointer;
    transition: filter .12s, transform .06s;
}
/* A stacked button is labelled with the full model id (docs/model_stack_plan.md
   D4), at the brand button's own size, and truncates rather than wrapping
   taller than its neighbours. min-width:0 is load-bearing: 1fr is
   minmax(auto, 1fr), and a long label would otherwise widen its column and
   un-equalise the row. Placement (grid-area, row AND column) is inline from
   compose.js — column alone lets sparse auto-placement drag every later
   primary down a row. */
.provider-btn.is-model {
    min-width: 0;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}
.provider-btn:hover:not(:disabled) { filter: brightness(1.08); }
.provider-btn:active:not(:disabled) { transform: translateY(1px); }
/* Neutral grey rather than a faded brand colour: white-on-pale-tint was barely
   legible, and a missing API key isn't a brand state. */
.provider-btn:disabled {
    background: #e6e7ea;
    color: var(--text-faint);
    cursor: not-allowed;
    filter: none;
}

.provider-chatgpt { background: var(--gpt); }
.provider-grok    { background: var(--grok); }
.provider-gemini  { background: var(--gemini); }
.provider-claude  { background: var(--claude); }

/* ── attachments ────────────────────────────────────────────────────── */

/* Content is the drop zone — see attachments.js for why it is that box and
   nothing wider. Deliberately unlike .text-area:focus, which is a solid accent
   border plus a soft accent glow: a drop state in the same colour AND the same
   shape would read as "you clicked in here". Dashed edge and a wash keeps the
   accent family while looking like a different kind of event.
   An id (1,0,0) out-specifies .text-area, so order here is moot. */
#promptB.is-dropping {
    border: 1px dashed var(--accent);
    background: rgba(32, 89, 208, .06);
}

.attach-list { display: flex; flex-direction: column; gap: 5px; }
.attach-list:empty { display: none; }

.attach-item {
    display: flex;
    align-items: center;
    gap: 9px;
    padding: 5px 8px;
    border: 1px solid var(--line);
    border-radius: 5px;
    background: #fbfbfc;
}
.attach-item.is-bad { border-color: #f0c2be; background: #fdf4f3; }

.attach-thumb {
    width: 34px;
    height: 34px;
    object-fit: cover;
    border-radius: 4px;
    background: var(--line);
    display: block;
}

.attach-kind {
    display: flex;
    align-items: center;
    justify-content: center;
    width: 34px;
    height: 34px;
    border-radius: 4px;
    background: #dfe2e7;
    color: var(--text-dim);
    font-size: 10px;
    font-weight: 700;
}

.attach-meta { flex: 1; min-width: 0; }

.attach-name {
    font-size: 13px;
    color: var(--text);
    text-decoration: none;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
    display: block;
}
a.attach-name:hover { color: var(--accent); text-decoration: underline; }

.attach-size { font-size: 11px; color: var(--text-dim); }

/* Evicted to make room (invariant 35): the row is a record, not a file. */
.attach-item.is-purged { opacity: .6; background: transparent; border-style: dashed; }
.attach-item.is-purged .attach-name { color: var(--text-dim); }

.attach-remove {
    width: 20px;
    height: 20px;
    padding: 0;
    border: none;
    border-radius: 50%;
    background: transparent;
    color: var(--text-faint);
    font-size: 16px;
    line-height: 1;
    cursor: pointer;
}
.attach-remove:hover { background: var(--line); color: var(--err); }

.attach-summary { margin-top: 5px; font-size: 12px; color: var(--text-faint); }
.attach-summary.is-bad { color: var(--err); }

/* ── metadata strip ─────────────────────────────────────────────────── */

.meta-strip {
    margin-bottom: 6px;
    padding: 5px 9px;
    border-radius: 5px;
    background: #e9ebef;
    color: var(--text-dim);
    font: 12px/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
    word-break: break-word;
}
.meta-running { background: #fbeecd; color: #7a5405; }
.meta-ok      { background: #ddf0e4; color: #106033; }
.meta-error   { background: #fbdedb; color: #93231c; }

/* ── viewing state ──────────────────────────────────────────────────── */

/* Compose is the default; viewing swaps in dedicated read-only nodes so the
   preset-backed textareas are never written to. */
.view-box, .view-banner { display: none; }

body.is-viewing .compose-only { display: none; }
body.is-viewing .view-box { display: block; }
body.is-viewing .view-banner { display: flex; }
/* Hidden, not dimmed. Both rows were already inert here (opacity .35 +
   pointer-events: none), but a ghosted control still reads as a control, and
   these two invite exactly the wrong thing: a preset button would switch the
   active slot behind a hidden textarea, and a provider button would fire the
   COMPOSED prompt, not the one on screen. "Load into editor" is the way through.
   Hiding also matches [data-paste] below — the viewing state had two different
   treatments for "not applicable here" — and hands ~90px back to the boxes you
   came here to read. */
body.is-viewing .provider-row { display: none; }

/* /r/<requestId> — the app opened as a single-response reader (app.js sets the
   class). The tab shows the SAME viewing column as the #<requestId> deep link:
   banner, Prompt, Reference files, Response. Only the rail and the banner's
   controls go.

   The rail: hidden here rather than by forcing focus level 2 (which the reader
   used to do, and which also hid Prompt and Reference files). Mirrors is-full,
   and needs the same one-column grid or hiding .side leaves a 340px hole. */
body.is-reader .side { display: none; }
body.is-reader .shell { grid-template-columns: minmax(0, 1fr); }

/* The banner stays for parity with the app — it names the request you are
   reading — but its controls all point back at a session this tab does not
   have: "Back to compose" to a compose pane you never used, "Load into editor"
   to presets you came here to avoid touching, and [delete] to an irreversible
   action nobody wants one careless click from a reading tab. Info only. */
body.is-reader .view-del,
body.is-reader .view-banner-actions { display: none; }

/* New tab would open a second copy of the tab you are already in. Expand is
   back (it was hidden when the reader forced focus mode — Collapse would have
   dropped the tab out of the only layout it had): from the full viewing column
   it is the same two-state control as in the app, and the way to a full-height
   response. */
body.is-reader #openResponseTab { display: none; }

/* Installed to a home screen, "New tab" is offering something that does not
   exist. There are no tabs in a standalone window, so compose.js's
   window.open('/r/…','_blank') either bounces you out to Safari — leaving the
   app you installed — or is swallowed with no feedback at all. Both are worse
   than the button not being there.

   Scoped to display-mode, NOT to a user-agent or a viewport width: the same
   phone running the same page in Safari still has tabs, and the button still
   works there. It goes when the tabs go, which is exactly what this query
   means. #openResponseTab keeps its disabled/enabled handling in compose.js
   either way — hiding a control is not the same as unwiring it, and a rule
   that only applies in one display mode must not leave state behind. */
@media (display-mode: standalone) {
    #openResponseTab { display: none; }
}

body.is-viewing .preset-row { display: none; }
/* Copy went with Paste. It was worse than useless here: data-copy resolves to
   the compose textarea, so in viewing state it silently copied whatever was in
   the current preset instead of the request on screen. The Response copy button
   is the one that gets used, and it is untouched — it carries an id, not a
   data-copy attribute. */
body.is-viewing [data-copy],
body.is-viewing [data-paste] { display: none; }

/* Instruction and Content are a composing convenience; the request that went out
   was one string. Viewing shows it as one "Prompt" box, so the second field goes
   away entirely — which also disposes of the "(empty)" box a request with no
   Content used to leave behind. */
body.is-viewing #promptFieldB { display: none; }

/* Plain view-state visibility, for things that are not boxes. Deliberately not
   .view-box: that class carries the read-only box's border, padding and
   background, and a heading is not a box. */
.view-only { display: none; }
body.is-viewing .view-only { display: inline; }

/* ── split view (Split ↔ Stack) ─────────────────────────────────────── */

/* Two columns, each pane scrolling on its own. One flag and one class in both
   states (docs/compose_split_plan.md); the rules are per state because the
   items' PLACEMENT differs, not because either state needs its own template —
   a hidden child is not a grid item and takes no cell, so a shared template
   would lay out, but the 1fr row belongs to the prompt box while viewing and
   to Content while composing, and the response sits under the banner in one
   and spans the column in the other.

   Viewing: banner across the top, prompt (plus Reference files) left, response
   right. Compose: Instruction, Content, Reference files and the provider row
   left, response right.

   Scoped to the wide breakpoint for the same reason the rail stacks there —
   two ~half-width panes below 1100px would both be unreadable; the button
   hides with it and the layout simply stays stacked. Spacing comes from the
   items' own margins (banner 14px, fields 16px), not row-gap, so it matches
   the stacked rhythm. */
@media (min-width: 1101px) {

    /* -- viewing ------------------------------------------------------ */

    /* Row 3 exists for Reference files. The response spans rows 2–3, which is
       safe against inflating row 3 when the section is hidden: an item
       spanning a flexible track (row 2's 1fr) is ignored when the auto tracks
       it crosses are sized. */
    body.is-viewing .col-work.is-split {
        display: grid;
        grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
        grid-template-rows: auto minmax(0, 1fr) auto;
        column-gap: var(--gap);
    }
    body.is-viewing .col-work.is-split > .view-banner { grid-column: 1 / -1; grid-row: 1; }
    body.is-viewing .col-work.is-split > #promptFieldA {
        grid-column: 1;
        grid-row: 2;
        display: flex;
        flex-direction: column;
        min-height: 0;
        margin-bottom: 0;   /* the pane's floor is the track, not a margin */
    }
    /* Fill the pane; the stacked collapse and the drag handle are both
       neutralized HERE rather than by removing .is-collapsed in JS — the
       class staying put is what re-collapses the box when you Stack. */
    body.is-viewing .col-work.is-split > #promptFieldA pre.view-box {
        flex: 1;
        min-height: 0;
        height: auto;
        resize: none;
    }
    body.is-viewing .col-work.is-split > #referenceField {
        grid-column: 1;
        grid-row: 3;
        margin-top: 16px;   /* replaces the prompt margin zeroed above; gone with the section */
        margin-bottom: 0;
    }
    body.is-viewing .col-work.is-split > .response-field {
        grid-column: 2;
        grid-row: 2 / span 2;
    }

    /* -- compose ------------------------------------------------------ */

    /* Content is the flexing pane and its row is minmax(AUTO, 1fr), not 0:
       the auto minimum makes the track's base size the field's own minimum
       (head + preset row + the four-row floor below), so when a tall
       Instruction plus attachments outgrow the column, Reference files and
       the provider row are PUSHED down and .col scrolls. With a 0 minimum the
       track collapses instead and the floored textarea overflows its field
       straight over the provider buttons — nothing clips it. The response
       cannot inflate the row: its min-height:0 makes its minimum contribution
       0. (Not min-content — that would take the whole rendered answer.) */
    body:not(.is-viewing) .col-work.is-split {
        display: grid;
        grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
        grid-template-rows: auto minmax(auto, 1fr) auto auto;
        column-gap: var(--gap);
    }
    body:not(.is-viewing) .col-work.is-split > #promptFieldA { grid-column: 1; grid-row: 1; }
    /* No min-height:0 here — the field's minimum IS the row's floor. */
    body:not(.is-viewing) .col-work.is-split > #promptFieldB {
        grid-column: 1;
        grid-row: 2;
        display: flex;
        flex-direction: column;
    }
    /* The floor is the box rows="4" gives: four lines of the unitless 1.65
       line-height, plus 9+9 padding and 1+1 border (border-box) — same
       arithmetic as pre.view-box.is-collapsed. A belt against flex-shrink; the
       track's auto minimum is what actually holds the layout. */
    body:not(.is-viewing) .col-work.is-split > #promptFieldB .text-area {
        flex: 1;
        min-height: calc(4 * 1.65em + 20px);
        resize: none;
    }
    /* Pinned by `m` (presets.js toggleMin): the box stops flexing and sits
       at the inline two-row height; the empty space opens below it, inside
       the field, and Reference files and the provider row stay at the
       bottom of the column. Same selector plus a class, so it wins. */
    body:not(.is-viewing) .col-work.is-split > #promptFieldB .text-area.is-min {
        flex: 0 0 auto;
        min-height: 0;
    }
    body:not(.is-viewing) .col-work.is-split > #referenceField { grid-column: 1; grid-row: 3; }
    /* Last in its column: the 22px that separated it from the response below
       would be a dead band at the pane's foot. */
    body:not(.is-viewing) .col-work.is-split > .provider-row {
        grid-column: 1;
        grid-row: 4;
        margin-bottom: 0;
    }
    body:not(.is-viewing) .col-work.is-split > .response-field {
        grid-column: 2;
        grid-row: 1 / -1;
    }

    /* -- both --------------------------------------------------------- */

    /* min-height:auto would let a long response stretch its pane and hand
       scrolling to the whole column — the banner would scroll away and the
       panes would move together, which is the opposite of the feature. Zeroed,
       flex:1 clamps each box to its pane and overflow-y scrolls inside it.
       (0,3,x) — out-specifies the breakpoint's .response-field min-height:220px
       and .md-rendered's min-height:120px. */
    .col-work.is-split > .response-field { min-height: 0; }
    .col-work.is-split > .response-field .md-rendered,
    .col-work.is-split > .response-field .text-area {
        min-height: 0;
    }
}

/* Below the breakpoint neither grid applies, so the button would toggle
   nothing in either state — hide it. */
@media (max-width: 1100px) {
    #splitToggle { display: none; }
}

/* A "Reference files" heading over nothing is noise, and it is noise on the
   large majority of requests in BOTH states — so this is not scoped to viewing.
   It used to be: the body.is-viewing prefix was a safety net, because the file
   picker lived inside this section and one stale is-empty would have hidden it
   for good. Add files now sits in Content's header, outside anything this rule
   can reach, so the way back in no longer depends on getting the class right.
   Two writers, and both must set it from state rather than from an event:
   render() in attachments.js from staged.length, renderViewAttachments() in
   compose.js from the viewed request. attachments.js re-runs render() on
   'viewing:exit' so the compose truth is restored on the way out — which the
   is-viewing prefix used to do for free. */
#referenceField.is-empty { display: none; }

.view-banner {
    align-items: center;
    justify-content: space-between;
    gap: 8px;
    margin-bottom: 14px;
    padding: 8px 11px;
    border: 1px solid #d6e0f5;
    border-radius: 6px;
    background: #eef3fd;
    color: #24467f;
    font: 13px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
.view-banner-actions { display: flex; gap: 6px; }

/* "Viewing {id}" and the delete control ride together on the left. */
.view-banner-left { display: inline-flex; align-items: center; gap: 10px; min-width: 0; }
.view-del { display: inline-flex; align-items: center; gap: 8px; }
.del-done { display: inline-flex; align-items: center; gap: 8px; }
/* [hidden] is only low-specificity display:none — the rule above would beat it,
   leaving the "deleted" group visible before any click. Let hidden win. */
.del-done[hidden] { display: none; }
/* Text links, not buttons — quiet grey until hovered. */
.del-link {
    border: none;
    background: none;
    padding: 0;
    font-size: 12px;
    color: var(--text-faint);
    cursor: pointer;
}
.del-link:hover { color: var(--text-dim); text-decoration: underline; }
/* The "deleted" notice carries a faint red tint so the state reads at a glance. */
.del-flag { font-size: 12px; color: #c0392b; }

.view-box {
    margin: 0;
    padding: 9px 11px;
    border: 1px solid var(--line-strong);
    border-radius: 6px;
    background: #fbfbfc;
    font-family: inherit;
    /* Tracks .text-area — see .md-rendered. */
    font-size: var(--text-size, 16px);
    line-height: 1.65;
    white-space: pre-wrap;
    word-break: break-word;
}

/* The prompt box only (the response boxes size themselves — a cap here once
   froze the response textarea at 220px). It opens COLLAPSED — enterViewing()
   adds .is-collapsed whenever the prompt overflows it — and the native handle
   drags it out. Deliberately a height and NOT a max-height: the browser's
   resize handle cannot drag past a max-height, which is why the old
   380px/45vh cap is gone rather than kept alongside. 2.5 lines, so the third
   line is cut mid-line — the same "there is more below" cue as Content's
   rows=4. In em, never computed px, so it tracks --text-size live (invariant
   30). Touch browsers draw no resize handle; there the box scrolls inside
   itself, so nothing is unreachable. */
pre.view-box {
    resize: vertical;
    overflow-y: auto;
    min-height: calc(1.65em + 20px);
}
pre.view-box.is-collapsed { height: calc(2.5 * 1.65em + 20px); }
pre.view-box:empty::before { content: '(empty)'; color: var(--text-faint); }

/* ── toast ──────────────────────────────────────────────────────────── */

.toast {
    position: fixed;
    left: 50%;
    bottom: 26px;
    transform: translateX(-50%) translateY(12px);
    padding: 9px 16px;
    border-radius: 6px;
    background: #24262b;
    color: #fff;
    font-size: 14px;
    opacity: 0;
    pointer-events: none;
    transition: opacity .18s, transform .18s;
    z-index: 50;
    max-width: 80vw;
}
.toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
.toast-error { background: var(--err); }

/* ---- Models header + stats modal --------------------------------------- */

/* Header row: title left, Stats pill right. The title's own bottom margin is
   dropped so the two align on the flex cross-axis. */
.col-head {
    display: flex;
    align-items: center;
    justify-content: space-between;
    margin-bottom: 12px;
}
.col-head .col-title { margin: 0; }

.pill {
    padding: 3px 12px;
    border: 1px solid var(--line-strong);
    border-radius: 999px;
    background: #fff;
    color: var(--text-dim);
    font-family: inherit;
    font-size: 12px;
    cursor: pointer;
}
.pill:hover  { background: #eceef1; border-color: var(--text-faint); }
.pill:active { transform: translateY(1px); }

/* The History header's pin toggle while pinned-first ordering is on — the same
   amber as a pinned card pill, tying the two controls together. */
.pill.is-on        { border-color: #ddc383; background: #fcf5e2; color: #a16207; font-weight: 600; }
.pill.is-on:hover  { background: #f7ecce; border-color: #cbab5e; }

.modal-overlay {
    position: fixed;
    inset: 0;
    display: flex;
    align-items: center;
    justify-content: center;
    padding: 24px;
    background: rgba(20, 22, 26, .45);
    z-index: 60;
}
.modal-overlay[hidden] { display: none; }

.modal {
    display: flex;
    flex-direction: column;
    width: 100%;
    max-width: 1040px;
    max-height: 85vh;
    overflow: hidden;
    background: var(--panel);
    border: 1px solid var(--line);
    border-radius: var(--radius);
    box-shadow: 0 10px 40px rgba(0, 0, 0, .25);
}
.modal-head {
    display: flex;
    align-items: center;
    justify-content: space-between;
    padding: 14px 18px;
    border-bottom: 1px solid var(--line);
}
.modal-head-left { display: flex; align-items: baseline; gap: 10px; }
.modal-title { margin: 0; font-size: 15px; font-weight: 700; }
.modal-stamp { color: var(--text-faint); font-size: 12px; }
.modal-close {
    border: none;
    background: none;
    padding: 4px 7px;
    border-radius: 5px;
    font-size: 16px;
    line-height: 1;
    color: var(--text-faint);
    cursor: pointer;
}
.modal-close:hover { background: #eceef1; color: var(--text); }
/* Duplicate-send confirmation — a small centred box, not the full stats sheet. */
.modal-confirm { max-width: 420px; padding: 20px; }
.confirm-message { margin: 0 0 18px; font-size: 14px; line-height: 1.5; }
.confirm-actions { display: flex; justify-content: flex-end; gap: 8px; }
.btn-go {
    padding: 6px 14px;
    border: 1px solid var(--accent);
    border-radius: 6px;
    background: var(--accent);
    color: #fff;
    font-size: 13px;
    font-weight: 600;
    cursor: pointer;
}
.btn-go:hover { filter: brightness(1.05); }

.modal-body { overflow: auto; padding: 6px 18px 12px; }
.modal-foot {
    padding: 10px 18px;
    border-top: 1px solid var(--line);
    color: var(--text-faint);
    font-size: 12px;
    /* Disclaimer left, last month's spend hard right. Both strings are long, so
       they wrap onto separate lines rather than colliding on a narrow modal. */
    display: flex;
    justify-content: space-between;
    align-items: baseline;
    gap: 8px 16px;
    flex-wrap: wrap;
}
.modal-foot .foot-last { margin-left: auto; text-align: right; }
/* Pinned rather than left to the UA default, like every other weight here. Only
   the grand total is bold — the per-provider split stays at the strip's weight. */
.modal-foot .foot-last strong { font-weight: 700; }

.stats-table { width: 100%; border-collapse: collapse; font-size: 13px; }
.stats-table th {
    position: sticky;
    top: 0;
    padding: 8px 6px;
    background: #e2e5ea;
    border-bottom: 1px solid var(--line-strong);
    font-weight: 600;
    color: var(--text);
    text-align: right;
}
.stats-table th.mdl,
.stats-table th.rel { text-align: left; }
.stats-table td {
    padding: 6px;
    border-bottom: 1px solid var(--line);
    vertical-align: top;
}
.stats-table td.mdl { white-space: nowrap; }
.stats-table td.rel { color: var(--text-dim); white-space: nowrap; }
.stats-table td.num { text-align: right; white-space: nowrap; }
/* tokens and cost sit on one line: "22k  ~$0.41" */
.stats-table td.num .tok  { color: var(--text); }
.stats-table td.num .cost { margin-left: 8px; color: var(--text-dim); }
/* model ids use the default UI font, not monospace */
.stats-table code { font-family: inherit; font-size: inherit; }

/* Provider subtotal row doubles as the group header — shaded to band the group. */
.stats-table .prov-row td {
    padding-top: 10px;
    background: #eef0f3;
    border-top: 1px solid var(--line);
    border-bottom: 1px solid var(--line);
    font-weight: 700;
}
.stats-table .prov-row td.num .tok  { font-weight: 700; }
.stats-table .prov-row td.num .cost { color: var(--text-dim); }
.stats-table .mdl-row td.mdl { padding-left: 16px; }
/* per-MTok price beside the model name — deliberately faint */
.stats-table .mprice { margin-left: 4px; color: var(--text-faint); font-size: 12px; }

/* Grand total across all providers. Strong top rule sets it apart from the last
   provider band; whole row bold, label pushed right against the figures. */
.stats-table tfoot .total-row td {
    padding-top: 10px;
    border-top: 2px solid var(--line-strong);
    border-bottom: none;
    font-weight: 700;
}
.stats-table .total-label { text-align: right; }
.stats-table .total-row td.num .tok  { color: var(--text); }
.stats-table .total-row td.num .cost { font-weight: 700; color: var(--text-dim); }
