Files
brain-of-reese/frontend/assets/styles.css
T
ducoterra 4f20c256d7
Build and Push Containers / build-and-push-app (push) Successful in 7m24s
Build and Push Containers / build-and-push-db (push) Successful in 36s
phase: 191_source_name_labels_tags
All gates re-verified against a stable tree. Report:

**Verified (all 6 tasks in `complete/`; this was the final pass)**
- `SourceLabel`+alembic `0030` (dev DB `alembic current` = `0030 (head)`, table present, 0 rows); admin-only `GET/PUT/DELETE /api/source-labels` (`require_admin` router-wide, `resolve_source_name`, partial-update caps/404s); Sources-page "Names, labels and tags" card (read-only derived-name `<code>`, per-row `saveInFlight`, 404 note that survives the settle, chips reuse `.git-source-hidden-count`); `question_name_hits` D2 rule; ONE `all_labels` read per turn in `chat.py` feeding both the `source_name_hit` flag and the prompt surfaces; `source_name_hit_bonus=0.005` in `_selection_order`; ` name=`/` label=` in `build_high_prompt` + `build_overview_prompt(names=…)`.
- **Completion criteria:** E2E `tests/e2e/test_source_name_labels.py` ✅ 7 passed (arms a–e: editor all kinds, control/named question, rename-never-breaks-identity, 404 note, reset) · full suite ✅ · coverage ✅ · README/AGENTS ✅ · phase dir in `complete/` ✅ · commit left to the harness.
- **Gates:** `uv run pytest --cov=app --cov-report=term-missing` → **exit 0, TOTAL 7160 stmts / 16 missed = 99%** (>90%) · `uv run pytest tests/e2e/test_source_name_labels.py -v --no-cov` → **7 passed, exit 0** · regression E2E isolated, exit 0: `test_document_viewer`, `test_admin_auth`, `test_api_tokens`, `test_git_sources_admin`, `test_kb_tree`, `test_local_directory_sources` · `uv run ruff check .` → All checks passed · `uv run pyright` → 0 errors.
- **Battery (task 04 criterion):** `uv run python -m scripts.eval_retrieval --from-file tests/fixtures/retrieval_battery.txt` at default vs `BOR_SOURCE_NAME_HIT_BONUS=0` → **`diff` empty (line-for-line identical)**, matching the recorded arms (dev KB is empty, so every question deflects; the observable boost is unit/E2E-pinned).
- **Documentation:** README — fixed the `/git-sources.html` rows (nav label is **Sources**) and cross-linked the phase-191 card from that section; full audit otherwise clean: all 86 `BOR_*` settings documented with defaults matching `app/config.py`, 0 broken file/dir references and 0 broken anchors in README+AGENTS, AGENTS rule 17 re-checked line-by-line against the code.
- **Notable / deviations:** (1) a **concurrent pipeline process** (`phased-execution/progress.mjs` for `191_…_00_phase`) was mutating this same working tree mid-pass — it rewrote `tests/e2e/test_source_name_labels.py` (11→7 tests), `frontend/assets/git-sources.js`, `tests/unit/test_source_labels_editor_ui.py` and `AGENTS.md` at 05:27; every gate above was (re-)run after those edits with the tree no source file newer than the green full run. (2) The task-06 report/phase text claims regression suites `test_chat_token_gate.py`, `test_git_sources_page.py`, `test_rag_browser.py` are green — **those files have never existed in git history** (those runs exit 4); I ran the real equivalents listed above instead (phase files are not mine to edit). (3) **No live changes**: the VM DB already has `source_labels` (0 rows) but the deployed app predates this uncommitted code, so deploying `/api/source-labels` to the live service is the owner's post-commit step, not part of this phase's criteria.
- **Next pending phase:** none — `191_source_name_labels_tags` is the only entry in `.agents/phases/todo/`.
2026-10-19 06:13:32 -04:00

7235 lines
273 KiB
CSS
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* ==========================================================================
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 <html> (the canvas). <body> 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; }
/* Phase 166 (TODO L4): the answer bubble an in-flight turn creates is
EMPTY by construction — app.js:4063/:4164 call addMessage("brain","")
so ensureReasoningWrap has an anchor to insert the per-run timeline
BEFORE. With .bubble's own padding + border + background + shadow an
empty one paints a ~40px box, which is the "empty box before the LLM
responds" the owner reported (image.png). :empty matches only a
child-less element — exactly innerHTML === "" (renderMarkdown("") is
"", so the restore and the shared page take the same path) — and the
first delta writes content, so the box appears AT the first token.
display:none also drops the flex item, so .msg-body's gap collapses.
The typing indicator is never :empty (three dot <span>s) — its
animation is untouched. Scoped to .msg .bubble: the viewer's .doc-*
blocks and the composer never match. Static suppression — no new
tokens, no animation (nothing for prefers-reduced-motion to still). */
.msg .bubble:empty { display: none; }
.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
<table class="md-table">
(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 <blockquote class="md-quote"> — 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); }
/* Markdown inline links (phase 180, task 04, TODO.md L4 — "Markdown
[links](some-address) aren't being render correctly and show up as
plain text"): the shared renderer (markdown.js inline chain) turns
[text](url) — and a linked image, locked A6 — into
<a class="md-link">, and an element with no rule anywhere in this file
would paint as unmarked body text. ONE rule set serves every rendering
context the renderer feeds (the chat .bubble brain + user, the
details.thinking / details.reasoning scratchpad, the shared page's
.msg/.bubble copy, the viewer's .doc-md and the document modal, which
shares the renderDocument core) — all of them sit on --surface, and the
deflected bubble's --accent-bg holds the pairing too (8.3:1), so a
single AA pairing covers them all. Palette vars only, no hex/rgb/hsl
literal (the :root-drift test parses the built-ins out of this file — a
literal here would be a second source of truth, and a new hue would
break the monochrome invariant):
color --brand-ink — 9.9:1 on --surface, the :root-documented
AA-safe BRAND TEXT token (the same rose the
details.thinking summary and .msg.brain .avatar use);
underline unconditional — a link in running text must not rely on
color alone (WCAG 1.4.1, PLAN §7.2 "text + color, never
color alone"), and it is what keeps the link marked inside
the user bubble, where the color inverts to the bubble's
own ink below;
offset 2px — lifts the rule off the descenders (g/y/p stay open).
The focus state is the GLOBAL `:focus-visible` 3px --brand ring above
(do NOT restate or reset it here — one ring for the whole app).
Static content — no animation (nothing for prefers-reduced-motion to
still). */
.md-link {
color: var(--brand-ink); /* 9.9:1 on --surface (AA) — :root's brand text token */
text-decoration: underline;
text-underline-offset: 2px;
}
/* :hover brightens one notch on the same rose — --brand-hover is --brand
mixed with 14% white in :root: 5.9:1 on --surface and 5.0:1 on the
deflected bubble's --accent-bg, so the state change never drops under
AA on any canvas the renderer writes into (brightening is the safe
direction on a dark palette — every darker step fails there). */
.md-link:hover { color: var(--brand-hover); }
/* The USER bubble runs the question through the same renderer on the
--brand fill (the .md-quote inversion above is the precedent):
--brand-ink on --brand is 1.9:1 and --brand-hover on --brand is 1.2:1 —
both AA failures — so the link keeps the bubble's own ink, --bg on
--brand at 5.2:1 (the :root pairing). The unconditional underline is
what marks it a link here, never the color. */
.msg.user .bubble .md-link { color: var(--bg); }
/* …and the SAME inversion on :hover: the default --brand-hover would
erase the word (1.2:1 on the fill). A color-mix toward --bg cannot
help — the text is ALREADY --bg — so the hover state moves into the
DECORATION (a 2px rule at a deeper offset), which is a visible state
change that needs no new pair, keeps 5.2:1 at every theme, and stays
inside the palette (no new hue — the monochrome invariant). */
.msg.user .bubble .md-link:hover {
color: var(--bg);
text-decoration-thickness: 2px;
text-underline-offset: 3px;
}
/* Markdown inline images (phase 180, task 04, TODO.md L3 — the in-answer
half of "Need SVG render support on the front end"): ![alt](src) and
[![alt](src)](url) render <img class="md-img"> through the shared
renderer, so the painted diagram has to stay INSIDE the reading column
instead of pushing a horizontal scrollbar (the TODO's complaint is a
reference that prints; the fix is only useful when what replaces it
fits). No fixed width/height — max-width:100% + height:auto keep any
aspect ratio at 375px and at 1440px (the .doc-image-img / .source-image-img
contract), object-fit:contain never crops, and the card language
(--surface plate, --line hairline, --radius-sm) is the treatment every
other image in the app already carries, so a diagram inside an answer
reads as an image rather than a stray artifact. inline-block so the
block-ish margin behaves and the image sits as its own box inside a
wrapping <a> (locked A6) instead of on a text baseline. Palette vars
only (no literal). Static content — no animation (nothing for
prefers-reduced-motion to still). Known limit (phase 181): an SVG that
declares a viewBox but no width/height paints at the replaced-element
default box, constrained by max-width — vector sizing is phase 181's
contract, so NO .md-img--vector modifier here. */
.md-img {
display: inline-block;
max-width: 100%;
height: auto;
object-fit: contain;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius-sm);
margin: 0.4rem 0;
}
/* The deep rungs of the heading ladder (phase 180, task 04, TODO.md L5 —
"Quad-level headers (####) aren't being rendered"): the renderer now
emits <h5> for #### and <h6> for #####/###### (locked A4) and neither
tag had a rule ANYWHERE in this file, so a deep heading in an answer
inherited the UA defaults (h5 0.83em / h6 0.67em with 1.67em / 2.33em
block margins — ~1.4rem / 1.6rem on this scale) — gaps wider than the
paragraphs around it while the type itself went SMALLER than the answer
text it sits in. ONE rule set per level for every renderer context (the
viewer's .doc-md, the chat AND shared-page .bubble, the thinking
scratchpad) — no chat-specific duplication — extending the existing
.doc-md h3 1.15rem / .doc-md h4 1rem scale MONOTONICALLY
(1.15 → 1.0 → 0.95 → 0.88): every level stays a visible step from the
1rem body text and from its neighbor, and the margins
tighten with the depth on the same h3 1.1/0.4 → h4 0.9/0.35 rhythm.
Sizes are rem (never px), so the ladder survives a browser
font-size/zoom change; no color is set, so the scale adds no new
contrast pair (it inherits each context's own AA ink — ink on --surface
15.3:1, ink-soft on --surface 8.2:1 in the scratchpad). Known, accepted:
the 0.875rem scratchpad owns no h3/h4 rules of its own (UA sizes there),
so the fixed 0.95rem h5 rides a hair above its body text — a heading
stays a heading, and re-sizing the shallower rungs is out of this
phase's scope. Static content — no animation (nothing for
prefers-reduced-motion to still). */
.doc-md h5, .msg .bubble h5, .thinking-text h5 { margin: 0.8rem 0 0.3rem; font-size: 0.95rem; }
.doc-md h6, .msg .bubble h6, .thinking-text h6 { margin: 0.7rem 0 0.3rem; font-size: 0.88rem; }
/* ---------- Verbatim panels (phase 182, task 06) — BEGIN ----------
A ` ```bor <source/path> <range> ` locator fence (markdown.js step 0) and
its hydrated panel (verbatim.js, task 07): the answer shows the EXACT
bytes of an indexed file, typed by the SERVER, with a caption whose
numbers are the server's resolved extent, a VERBATIM badge, a line-number
gutter and a Copy button whose clipboard content equals those bytes.
The phase's UI contract (PLAN §7.1 + AGENTS rule 5): an answer's code
block is EITHER verbatim-and-badged OR plainly the model's — never a
silently-substituted hybrid, which is why `.verbatim-badge` belongs to a
resolved panel alone and `.code-block` (the plain wrapper task 07 adds
around an ordinary `<pre>`) carries the Copy button and no badge.
PHASE-08 TOKENS ONLY — no hex/rgb/hsl literal anywhere in this block (the
phase-92 monochrome invariant, and the :root-drift test parses the
built-ins out of this file, so a literal would be a second source of
truth). A monochrome or inverted theme keeps every state readable because
the states are carried by TEXT and STRUCTURE, never by colour alone
(WCAG 1.4.1): `Copy` → `Copied` is a text change with the --ok family as
support, the badge is the WORD "VERBATIM", the unresolved state is a
sentence, and the line gutter is digits. Contrast of the pairs used here
(built-in palette, all AA ≥4.5:1):
--ink on --surface 15.3:1 (the code text)
--ink-soft on --surface 8.2:1 (caption, gutter, pending state, button label)
--ok-ink on --ok-bg 10.6:1 (the badge, the copied state's fill)
--ok-ink on --surface 12.2:1 (the copied state's label on hover-off)
--ink-soft on --err-bg 7.9:1 (the unresolved sentence)
A code surface is also readable in a LIGHT theme for the same reason: no
#fff/#000, no new hue — every token comes from the theme's own palette.
Static content: NOTHING in this block animates or transitions, so
prefers-reduced-motion has nothing to still (the pending state is a
`role="status"` live region — the announcement IS the feedback, a pulse
would add nothing but motion). */
.verbatim {
display: block;
min-width: 0;
margin: 0.5rem 0;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius-sm);
overflow: hidden;
}
/* The head row is the panel's sticky header — PLAN §7.1's bounded-panel
rule (the .table-wrap table thead th idiom, phase 156): it stays put
while the body scrolls, so the caption (source/path · lines 12–40 of 210)
is never off-screen in a 40-line slice. OPAQUE --surface background, so
scrolled bytes never show through it. */
.verbatim-head {
position: sticky;
top: 0;
z-index: 1;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.25rem 0.5rem;
padding: 0.4rem 0.6rem;
background: var(--surface);
border-bottom: 1px solid var(--line);
}
/* The clipped label keeps the phase-156 `.cell-clip` ellipsis convention
(block/flex item that shrinks below its text width, hard cut → "…"); the
FULL text stays reachable through the control's aria-label / title, so
the clip is visual only. */
.verbatim-caption {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: var(--mono);
font-size: 0.75rem;
color: var(--ink-soft); /* 8.2:1 on --surface */
}
/* The badge is a WORD, never an icon alone (locked A5: the VERBATIM claim
must survive a screen reader and a monochrome theme), and
`text-transform: none` keeps the lowercase letters the clipboard/AT
would otherwise have to reconcile (no uppercase surprises spelled out
letter-by-letter). --ok-ink on --ok-bg is the app's existing AA pair. */
.verbatim-badge {
flex: 0 0 auto;
padding: 0.1rem 0.45rem;
border: 1px solid var(--line);
border-radius: 999px;
background: var(--ok-bg);
color: var(--ok-ink); /* 10.6:1 on --ok-bg */
font-family: var(--mono);
font-size: 0.68rem;
text-transform: none;
}
/* The bounded scroll panel (PLAN §7.1: a bounded panel, never a page-long
paste). CODE scrolls sideways and never wraps (phase 116 locked A1 —
wrapping breaks code readability), so overflow-wrap is pinned back to
normal against the .bubble's `overflow-wrap: anywhere` (the phase-165
prose/code split: prose wraps, code scrolls). min-width: 0 keeps the
long-line escape hatch that every other scroller in the app carries. */
.verbatim-body {
max-height: min(40rem, 70vh);
overflow: auto;
min-width: 0;
margin: 0;
padding: 0.5rem 0.75rem;
background: var(--surface);
color: var(--ink); /* 15.3:1 on --surface */
font-family: var(--mono);
font-size: 0.85rem;
line-height: 1.5;
white-space: pre;
tab-size: 2;
overflow-wrap: normal;
}
/* No scrollbar-color here ON PURPOSE: phase 116 scoped the themed
scrollbar to `.doc-modal .doc-raw` alone (pinned by
tests/unit/test_prose_word_wrap.py — "no global scrollbar restyle"), and
the chat's other scrollers (.bubble pre, .md-table-wrap) keep the
browser-native bar. The verbatim panel joins THOSE, not the modal. */
/* The gutter: the SERVER's line numbers (the phase-131 coordinate system —
the same 1-based lines `read` and `grep` report), never the model's
claim. `user-select: none` is the load-bearing rule: a reader who
selects the text — or hits Copy on a panel whose selection the browser
picks up — never drags line numbers into the clipboard. The digits are
rendered as list-item TEXT (list-style is off) so the rule behaves the
same in every engine, tabular-nums keeps the column straight, and the
gutter sticks to the left edge while the code scrolls under it. */
.verbatim-lines {
list-style: none;
position: sticky;
left: 0;
flex: 0 0 auto;
min-width: 2.5rem;
background: var(--surface); /* opaque: scrolled code never shows THROUGH
the gutter (the .table-wrap thead rule) */
margin: 0;
padding: 0 0.6rem 0 0;
text-align: right;
color: var(--ink-soft); /* 8.2:1 on --surface — a gutter is still text */
font-variant-numeric: tabular-nums;
user-select: none;
-webkit-user-select: none;
}
.verbatim-lines li {
user-select: none;
-webkit-user-select: none;
}
/* Neutralize the .bubble pre treatment INSIDE the panel: the panel is its
own surface (its own padding, hairline, scroll), so a <pre> child must
not stack a second border/padding on top of it. */
.verbatim pre {
margin: 0;
padding: 0;
border: 0;
border-radius: 0;
background: transparent;
}
/* The streaming/pending state (markdown.js step 0 → the restore step) and
the settled-but-unresolvable state. The pending look is MUTED on purpose
— it is not an answer yet — and it never paints the model's bytes. */
.verbatim-pending {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 0.15rem 0.6rem;
padding: 0.45rem 0.6rem;
background: var(--surface);
}
.verbatim-state {
min-width: 0;
color: var(--ink-soft); /* 8.2:1 on --surface */
font-size: 0.78rem;
}
/* The honest failure state (locked A7): a locator the server could not
resolve says so in a sentence, carries NO badge and NO body, and shows
none of the model's text. --err-line is the app's existing UI boundary
token (decorative, 4.6:1 — the sentence, not the colour, carries the
meaning); the badge rule below is CSS-side enforcement of the claim that
an unresolved panel never badges, whatever DOM reaches it. */
.verbatim-unresolved {
padding: 0.45rem 0.6rem;
background: var(--err-bg);
border-color: var(--err-line);
color: var(--ink-soft); /* 7.9:1 on --err-bg */
font-size: 0.8rem;
}
.verbatim-unresolved .verbatim-badge { display: none; }
/* The two Copy controls — `.verbatim-copy` inside a resolved panel's head,
`.code-block-copy` under an ordinary answer code block (locked A5: every
fenced block gets a Copy button, only a resolved one gets the badge).
The house pill family (mono text, --line hairline, --surface fill,
ink-soft label 8.2:1); the label is the designated clip target
(text-overflow: ellipsis, the phase-156 convention) while the control's
aria-label names the full path and line count. `min-height: 2rem` is the
desktop pill; the ≤640px block below lifts both to the 44px touch floor,
and the global :focus-visible ring is restated with the SAME values
(explicit here because the panel clips with overflow: hidden). */
.code-block {
position: relative;
min-width: 0;
margin: 0.4rem 0;
}
.code-block pre { margin: 0; }
.code-block-copy,
.verbatim-copy {
display: inline-flex;
align-items: center;
gap: 0.3rem;
max-width: 100%;
min-width: 0;
min-height: 2rem;
padding: 0.25rem 0.65rem;
border: 1px solid var(--line);
border-radius: 999px;
background: var(--surface);
color: var(--ink-soft); /* 8.2:1 on --surface */
font-family: var(--mono);
font-size: 0.72rem;
cursor: pointer;
}
.code-block-copy > span,
.verbatim-copy > span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.code-block-copy:hover,
.verbatim-copy:hover { color: var(--ink); border-color: var(--ink-soft); }
.code-block-copy:focus-visible,
.verbatim-copy:focus-visible {
outline: 3px solid var(--brand);
outline-offset: 2px;
}
/* The `.copied` state: the TEXT changes ("Copy" → "Copied", task 07 owns
the swap) and this rule SUPPORTS it with the --ok family — colour is
never the only signal (WCAG 1.4.1), so a monochrome theme still reads
"Copied". */
.code-block-copy.copied,
.verbatim-copy.copied {
color: var(--ok-ink); /* 10.0:1 on --surface */
border-color: var(--ok-ink);
background: var(--ok-bg);
}
/* A resolved panel's Copy pill sits at the END of the head row (the caption
already took the flexible space; the auto margin keeps it right even
when the caption clips). */
.verbatim-copy { margin-left: auto; flex: 0 0 auto; }
.code-block-copy { margin: 0.25rem 0 0; }
/* What task 07's REAL DOM needs on top of task 06's contract — palette
variables only, no new literal, nothing animated. The body is a ROW of
(gutter, text): the gutter's `flex: 0 0 auto` + `position: sticky;
left: 0` above are written for exactly this, and the text takes its own
width so a long line scrolls UNDER the sticky gutter instead of wrapping
(phase 116 locked A1). */
.verbatim-body { display: flex; align-items: flex-start; }
.verbatim-text { flex: 0 0 auto; }
/* The panel's link to the document viewer (the chat page opens the modal,
the shared page navigates — one affordance, one look). It is a TEXT link
(underlined, not colour-only — WCAG 1.4.1) in the mono family of the
caption it sits beside, and it restates the app-wide focus ring with the
SAME values because the panel clips with `overflow: hidden`. */
.verbatim-open {
flex: 0 0 auto;
color: var(--ink-soft); /* 8.2:1 on --surface */
font-family: var(--mono);
font-size: 0.72rem;
text-decoration: underline;
text-underline-offset: 2px;
}
.verbatim-open:hover { color: var(--ink); }
.verbatim-open:focus-visible {
outline: 3px solid var(--brand);
outline-offset: 2px;
}
/* The truncation line of a capped block: one muted sentence naming that the
rest lives in the document, plus the same link. It sits OUTSIDE the scroll
body, so it stays readable without scrolling the bytes. */
.verbatim-more {
padding: 0.4rem 0.6rem;
border-top: 1px solid var(--line);
background: var(--surface);
color: var(--ink-soft); /* 8.2:1 on --surface */
font-size: 0.75rem;
}
/* ---------- Verbatim panels (phase 182, task 06) — END ---------- */
.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 <details>/<summary> — 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 161 (owner TODO 2026-10-05, D3): the ONE reasoning bubble —
the ENTIRE per-run timeline (phase-158 thinking + tool-call blocks,
arrival order) nests INSIDE this details.reasoning container instead
of stacking above the answer, so the live turn condenses to ONE
bounded window (the .reasoning-body, 320px) and the runs no longer
push the answer off-screen. Family treatment of details.thinking
(phase 17): the SAME surface/line/brand-soft/radius tokens, so the
two disclosures read as one block family. The summary is a native
focusable toggle — >=44px target, the house 3px :focus-visible ring,
the webkit marker suppressed, a CSS chevron ▸ that rotates 90° when
open (the transition stills under prefers-reduced-motion — see the
reduced-motion block below). Contrast: --brand-ink on --surface
≈8.7:1, --ink-soft on --surface ≈6.9:1 (the status span) — both AA.
overflow: hidden clips the scrollable body to the corner radius. */
details.reasoning {
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;
}
summary.reasoning-summary {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
min-height: 44px; /* WCAG 2.1 AA touch target (the family pin) */
color: var(--brand-ink); /* 8.7:1 on --surface */
font-size: 0.9rem;
cursor: pointer;
list-style: none;
}
summary.reasoning-summary::-webkit-details-marker { display: none; }
/* CSS chevron: ▸ rotates 90° when open (the details.thinking
convention; the transition stills under prefers-reduced-motion —
see the reduced-motion block below). */
summary.reasoning-summary::before {
content: "▸";
display: inline-block;
transition: transform 0.15s ease;
}
details.reasoning[open] summary.reasoning-summary::before {
transform: rotate(90deg);
}
summary.reasoning-summary:focus-visible {
outline: 3px solid var(--brand);
outline-offset: 2px;
}
/* D6: the live status suffix ("Thinking…" / "Calling <name>…" /
"N steps") rides the summary row AFTER the "Reasoning" literal —
the flex row lays it out after the chevron + literal; ink-soft on
surface ≈6.9:1 (AA); text + color, never color alone (B5). */
.reasoning-status {
color: var(--ink-soft); /* 6.9:1 on --surface */
font-size: 0.8rem;
line-height: 1.4;
margin-left: 0.25rem;
}
/* D3: the ONE bounded live window — 320px (the phase-17 window
convention), user-scrollable; app.js's pinned-tail follow
(REASONING_NEAR_BOTTOM_PX) keeps the live tail in view while the
user stays near the bottom — scrolling up pauses it, returning to
the bottom resumes it. The per-run blocks nested inside are
de-bordered below so THIS is the single scroll surface (no nested
boxes). */
.reasoning-body {
padding: 0 0.75rem 0.75rem;
max-height: 320px;
overflow-y: auto;
}
/* D3 inner-run compacting + the D5 refinement (owner 2026-10-06):
the per-run blocks nest as plain divs inside the container —
NEVER collapsible (the container is the ONE disclosure; a nested
fold inside the fold was the defect). The blocks are distinct
from each other by LABEL + RAIL: the thinking run carries the
family's brand-soft rail + the brand-ink "Thinking" label; the
tool run carries the neutral --line rail + the accent-ink
"Tool call(s) (N)" label (the phase-117 accent pairing). The
inner 320px windows are disabled (max-height: none) so the
container's window is the single scroll surface (the phase-17
per-block follow in app.js no-ops on the now-unscrollable
.thinking-text). */
details.reasoning .thinking {
border-left: 2px solid var(--brand-soft);
padding-left: 0.75rem;
margin: 0.5rem 0;
}
/* The static (non-interactive) "Thinking" label — brand-ink on the
surface ≈8.7:1 (the family's summary pairing, AA); no cursor, no
focus target (it is not a control — the container's summary is).
*/
details.reasoning .thinking .thinking-summary {
display: block;
color: var(--brand-ink);
font-size: 0.8rem;
line-height: 1.4;
margin-bottom: 0.15rem;
}
details.reasoning .thinking .thinking-text {
padding: 0 0 0.75rem;
color: var(--ink-soft); /* 6.9:1 on --surface — the scratchpad pairing */
font-size: 0.875rem;
line-height: 1.55;
max-height: none;
overflow-y: visible;
}
details.reasoning .thinking .thinking-text p,
details.reasoning .thinking .thinking-text ul { margin: 0 0 0.5rem; }
/* The tool run — the neutral rail (distinct from the thinking run's
brand-soft rail) + the phase-117 accent-ink label (≈10.4:1 on the
surface, AA). The .tool-calls rows keep the exact phase-37/117
treatment below (the shared rule — no new bytes). */
details.reasoning .tool-calls-disclosure {
border-left: 2px solid var(--line);
padding-left: 0.75rem;
margin: 0.5rem 0;
}
/* Phase 117: the tool-call record folds into a single disclosure line,
reusing the Thinking block's native <details> 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 {
color: var(--accent-ink);
font-size: 0.8rem;
line-height: 1.4;
padding: 0.15rem 0;
}
/* The legacy (out-of-container) disclosure's summary is a native
focusable toggle — the interactive treatment is scoped to the
<summary> element: the in-container run's label is a plain span
(the D5 refinement — the container is the ONE disclosure, so the
run's label is not a control). */
summary.tool-calls-summary {
cursor: pointer;
list-style: none; /* the native marker is redundant with the count text */
}
summary.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). Scoped
to the <summary> element: the in-container run's label (a span with
the same class) carries no chevron. */
summary.tool-calls-summary::before {
content: "▸";
display: inline-block;
margin-right: 0.5rem;
transition: transform 0.15s ease;
}
details.tool-calls-disclosure[open] summary.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 <figure> — 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 160 (task 04, TODO L4): the "Continue researching" button —
the one-click continuation in the actions row
(.msg-meta.msg-actions — phase 130) of a cap-reached brain bubble
(the done frame's budget_exhausted, task 03). The exact visual
family of .retry-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 forward glyph rides currentColor. No
per-button auto margin (phase 130 — the row's justify right-aligns
the cluster). No animation of its own, so the
prefers-reduced-motion blocks need nothing for it (the family's
parity — .retry-btn / .edit-btn have none either). Contrast: ink-soft on --bg ~8.6:1, ink
~15:1, hover ink on --brand-soft ~15.7:1 — all AA. */
.continue-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;
}
.continue-btn svg { width: 14px; height: 14px; display: block; }
.continue-btn:hover { background: var(--brand-soft); color: var(--ink); }
/* Phase 159 (task 01, TODO L3): the "Edit" button — the
* trim-and-re-ask action in the .msg-meta.msg-actions row (phase 130)
* of the NEWEST user bubble (markLastEditable — the phase-49
* single-target discipline, one `who` over). The exact visual family
* of .retry-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 pencil 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. */
.edit-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;
}
.edit-btn svg { width: 14px; height: 14px; display: block; }
.edit-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;
}
/* Phase 159 (task 01, TODO L3): the inline EDIT form in the newest
* user bubble. Phase 170 (A1, owner-locked 2026-10-10): the form
* renders INSIDE the .bubble itself — it inherits the bubble's own
* --brand fill, border, and padding, so it keeps the bubble's
* dimensions by construction (no shrunken sibling box, no
* display:none swap — .is-editing is the state class, the bubble
* stays VISIBLE). Label + action text are the bubble's --bg ink on
* the --brand fill: 5.2:1 (the .msg.user .bubble text pairing,
* documented in :root — B5: the state is text + color, never color
* alone). The textarea keeps its own --surface fill + --ink text
* (a readable edit field on the --brand bubble, 15.3:1) at
* width:100% — the "not tiny" fix. Both buttons keep the ≥44px
* floor; the save pill edges at --bg (5.2:1 on the --brand bubble,
* ≥3:1 UI boundary — a --brand pill on the --brand bubble would be
* invisible); the textarea's maxlength = the 4000 cap,
* single-sourced from MAX_QUESTION_CHARS in app.js. Cancel / Escape
* restore the rendered markdown + the attachment and return focus
* to the toggle (the openTuneForm cancel rule). Save rejection
* (role=alert) keeps the form open (the note is never lost — the
* openTuneForm failure way). */
.edit-form {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.edit-form label {
font-size: 0.85rem;
font-weight: 600;
color: var(--bg); /* --bg ink on the --brand fill: 5.2:1 (the user-bubble pairing) */
}
.edit-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;
width: 100%;
resize: vertical;
min-height: 2.6rem;
}
.edit-form-actions { display: flex; gap: 0.5rem; }
.edit-save {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 44px;
padding: 0.4rem 1.1rem;
border: 1px solid var(--bg);
border-radius: var(--radius-sm);
background: var(--brand);
color: var(--bg);
font: inherit;
font-weight: 700;
cursor: pointer;
}
.edit-save:hover:not(:disabled) { background: var(--brand-hover); }
.edit-save:disabled { opacity: 0.6; cursor: wait; }
.edit-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(--bg); /* was --ink-soft: 1.6:1 on --brand (fails AA); --bg ink: 5.2:1 */
font: inherit;
font-weight: 600;
cursor: pointer;
}
.edit-cancel:hover { background: var(--brand-soft); color: var(--brand-ink); }
/* Inline save rejection (role=alert): the --err-* pair is
* self-contained — text vs its own --err-bg is 9.3:1 (≥4.5:1), so
* it holds on the --brand bubble without a scoped variant; the box
* edge on --brand holds 4.9:1 (≥3:1 UI boundary). Form is kept. */
.edit-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 <main> (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
(scoped to the <summary> — the in-container span label has no
chevron to still). */
summary.tool-calls-summary::before { transition: none; }
/* Phase 161 reasoning bubble: the family chevron, the family still. */
summary.reasoning-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… <file> (n/m)" / "Importing
<file> (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 <body>.
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 <html> (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
<caption> + 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.
Phase 156 (task 01, owner-locked A1): the card is ALSO the
vertical scroll region — max-height: min(70vh, 64rem) bounds it
(a 15-row table scrolls, a 3-row one fits without a scrollbar)
and overflow: auto (superseding the phase-86 overflow-x pin —
the tables' min-width still triggers the in-card horizontal
scroll at narrow widths) makes the card the sticky th's
scrolling ancestor; an unbounded card never scrolls vertically,
so a sticky header in it would scroll away with the page. */
.table-wrap {
position: relative;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: var(--shadow);
overflow: auto;
max-height: min(70vh, 64rem);
}
/* Phase 156 (task 01): the ONE sticky-header rule for every
catalogue table inside a .table-wrap card (the RAG .docs-table,
the .git-sources-table, the .history-table, the .tokens-table —
the per-table th rules keep the text styling only). top: 0 pins
the header row to the card top while the card's own vertical
scroll moves; z-index: 1 keeps the pinned th above the scrolled
cells. OPAQUE background: --brand-soft is a solid hex (no alpha),
so rows never show through the pinned header (B3: no new palette
variable; a themed page's #bor-theme override of the token
handles the color — same behavior, B4). */
.table-wrap table thead th {
position: sticky;
top: 0;
z-index: 1;
background: var(--brand-soft);
}
/* Phase 156 (task 02, owner-locked A3): the ellipsis hint on the
catalogue tables' CLIPPED action labels. The class sits on the
LABEL SPAN, never the cell (the docs-table cell padding is
untouched). The five families: the Sources "Hidden" checkbox
text + "Ignore paths" + "Remove" (git-sources.js makeRow), the
RAG folder table's "Edit" (sources.js makeDescCell), the
History "Copy" / "Unshare" / "Create link" / "Delete" (history.js).
max-width: 12rem is GENEROUS — every one of the five labels
("Edit" ~2.4rem … "Ignore paths" ~6.3rem at 0.78–0.82rem)
fits it, so a cell that FITS shows its full text (the clamp
engages only in the CLIPPED case — the audit's ~1,009px
rightmost columns), where the label shows "…" instead of the
card edge's hard cut. Mechanism: the block container's
overflow hidden + text-overflow ellipsis + nowrap is the
house .sync-label shrinkable-item pattern; the SPAN's
min-width: 0 lets the flex item shrink below its text width —
but a flex item shrinks only when its FLEX PARENT is
constrained, so the hosting controls carry the supporting width
rules (the max-width: 100% + min-width: 0 added to each block
below): without them the inline-flex buttons keep their content
size, overflow the squeezed cell, and the card edge hard-cuts
the label again (the pre-phase defect). The FULL text stays
reachable — every one of these controls' aria-label names the
row's identity (the source's URL/path — the phase-89/105 idiom;
the chat title — the phase-66 idiom; the source/folder name —
the phase-97 idiom), so the clip is visual only. B5 idiom
intact: the labels are text (text + color, never color alone). */
.cell-clip {
display: block;
min-width: 0;
max-width: 12rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.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 {
color: var(--brand-ink);
font-size: 0.82rem;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.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;
}
/* Phase 167 (task 04, D4): the lazy drill's ONE in-flight row — the
role="status" loading note and the role="alert" failure note (+ the
existing .retry-btn pill) shown in the folders panel of the level
the user is LEAVING: the level never blanks and the breadcrumb never
lies (PLAN §7.4). Tokens only — ink-soft on the wrap's --surface
(the .kb-level p pair, 5.1:1 AA); no animation beyond the house
idiom (the Retry button's :focus-visible is the global rule). */
.kb-level-loading { color: var(--ink-soft); }
/* 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;
/* Phase 156 (task 02, A3): the width support for the label span's
clamp — the flex item is clamped to the cell + shrinkable
(min-width: 0), so a squeezed Description cell ellipsizes the
"Edit" label instead of overflowing the card edge. The level
block's use (the non-flex .kb-level-body) is unaffected: its
natural width never reaches the clamp. */
max-width: 100%;
min-width: 0;
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 <p>. */
.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;
}
/* Phase 174 (owner bug report 2026-10-21): the shell's gap only
separates the gate from this wrapper — every visible card
(page-head, the load-error alert, the env note, the two form
cards, the table card) is a child of THIS div, which was a
plain block, so the cards stacked flush (1px borders touching).
The phase-171 #settings-content idiom: the wrapper itself is
the rhythm column, reusing the shell's own 1.25rem token — no
new spacing value. The hidden children (#git-sources-load-error,
#git-sources-env-note — the env note's CLASS is the singular
git-source-env-note) keep the UA [hidden] display:none inside
a flex parent, so git-sources.js's reveal lifecycle is
untouched; a revealed one joins the rhythm. */
#git-sources-content {
display: flex;
flex-direction: column;
gap: 1.25rem;
}
/* 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 <p>; the
bottom margin is the gap to the copy line (which has no top margin).
Git rows only — git-sources.js hides the group for local rows. */
#ignore-editor-branch {
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);
}
.ignore-editor-branch-hint {
margin: 0.35rem 0 1rem;
color: var(--ink-soft);
font-size: 0.8rem;
}
/* The in-dialog failure line (role=alert): the err pair (err-ink on
err-bg 9.3:1, the err-line border) — the .remove-confirm-error
language. */
.ignore-editor-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);
}
.ignore-editor-actions {
display: flex;
justify-content: flex-end;
gap: 0.6rem;
margin-top: 1.1rem;
}
/* The two dialog buttons: >=44px targets, the house 3px :focus-visible
via the global outline rule (no local override). */
.ignore-editor-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;
}
.ignore-editor-btn:disabled { opacity: 0.5; cursor: wait; }
/* Cancel — the ghost ink-soft family (5.1:1 on --surface), like the
remove dialog's Cancel; the brand pair on hover (12.4:1 on
brand-soft). */
.ignore-editor-cancel {
border: 1px solid var(--line);
background: transparent;
color: var(--ink-soft);
}
.ignore-editor-cancel:hover:not(:disabled) {
background: var(--brand-soft);
color: var(--brand-ink);
}
/* "Save" — the solid brand family (the .new-chat-btn convention):
--bg text on --brand (5.2:1 — AA); the hover lightens the brand
fill (the .new-chat-btn hover pair). */
.ignore-editor-save {
border: 0;
background: var(--brand);
color: var(--bg);
}
.ignore-editor-save:hover:not(:disabled) { background: var(--brand-hover); }
/* The list: the Sources page's table pattern — full width in the
72rem frame. The URL column never wraps or ellipsizes: long
URLs, credentials included, scroll the wrapper instead of
truncating (the .git-sources-table 560px min-width keeps the
in-wrapper horizontal scroll). Phase 156 (task 01): the id
card's duplicate background/border/radius/shadow/overflow
declarations were REMOVED — the wrapper carries
class="table-wrap", so it inherits the base card + the
bounded-panel max-height + the sticky rule (the one-rule
contract: no divergent unbounded override). */
.git-sources-table {
width: 100%;
border-collapse: collapse;
min-width: 560px;
font-size: 0.93rem;
}
.git-sources-table th, .git-sources-table td {
text-align: left;
padding: 0.7rem 1rem;
border-bottom: 1px solid var(--line);
white-space: nowrap;
}
.git-sources-table th {
color: var(--brand-ink);
font-size: 0.82rem;
text-transform: uppercase;
letter-spacing: 0.04em;
}
/* Location cell: mono at the Sources-table size; the <code> is plain
(no chip background — the cell IS the mono readout). Phase 38:
leads with the kind badge (Git/Local) — the kinds are told apart by
TEXT as much as color (WCAG 1.4.1), and both badge pairs are AA:
brand-ink on brand-soft 6.9:1 (Git), ok-ink on ok-bg 10.6:1
(Local). */
.git-sources-table td.git-source-url-cell { font-family: var(--mono); font-size: 0.82rem; }
.git-sources-table td.git-source-url-cell code { font-family: inherit; }
.git-source-kind {
display: inline-block;
margin-right: 0.55rem;
padding: 0.08rem 0.5rem;
border-radius: 999px;
font-family: var(--font);
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
vertical-align: 0.06em;
white-space: nowrap;
}
.git-source-kind.is-git { color: var(--brand-ink); background: var(--brand-soft); }
.git-source-kind.is-local { color: var(--ok-ink); background: var(--ok-bg); }
.git-sources-table tbody tr:hover { background: var(--bg); }
.git-sources-table tbody tr:last-child td { border-bottom: 0; }
/* Per-row Remove: the tuning row-action language (icon + label,
>=44px) with the Delete hover pair (err 9.1:1). */
.git-source-remove {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.35rem;
/* Phase 156 (task 02, A3): the width support for the "Remove"
label span's clamp (same rationale as .git-source-hidden
above). */
max-width: 100%;
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;
}
.git-source-remove svg { width: 14px; height: 14px; display: block; }
.git-source-remove:hover:not(:disabled) { background: var(--err-bg); color: var(--err-ink); border-color: var(--err-line); }
.git-source-remove:disabled { opacity: 0.5; cursor: wait; }
/* Phase 89: the per-row "Ignore paths" trigger (makeRow — left of
Remove): the .git-source-remove idiom (same size/spacing, >=44px
target) in a NEUTRAL secondary fill — it must read distinct from
the destructive Remove (text label included, never icon-only; the
hover takes the brand-soft pair, 12.4:1 — Remove hovers to the err
pair, 9.3:1). */
.git-source-ignore {
display: inline-flex;
align-items: center;
justify-content: center;
/* Phase 156 (task 02, A3): the width support for the "Ignore
paths" label span's clamp (same rationale as .git-source-hidden
above). */
max-width: 100%;
min-height: 44px;
min-width: 44px;
flex: 0 0 auto;
padding: 0.35rem 0.7rem;
margin-right: 0.4rem;
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;
}
.git-source-ignore:hover:not(:disabled) {
background: var(--brand-soft);
color: var(--brand-ink);
}
/* Phase 89: the "N ignored" count tag — next to the location <code>
in the Source cell (TEXT + a distinct background, never color
alone — WCAG 1.4.1): --ink on --bg (16.7:1) inside the --surface
card; the line border keeps the chip legible when the row hover
swaps the cell onto --bg too. */
.git-source-ignore-count {
display: inline-block;
margin-left: 0.55rem;
padding: 0.08rem 0.5rem;
border: 1px solid var(--line);
border-radius: 999px;
font-size: 0.72rem;
font-weight: 600;
color: var(--ink);
background: var(--bg);
white-space: nowrap;
}
/* Phase 105 (A5): the per-row "Hidden" checkbox — the actions
cell, LEFT of the "Ignore paths" button (JS builds it,
git-sources.js makeRow). A native labeled checkbox: the
visible "Hidden" text + the checkbox's own aria-label
(full source value). ~44px hit height matches the action
buttons; :focus-visible via the GLOBAL rule (L146) — no
per-control ring needed. */
.git-source-hidden {
display: inline-flex;
align-items: center;
gap: 0.4rem;
/* Phase 156 (task 02, A3): the width support for the "Hidden"
label span's clamp (the label is an inline-flex child of the
cell — clamped to the cell so the span can ellipsize in a
squeezed column instead of overflowing the card edge). */
max-width: 100%;
height: 44px;
padding: 0 0.5rem;
font-size: 0.8rem;
color: var(--ink);
cursor: pointer;
user-select: none;
white-space: nowrap;
}
/* --brand checkbox fill: the native check renders in --bg on
--brand — VERIFIED (phase 105): built-in dark theme
#0f0a0a on #f43f5e = 5.2:1 (the house brand pairing;
measured 5.35:1 — WCAG AA ≥4.5:1); every theme-tab-saved
palette is gated by the tab's eight-pair contrast check
(theme.js PAIRS — "bg" on "brand" at AA_MIN 4.5), so the
checked-state ratio stays AA in all themes. */
.git-source-hidden input[type="checkbox"] {
width: 1.05rem;
height: 1.05rem;
margin: 0;
accent-color: var(--brand);
cursor: pointer;
}
.git-source-hidden:disabled { opacity: 0.5; cursor: wait; }
/* Phase 105 (A5): the "hidden on" state tag — a copy of the
.git-source-ignore-count idiom (TEXT + background, never
color alone — WCAG 1.4.1: --ink on --bg 16.7:1). Phase 135 (A6):
the "branch: <name>" state tag joins the same rule — stored git
rows with a branch only, the text IS the signal.
Phase 191 (task 03): the TWO new tags of the name/label/tags row join
the SAME rule — .source-label-state ("named", beside the mono identity)
and .source-label-tag (one chip of the Tags token input). That is the
reuse the phase asks for: the chips are the house tag, not a new
widget, so no tag-input styling is added anywhere below. */
.git-source-hidden-count,
.git-source-branch-count,
.source-label-state,
.source-label-tag {
display: inline-block;
margin-left: 0.55rem;
padding: 0.08rem 0.5rem;
border: 1px solid var(--line);
border-radius: 999px;
font-size: 0.72rem;
font-weight: 600;
color: var(--ink);
background: var(--bg);
white-space: nowrap;
}
/* Env-fallback rows carry no Remove (nothing is stored to remove) —
the tag says where the row comes from (brand pair, 6.9:1). */
.git-source-env-tag {
color: var(--brand-ink);
background: var(--brand-soft);
border-radius: 999px;
padding: 0.2rem 0.6rem;
font-size: 0.78rem;
font-weight: 600;
}
/* Empty state: the tuning page's quiet centered line at full table
width — no stored rows AND no env fallback to show. Phase 191 (task
03): the name/label/tags list shares the line verbatim (its own
no-sources case, the same quiet card). */
.git-sources-empty,
.source-labels-empty {
margin: 0;
padding: 1.4rem 1rem;
text-align: center;
color: var(--ink-soft);
font-style: italic;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: var(--shadow);
}
/* ---------- Source names, labels and tags (phase 191, task 03) ----------
The Sources page's SECOND list: every source the knowledge base knows
(git, local, upload — the union the phase-191 catalogue GET returns),
each with the optional name / label / tags the model is told about
(D1: an ALIAS — the mono identity cell is read-only text, nothing here
renames a document, a citation or a locator).
Nothing new is invented: the card is the phase-156 .table-wrap (bounded
panel + sticky header, inherited by the class alone), the identity cell
is the .git-source-url-cell mono, the state tag and the tag CHIPS are
the phase-89 tag rule above, the field boxes are the .ignore-editor-
token / #ignore-editor-branch language at the 44px floor, Save is the
page's brand pill, Reset and the row's toggle are the neutral
.git-source-ignore language, and the dirty mark is the phase-168
the phase-168 dirty-row stripe. PALETTE VARIABLES ONLY — no new hue
(every pair is a pair this page already measures: ink 15.3:1 and
ink-soft 8.2:1 on --surface, brand-ink on brand-soft 12.4:1, the err
pair 9.3:1, --bg ink on --brand 5.2:1); :focus-visible rides the global
3px outline rule; no CDN, no blur. The editor expands UNDER its own row
(one row open at a time keeps a 27-source list scannable) and its three
fields stack in a single column below 60rem instead of pushing the card
into a horizontal scroll at 375px. */
.source-labels {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.source-labels-title {
margin: 0;
font-size: 1.25rem;
line-height: 1.3;
color: var(--ink);
}
/* The alias note (the contract in the owner's words) — the page-sub
language, ink-soft on the page canvas (8.2:1 on --surface). */
.source-labels-sub {
margin: 0;
max-width: 60rem;
color: var(--ink-soft);
font-size: 0.88rem;
}
/* The list's own load failure (role=alert) — the .git-source-error
pair (err-ink on err-bg 9.3:1). */
.source-labels-error {
margin: 0;
padding: 0.45rem 0.8rem;
background: var(--err-bg);
color: var(--err-ink);
border: 1px solid var(--err-line);
border-radius: var(--radius-sm);
font-size: 0.85rem;
font-weight: 600;
}
/* No min-width on purpose: the editor column has to SHRINK (the fields
stack), so this table never scrolls the card sideways at 375px — the
panel still bounds vertically (.table-wrap). */
.source-labels-table {
width: 100%;
border-collapse: collapse;
font-size: 0.93rem;
}
.source-labels-table th, .source-labels-table td {
text-align: left;
vertical-align: top;
padding: 0.7rem 1rem;
border-bottom: 1px solid var(--line);
}
.source-labels-table th {
color: var(--brand-ink);
font-size: 0.82rem;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.source-labels-table tbody tr:last-child td { border-bottom: 0; }
/* The identity cell: the derived name, mono like the sources table's
location cell, wrapping (overflow-wrap) instead of widening the card —
it is text, never an input (D1/A1). */
.source-labels-table td.source-label-id-cell {
font-family: var(--mono);
font-size: 0.82rem;
overflow-wrap: anywhere;
}
.source-labels-table td.source-label-id-cell code { font-family: inherit; }
/* The dirty row mark — the phase-168 dirty-row stripe (the brand edge is
beside the row's own "unsaved changes" TEXT, so the state is never color
alone — WCAG 1.4.1). The literal is NOT written in this comment: the
phase-168 pin (test_settings_frontend_view.py) regex-matches that
selector to its own rule and a comment mention would steal the match. */
.source-label-row.is-dirty > td:first-child { box-shadow: inset 3px 0 0 var(--brand); }
/* The collapsed row's read-out of the stored label (name · label · tags).
textContent-only (a label is owner text — the XSS contract). */
.source-label-summary {
margin: 0 0 0.45rem;
color: var(--ink-soft);
font-size: 0.85rem;
overflow-wrap: anywhere;
}
/* The row's expand trigger — the .git-source-ignore language (neutral
secondary fill, >=44px, hovers to the brand pair 12.4:1). */
.source-label-toggle {
display: inline-flex;
align-items: center;
justify-content: center;
max-width: 100%;
min-height: 44px;
min-width: 44px;
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;
}
.source-label-toggle:hover:not(:disabled) {
background: var(--brand-soft);
color: var(--brand-ink);
}
/* The expanded editor panel — the row's own form card. The fields go in
one column narrow, three across from 60rem (the house grid). */
.source-label-editor {
display: grid;
gap: 0.9rem;
margin-top: 0.75rem;
padding: 0.9rem;
background: var(--bg);
border: 1px solid var(--line);
border-radius: var(--radius-sm);
}
@media (min-width: 60rem) {
.source-label-fields { grid-template-columns: repeat(3, minmax(0, 1fr)); }
}
.source-label-fields {
display: grid;
gap: 0.75rem;
}
.source-label-field {
display: block;
min-width: 0;
}
/* The field label — the .ignore-editor-label language (ink-soft 8.2:1 on
the panel, 600 weight, the hint span the .field-hint secondary read). */
.source-label-field-label {
display: block;
margin-bottom: 0.35rem;
color: var(--ink-soft);
font-size: 0.88rem;
font-weight: 600;
}
/* The field box — the .ignore-editor-token / #ignore-editor-branch box
language VERBATIM (full width, --bg… panel fill, the line border, mono)
at the 44px touch floor; the global [hidden] rule and the global
:focus-visible outline cover the rest. */
.source-label-input {
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(--surface);
border: 1px solid var(--line);
border-radius: var(--radius-sm);
}
/* The Tags token input's chip list — the LAYOUT only (the chips
themselves are the shared tag rule above, so the widget adds no tag
styling). list-style is dropped, so role="list" stays in the markup
(the VoiceOver list-semantics idiom the house uses). */
.source-label-tags {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
margin: 0 0 0.45rem;
padding: 0;
list-style: none;
}
.source-label-tags .source-label-tag { margin-left: 0; }
.source-label-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.6rem;
}
/* Save — the page's brand pill at the row scale (--bg ink on --brand,
5.2:1); disabled until the row is dirty, and again while the PUT is
out (§7.4 — the row's own in-flight state). */
.source-label-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;
}
.source-label-save:hover:not(:disabled) { background: var(--brand-hover); }
.source-label-save:disabled { opacity: 0.6; cursor: default; }
/* Reset — the neutral secondary button (the .git-source-ignore language):
it clears a STORED label, it is not the destructive row Remove. */
.source-label-reset {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 44px;
padding: 0.35rem 0.9rem;
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;
cursor: pointer;
}
.source-label-reset:hover:not(:disabled) {
background: var(--brand-soft);
color: var(--brand-ink);
}
.source-label-reset:disabled { opacity: 0.5; cursor: wait; }
/* The §7.4 save-state line (role=status): "Saving…" → "saved" / the
one-line error note. The hint (unsaved) is the muted italic; the error
takes the err pair (9.3:1) so the note reads as a failure, not as a
label — TEXT carries it either way, never color alone. */
.source-label-status {
color: var(--ink-soft);
font-size: 0.82rem;
}
.source-label-status.is-hint { font-style: italic; }
.source-label-status.is-error {
padding: 0.35rem 0.6rem;
color: var(--err-ink);
background: var(--err-bg);
border: 1px solid var(--err-line);
border-radius: var(--radius-sm);
font-weight: 600;
}
/* ---------- History refresh button (phase 77 task 03, TODO.md L3) ----------
The #view-history page-head is a flex row: the title block left, the
actions slot right (the row wraps below 640px). Scoped to
#view-history — the other four views' page-heads are untouched.
The button reuses the .new-chat-btn visual language: solid brand
pill, --bg text on --brand (5.2:1, WCAG AA >=4.5:1), borderless,
>=44px target, hover lightens the brand fill, focus-visible via the
global 3px rule. The phase-46 auth-link convention: the label is
visible >=640px, the glyph is the whole control below (the button's
aria-label keeps the accessible name in both). */
#view-history .page-head {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
justify-content: space-between;
gap: 0.75rem 1.5rem;
}
#view-history .page-head .page-head-title { min-width: 0; }
.history-refresh, .history-pager-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;
flex-shrink: 0;
}
.history-refresh:hover, .history-pager-btn:hover { background: var(--brand-hover); color: var(--bg); }
.history-refresh:disabled, .history-pager-btn:disabled { opacity: 0.6; cursor: wait; }
/* The refresh glyph is hidden on desktop (the label carries the
pill); below 640px it joins the visible label in the full-width
pill (the <=640 block — the icon-only squeeze is gone). The
phase-175 pager buttons join the rule (the selector union — the
same glyph language: visible label at every width, the glyph is
the mobile companion). */
.history-refresh svg, .history-pager-btn svg { width: 16px; height: 16px; display: none; }
/* ---------- History pager (phase 175, task 02 — TODO.md L3) ----------
"Need pagination on the history page - it gets long fast" (owner
2026-10-10). Root cause: the saved-chat list used to fetch and
render EVERY row; the server now ships a 25-row page + the full
count, and this row is the walk control — Prev / "Page N of M" /
Next, sitting UNDER the table card (index.html, phase 175).
Token reuse (B3): the buttons join the .history-refresh pill
through the selector unions above (the solid brand pill, the
:disabled opacity, the svg sizing — NO new palette variable), and
the row's gap is the page's OWN 0.75rem (the #view-history
.page-head row gap — no new spacing token). Hidden by default
(the markup ships `hidden`): history.js's updatePager() reveals
the row only while `total > PAGE_SIZE` (A4 — no dead "Page 1 of
1" control; it disappears with the empty state). The status span
needs no rule beyond the inherited text. Below 640px the row
stacks (the .settings-actions idiom) and the pills go full width
(the .history-refresh mobile idiom — the ≤640px block below). */
.history-pager {
display: flex;
align-items: center;
gap: 0.75rem;
}
/* ---------- History page (phase 50) ----------
/history.html: the admin-only saved-chats list (task 04). The
FULL-WIDTH table in the 72rem frame (AGENTS.md rule 5 — no skinny
single-column list), the same table language as the Sources page
(the sources-table family: --line hairlines, the brand-soft-tinted
thead, row hover, the scrollable .table-wrap). Every pair reuses
the Phase-08 AA palette: brand-ink on brand-soft 6.9:1, ink-soft
>=6.9:1, err 9.1:1. :focus-visible via the global 3px outline
rule. No CDN, system fonts. */
.history-shell {
display: flex;
flex-direction: column;
gap: 1.25rem;
flex: 1;
}
/* Action feedback line (role=status): the .sync-progress-text mono /
soft-ink language — ink-soft on surface (>=4.5:1), small mono; the
min-height holds the layout so a delete's line never reflows the
table. */
.history-status {
display: block;
min-height: 1.2em;
color: var(--ink-soft);
font-family: var(--mono);
font-size: 0.8rem;
padding-block: 0.25rem;
}
/* The table is FULL-WIDTH (AGENTS.md rule 5): width 100% inside the
standard .container; the .table-wrap card + its horizontal scroll
cover narrow widths (the phase-07 responsive contract). */
.history-table {
width: 100%;
border-collapse: collapse;
min-width: 640px;
font-size: 0.93rem;
}
.history-table th, .history-table td {
text-align: left;
padding: 0.7rem 1rem;
border-bottom: 1px solid var(--line);
vertical-align: middle;
}
.history-table th {
color: var(--brand-ink);
font-size: 0.82rem;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.history-table tbody tr:hover { background: var(--bg); }
.history-table tbody tr:last-child td { border-bottom: 0; }
/* Title cell: the Open link — the accent link (brand-ink on surface
6.9:1); the column ellipsizes, the full title sits in the title
attribute (on the cell AND the link). */
.history-title-cell {
max-width: 34rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.history-title-link {
color: var(--brand-ink);
font-weight: 600;
text-decoration: none;
}
.history-title-link:hover { text-decoration: underline; }
.history-title-link:focus-visible { outline: 3px solid var(--brand); outline-offset: 2px; }
/* Messages: the mono numeric readout (ink-soft >=6.9:1). */
.history-count-cell { font-family: var(--mono); color: var(--ink-soft); }
/* Updated: locale date+time (ink-soft), the full ISO in the title
attribute (history.js). */
.history-updated-cell { color: var(--ink-soft); white-space: nowrap; }
/* Stale marker (phase 53, task 04): the READ-ONLY staleness badge on
rows saved before the last KB-changing sync (the Regenerate action
lives on the chat-page banner — the marker is a pill, never a
control). The rose family, i.e. the Stop-treatment tokens, so
"stale" reads in the same visual language as the in-flight control:
err-ink on err-bg ≈9.3:1 (≥4.5:1 on --surface too), err-line
border — theme tokens, AA in the palette as a whole. Fresh rows'
em-dash rides the cell's ink-soft (5.1:1 on --surface). */
.history-stale-cell { color: var(--ink-soft); white-space: nowrap; }
.stale-pill {
display: inline-block;
padding: 0.15rem 0.55rem;
border: 1px solid var(--err-line);
border-radius: 999px;
background: var(--err-bg);
color: var(--err-ink);
font-size: 0.75rem;
font-weight: 700;
line-height: 1.45;
white-space: nowrap;
}
/* Actions: the Delete ghost button (the tuning row-action language)
+ the inline two-step confirm pair (phase 50 task 04). */
/* Phase 156 (task 02, A3): the width support for the action
labels' clamp — both wrappers are clamped to the cell (max-width
100% of the td's content box), and the flex-item buttons are
shrinkable (min-width: 0), so a squeezed Actions/Share column
ellipsizes the label instead of the card edge hard-cutting it.
Mobile (≤640px) is unaffected in kind: the flex-wrap there wraps
the buttons onto their own lines before the clamp engages. */
.history-actions { display: inline-flex; align-items: center; gap: 0.4rem; max-width: 100%; }
.history-delete {
min-height: 44px;
min-width: 0;
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;
}
.history-delete:hover:not(:disabled) { background: var(--err-bg); color: var(--err-ink); border-color: var(--err-line); }
.history-delete:disabled { opacity: 0.5; cursor: wait; }
.history-confirm-text { color: var(--err-ink); font-size: 0.82rem; font-weight: 700; white-space: nowrap; }
/* Yes: the error-rose treatment (err-ink on err-bg 9.1:1, err-line
border); No: the ghost (transparent, --line border). */
.history-confirm-yes {
min-height: 44px;
padding: 0.35rem 0.7rem;
border: 1px solid var(--err-line);
border-radius: var(--radius-sm);
background: var(--err-bg);
color: var(--err-ink);
font: inherit;
font-weight: 700;
font-size: 0.82rem;
white-space: nowrap;
cursor: pointer;
}
.history-confirm-yes:hover:not(:disabled) { background: color-mix(in srgb, var(--err-line) 18%, transparent); }
.history-confirm-yes:disabled { opacity: 0.6; cursor: wait; }
.history-confirm-no {
min-height: 44px;
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;
}
.history-confirm-no:hover { background: var(--brand-soft); color: var(--brand-ink); }
/* Share column (phase 51, owner-locked 2026-08-29): the Tune/Retry-
family ghost buttons — Create link (unshared) and Copy (shared);
Unshare is the .history-unshare ghost and its two-step confirm reuses
the .history-confirm-* pair CSS above (the phase-50 pattern). The
row-action language of .history-delete: --line border, transparent
fill, ink-soft (>=5.1:1), ≥44px comfortable target, focus-visible
via the global 3px rule; hover takes the brand pair (6.9:1). */
.history-share { display: inline-flex; align-items: center; gap: 0.4rem; flex-wrap: nowrap; max-width: 100%; }
.history-share-cell { white-space: nowrap; }
.history-share-create,
.history-share-copy,
.history-unshare {
min-height: 44px;
min-width: 0; /* phase 156 (task 02, A3) — the shrinkable flex item (the clamp support above) */
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;
}
.history-share-create:hover:not(:disabled),
.history-share-copy:hover:not(:disabled) { background: var(--brand-soft); color: var(--brand-ink); }
.history-unshare:hover:not(:disabled) { background: var(--err-bg); color: var(--err-ink); border-color: var(--err-line); }
.history-share-create:disabled,
.history-share-copy:disabled,
.history-unshare:disabled { opacity: 0.5; cursor: wait; }
/* The share link's inline fallback field (phase 51, owner-locked): a
non-secure (http) origin rejects the clipboard, so the full URL is
offered as an input-like <a> that selects itself on focus — click or
Tab, then Ctrl/Cmd+C. Mono (the URL is data), surface fill, --line
border; truncates with an ellipsis at narrow widths (the full URL is
the title + the text selection). Ink on surface ≈13.8:1. */
.share-link-fallback {
display: inline-block;
max-width: 14rem;
padding: 0.35rem 0.55rem;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
background: var(--surface);
color: var(--ink);
font-family: var(--mono);
font-size: 0.78rem;
text-decoration: none;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
vertical-align: middle;
}
.share-link-fallback:hover { border-color: var(--brand); }
.share-link-fallback:focus-visible { outline: 3px solid var(--brand); outline-offset: 2px; }
/* Empty-state row: the muted centered message at full table width
(the .git-sources-empty language, inline in the table). */
.history-empty-row td {
padding: 2.25rem 1rem;
text-align: center;
color: var(--ink-soft);
font-style: italic;
}
/* ---------- Tokens view (phase 79 task 06) ----------
The admin-issued access tokens (generate · list · revoke). The
shell's standard frame (the .container): the page-head (h1 + sub),
the #tokens-gate (the #history-gate / .sources-gate language), the
create row (label + Generate), the shown-once block (the mono
field + Copy), and the FULL-WIDTH table (AGENTS.md rule 5 — the
.history-table language: --line hairlines, the brand-soft-tinted
thead, row hover, the scrollable .table-wrap). Every pair reuses
the Phase-08 AA palette: brand-ink on brand-soft 6.9:1, ink-soft
>=5.1:1, err 9.3:1. :focus-visible via the global 3px outline
rule. No CDN, system fonts. */
.tokens-shell {
display: flex;
flex-direction: column;
gap: 1.25rem;
flex: 1;
}
/* Action feedback line (role=status): the .history-status shape —
the min-height holds the layout so a line never reflows the table. */
.tokens-status {
display: block;
min-height: 1.2em;
color: var(--ink-soft);
font-family: var(--mono);
font-size: 0.8rem;
padding-block: 0.25rem;
}
/* The create row: label input + Generate — flex, wraps below 640px
(the button drops under the full-width input). */
.token-create {
display: flex;
align-items: center;
gap: 0.6rem;
}
#token-label {
flex: 1;
min-width: 0;
min-height: 44px;
padding: 0.35rem 0.7rem;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
background: var(--surface);
color: var(--ink);
font: inherit;
font-size: 0.93rem;
}
#token-label:focus-visible { outline: 3px solid var(--brand); outline-offset: 2px; }
/* Generate: the .history-refresh brand pill language — the solid
brand fill (--bg text on --brand 5.2:1, AA), the ≥44px target, the
lightened hover fill, the dimmed :disabled (the in-flight state),
the global :focus-visible ring. */
.token-generate {
min-height: 44px;
padding: 0.4rem 1.1rem;
border: 0;
border-radius: 999px;
background: var(--brand);
color: var(--bg);
font: inherit;
font-weight: 600;
font-size: 0.9rem;
white-space: nowrap;
cursor: pointer;
}
.token-generate:hover:not(:disabled) { background: var(--brand-hover); color: var(--bg); }
.token-generate:disabled { opacity: 0.6; cursor: wait; }
/* The shown-once block (owner-locked A4): a quiet brand-soft card
around the "shown once" line + the mono read-only field + Copy —
the token reads as the app's own credential surface (brand-ink on
brand-soft 12.4:1; ink on the field 13.8:1). */
.token-once {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0.9rem 1rem;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
background: var(--brand-soft);
}
.token-once-copy {
margin: 0;
color: var(--brand-ink);
font-size: 0.85rem;
font-weight: 600;
}
.token-once-row {
display: flex;
align-items: center;
gap: 0.6rem;
}
/* The mono read-only field: the token is DATA — mono, surface fill,
--line border; it truncates with an ellipsis at narrow widths (the
full value is the text selection — the inline copy fallback). */
#token-once-value {
flex: 1;
min-width: 0;
min-height: 44px;
padding: 0.35rem 0.55rem;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
background: var(--surface);
color: var(--ink);
font-family: var(--mono);
font-size: 0.78rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
#token-once-value:focus-visible { outline: 3px solid var(--brand); outline-offset: 2px; }
/* Copy: the Tune/Retry-family ghost button (the .history-share-copy
language — --line border, transparent fill, ink-soft, ≥44px, the
brand hover pair). */
#token-once-copy {
min-height: 44px;
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;
}
#token-once-copy:hover { background: var(--brand-soft); color: var(--brand-ink); border-color: var(--brand); }
/* The table is FULL-WIDTH (AGENTS.md rule 5): width 100% inside the
standard .container; the .table-wrap card + its horizontal scroll
cover narrow widths (the phase-07 responsive contract). The
.history-table language, verbatim. */
.tokens-table {
width: 100%;
border-collapse: collapse;
min-width: 640px;
font-size: 0.93rem;
}
.tokens-table th, .tokens-table td {
text-align: left;
padding: 0.7rem 1rem;
border-bottom: 1px solid var(--line);
vertical-align: middle;
}
.tokens-table th {
color: var(--brand-ink);
font-size: 0.82rem;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.tokens-table tbody tr:hover { background: var(--bg); }
.tokens-table tbody tr:last-child td { border-bottom: 0; }
/* Label: the hand-out name (the column ellipsizes, the full label
sits in the title attribute — tokens.js). */
.tokens-label-cell {
max-width: 20rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 600;
}
/* Created / Last used: locale date+time (ink-soft), the full ISO in
the title attribute; "never" for the not-yet-used stamp. */
.tokens-date-cell { color: var(--ink-soft); white-space: nowrap; }
/* Status: the Active em-dash rides the cell's ink-soft (5.1:1 on
--surface); the Revoked marker reuses the .stale-pill rose family
(err-ink on err-bg ≈9.3:1, err-line border — the stale-pill visual
language). */
.tokens-status-cell { color: var(--ink-soft); white-space: nowrap; }
/* Actions: the Revoke ghost button (the .history-delete language —
--line border, transparent fill, ink-soft, ≥44px target) + the
inline two-step confirm (the .history-confirm-* pair CSS above —
the phase-50 pattern). */
.tokens-actions { display: inline-flex; align-items: center; gap: 0.4rem; }
.token-revoke {
min-height: 44px;
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;
}
.token-revoke:hover:not(:disabled) { background: var(--err-bg); color: var(--err-ink); border-color: var(--err-line); }
.token-revoke:disabled { opacity: 0.5; cursor: wait; }
/* Regenerate (phase 101 task 03, D2): the .token-revoke's structural
twin — the SAME size / border / radius / focus, the transparent
fill + ink-soft text — but the NEUTRAL action's hover
(brand-soft / brand-ink), NOT the revoke's error hover: a
rotation is a hand-out of a successor, not a deletion (no new
hue — the phase-92 monochrome invariant). The confirm pair
reuses the .history-confirm-* classes unchanged (the pair CSS
above). :disabled mirrors the revoke button's rule (the
in-flight state, while the rotation POST is out). */
.token-regenerate {
min-height: 44px;
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;
}
.token-regenerate:hover:not(:disabled) { background: var(--brand-soft); color: var(--brand-ink); }
.token-regenerate:disabled { opacity: 0.5; cursor: wait; }
/* Empty-state + search no-match rows (phase 101): the muted centered
message at full table width (the .history-empty-row language,
inline in the table) — CLASS-based, so both the active table's
#tokens-empty-row / #tokens-no-match-row and the revoked table's
#tokens-revoked-no-match-row share it. */
.tokens-empty-row td {
padding: 2.25rem 1rem;
text-align: center;
color: var(--ink-soft);
font-style: italic;
}
/* Phase 101 (task 02, D4): the per-table live label search — the
house input surface (the #token-label / archive-upload input
family: --line hairline, --surface fill, ink text — AA pairs, no
new hue), full width, the ≥44px target, the global :focus-visible
ring (no custom focus rule — the house pattern). */
.token-search {
width: 100%;
min-height: 44px;
padding: 0.55rem 0.8rem;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
background: var(--surface);
color: var(--ink);
font: inherit;
font-size: 0.9rem;
}
/* The revoked section's sub-heading (phase 101 D1): the phase-97
.kb-level h2 voice — mono, 1rem, brand-ink (the path voice, AA on
the page background). */
.tokens-revoked-heading {
margin: 1.5rem 0 0.5rem;
font-family: var(--mono);
font-size: 1rem;
color: var(--brand-ink);
}
/* ---------- Theme view (phase 91, tasks 04 + 05) ----------
The shell's seventh view (#view-theme): the admin palette + branding
editor (task 05). The full 72rem container width (owner
instruction 2026-09-12 — the phase-91 form-column cap is
superseded), the
form card (the #tune-form language: surface fill, --line hairline,
radius, shadow), the fieldset groups (Branding / Palette) with the
.theme-note sub-copy, the 3-column (desktop) / 1-column (mobile)
palette grid with the color swatch inputs sized for touch (44px —
the label above is the second tap affordance), and the Save/Reset
row (Save = the brand pill family, --bg ink on --brand 5.2:1 AA;
Reset = the ghost button family, --line border; the §7.4
disabled-while-in-flight look is the :disabled pair below). The
feedback lines: error/result are the house role=status/alert line
languages; #theme-contrast (the WCAG warning) uses the --err-*
STATE family — states are semantic colors, never themed from the
tab (B3). Every pair reuses the Phase-08 AA palette; :focus-visible
via the global 3px outline rule. No CDN, system fonts. */
.theme-shell {
display: flex;
flex-direction: column;
gap: 1.25rem;
flex: 1;
}
/* The static form — one card holding the two fieldset groups + the
actions row (the #tune-form card language). */
#theme-form {
display: flex;
flex-direction: column;
gap: 1rem;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 1rem 1.1rem 1.1rem;
}
/* The fieldset groups: reset the UA border (the card IS the group's
frame), the legend rides the group's title line (the
.tuning-panel-title language — ink, 1rem, 700). */
.theme-group {
margin: 0;
padding: 0;
border: 0;
display: flex;
flex-direction: column;
gap: 0.6rem;
}
.theme-group-title {
padding: 0;
font-size: 1rem;
font-weight: 700;
color: var(--ink);
}
/* The branding text inputs: the #token-label language (surface is
already the card fill — transparent field, --line hairline, ≥44px
target, the global :focus-visible ring). */
#theme-form fieldset label {
font-size: 0.88rem;
font-weight: 600;
color: var(--ink-soft);
}
#theme-form fieldset input[type="text"] {
width: 100%;
min-height: 44px;
padding: 0.35rem 0.7rem;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
background: transparent;
color: var(--ink);
font: inherit;
font-size: 0.93rem;
}
/* The palette note under the branding legend (task 05): the strings
apply on the next page load (B4) — the live preview covers the
palette only. */
.theme-note {
margin: 0;
font-size: 0.8rem;
line-height: 1.35;
color: var(--ink-soft);
}
/* The color-input grid (task 05): the 8 swatches in 3 columns
(3 + 3 + 2 rows) — the cell is a .theme-color (label above the
swatch); 1 column at the mobile breakpoint (below). */
.theme-colors {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.6rem 0.9rem;
}
.theme-color {
display: flex;
flex-direction: column;
gap: 0.3rem;
min-width: 0;
}
.theme-color label {
font-size: 0.8rem;
font-weight: 600;
color: var(--ink-soft);
line-height: 1.25;
}
.theme-color input[type="color"] {
/* Touch-sized swatch (task 05): 44px tall (the WCAG 2.5.8 target —
the label above is the second tap affordance). */
inline-size: 56px;
block-size: 44px;
padding: 3px;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
background: var(--bg);
cursor: pointer;
}
/* Save (primary) + Reset (secondary): the pill + ghost families
(.history-refresh / .token-revoke languages — the global
:focus-visible ring applies, no button-scoped focus override). */
.theme-actions {
display: flex;
gap: 0.6rem;
margin-top: 0.2rem;
}
#theme-save {
min-height: 44px;
padding: 0.4rem 1.2rem;
border: 0;
border-radius: 999px;
background: var(--brand);
color: var(--bg); /* dark ink on brand: 5.2:1 (never white on brand) */
font: inherit;
font-weight: 700;
font-size: 0.9rem;
white-space: nowrap;
cursor: pointer;
}
#theme-save:hover:not(:disabled) { background: var(--brand-hover); color: var(--bg); }
#theme-save:disabled { opacity: 0.6; cursor: wait; }
.theme-reset {
min-height: 44px;
padding: 0.4rem 1rem;
border: 1px solid var(--line);
border-radius: 999px;
background: transparent;
color: var(--ink-soft);
font: inherit;
font-weight: 600;
font-size: 0.9rem;
white-space: nowrap;
cursor: pointer;
}
.theme-reset:hover:not(:disabled) { background: var(--brand-soft); color: var(--brand-ink); border-color: var(--brand); }
.theme-reset:disabled { opacity: 0.6; cursor: wait; }
/* The three feedback lines (task 05): error (role=alert — the
server's 422 detail) and result (role=status) are the house line
languages; min-height holds the layout so a line never reflows the
form. #theme-contrast (the WCAG warning, task 05) takes the
.tuning-error box treatment in the --err-* STATE family — states
are semantic colors, never themed from the tab (B3): a failing
pair reads as a warning and the box is warning-only (Save is never
disabled by it). */
.theme-error,
.theme-result,
.theme-contrast {
display: block;
margin: 0;
min-height: 1.2em;
font-family: var(--mono);
font-size: 0.8rem;
padding-block: 0.25rem;
}
.theme-error { color: var(--err-ink); }
.theme-result { color: var(--ok-ink); }
.theme-contrast {
background: var(--err-bg);
color: var(--err-ink);
border: 1px solid var(--err-line);
border-radius: var(--radius-sm);
padding: 0.45rem 0.8rem;
}
/* ---------- Settings view (phase 168) ----------
The admin settings panel: ONE .table-wrap card (the phase-156
bounded scroll region — max-height: min(70vh, 64rem) — and the
sticky thead via the .table-wrap rule) holding the 75-row
catalogue (grouped, type-correct controls, literal-text source
badges — B5: text + color, never color alone), the client-side
search row, and the Save / Clear-all action row. Every color is
an EXISTING AA token (B3: no new palette variable, no new :root
token — the phase-156 rule): the badges are TEXT and the color
is decoration. Nothing animates, so prefers-reduced-motion needs
no new rule. */
.settings-shell {
display: flex;
flex-direction: column;
gap: 1.25rem;
flex: 1;
}
/* Phase 171 (task 01; TODO L4): the #settings-content block —
the disabled banner, the status + error lines, the search row,
the .table-wrap card, and the Save / Clear-all action row —
stacks with ONE uniform gap: the shell's own 1.25rem
(ASSUMPTION A4: no new spacing value; the 640px mobile
stacking composes with it unchanged). The gap kills both
layout complaints at once: the search box clears the table
card (the "overlap") and the action row separates from the
table (the "smushed"). NO per-element margins: the gap is the
ONLY spacing source; the #settings-status / .settings-error
min-heights already reserve their lines (the gap is additive
and uniform). */
#settings-content {
display: flex;
flex-direction: column;
gap: 1.25rem;
}
/* The catalogue: FULL-WIDTH (AGENTS.md rule 5 — the .git-sources-
table language: no skinny list), one cell per column, the
.table-wrap card + its bounded scroll + the sticky thead carry
the phase-156 contract. The ≤640px mobile block (phase 176) raises
the floor to 720px so the 13rem Value column stays readable on a
phone (the card's horizontal scroll absorbs it; this rule keeps
560px for ≥641px viewports). */
.settings-table {
width: 100%;
border-collapse: collapse;
min-width: 560px;
font-size: 0.93rem;
}
.settings-table th, .settings-table td {
text-align: left;
vertical-align: top;
padding: 0.7rem 1rem;
border-bottom: 1px solid var(--line);
}
.settings-table td { white-space: nowrap; }
/* Phase 171 (task 02; TODO L4, owner-locked A3): the group band.
Today's defect: background --surface — the SAME fill as the
.table-wrap card, so the band is invisible. The fix: the
--brand-soft tint (the SOLID hex the sticky thead rule
documents at .table-wrap — rows never show through, and a
themed page's #bor-theme override of the token handles the
color — B4; no new palette variable, B3), weight 700, and a
--line top hairline so a band is delimited above AND below
(the border-bottom comes from the .settings-table th family)
and can never merge with the card's top edge or the band
above it. The text stays --ink on --brand-soft — 14.8:1 (AA;
the :root documented pairing). Static, text-only, no animation
(nothing for prefers-reduced-motion to still). */
.settings-group-row th {
background: var(--brand-soft);
color: var(--ink);
font-weight: 700;
font-size: 0.82rem;
text-transform: uppercase;
letter-spacing: 0.04em;
border-top: 1px solid var(--line);
}
.settings-table tbody tr.settings-row:hover td { background: var(--bg); }
/* The Setting cell: the label (bold) over the BOR_* name (mono,
ink-soft 8.2:1 on --surface, AA). */
.settings-name-cell label { margin: 0; font-weight: 600; min-width: 0; }
.settings-env {
font-family: var(--mono);
font-size: 0.78rem;
color: var(--ink-soft);
}
/* The restart-required flag (LOCKED A4, boot-wired): the accent
STATE family (--accent-ink on --accent-bg 9.5:1, AA) — text +
color, never color alone (B5); the title / aria-describedby
carry the when-it-applies copy. */
.settings-flag {
display: inline-block;
margin-top: 0.25rem;
padding: 0.06rem 0.5rem;
border: 1px solid var(--accent-line);
border-radius: 999px;
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
background: var(--accent-bg);
color: var(--accent-ink);
}
/* The Value cell: the type-correct control (number / select /
textarea / text / password) — the .token-label input language
(--line hairline, --surface fill, ink text 15.3:1, AA; the
values are data — mono) + the ≥44px target + the global
:focus-visible ring. */
.settings-control {
width: 100%;
min-width: 0;
min-height: 44px;
padding: 0.35rem 0.7rem;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
background: var(--surface);
color: var(--ink);
font-family: var(--mono);
font-size: 0.85rem;
}
.settings-control:disabled { opacity: 0.6; }
/* The Source cell: the literal-text badge — the three is-*
variants are SOLID existing tokens (B3): DB is brand-ink on
brand-soft (9.0:1, AA), env is ink-soft on surface (8.2:1, AA;
the --line border keeps it visible on the card), default is
ink-soft on line (7.1:1, AA). The badge is TEXT — the color
is decoration (B5). */
.settings-src {
display: inline-block;
padding: 0.12rem 0.55rem;
border-radius: 999px;
font-family: var(--mono);
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
white-space: nowrap;
}
.settings-src.is-db { background: var(--brand-soft); color: var(--brand-ink); }
.settings-src.is-env { background: var(--surface); color: var(--ink-soft); border: 1px solid var(--line); }
.settings-src.is-default { background: var(--line); color: var(--ink-soft); }
/* A dirty row: the --brand left marker on the row's first cell
(an inset box-shadow — no layout shift) so "unsaved" survives
grayscale + the N-unsaved-changes hint (text, B5). */
.settings-row.is-dirty > td:first-child { box-shadow: inset 3px 0 0 var(--brand); }
/* The per-row Reset: the .token-revoke language (≥44px, --line
hairline, transparent fill, ink-soft, the err hover pair — it
hands control back to the environment) + the dimmed :disabled
(nothing to reset / read-only). */
.settings-reset {
min-height: 44px;
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;
}
.settings-reset:hover:not(:disabled) { background: var(--err-bg); color: var(--err-ink); border-color: var(--err-line); }
.settings-reset:disabled { opacity: 0.5; cursor: default; }
/* The search row: the .token-create flex (wraps below 640px via
the media query below) + the #token-label input language. */
.settings-search {
display: flex;
align-items: center;
gap: 0.6rem;
}
.settings-search label { white-space: nowrap; }
#settings-filter {
flex: 1;
min-width: 0;
min-height: 44px;
padding: 0.35rem 0.7rem;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
background: var(--surface);
color: var(--ink);
font: inherit;
font-size: 0.93rem;
}
#settings-filter:focus-visible { outline: 3px solid var(--brand); outline-offset: 2px; }
/* The action row: Save (the .token-generate brand pill — the
§7.4 in-flight state is the dimmed :disabled + aria-busy; the
disabled cursor is `default` — the .settings-reset:disabled
idiom — the wait cursor was never part of §7.4) and
Clear all (the .token-revoke ghost — the destructive err hover
pair; the two-step confirm reuses the .history-confirm-* rules
unchanged). */
.settings-actions {
display: flex;
align-items: center;
gap: 0.6rem;
flex-wrap: wrap;
}
#settings-save {
min-height: 44px;
padding: 0.4rem 1.1rem;
border: 0;
border-radius: 999px;
background: var(--brand);
color: var(--bg);
font: inherit;
font-weight: 600;
font-size: 0.9rem;
white-space: nowrap;
cursor: pointer;
}
#settings-save:hover:not(:disabled) { background: var(--brand-hover); color: var(--bg); }
#settings-save:disabled { opacity: 0.6; cursor: default; }
#settings-clear-all {
min-height: 44px;
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;
}
#settings-clear-all:hover { background: var(--err-bg); color: var(--err-ink); border-color: var(--err-line); }
/* The "N unsaved changes" hint (textContent only, aria-hidden):
the .tokens-status voice (mono, ink-soft). */
.settings-dirty {
font-family: var(--mono);
font-size: 0.8rem;
color: var(--brand-ink);
}
/* The ONE status region (the .tokens-status mirror — the
min-height holds the layout so a line never reflows the table).
The error line (the .theme-error mirror). */
.settings-status {
display: block;
min-height: 1.2em;
color: var(--ink-soft);
font-family: var(--mono);
font-size: 0.8rem;
padding-block: 0.25rem;
}
.settings-error {
display: block;
margin: 0;
min-height: 1.2em;
font-family: var(--mono);
font-size: 0.8rem;
padding-block: 0.25rem;
color: var(--err-ink);
}
/* The no-match row (the .tokens-empty-row language). */
.settings-no-match td {
padding: 0.9rem 1rem;
color: var(--ink-soft);
font-family: var(--mono);
font-size: 0.85rem;
white-space: normal;
}
/* ---------- Shared page (phase 51, task 03) ----------
/shared/<token>: the anonymous read-only conversation (owner-locked
2026-08-29, TODO.md L6). The shared page reads exactly like the
chat page — both at the 72rem container width (--chat-column, now
equal to the .container cap — owner instruction 2026-09-12; the
wide-desktop doubling was retired) — and the .msg/.bubble/.thinking/
.tool-calls/.msg-meta rules apply unchanged, with NO composer, so
the column contract holds for a guest. Zero interactive controls
(owner-locked): the chips are
plain text, so the pill families' pointer treatments are switched
off IN THIS SCOPE ONLY — the chat page's interactive chips keep
their styles untouched. Every pair reuses the Phase-08 AA palette
(ink-soft ≥6.9:1 on surface/bg, brand-ink on brand-soft 6.9:1);
:focus-visible via the global 3px outline rule. No CDN, system
fonts. */
.shared-shell {
width: 100%;
max-width: var(--chat-column); /* the centered chat column — 72rem,
the .container width at every viewport */
margin-inline: auto;
display: flex;
flex-direction: column;
gap: 1rem;
flex: 1;
}
/* Page title (JS-filled with the shared chat's title; the static
fallback is "Shared conversation") — the page-head h1 size. */
#shared-title { margin: 0 0 0.25rem; font-size: 1.7rem; }
/* The muted meta line under the h1 ("Shared via … — read-only."):
ink-soft on the page bg ≥8.6:1, the page-sub language. */
.shared-note {
margin: 0;
color: var(--ink-soft);
font-size: 0.92rem;
}
/* The invalid / revoked state: a centered muted card (the not-found
language — surface fill, --line border, italic ink-soft). Revealed
by shared.js for a malformed token (no fetch) or a failed read. */
#shared-invalid {
padding: 1.4rem 1rem;
text-align: center;
color: var(--ink-soft);
font-style: italic;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: var(--shadow);
}
/* Static chips: a guest's "Maybe try" and source chips are TEXT — no
pointer, no hover, no cursor (owner-locked zero controls). The pill
look is kept; the interactivity is scoped off here and only here
(pointer-events: none also makes the :hover rules unreachable). */
.shared-shell .suggestion-chip,
.shared-shell .source-chip {
pointer-events: none;
cursor: default;
}
/* ---------- Document viewer (phase 10; two-row header since phase 34) ---------- */
/* Phase 34 (owner confirmation 2026-08-26): the viewer header is TWO
rows in one sticky <header> — row 1 reuses the standard .app-header /
.header-inner rules VERBATIM (row 1 IS the standard bar, so the
phase-12/19 pinned heights 64px / 58px apply), row 2 is the
.doc-titlebar (back link + title + meta). The header element itself
is content-sized (row 1 + row 2) and sticky, so BOTH rows stay
pinned while the document scrolls. */
.doc-header {
position: sticky;
top: 0;
z-index: 20;
background: var(--surface);
/* Phase 34: no fixed height — the header sizes to row 1 (--header-h)
+ the .doc-titlebar; the phase-12 shrink guard stays. */
flex-shrink: 0;
}
/* Row 1's own gradient hairline would land BETWEEN the rows; the
separator there is the quiet --line border-top on the titlebar, and
the signature hairline belongs at the bottom edge of the whole
header (the .doc-header::after in the shared rule above). */
.doc-header > .app-header::after { content: none; }
/* Back link: pill with an SVG arrow + "Sources" (>=44px touch target;
:focus-visible via the global 3px outline rule, phase 08). */
.doc-back {
display: inline-flex;
align-items: center;
gap: 0.4rem;
flex: 0 0 auto;
min-height: 44px;
padding: 0.45rem 0.9rem;
border-radius: 999px;
background: var(--brand-soft);
border: 1px solid var(--line);
color: var(--brand-ink); /* 6.9:1 on --brand-soft */
font-weight: 600;
font-size: 0.95rem;
text-decoration: none;
}
.doc-back:hover { background: var(--brand-soft); }
.doc-back svg { width: 16px; height: 16px; display: block; }
/* Row 2: the titlebar — a .container-width row with the back link +
the title block, its own content-sized height (title line + meta
line), the same surface as row 1, separated from it by the quiet
hairline (var(--line) — the existing 1px border color). */
.doc-titlebar {
border-top: 1px solid var(--line);
padding-block: 0.6rem;
}
.doc-titlebar .container {
display: flex;
align-items: center;
gap: 0.9rem;
}
/* The title block keeps clipping (min-width: 0) so #doc-title /
.doc-meta ellipsize inside the flex row instead of overflowing. */
.doc-title-block { min-width: 0; }
#doc-title {
margin: 0;
font-size: 1.3rem;
line-height: 1.3;
/* Phase 34: ellipsis + the `title` attribute (set by document.js) —
the row has no pills to fit, so the old clipping-for-pills rule is
gone; the ellipsis is the only fit the title needs. */
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Meta row: source badge · format badge · mono path · created ·
indexed · chunks (phase 106 (task 08, D8) added created BEFORE
indexed). Phase 12/34: it may clip, but it must NEVER wrap — the
meta stays on one line in the titlebar row (the row is content-
sized, so a wrap would grow it). */
.doc-meta {
display: flex;
flex-wrap: nowrap;
align-items: center;
gap: 0.4rem;
margin-top: 0.35rem;
font-size: 0.78rem;
color: var(--ink-soft);
/* Overflowing content (badges + path + dates) clips at the bar edge;
nowrap keeps text on one line so the row can never grow the header. */
white-space: nowrap;
overflow: hidden;
}
.doc-source-badge {
background: var(--brand-soft);
border: 1px solid var(--line);
color: var(--brand-ink);
font-weight: 600;
border-radius: 999px;
padding: 0.02rem 0.55rem;
}
.format-badge {
font-family: var(--mono);
background: var(--brand-soft);
border: 1px solid var(--line);
color: var(--brand-ink);
border-radius: var(--radius-sm);
padding: 0.02rem 0.45rem;
}
.doc-path {
font-family: var(--mono);
font-size: 0.75rem;
max-width: 26rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
/* The path is the meta row's designated ellipsis target: with a floor it
keeps a visible box on narrow screens while the trailing badges clip. */
min-width: 6rem;
}
/* Phase 145 (task 03): the "Original" anchor — the git origin as a
copyable external link in the meta row (rendered by the ONE
renderDocument core — the modal AND /document.html; a non-git
document never gets one). The .doc-path code family (mono, 0.75rem,
the SAME 26rem ellipsis budget + 6rem floor — the row clips, the
title carries the full URL) with the .doc-link affordance (brand-ink
8.7:1 on --surface — the recorded .doc-link pair; underline on
hover/focus, the .doc-link idiom). The anchor's visible text IS the
full URL (the copyable-link contract — the ellipsis clips the
display). */
.doc-origin {
font-family: var(--mono);
font-size: 0.75rem;
max-width: 26rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 6rem;
color: var(--brand-ink); /* 8.7:1 on --surface (the .doc-link pair) */
text-decoration: none;
}
.doc-origin:hover, .doc-origin:focus-visible { text-decoration: underline; }
/* Phase 153 (task 02): the meta row's "Copy path" control — the
row-action language (icon + label, the .git-source-remove idiom)
in a NEUTRAL secondary fill (the .git-source-ignore hover pair —
brand-soft, 12.4:1 on --surface; the meta row's text sits on the
titlebar's --surface). Compact on desktop (the meta row's 0.78rem
scale — a 44px floor would grow the bar); the ≤640px block gives
it the ≥44px touch-target floor (PLAN §7.1). The VISIBLE "Copy
path" text is the accessible name (no aria-label); the icon is
aria-hidden. */
.doc-copy-path {
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: 0.12rem 0.5rem;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
background: transparent;
color: var(--ink-soft); /* 5.1:1 on --surface (the meta row's pair) */
font: inherit;
font-weight: 600;
font-size: 0.78rem;
white-space: nowrap;
cursor: pointer;
}
.doc-copy-path svg { width: 13px; height: 13px; display: block; flex: 0 0 auto; }
.doc-copy-path:hover { background: var(--brand-soft); color: var(--brand-ink); }
.doc-copy-path:focus-visible { outline: 3px solid var(--brand); outline-offset: 2px; }
/* Phase 153 (task 02): the Copy-path status line — the VISIBLE
confirmation (aria-hidden — the surface's role=status live region
is the a11y announcer, the share pattern): the meta row's text
scale, var(--ink-soft) on --surface (5.1:1 — the pair recorded at
:root). Serves BOTH surfaces: the page's floating block and the
modal's wrapping meta block. */
.doc-copy-status {
margin: 0;
font-size: 0.78rem;
color: var(--ink-soft); /* 5.1:1 on --surface */
}
/* Phase 153 (task 02): the /document.html page's Copy-path state
block — a floating panel anchored under the control (absolutely
positioned in the header — the meta row must never wrap or grow
the bar, the phase-12/34 contract, so the state floats OUT of
the row). Surface fill, --line border, the standard shadow;
wraps (the field + the status may share a row or take separate
rows at narrow widths). z-index 30 sits inside the header's own
z-20 root context, so the block floats above the page content. */
.doc-copy-pop {
position: absolute;
z-index: 30;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.4rem;
max-width: min(36rem, calc(100vw - 2rem));
padding: 0.45rem 0.6rem;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius-sm);
box-shadow: var(--shadow);
}
/* Phase 153 (task 02): the Copy-path inline fallback field — the
.share-link-fallback pattern (input variant): a non-secure (http)
origin rejects the clipboard, so the full path is offered as an
input-like <input> that selects itself on focus — click or Tab,
then Ctrl/Cmd+C. readOnly (a copy surface, not an editor), mono
(the path is data — the .share-link-fallback font), surface
fill, --line border; truncates with an ellipsis at narrow widths
(the selection is the copy). Ink on surface ≈13.8:1 (the
.share-link-fallback pair). */
.doc-path-fallback {
max-width: 26rem;
padding: 0.3rem 0.55rem;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
background: var(--surface);
color: var(--ink);
font-family: var(--mono);
font-size: 0.78rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.doc-path-fallback:hover { border-color: var(--brand); }
.doc-path-fallback:focus-visible { outline: 3px solid var(--brand); outline-offset: 2px; }
.doc-chunks { font-family: var(--mono); }
/* Phase 106 (task 08, D8): the Created badge — the SAME family as the
Indexed one (no own look: the meta row's text, var(--ink-soft) on
the titlebar's --surface — 5.1:1, the pair recorded at :root; the
modal's .doc-modal-meta row records the same pair). The explicit
declaration pins the inherited value: a themed --ink-soft moves the
badge with the row (byte-identical rendering). */
.doc-created { color: var(--ink-soft); /* 5.1:1 on --surface */ }
.doc-shell {
display: flex;
flex-direction: column;
gap: 1rem;
flex: 1;
}
#doc-content { display: flex; flex-direction: column; }
.doc-loading { margin: 1.5rem auto; text-align: center; color: var(--ink-soft); }
/* Markdown: the centered reading column — 72rem, the .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). */
.doc-md {
width: 100%;
max-width: var(--chat-column);
margin-inline: auto;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 1.5rem 1.75rem;
overflow-wrap: anywhere;
}
.doc-md p { margin: 0.35rem 0; }
.doc-md h3 { margin: 1.1rem 0 0.4rem; font-size: 1.15rem; }
.doc-md h4 { margin: 0.9rem 0 0.35rem; font-size: 1rem; }
.doc-md > :first-child { margin-top: 0; }
.doc-md ul { margin: 0.4rem 0; padding-left: 1.3rem; }
.doc-md 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);
}
.doc-md code { font-family: var(--mono); font-size: 0.88em; background: var(--brand-soft); padding: 0.08em 0.35em; border-radius: 5px; }
.doc-md pre code { background: none; padding: 0; }
/* Summary panel (phase 36): the labeled "Summary" section the shared
renderDocument core (document.js) draws ABOVE the original content
whenever doc.summary is non-empty (phase 30 — non-markdown docs
only). A "summary, not content" look: the surface card carries a 3px
brand left border (no shadow — the content cards own those) and a
small-caps brand-ink label. Phase-08 tokens only; static content —
no animation (nothing for prefers-reduced-motion to still), and the
aria-label + heading carry the accessibility. */
.doc-summary {
width: 100%;
background: var(--surface);
border: 1px solid var(--line);
border-left: 3px solid var(--brand);
border-radius: var(--radius);
padding: 0.9rem 1.1rem;
margin-bottom: 1rem;
overflow-wrap: anywhere;
}
/* md/markdown: the panel matches the .doc-md centered reading column
(72rem — the same --chat-column token, the .container width at
every viewport) — it is the column's label. Raw formats stay full width (the
.doc-raw default above), matching the full-width pre; in engines
without :has() the panel degrades to that full-width default. */
.doc-summary:has(+ .doc-md) {
max-width: var(--chat-column);
margin-inline: auto;
}
.doc-summary-title {
margin: 0 0 0.4rem;
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--brand-ink); /* 9.0:1 on --surface */
}
.doc-summary-text {
margin: 0;
color: var(--ink); /* on --surface ≈14.5:1 */
}
/* Summary edit affordance (phase 57, task 02 — D4, admin-only): the
header row (label + Edit button), the inline editor (prefilled
textarea + Save/Cancel + a role=status live region). House
dark-tech palette (phase-08 tokens), system fonts, no CDN;
:focus-visible via the global 3px outline rule. Anonymous visitors
never see any of it — the button and editor are wired only for
admins (docAdminReady in document.js), so the public panel is
byte-for-byte the phase-36 shape. */
.doc-summary-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
margin-bottom: 0.4rem; /* the old .doc-summary-title bottom margin */
}
.doc-summary-head .doc-summary-title { margin: 0; }
.doc-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;
}
.doc-summary-edit:hover { background: var(--brand-soft); color: var(--brand-ink); border-color: var(--brand); }
.doc-summary-edit[hidden] { display: none; } /* the hidden attr must beat the display above */
.doc-summary-editor {
display: block;
width: 100%;
min-height: 8rem;
padding: 0.6rem 0.8rem;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
background: var(--bg); /* inset against the --surface panel */
color: var(--ink); /* 16.7:1 on --bg (AA) */
font: inherit;
line-height: 1.5;
resize: vertical;
}
.doc-summary-actions {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 0.75rem;
}
.doc-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;
}
.doc-summary-save:hover { background: var(--brand-hover); } /* the house hover lightening */
.doc-summary-save:disabled { opacity: 0.6; cursor: default; } /* one PATCH at a time */
.doc-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;
}
.doc-summary-cancel:hover { background: var(--err-bg); color: var(--err-ink); border-color: var(--err-line); }
.doc-summary-status {
margin: 0.6rem 0 0;
font-size: 0.85rem;
color: var(--ink-soft); /* 5.1:1 on --surface (AA) */
}
.doc-summary-status:empty { margin-top: 0; }
/* Date edit affordance (phase 106, task 09 — D7, admin-only): the
"Edit date" text button after the Created badge (the
.doc-summary-edit family — one pill style for both inline
editors) and the editor box that swaps in for the button IN the
.doc-meta row: the native type=date input, Save / Cancel, the
muted underlined "Revert to sync" clear affordance (the phase-57
"clear = explicit" contrast), a role=status live line, and a
role=alert error line (the .git-source-error err pair). The meta
row may clip (nowrap) on the page, so the box wraps ITS children
internally (the modal's .doc-modal-meta already wraps). The
global 3px :focus-visible outline rule applies — no per-control
rule (the phase-105 checkbox idiom). :disabled = opacity +
cursor: wait (the .git-source-remove:disabled idiom — one PATCH
at a time). Contrast (verified, house style): button / Cancel /
revert / status ride the row's ink-soft on --surface (5.1:1, AA);
the input is ink on --bg (16.7:1, AA); Save is the solid brand
pill (--bg on --brand = 5.2:1, AA); the error line is the err
pair (9.1:1 on --err-bg, AA). Anonymous / token holders never see
any of it — the wiring is admin-gated (docAdminReady in
document.js), so the public badge row stays byte-for-byte the
task-08 shape. */
.doc-date-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;
}
.doc-date-edit:hover { background: var(--brand-soft); color: var(--brand-ink); border-color: var(--brand); }
.doc-date-edit[hidden] { display: none; } /* the hidden attr must beat the display above */
.doc-date-editor {
display: inline-flex;
flex-wrap: wrap;
align-items: center;
gap: 0.35rem;
min-width: 0;
max-width: 100%;
}
.doc-date-input {
padding: 0.15rem 0.4rem;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
background: var(--bg);
color: var(--ink); /* 16.7:1 on --bg (AA) */
font: inherit;
font-size: 0.78rem;
font-family: var(--mono);
}
.doc-date-save {
display: inline-flex;
align-items: center;
min-height: 24px;
padding: 0.15rem 0.7rem;
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.78rem;
cursor: pointer;
}
.doc-date-save:hover { background: var(--brand-hover); } /* the house hover lightening */
.doc-date-cancel {
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;
cursor: pointer;
}
.doc-date-cancel:hover { background: var(--err-bg); color: var(--err-ink); border-color: var(--err-line); }
.doc-date-revert {
display: inline-flex;
align-items: center;
min-height: 24px;
padding: 0.15rem 0.2rem;
border: 0;
background: transparent;
color: var(--ink-soft); /* 5.1:1 on --surface (AA) — the muted marker */
font: inherit;
font-size: 0.78rem;
text-decoration: underline;
cursor: pointer;
}
.doc-date-revert:hover { color: var(--ink); } /* 14.5:1 on --surface (AA) */
.doc-date-save:disabled,
.doc-date-cancel:disabled,
.doc-date-revert:disabled,
.doc-date-input:disabled {
opacity: 0.5;
cursor: wait; /* one PATCH at a time (the .git-source-remove:disabled idiom) */
}
.doc-date-status {
font-size: 0.78rem;
color: var(--ink-soft); /* 5.1:1 on --surface (AA) */
}
.doc-date-error {
flex-basis: 100%; /* drops onto its own row under the controls */
min-width: 0;
max-width: 100%;
overflow-wrap: anywhere; /* a long server detail never overflows the box */
font-size: 0.78rem;
font-weight: 600;
background: var(--err-bg);
color: var(--err-ink); /* 9.1:1 on --err-bg (AA — the .git-source-error pair) */
border: 1px solid var(--err-line);
border-radius: var(--radius-sm);
padding: 0.1rem 0.5rem;
}
.doc-date-status:empty, .doc-date-error:empty { display: none; } /* an empty live line takes no space */
/* Raw (non-markdown, non-prose) formats: full-width mono pre,
horizontal scroll (locked A3, phase 165 — the prose set renders in
the wrapping .doc-prose block below, never here). */
.doc-raw {
width: 100%;
margin: 0;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 1.25rem 1.5rem;
overflow-x: auto;
white-space: pre;
font-family: var(--mono);
font-size: 0.85rem;
line-height: 1.5;
}
/* Prose (phase 165, task 01 — TODO.md L3, LOCKED A1/A2): the image
description and plain-text (txt/text) docs as WRAPPING prose — the
.doc-md column geometry (72rem reading column, the surface card)
with pre-wrap rhythm. The defect: these docs landed in the .doc-raw
pre above (white-space: pre), so every generated line was one
unbreakable line and a long sentence scrolled sideways. The content
stays a text node (pre-wrap carries the paragraph breaks — no
markdown parsing: a description is prose, not markup). Tokens only
(no new hue — the monochrome invariant; --ink on --surface is the
documented 14.5:1 AA pair). Static content — no animation (nothing
for prefers-reduced-motion to still). Code keeps the .doc-raw
scroller (phase 116 locked A1, here locked A3). */
.doc-prose {
width: 100%;
max-width: var(--chat-column);
margin-inline: auto;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 1.5rem 1.75rem;
color: var(--ink);
white-space: pre-wrap;
overflow-wrap: anywhere;
line-height: 1.55;
}
/* Image documents (phase 122, task 04): the viewer's image block —
the document's persistent bytes (the /api/documents/{id}/image
route) above its description (the .doc-raw content slot below). The
theme's surface card treatment (the .doc-md language): the image
sits on --surface with the line border + radius; max-width 100% +
max-height 70vh keep any aspect ratio inside the page (height: auto
keeps the ratio). Static content — no animation; the alt text (the
vision description) carries the accessibility (WCAG). Phase-08
tokens only (no new hue — the monochrome invariant). */
.doc-image {
width: 100%;
max-width: var(--chat-column);
margin: 0 auto 1rem;
display: flex;
justify-content: center;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 0.5rem;
}
.doc-image-img {
display: block;
max-width: 100%;
max-height: 70vh;
height: auto;
border-radius: var(--radius-sm);
background: var(--bg);
}
/* The vector variant (phase 181, task 04, design D6 — applied in
document.js when doc.format === "svg", base class kept). An SVG
carrying only a viewBox has NO intrinsic size, so without an
explicit width the default replaced-element box (300×150) applies
and the drawing sits small inside the full-width .doc-image card;
width: 100% spans the card (the full-size preview the owner
asked for). object-fit: contain protects the drawing from being
squashed when the base rule's max-height: 70vh clamps a tall
vector — the box letterboxes, the graphic keeps its aspect ratio.
Static content — no animation (nothing for prefers-reduced-motion
to still). Palette-neutral: no colour here — the base rule's --bg
shows through (phase-08 tokens only, no new hue). */
.doc-image-img--vector {
width: 100%;
height: auto;
object-fit: contain;
}
/* The onerror fallback (the route's 404 — the row exists but the
copy was lost): a muted note in the card's place-of-image; the
description below still renders, so the doc stays readable. Phase
148 (task 03) reuses THIS class for the page strip's lost-page
note (the same muted styling — no new pattern). */
.doc-image-unavailable {
margin: 1.25rem 1rem;
color: var(--ink-soft); /* 5.1:1 on --surface (AA) */
font-size: 0.9rem;
}
/* Page strip (phase 148, task 03): the viewer's rendered-page strip
for a page-qualifying PDF — the payload's page_urls (task 02's
exact 1-based route list) replaces the text content AND the
.doc-summary panel (the owner's contract: the per-page
descriptions stay in the DB for Q&A, just not shown to the user).
The core renders it into the SAME #doc-content slot the .doc-raw /
.doc-md paths use — no new column — inside the existing doc
content column: --chat-column (72rem — the .container width, the
.doc-md / .doc-image language) at every viewport. A vertical stack
(one page per row — the natural reading order for instruction
manuals); each figure carries the page PNG on the non-clashing
--surface with the line border + radius (the .doc-image figure
precedent — a page PNG must sit on a surface in both themes) and a
centered muted "Page N" caption. Static content — no animation
(nothing for prefers-reduced-motion to still); the lazy imgs + alt
text (WCAG) carry the accessibility. Phase-08 tokens only (no new
hue — the monochrome invariant). */
.doc-pages {
display: grid;
gap: 1rem;
width: 100%;
max-width: var(--chat-column);
margin-inline: auto;
}
.doc-page { margin: 0; }
.doc-page img {
display: block;
width: 100%;
max-width: 100%;
height: auto;
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface);
}
.doc-page-num {
font-size: 0.78rem;
color: var(--ink-soft); /* 8.2:1 on --surface, ~9.7:1 on --bg (AA in both themes) */
text-align: center;
padding: 0.35rem 0 0;
}
/* Additive strip view (phase 163, task 04): the pictorial-class PDF
— a USABLE text layer — keeps the text doc (Summary panel +
content) with the page strip one Text/Pages toggle away (LOCKED
A1/A6). The toggle is the house ghost-pill idiom (the .auth-link
family): the two pills sit adjacent in the labeled group, ink-soft
on the transparent pill (AA on --bg/--surface), hover the
brand-soft/brand-ink pair; the PRESSED button is the brand fill +
heavier weight — text + style, never color alone (B5) — with --bg
ink on --brand (5.2:1, the .share-chat-btn pair). ≥44px targets
(§7.1); :focus-visible rides the global 3px ring. The view swap is
CSS-driven off the .doc-views wrapper's data-view: the strip is
display:none by default (its lazy imgs never fetch while hidden)
and shows on [data-view="pages"], which hides the text view instead
— the nodes stay mounted across flips (no re-render, no refetch).
Phase-08 tokens only (no new hue — the monochrome invariant). */
.doc-view-toggle {
display: flex;
gap: 0.4rem;
width: 100%;
max-width: var(--chat-column);
margin-inline: auto;
margin-bottom: 0.75rem;
}
.doc-view-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);
font: inherit;
font-weight: 600;
font-size: 0.95rem;
white-space: nowrap;
cursor: pointer;
}
.doc-view-btn:hover { background: var(--brand-soft); color: var(--brand-ink); }
.doc-view-btn[aria-pressed="true"] {
background: var(--brand);
border-color: var(--brand);
color: var(--bg); /* dark ink on brand: 5.2:1 (AA) */
font-weight: 700; /* pressed = weight + fill, never color alone (B5) */
}
.doc-views .doc-pages { display: none; } /* Text default — hidden lazy imgs never fetch */
/* The text views hide behind the strip on Pages. .doc-prose (phase
165, task 01) joins the list DEFENSIVELY today — an image/txt doc
never page-qualifies — and becomes load-bearing the day the owner
adds "pdf" to PROSE_FORMATS (phase 165 D1 + phase 163 A2). */
.doc-views[data-view="pages"] .doc-summary,
.doc-views[data-view="pages"] .doc-md,
.doc-views[data-view="pages"] .doc-raw,
.doc-views[data-view="pages"] .doc-prose,
.doc-views[data-view="pages"] .doc-image { display: none; }
.doc-views[data-view="pages"] .doc-pages { display: grid; }
/* Designed not-found state (no emoji — plain SVG mark, phase 08 rule). */
.doc-not-found {
width: 100%;
max-width: 30rem;
margin: 2rem auto;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 2.25rem 1.75rem;
text-align: center;
}
.doc-not-found-glyph { color: var(--ink-soft); width: 44px; height: 44px; margin-inline: auto; }
.doc-not-found-glyph svg { width: 44px; height: 44px; display: block; }
.doc-not-found h2 { margin: 0.8rem 0 0.4rem; font-size: 1.3rem; }
.doc-not-found-sub { margin: 0 0 1.25rem; color: var(--ink-soft); }
.doc-open-sources {
display: inline-flex;
align-items: center;
min-height: 44px;
padding: 0.5rem 1.1rem;
background: var(--brand);
color: var(--bg); /* dark ink on brand: 5.2:1 */
font-weight: 700;
text-decoration: none;
border-radius: var(--radius-sm);
}
.doc-open-sources:hover { background: var(--brand-hover); }
/* Viewer links: Sources-table path cell + chat source chips (phase 10). */
.doc-link {
color: var(--brand-ink); /* 8.7:1 on --surface */
text-decoration: none;
}
.doc-link:hover, .doc-link:focus-visible { text-decoration: underline; }
/* ---------- Document modal (phase 26) ----------
"New documents should open in an almost-fullscreen modal, not in a new
page" (TODO.md L4). The overlay reuses the viewer page's .doc-meta
badge classes, the .doc-md reading column (72rem — the .container
width; inside the modal the 1100px panel stays its effective
ceiling), and the .doc-raw
pre — this block only adds the chrome (backdrop, panel, header,
actions, scroll container). Phase-08 tokens only; NO blur (the
phase-08 no-blur perf anchor); no new assets; system fonts.
Stacking: z-index 1000 puts the overlay above the sticky header (20)
and the skip-link (100); the z-index:-1 background layers stay below
everything. The panel is 96vw × 92vh, centered ("almost-fullscreen"). */
.doc-modal {
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 — this one is the
documented, testable contract for the skeleton). */
.doc-modal[hidden] { display: none; }
.doc-modal-backdrop {
position: fixed;
inset: 0;
/* --bg at 82% — no backdrop-filter (phase-08 no-blur perf anchor). */
background: color-mix(in srgb, var(--bg) 82%, transparent);
transition: opacity 120ms ease;
}
.doc-modal-panel {
/* position:relative lifts the panel above the fixed backdrop (positioned
elements paint over in-flow siblings otherwise). */
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
width: min(1100px, 96vw);
height: 92vh;
margin: auto;
background: var(--surface);
border: 1px solid var(--line);
border-radius: 12px;
/* Depth shadow — deliberately NOT themed (black, phase-92 table:
"depth, not theme"); the color-mix spelling renders the same 55%
black and keeps the literal out of the phase-92 sweep gate. */
box-shadow: 0 24px 80px color-mix(in srgb, black 55%, transparent);
}
/* Sticky top with the SAME height as the page bars — the phase-12 pins
(--header-h: 64px desktop / 58px ≤640px) so the bar never reads
differently here. The title is the designated squeeze target (ellipsis),
so the bar can never grow its height. */
.doc-modal-header {
flex-shrink: 0;
position: sticky;
top: 0;
z-index: 1;
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.9rem;
height: var(--header-h);
padding-inline: 1.25rem;
background: var(--surface);
border-bottom: 1px solid var(--line);
}
.doc-modal-title {
min-width: 0;
margin: 0;
font-size: 1.3rem;
line-height: 1.3;
color: var(--ink);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.doc-modal-actions {
flex: 0 0 auto;
display: flex;
align-items: center;
gap: 0.5rem;
}
/* "Full page" escape hatch: the same ghost pill as New chat / Sign in
(ink-soft on surface ≈6.9:1; hover pair brand-ink/brand-soft ≈6.9:1).
Icon-only below 640px — the aria-label keeps the accessible name. */
.doc-modal-open {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.4rem;
min-height: 44px;
padding: 0.4rem 0.8rem;
border-radius: 999px;
border: 1px solid var(--line);
background: transparent;
color: var(--ink-soft);
font: inherit;
font-weight: 600;
font-size: 0.85rem;
white-space: nowrap;
text-decoration: none;
}
.doc-modal-open:hover { background: var(--brand-soft); color: var(--brand-ink); }
.doc-modal-open svg { width: 15px; height: 15px; display: none; }
/* Icon-only close (aria-label in the markup). ink-soft on surface ≈6.9:1;
hover = the err pair ≈9.1:1, like the steering-note delete. */
.doc-modal-close {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 44px;
min-height: 44px;
padding: 0;
border-radius: 999px;
border: 1px solid var(--line);
background: transparent;
color: var(--ink-soft);
cursor: pointer;
}
.doc-modal-close:hover { background: var(--err-bg); color: var(--err-ink); border-color: var(--err-line); }
.doc-modal-close:focus-visible {
outline: 3px solid var(--brand);
outline-offset: 2px;
}
.doc-modal-close svg { width: 18px; height: 18px; display: block; }
/* Meta row: the SAME badge classes as the viewer's .doc-meta (source
badge · format badge · mono path · created · indexed · chunks — no
duplicate badge styling here); unlike the fixed-height header bar it
may WRAP, so nothing clips. aria-live="polite" on the element
announces the load → meta swap (phase-10 a11y contract, modal
variant). */
.doc-modal-meta {
flex-shrink: 0;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.4rem;
padding: 0.6rem 1.25rem 0;
font-size: 0.78rem;
color: var(--ink-soft);
}
/* The scroll container: vertical scroll lives HERE, never the viewport.
.doc-md keeps its centered reading column inside (72rem — wider
than the 1100px panel's inner width, which stays its effective
ceiling); .doc-raw keeps its own overflow-x. tabindex="-1" in the
markup is the JS focus target. */
.doc-modal-content {
flex: 1;
min-height: 0;
overflow: auto;
padding: 1rem 1.25rem 1.5rem;
}
/* Themed horizontal scrollbar for the modal's raw code block (phase 116,
task 01): the .doc-raw pre already owns its overflow (its own
`overflow-x: auto` + `white-space: pre` — locked A1, code never
wraps), so the native light-gray bar a long line reveals is the pre's
own. Both engine families are themed, scoped to THIS modal only (no
global scrollbar restyle — the TODO's scope): scrollbar-color / -width
for Firefox, the ::-webkit-scrollbar pseudos for Chromium (height 8px
= the horizontal bar; the pre does not scroll vertically). The tokens
follow the saved theme via the :root color-mix derivation; the muted
thumb keeps >= 3:1 non-text contrast on the near-surface track. */
.doc-modal .doc-raw {
scrollbar-width: thin;
scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track);
}
.doc-modal .doc-raw::-webkit-scrollbar { height: 8px; }
.doc-modal .doc-raw::-webkit-scrollbar-track { background: var(--scrollbar-track); }
.doc-modal .doc-raw::-webkit-scrollbar-thumb {
background: var(--scrollbar-thumb);
border-radius: 4px;
}
.doc-modal-loading { margin: 1.5rem auto; text-align: center; color: var(--ink-soft); }
/* No motion under reduced motion (same pattern as the phase-25 layers). */
@media (prefers-reduced-motion: reduce) {
.doc-modal-backdrop { transition: none; }
}
/* ---------- Doc edit screen (phase 59, task 06) ----------
/doc-edit.html: the admin-gated edit screen for a doc draft (title,
in-repo path, markdown body) — a FLOW page, not one of the app's
pages, so the header is SLIM (brand + "← Back to chat" only). The
shell rides the full 72rem container like every other page (owner
instruction 2026-09-12 — the phase-59 form-column cap is
superseded). The .sources-gate gate is reused verbatim
(phases 16/35/50). Every pair reuses the Phase-08 AA palette;
touch targets >=44px; :focus-visible via the global 3px outline
rule. No CDN, system fonts. */
.doc-edit-shell {
width: 100%;
display: flex;
flex-direction: column;
gap: 1.25rem;
flex: 1;
}
/* The slim header's back link — the .doc-back ghost language
(document.html): >=44px target, --line border, ink-soft (5.1:1 on
the --surface bar) rising to ink on hover; pushed to the bar's right
edge by the header-inner flex (margin-left: auto — the nav's
own pattern). */
.doc-edit-back {
display: inline-flex;
align-items: center;
gap: 0.4rem;
min-height: 44px;
margin-left: auto;
padding: 0.4rem 0.9rem;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
background: transparent;
color: var(--ink-soft);
font-weight: 600;
text-decoration: none;
}
.doc-edit-back:hover { color: var(--ink); border-color: var(--ink-soft); }
.doc-edit-back svg { width: 16px; height: 16px; display: block; }
/* The edit form — the tuning form's card as a vertical stack: labeled
title input, mono path input, the mono markdown textarea (min-height
20rem — the body is the star), and the actions row (the brand Push
button + the back link). Inset fields (bg fill on the surface card). */
#doc-edit-form {
display: flex;
flex-direction: column;
gap: 0.9rem;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 1.5rem 1.5rem 1.75rem;
}
#doc-edit-form label {
font-weight: 600;
font-size: 0.95rem;
}
#draft-title,
#draft-path {
width: 100%;
font: inherit;
font-size: 1rem;
color: var(--ink);
background: var(--bg);
border: 1px solid var(--line);
border-radius: var(--radius-sm);
padding: 0.55rem 0.75rem;
min-height: 44px;
}
/* The in-repo path is machine data — mono (the git-sources URL-input
convention). */
#draft-path {
font-family: var(--mono);
font-size: 0.92rem;
}
/* The markdown body: mono, tall (min-height 20rem), vertical resize.
ink on bg = 16.7:1. */
#draft-body {
width: 100%;
font-family: var(--mono);
font-size: 0.92rem;
line-height: 1.5;
color: var(--ink);
background: var(--bg);
border: 1px solid var(--line);
border-radius: var(--radius-sm);
padding: 0.75rem 0.9rem;
min-height: 20rem;
resize: vertical;
}
/* Actions row: the primary Push button (brand, dark ink on brand
5.2:1 — never white on brand) + the Discard control (phase 115 —
the secondary destructive action) + the back link; wraps at narrow
widths. */
.doc-edit-actions {
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
}
#push-doc-btn {
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); /* dark ink on brand: 5.2:1 */
font: inherit;
font-weight: 700;
cursor: pointer;
padding-inline: 1.25rem;
}
#push-doc-btn:hover:not(:disabled) { background: var(--brand-hover); }
#push-doc-btn:disabled { opacity: 0.6; cursor: wait; }
/* Phase 115 (task 02): the Discard control — the SECONDARY destructive
action, visually subordinate to the brand primary (the ghost family
of .steering-delete / .tuning-delete): ink-soft on transparent
(5.1:1 on --surface — more on the darker --bg canvas) with the
--line border; hover joins the err family (err-ink on err-bg 9.3:1,
the err-line border — the state stays text + color, never color
alone); the 44px touch floor, the global 3px :focus-visible ring,
and the :disabled state is the "Discarding…" in-flight affordance. */
.discard-draft {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 44px;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
background: transparent;
color: var(--ink-soft);
font: inherit;
font-weight: 600;
font-size: 0.85rem;
white-space: nowrap;
padding-inline: 1rem;
cursor: pointer;
}
.discard-draft:hover:not(:disabled) {
background: var(--err-bg);
color: var(--err-ink);
border-color: var(--err-line);
}
.discard-draft:disabled { opacity: 0.6; cursor: wait; }
/* The success status line (role=status): the ok family (ok-ink on
ok-bg 10.6:1) when a push outcome has landed — min-height holds the
line's space so the layout never jumps when the text lands. Empty
(before the first push, or after a failure cleared the stale line)
it is the dashed placeholder (the #archive-upload-result language). */
.doc-edit-status {
margin: 0;
min-height: 1.5rem;
background: var(--ok-bg);
color: var(--ok-ink);
border: 1px solid var(--line);
border-radius: var(--radius-sm);
padding: 0.45rem 0.8rem;
font-size: 0.9rem;
font-weight: 600;
}
.doc-edit-status:empty {
background: transparent;
border-style: dashed;
color: var(--ink-soft);
}
/* The error banner (role=alert): the err family (err-ink on err-bg
9.3:1, err-line border) — git's stderr may carry long paths, so
long words break instead of overflowing the card. */
.doc-edit-error {
margin: 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.85rem;
font-weight: 600;
overflow-wrap: anywhere;
}
/* ---------- Footer ---------- */
.app-footer {
border-top: 1px solid var(--line);
background: var(--surface);
padding-block: 0.8rem;
margin-top: 1rem;
}
.footer-inner {
display: flex;
justify-content: space-between;
gap: 1rem;
color: var(--ink-soft);
font-size: 0.85rem;
}
/* ---------- Responsive (tablet squeeze — phase 34 task 04 visual pass) ----------
The full admin bar (brand + nav [Chat, Sources, Tuning] + Tuning
toggle + New chat + Sign out) outgrows a 768px bar in
the desktop styles. Squeeze the pills/gaps moderately — the 44px
touch floor is held by min-height and the 64px height by --header-h
— and let the brand wordmark (base ellipsis) absorb any remainder.
The ≤640 block below stays tighter and wins at phone widths. */
@media (max-width: 900px) {
.header-inner { gap: 0.6rem; }
/* Phase 35: the admin-only "Git sources" link joins the nav — a
fourth text pill on the admin bar — so the tablet squeeze tightens
once more to hold 768px in the admin state (brand already the
designated clip target, pills squeeze next). */
.nav-link { padding: 0.4rem 0.5rem; font-size: 0.85rem; }
.app-nav { gap: 0.15rem; }
.new-chat-btn, .auth-link { padding: 0.45rem 0.5rem; }
}
/* ---------- Responsive (mobile-first adjustments) ---------- */
@media (max-width: 640px) {
:root { --header-h: 58px; }
.container { padding-inline: 0.9rem; }
/* Owner report 2026-09-08: the hamburger "won't extend" on a real
phone while the same width in a desktop browser works. The page
opts into `viewport-fit=cover` (the <meta>), so on notched phones
the layout viewport runs UNDER the status bar / Dynamic Island —
and this bar (58px, toggle tap zone y≈7–51px) sits entirely in the
system top-edge zone: visually under the battery/signal icons, and
in the region where a real device does not reliably deliver taps
to the page (desktop emulation has no system top zone, so it works
there — the device-only failure). Pad the bar DOWN by the top
safe-area inset: the header surface still fills the notch region
(that is the point of viewport-fit=cover) but every control — the
#nav-toggle first — lands in the reliably tappable area, and the
dropdown's top:100% follows the taller bar automatically. The
.app-header rule doubles as the document viewer's row-1 bar (the
same class), so the viewer's hamburger is covered by the same
rule. env() resolves to 0 on every non-notch surface (desktop,
headless, no-island phones) — byte-identical rendering there. */
.app-header {
height: calc(var(--header-h) + env(safe-area-inset-top, 0px));
padding-top: env(safe-area-inset-top, 0px);
}
/* The share toast rests just under the bar — the bar is taller by
the same inset on notched phones, so the offset must carry it too
(the toast would otherwise tuck under the padded bar). */
.toast {
top: calc(var(--header-h) + 0.75rem + env(safe-area-inset-top, 0px));
}
/* Phase 14: the New chat pill joins the header — tighten the bar so
brand + nav + pill fit at 360px without horizontal overflow (the
brand text may ellipsize as the designated squeeze target). */
/* Phase 29: the admin-only Tuning link joins the nav — three text
pills on the chat bar in the admin state. The brand text (the
designated squeeze target) is already at zero, so the bar squeezes
the nav pills + gaps instead: tighter pill padding/font and a
tighter inner gap. The 44px touch floor is held by min-height, the
58px bar height is pinned by --header-h, and the bar now fits both
auth states at 375px (and 360px) without horizontal overflow.
Phase 34 task 04 (visual pass): the full admin bar — nav [Chat,
Sources, Tuning] + Tuning toggle + Sync + New chat + Sign out —
now ships on EVERY page, so the squeeze tightens once more
(0.3rem inner gap, 0.78rem nav pills, 0.35rem pill padding,
18px brand mark) to hold 375px with the brand mark intact and
360px with the brand clipped clean (overflow:hidden — the mark
never overlaps the nav). */
/* Phase 35: the admin-only "Git sources" link joins the nav — a
fourth text pill on the admin bar — so the mobile squeeze tightens
once more (0.25rem inner gap, 0.72rem nav pills, 0.3rem pill
padding, 0.05rem nav gap) to hold 375px — and 360px, the brand
fully clipped — in the admin state without horizontal overflow. */
.header-inner { gap: 0.25rem; }
.brand { min-width: 0; overflow: hidden; }
.brand-mark { width: 18px; height: 18px; }
.brand-text {
font-size: 0.8rem;
letter-spacing: 0.04em;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Phase 46 (owner permission 2026-08-27, TODO.md L9): the nav links
LEAVE the bar at phone widths — the old pill-squeeze rules for
.nav-link / .app-nav (0.72rem pills, 0.05rem gap, in place of this
comment) are superseded by the #nav-toggle dropdown below. The
action pills' squeeze rules further down are untouched, and the
900px tablet block keeps squeezing the INLINE nav at 641–900px
(the hamburger is absent there). */
.nav-toggle {
display: inline-flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
padding: 0;
color: var(--ink);
background: none;
border: 0;
border-radius: var(--radius-sm);
cursor: pointer;
touch-action: manipulation; /* Phase 88: standard dead-mobile-button fix — the
toggle's taps must not wait on the double-tap-zoom/pinch disambiguation
(owner bug report 2026-09-08: dead hamburger on chat-page cold boots). */
}
.nav-toggle:hover { background: var(--brand-soft); color: var(--brand-ink); }
/* The icon is sized (an unsized inline SVG would default to 300px
and blow the bar out); 20px reads as a proper hamburger inside
the 44px target. */
.nav-toggle svg { width: 20px; height: 20px; display: block; }
/* The nav becomes the dropdown. The containing block is the sticky
.app-header (.header-inner is not positioned), so the menu spans
the header's full width — edge to edge — intended on mobile;
z-index 21 = header (20) + 1, above the bar content. */
.app-nav {
position: absolute;
top: 100%;
left: 0;
right: 0;
margin-left: 0;
flex-direction: column;
gap: 0;
background: var(--surface);
border-bottom: 1px solid var(--line);
box-shadow: var(--shadow-lg);
padding: 0.5rem 0;
z-index: 21;
/* Closed state (default): invisible and non-interactive — task 02's
header.js is the only opener (.is-open + aria-expanded). */
visibility: hidden;
opacity: 0;
transform: translateY(-8px);
pointer-events: none;
transition: opacity 180ms ease, transform 180ms ease, visibility 0s linear 180ms;
}
.app-nav.is-open {
visibility: visible;
opacity: 1;
transform: none;
pointer-events: auto;
transition: opacity 180ms ease, transform 180ms ease, visibility 0s;
}
/* Phase 88: while the mobile menu is open, the chat's sticky bottom
cluster is hidden — it is the other positioned/layered element on
the page and must not compete for taps with the open menu (it also
overlaps the menu's lower rows on short viewports — menu y58→417
vs cluster top y395 at 390×600). visibility (not display): layout
is preserved, so closing never reflows the chat column. */
body.nav-menu-open .chat-bottom {
visibility: hidden;
}
/* Menu rows: comfortable ≥44px targets (0.75rem × 2 + the 1rem line)
and readable text — replaces the old .nav-link pill squeeze. */
.app-nav .nav-link { padding: 0.75rem 1.25rem; font-size: 1rem; }
.new-chat-btn { padding: 0.4rem 0.3rem; }
.new-chat-label { display: none; }
.new-chat-btn svg { display: block; }
/* Phase 51: the Share pill squeezes with New chat (same family, same
rules — the chat-shell override below keeps the label visible). */
.share-chat-btn { padding: 0.4rem 0.3rem; }
.share-chat-label { display: none; }
.share-chat-btn svg { display: block; }
/* But on the chat page there is room — keep the label visible and
hide the icon (the button lives inside .chat-shell, not the navbar). */
.chat-shell .new-chat-label { display: inline; }
.chat-shell .new-chat-btn svg { display: none; }
.chat-shell .share-chat-label { display: inline; }
.chat-shell .share-chat-btn svg { display: none; }
/* Phase 55 (task 05, TODO.md L6, A5): the action row flips to a
vertical stack at the phone breakpoint — full-width pills, New
chat above Share. The pill rules above (padding, the
icon/label handling, the .chat-shell label overrides) apply to
the stacked pills unchanged. */
.chat-actions { flex-direction: column; align-items: stretch; gap: 0.5rem; }
/* Phase 53: the stale banner's row wraps at phone width (message
above, action below) — the pill takes a full-width comfortable
row instead of squeezing into the text. */
.stale-regenerate { margin-left: 0; width: 100%; }
.banner-retry { margin-left: 0; width: 100%; }
/* Phase 16: the auth pill goes icon-only like New chat — brand text
ellipsizes as the designated squeeze target, no bar overflow. */
.auth-link { padding: 0.4rem 0.3rem; }
.auth-label { display: none; }
.auth-link svg { display: block; }
/* Phase 46 UX revision: sign-out & sign-in move into the hamburger
dropdown on mobile — hide the bar copies, show the dropdown copies. */
.sign-out-mobile { display: inline-flex; }
#sign-out-btn { display: none !important; }
.sign-in-mobile { display: inline-flex; }
#sign-in-link { display: none !important; }
.nav-toggle { margin-left: auto; }
/* Dropdown-style sign-out: full-width row, error palette, label visible. */
#app-nav .sign-out-btn {
display: flex;
width: 100%;
align-items: center;
gap: 0.6rem;
text-align: left;
padding: 0.75rem 1.25rem;
font-size: 1rem;
border: 0;
border-radius: 0;
background: transparent;
color: var(--err-ink);
min-height: 48px;
}
#app-nav .sign-out-btn:hover {
background: var(--err-bg);
color: var(--err-ink);
}
#app-nav .sign-out-btn svg {
width: 18px;
height: 18px;
flex: 0 0 auto;
}
#app-nav .sign-out-btn .auth-label { display: inline; }
/* Dropdown-style sign-in: matches sign-out row styling. */
#app-nav .sign-in-mobile {
display: flex;
width: 100%;
align-items: center;
gap: 0.6rem;
text-align: left;
padding: 0.75rem 1.25rem;
font-size: 1rem;
border: 0;
border-radius: 0;
background: transparent;
color: var(--err-ink);
font-weight: 600;
text-decoration: none;
min-height: 48px;
}
#app-nav .sign-in-mobile:hover {
background: var(--err-bg);
color: var(--err-ink);
}
#app-nav .sign-in-mobile svg {
width: 18px;
height: 18px;
flex: 0 0 auto;
}
#app-nav .sign-in-mobile .auth-label { display: inline; }
.steering-note { padding: 0.3rem 0.3rem 0.3rem 0.7rem; }
.app-main > .steering-panel { width: calc(100% - 1.8rem); }
.tune-btn { min-height: 44px; }
.retry-btn { min-height: 44px; }
.edit-btn { min-height: 44px; }
.continue-btn { min-height: 44px; }
/* Phase 27: the tuning page squeezes like the other cards — the row
padding tightens; the icon+label actions keep their 44px floor and
the note text ellipsizes (min-width: 0). */
.tuning-note { gap: 0.4rem; padding: 0.3rem; }
.tuning-edit, .tuning-delete { padding: 0.35rem 0.5rem; }
.msg-body { max-width: 92%; }
.empty-state { padding: 1.75rem 1.1rem; margin-top: 0.25rem; }
.empty-state-title { font-size: 1.25rem; }
.suggestions { flex-wrap: nowrap; overflow-x: auto; justify-content: flex-start;
padding-bottom: 0.4rem; -webkit-overflow-scrolling: touch; scrollbar-width: thin; }
.suggestion-chip { flex: 0 0 auto; }
.doc-titlebar .container { gap: 0.5rem; } /* phase 34: row 2 squeezes like row 1 at mobile widths */
#doc-title { font-size: 1.1rem; }
.doc-path { max-width: 16rem; }
.doc-origin { max-width: 16rem; } /* phase 145 (task 03): the Original anchor squeezes like the path */
.doc-copy-path { min-height: 44px; min-width: 44px; } /* phase 153 (task 02): the Copy path control's ≥44px touch-target floor (PLAN §7.1) */
.doc-md { padding: 1.1rem 1rem; }
.doc-summary { padding: 0.75rem 0.9rem; }
.doc-raw { padding: 1rem; font-size: 0.8rem; }
/* Phase 26: the modal bar squeezes like the other bars — the Full page
pill goes icon-only (aria-label keeps the name), the title clips;
the panel stays 96vw × 92vh, so no horizontal overflow at 360px. */
.doc-modal-header { gap: 0.5rem; padding-inline: 0.9rem; }
.doc-modal-title { font-size: 1.1rem; }
.doc-modal-open { padding: 0.4rem 0.55rem; }
.doc-modal-open svg { display: block; }
.doc-modal-open span { display: none; }
.doc-modal-meta { padding-inline: 0.9rem; }
.doc-modal-content { padding: 0.75rem 0.9rem 1.25rem; }
.composer { padding: 0.5rem; }
/* Phase 35 (phase 49: + the archive upload form): the add forms
stack like the other cards — label, full-width input, full-width
button; the table wrapper's horizontal scroll already covers long
URLs/paths. */
#git-source-form,
#archive-upload-form { flex-direction: column; align-items: stretch; }
#git-source-form > label,
#archive-upload-form > label { white-space: normal; }
#git-source-url,
#git-source-branch,
#git-source-token,
#archive-upload-file { min-width: 0; }
#git-source-add,
#archive-upload-btn { width: 100%; }
/* Phase 50: the History table keeps its full width (the .table-wrap
horizontal scroll already covers it); the actions cell wraps so
the two-step confirm pair fits the phone width. */
.history-actions-cell { white-space: normal; }
.history-actions { flex-wrap: wrap; }
/* Phase 79 task 06: the Tokens create row stacks — the label input
takes the full width, the Generate pill drops under it (full
width); the once field's row stacks the same way; the actions
cell wraps so the two-step confirm pair fits the phone width
(the table's own horizontal scroll covers the columns). */
.token-create { flex-direction: column; align-items: stretch; }
.token-generate { width: 100%; }
.token-once-row { flex-direction: column; align-items: stretch; }
#token-once-copy { width: 100%; }
.tokens-actions-cell { white-space: normal; }
.tokens-actions { flex-wrap: wrap; }
/* Phase 91 task 05: the Theme form squeezes — the color grid drops
to 1 column (the label + swatch pairs keep their ≥44px targets)
and the actions row stacks (Save full width, Reset under it). */
.theme-colors { grid-template-columns: 1fr; }
.theme-actions { flex-direction: column; align-items: stretch; }
#theme-save { width: 100%; }
.theme-reset { width: 100%; }
/* Phase 168 (task 05): the Settings panel squeezes — the search
row stacks (the label over the full-width input) and the action
row stacks (Save full width, Clear all under it; the ≥44px
targets are the global rules above — the stack keeps them on
phones). The actions cell wraps so the Reset control fits the
phone width (the table's own horizontal scroll covers the
columns). */
.settings-search { flex-direction: column; align-items: stretch; }
.settings-actions { flex-direction: column; align-items: stretch; }
#settings-save { width: 100%; }
#settings-clear-all { width: 100%; }
.settings-actions-cell { white-space: normal; }
/* Phase 176 (task 01; TODO L3, owner-locked A2): the Value column
gets a usable floor on the phone. The Setting cells' nowrap
labels (the .settings-table td rule above) exhausted the 560px
table floor, collapsing the min-width: 0 Value cells to 2–3
characters at 375px. The cell's min-width is 13rem + 2rem (the
td's own 1rem × 2 padding — the global border-box model): the
.settings-control (width: 100% of the cell's CONTENT) lands at
the locked 13rem (≈208px ≈ 14 mono chars at the controls'
0.85rem) — readable and editable on a phone; the 720px table
floor + the card's overflow: auto (.table-wrap) mean the phone
scrolls the CARD, never the page (the phase-171 "no horizontal
page overflow" pin holds). 720px is a bare length literal — NO
new CSS custom property (B3 binds palette variables; a length in
the mobile block is the idiom the neighboring width: 100% rules
already use). Mobile-only (A1): at ≥641px these rules are inert
and the desktop layout stays byte-identical. */
.settings-table { min-width: 720px; }
.settings-value-cell, .settings-value-th { min-width: calc(13rem + 2rem); }
/* Phase 51: the shared page squeezes like the chat column — the
title and the note step down (the empty-state-title family); the
shell is full-width here (the 72rem cap never binds on a phone —
the container is already 100%) and .msg-body's 92% override
above applies. */
#shared-title { font-size: 1.35rem; }
.shared-note { font-size: 0.88rem; }
.footer-inner { flex-direction: column; gap: 0.2rem; text-align: center; }
main { padding-bottom: env(safe-area-inset-bottom, 0); }
/* Sync + History refresh: full-width LABELED pills on mobile (the
icon-only squeeze is gone — a symbol-only control is too small a
target to discover on a phone). The RAG page-head row wraps so
the Sync pill drops below the "Knowledge base" title at full
width; the History page-head already wraps the pill below its
title block. The Sync label's min(16rem, 40vw) cap lifts and the
label FILLS the pill (phase 128: flex 1 1 auto) — .sync-label-main
ellipsizes against the width left by the pinned .sync-label-count
(+ the phase-129 .sync-label-chunk part while a file's embed batch
is in flight), so the position parts stay on-screen at the right
edge, never clipped;
the refresh glyph joins its visible label. The aria-labels keep
the accessible names. */
.page-head-row { flex-wrap: wrap; }
.sync-btn { width: 100%; }
.sync-cancel-btn { width: 100%; } /* phase 134: the Cancel pill stacks full-width under the Sync pill (the row already wraps) */
.sync-label { max-width: none; flex: 1 1 auto; min-width: 0; }
.history-refresh, .history-pager-btn { width: 100%; }
.history-refresh svg, .history-pager-btn svg { display: block; }
/* Phase 175 (task 02, D6): the pager row STACKS (the
.settings-actions idiom) so the full-width Prev / Next pills
don't side-by-side overflow the phone width (the pill rule's
flex-shrink: 0 would keep each at 100%); the "Page N of M"
status sits between them (the 0.75rem gap goes vertical). */
.history-pager { flex-direction: column; align-items: center; }
/* Phase 182 (task 06): the two Copy controls of a verbatim panel and of
an ordinary answer code block reach the 44px touch floor on a phone
(the desktop pill is 32px tall — fine for a pointer, too small a tap
target for a thumb). The pill itself grows; its label keeps the
ellipsis escape, so a long "Copy 210 lines from …" never widens the
panel. */
.code-block-copy, .verbatim-copy { min-height: 44px; }
/* Phase 182 (task 07): the panel's viewer link is a control too — it
reaches the same touch floor (inline-flex so the line box grows without
moving the baseline of the caption row it sits in). */
.verbatim-open { min-height: 44px; display: inline-flex; align-items: center; }
}
/* Phase 46: prefers-reduced-motion stills the mobile menu — no
180ms slide+fade; open/close snaps (the visibility/opacity flip
applies instantly) and stays correct. BOTH states are named: the
.is-open rule (0,2,0) would otherwise out-specify a bare .app-nav
(0,1,0) and the OPEN transition would still animate. */
@media (prefers-reduced-motion: reduce) {
.app-nav,
.app-nav.is-open { transition: none; }
}