diff --git a/Cargo.lock b/Cargo.lock
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1215,6 +1215,16 @@ dependencies = [
  "zip 9.1.0",
 ]
 
+[[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"
@@ -3758,6 +3768,7 @@ dependencies = [
 name = "lightcraft-web"
 version = "0.14.0"
 dependencies = [
+ "craft-web-host",
  "crc32fast",
  "eframe",
  "egui",
diff --git a/Cargo.toml b/Cargo.toml
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -14,6 +14,8 @@ craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "1e6f127844e
 craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "1e6f127844ec61b3b8afdcc2bb76e7cfe470153f" }
 craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "1e6f127844ec61b3b8afdcc2bb76e7cfe470153f", features = ["egui"] }
 craft-window = { git = "https://github.com/storytold/craft-libs", rev = "1e6f127844ec61b3b8afdcc2bb76e7cfe470153f", features = ["egui"] }
+# 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" }
 # internal
 lightcraft-denoise-core = { path = "crates/denoise-core" }
 lightcraft-denoise = { path = "crates/denoise", default-features = false }
diff --git a/apps/lightcraft-web/Cargo.toml b/apps/lightcraft-web/Cargo.toml
--- a/apps/lightcraft-web/Cargo.toml
+++ b/apps/lightcraft-web/Cargo.toml
@@ -24,6 +24,7 @@ log = { workspace = true }
 crc32fast = { workspace = true }
 
 [target.'cfg(target_arch = "wasm32")'.dependencies]
+craft-web-host = { workspace = true }
 eframe = { workspace = true, features = ["default_fonts", "glow"] }
 egui = { workspace = true }
 wasm-bindgen = { workspace = true }
diff --git a/apps/lightcraft-web/src/lib.rs b/apps/lightcraft-web/src/lib.rs
--- a/apps/lightcraft-web/src/lib.rs
+++ b/apps/lightcraft-web/src/lib.rs
@@ -8,7 +8,9 @@
 //!   ([`store`]); a new library starts with the procedural demo photos;
 //! - renders run in Web Workers, each a second instance of this module ([`wire`], `workers`), and
 //!   fall back to the main thread (one job per frame) if workers can't start;
-//! - export downloads the file through the browser.
+//! - export downloads the file through the browser;
+//! - a page that serves the app can offer its own file storage for Import and Export
+//!   (`window.craftStorage`, [`storage`]).
 //!
 //! `?bench` in the URL runs a scripted first-paint / slider-latency measurement and logs it to the
 //! console (see [`bench`]); `?store=idb` forces the IndexedDB backend, `?store=memory` disables
@@ -28,6 +30,8 @@ mod backend;
 #[cfg(target_arch = "wasm32")]
 mod safety;
 #[cfg(target_arch = "wasm32")]
+mod storage;
+#[cfg(target_arch = "wasm32")]
 mod web;
 #[cfg(target_arch = "wasm32")]
 mod workers;
diff --git a/apps/lightcraft-web/src/storage.rs b/apps/lightcraft-web/src/storage.rs
new file mode 100644
--- /dev/null
+++ b/apps/lightcraft-web/src/storage.rs
@@ -0,0 +1,74 @@
+//! Host storage: a page that serves LightCraft can offer its own file storage (a cloud drive, a
+//! team folder) next to this device, through `window.craftStorage`. Import can then pick a photo
+//! there, and Export asks where each file goes. The contract and its rules are the same for every
+//! Craft app and live in `craft-web-host`; this module connects them to LightCraft's import picker
+//! and export download. Without `window.craftStorage` nothing changes.
+//!
+//! LightCraft keeps no document files: an imported photo is copied into the library, so a photo
+//! from the host is imported from its bytes, and an export is a one-off file.
+
+use craft_web_host::storage::{self, OpenChoice, SaveChoice, SaveQueue};
+
+use crate::backend::Backend;
+use crate::safety::notice;
+use crate::store::{Originals, accept_extensions, download_name};
+
+thread_local! {
+    /// Exports to the host, one at a time in the order they were made (`craft_web_host`).
+    static EXPORTS: SaveQueue<()> = SaveQueue::new();
+}
+
+/// Import: the host's Open dialog when the page offers one, else (or when the user picks this
+/// device there) the browser's own picker, `device`.
+pub fn pick(originals: Originals, backend: Option<Backend>, ctx: egui::Context, accept: String, device: impl FnOnce() + 'static) {
+    if !storage::available() {
+        return device();
+    }
+    wasm_bindgen_futures::spawn_local(async move {
+        let exts = accept_extensions(&accept);
+        let exts: Vec<&str> = exts.iter().map(String::as_str).collect();
+        match storage::choose_open(Some(&exts)).await {
+            Ok(OpenChoice::File { path, bytes }) => {
+                let name = download_name(&path).to_string();
+                crate::web::store_file(originals, backend, name, bytes, ctx).await;
+            }
+            // The user's own choice: the browser's picker.
+            Ok(OpenChoice::Device) => device(),
+            Ok(OpenChoice::Cancelled) => {}
+            // A host that failed or answered something else: nothing was chosen.
+            Err(e) => notice(format!("Couldn't import from the page's storage: {e}")),
+        }
+    });
+}
+
+/// Export: ask the host where the file goes (its storage, a download to `device`, or nowhere).
+/// The export counts as started at once; the host's answer, or its failure, comes later.
+pub fn export(path: &str, bytes: &[u8], device: fn(&str, &[u8]) -> Result<(), String>) -> Result<(), String> {
+    if !storage::available() {
+        return device(path, bytes);
+    }
+    let (name, bytes) = (download_name(path).to_string(), bytes.to_vec());
+    EXPORTS.with(|q| {
+        // Each export reports its own failure as a notice: nothing waits for the results.
+        q.take_done();
+        q.push(async move {
+            match storage::choose_save(&name).await {
+                Ok(SaveChoice::Host(to)) => {
+                    if let Err(e) = storage::write(&to, &bytes).await {
+                        notice(format!("{name} was not exported: {e}"));
+                    }
+                }
+                // The user's own choice: a download to the device.
+                Ok(SaveChoice::Device) => {
+                    if let Err(e) = device(&name, &bytes) {
+                        notice(format!("{name} was not exported: {e}"));
+                    }
+                }
+                Ok(SaveChoice::Cancelled) => {}
+                // A host that failed or answered something else: not exported.
+                Err(e) => notice(format!("{name} was not exported: {e}")),
+            }
+        })
+    });
+    Ok(())
+}
diff --git a/apps/lightcraft-web/src/store.rs b/apps/lightcraft-web/src/store.rs
--- a/apps/lightcraft-web/src/store.rs
+++ b/apps/lightcraft-web/src/store.rs
@@ -168,6 +168,12 @@ impl Originals {
 }
 
 /// File name part of an export path (the browser download name).
+/// A file picker's accept list (`.jpg,.png,…`) as extensions without dots, for a host's Open
+/// dialog (`craftStorage.chooseOpen`).
+pub fn accept_extensions(accept: &str) -> Vec<String> {
+    accept.split(',').map(|e| e.trim().trim_start_matches('.').to_string()).filter(|e| !e.is_empty()).collect()
+}
+
 pub fn download_name(path: &str) -> &str {
     path.rsplit(['/', '\\']).next().filter(|n| !n.is_empty()).unwrap_or("export.png")
 }
@@ -237,6 +243,12 @@ mod tests {
         assert_eq!(out.image.width, 20);
     }
 
+    #[test]
+    fn accept_lists_become_extensions() {
+        assert_eq!(accept_extensions(".jpg, .png,,.dng"), ["jpg", "png", "dng"]);
+        assert!(accept_extensions("").is_empty());
+    }
+
     #[test]
     fn download_names() {
         assert_eq!(download_name("dir/x-lightcraft.png"), "x-lightcraft.png");
diff --git a/apps/lightcraft-web/src/web.rs b/apps/lightcraft-web/src/web.rs
--- a/apps/lightcraft-web/src/web.rs
+++ b/apps/lightcraft-web/src/web.rs
@@ -100,7 +100,7 @@ impl Options {
 }
 
 /// Store a picked/dropped file (bytes into browser storage, then queue its import).
-async fn store_file(originals: Originals, backend: Option<Backend>, name: String, bytes: Vec<u8>, ctx: egui::Context) {
+pub(crate) async fn store_file(originals: Originals, backend: Option<Backend>, name: String, bytes: Vec<u8>, ctx: egui::Context) {
     let hash = content_hash(&bytes);
     if let Some(b) = &backend
         && let Err(e) = b.write(&storage_key(&hash), &bytes).await
@@ -214,7 +214,9 @@ fn services(originals: Originals, backend: Option<Backend>, files: Files, frozen
             Ok(json!({"started": true}))
         })),
         pick_files: Some(Box::new(move || {
-            open_picker(originals.clone(), backend.clone(), ctx.clone());
+            // A page that serves the app may offer its own file storage (`window.craftStorage`).
+            let (o, b, c) = (originals.clone(), backend.clone(), ctx.clone());
+            crate::storage::pick(originals.clone(), backend.clone(), ctx.clone(), accept_list(), move || open_picker(o, b, c));
             Vec::new() // files arrive asynchronously and are imported on a later frame
         })),
         pick_denoise_model: None,
@@ -233,7 +235,8 @@ fn services(originals: Originals, backend: Option<Backend>, files: Files, frozen
         save_slideshow_template: None,
         pick_print_templates: None,
         save_print_template: None,
-        write: Some(Box::new(download)),
+        // Exports go to the page's own storage when it offers one (`window.craftStorage`).
+        write: Some(Box::new(|path: &str, bytes: &[u8]| crate::storage::export(path, bytes, download))),
         // the browser download path has no meaningful create-new file write; the control channel
         // (and therefore screenshot paths) is not available on the web
         write_new: None,
diff --git a/docs/web.md b/docs/web.md
--- a/docs/web.md
+++ b/docs/web.md
@@ -156,6 +156,11 @@ are the same in every version, so serve them with `Cache-Control: no-cache` (not
 - **Export downloads the file.** *Export…* (<kbd>⌘⇧E</kbd>) runs the same `app.export` path as
   the desktop app (`lightcraft_engine::export`: JPEG/PNG/TIFF/WebP, sizing, naming). The host's
   `write` service hands each file to the browser as a download instead of writing it to disk.
+- **A page's own storage.** A page that serves LightCraft can set `window.craftStorage` (the
+  contract every Craft app shares, in craft-libs' `craft-web-host`). Then Import offers a photo
+  from that storage, and Export asks where each file goes: a path there, this device (a
+  download), or nowhere. Exports to the page's storage go one at a time; a failed one shows a
+  notice. Without `window.craftStorage` nothing changes (`apps/lightcraft-web/src/storage.rs`).
 - **Automation.** `await lightcraft.command("library.info", "{}")` runs any engine or UI command
   by id on the next frame and resolves to the JSON result (`web.stats` reports storage, workers
   and the render queue). The params are a JSON object; empty text and JSON `null` mean `{}`
