collective

Browse / github.com/storytold/figcraft / patch

M1: keep saved files in sync across windows (#1388)

[Bug Report] [FigCraft] opening file in two windows ### Description When I create a file and open it in two separate windows, changes made in one window are not reflected in the other. Even after saving the changes in one window, the other window remains unchanged. If I close both windows, the file ends up empty, even if I close the window containing the saved content after closing the one with the empty file. ### Operating system and version cachyos ### Ticket details | | | |---|---| | **App** | FigCraft | | **Type** | Bug Report | | **Reported by** | PARZIVAL (Discord username: 5p3c73r) — Discord user ID `1038039604189741117` | | **Submi

Status
open
Score
0.00
Adopters
0 other operators
Author
StrajnarFilip (imported from pull request #1392, open)
Base
740d117313d7476e29b84f8697af45c40404a270
Size
+804 −31 in 33 files
Review
pending
Shared
2026-10-28
Id
dfd8a5ed4338d9293defb1e7

How it went

No other operator has reported using it yet.

Diff

raw · ROADMAP.md apps/figcraft/src/file_watch.rs apps/figcraft/src/main.rs crates/engine/src/cmd/file.rs crates/engine/src/file_access.rs crates/engine/src/file_access_tests.rs crates/engine/src/lib.rs crates/engine/src/library.rs crates/engine/src/library_tests.rs crates/engine/src/save_disk_changes_tests.rs crates/ui-egui/locales/bn.toml crates/ui-egui/locales/cs.toml crates/ui-egui/locales/de.toml crates/ui-egui/locales/es.toml crates/ui-egui/locales/fi.toml crates/ui-egui/locales/fr.toml crates/ui-egui/locales/id.toml crates/ui-egui/locales/it.toml crates/ui-egui/locales/ja.toml crates/ui-egui/locales/ka.toml crates/ui-egui/locales/ko.toml crates/ui-egui/locales/nl.toml crates/ui-egui/locales/pl.toml crates/ui-egui/locales/pt-BR.toml crates/ui-egui/locales/ru.toml crates/ui-egui/locales/sv.toml crates/ui-egui/locales/th.toml crates/ui-egui/locales/tr.toml crates/ui-egui/locales/vi.toml crates/ui-egui/locales/zh-Hans.toml docs/control-protocol.md docs/gaps.md docs/hardware-parity.md

diff --git a/ROADMAP.md b/ROADMAP.md--- a/ROADMAP.md+++ b/ROADMAP.md@@ -2,7 +2,7 @@  **Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-26 · **Change:** minor (colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); branches in live sessions and branch history entries, gap 17; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7)+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-26 · **Change:** minor (M1: saved-file refresh with bounded retries and deduplicated errors (#1388, #1392); colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); branches in live sessions and branch history entries, gap 17; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7)  FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow, native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through@@ -132,6 +132,10 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).  ## Progress log +- **2026-10-26** · Saved-file refresh review follow-up (M1, #1392): failed automatic refreshes retry after 30 s even if the disk stamp stays unchanged, with immediate retry for an explicit `file.refresh`. The warning is shown once per file and failed replacement; recovery or a new replacement resets it. Busy tabs keep local work and have bounded retry deadlines. Regression tests cover same-stamp recovery, explicit retry, warning suppression and replacement changes. Numbers unchanged.++- **2026-10-26** · Saving a file open in two desktop app instances (M1, #1388): a filesystem watcher notices saved changes without repainting stable files, and `file.refresh` reloads clean native tabs, keeping their identity and view. Local edits, gestures, text and version previews, live sessions and open branch tabs are kept; conflicting saves are refused, including an explicit Save path or Save as to the same file. Path aliases reuse the open tab, and Save as cannot replace another open tab's file. Unsaved edits between independent processes still need a live session. Numbers unchanged.+ - **2026-10-26** · Non-ASCII layer names transliterate in code export (M6, #1235): `class_name` runs the name through `deunicode` before its ASCII filter, so 로그인 화면 → `rogeuin-hwamyeon`, кнопка → `knopka`, über → `uber` in the HTML/CSS/React/Leptos class names, component roots and `data-*` variant attributes (SwiftUI/Compose type names unchanged); a name left with nothing (symbols only) takes the node's kind (`.text`, `.frame`) instead of `layer-`, and the 40-character cut lands on a word boundary. New dependency: deunicode (pure Rust tables, zero deps, BSD-3-Clause, the `slug` crate's base). `html_codegen_tests`. Numbers unchanged. - **2026-10-25** · egui 0.37 capture lifecycle: screenshots with hidden or unknown visibility queue, request a hidden paint, and expire after 60 seconds if no capture arrives (headless-tested contract). Logic-only edits, saves and inspection continue while hidden. Native visible GUI and forced hidden-window paint, browser and Wayland behavior remain unqualified; parity percentages are unchanged. - **2026-10-24** · Open, Save and the folder dialogs start in the folder of the last file or folder picked in the run (#1290): the Linux portal does not remember it between dialogs as the macOS and Windows panels do. A folder deleted since falls back to the system's choice. No numbers change.@@ -348,6 +352,8 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).  | Date | Change | Summary | |---|---|---|+| 2026-10-26 | minor | M1, #1392: automatic saved-file refresh retries after 30 s even without another metadata change; explicit refresh retries immediately. Warnings are scoped to the file and failed replacement, shown once until recovery or a new stamp. Busy tabs keep bounded deadlines. Numbers unchanged |+| 2026-10-26 | minor | M1, #1388: external saves refresh clean desktop tabs while retaining identity and view; all native save paths refuse conflicting external changes, path aliases reuse a tab and Save as cannot overwrite another open tab. Independent processes need a live session for unsaved edits. Numbers unchanged | | 2026-10-26 | minor | Non-ASCII layer names transliterate in code export (M6, #1235): `class_name` transliterates through the new `deunicode` dependency (pure Rust tables, zero deps, BSD-3-Clause), so Korean, Cyrillic, Latin-diacritic and CJK names become readable ASCII classes (`rogeuin-hwamyeon`, `knopka`, `uber`, `an-niu`) in HTML/CSS/React/Leptos output, component roots and `data-*` variant attributes; symbols-only names take the node's kind (`.text`) instead of `layer-`, and the 40-character cut lands on a word boundary. `html_codegen_tests` updated and extended. Numbers unchanged | | 2026-10-25 | minor | egui 0.37 capture lifecycle: screenshots with hidden or unknown visibility queue, request a hidden paint, and expire after 60 seconds if no capture arrives (headless-tested contract). Logic-only edits, saves and inspection continue while hidden. Native visible GUI and forced hidden-window paint, browser and Wayland behavior remain unqualified; parity percentages are unchanged. | | 2026-10-24 | minor | Server memory (#1134): when the last person leaves a file nobody edited, figcraft-server keeps the client ids it issued in the log instead of rewriting the snapshot (compaction still follows `compact_after`/`compact_mb` and edits), and a snapshot is encoded straight into its file buffer, without a second copy of the body; the file format is unchanged. Numbers unchanged |diff --git a/apps/figcraft/src/file_watch.rs b/apps/figcraft/src/file_watch.rsnew file mode 100644--- /dev/null+++ b/apps/figcraft/src/file_watch.rs@@ -0,0 +1,298 @@+//! Notice external saves without repainting idle windows. File contents are read only by the+//! engine's `file.refresh` command, on the session thread and inside the automation roots.++use std::collections::HashMap;+use std::sync::Arc;+use std::sync::atomic::{AtomicBool, Ordering};+use std::sync::mpsc::{RecvTimeoutError, Sender, channel};+use std::time::{Duration, Instant};++use figcraft_engine::{Session, file_access};+use figcraft_ui_egui::FigApp;+use serde_json::json;++type Stamp = Option<file_access::Stamp>;+type ShownError = (String, file_access::Stamp, String);++struct Watching {+    paths: Vec<String>,+    retry_at: Option<Instant>,+}++pub struct Watch {+    tx: Sender<Watching>,+    changed: Arc<AtomicBool>,+    paths: Vec<String>,+    revisions: Vec<(u64, u64)>,+    shown_errors: HashMap<u64, ShownError>,+    retry_at: Option<Instant>,+}++impl Watch {+    /// Start a confined metadata watcher. Dropping its sender ends the worker; stable files do+    /// not wake the UI, and file contents remain the engine's responsibility.+    pub fn start(wake: impl Fn() + Send + 'static, roots: Option<Arc<file_access::Roots>>) -> Option<Self> {+        let (tx, rx) = channel::<Watching>();+        let changed = Arc::new(AtomicBool::new(true));+        let flag = changed.clone();+        let thread = std::thread::Builder::new().name("figcraft-files".into()).spawn(move || {+            let mut paths = Vec::new();+            let mut stamps = HashMap::new();+            let mut retry_at: Option<Instant> = None;+            loop {+                let wait = retry_at.map_or(Duration::from_secs(1), |at| at.saturating_duration_since(Instant::now()).min(Duration::from_secs(1)));+                match rx.recv_timeout(wait) {+                    Ok(updated) => {+                        paths = updated.paths;+                        retry_at = updated.retry_at;+                        stamps.retain(|path, _| paths.contains(path));+                    }+                    Err(RecvTimeoutError::Timeout) => {}+                    Err(RecvTimeoutError::Disconnected) => break,+                }+                let external = file_access::confined(roots.as_ref(), || scan(&paths, &mut stamps));+                let retry_due = retry_at.is_some_and(|at| at <= Instant::now());+                if retry_due {+                    // One wake per deadline. The next poll sends a new deadline if it failed.+                    retry_at = None;+                }+                if external || retry_due {+                    flag.store(true, Ordering::Release);+                    wake();+                }+            }+        });+        match thread {+            Ok(_) => Some(Self { tx, changed, paths: Vec::new(), revisions: Vec::new(), shown_errors: HashMap::new(), retry_at: None }),+            Err(e) => {+                log::warn!("file watcher: {e}");+                None+            }+        }+    }++    /// Refresh after a disk change, local revision or failed-read deadline. Each file/replacement+    /// gets one warning, while every actual failed attempt remains in the log.+    pub fn poll(&mut self, fig: &mut FigApp, ctx: &egui::Context) {+        // Retry after a gesture, preview or local edit ends, including undo back to the saved+        // state. A notification that arrived while editing must not be lost forever.+        let observed = revisions(&fig.session);+        let edited = observed != self.revisions;+        self.revisions = observed;+        let due = next_retry(&fig.session).is_some_and(|at| at <= Instant::now());+        if (self.changed.swap(false, Ordering::AcqRel) || edited || due)+            && let Some(result) = fig.run("file.refresh", json!({"automatic": true}))+        {+            if result["refreshed"].as_array().is_some_and(|v| !v.is_empty()) {+                ctx.request_repaint();+                for window in &fig.windows.list {+                    ctx.request_repaint_of(figcraft_ui_egui::windows::viewport(window.id));+                }+            }+            if let Some(errors) = result["errors"].as_array() {+                for error in errors.iter().filter_map(serde_json::Value::as_str) {+                    log::warn!("file refresh: {error}");+                }+            }+        }+        self.revisions = revisions(&fig.session);+        self.show_new_errors(fig);+        let paths: Vec<_> = fig.session.docs.iter().filter_map(|d| d.path.clone()).collect();+        let retry_at = next_retry(&fig.session);+        if paths != self.paths || retry_at != self.retry_at {+            // An idle/hidden viewport may not paint for a delayed repaint request. The metadata+            // worker owns this deadline and wakes the app independently of UI-frame scheduling.+            let _ = self.tx.send(Watching { paths: paths.clone(), retry_at });+            self.paths = paths;+            self.retry_at = retry_at;+        }+    }++    /// Deduplicate by tab, path, failed stamp and message; recovery, a new replacement or closing+    /// the tab removes the old warning's identity without suppressing another file's warning.+    fn show_new_errors(&mut self, fig: &mut FigApp) {+        let current: HashMap<_, _> = fig+            .session+            .docs+            .iter()+            .filter_map(|d| d.refresh_failure.as_ref().map(|f| (d.uid, (f.path.clone(), f.stamp.clone(), f.message.clone()))))+            .collect();+        let messages: Vec<_> = current.iter().filter(|(uid, error)| self.shown_errors.get(*uid) != Some(*error)).map(|(_, e)| e.2.clone()).collect();+        self.shown_errors = current;+        for message in messages {+            fig.toast(message);+        }+    }+}++/// The soonest failed replacement's deadline; none means the watcher needs no timed repaint.+fn next_retry(session: &Session) -> Option<Instant> {+    session.docs.iter().filter_map(|d| d.refresh_failure.as_ref().map(|f| f.retry_at)).min()+}++/// Observe local edits and transient-state changes without treating a metadata poll as an edit.+fn revisions(session: &Session) -> Vec<(u64, u64)> {+    session.docs.iter().map(|d| (d.uid, d.revision)).collect()+}++/// First observation and any later replacement wake the app; a stable file costs no UI frame.+fn scan(paths: &[String], stamps: &mut HashMap<String, Stamp>) -> bool {+    let mut changed = false;+    for path in paths {+        let now = file_access::stamp(path).ok();+        if stamps.get(path) != Some(&now) {+            stamps.insert(path.clone(), now);+            changed = true;+        }+    }+    changed+}++#[cfg(test)]+mod tests {+    use super::*;++    /// A watcher without a background thread; tests inject the same disk-change notification.+    fn watcher() -> Watch {+        let (tx, _) = channel();+        Watch { tx, changed: Arc::new(AtomicBool::new(true)), paths: Vec::new(), revisions: Vec::new(), shown_errors: HashMap::new(), retry_at: None }+    }++    #[test]+    fn worker_wakes_once_for_a_retry_without_files_or_ui_activity() {+        let (wake, woken) = channel();+        let watch = Watch::start(+            move || {+                let _ = wake.send(());+            },+            None,+        )+        .unwrap();+        watch.tx.send(Watching { paths: Vec::new(), retry_at: Some(Instant::now() + Duration::from_millis(10)) }).unwrap();+        woken.recv_timeout(Duration::from_secs(5)).expect("the worker must wake the app for the deadline alone");+        assert!(woken.recv_timeout(Duration::from_millis(50)).is_err(), "the consumed deadline must not spin or keep waking the app");+    }++    struct FailedFile {+        dir: std::path::PathBuf,+        path: String,+        valid: Vec<u8>,+        modified: std::time::SystemTime,+        app: FigApp,+    }++    impl FailedFile {+        /// An external save replaced with damaged bytes, keeping its inode, size and timestamp.+        fn new() -> Self {+            static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);+            let dir = std::env::temp_dir().join(format!("figcraft-refresh-retry-{}-{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed)));+            std::fs::create_dir_all(&dir).unwrap();+            let path = dir.join("design.figcraft").to_string_lossy().into_owned();+            let mut writer = Session::new();+            writer.execute("file.save", &json!({"path": path})).unwrap();+            let mut reader = Session::empty();+            reader.execute("file.open", &json!({"path": path})).unwrap();+            writer.execute("page.rename", &json!({"name": "Recovered without an edit"})).unwrap();+            writer.execute("file.save", &json!({})).unwrap();+            let valid = std::fs::read(&path).unwrap();+            let modified = std::fs::metadata(&path).unwrap().modified().unwrap();+            let file = Self { dir, path, valid, modified, app: FigApp::new(reader, Box::new(figcraft_ui_egui::NoServices)) };+            file.replace_in_place(&vec![b'!'; file.valid.len()]);+            file+        }++        /// Simulate a writer completing or repairing a same-stamp replacement in place.+        fn replace_in_place(&self, bytes: &[u8]) {+            std::fs::write(&self.path, bytes).unwrap();+            std::fs::OpenOptions::new()+                .write(true)+                .open(&self.path)+                .unwrap()+                .set_times(std::fs::FileTimes::new().set_modified(self.modified))+                .unwrap();+        }++        /// Advance only the retry deadline, so recovery tests need no sleeps or user edits.+        fn expire(&mut self) {+            self.app.session.ds_mut().unwrap().refresh_failure.as_mut().unwrap().retry_at = Instant::now() - Duration::from_secs(1);+        }+    }++    impl Drop for FailedFile {+        fn drop(&mut self) {+            let _ = std::fs::remove_dir_all(&self.dir);+        }+    }++    #[test]+    fn same_stamp_failure_retries_automatically_after_its_deadline() {+        let mut file = FailedFile::new();+        let mut watch = watcher();+        let ctx = egui::Context::default();+        watch.poll(&mut file.app, &ctx);+        let failure = file.app.session.ds().unwrap().refresh_failure.clone().unwrap();+        assert!(file.app.ui.toast.is_some());+        file.app.ui.toast = None;+        file.replace_in_place(&file.valid);+        assert_eq!(failure.stamp, file_access::stamp(&file.path).unwrap());+        let revision = file.app.session.ds().unwrap().revision;+        watch.poll(&mut file.app, &ctx);+        assert!(file.app.session.ds().unwrap().refresh_failure.is_some(), "same-stamp recovery waits for the bounded retry");+        assert_eq!(file.app.session.ds().unwrap().revision, revision);+        assert!(file.app.ui.toast.is_none(), "the old failure does not renew its toast");+        file.expire();+        watch.poll(&mut file.app, &ctx);+        let ds = file.app.session.ds().unwrap();+        assert!(ds.refresh_failure.is_none(), "the deadline alone triggers recovery, without a metadata notification or edit");+        assert_eq!(ds.doc.node(ds.page).unwrap().name, "Recovered without an edit");+        assert!(!ds.is_dirty());+        assert!(watch.shown_errors.is_empty(), "recovery clears the warning identity");+    }++    #[test]+    fn persistent_errors_warn_once_and_a_new_replacement_can_warn_again() {+        let mut file = FailedFile::new();+        let mut watch = watcher();+        let ctx = egui::Context::default();+        watch.poll(&mut file.app, &ctx);+        let failure = file.app.session.ds().unwrap().refresh_failure.clone().unwrap();+        file.app.ui.toast = None;+        file.app.run("view.zoomIn", json!({}));+        watch.poll(&mut file.app, &ctx);+        assert_eq!(file.app.session.ds().unwrap().refresh_failure.as_ref().unwrap().retry_at, failure.retry_at);+        assert!(file.app.ui.toast.is_none(), "using the tab must not repeat the same warning");+        file.expire();+        watch.poll(&mut file.app, &ctx);+        assert!(file.app.session.ds().unwrap().refresh_failure.as_ref().unwrap().retry_at > Instant::now());+        assert!(file.app.ui.toast.is_none(), "a failed retry logs the error without repeating its toast");+        file_access::write(&file.path, &vec![b'!'; file.valid.len()]).unwrap();+        watch.changed.store(true, Ordering::Release);+        watch.poll(&mut file.app, &ctx);+        assert_ne!(file.app.session.ds().unwrap().refresh_failure.as_ref().unwrap().stamp, failure.stamp);+        assert!(file.app.ui.toast.is_some(), "a new failed replacement gets its own warning");+        assert_eq!(watch.shown_errors.len(), 1);+    }++    #[test]+    fn external_replacements_wake_once_and_stable_files_stay_idle() {+        let dir = std::env::temp_dir().join(format!("figcraft-file-watch-{}", std::process::id()));+        std::fs::create_dir_all(&dir).unwrap();+        let path = dir.join("design.figcraft");+        let paths = vec![path.to_string_lossy().into_owned()];+        let mut stamps = HashMap::new();+        assert!(scan(&paths, &mut stamps));+        assert!(!scan(&paths, &mut stamps));+        std::fs::write(&path, b"first").unwrap();+        assert!(scan(&paths, &mut stamps));+        assert!(!scan(&paths, &mut stamps));+        let staged = dir.join("replacement");+        std::fs::write(&staged, b"second save").unwrap();+        std::fs::rename(&staged, &path).unwrap();+        assert!(scan(&paths, &mut stamps));+        assert!(!scan(&paths, &mut stamps));+        std::fs::remove_file(&path).unwrap();+        assert!(scan(&paths, &mut stamps));+        assert!(!scan(&paths, &mut stamps));+        let _ = std::fs::remove_dir_all(dir);+    }+}diff --git a/apps/figcraft/src/main.rs b/apps/figcraft/src/main.rs--- a/apps/figcraft/src/main.rs+++ b/apps/figcraft/src/main.rs@@ -24,6 +24,7 @@ compile_error!("Windows GNU builds must set windows_slim_errors to keep UI Autom mod apple_events; mod args; mod control_server;+mod file_watch; mod gamepad; mod graphics; mod recovery;@@ -61,6 +62,7 @@ struct App {     title: String,     /// Shared with the services, which write the copies after a panic.     recovery: recovery::Shared,+    files: Option<file_watch::Watch>,     /// Documents and quit requests from Finder, the Dock and Open With.     #[cfg(target_os = "macos")]     apple_events: fmv_macos_events::Inbox,@@ -79,6 +81,9 @@ impl eframe::App for App {         }         let title = with_app(|fig| {             control_server::drain_queue(&self.queue, &mut fig.control_queue);+            if let Some(files) = &mut self.files {+                figcraft_ui_egui::windows::guard(fig, ctx, |fig| files.poll(fig, ctx));+            }             // Phase-safe background work: control queues, autosave/recovery deadlines, live polling.             // It runs on logic-only callbacks too, so it never reads UI input.             figcraft_ui_egui::windows::guard(fig, ctx, |fig| fig.background_tick(ctx));@@ -132,6 +137,8 @@ impl eframe::App for App {     } } +/// Start the desktop editor with native services, confined file watching and the optional+/// control channel; all document refreshes continue through the engine command interface. fn main() -> eframe::Result {     // Windows: the update helper is a copy of this program started with `--finish-update`.     #[cfg(windows)]@@ -224,6 +231,7 @@ fn main() -> eframe::Result {     figcraft_ui_egui::i18n::set(&session.prefs.language, figcraft_ui_egui::Services::system_language(&svc).as_deref());     let mut fig = FigApp::new(session, Box::new(svc));     fig.set_startup_update_check(true);+    let files = file_watch::Watch::start(wake(&ctx_slot), roots.clone());     fig.automation_roots = roots;     fig.ui.home = false;     // Files named on the command line are the user's too: their missing fonts are listed.@@ -275,6 +283,7 @@ fn main() -> eframe::Result {                 queue,                 ctx_slot,                 title: String::new(),+                files,                 recovery,                 #[cfg(target_os = "macos")]                 apple_events: apple_events.connect(&cc.egui_ctx),diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs--- a/crates/engine/src/cmd/file.rs+++ b/crates/engine/src/cmd/file.rs@@ -29,6 +29,7 @@ fn new_file(s: &mut Session, p: &Value, kind: FileKind) -> Result<Value> {     Ok(json!({"index": s.active, "kind": kind.id()})) } +/// File commands shared by the UI, CLI and automation, including conflict-safe saves and refresh. pub fn specs() -> Vec<CommandSpec> {     vec![         spec!("file.new", "New design file", "{name?}", false, |s, p| new_file(s, p, FileKind::Design)),@@ -111,6 +112,13 @@ pub fn specs() -> Vec<CommandSpec> {         ),         spec!("file.placeImage", "Place image", "{path | data, x?, y?}", true, super::create::place_image),         spec!("file.close", "Close tab", "{index?, force? (discard unsaved changes)}", false, close),+        spec!(+            "file.refresh",+            "Refresh saved files",+            "{automatic? (false: retry immediately; true: failed replacements wait 30 seconds)}  reload externally changed native files with no local edits, previews, branch tabs or live session; keeps tab identity and view; returns {refreshed: [document uid], errors: [message]}",+            false,+            refresh+        ),         spec!("file.moveTab", "Move tab", "{from, to} (indices among open documents)", false, |s, p| {             let from = req_index("file.moveTab", p, "from")?;             let to = req_index("file.moveTab", p, "to")?;@@ -934,6 +942,7 @@ fn open_recent(s: &mut Session, p: &Value) -> Result<Value> {     Ok(json!({"files": files})) } +/// Open a native file or import, reusing an existing tab even when its path is an alias. fn open(s: &mut Session, p: &Value) -> Result<Value> {     let variables = variables_param("file.open", p)?;     if string(p, "path").is_none()@@ -942,8 +951,9 @@ fn open(s: &mut Session, p: &Value) -> Result<Value> {         return open_data(s, &d, p, variables.as_ref());     }     let path = req_string("file.open", p, "path")?;+    crate::file_access::allow(&path, crate::file_access::Access::Read)?;     // Already open: switch to it.-    if let Some(i) = s.docs.iter().position(|d| d.path.as_deref() == Some(path.as_str())) {+    if let Some(i) = s.docs.iter().position(|d| d.path.as_deref().is_some_and(|p| crate::file_access::same_file(p, &path))) {         s.active = i;         remember(s, &path);         return Ok(json!({"index": i}));@@ -975,6 +985,119 @@ fn open(s: &mut Session, p: &Value) -> Result<Value> {     Ok(out) } +/// An external replacement that could not be loaded. A new stamp or a successful refresh clears+/// it; the desktop schedules `retry_at` even when the file's stamp stays unchanged.+#[derive(Clone, Debug)]+pub struct RefreshFailure {+    /// The failed destination; a changed Save as path starts a separate failure.+    pub path: String,+    /// The replacement that failed, distinct from the document's saved baseline.+    pub stamp: crate::file_access::Stamp,+    /// What to report once for this file and replacement, while logging each failed attempt.+    pub message: String,+    /// Monotonic deadline for the next automatic attempt; explicit refresh bypasses it.+    pub retry_at: web_time::Instant,+}++/// External saves are a new saved baseline, never an edit or the end of a preview. Prepare all+/// replacements before touching tabs, preserve local work, and bound automatic failure retries.+fn refresh(s: &mut Session, p: &Value) -> Result<Value> {+    let automatic = p.get("automatic").and_then(Value::as_bool) == Some(true);+    let now = web_time::Instant::now();+    let retry_at = now.checked_add(std::time::Duration::from_secs(30)).unwrap_or(now);+    let mut refreshed = Vec::new();+    let mut errors = Vec::new();+    let mut replacements = Vec::new();+    let mut failures = Vec::new();+    for i in 0..s.docs.len() {+        let Some(ds) = s.docs.get(i) else { continue };+        let (Some(path), Some(was)) = (&ds.path, &ds.on_disk) else {+            failures.push((i, None));+            continue;+        };+        let Ok(stamp) = crate::library::stat(path) else {+            failures.push((i, None));+            continue;+        };+        if stamp == *was {+            failures.push((i, None));+            continue;+        }+        let previous = ds.refresh_failure.as_ref().filter(|f| f.path == *path && f.stamp == stamp);+        if ds.branch_of.is_some()+            || ds.interaction.is_some()+            || ds.text_edit.is_some()+            || ds.text_preview.is_some()+            || ds.preview.is_some()+            || s.live_of(ds.uid).doc_uid.is_some()+            || s.docs.iter().any(|b| b.branch_of.as_ref().is_some_and(|(uid, _)| *uid == ds.uid))+            // Undo/normalisation can copy the saved document without changing its contents.+            || (ds.is_dirty() && (ds.versions_changed || ds.doc != ds.saved_doc))+        {+            // Keep the warning's identity while busy, but never leave an expired deadline that+            // would make the desktop spin. Finishing the edit also triggers another check.+            let held = previous.cloned().map(|mut failure| {+                if failure.retry_at <= now {+                    failure.retry_at = retry_at;+                }+                failure+            });+            failures.push((i, held));+            continue;+        }+        if automatic && previous.is_some_and(|f| f.retry_at > now) {+            continue;+        }+        let loaded = match load_path(path, None).and_then(|(loaded, rest)| {+            if !rest && loaded.issues.is_empty() && loaded.images_past_budget == 0 {+                Ok(loaded)+            } else {+                // An incomplete or damaged replacement must never become the saved baseline.+                Err(EngineError::Other(format!("{} couldn't be refreshed: the replacement file is incomplete or damaged", file_stem(path))))+            }+        }) {+            Ok(loaded) => loaded,+            Err(e) => {+                let message = e.to_string();+                errors.push(message.clone());+                failures.push((i, Some(RefreshFailure { path: path.clone(), stamp, message, retry_at })));+                continue;+            }+        };+        // A writer still replacing this file: retry on the next notification.+        if crate::library::stat(path).ok().as_ref() != Some(&stamp) {+            failures.push((i, None));+            continue;+        }+        let mut replacement = DocState::new(loaded.doc, Some(path.clone())).with_versions(loaded.versions).with_branches(loaded.branches);+        replacement.uid = ds.uid;+        replacement.revision = ds.revision.wrapping_add(1);+        replacement.view = ds.view.clone();+        replacement.view_from_prefs = ds.view_from_prefs;+        if replacement.doc.pages.contains(&ds.page) {+            replacement.page = ds.page;+        }+        replacement.selection = ds.selection.clone();+        replacement.clean_selection();+        replacement.on_disk = Some(stamp);+        refreshed.push(ds.uid);+        replacements.push((i, replacement));+    }+    // Parsing and settlement of every replacement happen before changing any tab, so the panic+    // guard can leave the session untouched even when a later file fails unexpectedly.+    for (i, failure) in failures {+        if let Some(slot) = s.docs.get_mut(i) {+            slot.refresh_failure = failure;+        }+    }+    for (i, replacement) in replacements {+        if let Some(slot) = s.docs.get_mut(i) {+            *slot = replacement;+        }+    }+    Ok(json!({"refreshed": refreshed, "errors": errors}))+}+ /// [`open`] for a file given as base64 `data` (a file dropped without a path on disk): `name` tells /// the format. It opens as a new document with no path, so the first save asks where to put it. fn open_data(s: &mut Session, data: &str, p: &Value, variables: Option<&Value>) -> Result<Value> {@@ -1128,15 +1251,23 @@ fn wants_data(p: &Value) -> bool { /// Save the file of the active tab (the main file for a branch tab, with its branches), or export /// the active document as an experimental `.fig` copy without changing its native save state. /// Without a path the file comes back as base64 `data` for the caller to deliver (#969).-fn save_to(s: &mut Session, path: Option<String>) -> Result<Value> {+fn save_to(s: &mut Session, path: Option<String>, overwrite: bool) -> Result<Value> {     if let Some(path) = path.as_deref().filter(|p| ext(p) == "fig") {         return export_fig(s, &json!({"path": path}));     }     let i = crate::branches::file_tab(s);+    if let Some(path) = &path+        && s.docs.iter().enumerate().any(|(j, d)| j != i && d.path.as_deref().is_some_and(|p| crate::file_access::same_file(p, path)))+    {+        return Err(EngineError::Other(format!("{} is already open in another tab: switch to it, or save under another name", file_stem(path))));+    }     let ds = s.docs.get_mut(i).ok_or(EngineError::NoDocument)?;     // A download (no path) writes over no file.     if let Some(path) = path.as_deref() {         refuse_dropping_images(ds, path)?;+        if !overwrite && ds.path.as_deref().is_some_and(|p| crate::file_access::same_file(p, path)) {+            refuse_overwriting_changes(ds, path)?;+        }     }     let mut snapshot = FileSnapshot::of(ds);     let follows_path = ds.path.as_deref().map_or(ds.doc.name == "Untitled", |old| ds.doc.name == file_stem(old));@@ -1172,9 +1303,12 @@ fn mark_saved(s: &mut Session, _: &Value) -> Result<Value> {     Ok(json!({"saved": true})) } +/// Save to an explicit or current native path, retaining the explicit overwrite opt-in and+/// requesting Save as when no usable native path exists. fn save(s: &mut Session, p: &Value) -> Result<Value> {+    let overwrite = p.get("overwrite").and_then(Value::as_bool) == Some(true);     if let Some(path) = string(p, "path") {-        return save_to(s, Some(path));+        return save_to(s, Some(path), overwrite);     }     let ds = s.docs.get(crate::branches::file_tab(s)).ok_or(EngineError::NoDocument)?;     match ds.path.clone() {@@ -1183,12 +1317,7 @@ fn save(s: &mut Session, p: &Value) -> Result<Value> {             s.request_ui("dialog.saveAs", json!({}));             Err(EngineError::Disabled("file.save".into(), "the saved path ends in .fig: choose Save as or pass `path`".into()))         }-        Some(path) => {-            if p.get("overwrite").and_then(Value::as_bool) != Some(true) {-                refuse_overwriting_changes(ds, &path)?;-            }-            save_to(s, Some(path))-        }+        Some(path) => save_to(s, Some(path), overwrite),         None => {             s.request_ui("dialog.saveAs", json!({}));             Err(EngineError::Disabled("file.save".into(), "the file has no path yet: pass `path`".into()))@@ -1196,15 +1325,17 @@ fn save(s: &mut Session, p: &Value) -> Result<Value> {     } } +/// Save to the chosen native destination or return a download; a conflicting current file is+/// protected even when its path was chosen explicitly. fn save_as(s: &mut Session, p: &Value) -> Result<Value> {     match string(p, "path") {         Some(mut path) => {             if ext(&path).is_empty() {                 path.push_str(".figcraft");             }-            save_to(s, Some(path))+            save_to(s, Some(path), false)         }-        None if wants_data(p) => save_to(s, None),+        None if wants_data(p) => save_to(s, None, false),         None => {             s.request_ui("dialog.saveAs", json!({}));             Err(EngineError::Disabled("file.saveAs".into(), "pass `path`".into()))diff --git a/crates/engine/src/file_access.rs b/crates/engine/src/file_access.rs--- a/crates/engine/src/file_access.rs+++ b/crates/engine/src/file_access.rs@@ -299,6 +299,35 @@ fn write_atomic(_: &Path, _: &[u8]) -> std::io::Result<()> {     Err(std::io::Error::new(std::io::ErrorKind::Unsupported, "no file system in the browser")) } +/// A file's saved generation. Identity distinguishes atomic replacements made within the same+/// filesystem timestamp tick, even when the encoded files have the same length (#1388).+#[derive(Clone, Debug, PartialEq, Eq)]+pub struct Stamp {+    pub(crate) len: u64,+    pub(crate) modified: Option<std::time::SystemTime>,+    pub(crate) identity: Option<u64>,+}++/// Read a confined file's size, modification time and native identity without retaining an open+/// descriptor. Non-files and paths outside the active read roots are refused.+pub fn stamp(path: impl AsRef<Path>) -> Result<Stamp> {+    use std::hash::{Hash, Hasher};++    let path = allow(path, Access::Read)?;+    let metadata = std::fs::metadata(&path).map_err(io(&path))?;+    if !metadata.is_file() {+        return Err(EngineError::Other(format!("{} is not a file", path.display())));+    }+    // same-file hashes its platform's file identity (device/inode or volume/file index). Drop+    // the handle here: watching many files must not keep one descriptor open for each of them.+    let identity = same_file::Handle::from_path(&path).ok().map(|handle| {+        let mut hash = std::collections::hash_map::DefaultHasher::new();+        handle.hash(&mut hash);+        hash.finish()+    });+    Ok(Stamp { len: metadata.len(), modified: metadata.modified().ok(), identity })+}+ /// Create folder `path` and the folders above it that are missing. pub fn create_dir_all(path: impl AsRef<Path>) -> Result<()> {     let path = path.as_ref();diff --git a/crates/engine/src/file_access_tests.rs b/crates/engine/src/file_access_tests.rs--- a/crates/engine/src/file_access_tests.rs+++ b/crates/engine/src/file_access_tests.rs@@ -386,3 +386,21 @@ fn only_resolved_paths_count_as_the_apps_own_folder() {     assert!(!super::within_app_folder(&install.join("missing.wasm"), &install));     assert!(!super::within_app_folder(&install.join("in.txt"), &f.0.join("no-such-folder"))); }++#[test]+fn stamps_detect_same_size_atomic_replacements_with_identical_timestamps() {+    let f = Fixture::new("stamp-identity");+    let path = f.p("root/design.figcraft");+    super::write(&path, b"old").unwrap();+    let before = super::stamp(&path).unwrap();+    super::write(&path, b"new").unwrap();+    std::fs::OpenOptions::new().write(true).open(&path).unwrap().set_times(std::fs::FileTimes::new().set_modified(before.modified.unwrap())).unwrap();+    let after = super::stamp(&path).unwrap();+    assert_eq!(before.len, after.len);+    assert_eq!(before.modified, after.modified);+    assert_ne!(before, after, "a stale window must see an atomic replacement");+    assert_eq!(after, super::stamp(&path).unwrap(), "a stable file stays idle");+    confined(Some(&Arc::new(f.rw())), || {+        assert!(super::stamp(f.p("outside/secret.txt")).unwrap_err().to_string().contains("automation path rejected"));+    });+}diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs--- a/crates/engine/src/lib.rs+++ b/crates/engine/src/lib.rs@@ -378,6 +378,8 @@ pub struct DocState {     /// The file on disk as this tab last read or wrote it: a save refuses to replace what another     /// app (figcraft-cli, say) wrote to it since (#1330).     on_disk: Option<library::Stamp>,+    /// A failed external refresh and its next automatic retry (transient, never saved).+    pub refresh_failure: Option<cmd::file::RefreshFailure>, }  /// A version shown on the canvas in place of the document: read-only until it's restored or the@@ -442,6 +444,7 @@ impl DocState {             last_duplicate: None,             images_left_in_file: None,             on_disk: None,+            refresh_failure: None,         };         st.zoom_to_fit();         st@@ -452,9 +455,10 @@ impl DocState {         self     } -    /// The tab's file now holds what this tab read or wrote.+    /// The tab's file now holds what this tab read or wrote; any failed refresh is obsolete.     pub(crate) fn note_on_disk(&mut self) {         self.on_disk = self.path.as_deref().and_then(|p| library::stat(p).ok());+        self.refresh_failure = None;     }      pub fn images_lost_saving_to(&self, path: &str) -> Option<usize> {@@ -970,6 +974,11 @@ impl Session {     /// as it was.     pub fn execute(&mut self, id: &str, params: &Value) -> Result<Value> {         let spec = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.to_string()))?;+        // A disk refresh manages its own saved baselines and skips busy/live documents. Running+        // edit bookkeeping here would end previews, normalise replacements and mark them dirty.+        if id == "file.refresh" {+            return guard::run(id, || (spec.run)(self, params));+        }         board::refuse_on_board(self, id)?;         // Revert replaces the document's uid: refuse before ending previews or syncing branches.         if id == "file.revert" && self.live().doc_uid.is_some() {diff --git a/crates/engine/src/library.rs b/crates/engine/src/library.rs--- a/crates/engine/src/library.rs+++ b/crates/engine/src/library.rs@@ -439,12 +439,7 @@ pub fn publish(doc: &Document, prev: Option<&PublishedLibrary>, sel: &Selection,     Ok(PublishedLibrary { manifest, doc: Arc::new(snap) }) } -/// When a file was last read: its length and modification time (also a document's own file, #1330).-#[derive(Clone, Debug, PartialEq)]-pub(crate) struct Stamp {-    len: u64,-    modified: Option<std::time::SystemTime>,-}+pub(crate) use crate::file_access::Stamp;  #[derive(Clone, Debug)] struct Entry {@@ -544,15 +539,13 @@ fn now() -> Option<std::time::SystemTime> {     None } +/// Observe the generation of a confined native file for cache and save-conflict checks. #[cfg(not(target_arch = "wasm32"))] pub(crate) fn stat(path: &str) -> std::result::Result<Stamp, String> {-    let m = crate::file_access::metadata(path).map_err(|e| e.to_string())?;-    if !m.is_file() {-        return Err(format!("{path} is not a file"));-    }-    Ok(Stamp { len: m.len(), modified: m.modified().ok() })+    crate::file_access::stamp(path).map_err(|e| e.to_string()) } +/// Browser sessions have no native filesystem stamps. #[cfg(target_arch = "wasm32")] pub(crate) fn stat(_path: &str) -> std::result::Result<Stamp, String> {     Err(NO_FILES.into())diff --git a/crates/engine/src/library_tests.rs b/crates/engine/src/library_tests.rs--- a/crates/engine/src/library_tests.rs+++ b/crates/engine/src/library_tests.rs@@ -451,15 +451,15 @@ fn a_file_stamp_from_the_moment_it_was_read_does_not_prove_it_unchanged() {     use std::time::{Duration, SystemTime};     let read_at = SystemTime::now();     let entry = |modified: SystemTime| Entry {-        stamp: Some(Stamp { len: 10, modified: Some(modified) }),+        stamp: Some(Stamp { len: 10, modified: Some(modified), identity: None }),         read_at: Some(read_at),         result: Err("unused".into()),     };     // Written in the same coarse tick as the read: a same-length rewrite would keep this stamp.     let fresh = entry(read_at);-    assert!(!fresh.still_valid(&Stamp { len: 10, modified: Some(read_at) }));+    assert!(!fresh.still_valid(&Stamp { len: 10, modified: Some(read_at), identity: None }));     // Written well before the read: an unchanged stamp means an unchanged file.     let old = read_at - Duration::from_secs(10);-    assert!(entry(old).still_valid(&Stamp { len: 10, modified: Some(old) }));-    assert!(!entry(old).still_valid(&Stamp { len: 11, modified: Some(old) }));+    assert!(entry(old).still_valid(&Stamp { len: 10, modified: Some(old), identity: None }));+    assert!(!entry(old).still_valid(&Stamp { len: 11, modified: Some(old), identity: None })); }diff --git a/crates/engine/src/save_disk_changes_tests.rs b/crates/engine/src/save_disk_changes_tests.rs--- a/crates/engine/src/save_disk_changes_tests.rs+++ b/crates/engine/src/save_disk_changes_tests.rs@@ -6,6 +6,7 @@ use serde_json::json; use crate::Session; use crate::cmd::file::FileSnapshot; +/// A synthetic native path isolated by process and regression scenario. fn temp_file(name: &str) -> String {     let dir = std::env::temp_dir().join(format!("figcraft-disk-changes-{}-{name}", std::process::id()));     std::fs::create_dir_all(&dir).unwrap();@@ -74,3 +75,244 @@ fn auto_save_leaves_changes_made_on_disk_alone() {     assert_eq!(page_on_disk(&path), "CLI VENCEU");     let _ = std::fs::remove_file(&path); }++/// Two independent app processes initially displaying the same empty native file (#1388).+fn two_windows(path: &str) -> (Session, Session) {+    let mut first = Session::new();+    first.execute("file.save", &json!({"path": path})).unwrap();+    let mut second = Session::empty();+    second.execute("file.open", &json!({"path": path})).unwrap();+    (first, second)+}++#[test]+fn another_windows_save_refreshes_the_clean_tab_and_survives_both_close_orders() {+    for close_first in [true, false] {+        let path = temp_file(if close_first { "close-first" } else { "close-second" });+        let (mut first, mut second) = two_windows(&path);+        let uid = second.ds().unwrap().uid;+        let stale = FileSnapshot::of(second.ds().unwrap());+        let stale_bytes = stale.encode().unwrap();+        let page = second.page().unwrap();+        second.ds_mut().unwrap().view.zoom = 2.0;+        first.execute("shape.rectangle", &json!({"width": 100, "height": 50})).unwrap();+        first.execute("file.save", &json!({})).unwrap();+        let refreshed = second.execute("file.refresh", &json!({})).unwrap();+        assert_eq!(refreshed["refreshed"], json!([uid]));+        assert_eq!(refreshed["errors"], json!([]));+        let ds = second.ds().unwrap();+        assert_eq!(ds.uid, uid, "the window still owns the same tab");+        assert_eq!(ds.page, page);+        assert_eq!(ds.view.zoom, 2.0);+        assert!(!ds.is_dirty(), "the refresh is a saved baseline");+        assert_eq!(second.undo_depth(), (0, 0));+        assert_eq!(first.doc().unwrap(), second.doc().unwrap());+        assert_eq!(second.execute("file.refresh", &json!({})).unwrap()["refreshed"], json!([]));+        assert!(!stale.save(&mut second, &stale_bytes).unwrap(), "a queued save cannot put the old empty file back");+        // Closing neither window can save a stale empty version over the rectangle.+        let (a, b) = if close_first { (&mut first, &mut second) } else { (&mut second, &mut first) };+        a.execute("file.close", &json!({})).unwrap();+        b.execute("file.close", &json!({})).unwrap();+        let mut reopened = Session::empty();+        reopened.execute("file.open", &json!({"path": path})).unwrap();+        assert_eq!(reopened.doc().unwrap().nodes().count(), 2);+        let _ = std::fs::remove_file(path);+    }+}++#[test]+fn explicit_save_paths_cannot_replace_another_windows_changes() {+    for (name, command, alias) in [("save-path", "file.save", false), ("save-as-path", "file.saveAs", false), ("save-as-alias", "file.saveAs", true)]+    {+        let path = temp_file(name);+        let mut app = app_and_cli(&path);+        let target = if alias { path.replace("TESTE.figcraft", "./TESTE.figcraft") } else { path.clone() };+        assert!(app.execute(command, &json!({"path": target})).unwrap_err().to_string().contains("changed on disk"));+        assert_eq!(page_on_disk(&path), "CLI VENCEU");+        assert!(app.ds().unwrap().is_dirty());+        app.execute("file.save", &json!({"path": target, "overwrite": true})).unwrap();+        assert_ne!(page_on_disk(&path), "CLI VENCEU");+        let _ = std::fs::remove_file(path);+    }+}++#[test]+fn refresh_keeps_local_edits_and_retries_once_undone() {+    let path = temp_file("refresh-conflict");+    let (mut first, mut second) = two_windows(&path);+    second.execute("shape.ellipse", &json!({"width": 30, "height": 30})).unwrap();+    let local = second.doc().unwrap().clone();+    first.execute("page.rename", &json!({"name": "Saved elsewhere"})).unwrap();+    first.execute("file.save", &json!({})).unwrap();+    assert_eq!(second.execute("file.refresh", &json!({})).unwrap()["refreshed"], json!([]));+    assert_eq!(second.doc().unwrap(), &local);+    assert!(second.execute("file.save", &json!({})).is_err());+    second.execute("edit.undo", &json!({})).unwrap();+    assert_eq!(second.execute("file.refresh", &json!({})).unwrap()["refreshed"].as_array().unwrap().len(), 1);+    assert_eq!(second.undo_depth(), (0, 0), "old redo must not restore a stale document");+    assert_eq!(second.doc().unwrap().node(second.page().unwrap()).unwrap().name, "Saved elsewhere");+    let _ = std::fs::remove_file(path);+}++#[test]+fn refresh_never_ends_a_gesture_or_version_preview() {+    let path = temp_file("refresh-busy");+    let (mut first, mut second) = two_windows(&path);+    second.begin_interaction("Drag").unwrap();+    first.execute("page.rename", &json!({"name": "External"})).unwrap();+    first.execute("file.save", &json!({})).unwrap();+    assert_eq!(second.execute("file.refresh", &json!({})).unwrap()["refreshed"], json!([]));+    assert!(second.ds().unwrap().interaction.is_some());+    second.cancel_interaction().unwrap();+    let ds = second.ds_mut().unwrap();+    ds.start_preview("test", "Preview", 0, ds.doc.clone());+    assert_eq!(second.execute("file.refresh", &json!({})).unwrap()["refreshed"], json!([]));+    assert!(second.ds().unwrap().preview.is_some());+    second.ds_mut().unwrap().end_preview();+    assert_eq!(second.execute("file.refresh", &json!({})).unwrap()["refreshed"].as_array().unwrap().len(), 1);+    let _ = std::fs::remove_file(path);+}++#[test]+fn missing_or_broken_replacements_leave_the_open_document_intact() {+    let path = temp_file("refresh-broken");+    let (first, mut second) = two_windows(&path);+    let before = second.ds().unwrap().doc.clone();+    std::fs::write(&path, b"broken replacement").unwrap();+    let result = second.execute("file.refresh", &json!({})).unwrap();+    assert_eq!(result["refreshed"], json!([]));+    assert!(!result["errors"].as_array().unwrap().is_empty());+    assert!(std::sync::Arc::ptr_eq(&second.ds().unwrap().doc, &before));+    std::fs::remove_file(&path).unwrap();+    assert_eq!(second.execute("file.refresh", &json!({})).unwrap()["refreshed"], json!([]));+    assert_eq!(first.doc().unwrap(), second.doc().unwrap());+}++#[test]+fn opening_a_path_alias_reuses_the_tab_and_save_as_cannot_overwrite_an_open_tab() {+    let path = temp_file("path-alias");+    let (mut first, _) = two_windows(&path);+    let uid = first.ds().unwrap().uid;+    let alias = path.replace("TESTE.figcraft", "./TESTE.figcraft");+    first.execute("file.open", &json!({"path": alias})).unwrap();+    assert_eq!(first.docs.len(), 1);+    assert_eq!(first.ds().unwrap().uid, uid);+    first.execute("file.new", &json!({})).unwrap();+    first.execute("shape.ellipse", &json!({"width": 20, "height": 20})).unwrap();+    assert!(first.execute("file.saveAs", &json!({"path": alias})).unwrap_err().to_string().contains("already open"));+    assert!(first.ds().unwrap().path.is_none());+    assert!(first.ds().unwrap().is_dirty());+    let _ = std::fs::remove_file(path);+}++#[test]+fn same_size_replacements_in_the_same_timestamp_tick_are_not_missed() {+    let path = temp_file("same-timestamp");+    let (mut first, mut second) = two_windows(&path);+    let stamp = second.ds().unwrap().on_disk.clone().unwrap();+    first.execute("page.rename", &json!({"name": "PAGE 2"})).unwrap();+    first.execute("file.save", &json!({})).unwrap();+    // Reproduce a coarse timestamp filesystem deterministically, with no timing assumptions.+    let modified = stamp.modified.unwrap();+    std::fs::OpenOptions::new().write(true).open(&path).unwrap().set_times(std::fs::FileTimes::new().set_modified(modified)).unwrap();+    let now = crate::library::stat(&path).unwrap();+    assert_eq!(stamp.modified, now.modified);+    assert_ne!(stamp.identity, now.identity);+    assert!(second.execute("file.save", &json!({})).is_err());+    assert_eq!(second.execute("file.refresh", &json!({})).unwrap()["refreshed"].as_array().unwrap().len(), 1);+    assert_eq!(second.doc().unwrap().node(second.page().unwrap()).unwrap().name, "PAGE 2");+    let _ = std::fs::remove_file(path);+}++#[test]+fn refresh_preserves_text_previews_live_documents_and_clean_branch_tabs() {+    for mode in ["text-preview", "live", "branch"] {+        let path = temp_file(mode);+        let (mut first, mut second) = two_windows(&path);+        match mode {+            "text-preview" => {+                let ds = second.ds_mut().unwrap();+                ds.text_preview = Some(ds.doc.clone());+            }+            "live" => {+                let script = std::sync::Arc::new(std::sync::Mutex::new(crate::live_tests::Script::default()));+                second.live.install(Box::new(crate::live_tests::Mock(script)));+                second.execute("collab.host", &json!({})).unwrap();+                assert!(second.live().doc_uid.is_some());+            }+            "branch" => {+                second.execute("branch.create", &json!({"name": "Local branch"})).unwrap();+                second.execute("file.save", &json!({})).unwrap();+                assert!(!second.docs[0].is_dirty());+                first.execute("file.revert", &json!({})).unwrap();+            }+            _ => panic!("unknown test mode: {mode}"),+        }+        let baseline = second.docs[0].doc.clone();+        let uid = second.docs[0].uid;+        first.execute("page.rename", &json!({"name": "Changed outside"})).unwrap();+        first.execute("file.save", &json!({})).unwrap();+        assert_eq!(second.execute("file.refresh", &json!({})).unwrap()["refreshed"], json!([]));+        assert!(std::sync::Arc::ptr_eq(&baseline, &second.docs[0].doc));+        assert_eq!(second.docs[0].uid, uid);+        if mode == "text-preview" {+            assert!(second.ds().unwrap().text_preview.is_some(), "background refresh never ends a preview");+        }+        if mode == "branch" {+            assert_eq!(second.docs.len(), 2);+            assert!(second.ds().unwrap().branch_of.is_some());+        }+        let _ = std::fs::remove_file(path);+    }+}++#[test]+fn automatic_refresh_backs_off_but_explicit_refresh_bypasses_the_deadline() {+    let path = temp_file("refresh-backoff");+    let (mut writer, mut reader) = two_windows(&path);+    writer.execute("page.rename", &json!({"name": "Recovered"})).unwrap();+    writer.execute("file.save", &json!({})).unwrap();+    let valid = std::fs::read(&path).unwrap();+    let modified = std::fs::metadata(&path).unwrap().modified().unwrap();+    std::fs::write(&path, vec![b'!'; valid.len()]).unwrap();+    let reset_time = || {+        std::fs::OpenOptions::new().write(true).open(&path).unwrap().set_times(std::fs::FileTimes::new().set_modified(modified)).unwrap();+    };+    reset_time();+    assert!(!reader.execute("file.refresh", &json!({"automatic": true})).unwrap()["errors"].as_array().unwrap().is_empty());+    let failed = reader.ds().unwrap().refresh_failure.clone().unwrap();+    std::fs::write(&path, valid).unwrap();+    reset_time();+    assert_eq!(failed.stamp, crate::file_access::stamp(&path).unwrap(), "recovery does not change the observed stamp");+    let suppressed = reader.execute("file.refresh", &json!({"automatic": true})).unwrap();+    assert_eq!(suppressed["refreshed"], json!([]));+    assert_eq!(suppressed["errors"], json!([]));+    assert_eq!(reader.ds().unwrap().refresh_failure.as_ref().unwrap().retry_at, failed.retry_at);+    assert_eq!(reader.execute("file.refresh", &json!({})).unwrap()["refreshed"].as_array().unwrap().len(), 1);+    assert!(reader.ds().unwrap().refresh_failure.is_none());+    assert_eq!(reader.doc().unwrap().node(reader.page().unwrap()).unwrap().name, "Recovered");+    let _ = std::fs::remove_file(path);+}++#[test]+fn a_new_stamp_bypasses_backoff_and_busy_failures_do_not_spin() {+    let path = temp_file("refresh-backoff-generation");+    let (mut writer, mut reader) = two_windows(&path);+    std::fs::write(&path, b"broken").unwrap();+    reader.execute("file.refresh", &json!({"automatic": true})).unwrap();+    let before = reader.ds().unwrap().refresh_failure.clone().unwrap();+    writer.execute("page.rename", &json!({"name": "New replacement"})).unwrap();+    writer.execute("file.save", &json!({"overwrite": true})).unwrap();+    assert_eq!(reader.execute("file.refresh", &json!({"automatic": true})).unwrap()["refreshed"].as_array().unwrap().len(), 1);+    assert!(reader.ds().unwrap().refresh_failure.is_none());+    assert_ne!(before.stamp, reader.ds().unwrap().on_disk.clone().unwrap());+    std::fs::write(&path, b"broken again").unwrap();+    reader.execute("file.refresh", &json!({"automatic": true})).unwrap();+    reader.ds_mut().unwrap().refresh_failure.as_mut().unwrap().retry_at = web_time::Instant::now() - std::time::Duration::from_secs(1);+    reader.begin_interaction("Drag").unwrap();+    let deferred = reader.execute("file.refresh", &json!({"automatic": true})).unwrap();+    assert_eq!(deferred["errors"], json!([]));+    assert!(reader.ds().unwrap().refresh_failure.as_ref().unwrap().retry_at > web_time::Instant::now());+    assert!(reader.ds().unwrap().interaction.is_some());+    let _ = std::fs::remove_file(path);+}diff --git a/crates/ui-egui/locales/bn.toml b/crates/ui-egui/locales/bn.toml--- a/crates/ui-egui/locales/bn.toml+++ b/crates/ui-egui/locales/bn.toml@@ -1813,6 +1813,7 @@ status = "draft — needs native review"   [commands]+"file.refresh" = "সংরক্ষিত ফাইল পুনরায় লোড করুন" "board.connector" = "সংযোগকারী" "board.ink" = "মার্কারের টান" "board.label" = "সংযোগকারীর লেবেল"diff --git a/crates/ui-egui/locales/cs.toml b/crates/ui-egui/locales/cs.toml--- a/crates/ui-egui/locales/cs.toml+++ b/crates/ui-egui/locales/cs.toml@@ -1776,6 +1776,7 @@ status = "draft — needs native review" "⌥ drag" = "⌥ tažení"  [commands]+"file.refresh" = "Obnovit uložené soubory" "align.bottom" = "Zarovnat dolů" "align.hcenter" = "Zarovnat vodorovné středy" "align.left" = "Zarovnat vlevo"diff --git a/crates/ui-egui/locales/de.toml b/crates/ui-egui/locales/de.toml--- a/crates/ui-egui/locales/de.toml+++ b/crates/ui-egui/locales/de.toml@@ -1733,6 +1733,7 @@ status = "draft — needs native review" "⌥ drag" = "⌥ Ziehen"  [commands]+"file.refresh" = "Gespeicherte Dateien neu laden" "align.bottom" = "Unten ausrichten" "align.hcenter" = "Horizontal zentrieren" "align.left" = "Links ausrichten"diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml--- a/crates/ui-egui/locales/es.toml+++ b/crates/ui-egui/locales/es.toml@@ -1857,6 +1857,7 @@ status = "draft — needs native review" "⌥ drag" = "⌥ arrastrar"  [commands]+"file.refresh" = "Volver a cargar los archivos guardados" "align.bottom" = "Alinear abajo" "align.hcenter" = "Alinear centros horizontales" "align.left" = "Alinear a la izquierda"diff --git a/crates/ui-egui/locales/fi.toml b/crates/ui-egui/locales/fi.toml--- a/crates/ui-egui/locales/fi.toml+++ b/crates/ui-egui/locales/fi.toml@@ -1818,6 +1818,7 @@ status = "draft — needs native review" "⌥ drag" = "⌥ drag"  [commands]+"file.refresh" = "Lataa tallennetut tiedostot uudelleen" "align.bottom" = "Tasaa alas" "align.hcenter" = "Keskitä vaakasuunnassa" "align.left" = "Tasaa vasemmalle"diff --git a/crates/ui-egui/locales/fr.toml b/crates/ui-egui/locales/fr.toml--- a/crates/ui-egui/locales/fr.toml+++ b/crates/ui-egui/locales/fr.toml@@ -1819,6 +1819,7 @@ status = "draft — needs native review" "⌥ drag" = "⌥ glisser"  [commands]+"file.refresh" = "Recharger les fichiers enregistrés" "align.bottom" = "Aligner en bas" "align.hcenter" = "Aligner les centres horizontalement" "align.left" = "Aligner à gauche"diff --git a/crates/ui-egui/locales/id.toml b/crates/ui-egui/locales/id.toml--- a/crates/ui-egui/locales/id.toml+++ b/crates/ui-egui/locales/id.toml@@ -1780,6 +1780,7 @@ status = "draft — needs native review" "⌥ drag" = "⌥ seret"  [commands]+"file.refresh" = "Muat ulang file tersimpan" "align.bottom" = "Ratakan bawah" "align.hcenter" = "Ratakan tengah horizontal" "align.left" = "Rata kiri"diff --git a/crates/ui-egui/locales/it.toml b/crates/ui-egui/locales/it.toml--- a/crates/ui-egui/locales/it.toml+++ b/crates/ui-egui/locales/it.toml@@ -1754,6 +1754,7 @@ status = "draft — needs native review" "⌥ drag" = "⌥ trascina"  [commands]+"file.refresh" = "Ricarica i file salvati" "align.bottom" = "Allinea in basso" "align.hcenter" = "Allinea i centri orizzontali" "align.left" = "Allinea a sinistra"diff --git a/crates/ui-egui/locales/ja.toml b/crates/ui-egui/locales/ja.toml--- a/crates/ui-egui/locales/ja.toml+++ b/crates/ui-egui/locales/ja.toml@@ -1799,6 +1799,7 @@ status = "draft — needs native review" "⌥ drag" = "⌥ ドラッグ"  [commands]+"file.refresh" = "保存済みファイルを再読み込み" "align.bottom" = "下揃え" "align.hcenter" = "水平方向の中央揃え" "align.left" = "左揃え"diff --git a/crates/ui-egui/locales/ka.toml b/crates/ui-egui/locales/ka.toml--- a/crates/ui-egui/locales/ka.toml+++ b/crates/ui-egui/locales/ka.toml@@ -1814,6 +1814,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "⌥ drag" = "⌥ გადათრევა"  [commands]+"file.refresh" = "შენახული ფაილების ხელახლა ჩატვირთვა" "align.bottom" = "ქვემოთ სწორება" "align.hcenter" = "ჰორიზონტალური ცენტრების სწორება" "align.left" = "მარცხნივ სწორება"diff --git a/crates/ui-egui/locales/ko.toml b/crates/ui-egui/locales/ko.toml--- a/crates/ui-egui/locales/ko.toml+++ b/crates/ui-egui/locales/ko.toml@@ -1839,6 +1839,7 @@ status = "draft — needs native review" "{percent}%" = "{percent}%"  [commands]+"file.refresh" = "저장된 파일 다시 불러오기" "align.bottom" = "아래쪽 정렬" "align.hcenter" = "수평 가운데 정렬" "align.left" = "왼쪽 정렬"diff --git a/crates/ui-egui/locales/nl.toml b/crates/ui-egui/locales/nl.toml--- a/crates/ui-egui/locales/nl.toml+++ b/crates/ui-egui/locales/nl.toml@@ -1817,6 +1817,7 @@ status = "draft — needs native review" "⌥ drag" = "⌥ drag"  [commands]+"file.refresh" = "Opgeslagen bestanden opnieuw laden" "align.bottom" = "Onder uitlijnen" "align.hcenter" = "Horizontale middens uitlijnen" "align.left" = "Links uitlijnen"diff --git a/crates/ui-egui/locales/pl.toml b/crates/ui-egui/locales/pl.toml--- a/crates/ui-egui/locales/pl.toml+++ b/crates/ui-egui/locales/pl.toml@@ -1829,6 +1829,7 @@ name = "Polski" "⌥ drag" = "⌥ przeciągnięcie"  [commands]+"file.refresh" = "Wczytaj ponownie zapisane pliki" "align.bottom" = "Wyrównaj do dołu" "align.hcenter" = "Wyrównaj poziomo do środka" "align.left" = "Wyrównaj do lewej"diff --git a/crates/ui-egui/locales/pt-BR.toml b/crates/ui-egui/locales/pt-BR.toml--- a/crates/ui-egui/locales/pt-BR.toml+++ b/crates/ui-egui/locales/pt-BR.toml@@ -1836,6 +1836,7 @@ status = "draft — needs native review" "{percent}%" = "{percent}%"  [commands]+"file.refresh" = "Recarregar arquivos salvos" "align.bottom" = "Alinhar embaixo" "align.hcenter" = "Alinhar centros horizontalmente" "align.left" = "Alinhar à esquerda"diff --git a/crates/ui-egui/locales/ru.toml b/crates/ui-egui/locales/ru.toml--- a/crates/ui-egui/locales/ru.toml+++ b/crates/ui-egui/locales/ru.toml@@ -1829,6 +1829,7 @@ name = "Русский" "⌥ drag" = "⌥ перетаскивание"  [commands]+"file.refresh" = "Перезагрузить сохранённые файлы" "align.bottom" = "Выровнять по нижнему краю" "align.hcenter" = "Выровнять центры по горизонтали" "align.left" = "Выровнять по левому краю"diff --git a/crates/ui-egui/locales/sv.toml b/crates/ui-egui/locales/sv.toml--- a/crates/ui-egui/locales/sv.toml+++ b/crates/ui-egui/locales/sv.toml@@ -1829,6 +1829,7 @@ name = "Svenska" "⌥ drag" = "⌥ dra"  [commands]+"file.refresh" = "Läs in sparade filer igen"  "align.bottom" = "Justera nederkant" "align.hcenter" = "Justera horisontella mittpunkter"diff --git a/crates/ui-egui/locales/th.toml b/crates/ui-egui/locales/th.toml--- a/crates/ui-egui/locales/th.toml+++ b/crates/ui-egui/locales/th.toml@@ -1805,6 +1805,7 @@ status = "draft — needs native review" "⌥ drag" = "⌥ ลาก"  [commands]+"file.refresh" = "โหลดไฟล์ที่บันทึกไว้อีกครั้ง" "align.bottom" = "จัดแนวชิดล่าง" "align.hcenter" = "จัดแนวกึ่งกลางแนวนอน" "align.left" = "จัดแนวชิดซ้าย"diff --git a/crates/ui-egui/locales/tr.toml b/crates/ui-egui/locales/tr.toml--- a/crates/ui-egui/locales/tr.toml+++ b/crates/ui-egui/locales/tr.toml@@ -1780,6 +1780,7 @@ name = "Türkçe" "⌥ drag" = "⌥ sürükle"  [commands]+"file.refresh" = "Kaydedilen dosyaları yeniden yükle" "align.bottom" = "Alta hizala" "align.hcenter" = "Yatay merkezleri hizala" "align.left" = "Sola hizala"diff --git a/crates/ui-egui/locales/vi.toml b/crates/ui-egui/locales/vi.toml--- a/crates/ui-egui/locales/vi.toml+++ b/crates/ui-egui/locales/vi.toml@@ -1755,6 +1755,7 @@ status = "draft — needs native review" "⌥ drag" = "⌥ kéo"  [commands]+"file.refresh" = "Tải lại các tệp đã lưu" "align.bottom" = "Căn dưới" "align.hcenter" = "Căn tâm ngang" "align.left" = "Căn trái"diff --git a/crates/ui-egui/locales/zh-Hans.toml b/crates/ui-egui/locales/zh-Hans.toml--- a/crates/ui-egui/locales/zh-Hans.toml+++ b/crates/ui-egui/locales/zh-Hans.toml@@ -1732,6 +1732,7 @@ status = "draft — needs native review" "⌥ drag" = "⌥ 拖动"  [commands]+"file.refresh" = "重新加载已保存的文件" "align.bottom" = "底部对齐" "align.hcenter" = "水平居中对齐" "align.left" = "左对齐"diff --git a/docs/control-protocol.md b/docs/control-protocol.md--- a/docs/control-protocol.md+++ b/docs/control-protocol.md@@ -106,6 +106,18 @@ JSON or the gradient shorthand `{"type":"linearGradient","stops":["#hex","#hex"] {"id":3,"method":"engine.execute","params":{"command":"command.batch","params":{"label":"Tidy","commands":[{"command":"align.left","params":{"ids":[2,3]}},{"command":"distribute.vertical","params":{"ids":[2,3]}}]}}} ``` +`file.refresh {automatic?}` reloads externally changed native files that have no local edits, gestures,+text/version previews, live session or open branch tabs. It keeps tab identity, view, and any+selection still present in the replacement, returning `{refreshed: [document uid], errors: [message]}`.+The desktop app uses `automatic: true`: a failed replacement waits 30 seconds before another+attempt, and its deadline wakes the app even if its stamp stays unchanged. An explicit refresh+(the default, `automatic: false`) retries immediately. The UI reports each file/replacement+failure once until recovery or a new stamp.++The desktop app runs this when its file watcher notices a save from another process. Conflicting+local edits stay unsaved: use Revert to saved to load the disk version, or Save as to a different+file to keep both. `file.save {overwrite: true}` explicitly chooses the local version.+ Inside `command.batch`, a step can name itself with `"as"` and later steps use its result: `{"$ref": "name"}` in a step's params stands for the `id` in that step's result, and `{"$step": n}` for the `id` in step `n`'s result (zero-based). Either takes `"field"` for another key or a slashdiff --git a/docs/gaps.md b/docs/gaps.md--- a/docs/gaps.md+++ b/docs/gaps.md@@ -1,6 +1,6 @@ # Where FigCraft falls short of Figma -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (gap 17: branches in live sessions, branch history entries, the list-merge rows brought up to date; gap 6: plugin widgets take text input and a property menu (plugin API 4); gap 24: board templates and tables; gap 24: a shared timer and voting sessions; gap 24: the board dot grid, connector ends dragged to re-attach, curved connectors; egui/eframe 0.37 verified for gap 18; known gaps from the #1311 review in gaps 4, 14, 15 and 16; gap 16: measurements kept on the canvas, a read-only layers panel in Dev Mode; gap 24: boards and their tail; Dev Mode tail: import lines in layer trees, compare with versions, an inspect-only canvas; gap 5: reviewed Turkish, Czech and Indonesian drafts, the Japanese draft completed; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail; the 2026-10-17 landings: `.fig` import fidelity, budgets for large files, the import report, a `.fig` copy from Save as and paste from another design app (gap 1); menus, the Actions palette, editor fixes, Selection colors and smart-selection spacing (gap 4); catalog counts re-measured (gap 5); bounded canvas pan overscan; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail) · **Target:** Figma Design (desktop app 126.10.7)+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-26 · **Change:** minor (gap 18: bounded refresh retries and per-file warning suppression (#1392); saved files refresh across desktop app instances, with local edits kept and conflicting saves refused (#1388); gap 17: branches in live sessions, branch history entries, the list-merge rows brought up to date; gap 6: plugin widgets take text input and a property menu (plugin API 4); gap 24: board templates and tables; gap 24: a shared timer and voting sessions; gap 24: the board dot grid, connector ends dragged to re-attach, curved connectors; egui/eframe 0.37 verified for gap 18; known gaps from the #1311 review in gaps 4, 14, 15 and 16; gap 16: measurements kept on the canvas, a read-only layers panel in Dev Mode; gap 24: boards and their tail; Dev Mode tail: import lines in layer trees, compare with versions, an inspect-only canvas; gap 5: reviewed Turkish, Czech and Indonesian drafts, the Japanese draft completed; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail; the 2026-10-17 landings: `.fig` import fidelity, budgets for large files, the import report, a `.fig` copy from Save as and paste from another design app (gap 1); menus, the Actions palette, editor fixes, Selection colors and smart-selection spacing (gap 4); catalog counts re-measured (gap 5); bounded canvas pan overscan; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail) · **Target:** Figma Design (desktop app 126.10.7)  Every known shortfall against Figma, one entry each, ranked by how much it stops a Figma user from doing real work in FigCraft. Agents: pick work from the top unless the owner says otherwise. The@@ -458,6 +458,8 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe - **Estimate:** 1–2 h.  ### 18. Display P3 on screen (several windows landed)+- **Review follow-up (2026-10-26, #1392):** failed automatic refreshes retry after 30 s without requiring another metadata change, while explicit refresh retries immediately; a file/replacement warning is shown once until recovery or a new stamp.+- **Fixed (2026-10-26, #1388):** saved native files refresh in other desktop app instances when their tabs have no local edits or active previews. Conflicting saves refuse on every native save path, path aliases reuse an open tab and Save as cannot overwrite another open tab's file. Independent processes still need a live session to share unsaved edits. - **Landed (2026-10-11):** several windows: File ▸ New window, a tab's Open in new window or a tab   dragged off the strip; each window has its own tabs, panels and canvas and draws only when it   needs to; closing one asks about unsaved changes; `window.new`, `window.list`, `window.focus` and@@ -519,6 +521,8 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe  | Date | Change | Summary | |---|---|---|+| 2026-10-26 | minor | Gap 18, #1392: bounded automatic retries recover same-stamp read failures; warnings are deduplicated per file and failed replacement, clearing on recovery or a new stamp. No parity or estimate changes |+| 2026-10-26 | minor | Gap 18: saved files refresh across desktop app instances (#1388), retaining clean tabs' identity and view; local edits and busy tabs stay intact and conflicting saves are refused. Path aliases reuse a tab and Save as cannot overwrite another open tab's file. No parity or estimate changes | | 2026-10-25 | minor | egui/eframe 0.37 verified for gap 18: the remaining limitations are unchanged. egui-wgpu 0.37 still picks an 8-bit sRGB surface with no colour-space option, so P3 colours outside sRGB still clip on screen; eframe 0.37 still gives only the main window an AccessKit adapter, so other windows have no screen-reader tree. Numbers unchanged | | 2026-10-25 | minor | Known gaps from the #1311 review: gap 4, the export preview follows a slice's first export setting; gap 14, the scopes a paint style's color takes are FigCraft's own rule; gap 15, `proto.linkNotice` from the palette or control channel isn't saved until the next preferences save; gap 16, the file name field still renames in Dev Mode (to check against Figma first). Ranking and hours unchanged | | 2026-10-24 | minor | Gap 24: the dot grid on board pages, dragging a connector's end to re-attach it, curved connectors and elbows around the objects they join landed; connector waypoints placed by hand stay missing. 30–60 h → 28–56 h; the ranking is unchanged |diff --git a/docs/hardware-parity.md b/docs/hardware-parity.md--- a/docs/hardware-parity.md+++ b/docs/hardware-parity.md@@ -1,6 +1,6 @@ # Hardware and platform parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (egui/eframe 0.37 dependency migration; hidden-window screenshot deadlines; zoom previews; hidden windows answer control requests; macOS file opening, the Windows start-up message, Linux packaging; copy and paste in the browser; Windows QA pass; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; pen pressure for the Pencil and brushes on Windows and from browser styluses; macOS: quitting ends the process once everything is kept, avoiding the Touch Bar teardown crash (#781); Linux and BSD clipboard: images under Wayland, reads time out; minimised windows under Wayland; window controls on Wayland without server decorations; canvas textures on device pixels; copy and paste in the browser; Windows QA pass; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; pen pressure for the Pencil and brushes on Windows and from browser styluses) · **Target:** Figma Design (desktop app 126.10.7)+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-26 · **Change:** minor (bounded external-refresh retries and per-file warning suppression (#1392); saved files refresh across desktop app instances; conflicting saves refused (#1388); egui/eframe 0.37 dependency migration; hidden-window screenshot deadlines; zoom previews; hidden windows answer control requests; macOS file opening, the Windows start-up message, Linux packaging; copy and paste in the browser; Windows QA pass; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; pen pressure for the Pencil and brushes on Windows and from browser styluses; macOS: quitting ends the process once everything is kept, avoiding the Touch Bar teardown crash (#781); Linux and BSD clipboard: images under Wayland, reads time out; minimised windows under Wayland; window controls on Wayland without server decorations; canvas textures on device pixels; copy and paste in the browser; Windows QA pass; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; pen pressure for the Pencil and brushes on Windows and from browser styluses) · **Target:** Figma Design (desktop app 126.10.7)  How FigCraft uses hardware and which platforms it runs on, against Figma. Figma's desktop app is Electron with a GPU helper process; its canvas renders on the GPU (WebGL, with WebGPU rolling out@@ -18,7 +18,7 @@ since 2025). FigCraft renders the canvas on the CPU and presents it through wgpu | Wide-gamut display (Display P3) | yes, on capable displays | colours stored and exported in P3; the screen shows sRGB: eframe/egui-wgpu 0.37 configure an 8-bit sRGB surface with no colour space option, although wgpu 30 can present Display P3 or extended sRGB on Metal, DX12, Vulkan and WebGPU (see gaps #18). The colour picker warns about colours outside sRGB and View ▸ Soft-proof sRGB stripes them on the canvas | 2–5 | | Trackpad pinch zoom and two-finger pan | yes | yes (egui zoom delta, smooth scroll) | — | | Pen tablet pressure (pencil, Figma Draw brushes) | yes (forum reports of regressions in 2025) | Windows pens (winit pointer input) and browser styluses that send touch events with a force (an Apple Pencil in Safari); the Pencil records it as the stroke's width profile. A mouse, macOS and Linux tablets and pens sending only pointer events in a browser report none: constant width | 1–3 |-| Several windows / multi-monitor | yes (a window per file; presenter in its own window) | desktop: File ▸ New window, a tab's Open in new window or a tab dragged off the strip; each window has its own tabs, panels and canvas, draws only when it needs to and asks about unsaved changes when it closes; a minimised or covered window still answers control and MCP requests, while screenshots with hidden or unknown visibility request a hidden paint and expire at their 60-second admission deadline if unfinished; injected pointer/key/text input waits for a real UI frame (#313, #332, #720). Browser and Wayland hidden-paint behavior remain unqualified. Only the main window has a screen-reader tree (eframe 0.37), so files stay tabs while a screen reader runs; positions aren't restored at the next start; the presenter stays inside its window; one window in the browser | 1–2 |+| Several windows / multi-monitor | yes (a window per file; presenter in its own window) | desktop: File ▸ New window, a tab's Open in new window or a tab dragged off the strip; each window has its own tabs, panels and canvas, draws only when it needs to and asks about unsaved changes when it closes; a minimised or covered window still answers control and MCP requests, while screenshots with hidden or unknown visibility request a hidden paint and expire at their 60-second admission deadline if unfinished; injected pointer/key/text input waits for a real UI frame (#313, #332, #720). Browser and Wayland hidden-paint behavior remain unqualified. Saved native files refresh in other app instances when clean and idle, keeping their tab and view; failed replacements retry after 30 s even without another metadata change and warn once per file/replacement; conflicting saves refuse, including explicit paths and Save as to the same file (#1388). Unsaved edits between independent processes need a live session. Only the main window has a screen-reader tree (eframe 0.37), so files stay tabs while a screen reader runs; positions aren't restored at the next start; the presenter stays inside its window; one window in the browser | 1–2 | | Screen readers | partial | AccessKit tree on desktop (names, roles, states), never tested with a real screen reader; none in the browser | 4–8 + human QA | | Keyboard layouts (non-US) | yes | keymap uses logical keys; not checked on AZERTY/QWERTZ/JIS | 1–2 | @@ -50,6 +50,8 @@ Nothing has been timed against Figma on the same document (needs `.fig` import).  | Date | Change | Summary | |---|---|---|+| 2026-10-26 | minor | #1392: failed external refreshes have a 30 s automatic retry, including unchanged stamps, and warnings are deduplicated per file/replacement. Explicit refresh bypasses the deadline. Numbers unchanged |+| 2026-10-26 | minor | Saved native files refresh across desktop app instances (#1388), without redrawing stable files. Local edits, active gestures/previews, live documents and open branch tabs are preserved; all native save paths check external conflicts. Path aliases reuse a tab and Save as cannot replace another open tab's file. Numbers unchanged | | 2026-10-25 | minor | egui/eframe 0.37 dependency migration: the workspace pins the egui family at 0.37, and the wide-gamut and several-windows rows now cite 0.37. The limitations are unchanged: egui-wgpu 0.37 offers no surface colour-space option, so the screen still shows sRGB; eframe 0.37 still gives only the main window an AccessKit adapter, so files stay tabs while a screen reader runs. Numbers unchanged | | 2026-10-25 | minor | Hidden or unknown-visibility screenshots request a hidden paint and expire at their 60-second admission deadline if unfinished; injected pointer/key/text input waits for a real UI frame. Browser and Wayland hidden-paint behavior remain unqualified. Numbers unchanged. | | 2026-10-15 | minor | Community fixes (#290, #306, #312, #313, #328, #332, #346, #474): coarse zoom previews when renders from scratch are slow; a minimised or covered window answers control and MCP requests (screenshots and input fail at once while it is hidden); `.figcraft` files open from Finder on macOS; a message box when the window can't open on Windows; on Linux the window id matches the desktop entry, the RPM requires glibc 2.35 and user fonts under `XDG_DATA_HOME` are found. Numbers unchanged |