diff --git a/contributors/people.toml b/contributors/people.toml
--- a/contributors/people.toml
+++ b/contributors/people.toml
@@ -140,3 +140,7 @@ sync_github_name = false
 
 [people.KaboomFox]
 sync_github_name = true
+
+[people.Bryan-Legend]
+real_name = "Bryan Livingston"
+display_name = "Bryan Livingston"
diff --git a/crates/engine/src/cmd/fileio/mod.rs b/crates/engine/src/cmd/fileio/mod.rs
--- a/crates/engine/src/cmd/fileio/mod.rs
+++ b/crates/engine/src/cmd/fileio/mod.rs
@@ -71,6 +71,7 @@ pub(crate) use native::preview_png;
 pub use native::with_compression_pref;
 pub use pdfimport::{LoadOptions, page_document};
 pub(crate) use save::job_for;
+pub use save::reload_with;
 pub use save::{
     SAVE_FORMATS, SaveJob, SaveMode, SavePlan, StreamWrite, TEMPLATE_FORMATS, export_folder, save_filters, save_format, save_job, save_plan,
     save_with, stamp_save_dates, template_filters, templates_dialog_folder, templates_folder,
diff --git a/crates/engine/src/cmd/fileio/save.rs b/crates/engine/src/cmd/fileio/save.rs
--- a/crates/engine/src/cmd/fileio/save.rs
+++ b/crates/engine/src/cmd/fileio/save.rs
@@ -690,6 +690,10 @@ impl SaveJob {
         let written = self.plan.path.clone().filter(|_| matches!(self.plan.format.id, "vectorcraft" | "template"));
         if let Some(st) = s.document_mut(uid) {
             self.finish(st);
+            // The file just written is its own, not a change made by another app.
+            if saved {
+                st.file_version += 1;
+            }
         }
         if saved {
             crate::cmd::recovery::forget(s, uid);
@@ -729,17 +733,34 @@ fn can_revert(s: &Session) -> std::result::Result<(), String> {
     }
 }
 
-/// File → Revert: read and decode the saved file first (on any failure the document is left as it
-/// is), then replace the document in its tab.
+/// File → Revert: see [`reload`].
 fn revert(s: &mut Session, _: &Value) -> Result<Value> {
     let index = s.active_index().ok_or(crate::EngineError::NoDocument)?;
-    let path = s.doc()?.path.clone().ok_or_else(|| bad("file.revert", "the document has never been saved"))?;
-    let Loaded { mut doc, .. } = load(&path, &read_file(&path)?)?.with_grid_prefs(&s.prefs);
+    reload(s, index, "file.revert")
+}
+
+/// Read the file of the document at `index` again (File → Revert): see [`reload_with`].
+fn reload(s: &mut Session, index: usize, command: &str) -> Result<Value> {
+    let path = s.documents().get(index).ok_or(crate::EngineError::NoDocument)?.path.clone();
+    let path = path.ok_or_else(|| bad(command, "the document has never been saved"))?;
+    reload_with(s, index, &read_file(&path)?)
+}
+
+/// Replace the document at `index` with `bytes`, its own file as it is now (read again by Revert,
+/// or by a file watcher when another app changed it): decode them first (on any failure the
+/// document is left as it is), then replace the document in its tab, which keeps its place and
+/// view; the history is cleared → {path}.
+pub fn reload_with(s: &mut Session, index: usize, bytes: &[u8]) -> Result<Value> {
+    let st = s.documents().get(index).ok_or(crate::EngineError::NoDocument)?;
+    let (path, uid) = (st.path.clone().ok_or_else(|| bad("file.revert", "the document has never been saved"))?, st.uid);
+    let Loaded { mut doc, .. } = load(&path, bytes)?.with_grid_prefs(&s.prefs);
     doc.template = false;
     // Back to the saved file: its recovery copy has nothing left to recover.
-    let uid = s.doc()?.uid;
     crate::cmd::recovery::forget(s, uid);
     s.replace_document(index, doc);
+    if let Some(st) = s.document_mut(uid) {
+        st.file_version += 1;
+    }
     Ok(json!({ "path": path }))
 }
 
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
@@ -381,6 +381,10 @@ pub struct DocState {
     /// Transform Again after a perspective move or scale (Perspective Selection tool): the
     /// `perspective.transform` params it repeats. `None` once an ordinary transform follows.
     pub last_perspective: Option<Value>,
+    /// Moves on whenever VectorCraft itself writes or reads again the document's own file (a save
+    /// to it, Save As, Revert, a reload of a version another app wrote): a file watcher's finding
+    /// from before is older than the document, and the file's state now is VectorCraft's own.
+    pub file_version: u64,
 }
 
 static NEXT_DOC_UID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
@@ -553,6 +557,7 @@ impl DocState {
             imported_from: None,
             import_losses: vec![],
             last_perspective: None,
+            file_version: 0,
         }
     }
     /// Unsaved changes: the document differs from the saved one (selection changes and changes that
@@ -1525,6 +1530,7 @@ impl Session {
         st.import_losses = old.import_losses.clone();
         st.view = old.view.clone();
         st.layers_open = old.layers_open.clone();
+        st.file_version = old.file_version;
         let old = std::mem::replace(&mut self.docs[index], st);
         if let Some(stash) = &mut self.batch_stash {
             stash.push(old);
diff --git a/crates/engine/src/tests_save.rs b/crates/engine/src/tests_save.rs
--- a/crates/engine/src/tests_save.rs
+++ b/crates/engine/src/tests_save.rs
@@ -163,6 +163,39 @@ fn revert_reloads_the_saved_file_in_the_same_tab() {
     let _ = std::fs::remove_dir_all(d);
 }
 
+/// `file_version` moves on when VectorCraft writes or reads again the document's own file, and only
+/// then: the desktop app's file watcher tells VectorCraft's own saves from another app's changes by it.
+#[test]
+fn the_file_version_moves_on_with_the_documents_own_file() {
+    let d = dir("fileversion");
+    let mut s = session();
+    let version = |s: &Session| s.doc().unwrap().file_version;
+    rect(&mut s);
+    assert_eq!(version(&s), 0, "an edit leaves the file alone");
+    s.execute("document.save", &json!({"path": path(&d, "a.vectorcraft")})).unwrap();
+    assert_eq!(version(&s), 1);
+    s.execute("file.saveCopy", &json!({"path": path(&d, "copy.vectorcraft")})).unwrap();
+    assert_eq!(version(&s), 1, "a copy is another file");
+    rect(&mut s);
+    s.execute("file.revert", &json!({})).unwrap();
+    assert_eq!(version(&s), 2);
+    let svg = path(&d, "b.svg");
+    s.execute("file.saveAs", &json!({"path": svg})).unwrap();
+    assert_eq!(version(&s), 3);
+    // A version another app wrote, read by the watcher: replaced in the same tab.
+    let (index, uid) = (s.active_index().unwrap(), s.doc().unwrap().uid);
+    let other =
+        r#"<svg xmlns="http://www.w3.org/2000/svg" width="200" height="100"><rect width="5" height="5"/><rect x="9" width="5" height="5"/></svg>"#;
+    crate::cmd::fileio::reload_with(&mut s, index, other.as_bytes()).unwrap();
+    assert_eq!((version(&s), s.doc().unwrap().uid), (4, uid));
+    assert!(!s.doc().unwrap().is_dirty());
+    // Bytes that can't be read leave the document and its version as they are.
+    let before = objects(&s);
+    assert!(crate::cmd::fileio::reload_with(&mut s, index, b"<svg").is_err());
+    assert_eq!((version(&s), objects(&s)), (4, before));
+    let _ = std::fs::remove_dir_all(d);
+}
+
 #[test]
 fn templates_suggest_a_name_in_the_templates_folder_and_open_untitled() {
     let d = dir("tpl");
diff --git a/crates/ui-egui/src/file_watch.rs b/crates/ui-egui/src/file_watch.rs
new file mode 100644
--- /dev/null
+++ b/crates/ui-egui/src/file_watch.rs
@@ -0,0 +1,437 @@
+//! Reload a document when its own file changes on disk (#3282): a script, a build or an agent
+//! rewriting the SVG that is open shows its new version without reopening it. Modelled on
+//! PdfCraft's watcher (`pdfcraft/crates/ui-egui/src/file_watch.rs`), so the Crafting Apps behave
+//! alike.
+//!
+//! Cheap by design. One worker thread looks at each open document's file metadata (no reading)
+//! every [`IDLE`], and blocks without waking at all while no open document has a file. A changed
+//! file is read only once it has stopped changing ([`SETTLE`] apart) and, for SVG and PDF, ends as
+//! a whole file does: its last KiB is read first, so a file still being written costs one small
+//! read. The new bytes go to the UI thread, which is woken for them: a document without unsaved
+//! changes is reloaded in place (same tab and view, history cleared, as File › Revert does); one
+//! with unsaved changes asks first, when it is in front, and Cancel keeps them. A version whose
+//! contents are the same as before (only its metadata changed: `touch`, `chmod`, a sync or backup
+//! tool's extended attributes) isn't a change: the watcher keeps a hash of the contents, taken
+//! once when it starts watching a version VectorCraft read or wrote.
+//!
+//! VectorCraft's own saves move the document's [`file_version`](vectorcraft_engine::DocState::file_version)
+//! on: the file is then stamped as it is, so they never reload, and a version the watcher found
+//! before is older than the document and isn't applied. Documents restored by Data Recovery
+//! aren't watched: their file isn't what they hold.
+
+use std::collections::HashMap;
+use std::fs::{File, Metadata};
+use std::io::{Read, Seek, SeekFrom};
+use std::sync::mpsc::{self, RecvTimeoutError};
+use std::sync::{Arc, Mutex, PoisonError};
+use std::time::{Duration, SystemTime};
+
+use vectorcraft_engine::cmd::fileio;
+
+use crate::VectorcraftApp;
+
+/// How often an unchanged file is looked at.
+pub const IDLE: Duration = Duration::from_millis(250);
+/// How long a changed file must stay the same before it is read: writers pause between chunks.
+pub const SETTLE: Duration = Duration::from_millis(40);
+/// How much of the end of a changed SVG or PDF is read to see whether it is a whole file.
+const TAIL: usize = 1024;
+
+/// One version of a file, told apart without reading it: its length and modification time, and
+/// what the platform adds (Unix: device, inode and change time; Windows: creation and last-write
+/// times in 100 ns ticks). The modification time alone is coarse on FAT and some network
+/// drives, and a rewrite can keep the length.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub struct Stamp {
+    len: u64,
+    modified: Option<SystemTime>,
+    extra: [u64; 4],
+}
+
+impl Stamp {
+    fn of(meta: &Metadata) -> Self {
+        #[cfg(unix)]
+        let extra = {
+            use std::os::unix::fs::MetadataExt;
+            [meta.dev(), meta.ino(), meta.ctime().cast_unsigned(), meta.ctime_nsec().cast_unsigned()]
+        };
+        #[cfg(windows)]
+        let extra = {
+            use std::os::windows::fs::MetadataExt;
+            [meta.creation_time(), meta.last_write_time(), 0, 0]
+        };
+        #[cfg(not(any(unix, windows)))]
+        let extra = [0; 4];
+        Self { len: meta.len(), modified: meta.modified().ok(), extra }
+    }
+
+    /// The file at `path` now; `None` when it is missing, not a file or can't be looked at.
+    pub fn at(path: &str) -> Option<Self> {
+        std::fs::metadata(path).ok().filter(Metadata::is_file).map(|m| Self::of(&m))
+    }
+}
+
+/// A file to watch, as the UI thread sees it.
+#[derive(Clone, Debug, PartialEq)]
+pub struct Watched {
+    /// The document's uid.
+    pub doc: u64,
+    pub path: String,
+    /// The version VectorCraft just read or wrote (`None`: keep the watcher's own; the first
+    /// version the watcher sees of a file it didn't watch).
+    pub known: Option<Stamp>,
+    /// The document's `file_version` it is watched for.
+    pub version: u64,
+}
+
+/// A new version of a watched file, read and ready to open.
+#[derive(Clone, Debug)]
+pub struct Changed {
+    pub doc: u64,
+    /// The file it was read from, and the `file_version` it was watched for (see [`Watched`]).
+    pub path: String,
+    pub version: u64,
+    pub stamp: Stamp,
+    pub bytes: Arc<Vec<u8>>,
+}
+
+/// The watcher's record of one file.
+struct Entry {
+    doc: u64,
+    path: String,
+    version: u64,
+    known: Option<Stamp>,
+    /// A changed version seen once, waiting to be seen unchanged again.
+    pending: Option<Stamp>,
+    /// A hash of the contents last read: of the version VectorCraft read or wrote, then of each
+    /// new version found. `None`: not read yet, or couldn't be.
+    content: Option<u64>,
+    /// The version VectorCraft read or wrote was looked at for `content` (read, or found unreadable).
+    baseline: bool,
+}
+
+/// Looks at the watched files: on the worker thread, or on the UI thread in tests.
+#[derive(Default)]
+pub struct Watcher {
+    files: Vec<Entry>,
+}
+
+impl Watcher {
+    /// Watch `list` from now on. A file watched already for the same version keeps what it knows
+    /// and its pending change; a version the UI thread knows (it just read or wrote the file)
+    /// replaces the watcher's own.
+    pub fn set(&mut self, list: Vec<Watched>) {
+        let old = std::mem::take(&mut self.files);
+        self.files = list
+            .into_iter()
+            .map(|w| {
+                // A version the UI thread knows is read again for `content`.
+                let prev = old.iter().find(|e| e.doc == w.doc && e.path == w.path && e.version == w.version).filter(|_| w.known.is_none());
+                Entry {
+                    known: w.known.or(prev.and_then(|e| e.known)),
+                    pending: prev.and_then(|e| e.pending),
+                    content: prev.and_then(|e| e.content),
+                    baseline: prev.is_some_and(|e| e.baseline),
+                    doc: w.doc,
+                    path: w.path,
+                    version: w.version,
+                }
+            })
+            .collect();
+    }
+
+    /// Look at every file once: the new versions found, and whether a file is still changing
+    /// (look again after [`SETTLE`] rather than [`IDLE`]).
+    pub fn tick(&mut self) -> (Vec<Changed>, bool) {
+        let mut found = Vec::new();
+        let mut settling = false;
+        for e in &mut self.files {
+            // Missing (deleted, or being replaced) or unreadable: the document stays as it is.
+            let Some(now) = Stamp::at(&e.path) else {
+                e.pending = None;
+                continue;
+            };
+            let known = *e.known.get_or_insert(now);
+            if now == known {
+                e.pending = None;
+                // Hash the version VectorCraft has, once, so that a later change of its metadata
+                // alone can be told apart from a change of its contents.
+                if !e.baseline {
+                    match read_whole(&e.path, now, false) {
+                        Version::Ready(bytes) => (e.content, e.baseline) = (Some(hash(&bytes)), true),
+                        Version::Incomplete => e.baseline = true,
+                        Version::Busy => {}
+                    }
+                }
+                continue;
+            }
+            if e.pending != Some(now) {
+                // Changed since the last look: read it once it stops changing.
+                e.pending = Some(now);
+                settling = true;
+                continue;
+            }
+            e.pending = None;
+            match read_whole(&e.path, now, true) {
+                Version::Ready(bytes) => {
+                    (e.known, e.baseline) = (Some(now), true);
+                    // Only its metadata changed: the same contents as the version before.
+                    let content = hash(&bytes);
+                    if e.content == Some(content) {
+                        continue;
+                    }
+                    e.content = Some(content);
+                    found.push(Changed { doc: e.doc, path: e.path.clone(), version: e.version, stamp: now, bytes });
+                }
+                // Not a whole file in this version (a writer that failed or paused): wait for the
+                // next one.
+                Version::Incomplete => e.known = Some(now),
+                // Changed again while being read, or held by its writer: look again soon.
+                Version::Busy => settling = true,
+            }
+        }
+        (found, settling)
+    }
+}
+
+/// What a changed file turned out to be.
+enum Version {
+    /// A whole file, read.
+    Ready(Arc<Vec<u8>>),
+    /// Not a whole file: a writer that failed or paused.
+    Incomplete,
+    /// Changing, replaced or held by its writer: look again.
+    Busy,
+}
+
+/// The text a whole file of `path`'s format ends with (within its last KiB), when it has one:
+/// `</svg>` for SVG, `%%EOF` for PDF and PDF-compatible `.ai`. Other formats are read as they are.
+fn ending(path: &str) -> Option<&'static [u8]> {
+    let ext = std::path::Path::new(path).extension()?.to_str()?.to_ascii_lowercase();
+    match ext.as_str() {
+        "svg" => Some(b"</svg>"),
+        "pdf" | "ai" => Some(b"%%EOF"),
+        _ => None,
+    }
+}
+
+/// A hash of a file's contents, to tell a version whose metadata alone changed.
+fn hash(bytes: &[u8]) -> u64 {
+    use std::hash::{Hash, Hasher};
+    let mut h = std::collections::hash_map::DefaultHasher::new();
+    bytes.hash(&mut h);
+    h.finish()
+}
+
+/// Read `path` if it is still the version `stamp` and, with `whole`, for SVG and PDF, ends as a
+/// whole file does (its last KiB read first). The whole file is then read into a buffer of exactly
+/// its size.
+fn read_whole(path: &str, stamp: Stamp, whole: bool) -> Version {
+    // Windows refuses to open a file its writer holds exclusively: not ready yet.
+    let Ok(mut file) = File::open(path) else { return Version::Busy };
+    let Ok(meta) = file.metadata() else { return Version::Busy };
+    if Stamp::of(&meta) != stamp {
+        return Version::Busy;
+    }
+    let len = meta.len();
+    if whole && let Some(ending) = ending(path) {
+        let mut end = [0u8; TAIL];
+        let tail = usize::try_from(len).map_or(TAIL, |l| l.min(TAIL));
+        let (Some(end), Ok(back)) = (end.get_mut(..tail), i64::try_from(tail)) else { return Version::Incomplete };
+        if file.seek(SeekFrom::End(-back)).is_err() || file.read_exact(end).is_err() {
+            return Version::Busy;
+        }
+        if !end.windows(ending.len()).any(|w| w == ending) {
+            return Version::Incomplete;
+        }
+    }
+    let mut bytes = Vec::new();
+    // Too big to hold: the open version stays.
+    let Ok(size) = usize::try_from(len) else { return Version::Incomplete };
+    if bytes.try_reserve_exact(size).is_err() {
+        return Version::Incomplete;
+    }
+    if file.seek(SeekFrom::Start(0)).is_err() || file.read_to_end(&mut bytes).is_err() {
+        return Version::Busy;
+    }
+    // Still that version, under that name: a writer may have replaced the file meanwhile.
+    if bytes.len() != size || Stamp::at(path) != Some(stamp) {
+        return Version::Busy;
+    }
+    Version::Ready(Arc::new(bytes))
+}
+
+/// The worker thread: watches the newest list the UI thread sent and hands back new versions,
+/// waking the UI for them. It ends when the app (its inbox's sender) is gone.
+fn run(inbox: mpsc::Receiver<Vec<Watched>>, out: Arc<Mutex<Vec<Changed>>>, ctx: egui::Context) {
+    let mut watcher = Watcher::default();
+    // `None`: nothing to watch, so block until there is.
+    let mut wait = None;
+    loop {
+        let message = match wait {
+            None => inbox.recv().map_err(|_| RecvTimeoutError::Disconnected),
+            Some(wait) => inbox.recv_timeout(wait),
+        };
+        match message {
+            Ok(list) => watcher.set(list),
+            Err(RecvTimeoutError::Timeout) => {}
+            Err(RecvTimeoutError::Disconnected) => return,
+        }
+        while let Ok(list) = inbox.try_recv() {
+            watcher.set(list);
+        }
+        if watcher.files.is_empty() {
+            wait = None;
+            continue;
+        }
+        let (found, settling) = watcher.tick();
+        if !found.is_empty() {
+            out.lock().unwrap_or_else(PoisonError::into_inner).extend(found);
+            ctx.request_repaint();
+        }
+        wait = Some(if settling { SETTLE } else { IDLE });
+    }
+}
+
+/// The app's side of the watcher.
+#[derive(Default)]
+pub struct FileWatch {
+    /// The worker's inbox (`None` until a document with a file is open).
+    inbox: Option<mpsc::Sender<Vec<Watched>>>,
+    found: Arc<Mutex<Vec<Changed>>>,
+    /// What the watcher was last told: (document uid, path, file version).
+    sent: Vec<(u64, String, u64)>,
+    /// In tests there is no thread: each frame looks once with this.
+    pub inline: Option<Watcher>,
+    /// New versions not acted on yet: a dialog is open, the document is being dragged in, or a
+    /// save of it is being written.
+    held: Vec<Changed>,
+    /// The version each document was just reloaded from: what the watcher is told it knows, not
+    /// the file as it is by then (another change may have come meanwhile).
+    reloaded: HashMap<u64, Stamp>,
+    /// Documents with unsaved changes whose file changed: asked about when they are in front.
+    ask: HashMap<u64, Changed>,
+}
+
+impl VectorcraftApp {
+    /// Every frame (desktop): tell the watcher about documents opened, closed and saved (without
+    /// allocating when nothing changed), and act on the files that changed on disk.
+    pub(crate) fn tick_file_watch(&mut self, ctx: &egui::Context) {
+        let watched = || self.session.documents().iter().filter(|d| !d.recovered).filter_map(|d| Some((d.uid, d.path.as_deref()?, d.file_version)));
+        let mut sent = self.file_watch.sent.iter();
+        let unchanged =
+            watched().all(|(uid, path, version)| sent.next().is_some_and(|s| s.0 == uid && s.1 == path && s.2 == version)) && sent.next().is_none();
+        if !unchanged {
+            let now: Vec<(u64, String, u64)> = watched().map(|(uid, path, version)| (uid, path.to_owned(), version)).collect();
+            let list = now
+                .iter()
+                .map(|(uid, path, version)| {
+                    let same = self.file_watch.sent.iter().any(|s| s.0 == *uid && s.1 == *path && s.2 == *version);
+                    // Opened, saved or read again since the watcher was last told: that version
+                    // is VectorCraft's own.
+                    let known = if same { None } else { self.file_watch.reloaded.remove(uid).or_else(|| Stamp::at(path)) };
+                    Watched { doc: *uid, path: path.clone(), known, version: *version }
+                })
+                .collect();
+            let open: Vec<u64> = self.session.documents().iter().map(|d| d.uid).collect();
+            self.file_watch.reloaded.retain(|uid, _| open.contains(uid));
+            self.file_watch.ask.retain(|uid, _| open.contains(uid));
+            if self.send_to_watcher(list, ctx) {
+                self.file_watch.sent = now;
+            }
+        }
+        if let Some(inline) = &mut self.file_watch.inline {
+            let (found, _) = inline.tick();
+            self.file_watch.found.lock().unwrap_or_else(PoisonError::into_inner).extend(found);
+        }
+        let mut found = std::mem::take(&mut self.file_watch.held);
+        found.extend(std::mem::take(&mut *self.file_watch.found.lock().unwrap_or_else(PoisonError::into_inner)));
+        for c in found {
+            // Not while a dialog may be working on the document, during a drag, or while a save of
+            // it is being written (it then makes this version stale).
+            if self.ui.dialog.is_some() || self.session.in_interaction() || self.background.busy_with(c.doc) {
+                self.file_watch.held.push(c);
+            } else {
+                self.file_changed(c);
+            }
+        }
+        self.ask_about_changed_file();
+    }
+
+    /// Hand `list` to the watcher, starting it when needed. `false`: not delivered (the worker
+    /// couldn't start or ended); the next frame tries again.
+    fn send_to_watcher(&mut self, list: Vec<Watched>, ctx: &egui::Context) -> bool {
+        if let Some(inline) = &mut self.file_watch.inline {
+            inline.set(list);
+            return true;
+        }
+        if self.file_watch.inbox.is_none() {
+            if list.is_empty() {
+                return true;
+            }
+            let (tx, rx) = mpsc::channel();
+            let (out, ctx) = (self.file_watch.found.clone(), ctx.clone());
+            if std::thread::Builder::new().name("vectorcraft-watch".into()).spawn(move || run(rx, out, ctx)).is_err() {
+                return false;
+            }
+            self.file_watch.inbox = Some(tx);
+        }
+        let delivered = self.file_watch.inbox.as_ref().is_some_and(|tx| tx.send(list).is_ok());
+        if !delivered {
+            // The worker ended: start a new one next frame.
+            self.file_watch.inbox = None;
+        }
+        delivered
+    }
+
+    /// A watched file has a new version: reload its document, or ask first when it has unsaved
+    /// changes.
+    fn file_changed(&mut self, c: Changed) {
+        let docs = self.session.documents();
+        let Some(index) = docs.iter().position(|d| d.uid == c.doc) else { return };
+        let Some(st) = docs.get(index) else { return };
+        // Found for an earlier reading or writing of the file, or for another file than the
+        // document's now (a save, Save As or a reload came between): older than the document.
+        if st.path.as_deref() != Some(c.path.as_str()) || st.file_version != c.version || st.recovered {
+            return;
+        }
+        if st.is_dirty() {
+            self.file_watch.ask.insert(c.doc, c);
+            return;
+        }
+        self.reload_changed(index, c);
+    }
+
+    /// Replace the document at `index` with the version `c` of its file.
+    fn reload_changed(&mut self, index: usize, c: Changed) {
+        match fileio::reload_with(&mut self.session, index, &c.bytes) {
+            Ok(_) => {
+                self.file_watch.reloaded.insert(c.doc, c.stamp);
+                self.status(format!("Reloaded {}: the file changed on disk", fileio::file_name(&c.path)));
+            }
+            // Can't be read (broken, or written in a way the watcher couldn't tell was unfinished):
+            // the document stays as it is until the file changes again.
+            Err(e) => self.status(e.to_string()),
+        }
+    }
+
+    /// The document in front has unsaved changes and its file changed: ask whether to read it
+    /// again ([`crate::io::ask_reload_changed`]), once no dialog is open. A document saved or read
+    /// again meanwhile has nothing to ask; one whose changes were undone meanwhile just reloads.
+    fn ask_about_changed_file(&mut self) {
+        if self.ui.dialog.is_some() {
+            return;
+        }
+        let Some(index) = self.session.active_index() else { return };
+        let Some(st) = self.session.documents().get(index) else { return };
+        let Some(c) = self.file_watch.ask.remove(&st.uid) else { return };
+        if st.file_version != c.version || st.path.as_deref() != Some(c.path.as_str()) {
+            return;
+        }
+        if st.is_dirty() {
+            crate::io::ask_reload_changed(self);
+        } else {
+            self.reload_changed(index, c);
+        }
+    }
+}
diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv
--- a/crates/ui-egui/src/i18n/bg.tsv
+++ b/crates/ui-egui/src/i18n/bg.tsv
@@ -5233,3 +5233,5 @@ theme	Appearance	Външен вид
 	Plaster	Гипс
 	Note Paper…	Хартия за писма…
 	Note Paper	Хартия за писма
+	“{name}” changed on disk. Reload it?	„{name}“ е променен на диска. Да се презареди ли?
+@msg	Reloaded {_1}: the file changed on disk	{_1} е презареден: файлът е променен на диска
diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv
--- a/crates/ui-egui/src/i18n/cs.tsv
+++ b/crates/ui-egui/src/i18n/cs.tsv
@@ -5273,3 +5273,5 @@ mark-style	Japanese	Japonské
 	Plaster	Sádra
 	Note Paper…	Dopisní papír…
 	Note Paper	Dopisní papír
+	“{name}” changed on disk. Reload it?	Dokument „{name}“ se změnil na disku. Načíst ho znovu?
+@msg	Reloaded {_1}: the file changed on disk	Znovu načteno: {_1} (soubor se změnil na disku)
diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv
--- a/crates/ui-egui/src/i18n/da.tsv
+++ b/crates/ui-egui/src/i18n/da.tsv
@@ -5251,3 +5251,5 @@ swatch	Bright Violet	Kraftig violet
 	Plaster	Gips
 	Note Paper…	Notatpapir…
 	Note Paper	Notatpapir
+	“{name}” changed on disk. Reload it?	»{name}« er ændret på disken. Vil du genindlæse den?
+@msg	Reloaded {_1}: the file changed on disk	Genindlæste {_1}: filen blev ændret på disken
diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv
--- a/crates/ui-egui/src/i18n/de.tsv
+++ b/crates/ui-egui/src/i18n/de.tsv
@@ -4854,3 +4854,5 @@ effect	Soft	Weich
 	Plaster	Gipsabdruck
 	Note Paper…	Prägepapier…
 	Note Paper	Prägepapier
+	“{name}” changed on disk. Reload it?	„{name}“ wurde auf dem Datenträger geändert. Neu laden?
+@msg	Reloaded {_1}: the file changed on disk	{_1} neu geladen: Die Datei wurde auf dem Datenträger geändert
diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv
--- a/crates/ui-egui/src/i18n/el.tsv
+++ b/crates/ui-egui/src/i18n/el.tsv
@@ -5225,3 +5225,5 @@ effect	Points	Σημεία
 	Plaster	Γύψος
 	Note Paper…	Χαρτί σημειώσεων…
 	Note Paper	Χαρτί σημειώσεων
+	“{name}” changed on disk. Reload it?	Το «{name}» άλλαξε στον δίσκο. Να γίνει επαναφόρτωση;
+@msg	Reloaded {_1}: the file changed on disk	Έγινε επαναφόρτωση του {_1}: το αρχείο άλλαξε στον δίσκο
diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv
--- a/crates/ui-egui/src/i18n/es.tsv
+++ b/crates/ui-egui/src/i18n/es.tsv
@@ -5215,3 +5215,5 @@ mark-style	Japanese	Japonesas
 	Plaster	Yeso
 	Note Paper…	Papel con relieve…
 	Note Paper	Papel con relieve
+	“{name}” changed on disk. Reload it?	“{name}” cambió en el disco. ¿Volver a cargarlo?
+@msg	Reloaded {_1}: the file changed on disk	Se volvió a cargar {_1}: el archivo cambió en el disco
diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv
--- a/crates/ui-egui/src/i18n/fi.tsv
+++ b/crates/ui-egui/src/i18n/fi.tsv
@@ -5304,3 +5304,5 @@ swatch	Bright Violet	Kirkas violetti
 	Plaster	Kipsi
 	Note Paper…	Kirjepaperi…
 	Note Paper	Kirjepaperi
+	“{name}” changed on disk. Reload it?	Tiedosto ”{name}” on muuttunut levyllä. Ladataanko se uudelleen?
+@msg	Reloaded {_1}: the file changed on disk	Ladattiin {_1} uudelleen: tiedosto muuttui levyllä
diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv
--- a/crates/ui-egui/src/i18n/fr.tsv
+++ b/crates/ui-egui/src/i18n/fr.tsv
@@ -5272,3 +5272,5 @@ theme	Appearance	Apparence
 	Plaster	Plâtre
 	Note Paper…	Papier gaufré…
 	Note Paper	Papier gaufré
+	“{name}” changed on disk. Reload it?	« {name} » a été modifié sur le disque. Le recharger ?
+@msg	Reloaded {_1}: the file changed on disk	{_1} rechargé : le fichier a été modifié sur le disque
diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv
--- a/crates/ui-egui/src/i18n/hu.tsv
+++ b/crates/ui-egui/src/i18n/hu.tsv
@@ -5259,3 +5259,5 @@ imageTrace	Outline	Körvonal
 	Plaster	Gipsz
 	Note Paper…	Jegyzetpapír…
 	Note Paper	Jegyzetpapír
+	“{name}” changed on disk. Reload it?	„{name}” módosult a lemezen. Újratöltöd?
+@msg	Reloaded {_1}: the file changed on disk	Újratöltve: {_1} (a fájl módosult a lemezen)
diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv
--- a/crates/ui-egui/src/i18n/id.tsv
+++ b/crates/ui-egui/src/i18n/id.tsv
@@ -5251,3 +5251,5 @@ swatch	Bright Violet	Violet cerah
 	Plaster	Plester
 	Note Paper…	Kertas Catatan…
 	Note Paper	Kertas Catatan
+	“{name}” changed on disk. Reload it?	“{name}” berubah di disk. Muat ulang?
+@msg	Reloaded {_1}: the file changed on disk	Telah memuat ulang {_1}: berkas berubah di disk
diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv
--- a/crates/ui-egui/src/i18n/it.tsv
+++ b/crates/ui-egui/src/i18n/it.tsv
@@ -4887,3 +4887,5 @@ lighting	Light	Luce
 	Plaster	Gesso
 	Note Paper…	Carta da lettere…
 	Note Paper	Carta da lettere
+	“{name}” changed on disk. Reload it?	“{name}” è stato modificato su disco. Ricaricarlo?
+@msg	Reloaded {_1}: the file changed on disk	{_1} ricaricato: il file è stato modificato su disco
diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv
--- a/crates/ui-egui/src/i18n/ja.tsv
+++ b/crates/ui-egui/src/i18n/ja.tsv
@@ -4927,3 +4927,5 @@ lighting	Light	ライト
 	Plaster	石膏
 	Note Paper…	ノート用紙…
 	Note Paper	ノート用紙
+	“{name}” changed on disk. Reload it?	「{name}」はディスク上で変更されました。再読み込みしますか？
+@msg	Reloaded {_1}: the file changed on disk	{_1} を再読み込みしました：ファイルがディスク上で変更されました
diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv
--- a/crates/ui-egui/src/i18n/ka.tsv
+++ b/crates/ui-egui/src/i18n/ka.tsv
@@ -5180,3 +5180,5 @@ imageTrace	Outline	კონტური
 	Plaster	თაბაშირი
 	Note Paper…	ჩანაწერების ქაღალდი…
 	Note Paper	ჩანაწერების ქაღალდი
+	“{name}” changed on disk. Reload it?	„{name}“ შეიცვალა დისკზე. ჩაიტვირთოს ხელახლა?
+@msg	Reloaded {_1}: the file changed on disk	ხელახლა ჩაიტვირთა {_1}: ფაილი შეიცვალა დისკზე
diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv
--- a/crates/ui-egui/src/i18n/ko.tsv
+++ b/crates/ui-egui/src/i18n/ko.tsv
@@ -4850,3 +4850,5 @@ lighting	Light	광원
 	Plaster	석고
 	Note Paper…	메모지…
 	Note Paper	메모지
+	“{name}” changed on disk. Reload it?	“{name}”이(가) 디스크에서 바뀌었습니다. 다시 불러오시겠습니까?
+@msg	Reloaded {_1}: the file changed on disk	{_1}을(를) 다시 불러왔습니다: 디스크에서 파일이 바뀌었습니다
diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv
--- a/crates/ui-egui/src/i18n/nl.tsv
+++ b/crates/ui-egui/src/i18n/nl.tsv
@@ -5293,3 +5293,5 @@ swatch	Bright Violet	Felviolet
 	Plaster	Gips
 	Note Paper…	Notitiepapier…
 	Note Paper	Notitiepapier
+	“{name}” changed on disk. Reload it?	„{name}” is gewijzigd op de schijf. Opnieuw laden?
+@msg	Reloaded {_1}: the file changed on disk	{_1} opnieuw geladen: het bestand is gewijzigd op de schijf
diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv
--- a/crates/ui-egui/src/i18n/pl.tsv
+++ b/crates/ui-egui/src/i18n/pl.tsv
@@ -5125,3 +5125,5 @@ mark-style	Japanese	Japońskie
 	Plaster	Gips
 	Note Paper…	Papier listowy…
 	Note Paper	Papier listowy
+	“{name}” changed on disk. Reload it?	Plik „{name}” zmienił się na dysku. Wczytać go ponownie?
+@msg	Reloaded {_1}: the file changed on disk	Wczytano ponownie {_1}: plik zmienił się na dysku
diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv
--- a/crates/ui-egui/src/i18n/pt-br.tsv
+++ b/crates/ui-egui/src/i18n/pt-br.tsv
@@ -5190,3 +5190,5 @@ mark-style	Japanese	Japonesas
 	Plaster	Gesso
 	Note Paper…	Papel de carta…
 	Note Paper	Papel de carta
+	“{name}” changed on disk. Reload it?	“{name}” foi alterado no disco. Recarregar?
+@msg	Reloaded {_1}: the file changed on disk	{_1} recarregado: o arquivo foi alterado no disco
diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv
--- a/crates/ui-egui/src/i18n/ru.tsv
+++ b/crates/ui-egui/src/i18n/ru.tsv
@@ -4883,3 +4883,5 @@ lighting	Light	Свет
 	Plaster	Гипс
 	Note Paper…	Почтовая бумага…
 	Note Paper	Почтовая бумага
+	“{name}” changed on disk. Reload it?	«{name}» изменен на диске. Перезагрузить?
+@msg	Reloaded {_1}: the file changed on disk	Перезагружен {_1}: файл изменен на диске
diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv
--- a/crates/ui-egui/src/i18n/sv.tsv
+++ b/crates/ui-egui/src/i18n/sv.tsv
@@ -5229,3 +5229,5 @@ theme	Appearance	Utseende
 	Plaster	Gips
 	Note Paper…	Anteckningspapper…
 	Note Paper	Anteckningspapper
+	“{name}” changed on disk. Reload it?	”{name}” har ändrats på disken. Vill du läsa in den igen?
+@msg	Reloaded {_1}: the file changed on disk	Läste in {_1} igen: filen ändrades på disken
diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv
--- a/crates/ui-egui/src/i18n/uk.tsv
+++ b/crates/ui-egui/src/i18n/uk.tsv
@@ -4867,3 +4867,5 @@ lighting	Light	Світло
 	Plaster	Гіпс
 	Note Paper…	Поштовий папір…
 	Note Paper	Поштовий папір
+	“{name}” changed on disk. Reload it?	«{name}» змінено на диску. Перезавантажити?
+@msg	Reloaded {_1}: the file changed on disk	Перезавантажено {_1}: файл змінено на диску
diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv
--- a/crates/ui-egui/src/i18n/vi.tsv
+++ b/crates/ui-egui/src/i18n/vi.tsv
@@ -4867,3 +4867,5 @@ theme	Appearance	Giao diện
 	Plaster	Thạch cao
 	Note Paper…	Giấy ghi chú…
 	Note Paper	Giấy ghi chú
+	“{name}” changed on disk. Reload it?	“{name}” đã thay đổi trên đĩa. Tải lại?
+@msg	Reloaded {_1}: the file changed on disk	Đã tải lại {_1}: tệp đã thay đổi trên đĩa
diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv
--- a/crates/ui-egui/src/i18n/zh-hans.tsv
+++ b/crates/ui-egui/src/i18n/zh-hans.tsv
@@ -5110,3 +5110,5 @@ lighting	Light	光源
 	Plaster	塑料效果
 	Note Paper…	便条纸…
 	Note Paper	便条纸
+	“{name}” changed on disk. Reload it?	“{name}”已在磁盘上更改。要重新载入吗？
+@msg	Reloaded {_1}: the file changed on disk	已重新载入 {_1}：文件已在磁盘上更改
diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv
--- a/crates/ui-egui/src/i18n/zh-hant.tsv
+++ b/crates/ui-egui/src/i18n/zh-hant.tsv
@@ -4905,3 +4905,5 @@ lighting	Light	光源
 	Plaster	塑膠效果
 	Note Paper…	便條紙…
 	Note Paper	便條紙
+	“{name}” changed on disk. Reload it?	「{name}」已在磁碟上變更。要重新載入嗎？
+@msg	Reloaded {_1}: the file changed on disk	已重新載入 {_1}：檔案已在磁碟上變更
diff --git a/crates/ui-egui/src/io.rs b/crates/ui-egui/src/io.rs
--- a/crates/ui-egui/src/io.rs
+++ b/crates/ui-egui/src/io.rs
@@ -873,6 +873,16 @@ pub fn ask_revert(app: &mut VectorcraftApp) -> Result<Value, String> {
     Ok(json!({ "pending": dialogs::confirm::KIND }))
 }
 
+/// The active document's file changed on disk while it has unsaved changes
+/// ([`crate::file_watch`]): ask whether to read it again; OK runs `file.revert` with
+/// `confirmed`, Cancel keeps the changes (asked again only when the file changes again).
+#[cfg(not(target_arch = "wasm32"))]
+pub(crate) fn ask_reload_changed(app: &mut VectorcraftApp) {
+    let name = app.session.active().map(|d| d.title()).unwrap_or_default();
+    let message = crate::i18n::fmt(tl!("“{name}” changed on disk. Reload it?"), &[("name", &name)]);
+    dialogs::confirm::ask(app, &message, tl!("Changes made since it was last saved will be lost."), "file.revert", json!({ "confirmed": true }));
+}
+
 /// File → Show in Folder: the document's file in the system file manager.
 pub fn reveal(app: &mut VectorcraftApp) -> Result<Value, String> {
     let path = app.session.active().and_then(|d| d.path.clone()).ok_or("the document has never been saved")?;
diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs
--- a/crates/ui-egui/src/lib.rs
+++ b/crates/ui-egui/src/lib.rs
@@ -26,6 +26,8 @@ pub mod dialogs;
 pub mod dock;
 mod edit_toolbar;
 pub mod export_progress;
+#[cfg(not(target_arch = "wasm32"))]
+pub mod file_watch;
 pub mod find_font;
 pub mod floating;
 pub mod font_menu;
@@ -82,6 +84,8 @@ mod tests_distortkeys;
 #[cfg(test)]
 mod tests_docsetup;
 #[cfg(test)]
+mod tests_filewatch;
+#[cfg(test)]
 mod tests_font_menu;
 #[cfg(test)]
 mod tests_fonts;
@@ -469,6 +473,9 @@ pub struct VectorcraftApp {
     pub background: background::Background,
     /// Documents being read off the UI thread ([`opening`]).
     pub opening: opening::Opening,
+    /// Documents whose own file changes on disk ([`file_watch`]).
+    #[cfg(not(target_arch = "wasm32"))]
+    pub file_watch: file_watch::FileWatch,
     /// Data Recovery's timer and startup question ([`recovery`]).
     pub recovery: recovery::Timer,
     /// Modifiers the keyboard holds (from the host's input), given back after synthetic input.
@@ -578,6 +585,8 @@ impl VectorcraftApp {
             paste_chord: Default::default(),
             background: Default::default(),
             opening: Default::default(),
+            #[cfg(not(target_arch = "wasm32"))]
+            file_watch: Default::default(),
             recovery: Default::default(),
             host_modifiers: Default::default(),
             synthetic_modifiers: false,
@@ -1227,6 +1236,8 @@ impl VectorcraftApp {
         opening::poll(self);
         #[cfg(not(target_arch = "wasm32"))]
         self.tick_link_updates(ctx, now);
+        #[cfg(not(target_arch = "wasm32"))]
+        self.tick_file_watch(ctx);
         if !self.background.jobs.is_empty() || !self.opening.pending.is_empty() {
             // Keep the status bar's progress moving and pick the result up when it arrives.
             ctx.request_repaint_after(std::time::Duration::from_millis(100));
diff --git a/crates/ui-egui/src/tests_filewatch.rs b/crates/ui-egui/src/tests_filewatch.rs
new file mode 100644
--- /dev/null
+++ b/crates/ui-egui/src/tests_filewatch.rs
@@ -0,0 +1,282 @@
+//! Documents whose own file changes on disk ([`crate::file_watch`]): a clean document reloads in
+//! place, one with unsaved changes asks first, and VectorCraft's own saves are never taken for
+//! another app's changes.
+
+// The watcher is desktop only (`crate::file_watch`).
+#![cfg(not(target_arch = "wasm32"))]
+
+use std::path::PathBuf;
+
+use serde_json::{Value, json};
+use vectorcraft_engine::Session;
+
+use crate::file_watch::{Watched, Watcher};
+use crate::state::Dialog;
+use crate::{Services, VectorcraftApp};
+
+/// A fresh folder for one test.
+fn dir(tag: &str) -> PathBuf {
+    let d = std::env::temp_dir().join(format!("vc-filewatch-{tag}-{}", std::process::id()));
+    let _ = std::fs::remove_dir_all(&d);
+    std::fs::create_dir_all(&d).unwrap();
+    d
+}
+
+fn path(d: &std::path::Path, name: &str) -> String {
+    d.join(name).to_string_lossy().into_owned()
+}
+
+/// An SVG with `n` rectangles.
+fn svg(n: usize) -> String {
+    let rects: String = (0..n).map(|i| format!(r#"<rect x="{}" y="10" width="5" height="5"/>"#, 10 + 10 * i)).collect();
+    format!(r#"<svg xmlns="http://www.w3.org/2000/svg" width="200" height="100">{rects}</svg>"#)
+}
+
+/// Another app writes `file`, late enough that its stamp moves even on coarse file systems.
+fn write_elsewhere(file: &str, bytes: impl AsRef<[u8]>) {
+    std::thread::sleep(std::time::Duration::from_millis(30));
+    std::fs::write(file, bytes).unwrap();
+}
+
+/// An app whose watcher looks on the UI thread, once per frame, with `file` open.
+fn app_with(file: &str) -> VectorcraftApp {
+    let mut app = VectorcraftApp::new(Session::new(), Services::default());
+    app.file_watch.inline = Some(Watcher::default());
+    app.session.execute("document.open", &json!({"path": file})).unwrap();
+    frames(&mut app);
+    app
+}
+
+/// Enough frames for a change to be seen, seen unchanged again, and read.
+fn frames(app: &mut VectorcraftApp) {
+    let ctx = egui::Context::default();
+    for _ in 0..3 {
+        app.tick_file_watch(&ctx);
+    }
+}
+
+fn objects(app: &VectorcraftApp) -> usize {
+    app.session.doc().unwrap().doc.node_count()
+}
+
+fn asking(app: &VectorcraftApp) -> bool {
+    app.ui.dialog.as_ref().is_some_and(|d| d.kind == crate::dialogs::confirm::KIND && d.fields.get("__command") == Some(&json!("file.revert")))
+}
+
+#[test]
+fn a_clean_document_reloads_in_place_once() {
+    let d = dir("clean");
+    let file = path(&d, "art.svg");
+    std::fs::write(&file, svg(1)).unwrap();
+    let mut app = app_with(&file);
+    let (uid, one) = (app.session.doc().unwrap().uid, objects(&app));
+    write_elsewhere(&file, svg(3));
+    frames(&mut app);
+    let st = app.session.doc().unwrap();
+    assert_eq!((objects(&app), st.uid, st.path.as_deref()), (one + 2, uid, Some(file.as_str())));
+    assert!(!st.is_dirty() && !asking(&app));
+    assert_eq!(app.ui.status, "Reloaded art.svg: the file changed on disk");
+    // Acted on once: the same version doesn't reload again.
+    app.ui.status.clear();
+    frames(&mut app);
+    assert!(app.ui.status.is_empty());
+    let _ = std::fs::remove_dir_all(d);
+}
+
+#[test]
+fn unsaved_changes_ask_first_and_cancel_keeps_them() {
+    let d = dir("dirty");
+    let file = path(&d, "art.svg");
+    std::fs::write(&file, svg(1)).unwrap();
+    let mut app = app_with(&file);
+    app.run("shape.rectangle", json!({"x": 100, "y": 50, "width": 5, "height": 5})).unwrap();
+    let edited = objects(&app);
+    write_elsewhere(&file, svg(3));
+    frames(&mut app);
+    assert!(asking(&app), "{:?}", app.ui.dialog.as_ref().map(|d| &d.kind));
+    assert_eq!(objects(&app), edited);
+    // Cancel: the changes stay, and the same version isn't asked about again.
+    crate::dialogs::cancel(&mut app);
+    frames(&mut app);
+    assert!(!asking(&app));
+    assert_eq!(objects(&app), edited);
+    // A newer version asks again; OK reads it.
+    write_elsewhere(&file, svg(4));
+    frames(&mut app);
+    assert!(asking(&app));
+    crate::dialogs::confirm(&mut app).unwrap();
+    assert_eq!(objects(&app), edited + 2);
+    assert!(!app.session.doc().unwrap().is_dirty());
+    let _ = std::fs::remove_dir_all(d);
+}
+
+#[test]
+fn the_documents_own_saves_never_reload() {
+    let d = dir("own");
+    let file = path(&d, "art.svg");
+    std::fs::write(&file, svg(1)).unwrap();
+    let mut app = app_with(&file);
+    app.run("shape.rectangle", json!({"x": 100, "y": 50, "width": 5, "height": 5})).unwrap();
+    app.session.execute("document.save", &json!({})).unwrap();
+    frames(&mut app);
+    frames(&mut app);
+    let st = app.session.doc().unwrap();
+    assert!(!asking(&app) && app.ui.status.is_empty(), "{}", app.ui.status);
+    assert!(!st.history.undo.is_empty(), "not read again: the history is still there");
+    let _ = std::fs::remove_dir_all(d);
+}
+
+#[test]
+fn a_version_found_before_a_save_is_not_applied_over_it() {
+    let d = dir("stale");
+    let file = path(&d, "art.svg");
+    std::fs::write(&file, svg(1)).unwrap();
+    let mut app = app_with(&file);
+    // Another app writes the file while a dialog is open: the new version waits.
+    app.ui.dialog = Some(Dialog::new("message", Value::Null));
+    write_elsewhere(&file, svg(3));
+    frames(&mut app);
+    // The document is saved over it before the dialog closes: that version is older now.
+    app.ui.dialog = None;
+    app.run("shape.rectangle", json!({"x": 100, "y": 50, "width": 5, "height": 5})).unwrap();
+    let edited = objects(&app);
+    app.session.execute("document.save", &json!({})).unwrap();
+    frames(&mut app);
+    assert!(!asking(&app) && app.ui.status.is_empty(), "{}", app.ui.status);
+    assert_eq!(objects(&app), edited);
+    let _ = std::fs::remove_dir_all(d);
+}
+
+#[test]
+fn save_as_watches_the_new_file() {
+    let d = dir("saveas");
+    let (a, b) = (path(&d, "a.svg"), path(&d, "b.svg"));
+    std::fs::write(&a, svg(1)).unwrap();
+    let mut app = app_with(&a);
+    app.session.execute("file.saveAs", &json!({"path": b})).unwrap();
+    frames(&mut app);
+    let one = objects(&app);
+    write_elsewhere(&a, svg(5));
+    frames(&mut app);
+    assert_eq!(objects(&app), one, "no longer the document's file");
+    write_elsewhere(&b, svg(5));
+    frames(&mut app);
+    assert_eq!(objects(&app), one + 4);
+    let _ = std::fs::remove_dir_all(d);
+}
+
+#[test]
+fn a_document_in_the_back_reloads_too_and_asks_when_in_front() {
+    let d = dir("back");
+    let (a, b) = (path(&d, "a.svg"), path(&d, "b.svg"));
+    std::fs::write(&a, svg(1)).unwrap();
+    std::fs::write(&b, svg(1)).unwrap();
+    let mut app = app_with(&a);
+    app.run("shape.rectangle", json!({"x": 100, "y": 50, "width": 5, "height": 5})).unwrap();
+    app.session.execute("document.open", &json!({"path": b})).unwrap();
+    frames(&mut app);
+    let count = |app: &VectorcraftApp, i: usize| app.session.documents()[i].doc.node_count();
+    let (a_edited, b_one) = (count(&app, 0), count(&app, 1));
+    write_elsewhere(&a, svg(3));
+    write_elsewhere(&b, svg(3));
+    frames(&mut app);
+    // b (in front, clean) reloads; a (in the back, modified) waits to be asked.
+    assert_eq!((count(&app, 0), count(&app, 1)), (a_edited, b_one + 2));
+    assert!(!asking(&app));
+    app.session.set_active(0);
+    frames(&mut app);
+    assert!(asking(&app));
+    let _ = std::fs::remove_dir_all(d);
+}
+
+#[test]
+fn a_file_that_cant_be_read_leaves_the_document_until_it_changes_again() {
+    let d = dir("broken");
+    let file = path(&d, "art.vectorcraft");
+    let mut s = Session::new();
+    s.execute("file.new", &json!({"width": 200, "height": 100})).unwrap();
+    s.execute("document.save", &json!({"path": file})).unwrap();
+    let good = std::fs::read(&file).unwrap();
+    let mut app = app_with(&file);
+    let one = objects(&app);
+    write_elsewhere(&file, b"{ not a document");
+    frames(&mut app);
+    assert_eq!(objects(&app), one);
+    assert!(!app.ui.status.is_empty(), "the error is shown");
+    app.ui.status.clear();
+    frames(&mut app);
+    assert!(app.ui.status.is_empty(), "once");
+    // A good version reloads.
+    s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 5, "height": 5})).unwrap();
+    s.execute("document.save", &json!({})).unwrap();
+    assert_ne!(std::fs::read(&file).unwrap(), good);
+    frames(&mut app);
+    assert_eq!(objects(&app), one + 1);
+    let _ = std::fs::remove_dir_all(d);
+}
+
+/// A half-written SVG is not read: the watcher waits for a version that ends as a whole SVG does.
+#[test]
+fn a_half_written_svg_waits_for_the_whole_file() {
+    let d = dir("half");
+    let file = path(&d, "art.svg");
+    std::fs::write(&file, svg(1)).unwrap();
+    let mut w = Watcher::default();
+    w.set(vec![Watched { doc: 1, path: file.clone(), known: None, version: 0 }]);
+    let looks = |w: &mut Watcher| (0..3).flat_map(|_| w.tick().0).collect::<Vec<_>>();
+    assert!(looks(&mut w).is_empty(), "first seen: remembered");
+    let whole = svg(3);
+    write_elsewhere(&file, &whole[..whole.len() - 10]);
+    assert!(looks(&mut w).is_empty(), "not a whole SVG");
+    write_elsewhere(&file, &whole);
+    let found = looks(&mut w);
+    assert_eq!(found.len(), 1);
+    assert_eq!(found[0].bytes.as_slice(), whole.as_bytes());
+    // A version the UI thread knows (it wrote it) is not a change.
+    write_elsewhere(&file, svg(4));
+    w.set(vec![Watched { doc: 1, path: file.clone(), known: crate::file_watch::Stamp::at(&file), version: 1 }]);
+    assert!(looks(&mut w).is_empty());
+    let _ = std::fs::remove_dir_all(d);
+}
+
+/// A version whose metadata alone changed (rewritten with the same bytes, `touch`, `chmod`, a sync
+/// tool's extended attributes) is not a change: only one whose contents differ is found.
+#[test]
+fn a_change_of_metadata_alone_is_not_a_change() {
+    let d = dir("meta");
+    let file = path(&d, "art.svg");
+    std::fs::write(&file, svg(1)).unwrap();
+    let mut w = Watcher::default();
+    w.set(vec![Watched { doc: 1, path: file.clone(), known: crate::file_watch::Stamp::at(&file), version: 0 }]);
+    let looks = |w: &mut Watcher| (0..3).flat_map(|_| w.tick().0).collect::<Vec<_>>();
+    assert!(looks(&mut w).is_empty());
+    // The same bytes written again, and a new modification time alone.
+    write_elsewhere(&file, svg(1));
+    assert!(looks(&mut w).is_empty(), "rewritten with the same bytes");
+    let later = std::time::SystemTime::now() + std::time::Duration::from_secs(60);
+    std::fs::File::options().write(true).open(&file).unwrap().set_modified(later).unwrap();
+    assert!(looks(&mut w).is_empty(), "touched");
+    write_elsewhere(&file, svg(2));
+    assert_eq!(looks(&mut w).len(), 1, "new contents");
+    let _ = std::fs::remove_dir_all(d);
+}
+
+/// In the app: a clean document whose file is rewritten with the same bytes stays as it is, its
+/// history and selection with it.
+#[test]
+fn a_file_rewritten_with_the_same_bytes_doesnt_reload() {
+    let d = dir("samebytes");
+    let file = path(&d, "art.svg");
+    std::fs::write(&file, svg(2)).unwrap();
+    let mut app = app_with(&file);
+    app.run("select.all", json!({})).unwrap();
+    let revision = app.session.doc().unwrap().revision;
+    write_elsewhere(&file, svg(2));
+    frames(&mut app);
+    frames(&mut app);
+    assert!(app.ui.status.is_empty() && !asking(&app), "{}", app.ui.status);
+    let st = app.session.doc().unwrap();
+    assert_eq!(st.revision, revision, "not replaced");
+    assert!(!st.selection.is_empty());
+    let _ = std::fs::remove_dir_all(d);
+}
diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md
--- a/docs/target-app-parity.md
+++ b/docs/target-app-parity.md
@@ -1,6 +1,6 @@
 # Parity with Adobe Illustrator
 
-> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-20 · **Change:** minor (live-effect plug-ins given their object's fills and strokes and the item they sit on, G17; plug-in parameters: labels, units, groups, help and conditions in their dialogs, text and number-list parameters, up to 256 of them, G17; exporter plug-ins: plug-in file formats in Export As and document.export, G17; G2: Effect › Artistic › Smudge Stick, Sponge and Watercolor; Sketch › Bas Relief, Charcoal, Photocopy and Stamp, #2533; Photoshop filters 45/57, 12 remaining (8–14 h); interactive Mirror Repeat axis; bounding-box handles take priority over the axis body while the center and end handles retain their move and rotation actions; Libraries panel: the list scrolls, items and groups reorder by dragging, items drag into and out of groups (`library.moveItem` before/after, `library.moveGroup`); scores unchanged; PDF invisible text opens as type with no fill or stroke and is written back invisible, #2675; status-readout sample widths cached by language, document unit and pixels per point; status messages and readouts use stable responsive allocations sized for localized labels and ordinary coordinates; cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission; cached canvas reprojection preserves scale during resize, rotation and DPI changes; interactive Mirror Repeat axis; bounding-box handles take priority over the axis body while the center and end handles retain their move and rotation actions; 3D lighting: one to ten colored lights, smooth colored Inflate, legacy white-light appearance preserved; surface batching and restored seam minimum; scores and estimates unchanged; Blend spine Anchor Point conversions, selection/stacking priority and Shift regression coverage (#2544); scores and hours unchanged; Artboard Options' X and Y with a reference point, #1876; Image Trace: Auto Color keeps rare but distinct colours; Image Trace: large images trace with a warning; anchor limit raised to 8,000,000; Image Trace: Raster::new returns an error for a buffer of the wrong length; M12 maximal-disc graph pruning and junction merging measured; M12 crop EDT and Guo–Hall thinning measured; M12 centre-lines baseline measured; scores and hours unchanged; installed plug-ins stay installed after a restart, #2541; Group Selection picks and moves one path of a compound path, #2441 in part; the Move dialog opens on the last move and its last checkboxes; Image Trace: a higher Paths never gives fewer segments on collapsed loops; Image Trace: the notch rule reads only its own piece; Image Trace: an edge's sub-pixel shift is the same from either side; Image Trace Flat Logo traces line art on white paper instead of a blank result; score and hours unchanged; Image Trace: a simpler contour walk with no precomputed direction table; transparency grid visibility saved in native documents, #1968; MCP cancellation, progress and busy answers during long exports, #2165, #2171, #2172; EPS editing data: legacy mesh corners, vector opacity masks and compound member paint retained; raster page comparison filters minification without relaxing limits; scores and hours unchanged; native family-name fallback from English-only typographic names, #2208; scores unchanged; batch exporting, #1935; G2: Effect › Sketch › Halftone Pattern; Photoshop filters 45/57, 12 remaining (8–14 h); G5: Symbol Options Export Type (Graphic / Movie Clip); Localization: Brazilian Portuguese complete under the tests, #1914, 71% → 74%; G6 image variables: Automation ~60% → ~62% / detail ~65% → ~68%, hours lowered; Warp Options controls verified by focused checks and original-image review; estimates unchanged; G11: Asset Export Art Optimized seam (#983 / #1037) and supersampled thumbs; Export for Screens 85% → 90%; existing-guide Escape cancellation validated 2026-10-14; scores and hours unchanged; open-path anchor Delete keeps surviving runs separate; estimates unchanged; PDF.R1: shared PDF reader with windowed content scanning and explicit incomplete-content errors; scores and estimates unchanged; PDF.W1: guarded shared PDF writer; output behavior, scores and estimates unchanged; Artboard tab-switch follow-up historically validated on `a0671777`; scores and hours unchanged; Artboard clean active-tab close/Delete follow-up validated 2026-10-15; later 2026-10-14 merges: anchor X/Y/W/H, #1824; Snap to Grid over Snap to Pixel, #1816; toolbar and All Tools, #1751; Actions panel selection, #1629 in part; redo during a drag, #1719; preferences written on OK, #1747; type in symbols after reopening, #1738; macOS save sheets, #1855, the Format menu withdrawn; stroke outlines shared, #1667; untrusted files, #1852, #1862–#1873; failed-save dialog, #1685; effects, #1864, Dim Images, #1683; duplicated rows of the 2026-10-05 table noted for the owner; scores unchanged; Image Trace: Shapes rework, stroke caps, enhanced presets, gradients over noise, measured in `image-trace-quality.md`, score unchanged; verified PngSuite downloads, #1729; synthetic control-input budgets, #1709; 2026-10-14 merges: anchor Align and Distribute, #1636; Direct Selection and Delete fixes, #1674, #1666, #1743; Escape while typing, #1686; Pen clicks, #1663; Expand on repeats, #1756; Roughen and Scribble, #1643, #1648; file and input hardening, #1776–#1783, #1786, #1807; MCP limits and envelope, #1727, #1728; Image Trace levelling and Flat Logo on scans, #1647, #1657; scores unchanged; later 2026-10-13 merges: SVG import, #1565, #1596, #1568, #1598, #1601; type on a path from PDF and `.ai`, #1585, #1586; ICC tag alignment, #1584; saves, #1620 in part; Warp Arc, #1578; Zig Zag, #1558; Pathfinder strokes, #1570; OpenType features, #1607; intersect Smart Guides, #1602 in part; a guide's position in the Transform panel, #1597 in part, a convenience with no parity claim; scores unchanged; TIFF is available in Export for Screens and Asset Export with shared format settings; scores and hours unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; Units › Identify Objects By (`identifyObjectsBy`) acts on Layers, Variables and Layer Options, #394, scores unchanged; Appearance of Black › Printing / Exporting (`blackOutput`) acts on RGB raster export and Print as Bitmap, #394, scores unchanged; per-corner radius fields with a link toggle, split from #1594; control transport deadlines, #1726; Color panel: the mode picked last comes back after a restart, #1675; Image Trace: shared edges, memory, thin lines, strokes, corners, and the Shapes, Auto Grouping and Gradients options, measured in `image-trace-quality.md`, score unchanged; 2026-10-13 merges: Polish interface, #1498; compact native saves, #1492; PDF and EPS paste as vector art, #1499; import fixes for `.ai`, PDF, EPS and SVG, #1528, #1530, #1531, #1544, #1551, #1553; guide objects, #1534; Join after Average, #1533; Keyboard Shortcuts lists commands outside the menus, #1539; correction: Average and Join with a corner or smooth choice is missing; scores unchanged; late 2026-10-12 merges: type that does not fit its frame is reported, #1471; Tabs panel drags, #1458; template layers in Outline view, #1486; dialog slider rails, #1483; Direct Selection anchor precedence, #1508; print-size images, #1517; 3D on outlined type, #1481; French catalog, #1511; scores unchanged; Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder panel on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; Localization: Vietnamese catalog added, 63% → 71%, hours 60–100 → 55–90, full readiness unchanged at ~62%; scores and hours unchanged; Image Trace places edges to a fraction of a pixel in every mode, measured in `image-trace-quality.md`, score unchanged; 3D and Materials: Extrude & Bevel, Inflate and Rotate added, 3D ~8% → ~25% (estimated), menu stubs 25 → 22; Affinity text frames too short for their first line show it, #1423; Transform dialogs preview, #1412, and the transform tools and commands act on direct-selected anchors, #1408; `.ai` compound path paint, #1410; strokes wider than their shape, #1396; corner drags hide the bounding box, #1229; scores and hours unchanged; PDF and `.ai` import keep overprint, #1388, scores unchanged; `.ai` editing-data follow-ups: gradient stops, type of objects with an appearance, short area-type frames (#1390, #1391, #1392); dialog keyboard focus on every open (#1382, #1395); art flush with an artboard (#1375); font rescan (#1376); symbol instance redraw cache (#1386); scores and hours unchanged; grid tool options, #1377 in part, scores unchanged; `.ai` placed (linked) files read from the editing data, #1264, scores unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click selects its corner, #1239; scores and hours unchanged; custom ruler origin, #1212; Artboard click-to-fit verified for objects, groups, visible bounds, undo and cancellation (#1227); scores and hours unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; Localization: Korean catalog added, 54% → 63%, full readiness unchanged at ~62%; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Image Trace: an image over 64 megapixels traces scaled down instead of being refused, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Path operations: Simplify preview/counts and component selection (#1272); scores unchanged; G2: Film Grain added; Photoshop effects 45/57; 28 filters remain; Platforms: RPM runtime capabilities and glibc floor (#1275); scores unchanged; Raster effects: squared-distance texture redraws (#1257); pixels unchanged; scores and hours unchanged; Drawing: Curvature point display improved (#1236, in part); scores unchanged; Type core: full-name font matching (#1258), scores and hours unchanged; Selection: no-op anchor Align preserves history and saved state (#1180 follow-up); scores unchanged; UI chrome: Eyedropper cursor and precise-setting regressions (#1269); scores unchanged; Colour: Eyedropper reads visible locked and outside-isolation sources (#1251); scores and hours unchanged; Selection: segment click/Delete preserves neighboring edges (#1278); scores unchanged; Colour: unpainted zero-width strokes regain 1 pt when painted (#1253); scores and hours unchanged; graphs: designs and markers, data, Graph Type and label options; UI chrome: native macOS save Format menu, #866, scores unchanged; compound paths inherit the backmost appearance and member fill-rule edits reach their owner, #1197; scores unchanged; Type core: Middle Eastern digit types done, Character panel menu › Digits; PDF export warns when the pixel cap lowers raster-effect resolution; Drawing tools: Live Corners round corners between curved sides, #1144; Type advanced: proportional vertical metrics (`vpal`) done, #966; Shape Builder: #893 fixed, merged regions of crossing lines no longer keep seams; scores unchanged while #937 is open; #1034: document-point character controls after object/group scaling; full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user score ~75% added; ready-for-real-work re-examined: 50% → 55%, mainstream 65% added; earlier the same day: full re-measure against Illustrator 2026 30.x; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file; SVG knockout groups of opaque art export without masks; Image Trace: colour presets no longer shatter noisy flat art and photos trace into smooth tones, measured in `image-trace-quality.md`, score unchanged; Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder panel on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; scores and hours unchanged; Image Trace places edges to a fraction of a pixel in every mode, measured in `image-trace-quality.md`, score unchanged; 3D and Materials: Extrude & Bevel, Inflate and Rotate added, 3D ~8% → ~25% (estimated), menu stubs 25 → 22; Affinity text frames too short for their first line show it, #1423; Transform dialogs preview, #1412, and the transform tools and commands act on direct-selected anchors, #1408; `.ai` compound path paint, #1410; strokes wider than their shape, #1396; corner drags hide the bounding box, #1229; scores and hours unchanged; PDF and `.ai` import keep overprint, #1388, scores unchanged; `.ai` editing-data follow-ups: gradient stops, type of objects with an appearance, short area-type frames (#1390, #1391, #1392); dialog keyboard focus on every open (#1382, #1395); art flush with an artboard (#1375); font rescan (#1376); symbol instance redraw cache (#1386); scores and hours unchanged; grid tool options, #1377 in part, scores unchanged; `.ai` placed (linked) files read from the editing data, #1264, scores unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click selects its corner, #1239; scores and hours unchanged; custom ruler origin, #1212; Artboard click-to-fit verified for objects, groups, visible bounds, undo and cancellation (#1227); scores and hours unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; Localization: Korean catalog added, 54% → 63%, full readiness unchanged at ~62%; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Image Trace: an image over 64 megapixels traces scaled down instead of being refused, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Path operations: Simplify preview/counts and component selection (#1272); scores unchanged; G2: Film Grain added; Photoshop effects 45/57; 28 filters remain; Platforms: RPM runtime capabilities and glibc floor (#1275); scores unchanged; Raster effects: squared-distance texture redraws (#1257); pixels unchanged; scores and hours unchanged; Drawing: Curvature point display improved (#1236, in part); scores unchanged; Type core: full-name font matching (#1258), scores and hours unchanged; Selection: no-op anchor Align preserves history and saved state (#1180 follow-up); scores unchanged; UI chrome: Eyedropper cursor and precise-setting regressions (#1269); scores unchanged; Colour: Eyedropper reads visible locked and outside-isolation sources (#1251); scores and hours unchanged; Selection: segment click/Delete preserves neighboring edges (#1278); scores unchanged; Colour: unpainted zero-width strokes regain 1 pt when painted (#1253); scores and hours unchanged; graphs: designs and markers, data, Graph Type and label options; UI chrome: native macOS save Format menu, #866, scores unchanged; compound paths inherit the backmost appearance and member fill-rule edits reach their owner, #1197; scores unchanged; Type core: Middle Eastern digit types done, Character panel menu › Digits; PDF export warns when the pixel cap lowers raster-effect resolution; Drawing tools: Live Corners round corners between curved sides, #1144; Type advanced: proportional vertical metrics (`vpal`) done, #966; Shape Builder: #893 fixed, merged regions of crossing lines no longer keep seams; scores unchanged while #937 is open; #1034: document-point character controls after object/group scaling; full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user score ~75% added; ready-for-real-work re-examined: 50% → 55%, mainstream 65% added; earlier the same day: full re-measure against Illustrator 2026 30.x; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file; SVG knockout groups of opaque art export without masks; MCP `add_text` exposes the existing `vertical` option for point, area and path type; horizontal remains the default; scores and hours unchanged; Vertical fullwidth question and exclamation marks keep their nominal glyph upright when the font has no vertical alternate; scores and hours unchanged.; Curvature curve geometry, #1236 in part; Width-point finite-arithmetic follow-up validated; scores and hours unchanged; Image Trace adjustment discovery; File interop: legacy `.ai` of version 7 saved with type as text, hidden objects kept and an artboard picker, #868, scores unchanged; Revolve: automatic smooth shading and default-on Cap with continuous rims; PDF.R1: shared PDF reader with windowed content scanning and explicit incomplete-content errors; scores and estimates unchanged; PDF.W1: guarded shared PDF writer; output behavior, scores and estimates unchanged; later 2026-10-14 merges: anchor X/Y/W/H, #1824; Snap to Grid over Snap to Pixel, #1816; toolbar and All Tools, #1751; Actions panel selection, #1629 in part; redo during a drag, #1719; preferences written on OK, #1747; type in symbols after reopening, #1738; macOS save sheets, #1855, the Format menu withdrawn; stroke outlines shared, #1667; untrusted files, #1852, #1862–#1873; failed-save dialog, #1685; effects, #1864, Dim Images, #1683; duplicated rows of the 2026-10-05 table noted for the owner; scores unchanged; Image Trace: Shapes rework, stroke caps, enhanced presets, gradients over noise, measured in `image-trace-quality.md`, score unchanged; verified PngSuite downloads, #1729; synthetic control-input budgets, #1709; 2026-10-14 merges: anchor Align and Distribute, #1636; Direct Selection and Delete fixes, #1674, #1666, #1743; Escape while typing, #1686; Pen clicks, #1663; Expand on repeats, #1756; Roughen and Scribble, #1643, #1648; file and input hardening, #1776–#1783, #1786, #1807; MCP limits and envelope, #1727, #1728; Image Trace levelling and Flat Logo on scans, #1647, #1657; scores unchanged; later 2026-10-13 merges: SVG import, #1565, #1596, #1568, #1598, #1601; type on a path from PDF and `.ai`, #1585, #1586; ICC tag alignment, #1584; saves, #1620 in part; Warp Arc, #1578; Zig Zag, #1558; Pathfinder strokes, #1570; OpenType features, #1607; intersect Smart Guides, #1602 in part; a guide's position in the Transform panel, #1597 in part, a convenience with no parity claim; scores unchanged; TIFF is available in Export for Screens and Asset Export with shared format settings; scores and hours unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; Units › Identify Objects By (`identifyObjectsBy`) acts on Layers, Variables and Layer Options, #394, scores unchanged; Appearance of Black › Printing / Exporting (`blackOutput`) acts on RGB raster export and Print as Bitmap, #394, scores unchanged; per-corner radius fields with a link toggle, split from #1594; control transport deadlines, #1726; Color panel: the mode picked last comes back after a restart, #1675; Image Trace: shared edges, memory, thin lines, strokes, corners, and the Shapes, Auto Grouping and Gradients options, measured in `image-trace-quality.md`, score unchanged; 2026-10-13 merges: Polish interface, #1498; compact native saves, #1492; PDF and EPS paste as vector art, #1499; import fixes for `.ai`, PDF, EPS and SVG, #1528, #1530, #1531, #1544, #1551, #1553; guide objects, #1534; Join after Average, #1533; Keyboard Shortcuts lists commands outside the menus, #1539; correction: Average and Join with a corner or smooth choice is missing; scores unchanged; late 2026-10-12 merges: type that does not fit its frame is reported, #1471; Tabs panel drags, #1458; template layers in Outline view, #1486; dialog slider rails, #1483; Direct Selection anchor precedence, #1508; print-size images, #1517; 3D on outlined type, #1481; French catalog, #1511; scores unchanged; Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder panel on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; Localization: Vietnamese catalog added, 63% → 71%, hours 60–100 → 55–90, full readiness unchanged at ~62%; scores and hours unchanged; Image Trace places edges to a fraction of a pixel in every mode, measured in `image-trace-quality.md`, score unchanged; 3D and Materials: Extrude & Bevel, Inflate and Rotate added, 3D ~8% → ~25% (estimated), menu stubs 25 → 22; Affinity text frames too short for their first line show it, #1423; Transform dialogs preview, #1412, and the transform tools and commands act on direct-selected anchors, #1408; `.ai` compound path paint, #1410; strokes wider than their shape, #1396; corner drags hide the bounding box, #1229; scores and hours unchanged; PDF and `.ai` import keep overprint, #1388, scores unchanged; `.ai` editing-data follow-ups: gradient stops, type of objects with an appearance, short area-type frames (#1390, #1391, #1392); dialog keyboard focus on every open (#1382, #1395); art flush with an artboard (#1375); font rescan (#1376); symbol instance redraw cache (#1386); scores and hours unchanged; grid tool options, #1377 in part, scores unchanged; `.ai` placed (linked) files read from the editing data, #1264, scores unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click selects its corner, #1239; scores and hours unchanged; custom ruler origin, #1212; Artboard click-to-fit verified for objects, groups, visible bounds, undo and cancellation (#1227); scores and hours unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; Localization: Korean catalog added, 54% → 63%, full readiness unchanged at ~62%; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Image Trace: an image over 64 megapixels traces scaled down instead of being refused, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Path operations: Simplify preview/counts and component selection (#1272); scores unchanged; G2: Film Grain added; Photoshop effects 45/57; 28 filters remain; Platforms: RPM runtime capabilities and glibc floor (#1275); scores unchanged; Raster effects: squared-distance texture redraws (#1257); pixels unchanged; scores and hours unchanged; Drawing: Curvature point display improved (#1236, in part); scores unchanged; Type core: full-name font matching (#1258), scores and hours unchanged; Selection: no-op anchor Align preserves history and saved state (#1180 follow-up); scores unchanged; UI chrome: Eyedropper cursor and precise-setting regressions (#1269); scores unchanged; Colour: Eyedropper reads visible locked and outside-isolation sources (#1251); scores and hours unchanged; Selection: segment click/Delete preserves neighboring edges (#1278); scores unchanged; Colour: unpainted zero-width strokes regain 1 pt when painted (#1253); scores and hours unchanged; graphs: designs and markers, data, Graph Type and label options; UI chrome: native macOS save Format menu, #866, scores unchanged; compound paths inherit the backmost appearance and member fill-rule edits reach their owner, #1197; scores unchanged; Type core: Middle Eastern digit types done, Character panel menu › Digits; PDF export warns when the pixel cap lowers raster-effect resolution; Drawing tools: Live Corners round corners between curved sides, #1144; Type advanced: proportional vertical metrics (`vpal`) done, #966; Shape Builder: #893 fixed, merged regions of crossing lines no longer keep seams; scores unchanged while #937 is open; #1034: document-point character controls after object/group scaling; full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user score ~75% added; ready-for-real-work re-examined: 50% → 55%, mainstream 65% added; earlier the same day: full re-measure against Illustrator 2026 30.x; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file; SVG knockout groups of opaque art export without masks; Image Trace: colour presets no longer shatter noisy flat art and photos trace into smooth tones, measured in `image-trace-quality.md`, score unchanged; Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder panel on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; scores and hours unchanged; Image Trace places edges to a fraction of a pixel in every mode, measured in `image-trace-quality.md`, score unchanged; 3D and Materials: Extrude & Bevel, Inflate and Rotate added, 3D ~8% → ~25% (estimated), menu stubs 25 → 22; Affinity text frames too short for their first line show it, #1423; Transform dialogs preview, #1412, and the transform tools and commands act on direct-selected anchors, #1408; `.ai` compound path paint, #1410; strokes wider than their shape, #1396; corner drags hide the bounding box, #1229; scores and hours unchanged; PDF and `.ai` import keep overprint, #1388, scores unchanged; `.ai` editing-data follow-ups: gradient stops, type of objects with an appearance, short area-type frames (#1390, #1391, #1392); dialog keyboard focus on every open (#1382, #1395); art flush with an artboard (#1375); font rescan (#1376); symbol instance redraw cache (#1386); scores and hours unchanged; grid tool options, #1377 in part, scores unchanged; `.ai` placed (linked) files read from the editing data, #1264, scores unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click selects its corner, #1239; scores and hours unchanged; custom ruler origin, #1212; Artboard click-to-fit verified for objects, groups, visible bounds, undo and cancellation (#1227); scores and hours unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; Localization: Korean catalog added, 54% → 63%, full readiness unchanged at ~62%; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Image Trace: an image over 64 megapixels traces scaled down instead of being refused, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Path operations: Simplify preview/counts and component selection (#1272); scores unchanged; G2: Film Grain added; Photoshop effects 45/57; 28 filters remain; Platforms: RPM runtime capabilities and glibc floor (#1275); scores unchanged; Raster effects: squared-distance texture redraws (#1257); pixels unchanged; scores and hours unchanged; Drawing: Curvature point display improved (#1236, in part); scores unchanged; Type core: full-name font matching (#1258), scores and hours unchanged; Selection: no-op anchor Align preserves history and saved state (#1180 follow-up); scores unchanged; UI chrome: Eyedropper cursor and precise-setting regressions (#1269); scores unchanged; Colour: Eyedropper reads visible locked and outside-isolation sources (#1251); scores and hours unchanged; Selection: segment click/Delete preserves neighboring edges (#1278); scores unchanged; Colour: unpainted zero-width strokes regain 1 pt when painted (#1253); scores and hours unchanged; graphs: designs and markers, data, Graph Type and label options; UI chrome: native macOS save Format menu, #866, scores unchanged; compound paths inherit the backmost appearance and member fill-rule edits reach their owner, #1197; scores unchanged; Type core: Middle Eastern digit types done, Character panel menu › Digits; PDF export warns when the pixel cap lowers raster-effect resolution; Drawing tools: Live Corners round corners between curved sides, #1144; Type advanced: proportional vertical metrics (`vpal`) done, #966; Shape Builder: #893 fixed, merged regions of crossing lines no longer keep seams; scores unchanged while #937 is open; #1034: document-point character controls after object/group scaling; full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user score ~75% added; ready-for-real-work re-examined: 50% → 55%, mainstream 65% added; earlier the same day: full re-measure against Illustrator 2026 30.x; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file; SVG knockout groups of opaque art export without masks) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026)
+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-20 · **Change:** minor (documents reload when another app changes their file on disk, asking first when they have unsaved changes, #3282)
 
 The authoritative parity assessment. [`ROADMAP.md`](../ROADMAP.md) summarizes it; [`gaps.md`](gaps.md) is
 the work list made from it. Deep areas have their own checklists: [UI and interaction](ui-parity.md),
@@ -762,6 +762,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
   - On Windows, a document opened from a mapped network drive finds its links stored with the share's own path (`\\server\share\…`), as the same document opened from the share does; links on other computers stay missing.
   - In the Links panel, Shift-click selects a range of links and Ctrl/Cmd-click adds or removes one.
   - Linked files another app changes while their document is open follow Preferences › File Handling › Update Links, as they do when it opens: Automatically reads them again (an Update Links undo step), Ask When Modified offers to, Manually leaves them modified in the Links panel. The desktop app compares the active document’s linked files by size and modification time every two seconds and when its window comes back to the front, on a worker thread; a file still being written is tried again (`links.updateChanged`).
+  - Beyond the target app: a document whose own file another app, a script or an agent changes while it is open reads it again in place, as File › Revert does (same tab and view, history cleared), when it has no unsaved changes; with unsaved changes the app asks first, when the document is in front, and Cancel keeps them until the file changes again. On the desktop one worker thread looks at every open document's file (size, modification time and the platform's creation/inode stamps; no reading) every 250 ms, and doesn't wake the app until a file changed; a changed file is read once it has stopped changing, an SVG or PDF only once it ends as a whole file does. VectorCraft's own saves are never taken for changes, and a version found before a save isn't applied over it. Modelled on PdfCraft's watcher (`crates/ui-egui/src/file_watch.rs`, #3282).
   - Import and paste, 2026-10-13: a multi-artboard `.ai` with notes beside its artboards opens from its editing data, a page's art past its artboard no longer lying over the page before it (#1528); a `.ai` gradient whose stops are listed upwards keeps each midpoint on the stop that starts its part of the ramp (#1530); SVG text on a path follows basic shapes (circle, ellipse, rect, line, polyline, polygon) as their SVG 2 equivalent paths (#1531, fixes #1527); PDFs whose alpha soft masks are drawn as images import with the mask instead of empty art (#1551); EPS and PostScript type set in Type 42 (TrueType) fonts imports at its size instead of 1000 times too large (#1553); PDF and EPS import keep a clipping path whose bounding box covers the page but whose shape doesn't (#1544, fixes #1542). Opening or placing a file with an image too large to decode says so, and the Links panel shows it on the row (#1540, part of #1479). Paste takes a PDF or EPS another app copied as editable vector art: from the macOS pasteboard (with SVG), EPS on Windows beside PDF, and `.eps` files copied in a file manager, with the new command `clipboard.importEps` (#1538, fixes #1499).
   - A `.ai` or `.ait` file copied in a file manager pastes as its art: one that holds a PDF pastes the objects of its editing data, and one saved as PostScript pastes as a copied `.eps` file does.
   - A copied `.ai` or `.ait` file that holds a PDF pastes the objects of its editing data as File › Open reads them: every layer's objects go into the active layer in their stacking order, with their names, hidden objects and type objects, instead of the PDF page's art with its type as outlines. When the editing data can't be read, the file pastes its page as before (`clipboard.importPdf {editingData}`). The objects of a hidden layer come in hidden, from a pasted EPS too.
@@ -851,6 +852,7 @@ Evidence for the shared light Color: geometry tests in `tests_lighting`, effect
 
 | Date | Change | Summary |
 |---|---|---|
+| 2026-10-20 | minor | A document whose file changes on disk while it is open reloads in place when it has no unsaved changes, and asks first when it has (#3282, a file watcher modelled on PdfCraft's); engine and UI regressions. Scores and hours unchanged. |
 | 2026-10-19 | minor | Plug-ins: live effects are given their object's fills and strokes, read only, and the item they sit on (G17); engine and unit regressions. Scores and hours unchanged. |
 | 2026-10-19 | minor | Plug-ins: parameters with labels, units, help, groups and conditions in their dialogs, `string` and `numberList` types, up to 256 per plug-in (G17); manifest and kittest dialog regressions. Scores and hours unchanged. |
 | 2026-10-19 | minor | Plug-ins: exporter plug-ins add write-only file formats to Export As and `document.export` (G17); engine, ABI and headless Export As regressions. Scores and hours unchanged. |
