diff --git a/crates/engine/src/cmd/component.rs b/crates/engine/src/cmd/component.rs index e0b2e02f4..f68fac714 100644 --- a/crates/engine/src/cmd/component.rs +++ b/crates/engine/src/cmd/component.rs @@ -24,8 +24,8 @@ 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}; -pub use variants::{can_add_variant, can_combine_variants}; +use variants::{add_variant, combine, create_set}; +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. @@ -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..96de6ba3a 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,75 @@ 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 +/// 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 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) { + 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 3458b0b22..3565a3165 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,245 @@ 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"); +} + +/// 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)); +} + +#[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)); +} + +/// 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/locales/ar.toml b/crates/ui-egui/locales/ar.toml index e50a9b9c4..5631f4b93 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 919bc0381..14cbbc902 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 7ecc6779a..64c9b6fd2 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 f57fcc5d3..f24bd1e87 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 258a1527d..6001ca76f 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 88fb21e06..92d9afc19 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 e40b3a060..8cb3ea7cc 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 59dbd492c..6654551dd 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 f61ee86fe..e7dd1fc1e 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 d440e910f..558c5dc7b 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 4c6764afa..b31aef724 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 b4c960fbc..c5614ff12 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 2d6816ab6..2b9974460 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 bcc9f0051..c7225c586 100644 --- a/crates/ui-egui/locales/pl.toml +++ b/crates/ui-egui/locales/pl.toml @@ -1970,6 +1970,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 9108dea4d..d3b8d6c2c 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 36e9abeaa..265561d3f 100644 --- a/crates/ui-egui/locales/ru.toml +++ b/crates/ui-egui/locales/ru.toml @@ -1970,6 +1970,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 ce02b3f7f..0b8f60bdb 100644 --- a/crates/ui-egui/locales/sv.toml +++ b/crates/ui-egui/locales/sv.toml @@ -2077,6 +2077,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 f4662a2b8..23fd432af 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 b98f8389b..8c72636d7 100644 --- a/crates/ui-egui/locales/tr.toml +++ b/crates/ui-egui/locales/tr.toml @@ -1918,6 +1918,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 5bd9cf7ff..39bf74e59 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 f78f0d938..0a90f879c 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 a1179aa0c..9b81a4b21 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" = "編輯元件屬性" 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..60d22a993 --- /dev/null +++ b/crates/ui-egui/src/create_multiple_menu_tests.rs @@ -0,0 +1,165 @@ +//! With two or more layers selected the Design panel's component button opens a menu (Create +//! 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}; +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() < 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", "Create component set"], "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; +} + +#[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"); +} + +/// 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/lib.rs b/crates/ui-egui/src/lib.rs index 1c87b5660..bb2ee978a 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 84572016f..b30d323a7 100644 --- a/crates/ui-egui/src/panels/right.rs +++ b/crates/ui-egui/src/panels/right.rs @@ -497,7 +497,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() { @@ -508,7 +511,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 [ @@ -533,10 +540,35 @@ 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. + 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_item(ui, label, key) { + app.run(c, json!({})); + ui.close(); + } + } + }); } else if bresp.clicked() { app.run(cmd, json!({})); } - x -= 26.0; + x -= bw + 2.0; } crate::component_panel::instance_section(app, ui, doc, sel); } diff --git a/docs/components-parity.md b/docs/components-parity.md index 8fcaea78a..9220a8ec9 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, 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,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, 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. |