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

commitb18d43b9d2c1fb3823b750e7902228914a92203d
parent4f81934c19
authorClaude <noreply@anthropic.com>
date2026-10-04 20:28
W2: draw a Frame2D in the browser through WebGPU

src/web/renderer.rs is the Vulkan renderer's 2D path on WebGPU, over
web-sys: batches under their scissors with the parameter block as a
dynamic-offset uniform (256-byte slots, laid out by the shared
batch_push_constants), images interleaved by z_before, the blur-behind
snapshot as end pass / copy / resume, then text from the shared glyph
atlas, then the overlay. The canvas is configured with its unorm format
and drawn through an sRGB view, so it encodes and blends as the
swapchain's *_SRGB format does. A frame can be read back
(capture_next_frame / take_capture) for tests.

The renderer probe holds the two renderers to each other:
examples/probe/scene.rs is one frame of nearly every prim, drawn by
probe_native through Vulkan and by probe_web (a wasm cdylib) through
WebGPU; scripts/web-probe builds the web half, runs it in headless
Chromium on SwiftShader and diffs it against a native screenshot.
Lavapipe against SwiftShader: 96.8% of channels equal, all others
within 2 levels but one at 3.

web-sys's WebGPU bindings need --cfg=web_sys_unstable_apis, set for
the wasm target in .cargo/config.toml; check-wasm also checks the
probe's browser half. Native is untouched: the same two known test
failures, the plate golden byte-identical, and the 24-step scripted
run identical to the pixel.

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

 .cargo/config.toml            |   5 +
 CLAUDE.md                     |  32 ++
 Cargo.lock                    |   4 +
 Cargo.toml                    |  92 +++++
 examples/probe/scene.rs       | 147 ++++++++
 examples/probe_native.rs      |  10 +
 examples/probe_web.rs         |  52 +++
 scripts/check-wasm            |   5 +
 scripts/web-probe/capture.mjs |  54 +++
 scripts/web-probe/compare.py  |  31 ++
 scripts/web-probe/page.html   |  19 +
 scripts/web-probe/run         |  30 ++
 src/lib.rs                    |   2 +
 src/web/mod.rs                |  12 +
 src/web/renderer.rs           | 786 ++++++++++++++++++++++++++++++++++++++++++
 15 files changed, 1281 insertions(+)

diff --git a/.cargo/config.toml b/.cargo/config.toml
new file mode 100644
index 0000000..a58289c
--- /dev/null
+++ b/.cargo/config.toml
@@ -0,0 +1,5 @@
+# web-sys still marks its WebGPU bindings unstable: the browser renderer
+# (src/web) needs them switched on for the wasm target. A client crate that
+# builds cce-ui for the browser needs the same line in its own config.
+[target.wasm32-unknown-unknown]
+rustflags = ["--cfg=web_sys_unstable_apis"]
diff --git a/CLAUDE.md b/CLAUDE.md
index 76e6aa6..9547604 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -70,6 +70,33 @@ the browser), and reaches what a renderer draws through `crate::draw`, not `crat
 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. There is no browser shell yet (W3): nothing drives `Pacer::turn` or feeds
+the page's events in.
+
+**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 (`capture.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").
+
 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.
@@ -1140,6 +1167,11 @@ cce-system-interface) to confirm behavior, not just the test suite.
   `textureSampleLevel(…, 0.0)` because WebGPU rejects implicit-LOD sampling in the
   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.
 - `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.lock b/Cargo.lock
index aecb135..e4b50b8 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -362,6 +362,7 @@ dependencies = [
  "cursor-icon",
  "glam",
  "gpu-allocator",
+ "js-sys",
  "kdl",
  "libc",
  "log",
@@ -373,10 +374,13 @@ dependencies = [
  "serde",
  "serde_json",
  "smithay-client-toolkit",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
  "wayland-backend",
  "wayland-client",
  "wayland-protocols 0.31.2",
  "wayland-scanner",
+ "web-sys",
  "web-time",
  "xkeysym",
 ]
diff --git a/Cargo.toml b/Cargo.toml
index 42e03f9..797f7c0 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -64,3 +64,95 @@ wayland-scanner = "0.31"
 xkeysym = "0.2"
 libc = "0.2"
 rfd = "0.15"
+
+# The browser shell and renderer (W2/W3 of the web port): WebGPU through
+# web-sys, the page through wasm-bindgen.
+[target.'cfg(target_arch = "wasm32")'.dependencies]
+wasm-bindgen = "0.2"
+wasm-bindgen-futures = "0.4"
+js-sys = "0.3"
+web-sys = { version = "0.3", features = [
+    "console",
+    "Window",
+    "Navigator",
+    "Document",
+    "Element",
+    "HtmlCanvasElement",
+    "Gpu",
+    "GpuAdapter",
+    "GpuDevice",
+    "GpuQueue",
+    "GpuCanvasContext",
+    "GpuCanvasConfiguration",
+    "GpuCanvasAlphaMode",
+    "GpuTextureFormat",
+    "GpuShaderModule",
+    "GpuShaderModuleDescriptor",
+    "GpuBindGroupLayout",
+    "GpuBindGroupLayoutDescriptor",
+    "GpuBindGroupLayoutEntry",
+    "GpuBufferBindingLayout",
+    "GpuBufferBindingType",
+    "GpuTextureBindingLayout",
+    "GpuTextureSampleType",
+    "GpuTextureViewDimension",
+    "GpuSamplerBindingLayout",
+    "GpuSamplerBindingType",
+    "GpuPipelineLayout",
+    "GpuPipelineLayoutDescriptor",
+    "GpuRenderPipeline",
+    "GpuRenderPipelineDescriptor",
+    "GpuVertexState",
+    "GpuVertexBufferLayout",
+    "GpuVertexAttribute",
+    "GpuVertexFormat",
+    "GpuVertexStepMode",
+    "GpuFragmentState",
+    "GpuColorTargetState",
+    "GpuBlendState",
+    "GpuBlendComponent",
+    "GpuBlendFactor",
+    "GpuBlendOperation",
+    "GpuPrimitiveState",
+    "GpuPrimitiveTopology",
+    "GpuBindGroup",
+    "GpuBindGroupDescriptor",
+    "GpuBindGroupEntry",
+    "GpuBufferBinding",
+    "GpuBuffer",
+    "GpuBufferDescriptor",
+    "GpuTexture",
+    "GpuTextureDescriptor",
+    "GpuTextureDimension",
+    "GpuTextureView",
+    "GpuTextureViewDescriptor",
+    "GpuSampler",
+    "GpuSamplerDescriptor",
+    "GpuAddressMode",
+    "GpuFilterMode",
+    "GpuMipmapFilterMode",
+    "GpuCommandEncoder",
+    "GpuCommandEncoderDescriptor",
+    "GpuCommandBuffer",
+    "GpuRenderPassEncoder",
+    "GpuRenderPassDescriptor",
+    "GpuRenderPassColorAttachment",
+    "GpuLoadOp",
+    "GpuStoreOp",
+    "GpuColorDict",
+    "GpuExtent3dDict",
+    "GpuOrigin3dDict",
+    "GpuTexelCopyTextureInfo",
+    "GpuTexelCopyBufferInfo",
+    "GpuTexelCopyBufferLayout",
+    "gpu_buffer_usage",
+    "gpu_texture_usage",
+    "gpu_shader_stage",
+    "gpu_map_mode",
+] }
+
+# The renderer probe: one scene through both renderers (examples/probe/).
+# probe_web is the browser half, a cdylib for wasm-bindgen; natively it is empty.
+[[example]]
+name = "probe_web"
+crate-type = ["cdylib"]
diff --git a/examples/probe/scene.rs b/examples/probe/scene.rs
new file mode 100644
index 0000000..99a1e6c
--- /dev/null
+++ b/examples/probe/scene.rs
@@ -0,0 +1,147 @@
+//! The renderer probe's scene: one app drawn by both renderers, natively in
+//! the Wayland runner (`probe_native`) and in a browser through the WebGPU
+//! renderer (`probe_web`), so the two can be compared pixel for pixel.
+//!
+//! It reaches every path a frame takes: the root plate and its roll, flat
+//! fills, a checker under frosted plates in all three frost recipes (the
+//! blur snapshot), raised / flush / flat control plates, fields in each form,
+//! recesses, bevels, a sphere, grooves, circles and capped vectors, text in
+//! two families at four sizes, and an uploaded image drawn at two sizes.
+
+use cce_ui::engine::{AppSender, Application, LogicalPosition, LogicalSize, WindowSettings};
+use cce_ui::scene::layout::Rect;
+use cce_ui::scene::paint::{Cap, ControlPlate, DisplayList, Field, PaintCtx, PlateSpec, PlateStance};
+use cce_ui::scene::{Frost, Material};
+use cce_ui::widget::{ElementState, KeyEvent, MouseButton, MouseScrollDelta};
+
+pub const W: u32 = 1280;
+pub const H: u32 = 800;
+
+pub struct ProbeApp {
+    image: u32,
+}
+
+fn r(x: f32, y: f32, w: f32, h: f32) -> Rect {
+    Rect { x, y, width: w, height: h }
+}
+
+const SANS: &str = "DejaVu Sans";
+const MONO: &str = "DejaVu Sans Mono";
+
+impl Application for ProbeApp {
+    type Message = ();
+
+    fn create(_sender: AppSender<()>) -> Self {
+        cce_ui::scale::set_scale_factor(1.0);
+        let (iw, ih) = (64u32, 40u32);
+        let mut px = Vec::with_capacity((iw * ih * 4) as usize);
+        for y in 0..ih {
+            for x in 0..iw {
+                px.extend([(x * 255 / (iw - 1)) as u8, (y * 255 / (ih - 1)) as u8, 160, 255]);
+            }
+        }
+        ProbeApp { image: cce_ui::draw::upload_rgba(px, iw, ih) }
+    }
+
+    fn settings(&self) -> WindowSettings {
+        WindowSettings { title: "probe".into(), app_id: "cce-probe".into(), width: W, height: H, fullscreen: false, min_size: None }
+    }
+
+    fn load_system_fonts(&self) -> bool {
+        true
+    }
+
+    fn display_list_text(&self) -> bool {
+        true
+    }
+
+    fn update(&mut self, _: (), _: &mut bool, _: &mut bool) {}
+    fn tick(&mut self, _: f32, _: &mut bool) {}
+
+    fn display_list(&mut self, size: LogicalSize, scale: f64) -> Option<DisplayList> {
+        cce_ui::scale::set_scale_factor(scale as f32);
+        let (w, h) = (size.width, size.height);
+        let mut pc = PaintCtx::new();
+        pc.root_plate(w, h);
+
+        // A checker under three frosted plates: unfrosted-compression,
+        // compressed, and clear (frost_pair's recipes).
+        for i in 0..16 {
+            let c = if i % 2 == 0 { [1.0, 1.0, 1.0, 1.0] } else { [0.02, 0.02, 0.03, 1.0] };
+            pc.quad(r(30.0 + i as f32 * 30.0, 30.0, 30.0, 220.0), c);
+        }
+        let tint = [0.0015, 0.0015, 0.0024, 0.25];
+        for (i, (k, radius)) in [(0.0, Frost::DEFAULT_RADIUS), (0.85, Frost::DEFAULT_RADIUS), (0.0, 0.0)].iter().enumerate() {
+            let m = Material::opaque(tint).with_frost(Frost::Frosted { compression: *k, refraction: 0.0, radius: *radius });
+            pc.plate_spec(&PlateSpec {
+                rect: r(40.0 + i as f32 * 155.0, 60.0, 140.0, 160.0),
+                material: m,
+                window_corners: (false, false, false, false),
+                depth: 6.0,
+            });
+        }
+
+        // A pane plate with the control vocabulary on it.
+        pc.plate_spec(&PlateSpec {
+            rect: r(540.0, 30.0, 700.0, 330.0),
+            material: Material::pane(),
+            window_corners: (false, false, false, false),
+            depth: cce_ui::layout::bevel_width(),
+        });
+        let face = Some(Material::opaque([0.18, 0.22, 0.32, 1.0]));
+        pc.control_plate(&ControlPlate::control(r(570.0, 60.0, 140.0, 36.0), 8.0, PlateStance::Raised, face.clone()));
+        pc.control_plate(&ControlPlate::control(r(730.0, 60.0, 140.0, 36.0), 8.0, PlateStance::Raised, None));
+        pc.control_plate(&ControlPlate::control(r(890.0, 60.0, 140.0, 36.0), 8.0, PlateStance::Flush, None));
+        pc.control_plate(&ControlPlate::control(r(1050.0, 60.0, 140.0, 36.0), 8.0, PlateStance::Flat, face));
+        let radii = (8.0, 8.0, 8.0, 8.0);
+        pc.field(&Field::well(r(570.0, 120.0, 300.0, 34.0), radii, 6.0));
+        pc.field(&Field::ending_in_run(r(890.0, 120.0, 300.0, 34.0), radii, 6.0, 1150.0));
+        pc.field(&Field::sliding_run(r(570.0, 180.0, 90.0, 34.0), (17.0, 17.0, 17.0, 17.0), 6.0, 45.0, 0.0));
+        pc.field(&Field::sliding_run(r(680.0, 180.0, 90.0, 34.0), (17.0, 17.0, 17.0, 17.0), 6.0, 45.0, 1.0));
+        pc.recess(r(800.0, 180.0, 120.0, 60.0), (12.0, 12.0, 12.0, 12.0), 8.0);
+        pc.bevel(r(950.0, 180.0, 120.0, 60.0), (12.0, 12.0, 12.0, 12.0), &Material::opaque([0.35, 0.30, 0.42, 1.0]), 8.0);
+        pc.sphere(1140.0, 210.0, 30.0, &Material::opaque([0.55, 0.25, 0.25, 1.0]));
+        let host = r(540.0, 30.0, 700.0, 330.0);
+        pc.groove((570.0, 280.0), (1210.0, 280.0), 4.0, 3.0, host);
+        pc.groove((600.0, 300.0), (760.0, 340.0), 4.0, 3.0, host);
+
+        // Flat geometry: circles and capped vectors.
+        for (i, cap) in [Cap::Flat, Cap::Round, Cap::Arrow].into_iter().enumerate() {
+            let y = 300.0 + i as f32 * 25.0;
+            pc.vector(820.0, y, 1000.0, y + 10.0, 5.0, [0.9, 0.6, 0.2, 1.0], cap);
+        }
+        pc.circle(1080.0, 320.0, 22.0, [0.2, 0.7, 0.5, 1.0]);
+        pc.circle(1150.0, 320.0, 22.0, [0.2, 0.5, 0.9, 0.6]);
+
+        // Text, two families at four sizes.
+        let mut y = 400.0;
+        for (i, size) in [11.0, 14.0, 20.0, 28.0].into_iter().enumerate() {
+            let family = if i % 2 == 0 { SANS } else { MONO };
+            pc.text_with(
+                format!("The quick brown fox jumps over the lazy dog — {size}px {family}"),
+                40.0,
+                y,
+                size,
+                [230, 230, 236],
+                Some(family.to_string()),
+                None,
+            );
+            y += size * 1.8;
+        }
+
+        // The image, at its size and scaled up.
+        pc.image(self.image, r(40.0, 620.0, 64.0, 40.0), 1.0);
+        pc.image(self.image, r(130.0, 600.0, 256.0, 160.0), 0.85);
+
+        Some(pc.finish())
+    }
+
+    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
+    }
+}
diff --git a/examples/probe_native.rs b/examples/probe_native.rs
new file mode 100644
index 0000000..783b75d
--- /dev/null
+++ b/examples/probe_native.rs
@@ -0,0 +1,10 @@
+//! The renderer probe's scene in the native Wayland runner — see
+//! `examples/probe/scene.rs`. Screenshot it in a shadow (or headless) session
+//! and compare with `probe_web`'s capture of the same scene.
+
+#[path = "probe/scene.rs"]
+mod scene;
+
+fn main() {
+    cce_ui::engine::run::<scene::ProbeApp>();
+}
diff --git a/examples/probe_web.rs b/examples/probe_web.rs
new file mode 100644
index 0000000..1f48a0d
--- /dev/null
+++ b/examples/probe_web.rs
@@ -0,0 +1,52 @@
+//! The renderer probe's scene through the WebGPU renderer, in a browser —
+//! see `examples/probe/scene.rs`. Built for wasm32 as a cdylib and bound with
+//! wasm-bindgen; a page calls `probe(canvas, fonts)` and gets the frame back
+//! as RGBA8 bytes, the canvas's own pixels read back from the GPU.
+//!
+//! On a native target this example is empty.
+
+#[cfg(target_arch = "wasm32")]
+#[path = "probe/scene.rs"]
+mod scene;
+
+#[cfg(target_arch = "wasm32")]
+mod web {
+    use super::scene::{ProbeApp, H, W};
+    use cce_ui::backend::frame::build_frame;
+    use cce_ui::engine::{AppSender, Application, LogicalSize};
+    use cce_ui::web::WebRenderer;
+    use wasm_bindgen::prelude::*;
+
+    /// Render the probe scene into `canvas` with the fonts in `fonts` (each a
+    /// font file's bytes) and return the frame as RGBA8.
+    #[wasm_bindgen]
+    pub async fn probe(canvas: web_sys::HtmlCanvasElement, fonts: js_sys::Array) -> Result<js_sys::Uint8Array, JsValue> {
+        std::panic::set_hook(Box::new(|info| web_sys::console::error_1(&info.to_string().into())));
+
+        let mut db = cce_ui::cosmic_text::fontdb::Database::new();
+        for font in fonts.iter() {
+            db.load_font_data(js_sys::Uint8Array::new(&font).to_vec());
+        }
+        let mut fs = cce_ui::cosmic_text::FontSystem::new_with_locale_and_db("en-US".into(), db);
+        let mut swash = cce_ui::cosmic_text::SwashCache::new();
+
+        let mut renderer = WebRenderer::new(canvas).await?;
+        renderer.resize(W, H);
+        cce_ui::scale::set_scale_factor(1.0);
+        let (tx, _rx) = std::sync::mpsc::channel();
+        let mut app = ProbeApp::create(AppSender::from(tx));
+
+        let mut owed = true;
+        let mut items = Vec::new();
+        let size = LogicalSize::new(W as f32, H as f32);
+        let frame = build_frame(&mut app, &mut fs, size, 1.0, &mut owed, &mut items);
+        if frame.dl_text {
+            let spans = frame.text_spans(&items);
+            renderer.prepare_text(&mut fs, &mut swash, &spans);
+        }
+        renderer.capture_next_frame();
+        renderer.draw_frame_2d(frame.frame2d())?;
+        let capture = renderer.take_capture().await?.ok_or("no frame was captured")?;
+        Ok(js_sys::Uint8Array::from(&capture.rgba[..]))
+    }
+}
diff --git a/scripts/check-wasm b/scripts/check-wasm
index 581ac62..079f187 100755
--- a/scripts/check-wasm
+++ b/scripts/check-wasm
@@ -4,6 +4,10 @@
 # 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;
+# 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.
 set -euo pipefail
 cd "$(dirname "$0")/.."
 if ! rustup target list --installed 2>/dev/null | grep -qx wasm32-unknown-unknown; then
@@ -13,4 +17,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
 echo "check-wasm: ok"
diff --git a/scripts/web-probe/capture.mjs b/scripts/web-probe/capture.mjs
new file mode 100644
index 0000000..9952619
--- /dev/null
+++ b/scripts/web-probe/capture.mjs
@@ -0,0 +1,54 @@
+// 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';
+import fs from 'fs';
+import path from 'path';
+
+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}/`);
+await page.waitForFunction(() => window.probeReady === true);
+let ok = true;
+try {
+  const b64 = await page.evaluate(() => window.runProbe());
+  fs.writeFileSync(out, Buffer.from(b64, 'base64'));
+  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();
+process.exit(ok ? 0 : 1);
diff --git a/scripts/web-probe/compare.py b/scripts/web-probe/compare.py
new file mode 100755
index 0000000..fe355c0
--- /dev/null
+++ b/scripts/web-probe/compare.py
@@ -0,0 +1,31 @@
+#!/usr/bin/env python3
+"""compare.py native.png web.rgba W H [bg]: 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`."""
+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
+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):
+    a = web[4 * i + 3]
+    for c in range(3):
+        w = web[4 * i + c] + bg * (255 - a) / 255.0
+        d = abs(round(w) - native[3 * i + c])
+        hist[d] = hist.get(d, 0) + 1
+        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("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/page.html b/scripts/web-probe/page.html
new file mode 100644
index 0000000..eaef4c4
--- /dev/null
+++ b/scripts/web-probe/page.html
@@ -0,0 +1,19 @@
+<!doctype html>
+<meta charset="utf-8">
+<title>cce-ui WebGPU probe</title>
+<style>html,body{margin:0;background:#000}canvas{display:block;width:1280px;height:800px}</style>
+<canvas id="c" width="1280" height="800"></canvas>
+<script type="module">
+import init, { probe } from './probe_web.js';
+window.runProbe = async () => {
+  await init();
+  const names = await (await fetch('fonts.json')).json();
+  const fonts = [];
+  for (const n of names) fonts.push(new Uint8Array(await (await fetch('fonts/' + n)).arrayBuffer()));
+  const rgba = await probe(document.getElementById('c'), fonts);
+  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.probeReady = true;
+</script>
diff --git a/scripts/web-probe/run b/scripts/web-probe/run
new file mode 100755
index 0000000..6a5c167
--- /dev/null
+++ b/scripts/web-probe/run
@@ -0,0 +1,30 @@
+#!/usr/bin/env bash
+# web-probe/run <out.rgba> — draw the renderer probe (examples/probe/scene.rs)
+# through the WebGPU renderer in headless Chromium and write the frame it read
+# back from the GPU: 1280x800 premultiplied RGBA8.
+#
+# Its other half is the same scene through the Vulkan renderer,
+# `cargo run --example probe_native`, screenshotted in a compositor; compare.py
+# composites this frame over that compositor's background and diffs the two.
+#
+# Needs: the wasm32-unknown-unknown target, wasm-bindgen-cli at the version in
+# Cargo.lock, node with Playwright (see capture.mjs), and the DejaVu fonts
+# ($PROBE_FONTS_DIR, default /usr/share/fonts/truetype/dejavu) — the native
+# half must see the same fonts, or text is the difference you measure.
+set -euo pipefail
+here=$(cd "$(dirname "$0")" && pwd)
+out=$(realpath -m "${1:?usage: web-probe/run <out.rgba>}")
+fonts=${PROBE_FONTS_DIR:-/usr/share/fonts/truetype/dejavu}
+cd "$here/../.."
+
+cargo build --release --target wasm32-unknown-unknown --example probe_web
+site=$(mktemp -d)
+trap 'rm -rf "$site"' EXIT
+wasm-bindgen --target web --no-typescript --out-dir "$site" \
+    target/wasm32-unknown-unknown/release/examples/probe_web.wasm
+cp "$here/page.html" "$site/index.html"
+mkdir "$site/fonts"
+cp "$fonts"/*.ttf "$site/fonts/"
+(cd "$site/fonts" && printf '%s\n' *.ttf | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read().split()))') > "$site/fonts.json"
+node "$here/capture.mjs" "$site" "$out"
+echo "web-probe: wrote $out"
diff --git a/src/lib.rs b/src/lib.rs
index fdab53f..6b62710 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -30,6 +30,8 @@ pub mod mcp;
 pub mod motion;
 #[cfg(not(target_arch = "wasm32"))]
 pub mod vk;
+#[cfg(target_arch = "wasm32")]
+pub mod web;
 
 pub mod colors {
     pub use crate::color::*;
diff --git a/src/web/mod.rs b/src/web/mod.rs
new file mode 100644
index 0000000..c71e053
--- /dev/null
+++ b/src/web/mod.rs
@@ -0,0 +1,12 @@
+//! 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).
+//!
+//! 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;
+
+pub use renderer::{Capture, WebRenderer};
diff --git a/src/web/renderer.rs b/src/web/renderer.rs
new file mode 100644
index 0000000..3f7d7aa
--- /dev/null
+++ b/src/web/renderer.rs
@@ -0,0 +1,786 @@
+//! `WebRenderer`: the Vulkan renderer's 2D path, on WebGPU.
+//!
+//! It draws a [`Frame2D`] the way `vk::VkRenderer::draw_frame_2d` does, batch
+//! for batch: the display-list geometry under each batch's scissor with its
+//! parameter block (a dynamic-offset uniform here, push constants there),
+//! user images interleaved by `z_before`, a blur-behind batch preceded by a
+//! snapshot of the frame so far (the pass ends, the target is copied, the
+//! pass resumes), then the text, then the overlay geometry. What differs is
+//! only what WebGPU has instead:
+//!
+//! - **The canvas.** A WebGPU canvas takes no sRGB format, so it is
+//!   configured with its preferred (unorm) format and an sRGB *view* format,
+//!   and drawn through the sRGB view: the hardware encodes on write and blends
+//!   in linear, as the Vulkan swapchain's `*_SRGB` format does.
+//! - **The parameter block** is a uniform at `@group(1)`, one 256-byte slot
+//!   per batch (`draw::shaders::WEBGPU_BLOCK_STRIDE`), plus a zero slot for
+//!   the overlay — laid out by the same `draw::batch_push_constants`.
+//! - **The backdrop.** With no 3D scene behind the UI, the Vulkan renderer's
+//!   backdrop is a cleared image; here it is a 1x1 transparent texture (the
+//!   shader samples it clamped, so every texel reads the same zero). The
+//!   blur snapshot is a full-size texture of the canvas's sRGB view format.
+//! - **No damage.** Every frame is drawn whole (`Frame2D::damage` is a
+//!   promise the renderer may use, never a requirement).
+//! - **Images have one mip level.** A mipmapped upload is uploaded plain, as
+//!   the Vulkan renderer does on a device that cannot blit mips.
+
+use std::collections::HashMap;
+
+use wasm_bindgen::{JsCast, JsValue};
+use web_sys::{
+    gpu_buffer_usage as buffer_usage, gpu_map_mode as map_mode, gpu_shader_stage as shader_stage,
+    gpu_texture_usage as texture_usage, GpuAddressMode, GpuBindGroup, GpuBindGroupDescriptor,
+    GpuBindGroupEntry, GpuBindGroupLayout, GpuBindGroupLayoutDescriptor, GpuBindGroupLayoutEntry,
+    GpuBlendComponent, GpuBlendFactor, GpuBlendOperation, GpuBlendState, GpuBuffer, GpuBufferBinding,
+    GpuBufferBindingLayout, GpuBufferBindingType, GpuBufferDescriptor, GpuCanvasAlphaMode,
+    GpuCanvasConfiguration, GpuCanvasContext, GpuColorTargetState, GpuCommandEncoder, GpuDevice,
+    GpuExtent3dDict, GpuFilterMode, GpuFragmentState, GpuLoadOp, GpuMipmapFilterMode,
+    GpuPipelineLayoutDescriptor, GpuPrimitiveState, GpuPrimitiveTopology, GpuQueue,
+    GpuRenderPassColorAttachment, GpuRenderPassDescriptor, GpuRenderPassEncoder, GpuRenderPipeline,
+    GpuRenderPipelineDescriptor, GpuSampler, GpuSamplerBindingLayout, GpuSamplerBindingType,
+    GpuSamplerDescriptor, GpuShaderModuleDescriptor, GpuStoreOp, GpuTexelCopyBufferInfo,
+    GpuTexelCopyBufferLayout, GpuTexelCopyTextureInfo, GpuTexture, GpuTextureBindingLayout,
+    GpuTextureDescriptor, GpuTextureFormat, GpuTextureSampleType, GpuTextureView,
+    GpuTextureViewDescriptor, GpuTextureViewDimension, GpuVertexAttribute, GpuVertexBufferLayout,
+    GpuVertexFormat, GpuVertexState, HtmlCanvasElement,
+};
+
+use crate::backend::tessellate::Vertex;
+use crate::draw::glyphs::{image_quad_vertices, GlyphAtlas, GlyphVertex, ATLAS_SIZE};
+use crate::draw::images::{image_table_built, retire_buffer, take_pending, Pending, PixelFormat};
+use crate::draw::shaders::{shader2d_for_webgpu, GLYPH, WEBGPU_BLOCK_STRIDE};
+use crate::draw::{
+    batch_push_constants, relief_px_at, window_info_data, Batch2D, Frame2D, TextSpan, MAX_PLATE_FEATURES,
+    PLATE_FEATURE_BYTES, PUSH_CONSTANT_FLOATS, WINDOW_INFO_BYTES,
+};
+
+/// The shader's `PlateFeatures` array is 128 entries; the binding must cover
+/// all of it even though a frame uses at most [`MAX_PLATE_FEATURES`].
+const PLATE_FEATURES_BINDING_BYTES: usize = 128 * PLATE_FEATURE_BYTES;
+
+/// One uploaded user image.
+struct WebImage {
+    texture: GpuTexture,
+    group: GpuBindGroup,
+    width: u32,
+    height: u32,
+    format: PixelFormat,
+}
+
+/// A grow-only GPU buffer: replaced by a larger one when a frame needs more.
+struct Growable {
+    buffer: GpuBuffer,
+    size: u32,
+    usage: u32,
+    label: &'static str,
+}
+
+impl Growable {
+    fn new(device: &GpuDevice, size: u32, usage: u32, label: &'static str) -> Result<Self, JsValue> {
+        let size = size.max(256).next_power_of_two();
+        let desc = GpuBufferDescriptor::new(size, usage | buffer_usage::COPY_DST);
+        desc.set_label(label);
+        Ok(Self { buffer: device.create_buffer(&desc)?, size, usage, label })
+    }
+
+    /// Make room for `needed` bytes; true when the buffer was replaced (any
+    /// bind group naming it must be rebuilt).
+    fn ensure(&mut self, device: &GpuDevice, needed: u32) -> Result<bool, JsValue> {
+        if needed <= self.size {
+            return Ok(false);
+        }
+        self.buffer.destroy();
+        *self = Self::new(device, needed, self.usage, self.label)?;
+        Ok(true)
+    }
+
+    fn write(&self, queue: &GpuQueue, bytes: &[u8]) -> Result<(), JsValue> {
+        if !bytes.is_empty() {
+            queue.write_buffer_with_u32_and_u8_slice(&self.buffer, 0, bytes)?;
+        }
+        Ok(())
+    }
+}
+
+/// A frame read back from the canvas: `width` x `height` RGBA8, sRGB-encoded
+/// — the bytes the Vulkan swapchain image would hold.
+pub struct Capture {
+    pub width: u32,
+    pub height: u32,
+    pub rgba: Vec<u8>,
+}
+
+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.
+    _gpu: web_sys::Gpu,
+    _adapter: web_sys::GpuAdapter,
+    device: GpuDevice,
+    queue: GpuQueue,
+    canvas: HtmlCanvasElement,
+    context: GpuCanvasContext,
+    /// What the canvas is configured as, and the sRGB view it is drawn through.
+    canvas_format: GpuTextureFormat,
+    view_format: GpuTextureFormat,
+
+    pipeline_2d: GpuRenderPipeline,
+    layout_0: GpuBindGroupLayout,
+    layout_1: GpuBindGroupLayout,
+    window_info: GpuBuffer,
+    plate_features: GpuBuffer,
+    backdrop_sampler: GpuSampler,
+    /// `@group(0)` over the empty backdrop, and over the blur snapshot.
+    group_0: GpuBindGroup,
+    snapshot: Option<(GpuTexture, GpuBindGroup, u32, u32)>,
+    blocks: Growable,
+    group_1: GpuBindGroup,
+    vertices: Growable,
+
+    glyph_pipeline: GpuRenderPipeline,
+    glyph_layout: GpuBindGroupLayout,
+    atlas: GlyphAtlas,
+    atlas_texture: GpuTexture,
+    atlas_group: GpuBindGroup,
+    atlas_uploaded: u64,
+    glyph_vertices: Growable,
+
+    image_sampler: GpuSampler,
+    images: HashMap<u32, WebImage>,
+    image_vertices: Growable,
+
+    /// Copy the next frame into this, for [`take_capture`](Self::take_capture).
+    capture: Option<(GpuBuffer, u32, u32, u32)>,
+    capture_requested: bool,
+}
+
+fn shader_module(device: &GpuDevice, code: &str, label: &str) -> web_sys::GpuShaderModule {
+    let desc = GpuShaderModuleDescriptor::new(code);
+    desc.set_label(label);
+    device.create_shader_module(&desc)
+}
+
+/// `wgpu::BlendState::ALPHA_BLENDING`, as both Vulkan pipelines blend.
+fn alpha_blending() -> GpuBlendState {
+    let color = GpuBlendComponent::new();
+    color.set_src_factor(GpuBlendFactor::SrcAlpha);
+    color.set_dst_factor(GpuBlendFactor::OneMinusSrcAlpha);
+    color.set_operation(GpuBlendOperation::Add);
+    let alpha = GpuBlendComponent::new();
+    alpha.set_src_factor(GpuBlendFactor::One);
+    alpha.set_dst_factor(GpuBlendFactor::OneMinusSrcAlpha);
+    alpha.set_operation(GpuBlendOperation::Add);
+    GpuBlendState::new(&alpha, &color)
+}
+
+fn uniform_entry(binding: u32, min_size: u32, dynamic: bool) -> GpuBindGroupLayoutEntry {
+    let entry = GpuBindGroupLayoutEntry::new(binding, shader_stage::FRAGMENT);
+    let layout = GpuBufferBindingLayout::new();
+    layout.set_type(GpuBufferBindingType::Uniform);
+    layout.set_min_binding_size(min_size);
+    layout.set_has_dynamic_offset(dynamic);
+    entry.set_buffer(&layout);
+    entry
+}
+
+fn texture_entry(binding: u32) -> GpuBindGroupLayoutEntry {
+    let entry = GpuBindGroupLayoutEntry::new(binding, shader_stage::FRAGMENT);
+    let layout = GpuTextureBindingLayout::new();
+    layout.set_sample_type(GpuTextureSampleType::Float);
+    layout.set_view_dimension(GpuTextureViewDimension::N2d);
+    entry.set_texture(&layout);
+    entry
+}
+
+fn sampler_entry(binding: u32) -> GpuBindGroupLayoutEntry {
+    let entry = GpuBindGroupLayoutEntry::new(binding, shader_stage::FRAGMENT);
+    let layout = GpuSamplerBindingLayout::new();
+    layout.set_type(GpuSamplerBindingType::Filtering);
+    entry.set_sampler(&layout);
+    entry
+}
+
+fn sampler(device: &GpuDevice, filter: GpuFilterMode, mipmap: GpuMipmapFilterMode) -> GpuSampler {
+    let desc = GpuSamplerDescriptor::new();
+    desc.set_address_mode_u(GpuAddressMode::ClampToEdge);
+    desc.set_address_mode_v(GpuAddressMode::ClampToEdge);
+    desc.set_address_mode_w(GpuAddressMode::ClampToEdge);
+    desc.set_mag_filter(filter);
+    desc.set_min_filter(filter);
+    desc.set_mipmap_filter(mipmap);
+    device.create_sampler_with_descriptor(&desc)
+}
+
+fn texture(device: &GpuDevice, format: GpuTextureFormat, w: u32, h: u32, usage: u32, label: &str) -> Result<GpuTexture, JsValue> {
+    let size = [js_sys::Number::from(w.max(1)), js_sys::Number::from(h.max(1))];
+    let desc = GpuTextureDescriptor::new(format, &size, usage);
+    desc.set_label(label);
+    device.create_texture(&desc)
+}
+
+fn whole_view(texture: &GpuTexture) -> Result<GpuTextureView, JsValue> {
+    texture.create_view()
+}
+
+fn extent(w: u32, h: u32) -> GpuExtent3dDict {
+    let e = GpuExtent3dDict::new(w);
+    e.set_height(h);
+    e
+}
+
+/// Write `pixels` (`w` x `h`, 4 bytes a texel, rows packed) into `texture`.
+fn write_texture(queue: &GpuQueue, texture: &GpuTexture, pixels: &[u8], w: u32, h: u32) -> Result<(), JsValue> {
+    let layout = GpuTexelCopyBufferLayout::new();
+    layout.set_bytes_per_row(w * 4);
+    layout.set_rows_per_image(h);
+    queue.write_texture_with_u8_slice_and_gpu_extent_3d_dict(
+        &GpuTexelCopyTextureInfo::new(texture),
+        pixels,
+        &layout,
+        &extent(w, h),
+    )
+}
+
+/// A two-entry (texture, sampler) bind group for the glyph shader.
+fn texture_group(device: &GpuDevice, layout: &GpuBindGroupLayout, view: &GpuTextureView, sampler: &GpuSampler) -> GpuBindGroup {
+    let entries = [GpuBindGroupEntry::new_with_gpu_texture_view(0, view), GpuBindGroupEntry::new(1, sampler)];
+    device.create_bind_group(&GpuBindGroupDescriptor::new(&entries, layout))
+}
+
+fn uniform_binding(buffer: &GpuBuffer, size: u32) -> GpuBufferBinding {
+    let binding = GpuBufferBinding::new(buffer);
+    binding.set_size(size);
+    binding
+}
+
+impl WebRenderer {
+    /// Ask the browser for a WebGPU device and set `canvas` up to draw into.
+    pub async fn new(canvas: HtmlCanvasElement) -> Result<Self, JsValue> {
+        let window = web_sys::window().ok_or("no window")?;
+        let gpu = window.navigator().gpu();
+        let adapter = gpu
+            .request_adapter()
+            .await?
+            .into_option()
+            .ok_or("this browser offers no WebGPU adapter")?;
+        let device: GpuDevice = adapter.request_device().await?;
+        // Report what the device rejects and why it was lost, on the console:
+        // a WebGPU validation error is otherwise silent.
+        js_sys::Function::new_with_args(
+            "d",
+            "d.onuncapturederror = (e) => console.error('cce-ui WebGPU:', e.error.message); \
+             d.lost.then((i) => console.error('cce-ui WebGPU device lost:', i.reason, i.message));",
+        )
+        .call1(&JsValue::NULL, &device)?;
+        let queue = device.queue();
+        let context: GpuCanvasContext = canvas
+            .get_context("webgpu")?
+            .ok_or("the canvas has no webgpu context")?
+            .dyn_into()?;
+
+        let canvas_format = gpu.get_preferred_canvas_format();
+        let view_format = match canvas_format {
+            GpuTextureFormat::Bgra8unorm => GpuTextureFormat::Bgra8unormSrgb,
+            GpuTextureFormat::Rgba8unorm => GpuTextureFormat::Rgba8unormSrgb,
+            other => other,
+        };
+        let config = GpuCanvasConfiguration::new(&device, canvas_format);
+        config.set_usage(texture_usage::RENDER_ATTACHMENT | texture_usage::COPY_SRC);
+        config.set_view_formats(&[js_sys::JsString::from(JsValue::from(view_format))]);
+        config.set_alpha_mode(GpuCanvasAlphaMode::Premultiplied);
+        context.configure(&config)?;
+
+        // The 2D pipeline: shader2d with its parameter block as a uniform.
+        let layout_0 = device.create_bind_group_layout(&GpuBindGroupLayoutDescriptor::new(&[
+            texture_entry(0),
+            sampler_entry(1),
+            uniform_entry(2, WINDOW_INFO_BYTES as u32, false),
+            uniform_entry(3, PLATE_FEATURES_BINDING_BYTES as u32, false),
+        ]))?;
+        let layout_1 = device.create_bind_group_layout(&GpuBindGroupLayoutDescriptor::new(&[uniform_entry(
+            0,
+            (PUSH_CONSTANT_FLOATS * 4) as u32,
+            true,
+        )]))?;
+        let layout_2d = device.create_pipeline_layout(&GpuPipelineLayoutDescriptor::new(&[
+            js_sys::JsOption::wrap(layout_0.clone()),
+            js_sys::JsOption::wrap(layout_1.clone()),
+        ]));
+        let module_2d = shader_module(&device, &shader2d_for_webgpu(), "shader2d");
+        let attrs_2d = [
+            GpuVertexAttribute::new(GpuVertexFormat::Float32x2, 0, 0),
+            GpuVertexAttribute::new(GpuVertexFormat::Float32x4, 8, 1),
+            GpuVertexAttribute::new(GpuVertexFormat::Float32x3, 24, 2),
+        ];
+        let vertex_2d = GpuVertexState::new(&module_2d);
+        vertex_2d.set_entry_point("vs_main");
+        vertex_2d.set_buffers(&[js_sys::JsOption::wrap(GpuVertexBufferLayout::new(
+            std::mem::size_of::<Vertex>() as u32,
+            &attrs_2d,
+        ))]);
+        let target = GpuColorTargetState::new(view_format);
+        target.set_blend(&alpha_blending());
+        let targets = [js_sys::JsOption::wrap(target)];
+        let fragment_2d = GpuFragmentState::new(&module_2d, &targets);
+        fragment_2d.set_entry_point("fs_main");
+        let primitive = GpuPrimitiveState::new();
+        primitive.set_topology(GpuPrimitiveTopology::TriangleList);
+        let desc_2d = GpuRenderPipelineDescriptor::new(&layout_2d, &vertex_2d);
+        desc_2d.set_fragment(&fragment_2d);
+        desc_2d.set_primitive(&primitive);
+        desc_2d.set_label("2d");
+        let pipeline_2d = device.create_render_pipeline(&desc_2d)?;
+
+        // Text and images: glyph.wgsl, one (texture, sampler) group each.
+        let glyph_layout = device.create_bind_group_layout(&GpuBindGroupLayoutDescriptor::new(&[
+            texture_entry(0),
+            sampler_entry(1),
+        ]))?;
+        let layout_glyph =
+            device.create_pipeline_layout(&GpuPipelineLayoutDescriptor::new(&[js_sys::JsOption::wrap(glyph_layout.clone())]));
+        let module_glyph = shader_module(&device, GLYPH, "glyph");
+        let attrs_glyph = [
+            GpuVertexAttribute::new(GpuVertexFormat::Float32x2, 0, 0),
+            GpuVertexAttribute::new(GpuVertexFormat::Float32x2, 8, 1),
+            GpuVertexAttribute::new(GpuVertexFormat::Float32x4, 16, 2),
+            GpuVertexAttribute::new(GpuVertexFormat::Float32x3, 32, 3),
+            GpuVertexAttribute::new(GpuVertexFormat::Float32x2, 44, 4),
+        ];
+        let vertex_glyph = GpuVertexState::new(&module_glyph);
+        vertex_glyph.set_entry_point("vs_main");
+        vertex_glyph.set_buffers(&[js_sys::JsOption::wrap(GpuVertexBufferLayout::new(
+            std::mem::size_of::<GlyphVertex>() as u32,
+            &attrs_glyph,
+        ))]);
+        let target = GpuColorTargetState::new(view_format);
+        target.set_blend(&alpha_blending());
+        let targets = [js_sys::JsOption::wrap(target)];
+        let fragment_glyph = GpuFragmentState::new(&module_glyph, &targets);
+        fragment_glyph.set_entry_point("fs_main");
+        let desc_glyph = GpuRenderPipelineDescriptor::new(&layout_glyph, &vertex_glyph);
+        desc_glyph.set_fragment(&fragment_glyph);
+        desc_glyph.set_primitive(&primitive);
+        desc_glyph.set_label("glyph");
+        let glyph_pipeline = device.create_render_pipeline(&desc_glyph)?;
+
+        let uniform_desc = |size: usize, label: &str| {
+            let d = GpuBufferDescriptor::new(size as u32, buffer_usage::UNIFORM | buffer_usage::COPY_DST);
+            d.set_label(label);
+            d
+        };
+        let window_info = device.create_buffer(&uniform_desc(WINDOW_INFO_BYTES, "window-info"))?;
+        let plate_features = device.create_buffer(&uniform_desc(PLATE_FEATURES_BINDING_BYTES, "plate-features"))?;
+
+        // Linear, clamp-to-edge, as the Vulkan backdrop sampler.
+        let backdrop_sampler = sampler(&device, GpuFilterMode::Linear, GpuMipmapFilterMode::Nearest);
+        let empty = texture(&device, view_format, 1, 1, texture_usage::TEXTURE_BINDING | texture_usage::COPY_DST, "backdrop")?;
+        write_texture(&queue, &empty, &[0, 0, 0, 0], 1, 1)?;
+        let group_0 = Self::group_0(&device, &layout_0, &whole_view(&empty)?, &backdrop_sampler, &window_info, &plate_features);
+
+        let blocks = Growable::new(&device, (2 * WEBGPU_BLOCK_STRIDE) as u32, buffer_usage::UNIFORM, "batch-blocks")?;
+        let group_1 = Self::group_1(&device, &layout_1, &blocks.buffer);
+        let vertices = Growable::new(&device, 64 * 1024, buffer_usage::VERTEX, "vertices")?;
+
+        // The atlas: RGBA8 unorm, sampled nearest (as the Vulkan atlas).
+        let atlas_texture = texture(
+            &device,
+            GpuTextureFormat::Rgba8unorm,
+            ATLAS_SIZE,
+            ATLAS_SIZE,
+            texture_usage::TEXTURE_BINDING | texture_usage::COPY_DST,
+            "glyph-atlas",
+        )?;
+        let atlas_sampler = sampler(&device, GpuFilterMode::Nearest, GpuMipmapFilterMode::Nearest);
+        let atlas_group = texture_group(&device, &glyph_layout, &whole_view(&atlas_texture)?, &atlas_sampler);
+        let glyph_vertices = Growable::new(&device, 64 * 1024, buffer_usage::VERTEX, "glyph-vertices")?;
+
+        // Images: sRGB, linear (as the Vulkan image stage).
+        let image_sampler = sampler(&device, GpuFilterMode::Linear, GpuMipmapFilterMode::Linear);
+        let image_vertices = Growable::new(&device, 4 * 1024, buffer_usage::VERTEX, "image-quads")?;
+        // This renderer's image table: the ids queued before it existed are
+        // drained into it, as a Vulkan renderer's are.
+        image_table_built();
+
+        Ok(Self {
+            _gpu: gpu,
+            _adapter: adapter,
+            device,
+            queue,
+            canvas,
+            context,
+            canvas_format,
+            view_format,
+            pipeline_2d,
+            layout_0,
+            layout_1,
+            window_info,
+            plate_features,
+            backdrop_sampler,
+            group_0,
+            snapshot: None,
+            blocks,
+            group_1,
+            vertices,
+            glyph_pipeline,
+            glyph_layout,
+            atlas: GlyphAtlas::new(),
+            atlas_texture,
+            atlas_group,
+            atlas_uploaded: 0,
+            glyph_vertices,
+            image_sampler,
+            images: HashMap::new(),
+            image_vertices,
+            capture: None,
+            capture_requested: false,
+        })
+    }
+
+    fn group_0(
+        device: &GpuDevice,
+        layout: &GpuBindGroupLayout,
+        backdrop: &GpuTextureView,
+        sampler: &GpuSampler,
+        window_info: &GpuBuffer,
+        plate_features: &GpuBuffer,
+    ) -> GpuBindGroup {
+        let entries = [
+            GpuBindGroupEntry::new_with_gpu_texture_view(0, backdrop),
+            GpuBindGroupEntry::new(1, sampler),
+            GpuBindGroupEntry::new_with_gpu_buffer_binding(2, &uniform_binding(window_info, WINDOW_INFO_BYTES as u32)),
+            GpuBindGroupEntry::new_with_gpu_buffer_binding(
+                3,
+                &uniform_binding(plate_features, PLATE_FEATURES_BINDING_BYTES as u32),
+            ),
+        ];
+        device.create_bind_group(&GpuBindGroupDescriptor::new(&entries, layout))
+    }
+
+    fn group_1(device: &GpuDevice, layout: &GpuBindGroupLayout, blocks: &GpuBuffer) -> GpuBindGroup {
+        let entries = [GpuBindGroupEntry::new_with_gpu_buffer_binding(
+            0,
+            &uniform_binding(blocks, (PUSH_CONSTANT_FLOATS * 4) as u32),
+        )];
+        device.create_bind_group(&GpuBindGroupDescriptor::new(&entries, layout))
+    }
+
+    /// The drawing buffer's size in physical px.
+    pub fn size(&self) -> (u32, u32) {
+        (self.canvas.width(), self.canvas.height())
+    }
+
+    /// Size the drawing buffer (physical px). The page sizes the element.
+    pub fn resize(&mut self, width: u32, height: u32) {
+        self.canvas.set_width(width.max(1));
+        self.canvas.set_height(height.max(1));
+    }
+
+    /// Shape this frame's text into glyph quads (see [`GlyphAtlas::prepare`]),
+    /// against the drawing buffer's current size.
+    pub fn prepare_text(&mut self, fs: &mut cosmic_text::FontSystem, swash: &mut cosmic_text::SwashCache, spans: &[TextSpan<'_>]) {
+        let (w, h) = self.size();
+        self.atlas.prepare(fs, swash, spans, w, h);
+    }
+
+    /// Copy the next frame drawn into a buffer [`take_capture`](Self::take_capture) reads.
+    pub fn capture_next_frame(&mut self) {
+        self.capture_requested = true;
+    }
+
+    /// 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);
+            }
+        }
+        Ok(Some(Capture { width, height, rgba }))
+    }
+
+    /// Apply the image queue: uploads, in-place updates, frees.
+    fn process_images(&mut self) -> Result<(), JsValue> {
+        for pending in take_pending() {
+            match pending {
+                Pending::Upload { id, pixels, width, height, format, mips: _ }
+                | Pending::Update { id, pixels, width, height, format } => {
+                    let same = self
+                        .images
+                        .get(&id)
+                        .is_some_and(|img| img.width == width && img.height == height && img.format == format);
+                    if !same {
+                        if let Some(old) = self.images.remove(&id) {
+                            old.texture.destroy();
+                        }
+                        let tex_format = match format {
+                            PixelFormat::Rgba => GpuTextureFormat::Rgba8unormSrgb,
+                            PixelFormat::Bgra => GpuTextureFormat::Bgra8unormSrgb,
+                        };
+                        let texture = texture(
+                            &self.device,
+                            tex_format,
+                            width,
+                            height,
+                            texture_usage::TEXTURE_BINDING | texture_usage::COPY_DST,
+                            "image",
+                        )?;
+                        let group = texture_group(&self.device, &self.glyph_layout, &whole_view(&texture)?, &self.image_sampler);
+                        self.images.insert(id, WebImage { texture, group, width, height, format });
+                    }
+                    let img = &self.images[&id];
+                    write_texture(&self.queue, &img.texture, &pixels, width, height)?;
+                    retire_buffer(pixels);
+                }
+                Pending::Free { id } => {
+                    if let Some(old) = self.images.remove(&id) {
+                        old.texture.destroy();
+                    }
+                }
+            }
+        }
+        Ok(())
+    }
+
+    /// The blur snapshot texture and its `@group(0)`, sized to the target.
+    fn snapshot_group(&mut self, w: u32, h: u32) -> Result<(GpuTexture, GpuBindGroup), JsValue> {
+        if let Some((tex, group, sw, sh)) = &self.snapshot {
+            if *sw == w && *sh == h {
+                return Ok((tex.clone(), group.clone()));
+            }
+            tex.destroy();
+        }
+        let tex = texture(&self.device, self.view_format, w, h, texture_usage::TEXTURE_BINDING | texture_usage::COPY_DST, "blur-snapshot")?;
+        let group = Self::group_0(&self.device, &self.layout_0, &whole_view(&tex)?, &self.backdrop_sampler, &self.window_info, &self.plate_features);
+        self.snapshot = Some((tex.clone(), group.clone(), w, h));
+        Ok((tex, group))
+    }
+
+    fn begin_pass(encoder: &GpuCommandEncoder, view: &GpuTextureView, clear: Option<[f32; 4]>) -> Result<GpuRenderPassEncoder, JsValue> {
+        let attachment = match clear {
+            Some(c) => {
+                let a = GpuRenderPassColorAttachment::new_with_gpu_texture_view(GpuLoadOp::Clear, GpuStoreOp::Store, view);
+                a.set_clear_value(&[
+                    js_sys::Number::from(c[0] as f64),
+                    js_sys::Number::from(c[1] as f64),
+                    js_sys::Number::from(c[2] as f64),
+                    js_sys::Number::from(c[3] as f64),
+                ]);
+                a
+            }
+            None => GpuRenderPassColorAttachment::new_with_gpu_texture_view(GpuLoadOp::Load, GpuStoreOp::Store, view),
+        };
+        encoder.begin_render_pass(&GpuRenderPassDescriptor::new(&[js_sys::JsOption::wrap(attachment)]))
+    }
+
+    /// Draw one frame into the canvas. The browser presents it when the task
+    /// that called this returns.
+    pub fn draw_frame_2d(&mut self, frame: Frame2D<'_>) -> Result<(), JsValue> {
+        self.process_images()?;
+        let target = self.context.get_current_texture()?;
+        let (w, h) = (target.width(), target.height());
+        let view_desc = GpuTextureViewDescriptor::new();
+        view_desc.set_format(self.view_format);
+        let view = target.create_view_with_descriptor(&view_desc)?;
+
+        // The frame's uniforms and vertices.
+        let info = window_info_data(w, h, 0.0, relief_px_at(crate::scale::scale_factor()));
+        self.queue.write_buffer_with_u32_and_u8_slice(&self.window_info, 0, bytemuck::cast_slice(&info))?;
+        if !frame.plate_features.is_empty() {
+            let n = frame.plate_features.len().min(MAX_PLATE_FEATURES);
+            self.queue.write_buffer_with_u32_and_u8_slice(
+                &self.plate_features,
+                0,
+                bytemuck::cast_slice(&frame.plate_features[..n]),
+            )?;
+        }
+        let vert_bytes: &[u8] = bytemuck::cast_slice(frame.verts);
+        let overlay_bytes: &[u8] = bytemuck::cast_slice(frame.overlay_verts);
+        let mut all = Vec::with_capacity(vert_bytes.len() + overlay_bytes.len());
+        all.extend_from_slice(vert_bytes);
+        all.extend_from_slice(overlay_bytes);
+        self.vertices.ensure(&self.device, all.len() as u32)?;
+        self.vertices.write(&self.queue, &all)?;
+        let vertex_count = frame.verts.len() as u32;
+        let overlay_count = frame.overlay_verts.len() as u32;
+
+        let default_batch = [Batch2D { scissor: None, clip_rrect: None, start: 0, end: vertex_count, plate: None, blur_behind: false }];
+        let batches: &[Batch2D] = if frame.batches.is_empty() { &default_batch } else { frame.batches };
+        // Corner-shape exponent for the rounded-rect clip SDF (see the Vulkan renderer).
+        let clip_shape = crate::layout::corner_shape();
+        // One block per batch, then the overlay's zero block.
+        let block_floats = WEBGPU_BLOCK_STRIDE / 4;
+        let mut blocks = vec![0.0f32; (batches.len() + 1) * block_floats];
+        for (i, batch) in batches.iter().enumerate() {
+            blocks[i * block_floats..i * block_floats + PUSH_CONSTANT_FLOATS]
+                .copy_from_slice(&batch_push_constants(batch, clip_shape, 0));
+        }
+        let overlay_block = batches.len() as u32;
+        if self.blocks.ensure(&self.device, (blocks.len() * 4) as u32)? {
+            self.group_1 = Self::group_1(&self.device, &self.layout_1, &self.blocks.buffer);
+        }
+        self.blocks.write(&self.queue, bytemuck::cast_slice(&blocks))?;
+
+        if self.atlas_uploaded != self.atlas.generation() {
+            write_texture(&self.queue, &self.atlas_texture, self.atlas.pixels(), ATLAS_SIZE, ATLAS_SIZE)?;
+            self.atlas_uploaded = self.atlas.generation();
+        }
+        let glyph_bytes: &[u8] = bytemuck::cast_slice(self.atlas.vertices());
+        self.glyph_vertices.ensure(&self.device, glyph_bytes.len() as u32)?;
+        self.glyph_vertices.write(&self.queue, glyph_bytes)?;
+        let image_verts = image_quad_vertices(frame.images, w, h);
+        let image_bytes: &[u8] = bytemuck::cast_slice(&image_verts);
+        self.image_vertices.ensure(&self.device, image_bytes.len() as u32)?;
+        self.image_vertices.write(&self.queue, image_bytes)?;
+
+        // The blur snapshot, made (or resized) before recording when any
+        // batch needs one.
+        let snapshot = if batches.iter().any(|b| b.blur_behind) { Some(self.snapshot_group(w, h)?) } else { None };
+
+        // Record.
+        let encoder = self.device.create_command_encoder();
+        let mut pass = Self::begin_pass(&encoder, &view, Some(frame.clear_color))?;
+        let clamp_scissor = |pass: &GpuRenderPassEncoder, (x, y, sw, sh): (u32, u32, u32, u32)| {
+            let x = x.min(w);
+            let y = y.min(h);
+            pass.set_scissor_rect(x, y, sw.min(w - x), sh.min(h - y));
+        };
+        let images = frame.images;
+        let mut order: Vec<usize> = (0..images.len()).collect();
+        order.sort_by_key(|&k| images[k].z_before);
+        let mut img_i = 0usize;
+        let draw_image = |pass: &GpuRenderPassEncoder, k: usize| {
+            let q = &images[k];
+            let Some(img) = self.images.get(&q.image) else { return }; // not landed / freed: skipped, as on Vulkan
+            clamp_scissor(pass, q.clip.unwrap_or((0, 0, w, h)));
+            pass.set_pipeline(&self.glyph_pipeline);
+            pass.set_bind_group(0, Some(&img.group));
+            pass.set_vertex_buffer_with_u32(0, Some(&self.image_vertices.buffer), 0);
+            pass.draw_with_instance_count_and_first_vertex(6, 1, (k * 6) as u32);
+        };
+
+        // The `@group(0)` vertex draws bind: the empty backdrop until the
+        // first blur snapshot, the snapshot after. Consecutive blur plates
+        // share one snapshot; only a non-blur draw invalidates it.
+        let mut active_group_0 = self.group_0.clone();
+        let mut snapshot_fresh = false;
+
+        for (bi, batch) in batches.iter().enumerate() {
+            // Images due at this batch's boundary draw first (beneath its
+            // geometry, and inside a snapshot taken for it).
+            while let Some(&k) = order.get(img_i) {
+                if images[k].z_before > batch.start {
+                    break;
+                }
+                img_i += 1;
+                draw_image(&pass, k);
+                snapshot_fresh = false;
+            }
+            if batch.blur_behind {
+                if !snapshot_fresh {
+                    let (snap, snap_group) = snapshot.as_ref().expect("made above for a blur batch");
+                    pass.end();
+                    encoder.copy_texture_to_texture_with_gpu_extent_3d_dict(
+                        &GpuTexelCopyTextureInfo::new(&target),
+                        &GpuTexelCopyTextureInfo::new(snap),
+                        &extent(w, h),
+                    )?;
+                    pass = Self::begin_pass(&encoder, &view, None)?;
+                    active_group_0 = snap_group.clone();
+                    snapshot_fresh = true;
+                }
+            } else if batch.start < batch.end {
+                snapshot_fresh = false;
+            }
+            // A degenerate scissor skips the geometry (images keep their own clips).
+            let scissor = match batch.scissor {
+                Some((bx, by, bw, bh)) => {
+                    if bx >= w || by >= h || bw.min(w - bx) == 0 || bh.min(h - by) == 0 {
+                        None
+                    } else {
+                        Some((bx, by, bw.min(w - bx), bh.min(h - by)))
+                    }
+                }
+                None => Some((0, 0, w, h)),
+            };
+            let mut cursor = batch.start;
+            while cursor < batch.end {
+                let next_z = order.get(img_i).map(|&k| images[k].z_before).unwrap_or(u32::MAX);
+                if next_z <= cursor {
+                    let k = order[img_i];
+                    img_i += 1;
+                    draw_image(&pass, k);
+                    continue;
+                }
+                let upto = next_z.min(batch.end);
+                if let Some(s) = scissor {
+                    pass.set_pipeline(&self.pipeline_2d);
+                    pass.set_bind_group(0, Some(&active_group_0));
+                    pass.set_bind_group_with_u32_slice_and_u32_and_dynamic_offsets_data_length(
+                        1,
+                        Some(&self.group_1),
+                        &[(bi * WEBGPU_BLOCK_STRIDE) as u32],
+                        0,
+                        1,
+                    )?;
+                    pass.set_vertex_buffer_with_u32(0, Some(&self.vertices.buffer), 0);
+                    clamp_scissor(&pass, s);
+                    pass.draw_with_instance_count_and_first_vertex(upto - cursor, 1, cursor);
+                }
+                cursor = upto;
+            }
+        }
+        // Images sorting after all geometry.
+        while let Some(&k) = order.get(img_i) {
+            img_i += 1;
+            draw_image(&pass, k);
+        }
+        pass.set_scissor_rect(0, 0, w, h);
+
+        // Text on top of the geometry.
+        let glyph_count = self.atlas.vertices().len() as u32;
+        if glyph_count > 0 {
+            pass.set_pipeline(&self.glyph_pipeline);
+            pass.set_bind_group(0, Some(&self.atlas_group));
+            pass.set_vertex_buffer_with_u32(0, Some(&self.glyph_vertices.buffer), 0);
+            pass.draw_with_instance_count_and_first_vertex(glyph_count, 1, 0);
+        }
+        // Overlays last, with the default backdrop and a zero parameter block.
+        if overlay_count > 0 {
+            pass.set_pipeline(&self.pipeline_2d);
+            pass.set_bind_group(0, Some(&self.group_0));
+            pass.set_bind_group_with_u32_slice_and_u32_and_dynamic_offsets_data_length(
+                1,
+                Some(&self.group_1),
+                &[overlay_block * WEBGPU_BLOCK_STRIDE as u32],
+                0,
+                1,
+            )?;
+            pass.set_vertex_buffer_with_u32(0, Some(&self.vertices.buffer), 0);
+            pass.draw_with_instance_count_and_first_vertex(overlay_count, 1, vertex_count);
+        }
+        pass.end();
+
+        if std::mem::take(&mut self.capture_requested) {
+            let row = (w * 4).div_ceil(256) * 256;
+            let desc = GpuBufferDescriptor::new(row * h, buffer_usage::COPY_DST | buffer_usage::MAP_READ);
+            desc.set_label("capture");
+            let buffer = self.device.create_buffer(&desc)?;
+            let dst = GpuTexelCopyBufferInfo::new(&buffer);
+            dst.set_bytes_per_row(row);
+            dst.set_rows_per_image(h);
+            encoder.copy_texture_to_buffer_with_gpu_extent_3d_dict(&GpuTexelCopyTextureInfo::new(&target), &dst, &extent(w, h))?;
+            self.capture = Some((buffer, w, h, row));
+        }
+
+        self.queue.submit(&[encoder.finish()]);
+        Ok(())
+    }
+}