From 7612b220efa63b5b5a2ee6c22ad284182581571d Mon Sep 17 00:00:00 2001 From: bflatastic Date: Thu, 8 Oct 2026 11:14:02 +0700 Subject: [PATCH 1/3] Project panel: a panel too short for the scroll bar draws (#231) The Project panel's vertical scroll bar clamped its thumb between 16 points and the track's height. When the track was shorter than 16 points (an empty project in a 1280 x 800 browser window at device pixel ratio 2, or any short panel), that clamp panicked on the first frame: the browser canvas went blank and the app stopped responding. The thumb is now at most the track's length, and both scroll bars share the calculation. Test: a_short_project_panel_draws (ui_project_rows). Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/project.rs | 10 ++++++++-- crates/ui-egui/tests/ui_project_rows.rs | 12 ++++++++++++ 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/panels/project.rs b/crates/ui-egui/src/panels/project.rs index 8d84e71d..c14b8131 100644 --- a/crates/ui-egui/src/panels/project.rs +++ b/crates/ui-egui/src/panels/project.rs @@ -174,6 +174,12 @@ fn fit(r: Rect, w: f32, h: f32) -> Rect { Rect::from_center_size(r.center(), vec2(w * s, h * s)) } +/// A scroll bar thumb's length on a `track` long: the share of `content` in view, at least 16 +/// points but never longer than the track (a panel only a few points tall still draws, #231). +fn thumb_len(track: f32, visible: f32, content: f32) -> f32 { + (track * visible / content).max(16.0).min(track).max(0.0) +} + /// An inline text edit in progress: (item, field `name`|`comment`, text). type Editing = (u64, String, String); fn edit_id() -> egui::Id { @@ -340,7 +346,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } if overflow > 0.0 { let track = Rect::from_min_max(pos2(opt_x0, list.max.y - 5.0), pos2(rect.max.x - 4.0, list.max.y - 1.0)); - let tw = (track.width() * track.width() / (track.width() + overflow)).max(16.0); + let tw = thumb_len(track.width(), track.width(), track.width() + overflow); let thumb = Rect::from_min_size(pos2(track.min.x + (track.width() - tw) * (hscroll / overflow), track.min.y), vec2(tw, track.height())); let sresp = ui.interact(track.expand2(vec2(0.0, 2.0)), egui::Id::new("proj-hscroll"), Sense::drag()); p.rect_filled(track, 2.0, t.field_bg); @@ -396,7 +402,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let scroll = app.ui.project_scroll; if max_scroll > 0.0 { let track = Rect::from_min_max(pos2(rect.max.x - 6.0, list.min.y + 1.0), pos2(rect.max.x - 2.0, list.max.y - 7.0)); - let th = (track.height() * list.height() / content_h).clamp(16.0, track.height()); + let th = thumb_len(track.height(), list.height(), content_h); let thumb = Rect::from_min_size(pos2(track.min.x, track.min.y + (track.height() - th) * (scroll / max_scroll)), vec2(track.width(), th)); let vresp = ui.interact(track.expand2(vec2(2.0, 0.0)), egui::Id::new("proj-vscroll"), Sense::drag()); p.rect_filled(thumb, 2.0, if vresp.hovered() || vresp.dragged() { t.text_dim } else { t.text_faint }); diff --git a/crates/ui-egui/tests/ui_project_rows.rs b/crates/ui-egui/tests/ui_project_rows.rs index 074b41b3..1fc07668 100644 --- a/crates/ui-egui/tests/ui_project_rows.rs +++ b/crates/ui-egui/tests/ui_project_rows.rs @@ -119,3 +119,15 @@ fn dragging_in_the_empty_area_box_selects_items() { click_at(&mut h, from); assert!(h.state().session.state.project_selection.is_empty()); } + +/// A Project panel shorter than its scroll bar's thumb draws instead of panicking: an empty +/// project in a 1280 × 800 browser window at device pixel ratio 2 (640 × 400 points) left the +/// list too short for the thumb's 16-point minimum (#231). +#[test] +fn a_short_project_panel_draws() { + for height in [400.0, 300.0] { + let mut h = Harness::builder().with_size(egui::vec2(640.0, height)).build_eframe(|_| EffectcraftApp::new(Session::default())); + h.run_steps(3); + assert!(h.state().auto.find("project.empty").is_some(), "the Project panel drew at {height} points"); + } +} From 82a1862fa1316896c6e453b38721559a2a052da4 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Thu, 8 Oct 2026 11:36:11 +0700 Subject: [PATCH 2/3] Web: Media Browser reports a picked file it can't read (#226) Add Files... and Upload Folder... read every picked file in one go and rejected on the first file the browser couldn't read; the Media Browser dropped that rejection, so nothing was added and nothing said why. Each file is now read on its own: the readable ones are added and the status bar names the unreadable ones with the browser's reason and suggests picking them again. Cancelling the picker is not reported. Test: apps/effectcraft-web/tests/page.mjs (Node, fake DOM). Co-Authored-By: Claude Opus 5.5 --- apps/effectcraft-web/js/host.js | 21 +++++---- apps/effectcraft-web/src/browse.rs | 40 +++++++++++++---- apps/effectcraft-web/src/persist.rs | 3 +- apps/effectcraft-web/tests/page.mjs | 70 +++++++++++++++++++++++++++++ docs/web.md | 7 ++- 5 files changed, 122 insertions(+), 19 deletions(-) create mode 100644 apps/effectcraft-web/tests/page.mjs diff --git a/apps/effectcraft-web/js/host.js b/apps/effectcraft-web/js/host.js index 702c5d33..2482521c 100644 --- a/apps/effectcraft-web/js/host.js +++ b/apps/effectcraft-web/js/host.js @@ -615,21 +615,26 @@ export async function browseRead(path) { } /// The fallback where folders can't be opened: pick files (or a whole folder, `directory`) with a -/// plain file input; resolves with [{path, bytes}] (path relative to the picked folder). +/// plain file input; resolves with [{path, bytes}] (path relative to the picked folder). A file +/// that can't be read is listed as {path, error} instead (#226); cancelling resolves with []. export function browsePickFiles(directory) { - return new Promise((resolve, reject) => { + return new Promise((resolve) => { const input = document.createElement("input"); input.type = "file"; input.multiple = true; if (directory) input.webkitdirectory = true; + input.oncancel = () => resolve([]); input.onchange = async () => { - try { - const out = []; - for (const f of input.files) { - out.push({ path: (directory && f.webkitRelativePath) || f.name, bytes: new Uint8Array(await f.arrayBuffer()) }); + const out = []; + for (const f of input.files) { + const path = (directory && f.webkitRelativePath) || f.name; + try { + out.push({ path, bytes: new Uint8Array(await f.arrayBuffer()) }); + } catch (e) { + out.push({ path, error: String((e && (e.message || e.name)) || e) }); } - resolve(out); - } catch (e) { reject(e); } + } + resolve(out); }; input.click(); }); diff --git a/apps/effectcraft-web/src/browse.rs b/apps/effectcraft-web/src/browse.rs index aba2eb22..ad7500fe 100644 --- a/apps/effectcraft-web/src/browse.rs +++ b/apps/effectcraft-web/src/browse.rs @@ -105,7 +105,7 @@ fn spawn(p: js_sys::Promise, what: &'static str, then: impl FnOnce(&mut effectcr crate::post(move |app| then(app, v)); } Err(e) => { - let msg = e.as_string().or_else(|| js_sys::Reflect::get(&e, &"message".into()).ok().and_then(|m| m.as_string())).unwrap_or_default(); + let msg = crate::persist::js_err(e); // Cancelling a picker is not an error. if !msg.contains("abort") && !msg.contains("AbortError") { crate::post(move |app| app.ui.status = format!("{what}: {msg}")); @@ -234,20 +234,42 @@ impl Browser for WebBrowser { Ok(json!({"pending": true})) } "addFiles" | "uploadFolder" => { + let what = if id == "uploadFolder" { "Upload Folder…" } else { "Add Files…" }; let p = pick_files(id == "uploadFolder"); wasm_bindgen_futures::spawn_local(async move { - let Ok(list) = wasm_bindgen_futures::JsFuture::from(p).await else { return }; - let mut n = 0; - for f in js_sys::Array::from(&list).iter() { - let path = js_sys::Reflect::get(&f, &"path".into()).ok().and_then(|v| v.as_string()); - let bytes = js_sys::Reflect::get(&f, &"bytes".into()).ok().and_then(|b| b.dyn_into::().ok()); - if let (Some(path), Some(bytes)) = (path, bytes) { - crate::files::put(&format!("/{}", path.trim_start_matches('/')), bytes.to_vec().into()); - n += 1; + let list = match wasm_bindgen_futures::JsFuture::from(p).await { + Ok(list) => js_sys::Array::from(&list), + Err(e) => { + let msg = crate::persist::js_err(e); + crate::post(move |app| app.ui.status = format!("{what}: {msg}")); + return; } + }; + // Files that can't be read are reported, not dropped silently (#226). + let (mut n, mut unread) = (0, vec![]); + for f in list.iter() { + let get = |key: &str| js_sys::Reflect::get(&f, &key.into()).ok(); + let Some(path) = get("path").and_then(|v| v.as_string()) else { continue }; + match get("bytes").and_then(|b| b.dyn_into::().ok()) { + Some(bytes) => { + crate::files::put(&format!("/{}", path.trim_start_matches('/')), bytes.to_vec().into()); + n += 1; + } + None => unread.push(match get("error").and_then(|e| e.as_string()).filter(|e| !e.is_empty()) { + Some(why) => format!("{path} ({why})"), + None => path, + }), + } + } + // Cancelled: nothing to report. + if n == 0 && unread.is_empty() { + return; } crate::post(move |app| { app.ui.status = format!("{n} file(s) added to Browser Storage"); + if !unread.is_empty() { + app.ui.status += &format!(". Could not read {}: try {what} again", unread.join(", ")); + } go(app, STORAGE); }); }); diff --git a/apps/effectcraft-web/src/persist.rs b/apps/effectcraft-web/src/persist.rs index 93fe8906..a2345cc3 100644 --- a/apps/effectcraft-web/src/persist.rs +++ b/apps/effectcraft-web/src/persist.rs @@ -60,7 +60,8 @@ pub fn last_error() -> Option { LAST_ERROR.with(|e| e.borrow().clone()) } -fn js_err(e: JsValue) -> String { +/// A JavaScript error (a rejected promise's reason) as text. +pub(crate) fn js_err(e: JsValue) -> String { e.as_string().or_else(|| js_sys::Reflect::get(&e, &"message".into()).ok().and_then(|m| m.as_string())).unwrap_or_else(|| format!("{e:?}")) } diff --git a/apps/effectcraft-web/tests/page.mjs b/apps/effectcraft-web/tests/page.mjs new file mode 100644 index 00000000..2b8cc9bf --- /dev/null +++ b/apps/effectcraft-web/tests/page.mjs @@ -0,0 +1,70 @@ +// Page-side helpers of the web app (js/host.js) under Node with a fake DOM, no browser or Wasm +// build needed: +// +// node apps/effectcraft-web/tests/page.mjs +// +// - Add Files… / Upload Folder…: a picked file that can't be read is listed with its error +// instead of failing the whole pick silently, and cancelling picks nothing (#226). +// Exits non-zero on failure. +import assert from "node:assert/strict"; +import { dirname, join } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const here = dirname(fileURLToPath(import.meta.url)); +globalThis.self = globalThis; + +class FakeElement { + constructor(tag) { + this.tag = tag; + this.children = []; + this.attributes = {}; + this.textContent = ""; + } + setAttribute(k, v) { + this.attributes[k] = v; + } + append(...c) { + this.children.push(...c); + } + *all() { + yield this; + for (const c of this.children) yield* c.all(); + } +} +let picker; +globalThis.document = { + body: new FakeElement("body"), + createElement(tag) { + const el = new FakeElement(tag); + if (tag === "input") { + picker = el; + el.click = () => {}; + } + return el; + }, + getElementById(id) { + return [...this.body.all()].find((e) => e.id === id) ?? null; + }, +}; + +const host = await import(pathToFileURL(join(here, "../js/host.js"))); + +// #226: one readable and one unreadable file. +const file = (name, read) => ({ name, webkitRelativePath: "", arrayBuffer: read }); +const picked = host.browsePickFiles(false); +picker.files = [ + file("ok.png", async () => new Uint8Array([1, 2, 3]).buffer), + file("red-corner.png", async () => { + throw new DOMException("Generated unreadable selected file", "NotReadableError"); + }), +]; +await picker.onchange(); +const out = await picked; +assert.equal(out.length, 2); +assert.deepEqual([out[0].path, [...out[0].bytes]], ["ok.png", [1, 2, 3]]); +assert.deepEqual(out[1], { path: "red-corner.png", error: "Generated unreadable selected file" }); +const cancelled = host.browsePickFiles(false); +picker.oncancel(); +assert.deepEqual(await cancelled, [], "cancelling picks nothing"); + +console.log("page: ok"); diff --git a/docs/web.md b/docs/web.md index f3b44306..1b5e0fa7 100644 --- a/docs/web.md +++ b/docs/web.md @@ -230,7 +230,8 @@ Window ▸ Media Browser browses a virtual tree (`src/browse.rs`): - **Browser Storage**: every file in browser storage (imported media, saved projects, files added with **Add Files…** or **Upload Folder…**, plain file inputs that work in every browser). This is - the persistent recent list where folders can't be opened. + the persistent recent list where folders can't be opened. A picked file that can't be read is + named in the status bar with the browser's reason, and the others are still added. - **Folders**: **Open Folder…** (File System Access API, `showDirectoryPicker`, Chromium) lists a folder from the user's disk; only the listing is read, and a file's bytes are read when it is imported (then kept in browser storage). The folder handles are remembered in IndexedDB; after @@ -418,6 +419,10 @@ node apps/effectcraft-web/tests/smoke.mjs --url http://127.0.0.1:8765/ --out tar reused worker again even at the same size, and a worker that fails to start fails its job and is terminated (`node apps/effectcraft-web/tests/workers.mjs`). +`apps/effectcraft-web/tests/page.mjs` checks page-side helpers of `js/host.js` the same way with +a fake DOM: a picked file that can't be read is reported while the others are added +(`node apps/effectcraft-web/tests/page.mjs`). + It also checks the M13.10 paths: viewer frames rendered in frame workers while scrubbing with the CPU renderer (project synced as diffs, event-loop gaps under 400 ms), a `wait: true` render that replies when done while the page keeps running, and the Media Browser over browser storage; and From db3b34b13421392b4f45726284fb2b0b65de29b1 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Thu, 8 Oct 2026 11:36:48 +0700 Subject: [PATCH 3/3] Web: a lost graphics device says so and offers a reload (#225) When the page's WebGPU device was lost, the canvas kept showing its last frame: edits still went through and rendered frames were right, but the editor never showed them and nothing said why (the status message about it is drawn in the canvas that can't present any more). The page now covers the canvas with a notice that offers Save Project (a download) and Reload. Reload first writes pending changes to browser storage, so the session snapshot brings the project and its unsaved changes back; if that write fails it says so and offers Reload Anyway. Without a kept session (?empty, memory storage) the notice says to save first. Test: apps/effectcraft-web/tests/page.mjs (Node, fake DOM). Co-Authored-By: Claude Opus 5.5 --- apps/effectcraft-web/js/host.js | 45 ++++++++++++++++++++++ apps/effectcraft-web/src/app.rs | 13 ++++++- apps/effectcraft-web/tests/page.mjs | 59 +++++++++++++++++++++++++++++ apps/effectcraft-web/web/index.html | 17 +++++++++ docs/web.md | 10 ++++- 5 files changed, 142 insertions(+), 2 deletions(-) diff --git a/apps/effectcraft-web/js/host.js b/apps/effectcraft-web/js/host.js index 2482521c..793900be 100644 --- a/apps/effectcraft-web/js/host.js +++ b/apps/effectcraft-web/js/host.js @@ -639,3 +639,48 @@ export function browsePickFiles(directory) { input.click(); }); } + +/// The page's WebGPU device was lost (#225): the canvas can't show anything new while the project +/// stays open and editable. Say so over the stale picture, and offer to save the project (a +/// download) and to reload; the reload first writes pending changes to browser storage, from +/// which the session comes back when it is `kept` there. +export function showDeviceLost(message, kept) { + if (document.getElementById("device-lost")) return; + const box = document.createElement("div"); + box.id = "device-lost"; + box.setAttribute("role", "alertdialog"); + const title = document.createElement("strong"); + title.textContent = "The graphics device was lost"; + const text = document.createElement("p"); + text.textContent = + "EffectCraft can't update the display any more. Your project is still open. " + + (kept ? "Reload to continue: the project and its unsaved changes come back." : "This session isn't kept in browser storage: save the project before you reload."); + const detail = document.createElement("small"); + detail.textContent = message; + const save = document.createElement("button"); + save.textContent = "Save Project"; + save.onclick = () => + self.effectcraft + .execute("file.save", {}) + .catch(() => self.effectcraft.execute("file.saveAs", { path: "/Untitled Project.ecproj" })) + .catch((e) => (text.textContent = `Saving failed: ${(e && e.message) || e}`)); + const reload = document.createElement("button"); + reload.textContent = "Reload"; + reload.onclick = async () => { + reload.disabled = true; + try { + await self.effectcraft.flush(); + } catch (e) { + text.textContent = `Writing to browser storage failed (${(e && e.message) || e}). Save the project, or reload anyway and lose the changes.`; + reload.textContent = "Reload Anyway"; + reload.disabled = false; + reload.onclick = () => location.reload(); + return; + } + location.reload(); + }; + const buttons = document.createElement("div"); + buttons.append(save, reload); + box.append(title, text, detail, buttons); + document.body.append(box); +} diff --git a/apps/effectcraft-web/src/app.rs b/apps/effectcraft-web/src/app.rs index ded2c56c..48f296c9 100644 --- a/apps/effectcraft-web/src/app.rs +++ b/apps/effectcraft-web/src/app.rs @@ -11,6 +11,12 @@ use effectcraft_ui_egui::EffectcraftApp; use serde_json::json; use wasm_bindgen::prelude::*; +#[wasm_bindgen(module = "/js/host.js")] +extern "C" { + #[wasm_bindgen(js_name = showDeviceLost)] + fn show_device_lost(message: &str, kept: bool); +} + /// Work for the UI thread with access to the app (posted by async tasks: picked files…). type Action = Box; @@ -215,9 +221,14 @@ pub async fn start(canvas_id: String) -> Result<(), JsValue> { log::error!("{message}"); } })); + // The canvas can't present any more (#225): the page says so over it and + // offers a reload, which brings the session back when browser storage keeps it. let lost = gpu_failures.clone(); + let kept = !empty && persist::backend() != "memory"; rs.device.set_device_lost_callback(move |reason, message| { - lost.report(&format!("GPU device lost ({reason:?}): {message}"), true); + let message = format!("GPU device lost ({reason:?}): {message}"); + lost.report(&message, true); + show_device_lost(&message, kept); }); } let mut session = session(); diff --git a/apps/effectcraft-web/tests/page.mjs b/apps/effectcraft-web/tests/page.mjs index 2b8cc9bf..06301523 100644 --- a/apps/effectcraft-web/tests/page.mjs +++ b/apps/effectcraft-web/tests/page.mjs @@ -5,12 +5,16 @@ // // - Add Files… / Upload Folder…: a picked file that can't be read is listed with its error // instead of failing the whole pick silently, and cancelling picks nothing (#226). +// - A lost WebGPU device: the page says so over the canvas and offers Save Project and Reload; +// the reload writes pending changes to browser storage first, and a failed write is reported +// instead of reloading (#225). // Exits non-zero on failure. import assert from "node:assert/strict"; import { dirname, join } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; const here = dirname(fileURLToPath(import.meta.url)); +const tick = () => new Promise((r) => setTimeout(r, 0)); globalThis.self = globalThis; class FakeElement { @@ -26,6 +30,9 @@ class FakeElement { append(...c) { this.children.push(...c); } + click() { + this.onclick?.(); + } *all() { yield this; for (const c of this.children) yield* c.all(); @@ -46,6 +53,8 @@ globalThis.document = { return [...this.body.all()].find((e) => e.id === id) ?? null; }, }; +let reloads = 0; +globalThis.location = { reload: () => reloads++ }; const host = await import(pathToFileURL(join(here, "../js/host.js"))); @@ -67,4 +76,54 @@ const cancelled = host.browsePickFiles(false); picker.oncancel(); assert.deepEqual(await cancelled, [], "cancelling picks nothing"); +// #225: the device-lost notice. +const calls = []; +let flushFails = false; +self.effectcraft = { + flush: async () => { + calls.push("flush"); + if (flushFails) throw new Error("quota exceeded"); + }, + execute: async (command, params) => { + calls.push([command, params]); + if (command === "file.save") throw new Error("no path: use file.saveAs {path}"); + }, +}; +const notice = () => document.getElementById("device-lost"); +const buttons = () => [...notice().all()].filter((e) => e.tag === "button"); +const text = () => [...notice().all()].find((e) => e.tag === "p").textContent; + +host.showDeviceLost("GPU device lost (Destroyed): destroyed", true); +host.showDeviceLost("again", true); +assert.equal(document.body.children.length, 1, "one notice"); +assert.equal([...notice().all()].find((e) => e.tag === "small").textContent, "GPU device lost (Destroyed): destroyed"); +assert.match(text(), /Reload to continue/); +assert.deepEqual( + buttons().map((b) => b.textContent), + ["Save Project", "Reload"], +); +// Save Project: Save, or Save As for an untitled project (a download). +await buttons()[0].onclick(); +assert.deepEqual(calls.splice(0), [ + ["file.save", {}], + ["file.saveAs", { path: "/Untitled Project.ecproj" }], +]); +// Reload writes pending changes first. +await buttons()[1].onclick(); +assert.deepEqual(calls.splice(0), ["flush"]); +assert.equal(reloads, 1); + +// A failed write is reported instead of reloading; Reload Anyway reloads. +document.body.children = []; +flushFails = true; +host.showDeviceLost("GPU device lost (Unknown): gone", false); +assert.match(text(), /isn't kept in browser storage/); +await buttons()[1].onclick(); +await tick(); +assert.equal(reloads, 1, "no reload after a failed write"); +assert.match(text(), /quota exceeded/); +assert.equal(buttons()[1].textContent, "Reload Anyway"); +buttons()[1].onclick(); +assert.equal(reloads, 2); + console.log("page: ok"); diff --git a/apps/effectcraft-web/web/index.html b/apps/effectcraft-web/web/index.html index 578e6159..70ab93ff 100644 --- a/apps/effectcraft-web/web/index.html +++ b/apps/effectcraft-web/web/index.html @@ -20,6 +20,23 @@ } #loading img { width: 56px; height: 56px; } #loading .err { color: #ff8a7a; white-space: pre-wrap; max-width: 70ch; pointer-events: auto; } + #device-lost { + position: absolute; inset: 0; display: flex; flex-direction: column; gap: 10px; + align-items: center; justify-content: center; padding: 16px; text-align: center; + background: rgb(18 18 18 / 0.85); color: #c8c8c8; font: 14px/1.4 system-ui, sans-serif; + } + #device-lost strong { color: #e3e3e3; font-size: 16px; } + #device-lost p { margin: 0; max-width: 60ch; } + #device-lost small { color: #8c8c8c; font-size: 12px; } + #device-lost div { display: flex; gap: 8px; } + #device-lost button { + padding: 6px 14px; border: 1px solid #3a3a3a; border-radius: 4px; + background: #232323; color: #e3e3e3; font: inherit; cursor: pointer; + } + #device-lost button:hover { background: #303030; } + #device-lost button:last-child { background: #2d8ceb; border-color: #2d8ceb; } + #device-lost button:last-child:hover { background: #4aa0f5; } + #device-lost button:disabled { opacity: 0.6; cursor: default; } diff --git a/docs/web.md b/docs/web.md index 1b5e0fa7..8c198970 100644 --- a/docs/web.md +++ b/docs/web.md @@ -263,6 +263,13 @@ eyedroppers read GPU frames back asynchronously (`Gpu::read_display_async`: the frame later). On WebGL2, or without a usable adapter, everything renders on the CPU. `effectcraft.info().gpu` reports `{compositor, viewerOnGpu}`. +If the page's WebGPU device is lost (a driver reset, the browser reclaiming the GPU), the canvas +can't show anything new, while the project stays open and commands keep working. The page then +covers the canvas with a notice (`showDeviceLost` in `js/host.js`) offering **Save Project** (a +download) and **Reload**, which first writes pending changes to browser storage +(`effectcraft.flush()`) so the session comes back after the reload; when the write fails it says +so and offers **Reload Anyway**. + **GPU effects run in the frame workers.** A browser never lets JavaScript (or wasm) wait for a GPU readback, not even in a worker: a buffer maps only once the thread returns to its event loop. The CPU renderer, which runs GPU effect chains and reads their results back mid-frame, therefore @@ -420,7 +427,8 @@ reused worker again even at the same size, and a worker that fails to start fail terminated (`node apps/effectcraft-web/tests/workers.mjs`). `apps/effectcraft-web/tests/page.mjs` checks page-side helpers of `js/host.js` the same way with -a fake DOM: a picked file that can't be read is reported while the others are added +a fake DOM: a picked file that can't be read is reported while the others are added, and the +device-lost notice saves, flushes before reloading and reports a failed flush (`node apps/effectcraft-web/tests/page.mjs`). It also checks the M13.10 paths: viewer frames rendered in frame workers while scrubbing with the