diff --git a/ROADMAP.md b/ROADMAP.md index fc7015a0..4e0d088d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -316,6 +316,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-20 | minor | Duplicating a main component makes an instance (#892): Duplicate and Alt-drag copied a main component as a second main; within its file the copy is now an instance of it, as Copy then Paste already was (a variant duplicated in its set stays a variant). Numbers unchanged. | | 2026-10-20 | minor | Readable disabled and selected text (#890): the dim text colour (disabled buttons, placeholders, hints) is at least 3:1 against panels, fields and selected rows in both themes (it was 2.1:1 in the light theme), a disabled button is grey with that text instead of fading to 40%, and selected text (renaming a page or layer, any text box) takes the theme's readable green on the highlight (1.4:1 in the dark theme before, now 4.2:1 or more); checked with the colour contrast helper (`theme::tests`). Numbers unchanged | | 2026-10-20 | minor | Layer data reads back (#879): `node.get` (the command, the control method and MCP `get_node`) and `document.inspect` report a layer's or page's `pluginData` as `{key: value}` when it has any, so a tool that keeps state in a file with `node.set {props: {pluginData}}` can read it on its next run (`plugin_data_tests`). Numbers unchanged | | 2026-10-20 | minor | Measuring while nudging (#894): with Alt held over another layer, the arrows nudge the selection (Shift: the big step) and the distances follow every nudge (Alt+arrows used to do nothing); while measuring, the selection's handles and size label hide and both layers keep their outlines (`measure_nudge_tests`). Numbers unchanged | diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 7d107d0c..a16fbb38 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -18,13 +18,19 @@ pub fn specs() -> Vec { spec!( "edit.duplicate", "Duplicate", - "{ids?, dx?, dy? (page offset; default: the offset of the last duplicate when ids are its copies, still selected; else top-level frames to the right with a 100 gap, everything else in place)}", + "{ids?, dx?, dy? (page offset; default: the offset of the last duplicate when ids are its copies, still selected; else top-level frames to the right with a 100 gap, everything else in place)} a main component's copy is an instance of it (a variant stays a new variant in its set)", true, duplicate ), spec!("edit.copy", "Copy", "{ids?}", false, copy), spec!("edit.cut", "Cut", "{ids?}", true, cut), - spec!("edit.paste", "Paste", "{parent?, x?, y? (page coordinates of the pasted top-left)}", true, paste), + spec!( + "edit.paste", + "Paste", + "{parent?, x?, y? (page coordinates of the pasted top-left)} a copied main component pastes as an instance of it within its file (cut ones, and pastes into other files, keep the component)", + true, + paste + ), spec!( "edit.pasteDesignLayers", "Paste design layers", @@ -466,7 +472,7 @@ fn duplicate(s: &mut Session, p: &Value) -> Result { let top_frame = doc.node(parent).is_some_and(Node::is_page) && n.kind.is_frame_like(); let idx = doc.index_in_parent(i).map(|x| x + 1); let w = n.width; - let nid = doc.duplicate_into(i, parent, idx)?; + let nid = crate::components::copy_in_file(doc, i, parent, idx)?; let (dx, dy) = match (num(p, "dx"), num(p, "dy")) { (Some(dx), dy) => (dx, dy.unwrap_or(0.0)), (None, Some(dy)) => (0.0, dy), @@ -481,7 +487,8 @@ fn duplicate(s: &mut Session, p: &Value) -> Result { nn.transform = Affine::translate((dx, dy)) * nn.transform; } } - if rename { + // An instance keeps the name instances get by default. + if rename && !matches!(doc.get(nid)?.kind, NodeKind::Instance { .. }) { let nn = doc.get_mut(nid)?; nn.name = bump_name(&nn.name); } diff --git a/crates/engine/src/components.rs b/crates/engine/src/components.rs index ea67bb07..67259d03 100644 --- a/crates/engine/src/components.rs +++ b/crates/engine/src/components.rs @@ -292,6 +292,21 @@ pub fn create_instance_at(doc: &mut Document, component: NodeId, parent: NodeId, Ok(id) } +/// Copy `id` within its own file under `parent` (at `index`), keeping its local transform. A file +/// has one main per component, so a main component's copy is an instance of it; a variant copied +/// into its set stays a new variant, and every other layer is deep-copied. +pub fn copy_in_file(doc: &mut Document, id: NodeId, parent: NodeId, index: Option) -> Result { + let n = doc.get(id)?; + let in_set = doc.node(parent).is_some_and(|p| matches!(p.kind, NodeKind::ComponentSet { .. })); + if !matches!(n.kind, NodeKind::Component { .. }) || in_set { + return doc.duplicate_into(id, parent, index); + } + let transform = n.transform; + let instance = create_instance_at(doc, id, parent, 0.0, 0.0, index)?; + doc.get_mut(instance)?.transform = transform; + Ok(instance) +} + /// Which part of the carried overrides to apply. #[derive(Clone, Copy, PartialEq)] enum Carry { diff --git a/crates/engine/src/components_behaviour_tests.rs b/crates/engine/src/components_behaviour_tests.rs index e068c6cf..ff78f73b 100644 --- a/crates/engine/src/components_behaviour_tests.rs +++ b/crates/engine/src/components_behaviour_tests.rs @@ -194,6 +194,67 @@ fn pasted_component_instances_keep_the_copied_transform_in_the_target_frame() { healthy(&s); } +#[test] +fn duplicating_a_main_component_makes_a_linked_instance() { + for rename in [false, true] { + let mut s = Session::new(); + ex(&mut s, "pref.renameDuplicates", json!({"on": rename})); + let k = kit(&mut s); + let rect = idv(&ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 300, "width": 50, "height": 50, "props": {"name": "Box"}}))); + let before = s.doc().unwrap().clone(); + let out = ex(&mut s, "edit.duplicate", json!({"ids": [k.card, rect]})); + let ids: Vec = out["ids"].as_array().unwrap().iter().filter_map(crate::params::id_of).collect(); + assert_eq!(s.selection(), ids); + let copy = *ids.iter().find(|i| matches!(node(&s, **i).kind, NodeKind::Instance { .. })).expect("the main's copy is an instance"); + assert_eq!(component_of(&s, copy), k.card); + assert_eq!(node(&s, copy).name, "Card", "instances keep their default name (rename={rename})"); + assert_eq!(node(&s, copy).parent, Some(s.page().unwrap())); + let (b, c) = (s.doc().unwrap().world_bounds(k.card).unwrap(), s.doc().unwrap().world_bounds(copy).unwrap()); + assert_eq!((c.x0, c.y0, c.width(), c.height()), (b.x1 + 100.0, b.y0, b.width(), b.height()), "a top-level frame's offset"); + let other = *ids.iter().find(|i| **i != copy).unwrap(); + assert!(matches!(node(&s, other).kind, NodeKind::Rectangle), "other layers are still copied"); + ex(&mut s, "node.set", json!({"id": k.title, "props": {"characters": "Updated"}})); + assert_eq!(chars(&s, copy_of(&s, copy, k.title)), "Updated", "the instance follows its main"); + ex(&mut s, "edit.undo", json!({})); + ex(&mut s, "edit.undo", json!({})); + assert_eq!(*s.doc().unwrap(), before, "undo restores exactly"); + healthy(&s); + } +} + +#[test] +fn alt_dragging_a_main_component_copies_an_instance() { + use crate::tools::{PointerEvent, PointerKind}; + let mut s = Session::new(); + let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 100, "height": 100, "name": "Tile"}))); + let main = idv(&ex(&mut s, "component.create", json!({"ids": [f]}))); + let ev = |kind, x| PointerEvent { alt: true, ..PointerEvent::at(kind, x, 50.0, 1.0) }; + s.pointer(&ev(PointerKind::Down, 50.0)).unwrap(); + s.pointer(&ev(PointerKind::Move, 150.0)).unwrap(); + s.pointer(&ev(PointerKind::Move, 250.0)).unwrap(); + s.pointer(&ev(PointerKind::Up, 250.0)).unwrap(); + let [copy] = s.selection()[..] else { panic!("one copy selected") }; + assert_eq!(component_of(&s, copy), main); + assert_eq!(s.doc().unwrap().world_bounds(copy).unwrap().x0, 200.0); + assert_eq!(s.doc().unwrap().world_bounds(main).unwrap().x0, 0.0, "the main stays"); + healthy(&s); +} + +#[test] +fn duplicating_a_variant_adds_a_variant_to_its_set() { + let mut s = Session::new(); + let (set, _, hover) = button_set(&mut s); + let copy = crate::params::id_of(&ex(&mut s, "edit.duplicate", json!({"ids": [hover]}))["ids"][0]).unwrap(); + assert!(matches!(node(&s, copy).kind, NodeKind::Component { .. })); + assert_eq!(node(&s, copy).parent, Some(set)); + assert_eq!(kids(&s, set).len(), 3); + // A whole set duplicates as a new set of variants. + let set2 = crate::params::id_of(&ex(&mut s, "edit.duplicate", json!({"ids": [set]}))["ids"][0]).unwrap(); + assert!(matches!(node(&s, set2).kind, NodeKind::ComponentSet { .. })); + assert!(kids(&s, set2).iter().all(|v| matches!(node(&s, *v).kind, NodeKind::Component { .. }))); + healthy(&s); +} + #[test] fn overridable_properties_stay_through_main_edits_and_reset() { // (what, layer, group, override on the instance, the same property edited on the main, read, diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index ad4d78d8..f0115e1f 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -1300,7 +1300,7 @@ impl Session { for i in &ids { let parent = doc.get(*i)?.parent.unwrap_or(page); let idx = doc.index_in_parent(*i).map(|x| x + 1); - copies.push(doc.duplicate_into(*i, parent, idx)?); + copies.push(crate::components::copy_in_file(doc, *i, parent, idx)?); } moving = copies; *sel = moving.clone(); diff --git a/docs/components-parity.md b/docs/components-parity.md index 46bc0fab..dcd93305 100644 --- a/docs/components-parity.md +++ b/docs/components-parity.md @@ -1,6 +1,6 @@ # Components, variants, styles and variables parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** minor (a bound colour variable's alpha applies once (#802); variables import reads `variables` outside `meta` and warns when it imports none (#790); variant properties reorder by drag; library files saved right after a read are read again; Hide when publishing note corrected; size and resizing overrides of layers inside instances (#731); variables export in the browser as a download; core editor leftovers: component playground, variables inside styles; layers and assets panels behaviour suite and fixes; extended collections tail; styles and variables behaviour suite and fixes; components behaviour suite and fixes; team libraries on the server, library usage and swapping a library; extended collections, variables import/export, gradient stops and variant properties bound to variables) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-20 · **Change:** minor (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 @@ -17,6 +17,7 @@ accounts). | 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; the instance itself resizes and its layers follow the main's constraints | | Component sets / variants, add variant, switch | done | | +| A main's copy within its file is an instance of it: Duplicate, Alt-drag, Copy then Paste | done | a variant duplicated in its set stays a new variant and a whole set copies as a new set; only the copied roots become instances (a main inside a copied frame is copied as a main); Cut then Paste and pastes into other files keep the component (#892) | | Switching variants keeps overrides where names match and both variants had the same value; text layers keep theirs | done | | | Default variant: the top-left one (new instances, Assets thumbnail, generated code) | done | | | Variant property order (names rewritten), duplicate combinations reported | done | drag a property in the Properties section to reorder it, one undo step (#679), or `component.editProperty {index}` | @@ -76,6 +77,7 @@ updates with overrides kept: `library_tests.rs`, `components_scale_tests.rs`. | Date | Change | Summary | |---|---|---| +| 2026-10-20 | minor | A main component's copy within its file is an instance of it (#892): Duplicate (`edit.duplicate`) and Alt-drag on the canvas made a second main; they now place an instance where the copy went (same offset, the instance's default name, selected), as Copy then Paste already did. A variant duplicated in its set stays a new variant. | | 2026-10-18 | minor | A bound colour variable's alpha applies once (#802): a `#RRGGBBAA` paint keeps its alpha in the paint opacity, and binding a variable with alpha left that opacity in place, so a 50% fill bound to a 50% variable drew at 25%. Binding (`variable.bind`, `node.set` `boundVariables`, `style.bind`) now restarts the paint's opacity at 100% and unbinding (also deleting the variable) moves the colour's alpha back into the opacity; `.fig` and REST imports take the variable's alpha back out of a bound paint's opacity, so `.fig` round trips no longer square it. Colour-picker edits of a colour variable or a plugin colour field, generated CSS strokes and the document colours list counted the paint opacity twice and now count it once (`bound_alpha_tests`). Numbers unchanged | | 2026-10-18 | minor | Variables import (#790): a `variables` map outside `meta` (or `meta.variables` next to top-level collections) is read, and an import that finds no variables, or fewer than the collections' `variableIds` list, says so in `warnings` instead of succeeding silently | | 2026-10-17 | minor | Variant properties reorder by dragging them in the Properties section, one undo step (#679; was "no drag in the panel yet"); a library file saved again right after it was read is read again, so a quick republish isn't missed (#652); the Hidden from publishing row no longer says the Hide when publishing item is missing (it landed 2026-10-12). Numbers unchanged |