From bfb01901fbd21e3bce325fb4ae5eca95c38ecbd7 Mon Sep 17 00:00:00 2001 From: Markus Reimr Date: Thu, 29 Oct 2026 08:53:13 +0100 Subject: [PATCH 1/2] M10: clarify German gradient, OpenType and startup labels --- ROADMAP.md | 4 +++- crates/ui-egui/locales/de.toml | 18 +++++++++--------- docs/localization-parity.md | 3 ++- 3 files changed, 14 insertions(+), 11 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index b650f08e..70daf090 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (offline Penpot 2.x import/export (#1143); optional batch continue-on-error with per-step outcomes (#1386); the Design panel's sections on one grid (#858); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); images placed at their own size, 2x screenshots in points (#1360); the toolbar on one 32 pt grid, without its Resources button (#1210); M4: zero-length round-capped dashes draw dots; dash expansion uses the existing safety budget; extracted from #1143; Arabic draft catalog; scaled instances keep their scale when their main is rebuilt (#922); .fig embedded images are copied once per blob, not once per hash (#1059); page ruler guides survive save and reopen (#1476); colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); branches in live sessions and branch history entries, gap 17; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (German terminology corrections (M10): gradient stops, OpenType stylistic sets and the Home-screen preference; partial review, catalog remains a draft; offline Penpot 2.x import/export (#1143); optional batch continue-on-error with per-step outcomes (#1386); the Design panel's sections on one grid (#858); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); images placed at their own size, 2x screenshots in points (#1360); the toolbar on one 32 pt grid, without its Resources button (#1210); M4: zero-length round-capped dashes draw dots; dash expansion uses the existing safety budget; extracted from #1143; Arabic draft catalog; scaled instances keep their scale when their main is rebuilt (#922); .fig embedded images are copied once per blob, not once per hash (#1059); page ruler guides survive save and reopen (#1476); colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); branches in live sessions and branch history entries, gap 17; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **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 @@ -132,6 +132,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). ## Progress log +- **2026-10-29** · M10: correct nine German labels in `de.toml`: gradient stops use Verlaufsstopp, OpenType ss01–ss05 use Stilsatz, and the Home-screen preference names the Startbildschirm consistently in the UI and Actions palette. This is a partial terminology pass; German remains a draft awaiting a full native-speaker review and maintainer approval. Numbers unchanged. - **2026-10-28** · M3, #922: an instance scaled with the Scale tool (or `transform.scale`) keeps its layers' scaled size and points when its main is rebuilt. The scaled layers' new size and place were read as what constraints give (an ancestor resized in the same edit), so no override was kept, and adding a layer to the main put a 14 px icon's 24 px triangle back. The Scale tool now records them as `size` and `position` overrides on layers inside instances; strokes already kept theirs. The report's own sequence (clearing effects on the component holding the icon) no longer reproduces on main. Regressions for a scaled instance, one scaled with the tool (with undo) and a scaled icon nested in a component and its instances. Numbers unchanged. - **2026-10-28** · M6, #1059: `.fig` import copies each image carried inside the message once per blob, shared by every hash that names it, instead of once per hash: a file pointing many hashes at one large blob no longer multiplies it in memory (100 hashes on a 250 MB blob were 25 GB). Regression test. Numbers unchanged. - **2026-10-28** · M1, #1476: page ruler guides survive save and reopen. A page kept its guides in the page kind while the layer's own `guides` field (frame guides, #70) took that key when a file was read, so a reopened page's guides vanished, and saving it wrote `guides` twice, which then refused to open. Every layer now keeps its guides in the one field, and a page with two `guides` lists opens with both. Regressions in the doc and format crates. Numbers unchanged. @@ -355,6 +356,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-29 | minor | German terminology corrections (M10): gradient stops, OpenType stylistic sets and the Home-screen preference; partial review, catalog remains a draft. Nine existing translations corrected; keys, placeholders, coverage, grade and estimates unchanged | | 2026-10-29 | minor | The Design panel's sections on one grid (M4, #858): 16 pt left and 8 pt right margins, two 88 pt columns with 8 pt gutters and a 24 pt button column, 24 pt rows under 10 pt captions, 4 pt between a row's two buttons; section titles stay 11 pt semibold; the panel's edge is drawn inside it, so its right margin no longer loses 1 pt to a frame stroke. Numbers unchanged | | 2026-10-29 | minor | The toolbar on one 32 pt grid (M4, #1210): the pill's cells, padding, gaps and divider share one grid in every state, and the Resources button is gone, as in UI3; the Assets panel opens from the left rail, Shift+I, Alt+2 and View ▸ Panels ▸ Assets. Numbers unchanged | | 2026-10-28 | minor | Arabic draft: 1,857 UI strings, 620 command labels, 31 menu wordings; count-neutral plurals and web font gate. Native review and panel mirroring remain open; grade and estimates unchanged. Evidence: `crates/ui-egui/locales/ar.toml`, i18n catalog tests and [translation notes](docs/translating.md#arabic-draft) | diff --git a/crates/ui-egui/locales/de.toml b/crates/ui-egui/locales/de.toml index fa5a02d8..9c62bada 100644 --- a/crates/ui-egui/locales/de.toml +++ b/crates/ui-egui/locales/de.toml @@ -1068,7 +1068,7 @@ status = "draft — needs native review" "Remove row" = "Zeile entfernen" "Remove slot" = "Slot entfernen" "Remove starting point" = "Startpunkt entfernen" -"Remove stop" = "Farbunterbrechung entfernen" +"Remove stop" = "Verlaufsstopp entfernen" "Remove text from path" = "Text vom Pfad lösen" "Remove the else and its actions" = "„Sonst“ und seine Aktionen entfernen" "Remove this condition and its actions" = "Diese Bedingung und ihre Aktionen entfernen" @@ -1280,7 +1280,7 @@ status = "draft — needs native review" "Start ascending from / stop descending at" = "Aufwärts zählen ab / abwärts zählen bis" "Start from a blank canvas" = "Mit einer leeren Arbeitsfläche beginnen" "Start on path" = "Anfang auf dem Pfad" -"Start on the Home screen" = "Mit Start beginnen" +"Start on the Home screen" = "Mit dem Startbildschirm starten" "Start point" = "Startpunkt" "Start sharing" = "Teilen starten" "Start vote" = "Abstimmung starten" @@ -1297,7 +1297,7 @@ status = "draft — needs native review" "Stop sharing" = "Teilen beenden" "Stop spotlighting" = "Spotlight beenden" "Stop timer" = "Timer stoppen" -"Stop {n} {hex}" = "Farbunterbrechung {n} {hex}" +"Stop {n} {hex}" = "Verlaufsstopp {n} {hex}" "Straight" = "Gerade" "Stretch" = "Strecken" "Strikethrough" = "Durchgestrichen" @@ -1308,11 +1308,11 @@ status = "draft — needs native review" "Strokes" = "Konturen" "Style" = "Stil" "Styles" = "Stile" -"Stylistic set 1" = "Stilistischer Satz 1" -"Stylistic set 2" = "Stilistischer Satz 2" -"Stylistic set 3" = "Stilistischer Satz 3" -"Stylistic set 4" = "Stilistischer Satz 4" -"Stylistic set 5" = "Stilistischer Satz 5" +"Stylistic set 1" = "Stilsatz 1" +"Stylistic set 2" = "Stilsatz 2" +"Stylistic set 3" = "Stilsatz 3" +"Stylistic set 4" = "Stilsatz 4" +"Stylistic set 5" = "Stilsatz 5" "Subtract" = "Subtrahieren" "Subtracts" = "Subtraktionen" "Suffix" = "Suffix" @@ -2040,7 +2040,7 @@ status = "draft — needs native review" "pref.reduceMotion" = "Bewegung reduzieren" "pref.renameDuplicates" = "Duplizierte Ebenen umbenennen" "pref.showDimensions" = "Abmessungen an Objekten anzeigen" -"pref.startOnHome" = "Mit Start beginnen" +"pref.startOnHome" = "Mit dem Startbildschirm starten" "pref.userName" = "Ihr Name in Kommentaren" "proto.action.add" = "Prototyp-Aktion hinzufügen" "proto.action.move" = "Prototyp-Aktion neu anordnen" diff --git a/docs/localization-parity.md b/docs/localization-parity.md index d06ebd42..7d35cb49 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -1,6 +1,6 @@ # Localization parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-28 · **Change:** minor (Arabic draft catalog, count-neutral plurals and web font gate; Thai draft catalog and Thai UI fonts; a missing translation fails no check: `cargo xtask i18n-report` lists them; Vietnamese draft catalog; string counts re-measured: 2,352 entries; Russian and Turkish catalogs reviewed by native speakers, Russian one/few/many plurals; Czech and Indonesian drafts; the Japanese draft completed; counts re-measured; Windows UI language from the first preferred language; draft catalogs catch up: every draft translates every string again; Simplified Chinese draft catalog; macOS finds its downloadable CJK system fonts (#406); string counts re-measured: 2,272 entries, the drafts at 95–99%; Swedish command labels and menu wordings; Russian, Swedish, Turkish, Czech and Indonesian listed; a duplicated paragraph removed; draft catalogs catch up: every draft translates every string again; Simplified Chinese draft catalog) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (German terminology corrections (M10): gradient stops, OpenType stylistic sets and the Home-screen preference; partial review, catalog remains a draft; Arabic draft catalog, count-neutral plurals and web font gate; Thai draft catalog and Thai UI fonts; a missing translation fails no check: `cargo xtask i18n-report` lists them; Vietnamese draft catalog; string counts re-measured: 2,352 entries; Russian and Turkish catalogs reviewed by native speakers, Russian one/few/many plurals; Czech and Indonesian drafts; the Japanese draft completed; counts re-measured; Windows UI language from the first preferred language; draft catalogs catch up: every draft translates every string again; Simplified Chinese draft catalog; macOS finds its downloadable CJK system fonts (#406); string counts re-measured: 2,272 entries, the drafts at 95–99%; Swedish command labels and menu wordings; Russian, Swedish, Turkish, Czech and Indonesian listed; a duplicated paragraph removed; draft catalogs catch up: every draft translates every string again; Simplified Chinese draft catalog) · **Target:** Figma Design (desktop app 126.10.7) FigCraft's interface has a localization layer with an English catalog, a Russian catalog reviewed by a native speaker, and Spanish (with a Latin American overlay), French, German, Brazilian @@ -123,6 +123,7 @@ Hindi and Arabic. Traditional Chinese is not planned (no catalog; its users see | Date | Change | Summary | |---|---|---| +| 2026-10-29 | minor | German terminology corrections (M10): gradient stops, OpenType stylistic sets and the Home-screen preference; partial review, catalog remains a draft. Nine existing translations corrected; keys, placeholders, coverage, grade and estimates unchanged | | 2026-10-28 | minor | Arabic draft: 1,857 UI entries, 620 command labels and 31 menu wordings, with count-neutral plurals. AI-assisted, native review pending. Desktop uses the existing Arabic fallback and bidi; web requires an embedded Arabic face. No panel mirroring or parity-grade increase claimed | | 2026-10-24 | minor | Thai (`th`) draft catalog: `th.toml` translates every UI string (1,791), 589 command labels and the 31 menu wordings, machine-drafted and marked as a draft awaiting native review (9 newer command labels read English, as in the other drafts). The desktop UI now adds an installed Thai face (Noto Sans Thai, Leelawadee UI, Tahoma, Sukhumvit Set, Thonburi, Ayuthaya, Sarabun, Loma, Garuda or Waree) when a layer or page name or the UI language needs it, so Thai names no longer show as tofu squares. No plural forms for Thai. Grade and hours unchanged | | 2026-10-24 | minor | Vietnamese (`vi`) draft catalog (#1026), assessed as a machine draft awaiting review like the others; string counts re-measured: 2,352 entries. | From 2f47014917600058edb015ed65ce44945614a3c7 Mon Sep 17 00:00:00 2001 From: Mert Dede Date: Fri, 30 Oct 2026 06:48:01 +0300 Subject: [PATCH 2/2] M3: stored variable and collection references keep their identity (#1613) Co-Authored-By: Claude Opus 5.5 Co-Authored-By: Claude Sonnet 5.5 Co-Authored-By: Claude Fable 5.1 Co-Authored-By: GPT-6.1 Sol Co-Authored-By: GPT-6 Astra Co-Authored-By: GPT-6.1 Terra --- crates/doc/src/proto_expr.rs | 20 ++- crates/doc/src/proto_expr_mode_tests.rs | 34 +++++ crates/doc/src/proto_expr_parse.rs | 10 +- crates/doc/src/styles.rs | 7 +- crates/doc/src/tests.rs | 14 ++ crates/engine/src/assets/library.rs | 13 +- crates/engine/src/assets/library_swap.rs | 8 +- .../engine/src/assets/library_swap/tests.rs | 32 ++++ crates/engine/src/cmd/library.rs | 7 +- crates/engine/src/cmd/proto.rs | 38 +---- crates/engine/src/cmd/proto_actions.rs | 71 ++++++++- crates/engine/src/cmd/styles.rs | 2 +- crates/engine/src/cmd/variables.rs | 18 ++- crates/engine/src/cmd/variables_io.rs | 4 +- crates/engine/src/dev/devinspect.rs | 2 +- crates/engine/src/devinspect_tests.rs | 19 +++ crates/engine/src/fig_export_assets_tests.rs | 23 +++ crates/engine/src/library_variables_tests.rs | 33 +++++ crates/engine/src/proto_run.rs | 16 +- crates/engine/src/proto_run_tests.rs | 137 ++++++++++++++++++ .../engine/src/variables_behaviour_tests.rs | 21 +++ crates/engine/src/variables_editor_tests.rs | 63 ++++++++ crates/engine/src/variables_io_tests.rs | 83 +++++++++++ crates/format/src/fig/export/assets.rs | 4 +- crates/format/src/penpot/export.rs | 2 +- crates/format/src/penpot/export_tests.rs | 24 +++ crates/format/src/rest/conditional_tests.rs | 31 ++++ crates/format/src/rest/proto.rs | 4 +- crates/ui-egui/src/panels/variable_ui.rs | 4 +- crates/ui-egui/src/present/actions.rs | 2 +- crates/ui-egui/src/present/variables_tests.rs | 31 +++- crates/ui-egui/src/proto_responsive_tests.rs | 28 ++++ docs/components-parity.md | 5 +- docs/roadmap.md | 5 +- 34 files changed, 717 insertions(+), 98 deletions(-) diff --git a/crates/doc/src/proto_expr.rs b/crates/doc/src/proto_expr.rs index d5441b28..e40d3ef7 100644 --- a/crates/doc/src/proto_expr.rs +++ b/crates/doc/src/proto_expr.rs @@ -103,10 +103,12 @@ impl Expr { Expr::Value(VariableValue::Alias(id)) => return vars.resolve(id, modes), Expr::Value(VariableValue::Number(n)) => return n.is_finite().then_some(VariableValue::Number(*n)), Expr::Value(v) => return Some(v.clone()), - Expr::Mode { collection } => return vars.collection(collection)?.current_mode(modes).map(|m| VariableValue::String(m.name.clone())), + Expr::Mode { collection } => { + return vars.collection_by_id(collection)?.current_mode(modes).map(|m| VariableValue::String(m.name.clone())); + } Expr::InMode { variable, mode } => { - let v = vars.get(variable)?; - let c = vars.collection(&v.collection)?; + let v = vars.get_by_id(variable)?; + let c = vars.collection_by_id(&v.collection)?; let mut at = modes.clone(); at.insert(c.id.clone(), c.find_mode(mode)?.id.clone()); return vars.resolve(&v.id, &at); @@ -143,20 +145,22 @@ impl Expr { return "…".into(); } match self { - Expr::Value(VariableValue::Alias(id)) => vars.get(id).map_or_else(|| "(missing variable)".into(), |v| var_ref(vars, v)), + Expr::Value(VariableValue::Alias(id)) => vars.get_by_id(id).map_or_else(|| "(missing variable)".into(), |v| var_ref(vars, v)), Expr::Value(VariableValue::String(s)) => quote(s), // Exact numbers and `#` colours, so the text reads back as the same expression. Expr::Value(VariableValue::Number(n)) => format!("{n}"), Expr::Value(VariableValue::Color(c)) => c.to_css_hex(), Expr::Value(v) => v.display(), Expr::Mode { collection } => { - let name = vars.collection(collection).map_or("(missing collection)", |c| c.name.as_str()); + let name = vars.collection_by_id(collection).map_or("(missing collection)", |c| c.name.as_str()); format!("mode({})", name_arg(name)) } Expr::InMode { variable, mode } => { - let var = vars.get(variable); - let mode = - var.and_then(|v| vars.collection(&v.collection)).and_then(|c| c.find_mode(mode)).map_or("(missing mode)", |m| m.name.as_str()); + let var = vars.get_by_id(variable); + let mode = var + .and_then(|v| vars.collection_by_id(&v.collection)) + .and_then(|c| c.find_mode(mode)) + .map_or("(missing mode)", |m| m.name.as_str()); format!("{}[{}]", Expr::var(variable.clone()).display_at(vars, depth + 1), name_arg(mode)) } Expr::Op { op, args } => { diff --git a/crates/doc/src/proto_expr_mode_tests.rs b/crates/doc/src/proto_expr_mode_tests.rs index ea2757c7..1a8d8b78 100644 --- a/crates/doc/src/proto_expr_mode_tests.rs +++ b/crates/doc/src/proto_expr_mode_tests.rs @@ -138,3 +138,37 @@ fn lookups_evaluate_in_their_mode_and_modes_follow_the_layer() { assert_eq!(mode_of("gone").eval(&vars, &light), None); assert_eq!(in_mode("n", "dark").variables(), ["n"]); } + +#[test] +fn stored_variable_ids_keep_expression_evaluation_display_and_types() { + let mut vars = more_vars(); + let mut shadow = vars.get_by_id("s").unwrap().clone(); + shadow.id = "shadow".into(); + shadow.name = "n".into(); + vars.variables.insert(0, shadow); + let mut shadow = vars.collection("cs").unwrap().clone(); + shadow.id = "shadow".into(); + shadow.name = "c".into(); + vars.collections.insert(0, shadow); + let modes = BTreeMap::new(); + for e in [Expr::var("n"), in_mode("n", "dark")] { + assert_eq!(e.type_of(&vars), Ok(VariableType::Number)); + assert!(e.display(&vars).starts_with("n-name")); + } + assert_eq!(Expr::var("n").eval(&vars, &modes), Some(VariableValue::Number(3.0))); + assert_eq!(in_mode("n", "dark").eval(&vars, &modes), Some(VariableValue::Number(7.0))); + assert_eq!(Expr::parse("n-name[Dark]", &vars), Ok(in_mode("n", "dark"))); + assert_eq!(mode_of("c").eval(&vars, &modes), Some(VariableValue::String("Light".into()))); + assert_eq!(mode_of("c").display(&vars), "mode(Theme)"); + + vars.variables.retain(|v| v.id != "n"); + for e in [Expr::var("n"), in_mode("n", "dark")] { + assert_eq!(e.eval(&vars, &modes), None); + assert_eq!(e.type_of(&vars), Err("No variable `n`".into())); + assert!(e.display(&vars).contains("missing variable")); + } + vars.collections.retain(|c| c.id != "c"); + assert_eq!(mode_of("c").eval(&vars, &modes), None); + assert_eq!(mode_of("c").type_of(&vars), Err("No variable collection `c`".into())); + assert_eq!(mode_of("c").display(&vars), "mode(\"(missing collection)\")"); +} diff --git a/crates/doc/src/proto_expr_parse.rs b/crates/doc/src/proto_expr_parse.rs index 54615ee0..660b4a83 100644 --- a/crates/doc/src/proto_expr_parse.rs +++ b/crates/doc/src/proto_expr_parse.rs @@ -88,15 +88,15 @@ impl Expr { return Err("The expression is nested too deeply".into()); } let (op, args) = match self { - Expr::Value(VariableValue::Alias(id)) => return vars.get(id).map(|v| v.kind).ok_or_else(|| format!("No variable `{id}`")), + Expr::Value(VariableValue::Alias(id)) => return vars.get_by_id(id).map(|v| v.kind).ok_or_else(|| format!("No variable `{id}`")), Expr::Value(VariableValue::Number(n)) if !n.is_finite() => return Err("Numbers must be finite".into()), Expr::Value(v) => return v.type_of().ok_or_else(|| "Not a value".to_string()), Expr::Mode { collection } => { - return vars.collection(collection).map(|_| T::String).ok_or_else(|| format!("No variable collection `{collection}`")); + return vars.collection_by_id(collection).map(|_| T::String).ok_or_else(|| format!("No variable collection `{collection}`")); } Expr::InMode { variable, mode } => { - let v = vars.get(variable).ok_or_else(|| format!("No variable `{variable}`"))?; - let c = vars.collection(&v.collection).ok_or_else(|| format!("No variable collection `{}`", v.collection))?; + let v = vars.get_by_id(variable).ok_or_else(|| format!("No variable `{variable}`"))?; + let c = vars.collection_by_id(&v.collection).ok_or_else(|| format!("No variable collection `{}`", v.collection))?; return c.find_mode(mode).map(|_| v.kind).ok_or_else(|| format!("{} has no mode `{mode}`", c.name)); } Expr::Op { op, args } => (*op, args), @@ -501,7 +501,7 @@ impl Parser<'_> { self.i += 1; let start = self.i; let name = self.argument(']', "mode")?; - let coll = self.vars.get(&var).and_then(|v| self.vars.collection(&v.collection)); + let coll = self.vars.get_by_id(&var).and_then(|v| self.vars.collection_by_id(&v.collection)); match coll.and_then(|c| c.find_mode(&name)) { Some(m) => Ok(Expr::InMode { variable: var, mode: m.id.clone() }), None => { diff --git a/crates/doc/src/styles.rs b/crates/doc/src/styles.rs index 92ea24cc..9f8dbdb7 100644 --- a/crates/doc/src/styles.rs +++ b/crates/doc/src/styles.rs @@ -278,13 +278,18 @@ impl Variables { self.collections.iter().find(|c| c.id == id || c.name == id) } + /// Look up a stored collection reference by exact id, without matching collection names. + pub fn collection_by_id(&self, id: &str) -> Option<&Collection> { + self.collections.iter().find(|c| c.id == id) + } + /// Resolve `id` in `modes` (collection → chosen mode; default mode otherwise), following aliases /// (bounded, cycles return None). pub fn resolve(&self, id: &str, modes: &BTreeMap) -> Option { let mut cur = id.to_string(); for _ in 0..32 { let var = self.get_by_id(&cur)?; - let coll = self.collection(&var.collection)?; + let coll = self.collection_by_id(&var.collection)?; let mode = modes.get(&coll.id).filter(|m| var.values.contains_key(*m)).or_else(|| coll.default_mode())?; match var.values.get(mode).or_else(|| var.values.values().next())? { VariableValue::Alias(next) => cur = next.clone(), diff --git a/crates/doc/src/tests.rs b/crates/doc/src/tests.rs index 0ed0ef35..5b1cf23c 100644 --- a/crates/doc/src/tests.rs +++ b/crates/doc/src/tests.rs @@ -708,3 +708,17 @@ fn two_segment_closed_loop_rounded_corners() { let path = net.fill_path(); assert!(!path.elements().is_empty()); } + +#[test] +fn stored_variable_ids_resolve_only_their_exact_collection() { + let mut vars = crate::proto_expr_tests::vars(); + // The wrong collection's modes force the old resolver's first-value fallback (dark). + let mut shadow = vars.collections[0].clone(); + shadow.id = "shadow".into(); + shadow.name = "c".into(); + shadow.modes = vec![Mode { id: "other".into(), name: "Other".into() }]; + vars.collections.insert(0, shadow); + assert_eq!(vars.resolve("n", &Default::default()), Some(VariableValue::Number(3.0))); + vars.collections.retain(|c| c.id != "c"); + assert_eq!(vars.resolve("n", &Default::default()), None); +} diff --git a/crates/engine/src/assets/library.rs b/crates/engine/src/assets/library.rs index 24699ef2..94500eac 100644 --- a/crates/engine/src/assets/library.rs +++ b/crates/engine/src/assets/library.rs @@ -240,7 +240,10 @@ pub fn default_selection(doc: &Document, prev: Option<&LibraryManifest>) -> Sele .collections .into_iter() .filter(|c| { - pick(prev.and_then(|m| m.collection(c)).map(|x| (x.published, x.name.as_str())), doc.variables.collection(c).map(|x| x.name.as_str())) + pick( + prev.and_then(|m| m.collection(c)).map(|x| (x.published, x.name.as_str())), + doc.variables.collection_by_id(c).map(|x| x.name.as_str()), + ) }) .collect(), } @@ -251,7 +254,7 @@ fn alias_closure(vars: &Variables, seeds: impl IntoIterator) let mut out = BTreeSet::new(); let mut stack: Vec = seeds.into_iter().collect(); while let Some(cur) = stack.pop() { - let Some(v) = vars.get(&cur) else { continue }; + let Some(v) = vars.get_by_id(&cur) else { continue }; if out.len() >= MAX_ASSETS || !out.insert(v.id.clone()) { continue; } @@ -702,7 +705,7 @@ pub fn import_variables( ) -> VariableMap { let (lid, m, src) = (lib.manifest.id.as_str(), &lib.manifest, &lib.doc.variables); let vars = alias_closure(src, keys.iter().cloned()); - let colls: BTreeSet<&CollectionId> = collections.iter().chain(vars.iter().filter_map(|v| src.get(v)).map(|v| &v.collection)).collect(); + let colls: BTreeSet<&CollectionId> = collections.iter().chain(vars.iter().filter_map(|v| src.get_by_id(v)).map(|v| &v.collection)).collect(); let mut out = VariableMap::default(); // Collections first: the variables name theirs. for key in colls { @@ -758,7 +761,7 @@ pub fn import_variables( let val = match val { // An alias to a variable the library doesn't have resolves to nothing: dropped // (never pointed at a variable of this file). - VariableValue::Alias(a) => match src.get(a).and_then(|t| out.variables.get(&t.id)) { + VariableValue::Alias(a) => match src.get_by_id(a).and_then(|t| out.variables.get(&t.id)) { Some(l) => VariableValue::Alias(l.clone()), None => continue, }, @@ -955,7 +958,7 @@ pub fn pending(doc: &Document, lib: &PublishedLibrary) -> Pending { .filter(|r| r.library == m.id) .map(|r| r.key.as_str()) .collect(); - let copied = |key: &str| libs.variables.iter().any(|r| r.library == m.id && r.key == key && vars.get(&r.local).is_some()); + let copied = |key: &str| libs.variables.iter().any(|r| r.library == m.id && r.key == key && vars.get_by_id(&r.local).is_some()); variables.extend(published_variables(lib).filter(|v| extended.contains(v.collection.as_str()) && !copied(&v.id)).map(|v| ImportedRecord { library: m.id.clone(), key: v.id.clone(), diff --git a/crates/engine/src/assets/library_swap.rs b/crates/engine/src/assets/library_swap.rs index 1dfc1691..f4af1c34 100644 --- a/crates/engine/src/assets/library_swap.rs +++ b/crates/engine/src/assets/library_swap.rs @@ -149,7 +149,7 @@ pub fn swap_library(doc: &mut Document, from: &str, to: &PublishedLibrary) -> Re out.styles = styles.len(); // Variables: by collection and variable name and type; collections and modes by name. - let coll_name = |d: &Document, id: &str| d.variables.collection(id).map(|c| c.name.clone()).unwrap_or_default(); + let coll_name = |d: &Document, id: &str| d.variables.collection_by_id(id).map(|c| c.name.clone()).unwrap_or_default(); // Variables the document's own layers bind or its own variables alias. let remote: BTreeSet = doc.libraries.variables.iter().map(|r| r.local.clone()).collect(); let aliases = doc.variables.variables.iter().filter(|v| !remote.contains(&v.id)).flat_map(|v| v.values.values()).filter_map(|x| match x { @@ -161,7 +161,7 @@ pub fn swap_library(doc: &mut Document, from: &str, to: &PublishedLibrary) -> Re let lib_coll = |name: &str| to.doc.variables.collections.iter().find(|c| c.name == name && m.collection(&c.id).is_some_and(|x| x.published)); let mut var_keys: BTreeMap = BTreeMap::new(); for r in &var_recs { - let Some(v) = doc.variables.get(&r.local) else { continue }; + let Some(v) = doc.variables.get_by_id(&r.local) else { continue }; let cname = coll_name(doc, &v.collection); let found = lib_coll(&cname).and_then(|c| { to.doc @@ -189,10 +189,10 @@ pub fn swap_library(doc: &mut Document, from: &str, to: &PublishedLibrary) -> Re // A's collection → (B's, A's mode id → B's mode of the same name). let mut colls: BTreeMap)> = BTreeMap::new(); for (a, k) in &coll_keys { - let (Some(b), Some(ac)) = (vmap.collections.get(k), doc.variables.collection(a)) else { continue }; + let (Some(b), Some(ac)) = (vmap.collections.get(k), doc.variables.collection_by_id(a)) else { continue }; let modes = doc .variables - .collection(b) + .collection_by_id(b) .map(|bc| { ac.modes.iter().filter_map(|am| bc.modes.iter().find(|bm| bm.name == am.name).map(|bm| (am.id.clone(), bm.id.clone()))).collect() }) diff --git a/crates/engine/src/assets/library_swap/tests.rs b/crates/engine/src/assets/library_swap/tests.rs index fa01e05a..db769a0e 100644 --- a/crates/engine/src/assets/library_swap/tests.rs +++ b/crates/engine/src/assets/library_swap/tests.rs @@ -114,3 +114,35 @@ fn swap_moves_instances_styles_and_variables_by_name() { assert!(s.execute("library.swap", &json!({"from": b, "to": b})).is_err()); assert!(s.execute("library.swap", &json!({"from": a, "to": b})).is_err()); } + +#[test] +fn stored_variable_ids_keep_library_swap_matches_on_the_real_copies() { + let dir = Scratch(std::env::temp_dir().join(format!("figcraft-libswap-ids-{}", std::process::id()))); + std::fs::create_dir_all(&dir.0).unwrap(); + let (pa, pb) = (dir.0.join("a.figcraft").to_string_lossy().into_owned(), dir.0.join("b.figcraft").to_string_lossy().into_owned()); + let a = library(&pa, "#0000ff", &[]); + let b = library(&pb, "#00ff00", &[]); + let mut s = Session::new(); + let shadow_collection = ex(&mut s, "variable.createCollection", json!({"name": "Shadow"})); + let shadow = ex(&mut s, "variable.create", json!({"collection": "Shadow", "name": "shadow", "type": "color", "value": "#ff0000"})); + ex(&mut s, "library.add", json!({"path": pa})); + ex(&mut s, "library.add", json!({"path": pb})); + let dot = idv(&ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10}))); + ex(&mut s, "library.bindVariable", json!({"library": a, "variable": "accent", "field": "fill", "ids": [dot]})); + ex(&mut s, "library.setVariableMode", json!({"library": a, "collection": "Theme", "mode": "Dark", "ids": [dot]})); + let doc = s.doc().unwrap(); + let target = doc.libraries.variables[0].local.clone(); + let collection = doc.libraries.collections[0].local.clone(); + ex(&mut s, "variable.update", json!({"variable": shadow["id"], "name": target})); + ex(&mut s, "variable.renameCollection", json!({"collection": shadow_collection["id"], "name": collection})); + let result = ex(&mut s, "library.swap", json!({"from": a, "to": b})); + assert_eq!(result["variables"], 1); + assert_eq!(result["unmatched"], json!([])); + let doc = s.doc().unwrap(); + let node = doc.node(dot).unwrap(); + assert_eq!(node.fills[0].solid_color().unwrap().to_hex(), "00FF00"); + assert!(!node.bound.is_empty()); + assert!(!node.var_modes.is_empty()); + assert!(node.bound.values().all(|v| doc.libraries.variable_import(v).is_some_and(|r| r.library == b))); + assert!(node.var_modes.keys().all(|c| doc.libraries.collection_import(c).is_some_and(|r| r.library == b))); +} diff --git a/crates/engine/src/cmd/library.rs b/crates/engine/src/cmd/library.rs index 0c7edbb2..e562cfd6 100644 --- a/crates/engine/src/cmd/library.rs +++ b/crates/engine/src/cmd/library.rs @@ -238,7 +238,7 @@ fn alias(s: &mut Session, p: &Value) -> Result { let kind = doc.variables.get(&own).filter(|v| !is_remote_variable(doc, &v.id)).map(|v| v.kind); let kind = kind.ok_or_else(|| bad(cmd, format!("no local variable `{own}`")))?; let (lib, key) = variable_arg(s, cmd, p, "alias")?; - if lib.doc.variables.get(&key).is_some_and(|v| v.kind != kind) { + if lib.doc.variables.get_by_id(&key).is_some_and(|v| v.kind != kind) { return Err(bad(cmd, "the library variable has another type")); } let local = import_variable(s, &lib, &key)?; @@ -467,7 +467,8 @@ fn swap_cmd(s: &mut Session, p: &Value) -> Result { fn assets_json(lib: &PublishedLibrary) -> Value { let m = &lib.manifest; - let modes = |c: &str| lib.doc.variables.collection(c).map(|c| c.modes.iter().map(|x| json!({"id": x.id, "name": x.name})).collect::>()); + let modes = + |c: &str| lib.doc.variables.collection_by_id(c).map(|c| c.modes.iter().map(|x| json!({"id": x.id, "name": x.name})).collect::>()); json!({ "components": m.components.iter().filter(|a| a.published).map(|a| json!({"id": a.id, "name": a.name, "set": a.set, "version": a.version})).collect::>(), "styles": m.styles.iter().filter(|x| x.published).map(|x| json!({"id": x.id, "name": x.name, "type": x.kind, "version": x.version})).collect::>(), @@ -513,7 +514,7 @@ fn list(s: &mut Session, p: &Value) -> Result { res["publishable"] = json!({ "components": all.components.iter().filter_map(|c| doc.node(*c).map(|n| json!({"id": c, "name": n.name, "selected": def.components.contains(c)}))).collect::>(), "styles": all.styles.iter().filter_map(|x| doc.style(x).map(|st| json!({"id": st.id, "name": st.name, "selected": def.styles.contains(x)}))).collect::>(), - "collections": all.collections.iter().filter_map(|x| doc.variables.collection(x).map(|c| json!({"id": c.id, "name": c.name, "selected": def.collections.contains(x)}))).collect::>(), + "collections": all.collections.iter().filter_map(|x| doc.variables.collection_by_id(x).map(|c| json!({"id": c.id, "name": c.name, "selected": def.collections.contains(x)}))).collect::>(), }); } Ok(res) diff --git a/crates/engine/src/cmd/proto.rs b/crates/engine/src/cmd/proto.rs index 3f51bb5e..a620b037 100644 --- a/crates/engine/src/cmd/proto.rs +++ b/crates/engine/src/cmd/proto.rs @@ -308,40 +308,6 @@ pub(crate) fn check_action_dests(cmd: &str, doc: &Document, actions: &[Action]) Ok(()) } -/// Variables, collections and modes that actions name by name, stored by id. -fn canonical_ids(doc: &Document, it: &mut Interaction) { - canonical_actions(&doc.variables, &mut it.actions); -} - -/// Variables (also those expressions read), collections and modes that `actions` name by name, -/// stored by id. -pub(crate) fn canonical_actions(vars: &Variables, actions: &mut [Action]) { - walk_mut(actions, &mut |a| match a { - Action::SetVariable { variable, expr, .. } => { - if let Some(v) = vars.get(variable) { - *variable = v.id.clone(); - } - if let Some(e) = expr { - canonical_expr(vars, e, 0); - } - } - Action::SetVariableMode { collection, mode } => { - if let Some(c) = vars.collection(collection) { - if let Some(m) = c.find_mode(mode) { - *mode = m.id.clone(); - } - *collection = c.id.clone(); - } - } - Action::Conditional { blocks } => { - for e in blocks.iter_mut().filter_map(|b| b.condition.as_mut()) { - canonical_expr(vars, e, 0); - } - } - _ => {} - }); -} - /// Variables, collections and modes an expression names by name, stored by id. pub(crate) fn canonical_expr(vars: &Variables, e: &mut Expr, depth: usize) { match e { @@ -357,7 +323,7 @@ pub(crate) fn canonical_expr(vars: &Variables, e: &mut Expr, depth: usize) { } Expr::InMode { variable, mode } => { if let Some(v) = vars.get(variable) { - if let Some(m) = vars.collection(&v.collection).and_then(|c| c.find_mode(mode)) { + if let Some(m) = vars.collection_by_id(&v.collection).and_then(|c| c.find_mode(mode)) { *mode = m.id.clone(); } *variable = v.id.clone(); @@ -425,7 +391,6 @@ fn add(s: &mut Session, p: &Value) -> Result { let mut it = it; it.delay_ms = delay(cmd, p)?.unwrap_or(0); settle(&mut it); - canonical_ids(s.doc()?, &mut it); check_dests(cmd, s, &it)?; let mut index = 0; for i in req_targets(cmd, s, p)? { @@ -484,7 +449,6 @@ fn set(s: &mut Session, p: &Value) -> Result { if it.actions.is_empty() { return Err(bad(cmd, "an interaction needs an action")); } - canonical_ids(s.doc()?, &mut it); check_dests(cmd, s, &it)?; if let Some(slot) = s.doc_mut()?.get_mut(i)?.interactions.get_mut(k) { *slot = it; diff --git a/crates/engine/src/cmd/proto_actions.rs b/crates/engine/src/cmd/proto_actions.rs index 95a53a9d..9af669cd 100644 --- a/crates/engine/src/cmd/proto_actions.rs +++ b/crates/engine/src/cmd/proto_actions.rs @@ -159,15 +159,73 @@ pub fn default_condition(vars: &Variables) -> Expr { } } -/// `p` with every `expression` text in it read: on a set variable action it becomes `expr` -/// (checked against the variable), elsewhere (conditional blocks) the `condition` (checked to give -/// true or false). +/// `p` with incoming names stored as ids and every `expression` text read: on a set variable +/// action it becomes `expr` (checked against the variable), elsewhere (conditional blocks) the +/// `condition` (checked to give true or false). Parsed text already holds ids, so it is never +/// canonicalized again. Existing actions that a command leaves alone never pass through here. pub fn with_expressions(cmd: &str, vars: &Variables, p: &Value) -> Result { let mut v = p.clone(); + canonical_inputs(cmd, vars, &mut v, 0)?; resolve_expressions(cmd, vars, &mut v, 0)?; Ok(v) } +/// Visit input actions and conditional blocks, never literal values or parsed expressions. +fn canonical_inputs(cmd: &str, vars: &Variables, v: &mut Value, depth: usize) -> Result<()> { + if depth > 4 * MAX_ACTION_DEPTH { + return Err(bad(cmd, "actions are nested too deeply")); + } + match v { + Value::Array(a) => a.iter_mut().try_for_each(|x| canonical_inputs(cmd, vars, x, depth + 1)), + Value::Object(o) => { + canonical_action_names(vars, o); + canonical_json_exprs(cmd, vars, o)?; + for key in ["action", "actions", "interaction", "blocks"] { + if let Some(child) = o.get_mut(key) { + canonical_inputs(cmd, vars, child, depth + 1)?; + } + } + Ok(()) + } + _ => Ok(()), + } +} + +/// Only command input accepts names: settle action targets before reading text expressions. +fn canonical_action_names(vars: &Variables, o: &mut serde_json::Map) { + match o.get("type").and_then(Value::as_str) { + Some("setVariable") => { + if let Some(v) = o.get("variable").and_then(Value::as_str).and_then(|n| vars.get(n)) { + o.insert("variable".into(), json!(v.id)); + } + } + Some("setVariableMode") => { + if let Some(c) = o.get("collection").and_then(Value::as_str).and_then(|n| vars.collection(n)) { + if let Some(m) = o.get("mode").and_then(Value::as_str).and_then(|n| c.find_mode(n)) { + o.insert("mode".into(), json!(m.id)); + } + o.insert("collection".into(), json!(c.id)); + } + } + _ => {} + } +} + +/// JSON expressions accept names too; text replaces them and is parsed separately. +fn canonical_json_exprs(cmd: &str, vars: &Variables, o: &mut serde_json::Map) -> Result<()> { + if o.get("expression").is_some_and(Value::is_string) { + return Ok(()); + } + for key in ["expr", "condition"] { + if let Some(value) = o.get_mut(key).filter(|v| !v.is_null()) { + let mut e: Expr = serde_json::from_value(value.clone()).map_err(|e| bad(cmd, format!("{key}: {e}")))?; + super::proto::canonical_expr(vars, &mut e, 0); + *value = expr_json(cmd, &e)?; + } + } + Ok(()) +} + fn resolve_expressions(cmd: &str, vars: &Variables, v: &mut Value, depth: usize) -> Result<()> { if depth > 4 * MAX_ACTION_DEPTH { return Err(bad(cmd, "actions are nested too deeply")); @@ -181,7 +239,7 @@ fn resolve_expressions(cmd: &str, vars: &Variables, v: &mut Value, depth: usize) let var = o .get("variable") .and_then(Value::as_str) - .and_then(|n| vars.get(n)) + .and_then(|n| vars.get_by_id(n)) .ok_or_else(|| bad(cmd, "setVariable: no such `variable`"))?; e.check_value(vars, var.kind).map_err(|m| bad(cmd, m))?; o.insert("expr".into(), expr_json(cmd, &e)?); @@ -208,7 +266,7 @@ fn check_action(cmd: &str, vars: &Variables, a: &Action) -> Result<()> { for x in walk(std::slice::from_ref(a)) { match x { Action::SetVariable { variable, expr, .. } => { - let v = vars.get(variable).ok_or_else(|| bad(cmd, format!("no variable `{variable}`")))?; + let v = vars.get_by_id(variable).ok_or_else(|| bad(cmd, format!("no variable `{variable}`")))?; if let Some(e) = expr { e.check_value(vars, v.kind).map_err(|m| bad(cmd, m))?; } @@ -231,8 +289,7 @@ fn check_action(cmd: &str, vars: &Variables, a: &Action) -> Result<()> { fn action_param(cmd: &str, s: &Session, p: &Value) -> Result { let doc = s.doc()?; let p = with_expressions(cmd, &doc.variables, p)?; - let mut a: Action = parse(cmd, &p, "action")?.ok_or_else(|| bad(cmd, "`action` required"))?; - super::proto::canonical_actions(&doc.variables, std::slice::from_mut(&mut a)); + let a: Action = parse(cmd, &p, "action")?.ok_or_else(|| bad(cmd, "`action` required"))?; check_action(cmd, &doc.variables, &a)?; super::proto::check_action_dests(cmd, doc, std::slice::from_ref(&a))?; Ok(a) diff --git a/crates/engine/src/cmd/styles.rs b/crates/engine/src/cmd/styles.rs index 5ccad426..b88e3159 100644 --- a/crates/engine/src/cmd/styles.rs +++ b/crates/engine/src/cmd/styles.rs @@ -958,7 +958,7 @@ pub fn var_set(s: &mut Session, p: &Value) -> Result { let v = req_string(cmd, p, "variable")?; let doc = s.doc_mut()?; let var = doc.variables.get(&v).cloned().ok_or_else(|| bad(cmd, "no such variable"))?; - let coll = doc.variables.collection(&var.collection).cloned().ok_or_else(|| bad(cmd, "variable has no collection"))?; + let coll = doc.variables.collection_by_id(&var.collection).cloned().ok_or_else(|| bad(cmd, "variable has no collection"))?; let mode = match string(p, "mode") { Some(m) => coll.modes.iter().find(|x| x.id == m || x.name == m).map(|x| x.id.clone()).ok_or_else(|| bad(cmd, "no such mode"))?, None => coll.default_mode().cloned().ok_or_else(|| bad(cmd, "collection has no modes"))?, diff --git a/crates/engine/src/cmd/variables.rs b/crates/engine/src/cmd/variables.rs index 24ecb009..723633f9 100644 --- a/crates/engine/src/cmd/variables.rs +++ b/crates/engine/src/cmd/variables.rs @@ -89,7 +89,7 @@ fn inherited(cmd: &str, what: &str) -> crate::EngineError { /// Refuse mode edits of extended collections (they have the modes of the collection they extend). pub(crate) fn check_modes_editable(doc: &Document, cmd: &str, collection: &str) -> Result<()> { - match doc.variables.collection(collection) { + match doc.variables.collection_by_id(collection) { Some(c) if c.extends.is_some() => Err(inherited(cmd, "an extended collection's modes")), _ => Ok(()), } @@ -304,7 +304,7 @@ pub(crate) fn forget_variables(doc: &mut Document, gone: &BTreeSet) for v in vars.variables.iter().filter(|v| !gone.contains(&v.id)) { for (m, val) in &v.values { let VariableValue::Alias(a) = val else { continue }; - let Some(t) = vars.get(a).filter(|t| gone.contains(&t.id)) else { continue }; + let Some(t) = vars.get_by_id(a).filter(|t| gone.contains(&t.id)) else { continue }; let modes = std::collections::BTreeMap::from([(t.collection.clone(), m.clone())]); if let Some(r) = vars.resolve(&t.id, &modes) { detached.push((v.id.clone(), m.clone(), r)); @@ -333,11 +333,11 @@ fn reset_override(s: &mut Session, p: &Value) -> Result { let v = req_string(cmd, p, "variable")?; let doc = s.doc_mut()?; let var = doc.variables.get(&v).cloned().ok_or_else(|| bad(cmd, "no such variable"))?; - let parent = var.extends.as_deref().and_then(|x| doc.variables.get(x)).cloned(); + let parent = var.extends.as_deref().and_then(|x| doc.variables.get_by_id(x)).cloned(); let parent = parent.ok_or_else(|| bad(cmd, "only variables an extended collection inherits have overrides"))?; let modes: Vec = match string(p, "mode") { Some(m) => { - let coll = doc.variables.collection(&var.collection).ok_or_else(|| bad(cmd, "variable has no collection"))?; + let coll = doc.variables.collection_by_id(&var.collection).ok_or_else(|| bad(cmd, "variable has no collection"))?; vec![coll.find_mode(&m).ok_or_else(|| bad(cmd, "no such mode"))?.id.clone()] } None => var.overrides.iter().cloned().collect(), @@ -433,6 +433,12 @@ pub fn sync_extensions(doc: &mut Document) { /// lead back to `var`. Returns the target's id. pub fn alias_target(vars: &Variables, var: &Variable, target: &str) -> std::result::Result { let t = vars.get(target).ok_or_else(|| format!("no variable `{target}` to alias"))?; + alias_target_by_id(vars, var, &t.id) +} + +/// Check an alias whose target is already a stored or remapped id, without matching names. +pub(crate) fn alias_target_by_id(vars: &Variables, var: &Variable, target: &str) -> std::result::Result { + let t = vars.get_by_id(target).ok_or_else(|| format!("no variable `{target}` to alias"))?; if t.kind != var.kind { return Err(format!("can't alias a {} variable to a {} one", var.kind.id(), t.kind.id())); } @@ -445,9 +451,9 @@ pub fn alias_target(vars: &Variables, var: &Variable, target: &str) -> std::resu if !seen.insert(cur.clone()) || seen.len() > 10_000 { continue; } - for v in vars.get(&cur).map(|x| x.values.values()).into_iter().flatten() { + for v in vars.get_by_id(&cur).map(|x| x.values.values()).into_iter().flatten() { if let VariableValue::Alias(a) = v - && let Some(next) = vars.get(a) + && let Some(next) = vars.get_by_id(a) { stack.push(next.id.clone()); } diff --git a/crates/engine/src/cmd/variables_io.rs b/crates/engine/src/cmd/variables_io.rs index 0648376b..3bbadcc7 100644 --- a/crates/engine/src/cmd/variables_io.rs +++ b/crates/engine/src/cmd/variables_io.rs @@ -95,7 +95,7 @@ fn css(doc: &Document, colls: &[&Collection], tailwind: bool) -> String { Some(VariableValue::Number(x)) => format!("{}px", trim_num(*x)), Some(VariableValue::String(t)) => format!("\"{}\"", css_safe(t)), Some(VariableValue::Boolean(b)) => b.to_string(), - Some(VariableValue::Alias(a)) => match vars.get(a) { + Some(VariableValue::Alias(a)) => match vars.get_by_id(a) { Some(t) => format!("var({})", name(t)), None => continue, }, @@ -330,7 +330,7 @@ fn import(s: &mut Session, p: &Value) -> Result { VariableValue::Alias(a) => { let Some(var) = doc.variables.variables.iter().find(|x| x.id == *local) else { continue }; let target = ids.get(a).ok_or_else(|| "its alias target wasn't imported".to_string()); - match target.and_then(|t| super::variables::alias_target(&doc.variables, var, t)) { + match target.and_then(|t| super::variables::alias_target_by_id(&doc.variables, var, t)) { Ok(t) => VariableValue::Alias(t), Err(e) => { warnings.push(format!("`{}`: {e}", v.name)); diff --git a/crates/engine/src/dev/devinspect.rs b/crates/engine/src/dev/devinspect.rs index dbf3ac91..e646e53f 100644 --- a/crates/engine/src/dev/devinspect.rs +++ b/crates/engine/src/dev/devinspect.rs @@ -372,7 +372,7 @@ pub fn bound_variable<'d>(doc: &'d Document, n: &Node, field: &str) -> Option<&' _ => &[], }; let found = if forms.is_empty() { n.bound.get(field) } else { forms.iter().find_map(|k| n.bound.get(*k)) }; - found.and_then(|v| doc.variables.get(v)) + found.and_then(|v| doc.variables.get_by_id(v)) } /// The variable bound to `field` on `n`, by name. diff --git a/crates/engine/src/devinspect_tests.rs b/crates/engine/src/devinspect_tests.rs index 5ee590cc..44a762d2 100644 --- a/crates/engine/src/devinspect_tests.rs +++ b/crates/engine/src/devinspect_tests.rs @@ -58,6 +58,25 @@ fn properties_group_layout_auto_layout_paints_and_tokens() { assert!(s.execute("inspect.properties", &json!({"id": 999_999})).is_err()); } +#[test] +fn stored_variable_ids_keep_their_dev_inspect_token() { + let mut s = Session::new(); + s.execute("variable.createCollection", &json!({"name": "Theme"})).unwrap(); + let shadow = s.execute("variable.create", &json!({"collection": "Theme", "name": "shadow", "type": "color", "value": "#FF0000"})).unwrap(); + let surface = s.execute("variable.create", &json!({"collection": "Theme", "name": "surface", "type": "color", "value": "#0D99FF"})).unwrap(); + let r = id(s.execute("node.create", &json!({"type": "rectangle", "props": {"fill": {"$var": surface["id"]}}})).unwrap()); + s.execute("variable.update", &json!({"variable": "surface", "codeSyntax": {"WEB": "--real-surface"}})).unwrap(); + s.execute("variable.update", &json!({"variable": shadow["id"], "name": surface["id"], "codeSyntax": {"WEB": "--shadow"}})).unwrap(); + + let code = s.execute("inspect.code", &json!({"id": r, "language": "css"})).unwrap(); + assert!(code["code"].as_str().unwrap().contains("var(--real-surface")); + assert!(!code["code"].as_str().unwrap().contains("--shadow")); + let out = s.execute("inspect.properties", &json!({"id": r})).unwrap(); + let fills = out["sections"].as_array().unwrap().iter().find(|s| s["title"] == "Fills").unwrap(); + assert_eq!(fills["rows"][0]["color"], "#0D99FF"); + assert_eq!(fills["rows"][0]["token"], "surface"); +} + #[test] fn typography_section_for_text() { let mut s = Session::new(); diff --git a/crates/engine/src/fig_export_assets_tests.rs b/crates/engine/src/fig_export_assets_tests.rs index 9e8567b8..f5b9f775 100644 --- a/crates/engine/src/fig_export_assets_tests.rs +++ b/crates/engine/src/fig_export_assets_tests.rs @@ -206,3 +206,26 @@ fn nested_instances_of_variants_link_to_their_mains_layers() { let _ = std::fs::remove_file(&path); assert_eq!(outline(s.doc().unwrap()), before); } + +#[test] +fn stored_variable_ids_keep_exported_binding_identity() { + let mut s = Session::new(); + run(&mut s, "variable.createCollection", json!({"name": "Theme"})); + let shadow = run(&mut s, "variable.create", json!({"collection": "Theme", "name": "shadow", "type": "color", "value": "#FF0000"})); + let surface = run(&mut s, "variable.create", json!({"collection": "Theme", "name": "surface", "type": "color", "value": "#0D99FF"})); + let r = id(&run(&mut s, "node.create", json!({"type": "rectangle", "props": {"name": "Bound", "fill": {"$var": surface["id"]}}}))); + run(&mut s, "select.set", json!({"ids": [r]})); + run(&mut s, "style.create", json!({"type": "paint", "name": "Surface"})); + run(&mut s, "node.set", json!({"id": r, "props": {"name": "Styled"}})); + run(&mut s, "node.create", json!({"type": "rectangle", "props": {"name": "Bound", "fill": {"$var": surface["id"]}}})); + run(&mut s, "variable.update", json!({"variable": shadow["id"], "name": surface["id"]})); + let (bytes, _) = figcraft_format::fig::export(s.doc().unwrap(), None).unwrap(); + let (back, _) = figcraft_format::fig::import(&bytes, "identity").unwrap(); + let bound = back.nodes().find(|n| n.name == "Bound").unwrap().bound.values().next().unwrap(); + assert_eq!(back.variables.get_by_id(bound).unwrap().name, "surface"); + let style = back.styles.iter().find(|s| s.name == "Surface").unwrap(); + assert!(!style.bound.is_empty()); + for bound in style.bound.values() { + assert_eq!(back.variables.get_by_id(bound).unwrap().name, "surface"); + } +} diff --git a/crates/engine/src/library_variables_tests.rs b/crates/engine/src/library_variables_tests.rs index a811806e..e2d8535e 100644 --- a/crates/engine/src/library_variables_tests.rs +++ b/crates/engine/src/library_variables_tests.rs @@ -428,3 +428,36 @@ fn a_library_published_before_modes_were_overrides_keeps_the_modes_set_inside_ne healthy(&s); let _ = std::fs::remove_dir_all(&d); } + +#[test] +fn stored_variable_ids_keep_library_alias_dependencies() { + let mut s = Session::new(); + for name in ["Shadow", "Base", "Theme"] { + ex(&mut s, "variable.createCollection", json!({"name": name})); + } + let shadow = ex(&mut s, "variable.create", json!({"collection": "Shadow", "name": "shadow", "type": "color", "value": "#FF0000"})); + let surface = ex(&mut s, "variable.create", json!({"collection": "Base", "name": "surface", "type": "color", "value": "#0D99FF"})); + let alias = ex(&mut s, "variable.create", json!({"collection": "Theme", "name": "alias", "type": "color", "value": {"alias": surface["id"]}})); + let f = idv(&ex(&mut s, "frame.create", json!({"name": "Chip", "x": 0, "y": 0, "width": 100, "height": 40}))); + ex(&mut s, "variable.bind", json!({"ids": [f], "field": "fill", "variable": alias["id"]})); + let chip = idv(&ex(&mut s, "component.create", json!({"ids": [f]}))); + ex(&mut s, "variable.update", json!({"variable": shadow["id"], "name": surface["id"]})); + let doc = s.doc().unwrap(); + let selection = Selection { components: vec![chip], ..Default::default() }; + let lib = publish(doc, None, &selection, None, 0).unwrap(); + let target = surface["id"].as_str().unwrap(); + assert!(lib.doc.variables.get_by_id(target).is_some()); + assert!(lib.doc.variables.get_by_id(shadow["id"].as_str().unwrap()).is_none()); + assert!(lib.doc.variables.collections.iter().any(|c| c.name == "Base")); + + // Import from a snapshot that retains both the dependency and its namesake. + let lib = publish(doc, None, &publishable(doc), None, 1).unwrap(); + let mut dest = Document::new("consumer"); + let key = alias["id"].as_str().unwrap().to_string(); + let map = import_variables(&mut dest, &lib, &std::collections::BTreeSet::from([key.clone()]), &Default::default(), false); + assert!(map.variables.contains_key(target)); + let copy = dest.variables.get_by_id(&map.variables[&key]).unwrap(); + assert!(!copy.values.is_empty()); + assert!(copy.values.values().all(|v| *v == VariableValue::Alias(map.variables[target].clone()))); + assert!(dest.variables.collections.iter().any(|c| c.name == "Base")); +} diff --git a/crates/engine/src/proto_run.rs b/crates/engine/src/proto_run.rs index 3fa374e0..7a25fcbd 100644 --- a/crates/engine/src/proto_run.rs +++ b/crates/engine/src/proto_run.rs @@ -42,10 +42,11 @@ fn fit(kind: VariableType, v: VariableValue) -> Option { } } -/// Set `variable` to `value`, or to `expr` evaluated in the modes of `node` (the layer whose -/// interaction runs), in every mode, and re-apply bindings. False when nothing could be set. +/// Set stored variable id `variable` to `value`, or to `expr` evaluated in the modes of `node` +/// (the layer whose interaction runs), in every mode, and re-apply bindings. False when nothing +/// could be set. pub fn set_variable(doc: &mut Document, node: NodeId, variable: &str, value: &Value, expr: Option<&Expr>) -> bool { - let Some(var) = doc.variables.get(variable) else { return false }; + let Some(var) = doc.variables.get_by_id(variable) else { return false }; let (id, kind) = (var.id.clone(), var.kind); let v = match expr { Some(e) => e.eval(&doc.variables, &doc.modes_for(node)).and_then(|v| fit(kind, v)), @@ -65,7 +66,14 @@ pub fn set_variable(doc: &mut Document, node: NodeId, variable: &str, value: &Va /// on the page's top-level frames that chose another mode of it (modes chosen deeper inside still /// win), then re-apply bindings. False for an unknown collection or mode. pub fn set_mode(doc: &mut Document, frame: NodeId, collection: &str, mode: &str) -> bool { - let Some(c) = doc.variables.collection(collection) else { return false }; + let Some(id) = doc.variables.collection(collection).map(|c| c.id.clone()) else { return false }; + set_mode_by_id(doc, frame, &id, mode) +} + +/// Switch a stored collection id to `mode`, without matching another collection's name. +/// Otherwise behaves like [`set_mode`]. +pub fn set_mode_by_id(doc: &mut Document, frame: NodeId, collection: &str, mode: &str) -> bool { + let Some(c) = doc.variables.collection_by_id(collection) else { return false }; let Some(mode) = c.find_mode(mode).map(|m| m.id.clone()) else { return false; }; diff --git a/crates/engine/src/proto_run_tests.rs b/crates/engine/src/proto_run_tests.rs index c59afe91..cd2d3b67 100644 --- a/crates/engine/src/proto_run_tests.rs +++ b/crates/engine/src/proto_run_tests.rs @@ -207,3 +207,140 @@ fn junk_conditional_actions_never_panic() { } } } + +#[test] +fn stored_variable_ids_set_only_the_prototype_action_target() { + let (mut s, frame, _, _, _) = setup(); + let shadow = var_id(s.doc().unwrap(), "count"); + let target = s.execute("variable.create", &json!({"collection": "Theme", "name": "target", "type": "number", "value": 5})).unwrap(); + let target = target["id"].as_str().unwrap().to_string(); + s.execute("variable.update", &json!({"variable": shadow, "name": target})).unwrap(); + let mut doc = s.doc().unwrap().clone(); + assert!(set_variable(&mut doc, frame, &target, &json!(9), None)); + assert_eq!(doc.variables.resolve(&target, &Default::default()), Some(VariableValue::Number(9.0))); + assert_eq!(doc.variables.resolve(&shadow, &Default::default()), Some(VariableValue::Number(0.0))); + doc.variables.variables.retain(|v| v.id != target); + let before = doc.clone(); + assert!(!set_variable(&mut doc, frame, &target, &json!(11), None)); + assert_eq!(doc, before); +} + +#[test] +fn stored_variable_ids_survive_unrelated_interaction_edits() { + let (mut s, frame, _, _, _) = setup(); + let target = s.execute("variable.create", &json!({"collection": "Theme", "name": "target", "type": "number", "value": 5})).unwrap()["id"] + .as_str() + .unwrap() + .to_string(); + s.execute( + "proto.interaction.add", + &json!({"id": frame, "actions": [ + {"type": "back"}, + {"type": "setVariable", "variable": "target", "expression": "target + 1"}, + {"type": "setVariableMode", "collection": "Theme", "mode": "Dark"} + ]}), + ) + .unwrap(); + let theme = s.doc().unwrap().variables.collections[0].id.clone(); + s.execute("variable.createCollection", &json!({"name": "shadow", "modes": ["Light", "Dark"]})).unwrap(); + s.execute("variable.renameCollection", &json!({"collection": "shadow", "name": theme})).unwrap(); + // Put the namesake before the referenced collection, as variable lookup already does. + s.doc_mut().unwrap().variables.collections.swap(0, 1); + s.execute("variable.update", &json!({"variable": "count", "name": target})).unwrap(); + let actions = s.doc().unwrap().node(frame).unwrap().interactions[0].actions.clone(); + for patch in [json!({"durationMs": 450}), json!({"action": {"type": "closeOverlay"}})] { + let mut p = json!({"id": frame, "index": 0}); + p.as_object_mut().unwrap().extend(patch.as_object().unwrap().clone()); + s.execute("proto.interaction.set", &p).unwrap(); + let it = &s.doc().unwrap().node(frame).unwrap().interactions[0]; + assert_eq!(it.duration_ms, 450); + assert_eq!(it.actions[1..], actions[1..], "only the supplied fields change"); + } +} + +#[test] +fn stored_variable_ids_from_text_are_not_resolved_twice() { + let (mut s, frame, _, _, _) = setup(); + let target = s.execute("variable.create", &json!({"collection": "Theme", "name": "target", "type": "number", "value": 5})).unwrap()["id"] + .as_str() + .unwrap() + .to_string(); + s.execute("variable.set", &json!({"variable": "count", "value": 100})).unwrap(); + s.execute("variable.update", &json!({"variable": "count", "name": target})).unwrap(); + let action = json!({"type": "setVariable", "variable": "target", "expression": "target + 1"}); + s.execute("proto.interaction.add", &json!({"id": frame, "action": action})).unwrap(); + for (cmd, patch) in [ + ("proto.interaction.add", json!({"action": action})), + ("proto.interaction.set", json!({"action": action})), + ("proto.interaction.set", json!({"interaction": {"actions": [action]}})), + ("proto.action.add", json!({"path": [0], "action": action})), + ("proto.action.set", json!({"path": [0], "action": action})), + ] { + let mut p = json!({"id": frame}); + if cmd != "proto.interaction.add" { + p["index"] = json!(0); + } + p.as_object_mut().unwrap().extend(patch.as_object().unwrap().clone()); + let r = s.execute(cmd, &p).unwrap(); + let index = if cmd == "proto.interaction.add" { r["index"].as_u64().unwrap() as usize } else { 0 }; + let Action::SetVariable { variable, expr: Some(expr), .. } = &s.doc().unwrap().node(frame).unwrap().interactions[index].actions[0] else { + panic!("{cmd}: no set variable expression") + }; + assert_eq!(variable, &target, "{cmd}"); + assert_eq!(expr.eval(&s.doc().unwrap().variables, &Default::default()), Some(VariableValue::Number(6.0)), "{cmd}"); + } +} + +#[test] +fn stored_variable_ids_resolve_only_incoming_action_and_expression_names() { + let (mut s, frame, _, _, _) = setup(); + let target = s.execute("variable.create", &json!({"collection": "Theme", "name": "target", "type": "number", "value": 5})).unwrap()["id"] + .as_str() + .unwrap() + .to_string(); + s.execute("variable.set", &json!({"variable": "count", "value": 100})).unwrap(); + s.execute("variable.update", &json!({"variable": "count", "name": target})).unwrap(); + let value = json!({"type": "setVariable", "variable": "target"}); + s.execute( + "proto.interaction.add", + &json!({"id": frame, "actions": [ + {"type": "setVariable", "variable": "target", "value": null, "expr": Expr::var(&target)}, + {"type": "conditional", "blocks": [{"expression": "target == 5", "actions": [ + {"type": "setVariable", "variable": "label", "value": value} + ]}]} + ]}), + ) + .unwrap(); + let doc = s.doc().unwrap(); + let actions = &doc.node(frame).unwrap().interactions[0].actions; + let Action::SetVariable { variable, expr: Some(expr), .. } = &actions[0] else { panic!("no set variable") }; + assert_eq!(variable, &target); + // A JSON expression is command input: it still accepts names, even one that resembles an id. + assert_eq!(expr.eval(&doc.variables, &Default::default()), Some(VariableValue::Number(100.0))); + let Action::Conditional { blocks } = &actions[1] else { panic!("no conditional") }; + assert_eq!(blocks[0].condition.as_ref().unwrap().eval(&doc.variables, &Default::default()), Some(VariableValue::Boolean(true))); + let Action::SetVariable { value: stored, .. } = &blocks[0].actions[0] else { panic!("no nested set variable") }; + assert_eq!(stored, &value, "a literal value is not an action to canonicalize"); +} + +#[test] +fn stored_variable_ids_switch_only_the_prototype_collection() { + let (s, frame, _, _, r) = setup(); + let mut doc = s.doc().unwrap().clone(); + let theme = doc.variables.collections[0].clone(); + let mut shadow = theme.clone(); + shadow.id = "shadow".into(); + shadow.name = theme.id.clone(); + doc.variables.collections.insert(0, shadow); + assert!(crate::proto_run::set_mode_by_id(&mut doc, frame, &theme.id, "Dark")); + assert_eq!(fill_hex(&doc, r).as_deref(), Some("000000")); + let page = doc.page_of(frame).unwrap(); + assert!(!doc.node(page).unwrap().var_modes.contains_key("shadow")); + // The public name-accepting wrapper keeps its existing lookup behavior. + assert!(set_mode(&mut doc, frame, &theme.id, "Light")); + assert!(doc.node(page).unwrap().var_modes.contains_key("shadow")); + doc.variables.collections.retain(|c| c.id != theme.id); + let before = doc.clone(); + assert!(!crate::proto_run::set_mode_by_id(&mut doc, frame, &theme.id, "Dark")); + assert_eq!(doc, before); +} diff --git a/crates/engine/src/variables_behaviour_tests.rs b/crates/engine/src/variables_behaviour_tests.rs index 7f80a044..9c485362 100644 --- a/crates/engine/src/variables_behaviour_tests.rs +++ b/crates/engine/src/variables_behaviour_tests.rs @@ -498,3 +498,24 @@ fn a_translucent_variable_bound_to_an_empty_fill_applies_its_alpha_once() { assert_eq!(node(&s, r).fills[0].solid_color().unwrap().to_css_hex(), "#00FF0040"); assert_eq!(node(&s, r).fills[0].opacity, 1.0); } + +#[test] +fn stored_variable_ids_cannot_hide_an_alias_cycle() { + for link in ["a", "b"] { + let mut s = Session::new(); + run(&mut s, "variable.createCollection", json!({"name": "Theme"})); + let mut ids = std::collections::BTreeMap::new(); + for name in ["shadow", "a", "b"] { + let v = run(&mut s, "variable.create", json!({"collection": "Theme", "name": name, "type": "number", "value": 1})); + ids.insert(name, v["id"].clone()); + } + run(&mut s, "variable.set", json!({"variable": "b", "value": {"alias": "a"}})); + run(&mut s, "variable.update", json!({"variable": ids["shadow"], "name": ids[link]})); + let before = s.doc().unwrap().clone(); + let undo = s.ds().unwrap().history.undo.len(); + let error = s.execute("variable.set", &json!({"variable": "a", "value": {"alias": "b"}})).unwrap_err(); + assert!(error.to_string().contains("refer back to itself"), "{link}: {error}"); + assert_eq!(s.doc().unwrap(), &before); + assert_eq!(s.ds().unwrap().history.undo.len(), undo); + } +} diff --git a/crates/engine/src/variables_editor_tests.rs b/crates/engine/src/variables_editor_tests.rs index 9518911e..806300c7 100644 --- a/crates/engine/src/variables_editor_tests.rs +++ b/crates/engine/src/variables_editor_tests.rs @@ -125,3 +125,66 @@ fn create_collection_refuses_a_taken_name() { ex(&mut s, "variable.createCollection", json!({"name": "Theme 2"})); assert_eq!(s.doc().unwrap().variables.collections.len(), before + 1); } + +#[test] +fn stored_variable_ids_detach_deleted_aliases_per_mode() { + for collection in [false, true] { + let mut s = Session::new(); + for name in ["Base", "Theme"] { + ex(&mut s, "variable.createCollection", json!({"name": name, "modes": ["Light", "Dark"]})); + } + let shadow = ex(&mut s, "variable.create", json!({"collection": "Theme", "name": "shadow", "type": "color", "value": "#FF0000"})); + let surface = ex( + &mut s, + "variable.create", + json!({"collection": "Base", "name": "surface", "type": "color", "values": {"Light": "#0D99FF", "Dark": "#000000"}}), + ); + // Use a different collection only when deleting the whole target collection. + let home = if collection { "Theme" } else { "Base" }; + let alias = ex(&mut s, "variable.create", json!({"collection": home, "name": "alias", "type": "color", "value": {"alias": surface["id"]}})); + ex(&mut s, "variable.update", json!({"variable": shadow["id"], "name": surface["id"]})); + let aid = alias["id"].as_str().unwrap(); + let before = s.doc().unwrap().variables.get_by_id(aid).unwrap().clone(); + let expected: std::collections::BTreeMap<_, _> = before + .values + .keys() + .map(|m| { + let modes = std::collections::BTreeMap::from([(before.collection.clone(), m.clone())]); + (m.clone(), s.doc().unwrap().variables.resolve(aid, &modes).unwrap()) + }) + .collect(); + if collection { + ex(&mut s, "variable.deleteCollection", json!({"collection": "Base"})); + } else { + ex(&mut s, "variable.delete", json!({"variable": "surface"})); + } + assert_eq!(s.doc().unwrap().variables.get_by_id(aid).unwrap().values, expected); + } +} + +#[test] +fn stored_variable_ids_keep_collection_edits_on_the_resolved_collection() { + let mut s = Session::new(); + let shadow = ex(&mut s, "variable.createCollection", json!({"name": "Shadow", "modes": ["Only"]})); + let theme = ex(&mut s, "variable.createCollection", json!({"name": "Theme", "modes": ["Light", "Dark"]})); + let surface = ex(&mut s, "variable.create", json!({"collection": "Theme", "name": "surface", "type": "number", "value": 1})); + ex(&mut s, "variable.renameCollection", json!({"collection": shadow["id"], "name": theme["id"]})); + ex(&mut s, "variable.set", json!({"variable": "surface", "mode": "Dark", "value": 7})); + let doc = s.doc().unwrap(); + let coll = doc.variables.collection_by_id(theme["id"].as_str().unwrap()).unwrap(); + let mode = coll.find_mode("Dark").unwrap().id.clone(); + let modes = std::collections::BTreeMap::from([(coll.id.clone(), mode)]); + assert_eq!(doc.variables.resolve(surface["id"].as_str().unwrap(), &modes), Some(figcraft_doc::VariableValue::Number(7.0))); + + // Mode commands resolve the user's collection argument before checking its stored id. + let doc = s.doc_mut().unwrap(); + let mut extended = doc.variables.collection_by_id(theme["id"].as_str().unwrap()).unwrap().clone(); + extended.id = "extended".into(); + extended.name = "Extended".into(); + extended.extends = Some(theme["id"].as_str().unwrap().into()); + doc.variables.collections.push(extended); + doc.variables.collections[0].name = "extended".into(); + let before = s.doc().unwrap().clone(); + assert!(s.execute("variable.removeMode", &json!({"collection": "Extended", "mode": "Dark"})).is_err()); + assert_eq!(s.doc().unwrap(), &before); +} diff --git a/crates/engine/src/variables_io_tests.rs b/crates/engine/src/variables_io_tests.rs index b34294a8..9f1300af 100644 --- a/crates/engine/src/variables_io_tests.rs +++ b/crates/engine/src/variables_io_tests.rs @@ -231,6 +231,39 @@ fn variables_export_and_import_round_trip() { let _ = std::fs::remove_file(&path); } +#[test] +fn stored_variable_ids_keep_remapped_import_aliases() { + let mut s = Session::new(); + ex(&mut s, "variable.createCollection", json!({"name": "Theme"})); + let shadow = ex(&mut s, "variable.create", json!({"collection": "Theme", "name": "shadow", "type": "number", "value": 100}))["id"] + .as_str() + .unwrap() + .to_string(); + let target = ex(&mut s, "variable.create", json!({"collection": "Theme", "name": "target", "type": "number", "value": 5}))["id"] + .as_str() + .unwrap() + .to_string(); + ex(&mut s, "variable.update", json!({"variable": shadow, "name": target})); + let imported = json!({"meta": { + "variableCollections": {"c": {"id": "c", "name": "Theme", "modes": [{"modeId": "m", "name": "Mode 1"}]}}, + "variables": { + "t": {"id": "t", "name": "target", "variableCollectionId": "c", "resolvedType": "FLOAT", "valuesByMode": {"m": 5}}, + "a": {"id": "a", "name": "alias", "variableCollectionId": "c", "resolvedType": "FLOAT", + "valuesByMode": {"m": {"type": "VARIABLE_ALIAS", "id": "t"}}} + } + }}); + let result = ex(&mut s, "variable.import", json!({"variables": imported})); + assert_eq!(result["warnings"], json!([])); + let vars = &s.doc().unwrap().variables; + let alias = vars.get("alias").unwrap(); + assert_eq!(alias.values.values().next(), Some(&VariableValue::Alias(target.clone()))); + assert_eq!(vars.resolve(&alias.id, &Default::default()), Some(VariableValue::Number(5.0))); + // The command's alias argument remains name-accepting, unlike the import's remapped id. + ex(&mut s, "variable.set", json!({"variable": "alias", "value": {"alias": target}})); + let vars = &s.doc().unwrap().variables; + assert_eq!(vars.resolve(&vars.get("alias").unwrap().id, &Default::default()), Some(VariableValue::Number(100.0))); +} + #[test] fn variables_import_checks_what_it_reads() { let mut s = Session::new(); @@ -555,3 +588,53 @@ fn css_export_neutralises_every_css_newline_in_strings() { assert!(css.contains("\"a b c\""), "{css:?}"); } } + +#[test] +fn stored_variable_ids_keep_exported_css_alias_names() { + let mut s = Session::new(); + ex(&mut s, "variable.createCollection", json!({"name": "Theme"})); + let shadow = ex(&mut s, "variable.create", json!({"collection": "Theme", "name": "shadow", "type": "color", "value": "#FF0000"})); + let surface = ex(&mut s, "variable.create", json!({"collection": "Theme", "name": "surface", "type": "color", "value": "#0D99FF"})); + ex(&mut s, "variable.create", json!({"collection": "Theme", "name": "alias", "type": "color", "value": {"alias": surface["id"]}})); + ex(&mut s, "variable.update", json!({"variable": shadow["id"], "name": surface["id"], "codeSyntax": {"WEB": "--shadow"}})); + let css = ex(&mut s, "variable.export", json!({"format": "css"})); + assert!(css["text"].as_str().unwrap().contains("--alias: var(--surface);")); +} + +#[test] +fn stored_variable_ids_reset_only_the_inherited_parent_and_collection() { + for collection_shadow in [false, true] { + let mut s = Session::new(); + let shadow_coll = ex(&mut s, "variable.createCollection", json!({"name": "Shadow"})); + ex(&mut s, "variable.createCollection", json!({"name": "Base", "modes": ["Light", "Dark"]})); + let shadow = ex(&mut s, "variable.create", json!({"collection": "Shadow", "name": "shadow", "type": "number", "value": 0})); + let surface = + ex(&mut s, "variable.create", json!({"collection": "Base", "name": "surface", "type": "number", "values": {"Light": 4, "Dark": 8}})); + if collection_shadow { + ex(&mut s, "variable.renameCollection", json!({"collection": shadow_coll["id"], "name": "extended"})); + } else { + ex(&mut s, "variable.update", json!({"variable": shadow["id"], "name": surface["id"]})); + } + let doc = s.doc_mut().unwrap(); + let parent = doc.variables.get_by_id(surface["id"].as_str().unwrap()).unwrap().clone(); + let mut coll = doc.variables.collection_by_id(&parent.collection).unwrap().clone(); + let (light, dark) = (coll.find_mode("Light").unwrap().id.clone(), coll.find_mode("Dark").unwrap().id.clone()); + coll.extends = Some(coll.id.clone()); + coll.id = "extended".into(); + coll.name = "Extended".into(); + let mut inherited = parent.clone(); + inherited.id = "inherited".into(); + inherited.name = "inherited".into(); + inherited.collection = coll.id.clone(); + inherited.extends = Some(parent.id); + inherited.overrides = inherited.values.keys().cloned().collect(); + inherited.values.values_mut().for_each(|v| *v = VariableValue::Number(2.0)); + doc.variables.collections.push(coll); + doc.variables.variables.push(inherited); + ex(&mut s, "variable.resetOverride", json!({"variable": "inherited", "mode": "Dark"})); + let result = s.doc().unwrap().variables.get_by_id("inherited").unwrap(); + assert_eq!(result.values[&light], VariableValue::Number(2.0)); + assert_eq!(result.values[&dark], VariableValue::Number(8.0)); + assert_eq!(result.overrides, std::collections::BTreeSet::from([light])); + } +} diff --git a/crates/format/src/fig/export/assets.rs b/crates/format/src/fig/export/assets.rs index bd269556..4bf790fd 100644 --- a/crates/format/src/fig/export/assets.rs +++ b/crates/format/src/fig/export/assets.rs @@ -251,9 +251,9 @@ impl<'d> Exporter<'d> { } } - /// A reference to variable `var` (its id or name) as variable data. + /// A reference to variable `var` (its stored id) as variable data. fn alias(&mut self, var: &str) -> Option> { - let found = self.doc.variables.get(var).and_then(|v| Some((v.kind, *self.var_ids.get(v.id.as_str())?))); + let found = self.doc.variables.get_by_id(var).and_then(|v| Some((v.kind, *self.var_ids.get(v.id.as_str())?))); let Some((kind, local)) = found else { self.warn("bindings to variables that aren't in the document were written as their values"); return None; diff --git a/crates/format/src/penpot/export.rs b/crates/format/src/penpot/export.rs index 1de09e89..1cec7eaf 100644 --- a/crates/format/src/penpot/export.rs +++ b/crates/format/src/penpot/export.rs @@ -1657,7 +1657,7 @@ impl<'a> Exporter<'a> { if let Some(name) = self.token_names.get(variable) { return Some(name.clone()); } - let var = self.doc.variables.get(variable)?; + let var = self.doc.variables.get_by_id(variable)?; let name = if variable.starts_with("penpot:tokens:") { var.name.split_once('/').map_or(var.name.clone(), |(_, n)| n.to_owned()) } else { diff --git a/crates/format/src/penpot/export_tests.rs b/crates/format/src/penpot/export_tests.rs index 233cfc85..1432a8c5 100644 --- a/crates/format/src/penpot/export_tests.rs +++ b/crates/format/src/penpot/export_tests.rs @@ -909,3 +909,27 @@ fn page_guides_reloaded_from_a_figcraft_file_are_exported() { let page_json = e.iter().find(|(k, _)| k.matches('/').count() == 3 && k.contains("/pages/")).unwrap().1; assert_eq!(page_json["guides"].as_object().unwrap().values().next().unwrap()["position"], 410); } + +#[test] +fn stored_variable_ids_keep_penpot_token_identity() { + for missing in [false, true] { + let mut doc = Document::new("Token identity"); + doc.variables.collections.push(Collection { + id: "c".into(), + name: "Theme".into(), + modes: vec![Mode { id: "m".into(), name: "Default".into() }], + extends: None, + }); + doc.variables.variables.push(variable("shadow", "target", "c", VariableType::Number, &[("m", VariableValue::Number(20.0))])); + if !missing { + doc.variables.variables.push(variable("target", "surface", "c", VariableType::Number, &[("m", VariableValue::Number(40.0))])); + } + let p = page(&doc); + let r = add(&mut doc, p, NodeKind::Rectangle, "Bound", 0.0, 0.0, 40.0, 10.0); + doc.node_mut(r).unwrap().bound.insert("width".into(), "target".into()); + let exported = entries(&export(&doc).unwrap().0); + let width = &shape(&exported, "Bound")["appliedTokens"]["width"]; + let expected = if missing { Value::Null } else { json!("surface") }; + assert_eq!(width, &expected); + } +} diff --git a/crates/format/src/rest/conditional_tests.rs b/crates/format/src/rest/conditional_tests.rs index 99e08cef..7f1d316c 100644 --- a/crates/format/src/rest/conditional_tests.rs +++ b/crates/format/src/rest/conditional_tests.rs @@ -248,3 +248,34 @@ fn junk_in_conditional_actions_never_panics() { } } } + +#[test] +fn stored_variable_ids_keep_imported_mode_lookup_identity() { + for collection in [false, true] { + let mut vars = variables_json(); + if collection { + let mut shadow = vars["meta"]["variableCollections"][THEME].clone(); + shadow["id"] = json!("VariableCollectionId:0:0"); + shadow["name"] = json!(THEME); + shadow["modes"] = json!([{"modeId": "other", "name": "Other"}]); + vars["meta"]["variableCollections"]["VariableCollectionId:0:0"] = shadow; + } else { + let mut shadow = vars["meta"]["variables"][SPACE].clone(); + shadow["id"] = json!("VariableID:0:0"); + shadow["name"] = json!(LABEL); + vars["meta"]["variables"]["VariableID:0:0"] = shadow; + } + let lookup = expr("VAR_MODE_LOOKUP", vec![var(LABEL), data("STRING", json!("1:0"))]); + let (doc, warnings) = import_with(&file(vec![on("ON_CLICK", vec![set(LABEL, lookup)])]), Some(&vars), &no_images).unwrap(); + let actions = &button(&doc).interactions[0].actions; + assert_eq!( + actions[0], + Action::SetVariable { + variable: LABEL.into(), + value: Value::Null, + expr: Some(Expr::InMode { variable: LABEL.into(), mode: "1:0".into() }), + }, + "{warnings:?}" + ); + } +} diff --git a/crates/format/src/rest/proto.rs b/crates/format/src/rest/proto.rs index 61869916..5ba0c5ad 100644 --- a/crates/format/src/rest/proto.rs +++ b/crates/format/src/rest/proto.rs @@ -442,8 +442,8 @@ impl Ctx<'_> { }; let vars = &self.doc.variables; let found = parts.filter(|(id, _)| self.var_kinds.contains_key(*id)).and_then(|(id, mode)| { - let v = vars.get(id)?; - let m = vars.collection(&v.collection)?.modes.iter().find(|m| m.id == mode)?; + let v = vars.get_by_id(id)?; + let m = vars.collection_by_id(&v.collection)?.modes.iter().find(|m| m.id == mode)?; Some(Expr::InMode { variable: v.id.clone(), mode: m.id.clone() }) }); if found.is_none() { diff --git a/crates/ui-egui/src/panels/variable_ui.rs b/crates/ui-egui/src/panels/variable_ui.rs index fe822a1f..80e544f9 100644 --- a/crates/ui-egui/src/panels/variable_ui.rs +++ b/crates/ui-egui/src/panels/variable_ui.rs @@ -131,7 +131,7 @@ pub fn mode_rows<'a>(doc: &'a Document, libs: &'a [Arc]) -> Ve let (id, name) = (lib.manifest.id.as_str(), library::library_name(doc, &lib.manifest.id)); for c in library::published_collections(lib) { let local = copy(id, &c.id).map(|r| r.local.as_str()); - let coll = local.and_then(|l| doc.variables.collection(l)).unwrap_or(c); + let coll = local.and_then(|l| doc.variables.collection_by_id(l)).unwrap_or(c); if several(coll) { out.push(ModeRow { coll, library: Some((id, name)), key: Some(&c.id), local }); } @@ -141,7 +141,7 @@ pub fn mode_rows<'a>(doc: &'a Document, libs: &'a [Arc]) -> Ve if out.iter().any(|x| x.local == Some(r.local.as_str())) { continue; } - if let Some(c) = doc.variables.collection(&r.local).filter(|c| several(c)) { + if let Some(c) = doc.variables.collection_by_id(&r.local).filter(|c| several(c)) { out.push(ModeRow { coll: c, library: Some((&r.library, library::library_name(doc, &r.library))), key: None, local: Some(&r.local) }); } } diff --git a/crates/ui-egui/src/present/actions.rs b/crates/ui-egui/src/present/actions.rs index 5cd4e03e..1b4e0f74 100644 --- a/crates/ui-egui/src/present/actions.rs +++ b/crates/ui-egui/src/present/actions.rs @@ -232,7 +232,7 @@ impl Presenter { Action::SetVariableMode { collection, mode } => { let Some(d) = self.design.as_mut() else { return false }; let before = d.clone(); - let set = figcraft_engine::proto_run::set_mode(d, self.current, collection, mode); + let set = figcraft_engine::proto_run::set_mode_by_id(d, self.current, collection, mode); if set { self.settle(&before); } diff --git a/crates/ui-egui/src/present/variables_tests.rs b/crates/ui-egui/src/present/variables_tests.rs index 23491800..3b67139a 100644 --- a/crates/ui-egui/src/present/variables_tests.rs +++ b/crates/ui-egui/src/present/variables_tests.rs @@ -76,7 +76,8 @@ fn counter_then_conditional_navigation() { #[test] fn set_variable_mode_recolours_bound_layers() { let (mut doc, a, _, button) = setup(); - on_click(&mut doc, button, vec![Action::SetVariableMode { collection: "Theme".into(), mode: "Dark".into() }]); + let theme = doc.variables.collection("Theme").unwrap().id.clone(); + on_click(&mut doc, button, vec![Action::SetVariableMode { collection: theme.clone(), mode: "Dark".into() }]); let hex = |p: &Presenter| p.doc.as_ref().and_then(|d| d.node(a)?.fills.first()?.solid_color()).map(|c| c.to_hex()); let mut p = Presenter::new(a); p.sync(&Arc::new(doc), 0.0); @@ -85,7 +86,7 @@ fn set_variable_mode_recolours_bound_layers() { assert_eq!(hex(&p).as_deref(), Some("000000")); // An unknown mode does nothing. let mut d2 = p.doc.clone().unwrap(); - on_click(&mut d2, button, vec![Action::SetVariableMode { collection: "Theme".into(), mode: "Sepia".into() }]); + on_click(&mut d2, button, vec![Action::SetVariableMode { collection: theme, mode: "Sepia".into() }]); let mut p2 = Presenter::new(a); p2.sync(&Arc::new(d2), 0.0); assert!(!p2.fire(button, 1, None, 1.0)); @@ -104,3 +105,29 @@ fn deeply_nested_conditionals_stop() { assert!(!p.fire(button, 0, None, 1.0)); assert_eq!(p.current, a); } + +#[test] +fn stored_variable_ids_keep_player_collection_identity() { + for missing in [false, true] { + let (mut doc, a, _, button) = setup(); + let theme = doc.variables.collections[0].clone(); + let mut shadow = theme.clone(); + shadow.id = "shadow".into(); + shadow.name = theme.id.clone(); + doc.variables.collections.insert(0, shadow); + if missing { + doc.variables.collections.retain(|c| c.id != theme.id); + } + on_click(&mut doc, button, vec![Action::SetVariableMode { collection: theme.id.clone(), mode: "Dark".into() }]); + let mut p = Presenter::new(a); + p.sync(&Arc::new(doc), 0.0); + assert_eq!(p.fire(button, 0, None, 1.0), !missing); + let played = p.doc.as_ref().unwrap(); + let modes = &played.node(played.page_of(a).unwrap()).unwrap().var_modes; + assert!(!modes.contains_key("shadow")); + if !missing { + let fill = played.node(a).unwrap().fills[0].solid_color().unwrap(); + assert_eq!(fill.to_hex(), "000000"); + } + } +} diff --git a/crates/ui-egui/src/proto_responsive_tests.rs b/crates/ui-egui/src/proto_responsive_tests.rs index f0ead8e9..8ff4f608 100644 --- a/crates/ui-egui/src/proto_responsive_tests.rs +++ b/crates/ui-egui/src/proto_responsive_tests.rs @@ -133,6 +133,34 @@ fn brand_library(tag: &str) -> (String, String) { (path, lid) } +#[test] +fn stored_variable_ids_keep_library_mode_picker_rows() { + let (path, lid) = brand_library("stored-ids"); + let mut s = Session::new(); + let f = id(&s.execute("frame.create", &json!({"width": 100, "height": 100})).unwrap()); + s.execute("variable.createCollection", &json!({"name": "shadow"})).unwrap(); + s.execute("library.add", &json!({"path": path})).unwrap(); + s.execute("library.setVariableMode", &json!({"library": lid, "collection": "Brand", "mode": "Dark", "ids": [f]})).unwrap(); + let local = s.doc().unwrap().libraries.collections[0].local.clone(); + s.execute("variable.renameCollection", &json!({"collection": "shadow", "name": local})).unwrap(); + let mut u = Ui::new(s); + let doc = u.app.session.doc().unwrap(); + let libs = crate::variable_ui::libraries(&u.app, doc); + // Both a read library and an unavailable one keep the actual copy's modes, not its namesake's. + for available in [libs.as_slice(), &[]] { + let rows = crate::variable_ui::mode_rows(doc, available); + assert_eq!(rows.len(), 1, "{rows:?}"); + assert_eq!(rows[0].coll.id, local); + assert_eq!(rows[0].coll.name, "Brand"); + assert_eq!(rows[0].coll.modes.len(), 2); + assert_eq!(rows[0].local, Some(local.as_str())); + assert_eq!(rows[0].library.unwrap().0, lid); + } + u.run("select.set", json!({"ids": [f]})); + u.frames(2, Vec::new); + let _ = std::fs::remove_file(&path); +} + #[test] fn library_collections_show_in_the_mode_picker_under_their_library() { let (path, lid) = brand_library("picker"); diff --git a/docs/components-parity.md b/docs/components-parity.md index 4cf94617..e5cc5abe 100644 --- a/docs/components-parity.md +++ b/docs/components-parity.md @@ -1,6 +1,6 @@ # Components, variants, styles and variables parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); components can't contain their own instances, directly or through others (#1084); the instance-swap menus have a search field (#1338); an instance-swap property's default menu scrolls (#1338); variables on a text style's fields from the style editor; instance-swap properties in the component playground; duplicating or Alt-dragging a main component makes an instance of it (#892); a bound colour variable's alpha applies once (#802); variables import reads `variables` outside `meta` and warns when it imports none (#790); variant properties reorder by drag; library files saved right after a read are read again; Hide when publishing note corrected; size and resizing overrides of layers inside instances (#731); variables export in the browser as a download; core editor leftovers: component playground, variables inside styles; layers and assets panels behaviour suite and fixes; extended collections tail; styles and variables behaviour suite and fixes; components behaviour suite and fixes; team libraries on the server, library usage and swapping a library; extended collections, variables import/export, gradient stops and variant properties bound to variables) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (stored variable and collection references keep their identity when another object is named like their id (follow-up to #1542); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); components can't contain their own instances, directly or through others (#1084); the instance-swap menus have a search field (#1338); an instance-swap property's default menu scrolls (#1338); variables on a text style's fields from the style editor; instance-swap properties in the component playground; duplicating or Alt-dragging a main component makes an instance of it (#892); a bound colour variable's alpha applies once (#802); variables import reads `variables` outside `meta` and warns when it imports none (#790); variant properties reorder by drag; library files saved right after a read are read again; Hide when publishing note corrected; size and resizing overrides of layers inside instances (#731); variables export in the browser as a download; core editor leftovers: component playground, variables inside styles; layers and assets panels behaviour suite and fixes; extended collections tail; styles and variables behaviour suite and fixes; components behaviour suite and fixes; team libraries on the server, library usage and swapping a library; extended collections, variables import/export, gradient stops and variant properties bound to variables) · **Target:** Figma Design (desktop app 126.10.7) Design systems are why teams adopt Figma. **Components: breadth ~96%, ready ~72%. Styles and variables: breadth ~95%, ready ~76% (estimated).** QA'd with a synthetic design system of ~300 @@ -68,7 +68,7 @@ updates with overrides kept: `library_tests.rs`, `components_scale_tests.rs`. | Deleting a bound variable or its collection | done | layers keep their last value and are unbound; aliases to it take its values | | Code syntax per platform (Web, iOS, Android, Flutter) | done | generated CSS uses the Web name; in the variables JSON; SwiftUI/Compose/ArkUI/Flutter code doesn't name variables yet | | Bindings: fills, strokes, effects, opacity, padding, gaps, sizes, radius, font family/style, font size/weight, line height, letter spacing, paragraph spacing/indent, text, visibility, layout grids, instance properties | done | type-checked per field; number variables fill text content; the Design panel's font family and font style menus start with Apply variable (string variables), and a bound one shows its variable in place of the menu; a colour variable bound to a solid paint brings its whole colour, alpha included, once (the paint's opacity restarts at 100% on binding, an opacity set while bound multiplies on top; unbinding moves the alpha back into the opacity) | -| Explicit modes per frame, library collections' modes | done | | +| Explicit modes per frame, library collections' modes | done | stored variable and collection references resolve by exact id in export, library copies and swaps, imported aliases, alias deletion and cycle checks, Dev Inspect, the library mode picker and prototype playback; a collection or variable named like another's id no longer stands in for it there | | Gradient stops and variant properties bound to variables | done | a stop's binding follows it when earlier stops are removed | | Extended collections | done | library collections, including a library's own extended collections (chains through libraries); overrides per mode with reset; follow library updates, variables new to the library collection being an update of their own; modes inherited. Checked with synthetic libraries only ([gaps](gaps.md) #14) | | Variables import/export (JSON) | done | the REST local variables shape (round-trips; merges by collection, mode and variable name; `variableCollections` and `variables` read under `meta` or at the top level, a warning when no variables are found or fewer than the collections list); CSS custom properties and Dart constants (Flutter) export; a variables file named by path must be a regular file of at most 256 MiB; in the browser the export arrives as a download (import there still needs a file picker) | @@ -78,6 +78,7 @@ updates with overrides kept: `library_tests.rs`, `components_scale_tests.rs`. | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | M3, follow-up to #1542: stored variable and collection references use exact ids in export, library copies and swaps, imported aliases, alias deletion and cycle checks, Dev Inspect, the library mode picker, and prototype actions and expressions. Interaction edits resolve only incoming names and leave stored references alone. Command arguments still accept names as before. Tests: the `stored_variable_ids_*` tests in the doc, engine, format and ui-egui present and responsive suites. Numbers unchanged | | 2026-10-30 | minor | M8, #1114: variables import from an explicit file path (`variable.import`, the `variables` of `file.open` and `file.import`) takes regular files of at most 256 MiB, the limit the sibling `variables.json` already had; anything else is refused before the document changes. Grade unchanged. Tests: `import_export_tests::an_explicit_variables_path_is_capped_like_the_sibling_file`, `variables_io_tests::variables_import_caps_the_file_it_reads` | | 2026-10-30 | minor | M3, #1536 and #1537: component properties are listed in the order they were made in, and renaming one keeps its place (they were sorted by name, so a rename moved the property); that holds for every kind, on every variant of a set and on instances, in `component.properties`, the Design panel and `.fig` export. Files from before keep their properties by name until one is added or renamed. Make slot (the Slot menu of a frame inside a component) asks for the slot's name first: a field under the Slot row with the frame's name selected, Enter or Make slot to make it, Escape to cancel; a name taken keeps the field open. Renaming stays in the component's Properties list. Tests: `engine/src/prop_order_tests.rs`, `ui-egui/src/make_slot_naming_tests.rs`. Left: dragging the shared properties to reorder them, and renaming a slot with only its frame selected. Numbers unchanged | | 2026-10-29 | minor | M3, #1338: the instance-swap property's menu (on an instance, its main component's default and the component playground) and the instance's swap menu open with a search field at the top that has the keyboard; typing filters the components by name (any case, headings without matches drop out), an empty result reads "No matching components", Up and Down step through the matches and Enter picks one (the first without a step), Escape clears the search and then closes the menu. Only the list scrolls; the menu grows back when the search is cleared. Tests: `component_swap_search_tests`. Numbers unchanged | diff --git a/docs/roadmap.md b/docs/roadmap.md index 889eccb0..b7b1dec9 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # FigCraft roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (SVG and PDF exports count pattern sources and the renders they embed from the layer's depth, so one cap bounds a whole export (#1082); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); joining and leaving a file nobody is editing reads and writes nothing on the server: it stays loaded a minute after everyone leaves (#1074); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); hovering the canvas hit-tests once per pointer move, not on every frame, and moving an auto layout frame no longer lays out its contents again on every move (#1571); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); `comment.people` lists names that were only @mentioned, and `comment.next` and `comment.prompt` carry the mentions too (#1415); grid auto-placement looks for free cells a 64-bit word at a time and skips what blocks it, with the same placements (#1083); with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); a recovery copy made while a version is previewed holds the file, not the version shown; a FigJam `.jam` file opens as a board (#1342); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); share links can comment: a third access level beside edit and view, whose holder sees the file live and adds, replies to, reacts to and resolves comments but can't change the design; Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); `comment.list {mentions}` lists only the threads that @mention a name (#1415); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); figcraft-cli parses its arguments with clap, following RFC-0012 (#1396); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); Thai, Lao, Khmer and Burmese lines break between words (#1219); an AI agent attached over MCP shows where it works (#1416); a manual overlay's position can be dragged on the canvas (#405); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); SVG import draws each pattern tile once per scale, within the layer budget (#1080); Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); M8: mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); M2: components can't contain their own instances, directly or through others; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); MCP and control calls name their document (#998); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); the instance-swap menus have a search field (#1338); a refused batch's fixes take time linear in the batch (#1063); the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); M0.9: optional batch continue-on-error with per-step outcomes (#1386); Alt pressed during a drag duplicates (#1385); variables in paint props (M3, #1000); finding layers without selecting them (M0.9, part of #996); the attached MCP server reads the app's replies with a 512 MiB bound (#1113); a failed batch keeps the undo steps it undid (#1410); Arabic draft catalog; Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (stored variable and collection references keep their identity when another object is named like their id (follow-up to #1542); SVG and PDF exports count pattern sources and the renders they embed from the layer's depth, so one cap bounds a whole export (#1082); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); joining and leaving a file nobody is editing reads and writes nothing on the server: it stays loaded a minute after everyone leaves (#1074); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); hovering the canvas hit-tests once per pointer move, not on every frame, and moving an auto layout frame no longer lays out its contents again on every move (#1571); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); `comment.people` lists names that were only @mentioned, and `comment.next` and `comment.prompt` carry the mentions too (#1415); grid auto-placement looks for free cells a 64-bit word at a time and skips what blocks it, with the same placements (#1083); with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); a recovery copy made while a version is previewed holds the file, not the version shown; a FigJam `.jam` file opens as a board (#1342); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); share links can comment: a third access level beside edit and view, whose holder sees the file live and adds, replies to, reacts to and resolves comments but can't change the design; Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); `comment.list {mentions}` lists only the threads that @mention a name (#1415); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); figcraft-cli parses its arguments with clap, following RFC-0012 (#1396); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); Thai, Lao, Khmer and Burmese lines break between words (#1219); an AI agent attached over MCP shows where it works (#1416); a manual overlay's position can be dragged on the canvas (#405); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); SVG import draws each pattern tile once per scale, within the layer budget (#1080); Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); M8: mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); M2: components can't contain their own instances, directly or through others; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); MCP and control calls name their document (#998); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); the instance-swap menus have a search field (#1338); a refused batch's fixes take time linear in the batch (#1063); the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); M0.9: optional batch continue-on-error with per-step outcomes (#1386); Alt pressed during a drag duplicates (#1385); variables in paint props (M3, #1000); finding layers without selecting them (M0.9, part of #996); the attached MCP server reads the app's replies with a 512 MiB bound (#1113); a failed batch keeps the undo steps it undid (#1410); Arabic draft catalog; Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7) Forward-looking plan. Where FigCraft stands today: [`ROADMAP.md`](../ROADMAP.md) and [`target-app-parity.md`](target-app-parity.md); the full ranked list: [`gaps.md`](gaps.md). Hours @@ -51,7 +51,7 @@ blockers. | M0.9 CLI + MCP, web build, release packaging, PDF export | done (v0.2.0 released 2026-10-10 for every platform) | — | — | | M1 Core editing completeness (tools, panels, menus, clipboard, files) | ~77% | interaction pass (Open recent, the outline mode variants and keyboard box selection landed 2026-10-11; repeat commands preserve the underlying edit and undo steps, #181; the Layout panel's constrain-proportions link scales the other side, #220) | 6–12 + pass | | M2 Layout depth | ~80% | grid `fr` rows, real-file QA | 6–12 | -| M3 Components & styles depth | ~78% (extended collections, variables import/export, gradient stop and variant bindings landed 2026-10-11) | real libraries, component playground | 7–14 | +| M3 Components & styles depth | ~78% (extended collections, variables import/export, gradient stop and variant bindings landed 2026-10-11; stored references keep exact variable and collection ids) | real libraries, component playground | 7–14 | | M4 Vector & text depth | ~80% (live booleans, arc fields, shape builder on strokes, boolean hit testing on the result, one-sided widths, text on a path, pattern fills, brushes and pen pressure landed 2026-10-11; vector edit mode regions 2026-10-13) | spell check, Figma Draw multi-edit | 7–18 | | M5 Prototype & present | ~85% | media, controllers on macOS/Linux/FreeBSD, separate window | 2–5 | | M6 Interop & Dev Mode | ~65% (code connect, Dev Mode plugins and best-effort `.fig` import landed 2026-10-11; best-effort `.sketch` import 2026-10-21) | `.fig` import's tail, video/HEIC (animated GIFs and TIFF landed 2026-10-11), CMYK intent | 15–30 | @@ -75,6 +75,7 @@ blockers. | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | M3, follow-up to #1542: stored variable and collection references use exact ids in export, library copies and swaps, imported aliases, alias deletion and cycle checks, Dev Inspect, the library mode picker, and prototype actions and expressions. Interaction edits resolve only incoming names and leave stored references alone. Command arguments still accept names as before. Tests: the `stored_variable_ids_*` tests in the doc, engine, format and ui-egui present and responsive suites. Numbers unchanged | | 2026-10-30 | minor | M8, #1082 (the rest): SVG and PDF pattern sources and the renders exports embed go on from the layer's depth: one cap for a whole export. Tests: `pattern_depth_tests` (SVG, PDF), `render_depth_tests` | | 2026-10-30 | minor | M8, #1082: pattern sources go on from the depth of the layer they fill (one cap for the whole render); render, save and band threads get 8 MiB stacks (a level takes about 6.5 KB). Tests: `render_depth_tests` | | 2026-10-30 | minor | M8, #1114 (explicit variables files): `variable.import` and the `variables` of `file.open` and `file.import` read a path with the same 256 MiB cap and regular-file check as the sibling `variables.json`, so an oversized file, a folder or a device is refused before the document changes; inline objects and JSON text, access roots and the browser's error are unchanged. Primary file reads and `image.place` are still uncapped. Tests: `import_export_tests::an_explicit_variables_path_is_capped_like_the_sibling_file`, `variables_io_tests::variables_import_caps_the_file_it_reads`. Numbers unchanged |