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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VoiEyuPjoBhvwACf8HAnLB
This commit is contained in:
parent
13dbadd0b6
commit
6ce1532699
|
|
@ -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<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 `3cbb9de`)
|
||||
|
||||
### 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: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<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.
|
||||
|
||||
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. `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.
|
||||
- **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.
|
||||
- `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.
|
||||
|
||||
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).
|
||||
Loading…
Reference in New Issue