From 2437a65d1bbd2ad8b2645028a095da08a5f56f99 Mon Sep 17 00:00:00 2001 From: lileeei Date: Thu, 22 Oct 2026 23:04:57 +0800 Subject: [PATCH] M1: fill/stroke props accept "none" and null (#1036) node.create, node.set and the other prop appliers map fill/stroke "none" (either case) and null to an empty fills/strokes list, as fills: [] already did (undocumented), so an outline-only shape is one call - agents write "none" for no paint. KEYS and docs/mcp.md document it; the error names the accepted forms. fill_none_tests covers create, set, undo, text and the error. --- ROADMAP.md | 4 +- crates/engine/src/cmd/props.rs | 23 ++++++-- crates/engine/src/fill_none_tests.rs | 88 ++++++++++++++++++++++++++++ crates/engine/src/lib.rs | 2 + docs/mcp.md | 2 + 5 files changed, 113 insertions(+), 6 deletions(-) create mode 100644 crates/engine/src/fill_none_tests.rs diff --git a/ROADMAP.md b/ROADMAP.md index f43cd015..d92ae3ec 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-17 · **Change:** minor (community batch #753: Selection colors, smart-selection spacing, a Present tab; language table re-measured from the catalogs; community fixes from #524 up (55 PRs: `.fig` import fidelity, paste from other apps, the Actions palette, editor fixes, server limits) and a boolean-op crash guard; number variables in the text content picker (#629); frames and sections convert into each other (#719); bounded canvas pan overscan; community fixes from #478 up and a control-channel fix (#589); bounded canvas pan overscan; Typography spacing icons support scrubbing (#373); font scans skip named pipes; system theme on first run; recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; 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; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; 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; 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; Windows QA pass with eight fixes; 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%) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (`fill`/`stroke` props accept `"none"` and `null` as no paint (#1036); community batch #753: Selection colors, smart-selection spacing, a Present tab; language table re-measured from the catalogs; community fixes from #524 up (55 PRs: `.fig` import fidelity, paste from other apps, the Actions palette, editor fixes, server limits) and a boolean-op crash guard; number variables in the text content picker (#629); frames and sections convert into each other (#719); bounded canvas pan overscan; community fixes from #478 up and a control-channel fix (#589); bounded canvas pan overscan; Typography spacing icons support scrubbing (#373); font scans skip named pipes; system theme on first run; recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; 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; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; 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; 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; Windows QA pass with eight fixes; 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%) · **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 @@ -131,6 +131,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). ## Progress log +- **2026-10-22** · `fill` and `stroke` props accept `"none"` and `null` (M1, #1036): `node.create`, `node.set` and the other prop appliers map them to an empty fills/strokes list, as `fills: []` already did, so an outline-only shape is one call (agents write `"none"` for no paint; the empty-list form was undocumented). The parameter docs (`KEYS`, shown by `list_commands` and the CLI) and `docs/mcp.md` say so, and the error message names the accepted forms. `fill_none_tests` covers create, set, undo, text and the error. Numbers unchanged. - **2026-10-17** · Control channel while the window is minimised (M8, #720): a minimised or covered window answers every request that acts on the app's state (edits, `ui.inspect`, `ui.pointer`, `ui.key`, `ui.type`, `ui.set`, `app.quit`…) without drawing, and what needs a drawn frame (`ui.screenshot`, `ui.input`, `window.new`) is an error at once; before, those waited up to 60 s and held up every later request on the connection (an attached agent's `inspect_ui` or window screenshot stalled until the window came back). Measured on Windows 11, minimised: requests ~100 ms each (eframe wakes a minimised window at most ten times a second; ~2 ms on screen), a window screenshot fails in ~100 ms instead of 60 s, idle CPU 0. Numbers unchanged. - **2026-10-17** · Number variables on text content (M3, #629): the Typography section's variable picker also lists number variables for a text layer's content, as the engine already accepted (the text shows the number, e.g. 42.5, and follows the variable). Other text fields keep their own types. Numbers unchanged. - **2026-10-17** · Frames and sections convert into each other (M1, #719): `object.convertToSection` turns a frame on the page or in a section into a section in place (same box, name, fill and layers, which stay where they are; what only a frame has goes: auto layout, clipping, layout grids, guides, scrolling), and `object.convertToFrame` now also turns a section into a frame that keeps its fill and doesn't clip, so the look stays. Nested frames, instances, main components and sections holding sections are refused with the reason; one undo step each. Both are in the canvas context menu (Convert to section / Convert to frame) and in Spanish. Numbers unchanged. @@ -316,6 +317,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-22 | minor | `fill`/`stroke` props accept `"none"` (either case) and `null` as no paint (M1, #1036): mapped to an empty fills/strokes list, as `fills: []` already did (undocumented), so an outline-only shape is one call; `KEYS` and `docs/mcp.md` document it, the error names the accepted forms, `fill_none_tests` covers create, set, undo, text and the error. Numbers unchanged | | 2026-10-20 | minor | A document dropped on the canvas opens (#916): a `.figcraft`, `.fig` or file JSON dropped on the canvas opens as a file of its own, as on the home screen, in the browser too; it used to add its pages to the file in front. Images and SVG still import at the drop point, File ▸ Import still adds pages, and dragging only documents over the canvas says "Drop to open" (not in the browser, which names no file until the drop). Numbers unchanged | | 2026-10-20 | minor | Duplicating a main component makes an instance (#892): Duplicate and Alt-drag copied a main component as a second main; within its file the copy is now an instance of it, as Copy then Paste already was (a variant duplicated in its set stays a variant). Numbers unchanged. | | 2026-10-20 | minor | Readable disabled and selected text (#890): the dim text colour (disabled buttons, placeholders, hints) is at least 3:1 against panels, fields and selected rows in both themes (it was 2.1:1 in the light theme), a disabled button is grey with that text instead of fading to 40%, and selected text (renaming a page or layer, any text box) takes the theme's readable green on the highlight (1.4:1 in the dark theme before, now 4.2:1 or more); checked with the colour contrast helper (`theme::tests`). Numbers unchanged | diff --git a/crates/engine/src/cmd/props.rs b/crates/engine/src/cmd/props.rs index f180b53c..83e2830a 100644 --- a/crates/engine/src/cmd/props.rs +++ b/crates/engine/src/cmd/props.rs @@ -13,7 +13,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), 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), strokes, stroke (colour), strokeWeight, strokeAlign (inside|center|outside), strokeSides [t,r,b,l], strokeTopWeight / strokeRightWeight / strokeBottomWeight / strokeLeftWeight (one side; turns on individual strokes), \ +fills (paints or colours; gradients as {type: linearGradient|radialGradient|angularGradient|diamondGradient, stops: [\"#hex\", ...], from?, to?} too), fill (colour, or \"none\"/null: no fill), strokes, stroke (colour, 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, \ 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), \ @@ -372,6 +372,11 @@ fn set_paints(n: &mut Node, fills: bool, paints: Vec) { } } +/// `"none"` (either case) and null ask for no paint, as an empty `fills`/`strokes` list does. +fn paint_none(v: &Value) -> bool { + v.is_null() || v.as_str().is_some_and(|s| s.eq_ignore_ascii_case("none")) +} + /// The order [`apply`] takes keys in: geometry first so rotation applies to the final size, variable /// bindings last so paints set in the same call don't detach them. fn apply_order(k: &str) -> u8 { @@ -462,13 +467,21 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map set_paints(doc.get_mut(id)?, true, paints_of(v).ok_or_else(|| bad(cmd, "`fills` must be paints or colours"))?), "fill" => { - let c = color_of(v).ok_or_else(|| bad(cmd, "`fill` must be a colour"))?; - set_paints(doc.get_mut(id)?, true, vec![Paint::solid(c)]); + if paint_none(v) { + set_paints(doc.get_mut(id)?, true, vec![]); + } else { + let c = color_of(v).ok_or_else(|| bad(cmd, "`fill` must be a colour, \"none\" or null"))?; + set_paints(doc.get_mut(id)?, true, vec![Paint::solid(c)]); + } } "strokes" => set_paints(doc.get_mut(id)?, false, paints_of(v).ok_or_else(|| bad(cmd, "`strokes` must be paints or colours"))?), "stroke" => { - let c = color_of(v).ok_or_else(|| bad(cmd, "`stroke` must be a colour"))?; - set_paints(doc.get_mut(id)?, false, vec![Paint::solid(c)]); + if paint_none(v) { + set_paints(doc.get_mut(id)?, false, vec![]); + } else { + let c = color_of(v).ok_or_else(|| bad(cmd, "`stroke` must be a colour, \"none\" or null"))?; + set_paints(doc.get_mut(id)?, false, vec![Paint::solid(c)]); + } } "strokeWeight" => doc.get_mut(id)?.stroke.weight = f64_of(v).ok_or_else(|| bad(cmd, "`strokeWeight` must be a number"))?.max(0.0), "strokeAlign" => doc.get_mut(id)?.stroke.align = enum_of(v).ok_or_else(|| bad(cmd, "strokeAlign: inside|center|outside"))?, diff --git a/crates/engine/src/fill_none_tests.rs b/crates/engine/src/fill_none_tests.rs new file mode 100644 index 00000000..74fe6231 --- /dev/null +++ b/crates/engine/src/fill_none_tests.rs @@ -0,0 +1,88 @@ +//! `props.fill` and `props.stroke` accept `"none"` (either case) and `null` as no paint, the +//! same as an empty `fills`/`strokes` list (#1036): an outline-only shape in one call. + +use figcraft_doc::NodeId; +use serde_json::{Value, json}; + +use crate::Session; + +fn id(v: Value) -> NodeId { + NodeId(v["id"].as_u64().unwrap()) +} + +fn ex(s: &mut Session, cmd: &str, p: Value) -> Value { + s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd} {p}: {e}")) +} + +fn create_rect(s: &mut Session, fill: Value) -> NodeId { + id(ex( + s, + "node.create", + json!({ + "type": "rectangle", "x": 0, "y": 0, "width": 10, "height": 10, + "props": {"fill": fill, "stroke": "#000000", "strokeWeight": 1} + }), + )) +} + +#[test] +fn fill_none_or_null_makes_an_outline_only_shape_in_one_call() { + for none in [json!("none"), json!("None"), json!("NONE"), json!(null)] { + let mut s = Session::new(); + let r = create_rect(&mut s, none.clone()); + let n = s.doc().unwrap().node(r).unwrap().clone(); + assert!(n.fills.is_empty(), "{none:?}: fills not empty"); + assert_eq!(n.strokes.len(), 1, "{none:?}: stroke kept"); + } +} + +#[test] +fn fill_none_clears_an_existing_fill_as_one_undo_step() { + let mut s = Session::new(); + let r = create_rect(&mut s, json!("#FF0000")); + assert_eq!(s.doc().unwrap().node(r).unwrap().fills.len(), 1); + ex(&mut s, "node.set", json!({"id": r, "props": {"fill": "none"}})); + assert!(s.doc().unwrap().node(r).unwrap().fills.is_empty()); + ex(&mut s, "edit.undo", json!({})); + assert_eq!(s.doc().unwrap().node(r).unwrap().fills.len(), 1); +} + +#[test] +fn stroke_none_or_null_clears_the_strokes() { + for none in [json!("none"), json!(null)] { + let mut s = Session::new(); + let r = create_rect(&mut s, json!("#FF0000")); + ex(&mut s, "node.set", json!({"id": r, "props": {"stroke": none}})); + assert!(s.doc().unwrap().node(r).unwrap().strokes.is_empty(), "{none:?}"); + assert_eq!(s.doc().unwrap().node(r).unwrap().fills.len(), 1, "{none:?}"); + } +} + +#[test] +fn text_with_fill_none_gets_no_automatic_colour() { + let mut s = Session::new(); + let t = id(ex( + &mut s, + "node.create", + json!({ + "type": "text", "x": 0, "y": 0, "text": "Hi", "props": {"fill": "none"} + }), + )); + assert!(s.doc().unwrap().node(t).unwrap().fills.is_empty()); +} + +#[test] +fn an_unknown_string_still_names_the_key() { + let mut s = Session::new(); + let e = s + .execute( + "node.create", + &json!({ + "type": "rectangle", "x": 0, "y": 0, "width": 10, "height": 10, + "props": {"fill": "nope"} + }), + ) + .unwrap_err(); + let msg = e.to_string(); + assert!(msg.contains("`fill`") && msg.contains("none"), "{msg}"); +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 79e7be0d..8e369adf 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1675,6 +1675,8 @@ mod fig_export_tests; #[cfg(test)] mod fig_import_tests; #[cfg(test)] +mod fill_none_tests; +#[cfg(test)] mod finite_geometry_tests; #[cfg(test)] mod frame_guides_tests; diff --git a/docs/mcp.md b/docs/mcp.md index dfea95cc..484255c2 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -144,6 +144,8 @@ Or in a project's `.mcp.json`: there is none, so they are page coordinates then). Groups and boolean groups have no coordinate space of their own: a layer in a group is placed in the frame (or on the page) the group is in. `inspect_document` reports `x`/`y` the same way, plus `absoluteBounds` in page coordinates. + In `props`, `fill` and `stroke` also accept `"none"` (either case) or `null` as no paint, the + same as an empty `fills`/`strokes` list, so an outline-only shape can be made in one call. - Frames, groups, sections and masks: drawing a frame or section with the `pointer` tool around layers puts those fully inside it in it; sections go only on the page or in sections (moves, drags, pastes and new sections elsewhere are refused or land on the page); resizing a section