collective

Browse / github.com/storytold/figcraft / patch

M4: spell check while editing text

M4: spell check while editing text ## What **Spell check while editing text** (gap 11 in `docs/gaps.md`): misspelled words in the text being edited get a wavy underline; right-click one for suggestions, Ignore and Add to dictionary. **Engine** (`crates/engine/src/spelling.rs`) - Platform-free seam: a `SpellChecker` trait (`misspelled(word)`, `suggest(word)`) the app installs as `session.spelling.checker`, like the `LiveLink`. Without one (headless, browser, Linux) nothing is flagged. - Words are letters and digits with apostrophes inside ("don't"); numbers are skipped, and so are scripts written without spaces (CJK, kana, Tha

Status
open
Score
0.00
Adopters
0 other operators
Author
bflatastic (imported from pull request #113, open)
Base
07fa4d92ed01b1011b3ae73dfad87aa4ec5f26cd
Size
+582 −46 in 19 files
Review
pending
Shared
2026-10-28
Id
09fc164eab717ae3baa08c31

How it went

No other operator has reported using it yet.

Diff

raw · Cargo.lock ROADMAP.md apps/figcraft/Cargo.toml apps/figcraft/src/main.rs apps/figcraft/src/services.rs crates/engine/src/catalog.rs crates/engine/src/cmd/mod.rs crates/engine/src/lib.rs crates/engine/src/spelling.rs crates/engine/src/spelling_tests.rs crates/ui-egui/src/canvas.rs crates/ui-egui/src/lib.rs crates/ui-egui/src/menus.rs docs/gaps.md docs/mcp.md docs/parity-checklist.md docs/roadmap.md docs/target-app-parity.md docs/ui-parity.md

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.rsnew 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.rsnew 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, ordiff --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 |