diff --git a/ROADMAP.md b/ROADMAP.md index 4484aa72..b962fb6d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); the instance-swap menus have a search field (#1338); a refused batch's fixes take time linear in the batch (#1063); the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); offline Penpot 2.x import/export (#1143); optional batch continue-on-error with per-step outcomes (#1386); the Design panel's sections on one grid (#858); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); images placed at their own size, 2x screenshots in points (#1360); the toolbar on one 32 pt grid, without its Resources button (#1210); M4: zero-length round-capped dashes draw dots; dash expansion uses the existing safety budget; extracted from #1143; Arabic draft catalog; scaled instances keep their scale when their main is rebuilt (#922); .fig embedded images are copied once per blob, not once per hash (#1059); page ruler guides survive save and reopen (#1476); colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); branches in live sessions and branch history entries, gap 17; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (MCP and control calls name their document (#998); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); the instance-swap menus have a search field (#1338); a refused batch's fixes take time linear in the batch (#1063); the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); offline Penpot 2.x import/export (#1143); optional batch continue-on-error with per-step outcomes (#1386); the Design panel's sections on one grid (#858); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); images placed at their own size, 2x screenshots in points (#1360); the toolbar on one 32 pt grid, without its Resources button (#1210); M4: zero-length round-capped dashes draw dots; dash expansion uses the existing safety budget; extracted from #1143; Arabic draft catalog; scaled instances keep their scale when their main is rebuilt (#922); .fig embedded images are copied once per blob, not once per hash (#1059); page ruler guides survive save and reopen (#1476); colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); branches in live sessions and branch history entries, gap 17; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **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 @@ -355,6 +355,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-29 | minor | M8, #998: MCP tools that act on a document (`run_command`, `batch`, `create_node`, `set_properties`, `inspect_document`, `get_node`, `select`, `export`, `screenshot`, `save_file`, `undo`, `redo`) take an optional `document`, an open file's `id` (`doc.list` and the `document.inspect` outline now carry it) or path, and so do the control methods `engine.execute`, `document.inspect`, `node.get` and `ui.render`: the call acts on that file and the tab in front stays in front, so an attached agent keeps editing its own file when the user switches tabs. Calls without it act on the file in front as before; other control methods refuse a `document` behind the tab in front. Numbers unchanged | | 2026-10-29 | minor | Sibling placement in live sessions (#1065, from a security audit): every replica (the server's and each app's) placed a created, moved or reordered layer by counting each sibling that sorts before it, so a batch of many layers under one parent took quadratic time, on the server's single hub thread too. It now binary-searches the siblings' `(key, id)` order on each side of the layer's old place. The order is unchanged, ties and fractional positions included: a test replays 5,000 creations and 2,500 moves against the counting rule. 20,000 creations and 10,000 moves under two parents took 20 s in a debug build; they now take 0.3 s. | | 2026-10-29 | minor | Comments from an AI agent (M8, #1414): an agent attached over MCP commented, reacted and read threads as the user (the name from Preferences) unless it passed `author` on every call. Every command an MCP client runs now acts as that client's name from `initialize` (`clientInfo.title`, else `.name`) with " (agent)" after it, or "AI agent" without one, carried to the app as `engine.execute {agent}`; its messages, reactions and read state are its own, and its messages are marked (`agent: true` in the file and in `comment.list`, an Agent label by the name in the thread). The user's name and the comments made in the app are unchanged, and in a live session the name and marker travel with the thread to everyone. Tests in `comment_agent_tests`, the MCP `agent_comments` and attached suites, the UI comments suite and the collab diff round trip. Numbers unchanged | | 2026-10-29 | minor | M3, #1338: the instance-swap property's menu (on an instance, its main component's default and the component playground) and the instance's swap menu open with a search field at the top that has the keyboard; typing filters the components by name (any case, headings without matches drop out), an empty result reads "No matching components", Up and Down step through the matches and Enter picks one (the first without a step), Escape clears the search and then closes the menu. Only the list scrolls; the menu grows back when the search is cleared. Tests: `component_swap_search_tests`. Numbers unchanged | diff --git a/crates/engine/src/automation/inspect.rs b/crates/engine/src/automation/inspect.rs index 886d2d85..6fd40004 100644 --- a/crates/engine/src/automation/inspect.rs +++ b/crates/engine/src/automation/inspect.rs @@ -344,6 +344,7 @@ pub fn document(s: &Session, depth: usize, all_pages: bool) -> Value { let pages: Vec = doc.pages.iter().map(|p| if all_pages || *p == ds.page { node_json(doc, *p, depth) } else { node_json(doc, *p, 0) }).collect(); json!({ + "id": ds.uid, "name": doc.name, "kind": doc.kind.id(), "path": ds.path, diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index bb1c7199..dd786a09 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -318,15 +318,21 @@ pub fn specs() -> Vec { }), spec!("doc.nextTab", "Next tab", "{}", false, |s, _| cycle_doc(s, 1)), spec!("doc.previousTab", "Previous tab", "{}", false, |s, _| cycle_doc(s, -1)), - spec!("doc.list", "List documents", "{}", false, |s, _| { - Ok(Value::Array( + spec!( + "doc.list", + "List documents", + "{} → [{index, id, name, path, dirty, active}]: `id` names the file in a control or MCP call's `document`", + false, + |s, _| { + Ok(Value::Array( s.docs .iter() .enumerate() - .map(|(i, d)| json!({"index": i, "name": d.doc.name, "path": d.path, "dirty": d.is_dirty(), "active": i == s.active})) + .map(|(i, d)| json!({"index": i, "id": d.uid, "name": d.doc.name, "path": d.path, "dirty": d.is_dirty(), "active": i == s.active})) .collect(), )) - }), + } + ), spec!("doc.home", "Home", "{}", false, |s, _| { s.request_ui("home", json!({})); ok() diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 0b801589..e7bddd7d 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -108,6 +108,7 @@ pub use derived::{changed_nodes, normalize}; pub use doc_state::{DocState, History, HistoryEntry, Interaction, PREVIEW_READ_ONLY, Preedit, TextEdit, VersionPreview}; pub use error::{EngineError, Result, bad}; pub use prefs::{Clipboard, MAX_RECENT_ACTIONS, Prefs, UI_SCALE_MAX, UI_SCALE_MIN, UiRequest, clamp_ui_scale}; +pub use session::Forward; pub use view_state::ViewState; /// The editing session: open documents, the current tool, clipboard and prefs. diff --git a/crates/engine/src/session.rs b/crates/engine/src/session.rs index 389f7f29..99101dc2 100644 --- a/crates/engine/src/session.rs +++ b/crates/engine/src/session.rs @@ -8,6 +8,7 @@ use super::{ }; mod access; +pub use access::Forward; mod exec; mod new; mod undo; diff --git a/crates/engine/src/session/access.rs b/crates/engine/src/session/access.rs index 05bc45d5..ed83f83b 100644 --- a/crates/engine/src/session/access.rs +++ b/crates/engine/src/session/access.rs @@ -1,6 +1,45 @@ use super::{DocState, Document, EngineError, NodeId, Result, Session, Value, json}; +/// The file [`Session::bring_forward`] put in front for one request, and the tab to bring back. +#[must_use = "pass it to `Session::put_back`"] +pub struct Forward { + target: u64, + shown: Option, +} + impl Session { + /// The uid of open file `target`: its `id` (from `doc.list` or `document.inspect`) or its path. + pub fn document_uid(&self, target: &Value) -> Result { + let found = match target { + Value::Number(n) => n.as_u64().and_then(|id| self.docs.iter().find(|d| d.uid == id)), + Value::String(p) => self.docs.iter().find(|d| d.path.as_deref() == Some(p.as_str())), + _ => None, + }; + found + .map(|d| d.uid) + .ok_or_else(|| EngineError::Other(format!("no open document {target} (`document` takes an `id` from doc.list, or an open file's path)"))) + } + + /// Put open file `target` in front for one request (a control call naming its `document`), + /// without the user's tab changing on screen: run the request, then [`Session::put_back`]. + pub fn bring_forward(&mut self, target: &Value) -> Result { + let uid = self.document_uid(target)?; + let shown = self.ds().ok().map(|d| d.uid); + if let Some(i) = self.docs.iter().position(|d| d.uid == uid) { + self.active = i; + } + Ok(Forward { target: uid, shown }) + } + + /// Bring back the tab that was in front before [`Session::bring_forward`], unless the request + /// itself moved the front to another open file (`file.open`, `doc.switch`…), as it would have. + pub fn put_back(&mut self, f: Forward) { + let moved = self.docs.iter().any(|d| d.uid == f.target) && self.ds().ok().map(|d| d.uid) != Some(f.target); + if let Some(i) = f.shown.and_then(|u| self.docs.iter().position(|d| d.uid == u)).filter(|_| !moved) { + self.active = i; + } + } + pub fn ds(&self) -> Result<&DocState> { self.docs.get(self.active).ok_or(EngineError::NoDocument) } diff --git a/crates/mcp/src/headless.rs b/crates/mcp/src/headless.rs index c0f948e3..4f5a2ed1 100644 --- a/crates/mcp/src/headless.rs +++ b/crates/mcp/src/headless.rs @@ -239,8 +239,8 @@ impl Headless { } } -impl Backend for Headless { - fn call(&mut self, method: &str, p: Value) -> Result { +impl Headless { + fn dispatch(&mut self, method: &str, p: Value) -> Result { match method { "engine.execute" => { let cmd = p.get("command").and_then(Value::as_str).ok_or("`command` required")?.to_string(); @@ -301,6 +301,18 @@ impl Backend for Headless { other => Err(format!("unknown method `{other}`")), } } +} + +impl Backend for Headless { + /// With `document` (an `id` from `doc.list`, or an open file's path) the call acts on that + /// file, and the file in front stays in front. + fn call(&mut self, method: &str, p: Value) -> Result { + let Some(target) = p.get("document").filter(|d| !d.is_null()) else { return self.dispatch(method, p) }; + let forward = self.session.bring_forward(target).map_err(|e| e.to_string())?; + let r = self.dispatch(method, p); + self.session.put_back(forward); + r + } fn has_ui(&self) -> bool { false diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index 9166141d..6adbbd11 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -105,8 +105,39 @@ pub const TOOL_NAMES: &[&str] = &[ "wait_for_changes", ]; +/// The tools that take `document`: they act on the file it names instead of the one in front. +const DOCUMENT_TOOLS: &[&str] = &[ + "run_command", + "batch", + "create_node", + "set_properties", + "inspect_document", + "get_node", + "select", + "export", + "screenshot", + "save_file", + "undo", + "redo", +]; + /// All tools, in `tools/list` order. pub fn tool_definitions() -> Vec { + let mut tools = tools(); + let document = id_schema( + "The open file to act on: its `id` (from run_command doc.list, or the `document` open_file and inspect_document return) or its path. Default: the file in front. The tab in front stays in front; with an attached app, a screenshot of the window needs the file in front", + ); + for t in &mut tools { + if t.get("name").and_then(Value::as_str).is_some_and(|n| DOCUMENT_TOOLS.contains(&n)) + && let Some(props) = t.pointer_mut("/inputSchema/properties").and_then(Value::as_object_mut) + { + props.insert("document".into(), document.clone()); + } + } + tools +} + +fn tools() -> Vec { let empty = || obj(json!({}), &[]); vec![ tool( @@ -346,6 +377,29 @@ pub fn tool_definitions() -> Vec { // ---------- helpers ---------- +/// A backend whose every call names `document` (a tool's `document` argument). +struct InDocument<'a> { + inner: &'a mut dyn Backend, + document: Value, +} + +impl Backend for InDocument<'_> { + fn call(&mut self, method: &str, mut params: Value) -> Result { + if let Some(o) = params.as_object_mut() { + o.insert("document".into(), self.document.clone()); + } + self.inner.call(method, params) + } + + fn has_ui(&self) -> bool { + self.inner.has_ui() + } + + fn describe(&self) -> String { + self.inner.describe() + } +} + fn exec(b: &mut dyn Backend, cmd: &str, params: Value) -> Result { b.call("engine.execute", json!({"command": cmd, "params": params})) } @@ -468,7 +522,7 @@ fn refuse_unknown_args(schema: &Value, args: &Value, path: &str) -> Result<(), S /// Run one tool. Every failure becomes an `isError` result (never a panic). pub fn call_tool(b: &mut dyn Backend, name: &str, args: &Value) -> ToolResult { - let a = match args_obj(args) { + let mut a = match args_obj(args) { Ok(a) => a, Err(e) => return ToolResult::error(e), }; @@ -479,6 +533,14 @@ pub fn call_tool(b: &mut dyn Backend, name: &str, args: &Value) -> ToolResult { { return ToolResult::error(e); } + let mut targeted; + let b: &mut dyn Backend = match a.remove("document").filter(|d| !d.is_null()) { + Some(document) => { + targeted = InDocument { inner: b, document }; + &mut targeted + } + None => b, + }; let r = match name { "list_commands" => list_commands(b, &a), "run_command" => run_command(b, &a), diff --git a/crates/mcp/tests/it/attached.rs b/crates/mcp/tests/it/attached.rs index 2b9e7dc5..d9f96eae 100644 --- a/crates/mcp/tests/it/attached.rs +++ b/crates/mcp/tests/it/attached.rs @@ -161,3 +161,23 @@ fn reconnect_failure_reports_request_not_sent() { let error = backend.call("document.inspect", json!({})).unwrap_err(); assert!(error.contains("request was not sent"), "{error}"); } + +#[test] +fn a_tool_call_naming_its_document_edits_that_file_and_not_the_one_in_front() { + let mut s = Server::new(Box::new(Attached::port(fake_app()).unwrap())); + let text = |r: &Value| -> Value { serde_json::from_str(r["content"][0]["text"].as_str().unwrap()).unwrap() }; + let back = text(&call(&mut s, "inspect_document", json!({"depth": 0})))["id"].clone(); + assert!(back.is_u64(), "inspect_document names the file: {back}"); + assert_eq!(call(&mut s, "run_command", json!({"command": "file.new", "params": {"name": "Front"}}))["isError"], false); + let r = call(&mut s, "create_node", json!({"type": "rectangle", "document": back})); + assert_eq!(r["isError"], false, "{r}"); + let list = text(&call(&mut s, "run_command", json!({"command": "doc.list"}))); + assert_eq!((list[0]["id"].clone(), list[0]["active"].clone(), list[1]["active"].clone()), (back.clone(), json!(false), json!(true))); + let front = text(&call(&mut s, "inspect_document", json!({"depth": 1}))); + assert_eq!((front["name"].as_str(), front["pages"][0]["children"].as_array().map_or(0, Vec::len)), (Some("Front"), 0)); + let behind = text(&call(&mut s, "inspect_document", json!({"depth": 1, "document": back}))); + assert_eq!(behind["pages"][0]["children"].as_array().map_or(0, Vec::len), 1, "{behind}"); + let e = call(&mut s, "inspect_document", json!({"document": 999_999})); + assert_eq!(e["isError"], true); + assert_eq!(call(&mut s, "pointer", json!({"x": 0, "y": 0, "document": back}))["isError"], true, "pointer takes no `document`"); +} diff --git a/crates/mcp/tests/it/protocol.rs b/crates/mcp/tests/it/protocol.rs index f4f07637..2794cc0e 100644 --- a/crates/mcp/tests/it/protocol.rs +++ b/crates/mcp/tests/it/protocol.rs @@ -135,7 +135,7 @@ fn tools_call_happy_and_error_paths() { assert!(c.err("open_file", json!({"path": "/definitely/not/here.figcraft"})).contains("here.figcraft")); // Names a tool or a command doesn't take are refused, not ignored (#1413). let e = c.err("run_command", json!({"command": "inspect.code", "args": {"id": id, "language": "html"}})); - assert!(e.contains("unknown argument `args` (it takes command, params)"), "{e}"); + assert!(e.contains("unknown argument `args` (it takes command, document, params)"), "{e}"); let e = c.err("batch", json!({"commands": [{"command": "comment.list", "args": {"filter": "open"}}]})); assert!(e.contains("unknown argument `commands[0].args`"), "{e}"); let e = c.err("run_command", json!({"command": "comment.list", "params": {"filterr": "open"}})); diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 4cc017e7..c92787a0 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -27,13 +27,15 @@ //! - `app.quit {}` //! //! Any method takes `window` (an id from `window.list`) to run in that window, with its tabs and -//! panels; without it, requests go to the window last in front. +//! panels; without it, requests go to the window last in front. `engine.execute`, +//! `document.inspect`, `node.get` and `ui.render` also take `document` (an `id` from `doc.list`, +//! or an open file's path) to act on that file without bringing its tab to the front. use std::sync::{Arc, Mutex}; use egui::Context; use figcraft_engine::cmd::comment::{Filter, Sort}; -use figcraft_engine::{PointerEvent, PointerKind}; +use figcraft_engine::{PointerEvent, PointerKind, Session}; use serde_json::{Value, json}; use crate::FigApp; @@ -356,6 +358,12 @@ fn admit(app: &mut FigApp, r: ControlRequest, window: u64, now: f64) { } let path = params.get("path").and_then(Value::as_str).map(str::to_string); let (document, presentation) = app.windows.presentation(&app.session, window); + if let Err(e) = front_only(&app.session, Some(document), "ui.screenshot", ¶ms) { + if !canceled { + let _ = tx.send(ControlReply { id, result: Err(e) }); + } + return; + } app.capture.shots.push(ShotRequest { id, path, @@ -1332,14 +1340,46 @@ fn ui_set_styles(app: &mut FigApp, p: &Value) -> Result<(), String> { Ok(()) } +/// The methods whose `document` may name a file behind the tab in front. +const DOCUMENT_METHODS: &[&str] = &["engine.execute", "document.inspect", "node.get", "ui.render"]; + +/// The request's `document` (an `id` from `doc.list`, or an open file's path), when it names one. +fn document_of(p: &Value) -> Option<&Value> { + p.get("document").filter(|d| !d.is_null()) +} + +/// Run `f` with the request's `document` in front and the user's tab back afterwards, else on the +/// tab in front (the same swap a command run in the background makes). +fn in_document(app: &mut FigApp, p: &Value, f: impl FnOnce(&mut Session) -> Result) -> Result { + let Some(target) = document_of(p) else { return f(&mut app.session) }; + let forward = app.session.bring_forward(target).map_err(|e| e.to_string())?; + let r = f(&mut app.session); + app.session.put_back(forward); + r +} + +/// A method that acts on the tab in front (`front`, a document uid) refuses a `document` naming +/// another file instead of quietly acting on the wrong one. +fn front_only(session: &Session, front: Option, method: &str, p: &Value) -> Result<(), String> { + let Some(target) = document_of(p) else { return Ok(()) }; + if Some(session.document_uid(target).map_err(|e| e.to_string())?) == front { + return Ok(()); + } + Err(format!("`{method}` acts on the tab in front: `document` can name another open file only for {}", DOCUMENT_METHODS.join(", "))) +} + /// Handle one control method (also used by tests and the headless snapshot tool). pub fn handle(app: &mut FigApp, ctx: &Context, method: &str, p: &Value) -> Result { + if !DOCUMENT_METHODS.contains(&method) { + front_only(&app.session, app.session.ds().ok().map(|d| d.uid), method, p)?; + } match method { "engine.execute" => { let cmd = p.get("command").and_then(Value::as_str).ok_or("`command` required")?; let params = p.get("params").cloned().unwrap_or(json!({})); let layers_generation = app.session.clipboard.layers_generation; - let r = app.session.execute_as(p.get("agent").and_then(Value::as_str), cmd, ¶ms).map_err(|e| e.to_string()); + let agent = p.get("agent").and_then(Value::as_str); + let r = in_document(app, p, |s| s.execute_as(agent, cmd, ¶ms).map_err(|e| e.to_string())); app.clear_comment_draft_after_tool_set(cmd, r.is_ok()); app.invalidate_synthetic_copy(layers_generation); // Programmatic calls never open dialogs. @@ -1358,12 +1398,12 @@ pub fn handle(app: &mut FigApp, ctx: &Context, method: &str, p: &Value) -> Resul )), "document.inspect" => { let depth = p.get("depth").and_then(Value::as_u64).unwrap_or(8).min(64) as usize; - Ok(figcraft_engine::inspect::document(&app.session, depth, b(p, "allPages"))) + in_document(app, p, |s| Ok(figcraft_engine::inspect::document(s, depth, b(p, "allPages")))) } "node.get" => { let id = p.get("id").and_then(figcraft_engine::params::id_of).ok_or("`id` required")?; let depth = p.get("depth").and_then(Value::as_u64).unwrap_or(1) as usize; - app.session.doc().map(|d| figcraft_engine::inspect::node_json(d, id, depth)).map_err(|e| e.to_string()) + in_document(app, p, |s| s.doc().map(|d| figcraft_engine::inspect::node_json(d, id, depth)).map_err(|e| e.to_string())) } "window.new" => { let index = p.get("index").and_then(Value::as_u64).map(|i| i as usize); @@ -1411,7 +1451,7 @@ pub fn handle(app: &mut FigApp, ctx: &Context, method: &str, p: &Value) -> Resul if !ids.is_empty() { params["ids"] = json!(ids); } - app.session.execute("file.export", ¶ms).map_err(|e| e.to_string()) + in_document(app, p, |s| s.execute("file.export", ¶ms).map_err(|e| e.to_string())) } "app.quit" => { app.ui.quit = true; @@ -1488,3 +1528,6 @@ mod roots_tests; #[cfg(test)] mod capture_tests; + +#[cfg(test)] +mod document_tests; diff --git a/crates/ui-egui/src/control/document_tests.rs b/crates/ui-egui/src/control/document_tests.rs new file mode 100644 index 00000000..dbba64dc --- /dev/null +++ b/crates/ui-egui/src/control/document_tests.rs @@ -0,0 +1,62 @@ +//! A control request naming its `document` acts on that file, and the tab in front stays in front. + +use figcraft_engine::Session; +use serde_json::{Value, json}; + +use crate::{FigApp, NoServices}; + +/// Two files open: "Back" behind "Front", which is in front. Returns the app and Back's id. +fn two_files() -> (FigApp, u64) { + let mut s = Session::new(); + s.execute("file.new", &json!({"name": "Front"})).unwrap(); + s.docs[0].doc_mut().name = "Back".into(); + let back = s.docs[0].uid; + (FigApp::new(s, Box::new(NoServices)), back) +} + +fn handle(app: &mut FigApp, method: &str, p: Value) -> Result { + super::handle(app, &egui::Context::default(), method, &p) +} + +fn layers(app: &FigApp, i: usize) -> usize { + let ds = &app.session.docs[i]; + ds.doc.node(ds.page).map_or(0, |p| p.children.len()) +} + +#[test] +fn a_call_naming_the_back_file_edits_it_and_leaves_the_front_one_alone() { + let (mut app, back) = two_files(); + let rect = json!({"command": "shape.rectangle", "params": {"x": 0, "y": 0, "width": 20, "height": 10}, "document": back}); + handle(&mut app, "engine.execute", rect).unwrap(); + assert_eq!((layers(&app, 0), layers(&app, 1)), (1, 0), "the rectangle landed in Back only"); + assert_eq!(app.session.active, 1, "Front stays in front"); + let doc = handle(&mut app, "document.inspect", json!({"depth": 1, "document": back})).unwrap(); + assert_eq!((doc["name"].as_str(), doc["id"].as_u64()), (Some("Back"), Some(back))); + let listed = handle(&mut app, "engine.execute", json!({"command": "doc.list"})).unwrap(); + assert_eq!(listed[0]["id"], json!(back)); + assert!(handle(&mut app, "ui.render", json!({"document": back})).is_ok()); + // An open file's path names it too. + app.session.docs[0].path = Some("/work/back.figcraft".into()); + let doc = handle(&mut app, "document.inspect", json!({"depth": 0, "document": "/work/back.figcraft"})).unwrap(); + assert_eq!(doc["id"], json!(back)); + assert_eq!(app.session.active, 1); +} + +#[test] +fn a_file_that_is_not_open_or_a_ui_method_on_the_back_file_is_an_error() { + let (mut app, back) = two_files(); + let e = handle(&mut app, "document.inspect", json!({"document": 999_999})).unwrap_err(); + assert!(e.contains("no open document 999999"), "{e}"); + let e = handle(&mut app, "ui.type", json!({"text": "x", "document": back})).unwrap_err(); + assert!(e.contains("acts on the tab in front"), "{e}"); + let front = app.session.docs[1].uid; + assert!(handle(&mut app, "ui.inspect", json!({"document": front})).is_ok(), "the file in front may be named"); + assert_eq!(app.session.active, 1); +} + +#[test] +fn a_command_that_opens_a_file_still_brings_it_to_the_front() { + let (mut app, back) = two_files(); + handle(&mut app, "engine.execute", json!({"command": "file.new", "document": back})).unwrap(); + assert_eq!(app.session.active, 2, "a new file opens in front"); +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 8b7c225f..3fcc5127 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -65,6 +65,13 @@ window, with its tabs, panels and canvas: `engine.execute` acts on the file it s last in front (the one the user clicked last, or the last `window.new` / `window.focus`). An unknown id is an error. +**Documents.** `engine.execute`, `document.inspect`, `node.get` and `ui.render` take an optional +`document`: an open file's `id` (from `doc.list`, or `document.inspect`'s `id`) or its path. The +request acts on that file without bringing its tab to the front; the tab in front comes back +afterwards unless the command opened or switched to another file itself. A file that isn't open is +an error, and so is a `document` naming a file behind the tab in front on any other method +(`ui.screenshot`, `ui.input`, `ui.key`… act on what the window shows). + ## Confining file access ```sh diff --git a/docs/mcp.md b/docs/mcp.md index 12ab9680..8f53f818 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -345,7 +345,7 @@ a later step can use `{"$step": 0, "field": "ids/0"}` to target the first match. | `batch {commands: [{command, params}], label?}` | Several commands as one undo step (`command.batch`); all-or-nothing for open documents and their document state. Failed inner commands are removed from the journal; preferences, clipboard, tool state and UI requests are not rolled back. A step can use what an earlier step returned: name it with `"as": "card"` and `{"$ref": "card"}` anywhere in a later step's params becomes the `id` in its result (`{"$step": n}` does the same by zero-based position); `"field"` takes another key or a slash path such as `ids/0` (see Step references below). | | `create_node {type, x, y, width, height, parent, index, name, text, path, props}` | Create a frame, rectangle, ellipse, polygon, star, line, arrow, text, vector, section, component or slice; returns `{id, node}`. | | `set_properties {ids, props}` | `node.set` on layers (geometry, fills, strokes, effects, corner radius, auto layout, sizing, constraints, masks (`isMask`, `maskType: alpha\|vector\|luminance`), handoff status (`devStatus: none\|readyForDev\|completed`; marking again clears "Changed"), a section's `sectionContentsHidden`, text…); returns the updated layers. | -| `inspect_document {depth, allPages}` | The document as JSON (layer tree, styles, selection, history). | +| `inspect_document {depth, allPages}` | The document as JSON (its `id`, layer tree, styles, selection, history). The tools that act on a document also take `document` (see Naming the document below). | | `get_node {id, depth}` | One layer as JSON; `pluginData` (`{key: value}`, values as strings) when the layer or page has layer data written with `set_properties {props: {pluginData}}`. | | `select {ids}` | Replace the selection (`[]` deselects). | | `export {ids, format, scale \| size, path?, bleed?, cropMarks?, bleedMarks?, cmyk?, contentsOnly?, includeBoundingBox?, svgOutlineText?, svgIdAttribute?, svgSimplifyStroke?, vdCompat?}` | png, jpg, svg, webp, pdf, xml (an Android Vector Drawable: `svgIdAttribute` writes `android:name`, `vdCompat` is `bestEffort` (default: approximated with a warning), `exact` (left out with a warning) or `ignore` (no warnings: overlapping layer opacity dropped, even-odd fills kept as `fillType="evenOdd"`, diamond gradients left out), and the result lists `warnings` for what was changed or left out), or `fig`: the whole file as `.fig`, experimental (see Exporting `.fig` files below); `size` is `2x`, `512w` or `512h`. The image covers what the layers paint (shadows towards their offset, strokes, unclipped children, a mask group's mask; hidden layers take no room; a slice is its area with everything under it, or only the frame or group it sits in while `contentsOnly` is on); `contentsOnly: false` also draws every other layer showing in that area, `includeBoundingBox` exports the layers' own boxes; SVG: `svgOutlineText: false` keeps ``, `svgIdAttribute` writes unique ids from layer names, `svgSimplifyStroke: false` writes every stroke as an outline. The same options go in export settings (`export.add` / `export.update`). PDF: frames one page each (sized in points), anything else together on one page; print options: `bleed` (points or a length like `"3mm"`, `"0.125in"`), crop and bleed marks, CMYK colour (see Exports below), also taken by `file.export`, `file.exportLayers`, `file.exportFramesPdf` and stored per export setting by `export.add` / `export.update`; `run_command` `file.exportFramesPdf {page?, path?}` writes every top-level frame of a page. Without `path`, images come back as MCP image content, SVG as text, XML as text followed by `{format, bytes, warnings}`. Layers' own export settings: `run_command` with `export.add` / `export.update {index, size?, suffix?, format?}` / `export.remove` and `file.exportLayers {ids?, dir?, assets?}` (every layer once per setting, named `.`, a slash in a layer name making a folder; `assets: true` exports the Inspect tab's assets instead: icons as SVG, images as PNG). | @@ -1300,6 +1300,20 @@ opens one (a new file, the file at `path`, or open file `index` moved out of its focus a window before working on its files, and pass its id to `screenshot {window}` to see it. Headless (one session, no windows) and in the browser the two commands do nothing. +### Naming the document + +Tools act on the file in front, so with an attached app a tab the user clicks mid-task would +change where the next edit lands. `run_command`, `batch`, `create_node`, `set_properties`, +`inspect_document`, `get_node`, `select`, `export`, `screenshot`, `save_file`, `undo` and `redo` +take an optional `document`: the file's `id` (in `run_command {command: "doc.list"}`, and the `id` +of the `document` outline `open_file` and `inspect_document` return) or the path of an open file. +The call acts on that file and the tab in front stays in front, unless the command itself opens or +switches to another file (`file.open`, `file.new`, `doc.switch`), as it would without `document`. A +file that isn't open is an error. `screenshot {window}` captures what the window shows, so it +needs the file in front. Over the control channel, `engine.execute`, `document.inspect`, +`node.get` and `ui.render` take the same `document`; other methods refuse one naming a file behind +the tab in front. Headless sessions take it too. + ### Live sessions People (and agents) edit one file together and see each other's cursors, selections and diff --git a/docs/roadmap.md b/docs/roadmap.md index c9f661e9..cda64272 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-28 · **Change:** minor (sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); the instance-swap menus have a search field (#1338); a refused batch's fixes take time linear in the batch (#1063); the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); M0.9: optional batch continue-on-error with per-step outcomes (#1386); Alt pressed during a drag duplicates (#1385); variables in paint props (M3, #1000); finding layers without selecting them (M0.9, part of #996); the attached MCP server reads the app's replies with a 512 MiB bound (#1113); a failed batch keeps the undo steps it undid (#1410); Arabic draft catalog; Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-28 · **Change:** minor (MCP and control calls name their document (#998); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); the instance-swap menus have a search field (#1338); a refused batch's fixes take time linear in the batch (#1063); the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); M0.9: optional batch continue-on-error with per-step outcomes (#1386); Alt pressed during a drag duplicates (#1385); variables in paint props (M3, #1000); finding layers without selecting them (M0.9, part of #996); the attached MCP server reads the app's replies with a 512 MiB bound (#1113); a failed batch keeps the undo steps it undid (#1410); Arabic draft catalog; Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **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 @@ -75,6 +75,7 @@ blockers. | Date | Change | Summary | |---|---|---| +| 2026-10-29 | minor | M8, #998: MCP tools that act on a document (`run_command`, `batch`, `create_node`, `set_properties`, `inspect_document`, `get_node`, `select`, `export`, `screenshot`, `save_file`, `undo`, `redo`) take an optional `document`, an open file's `id` (`doc.list` and the `document.inspect` outline now carry it) or path, and so do the control methods `engine.execute`, `document.inspect`, `node.get` and `ui.render`: the call acts on that file and the tab in front stays in front, so an attached agent keeps editing its own file when the user switches tabs. Calls without it act on the file in front as before; other control methods refuse a `document` behind the tab in front. Numbers unchanged | | 2026-10-29 | minor | Sibling placement in live sessions (#1065, from a security audit): every replica (the server's and each app's) placed a created, moved or reordered layer by counting each sibling that sorts before it, so a batch of many layers under one parent took quadratic time, on the server's single hub thread too. It now binary-searches the siblings' `(key, id)` order on each side of the layer's old place. The order is unchanged, ties and fractional positions included: a test replays 5,000 creations and 2,500 moves against the counting rule. 20,000 creations and 10,000 moves under two parents took 20 s in a debug build; they now take 0.3 s. | | 2026-10-29 | minor | Comments from an AI agent (M8, #1414): an agent attached over MCP commented, reacted and read threads as the user (the name from Preferences) unless it passed `author` on every call. Every command an MCP client runs now acts as that client's name from `initialize` (`clientInfo.title`, else `.name`) with " (agent)" after it, or "AI agent" without one, carried to the app as `engine.execute {agent}`; its messages, reactions and read state are its own, and its messages are marked (`agent: true` in the file and in `comment.list`, an Agent label by the name in the thread). The user's name and the comments made in the app are unchanged, and in a live session the name and marker travel with the thread to everyone. Tests in `comment_agent_tests`, the MCP `agent_comments` and attached suites, the UI comments suite and the collab diff round trip. Numbers unchanged | | 2026-10-29 | minor | M3, #1338: the instance-swap property's menu (on an instance, its main component's default and the component playground) and the instance's swap menu open with a search field at the top that has the keyboard; typing filters the components by name (any case, headings without matches drop out), an empty result reads "No matching components", Up and Down step through the matches and Enter picks one (the first without a step), Escape clears the search and then closes the menu. Only the list scrolls; the menu grows back when the search is cleared. Tests: `component_swap_search_tests`. Numbers unchanged | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 98167a69..f5b3b550 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-29 · **Change:** minor (shadows of images with transparent pixels follow their alpha (#1289); Arabic draft catalog; truncated text fills its last line before the ellipsis and keeps truncating when resized; auto-height text with max lines (#1375, #1376, #1377), numbers unchanged; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; `.sketch` import, best effort: import/export breadth 86% → 87%, file formats 60% → 61%, headline numbers unchanged; Canvas & navigation's remaining work no longer lists outline mode's Include hidden layers and Include object bounds; boards: area 22 (weight 0, outside the weighted sum; CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, iteration marks 〱–〵 and the halfwidth parentheses; Turkish reviewed, Czech and Indonesian drafts, the Japanese draft completed; reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%; last major: full re-measure against Figma desktop 126.10.7; rows for the community batches: Selection colors, smart-selection spacing and a Present tab (#753), `.fig` import fidelity, paste from another design app, a `.fig` copy from Save as; control requests while the window is minimised (#720); catalog counts re-measured; automation roots (#715); number variables in the text content picker (#629); frames and sections convert into each other (#719); reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%; last major: full re-measure against Figma desktop 126.10.7; Canvas & navigation's remaining work no longer lists outline mode's Include hidden layers and Include object bounds; boards: area 22 (weight 0, outside the weighted sum; Turkish reviewed, Czech and Indonesian drafts, the Japanese draft completed; reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%; last major: full re-measure against Figma desktop 126.10.7; rows for the community batches: Selection colors, smart-selection spacing and a Present tab (#753), `.fig` import fidelity, paste from another design app, a `.fig` copy from Save as; control requests while the window is minimised (#720); catalog counts re-measured; automation roots (#715); number variables in the text content picker (#629); frames and sections convert into each other (#719); reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%; 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-29 · **Change:** minor (MCP and control calls name their document (#998); shadows of images with transparent pixels follow their alpha (#1289); Arabic draft catalog; truncated text fills its last line before the ellipsis and keeps truncating when resized; auto-height text with max lines (#1375, #1376, #1377), numbers unchanged; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; `.sketch` import, best effort: import/export breadth 86% → 87%, file formats 60% → 61%, headline numbers unchanged; Canvas & navigation's remaining work no longer lists outline mode's Include hidden layers and Include object bounds; boards: area 22 (weight 0, outside the weighted sum; CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, iteration marks 〱–〵 and the halfwidth parentheses; Turkish reviewed, Czech and Indonesian drafts, the Japanese draft completed; reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%; last major: full re-measure against Figma desktop 126.10.7; rows for the community batches: Selection colors, smart-selection spacing and a Present tab (#753), `.fig` import fidelity, paste from another design app, a `.fig` copy from Save as; control requests while the window is minimised (#720); catalog counts re-measured; automation roots (#715); number variables in the text content picker (#629); frames and sections convert into each other (#719); reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%; last major: full re-measure against Figma desktop 126.10.7; Canvas & navigation's remaining work no longer lists outline mode's Include hidden layers and Include object bounds; boards: area 22 (weight 0, outside the weighted sum; Turkish reviewed, Czech and Indonesian drafts, the Japanese draft completed; reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%; last major: full re-measure against Figma desktop 126.10.7; rows for the community batches: Selection colors, smart-selection spacing and a Present tab (#753), `.fig` import fidelity, paste from another design app, a `.fig` copy from Save as; control requests while the window is minimised (#720); catalog counts re-measured; automation roots (#715); number variables in the text content picker (#629); frames and sections convert into each other (#719); reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%; 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 @@ -646,6 +646,7 @@ Carried over from the ROADMAP's "Shipped so far" (2026-10-10). The test count th | Date | Change | Summary | |---|---|---| +| 2026-10-29 | minor | M8, #998: MCP tools that act on a document (`run_command`, `batch`, `create_node`, `set_properties`, `inspect_document`, `get_node`, `select`, `export`, `screenshot`, `save_file`, `undo`, `redo`) take an optional `document`, an open file's `id` (`doc.list` and the `document.inspect` outline now carry it) or path, and so do the control methods `engine.execute`, `document.inspect`, `node.get` and `ui.render`: the call acts on that file and the tab in front stays in front, so an attached agent keeps editing its own file when the user switches tabs. Calls without it act on the file in front as before; other control methods refuse a `document` behind the tab in front. Numbers unchanged | | 2026-10-29 | minor | Shadows of images with transparent pixels (M3, #1289): a drop shadow is cast from the picture's alpha and hidden only behind its pixels, not its whole box (a transparent PNG's shadow was cut away inside its bounding box), unless shown behind transparent areas; its inner shadow shades the inside of its alpha, not the box's edge; the same for Fit images, crops smaller than the box and pattern fills. Opaque pictures that fill their box keep their outline (spread still offsets it exactly); each image is scanned for transparency once. Canvas, PNG, SVG (knocked out by the layer's alpha; an inner shadow embeds a render) and PDF (`image_shadow_tests` in render and svg). Numbers unchanged | | 2026-10-28 | minor | Arabic draft: 1,857 UI strings, 620 command labels, 31 menu wordings; count-neutral plurals and web font gate. Native review and panel mirroring remain open; grade and estimates unchanged. Evidence: `crates/ui-egui/locales/ar.toml`, i18n catalog tests and [translation notes](translating.md#arabic-draft) | | 2026-10-24 | patch | Canvas & navigation's remaining work no longer lists outline mode's Include hidden layers and Include object bounds, which landed (`view.outlinesHiddenLayers`, `view.outlinesObjectBounds`, `crates/engine/src/outline_options_tests.rs`). Numbers unchanged |