From 57f0185f41dc3a9a2ed64ccd1f233b036e27d124 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lars=20S=C3=B8raas?= Date: Fri, 30 Oct 2026 18:07:57 +0100 Subject: [PATCH 01/15] M0.9: document that x/y offset a vector path by its own box node.create / vector.create (and MCP create_node) place a vector made from SVG path data or points at x/y plus the box's unscaled offset: a path whose box starts at (30, 0) with x 830 lands at 860. That is deliberate and pinned by vector_path_set_tests, but the MCP schema described x as the layer's left, which misled an agent drawing a poster. The x, y and path descriptions and the node.create / vector.create parameter docs now say so, and an MCP test pins the placement for path and points, the 0,0 alternative, set_properties x moving the left edge, and the documentation. CI: fmt, clippy, layers, assets and wasm pass; every test passes except board_tables_tests::a_double_click_types_in_the_cell_and_its_row_grows_with_the_text and clipboard_fence_tests::a_synthetic_copy_shortcut_leaves_the_system_clipboard_alone, which fail the same way on main without this change. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/create.rs | 4 ++-- crates/mcp/src/tools.rs | 6 +++--- crates/mcp/tests/it/protocol.rs | 25 +++++++++++++++++++++++++ 3 files changed, 30 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/cmd/create.rs b/crates/engine/src/cmd/create.rs index 1e1f8f3bb..4ab07c6c2 100644 --- a/crates/engine/src/cmd/create.rs +++ b/crates/engine/src/cmd/create.rs @@ -55,7 +55,7 @@ pub fn specs() -> Vec { spec!( "node.create", "Create layer", - "{type: rectangle|ellipse|polygon|star|line|arrow|vector|text|frame|section|component|slice, x?, y? (relative to the parent frame; groups don't count), width?, height?, parent? (id; default: the page), index?, name?, text? | characters? (for text), path? | points? (vector: SVG path data | [[x,y],..]), closed? (vector), props? {node.set keys}, select? (default true)}", + "{type: rectangle|ellipse|polygon|star|line|arrow|vector|text|frame|section|component|slice, x?, y? (relative to the parent frame; groups don't count), width?, height?, parent? (id; default: the page), index?, name?, text? | characters? (for text), path? | points? (vector: SVG path data | [[x,y],..]; x/y move their own origin, so the layer's left/top is x/y plus their box's unscaled offset), closed? (vector), props? {node.set keys}, select? (default true)}", true, create ), @@ -102,7 +102,7 @@ pub fn specs() -> Vec { spec!( "vector.create", "Vector", - "{path (SVG path data) | points [[x,y],..], closed?, x?, y?, width?, height? (scale the path; one keeps its proportion), parent?, index?, name?, props?, select? (default true)}", + "{path (SVG path data) | points [[x,y],..], closed?, x?, y? (move the path's own origin: the layer's left/top is x/y plus the box's unscaled offset), width?, height? (scale the path; one keeps its proportion), parent?, index?, name?, props?, select? (default true)}", true, |s, p| create_kind(s, p, "vector") ), diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index 2cf139915..1cb141b58 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -187,15 +187,15 @@ fn tools() -> Vec { obj( json!({ "type": {"type": "string", "enum": ["frame", "rectangle", "ellipse", "polygon", "star", "line", "arrow", "text", "vector", "section", "component", "slice"], "description": "Layer type"}, - "x": num("Left, relative to the parent frame (default 0)"), - "y": num("Top, relative to the parent frame (default 0)"), + "x": num("Left, relative to the parent frame (default 0). For a vector with `path` or `points`, x/y move their own origin instead: data whose box starts at (30, 0) makes a layer whose left is x + 30. Start the path at 0,0 to place its left edge at x, or set x afterwards with set_properties (which moves the left edge)"), + "y": num("Top, relative to the parent frame (default 0). For a vector with `path` or `points`, their own top is added, as for x"), "width": num("Width (default 100; text hugs its content unless width is set, which makes it wrap; for a vector with path, width/height scale the path, one alone keeping its proportion, and without them the path's own box is used)"), "height": num("Height (default 100; ignored for lines/arrows and auto-height text)"), "parent": id_schema("Parent frame/group/component id (default: the current page)"), "index": int("Z-index among the parent's children (default: on top)"), "name": string("Layer name"), "text": string("Characters, for type text"), - "path": string("SVG path data, for type vector (e.g. \"M0 0 L100 0 L50 80 Z\")"), + "path": string("SVG path data, for type vector (e.g. \"M0 0 L100 0 L50 80 Z\"); its coordinates are kept relative to x/y, so a path that doesn't start at 0,0 is offset by its own box"), "points": {"type": "array", "items": {"type": "array", "items": {"type": "number"}}, "description": "For type vector, instead of path: the points [[x, y], ...]"}, "closed": boolean("For type vector: close the path"), "select": boolean("Select the new layer (default true)"), diff --git a/crates/mcp/tests/it/protocol.rs b/crates/mcp/tests/it/protocol.rs index 2794cc0e2..cadadbad0 100644 --- a/crates/mcp/tests/it/protocol.rs +++ b/crates/mcp/tests/it/protocol.rs @@ -325,3 +325,28 @@ fn set_properties_passes_on_what_it_skipped() { assert!(c.ok("set_properties", json!({"ids": [f], "props": {"gap": 4}})).get("warnings").is_none()); assert!(c.err("set_properties", json!({"ids": [t], "props": {"gap": 4}})).contains("auto layout")); } + +#[test] +fn create_node_x_y_offset_a_vector_path_by_its_own_box_as_documented() { + // An agent placed `M95 0 L225 0 ...` (box starting at x 30) with x 830 expecting its left at + // 830: x/y move the path's own origin, so the layer lands at 860. The schema has to say so. + let mut c = Client::new(); + let lid = "M95 0 L225 0 L290 130 L232 470 L88 470 L30 130 Z"; + let offset = c.create(json!({"type": "vector", "path": lid, "x": 830, "y": 445, "width": 260, "height": 470})); + let n = c.node(&offset); + assert_eq!((n["x"].as_f64(), n["y"].as_f64(), n["width"].as_f64()), (Some(860.0), Some(445.0), Some(260.0)), "{n}"); + // The same shape drawn from 0,0 lands at x/y. + let at_origin = c.create(json!({"type": "vector", "path": "M65 0 L195 0 L260 130 L202 470 L58 470 L0 130 Z", "x": 830, "y": 445})); + assert_eq!(c.node(&at_origin)["x"].as_f64(), Some(830.0)); + // Points are placed the same way. + let points = c.create(json!({"type": "vector", "points": [[30, 0], [290, 130], [88, 470]], "closed": true, "x": 830, "y": 445})); + assert_eq!(c.node(&points)["x"].as_f64(), Some(860.0)); + // set_properties x moves the left edge. + c.ok("set_properties", json!({"ids": [offset], "props": {"x": 830}})); + assert_eq!(c.node(&offset)["x"].as_f64(), Some(830.0)); + let r = c.rpc("tools/list", json!({})); + let tool = r["result"]["tools"].as_array().unwrap().iter().find(|t| t["name"] == "create_node").unwrap().clone(); + let x_doc = tool["inputSchema"]["properties"]["x"]["description"].as_str().unwrap(); + assert!(x_doc.contains("path") && x_doc.contains("x + 30"), "{x_doc}"); + assert!(tool["inputSchema"]["properties"]["path"]["description"].as_str().unwrap().contains("offset by its own box")); +} From 57a51af8bb4d0d3184a8a2f657d59b1c3d1b43a1 Mon Sep 17 00:00:00 2001 From: BFlat Date: Sat, 31 Oct 2026 08:41:40 +0700 Subject: [PATCH 02/15] M5: the Controller menu names each button as PlayStation and Switch controllers do (#1726) --- ROADMAP.md | 3 ++- crates/doc/src/proto.rs | 23 +++++++++++++++++++++++ crates/ui-egui/src/panels/proto_panel.rs | 16 ++++++++++++---- crates/ui-egui/src/present/tail_tests.rs | 5 +++++ docs/gaps.md | 5 +++-- docs/prototyping-parity.md | 5 +++-- docs/roadmap.md | 3 ++- 7 files changed, 50 insertions(+), 10 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 2adc48c1a..de5ceba97 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-30 · **Change:** minor (widgets draw lines and SVG pictures (plugin API 6); a connector end dropped along a side of a shape attaches to that side (#1708); regression tests pin a rounded shape's one-sided stroke and SVG ids and font families without XML-forbidden characters (#1351, #1124); a damaged image is refused with a reason instead of showing as an empty box (#1474); an image copied in another app pastes again on the desktop, and in the browser for the first time (#492); SwiftUI and Compose code names a bound variable by its iOS or Android code syntax; the colour picker's Image tab shows a placeholder and Upload from computer, an asset preview, and a file dropped on it replaces the image, keeping the fill mode, crop and position (#1682); the Frame tool's Phone presets have the iPhone 17 family (#1691); Show in folder works in the Flatpak build and selects the file on Linux (#1701); a dashed line starts and ends with a half-length dash, on the canvas and in exports; the Pages, Layers and Assets search fields keep their text in the middle of the field (#1703); a board connector's path can be reshaped by dragging its path handles (#1710); boards have 16 more shapes (arrows, polygons, star, plus and flowchart symbols), in pickers that lay them out in a grid; library analytics count insertions and show insertions and detachments over the past 30 days, 90 days or year; a stroke's Custom style takes a dash pattern of any length as a comma-separated list (#1681); a control request can set its own timeout, up to 10 minutes (`timeoutMs`; `figcraft-cli mcp --attach PORT --timeout SECONDS`) (#1696); gap 6: a widget opens its plugin's panel (plugin API 5), whose callbacks redraw it; the keyboard's Menu key opens the context menu, as Shift+F10 does; an Open link action can open in place of the prototype, with "Open in new tab" off (gap 15); a collection of this file can be extended (Extend collection on its row, `variable.extendCollection`), its extension following its edits at once, and extended collections extend again in the file; the eyedropper with Cmd/Ctrl+Shift makes a colour style or variable of what it picks; a live session's welcome sends the document compressed to clients that read it, about seven times smaller (#1665); a control request that times out says truthfully whether it was dropped or is still running and will take effect, and only screenshots and input blame the window (#1673); a board keeps earlier votes' results, listed newest first in Results and deleted one at a time; the object a stamp will stick to is outlined while the stamp tool is over it or a stamp is dragged over it, and board objects take no drops (gap 24); a server can list the files shared from it now for everyone who reaches it, Home ▸ Online shows them and their links carry no secret (`team_files`, part of #742); team library analytics count detached instances per component and file (gap 10); skipping the Open link notice by command is saved with the preferences at once (gap 15); a selected connector's bar on a board sets its start and end points, solid or dashed, its weight and any colour (#1596); cells copied from a spreadsheet paste onto a board as a table; a long layer name stays on one line in the Export button and the Dev Mode header, ending with an ellipsis, and no longer covers the Design panel (#1650); with hundreds of layers selected, the Design panel and the selection overlays stop going over the whole selection on every frame, so dragging 648 layers averages about 1.1 ms of UI per frame (#1571); plugin panels show pictures: an `image` control, base64 within 256 KB and 4,096 px a side, named by its label (gap 6); `.fig` connector labels without a saved snapshot are kept, centred on their line (gap 1); a drag moving many layers re-runs only what sits around them, not each layer's bindings, sizing, text and instance checks, on every move (#1571); damaged font tracking table offsets are checked instead of wrapping on 32-bit targets, and font indexing makes its existing offset bounds explicit (#1125); Bosnian (`bs`) draft catalog; text style dedupe is O(spans) via a last-two cache and fingerprint buckets (#1102); pasted layers whose image the clipboard lacks are named and show a placeholder that reads as missing (#1646); paint-style colours on layers pasted from another design app keep their style instead of turning black (#1638); progress docs cleaned of what merges left twice: rows merged into one, repeated status items removed, and `cargo xtask docs` (in `ci`) fails on a repeated table row or status item; a name typed for a variable or collection finds the one with that exact id before one named like it, and stored mode ids, font family variables and the panels' labels read exact ids (#1613); plugin widgets have hover styles: a layer's `hover` fill, stroke, opacity or text colour shows while the pointer is over it (gap 6); `.fig` elbowed and curved connectors without a saved path are routed like board connectors instead of drawn straight (gap 1); every file a command reads whole by path is a regular file within a cap that fits it: documents 16 GiB, placed images 256 MiB, CLI scripts 1 GiB; `figcraft-cli connect publish` publishes a code repository's code connect mappings to a design file (gap 16); while a drag only moves a large selection, its outlines, box, slots, spacing and Design panel values are worked out once and shifted, not rebuilt on every move (#1571); Shift+Cmd/Ctrl+Enter on a selected board shape adds the next shape already joined to it, and goes on typing (#1597); an imported or pasted file finds an installed family under the other names its fonts give it, and with other case and spacing, instead of reporting it missing (#1292); a development example generates figcraft-cli's shell completions and man pages from its clap definitions (part of #1396); `.fig` ZIP image reads share a total byte budget and count skipped images in the import warnings (#1076); German terminology corrections (M10): gradient stops, OpenType stylistic sets and the Home-screen preference; a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; the server key is checked by comparing SHA-256 hashes of the presented and the kept key, as the deployment guide says (#1130); opening a file contacts no team library server it names until you check it (#1097); a hostile server can no longer grow a client's memory with made-up people, and a session remembers a bounded number of deleted layer ids (#1121); `doc.list` gives AI agents and plugins the open files' names, not their paths (#1117); with a large selection, frames where nothing changed reuse the selection's outlines, box and slots and the Design panel's instance and position values instead of working them out again (#1571); team libraries and plugins larger than the 16 MiB download burst can be downloaded again, more slowly (#1089); a batch keeps one undo snapshot, not one per step (#1099); files nested 256 levels deep import on the browser's 1 MB stack (#1101); SVG and PDF exports count pattern sources and the renders they embed from the layer's depth, so one cap bounds a whole export (#1082); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); joining and leaving a file nobody is editing reads and writes nothing on the server: it stays loaded a minute after everyone leaves (#1074); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); hovering the canvas hit-tests once per pointer move, not on every frame, and moving an auto layout frame no longer lays out its contents again on every move (#1571); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); `comment.people` lists names that were only @mentioned, and `comment.next` and `comment.prompt` carry the mentions too (#1415); grid auto-placement looks for free cells a 64-bit word at a time and skips what blocks it, with the same placements (#1083); with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); a recovery copy made while a version is previewed holds the file, not the version shown; a FigJam `.jam` file opens as a board (#1342); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); share links can comment: a third access level beside edit and view, whose holder sees the file live and adds, replies to, reacts to and resolves comments but can't change the design; Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); `comment.list {mentions}` lists only the threads that @mention a name (#1415); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); figcraft-cli parses its arguments with clap, following RFC-0012 (#1396); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); Thai, Lao, Khmer and Burmese lines break between words (#1219); an AI agent attached over MCP shows where it works (#1416); a manual overlay's position can be dragged on the canvas (#405); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); SVG import draws each pattern tile once per scale, within the layer budget (#1080); Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); components can't contain their own instances, directly or through other components; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); 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) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (the Controller menu names each button as PlayStation and Switch controllers do; widgets draw lines and SVG pictures (plugin API 6); a connector end dropped along a side of a shape attaches to that side (#1708); regression tests pin a rounded shape's one-sided stroke and SVG ids and font families without XML-forbidden characters (#1351, #1124); a damaged image is refused with a reason instead of showing as an empty box (#1474); an image copied in another app pastes again on the desktop, and in the browser for the first time (#492); SwiftUI and Compose code names a bound variable by its iOS or Android code syntax; the colour picker's Image tab shows a placeholder and Upload from computer, an asset preview, and a file dropped on it replaces the image, keeping the fill mode, crop and position (#1682); the Frame tool's Phone presets have the iPhone 17 family (#1691); Show in folder works in the Flatpak build and selects the file on Linux (#1701); a dashed line starts and ends with a half-length dash, on the canvas and in exports; the Pages, Layers and Assets search fields keep their text in the middle of the field (#1703); a board connector's path can be reshaped by dragging its path handles (#1710); boards have 16 more shapes (arrows, polygons, star, plus and flowchart symbols), in pickers that lay them out in a grid; library analytics count insertions and show insertions and detachments over the past 30 days, 90 days or year; a stroke's Custom style takes a dash pattern of any length as a comma-separated list (#1681); a control request can set its own timeout, up to 10 minutes (`timeoutMs`; `figcraft-cli mcp --attach PORT --timeout SECONDS`) (#1696); gap 6: a widget opens its plugin's panel (plugin API 5), whose callbacks redraw it; the keyboard's Menu key opens the context menu, as Shift+F10 does; an Open link action can open in place of the prototype, with "Open in new tab" off (gap 15); a collection of this file can be extended (Extend collection on its row, `variable.extendCollection`), its extension following its edits at once, and extended collections extend again in the file; the eyedropper with Cmd/Ctrl+Shift makes a colour style or variable of what it picks; a live session's welcome sends the document compressed to clients that read it, about seven times smaller (#1665); a control request that times out says truthfully whether it was dropped or is still running and will take effect, and only screenshots and input blame the window (#1673); a board keeps earlier votes' results, listed newest first in Results and deleted one at a time; the object a stamp will stick to is outlined while the stamp tool is over it or a stamp is dragged over it, and board objects take no drops (gap 24); a server can list the files shared from it now for everyone who reaches it, Home ▸ Online shows them and their links carry no secret (`team_files`, part of #742); team library analytics count detached instances per component and file (gap 10); skipping the Open link notice by command is saved with the preferences at once (gap 15); a selected connector's bar on a board sets its start and end points, solid or dashed, its weight and any colour (#1596); cells copied from a spreadsheet paste onto a board as a table; a long layer name stays on one line in the Export button and the Dev Mode header, ending with an ellipsis, and no longer covers the Design panel (#1650); with hundreds of layers selected, the Design panel and the selection overlays stop going over the whole selection on every frame, so dragging 648 layers averages about 1.1 ms of UI per frame (#1571); plugin panels show pictures: an `image` control, base64 within 256 KB and 4,096 px a side, named by its label (gap 6); `.fig` connector labels without a saved snapshot are kept, centred on their line (gap 1); a drag moving many layers re-runs only what sits around them, not each layer's bindings, sizing, text and instance checks, on every move (#1571); damaged font tracking table offsets are checked instead of wrapping on 32-bit targets, and font indexing makes its existing offset bounds explicit (#1125); Bosnian (`bs`) draft catalog; text style dedupe is O(spans) via a last-two cache and fingerprint buckets (#1102); pasted layers whose image the clipboard lacks are named and show a placeholder that reads as missing (#1646); paint-style colours on layers pasted from another design app keep their style instead of turning black (#1638); progress docs cleaned of what merges left twice: rows merged into one, repeated status items removed, and `cargo xtask docs` (in `ci`) fails on a repeated table row or status item; a name typed for a variable or collection finds the one with that exact id before one named like it, and stored mode ids, font family variables and the panels' labels read exact ids (#1613); plugin widgets have hover styles: a layer's `hover` fill, stroke, opacity or text colour shows while the pointer is over it (gap 6); `.fig` elbowed and curved connectors without a saved path are routed like board connectors instead of drawn straight (gap 1); every file a command reads whole by path is a regular file within a cap that fits it: documents 16 GiB, placed images 256 MiB, CLI scripts 1 GiB; `figcraft-cli connect publish` publishes a code repository's code connect mappings to a design file (gap 16); while a drag only moves a large selection, its outlines, box, slots, spacing and Design panel values are worked out once and shifted, not rebuilt on every move (#1571); Shift+Cmd/Ctrl+Enter on a selected board shape adds the next shape already joined to it, and goes on typing (#1597); an imported or pasted file finds an installed family under the other names its fonts give it, and with other case and spacing, instead of reporting it missing (#1292); a development example generates figcraft-cli's shell completions and man pages from its clap definitions (part of #1396); `.fig` ZIP image reads share a total byte budget and count skipped images in the import warnings (#1076); German terminology corrections (M10): gradient stops, OpenType stylistic sets and the Home-screen preference; a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; the server key is checked by comparing SHA-256 hashes of the presented and the kept key, as the deployment guide says (#1130); opening a file contacts no team library server it names until you check it (#1097); a hostile server can no longer grow a client's memory with made-up people, and a session remembers a bounded number of deleted layer ids (#1121); `doc.list` gives AI agents and plugins the open files' names, not their paths (#1117); with a large selection, frames where nothing changed reuse the selection's outlines, box and slots and the Design panel's instance and position values instead of working them out again (#1571); team libraries and plugins larger than the 16 MiB download burst can be downloaded again, more slowly (#1089); a batch keeps one undo snapshot, not one per step (#1099); files nested 256 levels deep import on the browser's 1 MB stack (#1101); SVG and PDF exports count pattern sources and the renders they embed from the layer's depth, so one cap bounds a whole export (#1082); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); joining and leaving a file nobody is editing reads and writes nothing on the server: it stays loaded a minute after everyone leaves (#1074); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); hovering the canvas hit-tests once per pointer move, not on every frame, and moving an auto layout frame no longer lays out its contents again on every move (#1571); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); `comment.people` lists names that were only @mentioned, and `comment.next` and `comment.prompt` carry the mentions too (#1415); grid auto-placement looks for free cells a 64-bit word at a time and skips what blocks it, with the same placements (#1083); with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); a recovery copy made while a version is previewed holds the file, not the version shown; a FigJam `.jam` file opens as a board (#1342); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); share links can comment: a third access level beside edit and view, whose holder sees the file live and adds, replies to, reacts to and resolves comments but can't change the design; Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); `comment.list {mentions}` lists only the threads that @mention a name (#1415); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); figcraft-cli parses its arguments with clap, following RFC-0012 (#1396); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); Thai, Lao, Khmer and Burmese lines break between words (#1219); an AI agent attached over MCP shows where it works (#1416); a manual overlay's position can be dragged on the canvas (#405); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); SVG import draws each pattern tile once per scale, within the layer budget (#1080); Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); components can't contain their own instances, directly or through other components; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); 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 @@ -358,6 +358,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-31 | minor | M5: a key/gamepad trigger's Controller menu shows, beside each button, what PlayStation and Nintendo Switch controllers call it ("Gamepad A" is ✕ and B). Triggers are stored by the button's place, as before. | | 2026-10-31 | minor | M11, gap 6: widgets draw lines and SVG pictures, as a Figma widget's `Line` and `SVG` do (plugin API 6; older plugins unchanged). A line is 100 long, black and 1 thick unless told otherwise, and turns around its top-left corner. An SVG comes in as locked layers, scaled to fit a size, within the widget's 256 layers. The Counter example uses both. Numbers unchanged. Tests: `widget_shapes_tests`, `widget_tests`. | | 2026-10-31 | patch | M12, #1708: a connector end dropped on the outer half of a shape takes the side it is towards, instead of only within 10 px of a side's middle, so a drop anywhere along an edge chooses that edge (dragging a selected connector's end and with the connector tool, ring included); a drop in the middle of a shape still takes the facing side. | | 2026-10-31 | minor | M8, #1351 and #1124: both reports checked by running them and already fixed on main, so only tests are added: a rounded rectangle or frame strokes only the side asked for, in every alignment (`a_rounded_shape_strokes_only_the_side_asked_for`); a font family and a layer name written as an id drop characters XML forbids (`ids_and_font_families_drop_xml_forbidden_control_characters`). No behaviour changes. Numbers unchanged | diff --git a/crates/doc/src/proto.rs b/crates/doc/src/proto.rs index 4129dc121..7f3240b38 100644 --- a/crates/doc/src/proto.rs +++ b/crates/doc/src/proto.rs @@ -129,6 +129,29 @@ pub const GAMEPAD_BUTTONS: [&str; 17] = [ "Gamepad Home", ]; +/// What PlayStation and Nintendo Switch controllers call each of [`GAMEPAD_BUTTONS`], in the same +/// order: the same button by its place on the controller, so a Switch's bottom face button is its +/// B though it does what "Gamepad A" does. A trigger is stored by place and works on any of them. +pub const GAMEPAD_NAMES: [(&str, &str); 17] = [ + ("✕", "B"), + ("○", "A"), + ("□", "Y"), + ("△", "X"), + ("L1", "L"), + ("R1", "R"), + ("L2", "ZL"), + ("R2", "ZR"), + ("Share", "−"), + ("Options", "+"), + ("L3", "LS"), + ("R3", "RS"), + ("Up", "Up"), + ("Down", "Down"), + ("Left", "Left"), + ("Right", "Right"), + ("PS", "Home"), +]; + /// The button at standard-layout index `index`, as a key trigger names it. pub fn gamepad_button(index: usize) -> Option<&'static str> { GAMEPAD_BUTTONS.get(index).copied() diff --git a/crates/ui-egui/src/panels/proto_panel.rs b/crates/ui-egui/src/panels/proto_panel.rs index 3fe04dfe7..2bc635244 100644 --- a/crates/ui-egui/src/panels/proto_panel.rs +++ b/crates/ui-egui/src/panels/proto_panel.rs @@ -5,8 +5,8 @@ use egui::{Align2, Color32, CornerRadius, Id, Pos2, Rect, Sense, Shape, Stroke, StrokeKind, Ui, pos2, vec2}; use figcraft_color::Paint; use figcraft_doc::proto::{ - Action, ConditionalBlock, Direction, Easing, GAMEPAD_BUTTONS, Interaction, KeyMods, Overflow, OverlayAnchor, OverlayPosition, ScrollPosition, - Transition, Trigger, is_gamepad_button, + Action, ConditionalBlock, Direction, Easing, GAMEPAD_BUTTONS, GAMEPAD_NAMES, Interaction, KeyMods, Overflow, OverlayAnchor, OverlayPosition, + ScrollPosition, Transition, Trigger, is_gamepad_button, }; use figcraft_doc::{Document, Node, NodeId, NodeKind, VariableValue, proto}; use serde_json::{Value, json}; @@ -822,9 +822,14 @@ fn trigger_rows(app: &mut FigApp, ui: &mut Ui, node: NodeId, k: usize, it: &Inte let pads = widgets::dropdown(ui, tr("Controller"), PAD_MENU_W, None); menu_popup(&pads).show(|ui| { egui::ScrollArea::vertical().max_height(320.0).show(ui, |ui| { - for b in GAMEPAD_BUTTONS { + for (b, (playstation, switch)) in GAMEPAD_BUTTONS.into_iter().zip(GAMEPAD_NAMES) { let mark = if key.eq_ignore_ascii_case(b) { format!("✓ {b}") } else { b.to_string() }; - if menu_item(ui, &mark, "") { + // The same button as other controllers call it (not a shortcut: `+` is a name). + let names = |p: &egui::Painter, row: Rect, _: Color32| { + let at = pos2(row.max.x - PAD_NAMES_INSET, row.center().y); + p.text(at, Align2::RIGHT_CENTER, format!("{playstation} · {switch}"), regular(11.0), t.text_secondary); + }; + if crate::right::menu_item_with(ui, &mark, "", PAD_NAMES_W, names) { ui.ctx().data_mut(|d| d.remove::(capture_id())); patch(app, node, k, json!({"trigger": {"type": "keyPress", "key": b}})); ui.close(); @@ -840,6 +845,9 @@ fn trigger_rows(app: &mut FigApp, ui: &mut Ui, node: NodeId, k: usize, it: &Inte /// The width of the controller button menu beside a key trigger's key. const PAD_MENU_W: f32 = 92.0; +/// The room in a row of that menu for what other controllers call the button, and its inset. +const PAD_NAMES_W: f32 = 76.0; +const PAD_NAMES_INSET: f32 = 12.0; /// A destination picker for the action at `slot`: `make` builds the action for the destination. struct DestinationPicker<'a, Make> { diff --git a/crates/ui-egui/src/present/tail_tests.rs b/crates/ui-egui/src/present/tail_tests.rs index b3abd257b..62cc79dd6 100644 --- a/crates/ui-egui/src/present/tail_tests.rs +++ b/crates/ui-egui/src/present/tail_tests.rs @@ -251,6 +251,11 @@ fn the_interaction_editor_picks_a_controller_button() { }; // The key field keeps recording keys; the menu beside it names the controller's buttons. at(&mut u, "Controller"); + // Each with what PlayStation and Switch controllers call the same button. + let shown = crate::live_tests::flat(&u.frame_output(vec![]).shapes); + for names in ["✕ · B", "□ · Y", "L2 · ZL", "Options · +", "PS · Home"] { + assert!(crate::live_tests::has_text(&shown, names), "{names}"); + } at(&mut u, "Gamepad Menu"); let trigger = u.app.session.doc().unwrap().node(layer).unwrap().interactions[0].trigger.clone(); assert_eq!(trigger, figcraft_doc::proto::Trigger::KeyPress { key: "Gamepad Menu".into() }); diff --git a/docs/gaps.md b/docs/gaps.md index 11c0a9b01..914ff240c 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-31 · **Change:** minor (gap 6: widgets draw lines and SVG pictures (plugin API 6); gap 7: text reads only the fallback fonts it draws with; a board connector's path can be reshaped by dragging its path handles (#1710); gap 10: library analytics count insertions and show insertions and detachments over time; gap 6: a widget opens its plugin's panel (plugin API 5), whose callbacks redraw it; the keyboard's Menu key opens the context menu, as Shift+F10 does; gap 14: a collection of this file extends, following its edits at once; colour picker review fixes for text undo, fitted sidebars, moving controls and gradient variables; gap 6: plugin widgets have hover styles; gap 7: the browser draws a zoom's full render a band at a time; `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); Arabic draft catalog; gap 7: the browser refines a zoom's preview once the zoom has paused; gap 17: branches in live sessions, branch history entries, the list-merge rows brought up to date; gap 6: plugin widgets take text input and a property menu (plugin API 4); gap 24: board templates and tables; gap 24: a shared timer and voting sessions; gap 24: the board dot grid, connector ends dragged to re-attach, curved connectors; egui/eframe 0.37 verified for gap 18; known gaps from the #1311 review in gaps 4, 14, 15 and 16; gap 16: measurements kept on the canvas, a read-only layers panel in Dev Mode; gap 24: boards and their tail; Dev Mode tail: import lines in layer trees, compare with versions, an inspect-only canvas; gap 5: reviewed Turkish, Czech and Indonesian drafts, the Japanese draft completed; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; 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; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; 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: gap 14 down to its tail; the 2026-10-17 landings: `.fig` import fidelity, budgets for large files, the import report, a `.fig` copy from Save as and paste from another design app (gap 1); menus, the Actions palette, editor fixes, Selection colors and smart-selection spacing (gap 4); catalog counts re-measured (gap 5); bounded canvas pan overscan) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** minor (the Controller menu names each button as PlayStation and Switch controllers do; gap 6: widgets draw lines and SVG pictures (plugin API 6); gap 7: text reads only the fallback fonts it draws with; a board connector's path can be reshaped by dragging its path handles (#1710); gap 10: library analytics count insertions and show insertions and detachments over time; gap 6: a widget opens its plugin's panel (plugin API 5), whose callbacks redraw it; the keyboard's Menu key opens the context menu, as Shift+F10 does; gap 14: a collection of this file extends, following its edits at once; colour picker review fixes for text undo, fitted sidebars, moving controls and gradient variables; gap 6: plugin widgets have hover styles; gap 7: the browser draws a zoom's full render a band at a time; `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); Arabic draft catalog; gap 7: the browser refines a zoom's preview once the zoom has paused; gap 17: branches in live sessions, branch history entries, the list-merge rows brought up to date; gap 6: plugin widgets take text input and a property menu (plugin API 4); gap 24: board templates and tables; gap 24: a shared timer and voting sessions; gap 24: the board dot grid, connector ends dragged to re-attach, curved connectors; egui/eframe 0.37 verified for gap 18; known gaps from the #1311 review in gaps 4, 14, 15 and 16; gap 16: measurements kept on the canvas, a read-only layers panel in Dev Mode; gap 24: boards and their tail; Dev Mode tail: import lines in layer trees, compare with versions, an inspect-only canvas; gap 5: reviewed Turkish, Czech and Indonesian drafts, the Japanese draft completed; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; 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; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; 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: gap 14 down to its tail; the 2026-10-17 landings: `.fig` import fidelity, budgets for large files, the import report, a `.fig` copy from Save as and paste from another design app (gap 1); menus, the Actions palette, editor fixes, Selection colors and smart-selection spacing (gap 4); catalog counts re-measured (gap 5); bounded canvas pan overscan) · **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 @@ -430,7 +430,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe - **Missing:** video triggers (on media hit/end) and play/pause/mute/seek actions (the REST importer warns and skips them, `crates/format/src/rest/proto.rs`); controllers on macOS, Linux and FreeBSD (the usual Rust controller library links libudev on Linux; a pure-Rust reader per - platform, or an owner decision on that dependency); PlayStation and Switch button labels. + platform, or an owner decision on that dependency). - **Estimate:** 2–5 h, the media part after video fills (gap 12). ### 16. Dev Mode: the tail after dev resources @@ -588,6 +588,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe | Date | Change | Summary | |---|---|---| +| 2026-10-31 | minor | Gap 15: the Controller menu names buttons as PlayStation and Switch controllers do; that item leaves the missing list. Estimate unchanged. Test: `present::tail_tests`. | | 2026-10-30 | minor | Gap 7: text reads only the fallback fonts it draws with: a face per fallback family and style, a file's bytes shared between families, the faces worked out once per style (first text laid out: 41 font files and 265 MiB read down to 3 files and 20 MiB on a Linux machine with Noto Sans CJK). Numbers unchanged | | 2026-10-31 | minor | Gap 6: widgets draw lines and SVG pictures (plugin API 6), so that item leaves the missing list. | | 2026-10-31 | minor | Gap 24: connector paths can be reshaped by hand (path handles and `board.reshape`), so that item leaves the missing list. Estimate unchanged. Tests: `board_reshape_tests`, `board_line_tests`. | diff --git a/docs/prototyping-parity.md b/docs/prototyping-parity.md index abff41c9b..345d08753 100644 --- a/docs/prototyping-parity.md +++ b/docs/prototyping-parity.md @@ -1,6 +1,6 @@ # Prototyping parity -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-30 · **Change:** minor ("Open in new tab" on Open link: off, the web build's own tab goes to the link; a manual overlay's position can be dragged on the canvas (#405); Open link notices now cover mail links, and skipping them covers only links the viewer fired (#1096); overlay positions relative to the layer that opens them; the Triggers row no longer lists delays on mouse triggers as missing; top-level frames bigger than the device's screen scroll in the player; Change to, set variable and set variable mode lay out what they change in the player, as the canvas does; presentation tabs, one per page of a file; a Present tab with device and scale controls (#537); core editor leftovers: delays on mouse triggers and manual overlay offsets; controller buttons on key/gamepad triggers, widgets that react in prototypes, `proto.evaluate` and the mode lookup rules pinned) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-31 · **Change:** minor (the Controller menu names each button as PlayStation and Switch controllers do; "Open in new tab" on Open link: off, the web build's own tab goes to the link; a manual overlay's position can be dragged on the canvas (#405); Open link notices now cover mail links, and skipping them covers only links the viewer fired (#1096); overlay positions relative to the layer that opens them; the Triggers row no longer lists delays on mouse triggers as missing; top-level frames bigger than the device's screen scroll in the player; Change to, set variable and set variable mode lay out what they change in the player, as the canvas does; presentation tabs, one per page of a file; a Present tab with device and scale controls (#537); core editor leftovers: delays on mouse triggers and manual overlay offsets; controller buttons on key/gamepad triggers, widgets that react in prototypes, `proto.evaluate` and the mode lookup rules pinned) · **Target:** Figma Design (desktop app 126.10.7) **Breadth ~92%, ready ~74% (estimated).** Every Figma trigger except media, every action except media control, every transition and easing exist; key/gamepad triggers take controller buttons, @@ -86,7 +86,7 @@ tool; opt-in), **silent** (the documentation doesn't say; the case pins the curr | Overlays: position, close on click outside, background, animate out, stacking, timers from when they open | done | | | Scroll behaviour: overflow directions, fixed (drawn on top), sticky, preserve scroll position, scroll to with an offset, momentum and overscroll | done | | | Key combinations (`Shift+K`), recorded in the editor and imported from REST files | done | | -| Controller buttons on key/gamepad triggers (Controller menu, REST import, `ui.input {gamepad}` on the control channel) | done | read from controllers on Windows (Windows.Gaming.Input) and in the browser (Gamepad API); macOS, Linux and FreeBSD don't read controllers yet; button names follow one layout (no PlayStation or Switch labels) | +| Controller buttons on key/gamepad triggers (Controller menu, naming each button as PlayStation and Switch controllers do as well, REST import, `ui.input {gamepad}` on the control channel) | done | read from controllers on Windows (Windows.Gaming.Input) and in the browser (Gamepad API); macOS, Linux and FreeBSD don't read controllers yet; button names follow one layout (no PlayStation or Switch labels) | | Widgets react to clicks in a presented prototype | done | clicks only, like on the canvas | | Interactive components: instances play their variant's interactions, overridden per trigger | done | | | Drag-driven animations that follow the pointer, in their direction | done | | @@ -105,6 +105,7 @@ tool; opt-in), **silent** (the documentation doesn't say; the case pins the curr | Date | Change | Summary | |---|---|---| +| 2026-10-31 | minor | Gap 15: the Controller menu names buttons as PlayStation and Switch controllers do; that item leaves the missing list. Estimate unchanged. Test: `present::tail_tests`. | | 2026-10-30 | minor | Opening a link in place: Open link has "Open in new tab", on by default; off, the web build's own tab goes to the link after the notice (or at once with it skipped); the desktop app has no tab of its own and opens the browser either way. Saved in files only when off (`openInNewTab`) and read from REST files. | | 2026-10-30 | patch | A manual overlay can be placed on the canvas (#405): while the interaction editor has an Open overlay action with Manual position open, the overlay frame shows dimmed, outlined, where it opens from the top-left of the layer that opens it (right below that layer without an offset), and dragging it moves it there. The X and Y fields follow while dragging, and the drag is one undo step (`crates/ui-egui/src/canvas/proto_canvas/overlay_ghost.rs`). Numbers unchanged | | 2026-10-29 | patch | The Open link notice covers mail links, and skipping it covers only links the viewer fired: before, a mail link opened the mail app at once, also from an "After delay" trigger, and while the notice was skipped any trigger opened any number of web links. Now every openable link waits behind the notice; with it skipped, a link the viewer's own click, press, drag or key fired opens at once, one per frame, while a delay, the pointer passing over a layer or a timed mouse trigger still shows the notice, which then offers no "Don't show this again". Nothing opens while a notice shows, and a press under way when it appears ends there: before, its release could still open a link or navigate behind the notice. The mail notice says the link opens the mail app to write a message (#1096, `crates/ui-egui/src/present_links_tests.rs`). Numbers unchanged | diff --git a/docs/roadmap.md b/docs/roadmap.md index cef6f6814..12b650046 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-30 · **Change:** minor (widgets draw lines and SVG pictures (plugin API 6); a connector end dropped along a side of a shape attaches to that side (#1708); regression tests pin a rounded shape's one-sided stroke and SVG ids and font families without XML-forbidden characters (#1351, #1124); a damaged image is refused with a reason instead of showing as an empty box (#1474); SwiftUI and Compose code names a bound variable by its iOS or Android code syntax; the colour picker's Image tab shows a placeholder and Upload from computer, an asset preview, and a file dropped on it replaces the image, keeping the fill mode, crop and position (#1682); the Frame tool's Phone presets have the iPhone 17 family (#1691); Show in folder works in the Flatpak build and selects the file on Linux (#1701); a dashed line starts and ends with a half-length dash, on the canvas and in exports; the Pages, Layers and Assets search fields keep their text in the middle of the field (#1703); a board connector's path can be reshaped by dragging its path handles (#1710); boards have 16 more shapes (arrows, polygons, star, plus and flowchart symbols), in pickers that lay them out in a grid; library analytics count insertions and show insertions and detachments over the past 30 days, 90 days or year; a stroke's Custom style takes a dash pattern of any length as a comma-separated list (#1681); a control request can set its own timeout, up to 10 minutes (`timeoutMs`; `figcraft-cli mcp --attach PORT --timeout SECONDS`) (#1696); gap 6: a widget opens its plugin's panel (plugin API 5), whose callbacks redraw it; the keyboard's Menu key opens the context menu, as Shift+F10 does; an Open link action can open in place of the prototype, with "Open in new tab" off (gap 15); a collection of this file can be extended (Extend collection on its row, `variable.extendCollection`), its extension following its edits at once, and extended collections extend again in the file; the eyedropper with Cmd/Ctrl+Shift makes a colour style or variable of what it picks; a live session's welcome sends the document compressed to clients that read it, about seven times smaller (#1665); a control request that times out says truthfully whether it was dropped or is still running and will take effect, and only screenshots and input blame the window (#1673); a board keeps earlier votes' results, listed newest first in Results and deleted one at a time; a server can list the files shared from it now for everyone who reaches it, Home ▸ Online shows them and their links carry no secret (`team_files`, part of #742); a selected connector's bar on a board sets its start and end points, solid or dashed, its weight and any colour (#1596); cells copied from a spreadsheet paste onto a board as a table; a long layer name stays on one line in the Export button and the Dev Mode header, ending with an ellipsis, and no longer covers the Design panel (#1650); with hundreds of layers selected, the Design panel and the selection overlays stop going over the whole selection on every frame, so dragging 648 layers averages about 1.1 ms of UI per frame (#1571); plugin panels show pictures: an `image` control, base64 within 256 KB and 4,096 px a side, named by its label (gap 6); a drag moving many layers re-runs only what sits around them, not each layer's bindings, sizing, text and instance checks, on every move (#1571); damaged font tracking table offsets are checked instead of wrapping on 32-bit targets, and font indexing makes its existing offset bounds explicit (#1125); pasted layers whose image the clipboard lacks are named and show a placeholder that reads as missing (#1646); paint-style colours on layers pasted from another design app keep their style instead of turning black (#1638); Shift+Cmd/Ctrl+Enter on a selected board shape adds the next shape already joined to it, and goes on typing (#1597); a name typed for a variable or collection finds the one with that exact id before one named like it, and stored mode ids, font family variables and the panels' labels read exact ids (#1613); plugin widgets have hover styles: a layer's `hover` fill, stroke, opacity or text colour shows while the pointer is over it (gap 6); every file a command reads whole by path is a regular file within a cap that fits it: documents 16 GiB, placed images 256 MiB, CLI scripts 1 GiB; compact layer trees with fields and outline (M0.9, part of #996); while a drag only moves a large selection, its outlines, box, slots, spacing and Design panel values are worked out once and shifted, not rebuilt on every move (#1571); an imported or pasted file finds an installed family under the other names its fonts give it, and with other case and spacing, instead of reporting it missing (#1292); a panic in a grouped closure outside any command rolls the group back like a failed group: documents, selection, undo and redo, history limit and journal as before (#1085); a development example generates figcraft-cli's shell completions and man pages from its clap definitions (part of #1396); `.fig` ZIP image reads share a total byte budget and count skipped images in the import warnings (#1076); stored variable and collection references keep their identity when another object is named like their id (follow-up to #1542); a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; the server key is checked by comparing SHA-256 hashes of the presented and the kept key, as the deployment guide says (#1130); opening a file contacts no team library server it names until you check it (#1097); a hostile server can no longer grow a client's memory with made-up people, and a session remembers a bounded number of deleted layer ids (#1121); `doc.list` gives AI agents and plugins the open files' names, not their paths (#1117); with a large selection, frames where nothing changed reuse the selection's outlines, box and slots and the Design panel's instance and position values instead of working them out again (#1571); team libraries and plugins larger than the 16 MiB download burst can be downloaded again, more slowly (#1089); a batch keeps one undo snapshot, not one per step (#1099); files nested 256 levels deep import on the browser's 1 MB stack (#1101); SVG and PDF exports count pattern sources and the renders they embed from the layer's depth, so one cap bounds a whole export (#1082); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); joining and leaving a file nobody is editing reads and writes nothing on the server: it stays loaded a minute after everyone leaves (#1074); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); hovering the canvas hit-tests once per pointer move, not on every frame, and moving an auto layout frame no longer lays out its contents again on every move (#1571); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); `comment.people` lists names that were only @mentioned, and `comment.next` and `comment.prompt` carry the mentions too (#1415); grid auto-placement looks for free cells a 64-bit word at a time and skips what blocks it, with the same placements (#1083); with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); a recovery copy made while a version is previewed holds the file, not the version shown; a FigJam `.jam` file opens as a board (#1342); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); share links can comment: a third access level beside edit and view, whose holder sees the file live and adds, replies to, reacts to and resolves comments but can't change the design; Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); `comment.list {mentions}` lists only the threads that @mention a name (#1415); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); figcraft-cli parses its arguments with clap, following RFC-0012 (#1396); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); Thai, Lao, Khmer and Burmese lines break between words (#1219); an AI agent attached over MCP shows where it works (#1416); a manual overlay's position can be dragged on the canvas (#405); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); SVG import draws each pattern tile once per scale, within the layer budget (#1080); Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); M8: mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); M2: components can't contain their own instances, directly or through others; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); 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) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (the Controller menu names each button as PlayStation and Switch controllers do; widgets draw lines and SVG pictures (plugin API 6); a connector end dropped along a side of a shape attaches to that side (#1708); regression tests pin a rounded shape's one-sided stroke and SVG ids and font families without XML-forbidden characters (#1351, #1124); a damaged image is refused with a reason instead of showing as an empty box (#1474); SwiftUI and Compose code names a bound variable by its iOS or Android code syntax; the colour picker's Image tab shows a placeholder and Upload from computer, an asset preview, and a file dropped on it replaces the image, keeping the fill mode, crop and position (#1682); the Frame tool's Phone presets have the iPhone 17 family (#1691); Show in folder works in the Flatpak build and selects the file on Linux (#1701); a dashed line starts and ends with a half-length dash, on the canvas and in exports; the Pages, Layers and Assets search fields keep their text in the middle of the field (#1703); a board connector's path can be reshaped by dragging its path handles (#1710); boards have 16 more shapes (arrows, polygons, star, plus and flowchart symbols), in pickers that lay them out in a grid; library analytics count insertions and show insertions and detachments over the past 30 days, 90 days or year; a stroke's Custom style takes a dash pattern of any length as a comma-separated list (#1681); a control request can set its own timeout, up to 10 minutes (`timeoutMs`; `figcraft-cli mcp --attach PORT --timeout SECONDS`) (#1696); gap 6: a widget opens its plugin's panel (plugin API 5), whose callbacks redraw it; the keyboard's Menu key opens the context menu, as Shift+F10 does; an Open link action can open in place of the prototype, with "Open in new tab" off (gap 15); a collection of this file can be extended (Extend collection on its row, `variable.extendCollection`), its extension following its edits at once, and extended collections extend again in the file; the eyedropper with Cmd/Ctrl+Shift makes a colour style or variable of what it picks; a live session's welcome sends the document compressed to clients that read it, about seven times smaller (#1665); a control request that times out says truthfully whether it was dropped or is still running and will take effect, and only screenshots and input blame the window (#1673); a board keeps earlier votes' results, listed newest first in Results and deleted one at a time; a server can list the files shared from it now for everyone who reaches it, Home ▸ Online shows them and their links carry no secret (`team_files`, part of #742); a selected connector's bar on a board sets its start and end points, solid or dashed, its weight and any colour (#1596); cells copied from a spreadsheet paste onto a board as a table; a long layer name stays on one line in the Export button and the Dev Mode header, ending with an ellipsis, and no longer covers the Design panel (#1650); with hundreds of layers selected, the Design panel and the selection overlays stop going over the whole selection on every frame, so dragging 648 layers averages about 1.1 ms of UI per frame (#1571); plugin panels show pictures: an `image` control, base64 within 256 KB and 4,096 px a side, named by its label (gap 6); a drag moving many layers re-runs only what sits around them, not each layer's bindings, sizing, text and instance checks, on every move (#1571); damaged font tracking table offsets are checked instead of wrapping on 32-bit targets, and font indexing makes its existing offset bounds explicit (#1125); pasted layers whose image the clipboard lacks are named and show a placeholder that reads as missing (#1646); paint-style colours on layers pasted from another design app keep their style instead of turning black (#1638); Shift+Cmd/Ctrl+Enter on a selected board shape adds the next shape already joined to it, and goes on typing (#1597); a name typed for a variable or collection finds the one with that exact id before one named like it, and stored mode ids, font family variables and the panels' labels read exact ids (#1613); plugin widgets have hover styles: a layer's `hover` fill, stroke, opacity or text colour shows while the pointer is over it (gap 6); every file a command reads whole by path is a regular file within a cap that fits it: documents 16 GiB, placed images 256 MiB, CLI scripts 1 GiB; compact layer trees with fields and outline (M0.9, part of #996); while a drag only moves a large selection, its outlines, box, slots, spacing and Design panel values are worked out once and shifted, not rebuilt on every move (#1571); an imported or pasted file finds an installed family under the other names its fonts give it, and with other case and spacing, instead of reporting it missing (#1292); a panic in a grouped closure outside any command rolls the group back like a failed group: documents, selection, undo and redo, history limit and journal as before (#1085); a development example generates figcraft-cli's shell completions and man pages from its clap definitions (part of #1396); `.fig` ZIP image reads share a total byte budget and count skipped images in the import warnings (#1076); stored variable and collection references keep their identity when another object is named like their id (follow-up to #1542); a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; the server key is checked by comparing SHA-256 hashes of the presented and the kept key, as the deployment guide says (#1130); opening a file contacts no team library server it names until you check it (#1097); a hostile server can no longer grow a client's memory with made-up people, and a session remembers a bounded number of deleted layer ids (#1121); `doc.list` gives AI agents and plugins the open files' names, not their paths (#1117); with a large selection, frames where nothing changed reuse the selection's outlines, box and slots and the Design panel's instance and position values instead of working them out again (#1571); team libraries and plugins larger than the 16 MiB download burst can be downloaded again, more slowly (#1089); a batch keeps one undo snapshot, not one per step (#1099); files nested 256 levels deep import on the browser's 1 MB stack (#1101); SVG and PDF exports count pattern sources and the renders they embed from the layer's depth, so one cap bounds a whole export (#1082); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); joining and leaving a file nobody is editing reads and writes nothing on the server: it stays loaded a minute after everyone leaves (#1074); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); hovering the canvas hit-tests once per pointer move, not on every frame, and moving an auto layout frame no longer lays out its contents again on every move (#1571); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); `comment.people` lists names that were only @mentioned, and `comment.next` and `comment.prompt` carry the mentions too (#1415); grid auto-placement looks for free cells a 64-bit word at a time and skips what blocks it, with the same placements (#1083); with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); a recovery copy made while a version is previewed holds the file, not the version shown; a FigJam `.jam` file opens as a board (#1342); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); share links can comment: a third access level beside edit and view, whose holder sees the file live and adds, replies to, reacts to and resolves comments but can't change the design; Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); `comment.list {mentions}` lists only the threads that @mention a name (#1415); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); figcraft-cli parses its arguments with clap, following RFC-0012 (#1396); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); Thai, Lao, Khmer and Burmese lines break between words (#1219); an AI agent attached over MCP shows where it works (#1416); a manual overlay's position can be dragged on the canvas (#405); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); SVG import draws each pattern tile once per scale, within the layer budget (#1080); Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); M8: mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); M2: components can't contain their own instances, directly or through others; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); 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-31 | minor | M5: a key/gamepad trigger's Controller menu shows, beside each button, what PlayStation and Nintendo Switch controllers call it ("Gamepad A" is ✕ and B). Triggers are stored by the button's place, as before. | | 2026-10-31 | minor | M11, gap 6: widgets draw lines and SVG pictures, as a Figma widget's `Line` and `SVG` do (plugin API 6; older plugins unchanged). A line is 100 long, black and 1 thick unless told otherwise, and turns around its top-left corner. An SVG comes in as locked layers, scaled to fit a size, within the widget's 256 layers. The Counter example uses both. Numbers unchanged. Tests: `widget_shapes_tests`, `widget_tests`. | | 2026-10-31 | patch | M12, #1708: a connector end dropped on the outer half of a shape takes the side it is towards, instead of only within 10 px of a side's middle, so a drop anywhere along an edge chooses that edge (dragging a selected connector's end and with the connector tool, ring included); a drop in the middle of a shape still takes the facing side. | | 2026-10-31 | minor | M8, #1351 and #1124: both reports checked by running them and already fixed on main, so only tests are added: a rounded rectangle or frame strokes only the side asked for, in every alignment (`a_rounded_shape_strokes_only_the_side_asked_for`); a font family and a layer name written as an id drop characters XML forbids (`ids_and_font_families_drop_xml_forbidden_control_characters`). No behaviour changes. Numbers unchanged | From 9ae43a4c4ee3636ad5bcf818da448ea70c6bde06 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sat, 31 Oct 2026 03:02:39 +0100 Subject: [PATCH 03/15] Reject empty arguments in parse_css color functions Found while testing crates/color/src/lib.rs:247 (css_args, used by the rgb/rgba and hsl/hsla arms of parse_css at :159 and :178) Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/color/src/lib.rs | 20 +++++++++++++++----- crates/color/src/tests.rs | 9 +++++++++ 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/crates/color/src/lib.rs b/crates/color/src/lib.rs index c2c0d06ed..190fdbb9c 100644 --- a/crates/color/src/lib.rs +++ b/crates/color/src/lib.rs @@ -157,7 +157,7 @@ impl Color { return Self::from_hex(&t); } if let Some(inner) = t.strip_prefix("rgba(").or_else(|| t.strip_prefix("rgb(")).and_then(|r| r.strip_suffix(')')) { - let parts = css_args(inner); + let parts = css_args(inner)?; let num = |i: usize| -> Option { let p = parts.get(i)?; if let Some(pc) = p.strip_suffix('%') { @@ -176,7 +176,7 @@ impl Color { return Some(Self::new(num(0)?, num(1)?, num(2)?, alpha)); } if let Some(inner) = t.strip_prefix("hsla(").or_else(|| t.strip_prefix("hsl(")).and_then(|r| r.strip_suffix(')')) { - let parts = css_args(inner); + let parts = css_args(inner)?; let h = parts.first()?.trim_end_matches("deg").parse::().ok()?; let s = parts.get(1)?.trim_end_matches('%').parse::().ok()? / 100.0; let l = parts.get(2)?.trim_end_matches('%').parse::().ok()? / 100.0; @@ -244,14 +244,24 @@ impl Color { } } -fn css_args(inner: &str) -> Vec { - inner.replace('/', ",").split([',', ' ']).map(str::trim).filter(|p| !p.is_empty()).map(str::to_string).collect() +/// Split CSS function arguments on `,` and `/` (each may be surrounded by spaces) and on runs of +/// whitespace. An empty argument between two separators makes the whole list invalid. +fn css_args(inner: &str) -> Option> { + let mut out = Vec::new(); + for seg in inner.replace('/', ",").split(',') { + let before = out.len(); + out.extend(seg.split_whitespace().map(str::to_string)); + if out.len() == before { + return None; + } + } + Some(out) } /// The arguments of `lch()` / `oklch()`: lightness, chroma, hue and alpha. Percentages are of /// `l_full` / `c_full`; `none` is 0. fn css_polar(inner: &str, l_full: f32, c_full: f32) -> Option<([f32; 3], f32)> { - let parts = css_args(inner); + let parts = css_args(inner)?; if !(3..=4).contains(&parts.len()) { return None; } diff --git a/crates/color/src/tests.rs b/crates/color/src/tests.rs index 03e4d1675..a41d0dd2e 100644 --- a/crates/color/src/tests.rs +++ b/crates/color/src/tests.rs @@ -148,3 +148,12 @@ fn css_hsl_rejects_non_finite() { assert_eq!(Color::parse_css(s), None, "{s}"); } } + +#[test] +fn css_rejects_empty_arguments() { + for s in ["rgb(255,,0,0)", "rgb(,1,2,3)", "rgb(1,2,3,)", "hsl(10,,50%,50%)", "rgb(1,2,,)", "oklch(0.5,,0.1,20)"] { + assert_eq!(Color::parse_css(s), None, "{s}"); + } + assert!(Color::parse_css("rgb(255 0 0 / 50%)").is_some()); + assert!(Color::parse_css("hsl(120deg 50% 50%)").is_some()); +} From 87254913afe7e6bb2752ecf23dd653be19eee1b7 Mon Sep 17 00:00:00 2001 From: BFlat Date: Sat, 31 Oct 2026 09:13:08 +0700 Subject: [PATCH 04/15] M3: library analytics list how each style and variable is used (#1730) * M3: library analytics list how each style and variable is used A file reports how many of its layers use each of a team library's styles and variables, and Show usage lists them by a Type choice with their layers and files. Layers in instances count for their component. Also ignores plugins/target. * M3: the style analytics test waits for the instance report too --- .gitignore | 1 + ROADMAP.md | 3 +- apps/figcraft-server/src/lib.rs | 2 + .../src/library_assets_tests.rs | 145 ++++++++++++++++++ apps/figcraft-server/src/store.rs | 10 +- apps/figcraft-server/src/store/backend.rs | 2 + apps/figcraft-server/src/store/library.rs | 54 +++++-- crates/collab-net/src/link.rs | 2 +- crates/collab/src/message.rs | 18 +++ crates/engine/src/assets/library_server.rs | 47 +++++- crates/engine/src/cmd/library.rs | 39 ++++- crates/engine/src/sharing/background/tests.rs | 3 +- crates/ui-egui/locales/en.toml | 2 + crates/ui-egui/src/panels/assets_panel.rs | 61 +++++++- docs/collab-protocol.md | 10 +- docs/components-parity.md | 5 +- docs/gaps.md | 12 +- docs/mcp.md | 2 +- docs/roadmap.md | 3 +- docs/server.md | 9 +- 20 files changed, 389 insertions(+), 41 deletions(-) create mode 100644 apps/figcraft-server/src/library_assets_tests.rs diff --git a/.gitignore b/.gitignore index a22202885..8fb22ce0e 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ plan/ plans/ /target/ /target-*/ +/plugins/target/ /dist/ /corpus/ /log/ diff --git a/ROADMAP.md b/ROADMAP.md index de5ceba97..36fa0496f 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-30 · **Change:** minor (the Controller menu names each button as PlayStation and Switch controllers do; widgets draw lines and SVG pictures (plugin API 6); a connector end dropped along a side of a shape attaches to that side (#1708); regression tests pin a rounded shape's one-sided stroke and SVG ids and font families without XML-forbidden characters (#1351, #1124); a damaged image is refused with a reason instead of showing as an empty box (#1474); an image copied in another app pastes again on the desktop, and in the browser for the first time (#492); SwiftUI and Compose code names a bound variable by its iOS or Android code syntax; the colour picker's Image tab shows a placeholder and Upload from computer, an asset preview, and a file dropped on it replaces the image, keeping the fill mode, crop and position (#1682); the Frame tool's Phone presets have the iPhone 17 family (#1691); Show in folder works in the Flatpak build and selects the file on Linux (#1701); a dashed line starts and ends with a half-length dash, on the canvas and in exports; the Pages, Layers and Assets search fields keep their text in the middle of the field (#1703); a board connector's path can be reshaped by dragging its path handles (#1710); boards have 16 more shapes (arrows, polygons, star, plus and flowchart symbols), in pickers that lay them out in a grid; library analytics count insertions and show insertions and detachments over the past 30 days, 90 days or year; a stroke's Custom style takes a dash pattern of any length as a comma-separated list (#1681); a control request can set its own timeout, up to 10 minutes (`timeoutMs`; `figcraft-cli mcp --attach PORT --timeout SECONDS`) (#1696); gap 6: a widget opens its plugin's panel (plugin API 5), whose callbacks redraw it; the keyboard's Menu key opens the context menu, as Shift+F10 does; an Open link action can open in place of the prototype, with "Open in new tab" off (gap 15); a collection of this file can be extended (Extend collection on its row, `variable.extendCollection`), its extension following its edits at once, and extended collections extend again in the file; the eyedropper with Cmd/Ctrl+Shift makes a colour style or variable of what it picks; a live session's welcome sends the document compressed to clients that read it, about seven times smaller (#1665); a control request that times out says truthfully whether it was dropped or is still running and will take effect, and only screenshots and input blame the window (#1673); a board keeps earlier votes' results, listed newest first in Results and deleted one at a time; the object a stamp will stick to is outlined while the stamp tool is over it or a stamp is dragged over it, and board objects take no drops (gap 24); a server can list the files shared from it now for everyone who reaches it, Home ▸ Online shows them and their links carry no secret (`team_files`, part of #742); team library analytics count detached instances per component and file (gap 10); skipping the Open link notice by command is saved with the preferences at once (gap 15); a selected connector's bar on a board sets its start and end points, solid or dashed, its weight and any colour (#1596); cells copied from a spreadsheet paste onto a board as a table; a long layer name stays on one line in the Export button and the Dev Mode header, ending with an ellipsis, and no longer covers the Design panel (#1650); with hundreds of layers selected, the Design panel and the selection overlays stop going over the whole selection on every frame, so dragging 648 layers averages about 1.1 ms of UI per frame (#1571); plugin panels show pictures: an `image` control, base64 within 256 KB and 4,096 px a side, named by its label (gap 6); `.fig` connector labels without a saved snapshot are kept, centred on their line (gap 1); a drag moving many layers re-runs only what sits around them, not each layer's bindings, sizing, text and instance checks, on every move (#1571); damaged font tracking table offsets are checked instead of wrapping on 32-bit targets, and font indexing makes its existing offset bounds explicit (#1125); Bosnian (`bs`) draft catalog; text style dedupe is O(spans) via a last-two cache and fingerprint buckets (#1102); pasted layers whose image the clipboard lacks are named and show a placeholder that reads as missing (#1646); paint-style colours on layers pasted from another design app keep their style instead of turning black (#1638); progress docs cleaned of what merges left twice: rows merged into one, repeated status items removed, and `cargo xtask docs` (in `ci`) fails on a repeated table row or status item; a name typed for a variable or collection finds the one with that exact id before one named like it, and stored mode ids, font family variables and the panels' labels read exact ids (#1613); plugin widgets have hover styles: a layer's `hover` fill, stroke, opacity or text colour shows while the pointer is over it (gap 6); `.fig` elbowed and curved connectors without a saved path are routed like board connectors instead of drawn straight (gap 1); every file a command reads whole by path is a regular file within a cap that fits it: documents 16 GiB, placed images 256 MiB, CLI scripts 1 GiB; `figcraft-cli connect publish` publishes a code repository's code connect mappings to a design file (gap 16); while a drag only moves a large selection, its outlines, box, slots, spacing and Design panel values are worked out once and shifted, not rebuilt on every move (#1571); Shift+Cmd/Ctrl+Enter on a selected board shape adds the next shape already joined to it, and goes on typing (#1597); an imported or pasted file finds an installed family under the other names its fonts give it, and with other case and spacing, instead of reporting it missing (#1292); a development example generates figcraft-cli's shell completions and man pages from its clap definitions (part of #1396); `.fig` ZIP image reads share a total byte budget and count skipped images in the import warnings (#1076); German terminology corrections (M10): gradient stops, OpenType stylistic sets and the Home-screen preference; a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; the server key is checked by comparing SHA-256 hashes of the presented and the kept key, as the deployment guide says (#1130); opening a file contacts no team library server it names until you check it (#1097); a hostile server can no longer grow a client's memory with made-up people, and a session remembers a bounded number of deleted layer ids (#1121); `doc.list` gives AI agents and plugins the open files' names, not their paths (#1117); with a large selection, frames where nothing changed reuse the selection's outlines, box and slots and the Design panel's instance and position values instead of working them out again (#1571); team libraries and plugins larger than the 16 MiB download burst can be downloaded again, more slowly (#1089); a batch keeps one undo snapshot, not one per step (#1099); files nested 256 levels deep import on the browser's 1 MB stack (#1101); SVG and PDF exports count pattern sources and the renders they embed from the layer's depth, so one cap bounds a whole export (#1082); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); joining and leaving a file nobody is editing reads and writes nothing on the server: it stays loaded a minute after everyone leaves (#1074); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); hovering the canvas hit-tests once per pointer move, not on every frame, and moving an auto layout frame no longer lays out its contents again on every move (#1571); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); `comment.people` lists names that were only @mentioned, and `comment.next` and `comment.prompt` carry the mentions too (#1415); grid auto-placement looks for free cells a 64-bit word at a time and skips what blocks it, with the same placements (#1083); with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); a recovery copy made while a version is previewed holds the file, not the version shown; a FigJam `.jam` file opens as a board (#1342); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); share links can comment: a third access level beside edit and view, whose holder sees the file live and adds, replies to, reacts to and resolves comments but can't change the design; Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); `comment.list {mentions}` lists only the threads that @mention a name (#1415); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); figcraft-cli parses its arguments with clap, following RFC-0012 (#1396); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); Thai, Lao, Khmer and Burmese lines break between words (#1219); an AI agent attached over MCP shows where it works (#1416); a manual overlay's position can be dragged on the canvas (#405); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); SVG import draws each pattern tile once per scale, within the layer budget (#1080); Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); components can't contain their own instances, directly or through other components; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); 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) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (library analytics list how many layers and files use each style and variable; the Controller menu names each button as PlayStation and Switch controllers do; widgets draw lines and SVG pictures (plugin API 6); a connector end dropped along a side of a shape attaches to that side (#1708); regression tests pin a rounded shape's one-sided stroke and SVG ids and font families without XML-forbidden characters (#1351, #1124); a damaged image is refused with a reason instead of showing as an empty box (#1474); an image copied in another app pastes again on the desktop, and in the browser for the first time (#492); SwiftUI and Compose code names a bound variable by its iOS or Android code syntax; the colour picker's Image tab shows a placeholder and Upload from computer, an asset preview, and a file dropped on it replaces the image, keeping the fill mode, crop and position (#1682); the Frame tool's Phone presets have the iPhone 17 family (#1691); Show in folder works in the Flatpak build and selects the file on Linux (#1701); a dashed line starts and ends with a half-length dash, on the canvas and in exports; the Pages, Layers and Assets search fields keep their text in the middle of the field (#1703); a board connector's path can be reshaped by dragging its path handles (#1710); boards have 16 more shapes (arrows, polygons, star, plus and flowchart symbols), in pickers that lay them out in a grid; library analytics count insertions and show insertions and detachments over the past 30 days, 90 days or year; a stroke's Custom style takes a dash pattern of any length as a comma-separated list (#1681); a control request can set its own timeout, up to 10 minutes (`timeoutMs`; `figcraft-cli mcp --attach PORT --timeout SECONDS`) (#1696); gap 6: a widget opens its plugin's panel (plugin API 5), whose callbacks redraw it; the keyboard's Menu key opens the context menu, as Shift+F10 does; an Open link action can open in place of the prototype, with "Open in new tab" off (gap 15); a collection of this file can be extended (Extend collection on its row, `variable.extendCollection`), its extension following its edits at once, and extended collections extend again in the file; the eyedropper with Cmd/Ctrl+Shift makes a colour style or variable of what it picks; a live session's welcome sends the document compressed to clients that read it, about seven times smaller (#1665); a control request that times out says truthfully whether it was dropped or is still running and will take effect, and only screenshots and input blame the window (#1673); a board keeps earlier votes' results, listed newest first in Results and deleted one at a time; the object a stamp will stick to is outlined while the stamp tool is over it or a stamp is dragged over it, and board objects take no drops (gap 24); a server can list the files shared from it now for everyone who reaches it, Home ▸ Online shows them and their links carry no secret (`team_files`, part of #742); team library analytics count detached instances per component and file (gap 10); skipping the Open link notice by command is saved with the preferences at once (gap 15); a selected connector's bar on a board sets its start and end points, solid or dashed, its weight and any colour (#1596); cells copied from a spreadsheet paste onto a board as a table; a long layer name stays on one line in the Export button and the Dev Mode header, ending with an ellipsis, and no longer covers the Design panel (#1650); with hundreds of layers selected, the Design panel and the selection overlays stop going over the whole selection on every frame, so dragging 648 layers averages about 1.1 ms of UI per frame (#1571); plugin panels show pictures: an `image` control, base64 within 256 KB and 4,096 px a side, named by its label (gap 6); `.fig` connector labels without a saved snapshot are kept, centred on their line (gap 1); a drag moving many layers re-runs only what sits around them, not each layer's bindings, sizing, text and instance checks, on every move (#1571); damaged font tracking table offsets are checked instead of wrapping on 32-bit targets, and font indexing makes its existing offset bounds explicit (#1125); Bosnian (`bs`) draft catalog; text style dedupe is O(spans) via a last-two cache and fingerprint buckets (#1102); pasted layers whose image the clipboard lacks are named and show a placeholder that reads as missing (#1646); paint-style colours on layers pasted from another design app keep their style instead of turning black (#1638); progress docs cleaned of what merges left twice: rows merged into one, repeated status items removed, and `cargo xtask docs` (in `ci`) fails on a repeated table row or status item; a name typed for a variable or collection finds the one with that exact id before one named like it, and stored mode ids, font family variables and the panels' labels read exact ids (#1613); plugin widgets have hover styles: a layer's `hover` fill, stroke, opacity or text colour shows while the pointer is over it (gap 6); `.fig` elbowed and curved connectors without a saved path are routed like board connectors instead of drawn straight (gap 1); every file a command reads whole by path is a regular file within a cap that fits it: documents 16 GiB, placed images 256 MiB, CLI scripts 1 GiB; `figcraft-cli connect publish` publishes a code repository's code connect mappings to a design file (gap 16); while a drag only moves a large selection, its outlines, box, slots, spacing and Design panel values are worked out once and shifted, not rebuilt on every move (#1571); Shift+Cmd/Ctrl+Enter on a selected board shape adds the next shape already joined to it, and goes on typing (#1597); an imported or pasted file finds an installed family under the other names its fonts give it, and with other case and spacing, instead of reporting it missing (#1292); a development example generates figcraft-cli's shell completions and man pages from its clap definitions (part of #1396); `.fig` ZIP image reads share a total byte budget and count skipped images in the import warnings (#1076); German terminology corrections (M10): gradient stops, OpenType stylistic sets and the Home-screen preference; a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; the server key is checked by comparing SHA-256 hashes of the presented and the kept key, as the deployment guide says (#1130); opening a file contacts no team library server it names until you check it (#1097); a hostile server can no longer grow a client's memory with made-up people, and a session remembers a bounded number of deleted layer ids (#1121); `doc.list` gives AI agents and plugins the open files' names, not their paths (#1117); with a large selection, frames where nothing changed reuse the selection's outlines, box and slots and the Design panel's instance and position values instead of working them out again (#1571); team libraries and plugins larger than the 16 MiB download burst can be downloaded again, more slowly (#1089); a batch keeps one undo snapshot, not one per step (#1099); files nested 256 levels deep import on the browser's 1 MB stack (#1101); SVG and PDF exports count pattern sources and the renders they embed from the layer's depth, so one cap bounds a whole export (#1082); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); joining and leaving a file nobody is editing reads and writes nothing on the server: it stays loaded a minute after everyone leaves (#1074); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); hovering the canvas hit-tests once per pointer move, not on every frame, and moving an auto layout frame no longer lays out its contents again on every move (#1571); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); `comment.people` lists names that were only @mentioned, and `comment.next` and `comment.prompt` carry the mentions too (#1415); grid auto-placement looks for free cells a 64-bit word at a time and skips what blocks it, with the same placements (#1083); with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); a recovery copy made while a version is previewed holds the file, not the version shown; a FigJam `.jam` file opens as a board (#1342); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); share links can comment: a third access level beside edit and view, whose holder sees the file live and adds, replies to, reacts to and resolves comments but can't change the design; Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); `comment.list {mentions}` lists only the threads that @mention a name (#1415); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); figcraft-cli parses its arguments with clap, following RFC-0012 (#1396); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); Thai, Lao, Khmer and Burmese lines break between words (#1219); an AI agent attached over MCP shows where it works (#1416); a manual overlay's position can be dragged on the canvas (#405); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); SVG import draws each pattern tile once per scale, within the layer budget (#1080); Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); components can't contain their own instances, directly or through other components; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); 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 @@ -358,6 +358,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-31 | minor | M3, gap 10: library analytics cover styles and variables. A file reports how many of its layers use each of a library's styles and variables, and Show usage (`library.usage`) lists them beside the components, by a Type choice, with their layers and files. Layers inside instances count for their component. Insertions, detachments and modes aren't counted for them yet. Numbers unchanged. Tests: `library_assets_tests`. | | 2026-10-31 | minor | M5: a key/gamepad trigger's Controller menu shows, beside each button, what PlayStation and Nintendo Switch controllers call it ("Gamepad A" is ✕ and B). Triggers are stored by the button's place, as before. | | 2026-10-31 | minor | M11, gap 6: widgets draw lines and SVG pictures, as a Figma widget's `Line` and `SVG` do (plugin API 6; older plugins unchanged). A line is 100 long, black and 1 thick unless told otherwise, and turns around its top-left corner. An SVG comes in as locked layers, scaled to fit a size, within the widget's 256 layers. The Counter example uses both. Numbers unchanged. Tests: `widget_shapes_tests`, `widget_tests`. | | 2026-10-31 | patch | M12, #1708: a connector end dropped on the outer half of a shape takes the side it is towards, instead of only within 10 px of a side's middle, so a drop anywhere along an edge chooses that edge (dragging a selected connector's end and with the connector tool, ring included); a drop in the middle of a shape still takes the facing side. | diff --git a/apps/figcraft-server/src/lib.rs b/apps/figcraft-server/src/lib.rs index 452d7eb1a..99b2d3645 100644 --- a/apps/figcraft-server/src/lib.rs +++ b/apps/figcraft-server/src/lib.rs @@ -107,6 +107,8 @@ mod idle_io_tests; #[cfg(test)] mod library_actions_tests; #[cfg(test)] +mod library_assets_tests; +#[cfg(test)] mod library_detach_tests; #[cfg(test)] mod library_tests; diff --git a/apps/figcraft-server/src/library_assets_tests.rs b/apps/figcraft-server/src/library_assets_tests.rs new file mode 100644 index 000000000..4c9b8aaef --- /dev/null +++ b/apps/figcraft-server/src/library_assets_tests.rs @@ -0,0 +1,145 @@ +//! Styles and variables in a team library's analytics, end to end over loopback: a file that uses +//! them reports how many of its layers do, the library's owner sees them per style and variable +//! with their names and files, and a file that stops using them stops counting. Reports are cleaned +//! and capped, and the usage list keeps its encoding. + +use std::time::{Duration, Instant}; + +use figcraft_collab::message::{Access, AssetUsage, ClientMsg, LibraryReply, LibraryReq, MAX_ASSET_USAGE, OpenLibrary, PROTOCOL, ServerMsg}; +use figcraft_collab_net::Invite; +use figcraft_doc::NodeId; +use figcraft_engine::Session; +use serde_json::{Value, json}; + +use crate::test_util::{Raw, TempDir, config, publish, session}; + +fn ex(s: &mut Session, cmd: &str, p: Value) -> Value { + s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd} {p}: {e}")) +} + +fn id(v: &Value) -> NodeId { + NodeId(v["id"].as_u64().unwrap()) +} + +/// `a`'s analytics once `done` holds for them; `b` reports again each time, as a check asked for +/// while the last one still runs is dropped. +fn usage_when(a: &mut Session, b: &mut Session, done: impl Fn(&Value) -> bool) -> Value { + let start = Instant::now(); + loop { + b.refresh_server_libraries(); + let u = ex(a, "library.usage", json!({})); + if done(&u) { + return u; + } + assert!(start.elapsed() < Duration::from_secs(30), "the reports never arrived: {u}"); + std::thread::sleep(Duration::from_millis(20)); + } +} + +fn rect(s: &mut Session, x: i32) -> NodeId { + id(&ex(s, "shape.rectangle", json!({"x": x, "y": 0, "width": 40, "height": 40}))) +} + +#[test] +fn styles_and_variables_are_counted_per_layer_and_file() { + let dir = TempDir::new("library-assets"); + let cfg = config(dir.path()); + let mut server = crate::start(&cfg).unwrap(); + let addr = server.addr(); + // Ada's library: a colour style, a colour variable, and a Card that uses the style. + let mut a = session("Ada"); + let r = rect(&mut a, 0); + let style = ex(&mut a, "style.create", json!({"type": "paint", "name": "Brand", "from": r})); + let brand = style["id"].as_str().unwrap().to_string(); + let collection = ex(&mut a, "variable.createCollection", json!({"name": "Tokens"})); + ex(&mut a, "variable.create", json!({"name": "Accent", "type": "color", "collection": collection["id"], "value": "#0D99FF"})); + let card = id(&ex(&mut a, "frame.create", json!({"x": 0, "y": 100, "width": 100, "height": 40, "name": "Card"}))); + ex(&mut a, "style.apply", json!({"ids": [card], "style": brand})); + ex(&mut a, "component.create", json!({"ids": [card]})); + let p = publish(&mut a, dir.path(), addr); + ex(&mut a, "library.publishToServer", json!({})); + + // Bo uses the style on two layers and the variable on one, and places a Card: it belongs to + // the component, so it doesn't count for the style. + let mut b = session("Bo"); + let lid = ex(&mut b, "library.add", json!({"path": p.view}))["id"].as_str().unwrap().to_string(); + let (one, two, three) = (rect(&mut b, 0), rect(&mut b, 100), rect(&mut b, 200)); + ex(&mut b, "library.applyStyle", json!({"library": lid, "style": "Brand", "ids": [one, two]})); + ex(&mut b, "library.bindVariable", json!({"library": lid, "variable": "Accent", "ids": [three], "field": "fill"})); + ex(&mut b, "library.place", json!({"library": lid, "component": "Card", "x": 300, "y": 0})); + + let u = usage_when(&mut a, &mut b, |u| u["styles"][0]["layers"] == 2 && u["variables"][0]["layers"] == 1 && u["instances"] == 1); + assert_eq!(u["assetsKnown"], true, "{u}"); + let s = &u["styles"][0]; + assert_eq!((s["name"].clone(), s["type"].clone(), s["files"].clone()), (json!("Brand"), json!("paint"), json!(1)), "{u}"); + let v = &u["variables"][0]; + assert_eq!((v["name"].clone(), v["collection"].clone(), v["files"].clone()), (json!("Accent"), json!("Tokens"), json!(1)), "{u}"); + assert_eq!(u["instances"], 1, "components count as before: {u}"); + + // Bo takes the style off one layer and the variable off its only one. + ex(&mut b, "style.detach", json!({"ids": [two], "which": "fill"})); + ex(&mut b, "variable.bind", json!({"ids": [three], "field": "fill"})); + let u = usage_when(&mut a, &mut b, |u| u["styles"][0]["layers"] == 1 && u["variables"].as_array().is_some_and(Vec::is_empty)); + assert_eq!(u["styles"].as_array().unwrap().len(), 1, "{u}"); + server.stop(); +} + +/// Reports from the wire: names cleaned, counts capped and long ids dropped, one count per id; the +/// reports survive the server's restart, and a server's list has only files that use something. +#[test] +fn asset_reports_are_capped_and_kept() { + let dir = TempDir::new("library-assets-wire"); + let cfg = config(dir.path()); + let mut server = crate::start(&cfg).unwrap(); + let addr = server.addr(); + let mut a = session("Ada"); + let r = rect(&mut a, 0); + ex(&mut a, "style.create", json!({"type": "paint", "name": "Brand", "from": r})); + let p = publish(&mut a, dir.path(), addr); + ex(&mut a, "library.publishToServer", json!({})); + let view = Invite::parse(&p.view).unwrap(); + let open = |addr| { + let mut raw = Raw::connect(addr); + raw.send(&ClientMsg::OpenLibrary(OpenLibrary { + protocol: PROTOCOL, + file: view.file.clone(), + token: view.token.clone(), + client_version: String::new(), + })); + assert_eq!(answer(&mut raw), LibraryReply::Ready { access: Access::View }); + raw + }; + let mut raw = open(addr); + let flood = (0..5_000).map(|i| (format!("s{i}"), 1)).collect(); + let noted = |raw: &mut Raw, a| { + raw.send(&ClientMsg::Library(LibraryReq::ReportAssets(a))); + assert_eq!(answer(raw), LibraryReply::Noted); + }; + noted(&mut raw, AssetUsage { file: "Spam".into(), styles: flood, variables: Vec::new() }); + let odd = vec![("x".repeat(65), 7), ("a".into(), 4), ("a".into(), 9), ("b".into(), 0)]; + noted(&mut raw, AssetUsage { file: "\u{7}Shop\n".into(), styles: Vec::new(), variables: odd }); + noted(&mut raw, AssetUsage { file: "Nothing".into(), ..AssetUsage::default() }); + raw.send(&ClientMsg::Library(LibraryReq::Assets)); + let LibraryReply::Assets(files) = answer(&mut raw) else { panic!("no reports") }; + assert_eq!(files.len(), 2, "a file that uses nothing isn't listed: {files:?}"); + assert_eq!(files.iter().find(|f| f.file == "Spam").unwrap().styles.len(), MAX_ASSET_USAGE); + assert_eq!(files.iter().find(|f| f.file == "Shop").unwrap().variables, [("a".to_string(), 4)]); + // Older apps ask for the usage list, where nobody reported using the library. + raw.send(&ClientMsg::Library(LibraryReq::Usage)); + assert_eq!(answer(&mut raw), LibraryReply::Usage(Vec::new())); + server.stop(); + + let mut server = crate::start(&cfg).unwrap(); + let mut raw = open(server.addr()); + raw.send(&ClientMsg::Library(LibraryReq::Assets)); + let LibraryReply::Assets(kept) = answer(&mut raw) else { panic!("no reports") }; + assert_eq!(kept, files); + server.stop(); +} + +fn answer(raw: &mut Raw) -> LibraryReply { + match raw.next() { + ServerMsg::Library(r) => *r, + other => panic!("not a library answer: {other:?}"), + } +} diff --git a/apps/figcraft-server/src/store.rs b/apps/figcraft-server/src/store.rs index b8f0e30c1..2e713deb2 100644 --- a/apps/figcraft-server/src/store.rs +++ b/apps/figcraft-server/src/store.rs @@ -40,8 +40,8 @@ use std::path::{Path, PathBuf}; use std::time::{Instant, SystemTime, UNIX_EPOCH}; use figcraft_collab::message::{ - Access, Admin, AdminReply, ClientMsg, Detached, FileDetached, FileInfo, FileRow, FileUsage, HostInfo, LibraryReply, LibraryReq, LinkInfo, - MAX_OWNED, NewLink, OwnedFile, PluginReply, PluginReq, ReportInfo, Usage, WeekActions, + Access, Admin, AdminReply, AssetUsage, ClientMsg, Detached, FileDetached, FileInfo, FileRow, FileUsage, HostInfo, LibraryReply, LibraryReq, + LinkInfo, MAX_OWNED, NewLink, OwnedFile, PluginReply, PluginReq, ReportInfo, Usage, WeekActions, }; use figcraft_collab::{Change, NodeId, Replica, Server, same_secret}; use figcraft_collab_net::hub::{Admission, Backend, Closing, Handled, OpenFile, ip_blocks, ip_key}; @@ -106,7 +106,7 @@ pub const NO_PUBLIC_LIBRARY: &str = "on this public server only files published const MAX_USERS: usize = 200; const MAX_REPORTED: usize = 1_000; /// Largest `usage.json` (and `actions.json`) read. -const MAX_USAGE_FILE: u64 = 4 << 20; +const MAX_USAGE_FILE: u64 = 16 << 20; /// Weeks of a library's insertions and detachments kept (a year and the current week), and most /// rows (a component in a week) kept in all. const MAX_WEEKS: u64 = 53; @@ -317,6 +317,10 @@ struct UsageEntry { detached_updated: u64, #[serde(default, skip_serializing_if = "Vec::is_empty")] peaks: Vec<(NodeId, u32, u32)>, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + styles: Vec<(String, u32)>, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + variables: Vec<(String, u32)>, } fn is_zero(n: &u64) -> bool { diff --git a/apps/figcraft-server/src/store/backend.rs b/apps/figcraft-server/src/store/backend.rs index 56db011a7..ee4091a8b 100644 --- a/apps/figcraft-server/src/store/backend.rs +++ b/apps/figcraft-server/src/store/backend.rs @@ -274,6 +274,8 @@ impl Backend for Store { LibraryReq::Detached => self.library_detached(file).map_or_else(failed, LibraryReply::Detached), LibraryReq::ReportInserted(d) => self.report_inserted(file, d).map_or_else(failed, |()| LibraryReply::Noted), LibraryReq::Actions => self.library_actions(file).map_or_else(failed, LibraryReply::Actions), + LibraryReq::ReportAssets(a) => self.report_assets(file, a).map_or_else(failed, |()| LibraryReply::Noted), + LibraryReq::Assets => self.library_assets(file).map_or_else(failed, LibraryReply::Assets), } } diff --git a/apps/figcraft-server/src/store/library.rs b/apps/figcraft-server/src/store/library.rs index d782ef8f4..322c906e8 100644 --- a/apps/figcraft-server/src/store/library.rs +++ b/apps/figcraft-server/src/store/library.rs @@ -1,5 +1,7 @@ +use figcraft_collab::message::MAX_ASSET_USAGE; + use super::{ - ACTIONS, Action, BUSY_DOWNLOADS, Cursor, Detached, FILES, FileDetached, FileUsage, IpAddr, LIBRARY, LibraryMeta, LibraryReply, + ACTIONS, Action, AssetUsage, BUSY_DOWNLOADS, Cursor, Detached, FILES, FileDetached, FileUsage, IpAddr, LIBRARY, LibraryMeta, LibraryReply, MAX_FIRST_LIBRARY_VERSION, MAX_REPORTED, MAX_USAGE_FILE, MAX_USERS, MAX_WEEK_ROWS, MAX_WEEKS, META, NO_PUBLIC_LIBRARY, NodeId, Path, PathBuf, Store, USAGE, USAGE_WRITE_EVERY, Usage, UsageCache, UsageEntry, WeekActions, clean_name, disk, fs, now, write_json, }; @@ -12,6 +14,8 @@ enum Count { } const WEEK: u64 = 7 * 86_400; +/// Longest style or variable id a report may name. +const MAX_ASSET_ID: usize = 64; /// The start of the week `t` falls in (seconds since 1970 UTC, a Monday at 00:00). fn week_of(t: u64) -> u64 { @@ -20,6 +24,20 @@ fn week_of(t: u64) -> u64 { u64::try_from(monday.and_time(chrono::NaiveTime::MIN).and_utc().timestamp()).unwrap_or(0) } +/// A report's counts cleaned: positive, one per key, at most [`MAX_REPORTED`]. +fn cleaned(counts: Vec<(K, u32)>) -> Vec<(K, u32)> { + let mut counts: Vec<_> = counts.into_iter().filter(|(_, n)| *n > 0).take(MAX_REPORTED * 4).collect(); + counts.sort(); + counts.dedup_by(|a, b| a.0 == b.0); + counts.truncate(MAX_REPORTED); + counts +} + +/// A report's style or variable counts: ids of a sensible length, at most [`MAX_ASSET_USAGE`]. +fn known_assets(counts: Vec<(String, u32)>) -> Vec<(String, u32)> { + counts.into_iter().filter(|(id, _)| id.len() <= MAX_ASSET_ID).take(MAX_ASSET_USAGE).collect() +} + /// A kept list of statistics, or an empty one when it is missing or damaged (it starts over). fn read_kept(path: &Path) -> Vec { if !path.exists() { @@ -188,6 +206,13 @@ impl Store { .collect()) } + /// The style and variable reports kept for a file's library (none when it has no library). + pub fn library_assets(&mut self, file: &str) -> Result, String> { + let list = self.usage_of(file)?.map(|u| u.list.as_slice()).unwrap_or_default(); + let used = list.iter().filter(|e| !e.styles.is_empty() || !e.variables.is_empty()); + Ok(used.map(|e| AssetUsage { file: e.usage.file.clone(), styles: e.styles.clone(), variables: e.variables.clone() }).collect()) + } + /// A file's library's components' insertions and detachments week by week, over the last /// [`MAX_WEEKS`] weeks (none when it has no library). pub fn library_actions(&mut self, file: &str) -> Result, String> { @@ -209,6 +234,19 @@ impl Store { }) } + /// Keep how many layers of one file use a file's library's styles and variables (as + /// [`Self::report_usage`] keeps its usage, beside it). + pub fn report_assets(&mut self, file: &str, assets: AssetUsage) -> Result<(), String> { + let variables = cleaned(known_assets(assets.variables)); + self.report(file, &assets.file, known_assets(assets.styles), |e, styles| { + if e.styles == styles && e.variables == variables { + return false; + } + (e.styles, e.variables) = (styles, variables); + true + }) + } + /// Keep how many instances of a file's library components one file has detached (as /// [`Self::report_usage`] keeps its usage, beside it); what it detached since its last report /// counts in this week. @@ -241,24 +279,20 @@ impl Store { Ok(()) } - /// Keep `counts` from the file called `name` (cleaned: positive counts, one per component, at - /// most [`MAX_REPORTED`]) in its entry with `set` (which says whether anything changed), + /// Keep `counts` from the file called `name` ([`cleaned`]) in its entry with `set` (which says whether anything changed), /// keeping at most [`MAX_USERS`] files, the ones heard from most recently. - fn report( + fn report( &mut self, file: &str, name: &str, - counts: Vec<(NodeId, u32)>, - set: impl FnOnce(&mut UsageEntry, Vec<(NodeId, u32)>) -> bool, + counts: Vec<(K, u32)>, + set: impl FnOnce(&mut UsageEntry, Vec<(K, u32)>) -> bool, ) -> Result<(), String> { let name = clean_name(name); if name.is_empty() { return Ok(()); } - let mut counts: Vec<_> = counts.into_iter().filter(|(_, n)| *n > 0).take(MAX_REPORTED * 4).collect(); - counts.sort(); - counts.dedup_by_key(|c| c.0); - counts.truncate(MAX_REPORTED); + let counts = cleaned(counts); let Some(u) = self.usage_of(file)? else { return Ok(()) }; let at = match u.list.iter().position(|e| e.usage.file == name) { Some(at) => at, diff --git a/crates/collab-net/src/link.rs b/crates/collab-net/src/link.rs index 24703e860..fdcba5b97 100644 --- a/crates/collab-net/src/link.rs +++ b/crates/collab-net/src/link.rs @@ -234,7 +234,7 @@ impl LiveLink for NetLink { let spawned = std::thread::Builder::new().name("library-fetch".into()).spawn(move || { for (link, req) in jobs { // A report's answer says nothing about the library (and an older server refuses it). - let report = matches!(req, LibraryReq::ReportDetached(_) | LibraryReq::ReportInserted(_)); + let report = matches!(req, LibraryReq::ReportDetached(_) | LibraryReq::ReportInserted(_) | LibraryReq::ReportAssets(_)); let reply = manage::library(&link, req, &limits, CONNECT); if !report { inbox.push(Event::Library { link, reply }); diff --git a/crates/collab/src/message.rs b/crates/collab/src/message.rs index a5abb504d..07adbb2af 100644 --- a/crates/collab/src/message.rs +++ b/crates/collab/src/message.rs @@ -617,6 +617,18 @@ pub struct Usage { pub components: Vec<(NodeId, u32)>, } +/// Most styles and most variables of one library one file reports using. +pub const MAX_ASSET_USAGE: usize = 250; + +/// How many layers of one file use each of a library's styles and variables, as its app reports +/// them: the file's name and the counts (by their ids in the library). +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct AssetUsage { + pub file: String, + pub styles: Vec<(String, u32)>, + pub variables: Vec<(String, u32)>, +} + /// A file's last usage report, with when it came (seconds since 1970, UTC). #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct FileUsage { @@ -675,6 +687,11 @@ pub enum LibraryReq { /// The library's components' insertions and detachments week by week (answered with /// [`LibraryReply::Actions`]). Actions, + /// How many layers of the asking file use the library's styles and variables (answered with + /// [`LibraryReply::Noted`]). + ReportAssets(AssetUsage), + /// The style and variable reports of the files that use the library. + Assets, } /// The answer to a [`LibraryReq`] (the first one on a connection answers [`OpenLibrary`]). @@ -709,6 +726,7 @@ pub enum LibraryReply { Noted, Detached(Vec), Actions(Vec), + Assets(Vec), } /// Opens a connection to a server's plugin directory (instead of [`Hello`]). Anyone who can reach diff --git a/crates/engine/src/assets/library_server.rs b/crates/engine/src/assets/library_server.rs index 9b3bd5649..47d15a954 100644 --- a/crates/engine/src/assets/library_server.rs +++ b/crates/engine/src/assets/library_server.rs @@ -11,11 +11,12 @@ //! ([`Session::refresh_server_libraries`]) and keeps the last library fetched while the server //! can't be reached. +use std::collections::{BTreeMap, BTreeSet}; use std::io::Cursor; use std::sync::Arc; -use figcraft_collab::message::{Detached, LibraryReply, LibraryReq, Usage}; -use figcraft_doc::{DetachCount, Document, NodeId, NodeKind, PublishedLibrary}; +use figcraft_collab::message::{AssetUsage, Detached, LibraryReply, LibraryReq, MAX_ASSET_USAGE, Usage}; +use figcraft_doc::{DetachCount, Document, ImportedRecord, NodeId, NodeKind, PublishedLibrary}; use crate::live::{NetReply, NetRequest}; use crate::{EngineError, Result, Session}; @@ -91,6 +92,42 @@ pub fn usage(doc: &Document, library: &str) -> Usage { Usage { file: doc.name.clone(), components: counts.into_iter().take(MAX_USAGE).collect() } } +/// How many layers of `doc` use each of `library`'s styles and variables, the most used first (a +/// instance, a layer inside one or the library copies belongs to its component, which is what's +/// counted); `None` when it has none of them, so a file that stops using them still reports that. +pub fn asset_usage(doc: &Document, library: &str) -> Option { + let keys = |records: &[ImportedRecord]| -> BTreeMap { + records.iter().filter(|r| r.library == library).map(|r| (r.local.clone(), r.key.clone())).collect() + }; + let (style_keys, variable_keys) = (keys(&doc.libraries.styles), keys(&doc.libraries.variables)); + if style_keys.is_empty() && variable_keys.is_empty() { + return None; + } + let store = doc.library_store(); + let (mut styles, mut variables) = (BTreeMap::<&str, u32>::new(), BTreeMap::<&str, u32>::new()); + let instance = |n: &figcraft_doc::Node| matches!(n.kind, NodeKind::Instance { .. }) || crate::components::inside_instance(doc, n.id); + let own = doc.nodes().filter(|n| store.is_none_or(|st| doc.page_of(n.id) != Some(st)) && !instance(n)); + for n in own { + let used = crate::library::style_links(n).filter_map(|s| style_keys.get(s)).collect::>(); + for k in used { + *styles.entry(k).or_default() += 1; + } + let used = n.bound.values().filter_map(|v| variable_keys.get(v)).collect::>(); + for k in used { + *variables.entry(k).or_default() += 1; + } + } + Some(AssetUsage { file: doc.name.clone(), styles: most_used(styles), variables: most_used(variables) }) +} + +/// `counts`, the most used first, at most [`MAX_ASSET_USAGE`]. +fn most_used(counts: BTreeMap<&str, u32>) -> Vec<(String, u32)> { + let mut list: Vec<(String, u32)> = counts.into_iter().map(|(k, n)| (k.to_string(), n)).collect(); + list.sort_by_key(|c| std::cmp::Reverse(c.1)); + list.truncate(MAX_ASSET_USAGE); + list +} + /// How many instances of `library`'s components `doc` has detached, as its server keeps them; /// `None` when it has detached none. pub fn detached(doc: &Document, library: &str) -> Option { @@ -226,7 +263,11 @@ impl Session { }; // Then the detaches and insertions, when there are any (a server from before them // refuses the request, and nothing comes of it). - let reports = [detached(doc, &l.id).map(LibraryReq::ReportDetached), inserted(doc, &l.id).map(LibraryReq::ReportInserted)]; + let reports = [ + detached(doc, &l.id).map(LibraryReq::ReportDetached), + inserted(doc, &l.id).map(LibraryReq::ReportInserted), + asset_usage(doc, &l.id).map(LibraryReq::ReportAssets), + ]; let fetch = LibraryReq::Fetch { have, usage: Some(usage(doc, &l.id)) }; std::iter::once(fetch).chain(reports.into_iter().flatten()).map(|req| (l.path.clone(), req)) }) diff --git a/crates/engine/src/cmd/library.rs b/crates/engine/src/cmd/library.rs index 4c543c7a1..5f3263765 100644 --- a/crates/engine/src/cmd/library.rs +++ b/crates/engine/src/cmd/library.rs @@ -8,7 +8,7 @@ use std::sync::Arc; use figcraft_doc::{Document, ImportedComponent, ImportedRecord, LibraryLink, LibraryManifest, NodeId, PublishedLibrary, VariableId}; use serde_json::{Value, json}; -use figcraft_collab::message::{FileDetached, LibraryReply, LibraryReq, WeekActions}; +use figcraft_collab::message::{AssetUsage, FileDetached, LibraryReply, LibraryReq, WeekActions}; use super::CommandSpec; use crate::library::{ @@ -427,6 +427,7 @@ fn usage_cmd(s: &mut Session, p: &Value) -> Result { other => return Err(unexpected(&other)), }; let detached = detach_reports(s.library_request(&link, LibraryReq::Detached))?; + let assets = asset_reports(s.library_request(&link, LibraryReq::Assets))?; let days = num(p, "days").filter(|d| d.is_finite()).map_or(30, |d| d.round().clamp(1.0, 365.0) as u64); // Weeks overlapping the period; a server from before weekly counts refuses the request. let since = crate::cmd::comment::now_secs().saturating_sub(days * 86_400); @@ -479,9 +480,45 @@ fn usage_cmd(s: &mut Session, p: &Value) -> Result { "weeks": actions.weeks, "files": rows, "components": components, + "assetsKnown": assets.is_some(), + "styles": asset_rows(assets.iter().flatten().map(|a| &a.styles), |id| { + m.style(id).map(|x| json!({"name": x.name, "type": x.kind})) + }), + "variables": asset_rows(assets.iter().flatten().map(|a| &a.variables), |id| { + let v = m.variable(id)?; + Some(json!({"name": v.name, "collection": m.collection(&v.collection).map(|c| c.name.clone())})) + }), })) } +/// The style or variable reports' totals, the most used first: each one's `id`, `layers` and +/// `files`, with what `describe` says of it (nothing for one no longer published). +fn asset_rows<'a>(reports: impl Iterator>, describe: impl Fn(&str) -> Option) -> Vec { + let mut per = std::collections::BTreeMap::<&str, (u64, u64)>::new(); + for (id, n) in reports.flatten() { + let e = per.entry(id).or_default(); + e.0 += u64::from(*n); + e.1 += 1; + } + let mut rows: Vec<_> = per.into_iter().collect(); + rows.sort_by_key(|r| std::cmp::Reverse(r.1.0)); + let row = |(id, (layers, files)): (&str, (u64, u64))| { + let mut r = describe(id).unwrap_or_else(|| json!({})); + (r["id"], r["layers"], r["files"]) = (json!(id), json!(layers), json!(files)); + r + }; + rows.into_iter().map(row).collect() +} + +/// The style and variable reports, `None` from a server from before them. +fn asset_reports(reply: Result) -> Result>> { + match reply { + Ok(LibraryReply::Assets(a)) => Ok(Some(a)), + Err(EngineError::Waiting) => Err(EngineError::Waiting), + _ => Ok(None), + } +} + /// A period's insertions and detachments: per component, in all, and week by week (ascending). struct PeriodActions { per: std::collections::BTreeMap, diff --git a/crates/engine/src/sharing/background/tests.rs b/crates/engine/src/sharing/background/tests.rs index ff3422123..fa4d00824 100644 --- a/crates/engine/src/sharing/background/tests.rs +++ b/crates/engine/src/sharing/background/tests.rs @@ -91,9 +91,10 @@ fn library(w: &mut Wire, req: LibraryReq) -> LibraryReply { LibraryReply::Published { version: 1 } } (LibraryReq::Usage, _) => LibraryReply::Usage(Vec::new()), - (LibraryReq::ReportDetached(_) | LibraryReq::ReportInserted(_), _) => LibraryReply::Noted, + (LibraryReq::ReportDetached(_) | LibraryReq::ReportInserted(_) | LibraryReq::ReportAssets(_), _) => LibraryReply::Noted, (LibraryReq::Detached, _) => LibraryReply::Detached(Vec::new()), (LibraryReq::Actions, _) => LibraryReply::Actions(Vec::new()), + (LibraryReq::Assets, _) => LibraryReply::Assets(Vec::new()), } } diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index a20beb200..ff084163c 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -986,6 +986,7 @@ name = "English" "Not read yet" = "Not read yet" "Not shared" = "Not shared" "Note {n}" = "Note {n}" +"Nothing counted yet" = "Nothing counted yet" "Nothing changed since this version." = "Nothing changed since this version." "Nothing changed since this was marked ready for dev." = "Nothing changed since this was marked ready for dev." "Nothing here yet. Set a frame's status to list it." = "Nothing here yet. Set a frame's status to list it." @@ -1319,6 +1320,7 @@ name = "English" "Search results" = "Search results" "Search fonts" = "Search fonts" "This server doesn't count insertions" = "This server doesn't count insertions" +"This server doesn't count styles and variables" = "This server doesn't count styles and variables" "Someone who left" = "Someone who left" "Take the results off the board; they stay in the file" = "Take the results off the board; they stay in the file" "Upload from computer" = "Upload from computer" diff --git a/crates/ui-egui/src/panels/assets_panel.rs b/crates/ui-egui/src/panels/assets_panel.rs index 8917b3685..a63c10c9a 100644 --- a/crates/ui-egui/src/panels/assets_panel.rs +++ b/crates/ui-egui/src/panels/assets_panel.rs @@ -1036,11 +1036,22 @@ fn usage(app: &mut FigApp, ui: &mut Ui, w: f32, link: &str) { } else { hint(ui, tr("This server doesn't count insertions")); } - let t = Tokens::get(ui.ctx()); + let Some(kind) = usage_kind(ui, w, usage_id, &v) else { + ui.add_space(8.0); + return; + }; + if kind != "components" { + for a in v[kind].as_array().into_iter().flatten().take(USAGE_ROWS) { + let (n, f) = (a["layers"].as_u64().unwrap_or(0), a["files"].as_u64().unwrap_or(0)); + let counts = trf("{instances} in {files}", &[("instances", &trn("{n} layers", n as usize, &[])), ("files", &files_n(f))]); + usage_row(ui, w, a["name"].as_str().unwrap_or(tr("(no longer published)")), &counts); + } + ui.add_space(8.0); + return; + } for c in v["components"].as_array().into_iter().flatten().take(USAGE_ROWS) { let name = c["name"].as_str().unwrap_or(tr("(no longer published)")); let (n, f) = (c["instances"].as_u64().unwrap_or(0), c["files"].as_u64().unwrap_or(0)); - let (r, _) = ui.allocate_exact_size(vec2(w, 34.0), Sense::hover()); let gone = c["detached"].as_u64().unwrap_or(0); // A component only detached (no instances left anywhere) says just that. let right = if n == 0 && known && gone > 0 { @@ -1051,14 +1062,52 @@ fn usage(app: &mut FigApp, ui: &mut Ui, w: f32, link: &str) { // Inserted in the period, after what the component counts in all. let added = c["insertions"].as_u64().unwrap_or(0); let right = if actions && added > 0 { trf("{used} · {inserted}", &[("used", &right), ("inserted", &inserted_n(added))]) } else { right }; - // Two lines: the counts don't leave a name room on one. - crate::bidi::paint_name(ui.painter(), pos2(r.min.x + PAD, r.min.y + 10.0), name, regular(11.0), t.text, w - PAD * 2.0); - let counts = crate::widgets::elide_font(ui, &right, w - PAD * 2.0, regular(10.0)); - ui.painter().text(pos2(r.min.x + PAD, r.min.y + 25.0), Align2::LEFT_CENTER, counts, regular(10.0), t.text_secondary); + usage_row(ui, w, name, &right); } ui.add_space(8.0); } +/// Which list Show usage shows: `components`, `styles` or `variables` (a choice kept with the +/// answer's `id`), `None` for nothing to list. A server from before style and variable reports +/// shows just components, and says so. +fn usage_kind(ui: &mut Ui, w: f32, id: Id, v: &Value) -> Option<&'static str> { + const KINDS: [(&str, &str); 3] = [("components", "Components"), ("styles", "Styles"), ("variables", "Variables")]; + if v["assetsKnown"].as_bool() != Some(true) { + hint(ui, tr("This server doesn't count styles and variables")); + return Some(KINDS[0].0); + } + let id = id.with("kind"); + let mut at = ui.data(|d| d.get_temp::(id)).unwrap_or(0); + ui.horizontal(|ui| { + ui.add_space(PAD); + let chosen = KINDS.get(at).map_or("", |k| tr(k.1)); + let resp = crate::widgets::dropdown(ui, chosen, w - PAD * 2.0, None); + crate::right::menu_popup(&resp).width(w - PAD * 2.0).show(|ui| { + for (i, k) in KINDS.iter().enumerate() { + if crate::right::menu_item(ui, tr(k.1), "") { + at = i; + ui.close(); + } + } + }); + }); + ui.data_mut(|d| d.insert_temp(id, at)); + let kind = KINDS.get(at)?.0; + if v[kind].as_array().is_none_or(Vec::is_empty) { + hint(ui, tr("Nothing counted yet")); + } + Some(kind) +} + +/// A Show usage row: two lines, as the counts don't leave a name room on one. +fn usage_row(ui: &mut Ui, w: f32, name: &str, counts: &str) { + let t = Tokens::get(ui.ctx()); + let (r, _) = ui.allocate_exact_size(vec2(w, 34.0), Sense::hover()); + crate::bidi::paint_name(ui.painter(), pos2(r.min.x + PAD, r.min.y + 10.0), name, regular(11.0), t.text, w - PAD * 2.0); + let counts = crate::widgets::elide_font(ui, counts, w - PAD * 2.0, regular(10.0)); + ui.painter().text(pos2(r.min.x + PAD, r.min.y + 25.0), Align2::LEFT_CENTER, counts, regular(10.0), t.text_secondary); +} + /// Draw the libraries enabled in this file with version/count/status, remove and swap /// actions, add-file and pasted-link controls, and background fetch/cancel handling. fn enabled_libraries(app: &mut FigApp, ui: &mut Ui, w: f32, doc: &Document) { diff --git a/docs/collab-protocol.md b/docs/collab-protocol.md index 4edf1126e..b4b459afb 100644 --- a/docs/collab-protocol.md +++ b/docs/collab-protocol.md @@ -257,7 +257,7 @@ Client → server: | `Manage {protocol, key, client_version}` | Instead of `Hello`: manage a server's files with its key. | | `Admin {publish {name, snapshot} \| files \| links {file} \| createLink {file, access, label} \| revoke {file, link} \| remove {file}}` | A request on a managing connection; each gets one `Admin` reply, in order. | | `OpenLibrary {protocol, file, token, client_version}` | Instead of `Hello`: use a file's team library with one of the file's share links. | -| `Library {fetch {have?, usage?} \| publish {archive} \| usage \| reportDetached {file, components} \| detached \| reportInserted {file, components} \| actions}` | A request on a library connection; each gets one `Library` reply, in order. `usage` is `{file, components: [(id, instances)]}`; `reportDetached` gives the asking file's detached instances per component the same way (answered `noted`), `detached` asks for every file's. The last two are newer: a server from before them can't read them and refuses the request. | +| `Library {fetch {have?, usage?} \| publish {archive} \| usage \| reportDetached {file, components} \| detached \| reportInserted {file, components} \| actions \| reportAssets {file, styles, variables} \| assets}` | A request on a library connection; each gets one `Library` reply, in order. `usage` is `{file, components: [(id, instances)]}`; `reportDetached` gives the asking file's detached instances per component the same way (answered `noted`), `detached` asks for every file's. The last two are newer: a server from before them can't read them and refuses the request. | | `Signal {kind, text}` | Ephemeral presence, only after the server's `ready`: `chat` (the cursor chat message; empty: closed), `spotlight` (`"on"`, or empty: stopped) or `name` (your new name: the server keeps it, cleaned like a hello's, and sends everyone, you too, your `PeerJoined`; never passed on as a signal). | | `OpenPlugins {protocol, key, client_version}` | Instead of `Hello`: use the server's plugin directory; `key` empty to browse, the server key to publish too. | | `OpenHosting {protocol, client_version, file, owner}` | Instead of `Hello`: ask about the server's hosting; publish without the key (`Admin publish`) when it is open to the public; with a file's id and owner token, manage that file (`Admin links \| createLink \| revoke \| rename \| remove` of it). Answered with `Hosting` or `Refused`. | @@ -279,7 +279,7 @@ Server → client: | `Refused {reason}` | The connection is refused (protocol version, token, session full, a message out of place). | | `Pong` | Keep-alive. | | `Admin {ready \| published {file, edit, view} \| files \| links \| link \| done \| failed {reason}}` | A managing connection's answer: the key is right, a published file with its first two links (tokens shown once), listings, a new link, done, or why not. | -| `Library {ready {access} \| library {version, archive} \| unchanged {version} \| empty \| published {version} \| usage [{usage, updated}] \| failed {reason} \| noted \| detached [{detached, updated}] \| actions [{week, component, insertions, detachments}]}` | A library connection's answer: the link is right (with its access), the library (a `.figcraft` archive holding it), no newer version, no library yet, kept, the usage reports, or why not; then (newer) a detach report kept, and the detach reports. | +| `Library {ready {access} \| library {version, archive} \| unchanged {version} \| empty \| published {version} \| usage [{usage, updated}] \| failed {reason} \| noted \| detached [{detached, updated}] \| actions [{week, component, insertions, detachments}] \| assets [{file, styles, variables}]}` | A library connection's answer: the link is right (with its access), the library (a `.figcraft` archive holding it), no newer version, no library yet, kept, the usage reports, or why not; then (newer) a detach report kept, and the detach reports. | | `Signal {client_id, signal: {kind, text}}` | Someone's cursor chat or spotlight, cleaned by the server; `ready` (about the client itself, after its welcome): signals may be sent. Only to clients whose hello announced `signals`. | | `Hosting {open, operator, terms, idle_secs, max_file_mb, expires?}` | The answer to `OpenHosting`, and after a welcome to clients whose hello announced `hosting`: anyone may publish, how to reach the operator, the terms (a URL), how long files published without the key are kept unopened, the largest file, and this file's expiry (seconds since 1970). | | `Admin {hosted {file, edit, view, owner, expires?} \| listing [row] \| reports [report] \| blocked [ip]}` | A file published without the key with its links and owner token (shown once), and the answers to the later managing requests. | @@ -821,7 +821,11 @@ resuming with its offline edits, and leaving the session going back to snapshot `reportInserted` the same way with the instances it has inserted (placed, pasted or duplicated; not those nested in another instance). What either total grows past that file's highest counts in the current week, a Monday at 00:00 UTC, over all files (`actions.json`, a year of weeks, - capped), and `actions` answers with those weeks. The variants are appended to the enums, so the older messages keep their bytes; an older server refuses the new + capped), and `actions` answers with those weeks. A file that holds copies of the library's + styles or variables sends `reportAssets` with how many of its layers use each (at most 250 of + each kind, the most used first; none is a report too, so a file that stops using them stops + counting); the server keeps the latest in the same `usage.json` entry and answers `assets` with + them. The variants are appended to the enums, so the older messages keep their bytes; an older server refuses the new requests, and the app ignores that. *Built* (the plugin directory; operating it: [`server.md`](server.md#plugin-directory), publishing and diff --git a/docs/components-parity.md b/docs/components-parity.md index 6f082ae9e..051730215 100644 --- a/docs/components-parity.md +++ b/docs/components-parity.md @@ -1,6 +1,6 @@ # Components, variants, styles and variables parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (SwiftUI and Compose code names a bound variable by its iOS or Android code syntax; library analytics count insertions and show insertions and detachments over the past 30 days, 90 days or year; a collection of this file can be extended (Extend collection on its row, `variable.extendCollection`), its extension following its edits at once, and extended collections extend again in the file; a typed or stored variable, collection or mode id names only the object with that id (#1613); colour variables can be calculated from other colours per mode — `{alpha}` replaces a colour's alpha, `{mix}` blends two — and follow them (#1280); stored variable and collection references keep their identity when another object is named like their id (follow-up to #1542); a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; opening a file contacts no team library server it names until you check it (#1097); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); components can't contain their own instances, directly or through others (#1084); the instance-swap menus have a search field (#1338); an instance-swap property's default menu scrolls (#1338); variables on a text style's fields from the style editor; instance-swap properties in the component playground; duplicating or Alt-dragging a main component makes an instance of it (#892); a bound colour variable's alpha applies once (#802); variables import reads `variables` outside `meta` and warns when it imports none (#790); variant properties reorder by drag; library files saved right after a read are read again; Hide when publishing note corrected; size and resizing overrides of layers inside instances (#731); variables export in the browser as a download; core editor leftovers: component playground, variables inside styles; layers and assets panels behaviour suite and fixes; extended collections tail; styles and variables behaviour suite and fixes; components behaviour suite and fixes; team libraries on the server, library usage and swapping a library; extended collections, variables import/export, gradient stops and variant properties bound to variables) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** minor (library analytics list how many layers and files use each style and variable; SwiftUI and Compose code names a bound variable by its iOS or Android code syntax; library analytics count insertions and show insertions and detachments over the past 30 days, 90 days or year; a collection of this file can be extended (Extend collection on its row, `variable.extendCollection`), its extension following its edits at once, and extended collections extend again in the file; a typed or stored variable, collection or mode id names only the object with that id (#1613); colour variables can be calculated from other colours per mode — `{alpha}` replaces a colour's alpha, `{mix}` blends two — and follow them (#1280); stored variable and collection references keep their identity when another object is named like their id (follow-up to #1542); a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; opening a file contacts no team library server it names until you check it (#1097); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); components can't contain their own instances, directly or through others (#1084); the instance-swap menus have a search field (#1338); an instance-swap property's default menu scrolls (#1338); variables on a text style's fields from the style editor; instance-swap properties in the component playground; duplicating or Alt-dragging a main component makes an instance of it (#892); a bound colour variable's alpha applies once (#802); variables import reads `variables` outside `meta` and warns when it imports none (#790); variant properties reorder by drag; library files saved right after a read are read again; Hide when publishing note corrected; size and resizing overrides of layers inside instances (#731); variables export in the browser as a download; core editor leftovers: component playground, variables inside styles; layers and assets panels behaviour suite and fixes; extended collections tail; styles and variables behaviour suite and fixes; components behaviour suite and fixes; team libraries on the server, library usage and swapping a library; extended collections, variables import/export, gradient stops and variant properties bound to variables) · **Target:** Figma Design (desktop app 126.10.7) Design systems are why teams adopt Figma. **Components: breadth ~96%, ready ~72%. Styles and variables: breadth ~95%, ready ~76% (estimated).** QA'd with a synthetic design system of ~300 @@ -52,7 +52,7 @@ updates with overrides kept: `library_tests.rs`, `components_scale_tests.rs`. | Enable a library in a file; read-only linked copies | done | | | Library updates available; overrides kept | done | a library file saved again within two seconds of being read, at the same length, is read again (its time stamp can't prove it unchanged; #652) | | Team libraries: publish to a `figcraft-server` under the file, enable by a share link (a view link reads it), background update checks | done | [server.md](server.md#team-libraries); no team-wide library list or accounts ([gaps](gaps.md) #10) | -| Library analytics | partial | instances per component and the files using them, and the instances each file detached (per component and per file), as apps report them; insertions and detachments week by week for a year (Show usage: the past 30 days, 90 days or year), for components only: no styles or variables, no chart | +| Library analytics | partial | instances per component and the files using them, and the instances each file detached (per component and per file), as apps report them; insertions and detachments week by week for a year (Show usage: the past 30 days, 90 days or year), for components; the layers and files using each style and variable, without insertions, detachments or modes; no chart | | Swap library (instances, styles and variables to another library's of the same names, overrides kept) | done | what has no match stays and is listed; one undo step | ## Styles and variables @@ -78,6 +78,7 @@ updates with overrides kept: `library_tests.rs`, `components_scale_tests.rs`. | Date | Change | Summary | |---|---|---| +| 2026-10-31 | minor | M3, gap 10: library analytics cover styles and variables. A file reports how many of its layers use each of a library's styles and variables, and Show usage (`library.usage`) lists them beside the components, by a Type choice, with their layers and files. Layers inside instances count for their component. Insertions, detachments and modes aren't counted for them yet. Numbers unchanged. Tests: `library_assets_tests`. | | 2026-10-31 | minor | M6: as the help center describes code syntax, Dev Mode's SwiftUI and Compose code now writes a bound variable's iOS or Android code syntax in place of its value, as CSS already wrote the Web one: fills, strokes and their weight, sizes, corner radius, gaps, padding, opacity, the font family and size and the characters. A variable with no code syntax for the platform keeps the value, since a name made up here wouldn't exist in the code it lands in. ArkUI and Flutter have no code syntax platform and are unchanged. Test: `variables_behaviour_tests::swiftui_and_compose_name_variables_by_their_code_syntax`. Numbers unchanged. Grade unchanged | | 2026-10-31 | minor | M3, gap 10: library analytics over time. A file counts the instances of library components it inserts (placed, created, pasted or duplicated, Alt-dragged copies too; not those nested in another instance) and reports them like its detaches (`ReportInserted`). The server counts what a file's insertion and detach totals grow past their highest in the current week, a Monday at 00:00 UTC, so an undo and a redo count once, and keeps a year of weeks in `actions.json` (`Actions`). `library.usage {days?}` adds `insertions`, `detachments` and `weeks`, and per component `insertions` and `detachments`; Show usage has a 30 days, 90 days or 1 year choice, the period's totals, and each component's counts on a line of their own. The new variants are appended, so older messages keep their bytes; an older server refuses them and Show usage says it doesn't count insertions. A file's first report after this counts its total in that week. Tests: `library_actions_tests`, `inserting_library_instances_counts_them`. Grade unchanged | | 2026-10-30 | minor | M3, gap 14: a collection of this file can be extended, as the help center shows (right-click a collection, Extend collection); before, only a library's could. `variable.extendCollection {collection, name?}` makes " (extended)" with its modes and one inheriting variable per variable, and the Variables editor's collection menu offers it beside Rename and Delete collection. Edits of the collection it extends reach it at once (values in modes it doesn't override, new variables and modes, renames, mode order), so normalising keeps extensions in step whenever variables change, not only on library updates; undo takes both back. An extended collection extends again (parents settle first; an extension doesn't take its parent's overrides as its own), and a loop of extensions from a hostile file comes apart into plain collections keeping their values. Deleting the parent leaves a plain collection with the values it showed, as for libraries. Tests: `extended_chain_tests` (engine), `variables_rename_tests::a_collection_of_this_file_extends_from_its_context_menu` (headless UI). Grade unchanged | diff --git a/docs/gaps.md b/docs/gaps.md index 914ff240c..6436b6396 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-31 · **Change:** minor (the Controller menu names each button as PlayStation and Switch controllers do; gap 6: widgets draw lines and SVG pictures (plugin API 6); gap 7: text reads only the fallback fonts it draws with; a board connector's path can be reshaped by dragging its path handles (#1710); gap 10: library analytics count insertions and show insertions and detachments over time; gap 6: a widget opens its plugin's panel (plugin API 5), whose callbacks redraw it; the keyboard's Menu key opens the context menu, as Shift+F10 does; gap 14: a collection of this file extends, following its edits at once; colour picker review fixes for text undo, fitted sidebars, moving controls and gradient variables; gap 6: plugin widgets have hover styles; gap 7: the browser draws a zoom's full render a band at a time; `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); Arabic draft catalog; gap 7: the browser refines a zoom's preview once the zoom has paused; gap 17: branches in live sessions, branch history entries, the list-merge rows brought up to date; gap 6: plugin widgets take text input and a property menu (plugin API 4); gap 24: board templates and tables; gap 24: a shared timer and voting sessions; gap 24: the board dot grid, connector ends dragged to re-attach, curved connectors; egui/eframe 0.37 verified for gap 18; known gaps from the #1311 review in gaps 4, 14, 15 and 16; gap 16: measurements kept on the canvas, a read-only layers panel in Dev Mode; gap 24: boards and their tail; Dev Mode tail: import lines in layer trees, compare with versions, an inspect-only canvas; gap 5: reviewed Turkish, Czech and Indonesian drafts, the Japanese draft completed; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; 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; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; 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: gap 14 down to its tail; the 2026-10-17 landings: `.fig` import fidelity, budgets for large files, the import report, a `.fig` copy from Save as and paste from another design app (gap 1); menus, the Actions palette, editor fixes, Selection colors and smart-selection spacing (gap 4); catalog counts re-measured (gap 5); bounded canvas pan overscan) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** minor (gap 10: library analytics list how many layers and files use each style and variable; the Controller menu names each button as PlayStation and Switch controllers do; gap 6: widgets draw lines and SVG pictures (plugin API 6); gap 7: text reads only the fallback fonts it draws with; a board connector's path can be reshaped by dragging its path handles (#1710); gap 10: library analytics count insertions and show insertions and detachments over time; gap 6: a widget opens its plugin's panel (plugin API 5), whose callbacks redraw it; the keyboard's Menu key opens the context menu, as Shift+F10 does; gap 14: a collection of this file extends, following its edits at once; colour picker review fixes for text undo, fitted sidebars, moving controls and gradient variables; gap 6: plugin widgets have hover styles; gap 7: the browser draws a zoom's full render a band at a time; `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); Arabic draft catalog; gap 7: the browser refines a zoom's preview once the zoom has paused; gap 17: branches in live sessions, branch history entries, the list-merge rows brought up to date; gap 6: plugin widgets take text input and a property menu (plugin API 4); gap 24: board templates and tables; gap 24: a shared timer and voting sessions; gap 24: the board dot grid, connector ends dragged to re-attach, curved connectors; egui/eframe 0.37 verified for gap 18; known gaps from the #1311 review in gaps 4, 14, 15 and 16; gap 16: measurements kept on the canvas, a read-only layers panel in Dev Mode; gap 24: boards and their tail; Dev Mode tail: import lines in layer trees, compare with versions, an inspect-only canvas; gap 5: reviewed Turkish, Czech and Indonesian drafts, the Japanese draft completed; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; 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; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; 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: gap 14 down to its tail; the 2026-10-17 landings: `.fig` import fidelity, budgets for large files, the import report, a `.fig` copy from Save as and paste from another design app (gap 1); menus, the Actions palette, editor fixes, Selection colors and smart-selection spacing (gap 4); catalog counts re-measured (gap 5); bounded canvas pan overscan) · **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 @@ -23,7 +23,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe | 7 | CPU canvas where Figma uses the GPU | Hardware/performance | 30–60 | [hardware-parity](hardware-parity.md) | | 8 | Platform QA: Linux and the browser left (Windows pass done 2026-10-11) | Stability/platforms | 10–20 | [hardware-parity](hardware-parity.md) | | 9 | Figma Draw: pen pressure on macOS/Linux tablets (multi-edit, brushes, pressure, text on a path and pattern fills landed) | Feature | 1–3 | [target-app-parity](target-app-parity.md) | -| 10 | Team libraries: no team-wide lists or accounts; analytics cover components only | Feature | 4–8 | [components-parity](components-parity.md) | +| 10 | Team libraries: no team-wide lists or accounts; analytics count insertions for components only | Feature | 4–8 | [components-parity](components-parity.md) | | 11 | Spell check | Feature | 4–8 | [target-app-parity](target-app-parity.md) | | 12 | Video fills; HEIC images | File format | 5–10 | [file-format-parity](file-format-parity.md) | | 13 | Vector tail: none left (boolean hit testing on the result landed 2026-10-11) | Feature | — | [target-app-parity](target-app-parity.md) | @@ -351,10 +351,11 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe - **Impact:** illustration-flavoured work moved into Figma Draw in 2025. - **Estimate:** 1–3 h once winit (or a platform tablet API behind it) reports pressure there. -### 10. Team libraries: no team-wide lists or accounts; analytics cover components only +### 10. Team libraries: no team-wide lists or accounts; analytics count insertions for components only - **Missing:** libraries listed for a whole team or organization (each file enables a library by - its link), accounts and per-person library permissions, analytics of styles and variables (a - component's instances, insertions and detachments are counted, the last two week by week), + its link), accounts and per-person library permissions, insertions, detachments and mode counts for + styles and variables (they count layers and files using them; a component's instances, + insertions and detachments are counted, the last two week by week), update notices pushed by the server (apps ask every 30 seconds while the Assets panel shows). - **Evidence:** a file's library is published to `figcraft-server` under the file and enabled in other files by a share link, with per-asset versions, updates with overrides kept, usage counts @@ -588,6 +589,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe | Date | Change | Summary | |---|---|---| +| 2026-10-31 | minor | Gap 10: library analytics list the layers and files using each style and variable; their insertions, detachments and modes stay on the missing list. | | 2026-10-31 | minor | Gap 15: the Controller menu names buttons as PlayStation and Switch controllers do; that item leaves the missing list. Estimate unchanged. Test: `present::tail_tests`. | | 2026-10-30 | minor | Gap 7: text reads only the fallback fonts it draws with: a face per fallback family and style, a file's bytes shared between families, the faces worked out once per style (first text laid out: 41 font files and 265 MiB read down to 3 files and 20 MiB on a Linux machine with Noto Sans CJK). Numbers unchanged | | 2026-10-31 | minor | Gap 6: widgets draw lines and SVG pictures (plugin API 6), so that item leaves the missing list. | diff --git a/docs/mcp.md b/docs/mcp.md index 0a8c8f534..fcf09fd7f 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -957,7 +957,7 @@ most when it can't be reached). `library.acceptUpdates` takes it, overrides kept. The app's own background checks ask only about team libraries added (`library.add`) or checked (`library.check {library?}`) on this computer, so opening a file that names another one contacts nothing (#1097). -- `library.usage {library?, days?}` → `{id, name, instances, detached, detachesKnown, days, +- `library.usage {library?, days?}` → `{id, name, assetsKnown, styles [{id, name, type, layers, files}], variables [{id, name, collection, layers, files}], instances, detached, detachesKnown, days, insertions, detachments, actionsKnown, weeks: [{week, insertions, detachments}], files: [{name, updated, instances, detached}], components: [{id, name, instances, files, detached, insertions, detachments}]}`: what the files using it reported (each fetch reports the asking document's name diff --git a/docs/roadmap.md b/docs/roadmap.md index 12b650046..17c4340eb 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-30 · **Change:** minor (the Controller menu names each button as PlayStation and Switch controllers do; widgets draw lines and SVG pictures (plugin API 6); a connector end dropped along a side of a shape attaches to that side (#1708); regression tests pin a rounded shape's one-sided stroke and SVG ids and font families without XML-forbidden characters (#1351, #1124); a damaged image is refused with a reason instead of showing as an empty box (#1474); SwiftUI and Compose code names a bound variable by its iOS or Android code syntax; the colour picker's Image tab shows a placeholder and Upload from computer, an asset preview, and a file dropped on it replaces the image, keeping the fill mode, crop and position (#1682); the Frame tool's Phone presets have the iPhone 17 family (#1691); Show in folder works in the Flatpak build and selects the file on Linux (#1701); a dashed line starts and ends with a half-length dash, on the canvas and in exports; the Pages, Layers and Assets search fields keep their text in the middle of the field (#1703); a board connector's path can be reshaped by dragging its path handles (#1710); boards have 16 more shapes (arrows, polygons, star, plus and flowchart symbols), in pickers that lay them out in a grid; library analytics count insertions and show insertions and detachments over the past 30 days, 90 days or year; a stroke's Custom style takes a dash pattern of any length as a comma-separated list (#1681); a control request can set its own timeout, up to 10 minutes (`timeoutMs`; `figcraft-cli mcp --attach PORT --timeout SECONDS`) (#1696); gap 6: a widget opens its plugin's panel (plugin API 5), whose callbacks redraw it; the keyboard's Menu key opens the context menu, as Shift+F10 does; an Open link action can open in place of the prototype, with "Open in new tab" off (gap 15); a collection of this file can be extended (Extend collection on its row, `variable.extendCollection`), its extension following its edits at once, and extended collections extend again in the file; the eyedropper with Cmd/Ctrl+Shift makes a colour style or variable of what it picks; a live session's welcome sends the document compressed to clients that read it, about seven times smaller (#1665); a control request that times out says truthfully whether it was dropped or is still running and will take effect, and only screenshots and input blame the window (#1673); a board keeps earlier votes' results, listed newest first in Results and deleted one at a time; a server can list the files shared from it now for everyone who reaches it, Home ▸ Online shows them and their links carry no secret (`team_files`, part of #742); a selected connector's bar on a board sets its start and end points, solid or dashed, its weight and any colour (#1596); cells copied from a spreadsheet paste onto a board as a table; a long layer name stays on one line in the Export button and the Dev Mode header, ending with an ellipsis, and no longer covers the Design panel (#1650); with hundreds of layers selected, the Design panel and the selection overlays stop going over the whole selection on every frame, so dragging 648 layers averages about 1.1 ms of UI per frame (#1571); plugin panels show pictures: an `image` control, base64 within 256 KB and 4,096 px a side, named by its label (gap 6); a drag moving many layers re-runs only what sits around them, not each layer's bindings, sizing, text and instance checks, on every move (#1571); damaged font tracking table offsets are checked instead of wrapping on 32-bit targets, and font indexing makes its existing offset bounds explicit (#1125); pasted layers whose image the clipboard lacks are named and show a placeholder that reads as missing (#1646); paint-style colours on layers pasted from another design app keep their style instead of turning black (#1638); Shift+Cmd/Ctrl+Enter on a selected board shape adds the next shape already joined to it, and goes on typing (#1597); a name typed for a variable or collection finds the one with that exact id before one named like it, and stored mode ids, font family variables and the panels' labels read exact ids (#1613); plugin widgets have hover styles: a layer's `hover` fill, stroke, opacity or text colour shows while the pointer is over it (gap 6); every file a command reads whole by path is a regular file within a cap that fits it: documents 16 GiB, placed images 256 MiB, CLI scripts 1 GiB; compact layer trees with fields and outline (M0.9, part of #996); while a drag only moves a large selection, its outlines, box, slots, spacing and Design panel values are worked out once and shifted, not rebuilt on every move (#1571); an imported or pasted file finds an installed family under the other names its fonts give it, and with other case and spacing, instead of reporting it missing (#1292); a panic in a grouped closure outside any command rolls the group back like a failed group: documents, selection, undo and redo, history limit and journal as before (#1085); a development example generates figcraft-cli's shell completions and man pages from its clap definitions (part of #1396); `.fig` ZIP image reads share a total byte budget and count skipped images in the import warnings (#1076); stored variable and collection references keep their identity when another object is named like their id (follow-up to #1542); a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; the server key is checked by comparing SHA-256 hashes of the presented and the kept key, as the deployment guide says (#1130); opening a file contacts no team library server it names until you check it (#1097); a hostile server can no longer grow a client's memory with made-up people, and a session remembers a bounded number of deleted layer ids (#1121); `doc.list` gives AI agents and plugins the open files' names, not their paths (#1117); with a large selection, frames where nothing changed reuse the selection's outlines, box and slots and the Design panel's instance and position values instead of working them out again (#1571); team libraries and plugins larger than the 16 MiB download burst can be downloaded again, more slowly (#1089); a batch keeps one undo snapshot, not one per step (#1099); files nested 256 levels deep import on the browser's 1 MB stack (#1101); SVG and PDF exports count pattern sources and the renders they embed from the layer's depth, so one cap bounds a whole export (#1082); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); joining and leaving a file nobody is editing reads and writes nothing on the server: it stays loaded a minute after everyone leaves (#1074); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); hovering the canvas hit-tests once per pointer move, not on every frame, and moving an auto layout frame no longer lays out its contents again on every move (#1571); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); `comment.people` lists names that were only @mentioned, and `comment.next` and `comment.prompt` carry the mentions too (#1415); grid auto-placement looks for free cells a 64-bit word at a time and skips what blocks it, with the same placements (#1083); with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); a recovery copy made while a version is previewed holds the file, not the version shown; a FigJam `.jam` file opens as a board (#1342); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); share links can comment: a third access level beside edit and view, whose holder sees the file live and adds, replies to, reacts to and resolves comments but can't change the design; Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); `comment.list {mentions}` lists only the threads that @mention a name (#1415); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); figcraft-cli parses its arguments with clap, following RFC-0012 (#1396); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); Thai, Lao, Khmer and Burmese lines break between words (#1219); an AI agent attached over MCP shows where it works (#1416); a manual overlay's position can be dragged on the canvas (#405); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); SVG import draws each pattern tile once per scale, within the layer budget (#1080); Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); M8: mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); M2: components can't contain their own instances, directly or through others; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); 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) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (library analytics list how many layers and files use each style and variable; the Controller menu names each button as PlayStation and Switch controllers do; widgets draw lines and SVG pictures (plugin API 6); a connector end dropped along a side of a shape attaches to that side (#1708); regression tests pin a rounded shape's one-sided stroke and SVG ids and font families without XML-forbidden characters (#1351, #1124); a damaged image is refused with a reason instead of showing as an empty box (#1474); SwiftUI and Compose code names a bound variable by its iOS or Android code syntax; the colour picker's Image tab shows a placeholder and Upload from computer, an asset preview, and a file dropped on it replaces the image, keeping the fill mode, crop and position (#1682); the Frame tool's Phone presets have the iPhone 17 family (#1691); Show in folder works in the Flatpak build and selects the file on Linux (#1701); a dashed line starts and ends with a half-length dash, on the canvas and in exports; the Pages, Layers and Assets search fields keep their text in the middle of the field (#1703); a board connector's path can be reshaped by dragging its path handles (#1710); boards have 16 more shapes (arrows, polygons, star, plus and flowchart symbols), in pickers that lay them out in a grid; library analytics count insertions and show insertions and detachments over the past 30 days, 90 days or year; a stroke's Custom style takes a dash pattern of any length as a comma-separated list (#1681); a control request can set its own timeout, up to 10 minutes (`timeoutMs`; `figcraft-cli mcp --attach PORT --timeout SECONDS`) (#1696); gap 6: a widget opens its plugin's panel (plugin API 5), whose callbacks redraw it; the keyboard's Menu key opens the context menu, as Shift+F10 does; an Open link action can open in place of the prototype, with "Open in new tab" off (gap 15); a collection of this file can be extended (Extend collection on its row, `variable.extendCollection`), its extension following its edits at once, and extended collections extend again in the file; the eyedropper with Cmd/Ctrl+Shift makes a colour style or variable of what it picks; a live session's welcome sends the document compressed to clients that read it, about seven times smaller (#1665); a control request that times out says truthfully whether it was dropped or is still running and will take effect, and only screenshots and input blame the window (#1673); a board keeps earlier votes' results, listed newest first in Results and deleted one at a time; a server can list the files shared from it now for everyone who reaches it, Home ▸ Online shows them and their links carry no secret (`team_files`, part of #742); a selected connector's bar on a board sets its start and end points, solid or dashed, its weight and any colour (#1596); cells copied from a spreadsheet paste onto a board as a table; a long layer name stays on one line in the Export button and the Dev Mode header, ending with an ellipsis, and no longer covers the Design panel (#1650); with hundreds of layers selected, the Design panel and the selection overlays stop going over the whole selection on every frame, so dragging 648 layers averages about 1.1 ms of UI per frame (#1571); plugin panels show pictures: an `image` control, base64 within 256 KB and 4,096 px a side, named by its label (gap 6); a drag moving many layers re-runs only what sits around them, not each layer's bindings, sizing, text and instance checks, on every move (#1571); damaged font tracking table offsets are checked instead of wrapping on 32-bit targets, and font indexing makes its existing offset bounds explicit (#1125); pasted layers whose image the clipboard lacks are named and show a placeholder that reads as missing (#1646); paint-style colours on layers pasted from another design app keep their style instead of turning black (#1638); Shift+Cmd/Ctrl+Enter on a selected board shape adds the next shape already joined to it, and goes on typing (#1597); a name typed for a variable or collection finds the one with that exact id before one named like it, and stored mode ids, font family variables and the panels' labels read exact ids (#1613); plugin widgets have hover styles: a layer's `hover` fill, stroke, opacity or text colour shows while the pointer is over it (gap 6); every file a command reads whole by path is a regular file within a cap that fits it: documents 16 GiB, placed images 256 MiB, CLI scripts 1 GiB; compact layer trees with fields and outline (M0.9, part of #996); while a drag only moves a large selection, its outlines, box, slots, spacing and Design panel values are worked out once and shifted, not rebuilt on every move (#1571); an imported or pasted file finds an installed family under the other names its fonts give it, and with other case and spacing, instead of reporting it missing (#1292); a panic in a grouped closure outside any command rolls the group back like a failed group: documents, selection, undo and redo, history limit and journal as before (#1085); a development example generates figcraft-cli's shell completions and man pages from its clap definitions (part of #1396); `.fig` ZIP image reads share a total byte budget and count skipped images in the import warnings (#1076); stored variable and collection references keep their identity when another object is named like their id (follow-up to #1542); a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; the server key is checked by comparing SHA-256 hashes of the presented and the kept key, as the deployment guide says (#1130); opening a file contacts no team library server it names until you check it (#1097); a hostile server can no longer grow a client's memory with made-up people, and a session remembers a bounded number of deleted layer ids (#1121); `doc.list` gives AI agents and plugins the open files' names, not their paths (#1117); with a large selection, frames where nothing changed reuse the selection's outlines, box and slots and the Design panel's instance and position values instead of working them out again (#1571); team libraries and plugins larger than the 16 MiB download burst can be downloaded again, more slowly (#1089); a batch keeps one undo snapshot, not one per step (#1099); files nested 256 levels deep import on the browser's 1 MB stack (#1101); SVG and PDF exports count pattern sources and the renders they embed from the layer's depth, so one cap bounds a whole export (#1082); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); joining and leaving a file nobody is editing reads and writes nothing on the server: it stays loaded a minute after everyone leaves (#1074); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); hovering the canvas hit-tests once per pointer move, not on every frame, and moving an auto layout frame no longer lays out its contents again on every move (#1571); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); `comment.people` lists names that were only @mentioned, and `comment.next` and `comment.prompt` carry the mentions too (#1415); grid auto-placement looks for free cells a 64-bit word at a time and skips what blocks it, with the same placements (#1083); with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); a recovery copy made while a version is previewed holds the file, not the version shown; a FigJam `.jam` file opens as a board (#1342); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); share links can comment: a third access level beside edit and view, whose holder sees the file live and adds, replies to, reacts to and resolves comments but can't change the design; Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); `comment.list {mentions}` lists only the threads that @mention a name (#1415); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); figcraft-cli parses its arguments with clap, following RFC-0012 (#1396); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); Thai, Lao, Khmer and Burmese lines break between words (#1219); an AI agent attached over MCP shows where it works (#1416); a manual overlay's position can be dragged on the canvas (#405); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); SVG import draws each pattern tile once per scale, within the layer budget (#1080); Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); M8: mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); M2: components can't contain their own instances, directly or through others; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); 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-31 | minor | M3, gap 10: library analytics cover styles and variables. A file reports how many of its layers use each of a library's styles and variables, and Show usage (`library.usage`) lists them beside the components, by a Type choice, with their layers and files. Layers inside instances count for their component. Insertions, detachments and modes aren't counted for them yet. Numbers unchanged. Tests: `library_assets_tests`. | | 2026-10-31 | minor | M5: a key/gamepad trigger's Controller menu shows, beside each button, what PlayStation and Nintendo Switch controllers call it ("Gamepad A" is ✕ and B). Triggers are stored by the button's place, as before. | | 2026-10-31 | minor | M11, gap 6: widgets draw lines and SVG pictures, as a Figma widget's `Line` and `SVG` do (plugin API 6; older plugins unchanged). A line is 100 long, black and 1 thick unless told otherwise, and turns around its top-left corner. An SVG comes in as locked layers, scaled to fit a size, within the widget's 256 layers. The Counter example uses both. Numbers unchanged. Tests: `widget_shapes_tests`, `widget_tests`. | | 2026-10-31 | patch | M12, #1708: a connector end dropped on the outer half of a shape takes the side it is towards, instead of only within 10 px of a side's middle, so a drop anywhere along an edge chooses that edge (dragging a selected connector's end and with the connector tool, ring included); a drop in the middle of a shape still takes the facing side. | diff --git a/docs/server.md b/docs/server.md index 23b6ecaa6..9d42cb0fc 100644 --- a/docs/server.md +++ b/docs/server.md @@ -250,9 +250,12 @@ which other files use without anyone copying the library file around. styles asks the server too, since you did that yourself. - **Usage.** Each time a file's app asks for the library it reports which of its components the file uses and how often, and then how many instances of each the file has detached and inserted - (counted in the file when you detach one, or place, paste or duplicate one). **Show usage** in - the library file (`library.usage`) lists the files and the instances and detaches of each - component, and the insertions and detachments of the past 30 days, 90 days or year. Files are + (counted in the file when you detach one, or place, paste or duplicate one), and how many of its + layers use each of the library's styles and variables. **Show usage** in the library file + (`library.usage`) lists the files and the instances and detaches of each component, the + insertions and detachments of the past 30 days, 90 days or year, and, by its Type choice, the + layers and files using each style and variable. Layers inside instances count for their + component. Files are told apart by their names; a file's report changes only when its usage does. The server counts what a file's totals grow past their highest in the week it hears of it, keeps a year of weeks, and counts an undo and a redo once. A server from before these counts refuses the reports From 6b28ef57dc0c02b401cbbcdd7b74fe291f699d41 Mon Sep 17 00:00:00 2001 From: BFlat Date: Sat, 31 Oct 2026 09:41:39 +0700 Subject: [PATCH 05/15] M4: Dash cap setting for Dash and Custom strokes (#1728) --- ROADMAP.md | 3 +- crates/doc/src/props.rs | 22 +++++++ crates/engine/src/automation/inspect.rs | 3 + crates/engine/src/cmd/edit/clipboard.rs | 1 + crates/engine/src/cmd/props.rs | 14 +++-- crates/engine/src/dash_cap_tests.rs | 71 +++++++++++++++++++++ crates/engine/src/lib.rs | 2 + crates/pathops/src/dash_cap_tests.rs | 77 +++++++++++++++++++++++ crates/pathops/src/lib.rs | 18 +++--- crates/pathops/src/width.rs | 17 ++--- crates/render/src/draw.rs | 1 + crates/render/src/width_tests.rs | 15 +++++ crates/svg/src/lib.rs | 2 +- crates/svg/src/tests.rs | 24 +++++++ crates/ui-egui/locales/ar.toml | 1 + crates/ui-egui/locales/bn.toml | 1 + crates/ui-egui/locales/bs.toml | 1 + crates/ui-egui/locales/cs.toml | 1 + crates/ui-egui/locales/de.toml | 1 + crates/ui-egui/locales/en.toml | 1 + crates/ui-egui/locales/es.toml | 1 + crates/ui-egui/locales/fi.toml | 1 + crates/ui-egui/locales/fr.toml | 1 + crates/ui-egui/locales/id.toml | 1 + crates/ui-egui/locales/it.toml | 1 + crates/ui-egui/locales/ja.toml | 1 + crates/ui-egui/locales/ka.toml | 1 + crates/ui-egui/locales/ko.toml | 1 + crates/ui-egui/locales/nl.toml | 1 + crates/ui-egui/locales/pl.toml | 1 + crates/ui-egui/locales/pt-BR.toml | 1 + crates/ui-egui/locales/ru.toml | 1 + crates/ui-egui/locales/sv.toml | 1 + crates/ui-egui/locales/th.toml | 1 + crates/ui-egui/locales/tr.toml | 1 + crates/ui-egui/locales/vi.toml | 1 + crates/ui-egui/locales/zh-Hans.toml | 1 + crates/ui-egui/src/dash_pattern_tests.rs | 37 ++++++++++- crates/ui-egui/src/panels/right/stroke.rs | 34 +++++++--- docs/file-format-parity.md | 5 +- docs/mcp.md | 3 + docs/roadmap.md | 3 +- docs/target-app-parity.md | 5 +- 43 files changed, 339 insertions(+), 41 deletions(-) create mode 100644 crates/engine/src/dash_cap_tests.rs create mode 100644 crates/pathops/src/dash_cap_tests.rs diff --git a/ROADMAP.md b/ROADMAP.md index 36fa0496f..8c258eca1 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-30 · **Change:** minor (library analytics list how many layers and files use each style and variable; the Controller menu names each button as PlayStation and Switch controllers do; widgets draw lines and SVG pictures (plugin API 6); a connector end dropped along a side of a shape attaches to that side (#1708); regression tests pin a rounded shape's one-sided stroke and SVG ids and font families without XML-forbidden characters (#1351, #1124); a damaged image is refused with a reason instead of showing as an empty box (#1474); an image copied in another app pastes again on the desktop, and in the browser for the first time (#492); SwiftUI and Compose code names a bound variable by its iOS or Android code syntax; the colour picker's Image tab shows a placeholder and Upload from computer, an asset preview, and a file dropped on it replaces the image, keeping the fill mode, crop and position (#1682); the Frame tool's Phone presets have the iPhone 17 family (#1691); Show in folder works in the Flatpak build and selects the file on Linux (#1701); a dashed line starts and ends with a half-length dash, on the canvas and in exports; the Pages, Layers and Assets search fields keep their text in the middle of the field (#1703); a board connector's path can be reshaped by dragging its path handles (#1710); boards have 16 more shapes (arrows, polygons, star, plus and flowchart symbols), in pickers that lay them out in a grid; library analytics count insertions and show insertions and detachments over the past 30 days, 90 days or year; a stroke's Custom style takes a dash pattern of any length as a comma-separated list (#1681); a control request can set its own timeout, up to 10 minutes (`timeoutMs`; `figcraft-cli mcp --attach PORT --timeout SECONDS`) (#1696); gap 6: a widget opens its plugin's panel (plugin API 5), whose callbacks redraw it; the keyboard's Menu key opens the context menu, as Shift+F10 does; an Open link action can open in place of the prototype, with "Open in new tab" off (gap 15); a collection of this file can be extended (Extend collection on its row, `variable.extendCollection`), its extension following its edits at once, and extended collections extend again in the file; the eyedropper with Cmd/Ctrl+Shift makes a colour style or variable of what it picks; a live session's welcome sends the document compressed to clients that read it, about seven times smaller (#1665); a control request that times out says truthfully whether it was dropped or is still running and will take effect, and only screenshots and input blame the window (#1673); a board keeps earlier votes' results, listed newest first in Results and deleted one at a time; the object a stamp will stick to is outlined while the stamp tool is over it or a stamp is dragged over it, and board objects take no drops (gap 24); a server can list the files shared from it now for everyone who reaches it, Home ▸ Online shows them and their links carry no secret (`team_files`, part of #742); team library analytics count detached instances per component and file (gap 10); skipping the Open link notice by command is saved with the preferences at once (gap 15); a selected connector's bar on a board sets its start and end points, solid or dashed, its weight and any colour (#1596); cells copied from a spreadsheet paste onto a board as a table; a long layer name stays on one line in the Export button and the Dev Mode header, ending with an ellipsis, and no longer covers the Design panel (#1650); with hundreds of layers selected, the Design panel and the selection overlays stop going over the whole selection on every frame, so dragging 648 layers averages about 1.1 ms of UI per frame (#1571); plugin panels show pictures: an `image` control, base64 within 256 KB and 4,096 px a side, named by its label (gap 6); `.fig` connector labels without a saved snapshot are kept, centred on their line (gap 1); a drag moving many layers re-runs only what sits around them, not each layer's bindings, sizing, text and instance checks, on every move (#1571); damaged font tracking table offsets are checked instead of wrapping on 32-bit targets, and font indexing makes its existing offset bounds explicit (#1125); Bosnian (`bs`) draft catalog; text style dedupe is O(spans) via a last-two cache and fingerprint buckets (#1102); pasted layers whose image the clipboard lacks are named and show a placeholder that reads as missing (#1646); paint-style colours on layers pasted from another design app keep their style instead of turning black (#1638); progress docs cleaned of what merges left twice: rows merged into one, repeated status items removed, and `cargo xtask docs` (in `ci`) fails on a repeated table row or status item; a name typed for a variable or collection finds the one with that exact id before one named like it, and stored mode ids, font family variables and the panels' labels read exact ids (#1613); plugin widgets have hover styles: a layer's `hover` fill, stroke, opacity or text colour shows while the pointer is over it (gap 6); `.fig` elbowed and curved connectors without a saved path are routed like board connectors instead of drawn straight (gap 1); every file a command reads whole by path is a regular file within a cap that fits it: documents 16 GiB, placed images 256 MiB, CLI scripts 1 GiB; `figcraft-cli connect publish` publishes a code repository's code connect mappings to a design file (gap 16); while a drag only moves a large selection, its outlines, box, slots, spacing and Design panel values are worked out once and shifted, not rebuilt on every move (#1571); Shift+Cmd/Ctrl+Enter on a selected board shape adds the next shape already joined to it, and goes on typing (#1597); an imported or pasted file finds an installed family under the other names its fonts give it, and with other case and spacing, instead of reporting it missing (#1292); a development example generates figcraft-cli's shell completions and man pages from its clap definitions (part of #1396); `.fig` ZIP image reads share a total byte budget and count skipped images in the import warnings (#1076); German terminology corrections (M10): gradient stops, OpenType stylistic sets and the Home-screen preference; a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; the server key is checked by comparing SHA-256 hashes of the presented and the kept key, as the deployment guide says (#1130); opening a file contacts no team library server it names until you check it (#1097); a hostile server can no longer grow a client's memory with made-up people, and a session remembers a bounded number of deleted layer ids (#1121); `doc.list` gives AI agents and plugins the open files' names, not their paths (#1117); with a large selection, frames where nothing changed reuse the selection's outlines, box and slots and the Design panel's instance and position values instead of working them out again (#1571); team libraries and plugins larger than the 16 MiB download burst can be downloaded again, more slowly (#1089); a batch keeps one undo snapshot, not one per step (#1099); files nested 256 levels deep import on the browser's 1 MB stack (#1101); SVG and PDF exports count pattern sources and the renders they embed from the layer's depth, so one cap bounds a whole export (#1082); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); joining and leaving a file nobody is editing reads and writes nothing on the server: it stays loaded a minute after everyone leaves (#1074); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); hovering the canvas hit-tests once per pointer move, not on every frame, and moving an auto layout frame no longer lays out its contents again on every move (#1571); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); `comment.people` lists names that were only @mentioned, and `comment.next` and `comment.prompt` carry the mentions too (#1415); grid auto-placement looks for free cells a 64-bit word at a time and skips what blocks it, with the same placements (#1083); with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); a recovery copy made while a version is previewed holds the file, not the version shown; a FigJam `.jam` file opens as a board (#1342); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); share links can comment: a third access level beside edit and view, whose holder sees the file live and adds, replies to, reacts to and resolves comments but can't change the design; Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); `comment.list {mentions}` lists only the threads that @mention a name (#1415); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); figcraft-cli parses its arguments with clap, following RFC-0012 (#1396); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); Thai, Lao, Khmer and Burmese lines break between words (#1219); an AI agent attached over MCP shows where it works (#1416); a manual overlay's position can be dragged on the canvas (#405); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); SVG import draws each pattern tile once per scale, within the layer budget (#1080); Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); components can't contain their own instances, directly or through other components; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); 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) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (a Dash cap setting (none, round or square) on Dash and Custom strokes, drawn on both ends of every dash; library analytics list how many layers and files use each style and variable; the Controller menu names each button as PlayStation and Switch controllers do; widgets draw lines and SVG pictures (plugin API 6); a connector end dropped along a side of a shape attaches to that side (#1708); regression tests pin a rounded shape's one-sided stroke and SVG ids and font families without XML-forbidden characters (#1351, #1124); a damaged image is refused with a reason instead of showing as an empty box (#1474); an image copied in another app pastes again on the desktop, and in the browser for the first time (#492); SwiftUI and Compose code names a bound variable by its iOS or Android code syntax; the colour picker's Image tab shows a placeholder and Upload from computer, an asset preview, and a file dropped on it replaces the image, keeping the fill mode, crop and position (#1682); the Frame tool's Phone presets have the iPhone 17 family (#1691); Show in folder works in the Flatpak build and selects the file on Linux (#1701); a dashed line starts and ends with a half-length dash, on the canvas and in exports; the Pages, Layers and Assets search fields keep their text in the middle of the field (#1703); a board connector's path can be reshaped by dragging its path handles (#1710); boards have 16 more shapes (arrows, polygons, star, plus and flowchart symbols), in pickers that lay them out in a grid; library analytics count insertions and show insertions and detachments over the past 30 days, 90 days or year; a stroke's Custom style takes a dash pattern of any length as a comma-separated list (#1681); a control request can set its own timeout, up to 10 minutes (`timeoutMs`; `figcraft-cli mcp --attach PORT --timeout SECONDS`) (#1696); gap 6: a widget opens its plugin's panel (plugin API 5), whose callbacks redraw it; the keyboard's Menu key opens the context menu, as Shift+F10 does; an Open link action can open in place of the prototype, with "Open in new tab" off (gap 15); a collection of this file can be extended (Extend collection on its row, `variable.extendCollection`), its extension following its edits at once, and extended collections extend again in the file; the eyedropper with Cmd/Ctrl+Shift makes a colour style or variable of what it picks; a live session's welcome sends the document compressed to clients that read it, about seven times smaller (#1665); a control request that times out says truthfully whether it was dropped or is still running and will take effect, and only screenshots and input blame the window (#1673); a board keeps earlier votes' results, listed newest first in Results and deleted one at a time; the object a stamp will stick to is outlined while the stamp tool is over it or a stamp is dragged over it, and board objects take no drops (gap 24); a server can list the files shared from it now for everyone who reaches it, Home ▸ Online shows them and their links carry no secret (`team_files`, part of #742); team library analytics count detached instances per component and file (gap 10); skipping the Open link notice by command is saved with the preferences at once (gap 15); a selected connector's bar on a board sets its start and end points, solid or dashed, its weight and any colour (#1596); cells copied from a spreadsheet paste onto a board as a table; a long layer name stays on one line in the Export button and the Dev Mode header, ending with an ellipsis, and no longer covers the Design panel (#1650); with hundreds of layers selected, the Design panel and the selection overlays stop going over the whole selection on every frame, so dragging 648 layers averages about 1.1 ms of UI per frame (#1571); plugin panels show pictures: an `image` control, base64 within 256 KB and 4,096 px a side, named by its label (gap 6); `.fig` connector labels without a saved snapshot are kept, centred on their line (gap 1); a drag moving many layers re-runs only what sits around them, not each layer's bindings, sizing, text and instance checks, on every move (#1571); damaged font tracking table offsets are checked instead of wrapping on 32-bit targets, and font indexing makes its existing offset bounds explicit (#1125); Bosnian (`bs`) draft catalog; text style dedupe is O(spans) via a last-two cache and fingerprint buckets (#1102); pasted layers whose image the clipboard lacks are named and show a placeholder that reads as missing (#1646); paint-style colours on layers pasted from another design app keep their style instead of turning black (#1638); progress docs cleaned of what merges left twice: rows merged into one, repeated status items removed, and `cargo xtask docs` (in `ci`) fails on a repeated table row or status item; a name typed for a variable or collection finds the one with that exact id before one named like it, and stored mode ids, font family variables and the panels' labels read exact ids (#1613); plugin widgets have hover styles: a layer's `hover` fill, stroke, opacity or text colour shows while the pointer is over it (gap 6); `.fig` elbowed and curved connectors without a saved path are routed like board connectors instead of drawn straight (gap 1); every file a command reads whole by path is a regular file within a cap that fits it: documents 16 GiB, placed images 256 MiB, CLI scripts 1 GiB; `figcraft-cli connect publish` publishes a code repository's code connect mappings to a design file (gap 16); while a drag only moves a large selection, its outlines, box, slots, spacing and Design panel values are worked out once and shifted, not rebuilt on every move (#1571); Shift+Cmd/Ctrl+Enter on a selected board shape adds the next shape already joined to it, and goes on typing (#1597); an imported or pasted file finds an installed family under the other names its fonts give it, and with other case and spacing, instead of reporting it missing (#1292); a development example generates figcraft-cli's shell completions and man pages from its clap definitions (part of #1396); `.fig` ZIP image reads share a total byte budget and count skipped images in the import warnings (#1076); German terminology corrections (M10): gradient stops, OpenType stylistic sets and the Home-screen preference; a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; the server key is checked by comparing SHA-256 hashes of the presented and the kept key, as the deployment guide says (#1130); opening a file contacts no team library server it names until you check it (#1097); a hostile server can no longer grow a client's memory with made-up people, and a session remembers a bounded number of deleted layer ids (#1121); `doc.list` gives AI agents and plugins the open files' names, not their paths (#1117); with a large selection, frames where nothing changed reuse the selection's outlines, box and slots and the Design panel's instance and position values instead of working them out again (#1571); team libraries and plugins larger than the 16 MiB download burst can be downloaded again, more slowly (#1089); a batch keeps one undo snapshot, not one per step (#1099); files nested 256 levels deep import on the browser's 1 MB stack (#1101); SVG and PDF exports count pattern sources and the renders they embed from the layer's depth, so one cap bounds a whole export (#1082); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); joining and leaving a file nobody is editing reads and writes nothing on the server: it stays loaded a minute after everyone leaves (#1074); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); hovering the canvas hit-tests once per pointer move, not on every frame, and moving an auto layout frame no longer lays out its contents again on every move (#1571); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); `comment.people` lists names that were only @mentioned, and `comment.next` and `comment.prompt` carry the mentions too (#1415); grid auto-placement looks for free cells a 64-bit word at a time and skips what blocks it, with the same placements (#1083); with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); a recovery copy made while a version is previewed holds the file, not the version shown; a FigJam `.jam` file opens as a board (#1342); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); share links can comment: a third access level beside edit and view, whose holder sees the file live and adds, replies to, reacts to and resolves comments but can't change the design; Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); `comment.list {mentions}` lists only the threads that @mention a name (#1415); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); figcraft-cli parses its arguments with clap, following RFC-0012 (#1396); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); Thai, Lao, Khmer and Burmese lines break between words (#1219); an AI agent attached over MCP shows where it works (#1416); a manual overlay's position can be dragged on the canvas (#405); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); SVG import draws each pattern tile once per scale, within the layer budget (#1080); Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); components can't contain their own instances, directly or through other components; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); 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 @@ -358,6 +358,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-31 | patch | M4: Dash and Custom strokes have a Dash cap (None, Round, Square) in the advanced stroke settings and `node.set {dashCap}`, on both ends of every dash on the canvas and in PNG, PDF and SVG. Unset, dashes keep taking the end point caps, so older files look the same; the saved file writes it only when set. Numbers unchanged | | 2026-10-31 | minor | M3, gap 10: library analytics cover styles and variables. A file reports how many of its layers use each of a library's styles and variables, and Show usage (`library.usage`) lists them beside the components, by a Type choice, with their layers and files. Layers inside instances count for their component. Insertions, detachments and modes aren't counted for them yet. Numbers unchanged. Tests: `library_assets_tests`. | | 2026-10-31 | minor | M5: a key/gamepad trigger's Controller menu shows, beside each button, what PlayStation and Nintendo Switch controllers call it ("Gamepad A" is ✕ and B). Triggers are stored by the button's place, as before. | | 2026-10-31 | minor | M11, gap 6: widgets draw lines and SVG pictures, as a Figma widget's `Line` and `SVG` do (plugin API 6; older plugins unchanged). A line is 100 long, black and 1 thick unless told otherwise, and turns around its top-left corner. An SVG comes in as locked layers, scaled to fit a size, within the widget's 256 layers. The Counter example uses both. Numbers unchanged. Tests: `widget_shapes_tests`, `widget_tests`. | diff --git a/crates/doc/src/props.rs b/crates/doc/src/props.rs index 92ec539c7..0c78681f2 100644 --- a/crates/doc/src/props.rs +++ b/crates/doc/src/props.rs @@ -28,6 +28,8 @@ pub enum StrokeCap { } impl StrokeCap { + /// The caps a dash can have. + pub const DASH: [StrokeCap; 3] = [StrokeCap::None, StrokeCap::Round, StrokeCap::Square]; pub const ALL: [StrokeCap; 8] = [ StrokeCap::None, StrokeCap::Round, @@ -53,6 +55,14 @@ impl StrokeCap { pub fn is_arrow(self) -> bool { !matches!(self, StrokeCap::None | StrokeCap::Round | StrokeCap::Square) } + /// The cap shape this draws on the stroke's band (arrowheads are drawn on top of it). + pub fn shape(self) -> StrokeCap { + match self { + StrokeCap::Round | StrokeCap::CircleFilled => StrokeCap::Round, + StrokeCap::Square | StrokeCap::DiamondFilled => StrokeCap::Square, + _ => StrokeCap::None, + } + } } #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] @@ -82,6 +92,9 @@ pub struct StrokeProps { pub miter_limit: f64, /// Dash pattern (dash, gap, …); empty = solid. pub dashes: Vec, + /// The cap of every dash, both ends (`None`: dashes take `cap_start` and `cap_end`). + #[serde(skip_serializing_if = "Option::is_none")] + pub dash_cap: Option, /// Variable width: how the width changes along the path (`None` = uniform). #[serde(skip_serializing_if = "Option::is_none")] pub width_profile: Option, @@ -151,6 +164,7 @@ impl Default for StrokeProps { join: StrokeJoin::Miter, miter_limit: 4.0, dashes: Vec::new(), + dash_cap: None, width_profile: None, brush: None, } @@ -158,6 +172,14 @@ impl Default for StrokeProps { } impl StrokeProps { + /// The start and end cap the band is drawn with: a dashed stroke's dash cap when it has one. + pub fn band_caps(&self) -> [StrokeCap; 2] { + match self.dash_cap.filter(|_| !self.dashes.is_empty()) { + Some(c) => [c, c], + None => [self.cap_start, self.cap_end], + } + } + /// The variable width profile, when there is one with points. pub fn profile(&self) -> Option<&WidthProfile> { self.width_profile.as_ref().filter(|p| !p.points.is_empty()) diff --git a/crates/engine/src/automation/inspect.rs b/crates/engine/src/automation/inspect.rs index 5be87a213..ca3afa1ce 100644 --- a/crates/engine/src/automation/inspect.rs +++ b/crates/engine/src/automation/inspect.rs @@ -251,6 +251,9 @@ fn paint_props(n: &Node, o: &mut serde_json::Map, options: &Inspe if !n.stroke.dashes.is_empty() { insert_prop(o, options, "dashes", || json!(n.stroke.dashes)); } + if let Some(cap) = n.stroke.dash_cap { + insert_prop(o, options, "dashCap", || json!(cap)); + } if n.stroke.join != StrokeJoin::Miter { insert_prop(o, options, "strokeJoin", || json!(n.stroke.join)); } else if n.stroke.miter_limit != 4.0 { diff --git a/crates/engine/src/cmd/edit/clipboard.rs b/crates/engine/src/cmd/edit/clipboard.rs index db28ccd2f..546aa863c 100644 --- a/crates/engine/src/cmd/edit/clipboard.rs +++ b/crates/engine/src/cmd/edit/clipboard.rs @@ -328,6 +328,7 @@ pub(super) fn copy_props_of(doc: &Document, id: NodeId) -> Value { "fills": n.fills, "strokes": n.strokes, "strokeWeight": n.stroke.weight, "strokeAlign": n.stroke.align, "strokeSides": n.stroke.sides, "startCap": n.stroke.cap_start, "endCap": n.stroke.cap_end, "strokeJoin": n.stroke.join, "miterLimit": n.stroke.miter_limit, "dashes": n.stroke.dashes, + "dashCap": n.stroke.dash_cap, "widthProfile": super::super::props::width_points_json(n.stroke.width_profile.as_ref()), "brush": n.stroke.brush, "effects": n.effects, "opacity": n.opacity, "blend": n.blend, }); diff --git a/crates/engine/src/cmd/props.rs b/crates/engine/src/cmd/props.rs index d89b25ae0..fe7fdcd74 100644 --- a/crates/engine/src/cmd/props.rs +++ b/crates/engine/src/cmd/props.rs @@ -18,7 +18,7 @@ pub const STROKE_SIDES: [&str; 4] = ["strokeTopWeight", "strokeRightWeight", "st /// Keys `node.set` understands (documented for agents). pub const KEYS: &str = "name, visible, locked, opacity (0-1 or \"50%\"), blend, x, y (relative to the parent frame; groups don't count), width, height, rotation (degrees), \ fills (paints or colours; gradients as {type: linearGradient|radialGradient|angularGradient|diamondGradient, stops: [\"#hex\", ...], from?, to?} too), fill (colour, {$var: variable id or name}, or \"none\"/null: no fill), strokes, stroke (colour, {$var: variable id or name}, or \"none\"/null: no stroke), strokeWeight, strokeAlign (inside|center|outside), strokeSides [t,r,b,l], strokeTopWeight / strokeRightWeight / strokeBottomWeight / strokeLeftWeight (one side; turns on individual strokes), \ -strokeCap / startCap / endCap, strokeJoin, miterAngle (degrees: sharper joins bevel; or miterLimit as a ratio), dashes [..], widthProfile (preset uniform|wedge|taper|eye|rounded|pinch, [[t, scale], ...] or null), brush (preset id, {preset, spacing, sizeJitter, rotationJitter, scatter, seed} or null), cornerRadius (n or [tl,tr,br,bl]), cornerSmoothing (0-1), effects, \ +strokeCap / startCap / endCap, strokeJoin, miterAngle (degrees: sharper joins bevel; or miterLimit as a ratio), dashes [..], dashCap (none|round|square; null: dashes take startCap and endCap), widthProfile (preset uniform|wedge|taper|eye|rounded|pinch, [[t, scale], ...] or null), brush (preset id, {preset, spacing, sizeJitter, rotationJitter, scatter, seed} or null), cornerRadius (n or [tl,tr,br,bl]), cornerSmoothing (0-1), effects, \ clip, isMask, layout (null|horizontal|vertical|grid|{...}), gap, crossGap, padding (n or [t,r,b,l]), paddingTop/Right/Bottom/Left, \ mainAlign (min|center|max, or the auto gaps spaceBetween|spaceAround|spaceEvenly), crossAlign (min|center|max|baseline: first text baselines, horizontal layouts), contentAlign (wrapped lines: auto = packed where crossAlign says | spaceBetween), wrap (rows of a horizontal layout, columns of a vertical one), strokesIncluded (the frame's inside stroke takes up space), reverseZ (canvas stacking: true = first on top), \ columnGap / rowGap (aliases of gap / crossGap), gridColumns, gridRows, columnTracks / rowTracks ([120 | \"1fr\" | \"auto\" | {sizing, value}]), \ @@ -581,6 +581,12 @@ fn apply_look(cmd: &str, doc: &mut Document, id: NodeId, k: &str, v: &Value) -> let d: Vec = v.as_array().map(|a| a.iter().filter_map(f64_of).map(|x| x.max(0.0)).collect()).unwrap_or_default(); doc.get_mut(id)?.stroke.dashes = d; } + "dashCap" => { + let cap = (!v.is_null()) + .then(|| enum_of(v).filter(|c| StrokeCap::DASH.contains(c)).ok_or_else(|| bad(cmd, "dashCap: none|round|square, or null"))) + .transpose()?; + doc.get_mut(id)?.stroke.dash_cap = cap; + } "widthProfile" => doc.get_mut(id)?.stroke.width_profile = width_profile_of(v).ok_or_else(|| bad(cmd, WIDTH_PROFILE_HELP))?, "brush" | "strokeBrush" => doc.get_mut(id)?.stroke.brush = brush_of(v).ok_or_else(|| bad(cmd, BRUSH_HELP))?, _ => return Ok(false), @@ -1120,9 +1126,9 @@ fn set_path(cmd: &str, doc: &mut Document, id: NodeId, v: &Value, props: &Map &'static str { match k { "fills" | "fill" => "fills", - "strokes" | "stroke" | "strokeWeight" | "strokeAlign" | "dashes" | "strokeCap" | "startCap" | "endCap" | "strokeJoin" | "strokeSides" - | "strokeTopWeight" | "strokeRightWeight" | "strokeBottomWeight" | "strokeLeftWeight" | "miterLimit" | "miterAngle" | "widthProfile" - | "brush" | "strokeBrush" => "strokes", + "strokes" | "stroke" | "strokeWeight" | "strokeAlign" | "dashes" | "dashCap" | "strokeCap" | "startCap" | "endCap" | "strokeJoin" + | "strokeSides" | "strokeTopWeight" | "strokeRightWeight" | "strokeBottomWeight" | "strokeLeftWeight" | "miterLimit" | "miterAngle" + | "widthProfile" | "brush" | "strokeBrush" => "strokes", "effects" => "effects", "opacity" | "blend" | "blendMode" => "opacity", "visible" => "visible", diff --git a/crates/engine/src/dash_cap_tests.rs b/crates/engine/src/dash_cap_tests.rs new file mode 100644 index 000000000..e9c8e04bc --- /dev/null +++ b/crates/engine/src/dash_cap_tests.rs @@ -0,0 +1,71 @@ +//! The Dash cap through the session: `node.set`, the saved file, inspection and copied properties. + +use serde_json::{Value, json}; + +use crate::Session; +use crate::params::id_of; +use figcraft_doc::{NodeId, StrokeCap}; + +/// A 200-long vector with a dashed 10 px black stroke. +fn line(s: &mut Session) -> NodeId { + let v = id_of(&s.execute("vector.create", &json!({"path": "M0 0 L200 0", "x": 0, "y": 0})).unwrap()["id"]).unwrap(); + s.execute("node.set", &json!({"id": v, "props": {"strokes": ["#000000"], "strokeWeight": 10, "strokeAlign": "center", "dashes": [20, 20]}})) + .unwrap(); + v +} + +fn set(s: &mut Session, id: NodeId, cap: Value) -> crate::Result { + s.execute("node.set", &json!({"id": id, "props": {"dashCap": cap}})) +} + +fn cap(s: &Session, id: NodeId) -> Option { + s.doc().unwrap().node(id).unwrap().stroke.dash_cap +} + +#[test] +fn node_set_takes_the_three_caps_and_null_clears_it() { + let mut s = Session::new(); + let l = line(&mut s); + assert_eq!(cap(&s, l), None, "unset by default"); + for (name, want) in [("round", StrokeCap::Round), ("square", StrokeCap::Square), ("none", StrokeCap::None)] { + set(&mut s, l, json!(name)).unwrap(); + assert_eq!(cap(&s, l), Some(want)); + } + for junk in [json!("lineArrow"), json!("triangleArrow"), json!("wobbly"), json!(3)] { + assert!(set(&mut s, l, junk).is_err()); + } + assert_eq!(cap(&s, l), Some(StrokeCap::None), "a rejected value changes nothing"); + set(&mut s, l, Value::Null).unwrap(); + assert_eq!(cap(&s, l), None); +} + +#[test] +fn it_saves_only_when_set_and_older_files_open_unset() { + let mut s = Session::new(); + let l = line(&mut s); + let bytes = |s: &Session| figcraft_format::save_bytes(s.doc().unwrap(), None).unwrap(); + let unset = figcraft_format::to_json(s.doc().unwrap()).unwrap().to_string(); + assert!(!unset.contains("dash_cap") && !unset.contains("dashCap"), "not written when unset"); + let (open, _) = figcraft_format::load_bytes(&bytes(&s)).unwrap(); + assert_eq!(open.node(l).unwrap().stroke.dash_cap, None); + set(&mut s, l, json!("round")).unwrap(); + let (open, _) = figcraft_format::load_bytes(&bytes(&s)).unwrap(); + assert_eq!(open.node(l).unwrap().stroke.dash_cap, Some(StrokeCap::Round)); +} + +#[test] +fn inspect_shows_it_and_copied_properties_carry_it() { + let mut s = Session::new(); + let (a, b) = (line(&mut s), line(&mut s)); + set(&mut s, a, json!("square")).unwrap(); + assert_eq!(s.execute("node.get", &json!({"id": a})).unwrap()["dashCap"], "square"); + assert!(s.execute("node.get", &json!({"id": b})).unwrap().get("dashCap").is_none(), "only when set"); + s.execute("edit.copyProperties", &json!({"id": a})).unwrap(); + s.execute("edit.pasteProperties", &json!({"ids": [b]})).unwrap(); + assert_eq!(cap(&s, b), Some(StrokeCap::Square)); + // Copying an unset stroke clears what it pastes over. + set(&mut s, a, Value::Null).unwrap(); + s.execute("edit.copyProperties", &json!({"id": a})).unwrap(); + s.execute("edit.pasteProperties", &json!({"ids": [b]})).unwrap(); + assert_eq!(cap(&s, b), None); +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 6bcd8c8c1..4c9537b02 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -269,6 +269,8 @@ mod cursor_tests; #[cfg(test)] mod dark_page_text_tests; #[cfg(test)] +mod dash_cap_tests; +#[cfg(test)] mod datafill_tests; #[cfg(test)] mod design_paste_tests; diff --git a/crates/pathops/src/dash_cap_tests.rs b/crates/pathops/src/dash_cap_tests.rs new file mode 100644 index 000000000..4da3c952a --- /dev/null +++ b/crates/pathops/src/dash_cap_tests.rs @@ -0,0 +1,77 @@ +//! The Dash cap draws both ends of every dash, on Dash and Custom strokes alike. Unset, dashes keep +//! the end point caps they have always taken, so older files look the same. + +use figcraft_doc::{StrokeAlign, StrokeCap, StrokeProps, WidthPreset}; +use figcraft_geom::Point; +use figcraft_geom::kurbo::{BezPath, Shape}; + +use crate::{stroke_center, stroke_outline}; + +/// 200 long: a `10, 10` pattern fits it as it is, a half dash at each end. +fn line() -> BezPath { + let mut p = BezPath::new(); + p.move_to((0.0, 0.0)); + p.line_to((200.0, 0.0)); + p +} + +fn dashed(dash_cap: Option) -> StrokeProps { + StrokeProps { weight: 4.0, dashes: vec![10.0, 10.0], dash_cap, align: StrokeAlign::Center, ..Default::default() } +} + +fn area(props: &StrokeProps) -> f64 { + stroke_center(&line(), 4.0, props).area().abs() +} + +#[test] +fn the_cap_draws_both_ends_of_each_dash() { + let dashes = 11.0; // Nine whole ones and the two halves at the ends. + let band = 200.0 / 2.0 * 4.0; + assert!((area(&dashed(Some(StrokeCap::None))) - band).abs() < 0.01); + let square = area(&dashed(Some(StrokeCap::Square))) - band; + assert!((square - dashes * 2.0 * 2.0 * 4.0).abs() < 0.01, "{square}"); + let round = area(&dashed(Some(StrokeCap::Round))) - band; + assert!((round - dashes * std::f64::consts::PI * 4.0).abs() < 1.0, "{round}"); +} + +#[test] +fn unset_keeps_the_end_point_caps_and_set_overrides_them() { + let caps = |dash_cap| StrokeProps { cap_start: StrokeCap::Round, cap_end: StrokeCap::Square, ..dashed(dash_cap) }; + let old = stroke_center(&line(), 4.0, &caps(None)); + assert!(old.area().abs() > area(&dashed(None)), "dashes still take them"); + assert_eq!(caps(None).band_caps(), [StrokeCap::Round, StrokeCap::Square]); + assert_eq!(area(&caps(Some(StrokeCap::None))), area(&dashed(Some(StrokeCap::None)))); +} + +#[test] +fn a_solid_stroke_ignores_it() { + let solid = |dash_cap| StrokeProps { dashes: vec![], cap_end: StrokeCap::Square, ..dashed(dash_cap) }; + assert_eq!(area(&solid(Some(StrokeCap::Round))), area(&solid(None))); +} + +#[test] +fn zero_length_dashes_become_dots_of_the_cap() { + let dots = |cap| StrokeProps { dashes: vec![0.0, 20.0], ..dashed(Some(cap)) }; + assert!(area(&dots(StrokeCap::Round)) > 100.0); + assert!((area(&dots(StrokeCap::Square)) - 11.0 * 16.0).abs() < 0.1); + assert!(area(&dots(StrokeCap::None)) < 1e-6); +} + +#[test] +fn arrowheads_stay_on_the_path_ends() { + let arrow = |dash_cap| StrokeProps { cap_end: StrokeCap::TriangleArrow, ..dashed(dash_cap) }; + let p = stroke_outline(&line(), &arrow(Some(StrokeCap::Round)), false); + let plain = stroke_outline(&line(), &dashed(Some(StrokeCap::Round)), false); + assert!(p.bounding_box().y1 > plain.bounding_box().y1, "the head is still drawn"); +} + +#[test] +fn a_variable_width_dash_takes_it_too() { + let wide = |cap| StrokeProps { width_profile: WidthPreset::Eye.profile(), ..dashed(cap) }; + let (none, square) = + (stroke_outline(&line(), &wide(Some(StrokeCap::None)), false), stroke_outline(&line(), &wide(Some(StrokeCap::Square)), false)); + // The dash at the middle ends at 105. + let past = Point::new(106.0, 0.0); + assert_eq!(none.winding(past), 0); + assert_ne!(square.winding(past), 0); +} diff --git a/crates/pathops/src/lib.rs b/crates/pathops/src/lib.rs index a684fd781..e69cb9bb1 100644 --- a/crates/pathops/src/lib.rs +++ b/crates/pathops/src/lib.rs @@ -16,6 +16,8 @@ mod budget; #[cfg(test)] mod budget_tests; #[cfg(test)] +mod dash_cap_tests; +#[cfg(test)] mod dash_ends_tests; #[cfg(test)] mod dot_dash_tests; @@ -314,9 +316,9 @@ pub fn union_faces<'a>(faces: impl IntoIterator) -> Result Cap { - match c { - StrokeCap::Round | StrokeCap::CircleFilled => Cap::Round, - StrokeCap::Square | StrokeCap::DiamondFilled => Cap::Square, + match c.shape() { + StrokeCap::Round => Cap::Round, + StrokeCap::Square => Cap::Square, _ => Cap::Butt, } } @@ -339,15 +341,17 @@ pub fn stroke_center(path: &BezPath, width: f64, props: &StrokeProps) -> BezPath return BezPath::new(); } let width = width.min(MAX_STROKE_WIDTH); + let caps = props.band_caps(); let style = kurbo::Stroke::new(width) .with_join(kjoin(props.join)) .with_miter_limit(props.miter_limit.clamp(1.0, 100.0)) - .with_start_cap(kcap(props.cap_start)) - .with_end_cap(kcap(props.cap_end)); + .with_start_cap(kcap(caps[0])) + .with_end_cap(kcap(caps[1])); // Each subpath restarts the pattern, so it can add up to one more pattern's worth of dashes. let subpaths = path.elements().iter().filter(|e| matches!(e, PathEl::MoveTo(_))).count() as f64; - let round = [props.cap_start, props.cap_end].into_iter().any(|c| matches!(kcap(c), Cap::Round)); - let pattern = width::dash_pattern(props).map(|p| p.into_iter().map(|d| if round && d == 0.0 { 1e-3 } else { d }).collect::>()); + // A zero-length dash is a dot of its cap, in viewers too; older squares stay undrawn here. + let dots = caps.into_iter().any(|c| matches!(kcap(c), Cap::Round)) || props.dash_cap == Some(StrokeCap::Square); + let pattern = width::dash_pattern(props).map(|p| p.into_iter().map(|d| if dots && d == 0.0 { 1e-3 } else { d }).collect::>()); let opts = kurbo::StrokeOpts::default(); let Some(pat) = pattern.filter(|p| width::dashes_fit(path.perimeter(1.0) + subpaths * p.iter().sum::(), p)) else { return kurbo::stroke(path.iter(), &style, &opts, 0.05); diff --git a/crates/pathops/src/width.rs b/crates/pathops/src/width.rs index ad47ad23f..c1f2d2de3 100644 --- a/crates/pathops/src/width.rs +++ b/crates/pathops/src/width.rs @@ -53,16 +53,6 @@ struct Style { cap_end: StrokeCap, } -/// The cap shape an end cap draws (arrowheads are added by the caller; their stroke ends round -/// or square like the uniform stroke does). -fn cap_shape(c: StrokeCap) -> StrokeCap { - match c { - StrokeCap::Round | StrokeCap::CircleFilled => StrokeCap::Round, - StrokeCap::Square | StrokeCap::DiamondFilled => StrokeCap::Square, - _ => StrokeCap::None, - } -} - /// Points of an arc around `c` from the direction of `from` turning by `sweep` radians, with the /// radius going from `r0` to `r1` (ends excluded). fn arc(out: &mut Vec, c: Point, from: Vec2, sweep: f64, r0: f64, r1: f64) { @@ -134,7 +124,7 @@ fn side(segs: &[Seg], sigma: f64, closed: bool, style: Style) -> Vec { fn cap(out: &mut Vec, c: Point, dir: Vec2, from: f64, to: f64, kind: StrokeCap) { let n = dir.turn_90(); let (m, r) = (c + n * ((from - to) / 2.0), (from + to) / 2.0); - match cap_shape(kind) { + match kind.shape() { StrokeCap::Square => { out.push(m + n * r + dir * r); out.push(m - n * r + dir * r); @@ -172,7 +162,7 @@ fn outline_piece(path: &mut BezPath, pts: &[Point], hw: &[Half], closed: bool, s // A zero-length piece is a dot with round caps. let r = hw.first().map_or(0.0, |h| (h[0] + h[1]) / 2.0); if let Some(p) = pts.first() - && cap_shape(style.cap_start) == StrokeCap::Round + && style.cap_start.shape() == StrokeCap::Round && r > 0.0 { path.extend(kurbo::Shape::path_elements(&kurbo::Circle::new(*p, r), FLATTEN_TOL)); @@ -309,7 +299,8 @@ pub fn variable_outline(path: &BezPath, width: f64, props: &StrokeProps, profile } let profile = profile.sanitized(); let lines = polylines(path)?; - let style = Style { join: props.join, miter_limit: props.miter_limit.clamp(1.0, 100.0), cap_start: props.cap_start, cap_end: props.cap_end }; + let [cap_start, cap_end] = props.band_caps(); + let style = Style { join: props.join, miter_limit: props.miter_limit.clamp(1.0, 100.0), cap_start, cap_end }; let pattern = dash_pattern(props); let mut budget = MAX_WIDTH_SAMPLES; let mut out = BezPath::new(); diff --git a/crates/render/src/draw.rs b/crates/render/src/draw.rs index fcf035c69..f48899aea 100644 --- a/crates/render/src/draw.rs +++ b/crates/render/src/draw.rs @@ -254,6 +254,7 @@ fn cached_stroke_outline(n: &Node, path: &BezPath, closed: bool) -> Arc d.to_bits().hash(&mut h); } props.dashes.len().hash(&mut h); + props.dash_cap.map(|c| c as u8).hash(&mut h); // Points' own caps and joins and the fill rule Inside/Outside clip by (vector networks). let net = match &n.kind { NodeKind::Vector { network } => Some(network), diff --git a/crates/render/src/width_tests.rs b/crates/render/src/width_tests.rs index 8c64458d8..b1794483d 100644 --- a/crates/render/src/width_tests.rs +++ b/crates/render/src/width_tests.rs @@ -25,6 +25,21 @@ fn render(d: &figcraft_doc::Document, p: figcraft_doc::NodeId) -> Pixmap { render_view(d, p, &View { origin: (0.0, 0.0), scale: 1.0, width: 100, height: 100 }, &Options::default()).unwrap() } +/// The stroke outline is cached: changing the dash cap alone has to redraw it. +#[test] +fn changing_the_dash_cap_redraws_the_dashes() { + let (mut d, p) = line_doc(None); + let line = d.node(p).unwrap().children[0]; + let n = d.get_mut(line).unwrap(); + (n.stroke.weight, n.stroke.dashes) = (10.0, vec![20.0, 20.0]); + // Two 40-long patterns fit the 80-long line, so a whole dash ends at x = 60 on the page. + assert!(!dark(&render(&d, p), 62, 50), "butt: a gap"); + d.get_mut(line).unwrap().stroke.dash_cap = Some(figcraft_doc::StrokeCap::Square); + assert!(dark(&render(&d, p), 62, 50), "square: 5 further"); + d.get_mut(line).unwrap().stroke.dash_cap = Some(figcraft_doc::StrokeCap::None); + assert!(!dark(&render(&d, p), 62, 50)); +} + #[test] fn wedge_profile_tapers_the_stroke() { let (d, p) = line_doc(WidthPreset::Wedge.profile()); diff --git a/crates/svg/src/lib.rs b/crates/svg/src/lib.rs index a95e3b705..de98795d3 100644 --- a/crates/svg/src/lib.rs +++ b/crates/svg/src/lib.rs @@ -819,7 +819,7 @@ impl W<'_> { } _ => return None, }; - let cap = match k.cap_start { + let cap = match k.band_caps()[0] { StrokeCap::Round => " stroke-linecap=\"round\"", StrokeCap::Square => " stroke-linecap=\"square\"", _ => "", diff --git a/crates/svg/src/tests.rs b/crates/svg/src/tests.rs index 56dfd21a3..c3fb75f8e 100644 --- a/crates/svg/src/tests.rs +++ b/crates/svg/src/tests.rs @@ -367,6 +367,30 @@ fn dashed(kind: NodeKind, dashes: Vec) -> String { export_svg(&d, &[n], &SvgOptions { simplify_stroke: true, ..SvgOptions::default() }).unwrap() } +/// The `stroke-linecap` a dashed or solid line with a round start cap and `dash_cap` writes. +fn linecap(dashes: Vec, dash_cap: Option) -> Option { + let mut d = Document::new("dash cap"); + let p = d.first_page().unwrap(); + let n = d.create(p, NodeKind::Line, "Line", 0.0, 0.0, 100.0, 0.0).unwrap(); + let node = d.get_mut(n).unwrap(); + node.strokes = vec![Paint::solid(Color::from_hex("000000").unwrap())]; + node.stroke.align = figcraft_doc::StrokeAlign::Center; + (node.stroke.cap_start, node.stroke.cap_end) = (figcraft_doc::StrokeCap::Round, figcraft_doc::StrokeCap::Round); + (node.stroke.dashes, node.stroke.dash_cap) = (dashes, dash_cap); + let svg = export_svg(&d, &[n], &SvgOptions { simplify_stroke: true, ..SvgOptions::default() }).unwrap(); + assert!(svg.contains("stroke-width"), "a stroke, not an outline: {svg}"); + svg.split(" stroke-linecap=\"").nth(1).and_then(|r| r.split('"').next()).map(str::to_string) +} + +#[test] +fn a_dash_cap_writes_the_line_cap_of_a_dashed_stroke() { + use figcraft_doc::StrokeCap::{None as Butt, Square}; + assert_eq!(linecap(vec![4.0, 4.0], None).as_deref(), Some("round"), "unset: the end point cap"); + assert_eq!(linecap(vec![4.0, 4.0], Some(Square)).as_deref(), Some("square")); + assert_eq!(linecap(vec![4.0, 4.0], Some(Butt)), None); + assert_eq!(linecap(vec![], Some(Square)).as_deref(), Some("round"), "solid: it doesn't apply"); +} + /// A viewer draws what the canvas does: an open line's dashes fitted and started half a dash in. #[test] fn a_dashed_line_exports_as_the_canvas_draws_it() { diff --git a/crates/ui-egui/locales/ar.toml b/crates/ui-egui/locales/ar.toml index d428285ae..51ce2ab38 100644 --- a/crates/ui-egui/locales/ar.toml +++ b/crates/ui-egui/locales/ar.toml @@ -392,6 +392,7 @@ status = "draft — needs native review" "Dark" = "داكن" "Darken" = "تعتيم" "Dash" = "شرطة" +"Dash cap" = "طرف الشرطة" "Dashes" = "الشرطات" "Dashes take numbers separated by commas" = "تقبل الشرطات أرقامًا مفصولة بفواصل" "Dashboard" = "لوحة المعلومات" diff --git a/crates/ui-egui/locales/bn.toml b/crates/ui-egui/locales/bn.toml index 4fb0a1648..9f2e6421a 100644 --- a/crates/ui-egui/locales/bn.toml +++ b/crates/ui-egui/locales/bn.toml @@ -372,6 +372,7 @@ status = "draft — needs native review" "Dark" = "ডার্ক" "Darken" = "ডার্কেন" "Dash" = "ড্যাশ" +"Dash cap" = "ড্যাশ ক্যাপ" "Dashes" = "ড্যাশগুলো" "Dashes take numbers separated by commas" = "ড্যাশগুলো কমা দিয়ে আলাদা করা সংখ্যা হবে" "Dashboard" = "ড্যাশবোর্ড" diff --git a/crates/ui-egui/locales/bs.toml b/crates/ui-egui/locales/bs.toml index fcb52f539..efc380f58 100644 --- a/crates/ui-egui/locales/bs.toml +++ b/crates/ui-egui/locales/bs.toml @@ -395,6 +395,7 @@ status = "draft — needs native review" "Dark" = "Tamna" "Darken" = "Zatamni" "Dash" = "Crtica" +"Dash cap" = "Završetak crtice" "Dashes" = "Crtice" "Dashes take numbers separated by commas" = "Crtice su brojevi odvojeni zarezima" "Dashboard" = "Kontrolna tabla" diff --git a/crates/ui-egui/locales/cs.toml b/crates/ui-egui/locales/cs.toml index 86a8cef32..e77610771 100644 --- a/crates/ui-egui/locales/cs.toml +++ b/crates/ui-egui/locales/cs.toml @@ -362,6 +362,7 @@ status = "draft — needs native review" "Dark" = "Tmavý" "Darken" = "Ztmavit" "Dash" = "Čárkování" +"Dash cap" = "Zakončení čárek" "Dashes" = "Čárkování" "Dashes take numbers separated by commas" = "Čárkování zadejte jako čísla oddělená čárkami" "Dashboard" = "Přehled" diff --git a/crates/ui-egui/locales/de.toml b/crates/ui-egui/locales/de.toml index ee2cb90ab..e6d13bbea 100644 --- a/crates/ui-egui/locales/de.toml +++ b/crates/ui-egui/locales/de.toml @@ -362,6 +362,7 @@ status = "draft — needs native review" "Dark" = "Dunkel" "Darken" = "Abdunkeln" "Dash" = "Gestrichelt" +"Dash cap" = "Strichende" "Dashes" = "Striche" "Dashes take numbers separated by commas" = "Striche sind Zahlen, durch Kommas getrennt" "Decoration style" = "Linienstil" diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index ff084163c..4a8781c10 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -400,6 +400,7 @@ name = "English" "Dark" = "Dark" "Darken" = "Darken" "Dash" = "Dash" +"Dash cap" = "Dash cap" "Dashes" = "Dashes" "Dashes take numbers separated by commas" = "Dashes take numbers separated by commas" "Dashboard" = "Dashboard" diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index 5ff5ba2f1..3b1f7296e 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -396,6 +396,7 @@ status = "draft — needs native review" "Dark" = "Oscuro" "Darken" = "Oscurecer" "Dash" = "Discontinuo" +"Dash cap" = "Remate de guion" "Dashes" = "Guiones" "Dashes take numbers separated by commas" = "Los guiones son números separados por comas" "Dashboard" = "Panel de control" diff --git a/crates/ui-egui/locales/fi.toml b/crates/ui-egui/locales/fi.toml index 2a83954f8..dff5adf9c 100644 --- a/crates/ui-egui/locales/fi.toml +++ b/crates/ui-egui/locales/fi.toml @@ -396,6 +396,7 @@ status = "draft — needs native review" "Dark" = "Tumma" "Darken" = "Tummenna" "Dash" = "Katkoviiva" +"Dash cap" = "Katkoviivan pää" "Dashes" = "Katkoviivat" "Dashes take numbers separated by commas" = "Anna katkoviivat pilkuilla erotettuina lukuina" "Dashboard" = "Koontinäkymä" diff --git a/crates/ui-egui/locales/fr.toml b/crates/ui-egui/locales/fr.toml index 3b024c5b3..42a35007b 100644 --- a/crates/ui-egui/locales/fr.toml +++ b/crates/ui-egui/locales/fr.toml @@ -372,6 +372,7 @@ status = "draft — needs native review" "Dark" = "Sombre" "Darken" = "Assombrir" "Dash" = "Tirets" +"Dash cap" = "Extrémité des tirets" "Dashes" = "Tirets" "Dashes take numbers separated by commas" = "Les tirets sont des nombres séparés par des virgules" "Dashboard" = "Tableau de bord" diff --git a/crates/ui-egui/locales/id.toml b/crates/ui-egui/locales/id.toml index 5b710363a..a3457d820 100644 --- a/crates/ui-egui/locales/id.toml +++ b/crates/ui-egui/locales/id.toml @@ -377,6 +377,7 @@ status = "draft — needs native review" "Dark" = "Gelap" "Darken" = "Gelapkan" "Dash" = "Putus-putus" +"Dash cap" = "Ujung garis putus" "Dashes" = "Garis putus" "Dashes take numbers separated by commas" = "Garis putus berupa angka yang dipisahkan koma" "Dashboard" = "Dasbor" diff --git a/crates/ui-egui/locales/it.toml b/crates/ui-egui/locales/it.toml index ecdfd0c11..ce54d15db 100644 --- a/crates/ui-egui/locales/it.toml +++ b/crates/ui-egui/locales/it.toml @@ -356,6 +356,7 @@ status = "draft — needs native review" "Dark" = "Scuro" "Darken" = "Scurisci" "Dash" = "Tratteggio" +"Dash cap" = "Estremità tratto" "Dashes" = "Trattini" "Dashes take numbers separated by commas" = "I trattini sono numeri separati da virgole" "Dashboard" = "Dashboard" diff --git a/crates/ui-egui/locales/ja.toml b/crates/ui-egui/locales/ja.toml index 0179ec957..8e2334f4b 100644 --- a/crates/ui-egui/locales/ja.toml +++ b/crates/ui-egui/locales/ja.toml @@ -370,6 +370,7 @@ status = "draft — needs native review" "Dark" = "ダーク" "Darken" = "比較(暗)" "Dash" = "破線" +"Dash cap" = "破線の端" "Dashes" = "ダッシュ" "Dashes take numbers separated by commas" = "ダッシュはカンマ区切りの数値で入力します" "Dashboard" = "ダッシュボード" diff --git a/crates/ui-egui/locales/ka.toml b/crates/ui-egui/locales/ka.toml index 6564cda55..131aeca74 100644 --- a/crates/ui-egui/locales/ka.toml +++ b/crates/ui-egui/locales/ka.toml @@ -377,6 +377,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "Dark" = "ბნელი" "Darken" = "ჩამუქება" "Dash" = "ტირე" +"Dash cap" = "ტირის ბოლო" "Dashes" = "ტირეები" "Dashes take numbers separated by commas" = "ტირეები მძიმით გამოყოფილი რიცხვებია" "Dashboard" = "სამართავი დაფა" diff --git a/crates/ui-egui/locales/ko.toml b/crates/ui-egui/locales/ko.toml index 75b700699..691c1cbc7 100644 --- a/crates/ui-egui/locales/ko.toml +++ b/crates/ui-egui/locales/ko.toml @@ -358,6 +358,7 @@ status = "draft — needs native review" "Dark" = "다크" "Darken" = "어둡게 하기" "Dash" = "대시" +"Dash cap" = "대시 끝 모양" "Dashes" = "대시" "Dashes take numbers separated by commas" = "대시는 쉼표로 구분한 숫자로 입력하세요" "Decoration style" = "장식 스타일" diff --git a/crates/ui-egui/locales/nl.toml b/crates/ui-egui/locales/nl.toml index fe4535ddc..027b0b3a2 100644 --- a/crates/ui-egui/locales/nl.toml +++ b/crates/ui-egui/locales/nl.toml @@ -374,6 +374,7 @@ status = "draft — needs native review" "Dark" = "Donker" "Darken" = "Darken" "Dash" = "Streep" +"Dash cap" = "Streepuiteinde" "Dashes" = "Streepjes" "Dashes take numbers separated by commas" = "Streepjes zijn getallen gescheiden door komma's" "Dashboard" = "Dashboard" diff --git a/crates/ui-egui/locales/pl.toml b/crates/ui-egui/locales/pl.toml index 6be03546a..28406332a 100644 --- a/crates/ui-egui/locales/pl.toml +++ b/crates/ui-egui/locales/pl.toml @@ -373,6 +373,7 @@ name = "Polski" "Dark" = "Ciemny" "Darken" = "Ciemniej" "Dash" = "Kreska" +"Dash cap" = "Zakończenie kreski" "Dashes" = "Kreski" "Dashes take numbers separated by commas" = "Kreski to liczby oddzielone przecinkami" "Dashboard" = "Pulpit" diff --git a/crates/ui-egui/locales/pt-BR.toml b/crates/ui-egui/locales/pt-BR.toml index 36448325f..7924ed0f8 100644 --- a/crates/ui-egui/locales/pt-BR.toml +++ b/crates/ui-egui/locales/pt-BR.toml @@ -357,6 +357,7 @@ status = "draft — needs native review" "Dark" = "Escuro" "Darken" = "Escurecer" "Dash" = "Tracejado" +"Dash cap" = "Terminação do traço" "Dashes" = "Traços" "Dashes take numbers separated by commas" = "Os traços são números separados por vírgulas" "Decoration style" = "Estilo da decoração" diff --git a/crates/ui-egui/locales/ru.toml b/crates/ui-egui/locales/ru.toml index 2a3f373e3..90e6c607b 100644 --- a/crates/ui-egui/locales/ru.toml +++ b/crates/ui-egui/locales/ru.toml @@ -373,6 +373,7 @@ name = "Русский" "Dark" = "Тёмная" "Darken" = "Затемнение" "Dash" = "Штрих" +"Dash cap" = "Конец штриха" "Dashes" = "Штрихи" "Dashes take numbers separated by commas" = "Штрихи задаются числами через запятую" "Dashboard" = "Панель аналитики" diff --git a/crates/ui-egui/locales/sv.toml b/crates/ui-egui/locales/sv.toml index 9521b9067..dd3d35953 100644 --- a/crates/ui-egui/locales/sv.toml +++ b/crates/ui-egui/locales/sv.toml @@ -397,6 +397,7 @@ name = "Svenska" "Dark" = "Mörk" "Darken" = "Mörkare" "Dash" = "Streck" +"Dash cap" = "Streckände" "Dashes" = "Streck" "Dashes take numbers separated by commas" = "Streck anges som tal åtskilda med kommatecken" "Dashboard" = "Instrumentpanel" diff --git a/crates/ui-egui/locales/th.toml b/crates/ui-egui/locales/th.toml index f56934b34..d3268bdf9 100644 --- a/crates/ui-egui/locales/th.toml +++ b/crates/ui-egui/locales/th.toml @@ -371,6 +371,7 @@ status = "draft — needs native review" "Dark" = "มืด" "Darken" = "Darken" "Dash" = "เส้นประ" +"Dash cap" = "ปลายเส้นประ" "Dashes" = "เส้นประ" "Dashes take numbers separated by commas" = "ใส่เส้นประเป็นตัวเลขคั่นด้วยจุลภาค" "Dashboard" = "แดชบอร์ด" diff --git a/crates/ui-egui/locales/tr.toml b/crates/ui-egui/locales/tr.toml index 21ea8c74b..6c80e6864 100644 --- a/crates/ui-egui/locales/tr.toml +++ b/crates/ui-egui/locales/tr.toml @@ -366,6 +366,7 @@ name = "Türkçe" "Dark" = "Koyu" "Darken" = "Koyulaştır" "Dash" = "Kesik çizgi" +"Dash cap" = "Kesik çizgi ucu" "Dashes" = "Kesik çizgiler" "Dashes take numbers separated by commas" = "Kesik çizgiler virgülle ayrılmış sayılardır" "Dashboard" = "Pano" diff --git a/crates/ui-egui/locales/vi.toml b/crates/ui-egui/locales/vi.toml index 6002a88f7..8149ed30b 100644 --- a/crates/ui-egui/locales/vi.toml +++ b/crates/ui-egui/locales/vi.toml @@ -357,6 +357,7 @@ status = "draft — needs native review" "Dark" = "Tối" "Darken" = "Làm tối" "Dash" = "Nét đứt" +"Dash cap" = "Đầu nét đứt" "Dashes" = "Các nét đứt" "Dashes take numbers separated by commas" = "Nét đứt là các số cách nhau bằng dấu phẩy" "Dashboard" = "Bảng điều khiển" diff --git a/crates/ui-egui/locales/zh-Hans.toml b/crates/ui-egui/locales/zh-Hans.toml index ffe3a9e32..2fed25ce2 100644 --- a/crates/ui-egui/locales/zh-Hans.toml +++ b/crates/ui-egui/locales/zh-Hans.toml @@ -362,6 +362,7 @@ status = "draft — needs native review" "Dark" = "深色" "Darken" = "变暗" "Dash" = "虚线" +"Dash cap" = "虚线端点" "Dashes" = "虚线" "Dashes take numbers separated by commas" = "虚线为以逗号分隔的数字" "Decoration style" = "装饰线样式" diff --git a/crates/ui-egui/src/dash_pattern_tests.rs b/crates/ui-egui/src/dash_pattern_tests.rs index aa640f9e7..9ed9e8fcf 100644 --- a/crates/ui-egui/src/dash_pattern_tests.rs +++ b/crates/ui-egui/src/dash_pattern_tests.rs @@ -3,7 +3,7 @@ //! imported `[4, 2, 1, 2]` stays whole. use egui::{Event, Id, Key, Modifiers}; -use figcraft_doc::NodeId; +use figcraft_doc::{NodeId, StrokeCap}; use figcraft_engine::Session; use serde_json::json; @@ -152,3 +152,38 @@ fn a_single_value_pattern_is_the_dash_and_the_gap() { type_into(&mut u, "stroke_dash", "7"); assert_eq!(dashes(&u, r), [7.0, 2.0]); } + +/// Both dashed styles offer the Dash cap, and picking one sets it. Solid has no dashes to cap. +#[test] +fn the_dash_cap_shows_for_dash_and_custom_strokes() { + let has = |u: &mut Ui, text: &str| labelled(u).iter().any(|(t, _)| t == text); + let cap = |u: &Ui, r| u.app.session.doc().unwrap().node(r).unwrap().stroke.dash_cap; + let (mut u, _) = scene(json!([])); + assert!(!has(&mut u, "Dash cap")); + let (mut u, r) = scene(json!([6, 3])); + assert!(has(&mut u, "Dash cap")); + assert_eq!(cap(&u, r), None, "showing it changes nothing"); + pick_style(&mut u, "None", "Round"); + assert_eq!(cap(&u, r), Some(StrokeCap::Round)); + pick_style(&mut u, "Dash", "Custom"); + assert!(has(&mut u, "Dash cap")); + pick_style(&mut u, "Round", "Square"); + assert_eq!(cap(&u, r), Some(StrokeCap::Square)); + assert_eq!(dashes(&u, r), [6.0, 3.0]); +} + +/// Unset, the setting shows the end point caps the dashes take. +#[test] +fn an_unset_dash_cap_shows_what_draws() { + let (mut u, r) = scene(json!([6, 3])); + let caps = |u: &mut Ui, start: &str, end: &str| { + u.app.run("node.set", json!({"ids": [r], "props": {"startCap": start, "endCap": end}})); + u.frame(vec![]); + u.frame(vec![]); + }; + caps(&mut u, "round", "round"); + assert!(labelled(&mut u).iter().any(|(t, _)| t == "Round")); + caps(&mut u, "round", "square"); + assert!(labelled(&mut u).iter().any(|(t, _)| t == "Mixed")); + assert_eq!(u.app.session.doc().unwrap().node(r).unwrap().stroke.dash_cap, None); +} diff --git a/crates/ui-egui/src/panels/right/stroke.rs b/crates/ui-egui/src/panels/right/stroke.rs index 14d7e79be..5c491253c 100644 --- a/crates/ui-egui/src/panels/right/stroke.rs +++ b/crates/ui-egui/src/panels/right/stroke.rs @@ -280,6 +280,27 @@ fn dash_settings(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], dashes: Option<& } } +/// A dropdown of `caps` setting the prop `key`, showing `shown` (`None`: mixed). +fn cap_dropdown(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], key: &str, shown: Option, caps: &[StrokeCap], width: f32) { + let resp = widgets::dropdown(ui, shown.map(|c| tr(c.label())).unwrap_or(tr("Mixed")), width, None); + menu_popup(&resp).show(|ui| { + for &c in caps { + if menu_item_with(ui, tr(c.label()), "", CAP_ART_W, |p, r, col| cap_art(p, r, col, c, key == "startCap")) { + app.set_props(ids, json!({key: format!("{c:?}")})); + ui.close(); + } + } + }); +} + +/// The cap of a dashed stroke's dashes. Unset, dashes take the end point caps: it shows those. +fn dash_cap_row(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], stroke: &StrokeProps) { + let [start, end] = stroke.band_caps().map(StrokeCap::shape); + widgets::sublabels(ui, tr("Dash cap"), None); + let half = widgets::col_width(ui.available_width()); + row(ui, 32.0, |ui| cap_dropdown(app, ui, ids, "dashCap", (start == end).then_some(start), &StrokeCap::DASH, half)); +} + /// Advanced stroke settings: dash pattern, width profile, join and the end points of open paths. pub(super) fn stroke_settings(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { let ids = sel.to_vec(); @@ -287,6 +308,9 @@ pub(super) fn stroke_settings(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel let st = common(doc, sel, |n| n.stroke.clone()); let weight = st.as_ref().map(|s| s.weight).unwrap_or(1.0).max(1.0); dash_settings(app, ui, &ids, st.as_ref().map(|s| s.dashes.as_slice()), weight); + if let Some(s) = st.as_ref().filter(|s| !s.dashes.is_empty()) { + dash_cap_row(app, ui, &ids, s); + } width_profile_row(app, ui, &ids, st.as_ref().map(|s| s.profile().cloned())); brush_row(app, ui, &ids, st.as_ref().map(|s| s.brush.clone()), ui.available_width()); let join = st.as_ref().map(|s| s.join); @@ -313,15 +337,7 @@ pub(super) fn stroke_settings(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel widgets::sublabels(ui, tr("Start point"), Some(tr("End point"))); row(ui, 32.0, |ui| { for (key, cap) in [("startCap", st.as_ref().map(|s| s.cap_start)), ("endCap", st.as_ref().map(|s| s.cap_end))] { - let resp = widgets::dropdown(ui, cap.map(|c| tr(c.label())).unwrap_or(tr("Mixed")), half, None); - menu_popup(&resp).show(|ui| { - for c in StrokeCap::ALL { - if menu_item_with(ui, tr(c.label()), "", CAP_ART_W, |p, r, col| cap_art(p, r, col, c, key == "startCap")) { - app.set_props(&ids, json!({key: format!("{c:?}")})); - ui.close(); - } - } - }); + cap_dropdown(app, ui, &ids, key, cap, &StrokeCap::ALL, half); } }); } diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 1b22ee0c2..50805f39e 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (pasted layers whose image the clipboard lacks are named and show a placeholder that reads as missing (#1646); paint-style colours on layers pasted from another design app keep their style instead of turning black (#1638); imported and pasted text finds an installed family under the other names its fonts give it, and with other case and spacing (#1292); `.fig` export writes colour calculations as the colour they show in each mode and leaves out, with a warning, one that resolves to nothing (#1280); `.fig` import budgets four decoded values per compressed byte instead of one, still within 20M–128M, so dense files within that budget open instead of being refused as having too many values (#574); `.fig` ZIP image reads share a total byte budget and count skipped images in the import warnings (#1076); every file a command reads whole by path is a regular file within a cap that fits it: documents 16 GiB, placed images 256 MiB, CLI scripts 1 GiB; folders, devices and pipes are refused before anything is read (#1114); files nested 256 levels deep import on the browser's 1 MB stack (#1101); SVG and PDF exports count pattern sources and the renders they embed from the layer's depth, so one cap bounds a whole export (#1082); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); a FigJam `.jam` file opens as a board (#1342); FigJam `.jam` files open like `.fig` files: File ▸ Open, drag and drop, `file.import` and the command line (#1342); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); SVG import draws each pattern tile once per scale, within the layer budget (#1080); 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); offline Penpot 2.x import/export (#1143); `.sketch` import checked against Sketch's renders: master backgrounds, symbol clipping, tints before Sketch 64, mask opacity, bitmap fills, image overrides on shapes, stretching stacks, blur radii, colour adjustments, OKLab gradients, text growth, shape group borders (#1389); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); image member names are checked on read, and REST image placeholders get archive-safe keys (#1086); Android Vector Drawable (XML) export; `.fig` export: files the incumbent imports, with component set properties, and grid track sizes at its field ids; Save and auto-save don't replace changes another app made to the open file (#1330); `.sketch` import, best effort, Sketch 48 and newer; the PNG row names ignore overlapping layers and where include bounding box is offered; the Fonts table's missing-font row describes the Missing fonts window; `.figcraft` carries the file kind (boards); `.fig` import: resized instances and variable modes by asset key; atomic saves; auto-save; SVG export clip of nested frames; SVG/PDF export depth refusal (#208); `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; `.fig` export: written files checked by opening them in other software; experimental `.fig` export; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; embedded image colour profiles; `.fig` import, best effort; animated GIF fills and TIFF images; last major: created, formats from the installed Figma's Info.plist and help center, ours from source; REST import keeps slot layers and instances' slot content (#792); REST import reads grid track sizes (#465); `.fig` import fidelity from the community batches, budgets for large files, the import report, a `.fig` copy from Save as, paste from another design app; instance layers' size and resizing and Hug on import (#731, #732)) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** minor (a Dash cap setting (none, round or square) on Dash and Custom strokes, drawn on both ends of every dash; pasted layers whose image the clipboard lacks are named and show a placeholder that reads as missing (#1646); paint-style colours on layers pasted from another design app keep their style instead of turning black (#1638); imported and pasted text finds an installed family under the other names its fonts give it, and with other case and spacing (#1292); `.fig` export writes colour calculations as the colour they show in each mode and leaves out, with a warning, one that resolves to nothing (#1280); `.fig` import budgets four decoded values per compressed byte instead of one, still within 20M–128M, so dense files within that budget open instead of being refused as having too many values (#574); `.fig` ZIP image reads share a total byte budget and count skipped images in the import warnings (#1076); every file a command reads whole by path is a regular file within a cap that fits it: documents 16 GiB, placed images 256 MiB, CLI scripts 1 GiB; folders, devices and pipes are refused before anything is read (#1114); files nested 256 levels deep import on the browser's 1 MB stack (#1101); SVG and PDF exports count pattern sources and the renders they embed from the layer's depth, so one cap bounds a whole export (#1082); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); a FigJam `.jam` file opens as a board (#1342); FigJam `.jam` files open like `.fig` files: File ▸ Open, drag and drop, `file.import` and the command line (#1342); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); SVG import draws each pattern tile once per scale, within the layer budget (#1080); 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); offline Penpot 2.x import/export (#1143); `.sketch` import checked against Sketch's renders: master backgrounds, symbol clipping, tints before Sketch 64, mask opacity, bitmap fills, image overrides on shapes, stretching stacks, blur radii, colour adjustments, OKLab gradients, text growth, shape group borders (#1389); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); image member names are checked on read, and REST image placeholders get archive-safe keys (#1086); Android Vector Drawable (XML) export; `.fig` export: files the incumbent imports, with component set properties, and grid track sizes at its field ids; Save and auto-save don't replace changes another app made to the open file (#1330); `.sketch` import, best effort, Sketch 48 and newer; the PNG row names ignore overlapping layers and where include bounding box is offered; the Fonts table's missing-font row describes the Missing fonts window; `.figcraft` carries the file kind (boards); `.fig` import: resized instances and variable modes by asset key; atomic saves; auto-save; SVG export clip of nested frames; SVG/PDF export depth refusal (#208); `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; `.fig` export: written files checked by opening them in other software; experimental `.fig` export; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; embedded image colour profiles; `.fig` import, best effort; animated GIF fills and TIFF images; last major: created, formats from the installed Figma's Info.plist and help center, ours from source; REST import keeps slot layers and instances' slot content (#792); REST import reads grid track sizes (#465); `.fig` import fidelity from the community batches, budgets for large files, the import report, a `.fig` copy from Save as, paste from another design app; instance layers' size and resizing and Hug on import (#731, #732)) · **Target:** Figma Design (desktop app 126.10.7) Every format Figma reads or writes, with FigCraft's support. Sources: `Info.plist` of `/Applications/Figma.app` (document types only; nothing inside the bundle's code was read), Figma's @@ -135,7 +135,7 @@ source paint coordinates. out), set-variable, set-variable-mode and conditional actions, scroll-to offsets, text decoration looks, variable font axes, text on a path, nested list levels, list spacing, hanging punctuation, leading trim, OpenType features, per-layer operations inside boolean groups, different stroke ends - on lines. Not written at all: stroke outlines of dashed and per-side strokes and of inside or + on lines, the Dash cap (no public name for it). Not written at all: stroke outlines of dashed and per-side strokes and of inside or outside strokes on polygons, stars and vectors, glyph outlines of text (a reader lays text out with its own fonts), version history and branches. - **Checked by opening written files in other software:** the example documents and a small test @@ -187,6 +187,7 @@ source paint coordinates. | Date | Change | Summary | |---|---|---| +| 2026-10-31 | patch | M4: Dash and Custom strokes have a Dash cap (None, Round, Square) in the advanced stroke settings and `node.set {dashCap}`, on both ends of every dash on the canvas and in PNG, PDF and SVG. Unset, dashes keep taking the end point caps, so older files look the same; the saved file writes it only when set. Numbers unchanged | | 2026-10-30 | minor | `.fig` import: a connector's label without a saved snapshot was skipped; now the connector imports as a frame holding its line and the label, as a connector with a saved snapshot does, the label centred on the line's middle by length (in a 200-wide box, laid out again with the fonts at hand), in the connector's fill paints or black. Its background isn't known without the snapshot and is left out. Not checked on real files. Test: `fig::tests::tail::a_labelled_connector_without_a_snapshot_keeps_its_label` | | 2026-10-30 | minor | Pasted content row: an image the clipboard doesn't carry keeps its name and shows a placeholder that reads as missing; the paste names those layers in its message and opens the import report with its notes (#1646). Numbers unchanged | | 2026-10-30 | minor | Pasted content row: styles a pasted layer links by its library's guid are found by their library asset, so paint-style colours on pasted components and instances no longer import as black; a link to a style that isn't there keeps the stored colour, with a note (#1638). Numbers unchanged | diff --git a/docs/mcp.md b/docs/mcp.md index fcf09fd7f..af4affbc2 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -764,6 +764,9 @@ On the canvas (`pointer`), dragging points, handles and pen clicks snap to other midpoints (this vector's and other vectors'), the usual layer targets and the pixel grid; `cmd` held while dragging turns the object snapping off. +Dash caps: `node.set {dashCap}` takes `none`, `round` or `square` for both ends of every dash of a +dashed stroke, and `null` clears it (dashes then take `startCap` and `endCap`, the default). + Variable width strokes: `stroke.widthProfile {ids?, preset?: uniform|wedge|taper|eye|rounded|pinch, points?: [[t, scale], ...]}` (also `node.set {widthProfile}`; `t` runs 0–1 along each subpath by length, `scale` multiplies the stroke weight, widths in between are interpolated smoothly; no diff --git a/docs/roadmap.md b/docs/roadmap.md index 17c4340eb..189c00c4e 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-30 · **Change:** minor (library analytics list how many layers and files use each style and variable; the Controller menu names each button as PlayStation and Switch controllers do; widgets draw lines and SVG pictures (plugin API 6); a connector end dropped along a side of a shape attaches to that side (#1708); regression tests pin a rounded shape's one-sided stroke and SVG ids and font families without XML-forbidden characters (#1351, #1124); a damaged image is refused with a reason instead of showing as an empty box (#1474); SwiftUI and Compose code names a bound variable by its iOS or Android code syntax; the colour picker's Image tab shows a placeholder and Upload from computer, an asset preview, and a file dropped on it replaces the image, keeping the fill mode, crop and position (#1682); the Frame tool's Phone presets have the iPhone 17 family (#1691); Show in folder works in the Flatpak build and selects the file on Linux (#1701); a dashed line starts and ends with a half-length dash, on the canvas and in exports; the Pages, Layers and Assets search fields keep their text in the middle of the field (#1703); a board connector's path can be reshaped by dragging its path handles (#1710); boards have 16 more shapes (arrows, polygons, star, plus and flowchart symbols), in pickers that lay them out in a grid; library analytics count insertions and show insertions and detachments over the past 30 days, 90 days or year; a stroke's Custom style takes a dash pattern of any length as a comma-separated list (#1681); a control request can set its own timeout, up to 10 minutes (`timeoutMs`; `figcraft-cli mcp --attach PORT --timeout SECONDS`) (#1696); gap 6: a widget opens its plugin's panel (plugin API 5), whose callbacks redraw it; the keyboard's Menu key opens the context menu, as Shift+F10 does; an Open link action can open in place of the prototype, with "Open in new tab" off (gap 15); a collection of this file can be extended (Extend collection on its row, `variable.extendCollection`), its extension following its edits at once, and extended collections extend again in the file; the eyedropper with Cmd/Ctrl+Shift makes a colour style or variable of what it picks; a live session's welcome sends the document compressed to clients that read it, about seven times smaller (#1665); a control request that times out says truthfully whether it was dropped or is still running and will take effect, and only screenshots and input blame the window (#1673); a board keeps earlier votes' results, listed newest first in Results and deleted one at a time; a server can list the files shared from it now for everyone who reaches it, Home ▸ Online shows them and their links carry no secret (`team_files`, part of #742); a selected connector's bar on a board sets its start and end points, solid or dashed, its weight and any colour (#1596); cells copied from a spreadsheet paste onto a board as a table; a long layer name stays on one line in the Export button and the Dev Mode header, ending with an ellipsis, and no longer covers the Design panel (#1650); with hundreds of layers selected, the Design panel and the selection overlays stop going over the whole selection on every frame, so dragging 648 layers averages about 1.1 ms of UI per frame (#1571); plugin panels show pictures: an `image` control, base64 within 256 KB and 4,096 px a side, named by its label (gap 6); a drag moving many layers re-runs only what sits around them, not each layer's bindings, sizing, text and instance checks, on every move (#1571); damaged font tracking table offsets are checked instead of wrapping on 32-bit targets, and font indexing makes its existing offset bounds explicit (#1125); pasted layers whose image the clipboard lacks are named and show a placeholder that reads as missing (#1646); paint-style colours on layers pasted from another design app keep their style instead of turning black (#1638); Shift+Cmd/Ctrl+Enter on a selected board shape adds the next shape already joined to it, and goes on typing (#1597); a name typed for a variable or collection finds the one with that exact id before one named like it, and stored mode ids, font family variables and the panels' labels read exact ids (#1613); plugin widgets have hover styles: a layer's `hover` fill, stroke, opacity or text colour shows while the pointer is over it (gap 6); every file a command reads whole by path is a regular file within a cap that fits it: documents 16 GiB, placed images 256 MiB, CLI scripts 1 GiB; compact layer trees with fields and outline (M0.9, part of #996); while a drag only moves a large selection, its outlines, box, slots, spacing and Design panel values are worked out once and shifted, not rebuilt on every move (#1571); an imported or pasted file finds an installed family under the other names its fonts give it, and with other case and spacing, instead of reporting it missing (#1292); a panic in a grouped closure outside any command rolls the group back like a failed group: documents, selection, undo and redo, history limit and journal as before (#1085); a development example generates figcraft-cli's shell completions and man pages from its clap definitions (part of #1396); `.fig` ZIP image reads share a total byte budget and count skipped images in the import warnings (#1076); stored variable and collection references keep their identity when another object is named like their id (follow-up to #1542); a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; the server key is checked by comparing SHA-256 hashes of the presented and the kept key, as the deployment guide says (#1130); opening a file contacts no team library server it names until you check it (#1097); a hostile server can no longer grow a client's memory with made-up people, and a session remembers a bounded number of deleted layer ids (#1121); `doc.list` gives AI agents and plugins the open files' names, not their paths (#1117); with a large selection, frames where nothing changed reuse the selection's outlines, box and slots and the Design panel's instance and position values instead of working them out again (#1571); team libraries and plugins larger than the 16 MiB download burst can be downloaded again, more slowly (#1089); a batch keeps one undo snapshot, not one per step (#1099); files nested 256 levels deep import on the browser's 1 MB stack (#1101); SVG and PDF exports count pattern sources and the renders they embed from the layer's depth, so one cap bounds a whole export (#1082); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); joining and leaving a file nobody is editing reads and writes nothing on the server: it stays loaded a minute after everyone leaves (#1074); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); hovering the canvas hit-tests once per pointer move, not on every frame, and moving an auto layout frame no longer lays out its contents again on every move (#1571); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); `comment.people` lists names that were only @mentioned, and `comment.next` and `comment.prompt` carry the mentions too (#1415); grid auto-placement looks for free cells a 64-bit word at a time and skips what blocks it, with the same placements (#1083); with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); a recovery copy made while a version is previewed holds the file, not the version shown; a FigJam `.jam` file opens as a board (#1342); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); share links can comment: a third access level beside edit and view, whose holder sees the file live and adds, replies to, reacts to and resolves comments but can't change the design; Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); `comment.list {mentions}` lists only the threads that @mention a name (#1415); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); figcraft-cli parses its arguments with clap, following RFC-0012 (#1396); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); Thai, Lao, Khmer and Burmese lines break between words (#1219); an AI agent attached over MCP shows where it works (#1416); a manual overlay's position can be dragged on the canvas (#405); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); SVG import draws each pattern tile once per scale, within the layer budget (#1080); Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); M8: mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); M2: components can't contain their own instances, directly or through others; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); 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) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (a Dash cap setting (none, round or square) on Dash and Custom strokes, drawn on both ends of every dash; library analytics list how many layers and files use each style and variable; the Controller menu names each button as PlayStation and Switch controllers do; widgets draw lines and SVG pictures (plugin API 6); a connector end dropped along a side of a shape attaches to that side (#1708); regression tests pin a rounded shape's one-sided stroke and SVG ids and font families without XML-forbidden characters (#1351, #1124); a damaged image is refused with a reason instead of showing as an empty box (#1474); SwiftUI and Compose code names a bound variable by its iOS or Android code syntax; the colour picker's Image tab shows a placeholder and Upload from computer, an asset preview, and a file dropped on it replaces the image, keeping the fill mode, crop and position (#1682); the Frame tool's Phone presets have the iPhone 17 family (#1691); Show in folder works in the Flatpak build and selects the file on Linux (#1701); a dashed line starts and ends with a half-length dash, on the canvas and in exports; the Pages, Layers and Assets search fields keep their text in the middle of the field (#1703); a board connector's path can be reshaped by dragging its path handles (#1710); boards have 16 more shapes (arrows, polygons, star, plus and flowchart symbols), in pickers that lay them out in a grid; library analytics count insertions and show insertions and detachments over the past 30 days, 90 days or year; a stroke's Custom style takes a dash pattern of any length as a comma-separated list (#1681); a control request can set its own timeout, up to 10 minutes (`timeoutMs`; `figcraft-cli mcp --attach PORT --timeout SECONDS`) (#1696); gap 6: a widget opens its plugin's panel (plugin API 5), whose callbacks redraw it; the keyboard's Menu key opens the context menu, as Shift+F10 does; an Open link action can open in place of the prototype, with "Open in new tab" off (gap 15); a collection of this file can be extended (Extend collection on its row, `variable.extendCollection`), its extension following its edits at once, and extended collections extend again in the file; the eyedropper with Cmd/Ctrl+Shift makes a colour style or variable of what it picks; a live session's welcome sends the document compressed to clients that read it, about seven times smaller (#1665); a control request that times out says truthfully whether it was dropped or is still running and will take effect, and only screenshots and input blame the window (#1673); a board keeps earlier votes' results, listed newest first in Results and deleted one at a time; a server can list the files shared from it now for everyone who reaches it, Home ▸ Online shows them and their links carry no secret (`team_files`, part of #742); a selected connector's bar on a board sets its start and end points, solid or dashed, its weight and any colour (#1596); cells copied from a spreadsheet paste onto a board as a table; a long layer name stays on one line in the Export button and the Dev Mode header, ending with an ellipsis, and no longer covers the Design panel (#1650); with hundreds of layers selected, the Design panel and the selection overlays stop going over the whole selection on every frame, so dragging 648 layers averages about 1.1 ms of UI per frame (#1571); plugin panels show pictures: an `image` control, base64 within 256 KB and 4,096 px a side, named by its label (gap 6); a drag moving many layers re-runs only what sits around them, not each layer's bindings, sizing, text and instance checks, on every move (#1571); damaged font tracking table offsets are checked instead of wrapping on 32-bit targets, and font indexing makes its existing offset bounds explicit (#1125); pasted layers whose image the clipboard lacks are named and show a placeholder that reads as missing (#1646); paint-style colours on layers pasted from another design app keep their style instead of turning black (#1638); Shift+Cmd/Ctrl+Enter on a selected board shape adds the next shape already joined to it, and goes on typing (#1597); a name typed for a variable or collection finds the one with that exact id before one named like it, and stored mode ids, font family variables and the panels' labels read exact ids (#1613); plugin widgets have hover styles: a layer's `hover` fill, stroke, opacity or text colour shows while the pointer is over it (gap 6); every file a command reads whole by path is a regular file within a cap that fits it: documents 16 GiB, placed images 256 MiB, CLI scripts 1 GiB; compact layer trees with fields and outline (M0.9, part of #996); while a drag only moves a large selection, its outlines, box, slots, spacing and Design panel values are worked out once and shifted, not rebuilt on every move (#1571); an imported or pasted file finds an installed family under the other names its fonts give it, and with other case and spacing, instead of reporting it missing (#1292); a panic in a grouped closure outside any command rolls the group back like a failed group: documents, selection, undo and redo, history limit and journal as before (#1085); a development example generates figcraft-cli's shell completions and man pages from its clap definitions (part of #1396); `.fig` ZIP image reads share a total byte budget and count skipped images in the import warnings (#1076); stored variable and collection references keep their identity when another object is named like their id (follow-up to #1542); a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; the server key is checked by comparing SHA-256 hashes of the presented and the kept key, as the deployment guide says (#1130); opening a file contacts no team library server it names until you check it (#1097); a hostile server can no longer grow a client's memory with made-up people, and a session remembers a bounded number of deleted layer ids (#1121); `doc.list` gives AI agents and plugins the open files' names, not their paths (#1117); with a large selection, frames where nothing changed reuse the selection's outlines, box and slots and the Design panel's instance and position values instead of working them out again (#1571); team libraries and plugins larger than the 16 MiB download burst can be downloaded again, more slowly (#1089); a batch keeps one undo snapshot, not one per step (#1099); files nested 256 levels deep import on the browser's 1 MB stack (#1101); SVG and PDF exports count pattern sources and the renders they embed from the layer's depth, so one cap bounds a whole export (#1082); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); joining and leaving a file nobody is editing reads and writes nothing on the server: it stays loaded a minute after everyone leaves (#1074); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); hovering the canvas hit-tests once per pointer move, not on every frame, and moving an auto layout frame no longer lays out its contents again on every move (#1571); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); `comment.people` lists names that were only @mentioned, and `comment.next` and `comment.prompt` carry the mentions too (#1415); grid auto-placement looks for free cells a 64-bit word at a time and skips what blocks it, with the same placements (#1083); with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); a recovery copy made while a version is previewed holds the file, not the version shown; a FigJam `.jam` file opens as a board (#1342); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); share links can comment: a third access level beside edit and view, whose holder sees the file live and adds, replies to, reacts to and resolves comments but can't change the design; Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); `comment.list {mentions}` lists only the threads that @mention a name (#1415); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); figcraft-cli parses its arguments with clap, following RFC-0012 (#1396); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); Thai, Lao, Khmer and Burmese lines break between words (#1219); an AI agent attached over MCP shows where it works (#1416); a manual overlay's position can be dragged on the canvas (#405); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); SVG import draws each pattern tile once per scale, within the layer budget (#1080); Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); M8: mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); M2: components can't contain their own instances, directly or through others; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); 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-31 | patch | M4: Dash and Custom strokes have a Dash cap (None, Round, Square) in the advanced stroke settings and `node.set {dashCap}`, on both ends of every dash on the canvas and in PNG, PDF and SVG. Unset, dashes keep taking the end point caps, so older files look the same; the saved file writes it only when set. Numbers unchanged | | 2026-10-31 | minor | M3, gap 10: library analytics cover styles and variables. A file reports how many of its layers use each of a library's styles and variables, and Show usage (`library.usage`) lists them beside the components, by a Type choice, with their layers and files. Layers inside instances count for their component. Insertions, detachments and modes aren't counted for them yet. Numbers unchanged. Tests: `library_assets_tests`. | | 2026-10-31 | minor | M5: a key/gamepad trigger's Controller menu shows, beside each button, what PlayStation and Nintendo Switch controllers call it ("Gamepad A" is ✕ and B). Triggers are stored by the button's place, as before. | | 2026-10-31 | minor | M11, gap 6: widgets draw lines and SVG pictures, as a Figma widget's `Line` and `SVG` do (plugin API 6; older plugins unchanged). A line is 100 long, black and 1 thick unless told otherwise, and turns around its top-left corner. An SVG comes in as locked layers, scaled to fit a size, within the widget's 256 layers. The Counter example uses both. Numbers unchanged. Tests: `widget_shapes_tests`, `widget_tests`. | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index a045e5c8b..e40b0011a 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-31 · **Change:** minor (gap 6: widgets draw lines and SVG pictures (plugin API 6); gap 6: a widget opens its plugin's panel (plugin API 5), whose callbacks redraw it; the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); plugin widgets have hover styles: a layer's `hover` fill, stroke, opacity or text colour shows while the pointer is over it (gap 6); Thai, Lao, Khmer and Burmese lines break between words (#1219); 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)) · **Target:** Figma Design (desktop app 126.10.7, macOS) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** minor (a Dash cap setting (none, round or square) on Dash and Custom strokes, drawn on both ends of every dash; gap 6: widgets draw lines and SVG pictures (plugin API 6); gap 6: a widget opens its plugin's panel (plugin API 5), whose callbacks redraw it; the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); plugin widgets have hover styles: a layer's `hover` fill, stroke, opacity or text colour shows while the pointer is over it (gap 6); Thai, Lao, Khmer and Burmese lines break between words (#1219); 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)) · **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 @@ -588,7 +588,7 @@ Carried over from the ROADMAP's "Shipped so far" (2026-10-10). The test count th stars, lines/arrows, vector networks, text with styled runs, boolean groups, components, component sets (variants), instances with per-layer overrides, slices; fills (solid, linear/radial/angular/ diamond gradients, images with fill/fit/crop/tile + adjustments), multiple strokes with - inside/center/outside alignment, per-side weights, dashes, caps/arrowheads, joins; effects (drop + inside/center/outside alignment, per-side weights, dashes with a dash cap, caps/arrowheads, joins; effects (drop and inner shadow with spread that offsets the outline exactly: ellipses, stars, vectors, text; inner shadows over the fills and under the children and strokes; drop shadows hidden behind what the layer covers, translucent fills included, unless shown behind transparent areas, and images @@ -648,6 +648,7 @@ Carried over from the ROADMAP's "Shipped so far" (2026-10-10). The test count th | Date | Change | Summary | |---|---|---| +| 2026-10-31 | patch | M4: Dash and Custom strokes have a Dash cap (None, Round, Square) in the advanced stroke settings and `node.set {dashCap}`, on both ends of every dash on the canvas and in PNG, PDF and SVG. Unset, dashes keep taking the end point caps, so older files look the same; the saved file writes it only when set. Numbers unchanged | | 2026-10-31 | minor | M11, gap 6: widgets draw lines and SVG pictures, as a Figma widget's `Line` and `SVG` do (plugin API 6; older plugins unchanged). A line is 100 long, black and 1 thick unless told otherwise, and turns around its top-left corner. An SVG comes in as locked layers, scaled to fit a size, within the widget's 256 layers. The Counter example uses both. Numbers unchanged. Tests: `widget_shapes_tests`, `widget_tests`. | | 2026-10-30 | minor | M11, gap 6: a widget can open its plugin's panel, as a Figma widget's click handler shows a plugin window (plugin API 5). A widget callback's `ui_show` (click, field edit or menu item) opens the plugin's one panel and keeps the module loaded; each panel callback gets the widget's id and current state as well as the panel's event and values, and its `widget_render` redraws the widget in the same undo step. A widget removed meanwhile isn't redrawn (the callback rolls back, the panel stays); an API 3 or 4 plugin's `ui_show` from a widget still opens nothing; a presented prototype opens no panel. The Counter example (API 5) gets Set count… in its menu: a panel whose Set button redraws it with the count entered. Tests: `widget_panel_tests` (engine), the Counter's unit test. Numbers unchanged | | 2026-10-30 | minor | Gap 6: plugin widgets have hover styles (fill, stroke, opacity, text colour while the pointer is over a layer); timers, panels from widgets and more layer kinds remain. Numbers unchanged. | From e2f04b33e0e332729a2cc9141a6d449a99652316 Mon Sep 17 00:00:00 2001 From: BFlat Date: Sat, 31 Oct 2026 10:13:01 +0700 Subject: [PATCH 06/15] M11: a tap on a plugin panel's button keeps what was typed in its fields (#1732) --- ROADMAP.md | 3 ++- apps/figcraft-web/src/api.rs | 2 +- crates/ui-egui/src/dialogs/plugins_ui.rs | 31 ++++++++++++------------ crates/ui-egui/src/plugin_panel_tests.rs | 26 ++++++++++++++++++++ docs/roadmap.md | 3 ++- 5 files changed, 47 insertions(+), 18 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 8c258eca1..46f5e1bca 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-30 · **Change:** minor (a Dash cap setting (none, round or square) on Dash and Custom strokes, drawn on both ends of every dash; library analytics list how many layers and files use each style and variable; the Controller menu names each button as PlayStation and Switch controllers do; widgets draw lines and SVG pictures (plugin API 6); a connector end dropped along a side of a shape attaches to that side (#1708); regression tests pin a rounded shape's one-sided stroke and SVG ids and font families without XML-forbidden characters (#1351, #1124); a damaged image is refused with a reason instead of showing as an empty box (#1474); an image copied in another app pastes again on the desktop, and in the browser for the first time (#492); SwiftUI and Compose code names a bound variable by its iOS or Android code syntax; the colour picker's Image tab shows a placeholder and Upload from computer, an asset preview, and a file dropped on it replaces the image, keeping the fill mode, crop and position (#1682); the Frame tool's Phone presets have the iPhone 17 family (#1691); Show in folder works in the Flatpak build and selects the file on Linux (#1701); a dashed line starts and ends with a half-length dash, on the canvas and in exports; the Pages, Layers and Assets search fields keep their text in the middle of the field (#1703); a board connector's path can be reshaped by dragging its path handles (#1710); boards have 16 more shapes (arrows, polygons, star, plus and flowchart symbols), in pickers that lay them out in a grid; library analytics count insertions and show insertions and detachments over the past 30 days, 90 days or year; a stroke's Custom style takes a dash pattern of any length as a comma-separated list (#1681); a control request can set its own timeout, up to 10 minutes (`timeoutMs`; `figcraft-cli mcp --attach PORT --timeout SECONDS`) (#1696); gap 6: a widget opens its plugin's panel (plugin API 5), whose callbacks redraw it; the keyboard's Menu key opens the context menu, as Shift+F10 does; an Open link action can open in place of the prototype, with "Open in new tab" off (gap 15); a collection of this file can be extended (Extend collection on its row, `variable.extendCollection`), its extension following its edits at once, and extended collections extend again in the file; the eyedropper with Cmd/Ctrl+Shift makes a colour style or variable of what it picks; a live session's welcome sends the document compressed to clients that read it, about seven times smaller (#1665); a control request that times out says truthfully whether it was dropped or is still running and will take effect, and only screenshots and input blame the window (#1673); a board keeps earlier votes' results, listed newest first in Results and deleted one at a time; the object a stamp will stick to is outlined while the stamp tool is over it or a stamp is dragged over it, and board objects take no drops (gap 24); a server can list the files shared from it now for everyone who reaches it, Home ▸ Online shows them and their links carry no secret (`team_files`, part of #742); team library analytics count detached instances per component and file (gap 10); skipping the Open link notice by command is saved with the preferences at once (gap 15); a selected connector's bar on a board sets its start and end points, solid or dashed, its weight and any colour (#1596); cells copied from a spreadsheet paste onto a board as a table; a long layer name stays on one line in the Export button and the Dev Mode header, ending with an ellipsis, and no longer covers the Design panel (#1650); with hundreds of layers selected, the Design panel and the selection overlays stop going over the whole selection on every frame, so dragging 648 layers averages about 1.1 ms of UI per frame (#1571); plugin panels show pictures: an `image` control, base64 within 256 KB and 4,096 px a side, named by its label (gap 6); `.fig` connector labels without a saved snapshot are kept, centred on their line (gap 1); a drag moving many layers re-runs only what sits around them, not each layer's bindings, sizing, text and instance checks, on every move (#1571); damaged font tracking table offsets are checked instead of wrapping on 32-bit targets, and font indexing makes its existing offset bounds explicit (#1125); Bosnian (`bs`) draft catalog; text style dedupe is O(spans) via a last-two cache and fingerprint buckets (#1102); pasted layers whose image the clipboard lacks are named and show a placeholder that reads as missing (#1646); paint-style colours on layers pasted from another design app keep their style instead of turning black (#1638); progress docs cleaned of what merges left twice: rows merged into one, repeated status items removed, and `cargo xtask docs` (in `ci`) fails on a repeated table row or status item; a name typed for a variable or collection finds the one with that exact id before one named like it, and stored mode ids, font family variables and the panels' labels read exact ids (#1613); plugin widgets have hover styles: a layer's `hover` fill, stroke, opacity or text colour shows while the pointer is over it (gap 6); `.fig` elbowed and curved connectors without a saved path are routed like board connectors instead of drawn straight (gap 1); every file a command reads whole by path is a regular file within a cap that fits it: documents 16 GiB, placed images 256 MiB, CLI scripts 1 GiB; `figcraft-cli connect publish` publishes a code repository's code connect mappings to a design file (gap 16); while a drag only moves a large selection, its outlines, box, slots, spacing and Design panel values are worked out once and shifted, not rebuilt on every move (#1571); Shift+Cmd/Ctrl+Enter on a selected board shape adds the next shape already joined to it, and goes on typing (#1597); an imported or pasted file finds an installed family under the other names its fonts give it, and with other case and spacing, instead of reporting it missing (#1292); a development example generates figcraft-cli's shell completions and man pages from its clap definitions (part of #1396); `.fig` ZIP image reads share a total byte budget and count skipped images in the import warnings (#1076); German terminology corrections (M10): gradient stops, OpenType stylistic sets and the Home-screen preference; a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; the server key is checked by comparing SHA-256 hashes of the presented and the kept key, as the deployment guide says (#1130); opening a file contacts no team library server it names until you check it (#1097); a hostile server can no longer grow a client's memory with made-up people, and a session remembers a bounded number of deleted layer ids (#1121); `doc.list` gives AI agents and plugins the open files' names, not their paths (#1117); with a large selection, frames where nothing changed reuse the selection's outlines, box and slots and the Design panel's instance and position values instead of working them out again (#1571); team libraries and plugins larger than the 16 MiB download burst can be downloaded again, more slowly (#1089); a batch keeps one undo snapshot, not one per step (#1099); files nested 256 levels deep import on the browser's 1 MB stack (#1101); SVG and PDF exports count pattern sources and the renders they embed from the layer's depth, so one cap bounds a whole export (#1082); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); joining and leaving a file nobody is editing reads and writes nothing on the server: it stays loaded a minute after everyone leaves (#1074); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); hovering the canvas hit-tests once per pointer move, not on every frame, and moving an auto layout frame no longer lays out its contents again on every move (#1571); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); `comment.people` lists names that were only @mentioned, and `comment.next` and `comment.prompt` carry the mentions too (#1415); grid auto-placement looks for free cells a 64-bit word at a time and skips what blocks it, with the same placements (#1083); with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); a recovery copy made while a version is previewed holds the file, not the version shown; a FigJam `.jam` file opens as a board (#1342); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); share links can comment: a third access level beside edit and view, whose holder sees the file live and adds, replies to, reacts to and resolves comments but can't change the design; Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); `comment.list {mentions}` lists only the threads that @mention a name (#1415); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); figcraft-cli parses its arguments with clap, following RFC-0012 (#1396); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); Thai, Lao, Khmer and Burmese lines break between words (#1219); an AI agent attached over MCP shows where it works (#1416); a manual overlay's position can be dragged on the canvas (#405); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); SVG import draws each pattern tile once per scale, within the layer budget (#1080); Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); components can't contain their own instances, directly or through other components; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); 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) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (a tap on a plugin panel's button keeps what was typed in its fields; a Dash cap setting (none, round or square) on Dash and Custom strokes, drawn on both ends of every dash; library analytics list how many layers and files use each style and variable; the Controller menu names each button as PlayStation and Switch controllers do; widgets draw lines and SVG pictures (plugin API 6); a connector end dropped along a side of a shape attaches to that side (#1708); regression tests pin a rounded shape's one-sided stroke and SVG ids and font families without XML-forbidden characters (#1351, #1124); a damaged image is refused with a reason instead of showing as an empty box (#1474); an image copied in another app pastes again on the desktop, and in the browser for the first time (#492); SwiftUI and Compose code names a bound variable by its iOS or Android code syntax; the colour picker's Image tab shows a placeholder and Upload from computer, an asset preview, and a file dropped on it replaces the image, keeping the fill mode, crop and position (#1682); the Frame tool's Phone presets have the iPhone 17 family (#1691); Show in folder works in the Flatpak build and selects the file on Linux (#1701); a dashed line starts and ends with a half-length dash, on the canvas and in exports; the Pages, Layers and Assets search fields keep their text in the middle of the field (#1703); a board connector's path can be reshaped by dragging its path handles (#1710); boards have 16 more shapes (arrows, polygons, star, plus and flowchart symbols), in pickers that lay them out in a grid; library analytics count insertions and show insertions and detachments over the past 30 days, 90 days or year; a stroke's Custom style takes a dash pattern of any length as a comma-separated list (#1681); a control request can set its own timeout, up to 10 minutes (`timeoutMs`; `figcraft-cli mcp --attach PORT --timeout SECONDS`) (#1696); gap 6: a widget opens its plugin's panel (plugin API 5), whose callbacks redraw it; the keyboard's Menu key opens the context menu, as Shift+F10 does; an Open link action can open in place of the prototype, with "Open in new tab" off (gap 15); a collection of this file can be extended (Extend collection on its row, `variable.extendCollection`), its extension following its edits at once, and extended collections extend again in the file; the eyedropper with Cmd/Ctrl+Shift makes a colour style or variable of what it picks; a live session's welcome sends the document compressed to clients that read it, about seven times smaller (#1665); a control request that times out says truthfully whether it was dropped or is still running and will take effect, and only screenshots and input blame the window (#1673); a board keeps earlier votes' results, listed newest first in Results and deleted one at a time; the object a stamp will stick to is outlined while the stamp tool is over it or a stamp is dragged over it, and board objects take no drops (gap 24); a server can list the files shared from it now for everyone who reaches it, Home ▸ Online shows them and their links carry no secret (`team_files`, part of #742); team library analytics count detached instances per component and file (gap 10); skipping the Open link notice by command is saved with the preferences at once (gap 15); a selected connector's bar on a board sets its start and end points, solid or dashed, its weight and any colour (#1596); cells copied from a spreadsheet paste onto a board as a table; a long layer name stays on one line in the Export button and the Dev Mode header, ending with an ellipsis, and no longer covers the Design panel (#1650); with hundreds of layers selected, the Design panel and the selection overlays stop going over the whole selection on every frame, so dragging 648 layers averages about 1.1 ms of UI per frame (#1571); plugin panels show pictures: an `image` control, base64 within 256 KB and 4,096 px a side, named by its label (gap 6); `.fig` connector labels without a saved snapshot are kept, centred on their line (gap 1); a drag moving many layers re-runs only what sits around them, not each layer's bindings, sizing, text and instance checks, on every move (#1571); damaged font tracking table offsets are checked instead of wrapping on 32-bit targets, and font indexing makes its existing offset bounds explicit (#1125); Bosnian (`bs`) draft catalog; text style dedupe is O(spans) via a last-two cache and fingerprint buckets (#1102); pasted layers whose image the clipboard lacks are named and show a placeholder that reads as missing (#1646); paint-style colours on layers pasted from another design app keep their style instead of turning black (#1638); progress docs cleaned of what merges left twice: rows merged into one, repeated status items removed, and `cargo xtask docs` (in `ci`) fails on a repeated table row or status item; a name typed for a variable or collection finds the one with that exact id before one named like it, and stored mode ids, font family variables and the panels' labels read exact ids (#1613); plugin widgets have hover styles: a layer's `hover` fill, stroke, opacity or text colour shows while the pointer is over it (gap 6); `.fig` elbowed and curved connectors without a saved path are routed like board connectors instead of drawn straight (gap 1); every file a command reads whole by path is a regular file within a cap that fits it: documents 16 GiB, placed images 256 MiB, CLI scripts 1 GiB; `figcraft-cli connect publish` publishes a code repository's code connect mappings to a design file (gap 16); while a drag only moves a large selection, its outlines, box, slots, spacing and Design panel values are worked out once and shifted, not rebuilt on every move (#1571); Shift+Cmd/Ctrl+Enter on a selected board shape adds the next shape already joined to it, and goes on typing (#1597); an imported or pasted file finds an installed family under the other names its fonts give it, and with other case and spacing, instead of reporting it missing (#1292); a development example generates figcraft-cli's shell completions and man pages from its clap definitions (part of #1396); `.fig` ZIP image reads share a total byte budget and count skipped images in the import warnings (#1076); German terminology corrections (M10): gradient stops, OpenType stylistic sets and the Home-screen preference; a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; the server key is checked by comparing SHA-256 hashes of the presented and the kept key, as the deployment guide says (#1130); opening a file contacts no team library server it names until you check it (#1097); a hostile server can no longer grow a client's memory with made-up people, and a session remembers a bounded number of deleted layer ids (#1121); `doc.list` gives AI agents and plugins the open files' names, not their paths (#1117); with a large selection, frames where nothing changed reuse the selection's outlines, box and slots and the Design panel's instance and position values instead of working them out again (#1571); team libraries and plugins larger than the 16 MiB download burst can be downloaded again, more slowly (#1089); a batch keeps one undo snapshot, not one per step (#1099); files nested 256 levels deep import on the browser's 1 MB stack (#1101); SVG and PDF exports count pattern sources and the renders they embed from the layer's depth, so one cap bounds a whole export (#1082); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); joining and leaving a file nobody is editing reads and writes nothing on the server: it stays loaded a minute after everyone leaves (#1074); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); hovering the canvas hit-tests once per pointer move, not on every frame, and moving an auto layout frame no longer lays out its contents again on every move (#1571); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); `comment.people` lists names that were only @mentioned, and `comment.next` and `comment.prompt` carry the mentions too (#1415); grid auto-placement looks for free cells a 64-bit word at a time and skips what blocks it, with the same placements (#1083); with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); a recovery copy made while a version is previewed holds the file, not the version shown; a FigJam `.jam` file opens as a board (#1342); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); share links can comment: a third access level beside edit and view, whose holder sees the file live and adds, replies to, reacts to and resolves comments but can't change the design; Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); `comment.list {mentions}` lists only the threads that @mention a name (#1415); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); figcraft-cli parses its arguments with clap, following RFC-0012 (#1396); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); Thai, Lao, Khmer and Burmese lines break between words (#1219); an AI agent attached over MCP shows where it works (#1416); a manual overlay's position can be dragged on the canvas (#405); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); SVG import draws each pattern tile once per scale, within the layer budget (#1080); Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); components can't contain their own instances, directly or through other components; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); 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 @@ -358,6 +358,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-31 | patch | M11: a trackpad or touch tap sends its press and release in one frame, so a plugin panel's field committed in the frame its button was clicked and the value was lost: the Counter's Set count… kept the old count. The plugin now gets both, the field's value first. The `window.figcraft` docs name `screenshot`'s `data`. Numbers unchanged | | 2026-10-31 | patch | M4: Dash and Custom strokes have a Dash cap (None, Round, Square) in the advanced stroke settings and `node.set {dashCap}`, on both ends of every dash on the canvas and in PNG, PDF and SVG. Unset, dashes keep taking the end point caps, so older files look the same; the saved file writes it only when set. Numbers unchanged | | 2026-10-31 | minor | M3, gap 10: library analytics cover styles and variables. A file reports how many of its layers use each of a library's styles and variables, and Show usage (`library.usage`) lists them beside the components, by a Type choice, with their layers and files. Layers inside instances count for their component. Insertions, detachments and modes aren't counted for them yet. Numbers unchanged. Tests: `library_assets_tests`. | | 2026-10-31 | minor | M5: a key/gamepad trigger's Controller menu shows, beside each button, what PlayStation and Nintendo Switch controllers call it ("Gamepad A" is ✕ and B). Triggers are stored by the button's place, as before. | diff --git a/apps/figcraft-web/src/api.rs b/apps/figcraft-web/src/api.rs index 621925cf9..7f6a0ec4d 100644 --- a/apps/figcraft-web/src/api.rs +++ b/apps/figcraft-web/src/api.rs @@ -8,7 +8,7 @@ //! await figcraft.execute("node.create", {kind: "frame", name: "Home"}) // engine command → its result //! await figcraft.request("document.inspect") // control method → its result //! await figcraft.commands() // command list -//! const {base64} = await figcraft.screenshot() // the canvas as PNG (base64) +//! const {data} = await figcraft.screenshot() // the canvas as PNG (base64) //! await figcraft.openFile("home.fig", bytes) // `file.open` with the bytes //! figcraft.captureDownloads(true) // saves stay in the page… //! figcraft.files(); await figcraft.readFile("home.figcraft") // …to be read back diff --git a/crates/ui-egui/src/dialogs/plugins_ui.rs b/crates/ui-egui/src/dialogs/plugins_ui.rs index 923ffffad..350be2d6e 100644 --- a/crates/ui-egui/src/dialogs/plugins_ui.rs +++ b/crates/ui-egui/src/dialogs/plugins_ui.rs @@ -233,12 +233,12 @@ pub fn show(app: &mut FigApp, ctx: &Context) { fn panel_windows(app: &mut FigApp, ctx: &Context) { let t = Tokens::get(ctx); let right = ctx.content_rect().right() - 252.0; - // (plugin, what was used; None: closed) + // (plugin, what was used, in order; None: closed) let mut actions: Vec<(String, Option)> = Vec::new(); for (i, open) in app.session.plugins.panels.iter().enumerate() { let id = open.manifest.id.as_str(); let mut keep = true; - let mut used = None; + let mut used = Vec::new(); // Side by side leftwards from the properties panel, three to a row. let at = egui::pos2(right - PANEL_STEP * (i % 3) as f32, 52.0 + 28.0 * (i / 3) as f32); floating(open.title(), ctx).id(Id::unique(("plugin_panel", id))).pivot(Align2::RIGHT_TOP).default_pos(at).open(&mut keep).show(ctx, |ui| { @@ -247,8 +247,8 @@ fn panel_windows(app: &mut FigApp, ctx: &Context) { }); if !keep { actions.push((id.to_string(), None)); - } else if used.is_some() { - actions.push((id.to_string(), used)); + } else { + actions.extend(used.into_iter().map(|u| (id.to_string(), Some(u)))); } } for (id, used) in actions { @@ -580,12 +580,13 @@ fn form_window(app: &mut FigApp, ctx: &Context) { let title = if label.contains(name.as_str()) { label.to_string() } else { format!("{name}: {label}") }; window(&title, ctx).id(Id::unique("plugin_form")).open(&mut open).show(ctx, |ui| { ui.set_width(320.0); - match controls(ui, &format!("{}/{}", form.id, form.command), &command.params, &form.values, &t) { - Some(Used::Set(name, v)) => { - form.values.insert(name, v); + for used in controls(ui, &format!("{}/{}", form.id, form.command), &command.params, &form.values, &t) { + match used { + Used::Set(name, v) => { + form.values.insert(name, v); + } + Used::Pick(control) => pick = Some(control), } - Some(Used::Pick(control)) => pick = Some(control), - None => {} } ui.add_space(12.0); ui.horizontal(|ui| { @@ -604,8 +605,10 @@ fn form_window(app: &mut FigApp, ctx: &Context) { /// Parameters or panel controls in two columns (label, field); buttons in a row share one line, /// the first button filled. Returns what the user did to which control. -fn controls(ui: &mut Ui, salt: &str, params: &[Param], values: &Map, t: &Tokens) -> Option { - let mut changed = None; +fn controls(ui: &mut Ui, salt: &str, params: &[Param], values: &Map, t: &Tokens) -> Vec { + // In order: a tap's press and release share a frame, so a field that loses the keyboard + // commits in the same frame as the button that took it. + let mut used = Vec::new(); let mut first_button = true; egui::Grid::new(("plugin_controls", salt)).num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| { for row in params.chunk_by(|a, b| a.kind == ParamKind::Button && b.kind == ParamKind::Button) { @@ -643,13 +646,11 @@ fn controls(ui: &mut Ui, salt: &str, params: &[Param], values: &Map **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (a Dash cap setting (none, round or square) on Dash and Custom strokes, drawn on both ends of every dash; library analytics list how many layers and files use each style and variable; the Controller menu names each button as PlayStation and Switch controllers do; widgets draw lines and SVG pictures (plugin API 6); a connector end dropped along a side of a shape attaches to that side (#1708); regression tests pin a rounded shape's one-sided stroke and SVG ids and font families without XML-forbidden characters (#1351, #1124); a damaged image is refused with a reason instead of showing as an empty box (#1474); SwiftUI and Compose code names a bound variable by its iOS or Android code syntax; the colour picker's Image tab shows a placeholder and Upload from computer, an asset preview, and a file dropped on it replaces the image, keeping the fill mode, crop and position (#1682); the Frame tool's Phone presets have the iPhone 17 family (#1691); Show in folder works in the Flatpak build and selects the file on Linux (#1701); a dashed line starts and ends with a half-length dash, on the canvas and in exports; the Pages, Layers and Assets search fields keep their text in the middle of the field (#1703); a board connector's path can be reshaped by dragging its path handles (#1710); boards have 16 more shapes (arrows, polygons, star, plus and flowchart symbols), in pickers that lay them out in a grid; library analytics count insertions and show insertions and detachments over the past 30 days, 90 days or year; a stroke's Custom style takes a dash pattern of any length as a comma-separated list (#1681); a control request can set its own timeout, up to 10 minutes (`timeoutMs`; `figcraft-cli mcp --attach PORT --timeout SECONDS`) (#1696); gap 6: a widget opens its plugin's panel (plugin API 5), whose callbacks redraw it; the keyboard's Menu key opens the context menu, as Shift+F10 does; an Open link action can open in place of the prototype, with "Open in new tab" off (gap 15); a collection of this file can be extended (Extend collection on its row, `variable.extendCollection`), its extension following its edits at once, and extended collections extend again in the file; the eyedropper with Cmd/Ctrl+Shift makes a colour style or variable of what it picks; a live session's welcome sends the document compressed to clients that read it, about seven times smaller (#1665); a control request that times out says truthfully whether it was dropped or is still running and will take effect, and only screenshots and input blame the window (#1673); a board keeps earlier votes' results, listed newest first in Results and deleted one at a time; a server can list the files shared from it now for everyone who reaches it, Home ▸ Online shows them and their links carry no secret (`team_files`, part of #742); a selected connector's bar on a board sets its start and end points, solid or dashed, its weight and any colour (#1596); cells copied from a spreadsheet paste onto a board as a table; a long layer name stays on one line in the Export button and the Dev Mode header, ending with an ellipsis, and no longer covers the Design panel (#1650); with hundreds of layers selected, the Design panel and the selection overlays stop going over the whole selection on every frame, so dragging 648 layers averages about 1.1 ms of UI per frame (#1571); plugin panels show pictures: an `image` control, base64 within 256 KB and 4,096 px a side, named by its label (gap 6); a drag moving many layers re-runs only what sits around them, not each layer's bindings, sizing, text and instance checks, on every move (#1571); damaged font tracking table offsets are checked instead of wrapping on 32-bit targets, and font indexing makes its existing offset bounds explicit (#1125); pasted layers whose image the clipboard lacks are named and show a placeholder that reads as missing (#1646); paint-style colours on layers pasted from another design app keep their style instead of turning black (#1638); Shift+Cmd/Ctrl+Enter on a selected board shape adds the next shape already joined to it, and goes on typing (#1597); a name typed for a variable or collection finds the one with that exact id before one named like it, and stored mode ids, font family variables and the panels' labels read exact ids (#1613); plugin widgets have hover styles: a layer's `hover` fill, stroke, opacity or text colour shows while the pointer is over it (gap 6); every file a command reads whole by path is a regular file within a cap that fits it: documents 16 GiB, placed images 256 MiB, CLI scripts 1 GiB; compact layer trees with fields and outline (M0.9, part of #996); while a drag only moves a large selection, its outlines, box, slots, spacing and Design panel values are worked out once and shifted, not rebuilt on every move (#1571); an imported or pasted file finds an installed family under the other names its fonts give it, and with other case and spacing, instead of reporting it missing (#1292); a panic in a grouped closure outside any command rolls the group back like a failed group: documents, selection, undo and redo, history limit and journal as before (#1085); a development example generates figcraft-cli's shell completions and man pages from its clap definitions (part of #1396); `.fig` ZIP image reads share a total byte budget and count skipped images in the import warnings (#1076); stored variable and collection references keep their identity when another object is named like their id (follow-up to #1542); a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; the server key is checked by comparing SHA-256 hashes of the presented and the kept key, as the deployment guide says (#1130); opening a file contacts no team library server it names until you check it (#1097); a hostile server can no longer grow a client's memory with made-up people, and a session remembers a bounded number of deleted layer ids (#1121); `doc.list` gives AI agents and plugins the open files' names, not their paths (#1117); with a large selection, frames where nothing changed reuse the selection's outlines, box and slots and the Design panel's instance and position values instead of working them out again (#1571); team libraries and plugins larger than the 16 MiB download burst can be downloaded again, more slowly (#1089); a batch keeps one undo snapshot, not one per step (#1099); files nested 256 levels deep import on the browser's 1 MB stack (#1101); SVG and PDF exports count pattern sources and the renders they embed from the layer's depth, so one cap bounds a whole export (#1082); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); joining and leaving a file nobody is editing reads and writes nothing on the server: it stays loaded a minute after everyone leaves (#1074); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); hovering the canvas hit-tests once per pointer move, not on every frame, and moving an auto layout frame no longer lays out its contents again on every move (#1571); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); `comment.people` lists names that were only @mentioned, and `comment.next` and `comment.prompt` carry the mentions too (#1415); grid auto-placement looks for free cells a 64-bit word at a time and skips what blocks it, with the same placements (#1083); with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); a recovery copy made while a version is previewed holds the file, not the version shown; a FigJam `.jam` file opens as a board (#1342); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); share links can comment: a third access level beside edit and view, whose holder sees the file live and adds, replies to, reacts to and resolves comments but can't change the design; Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); `comment.list {mentions}` lists only the threads that @mention a name (#1415); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); figcraft-cli parses its arguments with clap, following RFC-0012 (#1396); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); Thai, Lao, Khmer and Burmese lines break between words (#1219); an AI agent attached over MCP shows where it works (#1416); a manual overlay's position can be dragged on the canvas (#405); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); SVG import draws each pattern tile once per scale, within the layer budget (#1080); Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); M8: mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); M2: components can't contain their own instances, directly or through others; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); 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) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (a tap on a plugin panel's button keeps what was typed in its fields; a Dash cap setting (none, round or square) on Dash and Custom strokes, drawn on both ends of every dash; library analytics list how many layers and files use each style and variable; the Controller menu names each button as PlayStation and Switch controllers do; widgets draw lines and SVG pictures (plugin API 6); a connector end dropped along a side of a shape attaches to that side (#1708); regression tests pin a rounded shape's one-sided stroke and SVG ids and font families without XML-forbidden characters (#1351, #1124); a damaged image is refused with a reason instead of showing as an empty box (#1474); SwiftUI and Compose code names a bound variable by its iOS or Android code syntax; the colour picker's Image tab shows a placeholder and Upload from computer, an asset preview, and a file dropped on it replaces the image, keeping the fill mode, crop and position (#1682); the Frame tool's Phone presets have the iPhone 17 family (#1691); Show in folder works in the Flatpak build and selects the file on Linux (#1701); a dashed line starts and ends with a half-length dash, on the canvas and in exports; the Pages, Layers and Assets search fields keep their text in the middle of the field (#1703); a board connector's path can be reshaped by dragging its path handles (#1710); boards have 16 more shapes (arrows, polygons, star, plus and flowchart symbols), in pickers that lay them out in a grid; library analytics count insertions and show insertions and detachments over the past 30 days, 90 days or year; a stroke's Custom style takes a dash pattern of any length as a comma-separated list (#1681); a control request can set its own timeout, up to 10 minutes (`timeoutMs`; `figcraft-cli mcp --attach PORT --timeout SECONDS`) (#1696); gap 6: a widget opens its plugin's panel (plugin API 5), whose callbacks redraw it; the keyboard's Menu key opens the context menu, as Shift+F10 does; an Open link action can open in place of the prototype, with "Open in new tab" off (gap 15); a collection of this file can be extended (Extend collection on its row, `variable.extendCollection`), its extension following its edits at once, and extended collections extend again in the file; the eyedropper with Cmd/Ctrl+Shift makes a colour style or variable of what it picks; a live session's welcome sends the document compressed to clients that read it, about seven times smaller (#1665); a control request that times out says truthfully whether it was dropped or is still running and will take effect, and only screenshots and input blame the window (#1673); a board keeps earlier votes' results, listed newest first in Results and deleted one at a time; a server can list the files shared from it now for everyone who reaches it, Home ▸ Online shows them and their links carry no secret (`team_files`, part of #742); a selected connector's bar on a board sets its start and end points, solid or dashed, its weight and any colour (#1596); cells copied from a spreadsheet paste onto a board as a table; a long layer name stays on one line in the Export button and the Dev Mode header, ending with an ellipsis, and no longer covers the Design panel (#1650); with hundreds of layers selected, the Design panel and the selection overlays stop going over the whole selection on every frame, so dragging 648 layers averages about 1.1 ms of UI per frame (#1571); plugin panels show pictures: an `image` control, base64 within 256 KB and 4,096 px a side, named by its label (gap 6); a drag moving many layers re-runs only what sits around them, not each layer's bindings, sizing, text and instance checks, on every move (#1571); damaged font tracking table offsets are checked instead of wrapping on 32-bit targets, and font indexing makes its existing offset bounds explicit (#1125); pasted layers whose image the clipboard lacks are named and show a placeholder that reads as missing (#1646); paint-style colours on layers pasted from another design app keep their style instead of turning black (#1638); Shift+Cmd/Ctrl+Enter on a selected board shape adds the next shape already joined to it, and goes on typing (#1597); a name typed for a variable or collection finds the one with that exact id before one named like it, and stored mode ids, font family variables and the panels' labels read exact ids (#1613); plugin widgets have hover styles: a layer's `hover` fill, stroke, opacity or text colour shows while the pointer is over it (gap 6); every file a command reads whole by path is a regular file within a cap that fits it: documents 16 GiB, placed images 256 MiB, CLI scripts 1 GiB; compact layer trees with fields and outline (M0.9, part of #996); while a drag only moves a large selection, its outlines, box, slots, spacing and Design panel values are worked out once and shifted, not rebuilt on every move (#1571); an imported or pasted file finds an installed family under the other names its fonts give it, and with other case and spacing, instead of reporting it missing (#1292); a panic in a grouped closure outside any command rolls the group back like a failed group: documents, selection, undo and redo, history limit and journal as before (#1085); a development example generates figcraft-cli's shell completions and man pages from its clap definitions (part of #1396); `.fig` ZIP image reads share a total byte budget and count skipped images in the import warnings (#1076); stored variable and collection references keep their identity when another object is named like their id (follow-up to #1542); a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; the server key is checked by comparing SHA-256 hashes of the presented and the kept key, as the deployment guide says (#1130); opening a file contacts no team library server it names until you check it (#1097); a hostile server can no longer grow a client's memory with made-up people, and a session remembers a bounded number of deleted layer ids (#1121); `doc.list` gives AI agents and plugins the open files' names, not their paths (#1117); with a large selection, frames where nothing changed reuse the selection's outlines, box and slots and the Design panel's instance and position values instead of working them out again (#1571); team libraries and plugins larger than the 16 MiB download burst can be downloaded again, more slowly (#1089); a batch keeps one undo snapshot, not one per step (#1099); files nested 256 levels deep import on the browser's 1 MB stack (#1101); SVG and PDF exports count pattern sources and the renders they embed from the layer's depth, so one cap bounds a whole export (#1082); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); joining and leaving a file nobody is editing reads and writes nothing on the server: it stays loaded a minute after everyone leaves (#1074); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); hovering the canvas hit-tests once per pointer move, not on every frame, and moving an auto layout frame no longer lays out its contents again on every move (#1571); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); `comment.people` lists names that were only @mentioned, and `comment.next` and `comment.prompt` carry the mentions too (#1415); grid auto-placement looks for free cells a 64-bit word at a time and skips what blocks it, with the same placements (#1083); with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); a recovery copy made while a version is previewed holds the file, not the version shown; a FigJam `.jam` file opens as a board (#1342); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); share links can comment: a third access level beside edit and view, whose holder sees the file live and adds, replies to, reacts to and resolves comments but can't change the design; Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); `comment.list {mentions}` lists only the threads that @mention a name (#1415); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); figcraft-cli parses its arguments with clap, following RFC-0012 (#1396); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); Thai, Lao, Khmer and Burmese lines break between words (#1219); an AI agent attached over MCP shows where it works (#1416); a manual overlay's position can be dragged on the canvas (#405); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); SVG import draws each pattern tile once per scale, within the layer budget (#1080); Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); M8: mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); M2: components can't contain their own instances, directly or through others; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); 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-31 | patch | M11: a trackpad or touch tap sends its press and release in one frame, so a plugin panel's field committed in the frame its button was clicked and the value was lost: the Counter's Set count… kept the old count. The plugin now gets both, the field's value first. The `window.figcraft` docs name `screenshot`'s `data`. Numbers unchanged | | 2026-10-31 | patch | M4: Dash and Custom strokes have a Dash cap (None, Round, Square) in the advanced stroke settings and `node.set {dashCap}`, on both ends of every dash on the canvas and in PNG, PDF and SVG. Unset, dashes keep taking the end point caps, so older files look the same; the saved file writes it only when set. Numbers unchanged | | 2026-10-31 | minor | M3, gap 10: library analytics cover styles and variables. A file reports how many of its layers use each of a library's styles and variables, and Show usage (`library.usage`) lists them beside the components, by a Type choice, with their layers and files. Layers inside instances count for their component. Insertions, detachments and modes aren't counted for them yet. Numbers unchanged. Tests: `library_assets_tests`. | | 2026-10-31 | minor | M5: a key/gamepad trigger's Controller menu shows, beside each button, what PlayStation and Nintendo Switch controllers call it ("Gamepad A" is ✕ and B). Triggers are stored by the button's place, as before. | From 6bf38fa9638dd9ba553362503dae5d3ed5a03c1d Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 30 Oct 2026 23:22:51 -0400 Subject: [PATCH 07/15] Release 0.13.0 Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 44 ++++++++++++++++++++++---------------------- Cargo.toml | 2 +- 2 files changed, 23 insertions(+), 23 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5641bff12..8fd1296eb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1763,7 +1763,7 @@ checksum = "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693" [[package]] name = "figcraft" -version = "0.12.1" +version = "0.13.0" dependencies = [ "arboard", "craft-menu", @@ -1795,7 +1795,7 @@ dependencies = [ [[package]] name = "figcraft-cli" -version = "0.12.1" +version = "0.13.0" dependencies = [ "clap", "clap_complete", @@ -1813,7 +1813,7 @@ dependencies = [ [[package]] name = "figcraft-collab" -version = "0.12.1" +version = "0.13.0" dependencies = [ "base64 0.22.1", "chrono", @@ -1831,7 +1831,7 @@ dependencies = [ [[package]] name = "figcraft-collab-net" -version = "0.12.1" +version = "0.13.0" dependencies = [ "base64 0.22.1", "figcraft-collab", @@ -1854,7 +1854,7 @@ dependencies = [ [[package]] name = "figcraft-color" -version = "0.12.1" +version = "0.13.0" dependencies = [ "kurbo", "serde", @@ -1863,7 +1863,7 @@ dependencies = [ [[package]] name = "figcraft-doc" -version = "0.12.1" +version = "0.13.0" dependencies = [ "figcraft-color", "figcraft-geom", @@ -1876,7 +1876,7 @@ dependencies = [ [[package]] name = "figcraft-engine" -version = "0.12.1" +version = "0.13.0" dependencies = [ "base64 0.22.1", "craft-docpath", @@ -1910,7 +1910,7 @@ dependencies = [ [[package]] name = "figcraft-format" -version = "0.12.1" +version = "0.13.0" dependencies = [ "figcraft-doc", "figcraft-layout", @@ -1927,7 +1927,7 @@ dependencies = [ [[package]] name = "figcraft-geom" -version = "0.12.1" +version = "0.13.0" dependencies = [ "kurbo", "serde", @@ -1935,7 +1935,7 @@ dependencies = [ [[package]] name = "figcraft-layout" -version = "0.12.1" +version = "0.13.0" dependencies = [ "figcraft-doc", "figcraft-geom", @@ -1943,7 +1943,7 @@ dependencies = [ [[package]] name = "figcraft-mcp" -version = "0.12.1" +version = "0.13.0" dependencies = [ "base64 0.22.1", "figcraft-collab-net", @@ -1959,7 +1959,7 @@ dependencies = [ [[package]] name = "figcraft-pathops" -version = "0.12.1" +version = "0.13.0" dependencies = [ "figcraft-doc", "figcraft-geom", @@ -1968,7 +1968,7 @@ dependencies = [ [[package]] name = "figcraft-pdf" -version = "0.12.1" +version = "0.13.0" dependencies = [ "figcraft-color", "figcraft-doc", @@ -1985,7 +1985,7 @@ dependencies = [ [[package]] name = "figcraft-plugin" -version = "0.12.1" +version = "0.13.0" dependencies = [ "figcraft-color", "serde", @@ -1998,7 +1998,7 @@ dependencies = [ [[package]] name = "figcraft-render" -version = "0.12.1" +version = "0.13.0" dependencies = [ "crc32fast", "figcraft-color", @@ -2017,7 +2017,7 @@ dependencies = [ [[package]] name = "figcraft-server" -version = "0.12.1" +version = "0.13.0" dependencies = [ "chrono", "crc32fast", @@ -2042,7 +2042,7 @@ dependencies = [ [[package]] name = "figcraft-svg" -version = "0.12.1" +version = "0.13.0" dependencies = [ "base64 0.22.1", "figcraft-color", @@ -2058,7 +2058,7 @@ dependencies = [ [[package]] name = "figcraft-text" -version = "0.12.1" +version = "0.13.0" dependencies = [ "core-text", "craft-font-catalog", @@ -2076,7 +2076,7 @@ dependencies = [ [[package]] name = "figcraft-ui-egui" -version = "0.12.1" +version = "0.13.0" dependencies = [ "base64 0.22.1", "chrono", @@ -2104,7 +2104,7 @@ dependencies = [ [[package]] name = "figcraft-vectordrawable" -version = "0.12.1" +version = "0.13.0" dependencies = [ "figcraft-color", "figcraft-doc", @@ -2118,7 +2118,7 @@ dependencies = [ [[package]] name = "figcraft-web" -version = "0.12.1" +version = "0.13.0" dependencies = [ "base64 0.22.1", "eframe", @@ -6739,7 +6739,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee" [[package]] name = "xtask" -version = "0.12.1" +version = "0.13.0" dependencies = [ "figcraft-engine", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index 2bef2eb20..b1b87f8c1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,7 +6,7 @@ exclude = ["plugins"] # default-members = ["crates/*", "apps/figcraft", "apps/figcraft-cli", "xtask"] [workspace.package] -version = "0.12.1" +version = "0.13.0" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.95" From 12e9b409eda63b300ee881bf428be039ab4e1e70 Mon Sep 17 00:00:00 2001 From: BFlat Date: Sat, 31 Oct 2026 10:27:56 +0700 Subject: [PATCH 08/15] M12: a connector end on any part of an object with Cmd/Ctrl (#1733) --- ROADMAP.md | 3 +- crates/collab/src/board_sync_tests.rs | 4 +- crates/doc/src/board.rs | 3 + crates/doc/src/board_route.rs | 36 ++++--- crates/engine/src/board_connected_tests.rs | 4 +- crates/engine/src/board_connector_tests.rs | 14 ++- crates/engine/src/board_reshape_tests.rs | 4 +- crates/engine/src/board_tests.rs | 7 +- crates/engine/src/board_tools_tests.rs | 2 +- crates/engine/src/boards/board.rs | 4 +- crates/engine/src/boards/board/connectors.rs | 33 ++++-- crates/engine/src/boards/board/derived.rs | 5 +- crates/engine/src/boards/board/items.rs | 7 +- crates/engine/src/boards/board/reshape.rs | 2 +- crates/engine/src/boards/board/tools.rs | 22 ++-- crates/engine/src/connector_anywhere_tests.rs | 100 ++++++++++++++++++ crates/engine/src/lib.rs | 2 + crates/format/src/fig/map/connector.rs | 2 +- crates/ui-egui/src/board_ui_tests.rs | 40 ++++++- docs/boards-parity.md | 4 +- docs/mcp.md | 10 +- docs/roadmap.md | 3 +- 22 files changed, 257 insertions(+), 54 deletions(-) create mode 100644 crates/engine/src/connector_anywhere_tests.rs diff --git a/ROADMAP.md b/ROADMAP.md index 46f5e1bca..fc0a521f8 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-30 · **Change:** minor (a tap on a plugin panel's button keeps what was typed in its fields; a Dash cap setting (none, round or square) on Dash and Custom strokes, drawn on both ends of every dash; library analytics list how many layers and files use each style and variable; the Controller menu names each button as PlayStation and Switch controllers do; widgets draw lines and SVG pictures (plugin API 6); a connector end dropped along a side of a shape attaches to that side (#1708); regression tests pin a rounded shape's one-sided stroke and SVG ids and font families without XML-forbidden characters (#1351, #1124); a damaged image is refused with a reason instead of showing as an empty box (#1474); an image copied in another app pastes again on the desktop, and in the browser for the first time (#492); SwiftUI and Compose code names a bound variable by its iOS or Android code syntax; the colour picker's Image tab shows a placeholder and Upload from computer, an asset preview, and a file dropped on it replaces the image, keeping the fill mode, crop and position (#1682); the Frame tool's Phone presets have the iPhone 17 family (#1691); Show in folder works in the Flatpak build and selects the file on Linux (#1701); a dashed line starts and ends with a half-length dash, on the canvas and in exports; the Pages, Layers and Assets search fields keep their text in the middle of the field (#1703); a board connector's path can be reshaped by dragging its path handles (#1710); boards have 16 more shapes (arrows, polygons, star, plus and flowchart symbols), in pickers that lay them out in a grid; library analytics count insertions and show insertions and detachments over the past 30 days, 90 days or year; a stroke's Custom style takes a dash pattern of any length as a comma-separated list (#1681); a control request can set its own timeout, up to 10 minutes (`timeoutMs`; `figcraft-cli mcp --attach PORT --timeout SECONDS`) (#1696); gap 6: a widget opens its plugin's panel (plugin API 5), whose callbacks redraw it; the keyboard's Menu key opens the context menu, as Shift+F10 does; an Open link action can open in place of the prototype, with "Open in new tab" off (gap 15); a collection of this file can be extended (Extend collection on its row, `variable.extendCollection`), its extension following its edits at once, and extended collections extend again in the file; the eyedropper with Cmd/Ctrl+Shift makes a colour style or variable of what it picks; a live session's welcome sends the document compressed to clients that read it, about seven times smaller (#1665); a control request that times out says truthfully whether it was dropped or is still running and will take effect, and only screenshots and input blame the window (#1673); a board keeps earlier votes' results, listed newest first in Results and deleted one at a time; the object a stamp will stick to is outlined while the stamp tool is over it or a stamp is dragged over it, and board objects take no drops (gap 24); a server can list the files shared from it now for everyone who reaches it, Home ▸ Online shows them and their links carry no secret (`team_files`, part of #742); team library analytics count detached instances per component and file (gap 10); skipping the Open link notice by command is saved with the preferences at once (gap 15); a selected connector's bar on a board sets its start and end points, solid or dashed, its weight and any colour (#1596); cells copied from a spreadsheet paste onto a board as a table; a long layer name stays on one line in the Export button and the Dev Mode header, ending with an ellipsis, and no longer covers the Design panel (#1650); with hundreds of layers selected, the Design panel and the selection overlays stop going over the whole selection on every frame, so dragging 648 layers averages about 1.1 ms of UI per frame (#1571); plugin panels show pictures: an `image` control, base64 within 256 KB and 4,096 px a side, named by its label (gap 6); `.fig` connector labels without a saved snapshot are kept, centred on their line (gap 1); a drag moving many layers re-runs only what sits around them, not each layer's bindings, sizing, text and instance checks, on every move (#1571); damaged font tracking table offsets are checked instead of wrapping on 32-bit targets, and font indexing makes its existing offset bounds explicit (#1125); Bosnian (`bs`) draft catalog; text style dedupe is O(spans) via a last-two cache and fingerprint buckets (#1102); pasted layers whose image the clipboard lacks are named and show a placeholder that reads as missing (#1646); paint-style colours on layers pasted from another design app keep their style instead of turning black (#1638); progress docs cleaned of what merges left twice: rows merged into one, repeated status items removed, and `cargo xtask docs` (in `ci`) fails on a repeated table row or status item; a name typed for a variable or collection finds the one with that exact id before one named like it, and stored mode ids, font family variables and the panels' labels read exact ids (#1613); plugin widgets have hover styles: a layer's `hover` fill, stroke, opacity or text colour shows while the pointer is over it (gap 6); `.fig` elbowed and curved connectors without a saved path are routed like board connectors instead of drawn straight (gap 1); every file a command reads whole by path is a regular file within a cap that fits it: documents 16 GiB, placed images 256 MiB, CLI scripts 1 GiB; `figcraft-cli connect publish` publishes a code repository's code connect mappings to a design file (gap 16); while a drag only moves a large selection, its outlines, box, slots, spacing and Design panel values are worked out once and shifted, not rebuilt on every move (#1571); Shift+Cmd/Ctrl+Enter on a selected board shape adds the next shape already joined to it, and goes on typing (#1597); an imported or pasted file finds an installed family under the other names its fonts give it, and with other case and spacing, instead of reporting it missing (#1292); a development example generates figcraft-cli's shell completions and man pages from its clap definitions (part of #1396); `.fig` ZIP image reads share a total byte budget and count skipped images in the import warnings (#1076); German terminology corrections (M10): gradient stops, OpenType stylistic sets and the Home-screen preference; a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; the server key is checked by comparing SHA-256 hashes of the presented and the kept key, as the deployment guide says (#1130); opening a file contacts no team library server it names until you check it (#1097); a hostile server can no longer grow a client's memory with made-up people, and a session remembers a bounded number of deleted layer ids (#1121); `doc.list` gives AI agents and plugins the open files' names, not their paths (#1117); with a large selection, frames where nothing changed reuse the selection's outlines, box and slots and the Design panel's instance and position values instead of working them out again (#1571); team libraries and plugins larger than the 16 MiB download burst can be downloaded again, more slowly (#1089); a batch keeps one undo snapshot, not one per step (#1099); files nested 256 levels deep import on the browser's 1 MB stack (#1101); SVG and PDF exports count pattern sources and the renders they embed from the layer's depth, so one cap bounds a whole export (#1082); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); joining and leaving a file nobody is editing reads and writes nothing on the server: it stays loaded a minute after everyone leaves (#1074); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); hovering the canvas hit-tests once per pointer move, not on every frame, and moving an auto layout frame no longer lays out its contents again on every move (#1571); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); `comment.people` lists names that were only @mentioned, and `comment.next` and `comment.prompt` carry the mentions too (#1415); grid auto-placement looks for free cells a 64-bit word at a time and skips what blocks it, with the same placements (#1083); with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); a recovery copy made while a version is previewed holds the file, not the version shown; a FigJam `.jam` file opens as a board (#1342); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); share links can comment: a third access level beside edit and view, whose holder sees the file live and adds, replies to, reacts to and resolves comments but can't change the design; Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); `comment.list {mentions}` lists only the threads that @mention a name (#1415); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); figcraft-cli parses its arguments with clap, following RFC-0012 (#1396); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); Thai, Lao, Khmer and Burmese lines break between words (#1219); an AI agent attached over MCP shows where it works (#1416); a manual overlay's position can be dragged on the canvas (#405); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); SVG import draws each pattern tile once per scale, within the layer budget (#1080); Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); components can't contain their own instances, directly or through other components; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); 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) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (a connector end moved with Cmd/Ctrl held goes on any part of an object, and stays there as the object moves or resizes; a tap on a plugin panel's button keeps what was typed in its fields; a Dash cap setting (none, round or square) on Dash and Custom strokes, drawn on both ends of every dash; library analytics list how many layers and files use each style and variable; the Controller menu names each button as PlayStation and Switch controllers do; widgets draw lines and SVG pictures (plugin API 6); a connector end dropped along a side of a shape attaches to that side (#1708); regression tests pin a rounded shape's one-sided stroke and SVG ids and font families without XML-forbidden characters (#1351, #1124); a damaged image is refused with a reason instead of showing as an empty box (#1474); an image copied in another app pastes again on the desktop, and in the browser for the first time (#492); SwiftUI and Compose code names a bound variable by its iOS or Android code syntax; the colour picker's Image tab shows a placeholder and Upload from computer, an asset preview, and a file dropped on it replaces the image, keeping the fill mode, crop and position (#1682); the Frame tool's Phone presets have the iPhone 17 family (#1691); Show in folder works in the Flatpak build and selects the file on Linux (#1701); a dashed line starts and ends with a half-length dash, on the canvas and in exports; the Pages, Layers and Assets search fields keep their text in the middle of the field (#1703); a board connector's path can be reshaped by dragging its path handles (#1710); boards have 16 more shapes (arrows, polygons, star, plus and flowchart symbols), in pickers that lay them out in a grid; library analytics count insertions and show insertions and detachments over the past 30 days, 90 days or year; a stroke's Custom style takes a dash pattern of any length as a comma-separated list (#1681); a control request can set its own timeout, up to 10 minutes (`timeoutMs`; `figcraft-cli mcp --attach PORT --timeout SECONDS`) (#1696); gap 6: a widget opens its plugin's panel (plugin API 5), whose callbacks redraw it; the keyboard's Menu key opens the context menu, as Shift+F10 does; an Open link action can open in place of the prototype, with "Open in new tab" off (gap 15); a collection of this file can be extended (Extend collection on its row, `variable.extendCollection`), its extension following its edits at once, and extended collections extend again in the file; the eyedropper with Cmd/Ctrl+Shift makes a colour style or variable of what it picks; a live session's welcome sends the document compressed to clients that read it, about seven times smaller (#1665); a control request that times out says truthfully whether it was dropped or is still running and will take effect, and only screenshots and input blame the window (#1673); a board keeps earlier votes' results, listed newest first in Results and deleted one at a time; the object a stamp will stick to is outlined while the stamp tool is over it or a stamp is dragged over it, and board objects take no drops (gap 24); a server can list the files shared from it now for everyone who reaches it, Home ▸ Online shows them and their links carry no secret (`team_files`, part of #742); team library analytics count detached instances per component and file (gap 10); skipping the Open link notice by command is saved with the preferences at once (gap 15); a selected connector's bar on a board sets its start and end points, solid or dashed, its weight and any colour (#1596); cells copied from a spreadsheet paste onto a board as a table; a long layer name stays on one line in the Export button and the Dev Mode header, ending with an ellipsis, and no longer covers the Design panel (#1650); with hundreds of layers selected, the Design panel and the selection overlays stop going over the whole selection on every frame, so dragging 648 layers averages about 1.1 ms of UI per frame (#1571); plugin panels show pictures: an `image` control, base64 within 256 KB and 4,096 px a side, named by its label (gap 6); `.fig` connector labels without a saved snapshot are kept, centred on their line (gap 1); a drag moving many layers re-runs only what sits around them, not each layer's bindings, sizing, text and instance checks, on every move (#1571); damaged font tracking table offsets are checked instead of wrapping on 32-bit targets, and font indexing makes its existing offset bounds explicit (#1125); Bosnian (`bs`) draft catalog; text style dedupe is O(spans) via a last-two cache and fingerprint buckets (#1102); pasted layers whose image the clipboard lacks are named and show a placeholder that reads as missing (#1646); paint-style colours on layers pasted from another design app keep their style instead of turning black (#1638); progress docs cleaned of what merges left twice: rows merged into one, repeated status items removed, and `cargo xtask docs` (in `ci`) fails on a repeated table row or status item; a name typed for a variable or collection finds the one with that exact id before one named like it, and stored mode ids, font family variables and the panels' labels read exact ids (#1613); plugin widgets have hover styles: a layer's `hover` fill, stroke, opacity or text colour shows while the pointer is over it (gap 6); `.fig` elbowed and curved connectors without a saved path are routed like board connectors instead of drawn straight (gap 1); every file a command reads whole by path is a regular file within a cap that fits it: documents 16 GiB, placed images 256 MiB, CLI scripts 1 GiB; `figcraft-cli connect publish` publishes a code repository's code connect mappings to a design file (gap 16); while a drag only moves a large selection, its outlines, box, slots, spacing and Design panel values are worked out once and shifted, not rebuilt on every move (#1571); Shift+Cmd/Ctrl+Enter on a selected board shape adds the next shape already joined to it, and goes on typing (#1597); an imported or pasted file finds an installed family under the other names its fonts give it, and with other case and spacing, instead of reporting it missing (#1292); a development example generates figcraft-cli's shell completions and man pages from its clap definitions (part of #1396); `.fig` ZIP image reads share a total byte budget and count skipped images in the import warnings (#1076); German terminology corrections (M10): gradient stops, OpenType stylistic sets and the Home-screen preference; a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; the server key is checked by comparing SHA-256 hashes of the presented and the kept key, as the deployment guide says (#1130); opening a file contacts no team library server it names until you check it (#1097); a hostile server can no longer grow a client's memory with made-up people, and a session remembers a bounded number of deleted layer ids (#1121); `doc.list` gives AI agents and plugins the open files' names, not their paths (#1117); with a large selection, frames where nothing changed reuse the selection's outlines, box and slots and the Design panel's instance and position values instead of working them out again (#1571); team libraries and plugins larger than the 16 MiB download burst can be downloaded again, more slowly (#1089); a batch keeps one undo snapshot, not one per step (#1099); files nested 256 levels deep import on the browser's 1 MB stack (#1101); SVG and PDF exports count pattern sources and the renders they embed from the layer's depth, so one cap bounds a whole export (#1082); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); joining and leaving a file nobody is editing reads and writes nothing on the server: it stays loaded a minute after everyone leaves (#1074); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); hovering the canvas hit-tests once per pointer move, not on every frame, and moving an auto layout frame no longer lays out its contents again on every move (#1571); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); `comment.people` lists names that were only @mentioned, and `comment.next` and `comment.prompt` carry the mentions too (#1415); grid auto-placement looks for free cells a 64-bit word at a time and skips what blocks it, with the same placements (#1083); with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); a recovery copy made while a version is previewed holds the file, not the version shown; a FigJam `.jam` file opens as a board (#1342); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); share links can comment: a third access level beside edit and view, whose holder sees the file live and adds, replies to, reacts to and resolves comments but can't change the design; Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); `comment.list {mentions}` lists only the threads that @mention a name (#1415); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); figcraft-cli parses its arguments with clap, following RFC-0012 (#1396); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); Thai, Lao, Khmer and Burmese lines break between words (#1219); an AI agent attached over MCP shows where it works (#1416); a manual overlay's position can be dragged on the canvas (#405); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); SVG import draws each pattern tile once per scale, within the layer budget (#1080); Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); components can't contain their own instances, directly or through other components; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); 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 @@ -358,6 +358,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-31 | minor | M12: as the help describes, holding Cmd/Ctrl while moving a connector's end places it on any part of an object instead of snapping to a side. It keeps that spot as a fraction of the object's box, so it follows the object when it moves or resizes, and without the key an end snaps as before. `board.connector {fromAt, toAt}` and `board.reattach {at}` take it; saved only when set. Numbers unchanged | | 2026-10-31 | patch | M11: a trackpad or touch tap sends its press and release in one frame, so a plugin panel's field committed in the frame its button was clicked and the value was lost: the Counter's Set count… kept the old count. The plugin now gets both, the field's value first. The `window.figcraft` docs name `screenshot`'s `data`. Numbers unchanged | | 2026-10-31 | patch | M4: Dash and Custom strokes have a Dash cap (None, Round, Square) in the advanced stroke settings and `node.set {dashCap}`, on both ends of every dash on the canvas and in PNG, PDF and SVG. Unset, dashes keep taking the end point caps, so older files look the same; the saved file writes it only when set. Numbers unchanged | | 2026-10-31 | minor | M3, gap 10: library analytics cover styles and variables. A file reports how many of its layers use each of a library's styles and variables, and Show usage (`library.usage`) lists them beside the components, by a Type choice, with their layers and files. Layers inside instances count for their component. Insertions, detachments and modes aren't counted for them yet. Numbers unchanged. Tests: `library_assets_tests`. | diff --git a/crates/collab/src/board_sync_tests.rs b/crates/collab/src/board_sync_tests.rs index a54f6da6b..422d60134 100644 --- a/crates/collab/src/board_sync_tests.rs +++ b/crates/collab/src/board_sync_tests.rs @@ -25,7 +25,7 @@ fn a_boards_kind_and_its_objects_metadata_sync() { let mut edited = host.document().clone(); let c = edited.create(page, NodeKind::Vector { network: Default::default() }, "Connector", 0.0, 0.0, 1.0, 1.0).unwrap(); edited.node_mut(c).unwrap().board = Some(BoardItem::Connector(Connector { - from: End::Node { node: a, side: Side::Auto }, + from: End::Node { node: a, side: Side::Auto, at: None }, to: End::Point { x: 5.0, y: 5.0 }, routing: Routing::Elbow, path: None, @@ -34,7 +34,7 @@ fn a_boards_kind_and_its_objects_metadata_sync() { r.apply(&diff(&host, &edited).unwrap().changes, Mode::Strict).unwrap(); let mut moved = r.document().clone(); moved.node_mut(c).unwrap().board = Some(BoardItem::Connector(Connector { - from: End::Node { node: a, side: Side::Top }, + from: End::Node { node: a, side: Side::Top, at: None }, to: End::Point { x: 9.0, y: 1.0 }, routing: Routing::Straight, path: None, diff --git a/crates/doc/src/board.rs b/crates/doc/src/board.rs index 00451a2a9..89413b2e9 100644 --- a/crates/doc/src/board.rs +++ b/crates/doc/src/board.rs @@ -577,6 +577,9 @@ pub enum End { node: NodeId, #[serde(default)] side: Side, + /// Where on the layer it sits by hand, as fractions of its box (`None`: on its side). + #[serde(default, skip_serializing_if = "Option::is_none")] + at: Option<[f64; 2]>, }, Point { x: f64, diff --git a/crates/doc/src/board_route.rs b/crates/doc/src/board_route.rs index 037fb3f91..1aa45c810 100644 --- a/crates/doc/src/board_route.rs +++ b/crates/doc/src/board_route.rs @@ -12,12 +12,14 @@ use crate::{ConnectorPath, Routing, Side}; pub enum RouteEnd { Point(Point), Box(Rect, Side), + /// A point on the object in the box, placed by hand, with the side the route leaves by. + On(Rect, Side, Point), } impl RouteEnd { fn centre(self) -> Point { match self { - RouteEnd::Point(p) => p, + RouteEnd::Point(p) | RouteEnd::On(_, _, p) => p, RouteEnd::Box(b, _) => b.center(), } } @@ -166,22 +168,26 @@ fn attach(e: RouteEnd, other: Point) -> (Point, Option) { match e { RouteEnd::Point(p) => (p, None), RouteEnd::Box(b, side) => { - let side = match side { - Side::Auto => { - let d = other - b.center(); - // Compare against the box's proportions: a wide box takes the sides more. - if d.x.abs() * b.height().max(1e-9) >= d.y.abs() * b.width().max(1e-9) { - if d.x >= 0.0 { Side::Right } else { Side::Left } - } else if d.y >= 0.0 { - Side::Bottom - } else { - Side::Top - } - } - s => s, - }; + let side = named_or_facing(b, side, other); (side.point(b), Some(side)) } + RouteEnd::On(b, side, p) => (p, Some(named_or_facing(b, side, other))), + } +} + +/// `side`, or for [`Side::Auto`] the side of `b` facing `other`. +fn named_or_facing(b: Rect, side: Side, other: Point) -> Side { + if side != Side::Auto { + return side; + } + let d = other - b.center(); + // Compare against the box's proportions: a wide box takes the sides more. + if d.x.abs() * b.height().max(1e-9) >= d.y.abs() * b.width().max(1e-9) { + if d.x >= 0.0 { Side::Right } else { Side::Left } + } else if d.y >= 0.0 { + Side::Bottom + } else { + Side::Top } } diff --git a/crates/engine/src/board_connected_tests.rs b/crates/engine/src/board_connected_tests.rs index 3f97b84e7..2e5f8a344 100644 --- a/crates/engine/src/board_connected_tests.rs +++ b/crates/engine/src/board_connected_tests.rs @@ -40,7 +40,7 @@ fn a_shape_gets_the_next_one_joined_on_each_side() { assert!(matches!(nb.board, Some(BoardItem::Shape { shape: BoardShape::Diamond })), "{side}: the same shape"); assert_eq!(colour(&s, b), colour(&s, a), "{side}: the same colour"); let k = connector(&s, c); - assert_eq!((k.from, k.to), (End::Node { node: a, side: ends.0 }, End::Node { node: b, side: ends.1 }), "{side}"); + assert_eq!((k.from, k.to), (End::Node { node: a, side: ends.0, at: None }, End::Node { node: b, side: ends.1, at: None }), "{side}"); assert_eq!(k.routing, s.tool.board.routing, "{side}: as the connector tool makes one"); assert_eq!(s.selection(), vec![b], "{side}: the new one is selected"); } @@ -93,7 +93,7 @@ fn the_next_sticky_shortcut_joins_the_next_shape_and_leaves_stickies_as_they_wer let (b, c) = (id(&r, "id"), id(&r, "connector")); assert!(matches!(node(&s, b).board, Some(BoardItem::Shape { shape: BoardShape::Rectangle }))); assert_eq!(at(&s, b), (200.0 + CONNECTED_GAP, 0.0)); - assert_eq!(connector(&s, c).from, End::Node { node: a, side: Side::Right }); + assert_eq!(connector(&s, c).from, End::Node { node: a, side: Side::Right, at: None }); assert_eq!(s.ds().unwrap().history.undo.len(), undo + 1, "one undo step"); // Another side, as the command takes one. let r = s.execute("board.nextSticky", &json!({"id": a, "side": "below"})).unwrap(); diff --git a/crates/engine/src/board_connector_tests.rs b/crates/engine/src/board_connector_tests.rs index 14578f178..da5b3b924 100644 --- a/crates/engine/src/board_connector_tests.rs +++ b/crates/engine/src/board_connector_tests.rs @@ -25,7 +25,7 @@ fn three() -> (Session, [figcraft_doc::NodeId; 4]) { fn reattach_moves_one_end_and_undoes() { let (mut s, [a, b, c, k]) = three(); s.execute("board.reattach", &json!({"id": k, "end": "to", "target": c, "side": "top"})).unwrap(); - assert_eq!(connector(&s, k).to, End::Node { node: c, side: Side::Top }); + assert_eq!(connector(&s, k).to, End::Node { node: c, side: Side::Top, at: None }); assert_eq!(connector(&s, k).from.node(), Some(a)); assert!(near(*drawn(&s, k).last().unwrap(), Point::new(650.0, 0.0)), "arrives on c's top: {:?}", drawn(&s, k)); s.execute("edit.undo", &json!({})).unwrap(); @@ -257,7 +257,7 @@ fn an_end_dropped_along_a_side_attaches_to_that_side() { let want = (side != Side::Auto).then(|| side.point(bounds)).map(|p| [p.x, p.y]); assert_eq!(lit, want.into_iter().collect::>(), "{drop:?}"); s.pointer(&ev(PointerKind::Up, drop)).unwrap(); - assert_eq!(connector(&s, k).from, End::Node { node: a, side }, "{drop:?}"); + assert_eq!(connector(&s, k).from, End::Node { node: a, side, at: None }, "{drop:?}"); if side != Side::Auto { assert_eq!(drawn(&s, k)[0], side.point(bounds), "the route leaves from that side"); } @@ -277,7 +277,10 @@ fn the_connector_tool_takes_the_side_it_starts_and_ends_on() { s.pointer(&ev(PointerKind::Move, 590.0, 330.0)).unwrap(); s.pointer(&ev(PointerKind::Up, 590.0, 330.0)).unwrap(); let k = the_connector(&s).unwrap().id; - assert_eq!((connector(&s, k).from, connector(&s, k).to), (End::Node { node: a, side: Side::Bottom }, End::Node { node: b, side: Side::Right })); + assert_eq!( + (connector(&s, k).from, connector(&s, k).to), + (End::Node { node: a, side: Side::Bottom, at: None }, End::Node { node: b, side: Side::Right, at: None }) + ); } #[test] @@ -300,6 +303,9 @@ fn a_connector_end_snaps_to_the_attach_point_under_the_pointer() { s.pointer(&ev(PointerKind::Move, 352.0, 204.0)).unwrap(); s.pointer(&ev(PointerKind::Up, 352.0, 204.0)).unwrap(); let k = the_connector(&s).unwrap().id; - assert_eq!((connector(&s, k).from, connector(&s, k).to), (End::Node { node: a, side: Side::Right }, End::Node { node: b, side: Side::Top })); + assert_eq!( + (connector(&s, k).from, connector(&s, k).to), + (End::Node { node: a, side: Side::Right, at: None }, End::Node { node: b, side: Side::Top, at: None }) + ); assert_eq!(drawn(&s, k), vec![Point::new(100.0, 50.0), Point::new(350.0, 200.0)]); } diff --git a/crates/engine/src/board_reshape_tests.rs b/crates/engine/src/board_reshape_tests.rs index 30c57dba1..5a73267e3 100644 --- a/crates/engine/src/board_reshape_tests.rs +++ b/crates/engine/src/board_reshape_tests.rs @@ -71,8 +71,8 @@ fn a_reshaped_connector_keeps_the_sides_it_left_by() { let (portion, at) = handles[handles.len() / 2]; reshape(&mut s, k, portion as f64, at.x + 30.0, at.y + 30.0).unwrap(); let c = connector(&s, k); - assert!(matches!(c.from, End::Node { node, side } if node == a && side != Side::Auto), "{c:?}"); - assert!(matches!(c.to, End::Node { node, side } if node == b && side != Side::Auto), "{c:?}"); + assert!(matches!(c.from, End::Node { node, side, .. } if node == a && side != Side::Auto), "{c:?}"); + assert!(matches!(c.to, End::Node { node, side, .. } if node == b && side != Side::Auto), "{c:?}"); } /// "For the start and end portions of a connector, dragging can add an extra portion to the path." diff --git a/crates/engine/src/board_tests.rs b/crates/engine/src/board_tests.rs index d684da355..6849ac421 100644 --- a/crates/engine/src/board_tests.rs +++ b/crates/engine/src/board_tests.rs @@ -201,7 +201,12 @@ fn connectors_follow_what_they_join_and_undo_with_it() { fn routes_are_the_same_for_the_same_document() { let mut s = board(); let a = id(&s.execute("board.shape", &json!({"x": 0, "y": 0})).unwrap(), "id"); - let c = Connector { from: End::Node { node: a, side: Side::Bottom }, to: End::Point { x: 300.0, y: 500.0 }, routing: Routing::Elbow, path: None }; + let c = Connector { + from: End::Node { node: a, side: Side::Bottom, at: None }, + to: End::Point { x: 300.0, y: 500.0 }, + routing: Routing::Elbow, + path: None, + }; let doc = s.doc().unwrap(); let r = route(doc, &c).unwrap(); assert_eq!(r, route(&doc.clone(), &c).unwrap()); diff --git a/crates/engine/src/board_tools_tests.rs b/crates/engine/src/board_tools_tests.rs index 2afc2d5e3..6862e4219 100644 --- a/crates/engine/src/board_tools_tests.rs +++ b/crates/engine/src/board_tools_tests.rs @@ -97,7 +97,7 @@ fn routes_take_named_sides_and_free_points() { let b = id(&s.execute("board.shape", &json!({"x": 300, "y": 300, "width": 100, "height": 100})).unwrap(), "id"); let doc = s.doc().unwrap(); let elbow = |from, to| route(doc, &figcraft_doc::Connector { from, to, routing: figcraft_doc::Routing::Elbow, path: None }).unwrap(); - let side = |n, side| End::Node { node: n, side }; + let side = |n, side| End::Node { node: n, side, at: None }; let p = |x, y| End::Point { x, y }; let pt = Point::new; // Side to side: out, across halfway, in. diff --git a/crates/engine/src/boards/board.rs b/crates/engine/src/boards/board.rs index 5f7259b01..bf7f8ccf1 100644 --- a/crates/engine/src/boards/board.rs +++ b/crates/engine/src/boards/board.rs @@ -128,14 +128,14 @@ pub fn specs() -> Vec { spec!( "board.connector", "Connector", - "{from, to (each a layer id or {x, y} on the page), fromSide?, toSide? (auto|top|right|bottom|left), style?: straight|elbow|curved, startCap?, endCap? (none|lineArrow|triangleArrow|circleFilled|diamondFilled…; default an arrow at the end), label?} → {id, label?} a line that follows the layers it joins", + "{from, to (each a layer id or {x, y} on the page), fromSide?, toSide? (auto|top|right|bottom|left), fromAt?, toAt? ([x, y] on the layer, fractions of its box), style?: straight|elbow|curved, startCap?, endCap? (none|lineArrow|triangleArrow|circleFilled|diamondFilled…; default an arrow at the end), label?} → {id, label?} a line that follows the layers it joins", true, connector_cmd ), spec!( "board.reattach", "Reattach connector", - "{id? (a connector; default the selection), end: from|to, target (a layer id, or {x, y} on the page to leave the end free), side? (auto|top|right|bottom|left)} → {id} moves one end of a connector", + "{id? (a connector; default the selection), end: from|to, target (a layer id, or {x, y} on the page to leave the end free), side? (auto|top|right|bottom|left), at? ([x, y] on the layer, fractions of its box)} → {id} moves one end of a connector", true, reattach ), diff --git a/crates/engine/src/boards/board/connectors.rs b/crates/engine/src/boards/board/connectors.rs index ee93ab71b..f12a7792a 100644 --- a/crates/engine/src/boards/board/connectors.rs +++ b/crates/engine/src/boards/board/connectors.rs @@ -6,8 +6,21 @@ use super::{ VectorNetwork, bad, cap_of, get, id_of, json, num, req_targets, require, routing_of, string, }; -/// One connector end from the params: a layer on this page, or a point. -fn end_param(cmd: &str, s: &Session, p: &Value, key: &str, side_key: &str) -> Result { +/// Where on a layer a connector end sits, from the params: `[x, y]`, fractions of its box. +fn at_param(cmd: &str, p: &Value, key: &str) -> Result> { + let Some(v) = get(p, key).filter(|v| !v.is_null()) else { return Ok(None) }; + match v.as_array().map(Vec::as_slice) { + Some([x, y]) => match (x.as_f64().filter(|x| x.is_finite()), y.as_f64().filter(|y| y.is_finite())) { + (Some(x), Some(y)) => Ok(Some([x.clamp(0.0, 1.0), y.clamp(0.0, 1.0)])), + _ => Err(bad(cmd, format!("`{key}` is [x, y], fractions of the layer's box"))), + }, + _ => Err(bad(cmd, format!("`{key}` is [x, y], fractions of the layer's box"))), + } +} + +/// One connector end from the params (`keys`: its target, side and place on the layer): a layer on +/// this page, or a point. +fn end_param(cmd: &str, s: &Session, p: &Value, [key, side_key, at_key]: [&str; 3]) -> Result { let side = match string(p, side_key) { Some(v) => Side::from_id(&v).ok_or_else(|| bad(cmd, format!("`{side_key}` is auto, top, right, bottom or left")))?, None => Side::Auto, @@ -30,7 +43,7 @@ fn end_param(cmd: &str, s: &Session, p: &Value, key: &str, side_key: &str) -> Re if matches!(n.board, Some(BoardItem::Connector(_))) { return Err(bad(cmd, format!("`{key}` can't be another connector"))); } - Ok(End::Node { node, side }) + Ok(End::Node { node, side, at: at_param(cmd, p, at_key)? }) } None => Err(bad(cmd, format!("`{key}` is required"))), } @@ -39,8 +52,8 @@ fn end_param(cmd: &str, s: &Session, p: &Value, key: &str, side_key: &str) -> Re pub(super) fn connector_cmd(s: &mut Session, p: &Value) -> Result { let cmd = "board.connector"; require(s, cmd)?; - let from = end_param(cmd, s, p, "from", "fromSide")?; - let to = end_param(cmd, s, p, "to", "toSide")?; + let from = end_param(cmd, s, p, ["from", "fromSide", "fromAt"])?; + let to = end_param(cmd, s, p, ["to", "toSide", "toAt"])?; if from.node().is_some() && from.node() == to.node() { return Err(bad(cmd, "connect two different layers")); } @@ -106,7 +119,7 @@ pub(super) fn reattach(s: &mut Session, p: &Value) -> Result { Some("to") => false, _ => return Err(bad(cmd, "`end` is from or to")), }; - let end = end_param(cmd, s, p, "target", "side")?; + let end = end_param(cmd, s, p, ["target", "side", "at"])?; let other = if from { c.to } else { c.from }; if end.node().is_some() && end.node() == other.node() { return Err(bad(cmd, "connect two different layers")); @@ -141,6 +154,14 @@ pub(super) fn side_json(e: End) -> Value { } } +/// Where a connector end sits on its layer, as the commands take it. +pub(super) fn at_json(e: End) -> Value { + match e { + End::Node { at: Some(at), .. } => json!(at), + _ => Value::Null, + } +} + /// The end of connector `id` that stays while its other one (`from`: its start) is dragged. pub(super) fn other_end(doc: &Document, id: NodeId, from: bool) -> Option { match &doc.node(id)?.board { diff --git a/crates/engine/src/boards/board/derived.rs b/crates/engine/src/boards/board/derived.rs index ec6998617..9fae17206 100644 --- a/crates/engine/src/boards/board/derived.rs +++ b/crates/engine/src/boards/board/derived.rs @@ -297,7 +297,10 @@ pub fn route(doc: &Document, c: &Connector) -> Option> { pub(super) fn route_ends(doc: &Document, c: &Connector) -> Option<(RouteEnd, RouteEnd)> { let end = |e: &End| match e { End::Point { x, y } => Some(RouteEnd::Point(Point::new(*x, *y))), - End::Node { node, side } => doc.world_bounds(*node).map(|b| RouteEnd::Box(b, *side)), + End::Node { node, side, at } => doc.world_bounds(*node).map(|b| match at { + Some([x, y]) => RouteEnd::On(b, *side, Point::new(b.x0 + x * b.width(), b.y0 + y * b.height())), + None => RouteEnd::Box(b, *side), + }), }; Some((end(&c.from)?, end(&c.to)?)) } diff --git a/crates/engine/src/boards/board/items.rs b/crates/engine/src/boards/board/items.rs index 936bd1df1..44708d944 100644 --- a/crates/engine/src/boards/board/items.rs +++ b/crates/engine/src/boards/board/items.rs @@ -232,7 +232,12 @@ pub(super) fn add_connected(s: &mut Session, p: &Value) -> Result { None => make_sticky(doc, parent, to_at, "", color, &author)?, Some(shape) => make_shape(doc, parent, to_at, next, shape, "", color)?, }; - let connector = Connector { from: End::Node { node: from, side: from_side }, to: End::Node { node: id, side: to_side }, routing, path: None }; + let connector = Connector { + from: End::Node { node: from, side: from_side, at: None }, + to: End::Node { node: id, side: to_side, at: None }, + routing, + path: None, + }; let line = make(doc, page, connector, CAPS)?; s.set_selection(vec![id]); if boolean(p, "edit") == Some(true) { diff --git a/crates/engine/src/boards/board/reshape.rs b/crates/engine/src/boards/board/reshape.rs index 1f926ea3a..2f441c7ce 100644 --- a/crates/engine/src/boards/board/reshape.rs +++ b/crates/engine/src/boards/board/reshape.rs @@ -16,7 +16,7 @@ const MIN_RUN_PX: f64 = 28.0; /// `end` with the side it leaves by named: a reshaped path holds only as long as its ends do. fn pinned(end: End, side: Option) -> End { match (end, side) { - (End::Node { node, .. }, Some(side)) => End::Node { node, side }, + (End::Node { node, at, .. }, Some(side)) => End::Node { node, side, at }, _ => end, } } diff --git a/crates/engine/src/boards/board/tools.rs b/crates/engine/src/boards/board/tools.rs index 620e1408c..cf4753357 100644 --- a/crates/engine/src/boards/board/tools.rs +++ b/crates/engine/src/boards/board/tools.rs @@ -1,4 +1,4 @@ -use super::connectors::{CAPS, end_json, make, other_end, side_json}; +use super::connectors::{CAPS, at_json, end_json, make, other_end, side_json}; use super::derived::{ends_now, route}; use super::edit::label_of; use super::items::edit_text; @@ -10,6 +10,11 @@ use super::{ /// How near (screen pixels) the pointer must be to the middle of an object's side for a connector /// end to attach there, however small the object is on screen. const ATTACH_PX: f64 = 10.0; + +/// Point `p` as fractions of box `b` (`None`: a box with no area). +fn part_at(b: figcraft_geom::Rect, p: Point) -> Option<[f64; 2]> { + (b.width() > 0.0 && b.height() > 0.0).then(|| [((p.x - b.x0) / b.width()).clamp(0.0, 1.0), ((p.y - b.y0) / b.height()).clamp(0.0, 1.0)]) +} /// The sides an object takes a connector end at (their middles are its attach points). const SIDES: [Side; 4] = [Side::Top, Side::Right, Side::Bottom, Side::Left]; @@ -92,7 +97,8 @@ impl Session { } /// What a connector end at the pointer attaches to: the object there (at the side whose middle - /// is within [`ATTACH_PX`], else the side [`side_at`] the pointer), or the point. + /// is within [`ATTACH_PX`], else the side [`side_at`] the pointer; with Cmd/Ctrl held, right + /// there), or the point. fn end_at(&self, ev: &PointerEvent) -> End { let zoom = ev.zoom.max(1e-6); let hit = (|| { @@ -109,7 +115,10 @@ impl Session { let far = |s: &Side| b.map(|b| (s.point(b) - ev.p()).hypot() * zoom).filter(|d| *d <= ATTACH_PX); let near = SIDES.iter().filter_map(|s| Some((*s, far(s)?))).min_by(|x, y| x.1.total_cmp(&y.1)).map(|(s, _)| s); let side = near.or(b.map(|b| side_at(b, ev.p()))).unwrap_or(Side::Auto); - Some(End::Node { node: t, side }) + match b.and_then(|b| part_at(b, ev.p())).filter(|_| ev.cmd) { + Some(at) => Some(End::Node { node: t, side: Side::Auto, at: Some(at) }), + None => Some(End::Node { node: t, side, at: None }), + } })(); hit.unwrap_or(End::Point { x: ev.x, y: ev.y }) } @@ -128,7 +137,7 @@ impl Session { /// (`at`, else its named side's) lit. fn show_attach(&mut self, end: Option, at: Option) { let attach = match end { - Some(End::Node { node, side }) => self.doc().ok().and_then(|d| d.world_bounds(node)).map(|b| { + Some(End::Node { node, side, .. }) => self.doc().ok().and_then(|d| d.world_bounds(node)).map(|b| { let on = at.or_else(|| (side != Side::Auto).then(|| side.point(b))); SIDES.map(|s| s.point(b)).map(|p| ([p.x, p.y], on.is_some_and(|o| (o - p).hypot() < 1e-6))).to_vec() }), @@ -179,7 +188,8 @@ impl Session { self.cancel_interaction()?; let Some(to) = self.drag_end(ev, from, start) else { return Ok(()) }; let style = self.tool.board.routing.id(); - let p = json!({"from": end_json(from), "fromSide": side_json(from), "to": end_json(to), "toSide": side_json(to), "style": style}); + let (from_at, to_at) = (at_json(from), at_json(to)); + let p = json!({"from": end_json(from), "fromSide": side_json(from), "fromAt": from_at, "to": end_json(to), "toSide": side_json(to), "toAt": to_at, "style": style}); self.execute("board.connector", &p)?; self.board_placed(); Ok(()) @@ -214,7 +224,7 @@ impl Session { return Ok(()); } let end = if from { "from" } else { "to" }; - self.execute("board.reattach", &json!({"id": id, "end": end, "target": end_json(target), "side": side_json(target)}))?; + self.execute("board.reattach", &json!({"id": id, "end": end, "target": end_json(target), "side": side_json(target), "at": at_json(target)}))?; Ok(()) } diff --git a/crates/engine/src/connector_anywhere_tests.rs b/crates/engine/src/connector_anywhere_tests.rs new file mode 100644 index 000000000..15776fb23 --- /dev/null +++ b/crates/engine/src/connector_anywhere_tests.rs @@ -0,0 +1,100 @@ +//! A connector end placed on any part of an object: holding Cmd/Ctrl while moving it puts it right +//! where the pointer is, and it stays at that part of the object when the object moves or resizes. + +use figcraft_doc::{End, NodeId, Side}; +use figcraft_geom::Point; +use serde_json::json; + +use crate::Session; +use crate::board_tests::{board, connector, drawn, id, near}; +use crate::tools::{PointerEvent, PointerKind}; + +/// A board with two 200 × 100 shapes and a straight connector between them: (a, b, connector). +fn scene() -> (Session, NodeId, NodeId, NodeId) { + let mut s = board(); + let mut shape = |x: f64| id(&s.execute("board.shape", &json!({"x": x, "y": 0, "width": 200, "height": 100})).unwrap(), "id"); + let (a, b) = (shape(0.0), shape(500.0)); + let k = id(&s.execute("board.connector", &json!({"from": a, "to": b, "style": "straight"})).unwrap(), "id"); + s.execute("select.set", &json!({"ids": [k]})).unwrap(); + (s, a, b, k) +} + +/// Drag the connector's end at `from` to `to`, with Cmd/Ctrl held or not. +fn drag_end(s: &mut Session, from: Point, to: Point, cmd: bool) { + let ev = |kind, p: Point| PointerEvent { cmd, ..PointerEvent::at(kind, p.x, p.y, 1.0) }; + s.pointer(&ev(PointerKind::Down, from)).unwrap(); + s.pointer(&ev(PointerKind::Move, Point::new(300.0, 300.0))).unwrap(); + s.pointer(&ev(PointerKind::Move, to)).unwrap(); + s.pointer(&ev(PointerKind::Up, to)).unwrap(); +} + +#[test] +fn cmd_puts_an_end_on_any_part_of_the_object() { + let (mut s, _, b, k) = scene(); + let end = *drawn(&s, k).last().unwrap(); + drag_end(&mut s, end, Point::new(650.0, 25.0), true); + assert_eq!(connector(&s, k).to, End::Node { node: b, side: Side::Auto, at: Some([0.75, 0.25]) }); + assert!(near(*drawn(&s, k).last().unwrap(), Point::new(650.0, 25.0)), "{:?}", drawn(&s, k)); + // It undoes as one step, back to the side. + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(connector(&s, k).to, End::Node { node: b, side: Side::Auto, at: None }); +} + +#[test] +fn without_cmd_an_end_keeps_snapping_to_a_side() { + let (mut s, _, b, k) = scene(); + let end = *drawn(&s, k).last().unwrap(); + drag_end(&mut s, end, Point::new(650.0, 25.0), false); + assert_eq!(connector(&s, k).to, End::Node { node: b, side: Side::Right, at: None }); + assert!(near(*drawn(&s, k).last().unwrap(), Point::new(700.0, 50.0))); +} + +#[test] +fn the_end_stays_on_that_part_when_the_object_moves_or_resizes() { + let (mut s, _, b, k) = scene(); + s.execute("board.reattach", &json!({"id": k, "end": "to", "target": b, "at": [0.5, 0.5]})).unwrap(); + assert!(near(*drawn(&s, k).last().unwrap(), Point::new(600.0, 50.0))); + s.execute("node.set", &json!({"id": b, "props": {"x": 600, "y": 100, "width": 400, "height": 200}})).unwrap(); + assert!(near(*drawn(&s, k).last().unwrap(), Point::new(800.0, 200.0)), "{:?}", drawn(&s, k)); +} + +#[test] +fn an_end_moved_back_without_cmd_leaves_its_place() { + let (mut s, _, b, k) = scene(); + s.execute("board.reattach", &json!({"id": k, "end": "to", "target": b, "at": [0.5, 0.5]})).unwrap(); + s.execute("board.reattach", &json!({"id": k, "end": "to", "target": b, "side": "top"})).unwrap(); + assert_eq!(connector(&s, k).to, End::Node { node: b, side: Side::Top, at: None }); +} + +#[test] +fn the_commands_take_a_place_and_refuse_junk() { + let (mut s, a, b, _) = scene(); + let k = id(&s.execute("board.connector", &json!({"from": a, "to": b, "fromAt": [0.25, 0.5], "toAt": [9, -1]})).unwrap(), "id"); + assert!(matches!(connector(&s, k).from, End::Node { at: Some([0.25, 0.5]), .. })); + assert!(matches!(connector(&s, k).to, End::Node { at: Some([1.0, 0.0]), .. }), "kept on the object"); + for junk in [json!("middle"), json!([1]), json!([1, 2, 3]), json!(["x", 0]), json!({"x": 0, "y": 0})] { + assert!(s.execute("board.reattach", &json!({"id": k, "end": "to", "target": b, "at": junk})).is_err()); + } +} + +#[test] +fn a_reshaped_path_keeps_the_end_where_it_is() { + let (mut s, a, b, _) = scene(); + let k = id(&s.execute("board.connector", &json!({"from": a, "to": b, "style": "curved", "toAt": [0.5, 0.5]})).unwrap(), "id"); + s.execute("board.reshape", &json!({"id": k, "portion": 0, "x": 350, "y": 200})).unwrap(); + assert!(matches!(connector(&s, k).to, End::Node { at: Some([0.5, 0.5]), .. })); + assert!(near(*drawn(&s, k).last().unwrap(), Point::new(600.0, 50.0)), "{:?}", drawn(&s, k)); +} + +#[test] +fn it_saves_only_when_placed_by_hand() { + let (mut s, _, b, k) = scene(); + let saved = |s: &Session| figcraft_format::to_json(s.doc().unwrap()).unwrap().to_string(); + assert!(!saved(&s).contains("\"at\":"), "older files stay as they are"); + s.execute("board.reattach", &json!({"id": k, "end": "to", "target": b, "at": [0.5, 0.25]})).unwrap(); + assert!(saved(&s).contains("\"at\":[0.5,0.25]")); + let bytes = figcraft_format::save_bytes(s.doc().unwrap(), None).unwrap(); + let (open, _) = figcraft_format::load_bytes(&bytes).unwrap(); + let kept = open.node(k).and_then(|n| n.board.clone()); + assert!(matches!(kept, Some(figcraft_doc::BoardItem::Connector(c)) if matches!(c.to, End::Node { at: Some([0.5, 0.25]), .. }))); +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 4c9537b02..1c0b7e39a 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -263,6 +263,8 @@ mod components_behaviour_tests; #[cfg(test)] mod components_scale_tests; #[cfg(test)] +mod connector_anywhere_tests; +#[cfg(test)] mod convert_container_tests; #[cfg(test)] mod cursor_tests; diff --git a/crates/format/src/fig/map/connector.rs b/crates/format/src/fig/map/connector.rs index e165e80ab..7da7458b9 100644 --- a/crates/format/src/fig/map/connector.rs +++ b/crates/format/src/fig/map/connector.rs @@ -87,7 +87,7 @@ impl<'a> Mapper<'a> { }; let finite = |p: Point| p.x.is_finite() && p.y.is_finite(); let ok = match end { - RouteEnd::Point(p) => finite(p), + RouteEnd::Point(p) | RouteEnd::On(_, _, p) => finite(p), RouteEnd::Box(b, _) => finite(b.origin()) && finite(Point::new(b.x1, b.y1)), }; ok.then_some(end) diff --git a/crates/ui-egui/src/board_ui_tests.rs b/crates/ui-egui/src/board_ui_tests.rs index 3ec4a3431..554d117d9 100644 --- a/crates/ui-egui/src/board_ui_tests.rs +++ b/crates/ui-egui/src/board_ui_tests.rs @@ -234,8 +234,8 @@ fn the_next_shape_from_the_keyboard_comes_connected_and_goes_on_typing() { let b = figcraft_engine::board::owner_of_text(doc, te.id).unwrap(); assert!(matches!(doc.node(b).unwrap().board, Some(BoardItem::Shape { shape: BoardShape::Diamond }))); let joined = doc.nodes().any(|n| { - matches!(&n.board, Some(BoardItem::Connector(k)) if k.from == figcraft_doc::End::Node { node: a, side: figcraft_doc::Side::Right } - && k.to == figcraft_doc::End::Node { node: b, side: figcraft_doc::Side::Left }) + matches!(&n.board, Some(BoardItem::Connector(k)) if k.from == figcraft_doc::End::Node { node: a, side: figcraft_doc::Side::Right, at: None } + && k.to == figcraft_doc::End::Node { node: b, side: figcraft_doc::Side::Left, at: None }) }); assert!(joined, "a connector from the first shape's right side to the new one's left"); } @@ -362,3 +362,39 @@ fn preferences_set_the_board_grid_and_the_default_dots_keep_their_size() { let shapes = flat(&u.frame_output(vec![]).shapes); assert!(has_text(&shapes, "Board grid") && !has_text(&shapes, "Spacing")); } + +/// Moving a selected connector's end with the mouse: it snaps to a side, and with Cmd/Ctrl held it +/// goes where the pointer is on the object. +#[test] +fn cmd_while_moving_a_connector_end_places_it_on_the_object() { + let mut u = board(); + let a = id(&u.app.run("board.shape", json!({"shape": "rectangle", "x": 0, "y": 0, "width": 200, "height": 100})).unwrap()); + let b = id(&u.app.run("board.shape", json!({"shape": "rectangle", "x": 400, "y": 0, "width": 200, "height": 100})).unwrap()); + let k = id(&u.app.run("board.connector", json!({"from": a, "to": b, "style": "straight"})).unwrap()); + u.app.run("select.set", json!({"ids": [k]})); + u.frame(vec![]); + u.frame(vec![]); + let end = |u: &Ui| match &u.app.session.doc().unwrap().node(k).unwrap().board { + Some(BoardItem::Connector(c)) => c.to, + other => panic!("{other:?}"), + }; + let drag = |u: &mut Ui, mods: Modifiers| { + let from = u.app.to_screen(400.0, 50.0); + let to = u.app.to_screen(550.0, 25.0); + u.frame_with(vec![Event::PointerMoved(from)], mods); + let press = |pos, pressed| Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: mods }; + u.frame_with(vec![press(from, true)], mods); + for t in 1..=8 { + u.frame_with(vec![Event::PointerMoved(from + (to - from) * (t as f32 / 8.0))], mods); + } + u.frame_with(vec![press(to, false)], mods); + u.frame_with(vec![], mods); + }; + drag(&mut u, Modifiers::COMMAND); + assert_eq!(end(&u), figcraft_doc::End::Node { node: b, side: figcraft_doc::Side::Auto, at: Some([0.75, 0.25]) }); + u.app.run("edit.undo", json!({})); + u.app.run("select.set", json!({"ids": [k]})); + u.frame(vec![]); + drag(&mut u, Modifiers::NONE); + assert!(matches!(end(&u), figcraft_doc::End::Node { at: None, side: figcraft_doc::Side::Right, .. }), "{:?}", end(&u)); +} diff --git a/docs/boards-parity.md b/docs/boards-parity.md index 7ace47651..7526d39aa 100644 --- a/docs/boards-parity.md +++ b/docs/boards-parity.md @@ -1,6 +1,6 @@ # Boards parity -> **Last reviewed:** 2026-10-24 · **Last updated:** 2026-10-30 · **Change:** minor (a connector end dropped along a side of a shape attaches to that side (#1708); a board connector's path can be reshaped by dragging its path handles (#1710); boards have 16 more shapes (arrows, polygons, star, plus and flowchart symbols), in pickers that lay them out in a grid; a board keeps earlier votes' results, listed newest first in Results and deleted one at a time; a connector's bar sets its start and end points, dashes, weight and any colour (#1596); cells copied from a spreadsheet paste onto a board as a table; Shift+Cmd/Ctrl+Enter on a selected board shape adds the next shape already joined to it, and goes on typing (#1597); "+" handles beside a selected sticky or shape make the next one, joined by a connector (#1597); a FigJam `.jam` file opens as a board (#1342); board grid settings: dots, lines, graph paper or none, spacing, size and contrast; the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); templates and tables; facilitation: a shared timer and voting sessions; the dot grid, dragging a connector's end to re-attach it, curved connectors, elbows that go around what they join) · **Target:** the incumbent's whiteboard product (behaviour from its public help centre) +> **Last reviewed:** 2026-10-24 · **Last updated:** 2026-10-31 · **Change:** minor (a connector end moved with Cmd/Ctrl held goes on any part of an object, and stays there as the object moves or resizes; a connector end dropped along a side of a shape attaches to that side (#1708); a board connector's path can be reshaped by dragging its path handles (#1710); boards have 16 more shapes (arrows, polygons, star, plus and flowchart symbols), in pickers that lay them out in a grid; a board keeps earlier votes' results, listed newest first in Results and deleted one at a time; a connector's bar sets its start and end points, dashes, weight and any colour (#1596); cells copied from a spreadsheet paste onto a board as a table; Shift+Cmd/Ctrl+Enter on a selected board shape adds the next shape already joined to it, and goes on typing (#1597); "+" handles beside a selected sticky or shape make the next one, joined by a connector (#1597); a FigJam `.jam` file opens as a board (#1342); board grid settings: dots, lines, graph paper or none, spacing, size and contrast; the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); templates and tables; facilitation: a shared timer and voting sessions; the dot grid, dragging a connector's end to re-attach it, curved connectors, elbows that go around what they join) · **Target:** the incumbent's whiteboard product (behaviour from its public help centre) **Breadth ~47%, ready ~29% (estimated, erring low).** A board is a FigCraft file of its own kind (`file.newBoard`) with a dot grid on the page, sticky notes, shapes with text, connectors that @@ -40,6 +40,7 @@ all end with the same geometry. | Connectors | done | from a layer's side (the facing one, or one named) to another layer or a free point, straight, elbow or curved, arrowheads (the stroke's caps), a label in the middle of the route by length (double-click to add); while one is dragged out it shows as it will be made (route, line and arrowhead), the object under the pointer outlined with its four attach points (side middles), the one the end goes to ringed, and within 10 px of one the end keeps that side; re-routed when either end moves or resizes; a deleted end stays where it was | | Create the next shape already connected | done | a selected sticky or shape shows a "+" beside each side; hovering one shows where the next goes, clicking makes one of the same kind, size and colour there, joined by a connector as the Connector tool makes one, and starts typing in it; one undo step (`board.addConnected {id?, side?, edit?}`, #1597). Shift+Cmd/Ctrl+Enter on a selected (or edited) shape adds the next shape on its right, joined the same way, and goes on typing; on a sticky it adds a sticky beside it without a connector, as before | | Curved connectors, elbows around objects, dragging an end to re-attach | done | `board.setStyle {style: curved}` (and the contextual bar): one curve leaving and arriving along the sides; an elbow whose short way would cross what it joins goes around their boxes, 24 units clear; a selected connector shows its two ends as handles, and dragging one onto an object attaches it there (the object is outlined while dragging), onto empty canvas leaves it free (`board.reattach {id, end, target, side?}`, one undo step) | +| A connector end on any part of an object | done | holding Cmd/Ctrl while moving an end places it where the pointer is on the object, as a share of its box, so it follows moves and resizes; the line leaves by the side facing the other end. Without the key an end snaps to a side | | Connector paths reshaped by hand | done | a selected elbow or curved connector shows path handles: one on each run long enough to take one, one in the middle of a curve. Dragging a run moves it, and a first or last run gains an extra run to stay on its end; a curve's middle goes where it is dragged (`board.reshape`, one undo step). The shape travels with what the connector joins and is saved with the file; changing the style or moving an end to something else goes back to routing itself. No way back by itself otherwise: the public help describes none | | Quick-create handles (drag out a connected copy) | missing | | | Connector waypoints placed by hand | missing | routes are computed from the ends; no bend points of your own | @@ -69,6 +70,7 @@ all end with the same geometry. | Date | Change | Summary | |---|---|---| +| 2026-10-31 | minor | M12: as the help describes, holding Cmd/Ctrl while moving a connector's end places it on any part of an object instead of snapping to a side. It keeps that spot as a fraction of the object's box, so it follows the object when it moves or resizes, and without the key an end snaps as before. `board.connector {fromAt, toAt}` and `board.reattach {at}` take it; saved only when set. Numbers unchanged | | 2026-10-31 | patch | M12, #1708: a connector end dropped on the outer half of a shape takes the side it is towards, instead of only within 10 px of a side's middle, so a drop anywhere along an edge chooses that edge (dragging a selected connector's end and with the connector tool, ring included); a drop in the middle of a shape still takes the facing side. | | 2026-10-31 | minor | M12, #1710: a selected elbow or curved connector shows path handles. Dragging a run moves it and a first or last run gains an extra run to stay on its end; a curve's middle goes where it is dragged (`board.reshape`, one undo step). The shape travels with what the connector joins, is saved with the file, and goes back to routing itself when the style changes or an end moves to something else. | | 2026-10-31 | minor | M12: boards have 23 shapes with text instead of 7: triangle down, parallelogram right and left, trapezoid, pentagon, hexagon, octagon, star, plus, arrow left and right, and the flowchart symbols database, document, predefined process, manual input and internal storage. The toolbar flyout wraps into rows of eight and the selection bar's shape menu is a grid of outlines named on hover. The stale "Quick-create handles: missing" row now says what exists. | diff --git a/docs/mcp.md b/docs/mcp.md index af4affbc2..0e9f4f497 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -483,17 +483,19 @@ one undo step and returns the ids it made: in the app does both). - `board.shape {shape, x, y, width?, height?, text?, color?, parent?}` → `{id, text}`: a shape with centred, wrapping text: `rectangle roundedRectangle ellipse diamond triangle triangleDown parallelogramRight parallelogramLeft trapezoid pentagon hexagon octagon star plus arrowLeft arrowRight chevron speechBubble database document predefinedProcess manualInput internalStorage`. -- `board.connector {from, to, fromSide?, toSide?, style?: straight|elbow|curved, startCap?, endCap?, label?}` +- `board.connector {from, to, fromSide?, toSide?, fromAt?, toAt?, style?: straight|elbow|curved, startCap?, endCap?, label?}` → `{id, label?}`: `from`/`to` are layer ids or `{x, y}` page points; sides are `auto` (the side - facing the other end) or `top right bottom left`; caps are stroke caps (default an arrow at the + facing the other end) or `top right bottom left`; `fromAt`/`toAt` are `[x, y]` fractions of the + layer's box that put the end on that part of it (it leaves by `side`); caps are stroke caps (default an arrow at the end). An elbow goes around the objects it joins when the short way would cross them; a curved one leaves and arrives along the sides. The connector re-routes itself whenever what it joins moves or resizes, also when someone else in a live session moves it; a deleted end stays where it was as a point. `board.label {id, text}` sets its label (empty removes it). -- `board.reattach {id?, end: from|to, target, side?}` → `{id}`: moves one end of a connector +- `board.reattach {id?, end: from|to, target, side?, at?}` → `{id}`: moves one end of a connector (default the selected one; its label stands for it) onto `target`, a layer id or an `{x, y}` page point where the end stays free. Refused when both ends would be on one layer or the target - is a connector. Dragging a selected connector's end handle in the app does the same. A path + is a connector. Dragging a selected connector's end handle in the app does the same; with Cmd/Ctrl + held the end goes where the pointer is on the object (`at`, as in `board.connector`). A path reshaped by hand goes back to routing itself. - `board.reshape {id?, portion, x, y}` → `{id}`: moves one portion of a connector's path to that page point, as dragging a path handle in the app does: run `portion` of an elbow (a first or last diff --git a/docs/roadmap.md b/docs/roadmap.md index 76f176c25..984f39ff2 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-30 · **Change:** minor (a tap on a plugin panel's button keeps what was typed in its fields; a Dash cap setting (none, round or square) on Dash and Custom strokes, drawn on both ends of every dash; library analytics list how many layers and files use each style and variable; the Controller menu names each button as PlayStation and Switch controllers do; widgets draw lines and SVG pictures (plugin API 6); a connector end dropped along a side of a shape attaches to that side (#1708); regression tests pin a rounded shape's one-sided stroke and SVG ids and font families without XML-forbidden characters (#1351, #1124); a damaged image is refused with a reason instead of showing as an empty box (#1474); SwiftUI and Compose code names a bound variable by its iOS or Android code syntax; the colour picker's Image tab shows a placeholder and Upload from computer, an asset preview, and a file dropped on it replaces the image, keeping the fill mode, crop and position (#1682); the Frame tool's Phone presets have the iPhone 17 family (#1691); Show in folder works in the Flatpak build and selects the file on Linux (#1701); a dashed line starts and ends with a half-length dash, on the canvas and in exports; the Pages, Layers and Assets search fields keep their text in the middle of the field (#1703); a board connector's path can be reshaped by dragging its path handles (#1710); boards have 16 more shapes (arrows, polygons, star, plus and flowchart symbols), in pickers that lay them out in a grid; library analytics count insertions and show insertions and detachments over the past 30 days, 90 days or year; a stroke's Custom style takes a dash pattern of any length as a comma-separated list (#1681); a control request can set its own timeout, up to 10 minutes (`timeoutMs`; `figcraft-cli mcp --attach PORT --timeout SECONDS`) (#1696); gap 6: a widget opens its plugin's panel (plugin API 5), whose callbacks redraw it; the keyboard's Menu key opens the context menu, as Shift+F10 does; an Open link action can open in place of the prototype, with "Open in new tab" off (gap 15); a collection of this file can be extended (Extend collection on its row, `variable.extendCollection`), its extension following its edits at once, and extended collections extend again in the file; the eyedropper with Cmd/Ctrl+Shift makes a colour style or variable of what it picks; a live session's welcome sends the document compressed to clients that read it, about seven times smaller (#1665); a control request that times out says truthfully whether it was dropped or is still running and will take effect, and only screenshots and input blame the window (#1673); a board keeps earlier votes' results, listed newest first in Results and deleted one at a time; a server can list the files shared from it now for everyone who reaches it, Home ▸ Online shows them and their links carry no secret (`team_files`, part of #742); a selected connector's bar on a board sets its start and end points, solid or dashed, its weight and any colour (#1596); cells copied from a spreadsheet paste onto a board as a table; a long layer name stays on one line in the Export button and the Dev Mode header, ending with an ellipsis, and no longer covers the Design panel (#1650); with hundreds of layers selected, the Design panel and the selection overlays stop going over the whole selection on every frame, so dragging 648 layers averages about 1.1 ms of UI per frame (#1571); plugin panels show pictures: an `image` control, base64 within 256 KB and 4,096 px a side, named by its label (gap 6); a drag moving many layers re-runs only what sits around them, not each layer's bindings, sizing, text and instance checks, on every move (#1571); damaged font tracking table offsets are checked instead of wrapping on 32-bit targets, and font indexing makes its existing offset bounds explicit (#1125); pasted layers whose image the clipboard lacks are named and show a placeholder that reads as missing (#1646); paint-style colours on layers pasted from another design app keep their style instead of turning black (#1638); Shift+Cmd/Ctrl+Enter on a selected board shape adds the next shape already joined to it, and goes on typing (#1597); a name typed for a variable or collection finds the one with that exact id before one named like it, and stored mode ids, font family variables and the panels' labels read exact ids (#1613); plugin widgets have hover styles: a layer's `hover` fill, stroke, opacity or text colour shows while the pointer is over it (gap 6); every file a command reads whole by path is a regular file within a cap that fits it: documents 16 GiB, placed images 256 MiB, CLI scripts 1 GiB; compact layer trees with fields and outline (M0.9, part of #996); while a drag only moves a large selection, its outlines, box, slots, spacing and Design panel values are worked out once and shifted, not rebuilt on every move (#1571); an imported or pasted file finds an installed family under the other names its fonts give it, and with other case and spacing, instead of reporting it missing (#1292); a panic in a grouped closure outside any command rolls the group back like a failed group: documents, selection, undo and redo, history limit and journal as before (#1085); a development example generates figcraft-cli's shell completions and man pages from its clap definitions (part of #1396); `.fig` ZIP image reads share a total byte budget and count skipped images in the import warnings (#1076); stored variable and collection references keep their identity when another object is named like their id (follow-up to #1542); a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; the server key is checked by comparing SHA-256 hashes of the presented and the kept key, as the deployment guide says (#1130); opening a file contacts no team library server it names until you check it (#1097); a hostile server can no longer grow a client's memory with made-up people, and a session remembers a bounded number of deleted layer ids (#1121); `doc.list` gives AI agents and plugins the open files' names, not their paths (#1117); with a large selection, frames where nothing changed reuse the selection's outlines, box and slots and the Design panel's instance and position values instead of working them out again (#1571); team libraries and plugins larger than the 16 MiB download burst can be downloaded again, more slowly (#1089); a batch keeps one undo snapshot, not one per step (#1099); files nested 256 levels deep import on the browser's 1 MB stack (#1101); SVG and PDF exports count pattern sources and the renders they embed from the layer's depth, so one cap bounds a whole export (#1082); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); joining and leaving a file nobody is editing reads and writes nothing on the server: it stays loaded a minute after everyone leaves (#1074); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); hovering the canvas hit-tests once per pointer move, not on every frame, and moving an auto layout frame no longer lays out its contents again on every move (#1571); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); `comment.people` lists names that were only @mentioned, and `comment.next` and `comment.prompt` carry the mentions too (#1415); grid auto-placement looks for free cells a 64-bit word at a time and skips what blocks it, with the same placements (#1083); with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); a recovery copy made while a version is previewed holds the file, not the version shown; a FigJam `.jam` file opens as a board (#1342); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); share links can comment: a third access level beside edit and view, whose holder sees the file live and adds, replies to, reacts to and resolves comments but can't change the design; Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); `comment.list {mentions}` lists only the threads that @mention a name (#1415); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); figcraft-cli parses its arguments with clap, following RFC-0012 (#1396); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); Thai, Lao, Khmer and Burmese lines break between words (#1219); an AI agent attached over MCP shows where it works (#1416); a manual overlay's position can be dragged on the canvas (#405); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); SVG import draws each pattern tile once per scale, within the layer budget (#1080); Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); M8: mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); M2: components can't contain their own instances, directly or through others; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); 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) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (a connector end moved with Cmd/Ctrl held goes on any part of an object, and stays there as the object moves or resizes; a tap on a plugin panel's button keeps what was typed in its fields; a Dash cap setting (none, round or square) on Dash and Custom strokes, drawn on both ends of every dash; library analytics list how many layers and files use each style and variable; the Controller menu names each button as PlayStation and Switch controllers do; widgets draw lines and SVG pictures (plugin API 6); a connector end dropped along a side of a shape attaches to that side (#1708); regression tests pin a rounded shape's one-sided stroke and SVG ids and font families without XML-forbidden characters (#1351, #1124); a damaged image is refused with a reason instead of showing as an empty box (#1474); SwiftUI and Compose code names a bound variable by its iOS or Android code syntax; the colour picker's Image tab shows a placeholder and Upload from computer, an asset preview, and a file dropped on it replaces the image, keeping the fill mode, crop and position (#1682); the Frame tool's Phone presets have the iPhone 17 family (#1691); Show in folder works in the Flatpak build and selects the file on Linux (#1701); a dashed line starts and ends with a half-length dash, on the canvas and in exports; the Pages, Layers and Assets search fields keep their text in the middle of the field (#1703); a board connector's path can be reshaped by dragging its path handles (#1710); boards have 16 more shapes (arrows, polygons, star, plus and flowchart symbols), in pickers that lay them out in a grid; library analytics count insertions and show insertions and detachments over the past 30 days, 90 days or year; a stroke's Custom style takes a dash pattern of any length as a comma-separated list (#1681); a control request can set its own timeout, up to 10 minutes (`timeoutMs`; `figcraft-cli mcp --attach PORT --timeout SECONDS`) (#1696); gap 6: a widget opens its plugin's panel (plugin API 5), whose callbacks redraw it; the keyboard's Menu key opens the context menu, as Shift+F10 does; an Open link action can open in place of the prototype, with "Open in new tab" off (gap 15); a collection of this file can be extended (Extend collection on its row, `variable.extendCollection`), its extension following its edits at once, and extended collections extend again in the file; the eyedropper with Cmd/Ctrl+Shift makes a colour style or variable of what it picks; a live session's welcome sends the document compressed to clients that read it, about seven times smaller (#1665); a control request that times out says truthfully whether it was dropped or is still running and will take effect, and only screenshots and input blame the window (#1673); a board keeps earlier votes' results, listed newest first in Results and deleted one at a time; a server can list the files shared from it now for everyone who reaches it, Home ▸ Online shows them and their links carry no secret (`team_files`, part of #742); a selected connector's bar on a board sets its start and end points, solid or dashed, its weight and any colour (#1596); cells copied from a spreadsheet paste onto a board as a table; a long layer name stays on one line in the Export button and the Dev Mode header, ending with an ellipsis, and no longer covers the Design panel (#1650); with hundreds of layers selected, the Design panel and the selection overlays stop going over the whole selection on every frame, so dragging 648 layers averages about 1.1 ms of UI per frame (#1571); plugin panels show pictures: an `image` control, base64 within 256 KB and 4,096 px a side, named by its label (gap 6); a drag moving many layers re-runs only what sits around them, not each layer's bindings, sizing, text and instance checks, on every move (#1571); damaged font tracking table offsets are checked instead of wrapping on 32-bit targets, and font indexing makes its existing offset bounds explicit (#1125); pasted layers whose image the clipboard lacks are named and show a placeholder that reads as missing (#1646); paint-style colours on layers pasted from another design app keep their style instead of turning black (#1638); Shift+Cmd/Ctrl+Enter on a selected board shape adds the next shape already joined to it, and goes on typing (#1597); a name typed for a variable or collection finds the one with that exact id before one named like it, and stored mode ids, font family variables and the panels' labels read exact ids (#1613); plugin widgets have hover styles: a layer's `hover` fill, stroke, opacity or text colour shows while the pointer is over it (gap 6); every file a command reads whole by path is a regular file within a cap that fits it: documents 16 GiB, placed images 256 MiB, CLI scripts 1 GiB; compact layer trees with fields and outline (M0.9, part of #996); while a drag only moves a large selection, its outlines, box, slots, spacing and Design panel values are worked out once and shifted, not rebuilt on every move (#1571); an imported or pasted file finds an installed family under the other names its fonts give it, and with other case and spacing, instead of reporting it missing (#1292); a panic in a grouped closure outside any command rolls the group back like a failed group: documents, selection, undo and redo, history limit and journal as before (#1085); a development example generates figcraft-cli's shell completions and man pages from its clap definitions (part of #1396); `.fig` ZIP image reads share a total byte budget and count skipped images in the import warnings (#1076); stored variable and collection references keep their identity when another object is named like their id (follow-up to #1542); a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; the server key is checked by comparing SHA-256 hashes of the presented and the kept key, as the deployment guide says (#1130); opening a file contacts no team library server it names until you check it (#1097); a hostile server can no longer grow a client's memory with made-up people, and a session remembers a bounded number of deleted layer ids (#1121); `doc.list` gives AI agents and plugins the open files' names, not their paths (#1117); with a large selection, frames where nothing changed reuse the selection's outlines, box and slots and the Design panel's instance and position values instead of working them out again (#1571); team libraries and plugins larger than the 16 MiB download burst can be downloaded again, more slowly (#1089); a batch keeps one undo snapshot, not one per step (#1099); files nested 256 levels deep import on the browser's 1 MB stack (#1101); SVG and PDF exports count pattern sources and the renders they embed from the layer's depth, so one cap bounds a whole export (#1082); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); joining and leaving a file nobody is editing reads and writes nothing on the server: it stays loaded a minute after everyone leaves (#1074); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); hovering the canvas hit-tests once per pointer move, not on every frame, and moving an auto layout frame no longer lays out its contents again on every move (#1571); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); `comment.people` lists names that were only @mentioned, and `comment.next` and `comment.prompt` carry the mentions too (#1415); grid auto-placement looks for free cells a 64-bit word at a time and skips what blocks it, with the same placements (#1083); with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); a recovery copy made while a version is previewed holds the file, not the version shown; a FigJam `.jam` file opens as a board (#1342); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); share links can comment: a third access level beside edit and view, whose holder sees the file live and adds, replies to, reacts to and resolves comments but can't change the design; Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); `comment.list {mentions}` lists only the threads that @mention a name (#1415); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); figcraft-cli parses its arguments with clap, following RFC-0012 (#1396); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); Thai, Lao, Khmer and Burmese lines break between words (#1219); an AI agent attached over MCP shows where it works (#1416); a manual overlay's position can be dragged on the canvas (#405); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); SVG import draws each pattern tile once per scale, within the layer budget (#1080); Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); M8: mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); M2: components can't contain their own instances, directly or through others; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); 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-31 | minor | M12: as the help describes, holding Cmd/Ctrl while moving a connector's end places it on any part of an object instead of snapping to a side. It keeps that spot as a fraction of the object's box, so it follows the object when it moves or resizes, and without the key an end snaps as before. `board.connector {fromAt, toAt}` and `board.reattach {at}` take it; saved only when set. Numbers unchanged | | 2026-10-31 | patch | M11: a trackpad or touch tap sends its press and release in one frame, so a plugin panel's field committed in the frame its button was clicked and the value was lost: the Counter's Set count… kept the old count. The plugin now gets both, the field's value first. The `window.figcraft` docs name `screenshot`'s `data`. Numbers unchanged | | 2026-10-31 | patch | M4: Dash and Custom strokes have a Dash cap (None, Round, Square) in the advanced stroke settings and `node.set {dashCap}`, on both ends of every dash on the canvas and in PNG, PDF and SVG. Unset, dashes keep taking the end point caps, so older files look the same; the saved file writes it only when set. Numbers unchanged | | 2026-10-31 | minor | M3, gap 10: library analytics cover styles and variables. A file reports how many of its layers use each of a library's styles and variables, and Show usage (`library.usage`) lists them beside the components, by a Type choice, with their layers and files. Layers inside instances count for their component. Insertions, detachments and modes aren't counted for them yet. Numbers unchanged. Tests: `library_assets_tests`. | From 96552d6d094802f99699b500aa083f1012f865ed Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 30 Oct 2026 23:34:10 -0400 Subject: [PATCH 09/15] craft-libs: move to e11f9d9 so craft-menu parses on FreeBSD's Cargo 1.98 (craft-libs#366) Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 26 +++++++++++++------------- Cargo.toml | 16 ++++++++-------- 2 files changed, 21 insertions(+), 21 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8fd1296eb..be183cc03 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1001,7 +1001,7 @@ dependencies = [ [[package]] name = "craft-atomic" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" +source = "git+https://github.com/storytold/craft-libs?rev=e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545#e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545" dependencies = [ "rustix 1.1.5", ] @@ -1009,12 +1009,12 @@ dependencies = [ [[package]] name = "craft-docpath" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" +source = "git+https://github.com/storytold/craft-libs?rev=e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545#e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545" [[package]] name = "craft-font-catalog" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" +source = "git+https://github.com/storytold/craft-libs?rev=e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545#e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545" dependencies = [ "fontique", "skrifa 0.47.0", @@ -1025,7 +1025,7 @@ dependencies = [ [[package]] name = "craft-font-store" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" +source = "git+https://github.com/storytold/craft-libs?rev=e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545#e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545" dependencies = [ "craft-atomic", "craft-font-catalog", @@ -1041,12 +1041,12 @@ dependencies = [ [[package]] name = "craft-fonts-build" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" +source = "git+https://github.com/storytold/craft-libs?rev=e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545#e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545" [[package]] name = "craft-i18n" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" +source = "git+https://github.com/storytold/craft-libs?rev=e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545#e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545" dependencies = [ "fixed_decimal", "icu_decimal", @@ -1069,7 +1069,7 @@ dependencies = [ [[package]] name = "craft-keymap" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" +source = "git+https://github.com/storytold/craft-libs?rev=e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545#e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545" dependencies = [ "egui", "serde", @@ -1079,7 +1079,7 @@ dependencies = [ [[package]] name = "craft-menu" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" +source = "git+https://github.com/storytold/craft-libs?rev=e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545#e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545" dependencies = [ "craft-keymap", "craft-quit", @@ -1092,7 +1092,7 @@ dependencies = [ [[package]] name = "craft-quit" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" +source = "git+https://github.com/storytold/craft-libs?rev=e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545#e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545" dependencies = [ "dispatch2", "egui", @@ -1103,7 +1103,7 @@ dependencies = [ [[package]] name = "craft-settings" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" +source = "git+https://github.com/storytold/craft-libs?rev=e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545#e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545" dependencies = [ "craft-atomic", "dirs", @@ -1114,7 +1114,7 @@ dependencies = [ [[package]] name = "craft-ui" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" +source = "git+https://github.com/storytold/craft-libs?rev=e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545#e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545" dependencies = [ "craft-fonts-build", "craft-i18n", @@ -1160,7 +1160,7 @@ dependencies = [ [[package]] name = "craft-window" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" +source = "git+https://github.com/storytold/craft-libs?rev=e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545#e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545" dependencies = [ "wayland-client", ] @@ -3136,7 +3136,7 @@ checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" [[package]] name = "krilla" version = "0.8.2" -source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" +source = "git+https://github.com/storytold/craft-libs?rev=e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545#e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545" dependencies = [ "base64 0.22.1", "bumpalo", diff --git a/Cargo.toml b/Cargo.toml index b1b87f8c1..f3813ec6c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -36,25 +36,25 @@ figcraft-plugin = { path = "crates/plugin" } # Keep coupled craft-libs crates on one rev. craft-update has no sibling-crate # dependencies and is pinned separately for the suite updater rollout. # Which paths stored in a document may be followed (craft-libs). -craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" } +craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545" } # Who draws the window frame on Linux — the compositor or the app — so every Craft app shows the # same native titlebar per desktop (craft-libs). FigCraft only reads the probe result and maps it # to its own `TitleBar`, so it does not enable craft-window's optional `egui` helpers (which would # pull a second egui family version). -craft-window = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" } +craft-window = { git = "https://github.com/storytold/craft-libs", rev = "e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545" } # craft-update: app-specific pretend versions apply to startup and manual checks. craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "04aa1703b32d299e54f78fcde4182f2b393a9116", features = ["egui"] } # The suite's font store: exact lookup of a missing font in the pinned Google Fonts and Noto # indexes, and checksum-verified installs into the one folder every Craft app reads (craft-libs). -craft-font-store = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" } -craft-font-catalog = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" } +craft-font-store = { git = "https://github.com/storytold/craft-libs", rev = "e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545" } +craft-font-catalog = { git = "https://github.com/storytold/craft-libs", rev = "e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545" } # Shared dropdown menus: a combo box whose menu grows back after shrinking (craft-libs). -craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" } +craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545" } # The macOS app menu whose Quit (⌘Q) asks the app before quitting, like the window's close button # (craft-libs; the menu it replaces quits straight through `terminate:` and loses unsaved work). -craft-menu = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" } +craft-menu = { git = "https://github.com/storytold/craft-libs", rev = "e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545" } # Where the settings live: the suite's folder rules, portable mode included (craft-libs). -craft-settings = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" } +craft-settings = { git = "https://github.com/storytold/craft-libs", rev = "e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545" } # foundation kurbo = { version = "0.13", features = ["serde"] } @@ -183,4 +183,4 @@ winit = { git = "https://github.com/storytold/winit.git", rev = "a517b3846ee8457 # craft-libs' vendored krilla 0.8.2: decoded PNGs are capped at 1 GiB and reserved fallibly, and a # panic while drawing unwinds without aborting. -krilla = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" } +krilla = { git = "https://github.com/storytold/craft-libs", rev = "e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545" } From 76f520ffaaab73fcacf14dfaf30f29272ec178cb Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 30 Oct 2026 23:34:12 -0400 Subject: [PATCH 10/15] Release 0.13.1 Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 44 ++++++++++++++++++++++---------------------- Cargo.toml | 2 +- 2 files changed, 23 insertions(+), 23 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index be183cc03..5e61bce32 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1763,7 +1763,7 @@ checksum = "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693" [[package]] name = "figcraft" -version = "0.13.0" +version = "0.13.1" dependencies = [ "arboard", "craft-menu", @@ -1795,7 +1795,7 @@ dependencies = [ [[package]] name = "figcraft-cli" -version = "0.13.0" +version = "0.13.1" dependencies = [ "clap", "clap_complete", @@ -1813,7 +1813,7 @@ dependencies = [ [[package]] name = "figcraft-collab" -version = "0.13.0" +version = "0.13.1" dependencies = [ "base64 0.22.1", "chrono", @@ -1831,7 +1831,7 @@ dependencies = [ [[package]] name = "figcraft-collab-net" -version = "0.13.0" +version = "0.13.1" dependencies = [ "base64 0.22.1", "figcraft-collab", @@ -1854,7 +1854,7 @@ dependencies = [ [[package]] name = "figcraft-color" -version = "0.13.0" +version = "0.13.1" dependencies = [ "kurbo", "serde", @@ -1863,7 +1863,7 @@ dependencies = [ [[package]] name = "figcraft-doc" -version = "0.13.0" +version = "0.13.1" dependencies = [ "figcraft-color", "figcraft-geom", @@ -1876,7 +1876,7 @@ dependencies = [ [[package]] name = "figcraft-engine" -version = "0.13.0" +version = "0.13.1" dependencies = [ "base64 0.22.1", "craft-docpath", @@ -1910,7 +1910,7 @@ dependencies = [ [[package]] name = "figcraft-format" -version = "0.13.0" +version = "0.13.1" dependencies = [ "figcraft-doc", "figcraft-layout", @@ -1927,7 +1927,7 @@ dependencies = [ [[package]] name = "figcraft-geom" -version = "0.13.0" +version = "0.13.1" dependencies = [ "kurbo", "serde", @@ -1935,7 +1935,7 @@ dependencies = [ [[package]] name = "figcraft-layout" -version = "0.13.0" +version = "0.13.1" dependencies = [ "figcraft-doc", "figcraft-geom", @@ -1943,7 +1943,7 @@ dependencies = [ [[package]] name = "figcraft-mcp" -version = "0.13.0" +version = "0.13.1" dependencies = [ "base64 0.22.1", "figcraft-collab-net", @@ -1959,7 +1959,7 @@ dependencies = [ [[package]] name = "figcraft-pathops" -version = "0.13.0" +version = "0.13.1" dependencies = [ "figcraft-doc", "figcraft-geom", @@ -1968,7 +1968,7 @@ dependencies = [ [[package]] name = "figcraft-pdf" -version = "0.13.0" +version = "0.13.1" dependencies = [ "figcraft-color", "figcraft-doc", @@ -1985,7 +1985,7 @@ dependencies = [ [[package]] name = "figcraft-plugin" -version = "0.13.0" +version = "0.13.1" dependencies = [ "figcraft-color", "serde", @@ -1998,7 +1998,7 @@ dependencies = [ [[package]] name = "figcraft-render" -version = "0.13.0" +version = "0.13.1" dependencies = [ "crc32fast", "figcraft-color", @@ -2017,7 +2017,7 @@ dependencies = [ [[package]] name = "figcraft-server" -version = "0.13.0" +version = "0.13.1" dependencies = [ "chrono", "crc32fast", @@ -2042,7 +2042,7 @@ dependencies = [ [[package]] name = "figcraft-svg" -version = "0.13.0" +version = "0.13.1" dependencies = [ "base64 0.22.1", "figcraft-color", @@ -2058,7 +2058,7 @@ dependencies = [ [[package]] name = "figcraft-text" -version = "0.13.0" +version = "0.13.1" dependencies = [ "core-text", "craft-font-catalog", @@ -2076,7 +2076,7 @@ dependencies = [ [[package]] name = "figcraft-ui-egui" -version = "0.13.0" +version = "0.13.1" dependencies = [ "base64 0.22.1", "chrono", @@ -2104,7 +2104,7 @@ dependencies = [ [[package]] name = "figcraft-vectordrawable" -version = "0.13.0" +version = "0.13.1" dependencies = [ "figcraft-color", "figcraft-doc", @@ -2118,7 +2118,7 @@ dependencies = [ [[package]] name = "figcraft-web" -version = "0.13.0" +version = "0.13.1" dependencies = [ "base64 0.22.1", "eframe", @@ -6739,7 +6739,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee" [[package]] name = "xtask" -version = "0.13.0" +version = "0.13.1" dependencies = [ "figcraft-engine", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index f3813ec6c..fe3059450 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,7 +6,7 @@ exclude = ["plugins"] # default-members = ["crates/*", "apps/figcraft", "apps/figcraft-cli", "xtask"] [workspace.package] -version = "0.13.0" +version = "0.13.1" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.95" From 69f7e91db31ebf760d909d4d6b89d1e94a3a2aed Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 31 Oct 2026 04:17:30 +0000 Subject: [PATCH 11/15] docs: tidy agent, contributor and asset docs --- AGENTS.md | 16 +++++----------- ATTRIBUTION.md | 2 +- CLAUDE.md | 16 +++++----------- ROADMAP.md | 4 ++-- assets/app-icon/README.md | 2 +- contributors/people.toml | 7 ++----- crates/ui-egui/build.rs | 7 +++---- crates/ui-egui/src/dialogs/credits.rs | 2 +- docs/contributors.md | 25 ++++--------------------- docs/gaps.md | 3 +-- docs/localization-parity.md | 2 +- docs/release-playbook.md | 6 ------ docs/releasing.md | 6 ++---- docs/target-app-parity.md | 6 +++--- 14 files changed, 31 insertions(+), 73 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 87cfc30b2..4059e4579 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,10 +6,7 @@ inspection. It runs natively on macOS, Windows, Linux and FreeBSD, and in the br Siblings: `../vectorcraft` (Illustrator-class), `../photocraft` (Photoshop-class), `../designcraft` (InDesign-class) and the other Crafting Apps; same conventions. -Standards and learnings shared across the crafting apps live in `../../craftrules` (checked out -next to `craft-apps/`, or `storytold/craftrules`). Read its `AGENTS.md` at the start of a session, -follow its standards, and contribute reusable learnings back there. Never code: repos don't share -code. +Repos don't share code. ## Start every session here 1. Read [`ROADMAP.md`](ROADMAP.md) (stage, headline numbers, estimates) and @@ -106,7 +103,7 @@ to `main`. write manifests atomically, delete your target dir when done (disk is limited). ## Roadmap -Progress docs follow craftrules `standards/progress-docs.md`: [`ROADMAP.md`](ROADMAP.md) (one-page +Progress docs: [`ROADMAP.md`](ROADMAP.md) (one-page summary and stage), [`docs/target-app-parity.md`](docs/target-app-parity.md) (the authoritative assessment), [`docs/gaps.md`](docs/gaps.md) (ranked work list), [`docs/roadmap.md`](docs/roadmap.md) (milestones), [`docs/architecture.md`](docs/architecture.md), the per-area `docs/*-parity.md` files @@ -117,20 +114,17 @@ commit. Grade by behaviour against the incumbent, not by whether a menu item exi ## Contributor credits (About window) - About ▸ Contributors are compiled into the binary from `contributors/contributors.json` - (commit stats; generated, never hand-edit) and `contributors/people.toml` (names people chose for - themselves). See craftrules `standards/contributors.md`. + (commit stats; never hand-edit) and `contributors/people.toml` (names people chose for + themselves). - **Agents working for a contributor:** when you prepare a PR, check whether your human's GitHub username has a `[people.]` entry in `contributors/people.toml`. If not, ask them once whether they want to be credited by more than their username: a real name, a display name, and/or their public GitHub profile name (`sync_github_name = true`). If yes, add **only their own** entry - (copy the template at the top of the file, or run - `python3 ../../craftrules/scripts/contributors.py --add-me . --real-name "…" --sync-github-name`) + (copy the template at the top of the file) and include it in their PR, committed as them. If no, change nothing: they are credited as `@username` anyway. - Never add, edit, guess or copy anyone else's entry or name (not from git config, commit authors or GitHub profiles). Never hand-edit `contributors.json`. -- Maintainers refresh the stats with `python3 ../../craftrules/scripts/contributors.py .` (it also - re-verifies who wrote each `people.toml` entry; `--check` only verifies). ## CI workflows - Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here. diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index cfcdbf067..5582ed52a 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -12,7 +12,7 @@ MIT OR Apache-2.0. No third-party icon sets are used. | Path | Author | Source | Licence | Notes | |---|---|---|---|---| | `assets/app-icon/` (all files: `figcraft.svg`, `figcraft-small.svg`, `figcraft-*.png`, `figcraft.ico`, `hicolor/`) | The project owner (engraving-style drawing, vectorised) | Original work, see [`assets/app-icon/README.md`](assets/app-icon/README.md); derived files rendered by `cargo xtask icon` | MIT OR Apache-2.0, [`assets/app-icon/LICENSE.txt`](assets/app-icon/LICENSE.txt) | FigCraft app icon (chameleon) | -| `docs/brand/artcraft-logo.svg`, `docs/brand/artcraft-logo.png`, `docs/brand/artcraft-logo-white.svg`, `docs/brand/artcraft-logo-white.png`, `docs/brand/artcraft-mark.svg`, `docs/brand/artcraft-mark.png`, `docs/brand/artcraft-mark-black.svg`, `docs/brand/artcraft-mark-black.png` | ArtCraft Team | storytold/craftrules `assets/brand/` | ArtCraft brand terms (`docs/brand/LICENSE-brand.txt`), not open source | Trademarks; unmodified use within FigCraft only | +| `docs/brand/artcraft-logo.svg`, `docs/brand/artcraft-logo.png`, `docs/brand/artcraft-logo-white.svg`, `docs/brand/artcraft-logo-white.png`, `docs/brand/artcraft-mark.svg`, `docs/brand/artcraft-mark.png`, `docs/brand/artcraft-mark-black.svg`, `docs/brand/artcraft-mark-black.png` | ArtCraft Team | ArtCraft brand kit | ArtCraft brand terms (`docs/brand/LICENSE-brand.txt`), not open source | Trademarks; unmodified use within FigCraft only | | `docs/images/` (all screenshots and exports: `shot-*.png`, `art-*.png`) | FigCraft contributors | Screenshots of FigCraft showing synthetic demo documents made in FigCraft, and PNG exports of those documents by FigCraft's renderer | MIT OR Apache-2.0 | No third-party artwork | | `examples/*.figcraft` | FigCraft contributors | Synthetic demo documents built through FigCraft's command API; invented copy, shapes and vectors drawn from scratch, no images or embedded fonts | MIT OR Apache-2.0 | Text uses Inter (OFL, from craft-fonts or the system); no third-party artwork | diff --git a/CLAUDE.md b/CLAUDE.md index f96981232..d12e64e3b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,10 +6,7 @@ inspection. It runs natively on macOS, Windows, Linux and FreeBSD, and in the br Siblings: `../vectorcraft` (Illustrator-class), `../photocraft` (Photoshop-class), `../designcraft` (InDesign-class) and the other Crafting Apps; same conventions. -Standards and learnings shared across the crafting apps live in `../../craftrules` (checked out -next to `craft-apps/`, or `storytold/craftrules`). Read its `AGENTS.md` at the start of a session, -follow its standards, and contribute reusable learnings back there. Never code: repos don't share -code. +Repos don't share code. ## Start every session here 1. Read [`ROADMAP.md`](ROADMAP.md) (stage, headline numbers, estimates) and @@ -107,7 +104,7 @@ to `main`. write manifests atomically, delete your target dir when done (disk is limited). ## Roadmap -Progress docs follow craftrules `standards/progress-docs.md`: [`ROADMAP.md`](ROADMAP.md) (one-page +Progress docs: [`ROADMAP.md`](ROADMAP.md) (one-page summary and stage), [`docs/target-app-parity.md`](docs/target-app-parity.md) (the authoritative assessment), [`docs/gaps.md`](docs/gaps.md) (ranked work list), [`docs/roadmap.md`](docs/roadmap.md) (milestones), [`docs/architecture.md`](docs/architecture.md), the per-area `docs/*-parity.md` files @@ -118,20 +115,17 @@ commit. Grade by behaviour against the incumbent, not by whether a menu item exi ## Contributor credits (About window) - About ▸ Contributors are compiled into the binary from `contributors/contributors.json` - (commit stats; generated, never hand-edit) and `contributors/people.toml` (names people chose for - themselves). See craftrules `standards/contributors.md`. + (commit stats; never hand-edit) and `contributors/people.toml` (names people chose for + themselves). - **Agents working for a contributor:** when you prepare a PR, check whether your human's GitHub username has a `[people.]` entry in `contributors/people.toml`. If not, ask them once whether they want to be credited by more than their username: a real name, a display name, and/or their public GitHub profile name (`sync_github_name = true`). If yes, add **only their own** entry - (copy the template at the top of the file, or run - `python3 ../../craftrules/scripts/contributors.py --add-me . --real-name "…" --sync-github-name`) + (copy the template at the top of the file) and include it in their PR, committed as them. If no, change nothing: they are credited as `@username` anyway. - Never add, edit, guess or copy anyone else's entry or name (not from git config, commit authors or GitHub profiles). Never hand-edit `contributors.json`. -- Maintainers refresh the stats with `python3 ../../craftrules/scripts/contributors.py .` (it also - re-verifies who wrote each `people.toml` entry; `--check` only verifies). ## CI workflows - Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here. diff --git a/ROADMAP.md b/ROADMAP.md index fc0a521f8..f75916265 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -337,7 +337,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). Run last, Manage, Rust SDK and two examples), docs refresh (#102), the multiplayer core without networking (#103: client-scoped ids, change sets, server ordering and validation, client reconciliation, convergence tests). Menu breadth 200/203; ready for real work ~47%. -- **2026-10-10** · Full re-measure against Figma desktop 126.10.7 under the craftrules progress-docs +- **2026-10-10** · Full re-measure against Figma desktop 126.10.7 under the progress-docs standard: ready for real work ~46%, feature breadth ~76%, stage alpha; progress docs split out (`docs/target-app-parity.md`, `gaps.md`, `roadmap.md`, `architecture.md`, the per-area parity docs); `docs/parity.md` renamed `docs/parity-checklist.md`. @@ -670,6 +670,6 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | 2026-10-10 | minor | Readiness by audience with hours to ~95% (full ~400–750, mainstream ~180–330, essentials ~85–160) | | 2026-10-10 | minor | Added essentials user (~53%) and mainstream practitioner (~43%); ready for real work unchanged at ~46% | | 2026-10-10 | minor | Alpha gate (core workflows) checked under the revised stage rules: passes, stays alpha | -| 2026-10-10 | major | Rewritten to the craftrules progress-docs shape after a full re-measure; rubric, area narrative and inventory moved to docs/target-app-parity.md, gaps to docs/gaps.md, milestones to docs/roadmap.md. Depth ~79% (self-graded, 16 areas) → ready for real work ~46% (all dimensions); full parity 115–195 h → 420–790 h | +| 2026-10-10 | major | Rewritten to the progress-docs shape after a full re-measure; rubric, area narrative and inventory moved to docs/target-app-parity.md, gaps to docs/gaps.md, milestones to docs/roadmap.md. Depth ~79% (self-graded, 16 areas) → ready for real work ~46% (all dimensions); full parity 115–195 h → 420–790 h | | 2026-10-10 | minor | Day-2 refreshes after each batch of PRs (depth 58% → 79%) | | 2026-10-09 | major | First roadmap | diff --git a/assets/app-icon/README.md b/assets/app-icon/README.md index 435dc6876..3a86d8407 100644 --- a/assets/app-icon/README.md +++ b/assets/app-icon/README.md @@ -22,7 +22,7 @@ its rounded corners, like the other Crafting Apps. ## Provenance The owner's original engraving-style drawing (1500 px, keyed to the palette), supplied for -FigCraft and vectorised with craftrules `assets/logo-options/_tools/vectorize_tile.py`. The source +FigCraft and vectorised. The source image is not kept in this repo. License: see `LICENSE.txt`. ## Files diff --git a/contributors/people.toml b/contributors/people.toml index acab9bca2..db1f5ce59 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -1,13 +1,10 @@ -# Contributor names for the About window (craftrules standards/contributors.md). +# Contributor names for the About window. # # Everyone who contributes is credited by GitHub username automatically. Anything more is up to # you: add YOUR OWN entry below, in your own PR (commit it yourself). Never add someone else; a # maintainer may add a person only when that person asked, recording where in consent_source. # -# Easiest: python3 ../../craftrules/scripts/contributors.py --add-me . \ -# [--real-name "Ada Lovelace"] [--display-name "Ada"] [--sync-github-name] -# -# Or copy this block, replacing your-github-username (every field is optional): +# Copy this block, replacing your-github-username (every field is optional): # # [people.your-github-username] # real_name = "Your Name" # shown when the viewer picks "Real name" diff --git a/crates/ui-egui/build.rs b/crates/ui-egui/build.rs index cc9d2eba8..10e70608a 100644 --- a/crates/ui-egui/build.rs +++ b/crates/ui-egui/build.rs @@ -1,8 +1,7 @@ -//! Bake the contributor credits into the binary as static Rust tables (craftrules -//! standards/contributors.md). Nothing is read at run time. +//! Bake the contributor credits into the binary as static Rust tables. Nothing is read at run time. //! -//! - contributors/contributors.json: commit stats per GitHub username, generated by craftrules -//! scripts/contributors.py, which also marks whose people.toml entry it verified. +//! - contributors/contributors.json: commit stats per GitHub username, with a mark for whose people.toml +//! entry is verified. //! - contributors/people.toml: the names people chose for themselves. A name is used only when the //! JSON says the entry was verified (`people_entry`). //! diff --git a/crates/ui-egui/src/dialogs/credits.rs b/crates/ui-egui/src/dialogs/credits.rs index 957c86b2a..e10a8434a 100644 --- a/crates/ui-egui/src/dialogs/credits.rs +++ b/crates/ui-egui/src/dialogs/credits.rs @@ -1,6 +1,6 @@ //! The About window's credits: every contributor (GitHub login always; display and real names only //! with recorded consent) and the AI models that helped. The tables are baked into the binary by -//! build.rs from contributors/contributors.json (craftrules standards/contributors.md); nothing is +//! build.rs from contributors/contributors.json; nothing is //! read at run time. use std::cmp::Ordering; diff --git a/docs/contributors.md b/docs/contributors.md index 24a250578..1f18de9a3 100644 --- a/docs/contributors.md +++ b/docs/contributors.md @@ -1,16 +1,14 @@ # Contributors in the About window **About ▸ Contributors** credits everyone who contributed to FigCraft, and **About ▸ Models** credits -the AI models named in `Co-Authored-By` trailers. This follows the shared craftrules standard -[`standards/contributors.md`](https://github.com/storytold/craftrules/blob/main/standards/contributors.md); -this page is the local copy of the decision. +the AI models named in `Co-Authored-By` trailers. ## Decision - Two files, both **compiled into the binary** by the UI crate's `build.rs` (nothing is read from disk or the network at run time; the web build has them too): - [`contributors/contributors.json`](../contributors/contributors.json): commit stats per GitHub - username. **Generated** by `craftrules/scripts/contributors.py`; never hand-edit. + username; never hand-edit. - [`contributors/people.toml`](../contributors/people.toml): the names people chose for themselves. **Each contributor adds only their own entry**, in their own PR. - Each contributor gets one line: GitHub username (always), display name (opt-in, or their synced @@ -32,20 +30,5 @@ display_name = "Nickname" sync_github_name = true # use the name on your public GitHub profile as display name ``` -or, with craftrules checked out next to `craft-apps/`: - -```sh -python3 ../../craftrules/scripts/contributors.py --add-me . --real-name "Your Name" --sync-github-name -``` - -Names show after the next refresh: the script checks that each entry was committed by its own -user (or by a maintainer with a `consent_source`). Delete your entry to remove your names. - -## Refreshing (maintainers) - -```sh -python3 ../../craftrules/scripts/contributors.py . # regenerate + verify; needs git, Python 3.11+, gh -python3 ../../craftrules/scripts/contributors.py --check . # verify people.toml only -``` - -Commit the updated `contributors/contributors.json`. Git author names and emails are never recorded. +Names show for entries committed by their own user (or by a maintainer with a `consent_source`). +Delete your entry to remove your names. Git author names and emails are never recorded. diff --git a/docs/gaps.md b/docs/gaps.md index 6436b6396..5437c6766 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -173,8 +173,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe still unmeasured (`crates/text/src/metrics_tests.rs`, and a local check over such documents in `crates/engine/src/text_metrics_local_tests.rs`). - **Estimate:** 15–30 h once `.fig` or REST import of real files is available; needs files whose - licence allows use (owner to supply or approve; never committed if not redistributable, see - craftrules `standards/test-corpora.md`). + licence allows use (owner to supply or approve; never committed if not redistributable). ### 4. Interaction fidelity not compared tool by tool - **Missing:** a side-by-side pass of every tool and panel: cursors, modifier keys while dragging, diff --git a/docs/localization-parity.md b/docs/localization-parity.md index a84e478d7..541e7ab90 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -6,7 +6,7 @@ FigCraft's interface has a localization layer with an English catalog, a Russian by a native speaker, and Spanish (with a Latin American overlay), French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Indonesian, Vietnamese, Bosnian and Czech drafts (machine-drafted, waiting for native speakers' review), plus Swedish and Turkish catalogs. This page lists the twelve key -languages of the craftrules standard, then the other languages Figma ships. How to add a +languages, then the other languages Figma ships. How to add a language: [`translating.md`](translating.md). ## What Figma ships diff --git a/docs/release-playbook.md b/docs/release-playbook.md index 9fb024e91..e4b304011 100644 --- a/docs/release-playbook.md +++ b/docs/release-playbook.md @@ -1,11 +1,5 @@ # Release playbook -The release playbook shared by every crafting app lives in craftrules: -[`../craftrules/release/playbook.md`](../../../craftrules/release/playbook.md) -(`storytold/craftrules`, checked out next to `craft-apps/`; the repository is not public). It is -the canonical copy; don't duplicate it here. Cutting a release step by step: -`craftrules/release/cut-a-release.md`. - FigCraft copies PhotoCraft's reference implementation (`.github/workflows/release.yml`, `packaging/`, the `xtask version` command) with FigCraft's names. FigCraft-specific details are in [releasing.md](releasing.md). diff --git a/docs/releasing.md b/docs/releasing.md index fed0b26ef..ddd71aa41 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -7,9 +7,7 @@ Nobody sees a draft until a maintainer publishes it. This is FigCraft's copy of the shared [release playbook](release-playbook.md) (PhotoCraft is the reference implementation). User-facing names say **FigCraft**. Files, binaries and ids stay -lowercase (`figcraft---.`, `ai.storyteller.figcraft`). The -operational runbook (secrets, the `release` ruleset, runner group, promoting the draft) is -craftrules `release/cut-a-release.md`. +lowercase (`figcraft---.`, `ai.storyteller.figcraft`). ## FigCraft's names @@ -216,7 +214,7 @@ never repo or org secrets. Names (identical in every app): `APPLE_CERTIFICATE`, `WINDOWS_CERTIFICATE`, `WINDOWS_CERTIFICATE_PASSWORD`, `AZURE_TENANT_ID`, `AZURE_CLIENT_ID`, `AZURE_CLIENT_SECRET`, `AZURE_SIGNING_ENDPOINT`, `AZURE_SIGNING_ACCOUNT`, `AZURE_CERT_PROFILE`. Each is optional: a missing one gives an unsigned artifact and a `::warning::`. An org admin -provisions the environment, ruleset and secrets with craftrules `release/signing-setup.md`, and +provisions the environment, ruleset and secrets, and adds the repo to the org's Default runner group so `macos-15-xlarge` can pick up the job. ## Checks diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index e40b0011a..489c0e7da 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -47,7 +47,7 @@ wall-clock); the `.fig` reader, the multiplayer core and a plugin runtime each n native-speaker review, real-file licensing and screen-reader QA need people. The full number is an additive weighted sum over the dimension table; mainstream and essentials use -multiplicative discounts, as the standard says. +multiplicative discounts. ## What was measured, and how @@ -182,10 +182,10 @@ type, and everything the mainstream number leaves out. The same question for the typical professional Figma Design user: a product designer who designs screens, maintains or uses a design system, prototypes and hands off to developers every week. -Computed per craftrules `standards/progress-docs.md`. +Computed with the same method. **1. Areas used weekly, with weights.** Feature areas 1–16 of the table above, with their weights -(sum 94). Left out as the standard says: plugins and widgets (18), AI (21), team and organization +(sum 94). Left out: plugins and widgets (18), AI (21), team and organization libraries (19, cloud/team admin), Figma Draw extras (20, not weekly for a product designer), localization beyond English, specialist hardware. Real-time multiplayer (17) is cloud collaboration and is left out as an area, but its effect on working with Figma users is counted in discount 3. From 97dbfe37f618e6277368a834e9c5d84a49f46336 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 31 Oct 2026 04:25:44 +0000 Subject: [PATCH 12/15] docs: tidy wording --- ROADMAP.md | 5 ++--- docs/roadmap.md | 2 +- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index f75916265..410705919 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -337,8 +337,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). Run last, Manage, Rust SDK and two examples), docs refresh (#102), the multiplayer core without networking (#103: client-scoped ids, change sets, server ordering and validation, client reconciliation, convergence tests). Menu breadth 200/203; ready for real work ~47%. -- **2026-10-10** · Full re-measure against Figma desktop 126.10.7 under the progress-docs - standard: ready for real work ~46%, feature breadth ~76%, stage alpha; progress docs split out +- **2026-10-10** · Full re-measure against Figma desktop 126.10.7 : ready for real work ~46%, feature breadth ~76%, stage alpha; progress docs split out (`docs/target-app-parity.md`, `gaps.md`, `roadmap.md`, `architecture.md`, the per-area parity docs); `docs/parity.md` renamed `docs/parity-checklist.md`. - **2026-10-10** · Day 2 (PRs #1–#99): vector edit mode depth (transform box, point snapping, arc @@ -670,6 +669,6 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | 2026-10-10 | minor | Readiness by audience with hours to ~95% (full ~400–750, mainstream ~180–330, essentials ~85–160) | | 2026-10-10 | minor | Added essentials user (~53%) and mainstream practitioner (~43%); ready for real work unchanged at ~46% | | 2026-10-10 | minor | Alpha gate (core workflows) checked under the revised stage rules: passes, stays alpha | -| 2026-10-10 | major | Rewritten to the progress-docs shape after a full re-measure; rubric, area narrative and inventory moved to docs/target-app-parity.md, gaps to docs/gaps.md, milestones to docs/roadmap.md. Depth ~79% (self-graded, 16 areas) → ready for real work ~46% (all dimensions); full parity 115–195 h → 420–790 h | +| 2026-10-10 | major | Progress docs rewritten after a full re-measure; rubric, area narrative and inventory moved to docs/target-app-parity.md, gaps to docs/gaps.md, milestones to docs/roadmap.md. Depth ~79% (self-graded, 16 areas) → ready for real work ~46% (all dimensions); full parity 115–195 h → 420–790 h | | 2026-10-10 | minor | Day-2 refreshes after each batch of PRs (depth 58% → 79%) | | 2026-10-09 | major | First roadmap | diff --git a/docs/roadmap.md b/docs/roadmap.md index 984f39ff2..6bc72a278 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -40,7 +40,7 @@ builders' side-by-side checks with synthetic files; nothing has been run on real own files, which save and reopen, and ready for real work (~47%) is above the ~40% bar. The two gaps are real but don't break a workflow done in FigCraft: review works asynchronously through comments in a shared file, and opening `.fig` is the step that moves work across from Figma, which -the standard requires for beta, not alpha. Both stay at the top of [`gaps.md`](gaps.md) as beta +is required for beta, not alpha. Both stay at the top of [`gaps.md`](gaps.md) as beta blockers. ## Milestones From 57d92d291a2e1d490331c50835054e44bce707e7 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 31 Oct 2026 04:42:35 +0000 Subject: [PATCH 13/15] updates: show check results with craft-update's CheckResultDialog --- Cargo.lock | 2 +- Cargo.toml | 2 +- crates/ui-egui/locales/ar.toml | 3 -- crates/ui-egui/locales/bs.toml | 3 -- crates/ui-egui/locales/en.toml | 3 -- crates/ui-egui/locales/fi.toml | 3 -- crates/ui-egui/locales/ka.toml | 3 -- crates/ui-egui/locales/sv.toml | 3 -- crates/ui-egui/src/dialogs/updates_ui.rs | 35 ++++++++++++------------ 9 files changed, 20 insertions(+), 37 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5e61bce32..797f6498d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1134,7 +1134,7 @@ dependencies = [ [[package]] name = "craft-update" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=04aa1703b32d299e54f78fcde4182f2b393a9116#04aa1703b32d299e54f78fcde4182f2b393a9116" +source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7" dependencies = [ "chrono", "dunce", diff --git a/Cargo.toml b/Cargo.toml index fe3059450..a560971a8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -43,7 +43,7 @@ craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-l # pull a second egui family version). craft-window = { git = "https://github.com/storytold/craft-libs", rev = "e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545" } # craft-update: app-specific pretend versions apply to startup and manual checks. -craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "04aa1703b32d299e54f78fcde4182f2b393a9116", features = ["egui"] } +craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", features = ["egui"] } # The suite's font store: exact lookup of a missing font in the pinned Google Fonts and Noto # indexes, and checksum-verified installs into the one folder every Craft app reads (craft-libs). craft-font-store = { git = "https://github.com/storytold/craft-libs", rev = "e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545" } diff --git a/crates/ui-egui/locales/ar.toml b/crates/ui-egui/locales/ar.toml index 51ce2ab38..86a21e234 100644 --- a/crates/ui-egui/locales/ar.toml +++ b/crates/ui-egui/locales/ar.toml @@ -228,7 +228,6 @@ status = "draft — needs native review" "Check" = "تحقق" "Check for Updates" = "التحقق من التحديثات" "Check for updates…" = "التحقق من التحديثات…" -"Checking for updates…" = "جارٍ التحقق من التحديثات…" "Checking the fonts installed on this computer…" = "جارٍ فحص الخطوط المثبتة على هذا الحاسوب…" "Checking the server…" = "جارٍ التحقق من الخادم…" "Chevron" = "شارة زاوية" @@ -357,7 +356,6 @@ status = "draft — needs native review" "Copy {value}" = "نسخ {value}" "Corner radius" = "نصف قطر الزوايا" "Corner smoothing" = "تنعيم الزوايا" -"Could not check for updates:\n{error}" = "تعذر التحقق من التحديثات:\n{error}" "Couldn't download the variables" = "تعذر تنزيل المتغيرات" "Couldn't download {name}" = "تعذر تنزيل {name}" "Couldn't reach {server}: showing what this computer remembers." = "تعذر الاتصال بـ {server}؛ تُعرض البيانات المحفوظة على هذا الحاسوب." @@ -548,7 +546,6 @@ status = "draft — needs native review" "Fetching from the server…" = "جارٍ الجلب من الخادم…" "FigCraft couldn't open its window: {error}" = "تعذر على FigCraft فتح نافذته: {error}" "FigCraft doesn't support every feature of these files yet. The notes list what was left out or changed in the imported copy; the original file is unchanged." = "لا يدعم FigCraft جميع مزايا هذه الملفات بعد. تسرد الملاحظات ما أُهمل أو عُدّل في النسخة المستوردة؛ ويظل الملف الأصلي دون تغيير." -"FigCraft {version} is currently the newest version available." = "FigCraft {version} هو أحدث إصدار متاح حاليًا." "File" = "ملف" "File name" = "اسم الملف" "File not found" = "لم يُعثر على الملف" diff --git a/crates/ui-egui/locales/bs.toml b/crates/ui-egui/locales/bs.toml index efc380f58..67e0a14cd 100644 --- a/crates/ui-egui/locales/bs.toml +++ b/crates/ui-egui/locales/bs.toml @@ -228,7 +228,6 @@ status = "draft — needs native review" "Check for Updates" = "Provjeri ažuriranja" "Check for updates" = "Provjeri ažuriranja" "Check for updates…" = "Provjeri ažuriranja…" -"Checking for updates…" = "Provjera ažuriranja…" "Checking the fonts installed on this computer…" = "Provjera fontova instaliranih na ovom računaru…" "Checking the server…" = "Provjera servera…" "Chevron" = "Ševron" @@ -359,7 +358,6 @@ status = "draft — needs native review" "Copy {value}" = "Kopiraj {value}" "Corner radius" = "Radijus ugla" "Corner smoothing" = "Zaglađivanje uglova" -"Could not check for updates:\n{error}" = "Nije moguće provjeriti ažuriranja:\n{error}" "Couldn't download the variables" = "Nije moguće preuzeti varijable" "Couldn't download {name}" = "Nije moguće preuzeti {name}" "Couldn't reach {server}: showing what this computer remembers." = "Server {server} nije dostupan: prikazuje se ono što ovaj računar pamti." @@ -554,7 +552,6 @@ status = "draft — needs native review" "FigCraft couldn't open its window: {error}" = "FigCraft nije mogao otvoriti svoj prozor: {error}" "FigCraft doesn't support every feature of these files yet. The notes list what was left out or changed in the imported copy; the original file is unchanged." = "FigCraft još ne podržava sve mogućnosti ovih datoteka. Napomene navode šta je izostavljeno ili promijenjeno u uvezenoj kopiji; originalna datoteka je nepromijenjena." "FigCraft stopped because of an internal error." = "FigCraft je zaustavljen zbog interne greške." -"FigCraft {version} is currently the newest version available." = "FigCraft {version} je trenutno najnovija dostupna verzija." "File" = "Datoteka" "File name" = "Ime datoteke" "File not found" = "Datoteka nije pronađena" diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index 4a8781c10..ba567dd81 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -230,7 +230,6 @@ name = "English" "Check for Updates" = "Check for Updates" "Check for updates" = "Check for updates" "Check for updates…" = "Check for updates…" -"Checking for updates…" = "Checking for updates…" "Checking the fonts installed on this computer…" = "Checking the fonts installed on this computer…" "Checking the server…" = "Checking the server…" "Chevron" = "Chevron" @@ -364,7 +363,6 @@ name = "English" "Copy {value}" = "Copy {value}" "Corner radius" = "Corner radius" "Corner smoothing" = "Corner smoothing" -"Could not check for updates:\n{error}" = "Could not check for updates:\n{error}" "Couldn't download the variables" = "Couldn't download the variables" "Couldn't download {name}" = "Couldn't download {name}" "Couldn't reach {server}: showing what this computer remembers." = "Couldn't reach {server}: showing what this computer remembers." @@ -562,7 +560,6 @@ name = "English" "FigCraft couldn't open its window: {error}" = "FigCraft couldn't open its window: {error}" "FigCraft doesn't support every feature of these files yet. The notes list what was left out or changed in the imported copy; the original file is unchanged." = "FigCraft doesn't support every feature of these files yet. The notes list what was left out or changed in the imported copy; the original file is unchanged." "FigCraft stopped because of an internal error." = "FigCraft stopped because of an internal error." -"FigCraft {version} is currently the newest version available." = "FigCraft {version} is currently the newest version available." "File" = "File" "File name" = "File name" "File not found" = "File not found" diff --git a/crates/ui-egui/locales/fi.toml b/crates/ui-egui/locales/fi.toml index dff5adf9c..f181ba8a5 100644 --- a/crates/ui-egui/locales/fi.toml +++ b/crates/ui-egui/locales/fi.toml @@ -359,7 +359,6 @@ status = "draft — needs native review" "Copy {value}" = "Kopioi: {value}" "Corner radius" = "Kulman säde" "Corner smoothing" = "Kulman pehmennys" -"Could not check for updates:\n{error}" = "Päivityksiä ei voitu tarkistaa:\n{error}" "Couldn't download the variables" = "Muuttujia ei voitu ladata" "Couldn't download {name}" = "Kohdetta {name} ei voitu ladata" "Couldn't reach {server}: showing what this computer remembers." = "Palvelimeen {server} ei saatu yhteyttä. Näytetään tämän tietokoneen muistamat tiedot." @@ -557,7 +556,6 @@ status = "draft — needs native review" "FigCraft couldn't open its window: {error}" = "FigCraft ei voinut avata ikkunaansa: {error}" "FigCraft doesn't support every feature of these files yet. The notes list what was left out or changed in the imported copy; the original file is unchanged." = "FigCraft ei vielä tue näiden tiedostojen kaikkia ominaisuuksia. Huomautukset kertovat, mitä tuodusta kopiosta jätettiin pois tai muutettiin. Alkuperäinen tiedosto säilyy ennallaan." "FigCraft stopped because of an internal error." = "FigCraft pysähtyi sisäisen virheen vuoksi." -"FigCraft {version} is currently the newest version available." = "FigCraft {version} on tällä hetkellä uusin saatavilla oleva versio." "File" = "Tiedosto" "File name" = "Tiedostonimi" "File not found" = "Tiedostoa ei löydy" @@ -978,7 +976,6 @@ status = "draft — needs native review" "Not read yet" = "Ei vielä luettu" "Check for updates" = "Tarkista päivitykset" "Check for updates…" = "Tarkista päivitykset…" -"Checking for updates…" = "Tarkistetaan päivityksiä…" "Not checked yet" = "Ei vielä tarkistettu" "Someone who left" = "Joku, joka lähti" "Take the results off the board; they stay in the file" = "Poista tulokset taululta; ne jäävät tiedostoon" diff --git a/crates/ui-egui/locales/ka.toml b/crates/ui-egui/locales/ka.toml index 131aeca74..90dffe678 100644 --- a/crates/ui-egui/locales/ka.toml +++ b/crates/ui-egui/locales/ka.toml @@ -1883,19 +1883,16 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "Best effort" = "მაქსიმალური მიახლოება" "Check for Updates" = "განახლებების შემოწმება" "Check for updates…" = "განახლებების შემოწმება…" -"Checking for updates…" = "განახლებების შემოწმება…" "Compatibility" = "თავსებადობა" "Dart constants for Flutter…" = "Dart-ის კონსტანტები Flutter-ისთვის…" "Copy effect" = "ეფექტის კოპირება" "Copy fill" = "შევსების კოპირება" "Copy stroke" = "კონტურის კოპირება" -"Could not check for updates:\n{error}" = "განახლებების შემოწმება ვერ მოხერხდა:\n{error}" "Couldn't download {name}" = "{name} ვერ ჩამოიტვირთა" "Don't show this again" = "აღარ აჩვენო" "Exact only" = "მხოლოდ ზუსტი" "Exported {name}; {n} kinds of content were changed or left out" = { one = "ექსპორტირებულია: {name}; შეცვლილი ან გამოტოვებული შიგთავსის სახეობა: {n}", other = "ექსპორტირებულია: {name}; შეცვლილი ან გამოტოვებული შიგთავსის სახეობა: {n}" } "Exported {n} files; some content was changed or left out" = { one = "ექსპორტირებულია ფაილი: {n}; ზოგი შიგთავსი შეიცვალა ან გამოტოვებულია", other = "ექსპორტირებულია ფაილი: {n}; ზოგი შიგთავსი შეიცვალა ან გამოტოვებულია" } -"FigCraft {version} is currently the newest version available." = "FigCraft {version} ამჟამად უახლესი ხელმისაწვდომი ვერსიაა." "Format" = "ფორმატი" "Ignore unsupported" = "მხარდაუჭერელის უგულებელყოფა" "Leaving the prototype" = "პროტოტიპიდან გასვლა" diff --git a/crates/ui-egui/locales/sv.toml b/crates/ui-egui/locales/sv.toml index dd3d35953..748518fff 100644 --- a/crates/ui-egui/locales/sv.toml +++ b/crates/ui-egui/locales/sv.toml @@ -227,7 +227,6 @@ name = "Svenska" "Check for Updates" = "Sök efter uppdateringar" "Check for updates" = "Sök efter uppdateringar" "Check for updates…" = "Sök efter uppdateringar…" -"Checking for updates…" = "Söker efter uppdateringar…" "Checking the fonts installed on this computer…" = "Kontrollerar vilka typsnitt som är installerade på den här datorn…" "Checking the server…" = "Kontrollerar servern…" "Chevron" = "Vinkel" @@ -361,7 +360,6 @@ name = "Svenska" "Copy {value}" = "Kopiera {value}" "Corner radius" = "Hörnradie" "Corner smoothing" = "Hörnutjämning" -"Could not check for updates:\n{error}" = "Det gick inte att söka efter uppdateringar:\n{error}" "Couldn't download the variables" = "Det gick inte att hämta variablerna" "Couldn't download {name}" = "Det gick inte att hämta {name}" "Couldn't reach {server}: showing what this computer remembers." = "Det gick inte att nå {server}: visar det som den här datorn minns." @@ -558,7 +556,6 @@ name = "Svenska" "FigCraft couldn't open its window: {error}" = "FigCraft kunde inte öppna sitt fönster: {error}" "FigCraft doesn't support every feature of these files yet. The notes list what was left out or changed in the imported copy; the original file is unchanged." = "FigCraft stöder ännu inte alla funktioner i de här filerna. Anteckningarna visar vad som utelämnades eller ändrades i den importerade kopian; originalfilen är oförändrad." "FigCraft stopped because of an internal error." = "FigCraft stoppades på grund av ett internt fel." -"FigCraft {version} is currently the newest version available." = "FigCraft {version} är den senaste tillgängliga versionen." "File" = "Fil" "File name" = "Filnamn" "File not found" = "Filen hittades inte" diff --git a/crates/ui-egui/src/dialogs/updates_ui.rs b/crates/ui-egui/src/dialogs/updates_ui.rs index b60bdc863..a7809b41f 100644 --- a/crates/ui-egui/src/dialogs/updates_ui.rs +++ b/crates/ui-egui/src/dialogs/updates_ui.rs @@ -4,7 +4,9 @@ use crate::FigApp; use crate::i18n::tr; #[cfg(not(target_arch = "wasm32"))] -use craft_update::updater::{Action, Status, Updater}; +use craft_update::updater::{Action, Updater}; +#[cfg(not(target_arch = "wasm32"))] +use craft_update::{CheckResultDialog, CheckResultResponse}; pub(crate) struct Runtime { #[cfg(not(target_arch = "wasm32"))] @@ -17,7 +19,10 @@ pub(crate) struct Runtime { /// The context of the last frame, to wake the UI when a check is queued from a menu callback. #[cfg(not(target_arch = "wasm32"))] repaint: Option, + /// The result dialog of a manual check is open. pub(crate) manual_window_open: bool, + /// The web build has no updater; its only result is that checks are unsupported. + #[cfg(target_arch = "wasm32")] pub(crate) status_message: Option, } @@ -33,6 +38,7 @@ impl Default for Runtime { #[cfg(not(target_arch = "wasm32"))] repaint: None, manual_window_open: false, + #[cfg(target_arch = "wasm32")] status_message: None, } } @@ -50,6 +56,7 @@ pub(crate) fn check(app: &mut FigApp) { #[cfg(not(target_arch = "wasm32"))] { app.updates_rt.check_requested = true; + app.updates_rt.manual_window_open = true; if let Some(ctx) = &app.updates_rt.repaint { ctx.request_repaint(); } @@ -88,18 +95,20 @@ pub(crate) fn show(app: &mut FigApp, ctx: &egui::Context) { Action::Close => ctx.send_viewport_cmd_to(egui::ViewportId::ROOT, egui::ViewportCommand::Close), Action::None => {} } - // The window answers Help ▸ Check for Updates, so it follows the updater's status. let rt = &mut app.updates_rt; - (rt.manual_window_open, rt.status_message) = match rt.updater.status() { - Status::Idle => (false, None), - Status::Checking => (true, None), - Status::UpToDate => { - (true, Some(tr("FigCraft {version} is currently the newest version available.").replace("{version}", env!("CARGO_PKG_VERSION")))) + if rt.manual_window_open { + match CheckResultDialog::new(&crate::UPDATE_APP, env!("CARGO_PKG_VERSION")).show(ctx, rt.updater.status()) { + CheckResultResponse::None => {} + CheckResultResponse::Close => { + rt.manual_window_open = false; + rt.updater.dismiss_status(); + } + CheckResultResponse::CheckAgain => rt.check_requested = true, } - Status::Failed(e) => (true, Some(tr("Could not check for updates:\n{error}").replace("{error}", e))), - }; + } } + #[cfg(target_arch = "wasm32")] if app.updates_rt.manual_window_open { let mut open = true; let mut close = false; @@ -111,8 +120,6 @@ pub(crate) fn show(app: &mut FigApp, ctx: &egui::Context) { .show(ctx, |ui| { if let Some(msg) = &app.updates_rt.status_message { ui.label(msg); - } else { - ui.label(tr("Checking for updates…")); } ui.add_space(8.0); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { @@ -126,10 +133,4 @@ pub(crate) fn show(app: &mut FigApp, ctx: &egui::Context) { app.updates_rt.status_message = None; } } - - // Closing the window dismisses the outcome it showed. - #[cfg(not(target_arch = "wasm32"))] - if !app.updates_rt.manual_window_open { - app.updates_rt.updater.dismiss_status(); - } } From 29e333b5b14649345ca214a304980850f37ebd62 Mon Sep 17 00:00:00 2001 From: Dylan Taylor Date: Sat, 31 Oct 2026 01:05:13 -0400 Subject: [PATCH 14/15] fix: remove unused tr import in updates_ui --- crates/ui-egui/src/dialogs/updates_ui.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/crates/ui-egui/src/dialogs/updates_ui.rs b/crates/ui-egui/src/dialogs/updates_ui.rs index a7809b41f..7c49e8995 100644 --- a/crates/ui-egui/src/dialogs/updates_ui.rs +++ b/crates/ui-egui/src/dialogs/updates_ui.rs @@ -1,7 +1,6 @@ //! Software updates, background check at startup, and startup prompt modal. use crate::FigApp; -use crate::i18n::tr; #[cfg(not(target_arch = "wasm32"))] use craft_update::updater::{Action, Updater}; From 0abbe44cd4a2450cb6d25c9368bc96a6ab88af19 Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Sat, 31 Oct 2026 01:43:37 -0400 Subject: [PATCH 15/15] updates: gate the wasm-only import (#1746) --- crates/ui-egui/src/dialogs/updates_ui.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/ui-egui/src/dialogs/updates_ui.rs b/crates/ui-egui/src/dialogs/updates_ui.rs index 7c49e8995..f4481d064 100644 --- a/crates/ui-egui/src/dialogs/updates_ui.rs +++ b/crates/ui-egui/src/dialogs/updates_ui.rs @@ -2,6 +2,8 @@ use crate::FigApp; +#[cfg(target_arch = "wasm32")] +use crate::i18n::tr; #[cfg(not(target_arch = "wasm32"))] use craft_update::updater::{Action, Updater}; #[cfg(not(target_arch = "wasm32"))]