collective

Browse / github.com/storytold/cadcraft / patch

feat(web): page host mode, so a page can host CADCraft in its own document (#2178)

Web: host CADCraft in the page's own document (no iframe) The web app's host bridge (`?host=parent`, [`host.rs`](https://github.com/storytold/cadcraft/blob/main/apps/cadcraft-web/src/host.rs)) only works inside an iframe: `Bridge::new()` returns `None` when the page isn't framed. A page that loads the wasm-bindgen module into its own document, with its own `<canvas id="cadcraft_canvas">` inside a component, can run CADCraft fine but has no way to hand it a drawing or receive its saves. **Why it matters.** Embedding the editor as a component sized to a box, next to the page's own header and controls, is simpler than an iframe (one document, one style

Status
open
Score
0.00
Adopters
0 other operators
Author
helenkwok (imported from pull request #2180, open)
Base
2521605713d1435f1cd1f3e7d01b5d25c95aac9a
Size
+248 −63 in 6 files
Review
pending
Shared
2026-10-28
Id
6cb94ebcfe27880ec9741f76

How it went

No other operator has reported using it yet.

Diff

raw · ROADMAP.md apps/cadcraft-web/Cargo.toml apps/cadcraft-web/src/host.rs apps/cadcraft-web/src/main.rs contributors/people.toml packaging/web/README.md

diff --git a/ROADMAP.md b/ROADMAP.md--- a/ROADMAP.md+++ b/ROADMAP.md@@ -2,7 +2,7 @@  **Stage: pre-alpha** · next: alpha, ~2 points (38% → 40% ready for real work) and ~125–190 Opus 5.5 hours away (the four failing core workflows of the [alpha gate](docs/roadmap.md#alpha-gate)) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-21 · **Change:** trivial (progress log: proxy objects keep their class through a save, #1812; previously minor: HATCHEDIT boundary add/remove progress) · **Target:** Autodesk AutoCAD 2027+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-26 · **Change:** minor (web: same-document host mode, `data-host="page"`; previously trivial: proxy objects keep their class through a save, #1812) · **Target:** Autodesk AutoCAD 2027  CADCraft targets full parity with AutoCAD (2D drafting first, then annotation, layouts and plotting, DWG, parametrics and 3D), plus what AutoCAD doesn't have: agent control over MCP, a@@ -119,6 +119,7 @@ Ranked; detail and the full beta checklist in [docs/roadmap.md](docs/roadmap.md)  | Date | Entry | |---|---|+| 2026-10-26 | Web: a page can host CADCraft in its own document (no iframe) with `data-host="page"` on the canvas, talking over DOM `CustomEvent`s with the same messages as the `?host=parent` parent bridge, which is now documented in the hosting guide | | 2026-10-21 | DXF save keeps each proxy object's and proxy entity's class: its class number (group 91) is renumbered to the rewritten CLASSES instead of naming another class, so AutoCAD's visual styles in an R14 file no longer come back as SCALE or MLEADERSTYLE (#1812) | | 2026-10-14 | HATCHEDIT adds boundaries from internal points or closed objects and removes selected loops in the dialog, command line and JSON command; associative links and undo are covered by tests. | | 2026-10-12 | TRIM: a pick after a closed polyline's last cut keeps only the complementary run instead of an extra circuit of the outline (#726) |@@ -136,6 +137,7 @@ Ranked; detail and the full beta checklist in [docs/roadmap.md](docs/roadmap.md)  | Date | Change | Summary | |---|---|---|+| 2026-10-26 | minor | Web: same-document host mode (`data-host="page"` on the canvas); the parent bridge documented in the hosting guide | | 2026-10-21 | trivial | Progress log: proxy objects keep their class through a save (#1812) | | 2026-10-14 | minor | Progress log: HATCHEDIT boundary add/remove | | 2026-10-12 | trivial | Progress log: closed-polyline TRIM (#726) |diff --git a/apps/cadcraft-web/Cargo.toml b/apps/cadcraft-web/Cargo.toml--- a/apps/cadcraft-web/Cargo.toml+++ b/apps/cadcraft-web/Cargo.toml@@ -30,4 +30,4 @@ web-sys = { version = "0.3", features = ["HtmlInputElement", "FileList", "File"] eframe = { workspace = true, features = ["persistence", "wgpu", "accesskit"] } wasm-bindgen = "0.2" wasm-bindgen-futures = "0.4"-web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Location", "Navigator", "Blob", "BlobPropertyBag", "Url", "MessageEvent", "UrlSearchParams", "MediaQueryList"] }+web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Location", "Navigator", "Blob", "BlobPropertyBag", "Url", "MessageEvent", "UrlSearchParams", "MediaQueryList", "Event", "EventTarget", "CustomEvent", "CustomEventInit"] }diff --git a/apps/cadcraft-web/src/host.rs b/apps/cadcraft-web/src/host.rs--- a/apps/cadcraft-web/src/host.rs+++ b/apps/cadcraft-web/src/host.rs@@ -1,5 +1,10 @@-//! The `postMessage` bridge to a same-origin parent page (`?host=parent`), for pages that embed-//! CADCraft in an `<iframe>` (such as a Nextcloud app). Messages are plain objects with a `type`:+//! Host modes: a page that runs CADCraft opens drawings in it and stores what it saves instead+//! of the browser downloading.+//!+//! ## `?host=parent` — an `<iframe>` (such as a Nextcloud app)+//!+//! The framing page and CADCraft talk over `postMessage` between same-origin windows. Messages+//! are plain objects with a `type`: //! //! - `cadcraft:ready` (to the parent): `{ version }`, the app is listening; send a drawing now. //! - `cadcraft:open` (from the parent): `{ name, bytes }`, bytes an `ArrayBuffer` or@@ -10,8 +15,20 @@ //! - `cadcraft:dirty` (to the parent): `{ dirty }` whenever unsaved changes appear or go away. //! - `cadcraft: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. In host mode the app-//! starts on the Start tab rather than an empty Drawing1, and the parent guards leaving the page.+//! 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="cadcraft_canvas">`+//! with `data-host="page"`, no iframe) gets the same behaviour over DOM `CustomEvent`s on the+//! canvas instead of `postMessage`: the same events outward (`ready`, `save`, `dirty`, `failed`,+//! with the message fields as the event's `detail`), and the page dispatches `cadcraft:open` on+//! the canvas with `detail: { name, bytes }` to open a drawing. Events don't bubble, so two+//! editors on one page can't hear each other, and the canvas is the only target CADCraft+//! listens on.+//!+//! In both host modes the app starts on the Start tab rather than an empty Drawing1, and the+//! host guards leaving the page.  use std::cell::{Cell, RefCell}; @@ -21,7 +38,7 @@ use wasm_bindgen::JsValue; use wasm_bindgen::closure::Closure; use web_sys::js_sys; -/// Longest file name taken from the parent page.+/// Longest file name taken from the host page. const MAX_NAME_CHARS: usize = 255;  thread_local! {@@ -29,19 +46,64 @@ thread_local! {     static BRIDGE: RefCell<Option<Bridge>> = const { RefCell::new(None) }; } +/// 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 app's own canvas.+    Page { canvas: web_sys::HtmlCanvasElement },+}++impl Transport {+    /// Hand an event to the host. In parent mode this is a `postMessage` with 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+    /// the save buffer to the parent instead of copying it (the parent mode only; the page mode+    /// cannot transfer, so its `cadcraft:save` detail carries a copy).+    fn send(&self, name: &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(name)));+                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| e.as_string().unwrap_or_else(|| format!("{e:?}")))+            }+            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 (they take `&self`).+                init.set_bubbles(false);+                init.set_detail(&detail);+                let event =+                    web_sys::CustomEvent::new_with_event_init_dict(name, &init).map_err(|e| e.as_string().unwrap_or_else(|| format!("{e:?}")))?;+                // The bool says whether a listener called `preventDefault`; not a send failure.+                canvas.dispatch_event(&event).map(|_| ()).map_err(|e| e.as_string().unwrap_or_else(|| format!("{e:?}")))?;+                Ok(())+            }+        }+    }+}+ #[derive(Clone)] pub struct Bridge {-    window: web_sys::Window,-    parent: web_sys::Window,-    origin: String,+    transport: Transport, } -/// The unsaved-changes state last reported to the parent.+/// The unsaved-changes state last reported to the host. #[derive(Default)] pub struct DirtyReport(Cell<Option<bool>>);  impl Bridge {-    /// `None` when the page isn't framed (there is no parent to talk to).+    /// 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.     pub fn new() -> Option<Self> {         let window = web_sys::window()?;         let parent = window.parent().ok().flatten()?;@@ -49,7 +111,13 @@ impl Bridge {             return None;         }         let origin = window.location().origin().ok()?;-        Some(Self { window, parent, origin })+        Some(Self { transport: Transport::Parent { window, parent, origin } })+    }++    /// The page bridge for `data-host="page"`: the host page runs CADCraft in its own document+    /// and hears the app's DOM `CustomEvent`s on this canvas.+    pub fn for_page(canvas: &web_sys::HtmlCanvasElement) -> Self {+        Self { transport: Transport::Page { canvas: canvas.clone() } }     }      /// Make this the bridge [`deliver`] uses.@@ -58,74 +126,90 @@ impl Bridge {     }      pub fn post_ready(&self) {-        self.send(&[("type", "cadcraft:ready".into()), ("version", env!("CARGO_PKG_VERSION").into())]);+        let _ = self.transport.send("cadcraft:ready", &[("version", env!("CARGO_PKG_VERSION").into())], None);     }      pub fn post_failed(&self, error: &str) {-        self.send(&[("type", "cadcraft:failed".into()), ("error", error.into())]);+        let _ = self.transport.send("cadcraft:failed", &[("error", error.into())], None);     } -    /// Hear drawings from the parent; they arrive through the upload inbox.+    /// Hear drawings from the host; they arrive through the upload inbox.     pub fn listen(&self, inbox: crate::upload::Inbox) {-        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();-            if string(&data, "type").as_deref() != Some("cadcraft:open") {-                return;+        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("cadcraft:open") {+                        return;+                    }+                    receive_open(&data, &inbox);+                });+                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();+                }             }-            let name = string(&data, "name")-                .filter(|n| !n.trim().is_empty())-                .map(|n| n.chars().take(MAX_NAME_CHARS).collect())-                .unwrap_or_else(|| "Drawing.dxf".to_string());-            // Refuse an oversized drawing before copying it into the app's memory.-            let Some(array) = byte_array(&data) else { return };-            if let Some(e) = CadApp::open_size_error(&name, u64::from(array.length())) {-                inbox.refuse(e);-                return;+            Transport::Page { canvas } => {+                let on_open = Closure::<dyn FnMut(web_sys::Event)>::new(move |event: web_sys::Event| {+                    let Some(open) = event.dyn_ref::<web_sys::CustomEvent>() else { return };+                    let detail = open.detail();+                    receive_open(&detail, &inbox);+                });+                if canvas.add_event_listener_with_callback("cadcraft:open", on_open.as_ref().unchecked_ref()).is_ok() {+                    // The listener lives as long as the page.+                    on_open.forget();+                }             }-            inbox.receive(name, array.to_vec());-        });-        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();         }     } -    /// After each logic callback (including a hidden tab's): tell the parent when unsaved changes+    /// After each logic callback (including a hidden tab's): tell the host when unsaved changes     /// appear or go away. `DirtyReport` suppresses duplicates, so this is idempotent.     pub fn report_dirty(&self, app: &CadApp, reported: &DirtyReport) {         let dirty = app.session.docs.iter().any(|d| d.is_dirty());-        if reported.0.get() != Some(dirty) && self.send(&[("type", "cadcraft:dirty".into()), ("dirty", dirty.into())]) {+        if reported.0.get() != Some(dirty) && self.transport.send("cadcraft:dirty", &[("dirty", dirty.into())], None).is_ok() {             reported.0.set(Some(dirty));         }     }      fn post_save(&self, name: &str, bytes: &[u8]) -> Result<(), String> {+        // `cadcraft:save` carries the bytes as an `ArrayBuffer`; the parent bridge transfers it.         let buffer = js_sys::Uint8Array::from(bytes).buffer();-        let message = message(&[("type", "cadcraft:save".into()), ("name", name.into()), ("bytes", buffer.clone().into())])-            .ok_or_else(|| "couldn't build the message".to_string())?;-        self.parent-            .post_message_with_transfer(&message, &self.origin, &js_sys::Array::of1(&buffer))-            .map_err(|e| e.as_string().unwrap_or_else(|| format!("{e:?}")))-    }--    /// 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())+        self.transport.send("cadcraft:save", &[("name", name.into()), ("bytes", buffer.clone().into())], Some(&buffer))     } }  /// The engine's delivery hook in host mode ([`cadcraft_engine::cmd::file::set_deliver`]): hand-/// the file to the parent page. `Ok` once it was posted; the engine then counts a drawing as saved.+/// the file to the host page. `Ok` once it was sent; the engine then counts a drawing as saved. pub fn deliver(name: &str, _mime: &str, bytes: &[u8]) -> Result<(), String> {     let bridge = BRIDGE.with(|b| b.borrow().clone()).ok_or("the page that embeds CADCraft is gone")?;     bridge.post_save(name, bytes) } +/// The `cadcraft:open` payload, `{ name, bytes }` where `bytes` is an `ArrayBuffer` or+/// `Uint8Array` holding a drawing: it opens like a file picked with File › Open or dropped on+/// the window. Shared by both host modes (the parent's message object and the page event's+/// `detail` carry the same fields). A blank or over-long name falls back to `Drawing.dxf`+/// (trimmed to [`MAX_NAME_CHARS`]); an oversized drawing is refused before it is copied.+fn receive_open(data: &JsValue, inbox: &crate::upload::Inbox) {+    let name = string(data, "name")+        .filter(|n| !n.trim().is_empty())+        .map(|n| n.chars().take(MAX_NAME_CHARS).collect())+        .unwrap_or_else(|| "Drawing.dxf".to_string());+    // Refuse an oversized drawing before copying it into the app's memory.+    let Some(array) = byte_array(data) else { return };+    if let Some(e) = CadApp::open_size_error(&name, u64::from(array.length())) {+        inbox.refuse(e);+        return;+    }+    inbox.receive(name, array.to_vec());+}+ fn message(fields: &[(&str, JsValue)]) -> Option<js_sys::Object> {     let m = js_sys::Object::new();     for (key, value) in fields {diff --git a/apps/cadcraft-web/src/main.rs b/apps/cadcraft-web/src/main.rs--- a/apps/cadcraft-web/src/main.rs+++ b/apps/cadcraft-web/src/main.rs@@ -6,7 +6,8 @@ //! with egui shapes instead of the GPU canvas, `?sample` opens the sample drawing. Saving downloads //! the drawing through the browser (`download.rs`). With `?host=parent` (inside an `<iframe>`), a //! same-origin parent page sends the drawing to open and stores what CADCraft saves instead-//! (`host.rs`).+//! (`host.rs`); a page can also host CADCraft in its own document (no iframe) with+//! `data-host="page"` on the canvas, talking over DOM `CustomEvent`s on it (`host.rs`). #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]  #[cfg(target_arch = "wasm32")]@@ -24,7 +25,8 @@ mod web {     const LOADING_ID: &str = "cadcraft_loading";      /// The app, the files File > Open… and drag and drop read for it in the background, the-    /// page embedding it (`?host=parent`), the browser's `prefers-reduced-motion` (it stays+    /// host page (`?host=parent`, or `data-host="page"` on the canvas), the browser's+    /// `prefers-reduced-motion` (it stays     /// live, so View ▸ Accessibility ▸ Animation Effects ▸ Use System Setting follows a change),     /// and the document title last written to the page.     struct Shell(@@ -93,6 +95,12 @@ mod web {         web_sys::window().and_then(|w| w.location().search().ok()).unwrap_or_default()     } +    /// The app's canvas: the one element `index.html` and every embedding page must provide,+    /// with the id the app looks up.+    fn app_canvas() -> Option<web_sys::HtmlCanvasElement> {+        web_sys::window()?.document()?.get_element_by_id(CANVAS_ID).and_then(|e| e.dyn_into::<web_sys::HtmlCanvasElement>().ok())+    }+     fn query_param(key: &str) -> Option<String> {         web_sys::UrlSearchParams::new_with_str(&query()).ok()?.get(key)     }@@ -120,10 +128,23 @@ mod web {             groups: cadcraft_io::entity_groups,             entity: cadcraft_io::entity_from_groups,         });+        let Some(canvas) = app_canvas() else {+            log::error!("missing <canvas id=\"{CANVAS_ID}\">");+            return;+        };         // `?host=parent`: a same-origin page framing CADCraft (such as a Nextcloud app) opens-        // drawings in it and stores what it saves. Otherwise there is no file system here: saved-        // files reach the user as browser downloads.-        let bridge = if query_param("host").as_deref() == Some("parent") { crate::host::Bridge::new() } else { None };+        // drawings in it and stores what it saves. Otherwise `data-host="page"` on the canvas+        // opts in a page that runs CADCraft in its own document (no iframe) and talks over DOM+        // `CustomEvent`s on the canvas (`host`). With neither, there is no file system here:+        // saved files reach the user as browser downloads; an unframed `?host=parent` stays+        // that normal app.+        let bridge = if query_param("host").as_deref() == Some("parent") {+            crate::host::Bridge::new()+        } else if crate::page_host_enabled(canvas.get_attribute("data-host").as_deref()) {+            Some(crate::host::Bridge::for_page(&canvas))+        } else {+            None+        };         match &bridge {             Some(bridge) => {                 bridge.install();@@ -134,10 +155,6 @@ mod web {         let app_bridge = bridge.clone();         wasm_bindgen_futures::spawn_local(async move {             let Some(document) = web_sys::window().and_then(|w| w.document()) else { return };-            let Some(canvas) = document.get_element_by_id(CANVAS_ID).and_then(|e| e.dyn_into::<web_sys::HtmlCanvasElement>().ok()) else {-                log::error!("missing <canvas id=\"{CANVAS_ID}\">");-                return;-            };             let mut options = eframe::WebOptions::default();             if query().contains("webgl")                 && let eframe::egui_wgpu::WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup@@ -149,7 +166,7 @@ mod web {                     canvas,                     options,                     Box::new(move |cc| {-                        // Embedded, the parent sends the drawing: start on the Start tab, not Drawing1.+                        // Embedded, the host sends the drawing: start on the Start tab, not Drawing1.                         let session = if app_bridge.is_some() { Session::empty() } else { Session::new() };                         let mut app = CadApp::new(session, services());                         app.system_languages =@@ -210,6 +227,14 @@ fn wants_cpu(query: &str) -> bool {     query.trim_start_matches('?').split('&').any(|p| p == "cpu" || p.starts_with("cpu=")) } +/// Host mode on the canvas is opt-in by attribute value: only the exact `page` turns it on+/// (`data-host="page"`). Everything else — `Page`, ` page`, `page `, `parent` (the URL opt-in),+/// empty or missing — keeps the normal app with downloads.+#[cfg(any(target_arch = "wasm32", test))]+fn page_host_enabled(data_host: Option<&str>) -> bool {+    data_host == Some("page")+}+ /// The browser tab title: the open drawing's file name, a bullet once it has unsaved changes, /// and the app name (`drawing.dwg • — CADCraft`), or just the app name with no drawing. An /// embedding page (`?host=parent`) reads it to tell which drawing opened and whether it is dirty.@@ -263,7 +288,7 @@ fn main() {  #[cfg(test)] mod tests {-    use super::{document_title, push_title, title_to_send, wants_cpu};+    use super::{document_title, page_host_enabled, push_title, title_to_send, wants_cpu};      /// Guards the `?cpu` URL flag: only a whole `cpu` query parameter (bare or with a value)     /// selects the CPU canvas, so `?sample` and `?gpucpu` stay on the GPU path.@@ -325,4 +350,15 @@ mod tests {         assert!(push_title(&mut sent, None, false, |title| written.push(title.to_owned())));         assert_eq!(written, ["drawing.dwg — CADCraft", "drawing.dwg • — CADCraft", "CADCraft"]);     }++    /// Guards the `data-host="page"` opt-in: only the exact value `page` on the canvas turns the+    /// page host mode on; anything else (or a missing attribute) keeps the normal app with+    /// downloads, and so does an unframed `?host=parent` (decided in `web::start`, not here).+    #[test]+    fn only_the_exact_page_attribute_enables_the_page_host_mode() {+        assert!(page_host_enabled(Some("page")));+        for off in [None, Some(""), Some("Page"), Some(" page"), Some("page "), Some("parent")] {+            assert!(!page_host_enabled(off), "{off:?} must keep host mode off");+        }+    } }diff --git a/contributors/people.toml b/contributors/people.toml--- a/contributors/people.toml+++ b/contributors/people.toml@@ -91,3 +91,6 @@ sync_github_name = false real_name = "Robert Lee" 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@@ -73,6 +73,66 @@ 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. +## Host mode: let the page open and store drawings++A page can take over the file side of the app instead of the browser's downloads. The iframe+embed above opts in by adding `?host=parent` to the iframe's URL+(`https://example.com/cadcraft/?host=parent`); the app then talks to its same-origin framing+page over `postMessage`, with plain window messages that carry a `type`:++| Message | Direction | Fields |+|---|---|---|+| `cadcraft:ready` | app → page | `version`: the app is listening; send a drawing now |+| `cadcraft:open` | page → app | `name`, `bytes` (an `ArrayBuffer` or `Uint8Array`): open like File › Open |+| `cadcraft:save` | app → page | `name`, `bytes` (a transferred `ArrayBuffer`): every file the app would otherwise download (SAVE, SAVEAS, exports). The page stores it and reports failures itself. |+| `cadcraft:dirty` | app → page | `dirty`: unsaved changes appeared or went away |+| `cadcraft:failed` | app → page | `error`: the app couldn't start |++In host mode the app starts on the Start tab instead of an empty Drawing1, so the page should+guard leaving it while changes are unsaved (that is what `cadcraft:dirty` reports).++### Same-document host mode (no iframe)++A page can also run the app in its own document — its own `<canvas id="cadcraft_canvas">`+inside a component, no iframe — by loading the wasm-bindgen module itself. It opts in with+`data-host="page"` on that canvas — the exact value `page`; anything else, and an unframed+`?host=parent`, keeps the normal app with downloads. The behaviour is the same as the parent+bridge, except the transport is DOM `CustomEvent`s on the canvas instead of `postMessage`: the+app dispatches `cadcraft:ready`, `cadcraft:save`, `cadcraft:dirty` and `cadcraft:failed` on the+canvas with the message fields (without the `type`) as the event's `detail`, and the page+dispatches a `cadcraft:open` event with `detail: { name, bytes }` to open a drawing. Events+don't bubble, so two editors on one page can't hear each other; listen on the canvas itself.++```html+<canvas id="cadcraft_canvas" data-host="page" style="width: 100%; height: 600px; border: 0;"></canvas>+```++```js+import init from "./cadcraft-web-<hash>.js"; // the wasm-bindgen glue shipped with the app++const canvas = document.getElementById("cadcraft_canvas");+// The app announces itself while `init` runs, so add the listeners first.+canvas.addEventListener("cadcraft:ready", () => {+  // The app is listening; hand it a drawing of the page's own now, if you have one.+  canvas.dispatchEvent(new CustomEvent("cadcraft:open", {+    detail: { name: "plan.dxf", bytes: drawingBytes }, // ArrayBuffer or Uint8Array+  }));+});+// Every file the app would otherwise download (SAVE, SAVEAS, exports) arrives here.+canvas.addEventListener("cadcraft:save", (event) => {+  const { name, bytes } = event.detail; // bytes is an ArrayBuffer+  storeFile(name, bytes);+});+canvas.addEventListener("cadcraft:dirty", (event) => {+  warnBeforeLeaving(event.detail.dirty);+});+canvas.addEventListener("cadcraft:failed", (event) => {+  showError(event.detail.error);+});++await init({ module_or_path: "./cadcraft-web-<hash>_bg.wasm" });+```+ ## Renderer selection and fallback flags  CADCraft renders with wgpu. It uses **WebGPU** when the browser has it and falls back to