From 386c6c1d3eb923459111e9df0fb75c231a5c3722 Mon Sep 17 00:00:00 2001
From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com>
Date: Sat, 7 Nov 2026 13:45:31 +0100
Subject: [PATCH 01/51] Reject negative and fractional index in
image.savePreset
Found while testing crates/engine/src/cmd/image.rs:239 (save_preset, `index = num(p, "index").filter(..).map(|k| k as usize)`)
Co-Authored-By: Claude Haiku 5.5
Co-Authored-By: Claude Sonnet 5.5
---
crates/engine/src/cmd/image.rs | 7 +++++--
crates/engine/src/paint_tests.rs | 10 ++++++++++
2 files changed, 15 insertions(+), 2 deletions(-)
diff --git a/crates/engine/src/cmd/image.rs b/crates/engine/src/cmd/image.rs
index 865e4d19a..37472b2d4 100644
--- a/crates/engine/src/cmd/image.rs
+++ b/crates/engine/src/cmd/image.rs
@@ -5,7 +5,7 @@ use figcraft_doc::{Document, ImagePreset, Node};
use serde_json::{Value, json};
use super::{CommandSpec, ok};
-use crate::params::{f64_of, get, num, req_index, req_string, req_targets, string, targets};
+use crate::params::{f64_of, get, req_index, req_string, req_targets, string, targets};
use crate::{EngineError, Result, Session, bad, spec};
pub fn specs() -> Vec {
@@ -236,7 +236,10 @@ fn save_preset(s: &mut Session, p: &Value) -> Result {
Some(v) => with_values(cmd, ImageFilters::NONE, v)?,
None => {
let stroke = strokes(cmd, p)?;
- let index = num(p, "index").filter(|k| k.is_finite() && *k >= 0.0).map(|k| k as usize);
+ let index = match get(p, "index") {
+ None | Some(Value::Null) => None,
+ Some(_) => Some(req_index(cmd, p, "index")?),
+ };
let doc = s.doc()?;
targets(s, p)
.iter()
diff --git a/crates/engine/src/paint_tests.rs b/crates/engine/src/paint_tests.rs
index 1e97b05ca..00ada8d79 100644
--- a/crates/engine/src/paint_tests.rs
+++ b/crates/engine/src/paint_tests.rs
@@ -135,6 +135,16 @@ fn image_adjust_refuses_what_it_cannot_do() {
assert_eq!(s.execute("image.adjust", &json!({"ids": [plain, r], "index": 1, "preset": "vivid"})).unwrap()["changed"], 1);
}
+#[test]
+fn save_preset_rejects_bad_index() {
+ let mut s = Session::new();
+ let r = image_rect(&mut s);
+ assert!(s.execute("image.savePreset", &json!({"ids": [r], "name": "B", "index": 0})).is_err());
+ assert!(s.execute("image.savePreset", &json!({"ids": [r], "name": "B", "index": -1})).is_err(), "negative index accepted");
+ assert!(s.execute("image.savePreset", &json!({"ids": [r], "name": "B", "index": 1.5})).is_err(), "fractional index accepted");
+ assert!(s.execute("image.savePreset", &json!({"ids": [r], "name": "B", "index": 1})).is_ok());
+}
+
#[test]
fn saved_image_presets() {
let mut s = Session::new();
From f5a52ea528e3e597e753f458d4fde92422b4583c Mon Sep 17 00:00:00 2001
From: Trevor Brown
Date: Sat, 7 Nov 2026 21:20:56 -0500
Subject: [PATCH 02/51] Components: several layers selected, the component
button offers Create multiple components (#2088)
With two or more layers selected, the Design panel header's component button opens a menu with Create component and Create multiple components, as Figma's does; one layer keeps the plain button. Headless UI tests cover both cases (three frames: menu order, names kept, all selected, one undo; one frame: plain click), and an engine test pins createMultiple's names, positions and single undo step.
Co-Authored-By: Claude Sonnet 5.5
Model-Effort: Opus 5.5 drove; Opus 5.5 briefed; Sonnet 5.5 wrote at medium
---
.../engine/src/components_behaviour_tests.rs | 33 +++++
.../ui-egui/src/create_multiple_menu_tests.rs | 115 ++++++++++++++++++
crates/ui-egui/src/lib.rs | 2 +
crates/ui-egui/src/panels/right.rs | 13 ++
docs/components-parity.md | 5 +-
5 files changed, 166 insertions(+), 2 deletions(-)
create mode 100644 crates/ui-egui/src/create_multiple_menu_tests.rs
diff --git a/crates/engine/src/components_behaviour_tests.rs b/crates/engine/src/components_behaviour_tests.rs
index 3458b0b22..198151768 100644
--- a/crates/engine/src/components_behaviour_tests.rs
+++ b/crates/engine/src/components_behaviour_tests.rs
@@ -7,6 +7,7 @@
use serde_json::{Value, json};
use crate::Session;
+use crate::automation::params::id_of;
use crate::components::tests::{chars, copy_of, ex, fill, healthy, idv, kids, node};
use figcraft_doc::*;
@@ -1000,3 +1001,35 @@ fn deleting_a_layer_inside_an_instance_hides_it_instead() {
s.execute("edit.delete", &json!({"ids": [k.inst]})).unwrap();
assert!(s.doc().unwrap().node(k.inst).is_none());
}
+
+/// Create multiple components (#2088): each selected layer becomes its own component in place,
+/// keeping its name and position; all stay selected and one undo reverts them all.
+#[test]
+fn create_multiple_components_keeps_names_and_places_and_is_one_undo_step() {
+ let mut s = Session::new();
+ let mut frames = Vec::new();
+ for (i, name) in ["Card", "Badge", "Tile"].into_iter().enumerate() {
+ let x = 150.0 * i as f64;
+ let v = s.execute("frame.create", &json!({"x": x, "y": 20, "width": 100, "height": 60})).unwrap();
+ let f = id_of(&v["id"]).unwrap();
+ s.execute("object.rename", &json!({"ids": [f], "name": name})).unwrap();
+ frames.push(f);
+ }
+ let before = undo_len(&s);
+ let out = s.execute("component.createMultiple", &json!({"ids": frames})).unwrap();
+ assert_eq!(undo_len(&s), before + 1, "one undo step");
+ let made: Vec = out["ids"].as_array().unwrap().iter().filter_map(id_of).collect();
+ assert_eq!(made.len(), 3);
+ assert_eq!(s.selection(), made, "all of them stay selected");
+ let doc = s.doc().unwrap();
+ for (i, (c, name)) in made.iter().zip(["Card", "Badge", "Tile"]).enumerate() {
+ let n = doc.node(*c).unwrap();
+ assert!(matches!(n.kind, NodeKind::Component { .. }), "{name} is a component");
+ assert_eq!(n.name, name);
+ let b = doc.world_bounds(*c).unwrap();
+ assert_eq!((b.x0, b.y0), (150.0 * i as f64, 20.0), "{name} stays in place");
+ }
+ s.undo().unwrap();
+ let doc = s.doc().unwrap();
+ assert!(frames.iter().all(|f| matches!(doc.node(*f).map(|n| &n.kind), Some(NodeKind::Frame))), "one undo restores the frames");
+}
diff --git a/crates/ui-egui/src/create_multiple_menu_tests.rs b/crates/ui-egui/src/create_multiple_menu_tests.rs
new file mode 100644
index 000000000..a8c2b3a56
--- /dev/null
+++ b/crates/ui-egui/src/create_multiple_menu_tests.rs
@@ -0,0 +1,115 @@
+//! With two or more layers selected the Design panel's component button opens a menu (Create
+//! component, Create multiple components); one layer keeps the plain click (#2088): drawn headless.
+
+use egui::accesskit::Node;
+use egui::{Event, PointerButton, Pos2, Rect, pos2};
+use figcraft_doc::{NodeId, NodeKind};
+use figcraft_engine::Session;
+use serde_json::json;
+
+use crate::layers_drag_tests::Ui;
+
+const NAMES: [&str; 3] = ["Card", "Badge", "Tile"];
+
+fn id(v: serde_json::Value) -> NodeId {
+ serde_json::from_value(v["id"].clone()).unwrap()
+}
+
+/// Frames named `NAMES[..n]`, all selected, headless.
+fn frames(n: usize) -> (Ui, Vec) {
+ let mut s = Session::new();
+ let mut ids = Vec::new();
+ for (i, name) in NAMES.into_iter().take(n).enumerate() {
+ let f = id(s.execute("frame.create", &json!({"x": 150 * i, "y": 0, "width": 100, "height": 60})).unwrap());
+ s.execute("object.rename", &json!({"ids": [f], "name": name})).unwrap();
+ ids.push(f);
+ }
+ s.execute("select.set", &json!({"ids": ids})).unwrap();
+ let mut u = Ui::new(s);
+ u.frame(vec![]);
+ (u, ids)
+}
+
+fn nodes(u: &mut Ui) -> Vec {
+ u.ctx.enable_accesskit();
+ u.frame(vec![]);
+ u.frame_output(vec![]).platform_output.accesskit_update.expect("an AccessKit update").nodes.into_iter().map(|(_, n)| n).collect()
+}
+
+fn bounds(n: &Node) -> Rect {
+ let b = n.bounds().unwrap();
+ Rect::from_min_max(pos2(b.x0 as f32, b.y0 as f32), pos2(b.x1 as f32, b.y1 as f32))
+}
+
+fn click(u: &mut Ui, at: Pos2) {
+ for pressed in [true, false] {
+ u.frame(vec![
+ Event::PointerMoved(at),
+ Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Default::default() },
+ ]);
+ }
+}
+
+fn settle(u: &mut Ui) {
+ for _ in 0..10 {
+ u.frame(vec![]);
+ }
+}
+
+/// The header's component button (its tooltip may carry the shortcut).
+fn click_button(u: &mut Ui) {
+ let all = nodes(u);
+ let button = all.iter().find(|n| n.label().is_some_and(|l| l.starts_with("Create component"))).expect("the Design panel's component button");
+ click(u, bounds(button).center());
+ settle(u);
+}
+
+fn undo_len(u: &Ui) -> usize {
+ u.app.session.ds().unwrap().history.undo.len()
+}
+
+#[test]
+fn several_layers_open_a_menu_and_create_multiple_components_in_one_undo_step() {
+ let (mut u, ids) = frames(3);
+ let before = undo_len(&u);
+ click_button(&mut u);
+ assert_eq!(undo_len(&u), before, "opening the menu changes nothing");
+ let all = nodes(&mut u);
+ // The menu's rows share an x with Create multiple components: exactly these two, in this order.
+ let multiple = all.iter().find(|n| n.label() == Some("Create multiple components")).expect("the menu's second entry");
+ let mb = bounds(multiple);
+ let mut rows: Vec<(f32, String)> = all
+ .iter()
+ .filter(|n| n.label().is_some() && (bounds(n).min.x - mb.min.x).abs() < 1.0 && (bounds(n).min.y - mb.min.y).abs() < 60.0)
+ .map(|n| (bounds(n).min.y, n.label().unwrap_or("").to_owned()))
+ .collect();
+ rows.sort_by(|a, b| a.0.total_cmp(&b.0));
+ let labels: Vec<&str> = rows.iter().map(|(_, l)| l.as_str()).collect();
+ assert_eq!(labels, ["Create component", "Create multiple components"], "the menu's entries");
+ click(&mut u, bounds(multiple).center());
+ settle(&mut u);
+ let sel = u.app.session.selection();
+ assert_eq!(sel.len(), 3);
+ let doc = u.app.session.doc().unwrap();
+ let names: Vec<&str> = sel.iter().map(|i| doc.node(*i).unwrap().name.as_str()).collect();
+ assert_eq!(names, NAMES);
+ assert!(sel.iter().all(|i| matches!(doc.node(*i).unwrap().kind, NodeKind::Component { .. })));
+ assert_eq!(undo_len(&u), before + 1);
+ u.app.session.undo().unwrap();
+ let doc = u.app.session.doc().unwrap();
+ assert!(ids.iter().all(|i| matches!(doc.node(*i).map(|n| &n.kind), Some(NodeKind::Frame))), "one undo reverts all three");
+}
+
+#[test]
+fn one_layer_keeps_the_plain_button() {
+ let (mut u, ids) = frames(1);
+ click_button(&mut u);
+ let all = nodes(&mut u);
+ assert!(all.iter().all(|n| n.label() != Some("Create multiple components")), "no menu opens");
+ let sel = u.app.session.selection();
+ assert_eq!(sel.len(), 1);
+ let doc = u.app.session.doc().unwrap();
+ assert!(matches!(doc.node(sel[0]).unwrap().kind, NodeKind::Component { .. }));
+ assert_eq!(doc.node(sel[0]).unwrap().name, NAMES[0]);
+ let _ = ids;
+}
diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs
index 11c96da74..b47f05fbb 100644
--- a/crates/ui-egui/src/lib.rs
+++ b/crates/ui-egui/src/lib.rs
@@ -88,6 +88,8 @@ mod context_menu_tests;
#[cfg(test)]
mod corner_individual_tests;
#[cfg(test)]
+mod create_multiple_menu_tests;
+#[cfg(test)]
mod crop_image_tests;
#[cfg(test)]
mod cursors_tests;
diff --git a/crates/ui-egui/src/panels/right.rs b/crates/ui-egui/src/panels/right.rs
index bba57c778..b408491a2 100644
--- a/crates/ui-egui/src/panels/right.rs
+++ b/crates/ui-egui/src/panels/right.rs
@@ -530,6 +530,19 @@ fn header(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) {
ui.close();
}
});
+ } else if cmd == "component.create" && sel.len() > 1 {
+ // Several layers: the button opens a menu, as Figma's does (#2088). One layer keeps the plain click.
+ // TODO: "Create component set" is the third entry, to add once its naming is measured.
+ menu_popup(&bresp).width(220.0).show(|ui| {
+ let multiple = crate::i18n::command("component.createMultiple", "Create multiple components");
+ for (c, label) in [("component.create", tr("Create component")), ("component.createMultiple", &multiple)] {
+ let key = crate::shortcuts::shortcut(c).unwrap_or("");
+ if menu_icon_item(ui, None, None, label, key) {
+ app.run(c, json!({}));
+ ui.close();
+ }
+ }
+ });
} else if bresp.clicked() {
app.run(cmd, json!({}));
}
diff --git a/docs/components-parity.md b/docs/components-parity.md
index d6b1ee5e6..3ecdae292 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-11-06 · **Change:** minor (the Local variables window selects several variables (click, Shift+click for a range, Cmd/Ctrl+click to toggle, Escape to clear) and delete, move to a group and scope act on the selected local variables in one undo step (#2002); text styles carry paragraph spacing and indent, the list style and the OpenType features, as the Supported in text styles table of Create and apply text styles says (#1829); a layer's effect binding takes only a field its effect has; Go to main component on an instance of a library component opens the library file in a tab and selects the main there; glass effects take variables; Delete on a layer inside an instance hides it as an override instead of being refused, as the help center says: "Figma will only toggle the layer's visibility instead of removing it"; 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)
+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-07 · **Change:** minor (with several layers selected the Design panel header's component button offers Create multiple components (#2088); the Local variables window selects several variables (click, Shift+click for a range, Cmd/Ctrl+click to toggle, Escape to clear) and delete, move to a group and scope act on the selected local variables in one undo step (#2002); text styles carry paragraph spacing and indent, the list style and the OpenType features, as the Supported in text styles table of Create and apply text styles says (#1829); a layer's effect binding takes only a field its effect has; Go to main component on an instance of a library component opens the library file in a tab and selects the main there; glass effects take variables; Delete on a layer inside an instance hides it as an override instead of being refused, as the help center says: "Figma will only toggle the layer's visibility instead of removing it"; 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
@@ -12,7 +12,7 @@ accounts).
| Feature | Status | Notes |
|---|---|---|
-| Create component, instances, detach, reset, go to main, restore main, push changes | done | push from a nested layer goes to the outer main |
+| Create component, instances, detach, reset, go to main, restore main, push changes | done | push from a nested layer goes to the outer main; with two or more layers selected the Design panel header's component button opens a menu with Create component and Create multiple components (each layer its own component in place, one undo step), one layer keeps the plain click (#2088); Create component set is not in the menu yet |
| Per-layer overrides with reset; kept across main edits, nested swaps, copy/paste | done | main edits match a full rebuild exactly at 2,000 instances |
| What an instance overrides: fills, strokes, effects, visibility, layer names, text content and every text property (resizing included), auto layout padding, gap and alignment, layout grids, export settings, prototype interactions, nested swaps and property values; the size and resizing (hug, fill, fixed, min and max) of layers inside it | done | each kept when the main changes that property, reset per group; opacity and corner radius too (documentation silent); size and resizing are kept through relayout, undo, save, `.fig` and REST import and detach, and stay with the instance on push |
| What it doesn't: position, rotation, constraints and absolute or grid placement of layers inside it, its auto layout direction and other settings, its structure | done | refused with a message, the fields greyed out (Delete on a layer inside an instance hides it instead of removing it, as the help center says it does); the instance itself resizes and its layers follow the main's constraints |
@@ -79,6 +79,7 @@ updates with overrides kept: `library_tests.rs`, `components_scale_tests.rs`.
| Date | Change | Summary |
|---|---|---|
+| 2026-11-07 | patch | M3, #2088: with two or more layers selected the Design panel header's component button opens a menu with Create component and Create multiple components, as Figma's does; one layer keeps the plain button. Create component set is left out until its naming is measured. Tests: `create_multiple_menu_tests`, `components_behaviour_tests::create_multiple_components_keeps_names_and_places_and_is_one_undo_step`. Grade unchanged. |
| 2026-11-05 | minor | M3, #2002: the Local variables window selects several variables, as the Layers panel selects layers. A click selects one row, Shift+click the range from the anchor through the rows shown, Cmd/Ctrl+click toggles a row, Escape or a click on the empty space below the rows clears; selected rows use the sidebar's selected-row colour. Right-clicking a row that is part of a selection of several offers Delete, Move to group and Scope (all supported properties, hidden from pickers, or only one scope) for the selected local variables, each sent as one `command.batch`, so one undo step; no engine command changed. Renaming and value edits stay single-row. Tests: `variables_select_tests` (headless UI). Grade unchanged |
| 2026-11-02 | minor | M3, #1829: text styles carry paragraph spacing and indent, the list style and the OpenType features ("Supported in text styles": "Paragraph spacing and indentation", "Lists", "OpenType features", Create and apply text styles) beside the character properties: `style.create` takes them from the layer (or the selected paragraphs' list) or from a `value` (`paragraphSpacing`, `paragraphIndent`, `listStyle`, `features`), `style.apply` sets them (on a range, the list goes to the paragraphs touched; the rest is the layer's, as in Figma), `style.update` moves them on every linked layer, editing one on a styled text unlinks it as editing its font does, the style editor lists them, and a `.fig` file keeps them with the style (its sample layer). Grade unchanged. |
| 2026-11-06 | patch | M3: a glass's light angle bound to a number past ±180 turns round to the same direction (270 is -90), as the angle drawn from it does, instead of stopping at 180. Tests: `effects_tests::a_bound_light_angle_turns_round_instead_of_stopping`. Grade unchanged. |
From 524ad8b1ddfd576ad86dd13d7c1b71f4deaad3e1 Mon Sep 17 00:00:00 2001
From: Trevor Brown
Date: Sat, 7 Nov 2026 21:38:30 -0500
Subject: [PATCH 03/51] Components: Create component set in the component
button's menu (#2088)
The third entry of the menu several selected layers get: each layer becomes a component and they are wrapped in a component set in place, 20 px around, named as Figma names them (measured): "Property 1=" in left-to-right order, prop=value names kept, and layers that all share a name give a set of that name with "Default" variants. Engine tests cover the three measured cases, geometry, selection, one undo and the refusal of instances; the UI test checks the three menu entries in order and that the third makes one set.
Co-Authored-By: Claude Sonnet 5.5
Model-Effort: Opus 5.5 drove; Opus 5.5 briefed; Sonnet 5.5 wrote at medium
---
crates/engine/src/cmd/component.rs | 9 +-
crates/engine/src/cmd/component/variants.rs | 55 ++++++++-
.../engine/src/components_behaviour_tests.rs | 106 ++++++++++++++++++
.../ui-egui/src/create_multiple_menu_tests.rs | 27 ++++-
crates/ui-egui/src/panels/right.rs | 6 +-
docs/components-parity.md | 4 +-
6 files changed, 197 insertions(+), 10 deletions(-)
diff --git a/crates/engine/src/cmd/component.rs b/crates/engine/src/cmd/component.rs
index e0b2e02f4..06e6bf166 100644
--- a/crates/engine/src/cmd/component.rs
+++ b/crates/engine/src/cmd/component.rs
@@ -24,7 +24,7 @@ use instance::{detach, make_slot, push_changes, remove_slot, reset_instance, res
pub(crate) use instance::{swap_at, swap_instance};
use props::{add_prop, bind_prop, delete_prop, edit_prop, set_props};
pub(crate) use props::{apply_prop_bindings, check_prop_binding, describe_props};
-use variants::{add_variant, combine};
+use variants::{add_variant, combine, create_set};
pub use variants::{can_add_variant, can_combine_variants};
/// `component.hideWhenPublishing`: the leading `.` of the names of this file's components and
@@ -72,6 +72,13 @@ pub fn specs() -> Vec {
s.set_selection(out.iter().filter_map(id_of).collect());
Ok(json!({"ids": out}))
}),
+ spec!(
+ "component.createSet",
+ "Create component set",
+ "{ids? (layers, not components, sets or instances)} (each becomes a component, wrapped in place in a component set: variants \"Property 1=\" left to right, 20 px around)",
+ true,
+ create_set
+ ),
spec!(
"component.combineVariants",
"Combine as variants",
diff --git a/crates/engine/src/cmd/component/variants.rs b/crates/engine/src/cmd/component/variants.rs
index 5f9e218ac..53e00ca6d 100644
--- a/crates/engine/src/cmd/component/variants.rs
+++ b/crates/engine/src/cmd/component/variants.rs
@@ -1,6 +1,6 @@
use super::{
- BTreeMap, BTreeSet, Document, EngineError, Node, NodeId, NodeKind, Result, Session, Value, bad, inside_instance, json, owning_instance,
- req_targets, roots, set_name_from, string, targets, variant_name, variant_options, variant_props, variant_set, z_sorted,
+ BTreeMap, BTreeSet, Document, EngineError, Node, NodeId, NodeKind, Result, Session, Value, bad, create, id_of, inside_instance, json,
+ owning_instance, req_targets, roots, set_name_from, string, targets, variant_name, variant_options, variant_props, variant_set, z_sorted,
};
/// Wrap components in a new component set (named after the first) placed around them.
@@ -94,6 +94,57 @@ pub(super) fn combine(s: &mut Session, p: &Value) -> Result {
Ok(json!({"id": set}))
}
+/// `component.createSet`: each target becomes a component (as `component.createMultiple` makes
+/// them) and all are wrapped in place in a component set, 20 px around, named as Figma names them:
+/// variants "Property 1=" in left-to-right order (a name holding `=` is kept); layers
+/// that all share a name give a set of that name with "Property 1=Default" variants; otherwise the
+/// set is "Component N".
+pub(super) fn create_set(s: &mut Session, p: &Value) -> Result {
+ let cmd = "component.createSet";
+ let mut seen = BTreeSet::new();
+ let ids: Vec = req_targets(cmd, s, p)?.into_iter().filter(|i| seen.insert(*i)).collect();
+ let doc = s.doc()?;
+ if ids.iter().any(|i| inside_instance(doc, *i)) {
+ return Err(EngineError::Disabled(cmd.into(), "layers inside an instance can't become components".into()));
+ }
+ if ids.iter().any(|i| doc.is_remote(*i)) {
+ return Err(EngineError::Disabled(cmd.into(), crate::library::READ_ONLY.into()));
+ }
+ if ids.iter().any(|i| {
+ doc.node(*i).is_some_and(|n| matches!(n.kind, NodeKind::Instance { .. } | NodeKind::Component { .. } | NodeKind::ComponentSet { .. }))
+ }) {
+ return Err(EngineError::Disabled(cmd.into(), "select layers that aren't components, component sets or instances".into()));
+ }
+ let mut made = Vec::new();
+ for i in roots(doc, &ids) {
+ let r = create(s, &json!({"ids": [i]}))?;
+ made.push(id_of(&r["id"]).ok_or_else(|| bad(cmd, "component creation failed"))?);
+ }
+ let doc = s.doc_mut()?;
+ // Left to right by world x, ties by y: the variants' order, and the values' order.
+ let mut by_x: Vec<(NodeId, f64, f64)> = made.iter().map(|i| doc.world_bounds(*i).map_or((*i, 0.0, 0.0), |b| (*i, b.x0, b.y0))).collect();
+ by_x.sort_by(|a, b| a.1.total_cmp(&b.1).then(a.2.total_cmp(&b.2)));
+ let names: Vec = by_x.iter().map(|(i, ..)| doc.node(*i).map(|n| n.name.clone()).unwrap_or_default()).collect();
+ let same = names.windows(2).all(|w| w.first() == w.get(1)) && names.first().is_some_and(|n| !n.is_empty() && !n.contains('='));
+ let set_name = match names.first() {
+ Some(n) if same => n.clone(),
+ _ => doc.next_name("Component"),
+ };
+ for ((i, ..), name) in by_x.iter().zip(&names) {
+ let node = doc.get_mut(*i)?;
+ if same {
+ node.name = "Property 1=Default".into();
+ } else if !name.contains('=') {
+ node.name = format!("Property 1={name}");
+ }
+ }
+ let ordered = z_sorted(doc, &made);
+ let set = wrap_in_set(cmd, doc, &ordered)?;
+ doc.get_mut(set)?.name = set_name;
+ s.set_selection(vec![set]);
+ Ok(json!({"id": set}))
+}
+
/// Grow a component set so it wraps its variants with 20 px of padding (right and bottom).
pub(super) fn fit_set(doc: &mut Document, set: NodeId) {
let Some(kids) = doc.node(set).map(|n| n.children.clone()) else { return };
diff --git a/crates/engine/src/components_behaviour_tests.rs b/crates/engine/src/components_behaviour_tests.rs
index 198151768..f65049de5 100644
--- a/crates/engine/src/components_behaviour_tests.rs
+++ b/crates/engine/src/components_behaviour_tests.rs
@@ -1033,3 +1033,109 @@ fn create_multiple_components_keeps_names_and_places_and_is_one_undo_step() {
let doc = s.doc().unwrap();
assert!(frames.iter().all(|f| matches!(doc.node(*f).map(|n| &n.kind), Some(NodeKind::Frame))), "one undo restores the frames");
}
+
+/// Frames named `names` at x = 150 * i (mirrored when `reverse`), y 20, 100 x 60.
+fn named_frames(s: &mut Session, names: &[&str], reverse: bool) -> Vec {
+ let n = names.len();
+ names
+ .iter()
+ .enumerate()
+ .map(|(i, name)| {
+ let x = 150.0 * if reverse { n - 1 - i } else { i } as f64;
+ let v = s.execute("frame.create", &json!({"x": x, "y": 20, "width": 100, "height": 60})).unwrap();
+ let f = id_of(&v["id"]).unwrap();
+ s.execute("object.rename", &json!({"ids": [f], "name": name})).unwrap();
+ f
+ })
+ .collect()
+}
+
+/// The set's name and its children's names in layer order.
+fn set_names(s: &Session, set: NodeId) -> (String, Vec) {
+ let doc = s.doc().unwrap();
+ let n = doc.node(set).unwrap();
+ (n.name.clone(), n.children.iter().map(|c| doc.node(*c).unwrap().name.clone()).collect())
+}
+
+/// The set's variant names ordered by world x.
+fn names_by_x(s: &Session, set: NodeId) -> Vec {
+ let doc = s.doc().unwrap();
+ let mut v: Vec<(f64, String)> =
+ doc.node(set).unwrap().children.iter().map(|c| (doc.world_bounds(*c).unwrap().x0, doc.node(*c).unwrap().name.clone())).collect();
+ v.sort_by(|a, b| a.0.total_cmp(&b.0));
+ v.into_iter().map(|(_, n)| n).collect()
+}
+
+/// Create component set (#2088, measured in Figma): variants "Property 1=" in left-to-right
+/// order, the set "Component 1", wrapped in place 20 px around, selected, one undo step.
+#[test]
+fn create_set_names_variants_by_layer_name_and_wraps_in_place() {
+ let mut s = Session::new();
+ let frames = named_frames(&mut s, &["Primary", "Secondary", "Ghost"], false);
+ let before = undo_len(&s);
+ let out = s.execute("component.createSet", &json!({"ids": frames})).unwrap();
+ assert_eq!(undo_len(&s), before + 1, "one undo step");
+ let set = id_of(&out["id"]).unwrap();
+ assert_eq!(s.selection(), vec![set], "the set is selected");
+ assert_eq!(set_names(&s, set).0, "Component 1");
+ assert_eq!(names_by_x(&s, set), ["Property 1=Primary", "Property 1=Secondary", "Property 1=Ghost"]);
+ let doc = s.doc().unwrap();
+ for (i, c) in doc.node(set).unwrap().children.iter().enumerate() {
+ assert!(matches!(doc.node(*c).unwrap().kind, NodeKind::Component { .. }));
+ let b = doc.world_bounds(*c).unwrap();
+ assert_eq!((b.x0, b.y0), (150.0 * i as f64, 20.0), "frames stay put");
+ }
+ let sb = doc.world_bounds(set).unwrap();
+ assert_eq!((sb.x0, sb.y0, sb.x1, sb.y1), (-20.0, 0.0, 420.0, 100.0), "20 px on every side");
+ s.undo().unwrap();
+ let doc = s.doc().unwrap();
+ assert!(doc.node(set).is_none());
+ assert!(frames.iter().all(|f| matches!(doc.node(*f).map(|n| &n.kind), Some(NodeKind::Frame))), "one undo restores the frames");
+}
+
+#[test]
+fn create_set_orders_left_to_right_whatever_the_selection_order() {
+ let mut s = Session::new();
+ let frames = named_frames(&mut s, &["A", "B", "C"], true);
+ let out = s.execute("component.createSet", &json!({"ids": frames})).unwrap();
+ let set = id_of(&out["id"]).unwrap();
+ assert_eq!(names_by_x(&s, set), ["Property 1=C", "Property 1=B", "Property 1=A"]);
+ let (_, kids) = set_names(&s, set);
+ assert_eq!(kids.len(), 3);
+}
+
+#[test]
+fn create_set_keeps_names_already_in_prop_value_form() {
+ let mut s = Session::new();
+ let frames = named_frames(&mut s, &["Size=Small", "Size=Large", "Size=Medium"], false);
+ let out = s.execute("component.createSet", &json!({"ids": frames})).unwrap();
+ let set = id_of(&out["id"]).unwrap();
+ assert_eq!(set_names(&s, set).0, "Component 1");
+ assert_eq!(names_by_x(&s, set), ["Size=Small", "Size=Large", "Size=Medium"]);
+}
+
+#[test]
+fn create_set_with_one_shared_name_names_the_set_and_makes_default_variants() {
+ let mut s = Session::new();
+ let frames = named_frames(&mut s, &["Card", "Card", "Card"], false);
+ let out = s.execute("component.createSet", &json!({"ids": frames})).unwrap();
+ let set = id_of(&out["id"]).unwrap();
+ let (name, kids) = set_names(&s, set);
+ assert_eq!(name, "Card");
+ assert_eq!(kids, ["Property 1=Default", "Property 1=Default", "Property 1=Default"]);
+}
+
+#[test]
+fn create_set_refuses_instances_and_leaves_the_document_alone() {
+ let mut s = Session::new();
+ let frames = named_frames(&mut s, &["A", "B"], false);
+ let c = s.execute("component.create", &json!({"ids": [frames[0]]})).unwrap();
+ let comp = id_of(&c["id"]).unwrap();
+ let inst = s.execute("instance.create", &json!({"component": comp})).unwrap();
+ let inst = id_of(&inst["id"]).unwrap();
+ let before = undo_len(&s);
+ let e = s.execute("component.createSet", &json!({"ids": [inst, frames[1]]})).unwrap_err();
+ assert!(matches!(e, crate::EngineError::Disabled(..)), "{e:?}");
+ assert_eq!(undo_len(&s), before);
+ assert!(matches!(s.doc().unwrap().node(frames[1]).unwrap().kind, NodeKind::Frame));
+}
diff --git a/crates/ui-egui/src/create_multiple_menu_tests.rs b/crates/ui-egui/src/create_multiple_menu_tests.rs
index a8c2b3a56..409b67e54 100644
--- a/crates/ui-egui/src/create_multiple_menu_tests.rs
+++ b/crates/ui-egui/src/create_multiple_menu_tests.rs
@@ -1,5 +1,5 @@
//! With two or more layers selected the Design panel's component button opens a menu (Create
-//! component, Create multiple components); one layer keeps the plain click (#2088): drawn headless.
+//! component, Create multiple components, Create component set); one layer keeps the plain click (#2088): drawn headless.
use egui::accesskit::Node;
use egui::{Event, PointerButton, Pos2, Rect, pos2};
@@ -80,12 +80,12 @@ fn several_layers_open_a_menu_and_create_multiple_components_in_one_undo_step()
let mb = bounds(multiple);
let mut rows: Vec<(f32, String)> = all
.iter()
- .filter(|n| n.label().is_some() && (bounds(n).min.x - mb.min.x).abs() < 1.0 && (bounds(n).min.y - mb.min.y).abs() < 60.0)
+ .filter(|n| n.label().is_some() && (bounds(n).min.x - mb.min.x).abs() < 1.0 && (bounds(n).min.y - mb.min.y).abs() < 90.0)
.map(|n| (bounds(n).min.y, n.label().unwrap_or("").to_owned()))
.collect();
rows.sort_by(|a, b| a.0.total_cmp(&b.0));
let labels: Vec<&str> = rows.iter().map(|(_, l)| l.as_str()).collect();
- assert_eq!(labels, ["Create component", "Create multiple components"], "the menu's entries");
+ assert_eq!(labels, ["Create component", "Create multiple components", "Create component set"], "the menu's entries");
click(&mut u, bounds(multiple).center());
settle(&mut u);
let sel = u.app.session.selection();
@@ -113,3 +113,24 @@ fn one_layer_keeps_the_plain_button() {
assert_eq!(doc.node(sel[0]).unwrap().name, NAMES[0]);
let _ = ids;
}
+
+#[test]
+fn the_third_entry_creates_one_component_set() {
+ let (mut u, ids) = frames(3);
+ let before = undo_len(&u);
+ click_button(&mut u);
+ let all = nodes(&mut u);
+ let entry = all.iter().find(|n| n.label() == Some("Create component set")).expect("the menu's third entry");
+ click(&mut u, bounds(entry).center());
+ settle(&mut u);
+ let sel = u.app.session.selection();
+ assert_eq!(sel.len(), 1, "the set is selected");
+ let doc = u.app.session.doc().unwrap();
+ let set = doc.node(sel[0]).unwrap();
+ assert!(matches!(set.kind, NodeKind::ComponentSet { .. }));
+ assert_eq!(set.children.len(), 3);
+ assert_eq!(undo_len(&u), before + 1);
+ u.app.session.undo().unwrap();
+ let doc = u.app.session.doc().unwrap();
+ assert!(ids.iter().all(|i| matches!(doc.node(*i).map(|n| &n.kind), Some(NodeKind::Frame))), "one undo reverts it all");
+}
diff --git a/crates/ui-egui/src/panels/right.rs b/crates/ui-egui/src/panels/right.rs
index b408491a2..55b36ebf5 100644
--- a/crates/ui-egui/src/panels/right.rs
+++ b/crates/ui-egui/src/panels/right.rs
@@ -532,10 +532,12 @@ fn header(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) {
});
} else if cmd == "component.create" && sel.len() > 1 {
// Several layers: the button opens a menu, as Figma's does (#2088). One layer keeps the plain click.
- // TODO: "Create component set" is the third entry, to add once its naming is measured.
menu_popup(&bresp).width(220.0).show(|ui| {
let multiple = crate::i18n::command("component.createMultiple", "Create multiple components");
- for (c, label) in [("component.create", tr("Create component")), ("component.createMultiple", &multiple)] {
+ let set = crate::i18n::command("component.createSet", "Create component set");
+ for (c, label) in
+ [("component.create", tr("Create component")), ("component.createMultiple", &multiple), ("component.createSet", &set)]
+ {
let key = crate::shortcuts::shortcut(c).unwrap_or("");
if menu_icon_item(ui, None, None, label, key) {
app.run(c, json!({}));
diff --git a/docs/components-parity.md b/docs/components-parity.md
index 3ecdae292..a28ca6d0e 100644
--- a/docs/components-parity.md
+++ b/docs/components-parity.md
@@ -12,7 +12,7 @@ accounts).
| Feature | Status | Notes |
|---|---|---|
-| Create component, instances, detach, reset, go to main, restore main, push changes | done | push from a nested layer goes to the outer main; with two or more layers selected the Design panel header's component button opens a menu with Create component and Create multiple components (each layer its own component in place, one undo step), one layer keeps the plain click (#2088); Create component set is not in the menu yet |
+| Create component, instances, detach, reset, go to main, restore main, push changes | done | push from a nested layer goes to the outer main; with two or more layers selected the Design panel header's component button opens a menu with Create component, Create multiple components (each layer its own component in place, one undo step) and Create component set (each layer a component, wrapped in place in a set 20 px around: variants `Property 1=` left to right, `prop=value` names kept, layers that all share a name give a set of that name with `Property 1=Default` variants; measured in Figma), one layer keeps the plain click (#2088) |
| Per-layer overrides with reset; kept across main edits, nested swaps, copy/paste | done | main edits match a full rebuild exactly at 2,000 instances |
| What an instance overrides: fills, strokes, effects, visibility, layer names, text content and every text property (resizing included), auto layout padding, gap and alignment, layout grids, export settings, prototype interactions, nested swaps and property values; the size and resizing (hug, fill, fixed, min and max) of layers inside it | done | each kept when the main changes that property, reset per group; opacity and corner radius too (documentation silent); size and resizing are kept through relayout, undo, save, `.fig` and REST import and detach, and stay with the instance on push |
| What it doesn't: position, rotation, constraints and absolute or grid placement of layers inside it, its auto layout direction and other settings, its structure | done | refused with a message, the fields greyed out (Delete on a layer inside an instance hides it instead of removing it, as the help center says it does); the instance itself resizes and its layers follow the main's constraints |
@@ -79,7 +79,7 @@ updates with overrides kept: `library_tests.rs`, `components_scale_tests.rs`.
| Date | Change | Summary |
|---|---|---|
-| 2026-11-07 | patch | M3, #2088: with two or more layers selected the Design panel header's component button opens a menu with Create component and Create multiple components, as Figma's does; one layer keeps the plain button. Create component set is left out until its naming is measured. Tests: `create_multiple_menu_tests`, `components_behaviour_tests::create_multiple_components_keeps_names_and_places_and_is_one_undo_step`. Grade unchanged. |
+| 2026-11-07 | patch | M3, #2088: with two or more layers selected the Design panel header's component button opens a menu with Create component, Create multiple components and Create component set (`component.createSet`, naming measured in Figma), as Figma's does; one layer keeps the plain button. Tests: `create_multiple_menu_tests`, `components_behaviour_tests::create_multiple_components_keeps_names_and_places_and_is_one_undo_step`, `components_behaviour_tests::create_set_*`. Grade unchanged. |
| 2026-11-05 | minor | M3, #2002: the Local variables window selects several variables, as the Layers panel selects layers. A click selects one row, Shift+click the range from the anchor through the rows shown, Cmd/Ctrl+click toggles a row, Escape or a click on the empty space below the rows clears; selected rows use the sidebar's selected-row colour. Right-clicking a row that is part of a selection of several offers Delete, Move to group and Scope (all supported properties, hidden from pickers, or only one scope) for the selected local variables, each sent as one `command.batch`, so one undo step; no engine command changed. Renaming and value edits stay single-row. Tests: `variables_select_tests` (headless UI). Grade unchanged |
| 2026-11-02 | minor | M3, #1829: text styles carry paragraph spacing and indent, the list style and the OpenType features ("Supported in text styles": "Paragraph spacing and indentation", "Lists", "OpenType features", Create and apply text styles) beside the character properties: `style.create` takes them from the layer (or the selected paragraphs' list) or from a `value` (`paragraphSpacing`, `paragraphIndent`, `listStyle`, `features`), `style.apply` sets them (on a range, the list goes to the paragraphs touched; the rest is the layer's, as in Figma), `style.update` moves them on every linked layer, editing one on a styled text unlinks it as editing its font does, the style editor lists them, and a `.fig` file keeps them with the style (its sample layer). Grade unchanged. |
| 2026-11-06 | patch | M3: a glass's light angle bound to a number past ±180 turns round to the same direction (270 is -90), as the angle drawn from it does, instead of stopping at 180. Tests: `effects_tests::a_bound_light_angle_turns_round_instead_of_stopping`. Grade unchanged. |
From 34c44e6063e4eb70f801bf3b8bffc6d6398faf6c Mon Sep 17 00:00:00 2001
From: Trevor Brown
Date: Sat, 7 Nov 2026 22:15:22 -0500
Subject: [PATCH 04/51] Text: a font loaded with text.loadFont wins over the
built-in and installed faces of the same family and style (#2092)
FigCraft chose the first face added for a family and style, so the embedded Inter 4.x kept Regular, SemiBold and Bold after Inter 3.19 was loaded and only Medium refitted. A loaded font is the counterpart of a font uploaded to the organization, which Figma's help center ranks first ("Manage conflicting fonts"). The test builds its faces from any embedded or installed font (skipped when there is none) and checks a loaded face beats a built-in one, the latest load beats earlier ones, and other styles resolve as before.
Co-Authored-By: Claude Sonnet 5.5
Model-Effort: Opus 5.5 drove; Opus 5.5 briefed; Sonnet 5.5 wrote at medium
---
crates/text/src/fontdb.rs | 29 +++++++++++++++++++++---
crates/text/src/fontdb_tests.rs | 39 +++++++++++++++++++++++++++++++++
docs/file-format-parity.md | 2 ++
3 files changed, 67 insertions(+), 3 deletions(-)
diff --git a/crates/text/src/fontdb.rs b/crates/text/src/fontdb.rs
index 3c82cd261..5159a68d1 100644
--- a/crates/text/src/fontdb.rs
+++ b/crates/text/src/fontdb.rs
@@ -92,6 +92,10 @@ pub struct Face {
pub italic: bool,
pub bytes: FontBytes,
pub index: u32,
+ /// 0 for a built-in or installed face; else the order it was loaded in with `text.loadFont`
+ /// ([`FontDb::add_loaded`]). A loaded face wins over any other of its family and style, the
+ /// latest load over earlier ones (#2092).
+ pub loaded: u32,
pub upem: f64,
/// Ascent and descent in font units, both positive.
pub ascent: f64,
@@ -207,6 +211,7 @@ impl Face {
family,
style,
index,
+ loaded: 0,
upem,
ascent,
descent,
@@ -620,6 +625,8 @@ pub struct FontDb {
loaded_files: std::collections::BTreeSet<(std::path::PathBuf, u32)>,
/// Font files added as bytes (hash and length → families), so loading one again adds nothing.
loaded_bytes: std::collections::HashMap<(u64, usize), Vec>,
+ /// Loads so far ([`Face::loaded`]).
+ load_seq: u32,
system_scanned: bool,
/// Browser fonts text asked for and not yet taken by the browser layer.
wanted: Vec,
@@ -716,8 +723,20 @@ impl FontDb {
face
}
+ /// [`add`](Self::add) a face loaded explicitly (`text.loadFont`): it wins over the other faces
+ /// of its family and style, and over those loaded before it.
+ pub fn add_loaded(&mut self, mut face: Face) -> Arc {
+ self.load_seq += 1;
+ face.loaded = self.load_seq;
+ self.add(face)
+ }
+
/// Add font file bytes (all faces of a collection). Returns the faces added.
pub fn add_bytes(&mut self, bytes: Vec) -> Vec> {
+ self.add_bytes_with(bytes, false)
+ }
+
+ fn add_bytes_with(&mut self, bytes: Vec, loaded: bool) -> Vec> {
let shared = Arc::new(bytes);
let count = match FileRef::new(shared.as_slice()) {
// A collection's header may name any number of faces, each a few bytes pointing at
@@ -727,7 +746,9 @@ impl FontDb {
Ok(FileRef::Font(_)) => 1,
Err(_) => 0,
};
- (0..count).filter_map(|i| Face::from_bytes(FontBytes::Shared(shared.clone()), i, None, None)).map(|f| self.add(f)).collect()
+ (0..count).filter_map(|i| Face::from_bytes(FontBytes::Shared(shared.clone()), i, None, None))
+ .map(|f| if loaded { self.add_loaded(f) } else { self.add(f) })
+ .collect()
}
/// The family and style of the face whose PostScript name is `ps` (ignoring case), loaded or
@@ -1035,7 +1056,9 @@ impl FontDb {
/// The loaded face of `family` nearest `style`.
fn best(&self, family: &str, style: &str) -> Option> {
let (_, idx) = self.families.get(&self.key(family))?;
- let faces: Vec<&Arc> = idx.iter().filter_map(|i| self.faces.get(*i)).collect();
+ let mut faces: Vec<&Arc> = idx.iter().filter_map(|i| self.faces.get(*i)).collect();
+ // A loaded font comes first (the latest load first): of faces as near, the first wins.
+ faces.sort_by_key(|f| std::cmp::Reverse(f.loaded));
let want = norm_style(style);
if let Some(f) = faces.iter().find(|f| norm_style(&f.style) == want).or_else(|| faces.iter().find(|f| has_style(f, style))) {
return Some((*f).clone());
@@ -1595,7 +1618,7 @@ pub fn load_font_bytes(bytes: Vec) -> Vec {
if let Some(families) = db.loaded_bytes.get(&key) {
return families.clone();
}
- let faces = db.add_bytes(bytes);
+ let faces = db.add_bytes_with(bytes, true);
let mut families: Vec = faces.iter().map(|f| f.family.clone()).collect();
families.dedup();
if !families.is_empty() {
diff --git a/crates/text/src/fontdb_tests.rs b/crates/text/src/fontdb_tests.rs
index 38e04c790..a3dd15396 100644
--- a/crates/text/src/fontdb_tests.rs
+++ b/crates/text/src/fontdb_tests.rs
@@ -382,6 +382,45 @@ fn font_bytes_load_into_the_db() {
}
}
+/// #2092: a font loaded with `text.loadFont` wins over the built-in and installed faces of its family
+/// and style, the latest load over earlier ones; other styles resolve as before.
+#[test]
+fn a_loaded_face_wins_over_the_built_in_one_of_its_family_and_style() {
+ // Any embedded or installed font stands in for the faces (none without craft-fonts on CI).
+ let stand_in = CRAFT_FONTS.first().map(|f| f.bytes.to_vec()).or_else(|| {
+ let mut installed = BTreeMap::new();
+ for dir in system_font_dirs() {
+ scan_dir(&dir, 0, &mut installed);
+ }
+ installed.values().flat_map(|(_, v)| v).find_map(|p| std::fs::read(&p.path).ok())
+ });
+ let Some(bytes) = stand_in.map(Arc::new) else {
+ eprintln!("skipping: no font to stand in");
+ return;
+ };
+ let face = |style: &str, name: &str| {
+ let mut f = Face::from_bytes(FontBytes::Shared(bytes.clone()), 0, Some("Brand Sans"), Some(style)).unwrap();
+ f.instances.clear();
+ f.postscript = Some(name.into());
+ f
+ };
+ let name = |db: &FontDb, style: &str| db.best("Brand Sans", style).and_then(|f| f.postscript.clone());
+ let mut db = FontDb::default();
+ db.add(face("Regular", "Built-in-Regular"));
+ db.add(face("Bold", "Built-in-Bold"));
+ assert_eq!(name(&db, "Regular").as_deref(), Some("Built-in-Regular"));
+ db.add_loaded(face("Regular", "Loaded-Regular"));
+ assert_eq!(name(&db, "Regular").as_deref(), Some("Loaded-Regular"));
+ // Bold wasn't loaded: the built-in one stays. Nor does the loaded Regular stand in for it.
+ assert_eq!(name(&db, "Bold").as_deref(), Some("Built-in-Bold"));
+ // The latest load wins.
+ db.add_loaded(face("Regular", "Loaded-Regular-2"));
+ assert_eq!(name(&db, "Regular").as_deref(), Some("Loaded-Regular-2"));
+ // A face added after a load, not loaded, doesn't displace it.
+ db.add(face("Regular", "Installed-Regular"));
+ assert_eq!(name(&db, "Regular").as_deref(), Some("Loaded-Regular-2"));
+}
+
/// Apple's system font answers to the names designs and CSS use for it (SF Pro, SF UI Text,
/// system-ui, …), unless a font of that name is installed.
#[test]
diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md
index bce90b008..0aaf249ca 100644
--- a/docs/file-format-parity.md
+++ b/docs/file-format-parity.md
@@ -181,6 +181,7 @@ source paint coordinates.
| | Figma | FigCraft |
|---|---|---|
| Local fonts | yes (font helper / desktop app) | yes (system scan) |
+| Conflicting fonts | an uploaded organization font comes first, then fonts installed on the device, then Figma's web fonts (help center, "Manage conflicting fonts") | a font loaded with `text.loadFont` wins over the built-in and installed faces of the same family and style, the latest load over earlier ones (#2092); installed versus built-in order is unchanged, and browser local fonts count as installed |
| Bundled/shared fonts | Google Fonts in the cloud | optional `CRAFT_FONTS_DIR` build input (storytold/craft-fonts) |
| Variable fonts | yes | yes (axes, named instances, per-range axis values) |
| Missing-font replacement on open | yes | listed, not replaced: in the desktop app the Missing fonts window opens after a file that uses them opens and from Text ▸ Missing fonts (`text.missingFonts`; `figcraft-cli info` prints `missing font` lines), and their text draws in a fallback font. A family the font store has (Google Fonts, Noto) downloads from the window once the user allows downloading fonts (`font.download`, #754): pinned, checksum-verified files into the Craft apps' shared font folder, after which the text draws in it. A family is found however a file names it: by the legacy name of one of its weights ("Family Medium") or by the typographic name of a family installed one family per weight, and spelt with other case, spaces or hyphens (#1292). No substitute picker |
@@ -189,6 +190,7 @@ source paint coordinates.
| Date | Change | Summary |
|---|---|---|
+| 2026-11-07 | patch | Fonts, #2092: a font loaded with `text.loadFont` is the counterpart of a font uploaded to the organization, which Figma's help center ("Manage conflicting fonts") ranks first, ahead of installed fonts and the web fonts. FigCraft chose the first face added for a family and style, so the built-in Inter 4.x kept Regular, SemiBold and Bold after Inter 3.19 was loaded and only Medium refitted. A loaded face now wins over the other faces of its family and style, the latest load over earlier ones; style matching and fallbacks are unchanged, and `refitted` lists the text whose size changes. Installed versus built-in order is not changed here. Test: `fontdb::tests::a_loaded_face_wins_over_the_built_in_one_of_its_family_and_style`. Numbers unchanged. |
| 2026-11-03 | patch | M6, #1909: `.jam` import: a connector end whose magnet is `AUTO` (FigJam picks the side when it draws) or `CENTER` (the end aims at the layer's middle) left the side its stale `relativePosition` named, the left one in the reporter's file, so the connectors looped behind their shape or crossed it. It now leaves the side facing the other end, as a board connector drawn here does (`Side::Auto`), for straight, elbowed and curved connectors alike; a named magnet (`TOP`, `RIGHT`, `BOTTOM`, `LEFT`) is unchanged, and an end without a magnet still takes the edge its point lies on. The Plugin API lists the magnets (`ConnectorEndpoint.magnet`: "Which side of the node it attaches to"; NONE, AUTO, TOP, LEFT, BOTTOM, RIGHT, CENTER; "Straight connectors may use only CENTER or NONE magnets"). Test: `connectors_with_an_automatic_side_leave_the_side_facing_the_target`. Numbers unchanged. |
| 2026-11-04 | minor | JPG export: `quality` (1 to 100, default 90) on `file.export`, `export.add` / `export.update` and MCP `export`, kept in the export setting; an sRGB file's JPGs embed the sRGB ICC profile, as Display P3 files' JPGs already embedded theirs, since an untagged JPG is only assumed to be sRGB (#2004). |
| 2026-11-04 | minor | `.fig` and `.jam` import, #1910: an elbowed connector saved with its path keeps it. FigJam stores `connectorControlPoints`, one per run between the first and the last, each a point on its run (in the connector's parent's coordinates) with the run's axis; the first run leaves the start's side and the last arrives at the end's. They become the `ConnectorPath::Runs` a board connector reshaped by hand has (`figcraft_doc::route_along`), so a connector drawn around a layer in FigJam stays around it instead of being routed through it. Points that don't make such a path (off both axes, two runs in a row the same way, a first or last run sideways to its side or going back into its object, more runs than a hand could make) leave the connector to the router as before, and only connectors routed here count in the "follow FigCraft's own routing" note. A curved connector's `connectorBezierControlPoints` are still not read. Tests: `tail_tests::an_elbow_connector_saved_with_its_path_keeps_it`, `tail_tests::an_elbow_whose_saved_path_doesnt_fit_its_sides_is_routed_here`. |
From a375ac06893ed41af6916508c18bb5896e1e527b Mon Sep 17 00:00:00 2001
From: Trevor Brown
Date: Sat, 7 Nov 2026 22:15:41 -0500
Subject: [PATCH 05/51] Format (#2092)
Co-Authored-By: Claude Sonnet 5.5
---
crates/text/src/fontdb.rs | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/crates/text/src/fontdb.rs b/crates/text/src/fontdb.rs
index 5159a68d1..65df051e7 100644
--- a/crates/text/src/fontdb.rs
+++ b/crates/text/src/fontdb.rs
@@ -746,7 +746,8 @@ impl FontDb {
Ok(FileRef::Font(_)) => 1,
Err(_) => 0,
};
- (0..count).filter_map(|i| Face::from_bytes(FontBytes::Shared(shared.clone()), i, None, None))
+ (0..count)
+ .filter_map(|i| Face::from_bytes(FontBytes::Shared(shared.clone()), i, None, None))
.map(|f| if loaded { self.add_loaded(f) } else { self.add(f) })
.collect()
}
From 253e0c3df3951f4a6e6f4f85e6d2ae0a08dfd309 Mon Sep 17 00:00:00 2001
From: Trevor Brown
Date: Sat, 7 Nov 2026 22:49:04 -0500
Subject: [PATCH 06/51] Components: Create component set refuses nested layers
and is translated (#2095 review)
component.createSet now refuses a selection where one layer is inside another, as component.combineVariants does, before changing the document (the nested layer was silently dropped from the set). Adds the component.createSet label to the 22 locale catalogs that list component.createMultiple.
Co-Authored-By: Claude Sonnet 5.5
Model-Effort: Opus 5.5 drove; Opus 5.5 briefed; Sonnet 5.5 wrote at medium
---
crates/engine/src/cmd/component/variants.rs | 3 +++
crates/engine/src/components_behaviour_tests.rs | 14 ++++++++++++++
crates/ui-egui/locales/ar.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/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/locales/zh-Hant.toml | 1 +
24 files changed, 39 insertions(+)
diff --git a/crates/engine/src/cmd/component/variants.rs b/crates/engine/src/cmd/component/variants.rs
index 53e00ca6d..cfba5accf 100644
--- a/crates/engine/src/cmd/component/variants.rs
+++ b/crates/engine/src/cmd/component/variants.rs
@@ -115,6 +115,9 @@ pub(super) fn create_set(s: &mut Session, p: &Value) -> Result {
}) {
return Err(EngineError::Disabled(cmd.into(), "select layers that aren't components, component sets or instances".into()));
}
+ if roots(doc, &ids).len() != ids.len() {
+ return Err(EngineError::Disabled(cmd.into(), "a layer can't be in a set with one inside it".into()));
+ }
let mut made = Vec::new();
for i in roots(doc, &ids) {
let r = create(s, &json!({"ids": [i]}))?;
diff --git a/crates/engine/src/components_behaviour_tests.rs b/crates/engine/src/components_behaviour_tests.rs
index f65049de5..510fb2e74 100644
--- a/crates/engine/src/components_behaviour_tests.rs
+++ b/crates/engine/src/components_behaviour_tests.rs
@@ -1139,3 +1139,17 @@ fn create_set_refuses_instances_and_leaves_the_document_alone() {
assert_eq!(undo_len(&s), before);
assert!(matches!(s.doc().unwrap().node(frames[1]).unwrap().kind, NodeKind::Frame));
}
+
+#[test]
+fn create_set_refuses_a_target_inside_another_target_and_leaves_the_document_alone() {
+ let mut s = Session::new();
+ let frames = named_frames(&mut s, &["Outer", "Inner"], false);
+ s.execute("node.reparent", &json!({"ids": [frames[1]], "parent": frames[0]})).unwrap();
+ let before = undo_len(&s);
+ let e = s.execute("component.createSet", &json!({"ids": [frames[0], frames[1]]})).unwrap_err();
+ assert!(matches!(e, crate::EngineError::Disabled(..)), "{e:?}");
+ assert_eq!(undo_len(&s), before);
+ let doc = s.doc().unwrap();
+ assert!(matches!(doc.node(frames[0]).unwrap().kind, NodeKind::Frame));
+ assert!(matches!(doc.node(frames[1]).unwrap().kind, NodeKind::Frame));
+}
diff --git a/crates/ui-egui/locales/ar.toml b/crates/ui-egui/locales/ar.toml
index da4474e79..cbbe8e07d 100644
--- a/crates/ui-egui/locales/ar.toml
+++ b/crates/ui-egui/locales/ar.toml
@@ -2009,6 +2009,7 @@ status = "draft — needs native review"
"component.combineVariants" = "جمع كتنويعات"
"component.create" = "إنشاء مكوّن"
"component.createMultiple" = "إنشاء مكونات متعددة"
+"component.createSet" = "إنشاء مجموعة مكونات"
"component.deleteProperty" = "حذف خاصية المكوّن"
"component.detach" = "فصل النسخة المثيلة"
"component.editProperty" = "تحرير خاصية المكوّن"
diff --git a/crates/ui-egui/locales/bs.toml b/crates/ui-egui/locales/bs.toml
index 36d71664c..e39053f08 100644
--- a/crates/ui-egui/locales/bs.toml
+++ b/crates/ui-egui/locales/bs.toml
@@ -2038,6 +2038,7 @@ status = "draft — needs native review"
"component.combineVariants" = "Kombinuj kao varijante"
"component.create" = "Kreiraj komponentu"
"component.createMultiple" = "Kreiraj više komponenti"
+"component.createSet" = "Kreiraj skup komponenti"
"component.deleteProperty" = "Izbriši svojstvo komponente"
"component.detach" = "Odvoji instancu"
"component.editProperty" = "Uredi svojstvo komponente"
diff --git a/crates/ui-egui/locales/cs.toml b/crates/ui-egui/locales/cs.toml
index d401654ac..de7f9cc89 100644
--- a/crates/ui-egui/locales/cs.toml
+++ b/crates/ui-egui/locales/cs.toml
@@ -1913,6 +1913,7 @@ status = "draft — needs native review"
"component.combineVariants" = "Sloučit jako varianty"
"component.create" = "Vytvořit komponentu"
"component.createMultiple" = "Vytvořit více komponent"
+"component.createSet" = "Vytvořit sadu komponent"
"component.deleteProperty" = "Smazat vlastnost komponenty"
"component.detach" = "Odpojit instanci"
"component.editProperty" = "Upravit vlastnost komponenty"
diff --git a/crates/ui-egui/locales/de.toml b/crates/ui-egui/locales/de.toml
index 6fd1838d6..6256fc1e0 100644
--- a/crates/ui-egui/locales/de.toml
+++ b/crates/ui-egui/locales/de.toml
@@ -2079,6 +2079,7 @@ status = "draft — needs native review"
"component.combineVariants" = "Als Varianten kombinieren"
"component.create" = "Komponente erstellen"
"component.createMultiple" = "Mehrere Komponenten erstellen"
+"component.createSet" = "Komponentensatz erstellen"
"component.deleteProperty" = "Komponenteneigenschaft löschen"
"component.detach" = "Instanz lösen"
"component.editProperty" = "Komponenteneigenschaft bearbeiten"
diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml
index 2baa2b31e..6c2d171c3 100644
--- a/crates/ui-egui/locales/es.toml
+++ b/crates/ui-egui/locales/es.toml
@@ -2002,6 +2002,7 @@ status = "draft — needs native review"
"component.combineVariants" = "Combinar como variantes"
"component.create" = "Crear componente"
"component.createMultiple" = "Crear varios componentes"
+"component.createSet" = "Crear conjunto de componentes"
"component.deleteProperty" = "Eliminar propiedad de componente"
"component.detach" = "Separar instancia"
"component.editProperty" = "Editar propiedad de componente"
diff --git a/crates/ui-egui/locales/fi.toml b/crates/ui-egui/locales/fi.toml
index 2f5db319a..a1b33417f 100644
--- a/crates/ui-egui/locales/fi.toml
+++ b/crates/ui-egui/locales/fi.toml
@@ -2086,6 +2086,7 @@ status = "draft — needs native review"
"component.combineVariants" = "Yhdistä varianteiksi"
"component.create" = "Luo komponentti"
"component.createMultiple" = "Luo useita komponentteja"
+"component.createSet" = "Luo komponenttijoukko"
"component.deleteProperty" = "Poista komponentin ominaisuus"
"component.detach" = "Irrota ilmentymä"
"component.editProperty" = "Muokkaa komponentin ominaisuutta"
diff --git a/crates/ui-egui/locales/fr.toml b/crates/ui-egui/locales/fr.toml
index d47ad9597..e5feebc9b 100644
--- a/crates/ui-egui/locales/fr.toml
+++ b/crates/ui-egui/locales/fr.toml
@@ -1958,6 +1958,7 @@ status = "draft — needs native review"
"component.combineVariants" = "Combiner en variantes"
"component.create" = "Créer un composant"
"component.createMultiple" = "Créer plusieurs composants"
+"component.createSet" = "Créer un ensemble de composants"
"component.deleteProperty" = "Supprimer la propriété de composant"
"component.detach" = "Détacher l’instance"
"component.editProperty" = "Modifier la propriété de composant"
diff --git a/crates/ui-egui/locales/id.toml b/crates/ui-egui/locales/id.toml
index cbebaf1c4..3bb5d027c 100644
--- a/crates/ui-egui/locales/id.toml
+++ b/crates/ui-egui/locales/id.toml
@@ -1917,6 +1917,7 @@ status = "draft — needs native review"
"component.combineVariants" = "Gabungkan sebagai variant"
"component.create" = "Buat component"
"component.createMultiple" = "Buat beberapa component"
+"component.createSet" = "Buat set component"
"component.deleteProperty" = "Hapus properti component"
"component.detach" = "Lepaskan instance"
"component.editProperty" = "Edit properti component"
diff --git a/crates/ui-egui/locales/it.toml b/crates/ui-egui/locales/it.toml
index 364aeedf8..f6b6dfe0d 100644
--- a/crates/ui-egui/locales/it.toml
+++ b/crates/ui-egui/locales/it.toml
@@ -1861,6 +1861,7 @@ status = "draft — needs native review"
"component.combineVariants" = "Combina come varianti"
"component.create" = "Crea componente"
"component.createMultiple" = "Crea più componenti"
+"component.createSet" = "Crea set di componenti"
"component.deleteProperty" = "Elimina proprietà del componente"
"component.detach" = "Scollega istanza"
"component.editProperty" = "Modifica proprietà del componente"
diff --git a/crates/ui-egui/locales/ja.toml b/crates/ui-egui/locales/ja.toml
index 5a8918a20..c53b03e25 100644
--- a/crates/ui-egui/locales/ja.toml
+++ b/crates/ui-egui/locales/ja.toml
@@ -1938,6 +1938,7 @@ status = "draft — needs native review"
"component.combineVariants" = "バリアントとして結合"
"component.create" = "コンポーネントを作成"
"component.createMultiple" = "複数のコンポーネントを作成"
+"component.createSet" = "コンポーネントセットを作成"
"component.deleteProperty" = "コンポーネントプロパティを削除"
"component.detach" = "インスタンスを切り離す"
"component.editProperty" = "コンポーネントプロパティを編集"
diff --git a/crates/ui-egui/locales/ka.toml b/crates/ui-egui/locales/ka.toml
index 2af58f385..fa681b9fd 100644
--- a/crates/ui-egui/locales/ka.toml
+++ b/crates/ui-egui/locales/ka.toml
@@ -2013,6 +2013,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv
"component.combineVariants" = "ვარიანტებად გაერთიანება"
"component.create" = "კომპონენტის შექმნა"
"component.createMultiple" = "რამდენიმე კომპონენტის შექმნა"
+"component.createSet" = "კომპონენტების ნაკრების შექმნა"
"component.deleteProperty" = "კომპონენტის თვისების წაშლა"
"component.detach" = "ინსტანსის გამოყოფა"
"component.editProperty" = "კომპონენტის თვისების რედაქტირება"
diff --git a/crates/ui-egui/locales/ko.toml b/crates/ui-egui/locales/ko.toml
index ebea678d1..6b5a39b60 100644
--- a/crates/ui-egui/locales/ko.toml
+++ b/crates/ui-egui/locales/ko.toml
@@ -1978,6 +1978,7 @@ status = "draft — needs native review"
"component.combineVariants" = "배리언트로 결합"
"component.create" = "컴포넌트 만들기"
"component.createMultiple" = "여러 컴포넌트 만들기"
+"component.createSet" = "컴포넌트 세트 만들기"
"component.deleteProperty" = "컴포넌트 속성 삭제"
"component.detach" = "인스턴스 분리"
"component.editProperty" = "컴포넌트 속성 편집"
diff --git a/crates/ui-egui/locales/nl.toml b/crates/ui-egui/locales/nl.toml
index e43d40b5e..a1d39c7fd 100644
--- a/crates/ui-egui/locales/nl.toml
+++ b/crates/ui-egui/locales/nl.toml
@@ -1956,6 +1956,7 @@ status = "draft — needs native review"
"component.combineVariants" = "Combineren als varianten"
"component.create" = "Component maken"
"component.createMultiple" = "Meerdere componenten maken"
+"component.createSet" = "Componentenset maken"
"component.deleteProperty" = "Componenteigenschap verwijderen"
"component.detach" = "Instance loskoppelen"
"component.editProperty" = "Componenteigenschap bewerken"
diff --git a/crates/ui-egui/locales/pl.toml b/crates/ui-egui/locales/pl.toml
index 68fe136d7..c479cd1be 100644
--- a/crates/ui-egui/locales/pl.toml
+++ b/crates/ui-egui/locales/pl.toml
@@ -1969,6 +1969,7 @@ name = "Polski"
"component.combineVariants" = "Połącz jako warianty"
"component.create" = "Utwórz komponent"
"component.createMultiple" = "Utwórz wiele komponentów"
+"component.createSet" = "Utwórz zestaw komponentów"
"component.deleteProperty" = "Usuń właściwość komponentu"
"component.detach" = "Odłącz instancję"
"component.editProperty" = "Edytuj właściwość komponentu"
diff --git a/crates/ui-egui/locales/pt-BR.toml b/crates/ui-egui/locales/pt-BR.toml
index c2ccba835..efcde47fe 100644
--- a/crates/ui-egui/locales/pt-BR.toml
+++ b/crates/ui-egui/locales/pt-BR.toml
@@ -1975,6 +1975,7 @@ status = "draft — needs native review"
"component.combineVariants" = "Combinar como variantes"
"component.create" = "Criar componente"
"component.createMultiple" = "Criar vários componentes"
+"component.createSet" = "Criar conjunto de componentes"
"component.deleteProperty" = "Excluir propriedade de componente"
"component.detach" = "Desvincular instância"
"component.editProperty" = "Editar propriedade de componente"
diff --git a/crates/ui-egui/locales/ru.toml b/crates/ui-egui/locales/ru.toml
index 01e1d86ac..97980eaed 100644
--- a/crates/ui-egui/locales/ru.toml
+++ b/crates/ui-egui/locales/ru.toml
@@ -1969,6 +1969,7 @@ name = "Русский"
"component.combineVariants" = "Объединить в варианты"
"component.create" = "Создать компонент"
"component.createMultiple" = "Создать несколько компонентов"
+"component.createSet" = "Создать набор компонентов"
"component.deleteProperty" = "Удалить свойство компонента"
"component.detach" = "Отсоединить экземпляр"
"component.editProperty" = "Изменить свойство компонента"
diff --git a/crates/ui-egui/locales/sv.toml b/crates/ui-egui/locales/sv.toml
index a94feb2a9..0a97701f8 100644
--- a/crates/ui-egui/locales/sv.toml
+++ b/crates/ui-egui/locales/sv.toml
@@ -2076,6 +2076,7 @@ name = "Svenska"
"component.combineVariants" = "Kombinera som varianter"
"component.create" = "Skapa komponent"
"component.createMultiple" = "Skapa flera komponenter"
+"component.createSet" = "Skapa komponentuppsättning"
"component.deleteProperty" = "Radera komponentegenskap"
"component.detach" = "Koppla bort instans"
"component.editProperty" = "Redigera komponentegenskap"
diff --git a/crates/ui-egui/locales/th.toml b/crates/ui-egui/locales/th.toml
index 81b581ce0..d5e98915f 100644
--- a/crates/ui-egui/locales/th.toml
+++ b/crates/ui-egui/locales/th.toml
@@ -1934,6 +1934,7 @@ status = "draft — needs native review"
"component.combineVariants" = "รวมเป็นแวเรียนต์"
"component.create" = "สร้างคอมโพเนนต์"
"component.createMultiple" = "สร้างหลายคอมโพเนนต์"
+"component.createSet" = "สร้างชุดคอมโพเนนต์"
"component.deleteProperty" = "ลบพร็อพเพอร์ตีของคอมโพเนนต์"
"component.detach" = "ถอดอินสแตนซ์"
"component.editProperty" = "แก้ไขพร็อพเพอร์ตีของคอมโพเนนต์"
diff --git a/crates/ui-egui/locales/tr.toml b/crates/ui-egui/locales/tr.toml
index b07d25462..25bf777ce 100644
--- a/crates/ui-egui/locales/tr.toml
+++ b/crates/ui-egui/locales/tr.toml
@@ -1917,6 +1917,7 @@ name = "Türkçe"
"component.combineVariants" = "Varyant olarak birleştir"
"component.create" = "Bileşen oluştur"
"component.createMultiple" = "Birden çok bileşen oluştur"
+"component.createSet" = "Bileşen kümesi oluştur"
"component.deleteProperty" = "Bileşen özelliğini sil"
"component.detach" = "Örneği ayır"
"component.editProperty" = "Bileşen özelliğini düzenle"
diff --git a/crates/ui-egui/locales/vi.toml b/crates/ui-egui/locales/vi.toml
index 9a4d600fe..98420ba48 100644
--- a/crates/ui-egui/locales/vi.toml
+++ b/crates/ui-egui/locales/vi.toml
@@ -1862,6 +1862,7 @@ status = "draft — needs native review"
"component.combineVariants" = "Kết hợp thành các biến thể"
"component.create" = "Tạo thành phần"
"component.createMultiple" = "Tạo nhiều thành phần"
+"component.createSet" = "Tạo bộ thành phần"
"component.deleteProperty" = "Xóa thuộc tính thành phần"
"component.detach" = "Tách bản thể"
"component.editProperty" = "Chỉnh sửa thuộc tính thành phần"
diff --git a/crates/ui-egui/locales/zh-Hans.toml b/crates/ui-egui/locales/zh-Hans.toml
index b48345acc..7d6bfb0b7 100644
--- a/crates/ui-egui/locales/zh-Hans.toml
+++ b/crates/ui-egui/locales/zh-Hans.toml
@@ -1871,6 +1871,7 @@ status = "draft — needs native review"
"component.combineVariants" = "合并为变体"
"component.create" = "创建组件"
"component.createMultiple" = "创建多个组件"
+"component.createSet" = "创建组件集"
"component.deleteProperty" = "删除组件属性"
"component.detach" = "分离实例"
"component.editProperty" = "编辑组件属性"
diff --git a/crates/ui-egui/locales/zh-Hant.toml b/crates/ui-egui/locales/zh-Hant.toml
index f14698b00..59b9064de 100644
--- a/crates/ui-egui/locales/zh-Hant.toml
+++ b/crates/ui-egui/locales/zh-Hant.toml
@@ -2089,6 +2089,7 @@ status = "draft — needs native review"
"component.combineVariants" = "合併為變體"
"component.create" = "建立元件"
"component.createMultiple" = "建立多個元件"
+"component.createSet" = "建立元件集"
"component.deleteProperty" = "刪除元件屬性"
"component.detach" = "分離實例"
"component.editProperty" = "編輯元件屬性"
From a285fd293b8a31b6583228f5a4654841d8158723 Mon Sep 17 00:00:00 2001
From: Trevor Brown
Date: Sun, 8 Nov 2026 00:11:49 -0500
Subject: [PATCH 07/51] Selection: Escape clears the selection instead of
stepping out to the parent, as in Figma
Measured in Figma: with a layer inside a group inside a frame selected (from the canvas, from the Layers panel, or after Tab), one Escape selects nothing; FigCraft selected the parent first. Escape still leaves text and vector editing, ends gestures and the pen first. Three tests updated to the measured rule (selection_behaviour, devfocus, proto_editor); the rest of the suites pass.
Co-Authored-By: Claude Sonnet 5.5
Model-Effort: Opus 5.5 drove; Opus 5.5 briefed; Sonnet 5.5 wrote at low
---
crates/engine/src/canvas/tools/keys.rs | 20 ++++---------------
crates/engine/src/devfocus_tests.rs | 6 +++---
.../engine/src/selection_behaviour_tests.rs | 8 +++-----
crates/ui-egui/src/proto_editor_tests.rs | 12 ++++-------
docs/ui-parity.md | 2 ++
5 files changed, 16 insertions(+), 32 deletions(-)
diff --git a/crates/engine/src/canvas/tools/keys.rs b/crates/engine/src/canvas/tools/keys.rs
index 2b56aecd3..870565207 100644
--- a/crates/engine/src/canvas/tools/keys.rs
+++ b/crates/engine/src/canvas/tools/keys.rs
@@ -1,4 +1,4 @@
-use super::{Document, Gesture, Node, NodeId, Overlay, Point, PointerEvent, PointerKind, Result, Session, ShapeHandle, ToolId, json, textedit};
+use super::{Document, Gesture, NodeId, Overlay, Point, PointerEvent, PointerKind, Result, Session, ShapeHandle, ToolId, json, textedit};
impl Session {
/// Keys the current mode handles before menu shortcuts (vector edit mode's tools and point
@@ -146,21 +146,9 @@ impl Session {
self.ds_mut()?.focus = None;
return Ok(());
}
- // Escape selects the parent, then nothing.
- let doc = self.doc()?;
- let parents: Vec =
- sel.iter().filter_map(|i| doc.node(*i).and_then(|n| n.parent)).filter(|p| !doc.node(*p).is_some_and(Node::is_page)).collect();
- if !parents.is_empty() {
- let mut uniq = Vec::new();
- for p in parents {
- if !uniq.contains(&p) {
- uniq.push(p);
- }
- }
- self.set_selection(uniq);
- } else {
- self.set_selection(vec![]);
- }
+ // Escape clears the selection whatever its nesting (measured in Figma: it never selects the
+ // parent).
+ self.set_selection(vec![]);
Ok(())
}
diff --git a/crates/engine/src/devfocus_tests.rs b/crates/engine/src/devfocus_tests.rs
index 0801746de..f36af7ee6 100644
--- a/crates/engine/src/devfocus_tests.rs
+++ b/crates/engine/src/devfocus_tests.rs
@@ -84,12 +84,12 @@ fn only_the_focused_frame_is_hit_and_escape_leaves() {
click(&mut s, 75.0, 75.0);
assert_eq!(s.selection(), vec![in_a], "the layer on top of it isn't hit either");
assert_ne!(s.selection(), vec![over]);
- // Escape: selection up to the frame, then out of the focus view.
+ // Escape: the selection goes, then out of the focus view.
s.escape().unwrap();
- assert_eq!(s.selection(), vec![a]);
+ assert!(s.selection().is_empty(), "Escape selects nothing, not the parent frame");
assert_eq!(focused(&s), Some(a));
s.escape().unwrap();
- assert_eq!(focused(&s), None, "Escape with the frame selected leaves");
+ assert_eq!(focused(&s), None, "Escape with nothing selected leaves");
click(&mut s, 350.0, 100.0);
assert_eq!(s.selection(), vec![b], "everything is hit again");
}
diff --git a/crates/engine/src/selection_behaviour_tests.rs b/crates/engine/src/selection_behaviour_tests.rs
index c58527286..b3289c6c3 100644
--- a/crates/engine/src/selection_behaviour_tests.rs
+++ b/crates/engine/src/selection_behaviour_tests.rs
@@ -271,14 +271,12 @@ fn keyboard_selection() {
ex(&mut s, cmd, json!({}));
assert_eq!(sorted(s.selection()), sorted(after), "{why}");
}
- // Esc steps out to the parent, then deselects.
+ // Esc clears the selection at once, whatever the nesting (measured in Figma).
ex(&mut s, "select.set", json!({"ids": [k.a]}));
s.escape().unwrap();
- assert_eq!(s.selection(), vec![k.g], "Esc selects the parent");
+ assert_eq!(s.selection(), Vec::::new(), "Esc from a nested layer selects nothing, not the parent");
s.escape().unwrap();
- assert_eq!(s.selection(), vec![k.f]);
- s.escape().unwrap();
- assert_eq!(s.selection(), Vec::::new(), "Esc on a top-level layer deselects");
+ assert_eq!(s.selection(), Vec::::new());
}
#[test]
diff --git a/crates/ui-egui/src/proto_editor_tests.rs b/crates/ui-egui/src/proto_editor_tests.rs
index b16894425..45fbe4d65 100644
--- a/crates/ui-egui/src/proto_editor_tests.rs
+++ b/crates/ui-egui/src/proto_editor_tests.rs
@@ -328,8 +328,8 @@ fn edit_delete_from_the_menu_removes_the_open_interaction_not_the_layer() {
}
#[test]
-fn escape_closes_prototype_editor_without_deselecting_layer() {
- let (s, a, _b, btn) = setup();
+fn escape_closes_prototype_editor_and_the_next_deselects() {
+ let (s, _a, _b, btn) = setup();
let mut u = Ui::new(s);
u.app.ui.right_tab = crate::RightTab::Prototype;
u.run("select.set", json!({"ids": [btn]}));
@@ -344,13 +344,9 @@ fn escape_closes_prototype_editor_without_deselecting_layer() {
assert_eq!(crate::proto_panel::editor(&u.ctx), None);
assert_eq!(u.app.session.selection(), vec![btn], "layer remains selected");
- // Second Escape steps out to parent frame.
+ // Second Escape clears the selection, not stepping out to the parent frame.
u.key(pos2(100.0, 100.0), egui::Key::Escape);
- assert_eq!(u.app.session.selection(), vec![a], "steps out to parent frame");
-
- // Third Escape deselects all.
- u.key(pos2(100.0, 100.0), egui::Key::Escape);
- assert!(u.app.session.selection().is_empty(), "selection cleared on third escape");
+ assert!(u.app.session.selection().is_empty(), "the second Escape deselects");
}
/// After a click in the Pages list, Delete that removes an interaction ends the list's hold on the
diff --git a/docs/ui-parity.md b/docs/ui-parity.md
index aa0ef25bd..e48e8b0ef 100644
--- a/docs/ui-parity.md
+++ b/docs/ui-parity.md
@@ -59,6 +59,7 @@ interaction feel not yet compared.
| Click, Shift-click, marquee, Shift-marquee toggle | done | Shift-drag on a selected layer moves the selection, kept to one axis (#498); a marquee selects what it touches; from a top-level frame's background, the layers inside it; locked and hidden layers are skipped (`selection_behaviour_tests`) |
| Deep select (Cmd/Ctrl-click, Cmd/Ctrl-marquee) | done | |
| Select layer menu (every layer under the click) | done | |
+| Escape with layers selected | done | clears the selection at once, whatever the nesting; it never selects the parent. Measured in the Figma web app (2026-11-08) with a rectangle in a group in a frame, selected from the canvas (a double-click into the group), from the Layers panel, and after Tab moved the selection to its sibling: one Escape selects nothing, a second leaves nothing selected. Escape still leaves text and vector editing, ends gestures and the pen first, and ends the focus view once nothing or the focused frame is selected (`selection_behaviour_tests`, `devfocus_tests`) |
| Enter / Shift+Enter (children / parent), Tab / Shift+Tab (siblings) | done | Tab renames the next layer while renaming |
| Resize handles: Shift keeps proportions, Alt from centre, constraints-aware | done | Alt shrinks too; past an edge flips (several layers mirror); groups and mixed rotations scale each layer along its own axes, keeping rotation and flip (no skew: children turned off a right angle are approximate); Cmd/Ctrl ignores a frame's constraints (`transform_behaviour_tests`) |
| Rotate just outside a corner; Shift in 15° steps | done | rotated handle box when layers share a rotation |
@@ -186,6 +187,7 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look
## Revision history
| Date | Change | Summary |
+| 2026-11-08 | patch | Selection: Escape with layers selected clears the selection instead of stepping out to the parent first. Measured in the Figma web app with a rectangle in a group in a frame, selected from the canvas, from the Layers panel and after Tab: one Escape selects nothing. Editing, gestures and the pen still end first |
|---|---|---|
| 2026-11-02 | patch | Shortcuts: a Cmd/Ctrl-click on a padding field (the horizontal or vertical pair, or a side with Individual padding open) makes the value typed go to all four sides, in one undo step (Guide to auto layout, Keyboard shortcut guide); a plain click still edits that axis or side alone. A documented gesture row: 134 documented, 133 bound. Test: `padding_individual_tests` |
| 2026-11-01 | patch | Number fields: dragging the field itself with Alt held scrubs it ("You can also hover the input field itself and hold down the modifier key: Mac: ⌥ Option Windows: Alt", Adjust alignment, rotation, position, and dimensions), with the resize cursor and the label's rates (Alt is the enabler there, so it isn't the fine rate); a click with Alt doesn't open the editor. Found by a documentation audit of the ui-parity rows. Test: `field_tests::alt_held_over_a_field_scrubs_it`. Counts unchanged |
From 476daeab8ea7ecc2174336d932f53deec30226bc Mon Sep 17 00:00:00 2001
From: Trevor Brown
Date: Sun, 8 Nov 2026 00:23:51 -0500
Subject: [PATCH 08/51] Tests: paste lands in place, at the view's centre, or
in a selected frame's centre, as measured in Figma
Co-Authored-By: Claude Sonnet 5.5
Model-Effort: Opus 5.5 drove; Opus 5.5 briefed; Sonnet 5.5 wrote at low
---
crates/engine/src/design_paste_tests.rs | 63 +++++++++++++++++++++++++
1 file changed, 63 insertions(+)
diff --git a/crates/engine/src/design_paste_tests.rs b/crates/engine/src/design_paste_tests.rs
index 52dc42305..497ce2f33 100644
--- a/crates/engine/src/design_paste_tests.rs
+++ b/crates/engine/src/design_paste_tests.rs
@@ -106,3 +106,66 @@ fn other_content_is_refused_without_changes() {
assert_eq!(s.doc().unwrap().nodes().count(), before);
assert!(s.clipboard.nodes.is_empty());
}
+
+// The next four pin the paste-placement rules measured in Figma's web app on 2026-11-08 (a
+// synthetic file): in place when the original is in view, at the view's centre when it is off
+// screen, in a selected frame's centre when the frame is elsewhere, and in place inside a selected
+// frame that contains the original.
+
+fn copied_rect(s: &mut Session) -> NodeId {
+ let r = id(&s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 50, "height": 50})).unwrap());
+ s.execute("edit.copy", &json!({"ids": [r]})).unwrap();
+ r
+}
+
+fn pasted(s: &mut Session) -> NodeId {
+ let r = s.execute("edit.paste", &json!({})).unwrap();
+ params::id_of(&r["ids"][0]).unwrap()
+}
+
+#[test]
+fn paste_with_the_original_in_view_lands_in_place_above_it() {
+ let mut s = Session::new();
+ let page = s.page().unwrap();
+ let r = copied_rect(&mut s);
+ s.execute("view.set", &json!({"zoom": 1, "cx": 125, "cy": 125})).unwrap();
+ let c = pasted(&mut s);
+ let b = s.doc().unwrap().world_bounds(c).unwrap();
+ assert_eq!((b.x0, b.y0), (100.0, 100.0));
+ assert_eq!(s.selection(), [c]);
+ assert_eq!(s.doc().unwrap().node(page).unwrap().children, [r, c]);
+}
+
+#[test]
+fn paste_with_the_original_off_screen_lands_at_the_view_centre() {
+ let mut s = Session::new();
+ copied_rect(&mut s);
+ s.execute("view.set", &json!({"zoom": 1, "cx": 5000, "cy": 3000})).unwrap();
+ let c = pasted(&mut s);
+ let b = s.doc().unwrap().world_bounds(c).unwrap();
+ assert_eq!((b.center().x, b.center().y), (5000.0, 3000.0));
+}
+
+#[test]
+fn paste_into_a_selected_far_frame_lands_at_its_centre() {
+ let mut s = Session::new();
+ copied_rect(&mut s);
+ let f = id(&s.execute("frame.create", &json!({"x": 2000, "y": 1000, "width": 300, "height": 300})).unwrap());
+ s.execute("select.set", &json!({"ids": [f]})).unwrap();
+ let c = pasted(&mut s);
+ assert_eq!(s.doc().unwrap().node(c).unwrap().parent, Some(f));
+ let b = s.doc().unwrap().world_bounds(c).unwrap();
+ assert_eq!((b.center().x, b.center().y), (2150.0, 1150.0));
+}
+
+#[test]
+fn paste_into_a_selected_frame_holding_the_original_keeps_its_place() {
+ let mut s = Session::new();
+ copied_rect(&mut s);
+ let f = id(&s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 500, "height": 500})).unwrap());
+ s.execute("select.set", &json!({"ids": [f]})).unwrap();
+ let c = pasted(&mut s);
+ assert_eq!(s.doc().unwrap().node(c).unwrap().parent, Some(f));
+ let b = s.doc().unwrap().world_bounds(c).unwrap();
+ assert_eq!((b.x0, b.y0), (100.0, 100.0));
+}
From bceb5476e0d9d170155dfff32107e6ec366a698e Mon Sep 17 00:00:00 2001
From: Trevor Brown
Date: Sun, 8 Nov 2026 00:31:58 -0500
Subject: [PATCH 09/51] Docs: the new history row below the table's separator
(#2107 review)
Co-Authored-By: Claude Opus 5.5
Model-Effort: Opus 5.5 drove and wrote
---
docs/ui-parity.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/docs/ui-parity.md b/docs/ui-parity.md
index e48e8b0ef..70d495bd0 100644
--- a/docs/ui-parity.md
+++ b/docs/ui-parity.md
@@ -187,8 +187,8 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look
## Revision history
| Date | Change | Summary |
-| 2026-11-08 | patch | Selection: Escape with layers selected clears the selection instead of stepping out to the parent first. Measured in the Figma web app with a rectangle in a group in a frame, selected from the canvas, from the Layers panel and after Tab: one Escape selects nothing. Editing, gestures and the pen still end first |
|---|---|---|
+| 2026-11-08 | patch | Selection: Escape with layers selected clears the selection instead of stepping out to the parent first. Measured in the Figma web app with a rectangle in a group in a frame, selected from the canvas, from the Layers panel and after Tab: one Escape selects nothing. Editing, gestures and the pen still end first |
| 2026-11-02 | patch | Shortcuts: a Cmd/Ctrl-click on a padding field (the horizontal or vertical pair, or a side with Individual padding open) makes the value typed go to all four sides, in one undo step (Guide to auto layout, Keyboard shortcut guide); a plain click still edits that axis or side alone. A documented gesture row: 134 documented, 133 bound. Test: `padding_individual_tests` |
| 2026-11-01 | patch | Number fields: dragging the field itself with Alt held scrubs it ("You can also hover the input field itself and hold down the modifier key: Mac: ⌥ Option Windows: Alt", Adjust alignment, rotation, position, and dimensions), with the resize cursor and the label's rates (Alt is the enabler there, so it isn't the fine rate); a click with Alt doesn't open the editor. Found by a documentation audit of the ui-parity rows. Test: `field_tests::alt_held_over_a_field_scrubs_it`. Counts unchanged |
| 2026-11-02 | patch | M6, #1839: with a shape or frame tool chosen and nothing selected, the arrows show a placement crosshair in the middle of the view and move it by the nudge amounts (`tool.crosshair`), Enter places the object there (`tool.place`), Escape or a tool change takes it away; the canvas draws the crosshair and `ui.inspect` reports it (`tool.crosshair`), the Keyboard shortcuts panel lists the gesture ("Use the arrow keys to activate the crosshair cursor...", Accessibility at Figma). The board Shapes tool places on it too (its 160 px shape centred, as the tool centres a press), and the view pans by the overshoot when the crosshair is nudged past its edge. Tests: `place_crosshair_tests`, `shortcut_keys_tests::arrows_show_and_move_the_placement_crosshair_with_a_shape_tool`. |
From db1bec08c87626d1c0f7fd161119693330984040 Mon Sep 17 00:00:00 2001
From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com>
Date: Sun, 8 Nov 2026 06:31:13 +0000
Subject: [PATCH 10/51] Reject invalid layer ids in file.export and
file.exportLayers
Found while testing crates/engine/src/cmd/file/export.rs:89 (export; same pattern at :332 in export_layers)
Co-Authored-By: Claude Haiku 5.5
Co-Authored-By: Claude Sonnet 5.5
---
crates/engine/src/cmd/file/export.rs | 17 ++++++++++++++---
crates/engine/src/export_behaviour_tests.rs | 14 ++++++++++++++
2 files changed, 28 insertions(+), 3 deletions(-)
diff --git a/crates/engine/src/cmd/file/export.rs b/crates/engine/src/cmd/file/export.rs
index 542ee246c..de426ba89 100644
--- a/crates/engine/src/cmd/file/export.rs
+++ b/crates/engine/src/cmd/file/export.rs
@@ -1,7 +1,7 @@
use super::load::{ext, write};
use super::save::thumbnail;
use super::*;
-use crate::params::f64_of;
+use crate::params::{f64_of, req_targets};
/// Encode `ids` (or the page) in `format` at `scale`.
pub fn export_bytes(doc: &Document, ids: &[NodeId], format: ExportFormat, scale: f64) -> Result> {
@@ -86,7 +86,7 @@ pub(super) fn export(s: &mut Session, p: &Value) -> Result {
_ => {}
}
let doc = s.doc()?;
- let mut ids = targets(s, p);
+ let mut ids = export_targets(cmd, s, p)?;
if ids.is_empty() {
ids = doc.get(s.page()?)?.children.clone();
}
@@ -325,11 +325,22 @@ pub fn exportable_layers(doc: &Document, page: NodeId) -> Vec {
doc.subtree(page).into_iter().skip(1).filter(|i| doc.node(*i).is_some_and(|n| !n.exports.is_empty())).collect()
}
+/// Named layers (`ids` / `id`), checked like other commands; empty when none are named, so the
+/// caller can fall back to its default instead of silently dropping a bad id.
+fn export_targets(cmd: &str, s: &Session, p: &Value) -> Result> {
+ let named = match get(p, "ids") {
+ Some(Value::Array(a)) if !a.is_empty() => true,
+ Some(Value::Array(_) | Value::Null) | None => get(p, "id").is_some_and(|v| !v.is_null()),
+ Some(_) => true,
+ };
+ if named { req_targets(cmd, s, p) } else { Ok(targets(s, p)) }
+}
+
pub(super) fn export_layers(s: &mut Session, p: &Value) -> Result {
let cmd = "file.exportLayers";
let doc = s.doc()?;
let assets = boolean(p, "assets").unwrap_or(false);
- let mut ids = targets(s, p);
+ let mut ids = export_targets(cmd, s, p)?;
if ids.is_empty() {
ids = if assets { vec![s.page()?] } else { exportable_layers(doc, s.page()?) };
}
diff --git a/crates/engine/src/export_behaviour_tests.rs b/crates/engine/src/export_behaviour_tests.rs
index 1cd836784..492fbf881 100644
--- a/crates/engine/src/export_behaviour_tests.rs
+++ b/crates/engine/src/export_behaviour_tests.rs
@@ -384,3 +384,17 @@ fn copy_as_png_svg_and_code() {
let css = crate::codegen::code_for(doc, a, crate::codegen::CodeLang::Css);
assert!(css.contains("background: #FF0000;"), "Copy as CSS is the layer's CSS from Inspect");
}
+
+#[test]
+fn export_rejects_an_invalid_layer_id_instead_of_exporting_the_page() {
+ let mut s = Session::new();
+ let r = rect(&mut s, 0.0, 0.0, 10.0, 10.0, "#ff0000", None);
+ ex(&mut s, "export.add", json!({"ids": [r]}));
+ for cmd in ["file.export", "file.exportLayers"] {
+ for p in [json!({"ids": ["abc"], "format": "svg"}), json!({"id": "abc", "format": "svg"}), json!({"ids": [], "id": "abc", "format": "svg"})] {
+ let res = s.execute(cmd, &p);
+ assert!(res.is_err(), "{cmd} {p} exported for an invalid id: {res:?}");
+ }
+ }
+ assert!(s.execute("file.export", &json!({"format": "svg"})).is_ok());
+}
From ebd463fa5f4172a549839604691ffd38eebff949 Mon Sep 17 00:00:00 2001
From: Trevor Brown
Date: Sun, 8 Nov 2026 01:31:31 -0500
Subject: [PATCH 11/51] Tests: the comment says which paste cases were measured
and which pins FigCraft's own branch (#2108 review)
Co-Authored-By: Claude Opus 5.5
Model-Effort: Opus 5.5 drove and wrote
---
crates/engine/src/design_paste_tests.rs | 7 ++++---
1 file changed, 4 insertions(+), 3 deletions(-)
diff --git a/crates/engine/src/design_paste_tests.rs b/crates/engine/src/design_paste_tests.rs
index 497ce2f33..6823645ed 100644
--- a/crates/engine/src/design_paste_tests.rs
+++ b/crates/engine/src/design_paste_tests.rs
@@ -107,10 +107,11 @@ fn other_content_is_refused_without_changes() {
assert!(s.clipboard.nodes.is_empty());
}
-// The next four pin the paste-placement rules measured in Figma's web app on 2026-11-08 (a
+// The next three pin the paste-placement rules measured in Figma's web app on 2026-11-08 (a
// synthetic file): in place when the original is in view, at the view's centre when it is off
-// screen, in a selected frame's centre when the frame is elsewhere, and in place inside a selected
-// frame that contains the original.
+// screen, and in a selected frame's centre when the frame is elsewhere. The fourth pins
+// FigCraft's own branch for a selected frame that contains the original (in place inside it),
+// which was not measured.
fn copied_rect(s: &mut Session) -> NodeId {
let r = id(&s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 50, "height": 50})).unwrap());
From dda27030c8170b431410972cb5c28638751a97b1 Mon Sep 17 00:00:00 2001
From: Trevor Brown
Date: Sun, 8 Nov 2026 01:47:50 -0500
Subject: [PATCH 12/51] Components: the component button shows its menu arrow,
Create component set is offered only where it works; tests for every refusal
(#2095 review)
Co-Authored-By: Claude Sonnet 5.5
Model-Effort: Opus 5.5 drove; Opus 5.5 briefed; Sonnet 5.5 wrote at medium
---
crates/engine/src/cmd/component.rs | 2 +-
crates/engine/src/cmd/component/variants.rs | 41 ++++++---
.../engine/src/components_behaviour_tests.rs | 90 +++++++++++++++++++
.../ui-egui/src/create_multiple_menu_tests.rs | 29 ++++++
crates/ui-egui/src/panels/right.rs | 37 +++++---
5 files changed, 175 insertions(+), 24 deletions(-)
diff --git a/crates/engine/src/cmd/component.rs b/crates/engine/src/cmd/component.rs
index 06e6bf166..f68fac714 100644
--- a/crates/engine/src/cmd/component.rs
+++ b/crates/engine/src/cmd/component.rs
@@ -25,7 +25,7 @@ pub(crate) use instance::{swap_at, swap_instance};
use props::{add_prop, bind_prop, delete_prop, edit_prop, set_props};
pub(crate) use props::{apply_prop_bindings, check_prop_binding, describe_props};
use variants::{add_variant, combine, create_set};
-pub use variants::{can_add_variant, can_combine_variants};
+pub use variants::{can_add_variant, can_combine_variants, can_create_set};
/// `component.hideWhenPublishing`: the leading `.` of the names of this file's components and
/// sets (those `ids` are or are in), put on or taken off.
diff --git a/crates/engine/src/cmd/component/variants.rs b/crates/engine/src/cmd/component/variants.rs
index cfba5accf..96de6ba3a 100644
--- a/crates/engine/src/cmd/component/variants.rs
+++ b/crates/engine/src/cmd/component/variants.rs
@@ -94,6 +94,32 @@ pub(super) fn combine(s: &mut Session, p: &Value) -> Result {
Ok(json!({"id": set}))
}
+/// Why `ids` (distinct) can't each become a component of a new set (`component.createSet`), or
+/// `None` when they can: layers inside an instance, library copies, instances, components and
+/// component sets, and a layer inside another one of `ids`.
+fn create_set_refusal(doc: &Document, ids: &[NodeId]) -> Option<&'static str> {
+ if ids.iter().any(|i| inside_instance(doc, *i)) {
+ Some("layers inside an instance can't become components")
+ } else if ids.iter().any(|i| doc.is_remote(*i)) {
+ Some(crate::library::READ_ONLY)
+ } else if ids.iter().any(|i| {
+ doc.node(*i).is_some_and(|n| matches!(n.kind, NodeKind::Instance { .. } | NodeKind::Component { .. } | NodeKind::ComponentSet { .. }))
+ }) {
+ Some("select layers that aren't components, component sets or instances")
+ } else if roots(doc, ids).len() != ids.len() {
+ Some("a layer can't be in a set with one inside it")
+ } else {
+ None
+ }
+}
+
+/// Whether "Create component set" is on offer for `ids`: at least one layer that
+/// [`create_set_refusal`] lets through.
+pub fn can_create_set(doc: &Document, ids: &[NodeId]) -> bool {
+ let distinct: Vec = ids.iter().copied().collect::>().into_iter().collect();
+ !distinct.is_empty() && distinct.iter().all(|i| doc.contains(*i)) && create_set_refusal(doc, &distinct).is_none()
+}
+
/// `component.createSet`: each target becomes a component (as `component.createMultiple` makes
/// them) and all are wrapped in place in a component set, 20 px around, named as Figma names them:
/// variants "Property 1=" in left-to-right order (a name holding `=` is kept); layers
@@ -104,19 +130,8 @@ pub(super) fn create_set(s: &mut Session, p: &Value) -> Result {
let mut seen = BTreeSet::new();
let ids: Vec = req_targets(cmd, s, p)?.into_iter().filter(|i| seen.insert(*i)).collect();
let doc = s.doc()?;
- if ids.iter().any(|i| inside_instance(doc, *i)) {
- return Err(EngineError::Disabled(cmd.into(), "layers inside an instance can't become components".into()));
- }
- if ids.iter().any(|i| doc.is_remote(*i)) {
- return Err(EngineError::Disabled(cmd.into(), crate::library::READ_ONLY.into()));
- }
- if ids.iter().any(|i| {
- doc.node(*i).is_some_and(|n| matches!(n.kind, NodeKind::Instance { .. } | NodeKind::Component { .. } | NodeKind::ComponentSet { .. }))
- }) {
- return Err(EngineError::Disabled(cmd.into(), "select layers that aren't components, component sets or instances".into()));
- }
- if roots(doc, &ids).len() != ids.len() {
- return Err(EngineError::Disabled(cmd.into(), "a layer can't be in a set with one inside it".into()));
+ if let Some(why) = create_set_refusal(doc, &ids) {
+ return Err(EngineError::Disabled(cmd.into(), why.into()));
}
let mut made = Vec::new();
for i in roots(doc, &ids) {
diff --git a/crates/engine/src/components_behaviour_tests.rs b/crates/engine/src/components_behaviour_tests.rs
index 510fb2e74..3565a3165 100644
--- a/crates/engine/src/components_behaviour_tests.rs
+++ b/crates/engine/src/components_behaviour_tests.rs
@@ -1153,3 +1153,93 @@ fn create_set_refuses_a_target_inside_another_target_and_leaves_the_document_alo
assert!(matches!(doc.node(frames[0]).unwrap().kind, NodeKind::Frame));
assert!(matches!(doc.node(frames[1]).unwrap().kind, NodeKind::Frame));
}
+
+/// Every node's id, parent, name and kind, to compare a document before and after a refused command.
+fn snapshot(s: &Session) -> Vec {
+ s.doc().unwrap().nodes().map(|n| format!("{:?}|{:?}|{}|{:?}", n.id, n.parent, n.name, n.kind)).collect()
+}
+
+/// A refused `component.createSet`: `Disabled`, no undo step, the document and the selection as they were.
+fn assert_create_set_refused(s: &mut Session, ids: &[NodeId]) {
+ let (before, undo, selection) = (snapshot(s), undo_len(s), s.selection());
+ let e = s.execute("component.createSet", &json!({"ids": ids})).unwrap_err();
+ assert!(matches!(e, crate::EngineError::Disabled(..)), "{e:?}");
+ assert_eq!(undo_len(s), undo, "no undo step");
+ assert_eq!(snapshot(s), before, "the document is unchanged");
+ assert_eq!(s.selection(), selection, "the selection is unchanged");
+}
+
+/// Nothing to wrap is refused before anything is created.
+#[test]
+fn create_set_refuses_no_layers_and_leaves_the_document_alone() {
+ let mut s = Session::new();
+ named_frames(&mut s, &["A"], false);
+ s.execute("select.set", &json!({"ids": []})).unwrap();
+ assert_create_set_refused(&mut s, &[]);
+}
+
+/// One layer is allowed: a set of one variant, as many layers would make.
+#[test]
+fn create_set_with_one_layer_makes_a_one_variant_set() {
+ let mut s = Session::new();
+ let frames = named_frames(&mut s, &["Solo"], false);
+ let before = undo_len(&s);
+ let out = s.execute("component.createSet", &json!({"ids": frames})).unwrap();
+ assert_eq!(undo_len(&s), before + 1, "one undo step");
+ let set = id_of(&out["id"]).unwrap();
+ let doc = s.doc().unwrap();
+ assert_eq!(doc.node(set).unwrap().children.len(), 1);
+ assert_eq!(names_by_x(&s, set), ["Property 1=Default"], "a lone layer shares its name with itself");
+ assert!(matches!(doc.node(doc.node(set).unwrap().children[0]).unwrap().kind, NodeKind::Component { .. }));
+ s.undo().unwrap();
+ assert!(matches!(s.doc().unwrap().node(frames[0]).unwrap().kind, NodeKind::Frame));
+}
+
+/// Each refusal branch of `create_set`, with `can_create_set` agreeing with it.
+#[test]
+fn create_set_refuses_every_ineligible_selection_and_leaves_the_document_alone() {
+ use crate::cmd::component::can_create_set;
+ let mut s = Session::new();
+ let frames = named_frames(&mut s, &["A", "B", "C"], false);
+ let comp = id_of(&s.execute("component.create", &json!({"ids": [frames[0]]})).unwrap()["id"]).unwrap();
+ let inst = id_of(&s.execute("instance.create", &json!({"component": comp})).unwrap()["id"]).unwrap();
+ let set_frames = named_frames(&mut s, &["V1", "V2"], false);
+ let set = id_of(&s.execute("component.createSet", &json!({"ids": set_frames})).unwrap()["id"]).unwrap();
+ // A layer inside the instance (its copy of the card's content).
+ let in_inst = s.doc().unwrap().node(inst).unwrap().children.first().copied();
+ // A library copy.
+ let remote = {
+ let doc = s.doc_mut().unwrap();
+ let store = doc.ensure_library_store();
+ let remote = doc.alloc_id();
+ doc.insert(Node::new(remote, NodeKind::Frame, "Remote", 10.0, 10.0), store, None).unwrap();
+ remote
+ };
+ let ok = frames[1];
+ assert!(can_create_set(s.doc().unwrap(), &[ok, frames[2]]));
+ assert!(!can_create_set(s.doc().unwrap(), &[]));
+ let mut cases = vec![("instance", inst), ("library copy", remote), ("component", comp), ("component set", set)];
+ if let Some(i) = in_inst {
+ cases.push(("inside an instance", i));
+ }
+ for (what, bad) in cases {
+ assert!(!can_create_set(s.doc().unwrap(), &[ok, bad]), "{what}: not offered");
+ assert_create_set_refused(&mut s, &[ok, bad]);
+ assert_create_set_refused(&mut s, &[bad]);
+ }
+}
+
+/// A command that fails leaves no trace: the checks before the creation loop catch every
+/// refusal the loop's own commands could hit, and `Session::execute` restores the document, the
+/// selection and the undo stack on any error, so no half-made components can survive.
+#[test]
+fn a_failing_create_set_leaves_no_components_behind() {
+ let mut s = Session::new();
+ let frames = named_frames(&mut s, &["A", "B"], false);
+ let (before, undo, selection) = (snapshot(&s), undo_len(&s), s.selection());
+ let e = s.execute("component.createSet", &json!({"ids": [frames[0], NodeId(u64::MAX)]}));
+ assert!(e.is_err());
+ assert_eq!(undo_len(&s), undo);
+ assert_eq!(snapshot(&s), before);
+ assert_eq!(s.selection(), selection);
+}
diff --git a/crates/ui-egui/src/create_multiple_menu_tests.rs b/crates/ui-egui/src/create_multiple_menu_tests.rs
index 409b67e54..60d22a993 100644
--- a/crates/ui-egui/src/create_multiple_menu_tests.rs
+++ b/crates/ui-egui/src/create_multiple_menu_tests.rs
@@ -134,3 +134,32 @@ fn the_third_entry_creates_one_component_set() {
let doc = u.app.session.doc().unwrap();
assert!(ids.iter().all(|i| matches!(doc.node(*i).map(|n| &n.kind), Some(NodeKind::Frame))), "one undo reverts it all");
}
+
+/// The component button's width in the Design panel: wider with a dropdown chevron beside the icon.
+fn button_width(u: &mut Ui) -> f32 {
+ let all = nodes(u);
+ let button = all.iter().find(|n| n.label().is_some_and(|l| l.starts_with("Create component"))).expect("the Design panel's component button");
+ bounds(button).width()
+}
+
+#[test]
+fn the_dropdown_chevron_is_there_only_for_several_layers() {
+ let (mut one, _) = frames(1);
+ let (mut two, _) = frames(2);
+ let (single, several) = (button_width(&mut one), button_width(&mut two));
+ assert_eq!(single, 24.0, "one layer: the plain icon button");
+ assert_eq!(several, 34.0, "several layers: room for the chevron");
+}
+
+#[test]
+fn create_component_set_is_offered_only_where_it_works() {
+ let (mut u, ids) = frames(3);
+ // Make the first a component: the set can't take it, so the entry is left out.
+ u.app.session.execute("component.create", &json!({"ids": [ids[0]]})).unwrap();
+ u.app.session.execute("select.set", &json!({"ids": ids})).unwrap();
+ u.frame(vec![]);
+ click_button(&mut u);
+ let all = nodes(&mut u);
+ assert!(all.iter().any(|n| n.label() == Some("Create multiple components")), "the menu is open");
+ assert!(all.iter().all(|n| n.label() != Some("Create component set")), "left out for a selection with a component");
+}
diff --git a/crates/ui-egui/src/panels/right.rs b/crates/ui-egui/src/panels/right.rs
index 55b36ebf5..4d7ce1b19 100644
--- a/crates/ui-egui/src/panels/right.rs
+++ b/crates/ui-egui/src/panels/right.rs
@@ -494,7 +494,10 @@ fn header(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) {
// Layers in boolean groups, or one boolean group: the operation they use (checked in the menu).
let current = figcraft_engine::booleans::shown_op(doc, sel);
for (icon, label, cmd) in actions.into_iter().rev() {
- let br = Rect::from_min_size(pos2(x - 24.0, r.center().y - 12.0), vec2(24.0, 24.0));
+ // With several layers the component button is a dropdown: a small chevron sits beside its icon.
+ let dropdown = cmd == "component.create" && sel.len() > 1;
+ let bw = if dropdown { 34.0 } else { 24.0 };
+ let br = Rect::from_min_size(pos2(x - bw, r.center().y - 12.0), vec2(bw, 24.0));
let tip = if cmd == "bool.union" { label } else { crate::shortcuts::tip(label, cmd) };
let bresp = ui.interact(br, ui.scope_id().with(("qa", cmd)), Sense::click()).tip(tip);
if bresp.hovered() {
@@ -505,7 +508,11 @@ fn header(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) {
("bool.union", Some(op)) => crate::layers::op_icon(op),
_ => icon,
};
- icons::paint(ui.painter(), br.shrink(5.0), icon, t.icon);
+ let icon_rect = Rect::from_min_size(br.min, vec2(24.0, 24.0)).shrink(5.0);
+ icons::paint(ui.painter(), icon_rect, icon, t.icon);
+ if dropdown {
+ icons::paint(ui.painter(), Rect::from_center_size(pos2(br.max.x - 6.0, br.center().y), vec2(8.0, 8.0)), Icon::Chevron, t.icon_secondary);
+ }
if cmd == "bool.union" {
menu_popup(&bresp).width(180.0).show(|ui| {
for (op, c) in [
@@ -532,14 +539,24 @@ fn header(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) {
});
} else if cmd == "component.create" && sel.len() > 1 {
// Several layers: the button opens a menu, as Figma's does (#2088). One layer keeps the plain click.
- menu_popup(&bresp).width(220.0).show(|ui| {
- let multiple = crate::i18n::command("component.createMultiple", "Create multiple components");
- let set = crate::i18n::command("component.createSet", "Create component set");
- for (c, label) in
- [("component.create", tr("Create component")), ("component.createMultiple", &multiple), ("component.createSet", &set)]
- {
+ let create = crate::i18n::command("component.create", "Create component");
+ let multiple = crate::i18n::command("component.createMultiple", "Create multiple components");
+ let set = crate::i18n::command("component.createSet", "Create component set");
+ // Like the layers menu, leave out what the selection can't use.
+ let can_set = figcraft_engine::cmd::component::can_create_set(doc, sel);
+ let mut entries = vec![("component.create", &create), ("component.createMultiple", &multiple)];
+ if can_set {
+ entries.push(("component.createSet", &set));
+ }
+ // Wide enough for the longest translated label (the rows grow with it, the menu never clips).
+ let widest = entries
+ .iter()
+ .map(|(_, l)| ui.painter().layout_no_wrap(l.to_string(), regular(11.5), Color32::PLACEHOLDER).size().x)
+ .fold(0.0f32, f32::max);
+ menu_popup(&bresp).width(menu::menu_row_width(widest, 0.0).ceil().max(160.0)).show(|ui| {
+ for (c, label) in &entries {
let key = crate::shortcuts::shortcut(c).unwrap_or("");
- if menu_icon_item(ui, None, None, label, key) {
+ if menu_item(ui, label, key) {
app.run(c, json!({}));
ui.close();
}
@@ -548,7 +565,7 @@ fn header(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) {
} else if bresp.clicked() {
app.run(cmd, json!({}));
}
- x -= 26.0;
+ x -= bw + 2.0;
}
crate::component_panel::instance_section(app, ui, doc, sel);
}
From 10d97e2716a7b68724e95c3326425ab58f4681fe Mon Sep 17 00:00:00 2001
From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com>
Date: Sun, 8 Nov 2026 11:00:14 +0000
Subject: [PATCH 13/51] Reject negative or fractional index in node.reparent
Found while testing crates/engine/src/cmd/edit/props.rs:248 (reparent, `index = num(p, "index").map(|i| i.max(0.0) as usize)`)
Co-Authored-By: Claude Haiku 5.5
Co-Authored-By: Claude Sonnet 5.5
---
crates/engine/src/cmd/edit/props.rs | 2 +-
crates/engine/src/panels_behaviour_tests.rs | 16 ++++++++++++++++
2 files changed, 17 insertions(+), 1 deletion(-)
diff --git a/crates/engine/src/cmd/edit/props.rs b/crates/engine/src/cmd/edit/props.rs
index fb8a1c17f..e7f04d7a1 100644
--- a/crates/engine/src/cmd/edit/props.rs
+++ b/crates/engine/src/cmd/edit/props.rs
@@ -245,7 +245,7 @@ pub(super) fn stroke_brush(s: &mut Session, p: &Value) -> Result {
pub(super) fn reparent(s: &mut Session, p: &Value) -> Result {
let ids = req_targets("node.reparent", s, p)?;
let parent = req_id("node.reparent", p, "parent")?;
- let index = num(p, "index").map(|i| i.max(0.0) as usize);
+ let index = opt_index("node.reparent", p, "index")?;
let doc = s.doc_mut()?;
let ids = z_sorted(doc, &roots(doc, &ids));
// `index` is where the first moved layer ends up among the parent's children (bottom first),
diff --git a/crates/engine/src/panels_behaviour_tests.rs b/crates/engine/src/panels_behaviour_tests.rs
index 585fc3e08..60450dcbe 100644
--- a/crates/engine/src/panels_behaviour_tests.rs
+++ b/crates/engine/src/panels_behaviour_tests.rs
@@ -200,6 +200,22 @@ fn reparent_index_is_the_final_position_in_auto_layout_and_across_parents() {
assert!(kids(&s, row).is_empty());
}
+#[test]
+fn reparent_rejects_a_negative_or_fractional_index() {
+ let mut s = Session::new();
+ let frame = id(&ex(&mut s, "node.create", json!({"type": "frame", "name": "F"})));
+ let rect = |s: &mut Session, n: &str, parent: NodeId| id(&ex(s, "node.create", json!({"type": "rectangle", "name": n, "parent": parent})));
+ let (a, b) = (rect(&mut s, "a", frame), rect(&mut s, "b", frame));
+ let free = id(&ex(&mut s, "node.create", json!({"type": "rectangle", "name": "free"})));
+ for bad in [json!(-1), json!(1.5), json!("x")] {
+ let r = s.execute("node.reparent", &json!({"ids": [free], "parent": frame, "index": bad}));
+ assert!(r.is_err(), "index {bad} accepted");
+ assert_eq!(kids(&s, frame), [a, b], "the tree is unchanged for index {bad}");
+ }
+ ex(&mut s, "node.reparent", json!({"ids": [free], "parent": frame, "index": 0}));
+ assert_eq!(kids(&s, frame), [free, a, b]);
+}
+
#[test]
fn renaming_several_layers_by_the_documented_codes() {
// Three layers named `names`, bottom to top; (names, params, new names, derivation).
From 6222fce8d13289dc30f909df17b3ae3f913bfeb0 Mon Sep 17 00:00:00 2001
From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com>
Date: Sun, 8 Nov 2026 12:22:45 +0000
Subject: [PATCH 14/51] Reject signs and exponents in ExportSize::parse
Found while testing crates/doc/src/props.rs:1394 (ExportSize::parse, `num.parse::()`)
Co-Authored-By: Claude Haiku 5.5
Co-Authored-By: Claude Sonnet 5.5
---
crates/doc/src/props.rs | 4 ++++
crates/engine/src/import_export_tests.rs | 2 +-
2 files changed, 5 insertions(+), 1 deletion(-)
diff --git a/crates/doc/src/props.rs b/crates/doc/src/props.rs
index 99b9d9c39..15d557d88 100644
--- a/crates/doc/src/props.rs
+++ b/crates/doc/src/props.rs
@@ -1399,6 +1399,10 @@ impl ExportSize {
Some((i, c)) if c.is_ascii_alphabetic() => (s.get(..i)?.trim(), Some(c)),
_ => (s.as_str(), None),
};
+ // Plain decimal only: no sign, exponent, "inf" or "nan".
+ if !num.bytes().all(|b| b.is_ascii_digit() || b == b'.') {
+ return None;
+ }
let value: f64 = num.parse().ok()?;
match unit {
None | Some('x') => ExportSize::Scale { value },
diff --git a/crates/engine/src/import_export_tests.rs b/crates/engine/src/import_export_tests.rs
index 4acc86216..fdc8b29d4 100644
--- a/crates/engine/src/import_export_tests.rs
+++ b/crates/engine/src/import_export_tests.rs
@@ -283,7 +283,7 @@ fn export_sizes_parse_and_label() {
assert_eq!(ExportSize::parse(text), Some(want), "{text}");
assert_eq!(ExportSize::parse(&want.label()), Some(want));
}
- for bad in ["", "x", "0x", "-1x", "NaNx", "infx", "65x", "20000w", "2q", "2xx"] {
+ for bad in ["", "x", "0x", "-1x", "NaNx", "infx", "65x", "20000w", "2q", "2xx", "+2x", "+512w", "2e0x", "2E0x", "+0.5x"] {
assert_eq!(ExportSize::parse(bad), None, "{bad}");
}
let e = |size, suffix: &str, format| ExportSetting { size, suffix: suffix.into(), format, ..Default::default() };
From a6be897d3141f11f396cdf5a63a797463d69428b Mon Sep 17 00:00:00 2001
From: Giovanni Almeida
Date: Sun, 8 Nov 2026 13:53:24 -0300
Subject: [PATCH 15/51] M6: a vector point without a corner radius of its own
takes the layer's on .fig import (#2114)
Figma rounds every point whose style doesn't set a corner radius with the
layer's cornerRadius. The import read only the point's style, so those
vectors came in sharp. Points now fall back to the layer's radius, at the
scale of the layer that carries it (#2091), and the network cache keys on
it. The export writes an explicit 0 for a sharp point on a rounded vector
so it stays sharp.
---
contributors/people.toml | 3 +
crates/format/src/fig/export/shape.rs | 4 +-
crates/format/src/fig/export_tests.rs | 19 ++++++
crates/format/src/fig/map.rs | 5 +-
crates/format/src/fig/map/layer.rs | 7 ++-
crates/format/src/fig/tail_tests.rs | 90 +++++++++++++++++++++++++++
6 files changed, 123 insertions(+), 5 deletions(-)
diff --git a/contributors/people.toml b/contributors/people.toml
index c6924df5f..34f849ed7 100644
--- a/contributors/people.toml
+++ b/contributors/people.toml
@@ -115,3 +115,6 @@ display_name = "Hamza0101"
[people.ashokram-si]
real_name = "Ashok Ram"
+
+[people.GiovanniBot]
+sync_github_name = true
diff --git a/crates/format/src/fig/export/shape.rs b/crates/format/src/fig/export/shape.rs
index ca5846cde..bfc2b0224 100644
--- a/crates/format/src/fig/export/shape.rs
+++ b/crates/format/src/fig/export/shape.rs
@@ -207,7 +207,9 @@ impl<'d> Exporter<'d> {
.enumerate()
.map(|(i, v)| {
let mut st: Fields = Vec::new();
- if v.radius > 0.0 {
+ // A point without a radius of its own is drawn with the layer's, so a sharp
+ // point on a rounded layer says so.
+ if v.radius > 0.0 || n.corners.radii[0] > 0.0 {
st.push(("cornerRadius", fl(v.radius)));
}
if let Some(m) = v.mirror {
diff --git a/crates/format/src/fig/export_tests.rs b/crates/format/src/fig/export_tests.rs
index 9651a1025..4fd533545 100644
--- a/crates/format/src/fig/export_tests.rs
+++ b/crates/format/src/fig/export_tests.rs
@@ -955,6 +955,25 @@ fn closed_vector_geometry_is_written_with_its_corner_smoothing() {
assert_eq!(radii, vec![20.0, 0.0, 10.0, 0.0], "each point keeps its own radius");
}
+#[test]
+fn sharp_points_on_a_rounded_vector_stay_sharp() {
+ // A point without a radius takes the layer's when read, so a sharp point on a vector with a
+ // layer radius is written with its own zero.
+ let mut doc = Document::new("sharp point");
+ let page = doc.first_page().unwrap();
+ let mut network = VectorNetwork::from_points(&[(0.0, 0.0), (100.0, 0.0), (100.0, 100.0), (0.0, 100.0)].map(|(x, y)| Point::new(x, y)), true);
+ for (v, r) in network.vertices.iter_mut().zip([8.0, 0.0, 8.0, 0.0]) {
+ v.radius = r;
+ }
+ let id = doc.create(page, NodeKind::Vector { network }, "V", 0.0, 0.0, 100.0, 100.0).unwrap();
+ doc.node_mut(id).unwrap().corners.radii = [8.0; 4];
+ let (back, _) = round_trip(&doc, "sharp point");
+ let v = back.nodes().find(|n| matches!(n.kind, NodeKind::Vector { .. })).unwrap();
+ let NodeKind::Vector { network } = &v.kind else { panic!("vector") };
+ let radii: Vec = network.vertices.iter().map(|x| x.radius).collect();
+ assert_eq!(radii, vec![8.0, 0.0, 8.0, 0.0]);
+}
+
#[test]
fn the_written_schema_parses_and_round_trips() {
let schema = Schema::parse(export::SCHEMA).unwrap();
diff --git a/crates/format/src/fig/map.rs b/crates/format/src/fig/map.rs
index fd1b47575..aa0db5cf4 100644
--- a/crates/format/src/fig/map.rs
+++ b/crates/format/src/fig/map.rs
@@ -356,8 +356,9 @@ struct Mapper<'a> {
/// holding it) name it by `vectorNetworkRef`. Kept out of the JSON, where a large file's
/// networks took gigabytes.
networks: Vec
")), "slot Label HTML: {html}");
+}
+
+fn assert_local_slot_text(s: &mut Session, instance: NodeId, expected: &str) {
+ assert!(matches!(&node(s, instance).kind, NodeKind::Instance { props, .. } if props.get("Text") == Some(&json!(expected))));
+ let properties = props_of(s, instance);
+ assert_eq!(properties.iter().find(|p| p["name"] == "Text").unwrap()["value"], expected);
+}
+
+#[test]
+fn slot_text_properties_stay_local_in_default_and_own_content() {
+ for own in [false, true] {
+ let mut s = Session::new();
+ let (content, label, _base, composition, main_content) = slot_text_composition(&mut s, own);
+ let a = idv(&ex(&mut s, "instance.create", json!({"component": composition, "x": 400})));
+ let b = idv(&ex(&mut s, "instance.create", json!({"component": composition, "x": 800})));
+ let ac = copy_of(&s, a, main_content);
+ let bc = copy_of(&s, b, main_content);
+ ex(&mut s, "instance.setProperties", json!({"ids": [ac], "properties": {"Text": "Custom"}}));
+ assert_local_slot_text(&mut s, ac, "Custom");
+ assert_slot_text(&mut s, ac, "Custom");
+ assert_slot_text(&mut s, bc, "Default");
+ assert_slot_text(&mut s, main_content, "Default");
+ assert_eq!(node(&s, label).kind.text().unwrap().characters, "Default");
+ // Main property edits propagate without replacing another instance's local value.
+ ex(&mut s, "instance.setProperties", json!({"ids": [main_content], "properties": {"Text": "Main value"}}));
+ assert_slot_text(&mut s, ac, "Custom");
+ assert_slot_text(&mut s, bc, "Main value");
+ assert_slot_text(&mut s, main_content, "Main value");
+ ex(&mut s, "component.editProperty", json!({"id": content, "name": "Text", "default": "New default"}));
+ assert_slot_text(&mut s, ac, "Custom");
+ assert_slot_text(&mut s, bc, "Main value");
+ healthy(&s);
+ }
+}
+
+#[test]
+fn slot_text_properties_do_not_read_an_unbound_base_property() {
+ let mut s = Session::new();
+ let (_content, _label, base, composition, main_content) = slot_text_composition(&mut s, true);
+ ex(&mut s, "component.addProperty", json!({"id": base, "name": "Text", "type": "text", "default": "Wrong owner"}));
+ let i = idv(&ex(&mut s, "instance.create", json!({"component": composition, "x": 400})));
+ let nested = copy_of(&s, i, main_content);
+ assert_slot_text(&mut s, nested, "Default");
+ ex(&mut s, "instance.setProperties", json!({"ids": [nested], "properties": {"Text": "Custom"}}));
+ assert_local_slot_text(&mut s, nested, "Custom");
+ assert_slot_text(&mut s, nested, "Custom");
+ assert_slot_text(&mut s, main_content, "Default");
+ healthy(&s);
+}
+
+#[test]
+fn slot_text_properties_survive_inherited_own_content_and_persistence() {
+ let mut s = Session::new();
+ let (_content, _label, _base, composition, main_content) = slot_text_composition(&mut s, true);
+ let (outer, inherited) = slot_text_wrapper(&mut s, composition, "Outer composition");
+ let inherited_content = copy_of(&s, inherited, main_content);
+ let a = idv(&ex(&mut s, "instance.create", json!({"component": outer, "x": 400})));
+ let b = idv(&ex(&mut s, "instance.create", json!({"component": outer, "x": 800})));
+ let ac = copy_of(&s, a, inherited_content);
+ let bc = copy_of(&s, b, inherited_content);
+ ex(&mut s, "instance.setProperties", json!({"ids": [ac], "properties": {"Text": "Custom"}}));
+ assert_local_slot_text(&mut s, ac, "Custom");
+ let bytes = figcraft_format::save_bytes(s.doc().unwrap(), None).unwrap();
+ let (back, _) = figcraft_format::load_bytes(&bytes).unwrap();
+ let mut s = Session::with_document(back);
+ assert_local_slot_text(&mut s, ac, "Custom");
+ assert_slot_text(&mut s, ac, "Custom");
+ assert_slot_text(&mut s, bc, "Default");
+ assert_slot_text(&mut s, inherited_content, "Default");
+ assert_slot_text(&mut s, main_content, "Default");
+ // Explicit characters override the bound property, even after a main update.
+ let alabel = kids(&s, ac)[0];
+ ex(&mut s, "text.set", json!({"id": alabel, "characters": "Explicit"}));
+ ex(&mut s, "instance.setProperties", json!({"ids": [ac], "properties": {"Text": "Later"}}));
+ ex(&mut s, "instance.setProperties", json!({"ids": [main_content], "properties": {"Text": "Main value"}}));
+ assert_local_slot_text(&mut s, ac, "Later");
+ assert_slot_text(&mut s, ac, "Explicit");
+ assert_slot_text(&mut s, bc, "Main value");
+ ex(&mut s, "component.resetOverride", json!({"id": alabel, "group": "characters"}));
+ assert_slot_text(&mut s, ac, "Later");
+ s.undo().unwrap();
+ assert_slot_text(&mut s, ac, "Explicit");
+ healthy(&s);
+}
+
/// Random slot edits, main edits and resets keep every instance mirroring its main.
#[test]
fn random_slot_edits_keep_instances_consistent() {
From 92cf9849027aff7dc4a2aee82ecdc1bd1d7a0cd1 Mon Sep 17 00:00:00 2001
From: Trevor Brown
Date: Sun, 8 Nov 2026 17:38:13 -0500
Subject: [PATCH 17/51] Selection: Escape tests for a top-level layer, several
layers and the focused frame (#2107 review)
One Escape selects nothing for a top-level frame, a top-level layer, two layers in one group,
layers in different parents, and a nested layer with a top-level one. With the focused frame
itself selected, one Escape leaves the focus view.
Co-Authored-By: Claude Opus 5.5
Model-Effort: Opus 5.5 drove and wrote
---
crates/engine/src/devfocus_tests.rs | 6 ++++++
crates/engine/src/selection_behaviour_tests.rs | 14 ++++++++++++++
2 files changed, 20 insertions(+)
diff --git a/crates/engine/src/devfocus_tests.rs b/crates/engine/src/devfocus_tests.rs
index f36af7ee6..b7a4f9341 100644
--- a/crates/engine/src/devfocus_tests.rs
+++ b/crates/engine/src/devfocus_tests.rs
@@ -92,6 +92,12 @@ fn only_the_focused_frame_is_hit_and_escape_leaves() {
assert_eq!(focused(&s), None, "Escape with nothing selected leaves");
click(&mut s, 350.0, 100.0);
assert_eq!(s.selection(), vec![b], "everything is hit again");
+ // With the focused frame itself selected, one Escape leaves the focus view.
+ ex(&mut s, "dev.focus", json!({"id": a}));
+ ex(&mut s, "select.set", json!({"ids": [a]}));
+ assert_eq!(focused(&s), Some(a));
+ s.escape().unwrap();
+ assert_eq!(focused(&s), None, "Escape with the focused frame selected leaves at once");
}
#[test]
diff --git a/crates/engine/src/selection_behaviour_tests.rs b/crates/engine/src/selection_behaviour_tests.rs
index b3289c6c3..6498b8cb5 100644
--- a/crates/engine/src/selection_behaviour_tests.rs
+++ b/crates/engine/src/selection_behaviour_tests.rs
@@ -277,6 +277,20 @@ fn keyboard_selection() {
assert_eq!(s.selection(), Vec::::new(), "Esc from a nested layer selects nothing, not the parent");
s.escape().unwrap();
assert_eq!(s.selection(), Vec::::new());
+ // A top-level layer, and several layers, nested or not: one Esc selects nothing.
+ let cases: Vec<(Vec, &str)> = vec![
+ (vec![k.f], "a top-level frame"),
+ (vec![k.r], "a top-level layer"),
+ (vec![k.a, k.b], "two layers in one group (not their group)"),
+ (vec![k.a, k.d], "layers in different parents (not the parents)"),
+ (vec![k.a, k.r], "a nested layer and a top-level one"),
+ ];
+ for (ids, why) in cases {
+ ex(&mut s, "select.set", json!({"ids": ids}));
+ assert_eq!(sorted(s.selection()), sorted(ids.clone()), "{why}: selected");
+ s.escape().unwrap();
+ assert_eq!(s.selection(), Vec::::new(), "Esc on {why} selects nothing");
+ }
}
#[test]
From 0aba3f7632457150cfe18c96510022e1566151c6 Mon Sep 17 00:00:00 2001
From: ljkx <177671075+ljkx@users.noreply.github.com>
Date: Mon, 9 Nov 2026 06:15:26 +0100
Subject: [PATCH 18/51] M4: a Pencil stroke stays where it was drawn, without
spikes off it (#1741)
kurbo's simplify_bezpath, which smooths Pencil samples and runs Simplify
path, fitted two common shapes with a curve that ran far off the path: a
stroke that goes out and back to the very point it left (kurbo keeps a
reversal as a smooth join) and nearly collinear samples (kurbo measures
its error across the path, not along it).
figcraft_geom::simplify_path cuts the path where it turns back by more
than a right angle, simplifies each run with kurbo, and keeps a run as it
was when its fit strays more than twice the accuracy from it. The Pencil
and vector.simplify use it.
---
crates/engine/src/brush_tests.rs | 46 +++++++
crates/engine/src/canvas/tools/pen.rs | 5 +-
crates/engine/src/cmd/structure.rs | 8 +-
crates/engine/src/vector_behaviour_tests.rs | 21 +++
crates/geom/src/lib.rs | 2 +
crates/geom/src/simplify.rs | 140 ++++++++++++++++++++
crates/geom/src/tests.rs | 117 ++++++++++++++++
7 files changed, 333 insertions(+), 6 deletions(-)
create mode 100644 crates/geom/src/simplify.rs
diff --git a/crates/engine/src/brush_tests.rs b/crates/engine/src/brush_tests.rs
index 35dba6a7f..50317496d 100644
--- a/crates/engine/src/brush_tests.rs
+++ b/crates/engine/src/brush_tests.rs
@@ -149,3 +149,49 @@ fn the_pencil_draws_white_on_a_dark_background() {
assert_eq!(stroke(&mut s, 0.0, 600.0).0, want, "on {bg}");
}
}
+
+/// A Pencil stroke stays where it was drawn: the fitted curve shot far off the stroke, a spike
+/// (#1741, its second point), where the stroke went out and back to the very point it left and
+/// where samples ran on nearly straight. Pointer pixels become fractional page points, so going
+/// back to a pixel lands on exactly the same point. Both sample runs are ones kurbo's fit took
+/// hundreds of units off.
+#[test]
+fn the_pencil_keeps_its_stroke_where_it_was_drawn() {
+ let mut s = Session::new();
+ s.execute("tool.set", &json!({"tool": "pencil"})).unwrap();
+ let (p, q) = ((86.043_565_719_086_97, -173.744_402_349_100_94), (87.851_850_739_335_35, -179.202_846_445_954_52));
+ let back = [(80.0, -160.0), p, q, p, (95.0, -185.0)];
+ let straight = [
+ (204.0, -198.0),
+ (209.598_690_794_015_5, -204.909_328_119_739_88),
+ (211.277_914_263_870_97, -207.190_974_020_753_4),
+ (214.246_506_828_720_84, -211.228_827_590_623_24),
+ (218.0, -214.0),
+ ];
+ for (what, samples) in [("out and back", &back[..]), ("nearly straight", &straight[..])] {
+ let (first, last) = (samples[0], samples[samples.len() - 1]);
+ s.pointer(&PointerEvent::at(PointerKind::Down, first.0, first.1, 1.0)).unwrap();
+ for (x, y) in &samples[1..] {
+ s.pointer(&PointerEvent::at(PointerKind::Move, *x, *y, 1.0)).unwrap();
+ }
+ s.pointer(&PointerEvent::at(PointerKind::Up, last.0, last.1, 1.0)).unwrap();
+ let doc = s.doc().unwrap();
+ let n = doc.nodes().filter(|n| matches!(n.kind, NodeKind::Vector { .. })).max_by_key(|n| n.id).unwrap();
+ let NodeKind::Vector { network } = &n.kind else { panic!("{what}: not a vector") };
+ let drawn = doc.world_transform(n.id) * network.stroke_path();
+ let samples_box = figcraft_geom::points_bounds(samples.iter().map(|(x, y)| Point::new(*x, *y))).unwrap();
+ let (path_box, node_box) = (figcraft_geom::Shape::bounding_box(&drawn), doc.world_bounds(n.id).unwrap());
+ assert!(
+ samples_box.inflate(1.5, 1.5).contains_rect(path_box),
+ "{what}: the stroke {path_box:?} goes past the samples {samples_box:?}: {}",
+ drawn.to_svg()
+ );
+ assert!(samples_box.inflate(1.5, 1.5).contains_rect(node_box), "{what}: the layer {node_box:?}, the samples {samples_box:?}");
+ }
+ // The turn is a point of the stroke, where it was drawn.
+ let doc = s.doc().unwrap();
+ let turned = doc.nodes().find(|n| n.name == "Vector" && doc.world_bounds(n.id).is_some_and(|b| b.y1 < -150.0 && b.x1 < 100.0)).unwrap();
+ let NodeKind::Vector { network } = &turned.kind else { panic!("not a vector") };
+ let xf = doc.world_transform(turned.id);
+ assert!(network.vertices.iter().any(|v| (xf * Point::new(v.x, v.y) - Point::new(q.0, q.1)).hypot() < 1e-6), "no point at the turn");
+}
diff --git a/crates/engine/src/canvas/tools/pen.rs b/crates/engine/src/canvas/tools/pen.rs
index 4b98ccee1..3f8b47e59 100644
--- a/crates/engine/src/canvas/tools/pen.rs
+++ b/crates/engine/src/canvas/tools/pen.rs
@@ -313,7 +313,8 @@ impl Session {
if points.len() < 2 {
return Ok(());
}
- // Smooth: fit a curve through the samples.
+ // Smooth: fit a curve through the samples, kept from shooting past them where the stroke
+ // turns back or runs nearly straight (#1741).
let mut poly = figcraft_geom::BezPath::new();
for (k, p) in points.iter().enumerate() {
if k == 0 {
@@ -322,7 +323,7 @@ impl Session {
poly.line_to(*p);
}
}
- let fitted = figcraft_geom::kurbo::simplify::simplify_bezpath(poly.iter(), 0.75, &Default::default());
+ let fitted = figcraft_geom::simplify_path(&poly, 0.75);
let d = fitted.to_svg();
if matches!(self.tool.tool, ToolId::Marker | ToolId::Highlighter) {
return self.ink_finish(&d);
diff --git a/crates/engine/src/cmd/structure.rs b/crates/engine/src/cmd/structure.rs
index 3339faa38..34cc91fbb 100644
--- a/crates/engine/src/cmd/structure.rs
+++ b/crates/engine/src/cmd/structure.rs
@@ -799,7 +799,7 @@ fn simplify(s: &mut Session, p: &Value) -> Result {
WindingRule::NonZero
};
let path = drawn.fill_path();
- let path = figcraft_geom::kurbo::simplify::simplify_bezpath(path.iter(), tol, &Default::default());
+ let path = figcraft_geom::simplify_path(&path, tol);
let (path, output_winding) = match figcraft_pathops::simplify_with_rule(&path, winding == WindingRule::EvenOdd) {
Ok(path) => (path, WindingRule::EvenOdd),
Err(_) => (path, winding),
@@ -809,7 +809,7 @@ fn simplify(s: &mut Session, p: &Value) -> Result {
} else {
for (index, region) in source.regions.iter().enumerate() {
let Some(path) = drawn.region_path(index) else { continue };
- let path = figcraft_geom::kurbo::simplify::simplify_bezpath(path.iter(), tol, &Default::default());
+ let path = figcraft_geom::simplify_path(&path, tol);
let path = figcraft_pathops::simplify_with_rule(&path, region.winding == WindingRule::EvenOdd).unwrap_or(path);
append_region_path(&mut net, &path, region, region.winding);
}
@@ -826,7 +826,7 @@ fn simplify(s: &mut Session, p: &Value) -> Result {
open.regions.clear();
if !open.segments.is_empty() {
let path = open.stroke_path();
- let simplified = figcraft_geom::kurbo::simplify::simplify_bezpath(path.iter(), tol, &Default::default());
+ let simplified = figcraft_geom::simplify_path(&path, tol);
let mut loose = VectorNetwork::from_path(&simplified);
loose.regions.clear();
append_network(&mut net, loose);
@@ -834,7 +834,7 @@ fn simplify(s: &mut Session, p: &Value) -> Result {
}
if source.regions.is_empty() {
let path = source.stroke_path();
- let simplified = figcraft_geom::kurbo::simplify::simplify_bezpath(path.iter(), tol, &Default::default());
+ let simplified = figcraft_geom::simplify_path(&path, tol);
net = VectorNetwork::from_path(&simplified);
net.regions.clear();
}
diff --git a/crates/engine/src/vector_behaviour_tests.rs b/crates/engine/src/vector_behaviour_tests.rs
index c990ad348..4666c3028 100644
--- a/crates/engine/src/vector_behaviour_tests.rs
+++ b/crates/engine/src/vector_behaviour_tests.rs
@@ -326,6 +326,27 @@ fn ex_to_vector(doc: &mut figcraft_doc::Document, id: NodeId) {
doc.get_mut(id).unwrap().kind = NodeKind::Vector { network };
}
+/// Simplify path keeps nearly collinear points near where they were: kurbo's fit, which Simplify
+/// path and the Pencil share, ran a handle hundreds of units on along the line (#1741).
+#[test]
+fn simplify_keeps_nearly_collinear_points_near_them() {
+ let mut s = Session::new();
+ let id = id_of(&ex(&mut s, "vector.create", json!({"path": "M0 0 L10 10"}))["id"]).unwrap();
+ // The points exactly as sampled, at the page origin (`vector.create` would move them).
+ let d = "M209.5986907940155 -204.90932811973988 L211.27791426387097 -207.1909740207534 L214.24650682872084 -211.22882759062324";
+ let before = BezPath::from_svg(d).unwrap();
+ {
+ let n = s.doc_mut().unwrap().get_mut(id).unwrap();
+ n.transform = figcraft_geom::Affine::IDENTITY;
+ n.kind = NodeKind::Vector { network: VectorNetwork::from_path(&before) };
+ }
+ assert_eq!(net(&s, id).stroke_path().elements(), before.elements(), "the samples as they were");
+ ex(&mut s, "vector.simplify", json!({"ids": [id], "tolerance": 0.75}));
+ let after = s.doc().unwrap().world_transform(id) * net(&s, id).stroke_path();
+ let (b0, b1) = (before.bounding_box(), after.bounding_box());
+ assert!(b0.inflate(1.5, 1.5).contains_rect(b1), "{b1:?} past {b0:?}: {}", after.to_svg());
+}
+
fn ex(s: &mut Session, cmd: &str, p: Value) -> Value {
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}"))
}
diff --git a/crates/geom/src/lib.rs b/crates/geom/src/lib.rs
index a775a4170..191b30419 100644
--- a/crates/geom/src/lib.rs
+++ b/crates/geom/src/lib.rs
@@ -12,7 +12,9 @@ use kurbo::ParamCurve;
use std::f64::consts::{FRAC_PI_2, PI, TAU};
pub mod measure;
+mod simplify;
mod transform;
+pub use simplify::simplify_path;
pub use transform::*;
/// Corner radii in clockwise order from the top-left: `[tl, tr, br, bl]`.
diff --git a/crates/geom/src/simplify.rs b/crates/geom/src/simplify.rs
new file mode 100644
index 000000000..e768f303a
--- /dev/null
+++ b/crates/geom/src/simplify.rs
@@ -0,0 +1,140 @@
+//! Path simplification that keeps to its input where kurbo's fit would shoot past it.
+
+use kurbo::{BezPath, ParamCurve, ParamCurveNearest, PathEl, PathSeg, Point, Vec2};
+
+/// How far, as a multiple of the accuracy asked for, a fitted run may stray from the run it
+/// replaces before the run is kept as it was. kurbo's own fits stay within about the accuracy; its
+/// failures stray by tens to hundreds of units, so twice the accuracy tells them apart without
+/// undoing fits that are merely a little over.
+const STRAY: f64 = 2.0;
+
+/// `path` simplified to within `accuracy` by kurbo's [`simplify_bezpath`], kept from straying
+/// far from `path` where kurbo's fit does.
+///
+/// Two inputs made kurbo's fit shoot far past the path it simplified (#1741, a Pencil stroke with
+/// a spike off it), and both are common in pointer samples:
+/// - A join where the path turns straight back (samples going down and back up the same line):
+/// kurbo keeps two segments in one smooth fit when their tangents are parallel whichever way
+/// they point (`|cross| <= |dot| * angle_thresh`), so it fits a smooth curve through the cusp.
+/// The path is cut where it turns back by more than a right angle, each run between the cuts is
+/// simplified on its own, and the runs are joined again, so the turn stays a sharp corner. Any
+/// other join that isn't straight within kurbo's threshold is a corner to kurbo already, so the
+/// cut changes nothing there.
+/// - A run of nearly collinear segments: kurbo measures its error across the path, not along it, so
+/// a fitted cubic whose handle runs hundreds of units on along the line passes its check. Each
+/// fitted run is measured against the run it replaces, and one that strays more than
+/// twice `accuracy` from it is kept as it was instead.
+///
+/// Subpaths and closes are kept, and a closing segment stays implicit, as kurbo leaves it.
+///
+/// [`simplify_bezpath`]: kurbo::simplify::simplify_bezpath
+pub fn simplify_path(path: &BezPath, accuracy: f64) -> BezPath {
+ let mut out = BezPath::new();
+ let mut run = BezPath::new();
+ // Where the current subpath started, where the pen is, and the last segment's end tangent.
+ let mut start = Point::ZERO;
+ let mut at = Point::ZERO;
+ let mut last_tan: Option = None;
+ // Whether the next run that draws anything has to start the subpath with its move.
+ let mut fresh = true;
+ for el in path.elements() {
+ let seg = match *el {
+ PathEl::MoveTo(p) => {
+ flush(&mut out, &mut run, accuracy, &mut fresh);
+ (start, at, last_tan, fresh) = (p, p, None, true);
+ run.move_to(p);
+ continue;
+ }
+ PathEl::ClosePath => {
+ flush(&mut out, &mut run, accuracy, &mut fresh);
+ if !fresh {
+ out.close_path();
+ }
+ // A segment after a close starts a new subpath at the closed one's start.
+ (at, last_tan, fresh) = (start, None, true);
+ run.move_to(start);
+ continue;
+ }
+ PathEl::LineTo(p) => PathSeg::Line(kurbo::Line::new(at, p)),
+ PathEl::QuadTo(p1, p2) => PathSeg::Quad(kurbo::QuadBez::new(at, p1, p2)),
+ PathEl::CurveTo(p1, p2, p3) => PathSeg::Cubic(kurbo::CubicBez::new(at, p1, p2, p3)),
+ };
+ if run.elements().is_empty() {
+ run.move_to(at);
+ }
+ let Some((t0, t1)) = tangents(seg) else { continue };
+ if last_tan.is_some_and(|last| last.dot(t0) < 0.0) {
+ flush(&mut out, &mut run, accuracy, &mut fresh);
+ run.move_to(at);
+ }
+ run.push(*el);
+ at = seg.end();
+ last_tan = Some(t1);
+ }
+ flush(&mut out, &mut run, accuracy, &mut fresh);
+ out
+}
+
+/// Simplify `run` (one move and the segments after it) with kurbo and append it to `out`; the
+/// run's move is kept only when it starts a subpath (`fresh`), else the run carries on from the
+/// last point drawn. `run` is left empty.
+fn flush(out: &mut BezPath, run: &mut BezPath, accuracy: f64, fresh: &mut bool) {
+ if run.elements().len() > 1 {
+ let fitted = kurbo::simplify::simplify_bezpath(run.iter(), accuracy, &Default::default());
+ let fitted = if keeps_to(&fitted, run, STRAY * accuracy) { fitted } else { std::mem::take(run) };
+ for el in fitted.elements() {
+ match el {
+ PathEl::MoveTo(_) if !*fresh => {}
+ _ => {
+ out.push(*el);
+ *fresh = false;
+ }
+ }
+ }
+ }
+ *run = BezPath::new();
+}
+
+/// Whether every point of `fitted` lies within `tol` of `source`: one subpath each, from the same
+/// start to the same end, `fitted` being kurbo's simplification of `source`.
+///
+/// kurbo ends each fitted segment on the source, so the two are walked together: for each fitted
+/// segment, the source segments from where it starts to the first one its end lies on are the only
+/// ones its sample points are measured against. The work is linear in the two lengths. A fitted end
+/// found on no source segment counts as straying.
+fn keeps_to(fitted: &BezPath, source: &BezPath, tol: f64) -> bool {
+ /// Parts each fitted segment is cut into: the points between them are the ones measured.
+ const SAMPLES: u32 = 16;
+ let src: Vec = source.segments().collect();
+ // kurbo puts each end on the source by evaluating it: off by rounding at most.
+ let on2 = (tol * 1e-3).powi(2);
+ let (tol2, mut cur) = (tol * tol, 0);
+ for seg in fitted.segments() {
+ let Some(last) = (cur..src.len()).find(|&i| src.get(i).is_some_and(|s| s.nearest(seg.end(), 1e-9).distance_sq <= on2)) else {
+ return false;
+ };
+ let near = src.get(cur..=last).unwrap_or_default();
+ for i in 1..SAMPLES {
+ let p = seg.eval(f64::from(i) / f64::from(SAMPLES));
+ if !near.iter().any(|s| s.nearest(p, 1e-6).distance_sq <= tol2) {
+ return false;
+ }
+ }
+ cur = last;
+ }
+ true
+}
+
+/// The directions `seg` leaves its start and reaches its end, skipping control points that sit on
+/// an end; `None` for a segment of no length, which kurbo skips too.
+fn tangents(seg: PathSeg) -> Option<(Vec2, Vec2)> {
+ let pts: Vec = match seg {
+ PathSeg::Line(l) => vec![l.p0, l.p1],
+ PathSeg::Quad(q) => vec![q.p0, q.p1, q.p2],
+ PathSeg::Cubic(c) => vec![c.p0, c.p1, c.p2, c.p3],
+ };
+ let (first, last) = (*pts.first()?, *pts.last()?);
+ let t0 = pts.iter().map(|p| *p - first).find(|d| d.hypot2() > 0.0)?;
+ let t1 = pts.iter().rev().map(|p| last - *p).find(|d| d.hypot2() > 0.0)?;
+ Some((t0, t1))
+}
diff --git a/crates/geom/src/tests.rs b/crates/geom/src/tests.rs
index 459b2b101..10e09318c 100644
--- a/crates/geom/src/tests.rs
+++ b/crates/geom/src/tests.rs
@@ -366,3 +366,120 @@ fn svg_path_refuses_numbers_past_the_limit_before_parsing() {
assert!(number_past("M0 0L-2.5.5 1e-3-4e3z", 1e3));
assert!(number_past("M.5.5e10", 1e3));
}
+
+/// A path through `pts` with straight lines (pencil samples).
+fn polyline(pts: &[(f64, f64)]) -> BezPath {
+ let mut p = BezPath::new();
+ for (k, (x, y)) in pts.iter().enumerate() {
+ if k == 0 { p.move_to((*x, *y)) } else { p.line_to((*x, *y)) }
+ }
+ p
+}
+
+/// How far `out` strays from `input`: the largest distance from a point sampled along `out` to the
+/// nearest segment of `input` (closing segments included).
+fn deviation(out: &BezPath, input: &BezPath) -> f64 {
+ use kurbo::{ParamCurve, ParamCurveNearest};
+ let segs: Vec = input.segments().collect();
+ out.segments()
+ .flat_map(|seg| (0..=32).map(move |i| seg.eval(f64::from(i) / 32.0)))
+ .map(|p| segs.iter().map(|s| s.nearest(p, 1e-6).distance_sq).fold(f64::INFINITY, f64::min).sqrt())
+ .fold(0.0, f64::max)
+}
+
+/// Pencil strokes kurbo's fit shot far past keep to their samples (#1741): a stroke that goes out
+/// and back to the very point it left, nearly collinear samples, and strokes that turn back along
+/// their own line. The first two are from fuzzing pencil-like strokes, where kurbo's curve went
+/// about 390 and 160 units off; at whole-pixel coordinates the same shapes happened to fit well.
+#[test]
+fn simplifying_keeps_to_strokes_kurbo_shot_past() {
+ use kurbo::ParamCurve;
+ let cases: [(&str, &[(f64, f64)]); 5] = [
+ (
+ "out and back to the same point",
+ &[
+ (86.043_565_719_086_97, -173.744_402_349_100_94),
+ (87.851_850_739_335_35, -179.202_846_445_954_52),
+ (86.043_565_719_086_97, -173.744_402_349_100_94),
+ ],
+ ),
+ (
+ "nearly collinear",
+ &[
+ (209.598_690_794_015_5, -204.909_328_119_739_88),
+ (211.277_914_263_870_97, -207.190_974_020_753_4),
+ (214.246_506_828_720_84, -211.228_827_590_623_24),
+ ],
+ ),
+ (
+ "a dip straight down and back up",
+ &[(0.0, 0.0), (10.0, 0.0), (20.0, 2.0), (30.0, 6.0), (30.0, 12.0), (30.0, 6.0), (40.0, 2.0), (50.0, 0.0)],
+ ),
+ ("a slip back at the end", &[(0.0, 0.0), (10.0, 3.0), (20.0, 8.0), (30.0, 15.0), (30.0, 25.0), (30.0, 22.0)]),
+ (
+ "a near reversal in a curve",
+ &[(0.0, 0.0), (8.0, 2.0), (16.0, 6.0), (24.0, 12.0), (32.0, 20.0), (24.0, 12.000_01), (16.0, 6.0), (8.0, 2.0)],
+ ),
+ ];
+ for (what, pts) in cases {
+ let input = polyline(pts);
+ let out = simplify_path(&input, 0.75);
+ let d = deviation(&out, &input);
+ assert!(d <= 1.5, "{what}: strays {d} from the samples: {}", out.to_svg());
+ let (inb, outb) = (input.bounding_box(), out.bounding_box());
+ assert!(inb.inflate(1.5, 1.5).contains_rect(outb), "{what}: {outb:?} past {inb:?}");
+ // One subpath, starting and ending where the stroke does.
+ let moves = out.elements().iter().filter(|e| matches!(e, kurbo::PathEl::MoveTo(_))).count();
+ assert_eq!(moves, 1, "{what}: {}", out.to_svg());
+ let (first, last) = (pts[0], pts[pts.len() - 1]);
+ assert_eq!(out.segments().next().map(|s| s.start()), Some(Point::new(first.0, first.1)), "{what}");
+ assert_eq!(out.segments().last().map(|s| s.end()), Some(Point::new(last.0, last.1)), "{what}");
+ }
+}
+
+/// Where a path never turns back and kurbo's fit holds, the result is kurbo's own, element for
+/// element: smooth runs, corners sharper than a right angle, closed subpaths and several subpaths.
+#[test]
+fn simplifying_is_kurbos_where_its_fit_holds() {
+ let wave: Vec<(f64, f64)> = (0..=60).map(|i| (f64::from(i) * 4.0, (f64::from(i) / 6.0).sin() * 30.0)).collect();
+ let zigzag: Vec<(f64, f64)> = (0..=8).map(|i| (f64::from(i) * 10.0, if i % 2 == 0 { 0.0 } else { 40.0 })).collect();
+ let mut closed = polyline(&wave[..20]);
+ closed.close_path();
+ let mut several = polyline(&wave);
+ several.extend(polyline(&zigzag).elements().iter().map(|e| match *e {
+ kurbo::PathEl::MoveTo(p) => kurbo::PathEl::MoveTo(p + Vec2::new(0.0, 100.0)),
+ kurbo::PathEl::LineTo(p) => kurbo::PathEl::LineTo(p + Vec2::new(0.0, 100.0)),
+ e => e,
+ }));
+ let curves = BezPath::from_svg("M0 0 C10 -20 30 -20 40 0 C50 20 70 20 80 0 Q100 -30 120 0 L140 10 Z M200 0 L240 0").unwrap();
+ for (what, input) in
+ [("a wave", polyline(&wave)), ("a zigzag", polyline(&zigzag)), ("closed", closed), ("two subpaths", several), ("curves", curves)]
+ {
+ let ours = simplify_path(&input, 0.75);
+ let theirs = kurbo::simplify::simplify_bezpath(input.iter(), 0.75, &Default::default());
+ assert_eq!(ours.elements(), theirs.elements(), "{what}");
+ }
+}
+
+/// A closed path with a reversal in it stays one closed subpath, and each subpath of several keeps
+/// its own start.
+#[test]
+fn simplifying_keeps_closes_and_subpaths_across_a_reversal() {
+ let mut input = polyline(&[(0.0, 0.0), (40.0, 0.0), (40.0, 30.0), (40.0, 20.0), (0.0, 30.0)]);
+ input.close_path();
+ input.extend(polyline(&[(100.0, 0.0), (100.0, 20.0), (100.0, 10.0)]).elements().iter().copied());
+ let out = simplify_path(&input, 0.75);
+ let els = out.elements();
+ let moves: Vec = els.iter().filter_map(|e| if let kurbo::PathEl::MoveTo(p) = e { Some(*p) } else { None }).collect();
+ assert_eq!(moves, [Point::new(0.0, 0.0), Point::new(100.0, 0.0)], "{}", out.to_svg());
+ assert_eq!(els.iter().filter(|e| matches!(e, kurbo::PathEl::ClosePath)).count(), 1, "{}", out.to_svg());
+ let close = els.iter().position(|e| matches!(e, kurbo::PathEl::ClosePath)).unwrap();
+ assert!(matches!(els[close + 1], kurbo::PathEl::MoveTo(_)), "the first subpath closes before the second starts");
+ assert!(deviation(&out, &input) <= 1.5, "{}", out.to_svg());
+ // Nothing to draw: nothing out, not a stray move or close.
+ let mut empty = BezPath::new();
+ empty.move_to((5.0, 5.0));
+ empty.line_to((5.0, 5.0));
+ empty.close_path();
+ assert!(simplify_path(&empty, 0.75).elements().is_empty());
+}
From cf567c3f63993b0ec0aabe122b2fac73b900e6e0 Mon Sep 17 00:00:00 2001
From: Trevor Brown
Date: Mon, 9 Nov 2026 00:51:03 -0500
Subject: [PATCH 19/51] M2: a child dragged in auto layout passes a sibling
when its leading edge reaches the sibling's middle
Within its own row or column, a dragged child's slot follows the box it would fill under the pointer (start bounds plus the unsnapped drag), by the edge facing the drag, not the pointer. Measured in Figma's web app, 2026-11-09. Dragging in from outside, wrapping layouts and grids keep their rules. Several moving layers use their combined box.
Co-Authored-By: Claude Sonnet 5.5
Model-Effort: Opus 5.5 drove and briefed; Sonnet 5.5 measured and wrote at medium
---
crates/engine/src/al_drag_tests.rs | 53 ++++++++++++++++++++++++++
crates/engine/src/canvas/drop.rs | 40 ++++++++++++++++---
crates/engine/src/canvas/tools/drag.rs | 16 +++++++-
docs/auto-layout-parity.md | 5 ++-
4 files changed, 105 insertions(+), 9 deletions(-)
diff --git a/crates/engine/src/al_drag_tests.rs b/crates/engine/src/al_drag_tests.rs
index 528fdd9d7..23f416481 100644
--- a/crates/engine/src/al_drag_tests.rs
+++ b/crates/engine/src/al_drag_tests.rs
@@ -69,3 +69,56 @@ fn grid_drag_takes_the_cell_under_the_pointer() {
let cl = s.doc().unwrap().node(kids[0]).unwrap().child_layout.clone();
assert_eq!((cl.grid_column, cl.grid_row), (None, None), "one undo step");
}
+
+/// Three 60 x 60 rectangles in a row (or column), gap 20, at 0, 80 and 160 along the flow.
+fn three(s: &mut Session, mode: &str) -> (NodeId, [NodeId; 3]) {
+ let f = id(s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 300, "height": 300})).unwrap());
+ let kids: Vec =
+ (0..3).map(|_| id(s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 60, "height": 60, "parent": f})).unwrap())).collect();
+ s.execute("layout.add", &json!({"ids": [f]})).unwrap();
+ s.execute("node.set", &json!({"id": f, "props": {"layoutMode": mode, "gap": 20, "padding": 0, "sizingH": "fixed", "sizingV": "fixed"}})).unwrap();
+ s.execute("select.none", &json!({})).unwrap();
+ (f, [kids[0], kids[1], kids[2]])
+}
+
+/// The order after dragging kid `which` by `by` along the flow, grabbed `grab` from its start edge.
+fn dragged_order(mode: &str, which: usize, grab: f64, by: f64) -> Vec {
+ let mut s = Session::new();
+ let (f, kids) = three(&mut s, mode);
+ let vertical = mode == "vertical";
+ let b = s.doc().unwrap().world_bounds(kids[which]).unwrap();
+ let from = if vertical { (b.center().x, b.y0 + grab) } else { (b.x0 + grab, b.center().y) };
+ let to = if vertical { (from.0, from.1 + by) } else { (from.0 + by, from.1) };
+ drag(&mut s, from, to);
+ let order = s.doc().unwrap().node(f).unwrap().children.clone();
+ order.iter().map(|c| kids.iter().position(|k| k == c).unwrap()).collect()
+}
+
+#[test]
+fn a_child_dragged_right_passes_a_sibling_when_its_leading_edge_reaches_the_siblings_middle() {
+ assert_eq!(dragged_order("horizontal", 0, 30.0, 49.0), vec![0, 1, 2]);
+ assert_eq!(dragged_order("horizontal", 0, 30.0, 50.0), vec![1, 0, 2]);
+ assert_eq!(dragged_order("horizontal", 0, 30.0, 129.0), vec![1, 0, 2]);
+ assert_eq!(dragged_order("horizontal", 0, 30.0, 130.0), vec![1, 2, 0]);
+}
+
+#[test]
+fn where_the_child_is_grabbed_does_not_change_its_slot() {
+ assert_eq!(dragged_order("horizontal", 0, 10.0, 49.0), vec![0, 1, 2]);
+ assert_eq!(dragged_order("horizontal", 0, 10.0, 50.0), vec![1, 0, 2]);
+ assert_eq!(dragged_order("horizontal", 0, 10.0, 129.0), vec![1, 0, 2]);
+ assert_eq!(dragged_order("horizontal", 0, 10.0, 130.0), vec![1, 2, 0]);
+}
+
+#[test]
+fn a_child_dragged_left_passes_a_sibling_by_its_left_edge() {
+ assert_eq!(dragged_order("horizontal", 2, 30.0, -49.0), vec![0, 1, 2]);
+ assert_eq!(dragged_order("horizontal", 2, 30.0, -50.0), vec![0, 2, 1]);
+ assert_eq!(dragged_order("horizontal", 2, 30.0, -130.0), vec![2, 0, 1]);
+}
+
+#[test]
+fn a_column_reorders_by_the_bottom_edge() {
+ assert_eq!(dragged_order("vertical", 0, 30.0, 50.0), vec![1, 0, 2]);
+ assert_eq!(dragged_order("vertical", 0, 30.0, 130.0), vec![1, 2, 0]);
+}
diff --git a/crates/engine/src/canvas/drop.rs b/crates/engine/src/canvas/drop.rs
index e46734ac9..7bad66ac3 100644
--- a/crates/engine/src/canvas/drop.rs
+++ b/crates/engine/src/canvas/drop.rs
@@ -20,6 +20,31 @@ pub struct DropSlot {
/// placed child whose centre is past the pointer, and the line to draw there. Hidden children and
/// those ignoring auto layout don't take part.
pub fn auto_layout_slot(doc: &Document, parent: NodeId, p: Point, exclude: &[NodeId]) -> Option<(usize, Option<[f64; 4]>)> {
+ slot_in(doc, parent, p, exclude, None)
+}
+
+/// [`auto_layout_slot`] for layers dragged within their own frame (measured in Figma's web app,
+/// 2026-11-09): the slot follows the leading edge of `moved`, the box the layers would fill under
+/// the pointer, in the direction of `delta` (the drag along the page). Wrapping layouts keep the
+/// pointer rule.
+pub fn auto_layout_slot_within(
+ doc: &Document,
+ parent: NodeId,
+ p: Point,
+ exclude: &[NodeId],
+ moved: figcraft_geom::Rect,
+ delta: Point,
+) -> Option<(usize, Option<[f64; 4]>)> {
+ slot_in(doc, parent, p, exclude, Some((moved, delta)))
+}
+
+fn slot_in(
+ doc: &Document,
+ parent: NodeId,
+ p: Point,
+ exclude: &[NodeId],
+ dragged: Option<(figcraft_geom::Rect, Point)>,
+) -> Option<(usize, Option<[f64; 4]>)> {
let pn = doc.node(parent)?;
let layout = pn.layout.as_ref()?;
let vertical = layout.mode == LayoutMode::Vertical;
@@ -34,11 +59,16 @@ pub fn auto_layout_slot(doc: &Document, parent: NodeId, p: Point, exclude: &[Nod
let (k, line) = wrap_slot(doc, &flow_ids, p, vertical);
return Some((index(k), line));
}
- let pos = if vertical { p.y } else { p.x };
- let k = flow_ids
- .iter()
- .position(|c| doc.world_bounds(*c).is_some_and(|b| pos < if vertical { b.center().y } else { b.center().x }))
- .unwrap_or(flow_ids.len());
+ let mid = |c: &NodeId| doc.world_bounds(*c).map(|b| if vertical { b.center().y } else { b.center().x });
+ let k = if let Some((r, d)) = dragged {
+ let (lo, hi, dd) = if vertical { (r.y0, r.y1, d.y) } else { (r.x0, r.x1, d.x) };
+ // Forward: past every sibling whose middle the trailing edge has reached; backward: past
+ // every one whose middle lies before the leading (low) edge.
+ flow_ids.iter().position(|c| mid(c).is_some_and(|m| if dd > 0.0 { m > hi } else { m >= lo })).unwrap_or(flow_ids.len())
+ } else {
+ let pos = if vertical { p.y } else { p.x };
+ flow_ids.iter().position(|c| mid(c).is_some_and(|m| pos < m)).unwrap_or(flow_ids.len())
+ };
let line = flow_ids.get(k).and_then(|c| doc.world_bounds(*c)).map(|b| line_before(b, vertical, 2.0)).or_else(|| {
flow_ids
.last()
diff --git a/crates/engine/src/canvas/tools/drag.rs b/crates/engine/src/canvas/tools/drag.rs
index 64d48bc86..94f0e654e 100644
--- a/crates/engine/src/canvas/tools/drag.rs
+++ b/crates/engine/src/canvas/tools/drag.rs
@@ -216,6 +216,8 @@ impl Session {
}
}
// Cmd/Ctrl (and Control on macOS) held while dragging suspends snapping.
+ // The pointer's own drag, before snapping: a reorder in auto layout follows it.
+ let raw = d;
let snap_on = self.ds().map(|ds| ds.view.snap_objects).unwrap_or(true) && !ev.cmd && !ev.ctrl;
let mut guides = Vec::new();
if self.ds().map(|ds| ds.view.snap_pixel).unwrap_or(true) {
@@ -268,8 +270,18 @@ impl Session {
// cell) under the pointer, as the frame shows it now. A frame other than the
// one they come from is outlined too.
let cell = if mode == LayoutMode::Grid { crate::drop::grid_cell_at(doc, target, p) } else { None };
- let idx =
- if mode == LayoutMode::Grid { 0 } else { crate::drop::auto_layout_slot(doc, target, p, &moving).map_or(0, |(i, _)| i) };
+ // Within the frame they came from, the slot follows the leading edge of the
+ // box they would fill (their combined start bounds moved by the drag), not
+ // the pointer. The preview starts from the document as the drag began.
+ let within = moving.iter().all(|m| doc.node(*m).is_some_and(|n| n.parent == Some(target)));
+ let idx = if mode == LayoutMode::Grid {
+ 0
+ } else if let (true, Some(b)) = (within, doc.bounds_of(&moving)) {
+ let moved = Rect::new(b.x0 + raw.x, b.y0 + raw.y, b.x1 + raw.x, b.y1 + raw.y);
+ crate::drop::auto_layout_slot_within(doc, target, p, &moving, moved, Point::new(raw.x, raw.y)).map_or(0, |(i, _)| i)
+ } else {
+ crate::drop::auto_layout_slot(doc, target, p, &moving).map_or(0, |(i, _)| i)
+ };
for m in &moving {
if doc.get(*m)?.parent != Some(target) {
doc.reparent(*m, target, None)?;
diff --git a/docs/auto-layout-parity.md b/docs/auto-layout-parity.md
index f60cd4350..16776ca5e 100644
--- a/docs/auto-layout-parity.md
+++ b/docs/auto-layout-parity.md
@@ -1,6 +1,6 @@
# Auto layout and constraints parity
-> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-11-03 · **Change:** patch (a Cmd/Ctrl-click on a padding field sets all four sides with the value typed, as the Guide to auto layout's shortcut guide says; Suggest auto layout is its own command, as the help center says: it adds as many auto layout frames as needed, splitting a frame's layers into rows or columns by the gaps between them, and keeps the placement of the design where the gaps are even (#1823); baseline alignment puts a layer without text with its bottom on the line, as the help center's figure shows an icon next to text (#1825); fill children share the free space by their content area, border-box fashion, as the Flexbox article says (#1824); double-click an edge of an auto layout frame's box for Hug contents, Alt+double-click an edge of a child's box for Fill container, as the Guide to auto layout says (#1838); a child resized to the full available space of its auto layout frame becomes Fill container on that axis, as the Guide to auto layout says; a text keeps a max height or a number of max lines, not both, as the Guide to auto layout says: a max height sets max lines to Auto and max lines remove the max height; Remove all auto layout, as the help center's More layout options has it: `layout.removeAll` takes the auto layout off a frame and every frame nested in it in one undo step; grid auto-placement looks for free cells a 64-bit word at a time and skips what blocks it, with the same placements (#1083); Layout sidebar: Add auto layout (+) is available for groups and converts a single group to a frame in place, with mask order, flow-only inference and Fill sizing preserved; adding auto layout to an empty frame keeps its size (#369); `node.set` applies auto layout keys only to frames, with warnings (#603); core editor leftovers: auto gap handles, wrapped-line alignment; behaviour suite written from the public documentation, every rule's status; fixes and the missing settings it found) · **Target:** Figma Design (desktop app 126.10.7)
+> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-11-09 · **Change:** patch (a child dragged within its own auto layout row or column passes a sibling when its leading edge reaches the sibling's middle, as measured in Figma's web app, wherever it is grabbed; a Cmd/Ctrl-click on a padding field sets all four sides with the value typed, as the Guide to auto layout's shortcut guide says; Suggest auto layout is its own command, as the help center says: it adds as many auto layout frames as needed, splitting a frame's layers into rows or columns by the gaps between them, and keeps the placement of the design where the gaps are even (#1823); baseline alignment puts a layer without text with its bottom on the line, as the help center's figure shows an icon next to text (#1825); fill children share the free space by their content area, border-box fashion, as the Flexbox article says (#1824); double-click an edge of an auto layout frame's box for Hug contents, Alt+double-click an edge of a child's box for Fill container, as the Guide to auto layout says (#1838); a child resized to the full available space of its auto layout frame becomes Fill container on that axis, as the Guide to auto layout says; a text keeps a max height or a number of max lines, not both, as the Guide to auto layout says: a max height sets max lines to Auto and max lines remove the max height; Remove all auto layout, as the help center's More layout options has it: `layout.removeAll` takes the auto layout off a frame and every frame nested in it in one undo step; grid auto-placement looks for free cells a 64-bit word at a time and skips what blocks it, with the same placements (#1083); Layout sidebar: Add auto layout (+) is available for groups and converts a single group to a frame in place, with mask order, flow-only inference and Fill sizing preserved; adding auto layout to an empty frame keeps its size (#369); `node.set` applies auto layout keys only to frames, with warnings (#603); core editor leftovers: auto gap handles, wrapped-line alignment; behaviour suite written from the public documentation, every rule's status; fixes and the missing settings it found) · **Target:** Figma Design (desktop app 126.10.7)
Auto layout is the most used layout feature in Figma and the one where small differences (a
hugging frame one pixel off, text wrapping at another width) break real files. **Breadth ~98%,
@@ -88,7 +88,7 @@ Status: **pass** (a case in the suite holds), **fixed** (the suite found a diver
| Absolute position (ignore auto layout) with constraints | done | |
| Strokes included in layout (inside strokes), canvas stacking | done | |
| On-canvas padding and gap handles, incl. wrap row/column gap and grid gaps | done | an automatic gap's handle shows the distance it has; dragging it fixes the gap from there (silent in the documentation) |
-| Drag to reorder (wrap-aware, by row or column), into grid cells, across nested frames | done | `al_drag_tests.rs` |
+| Drag to reorder (wrap-aware, by row or column), into grid cells, across nested frames | done | `al_drag_tests.rs`. A child dragged within its own row or column takes its slot by the dragged box's leading edge, not the pointer: it passes a sibling when the edge facing the drag reaches the sibling's middle (right or bottom edge going forward, left or top edge going back), wherever it was grabbed. Measured in Figma's web app, 2026-11-09 (three 60 x 60 rectangles, gap 20: after B at 50, after C at 130; the mirror for a leftward drag; the same along y). Layers dragged in from outside and wrapping layouts keep the pointer rule (unmeasured). Tests: `a_child_dragged_right_passes_a_sibling_when_its_leading_edge_reaches_the_siblings_middle`, `where_the_child_is_grabbed_does_not_change_its_slot`, `a_child_dragged_left_passes_a_sibling_by_its_left_edge`, `a_column_reorders_by_the_bottom_edge` |
| Arrow keys move children one place (flows, rows, columns, grid cells, into neighbours) | done | `al_keys_tests.rs` |
| Constraints: left, right, left and right, centre, scale; top/bottom likewise | done | |
| Layout grids: columns, rows, grid; stretch/left/centre/right; variables bound | done | |
@@ -104,6 +104,7 @@ Status: **pass** (a case in the suite holds), **fixed** (the suite found a diver
| 2026-11-05 | patch | M2, #1823: Suggest auto layout is no longer Add auto layout under another name: `layout.suggest` splits a frame's layers into rows (Vertical) or else columns (Horizontal) by the gaps between their boxes, wraps each band of several layers in a new frame without fill or clipping that hugs it, and repeats inside it (16 levels, 1,000 layers per frame at most). The frame keeps its size; gap and padding come from the bands, so layers keep their boxes where the gaps are even and the bands start at the same offset; instances and layers inside them are refused; uneven gaps (smallest used), bands that start at different offsets across the flow (lined up at the smallest) and layers that overlap along both axes come back as `warnings`. A band frame hugs only when its own split succeeds: a band whose layers overlap along both axes gets no auto layout and stays Fixed, so it cannot reflow those children. `direction` sets the top level only; one undo step; `layout.add` is unchanged. Tests in `layout_suggest_tests.rs` |
| 2026-11-02 | patch | M2: a Cmd/Ctrl-click on a padding field (the horizontal or vertical pair, or a side with Individual padding open) makes the value typed go to all four sides in one undo step (Guide to auto layout, Keyboard shortcut guide); a plain click still edits that axis or side alone. The modifier was ignored before. Test: `padding_individual_tests` |
| 2026-11-03 | patch | M2, #1825: baseline alignment of a layer without text (an icon next to a text) puts its bottom on the line's baseline, as the help center's figure shows ("the bottoms of the icon and the word home are aligned on the red line"); it kept to the top of the line. A layer reaching below the text's baseline moves the text down, and a line with no text lines the layers' bottoms up (the documentation is silent; CSS baseline alignment does the same). Tests: `alignment_rules` (three cases), engine `baseline_alignment_puts_an_icons_bottom_on_the_text_baseline`. Numbers unchanged. |
+| 2026-11-09 | patch | M2: a child dragged within its own auto layout row or column takes its slot by the dragged box's leading edge instead of the pointer, as measured in Figma's web app on 2026-11-09: it passes a sibling when the edge facing the drag reaches the sibling's middle, so the result no longer depends on where the child was grabbed. Several moving layers use their combined box. Dragging in from outside, wrapping layouts and grids are unchanged. Tests in `al_drag_tests.rs` |
| 2026-11-02 | patch | M2, #1824: fill container children share the free space by their content area: each one's own insets (padding and an included inside stroke, from the same `insets` the frame uses) come off the free space before the equal split and go back on its share, so a child with more padding or a thicker inside stroke takes a little more and the content areas match ("Figma distributes space amongst fill container children by the children's content area instead of by their size, matching the CSS border-box model", Use auto layout with CSS Flexbox in mind). Min and max still clamp the outer sizes and the frozen-share loop is as it was; children without insets split as before. Test: `nested_tests::fill_children_share_by_content_area` |
| 2026-11-02 | patch | M2, #1838: a double-click on an edge of an auto layout frame's bounding box sets Hug contents on that axis, Alt+double-click on an edge of a child's Fill container ("Double-click the vertical or horizontal edge of an auto layout object's bounding box to set it to Hug contents", "Hold ⌥ Option or Alt and double-click ... to set it to Fill container", Guide to auto layout); a vertical edge sizes the width, a horizontal one the height; the Keyboard shortcuts panel lists both gestures. Test: `double_click_tests::a_double_click_on_an_auto_layout_edge_hugs_and_with_alt_fills`. Numbers unchanged. |
| 2026-11-02 | patch | M2: a child of an auto layout frame resized to the full space available to it on an axis (the frame's size less its padding: across the flow for any child, along the flow for a lone child) becomes Fill container on that axis, whether by `transform.resize`, the W/H fields or a canvas handle ("Child objects of an auto layout frame will also be set to Fill container if they are manually resized to the full available space of the parent frame", Guide to auto layout). Any other size keeps the axis Fixed as before; beside siblings along the flow, in a hugging or wrapping frame, in a grid and for a layer that ignores the layout nothing changes. Test: `al_behaviour_tests::a_child_resized_to_the_full_available_space_becomes_fill` |
From 92145e6f8da614aae4ea48f4fade36086accb7f5 Mon Sep 17 00:00:00 2001
From: Trevor Brown
Date: Mon, 9 Nov 2026 02:59:53 -0500
Subject: [PATCH 20/51] M8: K focuses the Scale field, so K, 2, Enter scales by
two (#2146)
The Scale tool now opens its factor field focused with its text selected (once per activation), the origin starts at the centre, and Enter or Escape in the field returns the tool to Move with the selection kept. Measured in Figma web, 2026-11-09. Docs: ui-parity Scale tool row and revision.
Co-Authored-By: Claude Sonnet 5.5
Model-Effort: Opus 5.5 drove and briefed; Sonnet 5.5 measured and wrote at medium
---
crates/ui-egui/src/field_tests.rs | 63 ++++++++++++++++++++++++
crates/ui-egui/src/panels/right.rs | 2 +
crates/ui-egui/src/panels/scale_panel.rs | 33 +++++++++++--
crates/ui-egui/src/widgets.rs | 9 +++-
docs/ui-parity.md | 5 +-
5 files changed, 106 insertions(+), 6 deletions(-)
diff --git a/crates/ui-egui/src/field_tests.rs b/crates/ui-egui/src/field_tests.rs
index e3bdad1dd..a0f5f24d6 100644
--- a/crates/ui-egui/src/field_tests.rs
+++ b/crates/ui-egui/src/field_tests.rs
@@ -737,6 +737,69 @@ fn scrubbing_the_scale_panel_width_scales_live_in_one_undo_step() {
assert_eq!((w, h, undo_steps(&u)), (60.0, 45.0, steps + 1), "40×30 scaled to a width of 60");
}
+/// A 100 x 100 rectangle at the origin, selected, in a Ui with the Move tool.
+fn scale_setup() -> (Ui, NodeId) {
+ let mut s = Session::new();
+ let r = id(s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 100, "height": 100})).unwrap());
+ s.execute("select.set", &json!({"ids": [r]})).unwrap();
+ let mut u = Ui::new(s);
+ u.frame(vec![]);
+ u.frame(vec![]);
+ (u, r)
+}
+
+fn scale_field_focused(u: &Ui) -> bool {
+ u.ctx.memory(|m| m.has_focus(Id::unique("scale_k").with("te")))
+}
+
+/// Measured in Figma's web app (2026-11-09): K opens the Scale section with its input focused.
+#[test]
+fn pressing_k_focuses_the_scale_field_with_its_text_selected() {
+ let (mut u, _) = scale_setup();
+ assert!(!scale_field_focused(&u));
+ key(&mut u, Key::K, Modifiers::NONE);
+ u.frame(vec![]);
+ assert_eq!(u.app.session.tool.tool, figcraft_engine::ToolId::Scale);
+ assert!(scale_field_focused(&u), "the scale field has the keyboard");
+ let state = egui::TextEdit::load_state(&u.ctx, Id::unique("scale_k").with("te")).expect("the editor exists");
+ let range = state.cursor.char_range().expect("a selection");
+ assert_ne!(range.primary.index, range.secondary.index, "its text is selected");
+}
+
+/// K, 2, Enter: 100 x 100 becomes 200 x 200 about the centre, the tool is Move, one undo step.
+#[test]
+fn k_two_enter_scales_the_selection_by_two_about_its_centre() {
+ let (mut u, r) = scale_setup();
+ let steps = undo_steps(&u);
+ key(&mut u, Key::K, Modifiers::NONE);
+ u.frame(vec![]);
+ u.frame(vec![Event::Text("2".to_string())]);
+ key(&mut u, Key::Enter, Modifiers::NONE);
+ u.frame(vec![]);
+ assert_eq!(geom(&u, r), (-50.0, -50.0, 200.0, 200.0));
+ assert_eq!(u.app.session.tool.tool, figcraft_engine::ToolId::Move);
+ assert_eq!(u.app.session.ds().unwrap().selection, vec![r]);
+ assert_eq!(undo_steps(&u), steps + 1);
+ u.app.run("edit.undo", json!({}));
+ assert_eq!(geom(&u, r), (0.0, 0.0, 100.0, 100.0));
+}
+
+/// Escape in the scale field closes it and changes nothing.
+#[test]
+fn escape_in_the_scale_field_closes_it_and_changes_nothing() {
+ let (mut u, r) = scale_setup();
+ let steps = undo_steps(&u);
+ key(&mut u, Key::K, Modifiers::NONE);
+ u.frame(vec![]);
+ u.frame(vec![Event::Text("3".to_string())]);
+ key(&mut u, Key::Escape, Modifiers::NONE);
+ u.frame(vec![]);
+ assert_eq!(geom(&u, r), (0.0, 0.0, 100.0, 100.0));
+ assert_eq!(u.app.session.tool.tool, figcraft_engine::ToolId::Move);
+ assert_eq!(u.app.session.ds().unwrap().selection, vec![r]);
+ assert_eq!(undo_steps(&u), steps);
+}
+
/// The texts painted in the last frame: what they say, whether they end in an ellipsis, and where
/// they show (within their clip).
fn painted_texts(shapes: &[egui::epaint::ClippedShape]) -> Vec<(String, bool, egui::Rect)> {
diff --git a/crates/ui-egui/src/panels/right.rs b/crates/ui-egui/src/panels/right.rs
index bba57c778..5c190576f 100644
--- a/crates/ui-egui/src/panels/right.rs
+++ b/crates/ui-egui/src/panels/right.rs
@@ -281,6 +281,8 @@ fn design(app: &mut FigApp, ui: &mut Ui) {
if app.session.tool.tool == figcraft_engine::ToolId::Scale {
crate::scale_panel::section(app, ui, &shown, &sel);
widgets::separator(ui);
+ } else {
+ crate::scale_panel::closed(ui.ctx());
}
position(app, ui, &doc, &shown, &sel);
}
diff --git a/crates/ui-egui/src/panels/scale_panel.rs b/crates/ui-egui/src/panels/scale_panel.rs
index fd3a100e8..d86355c25 100644
--- a/crates/ui-egui/src/panels/scale_panel.rs
+++ b/crates/ui-egui/src/panels/scale_panel.rs
@@ -17,6 +17,27 @@ use crate::widgets::{self, FieldEdit, Lead};
pub fn section(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) {
let Some(b) = doc.bounds_of(sel) else { return };
widgets::section_header(ui, tr("Scale"), &[], false);
+ // The origin starts at the centre, as Figma's grid does: set once per app, so a point the
+ // user picked later stays.
+ let anchor_init = Id::unique("scale_anchor_init");
+ if !ui.data(|d| d.get_temp::(anchor_init)).unwrap_or(false) {
+ ui.data_mut(|d| d.insert_temp(anchor_init, true));
+ app.run("tool.scaleAnchor", json!({"anchor": "c"}));
+ }
+ // The factor field takes the keyboard once, with its text selected, when the tool opens.
+ let k_id = Id::unique("scale_k");
+ let opened = Id::unique("scale_opened");
+ if !ui.data(|d| d.get_temp::(opened)).unwrap_or(false) {
+ ui.data_mut(|d| d.insert_temp(opened, true));
+ widgets::request_edit(ui, k_id);
+ }
+ // Enter or Escape in the factor field ends the tool (back to Move, the selection kept).
+ // (Focus as it was last frame: the shortcut layer may already have let go of it this frame.)
+ let in_k_id = Id::unique("scale_in_k");
+ let in_k = ui.data(|d| d.get_temp::(in_k_id)).unwrap_or(false);
+ let focused_now = ui.memory(|m| m.has_focus(k_id.with("te")));
+ ui.data_mut(|d| d.insert_temp(in_k_id, focused_now));
+ let (enter, esc) = ui.input(|i| (i.key_pressed(egui::Key::Enter), i.key_pressed(egui::Key::Escape)));
let col = widgets::col_width(ui.available_width());
// Anchor: column and row (0 start, 1 middle, 2 end) of the point that stays put.
let anchor = app.session.tool.scale_anchor;
@@ -41,9 +62,7 @@ pub fn section(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) {
widgets::sublabels(ui, tr("Scale"), Some(tr("Anchor")));
crate::right::block(ui, 52.0, 2.0, |ui| {
// Relative to the layers as they are: a scrub applies once, when let go.
- if let Some(FieldEdit::Set(k) | FieldEdit::Scrub(k, true)) =
- widgets::number_field(ui, Id::unique("scale_k"), Lead::None, Some(1.0), "x", col, 0.1)
- {
+ if let Some(FieldEdit::Set(k) | FieldEdit::Scrub(k, true)) = widgets::number_field(ui, k_id, Lead::None, Some(1.0), "x", col, 0.1) {
factor = Some(k);
}
ui.add_space(8.0);
@@ -70,6 +89,14 @@ pub fn section(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) {
}
}
}
+ if in_k && (enter || esc) {
+ app.run("tool.set", json!({"tool": "move"}));
+ }
+}
+
+/// Forget that the section opened, so the next time the Scale tool opens it takes the keyboard.
+pub fn closed(ctx: &egui::Context) {
+ ctx.data_mut(|d| d.remove::(Id::unique("scale_opened")));
}
/// The factor (clamped to 0.01–100) and pivot for scaling `b` by `k` around `anchor`; None when
diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs
index eae46fd81..b25ed54b9 100644
--- a/crates/ui-egui/src/widgets.rs
+++ b/crates/ui-egui/src/widgets.rs
@@ -223,6 +223,12 @@ pub fn number_field(ui: &mut Ui, id: Id, lead: Lead, value: Option, suffix:
number_field_mixed(ui, id, lead, value, suffix, width, step)?.settled()
}
+/// Ask number field `id` to open for typing on its next frame, its text selected (a tool that
+/// opens with its field focused, such as Scale).
+pub fn request_edit(ui: &Ui, id: Id) {
+ ui.data_mut(|d| d.insert_temp(id.with("beginedit"), true));
+}
+
/// Whether the edit of number field `id` began with a Cmd/Ctrl-click. A padding field edited
/// that way sets every side (Guide to auto layout, Keyboard shortcut guide).
pub fn edit_began_with_command(ui: &Ui, id: Id) -> bool {
@@ -400,7 +406,8 @@ fn number_field_impl(
}
// Typing on a field the keyboard reached (Tab) edits it: the editor takes the typed text.
let typed_now = editing.is_none() && on_field && ui.input(|i| i.events.iter().any(|e| matches!(e, egui::Event::Text(_))));
- let started = editing.is_none() && ((field_resp.clicked() && !alt_now) || typed_now);
+ let requested = ui.data_mut(|d| d.remove_temp::(id.with("beginedit"))).unwrap_or(false);
+ let started = editing.is_none() && ((field_resp.clicked() && !alt_now) || typed_now || requested);
// The text the field would commit to no effect: what it showed when the edit began, or the
// value the arrows last stepped it to.
let kept_id = id.with("kept");
diff --git a/docs/ui-parity.md b/docs/ui-parity.md
index 463be3b47..9eedb74db 100644
--- a/docs/ui-parity.md
+++ b/docs/ui-parity.md
@@ -1,6 +1,6 @@
# UI and interaction parity
-> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-04 · **Change:** minor (the left rail's and tab strip's icons are larger, 20 pt (#553); pick select: Tab while the keyboard selection box shows (or the chord again) puts a pink outline on a layer and the keys walk the layer tree from it, as Accessibility at Figma says (#1836); scrubbing a field faster or slower by the pointer's height, with a notice of the speed, as the help center's Scrub fields says (#1837); component rows in the Layers panel at AA contrast in the theme's component tint, with the rows inside a selected component in a tone of its row (#1903); Cmd/Ctrl-click on a padding field sets all four sides with the value typed, a documented auto layout shortcut; a fill per region of a vector: with regions selected in edit mode, the Fill section and the fill commands edit those regions' own fills, as Edit vector layers says (#1875); the Fill section of a text whose characters were coloured differently while editing shows mixed content, characters that all share fills the layer doesn't have show those, and a layer-level fill edit reaches every character (#1856); a number field scrubs when dragged with Alt held, as the help center's scrub section says, not only by its label; reuse visible layer selection roots (M8); ; with a shape or frame tool chosen and nothing selected, the arrows show a placement crosshair in the middle of the view and move it, and Enter places there, as Accessibility at Figma says (#1839); text direction per paragraph: Text ▸ Text direction forces a paragraph left to right or right to left, as Add right-to-left text says (#1830), with `text.direction`, `node.set textDirection`, the Type settings control once a right-to-left script is in the text, inspect, the CSS `direction` and the `.fig` lines' `sourceDirectionality`; the boolean operation menu in the Design panel leads each entry with an original icon drawn in code, a filled preview of the result (#2003); a press on the open gradient's handles or stops on the canvas keeps the colour picker open through the drag, as its own control (#1852); a click with the Bend tool on a sharp point curves it, its handles along the line between its neighbours, as Edit vector layers says (#1875); wrap style: Auto, Balance and Pretty on a text layer decide where its lines break, as Explore text properties says (#1827), with the Type settings control, `text.wrapStyle`, `node.set wrapStyle`, inspect and the CSS `text-wrap`; Backspace or Delete at the start of a list item keeps the item's indentation level, as the lists article says; max lines on auto-width text keep it auto width, hugging that many lines, as the text properties article says; superscript and subscript are synthesised where the font has no glyphs for them, from the standard glyphs shrunk and shifted by the font's OS/2 metrics, and a run mixing available and unavailable glyphs is synthesised whole, as Explore text properties says (#1828); Alt+Enter while editing text opens the link at the caret, as the accessibility page documents; Minimize, by its shortcut or the button by the file name, collapses the navigation bar and both sidebars as the help center says, with the right sidebar back while something is selected (#1835); the interface size keys of the help center's accessibility page: Cmd/Ctrl+Shift+Alt with =, - and 0 step the interface through the Preferences sizes and back to the system's size; Cmd/Ctrl+Alt+arrows rotate the selection a degree at a time, 15 with Shift, as the help center's accessibility page says; they resized before; the Layers panel answers Ctrl+1 as well as Alt+1 on Windows and Linux, the two keys the help center gives it; 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 keyboard's Menu key opens the context menu, as Shift+F10 does; the eyedropper with Cmd/Ctrl+Shift makes a colour style or variable of what it picks; sidebar number and text fields keep their text vertically centered when focused, including typography spacing; 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); 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); Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); colour picker review fixes for text undo, fitted sidebars, moving controls and gradient variables; Corner smoothing: an original icon and the shared number-field drag gesture, preserving fractional baselines; guides snap to layers when dragged; Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); the Design panel's sections on one grid — 16 / 8 margins, 88 + 8 + 88 + 8 + 24 columns, 24 pt rows, 4 pt between a row's buttons (#858); the toolbar on one 32 pt grid, without its Resources button (#1210); colour picker LCH and OKLCH models; an XML (Android Vector Drawable) export format; Context menus: the Menu key reaches egui 0.37 but is unbound (row done → partial); the Text on a path tool; board tool keys pinned; community fixes: the text box by dragging, the font picker keys, the pen path on leaving Design, a still canvas in vector edit mode, a scrolling pages list, colour emoji, readable new text in frames, system theme on first run; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; a rename selects the whole name, so typing replaces it; Escape or Enter after a pen path stays on the same vector (#760); IME composition kept on through idle frames of a text edit, candidates at the caret (#747); Selection colors for mixed selections and smart-selection spacing (#753); submenus with a hover delay and a safe triangle; the Actions palette's tabs, ranked search, highlight and recents; menu items that ask for a value; editor fixes from the community batches) · **Target:** Figma Design UI3 (desktop app 126.10.7)
+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-09 · **Change:** minor (M8, #2146: K focuses the Scale tool's factor field with its text selected, so K, 2, Enter scales the selection by two about the origin grid's centre (the default) in one undo step, after which the tool returns to Move with the selection kept; Escape in the field closes it with the tool back on Move and nothing changed (measured in Figma's web app, 2026-11-09; `pressing_k_focuses_the_scale_field_with_its_text_selected`, `k_two_enter_scales_the_selection_by_two_about_its_centre`, `escape_in_the_scale_field_closes_it_and_changes_nothing`); the left rail's and tab strip's icons are larger, 20 pt (#553); pick select: Tab while the keyboard selection box shows (or the chord again) puts a pink outline on a layer and the keys walk the layer tree from it, as Accessibility at Figma says (#1836); scrubbing a field faster or slower by the pointer's height, with a notice of the speed, as the help center's Scrub fields says (#1837); component rows in the Layers panel at AA contrast in the theme's component tint, with the rows inside a selected component in a tone of its row (#1903); Cmd/Ctrl-click on a padding field sets all four sides with the value typed, a documented auto layout shortcut; a fill per region of a vector: with regions selected in edit mode, the Fill section and the fill commands edit those regions' own fills, as Edit vector layers says (#1875); the Fill section of a text whose characters were coloured differently while editing shows mixed content, characters that all share fills the layer doesn't have show those, and a layer-level fill edit reaches every character (#1856); a number field scrubs when dragged with Alt held, as the help center's scrub section says, not only by its label; reuse visible layer selection roots (M8); ; with a shape or frame tool chosen and nothing selected, the arrows show a placement crosshair in the middle of the view and move it, and Enter places there, as Accessibility at Figma says (#1839); text direction per paragraph: Text ▸ Text direction forces a paragraph left to right or right to left, as Add right-to-left text says (#1830), with `text.direction`, `node.set textDirection`, the Type settings control once a right-to-left script is in the text, inspect, the CSS `direction` and the `.fig` lines' `sourceDirectionality`; the boolean operation menu in the Design panel leads each entry with an original icon drawn in code, a filled preview of the result (#2003); a press on the open gradient's handles or stops on the canvas keeps the colour picker open through the drag, as its own control (#1852); a click with the Bend tool on a sharp point curves it, its handles along the line between its neighbours, as Edit vector layers says (#1875); wrap style: Auto, Balance and Pretty on a text layer decide where its lines break, as Explore text properties says (#1827), with the Type settings control, `text.wrapStyle`, `node.set wrapStyle`, inspect and the CSS `text-wrap`; Backspace or Delete at the start of a list item keeps the item's indentation level, as the lists article says; max lines on auto-width text keep it auto width, hugging that many lines, as the text properties article says; superscript and subscript are synthesised where the font has no glyphs for them, from the standard glyphs shrunk and shifted by the font's OS/2 metrics, and a run mixing available and unavailable glyphs is synthesised whole, as Explore text properties says (#1828); Alt+Enter while editing text opens the link at the caret, as the accessibility page documents; Minimize, by its shortcut or the button by the file name, collapses the navigation bar and both sidebars as the help center says, with the right sidebar back while something is selected (#1835); the interface size keys of the help center's accessibility page: Cmd/Ctrl+Shift+Alt with =, - and 0 step the interface through the Preferences sizes and back to the system's size; Cmd/Ctrl+Alt+arrows rotate the selection a degree at a time, 15 with Shift, as the help center's accessibility page says; they resized before; the Layers panel answers Ctrl+1 as well as Alt+1 on Windows and Linux, the two keys the help center gives it; 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 keyboard's Menu key opens the context menu, as Shift+F10 does; the eyedropper with Cmd/Ctrl+Shift makes a colour style or variable of what it picks; sidebar number and text fields keep their text vertically centered when focused, including typography spacing; 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); 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); Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); colour picker review fixes for text undo, fitted sidebars, moving controls and gradient variables; Corner smoothing: an original icon and the shared number-field drag gesture, preserving fractional baselines; guides snap to layers when dragged; Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); the Design panel's sections on one grid — 16 / 8 margins, 88 + 8 + 88 + 8 + 24 columns, 24 pt rows, 4 pt between a row's buttons (#858); the toolbar on one 32 pt grid, without its Resources button (#1210); colour picker LCH and OKLCH models; an XML (Android Vector Drawable) export format; Context menus: the Menu key reaches egui 0.37 but is unbound (row done → partial); the Text on a path tool; board tool keys pinned; community fixes: the text box by dragging, the font picker keys, the pen path on leaving Design, a still canvas in vector edit mode, a scrolling pages list, colour emoji, readable new text in frames, system theme on first run; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; a rename selects the whole name, so typing replaces it; Escape or Enter after a pen path stays on the same vector (#760); IME composition kept on through idle frames of a text edit, candidates at the caret (#747); Selection colors for mixed selections and smart-selection spacing (#753); submenus with a hover delay and a safe triangle; the Actions palette's tabs, ranked search, highlight and recents; menu items that ask for a value; editor fixes from the community batches) · **Target:** Figma Design UI3 (desktop app 126.10.7)
Fine canvas control is what Figma users feel first: handles, snapping, nudging, modifier keys,
drag behaviour, numeric precision. This checklist covers the tools, gestures and panels. Status:
@@ -67,7 +67,7 @@ interaction feel not yet compared.
| Enter with a shape or frame tool chosen | done | places what a click makes (100×100, lines 100 long, sections 400×300) in the middle of the view, selected (`tool.place`); from the toolbar, Enter on the chosen tool. With nothing selected, the arrows first show a crosshair in the middle of the view and then move it by the nudge amounts (`tool.crosshair`; Shift the large one), and Enter places there; Escape or a tool change takes it away ("Use the arrow keys to activate the crosshair cursor. It will appear in the middle of the viewport indicating where the new object will be placed", Accessibility at Figma; #1839) |
| Alt-drag duplicate | done | the copy snaps to the layer it came from too (edges, centres, equal spacing); Duplicate (Cmd+D) then repeats the copy's offset, also after a Duplicate whose copy was moved, as long as the copies stay selected (the documentation doesn't say; pinned from community answers) |
| Drag threshold; double-click into groups and text | done | a press moves or resizes only once the pointer has travelled 3 px on screen, so a click or double-click made with an unsteady hand drags nothing; a double-click goes one level into a group and the next one edits the text under it, wherever on the text it lands, with all of it selected (a double-click in the text being edited selects the word) (`double_click_tests`); Enter on a selected text does the same (all of it selected, typing replaces it) and Escape leaves editing with the layer still selected (`text_enter_tests`) |
-| Scale tool (K): handles about an anchor, live factor, panel | done | |
+| Scale tool (K): handles about an anchor, live factor, panel | done | Pressing K opens the Scale section with its factor field focused and its text selected; typing 2 (or 2x, 1.5) then Enter scales the selection by it about the origin grid's centre (the default), as one undo step, then closes the section and returns to Move with the selection kept; Escape in the field closes it, back to Move, nothing changed. Measured in Figma's web app, 2026-11-09. Tests: `pressing_k_focuses_the_scale_field_with_its_text_selected`, `k_two_enter_scales_the_selection_by_two_about_its_centre`, `escape_in_the_scale_field_closes_it_and_changes_nothing`. |
| Nudge 1 / Shift 10 (preference), Cmd+arrows resize | done | arrows reorder auto layout children |
| Smart guides: edges, centres, equal spacing (between two layers or continuing a row's gap, labelled), spacing labels, parent, layout grid, ruler guides | done | red, `snap.rs`; a guide dragged out of a ruler or moved snaps to layer edges and centres too (Snap to Objects; Cmd/Ctrl suspends), on its own axis only; page guides see the page's top-level layers, frame guides the frame's box, layout grids and every layer in it, along a rotated frame's own axes; thresholds not yet compared with Figma's (`guide_snap_tests`) |
| Ctrl to suspend snapping while dragging (macOS) | done | Control on macOS (Cmd keeps working), Ctrl elsewhere; moving, resizing and drawing |
@@ -271,4 +271,5 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look
| 2026-10-11 | minor | Canvas fixes: boolean groups clicked where their result is, a layer dragged inside a group stays in it, View ▸ Frame outlines and View ▸ Slices draw |
| 2026-10-11 | minor | Outline mode done: outlines for the selection (the selected layers draw as outlines, the rest normally) and mask outlines |
| 2026-10-11 | minor | Design panel arc fields, live boolean editing (operands on the canvas, live results, per-operand operations), shape builder on strokes and open paths, one-sided widths (Alt-drag a width handle) |
+| 2026-11-09 | patch | M8, #2146: K focuses the Scale tool's factor field with its text selected, so K, 2, Enter scales the selection by two about the origin grid's centre (the default) in one undo step, after which the tool returns to Move with the selection kept; Escape in the field closes it with the tool back on Move and nothing changed (measured in Figma's web app, 2026-11-09; `pressing_k_focuses_the_scale_field_with_its_text_selected`, `k_two_enter_scales_the_selection_by_two_about_its_centre`, `escape_in_the_scale_field_closes_it_and_changes_nothing`) |
| 2026-10-10 | major | Created from the ROADMAP's look-and-feel and interaction items, the keymap and the canvas code |
From 6856437479dfa29d8e224359eda3db13b3b91320 Mon Sep 17 00:00:00 2001
From: Trevor Brown
Date: Mon, 9 Nov 2026 03:03:26 -0500
Subject: [PATCH 21/51] M8: the Scale panel keeps FigCraft's origin; the centre
default is a follow-up (#2146)
The panel set the origin to the centre once per app. The origin is the Scale tool's drag pivot
too (scale_factor), so that also changed handle drags, unmeasured against Figma, while the
engine and its tests keep top-left. This PR keeps to the focus and the Enter and Escape
endings the issue asks for.
Co-Authored-By: Claude Opus 5.5
Model-Effort: Opus 5.5 drove and wrote
---
crates/ui-egui/src/field_tests.rs | 5 +++--
crates/ui-egui/src/panels/scale_panel.rs | 7 -------
docs/ui-parity.md | 6 +++---
3 files changed, 6 insertions(+), 12 deletions(-)
diff --git a/crates/ui-egui/src/field_tests.rs b/crates/ui-egui/src/field_tests.rs
index a0f5f24d6..dfa097db0 100644
--- a/crates/ui-egui/src/field_tests.rs
+++ b/crates/ui-egui/src/field_tests.rs
@@ -768,7 +768,7 @@ fn pressing_k_focuses_the_scale_field_with_its_text_selected() {
/// K, 2, Enter: 100 x 100 becomes 200 x 200 about the centre, the tool is Move, one undo step.
#[test]
-fn k_two_enter_scales_the_selection_by_two_about_its_centre() {
+fn k_two_enter_scales_the_selection_by_two() {
let (mut u, r) = scale_setup();
let steps = undo_steps(&u);
key(&mut u, Key::K, Modifiers::NONE);
@@ -776,7 +776,8 @@ fn k_two_enter_scales_the_selection_by_two_about_its_centre() {
u.frame(vec![Event::Text("2".to_string())]);
key(&mut u, Key::Enter, Modifiers::NONE);
u.frame(vec![]);
- assert_eq!(geom(&u, r), (-50.0, -50.0, 200.0, 200.0));
+ // About the panel's origin, FigCraft's top-left default (Figma's grid starts at the centre).
+ assert_eq!(geom(&u, r), (0.0, 0.0, 200.0, 200.0));
assert_eq!(u.app.session.tool.tool, figcraft_engine::ToolId::Move);
assert_eq!(u.app.session.ds().unwrap().selection, vec![r]);
assert_eq!(undo_steps(&u), steps + 1);
diff --git a/crates/ui-egui/src/panels/scale_panel.rs b/crates/ui-egui/src/panels/scale_panel.rs
index d86355c25..7ba0618c8 100644
--- a/crates/ui-egui/src/panels/scale_panel.rs
+++ b/crates/ui-egui/src/panels/scale_panel.rs
@@ -17,13 +17,6 @@ use crate::widgets::{self, FieldEdit, Lead};
pub fn section(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) {
let Some(b) = doc.bounds_of(sel) else { return };
widgets::section_header(ui, tr("Scale"), &[], false);
- // The origin starts at the centre, as Figma's grid does: set once per app, so a point the
- // user picked later stays.
- let anchor_init = Id::unique("scale_anchor_init");
- if !ui.data(|d| d.get_temp::(anchor_init)).unwrap_or(false) {
- ui.data_mut(|d| d.insert_temp(anchor_init, true));
- app.run("tool.scaleAnchor", json!({"anchor": "c"}));
- }
// The factor field takes the keyboard once, with its text selected, when the tool opens.
let k_id = Id::unique("scale_k");
let opened = Id::unique("scale_opened");
diff --git a/docs/ui-parity.md b/docs/ui-parity.md
index 9eedb74db..5a6614d2e 100644
--- a/docs/ui-parity.md
+++ b/docs/ui-parity.md
@@ -1,6 +1,6 @@
# UI and interaction parity
-> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-09 · **Change:** minor (M8, #2146: K focuses the Scale tool's factor field with its text selected, so K, 2, Enter scales the selection by two about the origin grid's centre (the default) in one undo step, after which the tool returns to Move with the selection kept; Escape in the field closes it with the tool back on Move and nothing changed (measured in Figma's web app, 2026-11-09; `pressing_k_focuses_the_scale_field_with_its_text_selected`, `k_two_enter_scales_the_selection_by_two_about_its_centre`, `escape_in_the_scale_field_closes_it_and_changes_nothing`); the left rail's and tab strip's icons are larger, 20 pt (#553); pick select: Tab while the keyboard selection box shows (or the chord again) puts a pink outline on a layer and the keys walk the layer tree from it, as Accessibility at Figma says (#1836); scrubbing a field faster or slower by the pointer's height, with a notice of the speed, as the help center's Scrub fields says (#1837); component rows in the Layers panel at AA contrast in the theme's component tint, with the rows inside a selected component in a tone of its row (#1903); Cmd/Ctrl-click on a padding field sets all four sides with the value typed, a documented auto layout shortcut; a fill per region of a vector: with regions selected in edit mode, the Fill section and the fill commands edit those regions' own fills, as Edit vector layers says (#1875); the Fill section of a text whose characters were coloured differently while editing shows mixed content, characters that all share fills the layer doesn't have show those, and a layer-level fill edit reaches every character (#1856); a number field scrubs when dragged with Alt held, as the help center's scrub section says, not only by its label; reuse visible layer selection roots (M8); ; with a shape or frame tool chosen and nothing selected, the arrows show a placement crosshair in the middle of the view and move it, and Enter places there, as Accessibility at Figma says (#1839); text direction per paragraph: Text ▸ Text direction forces a paragraph left to right or right to left, as Add right-to-left text says (#1830), with `text.direction`, `node.set textDirection`, the Type settings control once a right-to-left script is in the text, inspect, the CSS `direction` and the `.fig` lines' `sourceDirectionality`; the boolean operation menu in the Design panel leads each entry with an original icon drawn in code, a filled preview of the result (#2003); a press on the open gradient's handles or stops on the canvas keeps the colour picker open through the drag, as its own control (#1852); a click with the Bend tool on a sharp point curves it, its handles along the line between its neighbours, as Edit vector layers says (#1875); wrap style: Auto, Balance and Pretty on a text layer decide where its lines break, as Explore text properties says (#1827), with the Type settings control, `text.wrapStyle`, `node.set wrapStyle`, inspect and the CSS `text-wrap`; Backspace or Delete at the start of a list item keeps the item's indentation level, as the lists article says; max lines on auto-width text keep it auto width, hugging that many lines, as the text properties article says; superscript and subscript are synthesised where the font has no glyphs for them, from the standard glyphs shrunk and shifted by the font's OS/2 metrics, and a run mixing available and unavailable glyphs is synthesised whole, as Explore text properties says (#1828); Alt+Enter while editing text opens the link at the caret, as the accessibility page documents; Minimize, by its shortcut or the button by the file name, collapses the navigation bar and both sidebars as the help center says, with the right sidebar back while something is selected (#1835); the interface size keys of the help center's accessibility page: Cmd/Ctrl+Shift+Alt with =, - and 0 step the interface through the Preferences sizes and back to the system's size; Cmd/Ctrl+Alt+arrows rotate the selection a degree at a time, 15 with Shift, as the help center's accessibility page says; they resized before; the Layers panel answers Ctrl+1 as well as Alt+1 on Windows and Linux, the two keys the help center gives it; 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 keyboard's Menu key opens the context menu, as Shift+F10 does; the eyedropper with Cmd/Ctrl+Shift makes a colour style or variable of what it picks; sidebar number and text fields keep their text vertically centered when focused, including typography spacing; 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); 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); Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); colour picker review fixes for text undo, fitted sidebars, moving controls and gradient variables; Corner smoothing: an original icon and the shared number-field drag gesture, preserving fractional baselines; guides snap to layers when dragged; Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); the Design panel's sections on one grid — 16 / 8 margins, 88 + 8 + 88 + 8 + 24 columns, 24 pt rows, 4 pt between a row's buttons (#858); the toolbar on one 32 pt grid, without its Resources button (#1210); colour picker LCH and OKLCH models; an XML (Android Vector Drawable) export format; Context menus: the Menu key reaches egui 0.37 but is unbound (row done → partial); the Text on a path tool; board tool keys pinned; community fixes: the text box by dragging, the font picker keys, the pen path on leaving Design, a still canvas in vector edit mode, a scrolling pages list, colour emoji, readable new text in frames, system theme on first run; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; a rename selects the whole name, so typing replaces it; Escape or Enter after a pen path stays on the same vector (#760); IME composition kept on through idle frames of a text edit, candidates at the caret (#747); Selection colors for mixed selections and smart-selection spacing (#753); submenus with a hover delay and a safe triangle; the Actions palette's tabs, ranked search, highlight and recents; menu items that ask for a value; editor fixes from the community batches) · **Target:** Figma Design UI3 (desktop app 126.10.7)
+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-09 · **Change:** minor (M8, #2146: K focuses the Scale tool's factor field with its text selected, so K, 2, Enter scales the selection by two about the panel's origin (FigCraft's starts at the top left; Figma's grid starts at the centre, left for a follow-up because the origin also sets where a handle drag pivots, which is not measured) in one undo step, after which the tool returns to Move with the selection kept; Escape in the field closes it with the tool back on Move and nothing changed (measured in Figma's web app, 2026-11-09; `pressing_k_focuses_the_scale_field_with_its_text_selected`, `k_two_enter_scales_the_selection_by_two`, `escape_in_the_scale_field_closes_it_and_changes_nothing`); the left rail's and tab strip's icons are larger, 20 pt (#553); pick select: Tab while the keyboard selection box shows (or the chord again) puts a pink outline on a layer and the keys walk the layer tree from it, as Accessibility at Figma says (#1836); scrubbing a field faster or slower by the pointer's height, with a notice of the speed, as the help center's Scrub fields says (#1837); component rows in the Layers panel at AA contrast in the theme's component tint, with the rows inside a selected component in a tone of its row (#1903); Cmd/Ctrl-click on a padding field sets all four sides with the value typed, a documented auto layout shortcut; a fill per region of a vector: with regions selected in edit mode, the Fill section and the fill commands edit those regions' own fills, as Edit vector layers says (#1875); the Fill section of a text whose characters were coloured differently while editing shows mixed content, characters that all share fills the layer doesn't have show those, and a layer-level fill edit reaches every character (#1856); a number field scrubs when dragged with Alt held, as the help center's scrub section says, not only by its label; reuse visible layer selection roots (M8); ; with a shape or frame tool chosen and nothing selected, the arrows show a placement crosshair in the middle of the view and move it, and Enter places there, as Accessibility at Figma says (#1839); text direction per paragraph: Text ▸ Text direction forces a paragraph left to right or right to left, as Add right-to-left text says (#1830), with `text.direction`, `node.set textDirection`, the Type settings control once a right-to-left script is in the text, inspect, the CSS `direction` and the `.fig` lines' `sourceDirectionality`; the boolean operation menu in the Design panel leads each entry with an original icon drawn in code, a filled preview of the result (#2003); a press on the open gradient's handles or stops on the canvas keeps the colour picker open through the drag, as its own control (#1852); a click with the Bend tool on a sharp point curves it, its handles along the line between its neighbours, as Edit vector layers says (#1875); wrap style: Auto, Balance and Pretty on a text layer decide where its lines break, as Explore text properties says (#1827), with the Type settings control, `text.wrapStyle`, `node.set wrapStyle`, inspect and the CSS `text-wrap`; Backspace or Delete at the start of a list item keeps the item's indentation level, as the lists article says; max lines on auto-width text keep it auto width, hugging that many lines, as the text properties article says; superscript and subscript are synthesised where the font has no glyphs for them, from the standard glyphs shrunk and shifted by the font's OS/2 metrics, and a run mixing available and unavailable glyphs is synthesised whole, as Explore text properties says (#1828); Alt+Enter while editing text opens the link at the caret, as the accessibility page documents; Minimize, by its shortcut or the button by the file name, collapses the navigation bar and both sidebars as the help center says, with the right sidebar back while something is selected (#1835); the interface size keys of the help center's accessibility page: Cmd/Ctrl+Shift+Alt with =, - and 0 step the interface through the Preferences sizes and back to the system's size; Cmd/Ctrl+Alt+arrows rotate the selection a degree at a time, 15 with Shift, as the help center's accessibility page says; they resized before; the Layers panel answers Ctrl+1 as well as Alt+1 on Windows and Linux, the two keys the help center gives it; 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 keyboard's Menu key opens the context menu, as Shift+F10 does; the eyedropper with Cmd/Ctrl+Shift makes a colour style or variable of what it picks; sidebar number and text fields keep their text vertically centered when focused, including typography spacing; 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); 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); Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); colour picker review fixes for text undo, fitted sidebars, moving controls and gradient variables; Corner smoothing: an original icon and the shared number-field drag gesture, preserving fractional baselines; guides snap to layers when dragged; Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); the Design panel's sections on one grid — 16 / 8 margins, 88 + 8 + 88 + 8 + 24 columns, 24 pt rows, 4 pt between a row's buttons (#858); the toolbar on one 32 pt grid, without its Resources button (#1210); colour picker LCH and OKLCH models; an XML (Android Vector Drawable) export format; Context menus: the Menu key reaches egui 0.37 but is unbound (row done → partial); the Text on a path tool; board tool keys pinned; community fixes: the text box by dragging, the font picker keys, the pen path on leaving Design, a still canvas in vector edit mode, a scrolling pages list, colour emoji, readable new text in frames, system theme on first run; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; a rename selects the whole name, so typing replaces it; Escape or Enter after a pen path stays on the same vector (#760); IME composition kept on through idle frames of a text edit, candidates at the caret (#747); Selection colors for mixed selections and smart-selection spacing (#753); submenus with a hover delay and a safe triangle; the Actions palette's tabs, ranked search, highlight and recents; menu items that ask for a value; editor fixes from the community batches) · **Target:** Figma Design UI3 (desktop app 126.10.7)
Fine canvas control is what Figma users feel first: handles, snapping, nudging, modifier keys,
drag behaviour, numeric precision. This checklist covers the tools, gestures and panels. Status:
@@ -67,7 +67,7 @@ interaction feel not yet compared.
| Enter with a shape or frame tool chosen | done | places what a click makes (100×100, lines 100 long, sections 400×300) in the middle of the view, selected (`tool.place`); from the toolbar, Enter on the chosen tool. With nothing selected, the arrows first show a crosshair in the middle of the view and then move it by the nudge amounts (`tool.crosshair`; Shift the large one), and Enter places there; Escape or a tool change takes it away ("Use the arrow keys to activate the crosshair cursor. It will appear in the middle of the viewport indicating where the new object will be placed", Accessibility at Figma; #1839) |
| Alt-drag duplicate | done | the copy snaps to the layer it came from too (edges, centres, equal spacing); Duplicate (Cmd+D) then repeats the copy's offset, also after a Duplicate whose copy was moved, as long as the copies stay selected (the documentation doesn't say; pinned from community answers) |
| Drag threshold; double-click into groups and text | done | a press moves or resizes only once the pointer has travelled 3 px on screen, so a click or double-click made with an unsteady hand drags nothing; a double-click goes one level into a group and the next one edits the text under it, wherever on the text it lands, with all of it selected (a double-click in the text being edited selects the word) (`double_click_tests`); Enter on a selected text does the same (all of it selected, typing replaces it) and Escape leaves editing with the layer still selected (`text_enter_tests`) |
-| Scale tool (K): handles about an anchor, live factor, panel | done | Pressing K opens the Scale section with its factor field focused and its text selected; typing 2 (or 2x, 1.5) then Enter scales the selection by it about the origin grid's centre (the default), as one undo step, then closes the section and returns to Move with the selection kept; Escape in the field closes it, back to Move, nothing changed. Measured in Figma's web app, 2026-11-09. Tests: `pressing_k_focuses_the_scale_field_with_its_text_selected`, `k_two_enter_scales_the_selection_by_two_about_its_centre`, `escape_in_the_scale_field_closes_it_and_changes_nothing`. |
+| Scale tool (K): handles about an anchor, live factor, panel | done | Pressing K opens the Scale section with its factor field focused and its text selected; typing 2 (or 2x, 1.5) then Enter scales the selection by it about the panel's origin (FigCraft's starts at the top left; Figma's grid starts at the centre, left for a follow-up because the origin also sets where a handle drag pivots, which is not measured), as one undo step, then closes the section and returns to Move with the selection kept; Escape in the field closes it, back to Move, nothing changed. Measured in Figma's web app, 2026-11-09. Tests: `pressing_k_focuses_the_scale_field_with_its_text_selected`, `k_two_enter_scales_the_selection_by_two`, `escape_in_the_scale_field_closes_it_and_changes_nothing`. |
| Nudge 1 / Shift 10 (preference), Cmd+arrows resize | done | arrows reorder auto layout children |
| Smart guides: edges, centres, equal spacing (between two layers or continuing a row's gap, labelled), spacing labels, parent, layout grid, ruler guides | done | red, `snap.rs`; a guide dragged out of a ruler or moved snaps to layer edges and centres too (Snap to Objects; Cmd/Ctrl suspends), on its own axis only; page guides see the page's top-level layers, frame guides the frame's box, layout grids and every layer in it, along a rotated frame's own axes; thresholds not yet compared with Figma's (`guide_snap_tests`) |
| Ctrl to suspend snapping while dragging (macOS) | done | Control on macOS (Cmd keeps working), Ctrl elsewhere; moving, resizing and drawing |
@@ -271,5 +271,5 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look
| 2026-10-11 | minor | Canvas fixes: boolean groups clicked where their result is, a layer dragged inside a group stays in it, View ▸ Frame outlines and View ▸ Slices draw |
| 2026-10-11 | minor | Outline mode done: outlines for the selection (the selected layers draw as outlines, the rest normally) and mask outlines |
| 2026-10-11 | minor | Design panel arc fields, live boolean editing (operands on the canvas, live results, per-operand operations), shape builder on strokes and open paths, one-sided widths (Alt-drag a width handle) |
-| 2026-11-09 | patch | M8, #2146: K focuses the Scale tool's factor field with its text selected, so K, 2, Enter scales the selection by two about the origin grid's centre (the default) in one undo step, after which the tool returns to Move with the selection kept; Escape in the field closes it with the tool back on Move and nothing changed (measured in Figma's web app, 2026-11-09; `pressing_k_focuses_the_scale_field_with_its_text_selected`, `k_two_enter_scales_the_selection_by_two_about_its_centre`, `escape_in_the_scale_field_closes_it_and_changes_nothing`) |
+| 2026-11-09 | patch | M8, #2146: K focuses the Scale tool's factor field with its text selected, so K, 2, Enter scales the selection by two about the panel's origin (FigCraft's starts at the top left; Figma's grid starts at the centre, left for a follow-up because the origin also sets where a handle drag pivots, which is not measured) in one undo step, after which the tool returns to Move with the selection kept; Escape in the field closes it with the tool back on Move and nothing changed (measured in Figma's web app, 2026-11-09; `pressing_k_focuses_the_scale_field_with_its_text_selected`, `k_two_enter_scales_the_selection_by_two`, `escape_in_the_scale_field_closes_it_and_changes_nothing`) |
| 2026-10-10 | major | Created from the ROADMAP's look-and-feel and interaction items, the keymap and the canvas code |
From 197a390a8406150a3c9beae0ad4c7855f370985e Mon Sep 17 00:00:00 2001
From: Trevor Brown
Date: Mon, 9 Nov 2026 03:40:27 -0500
Subject: [PATCH 22/51] M8: the Scale field refocuses after another tab, and
Enter elsewhere keeps the tool (#2148 review)
The focus request was reset only while the Design panel drew, so choosing the tool again on the
Prototype tab left the field without the keyboard; it now follows the frame the section last
showed in. Whether the factor field had the keyboard was read before the widgets ran, so an
Enter right after a click into W or H could end the tool; it is read after them now. A number
field's leftover edit from when it was last shown no longer swallows the focus request.
Co-Authored-By: Claude Opus 5.5
Model-Effort: Opus 5.5 drove and wrote
---
crates/ui-egui/src/field_tests.rs | 39 +++++++++++++++++++++++-
crates/ui-egui/src/panels/right.rs | 2 --
crates/ui-egui/src/panels/scale_panel.rs | 27 ++++++++--------
crates/ui-egui/src/widgets.rs | 8 +++--
4 files changed, 58 insertions(+), 18 deletions(-)
diff --git a/crates/ui-egui/src/field_tests.rs b/crates/ui-egui/src/field_tests.rs
index dfa097db0..06b78823c 100644
--- a/crates/ui-egui/src/field_tests.rs
+++ b/crates/ui-egui/src/field_tests.rs
@@ -766,7 +766,44 @@ fn pressing_k_focuses_the_scale_field_with_its_text_selected() {
assert_ne!(range.primary.index, range.secondary.index, "its text is selected");
}
-/// K, 2, Enter: 100 x 100 becomes 200 x 200 about the centre, the tool is Move, one undo step.
+/// The Scale tool chosen again while the Design tab was away (Prototype tab, Move, then K): the
+/// field takes the keyboard again when the Design tab comes back.
+#[test]
+fn the_scale_field_takes_the_keyboard_again_after_another_tab() {
+ let (mut u, _) = scale_setup();
+ key(&mut u, Key::K, Modifiers::NONE);
+ u.frame(vec![]);
+ assert!(scale_field_focused(&u));
+ u.app.ui.right_tab = crate::state::RightTab::Prototype;
+ u.frame(vec![]);
+ u.app.run("tool.set", json!({"tool": "move"}));
+ u.frame(vec![]);
+ u.app.run("tool.set", json!({"tool": "scale"}));
+ u.frame(vec![]);
+ u.app.ui.right_tab = crate::state::RightTab::Design;
+ u.frame(vec![]);
+ u.frame(vec![]);
+ assert!(scale_field_focused(&u), "the field has the keyboard again");
+}
+
+/// Tab moves the keyboard from the factor field to the next one; an Enter there leaves the
+/// Scale tool on.
+#[test]
+fn enter_in_another_field_after_tab_keeps_the_scale_tool() {
+ let (mut u, _) = scale_setup();
+ key(&mut u, Key::K, Modifiers::NONE);
+ u.frame(vec![]);
+ assert!(scale_field_focused(&u));
+ // Tab, then Enter on the very next frame (as a quick Tab, Enter or a click and Enter would).
+ let ev = |key, pressed| Event::Key { key, physical_key: None, pressed, repeat: false, modifiers: Modifiers::NONE };
+ u.frame(vec![ev(Key::Tab, true), ev(Key::Tab, false)]);
+ assert!(!scale_field_focused(&u), "Tab moved the keyboard on");
+ u.frame(vec![ev(Key::Enter, true), ev(Key::Enter, false)]);
+ u.frame(vec![]);
+ assert_eq!(u.app.session.tool.tool, figcraft_engine::ToolId::Scale);
+}
+
+/// K, 2, Enter: 100 x 100 becomes 200 x 200 about the panel's origin, the tool is Move, one undo step.
#[test]
fn k_two_enter_scales_the_selection_by_two() {
let (mut u, r) = scale_setup();
diff --git a/crates/ui-egui/src/panels/right.rs b/crates/ui-egui/src/panels/right.rs
index 5c190576f..bba57c778 100644
--- a/crates/ui-egui/src/panels/right.rs
+++ b/crates/ui-egui/src/panels/right.rs
@@ -281,8 +281,6 @@ fn design(app: &mut FigApp, ui: &mut Ui) {
if app.session.tool.tool == figcraft_engine::ToolId::Scale {
crate::scale_panel::section(app, ui, &shown, &sel);
widgets::separator(ui);
- } else {
- crate::scale_panel::closed(ui.ctx());
}
position(app, ui, &doc, &shown, &sel);
}
diff --git a/crates/ui-egui/src/panels/scale_panel.rs b/crates/ui-egui/src/panels/scale_panel.rs
index 7ba0618c8..9b6dd98a1 100644
--- a/crates/ui-egui/src/panels/scale_panel.rs
+++ b/crates/ui-egui/src/panels/scale_panel.rs
@@ -17,19 +17,23 @@ use crate::widgets::{self, FieldEdit, Lead};
pub fn section(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) {
let Some(b) = doc.bounds_of(sel) else { return };
widgets::section_header(ui, tr("Scale"), &[], false);
- // The factor field takes the keyboard once, with its text selected, when the tool opens.
+ // The factor field takes the keyboard, with its text selected, whenever the section shows
+ // after a frame without it: the tool was chosen, or the panel came back from another tab or a
+ // hidden UI.
let k_id = Id::unique("scale_k");
- let opened = Id::unique("scale_opened");
- if !ui.data(|d| d.get_temp::(opened)).unwrap_or(false) {
- ui.data_mut(|d| d.insert_temp(opened, true));
+ let shown_id = Id::unique("scale_shown_frame");
+ let frame = ui.ctx().cumulative_frame_nr();
+ let last = ui.data(|d| d.get_temp::(shown_id));
+ ui.data_mut(|d| d.insert_temp(shown_id, frame));
+ if last.is_none_or(|l| l.saturating_add(1) < frame) {
widgets::request_edit(ui, k_id);
}
- // Enter or Escape in the factor field ends the tool (back to Move, the selection kept).
- // (Focus as it was last frame: the shortcut layer may already have let go of it this frame.)
+ // Enter or Escape in the factor field ends the tool (back to Move, the selection kept). Whether
+ // the field had the keyboard is read as it was when the last frame's widgets were done: the
+ // shortcut layer may let go of it before this frame's panel runs, and a click or Tab that moved
+ // it to another field during the last frame counts.
let in_k_id = Id::unique("scale_in_k");
let in_k = ui.data(|d| d.get_temp::(in_k_id)).unwrap_or(false);
- let focused_now = ui.memory(|m| m.has_focus(k_id.with("te")));
- ui.data_mut(|d| d.insert_temp(in_k_id, focused_now));
let (enter, esc) = ui.input(|i| (i.key_pressed(egui::Key::Enter), i.key_pressed(egui::Key::Escape)));
let col = widgets::col_width(ui.available_width());
// Anchor: column and row (0 start, 1 middle, 2 end) of the point that stays put.
@@ -82,16 +86,13 @@ pub fn section(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) {
}
}
}
+ let focused_now = ui.memory(|m| m.has_focus(k_id.with("te")));
+ ui.data_mut(|d| d.insert_temp(in_k_id, focused_now));
if in_k && (enter || esc) {
app.run("tool.set", json!({"tool": "move"}));
}
}
-/// Forget that the section opened, so the next time the Scale tool opens it takes the keyboard.
-pub fn closed(ctx: &egui::Context) {
- ctx.data_mut(|d| d.remove::(Id::unique("scale_opened")));
-}
-
/// The factor (clamped to 0.01–100) and pivot for scaling `b` by `k` around `anchor`; None when
/// nothing would change or `k` isn't a usable number.
fn scale_about(b: figcraft_geom::Rect, anchor: (u8, u8), k: f64) -> Option<(f64, f64, f64)> {
diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs
index b25ed54b9..252ae65af 100644
--- a/crates/ui-egui/src/widgets.rs
+++ b/crates/ui-egui/src/widgets.rs
@@ -224,9 +224,13 @@ pub fn number_field(ui: &mut Ui, id: Id, lead: Lead, value: Option, suffix:
}
/// Ask number field `id` to open for typing on its next frame, its text selected (a tool that
-/// opens with its field focused, such as Scale).
+/// opens with its field focused, such as Scale). An edit left over from when the field was last
+/// shown (it went away with the keyboard on it) is dropped first, or the request would be ignored.
pub fn request_edit(ui: &Ui, id: Id) {
- ui.data_mut(|d| d.insert_temp(id.with("beginedit"), true));
+ ui.data_mut(|d| {
+ d.remove::(id.with("editing"));
+ d.insert_temp(id.with("beginedit"), true);
+ });
}
/// Whether the edit of number field `id` began with a Cmd/Ctrl-click. A padding field edited
From 984270fbecdaace2353d8247f04b94626f10390f Mon Sep 17 00:00:00 2001
From: ljkx <177671075+ljkx@users.noreply.github.com>
Date: Mon, 9 Nov 2026 12:38:29 +0100
Subject: [PATCH 23/51] M8: automation roots refuse a path on another computer
before resolving it (#1094)
On Windows, Roots::check resolved every path (canonicalize opens it) before
comparing it with the roots, so `\server\share\x` contacted the server, and
could send it the user's credentials, before it was refused. Now the path as
given and made absolute is classified by its spelling with craft_docpath
first: another computer that no root's share is on, a device path, or a DOS
device name anywhere in it is refused, and so is a link on the way that
leads to one (followed by its target's spelling, up to 40 links).
Fixes #1094
---
crates/engine/src/automation/file_access.rs | 189 ++++++++++++++++--
.../src/automation/file_access/tests.rs | 177 ++++++++++++++++
2 files changed, 344 insertions(+), 22 deletions(-)
diff --git a/crates/engine/src/automation/file_access.rs b/crates/engine/src/automation/file_access.rs
index 5ce06b122..97c6cd2f5 100644
--- a/crates/engine/src/automation/file_access.rs
+++ b/crates/engine/src/automation/file_access.rs
@@ -12,6 +12,11 @@
//! existing folder is resolved and the rest must be plain names. The comparison is by whole
//! components (`/work2` is not inside `/work`), and the I/O uses the resolved path, so what was
//! checked is what is opened.
+//!
+//! On Windows, resolving a path opens it, and opening a path on another computer contacts that
+//! computer and may send it the signed-in user's credentials. So before anything is resolved, a
+//! path on another computer that no root is on, a device path, or one reached through a link that
+//! leads to them is refused by how it is spelled (#1094).
#![allow(clippy::disallowed_methods)]
use std::cell::RefCell;
@@ -19,6 +24,8 @@ use std::ffi::{OsStr, OsString};
use std::path::{Component, Path, PathBuf};
use std::sync::Arc;
+use craft_docpath::Refused;
+
use crate::{EngineError, Result};
/// What a command does with a file.
@@ -58,6 +65,19 @@ impl Roots {
Access::Read => ([self.read.as_slice(), self.write.as_slice()].concat(), "read"),
Access::Write => (self.write.clone(), "written"),
};
+ if cfg!(windows) {
+ match refused_unresolved(path, &roots) {
+ Some(Unresolved::Spelled(Refused::Network)) => {
+ return Err(format!(
+ "automation path rejected: {asked} is on another computer, and no folder that may be {what} is on its share"
+ ));
+ }
+ Some(Unresolved::Spelled(_)) => return Err(format!("automation path rejected: {asked} names a device, not a file")),
+ // Where a link leads is only told by the usual message: it would say what is there.
+ Some(Unresolved::Linked) => return Err(outside(asked, &roots, what)),
+ None => {}
+ }
+ }
match resolve(path) {
Ok(resolved) if roots.iter().any(|r| resolved.starts_with(r)) => return Ok(resolved),
Ok(_) => {}
@@ -65,11 +85,7 @@ impl Roots {
Err(why) if inside_as_far_as_it_exists(path, &roots) => return Err(format!("automation path rejected: {asked}: {why}")),
Err(_) => {}
}
- if roots.is_empty() {
- return Err(format!("automation path rejected: no folder may be {what} (no root for it was given): {asked}"));
- }
- let shown: Vec = roots.iter().map(|r| shown(r)).collect();
- Err(format!("automation path rejected: {asked} is outside the folders that may be {what}: {}", shown.join(", ")))
+ Err(outside(asked, &roots, what))
}
/// The folders as JSON, resolved: `{read: [every folder that may be read], write: [the write roots]}`.
@@ -79,23 +95,146 @@ impl Roots {
}
}
+/// Why `asked` is refused: it is outside every one of `roots`, the folders that may be `what`.
+fn outside(asked: std::path::Display<'_>, roots: &[PathBuf], what: &str) -> String {
+ if roots.is_empty() {
+ return format!("automation path rejected: no folder may be {what} (no root for it was given): {asked}");
+ }
+ let shown: Vec = roots.iter().map(|r| shown(r)).collect();
+ format!("automation path rejected: {asked} is outside the folders that may be {what}: {}", shown.join(", "))
+}
+
+/// Why a path is refused before it is resolved (#1094).
+#[derive(Debug, PartialEq, Eq)]
+enum Unresolved {
+ /// Its spelling leads to another computer ([`Refused::Network`]) or a device ([`Refused::Device`]).
+ Spelled(Refused),
+ /// A link on the way leads there.
+ Linked,
+}
+
+/// How many links are followed on the way to a path before it is given up on, as on Linux.
+const MAX_LINKS: u8 = 40;
+
+/// Why `path` is refused before anything on the way to it is opened, if it is: resolving it would
+/// open a path on another computer, which on Windows contacts that computer and may send it the
+/// signed-in user's credentials, or open a device (#1094). That is the case when the path as
+/// given or made absolute is (see [`refused_spelling`]), or when a link on the way, or on the way
+/// on from where one leads, leads to such a path ([`link_refused`]). A path on the share of one of
+/// `roots` goes on to the usual check.
+fn refused_unresolved(path: &Path, roots: &[PathBuf]) -> Option {
+ // Made absolute without looking at the disk (on Windows `GetFullPathNameW`, which also turns a
+ // bare device name such as `NUL` into `\\.\NUL`, as opening it would).
+ let abs = std::path::absolute(path).ok()?;
+ if let Some(why) = [path, abs.as_path()].into_iter().find_map(|p| refused_spelling(&p.to_string_lossy(), roots)) {
+ return Some(Unresolved::Spelled(why));
+ }
+ link_refused(&abs, roots, MAX_LINKS, &on_disk).then_some(Unresolved::Linked)
+}
+
+/// What is at a path, looked at without following a link there.
+#[derive(Debug, Clone, PartialEq, Eq)]
+enum Entry {
+ /// Nothing, or what can't be looked at: resolving the path meets the same there and goes no
+ /// further.
+ Missing,
+ /// A file or a folder.
+ Plain,
+ /// A link, with where it leads (`None` when that can't be read).
+ Link(Option),
+}
+
+/// What is at `p` on disk (see [`Entry`]).
+fn on_disk(p: &Path) -> Entry {
+ match std::fs::symlink_metadata(p) {
+ Ok(m) if m.is_symlink() => Entry::Link(std::fs::read_link(p).ok()),
+ Ok(_) => Entry::Plain,
+ Err(_) => Entry::Missing,
+ }
+}
+
+/// Why path `p` is refused by its spelling alone, if it is (see [`craft_docpath::resolve`], which
+/// reads both `/` and `\` as separators and compares names ignoring case):
+/// - on another computer (`\\server\share`, `//server/share`, `\\?\UNC\…`, `\\.\UNC\…`, or a
+/// server without a share) and on no root's share;
+/// - a device path (`\\.\pipe\…`, `\\?\GLOBALROOT\…`, `\\.\C:\…`), other than a local volume's
+/// `\\?\Volume{…}\`, which a folder can be mounted as;
+/// - or with a device name (`CON`, `NUL`, `COM1.png`, `lpt1 `…) as any of its names: each folder on
+/// the way is opened in turn while the path is resolved, and the device behind a name such as
+/// `LPT1` can be a printer on another computer.
+fn refused_spelling(p: &str, roots: &[PathBuf]) -> Option {
+ match craft_docpath::resolve(p, None) {
+ // With a root as the document, a path on its share is allowed.
+ Err(Refused::Network) if !roots.iter().any(|r| craft_docpath::resolve(p, Some(r)) != Err(Refused::Network)) => {
+ return Some(Refused::Network);
+ }
+ Err(Refused::Device) if !is_volume(p) => return Some(Refused::Device),
+ _ => {}
+ }
+ // `craft_docpath` stops at the first name it refuses, which may not be the device's.
+ p.split(['\\', '/']).any(device_name).then_some(Refused::Device)
+}
+
+/// Whether `p` is in a local volume by its `\\?\Volume{…}` (or `\\.\Volume{…}`) name.
+fn is_volume(p: &str) -> bool {
+ let mut names = p.split(['\\', '/']);
+ let (Some(""), Some(""), Some("?" | "."), Some(volume)) = (names.next(), names.next(), names.next(), names.next()) else {
+ return false;
+ };
+ volume.get(..7).is_some_and(|v| v.eq_ignore_ascii_case("Volume{")) && volume.ends_with('}')
+}
+
+/// Whether a link on the way to `abs` (an absolute path) leads to a path [`refused_spelling`]
+/// refuses, following at most `links` links: one that leads to another, or on through further
+/// links, is followed too, as resolving the path would. A link that can't be read, or more links
+/// than that, are refused as well. Each name on the way is looked at with `look` ([`on_disk`]),
+/// which doesn't follow a link: where a link leads is looked at only once its spelling is known
+/// not to be refused.
+fn link_refused(abs: &Path, roots: &[PathBuf], links: u8, look: &dyn Fn(&Path) -> Entry) -> bool {
+ let mut at = PathBuf::new();
+ let mut rest = abs.components();
+ while let Some(c) = rest.next() {
+ at.push(c);
+ if !matches!(c, Component::Normal(_)) {
+ continue;
+ }
+ let target = match look(&at) {
+ Entry::Plain => continue,
+ // Nothing is below what isn't there, so no link either.
+ Entry::Missing => return false,
+ Entry::Link(target) => target,
+ };
+ let (Some(links), Some(target)) = (links.checked_sub(1), target) else { return true };
+ // A relative target is relative to the link's folder; an absolute one replaces it.
+ let next = lexical(&at.parent().unwrap_or(&at).join(target).join(rest.as_path()));
+ return refused_spelling(&next.to_string_lossy(), roots).is_some() || link_refused(&next, roots, links, look);
+ }
+ false
+}
+
+/// `p` with its `.` and `..` taken by name, as Windows takes them.
+fn lexical(p: &Path) -> PathBuf {
+ let mut out = PathBuf::new();
+ for c in p.components() {
+ match c {
+ Component::ParentDir => {
+ out.pop();
+ }
+ Component::CurDir => {}
+ c => out.push(c),
+ }
+ }
+ out
+}
+
/// Whether `path` lies in one of `roots` once made absolute with its `.` and `..` taken by name and
/// its longest leading part that exists resolved, so links on the way are followed as the roots'
/// were (`/tmp/work/x` is inside root `/private/tmp/work`, a link out of a root leads out of it).
fn inside_as_far_as_it_exists(path: &Path, roots: &[PathBuf]) -> bool {
let Ok(abs) = std::path::absolute(path) else { return false };
- let mut lexical = PathBuf::new();
- for c in abs.components() {
- match c {
- Component::ParentDir => {
- lexical.pop();
- }
- Component::CurDir => {}
- c => lexical.push(c),
- }
- }
+ let folded = lexical(&abs);
let mut rest: Vec<&OsStr> = Vec::new();
- let mut at = lexical.as_path();
+ let mut at = folded.as_path();
let resolved = loop {
if let Ok(mut base) = std::fs::canonicalize(at) {
base.extend(rest.iter().rev());
@@ -169,18 +308,24 @@ fn resolve(path: &Path) -> std::result::Result {
fn plain_name(name: &OsStr) -> std::result::Result<(), String> {
if cfg!(windows) {
let n = name.to_string_lossy();
- let stem = n.split('.').next().unwrap_or_default().trim_end().to_ascii_uppercase();
- let device = matches!(stem.as_str(), "CON" | "PRN" | "AUX" | "NUL" | "CONIN$" | "CONOUT$")
- || ((stem.starts_with("COM") || stem.starts_with("LPT"))
- && stem.chars().count() == 4
- && stem.chars().last().is_some_and(|c| c.is_ascii_digit() || matches!(c, '¹' | '²' | '³')));
- if device || n.contains(':') {
+ if device_name(&n) || n.contains(':') {
return Err(format!("`{n}` is a device or stream name, not a file"));
}
}
Ok(())
}
+/// Whether Windows opens a device for file or folder name `n`: `CON`, `PRN`, `AUX`, `NUL`,
+/// `CONIN$`, `CONOUT$`, `COM1`…`COM9` and `LPT1`…`LPT9` (also with `¹`, `²` or `³`), in any case,
+/// and with anything after a dot or a colon or trailing spaces (`nul.txt`, `COM1 .png`, `con:x`).
+fn device_name(n: &str) -> bool {
+ let stem = n.split(['.', ':']).next().unwrap_or_default().trim_end().to_ascii_uppercase();
+ matches!(stem.as_str(), "CON" | "PRN" | "AUX" | "NUL" | "CONIN$" | "CONOUT$")
+ || ((stem.starts_with("COM") || stem.starts_with("LPT"))
+ && stem.chars().count() == 4
+ && stem.chars().last().is_some_and(|c| c.is_ascii_digit() || matches!(c, '¹' | '²' | '³')))
+}
+
thread_local! {
static ROOTS: RefCell