/* ==========================================================================
Brain of Reese — design system (no CDN; system fonts only)
Dark red theme (phase 08): emoji-free chrome, subtle animated pure-CSS
background, WCAG 2.1 AA dark palette (every pair computed >= 4.5:1).
========================================================================== */
:root {
/* Palette — all text/background pairs meet WCAG 2.1 AA (>= 4.5:1) */
--bg: #0f0a0a; /* page: ink on bg 16.7:1 */
--surface: #1a0f0f; /* ink on surface 15.3:1 */
--ink: #f0e6e6;
--ink-soft: #b8a8a8; /* 8.2:1 on --surface */
--line: #2d1a1a; /* decorative 1px borders */
--grid-line: #4a2626; /* background grid texture — decorative, no
contrast duty (like --line) */
--brand: #f43f5e; /* text on brand is DARK ink (--bg): 5.2:1 —
never white on brand (3.7:1, fails) */
--brand-soft: #2d0a0a;
--brand-ink: #fca5a5; /* 9.0:1 on --surface, 12.4:1 on --brand-soft */
/* Derived state colors (phase 92, task 02) — COMPUTED in :root from
the identity palette via color-mix(): NOT stored, NOT tab-controlled,
NOT in BUILTIN_COLORS (theming.py). Every state follows the theme;
the built-in defaults reproduce the pre-phase-92 look, or are the
deliberate legacy-indigo fixes (hovers, busy). */
--brand-hover: color-mix(in srgb, var(--brand) 86%, white);
--brand-busy: color-mix(in srgb, var(--brand) 40%, white);
--brand-stop: color-mix(in srgb, var(--brand) 75%, var(--bg));
--accent-bg: #2b2110;
--accent-ink: #fbbf24; /* 9.5:1 on --accent-bg */
--accent-line: #f59e0b; /* 8.9:1 on --bg (deflection border) */
--ok-bg: #10241b;
--ok-ink: #6ee7a8; /* 10.6:1 on --ok-bg */
--err-bg: #2d0a0a;
--err-ink: #fca5a5; /* 9.3:1 on --err-bg */
--err-line: #ef4444; /* 4.6:1 on --err-bg (UI boundary, not text) */
/* Scrollbar tokens (phase 116, task 01) — the document modal's raw
code block scrolls horizontally (locked A1: code never wraps), so
its scrollbar must match the theme instead of the browser-native
light bar. Like the derived state colors above, COMPUTED in :root
from the identity palette via color-mix(): NOT stored, NOT
tab-controlled, NOT in BUILTIN_COLORS (theming.py) — every saved
theme repaints them. Muted thumb on a near-code-background track
(the .doc-raw background is --surface); the thumb holds >= 3:1
non-text contrast against the track (WCAG 2.1 AA — 3.7:1 at the
built-in palette). */
--scrollbar-thumb: color-mix(in srgb, var(--ink-soft) 60%, var(--surface));
--scrollbar-track: color-mix(in srgb, var(--surface) 75%, var(--bg));
--radius: 10px;
--radius-sm: 6px;
--shadow: 0 1px 2px rgb(0 0 0 / 0.30), 0 4px 16px rgb(0 0 0 / 0.35);
--shadow-lg: 0 4px 10px rgb(0 0 0 / 0.40), 0 12px 32px rgb(0 0 0 / 0.50);
--font: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
--header-h: 64px;
/* Reading column: 72rem — EQUAL to the .container's 72rem cap, so
the reading columns (chat, shared, the document viewer's .doc-md
+ its summary panel) ride the same width as every other page
(owner instruction 2026-09-12: "match the width of the RAG page
for all other pages" — supersedes the 2026-08-31 instruction, and
the wide-desktop doubling with it). */
--chat-column: 72rem;
}
* { box-sizing: border-box; }
/* Phase 60 (owner confirmation 2026-08-31, TODO L3): body must NOT carry a
fixed height — a sticky element's travel range is capped by its
containing block, and `body { height: 100% }` pinned the box to one
viewport, so the navbar un-pinned after ~1 viewport of scroll.
`min-height: 100dvh` on body (below) is what stretches short pages. */
html { height: 100%; }
/* The visible page background lives on (the canvas).
must
stay transparent and must NOT create a stacking context, or the
z-index:-1 background layers below would be painted over. */
html { background: var(--bg); }
body {
margin: 0;
font-family: var(--font);
font-size: 16px;
line-height: 1.55;
color: var(--ink);
background: transparent;
position: relative;
display: flex;
flex-direction: column;
min-height: 100dvh;
}
/* ---------- Static background (pure CSS, zero JS) ----------
Phase 78 (owner direction, TODO.md L4): the animated background was
removed as too resource-intensive — the three opacity-fading glow
spots (and their keyframes) are deleted. What remains is fully
static: the 44px grid texture below — zero animation cost, zero JS,
no filter/blur. */
/* Static grid texture: 44px cells, 1px lines at 60% --grid-line, masked
with a widened radial fade (visible across most of the viewport,
fading to the corners). Phase 25 (owner 2026-08-25): the grid drift is
REMOVED — the 0.73px/s sub-pixel drift rasterizes as a once-per-second
down-right jitter, and the owner wants no movement. The grid stays as
a still texture. Phase 92 (task 02): the line color is the 9th
identity variable --grid-line (the built-in at 60% alpha — the tab's
"Grid lines" picker repaints this texture; it was a hardcoded
literal before). */
body::before {
content: "";
position: fixed;
inset: 0;
z-index: -1;
pointer-events: none;
background-image:
linear-gradient(to right, color-mix(in srgb, var(--grid-line) 60%, transparent) 1px, transparent 1px),
linear-gradient(to bottom, color-mix(in srgb, var(--grid-line) 60%, transparent) 1px, transparent 1px);
background-size: 44px 44px;
-webkit-mask-image: radial-gradient(140% 110% at 50% 0%, black 40%, transparent 90%);
mask-image: radial-gradient(140% 110% at 50% 0%, black 40%, transparent 90%);
}
.container {
width: 100%;
max-width: 72rem;
margin-inline: auto;
padding-inline: 1.25rem;
}
/* ---------- Accessibility helpers ---------- */
/* The `hidden` attribute must always win — some components set an explicit
`display` (e.g. .kb-banner { display: flex }) which would otherwise override
the UA stylesheet's `[hidden] { display: none }` and leave the element visible. */
[hidden] { display: none !important; }
.visually-hidden {
position: absolute !important;
width: 1px; height: 1px;
margin: -1px; padding: 0;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
border: 0;
}
.skip-link {
position: absolute;
left: -9999px;
top: 0;
background: var(--brand);
color: var(--bg);
padding: 0.6rem 1rem;
border-radius: 0 0 var(--radius-sm) 0;
z-index: 100;
}
.skip-link:focus { left: 0; }
:focus-visible {
outline: 3px solid var(--brand);
outline-offset: 2px;
border-radius: 4px;
}
::selection {
background: color-mix(in srgb, var(--brand) 45%, transparent);
color: var(--ink);
}
/* ---------- Header ---------- */
.app-header {
height: var(--header-h);
background: var(--surface);
position: sticky;
top: 0;
z-index: 20;
/* Phase 12 (reworded phase 60): body is a flex column that stretches
to at least one viewport (min-height: 100dvh) and grows with its
content; without this the header shrinks (flex-shrink:1) to its
content minimum on any page whose content overflows the viewport
(e.g. Sources at ≤640px). */
flex-shrink: 0;
}
/* 2px brand-fade hairline under the sticky header (phase 08; shared by
the app header and the document-viewer header, phase 10). Phase 92
(task 02): the pre-theme rose→orange→amber art direction retires —
the 2px line now fades from the brand (55% → 30% → 5% of --brand)
and follows the theme (deliberate default change). In the viewer's
two-row header (phase 34) this lands at the BOTTOM edge of the whole
header — row 1's own copy is suppressed there (see the .doc-header
rules below). */
.app-header::after,
.doc-header::after {
content: "";
position: absolute;
inset-inline: 0;
bottom: -2px;
height: 2px;
pointer-events: none;
background: linear-gradient(
90deg,
color-mix(in srgb, var(--brand) 55%, transparent),
color-mix(in srgb, var(--brand) 30%, transparent) 45%,
color-mix(in srgb, var(--brand) 5%, transparent) 90%
);
}
/* margin-left:auto on the nav (not justify-content:space-between) so the
phase-14 "New chat" pill clusters with the nav on the right while the
two-child Sources header keeps the exact same look. */
.header-inner {
height: 100%;
display: flex;
align-items: center;
gap: 1rem;
}
.brand {
display: inline-flex;
align-items: center;
gap: 0.55rem;
font-size: 1.125rem;
color: var(--ink);
text-decoration: none;
/* Phase 34 task 04 (visual pass): the bar must fit the FULL admin
control set at every width — the wordmark is the designated
squeeze target (min-width:0 lets flex shrink it; overflow:hidden
clips it clean instead of letting it overlap the nav). */
min-width: 0;
overflow: hidden;
}
/* Mono wordmark with letter-spacing — the "technical" touch (phase 08);
ellipsizes as the squeeze target (phase 34 task 04). */
.brand-text {
font-family: var(--mono);
font-size: 0.95rem;
letter-spacing: 0.08em;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.brand-mark { width: 22px; height: 22px; flex: 0 0 auto; display: block; }
/* The wordmark's theming resolves HERE from :root — the markup carries
no inline style attributes: this policy's style-src (phase 82 A1:
'self' + the theme tag's sha256, no 'unsafe-inline') blocks inline
style attributes (hashes never cover them), so the identity vars
land via this stylesheet instead (CSP-clean 'self'). The :nth-child
keys follow the wordmark's fixed 3-shape markup (hexagon, dot,
spokes) — identical in all five HTML files. */
.brand-mark path:nth-child(1) { fill: var(--surface); stroke: var(--brand); }
.brand-mark circle { fill: var(--brand); }
.brand-mark path:nth-child(3) { fill: none; stroke: var(--brand-ink); }
.brand-text strong { color: var(--brand-ink); font-weight: 700; }
.app-nav { display: flex; gap: 0.25rem; margin-left: auto; }
.nav-link {
padding: 0.5rem 0.9rem;
border-radius: 999px;
text-decoration: none;
color: var(--ink-soft);
font-weight: 600;
font-size: 0.95rem;
min-height: 44px;
display: inline-flex;
align-items: center;
}
.nav-link:hover { background: var(--brand-soft); color: var(--brand-ink); }
.nav-link.is-active { background: var(--brand); color: var(--bg); }
/* Phase 46 (owner permission 2026-08-27, TODO.md L9): the mobile
hamburger button — desktop is byte-identical to before (the control
is absent outside the ≤640px block, which re-displays it and turns
the nav into the dropdown). :focus-visible inherits the global 3px
outline rule; the 44px target + the rest of the look live in the
≤640px block below. */
.nav-toggle { display: none; }
/* "New chat" reset (phase 14): ghost pill in the chat header, hover like
a nav link. ink-soft on surface ≈6.9:1; hover pair brand-ink/brand-soft
≈6.9:1 — both WCAG AA. Icon-only below 640px (aria-label keeps the
accessible name); ≥44px touch target at every width. */
.new-chat-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.4rem;
min-height: 44px;
padding: 0.5rem 0.9rem;
border-radius: 999px;
border: 0;
background: var(--brand);
color: var(--bg);
font: inherit;
font-weight: 700;
font-size: 0.95rem;
white-space: nowrap;
cursor: pointer;
}
.new-chat-btn:hover { background: var(--brand-hover); color: var(--bg); }
/* The plus mark is hidden on desktop (label carries the pill); it is the
whole control below 640px. */
.new-chat-btn svg { width: 16px; height: 16px; display: none; }
/* Phase 55 (owner-locked A2, 2026-08-31): the phase-50 Save-pill rules
are GONE with the pill — every conversation auto-saves (app.js);
the chat-shell actions are New chat + Share only.
Phase 51 (owner-locked 2026-08-29, TODO.md L6): the "Share" pill —
the EXACT visual family of .new-chat-btn (same declarations, so the
chat-shell actions always read as a pair): solid brand pill, --bg
text on --brand (5.2:1, WCAG AA), borderless, ≥44px target, hover
lightens the brand fill, focus-visible via the global 3px rule.
Shipped hidden in index.html — app.js reveals it for admin only
(phase 16 absent-not-hidden); ≤640px overrides below mirror the New
chat ones (label stays visible in .chat-shell, icon hidden there). */
.share-chat-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.4rem;
min-height: 44px;
padding: 0.5rem 0.9rem;
border-radius: 999px;
border: 0;
background: var(--brand);
color: var(--bg);
font: inherit;
font-weight: 700;
font-size: 0.95rem;
white-space: nowrap;
cursor: pointer;
}
.share-chat-btn:hover { background: var(--brand-hover); color: var(--bg); }
/* The link mark is hidden on desktop (the label carries the pill); it
is the whole control below 640px (mirrored in the ≤640 block
below). */
.share-chat-btn svg { width: 16px; height: 16px; display: none; }
/* Phase 16: header auth controls (Sign in link / Sign out button) — the
same ghost pill as New chat, so the bar keeps one visual language.
ink-soft on surface ≈6.9:1; hover pair brand-ink/brand-soft ≈6.9:1.
Icon-only below 640px (aria-labels/labels keep the accessible names);
≥44px touch target at every width. Exactly one is ever visible. */
.auth-link {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.4rem;
min-height: 44px;
padding: 0.5rem 0.9rem;
border-radius: 999px;
border: 1px solid var(--line);
background: transparent;
color: var(--ink-soft);
font: inherit;
font-weight: 600;
font-size: 0.95rem;
white-space: nowrap;
text-decoration: none;
cursor: pointer;
}
.auth-link:hover { background: var(--brand-soft); color: var(--brand-ink); }
.auth-link:disabled { opacity: 0.6; cursor: wait; }
.auth-link svg { width: 16px; height: 16px; display: none; }
/* Mobile-only sign-out / sign-in copies: hidden on desktop,
revealed inside the hamburger dropdown on mobile (phase 46 UX revision). */
.sign-out-mobile,
.sign-in-mobile {
display: none;
}
/* ---------- Main frame ---------- */
/* Phase 52 (owner revision 2026-08-30): `flex: 1` is load-bearing, not
cosmetic. Body is a `min-height: 100dvh` column flex box, so `main`
grows to the full viewport height and `.chat-shell` grows to fill
`main`. That full-height column is what makes the composer's sticky
shift range tall enough to hold the box on screen — the pin works in
BOTH directions only because of it (see `.composer` and `.messages`).
No `overflow` here: the document must stay the scroll container. */
.app-main {
flex: 1;
display: flex;
flex-direction: column;
padding-block: 1.25rem;
}
/* Phase 76 (task 02): the shell's #view-chat wrapper sits between
.app-main and .chat-shell — it must CONTINUE the full-height column
chain (body's min-height: 100dvh flex → .app-main → #view-chat →
.chat-shell, phase 52) or .chat-shell's flex:1 loses its flex
parent and the column sizes to content: the composer's sticky pin
(phases 43/55/65) then has no tall scroll range and the empty chat
stops resting at the screen bottom. The folded document views
(tuning/rag/git-sources) size to content in normal flow — they need
no chain. */
#view-chat {
flex: 1;
display: flex;
flex-direction: column;
}
/* Chat is a vertical conversation: a centered, capped column is the
correct layout here (PLAN §UI/UX). The surrounding frame keeps it
from collapsing into a hairline on wide screens. The cap is the
--chat-column token: 72rem — equal to the .container's cap, so the
chat column reads at the RAG page's full container width at every
viewport (owner instruction 2026-09-12: "match the width of the
RAG page for all other pages" — the wide-desktop doubling was
retired with it). */
.chat-shell {
max-width: var(--chat-column);
margin-inline: auto;
display: flex;
flex-direction: column;
gap: 1rem;
flex: 1;
}
/* Phase 55 (task 05, 2026-08-31, TODO.md L6, owner-locked A5): the
chat-actions row — New chat + Share share ONE .chat-actions element
(a normal .chat-shell column child). As a flex ITEM of the column it
spans the column width, but as a flex ROW with align-items: center
(not the column default stretch) each pill keeps its INTRINSIC
content width — two pills side by side on desktop. The ≤640px block
flips this to a vertical stack (flex-direction: column +
align-items: stretch — full-width pills, New chat above Share; the
existing ≤640px pill rules apply to the stacked pills unchanged).
The reading-column contract is untouched (--chat-column: 72rem —
the .container width at every viewport).
Phase 65 (task 03, 2026-09-01, `TODO.md` L3, owner-locked A2): the
row is the TOP member of the pinned .chat-bottom cluster (task
01/02) and hugs the column's RIGHT edge on desktop
(justify-content: flex-end) — mirroring the right-aligned
"Save as doc" action of every brain bubble above it, so the
bottom-right of the chat reads as ONE aligned action column. The
pills keep their intrinsic widths (align-items: center already
beats the column default stretch; justify-content only shifts the
group). The vertical row↔composer spacing is the .chat-bottom
wrapper's 0.5rem gap (task 02). The ≤640px stack rule below is
UNCHANGED — with stretched full-width pills the horizontal
alignment is moot (the stacked pills already edge-to-edge the
column). */
.chat-actions {
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-end;
gap: 0.6rem;
}
/* Phase 52 (owner revision 2026-08-30): `flex-grow` is the other half of
the pin. `position: sticky` only ever pulls a box UP toward the
viewport bottom — it can never push a box DOWN to meet it — so on an
empty/short chat the composer used to sit just under the empty state,
mid-screen, with a dead band between it and the footer. Growing the
message list absorbs that free space instead, so the composer's
resting (in-flow) position already IS the bottom of the screen. Once
the conversation is taller than the viewport there is no free space
left to absorb, `flex-grow` does nothing, and the `.composer` sticky
rule takes over. `flex-basis: auto` (not `1`) so the list keeps its
content height when the page overflows; the phase-01 `min-height`
floor stays; still no inner scroller (the document scrolls). */
.messages {
flex: 1 1 auto;
display: flex;
flex-direction: column;
gap: 0.9rem;
min-height: 200px;
}
/* ---------- Messages ---------- */
.msg { display: flex; gap: 0.6rem; max-width: 100%; }
.msg .avatar {
flex: 0 0 auto;
width: 34px; height: 34px;
border-radius: 50%;
display: grid;
place-items: center;
background: var(--brand-soft);
border: 1px solid var(--line);
}
/* Emoji-free inline SVG glyphs (phase 08), currentColor so the theme
controls the stroke; sized to sit crisp at ~16-20px. */
.msg .avatar svg { width: 20px; height: 20px; display: block; }
.msg.brain .avatar { color: var(--brand-ink); }
.msg.user .avatar { color: var(--ink-soft); }
.msg-body {
max-width: 85%;
/* min-width: 0 — as a flex item this overrides min-width:auto so a
long unbroken token can wrap (overflow-wrap: anywhere) instead of
widening the bubble past the column (AC7, phase 07). */
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.bubble {
padding: 0.7rem 1rem;
border-radius: var(--radius);
background: var(--surface);
border: 1px solid var(--line);
box-shadow: var(--shadow);
overflow-wrap: anywhere;
}
.bubble p { margin: 0.2rem 0; }
.bubble pre {
background: var(--surface);
color: var(--ink);
padding: 0.7rem 0.9rem;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
overflow-x: auto;
font-size: 0.85rem;
font-family: var(--mono);
}
.bubble code { font-family: var(--mono); font-size: 0.88em; background: var(--brand-soft); padding: 0.08em 0.35em; border-radius: 5px; }
.bubble pre code { background: none; padding: 0; }
/* GFM pipe tables (phase 44, 2026-08-27, TODO.md L6): the shared
renderer wraps every table in .md-table-wrap — the horizontal
scroller, so a wide table scrolls inside the bubble instead of
breaking the reading column (--chat-column: 72rem — the
.container width at every viewport) — around a semantic
(escape-first cells; alignment colons render left, owner decision).
Phase-08 tokens only: --line hairline borders and the thead tinted
from the plain surface family — --ink on --surface is 14.5:1 (PLAN
§7.2), never the brand. The rules are unscoped on purpose: the same
renderer serves the chat bubble, the thinking scratchpad, and the
document viewer (.doc-md). width:100% stretches narrow tables to the
column; min-width:max-content lets a WIDE table keep its natural width
so the wrapper is the real scroller (phase 44 task 03: width:100% alone
wrapped the wide table's cells and it never overflowed). Static content
— no animation (nothing for prefers-reduced-motion to still). */
.md-table-wrap { overflow-x: auto; }
.md-table { border-collapse: collapse; width: 100%; min-width: max-content; font-size: 0.9rem; }
.md-table th, .md-table td { border: 1px solid var(--line); padding: 0.4rem 0.6rem; text-align: left; vertical-align: top; }
.md-table thead th { background: var(--surface); color: var(--ink); }
/* Markdown blockquotes (phase 138, 2026-09-30, TODO L3): the shared
renderer (markdown.js step 2b) turns a run of "> "-prefixed lines
into
— ONE rule set serves all three
rendering contexts (the chat .bubble, the details.thinking
scratchpad, the document viewer's .doc-md): every one sits on the
--surface canvas (the deflected bubble's --accent-bg holds too —
ink-soft there is 6.9:1), so a single AA pairing covers them all.
A visible-but-restrained left border + padding + a muted ink — NO
background fill (locked A2). Palette vars only (the :root-drift
test parses the built-ins from this file — no hex literal here):
border-left --brand-ink — 9.9:1 on --surface, the rose accent bar
(--line would be invisible: 1.14:1 on the surface;
plain --brand vanishes on the user bubble, 1:1);
color --ink-soft — 8.2:1 on --surface, the same AA pairing
the --ink-soft :root comment documents.
Static content — no animation (nothing for prefers-reduced-motion
to still). */
.md-quote {
border-left: 3px solid var(--brand-ink);
padding: 0.25rem 0.75rem;
margin: 0.5rem 0;
color: var(--ink-soft);
}
/* The USER bubble also runs the question through the shared renderer
(app.js addMessage), on the --brand fill with --bg text: --ink-soft
there is 1.6:1 — an AA failure — so the quote keeps the bubble's
own dark ink, 5.2:1 on --brand (the :root --bg pairing). The
--brand-ink bar holds 1.9:1 against the fill — still visible, the
quote stays distinct. */
.msg.user .bubble .md-quote { color: var(--bg); }
.msg.user { justify-content: flex-end; }
.msg.user .msg-body { align-items: flex-end; }
.msg.user .bubble {
background: var(--brand);
border-color: var(--brand);
color: var(--bg);
border-bottom-right-radius: 4px;
}
.msg.user .bubble code { background: color-mix(in srgb, var(--bg) 16%, transparent); }
/* Phase 123 (task 02, TODO L6): the question's IMAGE in the user
bubble — the attachment is PART of the question, so it sits at the
TOP of the bubble (above the text). Capped height (a tall portrait
must not blow the chat column) + full-width safe; the theme's
bordered-image treatment (the .source-image-img language) — the
border gives the bytes a boundary against the brand bubble fill. */
.msg-image {
display: block;
width: auto;
height: auto;
max-width: 100%;
max-height: 240px;
object-fit: contain;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
margin-bottom: 0.45rem;
}
/* Phase 123 (task 03, TODO L6): the question's image is UNAVAILABLE —
the stored file was deleted out-of-band (the record keeps its
path, the render degrades): the small muted line takes the img's
place at the top of the user bubble on BOTH pages (the chat
restore and the shared view render the same record shape through
the same .msg/.bubble structure, and share this stylesheet). It
inherits the bubble's text color — the user bubble's text already
passes 4.5:1 (PLAN §7.2), so the note does too; the smaller size +
italic make it read as a note, never a broken-image icon. The
margin-bottom mirrors .msg-image's, so the text below keeps its
spacing either way. */
.msg-image-unavailable {
display: block;
margin-bottom: 0.45rem;
font-size: 0.75rem;
font-style: italic;
}
.msg.brain .bubble { border-bottom-left-radius: 4px; }
.msg.brain.is-deflected .bubble {
background: var(--accent-bg);
border-color: var(--accent-line);
}
/* Collapsible "Thinking" block (phase 17): the model's reasoning streams
open ABOVE the answer bubble, auto-collapses when the answer starts, and
stays user-toggleable (native / — a real focusable
control: >=44px target, :focus-visible via the global rule). Summary
text is brand-ink on surface ≈8.7:1; the scratchpad body is ink-soft on
surface ≈6.9:1 — both AA. */
details.thinking {
background: var(--surface);
border: 1px solid var(--line);
border-left: 3px solid var(--brand-soft);
border-radius: var(--radius-sm);
margin: 0 0 0.5rem;
overflow: hidden;
}
details.thinking summary {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
min-height: 44px;
color: var(--brand-ink); /* 8.7:1 on --surface */
font-size: 0.9rem;
cursor: pointer;
list-style: none;
}
details.thinking summary::-webkit-details-marker { display: none; }
/* CSS chevron: ▸ rotates 90° when open (transition stills under
prefers-reduced-motion — see the reduced-motion block below). */
details.thinking summary::before {
content: "▸";
display: inline-block;
transition: transform 0.15s ease;
}
details.thinking[open] summary::before { transform: rotate(90deg); }
details.thinking summary:focus-visible {
outline: 3px solid var(--brand);
outline-offset: 2px;
}
/* The scratchpad is a live-tail, compact area (max-height keeps long
reasoning from pushing the answer off-screen while open). */
details.thinking .thinking-text {
padding: 0 0.75rem 0.75rem;
color: var(--ink-soft); /* 6.9:1 on --surface */
font-size: 0.875rem;
line-height: 1.55;
max-height: 320px;
overflow-y: auto; /* user-scrollable window (owner direction 2026-08-27,
TODO.md L7): autoscroll follows the live tail
only while the user is pinned near the window's
bottom — the phase-17 pin, gated in app.js (task
02: THINKING_NEAR_BOTTOM_PX); scrolling up
pauses the follow, returning to the bottom
resumes it. */
}
/* The scratchpad is compact: tighten the renderer's paragraph/list margins. */
details.thinking .thinking-text p,
details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
/* Phase 117: the tool-call record folds into a single disclosure line,
reusing the Thinking block's native convention. Open while
the turn is live (phase 87's elapsed suffix stays visible), folded at
rest / on restore. The summary is a native focusable toggle — it
inherits the house 3px :focus-visible ring (see details.thinking
summary). Accent rides the TEXT (var(--accent-ink), ≈10.4:1 on the
surface) — text + color, never color alone (B5). No new literal. The
chevron matches details.thinking summary::before so the two
disclosures read as one family — the count text is the accessible
label either way. */
.tool-calls-disclosure { margin: 0.25rem 0; }
.tool-calls-summary {
cursor: pointer;
color: var(--accent-ink);
font-size: 0.8rem;
line-height: 1.4;
padding: 0.15rem 0;
list-style: none; /* the native marker is redundant with the count text */
}
.tool-calls-summary::-webkit-details-marker { display: none; }
/* CSS chevron: ▸ rotates 90° when open — the same family treatment as
details.thinking summary::before (the transition stills under
prefers-reduced-motion — see the reduced-motion block below). */
.tool-calls-summary::before {
content: "▸";
display: inline-block;
margin-right: 0.5rem;
transition: transform 0.15s ease;
}
.tool-calls-disclosure[open] .tool-calls-summary::before {
transform: rotate(90deg);
}
/* Agent tool-call lines (phase 37, deboxed in phase 117): one compact
inline-flow row per `tool` SSE frame — in the same wrap as the
Thinking block, above the answer, below the Thinking summary, inside
the phase-117 disclosure. Deliberately distinct from the scratchpad:
accent text (--accent-ink) vs the brand-ink summary. Contrast:
--accent-ink on the surface ≈10.4:1 (11.6:1 on the page bg), and
--ink on the surface in the path `code` ≈11.5:1 — all comfortably AA
in the (single dark) theme. Inline rows only: appending lines never
shifts the chat column (72rem — the .container width at every
viewport; no new container), and the rows are not interactive — no
focus targets (the fold is the disclosure's native summary). */
.tool-calls {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
/* Phase 117 deboxed the line (owner visual-glitch report 2026-09-15):
no card, no flex — the label + the inline `code` flow as ONE
continuous run, so a long path wraps to the left edge like a normal
sentence and the label no longer breaks mid-word ("Rea/ding" is
gone). The accent now rides the TEXT color (var(--accent-ink))
instead of a border. */
.tool-call {
color: var(--accent-ink);
font-size: 0.8rem;
line-height: 1.4;
overflow-wrap: anywhere;
}
/* Phase 117 dechipped the path: it keeps the mono + ink treatment
(var(--ink) on the surface ≈11.5:1 — AA) but loses the chip
background — it is inline text in the line's run now. */
.tool-call code {
font-family: var(--mono);
font-size: 0.95em;
color: var(--ink);
overflow-wrap: anywhere;
}
/* Phase 87 (TODO.md L5): the latest tool line's visible "processing"
suffix — ticking "(Ns)" while ≥5s of the turn's stream stay silent
after the call; removed on the next frame (settle). ink-soft on the
bubble surface (the AA pairing), small mono like every status line. */
.tool-elapsed {
font-family: var(--mono);
font-size: 0.75rem;
color: var(--ink-soft);
margin-left: 0.5rem;
white-space: nowrap;
}
/* Phase 95 (task 02): the truncation marker on a Reading line — the
" (truncated — showing N of M chars)" span the `tool_result` frame
appends (live) or the restore/shared paths re-render from the stored
record. Theme-neutral, NO new hue (the phase-92 zero-literal
invariant): it borrows --ink-soft — the same AA-safe soft-ink the
status suffixes use — so under phase 93's monochrome theme it grays
automatically, and the marker stays TEXT, never color alone (B5). */
.tool-call .truncated-note {
color: var(--ink-soft);
}
.msg-meta {
font-size: 0.75rem;
color: var(--ink-soft);
padding-inline: 0.25rem;
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
align-items: center;
}
/* Phase 130 (task 03, TODO L6): the ACTIONS cluster — the bubble's
LAST .msg-meta row (Tune / Retry / Save-as-doc + the Stopped/Failed
notes — phase 130 task 02's getActionsRow). The row's justify does
the TODO's "bottom right" (the bubble's bottom row, right edge);
the buttons are plain flex items of the row — the per-button auto
margins (the phase-49/59 .tune-btn/.retry-btn/.save-as-doc-btn
margin mechanisms) are gone, so chips and figures can never push
the cluster around. DOM order inside the row = insertion order
(Tune, Retry, Save-as-doc per their gates). */
.msg-meta.msg-actions {
justify-content: flex-end;
}
/* Phase 130 (task 03, TODO L6): the source-IMAGES cluster — the
second .msg-meta row appendSources builds (phase 130 task 01):
one .source-image figure per image ref, no chips (the figure IS
the citation for image docs). The explicit rule PINS the chip row's
rhythm — wrap + the base .msg-meta 0.35rem gap + center align — so
a future base-rule change cannot drift the two clusters apart. */
.msg-meta.msg-images {
flex-wrap: wrap;
gap: 0.35rem;
align-items: center;
}
.source-chip {
display: inline-flex;
align-items: center;
gap: 0.3rem;
font-family: var(--mono);
font-size: 0.72rem;
background: var(--brand-soft);
color: var(--brand-ink);
border: 1px solid var(--line);
border-radius: 999px;
padding: 0.15rem 0.6rem;
text-decoration: none;
max-width: 100%;
/* min-width: 0 so the nowrap pill ellipsizes instead of widening its row */
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.source-chip:hover { background: var(--brand-soft); text-decoration: underline; }
/* Phase 122 (task 05): the sources block's COMPACT INLINE IMAGE for a
retrieved IMAGE document ("shown in the chat nicely", TODO L6) — the
.source-image figure sits beside its chip in the .msg-meta flex row
(additive: the chip's text and affordance stay). A capped (96px,
contain) img on the theme's surface, the document's summary as the
visible caption in the AA-safe muted ink (8.6:1 on --bg — the
row's page background). A failed image load removes the whole
figure in JS (the plain chip stays — never a broken-image icon). */
.source-image {
display: inline-flex;
flex-direction: column;
align-items: center;
gap: 0.15rem;
max-width: 100%;
min-width: 0;
text-decoration: none;
}
.source-image-img {
display: block;
max-height: 96px;
max-width: 100%;
width: auto;
height: auto;
object-fit: contain;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius-sm);
}
.source-image-caption {
max-width: 14rem;
color: var(--ink-soft); /* 8.6:1 on --bg (AA) — the row's page background */
font-size: 0.7rem;
line-height: 1.25;
text-align: center;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.source-image:hover { text-decoration: underline; } /* flat — the caption underlines, the chip pattern */
/* Phase 124 (task 06, TODO L3): the LLM's ANSWER IMAGE — the brain
answer's first-class figure (A4: below the text, above the chips).
The .source-image language at ANSWER scale — the figure is the
answer's own image, not a chip accessory: max-width 100% of the
.msg-body column, a 320px height cap (the 46rem chat column — a
full-height image would blow the turn's height), contain on the
theme's surface with the bordered-image treatment
(.source-image-img's border/background/radius), the visible
caption in the AA-safe muted ink (the same --ink-soft the chip
caption uses — the row's page background, 8.6:1). Token-only (the
zero-literal invariant — the monochrome themes carry it). The
hover underline applies to the chat page's ANCHOR figure only (the
shared page's figure is a plain — zero controls, the
element-type scope keeps it out). */
.answer-image {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.2rem;
max-width: 100%;
min-width: 0;
}
.answer-image-img {
display: block;
max-width: 100%;
max-height: 320px; /* the one free number (task 06): the answer scale */
width: auto;
height: auto;
object-fit: contain;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius-sm);
}
.answer-image-caption {
max-width: 100%;
color: var(--ink-soft); /* 8.6:1 on --bg (AA) — the row's page background */
font-size: 0.75rem;
line-height: 1.25;
text-align: center;
display: -webkit-box;
-webkit-line-clamp: 3; /* the summary can be long; the alt + title carry the full text */
-webkit-box-orient: vertical;
overflow: hidden;
}
a.answer-image { text-decoration: none; }
a.answer-image:hover { text-decoration: underline; } /* flat — the caption underlines, the chip pattern */
/* Phase 113 (task 02): the related-docs row — the SECONDARY tier of
scored docs (phase 113 task 01's usefulness bar demotes the
sub-floor hits out of the citation surface; on a deflected turn the
weak hits live HERE and the chip row is absent). It must never read
as a citation: smaller mono links, DASHED border (the solid brand
pill is the citation look), transparent fill, muted --ink-soft text
— 8.6:1 on --bg (verified ≥4.5:1, WCAG 2.1 AA), palette variables
only (phase-92 zero-literal: no new hue, the monochrome theme grays
it automatically). The row joins .msg-meta (the .msg-body flex gap
stacks it below the citation row — no extra margin) but the meta
ACTIONS never do: app.js appends it last, after the first-row
claimers. :focus-visible via the global 3px outline rule. The hover
is deliberately flat — NO background swap (the chip's brand-soft
hover is the citation affordance): only the ink-soft → ink step-up
plus the underline. */
.related-docs-label {
font-size: 0.68rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--ink-soft);
}
.related-doc {
display: inline-flex;
align-items: center;
font-family: var(--mono);
font-size: 0.7rem;
color: var(--ink-soft);
background: transparent;
border: 1px dashed var(--line);
border-radius: 999px;
padding: 0.12rem 0.55rem;
text-decoration: none;
max-width: 100%;
/* min-width: 0 so the nowrap pill ellipsizes (same as the chips) */
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.related-doc:hover {
color: var(--ink); /* ink on --bg 16.7:1 — the flat secondary hover */
text-decoration: underline;
}
/* "Maybe try" chips under a deflected bubble (phase 04). Unlike the
onboarding row (which scrolls horizontally on mobile), this group wraps
at every width: the chips are the actionable follow-up, not decoration.
The pills themselves reuse .suggestion-chip (>=44px, brand-soft/ink). */
.maybe-try {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.45rem;
padding-inline: 0.25rem;
}
/* ---------- Steering notes (phase 15) ---------- */
/* "Tune" button in the completed brain bubble's actions row
(.msg-meta.msg-actions — phase 130: the ROW's right-align is the
cluster's "bottom right"; the button is a plain flex item, no
per-button auto margin). Ghost pill, ≥44px. ink-soft on surface
≈6.9:1; hover pair brand-ink/brand-soft ≈6.9:1. */
.tune-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.35rem;
min-height: 44px;
padding: 0.35rem 0.8rem;
border-radius: 999px;
border: 1px solid var(--line);
background: transparent;
color: var(--ink-soft);
font: inherit;
font-weight: 600;
font-size: 0.82rem;
white-space: nowrap;
cursor: pointer;
}
.tune-btn svg { width: 14px; height: 14px; display: block; }
.tune-btn:hover { background: var(--brand-soft); color: var(--brand-ink); }
/* Phase 49 (owner-locked 2026-08-29, TODO.md L4): the "Retry" button —
the redo-in-place action in the actions row (.msg-meta.msg-actions —
phase 130) of the LAST brain bubble. The exact visual family of
.tune-btn (same pill size/spacing, the global :focus-visible ring,
>=44px via min-height) so the meta actions read as a set — but
neutral: ink-soft -> ink on hover (Tune keeps the brand pair). The
redo glyph rides currentColor. No per-button auto margin (phase 130
— the row's justify right-aligns the cluster). Contrast:
ink-soft on --bg ~8.6:1, ink ~15:1, hover ink on --brand-soft
~15.7:1 — all AA. */
.retry-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.35rem;
min-height: 44px;
padding: 0.35rem 0.8rem;
border-radius: 999px;
border: 1px solid var(--line);
background: transparent;
color: var(--ink-soft);
font: inherit;
font-weight: 600;
font-size: 0.82rem;
white-space: nowrap;
cursor: pointer;
}
.retry-btn svg { width: 14px; height: 14px; display: block; }
.retry-btn:hover { background: var(--brand-soft); color: var(--ink); }
/* Phase 59 (owner-locked 2026-08-31, TODO.md L3): the "Save as doc"
button — the bottom-right action of every completed brain bubble's
meta row (the JS gates: admin + a configured docs repo; this rule
only styles). The exact visual family of .tune-btn / .retry-btn
(same pill size/spacing, the global :focus-visible ring, >=44px via
min-height) so the meta actions read as one set — the brand hover
pair like Tune (a docs action), the file glyph rides currentColor.
No per-button auto margin (phase 130 — the row's .msg-actions
justify right-aligns the WHOLE cluster to the row's RIGHT edge, the
TODO's "bottom right"; the button appends LAST in the row's DOM
order per its gate, so it stays the rightmost action). Contrast:
ink-soft on --bg ~8.6:1, hover brand-ink on --brand-soft — AA,
same as the family. */
.save-as-doc-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.35rem;
min-height: 44px;
padding: 0.35rem 0.8rem;
border-radius: 999px;
border: 1px solid var(--line);
background: transparent;
color: var(--ink-soft);
font: inherit;
font-weight: 600;
font-size: 0.82rem;
white-space: nowrap;
cursor: pointer;
}
.save-as-doc-btn svg { width: 14px; height: 14px; display: block; }
.save-as-doc-btn:hover { background: var(--brand-soft); color: var(--brand-ink); }
.save-as-doc-btn:disabled { opacity: 0.6; cursor: wait; } /* draft POST in flight */
/* Phase 48: the "Stopped" note in a stopped brain bubble's meta row:
ink-soft on the surface bubble ≈6.9:1, the 10px filled-square glyph
centered with the row (the Tune button shares the row), and
non-interactive — no hover, no focus (pointer-events: none). The text
carries the accessible meaning; the glyph is aria-hidden in the JS. */
.stopped-note {
display: inline-flex;
align-items: center;
gap: 0.3rem;
color: var(--ink-soft);
font-size: 0.75rem;
font-weight: 600;
white-space: nowrap;
pointer-events: none;
}
.stopped-note svg { width: 10px; height: 10px; display: block; fill: currentColor; }
/* Phase 120 (TODO.md L3–4): the "Failed" note in a failed brain
bubble's meta row — the .stopped-note family in the theme's error
color (text + color, never color alone — B5: the "Failed" label
carries the meaning, the triangle glyph is aria-hidden decoration in
the JS). --err-ink on the --surface bubble ≈9.0:1 (the same value as
--brand-ink, computed 9.0:1 on --surface above) — well past 4.5:1
(WCAG 2.1 AA); the monochrome theme grays it automatically (token,
not literal — phase-92 zero-literal). Non-interactive — no hover, no
focus (pointer-events: none), the stopped note's way. The detail
span WRAPS (the stopped note's nowrap fits a one-word label; a 500-
char error detail must not blow out the 46rem chat column) —
overflow-wrap: anywhere keeps long unbroken details inside the row. */
.failed-note {
display: inline-flex;
align-items: flex-start;
gap: 0.3rem;
max-width: 100%;
color: var(--err-ink);
font-size: 0.75rem;
font-weight: 600;
pointer-events: none;
}
.failed-note svg {
width: 10px;
height: 10px;
margin-top: 0.15rem;
display: block;
flex: none;
fill: none;
stroke: currentColor;
}
.failed-detail { font-weight: 400; overflow-wrap: anywhere; }
/* Inline tuning form under the bubble: labeled textarea + Save/Cancel
(both ≥44px). Save = brand button (dark ink 5.2:1), Cancel = ghost. */
.tune-form {
display: flex;
flex-direction: column;
gap: 0.5rem;
background: var(--surface);
border: 1px solid var(--brand-soft);
border-radius: var(--radius-sm);
padding: 0.75rem 0.85rem;
}
.tune-form label {
font-size: 0.85rem;
font-weight: 600;
color: var(--ink-soft);
}
.tune-form textarea {
font: inherit;
font-size: 0.9rem;
color: var(--ink);
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius-sm);
padding: 0.5rem 0.6rem;
resize: vertical;
min-height: 2.6rem;
}
.tune-form-actions { display: flex; gap: 0.5rem; }
.tune-save {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 44px;
padding: 0.4rem 1.1rem;
border: 0;
border-radius: var(--radius-sm);
background: var(--brand);
color: var(--bg);
font: inherit;
font-weight: 700;
cursor: pointer;
}
.tune-save:hover:not(:disabled) { background: var(--brand-hover); }
.tune-save:disabled { opacity: 0.6; cursor: wait; }
.tune-cancel {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 44px;
padding: 0.4rem 1rem;
border-radius: var(--radius-sm);
border: 1px solid var(--line);
background: transparent;
color: var(--ink-soft);
font: inherit;
font-weight: 600;
cursor: pointer;
}
.tune-cancel:hover { background: var(--brand-soft); color: var(--brand-ink); }
/* Save confirmation (role=status): ok pair ≈10.6:1. */
.tune-saved {
margin: 0.2rem 0 0 0.25rem;
background: var(--ok-bg);
color: var(--ok-ink);
border: 1px solid color-mix(in srgb, var(--ok-ink) 35%, transparent);
border-radius: var(--radius-sm);
padding: 0.45rem 0.8rem;
font-size: 0.85rem;
font-weight: 600;
}
/* Inline save failure (role=alert): err pair ≈9.1:1 — form is kept. */
.tune-error {
margin: 0 0 0 0.25rem;
background: var(--err-bg);
color: var(--err-ink);
border: 1px solid var(--err-line);
border-radius: var(--radius-sm);
padding: 0.45rem 0.8rem;
font-size: 0.85rem;
font-weight: 600;
}
/* Header panel above the messages: notes newest-first, per-note delete,
designed empty state. */
.steering-panel {
background: var(--surface);
border: 1px solid var(--brand-soft);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 0.9rem 1.1rem 1rem;
}
.steering-panel-head { display: flex; flex-wrap: wrap; align-items: baseline; gap: 0.15rem 0.6rem; }
.steering-panel-title { margin: 0; font-size: 1rem; font-weight: 700; color: var(--ink); }
.steering-panel-sub { margin: 0; font-size: 0.82rem; color: var(--ink-soft); }
.steering-list {
list-style: none;
margin: 0.65rem 0 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.steering-note {
display: flex;
align-items: stretch;
gap: 0.6rem;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius-sm);
padding: 0.35rem 0.4rem 0.35rem 0.8rem;
}
.steering-note-text {
color: var(--ink);
font-size: 0.9rem;
line-height: 1.45;
/* Notes may be multi-line instructions — keep line breaks as typed. */
white-space: pre-wrap;
overflow-wrap: anywhere;
flex: 1;
min-width: 0;
}
.steering-delete {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 44px;
min-width: 44px;
flex: 0 0 auto;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
background: transparent;
color: var(--ink-soft);
cursor: pointer;
}
.steering-delete svg { width: 16px; height: 16px; display: block; }
.steering-delete:hover:not(:disabled) { background: var(--err-bg); color: var(--err-ink); border-color: var(--err-line); }
.steering-delete:disabled { opacity: 0.5; cursor: wait; }
.steering-empty { margin: 0.65rem 0 0; color: var(--ink-soft); font-size: 0.88rem; }
/* Phase 34 task 04 (visual pass): on the non-chat pages the panel is a
direct child of (not inside a .container) — pin it to the same
72rem content column as everything else so it never renders as a
full-viewport band. The chat page's panel sits inside .chat-shell's
container, so this selector only hits the direct-child placement. */
.app-main > .steering-panel {
width: calc(100% - 2.5rem);
max-width: 69.5rem;
margin-inline: auto;
}
/* ---------- Global tuning page (phase 27) ---------- */
/* /tuning.html: create / edit / delete steering notes without a chat
conversation. Full-container width (owner instruction 2026-09-12:
"match the width of the RAG page for all other pages" — the
phase-27 capped-column discipline is superseded): the shell rides
the .container's 72rem frame like the RAG page, and the form and
the note list span its FULL width (no skinny lists). Every
interactive target is >=44px; text pairs reuse the Phase-08 AA
palette (dark ink on brand 5.2:1, brand-ink/brand-soft 6.9:1,
ok 10.6:1, err 9.1:1, ink-soft >=6.9:1). No filter: blur, no CDN,
system font stack. */
.tuning-shell {
display: flex;
flex-direction: column;
gap: 1.25rem;
flex: 1;
}
/* Create form — the composer's surface as a vertical card: labeled
textarea (visually-hidden label; the placeholder carries the visible
hint) + the brand "Add note" button (dark ink on brand: 5.2:1 —
never white on brand, 3.7:1, fails). */
#tune-form {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.6rem;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 0.9rem 1rem 1rem;
}
#tune-form:focus-within { border-color: var(--brand); box-shadow: 0 0 0 3px var(--brand-soft), var(--shadow); }
#tune-note {
width: 100%;
font: inherit;
font-size: 0.95rem;
color: var(--ink);
background: transparent;
border: 0;
padding: 0.2rem 0.1rem;
resize: vertical;
min-height: 4.6rem;
}
#tune-note::placeholder { color: var(--ink-soft); }
#tune-save {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 44px;
padding: 0.4rem 1.2rem;
border: 0;
border-radius: var(--radius-sm);
background: var(--brand);
color: var(--bg); /* dark ink on brand: 5.2:1 */
font: inherit;
font-weight: 700;
cursor: pointer;
}
#tune-save:hover:not(:disabled) { background: var(--brand-hover); }
#tune-save:disabled { opacity: 0.6; cursor: wait; }
/* Notes list — the phase-15 steering panel's language at full column
width: rows are flex (text flexes + ellipsizes, actions shrink-0)
with a bottom divider (the last row keeps none). */
.tuning-panel {
background: var(--surface);
border: 1px solid var(--brand-soft);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 0.9rem 1.1rem 1rem;
}
.tuning-panel-title { margin: 0; font-size: 1rem; font-weight: 700; color: var(--ink); }
.tuning-list {
list-style: none;
margin: 0.65rem 0 0;
padding: 0;
display: flex;
flex-direction: column;
}
.tuning-note {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.35rem 0;
border-bottom: 1px solid var(--line);
}
.tuning-note:last-child { border-bottom: 0; }
/* The note text is the row's flex citizen: it takes every spare pixel
and ellipsizes (the full text is one Edit away — the inline form). */
.tuning-note-text {
color: var(--ink);
font-size: 0.9rem;
line-height: 1.45;
flex: 1;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Row actions: icon + label, >=44px, shrink-0. Edit = brand pair on
hover (brand-ink/brand-soft 6.9:1); Delete = err pair on hover
(9.1:1), consistent with .steering-delete. */
.tuning-edit,
.tuning-delete {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.35rem;
min-height: 44px;
min-width: 44px;
flex: 0 0 auto;
padding: 0.35rem 0.7rem;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
background: transparent;
color: var(--ink-soft);
font: inherit;
font-weight: 600;
font-size: 0.82rem;
white-space: nowrap;
cursor: pointer;
}
.tuning-edit svg, .tuning-delete svg { width: 14px; height: 14px; display: block; }
.tuning-edit:hover:not(:disabled) { background: var(--brand-soft); color: var(--brand-ink); border-color: var(--brand-soft); }
.tuning-delete:hover:not(:disabled) { background: var(--err-bg); color: var(--err-ink); border-color: var(--err-line); }
.tuning-edit:disabled, .tuning-delete:disabled { opacity: 0.5; cursor: wait; }
/* Inline edit: the row swaps to a vertical card — the text span is
hidden and replaced by the .tuning-edit-form (textarea pre-filled +
Save/Cancel, reusing the phase-15 .tune-save/.tune-cancel styles);
the row-level Edit/Delete buttons step aside for the form's own. */
.tuning-note.is-editing {
flex-direction: column;
align-items: stretch;
gap: 0.5rem;
background: var(--surface);
border: 1px solid var(--brand-soft);
border-radius: var(--radius-sm);
padding: 0.6rem 0.7rem;
}
.tuning-note.is-editing .tuning-note-text,
.tuning-note.is-editing .tuning-edit,
.tuning-note.is-editing .tuning-delete { display: none; }
.tuning-edit-form { display: flex; flex-direction: column; gap: 0.5rem; width: 100%; }
.tuning-edit-input {
font: inherit;
font-size: 0.9rem;
color: var(--ink);
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius-sm);
padding: 0.5rem 0.6rem;
resize: vertical;
min-height: 2.6rem;
}
.tuning-edit-input::placeholder { color: var(--ink-soft); }
.tuning-edit-form-actions { display: flex; gap: 0.5rem; }
/* Per-action status pills (role=status / role=alert), the phase-15
pairs: ok 10.6:1, err 9.1:1. */
.tuning-saved {
background: var(--ok-bg);
color: var(--ok-ink);
border: 1px solid color-mix(in srgb, var(--ok-ink) 35%, transparent);
border-radius: var(--radius-sm);
padding: 0.45rem 0.8rem;
font-size: 0.85rem;
font-weight: 600;
}
.tuning-error {
background: var(--err-bg);
color: var(--err-ink);
border: 1px solid var(--err-line);
border-radius: var(--radius-sm);
padding: 0.45rem 0.8rem;
font-size: 0.85rem;
font-weight: 600;
}
#tune-empty {
margin: 0.65rem 0 0;
color: var(--ink-soft);
font-size: 0.88rem;
font-style: italic;
text-align: center;
}
/* typing indicator */
/* align-items: center — the dots stay vertically centered when the
phase-87 "Ns" hint (a full text line) makes the row taller; with
only the three 8px dots the centering is a visual no-op. */
.typing { display: inline-flex; align-items: center; gap: 5px; padding: 0.9rem 1rem; }
.typing span {
width: 8px; height: 8px;
border-radius: 50%;
background: var(--ink-soft);
opacity: 0.5;
animation: typing 1.2s infinite ease-in-out;
}
.typing span:nth-child(2) { animation-delay: 0.15s; }
.typing span:nth-child(3) { animation-delay: 0.3s; }
@keyframes typing {
0%, 60%, 100% { transform: translateY(0); opacity: 0.4; }
30% { transform: translateY(-5px); opacity: 1; }
}
/* Phase 87 (TODO.md L5): the visible pre-token elapsed hint — the
10s thinking clock promoted from aria-only to sighted users.
ink-soft on the bubble surface (the AA pairing), small mono like
every status line. Plain text: no animation, no motion opt-out.
The span is a sibling of the dots inside the same .typing bubble, so
it resets the dot geometry (.typing span) it would otherwise inherit.
The selector MUST stay ".typing span.typing-elapsed": a bare
".typing-elapsed" (0,1,0) LOSES the specificity war to the dot rule
".typing span" (0,1,1) — every reset below (width/height/border-
radius/background/opacity/animation) would be overridden and the
hint would render as an 8×8px bouncing dot with the "Ns" text
wrapping one character per line (phase-87 regression, 2026-09). */
.typing span.typing-elapsed {
width: auto;
height: auto;
border-radius: 0;
background: none;
opacity: 1;
animation: none;
font-family: var(--mono);
font-size: 0.75rem;
color: var(--ink-soft);
margin-left: 0.5rem;
}
@media (prefers-reduced-motion: reduce) {
.typing span { animation: none; opacity: 0.7; }
/* Phase 87 (TODO.md L5): the elapsed hint is plain text, not motion —
it keeps full opacity (the AA pairing) under reduced motion. */
.typing span.typing-elapsed { opacity: 1; }
/* Phase 17 thinking block: the chevron stills (no rotation motion). */
details.thinking summary::before { transition: none; }
/* Phase 117 tool-calls disclosure: the same chevron, the same still. */
.tool-calls-summary::before { transition: none; }
}
/* Phase 117 (owner request 2026-09-15, live-mockup-confirmed — the
phase-109 D16 dot cue replaced by a BRAIN-WAVE; the owner picked the
ECG over the first equalizer mock): a compact trace (P bump, QRS
spike, T bump — 49px wide, 8px flat tails; the middle is never
rescaled) in index.html as a static SVG. .bw-ghost is the
always-visible dim trace (the shape reads even when the sweep is
between peaks); .bw-pulse is the same path in brand with a
42/140 stroke-dash segment that TRAVELS the path — the classic
heart-monitor draw (the bwdraw keyframe below). Still the phase-109
D16 contract: visible for the ENTIRE active turn (send → terminal
frame), its hidden attribute owned solely by app.js's setUiState
(shown iff inFlight — hidden by construction in every terminal
state, PLAN §7.4). DECORATIVE (aria-hidden in index.html):
#send-status carries the state's meaning, so contrast is N/A here
(§7.2 "text + color, never color alone" — the state TEXT stays in
#send-status). The container is 44px = .send-btn min-height (house
cross-reference — keep in lockstep), the trace centered against the
button. The NEW bwdraw family is deliberate: the phase-109 "reuse
the typing animation" pin is superseded by the owner's redesign.
The loader sits LEFT of the button in the markup (index.html) so
its appearance/disappearance never shifts the button. */
.turn-loader {
display: inline-flex;
align-items: center; /* center the trace against the button */
width: auto;
height: 44px; /* = .send-btn min-height (44px) — keep in lockstep */
background: none;
border-radius: 0;
opacity: 1;
align-self: flex-end; /* the composer row is flex-end aligned */
animation: none; /* the sweep carries the motion, not the container */
}
.turn-loader svg {
width: 49px;
height: 32px;
overflow: visible; /* the QRS spike (y=3) may exceed the box top */
}
.turn-loader .bw-ghost {
fill: none;
stroke: var(--ink-soft);
stroke-opacity: 0.3; /* the dim always-visible trace */
stroke-width: 2;
}
.turn-loader .bw-pulse {
fill: none;
stroke: var(--brand);
stroke-width: 2.5;
stroke-linecap: round;
stroke-linejoin: round;
/* The sweep: a 42/140 bright segment on the pathLength-normalized
path — the dash cycle (42 + 98) equals the normalized length, so
the bwdraw loop is seamless (no visible restart point). */
stroke-dasharray: 42 98;
animation: bwdraw 0.9s linear infinite;
}
@keyframes bwdraw {
from { stroke-dashoffset: 140; }
to { stroke-dashoffset: 0; }
}
/* Phase 117: reduced motion — the sweep is STILLED (the full trace
shows static, no travel — §7.2 house law), never HIDDEN: visibility
is owned by the JS hidden attribute in setUiState (D16), not by
CSS. This override MUST sit AFTER the main rules above: equal
specificity, and the later rule wins the cascade — a media block
placed before the main rule would lose the cascade and the sweep
would keep traveling under reduced motion (the phase-109 cascade
pitfall, carried over). */
@media (prefers-reduced-motion: reduce) {
.turn-loader .bw-pulse { animation: none; stroke-dasharray: none; }
}
/* ---------- Empty state & suggestions ---------- */
.empty-state {
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 2.5rem 1.75rem;
text-align: center;
margin-top: 1rem;
}
.empty-state-glyph { color: var(--brand-ink); width: 44px; height: 44px; margin-inline: auto; }
.empty-state-glyph svg { width: 44px; height: 44px; display: block; }
.empty-state-title { margin: 0.8rem 0 0.4rem; font-size: 1.5rem; color: var(--ink); }
.empty-state-sub { margin: 0 auto 1.25rem; max-width: 34rem; color: var(--ink-soft); }
.empty-state-sub code { font-family: var(--mono); font-size: 0.85em; background: var(--brand-soft); padding: 0.1em 0.35em; border-radius: 5px; }
.suggestions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
justify-content: center;
list-style: none;
margin: 0;
padding: 0;
}
/* The shared chip pill — both chip rows (the onboarding row in the
empty state and the "Maybe try" row under a deflected bubble) reuse
it. Phase 104 (owner 2026-09-12, A1): a chip is ONE line at every
viewport width — `white-space: nowrap` stops a 400-char question
from wrapping the pill into a multi-line "chonk"; `overflow: hidden`
zeroes the flex item's automatic minimum size (min-width:auto would
pin the item to the full text width), so `max-width: 100%` binds and
`text-overflow: ellipsis` clips the text at the row edge — in the
desktop wrap row 100% is the chat column, in the ≤640px row
(nowrap + overflow-x: auto) 100% is the VISIBLE width and the row
scrolls. That clipping is the phase-07 overflow fix the old
"Maybe try" chip override used to carry (min-width:0 + max-width:
100%) — now fully subsumed here and deleted, provenance folded into
this comment so the history lives with the contract. The full text
stays one hover away (the title tooltip, phase 104 task 02). */
.suggestion-chip {
font: inherit;
font-size: 0.92rem;
font-weight: 600;
color: var(--brand-ink);
background: var(--brand-soft);
border: 1px solid var(--line);
border-radius: 999px;
padding: 0.55rem 1rem;
min-height: 44px;
cursor: pointer;
transition: background 0.15s ease, transform 0.05s ease;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
min-width: 0;
}
.suggestion-chip:hover { background: var(--brand-soft); }
.suggestion-chip:active { transform: scale(0.98); }
/* ---------- Composer ---------- */
/* Phase 52 (2026-08-30, TODO.md L3): the composer is PINNED to the
viewport bottom. The page scrolls at the document level and
`.chat-shell` (the centered reading column — 72rem, the
.container width at every viewport) is the composer's sticky
containing block, so the box sticks to the bottom edge of the
viewport at every scroll position and settles back into its normal
flow position (above the footer) once the document bottom is
reached. Before this, a long conversation put the composer below the
fold while phase 42 forbids auto-scrolling a streaming reply — the
Stop control could literally not be reached ("it runs away from the
user as they try to click stop").
The pin is TWO rules working together — sticky alone is not enough
(owner revision 2026-08-30: "the chat message-input textarea should be
at the bottom of the screen"). `position: sticky` can only pull the
box UP to the viewport's bottom edge while the document overflows; it
never pushes it DOWN, so on an empty/short chat the box still sat
mid-screen. `.messages { flex: 1 1 auto }` supplies the missing half:
it puts the box's resting position at the bottom of the full-height
column, and sticky takes over the moment the conversation overflows.
The offset is the notch-aware safe-area inset (`env()` resolves to 0,
or falls back to 0 where unsupported, so the box sits flush with the
viewport bottom; ≤640px keeps its own
`main { padding-bottom: env(safe-area-inset-bottom) }`).
CSS-only by design: no DOM change, no JS, and NO new scroll call
site (the phase-42 never-auto-scroll contract stays intact —
`scrollReveal` is still the one page scroll in app.js).
The solid `--surface` background, border, radius and shadow are kept
so messages scrolling behind the pinned box never show through it,
and no z-index is added: DOM order already paints the composer over
`.messages`, it never overlaps the sticky header (z 20) and stays
under the z-1000 document modal.
Phase 65 (2026-09-01, `TODO.md` L3, owner-locked A1): the pin now
belongs to the .chat-bottom wrapper (rule below) — the row + this
form are ONE sticky unit; this composer's own sticky declaration is
kept, redundant inside the wrapper but pinned as a computed style by
tests/e2e/test_pinned_composer.py. */
/* Phase 65 (owner-locked A1, 2026-09-01, `TODO.md` L3): the pinned
bottom cluster — the .chat-actions row + the #composer form as ONE
sticky unit (A1): the pills are literally always at the bottom of
the screen, even while the reader is scrolled up through a long
conversation (the rejected in-flow alternative would scroll away).
The sticky range is the .chat-shell containing block (the wrapper's
parent) — the same range the composer used to have alone — and the
.messages flex-grow still rests the WHOLE unit at the screen bottom
on short chats (the wrapper is the last shell child, so the grow
pushes it down). The shell's 1rem gap applies only ABOVE the unit,
from .messages — the 0.5rem gap here is the row↔composer spacing
INSIDE it. No z-index: the phase-12 sticky header (z 20) and the
phase-46 dropdown always stay on top, and at the document bottom the
unit settles back into flow above the footer (the composer's opaque
surface keeps messages hidden where the box overlaps them). The
composer's own sticky rule below stays — redundant here (its
containing block is the wrapper, so it cannot shift within the
unit), pinned by tests/e2e/test_pinned_composer.py. */
.chat-bottom {
display: flex;
flex-direction: column;
gap: 0.5rem;
position: sticky;
bottom: env(safe-area-inset-bottom, 0);
}
/* Phase 88: pre-boot, the composer cluster is NOT sticky. The sticky
promotion (a compositor layer) is deferred out of the first layout
commit — on real Android, the cluster's sticky layer born at first
commit was eating the hamburger's click (owner bug report
2026-09-08; the client-side switch into chat — same layer, born on
a settled page — always worked). app.js adds .chat-booted two
frames after boot settles; no-JS keeps the cluster static, which in
the only no-JS state (empty, 100dvh document) sits exactly where
the pin would. */
#view-chat:not(.chat-booted) .chat-bottom,
#view-chat:not(.chat-booted) .composer {
position: static;
}
/* Phase 104 (owner 2026-09-12, A4): the question-length counter —
right-aligned above the composer, a child of the .chat-bottom sticky
unit (between the actions row and the form), hidden until 80% of the
4,000-char cap (app.js updateCharCount). It sits on the APP
background behind the transparent .chat-bottom unit, so both pairings
are verified against --bg: --ink-soft on --bg = 8.6:1 (≥4.5:1, WCAG
AA) and the .is-max state's --err-ink on --bg = 10.4:1 (AA). The
.is-max state ALSO changes the copy ("— character limit") — text +
color, never color alone (B3). */
.char-count { margin: 0; text-align: right; font-size: 0.75rem; line-height: 1.2; color: var(--ink-soft); }
.char-count.is-max { color: var(--err-ink); }
/* Phase 123 (task 02, TODO L6): the ATTACH PREVIEW STRIP — the
selected image ABOVE the input row (thumbnail ≤48px + the filename
+ the remove button): a surface card in the .chat-bottom stack, between
the char-count line and the composer. The name is ellipsized
(AA-safe --ink on --surface = 13.8:1) — a long filename never widens
the strip; the thumbnail is a fixed 48px cover box (the preview
crops, the bubble shows the whole image); the remove button keeps the
44px touch
target (PLAN §7.1) with a destructive hover (--err-ink on --err-bg =
9.3:1 — text + color, never color alone, B3). Hidden by default
(the global [hidden] rule) — revealed only while a file is attached,
cleared with the send so the strip never lingers into a turn. */
.attach-preview {
display: flex;
align-items: center;
gap: 0.5rem;
margin: 0 0 0.45rem;
padding: 0.35rem 0.5rem;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius-sm);
}
.attach-preview-img {
flex: none;
width: 48px;
height: 48px;
object-fit: cover;
border: 1px solid var(--line);
border-radius: 4px;
background: var(--bg);
}
.attach-preview-name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 0.85rem;
color: var(--ink);
}
.attach-preview-remove {
display: inline-flex;
align-items: center;
justify-content: center;
flex: none;
width: 44px;
min-height: 44px;
border: 0;
border-radius: var(--radius-sm);
background: transparent;
color: var(--ink-soft);
cursor: pointer;
padding: 0;
}
.attach-preview-remove svg { width: 18px; height: 18px; }
.attach-preview-remove:hover { background: var(--err-bg); color: var(--err-ink); }
/* Phase 132 (task 02, TODO L8): the strip's UPLOADING look — the
visual half of the owner-confirmed double-spam block (task 01's JS
state proves the image is moving while POST /api/chat-images is in
flight; before this the strip looked frozen and the user spammed
Send). The thumbnail DIMS to 0.5 (it is decorative — no contrast
duty — and 0.5 is the house dim value the disabled remove button
below); the filename HIDES with visibility (the flex: 1 slot keeps
its measured width — the status takes the row's right hand WITHOUT
reflowing the pinned 44px remove button); the house loading arc
(the status part's svg — task 01's markup, currentColor = the
status's --ink-soft) SPINS at the sync spinner's pace (the house
`spin` keyframes at 1s linear — .sync-icon.is-spinning's in-flight
pace; the .spinner's 0.8s is the busy-turn arc's). The status text
is --ink-soft on the strip's --surface — the :root-pinned pair,
8.2:1 computed against WCAG 2.1 (AA ≥ 4.5:1), at the strip's
small text size (the name's 0.85rem). The remove button (task 01
disables it mid-flight) dims with its disabled attribute — the
attribute is the state, the 0.5 is its visual half; the Send rides
the same house :disabled language (.send-btn:disabled's
--brand-busy + not-allowed below — which reads as disabled already,
so NO new color or weight was added, B4). prefers-reduced-motion stills the arc (the house block): the
"Uploading image…" TEXT + the dimmed thumbnail carry the state
without motion (B5 — never motion alone). Every value is a theme
variable or a neutral number — the block ships no raw color. */
.attach-preview.is-uploading .attach-preview-img {
opacity: 0.5;
}
.attach-preview.is-uploading .attach-preview-name {
visibility: hidden; /* keep the flex: 1 slot; the status takes its right hand */
}
.attach-preview-status {
display: inline-flex;
align-items: center;
gap: 0.35rem;
color: var(--ink-soft);
font-size: 0.85rem; /* the strip's small text — the name's size */
min-width: 0; /* the row's clamp: at 320 px the text WRAPS (no clip) */
}
.attach-preview-status-icon {
width: 14px;
height: 14px;
flex: 0 0 auto;
animation: spin 1s linear infinite; /* the house spin keyframes (the sync pace) */
}
@media (prefers-reduced-motion: reduce) {
.attach-preview-status-icon { animation: none; }
}
.attach-preview.is-uploading .attach-preview-remove {
opacity: 0.5; /* the disabled attribute's visual half (task 01 sets it) */
}
.composer {
display: flex;
align-items: flex-end;
gap: 0.6rem;
position: sticky;
bottom: env(safe-area-inset-bottom, 0);
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 0.6rem;
}
.composer:focus-within { border-color: var(--brand); box-shadow: 0 0 0 3px var(--brand-soft), var(--shadow); }
.composer textarea {
flex: 1;
font: inherit;
color: var(--ink);
border: 0;
resize: none;
max-height: 12rem;
padding: 0.55rem 0.5rem;
background: transparent;
}
.composer textarea::placeholder { color: var(--ink-soft); }
/* Phase 123 (task 02, TODO L6): the composer's ATTACH CONTROL — the
paperclip glyph button LEFT of the input. Hidden in the markup until
app.js reveals it from the config's images flag (the global [hidden]
rule keeps it out of the flag-off DOM — A5's default-off contract).
A neutral cut of the .send-btn family: the same 44px hit target +
radius + the global :focus-visible ring (PLAN §7.2); surface fill
with a muted hover step (the icon: --ink-soft on --surface = 5.1:1,
hover --brand-ink on --brand-soft = 12.4:1 — both past the 3:1
non-text floor). */
.attach-btn {
display: inline-flex;
align-items: center;
justify-content: center;
flex: none;
width: 44px;
min-height: 44px;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
background: var(--surface);
color: var(--ink-soft);
cursor: pointer;
padding: 0;
}
.attach-btn svg { width: 20px; height: 20px; }
.attach-btn:hover { background: var(--brand-soft); border-color: var(--brand-soft); color: var(--brand-ink); }
.send-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.45rem;
min-width: 84px;
min-height: 44px;
border: 0;
border-radius: var(--radius-sm);
background: var(--brand);
/* Dark ink on brand (5.2:1) — never white on brand (3.7:1, fails). */
color: var(--bg);
font: inherit;
font-weight: 700;
cursor: pointer;
padding-inline: 1rem;
}
.send-btn:hover:not(:disabled) { background: var(--brand-hover); }
.send-btn:disabled { background: var(--brand-busy); cursor: not-allowed; }
/* Phase 48 (2026-08-29, TODO.md L3): the in-flight Stop treatment — one
button, two roles. --brand-stop (the brand darkened 75% toward --bg;
the built-in default mixes to ≈ (187, 50, 73)) with a white label =
5.8:1 (WCAG AA); the hover step (--brand at 55% toward --bg) holds
8.5:1. white is the ONE literal allowed outside :root — the dark bg
ink on the stop fill is only ≈3.4:1 (fails AA). Same radius/height/
hit target as the Send state and the shared :focus-visible ring — the
.is-stop class rides the same .send-btn element, and the later rules
win the hover specificity tie. */
.send-btn.is-stop { background: var(--brand-stop); color: #fff; }
.send-btn.is-stop:hover { background: color-mix(in srgb, var(--brand) 55%, var(--bg)); }
/* Busy spinner: dark arc (--bg) on the --brand-busy button ≈ 11.3:1
(well above the 9:1 floor the phase-48 note pinned for the arc). */
.spinner {
width: 16px; height: 16px;
border: 2.5px solid color-mix(in srgb, var(--bg) 30%, transparent);
border-top-color: var(--bg);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) {
.spinner { animation-duration: 2s; }
}
/* ---------- Banners ---------- */
.kb-banner {
display: flex;
align-items: center;
gap: 0.5rem;
background: var(--accent-bg);
color: var(--accent-ink);
border: 1px solid var(--accent-line);
border-radius: var(--radius-sm);
padding: 0.6rem 0.9rem;
font-size: 0.92rem;
font-weight: 600;
}
.kb-banner.is-error { background: var(--err-bg); color: var(--err-ink); border-color: var(--err-line); }
.kb-banner svg { width: 18px; height: 18px; flex: 0 0 auto; display: block; }
/* Phase 53 (task 05): the stale-saved-chat banner — the .kb-banner
FAMILY (the section carries both classes: same flex row, accent
tokens — accent-ink on accent-bg 9.5:1 — 18px glyph), but the
leading mark is the REDO glyph, distinct from the warning triangle.
It sits directly after #kb-banner in .chat-shell, so when both are
visible the empty-KB banner keeps the top slot and the stale banner
stacks directly below (the flex column's gap spaces them). The text
takes the row; the Regenerate pill right-aligns (margin-left: auto)
and the row wraps only when it must. The pill is the EXACT
brand-pill family of Save/Share: solid --brand, --bg text (5.2:1,
AA), borderless, 999px radius, ≥44px target, hover lightens the
brand fill; the 16px redo glyph is the phase-49 Retry asset. The
≤640px block below makes the pill a full-width row. */
.stale-banner { flex-wrap: wrap; }
.stale-banner > span { flex: 1 1 auto; }
.stale-regenerate {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.4rem;
min-height: 44px;
margin-left: auto;
padding: 0.5rem 0.9rem;
border-radius: 999px;
border: 0;
background: var(--brand);
color: var(--bg);
font: inherit;
font-weight: 700;
font-size: 0.95rem;
white-space: nowrap;
cursor: pointer;
}
.stale-regenerate:hover { background: var(--brand-hover); color: var(--bg); }
.stale-regenerate:disabled { opacity: 0.6; cursor: wait; }
.stale-regenerate svg { width: 16px; height: 16px; display: block; }
/* Phase 111 (task 01): the banner Retry button — the same pill treatment
as .stale-regenerate (brand fill, ≥44px, 999px radius, redo glyph) so
the banner's action reads as the same component family. The kb-banner
flex row + gap already handles inline spacing after #kb-banner-text. */
.banner-retry {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.4rem;
min-height: 44px;
margin-left: auto;
padding: 0.5rem 0.9rem;
border-radius: 999px;
border: 0;
background: var(--brand);
color: var(--bg);
font: inherit;
font-weight: 700;
font-size: 0.95rem;
white-space: nowrap;
cursor: pointer;
}
.banner-retry:hover { background: var(--brand-hover); color: var(--bg); }
.banner-retry:disabled { opacity: 0.6; cursor: wait; }
.banner-retry svg { width: 16px; height: 16px; display: block; }
/* Phase 55 (task 04): the share-success toast (TODO.md L5). Top-right,
just under the sticky header (--header-h + a small offset — the
variable already steps 64px → 58px at ≤640px), z-index 1000 (the
modal overlay contract — above the header's 20 and the skip-link's
100). VISUAL ONLY (A4 owner-locked): the node ships aria-hidden
from app.js — #send-status stays the a11y announcer (no double
screen-reader read). Solid brand fill: --bg text on --brand = 5.2:1
(AA — the .new-chat-btn family); rounded, shadowed, one line of
text that wraps under the small max-width. Hidden by default
(opacity 0 + pointer-events: none — it never intercepts clicks when
idle); .toast.is-visible (toggled by showToast) runs the entry:
slide-down + fade (translateY(-8px) → 0, 200ms). */
.toast {
position: fixed;
top: calc(var(--header-h) + 0.75rem);
right: 1rem;
z-index: 1000;
max-width: min(22rem, calc(100vw - 2rem));
padding: 0.6rem 1rem;
border-radius: var(--radius-sm);
background: var(--brand);
color: var(--bg);
font-size: 0.92rem;
font-weight: 600;
box-shadow: var(--shadow);
opacity: 0;
pointer-events: none;
transform: translateY(-8px);
transition: opacity 200ms ease, transform 200ms ease;
}
.toast.is-visible {
opacity: 1;
transform: translateY(0);
}
@media (prefers-reduced-motion: reduce) {
/* No slide: BOTH states rest at transform: none (the .is-visible
rule would otherwise out-specify the bare .toast); the opacity
fade remains (120ms). */
.toast,
.toast.is-visible { transform: none; }
.toast { transition: opacity 120ms ease; }
}
/* ---------- Login page (phase 16) ---------- */
/* Centered card in the standard frame: one admin, one password. */
.login-shell {
display: flex;
justify-content: center;
flex: 1;
}
.login-card {
width: 100%;
max-width: 26rem;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 2rem 2rem 2.25rem;
}
.login-card h1 { margin: 0 0 0.4rem; font-size: 1.6rem; }
.login-sub { margin: 0 0 1.5rem; color: var(--ink-soft); }
#login-form { display: flex; flex-direction: column; gap: 0.75rem; }
#login-password {
font: inherit;
font-size: 1rem;
color: var(--ink);
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius-sm);
padding: 0.55rem 0.75rem;
min-height: 44px;
}
#login-password:focus-visible { border-color: var(--brand); }
/* Brand button: dark ink on brand 5.2:1 (never white on brand). */
.login-submit {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 44px;
border: 0;
border-radius: var(--radius-sm);
background: var(--brand);
color: var(--bg);
font: inherit;
font-weight: 700;
cursor: pointer;
padding-inline: 1rem;
}
.login-submit:hover:not(:disabled) { background: var(--brand-hover); }
.login-submit:disabled { opacity: 0.6; cursor: wait; }
/* Login failure (role=alert): err pair ≈9.1:1. */
.login-error {
margin: 0.9rem 0 0;
background: var(--err-bg);
color: var(--err-ink);
border: 1px solid var(--err-line);
border-radius: var(--radius-sm);
padding: 0.5rem 0.8rem;
font-size: 0.9rem;
font-weight: 600;
}
/* ---------- Sources page ---------- */
.sources-shell {
display: flex;
flex-direction: column;
gap: 1.25rem;
flex: 1;
}
/* Phase 93 task 03 (TODO.md L3): "the header and description of each
page needs a background - the grid makes it hard to read." Every
page's h1 + description sits on a solid surface panel — the house
card language (var(--surface) fill, itself tab-controlled, so a
monochrome theme grays it automatically; the 1px --line border; the
house radius). No new color literal, no blur (the phase-08 perf
anchor). One rule covers the six shell views' .page-head (Global
Tuning, Knowledge base, Git sources, Saved chats, Access tokens,
Theme) + doc-edit.html, and the shared page's head (its h1 + lede
are wrapped in the same class in shared.html). #view-chat carries
no .page-head — its head is the navbar (audited, unchanged). The
History page-head keeps its flex row (scoped below — the padding
lives on the flex container, not the children). */
.page-head {
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 1rem 1.25rem;
}
.page-head h1 { margin: 0 0 0.25rem; font-size: 1.7rem; }
.page-head-row {
display: flex;
align-items: center;
gap: 1rem;
}
.page-sub { margin: 0; color: var(--ink-soft); }
.page-sub code { font-family: var(--mono); font-size: 0.85em; background: var(--brand-soft); padding: 0.1em 0.35em; border-radius: 5px; }
/* ---------- Sync button (Sources page) ---------- */
.sync-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.4rem;
min-height: 44px;
padding: 0.5rem 0.9rem;
border-radius: 999px;
border: 1px solid var(--line);
background: transparent;
color: var(--ink-soft);
font: inherit;
font-weight: 600;
font-size: 0.95rem;
white-space: nowrap;
cursor: pointer;
flex-shrink: 0;
}
.sync-btn:hover { background: var(--brand-soft); color: var(--brand-ink); }
.sync-btn:disabled { opacity: 0.6; cursor: wait; }
.sync-btn.is-error {
background: var(--err-bg);
color: var(--err-ink);
border-color: var(--err-line);
}
.sync-btn.is-error:hover { background: var(--err-bg); color: var(--err-ink); }
/* Phase 134 (task 02): the Cancel control — the in-flight sync run's
cooperative stop (visible ONLY while a SYNC run is in flight; the
upload job is not cancellable in this phase — sources.js owns the
show/hide, the same never-stale contract as the main button). The
SECONDARY pill language of the house button family (the
.kb-summary-cancel pair): transparent fill, 1px --line border,
--ink-soft text (5.1:1 on --surface, AA), the global 3px
:focus-visible ring, the >=44px touch floor. :disabled = the cancel
request in flight (one request at a time — never stale); the hover
is guarded on :not(:disabled) so a disabled button under the cursor
never reads as actionable. The [hidden] rule keeps the attribute
authoritative over the display above (the .kb-summary-edit pattern).
No layout regression on the sync-control row: the row already
flexes with a 1rem gap (desktop) and wraps (mobile, where the pill
goes full-width like the main button — the 360px case). */
.sync-cancel-btn {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 44px;
padding: 0.5rem 0.9rem;
border-radius: 999px;
border: 1px solid var(--line);
background: transparent;
color: var(--ink-soft); /* 5.1:1 on --surface (AA) */
font: inherit;
font-weight: 600;
font-size: 0.95rem;
white-space: nowrap;
cursor: pointer;
flex-shrink: 0;
}
.sync-cancel-btn:hover:not(:disabled) { background: var(--brand-soft); color: var(--brand-ink); }
.sync-cancel-btn:disabled { opacity: 0.6; cursor: default; } /* the cancel request is in flight */
.sync-cancel-btn[hidden] { display: none; } /* the hidden attr must beat the display above */
.sync-icon { width: 16px; height: 16px; display: block; flex: 0 0 auto; }
.sync-btn .sync-icon.is-spinning { animation: spin 1s linear infinite; }
@media (prefers-reduced-motion: reduce) {
.sync-btn .sync-icon.is-spinning { animation: none; }
}
/* Phase 64: the live-file label ("Syncing… (n/m)" / "Importing
(n/m)") — a long source/relative/path ellipsizes inside the
pill; the full path lives in the button title + the #sync-progress
box (the aria-live announcer — phase 150: the SOLE sync text
surface). Phase 128 (task 02): the label is a FLEX ROW
of parts — .sync-label-main (the prefix + file/folder: min-width
0, the shrinkable item whose ellipsis engages) + .sync-label-count
(the pinned "(n/m)": flex 0 0 auto, NEVER clipped — the
.sync-progress-text mono language, a position reads as data). Phase 129
(task 04): a THIRD pinned part — .sync-label-chunk (the "· chunk
n/m" embed-batch position of the in-flight file, the same pinned
mono language; hidden in every state but the import phase after
the file's first batch). */
.sync-label {
display: inline-flex;
align-items: baseline;
gap: 0.35rem;
min-width: 0;
max-width: min(16rem, 40vw);
}
.sync-label-main {
min-width: 0; /* the flex item that shrinks — what engages the ellipsis */
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sync-label-count {
flex: 0 0 auto; /* NEVER clipped — the (n/m) position is the point */
white-space: nowrap;
font-family: var(--mono); /* the .sync-progress-text mono language (position reads as data) */
}
.sync-label-chunk {
flex: 0 0 auto; /* NEVER clipped — the · chunk n/m position is the point (phase 129) */
white-space: nowrap;
font-family: var(--mono); /* the same pinned-part language as the count (position reads as data) */
}
/* Phase 149 (task 02): the sync detail box — the house's bordered panel
language (the .page-head card: --surface fill, 1px --line border,
the house radius; --line is decorative, no contrast duty, like every
other house border). It sits in the .sources-shell flex column whose
1.25rem gap IS the house spacing above and below — no margin-top
(it would double the gap). min-height ≈ 2 lines (0.8rem mono at the
body 1.55 line-height, + the 0.75rem block padding + the 2px border,
border-box) so even a one-line label reads as a calm panel with
breathing room. The box is hidden-attr-driven (the global [hidden]
!important rule keeps it display:none while its text is empty —
phase 150: it shows while a run is in flight AND stays up after
settle to carry the result line). Mobile: a block in the same
.container rides the 100%-width phone shell — full width with no
override (task 03's E2E asserts it at 360px). */
.sync-progress {
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 0.75rem 1rem;
min-height: 4rem;
}
.sync-progress-text {
display: block;
font-family: var(--mono); /* the house mono language — position reads as data */
font-size: 0.8rem; /* the house small-data size */
line-height: 1.55; /* the house body line-height */
color: var(--ink-soft); /* PINNED identity pair on --surface (8.2:1 on the
built-in dark, AA in both built-ins; the theme gate
keeps any custom theme at >= 4.5:1) */
overflow-wrap: anywhere; /* THE POINT of the box — a long source/relative/path WRAPS
inside the panel; the pre-phase pill ellipsis is retired
for the live detail (never clipped) */
}
/* Phase 149 (task 02): the running button IS the loading spinner (the
owner's ask) — the label parts hide via the .is-running class
(sources.js adds/removes it exactly where the icon's .is-spinning
travels). The pill's padding/border stay, so the control never
collapses: the 16px icon centers in a ~47x44px pill (desktop) or the
full-width 44px bar (mobile — the pre-phase .sync-btn { width: 100% }
rule keeps the big in-flight target, viewport-agnostic rule defined
ONCE here). :hover, :disabled, and :focus-visible are untouched —
they live on the .sync-btn element, and hiding the label span touches
neither. The idle "Sync sources" label (and the mobile ellipsis rules
for it) render exactly as pre-phase when the class is absent. */
.sync-btn.is-running .sync-label { display: none; }
/* Sync failure modal — lazily created by sources.js, appended to .
Error surface: --err-bg with --err-line border, title in --ink,
error text in --err-ink (9.1:1 on --err-bg). Backdrop dims with
--bg at 82%. z-index 1000 (same overlay contract as doc-modal). */
.sync-modal-backdrop {
position: fixed;
inset: 0;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
background: color-mix(in srgb, var(--bg) 82%, transparent);
visibility: hidden;
opacity: 0;
transition: opacity 120ms ease;
}
.sync-modal-backdrop.is-open {
visibility: visible;
opacity: 1;
}
.sync-modal {
position: relative;
width: 100%;
max-width: 28rem;
background: var(--err-bg);
border: 1px solid var(--err-line);
border-radius: var(--radius);
box-shadow: var(--shadow-lg);
padding: 1.1rem 3rem 1.25rem 1.25rem;
}
#sync-modal-title {
margin: 0 0 0.6rem;
font-size: 1.1rem;
color: var(--ink);
}
#sync-modal-error {
margin: 0 0 1rem;
font-family: var(--mono);
font-size: 0.9rem;
color: var(--err-ink);
overflow-wrap: anywhere;
}
.sync-modal-close {
position: absolute;
top: 0.3rem;
right: 0.3rem;
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 44px;
min-height: 44px;
padding: 0;
border-radius: 999px;
border: 1px solid var(--err-line);
background: transparent;
color: var(--err-ink);
font-size: 1.2rem;
line-height: 1;
cursor: pointer;
}
.sync-modal-close:hover { background: color-mix(in srgb, var(--err-line) 15%, transparent); }
@media (prefers-reduced-motion: reduce) {
.sync-modal-backdrop { transition: none; }
}
.stat-cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
gap: 0.9rem;
}
.stat-card {
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 1.1rem 1.25rem;
display: flex;
flex-direction: column;
gap: 0.15rem;
}
/* Mono stat values — technical readout (phase 08). */
.stat-value { font-family: var(--mono); font-size: 2rem; font-weight: 800; color: var(--brand-ink); line-height: 1.1; }
.stat-value-sm { font-size: 1.15rem; font-weight: 700; }
.stat-label { color: var(--ink-soft); font-size: 0.88rem; font-weight: 600; }
/* Phase 16: anonymous sign-in gate — the designed replacement for the
catalog (stat cards + table) until the admin signs in. */
.sources-gate {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
gap: 0.4rem;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 2.5rem 1.75rem;
}
.sources-gate-glyph { color: var(--brand-ink); width: 44px; height: 44px; }
.sources-gate-glyph svg { width: 44px; height: 44px; display: block; }
.sources-gate h2 { margin: 0.6rem 0 0.3rem; font-size: 1.4rem; }
.sources-gate-sub { margin: 0 auto; max-width: 30rem; color: var(--ink-soft); }
.sources-gate-link {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 44px;
margin-top: 0.75rem;
padding: 0.5rem 1.4rem;
border-radius: 999px;
background: var(--brand);
color: var(--bg); /* dark ink on brand: 5.2:1 */
font-weight: 700;
text-decoration: none;
}
.sources-gate-link:hover { background: var(--brand-hover); }
/* Phase 79 (task 05): the in-app token gate — the gate surface of the
two token-only pages (the shell + the document viewer, one shared
.auth-gate markup each). A body-level FIXED overlay (the
body-level doc-modal precedent): while visible it is the only
CONTENT-level interactive surface — assets/token-gate.js locks the
root (#main, inert) so focus and keyboard traversal stay inside
the gate (WCAG, the inert-pair contract).
Stacking (phase 85, TODO.md L3): z-index 15 — ABOVE all app
content (static) but BELOW the sticky header (20) and its mobile
dropdown (21, inside the header's stacking context): the
unauthenticated visitor keeps a reachable hamburger + menu while
the gate still covers and locks the app content (#main stays
inert — the lock is JS, not z-order — phase 79). The skip-link
(100) and the 1000-family overlays (doc-modal, sync backdrop) are
unaffected — all above 15; the doc-modal can only be opened from
the unlocked app anyway. Solid --bg: the canvas + grid texture
live on (the body stays transparent), so the overlay reads
as the app's own surface — no blur (the phase-08 perf anchor).
The centered card reuses the #sources-gate visual language
(surface card + hairline + glyph + heading + sub + action) in a
tighter column. */
.auth-gate {
position: fixed;
inset: 0;
z-index: 15;
display: flex; /* the card is the only in-flow child — margin: auto centers it */
overflow-y: auto; /* short viewports: the card scrolls fully into view */
background: var(--bg);
}
/* Explicit (the global [hidden] rule already wins — this is the
documented, testable contract for the skeleton). */
.auth-gate[hidden] { display: none; }
.auth-gate-card {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
gap: 0.4rem;
/* margin:auto centers AND stays scrollable on overflow (the flex
centering pitfall: align/justify center would clip the top of a
taller-than-viewport card). */
margin: auto;
width: min(28rem, calc(100vw - 2rem));
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 2.5rem 1.75rem;
}
.auth-gate-glyph { color: var(--brand-ink); width: 44px; height: 44px; }
.auth-gate-glyph svg { width: 44px; height: 44px; display: block; }
.auth-gate h2 { margin: 0.6rem 0 0.3rem; font-size: 1.4rem; }
.auth-gate-sub { margin: 0; max-width: 24rem; color: var(--ink-soft); }
/* The token form: mono input (the token is code — the house mono
language), a visible focus ring (WCAG — the 3px :focus-visible
outline at offset 0 + the brand border), and the house submit
button (the .tune-save language: dark ink on brand, 5.2:1). Both
targets are ≥44px (the touch-target contract). */
.auth-gate form {
display: flex;
flex-direction: column;
gap: 0.6rem;
width: 100%;
margin-top: 0.9rem;
}
.auth-gate input {
font-family: var(--mono);
font-size: 0.95rem;
color: var(--ink); /* 16.7:1 on --bg */
background: var(--bg);
border: 1px solid var(--line);
border-radius: var(--radius-sm);
padding: 0.65rem 0.8rem;
min-height: 44px;
width: 100%;
}
.auth-gate input::placeholder { color: var(--ink-soft); }
.auth-gate input:focus-visible { outline-offset: 0; border-color: var(--brand); }
.auth-gate-submit {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 44px;
padding: 0.4rem 1.1rem;
border: 0;
border-radius: var(--radius-sm);
background: var(--brand);
color: var(--bg); /* dark ink on brand: 5.2:1 */
font: inherit;
font-weight: 700;
cursor: pointer;
}
.auth-gate-submit:hover:not(:disabled) { background: var(--brand-hover); }
.auth-gate-submit:disabled { opacity: 0.6; cursor: wait; }
/* The one-line error (role=alert) — the rose/danger family the
house alerts use (--err-ink on --err-bg, 9.3:1). */
.auth-gate-error {
margin: 0.75rem 0 0;
background: var(--err-bg);
color: var(--err-ink);
border: 1px solid var(--err-line);
border-radius: var(--radius-sm);
padding: 0.45rem 0.8rem;
}
/* The secondary "Sign in as admin" link reuses the .sources-gate-link
pill language (its own margin-top: 0.75rem applies) — the guest's
other door: the admin password login (the static ?next=/ is the
no-JS fallback). */
/* Phase 86: the card is the containing block for the
position:absolute .visually-hidden elements it hosts (the table
+ the Actions header span in #view-history /
#view-tokens). Without a positioned ancestor their 1px boxes lay
out against the INITIAL containing block — at the 640px table's
right edge — and leak into the DOCUMENT's scrollable overflow
(the page appeared "the width of the table", TODO.md L4; the
Tokens view carried the identical defect). Zero-offset positioning
changes no layout: the spans stay clipped by their own
clip: rect(0 0 0 0) + 1px box, and cards without such spans (the
RAG .docs-table, the git-sources table) render unchanged. */
.table-wrap {
position: relative;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: var(--shadow);
overflow-x: auto;
}
.docs-table {
width: 100%;
border-collapse: collapse;
min-width: 640px;
font-size: 0.93rem;
}
.docs-table th, .docs-table td {
text-align: left;
padding: 0.7rem 1rem;
border-bottom: 1px solid var(--line);
white-space: nowrap;
}
.docs-table th {
background: var(--brand-soft);
color: var(--brand-ink);
font-size: 0.82rem;
text-transform: uppercase;
letter-spacing: 0.04em;
position: sticky;
top: 0;
}
.docs-table td:nth-child(2) { font-family: var(--mono); font-size: 0.82rem; max-width: 30rem; overflow: hidden; text-overflow: ellipsis; }
.docs-table tbody tr:hover { background: var(--bg); }
.docs-table tbody tr:last-child td { border-bottom: 0; }
/* ---------- KB drill-down tree (phase 97, task 04) ----------
The RAG view lists the catalog the way the agent's `ls` sees it
(the phase-94 concept, one end to end): #kb-crumb (the location
breadcrumb — hidden at the top level), #kb-level (the current
directory's STORED description — the surface-panel language of the
phase-93 page heads), and #folders-table (the ONE folders/sources
table — the .docs-table language; .kb-folders-table only re-styles
the 2nd/4th column cells — phase 106 (task 08, D8) moved
Description from 3rd to 4th, the Updated column taking 3rd). Phase-08
tokens only — NO new hue
(the phase-92 monochrome invariant): brand-ink on surface 9.0:1,
ink 13.8:1, ink-soft 5.1:1 (every pair AA). :focus-visible via the
global 3px outline rule; row targets keep the .docs-table cell
padding (>= 44px effective — the phase-08 touch floor). No CDN,
system fonts. */
.kb-crumb {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.1rem 0.3rem;
margin: 0.9rem 0 0;
font-size: 0.92rem;
}
.kb-crumb-link {
color: var(--brand-ink); /* 10.4:1 on --bg, 9.5:1 on the --brand-soft hover */
text-decoration: none;
padding: 0.25rem 0.35rem;
border-radius: var(--radius-sm);
}
.kb-crumb-link:hover { background: var(--brand-soft); text-decoration: underline; }
.kb-crumb-current {
color: var(--ink); /* 16.7:1 on --bg */
font-weight: 600;
padding: 0.25rem 0.35rem;
}
.kb-crumb-sep { color: var(--ink-soft); }
.kb-level {
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 1rem 1.25rem;
margin: 0.9rem 0 0.75rem;
}
.kb-level h2 {
margin: 0 0 0.35rem;
font-family: var(--mono);
font-size: 1rem;
color: var(--brand-ink); /* 9.0:1 on --surface — the path, in the path voice */
word-break: break-all;
}
.kb-level p { margin: 0; color: var(--ink-soft); } /* 5.1:1 on --surface */
.kb-folders-table { min-width: 480px; }
.kb-folders-table td:nth-child(2) { font-family: var(--font); font-size: inherit; max-width: none; }
/* Phase 106 (task 08, D8): the Updated column sits at 3rd (BETWEEN
Documents and Description) — it takes the .docs-table cell defaults
(nowrap text, the table ink: var(--ink) on the wrap's --surface —
13.8:1, the pair recorded at :root). NO new CSS: the one-line clamp
below follows the Description cell, which moved to 4th. */
.kb-folders-table td:nth-child(4) {
white-space: nowrap; /* phase 99 (D1): one line — the clamp's budget is the column width */
min-width: 18rem;
max-width: 44rem;
font-size: 0.88rem;
}
/* Phase 99 (task 01, D1): the Description cell is ONE flex row — the
text span flexes + ellipsizes (the row height is one line, independent
of the description's length), the Edit button stays fixed (flex: 0 0
auto — it never wraps to a second line). The gap replaces the button's
old margin-left (the context-spacing rule below keeps only the level
block's margin-top). The clamp is visual only: the full text stays in
the DOM + on the span's title (hover). */
.kb-desc-cell {
display: flex;
align-items: center;
gap: 0.4rem;
min-width: 0;
}
.kb-desc-text {
flex: 1 1 auto;
min-width: 0; /* lets the flex item shrink — what engages the ellipsis (the .sync-label pattern) */
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.folder-link {
color: var(--brand-ink); /* 9.0:1 on --surface (the .doc-link pair, row variant) */
text-decoration: none;
border-radius: var(--radius-sm);
}
.folder-link:hover, .folder-link:focus-visible {
background: var(--brand-soft);
text-decoration: underline;
}
/* Image-document rows (phase 122, task 04): the file table's Path cell
carries a FIXED 48px thumbnail box before the path link (image rows
only — text rows keep the pre-phase bare-link cell, no box). The
.kb-doc-path flex wrapper gives the link its own ellipsis budget
(min-width: 0 — the .sync-label pattern) now that the box shares
the cell; object-fit: cover keeps every aspect ratio inside the
square (CSS on .kb-doc-thumb-img). The glyph fallback (a failed
fetch, or a node without a servable image_url) swaps INSIDE the
same fixed box — no layout shift beyond the box, no broken-image
placeholder. The img's alt (the vision description) carries the
accessibility; the glyph is aria-hidden. Phase-08 tokens only. */
.kb-doc-path {
display: flex;
align-items: center;
gap: 0.5rem;
min-width: 0;
max-width: 100%;
}
.kb-doc-path .doc-link {
flex: 1 1 auto;
min-width: 0; /* lets the link shrink — what engages the ellipsis */
overflow: hidden;
text-overflow: ellipsis;
}
.kb-doc-thumb {
flex: 0 0 auto;
display: block;
width: 48px;
height: 48px;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
background: var(--bg);
color: var(--ink-soft); /* the glyph's stroke color (decorative) */
overflow: hidden;
}
.kb-doc-thumb-img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.kb-doc-thumb-glyph {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
padding: 9px;
box-sizing: border-box;
}
.kb-doc-thumb-glyph svg {
width: 100%;
height: 100%;
}
/* ---------- KB folder-description editor (phase 97, task 05) ----------
The phase-57 edit affordance on the RAG view's folder descriptions
(the .kb-summary-* family, mirroring the viewer's .doc-summary-*):
the Edit button (a 24px+ target; the [hidden] override beats its
display rule while the editor is open), the inline editor (the
8rem-min textarea + Save/Cancel + the role=status live region).
House dark-tech palette (phase-08 tokens), system fonts, no CDN;
:focus-visible via the global 3px outline rule. No new hue (the
phase-92 monochrome invariant) — every color is a var(). */
.kb-summary-edit {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
min-height: 24px;
padding: 0.15rem 0.7rem;
border: 1px solid var(--line);
border-radius: 999px;
background: transparent;
color: var(--ink-soft); /* 5.1:1 on --surface (AA) */
font: inherit;
font-weight: 600;
font-size: 0.78rem;
letter-spacing: 0.02em;
cursor: pointer;
}
.kb-summary-edit:hover { background: var(--brand-soft); color: var(--brand-ink); border-color: var(--brand); }
.kb-summary-edit[hidden] { display: none; } /* the hidden attr must beat the display above */
/* Context spacing: the row cell's button spacing comes from the
.kb-desc-cell gap (phase 99, task 01 — the margin-left is gone);
the level block's sits below the
. */
.kb-level-body .kb-summary-edit { margin-top: 0.5rem; }
.kb-summary-editor {
display: block;
width: 100%;
min-height: 8rem; /* the phase-57 spec */
padding: 0.6rem 0.8rem;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
background: var(--bg); /* inset against the --surface panel / row */
color: var(--ink); /* 16.7:1 on --bg (AA) */
font: inherit;
line-height: 1.5;
resize: vertical;
}
/* Phase 99 (task 01): the open editor fills the .kb-desc-cell flex
wrapper — as a flex item it needs the explicit grow + min-width: 0
(the width: 100% base above still serves the level block's non-flex
.kb-level-body). */
.kb-desc-cell .kb-summary-editor { flex: 1 1 auto; min-width: 0; }
.kb-summary-actions {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 0.75rem;
}
.kb-summary-save {
display: inline-flex;
align-items: center;
min-height: 32px;
padding: 0.35rem 0.95rem;
border: 0;
border-radius: 999px;
background: var(--brand);
color: var(--bg); /* --bg on --brand = 5.2:1 (AA) */
font: inherit;
font-weight: 600;
font-size: 0.85rem;
cursor: pointer;
}
.kb-summary-save:hover { background: var(--brand-hover); } /* the house hover lightening */
.kb-summary-save:disabled { opacity: 0.6; cursor: default; } /* one PATCH at a time */
.kb-summary-cancel {
display: inline-flex;
align-items: center;
min-height: 32px;
padding: 0.35rem 0.95rem;
border: 1px solid var(--line);
border-radius: 999px;
background: transparent;
color: var(--ink-soft); /* 5.1:1 on --surface (AA) */
font: inherit;
font-weight: 600;
font-size: 0.85rem;
cursor: pointer;
}
.kb-summary-cancel:hover { background: var(--err-bg); color: var(--err-ink); border-color: var(--err-line); }
.kb-summary-status {
margin: 0.6rem 0 0;
font-size: 0.85rem;
color: var(--ink-soft); /* 5.1:1 on --surface (AA) */
}
.kb-summary-status:empty { margin-top: 0; }
/* Phase 98 (task 04, D4): the row-cell "Summary pending" marker — the
muted ink-soft pair (5.1:1 on --surface, AA); text + color, never
color alone. The class sits on the EXISTING description text span,
so the row cell's font-size/line-height apply — the marker must not
change row height (no font/white-space overrides). No italic, no
new hue (the phase-92 monochrome invariant). */
.kb-summary-pending { color: var(--ink-soft); }
/* ---------- Git sources page (phase 35) ----------
/git-sources.html: the admin-only manager for the stored git source
list (add / remove, git-sources table). Same full-width table
language as the Sources page (no skinny single-column list), the
phase-16 gate reused verbatim (.sources-gate classes), and the
phase-27 form-card language for the add form. Every pair reuses the
Phase-08 AA palette: dark ink on brand 5.2:1 (never white on
brand, 3.7:1, fails), brand-ink/brand-soft 6.9:1, err 9.1:1,
ink-soft >=6.9:1. Touch targets >=44px; :focus-visible via the
global 3px outline rule. No filter: blur, no CDN, system fonts. */
.git-sources-shell {
display: flex;
flex-direction: column;
gap: 1.25rem;
flex: 1;
}
/* Add forms — the tuning form's surface as a single row: visible
label + mono location input (git URLs may embed credentials, so the
input is mono) + the brand button; wraps to a column at narrow
widths (the <=640px block below). Phase 49: the archive upload form
(#archive-upload-form, replacing the phase-38 local-directory form)
reuses the git form's card + button rules VERBATIM — one form
language for both; its file input carries its own rules below. */
#git-source-form,
#archive-upload-form {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.6rem;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 0.9rem 1rem 1rem;
}
#git-source-form:focus-within,
#archive-upload-form:focus-within { border-color: var(--brand); box-shadow: 0 0 0 3px var(--brand-soft), var(--shadow); }
#git-source-form > label,
#archive-upload-form > label { color: var(--ink); font-weight: 600; white-space: nowrap; }
/* Phase 121 (task 03): the form-label "optional" hint — the muted
ink-soft pair (5.1:1 on the label's surface, AA) at the label's
600 weight relaxed to 400 so the hint reads as secondary (it
qualifies the name, it is not the name); small, inline. */
.field-hint { color: var(--ink-soft); font-weight: 400; font-size: 0.8rem; }
/* #git-source-token (phase 121): the optional masked private-repo
credential — the URL input's treatment VERBATIM (mono, >=44px
target, brand focus); type=password masks the value in display. */
#git-source-url,
#git-source-branch,
#git-source-token {
flex: 1;
min-width: 14rem;
min-height: 44px;
font-family: var(--mono);
font-size: 0.88rem;
color: var(--ink);
background: var(--bg);
border: 1px solid var(--line);
border-radius: var(--radius-sm);
padding: 0.45rem 0.7rem;
}
#git-source-url::placeholder,
#git-source-branch::placeholder,
#git-source-token::placeholder { color: var(--ink-soft); }
#git-source-url:focus-visible,
#git-source-branch:focus-visible,
#git-source-token:focus-visible { outline-offset: 0; border-color: var(--brand); }
#git-source-add,
#archive-upload-btn {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 44px;
padding: 0.4rem 1.2rem;
border: 0;
border-radius: var(--radius-sm);
background: var(--brand);
color: var(--bg); /* dark ink on brand: 5.2:1 */
font: inherit;
font-weight: 700;
cursor: pointer;
}
#git-source-add:hover:not(:disabled),
#archive-upload-btn:hover:not(:disabled) { background: var(--brand-hover); }
#git-source-add:disabled,
#archive-upload-btn:disabled { opacity: 0.6; cursor: wait; }
/* Phase 49: the upload form's file control — mono-ish, on-surface, a
>=44px touch target, :focus-visible via the global 3px outline rule
(offset zeroed + brand border, exactly like the git URL input). The
chosen filename renders in mono; the selector button keeps a plain
surface chip. */
#archive-upload-file {
flex: 1;
min-width: 14rem;
min-height: 44px;
font-family: var(--mono);
font-size: 0.88rem;
color: var(--ink);
background: var(--bg);
border: 1px solid var(--line);
border-radius: var(--radius-sm);
padding: 0.3rem 0.5rem;
}
#archive-upload-file:focus-visible { outline-offset: 0; border-color: var(--brand); }
#archive-upload-file::file-selector-button {
min-height: 34px;
margin-right: 0.6rem;
padding: 0.3rem 0.9rem;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
background: var(--surface);
color: var(--ink); /* 13.8:1 on surface */
font: inherit;
font-size: 0.85rem;
font-weight: 600;
cursor: pointer;
}
#archive-upload-file::file-selector-button:hover { border-color: var(--brand); color: var(--brand-ink); }
/* The upload's success line (role=status): the .sync-progress-text
soft-ink result-line language ("2 added · 1 pruned") — ink-soft on
surface (>=4.5:1), the dashed hint-box border marks it as a result,
not an error; it drops onto its own row under the input like the
error line. */
#archive-upload-result {
flex-basis: 100%;
margin: 0;
color: var(--ink-soft);
background: var(--surface);
border: 1px dashed var(--line);
border-radius: var(--radius-sm);
padding: 0.45rem 0.8rem;
font-size: 0.85rem;
font-weight: 600;
}
/* The add form's inline error (role=alert): the err pair (9.1:1);
flex-basis 100% drops it onto its own row under the input. */
.git-source-error {
flex-basis: 100%;
margin: 0;
background: var(--err-bg);
color: var(--err-ink);
border: 1px solid var(--err-line);
border-radius: var(--radius-sm);
padding: 0.45rem 0.8rem;
font-size: 0.85rem;
font-weight: 600;
}
/* Load failure (role=alert) + the retry action: a failed
GET /api/git-sources must never leave a stuck page. The retry
button keeps the err pair on the err surface (9.1:1). */
.git-source-load-error {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.6rem;
background: var(--err-bg);
color: var(--err-ink);
border: 1px solid var(--err-line);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 0.7rem 0.9rem;
font-size: 0.9rem;
font-weight: 600;
}
.git-source-load-error > span { flex: 1; min-width: 12rem; }
#git-sources-retry {
min-height: 44px;
padding: 0.4rem 1rem;
border: 1px solid var(--err-line);
border-radius: var(--radius-sm);
background: transparent;
color: var(--err-ink);
font: inherit;
font-weight: 700;
cursor: pointer;
}
#git-sources-retry:hover { background: color-mix(in srgb, var(--err-line) 12%, transparent); }
/* Env-fallback note (from_env: true — the table is empty and the list
is BOR_GIT_SOURCES): the info chip in the theme palette —
brand-soft surface, brand-ink text (6.9:1). */
.git-source-env-note {
margin: 0;
background: var(--brand-soft);
color: var(--brand-ink);
border: 1px solid var(--brand-soft);
border-radius: var(--radius-sm);
padding: 0.6rem 0.9rem;
font-size: 0.88rem;
}
.git-source-env-note code {
font-family: var(--mono);
font-size: 0.85em;
background: var(--surface);
color: var(--brand-ink); /* 8.7:1 on surface */
padding: 0.1em 0.35em;
border-radius: 5px;
}
/* Hint box (role=note): the page-sub styling family — ink-soft on
surface (6.9:1), a dashed border marks it as guidance, not state.
It names the Sync button: the action that clones the listed repos
and prunes the removed ones (the phase scope boundary). */
.git-source-hint {
margin: 0;
color: var(--ink-soft);
background: var(--surface);
border: 1px dashed var(--line);
border-radius: var(--radius-sm);
padding: 0.6rem 0.9rem;
font-size: 0.88rem;
}
/* ---------- Remove confirmation modal (phase 69, task 02) ----------
/git-sources.html: the in-app confirmation that replaces the
native confirm() — the total-removal warning (the row, the
source's indexed documents, and — for git clones and uploaded
archives — the files on the server's disk). The .doc-modal overlay contract:
a fixed full-viewport dim backdrop + a centered panel (z-index
1000, above the sticky header (20) + skip-link (100); NO blur —
the phase-08 no-blur perf anchor), scaled to a compact dialog:
the chat-column width (the --chat-column token — 72rem, the
.container width) or the viewport, whichever is narrower.
Phase-08 tokens only; system fonts; no CDN.
AA pairs: title/copy are --ink on --surface (13.8:1); the source
value is --ink on --bg (16.7:1); the error line is the err pair
(err-ink on err-bg 9.3:1, the err-line border); the destructive
button rides the err token family — the .tuning-delete /
.steering-delete hover convention used as the resting state
(err-ink on err-bg 9.3:1; the hover inverts to dark --bg on
--err-line, 5.2:1 — both AA); Cancel is the ghost ink-soft family
(5.1:1 on --surface). :focus-visible via the global 3px outline
rule (no local suppression — focus lands on Cancel at open and is
visible); both buttons >=44px; no animation (reduced-motion safe
by construction). */
.remove-confirm {
position: fixed;
inset: 0;
z-index: 1000;
display: flex; /* the panel is the only in-flow child — margin: auto centers it */
}
/* Explicit (the global [hidden] rule already wins — the documented,
testable contract for the skeleton). */
.remove-confirm[hidden] { display: none; }
.remove-confirm-backdrop {
position: fixed;
inset: 0;
/* --bg at 82% — the doc-modal dim, no backdrop-filter (no-blur). */
background: color-mix(in srgb, var(--bg) 82%, transparent);
}
.remove-confirm-panel {
/* position:relative lifts the panel above the fixed backdrop
(positioned elements paint over in-flow siblings otherwise). */
position: relative;
z-index: 1;
margin: auto;
width: min(var(--chat-column), calc(100vw - 2rem));
padding: 1.5rem;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: var(--shadow-lg);
}
.remove-confirm-title {
margin: 0 0 0.75rem;
font-size: 1.25rem;
line-height: 1.3;
color: var(--ink);
}
/* The source's value (git URL or local path) — mono, wrapped (a long
URL must not overflow the panel), --ink on --bg (16.7:1). */
.remove-confirm-source {
display: block;
margin: 0 0 0.75rem;
padding: 0.5rem 0.65rem;
font-family: var(--mono);
font-size: 0.85rem;
line-height: 1.5;
color: var(--ink);
background: var(--bg);
border: 1px solid var(--line);
border-radius: var(--radius-sm);
overflow-wrap: anywhere;
}
.remove-confirm-copy {
margin: 0;
color: var(--ink); /* 13.8:1 on --surface */
}
/* The in-modal failure line (role=alert): the err pair (err-ink on
err-bg 9.3:1, the err-line border) — the .git-source-error banner
language, boxed. */
.remove-confirm-error {
margin: 0.75rem 0 0;
padding: 0.5rem 0.65rem;
color: var(--err-ink);
background: var(--err-bg);
border: 1px solid var(--err-line);
border-radius: var(--radius-sm);
}
.remove-confirm-actions {
display: flex;
justify-content: flex-end;
gap: 0.6rem;
margin-top: 1.1rem;
}
/* The two modal buttons: >=44px targets, the house 3px :focus-visible
via the global outline rule (no local override). */
.remove-confirm-btn {
min-height: 44px;
min-width: 44px;
padding: 0.55rem 1.1rem;
border-radius: var(--radius-sm);
font: inherit;
font-weight: 600;
font-size: 0.9rem;
white-space: nowrap;
cursor: pointer;
}
.remove-confirm-btn:disabled { opacity: 0.5; cursor: wait; }
/* Cancel — the ghost ink-soft family (5.1:1 on --surface), like the
row's Remove / .tuning-edit; the brand pair on hover (6.9:1). */
.remove-confirm-cancel {
border: 1px solid var(--line);
background: transparent;
color: var(--ink-soft);
}
.remove-confirm-cancel:hover:not(:disabled) {
background: var(--brand-soft);
color: var(--brand-ink);
}
/* "Remove source" — the destructive button on the err token family
(the .tuning-delete / .steering-delete convention): err-ink on
err-bg 9.3:1, the err-line border; the hover inverts to dark --bg
on --err-line (5.2:1 — AA). */
.remove-confirm-remove {
border: 1px solid var(--err-line);
background: var(--err-bg);
color: var(--err-ink);
}
.remove-confirm-remove:hover:not(:disabled) {
background: var(--err-line);
color: var(--bg);
}
/* ---------- Per-source ignore-paths editor (phase 89, task 05) ----------
The shell's Sources view: the page-local alertdialog that edits
one source's ignore list (one path per line — the phase-89 A1
prefix rule, stated in plain words in the helper copy). The EXACT
#remove-confirm-dialog overlay contract (phase 69): a fixed
full-viewport dim backdrop + a centered panel (z-index 1000, above
the sticky header (20) + skip-link (100); NO blur — the phase-08
no-blur perf anchor), scaled to the chat-column width (the
--chat-column token — 72rem, the .container width) or the
viewport, whichever is narrower. The box: a VISIBLE block label
(WCAG — never aria-label-only) over a mono textarea (the box is a
data entry, not prose — the var(--mono) stack); the error line is
the err pair (err-ink on err-bg 9.3:1, the err-line border);
Cancel is the ghost ink-soft family (5.1:1 on --surface); Save is
the solid brand family (--bg text on --brand 5.2:1 — the
.new-chat-btn convention; the hover lightens the fill). Both
buttons >=44px; :focus-visible via the global 3px outline rule (no
local suppression — focus lands on Cancel at open and is visible);
no animation (reduced-motion safe by construction). AA pairs: title
/ copy / label are --ink (or ink-soft) on --surface (13.8:1 /
5.1:1); the source value is --ink on --bg (16.7:1). Phase-08
tokens only; system fonts; no CDN. */
.ignore-editor {
position: fixed;
inset: 0;
z-index: 1000;
display: flex; /* the panel is the only in-flow child — margin: auto centers it */
}
/* Explicit (the global [hidden] rule already wins — the documented,
testable contract for the skeleton). */
.ignore-editor[hidden] { display: none; }
.ignore-editor-backdrop {
position: fixed;
inset: 0;
/* --bg at 82% — the doc-modal dim, no backdrop-filter (no-blur). */
background: color-mix(in srgb, var(--bg) 82%, transparent);
}
.ignore-editor-panel {
/* position:relative lifts the panel above the fixed backdrop
(positioned elements paint over in-flow siblings otherwise). */
position: relative;
z-index: 1;
margin: auto;
width: min(var(--chat-column), calc(100vw - 2rem));
padding: 1.5rem;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: var(--shadow-lg);
}
.ignore-editor-title {
margin: 0 0 0.75rem;
font-size: 1.25rem;
line-height: 1.3;
color: var(--ink);
}
/* The source's value (git URL or local path) — mono, wrapped (a long
URL must not overflow the panel), --ink on --bg (16.7:1). */
.ignore-editor-source {
display: block;
margin: 0 0 0.75rem;
padding: 0.5rem 0.65rem;
font-family: var(--mono);
font-size: 0.85rem;
line-height: 1.5;
color: var(--ink);
background: var(--bg);
border: 1px solid var(--line);
border-radius: var(--radius-sm);
overflow-wrap: anywhere;
}
.ignore-editor-copy {
margin: 0 0 1rem;
color: var(--ink); /* 13.8:1 on --surface */
}
.ignore-editor-copy code { font-family: var(--mono); font-size: 0.85em; }
/* Visible block label (WCAG — the textarea is NEVER labelled only via
aria-label); ink-soft on --surface (5.1:1). */
.ignore-editor-label {
display: block;
margin-bottom: 0.35rem;
color: var(--ink-soft);
font-size: 0.88rem;
font-weight: 600;
}
/* The box: full panel width, mono (the var(--mono) stack), theme-
consistent border on --bg; the focus-visible ring is the global
3px outline (no local suppression); vertical resize for long
lists (up to 200 entries, A4). */
.ignore-editor-textarea {
display: block;
width: 100%;
min-height: 9rem;
padding: 0.55rem 0.65rem;
font-family: var(--mono);
font-size: 0.85rem;
line-height: 1.5;
color: var(--ink);
background: var(--bg);
border: 1px solid var(--line);
border-radius: var(--radius-sm);
resize: vertical;
}
/* Phase 121 (task 03): the editor's masked token field — the
textarea's box language (full panel width, --bg fill, the line
border, mono) at the 44px touch floor; the label's top margin keeps
the field pair off the textarea. type=password + autocomplete=off
live in the markup (a PAT is not a site credential — no browser
save offer); the focus ring is the global 3px outline rule. */
.ignore-editor-token-label { margin-top: 0.9rem; }
.ignore-editor-token {
display: block;
width: 100%;
min-height: 44px;
padding: 0.55rem 0.65rem;
font-family: var(--mono);
font-size: 0.85rem;
color: var(--ink);
background: var(--bg);
border: 1px solid var(--line);
border-radius: var(--radius-sm);
}
/* Phase 135 (task 04): the editor's branch field — the token field's
box language (full panel width, --bg fill, the line border, mono)
at the 44px touch floor; type=text (a branch is a name, not a
credential — no masking) + autocomplete=off live in the markup, and
the label reuses .ignore-editor-label (the source line's 0.75rem
bottom margin is the field pair's top gap — no extra margin class).
The muted hint line carries the .field-hint values (ink-soft, 400,
0.8rem — the token field's hint styling) as a standalone