# Theme faces — framing (Arc 4 stage 1, themes) **Revision 4 — 2026-07-14. Status: awaiting approval; folds framing rounds 1–3.** 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 `3cbb9de`) ### 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:478`, seeded `Theme::default_dark()` at `:500`, exposed via `.theme()` at `:509`; line refs re-verified after PR #114's syntax.rs changes, whose bundled CUDA grammar also introduces 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