diff --git a/docs/semantic-frontend-protocol.md b/docs/semantic-frontend-protocol.md index 8814bae..3258fb7 100644 --- a/docs/semantic-frontend-protocol.md +++ b/docs/semantic-frontend-protocol.md @@ -116,6 +116,26 @@ layout-local frontend consumes). Peer cursors reuse the existing is: one capability bit, ~five instance→frontend interpretation variants, and one frontend→instance `Viewport` variant. +**`BufferSnapshot` resets buffer-scoped interpretation state.** A +frontend receiving a snapshot drops everything it holds for the +named buffer — spans, decorations, adornments, minimap summary, +completion popup, search and menu prompts (which also gate the +frontend's key/pointer interception), and status facts — and +rebuilds from the frames that follow; the instance mirrors this by +invalidating its per-buffer emission baselines whenever it writes a +snapshot, so the frontend's post-snapshot viewport declaration +receives authoritative re-sends even when nothing changed +daemon-side (the unchanged-generation A → B → A revisit). Bufferless +facts (`ThemeFacts`, the minibuffer prompt) and per-frontend state +(the gutter mode) survive snapshots on both sides, and the +instance's stale-store diagnostic-count freeze is store knowledge, +not session state — the re-sent `StatusFacts` after a snapshot +carries the frozen counts, never zeros, including for a session +whose first frame lands during staleness (a late joiner attaching +mid-edit). The store maintains those per-URI severity totals with +the retained vector, so frame-time producers read them in O(1) +rather than rescanning diagnostics for every attached session. + ## Capability and version mechanics Identical pattern to `crdt_replica`: @@ -205,6 +225,26 @@ ResourceOffer { mime: String, body: ResourceBody, // Inline(Vec) | Uri(String) }, + +/// Themes arc Q#TH7 (protocol v16): the daemon-resolved UI face +/// table. Bufferless — the theme is one global instance. Complete +/// replacement each send: a face absent from `faces` is unset and +/// the frontend uses its own default for that surface. Every +/// attachment receives exactly one authoritative table (the empty +/// table included) with its first emission after viewport +/// declaration; cached-compare suppressed thereafter, so an +/// unthemed session pays one small message and nothing more. +/// Resolution (the `ui.*` dotted-prefix inheritance walk) happens +/// daemon-side over the stage-1 face inventory — frontends do +/// exact-name lookup only, and apply each face within its +/// stage-1 component mask (docs/theme-faces-framing.md Q#TH3/Q#TH5: +/// a set face owns its surface; `Default` components mean the +/// frontend's plain rendering; out-of-mask components are never +/// read). Daemon-gated `>= 16`; appended as the FINAL variant — +/// postcard discriminants are ordinal. +ThemeFacts { + faces: Vec, // { name: String, style: Style }, sorted by name +}, ``` Each family member diffs against the previous frame the same way diff --git a/docs/theme-faces-framing.md b/docs/theme-faces-framing.md new file mode 100644 index 0000000..0a00b3d --- /dev/null +++ b/docs/theme-faces-framing.md @@ -0,0 +1,1038 @@ +# Theme faces — framing (Arc 4 stage 1, themes) + +**Revision 9 — 2026-07-15. Status: implemented on branch +`theme-faces` (PR #120); revision 9 folds PR round 5.** + +Revision 9 (PR #120 round 5, one finding): the store-sourced freeze +no longer rescans the retained diagnostic vector at frame cadence. +`DiagnosticStore` maintains per-URI severity totals alongside +`by_uri`, replacing or removing both in `set` / `clear` while +`mark_stale` deliberately preserves both. `status_facts_msg` reads +that tuple in O(1), so the round-4 late-join correctness does not +turn a long stale interval into O(frames x diagnostics x semantic +sessions) work under the shared store mutex. Acceptance item 34 is +the store unit pin: all four totals replace correctly, survive +staleness, and clear with the diagnostic vector. + +Revision 8 (PR #120 round 4, one finding): the diagnostic-count +freeze is sourced from the diag store itself, superseding round 3's +per-session `frozen_diag_counts` cache. That cache only seeded from +fresh computations, so a session FIRST rendering during staleness — +a late joiner attaching mid-edit, or a buffer first visited between +didChange and fresh diagnostics — had no entry and fell back to +(0, 0), contradicting the documented "frozen counts, never zeros" +contract. But `mark_stale` (T M11.8) keeps the last published +diagnostic vector — only the positions are invalid — so +`status_facts_msg` now counts the retained `for_uri` entries even +while stale: the retained entries ARE the frozen value, with no +session state to lose to a snapshot reset and no history needed at +attach. The per-session cache is deleted; the round-3 reset-survival +property holds by construction. Acceptance item 33 (a store marked +stale BEFORE the session exists still reports the preserved counts +on the first frame); item 32's round-trip claim is unchanged and +still passes against the new mechanism. + +Revision 7 (PR #120 round 3, findings 1–2): the reset contract is +now symmetric on the GPU. The round-2 producer reset covered +search/menu/status baselines, but the GPU's `BufferSnapshot` arm +only cleared spans, decorations, adornments, summary, and the +completion popup — a menu (or search) open at switch time survived +the snapshot with no close message ever coming (the new buffer's +first CLOSED state is suppressed daemon-side), leaving a stale +popup that also gated key and pointer interception +(`daemon_intercepts_keys`) indefinitely. The arm now clears +`search_prompt`, `menu`, and `status_facts`; the minibuffer is +deliberately exempt (one global core instance, matching the +producer's surviving `last_minibuffer` baseline) (finding 1). And +the round-2 reset broke the diagnostic-count freeze: `last_status` +was both the peer emission baseline and the stale-store freeze +source, so a snapshot between didChange and fresh diagnostics +re-shipped `StatusFacts` with zeroed counts. The freeze source now +lives apart — `frozen_diag_counts`, daemon-side knowledge about the +buffer, advanced on every fresh count and read when the store is +stale — and survives `on_buffer_snapshot_sent`, which keeps killing +the emission baseline to force the re-send (finding 2). Acceptance +items 31–32. + +Revision 6 (PR #120 round 2, findings 1–2): the producer gains the +**snapshot/baseline reset contract** +(`SemanticRenderState::on_buffer_snapshot_sent`). A `BufferSnapshot` +resets the receiving frontend's buffer-scoped render state wholesale +— spans, decorations, adornments, minimap summary, completion popup — +but the returned viewport only replaced the producer's declared +viewport, leaving every per-buffer emission baseline intact. On an +unchanged A → B → A round trip, `last_summary[A]`'s key still +matched, so the daemon emitted nothing and the frontend never +regained A's themed minimap (or A's `StatusFacts` — the band kept +showing B's name) until an edit, diagnostic republish, or theme +mutation happened to move the key. The daemon now invalidates every +buffer-scoped baseline for the snapshot's buffer wherever it writes +one — the active-buffer-follow path and the F29 upgrade broadcast; +the attach bootstrap needs no call because its session state is +constructed fresh — general contract, not a minimap special case +(finding 1). Deliberately surviving the reset: the bufferless +`ThemeFacts` pair (the frontend keeps its face table across +snapshots), the global minibuffer baseline, the per-frontend gutter +mode, and the revision-keyed diag line cache (a compute cache, not a +peer-state baseline); other buffers' baselines also survive, since +any buffer the frontend navigates to receives its own snapshot +first. Acceptance items 28–30 cover the producer round trip (themed +summary and `StatusFacts` return at one generation), the real-daemon +wiring over the wire, and the GPU minimap's pixel-identical return. +The acceptance-suite manifest header now lists the true item split +(finding 2). + +Revision 5 (PR #120 round 1, findings 1–4): accepting a +`FileStyleSummary` on the GPU now drops the cached minimap vertices — +theme recolors and diagnostic republishes arrive at an UNCHANGED +generation, and the cache keys only on (generation, dims, scroll), so +the stale strokes survived until an edit/resize/scroll; the daemon's +payload-equality suppression makes the invalidation precise +(finding 1). `lua_to_style` propagates every `Table::get` error: the +lookups run `__index`, so a raising metatable previously parsed as an +all-default style and the merge SUCCEEDED, committing its valid +siblings against the Q#TH6 all-or-nothing contract; boolean fields +deliberately keep Lua truthiness (mlua's `bool`), so only lookups +that RAISE fail the transaction (finding 2). The producer is +peer-version-aware: `SemanticRenderState::for_peer` records whether +the peer negotiated >= 16, and below that no `ThemeFacts` is produced +at all AND no `ui.diag.*` face is folded into the `FileStyleSummary` +marks — that summary is an ungated pre-v16 channel, and a v15 peer +must not get face-derived minimap colors while its squiggles, signs, +and counters stay unthemed; the summary's cache key zeroes its +face-epoch component for such peers so face edits don't trigger +pointless whole-file recomputes (finding 3). And the "canonical +severity color" claim is weakened to what is true: the +daemon-RESOLVED color is canonical (TUI squiggle/marker/minimap and, +when a face is set, all GPU surfaces), but the GPU's BUILT-IN +defaults — bright RGB constants for squiggles/signs/counters vs the +minimap's converted `Indexed(1/3/6/8)` — are a pre-existing +divergence this arc keeps, because unset faces must render +byte-identically to before (finding 4). + +Revision 4 (framing round 3, findings 1–7): +`ui.minibuffer.candidate` now has a real GPU site: its `fg` colors the +existing minibuffer-dropdown candidate glyph layer, while popup +background/selection theming stays deferred; the mask is therefore a +true intersection of both render paths (finding 1). The v15 +discriminant guard pins the byte encoding of `CompletionPopup`, the +actual final pre-v16 variant, rather than the earlier `LineNumbers` +variant (finding 2). The deterministic transactional bite now targets +the production ordered-result helper: it consumes an iterator of +`Result<(String, Style)>`, collects every result before taking the +theme lock, and commits only the completed vector, so an `Ok` followed +by an `Err` is both representable and guaranteed to leave the theme +untouched (finding 3). An empty diagnostic child is named as an +explicit reset to the built-in severity color, not as "unset" -- it +blocks parent inheritance -- and acceptance covers a themed +`ui.diag` parent plus an empty `ui.diag.error` child (finding 4). GPU +acceptance now covers every distinct stage-1 route: modeline reverse, +transient status text, minibuffer and isearch text, candidate-dropdown +glyphs, normal/active search washes, and the existing gutter, +selection, and diagnostic paths (finding 5). The prose and unset +fixture use the exact `ui`-or-`ui.*` predicate (finding 6). Finally, +the peer-selection acceptance drives the actual decoration collector +with simultaneous local and peer selections and inspects their +emitted colors; it is not a helper-only assertion (finding 7). + +Revision 3 (framing round 2, findings 1–9): the cross-frontend +contract is structural now — every face carries a stage-1 +**component mask**, identical on both frontends and sized to what +both can render today (the GPU gutter has no background layer; wash +quads cannot recolor or reverse glyphs); out-of-mask components are +ignored *everywhere*, so `ui.selection = { fg = 1 }` and +`ui.gutter = { bg = 1 }` are component no-ops on the TUI too, and +mask widening is a named, additive deferral (finding 1). Diag faces +get a representable `Default` policy: `Default` fg means the +built-in severity color, not "plain" — the minimap summary encodes +diagnostic *presence* by copying that color into `underline_color`, +where `Default` reads as "no mark", so an all-default diag face +uses the built-in severity color rather than an unrepresentable plain mark +(finding 2). The face predicate is explicit — +`name == "ui" || name.starts_with("ui.")` — shared by the +reservation, the counter-bump classification, and a merge-bare-`ui` +acceptance case (finding 3). The consecutive-`set` bite interleaves +render/observe between the mutations; two `set`s inside one dispatch +legitimately coalesce into one emission (finding 4). Producer and +gate caches advance on computation, not on emission: +`last_face_epoch` is `Option` (the gate cannot short-circuit +while the payload baseline is absent, guaranteeing the first +authoritative send against an epoch-0 daemon), an identical rebuild +records the epoch it inspected, and the summary inserts its new key +even when payload equality withholds the message; Rust units pin +single-recompute, which traffic assertions cannot see (finding 5). +`SearchView::new` takes `Option` — a bare core +constructs an unthemed view, no contradiction (finding 6). +`ThemeFacts` is appended after `CompletionPopup`, and a byte-level +encoding pin of the final existing variant guards the placement — postcard +discriminants are ordinal, and the new variant's own round-trip +cannot detect a shift that would corrupt v15 peers (finding 7). The +malformed-merge Lua acceptance pins only the order-independent user +contract; the deterministic bite is a Rust unit on an ordered result +stream, since Lua table iteration order is unspecified +(finding 8). `ui.selection` themes the local selection only: the +GPU splits its resolution path so peer selection rects keep the +constant, and peer theming stays deferred with the peer-cursor +palette (finding 9). + +Revision 2 (framing round 1, findings 1–8): the theme mutation +contract is transactional and monotonic — all four mutators parse +and validate their full input before taking the lock, commit +all-or-nothing, and bump from the prior counter value after every +successful commit; `set` replaces the `by_capture` field, never the +`Theme` value, so counters survive wholesale replacement (finding 1). +The counter is split into `syntax_epoch` (keys the `StyleGate`) and +`face_epoch` (keys the `ThemeFacts` producer), so a face-only edit +never re-runs the tree-sitter span query; the minimap summary keys on +both — diag faces feed its marks — and gains payload-equality +suppression, so a non-diag face edit recomputes once per mutation and +emits nothing (finding 8). Stage 2 takes protocol v17: once a v16 +binary ships, a variant added under v16 would let two incompatible +v16 schemas negotiate successfully (finding 2). Face application is +unified as "a set face owns its surface" — both frontends replace +the site default wholesale, with `Default` components meaning that +frontend's *plain* rendering (GPU mapping table in Q#TH5); the +finding-5 divergences (`{ fg }` keeping the GPU band bg, empty +`ui.selection` diverging) are gone, and the residual divergences +(`Indexed` palette meaning; GPU chrome honoring fg/bg/reverse only in +stage 1) are named and tested. `ThemeFacts` is authoritative per +attachment: the baseline seeds to `None`, every attachment receives +exactly one table — empty included — after viewport declaration, so +a frontend retaining face state across attachments is corrected even +by an unthemed daemon (finding 4; the current GPU has no reconnect +path, so this is contract, not accident). GPU ThemeFacts application +invalidates the status-band shaping cache — `E:`/`W:` colors are +baked into shaped rich text and re-shaping is gated on string +equality, so a diag-face change with constant counts kept stale +colors (finding 3). The grid views get a real theme path: +`EditorCore` gains an injected `ThemeHandle` for +`ensure_search_overlay`, and `install_diag` threads the handle to +`DiagnosticView`; acceptance drives the dispatched-search and Lua +diagnostic-attachment paths, not bare constructors (finding 6). The +reserved namespace covers the bare root `ui` as well as `ui.*`, +matching the inheritance walk's terminal (finding 7). Acceptance +adds consecutive-`set` monotonicity, malformed-merge atomicity, GPU +diag-recolor-with-constant-counts, owns-surface cross-frontend +semantics, and absence assertions for face-only mutations. + +Revision 1: initial framing. + +Arc 4 ("Themes + extensibility surface", `docs/roadmap-2026-07.md:83`) +names three deliverables: named UI faces wired to both frontends, a +`pmacs.gpu.set_font` runtime font swap, and a Lua statusline-segment +API. This framing covers **stage 1 only — named UI faces plus the +`ThemeFacts` wire channel (protocol v15→16)**. Font swap and +statusline segments are stages 2 and 3, each with its own framing +(Q#TH1). One branch (`theme-faces`), one PR. + +## Ground truth (as of `13dbadd`) + +### The theme substrate today + +- `Theme { by_capture: HashMap, default_style: Style }` + — `src/highlight.rs:59-67`. One global instance behind + `ThemeHandle = Arc>` (`highlight.rs:190`), owned by the + `SyntaxRegistry` (`src/syntax.rs:496`, seeded `Theme::default_dark()` + at `:518`, exposed via `.theme()` at `:527`; line refs re-verified + after the #114/#115/#116 highlight-stack merges, whose bundled CUDA + and shell grammars introduce no `ui`-named capture). +- `Theme::lookup` (`highlight.rs:161-172`) tries the full dotted name, + strips one `.`-segment at a time, and **falls back to + `default_style`** — correct for syntax captures, wrong for faces + (an unset face must mean "keep today's hardcoded look", which is + not expressible as a `Style` on the semantic path). +- Lua surface is complete and mounted: `pmacs.theme.{set, merge, get, + clear, default, current}` — `src/lua_bindings/mod.rs:6898-6993`. + `set` replaces `by_capture` wholesale but preserves `default_style` + (`:6913-6915`). Style/color marshalling: `lua_to_style` `:6865`, + `lua_to_color` `:6780` (nil/"default" → `Color::Default`, 0..=255 → + `Indexed`, `{r,g,b}` → `Rgb`). +- Mutator shapes today: `set` parses the whole table into a scratch + `Theme` *before* locking, then wholesale-replaces the locked value + (`:6906-6915`) — already transactional, but the replacement would + reset any naive counter carried on `Theme`. `merge` locks first and + inserts while iterating (`:6925-6931`) — a malformed later entry + errors out with earlier entries already applied. +- Wire vocabulary already suffices: `Color { Default, Rgb, Indexed }` + and `Style { fg, bg, bold, italic, underline, reverse, + underline_color }` — `pmacs-protocol/src/cell.rs:84-133`. No alpha + channel (relevant for GPU washes, Q#TH8). +- Theme colors already cross the wire *indirectly*: `scoped_style_spans` + (`src/semantic_render.rs:1501-1566`) clones the theme, resolves + capture → `Style`, and ships `StyleSpans`. Nothing chrome-related + crosses at all. + +### Chrome colors today — every one a hardcoded literal + +Grid path (daemon paints cells for the TUI; `paint_frame`, +`src/editor.rs:2086`, has `&EditorState` and therefore theme access): + +| Site | Where | Literal | +|---|---|---| +| Per-window mode line | `editor.rs:2474` (`paint_mode_line`) | `reverse: true` (`:2499-2502`) | +| Status/echo row | `editor.rs:2260` (`paint_status_line`) | `reverse: true` (`:2275-2286`) | +| Line-number gutter | `editor.rs:2320` | `fg: Indexed(8)` (`:2332-2335`) | +| Selection | `editor.rs:2375` | `cell.style.reverse = true` (`:2431`) | +| Minibuffer prompt/input/fill | `editor.rs:2541` | `Style::default()` (`:2571`, `:2588`, `:2613-2617`) | +| Minibuffer candidate suffix | `editor.rs:2605-2608` | `reverse: true` | +| Search prompt row | `editor.rs:2629` | `Style::default()` (`:2659`, `:2677`) | +| Search matches | `src/search.rs:362-378` | bg `Indexed(3)`/fg `Indexed(0)`; active bg `Indexed(11)` | +| Diagnostic severity color | `src/diag.rs:90-97` | `Indexed(1)/(3)/(6)/(8)` — shared by squiggle `underline_color`, column-0 marker, minimap mark | + +GPU path (frontend-local constants, `pmacs-gpu/src/main.rs`): + +| Site | Where | Literal | +|---|---|---| +| Status band bg | `:130` | `STATUS_BAND_BG [0.105, 0.105, 0.145, 1.0]` | +| Band left text (name/prompt) | `:4511` | `rgb(200,200,210)` | +| Band right readout | `:4496` | `rgb(168,168,180)` | +| Band `E:`/`W:` counters | `:3401`, `:3407` | `rgb(241,76,76)`, `rgb(245,245,67)` | +| Gutter digits | `:4534` | `rgb(120,120,135)` | +| Selection wash | `:6661` | `[0.31, 0.42, 0.82, 0.30]` | +| Search washes | `:6673-6674` | `[0.85,0.78,0.20,0.30]`, `[0.95,0.55,0.12,0.48]` | +| Diag squiggles/signs | `:6606-6623` | Error `[0.945,0.298,0.298]`, Warning `[0.961,0.961,0.263]`, Info `[0.231,0.557,0.918]`, Hint `[0.4,0.4,0.4]` | +| Surface clear / caret / menu / minimap | `:61-66`, `:73-74`, `:145-147`, `:111-114` | out of stage-1 scope (Deferred) | + +`Indexed` colors mean different things per frontend: the TUI defers +to the user's terminal palette; the GPU has its own hardcoded xterm +table (`indexed_to_glyphon`, `main.rs:6552-6587`). A face set with +`Indexed` colors will render per-frontend, same as syntax styles do +today — documented, not fixed here (Deferred). + +### The facts-channel template (LineNumbers, Q#UX1) + +- `PROTOCOL_VERSION = 15` (`pmacs-protocol/src/message.rs:1333`); + `SUPPORTED_PROTOCOL_VERSIONS = [6..=15]` (`:1393`). Standard bump + shape: additive variant + daemon write-loop gate on + `negotiated_protocol_version` (`src/daemon.rs:1119-1121`, + `:1153-1157`); old peers receive nothing on the channel. +- Producer pattern: cached-compare field on `SemanticRenderState`, + emit-on-change from `render_frame`'s emission list + (`src/semantic_render.rs:462-478`; `line_numbers_msg` `:810-825`). + Nothing emits before the frontend declares a viewport (`:307-310`). +- Late join is free: per-session state is built fresh at attach + (`daemon.rs:1377-1381`), and a baseline seeded to the *frontend's + default belief* re-emits exactly the non-default state + (`semantic_render.rs:265-269`). `InlineAdornments` additionally + suppresses "nothing to say, nothing ever sent" (`:297-304`). +- TUI drops the whole family silently (`src/frontend.rs:380-422`) — + its chrome arrives pre-painted in the cell grid. +- Pin tests updated every bump: `assert_eq!(PROTOCOL_VERSION, …)` at + `src/protocol.rs:1710`, the resume-ladder test at `:1774-1797` + (accepted range and the rejected next version), and a postcard + round-trip pin per new variant. +- The dormant `InstanceMessage::ModeLine(Vec)` (`message.rs:506`) + is cell-shaped, unproduced, and not what this arc needs; it stays + dormant. + +### Pre-existing staleness bug (this arc fixes it) + +`StyleGate` (`semantic_render.rs:226-246`) keys the `StyleSpans` +recompute on (parse-bundle `Arc`, CRDT generation, viewport) — never +the theme. `file_style_summary_msg` similarly keys the minimap +summary on `(generation, diag_epoch)` (`:914`). A mid-session +`pmacs.theme.set` therefore re-ships **nothing** to a semantic +frontend until the next buffer edit: the GPU keeps stale colors. The +grid path is immune (`SyntaxHighlightView` clones the theme every +render, `highlight.rs:316`), as is the ungated LSP-token span path. +There is no theme mutation counter anywhere in `src/`. Q#TH6 +introduces two (syntax/face) and folds them into the gates. + +## Decisions + +### Q#TH1 — Scope: stage 1 of Arc 4; faces + wire only + +This PR delivers named UI faces resolved daemon-side, applied by both +frontends, controlled through the existing `pmacs.theme` surface, and +shipped to semantic frontends over a new v16 `ThemeFacts` channel. +`pmacs.gpu.set_font` (stage 2) and the statusline-segment API +(stage 3) are deferred with named hooks: **stage 2 takes protocol +v17** — once a v16 binary exists, adding another variant under v16 +would let old and new v16 peers negotiate successfully despite +incompatible schemas (postcard is not self-describing); reusing 16 +is possible only if stage 2's wire shape lands in this same PR, +which it does not. The segment API lands after faces so segments can +carry face names instead of raw colors. + +### Q#TH2 — Faces are theme entries under the reserved `ui` / `ui.*` namespace + +A face is an entry in the existing `by_capture` map whose name is +exactly `ui` or starts with `ui.` -- no new type, no second map, no new +Lua API. +`pmacs.theme.merge { ["ui.modeline"] = { bg = 236, fg = 252 } }` +works today, unmodified. The face predicate is one explicit +function, used by every consumer — the docs reservation, Q#TH6's +counter-bump classification, and the producer's key filter: + +```rust +fn is_face_name(name: &str) -> bool { + name == "ui" || name.starts_with("ui.") +} +``` + +The bare root `ui` is included because Q#TH4 makes it the walk's +deliberate catch-all — a `merge { ui = … }` must bump `face_epoch` +and emit `ThemeFacts`, not disappear into the syntax path. No +tree-sitter capture or LSP token type is named `ui` or `ui.*` +(verified against `default_dark` and the grammar queries). +`pmacs.theme.get("ui.x")` keeps its +existing `lookup` semantics (resolves through to `default_style`) — +introspection tolerates that; rendering does not (Q#TH4). + +### Q#TH3 — Stage-1 face inventory: twelve faces + +The roadmap's five surfaces, plus the two chrome families that +already have daemon-side palettes (search, diagnostics), plus the +minibuffer's one sub-face. Effects per frontend (unset = exactly +today's literal, from the Ground-truth tables): + +The **mask** column is the stage-1 component contract of Q#TH5: +identical on both frontends, sized to what both can render today. + +| Face | Mask | Grid/TUI effect | GPU effect | +|---|---|---|---| +| `ui.modeline` | fg bg reverse | per-window mode-line row | status-band bg quad; band name + readout text | +| `ui.statusline` | fg | bottom status/echo row | echo-message segment text | +| `ui.minibuffer` | fg | prompt + input + fill; search prompt row | minibuffer/isearch band text | +| `ui.minibuffer.candidate` | fg | inline candidate suffix | minibuffer-dropdown candidate glyphs | +| `ui.gutter` | fg | line-number strip | gutter digit text | +| `ui.selection` | bg | local-region cells (wash) | local-selection wash RGB | +| `ui.search.match` | bg | lazy-match cells (wash) | match wash RGB | +| `ui.search.match.active` | bg | active-match cells (wash) | active wash RGB | +| `ui.diag.error` | fg† | severity color | squiggle + gutter sign + band `E:` counter | +| `ui.diag.warning` | fg† | severity color | squiggle + sign + `W:` counter | +| `ui.diag.info` | fg† | severity color | squiggle + sign | +| `ui.diag.hint` | fg† | severity color | squiggle + sign | + +† diag faces carry the special `Default` policy of Q#TH5: `Default` +fg means the built-in severity color, not "plain". + +The GPU status band is the mode-line analogue (one band, no separate +echo row — a structural divergence this arc does not resolve), so it +takes `ui.modeline` for its surface and `ui.statusline`/`ui.minibuffer` +only for the text of the corresponding *content* it happens to be +showing. `ui.minibuffer.candidate` colors the existing dropdown's +candidate glyphs only; its background and selected-row quad remain +popup chrome. Deliberately excluded from stage 1 (all named in Deferred): +popup/menu/completion-dropdown backgrounds and selection quads, +completion/menu glyph faces, `ui.background`, `ui.caret`, +`ui.modeline.inactive`, minimap chrome, peer-cursor palette, +inlay-hint face. + +### Q#TH4 — Resolution: `Theme::face()`, `None` when unset, never `default_style` + +New method on `Theme`: + +```rust +pub fn face(&self, name: &str) -> Option