git.lucas.co / cce-ui
GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git

commitc702f40771ad512056edd9c05ec94efdf1cabdae
parent49bee9238b 9da0c19d8f
authorClaude <noreply@anthropic.com>
date2026-10-05 23:56
Merge main: touch input, the shared text cache, CI

main's six commits, with the three that touched window_runner moved to
where this branch keeps their code:

- The text cache (f46e8b0): Rc<Buffer> entries keyed text -> variants,
  shared_text_buffer / shared_laid_out_buffer, boxed text cached by its
  box, DlText for the frame — in backend/text.rs, with its tests. The
  frame, the browser shell and the AppKit shell hold DlText; DlText is
  public (fields crate-private) since build_frame is, for probe_web.
  debug_clock_ms and present_debug stay in the Wayland shell.
- The status bar asked once per session: a OnceCell in the Driver, which
  owns the CSD checks it serves.
- Touch (13976a5): TouchTracker stays in backend/touch.rs and is now
  portable; what its actions do is Driver::touch (routing, as every other
  input), reusing the pointer's outside-press popover close; only the
  wl_touch binding is Wayland's. New test:
  a_finger_is_the_left_button_and_never_the_windows.
- motion.rs keeps web_time::Instant in main's moved cache.

Verified with CI's own steps (RUSTFLAGS=-D warnings): build --all-targets;
test 560 passed; test --all-features 599 passed; the vk:: tests ran on
lavapipe, none skipped. Plate golden identical; check-wasm and check-mac
ok.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WjL3pejMNY95NHv9BcmXaZ

 .github/workflows/ci.yml              |  60 ++++++
 CLAUDE.md                             |  43 +++-
 src/backend/driver.rs                 | 155 +++++++++++++--
 src/backend/frame.rs                  |   6 +-
 src/backend/mod.rs                    |   1 +
 src/backend/text.rs                   | 357 ++++++++++++++++++++++++++--------
 src/backend/touch.rs                  | 327 +++++++++++++++++++++++++++++++
 src/backend/window_runner.rs          |  63 ++++--
 src/color.rs                          |  11 +-
 src/config.rs                         |   1 -
 src/input.rs                          |  11 ++
 src/layout.rs                         |  14 +-
 src/mac/mod.rs                        |   5 +-
 src/motion.rs                         |   7 +-
 src/scene/material.rs                 |  39 ++--
 src/scene/paint.rs                    |   8 +-
 src/vk/mod.rs                         |   1 +
 src/vk/renderer.rs                    |   2 +-
 src/web/shell.rs                      |   5 +-
 src/widget/container/parameters_bg.rs |  51 ++++-
 src/widget/display/graph.rs           |   2 +-
 src/widget/input/button.rs            |  12 +-
 src/widget/shaping.rs                 |   4 +-
 23 files changed, 1026 insertions(+), 159 deletions(-)

diff --cc CLAUDE.md
index b2579f6,a8c675a..b501fbc
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@@ -57,336 -56,14 +57,344 @@@ scattering of widgets (`text_box`, `sli
  engine, that module's tests are the fast feedback loop; run `cargo test -p cce-ui scene::` before
  anything else.
  
 +**The library also builds for the browser** (`wasm32-unknown-unknown`, since 2026-10-04):
 +`scripts/check-wasm` type-checks it, with and without the optional features. The native
 +shell and renderer — `vk`, the Wayland shell (`backend::{window_runner, menu_popup, dnd}`),
 +`wayland`, `protocol`, `ipc`, `mcp`, `file_dialog` — and their crates (ash, smithay, calloop,
 +wayland-*, libc, rfd) are `cfg(not(target_arch = "wasm32"))`; so are the Wayland-typed parts
 +of the client contract: `Application::new(qh, …)`, `layer()` and `LayerSettings`,
 +`register_sources`, and `renderer_init` / `stage_renderer`, which take a `VkRenderer`.
 +`WindowAction::Resize` takes `app::WindowEdge` — xdg's `ResizeEdge` on Linux, as before.
 +Since macOS joined (2026-10-05) "native" is two things: the Vulkan renderer and the native
 +services (`vk`, `ipc`, `file_dialog`; ash, libc, rfd) are `cfg(not(target_arch = "wasm32"))`,
 +and the WAYLAND shell — `backend::{window_runner, menu_popup, dnd}`, `wayland`, `protocol`,
 +`mcp`, the crates smithay / calloop / wayland-* / xkeysym, and the contract's `new(qh, …)`,
 +`layer()`, `LayerSettings` and `register_sources` — is
 +`cfg(not(any(target_arch = "wasm32", target_os = "macos")))`. `renderer_init` /
 +`stage_renderer` are on macOS too, since it has the Vulkan renderer.
 +Portable code keeps time with `web_time::Instant` (std's own type natively; std's panics in
 +the browser), and reaches what a renderer draws through `crate::draw`, not `crate::vk`. A
 +change that makes portable code call into a native module fails `check-wasm` first — put
 +the native half behind the cfg, as `color_selector::place_picker_at_pointer` does.
 +
 +**It draws in the browser too** (`src/web`, `WebRenderer`, since 2026-10-04): the Vulkan
 +renderer's 2D path on WebGPU through web-sys, from the same `Frame2D`, shaders, glyph atlas
 +and image queue. web-sys still ships its WebGPU bindings behind `--cfg=web_sys_unstable_apis`;
 +`.cargo/config.toml` sets it for the wasm target, and **cargo reads that file from the
 +directory it is run in** — so build the browser half from inside `cce-ui` (as
 +`check-wasm` does), and a client crate that builds cce-ui for the browser needs the same line
 +in its own config.
 +
 +**And an `Application` runs in a page** (`web::run::<App>(canvas, fonts, sizing).await`, since
 +2026-10-05): the browser shell (`src/web/shell.rs`) is the Wayland shell's counterpart over a
 +`<canvas>`, on the same `Driver` and `Pacer`. What it does in the page's terms:
 +
 +- **Events**: pointer (captured on press, so a drag outside the canvas still ends), wheel,
 +  key and focus events on the canvas, mapped by `backend::dom` — `map_key` gives a key the
 +  TEXT xkb's `utf8` would (Tab "\t", Enter "\r", Ctrl+letter its control code: the Wayland
 +  shell hands widgets exactly that, and a focused text box inserts Tab's), `wheel_frame`
 +  reads a whole notch-sized pixel delta (Chromium's 100 px) or a line / page delta as a wheel
 +  notch and anything else as a finger, and a finger gesture's lift is synthesized after
 +  120 ms without a frame (`FINGER_LIFT`), since a page reports none. The browser's own key
 +  repeats are dropped: the driver repeats, as on Wayland. On a Mac, Command is the shortcut
 +  key (⌘Z is undo). A page has no grabs, so a press on a CSD border is the app's.
 +- **Pacing**: a turn per animation frame at the pacer's ACTIVE cadence, a timer at its idle
 +  one; any event, and any `AppSender::send` (through `backend::app::set_wake`), wakes the
 +  loop for the next frame. Measured idle: 5 turns in 5 s, the native count.
 +- **Size**: `Sizing::App` sizes the canvas from `WindowSettings` and `desired_size` (CSS px),
 +  as a window; `Sizing::Page` leaves it to the page's CSS and ignores size requests (a tiling
 +  compositor's answer); a ResizeObserver wakes a turn on relayout, and `devicePixelRatio` is
 +  the scale. The context menu is drawn in the canvas and kept inside it, as on a layer surface.
 +- **Fonts** (`web::Fonts`): the files, and the generic serif / sans / mono families — a page
 +  has no font directory and no fontconfig, so it says both. `lib::page_fonts` holds them, and
 +  on wasm EVERY font database the toolkit builds loads them: the shell's, the widget-geometry
 +  one (`geometry_font_system`) and the text-measurement one (`widget::input::get_font_db`,
 +  resvg's — the toggle's label is centred by it). cosmic-text has no family fallback list on
 +  wasm (`fallback/other.rs` is empty), where on Linux it walks Noto Sans → DejaVu Sans → …,
 +  so `page_fonts::stand_in_for_missing` gives the families the toolkit names (the configured
 +  fonts, "Berkeley Mono") the faces of the first family of that Linux list the set has; a
 +  family an app names itself must be in the set. Order matters: the measuring fallback is the
 +  first face with the glyph.
 +- **`web::capture().await`**: the next frame, read back from the GPU. Headless Chromium
 +  composites in software and leaves a WebGPU canvas out of its screenshots and `toDataURL`.
 +- **The clipboard** (since 2026-10-05): `widget::clipboard` is one synchronous text pair
 +  (`copy_to_clipboard` / `read_from_clipboard`, every widget's copy, cut and paste) with a
 +  backend per platform — `wl-copy` / `wl-paste` (`xclip`) on Wayland, `NSPasteboard` on
 +  macOS, and in a page the page's own clipboard events, because a page may read the
 +  clipboard only inside a `paste` event. So the canvas lets ⌘/Ctrl+C, X and V keep their
 +  defaults (`dom::clipboard_key`), and a ⌘/Ctrl+V is HELD from the app until its `paste`
 +  event has handed over the text (then a read answers it) — or, if none comes, until a
 +  zero timer, when a read answers the page's own last copy or paste; a release never
 +  overtakes it. A copy writes through `navigator.clipboard.writeText` where the page has it
 +  (a secure context; checked first, since calling into undefined throws through the wasm
 +  frames), and the `copy` / `cut` event the key raises carries it too, which needs no
 +  secure context. Until then a copy in a page panicked (`std::thread::spawn`).
 +  `scripts/web-probe/clipboard` is the check: copy, paste, copy with `writeText` refused,
 +  and paste with the `paste` event swallowed, each read back from the system clipboard —
 +  all four pass in headless Chromium (2026-10-05). (Five since the IME: a `paste`
 +  swallowed with its default kept pastes into the keyboard sink, an `input` of type
 +  `insertFromPaste`, which is the paste too.)
 +- **The keyboard is a hidden `<textarea>`'s** (the keyboard sink, since 2026-10-05), not
 +  the canvas's: a page composes input-method text only into an editable element. It takes
 +  the focus a press on the canvas gave the canvas (a canvas focused another way hands it
 +  over, and a blur over to the canvas is not a focus loss); its keys are the app's as the
 +  canvas's were, except one the input method takes (`isComposing`, keyCode 229); its
 +  `input` events while composing are the composition (`Driver::preedit`, the cursor from
 +  its selection via `dom::utf16_range_to_bytes`), `compositionend` the commit, and text
 +  with no composition (an emoji panel, dictation) a commit as it comes. After each frame
 +  it is moved to the editing widget's caret (`ime::caret`), where the candidates open; a
 +  composition a widget dropped (`ime::take_reset`) is cancelled by blurring and refocusing
 +  it INSIDE the turn, where the events that raises reach no handler.
 +  `scripts/web-probe/ime` is the check, through Chromium's own IME path (CDP
 +  `Input.imeSetComposition` / `insertText`): a composition shown with the sink at its
 +  caret, the commit replacing it, a cancelled one leaving the box, a no-composition
 +  insert, and plain keys — all six pass (2026-10-05), and the 24-step demo replay is
 +  identical to the pixel to the run before the sink through step 18.
 +
 +Not there yet: drag and drop, file dialogs, and an app whose
 +text is not the display list's (`display_list_text` false — it stages its own through the
 +native-only `stage_renderer`, so draws no text here).
 +
 +**Compute jobs run in the browser too** (`web::ComputeDevice`, since 2026-10-05). What a job
 +IS moved out of `vk` into the portable `crate::compute` — `Kernel`, `Binding`, `BindKind`,
 +`workgroups`, `MAX_BINDINGS`, and the rules a job is held to before any device sees it
 +(`check_job`, the ping-pong `slot_for` / `result_slot`, `parse_kernel`: naga's WGSL
 +frontend, now a dependency on every target, validates a kernel and reads its
 +`@workgroup_size` — WebGPU can report neither) — and `vk::compute` re-exports every one at
 +its old path. The browser device takes the same jobs and answers them the same way, with
 +one difference the platform makes: readback is a promise, so its `run`, `run_over`,
 +`run_passes`, `run_passes_over` and `workgroup_size` are `async`. Two things WebGPU does
 +differently underneath: its layouts tell read-only storage from read-write (the module
 +says which, `ParsedKernel::read_only_storage`), and a device starts at the spec's default
 +of eight storage buffers a stage, so it asks for the adapter's own (SwiftShader offers
 +ten; a job past the adapter's ceiling is an `Err` naming the limit). What WebGPU rejects
 +is caught in a validation error scope and returned. `examples/compute_probe/jobs.rs` is
 +the check — a map, a uniform, a 33- and a 34-pass ping-pong, a 2D dispatch, ten bindings,
 +a bad kernel and a missing entry, each exact against a CPU reference in f32 — run by
 +`compute_native` and by `scripts/web-probe/compute`: the two outputs are identical to the
 +bit (lavapipe vs SwiftShader, 2026-10-05). A reference written for a length that is not
 +a multiple of four floats must know that `arrayLength` counts the 16-byte padding, on
 +both devices.
 +
 +**3D scenes draw in the browser too** (`Stage3D`, since 2026-10-05). What an app stages a
 +scene through is a trait, `draw::scene::Stage3D` (`create_mesh`, `update_mesh`,
 +`stage_scene`, `stage_scene_images`, `set_scene_light`), implemented by `VkRenderer` (each
 +method its inherent one) and `WebRenderer`; the scene's types (`Vertex3D`, `MeshId`,
 +`SceneDraw`, `SceneImage`), its uniform blocks (`scene_uniforms`), image quads and the
 +wire-base depth bias (`wire_base_bias`) moved to `draw::scene`, and `scene3d.wgsl` /
 +`scene3d_image.wgsl` to `draw/`, shared by both renderers; `vk` and `engine` re-export them.
 +Two portable `Application` hooks take a `&mut dyn Stage3D`: **`init_3d`** (once per
 +renderer — make meshes) and **`stage_3d`** (every frame, just before the draw; true asks
 +for another frame). Natively `renderer_init` and `stage_renderer` forward to them by
 +default, as `new` forwards to `create`, so an app that overrides the native hooks (the
 +designer) is untouched, and one that moves to the portable pair runs on both shells. The
 +WebGPU pass (`web/scene.rs`) is the Vulkan `SceneStage`'s port: a full-size backdrop in the
 +canvas's sRGB view format with a depth32 buffer, copied into the canvas under a UI pass
 +that LOADS it and samples it for blur — and kept, as on Vulkan, until the next staged
 +scene. Depth bias is pipeline state in WebGPU, and a WebGPU line is one pixel (no
 +wideLines), so the biased fill is a pipeline of its own at `wire_base_bias(1.0)` — what a
 +Vulkan device without wideLines uses. `scene3d.wgsl` takes its derivatives at the top of
 +`fs_main` (WebGPU rejects one under a branch on a varying); natively pixel-identical.
 +`examples/probe3d/scene.rs` is the check, on the portable hooks — background quad, flat and
 +prelit fills, a wire-carrying fill and its wires, a see-through fill and its edges, an
 +image in the scene before the translucent draw, a host light, frost over the pane — run by
 +`probe3d_native` and `scripts/web-probe/probe3d`: 2026-10-05, lavapipe vs SwiftShader,
 +195 px differ by more than 8 levels, all on 1 px wires (where along its length a line
 +steps a row is the rasterizer's), everything else within 2.
 +
 +**And so does the path tracer** (since 2026-10-05). `Stage3D` carries the tracer's half
 +too — `set_rt_scene` / `set_rt_scene_with_image`, `set_rt_environment`,
 +`set_rt_background`, `stage_rt`, `rt_accumulating` — and what it traces from moved to
 +`draw::rt`: the schema (`RtTriangle`, `RtMaterial`, `RtImage`, `RtCamera`,
 +`RtEnvironment`), the binned-SAH BVH and its tests, the buffers (`pack_scene`) and
 +parameter blocks (`rt_params`, `denoise_params`) as the shaders read them, and the
 +constants; the shaders (`rt_common` / `rt_bvh` / `rt_query` / `rt_denoise`) moved to `draw/`.
 +`vk::rt` re-exports the schema and keeps its own state (frames in flight, the ray-query
 +tier, `RtOffscreen`) — it now packs and lays out through `draw::rt`, verified by its
 +GPU tests (`cargo test --lib rt -- --ignored`, lavapipe). `web/rt.rs` is the compute tier on
 +WebGPU: the same shaders and packing, the same rules for restarting the accumulation, one
 +sample a frame plus the three à-trous iterations in one compute pass. Two differences:
 +WebGPU has no ray tracing, so there is no ray-query tier (the compute tier is what every
 +Vulkan device without RT cores runs too); and Vulkan BLITS the tracer's `rgba8unorm`
 +image into the sRGB backdrop, converting as it copies, which WebGPU's copies cannot — a
 +small render pass loads each texel and writes it through the backdrop's sRGB view, the
 +same conversion. The probe's traced mode (`Probe3d<true>`, `PROBE3D_TRACE=1` natively,
 +`scripts/web-probe/probe3d <out> traced`) stages exactly eight frames and stops, so both
 +are compared at eight samples: 2026-10-05, Vulkan compute tier (`CCE_VK_RT=compute`) on
 +lavapipe vs SwiftShader, the traced pane's mean differs by 0.10 of a level, every pixel
 +within 8, 47 channels in the frame past 8 — a few paths that diverged.
 +
 +**The reference app runs on both, through one input script.** `examples/demo_web.rs` is
 +`src/main.rs`'s `DemoApp` (included by `#[path]`, hence `pub(crate)`) in a page;
 +`scripts/web-probe/demo <dir>` builds it, serves it with the machine's fonts and replays the
 +native harness's 24 steps (`drive.mjs`: moves, clicks, a drag, a wheel, typing, undo, the
 +menu, Tab, held keys, the CSD bands), one captured frame per step, which `compare.py` diffs
 +against the native run's screenshots (`--mask` the cursor's box; never sway's
 +`hide_cursor`, which clears pointer focus, so the native app drops its hover). Measured
 +2026-10-05 against lavapipe: steps 00–18 differ only at the slider band's two pointed tips,
 +1 px of rasterizer tie-break (≤ 63 channels beyond 8 levels; everything else within 2),
 +with `DEMO_FAMILIES=FreeSerif,FreeSans,FreeMono` — what native's fontdb made of this
 +machine's fontconfig, not `fc-match`'s DejaVu. Steps 19–20 hold a key, and the driver
 +repeats once per turn: SwiftShader takes ~250 ms a frame, so the page gets fewer repeats
 +than native in the same 1.5 s. Timing, not routing.
 +
 +**The renderer probe holds the two renderers to each other.** `examples/probe/scene.rs`
 +is one 1280x800 frame of nearly every prim — root, pane and frosted plates, every control
 +stance, fields, carves, bevel, sphere, grooves, vector caps, text at four sizes in two
 +families, an image at two sizes. `cargo run --example probe_native` draws it through
 +Vulkan (a Wayland session; screenshot it); `scripts/web-probe/run <out.rgba>` builds
 +`probe_web` for wasm, binds it with wasm-bindgen-cli (the version in Cargo.lock) and draws
 +it in headless Chromium on SwiftShader, reading the frame back from the GPU; and
 +`scripts/web-probe/compare.py native.png out.rgba 1280 800` diffs them. Both halves must
 +have the same fonts — the native one with `CCE_LOAD_SYSTEM_FONTS=1`, the web one handed the
 +DejaVu files (`$PROBE_FONTS_DIR`) — and the screenshot must not carry a cursor (sway:
 +`seat * hide_cursor 200`), which is a difference the diff cannot tell from the renderer's.
 +Lavapipe against SwiftShader, 2026-10-04: 96.8% of channels equal, every other within 2
 +levels but ONE at 3 — rounding at antialiased edges and in the blur, no shading difference.
 +Chromium needs `--use-angle=swiftshader --enable-unsafe-swiftshader
 +--disable-gpu-compositing` beside the WebGPU flags (`browser.mjs`): headless, with GPU
 +compositing it has no shared-image backing for a WebGPU canvas and loses the device on the
 +first present ("A valid external Instance reference no longer exists").
 +
 +**And on a Mac, type-checked only** (`src/mac`, since 2026-10-05). The fourth shell is an
 +AppKit window over the same `Driver`, `Pacer`, `build_frame` and **Vulkan renderer, on
 +Metal through MoltenVK**: `vk::SurfaceTarget` names what a window's `VkSurfaceKHR` is made
 +from — `Wayland { display, surface }` or `Metal { layer }` (a `CAMetalLayer`) —
 +`VkRenderer::try_new_for` / `attach_surface_to` and `VkCore::new_for_surface` /
 +`create_surface` take one, and the Wayland-pointer forms forward to them unchanged. The
 +instance enables VK_EXT_metal_surface when the loader offers it, and on macOS only
 +VK_KHR_portability_enumeration (the loader lists MoltenVK to no instance that does not ask);
 +a device that offers VK_KHR_portability_subset gets it enabled, as the spec requires, and
 +no Linux driver offers it — so a Linux instance and device are the ones they always were.
 +`engine::run::<App>()` is the AppKit shell's `run` on macOS, so a client's `main` does not
 +change. What the shell does, in AppKit's terms (module doc in `src/mac/mod.rs`):
 +
 +- **The window**: transparent, its titlebar transparent over full-size content, so the root
 +  plate fills it with the traffic lights on its corner. AppKit resizes from its own window
 +  edges, so the driver's CSD resize band is the app's (`PressSite::own_edges`, new; false
 +  on the other shells); a press the driver reads as a move drags the window
 +  (`performWindowDragWithEvent:`).
 +- **Events**: a flipped, layer-hosting `NSView` maps mouse, scroll, magnify and keys through
 +  `backend::appkit` (portable, tested on Linux like `dom`): named keys from the hardware key
 +  code (AppKit spells them in private-use characters, and Backspace as DEL), the rest from
 +  `characters`, or with ⌘/Ctrl from `charactersIgnoringModifiers` so ⌘Z is z typing ^Z;
 +  Command reads as `ctrl`, as in a page on a Mac; a Control-click is a right click. AppKit
 +  sends NO keyUp for a ⌘-combination, so the shell releases one as it presses it (else the
 +  driver repeats ⌘Z until focus is lost). Its key repeats are dropped (the driver repeats)
 +  and so is the system's scroll MOMENTUM (the toolkit coasts a flick itself; both would
 +  coast twice). The system has applied natural scrolling to the wheel too, where a Linux
 +  compositor applies it to the trackpad alone, so a wheel notch is turned back to the
 +  wheel's own direction, and each trackpad event's `isDirectionInvertedFromDevice` sets
 +  `input::force_natural_scroll` on the main thread: the system setting rules, not input.kdl's.
 +- **Pacing**: main-queue dispatches (`dispatch2`); an event or any `AppSender::send`, from
 +  any thread (`app::set_wake`, process-wide on macOS, per thread in a page), asks for a turn
 +  at most one ACTIVE frame after the last; between, the pacer's sleep. A superseded turn is
 +  dropped by its generation. Quit (⌘Q) and the close button ask the app to exit as a
 +  compositor's close does; the run loop is stopped once it has.
 +- **Fonts**: the system set is always loaded on macOS (`build_font_system`) — it is what
 +  cosmic-text's macOS fallback list names.
 +- **Clipboard**: the general `NSPasteboard`'s plain-text type, behind the same
 +  `widget::clipboard` pair every widget uses; ⌘C / ⌘X / ⌘V reach the widgets as Ctrl+C /
 +  X / V do on Linux, since Command reads as `ctrl`.
 +- **Input methods**: the view is an `NSTextInputClient`. While a widget is editing text
 +  (`ime::caret` is set) a key press without ⌘ goes through `interpretKeyEvents:` first:
 +  `setMarkedText:` is the composition, `insertText:` the commit — unless it is a plain
 +  key typing its own characters with nothing marked, which is left to the key path so it
 +  keeps its named key and the driver's repeat — and `doCommandBySelector:` leaves the key
 +  to the key path. `firstRectForCharacterRange:` is the caret in screen coordinates. With
 +  nothing editing, keys skip the input method, so one left on does not eat an app's
 +  single-key commands. After each frame a dropped composition is discarded through the
 +  input context (the marked text cleared first, so the `unmarkText` that may call commits
 +  nothing) and a moved caret invalidates the character coordinates.
 +
 +Not there yet: drag and drop, the context menu
 +in a popup window (it is drawn in the window, as on a layer surface), blur behind the window,
 +a menu bar beyond Quit. **None of it has run**: this is Linux, where an Apple target can be
 +type-checked but not linked. `scripts/check-mac` type-checks the library, the demo, every
 +example and the tests for `aarch64-apple-darwin` (`rustup target add aarch64-apple-darwin`);
 +the four examples that still used the legacy `new(qh, …)` moved to `create`, and
 +`plate_probe` / two integration tests reach the tessellator at `backend::tessellate` rather
 +than through `window_runner`. On a Mac, MoltenVK and the Vulkan loader must be installed
 +(the LunarG SDK, or Homebrew's `molten-vk` and `vulkan-loader`); `cargo run` is the test.
 +
 +**Input-method composition is one model for every shell** (`crate::ime`, since
 +2026-10-05). Three things cross between the text widget and the shell's input method:
 +the COMMIT is delivered as typed text (`Driver::commit_text`: a press of a key whose text
 +it is, then its release — never a shortcut, never repeated, past the chords), so every
 +widget that inserts a key's text takes it unchanged (`TextBox`, `LineEdit`, the
 +`DocEditor`, an app's own field); the COMPOSITION (`ime::Preedit`: text and the input
 +method's cursor as a byte range) is shared per thread, set through `Driver::preedit`; and
 +the CARET goes back — a widget editing text reports it as it paints
 +(`ime::report_caret`, in window px with the `PaintCtx` offset), `build_frame` brackets
 +the frame (`begin_frame` / `end_frame`), and `ime::caret()` is where the candidates go and
 +whether text is wanted at all. **`TextBox` shows a composition as a PROVISIONAL run** in
 +`edit_buffer` (`composing`: its char start and length), so wrap, scroll, caret and the
 +glyph advances draw it as typed text, and `selection_quads` underlines it; it is never
 +held (`committed_buffer`, which `take_change` publishes under `update_on_type`), never in
 +the history, and the box takes no key while it composes. It is applied in `prepare_text`
 +and at the top of `handle_key`, against `ime::generation`; a press, or editing ending,
 +drops it and asks the input method to cancel (`ime::request_reset`). A composition begun
 +over a selection replaces it, as typing would. `a_composition_is_shown_in_place_and_the_
 +commit_is_typed` is the test.
 +
 +**`LineEdit` and the `DocEditor` show it too** (since 2026-10-05), each without letting it
 +into what it holds — a host reads `LineEdit::text` directly and saves the `DocEditor`'s
 +buffer, so neither ever contains it. `LineEdit` splices it into `display()` at the caret,
 +and `display_index` / `text_index` map across it (the caret lands where the input method
 +has its cursor, a point inside the composition is the caret, one after it is the text it is
 +drawn after); `composition_range` is the span to underline, a masked field shows bullets.
 +The app, which draws the field, calls `sync_ime` each frame the field has the keyboard,
 +reports the caret it draws (`ime::report_caret` — also what tells the shell text is
 +wanted), and `drop_composition` when the field loses it. The `DocEditor` lays out the
 +caret's line WITH the composition (an active line, raw anyway) and maps every column read
 +off that layout across it (`laid_col` / `source_col`: the caret, `caret_rect`, `pos_at`);
 +it underlines it, and reports its caret itself while painted focused; a host calls
 +`drop_composition` when the editor loses the keyboard. Both take no key while a
 +composition is up, take the commit as typed, and treat a press as dropping the
 +composition (cancelled in the input method) and placing the caret — the `DocEditor`'s read
 +through the line as drawn. `a_composition_is_shown_at_the_caret_and_never_held` and
 +`a_composition_is_laid_out_in_place_and_never_held` are the tests; cce-notes, built against
 +this tree, was driven under the headless sway with the stand-in input method (2026-10-05):
 +the composition underlined at the caret, the commit typed, a second composition left up
 +through the editor's autosave and then dropped by a click — and the note on disk held the
- commit and never the composition. No `LineEdit` host calls the three methods yet
- (cce-browser's URL bar and dialog fields; it cannot be built here, for WPE).
++commit and never the composition. cce-browser's URL bar, bookmarks search and dialog
++fields are the `LineEdit` hosts (its `keyboard_field` / `sync_ime`, lsgalante/cce-browser#1).
 +
 +Not there yet: no shell sends surrounding text, so an input
 +method's `delete_surrounding_text` (text-input-v3) is not applied; and a password box is
 +announced with the normal content purpose.
 +
 +**On Wayland it is `text-input-v3`** (`backend/text_input.rs`, since 2026-10-05), relayed
 +by the compositor to an `input-method-v2` client (fcitx5, IBus's Wayland frontend). The
 +text input is the first keyboard seat's, made with the keyboard. After each render
 +(`EngineState::sync_text_input`) it is ENABLED while the seat's text-input focus is on our
 +surface (`enter`) and a widget is editing (`ime::caret`), with a normal content type and
 +the caret as the cursor rectangle (surface px — the app's logical px times a forced scale,
 +as pointer input is divided), re-sent when the caret moves; DISABLED when nothing is
 +editing; and disabled-then-enabled for a composition a widget dropped (`ime::take_reset`),
 +which resets the input method. Every change is one `commit`, counted (`TextInput::commits`,
 +what a current `done`'s serial is). `preedit_string` / `commit_string` /
 +`delete_surrounding_text` are double-buffered and applied on `done` in the protocol's
 +order (`Batch::apply_order`: the old composition out, the commit typed, the new one in;
 +a batch with no `preedit_string` ends the composition, a cursor of -1 hides it); `leave`
 +drops the composition. The decisions are pure (`TextInput::plan`, `Batch`) and tested
 +with no compositor (`backend::text_input::tests`). Verified end to end under the headless
 +sway with a scriptable `input-method-v2` client standing in for fcitx5 (2026-10-05): no
 +activation until a box is clicked into; a composition shown underlined at the caret; the
 +commit replacing it; a cancel; a press mid-composition dropping it with a
 +disable-and-enable; Escape disabling — and under `WAYLAND_DEBUG` the cursor rectangle
 +following the caret through every step, each `done`'s serial equal to the commits sent.
 +Sway routes text-input focus only while an input method is bound, so with none (the
 +24-step harness) nothing changes: 0 px.
 +
+ CI (`.github/workflows/ci.yml`, every push and PR) builds and tests on Ubuntu 24.04 with
+ default and with all features, warnings as errors. It installs `libwayland-dev` and
+ `libxkbcommon-dev` (the two native libraries the build links, through pkg-config) and Mesa's lavapipe, a software Vulkan device,
+ so the GPU tests (`vk::compute`, `vk::plate_probe`) RUN there rather than skip — and a
+ last step fails the job if they printed a skip note, since a skipped test passes. To match
+ it locally: `apt install libwayland-dev libxkbcommon-dev mesa-vulkan-drivers`, then
+ `RUSTFLAGS="-D warnings" cargo test --all-features`.
+ 
  Wayland protocol bindings are generated **inline at compile time** by `wayland-scanner` macros in
  `src/protocol.rs` from `protocol/*.xml` (`cce-inspector-v1`, `cce-window-management-v1`) — there is
  no `build.rs` and no codegen step to run.
@@@ -1853,6 -1454,36 +1861,37 @@@ deliberately left alone: a slider's an
  over them, which changes a VALUE after the hand has stopped.
  `the_animations_switch_stops_the_glide_and_not_the_coast` is the test.
  
+ ### A finger scrolls (touchscreens, since 2026-10-05)
+ 
+ The runner binds `wl_touch` when the seat offers it, and `backend/touch.rs`
+ turns the first finger into pointer input by what it does: a **tap** clicks
+ where it landed, a finger that **moves** past `SLOP` (10 px) scrolls — a
+ `PixelDelta` equal to the finger's travel, `ScrollPhase::Finger`, dispatched
+ at the down point, then `FingerEnd` at the lift so a flick coasts through
+ `ScrollMotion` like a trackpad's — and a finger **held** `HOLD_MS` (400 ms)
+ before moving is a held left button (a slider thumb, a text selection, a
+ scrollbar). The hold needs no timer: nothing is sent while the finger rests
+ inside the slop, so the choice is made at the first motion past it. Other
+ fingers are ignored until the first lifts. `TouchTracker` is the pure state
 -machine (tested in that file); the `TouchHandler` impl and the dispatch are
 -beside it, so `window_runner.rs` carries only the fields and the capability
 -hook.
++machine (tested in that file, and portable); what its actions do is the
++driver's (`Driver::touch`, routing like every other input), and only the
++`TouchHandler` impl beside the tracker is Wayland's, so `window_runner.rs`
++carries only the fields and the capability hook.
+ 
+ A finger is always natural — the content goes where it is pushed — so the
+ dispatch runs inside `input::with_natural_scroll(true, …)` and a value
+ control's `value_notches_y` reads the finger's real direction whatever the
+ trackpad's setting. No per-app trackpad factor either: 1:1 keeps the content
+ under the finger. Not by finger: CSD moves/resizes and
+ `Application::take_window_action`, since the compositor checks those serials
+ against a pointer grab; the runner drains a queued action after a touch so it
+ cannot fire on the next pointer press. Binding `wl_touch` is also what moves a
+ cce-ui window off the compositor's emulated-pointer route
+ (`cce-compositor`'s `cursor::TouchRoute`), where a finger drag was a held
+ button and selected rather than scrolled. `CCE_SCROLL_DEBUG=1` logs each
+ touch scroll (`[scroll] touch: …`); in a shadow, `ccectl touch down|motion|up`
+ drives it.
+ 
  ### A host may name the phase; a test may pin the settings (2026-09-30)
  
  The phase a wheel event belongs to (`Finger`, `FingerEnd`, `Wheel`) is a
diff --cc src/backend/driver.rs
index 320d0ff,0000000..bda611f
mode 100644,000000..100644
--- a/src/backend/driver.rs
+++ b/src/backend/driver.rs
@@@ -1,1031 -1,0 +1,1154 @@@
 +//! The runner's platform-neutral half: what happens to input once a shell has
 +//! it in cce-ui's own terms. A shell (the Wayland runner today) translates its
 +//! window system's events — evdev buttons, xkb keysyms, `wl_pointer` axis
 +//! frames — into the calls here, and carries out what they ask back of the
 +//! window (a cursor, an interactive move or resize). Everything between —
 +//! modifier tracking, the undo/redo and plate-navigation chords, key repeat,
 +//! the CSD hit zones, the outside-press popover close, held-button release on
 +//! a lost pointer, the scroll phase, the pinch fallback — lives here once, so
 +//! a second shell routes exactly as the first does.
 +//!
 +//! The app is never owned here: each call takes a [`Turn`], the app plus the
 +//! runner's `redraw` / `exit` flags, borrowed from wherever the shell keeps
 +//! them (the Wayland runner keeps them on `EngineState`, where clients'
 +//! `register_sources` callbacks reach `inner` and `redraw` directly).
 +
 +use web_time::Instant;
 +
 +use super::app::{Application, LogicalPosition, LogicalSize};
 +use cursor_icon::CursorIcon;
- use crate::widget::{ElementState, Key, KeyEvent, MouseButton, MouseScrollDelta, NamedKey, Position};
++use crate::widget::{ElementState, Key, KeyEvent, MouseButton, MouseScrollDelta, NamedKey, Position, ScrollPhase};
 +
 +/// A key held down, for the runner's own key repeat.
 +pub struct PressedKey {
 +    pub logical_key: Key,
 +    pub text: Option<String>,
 +    pub first_pressed: Instant,
 +    pub last_repeated: Instant,
 +}
 +
 +/// Held this long before the first repeat…
 +pub const KEY_REPEAT_DELAY: std::time::Duration = std::time::Duration::from_millis(500);
 +/// …then one repeat per this.
 +pub const KEY_REPEAT_INTERVAL: std::time::Duration = std::time::Duration::from_millis(50);
 +
 +fn is_repeatable_key(key: &Key) -> bool {
 +    match key {
 +        Key::Named(NamedKey::Backspace) |
 +        Key::Named(NamedKey::Delete) |
 +        Key::Named(NamedKey::ArrowLeft) |
 +        Key::Named(NamedKey::ArrowRight) |
 +        Key::Named(NamedKey::ArrowUp) |
 +        Key::Named(NamedKey::ArrowDown) |
 +        Key::Named(NamedKey::Home) |
 +        Key::Named(NamedKey::End) |
 +        Key::Character(_) => true,
 +        _ => false,
 +    }
 +}
 +
 +/// The modifier keys as the keyboard last reported them.
 +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
 +pub struct Modifiers {
 +    pub ctrl: bool,
 +    pub shift: bool,
 +    pub alt: bool,
 +    pub logo: bool,
 +}
 +
 +/// A window edge an interactive resize grabs: the window system's own enum,
 +/// spelled without it. The Wayland shell maps it onto `xdg_toplevel`'s.
 +#[derive(Debug, Clone, Copy, PartialEq, Eq)]
 +pub enum ResizeEdge {
 +    Top,
 +    Bottom,
 +    Left,
 +    Right,
 +    TopLeft,
 +    TopRight,
 +    BottomLeft,
 +    BottomRight,
 +}
 +
 +/// What a press turned out to be: the app's, or a grab of the window itself
 +/// that the shell carries out (and the app never hears about).
 +#[derive(Debug, Clone, Copy, PartialEq, Eq)]
 +pub enum Press {
 +    Dispatched,
 +    Resize(ResizeEdge),
 +    Move,
 +}
 +
 +/// Where a press landed, in the window's terms.
 +#[derive(Debug, Clone, Copy)]
 +pub struct PressSite {
 +    /// The surface's logical size: the CSD borders are measured from it.
 +    pub size: LogicalSize,
 +    /// The press came through the context menu's popup surface: it is the
 +    /// menu's, never a border or a movable plate of the window.
 +    pub on_popup: bool,
 +    /// The shell can start an interactive move / resize right now (a window
 +    /// and a seat to grab with). When it cannot, a press that would have been
 +    /// a grab is the app's instead — which is what the Wayland runner did on
 +    /// a layer surface, and what a shell with no grabs at all always does.
 +    pub can_grab: bool,
 +    /// The window system resizes the window from edges of its own (AppKit's
 +    /// window frame), and offers no way to start a resize from a press: the
 +    /// CSD resize band is then no grab, and a press there goes on to the
 +    /// move checks and the app.
 +    pub own_edges: bool,
 +}
 +
 +/// Where a scroll came from, as far as the phase cares.
 +#[derive(Debug, Clone, Copy, PartialEq, Eq)]
 +pub enum ScrollSource {
 +    Wheel,
 +    Finger,
 +    Continuous,
 +    WheelTilt,
 +}
 +
 +/// One frame of scroll input, coalesced: the shell sums a frame's axis events
 +/// (in its own window system's units — `wl_pointer` axis values here) and
 +/// hands the total over once.
 +#[derive(Debug, Clone, Copy, Default)]
 +pub struct ScrollFrame {
 +    pub h: f64,
 +    pub v: f64,
 +    pub discrete_h: i32,
 +    pub discrete_v: i32,
 +    /// The frame's source, if it named one.
 +    pub source: Option<ScrollSource>,
 +    /// A finger lifted (an axis stop) in this frame.
 +    pub stop: bool,
 +}
 +
 +/// The app and the runner's two flags, for the length of one dispatch.
 +pub struct Turn<'a, A: Application> {
 +    pub app: &'a mut A,
 +    pub redraw: &'a mut bool,
 +    pub exit: &'a mut bool,
 +}
 +
 +impl<A: Application> Turn<'_, A> {
 +    /// Hand a message from an input handler to `update`, and fold the two
 +    /// rebuild requests into the redraw flag — the shape every dispatch had.
 +    fn deliver(&mut self, msg: Option<A::Message>, mut rebuild: bool) {
 +        if let Some(msg) = msg {
 +            let mut update_rebuild = false;
 +            self.app.update(msg, &mut update_rebuild, self.exit);
 +            if update_rebuild {
 +                rebuild = true;
 +            }
 +        }
 +        if rebuild {
 +            *self.redraw = true;
 +        }
 +    }
 +}
 +
 +/// The input state a session carries, and the routing over it.
 +pub struct Driver {
 +    pub mods: Modifiers,
 +    pub pressed_key: Option<PressedKey>,
 +    /// The pointer's last position, window-logical (popup events translated).
 +    pub cursor_pos: (f32, f32),
 +    /// Mouse buttons held, as a bitmask (1 Left / 2 Right / 4 Middle). On a
 +    /// lost pointer the real release goes to whatever surface takes the
 +    /// pointer next (fullscreen switches, layout animations), so
 +    /// [`pointer_leave`](Self::pointer_leave) synthesizes releases for the
 +    /// held set — a drag must end, not stay armed and steered by later
 +    /// motion — and only then runs the off-screen hover-clear (which would
 +    /// otherwise corrupt the drag: a ramp key snapped to the graph corner).
 +    pub buttons_down: u32,
 +    pub last_pinch_scale: f32,
 +    /// The `undo` / `redo` chords, resolved from `input.kdl` when the
 +    /// session starts.
 +    pub undo_chord: String,
 +    pub redo_chord: String,
 +    /// `focus_next_group` / `focus_prev_group` (input.kdl, cce-ui domain):
 +    /// the plate-navigation group jump, for apps that opt in.
 +    pub group_next_chord: String,
 +    pub group_prev_chord: String,
++    /// Whether the app is the status bar, asked once (see
++    /// [`is_status_bar`](Self::is_status_bar)).
++    status_bar: std::cell::OnceCell<bool>,
 +}
 +
 +impl Default for Driver {
 +    fn default() -> Self {
 +        Self::new()
 +    }
 +}
 +
 +/// The CSD border's width, in logical px.
 +const CSD_BORDER: f32 = 8.0;
 +
++/// Close the open popovers a left press at (lx, ly) misses, BEFORE the app's
++/// dispatch: apps commonly region-gate their routing, so an open menu's owner
++/// may never hear about a press elsewhere.
++fn close_popovers_missed_by<A: Application>(app: &mut A, lx: f32, ly: f32) {
++    let offsets: Vec<_> = app
++        .ui_context()
++        .map(|ctx| ctx.popover_owners())
++        .unwrap_or_default()
++        .into_iter()
++        .map(|id| (id, app.popover_offset(id)))
++        .collect();
++    if let Some(ctx) = app.ui_context_mut() {
++        ctx.close_popovers_missed_by_press_with(lx, ly, |id| {
++            offsets.iter().find(|(o, _)| *o == id).map_or((0.0, 0.0), |&(_, d)| d)
++        });
++    }
++}
++
 +fn button_bit(btn: MouseButton) -> u32 {
 +    match btn {
 +        MouseButton::Left => 1,
 +        MouseButton::Right => 2,
 +        _ => 4,
 +    }
 +}
 +
 +impl Driver {
 +    pub fn new() -> Self {
 +        Self {
 +            mods: Modifiers::default(),
 +            pressed_key: None,
 +            cursor_pos: (0.0, 0.0),
 +            buttons_down: 0,
 +            last_pinch_scale: 1.0,
 +            undo_chord: crate::input::app_chord("undo", "ctrl+z"),
 +            redo_chord: crate::input::app_chord("redo", "ctrl+shift+z"),
 +            group_next_chord: crate::input::app_chord("focus_next_group", "ctrl+tab"),
 +            group_prev_chord: crate::input::app_chord("focus_prev_group", "ctrl+shift+tab"),
++            status_bar: std::cell::OnceCell::new(),
 +        }
 +    }
 +
++    /// The app is the status bar (`app_id` `cce-status…`): no CSD move,
++    /// resize or resize cursors. Read from `settings()` once per session —
++    /// the checks it serves run on every pointer motion and press, and
++    /// `settings()` builds two `String`s each call.
++    fn is_status_bar<A: Application>(&self, app: &A) -> bool {
++        *self.status_bar.get_or_init(|| app.settings().app_id.starts_with("cce-status"))
++    }
++
 +    /// Copy the modifiers into the app's widget context, where widgets read them.
 +    fn sync_mods<A: Application>(&self, app: &mut A) {
 +        if let Some(ctx) = app.ui_context_mut() {
 +            ctx.ctrl_pressed = self.mods.ctrl;
 +            ctx.shift_pressed = self.mods.shift;
 +            ctx.alt_pressed = self.mods.alt;
 +            ctx.logo_pressed = self.mods.logo;
 +        }
 +    }
 +
 +    /// The cursor for the pointer at (lx, ly): the app's
 +    /// [`Application::cursor_icon`] override, else the standard-CSD edge
 +    /// cursors (status bars and non-standard-CSD apps fall back to Default).
 +    pub fn cursor_icon_at<A: Application>(&self, app: &A, lx: f32, ly: f32, size: LogicalSize) -> CursorIcon {
 +        // Over the context menu the pointer is the menu's,
 +        // whatever of the app lies at that place under it (a splitter, a
 +        // resize border) — and in their popups that place may be outside
 +        // the window altogether.
 +        if crate::widget::context_menu::is_visible() && crate::widget::context_menu::hit_test(lx, ly) {
 +            return CursorIcon::Default;
 +        }
 +        if let Some(icon) = app.cursor_icon(lx, ly) {
 +            return icon;
 +        }
-         if app.settings().app_id.starts_with("cce-status")
++        if self.is_status_bar(app)
 +            || !app.standard_csd()
 +            || !app.csd_resize_borders()
 +        {
 +            return CursorIcon::Default;
 +        }
 +        match csd_edge(lx, ly, size) {
 +            Some(ResizeEdge::TopLeft) => CursorIcon::NwResize,
 +            Some(ResizeEdge::TopRight) => CursorIcon::NeResize,
 +            Some(ResizeEdge::Top) => CursorIcon::NResize,
 +            Some(ResizeEdge::BottomLeft) => CursorIcon::SwResize,
 +            Some(ResizeEdge::BottomRight) => CursorIcon::SeResize,
 +            Some(ResizeEdge::Bottom) => CursorIcon::SResize,
 +            Some(ResizeEdge::Left) => CursorIcon::WResize,
 +            Some(ResizeEdge::Right) => CursorIcon::EResize,
 +            None => CursorIcon::Default,
 +        }
 +    }
 +
 +    /// The pointer entered at `pos`. Enter carries the pointer's position but
 +    /// no motion follows until it actually moves — without this the app's
 +    /// hover state is stale from enter to first move, and a press in that
 +    /// window can misroute (e.g. a divider press falling through to the
 +    /// movable-root plate window drag).
 +    pub fn pointer_enter<A: Application>(&mut self, t: Turn<'_, A>, pos: LogicalPosition) {
 +        self.pointer_motion(t, pos);
 +    }
 +
 +    /// The pointer moved to `pos`.
 +    pub fn pointer_motion<A: Application>(&mut self, t: Turn<'_, A>, pos: LogicalPosition) {
 +        let mut rebuild = false;
 +        t.app.handle_pointer_move(pos, &mut rebuild);
 +        if rebuild {
 +            *t.redraw = true;
 +        }
 +    }
 +
 +    /// The pointer left the window. Focus can move mid-gesture (a fullscreen
 +    /// switch, a relayout sliding the window away): the real release then
 +    /// lands on another surface, and an armed drag would live forever. End
 +    /// held gestures with synthetic releases at the last known position
 +    /// first; then clear hover with an off-screen move — safe now that no
 +    /// drag is held.
 +    pub fn pointer_leave<A: Application>(&mut self, mut t: Turn<'_, A>) {
 +        if self.buttons_down != 0 {
 +            let (px, py) = self.cursor_pos;
 +            for btn in [MouseButton::Left, MouseButton::Right, MouseButton::Middle] {
 +                if self.buttons_down & button_bit(btn) == 0 {
 +                    continue;
 +                }
 +                let mut rebuild = false;
 +                let msg = t.app.handle_mouse_input(
 +                    btn,
 +                    ElementState::Released,
 +                    LogicalPosition::new(px, py),
 +                    &mut rebuild,
 +                );
 +                t.deliver(msg, rebuild);
 +            }
 +            self.buttons_down = 0;
 +        }
 +        let mut rebuild = false;
 +        t.app.handle_pointer_move(LogicalPosition::new(-10000.0, -10000.0), &mut rebuild);
 +        if rebuild {
 +            *t.redraw = true;
 +        }
 +    }
 +
 +    /// A button went down at `pos`. Returns a grab for the shell to start
 +    /// when the press is the window's (a CSD border, the titlebar band, a
 +    /// movable root plate); otherwise the press has been dispatched.
 +    pub fn pointer_press<A: Application>(
 +        &mut self,
 +        mut t: Turn<'_, A>,
 +        btn: MouseButton,
 +        pos: LogicalPosition,
 +        site: PressSite,
 +    ) -> Press {
 +        self.buttons_down |= button_bit(btn);
 +        let (lx, ly) = (pos.x, pos.y);
 +
 +        // Client-side decorations: drag and resize. Never on the menu popup:
 +        // its presses are the menu's, and its coordinates, translated into the
 +        // window's, would otherwise read as a resize border or a movable plate.
 +        if site.can_grab
 +            && btn == MouseButton::Left
 +            && !site.on_popup
-             && !t.app.settings().app_id.starts_with("cce-status")
++            && !self.is_status_bar(t.app)
 +            && t.app.standard_csd()
 +        {
 +            // Resize borders off: the compositor's own band outside the
 +            // window handles it; the move checks still run, so drag-to-move
 +            // still works.
 +            if t.app.csd_resize_borders() && !site.own_edges {
 +                if let Some(edge) = csd_edge(lx, ly, site.size) {
 +                    return Press::Resize(edge);
 +                }
 +            }
 +            // The titlebar band: y in [8, 32), clear of the top-right buttons.
 +            let is_widget = t.app.ui_context().is_some_and(|ctx| ctx.is_widget_at(lx, ly));
 +            if (!is_widget
 +                && t.app.csd_titlebar_move()
 +                && ly >= CSD_BORDER
 +                && ly < 32.0
 +                && lx < site.size.width - 70.0)
 +                || t.app.is_movable_root_plate_at(lx, ly)
 +            {
 +                return Press::Move;
 +            }
 +        }
 +
 +        // Outside-press close for open popovers, BEFORE the app's dispatch:
 +        // apps commonly region-gate their routing, so an open menu's owner may
 +        // never hear about a press elsewhere.
 +        if btn == MouseButton::Left {
-             let app = &mut *t.app;
-             let offsets: Vec<_> = app
-                 .ui_context()
-                 .map(|ctx| ctx.popover_owners())
-                 .unwrap_or_default()
-                 .into_iter()
-                 .map(|id| (id, app.popover_offset(id)))
-                 .collect();
-             if let Some(ctx) = app.ui_context_mut() {
-                 ctx.close_popovers_missed_by_press_with(lx, ly, |id| {
-                     offsets.iter().find(|(o, _)| *o == id).map_or((0.0, 0.0), |&(_, d)| d)
-                 });
-             }
++            close_popovers_missed_by(t.app, lx, ly);
 +        }
 +
 +        let mut rebuild = false;
 +        let msg = t.app.handle_mouse_input(btn, ElementState::Pressed, pos, &mut rebuild);
 +        t.deliver(msg, rebuild);
 +        Press::Dispatched
 +    }
 +
 +    /// A button came up at `pos`.
 +    pub fn pointer_release<A: Application>(&mut self, mut t: Turn<'_, A>, btn: MouseButton, pos: LogicalPosition) {
 +        self.buttons_down &= !button_bit(btn);
 +        let mut rebuild = false;
 +        let msg = t.app.handle_mouse_input(btn, ElementState::Released, pos, &mut rebuild);
 +        t.deliver(msg, rebuild);
 +    }
 +
 +    /// One coalesced frame of scrolling at `pos`. Publishes the frame's
 +    /// phase (`scroll_motion::set_scroll_phase`) before the app sees it.
 +    pub fn scroll<A: Application>(&mut self, t: Turn<'_, A>, frame: ScrollFrame, pos: LogicalPosition) {
 +        let ScrollFrame { h, v, discrete_h, discrete_v, source, stop } = frame;
 +        // Per-app scroll factors from input.kdl (`<app>`/`cce-ui` domain
 +        // `input { }` blocks); the compositor's global device scaling has
 +        // already been applied at the source.
 +        let factors = crate::input::scroll_factors();
 +        let (phase, delta) = scroll_delta(&frame, factors);
 +        crate::widget::scroll_motion::set_scroll_phase(phase);
 +        if crate::scroll_debug() {
 +            static T0: std::sync::OnceLock<Instant> = std::sync::OnceLock::new();
 +            let ms = T0.get_or_init(Instant::now).elapsed().as_millis();
 +            eprintln!(
 +                "[scroll {ms}ms] runner: coalesced=({h:.2},{v:.2}) discrete=({discrete_h},{discrete_v}) source={source:?} stop={stop} phase={phase:?} factors=(tp {:.2}, m {:.2}) -> {delta:?} at ({:.0},{:.0})",
 +                factors.trackpad, factors.mouse, pos.x, pos.y
 +            );
 +        }
 +        let mut rebuild = false;
 +        self.sync_mods(t.app);
 +        t.app.handle_mouse_wheel(&delta, pos, &mut rebuild);
 +        if rebuild {
 +            *t.redraw = true;
 +        }
 +    }
 +
++    /// What a touchscreen finger did, as pointer input (the shell's
++    /// [`TouchTracker`](super::touch::TouchTracker) decides which): a hover
++    /// or drag moves the pointer, a press and release are the left button's,
++    /// and a scroll is a trackpad finger scroll dispatched at `scroll_at`,
++    /// where the finger went down — the gesture belongs to what it began on,
++    /// however far the content moves.
++    ///
++    /// Never a CSD move or resize, and never an app's window action: a touch
++    /// serial does not satisfy the compositor's pointer-grab check, and an
++    /// action left queued would run on the next pointer press with that
++    /// press's serial, so one a touch queued is dropped.
++    pub fn touch<A: Application>(
++        &mut self,
++        mut t: Turn<'_, A>,
++        actions: Vec<super::touch::TouchAction>,
++        scroll_at: Option<(f32, f32)>,
++    ) {
++        use super::touch::TouchAction;
++        for action in actions {
++            let mut rebuild = false;
++            let mut msg = None;
++            match action {
++                TouchAction::Hover(x, y) | TouchAction::Drag(x, y) => {
++                    t.app.handle_pointer_move(LogicalPosition::new(x, y), &mut rebuild);
++                }
++                TouchAction::Leave => {
++                    t.app.handle_pointer_move(LogicalPosition::new(-10000.0, -10000.0), &mut rebuild);
++                }
++                TouchAction::Press(x, y) => {
++                    // Outside-press close for open popovers, as the pointer's
++                    // press does before the app's own dispatch.
++                    close_popovers_missed_by(t.app, x, y);
++                    let pos = LogicalPosition::new(x, y);
++                    msg = t.app.handle_mouse_input(MouseButton::Left, ElementState::Pressed, pos, &mut rebuild);
++                }
++                TouchAction::Release(x, y) => {
++                    let pos = LogicalPosition::new(x, y);
++                    msg = t.app.handle_mouse_input(MouseButton::Left, ElementState::Released, pos, &mut rebuild);
++                }
++                TouchAction::Scroll(dx, dy) => self.touch_scroll(t.app, scroll_at, ScrollPhase::Finger, dx, dy, &mut rebuild),
++                TouchAction::ScrollEnd => {
++                    self.touch_scroll(t.app, scroll_at, ScrollPhase::FingerEnd, 0.0, 0.0, &mut rebuild)
++                }
++            }
++            t.deliver(msg, rebuild);
++        }
++        let _ = t.app.take_window_action();
++    }
++
++    /// Finger travel as a trackpad pixel scroll. A finger is always
++    /// "natural" — the content goes where it is pushed — so the dispatch runs
++    /// with natural scrolling on whatever the trackpad's setting, and a value
++    /// control (`MouseScrollDelta::value_notches_y`) reads the finger's real
++    /// direction. 1:1, without the trackpad's per-app factor: the content
++    /// stays under the finger.
++    fn touch_scroll<A: Application>(
++        &self,
++        app: &mut A,
++        scroll_at: Option<(f32, f32)>,
++        phase: ScrollPhase,
++        dx: f32,
++        dy: f32,
++        rebuild: &mut bool,
++    ) {
++        let Some((x, y)) = scroll_at else { return };
++        crate::widget::scroll_motion::set_scroll_phase(phase);
++        let delta = MouseScrollDelta::PixelDelta(Position { x: dx as f64, y: dy as f64 });
++        if crate::scroll_debug() {
++            eprintln!("[scroll] touch: phase={phase:?} -> {delta:?} at ({x:.0},{y:.0})");
++        }
++        self.sync_mods(app);
++        crate::input::with_natural_scroll(true, || {
++            app.handle_mouse_wheel(&delta, LogicalPosition::new(x, y), rebuild);
++        });
++    }
++
 +    /// A pinch gesture began.
 +    pub fn pinch_begin(&mut self) {
 +        self.last_pinch_scale = 1.0;
 +    }
 +
 +    /// A pinch gesture ended.
 +    pub fn pinch_end(&mut self) {
 +        self.last_pinch_scale = 1.0;
 +    }
 +
 +    /// The pinch's cumulative `scale` moved, with the pointer where it last was.
 +    pub fn pinch_update<A: Application>(&mut self, t: Turn<'_, A>, scale: f32) {
 +        let factor = scale / self.last_pinch_scale;
 +        self.last_pinch_scale = scale;
 +
 +        let (px, py) = self.cursor_pos;
 +        let mut rebuild = false;
 +
 +        // First offer the gesture as-is: apps with true pinch surfaces (the
 +        // designer's 3D viewport) consume it here at 1:1 scale instead of
 +        // through the wheel synthesis below.
 +        if t.app.handle_pinch(factor, LogicalPosition::new(px, py), &mut rebuild) {
 +            if rebuild {
 +                *t.redraw = true;
 +            }
 +            return;
 +        }
 +
 +        // Calculate the y_delta for PixelDelta mapping.
 +        // Since cce-graph interprets factor = 1.0 + y_delta * 0.015, we reverse it:
 +        let y_delta = (factor - 1.0) / 0.015;
 +        let delta = MouseScrollDelta::PixelDelta(Position {
 +            x: 0.0,
 +            y: y_delta as f64,
 +        });
 +
 +        if let Some(ctx) = t.app.ui_context_mut() {
 +            ctx.ctrl_pressed = true; // Force ctrl_pressed = true for the pinch event
 +        }
 +        // A synthesized delta, not a scroll gesture: no glide, no fling.
 +        crate::widget::scroll_motion::set_scroll_phase(crate::widget::ScrollPhase::Wheel);
 +
 +        t.app.handle_mouse_wheel(&delta, LogicalPosition::new(px, py), &mut rebuild);
 +
 +        if let Some(ctx) = t.app.ui_context_mut() {
 +            ctx.ctrl_pressed = self.mods.ctrl; // Restore original state
 +        }
 +
 +        if rebuild {
 +            *t.redraw = true;
 +        }
 +    }
 +
 +    /// The keyboard reported new modifier state.
 +    pub fn set_modifiers<A: Application>(&mut self, app: &mut A, mods: Modifiers) {
 +        self.mods = mods;
 +        self.sync_mods(app);
 +    }
 +
 +    /// The window gained (`true`) or lost keyboard focus. Losing it drops a
 +    /// held key and the held modifiers, whose releases go elsewhere.
 +    pub fn keyboard_focus<A: Application>(&mut self, t: Turn<'_, A>, focused: bool) {
 +        if !focused {
 +            self.pressed_key = None;
 +            self.mods.ctrl = false;
 +            self.mods.shift = false;
 +            self.mods.alt = false;
 +        }
 +        let mut rebuild = false;
 +        t.app.handle_focus_change(focused, &mut rebuild);
 +        if rebuild {
 +            *t.redraw = true;
 +        }
 +    }
 +
 +    /// A key went down or up, already in cce-ui's terms: the shell maps its
 +    /// window system's key (an xkb keysym here) to `logical_key`, and passes
 +    /// the text the key types, if any.
 +    pub fn key<A: Application>(
 +        &mut self,
 +        mut t: Turn<'_, A>,
 +        logical_key: Key,
 +        text: Option<String>,
 +        state: ElementState,
 +    ) {
 +        let event = KeyEvent {
 +            state,
 +            logical_key,
 +            text,
 +            repeat: false,
 +            ctrl: self.mods.ctrl,
 +            shift: self.mods.shift,
 +            alt: self.mods.alt,
 +        };
 +
 +        if state == ElementState::Pressed {
 +            if is_repeatable_key(&event.logical_key) {
 +                self.pressed_key = Some(PressedKey {
 +                    logical_key: event.logical_key.clone(),
 +                    text: event.text.clone(),
 +                    first_pressed: Instant::now(),
 +                    last_repeated: Instant::now(),
 +                });
 +            } else {
 +                self.pressed_key = None;
 +            }
 +        } else if state == ElementState::Released {
 +            if let Some(ref pk) = self.pressed_key {
 +                if pk.logical_key == event.logical_key {
 +                    self.pressed_key = None;
 +                }
 +            }
 +        }
 +
 +        self.sync_mods(t.app);
 +
 +        // Escape dismisses the shared context menu before app dispatch — the
 +        // toolkit-wide default, mirroring the click-outside dismissal. Consumed:
 +        // while a menu is open, Escape means "close it", nothing else.
 +        if state == ElementState::Pressed
 +            && event.logical_key == Key::Named(NamedKey::Escape)
 +            && crate::widget::context_menu::is_visible()
 +        {
 +            crate::widget::context_menu::hide();
 +            *t.redraw = true;
 +            return;
 +        }
 +
 +        let mut rebuild = false;
 +        if self.route_history_chord(t.app, &event, &mut rebuild)
 +            || self.route_plate_navigation(t.app, &event, &mut rebuild)
 +        {
 +            *t.redraw = true;
 +            return;
 +        }
 +        let msg = t.app.handle_key_input(&event, &mut rebuild);
 +        t.deliver(msg, rebuild);
 +    }
 +
 +    /// Text an input method committed. It is delivered as TYPED — a press
 +    /// of a key whose text it is, then that key's release — so a widget
 +    /// that inserts what a key types takes it as it is (see `crate::ime`).
 +    /// Never a shortcut (no Ctrl, no Alt, whatever is held: an input method
 +    /// commits on its own keys), never repeated, and past the chords: a
 +    /// commit of "z" is a "z", not half of an undo.
 +    pub fn commit_text<A: Application>(&mut self, t: Turn<'_, A>, text: String) {
 +        if text.is_empty() {
 +            return;
 +        }
 +        let mut rebuild = false;
 +        for state in [ElementState::Pressed, ElementState::Released] {
 +            let event = KeyEvent {
 +                state,
 +                logical_key: Key::Character(text.clone()),
 +                text: (state == ElementState::Pressed).then(|| text.clone()),
 +                repeat: false,
 +                ctrl: false,
 +                shift: self.mods.shift,
 +                alt: false,
 +            };
 +            let msg = t.app.handle_key_input(&event, &mut rebuild);
 +            if let Some(msg) = msg {
 +                let mut update_rebuild = false;
 +                t.app.update(msg, &mut update_rebuild, t.exit);
 +                rebuild |= update_rebuild;
 +            }
 +        }
 +        *t.redraw |= rebuild;
 +        // The editing widget shows the text it now holds.
 +        *t.redraw = true;
 +    }
 +
 +    /// The input method's composition changed (`None`: it ended without a
 +    /// commit, or the commit follows). The editing widget shows it from the
 +    /// next frame.
 +    pub fn preedit<A: Application>(&mut self, t: Turn<'_, A>, preedit: Option<crate::ime::Preedit>) {
 +        crate::ime::set_preedit(preedit);
 +        *t.redraw = true;
 +    }
 +
 +    /// The runner's key repeat: once a held key has been down
 +    /// [`KEY_REPEAT_DELAY`], deliver it again every [`KEY_REPEAT_INTERVAL`].
 +    /// Called once per loop turn.
 +    pub fn repeat_keys<A: Application>(&mut self, mut t: Turn<'_, A>) {
 +        let Some(ref mut pk) = self.pressed_key else { return };
 +        let now = Instant::now();
 +        if now.duration_since(pk.first_pressed) < KEY_REPEAT_DELAY
 +            || now.duration_since(pk.last_repeated) < KEY_REPEAT_INTERVAL
 +        {
 +            return;
 +        }
 +        pk.last_repeated = now;
 +        let event = KeyEvent {
 +            state: ElementState::Pressed,
 +            logical_key: pk.logical_key.clone(),
 +            text: pk.text.clone(),
 +            repeat: true,
 +            ctrl: self.mods.ctrl,
 +            shift: self.mods.shift,
 +            alt: self.mods.alt,
 +        };
 +
 +        self.sync_mods(t.app);
 +
 +        let mut rebuild = false;
 +        if self.route_history_chord(t.app, &event, &mut rebuild)
 +            || self.route_plate_navigation(t.app, &event, &mut rebuild)
 +        {
 +            *t.redraw = true;
 +        } else {
 +            let msg = t.app.handle_key_input(&event, &mut rebuild);
 +            t.deliver(msg, false);
 +        }
 +        if rebuild {
 +            *t.redraw = true;
 +        }
 +    }
 +
 +    /// Advance the app by `dt` seconds: its own `tick`, then its retained
 +    /// `UiContext`'s (widget tick receivers — e.g. an animating Dropdown
 +    /// popover) — but only when the app's own tick did not already tick the
 +    /// context this turn. Receivers integrate `dt` (scroll glides, slider
 +    /// inertia), so the old "double-ticking is harmless" assumption ran
 +    /// every glide at twice its configured rate in apps that tick the
 +    /// context themselves.
 +    pub fn tick<A: Application>(&mut self, t: Turn<'_, A>, dt: f32) {
 +        let mut rebuild = false;
 +        let roster_ticks_before = t.app.ui_context_mut().map(|ctx| ctx.tick_count());
 +        t.app.tick(dt, &mut rebuild);
 +        if rebuild {
 +            *t.redraw = true;
 +        }
 +        if let Some(ctx) = t.app.ui_context_mut() {
 +            if Some(ctx.tick_count()) == roster_ticks_before && ctx.tick(dt) {
 +                *t.redraw = true;
 +            }
 +        }
 +    }
 +
 +    /// The toolkit's Tab traversal, for apps that opt in
 +    /// (`Application::plate_navigation`): a bare Tab / Shift+Tab press moves
 +    /// keyboard focus to the next / previous plate or well. Returns whether it
 +    /// moved; otherwise the key is dispatched as usual.
 +    fn route_plate_navigation<A: Application>(&self, app: &mut A, event: &KeyEvent, rebuild: &mut bool) -> bool {
 +        if event.state != ElementState::Pressed {
 +            return false;
 +        }
 +        // The group jump first (its chords carry ctrl); then a bare Tab.
 +        let group_next = crate::widget::match_key_shortcut(event, &self.group_next_chord);
 +        let group_prev = !group_next && crate::widget::match_key_shortcut(event, &self.group_prev_chord);
 +        let bare_tab = event.logical_key == Key::Named(NamedKey::Tab)
 +            && !self.mods.ctrl
 +            && !self.mods.alt
 +            && !self.mods.logo;
 +        if !group_next && !group_prev && !bare_tab {
 +            return false;
 +        }
 +        let reverse = if bare_tab { self.mods.shift } else { group_prev };
 +        if !app.plate_navigation() {
 +            return false;
 +        }
 +        let moved = app
 +            .ui_context_mut()
 +            .is_some_and(|ctx| if bare_tab { ctx.focus_step(reverse) } else { ctx.focus_step_group(reverse) });
 +        if moved {
 +            app.focus_stepped();
 +            *rebuild = true;
 +        }
 +        moved
 +    }
 +
 +    /// The toolkit-wide undo/redo routing: a press matching the `undo` /
 +    /// `redo` chord goes to the focused widget first (`ContextAction::Undo`
 +    /// / `Redo` — a text box that is editing steps its own typing), then to
 +    /// the app's `Application::undo` / `redo`. Returns whether either took
 +    /// it; otherwise the key is dispatched as usual, so an app with its own
 +    /// scheme is undisturbed. Runs for repeats too — holding the chord walks
 +    /// the history like holding Backspace walks the text.
 +    fn route_history_chord<A: Application>(&self, app: &mut A, event: &KeyEvent, rebuild: &mut bool) -> bool {
 +        if event.state != ElementState::Pressed {
 +            return false;
 +        }
 +        let undo = crate::widget::match_key_shortcut(event, &self.undo_chord);
 +        let redo = !undo && crate::widget::match_key_shortcut(event, &self.redo_chord);
 +        if !undo && !redo {
 +            return false;
 +        }
 +        let action = if undo { crate::widget::ContextAction::Undo } else { crate::widget::ContextAction::Redo };
 +        if let Some(ctx) = app.ui_context_mut() {
 +            if ctx.focused_context_action(action) {
 +                *rebuild = true;
 +                return true;
 +            }
 +        }
 +        let taken = if undo { app.undo(rebuild) } else { app.redo(rebuild) };
 +        if taken {
 +            *rebuild = true;
 +        }
 +        taken
 +    }
 +}
 +
 +/// A coalesced scroll frame as the delta the app is handed, and the phase it
 +/// belongs to. Smooth-scroll phase: a finger lift is a stop frame (no
 +/// delta); finger/continuous sources track 1:1 and may fling on the lift;
 +/// everything else is a wheel notch that glides.
 +fn scroll_delta(frame: &ScrollFrame, factors: crate::input::ScrollFactors) -> (crate::widget::ScrollPhase, MouseScrollDelta) {
 +    let ScrollFrame { h, v, discrete_h, discrete_v, source, stop } = *frame;
 +    let no_delta = h == 0.0 && v == 0.0 && discrete_h == 0 && discrete_v == 0;
 +    let phase = if stop && no_delta {
 +        crate::widget::ScrollPhase::FingerEnd
 +    } else if discrete_h == 0
 +        && discrete_v == 0
 +        && matches!(source, None | Some(ScrollSource::Finger) | Some(ScrollSource::Continuous))
 +    {
 +        crate::widget::ScrollPhase::Finger
 +    } else {
 +        crate::widget::ScrollPhase::Wheel
 +    };
 +    let delta = if discrete_h == 0 && discrete_v == 0 {
 +        // Pixel scroll event from touchpad / smooth mouse
 +        MouseScrollDelta::PixelDelta(Position {
 +            x: -h * factors.trackpad,
 +            y: -v * factors.trackpad,
 +        })
 +    } else {
 +        // Discrete scroll event (e.g. wheel clicks)
 +        let h_lines = if discrete_h != 0 { discrete_h as f32 } else { h as f32 / 10.0 };
 +        let v_lines = if discrete_v != 0 { discrete_v as f32 } else { v as f32 / 10.0 };
 +        MouseScrollDelta::LineDelta(-h_lines * factors.mouse as f32, -v_lines * factors.mouse as f32)
 +    };
 +    (phase, delta)
 +}
 +
 +/// The CSD resize edge under (lx, ly), if the point is within the border.
 +fn csd_edge(lx: f32, ly: f32, size: LogicalSize) -> Option<ResizeEdge> {
 +    let b = CSD_BORDER;
 +    if ly < b {
 +        Some(if lx < b {
 +            ResizeEdge::TopLeft
 +        } else if lx > size.width - b {
 +            ResizeEdge::TopRight
 +        } else {
 +            ResizeEdge::Top
 +        })
 +    } else if ly > size.height - b {
 +        Some(if lx < b {
 +            ResizeEdge::BottomLeft
 +        } else if lx > size.width - b {
 +            ResizeEdge::BottomRight
 +        } else {
 +            ResizeEdge::Bottom
 +        })
 +    } else if lx < b {
 +        Some(ResizeEdge::Left)
 +    } else if lx > size.width - b {
 +        Some(ResizeEdge::Right)
 +    } else {
 +        None
 +    }
 +}
 +
 +#[cfg(test)]
 +mod tests {
 +    //! The routing, driven with no window system: a mock app records what
 +    //! reaches it.
 +    use super::*;
 +    use crate::backend::app::{AppSender, WindowSettings};
 +
 +    #[derive(Debug, Clone, PartialEq)]
 +    enum Seen {
 +        Move(f32, f32),
 +        Button(MouseButton, ElementState, f32, f32),
 +        Wheel(MouseScrollDelta),
 +        Key(Key, bool),
 +        Focus(bool),
 +        Undo,
 +        Update(u32),
 +    }
 +
 +    struct Mock {
 +        seen: Vec<Seen>,
 +        csd: bool,
 +        takes_undo: bool,
 +        /// A press makes this message, which `update` records.
 +        press_msg: Option<u32>,
 +    }
 +
 +    impl Application for Mock {
 +        type Message = u32;
 +        fn create(_: AppSender<u32>) -> Self {
 +            unreachable!("built directly")
 +        }
 +        fn settings(&self) -> WindowSettings {
 +            WindowSettings {
 +                title: String::new(),
 +                app_id: "mock".into(),
 +                width: 400,
 +                height: 300,
 +                fullscreen: false,
 +                min_size: None,
 +            }
 +        }
 +        fn update(&mut self, msg: u32, needs_rebuild: &mut bool, _exit: &mut bool) {
 +            self.seen.push(Seen::Update(msg));
 +            *needs_rebuild = true;
 +        }
 +        fn tick(&mut self, _dt: f32, _needs_rebuild: &mut bool) {}
 +        fn handle_pointer_move(&mut self, pos: LogicalPosition, _: &mut bool) {
 +            self.seen.push(Seen::Move(pos.x, pos.y));
 +        }
 +        fn handle_mouse_input(
 +            &mut self,
 +            button: MouseButton,
 +            state: ElementState,
 +            pos: LogicalPosition,
 +            _: &mut bool,
 +        ) -> Option<u32> {
 +            self.seen.push(Seen::Button(button, state, pos.x, pos.y));
 +            if state == ElementState::Pressed { self.press_msg } else { None }
 +        }
 +        fn handle_mouse_wheel(&mut self, delta: &MouseScrollDelta, _: LogicalPosition, _: &mut bool) {
 +            self.seen.push(Seen::Wheel(delta.clone()));
 +        }
 +        fn handle_key_input(&mut self, event: &KeyEvent, _: &mut bool) -> Option<u32> {
 +            self.seen.push(Seen::Key(event.logical_key.clone(), event.repeat));
 +            None
 +        }
 +        fn handle_focus_change(&mut self, focused: bool, _: &mut bool) {
 +            self.seen.push(Seen::Focus(focused));
 +        }
 +        fn undo(&mut self, _: &mut bool) -> bool {
 +            self.seen.push(Seen::Undo);
 +            self.takes_undo
 +        }
 +        fn csd_resize_borders(&self) -> bool {
 +            self.csd
 +        }
 +        fn csd_titlebar_move(&self) -> bool {
 +            self.csd
 +        }
 +    }
 +
 +    fn mock() -> Mock {
 +        Mock { seen: Vec::new(), csd: false, takes_undo: false, press_msg: None }
 +    }
 +
 +    /// A driver with known chords, whatever the machine's input.kdl says.
 +    fn driver() -> Driver {
 +        Driver {
 +            undo_chord: "ctrl+z".into(),
 +            redo_chord: "ctrl+shift+z".into(),
 +            group_next_chord: "ctrl+tab".into(),
 +            group_prev_chord: "ctrl+shift+tab".into(),
 +            ..Driver::new()
 +        }
 +    }
 +
 +    struct Flags {
 +        redraw: bool,
 +        exit: bool,
 +    }
 +
 +    fn turn<'a>(app: &'a mut Mock, f: &'a mut Flags) -> Turn<'a, Mock> {
 +        Turn { app, redraw: &mut f.redraw, exit: &mut f.exit }
 +    }
 +
 +    const SIZE: LogicalSize = LogicalSize { width: 400.0, height: 300.0 };
 +
 +    fn site(can_grab: bool) -> PressSite {
 +        PressSite { size: SIZE, on_popup: false, can_grab, own_edges: false }
 +    }
 +
 +    #[test]
 +    fn a_lost_pointer_releases_what_was_held_then_clears_hover() {
 +        let (mut d, mut app, mut f) = (driver(), mock(), Flags { redraw: false, exit: false });
 +        let at = LogicalPosition::new(50.0, 60.0);
 +        d.cursor_pos = (50.0, 60.0);
 +        d.pointer_press(turn(&mut app, &mut f), MouseButton::Left, at, site(false));
 +        d.pointer_press(turn(&mut app, &mut f), MouseButton::Middle, at, site(false));
 +        assert_eq!(d.buttons_down, 1 | 4);
 +        app.seen.clear();
 +
 +        d.pointer_leave(turn(&mut app, &mut f));
 +        assert_eq!(
 +            app.seen,
 +            vec![
 +                Seen::Button(MouseButton::Left, ElementState::Released, 50.0, 60.0),
 +                Seen::Button(MouseButton::Middle, ElementState::Released, 50.0, 60.0),
 +                Seen::Move(-10000.0, -10000.0),
 +            ]
 +        );
 +        assert_eq!(d.buttons_down, 0);
 +    }
 +
 +    #[test]
 +    fn a_press_on_the_border_is_the_windows_only_when_the_shell_can_grab() {
 +        let (mut d, mut app, mut f) = (driver(), mock(), Flags { redraw: false, exit: false });
 +        app.csd = true;
 +        let corner = LogicalPosition::new(2.0, 2.0);
 +        let r = d.pointer_press(turn(&mut app, &mut f), MouseButton::Left, corner, site(true));
 +        assert_eq!(r, Press::Resize(ResizeEdge::TopLeft));
 +        assert!(app.seen.is_empty(), "a grab never reaches the app: {:?}", app.seen);
 +
 +        let band = LogicalPosition::new(100.0, 20.0);
 +        let r = d.pointer_press(turn(&mut app, &mut f), MouseButton::Left, band, site(true));
 +        assert_eq!(r, Press::Move);
 +
 +        // No grab to start (a layer surface, a shell without grabs): the app's.
 +        let r = d.pointer_press(turn(&mut app, &mut f), MouseButton::Left, corner, site(false));
 +        assert_eq!(r, Press::Dispatched);
 +        assert_eq!(app.seen, vec![Seen::Button(MouseButton::Left, ElementState::Pressed, 2.0, 2.0)]);
 +
 +        // A window system that resizes from its own edges: the band is no
 +        // grab, and the press is the app's.
 +        app.seen.clear();
 +        let own = PressSite { own_edges: true, ..site(true) };
 +        assert_eq!(d.pointer_press(turn(&mut app, &mut f), MouseButton::Left, corner, own), Press::Dispatched);
 +        assert_eq!(app.seen, vec![Seen::Button(MouseButton::Left, ElementState::Pressed, 2.0, 2.0)]);
 +        // But the titlebar band still moves the window.
 +        assert_eq!(d.pointer_press(turn(&mut app, &mut f), MouseButton::Left, band, own), Press::Move);
 +
 +        // Nor is a press through the menu popup ever a grab.
 +        app.seen.clear();
 +        let popup = PressSite { on_popup: true, ..site(true) };
 +        assert_eq!(d.pointer_press(turn(&mut app, &mut f), MouseButton::Left, corner, popup), Press::Dispatched);
 +        assert_eq!(app.seen.len(), 1);
 +    }
 +
 +    #[test]
 +    fn a_pressed_message_reaches_update_and_asks_for_a_frame() {
 +        let (mut d, mut app, mut f) = (driver(), mock(), Flags { redraw: false, exit: false });
 +        app.press_msg = Some(7);
 +        d.pointer_press(turn(&mut app, &mut f), MouseButton::Right, LogicalPosition::new(9.0, 9.0), site(true));
 +        assert_eq!(app.seen.last(), Some(&Seen::Update(7)));
 +        assert!(f.redraw);
 +    }
 +
 +    #[test]
 +    fn a_commit_is_typed_text_and_never_a_shortcut_or_a_held_key() {
 +        let (mut d, mut app, mut f) = (driver(), mock(), Flags { redraw: false, exit: false });
 +        app.takes_undo = true;
 +        // Ctrl held as the input method commits "z": typed, not an undo.
 +        d.set_modifiers(&mut app, Modifiers { ctrl: true, ..Modifiers::default() });
 +        d.commit_text(turn(&mut app, &mut f), "z".into());
 +        d.commit_text(turn(&mut app, &mut f), "日本".into());
 +        d.commit_text(turn(&mut app, &mut f), String::new());
 +        let typed = |t: &str| Seen::Key(Key::Character(t.into()), false);
 +        assert_eq!(app.seen, vec![typed("z"), typed("z"), typed("日本"), typed("日本")]);
 +        assert!(d.pressed_key.is_none(), "nothing left held to repeat");
 +        assert!(f.redraw);
 +    }
 +
 +    #[test]
 +    fn the_undo_chord_goes_to_the_app_hook_before_key_dispatch() {
 +        let (mut d, mut app, mut f) = (driver(), mock(), Flags { redraw: false, exit: false });
 +        d.set_modifiers(&mut app, Modifiers { ctrl: true, ..Modifiers::default() });
 +        app.takes_undo = true;
 +        d.key(turn(&mut app, &mut f), Key::Character("z".into()), None, ElementState::Pressed);
 +        assert_eq!(app.seen, vec![Seen::Undo]);
 +        assert!(f.redraw);
 +
 +        // Declined by the app, the key is dispatched as usual.
 +        app.seen.clear();
 +        app.takes_undo = false;
 +        d.key(turn(&mut app, &mut f), Key::Character("z".into()), None, ElementState::Pressed);
 +        assert_eq!(app.seen, vec![Seen::Undo, Seen::Key(Key::Character("z".into()), false)]);
 +    }
 +
 +    #[test]
 +    fn a_held_key_repeats_after_the_delay_and_stops_on_release() {
 +        let (mut d, mut app, mut f) = (driver(), mock(), Flags { redraw: false, exit: false });
 +        let bs = Key::Named(NamedKey::Backspace);
 +        d.key(turn(&mut app, &mut f), bs.clone(), None, ElementState::Pressed);
 +        app.seen.clear();
 +
 +        // Inside the delay: nothing.
 +        d.repeat_keys(turn(&mut app, &mut f));
 +        assert!(app.seen.is_empty());
 +
 +        // Past it: one repeat per turn that is an interval on.
 +        let pk = d.pressed_key.as_mut().unwrap();
 +        pk.first_pressed -= KEY_REPEAT_DELAY;
 +        pk.last_repeated -= KEY_REPEAT_INTERVAL;
 +        d.repeat_keys(turn(&mut app, &mut f));
 +        d.repeat_keys(turn(&mut app, &mut f));
 +        assert_eq!(app.seen, vec![Seen::Key(bs.clone(), true)]);
 +
 +        d.key(turn(&mut app, &mut f), bs, None, ElementState::Released);
 +        assert!(d.pressed_key.is_none());
 +    }
 +
 +    #[test]
 +    fn losing_focus_drops_the_held_key_and_modifiers() {
 +        let (mut d, mut app, mut f) = (driver(), mock(), Flags { redraw: false, exit: false });
 +        d.set_modifiers(&mut app, Modifiers { ctrl: true, shift: true, alt: true, logo: true });
 +        d.key(turn(&mut app, &mut f), Key::Character("a".into()), Some("a".into()), ElementState::Pressed);
 +        assert!(d.pressed_key.is_some());
 +
 +        d.keyboard_focus(turn(&mut app, &mut f), false);
 +        assert!(d.pressed_key.is_none());
 +        // Logo is left as it was, as the runner always left it.
 +        assert_eq!(d.mods, Modifiers { ctrl: false, shift: false, alt: false, logo: true });
 +        assert_eq!(app.seen.last(), Some(&Seen::Focus(false)));
 +    }
 +
++    #[test]
++    fn a_finger_is_the_left_button_and_never_the_windows() {
++        // Only the tap and the hold: a touch scroll publishes the scroll
++        // phase process-wide, which a parallel suite must not race.
++        use crate::backend::touch::TouchAction::*;
++        let (mut app, mut d) = (mock(), driver());
++        app.csd = true;
++        app.press_msg = Some(7);
++        let mut f = Flags { redraw: false, exit: false };
++        // A tap on the resize border: a click there, not a resize.
++        d.touch(turn(&mut app, &mut f), vec![Hover(2.0, 2.0), Press(2.0, 2.0), Release(2.0, 2.0), Leave], None);
++        assert_eq!(
++            app.seen,
++            vec![
++                Seen::Move(2.0, 2.0),
++                Seen::Button(MouseButton::Left, ElementState::Pressed, 2.0, 2.0),
++                Seen::Update(7),
++                Seen::Button(MouseButton::Left, ElementState::Released, 2.0, 2.0),
++                Seen::Move(-10000.0, -10000.0),
++            ]
++        );
++        assert!(f.redraw, "the press's message asked for a frame");
++        // A hold, then a drag: the held left button's motion.
++        app.seen.clear();
++        d.touch(turn(&mut app, &mut f), vec![Press(10.0, 10.0), Drag(40.0, 10.0), Release(40.0, 10.0)], None);
++        assert_eq!(app.seen[2], Seen::Move(40.0, 10.0));
++        assert_eq!(d.buttons_down, 0, "a finger holds no pointer button");
++    }
++
 +    #[test]
 +    fn a_scroll_frame_is_its_phase_and_delta() {
 +        // The pure half of `scroll`: the dispatch itself publishes the phase
 +        // process-wide, which a parallel suite must not race.
 +        use crate::input::ScrollFactors;
 +        use crate::widget::ScrollPhase;
 +        let unit = ScrollFactors { mouse: 1.0, trackpad: 1.0 };
 +        let notch = ScrollFrame { v: 15.0, discrete_v: 1, source: Some(ScrollSource::Wheel), ..ScrollFrame::default() };
 +        assert_eq!(scroll_delta(&notch, unit), (ScrollPhase::Wheel, MouseScrollDelta::LineDelta(-0.0, -1.0)));
 +
 +        let finger = ScrollFrame { v: 4.0, source: Some(ScrollSource::Finger), ..ScrollFrame::default() };
 +        assert_eq!(
 +            scroll_delta(&finger, ScrollFactors { mouse: 1.0, trackpad: 2.0 }),
 +            (ScrollPhase::Finger, MouseScrollDelta::PixelDelta(Position { x: -0.0, y: -8.0 }))
 +        );
 +        // No source named is a finger too; a lift with no delta ends it.
 +        let unnamed = ScrollFrame { h: 3.0, ..ScrollFrame::default() };
 +        assert_eq!(scroll_delta(&unnamed, unit).0, ScrollPhase::Finger);
 +        let lift = ScrollFrame { stop: true, source: Some(ScrollSource::Finger), ..ScrollFrame::default() };
 +        assert_eq!(scroll_delta(&lift, unit).0, ScrollPhase::FingerEnd);
 +        // A tilt wheel, or anything newer, glides like a wheel.
 +        let tilt = ScrollFrame { h: 5.0, source: Some(ScrollSource::WheelTilt), ..ScrollFrame::default() };
 +        assert_eq!(scroll_delta(&tilt, unit).0, ScrollPhase::Wheel);
 +    }
 +}
diff --cc src/backend/frame.rs
index 8076adf,0000000..5cedaf9
mode 100644,000000..100644
--- a/src/backend/frame.rs
+++ b/src/backend/frame.rs
@@@ -1,366 -1,0 +1,366 @@@
 +//! Building a frame: everything between "the window needs a frame" and "hand
 +//! the renderer its data" that knows nothing of the window system. The app is
 +//! asked for its display list, damage, custom vertices and overlays; widgets
 +//! are shaped against the glyph pass's font system; the list is tessellated
 +//! and its text gathered; popovers are collected for the text-occlusion
 +//! clamp. What comes out is a [`BuiltFrame`] — plain data the renderer draws
 +//! — and a shell presents it however its window system presents.
 +//!
 +//! The Wayland shell's half (`EngineState::render`) is what is left: the
 +//! grid patch and input region, uploading the glyphs, the extent gate and
 +//! buffer scale, the frame callback, `stage_renderer` and the draw. Order
 +//! between the two halves is the order the frame always had — the app's
 +//! damage is taken here even when the shell then skips the present.
 +
 +use cosmic_text::FontSystem;
 +
 +use super::app::{Application, LogicalSize};
 +use super::tessellate::{quad_vertices, tessellate_display_list, DlBatch, Vertex};
 +use super::text::{collect_dl_text, dl_text_spans, TextBounds};
 +use crate::draw::{Batch2D, Frame2D, ImageQuad, TextSpan};
- use crate::widget::TextItem;
++use super::text::DlText;
 +
 +/// One frame, built and owned: the renderer's [`Frame2D`] is a borrow of it
 +/// ([`frame2d`](Self::frame2d)). Its display-list text is shaped into the
 +/// `text_items` that [`build_frame`] was handed, and lent to the glyph pass
 +/// through [`text_spans`](Self::text_spans).
 +pub struct BuiltFrame {
 +    pub verts: Vec<Vertex>,
 +    pub batches: Vec<Batch2D>,
 +    /// Drawn after the text pass.
 +    pub overlay_verts: Vec<Vertex>,
 +    pub images: Vec<ImageQuad>,
 +    pub plate_features: Vec<[f32; 12]>,
 +    /// Linear, alpha as given.
 +    pub clear_color: [f32; 4],
 +    /// The part of the surface that changed, physical px; `None` = all of it.
 +    pub damage: Option<(u32, u32, u32, u32)>,
 +    /// The app's text is the display list's (`Application::display_list_text`).
 +    /// When false the app stages the renderer's text itself and the glyph
 +    /// pass must be left alone.
 +    pub dl_text: bool,
 +    /// Popover rects (and the in-window context menu) that page text is
 +    /// clamped away from, logical px, in stacking order.
 +    overlay_rects: Vec<(f32, f32, f32, f32)>,
 +    scale: f32,
 +    physical: (u32, u32),
 +}
 +
 +impl BuiltFrame {
 +    /// The display-list text as glyph spans, borrowing `items` — the vector
 +    /// [`build_frame`] shaped this frame's text into.
-     pub fn text_spans<'a>(&self, items: &'a [TextItem]) -> Vec<TextSpan<'a>> {
++    pub fn text_spans<'a>(&self, items: &'a [DlText]) -> Vec<TextSpan<'a>> {
 +        let (pw, ph) = self.physical;
 +        let bounds = TextBounds { left: 0, top: 0, right: pw as i32, bottom: ph as i32 };
 +        // All text is display-list text: each Text prim with the default
 +        // mapping (scale + surface clamp) plus the popover-occlusion clamp
 +        // against the app's registered popovers.
 +        dl_text_spans(items, self.scale, bounds, &self.overlay_rects)
 +    }
 +
 +    /// The frame as the renderer takes it.
 +    pub fn frame2d(&self) -> Frame2D<'_> {
 +        Frame2D {
 +            verts: &self.verts,
 +            batches: &self.batches,
 +            overlay_verts: &self.overlay_verts,
 +            images: &self.images,
 +            plate_features: &self.plate_features,
 +            clear_color: self.clear_color,
 +            damage: self.damage,
 +        }
 +    }
 +}
 +
 +/// Build the app's frame at `size` and `scale`.
 +///
 +/// `damage_owed` is the shell's: true when the last built frame was not
 +/// presented, so this one repaints everything. It is set true here; the
 +/// shell clears it once a frame is actually presented. `text_items` is where
 +/// the display-list text is shaped and held, for [`BuiltFrame::text_spans`].
 +pub fn build_frame<A: Application>(
 +    app: &mut A,
 +    fs: &mut FontSystem,
 +    size: LogicalSize,
 +    scale: f64,
 +    damage_owed: &mut bool,
-     text_items: &mut Vec<TextItem>,
++    text_items: &mut Vec<DlText>,
 +) -> BuiltFrame {
 +    let (logical_w, logical_h) = (size.width, size.height);
 +
 +    // The editing widget reports its caret as it paints (`ime::report_caret`):
 +    // where an input method's candidates go, and whether text is wanted.
 +    crate::ime::begin_frame();
 +
 +    // 0. Shape every registered widget against the SAME FontSystem the glyph pass draws
 +    // with, before the app builds its frame. A widget's caret/selection/click→index math
 +    // reads per-glyph advances its `prepare_text` records; nothing else calls it on the
 +    // display-list path (the paint walk is `&dyn`, and apps were left to remember —
 +    // cce-list, cce-secrets, and the reference DemoApp all forgot, so their carets fell
 +    // back to `measure_text_width("M")`, an inked extent that drifts off the glyphs).
 +    // The flat path shapes in `layout::render_widget`; apps that hand-shape still work —
 +    // their call and this one hit the same shaped-buffer cache. Pointers are collected
 +    // first so the registry borrow ends before any widget is mutated (the missed-press
 +    // walk dereferences the same registry the same way).
 +    {
 +        let ptrs: Vec<*mut (dyn crate::widget::WidgetHost + 'static)> = app
 +            .ui_context()
 +            .map(|ctx| ctx.tree.iter_registered().map(|(_, p)| p).collect())
 +            .unwrap_or_default();
 +        for ptr in ptrs {
 +            unsafe {
 +                if let Some(w) = ptr.as_mut() {
 +                    w.prepare_text(fs);
 +                }
 +            }
 +        }
 +    }
 +
 +    // 1. The frame's geometry IS the app's display list — the single paint path. Tessellated
 +    // below as one batched, GPU-scissor-clipped pass. An app that draws nothing returns
 +    // `None`, giving an empty frame (the legacy view*/tuple-wrapping path is gone).
 +    let dl = app
 +        .display_list(size, scale)
 +        .unwrap_or_else(|| crate::scene::paint::PaintCtx::new().finish());
 +    crate::ime::end_frame();
 +    // Taken with the display list it describes. A frame that took the
 +    // app's damage and then was not presented owes those pixels, so the
 +    // next one that is presented repaints everything.
 +    let app_damage = app.take_damage(size, scale);
 +    let damage = match (app_damage, *damage_owed) {
 +        (Some((x, y, w, h)), false) => {
 +            // Outward to whole physical pixels, plus one for an
 +            // antialiased edge.
 +            let s = scale as f32;
 +            let x0 = ((x * s).floor() - 1.0).max(0.0);
 +            let y0 = ((y * s).floor() - 1.0).max(0.0);
 +            let x1 = ((x + w) * s).ceil() + 1.0;
 +            let y1 = ((y + h) * s).ceil() + 1.0;
 +            Some((x0 as u32, y0 as u32, (x1 - x0).max(0.0) as u32, (y1 - y0).max(0.0) as u32))
 +        }
 +        _ => None,
 +    };
 +    *damage_owed = true;
 +
 +    // 1a. Phase 6 display-list text: shape the list's Text prims through the shared buffer
 +    // cache and hold them for the glyph pass (the TextSpans the shell builds borrow these).
 +    // Clip = the paint walk's item clip ∩ the prim's own bounds, in logical space.
 +    text_items.clear();
 +    let dl_text = app.display_list_text();
 +    if dl_text {
 +        collect_dl_text(fs, &dl, text_items);
 +    }
 +
 +    let (mut verts, mut dl_batches, dl_images, plate_features) =
 +        tessellate_display_list(&dl, logical_w, logical_h, scale as f32);
 +    // A pending height-field export (`CCE_HEIGHTMAP`, or an app's
 +    // `scene::heightfield::request`): the plates of THIS frame, sampled
 +    // as the geometry the shader is about to shade.
 +    if let Some(req) = crate::scene::heightfield::take_request() {
 +        let s = scale as f32;
 +        let (pw, ph) = ((logical_w * s).round() as usize, (logical_h * s).round() as usize);
 +        let hf = crate::scene::heightfield::HeightField::from_frame(&dl_batches, &plate_features, pw, ph, s);
 +        let (lo, hi) = hf.range_px();
 +        match crate::scene::heightfield::export_png(&hf, &req.path, req.mm_per_sample) {
 +            Ok(()) => log::info!(
 +                "[heightfield] wrote {} ({}x{} px, {:.3}..{:.3} mm, metric {})",
 +                req.path.display(), pw, ph, lo / hf.px_per_mm, hi / hf.px_per_mm, hf.source.as_str()
 +            ),
 +            Err(e) => log::warn!("[heightfield] export to {} failed: {e}", req.path.display()),
 +        }
 +    }
 +    // custom_vertices (e.g. graph geometry) is appended as a final unclipped batch drawn on top.
 +    let pre_custom = verts.len() as u32;
 +    app.custom_vertices(&mut verts, size, scale);
 +    if (verts.len() as u32) > pre_custom {
 +        dl_batches.push(DlBatch {
 +            scissor: None,
 +            clip_rrect: None,
 +            start: pre_custom,
 +            end: verts.len() as u32,
 +            plate: None,
 +            blur_behind: false,
 +        });
 +    }
 +
 +    // 1b. Overlay quads (drawn after the text pass).
 +    let mut overlay_quads = Vec::new();
 +    app.overlay_quads(&mut overlay_quads, size, scale);
 +    let mut overlay_verts = Vec::new();
 +    for &(qx, qy, qw, qh, qc) in &overlay_quads {
 +        overlay_verts.extend(quad_vertices(qx, qy, qw, qh, logical_w, logical_h, qc));
 +    }
 +
 +    // 2. What page text is clamped away from: the app's open popovers…
 +    let scale_f32 = scale as f32;
 +    let mut overlay_rects: Vec<(f32, f32, f32, f32)> = Vec::new();
 +    if let Some(ctx) = app.ui_context() {
 +        for &pop_id in &ctx.active_popovers {
 +            if let Some(ptr) = ctx.tree.get_ptr(pop_id) {
 +                unsafe {
 +                    if let Some((x, y, w, h)) = (*ptr).popover_rect() {
 +                        let (dx, dy) = app.popover_offset(pop_id);
 +                        overlay_rects.push((x + dx, y + dy, w, h));
 +                    }
 +                }
 +            }
 +        }
 +    }
 +    // …and the global context menu, which draws into the app's display list,
 +    // so it gets the same occlusion: the menu rect clamps list text beneath,
 +    // and the menu's own labels are exempt because they carry bounds equal to
 +    // the rect. Hosted in its popup surface, the menu covers the window from
 +    // above and nothing of it is in the list.
 +    if crate::widget::context_menu::is_visible() && !crate::widget::context_menu::is_hosted() {
 +        overlay_rects.push((
 +            crate::widget::context_menu::x(),
 +            crate::widget::context_menu::y(),
 +            crate::widget::context_menu::w(),
 +            crate::widget::context_menu::h(),
 +        ));
 +    }
 +
 +    // 3. Images and batches in physical px.
 +    let images: Vec<ImageQuad> = dl_images
 +        .iter()
 +        .map(|di| ImageQuad {
 +            image: di.image,
 +            rect: (
 +                di.rect.x * scale_f32,
 +                di.rect.y * scale_f32,
 +                di.rect.width * scale_f32,
 +                di.rect.height * scale_f32,
 +            ),
 +            alpha: di.alpha,
 +            z_before: di.at,
 +            clip: di.clip.map(|c| {
 +                (
 +                    (c.x * scale_f32).max(0.0) as u32,
 +                    (c.y * scale_f32).max(0.0) as u32,
 +                    (c.width * scale_f32) as u32,
 +                    (c.height * scale_f32) as u32,
 +                )
 +            }),
 +        })
 +        .collect();
 +
 +    let batches = super::tessellate::dl_batches_2d(&dl_batches, scale_f32);
 +
 +    let cc = app.clear_color();
 +    let clear_color = [cc[0].powf(2.2), cc[1].powf(2.2), cc[2].powf(2.2), cc[3]];
 +
 +    BuiltFrame {
 +        verts,
 +        batches,
 +        overlay_verts,
 +        images,
 +        plate_features,
 +        clear_color,
 +        damage,
 +        dl_text,
 +        overlay_rects,
 +        scale: scale_f32,
 +        physical: ((logical_w * scale_f32) as u32, (logical_h * scale_f32) as u32),
 +    }
 +}
 +
 +#[cfg(test)]
 +mod tests {
 +    //! The builder with no window and no GPU: a mock app, an empty font
 +    //! database, and the frame's data read back.
 +    use super::*;
 +    use crate::backend::app::{AppSender, LogicalPosition, WindowSettings};
 +    use crate::widget::{ElementState, KeyEvent, MouseButton, MouseScrollDelta};
 +
 +    #[derive(Default)]
 +    struct Mock {
 +        damage: Option<(f32, f32, f32, f32)>,
 +        custom: usize,
 +        overlay: bool,
 +    }
 +
 +    impl Application for Mock {
 +        type Message = ();
 +        fn create(_: AppSender<()>) -> Self {
 +            unreachable!("built directly")
 +        }
 +        fn settings(&self) -> WindowSettings {
 +            WindowSettings { title: String::new(), app_id: "mock".into(), width: 100, height: 50, fullscreen: false, min_size: None }
 +        }
 +        fn update(&mut self, _: (), _: &mut bool, _: &mut bool) {}
 +        fn tick(&mut self, _: f32, _: &mut bool) {}
 +        fn handle_pointer_move(&mut self, _: LogicalPosition, _: &mut bool) {}
 +        fn handle_mouse_input(&mut self, _: MouseButton, _: ElementState, _: LogicalPosition, _: &mut bool) -> Option<()> {
 +            None
 +        }
 +        fn handle_mouse_wheel(&mut self, _: &MouseScrollDelta, _: LogicalPosition, _: &mut bool) {}
 +        fn handle_key_input(&mut self, _: &KeyEvent, _: &mut bool) -> Option<()> {
 +            None
 +        }
 +        fn take_damage(&mut self, _: LogicalSize, _: f64) -> Option<(f32, f32, f32, f32)> {
 +            self.damage
 +        }
 +        fn custom_vertices(&mut self, verts: &mut Vec<Vertex>, size: LogicalSize, _: f64) {
 +            for _ in 0..self.custom {
 +                verts.extend(quad_vertices(0.0, 0.0, 1.0, 1.0, size.width, size.height, [1.0; 4]));
 +            }
 +        }
 +        fn overlay_quads(&mut self, quads: &mut Vec<(f32, f32, f32, f32, [f32; 4])>, _: LogicalSize, _: f64) {
 +            if self.overlay {
 +                quads.push((1.0, 2.0, 3.0, 4.0, [1.0; 4]));
 +            }
 +        }
 +        fn clear_color(&self) -> [f32; 4] {
 +            [0.5, 0.0, 1.0, 0.25]
 +        }
 +    }
 +
 +    fn fonts() -> FontSystem {
 +        FontSystem::new_with_locale_and_db("en-US".into(), cosmic_text::fontdb::Database::new())
 +    }
 +
 +    const SIZE: LogicalSize = LogicalSize { width: 100.0, height: 50.0 };
 +
 +    #[test]
 +    fn damage_is_physical_and_owed_until_a_present_clears_it() {
 +        let mut app = Mock { damage: Some((10.2, 4.0, 5.0, 3.0)), ..Mock::default() };
 +        let (mut fs, mut items) = (fonts(), Vec::new());
 +
 +        // Nothing owed: the app's rect, outward to whole pixels at 2x plus one.
 +        let mut owed = false;
 +        let f = build_frame(&mut app, &mut fs, SIZE, 2.0, &mut owed, &mut items);
 +        assert_eq!(f.damage, Some((19, 7, 13, 8)));
 +        assert!(owed, "a built frame owes its pixels until the shell presents it");
 +
 +        // The last frame was never presented: this one repaints everything.
 +        let f = build_frame(&mut app, &mut fs, SIZE, 2.0, &mut owed, &mut items);
 +        assert_eq!(f.damage, None);
 +    }
 +
 +    #[test]
 +    fn custom_vertices_are_a_last_unclipped_batch_and_overlays_their_own() {
 +        let mut app = Mock { custom: 2, overlay: true, ..Mock::default() };
 +        let (mut fs, mut items, mut owed) = (fonts(), Vec::new(), false);
 +        let f = build_frame(&mut app, &mut fs, SIZE, 1.0, &mut owed, &mut items);
 +        // An empty display list: the custom quads are the whole vertex list,
 +        // in one batch on top with no scissor.
 +        assert_eq!(f.verts.len(), 12);
 +        let last = f.batches.last().expect("a batch for the custom vertices");
 +        assert_eq!((last.start, last.end, last.scissor), (0, 12, None));
 +        assert_eq!(f.overlay_verts.len(), 6);
 +
 +        let none = build_frame(&mut Mock::default(), &mut fs, SIZE, 1.0, &mut owed, &mut items);
 +        assert!(none.verts.is_empty() && none.batches.is_empty());
 +    }
 +
 +    #[test]
 +    fn the_clear_colour_is_linearized_and_its_alpha_kept() {
 +        let (mut fs, mut items, mut owed) = (fonts(), Vec::new(), false);
 +        let f = build_frame(&mut Mock::default(), &mut fs, SIZE, 1.0, &mut owed, &mut items);
 +        let [r, g, b, a] = f.clear_color;
 +        assert!((r - 0.5f32.powf(2.2)).abs() < 1e-6 && g == 0.0 && b == 1.0 && a == 0.25);
 +        // And the frame lends the renderer exactly what it built.
 +        let f2 = f.frame2d();
 +        assert_eq!((f2.clear_color, f2.damage), (f.clear_color, f.damage));
 +    }
 +}
diff --cc src/backend/mod.rs
index 7d0c870,1e1c764..9462179
--- a/src/backend/mod.rs
+++ b/src/backend/mod.rs
@@@ -1,27 -1,9 +1,28 @@@
 +pub mod app;
 +pub mod appkit;
 +pub mod dom;
 +pub mod driver;
 +pub mod frame;
 +pub mod shell;
 +pub mod tessellate;
 +pub mod text;
++pub mod touch;
 +// The Wayland shell: native, but for macOS.
 +#[cfg(not(any(target_arch = "wasm32", target_os = "macos")))]
  pub mod dnd;
 +#[cfg(not(any(target_arch = "wasm32", target_os = "macos")))]
  pub mod menu_popup;
 -pub mod touch;
 +#[cfg(not(any(target_arch = "wasm32", target_os = "macos")))]
 +pub mod text_input;
 +#[cfg(not(any(target_arch = "wasm32", target_os = "macos")))]
  pub mod window_runner;
  
 -pub use window_runner::{
 -    EngineState, WindowSettings, LogicalPosition, LogicalSize, Application, run,
 -    Vertex, LineCap, PressedKey, get_text_buffer,
 -};
 +
 +pub use app::{Application, AppSender, LogicalPosition, LogicalSize, WindowSettings};
 +pub use driver::PressedKey;
 +pub use tessellate::{LineCap, Vertex};
 +pub use text::get_text_buffer;
 +#[cfg(not(any(target_arch = "wasm32", target_os = "macos")))]
 +pub use window_runner::{run, EngineState};
 +#[cfg(target_os = "macos")]
 +pub use crate::mac::run;
diff --cc src/backend/text.rs
index be03db4,0000000..0a1fb66
mode 100644,000000..100644
--- a/src/backend/text.rs
+++ b/src/backend/text.rs
@@@ -1,528 -1,0 +1,727 @@@
 +//! Text shaping for the runner: the shaped-buffer cache, family resolution,
 +//! the display list's text prims gathered for the glyph pass, and the
 +//! popover-occlusion clamp. Platform-neutral — nothing here knows the window
 +//! system; moved out of `window_runner` so another shell can share it.
 +
 +use cosmic_text::{FontSystem, Buffer, Attrs, Metrics};
- use crate::widget::TextItem;
++use std::rc::Rc;
 +use crate::draw::TextSpan;
 +
- #[derive(Hash, PartialEq, Eq, Clone)]
- struct BufferCacheKey {
-     text: String,
++/// One shaped buffer in the text cache, keyed by everything that shapes it
++/// beyond its text (the text is the outer map's key, so a lookup borrows it
++/// rather than allocating).
++struct CachedBuffer {
++    /// Physical font size, in thousandths of a px.
 +    size_milli: u32,
++    /// The family as the font string names it, size stripped.
 +    font: Option<String>,
 +    is_vertical: bool,
 +    attrs: crate::scene::paint::TextAttrs,
++    /// The scale factor's bits: a laid-out buffer's wrap width and box are
++    /// logical px turned physical by it.
++    scale_bits: u32,
++    /// `Some` for a boxed [`Prim::Text`](crate::scene::paint::Prim::Text) laid
++    /// out by [`get_text_buffer_laid_out`]; `None` for a single run.
++    layout: Option<crate::scene::paint::TextLayout>,
++    /// The laid-out buffer's vertical offset in its box (0 for a single run).
++    voff: f32,
++    /// Shared, not cloned, on a hit: a `Buffer` owns every shaped line and
++    /// glyph, and the frame used to deep-copy one per text prim per frame.
++    buffer: Rc<Buffer>,
++    /// [`BUFFER_TICK`] at the last hit, for least-recently-used eviction.
++    last_used: u64,
 +}
 +
- #[derive(Clone)]
- struct CachedBuffer {
-     buffer: Buffer,
-     last_accessed: web_time::Instant,
++impl CachedBuffer {
++    fn matches(&self, k: &BufferKey<'_>) -> bool {
++        self.size_milli == k.size_milli
++            && self.font.as_deref() == k.font
++            && self.is_vertical == k.is_vertical
++            && self.attrs == k.attrs
++            && self.scale_bits == k.scale_bits
++            && self.layout == k.layout
++    }
++}
++
++/// A cache lookup's key, borrowed from the caller.
++struct BufferKey<'a> {
++    size_milli: u32,
++    font: Option<&'a str>,
++    is_vertical: bool,
++    attrs: crate::scene::paint::TextAttrs,
++    scale_bits: u32,
++    layout: Option<crate::scene::paint::TextLayout>,
 +}
 +
++/// The text cache holds at most this many buffers; past it the least
++/// recently used [`BUFFER_EVICT`] go.
++const BUFFER_CAP: usize = 2000;
++const BUFFER_EVICT: usize = 100;
++
 +std::thread_local! {
-     static BUFFER_CACHE: std::cell::RefCell<std::collections::HashMap<BufferCacheKey, CachedBuffer>> = std::cell::RefCell::new(std::collections::HashMap::new());
++    /// Text → every shaped variant of it. Variants per text are few (a size,
++    /// a weight, a box), so they are scanned rather than hashed.
++    static BUFFER_CACHE: std::cell::RefCell<std::collections::HashMap<String, Vec<CachedBuffer>>> =
++        std::cell::RefCell::new(std::collections::HashMap::new());
++    static BUFFER_COUNT: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
++    static BUFFER_TICK: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
++}
++
++fn buffer_tick() -> u64 {
++    BUFFER_TICK.with(|t| {
++        let n = t.get() + 1;
++        t.set(n);
++        n
++    })
++}
++
++/// The cached buffer for `text` under `key`, and its vertical offset.
++fn buffer_cache_get(text: &str, key: &BufferKey<'_>) -> Option<(Rc<Buffer>, f32)> {
++    BUFFER_CACHE.with(|cache| {
++        let mut cache = cache.borrow_mut();
++        let hit = cache.get_mut(text)?.iter_mut().find(|e| e.matches(key))?;
++        hit.last_used = buffer_tick();
++        Some((Rc::clone(&hit.buffer), hit.voff))
++    })
++}
++
++fn buffer_cache_put(text: &str, key: &BufferKey<'_>, buffer: Rc<Buffer>, voff: f32) {
++    BUFFER_CACHE.with(|cache| {
++        let mut cache = cache.borrow_mut();
++        if BUFFER_COUNT.with(|c| c.get()) >= BUFFER_CAP {
++            let mut ticks: Vec<u64> = cache.values().flatten().map(|e| e.last_used).collect();
++            ticks.sort_unstable();
++            let cutoff = ticks[BUFFER_EVICT.min(ticks.len()) - 1];
++            cache.retain(|_, v| {
++                v.retain(|e| e.last_used > cutoff);
++                !v.is_empty()
++            });
++            BUFFER_COUNT.with(|c| c.set(cache.values().map(Vec::len).sum()));
++        }
++        let entry = CachedBuffer {
++            size_milli: key.size_milli,
++            font: key.font.map(str::to_owned),
++            is_vertical: key.is_vertical,
++            attrs: key.attrs,
++            scale_bits: key.scale_bits,
++            layout: key.layout,
++            voff,
++            buffer,
++            last_used: buffer_tick(),
++        };
++        match cache.get_mut(text) {
++            Some(v) => v.push(entry),
++            None => {
++                cache.insert(text.to_owned(), vec![entry]);
++            }
++        }
++        BUFFER_COUNT.with(|c| c.set(c.get() + 1));
++    });
 +}
 +
 +fn find_cased_family(fs: &FontSystem, name: &str) -> Option<String> {
 +    let lower_name = name.to_lowercase();
 +    for face in fs.db().faces() {
 +        for (family, _) in &face.families {
 +            if family.to_lowercase() == lower_name {
 +                return Some(family.clone());
 +            }
 +        }
 +    }
 +    None
 +}
 +
 +thread_local! {
 +    /// Family name → is-monospaced, resolved once per family from fontdb's
 +    /// face metadata (the post table's isFixedPitch, as fontdb records it).
 +    static MONO_FAMILY_CACHE: std::cell::RefCell<std::collections::HashMap<String, bool>> =
 +        std::cell::RefCell::new(std::collections::HashMap::new());
 +}
 +
 +fn family_is_monospaced(fs: &FontSystem, name: &str) -> bool {
 +    MONO_FAMILY_CACHE.with(|cache| {
 +        if let Some(&mono) = cache.borrow().get(name) {
 +            return mono;
 +        }
 +        let lower = name.to_lowercase();
 +        let mono = fs
 +            .db()
 +            .faces()
 +            .find(|face| face.families.iter().any(|(f, _)| f.to_lowercase() == lower))
 +            .map(|face| face.monospaced)
 +            .unwrap_or(false);
 +        cache.borrow_mut().insert(name.to_string(), mono);
 +        mono
 +    })
 +}
 +
 +/// The shaping mode for one text run: ASCII-only text in a MONOSPACED face
 +/// shapes `Basic`, everything else `Advanced`.
 +///
 +/// `Basic` bypasses OpenType substitution and positioning, and for ASCII in a
 +/// mono face that is exactly right: a mono font's ligatures are the one thing
 +/// `Advanced` adds there, and they break the grid — Chivo Mono's `liga`
 +/// squeezes f+i into a single-advance fi glyph, which is why the bar's window
 +/// titles rendered "file" with a cramped fi — while mono faces carry no
 +/// kerning to lose. Proportional faces keep `Advanced` (their kerning and
 +/// ligatures are wanted — a font preview must not misrepresent the face), and
 +/// any non-ASCII text keeps real shaping (combining marks, emoji, complex
 +/// scripts) whatever the face.
 +pub fn shaping_for(fs: &FontSystem, text: &str, family: &cosmic_text::Family) -> cosmic_text::Shaping {
 +    if text.is_ascii() {
 +        if let cosmic_text::Family::Name(name) = family {
 +            if family_is_monospaced(fs, name) {
 +                return cosmic_text::Shaping::Basic;
 +            }
 +        }
 +    }
 +    cosmic_text::Shaping::Advanced
 +}
 +
 +pub fn get_text_buffer(fs: &mut FontSystem, text: &str, size: f32, font: Option<&str>) -> Buffer {
 +    get_text_buffer_attrs(fs, text, size, font, crate::scene::paint::TextAttrs::default())
 +}
 +
 +/// [`get_text_buffer`] plus shaping attributes (italic / weight) — the backend's shape entry
 +/// for `Prim::Text` prims that carry [`TextAttrs`] (the font picker's style-variant previews).
 +pub fn get_text_buffer_attrs(
 +    fs: &mut FontSystem,
 +    text: &str,
 +    size: f32,
 +    font: Option<&str>,
 +    text_attrs: crate::scene::paint::TextAttrs,
 +) -> Buffer {
-     let scale = crate::scale::scale_factor();
-     let mut font_size = size;
-     let mut family_name = None;
++    Buffer::clone(&shared_text_buffer(fs, text, size, font, text_attrs))
++}
 +
-     if let Some(font_str) = font {
-         let (parsed_family, parsed_size) = crate::layout::parse_font_string(font_str);
-         if let Some(ps) = parsed_size {
-             font_size = ps;
++/// [`get_text_buffer_attrs`] without the copy: the cached buffer itself,
++/// shared. What the frame and the toolkit's own measuring use — a `Buffer`
++/// owns every shaped line and glyph, so the clone the public functions hand
++/// out costs as much as the text is long.
++pub(crate) fn shared_text_buffer(
++    fs: &mut FontSystem,
++    text: &str,
++    size: f32,
++    font: Option<&str>,
++    text_attrs: crate::scene::paint::TextAttrs,
++) -> Rc<Buffer> {
++    let scale = crate::scale::scale_factor();
++    let (family_name, font_size) = match font {
++        Some(font_str) => {
++            let (family, parsed_size) = crate::layout::split_font_string(font_str);
++            (Some(family), parsed_size.unwrap_or(size))
 +        }
-         family_name = Some(parsed_family);
-     }
++        None => (None, size),
++    };
 +
 +    let physical_size = font_size * scale;
-     let size_key = (physical_size * 1000.0).round() as u32;
 +    let is_vertical = crate::IS_VERTICAL.load(std::sync::atomic::Ordering::Relaxed);
-     let key = BufferCacheKey {
-         text: text.to_string(),
-         size_milli: size_key,
-         font: family_name.clone(),
++    let key = BufferKey {
++        size_milli: (physical_size * 1000.0).round() as u32,
++        font: family_name,
 +        is_vertical,
 +        attrs: text_attrs,
++        scale_bits: scale.to_bits(),
++        layout: None,
 +    };
- 
-     let cached = BUFFER_CACHE.with(|cache| {
-         let mut cache = cache.borrow_mut();
-         if let Some(cached_item) = cache.get_mut(&key) {
-             cached_item.last_accessed = web_time::Instant::now();
-             Some(cached_item.buffer.clone())
-         } else {
-             None
-         }
-     });
- 
-     if let Some(buf) = cached {
++    if let Some((buf, _)) = buffer_cache_get(text, &key) {
 +        return buf;
 +    }
 +
 +    let line_height = if is_vertical {
 +        physical_size * 1.05
 +    } else {
 +        physical_size * 1.0
 +    };
 +    let metrics = Metrics::new(physical_size, line_height);
 +    let mut buf = Buffer::new(fs, metrics);
 +    let mut attrs = Attrs::new();
 +
 +    let (sans_fallback, serif_fallback, mono_fallback, _) = crate::layout::read_preferred_fonts();
 +
-     let resolved_storage = family_name.as_deref().and_then(|font_name| match font_name {
++    let resolved_storage = family_name.and_then(|font_name| match font_name {
 +        "monospace" if !mono_fallback.is_empty() => find_cased_family(fs, &mono_fallback),
 +        "sans-serif" if !sans_fallback.is_empty() => find_cased_family(fs, &sans_fallback),
 +        "serif" if !serif_fallback.is_empty() => find_cased_family(fs, &serif_fallback),
 +        _ => None,
 +    });
 +
 +    let resolved_sans = if !sans_fallback.is_empty() {
 +        find_cased_family(fs, &sans_fallback)
 +    } else {
 +        None
 +    };
 +
-     let family = if let Some(ref font_family) = family_name {
-         match font_family.as_str() {
++    let family = if let Some(font_family) = family_name {
++        match font_family {
 +            "monospace" => {
 +                if !mono_fallback.is_empty() {
 +                    if let Some(ref cased) = resolved_storage {
 +                        cosmic_text::Family::Name(cased)
 +                    } else {
 +                        cosmic_text::Family::Name(crate::layout::get_system_monospace_font())
 +                    }
 +                } else {
 +                    cosmic_text::Family::Name(crate::layout::get_system_monospace_font())
 +                }
 +            }
 +            "sans-serif" => {
 +                if !sans_fallback.is_empty() {
 +                    if let Some(ref cased) = resolved_storage {
 +                        cosmic_text::Family::Name(cased)
 +                    } else {
 +                        cosmic_text::Family::SansSerif
 +                    }
 +                } else {
 +                    cosmic_text::Family::SansSerif
 +                }
 +            }
 +            "serif" => {
 +                if !serif_fallback.is_empty() {
 +                    if let Some(ref cased) = resolved_storage {
 +                        cosmic_text::Family::Name(cased)
 +                    } else {
 +                        cosmic_text::Family::Serif
 +                    }
 +                } else {
 +                    cosmic_text::Family::Serif
 +                }
 +            }
 +            name => cosmic_text::Family::Name(name),
 +        }
 +    } else {
 +        if !sans_fallback.is_empty() {
 +            if let Some(ref cased) = resolved_sans {
 +                cosmic_text::Family::Name(cased)
 +            } else {
 +                cosmic_text::Family::SansSerif
 +            }
 +        } else {
 +            cosmic_text::Family::SansSerif
 +        }
 +    };
 +    attrs = attrs.family(family);
 +    if text_attrs.italic {
 +        attrs = attrs.style(cosmic_text::Style::Italic);
 +    }
 +    if let Some(w) = text_attrs.weight {
 +        attrs = attrs.weight(cosmic_text::Weight(w));
 +    }
 +    let shaping = shaping_for(fs, text, &family);
 +    buf.set_text(fs, text, attrs, shaping);
 +    buf.shape_until_scroll(fs, true);
 +
-     BUFFER_CACHE.with(|cache| {
-         let mut cache = cache.borrow_mut();
-         if cache.len() >= 2000 {
-             let mut items: Vec<(BufferCacheKey, web_time::Instant)> = cache
-                 .iter()
-                 .map(|(k, v)| (k.clone(), v.last_accessed))
-                 .collect();
-             items.sort_by_key(|&(_, time)| time);
-             for (k, _) in items.iter().take(100) {
-                 cache.remove(k);
-             }
-         }
-         cache.insert(key, CachedBuffer {
-             buffer: buf.clone(),
-             last_accessed: web_time::Instant::now(),
-         });
-     });
- 
++    let buf = Rc::new(buf);
++    buffer_cache_put(text, &key, Rc::clone(&buf), 0.0);
 +    buf
 +}
 +
 +/// Byte-offset → x mapping of single-line `text`, shaped exactly as the renderer draws it —
 +/// same buffer cache as the draw, so this is a lookup when the text is already on screen.
 +/// Returns ascending `(byte_idx, x)` pairs (one per cluster start, logical px, relative to
 +/// the text origin), terminated by `(text.len(), total_advance)`. This is the correct
 +/// source for caret placement and click→cursor mapping in hand-rolled text fields:
 +/// `measure_text_width` reports SVG-rasterized inked extent through fontdb's family
 +/// resolution, which disagrees with cosmic-text's advance and can even resolve a
 +/// different face — a caret placed with it drifts off the drawn glyphs.
 +pub fn shaped_cluster_offsets(
 +    fs: &mut FontSystem,
 +    text: &str,
 +    size: f32,
 +    font: Option<&str>,
 +) -> Vec<(usize, f32)> {
 +    let scale = crate::scale::scale_factor().max(1.0);
-     let buffer = get_text_buffer(fs, text, size, font);
++    let buffer = shared_text_buffer(fs, text, size, font, crate::scene::paint::TextAttrs::default());
 +    let mut out: Vec<(usize, f32)> = Vec::new();
 +    let mut total: f32 = 0.0;
 +    for (start, x, w) in normalized_glyph_starts(&buffer, text) {
 +        if out.last().map_or(true, |&(b, _)| b != start) {
 +            out.push((start, x / scale));
 +        }
 +        total = total.max((x + w) / scale);
 +    }
 +    out.push((text.len(), total));
 +    out
 +}
 +
 +/// Every glyph of `buffer`'s layout runs as `(start_byte, x, w)` (physical px),
 +/// with `start` normalized to be text-relative.
 +///
 +/// Exists because cosmic-text 0.12's `Shaping::Basic` path (`shape_skip`) emits
 +/// `LayoutGlyph::start` relative to the shape SPAN — it resets to 0 at every
 +/// word — while the Advanced path emits line-relative starts. `shaping_for`
 +/// picks Basic exactly for ASCII text in a monospace family (the DE's default
 +/// control font), so any multi-word value hit the bug: offsets keyed by those
 +/// starts collide on the low columns and the caret/selection walk off the
 +/// glyphs. A reset can ONLY come from that path, which shapes strictly one
 +/// glyph per char in logical order — so when one is seen, byte starts are
 +/// rebuilt by walking the text's chars. `text` must be the single line the
 +/// buffer was shaped from.
 +pub(crate) fn normalized_glyph_starts(buffer: &Buffer, text: &str) -> Vec<(usize, f32, f32)> {
 +    let mut glyphs: Vec<(usize, f32, f32)> = Vec::new();
 +    let mut monotonic = true;
 +    let mut prev = 0usize;
 +    for run in buffer.layout_runs() {
 +        for g in run.glyphs {
 +            if g.start < prev {
 +                monotonic = false;
 +            }
 +            prev = g.start;
 +            glyphs.push((g.start, g.x, g.w));
 +        }
 +    }
 +    if !monotonic {
 +        let mut starts = text.char_indices().map(|(i, _)| i);
 +        for g in glyphs.iter_mut() {
 +            g.0 = starts.next().unwrap_or(text.len());
 +        }
 +    }
 +    glyphs
 +}
 +
 +/// Shape a boxed [`Prim::Text`] (word-wrap + alignment) and return `(buffer, vertical_offset)`.
- /// Reuses [`get_text_buffer_attrs`] for all the family resolution — that returns a *clone* of the
- /// cached single-run buffer, so re-applying metrics/size/align here does not touch the cache — then
- /// re-lays-it-out: a 1.4 line-height (the placed-text convention), the wrap width, per-line
- /// horizontal alignment, and re-shapes. The vertical offset positions the shaped block inside the
- /// box per `align_v`. Uncached by construction (each box may differ in width/align).
++/// Starts from [`get_text_buffer_attrs`]'s single run for all the family resolution, then
++/// re-lays it out: a 1.4 line-height (the placed-text convention), the wrap width, per-line
++/// horizontal alignment, and re-shapes. The vertical offset positions the shaped block inside
++/// the box per `align_v`. Cached beside the single runs, keyed by the box as well.
 +pub fn get_text_buffer_laid_out(
 +    fs: &mut FontSystem,
 +    text: &str,
 +    size: f32,
 +    font: Option<&str>,
 +    text_attrs: crate::scene::paint::TextAttrs,
 +    layout: crate::scene::paint::TextLayout,
 +) -> (Buffer, f32) {
++    let (buf, voff) = shared_laid_out_buffer(fs, text, size, font, text_attrs, layout);
++    (Buffer::clone(&buf), voff)
++}
++
++/// [`get_text_buffer_laid_out`] without the copy, as [`shared_text_buffer`] is to
++/// [`get_text_buffer_attrs`]. Until 2026-10-04 a boxed text was re-shaped from scratch
++/// every frame it was drawn; the box is part of the key now.
++pub(crate) fn shared_laid_out_buffer(
++    fs: &mut FontSystem,
++    text: &str,
++    size: f32,
++    font: Option<&str>,
++    text_attrs: crate::scene::paint::TextAttrs,
++    layout: crate::scene::paint::TextLayout,
++) -> (Rc<Buffer>, f32) {
 +    use crate::scene::paint::{AlignH, AlignV};
 +    let scale = crate::scale::scale_factor();
 +
-     // Resolved family + attrs come for free (a cache clone we are free to mutate).
-     let mut buf = get_text_buffer_attrs(fs, text, size, font, text_attrs);
- 
-     // The font string may override the size ("family:size") — mirror get_text_buffer_attrs.
-     let mut font_size = size;
-     if let Some(font_str) = font {
-         if let (_, Some(ps)) = crate::layout::parse_font_string(font_str) {
-             font_size = ps;
++    // The font string may override the size ("family:size") — mirror shared_text_buffer.
++    let (family, font_size) = match font {
++        Some(font_str) => {
++            let (family, parsed_size) = crate::layout::split_font_string(font_str);
++            (Some(family), parsed_size.unwrap_or(size))
 +        }
-     }
++        None => (None, size),
++    };
 +    let physical_size = font_size * scale;
++    let key = BufferKey {
++        size_milli: (physical_size * 1000.0).round() as u32,
++        font: family,
++        is_vertical: crate::IS_VERTICAL.load(std::sync::atomic::Ordering::Relaxed),
++        attrs: text_attrs,
++        scale_bits: scale.to_bits(),
++        layout: Some(layout),
++    };
++    if let Some(hit) = buffer_cache_get(text, &key) {
++        return hit;
++    }
++
++    // Resolved family + attrs come from the single run; this copy is ours to re-lay-out.
++    let mut buf = Buffer::clone(&shared_text_buffer(fs, text, size, font, text_attrs));
 +    let line_height = physical_size * 1.4;
 +    buf.set_metrics(fs, Metrics::new(physical_size, line_height));
 +    buf.set_size(fs, layout.wrap_width.map(|w| w * scale), Some(layout.box_height * scale));
 +
 +    let align = match layout.align_h {
 +        AlignH::Left => cosmic_text::Align::Left,
 +        AlignH::Center => cosmic_text::Align::Center,
 +        AlignH::Right => cosmic_text::Align::Right,
 +    };
 +    for line in &mut buf.lines {
 +        line.set_align(Some(align));
 +    }
 +    buf.shape_until_scroll(fs, true);
 +
 +    // Vertical offset (logical) from the shaped run count, matching the legacy per-app math.
 +    let runs = buf.layout_runs().count();
 +    let total_h = runs as f32 * font_size * 1.4;
 +    let voff = match layout.align_v {
 +        AlignV::Top => 0.0,
 +        AlignV::Middle => ((layout.box_height - total_h) / 2.0).max(0.0),
 +        AlignV::Bottom => (layout.box_height - total_h).max(0.0),
 +    };
++    let buf = Rc::new(buf);
++    buffer_cache_put(text, &key, Rc::clone(&buf), voff);
 +    (buf, voff)
 +}
 +
 +/// A text item's clip rect in physical pixels. This was `glyphon::TextBounds` — the one
 +/// glyphon-owned type cce-ui ever used, everything else being a cosmic-text re-export — so
 +/// it is defined here now that the dependency is cosmic-text directly. Same plain
 +/// four-`i32` layout; it is only an intermediate on the way to `TextSpan::bounds`.
 +#[derive(Clone, Copy, Debug, Eq, PartialEq)]
 +pub struct TextBounds {
 +    pub left: i32,
 +    pub top: i32,
 +    pub right: i32,
 +    pub bottom: i32,
 +}
 +
++/// A display-list text prim ready for the glyph pass: a [`TextItem`](crate::widget::TextItem) whose
++/// buffer is the cache's own, shared rather than copied. Public so a shell
++/// outside the crate can hold what [`build_frame`](super::frame::build_frame)
++/// fills; its fields are the frame's own.
++pub struct DlText {
++    pub(crate) buffer: Rc<Buffer>,
++    pub(crate) x: f32,
++    pub(crate) y: f32,
++    pub(crate) color: cosmic_text::Color,
++    pub(crate) bounds: Option<[f32; 4]>,
++    pub(crate) clip_circle: Option<[f32; 3]>,
++    pub(crate) clip_rrect: Option<[f32; 5]>,
++}
++
 +/// The display list's Text prims, shaped through the shared buffer cache and
 +/// held for the glyph pass (the [`TextSpan`]s built by [`dl_text_spans`] borrow
 +/// these). Clip = the paint walk's item clip ∩ the prim's own bounds, in
 +/// logical space. Shared by the window's frame and the context-menu popup's.
- pub(crate) fn collect_dl_text(fs: &mut FontSystem, dl: &crate::scene::paint::DisplayList, out: &mut Vec<TextItem>) {
++pub(crate) fn collect_dl_text(fs: &mut FontSystem, dl: &crate::scene::paint::DisplayList, out: &mut Vec<DlText>) {
 +    for item in &dl.items {
 +        if let crate::scene::paint::Prim::Text { text, x, y, font_size, color, alpha, font, bounds, attrs, layout } = &item.prim {
 +            let clip = item.clip.map(|c| [c.x, c.y, c.x + c.width, c.y + c.height]);
 +            let merged = match (clip, *bounds) {
 +                (Some(a), Some(b)) => Some([a[0].max(b[0]), a[1].max(b[1]), a[2].min(b[2]), a[3].min(b[3])]),
 +                (Some(a), None) => Some(a),
 +                (None, b) => b,
 +            };
-             // Boxed text (wrap/align) shapes uncached and shifts down by the vertical
-             // offset; ordinary labels take the shared cached buffer.
++            // Boxed text (wrap/align) is laid out in its box and shifts down by the
++            // vertical offset; ordinary labels are a single run. Both cached.
 +            let (buffer, y_off) = match layout {
-                 Some(l) => get_text_buffer_laid_out(fs, text, *font_size, font.as_deref(), *attrs, *l),
-                 None => (get_text_buffer_attrs(fs, text, *font_size, font.as_deref(), *attrs), 0.0),
++                Some(l) => shared_laid_out_buffer(fs, text, *font_size, font.as_deref(), *attrs, *l),
++                None => (shared_text_buffer(fs, text, *font_size, font.as_deref(), *attrs), 0.0),
 +            };
-             out.push(TextItem {
++            out.push(DlText {
 +                buffer,
 +                x: *x,
 +                y: *y + y_off,
 +                color: cosmic_text::Color::rgba(
 +                    color[0],
 +                    color[1],
 +                    color[2],
 +                    (alpha.clamp(0.0, 1.0) * 255.0).round() as u8,
 +                ),
 +                bounds: merged,
 +                clip_circle: item.clip_circle,
 +                clip_rrect: item.clip_rrect,
 +            });
 +        }
 +    }
 +}
 +
 +/// The glyph pass's spans for `items`: each clamped to the surface and its
 +/// own bounds, then by the popover-occlusion clamp against `overlays`.
 +pub(crate) fn dl_text_spans<'a>(
-     items: &'a [TextItem],
++    items: &'a [DlText],
 +    scale_f32: f32,
 +    bounds: TextBounds,
 +    overlays: &[(f32, f32, f32, f32)],
 +) -> Vec<TextSpan<'a>> {
 +    let mut spans: Vec<TextSpan<'a>> = Vec::new();
 +    for ti in items {
 +        let mut item_bounds = if let Some([l, t, r, b]) = ti.bounds {
 +            TextBounds {
 +                left: ((l * scale_f32).round() as i32).clamp(0, bounds.right),
 +                top: ((t * scale_f32).round() as i32).clamp(0, bounds.bottom),
 +                right: ((r * scale_f32).round() as i32).clamp(0, bounds.right),
 +                bottom: ((b * scale_f32).round() as i32).clamp(0, bounds.bottom),
 +            }
 +        } else {
 +            bounds
 +        };
 +        popover_occlusion_clamp(overlays, ti, scale_f32, &mut item_bounds);
 +        spans.push(TextSpan {
 +            buffer: &ti.buffer,
 +            left: (ti.x * scale_f32).round(),
 +            top: (ti.y * scale_f32).round(),
 +            // Buffers are shaped at physical size (get_text_buffer_attrs).
 +            scale: 1.0,
 +            bounds: Some([
 +                item_bounds.left,
 +                item_bounds.top,
 +                item_bounds.right,
 +                item_bounds.bottom,
 +            ]),
 +            default_color: [
 +                ti.color.r() as f32 / 255.0,
 +                ti.color.g() as f32 / 255.0,
 +                ti.color.b() as f32 / 255.0,
 +                ti.color.a() as f32 / 255.0,
 +            ],
 +            rotation: None,
 +            // Circle wins when both are set (the circular pane's innermost clip);
 +            // otherwise a rounded-rect clip rides as center+radius with extents.
 +            clip_circle: match (ti.clip_circle, ti.clip_rrect) {
 +                (Some(c), _) => [c[0] * scale_f32, c[1] * scale_f32, c[2] * scale_f32],
 +                (None, Some(rr)) => [rr[0] * scale_f32, rr[1] * scale_f32, rr[4] * scale_f32],
 +                (None, None) => [0.0; 3],
 +            },
 +            clip_extents: match (ti.clip_circle, ti.clip_rrect) {
 +                (None, Some(rr)) => [rr[2] * scale_f32, rr[3] * scale_f32],
 +                _ => [0.0; 2],
 +            },
 +        });
 +    }
 +    spans
 +}
 +
 +/// The popover-occlusion clamp shared by the default [`Application::text_areas`] mapping and
 +/// the display-list text path: clip a text item's bounds so it does not bleed through an open
 +/// popover's plate. A text item whose own bounds coincide with a popover rect IS that popover's
 +/// text and is left alone; anything else that intersects gets clamped horizontally toward
 +/// whichever side of the popover it starts on.
 +/// Clamp a text item's bounds away from the registered popover rects it
 +/// runs under, so page text does not bleed through a floating plate.
 +///
 +/// A text item BELONGS to a popover when it carries exactly that popover's
 +/// rect as its bounds (the convention every popover's own labels follow),
 +/// and it is then clamped only against the popovers registered AFTER its
 +/// own — `overlay_rects` is in stacking order, the shared context menu
 +/// last. Before 2026-09-22 a popover's text was exempt from its own rect
 +/// alone and clamped against every other, so a context menu opened over a
 +/// modal dialog had its labels clipped by the dialog it was drawn on top
 +/// of, and showed as a plate with no legible entries.
 +fn popover_occlusion_clamp(
 +    overlay_rects: &[(f32, f32, f32, f32)],
-     ti: &TextItem,
++    ti: &DlText,
 +    scale_f32: f32,
 +    item_bounds: &mut TextBounds,
 +) {
 +    let owner = ti.bounds.and_then(|[l, t, r, b]| {
 +        overlay_rects.iter().position(|&(ox, oy, ow, oh)| {
 +            (l - ox).abs() < 1.0
 +                && (t - oy).abs() < 1.0
 +                && (r - (ox + ow)).abs() < 1.0
 +                && (b - (oy + oh)).abs() < 1.0
 +        })
 +    });
 +    let first_above = owner.map_or(0, |k| k + 1);
 +    for &(ox, oy, ow, oh) in &overlay_rects[first_above..] {
 +        let ol = (ox * scale_f32).round() as i32;
 +        let ot = (oy * scale_f32).round() as i32;
 +        let or = ((ox + ow) * scale_f32).round() as i32;
 +        let ob = ((oy + oh) * scale_f32).round() as i32;
 +
 +        let tx_pixel = ti.x * scale_f32;
 +        let ty_pixel = ti.y * scale_f32;
 +
 +        let mut text_w = 0.0f32;
 +        let mut run_count = 0;
 +        for run in ti.buffer.layout_runs() {
 +            text_w = text_w.max(run.line_w);
 +            run_count += 1;
 +        }
 +        let text_h = run_count as f32 * ti.buffer.metrics().line_height;
 +
 +        let actual_left = tx_pixel;
 +        let actual_right = tx_pixel + text_w;
 +        let actual_top = ty_pixel;
 +        let actual_bottom = ty_pixel + text_h;
 +
 +        if actual_left < or as f32
 +            && actual_right > ol as f32
 +            && actual_top < ob as f32
 +            && actual_bottom > ot as f32
 +        {
 +            if tx_pixel < ol as f32 {
 +                item_bounds.right = item_bounds.right.min(ol);
 +            } else {
 +                item_bounds.left = item_bounds.left.max(or);
 +            }
 +        }
 +    }
 +}
++
++#[cfg(test)]
++mod text_cache_tests {
++    use super::*;
++    use crate::scene::paint::{AlignH, AlignV, TextAttrs, TextLayout};
++
++    fn boxed(wrap: f32) -> TextLayout {
++        TextLayout { wrap_width: Some(wrap), box_height: 80.0, align_h: AlignH::Center, align_v: AlignV::Middle }
++    }
++
++    /// A hit hands back the cached buffer itself: the frame used to deep-copy
++    /// every text prim's shaped buffer, every frame.
++    #[test]
++    fn a_hit_shares_the_buffer_rather_than_copying_it() {
++        let mut fs = crate::geometry_font_system().lock().unwrap();
++        let attrs = TextAttrs::default();
++        let a = shared_text_buffer(&mut fs, "shared run", 14.0, Some("monospace"), attrs);
++        let b = shared_text_buffer(&mut fs, "shared run", 14.0, Some("monospace"), attrs);
++        assert!(Rc::ptr_eq(&a, &b));
++        let bold = shared_text_buffer(&mut fs, "shared run", 14.0, Some("monospace"), TextAttrs { weight: Some(700), ..attrs });
++        assert!(!Rc::ptr_eq(&a, &bold), "attrs are part of the key");
++        let sized = shared_text_buffer(&mut fs, "shared run", 14.0, Some("monospace 18"), attrs);
++        assert!(!Rc::ptr_eq(&a, &sized), "a size in the font string is part of the key");
++    }
++
++    /// Boxed text is cached by its box, and a hit is what a fresh layout of
++    /// the same box would be.
++    #[test]
++    fn a_laid_out_buffer_is_cached_by_its_box() {
++        let mut fs = crate::geometry_font_system().lock().unwrap();
++        let text = "a line long enough to wrap inside a narrow box";
++        let attrs = TextAttrs::default();
++        let (a, va) = shared_laid_out_buffer(&mut fs, text, 14.0, None, attrs, boxed(90.0));
++        let (b, vb) = shared_laid_out_buffer(&mut fs, text, 14.0, None, attrs, boxed(90.0));
++        assert!(Rc::ptr_eq(&a, &b));
++        assert_eq!(va, vb);
++        let (wide, _) = shared_laid_out_buffer(&mut fs, text, 14.0, None, attrs, boxed(400.0));
++        assert!(!Rc::ptr_eq(&a, &wide), "a different box is a different layout");
++        let single = shared_text_buffer(&mut fs, text, 14.0, None, attrs);
++        assert!(!Rc::ptr_eq(&a, &single), "a box never answers for the single run");
++
++        // The cached layout against one shaped from nothing.
++        BUFFER_CACHE.with(|c| c.borrow_mut().clear());
++        BUFFER_COUNT.with(|c| c.set(0));
++        let (fresh, vf) = shared_laid_out_buffer(&mut fs, text, 14.0, None, attrs, boxed(90.0));
++        assert!(!Rc::ptr_eq(&a, &fresh));
++        assert_eq!(va, vf);
++        let runs = |b: &Buffer| b.layout_runs().map(|r| (r.line_y, r.line_w, r.glyphs.len())).collect::<Vec<_>>();
++        assert_eq!(runs(&a), runs(&fresh));
++    }
++
++    /// The cache holds at most `BUFFER_CAP` buffers, and eviction takes the
++    /// least recently used, not the oldest inserted.
++    #[test]
++    fn eviction_keeps_the_cap_and_the_recently_used() {
++        BUFFER_CACHE.with(|c| c.borrow_mut().clear());
++        BUFFER_COUNT.with(|c| c.set(0));
++        let key = |size_milli| BufferKey {
++            size_milli,
++            font: None,
++            is_vertical: false,
++            attrs: TextAttrs::default(),
++            scale_bits: 1.0f32.to_bits(),
++            layout: None,
++        };
++        let empty = || Rc::new(Buffer::new_empty(Metrics::new(10.0, 10.0)));
++        for i in 0..BUFFER_CAP as u32 {
++            buffer_cache_put(&format!("t{i}"), &key(i), empty(), 0.0);
++        }
++        // The first inserted is touched, so it is no longer the least recent.
++        assert!(buffer_cache_get("t0", &key(0)).is_some());
++        buffer_cache_put("over", &key(0), empty(), 0.0);
++        let count = BUFFER_CACHE.with(|c| c.borrow().values().map(Vec::len).sum::<usize>());
++        assert_eq!(count, BUFFER_CAP - BUFFER_EVICT + 1);
++        assert_eq!(BUFFER_COUNT.with(|c| c.get()), count);
++        assert!(buffer_cache_get("t0", &key(0)).is_some(), "recently used survives");
++        assert!(buffer_cache_get("t1", &key(1)).is_none(), "least recently used goes");
++        assert!(buffer_cache_get("over", &key(0)).is_some());
++    }
++}
diff --cc src/backend/touch.rs
index 0000000,06042d2..78ba7b4
mode 000000,100644..100644
--- a/src/backend/touch.rs
+++ b/src/backend/touch.rs
@@@ -1,0 -1,390 +1,327 @@@
+ // Touchscreen input (wl_touch).
+ //
+ // A cce-ui app has no touch widgets: everything it knows is a pointer that
+ // hovers, presses, drags and scrolls. So a finger is translated into those,
+ // and which one it becomes is decided by what the finger does, the way every
+ // touch surface decides it:
+ //
+ // - a TAP (down and up without leaving the slop) is a click where it landed;
+ // - a finger that MOVES past the slop scrolls whatever is under it, content
+ //   following the finger, and the lift is a trackpad lift — a flick coasts
+ //   through the same `ScrollMotion` a two-finger trackpad swipe does;
+ // - a finger HELD still for `HOLD_MS` and then moved is a held left button:
+ //   a slider's thumb, a text selection, a drag-and-drop source.
+ //
+ // No timer is needed for the hold: nothing is sent while the finger rests
+ // inside the slop, so the decision is only taken at the first motion past
+ // it, from how long the finger had been down by then.
+ //
+ // Only the first finger is followed; others are ignored until it lifts.
+ // Before this module the compositor drove every cce-ui window by touch with
+ // an emulated pointer, so a finger drag was a held button and selected
+ // rather than scrolled. Binding wl_touch is what moves cce-ui windows onto
+ // the compositor's touch route (cce-compositor's `cursor::TouchRoute`).
+ //
+ // Not here: window moves and CSD resizes by finger. A touch serial does not
+ // satisfy the compositor's pointer-grab check, so `xdg_toplevel.move` from a
+ // touch would be refused. Overview and the Super-held adjust mode, where the
+ // compositor drives the pointer itself, move windows by finger instead.
 -
 -use smithay_client_toolkit::reexports::client::protocol::{wl_surface::WlSurface, wl_touch::WlTouch};
 -use smithay_client_toolkit::reexports::client::{Connection, QueueHandle};
 -use smithay_client_toolkit::seat::touch::TouchHandler;
 -
 -use super::window_runner::{Application, EngineState, LogicalPosition};
 -use crate::widget::{ElementState, MouseButton, MouseScrollDelta, Position, ScrollPhase};
++//
++// The tracker is platform-neutral, and what its actions do is the driver's
++// (`Driver::touch`); only the `wl_touch` binding below is Wayland's.
+ 
+ /// Travel (logical px) a finger may wander and still be a tap or a hold.
+ pub const SLOP: f32 = 10.0;
+ /// How long a finger must rest before moving for the motion to be a drag
+ /// rather than a scroll.
+ pub const HOLD_MS: u32 = 400;
+ 
+ /// What a touch asks of the app, in pointer terms.
+ #[derive(Debug, Clone, Copy, PartialEq)]
+ pub enum TouchAction {
+     /// Move the hover to here (the finger arrived; nothing pressed yet).
+     Hover(f32, f32),
+     Press(f32, f32),
+     /// Motion with the button held.
+     Drag(f32, f32),
+     Release(f32, f32),
+     /// Scroll by this much finger travel, content following the finger.
+     Scroll(f32, f32),
+     /// The scrolling finger lifted: may coast.
+     ScrollEnd,
+     /// The finger is gone; no hover is left behind.
+     Leave,
+ }
+ 
+ #[derive(Debug, Clone, Copy, PartialEq)]
+ enum Phase {
+     /// Down, inside the slop: still a tap or a hold.
+     Pending,
+     Scrolling,
+     Dragging,
+ }
+ 
+ #[derive(Debug, Clone, Copy)]
+ struct Finger {
+     id: i32,
+     phase: Phase,
+     start: (f32, f32),
+     last: (f32, f32),
+     down_ms: u32,
+ }
+ 
+ /// The gesture of the one finger being followed. Pure: it maps touch events
+ /// to `TouchAction`s and holds no Wayland state, so it is tested directly.
+ #[derive(Debug, Default)]
+ pub struct TouchTracker {
+     finger: Option<Finger>,
+ }
+ 
+ impl TouchTracker {
+     pub fn down(&mut self, id: i32, x: f32, y: f32, time_ms: u32) -> Vec<TouchAction> {
+         if self.finger.is_some() {
+             return Vec::new();
+         }
+         self.finger = Some(Finger { id, phase: Phase::Pending, start: (x, y), last: (x, y), down_ms: time_ms });
+         vec![TouchAction::Hover(x, y)]
+     }
+ 
+     pub fn motion(&mut self, id: i32, x: f32, y: f32, time_ms: u32) -> Vec<TouchAction> {
+         let Some(f) = self.finger.as_mut().filter(|f| f.id == id) else { return Vec::new() };
+         let prev = f.last;
+         f.last = (x, y);
+         match f.phase {
+             Phase::Pending => {
+                 let (dx, dy) = (x - f.start.0, y - f.start.1);
+                 if dx.hypot(dy) < SLOP {
+                     return Vec::new();
+                 }
+                 if time_ms.wrapping_sub(f.down_ms) >= HOLD_MS {
+                     f.phase = Phase::Dragging;
+                     vec![TouchAction::Press(f.start.0, f.start.1), TouchAction::Drag(x, y)]
+                 } else {
+                     // The whole travel from the down point, not just past the
+                     // slop: the content stays under the finger.
+                     f.phase = Phase::Scrolling;
+                     vec![TouchAction::Scroll(dx, dy)]
+                 }
+             }
+             Phase::Scrolling => {
+                 let (dx, dy) = (x - prev.0, y - prev.1);
+                 if dx == 0.0 && dy == 0.0 {
+                     Vec::new()
+                 } else {
+                     vec![TouchAction::Scroll(dx, dy)]
+                 }
+             }
+             Phase::Dragging => vec![TouchAction::Drag(x, y)],
+         }
+     }
+ 
+     pub fn up(&mut self, id: i32) -> Vec<TouchAction> {
+         if self.finger.map_or(true, |f| f.id != id) {
+             return Vec::new();
+         }
+         let f = self.finger.take().unwrap();
+         match f.phase {
+             Phase::Pending => vec![
+                 TouchAction::Press(f.start.0, f.start.1),
+                 TouchAction::Release(f.start.0, f.start.1),
+                 TouchAction::Leave,
+             ],
+             Phase::Scrolling => vec![TouchAction::ScrollEnd, TouchAction::Leave],
+             Phase::Dragging => vec![TouchAction::Release(f.last.0, f.last.1), TouchAction::Leave],
+         }
+     }
+ 
+     /// The compositor took the sequence back (`wl_touch.cancel`). A tap that
+     /// never became anything clicks nothing; a held button is released where
+     /// it is, as a pointer leaving mid-drag is (`PointerEventKind::Leave`).
+     pub fn cancel(&mut self) -> Vec<TouchAction> {
+         let Some(f) = self.finger.take() else { return Vec::new() };
+         match f.phase {
+             Phase::Pending => vec![TouchAction::Leave],
+             Phase::Scrolling => vec![TouchAction::ScrollEnd, TouchAction::Leave],
+             Phase::Dragging => vec![TouchAction::Release(f.last.0, f.last.1), TouchAction::Leave],
+         }
+     }
+ 
+     pub fn id(&self) -> Option<i32> {
+         self.finger.map(|f| f.id)
+     }
+ }
+ 
 -impl<A: Application> EngineState<A> {
 -    /// A surface-local touch position in the app's window coordinates: the
 -    /// forced-scale divide and the menu popup's offset, as the pointer path
 -    /// applies them.
 -    fn touch_pos(&self, (x, y): (f64, f64)) -> (f32, f32) {
 -        let forced = crate::scale::forced_scale().unwrap_or(1.0);
 -        let (ox, oy) = self.touch_offset;
 -        (x as f32 / forced + ox, y as f32 / forced + oy)
 -    }
++/// The `wl_touch` binding: a finger's events, in the window's coordinates,
++/// through the tracker and on to the driver.
++#[cfg(not(any(target_arch = "wasm32", target_os = "macos")))]
++mod wayland {
++    use smithay_client_toolkit::reexports::client::protocol::{wl_surface::WlSurface, wl_touch::WlTouch};
++    use smithay_client_toolkit::reexports::client::{Connection, QueueHandle};
++    use smithay_client_toolkit::seat::touch::TouchHandler;
+ 
 -    /// The seat stopped offering touch: end the finger's gesture as a
 -    /// cancel would, and drop the object.
 -    pub(crate) fn touch_lost(&mut self) {
 -        if let Some(touch) = self.touch.take() {
 -            touch.release();
 -        }
 -        self.cancel_touch();
 -    }
++    use super::TouchAction;
++    use crate::backend::shell::Shell;
++    use crate::backend::window_runner::{Application, EngineState};
+ 
 -    fn cancel_touch(&mut self) {
 -        let actions = self.touch_tracker.cancel();
 -        self.run_touch_actions(actions);
 -        self.touch_scroll_at = None;
 -    }
++    impl<A: Application> EngineState<A> {
++        /// A surface-local touch position in the app's window coordinates: the
++        /// forced-scale divide and the menu popup's offset, as the pointer path
++        /// applies them.
++        fn touch_pos(&self, (x, y): (f64, f64)) -> (f32, f32) {
++            let forced = crate::scale::forced_scale().unwrap_or(1.0);
++            let (ox, oy) = self.touch_offset;
++            (x as f32 / forced + ox, y as f32 / forced + oy)
++        }
+ 
 -    fn run_touch_actions(&mut self, actions: Vec<TouchAction>) {
 -        for action in actions {
 -            let mut rebuild = false;
 -            match action {
 -                TouchAction::Hover(x, y) | TouchAction::Drag(x, y) => {
 -                    self.inner.as_mut().unwrap().handle_pointer_move(LogicalPosition::new(x, y), &mut rebuild);
 -                }
 -                TouchAction::Leave => {
 -                    self.inner.as_mut().unwrap().handle_pointer_move(LogicalPosition::new(-10000.0, -10000.0), &mut rebuild);
 -                }
 -                TouchAction::Press(x, y) => {
 -                    // Outside-press close for open popovers, as the pointer's
 -                    // press does before the app's own dispatch.
 -                    let app = self.inner.as_mut().unwrap();
 -                    let offsets: Vec<_> = app
 -                        .ui_context()
 -                        .map(|ctx| ctx.popover_owners())
 -                        .unwrap_or_default()
 -                        .into_iter()
 -                        .map(|id| (id, app.popover_offset(id)))
 -                        .collect();
 -                    if let Some(ctx) = app.ui_context_mut() {
 -                        ctx.close_popovers_missed_by_press_with(x, y, |id| {
 -                            offsets.iter().find(|(o, _)| *o == id).map_or((0.0, 0.0), |&(_, d)| d)
 -                        });
 -                    }
 -                    self.touch_button(ElementState::Pressed, x, y, &mut rebuild);
 -                }
 -                TouchAction::Release(x, y) => self.touch_button(ElementState::Released, x, y, &mut rebuild),
 -                TouchAction::Scroll(dx, dy) => {
 -                    self.touch_wheel(ScrollPhase::Finger, dx, dy, &mut rebuild);
 -                }
 -                TouchAction::ScrollEnd => self.touch_wheel(ScrollPhase::FingerEnd, 0.0, 0.0, &mut rebuild),
 -            }
 -            if rebuild {
 -                self.redraw = true;
++        /// The seat stopped offering touch: end the finger's gesture as a
++        /// cancel would, and drop the object.
++        pub(crate) fn touch_lost(&mut self) {
++            if let Some(touch) = self.touch.take() {
++                touch.release();
+             }
++            self.cancel_touch();
+         }
 -        // A press may queue an app-driven window move; it cannot be honoured
 -        // from a touch (see the module comment), and left queued it would
 -        // run on the next pointer press with that press's serial.
 -        let _ = self.inner.as_mut().unwrap().take_window_action();
 -    }
+ 
 -    fn touch_button(&mut self, state: ElementState, x: f32, y: f32, rebuild: &mut bool) {
 -        let app = self.inner.as_mut().unwrap();
 -        if let Some(msg) = app.handle_mouse_input(MouseButton::Left, state, LogicalPosition::new(x, y), rebuild) {
 -            let mut update_rebuild = false;
 -            app.update(msg, &mut update_rebuild, &mut self.exit);
 -            *rebuild |= update_rebuild;
++        fn cancel_touch(&mut self) {
++            let actions = self.touch_tracker.cancel();
++            self.run_touch_actions(actions);
++            self.touch_scroll_at = None;
+         }
 -    }
+ 
 -    /// Finger travel as a trackpad pixel scroll. A finger is always
 -    /// "natural" — the content goes where it is pushed — so the dispatch runs
 -    /// with natural scrolling on whatever the trackpad's setting, and a value
 -    /// control (`MouseScrollDelta::value_notches_y`) reads the finger's real
 -    /// direction. 1:1, without the trackpad's per-app factor: the content
 -    /// stays under the finger.
 -    fn touch_wheel(&mut self, phase: ScrollPhase, dx: f32, dy: f32, rebuild: &mut bool) {
 -        let Some((x, y)) = self.touch_scroll_at else { return };
 -        crate::widget::scroll_motion::set_scroll_phase(phase);
 -        let delta = MouseScrollDelta::PixelDelta(Position { x: dx as f64, y: dy as f64 });
 -        if crate::scroll_debug() {
 -            eprintln!("[scroll] touch: phase={phase:?} -> {delta:?} at ({x:.0},{y:.0})");
++        fn run_touch_actions(&mut self, actions: Vec<TouchAction>) {
++            let scroll_at = self.touch_scroll_at;
++            let (driver, t) = self.turn();
++            driver.touch(t, actions, scroll_at);
+         }
 -        if let Some(ctx) = self.inner.as_mut().unwrap().ui_context_mut() {
 -            ctx.ctrl_pressed = self.ctrl_pressed;
 -            ctx.shift_pressed = self.shift_pressed;
 -            ctx.alt_pressed = self.alt_pressed;
 -            ctx.logo_pressed = self.logo_pressed;
 -        }
 -        let app = self.inner.as_mut().unwrap();
 -        crate::input::with_natural_scroll(true, || {
 -            app.handle_mouse_wheel(&delta, LogicalPosition::new(x, y), rebuild);
 -        });
+     }
 -}
+ 
 -impl<A: Application> TouchHandler for EngineState<A> {
 -    fn down(
 -        &mut self,
 -        _conn: &Connection,
 -        _qh: &QueueHandle<Self>,
 -        _touch: &WlTouch,
 -        _serial: u32,
 -        time: u32,
 -        surface: WlSurface,
 -        id: i32,
 -        position: (f64, f64),
 -    ) {
 -        if self.touch_tracker.id().is_some() {
 -            return;
++    impl<A: Application> TouchHandler for EngineState<A> {
++        fn down(
++            &mut self,
++            _conn: &Connection,
++            _qh: &QueueHandle<Self>,
++            _touch: &WlTouch,
++            _serial: u32,
++            time: u32,
++            surface: WlSurface,
++            id: i32,
++            position: (f64, f64),
++        ) {
++            if self.touch_tracker.id().is_some() {
++                return;
++            }
++            // An event on the context menu's popup surface is the app's too, at
++            // the popup's offset from the window; fixed for the finger's life,
++            // since every later event of it is about the same surface.
++            self.touch_offset = self.menu_popup_offset(&surface).unwrap_or((0.0, 0.0));
++            let (x, y) = self.touch_pos(position);
++            // A scroll is dispatched where the finger went down: the gesture
++            // belongs to what it began on, as a trackpad scroll does
++            // (`scroll_initiate_widget_id`), however far the content moves.
++            self.touch_scroll_at = Some((x, y));
++            let actions = self.touch_tracker.down(id, x, y, time);
++            self.run_touch_actions(actions);
+         }
 -        // An event on the context menu's popup surface is the app's too, at
 -        // the popup's offset from the window; fixed for the finger's life,
 -        // since every later event of it is about the same surface.
 -        self.touch_offset = self.menu_popup_offset(&surface).unwrap_or((0.0, 0.0));
 -        let (x, y) = self.touch_pos(position);
 -        // A scroll is dispatched where the finger went down: the gesture
 -        // belongs to what it began on, as a trackpad scroll does
 -        // (`scroll_initiate_widget_id`), however far the content moves.
 -        self.touch_scroll_at = Some((x, y));
 -        let actions = self.touch_tracker.down(id, x, y, time);
 -        self.run_touch_actions(actions);
 -    }
+ 
 -    fn up(&mut self, _conn: &Connection, _qh: &QueueHandle<Self>, _touch: &WlTouch, _serial: u32, _time: u32, id: i32) {
 -        let actions = self.touch_tracker.up(id);
 -        self.run_touch_actions(actions);
 -        if self.touch_tracker.id().is_none() {
 -            self.touch_scroll_at = None;
++        fn up(&mut self, _conn: &Connection, _qh: &QueueHandle<Self>, _touch: &WlTouch, _serial: u32, _time: u32, id: i32) {
++            let actions = self.touch_tracker.up(id);
++            self.run_touch_actions(actions);
++            if self.touch_tracker.id().is_none() {
++                self.touch_scroll_at = None;
++            }
+         }
 -    }
+ 
 -    fn motion(&mut self, _conn: &Connection, _qh: &QueueHandle<Self>, _touch: &WlTouch, time: u32, id: i32, position: (f64, f64)) {
 -        if self.touch_tracker.id() != Some(id) {
 -            return;
++        fn motion(&mut self, _conn: &Connection, _qh: &QueueHandle<Self>, _touch: &WlTouch, time: u32, id: i32, position: (f64, f64)) {
++            if self.touch_tracker.id() != Some(id) {
++                return;
++            }
++            let (x, y) = self.touch_pos(position);
++            let actions = self.touch_tracker.motion(id, x, y, time);
++            self.run_touch_actions(actions);
+         }
 -        let (x, y) = self.touch_pos(position);
 -        let actions = self.touch_tracker.motion(id, x, y, time);
 -        self.run_touch_actions(actions);
 -    }
+ 
 -    fn shape(&mut self, _: &Connection, _: &QueueHandle<Self>, _: &WlTouch, _: i32, _: f64, _: f64) {}
++        fn shape(&mut self, _: &Connection, _: &QueueHandle<Self>, _: &WlTouch, _: i32, _: f64, _: f64) {}
+ 
 -    fn orientation(&mut self, _: &Connection, _: &QueueHandle<Self>, _: &WlTouch, _: i32, _: f64) {}
++        fn orientation(&mut self, _: &Connection, _: &QueueHandle<Self>, _: &WlTouch, _: i32, _: f64) {}
+ 
 -    fn cancel(&mut self, _conn: &Connection, _qh: &QueueHandle<Self>, _touch: &WlTouch) {
 -        self.cancel_touch();
++        fn cancel(&mut self, _conn: &Connection, _qh: &QueueHandle<Self>, _touch: &WlTouch) {
++            self.cancel_touch();
++        }
+     }
 -}
+ 
 -smithay_client_toolkit::delegate_touch!(@<A: Application> EngineState<A>);
++    smithay_client_toolkit::delegate_touch!(@<A: Application> EngineState<A>);
++}
+ 
+ #[cfg(test)]
+ mod tests {
+     use super::*;
+     use TouchAction::*;
+ 
+     #[test]
+     fn a_tap_clicks_where_it_landed() {
+         let mut t = TouchTracker::default();
+         assert_eq!(t.down(1, 50.0, 60.0, 1000), vec![Hover(50.0, 60.0)]);
+         // Jitter inside the slop is nothing yet.
+         assert!(t.motion(1, 53.0, 62.0, 1050).is_empty());
+         assert_eq!(t.up(1), vec![Press(50.0, 60.0), Release(50.0, 60.0), Leave]);
+         assert_eq!(t.id(), None);
+     }
+ 
+     #[test]
+     fn a_quick_drag_scrolls_with_the_finger_and_coasts_on_the_lift() {
+         let mut t = TouchTracker::default();
+         t.down(1, 100.0, 300.0, 0);
+         // Past the slop: the whole travel from the down point.
+         assert_eq!(t.motion(1, 100.0, 280.0, 80), vec![Scroll(0.0, -20.0)]);
+         assert_eq!(t.motion(1, 95.0, 250.0, 100), vec![Scroll(-5.0, -30.0)]);
+         // A repeated position is not an empty scroll.
+         assert!(t.motion(1, 95.0, 250.0, 110).is_empty());
+         assert_eq!(t.up(1), vec![ScrollEnd, Leave]);
+     }
+ 
+     #[test]
+     fn a_hold_then_move_is_a_held_button_drag() {
+         let mut t = TouchTracker::default();
+         t.down(1, 10.0, 10.0, 5000);
+         assert!(t.motion(1, 12.0, 10.0, 5200).is_empty());
+         assert_eq!(t.motion(1, 40.0, 10.0, 5000 + HOLD_MS), vec![Press(10.0, 10.0), Drag(40.0, 10.0)]);
+         assert_eq!(t.motion(1, 60.0, 15.0, 5600), vec![Drag(60.0, 15.0)]);
+         assert_eq!(t.up(1), vec![Release(60.0, 15.0), Leave]);
+     }
+ 
+     #[test]
+     fn the_hold_survives_the_clock_wrapping() {
+         let mut t = TouchTracker::default();
+         t.down(1, 0.0, 0.0, u32::MAX - 100);
+         assert_eq!(t.motion(1, 30.0, 0.0, HOLD_MS)[0], Press(0.0, 0.0));
+     }
+ 
+     #[test]
+     fn only_the_first_finger_is_followed() {
+         let mut t = TouchTracker::default();
+         t.down(1, 0.0, 0.0, 0);
+         assert!(t.down(2, 100.0, 100.0, 10).is_empty());
+         assert!(t.motion(2, 300.0, 300.0, 20).is_empty());
+         assert!(t.up(2).is_empty());
+         assert_eq!(t.id(), Some(1));
+         // Once it lifts, a new finger is followed.
+         t.up(1);
+         assert_eq!(t.down(2, 5.0, 5.0, 30), vec![Hover(5.0, 5.0)]);
+     }
+ 
+     #[test]
+     fn a_cancel_clicks_nothing_and_releases_a_held_button() {
+         let mut t = TouchTracker::default();
+         t.down(1, 0.0, 0.0, 0);
+         assert_eq!(t.cancel(), vec![Leave]);
+ 
+         t.down(1, 0.0, 0.0, 0);
+         t.motion(1, 0.0, 50.0, 10);
+         assert_eq!(t.cancel(), vec![ScrollEnd, Leave]);
+ 
+         t.down(1, 0.0, 0.0, 0);
+         t.motion(1, 50.0, 0.0, HOLD_MS);
+         assert_eq!(t.cancel(), vec![Release(50.0, 0.0), Leave]);
+         assert!(t.cancel().is_empty());
+     }
+ }
diff --cc src/backend/window_runner.rs
index 4f558e1,0d72b4b..6c22cdf
--- a/src/backend/window_runner.rs
+++ b/src/backend/window_runner.rs
@@@ -27,33 -28,4090 +27,41 @@@ use wayland_client::
      Connection, QueueHandle, Proxy,
  };
  
 -use wayland_protocols::wp::pointer_gestures::zv1::client::{
 -    zwp_pointer_gesture_pinch_v1::{self, ZwpPointerGesturePinchV1},
 -    zwp_pointer_gestures_v1::{self as zwp_pointer_gestures, ZwpPointerGesturesV1},
 +use wayland_protocols::wp::text_input::zv3::client::{
 +    zwp_text_input_manager_v3::ZwpTextInputManagerV3,
 +    zwp_text_input_v3::{self, ZwpTextInputV3},
  };
 -pub use smithay_client_toolkit::reexports::protocols::xdg::shell::client::xdg_toplevel;
 -pub use smithay_client_toolkit::seat::pointer::CursorIcon as PointerCursorIcon;
 -use calloop::EventLoop;
 -use calloop_wayland_source::WaylandSource;
 -use cosmic_text::{FontSystem, Buffer, Attrs, Metrics};
 -use std::rc::Rc;
 -use crate::widget::{WidgetHost, MouseButton, ElementState, MouseScrollDelta, KeyEvent, Key, NamedKey, Position};
 -use crate::wayland::detect_scale_factor;
 -use crate::vk::{Batch2D, Frame2D, TextSpan, VkRenderer};
 -
 -/// One shaped buffer in the text cache, keyed by everything that shapes it
 -/// beyond its text (the text is the outer map's key, so a lookup borrows it
 -/// rather than allocating).
 -struct CachedBuffer {
 -    /// Physical font size, in thousandths of a px.
 -    size_milli: u32,
 -    /// The family as the font string names it, size stripped.
 -    font: Option<String>,
 -    is_vertical: bool,
 -    attrs: crate::scene::paint::TextAttrs,
 -    /// The scale factor's bits: a laid-out buffer's wrap width and box are
 -    /// logical px turned physical by it.
 -    scale_bits: u32,
 -    /// `Some` for a boxed [`Prim::Text`](crate::scene::paint::Prim::Text) laid
 -    /// out by [`get_text_buffer_laid_out`]; `None` for a single run.
 -    layout: Option<crate::scene::paint::TextLayout>,
 -    /// The laid-out buffer's vertical offset in its box (0 for a single run).
 -    voff: f32,
 -    /// Shared, not cloned, on a hit: a `Buffer` owns every shaped line and
 -    /// glyph, and the frame used to deep-copy one per text prim per frame.
 -    buffer: Rc<Buffer>,
 -    /// [`BUFFER_TICK`] at the last hit, for least-recently-used eviction.
 -    last_used: u64,
 -}
 -
 -impl CachedBuffer {
 -    fn matches(&self, k: &BufferKey<'_>) -> bool {
 -        self.size_milli == k.size_milli
 -            && self.font.as_deref() == k.font
 -            && self.is_vertical == k.is_vertical
 -            && self.attrs == k.attrs
 -            && self.scale_bits == k.scale_bits
 -            && self.layout == k.layout
 -    }
 -}
 -
 -/// A cache lookup's key, borrowed from the caller.
 -struct BufferKey<'a> {
 -    size_milli: u32,
 -    font: Option<&'a str>,
 -    is_vertical: bool,
 -    attrs: crate::scene::paint::TextAttrs,
 -    scale_bits: u32,
 -    layout: Option<crate::scene::paint::TextLayout>,
 -}
 -
 -/// The text cache holds at most this many buffers; past it the least
 -/// recently used [`BUFFER_EVICT`] go.
 -const BUFFER_CAP: usize = 2000;
 -const BUFFER_EVICT: usize = 100;
 -
 -std::thread_local! {
 -    /// Text → every shaped variant of it. Variants per text are few (a size,
 -    /// a weight, a box), so they are scanned rather than hashed.
 -    static BUFFER_CACHE: std::cell::RefCell<std::collections::HashMap<String, Vec<CachedBuffer>>> =
 -        std::cell::RefCell::new(std::collections::HashMap::new());
 -    static BUFFER_COUNT: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
 -    static BUFFER_TICK: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
 -}
 -
 -fn buffer_tick() -> u64 {
 -    BUFFER_TICK.with(|t| {
 -        let n = t.get() + 1;
 -        t.set(n);
 -        n
 -    })
 -}
 -
 -/// The cached buffer for `text` under `key`, and its vertical offset.
 -fn buffer_cache_get(text: &str, key: &BufferKey<'_>) -> Option<(Rc<Buffer>, f32)> {
 -    BUFFER_CACHE.with(|cache| {
 -        let mut cache = cache.borrow_mut();
 -        let hit = cache.get_mut(text)?.iter_mut().find(|e| e.matches(key))?;
 -        hit.last_used = buffer_tick();
 -        Some((Rc::clone(&hit.buffer), hit.voff))
 -    })
 -}
 -
 -fn buffer_cache_put(text: &str, key: &BufferKey<'_>, buffer: Rc<Buffer>, voff: f32) {
 -    BUFFER_CACHE.with(|cache| {
 -        let mut cache = cache.borrow_mut();
 -        if BUFFER_COUNT.with(|c| c.get()) >= BUFFER_CAP {
 -            let mut ticks: Vec<u64> = cache.values().flatten().map(|e| e.last_used).collect();
 -            ticks.sort_unstable();
 -            let cutoff = ticks[BUFFER_EVICT.min(ticks.len()) - 1];
 -            cache.retain(|_, v| {
 -                v.retain(|e| e.last_used > cutoff);
 -                !v.is_empty()
 -            });
 -            BUFFER_COUNT.with(|c| c.set(cache.values().map(Vec::len).sum()));
 -        }
 -        let entry = CachedBuffer {
 -            size_milli: key.size_milli,
 -            font: key.font.map(str::to_owned),
 -            is_vertical: key.is_vertical,
 -            attrs: key.attrs,
 -            scale_bits: key.scale_bits,
 -            layout: key.layout,
 -            voff,
 -            buffer,
 -            last_used: buffer_tick(),
 -        };
 -        match cache.get_mut(text) {
 -            Some(v) => v.push(entry),
 -            None => {
 -                cache.insert(text.to_owned(), vec![entry]);
 -            }
 -        }
 -        BUFFER_COUNT.with(|c| c.set(c.get() + 1));
 -    });
 -}
 -
 -/// The `CCE_PRESENT_DEBUG` traces' timestamp: wall-clock milliseconds, mod
 -/// 100 s, short enough to read down a column of lines.
 -fn debug_clock_ms() -> u128 {
 -    std::time::SystemTime::now()
 -        .duration_since(std::time::UNIX_EPOCH)
 -        .map_or(0, |d| d.as_millis() % 100_000)
 -}
 -
 -/// A droplet spec resolved against a concrete rect: the push-constant fields
 -/// that define its SILHOUETTE, in logical px.
 -///
 -/// Shared by [`crate::scene::paint::Prim::Droplet`] and
 -/// [`crate::scene::paint::Prim::DropletScrim`] so the lit drop and the vignette
 -/// drawn inside it can never disagree about the shape — the whole reason the
 -/// scrim rides the droplet's shader path instead of approximating the outline
 -/// with a rounded rect.
 -struct DropletGeom {
 -    hx: f32,
 -    hy: f32,
 -    sag: f32,
 -    br: f32,
 -    bw: f32,
 -    k: f32,
 -    sr: f32,
 -    ar: f32,
 -    band: f32,
 -    bow: f32,
 -    /// How far the contact shadow reaches below/beside the box (0 when the
 -    /// spec has no shadow). The lit drop's cover quad grows by this; a scrim
 -    /// never draws outside the silhouette and ignores it.
 -    sh_reach: f32,
 -}
 -
 -fn droplet_geom(rect: &crate::scene::layout::Rect, spec: &crate::scene::paint::DropletSpec) -> DropletGeom {
 -    let hx = rect.width * 0.5;
 -    let hy = rect.height * 0.5;
 -    let sag = spec.sag.clamp(0.0, 0.9) * rect.height;
 -    // belly <= 0 disables the belly outright (the oval-dewdrop default) — the
 -    // shader skips the smin when the radius is 0.
 -    let (br, bw) = if spec.belly > 0.0 {
 -        let br = (spec.belly.min(1.0) * rect.height).min(hy).min(hx);
 -        (br, ((hx - br).max(0.0) * spec.belly_w.clamp(0.0, 1.0)).max(1.0))
 -    } else {
 -        (0.0, 0.0)
 -    };
 -    let k = (spec.blend.max(0.0) * rect.height).max(1.0);
 -    let sheet_hy = hy - sag * 0.5;
 -    // Bottom (sheet_r) and top (attach) corner radii: when the pair overfills
 -    // the sheet height, scale both down proportionally — 0.5 + 0.5 is the
 -    // fully continuous egg.
 -    let mut sr = (spec.sheet_r.clamp(0.0, 1.0) * rect.height).min(hx);
 -    let mut ar = (spec.attach.clamp(0.0, 1.0) * rect.height).min(hx);
 -    let sheet_h = (2.0 * sheet_hy).max(0.0);
 -    if sr + ar > sheet_h && sr + ar > 0.0 {
 -        let f = sheet_h / (sr + ar);
 -        sr *= f;
 -        ar *= f;
 -    }
 -    let band = (spec.band.max(0.05) * rect.height).max(1.0);
 -    // Bottom-bow edge rise; the shader derives the arc radius from it per drop
 -    // (R = hx^2/2*rise).
 -    let bow = (spec.bow.clamp(0.0, 0.5) * rect.height).min(hy * 0.9);
 -    let sh_reach = if spec.shadow > 0.0 { (0.18 * rect.height).max(2.0) } else { 0.0 };
 -    DropletGeom { hx, hy, sag, br, bw, k, sr, ar, band, bow, sh_reach }
 -}
 -
 -fn find_cased_family(fs: &FontSystem, name: &str) -> Option<String> {
 -    let lower_name = name.to_lowercase();
 -    for face in fs.db().faces() {
 -        for (family, _) in &face.families {
 -            if family.to_lowercase() == lower_name {
 -                return Some(family.clone());
 -            }
 -        }
 -    }
 -    None
 -}
 -
 -thread_local! {
 -    /// Family name → is-monospaced, resolved once per family from fontdb's
 -    /// face metadata (the post table's isFixedPitch, as fontdb records it).
 -    static MONO_FAMILY_CACHE: std::cell::RefCell<std::collections::HashMap<String, bool>> =
 -        std::cell::RefCell::new(std::collections::HashMap::new());
 -}
 -
 -fn family_is_monospaced(fs: &FontSystem, name: &str) -> bool {
 -    MONO_FAMILY_CACHE.with(|cache| {
 -        if let Some(&mono) = cache.borrow().get(name) {
 -            return mono;
 -        }
 -        let lower = name.to_lowercase();
 -        let mono = fs
 -            .db()
 -            .faces()
 -            .find(|face| face.families.iter().any(|(f, _)| f.to_lowercase() == lower))
 -            .map(|face| face.monospaced)
 -            .unwrap_or(false);
 -        cache.borrow_mut().insert(name.to_string(), mono);
 -        mono
 -    })
 -}
 -
 -/// The shaping mode for one text run: ASCII-only text in a MONOSPACED face
 -/// shapes `Basic`, everything else `Advanced`.
 -///
 -/// `Basic` bypasses OpenType substitution and positioning, and for ASCII in a
 -/// mono face that is exactly right: a mono font's ligatures are the one thing
 -/// `Advanced` adds there, and they break the grid — Chivo Mono's `liga`
 -/// squeezes f+i into a single-advance fi glyph, which is why the bar's window
 -/// titles rendered "file" with a cramped fi — while mono faces carry no
 -/// kerning to lose. Proportional faces keep `Advanced` (their kerning and
 -/// ligatures are wanted — a font preview must not misrepresent the face), and
 -/// any non-ASCII text keeps real shaping (combining marks, emoji, complex
 -/// scripts) whatever the face.
 -pub fn shaping_for(fs: &FontSystem, text: &str, family: &cosmic_text::Family) -> cosmic_text::Shaping {
 -    if text.is_ascii() {
 -        if let cosmic_text::Family::Name(name) = family {
 -            if family_is_monospaced(fs, name) {
 -                return cosmic_text::Shaping::Basic;
 -            }
 -        }
 -    }
 -    cosmic_text::Shaping::Advanced
 -}
 -
 -pub fn get_text_buffer(fs: &mut FontSystem, text: &str, size: f32, font: Option<&str>) -> Buffer {
 -    get_text_buffer_attrs(fs, text, size, font, crate::scene::paint::TextAttrs::default())
 -}
 -
 -/// [`get_text_buffer`] plus shaping attributes (italic / weight) — the backend's shape entry
 -/// for `Prim::Text` prims that carry [`TextAttrs`] (the font picker's style-variant previews).
 -pub fn get_text_buffer_attrs(
 -    fs: &mut FontSystem,
 -    text: &str,
 -    size: f32,
 -    font: Option<&str>,
 -    text_attrs: crate::scene::paint::TextAttrs,
 -) -> Buffer {
 -    Buffer::clone(&shared_text_buffer(fs, text, size, font, text_attrs))
 -}
 -
 -/// [`get_text_buffer_attrs`] without the copy: the cached buffer itself,
 -/// shared. What the frame and the toolkit's own measuring use — a `Buffer`
 -/// owns every shaped line and glyph, so the clone the public functions hand
 -/// out costs as much as the text is long.
 -pub(crate) fn shared_text_buffer(
 -    fs: &mut FontSystem,
 -    text: &str,
 -    size: f32,
 -    font: Option<&str>,
 -    text_attrs: crate::scene::paint::TextAttrs,
 -) -> Rc<Buffer> {
 -    let scale = crate::scale::scale_factor();
 -    let (family_name, font_size) = match font {
 -        Some(font_str) => {
 -            let (family, parsed_size) = crate::layout::split_font_string(font_str);
 -            (Some(family), parsed_size.unwrap_or(size))
 -        }
 -        None => (None, size),
 -    };
 -
 -    let physical_size = font_size * scale;
 -    let is_vertical = crate::IS_VERTICAL.load(std::sync::atomic::Ordering::Relaxed);
 -    let key = BufferKey {
 -        size_milli: (physical_size * 1000.0).round() as u32,
 -        font: family_name,
 -        is_vertical,
 -        attrs: text_attrs,
 -        scale_bits: scale.to_bits(),
 -        layout: None,
 -    };
 -    if let Some((buf, _)) = buffer_cache_get(text, &key) {
 -        return buf;
 -    }
 -
 -    let line_height = if is_vertical {
 -        physical_size * 1.05
 -    } else {
 -        physical_size * 1.0
 -    };
 -    let metrics = Metrics::new(physical_size, line_height);
 -    let mut buf = Buffer::new(fs, metrics);
 -    let mut attrs = Attrs::new();
 -
 -    let (sans_fallback, serif_fallback, mono_fallback, _) = crate::layout::read_preferred_fonts();
 -
 -    let resolved_storage = family_name.and_then(|font_name| match font_name {
 -        "monospace" if !mono_fallback.is_empty() => find_cased_family(fs, &mono_fallback),
 -        "sans-serif" if !sans_fallback.is_empty() => find_cased_family(fs, &sans_fallback),
 -        "serif" if !serif_fallback.is_empty() => find_cased_family(fs, &serif_fallback),
 -        _ => None,
 -    });
 -
 -    let resolved_sans = if !sans_fallback.is_empty() {
 -        find_cased_family(fs, &sans_fallback)
 -    } else {
 -        None
 -    };
 -
 -    let family = if let Some(font_family) = family_name {
 -        match font_family {
 -            "monospace" => {
 -                if !mono_fallback.is_empty() {
 -                    if let Some(ref cased) = resolved_storage {
 -                        cosmic_text::Family::Name(cased)
 -                    } else {
 -                        cosmic_text::Family::Name(crate::layout::get_system_monospace_font())
 -                    }
 -                } else {
 -                    cosmic_text::Family::Name(crate::layout::get_system_monospace_font())
 -                }
 -            }
 -            "sans-serif" => {
 -                if !sans_fallback.is_empty() {
 -                    if let Some(ref cased) = resolved_storage {
 -                        cosmic_text::Family::Name(cased)
 -                    } else {
 -                        cosmic_text::Family::SansSerif
 -                    }
 -                } else {
 -                    cosmic_text::Family::SansSerif
 -                }
 -            }
 -            "serif" => {
 -                if !serif_fallback.is_empty() {
 -                    if let Some(ref cased) = resolved_storage {
 -                        cosmic_text::Family::Name(cased)
 -                    } else {
 -                        cosmic_text::Family::Serif
 -                    }
 -                } else {
 -                    cosmic_text::Family::Serif
 -                }
 -            }
 -            name => cosmic_text::Family::Name(name),
 -        }
 -    } else {
 -        if !sans_fallback.is_empty() {
 -            if let Some(ref cased) = resolved_sans {
 -                cosmic_text::Family::Name(cased)
 -            } else {
 -                cosmic_text::Family::SansSerif
 -            }
 -        } else {
 -            cosmic_text::Family::SansSerif
 -        }
 -    };
 -    attrs = attrs.family(family);
 -    if text_attrs.italic {
 -        attrs = attrs.style(cosmic_text::Style::Italic);
 -    }
 -    if let Some(w) = text_attrs.weight {
 -        attrs = attrs.weight(cosmic_text::Weight(w));
 -    }
 -    let shaping = shaping_for(fs, text, &family);
 -    buf.set_text(fs, text, attrs, shaping);
 -    buf.shape_until_scroll(fs, true);
 -
 -    let buf = Rc::new(buf);
 -    buffer_cache_put(text, &key, Rc::clone(&buf), 0.0);
 -    buf
 -}
 -
 -/// Byte-offset → x mapping of single-line `text`, shaped exactly as the renderer draws it —
 -/// same buffer cache as the draw, so this is a lookup when the text is already on screen.
 -/// Returns ascending `(byte_idx, x)` pairs (one per cluster start, logical px, relative to
 -/// the text origin), terminated by `(text.len(), total_advance)`. This is the correct
 -/// source for caret placement and click→cursor mapping in hand-rolled text fields:
 -/// `measure_text_width` reports SVG-rasterized inked extent through fontdb's family
 -/// resolution, which disagrees with cosmic-text's advance and can even resolve a
 -/// different face — a caret placed with it drifts off the drawn glyphs.
 -pub fn shaped_cluster_offsets(
 -    fs: &mut FontSystem,
 -    text: &str,
 -    size: f32,
 -    font: Option<&str>,
 -) -> Vec<(usize, f32)> {
 -    let scale = crate::scale::scale_factor().max(1.0);
 -    let buffer = shared_text_buffer(fs, text, size, font, crate::scene::paint::TextAttrs::default());
 -    let mut out: Vec<(usize, f32)> = Vec::new();
 -    let mut total: f32 = 0.0;
 -    for (start, x, w) in normalized_glyph_starts(&buffer, text) {
 -        if out.last().map_or(true, |&(b, _)| b != start) {
 -            out.push((start, x / scale));
 -        }
 -        total = total.max((x + w) / scale);
 -    }
 -    out.push((text.len(), total));
 -    out
 -}
 -
 -/// Every glyph of `buffer`'s layout runs as `(start_byte, x, w)` (physical px),
 -/// with `start` normalized to be text-relative.
 -///
 -/// Exists because cosmic-text 0.12's `Shaping::Basic` path (`shape_skip`) emits
 -/// `LayoutGlyph::start` relative to the shape SPAN — it resets to 0 at every
 -/// word — while the Advanced path emits line-relative starts. `shaping_for`
 -/// picks Basic exactly for ASCII text in a monospace family (the DE's default
 -/// control font), so any multi-word value hit the bug: offsets keyed by those
 -/// starts collide on the low columns and the caret/selection walk off the
 -/// glyphs. A reset can ONLY come from that path, which shapes strictly one
 -/// glyph per char in logical order — so when one is seen, byte starts are
 -/// rebuilt by walking the text's chars. `text` must be the single line the
 -/// buffer was shaped from.
 -pub(crate) fn normalized_glyph_starts(buffer: &Buffer, text: &str) -> Vec<(usize, f32, f32)> {
 -    let mut glyphs: Vec<(usize, f32, f32)> = Vec::new();
 -    let mut monotonic = true;
 -    let mut prev = 0usize;
 -    for run in buffer.layout_runs() {
 -        for g in run.glyphs {
 -            if g.start < prev {
 -                monotonic = false;
 -            }
 -            prev = g.start;
 -            glyphs.push((g.start, g.x, g.w));
 -        }
 -    }
 -    if !monotonic {
 -        let mut starts = text.char_indices().map(|(i, _)| i);
 -        for g in glyphs.iter_mut() {
 -            g.0 = starts.next().unwrap_or(text.len());
 -        }
 -    }
 -    glyphs
 -}
 -
 -/// Shape a boxed [`Prim::Text`] (word-wrap + alignment) and return `(buffer, vertical_offset)`.
 -/// Starts from [`get_text_buffer_attrs`]'s single run for all the family resolution, then
 -/// re-lays it out: a 1.4 line-height (the placed-text convention), the wrap width, per-line
 -/// horizontal alignment, and re-shapes. The vertical offset positions the shaped block inside
 -/// the box per `align_v`. Cached beside the single runs, keyed by the box as well.
 -pub fn get_text_buffer_laid_out(
 -    fs: &mut FontSystem,
 -    text: &str,
 -    size: f32,
 -    font: Option<&str>,
 -    text_attrs: crate::scene::paint::TextAttrs,
 -    layout: crate::scene::paint::TextLayout,
 -) -> (Buffer, f32) {
 -    let (buf, voff) = shared_laid_out_buffer(fs, text, size, font, text_attrs, layout);
 -    (Buffer::clone(&buf), voff)
 -}
 -
 -/// [`get_text_buffer_laid_out`] without the copy, as [`shared_text_buffer`] is to
 -/// [`get_text_buffer_attrs`]. Until 2026-10-04 a boxed text was re-shaped from scratch
 -/// every frame it was drawn; the box is part of the key now.
 -pub(crate) fn shared_laid_out_buffer(
 -    fs: &mut FontSystem,
 -    text: &str,
 -    size: f32,
 -    font: Option<&str>,
 -    text_attrs: crate::scene::paint::TextAttrs,
 -    layout: crate::scene::paint::TextLayout,
 -) -> (Rc<Buffer>, f32) {
 -    use crate::scene::paint::{AlignH, AlignV};
 -    let scale = crate::scale::scale_factor();
 -
 -    // The font string may override the size ("family:size") — mirror shared_text_buffer.
 -    let (family, font_size) = match font {
 -        Some(font_str) => {
 -            let (family, parsed_size) = crate::layout::split_font_string(font_str);
 -            (Some(family), parsed_size.unwrap_or(size))
 -        }
 -        None => (None, size),
 -    };
 -    let physical_size = font_size * scale;
 -    let key = BufferKey {
 -        size_milli: (physical_size * 1000.0).round() as u32,
 -        font: family,
 -        is_vertical: crate::IS_VERTICAL.load(std::sync::atomic::Ordering::Relaxed),
 -        attrs: text_attrs,
 -        scale_bits: scale.to_bits(),
 -        layout: Some(layout),
 -    };
 -    if let Some(hit) = buffer_cache_get(text, &key) {
 -        return hit;
 -    }
 -
 -    // Resolved family + attrs come from the single run; this copy is ours to re-lay-out.
 -    let mut buf = Buffer::clone(&shared_text_buffer(fs, text, size, font, text_attrs));
 -    let line_height = physical_size * 1.4;
 -    buf.set_metrics(fs, Metrics::new(physical_size, line_height));
 -    buf.set_size(fs, layout.wrap_width.map(|w| w * scale), Some(layout.box_height * scale));
 -
 -    let align = match layout.align_h {
 -        AlignH::Left => cosmic_text::Align::Left,
 -        AlignH::Center => cosmic_text::Align::Center,
 -        AlignH::Right => cosmic_text::Align::Right,
 -    };
 -    for line in &mut buf.lines {
 -        line.set_align(Some(align));
 -    }
 -    buf.shape_until_scroll(fs, true);
 -
 -    // Vertical offset (logical) from the shaped run count, matching the legacy per-app math.
 -    let runs = buf.layout_runs().count();
 -    let total_h = runs as f32 * font_size * 1.4;
 -    let voff = match layout.align_v {
 -        AlignV::Top => 0.0,
 -        AlignV::Middle => ((layout.box_height - total_h) / 2.0).max(0.0),
 -        AlignV::Bottom => (layout.box_height - total_h).max(0.0),
 -    };
 -    let buf = Rc::new(buf);
 -    buffer_cache_put(text, &key, Rc::clone(&buf), voff);
 -    (buf, voff)
 -}
 -
 -/// A text item's clip rect in physical pixels. This was `glyphon::TextBounds` — the one
 -/// glyphon-owned type cce-ui ever used, everything else being a cosmic-text re-export — so
 -/// it is defined here now that the dependency is cosmic-text directly. Same plain
 -/// four-`i32` layout; it is only an intermediate on the way to `TextSpan::bounds`.
 -#[derive(Clone, Copy, Debug, Eq, PartialEq)]
 -pub struct TextBounds {
 -    pub left: i32,
 -    pub top: i32,
 -    pub right: i32,
 -    pub bottom: i32,
 -}
 -
 -/// A display-list text prim ready for the glyph pass: a [`TextItem`](crate::widget::TextItem) whose
 -/// buffer is the cache's own, shared rather than copied.
 -pub(crate) struct DlText {
 -    pub(crate) buffer: Rc<Buffer>,
 -    pub(crate) x: f32,
 -    pub(crate) y: f32,
 -    pub(crate) color: cosmic_text::Color,
 -    pub(crate) bounds: Option<[f32; 4]>,
 -    pub(crate) clip_circle: Option<[f32; 3]>,
 -    pub(crate) clip_rrect: Option<[f32; 5]>,
 -}
 -
 -/// The display list's Text prims, shaped through the shared buffer cache and
 -/// held for the glyph pass (the [`TextSpan`]s built by [`dl_text_spans`] borrow
 -/// these). Clip = the paint walk's item clip ∩ the prim's own bounds, in
 -/// logical space. Shared by the window's frame and the context-menu popup's.
 -pub(crate) fn collect_dl_text(fs: &mut FontSystem, dl: &crate::scene::paint::DisplayList, out: &mut Vec<DlText>) {
 -    for item in &dl.items {
 -        if let crate::scene::paint::Prim::Text { text, x, y, font_size, color, alpha, font, bounds, attrs, layout } = &item.prim {
 -            let clip = item.clip.map(|c| [c.x, c.y, c.x + c.width, c.y + c.height]);
 -            let merged = match (clip, *bounds) {
 -                (Some(a), Some(b)) => Some([a[0].max(b[0]), a[1].max(b[1]), a[2].min(b[2]), a[3].min(b[3])]),
 -                (Some(a), None) => Some(a),
 -                (None, b) => b,
 -            };
 -            // Boxed text (wrap/align) is laid out in its box and shifts down by the
 -            // vertical offset; ordinary labels are a single run. Both cached.
 -            let (buffer, y_off) = match layout {
 -                Some(l) => shared_laid_out_buffer(fs, text, *font_size, font.as_deref(), *attrs, *l),
 -                None => (shared_text_buffer(fs, text, *font_size, font.as_deref(), *attrs), 0.0),
 -            };
 -            out.push(DlText {
 -                buffer,
 -                x: *x,
 -                y: *y + y_off,
 -                color: cosmic_text::Color::rgba(
 -                    color[0],
 -                    color[1],
 -                    color[2],
 -                    (alpha.clamp(0.0, 1.0) * 255.0).round() as u8,
 -                ),
 -                bounds: merged,
 -                clip_circle: item.clip_circle,
 -                clip_rrect: item.clip_rrect,
 -            });
 -        }
 -    }
 -}
 -
 -/// The glyph pass's spans for `items`: each clamped to the surface and its
 -/// own bounds, then by the popover-occlusion clamp against `overlays`.
 -pub(crate) fn dl_text_spans<'a>(
 -    items: &'a [DlText],
 -    scale_f32: f32,
 -    bounds: TextBounds,
 -    overlays: &[(f32, f32, f32, f32)],
 -) -> Vec<TextSpan<'a>> {
 -    let mut spans: Vec<TextSpan<'a>> = Vec::new();
 -    for ti in items {
 -        let mut item_bounds = if let Some([l, t, r, b]) = ti.bounds {
 -            TextBounds {
 -                left: ((l * scale_f32).round() as i32).clamp(0, bounds.right),
 -                top: ((t * scale_f32).round() as i32).clamp(0, bounds.bottom),
 -                right: ((r * scale_f32).round() as i32).clamp(0, bounds.right),
 -                bottom: ((b * scale_f32).round() as i32).clamp(0, bounds.bottom),
 -            }
 -        } else {
 -            bounds
 -        };
 -        popover_occlusion_clamp(overlays, ti, scale_f32, &mut item_bounds);
 -        spans.push(TextSpan {
 -            buffer: &ti.buffer,
 -            left: (ti.x * scale_f32).round(),
 -            top: (ti.y * scale_f32).round(),
 -            // Buffers are shaped at physical size (get_text_buffer_attrs).
 -            scale: 1.0,
 -            bounds: Some([
 -                item_bounds.left,
 -                item_bounds.top,
 -                item_bounds.right,
 -                item_bounds.bottom,
 -            ]),
 -            default_color: [
 -                ti.color.r() as f32 / 255.0,
 -                ti.color.g() as f32 / 255.0,
 -                ti.color.b() as f32 / 255.0,
 -                ti.color.a() as f32 / 255.0,
 -            ],
 -            rotation: None,
 -            // Circle wins when both are set (the circular pane's innermost clip);
 -            // otherwise a rounded-rect clip rides as center+radius with extents.
 -            clip_circle: match (ti.clip_circle, ti.clip_rrect) {
 -                (Some(c), _) => [c[0] * scale_f32, c[1] * scale_f32, c[2] * scale_f32],
 -                (None, Some(rr)) => [rr[0] * scale_f32, rr[1] * scale_f32, rr[4] * scale_f32],
 -                (None, None) => [0.0; 3],
 -            },
 -            clip_extents: match (ti.clip_circle, ti.clip_rrect) {
 -                (None, Some(rr)) => [rr[2] * scale_f32, rr[3] * scale_f32],
 -                _ => [0.0; 2],
 -            },
 -        });
 -    }
 -    spans
 -}
 -
 -/// The tessellated display list's batches, scissors and rounded clips scaled
 -/// to physical px.
 -pub(crate) fn dl_batches_2d(dl_batches: &[DlBatch], scale_f32: f32) -> Vec<Batch2D> {
 -    dl_batches
 -        .iter()
 -        .map(|batch| Batch2D {
 -            scissor: batch.scissor.map(|clip| {
 -                (
 -                    (clip.x * scale_f32).max(0.0) as u32,
 -                    (clip.y * scale_f32).max(0.0) as u32,
 -                    (clip.width * scale_f32) as u32,
 -                    (clip.height * scale_f32) as u32,
 -                )
 -            }),
 -            clip_rrect: batch
 -                .clip_rrect
 -                .map(|c| [c[0] * scale_f32, c[1] * scale_f32, c[2] * scale_f32, c[3] * scale_f32, c[4] * scale_f32]),
 -            start: batch.start,
 -            end: batch.end,
 -            plate: batch.plate,
 -            blur_behind: batch.blur_behind,
 -        })
 -        .collect()
 -}
 -
 -/// The popover-occlusion clamp shared by the default [`Application::text_areas`] mapping and
 -/// the display-list text path: clip a text item's bounds so it does not bleed through an open
 -/// popover's plate. A text item whose own bounds coincide with a popover rect IS that popover's
 -/// text and is left alone; anything else that intersects gets clamped horizontally toward
 -/// whichever side of the popover it starts on.
 -/// Clamp a text item's bounds away from the registered popover rects it
 -/// runs under, so page text does not bleed through a floating plate.
 -///
 -/// A text item BELONGS to a popover when it carries exactly that popover's
 -/// rect as its bounds (the convention every popover's own labels follow),
 -/// and it is then clamped only against the popovers registered AFTER its
 -/// own — `overlay_rects` is in stacking order, the shared context menu
 -/// last. Before 2026-09-22 a popover's text was exempt from its own rect
 -/// alone and clamped against every other, so a context menu opened over a
 -/// modal dialog had its labels clipped by the dialog it was drawn on top
 -/// of, and showed as a plate with no legible entries.
 -fn popover_occlusion_clamp(
 -    overlay_rects: &[(f32, f32, f32, f32)],
 -    ti: &DlText,
 -    scale_f32: f32,
 -    item_bounds: &mut TextBounds,
 -) {
 -    let owner = ti.bounds.and_then(|[l, t, r, b]| {
 -        overlay_rects.iter().position(|&(ox, oy, ow, oh)| {
 -            (l - ox).abs() < 1.0
 -                && (t - oy).abs() < 1.0
 -                && (r - (ox + ow)).abs() < 1.0
 -                && (b - (oy + oh)).abs() < 1.0
 -        })
 -    });
 -    let first_above = owner.map_or(0, |k| k + 1);
 -    for &(ox, oy, ow, oh) in &overlay_rects[first_above..] {
 -        let ol = (ox * scale_f32).round() as i32;
 -        let ot = (oy * scale_f32).round() as i32;
 -        let or = ((ox + ow) * scale_f32).round() as i32;
 -        let ob = ((oy + oh) * scale_f32).round() as i32;
 -
 -        let tx_pixel = ti.x * scale_f32;
 -        let ty_pixel = ti.y * scale_f32;
 -
 -        let mut text_w = 0.0f32;
 -        let mut run_count = 0;
 -        for run in ti.buffer.layout_runs() {
 -            text_w = text_w.max(run.line_w);
 -            run_count += 1;
 -        }
 -        let text_h = run_count as f32 * ti.buffer.metrics().line_height;
 -
 -        let actual_left = tx_pixel;
 -        let actual_right = tx_pixel + text_w;
 -        let actual_top = ty_pixel;
 -        let actual_bottom = ty_pixel + text_h;
 -
 -        if actual_left < or as f32
 -            && actual_right > ol as f32
 -            && actual_top < ob as f32
 -            && actual_bottom > ot as f32
 -        {
 -            if tx_pixel < ol as f32 {
 -                item_bounds.right = item_bounds.right.min(ol);
 -            } else {
 -                item_bounds.left = item_bounds.left.max(or);
 -            }
 -        }
 -    }
 -}
 -
 -#[repr(C)]
 -#[derive(Debug, Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
 -pub struct Vertex {
 -    pub position: [f32; 2],
 -    pub color: [f32; 4],
 -    pub clip_circle: [f32; 3], // [cx, cy, r]
 -}
 -
 -pub fn quad_vertices(x: f32, y: f32, w: f32, h: f32, sw: f32, sh: f32, c: [f32; 4]) -> [Vertex; 6] {
 -    let x0 = (x / sw) * 2.0 - 1.0;
 -    let y0 = 1.0 - (y / sh) * 2.0;
 -    let x1 = ((x + w) / sw) * 2.0 - 1.0;
 -    let y1 = 1.0 - ((y + h) / sh) * 2.0;
 -    [
 -        Vertex { position: [x0, y0], color: c, clip_circle: [0.0, 0.0, 0.0] },
 -        Vertex { position: [x1, y0], color: c, clip_circle: [0.0, 0.0, 0.0] },
 -        Vertex { position: [x0, y1], color: c, clip_circle: [0.0, 0.0, 0.0] },
 -        Vertex { position: [x1, y0], color: c, clip_circle: [0.0, 0.0, 0.0] },
 -        Vertex { position: [x1, y1], color: c, clip_circle: [0.0, 0.0, 0.0] },
 -        Vertex { position: [x0, y1], color: c, clip_circle: [0.0, 0.0, 0.0] },
 -    ]
 -}
 -
 -pub fn quad_vertices_with_clip(
 -    x: f32, y: f32, w: f32, h: f32,
 -    sw: f32, sh: f32,
 -    color: [f32; 4],
 -    clip_circle: [f32; 3],
 -) -> [Vertex; 6] {
 -    let x0 = (x / sw) * 2.0 - 1.0;
 -    let y0 = 1.0 - (y / sh) * 2.0;
 -    let x1 = ((x + w) / sw) * 2.0 - 1.0;
 -    let y1 = 1.0 - ((y + h) / sh) * 2.0;
 -    [
 -        Vertex { position: [x0, y0], color, clip_circle },
 -        Vertex { position: [x1, y0], color, clip_circle },
 -        Vertex { position: [x0, y1], color, clip_circle },
 -        Vertex { position: [x1, y0], color, clip_circle },
 -        Vertex { position: [x1, y1], color, clip_circle },
 -        Vertex { position: [x0, y1], color, clip_circle },
 -    ]
 -}
 -
 -/// A quad whose four corners each carry their own color, Gouraud-interpolated across both
 -/// triangles by the shader (`@location(0) color` has no `flat` qualifier). Corner order is
 -/// TL, TR, BR, BL. Keep the alpha equal on all four: negative alpha is the blur sentinel,
 -/// so a gradient that crossed zero would tear the triangle in half.
 -pub fn quad_vertices_shaded(
 -    x: f32, y: f32, w: f32, h: f32,
 -    sw: f32, sh: f32,
 -    c_tl: [f32; 4], c_tr: [f32; 4], c_br: [f32; 4], c_bl: [f32; 4],
 -    clip_circle: [f32; 3],
 -) -> [Vertex; 6] {
 -    let x0 = (x / sw) * 2.0 - 1.0;
 -    let y0 = 1.0 - (y / sh) * 2.0;
 -    let x1 = ((x + w) / sw) * 2.0 - 1.0;
 -    let y1 = 1.0 - ((y + h) / sh) * 2.0;
 -    [
 -        Vertex { position: [x0, y0], color: c_tl, clip_circle },
 -        Vertex { position: [x1, y0], color: c_tr, clip_circle },
 -        Vertex { position: [x0, y1], color: c_bl, clip_circle },
 -        Vertex { position: [x1, y0], color: c_tr, clip_circle },
 -        Vertex { position: [x1, y1], color: c_br, clip_circle },
 -        Vertex { position: [x0, y1], color: c_bl, clip_circle },
 -    ]
 -}
 -
 -pub fn quad_vertices_clipped(
 -    x: f32, y: f32, w: f32, h: f32,
 -    surface_w: f32, surface_h: f32,
 -    color: [f32; 4],
 -    clip: (f32, f32, f32, f32),
 -    clip_circle: [f32; 3],
 -) -> Vec<Vertex> {
 -    let (cx0, cy0, cx1, cy1) = clip;
 -    let ix0 = x.max(cx0);
 -    let iy0 = y.max(cy0);
 -    let ix1 = (x + w).min(cx1);
 -    let iy1 = (y + h).min(cy1);
 -    if ix1 <= ix0 || iy1 <= iy0 {
 -        return Vec::new();
 -    }
 -    quad_vertices_with_clip(ix0, iy0, ix1 - ix0, iy1 - iy0, surface_w, surface_h, color, clip_circle).to_vec()
 -}
 -
 -pub fn line_vertices(
 -    x1: f32, y1: f32, x2: f32, y2: f32,
 -    thickness: f32,
 -    sw: f32, sh: f32,
 -    c: [f32; 4]
 -) -> [Vertex; 6] {
 -    let dx = x2 - x1;
 -    let dy = y2 - y1;
 -    let len = (dx * dx + dy * dy).sqrt();
 -    if len < 0.001 {
 -        return quad_vertices(x1 - thickness/2.0, y1 - thickness/2.0, thickness, thickness, sw, sh, c);
 -    }
 -    let ux = dx / len;
 -    let uy = dy / len;
 -    let nx = -uy;
 -    let ny = ux;
 -    
 -    let half_t = thickness * 0.5;
 -    let p0x = x1 + nx * half_t;
 -    let p0y = y1 + ny * half_t;
 -    let p1x = x1 - nx * half_t;
 -    let p1y = y1 - ny * half_t;
 -    let p2x = x2 - nx * half_t;
 -    let p2y = y2 - ny * half_t;
 -    let p3x = x2 + nx * half_t;
 -    let p3y = y2 + ny * half_t;
 -
 -    let ndc_p0x = (p0x / sw) * 2.0 - 1.0;
 -    let ndc_p0y = 1.0 - (p0y / sh) * 2.0;
 -    let ndc_p1x = (p1x / sw) * 2.0 - 1.0;
 -    let ndc_p1y = 1.0 - (p1y / sh) * 2.0;
 -    let ndc_p2x = (p2x / sw) * 2.0 - 1.0;
 -    let ndc_p2y = 1.0 - (p2y / sh) * 2.0;
 -    let ndc_p3x = (p3x / sw) * 2.0 - 1.0;
 -    let ndc_p3y = 1.0 - (p3y / sh) * 2.0;
 -
 -    let clip_circle = [0.0, 0.0, 0.0];
 -    [
 -        Vertex { position: [ndc_p0x, ndc_p0y], color: c, clip_circle },
 -        Vertex { position: [ndc_p1x, ndc_p1y], color: c, clip_circle },
 -        Vertex { position: [ndc_p2x, ndc_p2y], color: c, clip_circle },
 -        Vertex { position: [ndc_p0x, ndc_p0y], color: c, clip_circle },
 -        Vertex { position: [ndc_p2x, ndc_p2y], color: c, clip_circle },
 -        Vertex { position: [ndc_p3x, ndc_p3y], color: c, clip_circle },
 -    ]
 -}
 -
 -#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
 -pub enum LineCap {
 -    Arrow,
 -    Round,
 -    Flat,
 -}
 -
 -pub fn vector_vertices(
 -    x1: f32, y1: f32, x2: f32, y2: f32,
 -    thickness: f32,
 -    sw: f32, sh: f32,
 -    c: [f32; 4],
 -    line_cap: LineCap,
 -) -> Vec<Vertex> {
 -    let mut verts = Vec::new();
 -    let dx = x2 - x1;
 -    let dy = y2 - y1;
 -    let len = (dx * dx + dy * dy).sqrt();
 -    if len < 0.001 {
 -        return quad_vertices(x1 - thickness/2.0, y1 - thickness/2.0, thickness, thickness, sw, sh, c).to_vec();
 -    }
 -    
 -    match line_cap {
 -        LineCap::Arrow => {
 -            let ux = dx / len;
 -            let uy = dy / len;
 -            let nx = -uy;
 -            let ny = ux;
 -            
 -            let arrow_len = (thickness * 3.0).max(10.0).min(len);
 -            let arrow_width = (thickness * 2.5).max(8.0);
 -            
 -            let line_x2 = x2 - ux * arrow_len;
 -            let line_y2 = y2 - uy * arrow_len;
 -            
 -            if len > arrow_len {
 -                verts.extend_from_slice(&line_vertices(x1, y1, line_x2, line_y2, thickness, sw, sh, c));
 -            }
 -            
 -            let bx = line_x2;
 -            let by = line_y2;
 -            
 -            let w1x = bx + nx * (arrow_width * 0.5);
 -            let w1y = by + ny * (arrow_width * 0.5);
 -            let w2x = bx - nx * (arrow_width * 0.5);
 -            let w2y = by - ny * (arrow_width * 0.5);
 -            
 -            let ndc_tip_x = (x2 / sw) * 2.0 - 1.0;
 -            let ndc_tip_y = 1.0 - (y2 / sh) * 2.0;
 -            let ndc_w1x = (w1x / sw) * 2.0 - 1.0;
 -            let ndc_w1y = 1.0 - (w1y / sh) * 2.0;
 -            let ndc_w2x = (w2x / sw) * 2.0 - 1.0;
 -            let ndc_w2y = 1.0 - (w2y / sh) * 2.0;
 -            
 -            let clip_circle = [0.0, 0.0, 0.0];
 -            verts.push(Vertex { position: [ndc_tip_x, ndc_tip_y], color: c, clip_circle });
 -            verts.push(Vertex { position: [ndc_w1x, ndc_w1y], color: c, clip_circle });
 -            verts.push(Vertex { position: [ndc_w2x, ndc_w2y], color: c, clip_circle });
 -        }
 -        LineCap::Round => {
 -            push_feathered_line_vertices(x1, y1, x2, y2, thickness, sw, sh, c, &mut verts);
 -            let clip_circle = [0.0, 0.0, 0.0];
 -            verts.extend(circle_vertices(x2, y2, thickness / 2.0, sw, sh, c, 16, clip_circle));
 -        }
 -        LineCap::Flat => {
 -            push_feathered_line_vertices(x1, y1, x2, y2, thickness, sw, sh, c, &mut verts);
 -        }
 -    }
 -
 -    verts
 -}
 -
 -/// `line_vertices` with a half-px alpha ramp along each long edge (the arc
 -/// tessellator's poor-man's AA) — diagonal strokes resolve smoothly instead of
 -/// stair-stepping. Axis-aligned strokes keep the crisp single-quad path:
 -/// feathering a pixel-snapped hairline would only blur it.
 -fn push_feathered_line_vertices(
 -    x1: f32, y1: f32, x2: f32, y2: f32,
 -    thickness: f32,
 -    sw: f32, sh: f32,
 -    c: [f32; 4],
 -    out: &mut Vec<Vertex>,
 -) {
 -    let dx = x2 - x1;
 -    let dy = y2 - y1;
 -    let len = (dx * dx + dy * dy).sqrt();
 -    if len < 0.001 || dx.abs() < 0.01 || dy.abs() < 0.01 {
 -        out.extend_from_slice(&line_vertices(x1, y1, x2, y2, thickness, sw, sh, c));
 -        return;
 -    }
 -    let (nx, ny) = (-dy / len, dx / len);
 -    let f = 0.5f32.min(thickness * 0.25);
 -    let half = thickness * 0.5;
 -    // (offset at band start, offset at band end, alpha at start, alpha at end)
 -    let bands = [
 -        (-half - f, -half + f, 0.0, c[3]),
 -        (-half + f, half - f, c[3], c[3]),
 -        (half - f, half + f, c[3], 0.0),
 -    ];
 -    for &(oa, ob, aa, ab) in &bands {
 -        let ca = [c[0], c[1], c[2], aa];
 -        let cb = [c[0], c[1], c[2], ab];
 -        let p = |x: f32, y: f32, o: f32| -> [f32; 2] {
 -            [((x + nx * o) / sw) * 2.0 - 1.0, 1.0 - ((y + ny * o) / sh) * 2.0]
 -        };
 -        let clip_circle = [0.0, 0.0, 0.0];
 -        let (a1, b1) = (p(x1, y1, oa), p(x1, y1, ob));
 -        let (a2, b2) = (p(x2, y2, oa), p(x2, y2, ob));
 -        out.push(Vertex { position: a1, color: ca, clip_circle });
 -        out.push(Vertex { position: b1, color: cb, clip_circle });
 -        out.push(Vertex { position: b2, color: cb, clip_circle });
 -        out.push(Vertex { position: a1, color: ca, clip_circle });
 -        out.push(Vertex { position: b2, color: cb, clip_circle });
 -        out.push(Vertex { position: a2, color: ca, clip_circle });
 -    }
 -}
 -
 -pub fn rounded_rect_vertices_corners(
 -    x: f32, y: f32, ww: f32, h: f32,
 -    r: f32,
 -    sw: f32, sh: f32,
 -    color: [f32; 4],
 -    clip_circle: [f32; 3],
 -    corners: (bool, bool, bool, bool),
 -    clip_rect: Option<(f32, f32, f32, f32)>,
 -) -> Vec<Vertex> {
 -    let mut verts = Vec::new();
 -    let radii = crate::widget::CornerRadii::new(
 -        if corners.0 { r } else { 0.0 },
 -        if corners.1 { r } else { 0.0 },
 -        if corners.2 { r } else { 0.0 },
 -        if corners.3 { r } else { 0.0 },
 -    );
 -    push_rounded_rect_vertices_corners(x, y, ww, h, radii, sw, sh, color, clip_circle, clip_rect, &mut verts);
 -    verts
 -}
 -
 -/// Sample of the unit superellipse |x|^n + |y|^n = 1 at circle parameter θ —
 -/// the (cos θ, sin θ) replacement the corner fans use. Exactly the circle at
 -/// n = 2; higher `corner_shape` exponents give the DE's continuous-curvature
 -/// corners, so widget silhouettes follow the same corner family as the
 -/// SDF-lit plates. `e` is 2/n, hoisted by callers. Tangent points at the
 -/// quadrant ends are pinned to the exact axis points (see below), so fans
 -/// tile exactly against the body rects and edge strips.
 -#[inline]
 -fn superellipse_pt(theta: f32, e: f32) -> (f32, f32) {
 -    let (s, c) = theta.sin_cos();
 -    // f32 sin/cos are not exactly 0 at a quadrant end (sin(PI) is -8.7e-8),
 -    // and the fractional power magnifies that noise by orders of magnitude:
 -    // at corner_shape 4.5 it is 7e-4, which on the desktop grid's 138 px
 -    // cell corners put the fan's tangent vertex 0.1 px short of the body
 -    // quad's edge. The fan's edge then tilts away from the quad's, and the
 -    // row of pixel centres between them is covered by neither: a stray
 -    // gap-coloured line 32 px long at every cell's left edge, and a lone
 -    // gap pixel on the bottom row where the arc meets the body. Snap the
 -    // ends to the exact axis points so fans tile against the rects.
 -    let axis = |v: f32| -> f32 {
 -        if v.abs() < 1e-6 {
 -            0.0
 -        } else if v.abs() > 1.0 - 1e-6 {
 -            v.signum()
 -        } else {
 -            v.signum() * v.abs().powf(e)
 -        }
 -    };
 -    (axis(c), axis(s))
 -}
 -
 -/// Feathered glow ([`Prim::Glow`]): the rounded rect's interior fills at the
 -/// color's alpha and concentric outline rings fade it to zero across `reach`
 -/// px outside the boundary. Alpha rides the VERTICES, so the GPU interpolates
 -/// a per-pixel-smooth falloff between rings — stacked translucent layers band
 -/// visibly; this cannot. Ring alphas sit on a quadratic ease-out, giving the
 -/// vignette profile piecewise-linearly with kinks below visibility at glow
 -/// alphas. Corners sample [`superellipse_pt`], so a glow's silhouette sits in
 -/// the same corner family as the cells, nodes, and plates it highlights.
 -pub fn push_glow_vertices(
 -    x: f32, y: f32, ww: f32, h: f32,
 -    radius: f32, reach: f32,
 -    sw: f32, sh: f32,
 -    color: [f32; 4],
 -    clip_circle: [f32; 3],
 -    out: &mut Vec<Vertex>,
 -) {
 -    if ww <= 0.0 || h <= 0.0 || color[3].abs() <= 0.0005 || sw <= 0.0 || sh <= 0.0 {
 -        return;
 -    }
 -    let r0 = radius.clamp(0.0, ww.min(h) * 0.5);
 -    let ctl = (x + r0, y + r0);
 -    let ctr = (x + ww - r0, y + r0);
 -    let cbr = (x + ww - r0, y + h - r0);
 -    let cbl = (x + r0, y + h - r0);
 -    const K: usize = 10;
 -    use std::f32::consts::PI;
 -    let corner_e = 2.0 / crate::layout::corner_shape();
 -    // One outline ring `off` px outside the boundary, clockwise from the
 -    // top-left arc; every ring shares the layout, so strips never twist.
 -    let ring = |off: f32| -> Vec<[f32; 2]> {
 -        let r = (r0 + off).max(0.0);
 -        let mut pts = Vec::with_capacity(4 * (K + 1));
 -        let corners = [
 -            (ctl, PI, 1.5 * PI),
 -            (ctr, 1.5 * PI, 2.0 * PI),
 -            (cbr, 0.0, 0.5 * PI),
 -            (cbl, 0.5 * PI, PI),
 -        ];
 -        for ((cx, cy), a0, a1) in corners {
 -            for k in 0..=K {
 -                let a = a0 + (a1 - a0) * (k as f32 / K as f32);
 -                let (ux, uy) = superellipse_pt(a, corner_e);
 -                pts.push([cx + r * ux, cy + r * uy]);
 -            }
 -        }
 -        pts
 -    };
 -    let to_v = |p: [f32; 2], a: f32| Vertex {
 -        position: [(p[0] / sw) * 2.0 - 1.0, 1.0 - (p[1] / sh) * 2.0],
 -        color: [color[0], color[1], color[2], a],
 -        clip_circle,
 -    };
 -
 -    let rings: Vec<(Vec<[f32; 2]>, f32)> = [0.0f32, 0.35, 0.7, 1.0]
 -        .iter()
 -        .map(|&t| (ring(reach * t), color[3] * (1.0 - t) * (1.0 - t)))
 -        .collect();
 -    let n = rings[0].0.len();
 -
 -    // Interior: a fan from the rect center over the innermost ring (a rounded
 -    // rect is convex, so the fan covers it exactly), uniform core alpha.
 -    let center = [x + ww * 0.5, y + h * 0.5];
 -    for i in 0..n {
 -        let p1 = rings[0].0[i];
 -        let p2 = rings[0].0[(i + 1) % n];
 -        out.push(to_v(center, color[3]));
 -        out.push(to_v(p1, color[3]));
 -        out.push(to_v(p2, color[3]));
 -    }
 -    // The feather: strips between consecutive rings, each vertex carrying its
 -    // ring's alpha.
 -    for w in rings.windows(2) {
 -        let (inner, ia) = (&w[0].0, w[0].1);
 -        let (outer, oa) = (&w[1].0, w[1].1);
 -        for i in 0..n {
 -            let a1 = inner[i];
 -            let a2 = inner[(i + 1) % n];
 -            let b1 = outer[i];
 -            let b2 = outer[(i + 1) % n];
 -            out.push(to_v(a1, ia));
 -            out.push(to_v(b1, oa));
 -            out.push(to_v(a2, ia));
 -            out.push(to_v(a2, ia));
 -            out.push(to_v(b1, oa));
 -            out.push(to_v(b2, oa));
 -        }
 -    }
 -}
 -
 -pub fn push_rounded_rect_vertices_corners(
 -    x: f32, y: f32, ww: f32, h: f32,
 -    radii: crate::widget::CornerRadii,
 -    sw: f32, sh: f32,
 -    color: [f32; 4],
 -    clip_circle: [f32; 3],
 -    clip_rect: Option<(f32, f32, f32, f32)>,
 -    out: &mut Vec<Vertex>,
 -) {
 -    let corner_e = 2.0 / crate::layout::corner_shape();
 -    let mut r_tl = radii.top_left.max(0.0);
 -    let mut r_tr = radii.top_right.max(0.0);
 -    let mut r_br = radii.bottom_right.max(0.0);
 -    let mut r_bl = radii.bottom_left.max(0.0);
 -
 -    // Simple scale clamping
 -    let sum_top = r_tl + r_tr;
 -    if sum_top > ww {
 -        let f = ww / sum_top;
 -        r_tl *= f;
 -        r_tr *= f;
 -    }
 -    let sum_bottom = r_bl + r_br;
 -    if sum_bottom > ww {
 -        let f = ww / sum_bottom;
 -        r_bl *= f;
 -        r_br *= f;
 -    }
 -    let sum_left = r_tl + r_bl;
 -    if sum_left > h {
 -        let f = h / sum_left;
 -        r_tl *= f;
 -        r_bl *= f;
 -    }
 -    let sum_right = r_tr + r_br;
 -    if sum_right > h {
 -        let f = h / sum_right;
 -        r_tr *= f;
 -        r_br *= f;
 -    }
 -
 -    let clamp_x = |val: f32| -> f32 {
 -        if let Some((cx0, _, cx1, _)) = clip_rect {
 -            val.max(cx0).min(cx1)
 -        } else {
 -            val
 -        }
 -    };
 -    let clamp_y = |val: f32| -> f32 {
 -        if let Some((_, cy0, _, cy1)) = clip_rect {
 -            val.max(cy0).min(cy1)
 -        } else {
 -            val
 -        }
 -    };
 -
 -    let push_quad = |verts: &mut Vec<Vertex>, qx: f32, qy: f32, qw: f32, qh: f32| {
 -        let x0 = clamp_x(qx);
 -        let y0 = clamp_y(qy);
 -        let x1 = clamp_x(qx + qw);
 -        let y1 = clamp_y(qy + qh);
 -        
 -        if x1 <= x0 || y1 <= y0 {
 -            return;
 -        }
 -
 -        let ndc_x0 = (x0 / sw) * 2.0 - 1.0;
 -        let ndc_y0 = 1.0 - (y0 / sh) * 2.0;
 -        let ndc_x1 = (x1 / sw) * 2.0 - 1.0;
 -        let ndc_y1 = 1.0 - (y1 / sh) * 2.0;
 -        
 -        verts.push(Vertex { position: [ndc_x0, ndc_y0], color, clip_circle });
 -        verts.push(Vertex { position: [ndc_x1, ndc_y0], color, clip_circle });
 -        verts.push(Vertex { position: [ndc_x0, ndc_y1], color, clip_circle });
 -        verts.push(Vertex { position: [ndc_x1, ndc_y0], color, clip_circle });
 -        verts.push(Vertex { position: [ndc_x1, ndc_y1], color, clip_circle });
 -        verts.push(Vertex { position: [ndc_x0, ndc_y1], color, clip_circle });
 -    };
 -
 -    let has_corners = r_tl > 0.1 || r_tr > 0.1 || r_br > 0.1 || r_bl > 0.1;
 -    if !has_corners {
 -        push_quad(out, x, y, ww, h);
 -        return;
 -    }
 -
 -    // Body rectangles
 -    let mid_x0 = r_tl.max(r_bl);
 -    let mid_x1 = ww - r_tr.max(r_br);
 -    if mid_x1 > mid_x0 {
 -        push_quad(out, x + mid_x0, y, mid_x1 - mid_x0, h);
 -    }
 -    if h > r_tl + r_bl {
 -        push_quad(out, x, y + r_tl, mid_x0, h - r_tl - r_bl);
 -    }
 -    if h > r_tr + r_br {
 -        push_quad(out, x + mid_x1, y + r_tr, ww - mid_x1, h - r_tr - r_br);
 -    }
 -
 -    // Corner rendering. The fans are FEATHERED: the fan body stops half a
 -    // pixel short of the silhouette and a strip fades from opaque at
 -    // silhouette-0.5 to transparent at silhouette+0.5, so the arc
 -    // anti-aliases instead of rasterizing a hard staircase — invisible on
 -    // HiDPI widget buffers, glaring on the desktop grid's world-scale
 -    // cells. Perceived size is unchanged (the 50%-coverage line stays on
 -    // the exact silhouette). Radii too small to feather keep the hard fan.
 -    let segments = 16;
 -    let fade = [color[0], color[1], color[2], 0.0];
 -    let to_ndc = |px: f32, py: f32| -> [f32; 2] {
 -        [(px / sw) * 2.0 - 1.0, 1.0 - (py / sh) * 2.0]
 -    };
 -    let push_corner = |out: &mut Vec<Vertex>, cx: f32, cy: f32, r: f32, start: f32, end: f32| {
 -        let feather = r > 1.5;
 -        let r_fan = if feather { r - 0.5 } else { r };
 -        let r_out = r + 0.5;
 -        for i in 0..segments {
 -            let theta1 = start + (i as f32) * (end - start) / (segments as f32);
 -            let theta2 = start + ((i + 1) as f32) * (end - start) / (segments as f32);
 -
 -            let (c1, s1) = superellipse_pt(theta1, corner_e);
 -            let (c2, s2) = superellipse_pt(theta2, corner_e);
 -            let p0 = to_ndc(clamp_x(cx), clamp_y(cy));
 -            let p1 = to_ndc(clamp_x(cx + r_fan * c1), clamp_y(cy + r_fan * s1));
 -            let p2 = to_ndc(clamp_x(cx + r_fan * c2), clamp_y(cy + r_fan * s2));
 -
 -            out.push(Vertex { position: p0, color, clip_circle });
 -            out.push(Vertex { position: p1, color, clip_circle });
 -            out.push(Vertex { position: p2, color, clip_circle });
 -
 -            if feather {
 -                let q1 = to_ndc(clamp_x(cx + r_out * c1), clamp_y(cy + r_out * s1));
 -                let q2 = to_ndc(clamp_x(cx + r_out * c2), clamp_y(cy + r_out * s2));
 -                out.push(Vertex { position: p1, color, clip_circle });
 -                out.push(Vertex { position: q1, color: fade, clip_circle });
 -                out.push(Vertex { position: q2, color: fade, clip_circle });
 -                out.push(Vertex { position: p1, color, clip_circle });
 -                out.push(Vertex { position: q2, color: fade, clip_circle });
 -                out.push(Vertex { position: p2, color, clip_circle });
 -            }
 -        }
 -    };
 -
 -    // Top-Left
 -    if r_tl > 0.1 {
 -        push_corner(out, x + r_tl, y + r_tl, r_tl, std::f32::consts::PI, 1.5 * std::f32::consts::PI);
 -        if mid_x0 > r_tl {
 -            push_quad(out, x + r_tl, y, mid_x0 - r_tl, r_tl);
 -        }
 -    }
 -
 -    // Top-Right
 -    if r_tr > 0.1 {
 -        push_corner(out, x + ww - r_tr, y + r_tr, r_tr, 1.5 * std::f32::consts::PI, 2.0 * std::f32::consts::PI);
 -        if ww - mid_x1 > r_tr {
 -            push_quad(out, x + mid_x1, y, ww - mid_x1 - r_tr, r_tr);
 -        }
 -    }
 -
 -    // Bottom-Right
 -    if r_br > 0.1 {
 -        push_corner(out, x + ww - r_br, y + h - r_br, r_br, 0.0, 0.5 * std::f32::consts::PI);
 -        if ww - mid_x1 > r_br {
 -            push_quad(out, x + mid_x1, y + h - r_br, ww - mid_x1 - r_br, r_br);
 -        }
 -    }
 -
 -    // Bottom-Left
 -    if r_bl > 0.1 {
 -        push_corner(out, x + r_bl, y + h - r_bl, r_bl, 0.5 * std::f32::consts::PI, std::f32::consts::PI);
 -        if mid_x0 > r_bl {
 -            push_quad(out, x + r_bl, y + h - r_bl, mid_x0 - r_bl, r_bl);
 -        }
 -    }
 -}
 -
 -pub fn rounded_rect_vertices(
 -    x: f32, y: f32, ww: f32, h: f32,
 -    r: f32,
 -    sw: f32, sh: f32,
 -    color: [f32; 4],
 -    clip_circle: [f32; 3],
 -) -> Vec<Vertex> {
 -    let mut verts = Vec::new();
 -    push_rounded_rect_vertices_corners(x, y, ww, h, crate::widget::CornerRadii::uniform(r), sw, sh, color, clip_circle, None, &mut verts);
 -    verts
 -}
 -
 -pub fn push_rounded_rect_vertices(
 -    x: f32, y: f32, ww: f32, h: f32,
 -    r: f32,
 -    sw: f32, sh: f32,
 -    color: [f32; 4],
 -    clip_circle: [f32; 3],
 -    out: &mut Vec<Vertex>,
 -) {
 -    push_rounded_rect_vertices_corners(x, y, ww, h, crate::widget::CornerRadii::uniform(r), sw, sh, color, clip_circle, None, out);
 -}
 -
 -pub fn plate_bevel_vertices(
 -    x: f32, y: f32, ww: f32, h: f32,
 -    r: f32,
 -    t: f32,
 -    sw: f32, sh: f32,
 -    base_color: [f32; 4],
 -    clip_circle: [f32; 3],
 -) -> Vec<Vertex> {
 -    let mut verts = Vec::new();
 -    push_plate_bevel_vertices(x, y, ww, h, r, t, sw, sh, base_color, clip_circle, &mut verts);
 -    verts
 -}
 -
 -pub fn push_plate_bevel_vertices(
 -    x: f32, y: f32, ww: f32, h: f32,
 -    r: f32,
 -    t: f32,
 -    sw: f32, sh: f32,
 -    base_color: [f32; 4],
 -    clip_circle: [f32; 3],
 -    out: &mut Vec<Vertex>,
 -) {
 -    push_bevel_edge_vertices(x, y, ww, h, r, t, sw, sh, base_color, clip_circle, 1.0, out);
 -}
 -
 -/// The bevel edge shading, with the light direction selectable: `light_sign` is `1.0`
 -/// for a raised plate (edges facing `light_source_position` are lit) and `-1.0` for a
 -/// recess (those same edges fall into shadow instead, and the far edges catch the
 -/// light). Negating the whole light vector flips every edge and every corner segment
 -/// consistently, because both the flat-edge factors and the arc-normal dot product
 -/// below are linear in it.
 -pub fn push_bevel_edge_vertices(
 -    x: f32, y: f32, ww: f32, h: f32,
 -    r: f32,
 -    t: f32,
 -    sw: f32, sh: f32,
 -    base_color: [f32; 4],
 -    clip_circle: [f32; 3],
 -    light_sign: f32,
 -    out: &mut Vec<Vertex>,
 -) {
 -    push_bevel_edge_vertices_radii(
 -        x, y, ww, h, (r, r, r, r), t, sw, sh, base_color, clip_circle, light_sign, out,
 -    );
 -}
 -
 -/// As [`push_bevel_edge_vertices`], but with a per-corner radius (TL, TR, BR, BL) so the
 -/// lip can follow a shape whose corners differ — a recess carved along the top of a
 -/// rounded plate needs the plate's radius on its top corners and square ones where it
 -/// meets the content below. A uniform radius there would either square off the plate's
 -/// arc (painting a notch outside it) or wrongly round the inner corners.
 -pub fn push_bevel_edge_vertices_radii(
 -    x: f32, y: f32, ww: f32, h: f32,
 -    radii: (f32, f32, f32, f32),
 -    t: f32,
 -    sw: f32, sh: f32,
 -    base_color: [f32; 4],
 -    clip_circle: [f32; 3],
 -    light_sign: f32,
 -    out: &mut Vec<Vertex>,
 -) {
 -    push_bevel_edge_vertices_banded(
 -        x, y, ww, h, radii, t, sw, sh, base_color, clip_circle, light_sign,
 -        default_bevel_bands(t), (true, true, true, true), EdgeKind::Rim, out,
 -    );
 -}
 -
 -/// What kind of height change an edge represents. The two shade differently because they
 -/// are different shapes, and using one where the other belongs is what makes a bevel read
 -/// as a drawn line instead of a surface.
 -#[derive(Clone, Copy, Debug, PartialEq, Eq)]
 -pub enum EdgeKind {
 -    /// The surface *ends* here: a quarter-round rolling from face-on at the inner edge of
 -    /// the lip to fully in-plane at the outer boundary, where it drops away. The shading
 -    /// therefore peaks exactly at the boundary and dies inward. This is a plate's outer
 -    /// perimeter.
 -    Rim,
 -    /// The surface *continues* at a different height: one plateau steps down to another.
 -    /// A height field that falls monotonically across the transition has its normal tilted
 -    /// toward the low side the whole way, steepest in the middle and flat at both ends —
 -    /// so the shading is a bump straddling the boundary, not a band butted against it.
 -    /// Hanging the band on one side instead leaves the seam the eye reads as a drawn line.
 -    Step,
 -}
 -
 -/// Shading across an edge at signed distance `d` from the boundary (positive = toward the
 -/// shape's interior), for a transition of width `t`. Returns the light term as a fraction
 -/// of full tilt.
 -#[inline]
 -fn bevel_profile(kind: EdgeKind, d: f32, t: f32) -> f32 {
 -    if t <= 0.0 {
 -        return 0.0;
 -    }
 -    match kind {
 -        // Normal rotates from in-plane (d = 0) to face-on (d = t): sine of what tilt is
 -        // left. A linear ramp here reads as a flat 45° chamfer instead of a roll.
 -        EdgeKind::Rim => ((1.0 - (d / t).clamp(0.0, 1.0)) * std::f32::consts::FRAC_PI_2).sin(),
 -        // Symmetric bump over [-t/2, +t/2], zero at both ends so the transition blends into
 -        // both plateaus with no seam.
 -        EdgeKind::Step => {
 -            let s = (d / t + 0.5).clamp(0.0, 1.0);
 -            (s * std::f32::consts::PI).sin()
 -        }
 -    }
 -}
 -
 -/// The light-independent curvature term at signed distance `d` — the second depth cue,
 -/// on top of the directional one. Curvature shading is what ambient light does: convex
 -/// surface catches it from everywhere (bright), concave is self-occluded (dark). Because
 -/// it does not rotate with the light, it survives exactly where the directional term
 -/// dies — walls parallel to the light vector — so no edge ever vanishes entirely.
 -///
 -/// `high_sign` is +1 when the rect interior is the HIGH side of the transition and -1
 -/// when it is the low side (a recess). Geometry, not lighting: it does not flip with
 -/// `light_sign`... except that for these 2.5D shapes the two are the same number, since
 -/// a raised shape is lit like a plateau and shaded like one.
 -#[inline]
 -fn bevel_curvature(kind: EdgeKind, d: f32, t: f32, high_sign: f32) -> f32 {
 -    if t <= 0.0 {
 -        return 0.0;
 -    }
 -    match kind {
 -        // A rim is convex everywhere, tightest right at the silhouette: a bright crest
 -        // line hugging the boundary and dying fast inward. This is the line that makes
 -        // glass read as glass — the edge catches ambient light all the way around, even
 -        // (dimmer, via the gain asymmetry below) on the side facing away from the light.
 -        EdgeKind::Rim => {
 -            let u = (d / t).clamp(0.0, 1.0);
 -            let f = 1.0 - u;
 -            CREST_RATIO * f * f * f
 -        }
 -        // An S-curve step is convex on its high half (the shoulder) and concave on its
 -        // low half (the fillet, where the wall meets the floor): antisymmetric, zero at
 -        // the ends (no seam against either plateau) and at the midpoint.
 -        EdgeKind::Step => {
 -            let s = (d / t + 0.5).clamp(0.0, 1.0);
 -            let outer_is_high = -high_sign; // d < 0 is outside the rect
 -            // sin(2πs) is positive on the outer half — the shoulder when the outside is
 -            // the high side — and negative on the inner (fillet) half.
 -            AO_RATIO * outer_is_high * (s * std::f32::consts::TAU).sin()
 -        }
 -    }
 -}
 -
 -/// Crest amplitude as a fraction of `bevel_depth` — how much brighter a rim's silhouette
 -/// line is than flat surface under even light. Must stay clearly below ~0.7 (the
 -/// projection of a 135° light onto an axis edge), or it cancels the directional shadow
 -/// on the dark side and the rim goes flat there instead of showing a faint bright line
 -/// over a shadowed roll.
 -const CREST_RATIO: f32 = 0.4;
 -/// Shoulder/fillet amplitude as a fraction of `bevel_depth`.
 -const AO_RATIO: f32 = 0.6;
 -/// Per-sign overlay gains. These are asymmetric the opposite way from intuition: on the
 -/// dark bases this DE runs, white-over blending (`b + a(1-b)`) moves the pixel far more
 -/// per unit alpha than black-over (`b(1-a)`) — a dark surface has little brightness for
 -/// black to take away. The old subtractive shading effectively crushed shadow sides to
 -/// black in linear space; the black overlay needs a high gain to keep shadows reading
 -/// at all, while white needs damping to keep highlights from blowing out.
 -const LIGHT_GAIN: f32 = 0.7;
 -const DARK_GAIN: f32 = 3.0;
 -
 -/// A shading value (already scaled by `bevel_depth`) as the two overlay passes: the lit
 -/// pass is translucent white, the shadow pass translucent black. Painting the
 -/// *modulation* instead of a resolved surface color is what lets relief primitives compose — a step
 -/// crossing a rim shades the rim's gradient instead of stamping a flat band over it, a
 -/// lip on a translucent plate no longer doubles its opacity, and a recess needs no
 -/// knowledge of the surface color it carves.
 -///
 -/// Why two passes with fixed RGB rather than one signed color: a primitive whose value
 -/// crosses zero inside a band would interpolate white→black through mid-gray at
 -/// non-negligible alpha — on a dark base a *brightening* artifact right where the
 -/// shading should vanish. With per-pass alphas clamped at the crossing, each pass fades
 -/// to zero there and the hue can never be wrong. Alphas also stay non-negative on every
 -/// vertex, which the renderer requires (negative alpha is the blur sentinel).
 -#[inline]
 -fn overlay_light(v: f32) -> [f32; 4] {
 -    [1.0, 1.0, 1.0, (v.max(0.0) * LIGHT_GAIN).min(1.0)]
 -}
 -#[inline]
 -fn overlay_dark(v: f32) -> [f32; 4] {
 -    [0.0, 0.0, 0.0, ((-v).max(0.0) * DARK_GAIN).min(1.0)]
 -}
 -
 -/// The signed distance range an edge's shading occupies, relative to the boundary.
 -#[inline]
 -fn bevel_span(kind: EdgeKind, t: f32) -> (f32, f32) {
 -    match kind {
 -        EdgeKind::Rim => (0.0, t),
 -        EdgeKind::Step => (-0.5 * t, 0.5 * t),
 -    }
 -}
 -
 -/// How many gradient bands to slice a lip of thickness `t` into. Vertex colors interpolate
 -/// linearly, so each band is a chord of the shading curve; one band per ~1.25px keeps the
 -/// error under a shade step without emitting geometry finer than the display resolves.
 -/// The cap rose with the curvature term: a step now has two features across its width
 -/// (shoulder and fillet), so it needs double the samples a single bump did.
 -fn default_bevel_bands(t: f32) -> usize {
 -    ((t / 1.25).ceil() as usize).clamp(1, 12)
 -}
 -
 -/// As [`push_bevel_edge_vertices_radii`], with the band count forced and the walls
 -/// selectable — for callers that want a coarser or finer roll-off than thickness alone
 -/// implies, or that are shading a step rather than a closed shape.
 -///
 -/// `edges` is (top, right, bottom, left). Suppressing a wall matters for a region that
 -/// runs flush to the surface's own edge: a full-width menubar sunk into the top of a plate
 -/// is a *plateau one step down*, not a trough, so its only real wall is the one facing the
 -/// content. Drawing the other three would carve a lip along the plate's outer edge, where
 -/// the plate's own roll already lives, and the two would fight.
 -pub fn push_bevel_edge_vertices_banded(
 -    x: f32, y: f32, ww: f32, h: f32,
 -    radii: (f32, f32, f32, f32),
 -    t: f32,
 -    sw: f32, sh: f32,
 -    base_color: [f32; 4],
 -    clip_circle: [f32; 3],
 -    light_sign: f32,
 -    bands: usize,
 -    edges: (bool, bool, bool, bool),
 -    kind: EdgeKind,
 -    out: &mut Vec<Vertex>,
 -) {
 -    // Floored for the same reason as `plate_push_raised`'s cap: a negative
 -    // extent must degrade to no ring, not panic in `clamp`.
 -    let cap = (ww.min(h) * 0.5).max(0.0);
 -    let (tl, tr, br, bl) = (
 -        radii.0.clamp(0.0, cap),
 -        radii.1.clamp(0.0, cap),
 -        radii.2.clamp(0.0, cap),
 -        radii.3.clamp(0.0, cap),
 -    );
 -    let t = t.clamp(0.0, cap);
 -    if t <= 0.0 {
 -        return;
 -    }
 -    let bands = bands.max(1);
 -
 -    let rad = crate::layout::light_source_position();
 -    let lx = rad.cos() * light_sign;
 -    let ly = -rad.sin() * light_sign;
 -    let depth = crate::layout::bevel_depth();
 -
 -    // `base_color` is no longer painted: shading is an overlay (see `overlay_color`), so
 -    // the surface below shows through with its own gradients and translucency intact.
 -    let _ = base_color;
 -    // Shading (directional + curvature, scaled by bevel_depth) at signed distance `d`,
 -    // for an edge whose outward flat normal is `dir`. A `Step` band runs negative — it
 -    // straddles the boundary into the plateau outside the rect, which is exactly what
 -    // removes the seam.
 -    let value = |dot: f32, d: f32| {
 -        depth * (bevel_profile(kind, d, t) * dot + bevel_curvature(kind, d, t, light_sign))
 -    };
 -    // The (up to two) overlay color pairs for a band running from value `v0` to `v1`:
 -    // one white pair and/or one black pair, each pass fading to zero alpha wherever the
 -    // value has the other sign. Both fire only when the band straddles the terminator.
 -    let passes = |v0: f32, v1: f32| -> [Option<([f32; 4], [f32; 4])>; 2] {
 -        [
 -            (v0 > 0.0 || v1 > 0.0).then(|| (overlay_light(v0), overlay_light(v1))),
 -            (v0 < 0.0 || v1 < 0.0).then(|| (overlay_dark(v0), overlay_dark(v1))),
 -        ]
 -    };
 -    let (span_lo, span_hi) = bevel_span(kind, t);
 -
 -    // Each flat edge spans between its two adjoining corner radii, not a single uniform
 -    // inset — that is what lets the corners differ. At a square corner there is no arc to
 -    // cover the t×t patch where two edges meet, so the horizontal edges claim it (they run
 -    // the full span) and the vertical ones inset by `t`; overlapping them instead would
 -    // double-blend that patch, which shows as a dark notch on a translucent surface.
 -    let (left_top, left_bot) = (if tl > 0.0 { tl } else { t }, if bl > 0.0 { bl } else { t });
 -    let (right_top, right_bot) = (if tr > 0.0 { tr } else { t }, if br > 0.0 { br } else { t });
 -    let top_w = ww - tl - tr;
 -    let bottom_w = ww - bl - br;
 -    let left_h = h - left_top - left_bot;
 -    let right_h = h - right_top - right_bot;
 -
 -    for k in 0..bands {
 -        let d0 = span_lo + (span_hi - span_lo) * (k as f32 / bands as f32);
 -        let d1 = span_lo + (span_hi - span_lo) * ((k + 1) as f32 / bands as f32);
 -        let bw = d1 - d0;
 -
 -        // Top: outward normal (0,-1); the gradient runs downward, into the surface.
 -        if top_w > 0.0 && edges.0 {
 -            let (v0, v1) = (value(-ly, d0), value(-ly, d1));
 -            for (c0, c1) in passes(v0, v1).into_iter().flatten() {
 -                out.extend_from_slice(&quad_vertices_shaded(
 -                    x + tl, y + d0, top_w, bw, sw, sh, c0, c0, c1, c1, clip_circle,
 -                ));
 -            }
 -        }
 -        // Bottom: outward normal (0,1); gradient runs upward.
 -        if bottom_w > 0.0 && edges.2 {
 -            let (v0, v1) = (value(ly, d0), value(ly, d1));
 -            for (c0, c1) in passes(v0, v1).into_iter().flatten() {
 -                out.extend_from_slice(&quad_vertices_shaded(
 -                    x + bl, y + h - d1, bottom_w, bw, sw, sh, c1, c1, c0, c0, clip_circle,
 -                ));
 -            }
 -        }
 -        // Left: outward normal (-1,0); gradient runs rightward.
 -        if left_h > 0.0 && edges.3 {
 -            let (v0, v1) = (value(-lx, d0), value(-lx, d1));
 -            for (c0, c1) in passes(v0, v1).into_iter().flatten() {
 -                out.extend_from_slice(&quad_vertices_shaded(
 -                    x + d0, y + left_top, bw, left_h, sw, sh, c0, c1, c1, c0, clip_circle,
 -                ));
 -            }
 -        }
 -        // Right: outward normal (1,0); gradient runs leftward.
 -        if right_h > 0.0 && edges.1 {
 -            let (v0, v1) = (value(lx, d0), value(lx, d1));
 -            for (c0, c1) in passes(v0, v1).into_iter().flatten() {
 -                out.extend_from_slice(&quad_vertices_shaded(
 -                    x + ww - d1, y + right_top, bw, right_h, sw, sh, c1, c0, c0, c1, clip_circle,
 -                ));
 -            }
 -        }
 -    }
 -
 -    // A corner arc belongs to both of its adjoining walls, so it is drawn only when both
 -    // are — otherwise a suppressed wall would still get a quarter of a lip.
 -    let corners = [
 -        (x + tl, y + tl, tl, std::f32::consts::PI, 1.5 * std::f32::consts::PI, edges.0 && edges.3), // Top-Left
 -        (x + ww - tr, y + tr, tr, 1.5 * std::f32::consts::PI, 2.0 * std::f32::consts::PI, edges.0 && edges.1), // Top-Right
 -        (x + ww - br, y + h - br, br, 0.0, 0.5 * std::f32::consts::PI, edges.2 && edges.1), // Bottom-Right
 -        (x + bl, y + h - bl, bl, 0.5 * std::f32::consts::PI, std::f32::consts::PI, edges.2 && edges.3), // Bottom-Left
 -    ];
 -
 -    for &(cx, cy, r, start_angle, end_angle, enabled) in &corners {
 -        // A square corner has no arc to sweep — the flat edges already met there.
 -        if r <= 0.0 || !enabled {
 -            continue;
 -        }
 -        // The corner is a quarter of a torus: shading varies along the sweep (the normal
 -        // swings through 90° of the light) *and* across the lip (the roll-off). Both come
 -        // out of the vertex colors, so one quad per (segment × band) cell is enough — no
 -        // faceting, unlike the 16 flat wedges this replaced.
 -        let segments = ((r * 0.75) as usize).clamp(8, 48);
 -        let ct = t.min(r);
 -        for j in 0..segments {
 -            let theta0 = start_angle + (j as f32) * (end_angle - start_angle) / (segments as f32);
 -            let theta1 = start_angle + ((j + 1) as f32) * (end_angle - start_angle) / (segments as f32);
 -            let (cos0, sin0) = (theta0.cos(), theta0.sin());
 -            let (cos1, sin1) = (theta1.cos(), theta1.sin());
 -            for k in 0..bands {
 -                let d0 = span_lo + (span_hi - span_lo) * (k as f32 / bands as f32);
 -                let d1 = span_lo + (span_hi - span_lo) * ((k + 1) as f32 / bands as f32);
 -                // Inward along the corner's radius is the same signed distance as inward
 -                // from a flat edge, so the arc scales the span the same way.
 -                let (r0, r1) = (r - ct * (d0 / t), r - ct * (d1 / t));
 -                let p = |rho: f32, c: f32, s: f32| -> [f32; 2] {
 -                    [
 -                        ((cx + rho * c) / sw) * 2.0 - 1.0,
 -                        1.0 - ((cy + rho * s) / sh) * 2.0,
 -                    ]
 -                };
 -                // Outer/inner × the two sweep ends; each vertex gets its own value, and
 -                // the cell is drawn once per overlay pass that has any coverage.
 -                let vals = [
 -                    value(cos0 * lx + sin0 * ly, d0),
 -                    value(cos1 * lx + sin1 * ly, d0),
 -                    value(cos1 * lx + sin1 * ly, d1),
 -                    value(cos0 * lx + sin0 * ly, d1),
 -                ];
 -                let geo = [
 -                    p(r0, cos0, sin0),
 -                    p(r0, cos1, sin1),
 -                    p(r1, cos1, sin1),
 -                    p(r1, cos0, sin0),
 -                ];
 -                let mut cells: [Option<fn(f32) -> [f32; 4]>; 2] = [None, None];
 -                if vals.iter().any(|&v| v > 0.0) {
 -                    cells[0] = Some(overlay_light);
 -                }
 -                if vals.iter().any(|&v| v < 0.0) {
 -                    cells[1] = Some(overlay_dark);
 -                }
 -                for f in cells.into_iter().flatten() {
 -                    let c: Vec<Vertex> = (0..4)
 -                        .map(|i| Vertex { position: geo[i], color: f(vals[i]), clip_circle })
 -                        .collect();
 -                    out.extend_from_slice(&[c[0], c[1], c[2], c[0], c[2], c[3]]);
 -                }
 -            }
 -        }
 -    }
 -}
 -
 -/// How strong the face gradient is, as a fraction of `bevel_depth` at the corner nearest
 -/// the light. Deliberately well below the edge amplitude: the face is a plane, not a
 -/// roll — it only *leans* toward the light.
 -const FACE_RATIO: f32 = 0.35;
 -
 -/// The face lighting of a plate: a single diagonal luminance gradient across the whole
 -/// surface, brightest at the corner facing `light_source_position` and darkest at the
 -/// opposite one. This is the difference between an object and a sticker: a real surface
 -/// under directional light is never uniform, and a perfectly flat fill makes the eye
 -/// read the (much smaller) edge shading as frame decoration rather than shape.
 -///
 -/// Emitted as the same two-pass white/black overlays as the relief primitives (see
 -/// [`overlay_light`]/[`overlay_dark`]): fixed RGB per pass, per-corner alphas clamped at
 -/// the terminator, bilinear across the quad. The quad is square — its corners poke past
 -/// a rounded plate's arcs — but the compositor clips the window surface to the same
 -/// radius, so the overhang never reaches the screen.
 -pub fn push_plate_face_vertices(
 -    x: f32, y: f32, ww: f32, h: f32,
 -    sw: f32, sh: f32,
 -    clip_circle: [f32; 3],
 -    out: &mut Vec<Vertex>,
 -) {
 -    let rad = crate::layout::light_source_position();
 -    let (lx, ly) = (rad.cos(), -rad.sin());
 -    let amp = crate::layout::bevel_depth() * FACE_RATIO;
 -    // Corner value = how much its outward diagonal faces the light.
 -    let inv = std::f32::consts::FRAC_1_SQRT_2;
 -    let v_tl = amp * inv * (-lx - ly);
 -    let v_tr = amp * inv * (lx - ly);
 -    let v_br = amp * inv * (lx + ly);
 -    let v_bl = amp * inv * (-lx + ly);
 -    let vs = [v_tl, v_tr, v_br, v_bl];
 -    if vs.iter().any(|&v| v > 0.0) {
 -        out.extend_from_slice(&quad_vertices_shaded(
 -            x, y, ww, h, sw, sh,
 -            overlay_light(v_tl), overlay_light(v_tr), overlay_light(v_br), overlay_light(v_bl),
 -            clip_circle,
 -        ));
 -    }
 -    if vs.iter().any(|&v| v < 0.0) {
 -        out.extend_from_slice(&quad_vertices_shaded(
 -            x, y, ww, h, sw, sh,
 -            overlay_dark(v_tl), overlay_dark(v_tr), overlay_dark(v_br), overlay_dark(v_bl),
 -            clip_circle,
 -        ));
 -    }
 -}
 -
 -pub fn push_plate_solid_border_vertices(
 -    x: f32, y: f32, ww: f32, h: f32,
 -    radii: crate::widget::CornerRadii,
 -    t: f32,
 -    sw: f32, sh: f32,
 -    color: [f32; 4],
 -    clip_circle: [f32; 3],
 -    out: &mut Vec<Vertex>,
 -) {
 -    let mut r_tl = radii.top_left.max(0.0);
 -    let mut r_tr = radii.top_right.max(0.0);
 -    let mut r_br = radii.bottom_right.max(0.0);
 -    let mut r_bl = radii.bottom_left.max(0.0);
 -
 -    // Simple scale clamping
 -    let sum_top = r_tl + r_tr;
 -    if sum_top > ww {
 -        let f = ww / sum_top;
 -        r_tl *= f;
 -        r_tr *= f;
 -    }
 -    let sum_bottom = r_bl + r_br;
 -    if sum_bottom > ww {
 -        let f = ww / sum_bottom;
 -        r_bl *= f;
 -        r_br *= f;
 -    }
 -    let sum_left = r_tl + r_bl;
 -    if sum_left > h {
 -        let f = h / sum_left;
 -        r_tl *= f;
 -        r_bl *= f;
 -    }
 -    let sum_right = r_tr + r_br;
 -    if sum_right > h {
 -        let f = h / sum_right;
 -        r_tr *= f;
 -        r_br *= f;
 -    }
 -
 -    out.extend_from_slice(&quad_vertices_with_clip(x + r_tl, y, ww - r_tl - r_tr, t, sw, sh, color, clip_circle));
 -    out.extend_from_slice(&quad_vertices_with_clip(x, y + r_tl, t, h - r_tl - r_bl, sw, sh, color, clip_circle));
 -    out.extend_from_slice(&quad_vertices_with_clip(x + r_bl, y + h - t, ww - r_bl - r_br, t, sw, sh, color, clip_circle));
 -    out.extend_from_slice(&quad_vertices_with_clip(x + ww - t, y + r_tr, t, h - r_tr - r_br, sw, sh, color, clip_circle));
 -
 -    let segments = 16;
 -    let corner_e = 2.0 / crate::layout::corner_shape();
 -
 -    // Corner strokes as annulus strips between the outer superellipse (radius
 -    // r) and its inner scaled copy (r - t): at 1px thickness the scaled inner
 -    // curve is indistinguishable from the true parallel curve, and at
 -    // corner_shape 2 this is exactly the circular arc annulus. NOT
 -    // push_arc_background_vertices — that stays circular for genuine arcs.
 -    //
 -    // Both edges of the annulus are FEATHERED, like the fill fan's corners
 -    // (push_rounded_rect_vertices_corners) and push_feathered_line_vertices:
 -    // each fades over `f` either side of its true curve, so the 50%-coverage
 -    // lines stay on the exact silhouette and the stroke reads at the same
 -    // weight, but the arc anti-aliases instead of rasterizing a staircase
 -    // beside the SDF-smooth face it outlines. The straight edges stay crisp
 -    // quads (a pixel-snapped hairline would only blur). A corner too tight to
 -    // fit the inner fade keeps the hard annulus.
 -    let f = 0.5f32.min(t * 0.25);
 -    let fade = [color[0], color[1], color[2], 0.0];
 -    let corner = |cx: f32, cy: f32, r: f32, start: f32, end: f32, out: &mut Vec<Vertex>| {
 -        let r_in = (r - t).max(0.0);
 -        // (inner radius, outer radius, inner alpha colour, outer alpha colour)
 -        let bands: &[(f32, f32, [f32; 4], [f32; 4])] = if r_in - f > 0.0 {
 -            &[
 -                (r_in - f, r_in + f, fade, color),
 -                (r_in + f, r - f, color, color),
 -                (r - f, r + f, color, fade),
 -            ]
 -        } else {
 -            &[(r_in, r, color, color)]
 -        };
 -        let ndc = |px: f32, py: f32| [(px / sw) * 2.0 - 1.0, 1.0 - (py / sh) * 2.0];
 -        for i in 0..segments {
 -            let t1 = start + (i as f32) * (end - start) / segments as f32;
 -            let t2 = start + ((i + 1) as f32) * (end - start) / segments as f32;
 -            let (c1, s1) = superellipse_pt(t1, corner_e);
 -            let (c2, s2) = superellipse_pt(t2, corner_e);
 -            for &(ra, rb, ca, cb) in bands {
 -                if rb - ra <= 0.0 {
 -                    continue;
 -                }
 -                let o1 = ndc(cx + rb * c1, cy + rb * s1);
 -                let o2 = ndc(cx + rb * c2, cy + rb * s2);
 -                let i1 = ndc(cx + ra * c1, cy + ra * s1);
 -                let i2 = ndc(cx + ra * c2, cy + ra * s2);
 -                out.push(Vertex { position: o1, color: cb, clip_circle });
 -                out.push(Vertex { position: o2, color: cb, clip_circle });
 -                out.push(Vertex { position: i1, color: ca, clip_circle });
 -                out.push(Vertex { position: o2, color: cb, clip_circle });
 -                out.push(Vertex { position: i2, color: ca, clip_circle });
 -                out.push(Vertex { position: i1, color: ca, clip_circle });
 -            }
 -        }
 -    };
 -
 -    if r_tl > 0.1 {
 -        corner(x + r_tl, y + r_tl, r_tl, std::f32::consts::PI, 1.5 * std::f32::consts::PI, out);
 -    }
 -    if r_tr > 0.1 {
 -        corner(x + ww - r_tr, y + r_tr, r_tr, 1.5 * std::f32::consts::PI, 2.0 * std::f32::consts::PI, out);
 -    }
 -    if r_br > 0.1 {
 -        corner(x + ww - r_br, y + h - r_br, r_br, 0.0, 0.5 * std::f32::consts::PI, out);
 -    }
 -    if r_bl > 0.1 {
 -        corner(x + r_bl, y + h - r_bl, r_bl, 0.5 * std::f32::consts::PI, std::f32::consts::PI, out);
 -    }
 -}
 -
 -pub fn push_plate_solid_border_vertices_legacy(
 -    x: f32, y: f32, ww: f32, h: f32,
 -    r: f32,
 -    t: f32,
 -    sw: f32, sh: f32,
 -    color: [f32; 4],
 -    clip_circle: [f32; 3],
 -    out: &mut Vec<Vertex>,
 -) {
 -    let radii = crate::widget::CornerRadii::uniform(r);
 -    push_plate_solid_border_vertices(x, y, ww, h, radii, t, sw, sh, color, clip_circle, out);
 -}
 -
 -pub fn widget_vertices(w: &dyn crate::widget::WidgetHost, sw: f32, sh: f32, clip_circle: [f32; 3]) -> Vec<Vertex> {
 -    let mut verts = Vec::new();
 -    push_widget_vertices(w, sw, sh, clip_circle, &mut verts);
 -    verts
 -}
 -
 -pub fn push_widget_vertices(w: &dyn crate::widget::WidgetHost, sw: f32, sh: f32, clip_circle: [f32; 3], out: &mut Vec<Vertex>) {
 -    let (x, y, ww, h) = w.rect();
 -    let radii = w.corner_radii();
 -    if let Some(thickness) = w.plate_bevel() {
 -        let t = thickness;
 -        // Full-size fill: the bevel lip is a shading overlay now, not a paint of the
 -        // outer ring, so an inset fill would leave the ring unfilled.
 -        push_rounded_rect_vertices_corners(x, y, ww, h, radii, sw, sh, w.color(), clip_circle, None, out);
 -        push_plate_bevel_vertices(x, y, ww, h, radii.top_left, t, sw, sh, w.color(), clip_circle, out);
 -    } else {
 -        push_rounded_rect_vertices_corners(x, y, ww, h, radii, sw, sh, w.color(), clip_circle, None, out);
 -        if let Some((color, thickness)) = w.solid_border() {
 -            push_plate_solid_border_vertices(x, y, ww, h, radii, thickness, sw, sh, color, clip_circle, out);
 -        }
 -    }
 -
 -    for (cx, cy, r, t, start, end, qc) in w.extra_arcs() {
 -        push_arc_background_vertices(cx, cy, r, t, start, end, sw, sh, qc, 16, clip_circle, out);
 -    }
 -}
 -
 -/// A contiguous run of vertices sharing one scissor rect (Phase 3 single paint path) and one
 -/// rounded-rect clip. `scissor` is a logical-pixel clip (`None` = unclipped); `clip_rrect` is
 -/// the paint walk's `[cx, cy, bx, by, r]` rounded clip in logical px (`None` = unclipped),
 -/// applied as per-draw push-constant state; `start..end` indexes the flat vertex buffer.
 -pub struct DlBatch {
 -    pub scissor: Option<crate::scene::layout::Rect>,
 -    pub clip_rrect: Option<[f32; 5]>,
 -    pub start: u32,
 -    pub end: u32,
 -    /// When set, this batch is one SDF-lit plate cover quad (see
 -    /// [`crate::vk::PlatePush`]; already in physical px). Never merged.
 -    pub plate: Option<crate::vk::PlatePush>,
 -    /// A blur-behind plate (negative-alpha color): before drawing this batch
 -    /// the renderer snapshots the swapchain-so-far into its snapshot image, so
 -    /// the blur samples everything painted beneath the plate — not just the 3D
 -    /// scene backdrop. Never merged.
 -    pub blur_behind: bool,
 -}
 -
 -/// An image draw from the display list: `at` is the vertex index it sorts
 -/// before (its position in the tessellated stream); `clip` is the item's
 -/// paint-walk clip. Logical coordinates throughout.
 -pub struct DlImage {
 -    pub image: u32,
 -    pub rect: crate::scene::layout::Rect,
 -    pub alpha: f32,
 -    pub at: u32,
 -    pub clip: Option<crate::scene::layout::Rect>,
 -}
 -
 -/// Tessellate a `scene::paint::DisplayList`'s geometry into a flat vertex buffer plus per-clip draw
 -/// batches, reusing the same tessellators as the legacy path so vertices are identical. `Text`
 -/// prims are skipped here — text is still rendered via the app's `text_areas()` path. `sw`/`sh` are
 -/// logical surface dimensions (as everywhere else); `scale` is the HiDPI factor, needed because an
 -/// item's circular clip rides the vertices in PHYSICAL pixels. Consecutive prims sharing a clip are
 -/// merged into one batch (the circle clip is per-vertex, so it never splits batches).
 -/// `CCE_PLATE_DEBUG=1` — trace which carves group into their host plate as exact
 -/// CSG features and which fall back to the standalone overlay shading.
 -///
 -/// The two paths do NOT look the same: a grouped carve is part of the plate's
 -/// single height field, so its wall meets the plate's rolled perimeter as a real
 -/// junction, while the fallback approximates that with the host-box fade. Six
 -/// conditions decide it, three of them dynamic (draw order, neighbouring plates,
 -/// whether another carve already claimed the host's feature run), so the SAME
 -/// widget can render either way depending on what is around it — and it does so
 -/// silently. That has already shipped as a bug once: a hovered button's opaque
 -/// fill used to sever every later button from the root plate they carve into,
 -/// which is why `plate_stack` is a stack (see its comment below).
 -///
 -/// Off by default and read once; the classification below runs only when set.
 -/// Prim discriminant name, for `CCE_PLATE_DEBUG` reporting only.
 -fn prim_kind(p: &crate::scene::paint::Prim) -> &'static str {
 -    use crate::scene::paint::Prim as P;
 -    match p {
 -        P::Quad { .. } => "Quad", P::RoundedRect { .. } => "RoundedRect",
 -        P::Border { .. } => "Border", P::Bevel { .. } => "Bevel",
 -        P::Recess { .. } => "Recess", P::Boss { .. } => "Boss",
 -        P::Ridge { .. } => "Ridge", P::Trough { .. } => "Trough", P::Field { .. } => "Field", P::Plate { .. } => "Plate",
 -        P::Arc { .. } => "Arc", P::ArcShaded { .. } => "ArcShaded",
 -        P::Vector { .. } => "Vector", P::Circle { .. } => "Circle",
 -        P::Sphere { .. } => "Sphere", P::Droplet { .. } => "Droplet",
 -        P::DropletScrim { .. } => "DropletScrim",
 -        P::ConcaveFillet { .. } => "ConcaveFillet",
 -        P::Groove { .. } => "Groove", P::Lattice { .. } => "Lattice", P::Grout { .. } => "Grout", P::Fill { .. } => "Fill",
 -        P::CarveUnion { .. } => "CarveUnion", P::Glow { .. } => "Glow",
 -        P::Text { .. } => "Text", P::Image { .. } => "Image",
 -    }
 -}
 -
 -fn plate_debug() -> bool {
 -    static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
 -    *ON.get_or_init(|| std::env::var("CCE_PLATE_DEBUG").is_ok_and(|v| v != "0"))
 -}
 -
 -/// Debug builds make one kind of fallback LOUD without `CCE_PLATE_DEBUG`: a
 -/// carve that could group (full ring, untinted) failing to while a still-open
 -/// plate encloses it and the carve's shaded region reaches that plate's
 -/// perimeter roll. There the grouped and overlay paths shade the junction
 -/// differently, and the rejection is one of the dynamic rules — so the SAME
 -/// widget can flip looks frame to frame with nothing on stderr. Not an
 -/// assert/panic: every rejection is conservative-CORRECT (the audit that
 -/// shipped CCE_PLATE_DEBUG found no misgrouping; a later plate overlapping the
 -/// carve genuinely must be shaded over, not under) — it is the frame-to-frame
 -/// LOOK that flips, so the right loudness is an unmissable warning, not a
 -/// crash. The ubiquitous quiet case stays quiet by construction: ordinary
 -/// geometry closing every grouping window empties `plate_stack`, so no
 -/// enclosing OPEN plate exists and this never runs — that is draw-order
 -/// design, not a flip.
 -///
 -/// Returns the dynamic rule to report, or `None` when the fallback is not the
 -/// loud case. Pure so the classification is unit-testable; `later_plates` are
 -/// the open plates emitted after the enclosing host.
 -#[cfg(debug_assertions)]
 -fn near_roll_fallback_reason(
 -    carve: &crate::scene::layout::Rect,
 -    depth: f32,
 -    host: &crate::scene::layout::Rect,
 -    roll: f32,
 -    later_plates: &[crate::scene::layout::Rect],
 -    budget_full: bool,
 -) -> Option<&'static str> {
 -    // The carve's shaded region — the overlay path's cover-quad inflation.
 -    let infl = depth * 0.5 + 2.0;
 -    let (sx0, sy0) = (carve.x - infl, carve.y - infl);
 -    let (sx1, sy1) = (carve.x + carve.width + infl, carve.y + carve.height + infl);
 -    // "Near the roll" = the shaded region leaves the host rect deflated by the
 -    // host's own roll width on any side.
 -    let near = sx0 < host.x + roll
 -        || sy0 < host.y + roll
 -        || sx1 > host.x + host.width - roll
 -        || sy1 > host.y + host.height - roll;
 -    if !near {
 -        return None;
 -    }
 -    // The dynamic rules, in the order the grouping guard tests them.
 -    if budget_full {
 -        return Some("the feature budget is full");
 -    }
 -    if later_plates
 -        .iter()
 -        .any(|o| sx0 < o.x + o.width && sx1 > o.x && sy0 < o.y + o.height && sy1 > o.y)
 -    {
 -        return Some("a later plate overlaps the carve's shaded region");
 -    }
 -    Some("the host's feature run is closed (another plate appended features since)")
 -}
 -
 -/// Print a near-roll fallback warning once per distinct message — a carve in a
 -/// steady layout would otherwise repeat it every frame.
 -#[cfg(debug_assertions)]
 -fn plate_carve_warn_once(msg: String) {
 -    use std::sync::{Mutex, OnceLock};
 -    static SEEN: OnceLock<Mutex<std::collections::HashSet<String>>> = OnceLock::new();
 -    let seen = SEEN.get_or_init(|| Mutex::new(std::collections::HashSet::new()));
 -    if seen.lock().unwrap().insert(msg.clone()) {
 -        eprintln!("{msg}");
 -    }
 -}
 -
 -pub fn tessellate_display_list(
 -    dl: &crate::scene::paint::DisplayList,
 -    sw: f32,
 -    sh: f32,
 -    scale: f32,
 -) -> (Vec<Vertex>, Vec<DlBatch>, Vec<DlImage>, Vec<[f32; 12]>) {
 -    use crate::scene::material::PlateRole;
 -    use crate::scene::paint::{Cap, Prim};
 -    let mut verts: Vec<Vertex> = Vec::new();
 -    let mut batches: Vec<DlBatch> = Vec::new();
 -    let mut images: Vec<DlImage> = Vec::new();
 -    // Carves CSG'd into plates (see Frame2D::plate_features), plus the plate
 -    // they group into: the most recent Plate/Bevel batch, provided only Text
 -    // and Image prims (which draw through separate paths anyway) intervene.
 -    let mut features: Vec<[f32; 12]> = Vec::new();
 -    // Open carve-host plates, in emission order (innermost candidates last).
 -    // A STACK, not a single slot: a sibling plate emitted between a root plate
 -    // and its later carves (a hovered button's opaque fill among transparent
 -    // ones) must not sever those carves from the root plate they are carved
 -    // into — that severing rendered every button after the hovered one
 -    // through the visually-different overlay fallback. Ordinary geometry
 -    // still closes every open plate (the draw-order rule below).
 -    let mut plate_stack: Vec<(usize, crate::scene::layout::Rect)> = Vec::new();
 -    // Which plate last appended a carve feature: a plate's features are
 -    // addressed as one contiguous [offset, count] run (PlatePush::host), so a
 -    // plate may only receive MORE features while no other plate has appended
 -    // any since.
 -    let mut last_feature_plate: Option<usize> = None;
 -    // `CCE_PLATE_DEBUG` bookkeeping — see `plate_debug`.
 -    let dbg_plates = plate_debug();
 -    let mut dbg_grouped = 0usize;
 -    let mut dbg_fell_back: Vec<String> = Vec::new();
 -    let mut dbg_opened = 0usize;
 -    // Which prim kind closed a still-open grouping window, and how many plates
 -    // it closed — the answer to "why was there no enclosing plate?".
 -    let mut dbg_closed_by: std::collections::BTreeMap<&'static str, usize> =
 -        std::collections::BTreeMap::new();
 -
 -    // SDF-lit plate path (shader2d's plate branch) vs the legacy banded vertex
 -    // shading, plus the frame-constant lighting inputs it pushes per plate.
 -    let shader_plates = crate::layout::bevel_shader();
 -    // Light and material come from `scene::relief_shade`, which is also what
 -    // cce-relief predicts pixels with — one definition, so the editor cannot
 -    // draw a different material than the renderer applies.
 -    let plate_light = crate::scene::relief_shade::light_vector();
 -    // [shading strength (1.0 at the default bevel_depth), specular strength,
 -    // shininess, curvature/AO strength] — the DE's finish, for the CARVES,
 -    // which shade whatever is beneath them and so take the host's. A prim
 -    // that carries a Material (Plate, Bevel, Sphere, Droplet) pushes its own
 -    // `material.finish` instead. Curvature is kept near the raised path's
 -    // crest amplitude: the recess shoulder's brightening lands on the same
 -    // pixels as its specular line, and the two stack — at 0.5 the step read
 -    // several times hotter than a plate roll.
 -    let plate_mat = crate::scene::material::Finish::from_style().to_array();
 -
 -    for item in &dl.items {
 -        let mut start = verts.len() as u32;
 -        let mut plate: Option<crate::vk::PlatePush> = None;
 -        // A frosted flat fill promoted to a zero-depth plate batch (below):
 -        // it carries a recipe like any plate, but it is ordinary geometry to
 -        // the carve grouping — it opens no host and closes the open ones.
 -        let mut promoted = false;
 -        let mut made_plate: Option<crate::scene::layout::Rect> = None;
 -        // Blur-behind marker: a prim whose FILL alpha is negative asks the
 -        // renderer to snapshot the frame-so-far before it draws. Every
 -        // fill-bearing prim counts — the shader's a<0 branch runs for all of
 -        // them, and a variant missing here still frosts, but against the
 -        // stale scene backdrop instead of the frame: a flat tint with no
 -        // content and no blur, which is how the Dropdown popover (Border)
 -        // and the menubar panels (Quad) shipped visibly unfrosted while the
 -        // context menu (Plate) worked.
 -        let mut blur_behind = matches!(
 -            &item.prim,
 -            crate::scene::paint::Prim::Quad { color, .. }
 -            | crate::scene::paint::Prim::RoundedRect { color, .. } if color[3] < 0.0
 -        ) || matches!(
 -            &item.prim,
 -            crate::scene::paint::Prim::Bevel { material, .. }
 -            | crate::scene::paint::Prim::Plate { material, .. }
 -            | crate::scene::paint::Prim::Droplet { material, .. }
 -                if material.fill(PlateRole::Nested)[3] < 0.0
 -        ) || matches!(
 -            &item.prim,
 -            crate::scene::paint::Prim::Border { fill, .. } if fill[3] < 0.0
 -        ) || matches!(
 -            &item.prim,
 -            crate::scene::paint::Prim::Fill { material, .. } if material.fill(PlateRole::Nested)[3] < 0.0
 -        );
 -        // Logical [cx, cy, r] → the physical-pixel triple the vertex attribute carries.
 -        let no = item
 -            .clip_circle
 -            .map(|c| [c[0] * scale, c[1] * scale, c[2] * scale])
 -            .unwrap_or([0.0f32, 0.0, 0.0]);
 -        // Fixed 16-segment fans read as polygons once a circle/arc is pane-sized; scale
 -        // the fan with the PHYSICAL radius (capped — beyond 128 the chord error is
 -        // subpixel even on HiDPI).
 -        let segs = |radius: f32| -> usize { ((radius * scale) as usize).clamp(16, 128) };
 -        match &item.prim {
 -            Prim::Text { .. } => continue, // text goes through the glyph/text-span path
 -            Prim::Image { image, rect, alpha } => {
 -                images.push(DlImage {
 -                    image: *image,
 -                    rect: *rect,
 -                    alpha: *alpha,
 -                    at: verts.len() as u32,
 -                    clip: item.clip,
 -                });
 -                continue;
 -            }
 -            // A frosted FLAT fill — a `Flat` control face, a menu panel, a
 -            // popover, an inset plate's face — is a zero-depth plate batch
 -            // (RFC material § 6.2): the same shader path as every plate, so
 -            // it carries its own frost recipe instead of a window-wide one,
 -            // with the configured `corner_shape` and no roll, which is what the
 -            // tessellated fill drew. The display list is untouched, so the
 -            // legacy bridges that extract RoundedRects still see one.
 -            Prim::Quad { rect, color } if shader_plates && color[3] < 0.0 => {
 -                verts.extend(quad_vertices(rect.x, rect.y, rect.width, rect.height, sw, sh, *color));
 -                plate = Some(flat_frost_push(rect, (0.0, 0.0, 0.0, 0.0), *color, scale, plate_light, plate_mat));
 -                promoted = true;
 -            }
 -            Prim::RoundedRect { rect, radius, corners, color } if shader_plates && color[3] < 0.0 => {
 -                let radii = (
 -                    if corners.0 { *radius } else { 0.0 },
 -                    if corners.1 { *radius } else { 0.0 },
 -                    if corners.2 { *radius } else { 0.0 },
 -                    if corners.3 { *radius } else { 0.0 },
 -                );
 -                verts.extend(quad_vertices(rect.x, rect.y, rect.width, rect.height, sw, sh, *color));
 -                plate = Some(flat_frost_push(rect, radii, *color, scale, plate_light, plate_mat));
 -                promoted = true;
 -            }
 -            Prim::Fill { rect, radii, material } if shader_plates && material.frost.is_frosted() => {
 -                // A material's flat fill: the frosted promotion above with
 -                // the MATERIAL's recipe (compression, refraction, radius)
 -                // instead of the DE default's. Zero depth, the configured
 -                // corner shape, no host — exactly a promoted RoundedRect.
 -                let color = material.fill(PlateRole::Nested);
 -                verts.extend(quad_vertices(rect.x, rect.y, rect.width, rect.height, sw, sh, color));
 -                let mut p = plate_push_raised(rect, *radii, 0.0, scale, plate_light, plate_mat, false, None);
 -                let [fz, fw] = material.frost.pack(scale);
 -                p.host[2] = fz;
 -                p.host[3] = fw;
 -                plate = Some(p);
 -                promoted = true;
 -            }
 -            Prim::Fill { rect, radii, material } => {
 -                // Opaque (or the legacy path): a plain rounded fill.
 -                let cr = crate::widget::CornerRadii::new(radii.0, radii.1, radii.2, radii.3);
 -                push_rounded_rect_vertices_corners(rect.x, rect.y, rect.width, rect.height, cr, sw, sh, material.fill(PlateRole::Nested), no, None, &mut verts);
 -            }
 -            Prim::Border { rect, radii, fill, border, thickness } if shader_plates && fill[3] < 0.0 => {
 -                // The fill as its own plate batch, closed here; the stroke
 -                // follows as ordinary geometry in the batch the tail makes.
 -                verts.extend(quad_vertices(rect.x, rect.y, rect.width, rect.height, sw, sh, *fill));
 -                let p = flat_frost_push(rect, *radii, *fill, scale, plate_light, plate_mat);
 -                let end = verts.len() as u32;
 -                plate_stack.clear();
 -                batches.push(DlBatch { scissor: item.clip, clip_rrect: item.clip_rrect, start, end, plate: Some(p), blur_behind: true });
 -                start = end;
 -                blur_behind = false;
 -                let cr = crate::widget::CornerRadii::new(radii.0, radii.1, radii.2, radii.3);
 -                push_plate_solid_border_vertices(rect.x, rect.y, rect.width, rect.height, cr, *thickness, sw, sh, *border, no, &mut verts);
 -            }
 -            Prim::Quad { rect, color } => {
 -                // Quads honor an active circle clip like circles/arcs do (the
 -                // Ramp's foam-cell fills draw as clipped strips).
 -                verts.extend(quad_vertices_with_clip(rect.x, rect.y, rect.width, rect.height, sw, sh, *color, no));
 -            }
 -            Prim::RoundedRect { rect, radius, corners, color } => {
 -                let radii = crate::widget::CornerRadii::new(
 -                    if corners.0 { *radius } else { 0.0 },
 -                    if corners.1 { *radius } else { 0.0 },
 -                    if corners.2 { *radius } else { 0.0 },
 -                    if corners.3 { *radius } else { 0.0 },
 -                );
 -                push_rounded_rect_vertices_corners(rect.x, rect.y, rect.width, rect.height, radii, sw, sh, *color, no, None, &mut verts);
 -            }
 -            Prim::Border { rect, radii, fill, border, thickness } => {
 -                let cr = crate::widget::CornerRadii::new(radii.0, radii.1, radii.2, radii.3);
 -                push_rounded_rect_vertices_corners(rect.x, rect.y, rect.width, rect.height, cr, sw, sh, *fill, no, None, &mut verts);
 -                push_plate_solid_border_vertices(rect.x, rect.y, rect.width, rect.height, cr, *thickness, sw, sh, *border, no, &mut verts);
 -            }
 -            Prim::Glow { rect, radius, reach, color } => {
 -                push_glow_vertices(rect.x, rect.y, rect.width, rect.height, *radius, *reach, sw, sh, *color, no, &mut verts);
 -            }
 -            Prim::Bevel { rect, radii, material, depth, tint } if shader_plates => {
 -                let color = material.fill(PlateRole::Nested);
 -                let mat = material.finish.to_array();
 -                // SDF-lit raised plate: one cover quad; the shader owns fill,
 -                // roll shading, corners, and silhouette AA. Nominal corner
 -                // radii (scale_corners false): a Bevel is a WIDGET-scale plate
 -                // whose silhouette must match the nominal-radius squircles of
 -                // the controls around it — only window-scale `Plate`s get the
 -                // curvature-matched span.
 -                verts.extend(quad_vertices(rect.x, rect.y, rect.width, rect.height, sw, sh, color));
 -                let mut p = plate_push_raised(rect, *radii, *depth, scale, plate_light, mat, false, None);
 -                // The plate's own frost recipe rides host.zw (see PlatePush).
 -                let [fz, fw] = material.frost.pack(scale);
 -                p.host[2] = fz;
 -                p.host[3] = fw;
 -                // w = 1 marks an accent-tinted plate (the focused-pane
 -                // treatment): the shader keeps the roll's light and shadow
 -                // and recolours them — light toward the tint, shadow toward
 -                // a dark tint — matching the free-carve path's tinted-well
 -                // convention. Neutral white keeps w = 0 (a no-op multiply).
 -                let full = if *tint == [1.0, 1.0, 1.0] { 0.0 } else { 1.0 };
 -                p.specular_tint = [tint[0], tint[1], tint[2], full];
 -                plate = Some(p);
 -                made_plate = Some(*rect);
 -            }
 -            Prim::Plate { rect, radii, material, depth, shape } if shader_plates => {
 -                let color = material.fill(PlateRole::Nested);
 -                let mat = material.finish.to_array();
 -                if *depth < 0.0 {
 -                    // Negative depth = fill-less roll overlay (MODE_ROLL): the
 -                    // window-edge roll shading alone, screened over whatever is
 -                    // beneath — for a root plate whose face is not a fill (the
 -                    // designer's 3D canvas). The cover quad carries no color,
 -                    // and the batch is NOT opened as a carve host: an overlay
 -                    // owns no surface for a CSG feature to cut into.
 -                    verts.extend(quad_vertices(rect.x, rect.y, rect.width, rect.height, sw, sh, [0.0; 4]));
 -                    let mut p = plate_push_raised(rect, *radii, -*depth, scale, plate_light, mat, true, *shape);
 -                    p.mode = 11.0; // MODE_ROLL
 -                    plate = Some(p);
 -                } else {
 -                    // Same lit-plate branch; the cover quad is the exact rect so the
 -                    // silhouette and the compositor's rounded window corners agree.
 -                    verts.extend(quad_vertices(rect.x, rect.y, rect.width, rect.height, sw, sh, color));
 -                    let mut p = plate_push_raised(rect, *radii, *depth, scale, plate_light, mat, true, *shape);
 -                    let [fz, fw] = material.frost.pack(scale);
 -                    p.host[2] = fz;
 -                    p.host[3] = fw;
 -                    plate = Some(p);
 -                    made_plate = Some(*rect);
 -                }
 -            }
 -            // A sunken well ending in a flush run (see `Prim::Field`): one
 -            // outline, one overlay — never grouped, its profile is not a
 -            // monotonic step. The cover quad inflates by half the wall, as a
 -            // free carve's does; the host-box slot carries the run's two
 -            // ends (physical px), since nothing fades against a host here.
 -            Prim::Field { rect, radii, depth, split, end, tint } if shader_plates => {
 -                let infl = *depth * 0.5 + 2.0;
 -                verts.extend(quad_vertices(
 -                    rect.x - infl, rect.y - infl,
 -                    rect.width + 2.0 * infl, rect.height + 2.0 * infl,
 -                    sw, sh, [0.0; 4],
 -                ));
 -                let mut p = plate_push_raised(rect, *radii, *depth, scale, plate_light, plate_mat, false, None);
 -                p.mode = 16.0; // MODE_FIELD
 -                if let Some(t) = tint {
 -                    p.specular_tint = [t[0], t[1], t[2], 1.0];
 -                }
 -                p.host = [*split * scale, *end * scale, 0.0, 0.0];
 -                plate = Some(p);
 -            }
 -            Prim::Recess { rect, radii, depth, edges, .. }
 -            | Prim::Boss { rect, radii, depth, edges, .. }
 -            | Prim::Ridge { rect, radii, depth, edges }
 -            | Prim::Trough { rect, radii, depth, edges, .. }
 -                if shader_plates =>
 -            {
 -                let tint = match &item.prim {
 -                    Prim::Recess { tint, .. } => *tint,
 -                    Prim::Boss { tint, .. } => *tint,
 -                    Prim::Trough { tint, .. } => *tint,
 -                    _ => None,
 -                };
 -                // Recess carves down into the surface; Boss raises a plateau out
 -                // of it (same machinery, depth sign flipped); Ridge is a raised
 -                // rim straddling the boundary and Trough the sunken valley twin
 -                // (their own overlay profiles — never grouped, the CSG features
 -                // only model monotonic steps).
 -                let mode = match &item.prim {
 -                    Prim::Boss { .. } => 3.0f32,
 -                    Prim::Ridge { .. } => 4.0,
 -                    Prim::Trough { .. } => 9.0,
 -                    _ => 2.0,
 -                };
 -                let raised = mode > 2.5;
 -                // Grouped into the enclosing plate whenever one is live: the
 -                // carve becomes a CSG feature of that plate's single draw —
 -                // exact composite shading, real junctions at the plate's rolled
 -                // perimeter — instead of a shading overlay (the fallback below).
 -                //
 -                // Edge-suppressed carves NEVER group: a suppressed wall's rect
 -                // extends past the carve (below), relying on the overlay cover
 -                // quad to keep that shading out of the drawn pixels — a clip
 -                // the plate's whole-surface draw does not have, so grouped it
 -                // smears the extended walls across the plate. Union pieces
 -                // (section wells, a spinbox's field and button run) are
 -                // exactly these.
 -                // A tinted carve also never groups: a CSG feature is geometry only,
 -                // so the tint could only land on the whole plate's specular.
 -                let full_ring = *edges == (true, true, true, true);
 -                let host_plate = if mode < 3.5 && full_ring && tint.is_none() && features.len() < crate::vk::MAX_PLATE_FEATURES {
 -                    // The carve's shaded region, for the occlusion test below
 -                    // (the overlay path's cover-quad inflation).
 -                    let infl = *depth * 0.5 + 2.0;
 -                    let (sx0, sy0) = (rect.x - infl, rect.y - infl);
 -                    let (sx1, sy1) = (rect.x + rect.width + infl, rect.y + rect.height + infl);
 -                    plate_stack
 -                        .iter()
 -                        .enumerate()
 -                        .rev()
 -                        .find(|(si, (bi, prect))| {
 -                            let inside = rect.x >= prect.x - 0.5
 -                                && rect.y >= prect.y - 0.5
 -                                && rect.x + rect.width <= prect.x + prect.width + 0.5
 -                                && rect.y + rect.height <= prect.y + prect.height + 0.5;
 -                            if !inside {
 -                                return false;
 -                            }
 -                            // Pixels drawn since this plate (a LATER plate in the
 -                            // stack) must not overlap the carve — its shading would
 -                            // land beneath them in this plate's earlier draw.
 -                            if plate_stack[si + 1..].iter().any(|(_, orect)| {
 -                                sx0 < orect.x + orect.width
 -                                    && sx1 > orect.x
 -                                    && sy0 < orect.y + orect.height
 -                                    && sy1 > orect.y
 -                            }) {
 -                                return false;
 -                            }
 -                            // Contiguity: only the last feature-receiving plate (or
 -                            // one with no features yet) may take another.
 -                            batches[*bi].plate.as_ref().map_or(false, |p| p.host[1] == 0.0)
 -                                || last_feature_plate == Some(*bi)
 -                        })
 -                        .map(|(_, &(bi, _))| bi)
 -                } else {
 -                    None
 -                };
 -                // Debug-build loudness for the silent grouped→overlay flip —
 -                // see `near_roll_fallback_reason` on what qualifies and why
 -                // this warns instead of panicking.
 -                #[cfg(debug_assertions)]
 -                if host_plate.is_none() && mode < 3.5 && full_ring && tint.is_none() {
 -                    let enclosing = plate_stack.iter().enumerate().rev().find(|(_, (_, p))| {
 -                        rect.x >= p.x - 0.5
 -                            && rect.y >= p.y - 0.5
 -                            && rect.x + rect.width <= p.x + p.width + 0.5
 -                            && rect.y + rect.height <= p.y + p.height + 0.5
 -                    });
 -                    if let Some((si, &(bi, prect))) = enclosing {
 -                        // Host roll width rides the push's light.w (physical px).
 -                        let roll = batches[bi].plate.as_ref().map_or(0.0, |p| p.light[3]) / scale;
 -                        let later: Vec<crate::scene::layout::Rect> =
 -                            plate_stack[si + 1..].iter().map(|&(_, r)| r).collect();
 -                        let budget_full = features.len() >= crate::vk::MAX_PLATE_FEATURES;
 -                        if let Some(why) =
 -                            near_roll_fallback_reason(rect, *depth, &prect, roll, &later, budget_full)
 -                        {
 -                            let kind = if mode > 2.5 { "boss" } else { "recess" };
 -                            plate_carve_warn_once(format!(
 -                                "plate-carve: near-roll {kind} ({:.0},{:.0} {:.0}x{:.0}) lost grouping — {why}; \
 -                                 its junction with the host plate's roll shades through the overlay fallback, \
 -                                 visually different from grouped frames (CCE_PLATE_DEBUG=1 traces verdicts) \
 -                                 [debug-build warning, printed once]",
 -                                rect.x, rect.y, rect.width, rect.height
 -                            ));
 -                        }
 -                    }
 -                }
 -                if dbg_plates {
 -                    match host_plate {
 -                        Some(_) => dbg_grouped += 1,
 -                        None => {
 -                            // Re-derive WHY, in the same order the guard tests
 -                            // them. Debug-only: the hot path above is untouched.
 -                            let kind = match &item.prim {
 -                                Prim::Boss { .. } => "boss",
 -                                Prim::Ridge { .. } => "ridge",
 -                                Prim::Trough { .. } => "trough",
 -                                _ => "recess",
 -                            };
 -                            let infl = *depth * 0.5 + 2.0;
 -                            let (sx0, sy0) = (rect.x - infl, rect.y - infl);
 -                            let (sx1, sy1) = (rect.x + rect.width + infl, rect.y + rect.height + infl);
 -                            let enclosing: Vec<usize> = plate_stack
 -                                .iter()
 -                                .enumerate()
 -                                .filter(|(_, (_, p))| {
 -                                    rect.x >= p.x - 0.5
 -                                        && rect.y >= p.y - 0.5
 -                                        && rect.x + rect.width <= p.x + p.width + 0.5
 -                                        && rect.y + rect.height <= p.y + p.height + 0.5
 -                                })
 -                                .map(|(si, _)| si)
 -                                .collect();
 -                            let occluded = |si: usize| {
 -                                plate_stack[si + 1..].iter().any(|(_, o)| {
 -                                    sx0 < o.x + o.width && sx1 > o.x && sy0 < o.y + o.height && sy1 > o.y
 -                                })
 -                            };
 -                            let why = if mode >= 3.5 {
 -                                "ridge — never groups (its bump profile is not a monotonic step)".into()
 -                            } else if !full_ring {
 -                                format!("edge-suppressed {edges:?} — the extended wall would smear across the host")
 -                            } else if tint.is_some() {
 -                                "tinted — a CSG feature is geometry only, it carries no color".into()
 -                            } else if features.len() >= crate::vk::MAX_PLATE_FEATURES {
 -                                format!("feature budget full ({} used)", features.len())
 -                            } else if enclosing.is_empty() {
 -                                format!("no enclosing plate ({} open)", plate_stack.len())
 -                            } else if enclosing.iter().all(|&si| occluded(si)) {
 -                                "a later plate overlaps this carve's shaded region".into()
 -                            } else {
 -                                "host plate's feature run is closed (another carve appended since)".into()
 -                            };
 -                            dbg_fell_back.push(format!(
 -                                "  overlay: {kind} ({:.0},{:.0} {:.0}x{:.0}) — {why}",
 -                                rect.x, rect.y, rect.width, rect.height
 -                            ));
 -                        }
 -                    }
 -                }
 -                if let Some(bi) = host_plate {
 -                    {
 -                        // A wall the carve shares with the plate's edge extends
 -                        // past the plate, so the carve has no wall there.
 -                        let ext = *depth + 4.0;
 -                        let (mut x0, mut y0) = (rect.x, rect.y);
 -                        let (mut x1, mut y1) = (rect.x + rect.width, rect.y + rect.height);
 -                        if !edges.0 { y0 -= ext; }
 -                        if !edges.1 { x1 += ext; }
 -                        if !edges.2 { y1 += ext; }
 -                        if !edges.3 { x0 -= ext; }
 -                        let t_px = *depth * scale;
 -                        // The carve's drop: the material's pinned height, else
 -                        // the analytic ratio of the wall saturating at the DE's
 -                        // roll width (`layout::carve_depth_px` states the rule
 -                        // once for this path and the shader's free carves).
 -                        let k_mag = crate::layout::carve_depth_px(*depth) * scale;
 -                        // Negative depth = raised (Boss); the shader's summed
 -                        // slope vectors and curvature sign follow it.
 -                        let k_px = if raised { -k_mag } else { k_mag };
 -                        if let Some(p) = batches[bi].plate.as_mut() {
 -                            if p.host[1] == 0.0 {
 -                                p.host[0] = features.len() as f32;
 -                            }
 -                            p.host[1] += 1.0;
 -                        }
 -                        last_feature_plate = Some(bi);
 -                        features.push([
 -                            (x0 + x1) * 0.5 * scale,
 -                            (y0 + y1) * 0.5 * scale,
 -                            (x1 - x0) * 0.5 * scale,
 -                            (y1 - y0) * 0.5 * scale,
 -                            radii.0 * scale,
 -                            radii.1 * scale,
 -                            radii.2 * scale,
 -                            radii.3 * scale,
 -                            t_px,
 -                            k_px,
 -                            0.0,
 -                            0.0,
 -                        ]);
 -                        continue;
 -                    }
 -                }
 -                // Overlay-only carve: the cover quad inflates by half the roll
 -                // width (the step straddles the boundary) and carries no color —
 -                // the shader emits translucent white/black over what's beneath.
 -                let infl = *depth * 0.5 + 2.0;
 -                verts.extend(quad_vertices(
 -                    rect.x - infl, rect.y - infl,
 -                    rect.width + 2.0 * infl, rect.height + 2.0 * infl,
 -                    sw, sh, [0.0; 4],
 -                ));
 -                // A suppressed wall is pushed past the cover quad, so its
 -                // shading falls outside the drawn pixels (see Prim::Recess on
 -                // why a flush region is a step, not a trough).
 -                let ext = *depth + 4.0;
 -                let (mut x0, mut y0) = (rect.x, rect.y);
 -                let (mut x1, mut y1) = (rect.x + rect.width, rect.y + rect.height);
 -                if !edges.0 { y0 -= ext; }
 -                if !edges.1 { x1 += ext; }
 -                if !edges.2 { y1 += ext; }
 -                if !edges.3 { x0 -= ext; }
 -                let sdf_rect = crate::scene::layout::Rect { x: x0, y: y0, width: x1 - x0, height: y1 - y0 };
 -                let mut p = plate_push_raised(&sdf_rect, *radii, *depth, scale, plate_light, plate_mat, false, None);
 -                p.mode = mode;
 -                // w = 1.0 flags the free-carve shader path to composite its
 -                // light in the tint and its shadow in a dark tint instead of
 -                // white and black (plates leave w at 0.0).
 -                if let Some(t) = tint {
 -                    p.specular_tint = [t[0], t[1], t[2], 1.0];
 -                }
 -                // Host-plate box for the roll fade: a suppressed wall means the
 -                // recess runs flush to the host's edge there, so that side of
 -                // the box sits at the original rect edge; enabled walls face
 -                // host interior, pushed to ±1e5 so no fade applies.
 -                const FAR: f32 = 1e5;
 -                let (hx0, hy0) = (
 -                    if edges.3 { rect.x - FAR } else { rect.x },
 -                    if edges.0 { rect.y - FAR } else { rect.y },
 -                );
 -                let (hx1, hy1) = (
 -                    if edges.1 { rect.x + rect.width + FAR } else { rect.x + rect.width },
 -                    if edges.2 { rect.y + rect.height + FAR } else { rect.y + rect.height },
 -                );
 -                p.host = [
 -                    (hx0 + hx1) * 0.5 * scale,
 -                    (hy0 + hy1) * 0.5 * scale,
 -                    (hx1 - hx0) * 0.5 * scale,
 -                    (hy1 - hy0) * 0.5 * scale,
 -                ];
 -                plate = Some(p);
 -            }
 -            Prim::Bevel { rect, radii, material, depth, tint: _ } => {
 -                let color = material.fill(PlateRole::Nested);
 -                // Full-size fill: the lip is now a shading overlay, not a paint of the
 -                // outer ring, so the fill must cover the whole rect (the old inset fill
 -                // would leave the ring showing whatever lay beneath).
 -                let corners = crate::widget::CornerRadii {
 -                    top_left: radii.0, top_right: radii.1,
 -                    bottom_right: radii.2, bottom_left: radii.3,
 -                };
 -                push_rounded_rect_vertices_corners(rect.x, rect.y, rect.width, rect.height, corners, sw, sh, color, no, None, &mut verts);
 -                push_plate_bevel_vertices(rect.x, rect.y, rect.width, rect.height, radii.0, *depth, sw, sh, color, no, &mut verts);
 -            }
 -            Prim::Plate { rect, radii, material, depth, .. } => {
 -                let color = material.fill(PlateRole::Nested);
 -                if *depth < 0.0 {
 -                    // Fill-less roll overlay (negative-depth sentinel): the banded
 -                    // legacy tessellation has no overlay compositing, so the roll
 -                    // is simply absent here — the A/B path draws nothing rather
 -                    // than a wrong fill.
 -                    continue;
 -                }
 -                // Fill at full size (no inset — see Prim::Plate), then light the face,
 -                // then roll the perimeter. The lip rides on top of the fill's outer band
 -                // rather than replacing it, so the plate's silhouette and the
 -                // compositor's rounded window corners still agree exactly.
 -                let corners = crate::widget::CornerRadii {
 -                    top_left: radii.0, top_right: radii.1,
 -                    bottom_right: radii.2, bottom_left: radii.3,
 -                };
 -                push_rounded_rect_vertices_corners(
 -                    rect.x, rect.y, rect.width, rect.height, corners, sw, sh, color, no, None, &mut verts,
 -                );
 -                push_plate_face_vertices(rect.x, rect.y, rect.width, rect.height, sw, sh, no, &mut verts);
 -                push_bevel_edge_vertices_radii(
 -                    rect.x, rect.y, rect.width, rect.height, *radii, *depth,
 -                    sw, sh, color, no, 1.0, &mut verts,
 -                );
 -            }
 -            Prim::Recess { rect, radii, depth, edges, .. } => {
 -                // Edges only — no fill: the shading is an overlay, so whatever is painted
 -                // below (fill, rim gradient, blur) shows through the carve modulated
 -                // rather than repainted. `light_sign = -1.0` shadows the lit-facing edges,
 -                // which is the raised->recessed inversion.
 -                push_bevel_edge_vertices_banded(
 -                    rect.x, rect.y, rect.width, rect.height, *radii, *depth,
 -                    sw, sh, [0.0; 4], no, -1.0, default_bevel_bands(*depth), *edges,
 -                    EdgeKind::Step, &mut verts,
 -                );
 -            }
 -            Prim::Boss { rect, radii, depth, edges, .. } => {
 -                // Legacy raised step: the recess overlay with the light sign upright.
 -                push_bevel_edge_vertices_banded(
 -                    rect.x, rect.y, rect.width, rect.height, *radii, *depth,
 -                    sw, sh, [0.0; 4], no, 1.0, default_bevel_bands(*depth), *edges,
 -                    EdgeKind::Step, &mut verts,
 -                );
 -            }
 -            Prim::Ridge { rect, radii, depth, edges } => {
 -                // Legacy approximation: a raised step up at the boundary plus a
 -                // recessed step down half a width in (the banded machinery has no
 -                // bump profile; the double-pass hot crest is accepted here — the
 -                // legacy path exists only for A/B comparison).
 -                let half = *depth * 0.5;
 -                push_bevel_edge_vertices_banded(
 -                    rect.x, rect.y, rect.width, rect.height, *radii, half,
 -                    sw, sh, [0.0; 4], no, 1.0, default_bevel_bands(half), *edges,
 -                    EdgeKind::Step, &mut verts,
 -                );
 -                let ir = (radii.0 - half).max(0.0);
 -                push_bevel_edge_vertices_banded(
 -                    rect.x + half, rect.y + half,
 -                    rect.width - *depth, rect.height - *depth,
 -                    (ir, ir, ir, ir), half,
 -                    sw, sh, [0.0; 4], no, -1.0, default_bevel_bands(half), *edges,
 -                    EdgeKind::Step, &mut verts,
 -                );
 -            }
 -            Prim::Trough { rect, radii, depth, edges, .. } => {
 -                // Legacy approximation, the Ridge arm's two steps with the light
 -                // signs swapped: down at the boundary, back up half a width in.
 -                // The banded machinery has no valley profile, so this is the old
 -                // stacked look — accepted here, as the legacy path exists only
 -                // for A/B comparison against the SDF one.
 -                let half = *depth * 0.5;
 -                push_bevel_edge_vertices_banded(
 -                    rect.x, rect.y, rect.width, rect.height, *radii, half,
 -                    sw, sh, [0.0; 4], no, -1.0, default_bevel_bands(half), *edges,
 -                    EdgeKind::Step, &mut verts,
 -                );
 -                let ir = (radii.0 - half).max(0.0);
 -                push_bevel_edge_vertices_banded(
 -                    rect.x + half, rect.y + half,
 -                    rect.width - *depth, rect.height - *depth,
 -                    (ir, ir, ir, ir), half,
 -                    sw, sh, [0.0; 4], no, 1.0, default_bevel_bands(half), *edges,
 -                    EdgeKind::Step, &mut verts,
 -                );
 -            }
 -            Prim::Field { rect, radii, depth, split, end, .. } => {
 -                // Legacy approximation: the two-box form `Prim::Field`
 -                // replaced — a well either side of the run a step down, the
 -                // run the Trough arm's down-then-up stack. The banded
 -                // machinery has no blended outline, and the legacy path
 -                // exists only for A/B comparison.
 -                let all = (true, true, true, true);
 -                let (fl, fr) = (rect.x, rect.x + rect.width);
 -                let (well_l, well_r) = (*split > fl, *end < fr);
 -                let rx = split.max(fl);
 -                let rw = (end.min(fr) - rx).max(0.0);
 -                if well_l {
 -                    push_bevel_edge_vertices_banded(
 -                        fl, rect.y, rx - fl, rect.height, (radii.0, 0.0, 0.0, radii.3), *depth,
 -                        sw, sh, [0.0; 4], no, -1.0, default_bevel_bands(*depth), all,
 -                        EdgeKind::Step, &mut verts,
 -                    );
 -                }
 -                if well_r {
 -                    push_bevel_edge_vertices_banded(
 -                        rx + rw, rect.y, fr - rx - rw, rect.height, (0.0, radii.1, radii.2, 0.0), *depth,
 -                        sw, sh, [0.0; 4], no, -1.0, default_bevel_bands(*depth), all,
 -                        EdgeKind::Step, &mut verts,
 -                    );
 -                }
 -                // A run's own corners where it reaches the outline; square at a seam.
 -                let (l0, l3) = if well_l { (0.0, 0.0) } else { (radii.0, radii.3) };
 -                let (r1, r2) = if well_r { (0.0, 0.0) } else { (radii.1, radii.2) };
 -                let half = *depth * 0.5;
 -                push_bevel_edge_vertices_banded(
 -                    rx, rect.y, rw, rect.height, (l0, r1, r2, l3), half,
 -                    sw, sh, [0.0; 4], no, -1.0, default_bevel_bands(half), all,
 -                    EdgeKind::Step, &mut verts,
 -                );
 -                let ir = if well_r { 0.0 } else { (radii.1 - half).max(0.0) };
 -                let il = if well_l { 0.0 } else { (radii.0 - half).max(0.0) };
 -                push_bevel_edge_vertices_banded(
 -                    rx + half, rect.y + half, rw - *depth, rect.height - *depth, (il, ir, ir, il), half,
 -                    sw, sh, [0.0; 4], no, 1.0, default_bevel_bands(half), all,
 -                    EdgeKind::Step, &mut verts,
 -                );
 -            }
 -            Prim::Arc { cx, cy, radius, thickness, start: sa, end: ea, color } => {
 -                push_arc_background_vertices(*cx, *cy, *radius, *thickness, *sa, *ea, sw, sh, *color, segs(*radius), no, &mut verts);
 -            }
 -            Prim::ArcShaded { cx, cy, radius, thickness, start: sa, end: ea, inner, crest, outer } => {
 -                push_arc_shaded_vertices(*cx, *cy, *radius, *thickness, *sa, *ea, sw, sh, *inner, *crest, *outer, segs(*radius), no, &mut verts);
 -            }
 -            Prim::Vector { x1, y1, x2, y2, thickness, color, cap } => {
 -                let lc = match cap {
 -                    Cap::Flat => LineCap::Flat,
 -                    Cap::Round => LineCap::Round,
 -                    Cap::Arrow => LineCap::Arrow,
 -                };
 -                verts.extend(vector_vertices(*x1, *y1, *x2, *y2, *thickness, sw, sh, *color, lc));
 -            }
 -            Prim::Circle { cx, cy, radius, color } => {
 -                if item.clip_circle.is_none() && *radius > 1.5 {
 -                    // Cover quad with the disc itself as the (feathered) circle
 -                    // clip: a per-pixel smooth silhouette instead of a hard-edged
 -                    // fan. The quad overhangs by 1px for the feather. Only when
 -                    // no ancestor clip holds the slot — then it's the fan path.
 -                    let own = [cx * scale, cy * scale, radius * scale];
 -                    let d = *radius + 1.0;
 -                    verts.extend(quad_vertices_with_clip(
 -                        cx - d, cy - d, 2.0 * d, 2.0 * d, sw, sh, *color, own,
 -                    ));
 -                } else {
 -                    verts.extend(circle_vertices(*cx, *cy, *radius, sw, sh, *color, segs(*radius), no));
 -                }
 -            }
 -            Prim::Sphere { cx, cy, radius, material } if shader_plates => {
 -                let color = material.fill(PlateRole::Nested);
 -                let mat = material.finish.to_array();
 -                // A hemisphere lit per pixel by the plate branch (mode 5): one
 -                // cover quad, its own never-merged batch. The quad overhangs
 -                // the disc by 1px for the shader's silhouette anti-aliasing.
 -                let d = *radius + 1.0;
 -                verts.extend(quad_vertices(cx - d, cy - d, 2.0 * d, 2.0 * d, sw, sh, color));
 -                plate = Some(crate::vk::PlatePush {
 -                    // Center + radius in physical px; the SDF box machinery is
 -                    // unused in this mode, so .w is free.
 -                    rect: [cx * scale, cy * scale, radius * scale, 0.0],
 -                    radii: [0.0; 4],
 -                    light: [plate_light[0], plate_light[1], plate_light[2], 0.0],
 -                    material: mat,
 -                    host: [0.0; 4],
 -                    specular_tint: [1.0, 1.0, 1.0, 0.0],
 -                    mode: 5.0,
 -                    shape: 2.0,
 -                });
 -            }
 -            Prim::Sphere { cx, cy, radius, material } => {
 -                let color = material.fill(PlateRole::Nested);
 -                // Legacy path: the flat disc, exactly a Circle.
 -                verts.extend(circle_vertices(*cx, *cy, *radius, sw, sh, color, segs(*radius), no));
 -            }
 -            Prim::DropletScrim { rect, material, spec, feather } if shader_plates => {
 -                let color = material.fill(PlateRole::Nested);
 -                let mat = material.finish.to_array();
 -                // Shader mode 12: the droplet's own SDF, filled flat and
 -                // feathered inward. No contact shadow, so unlike the lit drop
 -                // the cover quad is exactly the box — a scrim never draws
 -                // outside the silhouette.
 -                let g = droplet_geom(rect, spec);
 -                verts.extend(quad_vertices(rect.x, rect.y, rect.width, rect.height, sw, sh, color));
 -                plate = Some(crate::vk::PlatePush {
 -                    rect: [
 -                        (rect.x + rect.width * 0.5) * scale,
 -                        (rect.y + rect.height * 0.5) * scale,
 -                        g.hx * scale,
 -                        g.hy * scale,
 -                    ],
 -                    radii: [g.sag * scale, g.br * scale, g.bw * scale, g.k * scale],
 -                    // p_light.w carries the FEATHER here; mode 12 returns
 -                    // before the shading band it otherwise holds is read.
 -                    light: [plate_light[0], plate_light[1], plate_light[2], feather.max(0.001) * scale],
 -                    material: [mat[0], 0.0, 0.0, 0.0],
 -                    host: [g.sr * scale, 0.0, 0.0, g.ar * scale],
 -                    specular_tint: [0.0, 0.0, 0.0, g.bow * scale],
 -                    mode: 12.0,
 -                    shape: spec.curve.clamp(2.0, 6.0),
 -                });
 -            }
 -            Prim::Droplet { rect, material, spec } if shader_plates => {
 -                let color = material.fill(PlateRole::Nested);
 -                let mat = material.finish.to_array();
 -                // A water droplet lit by shader mode 10: one cover quad; the
 -                // shader owns silhouette (sheet ∪smin belly), dome shading,
 -                // fresnel rim and thin-edge clarity. The spec's height
 -                // fractions resolve against the concrete rect here, clamped so
 -                // small or narrow boxes stay well-formed (a belly wider than
 -                // the box would turn the SDF interior inside out).
 -                // The cover quad grows sideways and BELOW the box by the
 -                // contact shadow's reach — shadow fragments live outside the
 -                // silhouette, so they need covered pixels to shade.
 -                let g = droplet_geom(rect, spec);
 -                let (hx, hy, sag, br, bw, k, sr, ar, band, bow, sh_reach) =
 -                    (g.hx, g.hy, g.sag, g.br, g.bw, g.k, g.sr, g.ar, g.band, g.bow, g.sh_reach);
 -                verts.extend(quad_vertices(
 -                    rect.x - sh_reach,
 -                    rect.y,
 -                    rect.width + 2.0 * sh_reach,
 -                    rect.height + sh_reach,
 -                    sw, sh, color,
 -                ));
 -                plate = Some(crate::vk::PlatePush {
 -                    rect: [
 -                        (rect.x + rect.width * 0.5) * scale,
 -                        (rect.y + rect.height * 0.5) * scale,
 -                        hx * scale,
 -                        hy * scale,
 -                    ],
 -                    radii: [sag * scale, br * scale, bw * scale, k * scale],
 -                    light: [plate_light[0], plate_light[1], plate_light[2], band * scale],
 -                    // Slots y/z/w feed roll_spec and the rim term directly:
 -                    // a droplet's material carries its own gleam/shine/rim
 -                    // there (`DropletSpec::finish`; a drop is wetter than the
 -                    // DE's plates), so this is the material's finish like any
 -                    // plate's.
 -                    material: mat,
 -                    host: [sr * scale, spec.clarity.clamp(0.0, 1.0), spec.dome, ar * scale],
 -                    // Droplet glints are always white, so the tint RGB slots
 -                    // carry droplet params instead: x = core density,
 -                    // y = contact-shadow reach px, z = shadow strength.
 -                    specular_tint: [
 -                        spec.core.clamp(0.0, 2.0),
 -                        sh_reach * scale,
 -                        spec.shadow.clamp(0.0, 1.0),
 -                        bow * scale,
 -                    ],
 -                    mode: 10.0,
 -                    shape: spec.curve.clamp(2.0, 6.0),
 -                });
 -            }
 -            Prim::DropletScrim { rect, material, spec, .. } => {
 -                let color = material.fill(PlateRole::Nested);
 -                // Legacy banded path: no SDF to feather against, so the scrim
 -                // degrades to the same flat outline the drop itself does —
 -                // hard-edged, but present. A prim with no arm here VANISHES.
 -                let cap = (rect.height * 0.5).min(rect.width * 0.5);
 -                let sr = (spec.sheet_r.clamp(0.0, 1.0) * rect.height).min(cap);
 -                let ar = (spec.attach.clamp(0.0, 1.0) * rect.height).min(cap);
 -                let radii = crate::widget::CornerRadii::new(ar, ar, sr, sr);
 -                push_rounded_rect_vertices_corners(rect.x, rect.y, rect.width, rect.height, radii, sw, sh, color, no, None, &mut verts);
 -            }
 -            Prim::Droplet { rect, material, spec } => {
 -                let color = material.fill(PlateRole::Nested);
 -                // Legacy banded path: the flat drop outline — attach-tapered
 -                // top, round bottom. Degrades the material but keeps the
 -                // silhouette (a prim with no arm here VANISHES, it doesn't
 -                // degrade — see Ridge/Groove above).
 -                let cap = (rect.height * 0.5).min(rect.width * 0.5);
 -                let sr = (spec.sheet_r.clamp(0.0, 1.0) * rect.height).min(cap);
 -                let ar = (spec.attach.clamp(0.0, 1.0) * rect.height).min(cap);
 -                let radii = crate::widget::CornerRadii::new(ar, ar, sr, sr);
 -                push_rounded_rect_vertices_corners(rect.x, rect.y, rect.width, rect.height, radii, sw, sh, color, no, None, &mut verts);
 -            }
 -            Prim::ConcaveFillet { cx, cy, radius, depth, start: a0, raised } if shader_plates => {
 -                // A quarter-arc carve wall (shader mode 6/7): one cover quad
 -                // over the wedge's reach; the wall straddles the arc by ±t/2
 -                // like every carve boundary. p_rect carries centre + radius,
 -                // p_radii.x the wedge start angle. Host box pushed far out —
 -                // an inside-corner fillet never fades.
 -                let m = *depth * 0.5 + 2.0;
 -                let r = *radius + m;
 -                verts.extend(quad_vertices(cx - r, cy - r, 2.0 * r, 2.0 * r, sw, sh, [0.0; 4]));
 -                plate = Some(crate::vk::PlatePush {
 -                    rect: [cx * scale, cy * scale, *radius * scale, 0.0],
 -                    radii: [*a0, 0.0, 0.0, 0.0],
 -                    light: [plate_light[0], plate_light[1], plate_light[2], *depth * scale],
 -                    material: plate_mat,
 -                    host: [0.0, 0.0, 1e6, 1e6],
 -                    specular_tint: [1.0, 1.0, 1.0, 0.0],
 -                    mode: if *raised { 7.0 } else { 6.0 },
 -                    shape: crate::layout::corner_shape(),
 -                });
 -            }
 -            // Legacy banded path has no radial wall — the composed corner
 -            // stays square there (A/B comparison path only).
 -            Prim::ConcaveFillet { .. } => {}
 -            Prim::Groove { a, b, width, depth, host, strength } if shader_plates => {
 -                // A slab carve about the line a–b (shader mode 8): the cover
 -                // quad is the segment's bounding box grown by the groove's own
 -                // half-width plus the wall's reach. Off-band corners of that
 -                // box sit at u = 1 (plateau), so the box overhang shades
 -                // nothing — the slab is what bounds the mark, not the quad.
 -                let m = *width * 0.5 + *depth * 0.5 + 2.0;
 -                let (x0, x1) = (a.0.min(b.0) - m, a.0.max(b.0) + m);
 -                let (y0, y1) = (a.1.min(b.1) - m, a.1.max(b.1) + m);
 -                verts.extend(quad_vertices(x0, y0, x1 - x0, y1 - y0, sw, sh, [0.0; 4]));
 -                // Unit normal of the line — the direction the slab's distance is
 -                // measured along. A degenerate segment falls back to vertical so
 -                // a zero-length groove is a no-op wall rather than a NaN.
 -                let (dx, dy) = (b.0 - a.0, b.1 - a.1);
 -                let len = (dx * dx + dy * dy).sqrt();
 -                let n = if len > 1e-4 { (-dy / len, dx / len) } else { (1.0, 0.0) };
 -                plate = Some(crate::vk::PlatePush {
 -                    // Centre + slab half-width in physical px; .w unused.
 -                    rect: [
 -                        (a.0 + b.0) * 0.5 * scale,
 -                        (a.1 + b.1) * 0.5 * scale,
 -                        *width * 0.5 * scale,
 -                        0.0,
 -                    ],
 -                    radii: [n.0, n.1, 0.0, 0.0],
 -                    light: [plate_light[0], plate_light[1], plate_light[2], *depth * scale],
 -                    // The finish's shading, specular and AO, at the groove's
 -                    // strength; shininess is a shape, not an amount.
 -                    material: {
 -                        let s = strength.clamp(0.0, 1.0);
 -                        [plate_mat[0] * s, plate_mat[1] * s, plate_mat[2], plate_mat[3] * s]
 -                    },
 -                    host: [
 -                        (host.x + host.width * 0.5) * scale,
 -                        (host.y + host.height * 0.5) * scale,
 -                        host.width * 0.5 * scale,
 -                        host.height * 0.5 * scale,
 -                    ],
 -                    specular_tint: [1.0, 1.0, 1.0, 0.0],
 -                    mode: 8.0,
 -                    shape: crate::layout::corner_shape(),
 -                });
 -            }
 -            Prim::Groove { a, b, width, depth, host: _, strength } => {
 -                // Legacy approximation. The banded tessellators walk BOX edges —
 -                // exactly the axis-aligned assumption a groove exists to escape —
 -                // so the walls are drawn directly as two feathered lines meeting
 -                // at the centerline: the engraved-line fake, one half in shadow
 -                // and one lit. Coarser than the SDF (no profile curve, no host
 -                // fade), but this path exists for A/B comparison, and drawing
 -                // NOTHING would silently delete the mark rather than degrade it
 -                // — see `Prim::Ridge` above, which accepts a hot crest for the
 -                // same reason.
 -                let (dx, dy) = (b.0 - a.0, b.1 - a.1);
 -                let len = (dx * dx + dy * dy).sqrt();
 -                if len < 0.001 {
 -                    continue;
 -                }
 -                let n = (-dy / len, dx / len);
 -                // Same convention as `push_bevel_edge_vertices_banded`: the
 -                // light folded through `light_sign` (-1.0 — a groove is a
 -                // carve), dotted with each wall's OUTWARD normal, amplitude on
 -                // `bevel_depth`. So a groove re-lights with the DE's light
 -                // instead of hardcoding which side is dark.
 -                let rad = crate::layout::light_source_position();
 -                let (lx, ly) = (-rad.cos(), rad.sin());
 -                let v = crate::layout::bevel_depth() * (n.0 * lx + n.1 * ly);
 -                // Each wall covers its own half, centreline to outer edge —
 -                // abutting rather than overlapping. The SDF gets away with
 -                // walls that overlap across a sub-pixel floor because it is one
 -                // evaluation of |distance|; two opposite-signed overlays would
 -                // just blend to mud.
 -                let half = (*width * 0.5 + *depth * 0.5).max(0.5);
 -                for side in [1.0f32, -1.0] {
 -                    let sv = v * side;
 -                    let mut c = if sv >= 0.0 { overlay_light(sv) } else { overlay_dark(sv) };
 -                    c[3] *= strength.clamp(0.0, 1.0);
 -                    if c[3] <= 0.0 {
 -                        continue;
 -                    }
 -                    let off = side * half * 0.5;
 -                    push_feathered_line_vertices(
 -                        a.0 + n.0 * off, a.1 + n.1 * off,
 -                        b.0 + n.0 * off, b.1 + n.1 * off,
 -                        half, sw, sh, c, &mut verts,
 -                    );
 -                }
 -            }
 -            Prim::Lattice { rect, period, origin, cell, radius, depth } if shader_plates => {
 -                // A periodic well field (shader mode 13): one cover quad over
 -                // `rect`; the shader folds each pixel into the period and
 -                // measures the nearest cell, so the whole lattice is a single
 -                // evaluation. p_rect = one cell's centre + half-extents,
 -                // p_radii = the corner radius, p_host.xy = the period; the
 -                // host-box fade sides are pushed far out (a lattice never
 -                // fades against a host — its own rect bounds it).
 -                let (pw, ph) = (period.0.max(1e-3), period.1.max(1e-3));
 -                verts.extend(quad_vertices(rect.x, rect.y, rect.width, rect.height, sw, sh, [0.0; 4]));
 -                plate = Some(crate::vk::PlatePush {
 -                    rect: [origin.0 * scale, origin.1 * scale, cell.0 * 0.5 * scale, cell.1 * 0.5 * scale],
 -                    radii: [*radius * scale; 4],
 -                    light: [plate_light[0], plate_light[1], plate_light[2], *depth * scale],
 -                    material: plate_mat,
 -                    host: [pw * scale, ph * scale, 1e6, 1e6],
 -                    specular_tint: [1.0, 1.0, 1.0, 0.0],
 -                    mode: 13.0,
 -                    shape: crate::layout::corner_shape(),
 -                });
 -            }
 -            Prim::Grout { rect, period, origin, cell, radius, color } if shader_plates => {
 -                // The lattice's fold, painted flat (shader mode 15): one cover
 -                // quad in the grout colour; the shader keeps it outside the
 -                // cells. Same push layout as the lattice; light/material are
 -                // carried but unread.
 -                let (pw, ph) = (period.0.max(1e-3), period.1.max(1e-3));
 -                verts.extend(quad_vertices(rect.x, rect.y, rect.width, rect.height, sw, sh, *color));
 -                plate = Some(crate::vk::PlatePush {
 -                    rect: [origin.0 * scale, origin.1 * scale, cell.0 * 0.5 * scale, cell.1 * 0.5 * scale],
 -                    radii: [*radius * scale; 4],
 -                    light: [plate_light[0], plate_light[1], plate_light[2], 0.0],
 -                    material: plate_mat,
 -                    host: [pw * scale, ph * scale, 1e6, 1e6],
 -                    specular_tint: [1.0, 1.0, 1.0, 0.0],
 -                    mode: 15.0,
 -                    shape: crate::layout::corner_shape(),
 -                });
 -            }
 -            // Legacy banded path: no periodic wall — the lattice and the grout
 -            // draw nothing there, like the fillet (A/B comparison path only).
 -            Prim::Lattice { .. } | Prim::Grout { .. } => {}
 -            Prim::CarveUnion { boxes, depth, raised } if shader_plates => {
 -                // The union of several boxes as ONE wall (shader mode 14): the
 -                // boxes go into the frame's feature buffer as a contiguous run
 -                // and the shader takes the nearest one per pixel. The cover
 -                // quad is the union's bounding box grown by the wall's reach;
 -                // off-shape corners of it sit at the plateau and shade nothing.
 -                let budget = crate::vk::MAX_PLATE_FEATURES.saturating_sub(features.len());
 -                let take = boxes.len().min(budget);
 -                if take < boxes.len() && plate_debug() {
 -                    eprintln!(
 -                        "plate-carve: union of {} boxes gets {} — feature budget full ({} used)",
 -                        boxes.len(), take, features.len()
 -                    );
 -                }
 -                if take == 0 {
 -                    continue;
 -                }
 -                let kept = &boxes[..take];
 -                let (mut x0, mut y0, mut x1, mut y1) = (f32::MAX, f32::MAX, f32::MIN, f32::MIN);
 -                for (r, _) in kept {
 -                    x0 = x0.min(r.x);
 -                    y0 = y0.min(r.y);
 -                    x1 = x1.max(r.x + r.width);
 -                    y1 = y1.max(r.y + r.height);
 -                }
 -                let infl = *depth * 0.5 + 2.0;
 -                verts.extend(quad_vertices(
 -                    x0 - infl, y0 - infl,
 -                    (x1 - x0) + 2.0 * infl, (y1 - y0) + 2.0 * infl,
 -                    sw, sh, [0.0; 4],
 -                ));
 -                let off = features.len() as f32;
 -                for (r, radii) in kept {
 -                    features.push([
 -                        (r.x + r.width * 0.5) * scale,
 -                        (r.y + r.height * 0.5) * scale,
 -                        r.width * 0.5 * scale,
 -                        r.height * 0.5 * scale,
 -                        radii.0 * scale,
 -                        radii.1 * scale,
 -                        radii.2 * scale,
 -                        radii.3 * scale,
 -                        *depth * scale,
 -                        0.0,
 -                        0.0,
 -                        0.0,
 -                    ]);
 -                }
 -                // The run is complete: a plate with an open feature run must
 -                // not append past it (its features would no longer be
 -                // contiguous), so it is closed here like any other appender.
 -                last_feature_plate = None;
 -                plate = Some(crate::vk::PlatePush {
 -                    rect: [
 -                        (x0 + x1) * 0.5 * scale,
 -                        (y0 + y1) * 0.5 * scale,
 -                        (x1 - x0) * 0.5 * scale,
 -                        (y1 - y0) * 0.5 * scale,
 -                    ],
 -                    // x: the raised flag; the shader reads nothing else here.
 -                    radii: [if *raised { 1.0 } else { 0.0 }, 0.0, 0.0, 0.0],
 -                    light: [plate_light[0], plate_light[1], plate_light[2], *depth * scale],
 -                    material: plate_mat,
 -                    // Feature run [offset, count] (the renderer rebases the
 -                    // offset onto the frame slot, as for mode 1); zw far out
 -                    // so the host-box fade never applies.
 -                    host: [off, take as f32, 1e6, 1e6],
 -                    specular_tint: [1.0, 1.0, 1.0, 0.0],
 -                    mode: 14.0,
 -                    shape: crate::layout::corner_shape(),
 -                });
 -            }
 -            // Legacy banded path: no union — nothing is drawn there, like the
 -            // fillet and the lattice (A/B comparison path only).
 -            Prim::CarveUnion { .. } => {}
 -        }
 -        let end = verts.len() as u32;
 -        if end == start {
 -            continue;
 -        }
 -        // Some tessellators (quad_vertices, vector_vertices) don't thread the circle clip —
 -        // stamp the whole emitted range so every prim kind honors it uniformly.
 -        if item.clip_circle.is_some() {
 -            for v in verts[start as usize..].iter_mut() {
 -                v.clip_circle = no;
 -            }
 -        }
 -        // Merge into the previous batch if it shares this clip pair and is contiguous.
 -        // Plate batches carry per-draw push constants, and blur-behind batches
 -        // trigger the renderer's snapshot copy, so neither ever merges.
 -        if plate.is_none() && !blur_behind {
 -            // Ordinary geometry painted after a plate ends its carve-grouping
 -            // window: a recess emitted later must overlay this geometry (the
 -            // fallback path), not shade beneath it inside the plate's draw.
 -            if dbg_plates && !plate_stack.is_empty() {
 -                *dbg_closed_by.entry(prim_kind(&item.prim)).or_insert(0) += plate_stack.len();
 -            }
 -            plate_stack.clear();
 -            if let Some(last) = batches.last_mut() {
 -                if last.plate.is_none()
 -                    && last.scissor == item.clip
 -                    && last.clip_rrect == item.clip_rrect
 -                    && last.end == start
 -                {
 -                    last.end = end;
 -                    continue;
 -                }
 -            }
 -        }
 -        if promoted {
 -            plate_stack.clear();
 -        }
 -        batches.push(DlBatch { scissor: item.clip, clip_rrect: item.clip_rrect, start, end, plate, blur_behind });
 -        if let Some(prect) = made_plate {
 -            plate_stack.push((batches.len() - 1, prect));
 -            if dbg_plates {
 -                dbg_opened += 1;
 -            }
 -        }
 -    }
 -
 -    if dbg_plates && (dbg_grouped > 0 || !dbg_fell_back.is_empty()) {
 -        eprintln!(
 -            "plate-dbg: {} carves — {dbg_grouped} grouped (exact CSG), {} overlay fallback",
 -            dbg_grouped + dbg_fell_back.len(),
 -            dbg_fell_back.len(),
 -        );
 -        eprintln!(
 -            "plate-dbg:   {dbg_opened} grouping window(s) opened by a filled plate; closed early by {}",
 -            if dbg_closed_by.is_empty() {
 -                "nothing".to_string()
 -            } else {
 -                dbg_closed_by
 -                    .iter()
 -                    .map(|(k, n)| format!("{k}x{n}"))
 -                    .collect::<Vec<_>>()
 -                    .join(", ")
 -            }
 -        );
 -        for line in &dbg_fell_back {
 -            eprintln!("plate-dbg: {line}");
 -        }
 -    }
 -
 -    (verts, batches, images, features)
 -}
 -
 -/// The push-constant block for a raised SDF-lit plate over `rect` (logical px in,
 -/// physical px out). Corner radii clamp to the half-extent cap the SDF needs.
 -///
 -/// `shape` is a per-plate corner exponent (`Prim::Plate`'s override); `None`
 -/// follows the DE-wide `layout::corner_shape`. The span factor follows the
 -/// exponent actually used, so a circular override (2.0) spans nothing and a
 -/// half-extent radius lands on a true circle.
 -#[allow(clippy::too_many_arguments)]
 -/// The push block of a frosted flat fill promoted to a zero-depth plate: a
 -/// mode-1 plate with no roll (`t` = 0.001, so the face is exactly the fill),
 -/// corners at the nominal radii in the configured `corner_shape`, and the
 -/// fill's own frost recipe in `host.zw` (`Material::from_fill` decodes the
 -/// sentinel).
 -///
 -/// The shape must be `corner_shape`, not a fixed circle: a frosted `Border`
 -/// draws its stroke as `push_plate_solid_border_vertices` geometry in that
 -/// shape, and the unfrosted fill fan uses it too. A circular face under a
 -/// squircle stroke left the stroke cutting inside the face's corners.
 -fn flat_frost_push(
 -    rect: &crate::scene::layout::Rect,
 -    radii: (f32, f32, f32, f32),
 -    fill: [f32; 4],
 -    scale: f32,
 -    light: [f32; 3],
 -    material: [f32; 4],
 -) -> crate::vk::PlatePush {
 -    let mut p = plate_push_raised(rect, radii, 0.0, scale, light, material, false, None);
 -    let [fz, fw] = crate::scene::material::Material::from_fill(fill).frost.pack(scale);
 -    p.host[2] = fz;
 -    p.host[3] = fw;
 -    p
 -}
 -
 -fn plate_push_raised(
 -    rect: &crate::scene::layout::Rect,
 -    radii: (f32, f32, f32, f32),
 -    width: f32,
 -    scale: f32,
 -    light: [f32; 3],
 -    material: [f32; 4],
 -    scale_corners: bool,
 -    shape: Option<f32>,
 -) -> crate::vk::PlatePush {
 -    // Floored: a rect already shrunk past its padding (a window dragged
 -    // below what its layout can hold) has a NEGATIVE extent here, and
 -    // `clamp(0.0, cap)` with a negative cap is a panic, not a zero radius.
 -    let cap = (rect.width.min(rect.height) * 0.5).max(0.0);
 -    let shape = shape.map_or_else(crate::layout::corner_shape, |n| n.clamp(2.0, 16.0));
 -    // For PLATES (`scale_corners`), widen the corner span by the
 -    // curvature-match factor (see `layout::corner_span_factor`): the diagonal
 -    // curvature radius equals the configured radius, the corner reads as the
 -    // same size as a circular one, and every roll inset ≤ r stays crease-free
 -    // (past the diagonal curvature radius the offset curve the specular band
 -    // follows creases into a visible square corner). Widget-scale overlay
 -    // reliefs (recess/boss/ridge fallbacks) pass false: their radii must MATCH
 -    // the nominal-radius squircles of the widget silhouettes around them, and
 -    // at their few-px roll widths the offset crease is subpixel.
 -    let rscale = if scale_corners { crate::layout::corner_span_factor_for(shape) } else { 1.0 };
 -    crate::vk::PlatePush {
 -        rect: [
 -            (rect.x + rect.width * 0.5) * scale,
 -            (rect.y + rect.height * 0.5) * scale,
 -            rect.width * 0.5 * scale,
 -            rect.height * 0.5 * scale,
 -        ],
 -        radii: [
 -            (radii.0 * rscale).clamp(0.0, cap) * scale,
 -            (radii.1 * rscale).clamp(0.0, cap) * scale,
 -            (radii.2 * rscale).clamp(0.0, cap) * scale,
 -            (radii.3 * rscale).clamp(0.0, cap) * scale,
 -        ],
 -        light: [light[0], light[1], light[2], width * scale],
 -        material,
 -        // Mode-1 semantics: [feature offset, feature count] — no carves yet;
 -        // the tessellator fills these in as recesses group into this plate.
 -        host: [0.0, 0.0, 0.0, 0.0],
 -        specular_tint: [1.0, 1.0, 1.0, 0.0],
 -        mode: 1.0,
 -        shape,
 -    }
 -}
 -
 -pub fn extra_quad_vertices(
 -    w: &dyn crate::widget::WidgetHost,
 -    qx: f32, qy: f32, qw: f32, qh: f32,
 -    sw: f32, sh: f32,
 -    qc: [f32; 4],
 -    clip_circle: [f32; 3],
 -) -> Vec<Vertex> {
 -    let mut verts = Vec::new();
 -    push_extra_quad_vertices(w, qx, qy, qw, qh, sw, sh, qc, clip_circle, &mut verts);
 -    verts
 -}
 -
 -fn get_child_widget_for_quad<'a>(
 -    w: &'a dyn crate::widget::WidgetHost,
 -    qx: f32, qy: f32, qw: f32, qh: f32,
 -) -> &'a dyn crate::widget::WidgetHost {
 -    if let Some(pbg) = w.as_any().downcast_ref::<crate::widget::ParametersBg>() {
 -        for s_opt in &pbg.sliders {
 -            if let Some(s) = s_opt {
 -                let (sx, sy, sww, shh) = s.rect();
 -                if qx >= sx - 0.1 && qx + qw <= sx + sww + 0.1 && qy >= sy - 0.1 && qy + qh <= sy + shh + 0.1 {
 -                    return s;
 -                }
 -            }
 -        }
 -        for f_opt in &pbg.float3s {
 -            if let Some(f) = f_opt {
 -                let (fx, fy, fww, fhh) = f.rect();
 -                if qx >= fx - 0.1 && qx + qw <= fx + fww + 0.1 && qy >= fy - 0.1 && qy + qh <= fy + fhh + 0.1 {
 -                    return f;
 -                }
 -            }
 -        }
 -        for sb_opt in &pbg.spinboxes {
 -            if let Some(sb) = sb_opt {
 -                let (sx, sy, sww, shh) = sb.rect();
 -                if qx >= sx - 0.1 && qx + qw <= sx + sww + 0.1 && qy >= sy - 0.1 && qy + qh <= sy + shh + 0.1 {
 -                    return sb;
 -                }
 -            }
 -        }
 -        for btn_opt in &pbg.buttons {
 -            if let Some(btn) = btn_opt {
 -                let (bx, by, bww, bhh) = btn.rect();
 -                if qx >= bx - 0.1 && qx + qw <= bx + bww + 0.1 && qy >= by - 0.1 && qy + qh <= by + bhh + 0.1 {
 -                    return btn;
 -                }
 -            }
 -        }
 -        for ch_opt in &pbg.choices {
 -            if let Some(ch) = ch_opt {
 -                let (cx, cy, cww, chh) = ch.rect();
 -                if qx >= cx - 0.1 && qx + qw <= cx + cww + 0.1 && qy >= cy - 0.1 && qy + qh <= cy + chh + 0.1 {
 -                    return ch;
 -                }
 -            }
 -        }
 -        for t_opt in &pbg.texts {
 -            if let Some(t) = t_opt {
 -                let (tx, ty, tww, thh) = t.rect();
 -                if qx >= tx - 0.1 && qx + qw <= tx + tww + 0.1 && qy >= ty - 0.1 && qy + qh <= ty + thh + 0.1 {
 -                    return t;
 -                }
 -            }
 -        }
 -        for cb_opt in &pbg.toggles {
 -            if let Some(cb) = cb_opt {
 -                let (cx, cy, cww, chh) = cb.rect();
 -                if qx >= cx - 0.1 && qx + qw <= cx + cww + 0.1 && qy >= cy - 0.1 && qy + qh <= cy + chh + 0.1 {
 -                    return cb;
 -                }
 -            }
 -        }
 -        for c_opt in &pbg.colors {
 -            if let Some(c) = c_opt {
 -                let (cx, cy, cww, chh) = c.rect();
 -                if qx >= cx - 0.1 && qx + qw <= cx + cww + 0.1 && qy >= cy - 0.1 && qy + qh <= cy + chh + 0.1 {
 -                    return c;
 -                }
 -            }
 -        }
 -    }
 -    w
 -}
 -
 -pub fn push_extra_quad_vertices(
 -    w: &dyn crate::widget::WidgetHost,
 -    qx: f32, qy: f32, qw: f32, qh: f32,
 -    sw: f32, sh: f32,
 -    qc: [f32; 4],
 -    clip_circle: [f32; 3],
 -    out: &mut Vec<Vertex>,
 -) {
 -    if let Some(graph) = w.as_any().downcast_ref::<crate::widget::display::Graph>() {
 -        if graph.is_node_rect(qx, qy, qw, qh) {
 -            let r = crate::layout::graph_node_corner_radius();
 -            let extra_radii = crate::widget::CornerRadii::new(r, r, r, r);
 -            push_rounded_rect_vertices_corners(qx, qy, qw, qh, extra_radii, sw, sh, qc, clip_circle, None, out);
 -            return;
 -        }
 -    }
 -
 -    let target_w = get_child_widget_for_quad(w, qx, qy, qw, qh);
 -    let radii = target_w.corner_radii();
 -    if radii.top_left <= 0.1 && radii.top_right <= 0.1 && radii.bottom_right <= 0.1 && radii.bottom_left <= 0.1 {
 -        out.extend_from_slice(&quad_vertices_with_clip(qx, qy, qw, qh, sw, sh, qc, clip_circle));
 -        if let Some((color, thickness)) = target_w.solid_border() {
 -            let (wx, wy, ww, wh) = target_w.rect();
 -            if (qx - wx).abs() < 0.1 && (qy - wy).abs() < 0.1 && (qw - ww).abs() < 0.1 && (qh - wh).abs() < 0.1 {
 -                push_plate_solid_border_vertices(qx, qy, qw, qh, radii, thickness, sw, sh, color, clip_circle, out);
 -            }
 -        }
 -        return;
 -    }
 -
 -    let (wx, mut wy, ww, mut wh) = target_w.rect();
 -    let top_room = target_w.label_strip();
 -    wy += top_room;
 -    wh -= top_room;
 -    let extra_radii = crate::widget::CornerRadii::new(
 -        if qx <= wx + 1.5 && qy <= wy + 1.5 { radii.top_left } else { 0.0 },
 -        if qx + qw >= wx + ww - 1.5 && qy <= wy + 1.5 { radii.top_right } else { 0.0 },
 -        if qx + qw >= wx + ww - 1.5 && qy + qh >= wy + wh - 1.5 { radii.bottom_right } else { 0.0 },
 -        if qx <= wx + 1.5 && qy + qh >= wy + wh - 1.5 { radii.bottom_left } else { 0.0 },
 -    );
 -
 -    push_rounded_rect_vertices_corners(qx, qy, qw, qh, extra_radii, sw, sh, qc, clip_circle, None, out);
 -
 -    if let Some((color, thickness)) = target_w.solid_border() {
 -        let (rx, mut ry, rw, mut rh) = target_w.rect();
 -        let top = target_w.label_strip();
 -        ry += top;
 -        rh -= top;
 -        if (qx - rx).abs() < 0.1 && (qy - ry).abs() < 0.1 && (qw - rw).abs() < 0.1 && (qh - rh).abs() < 0.1 {
 -            push_plate_solid_border_vertices(qx, qy, qw, qh, radii, thickness, sw, sh, color, clip_circle, out);
 -        }
 -    }
 -}
 -
 -pub fn extra_quad_vertices_clipped(
 -    w: &dyn crate::widget::WidgetHost,
 -    qx: f32, qy: f32, qw: f32, qh: f32,
 -    sw: f32, sh: f32,
 -    qc: [f32; 4],
 -    clip: (f32, f32, f32, f32),
 -    clip_circle: [f32; 3],
 -) -> Vec<Vertex> {
 -    let mut verts = Vec::new();
 -    push_extra_quad_vertices_clipped(w, qx, qy, qw, qh, sw, sh, qc, clip, clip_circle, &mut verts);
 -    verts
 -}
 -
 -pub fn push_extra_quad_vertices_clipped(
 -    w: &dyn crate::widget::WidgetHost,
 -    qx: f32, qy: f32, qw: f32, qh: f32,
 -    sw: f32, sh: f32,
 -    qc: [f32; 4],
 -    clip: (f32, f32, f32, f32),
 -    clip_circle: [f32; 3],
 -    out: &mut Vec<Vertex>,
 -) {
 -    if let Some(graph) = w.as_any().downcast_ref::<crate::widget::display::Graph>() {
 -        if graph.is_node_rect(qx, qy, qw, qh) {
 -            let r = crate::layout::graph_node_corner_radius();
 -            let extra_radii = crate::widget::CornerRadii::new(r, r, r, r);
 -            push_rounded_rect_vertices_corners(qx, qy, qw, qh, extra_radii, sw, sh, qc, clip_circle, Some(clip), out);
 -            return;
 -        }
 -    }
 -
 -    let target_w = get_child_widget_for_quad(w, qx, qy, qw, qh);
 -    let radii = target_w.corner_radii();
 -    if radii.top_left <= 0.1 && radii.top_right <= 0.1 && radii.bottom_right <= 0.1 && radii.bottom_left <= 0.1 {
 -        let (cx0, cy0, cx1, cy1) = clip;
 -        let ix0 = qx.max(cx0);
 -        let iy0 = qy.max(cy0);
 -        let ix1 = (qx + qw).min(cx1);
 -        let iy1 = (qy + qh).min(cy1);
 -        if ix1 <= ix0 || iy1 <= iy0 {
 -            return;
 -        }
 -        out.extend_from_slice(&quad_vertices_with_clip(ix0, iy0, ix1 - ix0, iy1 - iy0, sw, sh, qc, clip_circle));
 -        if let Some((color, thickness)) = target_w.solid_border() {
 -            let (wx, wy, ww, wh) = target_w.rect();
 -            if (qx - wx).abs() < 0.1 && (qy - wy).abs() < 0.1 && (qw - ww).abs() < 0.1 && (qh - wh).abs() < 0.1 {
 -                push_plate_solid_border_vertices(qx, qy, qw, qh, radii, thickness, sw, sh, color, clip_circle, out);
 -            }
 -        }
 -        return;
 -    }
 -
 -    let (wx, mut wy, ww, mut wh) = target_w.rect();
 -    let top_room = target_w.label_strip();
 -    wy += top_room;
 -    wh -= top_room;
 -    let extra_radii = crate::widget::CornerRadii::new(
 -        if qx <= wx + 1.5 && qy <= wy + 1.5 { radii.top_left } else { 0.0 },
 -        if qx + qw >= wx + ww - 1.5 && qy <= wy + 1.5 { radii.top_right } else { 0.0 },
 -        if qx + qw >= wx + ww - 1.5 && qy + qh >= wy + wh - 1.5 { radii.bottom_right } else { 0.0 },
 -        if qx <= wx + 1.5 && qy + qh >= wy + wh - 1.5 { radii.bottom_left } else { 0.0 },
 -    );
 -
 -    push_rounded_rect_vertices_corners(qx, qy, qw, qh, extra_radii, sw, sh, qc, clip_circle, Some(clip), out);
 -
 -    if let Some((color, thickness)) = target_w.solid_border() {
 -        let (rx, mut ry, rw, mut rh) = target_w.rect();
 -        let top = target_w.label_strip();
 -        ry += top;
 -        rh -= top;
 -        if (qx - rx).abs() < 0.1 && (qy - ry).abs() < 0.1 && (qw - rw).abs() < 0.1 && (qh - rh).abs() < 0.1 {
 -            push_plate_solid_border_vertices(qx, qy, qw, qh, radii, thickness, sw, sh, color, clip_circle, out);
 -        }
 -    }
 -}
 -
 -pub fn circle_vertices(
 -    cx: f32, cy: f32, r: f32,
 -    sw: f32, sh: f32,
 -    color: [f32; 4],
 -    segments: usize,
 -    clip_circle: [f32; 3],
 -) -> Vec<Vertex> {
 -    let mut verts = Vec::new();
 -    for i in 0..segments {
 -        let theta1 = (i as f32) * 2.0 * std::f32::consts::PI / (segments as f32);
 -        let theta2 = ((i + 1) as f32) * 2.0 * std::f32::consts::PI / (segments as f32);
 -        let x0 = cx;
 -        let y0 = cy;
 -        let x1 = cx + r * theta1.cos();
 -        let y1 = cy + r * theta1.sin();
 -        let x2 = cx + r * theta2.cos();
 -        let y2 = cy + r * theta2.sin();
 -        
 -        let ndc_x0 = (x0 / sw) * 2.0 - 1.0;
 -        let ndc_y0 = 1.0 - (y0 / sh) * 2.0;
 -        let ndc_x1 = (x1 / sw) * 2.0 - 1.0;
 -        let ndc_y1 = 1.0 - (y1 / sh) * 2.0;
 -        let ndc_x2 = (x2 / sw) * 2.0 - 1.0;
 -        let ndc_y2 = 1.0 - (y2 / sh) * 2.0;
 -        
 -        verts.push(Vertex { position: [ndc_x0, ndc_y0], color, clip_circle });
 -        verts.push(Vertex { position: [ndc_x1, ndc_y1], color, clip_circle });
 -        verts.push(Vertex { position: [ndc_x2, ndc_y2], color, clip_circle });
 -    }
 -    verts
 -}
 -
 -pub fn circle_border_vertices(
 -    cx: f32, cy: f32, r: f32,
 -    thickness: f32,
 -    sw: f32, sh: f32,
 -    color: [f32; 4],
 -    segments: usize,
 -    clip_circle: [f32; 3],
 -) -> Vec<Vertex> {
 -    let mut verts = Vec::new();
 -    for i in 0..segments {
 -        let theta1 = (i as f32) * 2.0 * std::f32::consts::PI / (segments as f32);
 -        let theta2 = ((i + 1) as f32) * 2.0 * std::f32::consts::PI / (segments as f32);
 -        
 -        let x0 = cx + (r - thickness) * theta1.cos();
 -        let y0 = cy + (r - thickness) * theta1.sin();
 -        let x1 = cx + r * theta1.cos();
 -        let y1 = cy + r * theta1.sin();
 -        
 -        let x2 = cx + r * theta2.cos();
 -        let y2 = cy + r * theta2.sin();
 -        let x3 = cx + (r - thickness) * theta2.cos();
 -        let y3 = cy + (r - thickness) * theta2.sin();
 -        
 -        let ndc_x0 = (x0 / sw) * 2.0 - 1.0; let ndc_y0 = 1.0 - (y0 / sh) * 2.0;
 -        let ndc_x1 = (x1 / sw) * 2.0 - 1.0; let ndc_y1 = 1.0 - (y1 / sh) * 2.0;
 -        let ndc_x2 = (x2 / sw) * 2.0 - 1.0; let ndc_y2 = 1.0 - (y2 / sh) * 2.0;
 -        let ndc_x3 = (x3 / sw) * 2.0 - 1.0; let ndc_y3 = 1.0 - (y3 / sh) * 2.0;
 -        
 -        verts.push(Vertex { position: [ndc_x0, ndc_y0], color, clip_circle });
 -        verts.push(Vertex { position: [ndc_x1, ndc_y1], color, clip_circle });
 -        verts.push(Vertex { position: [ndc_x2, ndc_y2], color, clip_circle });
 -        
 -        verts.push(Vertex { position: [ndc_x0, ndc_y0], color, clip_circle });
 -        verts.push(Vertex { position: [ndc_x2, ndc_y2], color, clip_circle });
 -        verts.push(Vertex { position: [ndc_x3, ndc_y3], color, clip_circle });
 -    }
 -    verts
 -}
 -
 -pub fn arc_background_vertices(
 -    cx: f32, cy: f32, r: f32,
 -    thickness: f32,
 -    start_angle: f32, end_angle: f32,
 -    sw: f32, sh: f32,
 -    color: [f32; 4],
 -    segments: usize,
 -    clip_circle: [f32; 3],
 -) -> Vec<Vertex> {
 -    let mut verts = Vec::new();
 -    push_arc_background_vertices(cx, cy, r, thickness, start_angle, end_angle, sw, sh, color, segments, clip_circle, &mut verts);
 -    verts
 -}
 -
 -/// A ring band with radial Gouraud shading: two sub-bands (inner rim → crest
 -/// centerline, crest → outer rim) whose vertex colors interpolate across the
 -/// stroke — the rounded-bevel profile — plus the half-px alpha feathers at
 -/// both true rims (colors matched to the adjacent band, so no seams).
 -#[allow(clippy::too_many_arguments)]
 -pub fn push_arc_shaded_vertices(
 -    cx: f32, cy: f32, r: f32,
 -    thickness: f32,
 -    start_angle: f32, end_angle: f32,
 -    sw: f32, sh: f32,
 -    inner: [f32; 4], crest: [f32; 4], outer: [f32; 4],
 -    segments: usize,
 -    clip_circle: [f32; 3],
 -    out: &mut Vec<Vertex>,
 -) {
 -    let f = 0.5f32.min(thickness * 0.25);
 -    let r_out = r;
 -    let r_in = (r - thickness).max(0.0);
 -    let r_mid = (r_in + r_out) / 2.0;
 -    let fade_in = [inner[0], inner[1], inner[2], 0.0];
 -    let fade_out = [outer[0], outer[1], outer[2], 0.0];
 -    // (inner radius, outer radius, color at inner edge, color at outer edge)
 -    let bands = [
 -        ((r_in - f).max(0.0), r_in + f, fade_in, inner),
 -        (r_in + f, r_mid, inner, crest),
 -        (r_mid, r_out - f, crest, outer),
 -        (r_out - f, r_out + f, outer, fade_out),
 -    ];
 -    for i in 0..segments {
 -        let theta1 = start_angle + (i as f32) * (end_angle - start_angle) / (segments as f32);
 -        let theta2 = start_angle + ((i + 1) as f32) * (end_angle - start_angle) / (segments as f32);
 -        let (c1, s1) = (theta1.cos(), theta1.sin());
 -        let (c2, s2) = (theta2.cos(), theta2.sin());
 -        for &(ra, rb, ca, cb) in &bands {
 -            if rb <= ra {
 -                continue;
 -            }
 -            let p = |rad: f32, c: f32, s: f32| -> [f32; 2] {
 -                [((cx + rad * c) / sw) * 2.0 - 1.0, 1.0 - ((cy + rad * s) / sh) * 2.0]
 -            };
 -            let (i1, o1) = (p(ra, c1, s1), p(rb, c1, s1));
 -            let (i2, o2) = (p(ra, c2, s2), p(rb, c2, s2));
 -            out.push(Vertex { position: i1, color: ca, clip_circle });
 -            out.push(Vertex { position: o1, color: cb, clip_circle });
 -            out.push(Vertex { position: o2, color: cb, clip_circle });
 -            out.push(Vertex { position: i1, color: ca, clip_circle });
 -            out.push(Vertex { position: o2, color: cb, clip_circle });
 -            out.push(Vertex { position: i2, color: ca, clip_circle });
 -        }
 -    }
 -}
 -
 -pub fn push_arc_background_vertices(
 -    cx: f32, cy: f32, r: f32,
 -    thickness: f32,
 -    start_angle: f32, end_angle: f32,
 -    sw: f32, sh: f32,
 -    color: [f32; 4],
 -    segments: usize,
 -    clip_circle: [f32; 3],
 -    out: &mut Vec<Vertex>,
 -) {
 -    // The stroke band [r - thickness, r], with a half-px alpha ramp on each rim
 -    // (Gouraud across thin edge bands) so curved edges resolve smoothly instead
 -    // of hard-stepping — the poor-man's AA the flat pipeline doesn't provide.
 -    let f = 0.5f32.min(thickness * 0.25);
 -    let r_in = (r - thickness).max(0.0);
 -    // (inner radius, outer radius, alpha at inner rim, alpha at outer rim)
 -    let bands = [
 -        ((r_in - f).max(0.0), r_in + f, 0.0, color[3]),
 -        (r_in + f, r - f, color[3], color[3]),
 -        (r - f, r + f, color[3], 0.0),
 -    ];
 -    for i in 0..segments {
 -        let theta1 = start_angle + (i as f32) * (end_angle - start_angle) / (segments as f32);
 -        let theta2 = start_angle + ((i + 1) as f32) * (end_angle - start_angle) / (segments as f32);
 -        let (c1, s1) = (theta1.cos(), theta1.sin());
 -        let (c2, s2) = (theta2.cos(), theta2.sin());
 -        for &(ra, rb, aa, ab) in &bands {
 -            if rb <= ra {
 -                continue;
 -            }
 -            let ca = [color[0], color[1], color[2], aa];
 -            let cb = [color[0], color[1], color[2], ab];
 -            let p = |rad: f32, c: f32, s: f32| -> [f32; 2] {
 -                [((cx + rad * c) / sw) * 2.0 - 1.0, 1.0 - ((cy + rad * s) / sh) * 2.0]
 -            };
 -            let (i1, o1) = (p(ra, c1, s1), p(rb, c1, s1));
 -            let (i2, o2) = (p(ra, c2, s2), p(rb, c2, s2));
 -            out.push(Vertex { position: i1, color: ca, clip_circle });
 -            out.push(Vertex { position: o1, color: cb, clip_circle });
 -            out.push(Vertex { position: o2, color: cb, clip_circle });
 -            out.push(Vertex { position: i1, color: ca, clip_circle });
 -            out.push(Vertex { position: o2, color: cb, clip_circle });
 -            out.push(Vertex { position: i2, color: ca, clip_circle });
 -        }
 -    }
 -}
 -
 -#[derive(Debug, Clone)]
 -pub struct WindowSettings {
 -    pub title: String,
 -    pub app_id: String,
 -    pub width: u32,
 -    pub height: u32,
 -    pub fullscreen: bool,
 -    pub min_size: Option<(u32, u32)>,
 -}
 -
 -/// A compositor-side window operation requested by the app: an interactive
 -/// move or resize grab. Returned from [`Application::take_window_action`];
 -/// the runner executes it with the serial of the most recent pointer press.
 -#[derive(Debug, Clone, Copy, PartialEq, Eq)]
 -pub enum WindowAction {
 -    Move,
 -    Resize(xdg_toplevel::ResizeEdge),
 -}
 -
 -// Re-export the wlr-layer-shell types apps need to describe a layer surface.
 -pub use smithay_client_toolkit::shell::wlr_layer::{
 -    Anchor as LayerAnchor, KeyboardInteractivity as LayerKeyboardInteractivity, Layer as LayerKind,
 +use wayland_protocols::wp::pointer_gestures::zv1::client::{
 +    zwp_pointer_gesture_pinch_v1::{self, ZwpPointerGesturePinchV1},
 +    zwp_pointer_gestures_v1::{self as zwp_pointer_gestures, ZwpPointerGesturesV1},
  };
 +pub use smithay_client_toolkit::reexports::protocols::xdg::shell::client::xdg_toplevel;
 +pub use smithay_client_toolkit::seat::pointer::CursorIcon as PointerCursorIcon;
 +use calloop::EventLoop;
 +use calloop_wayland_source::WaylandSource;
 +use cosmic_text::FontSystem;
- use crate::widget::{TextItem, MouseButton, ElementState, Key, NamedKey};
++use crate::widget::{MouseButton, ElementState, Key, NamedKey};
 +use crate::wayland::detect_scale_factor;
 +use crate::vk::VkRenderer;
  
 -/// Opt-in configuration for running an [`Application`] on a wlr-layer-shell
 -/// surface (panels, overlays, notifications) instead of an xdg toplevel.
 -/// Return one from [`Application::layer`] to select layer-shell.
 -#[derive(Debug, Clone)]
 -pub struct LayerSettings {
 -    pub layer: LayerKind,
 -    pub anchor: LayerAnchor,
 -    pub exclusive_zone: i32,
 -    pub keyboard_interactivity: LayerKeyboardInteractivity,
 -    /// (top, right, bottom, left) margins in logical pixels.
 -    pub margin: (i32, i32, i32, i32),
 -    pub namespace: String,
 -}
 -
 -#[derive(Debug, Clone, Copy, PartialEq)]
 -pub struct LogicalPosition {
 -    pub x: f32,
 -    pub y: f32,
 -}
 -
 -impl LogicalPosition {
 -    pub fn new(x: f32, y: f32) -> Self {
 -        Self { x, y }
 -    }
 -}
 -
 -#[derive(Debug, Clone, Copy, PartialEq)]
 -pub struct LogicalSize {
 -    pub width: f32,
 -    pub height: f32,
 -}
 -
 -impl LogicalSize {
 -    pub fn new(width: f32, height: f32) -> Self {
 -        Self { width, height }
 -    }
 -}
 -
 -pub struct RenderContext<'a> {
 -    pub font_system: &'a mut FontSystem,
 -}
 -
 -pub trait Application: Sized + 'static {
 -    type Message: Send + Clone + 'static;
 -
 -    fn new(qh: &QueueHandle<EngineState<Self>>, sender: calloop::channel::Sender<Self::Message>) -> Self;
 -    fn settings(&self) -> WindowSettings;
 -    /// Return `Some(..)` to run on a wlr-layer-shell surface (overlay/panel)
 -    /// instead of an xdg toplevel. Defaults to `None` (a normal window).
 -    fn layer(&self) -> Option<LayerSettings> {
 -        None
 -    }
 -    /// Declare the window a UTILITY window: a tool whose shape is decided by
 -    /// its contents. The compositor then never dictates a size to it (every
 -    /// configure is the "you choose" 0x0 — [`WindowSettings::width`]/`height`
 -    /// become the surface's own initial size), offers no resize affordance
 -    /// (the whole border band moves the window), and never saves geometry
 -    /// for it, so a stale remembered size can't be restored over what the
 -    /// app asks for. Declared over the cce window-management protocol at
 -    /// window creation; on a compositor too old to know the request this is
 -    /// silently a plain floating window. Defaults to `false`.
 -    fn utility(&self) -> bool {
 -        false
 -    }
 -    /// Declare the window the DESKTOP-GRID layer (zcce set_grid): the
 -    /// compositor world-anchors the surface to the virtual desktop and
 -    /// pans/zooms it per frame like window content; the app renders only
 -    /// when handed a patch (see [`Application::grid_patch`]). The surface
 -    /// becomes input-transparent and lives behind all windows. Needs
 -    /// manager v6; on an older compositor the declaration is skipped.
 -    /// Defaults to `false`.
 -    fn grid(&self) -> bool {
 -        false
 -    }
 -    /// A grid patch to render (grid apps only): virtual origin (`x`, `y`),
 -    /// virtual size (`w`, `h`), and `scale` surface px per virtual unit.
 -    /// Called right before the frame that must show it; the runner has
 -    /// already resized the surface to `(w*scale, h*scale)` and acks the
 -    /// patch so the coming commit is latched at the new anchor.
 -    fn grid_patch(&mut self, _x: f64, _y: f64, _w: f64, _h: f64, _scale: f64) {}
 -    fn update(&mut self, msg: Self::Message, needs_rebuild: &mut bool, exit: &mut bool);
 -    fn tick(&mut self, dt: f32, needs_rebuild: &mut bool);
 -    /// How long the runner may sleep between `tick`s while the window is
 -    /// idle — nothing to draw, no animation, no key held, no frame callback
 -    /// outstanding. `None` (the default) lets it sleep until a Wayland
 -    /// event or a message on the app's calloop `Sender` arrives, bounded by
 -    /// [`IDLE_DISPATCH`]. Override with `Some` ONLY if your `tick` polls
 -    /// something the loop cannot see — a `std::sync::mpsc` receiver drained
 -    /// in `tick`, say — because with the default that poll waits for the
 -    /// next unrelated event. The better fix is to send through the calloop
 -    /// `Sender` handed to `new`, which wakes the loop by itself.
 -    fn idle_poll_interval(&self) -> Option<std::time::Duration> {
 -        None
 -    }
 -    /// On-top overlay quads drawn after the display list and its text (e.g. the status bar's
 -    /// tray-hover highlights). Deliberately separate from the single paint path.
 -    fn overlay_quads(&mut self, _quads: &mut Vec<(f32, f32, f32, f32, [f32; 4])>, _size: LogicalSize, _scale: f64) {}
 -    /// The part of the surface that changed since the last frame this app
 -    /// painted, as (x, y, w, h) in logical px, taken (and reset) once per
 -    /// rendered frame right after `display_list`. `None` — the default —
 -    /// means all of it. Returning a rect makes the frame a partial one: only
 -    /// that rect is repainted and only it is reported to the compositor as
 -    /// damage, which is what keeps a small change on a very large surface
 -    /// (an image dragged across the desktop grid) from costing a full
 -    /// repaint on both sides. The app vouches for the rect: anything that
 -    /// changed outside it keeps its old pixels. A frame that was skipped is
 -    /// the runner's to make up — the next one is painted in full.
 -    fn take_damage(&mut self, _size: LogicalSize, _scale: f64) -> Option<(f32, f32, f32, f32)> {
 -        None
 -    }
 -    fn input_regions(&self) -> Option<Vec<(i32, i32, i32, i32)>> {
 -        None
 -    }
 -
 -    /// Transparent overflow rim, in logical px, on the RIGHT and BOTTOM of
 -    /// the window. Non-zero opts into buffer-larger-than-geometry mode: the
 -    /// runner sizes the surface `margin` wider/taller than the configured
 -    /// window size, publishes the top-left rect as the xdg window geometry
 -    /// (what the compositor tiles, borders, and snaps) and an input region of
 -    /// the frame plus any open popover rects — an overhanging menu stays
 -    /// clickable while empty rim falls through to whatever is behind.
 -    ///
 -    /// Right/bottom ONLY, deliberately: the surface grows away from its
 -    /// origin, so the frame never moves relative to the surface and pointer
 -    /// coordinates stay valid across the resize (a leading rim shifts the
 -    /// surface under an unmoved cursor, and the compositor's stale pointer
 -    /// state then drops the very next click). Frame coords == surface coords:
 -    /// no input translation, no paint shift — the app's only obligation is to
 -    /// lay out against the frame (`display_list`'s `size` minus the margin);
 -    /// content emitted past the frame edge renders in the rim instead of
 -    /// clipping at the buffer edge.
 -    ///
 -    /// The value may change at runtime (return the popover overhang while a
 -    /// menu is open, 0 otherwise): the engine re-derives the surface from the
 -    /// stored frame and resizes on drift. Quantize the answer (e.g. 64px
 -    /// steps) so an animating popover doesn't resize the surface per frame.
 -    /// xdg toplevels only (layer surfaces ignore it).
 -    fn overflow_margin(&self) -> u32 {
 -        0
 -    }
 -
 -    fn desired_size(&self) -> Option<(u32, u32)> {
 -        None
 -    }
 -    
 -    fn ui_context(&self) -> Option<&crate::context::UiContext> {
 -        None
 -    }
 -
 -    fn ui_context_mut(&mut self) -> Option<&mut crate::context::UiContext> {
 -        None
 -    }
 -
 -    /// Where a widget's open popover is DRAWN, as an offset from the rect it
 -    /// reports (`popover_rect`). A widget reports in the coordinates it was
 -    /// laid out in; an app that lays its page out unscrolled and shifts what
 -    /// it emits draws the popover `scroll` px away from there, and returns
 -    /// `(0.0, -scroll_y)` here for the page's widgets. Everything the engine
 -    /// derives from a popover rect reads it through this: the text-occlusion
 -    /// clamp, the overflow input region, and the region sent to the
 -    /// compositor. Without it a menu opened on a scrolled page had the page's
 -    /// text drawn over it, and cut a menu-shaped hole in the text one scroll
 -    /// offset away.
 -    fn popover_offset(&self, _id: crate::widget::WidgetId) -> (f32, f32) {
 -        (0.0, 0.0)
 -    }
 -
 -    /// Whether a left-press at (px, py) should start a compositor window drag. Every root
 -    /// root plate container is dissolved (Phase 6), so the default is "no" — apps that want
 -    /// drag-anywhere override this with `ctx.drag_allowed_at(px, py)`.
 -    fn is_movable_root_plate_at(&self, _px: f32, _py: f32) -> bool {
 -        false
 -    }
 -    
 -    fn clear_color(&self) -> [f32; 4] {
 -        [0.0, 0.0, 0.0, 0.0]
 -    }
 -
 -    fn register_sources(&mut self, _handle: &calloop::LoopHandle<'_, EngineState<Self>>) {}
 -
 -    fn adjust_size(&self, width: f32, height: f32) -> (f32, f32) {
 -        (width, height)
 -    }
 -    
 -    /// Mime types this app accepts from a drag, in the app's own preference
 -    /// order (the source's order is ignored — a browser lists `text/html`
 -    /// before `text/uri-list` and which is more useful is the app's call).
 -    /// The default is empty: the app accepts nothing and drags over it read
 -    /// as "can't drop here", which is what every client did before drops
 -    /// existed. Opting in also requires [`Application::handle_drop`].
 -    fn drop_mimes(&self) -> &'static [&'static str] {
 -        &[]
 -    }
 -
 -    /// A completed drop: `data` is everything the source wrote for `mime`,
 -    /// and `pos` is where it was released in the app's logical coordinates.
 -    /// Runs on the main loop, after the transfer finished — this is not the
 -    /// place to block, since the compositor is waiting on the next frame.
 -    fn handle_drop(
 -        &mut self,
 -        _mime: &str,
 -        _data: &[u8],
 -        _pos: LogicalPosition,
 -        _needs_rebuild: &mut bool,
 -    ) {
 -    }
 -
 -    fn handle_pointer_move(&mut self, pos: LogicalPosition, needs_rebuild: &mut bool);
 -    fn handle_mouse_input(&mut self, button: MouseButton, state: ElementState, pos: LogicalPosition, needs_rebuild: &mut bool) -> Option<Self::Message>;
 -    fn handle_mouse_wheel(&mut self, delta: &MouseScrollDelta, pos: LogicalPosition, needs_rebuild: &mut bool);
 -    /// Trackpad pinch (zwp_pointer_gestures pinch). `factor` is the scale
 -    /// change SINCE THE LAST update (1.0 = no change, >1 = fingers spreading),
 -    /// so direct-manipulation zoom is `content_scale *= factor`. Return true
 -    /// to consume; returning false falls back to the engine's legacy
 -    /// synthesis — a ctrl+wheel PixelDelta sized for the graph's zoom mapping
 -    /// (`y = (factor-1)/0.015`) — so ctrl-scroll-zoom surfaces keep working
 -    /// without implementing this.
 -    fn handle_pinch(&mut self, _factor: f32, _pos: LogicalPosition, _needs_rebuild: &mut bool) -> bool {
 -        false
 -    }
 -    fn handle_key_input(&mut self, event: &KeyEvent, needs_rebuild: &mut bool) -> Option<Self::Message>;
 -
 -    /// Undo, after the focused widget declined the chord (a text box that is
 -    /// editing takes it for its own typing). Return true when something was
 -    /// undone; false lets the key fall through to `handle_key_input` like any
 -    /// other. The chords are `undo` / `redo` in `input.kdl` (cce-ui domain
 -    /// defaults `ctrl+z` / `ctrl+shift+z`), resolved once at startup. Build
 -    /// the history on `cce_ui::history::History`.
 -    fn undo(&mut self, _needs_rebuild: &mut bool) -> bool {
 -        false
 -    }
 -
 -    /// Redo — see [`undo`](Self::undo).
 -    fn redo(&mut self, _needs_rebuild: &mut bool) -> bool {
 -        false
 -    }
 -
 -    /// Opt into the toolkit's keyboard navigation in plate terms: Tab and
 -    /// Shift+Tab move focus to the next / previous plate or well in reading
 -    /// order (`UiContext::focus_step`), a press (Enter / Space) acts on the
 -    /// focused plate, a well opens for typing when focused. Default false: an
 -    /// app that routes Tab itself (a terminal, a web view, its own field
 -    /// order) is undisturbed. See "Plates, wells and seams" in `CLAUDE.md`.
 -    fn plate_navigation(&self) -> bool {
 -        false
 -    }
 -
 -    /// Wait for the NEXT compositor when this one goes away, instead of
 -    /// exiting. Default false, which is right for any window the compositor
 -    /// saves and restores: its successor respawns the app itself, and a
 -    /// client that rejoined too came up beside its own copy (see
 -    /// [`after_session`]). Return true from a process the compositor does NOT
 -    /// restore and that must outlive it — a systemd user service like the
 -    /// status bar or the notifier, whose D-Bus names (the tray's
 -    /// StatusNotifierWatcher, org.freedesktop.Notifications) other programs
 -    /// depend on. Exiting took those names down at every logout and
 -    /// compositor restart, and Dropbox, starting into the gap, found no tray.
 -    fn outlives_compositor(&self) -> bool {
 -        false
 -    }
 -
 -    /// Keyboard focus just moved by the toolkit's Tab traversal. An app that
 -    /// caches its geometry until its own rebuild flag (relief carves collected
 -    /// in a view pass, widget lists built on layout) raises that flag here, so
 -    /// the new ring is drawn; an app that paints fresh every frame needs
 -    /// nothing. Default: nothing.
 -    fn focus_stepped(&mut self) {}
 -    /// Keyboard focus entered/left the window (the compositor keyboard-focuses
 -    /// the focused window, so this is the "am I the focused window" signal —
 -    /// e.g. for focus-dependent chrome). Default: ignore.
 -    fn handle_focus_change(&mut self, _focused: bool, _needs_rebuild: &mut bool) {}
 -
 -    fn custom_vertices(&mut self, _verts: &mut Vec<Vertex>, _size: LogicalSize, _scale: f64) {}
 -
 -    /// The frame's geometry, drawn via one batched, GPU-scissor-clipped pass (the single
 -    /// paint path). Every rendering app implements this — the legacy `view*` sinks are gone;
 -    /// `None` yields an empty frame. Overlays ([`overlay_quads`](Application::overlay_quads))
 -    /// and [`custom_vertices`](Application::custom_vertices) still go through their own paths;
 -    /// text renders from the list when [`display_list_text`](Application::display_list_text)
 -    /// opts in. Receives the frame's logical size and HiDPI scale. Typically implemented as
 -    /// `Some(cce_ui::scene::painter::paint_tree(&self.ui_context, &self.root))`.
 -    fn display_list(&mut self, _size: LogicalSize, _scale: f64) -> Option<crate::scene::paint::DisplayList> {
 -        None
 -    }
 -
 -    /// Opt in to render the display list's `Prim::Text` items through the glyph pass
 -    /// (shaped via the shared buffer cache, clipped to the item clip ∩ the prim bounds). An
 -    /// app's ENTIRE frame — geometry and text — is then one
 -    /// [`display_list`](Application::display_list). Default `false` draws no text (an app that
 -    /// only draws geometry, or none at all).
 -    ///
 -    /// Display-list text gets the same popover-occlusion clamp as the legacy `text_areas`
 -    /// mapping (`popover_occlusion_clamp`, driven by `ui_context().active_popovers`), so an
 -    /// open popover's plate clips list text beneath it on both paths.
 -    fn display_list_text(&self) -> bool {
 -        false
 -    }
 -
 -    /// Opt into system fonts in the ENGINE's render `FontSystem` (the one that shapes
 -    /// display-list text and rasterizes every glyph at prepare time). Default `false`: the
 -    /// render FontSystem loads only the bundled CCE fonts, and text asking for a family that
 -    /// exists only among installed system fonts is silently invisible — buffers shaped
 -    /// app-side against a system-fonts `FontSystem` carry fontdb face IDs the engine's
 -    /// database doesn't have (the cce-colors Phase 6e bug). An app whose UI must render
 -    /// arbitrary installed families (the font picker) returns `true`; its own `FontSystem`,
 -    /// if it keeps one for measurement, should be `create_font_system_with_system_fonts()`
 -    /// so both databases load identically. Consulted once, at GPU init.
 -    fn load_system_fonts(&self) -> bool {
 -        false
 -    }
 -
 -    /// Called once, right after the renderer is created and before the first
 -    /// frame: create persistent renderer resources here (3D meshes via
 -    /// [`VkRenderer::create_mesh`]). Most 2D apps never need this.
 -    fn renderer_init(&mut self, _renderer: &mut VkRenderer) {}
 -
 -    /// Direct renderer staging, called every frame after the engine's own text
 -    /// prep and immediately before the frame is drawn: stage 3D scene panes
 -    /// (`stage_scene`), path-traced panes (`stage_rt`), flush mesh updates, or
 -    /// prepare app-shaped text (`prepare_text` — an app that returns `false`
 -    /// from [`display_list_text`](Application::display_list_text) fully owns
 -    /// the renderer's text state, the engine never touches it). Return `true`
 -    /// to request another frame immediately (e.g. while a path tracer is still
 -    /// accumulating samples).
 -    fn stage_renderer(&mut self, _renderer: &mut VkRenderer, _size: LogicalSize, _scale: f64) -> bool {
 -        false
 -    }
 -
 -    /// The surface was resized (or the scale factor changed): `width`/`height`
 -    /// are the new logical size. The renderer has already been resized; use
 -    /// this for stateful relayout that can't wait for the next paint callback.
 -    fn handle_resize(&mut self, _width: f32, _height: f32, _scale: f64) {}
 -
 -    /// Whether the runner's built-in client-side decorations apply: the
 -    /// titlebar move band, the movable-root plate drag regions, and — when
 -    /// [`csd_resize_borders`](Application::csd_resize_borders) is also on —
 -    /// the rect-edge resize grabs and their edge cursors. Return `false` for a
 -    /// window whose chrome doesn't follow its rect (e.g. a circular pane) and
 -    /// drive moves/resizes yourself via
 -    /// [`take_window_action`](Application::take_window_action).
 -    fn standard_csd(&self) -> bool {
 -        true
 -    }
 -
 -    /// Whether the standard CSD claims the outer 8px of the surface as resize
 -    /// grabs (with matching edge cursors). Off by default: under the cce
 -    /// compositor the server already provides a resize band just *outside* the
 -    /// window, so enabling this gives a window two adjacent 8px gutters driven
 -    /// by different code paths — and only the compositor's snaps to the
 -    /// desktop grid. It also costs the app clicks, since a press inside the
 -    /// band starts a grab and never reaches the widgets underneath.
 -    ///
 -    /// Turn it on for a window that must be resizable by its own edges under a
 -    /// compositor that provides no such affordance. Only consulted when
 -    /// [`standard_csd`](Application::standard_csd) is on.
 -    fn csd_resize_borders(&self) -> bool {
 -        false
 -    }
 -
 -    /// Whether the standard CSD reserves an implicit title-bar strip (`y` in `[8, 32)`) as a
 -    /// drag-to-move handle. Opt-in: off by default, so a window has no title bar and is moved
 -    /// through the compositor (or via explicitly-declared handles —
 -    /// [`is_movable_root_plate_at`](Application::is_movable_root_plate_at)); nothing is
 -    /// implicitly draggable. An app with an actual title bar returns `true`. Separate from
 -    /// [`standard_csd`](Application::standard_csd), which also gates the resize borders, and
 -    /// only consulted when `standard_csd()` is on.
 -    fn csd_titlebar_move(&self) -> bool {
 -        false
 -    }
 -
 -    /// Override the pointer cursor at (x, y). `None` falls back to the
 -    /// runner's standard CSD edge cursors (or `Default` when
 -    /// [`standard_csd`](Application::standard_csd) is off).
 -    fn cursor_icon(&self, _x: f32, _y: f32) -> Option<CursorIcon> {
 -        None
 -    }
 -
 -    /// Polled after each pointer frame is dispatched: return a
 -    /// [`WindowAction`] to start an interactive move/resize grab with the
 -    /// serial of the most recent pointer press. This is take-semantics — the
 -    /// implementation should clear its pending action when returning it.
 -    fn take_window_action(&mut self) -> Option<WindowAction> {
 -        None
 -    }
 -
 -    /// Called once when the event loop ends (window closed, app-requested
 -    /// exit): last-chance work like autosave. The surface is still alive.
 -    fn on_exit(&mut self) {}
 -}
 +pub use super::app::*;
 +pub use super::driver::PressedKey;
 +use super::frame::build_frame;
 +use super::shell::{Pacer, Shell, Step, ACTIVE_DISPATCH};
 +use super::driver::{Driver, Modifiers, Press, PressSite, ResizeEdge, ScrollFrame, ScrollSource, Turn};
 +pub use super::tessellate::*;
 +pub use super::text::*;
  
 -pub struct PressedKey {
 -    pub logical_key: Key,
 -    pub text: Option<String>,
 -    pub first_pressed: Instant,
 -    pub last_repeated: Instant,
 -}
 +pub use super::shell::IDLE_DISPATCH;
  
 -fn is_repeatable_key(key: &Key) -> bool {
 -    match key {
 -        Key::Named(NamedKey::Backspace) |
 -        Key::Named(NamedKey::Delete) |
 -        Key::Named(NamedKey::ArrowLeft) |
 -        Key::Named(NamedKey::ArrowRight) |
 -        Key::Named(NamedKey::ArrowUp) |
 -        Key::Named(NamedKey::ArrowDown) |
 -        Key::Named(NamedKey::Home) |
 -        Key::Named(NamedKey::End) |
 -        Key::Character(_) => true,
 -        _ => false,
 -    }
++/// The `CCE_PRESENT_DEBUG` traces' timestamp: wall-clock milliseconds, mod
++/// 100 s, short enough to read down a column of lines.
++fn debug_clock_ms() -> u128 {
++    std::time::SystemTime::now()
++        .duration_since(std::time::UNIX_EPOCH)
++        .map_or(0, |d| d.as_millis() % 100_000)
+ }
+ 
 -/// Default cap on the runner's idle sleep — see `Application::idle_poll_interval`.
 -pub const IDLE_DISPATCH: std::time::Duration = std::time::Duration::from_millis(1000);
 -
  pub struct EngineState<A: Application> {
      pub registry_state: RegistryState,
      pub compositor_state: CompositorState,
@@@ -156,6 -4224,24 +164,14 @@@
      /// Serial of the most recent pointer press, kept for
      /// [`Application::take_window_action`] move/resize grabs.
      pub last_press_serial: Option<u32>,
 -    /// Mouse buttons currently held, as a bitmask (1 Left / 2 Right /
 -    /// 4 Middle). On pointer Leave mid-gesture the real Release goes to
 -    /// whatever surface takes the pointer next (fullscreen switches, layout
 -    /// animations), so Leave synthesizes releases for the held set — a drag
 -    /// must end, not stay armed and steered by later motion — and only then
 -    /// runs the off-screen hover-clear (which would otherwise corrupt the
 -    /// drag: a ramp key snapped to the graph corner).
 -    pub buttons_down: u32,
+     /// The touchscreen, once the seat offers one; see `backend/touch.rs`.
+     pub touch: Option<wl_touch::WlTouch>,
+     pub touch_tracker: super::touch::TouchTracker,
+     /// The followed finger's surface offset into window coordinates (the
+     /// menu popup's, or none), fixed at its down.
+     pub touch_offset: (f32, f32),
+     /// Where a touch scroll is dispatched: the finger's down point.
+     pub touch_scroll_at: Option<(f32, f32)>,
      /// This frame's display-list text, shaped and held here so the `TextSpan`s built
      /// in the render pass can borrow the buffers (Phase 6 —
      /// [`Application::display_list_text`]).
@@@ -501,162 -4793,6 +523,162 @@@ impl<A: Application> EngineState<A> 
          } else {
              self.damage_owed = false;
          }
 +        self.sync_text_input();
 +    }
 +
 +    /// Bring the text input in step with the frame just built: enabled at
 +    /// the editing widget's caret, disabled with nothing editing, reset for
 +    /// a composition a widget dropped (`backend::text_input`).
 +    fn sync_text_input(&mut self) {
 +        use crate::backend::text_input::Send;
 +        use zwp_text_input_v3::{ContentHint, ContentPurpose};
 +        let reset = crate::ime::take_reset();
 +        let Some(ti) = self.text_input.clone() else { return };
 +        // Forced mode: the surface is the compositor's scale-1 space.
 +        let surface_scale = crate::scale::forced_scale().unwrap_or(1.0);
 +        match self.text_input_state.plan(crate::ime::caret(), surface_scale, reset) {
 +            Send::Nothing => {}
 +            Send::Enable { rect: [x, y, w, h], reset } => {
 +                if reset {
 +                    ti.disable();
 +                    ti.commit();
 +                }
 +                ti.enable();
 +                ti.set_content_type(ContentHint::None, ContentPurpose::Normal);
 +                ti.set_cursor_rectangle(x, y, w, h);
 +                ti.commit();
 +            }
 +            Send::Move { rect: [x, y, w, h] } => {
 +                ti.set_cursor_rectangle(x, y, w, h);
 +                ti.commit();
 +            }
 +            Send::Disable => {
 +                ti.disable();
 +                ti.commit();
 +            }
 +        }
 +    }
 +}
 +
 +impl<A: Application> Shell for EngineState<A> {
 +    type App = A;
 +
 +    /// The driver and the app's turn, borrowed apart: every input dispatch
 +    /// is `let (driver, t) = self.turn(); driver.<event>(t, ..)`.
 +    fn turn(&mut self) -> (&mut Driver, Turn<'_, A>) {
 +        (
 +            &mut self.driver,
 +            Turn { app: self.inner.as_mut().unwrap(), redraw: &mut self.redraw, exit: &mut self.exit },
 +        )
 +    }
 +
 +    fn app(&self) -> &A {
 +        self.inner.as_ref().unwrap()
 +    }
 +
 +    fn redraw(&mut self) -> &mut bool {
 +        &mut self.redraw
 +    }
 +
 +    fn exit_requested(&self) -> bool {
 +        self.exit
 +    }
 +
 +    fn take_just_configured(&mut self) -> bool {
 +        std::mem::replace(&mut self.just_configured, false)
 +    }
 +
 +    fn request_size(&mut self, w: u32, h: u32) {
 +        // desired_size is a window-frame size; the surface adds the
 +        // right/bottom overflow rim (0 for margin-less apps).
 +        let m = self.inner.as_ref().unwrap().overflow_margin() as f32;
 +        let (sw, sh) = (w as f32 + m, h as f32 + m);
 +        if (self.logical_width - sw).abs() > 0.001 || (self.logical_height - sh).abs() > 0.001 {
 +            self.frame_logical = (w as f32, h as f32);
 +            self.applied_margin = m;
 +            self.resize(sw, sh);
 +            self.redraw = true;
 +        }
 +    }
 +
 +    fn sync(&mut self) {
 +        // Overflow-margin drift (configure-sized apps): the rim can change at
 +        // runtime — a popover overhanging the window frame — so re-derive the
 +        // surface from the stored frame whenever the app's answer moves. While
 +        // the rim is live, re-publish geometry every loop: the input region
 +        // tracks the animating popover rects.
 +        let m_now = self.inner.as_ref().unwrap().overflow_margin() as f32;
 +        if (m_now - self.applied_margin).abs() > 0.001 && self.frame_logical.0 > 0.0 {
 +            self.applied_margin = m_now;
 +            let (fw, fh) = self.frame_logical;
 +            self.resize(fw + m_now, fh + m_now);
 +            self.redraw = true;
 +        }
 +        if self.applied_margin > 0.0 || self.overflow_was_active {
 +            self.publish_window_geometry();
 +            self.overflow_was_active = self.applied_margin > 0.0;
 +        }
 +        self.send_popover_region();
 +        self.sync_menu_popup();
 +    }
 +
 +    fn set_title(&mut self, title: &str) {
 +        if let Some(ref window) = self.window {
 +            window.set_title(title);
 +            window.commit();
 +        }
 +    }
 +
 +    fn frame_pending(&mut self) -> bool {
 +        // Frame-callback starvation fallback: the compositor only sends
 +        // frame-done for surfaces it actually renders, so a callback armed
 +        // while the window sat off-viewport (or the scene went static) may
 +        // never fire — and the vsync gate then freezes the app forever
 +        // with a perfectly live event loop (input processes, state changes,
 +        // nothing repaints). If a redraw has been waiting on a callback well
 +        // past any real vsync interval, stop waiting and draw.
 +        //
 +        // Gated on the renderer's present mode: forcing a present past a
 +        // dead callback is only safe under MAILBOX (the present replaces the
 +        // queued buffer). Under FIFO the driver's throttle waits on the
 +        // previous present's frame event, so the forced present itself
 +        // blocks forever inside the driver — the exact freeze this fallback
 +        // exists to prevent. There the gate stays closed: pixels may stale
 +        // until the next frame-done/configure, but the loop stays alive.
 +        if self.redraw
 +            && self.frame_callback_pending
 +            && self.renderer.as_ref().is_some_and(|r| r.forced_present_safe())
 +            && self.frame_callback_armed_at.is_none_or(|t| t.elapsed().as_millis() > 250)
 +        {
 +            self.frame_callback_pending = false;
-             if std::env::var("CCE_PRESENT_DEBUG").is_ok() {
-                 let t = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_millis() % 100000;
++            if crate::vk::present_debug() {
++                let t = debug_clock_ms();
 +                eprintln!("[vk] t={} starvation fallback fired (callback never came)", t);
 +            }
 +        }
 +        self.frame_callback_pending
 +    }
 +
 +    fn configured(&self) -> bool {
 +        self.first_configure_received
 +    }
 +
 +    fn present(&mut self, fresh: bool) {
 +        if !fresh {
 +            // A warm-down re-render: the window alone.
 +            self.render();
 +            return;
 +        }
 +        // A menu handed over from the window commits first, so
 +        // there is no moment with neither (`take_menu_popup_lead`).
 +        let lead = self.take_menu_popup_lead();
 +        if lead {
 +            self.render_menu_popup();
 +        }
 +        self.render();
 +        if !lead {
 +            self.render_menu_popup();
 +        }
      }
  }
  
@@@ -920,10 -5056,10 +942,13 @@@ impl<A: Application> SeatHandler for En
          if capability == Capability::Keyboard && self.keyboard.is_none() {
              let keyboard = self.seat_state.get_keyboard(qh, &seat, None).unwrap();
              self.keyboard = Some(keyboard);
 +            if let (Some(m), None) = (&self.text_input_manager, &self.text_input) {
 +                self.text_input = Some(m.get_text_input(&seat, qh, ()));
 +            }
          }
+         if capability == Capability::Touch && self.touch.is_none() {
+             self.touch = self.seat_state.get_touch(qh, &seat).ok();
+         }
      }
      
      fn remove_capability(
@@@ -1865,8 -6311,16 +1893,13 @@@ fn run_session<'l, A: Application>
          pinch_gesture: None,
          cce_toplevel: None,
          pending_grid_patch: None,
 -        last_pinch_scale: 1.0,
 -        cursor_pos: (0.0, 0.0),
          last_press_serial: None,
 -        buttons_down: 0,
+         touch: None,
+         touch_tracker: Default::default(),
+         touch_offset: (0.0, 0.0),
+         touch_scroll_at: None,
          dl_text_items: Vec::new(),
+         is_status_bar: false,
      };
  
      if let Err(e) = event_queue.roundtrip(&mut engine_state) {
@@@ -2021,10 -6479,28 +2055,9 @@@
      /// in the per-iteration path, so a `std::env::var` call here would be I/O
      /// on the loop that is under measurement.
      fn loop_debug() -> bool {
-         static FLAG: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
-         *FLAG.get_or_init(|| std::env::var_os("CCE_PRESENT_DEBUG").is_some())
+         crate::vk::present_debug()
      }
  
 -    /// Loop cadence while something is in motion: one tick per frame.
 -    const ACTIVE_DISPATCH: std::time::Duration = std::time::Duration::from_millis(16);
 -
 -    /// Upper bound on an idle sleep. The loop is woken early by any Wayland
 -    /// event or calloop-channel message, so this only caps how long an
 -    /// app-side poll that bypasses both (see `Application::idle_poll_interval`)
 -    /// can wait. `CCE_UI_IDLE_MS` overrides it — `16` restores the old
 -    /// always-ticking loop for a bisect.
 -    fn idle_dispatch() -> std::time::Duration {
 -        static IDLE: std::sync::OnceLock<std::time::Duration> = std::sync::OnceLock::new();
 -        *IDLE.get_or_init(|| {
 -            std::env::var("CCE_UI_IDLE_MS")
 -                .ok()
 -                .and_then(|v| v.parse::<u64>().ok())
 -                .map(std::time::Duration::from_millis)
 -                .unwrap_or(IDLE_DISPATCH)
 -        })
 -    }
 -
      /// Seconds after session start at which to inject a simulated connection
      /// loss, from `CCE_UI_FAULT_RECONNECT`. Resolved once: this is read from
      /// the per-iteration path.
diff --cc src/mac/mod.rs
index 19ca7a5,0000000..22532a9
mode 100644,000000..100644
--- a/src/mac/mod.rs
+++ b/src/mac/mod.rs
@@@ -1,1064 -1,0 +1,1065 @@@
 +//! The macOS shell: an [`Application`] run in an AppKit window, the way the
 +//! Wayland shell runs one on a surface and the browser shell on a canvas. It
 +//! is the third shell over the shared pieces — [`Driver`] routes what input
 +//! means, [`Pacer::turn`] decides what a turn does, [`build_frame`] builds
 +//! the frame — and the renderer is the Vulkan one, on Metal through
 +//! MoltenVK (a `CAMetalLayer` is a [`SurfaceTarget::Metal`]). What is left
 +//! here is AppKit's side:
 +//!
 +//! - **The window.** An `NSWindow` whose content is the app's root plate:
 +//!   transparent, its titlebar transparent over full-size content, so the
 +//!   plate fills the window and the traffic lights stand on its corner. The
 +//!   window keeps AppKit's own resize edges, so the driver's CSD resize band
 +//!   is the app's (`PressSite::own_edges`); a press it reads as the
 +//!   window's (the titlebar band, a movable root plate) drags the window
 +//!   (`performWindowDragWithEvent:`).
 +//! - **Events in.** A layer-hosting `NSView` (flipped, so y runs down as
 +//!   everywhere else in the toolkit) takes the mouse, scroll, magnify and
 +//!   key events and hands them, mapped by `backend::appkit`, to the driver.
 +//!   Command is the shortcut key (⌘Z is undo). AppKit's own key repeats
 +//!   and scroll momentum are dropped: the driver repeats a held key and the
 +//!   toolkit coasts a flick, as on Wayland.
 +//! - **Turns.** Main-queue dispatches (`dispatch2`): any event, and any
 +//!   `AppSender::send` from any thread (`set_wake`), asks for a turn at most
 +//!   one ACTIVE frame after the last; between, the loop sleeps for whatever
 +//!   the pacer said. A turn superseded by an earlier one is dropped by its
 +//!   generation.
 +//! - **Frames out.** [`build_frame`] at the view's size in points and the
 +//!   window's backing scale, drawn by the [`VkRenderer`]; `stage_renderer`
 +//!   (and so the portable `stage_3d`) runs before each draw, `renderer_init`
 +//!   once.
 +//!
 +//! The menu is a minimal one (Quit, ⌘Q), whose quit — like the close
 +//! button — asks the app to exit the way a compositor's close does.
 +//!
 +//! - **Input methods.** The view is an `NSTextInputClient`: while a widget
 +//!   is editing text, a key press goes through `interpretKeyEvents:`, whose
 +//!   marked text is the composition and whose inserted text the commit
 +//!   (`crate::ime`); the candidate window is put under the caret the
 +//!   widget reported. The clipboard is the general `NSPasteboard`
 +//!   (`widget::clipboard`).
 +//!
 +//! Not there yet: drag and drop, the context menu in a popup window (it is
 +//! drawn in the window and kept inside it, as on a layer surface), and blur
 +//! behind the window (`NSVisualEffectView`).
 +//!
 +//! **This module has never been run.** It is written against objc2's
 +//! AppKit bindings and type-checked for `aarch64-apple-darwin`
 +//! (`scripts/check-mac`) on Linux, where nothing can be linked or run: it
 +//! needs a Mac, with MoltenVK installed (the Vulkan SDK, or Homebrew's
 +//! `molten-vk` and `vulkan-loader`).
 +
 +use std::cell::{Cell, RefCell};
 +use std::sync::mpsc::Receiver;
 +use std::time::Duration;
 +
 +use cursor_icon::CursorIcon;
 +use dispatch2::{DispatchQueue, DispatchTime};
 +use objc2::rc::Retained;
 +use objc2::runtime::{AnyObject, NSObject, ProtocolObject, Sel};
 +use objc2::{define_class, msg_send, sel, AnyThread, DefinedClass, MainThreadOnly};
 +use objc2_app_kit::{
 +    NSApplication, NSApplicationActivationPolicy, NSApplicationDelegate, NSApplicationTerminateReply,
 +    NSBackingStoreType, NSColor, NSCursor, NSEvent, NSEventModifierFlags, NSEventType, NSMenu, NSMenuItem,
 +    NSTextInputClient, NSTrackingArea, NSTrackingAreaOptions, NSView, NSWindow, NSWindowDelegate, NSWindowStyleMask,
 +    NSWindowTitleVisibility,
 +};
 +use objc2_foundation::{
 +    ns_string, MainThreadMarker, NSArray, NSAttributedString, NSAttributedStringKey, NSNotFound, NSNotification,
 +    NSObjectProtocol, NSPoint, NSRange, NSRangePointer, NSRect, NSSize, NSString, NSUInteger,
 +};
 +use objc2_quartz_core::CAMetalLayer;
 +use web_time::Instant;
 +
 +use crate::backend::app::{set_wake, AppSender, Application, LogicalPosition, LogicalSize};
 +use crate::backend::appkit;
 +use crate::backend::driver::{Driver, Press, PressSite, Turn};
 +use crate::backend::frame::build_frame;
 +use crate::backend::shell::{Pacer, Shell, Step, ACTIVE_DISPATCH};
 +use crate::vk::{SurfaceTarget, VkRenderer};
- use crate::widget::{context_menu, ElementState, Key, TextItem};
++use crate::widget::{context_menu, ElementState, Key};
++use crate::backend::text::DlText;
 +
 +/// Run `A` in a window until it exits. Must be called on the main thread
 +/// (a process's `main`), as AppKit requires.
 +pub fn run<A: Application>() {
 +    let mtm = MainThreadMarker::new().expect("cce-ui's macOS shell runs on the main thread");
 +    let ns_app = NSApplication::sharedApplication(mtm);
 +    ns_app.setActivationPolicy(NSApplicationActivationPolicy::Regular);
 +
 +    let (tx, rx) = std::sync::mpsc::channel();
 +    // The scale is known before the app is built, as on the other shells: a
 +    // widget may read it as it is made.
 +    let main_scale = objc2_app_kit::NSScreen::mainScreen(mtm).map_or(2.0, |s| s.backingScaleFactor());
 +    crate::scale::set_scale_factor(main_scale as f32);
 +    let mut app = A::create(AppSender::from(tx));
 +    let settings = app.settings();
 +    crate::scale::set_app_id(settings.app_id.clone());
 +
 +    let delegate = Delegate::new(mtm);
 +    ns_app.setDelegate(Some(ProtocolObject::from_ref(&*delegate)));
 +    ns_app.setMainMenu(Some(&main_menu(mtm, &settings.title)));
 +
 +    let style = NSWindowStyleMask::Titled
 +        | NSWindowStyleMask::Closable
 +        | NSWindowStyleMask::Miniaturizable
 +        | NSWindowStyleMask::Resizable
 +        | NSWindowStyleMask::FullSizeContentView;
 +    let rect = NSRect::new(NSPoint::new(0.0, 0.0), NSSize::new(settings.width as f64, settings.height as f64));
 +    // SAFETY: released-when-closed is turned off at once, as a window made
 +    // outside a window controller must be.
 +    let window = unsafe {
 +        NSWindow::initWithContentRect_styleMask_backing_defer(
 +            NSWindow::alloc(mtm),
 +            rect,
 +            style,
 +            NSBackingStoreType::Buffered,
 +            false,
 +        )
 +    };
 +    unsafe { window.setReleasedWhenClosed(false) };
 +    window.setTitle(&NSString::from_str(&settings.title));
 +    window.setTitlebarAppearsTransparent(true);
 +    window.setTitleVisibility(NSWindowTitleVisibility::Hidden);
 +    window.setOpaque(false);
 +    window.setBackgroundColor(Some(&NSColor::clearColor()));
 +    window.setAcceptsMouseMovedEvents(true);
 +    if let Some((w, h)) = settings.min_size {
 +        window.setContentMinSize(NSSize::new(w as f64, h as f64));
 +    }
 +    window.setDelegate(Some(ProtocolObject::from_ref(&*delegate)));
 +
 +    let view = CceView::new(mtm, rect);
 +    let layer = CAMetalLayer::new();
 +    layer.setOpaque(false);
 +    let scale = window.backingScaleFactor();
 +    layer.setContentsScale(scale);
 +    // A layer-HOSTING view: the layer is set before the view wants one.
 +    view.setLayer(Some(&layer));
 +    view.setWantsLayer(true);
 +    window.setContentView(Some(&view));
 +    let _ = window.makeFirstResponder(Some(&view));
 +    window.center();
 +
 +    let fs = if app.load_system_fonts() {
 +        crate::create_font_system_with_system_fonts()
 +    } else {
 +        crate::create_font_system()
 +    };
 +    let (pw, ph) = physical((settings.width as f32, settings.height as f32), scale);
 +    let layer_ptr: *const std::ffi::c_void = Retained::as_ptr(&layer).cast();
 +    // SAFETY: the layer is held by the shell, which drops the renderer first.
 +    let mut renderer = match unsafe { VkRenderer::try_new_for(SurfaceTarget::Metal { layer: layer_ptr }, pw, ph, 0.0) } {
 +        Ok(r) => r,
 +        Err(e) => {
 +            log::error!("[mac] no renderer for the window: {e}");
 +            return;
 +        }
 +    };
 +    app.renderer_init(&mut renderer);
 +
 +    let shell = MacShell {
 +        renderer,
 +        app,
 +        driver: Driver::new(),
 +        redraw: true,
 +        exit: false,
 +        rx,
 +        fs,
 +        swash: cosmic_text::SwashCache::new(),
 +        items: Vec::new(),
 +        damage_owed: true,
 +        logical: (0.0, 0.0),
 +        scale,
 +        just_configured: true,
 +        cursor: CursorIcon::Default,
 +        pinch: 1.0,
 +        pacer: Pacer::new(settings.title),
 +        generation: 0,
 +        due: None,
 +        last_turn: Instant::now(),
 +        stopped: false,
 +        ime_caret: None,
 +        mtm,
 +        layer,
 +        view: view.clone(),
 +        window: window.clone(),
 +    };
 +    let shell = RefCell::new(shell);
 +    shell.borrow_mut().measure();
 +    SINK.with(|s| *s.borrow_mut() = Some(Box::new(move |ev| shell.borrow_mut().event(ev))));
 +    // A send from any thread hops to the main queue and asks for a turn.
 +    set_wake(Some(std::sync::Arc::new(|| DispatchQueue::main().exec_async(|| {
 +        send(Ev::Wake);
 +    }))));
 +
 +    window.makeKeyAndOrderFront(None);
 +    #[allow(deprecated)]
 +    ns_app.activateIgnoringOtherApps(true);
 +    send(Ev::Wake);
 +    ns_app.run();
 +
 +    // `stop:` returned the run loop: the app has exited. The shell — the
 +    // renderer before the layer it presents to — goes with the sink.
 +    set_wake(None);
 +    SINK.with(|s| s.borrow_mut().take());
 +    drop(view);
 +    drop(window);
 +}
 +
 +/// What the view, the delegate and the loop's dispatches hand the shell, in
 +/// plain values: the shell is generic over the app and the AppKit classes
 +/// cannot be, so they reach it through [`SINK`].
 +enum Ev {
 +    Moved { x: f32, y: f32 },
 +    Entered { x: f32, y: f32 },
 +    Exited,
 +    /// Answers true when the press is a window drag the view must start.
 +    Pressed { number: i64, flags: u64, x: f32, y: f32 },
 +    Released { number: i64, flags: u64, x: f32, y: f32 },
 +    Scrolled { dx: f64, dy: f64, precise: bool, inverted: bool, phase: u64, momentum: u64, flags: u64, x: f32, y: f32 },
 +    Magnified { phase: u64, magnification: f64 },
 +    Keyed { code: u16, chars: String, unmodified: String, flags: u64, down: bool },
 +    /// The input method's composition changed (marked text).
 +    Preedit(Option<crate::ime::Preedit>),
 +    /// The input method committed text.
 +    Commit(String),
 +    FlagsChanged { code: u16, flags: u64 },
 +    Focused(bool),
 +    /// The window's size or backing scale changed.
 +    Resized,
 +    /// The close button, or Quit: the app is asked to exit.
 +    CloseRequested,
 +    /// A turn scheduled under this generation is due.
 +    Turn(u64),
 +    /// Turn soon.
 +    Wake,
 +}
 +
 +thread_local! {
 +    static SINK: RefCell<Option<Box<dyn FnMut(Ev) -> bool>>> = const { RefCell::new(None) };
 +}
 +
 +/// Hand `ev` to the shell. An event raised from inside a dispatch (AppKit
 +/// calling back while the app runs) finds the shell busy and is dropped,
 +/// as the browser shell drops one; so is everything after the exit.
 +fn send(ev: Ev) -> bool {
 +    SINK.with(|s| match s.try_borrow_mut() {
 +        Ok(mut sink) => sink.as_mut().is_some_and(|f| f(ev)),
 +        Err(_) => false,
 +    })
 +}
 +
 +/// The window's side of the run loop, as the Wayland shell's `EngineState`
 +/// is the compositor's. Fields drop in order: the renderer before the layer
 +/// it presents to.
 +struct MacShell<A: Application> {
 +    renderer: VkRenderer,
 +    app: A,
 +    driver: Driver,
 +    redraw: bool,
 +    exit: bool,
 +    rx: Receiver<A::Message>,
 +    fs: cosmic_text::FontSystem,
 +    swash: cosmic_text::SwashCache,
-     items: Vec<TextItem>,
++    items: Vec<DlText>,
 +    damage_owed: bool,
 +    /// The view's size in points, and the window's backing scale.
 +    logical: (f32, f32),
 +    scale: f64,
 +    just_configured: bool,
 +    cursor: CursorIcon,
 +    /// The pinch's cumulative scale (AppKit reports each step's change).
 +    pinch: f32,
 +    pacer: Pacer,
 +    /// The generation of the turn that is due; older dispatches are stale.
 +    generation: u64,
 +    due: Option<Instant>,
 +    last_turn: Instant,
 +    stopped: bool,
 +    /// The caret the input method was last told of.
 +    ime_caret: Option<[f32; 4]>,
 +    mtm: MainThreadMarker,
 +    layer: Retained<CAMetalLayer>,
 +    view: Retained<CceView>,
 +    window: Retained<NSWindow>,
 +}
 +
 +impl<A: Application> MacShell<A> {
 +    fn event(&mut self, ev: Ev) -> bool {
 +        if self.stopped {
 +            return false;
 +        }
 +        let mut drag = false;
 +        match ev {
 +            Ev::Turn(g) => {
 +                if g == self.generation {
 +                    self.take_turn();
 +                }
 +                return false;
 +            }
 +            Ev::Wake => {}
 +            Ev::Moved { x, y } => {
 +                let pos = self.at(x, y);
 +                let (driver, t) = self.turn();
 +                driver.pointer_motion(t, pos);
 +                self.update_cursor();
 +            }
 +            Ev::Entered { x, y } => {
 +                let pos = self.at(x, y);
 +                let (driver, t) = self.turn();
 +                driver.pointer_enter(t, pos);
 +                self.update_cursor();
 +            }
 +            Ev::Exited => {
 +                let (driver, t) = self.turn();
 +                driver.pointer_leave(t);
 +            }
 +            Ev::Pressed { number, flags, x, y } => {
 +                let Some(btn) = appkit::button(number, flags) else { return false };
 +                self.sync_mods(flags);
 +                let pos = self.at(x, y);
 +                // AppKit resizes from its own window edges; a move is a drag.
 +                let site = PressSite { size: self.size(), on_popup: false, can_grab: true, own_edges: true };
 +                let (driver, t) = self.turn();
 +                drag = driver.pointer_press(t, btn, pos, site) == Press::Move;
 +            }
 +            Ev::Released { number, flags, x, y } => {
 +                let Some(btn) = appkit::button(number, flags) else { return false };
 +                let pos = self.at(x, y);
 +                let (driver, t) = self.turn();
 +                driver.pointer_release(t, btn, pos);
 +                self.update_cursor();
 +            }
 +            Ev::Scrolled { dx, dy, precise, inverted, phase, momentum, flags, x, y } => {
 +                // The system's natural-scrolling setting is the one in force
 +                // here, not input.kdl's: value controls read it through this.
 +                if precise {
 +                    crate::input::force_natural_scroll(Some(inverted));
 +                }
 +                let Some(frame) = appkit::scroll_frame(dx, dy, precise, inverted, phase, momentum) else { return false };
 +                self.sync_mods(flags);
 +                let pos = self.at(x, y);
 +                let (driver, t) = self.turn();
 +                driver.scroll(t, frame, pos);
 +            }
 +            Ev::Magnified { phase, magnification } => {
 +                if phase & appkit::phase::BEGAN != 0 {
 +                    self.pinch = 1.0;
 +                    self.driver.pinch_begin();
 +                }
 +                if magnification != 0.0 {
 +                    self.pinch *= 1.0 + magnification as f32;
 +                    let pinch = self.pinch;
 +                    let (driver, t) = self.turn();
 +                    driver.pinch_update(t, pinch);
 +                }
 +                if phase & (appkit::phase::ENDED | appkit::phase::CANCELLED) != 0 {
 +                    self.driver.pinch_end();
 +                }
 +            }
 +            Ev::Keyed { code, chars, unmodified, flags, down } => {
 +                let accel = flags & (appkit::flags::COMMAND | appkit::flags::CONTROL) != 0;
 +                let Some((key, text)) = appkit::map_key(code, &chars, &unmodified, accel) else { return false };
 +                self.sync_mods(flags);
 +                let state = if down { ElementState::Pressed } else { ElementState::Released };
 +                let (driver, t) = self.turn();
 +                driver.key(t, key, text, state);
 +            }
 +            Ev::Preedit(preedit) => {
 +                let (driver, t) = self.turn();
 +                driver.preedit(t, preedit);
 +            }
 +            Ev::Commit(text) => {
 +                let (driver, t) = self.turn();
 +                driver.preedit(t, None);
 +                let (driver, t) = self.turn();
 +                driver.commit_text(t, text);
 +            }
 +            Ev::FlagsChanged { code, flags } => {
 +                // A modifier key went down or up: the new state, and the key
 +                // itself as the other shells report it.
 +                self.sync_mods(flags);
 +                let bit = match code {
 +                    0x37 | 0x36 => appkit::flags::COMMAND,
 +                    0x38 | 0x3C => appkit::flags::SHIFT,
 +                    0x3A | 0x3D => appkit::flags::OPTION,
 +                    0x3B | 0x3E => appkit::flags::CONTROL,
 +                    _ => 0,
 +                };
 +                if let (true, Some((key @ Key::Named(_), _))) = (bit != 0, appkit::map_key(code, "", "", false)) {
 +                    let state = if flags & bit != 0 { ElementState::Pressed } else { ElementState::Released };
 +                    let (driver, t) = self.turn();
 +                    driver.key(t, key, None, state);
 +                }
 +            }
 +            Ev::Focused(focused) => {
 +                let (driver, t) = self.turn();
 +                driver.keyboard_focus(t, focused);
 +            }
 +            Ev::Resized => {
 +                if self.measure() {
 +                    self.just_configured = true;
 +                }
 +            }
 +            Ev::CloseRequested => self.exit = true,
 +        }
 +        self.wake();
 +        drag
 +    }
 +
 +    /// Ask for a turn one ACTIVE frame after the last at the latest, unless
 +    /// one is due sooner already.
 +    fn wake(&mut self) {
 +        let at = (self.last_turn + ACTIVE_DISPATCH).max(Instant::now());
 +        if self.due.is_some_and(|d| d <= at) {
 +            return;
 +        }
 +        self.schedule(at.saturating_duration_since(Instant::now()));
 +    }
 +
 +    fn schedule(&mut self, after: Duration) {
 +        self.generation += 1;
 +        self.due = Some(Instant::now() + after);
 +        let g = self.generation;
 +        let when = DispatchTime::try_from(after).unwrap_or(DispatchTime::NOW);
 +        let _ = DispatchQueue::main().after(when, move || {
 +            send(Ev::Turn(g));
 +        });
 +    }
 +
 +    fn take_turn(&mut self) {
 +        self.due = None;
 +        self.last_turn = Instant::now();
 +        while let Ok(msg) = self.rx.try_recv() {
 +            let mut rebuild = false;
 +            self.app.update(msg, &mut rebuild, &mut self.exit);
 +            self.redraw |= rebuild;
 +        }
 +        let mut pacer = std::mem::replace(&mut self.pacer, Pacer::new(String::new()));
 +        let step = pacer.turn(self);
 +        self.pacer = pacer;
 +        match step {
 +            Step::Exit => self.finish(),
 +            Step::Sleep(d) => {
 +                if self.due.is_none() {
 +                    self.schedule(d);
 +                }
 +            }
 +        }
 +    }
 +
 +    /// The app exited: let it take its leave, then return the run loop.
 +    fn finish(&mut self) {
 +        self.stopped = true;
 +        self.app.on_exit();
 +        let ns_app = NSApplication::sharedApplication(self.mtm);
 +        ns_app.stop(None);
 +        // `stop:` takes effect after the next event; this is it.
 +        if let Some(ev) = NSEvent::otherEventWithType_location_modifierFlags_timestamp_windowNumber_context_subtype_data1_data2(
 +            NSEventType::ApplicationDefined,
 +            NSPoint::new(0.0, 0.0),
 +            NSEventModifierFlags::empty(),
 +            0.0,
 +            0,
 +            None,
 +            0,
 +            0,
 +            0,
 +        ) {
 +            ns_app.postEvent_atStart(&ev, true);
 +        }
 +    }
 +
 +    /// Read the view's size and the backing scale, and size the drawable to
 +    /// them. Whether either changed.
 +    fn measure(&mut self) -> bool {
 +        let bounds = self.view.bounds();
 +        let (w, h) = (bounds.size.width as f32, bounds.size.height as f32);
 +        let scale = self.window.backingScaleFactor();
 +        if (w, h) == self.logical && scale == self.scale {
 +            return false;
 +        }
 +        self.logical = (w, h);
 +        if scale != self.scale {
 +            self.scale = scale;
 +            crate::scale::set_scale_factor(scale as f32);
 +        }
 +        // MoltenVK reports the layer's bounds times its contents scale as
 +        // the surface's extent.
 +        self.layer.setContentsScale(scale);
 +        let (pw, ph) = physical(self.logical, self.scale);
 +        self.renderer.resize(pw, ph);
 +        self.redraw = true;
 +        true
 +    }
 +
 +    /// Keep the input method in step with the frame just drawn: a
 +    /// composition a widget dropped, or one with no widget editing any more,
 +    /// is discarded (the marked text cleared first, so the `unmarkText` this
 +    /// may call commits nothing); a caret that moved has the candidate
 +    /// window follow it.
 +    fn sync_input_method(&mut self) {
 +        let caret = crate::ime::caret();
 +        let drop = crate::ime::take_reset() || (caret.is_none() && !self.view.ivars().marked.borrow().is_empty());
 +        let context = self.view.inputContext();
 +        if drop {
 +            self.view.ivars().marked.borrow_mut().clear();
 +            crate::ime::set_preedit(None);
 +            if let Some(c) = &context {
 +                c.discardMarkedText();
 +            }
 +        }
 +        if caret != self.ime_caret {
 +            self.ime_caret = caret;
 +            if let Some(c) = &context {
 +                c.invalidateCharacterCoordinates();
 +            }
 +        }
 +    }
 +
 +    fn size(&self) -> LogicalSize {
 +        LogicalSize::new(self.logical.0, self.logical.1)
 +    }
 +
 +    fn at(&mut self, x: f32, y: f32) -> LogicalPosition {
 +        self.driver.cursor_pos = (x, y);
 +        LogicalPosition::new(x, y)
 +    }
 +
 +    fn sync_mods(&mut self, flags: u64) {
 +        let mods = appkit::modifiers(flags);
 +        if mods != self.driver.mods {
 +            self.driver.set_modifiers(&mut self.app, mods);
 +        }
 +    }
 +
 +    fn update_cursor(&mut self) {
 +        let (x, y) = self.driver.cursor_pos;
 +        let icon = self.driver.cursor_icon_at(&self.app, x, y, self.size());
 +        if icon != self.cursor {
 +            self.cursor = icon;
 +            ns_cursor(icon).set();
 +        }
 +    }
 +}
 +
 +impl<A: Application> Shell for MacShell<A> {
 +    type App = A;
 +
 +    fn turn(&mut self) -> (&mut Driver, Turn<'_, A>) {
 +        (&mut self.driver, Turn { app: &mut self.app, redraw: &mut self.redraw, exit: &mut self.exit })
 +    }
 +
 +    fn app(&self) -> &A {
 +        &self.app
 +    }
 +
 +    fn redraw(&mut self) -> &mut bool {
 +        &mut self.redraw
 +    }
 +
 +    fn exit_requested(&self) -> bool {
 +        self.exit
 +    }
 +
 +    fn take_just_configured(&mut self) -> bool {
 +        std::mem::replace(&mut self.just_configured, false)
 +    }
 +
 +    fn request_size(&mut self, w: u32, h: u32) {
 +        if (w as f32, h as f32) != self.logical {
 +            self.window.setContentSize(NSSize::new(w as f64, h as f64));
 +            self.measure();
 +        }
 +    }
 +
 +    fn sync(&mut self) {
 +        // No popup window to host the menu yet: it is drawn in the window
 +        // and kept inside it, frames asked for while a page turn animates.
 +        if context_menu::is_visible() {
 +            if context_menu::is_turning() {
 +                self.redraw = true;
 +            }
 +            context_menu::set_hosted(false);
 +            context_menu::constrain_to(0.0, 0.0, self.logical.0, self.logical.1);
 +        }
 +    }
 +
 +    fn set_title(&mut self, title: &str) {
 +        self.window.setTitle(&NSString::from_str(title));
 +    }
 +
 +    fn frame_pending(&mut self) -> bool {
 +        false
 +    }
 +
 +    fn configured(&self) -> bool {
 +        self.logical.0 > 0.0 && self.logical.1 > 0.0
 +    }
 +
 +    fn present(&mut self, _fresh: bool) {
 +        let size = self.size();
 +        let frame = build_frame(&mut self.app, &mut self.fs, size, self.scale, &mut self.damage_owed, &mut self.items);
 +        if frame.dl_text {
 +            let spans = frame.text_spans(&self.items);
 +            self.renderer.prepare_text(&mut self.fs, &mut self.swash, &spans);
 +        }
 +        let (pw, ph) = physical(self.logical, self.scale);
 +        let e = self.renderer.pending_extent();
 +        if (e.width, e.height) != (pw, ph) {
 +            self.renderer.resize(pw, ph);
 +        }
 +        if self.app.stage_renderer(&mut self.renderer, size, self.scale) {
 +            self.redraw = true;
 +        }
 +        if self.renderer.draw_frame_2d(frame.frame2d()) {
 +            self.damage_owed = false;
 +        } else {
 +            self.redraw = true;
 +        }
 +        self.sync_input_method();
 +    }
 +}
 +
 +fn physical(logical: (f32, f32), scale: f64) -> (u32, u32) {
 +    let s = scale as f32;
 +    ((logical.0 * s).round().max(1.0) as u32, (logical.1 * s).round().max(1.0) as u32)
 +}
 +
 +/// The toolkit's cursor as AppKit's. AppKit has no public diagonal resize
 +/// cursors before macOS 15, so those are the arrow; the two straight ones
 +/// are deprecated there, for the directional cursors macOS 15 added, and
 +/// still what every earlier release has.
 +#[allow(deprecated)]
 +fn ns_cursor(icon: CursorIcon) -> Retained<NSCursor> {
 +    match icon {
 +        CursorIcon::Pointer => NSCursor::pointingHandCursor(),
 +        CursorIcon::Text | CursorIcon::VerticalText => NSCursor::IBeamCursor(),
 +        CursorIcon::Crosshair | CursorIcon::Cell => NSCursor::crosshairCursor(),
 +        CursorIcon::Grab => NSCursor::openHandCursor(),
 +        CursorIcon::Grabbing | CursorIcon::Move | CursorIcon::AllScroll => NSCursor::closedHandCursor(),
 +        CursorIcon::NotAllowed | CursorIcon::NoDrop => NSCursor::operationNotAllowedCursor(),
 +        CursorIcon::EResize | CursorIcon::WResize | CursorIcon::EwResize | CursorIcon::ColResize => {
 +            NSCursor::resizeLeftRightCursor()
 +        }
 +        CursorIcon::NResize | CursorIcon::SResize | CursorIcon::NsResize | CursorIcon::RowResize => {
 +            NSCursor::resizeUpDownCursor()
 +        }
 +        CursorIcon::ContextMenu => NSCursor::contextualMenuCursor(),
 +        CursorIcon::Copy => NSCursor::dragCopyCursor(),
 +        CursorIcon::Alias => NSCursor::dragLinkCursor(),
 +        _ => NSCursor::arrowCursor(),
 +    }
 +}
 +
 +/// An application menu with Quit (⌘Q) in it: every Mac app has one, and
 +/// without it ⌘Q does nothing.
 +fn main_menu(mtm: MainThreadMarker, title: &str) -> Retained<NSMenu> {
 +    let bar = NSMenu::new(mtm);
 +    let app_item = NSMenuItem::new(mtm);
 +    let app_menu = NSMenu::new(mtm);
 +    // SAFETY: `terminate:` is NSApplication's, which answers it through the
 +    // delegate's `applicationShouldTerminate:` below.
 +    let quit = unsafe {
 +        NSMenuItem::initWithTitle_action_keyEquivalent(
 +            NSMenuItem::alloc(mtm),
 +            &NSString::from_str(&format!("Quit {title}")),
 +            Some(sel!(terminate:)),
 +            ns_string!("q"),
 +        )
 +    };
 +    app_menu.addItem(&quit);
 +    app_item.setSubmenu(Some(&app_menu));
 +    bar.addItem(&app_item);
 +    bar
 +}
 +
 +/// Where an event's pointer is, in the view's (flipped) coordinates.
 +fn location(view: &NSView, event: &NSEvent) -> (f32, f32) {
 +    let p = view.convertPoint_fromView(event.locationInWindow(), None);
 +    (p.x as f32, p.y as f32)
 +}
 +
 +/// The view's input-method state, for the `NSTextInputClient` methods.
 +#[derive(Default)]
 +struct ViewIme {
 +    /// The marked text (the composition) as the input method last set it.
 +    marked: RefCell<String>,
 +    /// Inside `interpretKeyEvents:` for a key press, whose characters are
 +    /// these; and whether the input method took the press.
 +    in_key: Cell<bool>,
 +    consumed: Cell<bool>,
 +    key_chars: RefCell<String>,
 +}
 +
 +/// The text of what an input method hands over: an `NSString`, or an
 +/// `NSAttributedString` around one.
 +fn ns_text(obj: &AnyObject) -> String {
 +    if let Some(a) = obj.downcast_ref::<NSAttributedString>() {
 +        a.string().to_string()
 +    } else if let Some(s) = obj.downcast_ref::<NSString>() {
 +        s.to_string()
 +    } else {
 +        String::new()
 +    }
 +}
 +
 +define_class!(
 +    // SAFETY: NSView has no subclassing requirements, and CceView no Drop.
 +    #[unsafe(super(NSView, objc2_app_kit::NSResponder, NSObject))]
 +    #[thread_kind = MainThreadOnly]
 +    #[name = "CceUiView"]
 +    #[ivars = ViewIme]
 +    struct CceView;
 +
 +    unsafe impl NSObjectProtocol for CceView {}
 +
 +    // The input method's side of the view: AppKit calls these from inside
 +    // `interpretKeyEvents:` (see `key`), and the candidate window asks where
 +    // the caret is.
 +    unsafe impl NSTextInputClient for CceView {
 +        #[unsafe(method(insertText:replacementRange:))]
 +        unsafe fn insert_text(&self, string: &AnyObject, _replacement: NSRange) {
 +            let text = ns_text(string);
 +            let ime = self.ivars();
 +            let was_marked = !std::mem::take(&mut *ime.marked.borrow_mut()).is_empty();
 +            // A plain key typing its own character is left to the key path,
 +            // which keeps its named keys and the driver's repeat.
 +            if ime.in_key.get() && !was_marked && text == *ime.key_chars.borrow() {
 +                return;
 +            }
 +            ime.consumed.set(true);
 +            send(Ev::Commit(text));
 +        }
 +
 +        #[unsafe(method(doCommandBySelector:))]
 +        unsafe fn do_command(&self, _selector: Sel) {
 +            // A key the input method does not take (Return, Backspace, an
 +            // arrow): left to the key path.
 +        }
 +
 +        #[unsafe(method(setMarkedText:selectedRange:replacementRange:))]
 +        unsafe fn set_marked_text(&self, string: &AnyObject, selected: NSRange, _replacement: NSRange) {
 +            let text = ns_text(string);
 +            let ime = self.ivars();
 +            ime.consumed.set(true);
 +            *ime.marked.borrow_mut() = text.clone();
 +            let cursor = crate::backend::dom::utf16_range_to_bytes(
 +                &text,
 +                Some(selected.location as u32),
 +                Some((selected.location + selected.length) as u32),
 +            );
 +            send(Ev::Preedit((!text.is_empty()).then(|| crate::ime::Preedit { text, cursor })));
 +        }
 +
 +        #[unsafe(method(unmarkText))]
 +        fn unmark_text(&self) {
 +            // Accept the composition as it stands.
 +            let text = std::mem::take(&mut *self.ivars().marked.borrow_mut());
 +            if !text.is_empty() {
 +                send(Ev::Commit(text));
 +            }
 +        }
 +
 +        #[unsafe(method(selectedRange))]
 +        fn selected_range(&self) -> NSRange {
 +            NSRange::new(NSNotFound as usize, 0)
 +        }
 +
 +        #[unsafe(method(markedRange))]
 +        fn marked_range(&self) -> NSRange {
 +            let marked = self.ivars().marked.borrow();
 +            if marked.is_empty() {
 +                NSRange::new(NSNotFound as usize, 0)
 +            } else {
 +                NSRange::new(0, marked.encode_utf16().count())
 +            }
 +        }
 +
 +        #[unsafe(method(hasMarkedText))]
 +        fn has_marked_text(&self) -> bool {
 +            !self.ivars().marked.borrow().is_empty()
 +        }
 +
 +        #[unsafe(method_id(attributedSubstringForProposedRange:actualRange:))]
 +        unsafe fn attributed_substring(&self, _range: NSRange, _actual: NSRangePointer) -> Option<Retained<NSAttributedString>> {
 +            None
 +        }
 +
 +        #[unsafe(method_id(validAttributesForMarkedText))]
 +        fn valid_attributes(&self) -> Retained<NSArray<NSAttributedStringKey>> {
 +            NSArray::new()
 +        }
 +
 +        /// Where the candidate window goes: under the editing widget's caret,
 +        /// in screen coordinates.
 +        #[unsafe(method(firstRectForCharacterRange:actualRange:))]
 +        unsafe fn first_rect(&self, _range: NSRange, _actual: NSRangePointer) -> NSRect {
 +            let [x, y, w, h] = crate::ime::caret().unwrap_or([0.0, 0.0, 1.0, 16.0]);
 +            let caret = NSRect::new(NSPoint::new(x as f64, y as f64), NSSize::new(w.max(1.0) as f64, h as f64));
 +            let in_window = self.convertRect_toView(caret, None);
 +            self.window().map_or(in_window, |w| w.convertRectToScreen(in_window))
 +        }
 +
 +        #[unsafe(method(characterIndexForPoint:))]
 +        fn character_index(&self, _point: NSPoint) -> NSUInteger {
 +            NSNotFound as NSUInteger
 +        }
 +    }
 +
 +    impl CceView {
 +        #[unsafe(method(isFlipped))]
 +        fn is_flipped(&self) -> bool {
 +            true
 +        }
 +
 +        #[unsafe(method(acceptsFirstResponder))]
 +        fn accepts_first_responder(&self) -> bool {
 +            true
 +        }
 +
 +        #[unsafe(method(acceptsFirstMouse:))]
 +        fn accepts_first_mouse(&self, _event: Option<&NSEvent>) -> bool {
 +            true
 +        }
 +
 +        #[unsafe(method(mouseMoved:))]
 +        fn mouse_moved(&self, event: &NSEvent) {
 +            let (x, y) = location(self, event);
 +            send(Ev::Moved { x, y });
 +        }
 +
 +        #[unsafe(method(mouseDragged:))]
 +        fn mouse_dragged(&self, event: &NSEvent) {
 +            let (x, y) = location(self, event);
 +            send(Ev::Moved { x, y });
 +        }
 +
 +        #[unsafe(method(rightMouseDragged:))]
 +        fn right_mouse_dragged(&self, event: &NSEvent) {
 +            let (x, y) = location(self, event);
 +            send(Ev::Moved { x, y });
 +        }
 +
 +        #[unsafe(method(otherMouseDragged:))]
 +        fn other_mouse_dragged(&self, event: &NSEvent) {
 +            let (x, y) = location(self, event);
 +            send(Ev::Moved { x, y });
 +        }
 +
 +        #[unsafe(method(mouseEntered:))]
 +        fn mouse_entered(&self, event: &NSEvent) {
 +            let (x, y) = location(self, event);
 +            send(Ev::Entered { x, y });
 +        }
 +
 +        #[unsafe(method(mouseExited:))]
 +        fn mouse_exited(&self, _event: &NSEvent) {
 +            send(Ev::Exited);
 +        }
 +
 +        #[unsafe(method(mouseDown:))]
 +        fn mouse_down(&self, event: &NSEvent) {
 +            self.press(event);
 +        }
 +
 +        #[unsafe(method(rightMouseDown:))]
 +        fn right_mouse_down(&self, event: &NSEvent) {
 +            self.press(event);
 +        }
 +
 +        #[unsafe(method(otherMouseDown:))]
 +        fn other_mouse_down(&self, event: &NSEvent) {
 +            self.press(event);
 +        }
 +
 +        #[unsafe(method(mouseUp:))]
 +        fn mouse_up(&self, event: &NSEvent) {
 +            self.release(event);
 +        }
 +
 +        #[unsafe(method(rightMouseUp:))]
 +        fn right_mouse_up(&self, event: &NSEvent) {
 +            self.release(event);
 +        }
 +
 +        #[unsafe(method(otherMouseUp:))]
 +        fn other_mouse_up(&self, event: &NSEvent) {
 +            self.release(event);
 +        }
 +
 +        #[unsafe(method(scrollWheel:))]
 +        fn scroll_wheel(&self, event: &NSEvent) {
 +            let (x, y) = location(self, event);
 +            send(Ev::Scrolled {
 +                dx: event.scrollingDeltaX(),
 +                dy: event.scrollingDeltaY(),
 +                precise: event.hasPreciseScrollingDeltas(),
 +                inverted: event.isDirectionInvertedFromDevice(),
 +                phase: event.phase().0 as u64,
 +                momentum: event.momentumPhase().0 as u64,
 +                flags: event.modifierFlags().0 as u64,
 +                x,
 +                y,
 +            });
 +        }
 +
 +        #[unsafe(method(magnifyWithEvent:))]
 +        fn magnify(&self, event: &NSEvent) {
 +            send(Ev::Magnified { phase: event.phase().0 as u64, magnification: event.magnification() });
 +        }
 +
 +        #[unsafe(method(keyDown:))]
 +        fn key_down(&self, event: &NSEvent) {
 +            self.key(event, true);
 +        }
 +
 +        #[unsafe(method(keyUp:))]
 +        fn key_up(&self, event: &NSEvent) {
 +            self.key(event, false);
 +        }
 +
 +        #[unsafe(method(flagsChanged:))]
 +        fn flags_changed(&self, event: &NSEvent) {
 +            send(Ev::FlagsChanged { code: event.keyCode(), flags: event.modifierFlags().0 as u64 });
 +        }
 +    }
 +);
 +
 +impl CceView {
 +    fn new(mtm: MainThreadMarker, frame: NSRect) -> Retained<Self> {
 +        let this = Self::alloc(mtm).set_ivars(ViewIme::default());
 +        // SAFETY: NSView's designated initializer.
 +        let view: Retained<Self> = unsafe { msg_send![super(this), initWithFrame: frame] };
 +        // Enter, exit and motion wherever the view is, window key or not.
 +        let options = NSTrackingAreaOptions::MouseEnteredAndExited
 +            | NSTrackingAreaOptions::MouseMoved
 +            | NSTrackingAreaOptions::ActiveAlways
 +            | NSTrackingAreaOptions::InVisibleRect;
 +        // SAFETY: the view owns the area and outlives it; no user info.
 +        let area = unsafe {
 +            NSTrackingArea::initWithRect_options_owner_userInfo(
 +                NSTrackingArea::alloc(),
 +                NSRect::ZERO,
 +                options,
 +                Some(&view),
 +                None,
 +            )
 +        };
 +        view.addTrackingArea(&area);
 +        view
 +    }
 +
 +    fn press(&self, event: &NSEvent) {
 +        let (x, y) = location(self, event);
 +        let ev = Ev::Pressed { number: event.buttonNumber() as i64, flags: event.modifierFlags().0 as u64, x, y };
 +        if send(ev) {
 +            if let Some(window) = self.window() {
 +                window.performWindowDragWithEvent(event);
 +            }
 +        }
 +    }
 +
 +    fn release(&self, event: &NSEvent) {
 +        let (x, y) = location(self, event);
 +        send(Ev::Released { number: event.buttonNumber() as i64, flags: event.modifierFlags().0 as u64, x, y });
 +    }
 +
 +    fn key(&self, event: &NSEvent, down: bool) {
 +        let text = |s: Option<Retained<NSString>>| s.map(|s| s.to_string()).unwrap_or_default();
 +        let flags = event.modifierFlags().0 as u64;
 +        // While a widget is editing text, a key press goes to the input
 +        // method first (`interpretKeyEvents:`, which calls back into the
 +        // `NSTextInputClient` methods above); what it takes is composition
 +        // or a commit, what it leaves takes the key path below. A ⌘
 +        // shortcut never goes, and nothing does while nothing is editing,
 +        // so an input method left on does not eat an app's single-key
 +        // commands.
 +        let ime = self.ivars();
 +        if down && flags & appkit::flags::COMMAND == 0 && crate::ime::caret().is_some() {
 +            ime.in_key.set(true);
 +            ime.consumed.set(false);
 +            *ime.key_chars.borrow_mut() = text(event.characters());
 +            self.interpretKeyEvents(&NSArray::from_slice(&[event]));
 +            ime.in_key.set(false);
 +            if ime.consumed.get() {
 +                return;
 +            }
 +        }
 +        // The driver repeats a held key itself, as it does on Wayland.
 +        if event.isARepeat() {
 +            return;
 +        }
 +        let keyed = |down| Ev::Keyed {
 +            code: event.keyCode(),
 +            chars: text(event.characters()),
 +            unmodified: text(event.charactersIgnoringModifiers()),
 +            flags,
 +            down,
 +        };
 +        send(keyed(down));
 +        // AppKit sends no keyUp for a key pressed with Command held, so a ⌘
 +        // shortcut is released as it is pressed: otherwise the driver would
 +        // take the key as held, and repeat ⌘Z until the window lost focus.
 +        if down && flags & appkit::flags::COMMAND != 0 {
 +            send(keyed(false));
 +        }
 +    }
 +}
 +
 +define_class!(
 +    // SAFETY: NSObject has no subclassing requirements, and Delegate no Drop.
 +    #[unsafe(super(NSObject))]
 +    #[thread_kind = MainThreadOnly]
 +    #[name = "CceUiDelegate"]
 +    struct Delegate;
 +
 +    unsafe impl NSObjectProtocol for Delegate {}
 +
 +    unsafe impl NSApplicationDelegate for Delegate {
 +        /// Quit asks the app to exit, as a close does; the shell stops the
 +        /// run loop once it has.
 +        #[unsafe(method(applicationShouldTerminate:))]
 +        fn should_terminate(&self, _sender: &NSApplication) -> NSApplicationTerminateReply {
 +            send(Ev::CloseRequested);
 +            NSApplicationTerminateReply::TerminateCancel
 +        }
 +    }
 +
 +    unsafe impl NSWindowDelegate for Delegate {
 +        #[unsafe(method(windowShouldClose:))]
 +        fn window_should_close(&self, _sender: &NSWindow) -> bool {
 +            send(Ev::CloseRequested);
 +            false
 +        }
 +
 +        #[unsafe(method(windowDidResize:))]
 +        fn window_did_resize(&self, _notification: &NSNotification) {
 +            send(Ev::Resized);
 +        }
 +
 +        #[unsafe(method(windowDidChangeBackingProperties:))]
 +        fn window_did_change_backing(&self, _notification: &NSNotification) {
 +            send(Ev::Resized);
 +        }
 +
 +        #[unsafe(method(windowDidBecomeKey:))]
 +        fn window_did_become_key(&self, _notification: &NSNotification) {
 +            send(Ev::Focused(true));
 +        }
 +
 +        #[unsafe(method(windowDidResignKey:))]
 +        fn window_did_resign_key(&self, _notification: &NSNotification) {
 +            send(Ev::Focused(false));
 +        }
 +    }
 +);
 +
 +impl Delegate {
 +    fn new(mtm: MainThreadMarker) -> Retained<Self> {
 +        let this = Self::alloc(mtm).set_ivars(());
 +        // SAFETY: NSObject's `init`.
 +        unsafe { msg_send![super(this), init] }
 +    }
 +}
diff --cc src/motion.rs
index de298e6,aedaead..cbf6e35
--- a/src/motion.rs
+++ b/src/motion.rs
@@@ -88,6 -84,9 +84,9 @@@ fn enabled_on_this_machine() -> bool 
      if let Some(forced) = *ENV.get_or_init(|| std::env::var("CCE_ANIMATIONS").ok().and_then(|v| parse(&v))) {
          return forced;
      }
+     use std::sync::Mutex;
 -    use std::time::Instant;
++    use web_time::Instant;
+     static CACHE: Mutex<Option<(Instant, bool)>> = Mutex::new(None);
      let mut cache = CACHE.lock().unwrap_or_else(|e| e.into_inner());
      let now = Instant::now();
      match *cache {
diff --cc src/web/shell.rs
index df53633,0000000..37536bc
mode 100644,000000..100644
--- a/src/web/shell.rs
+++ b/src/web/shell.rs
@@@ -1,929 -1,0 +1,930 @@@
 +//! The browser shell: an [`Application`] run on a `<canvas>`, the way the
 +//! Wayland shell runs one on a surface. It is the third piece of the run
 +//! loop over the shared two — [`Driver`] routes what the page's events mean,
 +//! [`Pacer::turn`] decides what a turn does — and what is left here is the
 +//! page's side of both:
 +//!
 +//! - **Events in.** Pointer, wheel, key and focus events on the canvas,
 +//!   mapped into driver calls in cce-ui's terms (`map_key`, `wheel_frame`).
 +//!   A page has no grabs, so a press on what would be a CSD border is the
 +//!   app's ([`PressSite::can_grab`] false).
 +//! - **Turns.** One turn per animation frame while anything moves (the
 +//!   pacer's ACTIVE cadence), a timer while idle; any event, and any message
 +//!   on the app's [`AppSender`](crate::engine::AppSender), wakes the loop
 +//!   for the next frame. Presenting happens inside an animation-frame
 +//!   callback, which is the browser's own frame pacing — there is no
 +//!   outstanding frame to wait on ([`Shell::frame_pending`] is false).
 +//! - **Frames out.** [`build_frame`] at the canvas's CSS size and the page's
 +//!   `devicePixelRatio`, drawn by the [`WebRenderer`].
 +//!
 +//! **The keyboard is a hidden `<textarea>`'s**, not the canvas's: a page can
 +//! compose input-method text (Japanese, Chinese, Korean, a dead key, an
 +//! emoji panel) only into an editable element. It takes the focus a press
 +//! on the canvas would have given the canvas (and the canvas, focused some
 +//! other way, hands it over), its key events are the app's as the canvas's
 +//! were, and its composition is the input method's: a key the input method
 +//! takes (`Process`, keyCode 229, or one sent mid-composition) is left to
 +//! it, `input` events while composing are the composition
 +//! (`Driver::preedit`), `compositionend` its commit (`Driver::commit_text`),
 +//! and text that arrives with no composition (an emoji panel, dictation) is
 +//! committed as it comes. After each frame it is moved to the editing
 +//! widget's caret (`ime::caret`), where the input method puts its
 +//! candidates. A widget that drops a composition (`ime::take_reset`) has it
 +//! cancelled by taking the focus off the textarea and back, inside the turn,
 +//! where the events that raises are not the app's.
 +//!
 +//! **The clipboard** comes through the page's clipboard events, since a page
 +//! may read the clipboard only inside a `paste` event: a ⌘/Ctrl+V is held
 +//! back from the app until its `paste` event has handed over the text (or,
 +//! if none comes, until the task after), so the widget that pastes on it
 +//! reads that text (`widget::clipboard`). A ⌘/Ctrl+C or X reaches the app at
 +//! once, and the `copy` / `cut` event it raises carries whatever the app
 +//! copied. The three keys' defaults are the only ones the canvas lets the
 +//! page have.
 +//!
 +//! The page owns the canvas's place in it; [`Sizing`] says who owns its
 +//! size. There is no context-menu popup surface here: the menu is drawn in
 +//! the canvas and kept inside it (`context_menu::constrain_to`), as on a
 +//! layer surface.
 +
 +use std::cell::{Cell, RefCell};
 +use std::rc::Rc;
 +use std::sync::mpsc::Receiver;
 +use std::time::Duration;
 +
 +use cursor_icon::CursorIcon;
 +use wasm_bindgen::prelude::*;
 +use wasm_bindgen::JsCast;
 +use web_sys::{
 +    AddEventListenerOptions, ClipboardEvent, CompositionEvent, FocusEvent, HtmlCanvasElement, HtmlTextAreaElement, InputEvent,
 +    KeyboardEvent, PointerEvent, WheelEvent,
 +};
 +
 +use super::renderer::{Capture, WebRenderer};
 +use crate::backend::app::{set_wake, AppSender, Application, LogicalPosition, LogicalSize};
 +use crate::backend::dom::{clipboard_key, map_key, utf16_range_to_bytes, wheel_frame, ClipKey};
 +use crate::backend::driver::{Driver, Modifiers, PressSite, ScrollFrame, ScrollSource, Turn};
 +use crate::backend::frame::build_frame;
 +use crate::backend::shell::{Pacer, Shell, Step, ACTIVE_DISPATCH};
- use crate::widget::{clipboard, context_menu, ElementState, Key, MouseButton, TextItem};
++use crate::widget::{clipboard, context_menu, ElementState, Key, MouseButton};
++use crate::backend::text::DlText;
 +
 +/// Who decides the canvas's size.
 +#[derive(Debug, Clone, Copy, PartialEq, Eq)]
 +pub enum Sizing {
 +    /// The app does, as it sizes a window: the canvas is given the app's
 +    /// `WindowSettings` size at start and its `desired_size` after (CSS px).
 +    App,
 +    /// The page does: the canvas is wherever its CSS box puts it, the app's
 +    /// size requests are not honoured (a tiling compositor's answer), and a
 +    /// change of the box is a resize.
 +    Page,
 +}
 +
 +/// The fonts an app is run with: a page has no font directory and no
 +/// fontconfig, so it says both what there is and what the generic families
 +/// are. A generic left `None` is the first family the set has of a list of
 +/// well-known ones (DejaVu, Noto, FreeFont, …).
 +#[derive(Debug, Clone, Default)]
 +pub struct Fonts {
 +    /// Font files' bytes (TrueType, OpenType, collections), in the order a
 +    /// directory scan would find them: where text falls back to the first
 +    /// face holding a glyph, order decides it.
 +    pub files: Vec<Vec<u8>>,
 +    pub serif: Option<String>,
 +    pub sans_serif: Option<String>,
 +    pub monospace: Option<String>,
 +}
 +
 +impl Fonts {
 +    pub fn new(files: Vec<Vec<u8>>) -> Self {
 +        Self { files, ..Default::default() }
 +    }
 +}
 +
 +/// Run `A` on `canvas`, its text set in `fonts`. Returns once the app is
 +/// up; the loop runs on the page's callbacks from then on, until the app
 +/// asks to exit.
 +///
 +/// One app per page: the toolkit's context menu is one per thread, and so is
 +/// [`set_wake`].
 +pub async fn run<A: Application>(canvas: HtmlCanvasElement, fonts: Fonts, sizing: Sizing) -> Result<(), JsValue> {
 +    // Every font database the toolkit builds from here on — this one, its
 +    // own for widget geometry, and the measuring one — loads the page's.
 +    crate::page_fonts::provide(fonts.files, fonts.serif, fonts.sans_serif, fonts.monospace);
 +    let fs = crate::create_font_system();
 +
 +    let mut renderer = WebRenderer::new(canvas.clone()).await?;
 +    // The scale is known before the app is built, as the Wayland shell sets
 +    // it before `create`: a widget may read it as it is made.
 +    let dpr = device_pixel_ratio();
 +    crate::scale::set_scale_factor(dpr as f32);
 +    let (tx, rx) = std::sync::mpsc::channel();
 +    let mut app = A::create(AppSender::from(tx));
 +    // The app's persistent GPU resources, as the Wayland shell's
 +    // `renderer_init` makes them: once, before the first frame.
 +    app.init_3d(&mut renderer);
 +    let settings = app.settings();
 +    crate::scale::set_app_id(settings.app_id.clone());
 +    if let Some(doc) = web_sys::window().and_then(|w| w.document()) {
 +        doc.set_title(&settings.title);
 +    }
 +
 +    let style = canvas.style();
 +    if sizing == Sizing::App {
 +        set_css_size(&canvas, settings.width, settings.height);
 +    }
 +    // The canvas can be focused (it hands the focus to the keyboard sink),
 +    // draws no focus outline of its own, and keeps touches for the app
 +    // rather than panning the page.
 +    canvas.set_tab_index(0);
 +    let _ = style.set_property("outline", "none");
 +    let _ = style.set_property("touch-action", "none");
 +    let sink = keyboard_sink()?;
 +
 +    let shell = WebShell {
 +        app,
 +        driver: Driver::new(),
 +        redraw: true,
 +        exit: false,
 +        rx,
 +        renderer,
 +        fs,
 +        swash: cosmic_text::SwashCache::new(),
 +        items: Vec::new(),
 +        damage_owed: true,
 +        canvas: canvas.clone(),
 +        sizing,
 +        logical: (0.0, 0.0),
 +        scale: dpr,
 +        just_configured: false,
 +        cursor: CursorIcon::Default,
 +        mac: is_mac(),
 +        sink: sink.clone(),
 +        sink_at: None,
 +    };
 +    let lp = Rc::new(Loop {
 +        shell: RefCell::new(shell),
 +        pacer: RefCell::new(Pacer::new(settings.title)),
 +        sched: RefCell::new(Sched::default()),
 +        frame_cb: RefCell::new(None),
 +        timer_cb: RefCell::new(None),
 +        finger_end_cb: RefCell::new(None),
 +        finger_timer: Cell::new(None),
 +        held_paste: RefCell::new(None),
 +        paste_cb: RefCell::new(None),
 +    });
 +    lp.shell.borrow_mut().measure();
 +    lp.shell.borrow_mut().just_configured = true;
 +    Loop::install(&lp, &canvas, &sink)?;
 +    lp.wake();
 +    Ok(())
 +}
 +
 +thread_local! {
 +    /// Callers of [`capture`] waiting on the next frame: each a promise's resolve.
 +    static CAPTURES: RefCell<Vec<js_sys::Function>> = const { RefCell::new(Vec::new()) };
 +}
 +
 +/// The next frame the shell draws, read back from the GPU. A fresh frame is
 +/// asked for, as an input event would ask, and this resolves once it is
 +/// drawn. For screenshot tests: a headless browser compositing in software
 +/// leaves a WebGPU canvas out of its page screenshots (and `toDataURL`).
 +pub async fn capture() -> Result<Capture, JsValue> {
 +    let promise = js_sys::Promise::new(&mut |resolve, _reject| CAPTURES.with(|c| c.borrow_mut().push(resolve)));
 +    crate::backend::app::wake();
 +    let got = wasm_bindgen_futures::JsFuture::from(promise).await?;
 +    let got: js_sys::Array = got.dyn_into().map_err(|_| JsValue::from_str("cce-ui: the frame was not captured"))?;
 +    Ok(Capture {
 +        width: got.get(0).as_f64().unwrap_or(0.0) as u32,
 +        height: got.get(1).as_f64().unwrap_or(0.0) as u32,
 +        rgba: js_sys::Uint8Array::new(&got.get(2)).to_vec(),
 +    })
 +}
 +
 +/// The page's side of the run loop, as the Wayland shell's `EngineState` is
 +/// the compositor's.
 +struct WebShell<A: Application> {
 +    app: A,
 +    driver: Driver,
 +    redraw: bool,
 +    exit: bool,
 +    rx: Receiver<A::Message>,
 +    renderer: WebRenderer,
 +    fs: cosmic_text::FontSystem,
 +    swash: cosmic_text::SwashCache,
 +    /// The frame's display-list text, shaped by [`build_frame`].
-     items: Vec<TextItem>,
++    items: Vec<DlText>,
 +    damage_owed: bool,
 +    canvas: HtmlCanvasElement,
 +    sizing: Sizing,
 +    /// The canvas's CSS size, and `devicePixelRatio`, as last measured.
 +    logical: (f32, f32),
 +    scale: f64,
 +    just_configured: bool,
 +    cursor: CursorIcon,
 +    /// Command is the shortcut key here: ⌘Z is undo, as every Mac app has it.
 +    mac: bool,
 +    /// The hidden textarea that holds the keyboard (see the module doc), and
 +    /// where it was last put (page px: left, top, height).
 +    sink: HtmlTextAreaElement,
 +    sink_at: Option<(f64, f64, f64)>,
 +}
 +
 +impl<A: Application> WebShell<A> {
 +    /// Read the canvas's box and the pixel ratio, and size the drawing
 +    /// buffer to them. Whether either changed.
 +    fn measure(&mut self) -> bool {
 +        let dpr = device_pixel_ratio();
 +        let w = self.canvas.client_width().max(0) as f32;
 +        let h = self.canvas.client_height().max(0) as f32;
 +        if (w, h) == self.logical && dpr == self.scale {
 +            return false;
 +        }
 +        self.logical = (w, h);
 +        if dpr != self.scale {
 +            self.scale = dpr;
 +            crate::scale::set_scale_factor(dpr as f32);
 +        }
 +        let (pw, ph) = self.physical();
 +        if self.renderer.size() != (pw, ph) {
 +            self.renderer.resize(pw, ph);
 +        }
 +        self.redraw = true;
 +        true
 +    }
 +
 +    fn physical(&self) -> (u32, u32) {
 +        let s = self.scale as f32;
 +        ((self.logical.0 * s).round().max(1.0) as u32, (self.logical.1 * s).round().max(1.0) as u32)
 +    }
 +
 +    fn size(&self) -> LogicalSize {
 +        LogicalSize::new(self.logical.0, self.logical.1)
 +    }
 +
 +    /// Hand the messages posted since the last turn to `update`, as the
 +    /// Wayland loop's channel source does between dispatches.
 +    fn drain_messages(&mut self) {
 +        while let Ok(msg) = self.rx.try_recv() {
 +            let mut rebuild = false;
 +            self.app.update(msg, &mut rebuild, &mut self.exit);
 +            self.redraw |= rebuild;
 +        }
 +    }
 +
 +    fn mods_from(&self, ctrl: bool, shift: bool, alt: bool, meta: bool) -> Modifiers {
 +        if self.mac {
 +            Modifiers { ctrl: ctrl || meta, shift, alt, logo: false }
 +        } else {
 +            Modifiers { ctrl, shift, alt, logo: meta }
 +        }
 +    }
 +
 +    fn sync_mods(&mut self, mods: Modifiers) {
 +        if mods != self.driver.mods {
 +            self.driver.set_modifiers(&mut self.app, mods);
 +        }
 +    }
 +
 +    fn pointer_pos(&mut self, e: &PointerEvent) -> LogicalPosition {
 +        let (x, y) = (e.offset_x() as f32, e.offset_y() as f32);
 +        self.driver.cursor_pos = (x, y);
 +        LogicalPosition::new(x, y)
 +    }
 +
 +    /// Put the keyboard sink at the editing widget's caret (the canvas's
 +    /// corner when nothing is editing), so the input method's candidates
 +    /// open there; and cancel a composition a widget dropped. Inside the
 +    /// turn: the shell is borrowed, so the blur and focus this raises (and
 +    /// the composition's end) reach no handler.
 +    fn place_sink(&mut self) {
 +        let r = self.canvas.get_bounding_client_rect();
 +        let (left, top, h) = match crate::ime::caret() {
 +            Some([x, y, _, h]) => (r.left() + x as f64, r.top() + y as f64, (h as f64).max(8.0)),
 +            None => (r.left(), r.top(), 16.0),
 +        };
 +        if self.sink_at != Some((left, top, h)) {
 +            self.sink_at = Some((left, top, h));
 +            let st = self.sink.style();
 +            let _ = st.set_property("left", &format!("{left}px"));
 +            let _ = st.set_property("top", &format!("{top}px"));
 +            let _ = st.set_property("height", &format!("{h}px"));
 +            let _ = st.set_property("font-size", &format!("{}px", (h * 0.8).round()));
 +            let _ = st.set_property("line-height", &format!("{h}px"));
 +        }
 +        if crate::ime::take_reset() {
 +            let focused = web_sys::window()
 +                .and_then(|w| w.document())
 +                .and_then(|d| d.active_element())
 +                .is_some_and(|a| a == *self.sink.unchecked_ref::<web_sys::Element>());
 +            self.sink.set_value("");
 +            if focused {
 +                let _ = self.sink.blur();
 +                let _ = self.sink.focus();
 +            }
 +        }
 +    }
 +
 +    fn update_cursor(&mut self) {
 +        let (x, y) = self.driver.cursor_pos;
 +        let icon = self.driver.cursor_icon_at(&self.app, x, y, self.size());
 +        if icon != self.cursor {
 +            self.cursor = icon;
 +            let _ = self.canvas.style().set_property("cursor", icon.name());
 +        }
 +    }
 +}
 +
 +impl<A: Application> Shell for WebShell<A> {
 +    type App = A;
 +
 +    fn turn(&mut self) -> (&mut Driver, Turn<'_, A>) {
 +        (&mut self.driver, Turn { app: &mut self.app, redraw: &mut self.redraw, exit: &mut self.exit })
 +    }
 +
 +    fn app(&self) -> &A {
 +        &self.app
 +    }
 +
 +    fn redraw(&mut self) -> &mut bool {
 +        &mut self.redraw
 +    }
 +
 +    fn exit_requested(&self) -> bool {
 +        self.exit
 +    }
 +
 +    fn take_just_configured(&mut self) -> bool {
 +        std::mem::replace(&mut self.just_configured, false)
 +    }
 +
 +    fn request_size(&mut self, w: u32, h: u32) {
 +        if self.sizing == Sizing::App && (w as f32, h as f32) != self.logical {
 +            set_css_size(&self.canvas, w, h);
 +            self.measure();
 +        }
 +    }
 +
 +    fn sync(&mut self) {
 +        // The page laid the canvas out anew, or moved it to another
 +        // display: a configure, which wins the next turn over the app's size.
 +        if self.measure() {
 +            self.just_configured = true;
 +        }
 +        if CAPTURES.with(|c| !c.borrow().is_empty()) {
 +            self.redraw = true;
 +        }
 +        // No popup surface to host the menu: it is drawn in the canvas and
 +        // kept inside it, frames asked for while a page turn animates.
 +        if context_menu::is_visible() {
 +            if context_menu::is_turning() {
 +                self.redraw = true;
 +            }
 +            context_menu::set_hosted(false);
 +            context_menu::constrain_to(0.0, 0.0, self.logical.0, self.logical.1);
 +        }
 +    }
 +
 +    fn set_title(&mut self, title: &str) {
 +        if let Some(doc) = web_sys::window().and_then(|w| w.document()) {
 +            doc.set_title(title);
 +        }
 +    }
 +
 +    fn frame_pending(&mut self) -> bool {
 +        false
 +    }
 +
 +    fn configured(&self) -> bool {
 +        self.logical.0 > 0.0 && self.logical.1 > 0.0
 +    }
 +
 +    fn present(&mut self, _fresh: bool) {
 +        let size = self.size();
 +        let frame = build_frame(&mut self.app, &mut self.fs, size, self.scale, &mut self.damage_owed, &mut self.items);
 +        if frame.dl_text {
 +            let spans = frame.text_spans(&self.items);
 +            self.renderer.prepare_text(&mut self.fs, &mut self.swash, &spans);
 +        }
 +        // The app's 3D scene, staged last, as the Wayland shell's
 +        // `stage_renderer`; true asks for another frame.
 +        if self.app.stage_3d(&mut self.renderer, size, self.scale) {
 +            self.redraw = true;
 +        }
 +        let waiting = CAPTURES.with(|c| std::mem::take(&mut *c.borrow_mut()));
 +        if !waiting.is_empty() {
 +            self.renderer.capture_next_frame();
 +        }
 +        match self.renderer.draw_frame_2d(frame.frame2d()) {
 +            Ok(()) => self.damage_owed = false,
 +            Err(e) => {
 +                web_sys::console::error_2(&"cce-ui: frame not drawn:".into(), &e);
 +                self.redraw = true;
 +            }
 +        }
 +        self.place_sink();
 +        if waiting.is_empty() {
 +            return;
 +        }
 +        let pending = self.renderer.take_pending_capture();
 +        wasm_bindgen_futures::spawn_local(async move {
 +            let got = match pending {
 +                Some(p) => p.read().await.ok(),
 +                None => None,
 +            };
 +            let value: JsValue = match got {
 +                Some(c) => js_sys::Array::of3(&c.width.into(), &c.height.into(), &js_sys::Uint8Array::from(&c.rgba[..])).into(),
 +                None => JsValue::NULL,
 +            };
 +            for resolve in waiting {
 +                let _ = resolve.call1(&JsValue::NULL, &value);
 +            }
 +        });
 +    }
 +}
 +
 +/// When the next turn is due.
 +#[derive(Default)]
 +struct Sched {
 +    /// An animation frame is requested: the next turn is in it.
 +    frame: Option<i32>,
 +    /// An idle timer is set; it requests the frame when it fires.
 +    timer: Option<i32>,
 +    /// The app exited: nothing turns again.
 +    stopped: bool,
 +}
 +
 +/// The shell, the pacer and the schedule, shared by the page's callbacks.
 +/// The schedule is its own cell, borrowed only briefly and never across a
 +/// call into the app, so a message the app sends mid-turn can wake the loop.
 +struct Loop<A: Application> {
 +    shell: RefCell<WebShell<A>>,
 +    pacer: RefCell<Pacer>,
 +    sched: RefCell<Sched>,
 +    frame_cb: RefCell<Option<Closure<dyn FnMut(f64)>>>,
 +    timer_cb: RefCell<Option<Closure<dyn FnMut()>>>,
 +    finger_end_cb: RefCell<Option<Closure<dyn FnMut()>>>,
 +    /// The lift timer the last finger frame set; the next frame cancels it.
 +    finger_timer: Cell<Option<i32>>,
 +    /// A ⌘/Ctrl+V held back until its `paste` event, and the timer that
 +    /// lets it through if no event comes.
 +    held_paste: RefCell<Option<HeldKey>>,
 +    paste_cb: RefCell<Option<Closure<dyn FnMut()>>>,
 +}
 +
 +/// A key press, kept to dispatch later: the key, its text and the
 +/// modifiers it came with (ctrl, shift, alt, meta).
 +struct HeldKey {
 +    key: Key,
 +    text: Option<String>,
 +    mods: (bool, bool, bool, bool),
 +}
 +
 +/// A browser reports no lift for a two-finger scroll: this long without a
 +/// finger frame is one, and ends the gesture (`ScrollPhase::FingerEnd`) so a
 +/// flick coasts and a side swipe readies its next turn.
 +const FINGER_LIFT: Duration = Duration::from_millis(120);
 +
 +impl<A: Application> Loop<A> {
 +    /// Turn the loop at the next animation frame.
 +    fn wake(&self) {
 +        let mut s = self.sched.borrow_mut();
 +        if s.stopped || s.frame.is_some() {
 +            return;
 +        }
 +        let win = window();
 +        if let Some(t) = s.timer.take() {
 +            win.clear_timeout_with_handle(t);
 +        }
 +        let cb = self.frame_cb.borrow();
 +        if let Some(cb) = cb.as_ref() {
 +            s.frame = win.request_animation_frame(cb.as_ref().unchecked_ref()).ok();
 +        }
 +    }
 +
 +    /// One turn, in an animation frame.
 +    fn on_frame(&self) {
 +        self.sched.borrow_mut().frame = None;
 +        let step = {
 +            let mut shell = self.shell.borrow_mut();
 +            shell.drain_messages();
 +            self.pacer.borrow_mut().turn(&mut *shell)
 +        };
 +        match step {
 +            Step::Exit => {
 +                self.sched.borrow_mut().stopped = true;
 +                set_wake(None);
 +                self.shell.borrow_mut().app.on_exit();
 +            }
 +            Step::Sleep(d) if d <= ACTIVE_DISPATCH => self.wake(),
 +            Step::Sleep(d) => {
 +                let mut s = self.sched.borrow_mut();
 +                let cb = self.timer_cb.borrow();
 +                // A frame already requested (the app posted itself a message
 +                // mid-turn) turns sooner than any timer would.
 +                if let (None, None, Some(cb)) = (s.frame, s.timer, cb.as_ref()) {
 +                    s.timer = window()
 +                        .set_timeout_with_callback_and_timeout_and_arguments_0(cb.as_ref().unchecked_ref(), d.as_millis() as i32)
 +                        .ok();
 +                }
 +            }
 +        }
 +    }
 +
 +    /// Dispatch an event to the shell, then turn soon. Input after the exit
 +    /// is dropped.
 +    fn event(&self, f: impl FnOnce(&mut WebShell<A>)) {
 +        if self.sched.borrow().stopped {
 +            return;
 +        }
 +        // A page event fired from inside a turn (a focus change the app's
 +        // own DOM call caused) finds the shell borrowed: it is dropped
 +        // rather than panicking the page.
 +        let Ok(mut shell) = self.shell.try_borrow_mut() else { return };
 +        f(&mut shell);
 +        drop(shell);
 +        self.wake();
 +    }
 +
 +    /// The callbacks: the loop's own two, the wake hook, and the canvas's events.
 +    fn install(lp: &Rc<Self>, canvas: &HtmlCanvasElement, sink: &HtmlTextAreaElement) -> Result<(), JsValue> {
 +        let l = lp.clone();
 +        *lp.frame_cb.borrow_mut() = Some(Closure::new(move |_t: f64| l.on_frame()));
 +        let l = lp.clone();
 +        *lp.timer_cb.borrow_mut() = Some(Closure::new(move || {
 +            l.sched.borrow_mut().timer = None;
 +            l.wake();
 +        }));
 +        let l = lp.clone();
 +        *lp.finger_end_cb.borrow_mut() = Some(Closure::new(move || l.finger_lift()));
 +        let l = lp.clone();
 +        *lp.paste_cb.borrow_mut() = Some(Closure::new(move || l.release_paste()));
 +        let l = Rc::downgrade(lp);
 +        set_wake(Some(Box::new(move || {
 +            if let Some(l) = l.upgrade() {
 +                l.wake();
 +            }
 +        })));
 +
 +        let target: &web_sys::EventTarget = canvas.as_ref();
 +        let l = lp.clone();
 +        listen(target, "pointermove", false, move |e: PointerEvent| {
 +            l.event(|s| {
 +                let pos = s.pointer_pos(&e);
 +                let (driver, t) = s.turn();
 +                driver.pointer_motion(t, pos);
 +                s.update_cursor();
 +            })
 +        })?;
 +        let l = lp.clone();
 +        listen(target, "pointerenter", false, move |e: PointerEvent| {
 +            l.event(|s| {
 +                let pos = s.pointer_pos(&e);
 +                let (driver, t) = s.turn();
 +                driver.pointer_enter(t, pos);
 +                s.update_cursor();
 +            })
 +        })?;
 +        let l = lp.clone();
 +        listen(target, "pointerleave", false, move |_e: PointerEvent| {
 +            l.event(|s| {
 +                let (driver, t) = s.turn();
 +                driver.pointer_leave(t);
 +            })
 +        })?;
 +        let l = lp.clone();
 +        let c = canvas.clone();
 +        let k = sink.clone();
 +        listen(target, "pointerdown", true, move |e: PointerEvent| {
 +            let Some(btn) = dom_button(e.button()) else { return };
 +            // Take the keyboard first: the focus event this fires is
 +            // dispatched now, before the shell is borrowed below. Capture
 +            // keeps a drag's moves and its release coming to the canvas when
 +            // the pointer leaves it, as a Wayland implicit grab does.
 +            let _ = k.focus();
 +            let _ = c.set_pointer_capture(e.pointer_id());
 +            e.prevent_default();
 +            l.event(|s| {
 +                let mods = s.mods_from(e.ctrl_key(), e.shift_key(), e.alt_key(), e.meta_key());
 +                s.sync_mods(mods);
 +                let pos = s.pointer_pos(&e);
 +                let site = PressSite { size: s.size(), on_popup: false, can_grab: false, own_edges: false };
 +                let (driver, t) = s.turn();
 +                driver.pointer_press(t, btn, pos, site);
 +            })
 +        })?;
 +        let l = lp.clone();
 +        listen(target, "pointerup", false, move |e: PointerEvent| {
 +            let Some(btn) = dom_button(e.button()) else { return };
 +            l.event(|s| {
 +                let pos = s.pointer_pos(&e);
 +                let (driver, t) = s.turn();
 +                driver.pointer_release(t, btn, pos);
 +                s.update_cursor();
 +            })
 +        })?;
 +        // The right button is the app's, not the page's menu.
 +        listen(target, "contextmenu", true, |e: web_sys::Event| e.prevent_default())?;
 +        let l = lp.clone();
 +        listen(target, "wheel", true, move |e: WheelEvent| {
 +            e.prevent_default();
 +            let frame = wheel_frame(e.delta_mode(), e.delta_x(), e.delta_y());
 +            l.event(|s| {
 +                let mods = s.mods_from(e.ctrl_key(), e.shift_key(), e.alt_key(), e.meta_key());
 +                s.sync_mods(mods);
 +                let pos = LogicalPosition::new(e.offset_x() as f32, e.offset_y() as f32);
 +                s.driver.cursor_pos = (pos.x, pos.y);
 +                let (driver, t) = s.turn();
 +                driver.scroll(t, frame, pos);
 +            });
 +            if frame.source == Some(ScrollSource::Finger) {
 +                l.arm_finger_lift();
 +            }
 +        })?;
 +        // The canvas focused some other way (Tab, the page's script) hands
 +        // the keyboard to the sink.
 +        let k = sink.clone();
 +        listen(target, "focus", false, move |_e: FocusEvent| {
 +            let _ = k.focus();
 +        })?;
 +        let keys: &web_sys::EventTarget = sink.as_ref();
 +        let l = lp.clone();
 +        listen(keys, "keydown", true, move |e: KeyboardEvent| l.key(&e, ElementState::Pressed))?;
 +        let l = lp.clone();
 +        listen(keys, "keyup", true, move |e: KeyboardEvent| l.key(&e, ElementState::Released))?;
 +        for (name, focused) in [("focus", true), ("blur", false)] {
 +            let l = lp.clone();
 +            let c = canvas.clone();
 +            listen(keys, name, false, move |e: FocusEvent| {
 +                // Over to the canvas is on its way back.
 +                let to_canvas = e.related_target().is_some_and(|r| AsRef::<JsValue>::as_ref(&r) == AsRef::<JsValue>::as_ref(&c));
 +                if !focused && to_canvas {
 +                    return;
 +                }
 +                l.event(|s| {
 +                    let (driver, t) = s.turn();
 +                    driver.keyboard_focus(t, focused);
 +                })
 +            })?;
 +        }
 +        // The input method's half.
 +        let l = lp.clone();
 +        let k = sink.clone();
 +        listen(keys, "input", false, move |e: InputEvent| {
 +            if e.is_composing() {
 +                let text = k.value();
 +                let cursor = utf16_range_to_bytes(&text, k.selection_start().ok().flatten(), k.selection_end().ok().flatten());
 +                l.event(|s| {
 +                    let (driver, t) = s.turn();
 +                    driver.preedit(t, Some(crate::ime::Preedit { text, cursor }));
 +                });
 +            } else if e.input_type() == "insertFromPaste" {
 +                // A paste that reached the sink by the browser's default (a
 +                // handler of the page's swallowed its `paste` event): the
 +                // clipboard's text all the same, for the held ⌘/Ctrl+V.
 +                clipboard::pasted(k.value());
 +                k.set_value("");
 +                l.release_paste();
 +            } else {
 +                // Text with no composition: an emoji panel, dictation.
 +                let text = k.value();
 +                k.set_value("");
 +                if !text.is_empty() {
 +                    l.event(|s| {
 +                        let (driver, t) = s.turn();
 +                        driver.commit_text(t, text);
 +                    });
 +                }
 +            }
 +        })?;
 +        let l = lp.clone();
 +        let k = sink.clone();
 +        listen(keys, "compositionend", false, move |e: CompositionEvent| {
 +            let text = e.data().unwrap_or_default();
 +            k.set_value("");
 +            l.event(|s| {
 +                let (driver, t) = s.turn();
 +                driver.preedit(t, None);
 +                let (driver, t) = s.turn();
 +                driver.commit_text(t, text);
 +            });
 +        })?;
 +        // The clipboard's events, raised by the three keys `key` lets
 +        // through. They go to the focused element or the body, so they are
 +        // heard on the document.
 +        if let Some(doc) = web_sys::window().and_then(|w| w.document()) {
 +            let doc: &web_sys::EventTarget = doc.as_ref();
 +            let l = lp.clone();
 +            listen(doc, "paste", true, move |e: ClipboardEvent| {
 +                if let Some(text) = e.clipboard_data().and_then(|d| d.get_data("text/plain").ok()) {
 +                    clipboard::pasted(text);
 +                }
 +                e.prevent_default();
 +                l.release_paste();
 +            })?;
 +            for name in ["copy", "cut"] {
 +                listen(doc, name, true, move |e: ClipboardEvent| {
 +                    if let (Some(text), Some(data)) = (clipboard::take_copied(), e.clipboard_data()) {
 +                        if data.set_data("text/plain", &text).is_ok() {
 +                            e.prevent_default();
 +                        }
 +                    }
 +                })?;
 +            }
 +        }
 +        // The page laying the canvas out anew is a configure; the turn it
 +        // wakes measures the box (`sync`).
 +        let l = lp.clone();
 +        let observer = Closure::<dyn FnMut()>::new(move || l.wake());
 +        let ro = web_sys::ResizeObserver::new(observer.as_ref().unchecked_ref())?;
 +        ro.observe(canvas);
 +        observer.forget();
 +        std::mem::forget(ro);
 +        Ok(())
 +    }
 +
 +    fn key(&self, e: &KeyboardEvent, state: ElementState) {
 +        // The input method's key: it composes with it (and a browser that
 +        // sends the key confirming a composition after its end still marks
 +        // it 229).
 +        if e.is_composing() || e.key_code() == 229 {
 +            return;
 +        }
 +        let accel = e.ctrl_key() || e.meta_key();
 +        let Some((key, text)) = map_key(&e.key(), accel) else { return };
 +        let clip = clipboard_key(&e.key(), accel, e.alt_key());
 +        // A clipboard key's default is its clipboard event: the page keeps it.
 +        if clip.is_none() && !passes_to_page(e) {
 +            e.prevent_default();
 +        }
 +        // The driver repeats a held key itself, at the toolkit's own rate
 +        // (`KEY_REPEAT_DELAY` / `_INTERVAL`), as it does on Wayland, where
 +        // the compositor sends one press: the browser's repeats are dropped.
 +        if e.repeat() {
 +            return;
 +        }
 +        let mods = (e.ctrl_key(), e.shift_key(), e.alt_key(), e.meta_key());
 +        if state == ElementState::Pressed {
 +            match clip {
 +                // Held until the `paste` event has handed over the text: it
 +                // is raised after this listener returns, in the same task,
 +                // so a zero timer is the fallback for a browser that raises
 +                // none (the clipboard then reads what it read before).
 +                Some(ClipKey::Paste) => {
 +                    self.release_paste();
 +                    *self.held_paste.borrow_mut() = Some(HeldKey { key, text, mods });
 +                    if let Some(cb) = self.paste_cb.borrow().as_ref() {
 +                        let _ = window().set_timeout_with_callback_and_timeout_and_arguments_0(cb.as_ref().unchecked_ref(), 0);
 +                    }
 +                    return;
 +                }
 +                // A copy left over from a menu click is not this key's.
 +                Some(ClipKey::Copy | ClipKey::Cut) => {
 +                    clipboard::take_copied();
 +                }
 +                None => {}
 +            }
 +        } else {
 +            // A release never overtakes the press it ends.
 +            self.release_paste();
 +        }
 +        self.dispatch_key(HeldKey { key, text, mods }, state);
 +    }
 +
 +    fn dispatch_key(&self, k: HeldKey, state: ElementState) {
 +        let (ctrl, shift, alt, meta) = k.mods;
 +        self.event(|s| {
 +            let mods = s.mods_from(ctrl, shift, alt, meta);
 +            s.sync_mods(mods);
 +            let (driver, t) = s.turn();
 +            driver.key(t, k.key, k.text, state);
 +        });
 +    }
 +
 +    /// Hand a held ⌘/Ctrl+V to the app, if one is held: its `paste` event
 +    /// has come, or will not.
 +    fn release_paste(&self) {
 +        let held = self.held_paste.borrow_mut().take();
 +        if let Some(k) = held {
 +            self.dispatch_key(k, ElementState::Pressed);
 +        }
 +    }
 +
 +    fn arm_finger_lift(&self) {
 +        let win = window();
 +        if let Some(t) = self.finger_timer.take() {
 +            win.clear_timeout_with_handle(t);
 +        }
 +        if let Some(cb) = self.finger_end_cb.borrow().as_ref() {
 +            self.finger_timer.set(
 +                win.set_timeout_with_callback_and_timeout_and_arguments_0(
 +                    cb.as_ref().unchecked_ref(),
 +                    FINGER_LIFT.as_millis() as i32,
 +                )
 +                .ok(),
 +            );
 +        }
 +    }
 +
 +    /// No finger frame for [`FINGER_LIFT`]: the gesture ended.
 +    fn finger_lift(&self) {
 +        self.finger_timer.set(None);
 +        let frame = ScrollFrame { source: Some(ScrollSource::Finger), stop: true, ..Default::default() };
 +        self.event(|s| {
 +            let (x, y) = s.driver.cursor_pos;
 +            let (driver, t) = s.turn();
 +            driver.scroll(t, frame, LogicalPosition::new(x, y));
 +        });
 +    }
 +}
 +
 +/// The hidden textarea the keyboard goes to (see the module doc): fixed in
 +/// the page, invisible, never hit, and no help offered on what is typed.
 +fn keyboard_sink() -> Result<HtmlTextAreaElement, JsValue> {
 +    let doc = window().document().ok_or_else(|| JsValue::from_str("cce-ui: no document"))?;
 +    let sink: HtmlTextAreaElement = doc.create_element("textarea")?.dyn_into()?;
 +    for (k, v) in [("autocomplete", "off"), ("autocorrect", "off"), ("autocapitalize", "off"), ("spellcheck", "false"), ("aria-hidden", "true")] {
 +        sink.set_attribute(k, v)?;
 +    }
 +    let st = sink.style();
 +    for (k, v) in [
 +        ("position", "fixed"),
 +        ("left", "0px"),
 +        ("top", "0px"),
 +        ("width", "1px"),
 +        ("height", "16px"),
 +        ("padding", "0"),
 +        ("border", "0"),
 +        ("margin", "0"),
 +        ("outline", "none"),
 +        ("resize", "none"),
 +        ("overflow", "hidden"),
 +        ("white-space", "pre"),
 +        ("opacity", "0"),
 +        ("pointer-events", "none"),
 +        ("caret-color", "transparent"),
 +        ("color", "transparent"),
 +        ("background", "transparent"),
 +    ] {
 +        st.set_property(k, v)?;
 +    }
 +    doc.body().ok_or_else(|| JsValue::from_str("cce-ui: no body"))?.append_child(&sink)?;
 +    Ok(sink)
 +}
 +
 +fn window() -> web_sys::Window {
 +    web_sys::window().expect("cce-ui's browser shell runs in a window")
 +}
 +
 +fn device_pixel_ratio() -> f64 {
 +    web_sys::window().map_or(1.0, |w| w.device_pixel_ratio())
 +}
 +
 +fn is_mac() -> bool {
 +    web_sys::window()
 +        .and_then(|w| w.navigator().platform().ok())
 +        .is_some_and(|p| p.starts_with("Mac") || p.starts_with("iP"))
 +}
 +
 +fn set_css_size(canvas: &HtmlCanvasElement, w: u32, h: u32) {
 +    let style = canvas.style();
 +    let _ = style.set_property("width", &format!("{w}px"));
 +    let _ = style.set_property("height", &format!("{h}px"));
 +}
 +
 +/// Add `f` as the target's `name` listener for the page's lifetime. A
 +/// `cancelable` one is added `passive: false`, so it may cancel the event's
 +/// default (the page's scroll, menu, focus move or shortcut).
 +fn listen<E: JsCast + 'static>(
 +    target: &web_sys::EventTarget,
 +    name: &str,
 +    cancelable: bool,
 +    mut f: impl FnMut(E) + 'static,
 +) -> Result<(), JsValue> {
 +    let cb = Closure::<dyn FnMut(web_sys::Event)>::new(move |e: web_sys::Event| f(e.unchecked_into()));
 +    let opts = AddEventListenerOptions::new();
 +    opts.set_passive(!cancelable);
 +    target.add_event_listener_with_callback_and_add_event_listener_options(name, cb.as_ref().unchecked_ref(), &opts)?;
 +    cb.forget();
 +    Ok(())
 +}
 +
 +/// A DOM `button` as the driver's. The back and forward buttons are not
 +/// buttons the toolkit has.
 +fn dom_button(b: i16) -> Option<MouseButton> {
 +    match b {
 +        0 => Some(MouseButton::Left),
 +        1 => Some(MouseButton::Middle),
 +        2 => Some(MouseButton::Right),
 +        _ => None,
 +    }
 +}
 +
 +/// Keys the page keeps however the app routes keys: reload and the
 +/// developer tools.
 +fn passes_to_page(e: &KeyboardEvent) -> bool {
 +    let k = e.key();
 +    let accel = e.ctrl_key() || e.meta_key();
 +    k == "F5" || k == "F12" || (accel && (k == "r" || k == "R")) || (accel && e.shift_key() && (k == "I" || k == "J"))
 +}
diff --cc src/widget/shaping.rs
index c4062c4,8658b10..4210e68
--- a/src/widget/shaping.rs
+++ b/src/widget/shaping.rs
@@@ -36,7 -36,7 +36,7 @@@ impl Measure for ShapingMeasure 
          if let Some(w) = self.cache.get(&key) {
              return *w;
          }
-         let buf = crate::backend::text::get_text_buffer_attrs(&mut self.fs, text, size, Some(font), attrs);
 -        let buf = crate::backend::window_runner::shared_text_buffer(&mut self.fs, text, size, Some(font), attrs);
++        let buf = crate::backend::text::shared_text_buffer(&mut self.fs, text, size, Some(font), attrs);
          // The glyphs' extent, trailing spaces included — the same measure
          // `offsets` ends on. (A layout run's `line_w` leaves trailing
          // whitespace out, so a width taken from it disagreed with where
@@@ -58,8 -58,8 +58,8 @@@ impl ShapingMeasure 
      /// combining mark) takes the cluster's start.
      pub fn offsets(&mut self, text: &str, size: f32, font: &str, attrs: TextAttrs) -> Vec<(usize, f32)> {
          let scale = crate::scale::scale_factor().max(0.01);
-         let buf = crate::backend::text::get_text_buffer_attrs(&mut self.fs, text, size, Some(font), attrs);
 -        let buf = crate::backend::window_runner::shared_text_buffer(&mut self.fs, text, size, Some(font), attrs);
 -        let glyphs = crate::backend::window_runner::normalized_glyph_starts(&buf, text);
++        let buf = crate::backend::text::shared_text_buffer(&mut self.fs, text, size, Some(font), attrs);
 +        let glyphs = crate::backend::text::normalized_glyph_starts(&buf, text);
          let mut starts: Vec<(usize, f32)> = Vec::with_capacity(glyphs.len() + 1);
          let mut width = 0.0f32;
          for (start, x, w) in glyphs {