From ecb3656c690cd9d66b6a1a2e7631aad0b8eaa607 Mon Sep 17 00:00:00 2001 From: BFlat Date: Sun, 11 Oct 2026 11:26:57 +0700 Subject: [PATCH] M1: open recent, outlines for the selection, mask outlines (#115) The last three catalogued menu items got commands: File > Open recent (recent files kept in preferences, capped, missing files greyed and dropped when picked, Clear recent), outlines for the selected layers only, and mask outlines. Menu breadth 100%. --- ROADMAP.md | 8 ++- apps/figcraft/src/services.rs | 31 +++++------- crates/engine/src/cmd/file.rs | 50 +++++++++++++++++++ crates/engine/src/cmd/view.rs | 4 ++ crates/engine/src/lib.rs | 12 +++++ crates/engine/src/menu_gaps_tests.rs | 73 ++++++++++++++++++++++++++++ crates/render/src/draw.rs | 6 ++- crates/render/src/lib.rs | 6 ++- crates/render/src/lod_tests.rs | 2 +- crates/render/src/retained.rs | 3 ++ crates/ui-egui/src/canvas.rs | 10 ++++ crates/ui-egui/src/home.rs | 2 +- crates/ui-egui/src/idle_tests.rs | 1 + crates/ui-egui/src/lib.rs | 7 --- crates/ui-egui/src/menus.rs | 35 +++++++++++++ crates/ui-egui/src/render_worker.rs | 11 ++++- docs/gaps.md | 16 +++--- docs/parity-checklist.md | 10 ++-- docs/roadmap.md | 5 +- docs/target-app-parity.md | 13 ++--- docs/ui-parity.md | 5 +- 21 files changed, 253 insertions(+), 57 deletions(-) create mode 100644 crates/engine/src/menu_gaps_tests.rs diff --git a/ROADMAP.md b/ROADMAP.md index e0473d2..0f92827 100644 --- 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 (extended collections, variables import/export, gradient stop and variant bindings; 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 (Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; 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 | **100%** (203 / 203) | 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)) | @@ -127,6 +127,9 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). ## Progress log +- **2026-10-11** · The last three menu items got commands: File ▸ Open recent (kept in the + preferences, missing files greyed, Clear recent), outlines for the selection and mask outlines + (gap 21). Menu breadth 203/203. - **2026-10-11** · Extended variable collections (a library collection's modes and variables in a collection of the file, overridden per mode with reset, following library updates), variables import/export (the REST local variables JSON, merged by name; CSS custom properties), gradient @@ -161,6 +164,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | The last three catalogued menu items got commands (gap 21): File ▸ Open recent (recent files kept in the preferences, at most 20, a file that's gone greyed in the menu and dropped when picked, Clear recent), View ▸ Outlines ▸ Show outlines for selection and View ▸ Mask outlines: menu breadth 98.5% → 100% (203/203); Canvas & navigation breadth 95% → 97%, 3–6 h → 2–5 h; feature breadth (~79%), ready for real work (~47%) and full parity hours unchanged at this rounding | | 2026-10-11 | minor | Extended collections, variables import/export and gradient stop and variant property bindings landed (gap 14): Styles & variables breadth 85% → 92%, ready 70% → 72%, 4–8 h → 1–2 h; feature breadth unchanged (~79%); ready for real work unchanged (~47%); full parity ~385–730 h → ~380–725 h | | 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 | diff --git a/apps/figcraft/src/services.rs b/apps/figcraft/src/services.rs index e7af76a..c056fd5 100644 --- a/apps/figcraft/src/services.rs +++ b/apps/figcraft/src/services.rs @@ -1,4 +1,4 @@ -//! Desktop services: native file dialogs, clipboard, URLs, persisted preferences and recents. +//! Desktop services: native file dialogs, clipboard, URLs, persisted preferences. use std::path::PathBuf; use std::sync::Arc; @@ -10,7 +10,6 @@ use serde_json::{Value, json}; pub struct Desktop { clipboard: Option, - recents: Vec, pub prefs: Option, /// The system's reduce-motion setting (read in the background at startup). reduce_motion: Arc, @@ -32,16 +31,20 @@ impl Desktop { .and_then(|d| std::fs::read_to_string(d.join("state.json")).ok()) .and_then(|s| serde_json::from_str(&s).ok()) .unwrap_or(json!({})); - let recents = state - .get("recents") - .and_then(Value::as_array) - .map(|a| a.iter().filter_map(|v| v.as_str().map(str::to_string)).collect()) - .unwrap_or_default(); - let prefs = state.get("prefs").and_then(|p| serde_json::from_value(p.clone()).ok()); + let prefs = state.get("prefs").and_then(|p| serde_json::from_value::(p.clone()).ok()).map(|mut p| { + // Recent files lived next to the preferences before they moved into them. + if p.recent_files.is_empty() + && let Some(old) = state.get("recents").and_then(Value::as_array) + { + p.recent_files = old.iter().filter_map(|v| v.as_str().map(str::to_string)).collect(); + } + p.recent_files.truncate(figcraft_engine::cmd::file::MAX_RECENT); + p + }); let reduce_motion = Arc::new(AtomicBool::new(false)); let flag = reduce_motion.clone(); std::thread::spawn(move || flag.store(system_reduce_motion(), Ordering::Relaxed)); - Self { clipboard: arboard::Clipboard::new().ok(), recents, prefs, reduce_motion } + Self { clipboard: arboard::Clipboard::new().ok(), prefs, reduce_motion } } } @@ -86,7 +89,7 @@ fn command_says(program: &str, args: &[&str], want: &str) -> bool { pub fn save_state(app: &FigApp) { let Some(dir) = config_dir() else { return }; let _ = std::fs::create_dir_all(&dir); - let v = json!({"recents": app.services.recent_files(), "prefs": app.session.prefs}); + let v = json!({"prefs": app.session.prefs}); let tmp = dir.join("state.json.tmp"); if std::fs::write(&tmp, v.to_string()).is_ok() { let _ = std::fs::rename(&tmp, dir.join("state.json")); @@ -147,14 +150,6 @@ impl Services for Desktop { fn open_url(&mut self, url: &str) { let _ = webbrowser::open(url); } - fn recent_files(&self) -> Vec { - self.recents.clone() - } - fn add_recent(&mut self, path: &str) { - self.recents.retain(|p| p != path); - self.recents.insert(0, path.to_string()); - self.recents.truncate(20); - } fn reduce_motion(&self) -> bool { self.reduce_motion.load(Ordering::Relaxed) } diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index f6d6981..957edd6 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -26,6 +26,13 @@ pub fn specs() -> Vec { false, open ), + spec!( + "file.openRecent", + "Open recent", + "{path? | index? (into the list), clear? (forget them all)} opens a recent file; a file that no longer exists is dropped from the list. Returns the list, newest first: {files: [{path, name, exists}]} (opening returns what `file.open` does)", + false, + open_recent + ), spec!("file.save", "Save", "{path?} (programmatic saves need a path the first time)", false, save), spec!("file.saveAs", "Save as", "{path}", false, save_as), spec!( @@ -517,15 +524,57 @@ pub(crate) fn variables_param(cmd: &str, p: &Value) -> Result> { Ok(Some(v)) } +/// How many files File ▸ Open recent keeps. +pub const MAX_RECENT: usize = 20; + +/// Put `path` at the top of the recent files. +fn remember(s: &mut Session, path: &str) { + let r = &mut s.prefs.recent_files; + r.retain(|p| p != path); + r.insert(0, path.to_string()); + r.truncate(MAX_RECENT); +} + +fn open_recent(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "file.openRecent"; + if p.get("clear").and_then(Value::as_bool) == Some(true) { + s.prefs.recent_files.clear(); + } + let path = match (string(p, "path"), num(p, "index")) { + (Some(path), _) => Some(path), + (None, Some(i)) => Some(s.prefs.recent_files.get(i.max(0.0) as usize).cloned().ok_or_else(|| bad(CMD, "no such recent file"))?), + _ => None, + }; + if let Some(path) = path { + if !std::path::Path::new(&path).exists() { + s.prefs.recent_files.retain(|r| *r != path); + return Err(bad(CMD, format!("{path} no longer exists"))); + } + return open(s, &json!({"path": path})); + } + let files: Vec = s + .prefs + .recent_files + .iter() + .map(|r| { + let name = std::path::Path::new(r).file_name().map_or_else(|| r.clone(), |f| f.to_string_lossy().into_owned()); + json!({"path": r, "name": name, "exists": std::path::Path::new(r).exists()}) + }) + .collect(); + Ok(json!({"files": files})) +} + fn open(s: &mut Session, p: &Value) -> Result { let path = req_string("file.open", p, "path")?; let variables = variables_param("file.open", p)?; // Already open: switch to it. if let Some(i) = s.docs.iter().position(|d| d.path.as_deref() == Some(path.as_str())) { s.active = i; + remember(s, &path); return Ok(json!({"index": i})); } let (loaded, rest) = load_path(&path, variables.as_ref())?; + remember(s, &path); let native = !rest && matches!(ext(&path).as_str(), "figcraft" | "json"); let warnings = rest || ext(&path) == "svg"; s.docs.push(DocState::new(loaded.doc, native.then_some(path)).with_versions(loaded.versions)); @@ -577,6 +626,7 @@ fn save_to(s: &mut Session, path: String) -> Result { ds.path = Some(path.clone()); ds.mark_saved(); ds.revision += 1; + remember(s, &path); Ok(json!({"path": path, "bytes": bytes.len()})) } diff --git a/crates/engine/src/cmd/view.rs b/crates/engine/src/cmd/view.rs index 37d154e..42de680 100644 --- a/crates/engine/src/cmd/view.rs +++ b/crates/engine/src/cmd/view.rs @@ -193,6 +193,10 @@ pub fn specs() -> Vec { spec!("view.slices", "Slices", "{on?}", false, |s, p| flag(s, p, |v| &mut v.show_slices)), spec!("view.comments", "Comments", "{on?}", false, |s, p| flag(s, p, |v| &mut v.comments)), spec!("view.outlines", "Outlines", "{on?}", false, |s, p| flag(s, p, |v| &mut v.outlines)), + spec!("view.outlinesSelection", "Outlines for selection", "{on?} outline mode for the selected layers only", false, |s, p| { + flag(s, p, |v| &mut v.outlines_selection) + }), + spec!("view.maskOutlines", "Mask outlines", "{on?}", false, |s, p| flag(s, p, |v| &mut v.mask_outlines)), spec!("view.pixelPreview", "Pixel preview", "{on?, scale? (1|2: rendered pixels per page pixel; turns it on)}", false, pixel_preview), spec!("view.frameOutlines", "Frame outlines", "{on?}", false, |s, p| flag(s, p, |v| &mut v.frame_outlines)), spec!("view.snapGeometry", "Snap to geometry", "{on?}", false, |s, p| flag(s, p, |v| &mut v.snap_geometry)), diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index c9d327d..02f6239 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -120,6 +120,10 @@ pub struct ViewState { pub layout_grids: bool, pub rulers: bool, pub outlines: bool, + /// Outline mode for the selected layers only (the rest draws normally). + pub outlines_selection: bool, + /// Outline the masks (they don't draw themselves). + pub mask_outlines: bool, pub pixel_preview: bool, /// Pixel preview resolution: rendered pixels per page pixel (1 or 2). pub pixel_scale: u8, @@ -144,6 +148,8 @@ impl Default for ViewState { layout_grids: true, rulers: false, outlines: false, + outlines_selection: false, + mask_outlines: false, pixel_preview: false, pixel_scale: 1, show_slices: false, @@ -459,6 +465,9 @@ pub struct Prefs { /// Installed plugins (Plugins menu), in menu order. #[serde(serialize_with = "plugins::persisted")] pub plugins: Vec, + /// Recently opened or saved files, newest first (File ▸ Open recent; at most + /// [`cmd::file::MAX_RECENT`]). + pub recent_files: Vec, } impl Default for Prefs { @@ -476,6 +485,7 @@ impl Default for Prefs { reduce_motion: false, invite_links: None, plugins: Vec::new(), + recent_files: Vec::new(), } } } @@ -1197,6 +1207,8 @@ mod mask_tests; #[cfg(test)] mod measure_tests; #[cfg(test)] +mod menu_gaps_tests; +#[cfg(test)] mod nested_override_tests; #[cfg(test)] mod paint_reorder_tests; diff --git a/crates/engine/src/menu_gaps_tests.rs b/crates/engine/src/menu_gaps_tests.rs new file mode 100644 index 0000000..2e32697 --- /dev/null +++ b/crates/engine/src/menu_gaps_tests.rs @@ -0,0 +1,73 @@ +//! File ▸ Open recent (kept in the preferences, capped, missing files dropped), outlines for the +//! selection and mask outlines. + +use serde_json::json; + +use crate::Session; +use crate::cmd::file::MAX_RECENT; +use crate::params::id_of; + +#[test] +fn open_recent_lists_opens_prunes_and_clears() { + let dir = std::env::temp_dir().join(format!("figcraft-recent-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let a = dir.join("a.figcraft").to_string_lossy().to_string(); + let b = dir.join("b.figcraft").to_string_lossy().to_string(); + let mut s = Session::new(); + s.execute("file.save", &json!({"path": a})).unwrap(); + s.execute("file.saveAs", &json!({"path": b})).unwrap(); + let list = s.execute("file.openRecent", &json!({})).unwrap(); + assert_eq!(list["files"][0]["path"], b.as_str(), "newest first"); + assert_eq!(list["files"][1]["name"], "a.figcraft"); + assert_eq!(list["files"][1]["exists"], true); + // Opening one moves it to the top (an open file is switched to). + s.execute("file.openRecent", &json!({"index": 1})).unwrap(); + assert_eq!(s.prefs.recent_files, vec![a.clone(), b.clone()]); + assert!(s.execute("file.openRecent", &json!({"index": 7})).is_err()); + // A file that's gone is dropped when it's picked. + std::fs::remove_file(&b).unwrap(); + assert!(s.execute("file.openRecent", &json!({"path": b})).is_err()); + assert_eq!(s.prefs.recent_files, vec![a.clone()]); + s.execute("file.openRecent", &json!({"clear": true})).unwrap(); + assert!(s.prefs.recent_files.is_empty()); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn recent_files_are_capped() { + let dir = std::env::temp_dir().join(format!("figcraft-recent-cap-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let mut s = Session::new(); + for i in 0..MAX_RECENT + 3 { + let p = dir.join(format!("{i}.figcraft")).to_string_lossy().to_string(); + s.execute("file.saveAs", &json!({"path": p})).unwrap(); + } + assert_eq!(s.prefs.recent_files.len(), MAX_RECENT); + assert!(s.prefs.recent_files[0].ends_with(&format!("{}.figcraft", MAX_RECENT + 2))); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn view_toggles_flip_their_flags() { + let mut s = Session::new(); + assert_eq!(s.execute("view.outlinesSelection", &json!({})).unwrap()["on"], true); + assert!(s.ds().unwrap().view.outlines_selection); + assert_eq!(s.execute("view.maskOutlines", &json!({"on": true})).unwrap()["on"], true); + assert_eq!(s.execute("view.maskOutlines", &json!({})).unwrap()["on"], false); +} + +#[test] +fn outlined_layers_draw_as_outlines_and_the_rest_normally() { + let mut s = Session::new(); + let a = id_of(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 40, "height": 40})).unwrap()["id"]).unwrap(); + s.execute("shape.rectangle", &json!({"x": 60, "y": 0, "width": 40, "height": 40})).unwrap(); + let ds = s.ds().unwrap(); + let view = figcraft_render::View { origin: (0.0, 0.0), scale: 1.0, width: 120, height: 60 }; + let render = + |outlined| figcraft_render::render_view(&ds.doc, ds.page, &view, &figcraft_render::Options { outlined, ..Default::default() }).unwrap(); + let (plain, outlined) = (render(vec![]), render(vec![a])); + let empty = plain.pixel(110, 50).unwrap(); + assert_ne!(plain.pixel(20, 20).unwrap(), empty, "a filled rectangle"); + assert_eq!(outlined.pixel(20, 20).unwrap(), empty, "only its outline"); + assert_eq!(outlined.pixel(80, 20).unwrap(), plain.pixel(80, 20).unwrap(), "the other layer draws normally"); +} diff --git a/crates/render/src/draw.rs b/crates/render/src/draw.rs index d2dac6a..e7be558 100644 --- a/crates/render/src/draw.rs +++ b/crates/render/src/draw.rs @@ -21,6 +21,8 @@ pub struct Ctx<'a> { /// Device pixels per document unit (for effect radii). pub scale: f64, pub outline_mode: bool, + /// Layers drawn in outline mode with their contents (outlines for the selection). + pub outlined: &'a [NodeId], /// Nodes not to draw (e.g. text being edited draws through the editor). pub skip: &'a [NodeId], /// Approximate what's too small to see in detail (canvas previews; see [`crate::lod`]). @@ -353,8 +355,8 @@ pub fn draw_node(ctx: &Ctx, pix: &mut Pixmap, id: NodeId, parent_xf: Affine, dep if dev.intersect(view).is_zero_area() && !(dev.width() == 0.0 || dev.height() == 0.0) { return; } - if ctx.outline_mode { - draw_outline(ctx, pix, n, xf, depth); + if ctx.outline_mode || ctx.outlined.contains(&id) { + draw_outline(&Ctx { outline_mode: true, ..*ctx }, pix, n, xf, depth); return; } if ctx.lod && dev.width().max(dev.height()) < lod::TINY_PX && lod::splattable(ctx.doc, n, 0) { diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 64b54c7..81dd6ad 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -52,6 +52,8 @@ pub struct Options { /// Fill the background (None = transparent, e.g. export without page colour). pub background: Option, pub outline_mode: bool, + /// Layers drawn in outline mode (with their contents) whatever `outline_mode` says. + pub outlined: Vec, /// Nodes to leave out (an editor drawing them itself). pub skip: Vec, /// Draw only this layer of the page, where it is (Dev Mode's focus view). @@ -151,7 +153,7 @@ pub fn render_view_single(doc: &Document, page: NodeId, view: &View, opts: &Opti pix.fill(paint::ts_color(bg)); } let lod = opts.lod && !opts.outline_mode && view.scale <= lod::MAX_SCALE; - let ctx = draw::Ctx { doc, scale: view.scale, outline_mode: opts.outline_mode, skip: &opts.skip, lod, under: None }; + let ctx = draw::Ctx { doc, scale: view.scale, outline_mode: opts.outline_mode, outlined: &opts.outlined, skip: &opts.skip, lod, under: None }; let xf = view.transform(); let Some(p) = doc.node(page) else { return Some(pix) }; match opts.solo.filter(|id| *id != page && doc.page_of(*id) == Some(page)) { @@ -167,7 +169,7 @@ pub fn render_view_single(doc: &Document, page: NodeId, view: &View, opts: &Opti /// Render several nodes (in z-order) into an existing pixmap through `view`. pub fn render_nodes_into(doc: &Document, ids: &[NodeId], view: &View, pix: &mut Pixmap) { - let ctx = draw::Ctx { doc, scale: view.scale, outline_mode: false, skip: &[], lod: false, under: None }; + let ctx = draw::Ctx { doc, scale: view.scale, outline_mode: false, outlined: &[], skip: &[], lod: false, under: None }; for id in ids { draw::draw_node(&ctx, pix, *id, view.transform() * parent_transform(doc, *id), 0); } diff --git a/crates/render/src/lod_tests.rs b/crates/render/src/lod_tests.rs index b76c29c..20a5f28 100644 --- a/crates/render/src/lod_tests.rs +++ b/crates/render/src/lod_tests.rs @@ -154,7 +154,7 @@ fn exports_never_use_lod() { crate::render_nodes_into(&d, &[card], &v, &mut exact); assert_eq!(png.data(), exact.data()); let mut preview = Pixmap::new(v.width, v.height).unwrap(); - let ctx = crate::draw::Ctx { doc: &d, scale, outline_mode: false, skip: &[], lod: true, under: None }; + let ctx = crate::draw::Ctx { doc: &d, scale, outline_mode: false, outlined: &[], skip: &[], lod: true, under: None }; let parent = d.world_transform(p); crate::draw::draw_node(&ctx, &mut preview, card, v.transform() * parent, 0); assert_ne!(png.data(), preview.data(), "the preview of the same area is approximated"); diff --git a/crates/render/src/retained.rs b/crates/render/src/retained.rs index daedc81..9834192 100644 --- a/crates/render/src/retained.rs +++ b/crates/render/src/retained.rs @@ -42,6 +42,7 @@ struct Frame { doc: Arc, page: NodeId, outline: bool, + outlined: Vec, lod: bool, skip: Vec, solo: Option, @@ -134,6 +135,7 @@ impl Retained { if let Some(last) = self.last.take() && last.page == page && last.outline == opts.outline_mode + && last.outlined == opts.outlined && last.lod == opts.lod && last.skip == opts.skip && last.solo == opts.solo @@ -176,6 +178,7 @@ impl Retained { doc: doc.clone(), page, outline: opts.outline_mode, + outlined: opts.outlined.clone(), lod: opts.lod, skip: opts.skip.clone(), solo: opts.solo, diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 137b893..6d9bc91 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -94,6 +94,7 @@ struct JobKey { scale: i64, size: (u32, u32), outline: bool, + outlined: Vec, skip: Vec, solo: Option, /// Text lays out anew when fonts arrive (system fonts load in the background). @@ -271,6 +272,7 @@ fn request_render(app: &mut FigApp, worker: &mut Worker, ppp: f32) { }; // The edited text is drawn live (an IME composition included); the caret is an overlay. let doc = app.preedit_view().map(|p| p.doc.clone()).unwrap_or_else(|| ds.doc.clone()); + let outlined = if ds.view.outlines_selection { ds.selection.clone() } else { vec![] }; let key = JobKey { doc: Arc::as_ptr(&doc) as usize, page: ds.page, @@ -278,6 +280,7 @@ fn request_render(app: &mut FigApp, worker: &mut Worker, ppp: f32) { scale: (scale * 10000.0) as i64, size: (w, h), outline: ds.view.outlines, + outlined: outlined.clone(), skip: vec![], solo: ds.focused(), fonts: figcraft_text::layout_generation(), @@ -290,6 +293,7 @@ fn request_render(app: &mut FigApp, worker: &mut Worker, ppp: f32) { page: ds.page, view: figcraft_render::View { origin: (ox, oy), scale, width: w.min(16384), height: h.min(16384) }, outline: ds.view.outlines, + outlined, skip: vec![], solo: key.solo, }; @@ -749,6 +753,12 @@ fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) -> Vec<(NodeId, [ for id in operands.iter().filter(|o| !sel.contains(o)) { draw_outline(app, painter, &doc, *id, stroke(outline_colour(&doc, *id).gamma_multiply(0.6), 1.0)); } + // Mask outlines: a mask doesn't draw itself. + if view.mask_outlines { + for id in doc.subtree(page).into_iter().filter(|i| doc.node(*i).is_some_and(|n| n.is_mask && n.visible)) { + draw_outline(app, painter, &doc, id, stroke(ACCENT, 1.0)); + } + } // Drop target / insertion indicator. let ov = &app.session.tool.overlay; if let Some(d) = ov.drop_target diff --git a/crates/ui-egui/src/home.rs b/crates/ui-egui/src/home.rs index 695fac7..73d0f61 100644 --- a/crates/ui-egui/src/home.rs +++ b/crates/ui-egui/src/home.rs @@ -88,7 +88,7 @@ pub fn show(app: &mut FigApp, ui: &mut Ui) { app.ui.home = false; } y += 16.0; - let recents = app.services.recent_files(); + let recents = app.session.prefs.recent_files.clone(); if !recents.is_empty() { ui.painter().text(pos2(main.min.x, y), Align2::LEFT_TOP, "Recent files", semibold(12.0), t.text_secondary); y += 28.0; diff --git a/crates/ui-egui/src/idle_tests.rs b/crates/ui-egui/src/idle_tests.rs index 0c080b0..200047b 100644 --- a/crates/ui-egui/src/idle_tests.rs +++ b/crates/ui-egui/src/idle_tests.rs @@ -200,6 +200,7 @@ fn a_finished_background_render_wakes_the_ui() { page: ds.page, view: figcraft_render::View { origin: (0.0, 0.0), scale: 1.0, width: 120, height: 120 }, outline: false, + outlined: vec![], skip: vec![], solo: None, }); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 4e53e84..d8a366f 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -135,11 +135,6 @@ pub trait Services { None } fn open_url(&mut self, _url: &str) {} - /// Recently opened files (persisted by the app). - fn recent_files(&self) -> Vec { - Vec::new() - } - fn add_recent(&mut self, _path: &str) {} /// The system asks for reduced motion (its accessibility setting). fn reduce_motion(&self) -> bool { false @@ -557,7 +552,6 @@ impl FigApp { if let Some(p) = self.services.pick_save("Save as", &format!("{name}.figcraft"), &["figcraft"]) { let ps = p.to_string_lossy().to_string(); if self.run("file.saveAs", json!({"path": ps})).is_some() { - self.services.add_recent(&ps); self.toast(format!("Saved {}", p.file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or_default())); } } @@ -577,7 +571,6 @@ impl FigApp { if let Some(p) = self.services.pick_open("Open", &["figcraft", "json", "svg", "png", "jpg", "jpeg", "webp", "gif"]) { let ps = p.to_string_lossy().to_string(); if self.run("file.open", json!({"path": ps})).is_some() { - self.services.add_recent(&ps); self.ui.home = false; } } diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 3513f3f..a873273 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -182,6 +182,12 @@ fn menu_level(app: &mut FigApp, ui: &mut Ui, prefix: &str) -> bool { continue; } last_sep = false; + if m.id == "file.openRecent" { + if recent_menu(app, ui, prefix, m.label, &t) { + return true; + } + continue; + } let enabled = available(m.id); if item(ui, m.label, crate::shortcuts::shortcut(m.id).unwrap_or(""), enabled, false, &t) && enabled { clicked = Some(m.id); @@ -205,6 +211,35 @@ fn menu_level(app: &mut FigApp, ui: &mut Ui, prefix: &str) -> bool { false } +/// File ▸ Open recent: the recent files (greyed when the file is gone) and Clear recent. Returns +/// whether an item ran. +fn recent_menu(app: &mut FigApp, ui: &mut Ui, prefix: &str, label: &str, t: &Tokens) -> bool { + let picked = submenu(ui, prefix, &format!("{prefix}/{label}"), label, t, |ui| { + let recents = &app.session.prefs.recent_files; + let mut picked = None; + for (i, p) in recents.iter().enumerate() { + let path = std::path::Path::new(p); + let name = path.file_name().map_or_else(|| p.clone(), |f| f.to_string_lossy().into_owned()); + let exists = path.exists(); + if item(ui, &name, "", exists, false, t) && exists { + picked = Some(json!({"index": i})); + } + } + if !recents.is_empty() { + separator(ui); + } + if item(ui, "Clear recent", "", !recents.is_empty(), false, t) && !recents.is_empty() { + picked = Some(json!({"clear": true})); + } + picked + }); + let Some(params) = picked else { return false }; + if app.run("file.openRecent", params).is_some_and(|r| r.get("index").is_some()) { + app.ui.home = false; + } + true +} + /// A submenu row `label` in the menu at `prefix`; while it's open (hovered last), `add` draws its /// items beside it. `key` is unique among the menu's submenus. Returns what `add` returned. pub(crate) fn submenu(ui: &mut Ui, prefix: &str, key: &str, label: &str, t: &Tokens, add: impl FnOnce(&mut Ui) -> Option) -> Option { diff --git a/crates/ui-egui/src/render_worker.rs b/crates/ui-egui/src/render_worker.rs index e3601e0..3111ea9 100644 --- a/crates/ui-egui/src/render_worker.rs +++ b/crates/ui-egui/src/render_worker.rs @@ -16,6 +16,8 @@ pub struct Job { pub page: NodeId, pub view: View, pub outline: bool, + /// Layers drawn as outlines (outlines for the selection). + pub outlined: Vec, pub skip: Vec, /// Only this layer (the focus view). pub solo: Option, @@ -45,7 +47,14 @@ fn run(job: &Job, retained: &mut Retained) -> Option { #[cfg(not(target_arch = "wasm32"))] let t0 = std::time::Instant::now(); // The canvas is a preview: what's too small to see in detail at this zoom is approximated. - let opts = figcraft_render::Options { outline_mode: job.outline, skip: job.skip.clone(), solo: job.solo, lod: true, ..Default::default() }; + let opts = figcraft_render::Options { + outline_mode: job.outline, + outlined: job.outlined.clone(), + skip: job.skip.clone(), + solo: job.solo, + lod: true, + ..Default::default() + }; let (view, pix, work) = retained.render(&job.doc, job.page, &job.view, &opts)?; let image = screen_image(pix, job.doc.color_profile); #[cfg(not(target_arch = "wasm32"))] diff --git a/docs/gaps.md b/docs/gaps.md index f86d102..1415787 100644 --- 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 (extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail) · **Target:** Figma Design (desktop app 126.10.7) Every known shortfall against Figma, one entry each, ranked by how much it stops a Figma user from doing real work in FigCraft. Agents: pick work from the top unless the owner says otherwise. The @@ -34,7 +34,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe | 18 | Display P3 on screen; one window only | Hardware | 6–12 | [hardware-parity](hardware-parity.md) | | 19 | AI features | AI | 30–60 | [target-app-parity](target-app-parity.md) | | 20 | Mobile apps (view, mirror, comment) | Platforms | 20–40 | [hardware-parity](hardware-parity.md) | -| 21 | Three menu items without a command | Feature | 2–4 | [parity-checklist](parity-checklist.md) | +| 21 | Menu items without a command: none left (landed 2026-10-11) | Feature | — | [parity-checklist](parity-checklist.md) | | 22 | Screen readers never run; browser has no accessibility tree | UI/UX | 4–8 | [ui-parity](ui-parity.md) | | 23 | CMYK output intent for print PDFs | File format | 2–4 | [file-format-parity](file-format-parity.md) | @@ -217,10 +217,13 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe FigCraft's web build runs in mobile browsers but is not designed for touch. - **Estimate:** 20–40 h for a touch-first viewer and mirror. -### 21. Three menu items without a command (measured) -- `file.openRecent`, `view.outlinesSelection`, `view.maskOutlines` - ([`parity-checklist.md`](parity-checklist.md)); the plugin pair landed in #101. -- **Estimate:** 2–4 h for the first three. +### 21. Menu items without a command (measured): none left +- **Landed (2026-10-11):** `file.openRecent` (File ▸ Open recent: recent files kept in the + preferences, at most 20, greyed when the file is gone and dropped when picked, Clear recent), + `view.outlinesSelection` (outline mode for the selected layers only) and `view.maskOutlines`; + the plugin pair landed in #101. Every item in the menu catalog has a command + ([`parity-checklist.md`](parity-checklist.md)). +- **Estimate:** none left. ### 22. Screen readers never run; browser has no accessibility tree - **Evidence:** AccessKit names and keyboard navigation landed (#96), checked by tests only; @@ -236,6 +239,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | The last three catalogued menu items got commands (gap 21): File ▸ Open recent (recent files kept in the preferences, at most 20, a file that's gone greyed in the menu and dropped when picked, Clear recent), View ▸ Outlines ▸ Show outlines for selection and View ▸ Mask outlines: menu breadth 98.5% → 100% (203/203); Canvas & navigation breadth 95% → 97%, 3–6 h → 2–5 h; feature breadth (~79%), ready for real work (~47%) and full parity hours unchanged at this rounding | | 2026-10-11 | minor | Extended collections, variables import/export and gradient stop and variant property bindings landed (gap 14): Styles & variables breadth 85% → 92%, ready 70% → 72%, 4–8 h → 1–2 h; feature breadth unchanged (~79%); ready for real work unchanged (~47%); full parity ~385–730 h → ~380–725 h | | 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 | diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 3f44670..73514a6 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -2,14 +2,10 @@ > **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 (468 commands in the engine). -**200 / 203 menu items (98.5%)** +**203 / 203 menu items (100.0%)** -## Missing - -- `file.openRecent` -- `view.outlinesSelection` -- `view.maskOutlines` +Every catalog item has a command. Menu coverage measures breadth only; see docs/target-app-parity.md for depth and fidelity. diff --git a/docs/roadmap.md b/docs/roadmap.md index e1851f3..2419882 100644 --- 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 (extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~79%) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; 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 @@ -47,7 +47,7 @@ blockers. |---|---|---|---| | M0 Foundation: workspace, model, renderer, engine, UI shell, app, control channel | done (2026-10-09) | — | — | | M0.9 CLI + MCP, web build, release packaging, PDF export | done (v0.2.0 released 2026-10-10 for every platform) | — | — | -| M1 Core editing completeness (tools, panels, menus, clipboard, files) | ~75% | Open recent, outline mode variants, keyboard box selection, interaction pass | 8–14 + pass | +| M1 Core editing completeness (tools, panels, menus, clipboard, files) | ~77% | keyboard box selection, interaction pass (Open recent and the outline mode variants landed 2026-10-11) | 6–12 + pass | | M2 Layout depth | ~80% | grid `fr` rows, real-file QA | 6–12 | | M3 Components & styles depth | ~78% (extended collections, variables import/export, gradient stop and variant bindings landed 2026-10-11) | real libraries, component playground | 7–14 | | 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 | @@ -71,6 +71,7 @@ blockers. | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | The last three catalogued menu items got commands (gap 21): File ▸ Open recent (recent files kept in the preferences, at most 20, a file that's gone greyed in the menu and dropped when picked, Clear recent), View ▸ Outlines ▸ Show outlines for selection and View ▸ Mask outlines: menu breadth 98.5% → 100% (203/203); Canvas & navigation breadth 95% → 97%, 3–6 h → 2–5 h; feature breadth (~79%), ready for real work (~47%) and full parity hours unchanged at this rounding | | 2026-10-11 | minor | Extended collections, variables import/export and gradient stop and variant property bindings landed (gap 14): Styles & variables breadth 85% → 92%, ready 70% → 72%, 4–8 h → 1–2 h; feature breadth unchanged (~79%); ready for real work unchanged (~47%); full parity ~385–730 h → ~380–725 h | | 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 | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index f985922..1c7aa2c 100644 --- 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 (extended collections, variables import/export, gradient stop and variant bindings; 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 (Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; 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 | **100%** (203 / 203 catalogued menu items have a live command) | **measured** (`cargo xtask parity`, 2026-10-11) | | 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) | @@ -65,7 +65,7 @@ multiplicative discounts, as the standard says. File, Edit, View, Object, Text, Arrange, Vector, Plugins, Preferences, Libraries, Help), written from public documentation and black-box observation (local notes in the gitignored `plan/figma/03-menu-tree.md`). It leaves out the Widgets menu and dynamic plugin lists, so the - 98.5% overstates breadth against the full menu a little. + 100% overstates breadth against the full menu a little. - **From source** (no build; 2026-10-10): 449 command ids registered with `spec!`, 963 `#[test]` functions in 177 files, ~108,800 lines of Rust in 13 crates + 3 apps + xtask (before #101–#103, which added the `plugin` and `collab` crates and ~7k lines), 140 key bindings and 13 gesture rows in `figcraft_engine::keymap`, 21 files in the SVG import corpus, 0 @@ -94,7 +94,7 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects | # | Area | Weight | Breadth | Ready | Remaining | Hours | |---|---|---|---|---|---|---| -| 1 | Canvas & navigation | 6 | 95% | 72% | Outline mode variants (`view.outlinesSelection`, `view.maskOutlines`), Display P3 on screen, QA at extreme zooms | 3–6 | +| 1 | Canvas & navigation | 6 | 97% | 72% | Display P3 on screen, QA at extreme zooms (outlines for the selection and mask outlines landed) | 2–5 | | 2 | Selection & transform | 8 | 95% | 68% | Handles, measuring and snapping checked tool by tool against Figma; rotated groups and mixed selections | 6–10 | | 3 | Layers panel | 5 | 95% | 78% | Keyboard box selection, keyboard rename/context menu, long-list QA | 2–4 | | 4 | Shapes & vector | 8 | 97% | 68% | Boolean groups hit-tested by their operands rather than their result; vector QA with real files (live boolean editing, arc fields, shape builder on strokes and open paths and one-sided widths landed) | 1–3 | @@ -109,7 +109,7 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects | 13 | Prototyping | 6 | 88% | 70% | Video triggers and media actions, gamepad triggers, real prototype QA ([prototyping-parity](prototyping-parity.md)) | 5–9 | | 14 | Import/export | 6 | 65% | 35% | **`.fig` import** (the blocking gap), `.fig` export, HEIC/TIFF/video import, CMYK output intent ([file-format-parity](file-format-parity.md)) | 30–60 | | 15 | Dev Mode / inspect | 2 | 80% | 62% | Code Connect, plugins in Dev Mode, editor integrations ([dev-mode-parity](dev-mode-parity.md)) | 4–8 | -| 16 | Comments, versions, find, misc | 3 | 80% | 58% | Comments are local only; branching; Open recent | 4–8 | +| 16 | Comments, versions, find, misc | 3 | 80% | 58% | Comments are local only; branching | 4–8 | | 17 | Multiplayer & sharing | 8 | 15% | 5% | Core landed (`crates/collab`: ids, change sets, server ordering, convergence tested); live sessions (preview): WebSocket transport, hosting from the desktop app, joining from the app or the browser, invite links that open the app, cursors, selections and follow, per-person undo, offline resume; a self-hosted `figcraft-server` with persistence and share links to edit or view. Left: accounts and teams, cursor chat, TLS in the desktop app ([collaboration-parity](collaboration-parity.md)) | 45–90 | | 18 | Plugins & widgets | 4 | 35% | 20% | Own sandboxed WASM plugins landed (#101: manifest, permissions, menu, Run last, Manage, SDK, 2 examples). Left: plugin UI at run time, settings storage, distribution, widgets; Figma's plugins are JavaScript and don't run (a JS engine would be an owner decision) ([plugins.md](plugins.md)) | 25–55 | | 19 | Team libraries & organization | 2 | 40% | 25% | Local library files exist; no shared/cloud library, no library analytics | 10–20 | @@ -148,7 +148,7 @@ graphic or a simple mockup. Same method, with the core features a casual user to | Feature | Weight | Depth | Notes | |---|---|---|---| -| Create, open, save and reopen a file; pages | 10 | 85% | home screen, `.figcraft` round trips; Open recent missing | +| Create, open, save and reopen a file; pages | 10 | 85% | home screen, `.figcraft` round trips, Open recent | | Pan and zoom | 10 | 80% | wheel, pinch, Space-drag, zoom shortcuts | | Select, move, resize, rotate, align, group, duplicate | 15 | 75% | not yet compared tool by tool | | Frames with device presets | 8 | 80% | | @@ -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 | The last three catalogued menu items got commands (gap 21): File ▸ Open recent (recent files kept in the preferences, at most 20, a file that's gone greyed in the menu and dropped when picked, Clear recent), View ▸ Outlines ▸ Show outlines for selection and View ▸ Mask outlines: menu breadth 98.5% → 100% (203/203); Canvas & navigation breadth 95% → 97%, 3–6 h → 2–5 h; feature breadth (~79%), ready for real work (~47%) and full parity hours unchanged at this rounding | | 2026-10-11 | minor | Extended collections, variables import/export and gradient stop and variant property bindings landed (gap 14): Styles & variables breadth 85% → 92%, ready 70% → 72%, 4–8 h → 1–2 h; feature breadth unchanged (~79%); ready for real work unchanged (~47%); full parity ~385–730 h → ~380–725 h | | 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 | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 9c15c87..7b52a22 100644 --- 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 (outlines for the selection, mask outlines; 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: @@ -70,7 +70,7 @@ interaction feel not yet compared. | Pan/zoom: wheel, pinch, Space-drag, Shift+0/1/2, Cmd+0, `+`/`-` | done | | | Rulers and guides (drag out, move, remove; rotated frames; frame guides) | done | | | Frame labels (rotated, right-to-left), size badge | done | | -| Outline mode | partial | "Outlines" done; outline selection and mask outlines missing | +| Outline mode | done | Outlines, outlines for the selection, mask outlines | | Layout grids (Ctrl+G) | done | | | Gradient and padding/gap handles on the canvas | done | | | Hover outline, selection colours, handle sizes | done | measured from UI3 screenshots | @@ -91,5 +91,6 @@ interaction feel not yet compared. | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | Outline mode done: outlines for the selection (the selected layers draw as outlines, the rest normally) and mask outlines | | 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 |