From 6ce1532699410421c249369498338c65c7ffad92 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Tue, 14 Jul 2026 13:09:30 +0100 Subject: [PATCH] docs: theme-faces framing (Arc 4 stage 1) at revision 4 Named UI faces under the reserved ui/ui.* theme namespace, resolved daemon-side and shipped to semantic frontends over a new ThemeFacts channel (protocol v15 -> 16). Twelve stage-1 faces with per-face component masks identical on both frontends; owns-surface-within-mask application; two monotonic theme mutation counters with transactional mutators (fixes the pre-existing mid-session StyleGate staleness bug); authoritative-per-attachment facts baseline; GPU status-band shaping-cache invalidation. Revisions 2-3 fold framing review rounds 1-2 (17 findings); revision 4 folds the user's round 3 (7 findings: candidate-dropdown GPU site, CompletionPopup byte pin, production transaction helper, empty-diag-child reset semantics, exhaustive GPU route acceptance, exact predicate wording, integration-shaped peer-selection test). Ground truth re-pinned at 3cbb9de after PR #114 moved syntax.rs. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01VoiEyuPjoBhvwACf8HAnLB --- docs/theme-faces-framing.md | 818 ++++++++++++++++++++++++++++++++++++ 1 file changed, 818 insertions(+) create mode 100644 docs/theme-faces-framing.md diff --git a/docs/theme-faces-framing.md b/docs/theme-faces-framing.md new file mode 100644 index 0000000..0d0ca87 --- /dev/null +++ b/docs/theme-faces-framing.md @@ -0,0 +1,818 @@ +# 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