diff --git a/ROADMAP.md b/ROADMAP.md
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -5,7 +5,7 @@
 
 **Stage: alpha** · next: beta, ~19 points of ready-for-real-work and ~400–700 h away, plus opening Premiere's `.prproj` ([why](docs/target-app-parity.md#stage); passes the [core-workflow gate](docs/roadmap.md#alpha-gate))
 
-> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (ProRes alpha final-run conformance and native macOS validation) · **Target:** Adobe Premiere Pro 2026 (26.5.2)
+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-26 · **Change:** minor (web host bridge for embedding pages) · **Target:** Adobe Premiere Pro 2026 (26.5.2)
 
 Progress toward parity with Adobe Premiere Pro. This page is the summary; the detail is in
 [docs/target-app-parity.md](docs/target-app-parity.md) (assessment, methodology, calibration),
@@ -107,6 +107,8 @@ Ranked; detail and "done when" in [docs/roadmap.md](docs/roadmap.md) and [docs/g
 
 ## Progress log
 
+- **2026-10-26 (web host bridge):** the web build can be embedded: `?host=parent` (a same-origin `<iframe>`, `postMessage`) or `data-host="page"` on the canvas (the host document itself, DOM `CustomEvent`s) — the same five messages as the other Craft apps' bridges (`filmcraft:ready/open/save/dirty/failed`). A file the host sends opens like the app's own File › Open (`.fcproj`) or drop (media); every file the app would otherwise download — saves, exports, captions, interchange — goes to the host instead, with a `saveAs` flag saying whether it becomes the document's file; the host hears when unsaved changes appear or go away. Host sessions start without the demo project and without recovering an earlier visit. Not a Premiere-parity item: it exists for the Craft family's embedders (a Nextcloud app, a web desktop, an agent dashboard).
+
 - **2026-10-22:** ProRes 4444 / XQ alpha slices include the final singleton run code required by RDD 36:2022. Six HD fixtures rejected by native macOS decoding now pass; syntax regression tests cover final runs and slice widths ([details](crates/prores/README.md#alpha-bitstream-conformance), EffectCraft #293).
 
 - **2026-10-12:** Transform's motion-blur shutter on adjustment layers uses the sequence frame rate instead of 24 fps, so a 360° shutter in a 60 fps sequence spans one frame, not 2.5 (#1088)
@@ -168,6 +170,7 @@ Ranked; detail and "done when" in [docs/roadmap.md](docs/roadmap.md) and [docs/g
 
 | Date | Change | Summary |
 |---|---|---|
+| 2026-10-26 | minor | Web host bridge: `?host=parent` / `data-host="page"` embedding protocol (`filmcraft:ready/open/save/dirty/failed`); saves, exports and opens go to the embedding page instead of downloads |
 | 2026-10-22 | minor | ProRes alpha: write the final singleton run code; 4444/XQ HD clips that failed native macOS decoding now decode, with syntax regression tests (EffectCraft #293) |
 | 2026-10-13 | minor | Language table: German covers the whole interface (~100%) |
 | 2026-10-12 | trivial | Progress log: Transform shutter on adjustment layers (#1088) |
diff --git a/apps/filmcraft-web/Cargo.toml b/apps/filmcraft-web/Cargo.toml
--- a/apps/filmcraft-web/Cargo.toml
+++ b/apps/filmcraft-web/Cargo.toml
@@ -45,6 +45,8 @@ web-sys = { version = "0.3", features = [
     "Blob",
     "BlobPropertyBag",
     "CssStyleDeclaration",
+    "CustomEvent",
+    "CustomEventInit",
     "DataTransfer",
     "Document",
     "DragEvent",
diff --git a/apps/filmcraft-web/src/fs.rs b/apps/filmcraft-web/src/fs.rs
--- a/apps/filmcraft-web/src/fs.rs
+++ b/apps/filmcraft-web/src/fs.rs
@@ -104,6 +104,16 @@ pub fn put(path: &str, data: Vec<u8>) {
     FS.with(|fs| fs.borrow_mut().entries.insert(path.to_string(), Entry::Mem(data.into())));
 }
 
+/// The in-memory bytes written at `path` (saved projects and exports are held here; host mode
+/// hands them to the host at the next frame instead of downloading them). Blob entries — opened
+/// media — are never written, only registered, so a written path is always in memory.
+pub fn mem_bytes(path: &str) -> Option<Arc<[u8]>> {
+    FS.with(|fs| match fs.borrow().entries.get(path) {
+        Some(Entry::Mem(b)) => Some(b.clone()),
+        _ => None,
+    })
+}
+
 /// (path, size, kind) of every file.
 pub fn list() -> Vec<(String, u64, &'static str)> {
     FS.with(|fs| {
diff --git a/apps/filmcraft-web/src/host.rs b/apps/filmcraft-web/src/host.rs
new file mode 100644
--- /dev/null
+++ b/apps/filmcraft-web/src/host.rs
@@ -0,0 +1,386 @@
+//! Host modes: a page that runs FilmCraft (as an `<iframe>`, or in its own document) opens
+//! projects in it and stores what it saves instead of the browser downloading. The same
+//! messages as the other Craft apps' bridges (WordCraft, GridCraft, PdfCraft), with the
+//! `filmcraft:` prefix.
+//!
+//! ## `?host=parent` — an `<iframe>` (such as a Nextcloud app)
+//!
+//! The framing page and FilmCraft talk over `postMessage` between same-origin windows. Messages
+//! are plain objects with a `type`:
+//!
+//! - `filmcraft:ready` (to the parent): `{ version }`, sent once the app is running and
+//!   listening; send a file now.
+//! - `filmcraft:open` (from the parent): `{ name, bytes }`, bytes an `ArrayBuffer` or
+//!   `Uint8Array`. A `.fcproj` opens like a project picked with File › Open or dropped on the
+//!   page; any other file imports like a dropped one. Over [`MAX_OPEN_BYTES`] (512 MiB, refused
+//!   before any byte is copied) or malformed: a message in the app's status bar, never a crash.
+//! - `filmcraft:save` (to the parent): `{ name, saveAs, bytes }` (a transferred `ArrayBuffer`)
+//!   for every file FilmCraft would otherwise download — see the `saveAs` rule below. The
+//!   parent stores it and reports failures itself.
+//! - `filmcraft:dirty` (to the parent): `{ dirty }` whenever unsaved changes appear or go away
+//!   (the session's own unsaved-changes state, reported only when it changes).
+//! - `filmcraft: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. With no parent, or a
+//! parent on another origin (messages to it are dropped silently, so saves would vanish while
+//! the document looked saved), there is no bridge: the normal app with downloads.
+//!
+//! ## `data-host="page"` — the page's own document
+//!
+//! A page that loads the module into its own document (its own `<canvas id="filmcraft_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 `filmcraft:open`
+//! on the canvas with `detail: { name, bytes }` to open a file. Events don't bubble, so two
+//! editors on one page can't hear each other, and the canvas is the only target FilmCraft
+//! listens on.
+//!
+//! ## The `saveAs` rule
+//!
+//! Every file the app writes for the user — saved projects, video/frame/audio exports,
+//! captions, interchange documents (AAF, OMF, EDL, markers), extracted audio, the schema
+//! backup kept beside an upgraded project — is queued from the write hook
+//! ([`Bridge::install`]) and handed to the host at the next frame ([`Bridge::sync`]), when the
+//! frame's commands have settled and the session's state is final. Nothing reaches the
+//! browser's download in host mode. `saveAs` says whether the file becomes the document's file
+//! — the project the next Save writes ([`is_save_as`], pure and unit-tested):
+//!
+//! - A write the session adopts as its project path (Save As; also the first Save of an
+//!   untitled project, which the UI runs as Save As under a picked name) that the host does
+//!   not already have as the document: `true`. From then on, Saves of that document carry its
+//!   name with `false`.
+//! - A Save of the document under its own name — including one the host opened, and a Save As
+//!   back to the file the host already has as the document: `false`.
+//! - Everything else — Save a Copy, exports, captions, interchange, extracted audio, the
+//!   schema backup: `false`. They are copies; the document's file stays the same.
+//!
+//! "The document the host has" is what the host can know: the project it opened over this
+//! bridge, and every file it received with `saveAs: true`. A project opened inside the app
+//! (File › Open) is new to the host, so its first Save goes out with `true`.
+//!
+//! In host mode the host sends the document to edit, so no demo project opens and nothing from
+//! an earlier visit is recovered (no OPFS snapshot, no restored media), saves are handed to the
+//! host rather than downloaded, and the host guards leaving the page while changes are
+//! unsaved. FilmCraft has no user names that work could be signed with, so there is no
+//! `?author=`.
+
+use std::cell::{Cell, RefCell};
+use std::rc::Rc;
+
+use filmcraft_engine::Session;
+use wasm_bindgen::JsCast as _;
+use wasm_bindgen::JsValue;
+use wasm_bindgen::closure::Closure;
+
+use crate::hostmode::{MAX_OPEN_BYTES, is_project, is_save_as, open_name, open_size_ok};
+
+/// 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::install`]).
+#[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"))?;
+                // The bool says whether a listener called `preventDefault`; not a send failure.
+                canvas.dispatch_event(&custom).map_err(|e| js_error(&e, "couldn't dispatch the event"))?;
+                Ok(())
+            }
+        }
+    }
+}
+
+/// The host bridge in host mode. Cloned into the write hook and the open listener; the state
+/// they share is behind `Rc`.
+#[derive(Clone)]
+pub struct Bridge {
+    transport: Transport,
+    /// Files written since the last frame: their paths (the bytes stay in the file table,
+    /// [`crate::fs::mem_bytes`], so a large export is never copied a second time).
+    written: Rc<RefCell<Vec<String>>>,
+    /// The document file the host knows: the project it opened here, and every file it was
+    /// sent with `saveAs: true`.
+    host_document: 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 }, written: Rc::default(), host_document: Rc::default(), reported: Rc::default() })
+    }
+
+    /// The page bridge for `data-host="page"`: the host page runs FilmCraft 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() }, written: Rc::default(), host_document: Rc::default(), reported: Rc::default() }
+    }
+
+    /// Install the bridge: hear the host's files, and hand the app's writes to it instead of
+    /// the browser's download.
+    pub fn install(&self) {
+        self.listen();
+        let bridge = self.clone();
+        crate::fs::set_on_write(move |path: &str, _bytes: &[u8]| bridge.wrote(path));
+    }
+
+    /// After each frame's commands (the web app's per-frame logic pass): files written this
+    /// frame go to the host, with the `saveAs` decision from the session's state (the module
+    /// doc has the rule), and the host hears when unsaved changes appear or go away.
+    pub fn sync(&self, session: &Session) {
+        self.flush_saves(session);
+        let dirty = session.is_dirty();
+        if self.reported.get() != Some(dirty) && self.transport.send("filmcraft:dirty", &[("dirty", dirty.into())], None).is_ok() {
+            self.reported.set(Some(dirty));
+        }
+    }
+
+    pub fn post_ready(&self) {
+        let _ = self.transport.send("filmcraft: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("filmcraft:failed", &[("error", error.into())], None);
+    }
+
+    /// The host's project opened: the host has this file as the document, so a Save of it goes
+    /// out under its own name (`saveAs: false`).
+    fn opened_document(&self, path: &str) {
+        *self.host_document.borrow_mut() = Some(path.to_string());
+    }
+
+    /// A file the app wrote (saved project, export, …): queued for [`Bridge::sync`], and a
+    /// repaint in case the write happened outside a frame.
+    fn wrote(&self, path: &str) {
+        self.written.borrow_mut().push(path.to_string());
+        crate::repaint();
+    }
+
+    /// Hand this frame's written files to the host. The `saveAs` decision needs the session's
+    /// state *after* the frame's commands (a Save As adopts its path only once the write has
+    /// happened), so it is made here, not in the write hook, one file at a time against the
+    /// host's document as the earlier hand-overs left it.
+    fn flush_saves(&self, session: &Session) {
+        let written = std::mem::take(&mut *self.written.borrow_mut());
+        if written.is_empty() {
+            return;
+        }
+        let current = session.path.clone();
+        for path in written {
+            let Some(bytes) = crate::fs::mem_bytes(&path) else {
+                log::warn!("host save {path}: the written bytes are gone");
+                continue;
+            };
+            let name = file_name(&path);
+            // Each save is classified against the host's document as it is now, after the
+            // previous hand-overs (a failed one changed nothing), and the borrow is dropped before
+            // sending: in page mode a host listener reacts to a save synchronously, and may open
+            // a file right back.
+            let before = self.host_document.borrow().clone();
+            let save_as = is_save_as(&path, before.as_deref(), current.as_deref());
+            match self.post_save(&name, &bytes, save_as) {
+                // The host has this file as the document now, unless it opened another one while
+                // handling the save; a failed hand-over changes nothing, so a retry is still a Save As.
+                Ok(()) if save_as => {
+                    let mut document = self.host_document.borrow_mut();
+                    if *document == before {
+                        *document = Some(path);
+                    }
+                }
+                Ok(()) => {}
+                Err(e) => log::error!("couldn't hand {name} to the host: {e}"),
+            }
+        }
+    }
+
+    fn post_save(&self, name: &str, bytes: &[u8], save_as: bool) -> Result<(), String> {
+        // `filmcraft: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.into()), ("saveAs", save_as.into()), ("bytes", buffer.clone().into())];
+        self.transport.send("filmcraft:save", &fields, Some(&buffer))
+    }
+
+    /// Hear `filmcraft:open` — from the same-origin parent (a `message` on the window) or from
+    /// the host document (a `filmcraft:open` event on the canvas) — and open or import the file
+    /// like one the user picked or dropped.
+    fn listen(&self) {
+        let bridge = self.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("filmcraft:open") {
+                        return;
+                    }
+                    deliver_open(&bridge, &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(&bridge, &open.detail());
+                });
+                if canvas.add_event_listener_with_callback("filmcraft:open", on_open.as_ref().unchecked_ref()).is_ok() {
+                    // The listener lives as long as the page.
+                    on_open.forget();
+                }
+            }
+        }
+    }
+}
+
+/// Handle a `filmcraft:open` payload: `{ name, bytes }`, bytes an `ArrayBuffer` or
+/// `Uint8Array` — checked, then opened or imported like a file the user picked or dropped.
+fn deliver_open(bridge: &Bridge, data: &JsValue) {
+    let Ok(raw) = js_sys::Reflect::get(data, &JsValue::from_str("bytes")) else { return };
+    if !raw.is_instance_of::<js_sys::ArrayBuffer>() && raw.dyn_ref::<js_sys::Uint8Array>().is_none() {
+        return rejected_open("the bytes are neither an ArrayBuffer nor a Uint8Array".to_string());
+    }
+    let Some(len) = byte_length(&raw) else { return rejected_open("the bytes have no readable length".to_string()) };
+    if !open_size_ok(len) {
+        return rejected_open(format!("{len} bytes is over the {} MiB host open limit", MAX_OPEN_BYTES >> 20));
+    }
+    let name = open_name(string(data, "name").as_deref());
+    if is_project(&name) {
+        let Some(bytes) = bytes_from(&raw) else { return rejected_open("couldn't read the bytes".to_string()) };
+        let path = format!("/projects/{name}");
+        let opened = bridge.clone();
+        wasm_bindgen_futures::spawn_local(async move {
+            match crate::import::open_project_bytes(path.clone(), bytes).await {
+                // The host has this file as the document now: a Save of it goes out under its
+                // own name (`saveAs: false`).
+                Ok(_) => opened.opened_document(&path),
+                Err(e) => crate::post(move |w, _| w.app.status(e)),
+            }
+        });
+    } else {
+        // Media registers like a dropped file: a `Blob` over the bytes, never copied whole.
+        let Some(blob) = blob_from_bytes(&raw) else { return rejected_open("couldn't wrap the bytes in a Blob".to_string()) };
+        wasm_bindgen_futures::spawn_local(async move { crate::import::import_blob(name, blob).await });
+    }
+}
+
+/// An open request the app can't act on: said in the app's status bar, never a panic. Takes
+/// the reason owned: it reaches the status bar a frame later.
+fn rejected_open(why: String) {
+    crate::post(move |w, _| w.app.status(format!("the host's file was not opened: {why}")));
+}
+
+/// The payload's `byteLength`, read as a number: the bindings' `u32` would wrap a 4 GiB view
+/// to 0 and let it past the size check.
+fn byte_length(v: &JsValue) -> Option<u64> {
+    let n = js_sys::Reflect::get(v, &JsValue::from_str("byteLength")).ok()?.as_f64()?;
+    if !n.is_finite() || n < 0.0 {
+        return None;
+    }
+    Some(n as u64)
+}
+
+/// The payload as Rust bytes (a project opens from them): one copy, from an `ArrayBuffer` or a
+/// `Uint8Array`.
+fn bytes_from(v: &JsValue) -> Option<Vec<u8>> {
+    if let Some(array) = v.dyn_ref::<js_sys::Uint8Array>() {
+        Some(array.to_vec())
+    } else if v.is_instance_of::<js_sys::ArrayBuffer>() {
+        Some(js_sys::Uint8Array::new(v).to_vec())
+    } else {
+        None
+    }
+}
+
+/// The payload as a `Blob` (media registers like a dropped file): a view over the bytes, no
+/// copy on the wasm side — the `Blob` constructor copies into its own store.
+fn blob_from_bytes(v: &JsValue) -> Option<web_sys::Blob> {
+    let parts = js_sys::Array::new();
+    if let Some(view) = v.dyn_ref::<js_sys::Uint8Array>() {
+        parts.push(view);
+    } else if v.is_instance_of::<js_sys::ArrayBuffer>() {
+        parts.push(&js_sys::Uint8Array::new(v));
+    } else {
+        return None;
+    }
+    let opts = web_sys::BlobPropertyBag::new();
+    opts.set_type("application/octet-stream");
+    web_sys::Blob::new_with_u8_array_sequence_and_options(&parts, &opts).ok()
+}
+
+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/filmcraft-web/src/hostmode.rs b/apps/filmcraft-web/src/hostmode.rs
new file mode 100644
--- /dev/null
+++ b/apps/filmcraft-web/src/hostmode.rs
@@ -0,0 +1,52 @@
+//! Pure host-mode rules, shared by the bridge ([`crate::host`]) and testable without a browser:
+//! what opts a page into hosting FilmCraft in its own document, and the name and kind the
+//! bridge reads out of an open request. Pure, so it is tested natively in `tests/it/hostmode.rs`.
+
+/// 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: an untitled project — what
+/// a host that never names a file still opens (FilmCraft's document is its project file).
+pub const DEFAULT_OPEN_NAME: &str = "Untitled.fcproj";
+
+/// The extension that makes a host's file open like File › Open; anything else imports like a
+/// file dropped on the page.
+pub const PROJECT_EXT: &str = ".fcproj";
+
+/// 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`]. `/` and `\` become `_`, as [`crate::fs::register_blob`] does for
+/// dropped names — the name becomes a path in the virtual file table.
+pub fn open_name(name: Option<&str>) -> String {
+    let name = name.filter(|n| !n.trim().is_empty()).unwrap_or(DEFAULT_OPEN_NAME);
+    name.chars().map(|c| if c == '/' || c == '\\' { '_' } else { c }).collect()
+}
+
+/// Is this host file a project (open like File › Open) rather than media to import (like a
+/// drop)? The app's own drop rule: the name's extension, case-insensitive.
+pub fn is_project(name: &str) -> bool {
+    name.to_ascii_lowercase().ends_with(PROJECT_EXT)
+}
+
+/// The largest `filmcraft:open` payload, refused before any byte is copied. FilmCraft has no
+/// single open limit (media is read through a chunked cache, projects whole at 256 MiB), so
+/// the bridge takes the family's sane default.
+pub const MAX_OPEN_BYTES: u64 = 512 << 20;
+
+/// Whether a payload may be copied: the check runs before any byte is copied.
+pub fn open_size_ok(len: u64) -> bool {
+    len <= MAX_OPEN_BYTES
+}
+
+/// Whether a written file becomes the document's file (`filmcraft:save` with `saveAs: true`):
+/// a write the session adopted as its project path that the host did not already have as the
+/// document.
+pub fn is_save_as(write: &str, host_document: Option<&str>, project_path: Option<&str>) -> bool {
+    host_document != Some(write) && project_path == Some(write)
+}
diff --git a/apps/filmcraft-web/src/import.rs b/apps/filmcraft-web/src/import.rs
--- a/apps/filmcraft-web/src/import.rs
+++ b/apps/filmcraft-web/src/import.rs
@@ -135,9 +135,34 @@ pub async fn import_files(files: Vec<web_sys::File>) -> Value {
 /// Open a `.fcproj` file picked or dropped by the user.
 pub async fn open_project_file(file: web_sys::File) -> Result<Value, String> {
     let buf = JsFuture::from(file.array_buffer()).await.map_err(|e| format!("{e:?}"))?;
-    let path = format!("/projects/{}", file.name());
-    fs::put(&path, js_sys::Uint8Array::new(&buf).to_vec());
-    on_ui(move |w, _| w.app.session.execute("file.open", json!({"path": path})).map_err(|e| e.to_string())).await
+    open_project_bytes(format!("/projects/{}", file.name()), js_sys::Uint8Array::new(&buf).to_vec()).await
+}
+
+/// Open a `.fcproj` handed over as bytes (a host's `filmcraft:open`) at a virtual `/projects/`
+/// path, like a picked or dropped project: into the file table, then `file.open`.
+pub async fn open_project_bytes(path: String, bytes: Vec<u8>) -> Result<Value, String> {
+    fs::put(&path, bytes);
+    on_ui(move |w, _| w.app.session.execute("file.open", json!({ "path": path })).map_err(|e| e.to_string())).await
+}
+
+/// Import a file handed over by a host (`filmcraft:open`): registered like a dropped file (a
+/// `Blob` under `/files`, never copied whole), the container index prefetched, then
+/// `file.import`. Media from the host is not kept for crash recovery: host sessions don't
+/// recover.
+pub async fn import_blob(name: String, blob: web_sys::Blob) {
+    let bin = on_ui(|w, _| w.app.import_bin().0).await;
+    let size = blob.size() as u64;
+    let path = fs::register_blob(&name, blob);
+    prewarm(&path, size).await;
+    match import_path(path, bin).await {
+        Ok(v) => {
+            if let Some(errors) = v.get("errors").and_then(Value::as_array).filter(|e| !e.is_empty()) {
+                let msg = errors.iter().filter_map(Value::as_str).collect::<Vec<_>>().join("; ");
+                crate::post(move |w, _| w.app.status(msg));
+            }
+        }
+        Err(e) => crate::post(move |w, _| w.app.status(e)),
+    }
 }
 
 /// Handle files from a picker or a drop: projects open, everything else is imported.
diff --git a/apps/filmcraft-web/src/lib.rs b/apps/filmcraft-web/src/lib.rs
--- a/apps/filmcraft-web/src/lib.rs
+++ b/apps/filmcraft-web/src/lib.rs
@@ -22,6 +22,8 @@ pub mod api;
 pub mod audio;
 pub mod download_pacing;
 pub mod fs;
+pub mod host;
+pub mod hostmode;
 pub mod import;
 pub mod opfs;
 pub mod recovery;
@@ -87,6 +89,10 @@ pub struct WebApp {
     pub app: FilmcraftApp,
     pub audio: Option<audio::Handle>,
     pub autosave: recovery::Autosave,
+    /// The host bridge in host mode (`?host=parent` / `data-host="page"`): written files go to
+    /// the host instead of downloads, it hears about unsaved changes, and it opens the files it
+    /// sends.
+    pub host: Option<host::Bridge>,
 }
 
 impl eframe::App for WebApp {
@@ -94,7 +100,7 @@ impl eframe::App for WebApp {
         self.app.raw_input_hook(ctx, raw_input);
     }
 
-    /// Hidden logic pass: host actions, app logic, API replies, jobs, audio, autosave, then IME reset; it does not draw UI.
+    /// Hidden logic pass: host actions, app logic, API replies, jobs, host saves and dirty reports, audio, autosave, then IME reset; it does not draw UI.
     fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
         let actions = ACTIONS.with(|a| std::mem::take(&mut *a.borrow_mut()));
         for f in actions {
@@ -106,6 +112,11 @@ impl eframe::App for WebApp {
         if self.app.session.pump_jobs(std::time::Duration::from_millis(30)) {
             ctx.request_repaint();
         }
+        // In host mode: files written this frame (commands, exports) go to the host, and it
+        // hears when unsaved changes appear or go away.
+        if let Some(host) = &self.host {
+            host.sync(&self.app.session);
+        }
         if let Some(a) = &self.audio {
             a.pump();
         }
@@ -122,6 +133,10 @@ fn query_flag(name: &str) -> bool {
     web_sys::window().and_then(|w| w.location().search().ok()).and_then(|s| web_sys::UrlSearchParams::new_with_str(&s).ok()).is_some_and(|p| p.has(name))
 }
 
+fn query_param(name: &str) -> Option<String> {
+    web_sys::window().and_then(|w| w.location().search().ok()).and_then(|s| web_sys::UrlSearchParams::new_with_str(&s).ok()).and_then(|p| p.get(name))
+}
+
 /// Entry point (called by the page's bootstrap script once the wasm module is instantiated).
 #[wasm_bindgen]
 pub async fn start(canvas_id: String) -> Result<(), JsValue> {
@@ -130,6 +145,21 @@ pub async fn start(canvas_id: String) -> Result<(), JsValue> {
     let doc = web_sys::window().and_then(|w| w.document()).ok_or("no document")?;
     let canvas: web_sys::HtmlCanvasElement = doc.get_element_by_id(&canvas_id).ok_or("no canvas")?.dyn_into()?;
 
+    // Host modes: `?host=parent`, a same-origin page framing FilmCraft in an `<iframe>`, opens
+    // files 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 FilmCraft in its own document and talks over DOM `CustomEvent`s
+    // on the canvas. In host mode the host sends the document to edit, so no demo project
+    // opens and nothing from an earlier visit is recovered.
+    let host = if query_param("host").as_deref() == Some("parent") {
+        host::Bridge::new()
+    } else if hostmode::page_host_requested(canvas.get_attribute("data-host").as_deref()) {
+        Some(host::Bridge::for_page(&canvas))
+    } else {
+        None
+    };
+    let in_host_mode = host.is_some();
+
     let isolated = js_sys::Reflect::get(&js_sys::global(), &"crossOriginIsolated".into()).ok().and_then(|v| v.as_bool()).unwrap_or(false);
     // Frame rendering runs cooperatively on this thread. wasm threads need a cross-origin
     // isolated page *and* a build with atomics (nightly `build-std`); this build has none.
@@ -139,14 +169,14 @@ pub async fn start(canvas_id: String) -> Result<(), JsValue> {
     set_info("opfs", json!(opfs::available()));
 
     // Media kept in OPFS by earlier sessions come back under their paths (recovery).
-    let restored = if opfs::available() && !query_flag("fresh") { recovery::restore_media().await } else { 0 };
-    let recovered = if query_flag("fresh") || query_flag("norecover") { None } else { recovery::load_snapshot().await };
+    let restored = if !in_host_mode && opfs::available() && !query_flag("fresh") { recovery::restore_media().await } else { 0 };
+    let recovered = if in_host_mode || query_flag("fresh") || query_flag("norecover") { None } else { recovery::load_snapshot().await };
     webcodecs::probe().await;
 
     api::install()?;
     import::install_drop_handlers()?;
 
-    let demo = !query_flag("empty");
+    let demo = !in_host_mode && !query_flag("empty");
     let mut web_options = eframe::WebOptions::default();
     // `?webgl`: WebGL2 only. The page reloads with it when starting on WebGPU fails (an adapter
     // whose device can't be created, a driver the browser rejects…); see `index.html`.
@@ -156,7 +186,8 @@ pub async fn start(canvas_id: String) -> Result<(), JsValue> {
         c.instance_descriptor.backends = eframe::wgpu::Backends::GL;
     }
     let runner = eframe::WebRunner::new();
-    runner
+    let host_for_app = host.clone();
+    let started = runner
         .start(
             canvas,
             web_options,
@@ -165,11 +196,19 @@ pub async fn start(canvas_id: String) -> Result<(), JsValue> {
                 set_context(cc.egui_ctx.clone());
                 let ctx = cc.egui_ctx.clone();
                 fs::set_on_data(move || ctx.request_repaint());
-                fs::set_on_write(|path, data| {
-                    if let Err(e) = fs::download(path, data) {
-                        log::warn!("download {path}: {e:?}");
+                // Host mode: written files (saved projects, exports, …) are handed to the host
+                // instead of downloading, and it opens the files it sends. Otherwise the app
+                // offers what it writes as a browser download.
+                match &host_for_app {
+                    Some(bridge) => bridge.install(),
+                    None => {
+                        fs::set_on_write(|path, data| {
+                            if let Err(e) = fs::download(path, data) {
+                                log::warn!("download {path}: {e:?}");
+                            }
+                        });
                     }
-                });
+                }
                 webcodecs::install();
                 let mut session = Session::new(Arc::new(fs::WebServices));
                 let mut toast = None;
@@ -217,10 +256,18 @@ pub async fn start(canvas_id: String) -> Result<(), JsValue> {
                 }
                 set_info("audio", json!(audio.is_some()));
                 set_info("restoredMedia", json!(restored));
-                Ok(Box::new(WebApp { app, audio, autosave: recovery::Autosave::default() }))
+                Ok(Box::new(WebApp { app, audio, autosave: recovery::Autosave::default(), host: host_for_app }))
             }),
         )
-        .await?;
+        .await;
+    // Ready only once the runner is up: starting can still fail after the app is made.
+    if let Some(bridge) = &host {
+        match &started {
+            Ok(()) => bridge.post_ready(),
+            Err(e) => bridge.post_failed(&e.as_string().unwrap_or_else(|| format!("{e:?}"))),
+        }
+    }
+    started?;
     set_info("startupMs", json!(t0.elapsed().as_secs_f64() * 1000.0));
     // keep the runner alive for the page's lifetime
     std::mem::forget(runner);
diff --git a/apps/filmcraft-web/tests/it/hostmode.rs b/apps/filmcraft-web/tests/it/hostmode.rs
new file mode 100644
--- /dev/null
+++ b/apps/filmcraft-web/tests/it/hostmode.rs
@@ -0,0 +1,57 @@
+//! The page host mode's pure rules (the `data-host` opt-in, open names, project or media). They run
+//! natively even though the rest of the crate is wasm32-only.
+
+#[path = "../../src/hostmode.rs"]
+mod rules;
+
+use rules::{DEFAULT_OPEN_NAME, MAX_OPEN_BYTES, is_project, is_save_as, open_name, open_size_ok, page_host_requested};
+
+#[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_and_lose_path_separators() {
+    assert_eq!(open_name(Some("Trailer v2.fcproj")), "Trailer v2.fcproj");
+    assert_eq!(open_name(Some(" Sheet2/../../etc.fcproj ")), " Sheet2_.._.._etc.fcproj ");
+    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 only_project_files_open_as_projects() {
+    assert!(is_project("Trailer.fcproj"));
+    assert!(is_project("TRAILER.FCPROJ"));
+    assert!(!is_project("clip.mp4"));
+    assert!(!is_project("notes.txt"));
+    assert!(!is_project("fcproj"));
+    assert!(!is_project(""));
+}
+
+#[test]
+fn save_as_only_when_the_host_gains_a_new_document_file() {
+    // Save of the document the host opened, under its own name — and a Save As back to the
+    // file the host already has as the document: it stays the document, so `false`.
+    assert!(!is_save_as("/projects/Movie.fcproj", Some("/projects/Movie.fcproj"), Some("/projects/Movie.fcproj")));
+    // Save As (the session adopts the written path), and the first Save of an untitled
+    // project: the file becomes the document's file, new to the host.
+    assert!(is_save_as("/projects/Trailer.fcproj", Some("/projects/Movie.fcproj"), Some("/projects/Trailer.fcproj")));
+    assert!(is_save_as("/projects/Untitled.fcproj", None, Some("/projects/Untitled.fcproj")));
+    // Save a Copy, exports, captions, the schema backup: copies; the document's file stays.
+    assert!(!is_save_as("/projects/Movie copy.fcproj", Some("/projects/Movie.fcproj"), Some("/projects/Movie.fcproj")));
+    assert!(!is_save_as("/exports/trailer.mp4", Some("/projects/Movie.fcproj"), Some("/projects/Movie.fcproj")));
+    assert!(!is_save_as("/exports/captions.srt", None, None));
+}
+
+#[test]
+fn open_sizes_up_to_the_limit_are_accepted() {
+    assert!(open_size_ok(0));
+    assert!(open_size_ok(MAX_OPEN_BYTES));
+    assert!(!open_size_ok(MAX_OPEN_BYTES + 1));
+    assert_eq!(MAX_OPEN_BYTES >> 20, 512);
+}
diff --git a/apps/filmcraft-web/tests/it/main.rs b/apps/filmcraft-web/tests/it/main.rs
--- a/apps/filmcraft-web/tests/it/main.rs
+++ b/apps/filmcraft-web/tests/it/main.rs
@@ -1,2 +1,3 @@
 mod download_pacing;
+mod hostmode;
 mod recovery_policy;
diff --git a/contributors/people.toml b/contributors/people.toml
--- a/contributors/people.toml
+++ b/contributors/people.toml
@@ -88,3 +88,6 @@ sync_github_name = false
 
 [people.webdevtodayjason]
 display_name = "Jason Brashear"
+
+[people.helenkwok]
+real_name = "Helen Kwok"
diff --git a/docs/web.md b/docs/web.md
--- a/docs/web.md
+++ b/docs/web.md
@@ -31,7 +31,9 @@ unaffected; `cargo xtask wasm` (part of `cargo xtask ci`) checks it, with `filmc
 URL flags: `?empty` (no demo project), `?norecover` (don't reopen the auto-saved project),
 `?fresh` (also don't restore media kept in OPFS), `?cpu` (CPU compositor), `?webgl` (WebGL2 only;
 the page reloads with it when WebGPU is present but fails to start), `?nowebcodecs` (decode with
-FilmCraft's own decoders only).
+FilmCraft's own decoders only), `?host=parent` (host mode: a same-origin page embedding the app
+hands it files and takes its saves back instead of downloads; `data-host="page"` on the canvas
+is the no-iframe form — [hosting](../packaging/web/README.md)).
 
 If the app panics after start-up (or runs out of memory), `index.html` shows a "FilmCraft stopped
 working" overlay with the panic message and a Reload button instead of a frozen canvas, and sets
diff --git a/packaging/web/README.md b/packaging/web/README.md
--- a/packaging/web/README.md
+++ b/packaging/web/README.md
@@ -75,6 +75,88 @@ Local test: `python3 -m http.server 8765` inside the folder, then open http://lo
   downloads) are blocked.
 - Don't send `X-Frame-Options: DENY` or a `frame-ancestors` CSP that excludes the embedding page.
 
+## The parent bridge (`?host=parent`)
+
+A page framing FilmCraft in a **same-origin** iframe can add `?host=parent` to the iframe's URL
+(`https://example.com/filmcraft/?host=parent`) and have FilmCraft hand its files over instead of
+downloading them. The two sides talk `postMessage` with plain window messages that carry a `type`:
+
+| Message | Direction | Fields |
+|---|---|---|
+| `filmcraft:ready` | app → page | `version`: the app is running and listening; send a file now |
+| `filmcraft:open` | page → app | `name`, `bytes` (an `ArrayBuffer` or `Uint8Array`): a `.fcproj` opens like File › Open, anything else imports like a file dropped on the page. Over 512 MiB or malformed: refused with a message in the app, nothing crashes |
+| `filmcraft:save` | app → page | `name`, `saveAs`, `bytes` (a transferred `ArrayBuffer`): every file the app would otherwise download — Save, Save As, Save a Copy, video / frame / audio exports, captions, interchange (AAF, OMF, EDL, markers), extracted audio, the schema backup. The page stores it and reports failures itself. `saveAs` says whether the file becomes the document's file — the project the next Save writes: `true` when the app adopts the file and the page didn't already have it as the document (Save As, and the first Save of a project the page never sent); `false` for a Save of the document under its own name and for every copy (Save a Copy, exports, the schema backup beside an upgraded project) |
+| `filmcraft:dirty` | app → page | `dirty`: unsaved changes appeared or went away |
+| `filmcraft:failed` | app → page | `error`: the app couldn't start (no WebGPU or WebGL2, for example) |
+
+In host mode the app opens no demo project and restores nothing from an earlier visit (no OPFS
+snapshot, no restored media), so the framing page should send the file to edit and guard leaving
+the page while changes are unsaved. Written files are also kept in the app's virtual file table
+(`filmcraft.files()` / `readFile`), but the host page is their destination. Opening `?host=parent`
+outside an iframe gives the normal app with downloads.
+
+## Same-document host mode (no iframe)
+
+A host application can also run FilmCraft from its own document: it loads the wasm-bindgen module
+itself and gives FilmCraft 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="filmcraft_canvas"`).
+
+```html
+<canvas id="filmcraft_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 — `filmcraft:ready`,
+`filmcraft:save`, `filmcraft:dirty`, `filmcraft:failed` — with the message fields (without the
+`type`) as the event's `detail`, and it opens a file when the page dispatches a `filmcraft: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 `filmcraft:ready` while `init` is
+still running, and a file sent in reply would otherwise be missed. And unlike the standalone
+page, which starts itself, a host calls the glue's exported `start` with the canvas id after
+`init`.
+
+```js
+import init, { start } from "./filmcraft_web.js"; // the wasm-bindgen glue shipped with the app
+
+const canvas = document.getElementById("filmcraft_canvas");
+
+canvas.addEventListener("filmcraft:ready", async () => {
+  // The app is listening; hand it a project now, if you have one.
+  const bytes = await projectBytes(); // an ArrayBuffer or Uint8Array
+  canvas.dispatchEvent(new CustomEvent("filmcraft:open", {
+    detail: { name: "Trailer.fcproj", bytes },
+  }));
+});
+
+// Every file the app would otherwise download (Save, Save As, exports, …) arrives here.
+canvas.addEventListener("filmcraft: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("filmcraft:dirty", (event) => {
+  unsaved = event.detail.dirty; // warn before leaving, as you like
+});
+canvas.addEventListener("filmcraft:failed", (event) => {
+  showError(event.detail.error); // the app couldn't start
+});
+
+await init({ module_or_path: "./filmcraft_web_bg.wasm?v=<build hash>" });
+await start("filmcraft_canvas"); // the canvas id the app renders on
+```
+
+- Saves are not downloads in this mode: the `bytes` in `filmcraft:save` hold the saved file, and
+  the host decides where the file goes.
+- The assets carry their build hash as a `?v=` query (as `index.html` loads them), so they can
+  be cached forever.
+- FilmCraft has no user names that work could be signed with, so there is no `?author=`.
+- 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
 
 FilmCraft renders with wgpu. It uses **WebGPU** when the browser has it and falls back to
