# 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