diff --git a/Cargo.lock b/Cargo.lock
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1408,6 +1408,7 @@ dependencies = [
  "figcraft-ui-egui",
  "image",
  "log",
+ "os-spellcheck",
  "rfd",
  "serde_json",
  "webbrowser",
@@ -2812,8 +2813,8 @@ dependencies = [
  "block2 0.5.1",
  "libc",
  "objc2 0.5.3",
- "objc2-core-data",
- "objc2-core-image",
+ "objc2-core-data 0.2.2",
+ "objc2-core-image 0.2.2",
  "objc2-foundation 0.2.2",
  "objc2-quartz-core 0.2.2",
 ]
@@ -2826,10 +2827,17 @@ checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c"
 dependencies = [
  "bitflags 2.13.2",
  "block2 0.6.2",
+ "libc",
  "objc2 0.6.5",
+ "objc2-cloud-kit 0.3.2",
+ "objc2-core-data 0.3.2",
  "objc2-core-foundation",
  "objc2-core-graphics",
+ "objc2-core-image 0.3.2",
+ "objc2-core-text",
+ "objc2-core-video",
  "objc2-foundation 0.3.2",
+ "objc2-quartz-core 0.3.2",
 ]
 
 [[package]]
@@ -2845,6 +2853,17 @@ dependencies = [
  "objc2-foundation 0.2.2",
 ]
 
+[[package]]
+name = "objc2-cloud-kit"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c"
+dependencies = [
+ "bitflags 2.13.2",
+ "objc2 0.6.5",
+ "objc2-foundation 0.3.2",
+]
+
 [[package]]
 name = "objc2-contacts"
 version = "0.2.2"
@@ -2868,6 +2887,17 @@ dependencies = [
  "objc2-foundation 0.2.2",
 ]
 
+[[package]]
+name = "objc2-core-data"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa"
+dependencies = [
+ "bitflags 2.13.2",
+ "objc2 0.6.5",
+ "objc2-foundation 0.3.2",
+]
+
 [[package]]
 name = "objc2-core-foundation"
 version = "0.3.2"
@@ -2904,6 +2934,16 @@ dependencies = [
  "objc2-metal 0.2.2",
 ]
 
+[[package]]
+name = "objc2-core-image"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006"
+dependencies = [
+ "objc2 0.6.5",
+ "objc2-foundation 0.3.2",
+]
+
 [[package]]
 name = "objc2-core-location"
 version = "0.2.2"
@@ -2916,6 +2956,31 @@ dependencies = [
  "objc2-foundation 0.2.2",
 ]
 
+[[package]]
+name = "objc2-core-text"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d"
+dependencies = [
+ "bitflags 2.13.2",
+ "objc2 0.6.5",
+ "objc2-core-foundation",
+ "objc2-core-graphics",
+]
+
+[[package]]
+name = "objc2-core-video"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6"
+dependencies = [
+ "bitflags 2.13.2",
+ "objc2 0.6.5",
+ "objc2-core-foundation",
+ "objc2-core-graphics",
+ "objc2-io-surface",
+]
+
 [[package]]
 name = "objc2-encode"
 version = "4.1.0"
@@ -2943,6 +3008,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
 dependencies = [
  "bitflags 2.13.2",
  "block2 0.6.2",
+ "libc",
  "objc2 0.6.5",
  "objc2-core-foundation",
 ]
@@ -3040,9 +3106,9 @@ dependencies = [
  "bitflags 2.13.2",
  "block2 0.5.1",
  "objc2 0.5.3",
- "objc2-cloud-kit",
- "objc2-core-data",
- "objc2-core-image",
+ "objc2-cloud-kit 0.2.2",
+ "objc2-core-data 0.2.2",
+ "objc2-core-image 0.2.2",
  "objc2-core-location",
  "objc2-foundation 0.2.2",
  "objc2-link-presentation",
@@ -3129,6 +3195,18 @@ dependencies = [
  "pin-project-lite",
 ]
 
+[[package]]
+name = "os-spellcheck"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0062960325585b13f5162fb61d236054ab5c300071d5e49dfcc37c98338b120c"
+dependencies = [
+ "objc2 0.6.5",
+ "objc2-app-kit 0.3.2",
+ "objc2-foundation 0.3.2",
+ "windows",
+]
+
 [[package]]
 name = "parking"
 version = "2.2.1"
diff --git a/ROADMAP.md b/ROADMAP.md
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -2,7 +2,7 @@
 
 **Stage: alpha** · next: beta, ~28 points and ~200–365 h away (one Opus 5.5 agent; ~40–80 h wall-clock with 5 agents)
 
-> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (the standalone server with share links to edit or view, wording only; live boolean editing and the vector tail: feature breadth ~79%; ready ~47%) · **Target:** Figma Design (desktop app 126.10.7)
+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (spell check while editing text on Windows and macOS; menu breadth 201/204; the standalone server with share links to edit or view, wording only; live boolean editing and the vector tail: feature breadth ~79%; ready ~47%) · **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
@@ -13,7 +13,7 @@ start with [`docs/gaps.md`](docs/gaps.md).
 
 | | Value | Kind |
 |---|---|---|
-| Menu/command breadth | **98.5%** (200 / 203) | measured, [`docs/parity-checklist.md`](docs/parity-checklist.md) (`cargo xtask parity`) |
+| Menu/command breadth | **98.5%** (201 / 204) | measured, [`docs/parity-checklist.md`](docs/parity-checklist.md) (`cargo xtask parity`) |
 | Feature breadth | **~79%** (core editor ~90%) | estimated, [`docs/target-app-parity.md`](docs/target-app-parity.md) |
 | Ready for real work | **~47%** | estimated, same doc (written dimension weights and scores) |
 | Essentials user | **~53%** | estimated: core features 77.8% × 0.92 launch/stability × 0.92 discoverability × 0.8 opening files people send ([method](docs/target-app-parity.md#essentials-user)) |
@@ -26,7 +26,7 @@ start with [`docs/gaps.md`](docs/gaps.md).
 | Audience | Ready % | Opus 5.5 agent wall-clock hours to ~95% | Work that dominates |
 |---|---|---|---|
 | Full target (ready for real work) | ~47% | ~370–695 h | multiplayer (45–90), localization (60–100), plugins (25–55), the interaction pass (40–60), `.fig` import (30–60), GPU canvas (30–60), AI (30–60) |
-| Mainstream practitioner | ~43% | ~175–320 h | core editor depth incl. `.fig` import (91–177), the interaction pass (40–60), real-file corpus and platform QA (25–50), `.fig` export for handing work back (15–30) |
+| Mainstream practitioner | ~43% | ~175–320 h | core editor depth incl. `.fig` import (88–173), the interaction pass (40–60), real-file corpus and platform QA (25–50), `.fig` export for handing work back (15–30) |
 | Essentials user | ~53% | ~85–160 h | `.fig` import so files people send open (30–60), core tool polish (25–45), interaction pass on the core tools (15–25), stability QA (10–20), onboarding and starter templates (5–10) |
 
 Hours are one agent working sequentially, calibrated from this repo's history (about 1 agent-hour
@@ -56,7 +56,7 @@ the localization layer with Figma's own languages (20–35 h).
 
 | Dimension | Ready | Hours | Doc |
 |---|---|---|---|
-| Features (depth) | 57% (core editor 67%) | 71–135 | [target-app-parity](docs/target-app-parity.md) |
+| Features (depth) | 57% (core editor 67%) | 68–131 | [target-app-parity](docs/target-app-parity.md) |
 | UI/UX fidelity | 58% | 40–60 | [ui-parity](docs/ui-parity.md) |
 | File formats | 40% (no `.fig`) | 30–60 | [file-format-parity](docs/file-format-parity.md) |
 | Collaboration | 12% (core in; live sessions with document sync, cursors and text carets, the browser joins, invite links open the app; a self-hosted server with edit and view links; preview) | 55–110 | [collaboration-parity](docs/collaboration-parity.md) |
@@ -81,7 +81,7 @@ the localization layer with Figma's own languages (20–35 h).
 | Effects | 100% | 72% | 3–6 | [target-app-parity](docs/target-app-parity.md) |
 | Frames, groups, sections, masks | 95% | 75% | 2–4 | [target-app-parity](docs/target-app-parity.md) |
 | Auto layout & constraints | 95% | 68% | 6–12 | [auto-layout-parity](docs/auto-layout-parity.md) |
-| Text | 85% | 64% | 8–14 | [target-app-parity](docs/target-app-parity.md) |
+| Text | 88% | 66% | 5–10 | [target-app-parity](docs/target-app-parity.md) |
 | Components & variants | 95% | 68% | 6–12 | [components-parity](docs/components-parity.md) |
 | Styles & variables | 85% | 70% | 4–8 | [components-parity](docs/components-parity.md) |
 | Prototyping | 88% | 70% | 5–9 | [prototyping-parity](docs/prototyping-parity.md) |
@@ -157,6 +157,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
 
 | Date | Change | Summary |
 |---|---|---|
+| 2026-10-11 | minor | Spell check while editing text: misspelled words underlined, suggestions, Ignore and Add to dictionary in the context menu, Text ▸ Spelling ▸ Check spelling (the system's checker on Windows and macOS, in the system's language; none on Linux, FreeBSD or the web yet). Text 85% → 88% breadth, 64% → 66% ready, 8–14 h → 5–10 h; menu breadth 200/203 → 201/204 (98.5%); gap 11 narrowed to the remaining platforms and languages (4–8 h → 2–5 h); weighted percentages unchanged, hour totals down 3–4 h (headline ranges unchanged within their rounding) |
 | 2026-10-11 | minor | Collaboration wording after the standalone server: `figcraft-server` keeps many files, persisted as a snapshot plus a log of what changed (compacted, checksummed, damaged files skipped and reported), restarts lose nothing confirmed and apps resume where they were; the app publishes its file to it; share links per file let people edit or only view (the server refuses a viewer's edits), are revocable and stored as hashes; TLS comes from a reverse proxy (the desktop app has none of its own yet); percentages and hours unchanged until the next re-measure |
 | 2026-10-11 | minor | Live boolean editing, Design panel arc fields, the shape builder on strokes and open paths and one-sided variable widths landed (gap 13): Shapes & vector breadth 92% → 97%, ready 66% → 68%, 8–14 h → 1–3 h; feature breadth ~78% → ~79%; ready for real work unchanged (~47%); full parity ~390–745 h → ~385–730 h |
 | 2026-10-11 | minor | Collaboration wording after the collaboration polish: styles, variables, collections, modes and comments created in a live session take ids from their creator's id space (concurrent creations no longer overwrite each other; the server checks it), others' text carets and selections show on the canvas, and typing in a layer someone else is typing in says so; percentages unchanged |
diff --git a/apps/figcraft/Cargo.toml b/apps/figcraft/Cargo.toml
--- a/apps/figcraft/Cargo.toml
+++ b/apps/figcraft/Cargo.toml
@@ -29,5 +29,9 @@ env_logger = { version = "0.11", default-features = false, features = ["auto-col
 [target.'cfg(windows)'.dependencies]
 windows = { version = "0.62", default-features = false, features = ["UI_ViewManagement"] }
 
+# The system's spell checker (ISpellChecker, NSSpellChecker).
+[target.'cfg(any(windows, target_os = "macos"))'.dependencies]
+os-spellcheck = "0.2"
+
 [build-dependencies]
 winresource = "0.1"
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
@@ -120,6 +120,12 @@ fn main() -> eframe::Result {
     // default, never a build from source unless asked).
     session.live.links = figcraft_collab_net::links::system();
     session.invite_links_startup();
+    // shortcut: no spell check on Linux and FreeBSD (their checkers are C libraries), add a
+    // hunspell/enchant checker when Linux users ask.
+    #[cfg(any(windows, target_os = "macos"))]
+    {
+        session.spelling.checker = Some(Box::new(services::SystemSpelling::default()));
+    }
     if session.docs.is_empty() {
         let _ = session.execute("file.new", &json!({}));
     }
diff --git a/apps/figcraft/src/services.rs b/apps/figcraft/src/services.rs
--- a/apps/figcraft/src/services.rs
+++ b/apps/figcraft/src/services.rs
@@ -160,6 +160,22 @@ impl Services for Desktop {
     }
 }
 
+/// The system's spell checker, connected on first use from the UI thread (on Windows, COM is set
+/// up there once the window exists).
+#[cfg(any(windows, target_os = "macos"))]
+#[derive(Default)]
+pub struct SystemSpelling(Option<os_spellcheck::SpellChecker>);
+
+#[cfg(any(windows, target_os = "macos"))]
+impl figcraft_engine::spelling::SpellChecker for SystemSpelling {
+    fn misspelled(&mut self, word: &str) -> bool {
+        !self.0.get_or_insert_with(os_spellcheck::SpellChecker::new).check(word).is_empty()
+    }
+    fn suggest(&mut self, word: &str) -> Vec<String> {
+        self.0.get_or_insert_with(os_spellcheck::SpellChecker::new).suggestions(word)
+    }
+}
+
 /// The window icon from the bundled app icon (if present).
 pub fn window_icon() -> Option<egui::IconData> {
     let bytes = include_bytes!("../../../assets/app-icon/figcraft-256.png");
diff --git a/crates/engine/src/catalog.rs b/crates/engine/src/catalog.rs
--- a/crates/engine/src/catalog.rs
+++ b/crates/engine/src/catalog.rs
@@ -198,6 +198,8 @@ pub static MENU: &[MenuItem] = &[
     m!("Text/Case", "Uppercase", "text.caseUpper"),
     m!("Text/Case", "Lowercase", "text.caseLower"),
     m!("Text/Case", "Title case", "text.caseTitle"),
+    m!("Text", SEP, ""),
+    m!("Text/Spelling", "Check spelling", "text.spelling"),
     // Arrange
     m!("Arrange", "Align left", "align.left"),
     m!("Arrange", "Align horizontal centers", "align.hcenter"),
diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs
--- a/crates/engine/src/cmd/mod.rs
+++ b/crates/engine/src/cmd/mod.rs
@@ -97,6 +97,7 @@ fn registry() -> &'static Registry {
         list.extend(crate::plugins::specs());
         list.extend(crate::live::specs());
         list.extend(crate::invite_links::specs());
+        list.extend(crate::spelling::specs());
         let mut index = HashMap::new();
         for (i, s) in list.iter().enumerate() {
             if index.insert(s.id, i).is_some() {
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
@@ -34,6 +34,7 @@ pub mod plugins;
 pub mod proto_run;
 pub mod shapebuilder;
 pub mod snap;
+pub mod spelling;
 pub mod synthetic;
 pub mod synthetic_ds;
 pub mod textedit;
@@ -456,6 +457,10 @@ pub struct Prefs {
     /// `figcraft://` invite links open this app (`collab.registerLinks`); `None`: on for installed
     /// builds, off for development builds.
     pub invite_links: Option<bool>,
+    /// Underline misspelled words while editing text (`text.spelling`).
+    pub spell_check: bool,
+    /// Words never flagged as misspelled (`spelling.addWord`), lowercased.
+    pub dictionary: Vec<String>,
     /// Installed plugins (Plugins menu), in menu order.
     #[serde(serialize_with = "plugins::persisted")]
     pub plugins: Vec<plugins::InstalledPlugin>,
@@ -475,6 +480,8 @@ impl Default for Prefs {
             user_name: cmd::comment::DEFAULT_AUTHOR.into(),
             reduce_motion: false,
             invite_links: None,
+            spell_check: true,
+            dictionary: Vec::new(),
             plugins: Vec::new(),
         }
     }
@@ -526,6 +533,8 @@ pub struct Session {
     pub plugins: plugins::Runtime,
     /// The live session (collaboration): status, peers, presence.
     pub live: live::Live,
+    /// Spell check while editing text.
+    pub spelling: spelling::Spelling,
 }
 
 impl Default for Session {
@@ -558,6 +567,7 @@ impl Session {
             libraries: library::Cache::default(),
             plugins: plugins::Runtime::default(),
             live: live::Live::default(),
+            spelling: spelling::Spelling::default(),
         }
     }
 
@@ -1243,6 +1253,8 @@ mod shapebuilder_tests;
 #[cfg(test)]
 mod slot_tests;
 #[cfg(test)]
+mod spelling_tests;
+#[cfg(test)]
 mod style_tests;
 #[cfg(test)]
 mod svg_import_tests;
diff --git a/crates/engine/src/spelling.rs b/crates/engine/src/spelling.rs
new file mode 100644
--- /dev/null
+++ b/crates/engine/src/spelling.rs
@@ -0,0 +1,222 @@
+//! Spell check while editing text (milestone M4): misspelled words in the text being edited are
+//! underlined, and a flagged word's context menu offers suggestions, Ignore and Add to dictionary.
+//!
+//! The engine stays platform-free: the desktop app installs the operating system's checker as a
+//! [`SpellChecker`] (like the [`LiveLink`](crate::live::LiveLink)). Words are checked one at a
+//! time and cached, and the edited text's flagged words are worked out again only when the
+//! document changes ([`Session::sync_spelling`]), never per frame.
+
+use std::collections::{HashMap, HashSet};
+use std::sync::Arc;
+
+use figcraft_doc::{Document, NodeId};
+use serde_json::{Value, json};
+
+use crate::cmd::{CommandSpec, ok};
+use crate::params::{boolean, req_index, req_string};
+use crate::{EngineError, Result, Session, bad, spec, textedit};
+
+/// The system's spell checker (the desktop app installs one: `session.spelling.checker`).
+// shortcut: one language, the system's (no Text ▸ Spelling ▸ Language), add a language list to
+// this trait when someone needs to check text in another language.
+pub trait SpellChecker {
+    /// Whether `word` (one word, no spaces) is misspelled.
+    fn misspelled(&mut self, word: &str) -> bool;
+    /// Replacements for a misspelled `word`, best first.
+    fn suggest(&mut self, word: &str) -> Vec<String>;
+}
+
+/// Spell-check state: the checker, its answers so far and the words ignored until the app quits.
+#[derive(Default)]
+pub struct Spelling {
+    pub checker: Option<Box<dyn SpellChecker>>,
+    /// Word → misspelled, as the checker answered.
+    known: HashMap<String, bool>,
+    /// Lowercased words ignored for this run of the app.
+    ignored: HashSet<String>,
+    /// The flagged words of text layer `NodeId` in that document.
+    flagged: Option<(Arc<Document>, NodeId, Ranges)>,
+}
+
+/// Char ranges.
+type Ranges = Vec<(usize, usize)>;
+
+/// Most suggestions offered for a word.
+const MAX_SUGGESTIONS: usize = 5;
+/// Longest word added to the dictionary, and most words in it (and in the checker's cache).
+const MAX_WORD: usize = 64;
+const MAX_DICTIONARY: usize = 10_000;
+
+/// Scripts written without spaces between words: CJK, kana, Thai, Lao, Myanmar, Khmer.
+// shortcut: text in these scripts is never checked (a run of it isn't one word, and the system
+// checkers flag whole runs too), split it with a word breaker when someone needs it checked.
+fn unspaced(c: char) -> bool {
+    matches!(c as u32, 0x0E00..=0x0EFF | 0x1000..=0x109F | 0x1780..=0x17FF | 0x2E80..=0x9FFF | 0xF900..=0xFAFF | 0xFF66..=0xFF9F | 0x20000..)
+}
+
+/// The words of `chars` as char ranges: letters and digits, with apostrophes inside a word
+/// ("don't").
+fn words(chars: &[char]) -> Vec<(usize, usize)> {
+    let letter = |i: usize| chars.get(i).is_some_and(|c| c.is_alphanumeric() && !unspaced(*c));
+    let mut out = Vec::new();
+    let mut i = 0;
+    while i < chars.len() {
+        if !letter(i) {
+            i += 1;
+            continue;
+        }
+        let a = i;
+        while letter(i) || (matches!(chars.get(i), Some('\'' | '\u{2019}')) && letter(i + 1)) {
+            i += 1;
+        }
+        out.push((a, i));
+    }
+    out
+}
+
+impl Session {
+    /// Whether `word` should be flagged: the checker says so, and it isn't a number, ignored or in
+    /// the dictionary.
+    fn is_misspelled(&mut self, word: &str) -> bool {
+        if word.chars().any(char::is_numeric) {
+            return false;
+        }
+        let lower = word.to_lowercase();
+        if self.prefs.dictionary.contains(&lower) || self.spelling.ignored.contains(&lower) {
+            return false;
+        }
+        let sp = &mut self.spelling;
+        let Some(checker) = sp.checker.as_mut() else { return false };
+        if let Some(m) = sp.known.get(word) {
+            return *m;
+        }
+        if sp.known.len() >= MAX_DICTIONARY {
+            sp.known.clear();
+        }
+        let m = checker.misspelled(word);
+        sp.known.insert(word.to_string(), m);
+        m
+    }
+
+    /// Work out the edited text's misspelled words again if the document changed (the UI calls
+    /// it before drawing; cheap otherwise).
+    pub fn sync_spelling(&mut self) {
+        let edited = self.ds().ok().and_then(|ds| Some((ds.doc.clone(), ds.text_edit.as_ref()?.id)));
+        let Some((doc, id)) = edited.filter(|_| self.prefs.spell_check && self.spelling.checker.is_some()) else {
+            self.spelling.flagged = None;
+            return;
+        };
+        if self.spelling.flagged.as_ref().is_some_and(|(d, i, _)| Arc::ptr_eq(d, &doc) && *i == id) {
+            return;
+        }
+        let chars: Vec<char> = doc.node(id).and_then(|n| n.kind.text()).map(|t| t.characters.chars().collect()).unwrap_or_default();
+        let flagged = words(&chars)
+            .into_iter()
+            .filter(|(a, b)| {
+                let word: String = chars.get(*a..*b).unwrap_or_default().iter().collect();
+                self.is_misspelled(&word)
+            })
+            .collect();
+        self.spelling.flagged = Some((doc, id, flagged));
+    }
+
+    /// The misspelled words (char ranges) of the text being edited, as of the last
+    /// [`Session::sync_spelling`].
+    pub fn misspelled(&self) -> &[(usize, usize)] {
+        self.spelling.flagged.as_ref().map_or(&[], |(_, _, f)| f)
+    }
+
+    /// The word at char `index` of the text being edited (an index just after a word counts):
+    /// its char range and text.
+    fn word_at(&self, cmd: &str, index: usize) -> Result<Option<(usize, usize, String)>> {
+        let te = self.ds()?.text_edit.clone().ok_or_else(|| EngineError::Other(format!("{cmd}: not editing text")))?;
+        let chars: Vec<char> = self.doc()?.node(te.id).and_then(|n| n.kind.text()).map(|t| t.characters.chars().collect()).unwrap_or_default();
+        Ok(words(&chars)
+            .into_iter()
+            .find(|(a, b)| (*a..=*b).contains(&index))
+            .map(|(a, b)| (a, b, chars.get(a..b).unwrap_or_default().iter().collect())))
+    }
+}
+
+fn toggle(s: &mut Session, p: &Value) -> Result<Value> {
+    let on = boolean(p, "on").unwrap_or(!s.prefs.spell_check);
+    s.prefs.spell_check = on;
+    s.spelling.flagged = None;
+    Ok(json!({"on": on, "available": s.spelling.checker.is_some()}))
+}
+
+fn suggestions(s: &mut Session, p: &Value) -> Result<Value> {
+    const CMD: &str = "text.spellingSuggestions";
+    let Some((a, b, word)) = s.word_at(CMD, req_index(CMD, p, "index")?)? else { return Ok(Value::Null) };
+    let misspelled = s.is_misspelled(&word);
+    let mut suggestions = match s.spelling.checker.as_mut() {
+        Some(c) if misspelled => c.suggest(&word),
+        _ => Vec::new(),
+    };
+    suggestions.truncate(MAX_SUGGESTIONS);
+    Ok(json!({"word": word, "start": a, "end": b, "misspelled": misspelled, "suggestions": suggestions}))
+}
+
+fn replace_word(s: &mut Session, p: &Value) -> Result<Value> {
+    const CMD: &str = "text.replaceWord";
+    let with = req_string(CMD, p, "with")?;
+    if with.is_empty() {
+        return Err(bad(CMD, "`with` can't be empty"));
+    }
+    let (a, b, _) = s.word_at(CMD, req_index(CMD, p, "index")?)?.ok_or_else(|| bad(CMD, "no word at `index`"))?;
+    textedit::set_caret(s, a, false)?;
+    textedit::set_caret(s, b, true)?;
+    textedit::insert(s, &with)?;
+    ok()
+}
+
+/// A word from params, lowercased: one word of reasonable length.
+fn word_param(cmd: &str, p: &Value) -> Result<String> {
+    let w = req_string(cmd, p, "word")?.trim().to_lowercase();
+    if w.is_empty() || w.chars().count() > MAX_WORD || w.chars().any(char::is_whitespace) {
+        return Err(bad(cmd, format!("`word` must be one word of at most {MAX_WORD} characters")));
+    }
+    Ok(w)
+}
+
+fn add_word(s: &mut Session, p: &Value) -> Result<Value> {
+    const CMD: &str = "spelling.addWord";
+    let w = word_param(CMD, p)?;
+    if !s.prefs.dictionary.contains(&w) {
+        if s.prefs.dictionary.len() >= MAX_DICTIONARY {
+            return Err(bad(CMD, format!("the dictionary is full ({MAX_DICTIONARY} words)")));
+        }
+        s.prefs.dictionary.push(w);
+    }
+    s.spelling.flagged = None;
+    ok()
+}
+
+fn ignore(s: &mut Session, p: &Value) -> Result<Value> {
+    let w = word_param("spelling.ignore", p)?;
+    s.spelling.ignored.insert(w);
+    s.spelling.flagged = None;
+    ok()
+}
+
+pub fn specs() -> Vec<CommandSpec> {
+    vec![
+        spec!(
+            "text.spelling",
+            "Check spelling",
+            "{on? (default: switch)} → {on, available}: underline misspelled words while editing text (the system's spell checker; `available` is false where there is none)",
+            false,
+            toggle
+        ),
+        spec!(
+            "text.spellingSuggestions",
+            "Spelling suggestions",
+            "{index (char)} → {word, start, end, misspelled, suggestions} for the word at `index` of the text being edited, or null",
+            false,
+            suggestions
+        ),
+        spec!("text.replaceWord", "Replace word", "{index (char), with}: replace the word at `index` of the text being edited", true, replace_word),
+        spec!("spelling.addWord", "Add to dictionary", "{word}: never flag this word (saved in preferences)", false, add_word),
+        spec!("spelling.ignore", "Ignore", "{word}: don't flag this word until the app quits", false, ignore),
+    ]
+}
diff --git a/crates/engine/src/spelling_tests.rs b/crates/engine/src/spelling_tests.rs
new file mode 100644
--- /dev/null
+++ b/crates/engine/src/spelling_tests.rs
@@ -0,0 +1,92 @@
+//! Spell check while editing text, against a fake checker (tests never touch the system's).
+
+use std::sync::{Arc, Mutex};
+
+use serde_json::json;
+
+use crate::Session;
+use crate::params::id_of;
+use crate::spelling::SpellChecker;
+
+/// Knows a few words; counts how often it's asked.
+struct Fake(Arc<Mutex<usize>>);
+
+impl SpellChecker for Fake {
+    fn misspelled(&mut self, word: &str) -> bool {
+        *self.0.lock().unwrap() += 1;
+        !["Hello", "world", "don't", "the"].contains(&word)
+    }
+    fn suggest(&mut self, word: &str) -> Vec<String> {
+        vec![format!("{word}!"), "world".into()]
+    }
+}
+
+/// Editing a text layer "Hello wrold, don't Helo 42" with the fake checker installed.
+fn editing() -> (Session, Arc<Mutex<usize>>) {
+    let asked = Arc::new(Mutex::new(0));
+    let mut s = Session::new();
+    s.spelling.checker = Some(Box::new(Fake(asked.clone())));
+    let t = id_of(&s.execute("text.create", &json!({"text": "Hello wrold, don't Helo 42", "x": 0, "y": 0})).unwrap()["id"]).unwrap();
+    s.execute("text.edit.begin", &json!({"id": t, "caret": 0})).unwrap();
+    s.sync_spelling();
+    (s, asked)
+}
+
+#[test]
+fn misspelled_words_are_flagged_and_cached() {
+    let (mut s, asked) = editing();
+    // "wrold" and "Helo"; the apostrophe stays inside "don't" and numbers aren't words to check.
+    assert_eq!(s.misspelled(), &[(6, 11), (19, 23)]);
+    let n = *asked.lock().unwrap();
+    s.sync_spelling();
+    assert_eq!(*asked.lock().unwrap(), n, "nothing changed: nothing checked again");
+    s.execute("text.edit.key", &json!({"key": "End"})).unwrap();
+    s.execute("text.edit.insert", &json!({"text": " Hello"})).unwrap();
+    s.sync_spelling();
+    assert_eq!(*asked.lock().unwrap(), n, "known words come from the cache");
+    s.execute("text.spelling", &json!({"on": false})).unwrap();
+    s.sync_spelling();
+    assert!(s.misspelled().is_empty(), "switched off");
+    assert!(!s.prefs.spell_check);
+}
+
+#[test]
+fn scripts_without_spaces_are_not_checked() {
+    let (mut s, _) = editing();
+    s.execute("text.edit.key", &json!({"key": "End"})).unwrap();
+    s.execute("text.edit.insert", &json!({"text": " 这是中文 สวัสดี Привет"})).unwrap();
+    s.sync_spelling();
+    // CJK and Thai runs aren't single words; Cyrillic is checked like any other spaced script.
+    assert_eq!(s.misspelled(), &[(6, 11), (19, 23), (39, 45)]);
+}
+
+#[test]
+fn suggestions_replace_the_word_in_one_undo_step() {
+    let (mut s, _) = editing();
+    let r = s.execute("text.spellingSuggestions", &json!({"index": 8})).unwrap();
+    assert_eq!(r, json!({"word": "wrold", "start": 6, "end": 11, "misspelled": true, "suggestions": ["wrold!", "world"]}));
+    let ok = s.execute("text.spellingSuggestions", &json!({"index": 0})).unwrap();
+    assert_eq!(ok["misspelled"], json!(false));
+    assert_eq!(ok["suggestions"], json!([]));
+    s.execute("text.replaceWord", &json!({"index": 11, "with": "world"})).unwrap();
+    s.execute("text.edit.end", &json!({})).unwrap();
+    let text = |s: &Session| s.doc().unwrap().node(s.selection()[0]).unwrap().kind.text().unwrap().characters.clone();
+    assert_eq!(text(&s), "Hello world, don't Helo 42");
+    s.execute("edit.undo", &json!({})).unwrap();
+    assert_eq!(text(&s), "Hello wrold, don't Helo 42");
+    // Not editing: nothing to replace.
+    assert!(s.execute("text.replaceWord", &json!({"index": 0, "with": "x"})).is_err());
+}
+
+#[test]
+fn ignore_and_add_to_dictionary() {
+    let (mut s, _) = editing();
+    s.execute("spelling.ignore", &json!({"word": "wrold"})).unwrap();
+    s.sync_spelling();
+    assert_eq!(s.misspelled(), &[(19, 23)]);
+    s.execute("spelling.addWord", &json!({"word": "Helo"})).unwrap();
+    s.sync_spelling();
+    assert!(s.misspelled().is_empty());
+    assert_eq!(s.prefs.dictionary, vec!["helo".to_string()], "saved lowercased in preferences");
+    assert!(s.execute("spelling.addWord", &json!({"word": "two words"})).is_err());
+}
diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs
--- a/crates/ui-egui/src/canvas.rs
+++ b/crates/ui-egui/src/canvas.rs
@@ -141,6 +141,7 @@ pub fn show(app: &mut FigApp, ui: &mut Ui, worker: &mut Worker) {
     input(app, ui, &resp);
     crate::live::track_pointer(app, &ctx);
     app.sync_preedit();
+    app.session.sync_spelling();
     request_render(app, worker, ppp);
     // Every finished render is shown, even when a newer one is already on its way: renders finish
     // in order, and an edit every frame (a drag) would otherwise never show until it stopped.
@@ -453,6 +454,7 @@ fn input(app: &mut FigApp, ui: &mut Ui, resp: &egui::Response) {
 /// open the context menu there.
 pub fn open_context_menu(app: &mut FigApp, pos: Pos2, deep: bool) {
     let p = app.to_page(pos);
+    app.ui.context_spelling = misspelled_at(app, p).map(|(i, w, sugg)| (pos, i, w, sugg));
     if let Ok(ds) = app.session.ds() {
         let (doc, page, sel, zoom) = (ds.doc.clone(), ds.page, ds.selection.clone(), ds.view.zoom.max(1e-6));
         if let Some(leaf) = app.session.hit(&doc, page, p, 2.0 / zoom) {
@@ -465,6 +467,23 @@ pub fn open_context_menu(app: &mut FigApp, pos: Pos2, deep: bool) {
     app.ui.context_menu = Some((pos, app.session.selection()));
 }
 
+/// The misspelled word at page point `p` of the text being edited: its char index, the word and
+/// the checker's suggestions.
+fn misspelled_at(app: &mut FigApp, p: Point) -> Option<(usize, String, Vec<String>)> {
+    let ds = app.session.ds().ok()?;
+    let id = ds.text_edit.as_ref()?.id;
+    if !ds.doc.world_bounds(id).is_some_and(|b| b.contains(p)) {
+        return None;
+    }
+    let index = app.session.text_index_at(id, p).ok()?;
+    let r = app.session.execute("text.spellingSuggestions", &json!({"index": index})).ok()?;
+    if r["misspelled"] != true {
+        return None;
+    }
+    let suggestions = r["suggestions"].as_array()?.iter().filter_map(|v| v.as_str().map(str::to_string)).collect();
+    Some((index, r["word"].as_str()?.to_string(), suggestions))
+}
+
 #[cfg(not(target_arch = "wasm32"))]
 fn dropped_bytes(f: &std::sync::Arc<dyn egui::DroppedFile + Send + Sync>) -> Option<Vec<u8>> {
     f.bytes().ok()
@@ -512,6 +531,29 @@ impl TextOnScreen {
         rects.iter().map(|r| [self.at(app, r.x0, r.y0), self.at(app, r.x1, r.y0), self.at(app, r.x1, r.y1), self.at(app, r.x0, r.y1)]).collect()
     }
 
+    /// A line just below the baseline under characters `a..b` (their glyphs only), one screen
+    /// segment per line.
+    pub(crate) fn underline(&self, app: &FigApp, a: usize, b: usize) -> Vec<[Pos2; 2]> {
+        let layout = &self.layout;
+        let rects = layout.selection_rects(a, b);
+        rects
+            .iter()
+            .filter_map(|r| {
+                let line = layout.lines.iter().find(|l| (l.top - r.y0).abs() < 1e-6)?;
+                let x1 = layout
+                    .glyphs
+                    .get(line.glyph_start..line.glyph_end)
+                    .unwrap_or(&[])
+                    .iter()
+                    .filter(|g| g.char_end > g.char_start && g.char_start >= a && g.char_end <= b)
+                    .map(|g| g.x + g.advance)
+                    .fold(r.x0, f64::max);
+                let y = line.baseline + (line.top + line.height - line.baseline) * 0.35;
+                Some([self.at(app, r.x0, y), self.at(app, x1, y)])
+            })
+            .collect()
+    }
+
     /// The caret before character `index`: its top and bottom on screen (an empty layer: a line
     /// of default height at its origin).
     pub(crate) fn caret(&self, app: &FigApp, index: usize, zoom: f64) -> [Pos2; 2] {
@@ -537,6 +579,20 @@ pub(crate) fn node_quad(app: &FigApp, doc: &Document, id: NodeId) -> Option<[Pos
     }))
 }
 
+/// A wavy line along screen segment `[p0, p1]` (misspelled words).
+fn squiggle(painter: &egui::Painter, [p0, p1]: [Pos2; 2], col: Color32) {
+    let d = p1 - p0;
+    let len = d.length();
+    if !len.is_finite() || len < 2.0 {
+        return;
+    }
+    let (u, n) = (d / len, vec2(-d.y, d.x) / len);
+    let steps = ((len / 3.0) as usize).min(4000);
+    let step = len / steps as f32;
+    let pts = (0..=steps).map(|i| p0 + u * (i as f32 * step) + n * if i % 2 == 0 { -1.5 } else { 1.5 }).collect();
+    painter.add(Shape::line(pts, stroke(col, 1.0)));
+}
+
 fn quad(painter: &egui::Painter, q: [Pos2; 4], s: Stroke) {
     painter.add(Shape::closed_line(q.to_vec(), s));
 }
@@ -830,7 +886,6 @@ fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) -> Vec<(NodeId, [
     if let Some(te) = editing
         && let Some(text) = TextOnScreen::of(edoc, te.id)
     {
-        let layout = &text.layout;
         if let Some(q) = node_quad(app, edoc, te.id) {
             quad(painter, q, stroke(ACCENT, 1.0));
         }
@@ -841,19 +896,15 @@ fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) -> Vec<(NodeId, [
             }
         }
         if let Some(p) = preedit {
-            for r in layout.selection_rects(p.range.0, p.range.1) {
-                // Underline just below the baseline, across the composed chars only.
-                let Some(line) = layout.lines.iter().find(|l| (l.top - r.y0).abs() < 1e-6) else { continue };
-                let x1 = layout
-                    .glyphs
-                    .get(line.glyph_start..line.glyph_end)
-                    .unwrap_or(&[])
-                    .iter()
-                    .filter(|g| g.char_end > g.char_start && g.char_start >= p.range.0 && g.char_end <= p.range.1)
-                    .map(|g| g.x + g.advance)
-                    .fold(r.x0, f64::max);
-                let y = line.baseline + (line.top + line.height - line.baseline) * 0.35;
-                painter.line_segment([text.at(app, r.x0, y), text.at(app, x1, y)], stroke(t.text, 1.0));
+            for seg in text.underline(app, p.range.0, p.range.1) {
+                painter.line_segment(seg, stroke(t.text, 1.0));
+            }
+        } else {
+            // Misspelled words, except the one being typed.
+            for &(a, b) in app.session.misspelled().iter().filter(|(a, b)| !(*a..=*b).contains(&te.caret)) {
+                for seg in text.underline(app, a, b) {
+                    squiggle(painter, seg, t.danger);
+                }
             }
         }
         let caret = preedit.map(|p| p.cursor).unwrap_or(te.caret);
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
@@ -230,6 +230,9 @@ pub struct UiState {
     pub preview: Option<present::Pane>,
     pub picker: Option<colorpicker::PickerTarget>,
     pub context_menu: Option<(egui::Pos2, Vec<NodeId>)>,
+    /// The misspelled word the context menu at that position was opened on (while editing text):
+    /// its char index, the word and the suggestions that head the menu.
+    pub context_spelling: Option<(egui::Pos2, usize, String, Vec<String>)>,
     /// Layers panel search: open, the filter text, the shift-click range anchor and the selection
     /// the tree last revealed (ancestors expand and the row scrolls into view when it changes).
     pub layer_search: bool,
@@ -321,6 +324,7 @@ impl Default for UiState {
             preview: None,
             picker: None,
             context_menu: None,
+            context_spelling: None,
             layer_search: false,
             layer_filter: String::new(),
             layer_anchor: None,
diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs
--- a/crates/ui-egui/src/menus.rs
+++ b/crates/ui-egui/src/menus.rs
@@ -183,7 +183,8 @@ fn menu_level(app: &mut FigApp, ui: &mut Ui, prefix: &str) -> bool {
             }
             last_sep = false;
             let enabled = available(m.id);
-            if item(ui, m.label, crate::shortcuts::shortcut(m.id).unwrap_or(""), enabled, false, &t) && enabled {
+            let checked = m.id == "text.spelling" && app.session.prefs.spell_check;
+            if item(ui, m.label, crate::shortcuts::shortcut(m.id).unwrap_or(""), enabled, checked, &t) && enabled {
                 clicked = Some(m.id);
             }
         } else if let Some(rest) = m.menu.strip_prefix(prefix).and_then(|r| r.strip_prefix('/')) {
@@ -363,6 +364,9 @@ pub fn popups(app: &mut FigApp, ctx: &egui::Context) {
         let mut close = false;
         let area = egui::Area::new(Id::new("ctx_menu")).fixed_pos(pos).order(egui::Order::Foreground).constrain(true).show(ctx, |ui| {
             menu_frame(ui, &t, |ui| {
+                if spelling_entries(app, ui, pos, &t) {
+                    close = true;
+                }
                 if select_layer_menu(app, ui, pos, &t) {
                     close = true;
                 }
@@ -461,6 +465,30 @@ pub fn popups(app: &mut FigApp, ctx: &egui::Context) {
     palette(app, ctx);
 }
 
+/// The context menu's entries for the misspelled word it was opened on at `pos`: its suggestions,
+/// Ignore and Add to dictionary. Returns whether one was picked.
+fn spelling_entries(app: &mut FigApp, ui: &mut Ui, pos: egui::Pos2, t: &Tokens) -> bool {
+    let Some((_, index, word, suggestions)) = app.ui.context_spelling.clone().filter(|c| c.0 == pos) else { return false };
+    let mut picked = None;
+    if suggestions.is_empty() {
+        item(ui, "No suggestions", "", false, false, t);
+    }
+    for s in &suggestions {
+        if item(ui, s, "", true, false, t) {
+            picked = Some(("text.replaceWord", json!({"index": index, "with": s})));
+        }
+    }
+    separator(ui);
+    if item(ui, "Ignore", "", true, false, t) {
+        picked = Some(("spelling.ignore", json!({"word": word})));
+    }
+    if item(ui, "Add to dictionary", "", true, false, t) {
+        picked = Some(("spelling.addWord", json!({"word": word})));
+    }
+    separator(ui);
+    picked.map(|(id, p)| app.run(id, p)).is_some()
+}
+
 fn select_layer_id() -> Id {
     Id::new("ctx_select_layer_open")
 }
diff --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-11 · **Change:** minor (the standalone server with share links to edit or view, wording only; live boolean editing and the vector tail: feature breadth ~79%) · **Target:** Figma Design (desktop app 126.10.7)
+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (spell check while editing text on Windows and macOS; menu breadth 201/204; the standalone server with share links to edit or view, wording only; live boolean editing and the vector tail: feature breadth ~79%) · **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
@@ -24,7 +24,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe
 | 8 | Platform QA is mostly macOS | Stability/platforms | 10–20 | [hardware-parity](hardware-parity.md) |
 | 9 | Figma Draw: brushes, pressure, text on a path, pattern fills | Feature | 10–18 | [target-app-parity](target-app-parity.md) |
 | 10 | Shared (team) libraries | Feature | 10–20 | [components-parity](components-parity.md) |
-| 11 | Spell check | Feature | 4–8 | [target-app-parity](target-app-parity.md) |
+| 11 | Spell check on Linux, FreeBSD and the web; one language | Feature | 2–5 | [target-app-parity](target-app-parity.md) |
 | 12 | Video and animated GIF fills; HEIC/TIFF images | File format | 6–12 | [file-format-parity](file-format-parity.md) |
 | 13 | Boolean groups hit-tested by their operands (the rest of the vector tail landed) | Feature | 1–3 | [target-app-parity](target-app-parity.md) |
 | 14 | Variables: extended collections, import/export | Feature | 4–8 | [components-parity](components-parity.md) |
@@ -153,11 +153,15 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe
 - **Impact:** design-system teams. Builds on multiplayer's server.
 - **Estimate:** 10–20 h after gap 2.
 
-### 11. Spell check
-- **Missing:** spelling underline and suggestions while editing text.
-- **Evidence:** no spell-check code in the repo.
-- **Estimate:** 4–8 h with a permissively licensed dictionary and checker (provenance per
-  `ATTRIBUTION.md`).
+### 11. Spell check on Linux, FreeBSD and the web; one language
+- **Have:** misspelled words in the text being edited get a wavy underline; right-click one for
+  suggestions, Ignore (until the app quits) and Add to dictionary (saved in preferences); Text ▸
+  Spelling ▸ Check spelling, on by default (`crates/engine/src/spelling.rs`). The desktop app uses
+  the system's checker on Windows and macOS, in the system's language.
+- **Missing:** a checker on Linux and FreeBSD (hunspell or enchant) and in the browser; choosing the
+  language (Text ▸ Spelling ▸ Language); text in scripts written without spaces (CJK, Thai) isn't
+  checked; Add to dictionary doesn't reach the system's dictionary.
+- **Estimate:** 2–5 h.
 
 ### 12. Video and animated GIF fills; HEIC/TIFF images
 - **Missing:** Figma takes JPG, PNG, HEIC, WebP, GIF and TIFF images and MP4, MOV and WebM video
@@ -230,6 +234,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe
 
 | Date | Change | Summary |
 |---|---|---|
+| 2026-10-11 | minor | Spell check while editing text: misspelled words underlined, suggestions, Ignore and Add to dictionary in the context menu, Text ▸ Spelling ▸ Check spelling (the system's checker on Windows and macOS, in the system's language; none on Linux, FreeBSD or the web yet). Text 85% → 88% breadth, 64% → 66% ready, 8–14 h → 5–10 h; menu breadth 200/203 → 201/204 (98.5%); gap 11 narrowed to the remaining platforms and languages (4–8 h → 2–5 h); weighted percentages unchanged, hour totals down 3–4 h (headline ranges unchanged within their rounding) |
 | 2026-10-11 | minor | Collaboration wording after the standalone server: `figcraft-server` keeps many files, persisted as a snapshot plus a log of what changed (compacted, checksummed, damaged files skipped and reported), restarts lose nothing confirmed and apps resume where they were; the app publishes its file to it; share links per file let people edit or only view (the server refuses a viewer's edits), are revocable and stored as hashes; TLS comes from a reverse proxy (the desktop app has none of its own yet); percentages and hours unchanged until the next re-measure |
 | 2026-10-11 | minor | Live boolean editing, Design panel arc fields, the shape builder on strokes and open paths and one-sided variable widths landed (gap 13): Shapes & vector breadth 92% → 97%, ready 66% → 68%, 8–14 h → 1–3 h; feature breadth ~78% → ~79%; ready for real work unchanged (~47%); full parity ~390–745 h → ~385–730 h |
 | 2026-10-11 | minor | Collaboration wording after the collaboration polish: styles, variables, collections, modes and comments created in a live session take ids from their creator's id space (concurrent creations no longer overwrite each other; the server checks it), others' text carets and selections show on the canvas, and typing in a layer someone else is typing in says so; percentages unchanged |
diff --git a/docs/mcp.md b/docs/mcp.md
--- a/docs/mcp.md
+++ b/docs/mcp.md
@@ -465,6 +465,17 @@ visibility, text, font properties, single solid fills and strokes) and `explicit
 link to them, so switching a mode with `variable.setMode` restyles the layers (`mode: null` sets a
 frame back to Auto, inheriting its parent's mode).
 
+### Spell check
+
+While a text layer is being edited, the desktop app underlines misspelled words (the system's
+checker on Windows and macOS, in the system's language; the headless server and the browser have
+none, so nothing is flagged there). `text.spelling {on?}` switches it (saved in preferences;
+`available` says whether a checker is installed). With text being edited,
+`text.spellingSuggestions {index}` returns the word at char `index` with `misspelled` and up to five
+`suggestions` (null between words), and `text.replaceWord {index, with}` replaces that word (part of
+the editing session's undo step). `spelling.addWord {word}` never flags a word again (saved in
+preferences) and `spelling.ignore {word}` stops flagging it until the app quits.
+
 ### Comments
 
 Comment threads live in the file, pinned to a page point or to a layer (`anchor`: a node id, or
diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md
--- a/docs/parity-checklist.md
+++ b/docs/parity-checklist.md
@@ -2,9 +2,9 @@
 
 > **Generated:** 2026-10-11 by `cargo xtask parity`. Do not edit by hand.
 
-FigCraft commands registered for the incumbent's main menu and toolbar (455 commands in the engine).
+FigCraft commands registered for the incumbent's main menu and toolbar (469 commands in the engine).
 
-**200 / 203 menu items (98.5%)**
+**201 / 204 menu items (98.5%)**
 
 ## Missing
 
diff --git a/docs/roadmap.md b/docs/roadmap.md
--- a/docs/roadmap.md
+++ b/docs/roadmap.md
@@ -1,6 +1,6 @@
 # FigCraft roadmap: milestones and what's next
 
-> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (the standalone server with share links to edit or view, wording only; live boolean editing and the vector tail: feature breadth ~79%) · **Target:** Figma Design (desktop app 126.10.7)
+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (spell check while editing text on Windows and macOS; menu breadth 201/204; the standalone server with share links to edit or view, wording only; live boolean editing and the vector tail: feature breadth ~79%) · **Target:** Figma Design (desktop app 126.10.7)
 
 Forward-looking plan. Where FigCraft stands today: [`ROADMAP.md`](../ROADMAP.md) and
 [`target-app-parity.md`](target-app-parity.md); the full ranked list: [`gaps.md`](gaps.md). Hours
@@ -50,7 +50,7 @@ blockers.
 | M1 Core editing completeness (tools, panels, menus, clipboard, files) | ~75% | Open recent, outline mode variants, keyboard box selection, interaction pass | 8–14 + pass |
 | M2 Layout depth | ~80% | grid `fr` rows, real-file QA | 6–12 |
 | M3 Components & styles depth | ~75% | extended collections, variables import/export, real libraries | 10–20 |
-| M4 Vector & text depth | ~75% (live booleans, arc fields, shape builder on strokes, one-sided widths landed 2026-10-11) | spell check, Figma Draw (brushes, pressure, text on a path, patterns), boolean hit testing on the result | 18–32 |
+| M4 Vector & text depth | ~75% (live booleans, arc fields, shape builder on strokes, one-sided widths landed 2026-10-11) | spell check on Linux and the web, Figma Draw (brushes, pressure, text on a path, patterns), boolean hit testing on the result | 18–32 |
 | M5 Prototype & present | ~85% | media, gamepad, separate window | 5–9 |
 | M6 Interop & Dev Mode | ~55% | `.fig` import, video/HEIC/TIFF, CMYK intent, Code Connect | 40–80 |
 | M7 Release & fidelity (signed builds, look & feel, shortcuts) | builds done; fidelity pass started | tool-by-tool pass | 40–60 |
@@ -71,6 +71,7 @@ blockers.
 
 | Date | Change | Summary |
 |---|---|---|
+| 2026-10-11 | minor | Spell check while editing text: misspelled words underlined, suggestions, Ignore and Add to dictionary in the context menu, Text ▸ Spelling ▸ Check spelling (the system's checker on Windows and macOS, in the system's language; none on Linux, FreeBSD or the web yet). Text 85% → 88% breadth, 64% → 66% ready, 8–14 h → 5–10 h; menu breadth 200/203 → 201/204 (98.5%); gap 11 narrowed to the remaining platforms and languages (4–8 h → 2–5 h); weighted percentages unchanged, hour totals down 3–4 h (headline ranges unchanged within their rounding) |
 | 2026-10-11 | minor | Collaboration wording after the standalone server: `figcraft-server` keeps many files, persisted as a snapshot plus a log of what changed (compacted, checksummed, damaged files skipped and reported), restarts lose nothing confirmed and apps resume where they were; the app publishes its file to it; share links per file let people edit or only view (the server refuses a viewer's edits), are revocable and stored as hashes; TLS comes from a reverse proxy (the desktop app has none of its own yet); percentages and hours unchanged until the next re-measure |
 | 2026-10-11 | minor | Live boolean editing, Design panel arc fields, the shape builder on strokes and open paths and one-sided variable widths landed (gap 13): Shapes & vector breadth 92% → 97%, ready 66% → 68%, 8–14 h → 1–3 h; feature breadth ~78% → ~79%; ready for real work unchanged (~47%); full parity ~390–745 h → ~385–730 h |
 | 2026-10-11 | minor | Collaboration wording after the collaboration polish: styles, variables, collections, modes and comments created in a live session take ids from their creator's id space (concurrent creations no longer overwrite each other; the server checks it), others' text carets and selections show on the canvas, and typing in a layer someone else is typing in says so; percentages unchanged |
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 @@
 # FigCraft parity with Figma
 
-> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (the standalone server with share links to edit or view, wording only; live boolean editing and the vector tail: feature breadth ~79%; ready ~47%; last major: full re-measure against Figma desktop 126.10.7) · **Target:** Figma Design (desktop app 126.10.7, macOS)
+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (spell check while editing text on Windows and macOS; menu breadth 201/204; the standalone server with share links to edit or view, wording only; live boolean editing and the vector tail: feature breadth ~79%; ready ~47%; last major: full re-measure against Figma desktop 126.10.7) · **Target:** Figma Design (desktop app 126.10.7, macOS)
 
 The authoritative assessment of how close FigCraft is to Figma Design. [`ROADMAP.md`](../ROADMAP.md)
 summarizes it; [`gaps.md`](gaps.md) is the ranked work list; the measured menu checklist is
@@ -15,7 +15,7 @@ summarizes it; [`gaps.md`](gaps.md) is the ranked work list; the measured menu c
 
 | Number | Value | Kind |
 |---|---|---|
-| Menu/command breadth | **98.5%** (200 / 203 catalogued menu items have a live command) | **measured** (`cargo xtask parity`, re-checked from source on 2026-10-10) |
+| Menu/command breadth | **98.5%** (201 / 204 catalogued menu items have a live command) | **measured** (`cargo xtask parity`, re-checked from source on 2026-10-10) |
 | Feature breadth (does each Figma Design feature exist, weighted) | **~79%** (core editor areas 1–16: ~90%) | estimated from the area table below |
 | Ready for real work | **~47%** | estimated (dimension table below: written weights × written per-dimension scores) |
 | Essentials user | **~53%** | estimated ([below](#essentials-user): core features 77.8% × discounts 0.92 × 0.92 × 0.8) |
@@ -36,7 +36,7 @@ tool; presence was being graded as behaviour in places.
 | Audience | Ready % | Opus 5.5 agent wall-clock hours to ~95% | Work that dominates |
 |---|---|---|---|
 | Full target (ready for real work) | ~47% | ~370–695 h | multiplayer (45–90), localization (60–100), plugins (25–55), the interaction pass (40–60), `.fig` import (30–60), GPU canvas (30–60), AI (30–60) |
-| Mainstream practitioner | ~43% | ~175–320 h | core editor depth incl. `.fig` import (91–177), the interaction pass (40–60), real-file corpus and platform QA (25–50), `.fig` export for handing work back (15–30) |
+| Mainstream practitioner | ~43% | ~175–320 h | core editor depth incl. `.fig` import (88–173), the interaction pass (40–60), real-file corpus and platform QA (25–50), `.fig` export for handing work back (15–30) |
 | Essentials user | ~53% | ~85–160 h | `.fig` import so files people send open (30–60), core tool polish (25–45), interaction pass on the core tools (15–25), stability QA (10–20), onboarding and starter templates (5–10) |
 
 Hours are one agent working sequentially, calibrated from this repo's history (about 1 agent-hour
@@ -103,7 +103,7 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects
 | 7 | Effects | 4 | 100% | 72% | Glass and progressive blur tuned side by side | 3–6 |
 | 8 | Frames, groups, sections, masks | 5 | 95% | 75% | Visual QA with real files | 2–4 |
 | 9 | Auto layout & constraints | 9 | 95% | 68% | Grid `fr` rows around fixed children; behaviour against real Figma files ([auto-layout-parity](auto-layout-parity.md)) | 6–12 |
-| 10 | Text | 8 | 85% | 64% | Spell check, text on a path, CJK text QA, real-file text metrics | 8–14 |
+| 10 | Text | 8 | 88% | 66% | Spell check on Linux, FreeBSD and the web and in other languages (Windows and macOS use the system's checker), text on a path, CJK text QA, real-file text metrics | 5–10 |
 | 11 | Components & variants | 9 | 95% | 68% | Real (not synthetic) libraries, `.fig` libraries, component playground ([components-parity](components-parity.md)) | 6–12 |
 | 12 | Styles & variables | 5 | 85% | 70% | Extended collections, variables import/export, gradient stops and variant properties bound | 4–8 |
 | 13 | Prototyping | 6 | 88% | 70% | Video triggers and media actions, gamepad triggers, real prototype QA ([prototyping-parity](prototyping-parity.md)) | 5–9 |
@@ -115,14 +115,14 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects
 | 19 | Team libraries & organization | 2 | 40% | 25% | Local library files exist; no shared/cloud library, no library analytics | 10–20 |
 | 20 | Figma Draw extras | 2 | 45% | 30% | Have: variable width, lasso, shape builder, noise/texture. Missing: brushes (stretch/scatter), pressure, text on a path, pattern fills, multi-edit | 10–18 |
 | 21 | AI features | 3 | 0% | 0% | First draft, rename layers, image edit/remove background, visual search, Make: needs local models (owner decision) | 30–60 |
-| | **Weighted (÷113)** | 113 | **~79%** | **~57%** | | **211–420** |
-| | Core editor only (1–16, ÷94) | 94 | ~90% | ~67% | | 91–177 |
+| | **Weighted (÷113)** | 113 | **~79%** | **~57%** | | **208–416** |
+| | Core editor only (1–16, ÷94) | 94 | ~90% | ~67% | | 88–173 |
 
 ## By dimension (ready for real work)
 
 | Dimension | Weight | Ready | Hours | Evidence | Doc |
 |---|---|---|---|---|---|
-| Features (depth) | 35 | 57% | 71–135 (areas 1–13, 15, 16, 20) | The table above; core editor ~67% | this file |
+| Features (depth) | 35 | 57% | 68–131 (areas 1–13, 15, 16, 20) | The table above; core editor ~67% | this file |
 | UI/UX fidelity | 15 | 58% | 40–60 | Look & feel compared side by side with UI3 (~65% by the builders); tool-by-tool interaction (cursors, modifiers while dragging, hover states, popovers) not yet compared; accessibility names and keyboard navigation landed | [ui-parity](ui-parity.md) |
 | File formats | 12 | 40% | 30–60 | No `.fig` read or write; REST JSON import, SVG in/out, PNG/JPG/WebP/PDF export solid | [file-format-parity](file-format-parity.md) |
 | Collaboration | 10 | 12% | 55–110 | Local comments and version history; the multiplayer core (sync model, server ordering, client reconciliation, convergence tests) landed in #103, and live sessions (preview) co-edit a file hosted by a desktop app or kept on a self-hosted `figcraft-server` with edit and view links, joined from the app or the browser (`docs/collab-protocol.md`) | [collaboration-parity](collaboration-parity.md) |
@@ -153,7 +153,7 @@ graphic or a simple mockup. Same method, with the core features a casual user to
 | Select, move, resize, rotate, align, group, duplicate | 15 | 75% | not yet compared tool by tool |
 | Frames with device presets | 8 | 80% | |
 | Rectangle, ellipse, line, arrow, corner radius | 8 | 80% | |
-| Text: type, font, size, weight, colour, alignment | 12 | 70% | no spell check |
+| Text: type, font, size, weight, colour, alignment | 12 | 70% | spell check on Windows and macOS only |
 | Colour picker, fills and strokes; place and crop images | 10 | 72% | no HEIC images |
 | Layers panel: rename, hide, lock, reorder | 7 | 80% | |
 | Basic auto layout (add, gap, padding) | 6 | 70% | |
@@ -577,6 +577,7 @@ Carried over from the ROADMAP's "Shipped so far" (2026-10-10). The test count th
 
 | Date | Change | Summary |
 |---|---|---|
+| 2026-10-11 | minor | Spell check while editing text: misspelled words underlined, suggestions, Ignore and Add to dictionary in the context menu, Text ▸ Spelling ▸ Check spelling (the system's checker on Windows and macOS, in the system's language; none on Linux, FreeBSD or the web yet). Text 85% → 88% breadth, 64% → 66% ready, 8–14 h → 5–10 h; menu breadth 200/203 → 201/204 (98.5%); gap 11 narrowed to the remaining platforms and languages (4–8 h → 2–5 h); weighted percentages unchanged, hour totals down 3–4 h (headline ranges unchanged within their rounding) |
 | 2026-10-11 | minor | Collaboration wording after the standalone server: `figcraft-server` keeps many files, persisted as a snapshot plus a log of what changed (compacted, checksummed, damaged files skipped and reported), restarts lose nothing confirmed and apps resume where they were; the app publishes its file to it; share links per file let people edit or only view (the server refuses a viewer's edits), are revocable and stored as hashes; TLS comes from a reverse proxy (the desktop app has none of its own yet); percentages and hours unchanged until the next re-measure |
 | 2026-10-11 | minor | Live boolean editing, Design panel arc fields, the shape builder on strokes and open paths and one-sided variable widths landed (gap 13): Shapes & vector breadth 92% → 97%, ready 66% → 68%, 8–14 h → 1–3 h; feature breadth ~78% → ~79%; ready for real work unchanged (~47%); full parity ~390–745 h → ~385–730 h |
 | 2026-10-11 | minor | Wording only: live sessions (#104), then the browser joining them and `figcraft://` invite links opening the desktop app; percentages and hours unchanged until the next re-measure |
diff --git a/docs/ui-parity.md b/docs/ui-parity.md
--- a/docs/ui-parity.md
+++ b/docs/ui-parity.md
@@ -1,6 +1,6 @@
 # UI and interaction parity
 
-> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (arc fields, live boolean editing, shape builder on strokes, one-sided widths) · **Target:** Figma Design UI3 (desktop app 126.10.7)
+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (spell check while editing text on Windows and macOS; menu breadth 201/204; arc fields, live boolean editing, shape builder on strokes, one-sided widths) · **Target:** Figma Design UI3 (desktop app 126.10.7)
 
 Fine canvas control is what Figma users feel first: handles, snapping, nudging, modifier keys,
 drag behaviour, numeric precision. This checklist covers the tools, gestures and panels. Status:
@@ -57,7 +57,7 @@ interaction feel not yet compared.
 | Vector edit mode: bend, paint bucket, lasso, cut, eraser, transform box, point snapping, mirroring | done | |
 | Shape builder (Shift+M) on fills, strokes and open paths; variable width (Shift+W), one side with Alt | done | |
 | Boolean groups: double-click or Cmd/Ctrl-click into operands, live results while dragging or editing points, per-operand operations | done | hit-tested by the operands, not the result |
-| Text: point, auto height, fixed; caret, words, lines, IME | done | no spell check |
+| Text: point, auto height, fixed; caret, words, lines, IME; spell check | done | spell check uses the system's checker on Windows and macOS only, in its language |
 | Eyedropper (I) | done | |
 | Comment (C), Shift+C to show/hide | done | local only |
 | Brushes, text on a path (Figma Draw) | missing | |
@@ -91,5 +91,6 @@ interaction feel not yet compared.
 
 | Date | Change | Summary |
 |---|---|---|
+| 2026-10-11 | minor | Spell check while editing text: misspelled words underlined, suggestions, Ignore and Add to dictionary in the context menu, Text ▸ Spelling ▸ Check spelling (the system's checker on Windows and macOS, in the system's language; none on Linux, FreeBSD or the web yet). Text 85% → 88% breadth, 64% → 66% ready, 8–14 h → 5–10 h; menu breadth 200/203 → 201/204 (98.5%); gap 11 narrowed to the remaining platforms and languages (4–8 h → 2–5 h); weighted percentages unchanged, hour totals down 3–4 h (headline ranges unchanged within their rounding) |
 | 2026-10-11 | minor | Design panel arc fields, live boolean editing (operands on the canvas, live results, per-operand operations), shape builder on strokes and open paths, one-sided widths (Alt-drag a width handle) |
 | 2026-10-10 | major | Created from the ROADMAP's look-and-feel and interaction items, the keymap and the canvas code |
