diff --git a/apps/vectorcraft-web/Cargo.toml b/apps/vectorcraft-web/Cargo.toml
--- a/apps/vectorcraft-web/Cargo.toml
+++ b/apps/vectorcraft-web/Cargo.toml
@@ -29,4 +29,4 @@ wasm-bindgen = "0.2"
 serde_json = { workspace = true }
 wasm-bindgen-futures = "0.4"
 js-sys = "0.3"
-web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlInputElement", "KeyboardEvent", "HtmlIFrameElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "ClipboardEvent", "DataTransfer", "File", "FileList", "Url", "Location", "CssStyleDeclaration", "Event", "EventTarget", "MouseEvent", "PointerEvent", "TouchEvent", "AddEventListenerOptions", "DomRect", "DragEvent", "Storage", "Navigator", "RequestInit", "Response", "AbortController", "AbortSignal"] }
+web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlInputElement", "KeyboardEvent", "HtmlIFrameElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "ClipboardEvent", "DataTransfer", "File", "FileList", "Url", "Location", "MessageEvent", "CssStyleDeclaration", "Event", "EventTarget", "MouseEvent", "PointerEvent", "TouchEvent", "AddEventListenerOptions", "DomRect", "DragEvent", "Storage", "Navigator", "RequestInit", "Response", "AbortController", "AbortSignal"] }
diff --git a/apps/vectorcraft-web/src/bridge.rs b/apps/vectorcraft-web/src/bridge.rs
new file mode 100644
--- /dev/null
+++ b/apps/vectorcraft-web/src/bridge.rs
@@ -0,0 +1,245 @@
+//! The host bridge's decisions, kept free of `web_sys` so they are tested natively: which parent
+//! origin the bridge may talk to, and which saves the parent has confirmed.
+
+use std::collections::BTreeMap;
+
+/// How long the parent has to answer `vectorcraft:save` with `vectorcraft:saved` (milliseconds).
+pub const SAVE_ACK_TIMEOUT_MS: f64 = 30_000.0;
+
+/// The origin the bridge posts to and hears from, or `None` for no bridge (saves download).
+///
+/// - `own`: this page's origin.
+/// - `parent`: the parent window's origin, `None` when reading it threw (a cross-origin parent).
+/// - `allowed`: the `?host_origin=` query value, an origin the embedding page vouches for.
+///
+/// A same-origin parent is accepted. A cross-origin parent is accepted only when `allowed` is a
+/// valid URL origin (`scheme://host[:port]`, nothing else); messages then go to that exact origin,
+/// so the browser drops them if the parent is anything else. Opaque origins (`"null"`) never match.
+pub fn target_origin(own: &str, parent: Option<&str>, allowed: Option<&str>) -> Option<String> {
+    if let Some(parent) = parent
+        && parent == own
+        && is_origin(own)
+    {
+        return Some(own.to_string());
+    }
+    canonical_origin(allowed?.trim())
+}
+
+/// `s` as a browser serialises an origin (lowercase scheme and host, no default port), or `None`
+/// when it isn't one [`is_origin`] accepts. `MessageEvent.origin` is compared with this string
+/// exactly, so `https://Files.Example:443` must become `https://files.example` to ever match.
+pub fn canonical_origin(s: &str) -> Option<String> {
+    if !is_origin(s) {
+        return None;
+    }
+    let (scheme, rest) = s.split_once("://")?;
+    let scheme = scheme.to_ascii_lowercase();
+    let (host, port) = match rest.split_once(':') {
+        Some((h, p)) => (h, Some(p.parse::<u16>().ok()?)),
+        None => (rest, None),
+    };
+    let host = host.to_ascii_lowercase();
+    match (scheme.as_str(), port) {
+        ("https", Some(443)) | ("http", Some(80)) | (_, None) => Some(format!("{scheme}://{host}")),
+        (_, Some(port)) => Some(format!("{scheme}://{host}:{port}")),
+    }
+}
+
+/// True for a URL origin: `http(s)://host[:port]` with no path, query, fragment or credentials,
+/// and only in forms whose browser serialisation [`canonical_origin`] can produce: an ASCII host
+/// (an IDN must be given in punycode, `xn--…`), and a numeric host only as a dotted-decimal IPv4.
+pub fn is_origin(s: &str) -> bool {
+    let Some((scheme, rest)) = s.split_once("://") else { return false };
+    if !(scheme.eq_ignore_ascii_case("http") || scheme.eq_ignore_ascii_case("https")) || rest.is_empty() {
+        return false;
+    }
+    // IPv6 literals aren't accepted; name the host instead.
+    if rest.contains(['/', '?', '#', '@', ' ', '\\', '[', ']']) {
+        return false;
+    }
+    let (host, port) = match rest.split_once(':') {
+        Some((h, p)) => (h, Some(p)),
+        None => (rest, None),
+    };
+    if host.is_empty() || !host.bytes().all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_')) {
+        return false;
+    }
+    // Browsers rewrite numeric hosts (`0x7f.1`, `127.1`, `010.0.0.1`) to dotted decimal; accept
+    // only the form they would produce.
+    let last = host.trim_end_matches('.').rsplit('.').next().unwrap_or("");
+    if last.bytes().all(|b| b.is_ascii_digit()) || last.to_ascii_lowercase().starts_with("0x") {
+        let parts: Vec<&str> = host.split('.').collect();
+        let decimal = |p: &&str| !p.is_empty() && (*p == "0" || !p.starts_with('0')) && p.parse::<u8>().is_ok();
+        if parts.len() != 4 || !parts.iter().all(decimal) {
+            return false;
+        }
+    }
+    match port {
+        Some(p) => p.bytes().all(|b| b.is_ascii_digit()) && p.parse::<u16>().is_ok(),
+        None => true,
+    }
+}
+
+/// Saves posted to the parent and not yet confirmed.
+#[derive(Debug, Default)]
+pub struct SaveAcks {
+    next_id: u64,
+    /// Request id → (file name, deadline in ms, whether it is the session itself).
+    pending: BTreeMap<u64, (String, f64, bool)>,
+    /// A session save failed or went unanswered: the session stays dirty until one is confirmed.
+    unconfirmed: bool,
+}
+
+impl SaveAcks {
+    /// Record a save posted at `now`; returns its request id.
+    pub fn posted(&mut self, name: &str, session: bool, now: f64) -> u64 {
+        self.next_id = self.next_id.wrapping_add(1);
+        self.pending.insert(self.next_id, (name.to_string(), now + SAVE_ACK_TIMEOUT_MS, session));
+        self.next_id
+    }
+
+    /// The parent confirmed request `id`. False when no such save is waiting.
+    pub fn saved(&mut self, id: u64) -> bool {
+        match self.pending.remove(&id) {
+            Some((_, _, session)) => {
+                if session {
+                    self.unconfirmed = false;
+                }
+                true
+            }
+            None => false,
+        }
+    }
+
+    /// The parent reported request `id` failed; returns its name.
+    pub fn failed(&mut self, id: u64) -> Option<String> {
+        let (name, _, session) = self.pending.remove(&id)?;
+        self.unconfirmed |= session;
+        Some(name)
+    }
+
+    /// Saves past their deadline at `now`, removed and returned as `(id, name)`.
+    pub fn expire(&mut self, now: f64) -> Vec<(u64, String)> {
+        let late: Vec<u64> = self.pending.iter().filter(|(_, (_, deadline, _))| now >= *deadline).map(|(id, _)| *id).collect();
+        late.into_iter()
+            .filter_map(|id| {
+                let (name, _, session) = self.pending.remove(&id)?;
+                self.unconfirmed |= session;
+                Some((id, name))
+            })
+            .collect()
+    }
+
+    /// Whether any save is still waiting for the parent.
+    pub fn waiting(&self) -> bool {
+        !self.pending.is_empty()
+    }
+
+    /// The unsaved-changes state to report: the engine's, held dirty while a session save is
+    /// unconfirmed.
+    pub fn dirty(&self, engine_dirty: bool) -> bool {
+        engine_dirty || self.unconfirmed || self.pending.values().any(|(_, _, session)| *session)
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    const OWN: &str = "https://app.example";
+
+    #[test]
+    fn same_origin_parent_enables_the_bridge() {
+        assert_eq!(target_origin(OWN, Some(OWN), None).as_deref(), Some(OWN));
+    }
+
+    #[test]
+    fn cross_origin_parent_disables_the_bridge() {
+        // Reading a cross-origin parent's origin throws (None) or names another origin.
+        assert_eq!(target_origin(OWN, None, None), None);
+        assert_eq!(target_origin(OWN, Some("https://evil.example"), None), None);
+        assert_eq!(target_origin(OWN, Some("null"), None), None);
+        assert_eq!(target_origin("null", Some("null"), None), None);
+    }
+
+    #[test]
+    fn host_origin_parameter_must_be_an_exact_origin() {
+        let files = "https://files.example:8443";
+        assert_eq!(target_origin(OWN, None, Some(files)).as_deref(), Some(files));
+        for bad in
+            ["*", "null", "files.example", "https://files.example/", "https://a@files.example", "javascript://x", "https://", "https://x:99999"]
+        {
+            assert_eq!(target_origin(OWN, None, Some(bad)), None, "{bad}");
+        }
+    }
+
+    #[test]
+    fn host_origin_is_stored_as_the_browser_serialises_it() {
+        // `MessageEvent.origin` is lowercase with no default port; the stored origin must match it.
+        for (given, stored) in [
+            ("https://Files.Example", "https://files.example"),
+            ("HTTPS://FILES.EXAMPLE", "https://files.example"),
+            ("https://files.example:443", "https://files.example"),
+            ("http://files.example:80", "http://files.example"),
+            ("http://files.example:443", "http://files.example:443"),
+            ("https://files.example:80", "https://files.example:80"),
+            ("https://files.example:08443", "https://files.example:8443"),
+            ("https://xn--bcher-kva.example", "https://xn--bcher-kva.example"),
+            ("http://127.0.0.1:8080", "http://127.0.0.1:8080"),
+            ("  https://Files.Example:443  ", "https://files.example"),
+        ] {
+            assert_eq!(target_origin(OWN, None, Some(given)).as_deref(), Some(stored), "{given}");
+        }
+    }
+
+    #[test]
+    fn host_origin_a_browser_would_serialise_differently_is_refused() {
+        for bad in [
+            "https://b\u{fc}cher.example",
+            "https://files.example:",
+            "https://0x7f.0.0.1",
+            "https://127.1",
+            "https://010.0.0.1",
+            "https://256.0.0.1",
+            "https://files%2eexample",
+            "https://[::1]",
+        ] {
+            assert_eq!(target_origin(OWN, None, Some(bad)), None, "{bad}");
+        }
+    }
+
+    #[test]
+    fn save_stays_dirty_until_the_parent_confirms() {
+        let mut acks = SaveAcks::default();
+        let id = acks.posted("a.vectorcraft", true, 0.0);
+        // The engine marked the session saved, but the parent hasn't confirmed.
+        assert!(acks.dirty(false));
+        assert!(!acks.saved(id + 1), "an unknown id confirms nothing");
+        assert!(acks.dirty(false));
+        assert!(acks.saved(id));
+        assert!(!acks.dirty(false));
+        assert!(!acks.waiting());
+    }
+
+    #[test]
+    fn unanswered_save_times_out_as_failed_and_stays_dirty() {
+        let mut acks = SaveAcks::default();
+        let id = acks.posted("a.vectorcraft", true, 1_000.0);
+        assert!(acks.expire(1_000.0 + SAVE_ACK_TIMEOUT_MS - 1.0).is_empty());
+        assert_eq!(acks.expire(1_000.0 + SAVE_ACK_TIMEOUT_MS), vec![(id, "a.vectorcraft".to_string())]);
+        assert!(acks.dirty(false));
+        // A later confirmed save clears it.
+        let next = acks.posted("a.vectorcraft", true, 50_000.0);
+        assert!(acks.saved(next));
+        assert!(!acks.dirty(false));
+    }
+
+    #[test]
+    fn export_acks_do_not_hold_the_session_dirty() {
+        let mut acks = SaveAcks::default();
+        let id = acks.posted("art.svg", false, 0.0);
+        assert!(!acks.dirty(false));
+        assert_eq!(acks.failed(id).as_deref(), Some("art.svg"));
+        assert!(!acks.dirty(false));
+    }
+}
diff --git a/apps/vectorcraft-web/src/host.rs b/apps/vectorcraft-web/src/host.rs
new file mode 100644
--- /dev/null
+++ b/apps/vectorcraft-web/src/host.rs
@@ -0,0 +1,209 @@
+//! The `postMessage` bridge to a same-origin parent page (`?host=parent`), for pages that embed
+//! VectorCraft in an `<iframe>`. Messages are plain objects with a `type`:
+//!
+//! - `vectorcraft:ready` (to the parent): `{ version }`, the app is listening; send a document now.
+//! - `vectorcraft:open` (from the parent): `{ name, bytes }`, bytes an `ArrayBuffer` or
+//!   `Uint8Array`. It opens like a file picked with File › Open or dropped on the window.
+//! - `vectorcraft:save` (to the parent): `{ id, name, bytes }` (a transferred `ArrayBuffer`) for
+//!   every file VectorCraft would otherwise download (Save, Save As, Export…).
+//! - `vectorcraft:saved` (from the parent): `{ id }`, that save is stored. Until it arrives a
+//!   document save keeps the app reported dirty; after 30 s without it the app posts
+//!   `vectorcraft:failed { id, error }` and stays dirty.
+//! - `vectorcraft:dirty` (to the parent): `{ dirty }` whenever unsaved changes appear or go away.
+//! - `vectorcraft:failed` (to the parent): `{ error }` when the app couldn't start, or when a
+//!   `vectorcraft:open` document is over [`crate::host_open::MAX_OPEN_BYTES`] (it isn't opened), when the page
+//!   has no canvas, or when a save went unconfirmed.
+//!
+//! The bridge is installed only for a same-origin parent, or for a parent on the origin named by
+//! `?host_origin=` (a bare `scheme://host[:port]` origin); messages go to that exact origin and only
+//! the parent window on it is heard. A cross-origin parent without `host_origin` gets no bridge, so
+//! saves download as usual.
+
+use std::cell::{Cell, RefCell};
+use std::rc::Rc;
+
+use vectorcraft_ui_egui::VectorcraftApp;
+use wasm_bindgen::JsCast as _;
+use wasm_bindgen::JsValue;
+use wasm_bindgen::closure::Closure;
+
+use crate::bridge::SaveAcks;
+
+/// Longest file name taken from the parent page.
+const MAX_NAME_CHARS: usize = 255;
+
+thread_local! {
+    /// The bridge [`deliver`] posts through, once installed.
+    static BRIDGE: RefCell<Option<Bridge>> = const { RefCell::new(None) };
+}
+
+#[derive(Clone)]
+pub struct Bridge {
+    window: web_sys::Window,
+    parent: web_sys::Window,
+    origin: String,
+    /// The unsaved-changes state last reported to the parent.
+    reported: Rc<Cell<Option<bool>>>,
+    /// Saves waiting for `vectorcraft:saved`.
+    acks: Rc<RefCell<SaveAcks>>,
+}
+
+impl Bridge {
+    /// `None` when the page isn't framed, or the parent is cross-origin and not the origin
+    /// `host_origin` allows (see [`crate::bridge::target_origin`]).
+    pub fn new(host_origin: Option<&str>) -> Option<Self> {
+        let window = web_sys::window()?;
+        let parent = window.parent().ok().flatten()?;
+        if js_sys::Object::is(&parent, &window) {
+            return None;
+        }
+        let own = window.location().origin().ok()?;
+        // Reading a cross-origin parent's location throws: treat that as cross-origin.
+        let parent_origin = parent.location().origin().ok();
+        let origin = crate::bridge::target_origin(&own, parent_origin.as_deref(), host_origin)?;
+        Some(Self { window, parent, origin, reported: Rc::default(), acks: Rc::default() })
+    }
+
+    /// Make this the bridge [`deliver`] and [`report_dirty`] use.
+    pub fn install(&self) {
+        BRIDGE.with(|b| *b.borrow_mut() = Some(self.clone()));
+    }
+
+    pub fn post_ready(&self) {
+        self.send(&[("type", "vectorcraft:ready".into()), ("version", env!("CARGO_PKG_VERSION").into())]);
+    }
+
+    pub fn post_failed(&self, error: &str) {
+        self.send(&[("type", "vectorcraft:failed".into()), ("error", error.into())]);
+    }
+
+    /// Hear documents from the parent; they arrive through the open inbox like a picked file.
+    pub fn listen(&self, inbox: crate::web::Inbox, ctx: egui::Context) {
+        let bridge = self.clone();
+        let parent = self.parent.clone();
+        let origin = self.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();
+            match string(&data, "type").as_deref() {
+                Some("vectorcraft:open") => {}
+                Some("vectorcraft:saved") => {
+                    let id = js_sys::Reflect::get(&data, &JsValue::from_str("id")).ok().and_then(|v| v.as_f64());
+                    if let Some(id) = id.filter(|id| id.is_finite() && *id >= 0.0 && id.fract() == 0.0) {
+                        bridge.acks.borrow_mut().saved(id as u64);
+                        ctx.request_repaint();
+                    }
+                    return;
+                }
+                _ => return,
+            }
+            let name = string(&data, "name")
+                .filter(|n| !n.trim().is_empty())
+                .map(|n| n.chars().take(MAX_NAME_CHARS).collect())
+                .unwrap_or_else(|| "Untitled.vectorcraft".to_string());
+            let Some(array) = byte_array(&data) else { return };
+            // Checked before the bytes are copied out of the parent's buffer.
+            if let Err(e) = crate::host_open::check_open_size(&name, u64::from(array.byte_length())) {
+                log::warn!("vectorcraft-web: {e}");
+                bridge.post_failed(&e);
+                return;
+            }
+            inbox.lock().unwrap_or_else(|e| e.into_inner()).push((name, array.to_vec()));
+            ctx.request_repaint();
+        });
+        if self.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();
+        }
+    }
+
+    fn post_save(&self, name: &str, bytes: &[u8]) -> Result<(), String> {
+        let buffer = js_sys::Uint8Array::from(bytes).buffer();
+        let document = [vectorcraft_format::EXTENSION, vectorcraft_format::LEGACY_EXTENSION].iter().any(|ext| name.ends_with(&format!(".{ext}")));
+        let id = self.acks.borrow_mut().posted(name, document, js_sys::Date::now());
+        let sent = message(&[
+            ("type", "vectorcraft:save".into()),
+            ("id", JsValue::from_f64(id as f64)),
+            ("name", name.into()),
+            ("bytes", buffer.clone().into()),
+        ])
+        .ok_or_else(|| "couldn't build the message".to_string())
+        .and_then(|m| {
+            self.parent
+                .post_message_with_transfer(&m, &self.origin, &js_sys::Array::of1(&buffer))
+                .map_err(|e| e.as_string().unwrap_or_else(|| format!("{e:?}")))
+        });
+        if sent.is_err() {
+            self.acks.borrow_mut().failed(id);
+        }
+        sent
+    }
+
+    /// Post a message to the parent; false if it couldn't be sent.
+    fn send(&self, fields: &[(&str, JsValue)]) -> bool {
+        message(fields).is_some_and(|m| self.parent.post_message(&m, &self.origin).is_ok())
+    }
+}
+
+/// The installed bridge, in host mode.
+pub fn installed() -> Option<Bridge> {
+    BRIDGE.with(|b| b.borrow().clone())
+}
+
+/// In host mode, hand a file that would be downloaded to the parent page: `None` outside host
+/// mode, else whether it was posted.
+pub fn deliver(name: &str, bytes: &[u8]) -> Option<Result<(), String>> {
+    let bridge = BRIDGE.with(|b| b.borrow().clone())?;
+    Some(bridge.post_save(name, bytes))
+}
+
+/// After each frame: tell the parent when unsaved changes appear or go away (held dirty while a
+/// document save is unconfirmed), and fail saves the parent didn't confirm within 30 s.
+pub fn report_dirty(app: &VectorcraftApp, ctx: &egui::Context) {
+    let Some(bridge) = BRIDGE.with(|b| b.borrow().clone()) else { return };
+    let expired = bridge.acks.borrow_mut().expire(js_sys::Date::now());
+    for (id, name) in expired {
+        bridge.send(&[
+            ("type", "vectorcraft:failed".into()),
+            ("id", JsValue::from_f64(id as f64)),
+            ("error", format!("{name}: the host page didn't confirm the save").into()),
+        ]);
+    }
+    let engine_dirty = app.session.documents().iter().any(|d| d.is_dirty());
+    let (dirty, waiting) = {
+        let acks = bridge.acks.borrow();
+        (acks.dirty(engine_dirty), acks.waiting())
+    };
+    if waiting {
+        ctx.request_repaint_after(std::time::Duration::from_secs(1));
+    }
+    if bridge.reported.get() != Some(dirty) && bridge.send(&[("type", "vectorcraft:dirty".into()), ("dirty", dirty.into())]) {
+        bridge.reported.set(Some(dirty));
+    }
+}
+
+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()
+}
+
+/// `bytes` (an `ArrayBuffer` or `Uint8Array`) as a view, not yet copied.
+fn byte_array(data: &JsValue) -> Option<js_sys::Uint8Array> {
+    let bytes = js_sys::Reflect::get(data, &JsValue::from_str("bytes")).ok()?;
+    if let Some(array) = bytes.dyn_ref::<js_sys::Uint8Array>() {
+        Some(array.clone())
+    } else if bytes.is_instance_of::<js_sys::ArrayBuffer>() {
+        Some(js_sys::Uint8Array::new(&bytes))
+    } else {
+        None
+    }
+}
diff --git a/apps/vectorcraft-web/src/host_open.rs b/apps/vectorcraft-web/src/host_open.rs
new file mode 100644
--- /dev/null
+++ b/apps/vectorcraft-web/src/host_open.rs
@@ -0,0 +1,31 @@
+//! The size cap on documents a parent page sends through the host bridge (`host.rs`).
+
+/// Bytes a document sent by the parent page (`vectorcraft:open`) may have. It matches the cap
+/// on a library file read from disk (`MAX_FILE` in the engine's `cmd/library.rs`) and the
+/// largest raster VectorCraft allocates in the browser (`MAX_RASTER_ALLOC` on wasm32), so the
+/// bridge never copies more into the open inbox than the app's other import paths read.
+pub const MAX_OPEN_BYTES: u64 = 512 << 20;
+
+/// `Err` with the reason posted back as `vectorcraft:failed` when `len` bytes are over the cap.
+pub fn check_open_size(name: &str, len: u64) -> Result<(), String> {
+    if len > MAX_OPEN_BYTES {
+        return Err(format!(
+            "{name} wasn't opened: it is {len} bytes, larger than the {} MB VectorCraft opens from the host page",
+            MAX_OPEN_BYTES >> 20
+        ));
+    }
+    Ok(())
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn caps_open_bytes() {
+        assert!(check_open_size("a.svg", 0).is_ok());
+        assert!(check_open_size("a.svg", MAX_OPEN_BYTES).is_ok());
+        let e = check_open_size("big.svg", MAX_OPEN_BYTES + 1).unwrap_err();
+        assert!(e.contains("big.svg") && e.contains("512 MB"), "{e}");
+    }
+}
diff --git a/apps/vectorcraft-web/src/main.rs b/apps/vectorcraft-web/src/main.rs
--- a/apps/vectorcraft-web/src/main.rs
+++ b/apps/vectorcraft-web/src/main.rs
@@ -16,13 +16,22 @@
 //! - a Cmd or Ctrl chord the keymap binds runs its command, not the browser's own action as well
 //!   (`Cmd+=` zooms the canvas, not the page; `Cmd+Z` undoes in the document) (`keys`).
 //!
+//! With `?host=parent` (inside an `<iframe>`), a same-origin parent page (or the parent on the
+//! origin `?host_origin=` names) opens documents and receives saves instead of downloads (`host.rs`).
+//!
 //! URL query flags: `?webgl` forces the WebGL2 backend instead of WebGPU; `?lang=es` (any
 //! language code) stands in for the browser's languages when the interface language is Automatic.
 
 #[cfg(target_arch = "wasm32")]
 mod api;
+#[cfg(any(target_arch = "wasm32", test))]
+mod bridge;
 #[cfg(target_arch = "wasm32")]
 mod fonts;
+#[cfg(target_arch = "wasm32")]
+mod host;
+#[cfg(any(target_arch = "wasm32", test))]
+mod host_open;
 #[cfg(any(target_arch = "wasm32", test))]
 mod keys;
 #[cfg(target_arch = "wasm32")]
diff --git a/apps/vectorcraft-web/src/web.rs b/apps/vectorcraft-web/src/web.rs
--- a/apps/vectorcraft-web/src/web.rs
+++ b/apps/vectorcraft-web/src/web.rs
@@ -60,8 +60,19 @@ struct Host {
 
 pub fn start() {
     eframe::WebLogger::init(log::LevelFilter::Info).ok();
+    // `?host=parent`: a same-origin page framing VectorCraft (or the parent on the origin
+    // `?host_origin=` names) opens documents in it and stores what it saves (`crate::host`).
+    // Installed first, so a page without a canvas still reports the failure.
+    if query_param("host").as_deref() == Some("parent")
+        && let Some(bridge) = crate::host::Bridge::new(query_param("host_origin").as_deref())
+    {
+        bridge.install();
+    }
     let Some(canvas) = element(CANVAS_ID).and_then(|e| e.dyn_into::<web_sys::HtmlCanvasElement>().ok()) else {
         log::error!("missing <canvas id=\"{CANVAS_ID}\">");
+        if let Some(bridge) = crate::host::installed() {
+            bridge.post_failed(&format!("missing <canvas id=\"{CANVAS_ID}\">"));
+        }
         return;
     };
     detect_language();
@@ -136,6 +147,9 @@ async fn run(host: Host, canvas: web_sys::HtmlCanvasElement, moving: Option<Movi
                         if let Err(e) = crate::api::install() {
                             log::error!("vectorcraft-web: couldn't install window.vectorcraft: {}", js_err(e));
                         }
+                        if let Some(bridge) = crate::host::installed() {
+                            bridge.listen(host.inbox.clone(), ctx.clone());
+                        }
                         VectorcraftApp::new(Session::new(), services).with_control(control_rx)
                     }
                 };
@@ -144,7 +158,13 @@ async fn run(host: Host, canvas: web_sys::HtmlCanvasElement, moving: Option<Movi
         )
         .await;
     match (result, kept) {
-        (Ok(()), _) => {
+        (Ok(()), restart) => {
+            // Ready only once the runner started (and only on the first start, not a restart).
+            if restart.is_none()
+                && let Some(bridge) = crate::host::installed()
+            {
+                bridge.post_ready();
+            }
             if let Err(e) = crate::pencil::install(&runner, &pen_canvas, &pointers) {
                 log::error!("vectorcraft-web: no pen input: {}", js_err(e));
             }
@@ -152,7 +172,12 @@ async fn run(host: Host, canvas: web_sys::HtmlCanvasElement, moving: Option<Movi
                 el.remove();
             }
         }
-        (Err(e), None) => notice(&[&format!("VectorCraft failed to start: {}", js_err(e)), "A browser with WebGPU or WebGL2 is required."]),
+        (Err(e), None) => {
+            if let Some(bridge) = crate::host::installed() {
+                bridge.post_failed(&js_err(e.clone()));
+            }
+            notice(&[&format!("VectorCraft failed to start: {}", js_err(e)), "A browser with WebGPU or WebGL2 is required."]);
+        }
         (Err(e), Some(kept)) => {
             log::error!("vectorcraft-web: couldn't restart the graphics: {}", js_err(e));
             give_up(slot.borrow_mut().take(), kept);
@@ -370,6 +395,7 @@ impl eframe::App for WebShell {
         }
         let Some(app) = &mut self.app else { return };
         app.tick(ctx);
+        crate::host::report_dirty(app, ctx);
         crate::api::poll_replies();
         app.finish_ime_logic(ctx);
     }
@@ -572,6 +598,9 @@ fn download(path: &str, bytes: &[u8]) -> Result<(), String> {
     if crate::api::capture(path, bytes) {
         return Ok(());
     }
+    if let Some(posted) = crate::host::deliver(&fileio::file_name(path), bytes) {
+        return posted;
+    }
     let js = |e: wasm_bindgen::JsValue| format!("{e:?}");
     let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "vectorcraft".into());
     let window = web_sys::window().ok_or("no window")?;
