GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git
CLAUDE.md (43.4K)
1 # CLAUDE.md
2
3 This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4
5 > This is the `cce-ui` crate. It lives inside the larger **`cce` Cargo workspace** — read the
6 > workspace guide `../cce-compositor/WORKSPACE.md` first for the multi-repo layout, the
7 > standalone-build rule (no `[workspace.dependencies]`), the KDL config system, and the
8 > Unix-socket IPC convention. This file covers only what is specific to `cce-ui`.
9
10 ## What this crate is
11
12 `cce-ui` is the **shared, custom retained-mode GUI toolkit** every `cce-*` client depends on
13 (`cce-ui = { path = "../cce-ui" }`), and the compositor's only intra-workspace dependency. It is
14 not a wrapper around an existing framework — it owns its transport, rendering, layout, and widget
15 set outright.
16
17 - **Transport**: raw `wayland-client` 0.31 + `smithay-client-toolkit` 0.19, driven by a `calloop`
18 event loop. Clients are real Wayland surfaces (xdg toplevels, xdg popups, and `wlr-layer-shell`
19 layer surfaces), not toolkit-owned windows.
20 - **Rendering**: raw Vulkan via **ash** (`src/vk/`, `VkRenderer`) for all geometry, and
21 **cosmic-text** + swash for text (shaped into a self-managed glyph atlas by `src/vk/text.rs`).
22 `src/vk/compute.rs` is the one non-drawing seam: `ComputeDevice::run` uploads a list of
23 `Binding`s, dispatches a WGSL `Kernel` on a headless device, waits, and reads the read-write
24 ones back — buffers are host-visible and mapped, so upload and readback are memcpys, and every
25 failure (a user's bad WGSL included) is an `Err`, never a panic. Built for cce-designer's
26 solver operators (its `shapeshifter.md`, Phase 7 step 4); its tests run on whatever Vulkan the
27 machine has and skip with a note where there is none.
28 The wgpu path is retired; cosmic-text used to be reached through **glyphon**, which is gone
29 too — every `glyphon::` item used here was a cosmic-text re-export, and dropping it takes
30 wgpu out of the build. There is no HTML/DOM — the UI is GPU primitives (quads, rounded rects with
31 per-corner radii, vectors with caps, arcs, circles, and the **relief primitives** — the
32 lit-surface family: bevels, plates, recesses, bosses, ridges, fillets, grooves, lattices, box unions; see the
33 `Prim` enum doc in `src/scene/paint.rs`). Tessellators live in
34 `backend/window_runner.rs` and are re-exported through `src/engine.rs`.
35 - It is **both a library and a binary.** `src/lib.rs` is the toolkit; `src/main.rs` is
36 `DemoApp`, the reference `Application` — a small widget gallery on the Phase 6 target
37 architecture (display-list frame, scene-solver layout, routed events, in-frame
38 popovers). Copy it when starting a new client.
39
40 ## Build, test, run
41
42 Use cargo directly (the `Makefile` just wraps `cargo build --release` + install of the demo
43 binary). Prefer `-p cce-ui` from anywhere in the workspace so you don't rebuild the compositor.
44
45 ```sh
46 cargo build -p cce-ui # build the toolkit (+ demo binary)
47 cargo test -p cce-ui # run the test suite (headless unit tests)
48 cargo test -p cce-ui scene::arena # tests in one module
49 cargo test -p cce-ui --lib color:: # tests in one lib module path
50 cargo run -p cce-ui # run the demo/reference app (needs a Wayland session)
51 ```
52
53 Tests are headless unit tests colocated in `#[cfg(test)]` modules — concentrated in `src/scene/*`
54 (the arena/layout/paint/anim engine) and `src/color.rs`, `src/config.rs`, `src/layout.rs`, plus a
55 scattering of widgets (`text_box`, `slider`, `dropdown`, `treelist`, …). When touching the scene
56 engine, that module's tests are the fast feedback loop; run `cargo test -p cce-ui scene::` before
57 anything else.
58
59 Wayland protocol bindings are generated **inline at compile time** by `wayland-scanner` macros in
60 `src/protocol.rs` from `protocol/*.xml` (`cce-inspector-v1`, `cce-window-management-v1`) — there is
61 no `build.rs` and no codegen step to run.
62
63 ## The `Application` trait — the client contract
64
65 Every client implements `Application` (`src/backend/window_runner.rs`, re-exported from
66 `engine.rs`). A client's `main.rs` is typically a struct implementing it plus a one-line
67 `cce_ui::engine::run::<MyApp>();`. When adding a widget or client, **mirror an existing client**
68 (e.g. `cce-status-interface`) — do not invent a new structure.
69
70 Key methods (see the trait def around `window_runner.rs:1450`):
71 - `new`, `settings()` (→ `WindowSettings`), `layer()` (→ optional `LayerSettings` for
72 layer-shell surfaces like the status bar), `update(msg, needs_rebuild, exit)`, `tick(dt, …)`.
73 **`tick` is not a clock.** Since 2026-09-11 the runner sleeps between ticks while the
74 window is idle (no redraw pending, no animation, no key held, no warm-down) — up to
75 `IDLE_DISPATCH` (1 s, `CCE_UI_IDLE_MS` overrides) — and is woken by Wayland events and
76 by messages on the calloop `Sender` handed to `new`. It used to tick a flat 16 ms
77 forever: every client awake 60×/s doing nothing. So: deliver background results
78 through that `Sender`, never by draining a `std::sync::mpsc` in `tick`; if a widget
79 or app must poll something the loop cannot see, say so — a widget returns `true`
80 from `tick` while the session is live (ColorSelector's picker), an app overrides
81 `Application::idle_poll_interval` (cce-authenticator, cce-system-interface,
82 cce-designer while a pane is detached). Any animation keeps the frame cadence by
83 itself because it reports a change.
84 - **Draw**: `view` / `view_rounded_quads` / `view_vectors` / `overlay_quads` push legacy
85 primitive tuples; `text_items()` returns text; `custom_vertices()` appends raw vertices (e.g.
86 graph geometry). `display_list()` is the new opt-in path (see below).
87 - **Input**: `handle_pointer_move`, `handle_mouse_input`, `handle_mouse_wheel`,
88 `handle_key_input` — most return an optional `Message`. `needs_rebuild: &mut bool` is how a
89 handler requests a redraw; the loop is demand-driven and idles when nothing sets it.
90 - `ui_context()` / `ui_context_mut()` expose the widget tree (`UiContext`) for apps built on the
91 retained widget system rather than immediate drawing.
92 - **Undo/redo**: the runner owns the routing. A press matching the `undo` / `redo` chord
93 (`input.kdl`, cce-ui domain defaults `ctrl+z` / `ctrl+shift+z`) goes to the focused widget
94 as `ContextAction::Undo` / `Redo` (a TextBox that is editing steps its own typing), then to
95 the app's `undo(needs_rebuild)` / `redo(needs_rebuild)` hooks (default false); only if both
96 decline does the key reach `handle_key_input`. Apps keep their own document history on
97 `cce_ui::history::History<T>` — snapshots of the app's state type, with gesture/group
98 coalescing and the fork-on-new-edit rule built in (module doc in `src/history.rs`).
99
100 The frame loop is demand-driven (single `redraw` dirty bool, gated by a Wayland frame-callback
101 vsync) — it idles correctly when nothing changes. Don't add per-frame I/O to the render hot path.
102
103 ### `renderer_init` — GPU handles do not survive a reconnect
104
105 A connection is one **session**. A Wayland transport cannot be repaired once it breaks,
106 so `run` opens a *new* session around the same live `Application` — same app state, same
107 calloop loop, same message channel, but a new surface, a new swapchain and **a new
108 `VkRenderer`**. `renderer_init(&mut self, renderer)` is called once per session: the
109 first call is the process's own renderer, every later call is a replacement.
110
111 What that costs you: an id from `vk::upload_rgba` names an entry in **one renderer's**
112 image table, and `Frame2D` **skips a draw for an unknown id without logging it**. So any
113 image id cached across frames — in a struct field, an LRU, a `static` — silently stops
114 drawing after a reconnect, while every other part of the window keeps working. That
115 asymmetry is the tell: numbers and text intact, pictures gone.
116
117 The fix shape, in every client that needed it, is one method:
118
119 ```rust
120 fn renderer_init(&mut self, _r: &mut cce_ui::vk::VkRenderer) {
121 if std::mem::replace(&mut self.seen_renderer, true) {
122 // …drop the dead ids and arrange for the pixels to be produced again
123 }
124 }
125 ```
126
127 Act only on the second and later renderer: uploads queued before the first one existed
128 are drained into it, so dropping them there just uploads, destroys and re-uploads
129 everything before the first frame. Freeing a stale id is always safe and worth doing —
130 `ImageStage::destroy_image` returns early on an id it does not hold, and `NEXT_ID` never
131 resets, so a stale id can never collide with a live one. The failure is always "draws
132 nothing", never "draws the wrong picture".
133
134 Three traps, each of which cost a session real time in the 2026-09-19 sweep:
135
136 - **A widget can hold the id too.** `Button::with_icon(id, …)` captures what you hand it
137 and outlives the renderer, so invalidating a cache underneath it changes nothing on
138 screen. For bundled cce-icons artwork use `Button::with_icon_name` / `Button::new_icon`,
139 which hold the NAME and re-resolve through `upload_icon` per read; `upload_icon`'s own
140 cache is keyed on `vk::renderer_epoch()`. `with_icon` still means "the app owns this
141 upload", which is right for app-rendered content — and carries the app's duty to
142 re-set it from here. `ImageView` borrows its id on the same terms.
143 - **A WPE client does not self-heal.** Nothing provokes a repaint of a page that has
144 finished loading, so `pump` finds no buffer held and the stale id just stays stale.
145 cce-mail replays `MailWebView::last_frame`; cce-browser has to remap the active view,
146 the same nudge `activate` uses. (A page that happens to animate *would* recover on its
147 own, because `update_pixels` recreates an image under an id the new table lacks — which
148 is exactly how this hides from whatever page you test with.)
149 - **An in-flight worker result can carry a dead id.** A thread that uploaded just before
150 the drop delivers an id naming nothing, and a store caches it as an entry that draws
151 blank for as long as it stays resident. `cce-preview`'s `PageStore` and `cce-map`'s
152 `TileManager` carry a generation for this and free a mismatched result on arrival.
153
154 Verify with `CCE_UI_FAULT_RECONNECT` (below) — **and run the pre-change binary through
155 the same fault first.** A fix that passes a test which never reproduced the bug is worth
156 nothing, and both of the above traps first showed up as a "fixed" build that still drew
157 nothing.
158
159 ## Rendering: one paint path (the Phase 3 state)
160
161 The backend `render()` **always builds a `scene::paint::DisplayList` and tessellates that single
162 list** (`window_runner.rs` ~1799). Two ways an app feeds it:
163
164 1. **Migrated**: return `Some(DisplayList)` from `Application::display_list()`.
165 2. **Legacy (default)**: return `None`, and the backend wraps the app's `view*`/`view_vectors`
166 tuples into a `DisplayList` via a `PaintCtx` — byte-for-byte the old geometry, just routed
167 through the one path.
168
169 So every app, migrated or not, renders through the same tessellate step. `custom_vertices` is
170 appended as a final unclipped batch drawn on top.
171
172 ## Plates, wells and seams — the surface vocabulary
173
174 Everything cce draws is a lit surface, and the words below name those surfaces
175 so that a description of how a screen should look or behave can be given in
176 them. Use them in code comments, commit messages, and conversation; when a new
177 widget does not fit one of them, say so rather than stretching a word.
178
179 - **A plate is any lit, bounded surface with a silhouette and a stance.** The
180 silhouette is its corner radius (the DE's superellipse corner family,
181 `corner_shape`). The stance is how it sits on the surface beneath it:
182 - **raised** — it floats above that surface, drawn as a `Bevel` (fill plus
183 rolled edge) or a `Boss` (edges only, the surface below as its face):
184 menus, popovers, raised buttons, a ButtonStrip's selected plateau, a
185 Breadcrumb in its floating stance.
186 - **flush** — it sits level with that surface inside a groove ring, drawn as
187 an `inset_plate`: buttons, dropdown triggers, breadcrumb runs, font
188 selectors. Its face is the surface below unless a fill is configured.
189 - **Plates nest, and the ladder has three rungs of the same object.** The
190 **root plate** is a window's background (RFC 7a; `plate { root }` in
191 config). **Pane plates** are the surfaces controls and content sit on inside
192 a window; they carry the corner dock (`widget/plate_dock.rs`). **Control
193 plates** are the things you press. A control plate is not a different kind
194 of object from a root plate — it is a plate at a smaller scale.
195 - **Wells are not plates.** A well is an opening cut into a plate that you look
196 into or type into, drawn as a `Recess` (a `Trough` when it holds a moving
197 part): text boxes, keybind and spinbox fields, slider and progress tracks,
198 the trackpad pane, the ColorSelector's recess. Things you press are plates;
199 things you enter are wells. A well's floor can carry fills (a progress
200 fill, a colour swatch) — those are segments of the floor, not plates.
201 A **canvas well** is a well you look into or draw in — Trackpad, Slider2D,
202 the bevel and ramp previews — and every one is cut from the same material:
203 the plate darkened for its floor (`colors::WELL_FLOOR`) and the recess for
204 its rim, through `PaintCtx::well_floor` / `well_rim` (`canvas_well`),
205 rounded like the text wells. The Ramp editor's plot is the reference look.
206 With relief off, a well is its frame: the one hairline
207 `colors::well_frame_color` gives every well (lit in the highlight while it
208 is active, the relief rim's focus cue), and still no floor of its own.
209 - **A group is a lasso.** `widget::Group` owns nothing: it is a set of member
210 ids, and its frame is the padded hull of wherever the host's layout put
211 them, with a title tab flush on the top edge — the section's frame
212 (`PaintCtx::section_well` under relief, the section outline otherwise), so
213 a group is a segment of the plate it sits on, parted by a section carve
214 rather than a seam. Given its plate (`with_plate`) and `with_fit`, sides
215 within `snap` of the plate's edge take the edge one padding in and corners
216 on the plate's corner follow it concentrically: on a narrow pane a group is
217 that pane's inset lining, on a wide one a lasso. A group never hits.
218 - **Segments are plates or floors sharing one silhouette, parted by seams.**
219 A seam is a `Groove` cut across the shared surface, dying into its rolled
220 edge: Breadcrumb segments, ButtonStrip segments, the ColorSelector's
221 text/swatch split. One silhouette, one relief pass, seams between. A
222 `Separator` is the same cut made in the plate it sits on, with no segment
223 to part: a groove that dies out at its own ends (flat: a hairline).
224 - **Marks and bands sit outside this vocabulary on purpose.** The round
225 Checkbox mark is a mark; the Slider's swelling band is a band. Do not call
226 them plates or wells.
227
228 What this buys, and where the code is heading:
229
230 - **Navigation is stated in plate terms.** `Input::focus_role` says what a
231 widget is to the keyboard: a `Plate` (a thing you press — Enter / Space act
232 on it while focused), a `Well` (opens for typing when focused), or `None`
233 (not a stop). `UiContext::focus_step` walks the stops in reading order (row,
234 then x) with a `Group`'s members as one contiguous run where the group's
235 first member falls (`focus_clusters`), wrapping; `focus_step_group` jumps
236 between runs (input.kdl `focus_next_group` / `focus_prev_group`, defaults
237 `ctrl+tab` / `ctrl+shift+tab`). The runner calls them for Tab / Shift+Tab
238 and the chords when the app opts
239 in with `Application::plate_navigation` (default off, so an app that routes
240 Tab itself — a terminal, a web view, its own field order — is undisturbed)
241 and tells the app through `Application::focus_stepped` — an app that caches
242 its geometry until its own rebuild flag raises it there. The walk needs the
243 app's context exposed (`ui_context_mut`); the ring reaches flat-path hosts
244 through `RenderTarget::inset_plate_tinted` and `CarveKind::Boss { tint }`.
245 `CCE_FOCUS_DEBUG=1` prints the stops in walk order.
246 The focus ring is the plate's own silhouette: `ControlPlate::with_tint`
247 lights the rim (a tinted `Trough`, `Boss` or `Bevel`), the same treatment a
248 well's `recess_tinted` gives its rim while editing — never extra geometry.
249 A Checkbox lights the ring its mark already draws; a Toggle lights the
250 rim of the plate that slides in its well.
251 Roles today: Button, Checkbox, Toggle, Dropdown, FontSelector, ButtonStrip
252 (arrows move the selection between its segment plates) and Breadcrumb
253 (arrows walk its visible segments, Enter navigates) are plates; TextBox,
254 Spinbox, ColorSelector, KeybindRecorder, TreeList, Slider (a band, but
255 entered and adjusted in place — arrows step it, Enter opens the readout)
256 and RangeSlider (one stop, two ends: arrows step the focused end, Up / Down
257 switch ends) are wells. A new focusable widget declares its role and handles `FocusIn`
258 / `FocusOut`.
259 - **What a plate is made of is a `scene::Material`** — tint, `Frost` (opaque, or
260 frosted with compression / refraction / radius) and `Finish` (how it answers the
261 light: the old `relief_shade::Material`). `docs/rfc-material.md` is the design and
262 its phase tracker. `Material::fill_tint` is the ONE place the blur-behind sentinel
263 (a negative alpha) is written; `PlateSpec::fill` and `param_plate_fill` call it.
264 Rung defaults: `Material::root()` / `pane()` / `control()`, `popover(base)` for
265 menus; a well floor is `host.floor(lifted)`. `PlateSpec`, `ControlPlate.face`
266 (`Option<Material>`: `None` = the surface below IS the face) and the prims
267 `Plate` / `Bevel` / `Sphere` / `Droplet` carry one, and `PaintCtx::plate` /
268 `bevel` / `sphere` / `droplet` / `inset_plate` take one; the tessellator reads
269 each prim's fill and push-constant finish from it, and only the carves still take
270 the DE finish. `Material::from_fill` / `face` decode a colour a legacy site still
271 holds (the flat-path `RenderTarget` is colour-typed) — a new site says
272 `Material::opaque` / `with_frost` instead. **`tests/plate_golden.rs` is the exit
273 test for any change that must not move a pixel**: dump before, compare after.
274 - **One plate spec per rung, not five copies.** The root and pane rungs are
275 `scene::paint::PlateSpec` (RFC 7b, painted by `PaintCtx::plate`). The
276 control rung is `scene::paint::ControlPlate` (re-exported from `widget`):
277 footprint, per-corner silhouette, `PlateStance` (raised, flush or flat), face
278 and depth, painted by `PaintCtx::control_plate` — the ONE place a control
279 face's relief is composed (raised with a face = bevel; raised faceless =
280 carve inside + boss; flush = carve inside + inset plate). Button, Dropdown,
281 FontSelector, Breadcrumb and the ButtonStrip's selected plateau draw through
282 it; the migration was prim-identical against a dump of every face. A new
283 control face goes through `ControlPlate`, never a hand-rolled carve.
284 - **`Flat` is the stance for a control made of its pane's material.** The two
285 relief stances both carve INSIDE the footprint, which costs a control two
286 things a pane has: its visible edge sits half the carve depth in, so a
287 control laid out on the same numbers as a pane does not line up with one;
288 and its face is laid through a stroke, which the blur-behind sentinel (a
289 negative alpha) does not reach, so it cannot be frosted. `Flat` fills the
290 footprint with a quad and nothing else — silhouette equal to the rect,
291 frost carried, focus `tint` drawn as a ring since there is no rim to light.
292 It carries ONE radius, not four, so the concentric corner adjustment a
293 nested relief control computes has no equivalent. Reach for it when a bar
294 or a toolbar should read as plates at a smaller scale rather than as
295 controls of a different kind (`Button::with_flat`, `Dropdown::with_flat`);
296 leave the relief stances alone for things that should feel pressable.
297 - **Radii are configured per rung, overridden per widget.** Root:
298 `style.surface.plate.root.corner_radius` (`color::root_plate_corner_radius`).
299 Pane: `plate_corner_radius`, falling back to the root's. Control:
300 `style.control.corner_radius` (`layout::control_corner_radius`, default 8) —
301 every control-scale getter (button, dropdown, font selector, slider,
302 spinbox, textbox, toggle, list and tree wells; the ColorSelector's two via
303 the textbox) falls back to it when the widget's own `corner_radius` key is
304 unset, so a per-widget key is an override, not a requirement. Do not give a
305 new control-scale radius getter a literal default; fall back to the rung.
306 - **A config hex is gamma-decoded; a built-in default colour is not.** The
307 style loader's `parse_hex` runs every channel through `srgb_to_linear`
308 (alpha excepted), so `"#595969"` arrives as `[0.10, 0.10, 0.14]` — which is
309 exactly `PARAM_BG`'s default. The constants in `color.rs` are already
310 linear, so **the hex that pins a default is not that default's floats times
311 255.** `PARAM_BG = [0.10, 0.10, 0.14]` reads as `#1a1a24` if you scale it
312 naively, and `#1a1a24` decodes to `[0.010, 0.010, 0.018]` — a plate ten
313 times darker than the one you were trying to preserve, silently, because
314 both spellings are valid config.
315
316 Round-trip a default with `l2s(c) = 1.055·c^(1/2.4) − 0.055` (the inverse of
317 `srgb_to_linear`) before writing it into a config, or read the value back
318 out of the running app. This cost a measurement round on 2026-09-19: a
319 `backdrop_compression` sweep meant to hold the tint constant was silently
320 sweeping the tint too, and the two halves of the experiment disagreed by 3x
321 on the plate's luminance.
322
323 Two smaller edges of the same knife: an **8-digit** hex keeps its alpha raw
324 (`a/255`, no decode), so `#05050840` really is a quarter opacity; a
325 **6-digit** hex sets alpha to **1.0**, so dropping the last byte off a
326 translucent plate colour makes it fully opaque rather than leaving it
327 alone.
328 - **A frosted plate's legibility is `backdrop_compression`, not opacity.**
329 Blur destroys a backdrop's spatial DETAIL and preserves its mean LUMINANCE,
330 and text contrast is a mean-luminance property — so `resolve_blur`'s closing
331 `mix(backdrop, plate, opacity)` hands the backdrop's brightness through at
332 `1 - opacity` whatever the kernel does. At the designer dialog's 0.25 that is
333 75% of whatever is behind it. Measured on a row label (`#ccccd4`) over the
334 designer's Alt+D plate at the stock tint: **1.16:1 over a white viewport,
335 6.65:1 over the dark one** — the bright end not a contrast ratio so much as
336 its absence. More blur moves neither number, which is the whole of the
337 "liquid glass" legibility problem, and why refraction and specular cannot
338 help: they are shape cues, and legibility is a luminance budget.
339
340 `style.surface.plate.backdrop_compression` (0..1, `color::plate_backdrop_
341 compression`, **default 0** — every existing config keeps today's look)
342 remaps the blurred backdrop's luminance toward the plate's own key before
343 the tint, holding its chromaticity. It is not opacity and not "darken": it
344 is SYMMETRIC, pulling a bright backdrop down and a dark one UP, so both ends
345 converge on the plate's key. The contrast stops depending on what is behind
346 the window, which is the actual goal; hue, chroma and movement still read
347 through it.
348
349 **It only works with a tint dark enough to converge ON.** A 24-cell sweep
350 (k x tint x backdrop, 2026-09-20) — contrast on the bright/dark viewports,
351 with `show` the luminance sigma across bare plate (x100), a proxy for how
352 much backdrop still reads through:
353
354 | tint | k=0 | k=0.4 | k=0.6 | k=0.85 |
355 |---|---|---|---|---|
356 | `#595969` (stock) | 1.16 / 6.65 | 2.25 / 4.87 | 3.10 / 4.54 | 4.10 / 4.34 |
357 | `#1a1a24` | 1.23 / 9.97 | 2.99 / 10.34 | **5.08 / 10.53** | 9.36 / 10.70 |
358 | `#050508` | 1.24 / 10.47 | 3.08 / 11.67 | **5.41 / 12.14** | 10.76 / 12.60 |
359 | *show* (bright/dark) | 7.3 / 0.9 | 3.7 / 0.5 | 2.3 / 0.2 | 0.4 / 0.1 |
360
361 Three readings. **The stock tint cannot be rescued at any k** — it never
362 clears 4.5:1 on the bright backdrop, and on the DARK one it gets WORSE as k
363 rises (6.65 -> 4.34), because `#595969` is lighter than the scene and
364 compression lifts the plate toward it. **Tint does nothing without k**: at
365 k=0 the three tints read 1.16/1.23/1.24, indistinguishable, because at 0.25
366 opacity the tint barely participates — which is why "just darken it" was a
367 dead end before this existed. And **k ~ 0.6 is the knee**: both dark tints
368 clear the floor on both backdrops with a third of the backdrop variation
369 intact, where 0.85 doubles contrast for 95% of the remaining glass.
370
371 So the pair is orthogonal, and that is the point: **k buys independence from
372 the backdrop, the tint picks the key it becomes independent at.** cce-designer
373 ships `#05050840` at k=0.6 (5.41:1 / 12.14:1). Note `show` is 0.2-0.9 on a
374 dark backdrop at EVERY k: there is little luminance variation behind the
375 plate there to begin with, so "glass" on a dark desktop is carried by the rim
376 and bevel, not by the backdrop.
377 - **The recipe is per plate.** Since 2026-09-20 (RFC material step 3) compression,
378 refraction and the blur radius are a plate's own `Material.frost`
379 (`Frost::Frosted { compression, refraction, radius }`), packed into `p_host.zw` of
380 its push block by `Frost::pack` — the two style keys above are the DEFAULT
381 material's values (`Frost::from_style`), not a window setting, and two plates in
382 one window can differ. `radius` is the kernel sigma in logical px;
383 `Frost::DEFAULT_RADIUS` (5.5) reproduces the old fixed 5.5-physical-px stride on
384 the scale-2 panel; 0 is a clear plate. A frosted FLAT fill of any kind (Quad,
385 RoundedRect, Border fill — a `Flat` face, a menu, a popover) is promoted by the
386 tessellator to a zero-depth plate batch so it carries its recipe too; only a raw
387 vertex from outside the display list falls to the no-recipe branch.
388 `examples/frost_pair.rs` is the visual test: three recipes in one window, run in a
389 shadow, measured in the RFC's step-3 note.
390 - **Named materials in config** (RFC step 4): `style.surface.material { <name> { color;
391 frost …; finish … } }` and a binding per rung — `plate material="…"`, `plate { root
392 material="…" }`, `style.control.material` — resolved by `MaterialDef::resolve` over
393 the rung's legacy material (unset fields fall back; no `frost` child = opaque; a
394 binding wins over the legacy keys; an undefined name warns and degrades to legacy).
395 The DE finish's three fixed terms are `style.surface.relief.spec / shininess /
396 curvature`; the default frost's blur sigma is `style.surface.plate.radius`. cce-relief
397 edits them (Finish and Frost columns) and writes into the bound material's node or the
398 DE keys — never restructuring an unbound config. KDL trap when writing fixtures: two
399 nodes on one line need a `;`, and `a { b }` on one line is a parse error the loader
400 swallows into an empty document.
401 - **`style.surface.plate.refraction` (0..1, default 0) is the rim, and it buys
402 no legibility.** It is the answer to the other half of the question — not
403 "can I read this" but "is this an object". The roll is a real surface with a
404 real tilt, and `sv_rim` IS that tilt (the unnormalized normal's horizontal
405 part, already computed for the specular), so displacing the backdrop sample
406 along it is what a curved edge does to what you see through it. Scaled by the
407 roll width, so a 12px bevel bends more than a 2px one.
408
409 **It samples the CLEAN backdrop, not the blurred one**, cross-fading to the
410 frosted body on `f*f`. Refraction has to bend something with STRUCTURE or it
411 is invisible: displacing a field already blurred to sigma ~11px just moves
412 smooth values around. A thin edge scattering over a shorter path than a thick
413 middle is also what a real slab does — the droplet branch trades on the same
414 thing ("thin edges are clearer water"). One extra tap, not three: per-channel
415 dispersion inside a band this narrow is invisible once the body is 49 taps,
416 and paying for it would triple the most expensive path in this shader to be
417 erased.
418
419 **The clear rim is exempt from `backdrop_compression`**, in proportion to how
420 clear it is. Compression is a legibility control and the rim carries no text;
421 tone-mapping it pulls the refracted view back toward the plate's own key,
422 which is the exact contrast the rim exists to show. Measured, the two
423 fighting made the effect nearly invisible — exempting the rim made it **5.9x
424 stronger** at the same setting (rim pixel change 1.50 -> 8.86 of 255 at 0.3),
425 with the body still under 0.4. Useful range is ~0.3-0.6; the effect is in the
426 roll and stays there.
427
428 ## The standard app — root plate, rungs, and the spacing ladder
429
430 Every cce app is built the same way, and this section is the standard.
431 `scripts/style-audit` checks the sibling app crates against it (one row per
432 app; `--strict` fails on any off-standard row); `src/main.rs`, the demo, is
433 the reference implementation.
434
435 **Anatomy.** A window is a **root plate** with things standing on it. The root
436 plate is the first prim of every frame — `pc.root_plate(w, h)`, which emits
437 `PlateSpec::window(w, h)`: the root rung's material (`Material::root()`, the
438 DE's `style.surface.plate.root.color` at its opacity unless a `material=` is
439 bound), all four corners on the shared silhouette, the perimeter rolled over
440 `bevel_width`. Nothing else paints a window base — not a quad, not a rounded
441 rect at the silhouette radius, not a stroked border. On the root plate stand
442 **pane plates** (`PlateSpec` with the corners that touch the window edge
443 flagged, or `plate` / `rounded_rect` at `plate_corner_radius`) and **carves**
444 (`inset_plate`, `recess_edges`: a menubar or status band stepping down into
445 the surface, a well you type into). Which to use is the vocabulary above:
446 things you press and content you read sit on plates; things you enter and
447 bands that are part of the window's own surface are carved. On the pane
448 plates sit **control plates**. A window that is deliberately not a plate — a
449 transparent bar whose modules are the plates, a notification stack, a black
450 lock or screensaver surface, the desktop grid overlay — says so with a
451 `// style-audit: opt-out <reason>` comment and is listed as an opt-out.
452
453 **Spacing is a ladder, and an app never names a number.** Three rungs, each a
454 config key read through the style registry (so a nested KDL key works and
455 live-reloads), each with a getter in `layout.rs`:
456
457 | rung | inset from the rim | gap between siblings |
458 |---|---|---|
459 | root plate | `root_plate_inset()` = `bevel_width` + `style.surface.plate.root.padding` | `root_plate_gap()` (`…root.gap`) |
460 | pane plate | `plate_padding()` (`style.surface.plate.padding`) | `plate_gap()` (`…plate.gap`, unset = the root gap) |
461 | controls | — (inside a pane or root inset) | `control_gap()` (`style.control.gap`, unset = `CONTROL_GAP`) |
462
463 The root inset carries the roll because the padding is a run of FLAT face —
464 the same run the gap leaves between two panes — and the face only begins
465 where the roll ends; a bare padding at a window edge measured 4px of visible
466 flat against 12 between panes. The legacy keys (`page_margin`, `column_gap`,
467 `control_panel_{padding,gap}`) are honoured when set and land on their rung
468 when unset, the radius rule applied to spacing; do not add a new one.
469
470 In the box model the ladder is presets — `Style::root_column()` /
471 `root_row()`, `pane_column()` / `pane_row()`, `controls_column()` /
472 `controls_row()` — and the legacy strategies' `Default`s and
473 `ColumnLayout::pane` / `controls` read the same getters. An app picks the
474 rung; a literal padding or gap in an app (`const PAD`, `+ 12.0`) is a number
475 the ladder should be supplying, and the audit counts them.
476
477 **Rules, restated as the audit checks them:**
478 - The first prim of the frame is `root_plate(w, h)`, or the crate declares an
479 opt-out.
480 - Every inset and gap comes from a rung getter or a preset; the app declares
481 no spacing constants of its own.
482 - A deliberate deviation — the system settings' own tint, an overlay's
483 shallower roll — goes through `PlateSpec::window(..).with_material(..)` /
484 `.with_depth(..)` and a comment saying it is one, never a hand-built spec.
485 - Migrating an app is pixel-neutral for the plate (`tests/plate_golden.rs`)
486 and a measured change for spacing: screenshot in a shadow session, count
487 the columns of flat face at the edges and across a split, and they match.
488
489 ## The `scene/` core rebuild (read `docs/rfc-core-rebuild.md` before touching it)
490
491 `src/scene/` is a **retained scene graph being grown additively** to replace three overlaid legacy
492 subsystems (tripled tree ownership via raw widget pointers; three uncoordinated render paths;
493 layout smeared across five mechanisms). The RFC (`docs/rfc-core-rebuild.md`) is the authoritative
494 design and phase tracker — its inline "DONE" notes are the source of truth for what has landed.
495 Modules:
496
497 - `arena.rs` — `Arena` / `NodeId` / `Node`: a generational forest, the single source of truth for
498 tree ownership. Generational keys turn use-after-free into a `None` lookup, not UB.
499 - `tree.rs` — `WidgetTree`: arena-backed replacement for `UiContext`'s old `widget_registry` +
500 `layout_tree` twin stores, keyed `WidgetId → NodeId` so the public `WidgetId` API is preserved.
501 - `layout.rs` — the hand-rolled measure→arrange solver (`Style`/`Size`/`Rect`/`LayoutBox`).
502 Deliberately **not** taffy: a compact row/column + flex + align + gap/padding box model.
503 - `paint.rs` / `painter.rs` — `DisplayList` + `PaintCtx` (clip/transform stack) and the single
504 paint walk. Each widget emits its own geometry via `WidgetHost::paint_self`; the walk owns
505 recursion and clipping (`WidgetHost::clips_children`), instead of every container re-deriving
506 intersections. `renders_own_subtree` is an escape hatch for legacy subtree painters.
507 - `anim.rs` — `Animated<T>` (tween + spring + easing), the Phase 4 animation primitive replacing
508 ad-hoc bool flips.
509 - `heightfield.rs` — the relief as a height field: the geometry the plate shader shades
510 (plate rolls, CSG features, free carves) integrated back from the slopes it lights,
511 sampled per physical px and exported as a 16-bit PNG + JSON sidecar in millimetres
512 through the display metric. `CCE_HEIGHTMAP=<file>` in any client's environment, or
513 `heightfield::request` from an app. See the Units section.
514
515 ### `WidgetHost` (formerly the `Element` god-trait)
516
517 `WidgetHost` (`src/widget/mod.rs`) is the single ~52-method host surface the machinery
518 (context routing, paint walk, render loop, app dyn broadcasts) sees, produced by the RFC's 6bd
519 shrink-then-rename of the old ~125-method `Element` god-trait. Its ONE production implementor
520 is `Adapted<W>`; concrete widget behavior lives on the narrow `Layout`/`Paint`/`Input` traits
521 (`src/widget/model.rs`). `base()` is guaranteed (`&Widget`, no Option). The direct-dispatch
522 block (mouse/key/drag) and the value/polling block (`take_click`/`take_change`/value strings)
523 are GONE from the trait — events route through `handle_event`, and apps drain widget state
524 through the concrete inherent `Adapted<W>` methods. See the RFC's blueprint notes before
525 adding anything to this trait.
526
527 **Runtime verification matters here.** Several scene changes are "compiles + tests pass; runtime
528 verification pending" per the RFC — the headless tests can't catch paint/event regressions. When
529 changing scene wiring, `cargo run` a real client (cce-files, cce-designer, cce-graph,
530 cce-system-interface) to confirm behavior, not just the test suite.
531
532 ## Module map (where things live)
533
534 - `src/layout.rs` (largest file, ~6.9k lines) — fonts + sizing; many `*_font_parsed()` getters and
535 the `read_preferred_fonts` / font-family resolution used by the cosmic-text path.
536 - `color.rs` — color model and named colors (`colors` re-export module in `lib.rs`).
537 - `config.rs` — KDL loading and `kdl_to_json` conversion (see workspace `CLAUDE.md` for paths).
538 - `context.rs` — `UiContext`: the retained widget tree, event routing, spatial grid, dirty
539 tracking, hit-testing.
540 - `history.rs` — `History<T>`: the undo/redo snapshot stack (cap, gestures, grouped runs).
541 The toolkit defines the stack and the routing, never the step — see the trait section.
542 - `widget/` — `container/` (vbox/hbox/scroll/menu/treelist/…), `input/` (button/slider/text_box/
543 dropdown/…), `display/` (label/graph/svg/…), plus `editor.rs` and `core.rs`. (The
544 KDL/JSON-driven `json_layout.rs` is dissolved; `scene/layout.rs` is the box model.)
545 - `protocol.rs` — inline-generated Wayland protocol bindings.
546 - `ipc.rs` — the `/tmp/<prefix>-<WAYLAND_DISPLAY>.sock` helpers (`socket_path`, `send_command`).
547 - `process.rs` — detached/tracked child spawning, plus the `cce-cloud` popup pattern:
548 `CloudPopup` (one blocking `run_json`/`run_dmenu` invocation) and `CloudPopupTracker`
549 (an app's single-active-popup toggle state; see cce-status-interface for the
550 canonical usage).
551 - `icon.rs` — XDG icon-theme lookup: a `.desktop` `Icon=` key (or an SNI tray icon
552 name) → a file on disk, plus `upload_themed` to rasterize/decode and upload it.
553 **Not** `lib.rs`'s `upload_icon`, which loads a *bundled* cce-icons glyph by its
554 own name for in-widget use; this one resolves names any installed app may ship.
555 - `file_dialog.rs` (rfd), `scale.rs` (HiDPI), `wayland.rs` (surface/scale detection, and
556 `detect_metric` — the display's logical px per mm from its `wl_output` geometry).
557 - `units.rs` — lengths with units and the display metric; see the Units section below.
558
559 ## Units — logical px inside, real lengths at the edges
560
561 The toolkit's working unit is and stays the **logical pixel**: every layout
562 node, style slot and widget measure is an `f32` of logical px. `units.rs`
563 adds the bridge to real lengths, in two parts:
564
565 - **`Len`** — a value with a unit (`px`, `mm`, `cm`, `in`, `pt`), parsed from
566 `"2mm"` and resolved to logical px through a `Metric`. In config a length
567 carries its unit as a KDL type annotation, the same way `(rgba)` and
568 `(relief)` do: `width=(mm)2.0`. A bare number is a logical px, forever —
569 nothing migrates. `config::kdl_to_json` turns an annotated number into the
570 string `"2mm"`; the writer turns it back into `(mm)2`; `reload_config`
571 stores it in the style registry's `lens` map, and `get_float` resolves it
572 against the live metric at every read. So `layout::bevel_width()` and every
573 other getter are unit-aware without knowing it, and a metric that arrives
574 after config load (outputs come in after the first style read) or changes
575 with the display is honoured without a reload. `get_len` returns the
576 configured unit for editors that should show what the user typed.
577 - **`Metric`** — logical px per mm for the display this process is on, plus
578 its **source**: `measured` (EDID via `wl_output` geometry, or the
579 compositor's configured `size_mm` in its place — the client cannot tell
580 them apart; `ccectl outputs` can), `forced` (`CCE_FORCE_PPI`), or
581 `assumed` — the CSS 96 px/in convention when nothing is known (a headless
582 shadow, a projector with no EDID). The source is carried so fabrication
583 can refuse a guess: `Metric::is_real()`. The window runner installs it
584 beside `scale::set_scale_factor` (`units::set_metric`); apps read
585 `units::metric()`, `units::mm(v)`, or `Len::to_px()`.
586
587 **Relief has a real depth axis now.** `style.surface.relief.width` (the wall's
588 run) and the new **`height`** (a carve's drop) and **`edge_height`** (the plate
589 roll's rise) are all lengths — `height=(mm)0.3` is honest geometry, resolved
590 through the metric. Unset, a carve drops `relief_shade::RECESS_DEPTH` (0.6) of
591 its wall (saturating at the DE roll width) and the roll is a quarter-round of
592 radius width — the look every config had. `style.surface.relief.depth` is NOT a
593 length: it is the light strength (`bevel_depth` → `Finish.strength`), and
594 **`light`** is its honest alias. `layout::carve_depth_px` states the drop rule
595 once for the tessellator's CSG features and, through `WindowInfo.relief_meta`,
596 the shader's free carves; `carve_depth_ratio` / `roll_height_ratio` feed the
597 shading twin (`Finish.carve_depth` / `roll_height`). A `(relief)` value
598 carries the drop as `h=` (a length: `h=0.5mm`, or bare px) beside `w=` and
599 `d=` (light; `l=` reads as an alias). `cce-relief`'s Height knob is the editor:
600 its section's depth numbers read in mm when the metric is real, and Save
601 writes `height` as a `(mm)` length then, px otherwise.
602
603 **And the relief can leave the screen.** `scene/heightfield.rs` integrates the
604 height curves the shader only differentiates and samples a frame's plates into a
605 height field — plates stack, carves etch, exactly the composite model the shader
606 lights — then writes it as a 16-bit PNG whose sidecar carries the pitch and range
607 in millimetres via the metric. A pinned `height=(mm)0.3` is 0.3 mm in that file.
608 The sidecar states the metric's source; on an assumed metric the millimetres are
609 a guess, and a fabrication tool should say so.
610
611 Why not millimetres inside: UI sizes are perceptual and angular, not physical
612 — a hit target should not become 8 mm on a projector three metres away.
613 Documents and fabrication content live in real units and convert at view
614 time. Two domains, one bridge.
615
616 On the live laptop panel (3840×2400 over 344×215 mm at scale 2) the metric is
617 5.58 logical px/mm (141.8 ppi); the default 9.3 px relief roll is 1.67 mm, and
618 the 96 ppi assumption would have called it 2.46 mm.
619
620 ## Fonts & assets
621
622 `lib.rs` builds the cosmic-text `FontSystem` (re-exported as `cce_ui::cosmic_text` so clients
623 need no text dependency of their own). Bundled fonts load from `$CCE_FONTS_DIR` (else
624 `~/Dropbox/Fonts`); bundled icons from `$CCE_ICONS_DIR` (else `~/projects/cce/cce-icons/svg`).
625 System fonts are loaded only when `$CCE_LOAD_SYSTEM_FONTS` is set (or via
626 `create_font_system_with_system_fonts()`, used by the font picker). Configured custom font
627 families are validated at startup with a warning if missing.
628
629 ## Debug environment variables
630
631 All opt-in, all read once, all quiet when unset — set one and run any client.
632
633 - `CCE_PLATE_DEBUG=1` — per frame, how many relief carves grouped into their host plate
634 as exact CSG features vs fell back to standalone overlay shading, and for each fallback
635 **why** (one of six rules: ridge, edge-suppressed, tinted, feature budget, no enclosing
636 plate, host's feature run closed). The two paths shade junctions differently, and three
637 of those rules are dynamic, so this is the answer to "why does this widget's carve look
638 different here?". Note what it reveals: grouping is *rare* — the reference demo groups
639 2 of 10, cce-files 0 of 7, because any ordinary geometry painted after a plate closes
640 its grouping window (correctly — the carve's shading is baked into the plate's earlier
641 draw).
642 - `CCE_PRESENT_DEBUG=1` — swapchain present/acquire tracing.
643 - `CCE_VK_DEVICE=<substring>` — force a physical device; `CCE_VK_RT=0` disables ray tracing.
644 - `CCE_FORCE_SCALE=<f>` — override HiDPI scale detection.
645 - `CCE_FORCE_PPI=<f>` — pin the display metric (logical px per inch) regardless of what
646 the outputs report; a headless shadow has no EDID and would run `assumed`. The live
647 panel is 141.8.
648 - `CCE_HEIGHTMAP=<file.png>` — export the client's third rendered frame as a relief
649 height field (16-bit greyscale PNG + `<file>.json` sidecar: pitch, range in mm, datum,
650 metric source); `CCE_HEIGHTMAP_MM=<mm>` resamples to that pitch. `scene/heightfield.rs`.
651 - `CCE_UI_FAULT_RECONNECT=<seconds>` — drop the session that many seconds after it
652 starts, exactly as a transport error would, so the reconnect path (and the second
653 `renderer_init`) can be exercised on demand instead of waited for. One-shot per
654 process: the app reconnects and then stays up. A float, so `0.5` works; logs
655 `CCE_UI_FAULT_RECONNECT: dropping the session` at WARN. In a shadow, note that the
656 window MOVES across the reconnect (off-view recall), so capture with
657 `shot-window <id>` and re-read `ctl windows` before any pointer work.