GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git
CLAUDE.md (194.8K)
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 (a git pin on GitHub that the workspace root's `[patch]` redirects to this tree). Its GUI-free
14 half is the sibling crate `cce-core`, which the compositor depends on instead. It is
15 not a wrapper around an existing framework — it owns its transport, rendering, layout, and widget
16 set outright.
17
18 - **Transport**: raw `wayland-client` 0.31 + `smithay-client-toolkit` 0.19, driven by a `calloop`
19 event loop. Clients are real Wayland surfaces (xdg toplevels, xdg popups, and `wlr-layer-shell`
20 layer surfaces), not toolkit-owned windows.
21 - **Rendering**: raw Vulkan via **ash** (`src/vk/`, `VkRenderer`) for all geometry, and
22 **cosmic-text** + swash for text (shaped into a self-managed glyph atlas by `src/vk/text.rs`).
23 `src/vk/compute.rs` is the one non-drawing seam: `ComputeDevice::run` uploads a list of
24 `Binding`s, dispatches a WGSL `Kernel` on a headless device, waits, and reads the read-write
25 ones back — buffers are host-visible and mapped, so upload and readback are memcpys, and every
26 failure (a user's bad WGSL included) is an `Err`, never a panic. Built for cce-designer's
27 solver operators (its `shapeshifter.md`, Phase 7 step 4); its tests run on whatever Vulkan the
28 machine has and skip with a note where there is none.
29 The wgpu path is retired; cosmic-text used to be reached through **glyphon**, which is gone
30 too — every `glyphon::` item used here was a cosmic-text re-export, and dropping it takes
31 wgpu out of the build. There is no HTML/DOM — the UI is GPU primitives (quads, rounded rects with
32 per-corner radii, vectors with caps, arcs, circles, and the **relief primitives** — the
33 lit-surface family: bevels, plates, recesses, bosses, ridges, fillets, grooves, lattices, box unions; see the
34 `Prim` enum doc in `src/scene/paint.rs`). Tessellators live in
35 `backend/tessellate.rs` and are re-exported through `backend/window_runner.rs` and
36 `src/engine.rs`.
37 - It is **both a library and a binary.** `src/lib.rs` is the toolkit; `src/main.rs` is
38 `DemoApp`, the reference `Application` — a small widget gallery on the Phase 6 target
39 architecture (display-list frame, scene-solver layout, routed events, in-frame
40 popovers). Copy it when starting a new client.
41
42 ## Build, test, run
43
44 Use cargo directly (the `Makefile` wraps `cargo build --release` and
45 `ccebuild install --no-build cce-ui`, which installs its one bin, the demo; the relief
46 editors `cce-relief` and `cce-ramp` are the `cce-relief` crate since 2026-10-08). Prefer `-p cce-ui` from anywhere in the workspace so you don't rebuild the compositor.
47
48 ```sh
49 cargo build -p cce-ui # build the toolkit (+ demo binary)
50 cargo test -p cce-ui # run the test suite (headless unit tests)
51 cargo test -p cce-ui scene::arena # tests in one module
52 cargo test -p cce-ui --lib color:: # tests in one lib module path
53 cargo run -p cce-ui # run the demo/reference app (needs a Wayland session)
54 ```
55
56 Tests are headless unit tests colocated in `#[cfg(test)]` modules — concentrated in `src/scene/*`
57 (the arena/layout/paint/anim engine) and `src/color/`, `src/layout/`, plus a
58 scattering of widgets (`text_box`, `slider`, `dropdown`, `treelist`, …). When touching the scene
59 engine, that module's tests are the fast feedback loop; run `cargo test -p cce-ui scene::` before
60 anything else.
61
62 **The tests never read the machine's config** (since 2026-10-07). Under `cfg(test)`,
63 `config::config_home()` is a per-process directory nobody creates (`config`, `input` and
64 `motion` live in `cce-core` now, where that gate is its `test-isolation` feature, which
65 cce-ui's dev-dependency turns on — see "The GUI-free half is cce-core" below), so `config.kdl`, the per-app
66 overrides and `input.kdl` are all absent and every getter answers its default. A test that needs a
67 setting loads it from a string (`color::reload_colors`) or pins it on its thread. Before this, six
68 heightfield and material tests failed on the desktop, whose `relief edge height` and frost keys
69 overrode what they set, and passed elsewhere. Fonts are another matter: `fonts_dir()` still reads
70 `~/Dropbox/Fonts` (or `$CCE_FONTS_DIR`), and the text-shaping tests need those faces.
71
72 **The library also builds for the browser** (`wasm32-unknown-unknown`, since 2026-10-04):
73 `scripts/check-wasm` type-checks it, with and without the optional features. The native
74 shell and renderer — `vk`, the Wayland shell (`backend::{window_runner, menu_popup, dnd}`),
75 `wayland`, `protocol`, `ipc`, `mcp`, `file_dialog` — and their crates (ash, smithay, calloop,
76 wayland-*, libc, rfd) are `cfg(not(target_arch = "wasm32"))`; so are the Wayland-typed parts
77 of the client contract: `Application::new(qh, …)`, `layer()` and `LayerSettings`,
78 `register_sources`, and `renderer_init` / `stage_renderer`, which take a `VkRenderer`.
79 `WindowAction::Resize` takes `app::WindowEdge` — xdg's `ResizeEdge` on Linux, as before.
80 Since macOS joined (2026-10-05) "native" is two things: the Vulkan renderer and the native
81 services (`vk`, `ipc`, `file_dialog`; ash, libc, rfd) are `cfg(not(target_arch = "wasm32"))`,
82 and the WAYLAND shell — `backend::{window_runner, menu_popup, dnd}`, `wayland`, `protocol`,
83 `mcp`, the crates smithay / calloop / wayland-* / xkeysym, and the contract's `new(qh, …)`,
84 `layer()`, `LayerSettings` and `register_sources` — is
85 `cfg(not(any(target_arch = "wasm32", target_os = "macos")))`. `renderer_init` /
86 `stage_renderer` are on macOS too, since it has the Vulkan renderer.
87 Portable code keeps time with `web_time::Instant` (std's own type natively; std's panics in
88 the browser), and reaches what a renderer draws through `crate::draw`, not `crate::vk`. A
89 change that makes portable code call into a native module fails `check-wasm` first — put
90 the native half behind the cfg, as `color_selector::place_picker_at_pointer` does.
91
92 **It draws in the browser too** (`src/web`, `WebRenderer`, since 2026-10-04): the Vulkan
93 renderer's 2D path on WebGPU through web-sys, from the same `Frame2D`, shaders, glyph atlas
94 and image queue. web-sys still ships its WebGPU bindings behind `--cfg=web_sys_unstable_apis`;
95 `.cargo/config.toml` sets it for the wasm target, and **cargo reads that file from the
96 directory it is run in** — so build the browser half from inside `cce-ui` (as
97 `check-wasm` does), and a client crate that builds cce-ui for the browser needs the same line
98 in its own config.
99
100 **And an `Application` runs in a page** (`web::run::<App>(canvas, fonts, sizing).await`, since
101 2026-10-05): the browser shell (`src/web/shell.rs`) is the Wayland shell's counterpart over a
102 `<canvas>`, on the same `Driver` and `Pacer`. What it does in the page's terms:
103
104 - **Events**: pointer (captured on press, so a drag outside the canvas still ends), wheel,
105 key and focus events on the canvas, mapped by `backend::dom` — `map_key` gives a key the
106 TEXT xkb's `utf8` would (Tab "\t", Enter "\r", Ctrl+letter its control code: the Wayland
107 shell hands widgets exactly that, and a focused text box inserts Tab's), `wheel_frame`
108 reads a whole notch-sized pixel delta (Chromium's 100 px) or a line / page delta as a wheel
109 notch and anything else as a finger, and a finger gesture's lift is synthesized after
110 120 ms without a frame (`FINGER_LIFT`), since a page reports none. The browser's own key
111 repeats are dropped: the driver repeats, as on Wayland. On a Mac, Command is the shortcut
112 key (⌘Z is undo). A page has no grabs, so a press on a CSD border is the app's.
113 - **Pacing**: a turn per animation frame at the pacer's ACTIVE cadence, a timer at its idle
114 one; any event, and any `AppSender::send` (through `backend::app::set_wake`), wakes the
115 loop for the next frame. Measured idle: 5 turns in 5 s, the native count.
116 - **Size**: `Sizing::App` sizes the canvas from `WindowSettings` and `desired_size` (CSS px),
117 as a window; `Sizing::Page` leaves it to the page's CSS and ignores size requests (a tiling
118 compositor's answer); a ResizeObserver wakes a turn on relayout, and `devicePixelRatio` is
119 the scale. The context menu is drawn in the canvas and kept inside it, as on a layer surface.
120 - **Fonts** (`web::Fonts`): the files, and the generic serif / sans / mono families — a page
121 has no font directory and no fontconfig, so it says both. `lib::page_fonts` holds them, and
122 on wasm EVERY font database the toolkit builds loads them: the shell's, the widget-geometry
123 one (`geometry_font_system`) and the text-measurement one (`widget::input::get_font_db`,
124 resvg's — the toggle's label is centred by it). cosmic-text has no family fallback list on
125 wasm (`fallback/other.rs` is empty), where on Linux it walks Noto Sans → DejaVu Sans → …,
126 so `page_fonts::stand_in_for_missing` gives the families the toolkit names (the configured
127 fonts, "Berkeley Mono") the faces of the first family of that Linux list the set has; a
128 family an app names itself must be in the set. Order matters: the measuring fallback is the
129 first face with the glyph.
130 - **`web::capture().await`**: the next frame, read back from the GPU. Headless Chromium
131 composites in software and leaves a WebGPU canvas out of its screenshots and `toDataURL`.
132 - **The clipboard** (since 2026-10-05): `widget::clipboard` is one synchronous text pair
133 (`copy_to_clipboard` / `read_from_clipboard`, every widget's copy, cut and paste) with a
134 backend per platform — `wl-copy` / `wl-paste` (`xclip`) on Wayland, `NSPasteboard` on
135 macOS, and in a page the page's own clipboard events, because a page may read the
136 clipboard only inside a `paste` event. So the canvas lets ⌘/Ctrl+C, X and V keep their
137 defaults (`dom::clipboard_key`), and a ⌘/Ctrl+V is HELD from the app until its `paste`
138 event has handed over the text (then a read answers it) — or, if none comes, until a
139 zero timer, when a read answers the page's own last copy or paste; a release never
140 overtakes it. A copy writes through `navigator.clipboard.writeText` where the page has it
141 (a secure context; checked first, since calling into undefined throws through the wasm
142 frames), and the `copy` / `cut` event the key raises carries it too, which needs no
143 secure context. Until then a copy in a page panicked (`std::thread::spawn`).
144 `scripts/web-probe/clipboard` is the check: copy, paste, copy with `writeText` refused,
145 and paste with the `paste` event swallowed, each read back from the system clipboard —
146 all four pass in headless Chromium (2026-10-05). (Five since the IME: a `paste`
147 swallowed with its default kept pastes into the keyboard sink, an `input` of type
148 `insertFromPaste`, which is the paste too.)
149 - **The keyboard is a hidden `<textarea>`'s** (the keyboard sink, since 2026-10-05), not
150 the canvas's: a page composes input-method text only into an editable element. It takes
151 the focus a press on the canvas gave the canvas (a canvas focused another way hands it
152 over, and a blur over to the canvas is not a focus loss); its keys are the app's as the
153 canvas's were, except one the input method takes (`isComposing`, keyCode 229); its
154 `input` events while composing are the composition (`Driver::preedit`, the cursor from
155 its selection via `dom::utf16_range_to_bytes`), `compositionend` the commit, and text
156 with no composition (an emoji panel, dictation) a commit as it comes. After each frame
157 it is moved to the editing widget's caret (`ime::caret`), where the candidates open; a
158 composition a widget dropped (`ime::take_reset`) is cancelled by blurring and refocusing
159 it INSIDE the turn, where the events that raises reach no handler.
160 `scripts/web-probe/ime` is the check, through Chromium's own IME path (CDP
161 `Input.imeSetComposition` / `insertText`): a composition shown with the sink at its
162 caret, the commit replacing it, a cancelled one leaving the box, a no-composition
163 insert, and plain keys — all six pass (2026-10-05), and the 24-step demo replay is
164 identical to the pixel to the run before the sink through step 18.
165
166 Not there yet: drag and drop, file dialogs, and an app whose
167 text is not the display list's (`display_list_text` false — it stages its own through the
168 native-only `stage_renderer`, so draws no text here).
169
170 **Compute jobs run in the browser too** (`web::ComputeDevice`, since 2026-10-05). What a job
171 IS moved out of `vk` into the portable `crate::compute` — `Kernel`, `Binding`, `BindKind`,
172 `workgroups`, `MAX_BINDINGS`, and the rules a job is held to before any device sees it
173 (`check_job`, the ping-pong `slot_for` / `result_slot`, `parse_kernel`: naga's WGSL
174 frontend, now a dependency on every target, validates a kernel and reads its
175 `@workgroup_size` — WebGPU can report neither) — and `vk::compute` re-exports every one at
176 its old path. The browser device takes the same jobs and answers them the same way, with
177 one difference the platform makes: readback is a promise, so its `run`, `run_over`,
178 `run_passes`, `run_passes_over` and `workgroup_size` are `async`. Two things WebGPU does
179 differently underneath: its layouts tell read-only storage from read-write (the module
180 says which, `ParsedKernel::read_only_storage`), and a device starts at the spec's default
181 of eight storage buffers a stage, so it asks for the adapter's own (SwiftShader offers
182 ten; a job past the adapter's ceiling is an `Err` naming the limit). What WebGPU rejects
183 is caught in a validation error scope and returned. `examples/compute_probe/jobs.rs` is
184 the check — a map, a uniform, a 33- and a 34-pass ping-pong, a 2D dispatch, ten bindings,
185 a bad kernel and a missing entry, each exact against a CPU reference in f32 — run by
186 `compute_native` and by `scripts/web-probe/compute`: the two outputs are identical to the
187 bit (lavapipe vs SwiftShader, 2026-10-05). A reference written for a length that is not
188 a multiple of four floats must know that `arrayLength` counts the 16-byte padding, on
189 both devices.
190
191 **3D scenes draw in the browser too** (`Stage3D`, since 2026-10-05). What an app stages a
192 scene through is a trait, `draw::scene::Stage3D` (`create_mesh`, `update_mesh`,
193 `stage_scene`, `stage_scene_images`, `set_scene_light`), implemented by `VkRenderer` (each
194 method its inherent one) and `WebRenderer`; the scene's types (`Vertex3D`, `MeshId`,
195 `SceneDraw`, `SceneImage`), its uniform blocks (`scene_uniforms`), image quads and the
196 wire-base depth bias (`wire_base_bias`) moved to `draw::scene`, and `scene3d.wgsl` /
197 `scene3d_image.wgsl` to `draw/`, shared by both renderers; `vk` and `engine` re-export them.
198 Two portable `Application` hooks take a `&mut dyn Stage3D`: **`init_3d`** (once per
199 renderer — make meshes) and **`stage_3d`** (every frame, just before the draw; true asks
200 for another frame). Natively `renderer_init` and `stage_renderer` forward to them by
201 default, as `new` forwards to `create`, so an app that overrides the native hooks (the
202 designer) is untouched, and one that moves to the portable pair runs on both shells. The
203 WebGPU pass (`web/scene.rs`) is the Vulkan `SceneStage`'s port: a full-size backdrop in the
204 canvas's sRGB view format with a depth32 buffer, copied into the canvas under a UI pass
205 that LOADS it and samples it for blur — and kept, as on Vulkan, until the next staged
206 scene. Depth bias is pipeline state in WebGPU, and a WebGPU line is one pixel (no
207 wideLines), so the biased fill is a pipeline of its own at `wire_base_bias(1.0)` — what a
208 Vulkan device without wideLines uses. `scene3d.wgsl` takes its derivatives at the top of
209 `fs_main` (WebGPU rejects one under a branch on a varying); natively pixel-identical.
210 `examples/probe3d/scene.rs` is the check, on the portable hooks — background quad, flat and
211 prelit fills, a wire-carrying fill and its wires, a see-through fill and its edges, an
212 image in the scene before the translucent draw, a host light, frost over the pane — run by
213 `probe3d_native` and `scripts/web-probe/probe3d`: 2026-10-05, lavapipe vs SwiftShader,
214 195 px differ by more than 8 levels, all on 1 px wires (where along its length a line
215 steps a row is the rasterizer's), everything else within 2.
216
217 **A mesh update does not wait for the GPU** (since 2026-10-07,
218 `SceneStage::update_mesh` / `update_lit_mesh` through `Mesh::replace`). It
219 called `device_wait_idle` first, reasoning that geometry updates are rare;
220 a playing simulation updates several meshes every frame, and the wait made
221 each frame's upload wait out the previous frame's GPU work. Now the new
222 vertices go into another buffer — a spare of the mesh's that no submitted
223 frame still reads, or a new one — and the replaced buffer becomes a spare
224 tagged with the frames submitted so far (`SceneStage::submitted`, counted
225 at each submit). After each frame-slot fence wait, `frame_waited` knows
226 which frames have finished, releases spares too small for their mesh and
227 keeps two of the rest, so a steady playback allocates nothing. Measured in
228 the designer's replay at 57k points: the stage pass of a drawn frame 5.6–6.0 ms
229 to 3.6–3.8.
230
231 **A scene draw can be instanced** (since 2026-10-06, `SceneDraw::instances`). A draw
232 naming an instance mesh draws its `mesh` once per vertex of that mesh: an instance is a
233 `Vertex3D` read as an offset added to every vertex and a colour multiplying theirs, so a
234 white mesh takes each instance's colour. `scene3d.wgsl`'s vertex stage takes the instance
235 at locations 2 and 3, and every mesh pipeline on both renderers has a second, per-instance
236 vertex binding. A draw with `instances: None` is drawn for ONE instance at the origin in
237 white (`draw::scene::UNIT_INSTANCE`, a 24-byte buffer each stage keeps), which leaves its
238 vertices bit for bit as they were (`x + 0.0`, `c * 1.0`), so nothing that does not ask
239 for instancing changed; the pipelines and their count are the same. An instance mesh with
240 no vertices draws nothing. It exists for the designer's point markers, which were a
241 240-vertex sphere copied to every point and uploaded whole each frame of a playing
242 simulation — 46 MB at ten thousand points, where instanced they are 240 KB. The probe has
243 a row of instanced cubes, and Vulkan draws it (2026-10-06, a shadow); the WebGPU half
244 (`web/scene.rs`: the instance buffer layout with `GpuVertexStepMode::Instance`, slot 1,
245 `draw_with_instance_count`) was written alongside and NOT built — this machine's
246 toolchain has no wasm32 target — so `scripts/web-probe/probe3d` is the first thing to run
247 on one that has.
248
249 **A screen-space draw says so** (since 2026-10-07, `SceneDraw::screen_space`). A pane's
250 background quad is a mesh whose vertices are NDC corners; the draw sets `screen_space`, the
251 uniform block carries it in what was `_pad`, and `scene3d.wgsl` places those vertices at
252 their own xy on the far plane, unlit, without the mvp. Until then the signal was IN the
253 vertex data: any vertex of any mesh within 0.01 of z = 9.99 became a background corner, so
254 real geometry spanning that plane in its own units (cce-model's 25 mm STL sphere; a
255 designer Transform at z = 9.99) tore into spikes across the pane. Never key behaviour off
256 vertex values again. The probe has a regression for it: a sphere modelled around z = 9.99
257 and scaled into place by its mvp, which the old shader shredded and this one draws whole
258 (Vulkan, scale-2 shadow; the WebGPU half shares the shader and `scene_uniforms` and was
259 not built — still no wasm32 target here).
260
261 **A mesh can be lit and textured** (since 2026-10-07, `draw::lit`). Beside the
262 `Vertex3D` path, which is a position and a colour shaded flat or pre-lit by the host,
263 the scene pass has a second mesh kind for model files: a `LitVertex` (position,
264 normal, uv, colour), drawn by `scene3d_lit.wgsl` with a `LitMaterial` (base colour,
265 optional base-colour image id, metallic, roughness) under one `LitLight` (key, fill,
266 sky/ground ambient). Lambert plus GGX/Smith/Schlick per light, the hemisphere as the
267 diffuse ambient and as what a metal reflects; the diffuse has no 1/pi, the scale
268 hosts' baked light always had, so a matte lit draw matches a baked one. It is reached
269 through `Stage3D::lit()`, which **defaults to `None`**: the Vulkan renderer answers
270 `Some(self)` (`LitStage3D`: `create_lit_mesh`, `update_lit_mesh`, `set_lit_light`,
271 `stage_lit`), the WebGPU one does not yet, so it needed no change and a host checks.
272 `LitDraw::before` interleaves lit draws with the staged `SceneDraw`s as
273 `SceneImage::before` does (a host's background and grid first, wire overlays after).
274 What the Vulkan stage does (`vk/scene.rs`): a lit pipeline on the image pipeline's
275 layout (set 0 the per-frame dynamic uniforms, set 1 an image's descriptor set), no
276 culling (the shader flips a back face's normal), dynamic depth bias for
277 `wire_base_width`; lit uniform blocks share the per-frame buffer after the scene's and
278 the images', so the slot stride is now the larger of the two blocks (`SLOT_SIZE`, 224
279 bytes against the scene block's 128 — nothing that does not use lit draws changes but
280 that stride); an untextured draw, or one whose image is not resident, binds a 1x1 white
281 image the renderer uploads with the first lit mesh. Textures are ordinary image ids:
282 RGBA8 sRGB, so texels reach the shader linear; they die with the renderer (re-upload
283 in `init_3d`). The image sampler clamps, so the shader wraps uvs with `fract` and
284 samples with the UNWRAPPED uvs' gradients (`textureSampleGrad`), or the wrap would
285 pick the smallest mip and draw a seam. cce-model is the consumer. Verified 2026-10-07
286 in a scale-2 shadow: a textured globe, a gold metal sphere and a rough red cube, the
287 highlights moving with the camera; and `probe3d` drawn pixel-identical before and
288 after the change (the flat path untouched).
289
290 **And so does the path tracer** (since 2026-10-05). `Stage3D` carries the tracer's half
291 too — `set_rt_scene` / `set_rt_scene_with_image`, `set_rt_environment`,
292 `set_rt_background`, `stage_rt`, `rt_accumulating` — and what it traces from moved to
293 `draw::rt`: the schema (`RtTriangle`, `RtMaterial`, `RtImage`, `RtCamera`,
294 `RtEnvironment`), the binned-SAH BVH and its tests, the buffers (`pack_scene`) and
295 parameter blocks (`rt_params`, `denoise_params`) as the shaders read them, and the
296 constants; the shaders (`rt_common` / `rt_bvh` / `rt_query` / `rt_denoise`) moved to `draw/`.
297 `vk::rt` re-exports the schema and keeps its own state (frames in flight, the ray-query
298 tier, `RtOffscreen`) — it now packs and lays out through `draw::rt`, verified by its
299 GPU tests (`cargo test --lib rt -- --ignored`, lavapipe). `web/rt.rs` is the compute tier on
300 WebGPU: the same shaders and packing, the same rules for restarting the accumulation, one
301 sample a frame plus the three à-trous iterations in one compute pass. Two differences:
302 WebGPU has no ray tracing, so there is no ray-query tier (the compute tier is what every
303 Vulkan device without RT cores runs too); and Vulkan BLITS the tracer's `rgba8unorm`
304 image into the sRGB backdrop, converting as it copies, which WebGPU's copies cannot — a
305 small render pass loads each texel and writes it through the backdrop's sRGB view, the
306 same conversion. The probe's traced mode (`Probe3d<true>`, `PROBE3D_TRACE=1` natively,
307 `scripts/web-probe/probe3d <out> traced`) stages exactly eight frames and stops, so both
308 are compared at eight samples: 2026-10-05, Vulkan compute tier (`CCE_VK_RT=compute`) on
309 lavapipe vs SwiftShader, the traced pane's mean differs by 0.10 of a level, every pixel
310 within 8, 47 channels in the frame past 8 — a few paths that diverged.
311
312 **A large traced scene is prepared off the UI thread** (since 2026-10-07).
313 `set_rt_scene` builds the BVH where it is called, and an app only has the stage inside
314 `stage_3d`, so 5M triangles froze cce-model for 2.3 s. `PreparedRtScene::new(tris, mats,
315 image, with_bvh)` is the CPU half — packing into the buffer layouts plus the BVH — and is
316 `Send + Sync`, built on a worker; `Stage3D::set_rt_scene_prepared(&scene)` only uploads
317 (and keeps it: a reconnect re-uploads the same one). `with_bvh` is `Stage3D::rt_needs_bvh()`,
318 asked on the UI thread first: false on the Vulkan ray-query tier, which builds its BLAS on
319 the GPU from the packed triangles and ignores a BVH; a scene prepared without one on a
320 compute-tier renderer gets it built at upload (`PackedScene::with_bvh`), so a wrong answer
321 costs time, never a wrong image. `set_rt_scene` / `set_rt_scene_with_image` are wrappers
322 over the two halves. The GPU tests run per tier with `VK_DRIVER_FILES` pinned (Intel =
323 compute, NVIDIA = ray-query, NVIDIA + `CCE_VK_RT=compute`); no lavapipe ICD is installed.
324
325 **The reference app runs on both, through one input script.** `examples/demo_web.rs` is
326 `src/main.rs`'s `DemoApp` (included by `#[path]`, hence `pub(crate)`) in a page;
327 `scripts/web-probe/demo <dir>` builds it, serves it with the machine's fonts and replays the
328 native harness's 24 steps (`drive.mjs`: moves, clicks, a drag, a wheel, typing, undo, the
329 menu, Tab, held keys, the CSD bands), one captured frame per step, which `compare.py` diffs
330 against the native run's screenshots (`--mask` the cursor's box; never sway's
331 `hide_cursor`, which clears pointer focus, so the native app drops its hover). Measured
332 2026-10-05 against lavapipe: steps 00–18 differ only at the slider band's two pointed tips,
333 1 px of rasterizer tie-break (≤ 63 channels beyond 8 levels; everything else within 2),
334 with `DEMO_FAMILIES=FreeSerif,FreeSans,FreeMono` — what native's fontdb made of this
335 machine's fontconfig, not `fc-match`'s DejaVu. Steps 19–20 hold a key, and the driver
336 repeats once per turn: SwiftShader takes ~250 ms a frame, so the page gets fewer repeats
337 than native in the same 1.5 s. Timing, not routing.
338
339 **The renderer probe holds the two renderers to each other.** `examples/probe/scene.rs`
340 is one 1280x800 frame of nearly every prim — root, pane and frosted plates, every control
341 stance, fields, carves, bevel, sphere, grooves, vector caps, text at four sizes in two
342 families, an image at two sizes. `cargo run --example probe_native` draws it through
343 Vulkan (a Wayland session; screenshot it); `scripts/web-probe/run <out.rgba>` builds
344 `probe_web` for wasm, binds it with wasm-bindgen-cli (the version in Cargo.lock) and draws
345 it in headless Chromium on SwiftShader, reading the frame back from the GPU; and
346 `scripts/web-probe/compare.py native.png out.rgba 1280 800` diffs them. Both halves must
347 have the same fonts — the native one with `CCE_LOAD_SYSTEM_FONTS=1`, the web one handed the
348 DejaVu files (`$PROBE_FONTS_DIR`) — and the screenshot must not carry a cursor (sway:
349 `seat * hide_cursor 200`), which is a difference the diff cannot tell from the renderer's.
350 Lavapipe against SwiftShader, 2026-10-04: 96.8% of channels equal, every other within 2
351 levels but ONE at 3 — rounding at antialiased edges and in the blur, no shading difference.
352 Chromium needs `--use-angle=swiftshader --enable-unsafe-swiftshader
353 --disable-gpu-compositing` beside the WebGPU flags (`browser.mjs`): headless, with GPU
354 compositing it has no shared-image backing for a WebGPU canvas and loses the device on the
355 first present ("A valid external Instance reference no longer exists").
356
357 **And on a Mac, type-checked only** (`src/mac`, since 2026-10-05). The fourth shell is an
358 AppKit window over the same `Driver`, `Pacer`, `build_frame` and **Vulkan renderer, on
359 Metal through MoltenVK**: `vk::SurfaceTarget` names what a window's `VkSurfaceKHR` is made
360 from — `Wayland { display, surface }` or `Metal { layer }` (a `CAMetalLayer`) —
361 `VkRenderer::try_new_for` / `attach_surface_to` and `VkCore::new_for_surface` /
362 `create_surface` take one, and the Wayland-pointer forms forward to them unchanged. The
363 instance enables VK_EXT_metal_surface when the loader offers it, and on macOS only
364 VK_KHR_portability_enumeration (the loader lists MoltenVK to no instance that does not ask);
365 a device that offers VK_KHR_portability_subset gets it enabled, as the spec requires, and
366 no Linux driver offers it — so a Linux instance and device are the ones they always were.
367 `engine::run::<App>()` is the AppKit shell's `run` on macOS, so a client's `main` does not
368 change. What the shell does, in AppKit's terms (module doc in `src/mac/mod.rs`):
369
370 - **The window**: transparent, its titlebar transparent over full-size content, so the root
371 plate fills it with the traffic lights on its corner. AppKit resizes from its own window
372 edges, so the driver's CSD resize band is the app's (`PressSite::own_edges`, new; false
373 on the other shells); a press the driver reads as a move drags the window
374 (`performWindowDragWithEvent:`).
375 - **Events**: a flipped, layer-hosting `NSView` maps mouse, scroll, magnify and keys through
376 `backend::appkit` (portable, tested on Linux like `dom`): named keys from the hardware key
377 code (AppKit spells them in private-use characters, and Backspace as DEL), the rest from
378 `characters`, or with ⌘/Ctrl from `charactersIgnoringModifiers` so ⌘Z is z typing ^Z;
379 Command reads as `ctrl`, as in a page on a Mac; a Control-click is a right click. AppKit
380 sends NO keyUp for a ⌘-combination, so the shell releases one as it presses it (else the
381 driver repeats ⌘Z until focus is lost). Its key repeats are dropped (the driver repeats)
382 and so is the system's scroll MOMENTUM (the toolkit coasts a flick itself; both would
383 coast twice). The system has applied natural scrolling to the wheel too, where a Linux
384 compositor applies it to the trackpad alone, so a wheel notch is turned back to the
385 wheel's own direction, and each trackpad event's `isDirectionInvertedFromDevice` sets
386 `input::force_natural_scroll` on the main thread: the system setting rules, not input.kdl's.
387 - **Pacing**: main-queue dispatches (`dispatch2`); an event or any `AppSender::send`, from
388 any thread (`app::set_wake`, process-wide on macOS, per thread in a page), asks for a turn
389 at most one ACTIVE frame after the last; between, the pacer's sleep. A superseded turn is
390 dropped by its generation. Quit (⌘Q) and the close button ask the app to exit as a
391 compositor's close does; the run loop is stopped once it has.
392 - **Fonts**: the system set is always loaded on macOS (`build_font_system`) — it is what
393 cosmic-text's macOS fallback list names.
394 - **Clipboard**: the general `NSPasteboard`'s plain-text type, behind the same
395 `widget::clipboard` pair every widget uses; ⌘C / ⌘X / ⌘V reach the widgets as Ctrl+C /
396 X / V do on Linux, since Command reads as `ctrl`.
397 - **Input methods**: the view is an `NSTextInputClient`. While a widget is editing text
398 (`ime::caret` is set) a key press without ⌘ goes through `interpretKeyEvents:` first:
399 `setMarkedText:` is the composition, `insertText:` the commit — unless it is a plain
400 key typing its own characters with nothing marked, which is left to the key path so it
401 keeps its named key and the driver's repeat — and `doCommandBySelector:` leaves the key
402 to the key path. `firstRectForCharacterRange:` is the caret in screen coordinates. With
403 nothing editing, keys skip the input method, so one left on does not eat an app's
404 single-key commands. After each frame a dropped composition is discarded through the
405 input context (the marked text cleared first, so the `unmarkText` that may call commits
406 nothing) and a moved caret invalidates the character coordinates.
407
408 Not there yet: drag and drop, the context menu
409 in a popup window (it is drawn in the window, as on a layer surface), blur behind the window,
410 a menu bar beyond Quit. **None of it has run**: this is Linux, where an Apple target can be
411 type-checked but not linked. `scripts/check-mac` type-checks the library, the demo, every
412 example and the tests for `aarch64-apple-darwin` (`rustup target add aarch64-apple-darwin`);
413 the four examples that still used the legacy `new(qh, …)` moved to `create`, and
414 `plate_probe` / two integration tests reach the tessellator at `backend::tessellate` rather
415 than through `window_runner`. On a Mac, MoltenVK and the Vulkan loader must be installed
416 (the LunarG SDK, or Homebrew's `molten-vk` and `vulkan-loader`); `cargo run` is the test.
417
418 **Input-method composition is one model for every shell** (`crate::ime`, since
419 2026-10-05). Three things cross between the text widget and the shell's input method:
420 the COMMIT is delivered as typed text (`Driver::commit_text`: a press of a key whose text
421 it is, then its release — never a shortcut, never repeated, past the chords), so every
422 widget that inserts a key's text takes it unchanged (`TextBox`, `LineEdit`, the
423 `DocEditor`, an app's own field); the COMPOSITION (`ime::Preedit`: text and the input
424 method's cursor as a byte range) is shared per thread, set through `Driver::preedit`; and
425 the CARET goes back — a widget editing text reports it as it paints
426 (`ime::report_caret`, in window px with the `PaintCtx` offset), `build_frame` brackets
427 the frame (`begin_frame` / `end_frame`), and `ime::caret()` is where the candidates go and
428 whether text is wanted at all. **`TextBox` shows a composition as a PROVISIONAL run** in
429 `edit_buffer` (`composing`: its char start and length), so wrap, scroll, caret and the
430 glyph advances draw it as typed text, and `selection_quads` underlines it; it is never
431 held (`committed_buffer`, which `take_change` publishes under `update_on_type`), never in
432 the history, and the box takes no key while it composes. It is applied in `prepare_text`
433 and at the top of `handle_key`, against `ime::generation`; a press, or editing ending,
434 drops it and asks the input method to cancel (`ime::request_reset`). A composition begun
435 over a selection replaces it, as typing would. `a_composition_is_shown_in_place_and_the_
436 commit_is_typed` is the test.
437
438 **`LineEdit` and the `DocEditor` show it too** (since 2026-10-05), each without letting it
439 into what it holds — a host reads `LineEdit::text` directly and saves the `DocEditor`'s
440 buffer, so neither ever contains it. `LineEdit` splices it into `display()` at the caret,
441 and `display_index` / `text_index` map across it (the caret lands where the input method
442 has its cursor, a point inside the composition is the caret, one after it is the text it is
443 drawn after); `composition_range` is the span to underline, a masked field shows bullets.
444 The app, which draws the field, calls `sync_ime` each frame the field has the keyboard,
445 reports the caret it draws (`ime::report_caret` — also what tells the shell text is
446 wanted), and `drop_composition` when the field loses it. The `DocEditor` lays out the
447 caret's line WITH the composition (an active line, raw anyway) and maps every column read
448 off that layout across it (`laid_col` / `source_col`: the caret, `caret_rect`, `pos_at`);
449 it underlines it, and reports its caret itself while painted focused; a host calls
450 `drop_composition` when the editor loses the keyboard. Both take no key while a
451 composition is up, take the commit as typed, and treat a press as dropping the
452 composition (cancelled in the input method) and placing the caret — the `DocEditor`'s read
453 through the line as drawn. `a_composition_is_shown_at_the_caret_and_never_held` and
454 `a_composition_is_laid_out_in_place_and_never_held` are the tests; cce-notes, built against
455 this tree, was driven under the headless sway with the stand-in input method (2026-10-05):
456 the composition underlined at the caret, the commit typed, a second composition left up
457 through the editor's autosave and then dropped by a click — and the note on disk held the
458 commit and never the composition. cce-browser's URL bar, bookmarks search and dialog
459 fields are the `LineEdit` hosts (its `keyboard_field` / `sync_ime`, lsgalante/cce-browser#1).
460
461 Not there yet: no shell sends surrounding text, so an input
462 method's `delete_surrounding_text` (text-input-v3) is not applied; and a password box is
463 announced with the normal content purpose.
464
465 **On Wayland it is `text-input-v3`** (`backend/text_input.rs`, since 2026-10-05), relayed
466 by the compositor to an `input-method-v2` client (fcitx5, IBus's Wayland frontend). The
467 text input is the first keyboard seat's, made with the keyboard. After each render
468 (`EngineState::sync_text_input`) it is ENABLED while the seat's text-input focus is on our
469 surface (`enter`) and a widget is editing (`ime::caret`), with a normal content type and
470 the caret as the cursor rectangle (surface px — the app's logical px times a forced scale,
471 as pointer input is divided), re-sent when the caret moves; DISABLED when nothing is
472 editing; and disabled-then-enabled for a composition a widget dropped (`ime::take_reset`),
473 which resets the input method. Every change is one `commit`, counted (`TextInput::commits`,
474 what a current `done`'s serial is). `preedit_string` / `commit_string` /
475 `delete_surrounding_text` are double-buffered and applied on `done` in the protocol's
476 order (`Batch::apply_order`: the old composition out, the commit typed, the new one in;
477 a batch with no `preedit_string` ends the composition, a cursor of -1 hides it); `leave`
478 drops the composition. The decisions are pure (`TextInput::plan`, `Batch`) and tested
479 with no compositor (`backend::text_input::tests`). Verified end to end under the headless
480 sway with a scriptable `input-method-v2` client standing in for fcitx5 (2026-10-05): no
481 activation until a box is clicked into; a composition shown underlined at the caret; the
482 commit replacing it; a cancel; a press mid-composition dropping it with a
483 disable-and-enable; Escape disabling — and under `WAYLAND_DEBUG` the cursor rectangle
484 following the caret through every step, each `done`'s serial equal to the commits sent.
485 Sway routes text-input focus only while an input method is bound, so with none (the
486 24-step harness) nothing changes: 0 px.
487
488 CI (`.github/workflows/ci.yml`, every push and PR) has five jobs, warnings as errors in the first four:
489
490 - **`test`** (Ubuntu 24.04) builds and tests with default and with all features. It installs
491 `libwayland-dev` and `libxkbcommon-dev` (the two native libraries the build links, through
492 pkg-config), Mesa's lavapipe, a software Vulkan device, so the GPU tests (`vk::compute`,
493 `vk::plate_probe`) RUN rather than skip — a last step fails the job if they printed a skip
494 note, since a skipped test passes — and `fonts-liberation`, named as `CCE_FONTS_DIR`, so the
495 text tests have real faces (with none they fell back to the image's leftovers, and the
496 tests needing a second face or a fallback glyph kept the workflow red from 2026-10-07 to
497 10-08). It also runs the path tracer's `#[ignore]`d GPU tests on lavapipe's compute tier
498 (`CCE_VK_RT=compute cargo test --lib vk::rt -- --ignored`).
499 - **`clippy`** runs `cargo clippy --all-targets --all-features -- -D warnings` (since
500 2026-10-08). Five lints are allowed as house style in `Cargo.toml`'s `[lints.clippy]`, each
501 with its reason (too many arguments, complex tuple types, precise colour constants, index
502 loops, `new` without `Default`); anything else clippy reports is fixed, not allowed —
503 locally, `cargo clippy -p cce-ui --all-features --all-targets -- -D warnings` is the check.
504 Linux only: `src/web` and `src/mac` are compiled out there.
505 - **`miri`** runs the `widget::owned` tests under Miri, Stacked and Tree Borrows (nightly):
506 the app's access to a widget and the registry's taking turns (see "The registry holds
507 pointers, and knows when they die").
508 - **`wasm`** runs `scripts/check-wasm`: the library, its features and the four wasm examples,
509 type-checked for the browser. Its `RUSTFLAGS` carries `--cfg=web_sys_unstable_apis` itself,
510 since an environment `RUSTFLAGS` replaces `.cargo/config.toml`'s. Its first run (2026-10-08)
511 found the browser build broken since 10-06 by two native-only calls in portable code.
512 - **`macos`** builds every target, LINKED, on a macOS runner and runs the tests — what
513 `scripts/check-mac` can only type-check on Linux. `ash` loads Vulkan at run time, so no
514 MoltenVK is needed to build, and the GPU tests skip there.
515
516 To match the `test` job locally: `apt install libwayland-dev libxkbcommon-dev
517 mesa-vulkan-drivers fonts-liberation`, then `CCE_FONTS_DIR=/usr/share/fonts/truetype/liberation
518 RUSTFLAGS="-D warnings" cargo test --all-features`.
519
520 Wayland protocol bindings are generated **inline at compile time** by `wayland-scanner` macros in
521 `src/protocol.rs` from `protocol/*.xml` (`cce-inspector-v1`, `cce-window-management-v1`). The one
522 build step is `build.rs`, which compiles the renderer's fixed WGSL shaders to SPIR-V once, so a
523 WGSL error is a build failure and no process pays naga at launch
524 (`precompiled_spirv_matches_runtime_compile` holds it to the run-time compile).
525
526 ## The `Application` trait — the client contract
527
528 Every client implements `Application` (`src/backend/app.rs`, re-exported from
529 `window_runner` and `engine.rs`). A client's `main.rs` is typically a struct implementing it plus a one-line
530 `cce_ui::engine::run::<MyApp>();`. When adding a widget or client, **mirror an existing client**
531 (e.g. `cce-status-interface`) — do not invent a new structure.
532
533 Key methods (see the trait def in `backend/app.rs`):
534 - `create(sender)`, `settings()` (→ `WindowSettings`), `layer()` (→ optional `LayerSettings` for
535 layer-shell surfaces like the status bar), `update(msg, needs_rebuild, exit)`, `tick(dt, …)`.
536 **`tick` is not a clock.** Since 2026-09-11 the runner sleeps between ticks while the
537 window is idle (no redraw pending, no animation, no key held, no warm-down) — up to
538 `IDLE_DISPATCH` (1 s, `CCE_UI_IDLE_MS` overrides) — and is woken by Wayland events and
539 by messages on the `AppSender` handed to `create`. It used to tick a flat 16 ms
540 forever: every client awake 60×/s doing nothing. So: deliver background results
541 through that sender, never by draining a `std::sync::mpsc` in `tick`; if a widget
542 or app must poll something the loop cannot see, say so — a widget returns `true`
543 from `tick` while the session is live (ColorSelector's picker), an app overrides
544 `Application::idle_poll_interval` (cce-authenticator, cce-system-interface,
545 cce-designer while a pane is detached). Any animation keeps the frame cadence by
546 itself because it reports a change.
547 - **Construction is `create(sender: AppSender<Self::Message>)`** (since 2026-10-03).
548 `AppSender` is cce-ui's own handle — `send`, `Clone`, `Send`, and `From` both ways
549 with `calloop::channel::Sender` for a client that still stores calloop's type — so
550 the constructor names no window system, which is what lets a second shell (macOS,
551 the browser) run the same `Application`. `create` is REQUIRED (since 2026-10-08):
552 the legacy `new(qh, sender)`, whose queue handle no client ever used, is gone, and
553 so is the default that panicked at startup when an app implemented neither, so a
554 missing constructor is a compile error. An app that keeps calloop's sender converts
555 on the first line (`let tx: calloop::channel::Sender<_> = sender.into();`).
556 `register_sources` stays a calloop-only hook: it is the Wayland shell's, not part of
557 the portable contract.
558 - **Draw**: `display_list()` returns the frame (the one paint path, below);
559 `display_list_text` opts its `Prim::Text` into the glyph pass. Two side channels remain:
560 `overlay_quads` (flat quads over everything, the status bar's) and `custom_vertices` (raw
561 vertices appended as a final unclipped batch).
562 - **Input**: `handle_pointer_move`, `handle_mouse_input`, `handle_mouse_wheel`,
563 `handle_key_input` — most return an optional `Message`. `needs_rebuild: &mut bool` is how a
564 handler requests a redraw; the loop is demand-driven and idles when nothing sets it.
565 - **3D**: `init_3d(stage)` / `stage_3d(stage, size, scale)` — the portable pair, through
566 `Stage3D` (see "3D scenes draw in the browser too"); the native `renderer_init` /
567 `stage_renderer` take the `VkRenderer` itself and forward to them by default.
568 - `ui_context()` / `ui_context_mut()` expose the widget tree (`UiContext`) for apps built on the
569 retained widget system rather than immediate drawing.
570 - **Undo/redo**: the runner owns the routing. A press matching the `undo` / `redo` chord
571 (`input.kdl`, cce-ui domain defaults `ctrl+z` / `ctrl+shift+z`) goes to the focused widget
572 as `ContextAction::Undo` / `Redo` (a TextBox that is editing steps its own typing), then to
573 the app's `undo(needs_rebuild)` / `redo(needs_rebuild)` hooks (default false); only if both
574 decline does the key reach `handle_key_input`. Apps keep their own document history on
575 `cce_ui::history::History<T>` — snapshots of the app's state type, with gesture/group
576 coalescing and the fork-on-new-edit rule built in (module doc in `src/history.rs`).
577
578 The frame loop is demand-driven (single `redraw` dirty bool, gated by a Wayland frame-callback
579 vsync) — it idles correctly when nothing changes. Don't add per-frame I/O to the render hot path.
580
581 ### A lost surface ends the session; it does not panic (since 2026-09-25)
582
583 `VkRenderer::try_new` returns `vk::SurfaceLost` when the display connection under the
584 surface is already dead — Mesa's Wayland WSI answers the surface queries with a roundtrip,
585 so `vkGetPhysicalDeviceSurfaceFormatsKHR` is the first call to find out, with
586 `ERROR_SURFACE_LOST_KHR`. The runner ends that session as `ConnectionLost`: a reconnect if
587 the compositor is still there, a clean exit if it is not. Mid-session, a swapchain
588 rebuild, acquire or present that reports the surface lost latches `surface_lost()` and
589 skips draws (one WARN) until the event loop sees the dead connection itself; the menu
590 popup just closes. `try_new` is the ONLY constructor: the panicking `new` was removed
591 once its last callers (cce-cloud, cce-lock, designer's `vk-smoke`) had moved over, so a
592 new client cannot pick the one that takes the process down at logout. Found as
593 cce-cloud's daemon panicking at logout on `No surface formats`: it had outlived a
594 compositor and asked for a window over its connection. Reproduced by opening a
595 `wl_surface`, killing the shadow compositor, then constructing: `try_new` returns the
596 error where the old `new` panicked.
597
598 ### A daemon can outlive its compositor (`Application::outlives_compositor`, 2026-09-25)
599
600 When the compositor is gone (`SessionEnd::NoCompositor` — nothing at the socket) the
601 runner EXITS by default: the compositor saves windows for restore and its successor
602 respawns them, so a client that rejoined came up beside its own copy (2724002). That is
603 wrong for a process the compositor does not restore — a systemd user service like the
604 status bar or the notifier, which must outlive it and whose D-Bus names other programs
605 depend on. Such an app returns true from `outlives_compositor`; the runner then waits for
606 the successor's socket (`await_compositor_socket`, a 250 ms poll) and rejoins it with the
607 same `Application`. Found when the status bar was rebuilt against 2724002: at every logout
608 its modules exited, the launcher's backoff grew while nobody was logged in, and the tray's
609 StatusNotifierWatcher came back seconds after the next login — Dropbox, starting into the
610 gap, reported no tray.
611
612 ### The toolkit names no app (since 2026-10-08)
613
614 Nothing in cce-ui branches on WHICH app is running. The runner used to read the app id:
615 anything whose `app_id` began `cce-status` lost its client-side move, resize and resize
616 cursors, and had its input region pinned to its launch size. The status bar and its OSD
617 now say so through the contract — `Application::standard_csd` returns false — and the
618 input region went (the segments' menus grow the surface past it, and a row below the old
619 region took the click before and after; checked in a shadow). The same sweep moved the
620 designer's pane geometry (`SplitterLayout`, `CircularPaneLayout`) into cce-designer and
621 the relief editors (`cce-relief`, `cce-ramp`, until then bins of this crate) into the
622 `cce-relief` crate. When an app needs the runner to behave differently, add a defaulted
623 `Application` hook; never test the app id. (Two widgets LAUNCH a DE app by name — the
624 colour selector `cce-color-editor`, a default its host can replace, and the font selector
625 `cce-fonts` — which is the DE's toolkit using the DE, not a branch on the app.)
626
627 ### The runner works out each frame's damage (`backend::frame::derive_damage`, 2026-10-06)
628
629 An app that does not report its own damage (`take_damage`, which only cce-grid does) no
630 longer repaints its whole window per frame: the Wayland shell diffs the tessellated
631 batches (vertex bytes, scissor, clip, plate push, blur flag), the display-list text (line
632 text, position, colour, size, clips) and the image quads against the last built frame,
633 and damages what changed — where it was and where it is, over the common prefix and
634 suffix. Ids whose pixels `update_pixels`/`update_pixel_regions` replaced are damaged where
635 drawn. It gives up (full frame) on a new size, scale or clear colour, changed plate
636 carves, a frame owed after a skipped present, an app that stages its own text
637 (`display_list_text` false), and a 3D backdrop. `CCE_UI_FULL_DAMAGE=1` turns it off;
638 `CCE_PRESENT_DEBUG` logs each derived rect.
639
640 A frosted (blur-behind) batch used to force every frame full. Now the renderer grows the
641 partial region instead: a frosted plate the region touches is repainted whole plus its
642 blur's reach (`BLUR_REACH_PX`), until nothing more is touched — its blur reads a snapshot
643 that is only right inside the region. The first-drawn frosted batch over a transparent
644 clear with no scene (the root plate, frosted in every themed app) samples the zeroed
645 backdrop instead of a snapshot (`first_frost_exempt`): the same pixels, no copy, and no
646 dependence on what lies outside the region. `write_window_info` marks every image stale.
647
648 Checked by pixel A/B in a scale-2 shadow, full repaint forced vs derived, the window shot
649 after each step: the demo (hover grid, then focus + typing), cce-gallery and the settings
650 app's Notifications page matched exactly; differences on its System and Power pages were
651 live data (uptime, temperatures, a different saved plan per shadow home).
652
653 ### A layer app can have no surface while it is empty (`Application::wants_surface`, 2026-10-05)
654
655 A layer-shell app that is usually empty — the notifier, between notifications — returns
656 false from `wants_surface` while it has nothing to show. On that turn the runner detaches
657 its renderer (`VkRenderer::detach_surface`: the swapchain and `VkSurfaceKHR` go, the device,
658 pipelines, atlases and image table stay) and drops the layer surface (SCTK destroys the
659 role, then the `wl_surface`); on the turn it says true again it builds a fresh `wl_surface`,
660 re-attaches the same layer role, moves the SAME renderer onto it (`attach_surface`, as the
661 menu popup's renderer moves between popups), and the first configure makes it presentable
662 as at session start. The app
663 keeps running throughout — its calloop sources, D-Bus thread and state are untouched; only
664 the surface goes. Why bother: an always-mapped transparent overlay still made the
665 compositor blur behind it whenever anything under it changed, and it kept a fullscreen
666 client off direct scanout (scenefx scans out only a one-entry render list).
667
668 Since the renderer is the same one, image ids stay good across the gap and
669 `renderer_init` does not run; ids uploaded while hidden are drained into it at its next
670 frame. Until 2026-10-08 the runner dropped the renderer and built a new one on show — a
671 device and every pipeline, about 45 ms before a card after an empty spell appeared — and a
672 `surface_hidden` hook told the app, whose `renderer_init` then had to skip re-uploading what
673 was already queued (the notifier kept a flag for it). Both are gone. Only if attaching fails
674 does the surface stay hidden with no renderer; the next show makes a new one and calls
675 `renderer_init` as for a replacement. It is also what a launcher like cce-cloud, which keeps
676 one renderer for its life and moves it between popups, would need from the runner. Checked
677 in a scale-2 shadow on a private session bus: a card, its expiry, a second card with a
678 thumbnail after the empty spell — drawn identically to the pixel by the old binary and the
679 new. Default true; xdg windows ignore it.
680
681 ### `renderer_init` — GPU handles do not survive a reconnect
682
683 A connection is one **session**. A Wayland transport cannot be repaired once it breaks,
684 so `run` opens a *new* session around the same live `Application` — same app state, same
685 calloop loop, same message channel, but a new surface, a new swapchain and **a new
686 `VkRenderer`**. `renderer_init(&mut self, renderer)` is called once per session: the
687 first call is the process's own renderer, every later call is a replacement.
688
689 What that costs you: an id from `vk::upload_rgba` names an entry in **one renderer's**
690 image table, and `Frame2D` **skips a draw for an unknown id without logging it**. So any
691 image id cached across frames — in a struct field, an LRU, a `static` — silently stops
692 drawing after a reconnect, while every other part of the window keeps working. That
693 asymmetry is the tell: numbers and text intact, pictures gone.
694
695 The fix shape, in every client that needed it, is one method:
696
697 ```rust
698 fn renderer_init(&mut self, _r: &mut cce_ui::vk::VkRenderer) {
699 if std::mem::replace(&mut self.seen_renderer, true) {
700 // …drop the dead ids and arrange for the pixels to be produced again
701 }
702 }
703 ```
704
705 Act only on the second and later renderer: uploads queued before the first one existed
706 are drained into it, so dropping them there just uploads, destroys and re-uploads
707 everything before the first frame. Freeing a stale id is always safe and worth doing —
708 `ImageStage::destroy_image` returns early on an id it does not hold, and `NEXT_ID` never
709 resets, so a stale id can never collide with a live one. The failure is always "draws
710 nothing", never "draws the wrong picture".
711
712 Three traps, each of which cost a session real time in the 2026-09-19 sweep:
713
714 - **A widget can hold the id too.** `Button::with_icon(id, …)` captures what you hand it
715 and outlives the renderer, so invalidating a cache underneath it changes nothing on
716 screen. For bundled cce-icons artwork use `Button::with_icon_name` / `Button::new_icon`,
717 which hold the NAME and re-resolve through `upload_icon` per read; `upload_icon`'s own
718 cache is keyed on `vk::renderer_epoch()`. `with_icon` still means "the app owns this
719 upload", which is right for app-rendered content — and carries the app's duty to
720 re-set it from here. `ImageView` borrows its id on the same terms.
721 - **A WPE client does not self-heal.** Nothing provokes a repaint of a page that has
722 finished loading, so `pump` finds no buffer held and the stale id just stays stale.
723 cce-mail replays `MailWebView::last_frame`; cce-browser has to remap the active view,
724 the same nudge `activate` uses. (A page that happens to animate *would* recover on its
725 own, because `update_pixels` recreates an image under an id the new table lacks — which
726 is exactly how this hides from whatever page you test with.)
727 - **An in-flight worker result can carry a dead id.** A thread that uploaded just before
728 the drop delivers an id naming nothing, and a store caches it as an entry that draws
729 blank for as long as it stays resident. `cce-preview`'s `PageStore` and `cce-map`'s
730 `TileManager` carry a generation for this and free a mismatched result on arrival.
731
732 Verify with `CCE_UI_FAULT_RECONNECT` (below) — **and run the pre-change binary through
733 the same fault first.** A fix that passes a test which never reproduced the bug is worth
734 nothing, and both of the above traps first showed up as a "fixed" build that still drew
735 nothing.
736
737 ## Rendering: one paint path (the Phase 3 state)
738
739 The backend `render()` **always builds a `scene::paint::DisplayList` and tessellates that single
740 list** (`backend::frame::build_frame`, which the Wayland shell's `EngineState::render` presents).
741 An app feeds it by returning `Some(DisplayList)` from `Application::display_list()`; `None` is
742 an empty frame. (Until RFC phase 6al a `None` made the backend wrap the app's legacy `view*`
743 tuples into a list instead; those sinks are gone, and every app implements `display_list`.) `custom_vertices` is
744 appended as a final unclipped batch drawn on top.
745
746 ## The context menu draws in its own popup surface (since 2026-09-25)
747
748 `widget::context_menu` is one global menu that every app shows, paints into its own
749 display list and dispatches by window coordinates. Drawn in the window it was cut off
750 at the window's edge, and a menu taller than the room left could not be seen at all.
751 So on an xdg toplevel the runner mirrors the open menu into an `xdg_popup`
752 (`backend/menu_popup.rs`, with the reasoning in its module docs): the compositor may
753 put it anywhere on the output, and the positioner's flip-y / slide / resize-y keeps it
754 there. A menu cut short scrolls: `ContextMenuState` keeps `content_h` (all the rows)
755 apart from `h` (what is shown) and a `scroll`, and `row_at` / `row_y` / `hit_test`
756 answer for the rows as DRAWN — every host that dispatches through them scrolls for free.
757
758 A popup path existed before and was deleted in July (Phase 6x) for drawing in one
759 place and hit-testing in another. Two rules make this one different, and both are
760 load-bearing:
761
762 - **The configure is written back.** Where the compositor put the popup is where the
763 menu IS (`context_menu::place`), so the rect apps hit-test is the rect on screen.
764 - **The popup takes its own pointer input**, translated by its offset into window
765 coordinates (`pointer_frame`), so apps need no change — their menu coordinates may
766 now simply lie outside the window. The CSD move/resize checks are skipped for it.
767
768 While the popup is up the menu is `hosted`: the apps' in-window `paint*`,
769 `text_labels` and `extra_quads` draw nothing, and the runner paints a copy at the
770 origin (`paint_hosted`). There the plate is the surface's ROOT, so its frost is the
771 compositor's blur-behind, not the in-app pass — which has nothing to sample inside a
772 popup and resolves to flat opaque grey. The compositor's blur cannot compress luminance
773 the way the in-app frost does, so the root plate's alpha is raised to
774 `1 - (1 - a)(1 - k)` to let the backdrop through by the same amount. The compositor
775 blurs popups since the same date (`xdg_popup.rs`'s `update_blur`).
776
777 The popup's renderer is kept across opens: `VkRenderer::detach_surface` /
778 `attach_surface` move it from one popup's `wl_surface` to the next, so a re-open costs a
779 swapchain rather than a device and every pipeline. **Detach before the popup drops** —
780 the drop destroys the `wl_surface`, and a swapchain must not outlive it.
781
782 Layer surfaces keep the in-window menu, placed by `context_menu::constrain_to` with the
783 same flip / slide / shorten rules inside the window; so does any app run with
784 `CCE_UI_MENU_POPUP=0`.
785
786 ### A float row can be two, three or four wide (since 2026-10-01)
787
788 `float2:lo:hi` and `float4:lo:hi` parameter rows are the `float3` row's group with two
789 or four sliders (X Y, X Y Z W): `Float3::set_components(n)` / `with_components(n)`,
790 four sliders stored with the first `n` laid out, drawn, hit and written
791 (`value_string` joins `n` components, `scaled_values` / `set_values_n` read and write
792 them). `ParametersBg` treats every `floatN` row alike (`is_vec_row`, `vec_row_n`) and
793 sizes it with `Float3::preferred_height_for(labeled, n)`; a row whose width changes on
794 a re-read takes the new width in place. Only a three-wide group has a trackball — a
795 direction is three numbers — so `float4:…:trackball` has none. The designer's
796 Attribute node presents its Value through these.
797 `float2_and_float4_rows_are_the_group_with_two_or_four_sliders` is the test.
798
799 ### A parameter pane can draw separators (since 2026-10-01)
800
801 A row of type `separator` (`parameters_bg::SEPARATOR`) is a hairline between two runs
802 of rows: one pixel tall with the ordinary row gap either side, drawn in
803 `plain_quads` in the theme's `surface_border`, and never hovered, focused, edited or
804 given a backing. Its key and value mean nothing — a host writing rows back finds no
805 parameter by them and skips it. Unlike a `section` it has no title and collapses
806 nothing; it is what a host puts between groups of parameters that are about different
807 things (the designer derives them from its templates' `group` metadata).
808 `a_separator_row_is_a_rule_between_rows` is the test.
809
810 ### A slider's range can be soft (since 2026-10-01)
811
812 `Slider::set_soft` / `with_soft`, `Float3::set_soft`, and in `ParametersBg` a `soft`
813 segment after the range (`slider:lo:hi:dec:soft`, `float3:lo:hi:trackball:soft`,
814 `is_soft_row`): a value TYPED into the readout past either end widens the range to hold
815 it, where a hard range clamps it to the end. A drag and the wheel still stop at the
816 ends. It is for a value with no natural bounds whose range is only a scale to drag
817 over — the host is expected to choose the range around the value and re-choose it (the
818 designer's Attribute Value row). The pane now writes a slider row back from the
819 slider's OWN value (`get_scaled_value`) rather than its fraction over the row's
820 declared range, which was the same thing until a range could widen.
821 `a_soft_range_widens_to_a_typed_value` and `a_soft_row_writes_back_what_its_slider_holds`
822 are the tests.
823
824 ### The keyboard can walk a menu (since 2026-10-01)
825
826 `context_menu::set_hovered_item(Some(idx))` highlights a row as the pointer would, and
827 `context_menu::step_hovered(dir)` moves the highlight to the next row that can run
828 (down for `dir > 0`), skipping header rows and `-` separators, stopping at either end
829 rather than wrapping. Both scroll a shortened menu to the row, WITHOUT re-hovering the
830 row under the pointer the way `scroll_by` does: the keyboard put the highlight there.
831 The menu itself still reads no keys — a host that wants a walkable menu routes Up/Down
832 to `step_hovered` and runs `hovered_item()` on Enter.
833 `the_keyboard_steps_the_highlight_over_what_cannot_run` is the test.
834
835 ### A dropdown says whether it is taking input (since 2026-10-01)
836
837 `Dropdown::is_expanded()` is `open && !closing`: `open` alone stays true through the
838 closing animation, while the plate is still drawn but presses and keys are no longer
839 the dropdown's. A host that routes input to a dropdown ahead of what is under it (the
840 designer's dialog hosts one) asks this. Such a host should also know that the runner
841 hands every left press to each registered popover whose hit test MISSES it, before the
842 app sees the press (`close_popovers_missed_by_press`) — and a popover covered by an
843 outer popover's claim always misses — so the dropdown may already have taken the press
844 by the time the app is asked.
845
846 ### A dropdown's text names its font (since 2026-10-01)
847
848 The trigger's text and ▼ are emitted with `control_label_font_detached()` named on the
849 prim, where they used to leave the font to the host. Through the widget walk the
850 adapter attached `widget_font` and nothing changed; but a dropdown painted as a STAMP
851 by another widget (the designer's palette paints its choice rows so) took that
852 widget's font, and the menu `draw_popover` grows out of it — which has always named
853 the font — opened in a different one. The whole configured string is passed, family
854 and size together (`Berkeley Mono 14`): the runner parses both and the size wins over
855 the prim's, which is how the menu's hard-coded 12 px rows and 10 px ▼ come out at the
856 trigger's size. Passing the family alone breaks exactly that.
857
858 A host that draws an open dropdown should hand `render_popover` a real `PaintCtx`, as
859 cce-files does: a `PopoverCollector` keeps fills as plain rects, so the grown plate
860 loses its relief and its corners (`inset_plate` degrades to a flat fill there). The
861 designer's params pane went through one until the same day.
862
863 ### A well with a flush run at its end is ONE field (since 2026-10-01)
864
865 `Prim::Field { rect, radii, depth, split, tint }` (`PaintCtx::field`) is a sunken well
866 that ends in a flush run: left of `split` the interior one step down, right of it back at
867 the surface's level, as a flush control plate's face. Two controls are drawn so: a
868 `ParametersBg` textpick row (the TextBox and its completion picker, a menu-button Dropdown
869 `PICK_W` wide) and a `Spinbox` (its value and its -/+ run). In both the run is the
870 control's right end, reaching its outer edge on the top, right and bottom as a dropdown
871 trigger's plate does.
872
873 It is one prim because two did not work: a recess for the well and a trough for the run,
874 side by side, each shade their OWN box, so at the seam each turns its own square corner
875 and the strong line of the edge jumps — a recess is lit at its outer rim, a trough at its
876 inner lip — and the outline reads broken exactly where the two meet. The field's outline
877 is evaluated once (the whole rect, its own radii, `MODE_FIELD` in `shader2d.wgsl`). Its
878 outer half is ONE profile all the way round — the recess's fall and shoulder — and only
879 the inner half differs: on to the floor in the well, mirrored back up to the face in the
880 run, blended across one wall width about the seam. Note the run's valley is NOT
881 `Prim::Trough`'s, which fits its whole fall and rise into the wall's width, so its outer
882 half is a compressed copy of a step that read differently from the well's beside it. The
883 run's FACE is a rounded rect of its own, inset half a wall on every side — from the
884 outline on the top, right and bottom, from the seam on the left — whose lip is the
885 well's fall mirrored back up, and whose left corners are its right corners, so the
886 button has the same padding and rounding at both ends (since 2026-10-02; until then
887 the lip was the outline's own inner half and the face met the seam square). Where the
888 face's rounded corner leaves room by the straight outline is the valley's flat floor.
889 The seam is the well's own right wall's inner half, from half the step at `split`
890 down to the floor, meeting the face's lip where both stand at half the step; it fades
891 out at the outline, which runs straight across. **The run is laid out a wall wider than
892 its face**, so the BUTTON is what reaches the well and what it carries stands in its
893 middle: a textpick picker is `PICK_W` plus a wall, its arrow centred
894 (`Dropdown::center_arrow`) — which is a trigger's ARROW SLOT (`dropdown::arrow_slot`),
895 the right end every dropdown centres its ▼ in, so the picker's arrow and every other
896 trigger's stand in one column (since the same day; they stood 18 px in from the right
897 end, two pixels off the picker's; `a_pickers_arrow_lines_up_with_a_dropdowns`) — and a spinbox's run begins a wall before its flat layout's
898 buttons (`SpinGeom::run_x`), its face halved at its middle by the -/+ seam with each
899 glyph in the middle of its half — hit zones, washes, glyphs and relief all read
900 `SpinGeom`. For a few hours on 2026-10-02 the seam was the right side mirrored instead
901 — the button's valley rising to a ridge at the surface's level, the well's wall falling
902 from it a whole wall further left — which read as a strip of new surface between the
903 well and the button rather than a wider button; the well's floor ends where it did
904 then, the button having taken the strip. (Before that, the face began half a wall from
905 the seam with the arrow at the trigger's usual right-hand place, and its left side read
906 narrower than its right.) A textpick row's TextBox ends where its picker begins, so its
907 text stops at the well. Never grouped into a host
908 plate (its profile is not a monotonic step); the overlay's host-box slot carries `split`.
909 The legacy banded path and flat hosts (`layout::bridge`) draw the two-box form.
910
911 **Every flush control wears the run's edge** (since 2026-10-02): `PaintCtx::inset_plate`
912 — the one flush control plate, which `ControlPlate`'s Flush stance, every widget and the
913 apps' own plates all come through — draws a face and a field that is all run (its seam
914 `FIELD_RUN_ONLY` px to its left), where it drew a `Prim::Trough`, whose outer half is a
915 compressed copy of a step and read differently from a well beside it. So a dropdown
916 trigger and its grown menu, a button, a breadcrumb's run, the font selector, a menubar's
917 triggers, and the calendar's, cce-cloud's, cce-files' and the system interface's own
918 plates have the edge of the run at the end of a text row's field. It went in a widget at
919 a time the day before (`ControlPlate::with_run_edge`, `PaintCtx::flush_run`), both gone
920 now that the plate itself has it. In a parameter pane the dropdown and button rows are
921 fields too (`fields`; the pane's `troughs()` list went, and its button rows had been a
922 raised boss the button never drew). The flat-host bridge pairs a plate's face with its
923 field as it did with its trough. `Prim::Trough` / `PaintCtx::trough` stay for an
924 explicit valley and `cce-relief`'s preview. `a_dropdown_trigger_wears_the_runs_edge`,
925 `focus_lights_the_plate_rim`, `a_breadcrumb_and_a_font_selector_wear_the_runs_edge` and
926 `an_inset_plate_is_a_field_that_is_all_run` are the tests.
927
928 **A plain text box is a `Prim::Recess`**, grouped into the plate under it when the
929 plate's grouping window is open. For a few hours on 2026-10-02 it was a field that was
930 all well (`PaintCtx::well_field`, 6634ba3), to dodge a doubled outline that grouped
931 recesses drew; the cause was fixed in the plate shader the same day (see "A grouped carve
932 shades as its overlay does" below) and the workaround reverted, since a grouped recess
933 and an overlaid one now draw the same pixels.
934
935 The pieces that feed it: `ParametersBg::fields` (the pane's list, drawn after its troughs,
936 hover-tinted like them; textpick rows and spinboxes with a run are in neither `reliefs`
937 nor `troughs`), `TextBox::joined_right` (the box stops at the seam), `Dropdown::set_radii`
938 (the picker's own raised paint, square at the seam), and `Spinbox::relief_parts`, which
939 returns a `SpinRelief` — the outline as handed over, its radius and depth, and the run's
940 `split` with the engraved -/+ seam; the widget's own paint carves it inside as every well
941 is. `textpick_rows_carry_a_picker` and `a_spinbox_is_one_field_with_its_run_at_the_right_end`
942 are the tests. Until the same day the picker and the run were nested INSIDE a full-width
943 well, their faces stopping at the base of its wall, so they never reached the edge a
944 dropdown's ▼ does.
945
946 ### A field is one object, and a toggle is a field whose run glides (since 2026-10-02)
947
948 A dropdown, a button, a text row with its picker, a spinbox and a toggle are ONE
949 object: a well cut into a plate with a flush plate, the RUN, standing in it, one
950 outline round both (`Prim::Field`). They differ only in where the run is:
951
952 | form | `Field::` constructor | run | well |
953 | --- | --- | --- | --- |
954 | text box | `well` | none | the whole field |
955 | flush control plate (dropdown trigger, button, breadcrumb run, …) | `run` | the whole field | none |
956 | text row with its picker, spinbox with its -/+ run | `ending_in_run(split)` | the right end | left of it |
957 | toggle | `sliding_run(width, t)` | half the field: the left end off, the right end on | the other half; both sides mid-glide |
958 | check box | `well` (`Checkbox::box_field`), and checked a `run` plate in it (`Checkbox::box_plate`) | none, or a square plate in its middle | the whole field, showing all round the plate |
959
960 **`scene::paint::Field` is the object** (since the same day), and
961 `PaintCtx::field(&Field)` the one way to paint one: the outline (rect and radii,
962 the carve's boundary — a widget takes it through `carve_inside`), the wall width,
963 the run's span clamped to the outline (`run_span`, `None` for a well;
964 `Field::spanning(a, b)` is the general form the others are), and a rim `tint`
965 (`with_tint`). What builds a field asks for it by its FORM, never by numbers that
966 encode one — the old `field(rect, radii, depth, split, tint)` took a seam put
967 `FIELD_RUN_ONLY` px off the field to mean "no well", and every host spelled that
968 sentinel itself. Now only `Field::prim_span` does, turning a run that reaches an
969 end of the field (within the shader's half pixel) into the prim's encoding. A
970 field with NO run paints a `Prim::Recess`, as a plain well always has, because a
971 recess groups into the plate under it and a `Prim::Field` never does — so the
972 text box is a form of the object without changing what it draws. The widgets
973 hand theirs out: `TextBox::well`, `Toggle::field`, and `ParametersBg::fields` is
974 a `Vec<Field>` (the pane tints the hovered one); `PaintCtx::inset_plate` is a
975 face and `Field::run`.
976
977 `Prim::Field`'s run is a span, `split` to `end`. Where it stops short of the
978 right end, a well lies to its right too, and `MODE_FIELD`
979 mirrors everything it does on the left: the face is inset half a wall from that
980 seam with the corners of the run's LEFT end, and the seam is that well's left
981 wall's inner half. A run reaching the right end takes the same path through the
982 shader as before, term for term, so every field that was drawn is drawn as it was.
983 The host-box slot carries both ends (`p_host.x`, `.y`); the legacy banded path and
984 the flat-host bridge draw a well box either side of the run.
985
986 **The Toggle is drawn so** (`Toggle::field`, its sliding field, lit while focused;
987 `Toggle::face` what the relief-off path lights; `ParametersBg::fields` takes the
988 same, and its `reliefs` no longer carries toggles). Until this it was a recess
989 with a raised `Boss` on its floor: the one control whose nested plate stood ABOVE
990 the surface where every other stood flush with it, and whose outline turned its
991 own corner round the plate instead of running round the whole control. The run
992 is laid out half the field and a wall wider than its face, as a picker is, so the
993 face reaches the well; it glides by `slide_t` as the boss did. Focus lights the
994 field's rim (`ControlPlate::focus_tint`), where it lit the boss's.
995 `a_toggle_is_a_field_whose_run_glides` is the test.
996
997 **The Checkbox widget is a field too** (`Checkbox::field`): a square as tall as
998 the control (at most a toggle's height) at the left of its label, the largest
999 square in the rect when it has none — an empty well unchecked, and checked the
1000 same well with a square plate centred in it (`Checkbox::box_plate`, `PLATE_SHARE`
1001 of the side, all run), the well showing all round. Since 2026-10-05 (bae3712):
1002 until then a checked box drew the toggle's run, half the box's width and its
1003 whole height, which in a square box is a tall bar, and a ticked box read as
1004 having narrowed. A box FILLED with a run was tried before that and dropped: at a
1005 control's size an all-run field's outline is an empty well's, and the two states
1006 were hard to tell apart. In a parameter pane a `checkbox` row has always been a
1007 Toggle. `a_checkbox_is_a_well_with_a_square_plate_in_it_or_not` is the test.
1008
1009 **A check drawn inline is the same box** (`Checkbox::paint_inline(ctx, cx, cy,
1010 half, checked)`): cce-list's rows, a markdown task item, the doc editor. Both it
1011 and the widget build the box through `Checkbox::box_field(square)` and, checked,
1012 `Checkbox::box_plate(&well)`, so they cannot disagree; the corner is the toggle's IN PROPORTION (its radius over
1013 its height), which is the toggle's corner exactly at a toggle's height and keeps a
1014 14px box (`Checkbox::INLINE_HALF`, cce-list's) a rounded square where the
1015 toggle's radius taken whole would make it a disc. They drew a ring with a blue
1016 dot until the same day (`paint_round_mark`, gone). Rendered at 10–14px it reads at
1017 1x and 2x, the smallest least clearly; the state is the plate, with no colour.
1018 `an_inline_check_is_the_widgets_box` is the test.
1019
1020 ### A grouped carve shades as its overlay does (since 2026-10-02)
1021
1022 A full-ring, untinted `Prim::Recess` / `Boss` emitted while a plate's grouping window is
1023 open becomes a CSG feature of that plate's one draw (`MODE_PLATE` in `shader2d.wgsl`);
1024 otherwise it is its own overlay (`MODE_RECESS` / `MODE_BOSS`). The two must look the same
1025 on the plate's face. They did not: the grouped one drew a **doubled outline**, two thin
1026 black lines down its shadowed wall and two bright ones down its lit wall, where the
1027 overlay drew one soft edge. Two causes, both in how the plate path applied the carves:
1028
1029 - **The shade line took the carves' slope.** The plate's colour subtracted
1030 `roll_shade_line(sv)` with `sv` the roll's slope PLUS every carve's. The shade line is
1031 a narrow lobe at the half-vector's tilt (22.5° at the DE's 45° light); a carve wall's
1032 tilt rises through that angle and falls back through it, so the lobe fired twice per
1033 wall — subtracted in colour units from a face near 0.016 linear, both hits went to
1034 black. The overlay path, and `relief_shade::carve_shade`, never had a shade line: it
1035 exists for the raised roll. It is now `roll_shade_line(sv_rim)`, the roll's alone.
1036 - **Diffuse and curvature were a multiply on the face; the glint was added whole.** On a
1037 dark face a multiply barely moves the pixel, so the overlay's lit shoulder vanished,
1038 and the glint's two crossings (the same twice-through-the-angle as above) no longer
1039 cancelled against the fillet's darkening as they do inside the overlay's one signed
1040 value. The plate now lights its roll alone as before (multiply plus glint and shade
1041 line), and composites what the carves ADD — the summed normal's diffuse and glint less
1042 the roll's, plus their curvature — as an overlay is blended (`carve_over`: screen
1043 toward white, multiply toward black). On the face that is the overlay's `v` term for
1044 term; across the roll the normal is still the summed one, the junction grouping is
1045 for. A plate with no carves composites `v = 0` and is unchanged to the bit.
1046
1047 Measured with `examples/grouped_recess_probe.rs` (a plate whose recesses group beside the
1048 same recesses forced to overlay by a transparent quad): grouped and overlay columns
1049 5,462 px apart before, 0 after; in the designer with grouped text wells, 0 px from the
1050 `well_field` rendering the text boxes then had (since reverted, above).
1051
1052 **And it is tested by rendering, not by reading** (`vk::plate_probe`, `cfg(test)`): an
1053 offscreen 2D render — a `DisplayList` through the runner's own `tessellate_display_list`
1054 and `dl_batches_2d`, drawn with the LIVE pipeline into an image and read back. The
1055 renderer's pieces it needs are shared functions, not copies, so the two cannot draw
1056 differently: `create_ui_pipeline` (descriptor layout, push range, vertex layout, blend),
1057 `batch_push_constants` (a batch's 32-float block, feature rebase included),
1058 `window_info_data` and `relief_px_at`. It draws plates, carves and flat geometry; it
1059 refuses blur-behind (the snapshot is the swapchain path's) and draws no text or images.
1060 `render` returns `None` with no Vulkan device and the test skips with a note; it is not
1061 `#[ignore]`d, since a device is the normal case here and a regression nobody runs is
1062 not caught. `a_grouped_carve_is_drawn_as_its_overlay_is` renders two plates, one grouped
1063 and one forced to overlay, asserts the grouping really happened (five features), and
1064 holds them equal to the pixel: against the pre-fix shader it fails at 32,504 px, worst
1065 channel 54. Clean under `VK_INSTANCE_LAYERS=VK_LAYER_KHRONOS_validation`. Opening the
1066 device adds about 2 s to the suite. A test of any other 2D look can use the same harness.
1067 (Across devices the live path agrees to within 5/255 on about 125 edge pixels of the
1068 probe — float rounding at antialiased edges, not a shading difference.)
1069
1070 **It was never the GPU.** The report was "doubled on the NVIDIA card, single on the
1071 Iris Xe"; the 2D path is identical to the pixel on both, before the fix and after. Two
1072 things made it look GPU-specific. Grouping is decided per frame by what is painted
1073 between a plate and its carves, so two captures of "the same" pane can differ in what
1074 grouped. And inside a `cce-shadow` session the NVIDIA ICD does not load at all unless
1075 the process can reach an X display (`DISPLAY=:0` and `XAUTHORITY=$HOME/.Xauthority`):
1076 `vk_icdGetInstanceProcAddr` fails, the loader skips the ICD, and `CCE_VK_DEVICE=discrete`
1077 fell back to the Intel device SILENTLY. It says so on stderr now (below, "Debug
1078 environment variables"); `grep -c nvidia /proc/<pid>/maps` is the check from outside.
1079
1080 ### A plate can be turned inside out: `Prim::Frame` (since 2026-10-06)
1081
1082 `PaintCtx::frame(rect, hole, hole_radii, material, depth)` is a Bevel whose face is
1083 everything in `rect` OUTSIDE `hole`, its rolled edge running round the hole and falling
1084 INTO it. A corner of the hole is therefore an inside corner of the plate — a **cove**,
1085 rounded at the hole's radius in the DE's corner family — which no box prim can draw: box
1086 radii round only convex corners, and `ConcaveFillet` shades with the carve WALL profile,
1087 not a plate's roll, so a fillet beside a Bevel edge changes profile at the join.
1088
1089 - **The shader is the plate branch, unchanged** (`MODE_FRAME` = 17): the hole's SDF is
1090 negated (`gd = -gd0`), so the depth into the face is the distance outside the box and
1091 the outward gradient points into the hole, and everything after is `MODE_PLATE`'s —
1092 roll, crest, frost, focus tint and CSG carves.
1093 - **It is a carve host over `rect`**, like a Bevel: carves inside it group into its draw
1094 (the feature offset is rebased for mode 17 as for 1 and 14). Its own outer edges are
1095 NOT rolled — lay them past the window or under something.
1096 - **The legacy banded path fills only below the hole**, square: it has no inside-out SDF.
1097
1098 The first consumer is the designer's playbar, a shelf of the window's bottom edge whose
1099 top meets each side lip in a cove. `a_frame_is_an_inside_out_plate_that_hosts_its_carves`
1100 is the test.
1101
1102 ### A row can lead to a page, and a side swipe turns it (since 2026-10-02)
1103
1104 `context_menu::set_row_page(idx)`, called after `show` like `set_row_slider`, makes a
1105 row a PAGE row: it wears `›` at its right end, and a press on it, or a two-finger swipe
1106 to the side with the pointer on it, asks to TURN the menu into what the row leads to —
1107 another list of rows, or another plate altogether (the designer's dialog). **The menu
1108 recognizes the turn; the host shows the page**, since only the host knows what is
1109 there:
1110
1111 - **A turn is a `PageTurn`**: `Into(row)` or `Back`. A press is read with
1112 `turn_at(x, y)` (the standard `mouse_input` path records it instead of hiding); a
1113 swipe arrives through `mouse_wheel` and is drained with `take_turn()`.
1114 - **`show_page(x, y, back, options, header_count, target)`** shows a page with its
1115 top-left at the corner of the plate it replaces (`x()`, `y()` read before), so the
1116 menu reads as turning rather than a second menu arriving. With `back` naming the
1117 plate it came from, a BACK BAND across its top reads `‹ Title`: a press on it is
1118 `Back`, it hovers like a row, and the rows begin under it (`row_y`, `row_at` and the
1119 height all count it). A swipe back from anywhere on a page with a band is `Back`; on
1120 a menu that was opened rather than turned to, it goes nowhere. A page is `turned`:
1121 placed in a window, or by the popup's positioner, it SLIDES on screen and never flips
1122 up from the corner it took over.
1123 - **`refill(options, sliders)`** changes the shown rows' labels and slider values in
1124 place (hover, scroll, band, page rows and a held slider kept) — how a host re-marks a
1125 switch on a page that stays up after it ran. A different row count is refused.
1126 - **The swipe is `widget::side_swipe`**, one recognizer per window (`side_swipe::feed`)
1127 shared by the menu and any plate a host turns into, so one gesture turns one page
1128 however many plates pass under the fingers; the lift (`ScrollPhase::FingerEnd`) or a
1129 250 ms pause readies the next. It fires once the fingers have gone `SWIPE_PX` (40) to
1130 the side, half again more sideways than vertical; a tilt-wheel notch is a whole swipe.
1131 **Forward follows the content**: the delta that scrolls a list to show what is to its
1132 right, so under natural scrolling the fingers go LEFT to go in and right to go back,
1133 as on every touch surface, and the user's scrolling setting flips both. A test that
1134 swipes twice calls `side_swipe::end_gesture()` between, since a gesture's phase is the
1135 window's (a thread's own in a test).
1136 - **The rest of a gesture that turned is the turn's**: `side_swipe::swallow(delta)`,
1137 asked at the top of a host's wheel handling, is true for it until the lift, and the
1138 host drops the event. Without it a page narrower than the plate it replaced left the
1139 fingers over the scene, and the end of a swipe back orbited the camera.
1140 - **A page MOVES the popup that is up** (`menu_popup.rs`, `xdg_popup.reposition`,
1141 version 3), anchored at the corner, without `FlipY`; only where it cannot (no popup
1142 placed yet, an older protocol) is a new one opened. A NEW surface under a pointer that
1143 has not moved gets no pointer focus until it moves, so with a replaced popup the rest
1144 of a swipe and the swipe back went to nothing — found in a shadow session, where the
1145 first cut turned forward and then would not turn back.
1146
1147 - **A turn is animated** (`TURN_MS`, 180 ms, eased out): the plate grows or shrinks
1148 from the size of the one it replaced to its own at the shared corner (`drawn_rect`),
1149 the rows it had slide away `TURN_SLIDE` px and fade, and the page's slide in from the
1150 side the turn comes from and come up — forward from the right for a page with a back
1151 band, back from the left for one without. The old plate is a clone taken in
1152 `show_page`, from a menu that is up or was hidden in the same moment (a host that
1153 closes one menu and shows the next in one dispatch); `turn_from_size(w, h, forward)`
1154 is for a turn from a plate the menu does not draw (the designer's dialog). While it
1155 runs `natural_geometry` is the larger of the two sizes, so the popup is repositioned
1156 to hold both and again to the page's own when it lands, and the runner asks for
1157 frames (`is_turning`). **Both sets of rows fade, geometry and all**: each is
1158 drawn aside (`paint_rows`) and replayed moved through `Prim::faded`, which scales a
1159 colour's alpha, a text's or an image's, and a GROOVE's `strength` — the factor on
1160 its shading, specular and AO, since a carve has no colour to fade (a separator was
1161 the one relief prim a menu's rows draw; the slider rows are coloured quads). The
1162 relief prims with no colour or strength come back as they are. Squared fades
1163 (`turn_fades`), so the two are seldom both legible at once.
1164 `a_turn_fades_the_separators_of_both_plates` is the test. **A turned page that needs a NEW popup is handed over from the
1165 window** (`MenuPopup::handoff`): it stays drawn in the window until the popup's
1166 first configure, and the frame after commits the popup ahead of the window
1167 (`take_menu_popup_lead`). Hosted at once, as a menu opened at the pointer is, a
1168 swipe back from the designer's dialog left a frame with neither plate — the dialog
1169 gone, the popup not placed — and, drawn after the window, the popup arrived up to a
1170 window frame's draw late. `CCE_UI_TURN_MS` slows it down, to capture a turn frame by frame in a
1171 shadow session. `a_page_turn_grows_the_plate_from_the_one_it_replaced` is the test.
1172
1173 **Until this a row could open a SUBMENU** (2026-09-29 to 2026-10-02): a second
1174 `ContextMenuState` flying out beside the row on hover, in a child popup, with a
1175 hover-intent triangle. It went with the change, the `SUBMENU` thread-local, the
1176 `submenu` module, `SubmenuSpec` and the child popup included: the designer, its only
1177 consumer, had flyouts on some rows and plate swaps on others, two gestures for one
1178 idea, and the user asked for one. `context_menu_page_tests` covers the menu,
1179 `side_swipe::tests` the recognizer.
1180
1181 ## Plates, wells and seams — the surface vocabulary
1182
1183 Everything cce draws is a lit surface, and the words below name those surfaces
1184 so that a description of how a screen should look or behave can be given in
1185 them. Use them in code comments, commit messages, and conversation; when a new
1186 widget does not fit one of them, say so rather than stretching a word.
1187
1188 - **A plate is any lit, bounded surface with a silhouette and a stance.** The
1189 silhouette is its corner radius (the DE's superellipse corner family,
1190 `corner_shape`). The stance is how it sits on the surface beneath it:
1191 - **raised** — it floats above that surface, drawn as a `Bevel` (fill plus
1192 rolled edge) or a `Boss` (edges only, the surface below as its face):
1193 menus, popovers, raised buttons, a ButtonStrip's selected plateau, a
1194 Breadcrumb in its floating stance.
1195 - **flush** — it sits level with that surface inside a groove ring, drawn as
1196 an `inset_plate`: buttons, dropdown triggers, breadcrumb runs, font
1197 selectors. Its face is the surface below unless a fill is configured.
1198 - **Plates nest, and the ladder has three rungs of the same object.** The
1199 **root plate** is a window's background (RFC 7a; `plate { root }` in
1200 config). **Pane plates** are the surfaces controls and content sit on inside
1201 a window; they carry the corner dock (`widget/plate_dock.rs`). **Control
1202 plates** are the things you press. A control plate is not a different kind
1203 of object from a root plate — it is a plate at a smaller scale.
1204 - **Wells are not plates.** A well is an opening cut into a plate that you look
1205 into or type into, drawn as a `Recess` (a `Trough` when it holds a moving
1206 part): text boxes, keybind and spinbox fields, slider and progress tracks,
1207 the trackpad pane, the ColorSelector's recess. Things you press are plates;
1208 things you enter are wells. A well's floor can carry fills (a progress
1209 fill, a colour swatch) — those are segments of the floor, not plates.
1210 A **canvas well** is a well you look into or draw in — Trackpad, Slider2D,
1211 the bevel and ramp previews — and every one is cut from the same material:
1212 the plate darkened for its floor (`colors::WELL_FLOOR`) and the recess for
1213 its rim, through `PaintCtx::well_floor` / `well_rim` (`canvas_well`),
1214 rounded like the text wells. The Ramp editor's plot is the reference look.
1215 With relief off, a well is its frame: the one hairline
1216 `colors::well_frame_color` gives every well (lit in the highlight while it
1217 is active, the relief rim's focus cue), and still no floor of its own.
1218 - **A group is a lasso.** `widget::Group` owns nothing: it is a set of member
1219 ids, and its frame is the padded hull of wherever the host's layout put
1220 them, with a title tab flush on the top edge — the section's frame
1221 (`PaintCtx::section_well` under relief, the section outline otherwise), so
1222 a group is a segment of the plate it sits on, parted by a section carve
1223 rather than a seam. Given its plate (`with_plate`) and `with_fit`, sides
1224 within `snap` of the plate's edge take the edge one padding in and corners
1225 on the plate's corner follow it concentrically: on a narrow pane a group is
1226 that pane's inset lining, on a wide one a lasso. A group never hits.
1227 - **A dialog is a raised plate around a lasso** (`widget::Dialog`, since
1228 2026-10-08). Like a group it owns nothing: the host lays its members out,
1229 and its plate is their padded hull with a title band above, in the menu's
1230 material. `open(ctx, members)` makes it MODAL through the context
1231 (`UiContext::open_modal`): the Tab walk is trapped among the members, every
1232 widget outside reads as covered (`is_coordinate_covered`, which every hit
1233 test and hover asks) so nothing behind takes a press, focus moves in and is
1234 given back on `close`, and the members are linked as its children, so the
1235 accessibility tree nests them under a modal `Dialog` node. Paint the dialog,
1236 not its members (it paints them on its plate), after everything it covers;
1237 `set_backdrop` dims the window. Escape is the host's to read. Hide the
1238 members while it is closed, or they are stops nobody can see. The demo's
1239 Options… dialog is the pattern.
1240 - **Segments are plates or floors sharing one silhouette, parted by seams.**
1241 A seam is a `Groove` cut across the shared surface, dying into its rolled
1242 edge: Breadcrumb segments, ButtonStrip segments, the ColorSelector's
1243 text/swatch split. One silhouette, one relief pass, seams between. A
1244 `Separator` is the same cut made in the plate it sits on, with no segment
1245 to part: a groove that dies out at its own ends (flat: a hairline).
1246 - **Bands sit outside this vocabulary on purpose.** The Slider's swelling
1247 band is a band. (The round Checkbox mark was the other exception, a mark;
1248 since 2026-10-02 a check box is a field wherever it is drawn.) Do not call
1249 them plates or wells.
1250
1251 What this buys, and where the code is heading:
1252
1253 - **Navigation is stated in plate terms.** `Input::focus_role` says what a
1254 widget is to the keyboard: a `Plate` (a thing you press — Enter / Space act
1255 on it while focused), a `Well` (opens for typing when focused), or `None`
1256 (not a stop). `UiContext::focus_step` walks the stops in reading order (row,
1257 then x) with a `Group`'s members as one contiguous run where the group's
1258 first member falls (`focus_clusters`), wrapping; `focus_step_group` jumps
1259 between runs (input.kdl `focus_next_group` / `focus_prev_group`, defaults
1260 `ctrl+tab` / `ctrl+shift+tab`). The runner calls them for Tab / Shift+Tab
1261 and the chords unless the app opts OUT with `Application::plate_navigation`
1262 (default ON since 2026-10-08, accessibility RFC phase 3; the designer, the
1263 display manager and cce-notes give Tab meanings of their own and return
1264 false), and tells the app through `Application::focus_stepped` — an app that caches
1265 its geometry until its own rebuild flag raises it there. A focused widget
1266 that types Tab keeps it (`Input::keeps_tab`: a multi-line `TextBox` while
1267 editing), and the group chord leaves it. The walk needs the
1268 app's context exposed (`ui_context_mut`), so an app without one (a terminal,
1269 a web view) gets Tab as before; a widget registered but never drawn is a
1270 stop nobody can see, so register a widget only while it is shown. The ring reaches flat-path hosts
1271 through `RenderTarget::inset_plate_tinted` and `CarveKind::Boss { tint }`.
1272 `CCE_FOCUS_DEBUG=1` prints the stops in walk order.
1273 The focus ring is the plate's own silhouette: `ControlPlate::with_tint`
1274 tints the rim (a tinted `Trough`, `Boss` or `Bevel`), the same treatment a
1275 well's `recess_tinted` gives its rim while editing — never extra geometry.
1276 The tint recolours the relief rather than replacing it: the rim's light
1277 composites in the accent instead of white and its shadow in a dark accent
1278 instead of black (`FOCUS_SHADOW_LUM`, both at `FOCUS_GAIN`, in
1279 `shader2d.wgsl`), so a focused plate still reads which edges face the lamp.
1280 A Checkbox and a Toggle light the rim of their field, as every field is
1281 lit.
1282 Roles today: Button, Checkbox, Toggle, Dropdown, FontSelector, ButtonStrip
1283 (arrows move the selection between its segment plates), RadioGroup (one
1284 stop; arrows move the choice, which follows them) and Breadcrumb
1285 (arrows walk its visible segments, Enter navigates) are plates; TextBox,
1286 Spinbox, ColorSelector, KeybindRecorder, TreeList, Slider (a band, but
1287 entered and adjusted in place — arrows step it, Enter opens the readout)
1288 and RangeSlider (one stop, two ends: arrows step the focused end, Up / Down
1289 switch ends) are wells. A new focusable widget declares its role and handles `FocusIn`
1290 / `FocusOut`.
1291 - **What a plate is made of is a `scene::Material`** — tint, `Frost` (opaque, or
1292 frosted with compression / refraction / radius) and `Finish` (how it answers the
1293 light: the old `relief_shade::Material`). `docs/rfc-material.md` is the design and
1294 its phase tracker. `Material::fill_tint` is the ONE place the blur-behind sentinel
1295 (a negative alpha) is written; `PlateSpec::fill` and `param_plate_fill` call it.
1296 Rung defaults: `Material::root()` / `pane()` / `control()`, `popover(base)` for
1297 menus; a well floor is `host.floor(lifted)`. `PlateSpec`, `ControlPlate.face`
1298 (`Option<Material>`: `None` = the surface below IS the face) and the prims
1299 `Plate` / `Bevel` / `Sphere` / `Droplet` carry one, and `PaintCtx::plate` /
1300 `bevel` / `sphere` / `droplet` / `inset_plate` take one; the tessellator reads
1301 each prim's fill and push-constant finish from it, and only the carves still take
1302 the DE finish. `Material::from_fill` / `face` decode a colour a legacy site still
1303 holds (the flat-path `RenderTarget` is colour-typed) — a new site says
1304 `Material::opaque` / `with_frost` instead. **`tests/plate_golden.rs` is the exit
1305 test for any change that must not move a pixel**: dump before, compare after.
1306 - **One plate spec per rung, not five copies.** The root and pane rungs are
1307 `scene::paint::PlateSpec` (RFC 7b, painted by `PaintCtx::plate`). The
1308 control rung is `scene::paint::ControlPlate` (re-exported from `widget`):
1309 footprint, per-corner silhouette, `PlateStance` (raised, flush or flat), face
1310 and depth, painted by `PaintCtx::control_plate` — the ONE place a control
1311 face's relief is composed (raised with a face = bevel; raised faceless =
1312 carve inside + boss; flush = carve inside + inset plate). Button, Dropdown,
1313 FontSelector, Breadcrumb and the ButtonStrip's selected plateau draw through
1314 it; the migration was prim-identical against a dump of every face. A new
1315 control face goes through `ControlPlate`, never a hand-rolled carve.
1316 - **`Flat` is the stance for a control made of its pane's material.** The two
1317 relief stances both carve INSIDE the footprint, which costs a control two
1318 things a pane has: its visible edge sits half the carve depth in, so a
1319 control laid out on the same numbers as a pane does not line up with one;
1320 and its face is laid through a stroke, which the blur-behind sentinel (a
1321 negative alpha) does not reach, so it cannot be frosted. `Flat` fills the
1322 footprint with a quad and nothing else — silhouette equal to the rect,
1323 frost carried, focus `tint` drawn as a ring since there is no rim to light.
1324 It carries ONE radius, not four, so the concentric corner adjustment a
1325 nested relief control computes has no equivalent. Reach for it when a bar
1326 or a toolbar should read as plates at a smaller scale rather than as
1327 controls of a different kind (`Button::with_flat`, `Dropdown::with_flat`);
1328 leave the relief stances alone for things that should feel pressable.
1329 - **Radii are configured per rung, overridden per widget.** Root:
1330 `style.surface.plate.root.corner_radius` (`color::root_plate_corner_radius`).
1331 Pane: `plate_corner_radius`, falling back to the root's. Control:
1332 `style.control.corner_radius` (`layout::control_corner_radius`, default 8) —
1333 every control-scale getter (button, dropdown, font selector, slider,
1334 spinbox, textbox, toggle, list and tree wells; the ColorSelector's two via
1335 the textbox) falls back to it when the widget's own `corner_radius` key is
1336 unset, so a per-widget key is an override, not a requirement. Do not give a
1337 new control-scale radius getter a literal default; fall back to the rung.
1338 - **A config hex is gamma-decoded; a built-in default colour is not.** The
1339 style loader's `parse_hex` runs every channel through `srgb_to_linear`
1340 (alpha excepted), so `"#595969"` arrives as `[0.10, 0.10, 0.14]` — which is
1341 exactly `PARAM_BG`'s default. The constants in `color` are already
1342 linear, so **the hex that pins a default is not that default's floats times
1343 255.** `PARAM_BG = [0.10, 0.10, 0.14]` reads as `#1a1a24` if you scale it
1344 naively, and `#1a1a24` decodes to `[0.010, 0.010, 0.018]` — a plate ten
1345 times darker than the one you were trying to preserve, silently, because
1346 both spellings are valid config.
1347
1348 Round-trip a default with `l2s(c) = 1.055·c^(1/2.4) − 0.055` (the inverse of
1349 `srgb_to_linear`) before writing it into a config, or read the value back
1350 out of the running app. This cost a measurement round on 2026-09-19: a
1351 `backdrop_compression` sweep meant to hold the tint constant was silently
1352 sweeping the tint too, and the two halves of the experiment disagreed by 3x
1353 on the plate's luminance.
1354
1355 Two smaller edges of the same knife: an **8-digit** hex keeps its alpha raw
1356 (`a/255`, no decode), so `#05050840` really is a quarter opacity; a
1357 **6-digit** hex sets alpha to **1.0**, so dropping the last byte off a
1358 translucent plate colour makes it fully opaque rather than leaving it
1359 alone.
1360 - **Frost is one block, and "unfrosted" is not "solid"** (2026-09-28). The
1361 default plate material's recipe is spelled `style.surface.plate { frost
1362 radius=5.5 compression=0 refraction=0 }` — the same `frost` child a named
1363 material has — with a bare `frost` frosted at the defaults and `frost
1364 (bool)false` off. The four keys it replaced (`blur` as the switch,
1365 `radius`, `backdrop_compression`, `refraction`) were aliases for the rest
1366 of that day and are RETIRED: the loader reports one it finds
1367 (`color::retired_surface_keys`, a warning naming the path) and does not read
1368 it — so a config with `blur=true` and no block is sharp, and the warning
1369 is what says why. A material's `frost` child spells its compression
1370 `compression` too; its `backdrop_compression` alias went with them.
1371 cce-relief writes the block (only for a frosted plate, since the block is
1372 what frosts one), seeds from a retired key it still finds, and removes
1373 the retired keys on Save, so a file migrates the first time it is saved.
1374 The enum variant is `Frost::Unfrosted`
1375 (was `Opaque`): it means the plate never samples its backdrop, and a
1376 translucent tint stays translucent with a SHARP view through it — which
1377 is what the old name kept reading as "covers everything". The pane tint
1378 has a clear spelling too, `style.surface.plate.pane.color`, whose alpha
1379 is the whole tint strength; the legacy `style.surface.param.color` is
1380 still multiplied by the top-level `plate_opacity` line, as it always was
1381 (`color::pane_color_is_whole`).
1382 - **A frosted plate's legibility is `backdrop_compression`, not opacity.**
1383 Blur destroys a backdrop's spatial DETAIL and preserves its mean LUMINANCE,
1384 and text contrast is a mean-luminance property — so `resolve_blur`'s closing
1385 `mix(backdrop, plate, opacity)` hands the backdrop's brightness through at
1386 `1 - opacity` whatever the kernel does. At the designer dialog's 0.25 that is
1387 75% of whatever is behind it. Measured on a row label (`#ccccd4`) over the
1388 designer's Alt+D plate at the stock tint: **1.16:1 over a white viewport,
1389 6.65:1 over the dark one** — the bright end not a contrast ratio so much as
1390 its absence. More blur moves neither number, which is the whole of the
1391 "liquid glass" legibility problem, and why refraction and specular cannot
1392 help: they are shape cues, and legibility is a luminance budget.
1393
1394 `style.surface.plate { frost compression=… }` (0..1, `color::plate_backdrop_
1395 compression`, **default 0** — every existing config keeps today's look)
1396 remaps the blurred backdrop's luminance toward the plate's own key before
1397 the tint, holding its chromaticity. It is not opacity and not "darken": it
1398 is SYMMETRIC, pulling a bright backdrop down and a dark one UP, so both ends
1399 converge on the plate's key. The contrast stops depending on what is behind
1400 the window, which is the actual goal; hue, chroma and movement still read
1401 through it.
1402
1403 **It only works with a tint dark enough to converge ON.** A 24-cell sweep
1404 (k x tint x backdrop, 2026-09-20) — contrast on the bright/dark viewports,
1405 with `show` the luminance sigma across bare plate (x100), a proxy for how
1406 much backdrop still reads through:
1407
1408 | tint | k=0 | k=0.4 | k=0.6 | k=0.85 |
1409 |---|---|---|---|---|
1410 | `#595969` (stock) | 1.16 / 6.65 | 2.25 / 4.87 | 3.10 / 4.54 | 4.10 / 4.34 |
1411 | `#1a1a24` | 1.23 / 9.97 | 2.99 / 10.34 | **5.08 / 10.53** | 9.36 / 10.70 |
1412 | `#050508` | 1.24 / 10.47 | 3.08 / 11.67 | **5.41 / 12.14** | 10.76 / 12.60 |
1413 | *show* (bright/dark) | 7.3 / 0.9 | 3.7 / 0.5 | 2.3 / 0.2 | 0.4 / 0.1 |
1414
1415 Three readings. **The stock tint cannot be rescued at any k** — it never
1416 clears 4.5:1 on the bright backdrop, and on the DARK one it gets WORSE as k
1417 rises (6.65 -> 4.34), because `#595969` is lighter than the scene and
1418 compression lifts the plate toward it. **Tint does nothing without k**: at
1419 k=0 the three tints read 1.16/1.23/1.24, indistinguishable, because at 0.25
1420 opacity the tint barely participates — which is why "just darken it" was a
1421 dead end before this existed. And **k ~ 0.6 is the knee**: both dark tints
1422 clear the floor on both backdrops with a third of the backdrop variation
1423 intact, where 0.85 doubles contrast for 95% of the remaining glass.
1424
1425 So the pair is orthogonal, and that is the point: **k buys independence from
1426 the backdrop, the tint picks the key it becomes independent at.** cce-designer
1427 ships `#05050840` at k=0.6 (5.41:1 / 12.14:1). Note `show` is 0.2-0.9 on a
1428 dark backdrop at EVERY k: there is little luminance variation behind the
1429 plate there to begin with, so "glass" on a dark desktop is carried by the rim
1430 and bevel, not by the backdrop.
1431 - **The recipe is per plate.** Since 2026-09-20 (RFC material step 3) compression,
1432 refraction and the blur radius are a plate's own `Material.frost`
1433 (`Frost::Frosted { compression, refraction, radius }`), packed into `p_host.zw` of
1434 its push block by `Frost::pack` — the two style keys above are the DEFAULT
1435 material's values (`Frost::from_style`), not a window setting, and two plates in
1436 one window can differ. `radius` is the kernel sigma in logical px;
1437 `Frost::DEFAULT_RADIUS` (5.5) reproduces the old fixed 5.5-physical-px stride on
1438 the scale-2 panel; 0 is a clear plate. A frosted FLAT fill of any kind (Quad,
1439 RoundedRect, Border fill — a `Flat` face, a menu, a popover) is promoted by the
1440 tessellator to a zero-depth plate batch so it carries its recipe too; only a raw
1441 vertex from outside the display list falls to the no-recipe branch.
1442 `examples/frost_pair.rs` is the visual test: three recipes in one window, run in a
1443 shadow, measured in the RFC's step-3 note.
1444 - **The kernel reads a mip chain, or thin detail bands** (since 2026-10-06). The 7x7
1445 taps stand a whole stride apart (5.5 physical px by default), and at level 0 a tap
1446 reads only the texel or two it lands between: a hairline, a well's edge or a glyph
1447 behind the plate was picked up whole by the taps that hit it and missed by the rest,
1448 seven faint copies a stride apart — horizontal bands under an open dropdown over the
1449 params rows, measured as an 8–16-level sawtooth down a column that is now a smooth
1450 ramp. The blur snapshot carries `snapshot_levels` mips (at most
1451 `SNAPSHOT_LEVELS_MAX`, 7), rebuilt by linear blits after every frame-so-far copy
1452 (`snapshot_mip_chain`), the sampler filters between levels, and `resolve_blur`
1453 reads level `log2(stride)`, so each tap is the average of its stride-sized cell.
1454 That adds about 3% to the blur's sigma. The clean samples (a clear plate, the rim)
1455 stay at level 0, and the scene backdrop keeps one level — only the zeroed-backdrop
1456 exempt plate reads it, and it holds nothing. A surface format that cannot be
1457 blitted with a linear filter gets one level and the old look.
1458 - **Named materials in config** (RFC step 4): `style.surface.material { <name> { color;
1459 frost …; finish … } }` and a binding per rung — `plate material="…"`, `plate { root
1460 material="…" }`, `style.control.material` — resolved by `MaterialDef::resolve` over
1461 the rung's legacy material (unset fields fall back; no `frost` child = opaque; a
1462 binding wins over the legacy keys; an undefined name warns and degrades to legacy).
1463 The DE finish's three fixed terms are `style.surface.relief.spec / shininess /
1464 curvature`; the default frost's blur sigma is `plate { frost radius=… }`. cce-relief
1465 edits them (Finish and Frost columns) and writes into the bound material's node or the
1466 DE keys — never restructuring an unbound config. KDL trap when writing fixtures: two
1467 nodes on one line need a `;`, and `a { b }` on one line is a parse error the loader
1468 swallows into an empty document.
1469 - **`plate { frost refraction=… }` (0..1, default 0) is the rim, and it buys
1470 no legibility.** It is the answer to the other half of the question — not
1471 "can I read this" but "is this an object". The roll is a real surface with a
1472 real tilt, and `sv_rim` IS that tilt (the unnormalized normal's horizontal
1473 part, already computed for the specular), so displacing the backdrop sample
1474 along it is what a curved edge does to what you see through it. Scaled by the
1475 roll width, so a 12px bevel bends more than a 2px one.
1476
1477 **It samples the CLEAN backdrop, not the blurred one**, cross-fading to the
1478 frosted body on `f*f`. Refraction has to bend something with STRUCTURE or it
1479 is invisible: displacing a field already blurred to sigma ~11px just moves
1480 smooth values around. A thin edge scattering over a shorter path than a thick
1481 middle is also what a real slab does — the droplet branch trades on the same
1482 thing ("thin edges are clearer water"). One extra tap, not three: per-channel
1483 dispersion inside a band this narrow is invisible once the body is 49 taps,
1484 and paying for it would triple the most expensive path in this shader to be
1485 erased.
1486
1487 **The clear rim is exempt from `backdrop_compression`**, in proportion to how
1488 clear it is. Compression is a legibility control and the rim carries no text;
1489 tone-mapping it pulls the refracted view back toward the plate's own key,
1490 which is the exact contrast the rim exists to show. Measured, the two
1491 fighting made the effect nearly invisible — exempting the rim made it **5.9x
1492 stronger** at the same setting (rim pixel change 1.50 -> 8.86 of 255 at 0.3),
1493 with the body still under 0.4. Useful range is ~0.3-0.6; the effect is in the
1494 roll and stays there.
1495
1496 ### The surface config shape (`style.surface`, as of 2026-09-28)
1497
1498 The block every plate, wall and roll in the toolkit reads, in the spelling
1499 the loader treats as current — written down once because the rules below
1500 were settled one at a time across a day and each paragraph names only its
1501 own key. What a key is, in a line each:
1502
1503 ```kdl
1504 style {
1505 surface {
1506 plate material="glass" { // optional: bind the pane rung to a material node
1507 pane color=(rgba)"#6c6c7bf2" // the pane tint, alpha = strength (legacy: param.color × top-level plate_opacity)
1508 frost radius=(f64)5.5 compression=(f64)0.0 refraction=(f64)0.0 // the ONE frost spelling; absent = unfrosted
1509 border_color (rgba)"#9595a9ff" // the flat border — control_relief OFF only
1510 border_thickness (f64)1.0
1511 padding (i64)20 // the pane rung's inset
1512 root { // the root rung
1513 color (rgba)"#5e657acf"
1514 blur (f64)0.1 // the COMPOSITOR's blur-behind, not the client frost
1515 corner_radius (i64)24 // the pane radius falls back to this
1516 }
1517 }
1518 material { // named materials (RFC material, step 4)
1519 glass {
1520 color (rgba)"#05050840"
1521 frost compression=(f64)0.6 refraction=(f64)0.3 radius=(f64)5.5
1522 finish light=(f64)0.15 spec=(f64)0.4 shininess=(f64)24.0 curvature=(f64)0.2
1523 }
1524 }
1525 relief light=(f64)0.15 width=(f64)9.3 spec=(f64)0.4 shininess=(f64)24.0 curvature=(f64)0.2 shader=(bool)true {
1526 // light: the strength, NOT a length; width: the ONE run of every roll and wall;
1527 // spec / shininess / curvature: the DE finish beyond its strength;
1528 // shader: false = the legacy banded edge shading, for A/B comparison
1529 wall height=(mm)0.3 profile="smooth;…" // a carve's side (buttons, wells, rows): height = drop, profile = ramp spec
1530 edge height=4.0 profile="smooth;…" // a plate's perimeter roll: height = rise (unset = quarter-round of width)
1531 }
1532 menu color=(rgba)"#101018ff" opacity=(f64)0.06 compression=(f64)0.8 corner_radius=(i64)24 // every popover and the designer's dialog
1533 }
1534 }
1535 ```
1536
1537 Spellings that are NOT current, and what the loader does with each:
1538
1539 | Spelling | Status |
1540 |---|---|
1541 | `param.color` (+ top-level `plate_opacity`) | alias of `plate.pane.color`, multiplied by the opacity line |
1542 | `plate.blur` / `.radius` / `.backdrop_compression` / `.refraction`, `frost.backdrop_compression`, `plate.bevel_width`, `relief.depth`, a material's `finish depth=`, `relief.height` / `.profile` / `.edge_height` / `.edge_profile`, `window_manager.bevel_depth` / `.bevel_width` / `.bevel_shader` | RETIRED: reported by path (`color::retired_surface_keys`), not read; cce-relief seeds from each once and its Save writes the current spelling and removes the old (the shader toggle it carries across as `relief.shader`) |
1543 | `relief.wall.knobs` / `edge.knobs`, `profile_knobs` / `edge_knobs` | not style: cce-relief's own state (`~/.config/cce/cce-relief/state.kdl`); read once as a seed, removed on its next Save |
1544
1545 Where each rule is argued, by its lead-in: **Frost is one block** and
1546 **Named materials in config** above; **The relief is two shapes**, **The
1547 editor's knobs are not a style key** and **There is one roll width** under
1548 "Units" (the geometry is unit-aware, which is why they sit there); the
1549 `menu` block under "The context menu draws in its own popup surface".
1550 There is no alias precedence to decide any more — every legacy spelling of
1551 the relief is retired, so a file is what it says — and
1552 `color::retired_surface_keys` is the one place a retired key is named;
1553 cce-relief's Save is the migration for all of it: it seeds from a retired
1554 key once, writes the current spelling and removes every superseded one it
1555 finds.
1556
1557 ## The standard app — root plate, rungs, and the spacing ladder
1558
1559 Every cce app is built the same way, and this section is the standard.
1560 `scripts/style-audit` checks the sibling app crates against it (one row per
1561 app; `--strict` fails on any off-standard row); `src/main.rs`, the demo, is
1562 the reference implementation.
1563
1564 **Anatomy.** A window is a **root plate** with things standing on it. The root
1565 plate is the first prim of every frame — `pc.root_plate(w, h)`, which emits
1566 `PlateSpec::window(w, h)`: the root rung's material (`Material::root()`, the
1567 DE's `style.surface.plate.root.color` at its opacity unless a `material=` is
1568 bound), all four corners on the shared silhouette, the perimeter rolled over
1569 `bevel_width`. Nothing else paints a window base — not a quad, not a rounded
1570 rect at the silhouette radius, not a stroked border. On the root plate stand
1571 **pane plates** (`PlateSpec` with the corners that touch the window edge
1572 flagged, or `plate` / `rounded_rect` at `plate_corner_radius`) and **carves**
1573 (`inset_plate`, `recess_edges`: a menubar or status band stepping down into
1574 the surface, a well you type into). Which to use is the vocabulary above:
1575 things you press and content you read sit on plates; things you enter and
1576 bands that are part of the window's own surface are carved. On the pane
1577 plates sit **control plates**. A window that is deliberately not a plate — a
1578 transparent bar whose modules are the plates, a notification stack, a black
1579 lock or screensaver surface, the desktop grid overlay — says so with a
1580 `// style-audit: opt-out <reason>` comment and is listed as an opt-out.
1581
1582 **Spacing is a ladder, and an app never names a number.** Three rungs, each a
1583 config key read through the style registry (so a nested KDL key works and
1584 live-reloads), each with a getter in `layout`:
1585
1586 | rung | inset from the rim | gap between siblings |
1587 |---|---|---|
1588 | root plate | `root_plate_inset()` = `bevel_width` + `style.surface.plate.root.padding` | `root_plate_gap()` (`…root.gap`) |
1589 | pane plate | `plate_padding()` (`style.surface.plate.padding`) | `plate_gap()` (`…plate.gap`, unset = the root gap) |
1590 | controls | — (inside a pane or root inset) | `control_gap()` (`style.control.gap`, unset = `CONTROL_GAP`) |
1591 | inside a list row | `list_gap()` from the list's wall | `list_gap()` (`style.control.list_gap`, unset = `CONTROL_TEXT_INSET`) |
1592
1593 The root inset carries the roll because the padding is a run of FLAT face —
1594 the same run the gap leaves between two panes — and the face only begins
1595 where the roll ends; a bare padding at a window edge measured 4px of visible
1596 flat against 12 between panes. The legacy keys (`page_margin`, `column_gap`,
1597 `control_panel_{padding,gap}`) are honoured when set and land on their rung
1598 when unset, the radius rule applied to spacing; do not add a new one.
1599
1600 In the box model the ladder is presets — `Style::root_column()` /
1601 `root_row()`, `pane_column()` / `pane_row()`, `controls_column()` /
1602 `controls_row()` — and the container layouts' `Default`s read the same
1603 getters. An app picks the
1604 rung; a literal padding or gap in an app (`const PAD`, `+ 12.0`) is a number
1605 the ladder should be supplying, and the audit counts them.
1606
1607 **Rules, restated as the audit checks them:**
1608 - The first prim of the frame is `root_plate(w, h)`, or the crate declares an
1609 opt-out.
1610 - Every inset and gap comes from a rung getter or a preset; the app declares
1611 no spacing constants of its own.
1612 - A deliberate deviation — the system settings' own tint, an overlay's
1613 shallower roll — goes through `PlateSpec::window(..).with_material(..)` /
1614 `.with_depth(..)` and a comment saying it is one, never a hand-built spec.
1615 - Migrating an app is pixel-neutral for the plate (`tests/plate_golden.rs`)
1616 and a measured change for spacing: screenshot in a shadow session, count
1617 the columns of flat face at the edges and across a split, and they match.
1618
1619 ## Accessibility and locale are on the roadmap (read `docs/rfc-accessibility-locale.md`)
1620
1621 Accessibility reaches Linux screen readers only, and only for an app that opts in: the
1622 widget tree is published over AT-SPI through AccessKit (`backend::a11y_unix`, the `a11y`
1623 feature, `Application::publishes_accessibility` or `CCE_A11Y=1`; phase 2, proven on
1624 cce-data-editor). There is nothing yet on macOS or in the browser. The toolkit's own words
1625 are translatable (`crate::l10n`, phase 5): `tr("id")` looks a message up in
1626 `locale/en-US/cce-ui.ftl`'s English or a translation `<tag>/cce-ui.ftl` (`cce_core::l10n`
1627 for where), and a new string the toolkit shows goes there, never into the source;
1628 `every_message_the_toolkit_names_is_in_its_english` holds the two to each other. And
1629 right-to-left text is edited where it is drawn (carets, clicks and selections follow
1630 it, a right-to-left paragraph is set against the right, the `DocEditor` draws styled runs
1631 in bidi order, a multiline `TextBox` wraps by shaped width — phase 4). The RFC has
1632 the measured state and a phased plan. Until it lands, two rules keep the retrofit cheap:
1633
1634 - **A new widget declares what it is**: its `focus_role`, and a label that names it to a
1635 person (not to a host).
1636 - **An action is keyed by an ID, never by its label text.** Give a context-menu row its
1637 action with `context_menu::set_row_actions` (or build the menu with
1638 `UiContext::show_context_menu_rows`); a press runs the row's action, and matching the
1639 English label (`context_menu::legacy_action_for_label`) is only the fallback for menus that
1640 set none. Do not add code that matches on displayed text.
1641
1642 Phase 0 is done (2026-10-08): every font system is built with `locale::locale()` (from
1643 `cce-core`): `LC_ALL`, else `LC_CTYPE`, else `LANG`, as a BCP 47 tag; in the browser,
1644 `navigator.language` through `locale::set_locale` before the first font system.
1645 `every_font_system_is_built_with_the_users_locale` is the test.
1646
1647 ## The `scene/` core rebuild (read `docs/rfc-core-rebuild.md` before touching it)
1648
1649 `src/scene/` is a **retained scene graph being grown additively** to replace three overlaid legacy
1650 subsystems (tripled tree ownership via raw widget pointers; three uncoordinated render paths;
1651 layout smeared across five mechanisms). The RFC (`docs/rfc-core-rebuild.md`) is the authoritative
1652 design and phase tracker — its inline "DONE" notes are the source of truth for what has landed.
1653 Modules:
1654
1655 - `arena.rs` — `Arena` / `NodeId` / `Node`: a generational forest, the single source of truth for
1656 tree ownership. Generational keys turn use-after-free into a `None` lookup, not UB.
1657 - `tree.rs` — `WidgetTree`: arena-backed replacement for `UiContext`'s old `widget_registry` +
1658 `layout_tree` twin stores, keyed `WidgetId → NodeId` so the public `WidgetId` API is preserved.
1659 - `layout.rs` — the hand-rolled measure→arrange solver (`Style`/`Size`/`Rect`/`LayoutBox`).
1660 Deliberately **not** taffy: a compact row/column + flex + align + gap/padding box model.
1661 - `paint.rs` / `painter.rs` — `DisplayList` + `PaintCtx` (clip/transform stack) and the single
1662 paint walk. Each widget emits its own geometry via `WidgetHost::paint_self`; the walk owns
1663 recursion and clipping (`WidgetHost::clips_children`), instead of every container re-deriving
1664 intersections. `renders_own_subtree` is an escape hatch for legacy subtree painters.
1665 - `anim.rs` — `Animated<T>` (tween + spring + easing), the Phase 4 animation primitive replacing
1666 ad-hoc bool flips.
1667 - `heightfield.rs` — the relief as a height field: the geometry the plate shader shades
1668 (plate rolls, CSG features, free carves) integrated back from the slopes it lights,
1669 sampled per physical px and exported as a 16-bit PNG + JSON sidecar in millimetres
1670 through the display metric. `CCE_HEIGHTMAP=<file>` in any client's environment, or
1671 `heightfield::request` from an app. See the Units section.
1672
1673 ### `WidgetHost` (formerly the `Element` god-trait)
1674
1675 `WidgetHost` (`src/widget/mod.rs`) is the single 31-method host surface the machinery
1676 (context routing, paint walk, render loop, app dyn broadcasts) sees, produced by the RFC's 6bd
1677 shrink-then-rename of the old ~125-method `Element` god-trait. Its ONE production implementor
1678 is `Adapted<W>`; concrete widget behavior lives on the narrow `Layout`/`Paint`/`Input` traits
1679 (`src/widget/model.rs`). `base()` is guaranteed (`&Widget`, no Option). The direct-dispatch
1680 block (mouse/key/drag) and the value/polling block (`take_click`/`take_change`/value strings)
1681 are GONE from the trait — events route through `handle_event`, and apps drain widget state
1682 through the concrete inherent `Adapted<W>` methods. See the RFC's blueprint notes before
1683 adding anything to this trait.
1684
1685 **What a host's widget model answers is not a trait slot** (since 2026-10-08, 57 → 31).
1686 The host hands out its widget as its narrow traits — `layout_model()`, `paint_model()`,
1687 `input_model()` / `input_model_mut()` (`Adapted` returns its inner widget; a test shim that
1688 implements `WidgetHost` directly gets `NoModel`'s defaults, or returns itself after
1689 implementing the narrow trait it needs) — and `WidgetHostExt`, blanket-implemented for every
1690 host, `dyn` included, carries what used to be one-line forwards: `focus_role`, `keeps_tab`,
1691 `blocks_root_plate_drag`, `wants_tick`, `is_scrollable`, the `a11y_*` reads and acts,
1692 `set_modifiers`, `context_action`, `color`, `solid_border`, `widget_font`,
1693 `clips_children`, `renders_own_subtree`, `z_index`, `preferred_height`, plus the pure
1694 derivations `label`, `corner_radii`, `mark_dirty`, and `content_rect` / `painted_prims` (what
1695 the widget's model paints, as prims). Call them with `cce_ui::widget::WidgetHostExt` in
1696 scope.
1697
1698 **The legacy tuple views are gone** (2026-10-08): `extra_quads`, `extra_arcs`,
1699 `extra_circles`, `all_quads`, `all_rounded_quads`, `highlight_quad` and the host-side
1700 `corner_style` — what a widget paints projected onto the pre-display-list surface — with
1701 the model hooks that served only them (`serves_legacy_plain_quads` / `legacy_plain_quads`,
1702 `aggregates_child_extra_quads`, `forwarded_highlight`), the painter's `paint_legacy_leaf`
1703 and the tessellator's `widget_vertices`. Every host paints a widget through `paint_self` or
1704 the paint walk (the designer, the gallery, the greeter, the settings app and cce-secrets
1705 moved the same day, each checked by pixel A/B). A composite that draws a child's chrome in
1706 its own order — the params pane's rows, the ramp's key editor, the menubar's strip — reads
1707 the child's `painted_prims` (`widget::shown_prims` / `shown_quads` / `shown_rounded_quads`,
1708 crate-private). `Paint::corner_style` stays: it is what a widget says about its silhouette,
1709 read by `corner_radii`, `append_widget_plate` and the bridge. `append_widget_plate` is the
1710 plate alone now; it drew the widget's arcs too, which a host painting the widget after it
1711 drew twice. A method stays ON the trait only when the host
1712 adds something the model cannot (visibility gating, the content rect, child recursion,
1713 registry state). `plate_bevel` is gone: nothing overrode it, so it was always `None`.
1714
1715 ### Global state has a plan (`docs/rfc-global-state.md`)
1716
1717 About 300 statics and 18 thread-locals: style (≈200 `RwLock`s beside the style registry),
1718 interaction state (context menu, hover highlight, composition — per thread), properties of
1719 "the" window (scale, metric, scroll phase), and caches (fine). The RFC sorts them and
1720 phases the moves. Phase 1 is done: keyboard focus has ONE store, `UiContext::focused_widget`
1721 (the `widget::focus` thread-local is gone; a widget asks `EventCtx::is_focused`, claims with
1722 `request_focus`), and the context's dead hover and context-menu twins are deleted. Phase 2
1723 is done: a window's interaction state — the context menu, the hover highlight and its
1724 cursor, the side swipe, the input-method composition — is a `window_state::WindowState` the
1725 window OWNS, which its shell makes current (`window_state::enter`) while it runs that
1726 window's code; the modules' free functions (`context_menu::show`, `ime::caret`, …) act on
1727 the current one, so no caller changed, and with none entered (a test) each thread has a
1728 default. `context_menu::with_state` replaces reaching for the old `CONTEXT_MENU`. Phase 3
1729 is done: the style is ONE snapshot (`crate::style::Style`: colour slots, layout slots, named
1730 materials, the registry), published as an `Arc`; each former style `RwLock` is a
1731 `style::StyleCell` handle with the same `.read()` / `.write()` API, reads take no lock, and
1732 a reload runs as one `style::batch`, published once. A new style value is a field in its
1733 module's `style_slots!` block and a `StyleCell` handle, not a new lock. Phase 4 is done: a
1734 window's properties — scale, display metric, app id, fullscreen, maximized, vertical text,
1735 and the scroll phase — are its `WindowState`'s (`window_state::Props`). The window whose
1736 code runs reads its own; a thread with no window (a worker) reads the process-wide value,
1737 which every setter also writes, so one window in a process reads exactly what it did.
1738 `units::metric` asks cce-ui first (`units::set_metric_resolver`); a runner reports a
1739 metric through `window_state::set_metric`. An app
1740 that drives a widget's focus itself calls `UiContext::focus_id` / `unfocus_id` rather than
1741 `w.focus()` / `w.unfocus()`, so the window's record of focus follows. Do not
1742 add a static for state that belongs to a window: give it a field in `WindowState`.
1743
1744 ### The registry owns its widgets (`docs/rfc-owning-registry.md`, done 2026-10-08)
1745
1746 `UiContext`'s tree (`scene::tree::WidgetTree`) owns every widget in it. `ctx.insert(w)` moves
1747 a widget in and returns a `Handle<W>` (`Copy`, typed); the app reaches it through the context
1748 — `ctx[h]`, `ctx.get(h)` / `get_mut(h)`, `ctx.lend_h(h, |w, ctx| ..)` when it needs the widget
1749 and the context together (`Dialog::open`, `fit`) — so the borrow checker refuses an app access
1750 that overlaps a context call. `ctx.remove(h)` gives it back by value (its children stay, as
1751 roots); dropping the context drops the rest; `clear_hierarchy` drops links only. Every call the
1752 context makes into a widget that hands it the context goes through `lend`, which takes the
1753 widget out of reach for the call: a widget reaching itself through the context mid-event gets
1754 `None`, never a second `&mut`. The tree holds each widget as a raw ROOT, never a `Box` across
1755 accesses (a `Box` is a unique pointer, and every reborrow through it would invalidate the
1756 references the context hands out).
1757
1758 What a host uses: `render_widget_h`, `Form::widget_h` / `widget_w_h`, `register_popover_id`,
1759 `focus_id` / `unfocus_id` / `set_focused_id`, `link_ids`, `paint_root_into(ctx, &ctx[h], pc)`;
1760 `get_widget(id)` / `get_widget_mut(id)` / `widgets()` for a widget known by id, and
1761 `tree.parent_id` / `child_ids` for structure. The tree's raw-pointer accessors (`get_ptr`,
1762 `children_ptrs`, `iter_registered`) are crate-private.
1763
1764 **A composite's children are `widget::Embedded`**: held by value until the composite is
1765 inserted, then in the context under their own id (`Layout::register_embedded_children`
1766 attaches, `release_embedded_children` takes them back on `remove`, so a composite leaves
1767 whole). A composite's `set_rect` has no context, so it keeps the rect and places its children
1768 in that hook, which runs on insert, every layout and every tick. A Ramp's fields are never
1769 inserted: the focus record names the field with the keyboard and the ramp routes to it. A
1770 widget used only as a paint STAMP (the designer dialog's colour selectors) stays a bare
1771 `Adapted` and is never inserted.
1772
1773 **A context menu is opened on a widget by id or by reference.** `show_context_menu(_rows)`
1774 take the target's id; a widget asking for the config menu while it handles a press
1775 (`EventCtx::open_context_menu`: Breadcrumb, TextBox, Ramp) has the request recorded, and the
1776 adapter opens it once the widget is done, handing itself to `handle_right_click(&dyn
1777 WidgetHost, ..)`.
1778
1779 Things the move taught:
1780
1781 - **An inserted widget that `wants_tick` is a tick receiver.** Until the fix `insert` skipped
1782 it, and an inserted tree list never applied its search (it does so in its tick) — the data
1783 editor's A/B caught it.
1784 - **A widget made per frame is inserted, placed and removed** (a status dot in a timer row, a
1785 usage bar), and a row list rebuilt on data removes the outgoing handles (`ctx.remove`) and
1786 inserts the new ones; nothing re-registers each frame.
1787 - **A value built where there is no context** — a page state a worker fetches, merged field by
1788 field into the app's copy — holds `Handle::none()` (also `Handle`'s `Default`), which names
1789 no widget and is never read.
1790 - **Two widgets with one id cannot both be inserted.** A clone of a widget whose id was already
1791 drawn copies the id; a debug build asserts on the second insert.
1792
1793 **Until 2026-10-08 the registry held pointers.** The app owned its widgets and registered raw
1794 pointers to them, each watched by a liveness token (`Owned` boxes, `register_host`,
1795 `register_widget`, `set_focused_ptr`, `Liveness`, `stable_target`, `link_parent_child`). It
1796 was made sound (a raw-root `Owned`, checked by Miri) and then replaced: the RFC's five phases
1797 moved every app and the toolkit's own children onto handles and deleted the pointer path. A
1798 real bug went with it: cce-files' prompt focused a stack-local `TextBox` by pointer and then
1799 moved it into its box, and a second prompt corrupted the heap.
1800
1801 `widget::handle` (an app and the context taking turns, lending, removal), `widget::embedded`
1802 and `scene::tree` are the tests; CI's `miri` job runs all three under Stacked and Tree Borrows.
1803
1804 **Runtime verification matters here.** Several scene changes are "compiles + tests pass; runtime
1805 verification pending" per the RFC — the headless tests can't catch paint/event regressions. When
1806 changing scene wiring, `cargo run` a real client (cce-files, cce-designer, cce-graph,
1807 cce-system-interface) to confirm behavior, not just the test suite.
1808
1809 ## Module map (where things live)
1810
1811 - `layout/` — the style getters and the code that was filed beside them, one module whose
1812 `mod.rs` re-exports every submodule, so `crate::layout::…` paths are unchanged (split
1813 2026-10-07 from one 7.4k-line file):
1814 - `src/layout/mod.rs` (~2.3k lines) — the sizing constants and the style getters/setters
1815 (heights, radii, fonts — many `*_font_parsed()` — gaps, the relief and bevel profile
1816 state), `reload_config`, and `read_preferred_fonts` / font-family resolution used by the
1817 cosmic-text path. It names no widget: what does is in the modules below.
1818 - `registry.rs` — the style registry (config flattened to one map of keys), its test overlay,
1819 the font-string helpers. **A layout style key has one home, the registry** (since
1820 2026-10-08): its getter reads `registry_float` / `registry_string` / `registry_bool`
1821 with the default, its setter writes the registry (a test's write lands in the
1822 per-thread overlay), and a font's parse is cached against the string it came from
1823 (`parsed_font`). About fifty keys also had a slot of their own, filled by a second scan
1824 of the same flattened lines by prefix (so `button_height` matched a longer key too),
1825 and thirty-five getters re-read and re-parsed the whole `config.kdl` once each on
1826 first use; a `(mm)` length never reached a slot. Do not add a slot for a config key.
1827 One thing it changed on screen: those first-use scans ran after an app's own setter and
1828 overwrote it, so cce-system-interface's `set_grid_gap(root_plate_gap())` lost to the
1829 config's `layout { grid_gap 18 }` — the compositor's window-tiling gap. Its multi-column
1830 pages now stand their sections the root gap apart, as the app asks (kept by choice).
1831 **And another program's key never becomes the toolkit's**: the flatten maps the paths
1832 the toolkit reads to its keys and leaves every other path whole (`layout.grid_gap`).
1833 Until the same day it cut an unmapped path down — the compositor's `layout` and
1834 `transparency` blocks to their bare keys, anything else past its first segment — which
1835 is how the tiling gap arrived as `grid_gap`. A new key the toolkit reads from a nested
1836 block needs its mapping; `another_programs_keys_do_not_become_the_toolkits`.
1837 - `bridge.rs` — the flat-host render bridge: `RenderTarget`, `PopoverCollector`,
1838 `render_widget`, `render_popovers`, the carve types that cross it.
1839 - `section.rs` — a settings page's sections: `PageFlow` places them (a masonry of
1840 columns as wide as fit `grid_min_col_width`), `PageLayoutBuilder` draws each ONCE in
1841 the slot the flow gives it and hands its height back, and `SectionContext` frames a
1842 section (title tab, well) around what a page puts in it. cce-system-interface is its
1843 user. Since 2026-10-08:
1844 until then it was `legacy.rs`, and every section was drawn TWICE, once into a
1845 throwaway target to measure it, through the `LayoutStrategy` trait, whose `allocate`
1846 took the height before the position. A section's position never depended on its own
1847 height, so drawing once places everything where it was (all 14 settings pages
1848 pixel-identical before and after in a scale-2 shadow, live readings aside).
1849 `LayoutStrategy`, `ColumnLayout`, `AdaptiveGrid`, `FlexLayout`, `RadialLayout` and the
1850 unused `Column` / `Row` / `Section` / `UiFrame` / `Radial` went with it; cce-files'
1851 browse page is a `scene::layout` column now, and the container layouts
1852 (`widget::ContainerLayout`, the gallery's Layout exhibit) are a trait of their own,
1853 `layout` and `measure`, without the cursor. New layout is `scene::layout`.
1854 - `form.rs` — what goes INSIDE a section, on `scene::layout` (since 2026-10-08): a page
1855 asks the section for a `Form` (`SectionContext::form`), declares its contents into it
1856 — retained widgets (`widget`, `widget_w`), text (`text`, `text_fill`, `lines`), rows
1857 and columns, `block`s of text lines with no gap, `rule`s, `space`, pieces it paints
1858 itself (`draw`), and one that takes the rest of the page (`fill`, with
1859 `Form::fill_height`) — and hands it back (`SectionContext::place`), which solves the
1860 tree across the content box and paints each piece where it landed, in declaration
1861 order. The spacing is the ladder's: the form a `controls_column`, a row a
1862 `controls_row`, both `control_gap` apart; a page states a size only where a piece has
1863 one of its own (a list's height, a button's width). Until the same day a section
1864 placed its contents with a cursor (`VStack`, `add_row` / `add_row_for`, `row_layout`,
1865 `text`, `spacing`) and a hidden one-or-two-column grid that widgets fell into unless
1866 their type name said otherwise, and every page added insets of its own (`+ 14`,
1867 `- 28`, `44.0`); the 14 settings pages moved onto the form and the cursor went.
1868 What a scrolling list draws per visible row — its buttons, a glyph, its name — a
1869 `Form` (one tree per section) does not reach; `lay_row(rect, &[Cell])` lays one
1870 row's cells by `scene::layout`, `list_gap()` apart and in from its ends, centred on
1871 its height, a growing cell taking the slack. `list_gap` is the ladder's rung below
1872 the controls: a row is one control tall, so the control gap would part it into
1873 islands. The settings app's lists and its process table (columns as wide as their
1874 content, COMMAND taking the slack) are laid out so.
1875 - `color/` — the colour model and named colours (`colors` re-export module in `lib.rs`),
1876 split the same way: `mod.rs` the constants, statics and getters; `load.rs` reading the
1877 config into them (and `retired_surface_keys`); `math.rs` sRGB/linear, OKLab and the
1878 perceptual fade; `materials.rs` the named materials and rung bindings; `chords.rs` the
1879 tree/list search keys (input.kdl chords with a legacy colour-file fallback).
1880 - **Vertical text** is `backend::text::set_vertical_text(Some(bar_thickness))` /
1881 `vertical_text()` — the status bar's mode when it stands on a screen edge: labels stack
1882 their characters and text shapes at a 1.05 line height. Process-wide on purpose (a property
1883 of the app); it was two bare `pub static`s at the crate root until 2026-10-07.
1884 - **`config`, `input`, `motion`, `units`, `relief_spec`, `ipc`** — re-exported from
1885 `cce-core` (see "The GUI-free half is cce-core"), as are `color`'s hex/sRGB helpers,
1886 `scene::paint::DropletSpec` and the ramp spec functions (`widget::{format,parse}_ramp_spec`,
1887 `layout::sample_ramp_keys`).
1888 - `context.rs` — `UiContext`: the retained widget tree, event routing, spatial grid, dirty
1889 tracking, hit-testing.
1890 - `compute.rs` — what a compute job is, apart from the device that runs it: `Kernel`,
1891 `Binding`, the job rules and naga's parse (see "Compute jobs run in the browser too").
1892 `vk::ComputeDevice` and `web::ComputeDevice` run them.
1893 - `a11y.rs` — the accessibility tree, in AccessKit's schema: `app_tree(&mut app, scale)` is a
1894 window's `TreeUpdate` — its registered widgets (role, name, value, bounds, actions, focus;
1895 `WidgetHost::a11y_role` / `a11y_value` are what a widget says about itself), the nodes an
1896 app without widgets declares (`Application::accessibility`, `AppNodes`), and an open context
1897 menu; a widget's parts of its own (a radio group's radio buttons) are `A11yItem`s
1898 (`Input::a11y_items`), and a text field's text is TEXT RUNS with its caret
1899 (`Input::a11y_text` → `A11yText`; a password as bullets), which is what lets a reader read
1900 it by line and set it (`Input::a11y_set_text`; `TextBox`, `ColorSelector`). A field an app
1901 draws (a `LineEdit`) is `AppNodes::text_field`, and a reader's edit of it arrives as
1902 `Application::accessibility_action` (`AppAction`). Never give a node a ROLE DESCRIPTION:
1903 AccessKit makes it AT-SPI's `Extended` role, and the node never registers on the bus.
1904 `backend::a11y_unix` publishes it over AT-SPI (the `a11y` feature; see
1905 `docs/rfc-accessibility-locale.md`, phase 2).
1906 - `l10n.rs` — the toolkit's catalogue (`tr`, `tr_args`, `catalog`) over `cce_core::l10n`;
1907 its English is `locale/en-US/cce-ui.ftl`.
1908 - `style.rs` — the style snapshot: `Style`, `StyleCell`, `batch`, `style_slots!`.
1909 - `window_state.rs` — a window's interaction state (`WindowState`, `enter`).
1910 - `ime.rs` — input-method composition shared between the editing widget and the shell:
1911 `Preedit`, the composition and its generation, the reported caret, the reset request
1912 (see "Input-method composition is one model for every shell").
1913 - `history.rs` — `History<T>`: the undo/redo snapshot stack (cap, gestures, grouped runs).
1914 The toolkit defines the stack and the routing, never the step — see the trait section.
1915 - `widget/` — `container/` (vbox/hbox/scroll/menu/treelist/…), `input/` (button/slider/text_box/
1916 dropdown/…), `display/` (label/graph/svg/…), plus `editor.rs` (`TextEditorState`, the
1917 model behind `TextBox`), `line_edit.rs` (`LineEdit`: the text, caret, selection and keymap
1918 of a one-line field an app draws itself — cce-browser's URL bar and dialog fields) and
1919 `core.rs`. (The KDL/JSON-driven `json_layout.rs` is dissolved; `scene/layout.rs` is the
1920 box model.)
1921 - `backend/` — the runner, split (since 2026-10-03) so a second shell (macOS, the browser)
1922 can share everything that is not Wayland: `app.rs` (the `Application` trait, `AppSender`,
1923 the plain types it speaks in), `driver.rs` (`Driver`: input state and routing — modifiers,
1924 key repeat, the undo/redo and plate-navigation chords, the CSD hit zones, the
1925 outside-press popover close, held-button release on a lost pointer, the scroll phase,
1926 the pinch fallback — fed in cce-ui's own terms and unit-tested with no compositor),
1927 `dom.rs` (the DOM's key and wheel vocabulary as the driver's: `map_key`, `wheel_frame` —
1928 portable, so tested natively), `appkit.rs` (AppKit's, likewise: key codes and
1929 characters, scroll deltas and phases, modifier flags and buttons), `frame.rs` (`build_frame`: the app's display list, damage, custom vertices and overlays,
1930 widget shaping, text and the popover-occlusion rects, tessellated into a `BuiltFrame` the
1931 renderer draws — no window system in it, tested with no GPU), `shell.rs` (the `Shell`
1932 trait — a window system's side of the run loop: exit, size requests, per-turn sync,
1933 title, the frame gate, configured, present — and `Pacer`, one turn of the loop over any
1934 shell: the tick's `dt` and its idle clamp, `desired_size`, key repeat, the title, the
1935 present-or-warm-down decision, and the ACTIVE / idle cadence; tested against a mock
1936 shell), `tessellate.rs`, `text.rs`, and `window_runner.rs`, the Wayland shell
1937 (`EngineState` implements `Shell`; its loop is dispatch, the connection's health checks,
1938 `pacer.turn`, and the close fade): it maps evdev
1939 buttons, xkb keysyms and `wl_pointer` axis frames into driver calls and carries out the
1940 grabs and cursors the driver asks for, and presents what `build_frame` built (grid patch,
1941 input region, glyph upload, the extent gate and buffer scale, the frame callback,
1942 `stage_renderer`, the draw). A routing change belongs in `driver.rs`, a change to
1943 what a frame contains in `frame.rs` and a pacing change in `shell.rs`, never in the
1944 Wayland code. A second shell implements `Shell` and calls `Pacer::turn` from its own
1945 loop (an animation frame, a run-loop observer), sleeping or scheduling for the `Step`. `menu_popup.rs`, `dnd.rs` and `text_input.rs` (`text-input-v3`, the input method's way in) are Wayland-only.
1946 - `draw/` — what a renderer draws, with no renderer in it (since 2026-10-04): `Frame2D`,
1947 `Batch2D`, `PlatePush` and `batch_push_constants` (the one layout of a batch's 32-float
1948 parameter block — Vulkan pushes it, a renderer without push constants puts it in a
1949 uniform), `TextSpan`, `ImageQuad`, and `draw::images`, the image-id queue
1950 (`upload_rgba`, `update_pixels`, `free_image`, `renderer_epoch`, …) that a renderer
1951 drains with `take_pending`. They lived in `vk/` while Vulkan was the only renderer;
1952 `vk` re-exports every one at its old path, so `cce_ui::vk::upload_rgba` and the rest
1953 are unchanged for clients. Also here, shared by every renderer: `draw::glyphs`
1954 (`GlyphAtlas` — rasterizing, packing and the glyph quads; a renderer uploads
1955 `pixels()` when `generation()` moves — and `image_quad_vertices`), `window_info_data`
1956 (shader2d's `WindowInfo` block), and `draw::shaders`: `shader2d.wgsl` and `glyph.wgsl`
1957 live in `src/draw/` now, one source for both renderers. WebGPU has no push constants,
1958 so `shader2d_for_webgpu()` swaps the one push-block line for a `@group(1)` uniform read
1959 at a per-batch dynamic offset (`WEBGPU_BLOCK_STRIDE`); the backdrop is sampled with
1960 `textureSampleLevel(…, 0.0)` because WebGPU rejects implicit-LOD sampling in the
1961 non-uniform blur branch (the backdrop has one level, so the texel is the same —
1962 `frost_pair` is identical to the pixel either way). And the 3D halves: `draw::scene`
1963 (the raster scene's types, uniforms and the `Stage3D` trait) and `draw::rt` (the path
1964 tracer's schema, BVH and parameter blocks), with their shaders beside the 2D ones.
1965 - `web/` — wasm32 only: `WebRenderer` (`new(canvas).await`, `resize`, `prepare_text`,
1966 `draw_frame_2d`, and `capture_next_frame` / `take_capture().await` or
1967 `take_pending_capture` to read a frame back). Its module doc lists what differs from the
1968 Vulkan path: an sRGB VIEW of the canvas's unorm format, the parameter block as a
1969 dynamic-offset uniform, a 1x1 backdrop, the blur snapshot as end-pass / copy / resume,
1970 every frame drawn whole. And `shell.rs`, the browser shell: `run`, `Fonts`, `Sizing`,
1971 `capture` (see "And an `Application` runs in a page" above); `scene.rs`, the 3D pass;
1972 `rt.rs`, the path tracer's compute tier;
1973 `compute.rs`, the async
1974 `ComputeDevice`; and `request_device`, the adapter and device every one of them asks
1975 for (with the limits a caller names raised to the adapter's).
1976 - `mac/` — macOS only: the AppKit shell, `run` (see "And on a Mac, type-checked only").
1977 - `protocol.rs` — inline-generated Wayland protocol bindings.
1978 - `ipc` (in `cce-core`) — the `/tmp/<prefix>-<WAYLAND_DISPLAY>.sock` helpers (`socket_path`,
1979 `send_command`, the bounded `read_request_line`, `focus_window`), and `ipc::instance`:
1980 single-instance claim-or-forward for apps that run once per session.
1981 - `icon.rs` — XDG icon-theme lookup: a `.desktop` `Icon=` key (or an SNI tray icon
1982 name) → a file on disk, plus `upload_themed` to rasterize/decode and upload it.
1983 **Not** `lib.rs`'s `upload_icon`, which loads a *bundled* cce-icons glyph by its
1984 own name for in-widget use; this one resolves names any installed app may ship.
1985 - `file_dialog.rs` (rfd), `scale.rs` (HiDPI), `wayland.rs` (surface/scale detection, and
1986 `detect_metric` — the display's logical px per mm from its `wl_output` geometry).
1987 - `units` (in `cce-core`) — lengths with units and the display metric; see the Units section below.
1988
1989 ### The GUI-free half is cce-core (since 2026-10-07)
1990
1991 `config`, `input`, `motion`, `units`, `relief_spec` and `ipc` — and the parsers for the specs the
1992 DE writes (hex colours, ramps, droplets) — moved to the sibling crate `cce-core`
1993 (github.com/lsgalante/cce-core). cce-ui depends on it and re-exports each at its old path
1994 (`pub use cce_core::config;` in `lib.rs`, `pub use cce_core::droplet::DropletSpec` in
1995 `scene::paint`, …), so `cce_ui::config::…` and `crate::config::…` resolve as they always did and
1996 no app changed. Two things did change:
1997
1998 - `DropletSpec::finish()` became the extension trait `scene::paint::DropletFinish` (a `Finish` is
1999 a renderer type `cce-core` cannot name): a call site writes
2000 `use cce_ui::scene::paint::DropletFinish;`.
2001 - The `cfg(test)` gates in those modules are `cfg(any(test, feature = "test-isolation"))` there,
2002 and cce-ui's `[dev-dependencies]` names `cce-core` with that feature, so this suite still never
2003 reads the machine. Two tests of the style layer that sat in `config.rs`'s suite are
2004 `src/config_style_tests.rs`.
2005
2006 The compositor depends on `cce-core` alone (it used cce-ui only for config, input bindings,
2007 `motion::enabled` and the relief/droplet parsers, and linked the whole toolkit for it), as do
2008 `cce-browser-open` (the instance client, no copy any more) and cce-window-manager (the ramp, with
2009 `default-features = false`: no config half, so no KDL or JSON). A change to these modules is a
2010 change to `cce-core`; push it, then `bump-revs.sh` repins cce-ui and the rest.
2011
2012 ## Markdown: `MarkdownView` and `DocEditor` (features, 2026-10-01)
2013
2014 Two opt-in features for the clients that show notes (Obsidian-on-cce):
2015
2016 - **`markdown`** — `widget::markdown`, the reading view: `cce_vault`'s
2017 blocks laid out at a width into draw items and click targets
2018 (`layout`, `Layout::paint` / `paint_scaled`). cce-notes' reading mode
2019 and cce-grid's note cards draw through it. Brings in `cce-vault`.
2020 - **`doc_editor`** — `widget::doc_editor::DocEditor`, the editor with
2021 Markdown **live preview**: markup is hidden except on the caret's lines
2022 (the selection's, or the whole fenced block the caret is in), where it
2023 shows dimmed; `preview = false` is source mode. No extra dependencies.
2024 - `buffer` — lines, caret/selection as (line, byte), edits with merged
2025 typing/deleting undo runs, and a log of `Change`s for the layout.
2026 - `preview` — styles ONE line: block kind (heading, list, task, quote,
2027 rule, code, fence, frontmatter, table) plus inline segments that map
2028 1:1 onto source bytes. Markup is never replaced, only hidden, so
2029 caret maths never translates between screen and source.
2030 - `layout` — one styled line wrapped into runs, with the x of every
2031 byte (`ShapingMeasure::offsets`), so drawing, caret and clicks agree.
2032 - **Incremental:** a line is shaped only when it is drawn and has
2033 changed; undrawn lines keep an estimated height. A 5000-line note
2034 shapes one screen (`a_long_document_shapes_only_what_shows`).
2035 - **Host-driven, not a registered widget:** the app forwards keys,
2036 presses, motion and the wheel and paints it (`prepare` then
2037 `paint_prepared_with`, which takes a link resolver so unresolved
2038 links fade without a relayout). Answers come back as `Response`
2039 (`Follow(Target)` for a rendered-link click or a Ctrl+click). Undo
2040 and redo are the host's `Application::undo` / `redo` hooks calling
2041 `DocEditor::undo` / `redo` — the runner routes the chord there
2042 because the editor is not a focused widget.
2043 - Measure with the app's own font set: `DocEditor::new(.., system_fonts)`
2044 must match `Application::load_system_fonts`, or widths are not drawn
2045 widths. `widget::shaping` holds `Measure` / `ShapingMeasure`, shared
2046 with the reading view; a width includes trailing spaces (max of glyph
2047 x + w), which is what a run placed after it needs.
2048 - **Frontmatter is the Properties table** while the caret is outside
2049 it (`preview::properties` gives each line a role, `style_property`
2050 its row): a "Properties" header, keys in a column as wide as the
2051 reading view's, values inline-styled (links follow), list values as
2052 pills — a one-per-line YAML list is one pill per line, its key drawn
2053 by the first item and the key line itself zero height — and
2054 `true`/`false` as a checkbox that flips the bytes in place
2055 (`LineLayout::toggle`, undoable). The caret anywhere in the block
2056 shows all of it raw, like a fenced block; an unclosed block, nested
2057 maps and block scalars stay raw. `set_text` starts the caret past the
2058 block so a note opens on the table.
2059 - The caret does not blink (a blink is a frame every half second for
2060 as long as the window is open).
2061
2062 ## A graph's wires are strokes in a style (since 2026-09-30)
2063
2064 `Graph` draws its wires in one of four `WireStyle`s: **orthogonal** (down,
2065 across, down — what every wire was), **rounded** (the
2066 same with the two bends rounded, the radius at most half a node's height),
2067 **bezier** (a cubic that leaves the output and reaches the input heading
2068 down, so a wire back up the graph loops) and **straight**. The style is
2069 `style.surface.graph.node.wire_style` unless the host sets one
2070 (`Graph::set_wire_style`, `None` to follow the config again). Colour and
2071 width are `wire_color` and `wire_size` (px at 100%, scaled with the node
2072 body) in the same block — both parsed since long before and READ BY
2073 NOTHING until this change, when the wires were a hard-coded cyan 3 px.
2074
2075 - **The wires are not in `geometry_quads_tagged` any more.** They are
2076 `Graph::paint_wires` (also on `GraphController`), `Prim::Vector`s and
2077 `Prim::Arc`s, since only one style is axis-aligned. `Paint::paint` calls
2078 it after the grid; a host drawing the quads itself (the designer) calls
2079 it between `paint_grid` and the bodies.
2080 - **The run across is on the first lattice line below the source**
2081 (`Graph::wire_turn_y`, since 2026-10-06), for orthogonal and rounded
2082 wires running down. It was halfway between the ports, so a wire spanning
2083 several rows ran down its source's column through any node standing
2084 there before it turned (row -1 to row 3 turned on row 1's line, through
2085 the node on it). Between adjacent rows no line lies between the bodies,
2086 and a wire running up has the source's own line first: both turn
2087 halfway, as before, and so does the connection being dragged. A rounded
2088 bend's radius fits the shorter leg. `a_wire_turns_on_the_first_line_below_its_source`.
2089 - **One path, drawn and hit**: `wire_path` derives each style's pieces, and
2090 the splice hit test (`splice_wire_at`) walks the same pieces against the
2091 dragged ghost, so a drop lands on the wire as drawn in any style.
2092 - **The orthogonal joins do not overlap** — the across run is widened by
2093 half a thickness to fill the corners and the down runs stop at its edge —
2094 so a translucent wire is one alpha throughout. A bezier is flat-capped
2095 pieces about 6 px of control net apiece, fine enough that no notch shows
2096 at the joins.
2097 - **A wire may be thinner than a pixel** (the same day). `wire_size` has no
2098 floor; what is drawn does (`wire_stroke`): one DEVICE pixel, half a
2099 logical one at 2x, since the 2D pass has no antialiasing and a narrower
2100 axis-aligned quad covers a row of pixel centres or none. A wire under
2101 that is the pixel at the share of it the wire covers, so it reads
2102 thinner by reading fainter. Until then the stroke was clamped to one
2103 LOGICAL pixel, two device pixels at 2x, and `wire_size` below 1 did
2104 nothing.
2105
2106 `every_wire_style_runs_from_port_to_port` and the splice test, run in all
2107 four styles, are the tests.
2108
2109 ## A node has as many wires as the host says (since 2026-09-30)
2110
2111 `Graph::wire_pairs` draws a wire for EVERY parameter a host types `node`,
2112 the k-th into input port k (`node_wires`, public so a host can read the
2113 rule back) — a Switch's four inputs, a Boolean's With, a Transfer's From.
2114 Until then it drew one, the parameter NAMED `input`, so every second
2115 operand was a real connection with no line. A host that types no parameter
2116 `node` keeps exactly that (cce-files and cce-graph pass `("input", name,
2117 "string")`), so nothing changed for them. An empty value is a port with
2118 nothing wired; a wire past the node's ports lands on port 0.
2119
2120 A connection the pointer makes reports its port too:
2121 `GraphController::take_pending_connection_to_port` gives (input node id,
2122 output node name, port), and the old `take_pending_connection` — which
2123 takes the same connection — is the default for hosts that do not care.
2124 Splicing a dragged node onto a wire takes only a wire into port 0, since
2125 the splice rewires Inputs. `every_node_parameter_is_a_wire_into_its_own_port`
2126 is the test.
2127
2128 ## A node dropped on a node can swap with it (since 2026-10-06)
2129
2130 `Graph::set_swap_on_drop(true)` makes a node dropped on another node SWAP
2131 places with it: the dragged node takes the other's cell and the other the
2132 cell the dragged node was picked up from, and
2133 `GraphController::take_pending_swap` hands the host (dragged id, other id)
2134 to trade whatever else the two own — the designer trades their wires. Off
2135 by default, where a drop on an occupied cell walks to the nearest free
2136 one as it always did, so cce-files and cce-graph see no change.
2137
2138 - **The swap target** (`swap_target`, by id like `splice_target`) is the
2139 node on the cell nearest the ghost, set in `drag_update`. While it holds
2140 it is coloured as the dragged node is, `drop_target_cell_rect` is ITS
2141 cell (no walk), and it wins over a wire: a node's own wires run into its
2142 body, so a ghost over a node always touches one, and a swap and a splice
2143 are never both reported.
2144 - **A host turns it off for a multi-node drag**: the widget drags one node,
2145 and one of a group trading places would scatter the rest.
2146 - **A snapped drag sits only where it could land** (`drag_update`, since
2147 the same day): the nearest crossing when it is free or a swap target,
2148 else the nearest free one — `find_empty_cell`, the walk `commit_drag`
2149 makes — so the ghost never stands over a node it cannot stay on, and
2150 `drop_target_cell_rect` is where it is. It snapped to the nearest
2151 crossing whatever stood there until then. Unsnapped drags (a host with
2152 `grid_snap` off) are unchanged.
2153
2154 `a_node_dropped_on_a_node_swaps_with_it` is the test.
2155
2156 ## Units — logical px inside, real lengths at the edges
2157
2158 The toolkit's working unit is and stays the **logical pixel**: every layout
2159 node, style slot and widget measure is an `f32` of logical px. `units` (in `cce-core`)
2160 adds the bridge to real lengths, in two parts:
2161
2162 - **`Len`** — a value with a unit (`px`, `mm`, `cm`, `in`, `pt`), parsed from
2163 `"2mm"` and resolved to logical px through a `Metric`. In config a length
2164 carries its unit as a KDL type annotation, the same way `(rgba)` and
2165 `(relief)` do: `width=(mm)2.0`. A bare number is a logical px, forever —
2166 nothing migrates. `config::kdl_to_json` turns an annotated number into the
2167 string `"2mm"`; the writer turns it back into `(mm)2`; `reload_config`
2168 stores it in the style registry's `lens` map, and `get_float` resolves it
2169 against the live metric at every read. So `layout::bevel_width()` and every
2170 other getter are unit-aware without knowing it, and a metric that arrives
2171 after config load (outputs come in after the first style read) or changes
2172 with the display is honoured without a reload. `get_len` returns the
2173 configured unit for editors that should show what the user typed.
2174 - **`Metric`** — logical px per mm for the display this process is on, plus
2175 its **source**: `measured` (EDID via `wl_output` geometry, or the
2176 compositor's configured `size_mm` in its place — the client cannot tell
2177 them apart; `ccectl outputs` can), `forced` (`CCE_FORCE_PPI`), or
2178 `assumed` — the CSS 96 px/in convention when nothing is known (a headless
2179 shadow, a projector with no EDID). The source is carried so fabrication
2180 can refuse a guess: `Metric::is_real()`. The window runner installs it
2181 beside `scale::set_scale_factor` (`units::set_metric`); apps read
2182 `units::metric()`, `units::mm(v)`, or `Len::to_px()`.
2183
2184 **The relief is two shapes, and the config says which (2026-09-28).** A
2185 **wall** is a carve's side — a recess, boss, ridge or trough cut into a
2186 surface: buttons, wells, text boxes, the rows of a params pane — shaded as a
2187 translucent light-and-shadow overlay on whatever is under it. An **edge** is
2188 a plate's perimeter roll, the face curving down to its silhouette, shaded as a
2189 multiply on the plate's own fill plus a specular crest. Each is a node under
2190 `style.surface.relief` with the same three keys:
2191
2192 ```kdl
2193 relief light=0.15 width=9.3 {
2194 wall height=(mm)0.3 profile="smooth;…"
2195 edge height=4.0 profile="smooth;…"
2196 }
2197 ```
2198
2199 `width` (the run of both, one number — see the roll-width note below) and
2200 `light` (how hard the light falls across either shape — NOT a length, it is
2201 `bevel_depth` → `Finish.strength`; `depth`, what every config said until
2202 2026-09-28, was its alias for the rest of that day and is retired — reported
2203 by path, not read, seeded from once by cce-relief whose Save writes `light`
2204 and takes `depth` off) stay on the node itself, since both shapes share
2205 them. `height` is a length — the wall's drop, the edge's rise —
2206 and `height=(mm)0.3` is honest geometry resolved through the metric; unset, a
2207 carve drops `relief_shade::RECESS_DEPTH` (0.6) of its wall (saturating at the
2208 DE roll width) and the roll is a quarter-round of radius width, the look every
2209 config had. `profile` is the curve as a ramp spec (absent or the identity
2210 sentinel = the analytic curve: smoothstep for a wall, the superellipse
2211 quadrant for an edge). **`shader`** on the relief node is the A/B switch
2212 for how every one of those edges is LIT: `shader=(bool)false` renders the
2213 relief prims through the legacy banded vertex shading instead of
2214 shader2d's per-pixel SDF-lit branch (`layout::bevel_shader`; the registry
2215 key keeps the bevel name because it selects how the shared lit edge is
2216 computed, not which shapes exist). It was `window_manager.bevel_shader`
2217 until 2026-09-28 — the block the relief keys were born in — and only a
2218 number ever switched it, since a `(bool)` flattens to the string "false"
2219 that the float read never saw; `shader_on` reads both now, and the old
2220 spelling is retired with the block's other bevel keys (cce-relief's Save
2221 carries a value it finds across). The registry keys never moved —
2222 `bevel_depth`, `bevel_width`, `bevel_shader`, `bevel_height` /
2223 `roll_height`, `bevel_profile_spec` / `roll_profile_spec` — so nothing
2224 downstream of the registry knows. Until 2026-09-28 the keys were flat on the
2225 node with the wall UNNAMED (`height`, `profile`) and the edge prefixed
2226 (`edge_height`, `edge_profile`), which read as one shape with an "edge"
2227 variant rather than two shapes; those spellings were aliases for the rest
2228 of that day and are RETIRED — not read, reported by path with the other
2229 retired surface keys, reaching no registry key (the flatten test asserts
2230 it). While they were aliases a `prefer_relief_spellings` pass dropped a flat
2231 one whenever its node spelling was present, because two spellings of one
2232 registry key were otherwise decided by line order; with nothing left to
2233 prefer, the pass is gone. `cce-relief` seeds from a flat key once, and its
2234 Save writes the node spellings and REMOVES the flat ones
2235 (`config::remove_config_value`), so a file migrates the first time it is
2236 saved; `wall` and `edge` are `PROP_NODES` members so their keys land as
2237 properties.
2238
2239 **The editor's knobs are not a style key.** cce-relief's Shoulder / Base /
2240 Bias triples — the slider positions behind each profile spec — rode in the
2241 style block as `(bevel)`-typed keys (`relief.wall.knobs` / `edge.knobs`,
2242 before that `profile_knobs` / `edge_knobs`) so the editor could reopen where
2243 it was left: editor state beside the values that draw, and the one relief
2244 key nothing but the editor read. They live in that app's own
2245 `~/.config/cce/cce-relief/state.kdl` now, one `knobs` node per Save target
2246 (`shared`, a retargeted file's path, or `<path>#<key>` for a `(relief)`
2247 value — two materials must not seed each other's sliders; `knob_state` in
2248 cce-relief's `src/main.rs`). The registry keys `bevel_profile_knobs` /
2249 `roll_profile_knobs` and their flatten arms are gone; a config that still
2250 carries a knob key seeds the editor once, off the raw file, and the next
2251 Save takes the key off under either spelling. The `(bevel)` type and
2252 `parse_bevel_knobs` stay, because a `(relief)` value still carries its own
2253 `k=` ride-along and the data editor's preview of such a value draws it. `layout::carve_depth_px` states the drop rule
2254 once for the tessellator's CSG features and, through `WindowInfo.relief_meta`,
2255 the shader's free carves; `carve_depth_ratio` / `roll_height_ratio` feed the
2256 shading twin (`Finish.carve_depth` / `roll_height`). A `(relief)` value
2257 carries the drop as `h=` (a length: `h=0.5mm`, or bare px) beside `w=` and
2258 `d=` (light; `l=` reads as an alias). `cce-relief`'s Height knob is the editor:
2259 its section's depth numbers read in mm when the metric is real, and Save
2260 writes `wall.height` in the unit the config already spells (an untouched
2261 slider verbatim, a moved one converted through the same metric that seeded
2262 it — so a headless session never turns `(mm)0.3` into `(px)1.1339`, which
2263 it did until 2026-09-28), choosing a unit only for a height the config
2264 never had: `(mm)` when the metric is real, px otherwise
2265 (`height_len_for` in cce-relief's `src/main.rs`).
2266
2267 **There is one roll width.** `style.surface.relief.width` is the run of every
2268 roll and wall: the root plate's perimeter (`PlateSpec::window`), a `PlateSpec`
2269 pane plate, a bordered widget plate under relief (`append_widget_plate`, via
2270 `colors::plate_bevel_width`), every control wall, and the length
2271 `edge.height` is a rise against. Until 2026-09-28 the widget-plate path had a
2272 width of its own — `style.surface.plate.bevel_width`, default 6 against the
2273 relief's 9.3 — so two pane plates in one window rolled over different widths
2274 depending on which painter drew them, and no single key made a pane match the
2275 window lip. `plate_bevel_width()` now returns the relief width and nothing
2276 else: the old key was an explicit override for the rest of that day (so a
2277 config carrying it kept its look through the change) and is RETIRED — a
2278 config still carrying it is reported by path with the other retired surface
2279 keys and the key is not read, since an override that keeps working is a
2280 second width by another name. `the_pane_roll_is_the_relief_width` pins it,
2281 and cce-relief's Save removes the key.
2282
2283 **And the relief can leave the screen.** `scene/heightfield.rs` integrates the
2284 height curves the shader only differentiates and samples a frame's plates into a
2285 height field — plates stack, carves etch, exactly the composite model the shader
2286 lights — then writes it as a 16-bit PNG whose sidecar carries the pitch and range
2287 in millimetres via the metric. A pinned `height=(mm)0.3` is 0.3 mm in that file.
2288 The sidecar states the metric's source; on an assumed metric the millimetres are
2289 a guess, and a fabrication tool should say so.
2290
2291 Why not millimetres inside: UI sizes are perceptual and angular, not physical
2292 — a hit target should not become 8 mm on a projector three metres away.
2293 Documents and fabrication content live in real units and convert at view
2294 time. Two domains, one bridge.
2295
2296 On the live laptop panel (3840×2400 over 344×215 mm at scale 2) the metric is
2297 5.58 logical px/mm (141.8 ppi); the default 9.3 px relief roll is 1.67 mm, and
2298 the 96 ppi assumption would have called it 2.46 mm.
2299
2300 ## Fonts & assets
2301
2302 `lib.rs` builds the cosmic-text `FontSystem` (re-exported as `cce_ui::cosmic_text` so clients
2303 need no text dependency of their own). Bundled fonts load from `$CCE_FONTS_DIR` (else
2304 `~/Dropbox/Fonts`); bundled icons from `$CCE_ICONS_DIR` (else `~/projects/cce/cce-icons/svg`).
2305 System fonts are loaded only when `$CCE_LOAD_SYSTEM_FONTS` is set (or via
2306 `create_font_system_with_system_fonts()`, used by the font picker). Configured custom font
2307 families are validated at startup with a warning if missing.
2308
2309 ### Every symbol is a cce-icons glyph (since 2026-10-05)
2310
2311 The DE's one icon source is `cce-icons/svg`, and the toolkit draws a symbol
2312 — a chevron, a check, a mark, a + or a − — ONLY as one of those glyphs, never
2313 as a character (`▼`, `✓`, `●`, `›`, `+`, an emoji) in whatever face the font
2314 falls back to, and never built from primitives. Apps hold to the same rule.
2315
2316 - **`PaintCtx::icon(name, rect, color)`** draws a glyph tinted like the text
2317 beside it (raw sRGB, as a text colour is; its alpha the image's), rasterized
2318 at twice the rect so it is crisp on a 2x output. `icon_untinted` is for the
2319 `weather-*` family, the one set that carries its own colours.
2320 `RenderTarget::icon` is the same call for the legacy target (a
2321 `PopoverCollector` draws nothing). Underneath, `upload_icon_tinted` /
2322 `icon_tint` / `icon_pixels`: the artwork is white, so multiplying is
2323 tinting.
2324 - **A context-menu row MARK is a glyph**: a label beginning `MARK_CHECK`
2325 (`"✓ "`), `MARK_ON` (`"● "`) or `MARK_OFF` (`"○ "`) draws `check`, `circle`
2326 or `circle-outline` at its left and the label without it. The text stays the
2327 row's identity, so hosts that match their own labels still match. A page
2328 row's `›` and the back band's `‹` are `chevron-right` / `chevron-left`.
2329 **A `Dropdown` list honours the same marks** (`mark_column`): a marked
2330 option draws its glyph in a column every row then keeps, and the closed
2331 trigger shows its value without the mark — so a "View" menu-button marks
2332 its switches as a menu does (`a_marked_option_draws_its_glyph`).
2333 - **The menu popup has images now.** Its renderer used to pass none
2334 (`images: &[]`). It keeps its OWN copies (`menu_icon_ids`): a glyph painted
2335 by the window renderer's id is looked up with `icon_source` and uploaded
2336 once into the popup's table (`VkRenderer::upload_rgba_now`). And it no
2337 longer drains the shared upload queue (`set_shared_uploads(false)`) — it
2338 did, so an upload queued between the window's frame and the popup's landed
2339 in the popup's table and never drew in the window.
2340 - **A vertical `ButtonStrip` / `Paginator` takes glyphs by name**
2341 (`with_icons`). It took the label's first character when that was a word of
2342 its own ("📁 Browse") and drew it as text.
2343 - Converted: dropdown arrow (`arrow_rect`, `ARROW_SIDE`), the params pane's
2344 picker face (it drew "▾" AND the arrow), menubar title arrow and checked
2345 items, spreadsheet sort marks (`chevron-up` / `chevron-down`), spinbox −/+,
2346 font selector ("Aa" → `font`), breadcrumb overflow (`more-horizontal`),
2347 markdown's unrenderable embed (`Draw::Icon`, `link`), the tree list's
2348 missing-icon fallback (now an empty slot) and the copy button's fallback
2349 ("📋" → "Copy").
2350 - **A flat host gets glyphs, strokes, arcs and discs.** `render_widget`
2351 replays a widget onto a host's `RenderTarget` and dropped every image,
2352 vector, arc and circle: after the symbols became glyphs a flat host lost
2353 them all, and a graph there (cce-files' Graph page) never showed a wire. A
2354 bundled glyph now goes to `RenderTarget::icon`, a stroke to `line`, an arc
2355 to `arc`, a disc to `circle` — each a no-op by default, so a host draws what
2356 it implements (`a_widget_glyph_reaches_a_flat_host`,
2357 `a_graphs_wires_reach_a_flat_host`).
2358 - **`ParametersBg` paints its controls' glyphs itself** (2026-10-06). The
2359 pane draws its rows' chrome and collects their TEXT (`own_text_labels`)
2360 without ever running a control's `Paint::paint` into the frame, so when
2361 the symbols became glyphs every dropdown, picker and spinbox in it lost
2362 its arrow or −/+. `Adapted::own_glyphs` is the image half of that bridge,
2363 and `paint_ui` / `paint` emit `child_glyphs` over the chrome. A container
2364 that collects its children's labels must collect their glyphs too
2365 (`the_pane_draws_its_controls_glyphs`).
2366 - **Kept as shapes**, being indicators and not symbols: `StatusDot`'s LED
2367 disc and the plate dock's corner dot.
2368
2369 `every_glyph_the_toolkit_names_is_in_the_icon_set` scans the source for glyph
2370 names and fails on one with no file — a missing glyph draws NOTHING, silently
2371 (skipped where the icon set is not checked out). `menu_marks_and_chevrons_are_glyphs`
2372 holds the menu convention. **A shadow session needs `CCE_ICONS_DIR`**: its
2373 HOME is isolated, so the default path finds no icons and every glyph is blank.
2374
2375 ## Debug environment variables
2376
2377 All opt-in, all read once, all quiet when unset — set one and run any client.
2378
2379 - `CCE_PLATE_DEBUG=1` — per frame, how many relief carves grouped into their host plate
2380 as exact CSG features vs fell back to standalone overlay shading, and for each fallback
2381 **why** (one of six rules: ridge, edge-suppressed, tinted, feature budget, no enclosing
2382 plate, host's feature run closed). The two paths shade junctions differently, and three
2383 of those rules are dynamic, so this is the answer to "why does this widget's carve look
2384 different here?". Note what it reveals: grouping is *rare* — the reference demo groups
2385 2 of 10, cce-files 0 of 7, because any ordinary geometry painted after a plate closes
2386 its grouping window (correctly — the carve's shading is baked into the plate's earlier
2387 draw).
2388 - `CCE_PRESENT_DEBUG=1` — swapchain present/acquire tracing.
2389 - `CCE_A11Y=1` — publish the accessibility tree over AT-SPI for an app that has not opted in
2390 (`Application::publishes_accessibility`; needs the `a11y` feature). `CCE_A11Y_DEBUG=1` logs
2391 each reader connection, action, publish (node count, focus, build time) and window-focus
2392 change. A shadow window holds no keyboard until `ctl focus-window <app_id>`, and nothing
2393 reads FOCUSED until it does. See `docs/rfc-accessibility-locale.md`, phase 2.
2394 - `CCE_UI_MENU_POPUP=0` — keep the context menu in the window instead of its popup
2395 surface (see "The context menu draws in its own popup surface").
2396 - `CCE_VK_DEVICE=<substring>` — force a physical device; `CCE_VK_RT=0` disables ray tracing.
2397 `integrated`, `discrete` or a name substring; any of them also lifts a session-wide ICD
2398 pin (`VK_DRIVER_FILES`) for the process. **A preference the chosen device does not meet
2399 is printed to stderr** (since 2026-10-02, once per process, `unmet_device_preference` in
2400 `vk/core.rs`): what was asked, what was taken, and every device the loader offered — a
2401 driver that failed to LOAD is in no list, which is the case the line points at
2402 (`VK_LOADER_DEBUG=error` says why). Until then the fallback was silent, and a fallback
2403 renders exactly as the asked-for device would, so nothing on screen gave it away.
2404 - The style registry loads config LAZILY: a value read before the first load and one read
2405 after come from two configurations. The suite no longer reads the machine's config at all
2406 (see "The tests never read the machine's config"), but the lazy load still holds within a
2407 run, so a test asserting on shading numbers pins its inputs (`relief_shade`'s tests:
2408 `pinned_light`, `pinned_finish`); `deeper_carve_shades_harder` failed run alone and passed
2409 in the full suite until it did.
2410 - `CCE_FORCE_SCALE=<f>` — override HiDPI scale detection.
2411 - `CCE_FORCE_PPI=<f>` — pin the display metric (logical px per inch) regardless of what
2412 the outputs report; a headless shadow has no EDID and would run `assumed`. The live
2413 panel is 141.8.
2414 - `CCE_HEIGHTMAP=<file.png>` — export the client's third rendered frame as a relief
2415 height field (16-bit greyscale PNG + `<file>.json` sidecar: pitch, range in mm, datum,
2416 metric source); `CCE_HEIGHTMAP_MM=<mm>` resamples to that pitch. `scene/heightfield.rs`.
2417 - `CCE_UI_FAULT_RECONNECT=<seconds>` — drop the session that many seconds after it
2418 starts, exactly as a transport error would, so the reconnect path (and the second
2419 `renderer_init`) can be exercised on demand instead of waited for. One-shot per
2420 process: the app reconnects and then stays up. A float, so `0.5` works; logs
2421 `CCE_UI_FAULT_RECONNECT: dropping the session` at WARN. In a shadow, note that the
2422 window MOVES across the reconnect (off-view recall), so capture with
2423 `shot-window <id>` and re-read `ctl windows` before any pointer work.
2424
2425 ### The params pane stacks its labels when it is narrow
2426
2427 `ParametersBg` has two label layouts: **inline**, the pane's own column of
2428 labels beside unlabelled controls, and **stacked**, each control carrying
2429 its label above itself. `param_label_layout` in the style config is the
2430 PREFERENCE (`stacked`, or inline by default). Since 2026-09-28 the rows
2431 decide on top of it: an inline pane whose label column would leave any
2432 visible row's TRACK shorter than `MIN_INLINE_TRACK_W` (120 px) stacks, and
2433 takes the column back when there is room. The track is what is measured —
2434 the control's rect less its chrome (`control_chrome`: a slider's 60 px
2435 readout and its gap, plus a float3's axis column) — because the first cut
2436 measured the rect and let a slider's track shrink to 52 px before the
2437 labels moved; a control with no track is measured whole. One decision for
2438 the pane, taken by its shortest track. The rows' widgets are built with or
2439 without their label and the two layouts have different row heights, so the
2440 flip is a relabel and a re-layout on every metrics refresh — a rect
2441 assignment, a rebuild, a section collapsing (`apply_label_layout` /
2442 `relabel_rows`) — not a flag; `Adapted::clear_label` is the way a label
2443 comes OFF a widget, and `slider::detached_strip` reads an empty label as
2444 none for the widgets that store whatever they are handed.
2445
2446 ### A value control reads the wheel as "up is more", a natural finger too
2447
2448 `MouseScrollDelta::value_notches_y` (2026-09-30) is what a VALUE control —
2449 `Slider`, `Slider2D`, `Spinbox`, a context menu's slider row — turns by:
2450 a wheel notch up is positive, and a finger's pixel delta is taken as it
2451 comes with natural scrolling off and NEGATED with it on
2452 (`input::natural_scroll`, input.kdl's `trackpad { natural_scroll }`, read
2453 once). The delta the runner hands out is what a LIST scrolls by, and a
2454 natural list moves its content the way the fingers went; a value has no
2455 content to move, so under natural scrolling the fingers going up is
2456 more. Until then each control read `notches_y` with a sign of its own:
2457 the slider was right for a natural trackpad and backwards for a wheel,
2458 the spinbox and the menu slider the other way round. Under `cfg(test)`
2459 the toolkit's suite reads natural as off; `force_natural_scroll` sets it
2460 per THREAD for a test that drives a control with a finger — a dependent's
2461 test binary links cce-ui without `cfg(test)` and would otherwise read the
2462 machine's. `a_value_control_reads_up_as_more_on_a_wheel_and_a_natural_finger`
2463 is the test.
2464
2465 ### A flick coasts with animations off
2466
2467 The animations switch (`motion::enabled`) stops a wheel notch's GLIDE and
2468 not a trackpad flick's COAST (`scroll_motion::with_animations`, since
2469 2026-09-29). Until then it turned both off, so on a power mode with
2470 animations off a two-finger scroll stopped dead at the lift in every
2471 pane that scrolls through `ScrollMotion` — lists, the params pane, the
2472 spreadsheet, a graph's pan. The glide is an animation the toolkit adds;
2473 the coast is the rest of a gesture the hand made, and its own setting is
2474 input.kdl's `kinetic_scroll`. Two value controls still follow the switch,
2475 deliberately left alone: a slider's and a ramp key's drift after a scroll
2476 over them, which changes a VALUE after the hand has stopped.
2477 `the_animations_switch_stops_the_glide_and_not_the_coast` is the test.
2478
2479 ### A finger scrolls (touchscreens, since 2026-10-05)
2480
2481 The runner binds `wl_touch` when the seat offers it, and `backend/touch.rs`
2482 turns the first finger into pointer input by what it does: a **tap** clicks
2483 where it landed, a finger that **moves** past `SLOP` (10 px) scrolls — a
2484 `PixelDelta` equal to the finger's travel, `ScrollPhase::Finger`, dispatched
2485 at the down point, then `FingerEnd` at the lift so a flick coasts through
2486 `ScrollMotion` like a trackpad's — and a finger **held** `HOLD_MS` (400 ms)
2487 before moving is a held left button (a slider thumb, a text selection, a
2488 scrollbar). The hold needs no timer: nothing is sent while the finger rests
2489 inside the slop, so the choice is made at the first motion past it. Other
2490 fingers are ignored until the first lifts. `TouchTracker` is the pure state
2491 machine (tested in that file, and portable); what its actions do is the
2492 driver's (`Driver::touch`, routing like every other input), and only the
2493 `TouchHandler` impl beside the tracker is Wayland's, so `window_runner.rs`
2494 carries only the fields and the capability hook.
2495
2496 A finger is always natural — the content goes where it is pushed — so the
2497 dispatch runs inside `input::with_natural_scroll(true, …)` and a value
2498 control's `value_notches_y` reads the finger's real direction whatever the
2499 trackpad's setting. No per-app trackpad factor either: 1:1 keeps the content
2500 under the finger. Not by finger: CSD moves/resizes and
2501 `Application::take_window_action`, since the compositor checks those serials
2502 against a pointer grab; the runner drains a queued action after a touch so it
2503 cannot fire on the next pointer press. Binding `wl_touch` is also what moves a
2504 cce-ui window off the compositor's emulated-pointer route
2505 (`cce-compositor`'s `cursor::TouchRoute`), where a finger drag was a held
2506 button and selected rather than scrolled. `CCE_SCROLL_DEBUG=1` logs each
2507 touch scroll (`[scroll] touch: …`); in a shadow, `ccectl touch down|motion|up`
2508 drives it.
2509
2510 ### A field being edited says so (text-input-v3, since 2026-10-05)
2511
2512 A widget open for typing calls `cce_ui::text_input::claim(x, y, w, h)` from
2513 its paint, every frame (window px, the `PaintCtx` offset added). A claim per
2514 frame, not an enable/disable pair, because a field leaves editing on many
2515 paths (Enter, Escape, a click elsewhere, focus loss, its page dropped) and a
2516 widget that stops painting has stopped claiming. `claim` is
2517 `ime::report_caret` by its first name — the two were written the same day on
2518 two branches and merged into one: the frame's last claim is `ime::caret()`,
2519 which every shell reads (see "On Wayland it is `text-input-v3`" above for the
2520 Wayland half, `backend/text_input.rs`). The compositor raises the on-screen
2521 keyboard on an enable that follows a touch (`cce-compositor`'s `osk.rs`).
2522 `Spinbox`, `Slider`'s readout, `ColorSelector`, the params pane's code rows
2523 claim their field; `TextBox` and a focused `DocEditor` claim their field (the
2524 viewport) and then report the caret once it is drawn, which wins; an app that
2525 draws its own text (a `LineEdit`, a terminal, an editor) must claim from
2526 `display_list` while it has a caret, or the board will not follow it. Keys
2527 still come over `wl_keyboard`; an input method's commit is typed through
2528 `Driver::commit_text`. On `enter` the last frame's claim is applied at once,
2529 and `leave` disables an enabled text input: wlroots keeps the enabled state
2530 across a leave, and a stale "enabled" turns the next enable into a plain
2531 commit the compositor ignores.
2532
2533 Three things learned taking it to the apps (2026-10-05):
2534
2535 - **A press re-announces an open field.** The compositor reacts only to an
2536 enable or a commit right after a touch, and a field already open (a focused
2537 terminal claims from the moment it maps; a text box still editing) sends
2538 neither when tapped again. So the driver marks a pointer or touch press
2539 (`ime::note_press`) and the next plan that still has a caret commits once
2540 more, unchanged (`TextInput::plan`'s `pressed`); a press that ends the
2541 editing disables instead, so tapping away never flashes the board. A mouse
2542 click re-commits too and the compositor ignores it (no finger armed it).
2543 - **An app that replays cached geometry must replay the claim.** A host that
2544 paints its widgets only in a `rebuild_layout` (cce-system-interface,
2545 cce-files) claims on rebuild frames alone, and the first replayed frame
2546 disables the field: in a shadow the board was already gone two seconds
2547 after the tap. Run the rebuild under `text_input::capture` and claim what
2548 it returns on every frame.
2549 - **A widget drawn from its host's aggregates never claims.** `ParametersBg`
2550 paints its hosted text boxes, spinboxes, sliders, colours and vectors from
2551 its own views, not through their `paint`, so none of their claims ran in
2552 the designer; the pane claims for the row being typed into
2553 (`claim_typing`), from `paint_ui` as well as `paint`.
2554
2555 ### A host may name the phase; a test may pin the settings (2026-09-30)
2556
2557 The phase a wheel event belongs to (`Finger`, `FingerEnd`, `Wheel`) is the
2558 window's (`window_state`), published by the runner before each dispatch, and
2559 `ScrollMotion::apply_px` reads it. Until 2026-10-08 it was one process-wide
2560 atomic, so a test setting it changed what every other test's pixel delta
2561 meant; with no window entered it is now each thread's own. `ScrollMotion::apply_phase` takes the phase
2562 as an argument (`apply_px` is it with the published one), for a host that
2563 reads the phase itself and hands it on; cce-designer's viewport does, and
2564 its test drives a flick without touching the global.
2565
2566 `scroll_motion::force_scroll_settings` pins what `scroll_settings()`
2567 answers on the calling THREAD, over input.kdl and the animations switch
2568 alike, as `force_natural_scroll` pins natural scrolling: a dependent's test
2569 binary links cce-ui without `cfg(test)`, so a test whose result hangs on a
2570 coast would otherwise pass or fail by the machine's `kinetic_scroll`.
2571
2572 ### The suite's animations switch is its own
2573
2574 `motion::enabled()` reads `/run/cce/animations` in a shipped binary (see
2575 "Animations switch" in `../cce-compositor/WORKSPACE.md`). Under `cfg(test)`
2576 it does NOT: it answers on, or whatever `motion::force_for_test` set on the
2577 calling thread. Until 2026-09-28 the test binary read the machine's file,
2578 so the spreadsheet's wheel-glide tests and the scroll region's fade test
2579 passed on mains and failed on battery — the same lesson as cce-designer's
2580 pinned settings path and lattice, one layer down. A test wanting the
2581 snap-instead-of-ease path forces it for its thread; nothing sets
2582 `CCE_ANIMATIONS`, which is process-wide and would race the parallel suite.
2583 This pins only cce-ui's own suite: a dependent's test binary links cce-ui
2584 without `cfg(test)`, so a dependent test that eases still follows the
2585 machine — none does today.
2586
2587 ### A colour test that reloads a knob puts it back
2588
2589 `color::style_write` (every `set_*`) is a per-thread overlay under
2590 `cfg(test)`, but `reload_colors` writes the process-wide globals — and an
2591 absent frost knob KEEPS its last value by design, so `reload_colors("")`
2592 resets the named materials and bindings (replaced wholesale) and nothing
2593 else. A test that reloads `frost radius=3.0` and closes with the empty
2594 reload leaves radius 3 behind for every test after it; that is what had
2595 `frost_from_style_and_flag` fail one run in eight (2026-09-28, the radius
2596 read 3.0 for the default whenever its neighbour ran first). Two rules: a
2597 test that ASSERTS a knob pins it on its own thread through the setter, the
2598 radius included, not just the ones it is about; and a test that RELOADS a
2599 knob reloads its default back before the empty reload, and asserts the
2600 globals are back. `test_color_state_lock` orders the reloaders against
2601 each other; it cannot undo what one of them left behind.
2602
2603 ### A scroll gesture in the params pane belongs to what it begins on
2604
2605 `ParametersBg`'s wheel arm gives a gesture to the VALUE CONTROL it begins
2606 on and to the pane otherwise, for a wheel notch and a trackpad finger
2607 gesture alike: a slider and each row of a float3 by the band's halo
2608 (`Slider::scroll_hit`), a spinbox by its row. The control that acquired it
2609 keeps it until the gesture ends (`scroll_initiate_widget_id`, 250 ms of
2610 quiet), so the latched control is asked first and a gesture the pane or
2611 another control owns never lands on a band that slides under the pointer.
2612 From 2026-09-21 to 2026-09-28 every FINGER gesture was the pane's from
2613 anywhere, on the reasoning that a pane of mostly controls was scrollable
2614 only from a label; that reasoning was about the designer's Alt+D Settings
2615 list, which stopped being a `ParametersBg` on 2026-09-24, and what the
2616 rule left behind was sliders a trackpad could not turn. The pane still
2617 scrolls from the label column, the gaps between bands, and every row that
2618 is not a value control.
2619
2620 ### A slider's notch follows the value on a wide range
2621
2622 One wheel notch or arrow press moves a `Slider` by `notch_step`: 2% of the
2623 range for a range up to `FINE_SPAN` (20) wide, which leaves every slider
2624 the toolkit had exactly as it was. A WIDER range steps 2% of a span that
2625 grows with the value's magnitude — 20 times it, floored at 20 and capped
2626 at the range — so -1000..1000 moves 0.4 a notch near zero, 4 at ten, and
2627 the old 40 only from a hundred up. By magnitude rather than a finer flat
2628 step: one fine enough to set 0.06 takes thousands of notches to reach
2629 1000, and this takes under sixty. A drag still maps the pointer to the
2630 whole range; the readout is still where an exact value is typed.
2631
2632 ### A float3 group can carry a trackball
2633
2634 `Float3::set_trackball` (a `ParametersBg` row typed
2635 `float3:lo:hi:trackball`) stands a ball left of the three rows, as tall as
2636 they are. The vector is drawn on it from the centre — X right, Y up, Z
2637 toward the viewer; bright on the near side, dim pointing away — and
2638 dragging the ball rolls it under the pointer, turning the vector and
2639 keeping its length. The rows stay: a direction is turned on the ball, a
2640 component typed or a length changed on a row. Three things to know. The
2641 drag turns its OWN full-precision copy (`BallDrag`), because the rows hold
2642 the vector rounded and a host writes the rounded string back between
2643 moves; turning that loses every step smaller than a readout tick. With
2644 the ball on the rows read to three decimals, since at two a vector of
2645 length 0.06 has seven directions. And the ball is painted by
2646 `paint_ball` through the host's scene path (`paint_scene_rows`), not
2647 `Paint::paint`: a sphere is not a prim the legacy flat views carry. A
2648 vector of no length is given a length of one by the first drag. The ball
2649 counts as chrome in `ParametersBg::control_chrome`, so the label layout
2650 is decided by the track it leaves.
2651
2652 **The ball can be seen from a host's camera** (`Float3::set_view`,
2653 `ParametersBg::set_trackball_view`): three rows, the camera's right, its
2654 up and the direction toward it, in the vector's space. The vector and its
2655 rings are then drawn as the host's 3D view shows them, and a drag or a
2656 scroll rolls about the CAMERA's axes — pushing the ball right swings the
2657 vector to the right of the screen, whatever that is in the scene. Only
2658 what the ball shows and how it turns: the rows and the value stay in the
2659 vector's own space. Without one the view is X right, Y up, Z toward the
2660 viewer. The pane keeps the view for rows built later.
2661
2662 **The ball carries rings** (`Float3::ring`, `RING_ANGLES`): five circles
2663 of latitude about the vector as their pole, thirty degrees apart, the
2664 near half of each drawn in short strokes. A lit ball is the same from
2665 every side, so without them a drag showed the vector move and the ball
2666 stand still; the rings are the vector's own, concentric circles when it
2667 points at the viewer and foreshortening into ellipses as it turns, which
2668 is how a rotation is read. They follow the full-precision direction a
2669 drag or a scroll is turning, so they move on every pixel.
2670
2671 **A scroll rolls the ball** (`ball_scroll`), as content is scrolled: its
2672 surface moves the way a page under the pointer would, a two-finger
2673 gesture in both axes at once and a wheel notch in one, by `SCROLL_TURN`
2674 (15 degrees) a notch — the fine handle beside the drag's 1:1. The ball
2675 has an id of its own (`ball_id`) in the scroll-gesture bookkeeping, so a
2676 gesture that begins on it is the ball's until it ends and one a band
2677 holds stays the band's over the ball; `wheel_zone` / `wheel_latched` are
2678 what the pane asks. A scroll keeps its own full-precision copy too
2679 (`fine`), reused while the rows still hold what it rounds to: a trackpad
2680 sends a pixel at a time, a quarter of a degree, which on a short vector
2681 is less than the rows can hold.
2682
2683 ### A spreadsheet is columns of values, written as they are painted
2684
2685 `Spreadsheet` holds its table as COLUMNS (since 2026-10-07, `SheetColumn`:
2686 `Text`, `Int`, or `Float` to a number of decimals) and writes a cell's text
2687 only when it paints the cell. `SpreadsheetController::set_spreadsheet_columns`
2688 takes them; `set_spreadsheet_data` (rows of strings) still works, its cells
2689 becoming text columns. A sort compares the values — numbers by number, text
2690 as before (numerically where both cells parse) — instead of parsing every
2691 cell twice a comparison. It exists for a host that refills the table every
2692 frame: the designer did so during a simulation's playback with every cell of
2693 every row formatted into a `String`, ten thousand rows of them for a pane
2694 that shows thirty. A float cell is `format!("{:.*}", decimals, value)`, the
2695 string a host formatting it itself would have written.
2696 `a_column_table_is_written_as_painted_and_sorts_by_value` is the test.
2697
2698 ### A spreadsheet's columns are as wide as their content
2699
2700 Each column of a `Spreadsheet` (since 2026-10-07) is as wide as its header
2701 with room for the sort glyph (`SORT_MARK_CHARS`, kept whether or not the
2702 column is sorted, so a sort moves nothing) or its widest cell, whichever is
2703 wider, plus `CELL_PAD` — at least `MIN_COL_CHARS` characters. The widths are
2704 counted in characters when the table is set (`set_columns`), from the values
2705 rather than from written text (`SheetColumn::max_chars`: a whole number's
2706 least or greatest, a float's sign and integer digits and its decimals, the
2707 non-finite spellings), and made pixels by the label font's character width,
2708 the font being monospace (`col_edges`). The table does not stretch: what is
2709 left of a wide pane is empty, a line marking where the last column ends; a
2710 table wider than the pane scrolls as before. Until then every column was an
2711 even share of the pane, floored at 76 px, so a point index or a 0/1 column
2712 was as wide as a four-decimal float. A host whose header is its widest cell
2713 saves the most by keeping headers short. `columns_fit_their_content_and_overflow_scrolls`
2714 and `a_columns_widest_cell_is_worked_out_from_its_values` are the tests.
2715
2716 ### A spreadsheet's rows can be selected
2717
2718 `Spreadsheet` keeps a selection of rows (since 2026-09-29): a press on a
2719 row selects it alone, or clears it where it was the whole selection; with
2720 ctrl the press toggles that row and leaves the rest; with shift it selects
2721 the run from the last row pressed without shift to this one. A press on
2722 the empty body under the last row clears. The host pushes the modifiers in
2723 ahead of the press (`set_modifiers`), as it does for every widget.
2724
2725 **The selection is of rows of the DATA, not of places in the pane.** A
2726 sort moves where a selected row is drawn and not what is selected, and a
2727 shift run is the rows DISPLAYED between the two, which under a sort is
2728 what the eye sees. It stands across `set_spreadsheet_data`, less the rows
2729 a shorter table no longer has: a host that re-sets the table on every
2730 frame of a playback keeps its selection, and one whose table has become
2731 something else clears it itself.
2732
2733 `SpreadsheetController` carries it: `selected_rows` (ascending),
2734 `set_selected_rows`, and `take_selection_change`, which is true once after
2735 anything changed the selection. A press on a RAISED scrollbar is the drag
2736 surface's and selects nothing (`body_row_at`); a sunk one is behind the
2737 plate and the press is the row's. Selected rows wear
2738 `highlight_primary_color` at 28% over the zebra.
2739 `rows_select_alone_toggled_and_in_runs` is the test.
2740
2741 ### A spreadsheet's scrollbars are a cross behind its plate
2742
2743 `Spreadsheet`'s two bars (since 2026-10-06) ride the CENTRE lines of what
2744 they scroll — the vertical one the pane's width, the horizontal one the
2745 body's height — so with both they cross at the middle of the body, over
2746 the cells, reserving no lane; the params pane's bar and the designer
2747 dialog's ride theirs the same way. Until then they were 6 px strips at the
2748 right and bottom edges, always in front. They are `scrollbar_width` × 1.6
2749 pills in the shared track and thumb colours, and they share one
2750 `ScrollbarActivity`: a wheel, a key, a glide or coast in motion, or a
2751 thumb drag raises them; a pointer over a raised bar holds them up; with
2752 nothing holding them for the hold window they sink. Sunk they take no
2753 press (`drag_begin` and `body_row_at` ask the latch). The widget paints
2754 the FORE copy at the activity's fade; the copy that idles behind the
2755 plate is the host's, before the plate, through `paint_scrollbars(rect,
2756 ctx, 1.0)` (`scrollbars_shown` says whether there is one) — the plate is
2757 the host's too. The vertical bar owns the middle of the cross for a drag.
2758 `the_scrollbars_cross_at_the_body_and_sink_until_scrolled` is the test.
2759
2760 ### Every scrollbar rides a centre line, behind the plate
2761
2762 The rule the spreadsheet's cross follows is the DE's one scrollbar design
2763 (2026-10-06), and every scrolling list and pane in the toolkit offers it:
2764
2765 - **It rides the CENTRE line of what it scrolls** — the vertical bar down
2766 the middle of the width, the horizontal one across the middle of the
2767 viewport — so with both they cross there. Over the content, reserving no
2768 lane. `layout::centred_scrollbar_width()` thick (`scrollbar_width` × 1.6:
2769 over rows the stock width reads too slim), pills in the shared track and
2770 thumb colours.
2771 - **It idles BEHIND the host's translucent plate** and takes no press
2772 there: a press on its lane is a press on the row under it.
2773 - **A scroll raises it in front with a fade** (`ScrollbarActivity`): a
2774 wheel, a key, a glide or coast in motion, a host's own scroll
2775 (`notify_scrolled`), a thumb drag. A pointer over a RAISED bar holds it
2776 up; hover never raises a sunk one. With nothing holding it for
2777 `SCROLL_ACTIVE_HOLD` it sinks, fading out over `SCROLL_FADE_SECS`.
2778 - **Two copies, the host's and the widget's.** The idle copy is drawn at
2779 full alpha BEFORE the plate, every frame — raised or not, since the fore
2780 copy fades in over it and dropping it at the latch would blink the bar.
2781 The fore copy is drawn after the content at the activity's `fade()`.
2782
2783 Who draws what:
2784
2785 | Widget | Opt in | Idle copy (before the plate) | Fore copy (after the content) |
2786 |---|---|---|---|
2787 | `Spreadsheet` | always | host: `paint_scrollbars(rect, ctx, 1.0)` | the widget's own paint |
2788 | `ParametersBg` | always | host: `scrollbar_quads()` as pills | host: the same at `scrollbar_fade()` |
2789 | `ScrollRegion` | `with_sink_behind(true)` | framed: `push_prims`, under its bg; frameless: host, `push_scrollbar_prims` | host: `push_scrollbar_fore` |
2790 | `ScrollBox` | `sink_behind = true` | host: `paint_scrollbar_pills(pc, 1.0)` | host: `paint_scrollbar_pills(pc, scrollbar_fade())` |
2791 | `TreeList` | always (its `ScrollBox`) | the widget, under its own plate | the widget, over the rows and the well's wall |
2792
2793 For `ScrollRegion` and `ScrollBox`, sinking IS centring: a region or box
2794 that does not opt in keeps its always-on bar at the right/bottom edge
2795 (`edge_inset` applies to those only), and a sink-behind one ignores it.
2796 A sink-behind `ScrollRegion`'s `push_prims` no longer draws the fore copy,
2797 which would land under the rows a host draws after it. The tuple path
2798 (`push_quads` / `push_scrollbar_quads`) is flat squares on a hard flip,
2799 vertical only; every sink-behind host is on the prim path. The relief
2800 scrollbar (`paint_relief_scrollbar`, carved groove and bevelled thumb)
2801 is for edge bars only: shader-lit relief does not fade with a vertex
2802 alpha. Tests: `sink_behind_bars_cross_at_the_centre`,
2803 `the_fore_copy_is_drawn_after_the_rows`,
2804 `a_sink_behind_bar_rides_the_centre_and_sinks_until_scrolled`.
2805
2806 **Not on it, deliberately:** a multi-line `TextBox`'s position indicator
2807 stays at the right edge, always shown and non-interactive — an editor
2808 keeps its place marker (the user's call, 2026-10-06).