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

commitf7799aaaa5f6ef279d58c8a19a54fe7b69b14bd1
parent46afc03acb
authorClaude <noreply@anthropic.com>
date2026-10-04 19:59
build: the library builds for wasm32-unknown-unknown

The first step of the browser port (W1): everything that is not the
native shell or renderer now compiles for the browser target, checked
by scripts/check-wasm (cargo check --lib --target
wasm32-unknown-unknown, with and without markdown/doc_editor).

- Cargo: ash, gpu-allocator, naga, smithay-client-toolkit, calloop,
  calloop-wayland-source, wayland-*, raw-window-handle, xkeysym, libc
  and rfd move under cfg(not(target_arch = "wasm32")). Everything left
  (cosmic-text, resvg, png, kdl, serde, glam, cursor-icon, ...) builds
  for wasm as it is. web-time joins for all targets.
- Modules: vk, wayland, protocol, ipc, mcp, file_dialog and the Wayland
  shell (backend::{window_runner, menu_popup, dnd}) are native only.
  engine.rs and backend/mod.rs re-export from the portable modules
  (app, driver, tessellate, text) with the native items (run,
  EngineState, xdg_toplevel, the layer-shell types) behind the cfg;
  every name a client imported is still there natively.
- The client contract: Application::new(qh, ..), layer() and
  LayerSettings, register_sources, renderer_init and stage_renderer
  (both take a VkRenderer) are native only. WindowAction::Resize takes
  app::WindowEdge, which is xdg_toplevel's ResizeEdge on Linux exactly
  as before and the driver's ResizeEdge in the browser. AppSender wraps
  calloop's sender natively and a std::sync::mpsc sender in the
  browser, still Send either way.
- Time: portable code takes Instant from web-time (15 files). It IS
  std's type natively; std's panics in the browser, which has no clock
  of its own. config.rs keeps std's SystemTime: it only holds file
  modification times from metadata, never now().
- The ColorSelector's request that the compositor place its picker at
  the pointer moves into place_picker_at_pointer, native only.

Native is unchanged: test results the same (523 pass, the 2
pre-existing failures), the plate golden byte-identical, the demo
identical to the pixel over the 24 scripted steps, and the loop's
cadence the same (5 wake-ups in 5 s idle, 125-126 in 2 s of a held key
for the old binary and the new alike).

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

 CLAUDE.md                          | 13 ++++++++
 Cargo.lock                         | 11 +++++++
 Cargo.toml                         | 39 ++++++++++++++----------
 scripts/check-wasm                 | 16 ++++++++++
 src/backend/app.rs                 | 44 +++++++++++++++++++++++----
 src/backend/driver.rs              |  2 +-
 src/backend/mod.rs                 | 18 +++++++----
 src/backend/shell.rs               |  3 +-
 src/backend/text.rs                |  8 ++---
 src/context.rs                     |  4 +--
 src/engine.rs                      | 23 ++++++++++----
 src/lib.rs                         | 10 +++++++
 src/motion.rs                      |  3 +-
 src/widget/container/treelist.rs   |  4 +--
 src/widget/core.rs                 | 10 +++----
 src/widget/display/graph.rs        |  4 +--
 src/widget/doc_editor/mod.rs       |  3 +-
 src/widget/input/color_selector.rs | 61 +++++++++++++++++++++-----------------
 src/widget/input/dropdown.rs       |  8 ++---
 src/widget/input/ramp.rs           |  4 +--
 src/widget/input/slider.rs         |  4 +--
 src/widget/line_edit.rs            |  3 +-
 src/widget/scroll_motion.rs        |  2 +-
 src/widget/side_swipe.rs           |  3 +-
 24 files changed, 209 insertions(+), 91 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 70231ad..35fbd44 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -57,6 +57,19 @@ scattering of widgets (`text_box`, `slider`, `dropdown`, `treelist`, …). When
 engine, that module's tests are the fast feedback loop; run `cargo test -p cce-ui scene::` before
 anything else.
 
+**The library also builds for the browser** (`wasm32-unknown-unknown`, since 2026-10-04):
+`scripts/check-wasm` type-checks it, with and without the optional features. The native
+shell and renderer — `vk`, the Wayland shell (`backend::{window_runner, menu_popup, dnd}`),
+`wayland`, `protocol`, `ipc`, `mcp`, `file_dialog` — and their crates (ash, smithay, calloop,
+wayland-*, libc, rfd) are `cfg(not(target_arch = "wasm32"))`; so are the Wayland-typed parts
+of the client contract: `Application::new(qh, …)`, `layer()` and `LayerSettings`,
+`register_sources`, and `renderer_init` / `stage_renderer`, which take a `VkRenderer`.
+`WindowAction::Resize` takes `app::WindowEdge` — xdg's `ResizeEdge` on Linux, as before.
+Portable code keeps time with `web_time::Instant` (std's own type natively; std's panics in
+the browser), and reaches what a renderer draws through `crate::draw`, not `crate::vk`. A
+change that makes portable code call into a native module fails `check-wasm` first — put
+the native half behind the cfg, as `color_selector::place_picker_at_pointer` does.
+
 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.
diff --git a/Cargo.lock b/Cargo.lock
index 27631e3..aecb135 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -377,6 +377,7 @@ dependencies = [
  "wayland-client",
  "wayland-protocols 0.31.2",
  "wayland-scanner",
+ "web-time",
  "xkeysym",
 ]
 
@@ -2570,6 +2571,16 @@ dependencies = [
  "wasm-bindgen",
 ]
 
+[[package]]
+name = "web-time"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
 [[package]]
 name = "weezl"
 version = "0.1.12"
diff --git a/Cargo.toml b/Cargo.toml
index 99eff0b..42e03f9 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -18,17 +18,38 @@ doc_editor = []
 
 [dependencies]
 cce-vault = { git = "https://github.com/lsgalante/cce-vault.git", rev = "41ca52bd51ea3655a791c58005be9fce939e4333", optional = true }
-raw-window-handle = "0.6"
 glam = "0.29"
 bytemuck = { version = "1", features = ["derive"] }
 # Text shaping. This was reached through glyphon until the wgpu path was retired;
 # every `glyphon::` item cce-ui ever used was a cosmic-text re-export (the sole
-# exception, `TextBounds`, is now defined in backend/window_runner.rs), so depending
+# exception, `TextBounds`, is now defined in backend/text.rs), so depending
 # on cosmic-text directly drops glyphon, wgpu and 17 more crates from the build —
 # the rest of wgpu's tree stays only because naga/ash below already pull it.
 # Pinned to the exact version glyphon 0.8 resolved to, so shaping does not change:
 # verified by diffing shaped glyph ids/advances both ways over 288 runs.
 cosmic-text = "=0.12.1"
+bitflags = "2"
+# The cursor names an app returns. smithay-client-toolkit re-exports this same
+# crate's type, so the Wayland shell hands it over unconverted; depending on it
+# directly keeps the client contract free of the toolkit.
+cursor-icon = "1.2"
+resvg = "0.41.0"
+# App/tray icon themes are overwhelmingly PNG; resvg covers only the SVG half.
+png = "0.17"
+serde = { version = "1.0", features = ["derive"] }
+serde_json = "1.0"
+kdl = "4.6"
+log = "0.4"
+# `Instant` that works in the browser: std's panics on wasm32-unknown-unknown,
+# which has no clock of its own. On every other target this IS std's.
+web-time = "1"
+
+# The native shell and renderer: Wayland through smithay-client-toolkit on a
+# calloop loop, Vulkan through ash. None of it builds for the browser, where
+# the shell and renderer are the web platform's (`cargo check --lib --target
+# wasm32-unknown-unknown` is the check that keeps the rest portable).
+[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
+raw-window-handle = "0.6"
 # Raw-Vulkan backend (versions match what wgpu 24 already pulled in).
 ash = "0.38"
 gpu-allocator = { version = "0.27", default-features = false, features = ["vulkan"] }
@@ -40,20 +61,6 @@ wayland-client = { version = "0.31", features = ["system"] }
 wayland-protocols = { version = "0.31", features = ["client", "unstable"] }
 wayland-backend = "0.3"
 wayland-scanner = "0.31"
-bitflags = "2"
-# The cursor names an app returns. smithay-client-toolkit re-exports this same
-# crate's type, so the Wayland shell hands it over unconverted; depending on it
-# directly keeps the client contract free of the toolkit.
-cursor-icon = "1.2"
 xkeysym = "0.2"
-resvg = "0.41.0"
-# App/tray icon themes are overwhelmingly PNG; resvg covers only the SVG half.
-png = "0.17"
-serde = { version = "1.0", features = ["derive"] }
-serde_json = "1.0"
-kdl = "4.6"
 libc = "0.2"
-log = "0.4"
 rfd = "0.15"
-
-
diff --git a/scripts/check-wasm b/scripts/check-wasm
new file mode 100755
index 0000000..581ac62
--- /dev/null
+++ b/scripts/check-wasm
@@ -0,0 +1,16 @@
+#!/usr/bin/env bash
+# check-wasm — does the portable part of cce-ui still build for the browser?
+# Type-checks the library for wasm32-unknown-unknown, with and without the
+# 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.
+set -euo pipefail
+cd "$(dirname "$0")/.."
+if ! rustup target list --installed 2>/dev/null | grep -qx wasm32-unknown-unknown; then
+    echo "check-wasm: the wasm32-unknown-unknown target is not installed:" >&2
+    echo "    rustup target add wasm32-unknown-unknown" >&2
+    exit 2
+fi
+cargo check --lib --target wasm32-unknown-unknown
+cargo check --lib --target wasm32-unknown-unknown --features markdown,doc_editor
+echo "check-wasm: ok"
diff --git a/src/backend/app.rs b/src/backend/app.rs
index 2257387..60e4b3f 100644
--- a/src/backend/app.rs
+++ b/src/backend/app.rs
@@ -3,11 +3,13 @@
 //! Moved out of `window_runner` unchanged; still re-exported from there
 //! (and from `engine`) at the old paths.
 
-use smithay_client_toolkit::reexports::protocols::xdg::shell::client::xdg_toplevel;
-use wayland_client::QueueHandle;
 use cosmic_text::FontSystem;
 use crate::widget::{MouseButton, ElementState, MouseScrollDelta, KeyEvent};
+#[cfg(not(target_arch = "wasm32"))]
+use wayland_client::QueueHandle;
+#[cfg(not(target_arch = "wasm32"))]
 use crate::vk::VkRenderer;
+#[cfg(not(target_arch = "wasm32"))]
 use super::window_runner::EngineState;
 use cursor_icon::CursorIcon;
 use super::tessellate::Vertex;
@@ -22,16 +24,25 @@ pub struct WindowSettings {
     pub min_size: Option<(u32, u32)>,
 }
 
+/// The edge a [`WindowAction::Resize`] grabs. On Wayland it is
+/// `xdg_toplevel`'s own enum, as it always was; elsewhere the driver's.
+#[cfg(not(target_arch = "wasm32"))]
+pub use smithay_client_toolkit::reexports::protocols::xdg::shell::client::xdg_toplevel::ResizeEdge as WindowEdge;
+#[cfg(target_arch = "wasm32")]
+pub use super::driver::ResizeEdge as WindowEdge;
+
 /// A compositor-side window operation requested by the app: an interactive
 /// move or resize grab. Returned from [`Application::take_window_action`];
 /// the runner executes it with the serial of the most recent pointer press.
 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
 pub enum WindowAction {
     Move,
-    Resize(xdg_toplevel::ResizeEdge),
+    Resize(WindowEdge),
 }
 
 // Re-export the wlr-layer-shell types apps need to describe a layer surface.
+// Layer surfaces are a Wayland (wlr) notion: native only, like `layer()`.
+#[cfg(not(target_arch = "wasm32"))]
 pub use smithay_client_toolkit::shell::wlr_layer::{
     Anchor as LayerAnchor, KeyboardInteractivity as LayerKeyboardInteractivity, Layer as LayerKind,
 };
@@ -39,6 +50,7 @@ pub use smithay_client_toolkit::shell::wlr_layer::{
 /// Opt-in configuration for running an [`Application`] on a wlr-layer-shell
 /// surface (panels, overlays, notifications) instead of an xdg toplevel.
 /// Return one from [`Application::layer`] to select layer-shell.
+#[cfg(not(target_arch = "wasm32"))]
 #[derive(Debug, Clone)]
 pub struct LayerSettings {
     pub layer: LayerKind,
@@ -87,9 +99,15 @@ pub struct RenderContext<'a> {
 /// It names no window system. Handed to [`Application::create`], it is what
 /// a client written against it can be run by any shell with; the Wayland
 /// runner backs it with its calloop channel, and `From` converts both ways
-/// for a client that still keeps a `calloop::channel::Sender` somewhere.
+/// 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.
 pub struct AppSender<M> {
+    #[cfg(not(target_arch = "wasm32"))]
     inner: calloop::channel::Sender<M>,
+    #[cfg(target_arch = "wasm32")]
+    inner: std::sync::mpsc::Sender<M>,
 }
 
 impl<M> AppSender<M> {
@@ -112,12 +130,21 @@ impl<M> std::fmt::Debug for AppSender<M> {
     }
 }
 
+#[cfg(not(target_arch = "wasm32"))]
 impl<M> From<calloop::channel::Sender<M>> for AppSender<M> {
     fn from(inner: calloop::channel::Sender<M>) -> Self {
         Self { inner }
     }
 }
 
+#[cfg(target_arch = "wasm32")]
+impl<M> From<std::sync::mpsc::Sender<M>> for AppSender<M> {
+    fn from(inner: std::sync::mpsc::Sender<M>) -> Self {
+        Self { inner }
+    }
+}
+
+#[cfg(not(target_arch = "wasm32"))]
 impl<M> From<AppSender<M>> for calloop::channel::Sender<M> {
     fn from(sender: AppSender<M>) -> Self {
         sender.inner
@@ -145,7 +172,8 @@ pub trait Application: Sized + 'static {
     /// The legacy constructor, from before the runner had a second shell in
     /// view: the session's Wayland queue handle (no client ever used it) and
     /// its calloop sender. Prefer [`create`](Self::create); the default here
-    /// forwards to it.
+    /// forwards to it. Native only: it names the Wayland queue.
+    #[cfg(not(target_arch = "wasm32"))]
     fn new(qh: &QueueHandle<EngineState<Self>>, sender: calloop::channel::Sender<Self::Message>) -> Self {
         let _ = qh;
         Self::create(AppSender::from(sender))
@@ -153,6 +181,7 @@ pub trait Application: Sized + 'static {
     fn settings(&self) -> WindowSettings;
     /// Return `Some(..)` to run on a wlr-layer-shell surface (overlay/panel)
     /// instead of an xdg toplevel. Defaults to `None` (a normal window).
+    #[cfg(not(target_arch = "wasm32"))]
     fn layer(&self) -> Option<LayerSettings> {
         None
     }
@@ -282,6 +311,7 @@ pub trait Application: Sized + 'static {
         [0.0, 0.0, 0.0, 0.0]
     }
 
+    #[cfg(not(target_arch = "wasm32"))]
     fn register_sources(&mut self, _handle: &calloop::LoopHandle<'_, EngineState<Self>>) {}
 
     fn adjust_size(&self, width: f32, height: f32) -> (f32, f32) {
@@ -418,6 +448,7 @@ pub trait Application: Sized + 'static {
     /// Called once, right after the renderer is created and before the first
     /// frame: create persistent renderer resources here (3D meshes via
     /// [`VkRenderer::create_mesh`]). Most 2D apps never need this.
+    #[cfg(not(target_arch = "wasm32"))]
     fn renderer_init(&mut self, _renderer: &mut VkRenderer) {}
 
     /// Direct renderer staging, called every frame after the engine's own text
@@ -428,6 +459,7 @@ pub trait Application: Sized + 'static {
     /// the renderer's text state, the engine never touches it). Return `true`
     /// to request another frame immediately (e.g. while a path tracer is still
     /// accumulating samples).
+    #[cfg(not(target_arch = "wasm32"))]
     fn stage_renderer(&mut self, _renderer: &mut VkRenderer, _size: LogicalSize, _scale: f64) -> bool {
         false
     }
@@ -495,7 +527,7 @@ pub trait Application: Sized + 'static {
 }
 
 
-#[cfg(test)]
+#[cfg(all(test, not(target_arch = "wasm32")))]
 mod app_sender_tests {
     use super::AppSender;
     use calloop::channel::{channel, Event};
diff --git a/src/backend/driver.rs b/src/backend/driver.rs
index cf9074b..de5f978 100644
--- a/src/backend/driver.rs
+++ b/src/backend/driver.rs
@@ -13,7 +13,7 @@
 //! them (the Wayland runner keeps them on `EngineState`, where clients'
 //! `register_sources` callbacks reach `inner` and `redraw` directly).
 
-use std::time::Instant;
+use web_time::Instant;
 
 use super::app::{Application, LogicalPosition, LogicalSize};
 use cursor_icon::CursorIcon;
diff --git a/src/backend/mod.rs b/src/backend/mod.rs
index 71359c7..d4d4b4a 100644
--- a/src/backend/mod.rs
+++ b/src/backend/mod.rs
@@ -2,13 +2,19 @@ pub mod app;
 pub mod driver;
 pub mod frame;
 pub mod shell;
-pub mod dnd;
-pub mod menu_popup;
 pub mod tessellate;
 pub mod text;
+// The Wayland shell: native only.
+#[cfg(not(target_arch = "wasm32"))]
+pub mod dnd;
+#[cfg(not(target_arch = "wasm32"))]
+pub mod menu_popup;
+#[cfg(not(target_arch = "wasm32"))]
 pub mod window_runner;
 
-pub use window_runner::{
-    EngineState, WindowSettings, LogicalPosition, LogicalSize, Application, AppSender, run,
-    Vertex, LineCap, PressedKey, get_text_buffer,
-};
+pub use app::{Application, AppSender, LogicalPosition, LogicalSize, WindowSettings};
+pub use driver::PressedKey;
+pub use tessellate::{LineCap, Vertex};
+pub use text::get_text_buffer;
+#[cfg(not(target_arch = "wasm32"))]
+pub use window_runner::{run, EngineState};
diff --git a/src/backend/shell.rs b/src/backend/shell.rs
index be2c40f..78dc71e 100644
--- a/src/backend/shell.rs
+++ b/src/backend/shell.rs
@@ -11,7 +11,8 @@
 //! loop — the browser's animation frames, an AppKit run loop — calls `turn`
 //! from wherever its loop turns and sleeps (or schedules) for what it says.
 
-use std::time::{Duration, Instant};
+use std::time::Duration;
+use web_time::Instant;
 
 use super::app::Application;
 use super::driver::{Driver, Turn};
diff --git a/src/backend/text.rs b/src/backend/text.rs
index e57aad0..be03db4 100644
--- a/src/backend/text.rs
+++ b/src/backend/text.rs
@@ -19,7 +19,7 @@ struct BufferCacheKey {
 #[derive(Clone)]
 struct CachedBuffer {
     buffer: Buffer,
-    last_accessed: std::time::Instant,
+    last_accessed: web_time::Instant,
 }
 
 std::thread_local! {
@@ -124,7 +124,7 @@ pub fn get_text_buffer_attrs(
     let cached = BUFFER_CACHE.with(|cache| {
         let mut cache = cache.borrow_mut();
         if let Some(cached_item) = cache.get_mut(&key) {
-            cached_item.last_accessed = std::time::Instant::now();
+            cached_item.last_accessed = web_time::Instant::now();
             Some(cached_item.buffer.clone())
         } else {
             None
@@ -221,7 +221,7 @@ pub fn get_text_buffer_attrs(
     BUFFER_CACHE.with(|cache| {
         let mut cache = cache.borrow_mut();
         if cache.len() >= 2000 {
-            let mut items: Vec<(BufferCacheKey, std::time::Instant)> = cache
+            let mut items: Vec<(BufferCacheKey, web_time::Instant)> = cache
                 .iter()
                 .map(|(k, v)| (k.clone(), v.last_accessed))
                 .collect();
@@ -232,7 +232,7 @@ pub fn get_text_buffer_attrs(
         }
         cache.insert(key, CachedBuffer {
             buffer: buf.clone(),
-            last_accessed: std::time::Instant::now(),
+            last_accessed: web_time::Instant::now(),
         });
     });
 
diff --git a/src/context.rs b/src/context.rs
index 3cdd45c..5921fb7 100644
--- a/src/context.rs
+++ b/src/context.rs
@@ -86,7 +86,7 @@ pub struct UiContext {
     /// inertia), so a second tick per frame would run them at double speed.
     tick_count: u64,
     pub spatial_grid: SpatialGrid,
-    pub last_scroll_time: Option<std::time::Instant>,
+    pub last_scroll_time: Option<web_time::Instant>,
     pub scroll_initiate_widget_id: Option<WidgetId>,
     pub scroll_gesture_new: bool,
     pub ctrl_pressed: bool,
@@ -148,7 +148,7 @@ impl UiContext {
     /// take it (2026-09-20: the Settings list would not scroll after the
     /// params pane had).
     pub fn note_scroll_event(&mut self) {
-        let now = std::time::Instant::now();
+        let now = web_time::Instant::now();
         let elapsed_ms = match self.last_scroll_time {
             None => 999999,
             Some(last) => now.duration_since(last).as_millis(),
diff --git a/src/engine.rs b/src/engine.rs
index 68a3a8d..f0571ae 100644
--- a/src/engine.rs
+++ b/src/engine.rs
@@ -1,8 +1,13 @@
-pub use crate::backend::window_runner::{
-    Vertex, LineCap, WindowSettings, LogicalPosition, LogicalSize,
-    RenderContext, Application, AppSender, PressedKey, EngineState, run,
-    WindowAction, xdg_toplevel, PointerCursorIcon as CursorIcon,
-    LayerSettings, LayerKind, LayerAnchor, LayerKeyboardInteractivity,
+//! The client-facing names, re-exported in one place: what an app's
+//! `use cce_ui::engine::{...}` reaches. The portable contract first; the
+//! native shell's entry point and Wayland-typed items after it.
+
+pub use crate::backend::app::{
+    Application, AppSender, LogicalPosition, LogicalSize, RenderContext, WindowAction, WindowSettings,
+};
+pub use crate::backend::driver::PressedKey;
+pub use crate::backend::tessellate::{
+    Vertex, LineCap,
     quad_vertices, quad_vertices_with_clip, quad_vertices_clipped, line_vertices,
     vector_vertices, rounded_rect_vertices_corners, push_rounded_rect_vertices_corners,
     rounded_rect_vertices, push_rounded_rect_vertices, plate_bevel_vertices,
@@ -10,5 +15,11 @@ pub use crate::backend::window_runner::{
     extra_quad_vertices, push_extra_quad_vertices, extra_quad_vertices_clipped,
     push_extra_quad_vertices_clipped, circle_vertices, circle_border_vertices,
     arc_background_vertices, push_arc_background_vertices, push_plate_solid_border_vertices,
-    get_text_buffer_laid_out, shaped_cluster_offsets, shaping_for,
 };
+pub use crate::backend::text::{get_text_buffer_laid_out, shaped_cluster_offsets, shaping_for};
+pub use cursor_icon::CursorIcon;
+
+#[cfg(not(target_arch = "wasm32"))]
+pub use crate::backend::app::{LayerAnchor, LayerKeyboardInteractivity, LayerKind, LayerSettings};
+#[cfg(not(target_arch = "wasm32"))]
+pub use crate::backend::window_runner::{run, xdg_toplevel, EngineState};
diff --git a/src/lib.rs b/src/lib.rs
index 43d8a5a..fdab53f 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -1,3 +1,7 @@
+// Modules under `cfg(not(target_arch = "wasm32"))` are the native shell and
+// renderer (Wayland, Vulkan, the compositor IPC, file dialogs). Everything
+// else builds for the browser too: `cargo check --lib --target
+// wasm32-unknown-unknown` is the check.
 pub mod color;
 pub mod widget;
 pub mod config;
@@ -5,7 +9,9 @@ pub mod input;
 pub mod history;
 pub mod layout;
 pub mod relief_spec;
+#[cfg(not(target_arch = "wasm32"))]
 pub mod wayland;
+#[cfg(not(target_arch = "wasm32"))]
 pub mod protocol;
 pub mod engine;
 pub mod scale;
@@ -14,11 +20,15 @@ pub mod backend;
 pub mod context;
 pub mod draw;
 pub mod scene;
+#[cfg(not(target_arch = "wasm32"))]
 pub mod file_dialog;
 pub mod icon;
+#[cfg(not(target_arch = "wasm32"))]
 pub mod ipc;
+#[cfg(not(target_arch = "wasm32"))]
 pub mod mcp;
 pub mod motion;
+#[cfg(not(target_arch = "wasm32"))]
 pub mod vk;
 
 pub mod colors {
diff --git a/src/motion.rs b/src/motion.rs
index 7554c36..de298e6 100644
--- a/src/motion.rs
+++ b/src/motion.rs
@@ -19,7 +19,8 @@
 //! (`0`/`off` or `1`/`on`) overrides the file for one process, for testing.
 
 use std::sync::Mutex;
-use std::time::{Duration, Instant};
+use std::time::Duration;
+use web_time::Instant;
 
 /// Where the switch lives. Shared with `cce-power-apply`, the writer.
 pub const STATE_PATH: &str = "/run/cce/animations";
diff --git a/src/widget/container/treelist.rs b/src/widget/container/treelist.rs
index 9e92fd8..5d6ef1b 100644
--- a/src/widget/container/treelist.rs
+++ b/src/widget/container/treelist.rs
@@ -188,7 +188,7 @@ pub struct TreeList {
     pub deleted_key_path: Option<String>,
     pub edit_box: crate::widget::Adapted<TextBox>,
     pub editing_key_idx: Option<usize>,
-    pub double_click_timer: Option<(std::time::Instant, usize)>,
+    pub double_click_timer: Option<(web_time::Instant, usize)>,
     pub rename_request: Option<(String, String)>,
 }
 
@@ -459,7 +459,7 @@ impl TreeList {
                     let item = self.items[row_idx].clone();
                     
                     let mut is_double = false;
-                    let now = std::time::Instant::now();
+                    let now = web_time::Instant::now();
                     if let Some((prev_time, prev_row)) = self.double_click_timer {
                         if prev_row == row_idx && now.duration_since(prev_time).as_millis() < 300 {
                             is_double = true;
diff --git a/src/widget/core.rs b/src/widget/core.rs
index 4a8cbe6..3f5c9e9 100644
--- a/src/widget/core.rs
+++ b/src/widget/core.rs
@@ -448,7 +448,7 @@ pub mod context_menu {
     struct Turning {
         /// The plate as it stood when the turn began: its size and its rows.
         from: Box<ContextMenuState>,
-        start: std::time::Instant,
+        start: web_time::Instant,
         /// +1 forward (the page comes in from the right, where `›` points),
         /// -1 back.
         dir: f32,
@@ -514,7 +514,7 @@ pub mod context_menu {
         /// When the menu was last hidden: a page shown in the same moment
         /// turns from it, as a host that closes one menu and shows the next
         /// in one dispatch means it to.
-        hidden_at: Option<std::time::Instant>,
+        hidden_at: Option<web_time::Instant>,
         /// The slider row a press took hold of, until the release.
         pub slider_drag: Option<usize>,
         /// A trackpad's leftover fraction of a wheel notch.
@@ -684,7 +684,7 @@ pub mod context_menu {
             self.turned = true;
             self.turning = from.map(|from| Turning {
                 from,
-                start: std::time::Instant::now(),
+                start: web_time::Instant::now(),
                 dir: if back.is_some() { 1.0 } else { -1.0 },
             });
             if let Some(title) = back {
@@ -733,7 +733,7 @@ pub mod context_menu {
             from.hovered_item = None;
             from.w = w;
             from.h = h;
-            self.turning = Some(Turning { from: Box::new(from), start: std::time::Instant::now(), dir: if forward { 1.0 } else { -1.0 } });
+            self.turning = Some(Turning { from: Box::new(from), start: web_time::Instant::now(), dir: if forward { 1.0 } else { -1.0 } });
         }
 
         /// How far the turn in progress has gone, eased, 0..1; `None` when
@@ -1040,7 +1040,7 @@ pub mod context_menu {
             self.back_hovered = false;
             self.turn = None;
             self.turning = None;
-            self.hidden_at = Some(std::time::Instant::now());
+            self.hidden_at = Some(web_time::Instant::now());
         }
 
         pub fn hit_test(&self, px: f32, py: f32) -> bool {
diff --git a/src/widget/display/graph.rs b/src/widget/display/graph.rs
index 01b2f7e..48fc3d9 100644
--- a/src/widget/display/graph.rs
+++ b/src/widget/display/graph.rs
@@ -308,7 +308,7 @@ pub struct Graph {
     /// the first press's timer never survived to the second press, so
     /// double-click detection could not fire at all. Same id-keyed survival
     /// as `selected_id` and the hovered-port remap.
-    double_click_timer: Option<(std::time::Instant, String)>,
+    double_click_timer: Option<(web_time::Instant, String)>,
     grid_snap_enabled: bool,
     node_geom_toggled: Option<(usize, bool)>,
 
@@ -1330,7 +1330,7 @@ impl Graph {
             }
             if let Some((nx, ny, nw, nh)) = self.node_rect(i) {
                 if px >= nx && px < nx + nw && py >= ny && py < ny + nh {
-                    let now = std::time::Instant::now();
+                    let now = web_time::Instant::now();
                     let clicked_id = self.nodes[i].id.clone();
                     if let Some((prev_time, prev_id)) = self.double_click_timer.take() {
                         if prev_id == clicked_id && now.duration_since(prev_time) < std::time::Duration::from_millis(500) {
diff --git a/src/widget/doc_editor/mod.rs b/src/widget/doc_editor/mod.rs
index 6b4a992..794fe97 100644
--- a/src/widget/doc_editor/mod.rs
+++ b/src/widget/doc_editor/mod.rs
@@ -25,7 +25,8 @@ pub mod buffer;
 pub mod layout;
 pub mod preview;
 
-use std::time::{Duration, Instant};
+use std::time::Duration;
+use web_time::Instant;
 
 pub use buffer::{Buffer, EditKind, Pos};
 pub use layout::EditorTheme;
diff --git a/src/widget/input/color_selector.rs b/src/widget/input/color_selector.rs
index 56b8fff..b088d6c 100644
--- a/src/widget/input/color_selector.rs
+++ b/src/widget/input/color_selector.rs
@@ -654,31 +654,7 @@ impl Input for ColorSelector {
                 self.command.clone()
             };
 
-            // Ask the compositor to open the picker at this control instead of
-            // its remembered position: the pointer is on the swatch right now,
-            // so its location IS the control's location. One-shot, best-effort
-            // (`place-next` consumed at the picker's map; ignored off-cce).
-            if let Ok(reply) = crate::ipc::send_command("cce", "pointer-location") {
-                let mut px = None;
-                let mut py = None;
-                for tok in reply.split_whitespace() {
-                    if let Some(v) = tok.strip_prefix("x=") {
-                        px = v.parse::<f64>().ok();
-                    } else if let Some(v) = tok.strip_prefix("y=") {
-                        py = v.parse::<f64>().ok();
-                    }
-                }
-                if let (Some(x), Some(y)) = (px, py) {
-                    let app_id = std::path::Path::new(&self.command)
-                        .file_name()
-                        .map(|n| n.to_string_lossy().into_owned())
-                        .unwrap_or_else(|| self.command.clone());
-                    let _ = crate::ipc::send_command(
-                        "cce",
-                        &format!("place-next {} {:.0} {:.0}", app_id, x, y),
-                    );
-                }
-            }
+            place_picker_at_pointer(&self.command);
 
             let mut cmd = std::process::Command::new(&cmd_path);
             cmd.arg(&hex);
@@ -828,6 +804,39 @@ fn parse_hex(s: &str) -> Option<[u8; 4]> {
     crate::color::parse_hex_bytes(s)
 }
 
+/// Ask the compositor to open the picker at this control instead of its
+/// remembered position. Native only: in a browser there is no compositor to ask.
+#[cfg(not(target_arch = "wasm32"))]
+fn place_picker_at_pointer(command: &str) {
+    // The pointer is on the swatch right now, so its location IS the
+    // control's location. One-shot, best-effort (`place-next` consumed at
+    // the picker's map; ignored off-cce).
+    if let Ok(reply) = crate::ipc::send_command("cce", "pointer-location") {
+        let mut px = None;
+        let mut py = None;
+        for tok in reply.split_whitespace() {
+            if let Some(v) = tok.strip_prefix("x=") {
+                px = v.parse::<f64>().ok();
+            } else if let Some(v) = tok.strip_prefix("y=") {
+                py = v.parse::<f64>().ok();
+            }
+        }
+        if let (Some(x), Some(y)) = (px, py) {
+            let app_id = std::path::Path::new(&command)
+                .file_name()
+                .map(|n| n.to_string_lossy().into_owned())
+                .unwrap_or_else(|| command.to_string());
+            let _ = crate::ipc::send_command(
+                "cce",
+                &format!("place-next {} {:.0} {:.0}", app_id, x, y),
+            );
+        }
+    }
+}
+
+#[cfg(target_arch = "wasm32")]
+fn place_picker_at_pointer(_command: &str) {}
+
 #[cfg(test)]
 mod tests {
     use super::*;
@@ -1086,5 +1095,3 @@ mod tests {
     }
 
 }
-
-
diff --git a/src/widget/input/dropdown.rs b/src/widget/input/dropdown.rs
index 0dbb62a..d770b50 100644
--- a/src/widget/input/dropdown.rs
+++ b/src/widget/input/dropdown.rs
@@ -218,7 +218,7 @@ pub struct Dropdown {
     /// close. `closing` keeps `open` (and the shrinking popover) alive while
     /// everything interactive gates on `!closing`.
     anim_from: f32,
-    anim_start: Option<std::time::Instant>,
+    anim_start: Option<web_time::Instant>,
     closing: bool,
     /// Per-frame SNAPSHOT of the wall-clock progress, refreshed in `tick`
     /// (before each render) and on every routed event. All geometry readers —
@@ -366,7 +366,7 @@ impl Dropdown {
 
     fn begin_open(&mut self) {
         self.anim_from = self.anim_progress_now();
-        self.anim_start = Some(std::time::Instant::now());
+        self.anim_start = Some(web_time::Instant::now());
         self.open = true;
         self.closing = false;
         self.anim_snap = self.anim_progress_now();
@@ -377,7 +377,7 @@ impl Dropdown {
             return;
         }
         self.anim_from = self.anim_progress_now();
-        self.anim_start = Some(std::time::Instant::now());
+        self.anim_start = Some(web_time::Instant::now());
         self.closing = true;
         self.anim_snap = self.anim_progress_now();
     }
@@ -409,7 +409,7 @@ impl Dropdown {
     /// wall clock).
     #[cfg(test)]
     fn land_anim_for_test(&mut self) {
-        self.anim_start = Some(std::time::Instant::now() - std::time::Duration::from_secs(1));
+        self.anim_start = Some(web_time::Instant::now() - std::time::Duration::from_secs(1));
         self.settle_anim();
     }
 
diff --git a/src/widget/input/ramp.rs b/src/widget/input/ramp.rs
index f5d2dbb..67c9430 100644
--- a/src/widget/input/ramp.rs
+++ b/src/widget/input/ramp.rs
@@ -126,7 +126,7 @@ pub struct Ramp {
     /// the last scroll-event instant: when the event stream stops, the tick
     /// keeps the latched key coasting with exponential decay.
     scroll_vel: (f32, f32),
-    last_key_scroll: Option<std::time::Instant>,
+    last_key_scroll: Option<web_time::Instant>,
 
     // Child controls for key editing & deletion. The key pad is a 2-axis
     // slider driving the selected key's position (x) and value (y).
@@ -1674,7 +1674,7 @@ impl Input for Ramp {
                         // applied delta over inter-event time. A leisurely
                         // wheel produces negligible velocity (big gaps clamp
                         // to 0.1s); fast trackpad streams build real speed.
-                        let now = std::time::Instant::now();
+                        let now = web_time::Instant::now();
                         let dt_ev = self
                             .last_key_scroll
                             .map(|t| now.duration_since(t).as_secs_f32())
diff --git a/src/widget/input/slider.rs b/src/widget/input/slider.rs
index 4c7d38a..c580a1a 100644
--- a/src/widget/input/slider.rs
+++ b/src/widget/input/slider.rs
@@ -48,7 +48,7 @@ pub struct Slider {
     /// stream stops (fingers lifted), `tick` keeps the value coasting with
     /// exponential decay instead of stopping dead.
     scroll_vel: f32,
-    last_wheel: Option<std::time::Instant>,
+    last_wheel: Option<web_time::Instant>,
     /// Keyboard focus (FocusIn / FocusOut): the band lights in the highlight;
     /// the arrows adjust, Home / End go to the ends, Enter opens the readout.
     focused: bool,
@@ -474,7 +474,7 @@ impl Input for Slider {
                         // negligible velocity (big gaps clamp to 0.1s); fast
                         // trackpad streams build real speed. Hitting an end
                         // stops dead — no glide pinned at the bounds.
-                        let now = std::time::Instant::now();
+                        let now = web_time::Instant::now();
                         let idt = self
                             .last_wheel
                             .map_or(0.1, |l| now.duration_since(l).as_secs_f32())
diff --git a/src/widget/line_edit.rs b/src/widget/line_edit.rs
index 96ff869..41dc911 100644
--- a/src/widget/line_edit.rs
+++ b/src/widget/line_edit.rs
@@ -19,7 +19,8 @@
 //!
 //! [`TextBox`]: super::TextBox
 
-use std::time::{Duration, Instant};
+use std::time::Duration;
+use web_time::Instant;
 
 use crate::history::History;
 use crate::widget::{ElementState, Key, KeyEvent, NamedKey};
diff --git a/src/widget/scroll_motion.rs b/src/widget/scroll_motion.rs
index 52f2292..2413545 100644
--- a/src/widget/scroll_motion.rs
+++ b/src/widget/scroll_motion.rs
@@ -41,7 +41,7 @@
 //! ```
 
 use std::sync::atomic::{AtomicU8, Ordering};
-use std::time::Instant;
+use web_time::Instant;
 
 use crate::widget::MouseScrollDelta;
 
diff --git a/src/widget/side_swipe.rs b/src/widget/side_swipe.rs
index 295e99a..8a0a4fe 100644
--- a/src/widget/side_swipe.rs
+++ b/src/widget/side_swipe.rs
@@ -27,7 +27,8 @@
 
 use crate::widget::scroll_motion::{current_scroll_phase, ScrollPhase};
 use crate::widget::MouseScrollDelta;
-use std::time::{Duration, Instant};
+use std::time::Duration;
+use web_time::Instant;
 
 /// How far the fingers travel to the side, in the runner's pixel delta,
 /// before a swipe is a page turn. A deliberate flick covers this in two or