diff --git a/Cargo.lock b/Cargo.lock
index d373ce9..6f3e1ee 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -881,6 +881,23 @@ dependencies = [
"serde_json",
]
+[[package]]
+name = "designcraft-web"
+version = "0.1.0"
+dependencies = [
+ "designcraft-engine",
+ "designcraft-ui-egui",
+ "eframe",
+ "egui",
+ "js-sys",
+ "log",
+ "rfd",
+ "serde_json",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "web-sys",
+]
+
[[package]]
name = "dispatch"
version = "0.2.0"
diff --git a/Cargo.toml b/Cargo.toml
index 50220d7..967cb6d 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,7 +1,7 @@
[workspace]
resolver = "3"
members = ["crates/*", "apps/*", "xtask"]
-default-members = ["crates/*", "apps/*", "xtask"]
+default-members = ["crates/*", "apps/designcraft", "apps/designcraft-cli", "xtask"]
[workspace.package]
version = "0.1.0"
diff --git a/README.md b/README.md
index 79cb6a3..3a1d52c 100644
--- a/README.md
+++ b/README.md
@@ -43,6 +43,15 @@ cargo xtask ci # fmt, clippy, tests,
Drive a running app: send JSON lines to `127.0.0.1:7979` — see [`docs/control-protocol.md`](docs/control-protocol.md).
+### Web
+
+```sh
+cd apps/designcraft-web && trunk build --release # → dist/web (serve it with any static server)
+cd apps/designcraft-web && trunk serve --release # http://127.0.0.1:8767
+```
+
+Needs [trunk](https://trunkrs.dev) and the `wasm32-unknown-unknown` target. The same app runs through eframe's web runner on WebGPU, falling back to WebGL2 (`?webgl` forces it; `?sample` opens the sample magazine). Open and Place use the browser's file picker (dropping files works too); Save and Export download the file. The web build has no control channel.
+
## Architecture
An engine-first Cargo workspace with enforced layering (`cargo xtask layers`); the egui frontend is a separate crate, so the UI can be swapped without touching the engine.
@@ -56,7 +65,7 @@ An engine-first Cargo workspace with enforced layering (`cargo xtask layers`); t
| L4 | `tools` (pointer events → commands + overlays) |
| L5 | `engine` (session, history, command registry) |
| L6 | `ui-egui` (InDesign-style UI, control channel) |
-| L7 | `apps/designcraft`, `apps/designcraft-cli` |
+| L7 | `apps/designcraft`, `apps/designcraft-cli`, `apps/designcraft-web` |
Status and milestones: **[ROADMAP.md](ROADMAP.md)**. Contributor and agent rules (clean-room, asset policy, quality gates): [`AGENTS.md`](AGENTS.md). Every bundled asset is listed with its licence in [`ASSETS.md`](ASSETS.md).
diff --git a/ROADMAP.md b/ROADMAP.md
index 1420695..6366c59 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -11,8 +11,9 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope
- UI (egui): application bar + menus, Control panel (object and text modes), Tools panel with flyouts, document tabs, rulers, pasteboard, guides (margins, columns, bleed, baseline grid), frame edges, selection handles, text ports and threads, Properties / Pages / Layers dock, Swatches, Styles, Character, Paragraph, Stroke, Text Wrap panels, New Document dialog, ⌘K palette, 4 brightness themes.
- Tools: Selection (click, marquee, move, Alt-duplicate, resize handles), Direct Selection (basic), Type (draw frame, click caret, select, type), Rectangle/Ellipse/Polygon (+ frame variants), Line, Hand, Zoom.
- ~100 commands, all reachable through the JSON control channel; headless CLI rendering to PNG.
+- Web build (`apps/designcraft-web`, trunk): the same UI on WebGPU with a WebGL2 fallback; open/place via the browser file picker or drag-and-drop, save/export as downloads.
-**Next (in order):** MCP server · PDF export (krilla, PDF/X-4) · IDML import/export · native format as zip · web build · Pen tool & Direct Selection anchors · Story Editor · Find/Change (+GREP) · tables · Liang hyphenation · smart guides · Links panel + relink · Preflight · TOC/index/footnotes · EPUB.
+**Next (in order):** MCP server · PDF export (krilla, PDF/X-4) · IDML import/export · native format as zip · Pen tool & Direct Selection anchors · Story Editor · Find/Change (+GREP) · tables · Liang hyphenation · smart guides · Links panel + relink · Preflight · TOC/index/footnotes · EPUB.
## Milestones
diff --git a/apps/designcraft-web/Cargo.toml b/apps/designcraft-web/Cargo.toml
new file mode 100644
index 0000000..d27b3c4
--- /dev/null
+++ b/apps/designcraft-web/Cargo.toml
@@ -0,0 +1,30 @@
+[package]
+name = "designcraft-web"
+description = "DesignCraft in the browser (eframe web runner, WebGPU with WebGL2 fallback)"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+publish = false
+
+[lints]
+workspace = true
+
+[[bin]]
+name = "designcraft-web"
+path = "src/main.rs"
+
+[dependencies]
+designcraft-engine = { workspace = true }
+designcraft-ui-egui = { workspace = true }
+egui = { workspace = true }
+log = { workspace = true }
+serde_json = { workspace = true }
+
+[target.'cfg(target_arch = "wasm32")'.dependencies]
+# `wgpu` pulls egui-wgpu's defaults: WebGPU plus the WebGL2 fallback.
+eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit"] }
+rfd = { workspace = true }
+wasm-bindgen = "0.2"
+wasm-bindgen-futures = "0.4"
+js-sys = "0.3"
+web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration"] }
diff --git a/apps/designcraft-web/Trunk.toml b/apps/designcraft-web/Trunk.toml
new file mode 100644
index 0000000..19126e5
--- /dev/null
+++ b/apps/designcraft-web/Trunk.toml
@@ -0,0 +1,10 @@
+# Build with: trunk build --release (from apps/designcraft-web)
+[build]
+target = "index.html"
+dist = "../../dist/web"
+public_url = "./"
+filehash = true
+
+[serve]
+addresses = ["127.0.0.1"]
+port = 8767
diff --git a/apps/designcraft-web/index.html b/apps/designcraft-web/index.html
new file mode 100644
index 0000000..b05be39
--- /dev/null
+++ b/apps/designcraft-web/index.html
@@ -0,0 +1,19 @@
+
+
+
+
+
+ DesignCraft
+
+
+
+
+
+ Loading DesignCraft…
+
+
diff --git a/apps/designcraft-web/src/main.rs b/apps/designcraft-web/src/main.rs
new file mode 100644
index 0000000..5ccd73a
--- /dev/null
+++ b/apps/designcraft-web/src/main.rs
@@ -0,0 +1,28 @@
+//! DesignCraft in the browser.
+//!
+//! Runs the same [`designcraft_ui_egui::DesignApp`] as the desktop app through eframe's web
+//! runner (wgpu: WebGPU where available, WebGL2 otherwise). Build with `trunk build --release`
+//! from this directory (output in `dist/web`).
+//!
+//! Differences from the desktop app:
+//! - no TCP control channel (browsers can't listen on sockets);
+//! - File → Open / Place use the browser file picker; bytes arrive asynchronously through
+//! `Services::inbox` (`.designcraft` → `file.openBytes`, images → `file.place`);
+//! - Save and Export trigger a browser download;
+//! - dropped files are read asynchronously by `web::WebShell` and delivered through the inbox.
+//!
+//! URL query flags: `?webgl` forces the WebGL2 backend instead of WebGPU; `?sample` opens the
+//! sample magazine on start.
+
+#[cfg(target_arch = "wasm32")]
+mod web;
+
+#[cfg(target_arch = "wasm32")]
+fn main() {
+ web::start();
+}
+
+#[cfg(not(target_arch = "wasm32"))]
+fn main() {
+ eprintln!("designcraft-web only runs in the browser: build it with `trunk build --release` in apps/designcraft-web");
+}
diff --git a/apps/designcraft-web/src/web.rs b/apps/designcraft-web/src/web.rs
new file mode 100644
index 0000000..b440ef3
--- /dev/null
+++ b/apps/designcraft-web/src/web.rs
@@ -0,0 +1,163 @@
+//! The browser shell: web `Services`, drag-and-drop, and the eframe web runner.
+
+use designcraft_engine::Session;
+use designcraft_ui_egui::{DesignApp, Inbox, Services};
+use wasm_bindgen::JsCast as _;
+
+const DOC_EXTS: &[&str] = &["designcraft"];
+const IMAGE_EXTS: &[&str] = &["png", "jpg", "jpeg", "gif", "webp", "tif", "tiff"];
+const CANVAS_ID: &str = "designcraft_canvas";
+const LOADING_ID: &str = "designcraft_loading";
+
+pub fn start() {
+ eframe::WebLogger::init(log::LevelFilter::Info).ok();
+ wasm_bindgen_futures::spawn_local(async {
+ let Some(document) = web_sys::window().and_then(|w| w.document()) else {
+ log::error!("no document");
+ return;
+ };
+ let Some(canvas) = document.get_element_by_id(CANVAS_ID).and_then(|e| e.dyn_into::().ok()) else {
+ log::error!("missing