diff --git a/.github/workflows/web-host.yml b/.github/workflows/web-host.yml
new file mode 100644
--- /dev/null
+++ b/.github/workflows/web-host.yml
@@ -0,0 +1,56 @@
+name: Web host
+
+on:
+  workflow_dispatch:
+  push:
+    branches: [main]
+    paths: ['crates/web-host/**', 'Cargo.toml', 'Cargo.lock', '.github/workflows/web-host.yml']
+  pull_request:
+    paths: ['crates/web-host/**', 'Cargo.toml', 'Cargo.lock', '.github/workflows/web-host.yml']
+
+permissions:
+  contents: read
+
+jobs:
+  native:
+    timeout-minutes: 15
+    name: Web host
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v7
+        with:
+          persist-credentials: false
+      - uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable
+        with:
+          components: rustfmt, clippy
+      - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
+        with:
+          key: craft-web-host
+      - run: cargo fmt --check
+      - run: cargo clippy -p craft-web-host --all-targets --locked -- -D warnings
+      - run: cargo test -p craft-web-host --locked
+  wasm:
+    timeout-minutes: 20
+    name: Web host WASM
+    runs-on: ubuntu-latest
+    env:
+      # The tests run in Node: the module reads `globalThis.craftStorage`, which is `window` in a page.
+      CARGO_TARGET_WASM32_UNKNOWN_UNKNOWN_RUNNER: wasm-bindgen-test-runner
+    steps:
+      - uses: actions/checkout@v7
+        with:
+          persist-credentials: false
+      - uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable
+        with:
+          targets: wasm32-unknown-unknown
+          components: clippy
+      - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
+        with:
+          key: craft-web-host-wasm
+      - name: Install the matching WASM test runner
+        shell: bash
+        run: |
+          runner_version=$(python3 -c 'import tomllib; d=tomllib.load(open("Cargo.lock", "rb")); print(next(p["version"] for p in d["package"] if p["name"] == "wasm-bindgen"))')
+          cargo install wasm-bindgen-cli --version "$runner_version" --locked
+      - run: cargo clippy -p craft-web-host --all-targets --locked --target wasm32-unknown-unknown -- -D warnings
+      - run: cargo test -p craft-web-host --locked --target wasm32-unknown-unknown
diff --git a/Cargo.lock b/Cargo.lock
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1066,6 +1066,16 @@ dependencies = [
  "zip",
 ]
 
+[[package]]
+name = "craft-web-host"
+version = "0.1.0"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "wasm-bindgen-test",
+]
+
 [[package]]
 name = "craft-window"
 version = "0.1.0"
diff --git a/Cargo.toml b/Cargo.toml
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -43,6 +43,7 @@ craft-text = { path = "crates/text" }
 craft-ui = { path = "crates/ui" }
 craft-update = { path = "crates/update" }
 craft-window = { path = "crates/window" }
+craft-web-host = { path = "crates/web-host" }
 egui = { version = "0.37", default-features = false }
 rayon = "1"
 serde = { version = "1", features = ["derive"] }
diff --git a/README.md b/README.md
--- a/README.md
+++ b/README.md
@@ -26,6 +26,7 @@ craft-<name> = { git = "https://github.com/storytold/craft-libs", rev = "<commit
 ## Libraries
 
 - [`craft-platform`](crates/platform/README.md): optional native dialog lifecycle, Linux/BSD desktop appearance observation and browser download ownership; application policy stays in the apps.
+- [`craft-web-host`](crates/web-host/README.md): what a web page that hosts a Craft app offers it, the same for every app: the host's own file storage (`window.craftStorage`), host paths, and a queue that saves to the host one at a time. What a save means for a document stays in the app.
 
 - [`craft-ai-core`](crates/ai-core/README.md): the data model and contract for AI in the Craft Apps (connections, models, tools, requests, jobs, consent, history, route spec). Pure, no I/O, wasm-safe. Contract in [docs/ai-shared-layer.md](docs/ai-shared-layer.md).
 - [`craft-pdf`](crates/pdf/README.md): independently optional PDF parsing, interpretation, rasterization and guarded authoring. Apps retain layout, fonts, editing and export policy; writer-only consumers do not enable the shared reader.
diff --git a/crates/web-host/Cargo.toml b/crates/web-host/Cargo.toml
new file mode 100644
--- /dev/null
+++ b/crates/web-host/Cargo.toml
@@ -0,0 +1,22 @@
+[package]
+name = "craft-web-host"
+description = "What a web page that hosts a Craft app offers it: the host's own file storage (`window.craftStorage`)."
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+rust-version.workspace = true
+repository.workspace = true
+
+[dependencies]
+
+# Already in the apps' Cargo.lock through eframe's web runner.
+[target.'cfg(target_arch = "wasm32")'.dependencies]
+wasm-bindgen = "0.2"
+wasm-bindgen-futures = "0.4"
+js-sys = "0.3"
+
+[target.'cfg(target_arch = "wasm32")'.dev-dependencies]
+wasm-bindgen-test = "0.3"
+
+[lints]
+workspace = true
diff --git a/crates/web-host/README.md b/crates/web-host/README.md
new file mode 100644
--- /dev/null
+++ b/crates/web-host/README.md
@@ -0,0 +1,40 @@
+# craft-web-host
+
+What a web page that hosts a Craft app offers it, in one place for every app. Today that is the
+host's own file storage, `window.craftStorage`: a page that serves an app (a cloud drive, a team
+folder) can offer its storage, and the app's File › Open and Save offer it next to this device.
+
+```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 /* ["psd", …] or null */) { … },
+  // A save or export: a path in the host's storage, "device" for a download, or null.
+  async chooseSave(suggested /* a file name, or the document's host path */) { … },
+  // Writes a file the user saved to a host path. The save counts once it resolves; a
+  // rejection leaves the document unsaved.
+  async write(path, bytes /* Uint8Array */) { … },
+};
+```
+
+| Part | What it does |
+| --- | --- |
+| `is_host_path` (every target) | A host path has a scheme of its own, `cloud:home/art/photo.psd`. A one-letter scheme is a Windows drive. Documents opened from a host path keep it, so File › Save writes back there. |
+| `storage::available` | Whether the page offers `craftStorage`. Without it the app works as it does on its own. |
+| `storage::choose_open`, `storage::choose_save` | The host's dialogs as `OpenChoice` / `SaveChoice`. Only `"device"` means the device; a host that rejects or answers something else is an error, which the app treats as nothing chosen. |
+| `storage::write` | Writes to a host path; `Ok` once the host has the file. |
+| `storage::SaveQueue` | Saves one at a time, in order, so an earlier save of a file never lands after a later one. Each result comes back with the id `push` gave it. |
+
+Rules for a host:
+
+- Answer `"device"` right from the user's click in your own dialog: browsers open a file picker
+  only during a click.
+- Make every `write` settle. Saves run one at a time, so one that never settles holds the rest. A
+  host whose storage can hang should reject after its own timeout (only the host can tell a slow
+  upload from a stuck one).
+
+What a save means for a document (its path, its saved state, when it may close) stays in each
+app: the app builds each save's future, pushes it, and applies the results it takes back.
+
+The `storage` module is for `wasm32` only. Its tests run in Node (`wasm-bindgen-test-runner`) with
+a fake host, since the module reads `globalThis.craftStorage`, which is `window` in a page.
diff --git a/crates/web-host/src/lib.rs b/crates/web-host/src/lib.rs
new file mode 100644
--- /dev/null
+++ b/crates/web-host/src/lib.rs
@@ -0,0 +1,42 @@
+#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
+
+//! What a web page that hosts a Craft app offers it. Today that is the host's own file storage:
+//! a page that serves an app (a cloud drive, a team folder) can set `window.craftStorage`, and the
+//! app's File › Open and Save offer it next to this device. The contract is the same for every
+//! app, so each app's web shell calls this crate instead of its own copy.
+//!
+//! ```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 /* ["psd", …] or null */) { … },
+//!   // A save or export: a path in the host's storage, "device" for a download, or null.
+//!   async chooseSave(suggested /* a file name, or the document's host path */) { … },
+//!   // Writes a file the user saved to a host path. The save counts once it resolves; a
+//!   // rejection leaves the document unsaved.
+//!   async write(path, bytes /* Uint8Array */) { … },
+//! };
+//! ```
+//!
+//! Host paths have a scheme of their own, such as `cloud:home/art/photo.psd` ([`is_host_path`]):
+//! a document opened from one keeps it, so File › Save writes back there without asking.
+//!
+//! The rules every app follows (the [`storage`] module carries them out):
+//!
+//! - Only the answer `"device"` opens the browser's picker or download. A host that rejects, or
+//!   answers anything else, means nothing was chosen.
+//! - Browsers open a file picker only during a click, so a host should answer `"device"` right
+//!   from the user's click in its own dialog.
+//! - Saves reach the host one at a time, in the order the user made them ([`storage::SaveQueue`]),
+//!   so an earlier save never lands after a later one. So each `write` 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).
+//!
+//! What a save means for a document (its path, its saved state, closing it) stays in each app.
+
+mod path;
+
+pub use path::is_host_path;
+
+#[cfg(target_arch = "wasm32")]
+pub mod storage;
diff --git a/crates/web-host/src/path.rs b/crates/web-host/src/path.rs
new file mode 100644
--- /dev/null
+++ b/crates/web-host/src/path.rs
@@ -0,0 +1,37 @@
+//! Host paths: names in a host's own storage.
+
+/// 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. A one-letter scheme is a Windows
+/// drive, not a host.
+pub fn is_host_path(name: &str) -> bool {
+    name.split_once(':').is_some_and(|(scheme, rest)| {
+        rest.contains('/')
+            && scheme.len() > 1
+            && scheme.starts_with(|c: char| c.is_ascii_alphabetic())
+            && scheme.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '.' | '-'))
+    })
+}
+
+#[cfg(test)]
+mod tests {
+    use super::is_host_path;
+
+    #[test]
+    fn host_paths_have_a_scheme_and_a_folder() {
+        assert!(is_host_path("cloud:home/art/photo.psd"));
+        assert!(is_host_path("team-drive:shared/logo.svg"));
+        assert!(is_host_path("s3+eu.west:bucket/a.png"));
+    }
+
+    #[test]
+    fn bare_names_drives_and_odd_schemes_are_not_host_paths() {
+        assert!(!is_host_path("photo.psd"));
+        assert!(!is_host_path("C:/Users/me/photo.psd"));
+        assert!(!is_host_path("c:\\Users\\me\\photo.psd"));
+        assert!(!is_host_path("cloud:photo.psd"), "no folder");
+        assert!(!is_host_path("1cloud:home/photo.psd"), "a scheme starts with a letter");
+        assert!(!is_host_path("my cloud:home/photo.psd"));
+        assert!(!is_host_path(":home/photo.psd"));
+        assert!(!is_host_path(""));
+    }
+}
diff --git a/crates/web-host/src/storage.rs b/crates/web-host/src/storage.rs
new file mode 100644
--- /dev/null
+++ b/crates/web-host/src/storage.rs
@@ -0,0 +1,229 @@
+//! The host's file storage, `window.craftStorage` (see the crate docs for the contract).
+//!
+//! The calls answer in Rust terms: [`choose_open`] and [`choose_save`] turn the host's answer
+//! into a choice, or an error for a host that failed or answered something else, which the app
+//! treats as nothing chosen. [`SaveQueue`] runs saves one at a time and reports each back.
+
+use std::cell::RefCell;
+use std::collections::VecDeque;
+use std::future::Future;
+use std::pin::Pin;
+use std::rc::Rc;
+
+use wasm_bindgen::{JsCast, JsValue};
+use wasm_bindgen_futures::JsFuture;
+
+use crate::is_host_path;
+
+/// The page's `craftStorage` object, when it offers one.
+fn host() -> Option<JsValue> {
+    let storage = js_sys::Reflect::get(&js_sys::global(), &"craftStorage".into()).ok()?;
+    storage.is_object().then_some(storage)
+}
+
+/// Whether the page offers its own storage. Without it the app works as it does on its own.
+pub fn available() -> bool {
+    host().is_some()
+}
+
+/// What a JavaScript failure says: an `Error`'s message, a thrown string, or a plain note.
+fn message(e: &JsValue) -> String {
+    if let Some(e) = e.dyn_ref::<js_sys::Error>() {
+        return e.message().into();
+    }
+    e.as_string().unwrap_or_else(|| "the host's storage failed".to_string())
+}
+
+/// Call `craftStorage.<method>(args…)` and wait for its answer.
+async fn call(method: &str, args: &[JsValue]) -> Result<JsValue, String> {
+    let storage = host().ok_or("the page has no craftStorage")?;
+    let f = js_sys::Reflect::get(&storage, &method.into()).map_err(|e| message(&e))?;
+    let f: js_sys::Function = f.dyn_into().map_err(|_| format!("craftStorage.{method} is not a function"))?;
+    let answer = f.apply(&storage, &args.iter().collect::<js_sys::Array>()).map_err(|e| message(&e))?;
+    // A host may answer at once instead of with a promise.
+    match answer.dyn_into::<js_sys::Promise>() {
+        Ok(promise) => JsFuture::from(promise).await.map_err(|e| message(&e)),
+        Err(answer) => Ok(answer),
+    }
+}
+
+/// What the user picked in the host's Open dialog.
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub enum OpenChoice {
+    /// A file in the host's storage: its host path and its bytes.
+    File { path: String, bytes: Vec<u8> },
+    /// The browser's own file picker (the host answered `"device"`).
+    Device,
+    /// Nothing (the host answered `null`).
+    Cancelled,
+}
+
+/// What the user picked in the host's Save dialog.
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub enum SaveChoice {
+    /// A path in the host's storage, to [`write`] to.
+    Host(String),
+    /// A browser download (the host answered `"device"`).
+    Device,
+    /// Nothing (the host answered `null`).
+    Cancelled,
+}
+
+fn is_device(answer: &JsValue) -> bool {
+    answer.as_string().as_deref() == Some("device")
+}
+
+fn unexpected(method: &str, answer: &JsValue) -> String {
+    format!("craftStorage.{method} answered something unexpected: {answer:?}")
+}
+
+/// Ask the host which file to open; `extensions` (without dots) narrows its list, `None` shows all.
+/// An error means the host failed or answered something else: nothing was chosen.
+pub async fn choose_open(extensions: Option<&[&str]>) -> Result<OpenChoice, String> {
+    let exts = extensions.map_or(JsValue::NULL, |e| e.iter().map(|x| JsValue::from(*x)).collect::<js_sys::Array>().into());
+    let answer = call("chooseOpen", &[exts]).await?;
+    if answer.is_null() {
+        return Ok(OpenChoice::Cancelled);
+    }
+    if is_device(&answer) {
+        return Ok(OpenChoice::Device);
+    }
+    let path = js_sys::Reflect::get(&answer, &"path".into()).ok().and_then(|p| p.as_string()).filter(|p| is_host_path(p));
+    let bytes = js_sys::Reflect::get(&answer, &"bytes".into())
+        .ok()
+        .filter(|b| b.is_instance_of::<js_sys::Uint8Array>() || b.is_instance_of::<js_sys::ArrayBuffer>())
+        .map(|b| js_sys::Uint8Array::new(&b).to_vec());
+    match (path, bytes) {
+        (Some(path), Some(bytes)) => Ok(OpenChoice::File { path, bytes }),
+        _ => Err(unexpected("chooseOpen", &answer)),
+    }
+}
+
+/// Ask the host where a save of `suggested` (a file name, or the document's host path) goes.
+/// An error means the host failed or answered something else: nothing was chosen.
+pub async fn choose_save(suggested: &str) -> Result<SaveChoice, String> {
+    let answer = call("chooseSave", &[suggested.into()]).await?;
+    if answer.is_null() {
+        return Ok(SaveChoice::Cancelled);
+    }
+    if is_device(&answer) {
+        return Ok(SaveChoice::Device);
+    }
+    match answer.as_string().filter(|p| is_host_path(p)) {
+        Some(path) => Ok(SaveChoice::Host(path)),
+        None => Err(unexpected("chooseSave", &answer)),
+    }
+}
+
+/// Write `bytes` to `path` in the host's storage. `Ok` once the host has the file.
+pub async fn write(path: &str, bytes: &[u8]) -> Result<(), String> {
+    call("write", &[path.into(), js_sys::Uint8Array::from(bytes).into()]).await.map(|_| ())
+}
+
+/// A save waiting in a [`SaveQueue`].
+type Save<R> = Pin<Box<dyn Future<Output = R>>>;
+
+struct Queue<R> {
+    waiting: VecDeque<(u64, Save<R>)>,
+    running: Option<u64>,
+    /// The worker that works through `waiting` is alive (it may be between two saves).
+    worker: bool,
+    last_id: u64,
+    done: Vec<(u64, R)>,
+    wake: Option<Rc<dyn Fn()>>,
+}
+
+/// Saves to the host, one at a time in the order they were pushed, so an earlier save of a file
+/// never lands after a later one. Each save is a future the app builds (asking the host where it
+/// goes, then [`write`]); its result comes back through [`Self::take_done`] with the id
+/// [`Self::push`] gave it. Cloning shares the queue.
+pub struct SaveQueue<R> {
+    queue: Rc<RefCell<Queue<R>>>,
+}
+
+impl<R> Clone for SaveQueue<R> {
+    fn clone(&self) -> Self {
+        Self { queue: self.queue.clone() }
+    }
+}
+
+impl<R: 'static> Default for SaveQueue<R> {
+    fn default() -> Self {
+        Self::new()
+    }
+}
+
+impl<R: 'static> SaveQueue<R> {
+    pub fn new() -> Self {
+        Self {
+            queue: Rc::new(RefCell::new(Queue { waiting: VecDeque::new(), running: None, worker: false, last_id: 0, done: Vec::new(), wake: None })),
+        }
+    }
+
+    /// Called after each save finishes, for example to ask the UI for a frame that reads
+    /// [`Self::take_done`].
+    pub fn on_done(&self, wake: impl Fn() + 'static) {
+        self.queue.borrow_mut().wake = Some(Rc::new(wake));
+    }
+
+    /// Queue a save; it starts once the saves before it have finished. Returns its id.
+    pub fn push(&self, save: impl Future<Output = R> + 'static) -> u64 {
+        let (id, start) = {
+            let mut q = self.queue.borrow_mut();
+            q.last_id += 1;
+            let id = q.last_id;
+            q.waiting.push_back((id, Box::pin(save)));
+            let start = !q.worker;
+            q.worker = true;
+            (id, start)
+        };
+        if start {
+            self.run();
+        }
+        id
+    }
+
+    /// The id of the latest save that hasn't finished, if any.
+    pub fn latest(&self) -> Option<u64> {
+        let q = self.queue.borrow();
+        q.waiting.back().map(|w| w.0).or(q.running)
+    }
+
+    /// Whether a save is waiting or running.
+    pub fn busy(&self) -> bool {
+        let q = self.queue.borrow();
+        q.running.is_some() || !q.waiting.is_empty()
+    }
+
+    /// The saves that finished since the last call, in order: (id, result).
+    pub fn take_done(&self) -> Vec<(u64, R)> {
+        std::mem::take(&mut self.queue.borrow_mut().done)
+    }
+
+    fn run(&self) {
+        let queue = self.queue.clone();
+        wasm_bindgen_futures::spawn_local(async move {
+            loop {
+                let next = {
+                    let mut q = queue.borrow_mut();
+                    let next = q.waiting.pop_front();
+                    q.running = next.as_ref().map(|n| n.0);
+                    q.worker = next.is_some();
+                    next
+                };
+                let Some((id, save)) = next else { break };
+                // No borrow is held while the save runs: it may push more saves.
+                let result = save.await;
+                let wake = {
+                    let mut q = queue.borrow_mut();
+                    q.running = None;
+                    q.done.push((id, result));
+                    q.wake.clone()
+                };
+                if let Some(wake) = wake {
+                    wake();
+                }
+            }
+        });
+    }
+}
diff --git a/crates/web-host/tests/storage.rs b/crates/web-host/tests/storage.rs
new file mode 100644
--- /dev/null
+++ b/crates/web-host/tests/storage.rs
@@ -0,0 +1,161 @@
+#![cfg(target_arch = "wasm32")]
+#![allow(clippy::unwrap_used)]
+
+use std::cell::Cell;
+use std::rc::Rc;
+
+use craft_web_host::storage::{self, OpenChoice, SaveChoice, SaveQueue};
+use wasm_bindgen::prelude::*;
+use wasm_bindgen_futures::JsFuture;
+use wasm_bindgen_test::wasm_bindgen_test;
+
+// A fake host: `open` and `save` name the answer its dialogs give ("reject" throws), `write`
+// takes `delay` ms and throws when `fails`. Every call goes to a log.
+#[wasm_bindgen(inline_js = r#"
+const answers = {
+  file: () => ({ path: "cloud:home/a.psd", bytes: new Uint8Array([1, 2, 3]) }),
+  buffer: () => ({ path: "cloud:home/b.psd", bytes: new Uint8Array([4]).buffer }),
+  bare: () => ({ path: "a.psd", bytes: new Uint8Array([1]) }),
+  nobytes: () => ({ path: "cloud:home/a.psd" }),
+  host: () => "cloud:home/out.psd",
+  barename: () => "out.psd",
+  device: () => "device",
+  Device: () => "Device",
+  null: () => null,
+  number: () => 7,
+};
+export function host(open, save, fails, delay) {
+  const log = [];
+  const answer = (name) => { if (name === "reject") throw new Error("the host is offline"); return answers[name](); };
+  globalThis.craftStorage = {
+    async chooseOpen(exts) { log.push(["chooseOpen", exts]); return answer(open); },
+    async chooseSave(name) { log.push(["chooseSave", name]); return answer(save); },
+    async write(path, bytes) {
+      log.push(["start", path, bytes.length]);
+      await new Promise((r) => setTimeout(r, delay));
+      log.push(["end", path]);
+      if (fails) throw new Error("disk full");
+    },
+  };
+  globalThis.hostLog = log;
+}
+export function sync_host() { globalThis.craftStorage = { chooseSave: () => "device" }; }
+export function no_host() { delete globalThis.craftStorage; }
+export function log() { return JSON.stringify(globalThis.hostLog); }
+export function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); }
+"#)]
+extern "C" {
+    fn host(open: &str, save: &str, fails: bool, delay: u32);
+    fn sync_host();
+    fn no_host();
+    fn log() -> String;
+    fn sleep(ms: u32) -> js_sys::Promise;
+}
+
+async fn wait(ms: u32) {
+    JsFuture::from(sleep(ms)).await.unwrap();
+}
+
+#[wasm_bindgen_test]
+fn without_a_host_nothing_is_offered() {
+    no_host();
+    assert!(!storage::available());
+}
+
+#[wasm_bindgen_test]
+async fn open_answers_become_choices() {
+    for (answer, want) in [
+        ("file", Ok(OpenChoice::File { path: "cloud:home/a.psd".into(), bytes: vec![1, 2, 3] })),
+        ("buffer", Ok(OpenChoice::File { path: "cloud:home/b.psd".into(), bytes: vec![4] })),
+        ("device", Ok(OpenChoice::Device)),
+        ("null", Ok(OpenChoice::Cancelled)),
+    ] {
+        host(answer, "null", false, 0);
+        assert!(storage::available());
+        assert_eq!(storage::choose_open(Some(&["psd", "png"])).await, want, "{answer}");
+    }
+    assert!(log().contains(r#"["chooseOpen",["psd","png"]]"#), "{}", log());
+}
+
+#[wasm_bindgen_test]
+async fn only_device_means_the_device_and_a_failed_host_chooses_nothing() {
+    for answer in ["reject", "bare", "nobytes", "Device", "number"] {
+        host(answer, "null", false, 0);
+        assert!(storage::choose_open(None).await.is_err(), "open {answer}");
+    }
+    for answer in ["reject", "barename", "Device", "number"] {
+        host("null", answer, false, 0);
+        assert!(storage::choose_save("x.psd").await.is_err(), "save {answer}");
+    }
+    host("null", "reject", false, 0);
+    assert_eq!(storage::choose_save("x.psd").await, Err("the host is offline".into()));
+    no_host();
+    assert!(storage::choose_save("x.psd").await.is_err());
+}
+
+#[wasm_bindgen_test]
+async fn save_answers_become_choices_and_writes_report_back() {
+    for (answer, want) in [("host", SaveChoice::Host("cloud:home/out.psd".into())), ("device", SaveChoice::Device), ("null", SaveChoice::Cancelled)] {
+        host("null", answer, false, 0);
+        assert_eq!(storage::choose_save("out.psd").await, Ok(want), "{answer}");
+    }
+    host("null", "null", false, 0);
+    assert_eq!(storage::write("cloud:home/out.psd", &[1, 2]).await, Ok(()));
+    host("null", "null", true, 0);
+    assert_eq!(storage::write("cloud:home/out.psd", &[1, 2]).await, Err("disk full".into()));
+    // A host may answer without a promise.
+    sync_host();
+    assert_eq!(storage::choose_save("out.psd").await, Ok(SaveChoice::Device));
+}
+
+#[wasm_bindgen_test]
+async fn the_queue_saves_one_at_a_time_in_order() {
+    host("null", "null", false, 20);
+    let queue: SaveQueue<Result<(), String>> = SaveQueue::new();
+    let woken = Rc::new(Cell::new(0));
+    let w = woken.clone();
+    queue.on_done(move || w.set(w.get() + 1));
+    assert_eq!((queue.busy(), queue.latest()), (false, None));
+    let ids: Vec<u64> =
+        ["a", "b", "c"].into_iter().map(|n| queue.push(async move { storage::write(&format!("cloud:home/{n}.psd"), &[0]).await })).collect();
+    assert!(queue.busy());
+    assert_eq!(queue.latest(), Some(ids[2]));
+    while queue.busy() {
+        wait(10).await;
+    }
+    let done = queue.take_done();
+    assert_eq!(done.iter().map(|d| d.0).collect::<Vec<_>>(), ids);
+    assert!(done.iter().all(|d| d.1.is_ok()));
+    assert_eq!(woken.get(), 3);
+    assert!(queue.take_done().is_empty());
+    // Each write ended before the next one started.
+    let order = r#"["start","cloud:home/a.psd",1],["end","cloud:home/a.psd"],["start","cloud:home/b.psd",1],["end","cloud:home/b.psd"],["start","cloud:home/c.psd",1],["end","cloud:home/c.psd"]"#;
+    assert!(log().contains(order), "{}", log());
+}
+
+#[wasm_bindgen_test]
+async fn a_save_pushed_when_one_finishes_still_waits_its_turn() {
+    host("null", "null", true, 10);
+    let queue: SaveQueue<Result<(), String>> = SaveQueue::new();
+    let q = queue.clone();
+    let pushed = Rc::new(Cell::new(false));
+    let p = pushed.clone();
+    queue.on_done(move || {
+        if !p.replace(true) {
+            q.push(async { storage::write("cloud:home/again.psd", &[0]).await });
+        }
+    });
+    queue.push(async { storage::write("cloud:home/first.psd", &[0]).await });
+    queue.push(async { storage::write("cloud:home/second.psd", &[0]).await });
+    while queue.busy() {
+        wait(10).await;
+    }
+    let done = queue.take_done();
+    assert_eq!(done.len(), 3);
+    assert!(done.iter().all(|d| d.1 == Err("disk full".to_string())));
+    // One write at a time, and the one pushed from the callback stays behind the save that was
+    // already waiting.
+    let log = log();
+    let order = r#"["start","cloud:home/first.psd",1],["end","cloud:home/first.psd"],["start","cloud:home/second.psd",1],["end","cloud:home/second.psd"],["start","cloud:home/again.psd",1],["end","cloud:home/again.psd"]"#;
+    assert!(log.contains(order), "{log}");
+}
