diff --git a/Cargo.lock b/Cargo.lock
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1024,6 +1024,16 @@ dependencies = [
  "zip",
 ]
 
+[[package]]
+name = "craft-web-host"
+version = "0.1.0"
+source = "git+https://github.com/storytold/craft-libs?rev=31bda8afa9b94038b0bb32208789ac4be018889d#31bda8afa9b94038b0bb32208789ac4be018889d"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+]
+
 [[package]]
 name = "craft-window"
 version = "0.1.0"
@@ -6347,6 +6357,7 @@ dependencies = [
  "craft-keymap",
  "craft-ui",
  "craft-update",
+ "craft-web-host",
  "egui",
  "egui_extras",
  "egui_kittest",
@@ -6381,6 +6392,7 @@ dependencies = [
 name = "vectorcraft-web"
 version = "0.17.0"
 dependencies = [
+ "craft-web-host",
  "eframe",
  "egui",
  "js-sys",
diff --git a/Cargo.toml b/Cargo.toml
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -16,6 +16,8 @@ craft-print = { git = "https://github.com/storytold/craft-libs", rev = "a0916808
 craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "a0916808f7d78bed8c7b90cdf937176015454a7f" }
 craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "a0916808f7d78bed8c7b90cdf937176015454a7f", features = ["serde"] }
 craft-keymap = { git = "https://github.com/storytold/craft-libs", rev = "a0916808f7d78bed8c7b90cdf937176015454a7f" }
+# window.craftStorage, shared by the Craft apps' web builds (storytold/craft-libs#360).
+craft-web-host = { git = "https://github.com/storytold/craft-libs", rev = "31bda8afa9b94038b0bb32208789ac4be018889d" }
 # Decides who draws the window frame (desktop vs. app) so the suite shares one native titlebar per
 # desktop. The `egui` feature adds `craft_window::decorate`. (craft-libs#217.)
 craft-window = { git = "https://github.com/storytold/craft-libs", rev = "a0916808f7d78bed8c7b90cdf937176015454a7f", features = ["egui"] }
diff --git a/apps/vectorcraft-web/Cargo.toml b/apps/vectorcraft-web/Cargo.toml
--- a/apps/vectorcraft-web/Cargo.toml
+++ b/apps/vectorcraft-web/Cargo.toml
@@ -16,6 +16,7 @@ path = "src/main.rs"
 [dependencies]
 vectorcraft-engine = { workspace = true, features = ["bundled-fonts"] }
 vectorcraft-ui-egui = { workspace = true }
+craft-web-host = { workspace = true }
 vectorcraft-format = { workspace = true }
 vectorcraft-text = { workspace = true }
 egui = { workspace = true }
diff --git a/apps/vectorcraft-web/src/main.rs b/apps/vectorcraft-web/src/main.rs
--- a/apps/vectorcraft-web/src/main.rs
+++ b/apps/vectorcraft-web/src/main.rs
@@ -32,6 +32,8 @@ mod pencil;
 #[cfg(any(target_arch = "wasm32", test))]
 mod saved_files;
 #[cfg(target_arch = "wasm32")]
+mod storage;
+#[cfg(target_arch = "wasm32")]
 mod web;
 
 #[cfg(target_arch = "wasm32")]
diff --git a/apps/vectorcraft-web/src/storage.rs b/apps/vectorcraft-web/src/storage.rs
new file mode 100644
--- /dev/null
+++ b/apps/vectorcraft-web/src/storage.rs
@@ -0,0 +1,171 @@
+//! Host storage: a page that serves VectorCraft can offer its own file storage (a cloud drive, a
+//! team folder) next to this device, through `window.craftStorage`:
+//!
+//! ```js
+//! window.craftStorage = {
+//!   // File › Open: {path, bytes} for a file in the host's storage, "device" for the browser's
+//!   // own file picker, or null when the user cancelled.
+//!   async chooseOpen(extensions /* ["svg", …] or null */) { … },
+//!   // A save or export to download: a path in the host's storage, "device" for a download, or null.
+//!   async chooseSave(suggested /* the file name */) { … },
+//!   // Writes a file the user saved to a host path; the host reports progress and errors itself.
+//!   // The save counts once it resolves; a rejection leaves the document modified. Saves come
+//!   // one at a time, in the order the user made them, so each must settle: a host whose storage
+//!   // can hang should reject after its own timeout (only the host can tell a slow upload from a
+//!   // stuck one).
+//!   async write(path, bytes /* Uint8Array */) { … },
+//! };
+//! ```
+//!
+//! Host paths have a scheme of their own, such as `cloud:home/art/logo.svg`
+//! ([`craft_web_host::is_host_path`]): a document opened from one keeps it, so File › Save
+//! writes back there without asking. Without `window.craftStorage` nothing changes.
+//!
+//! Browsers open a file picker only during a click: when the user picks "This device" in the host's
+//! own dialog, `chooseOpen` should resolve with "device" right from that click.
+//!
+//! The contract and its rules are the same for every Craft app and live in `craft-web-host`; this
+//! module connects them to VectorCraft's open and download services.
+
+use std::cell::RefCell;
+use std::collections::HashMap;
+use std::future::Future;
+use std::rc::Rc;
+
+use craft_web_host::is_host_path;
+use craft_web_host::storage::{self, OpenChoice, SaveChoice, SaveQueue};
+use vectorcraft_ui_egui::Services;
+
+use crate::web::Inbox;
+
+/// What a save to the host came to: whether the file was stored, and the host path the user chose
+/// for it in the host's Save dialog.
+type Outcome = (bool, Option<String>);
+
+/// A finished save, for [`vectorcraft_ui_egui::io::saves_settled`]: (document, name or host path,
+/// saved, the host path the user chose).
+pub type Settled = (Option<u64>, String, bool, Option<String>);
+
+thread_local! {
+    /// Saves to the host, one at a time in the order the user made them (`craft_web_host`).
+    static SAVES: SaveQueue<Outcome> = SaveQueue::new();
+    /// Each save in [`SAVES`] by its id: the document whose save it is, and its name or host path.
+    static QUEUED: RefCell<HashMap<u64, (Option<u64>, String)>> = RefCell::new(HashMap::new());
+    /// The latest save of each document (or, for an export, each name): an earlier one that
+    /// failed doesn't count once a later one is on its way, which answers for the document itself.
+    static LATEST: RefCell<HashMap<(Option<u64>, String), u64>> = RefCell::new(HashMap::new());
+}
+
+fn in_flight() -> bool {
+    SAVES.with(SaveQueue::busy)
+}
+
+/// What tells saves apart for [`LATEST`]: the document, or an export's name.
+fn key(doc: Option<u64>, name: &str) -> (Option<u64>, String) {
+    (doc, if doc.is_some() { String::new() } else { name.to_string() })
+}
+
+/// Queue a save of `name` for `doc`; it runs once the saves before it have finished.
+fn enqueue(doc: Option<u64>, name: String, save: impl Future<Output = Outcome> + 'static) {
+    let id = SAVES.with(|q| q.push(save));
+    LATEST.with(|l| l.borrow_mut().insert(key(doc, &name), id));
+    QUEUED.with(|q| q.borrow_mut().insert(id, (doc, name)));
+}
+
+/// The saves that finished since the last call, for [`vectorcraft_ui_egui::io::saves_settled`]. One
+/// that failed or that the user cancelled marks its document modified again.
+pub fn take_settled() -> Vec<Settled> {
+    SAVES
+        .with(SaveQueue::take_done)
+        .into_iter()
+        .filter_map(|(id, (saved, host_path))| {
+            let (doc, name) = QUEUED.with(|q| q.borrow_mut().remove(&id))?;
+            let key = key(doc, &name);
+            let latest = LATEST.with(|l| {
+                let mut l = l.borrow_mut();
+                let latest = l.get(&key) == Some(&id);
+                if latest {
+                    l.remove(&key);
+                }
+                latest
+            });
+            // A failed save that a later one replaces leaves the document to that one.
+            Some((doc, name, saved || !latest, host_path.filter(|_| saved)))
+        })
+        .collect()
+}
+
+/// Write to the host; true once the host has the file.
+async fn write(path: &str, bytes: &[u8]) -> bool {
+    storage::write(path, bytes).await.inspect_err(|e| log::error!("vectorcraft-web: craftStorage.write of {path} failed: {e}")).is_ok()
+}
+
+/// Wrap the async open and the download in `services` so they offer the host's storage when the
+/// page has one: a file picked there arrives in `inbox` under its host path. The device's own
+/// picker and download stay one choice away.
+pub fn install(services: &mut Services, inbox: Inbox, ctx: egui::Context) {
+    {
+        let ctx = ctx.clone();
+        SAVES.with(|q| q.on_done(move || ctx.request_repaint()));
+    }
+    if let Some(device_open) = services.open_async.take() {
+        let device_open = Rc::new(RefCell::new(device_open));
+        services.open_async = Some(Box::new(move || {
+            if !storage::available() {
+                return (device_open.borrow_mut())();
+            }
+            let (device_open, inbox, ctx) = (device_open.clone(), inbox.clone(), ctx.clone());
+            wasm_bindgen_futures::spawn_local(async move {
+                match storage::choose_open(None).await {
+                    Ok(OpenChoice::File { path, bytes }) => {
+                        inbox.lock().unwrap_or_else(|e| e.into_inner()).push((path, bytes));
+                        ctx.request_repaint();
+                    }
+                    // The user's own choice: the browser's picker.
+                    Ok(OpenChoice::Device) => (device_open.borrow_mut())(),
+                    Ok(OpenChoice::Cancelled) => {}
+                    // A host that failed or answered something else: nothing was chosen.
+                    Err(e) => log::error!("vectorcraft-web: {e}"),
+                }
+            });
+        }));
+    }
+    if let Some(device_download) = services.download.take() {
+        services.save_in_flight = Some(Box::new(in_flight));
+        let device_download = Rc::new(RefCell::new(device_download));
+        services.download = Some(Box::new(move |name: &str, bytes: &[u8]| {
+            if !storage::available() {
+                return (device_download.borrow_mut())(name, bytes);
+            }
+            // The save counts as done now; one that doesn't happen marks the document modified again.
+            let doc = vectorcraft_ui_egui::io::saving_document();
+            let (device_download, owned, bytes) = (device_download.clone(), name.to_string(), bytes.to_vec());
+            enqueue(doc, name.to_string(), async move {
+                let name = owned;
+                if is_host_path(&name) {
+                    // File › Save of a document from the host: straight back to its path.
+                    return (write(&name, &bytes).await, None);
+                }
+                // Anything else the app would download (Save As, exports): ask where it goes.
+                match storage::choose_save(&name).await {
+                    Ok(SaveChoice::Host(path)) => (write(&path, &bytes).await, Some(path)),
+                    // The user's own choice: a download to the device.
+                    Ok(SaveChoice::Device) => {
+                        let downloaded = (device_download.borrow_mut())(&name, &bytes)
+                            .inspect_err(|e| log::error!("vectorcraft-web: download of {name} failed: {e}"))
+                            .is_ok();
+                        (downloaded, None)
+                    }
+                    Ok(SaveChoice::Cancelled) => (false, None),
+                    // A host that failed or answered something else: not saved, so the document
+                    // stays modified.
+                    Err(e) => {
+                        log::error!("vectorcraft-web: {e}");
+                        (false, None)
+                    }
+                }
+            });
+            Ok(())
+        }));
+    }
+}
diff --git a/apps/vectorcraft-web/src/web.rs b/apps/vectorcraft-web/src/web.rs
--- a/apps/vectorcraft-web/src/web.rs
+++ b/apps/vectorcraft-web/src/web.rs
@@ -120,7 +120,8 @@ async fn run(host: Host, canvas: web_sys::HtmlCanvasElement, moving: Option<Movi
                 }
                 host.pasted.borrow_mut().ctx = Some(ctx.clone());
                 let stylus = host.pointers.borrow().stylus();
-                let services = services(host.inbox.clone(), host.place_inbox.clone(), ctx.clone(), host.locks.clone(), stylus);
+                let mut services = services(host.inbox.clone(), host.place_inbox.clone(), ctx.clone(), host.locks.clone(), stylus);
+                crate::storage::install(&mut services, host.inbox.clone(), ctx.clone());
                 let app = match taken.borrow_mut().take() {
                     Some(mut app) => {
                         // The old services asked the old context for frames.
@@ -369,6 +370,10 @@ impl eframe::App for WebShell {
             self.restart(&why);
         }
         let Some(app) = &mut self.app else { return };
+        let settled = crate::storage::take_settled();
+        if !settled.is_empty() {
+            vectorcraft_ui_egui::io::saves_settled(app, settled);
+        }
         app.tick(ctx);
         crate::api::poll_replies();
         app.finish_ime_logic(ctx);
diff --git a/contributors/people.toml b/contributors/people.toml
--- a/contributors/people.toml
+++ b/contributors/people.toml
@@ -164,3 +164,7 @@ sync_github_name = false
 real_name = "Caio Pontes Magalhães"
 display_name = "Caio Pontes"
 sync_github_name = false
+
+[people.hayone1]
+real_name = "Boluwatife Morawo"
+sync_github_name = true
diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml
--- a/crates/ui-egui/Cargo.toml
+++ b/crates/ui-egui/Cargo.toml
@@ -21,6 +21,7 @@ interface-fonts = []
 
 [dependencies]
 craft-ui = { workspace = true }
+craft-web-host = { workspace = true }
 craft-keymap = { workspace = true, features = ["egui"] }
 vectorcraft-geom = { workspace = true }
 vectorcraft-color = { workspace = true }
diff --git a/crates/ui-egui/src/io.rs b/crates/ui-egui/src/io.rs
--- a/crates/ui-egui/src/io.rs
+++ b/crates/ui-egui/src/io.rs
@@ -343,14 +343,80 @@ pub fn new_from_template(app: &mut VectorcraftApp, path: Option<String>) -> Resu
     Ok(r)
 }
 
-/// Web: download `bytes` under `path`'s file name; desktop: write them to `path`.
+/// Web: download `bytes` under `path`'s file name, or hand a host path ([`is_host_path`]) as it is
+/// to the download service, which writes it back to the host; desktop: write them to `path`.
 pub(crate) fn write_to(services: &mut Services, path: &str, bytes: &[u8]) -> Result<(), String> {
     if let Some(dl) = services.download.as_mut() {
-        return dl(&fileio::file_name(path), bytes);
+        return dl(&if is_host_path(path) { path.to_string() } else { fileio::file_name(path) }, bytes);
     }
     services.write.as_mut().ok_or("no file writer")?(path, bytes)
 }
 
+/// Whether a file name is a path in a host's own storage, `<scheme>:<folder>/<path>` such as
+/// `cloud:home/art/logo.svg`, rather than a bare file name. On the web, the page that serves the app
+/// can offer such storage next to the device (`apps/vectorcraft-web/src/storage.rs`). The rule is
+/// shared by the Craft apps (`craft-web-host`).
+pub use craft_web_host::is_host_path;
+
+/// Open a file that arrived on the web (picked or dropped).
+pub(crate) fn open_arrived(app: &mut VectorcraftApp, name: &str, bytes: &[u8]) -> Result<Value, String> {
+    let (file, path) = arrival(name);
+    open_bytes(app, &file, bytes, path)
+}
+
+/// The file name and path to open an arrived file under. A document from a host's storage arrives
+/// under its host path ([`is_host_path`]) and keeps it, so File › Save writes back there. Scripts,
+/// presets and libraries are read from their bytes: nothing on the web can read a host path.
+fn arrival(name: &str) -> (String, Option<String>) {
+    if !is_host_path(name) {
+        return (name.to_string(), None);
+    }
+    let file = fileio::file_name(name);
+    let path = opens_as(&file).is_none().then(|| name.to_string());
+    (file, path)
+}
+
+thread_local! {
+    /// The document whose save is handing its bytes to the writer now ([`saving_document`]).
+    static SAVING: std::cell::Cell<Option<u64>> = const { std::cell::Cell::new(None) };
+}
+
+/// Web: the document (uid) whose File › Save or Save As is handing its bytes to `download` right
+/// now; `None` for exports and copies. A download service that finishes the save later (a host's
+/// storage) reports it back for that document through [`save_settled`].
+pub fn saving_document() -> Option<u64> {
+    SAVING.with(std::cell::Cell::get)
+}
+
+/// A save a host's storage made after `download` returned has finished: `doc` is the document
+/// whose save it was ([`saving_document`]; `None` for an export), `name` what it was saved as.
+/// One that failed or that the user cancelled leaves that document modified, so closing it asks
+/// first. One the user sent to `host_path` in the host's Save dialog (Save As) makes that path the
+/// document's file, so File › Save writes back there. A document that waited for the save to
+/// close closes now. [`saves_settled`] takes several at once.
+pub fn save_settled(app: &mut VectorcraftApp, doc: Option<u64>, name: &str, saved: bool, host_path: Option<&str>) {
+    saves_settled(app, [(doc, name.to_string(), saved, host_path.map(str::to_string))]);
+}
+
+/// [`save_settled`] for the saves that finished since the last frame: (document, name, saved, host
+/// path). Every one is applied before any waiting close runs, so a save that failed later in the
+/// batch still keeps its document open.
+pub fn saves_settled(app: &mut VectorcraftApp, saves: impl IntoIterator<Item = (Option<u64>, String, bool, Option<String>)>) {
+    for (doc, name, saved, host_path) in saves {
+        if let Some(st) = doc.and_then(|uid| app.session.document_mut(uid)) {
+            if !saved {
+                st.mark_unsaved();
+            } else if let Some(to) = host_path {
+                st.path = Some(to);
+            }
+        }
+        if !saved {
+            app.status(format!("Not saved: {}", fileio::file_name(&name)));
+        }
+    }
+    crate::unsaved::save_settled(app);
+}
+
 /// The web saves by downloading: no save panel, no folders.
 pub(crate) fn is_web(app: &VectorcraftApp) -> bool {
     app.services.download.is_some()
@@ -741,7 +807,11 @@ fn write_plan(app: &mut VectorcraftApp, plan: SavePlan) -> Result<Value, String>
         report_saved(app, &path, &r);
         Ok(r)
     };
-    let mut r = background::run(app, enabled, label, Some(uid), work, then)?;
+    // A document's own save (not a copy) names it to a writer that finishes later (saving_document).
+    SAVING.with(|s| s.set(retargets.then_some(uid)));
+    let r = background::run(app, enabled, label, Some(uid), work, then);
+    SAVING.with(|s| s.set(None));
+    let mut r = r?;
     if let Some(path) = target.filter(|_| r.get("path").is_none()) {
         r["path"] = json!(path);
     }
@@ -1196,6 +1266,117 @@ mod tests {
         assert!(app.ui.told.is_empty());
     }
 
+    /// On the web, a document saved to a host's storage (a cloud: path) and one opened from it keep
+    /// that path: File › Save hands it back to the download service (the host's writer) as it is.
+    #[test]
+    fn a_file_from_a_hosts_storage_saves_back_to_its_path() {
+        let written = Written::default();
+        let w = written.clone();
+        let download: crate::DownloadFn = Box::new(move |name: &str, b: &[u8]| {
+            w.borrow_mut().push((name.to_string(), b.to_vec()));
+            Ok(())
+        });
+        let mut app = VectorcraftApp::new(Session::new(), Services { download: Some(download), ..Default::default() });
+        app.run("file.new", json!({"width": 100, "height": 80})).unwrap();
+        app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 30, "height": 20})).unwrap();
+        save(&mut app, SaveMode::Save, &json!({"path": "cloud:home/art/Poster.vectorcraft"}), false).unwrap();
+        let (name, bytes) = written.borrow().last().cloned().unwrap();
+        assert_eq!(name, "cloud:home/art/Poster.vectorcraft");
+        // Opened again from the host: named after the file, saved back to the host path.
+        open_arrived(&mut app, "cloud:home/art/Poster.vectorcraft", &bytes).unwrap();
+        let st = app.session.doc().unwrap();
+        assert_eq!(st.path.as_deref(), Some("cloud:home/art/Poster.vectorcraft"));
+        app.run("shape.rectangle", json!({"x": 50, "y": 10, "width": 10, "height": 10})).unwrap();
+        save(&mut app, SaveMode::Save, &json!({}), false).unwrap();
+        assert_eq!(written.borrow().last().map(|(n, _)| n.as_str()), Some("cloud:home/art/Poster.vectorcraft"));
+        // A bare name still downloads under its file name.
+        save(&mut app, SaveMode::Save, &json!({"path": "/tmp/Other.vectorcraft"}), false).unwrap();
+        assert_eq!(written.borrow().last().map(|(n, _)| n.as_str()), Some("Other.vectorcraft"));
+    }
+
+    /// What a download got: (name, the document whose save it was).
+    type Saves = std::rc::Rc<std::cell::RefCell<Vec<(String, Option<u64>)>>>;
+
+    /// An app on the web whose download records which document each save was for.
+    fn web_app() -> (VectorcraftApp, Saves) {
+        let saves: Saves = Default::default();
+        let s = saves.clone();
+        let download: crate::DownloadFn = Box::new(move |name: &str, _: &[u8]| {
+            s.borrow_mut().push((name.to_string(), saving_document()));
+            Ok(())
+        });
+        (VectorcraftApp::new(Session::new(), Services { download: Some(download), ..Default::default() }), saves)
+    }
+
+    /// A save the host's storage turns down after it returned leaves that document modified, and
+    /// only that one, even when another has a file of the same name.
+    #[test]
+    fn a_host_save_that_fails_later_leaves_the_document_modified() {
+        let (mut app, saves) = web_app();
+        for folder in ["a", "b"] {
+            app.run("file.new", json!({"width": 100, "height": 80})).unwrap();
+            app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 30, "height": 20})).unwrap();
+            save(&mut app, SaveMode::Save, &json!({"path": format!("cloud:home/{folder}/Poster.vectorcraft")}), false).unwrap();
+        }
+        let uids: Vec<u64> = app.session.documents().iter().map(|d| d.uid).collect();
+        assert_eq!(saves.borrow().iter().map(|s| s.1).collect::<Vec<_>>(), uids.iter().map(|u| Some(*u)).collect::<Vec<_>>());
+        assert!(app.session.documents().iter().all(|d| !d.is_dirty()));
+        save_settled(&mut app, Some(uids[1]), "cloud:home/b/Poster.vectorcraft", false, None);
+        assert!(!app.session.documents()[0].is_dirty());
+        assert!(app.session.documents()[1].is_dirty());
+        // An export names no document: its failure leaves the documents as they are.
+        let before = saves.borrow().len();
+        app.run("file.export.png", json!({"path": "Poster.png"})).ok();
+        assert!(saves.borrow().len() > before, "the export went to the download");
+        assert!(saves.borrow()[before..].iter().all(|s| s.1.is_none()));
+        save_settled(&mut app, None, "Poster.png", false, None);
+        assert!(!app.session.documents()[0].is_dirty());
+    }
+
+    /// A Save As the user sent to the host's storage makes the host path the document's file once
+    /// the host has it; a failed one doesn't, and other documents of the same name keep theirs.
+    #[test]
+    fn a_host_save_as_makes_the_host_path_the_documents_file() {
+        let (mut app, _) = web_app();
+        for _ in 0..2 {
+            app.run("file.new", json!({"width": 100, "height": 80})).unwrap();
+            save(&mut app, SaveMode::SaveAs, &json!({"path": "Poster.vectorcraft"}), false).unwrap();
+        }
+        let uid = app.session.documents()[1].uid;
+        let path = |app: &VectorcraftApp, i: usize| app.session.documents()[i].path.clone();
+        let before = path(&app, 1);
+        assert!(before.as_deref().is_some_and(|p| !is_host_path(p)));
+        save_settled(&mut app, Some(uid), "Poster.vectorcraft", false, Some("cloud:home/art/Poster.vectorcraft"));
+        assert_eq!(path(&app, 1), before);
+        save_settled(&mut app, Some(uid), "Poster.vectorcraft", true, Some("cloud:home/art/Poster.vectorcraft"));
+        assert_eq!(path(&app, 1).as_deref(), Some("cloud:home/art/Poster.vectorcraft"));
+        assert_eq!(path(&app, 0), before, "the other Poster keeps its file");
+        // An export to the host names no document: it stays a copy, and the document keeps its file.
+        save_settled(&mut app, None, "Poster.png", true, Some("cloud:home/art/Poster.png"));
+        assert_eq!(path(&app, 1).as_deref(), Some("cloud:home/art/Poster.vectorcraft"));
+    }
+
+    /// Only documents keep a host path; a script or library from the host is read from its bytes.
+    #[test]
+    fn only_documents_keep_a_host_path() {
+        assert_eq!(arrival("cloud:home/art/logo.svg"), (String::from("logo.svg"), Some(String::from("cloud:home/art/logo.svg"))));
+        assert_eq!(arrival("logo.svg"), (String::from("logo.svg"), None));
+        let ext = vectorcraft_engine::cmd::scripts::EXTS[0];
+        assert_eq!(arrival(&format!("cloud:home/run.{ext}")), (format!("run.{ext}"), None));
+        let ext = vectorcraft_engine::cmd::swatchlib::LIBRARY_EXTS[0];
+        assert_eq!(arrival(&format!("cloud:home/colors.{ext}")), (format!("colors.{ext}"), None));
+    }
+
+    #[test]
+    fn host_paths_have_a_scheme_and_bare_names_and_drives_do_not() {
+        for path in ["cloud:home/logo.svg", "team-drive:a/b.png", "s3+x:bucket/k"] {
+            assert!(is_host_path(path), "{path}");
+        }
+        for name in ["logo.svg", "C:\\art\\logo.svg", "c:/art/logo.svg", ":nothing", "1x:y/z", "a b:c/d", "logo:1.svg"] {
+            assert!(!is_host_path(name), "{name}");
+        }
+    }
+
     /// #1693: a browser download that can't be started is a failed save: the document stays
     /// modified and without a file, and Save Changes doesn't close it.
     #[test]
diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs
--- a/crates/ui-egui/src/lib.rs
+++ b/crates/ui-egui/src/lib.rs
@@ -270,6 +270,10 @@ pub struct Services {
     pub inbox: Option<Inbox>,
     /// Web: trigger a browser download instead of writing a path.
     pub download: Option<DownloadFn>,
+    /// Web: whether a save handed to `download` is still going (a host's storage, which reports
+    /// back later through [`io::save_settled`]). Closing a document after Save waits for it.
+    /// Without it, `download` is done once it returns.
+    pub save_in_flight: Option<Box<dyn Fn() -> bool>>,
     /// Web: start an async open (bytes arrive via `inbox`).
     pub open_async: Option<Box<dyn FnMut()>>,
     /// Read the system clipboard's text (desktop). Without it, pasted text only arrives with
@@ -518,6 +522,10 @@ pub struct VectorcraftApp {
     clipboard_publishing: sysclip::Publishing,
     /// A URL to open through egui next frame (when the host has no `open_url` service).
     pending_url: Option<String>,
+    /// Closes waiting for the host's storage to finish its saves, in the order asked: the
+    /// document (uid) to close, or `None` for Close All or Quit, and what continues after it
+    /// (`close`, `closeAll` or `quit`). See [`unsaved::save_settled`].
+    pub(crate) close_when_saved: Vec<(Option<u64>, String)>,
     /// Windows and Linux: the window has no OS decorations, so the app bar is the title bar (drag,
     /// double-click to maximize, caption buttons) and invisible edge zones resize the window.
     pub custom_titlebar: bool,
@@ -797,6 +805,7 @@ impl VectorcraftApp {
             clipboard_in: None,
             clipboard_publishing: Default::default(),
             pending_url: None,
+            close_when_saved: Vec::new(),
             control_rx: None,
             pending_screenshots: vec![],
             queued_screenshots: vec![],
@@ -1594,7 +1603,7 @@ impl VectorcraftApp {
         let arrived: Vec<(String, Vec<u8>)> =
             self.services.inbox.as_ref().map(|q| std::mem::take(&mut *q.lock().unwrap_or_else(|e| e.into_inner()))).unwrap_or_default();
         for (name, bytes) in arrived {
-            if let Err(e) = io::open_bytes(self, &name, &bytes, None) {
+            if let Err(e) = io::open_arrived(self, &name, &bytes) {
                 io::report_open_error(self, &name, &e);
             }
         }
diff --git a/crates/ui-egui/src/unsaved.rs b/crates/ui-egui/src/unsaved.rs
--- a/crates/ui-egui/src/unsaved.rs
+++ b/crates/ui-egui/src/unsaved.rs
@@ -29,7 +29,27 @@ pub fn close(app: &mut VectorcraftApp, i: usize) -> Result<Value, String> {
     // A save still running decides whether there is anything left to save.
     crate::background::wait_all(app);
     let dirty = app.session.documents().get(i).ok_or("no such document")?.is_dirty();
-    if dirty { ask(app, i, "close") } else { close_now(app, i) }
+    if dirty {
+        return ask(app, i, "close");
+    }
+    // A host's storage may still be saving it: it closes once the host answers.
+    if saving_to_host(app) {
+        let uid = app.session.documents()[i].uid;
+        return Ok(close_when_saved(app, Some(uid), "close"));
+    }
+    close_now(app, i)
+}
+
+/// A save a host's storage hasn't answered yet: closing waits for it, so a save that fails
+/// still finds its document open ([`save_settled`]).
+fn saving_to_host(app: &VectorcraftApp) -> bool {
+    app.services.save_in_flight.as_ref().is_some_and(|f| f())
+}
+
+/// Close document `uid` (or, with `None`, carry on Close All or Quit) once the host's saves are done.
+fn close_when_saved(app: &mut VectorcraftApp, uid: Option<u64>, then: &str) -> Value {
+    app.close_when_saved.push((uid, then.to_string()));
+    json!({ "pending": "save" })
 }
 
 /// File → Close All (`then` = `closeAll`) or Quit (`quit`): ask about the next modified document,
@@ -40,6 +60,9 @@ pub fn close_all(app: &mut VectorcraftApp, then: &str) -> Result<Value, String>
     if let Some(i) = app.session.documents().iter().position(|d| d.is_dirty()) {
         return ask(app, i, then);
     }
+    if saving_to_host(app) {
+        return Ok(close_when_saved(app, None, then));
+    }
     if then == "quit" {
         // Nothing unsaved is left: no recovery copies either.
         vectorcraft_engine::cmd::recovery::forget_all(&mut app.session);
@@ -111,14 +134,46 @@ pub fn confirm(app: &mut VectorcraftApp) -> Result<Value, String> {
                 return Err(format!("{}: not saved, so not closed", d.title()));
             }
         }
+        // A host's storage finishes the save later: the document stays open until it does.
+        if saving_to_host(app) {
+            let uid = app.session.documents()[i].uid;
+            return Ok(close_when_saved(app, Some(uid), &d.str("then")));
+        }
     }
     close_now(app, i)?;
-    match d.str("then").as_str() {
+    carry_on(app, &d.str("then"))
+}
+
+/// After a Save Changes prompt closed its document: stop there, or ask about the next one.
+fn carry_on(app: &mut VectorcraftApp, then: &str) -> Result<Value, String> {
+    match then {
         "close" => Ok(Value::Null),
         then => close_all(app, then),
     }
 }
 
+/// The saves a host's storage was still making have all reported back ([`io::save_settled`]):
+/// run the closes that waited for them, in order. A document whose save failed or was cancelled
+/// is modified again, so it stays open (Close All and Quit ask about it), and so does one changed
+/// since.
+pub(crate) fn save_settled(app: &mut VectorcraftApp) {
+    if saving_to_host(app) {
+        return;
+    }
+    for (uid, then) in std::mem::take(&mut app.close_when_saved) {
+        let r = match uid {
+            Some(uid) => match app.session.documents().iter().position(|d| d.uid == uid && !d.is_dirty()) {
+                Some(i) => close_now(app, i).and_then(|_| carry_on(app, &then)),
+                None => continue,
+            },
+            None => close_all(app, &then),
+        };
+        if let Err(e) = r {
+            app.status(e);
+        }
+    }
+}
+
 #[cfg(test)]
 mod tests {
     use super::*;
@@ -201,6 +256,79 @@ mod tests {
         assert!(app.ui.dialog.is_none());
     }
 
+    /// Save Changes → Save through a host's storage closes the document only once the host has it.
+    #[test]
+    fn a_save_the_host_finishes_later_closes_the_document_only_once_done() {
+        let busy = std::rc::Rc::new(std::cell::Cell::new(false));
+        let b = busy.clone();
+        let mut app = VectorcraftApp::new(
+            Session::new(),
+            crate::Services {
+                download: Some(Box::new(|_: &str, _: &[u8]| Ok(()))),
+                save_in_flight: Some(Box::new(move || b.get())),
+                ..Default::default()
+            },
+        );
+        let path = "cloud:home/art/Poster.vectorcraft";
+        new_doc(&mut app, false);
+        new_doc(&mut app, true);
+        app.run("file.save", json!({ "path": path })).unwrap();
+        app.run("shape.rectangle", json!({ "x": 20, "y": 20, "width": 5, "height": 5 })).unwrap();
+        let uid = app.session.documents()[1].uid;
+        // The host fails: the document stays open and modified.
+        close(&mut app, 1).unwrap();
+        busy.set(true);
+        assert_eq!(confirm(&mut app).unwrap(), json!({ "pending": "save" }));
+        assert_eq!(docs(&app), 2);
+        busy.set(false);
+        io::save_settled(&mut app, Some(uid), path, false, None);
+        assert_eq!(docs(&app), 2);
+        assert!(app.session.documents()[1].is_dirty());
+        // The host saves it: then it closes.
+        close(&mut app, 1).unwrap();
+        busy.set(true);
+        confirm(&mut app).unwrap();
+        assert_eq!(docs(&app), 2);
+        busy.set(false);
+        io::save_settled(&mut app, Some(uid), path, true, None);
+        assert_eq!(docs(&app), 1);
+        // A plain Close or Close All while the host saves waits for it too.
+        new_doc(&mut app, false);
+        busy.set(true);
+        assert_eq!(close(&mut app, 1).unwrap(), json!({ "pending": "save" }));
+        assert_eq!(app.run("file.closeAll", json!({})).unwrap(), json!({ "pending": "save" }));
+        assert_eq!(docs(&app), 2);
+        busy.set(false);
+        io::save_settled(&mut app, None, "Other.png", true, None);
+        assert_eq!(docs(&app), 0, "both closes ran once the host was done");
+    }
+
+    /// Two saves of one document finish in the same frame, the first saved and the second not:
+    /// the document stays open and modified, though the first alone would have let it close.
+    #[test]
+    fn a_failed_save_later_in_the_batch_keeps_the_document_open() {
+        let busy = std::rc::Rc::new(std::cell::Cell::new(false));
+        let b = busy.clone();
+        let mut app = VectorcraftApp::new(
+            Session::new(),
+            crate::Services {
+                download: Some(Box::new(|_: &str, _: &[u8]| Ok(()))),
+                save_in_flight: Some(Box::new(move || b.get())),
+                ..Default::default()
+            },
+        );
+        let path = "cloud:home/art/Poster.vectorcraft";
+        new_doc(&mut app, true);
+        app.run("file.save", json!({ "path": path })).unwrap();
+        let uid = app.session.documents()[0].uid;
+        busy.set(true);
+        assert_eq!(close(&mut app, 0).unwrap(), json!({ "pending": "save" }));
+        busy.set(false);
+        io::saves_settled(&mut app, [(Some(uid), path.to_string(), true, None), (Some(uid), path.to_string(), false, None)]);
+        assert_eq!(docs(&app), 1);
+        assert!(app.session.documents()[0].is_dirty());
+    }
+
     #[test]
     fn close_all_and_quit_ask_once_per_modified_document() {
         let (mut app, written) = app();
diff --git a/docs/development.md b/docs/development.md
--- a/docs/development.md
+++ b/docs/development.md
@@ -34,6 +34,7 @@ How the web shell (`apps/vectorcraft-web/src/web.rs`) differs from desktop:
 
 - **Open** sets `Services::open_async`, which shows `rfd::AsyncFileDialog`. The bytes arrive in `Services::inbox`, which the app drains every frame.
 - **Save / Save As / Export** go through `Services::download`: a Blob, an object URL and a temporary `<a download>`, all created from Rust. There is no save dialog, so the suggested name becomes the download name.
+- **Host storage:** a page that serves VectorCraft can offer its own file storage (a cloud drive, a team folder) by defining `window.craftStorage` with `chooseOpen`, `chooseSave` and `write` (`apps/vectorcraft-web/src/storage.rs` describes them). File › Open then asks the host first and other downloads ask where to go, with the device's picker and download one choice away. A file opened from the host keeps its path there (`cloud:home/logo.svg`, see `io::is_host_path`), so File › Save writes back to it.
 - **Drag-and-drop:** `WebShell` takes the frame's `dropped_files` before the app sees them, reads each with `DroppedFile::bytes_async` and pushes the bytes into the inbox (files to be placed on a canvas go to `Services::place_inbox` with their `place::DropAt`: the document they were dropped on, by uid, and the point; `VectorcraftApp::drop_target` decides from the position the page's drag events give). Desktop drags carry no position (winit 0.30 reports none, and the pointer position egui last had is stale), so the desktop app decides by kind: documents open, pictures and text are placed in the middle of the view. On the web and the desktop, swatch and graphic style libraries, Libraries panel files, presets, plug-ins and shortcut sets open wherever they are dropped, as File › Open opens them (`io::opens_as`). A file that arrives after another document became active still lands in its own document, which becomes active again; if that document closed meanwhile, the file is not placed and the status bar says so. (The app's synchronous drop path is compiled out on wasm32.)
 - **Pen and touch input** (`apps/vectorcraft-web/src/pencil.rs`): the shell reads pen and finger Pointer Events itself, in the capture phase on the window, before eframe's touch and pointer handlers (which can't tell a pen from a finger and see each touch twice, as a pointer and as a touch event). Mouse and trackpad events stay eframe's, as do finger events until the first pen contact, preserving eframe's touch-only keyboard path. Pen buttons and event modifiers follow eframe's mappings. A pen drives egui's pointer and is a touch contact whose force is the pen's pressure; its coalesced events (an Apple Pencil reports up to 240 a second) reach the tool one by one with their pressure (`canvas::frame_moves`); holding it still opens the context menu (egui's long touch) and drops the tool's press. Fingers are touch contacts (pinch, two-finger pan, the undo and redo taps); the first one also drives the pointer, so it taps panels and menus. Once a pen has been seen in this page session (reset by reloading; no persistent storage), a finger never presses a tool on the canvas (`touch::finger_navigates_only`), and a pen landing cancels the fingers already down (palm rejection). The page itself doesn't scroll, zoom or select under touch (`touch-action: none`, `overscroll-behavior`), and the canvas stays inside the safe areas.
 - **Shortcuts the browser also has** (`apps/vectorcraft-web/src/keys.rs`): many of the app's Cmd and Ctrl chords are the browser's too (`Cmd+=`, `Cmd+-` and `Cmd+0` zoom the page, `Cmd+Z` undoes in the browser, in Safari Undo Close Tab, `Cmd+[` goes back, `Cmd+F` finds), and eframe lets most of them through. A capture listener on the window cancels the browser's action for a press the keymap in effect binds, the user's own shortcuts included (`shortcuts::runs_a_shortcut`, matched as the dispatcher matches it); egui still gets the key and runs the command. It acts only while the canvas or eframe's text input has the keyboard, and never on a plain or Shift press, an AltGr press (Windows reports it as Ctrl and Alt) or an IME's keys, which type; Cut, Copy and Paste stay the browser's, whose clipboard events carry the text, and so does Reload. Browsers keep a few chords from every page, which never reach it (Safari's `Cmd+L`, so New Layer's shortcut does nothing there; Chrome's `Cmd+T`, `Cmd+W`, `Cmd+N`): those still go to the browser. While the app has the keyboard, the page-zoom keys zoom the canvas instead; Preferences › User Interface › UI Scaling and the browser's View menu still scale the interface.
