# 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<u64>` (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<ThemeHandle>` — 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<String, Style>, default_style: Style }`
  — `src/highlight.rs:59-67`. One global instance behind
  `ThemeHandle = Arc<Mutex<Theme>>` (`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<Cell>)` (`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<Style>
```

Same dotted-prefix walk as `lookup`, but the walk returns `None` when
nothing matches instead of falling back to `default_style` — an
unset face must leave the paint site's hardcoded default untouched,
and a user's `pmacs.theme.default` (a *syntax* fallback) must never
bleed into chrome. The walk gives inheritance for free:
`ui.search.match.active` falls back to `ui.search.match`, the four
`ui.diag.*` to `ui.diag`, and everything to a deliberate catch-all
`ui` (a theme can tint all chrome in one entry; a consequence of
uniform walk semantics, not a special case). Callers pass full face
names only.

### Q#TH5 — A set face owns its surface, within its component mask

Wire `Style` cannot distinguish an omitted component from an
explicitly-requested default, so per-component fallback to the
site's old literal would diverge across frontends (`ui.modeline =
{ fg = 252 }` would drop the TUI's reverse video while the GPU kept
its band background; an empty `ui.selection` would disable the TUI
wash but leave the GPU wash intact). And an unmasked wholesale rule
is not implementable either: the GPU gutter has only a foreground
text layer (`main.rs:4522`), and the selection/search washes are
background quads that can neither recolor nor reverse glyphs
(`main.rs:4801`) — an unrestricted contract would make
`ui.selection = { fg = 1 }` recolor TUI glyphs with no GPU
counterpart. The unified rule:

- **Unset face** (resolution `None`): the site's hardcoded default,
  both frontends, byte-identical to today.
- **Set face**: the surface resets to plain, and the face's
  components **within the face's mask** (the Q#TH3 table) apply.
  Components outside the mask are ignored on BOTH frontends — the
  mask is the cross-frontend contract, sized to the intersection of
  what the two render paths can express today, so equivalence is
  structural rather than aspirational. `ui.selection = { fg = 1 }`
  is a component no-op on the TUI too (an effectively-empty wash
  face: wash disabled on both); `ui.gutter = { bg = 1 }` likewise
  renders as an all-default gutter face on both. Widening a mask
  later (GPU gutter background layer, wash glyph recolor/reverse,
  chrome bold/italic/underline) is additive and named in Deferred.
- **`Default` within the mask** means *that frontend's plain
  rendering*, never "the old chrome look". On the TUI, plain =
  terminal defaults. On the GPU, a fixed mapping: `Default` fg ↦
  the buffer-text default `rgb(230,230,235)` (`main.rs:4482`);
  `Default` bg ↦ the window background (an untinted band quad; no
  wash quad); `reverse` (in-mask only for `ui.modeline`) swaps
  fg/bg after the mapping. `ui.modeline = { fg = 252 }` therefore
  yields "plain background, color-252 text" on both frontends, and
  `ui.selection = {}` disables the selection wash on both —
  consistent, if deliberately drab; the user asked for an
  all-default surface.
- **Wash faces** (`ui.selection`, `ui.search.match`,
  `ui.search.match.active`) remain a *composition* class: the
  masked face style (bg only, stage 1) replaces the default
  **overlay** argument of the existing `merge_styles(cell, overlay)`
  (`src/overlay.rs:136` — non-`Default` components win, booleans
  OR), then merges over syntax-styled cells as today, so a wash
  never touches glyph fg in stage 1. The unset defaults remain the
  full overlay literals of today (`Style { reverse: true, .. }` for
  selection; the search literals as-is — the mask constrains *face*
  application, not the unset defaults). On the GPU the wash quad
  takes the face `bg` RGB with the site's current alpha — wire
  `Style` has no alpha; translucency is a rendering choice and
  stays frontend-local (Q#UX1 division).
- **`ui.selection` themes the local selection only.** The GPU
  currently colors peer selection rects through the same
  `DecorationKind::Selection` helper (`main.rs:4946`); the
  implementation splits that resolution path — the local path
  resolves the face, the peer path keeps the constant — so peer
  rendering stays outside the theme system until the deferred
  peer-cursor palette arc. (The TUI peer path, `overlay_paint.rs`,
  is already separate.)
- **`ui.minibuffer.candidate` has one GPU effect in stage 1:** its
  masked `fg` is the `default_color` of the existing minibuffer
  dropdown candidate `TextArea` (`main.rs:4594-4610`). This does not
  theme the popup background or selected-row quad; those remain on
  their current constants and in Deferred. On the grid path the same
  face colors the inline selected-candidate suffix.
- **Diag faces**: the `fg` component is the canonical severity color
  (the `diag.rs:84-97` contract: squiggle `underline_color`, marker,
  minimap mark all share it), with a face-family-specific `Default`
  policy: **`Default` fg means the built-in severity color**, not
  plain. The severity color doubles as the *presence* encoding in
  the minimap summary — `scoped_file_summary` copies it into the
  line's `underline_color` (`semantic_render.rs:1779`), and the GPU
  reads `underline_color == Default` as "no diagnostic mark"
  (`main.rs:5551`) — so a plain severity color is unrepresentable
  and `ui.diag.error = {}` explicitly resets that child to the
  built-in error color on every surface (squiggle, sign, minimap, band
  counter). It is not absent: an exact empty child stops Q#TH4's walk,
  so it overrides an inherited `ui.diag` color with the built-in.
  Scope of "built-in" (round 1 finding 4): a SET face with a concrete
  color unifies all surfaces on both frontends, but the per-surface
  built-in DEFAULTS keep their pre-existing values — the TUI and the
  wire summary use `Indexed(1/3/6/8)`, while the GPU's squiggle,
  sign, and counter constants are historical bright RGBs that differ
  from the minimap's converted `Indexed` marks. That divergence
  predates this arc and stays, because unset faces must render
  byte-identically to before.

Residual divergence, accepted and tested: `Indexed` colors mean the
user's terminal palette on the TUI and the `indexed_to_glyphon`
table on the GPU (pre-existing; identical to syntax styles today).

### Q#TH6 — Two monotonic mutation counters; transactional mutators

`Theme` gains `syntax_epoch: u64` and `face_epoch: u64` (both 0 at
construction), owned by the same mutex. The mutation contract for
the four Lua bindings — the only mid-session mutation paths:

1. **Parse before locking.** The production `set`/`merge` transaction
   helper accepts a commit mode plus an ordered iterator of
   `mlua::Result<(String, Style)>`, collects the whole iterator into
   `mlua::Result<Vec<(String, Style)>>`, and only after `Ok(Vec)`
   acquires the theme mutex and commits in the requested mode. The Lua
   table iterator maps each raw `(name, style_table)` entry through
   `lua_to_style` into that result stream — whose own lookups
   PROPAGATE errors (round 1 finding 2): `Table::get` runs `__index`,
   so a raising metatable fails the transaction rather than silently
   parsing as an all-default style; boolean fields keep Lua
   truthiness by design. `default` parses its one
   `Style` before locking; `clear` has no input to parse. `set`
   already parses into a scratch value pre-lock; `merge` today
   inserts while iterating under the lock (`mod.rs:6925-6931`), so a
   malformed later entry currently leaves earlier entries applied —
   that shape is disallowed.
2. **Commit all-or-nothing.** Every entry applies, or the binding
   errors with the theme untouched (and therefore no counter bump
   and no downstream emission).
3. **Bump from the prior value after every successful commit.**
   `set` replaces the `by_capture` *field* — never `*theme =
   new_theme`, whose zeroed counters would let consecutive `set`
   calls share an epoch and stay invisible to every gate — then
   bumps; counters only ever increment. Per mutator: `set` and
   `clear` bump both counters; `merge` classifies every committed
   key through `is_face_name` (Q#TH2 — bare `ui` is a face key) and
   bumps `syntax_epoch` iff any non-face key committed, `face_epoch`
   iff any face key did; `default` bumps `syntax_epoch` only.

Consumers:

- `StyleGate` gains a `syntax_epoch` field, compared in `matches()`
  — a capture recolor forces the tree-sitter span recompute and the
  changed spans ship (the fix for the pre-existing GPU staleness
  bug, bite-able on its own: mid-session `pmacs.theme.set` with no
  buffer edit must produce a `StyleSpans` re-emission). A face-only
  edit leaves `syntax_epoch` untouched and never re-runs the query.
- `file_style_summary_msg`'s cache key becomes `(generation,
  diag_epoch, syntax_epoch, face_epoch)` — `face_epoch` belongs in
  the key because `ui.diag.*` feeds the minimap marks — **plus a
  payload-equality suppression**: a face edit that leaves the
  summary unchanged (e.g. `ui.modeline`) recomputes it once per
  mutation, not per tick, and emits nothing. The cache key advances
  on *computation*, not on emission — the suppressed send still
  inserts the new key, or the recompute repeats every tick and the
  performance contract is a fiction. Rust units pin the
  single-recompute (by asserting the advanced cache state); traffic
  assertions cannot see repeated computation.
- The `ThemeFacts` producer gates on `face_epoch` (Q#TH7).

The LSP-token span path and the whole grid path already re-resolve
per tick/frame and need nothing.

### Q#TH7 — `ThemeFacts` channel: bufferless resolved-face table, protocol v15→16

```rust
/// Themes arc (Q#TH7, protocol v16). The daemon-resolved UI faces.
/// The theme is one global instance, so this is bufferless (the
/// `MinibufferPrompt` shape). Complete replacement each send: a
/// face absent from `faces` is unset, and the frontend uses its
/// own default for that surface. Cached-compare suppressed;
/// daemon-gated `>= 16`.
ThemeFacts {
    /// Every stage-1 face that resolves to a style (Q#TH4 walk),
    /// full names, sorted by name for deterministic comparison.
    faces: Vec<ThemeFace>,
},

pub struct ThemeFace {
    pub name: String,
    pub style: crate::cell::Style,
}
```

- **Resolution is daemon-side**: the producer walks the known face
  list (the Q#TH3 twelve) through `Theme::face()` and ships concrete
  entries — frontends do exact-name lookup, no walk, no inheritance
  logic. The daemon owns face semantics; frontends own pixels
  (Q#UX1). No epoch on the wire — the payload is self-contained and
  the house facts style carries no counters.
- **The producer is peer-version-aware** (round 1 finding 3):
  daemon sessions construct via `SemanticRenderState::for_peer`, and
  below v16 no `ThemeFacts` is produced at all — more importantly,
  no `ui.diag.*` face is folded into the `FileStyleSummary` marks,
  because that summary is an ungated pre-v16 channel and a v15 peer
  must not receive face-derived minimap colors while its squiggles,
  signs, and counters stay unthemed. The write-loop gate remains as
  the belt-and-braces filter. The summary cache key zeroes its
  face-epoch component for such peers (no whole-file recompute per
  face edit that could never change their marks).
- **Producer**: `theme_facts_msg` on `SemanticRenderState`, called
  from the `render_frame` emission list. Per-tick cheapness comes
  from a `last_face_epoch: Option<u64>` gate **seeded `None`** —
  `Option`, not a bare zero, because an unthemed daemon sits at
  `face_epoch == 0` and a `0 == 0` short-circuit would starve the
  first authoritative send; the gate can only short-circuit once it
  has recorded an inspected epoch. Emission truth comes from
  comparing the rebuilt table against `last_theme_faces`, also
  seeded `None`. Both records advance on *computation*: an
  identical rebuild (e.g. after a payload-equal re-merge) records
  the epoch it inspected even though nothing ships, so subsequent
  ticks skip — a Rust unit pins the no-rebuild second tick. Every
  attachment receives exactly one authoritative table — the empty
  table included — with its first emission after viewport
  declaration, cached-compare suppressed thereafter. One small
  message per unthemed session buys a reconnect-safe contract: a
  frontend that retains face state across attachments is corrected
  even by an unthemed daemon, instead of depending on every frontend
  starting empty. (The current GPU has no reconnect path — a
  disconnect only swaps the displayed text, `main.rs:1435` — so this
  is contract, not a load-bearing assumption about today's client.)
- **Variant placement**: `ThemeFacts` is **appended after
  `CompletionPopup`** (`message.rs:961`), the current final
  `InstanceMessage` variant. Postcard discriminants are ordinal — a
  variant inserted earlier shifts every later variant's tag and
  silently corrupts v15 peers on channels that are *not* gated, and
  the new variant's own round-trip test cannot detect the shift. A
  byte-level encoding pin of `CompletionPopup` -- the final existing
  variant, so any insertion before any v15 variant moves its ordinal --
  asserts its serialized bytes, discriminant included, are unchanged
  by the v16 build.
- **Gating**: `PROTOCOL_VERSION` 15→16 with a ladder paragraph;
  `SUPPORTED_PROTOCOL_VERSIONS` grows 16; daemon write-loop
  `peer_knows_theme_facts = negotiated_protocol_version >= 16` +
  skip arm; TUI silent-drop arm (`frontend.rs:380-422`); GPU
  debug-name arm (`main.rs:5606-5628`); `ThemeFace` re-exported from
  `pmacs-protocol/src/lib.rs`; pin tests at `protocol.rs:1710` and
  the ladder test (accept 16, reject 17); postcard round-trip pin.
- **Snapshot/baseline reset contract** (round 2 finding 1): a
  `BufferSnapshot` resets the receiving frontend's buffer-scoped
  render state wholesale, so wherever the daemon writes one it calls
  `SemanticRenderState::on_buffer_snapshot_sent(buffer_id)`, killing
  every buffer-scoped emission baseline for that buffer — spans +
  style gate, decorations, adornments, summary, status, search/menu
  prompts, completion popup. Without this, an unchanged-generation
  A → B → A round trip suppressed every re-send and the frontend
  never regained A's themed minimap or `StatusFacts`. Call sites:
  the active-buffer-follow path and the F29 upgrade broadcast; the
  attach bootstrap constructs its session state fresh, so its sweep
  needs no call. NOT reset: `last_face_epoch` / `last_theme_faces`
  (this channel is bufferless — the frontend keeps its face table
  across snapshots), the global minibuffer baseline, the
  per-frontend gutter mode, the revision-keyed diag line cache, and
  other buffers' baselines (each buffer's own snapshot precedes its
  revisit). The diagnostic-count freeze needs no protection here
  (rounds 3–4): it is sourced from the diag store's retained vector
  and its cached per-URI severity tuple — `mark_stale` keeps the last
  published diagnostics and counts, which merely lag — never from
  session state, so the reset cannot zero mid-edit counts and the
  freeze holds even for a session attaching during staleness. The
  producer reads the tuple in O(1), rather than scanning the retained
  vector for every semantic session at frame cadence (round 5).
  Resetting on a failed write is harmless — the
  failure mode is one redundant re-send, never staleness. The GPU's `BufferSnapshot` arm mirrors the contract
  (round 3 finding 1): it clears its buffer-scoped facts —
  search/menu popups (which gate key and pointer interception) and
  the status band — alongside spans, decorations, adornments,
  summary, and the completion popup; the minibuffer survives on
  both sides.
- `docs/semantic-frontend-protocol.md` gains the channel's contract
  section.

### Q#TH8 — GPU application: exact-name map, Q#TH5 mapping, shaping-cache invalidation

`State` gains `faces: HashMap<String, Style>`; the
`apply_attach_message` arm replaces the map, **invalidates the
status-band shaping cache**, and `request_redraw()`s (extending the
`LineNumbers` arm shape, `main.rs:2686-2692`). The invalidation is
load-bearing, not belt-and-braces: the `E:`/`W:` counter colors are
baked into glyphon rich-text attributes at compose time
(`compose_status_spans`, `main.rs:3390-3441`), and
`refresh_status_line` skips re-shaping whenever the composed strings
are unchanged (`main.rs:3502-3546`) — without clearing those cached
comparison strings, a diag-face change with constant counts keeps
stale counter colors indefinitely.

A newly accepted `FileStyleSummary` drops the cached minimap
vertices (round 1 finding 1): theme recolors and diagnostic
republishes ship a new summary at an UNCHANGED generation, and the
minimap cache keys only on (generation, dims, scroll) — without the
explicit invalidation the stale strokes survive until an edit,
resize, or scroll. The daemon payload-suppresses identical
summaries, so every accepted one is genuinely new.

Each themed site resolves per draw with the Q#TH5 rule: face absent
→ today's site constant; face present → the face's **in-mask**
components, with `Default` fg ↦ the buffer-text default, `Default`
bg ↦ the window background (untinted quad / no wash quad),
`reverse` (`ui.modeline` only) as a post-map swap, and the diag
`Default`-fg ↦ built-in-severity-color policy. Out-of-mask
components are never read. `Rgb` maps directly; `Indexed` through
the existing `indexed_to_glyphon` table; quad colors convert u8 RGB
into the same color space the current float constants use;
band/wash alphas keep their current values. Peer selection rects
resolve through the split constant path, never the face (Q#TH5).
The minibuffer candidate face supplies the existing dropdown
`TextArea.default_color`; `refresh_mb_buffer` already runs before each
dropdown paint (`main.rs:4337-4341`), and the default color is resolved
again when preparing the `TextArea`, so no new shaping-cache contract is
needed for its fg-only mask.

### Q#TH9 — Grid application: one theme clone per frame; views gain the handle

`paint_frame` clones the `Theme` once per frame (single mutex lock,
same discipline as `SyntaxHighlightView::render`) and passes `&Theme`
down to `paint_mode_line` / `paint_status_line` / `paint_minibuffer` /
`paint_search_prompt` / `paint_line_number_gutter` /
`paint_local_selection` (signature additions; `paint_mode_line` takes
the resolved `Style` since it is a pure formatter). `SearchView` and
`DiagnosticView` gain a `ThemeHandle` constructor parameter and
resolve faces under the lock per render — the exact
`SyntaxHighlightView::new` precedent (`highlight.rs:246`).

Ownership, named: `SearchView` is constructed inside
`EditorCore::ensure_search_overlay` (`src/editor_core.rs:858`), and
`EditorCore` owns no syntax state — so `EditorCore` gains a
`theme: Option<ThemeHandle>` field, injected once at editor bring-up
right after `SyntaxRegistry` construction, and
`SearchView::new(store, theme: Option<ThemeHandle>)` takes the
`Option` directly: `ensure_search_overlay` passes the field through
unconditionally, so a bare core (unit-test construction, field
`None`) still constructs a working view that resolves no faces and
paints today's literals — no contradiction between "views take the
handle" and "bare cores have none". `DiagnosticView`
is attached by the Lua path — `install_diag`
(`src/lua_bindings/diag.rs:46`, construction at `:220`) — which gains
the handle as a parameter, threaded from the installer call site
(`mod.rs:8626`). Acceptance exercises the real attachment paths: a
dispatched `C-s` reaching `ensure_search_overlay`, and the Lua
diagnostic attachment — never bare constructors. The completion
popup and menu keep their literals (faces deferred).

### Q#TH10 — `pmacs.theme.set` wholesale semantics include faces

`set` replaces the whole `by_capture` map today and therefore wipes
faces along with captures; `merge` is the incremental path. This is
kept unchanged — a "theme" legitimately includes its chrome, and a
theme-switching user wants no stale faces from the previous theme.
Documented in the Lua API docs alongside the `ui.` namespace
reservation.

## Bets

- The reserved-namespace design (Q#TH2) means zero new Lua API and
  zero protocol vocabulary beyond one variant — the whole feature
  rides existing `Style` plumbing.
- The authoritative-per-attachment contract (seed `None`) makes
  late-join and stateful reconnect correct by construction for the
  price of one small message per session; unthemed steady-state
  traffic remains zero.
- Daemon-side resolution keeps both frontends walk-free and makes
  face inheritance testable in one place.
- The split-counter fix (Q#TH6) closes the mid-session staleness bug
  at one-u64-compare per-tick cost, while face-only edits stay off
  the tree-sitter path entirely; the one whole-file summary
  recompute per face mutation is user-action-rate, not tick-rate.
- The owns-surface-within-mask rule (Q#TH5) is the only semantic
  expressible with the existing `Style` (no omitted-vs-default
  distinction) that renders equivalently on both frontends, and the
  masks make that equivalence structural — the contract promises
  only what both render paths can draw today, and widening a mask
  later is additive. Its cost — partial faces reset the rest of
  their surface to plain, and some components are simply not
  themable yet — is documented and falls only on users who theme.
- Unset faces reproduce today's rendering byte-identically on both
  frontends — the acceptance baseline tests pin this, so shipping
  stage 1 changes nothing for users who theme nothing.
- Twelve faces cover the roadmap's named surfaces; the deferred
  chrome (popups, background, caret) reuses the same substrate
  without another protocol bump.

## Deferred (named)

Popup/menu/completion-dropdown background, selected-row, and general
glyph faces (`ui.popup`, `ui.menu`, …; the fg-only
`ui.minibuffer.candidate` exception is in scope);
`ui.background` and `ui.caret` (GPU-only effect; background interacts
with the TUI's terminal-default philosophy and deserves its own
decision); `ui.modeline.inactive` (active/inactive split);
minimap chrome faces; peer-cursor palette theming
(`overlay_color.rs` — deliberately not on the wire today);
inlay-hint face (`ui.inlay_hint` — `semantic_render.rs:1137` names
the gap; its adornment producer needs the same epoch treatment when
it lands); alpha on the wire (washes keep frontend-local alpha until
a real theme needs to change it); unifying `Indexed` palette meaning
across frontends; named-theme registry / light-theme builtin /
persistence (no unified config surface yet — handoff §6); the
grid-vs-wire `default_style` asymmetry (uncaptured text: wire ships
nothing where the grid paints the base style — orthogonal to faces);
mask widening (GPU gutter background layer; wash glyph
recolor/reverse; a distinct GPU echo surface so `ui.statusline`
could carry bg; chrome bold/italic/underline — attribute re-shaping
that compounds the Q#TH8 shaping-cache invalidation);
`ui.selection` for peer selections (rides the deferred peer-cursor
palette; the GPU resolution path is already split);
`pmacs.gpu.set_font` (stage 2,
**protocol v17** per finding 2 — and the `pmacs-gpu-design.md:299`
claim that font customization needs no wire change is superseded by
the Q#UX1 lesson, to be corrected in that framing); Lua
statusline-segment API (stage 3 — should ship segments carrying face
names, hence after this arc).

## Acceptance

Suites: `tests/theme_faces_acceptance.rs` (grid-path rendering +
Lua surface + wire facts) and the existing pin/round-trip homes
(`src/protocol.rs`); GPU behavior in `pmacs-gpu` behind
`PMACS_REQUIRE_GPU=1` (frame-difference pattern, `main.rs:7877+`).
Keybinding-driven tests dispatch keys, never `pmacs.command.invoke`.

1. **Unset = today, byte-identical**: with neither a bare `ui` entry
   nor any `ui.*` entry, a
   painted frame (mode line, status row, gutter, minibuffer,
   selection, search matches, diag squiggle) is cell-for-cell
   identical to the pre-arc rendering; additionally a loud
   `pmacs.theme.default` (syntax fallback) leaks into **no** chrome
   cell (pins Q#TH4's `None`-not-`default_style`).
2. **Each surface face applies** (grid): setting `ui.modeline`,
   `ui.statusline`, `ui.minibuffer`, `ui.minibuffer.candidate`,
   `ui.gutter` each changes exactly its rows'/strip's cells to the
   masked face style on the next frame, per-cell asserted.
3. **Owns-surface semantics** (Q#TH5): `ui.modeline = { fg = 252 }`
   paints the grid mode line with fg 252 and **no** reverse video
   (partial face resets the surface to plain); `ui.selection = {}`
   leaves selected cells identical to unselected ones.
4. **Mask enforcement** (round 2 finding 1): `ui.selection = { fg = 1,
   reverse = true, bg = B }` renders exactly as `{ bg = B }` on both
   frontends — grid per-cell (glyph fg untouched under the wash),
   GPU frame identical to the bg-only variant; `ui.gutter = { bg =
   1, reverse = true, fg = F }` renders exactly as `{ fg = F }` on
   both.
5. **Wash faces merge**: `ui.selection` and both search faces
   compose over syntax-styled cells with `merge_styles` semantics —
   a bg-only wash keeps the syntax `fg` under the wash (per-cell
   fg+bg assertions, not any-styled-cell). Search asserted through a
   dispatched `C-s` reaching `ensure_search_overlay` (the real
   construction path), never a bare constructor.
6. **Diag faces**: `ui.diag.warning` recolors the squiggle
   `underline_color`, the column-0 marker, and the minimap mark,
   attached via the real Lua diagnostic path (`install_diag`);
   unset severities keep `Indexed` defaults. **Empty diag face**
   (round 2 finding 2, clarified in round 3): with no parent,
   `ui.diag.error = {}` renders identically to the built-in error face
   on every surface -- squiggle, sign, minimap mark (present, not
   vanished), band counter. With `ui.diag = { fg = C }` also set, the
   exact empty `ui.diag.error` child blocks inheritance and resets
   errors to the built-in color while warning/info/hint remain `C`.
7. **Inheritance**: with only `ui.diag` set, all four severities
   resolve to it; an explicit `ui.diag.error` then wins for errors
   only. Same for `ui.search.match.active` ← `ui.search.match`.
8. **Bare `ui` is a face** (round 2 finding 3):
   `pmacs.theme.merge { ui = … }` bumps `face_epoch` and ships a
   `ThemeFacts` table with every stage-1 face resolved to the
   catch-all, with **no** `StyleSpans` re-emission (the key
   classifies as face, not syntax).
9. **Mid-session recolor, no edit (the staleness bite)**: a semantic
   session with a grammar-backed buffer receives fresh `StyleSpans`
   after `pmacs.theme.set` changes a capture color with zero buffer
   edits — fails against pre-arc `StyleGate`. Twin for the minimap:
   `FileStyleSummary` re-emits on a diag-face change.
10. **Consecutive `set` monotonicity** (round 1 finding 1,
    observation shape per round 2 finding 4): `set` →
    render/observe one `StyleSpans` emission → `set` (different
    capture colors) → render/observe a second emission — fails if
    wholesale replacement resets the counters and the second `set`
    shares the first's epoch. Two mutations inside one dispatch
    legitimately coalesce: a companion assertion pins that
    back-to-back `set`s followed by one render yield one emission.
11. **Malformed merge is atomic**: the Lua acceptance pins the
    order-independent user contract — one `pmacs.theme.merge`
    carrying a valid and a malformed entry errors, every entry of
    that merge is absent afterwards, and no `ThemeFacts` or
    `StyleSpans` re-emission occurs. Lua table iteration order is
    unspecified (round 2 finding 8), so the deterministic bite drives
    the production transaction helper with an ordered iterator
    `[Ok((valid_name, valid_style)), Err(malformed)]`. The helper must
    return the error before acquiring the theme lock; the unit asserts
    the pre-existing map, `syntax_epoch`, and `face_epoch` are all
    unchanged. An implementation that locks and inserts while
    consuming the iterator fails deterministically.
12. **ThemeFacts emission discipline**: every v16 semantic attachment
    receives exactly one authoritative table after viewport
    declaration — the **empty table** for an unthemed session
    (`face_epoch` still 0; pins the `Option` gate of Q#TH7) — then
    silence; after `pmacs.theme.merge` of a face, exactly one
    message with the resolved sorted table; an identical re-merge
    emits nothing; `pmacs.theme.clear` emits the empty table.
13. **Face-only mutations stay cheap** (round 1 finding 8): after a
    `ui.modeline` merge, no `StyleSpans` and no `FileStyleSummary`
    re-emission occurs (absence assertions) while `ThemeFacts`
    ships.
14. **Caches advance on computation** (round 2 finding 5, Rust
    units): after an identical re-merge, `theme_facts_msg` rebuilds
    once and the next tick skips via the recorded epoch; after a
    non-diag face mutation, the summary recomputes once and its
    cache key advances despite the suppressed emission — asserted
    on the internal cache state, since traffic cannot show repeated
    computation.
15. **Resolution is daemon-side**: with only `ui.diag` set, the
    shipped table contains the four concrete `ui.diag.*` entries
    (frontends never walk).
16. **Late join**: faces set via init.lua/first session; a second
    semantic frontend attaching later receives the face table among
    its first frames without any mutation occurring post-attach.
17. **Version gate and placement**: a v15 peer session never
    receives `ThemeFacts` (daemon skip arm); pin tests updated —
    `assert_eq!(PROTOCOL_VERSION, 16)`, ladder accepts 6..=16 and
    rejects 17, `ThemeFacts` postcard round-trip pin, **and a
    byte-level encoding pin of `CompletionPopup`**, the final v15
    variant, proving the appended placement shifted no existing
    discriminant (round 2 finding 7, corrected in round 3).
18. **TUI drop arm**: grid-session frontend consumes a `ThemeFacts`
    message without error (silent drop, family test pattern).
19. **`set` wipes faces** (Q#TH10): `pmacs.theme.set` with a
    captures-only table removes prior faces — next frame chrome is
    back to defaults and `ThemeFacts` ships the empty table.
20. **GPU surface/text routes** (`PMACS_REQUIRE_GPU=1`): an empty
    `ThemeFacts` table renders identically to never-themed. A
    `ui.modeline` bg changes the band, and a modeline face with
    `reverse = true` swaps the sampled band/text colors after Default
    mapping. A `ui.gutter` fg changes only the gutter digits. With the
    composed strings held constant, `ui.statusline` recolors a
    transient-message left segment, while `ui.minibuffer` recolors
    both a live minibuffer and an isearch band without recoloring the
    ordinary buffer-name state. `ui.minibuffer.candidate` recolors the
    actual dropdown candidate glyph region while leaving its popup
    background and selected-row quad at their constants.
21. **GPU wash routes** (`PMACS_REQUIRE_GPU=1`): a local selection
    uses `ui.selection` (including the mask-equivalence assertion in
    item 4); simultaneous normal and active search decorations use
    `ui.search.match` and `ui.search.match.active` respectively, with
    distinct sampled/decoded quad colors and unchanged glyph fg.
22. **GPU diagnostic route and shaping invalidation**
    (`PMACS_REQUIRE_GPU=1`): changing `ui.diag.error` while the
    composed band text and diagnostic counts stay constant changes
    the squiggle, gutter sign, minimap mark, and `E:` counter. The
    counter assertion fails without clearing the rich-text comparison
    cache; the empty-child reset case is item 6.
23. **Peer selections keep the constant** (round 2 finding 9,
    integration shape corrected in round 3): construct simultaneous,
    non-overlapping local and peer selections and drive the actual
    `decoration_background_vertex_bytes` / `collect_peer_rects` path.
    Decode the emitted vertex colors: the local rectangle uses
    `ui.selection`, while the peer rectangle remains the hardcoded
    constant. A helper-only resolver assertion is insufficient.
24. **Lua surface unchanged**: `pmacs.theme.current()` lists both bare
    `ui` and `ui.*` entries alongside captures; `get("ui.modeline")`
    returns the set style (existing lookup semantics).
25. **Raising `__index` is transactional** (PR round 1 finding 2): a
    merge whose style value carries a raising `__index` metatable
    errors with the metatable's own message, commits nothing —
    including its valid sibling — and emits nothing.
26. **v15 peers get no face-derived summary marks** (PR round 1
    finding 3): with a diag face set, a `for_peer(…, 15)` producer
    ships summary marks at the built-in severity color and produces
    no `ThemeFacts`; the `for_peer(…, 16)` twin resolves the face.
27. **Same-generation summary updates repaint the GPU minimap**
    (PR round 1 finding 1, `PMACS_REQUIRE_GPU=1`): two summaries at
    one generation with different mark colors render different
    frames — fails without the cache invalidation on accept.
28. **Snapshot round trip re-ships buffer-scoped state** (PR round 2
    finding 1): with a themed diag mark on A, driving the producer
    through the daemon's exact A → B → A sequence
    (`on_buffer_snapshot_sent` + viewport re-declaration at each
    switch) re-ships A's `FileStyleSummary` — identical themed
    payload, UNCHANGED generation — and A's `StatusFacts`; an
    unchanged tick before the trip stays suppressed. A Rust unit
    pins the reset's scope: one buffer's baselines die, other
    buffers' and the bufferless `ThemeFacts` pair survive.
29. **The daemon wiring, end to end** (PR round 2 finding 1, CRDT):
    a real daemon session navigating A → B → A via dispatched keys
    (the active-buffer-follow path writes the snapshots) re-ships
    `FileStyleSummary` for A at its unchanged generation after the
    revisit's viewport re-declaration.
30. **The GPU minimap returns** (PR round 2 finding 1,
    `PMACS_REQUIRE_GPU=1`): after a real `BufferSnapshot` A → B → A
    round trip (same text both buffers, isolating the minimap), the
    stale minimap is gone, and applying the re-shipped summary at
    the same generation restores the first visit's pixels exactly.
31. **Snapshots clear GPU search/menu/status and release the
    intercept gate** (PR round 3 finding 1, `PMACS_REQUIRE_GPU=1`):
    with an open search prompt, menu popup, and status facts,
    applying a `BufferSnapshot` clears all three, flips
    `daemon_intercepts_keys` back to local key handling, and removes
    the popup pixels — hand-bitten by disabling the three clears.
32. **The reset preserves the diagnostic-count freeze** (PR round 3
    finding 2): render nonzero counts, mark the store stale
    (didChange), apply the snapshot reset — the re-sent
    `StatusFacts` carries the frozen nonzero counts, never (0, 0).
33. **The freeze holds without session history** (PR round 4): mark
    a populated store stale BEFORE constructing the
    `SemanticRenderState` — the session's first frame reports the
    store's preserved counts, because the freeze is the retained
    diagnostic vector itself, not a per-session cache.
34. **Frozen counts stay O(1) at frame cadence** (PR round 5, Rust
    unit): `DiagnosticStore` caches all four per-URI severity totals;
    replacement updates them, `mark_stale` preserves them, and
    `clear` / an empty replacement remove them with the diagnostic
    vector. `StatusFacts` reads this tuple rather than rescanning
    `for_uri` for every semantic session on every frame.
