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
@@ -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"@@ -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 |@@ -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"@@ -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!({})); }@@ -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");@@ -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"),@@ -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() {@@ -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;new file mode 100644@@ -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),+ ]+}new file mode 100644@@ -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());+}@@ -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);@@ -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,@@ -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") }@@ -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 |@@ -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@@ -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 @@ -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 |@@ -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 |@@ -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 |