diff --git a/ROADMAP.md b/ROADMAP.md
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -2,7 +2,7 @@
 
 **Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents)
 
-> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-26 · **Change:** minor (colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); branches in live sessions and branch history entries, gap 17; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7)
+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-26 · **Change:** minor (web host bridge: `?host=parent` in an iframe or `data-host="page"` on the canvas hands a page the app's files instead of downloads and reports unsaved changes, documented in the hosting guide; colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); branches in live sessions and branch history entries, gap 17; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7)
 
 FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow,
 native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through
@@ -133,6 +133,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
 ## Progress log
 
 - **2026-10-26** · Non-ASCII layer names transliterate in code export (M6, #1235): `class_name` runs the name through `deunicode` before its ASCII filter, so 로그인 화면 → `rogeuin-hwamyeon`, кнопка → `knopka`, über → `uber` in the HTML/CSS/React/Leptos class names, component roots and `data-*` variant attributes (SwiftUI/Compose type names unchanged); a name left with nothing (symbols only) takes the node's kind (`.text`, `.frame`) instead of `layer-`, and the 40-character cut lands on a word boundary. New dependency: deunicode (pure Rust tables, zero deps, BSD-3-Clause, the `slug` crate's base). `html_codegen_tests`. Numbers unchanged.
+- **2026-10-26** · Web host bridge: `?host=parent` (a same-origin page embedding FigCraft in an iframe) or `data-host="page"` on the canvas (the page's own document, over non-bubbling `CustomEvent`s) opens the host's file like File ▸ Open (over the browser open limit of 512 MB refused) and takes every file the app would otherwise download (`figcraft:save` with the `saveAs` rule), plus `figcraft:dirty`, `figcraft:ready` and `figcraft:failed`; `?author=` names the user for comments and board notes; host mode starts with no document. Unit tests cover the opt-in rule (only the exact `page` value), the `saveAs` rule and the open-name fallback. Numbers unchanged.
 - **2026-10-25** · egui 0.37 capture lifecycle: screenshots with hidden or unknown visibility queue, request a hidden paint, and expire after 60 seconds if no capture arrives (headless-tested contract). Logic-only edits, saves and inspection continue while hidden. Native visible GUI and forced hidden-window paint, browser and Wayland behavior remain unqualified; parity percentages are unchanged.
 - **2026-10-24** · Open, Save and the folder dialogs start in the folder of the last file or folder picked in the run (#1290): the Linux portal does not remember it between dialogs as the macOS and Windows panels do. A folder deleted since falls back to the system's choice. No numbers change.
 - **2026-10-24** · Boards (M12): board pages get a dot grid under their layers (drawn by the canvas render after the page colour, thinned out when zoomed out); a selected connector shows its ends as handles, and dragging one onto another object re-attaches it there, onto empty canvas leaves it free (`board.reattach`, one undo step, also over MCP); connectors can be curved, and elbows go around the objects they join when the short way would cross them. Checked in the app, through MCP and in the web build ([boards-parity](docs/boards-parity.md)).
@@ -349,6 +350,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
 | Date | Change | Summary |
 |---|---|---|
 | 2026-10-26 | minor | Non-ASCII layer names transliterate in code export (M6, #1235): `class_name` transliterates through the new `deunicode` dependency (pure Rust tables, zero deps, BSD-3-Clause), so Korean, Cyrillic, Latin-diacritic and CJK names become readable ASCII classes (`rogeuin-hwamyeon`, `knopka`, `uber`, `an-niu`) in HTML/CSS/React/Leptos output, component roots and `data-*` variant attributes; symbols-only names take the node's kind (`.text`) instead of `layer-`, and the 40-character cut lands on a word boundary. `html_codegen_tests` updated and extended. Numbers unchanged |
+| 2026-10-26 | minor | Web host bridge: `?host=parent` (a same-origin page embedding FigCraft in an iframe) or `data-host="page"` on the canvas (the page's own document, over non-bubbling `CustomEvent`s) opens the host's file like File ▸ Open and takes every file the app would otherwise download, with `figcraft:ready`, `figcraft:open`, `figcraft:save` (a `saveAs` rule), `figcraft:dirty` and `figcraft:failed`; `?author=` names the user for comments and board notes; documented in the hosting guide. Numbers unchanged |
 | 2026-10-25 | minor | egui 0.37 capture lifecycle: screenshots with hidden or unknown visibility queue, request a hidden paint, and expire after 60 seconds if no capture arrives (headless-tested contract). Logic-only edits, saves and inspection continue while hidden. Native visible GUI and forced hidden-window paint, browser and Wayland behavior remain unqualified; parity percentages are unchanged. |
 | 2026-10-24 | minor | Server memory (#1134): when the last person leaves a file nobody edited, figcraft-server keeps the client ids it issued in the log instead of rewriting the snapshot (compaction still follows `compact_after`/`compact_mb` and edits), and a snapshot is encoded straight into its file buffer, without a second copy of the body; the file format is unchanged. Numbers unchanged |
 | 2026-10-24 | minor | Boards (M12): a dot grid on board pages under the layers, connector ends dragged onto other objects or empty canvas (`board.reattach`), curved connectors, elbows that go around what they join. Boards row ~35% → ~37%, ~20% → ~21%, 30–60 h → 28–56 h; headline numbers unchanged (boards are outside the weighted sum) |
diff --git a/apps/figcraft-web/Cargo.toml b/apps/figcraft-web/Cargo.toml
--- a/apps/figcraft-web/Cargo.toml
+++ b/apps/figcraft-web/Cargo.toml
@@ -28,4 +28,4 @@ base64 = { workspace = true }
 serde_json = { workspace = true }
 js-sys = "0.3"
 wasm-bindgen-futures = "0.4"
-web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlCanvasElement", "HtmlElement", "HtmlAnchorElement", "Node", "Blob", "BlobPropertyBag", "File", "FileList", "HtmlInputElement", "EventTarget", "Url", "Location", "MediaQueryList", "Navigator", "Gamepad", "GamepadButton", "GamepadMappingType"] }
+web-sys = { version = "0.3", features = ["Window", "Document", "Element", "Event", "HtmlCanvasElement", "HtmlElement", "HtmlAnchorElement", "Node", "Blob", "BlobPropertyBag", "File", "FileList", "HtmlInputElement", "EventTarget", "Url", "Location", "MediaQueryList", "Navigator", "Gamepad", "GamepadButton", "GamepadMappingType", "MessageEvent", "UrlSearchParams", "CustomEvent", "CustomEventInit"] }
diff --git a/apps/figcraft-web/src/host.rs b/apps/figcraft-web/src/host.rs
new file mode 100644
--- /dev/null
+++ b/apps/figcraft-web/src/host.rs
@@ -0,0 +1,278 @@
+//! Host modes: a page that runs FigCraft (as an `<iframe>`, or in its own document) opens
+//! documents in it and stores what it saves instead of the browser downloading. The same
+//! messages as the other Crafting Apps' bridges.
+//!
+//! ## `?host=parent` — an `<iframe>` (such as a Nextcloud app)
+//!
+//! The framing page and FigCraft talk over `postMessage` between same-origin windows. Messages
+//! are plain objects with a `type`:
+//!
+//! - `figcraft:ready` (to the parent): `{ version }`, sent once the app is running and
+//!   listening; send a file now.
+//! - `figcraft:open` (from the parent): `{ name, bytes }`, bytes an `ArrayBuffer` or
+//!   `Uint8Array`. It opens exactly as a file picked with File › Open or dropped on the window
+//!   would (the app's own open queue, with its "Opening as a new file" card). A payload over the
+//!   browser's own open limit ([`figcraft_ui_egui::browser_can_read`]'s 512 MB) is refused before
+//!   its bytes are copied, and anything malformed is ignored.
+//! - `figcraft:save` (to the parent): `{ name, saveAs, bytes }` (a transferred `ArrayBuffer`) for
+//!   every file the app hands its download service — in host mode nothing reaches the browser's
+//!   downloads. The parent stores it and reports failures itself. `saveAs` says whether the file
+//!   becomes the document's file ([`crate::hostmode::save_as`]):
+//!   - A Save of the file the host opened, under its own name: `false`.
+//!   - A document file (`.figcraft` or `.json`, the formats `file.save` and `file.saveAs` write)
+//!     under a name the host didn't open — Save As, or a Save of a document the host never sent
+//!     (an untitled one): `true`. It becomes the document's file; the next Save sends its name
+//!     with `false`. Names compare without case.
+//!   - Everything else is a copy or export that never becomes the document: `false` — rendered
+//!     images, SVG and PDF, `.fig` copies, and the variables exports (a `.variables.json`
+//!     sidecar's `.json` extension doesn't make it a document).
+//! - `figcraft:dirty` (to the parent): `{ dirty }` whenever unsaved changes appear or go away.
+//! - `figcraft:failed` (to the parent): `{ error }` when the app couldn't start.
+//!
+//! Only the parent window on the page's own origin is heard or answered.
+//!
+//! ## `data-host="page"` — the page's own document
+//!
+//! A page that loads the module into its own document (its own `<canvas id="figcraft_canvas"
+//! data-host="page">`, no iframe) gets the same behaviour over DOM `CustomEvent`s on the canvas
+//! instead of `postMessage`: the same four events outward (`ready`, `save`, `dirty`, `failed`,
+//! with the message fields as the event's `detail`), and the page dispatches `figcraft:open` on
+//! the canvas with `detail: { name, bytes }` to open a document. Events don't bubble, so two
+//! editors on one page can't hear each other, and the canvas is the only target FigCraft
+//! listens on.
+//!
+//! In host mode the app starts with no document (the home screen) and the host sends the file to
+//! edit; `?author=` names the user for comments and board notes.
+
+use std::cell::{Cell, RefCell};
+use std::rc::Rc;
+
+use figcraft_ui_egui::FigApp;
+use wasm_bindgen::JsCast as _;
+use wasm_bindgen::JsValue;
+use wasm_bindgen::closure::Closure;
+
+/// The longest user name taken from `?author=`.
+const MAX_AUTHOR_CHARS: usize = 200;
+
+/// Files the host sent, on their way to the app's own open queue ([`figcraft_ui_egui::pending_open`]):
+/// `(name, bytes)`.
+pub type OpenQueue = Rc<RefCell<Vec<(String, Vec<u8>)>>>;
+
+/// How a host page is reached. The modes behave the same; they differ only in how an event goes
+/// out ([`Transport::send`]) and how an open request comes in ([`Bridge::listen`]).
+#[derive(Clone)]
+enum Transport {
+    /// `?host=parent`: `postMessage` between same-origin windows.
+    Parent { window: web_sys::Window, parent: web_sys::Window, origin: String },
+    /// `data-host="page"`: DOM `CustomEvent`s on the editor's own canvas.
+    Page { canvas: web_sys::HtmlCanvasElement },
+}
+
+impl Transport {
+    /// Hand an event to the host. In parent mode this is a `postMessage` whose object carries a
+    /// `type` field; in page mode a `CustomEvent` of that name on the canvas, whose `detail`
+    /// holds the fields and which never bubbles (two editors on one page must not hear each
+    /// other). `transfer` hands an `ArrayBuffer` to the parent instead of copying it (parent
+    /// mode only; the page's document already shares the buffer's bytes).
+    fn send(&self, event: &str, fields: &[(&str, JsValue)], transfer: Option<&js_sys::ArrayBuffer>) -> Result<(), String> {
+        match self {
+            Transport::Parent { parent, origin, .. } => {
+                let mut message_fields = Vec::with_capacity(fields.len() + 1);
+                message_fields.push(("type", JsValue::from_str(event)));
+                message_fields.extend_from_slice(fields);
+                let message = message(&message_fields).ok_or_else(|| "couldn't build the message".to_string())?;
+                let sent = match transfer {
+                    Some(buffer) => parent.post_message_with_transfer(&message, origin, &js_sys::Array::of1(buffer)),
+                    None => parent.post_message(&message, origin),
+                };
+                sent.map_err(|e| js_error(&e, "couldn't post the message"))
+            }
+            Transport::Page { canvas } => {
+                let detail = message(fields).ok_or_else(|| "couldn't build the event detail".to_string())?;
+                let init = web_sys::CustomEventInit::new();
+                // The builder methods (`bubbles(..)`, `detail(..)`) are deprecated in web-sys
+                // 0.3.106 and fail clippy -D warnings; use the setters.
+                init.set_bubbles(false);
+                init.set_detail(&detail);
+                let custom = web_sys::CustomEvent::new_with_event_init_dict(event, &init).map_err(|e| js_error(&e, "couldn't create the event"))?;
+                // A listener calling `preventDefault` would be the bool; not a send failure.
+                canvas.dispatch_event(&custom).map_err(|e| js_error(&e, "couldn't dispatch the event"))?;
+                Ok(())
+            }
+        }
+    }
+}
+
+#[derive(Clone)]
+pub struct Bridge {
+    transport: Transport,
+    /// The file the host opened, and after a `saveAs` hand-over the document's file: the name a
+    /// Save under (`saveAs: false`) is decided against.
+    host_file: Rc<RefCell<Option<String>>>,
+    /// The unsaved-changes state last reported to the host.
+    reported: Rc<Cell<Option<bool>>>,
+}
+
+impl Bridge {
+    /// The parent bridge for `?host=parent`. `None` when the page isn't framed (there is no
+    /// parent to talk to), so opening that URL directly stays the normal app with downloads, or
+    /// when the parent is on another origin: messages only go to this origin, so the browser
+    /// would drop every save without an error while the document showed as saved.
+    pub fn new() -> Option<Self> {
+        let window = web_sys::window()?;
+        let parent = window.parent().ok().flatten()?;
+        if js_sys::Object::is(&parent, &window) {
+            return None;
+        }
+        let origin = window.location().origin().ok()?;
+        // An opaque origin (a `file://` page in some browsers) can't be a `postMessage` target.
+        if origin == "null" {
+            return None;
+        }
+        // Reading another origin's location throws, which is `None` too.
+        if parent.location().origin().ok()? != origin {
+            return None;
+        }
+        Some(Self { transport: Transport::Parent { window, parent, origin }, host_file: Rc::default(), reported: Rc::default() })
+    }
+
+    /// The page bridge for `data-host="page"`: the host page runs FigCraft in its own document
+    /// and hears the editor's DOM `CustomEvent`s on this canvas.
+    pub fn for_page(canvas: &web_sys::HtmlCanvasElement) -> Self {
+        Self { transport: Transport::Page { canvas: canvas.clone() }, host_file: Rc::default(), reported: Rc::default() }
+    }
+
+    /// Hear the files the host sends, and queue each one on `opens` for the app's own open path
+    /// (the shell drains it into [`figcraft_ui_egui::pending_open`] every frame, so a host file
+    /// opens like a dropped one).
+    pub fn listen(&self, opens: OpenQueue, ctx: egui::Context) {
+        let host_file = self.host_file.clone();
+        match &self.transport {
+            Transport::Parent { window, parent, origin } => {
+                let parent = parent.clone();
+                let origin = origin.clone();
+                let on_message = Closure::<dyn FnMut(web_sys::MessageEvent)>::new(move |event: web_sys::MessageEvent| {
+                    if event.origin() != origin || !event.source().is_some_and(|s| js_sys::Object::is(&s, &parent)) {
+                        return;
+                    }
+                    let data = event.data();
+                    if string(&data, "type").as_deref() != Some("figcraft:open") {
+                        return;
+                    }
+                    deliver_open(&opens, &ctx, &host_file, &data);
+                });
+                if window.add_event_listener_with_callback("message", on_message.as_ref().unchecked_ref()).is_ok() {
+                    // The listener lives as long as the page.
+                    on_message.forget();
+                }
+            }
+            Transport::Page { canvas } => {
+                let on_open = Closure::<dyn FnMut(web_sys::Event)>::new(move |event: web_sys::Event| {
+                    // A plain `Event` of the same name carries no detail; only a `CustomEvent`.
+                    let Some(open) = event.dyn_ref::<web_sys::CustomEvent>() else { return };
+                    deliver_open(&opens, &ctx, &host_file, &open.detail());
+                });
+                if canvas.add_event_listener_with_callback("figcraft:open", on_open.as_ref().unchecked_ref()).is_ok() {
+                    // The listener lives as long as the page.
+                    on_open.forget();
+                }
+            }
+        }
+    }
+
+    /// `?author=`: the host's name for the user signs comments and board notes.
+    pub fn set_author(app: &mut FigApp, author: &str) {
+        let author: String = author.trim().chars().take(MAX_AUTHOR_CHARS).collect();
+        if !author.is_empty() {
+            app.session.prefs.user_name = author;
+        }
+    }
+
+    pub fn post_ready(&self) {
+        let _ = self.transport.send("figcraft:ready", &[("version", env!("CARGO_PKG_VERSION").into())], None);
+    }
+
+    /// The app couldn't start (no WebGPU or WebGL2, for example): let the host say so.
+    pub fn post_failed(&self, error: &str) {
+        let _ = self.transport.send("figcraft:failed", &[("error", error.into())], None);
+    }
+
+    /// Hand a file the app would otherwise download to the host; `false` when nothing was
+    /// delivered. The `saveAs` rule is this module's documentation's.
+    pub fn post_save(&self, path: &str, bytes: &[u8]) -> bool {
+        let name = file_name(path);
+        let host_file = self.host_file.borrow().clone();
+        let save_as = crate::hostmode::save_as(&name, host_file.as_deref());
+        // `figcraft:save` carries the bytes as an `ArrayBuffer`; the parent bridge transfers it.
+        let buffer = js_sys::Uint8Array::from(bytes).buffer();
+        let fields: [(&str, JsValue); 3] = [("name", name.as_str().into()), ("saveAs", save_as.into()), ("bytes", buffer.clone().into())];
+        let sent = self.transport.send("figcraft:save", &fields, Some(&buffer)).is_ok();
+        // After a Save As hand-over the host has the new file: the next Save of it sends its name
+        // with `saveAs: false`. A failed send changes nothing, so a retry is still a Save As.
+        if sent && save_as {
+            *self.host_file.borrow_mut() = Some(name);
+        }
+        sent
+    }
+
+    /// Report unsaved changes when they appear or go away, not every frame.
+    pub fn set_dirty(&self, dirty: bool) {
+        if self.reported.get() != Some(dirty) && self.transport.send("figcraft:dirty", &[("dirty", dirty.into())], None).is_ok() {
+            self.reported.set(Some(dirty));
+        }
+    }
+}
+
+/// The file an open message or event carries, queued for the app's own open path, or nothing when
+/// the payload isn't one (no bytes, or over the open limit).
+fn deliver_open(opens: &OpenQueue, ctx: &egui::Context, host_file: &RefCell<Option<String>>, data: &JsValue) {
+    let name = crate::hostmode::open_name(string(data, "name").as_deref());
+    let Some(bytes) = payload_bytes(data, &name) else { return };
+    // The document's file is now the one the host sent: a Save of it under this name is
+    // `saveAs: false`.
+    *host_file.borrow_mut() = Some(name.clone());
+    opens.borrow_mut().push((name, bytes));
+    ctx.request_repaint();
+}
+
+/// The `bytes` of an open message or event as owned bytes, refused over the browser's own open
+/// limit ([`figcraft_ui_egui::browser_can_read`]) before they are copied. `bytes` is an
+/// `ArrayBuffer` or `Uint8Array`.
+fn payload_bytes(data: &JsValue, name: &str) -> Option<Vec<u8>> {
+    let bytes = js_sys::Reflect::get(data, &JsValue::from_str("bytes")).ok()?;
+    let len = if let Some(array) = bytes.dyn_ref::<js_sys::Uint8Array>() {
+        array.length() as f64
+    } else {
+        bytes.dyn_ref::<js_sys::ArrayBuffer>()?.byte_length() as f64
+    };
+    if figcraft_ui_egui::browser_can_read(name, len).is_err() {
+        log::warn!("host file {name} is too large to open in the browser; not opened");
+        return None;
+    }
+    Some(if let Some(array) = bytes.dyn_ref::<js_sys::Uint8Array>() { array.to_vec() } else { js_sys::Uint8Array::new(&bytes).to_vec() })
+}
+
+/// The last component of `path`, or the whole of it when there is none.
+fn file_name(path: &str) -> String {
+    std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string())
+}
+
+/// A message object or `CustomEvent` detail with the given fields, or `None` if one couldn't be
+/// built (a field that isn't a valid property name, say).
+fn message(fields: &[(&str, JsValue)]) -> Option<js_sys::Object> {
+    let m = js_sys::Object::new();
+    for (key, value) in fields {
+        js_sys::Reflect::set(&m, &JsValue::from_str(key), value).ok()?;
+    }
+    Some(m)
+}
+
+fn string(data: &JsValue, key: &str) -> Option<String> {
+    js_sys::Reflect::get(data, &JsValue::from_str(key)).ok()?.as_string()
+}
+
+/// A `JsValue` error as a string, after `what` went wrong.
+fn js_error(e: &JsValue, what: &str) -> String {
+    format!("{what}: {}", e.as_string().unwrap_or_else(|| format!("{e:?}")))
+}
diff --git a/apps/figcraft-web/src/hostmode.rs b/apps/figcraft-web/src/hostmode.rs
new file mode 100644
--- /dev/null
+++ b/apps/figcraft-web/src/hostmode.rs
@@ -0,0 +1,109 @@
+//! Pure host-mode rules, shared by the bridge ([`crate::host`]) and testable without a browser
+//! (like [`crate::query`]): what opts a page into hosting FigCraft in its own document, the name
+//! the bridge fills in when an open request has none, and whether a handed-over file becomes the
+//! document's file.
+
+/// The one `data-host` value that opts a page in: the exact string `"page"`.
+pub const PAGE_HOST: &str = "page";
+
+/// The file name used when an open request carries no usable one.
+pub const DEFAULT_OPEN_NAME: &str = "Untitled.figcraft";
+
+/// The extensions of files that can be a document's own file: the formats `file.save` and
+/// `file.saveAs` write (`file.open` opens both as native documents). A `.fig` file is an export
+/// copy instead, and so is everything else the app hands over (rendered images, SVG, PDF,
+/// variables).
+const DOC_EXTS: &[&str] = &["figcraft", "json"];
+
+/// The suffix `variable.export`'s downloads carry: a `.json` that is a variables sidecar, not a
+/// document, so its extension alone can't tell the `saveAs` decision.
+const VARIABLES_SIDECAR: &str = ".variables.json";
+
+/// Does this `data-host` value ask for page host mode? Only the exact value [`PAGE_HOST`] counts;
+/// anything else is ignored, so the page host mode stays opt-in. `?host=parent`, the parent
+/// bridge's URL opt-in, is answered before this is asked.
+pub fn page_host_requested(data_host: Option<&str>) -> bool {
+    data_host == Some(PAGE_HOST)
+}
+
+/// A usable file name for an open request: the host's name when it isn't blank, else the
+/// [`DEFAULT_OPEN_NAME`].
+pub fn open_name(name: Option<&str>) -> String {
+    name.filter(|n| !n.trim().is_empty()).unwrap_or(DEFAULT_OPEN_NAME).to_string()
+}
+
+/// Whether the file `name` becomes the document's own file when handed over (`saveAs: true`),
+/// given the file the host opened (`None`: the host never sent one). The rule:
+///
+/// - A document file (`.figcraft` or `.json`, [`DOC_EXTS`]'s formats) under a name the host didn't
+///   open — Save As, or a Save of a document the host never sent — is the document's new file:
+///   `true`. Names compare without case.
+/// - A Save of the host's own file under its name is not: `false`.
+/// - Everything else is a copy or export that never becomes the document — rendered images, SVG,
+///   PDF, `.fig` copies, variables — and the variables sidecar counts as one whatever its `.json`
+///   extension says: `false`.
+pub fn save_as(name: &str, host_file: Option<&str>) -> bool {
+    if name.to_ascii_lowercase().ends_with(VARIABLES_SIDECAR) {
+        return false;
+    }
+    let ext = std::path::Path::new(name).extension().and_then(std::ffi::OsStr::to_str);
+    if !ext.is_some_and(|e| DOC_EXTS.iter().any(|x| x.eq_ignore_ascii_case(e))) {
+        return false;
+    }
+    match host_file {
+        Some(host) => !host.eq_ignore_ascii_case(name),
+        None => true,
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn only_the_exact_page_value_opts_in() {
+        assert!(page_host_requested(Some("page")));
+        for off in [None, Some(""), Some("Page"), Some("PAGE"), Some(" page"), Some("page "), Some("parent")] {
+            assert!(!page_host_requested(off), "{off:?} must keep the page host mode off");
+        }
+    }
+
+    #[test]
+    fn open_names_fall_back_to_the_default_when_blank() {
+        assert_eq!(open_name(Some("Poster.figcraft")), "Poster.figcraft");
+        assert_eq!(open_name(Some(" Poster.figcraft ")), " Poster.figcraft ", "a name with its spacing is kept");
+        assert_eq!(open_name(Some("")), DEFAULT_OPEN_NAME);
+        assert_eq!(open_name(Some("   ")), DEFAULT_OPEN_NAME);
+        assert_eq!(open_name(None), DEFAULT_OPEN_NAME);
+    }
+
+    #[test]
+    fn a_save_of_the_host_s_file_is_not_save_as() {
+        assert!(!save_as("Poster.figcraft", Some("Poster.figcraft")));
+        assert!(!save_as("poster.FIGCRAFT", Some("Poster.figcraft")), "names compare without case");
+        assert!(!save_as("design.json", Some("design.json")));
+    }
+
+    #[test]
+    fn a_new_document_file_is_save_as() {
+        assert!(save_as("Poster 2.figcraft", Some("Poster.figcraft")));
+        assert!(save_as("Poster.json", Some("Poster.figcraft")), "the other format a document's file takes");
+        assert!(save_as("Untitled.figcraft", None), "the host never sent a file");
+        assert!(save_as("Poster.figcraft", Some("design.json")), "the opened .json saved as .figcraft is a new file");
+    }
+
+    #[test]
+    fn copies_and_exports_never_become_the_document_s_file() {
+        for name in ["Poster.png", "Poster@2x.jpg", "Poster.svg", "Poster.pdf", "Poster.fig", "Poster.csv", "Poster.variables.css"] {
+            assert!(!save_as(name, Some("Poster.figcraft")), "{name} is a copy");
+            assert!(!save_as(name, None), "{name} is a copy even without a host file");
+        }
+    }
+
+    #[test]
+    fn the_variables_sidecar_is_a_copy_whatever_its_extension() {
+        assert!(!save_as("Poster.variables.json", Some("Poster.figcraft")));
+        assert!(!save_as("Poster.variables.json", None));
+        assert!(!save_as("design.variables.json", Some("design.variables.json")));
+    }
+}
diff --git a/apps/figcraft-web/src/main.rs b/apps/figcraft-web/src/main.rs
--- a/apps/figcraft-web/src/main.rs
+++ b/apps/figcraft-web/src/main.rs
@@ -8,13 +8,19 @@
 //! - no TCP control server (browsers can't listen on sockets);
 //! - no native file dialogs yet: the session starts with one new document;
 //! - links open in a new browser tab;
+//! - host mode: `?host=parent` (a same-origin page embedding FigCraft in an `<iframe>`) or
+//!   `data-host="page"` on the canvas (the page's own document) hands the page the app's files
+//!   instead of downloading them and reports unsaved changes (`host`);
 //! - live sessions: it joins sessions a desktop app hosts (File ▸ Join live session…) but can't
 //!   host one. A page served over `https://` can only reach sessions on the same computer
 //!   (browsers block `ws://` from secure pages); serve it over `http://` on a trusted network to
 //!   join from other machines.
 //!
 //! URL query flags: `?webgl` forces the WebGL2 backend instead of WebGPU; `?control` installs
-//! `window.figcraft`, the page's control channel (`api.rs`), which is off without it.
+//! `window.figcraft`, the page's control channel (`api.rs`), which is off without it; `?host=parent`
+//! (inside an `<iframe>`) talks to a same-origin parent page instead: it sends the document to open
+//! and stores what FigCraft saves. `&author=` names the user for comments and board notes in either
+//! host mode.
 //!
 //! URL fragment: `#join=<invite link or join code>` joins that live session once the page has
 //! loaded, so a bookmark opens a shared file. `join` takes the rest of the fragment: put it last.
@@ -23,6 +29,10 @@
 
 #[cfg(target_arch = "wasm32")]
 mod api;
+#[cfg(target_arch = "wasm32")]
+mod host;
+#[cfg(any(target_arch = "wasm32", test))]
+mod hostmode;
 #[cfg(any(target_arch = "wasm32", test))]
 mod query;
 #[cfg(test)]
diff --git a/apps/figcraft-web/src/web.rs b/apps/figcraft-web/src/web.rs
--- a/apps/figcraft-web/src/web.rs
+++ b/apps/figcraft-web/src/web.rs
@@ -18,6 +18,21 @@ pub fn start() {
             log::error!("missing <canvas id=\"{CANVAS_ID}\">");
             return;
         };
+        // Host modes: `?host=parent`, a same-origin page framing FigCraft, opens documents in it
+        // and stores what it saves (`host`); unframed there is no parent, so that URL on its own
+        // stays the normal app with downloads. `data-host="page"` on the canvas opts a page in
+        // instead: it runs FigCraft in its own document and talks over DOM `CustomEvent`s on the
+        // canvas (`host`).
+        let bridge = if query_param("host").as_deref() == Some("parent") {
+            crate::host::Bridge::new()
+        } else if crate::hostmode::page_host_requested(canvas.get_attribute("data-host").as_deref()) {
+            Some(crate::host::Bridge::for_page(&canvas))
+        } else {
+            None
+        };
+        let app_bridge = bridge.clone();
+        // Files the host sends, on their way into the app's own open queue each frame.
+        let opens: crate::host::OpenQueue = std::rc::Rc::default();
         let mut options = eframe::WebOptions::default();
         // `?webgl`: force the WebGL2 backend (troubleshooting, and headless browsers whose WebGPU
         // adapter request returns nothing).
@@ -30,17 +45,27 @@ pub fn start() {
             .start(
                 canvas,
                 options,
-                Box::new(|cc| {
+                Box::new(move |cc| {
                     if let Some(rs) = &cc.wgpu_render_state {
                         log::info!("figcraft-web: wgpu backend {:?}", rs.adapter.get_info().backend);
                     }
                     let reduce_motion = web_sys::window().and_then(|w| w.match_media("(prefers-reduced-motion: reduce)").ok().flatten());
-                    let mut session = Session::new();
+                    // Host mode: the host sends the file to edit, so start with no document (the
+                    // home screen), as the desktop app does without a file; otherwise the session
+                    // starts with one new document.
+                    let mut session = if app_bridge.is_some() { Session::empty() } else { Session::new() };
                     // Live sessions: the browser joins sessions a desktop app hosts; whatever arrives
                     // on the socket wakes the page for a frame.
                     let ctx = cc.egui_ctx.clone();
                     session.live.install(Box::new(figcraft_collab_net::NetLink::new(std::sync::Arc::new(move || ctx.request_repaint()))));
-                    let mut fig = FigApp::new(session, Box::new(WebServices { reduce_motion, ctx: cc.egui_ctx.clone() }));
+                    let mut fig = FigApp::new(session, Box::new(WebServices { reduce_motion, ctx: cc.egui_ctx.clone(), host: app_bridge.clone() }));
+                    // `?author=`: the host's name for the user signs this visit's comments and board
+                    // notes (nothing is stored in the browser, so nothing needs keeping).
+                    if app_bridge.is_some()
+                        && let Some(author) = query_param("author")
+                    {
+                        crate::host::Bridge::set_author(&mut fig, &author);
+                    }
                     // `#join=<link>`: join that session, as opening an invite link does on the desktop.
                     let hash = web_sys::window().and_then(|w| w.location().hash().ok()).unwrap_or_default();
                     if let Some(link) = crate::query::join_link(&hash) {
@@ -54,10 +79,21 @@ pub fn start() {
                             log::error!("couldn't install window.figcraft: {e:?}");
                         }
                     }
-                    Ok(Box::new(WebShell { fig }))
+                    // The host's files arrive on the app's own open path (a dropped file's).
+                    if let Some(bridge) = &app_bridge {
+                        bridge.listen(opens.clone(), cc.egui_ctx.clone());
+                    }
+                    Ok(Box::new(WebShell { fig, host: app_bridge, opens }))
                 }),
             )
             .await;
+        // Ready only once the runner is up: starting can still fail after the app is made.
+        if let Some(bridge) = &bridge {
+            match &result {
+                Ok(()) => bridge.post_ready(),
+                Err(e) => bridge.post_failed(&format!("{e:?}")),
+            }
+        }
         if let Some(el) = document.get_element_by_id(LOADING_ID) {
             match result {
                 Ok(()) => el.remove(),
@@ -71,18 +107,43 @@ fn query() -> String {
     web_sys::window().and_then(|w| w.location().search().ok()).unwrap_or_default()
 }
 
+fn query_param(key: &str) -> Option<String> {
+    web_sys::UrlSearchParams::new_with_str(&query()).ok()?.get(key)
+}
+
 struct WebShell {
     fig: FigApp,
+    /// The host bridge, when the page hosts the app (`?host=parent` or `data-host="page"`).
+    host: Option<crate::host::Bridge>,
+    /// Files the host sent, waiting to join the app's own open queue.
+    opens: crate::host::OpenQueue,
 }
 
 impl eframe::App for WebShell {
     // eframe runs `logic` for visible tabs and, with only `run_logic`, for hidden ones: keep
     // autosave/live/control deadlines moving without a paint. `FigApp::ui` does not repeat it.
-    /// Run the browser shell's eframe logic callback: guard `background_tick` so hidden tabs
-    /// still advance autosave, live and control deadlines without painting. `FigApp::ui` does
+    /// Run the browser shell's eframe logic callback: hand the host's files to the app's open
+    /// queue, guard `background_tick` so hidden tabs still advance autosave, live and control
+    /// deadlines without painting, and report unsaved changes to the host. `FigApp::ui` does
     /// not repeat it.
     fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
+        // Files the host sent join the app's own open queue (a dropped file's): the same
+        // "Opening as a new file" card, and `file.open` with the contents once read.
+        for (name, bytes) in std::mem::take(&mut *self.opens.borrow_mut()) {
+            let read: figcraft_ui_egui::ReadFuture = Box::pin(std::future::ready(Ok(bytes)));
+            self.fig.ui.pending_opens.push(figcraft_ui_egui::pending_open::PendingOpen {
+                label: name.clone(),
+                params: serde_json::json!({ "name": name }),
+                command: "file.open",
+                target: None,
+                read: Some(read),
+            });
+        }
         figcraft_ui_egui::windows::guard(&mut self.fig, ctx, |fig| fig.background_tick(ctx));
+        // The host hears when unsaved changes appear or go away (crate::host).
+        if let Some(bridge) = &self.host {
+            bridge.set_dirty(self.fig.session.docs.iter().any(|d| d.is_dirty()));
+        }
     }
 
     fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
@@ -94,12 +155,15 @@ impl eframe::App for WebShell {
 
 /// Browser services. Files are opened and imported through the browser's file picker, which hands
 /// over their contents (pickers that need a path return nothing, the trait's defaults); files are
-/// saved as downloads. Copies go to the system clipboard through egui's output; pastes arrive as
-/// paste events with their text (a page can't read the clipboard when it likes).
+/// saved as downloads, or handed to the host page in host mode. Copies go to the system clipboard
+/// through egui's output; pastes arrive as paste events with their text (a page can't read the
+/// clipboard when it likes).
 struct WebServices {
     /// `prefers-reduced-motion: reduce` (it follows the setting while the page is open).
     reduce_motion: Option<web_sys::MediaQueryList>,
     ctx: egui::Context,
+    /// The host bridge in host mode: files are handed over instead of downloaded ([`crate::host`]).
+    host: Option<crate::host::Bridge>,
 }
 
 impl Services for WebServices {
@@ -116,11 +180,15 @@ impl Services for WebServices {
     fn downloads(&self) -> bool {
         true
     }
-    /// A Blob behind a temporary `<a download>` link, clicked and removed.
+    /// A Blob behind a temporary `<a download>` link, clicked and removed; in host mode the file
+    /// goes to the host page instead ([`crate::host`]).
     fn download(&mut self, name: &str, bytes: &[u8]) -> bool {
         if crate::api::capture(name, bytes) {
             return true;
         }
+        if let Some(bridge) = &self.host {
+            return bridge.post_save(name, bytes);
+        }
         match download(name, bytes) {
             Ok(()) => true,
             Err(e) => {
diff --git a/contributors/people.toml b/contributors/people.toml
--- a/contributors/people.toml
+++ b/contributors/people.toml
@@ -89,3 +89,6 @@ sync_github_name = true
 
 [people.mateusvillain]
 sync_github_name = true
+
+[people.helenkwok]
+real_name = "Helen Kwok"
diff --git a/packaging/web/README.md b/packaging/web/README.md
--- a/packaging/web/README.md
+++ b/packaging/web/README.md
@@ -95,6 +95,94 @@ Local test: `python3 -m http.server 8767` inside the folder, then open http://lo
   (`allow-popups` lets Help links open in a new tab).
 - Don't send `X-Frame-Options: DENY` or a `frame-ancestors` CSP that excludes the embedding page.
 
+## Host mode (`?host=parent`, or the page's own document)
+
+A page can embed FigCraft so that it hands files over instead of downloading them and reports
+unsaved changes. Two opt-in transports carry the same messages:
+
+- **`?host=parent`** on the iframe's URL (`https://example.com/figcraft/?host=parent`): a
+  **same-origin** framing page talks to the app over `postMessage`. Opened outside an iframe, the
+  URL gives the normal app with downloads; with a parent on another origin there is no host mode
+  either (messages to another origin are dropped silently, so saves would be lost while the
+  document looked saved).
+- **`data-host="page"`** (the exact value `page`) on the canvas: a page that loads the module into
+  its own document gets the same behaviour over DOM `CustomEvent`s on the canvas, with no iframe
+  (see below).
+
+| Message | Direction | Fields |
+|---|---|---|
+| `figcraft:ready` | app → page | `version`: the app is running and listening; send a file now |
+| `figcraft:open` | page → app | `name`, `bytes` (an `ArrayBuffer` or `Uint8Array`): open like File › Open (a payload over the browser open limit of 512 MB is refused; anything malformed is ignored) |
+| `figcraft:save` | app → page | `name`, `saveAs`, `bytes` (a transferred `ArrayBuffer`): every file the app would otherwise download. The page stores it and reports failures itself. `saveAs` says whether the file becomes the document's file: `false` after Save of the file the page sent, under its own name; `true` for a document file (`.figcraft`, `.json`) under a name the page didn't send — Save As, or a Save of a document the page never sent — after which the next Save carries that name with `false`; `false` for copies and exports that don't become the document (rendered images, SVG, PDF, `.fig` copies, the variables exports) |
+| `figcraft:dirty` | app → page | `dirty`: unsaved changes appeared or went away |
+| `figcraft:failed` | app → page | `error`: the app couldn't start (no WebGPU or WebGL2, for example) |
+
+Files reach the page through the app's download service, the path this web build saves by: Save As
+(and a Save of a document with no file yet), the `.fig` copy, the export panel and its dialog,
+Export frames as PDF, plugin output and the variables export (Variables panel ▸ Export…). In host
+mode each arrives as `figcraft:save` instead of a download.
+
+In host mode the app starts with no document (the home screen), so the page sends the file to edit
+and guards leaving the page while changes are unsaved. `&author=` on the URL names the user for
+comments and board notes.
+
+### Same-document host mode
+
+The host application loads the wasm-bindgen module itself and gives FigCraft one of its own
+canvases, so there is no iframe and no `?host=parent`. It opts in with `data-host="page"` on that
+canvas — the exact value `page` — and the canvas keeps the id the app looks up
+(`id="figcraft_canvas"`):
+
+```html
+<canvas id="figcraft_canvas" data-host="page" style="width: 100%; height: 720px; display: block;"></canvas>
+```
+
+The page and the app then talk with DOM `CustomEvent`s on the canvas instead of `postMessage`. The
+app dispatches the same messages it would post to a parent — `figcraft:ready`, `figcraft:save`,
+`figcraft:dirty`, `figcraft:failed` — with the message fields (without the type) as the event's
+`detail`, and it opens a document when the page dispatches a `figcraft:open` event with
+`detail: { name, bytes }`. Events do not bubble, so listen on the canvas itself (two editors on
+one page can't hear each other).
+
+Add the listeners before `init`: the app announces itself with `figcraft:ready` while `init` is
+still running, and a file sent in reply would otherwise be missed.
+
+```js
+import init from "./figcraft-web-<hash>.js"; // the wasm-bindgen glue shipped with the app
+
+const canvas = document.getElementById("figcraft_canvas");
+
+canvas.addEventListener("figcraft:ready", async () => {
+  // The app is listening; hand it a document now, if you have one.
+  const bytes = await documentBytes(); // an ArrayBuffer or Uint8Array
+  canvas.dispatchEvent(new CustomEvent("figcraft:open", {
+    detail: { name: "Poster.figcraft", bytes },
+  }));
+});
+
+// Every file FigCraft would otherwise download arrives here.
+canvas.addEventListener("figcraft:save", (event) => {
+  const { name, saveAs, bytes } = event.detail; // bytes is an ArrayBuffer
+  // Store the file under `name`; `saveAs` says whether it becomes the document's file.
+  // Report your own errors.
+  storeFile(name, bytes, saveAs);
+});
+canvas.addEventListener("figcraft:dirty", (event) => {
+  unsaved = event.detail.dirty; // warn before leaving, as you like
+});
+canvas.addEventListener("figcraft:failed", (event) => {
+  showError(event.detail.error); // the app couldn't start
+});
+
+await init({ module_or_path: "./figcraft-web-<hash>_bg.wasm" });
+```
+
+- Saves are not downloads in this mode: the `bytes` in `figcraft:save` hold the file, and the host
+  decides where it goes.
+- `?author=` on the page's URL still names the user for comments and board notes.
+- An unframed page that uses `?host=parent` keeps the normal app with downloads; only the exact
+  `data-host` value `page` turns this mode on.
+
 ## Renderer selection and fallback flags
 
 FigCraft renders with wgpu. It uses **WebGPU** when the browser has it and falls back to
