collective

Browse / github.com/storytold/gridcraft / patch

Web: page host mode, so a page can host GridCraft in its own document (#2592)

Web: host GridCraft in the page's own document (no iframe) The web app's host bridge (`?host=parent`, [`host.rs`](https://github.com/storytold/gridcraft/blob/main/apps/gridcraft-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="gridcraft_canvas">` inside a component, can run GridCraft fine but has no way to hand it a workbook 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

Status
open
Score
0.00
Adopters
0 other operators
Author
helenkwok (imported from pull request #2593, open)
Base
ad10881cc3347c3a07bc5f04a4e876999647e839
Size
+282 −52 in 7 files
Review
pending
Shared
2026-10-28
Id
db501bd572c4722a61529e19

How it went

No other operator has reported using it yet.

Diff

raw · ROADMAP.md apps/gridcraft-web/Cargo.toml apps/gridcraft-web/src/host.rs apps/gridcraft-web/src/hostmode.rs apps/gridcraft-web/src/main.rs apps/gridcraft-web/src/web.rs packaging/web/README.md

diff --git a/ROADMAP.md b/ROADMAP.md--- a/ROADMAP.md+++ b/ROADMAP.md@@ -2,7 +2,7 @@  **Stage: alpha** · next: beta, ~25 points of "ready for real work" and ~160–240 h away -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-25 · **Change:** minor (AutoSum over a selected row puts its total to the right, and fills a selection's empty last row and column) · **Target:** Microsoft Excel (Microsoft 365)+> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-25 · **Change:** minor (page host mode: `data-host="page"` hosts GridCraft in a page's own document over DOM `CustomEvent`s; earlier: AutoSum over a selected row puts its total to the right, and fills a selection's empty last row and column) · **Target:** Microsoft Excel (Microsoft 365)  GridCraft aims at full Microsoft Excel parity, and at being better in places: faster, open (XLSX is the native format), fully scriptable by agents (MCP, control channel, CLI), and on the web.@@ -136,6 +136,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | What landed | |---|---| | 2026-10-25 | AutoSum over a selection does what Excel does: a selected row gets one total to its right (in its last cell if that is empty), not a one-cell total under each cell; a block whose last column is empty gets each row's total there, one whose last row is empty each column's, and with both the corner gets the total of the block; otherwise totals go under each column as before |+| 2026-10-25 | Page host mode: a page can host GridCraft 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-24 | Go To lists the named ranges of every sheet (`Sheet2!Name` for a name scoped to another sheet; hidden names, constants and formulas left out) and the references entered before, a double-click goes to an entry, and the Reference box takes several names or references separated by commas (`Price, Type`, `B14:C22,F19:G30`) | | 2026-10-24 | Status bar: a right-click opens a menu that sets which selection statistics show (Average, Count and Sum by default; Numerical Count, Minimum and Maximum are new), kept with the preferences (`view.statusBar`) | | 2026-10-24 | Ctrl+Shift+U (Control+Shift+U on a Mac) expands and collapses the formula bar, on the grid and while editing (`view.expandFormulaBar`); on Windows and Linux it used to underline |@@ -239,6 +240,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | Change | Summary | |---|---|---| | 2026-10-25 | minor | Progress log: AutoSum over a selected row, and a selection's empty last row and column |+| 2026-10-25 | minor | Page host mode (`data-host="page"` on the canvas): host GridCraft in its own document over DOM `CustomEvent`s, same messages as the `?host=parent` parent bridge; progress log row | | 2026-10-23 | minor | Progress log: accounting negatives in number text | | 2026-10-23 | minor | Progress log: INDEX with one index on a 2-D reference is #REF! | | 2026-10-22 | trivial | Progress log: formula hardening (#2163, #2162) |diff --git a/apps/gridcraft-web/Cargo.toml b/apps/gridcraft-web/Cargo.toml--- a/apps/gridcraft-web/Cargo.toml+++ b/apps/gridcraft-web/Cargo.toml@@ -26,4 +26,4 @@ rfd = { workspace = true } wasm-bindgen = "0.2" wasm-bindgen-futures = "0.4" js-sys = "0.3"-web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "HtmlIFrameElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Clipboard", "ClipboardItem", "Navigator", "MessageEvent", "UrlSearchParams", "AddEventListenerOptions", "Storage"] }+web-sys = { version = "0.3", features = ["Window", "Document", "Element", "Event", "EventTarget", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "HtmlIFrameElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Clipboard", "ClipboardItem", "Navigator", "MessageEvent", "UrlSearchParams", "AddEventListenerOptions", "Storage", "CustomEvent", "CustomEventInit"] }diff --git a/apps/gridcraft-web/src/host.rs b/apps/gridcraft-web/src/host.rs--- a/apps/gridcraft-web/src/host.rs+++ b/apps/gridcraft-web/src/host.rs@@ -1,5 +1,10 @@-//! The `postMessage` bridge to a same-origin parent page (`?host=parent`), for pages that embed-//! GridCraft in an `<iframe>` (such as a Nextcloud app). Messages are plain objects with a `type`:+//! Host modes: a page that runs GridCraft (as an iframe, or in its own document) opens workbooks+//! 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 GridCraft talk over `postMessage` between same-origin windows. Messages+//! are plain objects with a `type`: //! //! - `gridcraft:ready` (to the parent): `{ version }`, the app is listening; send a file now. //! - `gridcraft:open` (from the parent): `{ name, bytes }`, bytes an `ArrayBuffer` or@@ -10,8 +15,21 @@ //! - `gridcraft:dirty` (to the parent): `{ dirty }` whenever unsaved changes appear or go away. //! - `gridcraft: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 page-//! starts with an empty workbook instead of a sample, 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="gridcraft_canvas">`+//! with `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+//! `gridcraft:open` on the canvas with `detail: { name, bytes }` to open a workbook. Events don't+//! bubble, so two editors on one page can't hear each other, and the canvas is the only target+//! GridCraft listens on.+//!+//! In both host modes the page starts with an empty workbook instead of a sample, saves and PDF+//! exports are handed to the page rather than downloaded, and `?author=` names the user for new+//! notes and comments.  use std::cell::{Cell, RefCell}; use std::rc::Rc;@@ -21,26 +39,69 @@ use wasm_bindgen::JsCast as _; use wasm_bindgen::JsValue; use wasm_bindgen::closure::Closure; -/// Saving one of these hands the whole workbook to the parent, which stores it: the workbook+use crate::hostmode::open_name;++/// Saving one of these hands the whole workbook to the host, which stores it: the workbook /// counts as saved under that name, as it would after Save on the desktop. const WORKBOOK_EXTS: &[&str] = &["xlsx"];  /// The longest user name taken from `?author=`. const MAX_AUTHOR_CHARS: usize = 200; +/// How a host page is reached. The modes behave the same; they differ only in how an event goes+/// out ([`Transport::send`]) and how an open request comes in ([`Bridge::listen`]).+#[derive(Clone)]+enum Transport {+    /// `?host=parent`: `postMessage` between same-origin windows.+    Parent { window: web_sys::Window, parent: web_sys::Window, origin: String },+    /// `data-host="page"`: DOM `CustomEvent`s on the editor's own canvas.+    Page { canvas: web_sys::HtmlCanvasElement },+}++impl Transport {+    /// Hand an event to the host. In parent mode this is a `postMessage` 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).+    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` is only ever passed for the parent mode's small events; the page mode+                    // can't transfer anyway (below).+                    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();+                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"))?;+                canvas.dispatch_event(&custom).map_err(|e| js_error(&e, "couldn't dispatch the event"))?;+                Ok(())+            }+        }+    }+}+ #[derive(Clone)] pub struct Bridge {-    window: web_sys::Window,-    parent: web_sys::Window,-    origin: String,-    /// Names of the files handed to the parent since the last frame.+    transport: Transport,+    /// Names of the files handed to the host since the last frame.     saved: Rc<RefCell<Vec<String>>>,-    /// The unsaved-changes state last reported to the parent.+    /// The unsaved-changes state last reported to the host.     reported: Rc<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()?;@@ -48,10 +109,16 @@ impl Bridge {             return None;         }         let origin = window.location().origin().ok()?;-        Some(Self { window, parent, origin, saved: Rc::default(), reported: Rc::default() })+        Some(Self { transport: Transport::Parent { window, parent, origin }, saved: Rc::default(), reported: Rc::default() })     } -    /// Send saved and exported files to the parent instead of downloading them, and hear files+    /// The page bridge for `data-host="page"`: the host page runs GridCraft 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() }, saved: Rc::default(), reported: Rc::default() }+    }++    /// Send saved and exported files to the host instead of downloading them, and hear files     /// from it.     pub fn connect(&self, services: &mut Services, inbox: Inbox, ctx: egui::Context) {         self.listen(inbox, ctx);@@ -72,15 +139,15 @@ impl Bridge {     }      pub fn post_ready(&self) {-        self.send(&[("type", "gridcraft:ready".into()), ("version", env!("CARGO_PKG_VERSION").into())]);+        let _ = self.transport.send("gridcraft:ready", &[("version", env!("CARGO_PKG_VERSION").into())], None);     }      pub fn post_failed(&self, error: &str) {-        self.send(&[("type", "gridcraft:failed".into()), ("error", error.into())]);+        let _ = self.transport.send("gridcraft:failed", &[("error", error.into())], None);     } -    /// After each frame: a workbook handed to the parent this frame counts as saved under the-    /// name it was saved as, and the parent hears when unsaved changes appear or go away.+    /// After each frame: a workbook handed to the host this frame counts as saved under the+    /// name it was saved as, and the host hears when unsaved changes appear or go away.     pub fn sync(&self, app: &mut SheetApp) {         let saved = std::mem::take(&mut *self.saved.borrow_mut());         if let Some(name) = saved.iter().rev().find(|name| is_workbook(name))@@ -92,55 +159,81 @@ impl Bridge {             }         }         let dirty = app.session.documents().iter().any(|d| d.is_dirty());-        if self.reported.get() != Some(dirty) && self.send(&[("type", "gridcraft:dirty".into()), ("dirty", dirty.into())]) {+        if self.reported.get() != Some(dirty) && self.transport.send("gridcraft:dirty", &[("dirty", dirty.into())], None).is_ok() {             self.reported.set(Some(dirty));         }     }      fn post_save(&self, path: &str, bytes: &[u8]) -> Result<(), String> {-        let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string());+        let name = file_name(path);+        // `gridcraft:save` carries the bytes as an `ArrayBuffer`; the parent bridge transfers it.         let buffer = js_sys::Uint8Array::from(bytes).buffer();-        let message = message(&[("type", "gridcraft:save".into()), ("name", name.as_str().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:?}")))?;+        self.transport.send("gridcraft:save", &[("name", name.as_str().into()), ("bytes", buffer.clone().into())], Some(&buffer))?;         self.saved.borrow_mut().push(name);         Ok(())     }      fn listen(&self, inbox: Inbox, ctx: egui::Context) {-        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;+        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("gridcraft:open") {+                        return;+                    }+                    deliver_open(&inbox, &ctx, &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();+                }             }-            let data = event.data();-            if string(&data, "type").as_deref() != Some("gridcraft:open") {-                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();+                    deliver_open(&inbox, &ctx, &detail);+                });+                if canvas.add_event_listener_with_callback("gridcraft:open", on_open.as_ref().unchecked_ref()).is_ok() {+                    // The listener lives as long as the page.+                    on_open.forget();+                }             }-            let name = string(&data, "name").filter(|n| !n.trim().is_empty()).unwrap_or_else(|| "Book.xlsx".to_string());-            let Some(bytes) = bytes(&data) else { return };-            inbox.lock().unwrap_or_else(|e| e.into_inner()).push((name, bytes));-            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();         }     }+} -    /// 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 file an open message or event carries, pushed onto the inbox for the next frame, like a+/// dropped file.+fn deliver_open(inbox: &Inbox, ctx: &egui::Context, data: &JsValue) {+    if let Some((name, bytes)) = open_payload(data) {+        inbox.lock().unwrap_or_else(|e| e.into_inner()).push((name, bytes));+        ctx.request_repaint();     } } +/// The file an open message or event carries: a usable name and the bytes.+fn open_payload(data: &JsValue) -> Option<(String, Vec<u8>)> {+    let name = open_name(string(data, "name").as_deref());+    let bytes = bytes(data)?;+    Some((name, bytes))+}+ fn is_workbook(name: &str) -> bool {     name.rsplit_once('.').is_some_and(|(_, ext)| WORKBOOK_EXTS.iter().any(|e| ext.eq_ignore_ascii_case(e))) } +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+/// set. fn message(fields: &[(&str, JsValue)]) -> Option<js_sys::Object> {     let m = js_sys::Object::new();     for (key, value) in fields {@@ -164,3 +257,8 @@ fn bytes(data: &JsValue) -> Option<Vec<u8>> {         None     } }++/// 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/gridcraft-web/src/hostmode.rs b/apps/gridcraft-web/src/hostmode.rsnew file mode 100644--- /dev/null+++ b/apps/gridcraft-web/src/hostmode.rs@@ -0,0 +1,45 @@+//! Pure host-mode rules, shared by the bridge ([`crate::host`]) and testable without a browser+//! (like [`crate::prefs`]): what opts a page into hosting GridCraft in its own document, and the+//! name the bridge fills in when a host's open request has none.++/// The one `data-host` value that opts a page in: the exact string `"page"`.+pub const PAGE_HOST: &str = "page";++/// The file name used when an open request carries no usable one.+pub const DEFAULT_OPEN_NAME: &str = "Book.xlsx";++/// 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.+pub fn page_host_requested(data_host: Option<&str>) -> bool {+    data_host == Some(PAGE_HOST)+}++/// A usable file name for an open request: the host's name when it isn't blank, else the+/// [`DEFAULT_OPEN_NAME`].+pub fn open_name(name: Option<&str>) -> String {+    name.filter(|n| !n.trim().is_empty()).unwrap_or(DEFAULT_OPEN_NAME).to_string()+}++#[cfg(test)]+mod tests {+    use super::*;++    #[test]+    fn only_the_exact_page_value_opts_in() {+        assert!(page_host_requested(Some("page")));+        assert!(!page_host_requested(Some("Page")));+        assert!(!page_host_requested(Some(" page")));+        assert!(!page_host_requested(Some("parent")));+        assert!(!page_host_requested(Some("")));+        assert!(!page_host_requested(None));+    }++    #[test]+    fn open_names_fall_back_to_the_default_when_blank() {+        assert_eq!(open_name(Some("Budget.xlsx")), "Budget.xlsx");+        assert_eq!(open_name(Some(" Sheet2!Book.xlsx ")), " Sheet2!Book.xlsx ");+        assert_eq!(open_name(Some("")), DEFAULT_OPEN_NAME);+        assert_eq!(open_name(Some("   ")), DEFAULT_OPEN_NAME);+        assert_eq!(open_name(None), DEFAULT_OPEN_NAME);+    }+}diff --git a/apps/gridcraft-web/src/main.rs b/apps/gridcraft-web/src/main.rs--- a/apps/gridcraft-web/src/main.rs+++ b/apps/gridcraft-web/src/main.rs@@ -10,15 +10,19 @@ //! //! URL query flags: `?webgl` forces WebGL2; `?sample=sales|budget|grades` opens a sample; //! `?host=parent` (inside an `<iframe>`) talks to a same-origin parent page instead: it sends the-//! workbook to open and stores what GridCraft saves (`host`), and `&author=` names the user for-//! notes and comments. `?chrome=none|notitlebar|nofiletab` leaves the title bar and/or the File tab-//! undrawn for a host that embeds the sheet in its own window (see `Chrome`). Preferences (theme,-//! interface size, editing options…) are kept in `localStorage` (`prefs`).+//! workbook to open and stores what GridCraft saves (`host`). A page can host GridCraft in its+//! own document (no iframe) with `data-host="page"` on the canvas, talking over DOM `CustomEvent`s+//! on the canvas. `&author=` names the user for notes and comments in either host mode.+//! `?chrome=none|notitlebar|nofiletab` leaves the title bar and/or the File tab undrawn for a host+//! that embeds the sheet in its own window (see `Chrome`). Preferences (theme, interface size,+//! editing options…) are kept in `localStorage` (`prefs`). #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]  #[cfg(target_arch = "wasm32")] mod host; #[cfg(any(target_arch = "wasm32", test))]+mod hostmode;+#[cfg(any(target_arch = "wasm32", test))] mod prefs; #[cfg(target_arch = "wasm32")] mod web;diff --git a/apps/gridcraft-web/src/web.rs b/apps/gridcraft-web/src/web.rs--- a/apps/gridcraft-web/src/web.rs+++ b/apps/gridcraft-web/src/web.rs@@ -38,8 +38,17 @@ pub fn start() {             create.instance_descriptor.backends = eframe::wgpu::Backends::GL;         }         // `?host=parent`: a same-origin page framing GridCraft (such as a Nextcloud app) opens-        // workbooks in it and stores what it saves (`host`).-        let bridge = if query_param("host").as_deref() == Some("parent") { crate::host::Bridge::new() } else { None };+        // workbooks 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 GridCraft in its own document and talks over DOM+        // `CustomEvent`s on the canvas (`host`).+        let bridge = if query_param("host").as_deref() == Some("parent") {+            crate::host::Bridge::new()+        } else if crate::hostmode::page_host_requested(canvas.get_attribute("data-host").as_deref()) {+            Some(crate::host::Bridge::for_page(&canvas))+        } else {+            None+        };         let app_bridge = bridge.clone();         let result = eframe::WebRunner::new()             .start(@@ -48,7 +57,7 @@ pub fn start() {                 Box::new(move |cc| {                     let inbox: Inbox = Inbox::default();                     let mut session = Session::new();-                    // In host mode the parent sends the workbook to edit; no sample meanwhile.+                    // In host mode the host sends the workbook to edit; no sample meanwhile.                     let q = if app_bridge.is_some() { String::new() } else { query() };                     if let Some(name) = q.split(['?', '&']).find_map(|kv| kv.strip_prefix("sample=")) {                         let _ = session.execute("file.new", serde_json::json!({"sample": name}));diff --git a/packaging/web/README.md b/packaging/web/README.md--- a/packaging/web/README.md+++ b/packaging/web/README.md@@ -1,5 +1,6 @@ # Hosting GridCraft for the web + `gridcraft-web-<version>.zip` (from the GitHub release, or `packaging/web/package.sh`) holds a static site in `gridcraft-web-<version>/`: @@ -77,6 +78,77 @@ Local test: `python3 -m http.server 8765` inside the folder, then open http://lo   one. Use them when the host window already shows the file name and owns the files, so the sheet's   own Open or Save As cannot replace what the host thinks is open. Everything stays on by default. +### The parent bridge (`?host=parent`)++A page framing GridCraft in a **same-origin** iframe can add `?host=parent` to the iframe's URL+(`https://example.com/gridcraft/?host=parent`) and have GridCraft hand its files over instead of+downloading them. The two sides talk `postMessage` with plain window messages that carry a+`type`:++| Message | Direction | Fields |+|---|---|---|+| `gridcraft:ready` | app → page | `version`: the app is listening; send a file now |+| `gridcraft:open` | page → app | `name`, `bytes` (an `ArrayBuffer` or `Uint8Array`): open like File › Open |+| `gridcraft:save` | app → page | `name`, `bytes` (a transferred `ArrayBuffer`): every file the app would otherwise download (Save, Save As, Export as PDF). The page stores it and reports failures itself. |+| `gridcraft:dirty` | app → page | `dirty`: unsaved changes appeared or went away |+| `gridcraft:failed` | app → page | `error`: the app couldn't start |++In host mode the app starts with an empty workbook instead of a sample, so the framing page+should guard leaving the page while changes are unsaved. `&author=` names the user for the+notes and comments the app creates. Opening `?host=parent` outside an iframe gives the normal+app with downloads.++### Embedding without an iframe (page host mode)++A page that loads the GridCraft module into its own document — its own `<canvas+id="gridcraft_canvas">` inside a component, sized to a box beside the page's own header and+controls — opts in with `data-host="page"` on that canvas. 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 `gridcraft:ready`, `gridcraft:save`, `gridcraft:dirty` and+`gridcraft:failed` on the canvas with the message fields above as the event's `detail`, and the+page dispatches `gridcraft:open` on the canvas with `detail: { name, bytes }` to open a+workbook. Events don't bubble, so two editors on one page can't hear each other; listen on the+canvas itself.++```html+<canvas id="gridcraft_canvas" data-host="page" style="width: 100%; height: 600px; border: 0;"></canvas>+```++```js+import init from "./gridcraft-web-<hash>.js"; // the wasm-bindgen glue shipped with the app++// Listen first: the app announces itself while `init` runs.+const canvas = document.getElementById("gridcraft_canvas");++canvas.addEventListener("gridcraft:ready", async () => {+  // Open a workbook of the page's own, as with File › Open.+  const bytes = await workbookBytes(); // an ArrayBuffer or Uint8Array+  canvas.dispatchEvent(new CustomEvent("gridcraft:open", {+    detail: { name: "Budget.xlsx", bytes },+  }));+});++// Every file GridCraft would otherwise download (Save, Save As, Export as PDF) arrives here.+canvas.addEventListener("gridcraft:save", (event) => {+  const { name, bytes } = event.detail; // bytes is an ArrayBuffer+  storeFile(name, bytes);+});++canvas.addEventListener("gridcraft:dirty", (event) => {+  warnBeforeLeaving(event.detail.dirty);+});+canvas.addEventListener("gridcraft:failed", (event) => {+  showError(event.detail.error);+});++await init({ module_or_path: "./gridcraft-web-<hash>_bg.wasm" });+```++As with the parent bridge, the app starts with an empty workbook instead of a sample, and+`?author=` on the page's URL names the user for new notes and comments. The `data-host` value+must be exactly `page`; anything else is ignored, and the app runs as a standalone page with+downloads.+ ## Renderer selection  GridCraft's web build is the same egui app as the desktop one, compiled to wasm