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

commite1e80042ab331fc0c26e1b9a3d0c3c71a45b0439
parentb18d43b9d2
authorClaude <noreply@anthropic.com>
date2026-10-05 05:38
W3: run an Application in a browser page

src/web/shell.rs is the browser shell, the Wayland shell's counterpart
over a <canvas>, on the same Driver and Pacer. Pointer (captured on
press), wheel, key and focus events are mapped into driver calls by
backend::dom (portable and tested natively): a key carries the text
xkb's utf8 would — Tab "\t", Ctrl+letter its control code — since that
is what widgets have always been handed; a whole notch-sized pixel
delta is a wheel notch, anything else a finger, whose lift is
synthesized after 120 ms. The browser's key repeats are dropped (the
driver repeats). Turns run on animation frames at the pacer's active
cadence and on a timer at its idle one; any event, and any
AppSender::send (backend::app::set_wake), wakes the loop. Sizing::App
sizes the canvas from the app, Sizing::Page leaves it to the page.

A page has no font directory and no fontconfig, so web::Fonts carries
the files and the generic families, and on wasm every font database
the toolkit builds loads them (lib::page_fonts): the shell's, the
widget-geometry one and resvg's measuring one. cosmic-text has no
family fallback list on wasm, so the families the toolkit names are
given the faces Linux's list would fall back to.

web::capture() reads the next frame back from the GPU: headless
Chromium leaves a WebGPU canvas out of its screenshots.
examples/demo_web.rs runs src/main.rs's DemoApp in a page, and
scripts/web-probe/demo replays the native harness's 24-step input
script against it. Against the native run (lavapipe vs SwiftShader,
same fonts and generic families): steps 00-18 differ only at the
slider band's two pointed tips, a rasterizer tie-break; idle is 5
turns in 5 s, as native. Held-key steps get fewer repeats, as
SwiftShader takes ~250 ms a frame and the driver repeats once a turn.

Native is unchanged: the same two known test failures (527 pass with
three new), the plate golden byte-identical, the 24-step run identical
to the pixel.

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

 CLAUDE.md                     |  69 ++++-
 Cargo.toml                    |  17 ++
 examples/demo_web.rs          |  41 +++
 scripts/check-wasm            |   5 +-
 scripts/web-probe/browser.mjs |  43 +++
 scripts/web-probe/capture.mjs |  47 +--
 scripts/web-probe/compare.py  |  26 +-
 scripts/web-probe/demo        |  32 ++
 scripts/web-probe/demo.html   |  24 ++
 scripts/web-probe/drive.mjs   |  64 ++++
 src/backend/app.rs            |  32 +-
 src/backend/dom.rs            | 152 ++++++++++
 src/backend/mod.rs            |   1 +
 src/lib.rs                    | 187 ++++++++++--
 src/main.rs                   |   8 +-
 src/web/mod.rs                |   9 +-
 src/web/renderer.rs           |  57 +++-
 src/web/shell.rs              | 675 ++++++++++++++++++++++++++++++++++++++++++
 src/widget/input/text_box.rs  |  10 +-
 19 files changed, 1392 insertions(+), 107 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 9547604..111663f 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -76,8 +76,58 @@ and image queue. web-sys still ships its WebGPU bindings behind `--cfg=web_sys_u
 `.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. There is no browser shell yet (W3): nothing drives `Pacer::turn` or feeds
-the page's events in.
+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`.
+
+Not there yet: the clipboard, IME composition, 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).
+
+**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
@@ -93,7 +143,7 @@ DejaVu files (`$PROBE_FONTS_DIR`) — and the screenshot must not carry a cursor
 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 (`capture.mjs`): headless, with GPU
+--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").
 
@@ -1133,7 +1183,8 @@ cce-system-interface) to confirm behavior, not just the test suite.
   key repeat, the undo/redo and plate-navigation chords, the CSD hit zones, the
   outside-press popover close, held-button release on a lost pointer, the scroll phase,
   the pinch fallback — fed in cce-ui's own terms and unit-tested with no compositor),
-  `frame.rs` (`build_frame`: the app's display list, damage, custom vertices and overlays,
+  `dom.rs` (the DOM's key and wheel vocabulary as the driver's: `map_key`, `wheel_frame` —
+  portable, so tested natively), `frame.rs` (`build_frame`: the app's display list, damage, custom vertices and overlays,
   widget shaping, text and the popover-occlusion rects, tessellated into a `BuiltFrame` the
   renderer draws — no window system in it, tested with no GPU), `shell.rs` (the `Shell`
   trait — a window system's side of the run loop: exit, size requests, per-turn sync,
@@ -1168,10 +1219,12 @@ cce-system-interface) to confirm behavior, not just the test suite.
   non-uniform blur branch (the backdrop has one level, so the texel is the same —
   `frost_pair` is identical to the pixel either way).
 - `web/` — wasm32 only: `WebRenderer` (`new(canvas).await`, `resize`, `prepare_text`,
-  `draw_frame_2d`, and `capture_next_frame` / `take_capture().await` to read a frame back).
-  Its module doc lists what differs from the Vulkan path: an sRGB VIEW of the canvas's
-  unorm format, the parameter block as a dynamic-offset uniform, a 1x1 backdrop, the blur
-  snapshot as end-pass / copy / resume, every frame drawn whole.
+  `draw_frame_2d`, and `capture_next_frame` / `take_capture().await` or
+  `take_pending_capture` to read a frame back). Its module doc lists what differs from the
+  Vulkan path: an sRGB VIEW of the canvas's unorm format, the parameter block as a
+  dynamic-offset uniform, a 1x1 backdrop, the blur snapshot as end-pass / copy / resume,
+  every frame drawn whole. And `shell.rs`, the browser shell: `run`, `Fonts`, `Sizing`,
+  `capture` (see "And an `Application` runs in a page" above).
 - `protocol.rs` — inline-generated Wayland protocol bindings.
 - `ipc.rs` — the `/tmp/<prefix>-<WAYLAND_DISPLAY>.sock` helpers (`socket_path`, `send_command`,
   the bounded `read_request_line`, `focus_window`), and `ipc::instance`: single-instance
diff --git a/Cargo.toml b/Cargo.toml
index 797f7c0..b76fe95 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -78,6 +78,17 @@ web-sys = { version = "0.3", features = [
     "Document",
     "Element",
     "HtmlCanvasElement",
+    "HtmlElement",
+    "CssStyleDeclaration",
+    "EventTarget",
+    "Event",
+    "AddEventListenerOptions",
+    "MouseEvent",
+    "PointerEvent",
+    "WheelEvent",
+    "KeyboardEvent",
+    "FocusEvent",
+    "ResizeObserver",
     "Gpu",
     "GpuAdapter",
     "GpuDevice",
@@ -156,3 +167,9 @@ web-sys = { version = "0.3", features = [
 [[example]]
 name = "probe_web"
 crate-type = ["cdylib"]
+
+# The reference app (src/main.rs) in a browser, on the browser shell; a
+# cdylib for wasm-bindgen, empty natively.
+[[example]]
+name = "demo_web"
+crate-type = ["cdylib"]
diff --git a/examples/demo_web.rs b/examples/demo_web.rs
new file mode 100644
index 0000000..27ad2d3
--- /dev/null
+++ b/examples/demo_web.rs
@@ -0,0 +1,41 @@
+//! The reference app, `src/main.rs`, in a browser: the same `DemoApp` the
+//! Wayland shell runs, run on a canvas by `cce_ui::web::run`. Built for
+//! wasm32 as a cdylib and bound with wasm-bindgen; a page calls
+//! `start(canvas, fonts, families, fill)` — `fonts` each a font file's bytes,
+//! `families` the generic serif / sans-serif / monospace families as
+//! "serif,sans,mono" (empty for the defaults; any part may be empty), `fill`
+//! true to let the page size the canvas (`Sizing::Page`) rather than the app
+//! (`Sizing::App`) — and `capture()` for the next frame's pixels.
+//! `scripts/web-probe/demo` serves it and drives it.
+//!
+//! On a native target this example is empty.
+
+#[cfg(target_arch = "wasm32")]
+#[allow(dead_code)]
+#[path = "../src/main.rs"]
+mod demo;
+
+#[cfg(target_arch = "wasm32")]
+mod web {
+    use cce_ui::web::{Fonts, Sizing};
+    use wasm_bindgen::prelude::*;
+
+    #[wasm_bindgen]
+    pub async fn start(canvas: web_sys::HtmlCanvasElement, fonts: js_sys::Array, families: String, fill: bool) -> Result<(), JsValue> {
+        std::panic::set_hook(Box::new(|info| web_sys::console::error_1(&info.to_string().into())));
+        let mut fonts = Fonts::new(fonts.iter().map(|f| js_sys::Uint8Array::new(&f).to_vec()).collect());
+        let mut names = families.split(',').map(|f| Some(f.trim()).filter(|f| !f.is_empty()).map(String::from));
+        fonts.serif = names.next().flatten();
+        fonts.sans_serif = names.next().flatten();
+        fonts.monospace = names.next().flatten();
+        let sizing = if fill { Sizing::Page } else { Sizing::App };
+        cce_ui::web::run::<super::demo::DemoApp>(canvas, fonts, sizing).await
+    }
+
+    /// The next frame, read back from the GPU as RGBA8 (`cce_ui::web::capture`).
+    #[wasm_bindgen]
+    pub async fn capture() -> Result<js_sys::Uint8Array, JsValue> {
+        let frame = cce_ui::web::capture().await?;
+        Ok(js_sys::Uint8Array::from(&frame.rgba[..]))
+    }
+}
diff --git a/scripts/check-wasm b/scripts/check-wasm
index 079f187..8696f84 100755
--- a/scripts/check-wasm
+++ b/scripts/check-wasm
@@ -4,7 +4,8 @@
 # optional features. The native shell and renderer (Wayland, Vulkan, the
 # compositor IPC) are cfg'd out there; everything else must compile, and a
 # change that reaches for one of them from portable code fails here first.
-# The WebGPU renderer (src/web) and the probe's browser half build here too;
+# The browser shell and renderer (src/web), the probe's browser half and the
+# reference app's (examples/demo_web.rs) build here too;
 # they need web-sys's unstable WebGPU bindings, switched on for this crate in
 # .cargo/config.toml — which cargo reads from the directory it runs in, hence
 # the cd below.
@@ -17,5 +18,5 @@ if ! rustup target list --installed 2>/dev/null | grep -qx wasm32-unknown-unknow
 fi
 cargo check --lib --target wasm32-unknown-unknown
 cargo check --lib --target wasm32-unknown-unknown --features markdown,doc_editor
-cargo check --example probe_web --target wasm32-unknown-unknown
+cargo check --example probe_web --example demo_web --target wasm32-unknown-unknown
 echo "check-wasm: ok"
diff --git a/scripts/web-probe/browser.mjs b/scripts/web-probe/browser.mjs
new file mode 100644
index 0000000..b38a467
--- /dev/null
+++ b/scripts/web-probe/browser.mjs
@@ -0,0 +1,43 @@
+// browser.mjs: what capture.mjs and drive.mjs share — serve a site directory
+// on localhost (WebGPU needs a secure context) and open it in headless
+// Chromium with WebGPU on SwiftShader.
+//
+// Playwright is found as `playwright` on the module path, or at
+// $PLAYWRIGHT_MODULE (a package directory) when it is installed elsewhere.
+import { createRequire } from 'module';
+import http from 'http';
+import fs from 'fs';
+import path from 'path';
+
+const require = createRequire(import.meta.url);
+const { chromium } = require(process.env.PLAYWRIGHT_MODULE || 'playwright');
+const types = { '.html': 'text/html', '.js': 'text/javascript', '.wasm': 'application/wasm', '.json': 'application/json' };
+
+/// Open `page` (a file in `root`) at `width` x `height`. Resolves to
+/// { page, logs, close }; `logs` collects the page's console and errors.
+export async function open(root, file, width, height) {
+  const server = http.createServer((q, r) => {
+    const p = decodeURIComponent(q.url.split('?')[0]);
+    const f = path.join(root, p === '/' ? file : p);
+    fs.readFile(f, (e, d) => {
+      if (e) { r.writeHead(404); r.end(); return; }
+      r.writeHead(200, { 'content-type': types[path.extname(f)] || 'application/octet-stream' });
+      r.end(d);
+    });
+  });
+  await new Promise(res => server.listen(0, '127.0.0.1', res));
+  // SwiftShader for WebGPU, and for the compositor too: a headless shell with
+  // GPU compositing has no shared-image backing for a WebGPU canvas, and the
+  // device is lost on the first present ("A valid external Instance reference
+  // no longer exists").
+  const browser = await chromium.launch({ args: [
+    '--enable-unsafe-webgpu', '--use-webgpu-adapter=swiftshader', '--enable-features=Vulkan',
+    '--use-angle=swiftshader', '--enable-unsafe-swiftshader', '--disable-gpu-compositing',
+  ] });
+  const page = await browser.newPage({ viewport: { width, height }, deviceScaleFactor: Number(process.env.PROBE_DPR || 1) });
+  const logs = [];
+  page.on('console', m => logs.push(`[${m.type()}] ${m.text()}`));
+  page.on('pageerror', e => logs.push(`[pageerror] ${e}`));
+  await page.goto(`http://127.0.0.1:${server.address().port}/`);
+  return { page, logs, close: async () => { await browser.close(); server.close(); } };
+}
diff --git a/scripts/web-probe/capture.mjs b/scripts/web-probe/capture.mjs
index 9952619..a1aa9d8 100644
--- a/scripts/web-probe/capture.mjs
+++ b/scripts/web-probe/capture.mjs
@@ -1,46 +1,12 @@
-// capture.mjs <site-dir> <out.rgba>: serve <site-dir> on localhost (WebGPU
-// needs a secure context), open it in headless Chromium with WebGPU on
-// SwiftShader, run the probe, and write the frame it read back from the GPU
-// to <out.rgba> (premultiplied RGBA8), with a page screenshot beside it.
-//
-// Playwright is found as `playwright` on the module path, or at
-// $PLAYWRIGHT_MODULE (a package directory) when it is installed elsewhere.
-import { createRequire } from 'module';
-import http from 'http';
+// capture.mjs <site-dir> <out.rgba>: open the probe page (browser.mjs), run
+// the probe, and write the frame it read back from the GPU to <out.rgba>
+// (premultiplied RGBA8), with a page screenshot beside it.
 import fs from 'fs';
-import path from 'path';
+import { open } from './browser.mjs';
 
 const [root, out] = process.argv.slice(2);
 if (!root || !out) { console.error('usage: capture.mjs <site-dir> <out.rgba>'); process.exit(2); }
-const require = createRequire(import.meta.url);
-const { chromium } = require(process.env.PLAYWRIGHT_MODULE || 'playwright');
-
-const types = { '.html': 'text/html', '.js': 'text/javascript', '.wasm': 'application/wasm', '.json': 'application/json', '.ttf': 'font/ttf' };
-const server = http.createServer((q, r) => {
-  const p = decodeURIComponent(q.url.split('?')[0]);
-  const f = path.join(root, p === '/' ? 'index.html' : p);
-  fs.readFile(f, (e, d) => {
-    if (e) { r.writeHead(404); r.end(); return; }
-    r.writeHead(200, { 'content-type': types[path.extname(f)] || 'application/octet-stream' });
-    r.end(d);
-  });
-});
-await new Promise(res => server.listen(0, '127.0.0.1', res));
-const port = server.address().port;
-
-// SwiftShader for WebGPU, and for the compositor too: a headless shell with
-// GPU compositing has no shared-image backing for a WebGPU canvas, and the
-// device is lost on the first present ("A valid external Instance reference
-// no longer exists").
-const browser = await chromium.launch({ args: [
-  '--enable-unsafe-webgpu', '--use-webgpu-adapter=swiftshader', '--enable-features=Vulkan',
-  '--use-angle=swiftshader', '--enable-unsafe-swiftshader', '--disable-gpu-compositing',
-] });
-const page = await browser.newPage({ viewport: { width: 1280, height: 800 } });
-const logs = [];
-page.on('console', m => logs.push(`[${m.type()}] ${m.text()}`));
-page.on('pageerror', e => logs.push(`[pageerror] ${e}`));
-await page.goto(`http://127.0.0.1:${port}/`);
+const { page, logs, close } = await open(root, 'index.html', 1280, 800);
 await page.waitForFunction(() => window.probeReady === true);
 let ok = true;
 try {
@@ -49,6 +15,5 @@ try {
   await page.screenshot({ path: out + '.screenshot.png' });
 } catch (e) { ok = false; logs.push('[probe] ' + String(e)); }
 for (const l of logs) console.log(l);
-await browser.close();
-server.close();
+await close();
 process.exit(ok ? 0 : 1);
diff --git a/scripts/web-probe/compare.py b/scripts/web-probe/compare.py
index fe355c0..5fb1e89 100755
--- a/scripts/web-probe/compare.py
+++ b/scripts/web-probe/compare.py
@@ -1,22 +1,34 @@
 #!/usr/bin/env python3
-"""compare.py native.png web.rgba W H [bg]: composite the web capture
+"""compare.py native.png web.rgba W H [bg] [--mask X,Y,W,H …]: composite the web capture
 (premultiplied RGBA8, as a WebGPU canvas with alpha_mode=premultiplied holds
 it) over the compositor background the native screenshot shows behind the
 window (a grey level, default 63: headless sway's #3f3f3f), and report how far
 the two frames are apart, per channel.
 
-The native screenshot must be the window alone at its origin, with the pointer
-off it: a cursor sprite in the screenshot is a difference this cannot tell
-from the renderer's. Needs ImageMagick's `convert`."""
+The native screenshot must be the window alone at its origin. A cursor sprite
+in it is a difference this cannot tell from the renderer's: keep the pointer
+off the window, or `--mask` the box the sprite covers (repeatable). Hiding
+the cursor in the compositor is no answer — sway's `hide_cursor` clears the
+pointer focus, so the app sees a leave and drops its hover. Needs
+ImageMagick's `convert`."""
 import subprocess, sys
-native_png, web_path, W, H = sys.argv[1], sys.argv[2], int(sys.argv[3]), int(sys.argv[4])
-bg = int(sys.argv[5]) if len(sys.argv) > 5 else 63
+args = sys.argv[1:]
+masks = []
+while '--mask' in args:
+    i = args.index('--mask')
+    masks.append(tuple(int(v) for v in args[i + 1].split(',')))
+    del args[i:i + 2]
+native_png, web_path, W, H = args[0], args[1], int(args[2]), int(args[3])
+bg = int(args[4]) if len(args) > 4 else 63
+masked = lambda x, y: any(mx <= x < mx + mw and my <= y < my + mh for mx, my, mw, mh in masks)
 native = subprocess.run(['convert', native_png, '-alpha', 'off', 'rgb:-'], capture_output=True, check=True).stdout
 web = open(web_path, 'rb').read()
 assert len(native) == W * H * 3 and len(web) == W * H * 4, (len(native), len(web))
 hist = {}
 worst = (0, None)
 for i in range(W * H):
+    if masks and masked(i % W, i // W):
+        continue
     a = web[4 * i + 3]
     for c in range(3):
         w = web[4 * i + c] + bg * (255 - a) / 255.0
@@ -25,7 +37,7 @@ for i in range(W * H):
         if d > worst[0]:
             worst = (d, (i % W, i // W))
 px_over = lambda t: sum(n for d, n in hist.items() if d > t)
-print(f"channels compared: {3 * W * H}")
+print(f"channels compared: {sum(hist.values())}")
 print("difference histogram (levels of 255):", dict(sorted(hist.items())))
 print(f"channels off by >1: {px_over(1)}, >2: {px_over(2)}, >4: {px_over(4)}, >8: {px_over(8)}")
 print(f"worst: {worst[0]} at {worst[1]}")
diff --git a/scripts/web-probe/demo b/scripts/web-probe/demo
new file mode 100755
index 0000000..48600bf
--- /dev/null
+++ b/scripts/web-probe/demo
@@ -0,0 +1,32 @@
+#!/usr/bin/env bash
+# web-probe/demo <out-dir> — run the reference app (src/main.rs) on the
+# browser shell, in headless Chromium, through the native harness's input
+# script (drive.mjs), one screenshot per step into <out-dir>.
+#
+# Fonts: the native demo, run with CCE_LOAD_SYSTEM_FONTS=1, sees every system
+# font, and the generic families fontdb reads from fontconfig; the page is
+# handed the same files in the order fontdb's directory scan finds them
+# ($DEMO_FONTS_DIR, default /usr/share/fonts) and the same generic families
+# ($DEMO_FAMILIES, "serif,sans,mono" — what fontdb's fontconfig reading made
+# of this machine's config, which is not always what `fc-match` says; empty
+# for the shell's defaults). A family the app names and the set lacks falls
+# back through them — the text measurement to the serif family — so with
+# either different, text is the difference you measure. Needs what `run`
+# needs.
+set -euo pipefail
+here=$(cd "$(dirname "$0")" && pwd)
+out=$(realpath -m "${1:?usage: web-probe/demo <out-dir>}")
+fonts=${DEMO_FONTS_DIR:-/usr/share/fonts}
+cd "$here/../.."
+
+cargo build --release --target wasm32-unknown-unknown --example demo_web
+site=$(mktemp -d)
+trap 'rm -rf "$site"' EXIT
+wasm-bindgen --target web --no-typescript --out-dir "$site" \
+    target/wasm32-unknown-unknown/release/examples/demo_web.wasm
+cp "$here/demo.html" "$site/demo.html"
+ln -s "$fonts" "$site/fonts"
+(cd "$fonts" && find . \( -type f -o -type l \) \( -iname '*.ttf' -o -iname '*.otf' -o -iname '*.ttc' -o -iname '*.otc' \) \
+    | sed 's|^\./||' | DEMO_FAMILIES="${DEMO_FAMILIES:-}" python3 -c 'import json,os,sys; print(json.dumps({"files": sys.stdin.read().split(), "families": os.environ["DEMO_FAMILIES"]}))') > "$site/fonts.json"
+node "$here/drive.mjs" "$site" "$out"
+echo "web-probe: screenshots in $out"
diff --git a/scripts/web-probe/demo.html b/scripts/web-probe/demo.html
new file mode 100644
index 0000000..a88f642
--- /dev/null
+++ b/scripts/web-probe/demo.html
@@ -0,0 +1,24 @@
+<!doctype html>
+<meta charset="utf-8">
+<title>cce-ui</title>
+<!-- The reference app on the browser shell (examples/demo_web.rs). The page
+     owns the canvas's size (fill = Sizing::Page). window.capture() reads the
+     next frame back from the GPU, base64 RGBA8: a headless Chromium leaves
+     a WebGPU canvas out of its screenshots. -->
+<style>html,body{margin:0;background:#3f3f3f;overflow:hidden}canvas{display:block;width:100vw;height:100vh}</style>
+<canvas id="c"></canvas>
+<script type="module">
+import init, { start, capture } from './demo_web.js';
+await init();
+const { files, families } = await (await fetch('fonts.json')).json();
+const fonts = [];
+for (const n of files) fonts.push(new Uint8Array(await (await fetch('fonts/' + n)).arrayBuffer()));
+await start(document.getElementById('c'), fonts, families, true);
+window.capture = async () => {
+  const rgba = await capture();
+  let s = '';
+  for (let i = 0; i < rgba.length; i += 0x8000) s += String.fromCharCode.apply(null, rgba.subarray(i, i + 0x8000));
+  return btoa(s);
+};
+window.demoReady = true;
+</script>
diff --git a/scripts/web-probe/drive.mjs b/scripts/web-probe/drive.mjs
new file mode 100644
index 0000000..e0cae84
--- /dev/null
+++ b/scripts/web-probe/drive.mjs
@@ -0,0 +1,64 @@
+// drive.mjs <site-dir> <out-dir>: open the demo page (browser.mjs) and run
+// the native harness's input script against it — the same pointer moves,
+// presses, wheel notches and keys, with the same waits, and one screenshot
+// per step (00-start.rgba … 23-titlebar-band.rgba: premultiplied RGBA8, the
+// frame read back from the GPU — compare.py composites it). A native run of the same
+// script under headless sway (the pointer parked at the corner, the cursor
+// hidden, CCE_UI_MENU_POPUP=0 so the menu is drawn in the window as it is
+// here) is what each screenshot is compared with.
+import fs from 'fs';
+import { open } from './browser.mjs';
+
+const [root, out] = process.argv.slice(2);
+if (!root || !out) { console.error('usage: drive.mjs <site-dir> <out-dir>'); process.exit(2); }
+fs.mkdirSync(out, { recursive: true });
+const { page, logs, close } = await open(root, 'demo.html', 1280, 800);
+const m = page.mouse, k = page.keyboard;
+const wait = ms => page.waitForTimeout(ms);
+// One pointer command, then the 150 ms the native script's `v` waits.
+const v = async f => { await f(); await wait(150); };
+const shot = async name => {
+  await wait(900);
+  const b64 = await page.evaluate(() => window.capture());
+  fs.writeFileSync(`${out}/${name}.rgba`, Buffer.from(b64, 'base64'));
+};
+const BTN = { 272: 'left', 273: 'right' };
+const click = b => v(async () => { await m.down({ button: BTN[b] }); await m.up({ button: BTN[b] }); });
+const abs = (x, y) => v(() => m.move(x, y));
+
+let ok = true;
+try {
+  await abs(1279, 799);
+  await page.waitForFunction(() => window.demoReady === true, null, { timeout: 120000 });
+  await wait(5000);
+  await shot('00-start');
+  await abs(85, 91); await shot('01-hover-button');
+  await click(272); await shot('02-click-button');
+  await abs(202, 91); await click(272); await shot('03-toggle');
+  await abs(332, 91); await click(272); await shot('04-dropdown-open');
+  await abs(332, 122); await click(272); await shot('05-dropdown-pick');
+  await abs(640, 168); await click(272); await shot('06-textbox-focus');
+  await k.type('hello web'); await shot('07-typed');
+  await k.down('Control'); await k.press('z'); await k.up('Control'); await shot('08-undo');
+  for (let i = 0; i < 3; i++) await k.press('Backspace');
+  await shot('09-backspace');
+  await abs(900, 130); await v(() => m.down()); await abs(1000, 130); await abs(1100, 131); await v(() => m.up());
+  await shot('10-slider-drag');
+  await abs(517, 131); await v(() => m.wheel(0, -300)); await shot('11-slider-wheel');
+  await abs(600, 500); await click(273); await shot('12-context-menu');
+  await abs(640, 525); await shot('13-menu-hover');
+  await abs(1200, 750); await click(272); await shot('14-menu-dismiss');
+  await abs(600, 500); await click(273); await abs(640, 525); await click(272); await shot('15-menu-run');
+  await k.press('Tab'); await shot('16-tab');
+  await k.press('Escape'); await shot('17-esc');
+  await abs(640, 168); await click(272); await k.type('xyzxyz'); await shot('18-refocus-typed');
+  await k.down('Backspace'); await wait(1500); await k.up('Backspace'); await shot('19-hold-backspace');
+  await k.type('abcdef'); await k.down('ArrowLeft'); await wait(1500); await k.up('ArrowLeft'); await k.type('Z');
+  await shot('20-hold-left-then-type');
+  await abs(3, 400); await click(272); await shot('21-csd-left-edge');
+  await abs(640, 3); await click(272); await shot('22-csd-top-edge');
+  await abs(300, 20); await click(272); await shot('23-titlebar-band');
+} catch (e) { ok = false; logs.push('[drive] ' + String(e)); }
+for (const l of logs) console.log(l);
+await close();
+process.exit(ok ? 0 : 1);
diff --git a/src/backend/app.rs b/src/backend/app.rs
index 60e4b3f..7c2bc4a 100644
--- a/src/backend/app.rs
+++ b/src/backend/app.rs
@@ -102,7 +102,8 @@ pub struct RenderContext<'a> {
 /// for a client that still keeps a `calloop::channel::Sender` somewhere. In
 /// the browser it is a `std::sync::mpsc` sender whose receiver the shell
 /// drains every turn — still `Send`, so app code that hands it to a worker
-/// compiles unchanged.
+/// compiles unchanged — and a send wakes the browser shell's loop through
+/// [`set_wake`], as a calloop channel wakes the Wayland one.
 pub struct AppSender<M> {
     #[cfg(not(target_arch = "wasm32"))]
     inner: calloop::channel::Sender<M>,
@@ -114,10 +115,37 @@ impl<M> AppSender<M> {
     /// Post `msg` to the app. Fails, handing it back, only once the loop is
     /// gone: the app has exited.
     pub fn send(&self, msg: M) -> Result<(), std::sync::mpsc::SendError<M>> {
-        self.inner.send(msg)
+        self.inner.send(msg)?;
+        #[cfg(target_arch = "wasm32")]
+        wake();
+        Ok(())
     }
 }
 
+/// Call the hook [`set_wake`] installed, if any.
+#[cfg(target_arch = "wasm32")]
+pub(crate) fn wake() {
+    WAKE.with(|w| {
+        if let Some(wake) = w.borrow().as_ref() {
+            wake();
+        }
+    });
+}
+
+#[cfg(target_arch = "wasm32")]
+thread_local! {
+    static WAKE: std::cell::RefCell<Option<Box<dyn Fn()>>> = const { std::cell::RefCell::new(None) };
+}
+
+/// What an [`AppSender::send`] on this thread calls after posting: the
+/// browser shell's "turn the loop soon". Per thread, and the page's app runs
+/// on the one thread a page has; a send from a worker thread posts without
+/// waking, and is drained at the next turn the page takes.
+#[cfg(target_arch = "wasm32")]
+pub fn set_wake(wake: Option<Box<dyn Fn()>>) {
+    WAKE.with(|w| *w.borrow_mut() = wake);
+}
+
 impl<M> Clone for AppSender<M> {
     fn clone(&self) -> Self {
         Self { inner: self.inner.clone() }
diff --git a/src/backend/dom.rs b/src/backend/dom.rs
new file mode 100644
index 0000000..e0a3d10
--- /dev/null
+++ b/src/backend/dom.rs
@@ -0,0 +1,152 @@
+//! The DOM's input vocabulary in cce-ui's terms: a `KeyboardEvent.key` as a
+//! [`Key`] and the text it types, a `WheelEvent` as a [`ScrollFrame`]. The
+//! browser shell (`web::shell`) feeds these to the [`Driver`](super::driver::Driver),
+//! as the Wayland shell maps xkb keysyms and `wl_pointer` axis frames. Plain
+//! functions of strings and numbers, so they build and are tested natively.
+
+use super::driver::{ScrollFrame, ScrollSource};
+use crate::widget::{Key, NamedKey};
+
+/// A `KeyboardEvent.key` as cce-ui's key and the text it types — the
+/// browser's half of the Wayland shell's keysym map, text included: what
+/// xkb's `utf8` would carry for the same key, since that is what widgets
+/// have always been handed (Tab types "\t" and Enter "\r"; with Ctrl, or
+/// Command on a Mac, a letter types its control code and is matched by the
+/// letter). `None` for a key the toolkit has no use for (an IME's `Process`,
+/// a dead key).
+pub fn map_key(key: &str, accel: bool) -> Option<(Key, Option<String>)> {
+    let named = |n| Some((Key::Named(n), None));
+    let typing = |n, text: &str| Some((Key::Named(n), Some(text.to_string())));
+    match key {
+        "Escape" => typing(NamedKey::Escape, "\u{1b}"),
+        "Enter" => typing(NamedKey::Enter, "\r"),
+        "Backspace" => typing(NamedKey::Backspace, "\u{8}"),
+        "Tab" => typing(NamedKey::Tab, "\t"),
+        "Delete" => typing(NamedKey::Delete, "\u{7f}"),
+        " " => typing(NamedKey::Space, if accel { "\0" } else { " " }),
+        "ArrowDown" => named(NamedKey::ArrowDown),
+        "ArrowUp" => named(NamedKey::ArrowUp),
+        "ArrowLeft" => named(NamedKey::ArrowLeft),
+        "ArrowRight" => named(NamedKey::ArrowRight),
+        "PageUp" => named(NamedKey::PageUp),
+        "PageDown" => named(NamedKey::PageDown),
+        "Home" => named(NamedKey::Home),
+        "End" => named(NamedKey::End),
+        "Meta" | "OS" => named(NamedKey::Super),
+        "Alt" => named(NamedKey::Alt),
+        "Control" => named(NamedKey::Control),
+        "Shift" => named(NamedKey::Shift),
+        "F1" => named(NamedKey::F1),
+        "F2" => named(NamedKey::F2),
+        "F3" => named(NamedKey::F3),
+        "F4" => named(NamedKey::F4),
+        "F5" => named(NamedKey::F5),
+        "F6" => named(NamedKey::F6),
+        "F7" => named(NamedKey::F7),
+        "F8" => named(NamedKey::F8),
+        "F9" => named(NamedKey::F9),
+        "F10" => named(NamedKey::F10),
+        "F11" => named(NamedKey::F11),
+        "F12" => named(NamedKey::F12),
+        // A key that types: one character (a named key's name is longer).
+        k if k.chars().count() == 1 => {
+            let c = k.chars().next().unwrap();
+            let text = match c {
+                // xkb's control transformation: @, A–Z, [ \ ] ^ _ (either
+                // case of the letters) to 0x00–0x1f.
+                '@'..='_' | 'a'..='z' if accel => char::from(c.to_ascii_uppercase() as u8 & 0x1f).to_string(),
+                _ => k.to_string(),
+            };
+            Some((Key::Character(k.into()), Some(text)))
+        }
+        _ => None,
+    }
+}
+
+/// A `WheelEvent`'s deltas as a scroll frame in the units the driver takes
+/// (a `wl_pointer` axis frame's: a finger in px, a wheel notch as a discrete
+/// step plus ten units, positive down and right — the DOM's signs too).
+///
+/// A page cannot ask which device scrolled. A line or page delta is a wheel
+/// (Firefox's notches); a pixel delta is a wheel when it comes in whole
+/// notch-sized steps on one axis (Chromium's 100 px a notch, or 120), and a
+/// finger otherwise — a trackpad's deltas are small and fractional.
+pub fn wheel_frame(mode: u32, dx: f64, dy: f64) -> ScrollFrame {
+    // Whole notches, at least one each way a delta goes.
+    let notches = |d: f64, per: f64| -> i32 {
+        match (d / per).round() as i32 {
+            _ if d == 0.0 => 0,
+            0 => d.signum() as i32,
+            n => n.clamp(-100, 100),
+        }
+    };
+    let wheel = |per: f64| {
+        let (nh, nv) = (notches(dx, per), notches(dy, per));
+        ScrollFrame {
+            h: nh as f64 * 10.0,
+            v: nv as f64 * 10.0,
+            discrete_h: nh,
+            discrete_v: nv,
+            source: Some(ScrollSource::Wheel),
+            stop: false,
+        }
+    };
+    match mode {
+        // DOM_DELTA_LINE: Firefox gives three lines a notch.
+        1 => wheel(3.0),
+        // DOM_DELTA_PAGE: a notch is a page.
+        2 => wheel(1.0),
+        _ => {
+            let notch_sized = |d: f64| d == 0.0 || (d.fract() == 0.0 && d.abs() >= 50.0);
+            if (dx == 0.0 || dy == 0.0) && notch_sized(dx) && notch_sized(dy) {
+                wheel(100.0)
+            } else {
+                ScrollFrame { h: dx, v: dy, source: Some(ScrollSource::Finger), ..Default::default() }
+            }
+        }
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn named_keys_type_what_xkb_types() {
+        assert_eq!(map_key("Tab", false), Some((Key::Named(NamedKey::Tab), Some("\t".into()))));
+        assert_eq!(map_key("Enter", false), Some((Key::Named(NamedKey::Enter), Some("\r".into()))));
+        assert_eq!(map_key("Backspace", false), Some((Key::Named(NamedKey::Backspace), Some("\u{8}".into()))));
+        assert_eq!(map_key(" ", false), Some((Key::Named(NamedKey::Space), Some(" ".into()))));
+        assert_eq!(map_key("ArrowLeft", false), Some((Key::Named(NamedKey::ArrowLeft), None)));
+        assert_eq!(map_key("Dead", false), None);
+        assert_eq!(map_key("Process", false), None);
+    }
+
+    #[test]
+    fn a_shortcut_is_matched_by_its_letter_and_types_its_control_code() {
+        assert_eq!(map_key("a", false), Some((Key::Character("a".into()), Some("a".into()))));
+        assert_eq!(map_key("z", true), Some((Key::Character("z".into()), Some("\u{1a}".into()))));
+        // Ctrl+Shift+Z: the shifted letter, the same control code.
+        assert_eq!(map_key("Z", true), Some((Key::Character("Z".into()), Some("\u{1a}".into()))));
+        assert_eq!(map_key("1", true), Some((Key::Character("1".into()), Some("1".into()))));
+        assert_eq!(map_key("é", false), Some((Key::Character("é".into()), Some("é".into()))));
+    }
+
+    #[test]
+    fn notches_are_discrete_and_a_trackpad_is_a_finger() {
+        // Chromium's mouse wheel: 100 px a notch, three notches up.
+        let f = wheel_frame(0, 0.0, -300.0);
+        assert_eq!((f.discrete_v, f.discrete_h, f.source), (-3, 0, Some(ScrollSource::Wheel)));
+        assert_eq!(f.v, -30.0);
+        // Firefox's: three lines a notch.
+        let f = wheel_frame(1, 0.0, 3.0);
+        assert_eq!((f.discrete_v, f.source), (1, Some(ScrollSource::Wheel)));
+        // A page delta is a notch.
+        assert_eq!(wheel_frame(2, 0.0, -1.0).discrete_v, -1);
+        // A trackpad: small, fractional, often on both axes.
+        let f = wheel_frame(0, 1.5, -4.25);
+        assert_eq!((f.discrete_v, f.h, f.v, f.source), (0, 1.5, -4.25, Some(ScrollSource::Finger)));
+        // Whole but notch-sized on both axes at once: a finger too.
+        assert_eq!(wheel_frame(0, 100.0, 100.0).source, Some(ScrollSource::Finger));
+    }
+}
diff --git a/src/backend/mod.rs b/src/backend/mod.rs
index d4d4b4a..ea8c729 100644
--- a/src/backend/mod.rs
+++ b/src/backend/mod.rs
@@ -1,4 +1,5 @@
 pub mod app;
+pub mod dom;
 pub mod driver;
 pub mod frame;
 pub mod shell;
diff --git a/src/lib.rs b/src/lib.rs
index 6b62710..5773daf 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -208,6 +208,7 @@ pub fn create_font_system_with_system_fonts() -> cosmic_text::FontSystem {
 /// list of well-known files keeps startup cheap while giving cosmic-text's
 /// unix script fallback (family names "Noto Sans CJK *", "Noto Color Emoji")
 /// real faces to land on. `$CCE_NO_FALLBACK_FONTS` opts out.
+#[cfg(not(target_arch = "wasm32"))]
 fn load_fallback_fonts(db: &mut cosmic_text::fontdb::Database) {
     if std::env::var("CCE_NO_FALLBACK_FONTS").is_ok() {
         return;
@@ -231,38 +232,52 @@ fn load_fallback_fonts(db: &mut cosmic_text::fontdb::Database) {
     }
 }
 
-fn build_font_system(load_system_fonts: bool) -> cosmic_text::FontSystem {
-    let mut db = cosmic_text::fontdb::Database::new();
-    db.load_fonts_dir(fonts_dir());
-    load_fallback_fonts(&mut db);
-    if load_system_fonts || std::env::var("CCE_LOAD_SYSTEM_FONTS").is_ok() {
-        db.load_system_fonts();
-    }
-    // An empty database guarantees a panic on the first shaped glyph
-    // (cosmic-text: "no default font found"), so if the bundled dir yielded
-    // nothing (missing $HOME/Dropbox/Fonts — e.g. the greeter running as
-    // root), fall back to system fonts rather than crash.
-    if db.faces().next().is_none() {
-        db.load_system_fonts();
-    }
-
-    // Pin the generic families to faces that actually exist. fontdb's defaults
-    // name Windows faces ("Arial"/"Times New Roman"), so Family::SansSerif /
-    // Monospace never resolved here and every glyph of generic-family text
-    // dropped into the per-glyph fallback chain — where Noto Color Emoji sits
-    // high (cosmic-text common_fallback) and hijacked spaces and digits with
-    // emoji metrics. Berkeley Mono is the house mono; Noto Sans CJK SC (the
-    // targeted fallback face above) doubles as a full Latin sans.
+/// Pin the generic families to faces that actually exist. fontdb's defaults
+/// name Windows faces ("Arial"/"Times New Roman"), so Family::SansSerif /
+/// Monospace never resolved here and every glyph of generic-family text
+/// dropped into the per-glyph fallback chain — where Noto Color Emoji sits
+/// high (cosmic-text common_fallback) and hijacked spaces and digits with
+/// emoji metrics. Berkeley Mono is the house mono; Noto Sans CJK SC (the
+/// targeted fallback face) doubles as a full Latin sans. Shared by every
+/// shell's font system, the browser's included, so a font set resolves the
+/// same families wherever it is loaded.
+fn pin_generic_families(db: &mut cosmic_text::fontdb::Database) {
     fn has_family(db: &cosmic_text::fontdb::Database, fam: &str) -> bool {
         db.faces()
             .any(|f| f.families.iter().any(|(n, _)| n == fam))
     }
-    if has_family(&db, "Berkeley Mono") {
+    if has_family(db, "Berkeley Mono") {
         db.set_monospace_family("Berkeley Mono");
     }
-    if has_family(&db, "Noto Sans CJK SC") {
+    if has_family(db, "Noto Sans CJK SC") {
         db.set_sans_serif_family("Noto Sans CJK SC");
     }
+}
+
+fn build_font_system(load_system_fonts: bool) -> cosmic_text::FontSystem {
+    let mut db = cosmic_text::fontdb::Database::new();
+    #[cfg(not(target_arch = "wasm32"))]
+    {
+        db.load_fonts_dir(fonts_dir());
+        load_fallback_fonts(&mut db);
+        if load_system_fonts || std::env::var("CCE_LOAD_SYSTEM_FONTS").is_ok() {
+            db.load_system_fonts();
+        }
+        // An empty database guarantees a panic on the first shaped glyph
+        // (cosmic-text: "no default font found"), so if the bundled dir yielded
+        // nothing (missing $HOME/Dropbox/Fonts — e.g. the greeter running as
+        // root), fall back to system fonts rather than crash.
+        if db.faces().next().is_none() {
+            db.load_system_fonts();
+        }
+    }
+    #[cfg(target_arch = "wasm32")]
+    {
+        let _ = load_system_fonts;
+        page_fonts::load_into(&mut db);
+    }
+
+    pin_generic_families(&mut db);
 
     // Validate configured custom fonts
     let font_getters = vec![
@@ -301,5 +316,129 @@ fn build_font_system(load_system_fonts: bool) -> cosmic_text::FontSystem {
         }
     }
 
+    #[cfg(target_arch = "wasm32")]
+    page_fonts::stand_in_for_missing(&mut db);
+
     cosmic_text::FontSystem::new_with_locale_and_db("en-US".to_string(), db)
 }
+
+/// The fonts a page hands the browser shell (`web::run`). A page has no font
+/// directory and no fontconfig, so on wasm every font database the toolkit
+/// builds — the shell's, its own for widget geometry
+/// ([`geometry_font_system`]) and the text-measurement one
+/// (`widget::input::get_font_db`) — is loaded from these, one shared copy of
+/// each file, with the generic families the page named.
+#[cfg(target_arch = "wasm32")]
+pub(crate) mod page_fonts {
+    use std::sync::{Arc, Mutex};
+
+    use cosmic_text::fontdb::{Database, Language};
+
+    type Font = Arc<dyn AsRef<[u8]> + Send + Sync>;
+
+    struct Provided {
+        files: Vec<Font>,
+        serif: Option<String>,
+        sans_serif: Option<String>,
+        monospace: Option<String>,
+    }
+
+    static PROVIDED: Mutex<Provided> =
+        Mutex::new(Provided { files: Vec::new(), serif: None, sans_serif: None, monospace: None });
+
+    /// What every font database built from now on loads: `files` (each a
+    /// font file's bytes), in order, and the families the generic ones name —
+    /// a fontconfig's answer, which on Linux `load_system_fonts` reads.
+    pub(crate) fn provide(files: Vec<Vec<u8>>, serif: Option<String>, sans_serif: Option<String>, monospace: Option<String>) {
+        let mut p = PROVIDED.lock().unwrap();
+        p.files.extend(files.into_iter().map(|f| Arc::new(f) as Font));
+        p.serif = serif.or(p.serif.take());
+        p.sans_serif = sans_serif.or(p.sans_serif.take());
+        p.monospace = monospace.or(p.monospace.take());
+    }
+
+    /// Load the page's fonts into `db`, as `load_system_fonts` loads the
+    /// system's: the files, then the generic families.
+    pub(crate) fn load_into(db: &mut Database) {
+        let p = PROVIDED.lock().unwrap();
+        for font in &p.files {
+            db.load_font_source(cosmic_text::fontdb::Source::Binary(font.clone()));
+        }
+        if let Some(f) = &p.serif {
+            db.set_serif_family(f.clone());
+        }
+        if let Some(f) = &p.sans_serif {
+            db.set_sans_serif_family(f.clone());
+        }
+        if let Some(f) = &p.monospace {
+            db.set_monospace_family(f.clone());
+        }
+    }
+
+    fn has(db: &Database, family: &str) -> bool {
+        db.faces().any(|f| f.families.iter().any(|(n, _)| n.eq_ignore_ascii_case(family)))
+    }
+
+    /// cosmic-text falls back from a family the database lacks through a
+    /// list of well-known families per OS — on Linux "Noto Sans", then
+    /// "DejaVu Sans", "FreeSans", … (its `fallback/unix.rs`) — and has no
+    /// list at all on wasm, where such text lands on whichever face happens
+    /// to come first (Noto Color Emoji, in a set loaded as Linux loads it).
+    /// So here the families the toolkit itself names — the configured
+    /// fonts, and the house default "Berkeley Mono" — are given, when absent,
+    /// the faces of the first family of that Linux list the page provided,
+    /// and the generic sans and serif families likewise unless the page named
+    /// them; "monospace" (the name `fc-match` would have resolved) is given
+    /// the first monospaced one.
+    /// Text then resolves to the face it would on a Linux box with the same
+    /// fonts. A family an app names itself must be among the page's fonts.
+    pub(crate) fn stand_in_for_missing(db: &mut Database) {
+        const SANS: [&str; 6] = ["Noto Sans", "DejaVu Sans", "FreeSans", "Noto Sans Mono", "DejaVu Sans Mono", "FreeMono"];
+        const MONO: [&str; 4] = ["Noto Sans Mono", "DejaVu Sans Mono", "FreeMono", "Liberation Mono"];
+        let first = |db: &Database, list: &[&str]| list.iter().find(|f| has(db, f)).map(|f| f.to_string());
+        let alias = |db: &mut Database, name: &str, to: &str| {
+            if name.is_empty() || has(db, name) {
+                return;
+            }
+            let faces: Vec<_> =
+                db.faces().filter(|f| f.families.iter().any(|(n, _)| n == to)).cloned().collect();
+            for mut face in faces {
+                face.families = vec![(name.to_string(), Language::English_UnitedStates)];
+                db.push_face_info(face);
+            }
+        };
+        if let Some(sans) = first(db, &SANS) {
+            let mut names: Vec<String> = vec!["Berkeley Mono".into()];
+            names.extend(
+                [
+                    crate::layout::list_font_parsed().0,
+                    crate::layout::menubar_font_parsed().0,
+                    crate::layout::statusbar_font_parsed().0,
+                    crate::layout::font_selector_font_parsed().0,
+                    crate::layout::button_strip_font_parsed().0,
+                    crate::layout::control_label_font_parsed().0,
+                    crate::layout::control_label_font_detached_parsed().0,
+                    crate::layout::tree_font_parsed().0,
+                    crate::layout::graph_font_parsed().0,
+                    crate::layout::graph_node_font_parsed().0,
+                ]
+                .into_iter()
+                .filter(|f| !matches!(f.as_str(), "sans-serif" | "serif" | "monospace")),
+            );
+            for name in &names {
+                alias(db, name, &sans);
+            }
+            for generic in [cosmic_text::Family::SansSerif, cosmic_text::Family::Serif] {
+                if !has(db, db.family_name(&generic)) {
+                    match generic {
+                        cosmic_text::Family::SansSerif => db.set_sans_serif_family(sans.clone()),
+                        _ => db.set_serif_family(sans.clone()),
+                    }
+                }
+            }
+        }
+        if let Some(mono) = first(db, &MONO) {
+            alias(db, "monospace", &mono);
+        }
+    }
+}
diff --git a/src/main.rs b/src/main.rs
index 9c5cf5e..c6455e9 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -31,7 +31,7 @@ use cce_ui::widget::{
 };
 
 #[derive(Debug, Clone)]
-enum DemoMessage {
+pub(crate) enum DemoMessage {
     Exit,
 }
 
@@ -47,7 +47,7 @@ fn text_leaf_height(font_size: f32) -> f32 {
     (font_size * 1.2).ceil()
 }
 
-struct DemoApp {
+pub(crate) struct DemoApp {
     // ── Widgets: app-owned values on the narrow-trait adapter. Their addresses must be
     // stable across frames (plain struct fields, not Vec elements): the UiContext
     // registry and the router's drag-target bookkeeping hold pointers to them.
@@ -159,7 +159,7 @@ impl Application for DemoApp {
                 gradient.push(255);
             }
         }
-        let gradient_id = cce_ui::vk::upload_rgba(gradient, GRADIENT_W, GRADIENT_H);
+        let gradient_id = cce_ui::draw::upload_rgba(gradient, GRADIENT_W, GRADIENT_H);
         Self {
             // Relief styling (raised buttons/toggles/dropdowns, recessed
             // wells) is the `control_relief` config default — no opt-in.
@@ -563,5 +563,7 @@ impl Application for DemoApp {
 }
 
 fn main() {
+    // In a browser the same app is run by `examples/demo_web.rs`.
+    #[cfg(not(target_arch = "wasm32"))]
     cce_ui::engine::run::<DemoApp>();
 }
diff --git a/src/web/mod.rs b/src/web/mod.rs
index c71e053..6644aff 100644
--- a/src/web/mod.rs
+++ b/src/web/mod.rs
@@ -1,12 +1,15 @@
 //! The browser's side of cce-ui: a WebGPU renderer that draws the same
 //! [`Frame2D`](crate::draw::Frame2D)s the Vulkan renderer does, from the same
 //! shaders (`draw::shaders`), atlas (`draw::glyphs`) and image queue
-//! (`draw::images`). The browser shell that drives it — the page's events in,
-//! `Pacer::turn` on animation frames — is the next step of the port (W3).
+//! (`draw::images`), and the shell that runs an `Application` on a canvas
+//! with it ([`run`]) — the page's events in through the shared `Driver`,
+//! `Pacer::turn` on animation frames.
 //!
 //! wasm32 only, and it needs web-sys's WebGPU bindings switched on
 //! (`--cfg=web_sys_unstable_apis`, set for this crate in `.cargo/config.toml`).
 
 mod renderer;
+mod shell;
 
-pub use renderer::{Capture, WebRenderer};
+pub use renderer::{Capture, PendingCapture, WebRenderer};
+pub use shell::{capture, run, Fonts, Sizing};
diff --git a/src/web/renderer.rs b/src/web/renderer.rs
index 3f7d7aa..d9354fc 100644
--- a/src/web/renderer.rs
+++ b/src/web/renderer.rs
@@ -110,6 +110,38 @@ pub struct Capture {
     pub rgba: Vec<u8>,
 }
 
+/// A captured frame still on the GPU: [`read`](Self::read) waits for it.
+pub struct PendingCapture {
+    buffer: GpuBuffer,
+    width: u32,
+    height: u32,
+    /// Bytes per row in the buffer: a copy's rows are 256-byte aligned.
+    row: u32,
+    /// The canvas is BGRA: swap to RGBA on the way out.
+    bgra: bool,
+}
+
+impl PendingCapture {
+    pub async fn read(self) -> Result<Capture, JsValue> {
+        let Self { buffer, width, height, row, bgra } = self;
+        buffer.map_async(map_mode::READ).await?;
+        let mapped = js_sys::Uint8Array::new(&JsValue::from(buffer.get_mapped_range()?));
+        let padded = mapped.to_vec();
+        buffer.unmap();
+        buffer.destroy();
+        let mut rgba = Vec::with_capacity((width * height * 4) as usize);
+        for y in 0..height as usize {
+            rgba.extend_from_slice(&padded[y * row as usize..y * row as usize + width as usize * 4]);
+        }
+        if bgra {
+            for px in rgba.chunks_exact_mut(4) {
+                px.swap(0, 2);
+            }
+        }
+        Ok(Capture { width, height, rgba })
+    }
+}
+
 pub struct WebRenderer {
     /// Held for the device's lifetime: the device is the adapter's, and a
     /// browser may tear the instance under it down once nothing holds it.
@@ -488,22 +520,17 @@ impl WebRenderer {
 
     /// The captured frame, once the GPU has finished it.
     pub async fn take_capture(&mut self) -> Result<Option<Capture>, JsValue> {
-        let Some((buffer, width, height, row)) = self.capture.take() else { return Ok(None) };
-        buffer.map_async(map_mode::READ).await?;
-        let mapped = js_sys::Uint8Array::new(&JsValue::from(buffer.get_mapped_range()?));
-        let padded = mapped.to_vec();
-        buffer.unmap();
-        buffer.destroy();
-        let mut rgba = Vec::with_capacity((width * height * 4) as usize);
-        for y in 0..height as usize {
-            rgba.extend_from_slice(&padded[y * row as usize..y * row as usize + width as usize * 4]);
-        }
-        if matches!(self.canvas_format, GpuTextureFormat::Bgra8unorm) {
-            for px in rgba.chunks_exact_mut(4) {
-                px.swap(0, 2);
-            }
+        match self.take_pending_capture() {
+            Some(pending) => pending.read().await.map(Some),
+            None => Ok(None),
         }
-        Ok(Some(Capture { width, height, rgba }))
+    }
+
+    /// The captured frame's buffer, to be read without holding the renderer
+    /// across the wait (the browser shell reads it after the turn that drew it).
+    pub fn take_pending_capture(&mut self) -> Option<PendingCapture> {
+        let (buffer, width, height, row) = self.capture.take()?;
+        Some(PendingCapture { buffer, width, height, row, bgra: matches!(self.canvas_format, GpuTextureFormat::Bgra8unorm) })
     }
 
     /// Apply the image queue: uploads, in-place updates, frees.
diff --git a/src/web/shell.rs b/src/web/shell.rs
new file mode 100644
index 0000000..00b7096
--- /dev/null
+++ b/src/web/shell.rs
@@ -0,0 +1,675 @@
+//! 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 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, FocusEvent, HtmlCanvasElement, KeyboardEvent, PointerEvent, WheelEvent};
+
+use super::renderer::{Capture, WebRenderer};
+use crate::backend::app::{set_wake, AppSender, Application, LogicalPosition, LogicalSize};
+use crate::backend::dom::{map_key, wheel_frame};
+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::{context_menu, ElementState, MouseButton, TextItem};
+
+/// 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 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 app = A::create(AppSender::from(tx));
+    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 takes the keyboard (it is focused on a press), 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 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(),
+    };
+    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),
+    });
+    lp.shell.borrow_mut().measure();
+    lp.shell.borrow_mut().just_configured = true;
+    Loop::install(&lp, &canvas)?;
+    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>,
+    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,
+}
+
+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)
+    }
+
+    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);
+        }
+        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;
+            }
+        }
+        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 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) -> 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 = 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();
+        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 _ = c.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 };
+                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();
+            }
+        })?;
+        let l = lp.clone();
+        listen(target, "keydown", true, move |e: KeyboardEvent| l.key(&e, ElementState::Pressed))?;
+        let l = lp.clone();
+        listen(target, "keyup", true, move |e: KeyboardEvent| l.key(&e, ElementState::Released))?;
+        for (name, focused) in [("focus", true), ("blur", false)] {
+            let l = lp.clone();
+            listen(target, name, false, move |_e: FocusEvent| {
+                l.event(|s| {
+                    let (driver, t) = s.turn();
+                    driver.keyboard_focus(t, focused);
+                })
+            })?;
+        }
+        // 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) {
+        let Some((key, text)) = map_key(&e.key(), e.ctrl_key() || e.meta_key()) else { return };
+        if !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;
+        }
+        self.event(|s| {
+            let mods = s.mods_from(e.ctrl_key(), e.shift_key(), e.alt_key(), e.meta_key());
+            s.sync_mods(mods);
+            let (driver, t) = s.turn();
+            driver.key(t, key, text, state);
+        });
+    }
+
+    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));
+        });
+    }
+}
+
+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 --git a/src/widget/input/text_box.rs b/src/widget/input/text_box.rs
index 0fb4647..0b52757 100644
--- a/src/widget/input/text_box.rs
+++ b/src/widget/input/text_box.rs
@@ -30,8 +30,14 @@ static FONT_DB: OnceLock<resvg::usvg::fontdb::Database> = OnceLock::new();
 pub fn get_font_db() -> &'static resvg::usvg::fontdb::Database {
     FONT_DB.get_or_init(|| {
         let mut db = resvg::usvg::fontdb::Database::new();
-        db.load_system_fonts();
-        db.load_fonts_dir(crate::fonts_dir());
+        #[cfg(not(target_arch = "wasm32"))]
+        {
+            db.load_system_fonts();
+            db.load_fonts_dir(crate::fonts_dir());
+        }
+        // A page has neither: the fonts it handed the browser shell.
+        #[cfg(target_arch = "wasm32")]
+        crate::page_fonts::load_into(&mut db);
         db
     })
 }