From 62db8d4e36a90343ed7aae4f566fa1712b9dc1f7 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 12 Oct 2026 00:23:07 +0700 Subject: [PATCH] M3: components behaviour suite and fixes **A components and variants behaviour suite written from the public documentation, and fixes for every divergence it found.** Each documented rule of instances (what an instance overrides and what it can't, how overrides persist when the main changes, reset), variants (which overrides survive a switch, the default variant, property order, duplicate combinations), swaps (instance menu vs Assets panel), exposed nested instances, detach / reset all / push changes / go to main, resizing and auto layout instances is a table row with the values the rule implies and a one-line derivation; where the documentation is silent the row says so and pins today's behaviour. Nested instance overrides stay covered by `nested_override_tests`, slots by `components_tests`, accepting library updates with overrides kept by `library_tests` / `components_scale_tests` (#91). **Fixes (9 of the 10 new tests fail on the old code)** - **Overrides that were silently lost**: auto layout **padding**, **gap** and **alignment** (three override groups, so a padding override still follows the main's gap), **layout grids**, **export settings** and **prototype interactions** on layers inside instances, and every **text property** (resizing, paragraph spacing and indent, lists, max lines, OpenType features) were reset by the next edit of the main. They are override groups now: kept when the main changes that property, carried by variant switches and swaps, pushed to the main, reset per group, and listed in the Overrides section. REST imports map the matching `overriddenFields`. An instance's own export settings and interactions stay its own, like its place (as before). - **What an instance can't override is refused** (rolled back with a message, one place for every path: commands, MCP, gestures): moving, rotating, flipping, nudging or aligning a layer inside an instance (auto layout items included: their place is the layout's), resizing it (text boxes keep their main's bounds; text whose box follows its content still grows), changing its constraints or resizing (hug / fill / min / max / absolute), and an instance's auto layout direction or other auto layout settings (only padding, gap and alignment are its own). Before, these became `position` / `size` overrides or were lost on the next main edit. Resizing the instance itself still works and its layers follow the main's constraints. Existing `size` / `position` overrides in files are still read and kept. - **Variant switches** keep an override only where the layer's name path matches *and* both variants had the same value for that property; text layers keep all their changes (by name and hierarchy). Before, every same-named override survived, even where the new variant differs (a hover background overridden back to the default one). The instance's own overrides follow the same rule. Undo brings the dropped overrides back. - **Alt-drop swaps from the Assets panel** (`instance.swapAt`, `library.swapAt`) keep only text layers' overrides; the instance menu's swap (`instance.swap`) still keeps every override on same-named layers. - **Default variant = the top-left one** (was the first in layer order): new instances of a set, the set's Assets thumbnail, generated code, `component.properties` defaults and the panel's default dropdown. Picking another default moves that variant to the top-left corner (one undo step). - **Hidden exposed instances hide their properties** (`instance.exposed`). - **Push changes from a nested layer** goes to the outer instance's main, never into the nested instance's own main; it reuses the override copy code, so every group above is pushed too. **Added** - **Variant property order**: `component.editProperty {name, index}` moves a variant property; every variant's name follows. - **Duplicate combinations**: `component.properties` reports `conflicts` (variants sharing one combination of values), and the component panel warns until each has a unique one. - **Design panel**: Position, Rotation, Constraints, Dimensions / resizing and grid cell fields are greyed out (with a tooltip) for layers inside an instance; the auto layout direction, wrap, strokes / canvas stacking and add / remove auto layout are greyed out or hidden for instances. New strings go through `tr(...)` with `en.toml` / `es.toml` entries; disabled controls are announced as disabled. --- ROADMAP.md | 15 +- crates/engine/src/cmd/component.rs | 116 ++- crates/engine/src/cmd/props.rs | 14 +- crates/engine/src/component_codegen.rs | 10 +- crates/engine/src/components.rs | 253 +++++- .../engine/src/components_behaviour_tests.rs | 723 ++++++++++++++++++ crates/engine/src/components_scale_tests.rs | 15 +- crates/engine/src/components_tests.rs | 6 +- crates/engine/src/lib.rs | 7 +- crates/engine/src/tools.rs | 27 +- crates/format/src/rest/mod.rs | 6 + crates/format/src/rest/tests.rs | 4 +- crates/ui-egui/locales/en.toml | 6 +- crates/ui-egui/locales/es.toml | 6 +- crates/ui-egui/src/assets_panel.rs | 7 +- crates/ui-egui/src/component_panel.rs | 21 +- crates/ui-egui/src/layout_panel.rs | 27 +- crates/ui-egui/src/right.rs | 10 +- docs/components-parity.md | 29 +- docs/mcp.md | 27 +- docs/target-app-parity.md | 31 +- 21 files changed, 1211 insertions(+), 149 deletions(-) create mode 100644 crates/engine/src/components_behaviour_tests.rs diff --git a/ROADMAP.md b/ROADMAP.md index 089997ae..c5becc09 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~26 points and ~173–310 h away (one Opus 5.5 agent; ~35–70 h wall-clock with 5 agents) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7) FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow, native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through @@ -17,16 +17,16 @@ start with [`docs/gaps.md`](docs/gaps.md). | Feature breadth | **~80%** (core editor ~91%) | estimated, [`docs/target-app-parity.md`](docs/target-app-parity.md) | | Ready for real work | **~49%** | estimated, same doc (written dimension weights and scores) | | Essentials user | **~57%** | estimated: core features 77.8% × 0.92 launch/stability × 0.92 discoverability × 0.88 opening files people send ([method](docs/target-app-parity.md#essentials-user)) | -| Mainstream practitioner | **~46%** | estimated: weekly areas 68.1% × 0.9 interaction × 0.9 stability × 0.85 file exchange with Figma users ([method](docs/target-app-parity.md#mainstream-practitioner)) | +| Mainstream practitioner | **~47%** | estimated: weekly areas 68.8% × 0.9 interaction × 0.9 stability × 0.85 file exchange with Figma users ([method](docs/target-app-parity.md#mainstream-practitioner)) | | Remaining to beta | **~173–310 h** one agent | estimated | -| Remaining to full parity | **~320–615 h** one agent (~86–150 h wall-clock with 5 agents) | estimated | +| Remaining to full parity | **~315–607 h** one agent (~86–150 h wall-clock with 5 agents) | estimated | ### Readiness by audience | Audience | Ready % | Opus 5.5 agent wall-clock hours to ~95% | Work that dominates | |---|---|---|---| -| Full target (ready for real work) | ~49% | ~305–584 h | multiplayer (45–90), localization (45–75), plugins (15–40), the interaction pass (40–60), GPU canvas (30–60), AI (30–60), the `.fig` import's tail (8–15) | -| Mainstream practitioner | ~46% | ~140–259 h | core editor depth incl. the `.fig` import's tail (58–112), the interaction pass (40–60), real-file corpus and platform QA (25–50), `.fig` export for handing work back (15–30) | +| Full target (ready for real work) | ~49% | ~300–576 h | multiplayer (45–90), localization (45–75), plugins (15–40), the interaction pass (40–60), GPU canvas (30–60), AI (30–60), the `.fig` import's tail (8–15) | +| Mainstream practitioner | ~47% | ~135–251 h | core editor depth incl. the `.fig` import's tail (53–104), the interaction pass (40–60), real-file corpus and platform QA (25–50), `.fig` export for handing work back (15–30) | | Essentials user | ~57% | ~63–115 h | the `.fig` import's tail so the files people send open well (8–15), core tool polish (25–45), interaction pass on the core tools (15–25), stability QA (10–20), onboarding and starter templates (5–10) | Hours are one agent working sequentially, calibrated from this repo's history (about 1 agent-hour @@ -56,7 +56,7 @@ Figma's own languages on the landed localization layer (15–25 h). | Dimension | Ready | Hours | Doc | |---|---|---|---| -| Features (depth) | 57% (core editor 68%) | 59–112 | [target-app-parity](docs/target-app-parity.md) | +| Features (depth) | 57% (core editor 69%) | 54–104 | [target-app-parity](docs/target-app-parity.md) | | UI/UX fidelity | 58% | 40–60 | [ui-parity](docs/ui-parity.md) | | File formats | 55% (`.fig` read best effort, no write) | 8–15 | [file-format-parity](docs/file-format-parity.md) | | Collaboration | 13% (core in; live sessions with document sync, cursors and text carets, the browser joins, invite links open the app; a self-hosted server with edit and view links and team libraries; preview) | 49–98 | [collaboration-parity](docs/collaboration-parity.md) | @@ -82,7 +82,7 @@ Figma's own languages on the landed localization layer (15–25 h). | Frames, groups, sections, masks | 95% | 75% | 2–4 | [target-app-parity](docs/target-app-parity.md) | | Auto layout & constraints | 98% | 74% | 3–6 | [auto-layout-parity](docs/auto-layout-parity.md) | | Text | 87% | 68% | 5–10 | [target-app-parity](docs/target-app-parity.md) | -| Components & variants | 95% | 68% | 6–12 | [components-parity](docs/components-parity.md) | +| Components & variants | 96% | 72% | 4–9 | [components-parity](docs/components-parity.md) | | Styles & variables | 92% | 72% | 1–2 | [components-parity](docs/components-parity.md) | | Prototyping | 88% | 70% | 5–9 | [prototyping-parity](docs/prototyping-parity.md) | | Import/export | 65% | 35% | 30–60 | [file-format-parity](docs/file-format-parity.md) | @@ -195,6 +195,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | Components behaviour suite written from the public documentation (10 tests, 45 table cases) and its fixes: instances now keep overrides of auto layout padding, gap and alignment, layout grids, export settings, prototype interactions and every text property (resizing, paragraph spacing, lists…; these were lost when the main changed); moving, rotating, resizing or re-constraining layers inside an instance, and its auto layout direction, are refused (the instance itself resizes); switching variants keeps an override only where both variants had the same value (text layers keep theirs); swapping from the Assets panel keeps only text overrides; the default variant is the top-left one (instances, Assets thumbnails, code); hidden exposed instances hide their properties; pushing changes from a nested layer goes to the outer main. Added: variant property reordering and duplicate combination warnings. Components 95%/68% → 96%/72%, 6–12 h → 4–9 h; with #133's selection and transform change, core editor ready ~68% → ~69% and mainstream ~46% → ~47% (weekly areas 68.1% → 68.8%); full parity ~320–615 h → ~315–607 h (the totals now also carry #133's area 2 hours) | | 2026-10-11 | minor | Embedded image colour profiles (ICC in PNG/JPG/WebP/TIFF, PNG sRGB/gAMA/cHRM) converted into the file's colour space when decoded. Paint 85%/66% → 90%/67% (the row had lagged the parity doc), 5–9 h → 3–6 h; totals unchanged at this rounding | | 2026-10-11 | minor | Layers panel keyboard and stroke variables: F2 renames the selected layer (also from a layer row with the keyboard, which gets it back; Escape there cancels), Shift+F10 (`view.contextMenu`) opens the context menu at the focused row or below the canvas selection and the menu takes the keyboard, F8 (`select.area`) draws a selection box for keyboard-only users (arrows move it, Shift+arrows size it, Enter selects like a marquee; agents pass a box), long lists checked at 10k layers (revealing a layer, expanding or collapsing everything and arrow keys take at most 1.3 ms a frame in the release UI bench; the revealed row takes the keyboard); colour styles on strokes were already complete, and each side's stroke weight now binds to a number variable (`strokeTopWeight`…, bindable fields in the Design panel and REST import). Layers panel 95%/78% → 98%/82%, 2–4 h → 1–2 h; Strokes 90%/72% → 95%/75%, 1–3 h → 1–2 h; mainstream depth 67.8% → 68.1%; feature breadth (~80%), ready for real work (~49%), essentials (~57%) and mainstream (~46%) unchanged at this rounding; full parity ~321–618 h → ~320–615 h | | 2026-10-11 | minor | Text behaviour suite written from the public documentation (13 text cases, 5 command tests) and its fixes: CJK lines break between ideographs, kana and Hangul and before and after Latin words without breaking inside them, with the line-start and line-end rules (closing punctuation, small kana and the prolonged sound mark never start a line, opening brackets never end one); a style's line height, baseline and decoration come from its own font, so lines with CJK or emoji drawn by fallback fonts keep the spacing of the others; paragraph indent applies to start-aligned text only; carets, Delete and Backspace step over whole grapheme clusters (emoji sequences, flags, combining marks), which also shape with one font; double-click and word moves take one script's run in text without spaces. Added: vertical trim (cap height to baseline; `leadingTrim`, Type settings, REST import, inspect, CSS `text-box`). Text 85%/64% → 87%/68%, 7–13 h → 5–10 h; percentages unchanged at this rounding; full parity ~323–621 h → ~321–618 h | diff --git a/crates/engine/src/cmd/component.rs b/crates/engine/src/cmd/component.rs index 3d5ffa84..3a7ef497 100644 --- a/crates/engine/src/cmd/component.rs +++ b/crates/engine/src/cmd/component.rs @@ -126,7 +126,7 @@ pub fn specs() -> Vec { spec!( "component.resetOverride", "Reset override", - "{id?, group? (fills|strokes|effects|opacity|visible|name|corners|characters|textStyle|size|position|component|props|slot; all when omitted)}", + "{id?, group? (fills|strokes|effects|opacity|visible|name|corners|characters|textStyle|padding|gap|alignment|grids|exports|interactions|size|position|component|props|slot; all when omitted)}", true, reset_override ), @@ -164,7 +164,7 @@ pub fn specs() -> Vec { spec!( "instance.swapAt", "Swap instance at point", - "{component (component or set: its default variant), x, y (page point)} swaps the instance there (preferring one showing the same component set; Alt-drop from the Assets panel); overrides on layers with matching names stay", + "{component (component or set: its default variant), x, y (page point)} swaps the instance there (preferring one showing the same component set; Alt-drop from the Assets panel); only text layers keep their overrides (by matching names)", true, swap_at ), @@ -185,7 +185,7 @@ pub fn specs() -> Vec { spec!( "component.editProperty", "Edit component property", - "{id? (component or set), name, newName?, default?, value? + newValue? (rename a variant value), preferred? (instance swap and slot properties: component or set ids offered first)}", + "{id? (component or set), name, newName?, default? (variant properties: that variant takes the top-left place), value? + newValue? (rename a variant value), index? (variant properties: move to this place; the variants' names follow), preferred? (instance swap and slot properties: component or set ids offered first)}", true, edit_prop ), @@ -241,14 +241,11 @@ pub(crate) fn place(s: &mut Session, p: &Value) -> Result { Ok(json!({"id": i, "parent": slot.parent})) } -/// A component to instantiate: the component itself, or a set's default (first) variant. +/// A component to instantiate: the component itself, or a set's default (top-left) variant. pub(crate) fn main_of(doc: &Document, id: NodeId) -> Option { - let n = doc.node(id)?; - match n.kind { + match doc.node(id)?.kind { NodeKind::Component { .. } => Some(id), - NodeKind::ComponentSet { .. } => { - n.children.iter().copied().find(|c| doc.node(*c).is_some_and(|x| matches!(x.kind, NodeKind::Component { .. }))) - } + NodeKind::ComponentSet { .. } => default_variant(doc, id), _ => None, } } @@ -635,12 +632,14 @@ fn push_changes(s: &mut Session, p: &Value) -> Result { let cmd = "component.pushChanges"; let i = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select an instance"))?; let doc = s.doc_mut()?; - let inst = owning_instance(doc, i).ok_or_else(|| bad(cmd, "not an instance"))?; + // Changes go to the outer instance's main (never into a nested instance's own main). + let inst = root_instance(doc, i).ok_or_else(|| bad(cmd, "not an instance"))?; let Some(NodeKind::Instance { component, .. }) = doc.node(inst).map(|n| n.kind.clone()) else { return ok() }; if !doc.contains(component) { return Err(EngineError::Disabled(cmd.into(), "the main component was deleted (use component.restoreMain)".into())); } - // Copy every overridden layer's values to its source in the main. + // Copy every overridden layer's values to its source in the main (geometry, swaps, property + // values and slot content stay with the instance). let mut pushed = 0; for d in doc.subtree(inst) { let Some(n) = doc.node(d).cloned() else { continue }; @@ -649,53 +648,14 @@ fn push_changes(s: &mut Session, p: &Value) -> Result { continue; } let Some(mut target) = doc.node(src).cloned() else { continue }; - let mut groups = BTreeSet::new(); - for g in &n.overrides { - let done = match g.as_str() { - "fills" => { - target.fills = n.fills.clone(); - target.styles.fill = n.styles.fill.clone(); - true - } - "strokes" => { - target.strokes = n.strokes.clone(); - target.stroke = n.stroke.clone(); - target.styles.stroke = n.styles.stroke.clone(); - true - } - "effects" => { - target.effects = n.effects.clone(); - target.styles.effect = n.styles.effect.clone(); - true - } - "opacity" => { - target.opacity = n.opacity; - target.blend = n.blend; - true - } - "visible" => { - target.visible = n.visible; - true - } - "name" if d != inst => { - target.name = n.name.clone(); - true - } - "corners" => { - target.corners = n.corners; - true - } - "characters" | "textStyle" => { - if let (NodeKind::Text(a), NodeKind::Text(b)) = (&n.kind, &mut target.kind) { - *b = a.clone(); - } - true - } - _ => false, - }; - if done { - groups.insert(g.clone()); - } + let groups: BTreeSet = n + .overrides + .iter() + .filter(|g| !matches!(g.as_str(), "size" | "position" | "component" | "props" | SLOT) && !(d == inst && g.as_str() == "name")) + .cloned() + .collect(); + for g in &groups { + copy_group(&n, &mut target, g); } if groups.is_empty() { continue; @@ -829,6 +789,7 @@ pub(crate) fn swap_at(s: &mut Session, p: &Value) -> Result { let family = crate::drop::swap_family(doc, c); let target = crate::drop::swap_target(doc, page, pt, family.as_deref(), Some(c)) .ok_or_else(|| EngineError::Disabled(cmd.into(), "there is no instance at that point to swap".into()))?; + keep_text_overrides(s.doc_mut()?, target); swap(s, &json!({"ids": [target], "component": c}))?; s.set_selection(vec![target]); Ok(json!({"id": target, "component": c})) @@ -1248,7 +1209,14 @@ fn edit_prop(s: &mut Session, p: &Value) -> Result { } _ => None, }; - let order: Vec = order.into_iter().map(|k| if k == name { new_name.clone().unwrap_or(k) } else { k }).collect(); + let mut order: Vec = order.into_iter().map(|k| if k == name { new_name.clone().unwrap_or(k) } else { k }).collect(); + // Reordered: every variant's name lists the properties in the new order. + if let Some(to) = get(p, "index").filter(|v| !v.is_null()) { + let to = to.as_u64().and_then(|i| usize::try_from(i).ok()).ok_or_else(|| bad(cmd, "`index`: a property place (0 = first)"))?; + let key = new_name.clone().unwrap_or(name.clone()); + order.retain(|k| *k != key); + order.insert(to.min(order.len()), key); + } for c in &comps { let mut pairs = variant_props(&doc.get(*c)?.name); if let Some((v, nv)) = &rename_value @@ -1263,13 +1231,26 @@ fn edit_prop(s: &mut Session, p: &Value) -> Result { } doc.get_mut(*c)?.name = variant_name(&pairs, &order); } - // The default variant is the first one: move the first matching variant there. + // The default variant is the top-left one: the variant with that value (and the default's + // other values, when there is one) trades places with it. if let Some(dv) = string(p, "default") { let key = new_name.clone().unwrap_or(name.clone()); - let first = comps.iter().copied().find(|c| doc.node(*c).is_some_and(|n| variant_props(&n.name).get(&key) == Some(&dv))); - match first { - Some(f) => doc.reorder(f, 0)?, - None => return Err(bad(cmd, format!("no variant has {key}={dv}"))), + let current = default_variant(doc, set); + let mut wanted = current.and_then(|d| doc.node(d)).map(|n| variant_props(&n.name)).unwrap_or_default(); + wanted.insert(key.clone(), dv.clone()); + let props_of = |c: &NodeId| doc.node(*c).map(|n| variant_props(&n.name)).unwrap_or_default(); + let pick = + comps.iter().copied().find(|c| props_of(c) == wanted).or_else(|| comps.iter().copied().find(|c| props_of(c).get(&key) == Some(&dv))); + let (Some(f), Some(d)) = (pick, current) else { return Err(bad(cmd, format!("no variant has {key}={dv}"))) }; + if f != d { + let place = |doc: &Document, c: NodeId| doc.node(c).map(|n| n.transform.as_coeffs()).map(|[.., x, y]| (x, y)).unwrap_or_default(); + let (fp, dp) = (place(doc, f), place(doc, d)); + for (c, (x, y)) in [(f, dp), (d, fp)] { + let n = doc.get_mut(c)?; + let [a, b, cc, dd, ..] = n.transform.as_coeffs(); + n.transform = figcraft_geom::Affine::new([a, b, cc, dd, x, y]); + } + fit_set(doc, set); } } return ok(); @@ -1372,8 +1353,9 @@ fn describe_props(doc: &Document, id: NodeId) -> Value { let mut list: Vec = Vec::new(); if let Some(set) = variant_set(doc, comp) { let current = doc.node(comp).map(|c| variant_props(&c.name)).unwrap_or_default(); + let defaults = default_variant(doc, set).and_then(|d| doc.node(d)).map(|n| variant_props(&n.name)).unwrap_or_default(); for (k, vals) in variant_options(doc, set) { - let mut v = json!({"name": k, "type": "variant", "options": vals, "default": vals.first()}); + let mut v = json!({"name": k, "type": "variant", "options": vals, "default": defaults.get(&k).or(vals.first())}); if values.is_some() { v["value"] = json!(current.get(&k)); } @@ -1399,5 +1381,7 @@ fn describe_props(doc: &Document, id: NodeId) -> Value { } list.push(v); } - json!({"component": comp, "set": variant_set(doc, comp), "properties": list}) + let set = variant_set(doc, comp); + let conflicts = set.map(|s| variant_conflicts(doc, s)).unwrap_or_default(); + json!({"component": comp, "set": set, "properties": list, "conflicts": conflicts}) } diff --git a/crates/engine/src/cmd/props.rs b/crates/engine/src/cmd/props.rs index ec5dd947..b65ab5f2 100644 --- a/crates/engine/src/cmd/props.rs +++ b/crates/engine/src/cmd/props.rs @@ -718,11 +718,13 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map &'static str { "features" | "fontFeatures" | "openTypeFeatures" | "listStyle" | "maxLines" | "variationSettings" => "textStyle", "cornerRadius" | "cornerSmoothing" | "topLeftRadius" | "topRightRadius" | "bottomRightRadius" | "bottomLeftRadius" => "corners", "width" | "height" | "w" | "h" | "sizingH" | "sizingV" => "size", + "gap" | "itemSpacing" | "columnGap" | "crossGap" | "counterAxisSpacing" | "rowGap" => "gap", + "padding" | "paddingTop" | "paddingRight" | "paddingBottom" | "paddingLeft" | "paddingHorizontal" | "paddingVertical" => "padding", + "mainAlign" | "primaryAxisAlignItems" | "crossAlign" | "counterAxisAlignItems" => "alignment", + "grids" | "layoutGrids" | "gridStyle" => "grids", + "exports" | "exportSettings" => "exports", + "interactions" => "interactions", k if k.starts_with("font") || k.starts_with("text") || k.starts_with("letter") || k.starts_with("line") || k.starts_with("paragraph") => { "textStyle" } diff --git a/crates/engine/src/component_codegen.rs b/crates/engine/src/component_codegen.rs index 67135434..97f147c1 100644 --- a/crates/engine/src/component_codegen.rs +++ b/crates/engine/src/component_codegen.rs @@ -2,7 +2,7 @@ //! component whose properties are its parameters (a variant property → a union or enum, boolean → //! a flag showing the layers bound to it, text → a string, instance swap → a view parameter, slot → //! optional content), and an instance becomes a call passing the values it sets. Variants of a set -//! share one definition (the set's first variant); in React their differences become CSS rules on +//! share one definition (the set's default variant); in React their differences become CSS rules on //! `data-*` attributes. Returned by `inspect.componentCode` and used by `inspect.code`. A component //! connected to code (see [`crate::code_connect`]) shows its real snippet instead. @@ -53,7 +53,7 @@ pub struct Param { pub enum_name: String, } -/// A component as code: the main whose layers are drawn (a set's first variant), its set, its +/// A component as code: the main whose layers are drawn (a set's default variant), its set, its /// name in code and its parameters (variant properties first). #[derive(Clone, Debug, PartialEq)] pub struct Component { @@ -140,12 +140,12 @@ fn is_component(doc: &Document, id: NodeId) -> bool { } /// The main component a layer's code is defined by: a component itself, an instance's main, and -/// for anything in a set the set's first variant. +/// for anything in a set the set's default (top-left) variant. pub fn main_of(doc: &Document, id: NodeId) -> Option { let n = doc.node(id)?; let main = match &n.kind { NodeKind::Component { .. } => id, - NodeKind::ComponentSet { .. } => return n.children.iter().copied().find(|c| is_component(doc, *c)), + NodeKind::ComponentSet { .. } => return crate::components::default_variant(doc, id), NodeKind::Instance { component, .. } => *component, _ => return None, }; @@ -153,7 +153,7 @@ pub fn main_of(doc: &Document, id: NodeId) -> Option { return None; } match variant_set(doc, main) { - Some(set) => doc.node(set)?.children.iter().copied().find(|c| is_component(doc, *c)), + Some(set) => crate::components::default_variant(doc, set), None => Some(main), } } diff --git a/crates/engine/src/components.rs b/crates/engine/src/components.rs index 11d9b5b2..74c938ea 100644 --- a/crates/engine/src/components.rs +++ b/crates/engine/src/components.rs @@ -9,9 +9,18 @@ //! `prop_refs` on the layers they control. //! //! Override groups: `fills`, `strokes`, `effects`, `opacity`, `visible`, `name`, `corners`, -//! `characters`, `textStyle`, `size`, `position`, `component` (a swapped nested instance), -//! `props` (property values of a nested instance) and `slot` (a slot holding the instance's own -//! content). +//! `characters`, `textStyle` (font, size, spacing, resizing, …), `padding`, `gap` and `alignment` +//! (of auto layout: its direction and other settings are the main's), `grids`, `exports`, +//! `interactions`, `component` (a swapped nested instance), `props` (property values of a nested +//! instance) and `slot` (a slot holding the instance's own content). An instance's own export +//! settings and interactions are its own, like its place. `size` and `position` overrides of +//! layers inside an instance are still read and kept, but new ones are refused +//! ([`check_override_edit`]): those layers take their geometry, constraints and resizing from the +//! main; the instance itself resizes. +//! +//! Switching variants keeps an override where the layer's name path matches and the two variants +//! agreed on that property (text layers keep all of theirs); swapping from the Assets panel keeps +//! only text layers' overrides ([`keep_text_overrides`]). //! //! Instances never gain or lose layers of their own: commands that would add, remove or reorder //! layers inside an instance are refused ([`check_structure_edit`]), except inside **slots**. A @@ -42,6 +51,12 @@ pub const OVERRIDE_GROUPS: &[&str] = &[ "corners", "characters", "textStyle", + "padding", + "gap", + "alignment", + "grids", + "exports", + "interactions", "size", "position", "component", @@ -299,7 +314,7 @@ fn carry_overrides(from: &Node, to: &mut Node, what: Carry) { } /// Copy the values of property group `g` of `from` onto `to`, with the variables bound to them. -fn copy_group(from: &Node, to: &mut Node, g: &str) { +pub(crate) fn copy_group(from: &Node, to: &mut Node, g: &str) { if from.bound.iter().chain(to.bound.iter()).any(|(k, _)| binding_group(k) == Some(g)) { to.bound.retain(|k, _| binding_group(k) != Some(g)); to.bound.extend(bindings_of(from, g).map(|(k, v)| (k.clone(), v.clone()))); @@ -335,11 +350,32 @@ fn copy_group(from: &Node, to: &mut Node, g: &str) { if let (NodeKind::Text(a), NodeKind::Text(b)) = (&from.kind, &mut to.kind) { b.style = a.style.clone(); b.runs = a.runs.clone(); + b.auto_resize = a.auto_resize; b.align_h = a.align_h; b.align_v = a.align_v; + b.paragraph_spacing = a.paragraph_spacing; + b.paragraph_indent = a.paragraph_indent; + b.list = a.list; + b.max_lines = a.max_lines; + b.features = a.features.clone(); } to.styles.text = from.styles.text.clone(); } + "padding" | "gap" | "alignment" => { + if let (Some(a), Some(b)) = (&from.layout, &mut to.layout) { + match g { + "padding" => b.padding = a.padding, + "gap" => (b.gap, b.cross_gap) = (a.gap, a.cross_gap), + _ => (b.main_align, b.cross_align) = (a.main_align, a.cross_align), + } + } + } + "grids" => { + to.grids = from.grids.clone(); + to.styles.grid = from.styles.grid.clone(); + } + "exports" => to.exports = from.exports.clone(), + "interactions" => to.interactions = from.interactions.clone(), "size" => { to.width = from.width; to.height = from.height; @@ -506,7 +542,7 @@ fn sync_instance_depth(doc: &mut Document, inst: NodeId, depth: usize, spare: BT if depth > MAX_DEPTH { return; } - let Some(root) = doc.node(inst).cloned() else { return }; + let Some(mut root) = doc.node(inst).cloned() else { return }; let NodeKind::Instance { component, props: root_props } = &root.kind else { return }; let component = *component; let Some(comp) = doc.node(component).cloned() else { return }; @@ -572,6 +608,9 @@ fn sync_instance_depth(doc: &mut Document, inst: NodeId, depth: usize, spare: BT continue; } }; + if let Some(m) = old.get(&nid).and_then(|o| variant_kept(doc, o, *s)) { + old.insert(nid, std::sync::Arc::new(m)); + } if old.get(&nid).is_some_and(|o| holds_content(o)) && is_slot(doc, *s) { edited.insert(nid); } @@ -701,6 +740,11 @@ fn sync_instance_depth(doc: &mut Document, inst: NodeId, depth: usize, spare: BT _ => false, }); fresh.name = if renamed { root.name.clone() } else { default_instance_name(doc, component) }; + // Switched to another variant: its own overrides stay where both variants agreed. + if let Some(prev) = root.source.and_then(|p| doc.node(p)).filter(|p| sibling_variants(doc, p.id, component)) { + let differs = diff_groups(prev, &comp); + root.overrides.retain(|g| g == "name" || !differs.contains(&g.as_str())); + } carry_overrides(&root, &mut fresh, Carry::Root); if let Some(r) = doc.node_mut(inst) { *r = fresh; @@ -763,6 +807,43 @@ fn sync_instance_depth(doc: &mut Document, inst: NodeId, depth: usize, spare: BT } } +/// Whether main components `a` and `b` are two variants of one set. +fn sibling_variants(doc: &Document, a: NodeId, b: NodeId) -> bool { + a != b && variant_set(doc, a).is_some_and(|s| variant_set(doc, b) == Some(s)) +} + +/// Old copy `o` matched by name to main layer `s` of another variant (the instance switched +/// variants): `o` without the overrides of properties the two variants' layers differ in (`None` +/// when it keeps them all). Text layers keep every override. +fn variant_kept(doc: &Document, o: &Node, s: NodeId) -> Option { + let from = o.source.filter(|f| *f != s).and_then(|f| doc.node(f))?; + if o.overrides.is_empty() || matches!(o.kind, NodeKind::Text(_)) { + return None; + } + if !sibling_variants(doc, owning_component(doc, from.id)?, owning_component(doc, s)?) { + return None; + } + let differs = diff_groups(from, doc.node(s)?); + let mut m = o.clone(); + m.overrides.retain(|g| !differs.contains(&g.as_str())); + (m.overrides.len() != o.overrides.len()).then_some(m) +} + +/// Drop the overrides of instance `inst` but its text layers' (and its own size and the content of +/// its slots): what swapping it from the Assets panel keeps. +pub fn keep_text_overrides(doc: &mut Document, inst: NodeId) { + for d in old_layers(doc, inst).into_iter().chain([inst]) { + let Some(n) = doc.node(d) else { continue }; + let text = matches!(n.kind, NodeKind::Text(_)); + let keep = |g: &String| text || g == SLOT || (d == inst && g == "size"); + if !n.overrides.iter().all(keep) + && let Some(m) = doc.node_mut(d) + { + m.overrides.retain(keep); + } + } +} + /// Where auto layout places and sizes a rebuilt copy `n` of main layer `orig`, keep the old /// copy's `o` values (an instance's auto layout re-flows from its own content, so they usually /// differ from the main's): unchanged layers then stay unchanged, and auto layout re-flows wherever @@ -897,10 +978,30 @@ pub fn diff_groups(a: &Node, b: &Node) -> Vec<&'static str> { if x.characters != y.characters { out.push("characters"); } - if x.style != y.style || x.runs != y.runs || x.align_h != y.align_h || x.align_v != y.align_v { + if text_style_differs(x, y) { out.push("textStyle"); } } + if let (Some(x), Some(y)) = (&a.layout, &b.layout) { + if x.padding != y.padding { + out.push("padding"); + } + if x.gap != y.gap || x.cross_gap != y.cross_gap { + out.push("gap"); + } + if x.main_align != y.main_align || x.cross_align != y.cross_align { + out.push("alignment"); + } + } + if a.grids != b.grids || a.styles.grid != b.styles.grid { + out.push("grids"); + } + if a.exports != b.exports { + out.push("exports"); + } + if a.interactions != b.interactions { + out.push("interactions"); + } if let (NodeKind::Instance { component: x, props: px }, NodeKind::Instance { component: y, props: py }) = (&a.kind, &b.kind) { if x != y { out.push("component"); @@ -912,6 +1013,20 @@ pub fn diff_groups(a: &Node, b: &Node) -> Vec<&'static str> { out } +/// Whether two texts differ in anything but their characters (the `textStyle` group). +fn text_style_differs(x: &TextData, y: &TextData) -> bool { + x.style != y.style + || x.runs != y.runs + || x.auto_resize != y.auto_resize + || x.align_h != y.align_h + || x.align_v != y.align_v + || x.paragraph_spacing != y.paragraph_spacing + || x.paragraph_indent != y.paragraph_indent + || x.list != y.list + || x.max_lines != y.max_lines + || x.features != y.features +} + /// Whether a geometry change of `id` comes from layout rather than the user: an ancestor was /// resized in the same edit (constraints), the parent is an auto layout frame, or auto-sized text. fn geometry_derived(doc: &Document, before: &Document, id: NodeId, group: &str) -> bool { @@ -974,8 +1089,9 @@ pub fn sync_after_edit(doc: &mut Document, before: &Document, changed: &[NodeId] } let raw = diff_groups(b, n); let mut groups = raw.clone(); + // An instance's own place, export settings and interactions are its own, not overrides. if top_root { - groups.retain(|g| !matches!(*g, "component" | "props" | "position")); + groups.retain(|g| !matches!(*g, "component" | "props" | "position" | "exports" | "interactions")); } if groups.iter().any(|g| matches!(*g, "size" | "position")) { groups.retain(|g| !matches!(*g, "size" | "position") || !geometry_derived(doc, before, id, g)); @@ -1119,8 +1235,25 @@ fn full_rebuild() -> bool { /// values only (paint, text, visibility, size, position, …) that copies take as they are: carried to them without /// rebuilding the instances ([`patch_copies`]). `None` when anything else changed. fn patchable(doc: &Document, b: &Node, n: &Node) -> Option> { - const VALUES: [&str; 11] = - ["fills", "strokes", "effects", "opacity", "visible", "name", "corners", "characters", "textStyle", "size", "position"]; + const VALUES: [&str; 17] = [ + "fills", + "strokes", + "effects", + "opacity", + "visible", + "name", + "corners", + "characters", + "textStyle", + "padding", + "gap", + "alignment", + "grids", + "exports", + "interactions", + "size", + "position", + ]; if b.children != n.children || b.parent != n.parent || b.kind.type_name() != n.kind.type_name() { return None; } @@ -1314,14 +1447,18 @@ fn patch_copies(doc: &mut Document, seeds: &[(NodeId, Vec<&'static str>)]) -> bo { continue; } - // An instance keeps its own visibility and place; its name follows the main's unless - // renamed; a resized one lays its layers out against the main's size. + // An instance keeps its own visibility, place, export settings and interactions; its + // name follows the main's unless renamed; a resized one lays its layers out against + // the main's size. let root = matches!(pn.kind, NodeKind::Component { .. }); if root && ((groups.contains(&"name") && !cn.overrides.contains("name")) || (groups.contains(&"size") && cn.overrides.contains("size"))) { return false; } - let gs: Vec<&'static str> = - groups.iter().copied().filter(|g| !cn.overrides.contains(*g) && !(root && matches!(*g, "visible" | "name" | "position"))).collect(); + let gs: Vec<&'static str> = groups + .iter() + .copied() + .filter(|g| !cn.overrides.contains(*g) && !(root && matches!(*g, "visible" | "name" | "position" | "exports" | "interactions"))) + .collect(); if gs.is_empty() { continue; } @@ -1499,6 +1636,61 @@ pub fn check_structure_edit(before: &Document, after: &Document) -> Result<(), S Ok(()) } +/// Refuse edits an instance doesn't take as overrides ([`refused_override`]); the edit is rolled +/// back. +pub fn check_override_edit(before: &Document, after: &Document) -> Result<(), String> { + match after.dirty().iter().find_map(|id| refused_override(before, after, *id)) { + Some(what) => Err(format!("{what} comes from the main component in an instance: edit the main component, or detach the instance")), + None => Ok(()), + } +} + +/// What about layer `id` an edit from `before` to `after` changed that only its main can: the +/// auto layout of an instance or a layer in one but its padding, gap and alignment, or the +/// position (rotation too), size, constraints or resizing of a layer inside an instance (the +/// instance itself resizes, and its layers follow; text whose box follows its content still +/// does). `None` when the edit is fine, also when the main changed the same way in this edit (a +/// library update). +pub fn refused_override(before: &Document, after: &Document, id: NodeId) -> Option<&'static str> { + let (n, b) = (after.node(id)?, before.node(id)?); + if after.is_remote(id) || n.source != b.source || owning_instance(before, id).is_none() || owning_instance(after, id).is_none() { + return None; + } + let (mb, ma) = (before.node(n.source?)?, after.node(n.source?)?); + let mode = |x: &Node| x.layout.as_ref().map(|l| l.mode); + // Auto layout settings other than the overridable ones. + let settings = |x: &Node| { + x.layout.clone().map(|mut l| { + (l.gap, l.cross_gap, l.padding, l.main_align, l.cross_align) = (0.0, 0.0, [0.0; 4], MainAlign::Min, CrossAlign::Min); + l + }) + }; + let size = |x: &Node| (x.width, x.height); + let resized = |x: &Node, y: &Node| (x.width - y.width).abs() > 1e-6 || (x.height - y.height).abs() > 1e-6; + if mode(n) != mode(b) && mode(ma) == mode(mb) { + return Some("The auto layout direction"); + } + if settings(n) != settings(b) && settings(ma) == settings(mb) { + return Some("This auto layout setting"); + } + if !inside_instance(after, id) || !inside_instance(before, id) { + return None; + } + if n.transform != b.transform && ma.transform == mb.transform && !geometry_derived(after, before, id, "position") { + return Some("A layer's position"); + } + if resized(n, b) && size(ma) == size(mb) && !geometry_derived(after, before, id, "size") { + return Some("A layer's size"); + } + if n.constraints != b.constraints && ma.constraints == mb.constraints { + return Some("A layer's constraints"); + } + if n.child_layout != b.child_layout && ma.child_layout == mb.child_layout { + return Some("A layer's resizing"); + } + None +} + /// Re-derive `source` links and overrides of instance `inst` against its own main by comparing /// values (after detaching an outer instance, restoring a main, or copying a nested instance out): /// the look is kept exactly. Values a component property sets aren't overrides. @@ -1545,7 +1737,10 @@ pub fn rebase_instance(doc: &mut Document, inst: NodeId) { let root_groups = match (doc.node(component), doc.node(inst)) { (Some(m), Some(n)) => { let from_variables = variable_groups(n, m); - let mut groups: Vec = value_groups(m, n).into_iter().filter(|g| g != "name" && !from_variables.contains(&g.as_str())).collect(); + let mut groups: Vec = value_groups(m, n) + .into_iter() + .filter(|g| !matches!(g.as_str(), "name" | "exports" | "interactions") && !from_variables.contains(&g.as_str())) + .collect(); // A layer name of its own (a nested instance renamed in its old main) stays. if n.name != default_instance_name(doc, component) { groups.push("name".into()); @@ -1561,6 +1756,32 @@ pub fn rebase_instance(doc: &mut Document, inst: NodeId) { } } +/// A set's default variant: the one in its top-left corner (nearest to it, then the top-most, then +/// the left-most). +pub fn default_variant(doc: &Document, set: NodeId) -> Option { + let corner = |c: &NodeId| { + doc.node(*c).filter(|n| matches!(n.kind, NodeKind::Component { .. })).map(|n| n.transform.as_coeffs()).map(|[.., x, y]| (x + y, y, x)) + }; + let kids = &doc.node(set)?.children; + kids.iter() + .copied() + .filter_map(|c| corner(&c).map(|k| (k, c))) + .min_by(|(a, _), (b, _)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)) + .map(|(_, c)| c) +} + +/// Variants of a set sharing one combination of property values (each group two or more, in +/// layer order): a conflict to resolve by giving each a unique combination. +pub fn variant_conflicts(doc: &Document, set: NodeId) -> Vec> { + let mut by: BTreeMap, Vec> = BTreeMap::new(); + for c in doc.node(set).map(|s| s.children.clone()).unwrap_or_default() { + if let Some(n) = doc.node(c).filter(|n| matches!(n.kind, NodeKind::Component { .. })) { + by.entry(variant_props(&n.name)).or_default().push(c); + } + } + by.into_values().filter(|v| v.len() > 1).collect() +} + /// Parse a variant name "Size=Large, State=Hover" into pairs. pub fn variant_props(name: &str) -> BTreeMap { name.split(',') @@ -1671,7 +1892,9 @@ pub fn exposed_copies(doc: &Document, id: NodeId) -> Vec { break; } let Some(n) = doc.node(c) else { continue }; - let exposed = matches!(n.kind, NodeKind::Instance { .. }) && n.source.and_then(|src| doc.node(src)).is_some_and(|src| src.exposed); + // A hidden exposed instance hides its properties too. + let exposed = + n.visible && matches!(n.kind, NodeKind::Instance { .. }) && n.source.and_then(|src| doc.node(src)).is_some_and(|src| src.exposed); if exposed { out.push(c); } diff --git a/crates/engine/src/components_behaviour_tests.rs b/crates/engine/src/components_behaviour_tests.rs new file mode 100644 index 00000000..325ccdee --- /dev/null +++ b/crates/engine/src/components_behaviour_tests.rs @@ -0,0 +1,723 @@ +//! Components and variants behaviour suite: each documented rule of instances, overrides, variants, +//! swaps, detach / reset / push and resizing is a table row with a one-line derivation. Where the +//! documentation is silent the row says so and pins today's behaviour. Nested instance overrides +//! are covered in `nested_override_tests`, slots in `components_tests`, accepting library updates +//! with overrides kept in `library_tests` and `components_scale_tests`. + +use serde_json::{Value, json}; + +use crate::Session; +use crate::components::tests::{chars, copy_of, ex, fill, healthy, idv, kids, node}; +use figcraft_doc::*; + +/// The card: a 200×100 component with a "Bg" rectangle, a "Title" text and a "Row" auto layout +/// frame (padding 4, gap 2) holding a "Chip" rectangle; and one instance of it. +struct Kit { + card: NodeId, + bg: NodeId, + title: NodeId, + row: NodeId, + chip: NodeId, + inst: NodeId, +} + +#[derive(Clone, Copy, Debug)] +enum Layer { + Root, + Bg, + Title, + Row, + Chip, +} + +impl Kit { + fn main(&self, l: Layer) -> NodeId { + match l { + Layer::Root => self.card, + Layer::Bg => self.bg, + Layer::Title => self.title, + Layer::Row => self.row, + Layer::Chip => self.chip, + } + } + + /// The instance's copy of main layer `l`. + fn copy(&self, s: &Session, l: Layer) -> NodeId { + match l { + Layer::Root => self.inst, + _ => copy_of(s, self.inst, self.main(l)), + } + } +} + +fn kit(s: &mut Session) -> Kit { + let f = idv(&ex(s, "frame.create", json!({"x": 0, "y": 0, "width": 200, "height": 100, "name": "Card"}))); + let bg = + idv(&ex(s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 200, "height": 100, "parent": f, "props": {"name": "Bg", "fill": "#eeeeee"}}))); + let title = idv(&ex(s, "text.create", json!({"text": "Hello", "x": 10, "y": 10, "parent": f, "props": {"name": "Title"}}))); + let row = idv(&ex(s, "frame.create", json!({"x": 10, "y": 50, "width": 120, "height": 30, "parent": f, "name": "Row"}))); + ex(s, "node.set", json!({"id": row, "props": {"layout": "horizontal", "padding": 4, "gap": 2}})); + let chip = idv(&ex( + s, + "shape.rectangle", + json!({"x": 0, "y": 0, "width": 20, "height": 20, "parent": row, "props": {"name": "Chip", "fill": "#333333"}}), + )); + let card = idv(&ex(s, "component.create", json!({"ids": [f]}))); + let inst = idv(&ex(s, "instance.create", json!({"component": card, "x": 300, "y": 0}))); + Kit { card, bg, title, row, chip, inst } +} + +fn undo_len(s: &Session) -> usize { + s.ds().unwrap().history.undo.len() +} + +fn component_of(s: &Session, id: NodeId) -> NodeId { + match node(s, id).kind { + NodeKind::Instance { component, .. } => component, + _ => panic!("{id} is not an instance"), + } +} + +fn text(n: &Node) -> &TextData { + n.kind.text().unwrap() +} + +#[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, + // derivation) + type Row = (&'static str, Layer, &'static str, Value, Option, fn(&Node) -> String, &'static str); + let grid = serde_json::to_value(LayoutGrid::default()).unwrap(); + let export = serde_json::to_value(ExportSetting::next_after(None)).unwrap(); + let click = json!({"trigger": {"type": "onClick"}, "actions": [{"type": "back"}]}); + let rows: Vec = vec![ + ( + "fill", + Layer::Bg, + "fills", + json!({"fill": "#00ff00"}), + Some(json!({"fill": "#123456"})), + |n| format!("{:?}", n.fills), + "\"change the fill … of any layers\"", + ), + ( + "fill of the instance itself", + Layer::Root, + "fills", + json!({"fill": "#abcdef"}), + Some(json!({"fill": "#fedcba"})), + |n| format!("{:?}", n.fills), + "the instance is a layer too", + ), + ( + "stroke", + Layer::Bg, + "strokes", + json!({"stroke": "#0000ff", "strokeWeight": 3}), + Some(json!({"strokeWeight": 7})), + |n| format!("{:?} {:?}", n.strokes, n.stroke), + "\"change the … stroke of any layers\"", + ), + ( + "effects", + Layer::Bg, + "effects", + json!({"effects": [{"type": "dropShadow"}]}), + Some(json!({"effects": [{"type": "dropShadow"}, {"type": "dropShadow"}]})), + |n| n.effects.len().to_string(), + "\"add, edit, or remove any shadow or blur effects\"", + ), + ("visibility", Layer::Bg, "visible", json!({"visible": false}), None, |n| n.visible.to_string(), "hidden layers stand in for removed ones"), + ("layer name", Layer::Bg, "name", json!({"name": "Back"}), Some(json!({"name": "Bg2"})), |n| n.name.clone(), "\"change the layer name\""), + ( + "text content", + Layer::Title, + "characters", + json!({"characters": "Override"}), + Some(json!({"characters": "Main"})), + |n| text(n).characters.clone(), + "text content is overridable", + ), + ( + "font size", + Layer::Title, + "textStyle", + json!({"fontSize": 30}), + Some(json!({"fontSize": 18})), + |n| text(n).style.font_size.to_string(), + "\"change text properties, including font, weight, size …\"", + ), + ( + "text resizing", + Layer::Title, + "textStyle", + json!({"textAutoResize": "height"}), + Some(json!({"textAutoResize": "none"})), + |n| format!("{:?}", text(n).auto_resize), + "\"… letter spacing, and resizing\"", + ), + ( + "paragraph spacing", + Layer::Title, + "textStyle", + json!({"paragraphSpacing": 8}), + Some(json!({"paragraphSpacing": 2})), + |n| text(n).paragraph_spacing.to_string(), + "a text property like the others", + ), + ( + "padding", + Layer::Row, + "padding", + json!({"padding": 9}), + Some(json!({"padding": 1})), + |n| format!("{:?}", n.layout.as_ref().map(|l| l.padding)), + "\"adjust vertical and horizontal padding\" on instances", + ), + ( + "gap", + Layer::Row, + "gap", + json!({"gap": 12}), + Some(json!({"gap": 6})), + |n| format!("{:?}", n.layout.as_ref().map(|l| l.gap)), + "\"adjust gap between\" on instances", + ), + ( + "layout grids", + Layer::Row, + "grids", + json!({"grids": [grid.clone()]}), + Some(json!({"grids": [grid.clone(), grid.clone()]})), + |n| n.grids.len().to_string(), + "\"add, edit, or remove layout guides\"", + ), + ( + "layout grids of the instance", + Layer::Root, + "grids", + json!({"grids": [grid.clone()]}), + Some(json!({"grids": [grid.clone(), grid.clone()]})), + |n| n.grids.len().to_string(), + "\"add, edit, or remove layout guides\"", + ), + ( + "export settings", + Layer::Bg, + "exports", + json!({"exports": [export.clone()]}), + Some(json!({"exports": [export.clone(), export.clone()]})), + |n| n.exports.len().to_string(), + "\"add, edit, or remove export settings\"", + ), + ( + "prototype interactions", + Layer::Bg, + "interactions", + json!({"interactions": [click.clone()]}), + Some(json!({"interactions": [click.clone(), click.clone()]})), + |n| n.interactions.len().to_string(), + "interactions on an instance win over the inherited ones", + ), + ( + "opacity", + Layer::Bg, + "opacity", + json!({"opacity": 0.5}), + Some(json!({"opacity": 0.8})), + |n| n.opacity.to_string(), + "documentation silent (pinned)", + ), + ( + "corner radius", + Layer::Bg, + "corners", + json!({"cornerRadius": 6}), + Some(json!({"cornerRadius": 2})), + |n| format!("{:?}", n.corners.radii), + "documentation silent (pinned)", + ), + ]; + for (what, layer, group, over, main_edit, read, why) in rows { + let mut s = Session::new(); + let k = kit(&mut s); + let (main, copy) = (k.main(layer), k.copy(&s, layer)); + let steps = undo_len(&s); + ex(&mut s, "node.set", json!({"id": copy, "props": over})); + assert_eq!(undo_len(&s), steps + 1, "{what}: one undo step"); + assert!(node(&s, copy).overrides.contains(group), "{what}: a `{group}` override ({why}); has {:?}", node(&s, copy).overrides); + let value = read(&node(&s, copy)); + assert_ne!(value, read(&node(&s, main)), "{what}: the override shows"); + if let Some(m) = main_edit { + ex(&mut s, "node.set", json!({"id": main, "props": m})); + assert_eq!(read(&node(&s, copy)), value, "{what}: kept when the main changes the same property ({why})"); + } + // A structure edit of the main rebuilds every instance. + ex(&mut s, "shape.rectangle", json!({"x": 150, "y": 60, "width": 10, "height": 10, "parent": k.card})); + healthy(&s); + assert_eq!(read(&node(&s, copy)), value, "{what}: kept through a rebuild"); + ex(&mut s, "component.resetOverride", json!({"id": copy, "group": group})); + assert_eq!(read(&node(&s, copy)), read(&node(&s, main)), "{what}: reset shows the main's value"); + assert!(!node(&s, copy).overrides.contains(group), "{what}: reset clears the override"); + healthy(&s); + } +} + +#[test] +fn unrelated_main_edits_still_reach_overridden_layers() { + // (what, layer, override, main edit of another property, read, expected, derivation) + type Row = (&'static str, Layer, Value, Value, fn(&Node) -> String, &'static str, &'static str); + let rows: Vec = vec![ + ( + "text content override, main font size", + Layer::Title, + json!({"characters": "Mine"}), + json!({"fontSize": 24}), + |n| format!("{} {}", text(n).characters, text(n).style.font_size), + "Mine 24", + "only the overridden property stays; the font follows the main", + ), + ( + "fill override, main corner radius", + Layer::Bg, + json!({"fill": "#00ff00"}), + json!({"cornerRadius": 5}), + |n| format!("{} {:?}", n.fills.first().and_then(|p| p.solid_color()).map(|c| c.to_hex()).unwrap_or_default(), n.corners.radii), + "00FF00 [5.0, 5.0, 5.0, 5.0]", + "changes to the main reach every property not overridden", + ), + ( + "padding override, main direction", + Layer::Row, + json!({"padding": 9}), + json!({"layout": "vertical"}), + |n| format!("{:?}", n.layout.as_ref().map(|l| (l.mode, l.padding))), + "Some((Vertical, [9.0, 9.0, 9.0, 9.0]))", + "the direction isn't an instance's to change, so it follows the main", + ), + ]; + for (what, layer, over, main_edit, read, expected, why) in rows { + let mut s = Session::new(); + let k = kit(&mut s); + let copy = k.copy(&s, layer); + ex(&mut s, "node.set", json!({"id": copy, "props": over})); + ex(&mut s, "node.set", json!({"id": k.main(layer), "props": main_edit})); + healthy(&s); + assert_eq!(read(&node(&s, copy)), expected, "{what}: {why}"); + } +} + +#[test] +fn what_instances_cannot_override_is_refused() { + // (what, command, params (`$` stands for the instance's copy of the layer), layer, derivation) + type Row = (&'static str, &'static str, Value, Layer, &'static str); + let rows: Vec = vec![ + ("move a layer", "node.set", json!({"id": "$", "props": {"x": 50}}), Layer::Bg, "\"position of any layers within the component\""), + ("nudge a layer", "transform.nudge", json!({"ids": ["$"], "dx": 1, "dy": 0}), Layer::Bg, "position"), + ("rotate a layer", "node.set", json!({"id": "$", "props": {"rotation": 15}}), Layer::Bg, "position and rotation come with the layer's place"), + ("flip a layer", "object.flipH", json!({"ids": ["$"]}), Layer::Bg, "position"), + ( + "resize a layer", + "node.set", + json!({"id": "$", "props": {"width": 50}}), + Layer::Bg, + "not among the overridable properties; the instance resizes", + ), + ("resize a text box", "node.set", json!({"id": "$", "props": {"width": 150}}), Layer::Title, "\"the bounds of any text layers\""), + ( + "change constraints", + "node.set", + json!({"id": "$", "props": {"constraints": {"horizontal": "right"}}}), + Layer::Bg, + "\"any constraints applied to the layers\"", + ), + ( + "make a layer fill its auto layout", + "node.set", + json!({"id": "$", "props": {"sizingH": "fill"}}), + Layer::Chip, + "resizing comes with the layer's size", + ), + ( + "take a layer out of auto layout", + "node.set", + json!({"id": "$", "props": {"absolute": true}}), + Layer::Chip, + "position, including items in an auto layout frame", + ), + ( + "change the auto layout direction", + "node.set", + json!({"id": "$", "props": {"layout": "vertical"}}), + Layer::Row, + "only padding and gap are adjustable on instances", + ), + ("remove auto layout", "layout.remove", json!({"ids": ["$"]}), Layer::Row, "structure of the instance"), + ("reorder layers", "node.reorder", json!({"id": "$", "index": 3}), Layer::Bg, "\"the order, or z-index, of any layers within the instance\""), + ("delete a layer", "edit.delete", json!({"ids": ["$"]}), Layer::Bg, "layers can only be hidden"), + ( + "add a layer", + "shape.rectangle", + json!({"x": 0, "y": 0, "width": 5, "height": 5, "parent": "$"}), + Layer::Root, + "\"not possible to make changes to the underlying structure\"", + ), + ]; + for (what, cmd, params, layer, why) in rows { + let mut s = Session::new(); + let k = kit(&mut s); + let target = k.copy(&s, layer); + let p: Value = serde_json::from_str(¶ms.to_string().replace("\"$\"", &target.0.to_string())).unwrap(); + let (before, steps) = (s.doc().unwrap().clone(), undo_len(&s)); + assert!(s.execute(cmd, &p).is_err(), "{what} is refused ({why})"); + assert_eq!(*s.doc().unwrap(), before, "{what}: nothing changed"); + assert_eq!(undo_len(&s), steps, "{what}: no undo step"); + } + // The same edits on the main, and resizing the instance itself, are fine. + let mut s = Session::new(); + let k = kit(&mut s); + ex(&mut s, "node.set", json!({"id": k.bg, "props": {"x": 5, "width": 150, "constraints": {"horizontal": "leftRight"}}})); + ex(&mut s, "node.set", json!({"id": k.inst, "props": {"width": 260}})); + let ibg = k.copy(&s, Layer::Bg); + assert_eq!((node(&s, ibg).transform.as_coeffs()[4], node(&s, ibg).width), (5.0, 210.0), "the main's constraints apply when the instance resizes"); + assert!(node(&s, ibg).overrides.is_empty(), "layout isn't an override"); + // Placing an auto layout item is the layout's job: the x is ignored, not refused. + let ichip = k.copy(&s, Layer::Chip); + let x0 = node(&s, ichip).transform.as_coeffs()[4]; + ex(&mut s, "node.set", json!({"id": ichip, "props": {"x": 60}})); + assert_eq!(node(&s, ichip).transform.as_coeffs()[4], x0, "auto layout puts the item back"); + assert!(!node(&s, ichip).overrides.contains("position")); + // The instance's own export settings are its own like its place (documentation silent; pinned). + let export = serde_json::to_value(ExportSetting::next_after(None)).unwrap(); + ex(&mut s, "node.set", json!({"id": k.inst, "props": {"exports": [export]}})); + ex(&mut s, "shape.rectangle", json!({"x": 150, "y": 60, "width": 10, "height": 10, "parent": k.card})); + assert_eq!((node(&s, k.inst).exports.len(), node(&s, k.inst).overrides.contains("exports")), (1, false)); + healthy(&s); +} + +/// A "Button" set: State=Default (Bg white, Label "Default" dark, Badge, Dot) and State=Hover (Bg +/// grey, Label "Hover" blue, no Badge, the same Dot), both with a black stroke on Bg and a +/// "Show dot" boolean bound to Dot. Returns (set, default, hover). +fn button_set(s: &mut Session) -> (NodeId, NodeId, NodeId) { + let mut variants = Vec::new(); + for (k, (state, bg, label)) in [("Default", "#ffffff", "#222222"), ("Hover", "#eeeeee", "#0000ff")].into_iter().enumerate() { + let f = idv(&ex(s, "frame.create", json!({"x": k as f64 * 200.0, "y": 0, "width": 120, "height": 40, "name": format!("State={state}")}))); + ex(s, "node.set", json!({"id": f, "props": {"fill": "#fafafa", "cornerRadius": 4 + 4 * k}})); + let props = json!({"name": "Bg", "fill": bg, "stroke": "#000000", "strokeWeight": 1}); + ex(s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 120, "height": 40, "parent": f, "props": props})); + ex(s, "text.create", json!({"text": state, "x": 10, "y": 10, "parent": f, "props": {"name": "Label", "fill": label}})); + if k == 0 { + ex(s, "shape.ellipse", json!({"x": 90, "y": 4, "width": 8, "height": 8, "parent": f, "props": {"name": "Badge", "fill": "#ff0000"}})); + } + ex(s, "shape.ellipse", json!({"x": 104, "y": 4, "width": 8, "height": 8, "parent": f, "props": {"name": "Dot", "fill": "#00aa00"}})); + variants.push(idv(&ex(s, "component.create", json!({"ids": [f]})))); + } + let set = idv(&ex(s, "component.combineVariants", json!({"ids": variants.clone()}))); + ex(s, "component.addProperty", json!({"id": set, "name": "Show dot", "type": "boolean", "default": true})); + for v in &variants { + let dot = kids(s, *v).into_iter().find(|c| node(s, *c).name == "Dot").unwrap(); + ex(s, "component.bindProperty", json!({"layer": dot, "property": "Show dot"})); + } + (set, variants[0], variants[1]) +} + +/// The direct layer of instance `inst` mirroring a main layer named `name` (renames don't +/// matter); `None` when there is none. +fn layer(s: &Session, inst: NodeId, name: &str) -> Option { + kids(s, inst).into_iter().find(|c| node(s, *c).source.is_some_and(|src| node(s, src).name == name)) +} + +#[test] +fn switching_variants_keeps_overrides_where_the_variants_agreed() { + let mut s = Session::new(); + let (set, default, hover) = button_set(&mut s); + let i = idv(&ex(&mut s, "instance.create", json!({"component": set, "x": 0, "y": 200}))); + assert_eq!(component_of(&s, i), default); + // (what, layer ("" = the instance), override, kept after the switch, derivation) + type Row = (&'static str, &'static str, Value, bool, &'static str); + let rows: Vec = vec![ + ( + "Bg fill (the variants differ)", + "Bg", + json!({"fill": "#ff00ff"}), + false, + "kept only if the property \"originally matched between variants\"", + ), + ("Bg stroke (the variants agree)", "Bg", json!({"strokeWeight": 4}), true, "names match and the stroke matched"), + ("Label text (the variants differ)", "Label", json!({"characters": "Buy"}), true, "text layers keep their changes when their names match"), + ("Label colour (the variants differ)", "Label", json!({"fill": "#ff8800"}), true, "\"any changes you've made to text layers\""), + ("Dot renamed and faded (the variants agree)", "Dot", json!({"name": "My dot", "opacity": 0.5}), true, "renaming doesn't break matching"), + ("Badge (only in Default)", "Badge", json!({"fill": "#000000"}), false, "no layer of that name in the new variant"), + ("the instance's fill (the variants agree)", "", json!({"fill": "#123456"}), true, "names match and the fill matched"), + ("the instance's corners (the variants differ)", "", json!({"cornerRadius": 12}), false, "the corner radius didn't match"), + ]; + let target = |s: &Session, name: &str| if name.is_empty() { Some(i) } else { layer(s, i, name) }; + let read = |x: &Node, k: &str| match k { + "fill" => format!("{:?}", x.fills), + "strokeWeight" => x.stroke.weight.to_string(), + "characters" => text(x).characters.clone(), + "name" => x.name.clone(), + "opacity" => x.opacity.to_string(), + "cornerRadius" => format!("{:?}", x.corners.radii), + other => panic!("no reader for `{other}`"), + }; + // What each override shows before the switch. + let mut shown: std::collections::BTreeMap> = Default::default(); + for (what, name, over, _, _) in &rows { + let id = target(&s, name).unwrap(); + ex(&mut s, "node.set", json!({"id": id, "props": over})); + assert!(!node(&s, id).overrides.is_empty(), "{what}: an override"); + shown.insert(what.to_string(), over.as_object().unwrap().keys().map(|k| read(&node(&s, id), k)).collect()); + } + ex(&mut s, "instance.setProperties", json!({"ids": [i], "properties": {"Show dot": false}})); + let steps = undo_len(&s); + ex(&mut s, "instance.setProperties", json!({"ids": [i], "properties": {"State": "Hover"}})); + assert_eq!(undo_len(&s), steps + 1, "one undo step"); + healthy(&s); + assert_eq!(component_of(&s, i), hover); + for (what, name, over, kept, why) in &rows { + let Some(id) = target(&s, name) else { + assert!(!kept, "{what}: gone ({why})"); + continue; + }; + let (n, main) = (node(&s, id), node(&s, if id == i { hover } else { node(&s, id).source.unwrap() })); + for (k, overridden) in over.as_object().unwrap().keys().zip(&shown[&what.to_string()]) { + let expected = if *kept { overridden.clone() } else { read(&main, k) }; + assert_eq!(read(&n, k), expected, "{what}: {} ({why})", if *kept { "kept" } else { "shows the new variant" }); + } + } + let dot = layer(&s, i, "Dot").unwrap(); + assert!(!node(&s, dot).visible, "property values stay across variants (documentation silent; pinned)"); + // Switching back doesn't bring dropped overrides back. + ex(&mut s, "instance.setProperties", json!({"ids": [i], "properties": {"State": "Default"}})); + assert_eq!(fill(&s, layer(&s, i, "Bg").unwrap()), "FFFFFF"); + assert_eq!(chars(&s, layer(&s, i, "Label").unwrap()), "Buy"); + s.undo().unwrap(); + s.undo().unwrap(); + assert_eq!(component_of(&s, i), default, "undo restores the variant ..."); + assert_eq!(fill(&s, layer(&s, i, "Bg").unwrap()), "FF00FF", "... and its overrides"); + healthy(&s); +} + +#[test] +fn variant_combinations_order_default_and_conflicts() { + let mut s = Session::new(); + // Two variants created right to left: the layer order and the top-left corner disagree. + let a = idv(&ex(&mut s, "frame.create", json!({"x": 200, "y": 0, "width": 80, "height": 40, "name": "Size=Large, State=Default"}))); + let b = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 80, "height": 40, "name": "Size=Small, State=Default"}))); + let c = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 80, "width": 80, "height": 40, "name": "Size=Small, State=Hover"}))); + let mut v = Vec::new(); + for f in [a, b, c] { + v.push(idv(&ex(&mut s, "component.create", json!({"ids": [f]})))); + } + let set = idv(&ex(&mut s, "component.combineVariants", json!({"ids": v.clone()}))); + // Default: "the variant in the top-left corner". + let i = idv(&ex(&mut s, "instance.create", json!({"component": set, "x": 0, "y": 300}))); + assert_eq!(component_of(&s, i), v[1], "the top-left variant is the default"); + let props = ex(&mut s, "component.properties", json!({"id": set})); + assert_eq!(props["properties"][0]["default"], json!("Small"), "the default value is the top-left variant's"); + // Making Large the default moves its variant to the top-left corner (one undo step). + let steps = undo_len(&s); + ex(&mut s, "component.editProperty", json!({"id": set, "name": "Size", "default": "Large"})); + assert_eq!(undo_len(&s), steps + 1); + let j = idv(&ex(&mut s, "instance.create", json!({"component": set, "x": 0, "y": 400}))); + assert_eq!(component_of(&s, j), v[0], "the new default is the top-left variant"); + // Reordering properties rewrites every variant's name to the new order. + ex(&mut s, "component.editProperty", json!({"id": set, "name": "State", "index": 0})); + assert_eq!(node(&s, v[2]).name, "State=Hover, Size=Small"); + let props = ex(&mut s, "component.properties", json!({"id": i})); + assert_eq!(props["properties"][0]["name"], json!("State"), "the order shows in the properties"); + // A missing combination picks the variant matching the most values (documentation silent; pinned). + ex(&mut s, "instance.setProperties", json!({"ids": [i], "properties": {"Size": "Large", "State": "Hover"}})); + assert!(v.contains(&component_of(&s, i))); + // Two variants with the same values are a conflict, reported until resolved. + assert_eq!(ex(&mut s, "component.properties", json!({"id": set}))["conflicts"], json!([])); + ex(&mut s, "component.editProperty", json!({"id": set, "name": "State", "value": "Hover", "newValue": "Default"})); + let conflicts = ex(&mut s, "component.properties", json!({"id": set}))["conflicts"].clone(); + assert_eq!(conflicts, json!([[v[1].0, v[2].0]]), "duplicate variant combinations are reported as a conflict"); + healthy(&s); +} + +/// Components "From" and "To" with layers named alike ("Bg" rectangle, "Title" text) and one only +/// "From" has ("Mark"); an instance of From with overrides on all three. Returns (from, to, inst). +fn swap_kit(s: &mut Session) -> (NodeId, NodeId, NodeId) { + let mut comps = Vec::new(); + for (k, name) in ["From", "To"].iter().enumerate() { + let f = idv(&ex(s, "frame.create", json!({"x": k as f64 * 200.0, "y": 0, "width": 100, "height": 40, "name": name}))); + ex(s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 100, "height": 40, "parent": f, "props": {"name": "Bg", "fill": "#eeeeee"}})); + ex(s, "text.create", json!({"text": name, "x": 4, "y": 4, "parent": f, "props": {"name": "Title"}})); + if k == 0 { + ex(s, "shape.ellipse", json!({"x": 80, "y": 4, "width": 8, "height": 8, "parent": f, "props": {"name": "Mark"}})); + } + comps.push(idv(&ex(s, "component.create", json!({"ids": [f]})))); + } + let i = idv(&ex(s, "instance.create", json!({"component": comps[0], "x": 0, "y": 200}))); + let ks = kids(s, i); + ex(s, "node.set", json!({"id": ks[0], "props": {"fill": "#ff00ff"}})); + ex(s, "node.set", json!({"id": ks[1], "props": {"characters": "Mine", "fill": "#00ff00"}})); + ex(s, "node.set", json!({"id": ks[2], "props": {"opacity": 0.3}})); + (comps[0], comps[1], i) +} + +#[test] +fn swaps_keep_overrides_by_layer_name() { + // (how, command, kept on Bg, derivation) + let rows: [(&str, &str, bool, &str); 2] = [ + ("the instance menu", "instance.swap", true, "the layer names of the current instance and the instance you're selecting must match"), + ("the Assets panel", "instance.swapAt", false, "an Assets-panel swap keeps only text overrides"), + ]; + for (how, cmd, bg_kept, why) in rows { + let mut s = Session::new(); + let (_from, to, i) = swap_kit(&mut s); + let steps = undo_len(&s); + let p = if cmd == "instance.swap" { json!({"ids": [i], "component": to}) } else { json!({"component": to, "x": 50, "y": 220}) }; + ex(&mut s, cmd, p); + assert_eq!(undo_len(&s), steps + 1, "{how}: one undo step"); + healthy(&s); + assert_eq!(component_of(&s, i), to, "{how}: swapped"); + let (bg, title) = (layer(&s, i, "Bg").unwrap(), layer(&s, i, "Title").unwrap()); + assert_eq!(fill(&s, bg) == "FF00FF", bg_kept, "{how}: Bg fill ({why})"); + assert_eq!((chars(&s, title), fill(&s, title)), ("Mine".to_string(), "00FF00".to_string()), "{how}: text layers keep their changes"); + assert!(layer(&s, i, "Mark").is_none(), "{how}: a layer the new main lacks goes with its overrides"); + } + // Instance swap properties: preferred values are offered first, any component is allowed. + let mut s = Session::new(); + let (from, to, _) = swap_kit(&mut s); + let (icon, shape, button, nested) = crate::components::tests::button(&mut s); + let _ = (shape, from); + ex(&mut s, "component.addProperty", json!({"id": button, "name": "Icon", "type": "instanceSwap", "layer": nested, "preferred": [icon]})); + let props = ex(&mut s, "component.properties", json!({"id": button})); + assert_eq!(props["properties"][0]["preferred"], json!([icon.0])); + let b = idv(&ex(&mut s, "instance.create", json!({"component": button, "x": 600, "y": 600}))); + ex(&mut s, "instance.setProperties", json!({"ids": [b], "properties": {"Icon": to}})); + assert_eq!(component_of(&s, copy_of(&s, b, nested)), to, "a component outside the preferred values can still be picked"); + healthy(&s); +} + +#[test] +fn swapping_to_a_component_of_another_set_takes_its_properties() { + // Documentation silent beyond name matching (pinned): the instance shows the other set's + // default variant's layers and offers that set's properties. + let mut s = Session::new(); + let (set, _default, _hover) = button_set(&mut s); + let a = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 500, "width": 60, "height": 20, "name": "Kind=Solid"}))); + ex(&mut s, "text.create", json!({"text": "Tag", "x": 2, "y": 2, "parent": a, "props": {"name": "Label"}})); + let tag = idv(&ex(&mut s, "component.create", json!({"ids": [a]}))); + let tags = idv(&ex(&mut s, "component.combineVariants", json!({"ids": [tag]}))); + let i = idv(&ex(&mut s, "instance.create", json!({"component": set, "x": 0, "y": 200}))); + let t = layer(&s, i, "Label").unwrap(); + ex(&mut s, "node.set", json!({"id": t, "props": {"characters": "Kept"}})); + ex(&mut s, "instance.swap", json!({"ids": [i], "component": tags})); + healthy(&s); + assert_eq!(component_of(&s, i), tag); + assert_eq!(chars(&s, layer(&s, i, "Label").unwrap()), "Kept"); + let props = ex(&mut s, "component.properties", json!({"id": i})); + assert_eq!(props["set"], json!(tags.0)); + assert_eq!(props["properties"][0]["name"], json!("Kind")); +} + +#[test] +fn hidden_exposed_instances_hide_their_properties() { + let mut s = Session::new(); + let (_icon, _shape, button, nested) = crate::components::tests::button(&mut s); + ex(&mut s, "component.exposeInstance", json!({"id": nested})); + let i = idv(&ex(&mut s, "instance.create", json!({"component": button, "x": 300, "y": 400}))); + assert_eq!(ex(&mut s, "instance.exposed", json!({"id": i}))["instances"].as_array().unwrap().len(), 1); + // "Hidden nested instances hide their attached properties." + let t = copy_of(&s, i, nested); + ex(&mut s, "node.set", json!({"id": t, "props": {"visible": false}})); + assert_eq!(ex(&mut s, "instance.exposed", json!({"id": i}))["instances"], json!([])); +} + +#[test] +fn detach_reset_push_and_restore() { + // Detach: "a regular frame, while keeping the current layers and properties". + let mut s = Session::new(); + let k = kit(&mut s); + let row = k.copy(&s, Layer::Row); + ex(&mut s, "node.set", json!({"id": row, "props": {"padding": 9}})); + let t = k.copy(&s, Layer::Bg); + ex(&mut s, "node.set", json!({"id": t, "props": {"fill": "#00ff00"}})); + let look = figcraft_render::render_node(s.doc().unwrap(), k.inst, 1.0).unwrap(); + let steps = undo_len(&s); + ex(&mut s, "component.detach", json!({"ids": [k.inst]})); + assert_eq!(undo_len(&s), steps + 1); + assert_eq!(node(&s, k.inst).kind.type_name(), "FRAME"); + assert_eq!(figcraft_render::render_node(s.doc().unwrap(), k.inst, 1.0).unwrap().data(), look.data(), "the look is kept"); + assert_eq!(node(&s, row).layout.as_ref().map(|l| l.padding), Some([9.0; 4])); + healthy(&s); + + // Reset all: every layer shows its main again, property values go back to their defaults; + // the instance keeps its size (documentation silent; pinned). + let mut s = Session::new(); + let k = kit(&mut s); + let grid = serde_json::to_value(LayoutGrid::default()).unwrap(); + let t = k.copy(&s, Layer::Row); + ex(&mut s, "node.set", json!({"id": t, "props": {"padding": 9, "grids": [grid]}})); + let t = k.copy(&s, Layer::Title); + ex(&mut s, "node.set", json!({"id": t, "props": {"characters": "Mine", "paragraphSpacing": 6}})); + ex(&mut s, "node.set", json!({"id": k.inst, "props": {"width": 240}})); + ex(&mut s, "component.resetInstance", json!({"ids": [k.inst]})); + healthy(&s); + let (row, title) = (node(&s, k.copy(&s, Layer::Row)), node(&s, k.copy(&s, Layer::Title))); + assert_eq!((row.layout.as_ref().map(|l| l.padding), row.grids.len()), (Some([4.0; 4]), 0)); + assert_eq!((text(&title).characters.as_str(), text(&title).paragraph_spacing), ("Hello", 0.0)); + assert_eq!(node(&s, k.inst).width, 240.0); + + // Push changes: the instance's overrides become the main's ("cannot push changes to nested + // components": from a nested layer they still go to the outer main). + let mut s = Session::new(); + let (icon, shape, button, nested) = crate::components::tests::button(&mut s); + let a = idv(&ex(&mut s, "instance.create", json!({"component": button, "x": 300, "y": 400}))); + let b = idv(&ex(&mut s, "instance.create", json!({"component": button, "x": 600, "y": 400}))); + let ishape = copy_of(&s, a, copy_of(&s, nested, shape)); + ex(&mut s, "node.set", json!({"id": ishape, "props": {"fill": "#ff00ff"}})); + let label = kids(&s, a)[1]; + ex(&mut s, "node.set", json!({"id": label, "props": {"paragraphSpacing": 5}})); + ex(&mut s, "component.pushChanges", json!({"id": ishape})); + healthy(&s); + assert_eq!(fill(&s, copy_of(&s, nested, shape)), "FF00FF", "the outer main's nested instance takes it"); + assert_eq!(fill(&s, shape), "111111", "the nested instance's own main doesn't"); + assert_eq!(fill(&s, copy_of(&s, b, copy_of(&s, nested, shape))), "FF00FF", "other instances follow"); + assert_eq!(text(&node(&s, kids(&s, button)[1])).paragraph_spacing, 5.0, "text properties are pushed"); + assert!(node(&s, ishape).overrides.is_empty() && node(&s, label).overrides.is_empty()); + let _ = icon; + + // Go to main selects it; restore main brings a deleted main back from an instance + // (`components_tests::deleted_main_keeps_instances_and_restores`). + let mut s = Session::new(); + let k = kit(&mut s); + let t = k.copy(&s, Layer::Title); + ex(&mut s, "component.goToMain", json!({"id": t})); + assert_eq!(s.selection(), vec![k.card]); +} + +#[test] +fn auto_layout_instances_follow_their_main() { + let mut s = Session::new(); + let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 100, "height": 40, "name": "Chip"}))); + for _ in 0..2 { + ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 20, "height": 20, "parent": f})); + } + ex(&mut s, "node.set", json!({"id": f, "props": {"layout": "horizontal", "padding": 8, "gap": 4, "sizingH": "hug", "sizingV": "hug"}})); + let chip = idv(&ex(&mut s, "component.create", json!({"ids": [f]}))); + let i = idv(&ex(&mut s, "instance.create", json!({"component": chip, "x": 0, "y": 100}))); + assert_eq!((node(&s, i).width, node(&s, i).height), (60.0, 36.0), "8 + 20 + 4 + 20 + 8 by 8 + 20 + 8"); + // Padding on the instance is an override; the main's gap and direction still reach it. + ex(&mut s, "node.set", json!({"id": i, "props": {"padding": 2}})); + ex(&mut s, "node.set", json!({"id": chip, "props": {"gap": 10}})); + assert_eq!(node(&s, i).width, 2.0 + 20.0 + 10.0 + 20.0 + 2.0, "the hug follows the main's gap and the instance's padding"); + ex(&mut s, "node.set", json!({"id": chip, "props": {"layout": "vertical"}})); + let l = node(&s, i).layout.unwrap(); + assert_eq!((l.mode, l.padding, l.gap), (LayoutMode::Vertical, [2.0; 4], 10.0)); + assert_eq!((node(&s, i).width, node(&s, i).height), (24.0, 2.0 + 20.0 + 10.0 + 20.0 + 2.0)); + // The direction is the main's: refused on the instance. + assert!(s.execute("node.set", &json!({"id": i, "props": {"layout": "horizontal"}})).is_err()); + assert!(s.execute("layout.remove", &json!({"ids": [i]})).is_err()); + // Min / max width on the instance is its own (documentation silent; pinned). + ex(&mut s, "node.set", json!({"id": i, "props": {"minWidth": 80}})); + assert_eq!(node(&s, i).width, 80.0); + ex(&mut s, "node.set", json!({"id": chip, "props": {"gap": 12}})); + assert_eq!(node(&s, i).child_layout.min_w, Some(80.0)); + healthy(&s); +} diff --git a/crates/engine/src/components_scale_tests.rs b/crates/engine/src/components_scale_tests.rs index 8ed92b2a..3253ea83 100644 --- a/crates/engine/src/components_scale_tests.rs +++ b/crates/engine/src/components_scale_tests.rs @@ -685,7 +685,6 @@ fn alt_dropping_a_component_onto_an_instance_swaps_it() { }) .unwrap(); s.ds_mut().unwrap().page = doc.page_of(button).unwrap(); - let fill = doc.node(glyph).unwrap().fills.clone(); let label = chars(doc, find_path(doc, button, &["Label"]).unwrap()); let other = doc.node(ds.button).unwrap().children.iter().copied().find(|v| *v != main_of(doc, button)).unwrap(); let (x, y) = centre(doc, glyph); @@ -696,8 +695,9 @@ fn alt_dropping_a_component_onto_an_instance_swaps_it() { healthy(doc); assert_eq!(main_of(doc, button), other); assert_eq!(s.selection(), vec![button]); - // ... keeping overrides on layers with matching names and the property values. - assert_eq!(doc.node(find_path(doc, button, &["Icon", "Glyph"]).unwrap()).unwrap().fills, fill); + // ... keeping only text layers' overrides (by matching names) and the property values. + let new_glyph = find_path(doc, button, &["Icon", "Glyph"]).unwrap(); + assert!(doc.node(new_glyph).unwrap().overrides.is_empty()); assert_eq!(chars(doc, find_path(doc, button, &["Label"]).unwrap()), label); // An icon dropped there swaps the button's icon (a nested swap, an override). let icon = find_path(doc, button, &["Icon"]).unwrap(); @@ -708,7 +708,9 @@ fn alt_dropping_a_component_onto_an_instance_swaps_it() { healthy(doc); assert_eq!(comp_of(doc, icon), Some(to)); assert!(doc.node(icon).unwrap().overrides.contains("component")); - assert_eq!(doc.node(find_path(doc, icon, &["Glyph"]).unwrap()).unwrap().fills, fill); + let glyph = find_path(doc, icon, &["Glyph"]).unwrap(); + assert!(doc.node(glyph).unwrap().overrides.is_empty()); + assert_eq!(doc.node(glyph).unwrap().fills, doc.node(doc.node(glyph).unwrap().source.unwrap()).unwrap().fills); // Nothing there: refused, nothing changes. let before = s.doc().unwrap().clone(); assert!(s.execute("instance.swapAt", &json!({"component": to, "x": -50_000, "y": -50_000})).is_err()); @@ -731,7 +733,6 @@ fn alt_dropping_a_library_component_swaps_to_it() { let doc = &s.doc().unwrap().clone(); let (top, glyph) = inst.iter().copied().filter(|i| local.icons.contains(&main_of(doc, *i))).find_map(|i| find_path(doc, i, &["Glyph"]).map(|g| (i, g))).unwrap(); - let fill = doc.node(glyph).unwrap().fills.clone(); s.ds_mut().unwrap().page = doc.page_of(top).unwrap(); let k = ds.icons.len() - 1; let (x, y) = centre(doc, glyph); @@ -739,6 +740,8 @@ fn alt_dropping_a_library_component_swaps_to_it() { let doc = s.doc().unwrap(); healthy(doc); assert_eq!(main_of(doc, top), local.icons[k]); - assert_eq!(doc.node(find_path(doc, top, &["Glyph"]).unwrap()).unwrap().fills, fill); + // Swapped from the Assets panel: only text layers keep their overrides. + let glyph = find_path(doc, top, &["Glyph"]).unwrap(); + assert!(doc.node(glyph).unwrap().overrides.is_empty()); let _ = std::fs::remove_dir_all(dir); } diff --git a/crates/engine/src/components_tests.rs b/crates/engine/src/components_tests.rs index 6b6d7b9e..eb21b20e 100644 --- a/crates/engine/src/components_tests.rs +++ b/crates/engine/src/components_tests.rs @@ -66,7 +66,9 @@ fn overrides_survive_main_edits() { ex(&mut s, "node.set", json!({"id": ibg, "props": {"fill": "#00ff00", "stroke": "#0000ff", "strokeWeight": 2, "name": "Background"}})); ex(&mut s, "node.set", json!({"id": ibg, "props": {"effects": [{"type": "dropShadow"}]}})); ex(&mut s, "text.set", json!({"id": ititle, "characters": "Overridden"})); - ex(&mut s, "node.set", json!({"id": idot, "props": {"visible": false, "width": 30}})); + ex(&mut s, "node.set", json!({"id": idot, "props": {"visible": false}})); + // A layer inside an instance keeps its main's size (the instance itself resizes). + assert!(s.execute("node.set", &json!({"id": idot, "props": {"width": 30}})).is_err()); let ov = node(&s, ibg).overrides; for g in ["fills", "strokes", "effects", "name"] { assert!(ov.contains(g), "{g} in {ov:?}"); @@ -89,7 +91,7 @@ fn overrides_survive_main_edits() { assert_eq!(node(&s, ititle).kind.text().unwrap().style.font_size, 30.0, "text style follows the main"); let d = node(&s, idot); assert!(!d.visible); - assert_eq!(d.width, 30.0, "size override kept"); + assert_eq!(d.width, 10.0, "the main's size reaches the layer"); assert_eq!(fill(&s, idot), "00FFFF"); assert_eq!(fill(&s, i), "FAFAFA"); // Per-group reset. diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 08ec430e..2fe235ab 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -726,8 +726,8 @@ impl Session { } } - /// Instances can't gain, lose or reorder layers, and library copies are read-only: refuse such - /// an edit (it is rolled back). + /// Instances can't gain, lose or reorder layers, nor move, resize or re-constrain the layers in + /// them, and library copies are read-only: refuse such an edit (it is rolled back). fn check_instance_structure(&self, id: &str, before: &Option<(Arc, Vec, NodeId, u64)>) -> Result<()> { let Some((bdoc, _, _, uid)) = before else { return Ok(()) }; let Some(ds) = self.docs.iter().find(|d| d.uid == *uid) else { return Ok(()) }; @@ -741,6 +741,7 @@ impl Session { }; library::check_read_only(effective, bdoc, &ds.doc) .and_then(|()| components::check_structure_edit(bdoc, &ds.doc)) + .and_then(|()| components::check_override_edit(bdoc, &ds.doc)) .map_err(|m| EngineError::Disabled(id.to_string(), m)) } @@ -1227,6 +1228,8 @@ mod comments_polish_tests; #[cfg(test)] mod component_codegen_tests; #[cfg(test)] +mod components_behaviour_tests; +#[cfg(test)] mod components_scale_tests; #[cfg(test)] mod devcompare_tests; diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index 77267d0e..b738de55 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -1465,11 +1465,16 @@ impl Session { } crate::normalize(ds.doc_mut(), &before); } - // Reordering or dragging layers out of an instance (outside its slots) is a - // structure edit only its main can make: the gesture is undone. + // Reordering or dragging layers out of an instance (outside its slots), or + // moving layers inside one, are edits only its main can make: the gesture is + // undone. let ds = self.ds()?; - let allowed = - ds.interaction.as_ref().is_none_or(|it| moving.iter().all(|m| crate::components::move_allowed(&it.doc, &ds.doc, *m))); + let allowed = ds.interaction.as_ref().is_none_or(|it| { + moving.iter().all(|m| { + crate::components::move_allowed(&it.doc, &ds.doc, *m) + && crate::components::refused_override(&it.doc, &ds.doc, *m).is_none() + }) + }); if allowed { self.commit_interaction()?; } else { @@ -1481,7 +1486,19 @@ impl Session { let _ = ev; } } - Gesture::Resize { .. } | Gesture::Rotate { .. } | Gesture::Arc { .. } => self.commit_interaction()?, + Gesture::Resize { .. } | Gesture::Rotate { .. } | Gesture::Arc { .. } => { + // Layers inside an instance keep their main's size and rotation. + let ds = self.ds()?; + let refused = ds + .interaction + .as_ref() + .is_some_and(|it| ds.selection.iter().any(|m| crate::components::refused_override(&it.doc, &ds.doc, *m).is_some())); + if refused { + self.cancel_interaction()?; + } else { + self.commit_interaction()?; + } + } Gesture::Draw { start, id, parent, .. } => { let tiny = (ev.p() - start).hypot() * ev.zoom < 3.0; if tiny || id.is_none() { diff --git a/crates/format/src/rest/mod.rs b/crates/format/src/rest/mod.rs index c944bae9..ec75d4b7 100644 --- a/crates/format/src/rest/mod.rs +++ b/crates/format/src/rest/mod.rs @@ -891,6 +891,12 @@ fn override_group(field: &str) -> Option<&'static str> { | "paragraphSpacing" | "paragraphIndent" => "textStyle", "width" | "height" | "size" => "size", + "paddingLeft" | "paddingRight" | "paddingTop" | "paddingBottom" | "horizontalPadding" | "verticalPadding" => "padding", + "itemSpacing" | "counterAxisSpacing" => "gap", + "primaryAxisAlignItems" | "counterAxisAlignItems" => "alignment", + "layoutGrids" | "gridStyleId" => "grids", + "exportSettings" => "exports", + "interactions" | "reactions" => "interactions", _ => return None, }) } diff --git a/crates/format/src/rest/tests.rs b/crates/format/src/rest/tests.rs index 31e72e76..d7117fd0 100644 --- a/crates/format/src/rest/tests.rs +++ b/crates/format/src/rest/tests.rs @@ -414,7 +414,7 @@ fn components_file() -> Value { {"id": "5:1", "name": "Button", "type": "INSTANCE", "componentId": "1:2", "relativeTransform": at(0.0, 200.0), "size": {"x": 80, "y": 40}, "componentProperties": {"Label#1:9": {"type": "TEXT", "value": "Go"}, "State": {"type": "VARIANT", "value": "Default"}, "Icon#2:0": {"type": "INSTANCE_SWAP", "value": "3:1"}}, - "overrides": [{"id": "I5:1;1:3", "overriddenFields": ["characters", "fills", "unknownField"]}], + "overrides": [{"id": "I5:1;1:3", "overriddenFields": ["characters", "fills", "exportSettings", "unknownField"]}], "children": [{"id": "I5:1;1:3", "name": "Label", "type": "TEXT", "relativeTransform": at(8.0, 8.0), "size": {"x": 30, "y": 20}, "characters": "Go", "style": {"fontSize": 14}, "componentPropertyReferences": {"characters": "Label#1:9"}}]}, {"id": "6:1", "name": "Remote", "type": "INSTANCE", "componentId": "99:1", "relativeTransform": at(0.0, 300.0), "size": {"x": 10, "y": 10}} @@ -454,7 +454,7 @@ fn components_sets_instances_and_properties() { assert_eq!(props.get("Icon"), Some(&json!(icon.id.0))); assert!(!props.contains_key("State")); let inst_label = doc.node(inst.children[0]).unwrap(); - assert!(inst_label.overrides.contains("characters") && inst_label.overrides.contains("fills") && inst_label.overrides.len() == 2); + assert!(["characters", "fills", "exports"].iter().all(|g| inst_label.overrides.contains(*g)) && inst_label.overrides.len() == 3); assert!(matches!(named(&doc, "Remote").kind, NodeKind::Frame)); assert!(warnings.iter().any(|w| w.contains("aren't in the file")), "{warnings:?}"); } diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index fc55bb47..68872f2c 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -299,7 +299,7 @@ name = "English" "Deep select" = "Deep select" "Default" = "Default" "Default content" = "Default content" -"Default value (the first variant)" = "Default value (the first variant)" +"Default value (the top-left variant)" = "Default value (the top-left variant)" "Delay" = "Delay" "Delete" = "Delete" "Delete category (its annotations stay)" = "Delete category (its annotations stay)" @@ -396,6 +396,7 @@ name = "English" "Export" = "Export" "Export all" = "Export all" "Export page as PNG" = "Export page as PNG" +"Export settings" = "Export settings" "Export variables" = "Export variables" "Export {name}" = "Export {name}" "Export {n} layers" = { one = "Export {n} layer", other = "Export {n} layers" } @@ -520,6 +521,7 @@ name = "English" "Imported {name}" = "Imported {name}" "Imported {n} new variables, updated {updated}" = { one = "Imported {n} new variable, updated {updated}", other = "Imported {n} new variables, updated {updated}" } "Import…" = "Import…" +"In an instance, this comes from the main component" = "In an instance, this comes from the main component" "In the browser you can join sessions the desktop app hosts and files on a FigCraft server. A page opened over https reaches other computers only through wss:// links (a server behind TLS)." = "In the browser you can join sessions the desktop app hosts and files on a FigCraft server. A page opened over https reaches other computers only through wss:// links (a server behind TLS)." "Included" = "Included" "Included in layout" = "Included in layout" @@ -569,6 +571,7 @@ name = "English" "Layer blur" = "Layer blur" "Layer name" = "Layer name" "Layers" = "Layers" +"Layers in an instance take their position from the main component" = "Layers in an instance take their position from the main component" "Layout" = "Layout" "Layout grid" = "Layout grid" "Layout grid settings" = "Layout grid settings" @@ -1031,6 +1034,7 @@ name = "English" "Social media" = "Social media" "Soft light" = "Soft light" "Solid" = "Solid" +"Some variants have the same property values: give each one a unique combination" = "Some variants have the same property values: give each one a unique combination" "Sort" = "Sort" "Sort by date" = "Sort by date" "Sort by page" = "Sort by page" diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index 71ce0926..ff78d4c7 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -300,7 +300,7 @@ status = "draft — needs native review" "Deep select" = "Selección profunda" "Default" = "Predeterminado" "Default content" = "Contenido predeterminado" -"Default value (the first variant)" = "Valor predeterminado (la primera variante)" +"Default value (the top-left variant)" = "Valor predeterminado (la variante de arriba a la izquierda)" "Delay" = "Retardo" "Delete" = "Eliminar" "Delete category (its annotations stay)" = "Eliminar categoría (sus anotaciones se mantienen)" @@ -397,6 +397,7 @@ status = "draft — needs native review" "Export" = "Exportar" "Export all" = "Exportar todo" "Export page as PNG" = "Exportar página como PNG" +"Export settings" = "Ajustes de exportación" "Export variables" = "Exportar variables" "Export {name}" = "Exportar {name}" "Export {n} layers" = { one = "Exportar {n} capa", other = "Exportar {n} capas" } @@ -521,6 +522,7 @@ status = "draft — needs native review" "Imported {name}" = "{name} importado" "Imported {n} new variables, updated {updated}" = { one = "Variables importadas: {n} nueva; actualizadas: {updated}", other = "Variables importadas: {n} nuevas; actualizadas: {updated}" } "Import…" = "Importar…" +"In an instance, this comes from the main component" = "En una instancia, esto viene del componente principal" "In the browser you can join sessions the desktop app hosts and files on a FigCraft server. A page opened over https reaches other computers only through wss:// links (a server behind TLS)." = "En el navegador puedes unirte a sesiones alojadas por la app de escritorio y a archivos de un servidor FigCraft. Una página abierta por https solo llega a otros equipos mediante enlaces wss:// (un servidor con TLS)." "Included" = "Incluidos" "Included in layout" = "Incluidos en el diseño" @@ -570,6 +572,7 @@ status = "draft — needs native review" "Layer blur" = "Desenfoque de capa" "Layer name" = "Nombre de capa" "Layers" = "Capas" +"Layers in an instance take their position from the main component" = "Las capas de una instancia toman su posición del componente principal" "Layout" = "Disposición" "Layout grid" = "Cuadrícula de diseño" "Layout grid settings" = "Ajustes de la cuadrícula de diseño" @@ -1032,6 +1035,7 @@ status = "draft — needs native review" "Social media" = "Redes sociales" "Soft light" = "Luz suave" "Solid" = "Sólido" +"Some variants have the same property values: give each one a unique combination" = "Algunas variantes tienen los mismos valores de propiedad: da a cada una una combinación única" "Sort" = "Ordenar" "Sort by date" = "Ordenar por fecha" "Sort by page" = "Ordenar por página" diff --git a/crates/ui-egui/src/assets_panel.rs b/crates/ui-egui/src/assets_panel.rs index 8eb9d228..fd1b863d 100644 --- a/crates/ui-egui/src/assets_panel.rs +++ b/crates/ui-egui/src/assets_panel.rs @@ -467,7 +467,12 @@ fn render_thumbs(ui: &Ui, w: f32, thumbs: &mut Thumbs, wanted: &[(Source, NodeId break; } let Some(doc) = thumbs.docs.get(&key.0).cloned() else { continue }; - thumbs.map.insert(key.clone(), thumbnail(ui.ctx(), &doc, key.1, px)); + // A component set shows its default (top-left) variant. + let shown = match doc.node(key.1).map(|n| &n.kind) { + Some(NodeKind::ComponentSet { .. }) => figcraft_engine::components::default_variant(&doc, key.1).unwrap_or(key.1), + _ => key.1, + }; + thumbs.map.insert(key.clone(), thumbnail(ui.ctx(), &doc, shown, px)); rendered += 1; } if thumbs.map.len() > MAX_THUMBS { diff --git a/crates/ui-egui/src/component_panel.rs b/crates/ui-egui/src/component_panel.rs index 23f37745..646d6ecd 100644 --- a/crates/ui-egui/src/component_panel.rs +++ b/crates/ui-egui/src/component_panel.rs @@ -12,7 +12,8 @@ use egui::{Id, Sense, Ui, vec2}; use figcraft_doc::*; use figcraft_engine::components::{ - PROP_BINDING, inside_instance, owning_component, owning_instance, slot_def, slot_edited, slots_of, variant_options, variant_props, variant_set, + PROP_BINDING, default_variant, inside_instance, owning_component, owning_instance, slot_def, slot_edited, slots_of, variant_conflicts, + variant_options, variant_props, variant_set, }; use serde_json::{Value, json}; @@ -232,6 +233,14 @@ fn properties_ui(app: &mut FigApp, ui: &mut Ui, doc: &Document, owner: NodeId) { if defs.is_empty() && variants.is_empty() { widgets::note(ui, tr("Add properties to make instances configurable"), t.text_tertiary); } + if is_set && !variant_conflicts(doc, owner).is_empty() { + widgets::note(ui, tr("Some variants have the same property values: give each one a unique combination"), t.warning); + } + let defaults = if is_set { + default_variant(doc, owner).and_then(|d| doc.node(d)).map(|n| variant_props(&n.name)).unwrap_or_default() + } else { + Default::default() + }; // Variant properties: name, values (the matrix), add value, delete. for (k, vals) in &variants { row(ui, 32.0, |ui| { @@ -240,8 +249,8 @@ fn properties_ui(app: &mut FigApp, ui: &mut Ui, doc: &Document, owner: NodeId) { if let Some(nn) = widgets::text_field(ui, Id::new(("vprop_name", owner, k.as_str())), k, 110.0, tr("Name")) { app.run("component.editProperty", json!({"id": owner, "name": k, "newName": nn})); } - let def = vals.first().cloned().unwrap_or_default(); - let resp = widgets::dropdown(ui, &def, 70.0, None).on_hover_text(tr("Default value (the first variant)")); + let def = defaults.get(k).or(vals.first()).cloned().unwrap_or_default(); + let resp = widgets::dropdown(ui, &def, 70.0, None).on_hover_text(tr("Default value (the top-left variant)")); menu_popup(&resp).show(|ui| { for v in vals { if menu_item(ui, v, "") { @@ -478,6 +487,12 @@ fn group_label(g: &str) -> String { "corners" => tr("Corner radius"), "characters" => tr("Text content"), "textStyle" => tr("Text style"), + "padding" => tr("Padding"), + "gap" => tr("Gap"), + "alignment" => tr("Alignment"), + "grids" => tr("Layout grids"), + "exports" => tr("Export settings"), + "interactions" => tr("Interactions"), "size" => tr("Size"), "position" => tr("Position"), "component" => tr("Swapped instance"), diff --git a/crates/ui-egui/src/layout_panel.rs b/crates/ui-egui/src/layout_panel.rs index ae43e180..408d6456 100644 --- a/crates/ui-egui/src/layout_panel.rs +++ b/crates/ui-egui/src/layout_panel.rs @@ -29,7 +29,13 @@ pub fn layout_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[Node let al = common(doc, sel, |n| n.layout.clone()); let has_al = matches!(al, Some(Some(_))); let title = if has_al { tr("Auto layout") } else { tr("Layout") }; - let btns: Vec<(Icon, &str)> = if frames && !has_al { + // An instance's auto layout is its main's (only padding, gap and alignment are its own), and + // the layers inside it keep their main's size. + let in_instance = sel.iter().any(|i| figcraft_engine::components::owning_instance(doc, *i).is_some()); + let nested = sel.iter().any(|i| figcraft_engine::components::inside_instance(doc, *i)); + let btns: Vec<(Icon, &str)> = if in_instance { + vec![] + } else if frames && !has_al { vec![(Icon::Plus, crate::shortcuts::tip(tr("Add auto layout"), "layout.add"))] } else if has_al { vec![(Icon::Minus, crate::shortcuts::tip(tr("Remove auto layout"), "layout.remove"))] @@ -39,21 +45,26 @@ pub fn layout_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[Node if widgets::section_header(ui, title, &btns, false).is_some() { app.run(if has_al { "layout.remove" } else { "layout.add" }, json!({"ids": ids})); } + let from_main = tr("In an instance, this comes from the main component"); if let Some(Some(l)) = &al { - direction(app, ui, &ids, l); + ui.add_enabled_ui(!in_instance, |ui| direction(app, ui, &ids, l)).response.on_disabled_hover_text(from_main); } - dimensions(app, ui, doc, sel, has_al); + ui.add_enabled_ui(!nested, |ui| dimensions(app, ui, doc, sel, has_al)).response.on_disabled_hover_text(from_main); if let Some(Some(l)) = &al { alignment(app, ui, &ids, l); spacing(app, ui, &ids, l); - advanced(app, ui, &ids, l); - if l.mode == LayoutMode::Grid && sel.len() == 1 { - tracks(app, ui, doc, &ids); - } + ui.add_enabled_ui(!in_instance, |ui| { + advanced(app, ui, &ids, l); + if l.mode == LayoutMode::Grid && sel.len() == 1 { + tracks(app, ui, doc, &ids); + } + }) + .response + .on_disabled_hover_text(from_main); } let grid_parent = sel.iter().all(|i| parent_of(doc, *i).and_then(|p| p.layout.as_ref()).is_some_and(|l| l.mode == LayoutMode::Grid)); if grid_parent && common(doc, sel, |n| n.child_layout.absolute) == Some(false) { - grid_cell(app, ui, doc, sel); + ui.add_enabled_ui(!nested, |ui| grid_cell(app, ui, doc, sel)).response.on_disabled_hover_text(from_main); } if frames { let clip = common(doc, sel, |n| n.clip); diff --git a/crates/ui-egui/src/right.rs b/crates/ui-egui/src/right.rs index 0b688c7d..ad4396c4 100644 --- a/crates/ui-egui/src/right.rs +++ b/crates/ui-egui/src/right.rs @@ -534,6 +534,15 @@ fn header(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { fn position(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { widgets::section_header(ui, tr("Position"), &[], false); + // Layers inside an instance take their place and constraints from the main component. + let nested = sel.iter().any(|i| figcraft_engine::components::inside_instance(doc, *i)); + ui.add_enabled_ui(!nested, |ui| position_fields(app, ui, doc, sel)) + .response + .on_disabled_hover_text(tr("Layers in an instance take their position from the main component")); + ui.add_space(8.0); +} + +fn position_fields(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { let ids = sel.to_vec(); let col = widgets::col_width(ui.available_width()); widgets::sublabels(ui, tr("Alignment"), None); @@ -655,7 +664,6 @@ fn position(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { }); }); } - ui.add_space(8.0); } /// The selected ellipses' arc: Start (degrees), Sweep (a share of a full turn, negative running diff --git a/docs/components-parity.md b/docs/components-parity.md index 1678c48a..9a90ade5 100644 --- a/docs/components-parity.md +++ b/docs/components-parity.md @@ -1,30 +1,46 @@ # Components, variants, styles and variables parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (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-11 · **Change:** minor (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 ~95%, ready ~68%. Styles and +Design systems are why teams adopt Figma. **Components: breadth ~96%, ready ~72%. Styles and variables: breadth ~92%, ready ~72% (estimated).** QA'd with a synthetic design system of ~300 components used 2,000 times (`crates/engine/src/synthetic_ds.rs`, `components_scale_tests.rs`); -not with real libraries. Remaining: 7–14 h here, plus team libraries' tail (4–8 h, partly after +not with real libraries. Remaining: 5–11 h here, plus team libraries' tail (4–8 h, partly after accounts). ## Components and variants | Feature | Status | Notes | |---|---|---| -| Create component, instances, detach, reset, go to main, restore main, push changes | done | | +| Create component, instances, detach, reset, go to main, restore main, push changes | done | push from a nested layer goes to the outer main | | 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 | done | each kept when the main changes that property, reset per group; opacity and corner radius too (documentation silent) | +| What it doesn't: position, rotation, size, constraints and resizing 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 | | +| 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 | reordering through `component.editProperty {index}`; no drag in the panel yet | | Properties: boolean, text, instance swap (preferred values), variant | done | | | Slots (preferred components, fill menu, kept across updates) | done | Figma 2025 feature | -| Exposed nested instances | done | | +| Exposed nested instances | done | hidden ones hide their properties | | Descriptions | done | | -| Instance swap: panel, Alt-drop from Assets | done | | +| Instance swap: panel, Alt-drop from Assets | done | the panel keeps overrides on same-named layers, an Alt-drop from Assets only text layers' | | Assets panel: thumbnails, search, drag into frames and auto layout slots | done | virtualized | | Instances pasted into other files follow their mains | done | missing main restorable | | Component playground (try properties without placing) | missing | 1–2 h | | Real-world libraries QA | **not checked** | [gaps](gaps.md) #3 | +The behaviour of instances, overrides, variants and swaps is checked against the public +documentation by `crates/engine/src/components_behaviour_tests.rs` (one table row per documented +rule, with its derivation). Where the documentation is silent, the suite pins today's behaviour: +layer opacity and corner radius are overridable; rotation of a layer inside an instance is refused +with its position; boolean and text property values stay across variants; a missing combination +picks the variant matching the most values; reset all keeps the instance's size; an instance's own +min / max width and height stay its own. An instance's own export settings and interactions are +its own (the main's later changes to them don't reach existing instances, so connections people +added are never lost). Nested instance overrides: `nested_override_tests.rs`; accepting library +updates with overrides kept: `library_tests.rs`, `components_scale_tests.rs`. + ## Libraries | Feature | Status | Notes | @@ -53,6 +69,7 @@ accounts). | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | Behaviour suite from the public documentation and its fixes: overrides of padding, gap, alignment, layout grids, export settings, interactions and every text property kept (they were lost when the main changed); geometry, constraints and resizing of layers inside instances refused; variant switches keep only overrides both variants agreed on (text layers keep theirs); Alt-drops from Assets keep only text overrides; top-left default variant; hidden exposed instances hide their properties; push from nested layers goes to the outer main; variant property reordering and conflict warnings. Components ~95%/~68% → ~96%/~72%; remaining here 7–14 h → 5–11 h | | 2026-10-11 | minor | Team libraries on `figcraft-server` (publish under the file, enable by a share link, updates with overrides kept), usage counts per component and file, and swapping a library by names (gap 10) | | 2026-10-11 | minor | Extended collections, variables import/export (JSON, CSS) and gradient stop and variant property bindings landed: styles and variables breadth ~85% → ~92%, ready ~70% → ~72%; remaining here 10–20 h → 7–14 h | | 2026-10-10 | major | Created from the 2026-10-10 re-measure | diff --git a/docs/mcp.md b/docs/mcp.md index 13f06287..17d65bf9 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -413,6 +413,28 @@ most when it can't be reached). still from `from`, including this file's collections that extend one of its collections], removed (nothing of `from` was left, so it was removed)}`. +### Overrides and variants + +- **What an instance overrides** (`node.set` / `text.set` on its layers; `get_node` lists + `overrides`): fills, strokes, effects, opacity, visibility, layer names, corner radius, text + content and text properties (font, size, spacing, resizing, lists…), auto layout padding, gap and + alignment, layout grids, export settings and prototype interactions; nested instances swap + (`component`) and take property values (`props`). Each stays when the main changes that property + and goes with `component.resetOverride {id, group}` or `component.resetInstance`. +- **What it doesn't**: moving, rotating, resizing or re-constraining a layer inside an instance, + its resizing (hug / fill / min / max), the auto layout direction and other auto layout settings, + and adding, removing or reordering layers (outside slots) are refused (nothing changes); resize + the instance itself (its layers follow the main's constraints) or edit the main. +- **Variants**: `instance.setProperties {ids?, properties}` switching variants keeps an override + where the layer's name path matches and both variants had the same value for that property; + text layers keep all theirs. The default variant (`instance.create` with a set, the Assets + thumbnail) is the one in the set's top-left corner; `component.editProperty {name, default}` + moves the chosen variant there, `{name, index}` reorders variant properties (the variants' + names follow). `component.properties` on a set reports `conflicts`: variants sharing one + combination of values. +- `component.pushChanges {id?}` copies the instance's overrides into its main (from a layer of a + nested instance too: always into the outer instance's main). + ### Swapping instances - `instance.swap {ids?, component}` shows another component in the instances (overrides on layers @@ -420,8 +442,9 @@ most when it can't be reached). swapped on their own (an override of the instance they're in). - `instance.swapAt {component, x, y}` swaps the instance at a page point: of the instances under it (innermost first), the first showing a variant of the same component set, else the innermost - (never one inside the dropped component). The Assets panel does this when a component is dropped - onto an instance with Alt (Option) held, highlighting that instance while dragging. + (never one inside the dropped component); only text layers keep their overrides. The Assets + panel does this when a component is dropped onto an instance with Alt (Option) held, + highlighting that instance while dragging. - Instance swap properties offer **preferred values**: `component.addProperty {type: "instanceSwap", default, preferred? [component or set ids]}` and `component.editProperty {name, preferred}`; `component.properties` lists them, and the Design panel's swap menus show them first. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 34599508..53c12da6 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # FigCraft parity with Figma -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%; last major: full re-measure against Figma desktop 126.10.7) · **Target:** Figma Design (desktop app 126.10.7, macOS) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%; last major: full re-measure against Figma desktop 126.10.7) · **Target:** Figma Design (desktop app 126.10.7, macOS) The authoritative assessment of how close FigCraft is to Figma Design. [`ROADMAP.md`](../ROADMAP.md) summarizes it; [`gaps.md`](gaps.md) is the ranked work list; the measured menu checklist is @@ -19,9 +19,9 @@ summarizes it; [`gaps.md`](gaps.md) is the ranked work list; the measured menu c | Feature breadth (does each Figma Design feature exist, weighted) | **~80%** (core editor areas 1–16: ~91%) | estimated from the area table below | | Ready for real work | **~49%** | estimated (dimension table below: written weights × written per-dimension scores) | | Essentials user | **~57%** | estimated ([below](#essentials-user): core features 77.8% × discounts 0.92 × 0.92 × 0.88) | -| Mainstream practitioner | **~46%** | estimated ([below](#mainstream-practitioner): weekly areas 68.1% × discounts 0.9 × 0.9 × 0.85) | +| Mainstream practitioner | **~47%** | estimated ([below](#mainstream-practitioner): weekly areas 68.8% × discounts 0.9 × 0.9 × 0.85) | | Remaining to beta | **~173–310 h** one Opus 5.5 agent (~35–70 h wall-clock with 5 parallel agents) | estimated | -| Remaining to full parity | **~320–615 h** one agent (~86–150 h wall-clock with 5 parallel agents) | estimated | +| Remaining to full parity | **~315–607 h** one agent (~86–150 h wall-clock with 5 parallel agents) | estimated | | Stage | **alpha**: the alpha gate passes ([roadmap](roadmap.md#alpha-gate)); multiplayer and checking the best-effort `.fig` import on real files are beta gaps (see [ROADMAP](../ROADMAP.md#stage)) | | The previous figures (ROADMAP of 2026-10-10, "feature depth ~79%", "full parity 115–195 h") were @@ -35,8 +35,8 @@ tool; presence was being graded as behaviour in places. | Audience | Ready % | Opus 5.5 agent wall-clock hours to ~95% | Work that dominates | |---|---|---|---| -| Full target (ready for real work) | ~49% | ~305–584 h | multiplayer (45–90), localization (45–75), plugins (15–40), the interaction pass (40–60), GPU canvas (30–60), AI (30–60), the `.fig` import's tail (8–15) | -| Mainstream practitioner | ~46% | ~140–259 h | core editor depth incl. the `.fig` import's tail (58–112), the interaction pass (40–60), real-file corpus and platform QA (25–50), `.fig` export for handing work back (15–30) | +| Full target (ready for real work) | ~49% | ~300–576 h | multiplayer (45–90), localization (45–75), plugins (15–40), the interaction pass (40–60), GPU canvas (30–60), AI (30–60), the `.fig` import's tail (8–15) | +| Mainstream practitioner | ~47% | ~135–251 h | core editor depth incl. the `.fig` import's tail (53–104), the interaction pass (40–60), real-file corpus and platform QA (25–50), `.fig` export for handing work back (15–30) | | Essentials user | ~57% | ~63–115 h | the `.fig` import's tail so the files people send open well (8–15), core tool polish (25–45), interaction pass on the core tools (15–25), stability QA (10–20), onboarding and starter templates (5–10) | Hours are one agent working sequentially, calibrated from this repo's history (about 1 agent-hour @@ -104,7 +104,7 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects | 8 | Frames, groups, sections, masks | 5 | 95% | 75% | Visual QA with real files | 2–4 | | 9 | Auto layout & constraints | 9 | 98% | 74% | Behaviour against real Figma files; wrapped-line content alignment (behaviour suite from the documentation, `fr` tracks like CSS, vertical wrap, auto gaps evenly/around and cell alignment landed) ([auto-layout-parity](auto-layout-parity.md)) | 3–6 | | 10 | Text | 8 | 87% | 68% | Spell check, real-file text metrics, list spacing, hanging punctuation, underline styles (behaviour suite with CJK line breaking, vertical trim and carets by grapheme cluster landed) | 5–10 | -| 11 | Components & variants | 9 | 95% | 68% | Real (not synthetic) libraries, `.fig` libraries, component playground ([components-parity](components-parity.md)) | 6–12 | +| 11 | Components & variants | 9 | 96% | 72% | Real (not synthetic) libraries, `.fig` libraries, component playground (behaviour suite from the documentation, overrides of padding, gap, grids, exports and interactions, refused geometry overrides, variant-switch and Assets-swap override rules, top-left default variant landed) ([components-parity](components-parity.md)) | 4–9 | | 12 | Styles & variables | 5 | 92% | 72% | New library variables reaching extended collections on their own, extending an extended collection, real-library QA (extended collections, variables import/export, gradient stops and variant properties bound landed) | 1–2 | | 13 | Prototyping | 6 | 88% | 70% | Video triggers and media actions, gamepad triggers, real prototype QA ([prototyping-parity](prototyping-parity.md)) | 5–9 | | 14 | Import/export | 6 | 80% | 50% | `.fig` import's tail (best-effort import landed: vectors as outlines, non-design layers skipped, no real-file checks yet), `.fig` export, HEIC/video import, CMYK output intent ([file-format-parity](file-format-parity.md)) | 8–15 | @@ -115,14 +115,14 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects | 19 | Team libraries & organization | 2 | 70% | 40% | Libraries kept on `figcraft-server` under their file and enabled by a share link (per-asset versions, background update checks, overrides kept), usage counts per component and file, swapping a library by names. Left: team-wide library lists, accounts and permissions, analytics over time, pushed update notices | 4–8 | | 20 | Figma Draw extras | 2 | 80% | 55% | Have: variable width, lasso, shape builder, noise/texture, text on a path, pattern fills, stretch and scatter brushes, pen pressure (Windows pens, browser styluses). Missing: multi-edit, pressure from macOS/Linux tablets | 2–5 | | 21 | AI features | 3 | 0% | 0% | First draft, rename layers, image edit/remove background, visual search, Make: needs local models (owner decision) | 30–60 | -| | **Weighted (÷113)** | 113 | **~80%** | **~58%** | | **162–329** | -| | Core editor only (1–16, ÷94) | 94 | ~91% | ~68% | | 57–110 | +| | **Weighted (÷113)** | 113 | **~80%** | **~58%** | | **157–321** | +| | Core editor only (1–16, ÷94) | 94 | ~91% | ~69% | | 52–102 | ## By dimension (ready for real work) | Dimension | Weight | Ready | Hours | Evidence | Doc | |---|---|---|---|---|---| -| Features (depth) | 35 | 57% | 59–112 (areas 1–13, 15, 16, 20) | The table above; core editor ~68% | this file | +| Features (depth) | 35 | 57% | 54–104 (areas 1–13, 15, 16, 20) | The table above; core editor ~69% | this file | | UI/UX fidelity | 15 | 58% | 40–60 | Look & feel compared side by side with UI3 (~65% by the builders); tool-by-tool interaction (cursors, modifiers while dragging, hover states, popovers) not yet compared; accessibility names and keyboard navigation landed | [ui-parity](ui-parity.md) | | File formats | 12 | 55% | 8–15 | `.fig` read, best effort (tested on generated fixtures, not real files), no `.fig` write; REST JSON import, SVG in/out, PNG/JPG/WebP/PDF export solid | [file-format-parity](file-format-parity.md) | | Collaboration | 10 | 13% | 55–110 | Local comments, version history and branches with review and merge; the multiplayer core (sync model, server ordering, client reconciliation, convergence tests) landed in #103, and live sessions (preview) co-edit a file hosted by a desktop app or kept on a self-hosted `figcraft-server` with edit and view links, joined from the app or the browser (`docs/collab-protocol.md`) | [collaboration-parity](collaboration-parity.md) | @@ -133,7 +133,7 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects | Platforms | 3 | 75% | 10–20 | macOS, Windows (x64/x86/arm64), Linux, FreeBSD, web all packaged (v0.2.0); Figma also has iOS/Android apps; non-macOS QA thin | [hardware-parity](hardware-parity.md) | | Ecosystem/plugins | 3 | 28% | 15–40 | Own WASM plugins with a Rust SDK and two examples (#101), plugin panels and settings (ABI v2); no Figma plugin compatibility, no widgets, no distribution or community files; the CLI, control channel and MCP server are ahead of Figma for agents | [gaps](gaps.md) | | AI features | 2 | 0% | 30–60 | None | [gaps](gaps.md) | -| **Ready for real work** | 100 | **~49%** | **~320–615** | | | +| **Ready for real work** | 100 | **~49%** | **~315–607** | | | Dimension hours add up to the full-parity total without double counting: the feature-area rows for import/export (14), multiplayer and team libraries (17, 19), plugins (18) and AI (21) are @@ -187,7 +187,7 @@ libraries (19, cloud/team admin), Figma Draw extras (20, not weekly for a produc localization beyond English, specialist hardware. Real-time multiplayer (17) is cloud collaboration and is left out as an area, but its effect on working with Figma users is counted in discount 3. -**2. Average depth** ("Ready" column, weighted): **68.1%**. +**2. Average depth** ("Ready" column, weighted): **68.8%**. **3. Discounts for what still stops real work:** @@ -197,7 +197,7 @@ and is left out as an area, but its effect on working with Figma users is counte | Stability on real machines | ×0.90 | Never-crash lints, command guard with rollback, recovery copies and 963 tests, but no field record at all: the repository is private, v0.2.0 has had no outside users or issue reports, CI tests run on Linux only and hands-on QA is on macOS. Harsher than VectorCraft's ×0.92 because there is no outside evidence either way | | File exchange with Figma users | ×0.85 | A Figma designer's work lives in shared Figma files. FigCraft opens a `.fig` (best effort, not yet checked on real files) but writes nothing Figma opens as a document; incoming work can come through the REST API JSON (one way, needs an access token), outgoing only as SVG, PDF, PNG or code. Teammates, reviewers and developers would all need FigCraft ([file-format-parity](file-format-parity.md), [collaboration-parity](collaboration-parity.md)) | -68.1% × 0.90 × 0.90 × 0.85 = **~46%** (estimated). +68.8% × 0.90 × 0.90 × 0.85 = **~47%** (estimated). **4. User evidence (2026-10-10):** 0 issues, 0 praise or "switched from Figma" reports, 0 comments from anyone but the maintainers (`gh issue list`, `gh api .../issues/comments`; the repository is @@ -355,7 +355,11 @@ Hours in this narrative are the builders' estimates; the tables above supersede pasted into other files follow their mains (library copies shared, published components used from their library, other mains left behind as a restorable missing main). The Assets panel lays out only the tiles in view and renders thumbnails as they show (cached, re-rendered only for - changed components). *Left:* QA with real-world (not synthetic) libraries. 1 h. + changed components). A behaviour suite from the public documentation checks overrides (what an + instance takes, what it refuses: geometry, constraints and resizing of its layers, its auto + layout direction), variant switches (overrides stay where both variants agreed; text layers keep + theirs), swaps (by layer name; from the Assets panel only text overrides), the top-left default + variant, detach, reset and push. *Left:* QA with real-world (not synthetic) libraries. 1 h. 5. **Prototyping depth**: *landed:* on-canvas connections with drag-to-connect, interaction editor, flows, smart animate, overlays, scroll/fixed/sticky, change-to and set-variable in the presenter. Custom bezier and custom spring easings with editor fields; device frames (phone, tablet, @@ -591,6 +595,7 @@ Carried over from the ROADMAP's "Shipped so far" (2026-10-10). The test count th | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | Components behaviour suite written from the public documentation (10 tests, 45 table cases) and its fixes: instances now keep overrides of auto layout padding, gap and alignment, layout grids, export settings, prototype interactions and every text property (resizing, paragraph spacing, lists…; these were lost when the main changed); moving, rotating, resizing or re-constraining layers inside an instance, and its auto layout direction, are refused (the instance itself resizes); switching variants keeps an override only where both variants had the same value (text layers keep theirs); swapping from the Assets panel keeps only text overrides; the default variant is the top-left one (instances, Assets thumbnails, code); hidden exposed instances hide their properties; pushing changes from a nested layer goes to the outer main. Added: variant property reordering and duplicate combination warnings. Components 95%/68% → 96%/72%, 6–12 h → 4–9 h; with #133's selection and transform change, core editor ready ~68% → ~69% and mainstream ~46% → ~47% (weekly areas 68.1% → 68.8%); full parity ~320–615 h → ~315–607 h (the totals now also carry #133's area 2 hours) | | 2026-10-11 | minor | Embedded image colour profiles: placed and opened images are converted once, when decoded, from their own colour space into the file's (sRGB or Display P3): ICC profiles in PNG (`iCCP`), JPG (APP2), WebP and TIFF, matrix/curve and table profiles in RGB or grey (moxcms, already built for the `image` crate), and a PNG's `sRGB`, `gAMA` and `cHRM` chunks; images without a profile, or with one that can't be used (CMYK, damaged, over 4 MB), are taken as sRGB as before. The original file is kept (PDF exports still embed it); pixel exports carry the converted pixels with the file's profile. The image fill's details show the source profile (Unknown — treated as sRGB when there's none) and `image.place` reports it as `imageProfile`. Paint breadth 87% → 90%, ready 66% → 67%, 4–7 h → 3–6 h; feature breadth (~80%), ready for real work (~49%) and full parity hours unchanged at this rounding | | 2026-10-11 | minor | Layers panel keyboard and stroke variables: F2 renames the selected layer (also from a layer row with the keyboard, which gets it back; Escape there cancels), Shift+F10 (`view.contextMenu`) opens the context menu at the focused row or below the canvas selection and the menu takes the keyboard, F8 (`select.area`) draws a selection box for keyboard-only users (arrows move it, Shift+arrows size it, Enter selects like a marquee; agents pass a box), long lists checked at 10k layers (revealing a layer, expanding or collapsing everything and arrow keys take at most 1.3 ms a frame in the release UI bench; the revealed row takes the keyboard); colour styles on strokes were already complete, and each side's stroke weight now binds to a number variable (`strokeTopWeight`…, bindable fields in the Design panel and REST import). Layers panel 95%/78% → 98%/82%, 2–4 h → 1–2 h; Strokes 90%/72% → 95%/75%, 1–3 h → 1–2 h; mainstream depth 67.8% → 68.1%; feature breadth (~80%), ready for real work (~49%), essentials (~57%) and mainstream (~46%) unchanged at this rounding; full parity ~321–618 h → ~320–615 h | | 2026-10-11 | minor | Text behaviour suite written from the public documentation (13 text cases, 5 command tests) and its fixes: CJK lines break between ideographs, kana and Hangul and before and after Latin words without breaking inside them, with the line-start and line-end rules (closing punctuation, small kana and the prolonged sound mark never start a line, opening brackets never end one); a style's line height, baseline and decoration come from its own font, so lines with CJK or emoji drawn by fallback fonts keep the spacing of the others; paragraph indent applies to start-aligned text only; carets, Delete and Backspace step over whole grapheme clusters (emoji sequences, flags, combining marks), which also shape with one font; double-click and word moves take one script's run in text without spaces. Added: vertical trim (cap height to baseline; `leadingTrim`, Type settings, REST import, inspect, CSS `text-box`). Text 85%/64% → 87%/68%, 7–13 h → 5–10 h; percentages unchanged at this rounding; full parity ~323–621 h → ~321–618 h |