git.lucas.co / cce-ui
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).