diff --git a/ROADMAP.md b/ROADMAP.md index 5f3d2d1a..e0473d22 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~28 points and ~200–365 h away (one Opus 5.5 agent; ~40–80 h wall-clock with 5 agents) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (the standalone server with share links to edit or view, wording only; live boolean editing and the vector tail: feature breadth ~79%; ready ~47%) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~79%; ready ~47%) · **Target:** Figma Design (desktop app 126.10.7) FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow, native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through @@ -17,16 +17,16 @@ start with [`docs/gaps.md`](docs/gaps.md). | Feature breadth | **~79%** (core editor ~90%) | estimated, [`docs/target-app-parity.md`](docs/target-app-parity.md) | | Ready for real work | **~47%** | estimated, same doc (written dimension weights and scores) | | Essentials user | **~53%** | estimated: core features 77.8% × 0.92 launch/stability × 0.92 discoverability × 0.8 opening files people send ([method](docs/target-app-parity.md#essentials-user)) | -| Mainstream practitioner | **~43%** | estimated: weekly areas 66.7% × 0.9 interaction × 0.9 stability × 0.8 file exchange with Figma users ([method](docs/target-app-parity.md#mainstream-practitioner)) | +| Mainstream practitioner | **~43%** | estimated: weekly areas 66.8% × 0.9 interaction × 0.9 stability × 0.8 file exchange with Figma users ([method](docs/target-app-parity.md#mainstream-practitioner)) | | Remaining to beta | **~200–365 h** one agent | estimated | -| Remaining to full parity | **~385–730 h** one agent (~95–165 h wall-clock with 5 agents) | estimated | +| Remaining to full parity | **~380–725 h** one agent (~95–165 h wall-clock with 5 agents) | estimated | ### Readiness by audience | Audience | Ready % | Opus 5.5 agent wall-clock hours to ~95% | Work that dominates | |---|---|---|---| -| Full target (ready for real work) | ~47% | ~370–695 h | multiplayer (45–90), localization (60–100), plugins (25–55), the interaction pass (40–60), `.fig` import (30–60), GPU canvas (30–60), AI (30–60) | -| Mainstream practitioner | ~43% | ~175–320 h | core editor depth incl. `.fig` import (91–177), the interaction pass (40–60), real-file corpus and platform QA (25–50), `.fig` export for handing work back (15–30) | +| Full target (ready for real work) | ~47% | ~365–690 h | multiplayer (45–90), localization (60–100), plugins (25–55), the interaction pass (40–60), `.fig` import (30–60), GPU canvas (30–60), AI (30–60) | +| Mainstream practitioner | ~43% | ~170–315 h | core editor depth incl. `.fig` import (88–171), the interaction pass (40–60), real-file corpus and platform QA (25–50), `.fig` export for handing work back (15–30) | | Essentials user | ~53% | ~85–160 h | `.fig` import so files people send open (30–60), core tool polish (25–45), interaction pass on the core tools (15–25), stability QA (10–20), onboarding and starter templates (5–10) | Hours are one agent working sequentially, calibrated from this repo's history (about 1 agent-hour @@ -56,7 +56,7 @@ the localization layer with Figma's own languages (20–35 h). | Dimension | Ready | Hours | Doc | |---|---|---|---| -| Features (depth) | 57% (core editor 67%) | 71–135 | [target-app-parity](docs/target-app-parity.md) | +| Features (depth) | 57% (core editor 67%) | 68–129 | [target-app-parity](docs/target-app-parity.md) | | UI/UX fidelity | 58% | 40–60 | [ui-parity](docs/ui-parity.md) | | File formats | 40% (no `.fig`) | 30–60 | [file-format-parity](docs/file-format-parity.md) | | Collaboration | 12% (core in; live sessions with document sync, cursors and text carets, the browser joins, invite links open the app; a self-hosted server with edit and view links; preview) | 55–110 | [collaboration-parity](docs/collaboration-parity.md) | @@ -83,7 +83,7 @@ the localization layer with Figma's own languages (20–35 h). | Auto layout & constraints | 95% | 68% | 6–12 | [auto-layout-parity](docs/auto-layout-parity.md) | | Text | 85% | 64% | 8–14 | [target-app-parity](docs/target-app-parity.md) | | Components & variants | 95% | 68% | 6–12 | [components-parity](docs/components-parity.md) | -| Styles & variables | 85% | 70% | 4–8 | [components-parity](docs/components-parity.md) | +| Styles & variables | 92% | 72% | 1–2 | [components-parity](docs/components-parity.md) | | Prototyping | 88% | 70% | 5–9 | [prototyping-parity](docs/prototyping-parity.md) | | Import/export | 65% | 35% | 30–60 | [file-format-parity](docs/file-format-parity.md) | | Dev Mode / inspect | 80% | 62% | 4–8 | [dev-mode-parity](docs/dev-mode-parity.md) | @@ -127,6 +127,10 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). ## Progress log +- **2026-10-11** · Extended variable collections (a library collection's modes and variables in a + collection of the file, overridden per mode with reset, following library updates), variables + import/export (the REST local variables JSON, merged by name; CSS custom properties), gradient + stops and variant properties bound to variables (gap 14). - **2026-10-11** · Live boolean editing: operands stay editable layers (double-click or Cmd/Ctrl-click selects one, moving, resizing or editing its points redraws the result during the drag with every operand outlined, per-operand operations from the boolean controls, prefix-cached recompute); @@ -157,6 +161,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | Extended collections, variables import/export and gradient stop and variant property bindings landed (gap 14): Styles & variables breadth 85% → 92%, ready 70% → 72%, 4–8 h → 1–2 h; feature breadth unchanged (~79%); ready for real work unchanged (~47%); full parity ~385–730 h → ~380–725 h | | 2026-10-11 | minor | Collaboration wording after the standalone server: `figcraft-server` keeps many files, persisted as a snapshot plus a log of what changed (compacted, checksummed, damaged files skipped and reported), restarts lose nothing confirmed and apps resume where they were; the app publishes its file to it; share links per file let people edit or only view (the server refuses a viewer's edits), are revocable and stored as hashes; TLS comes from a reverse proxy (the desktop app has none of its own yet); percentages and hours unchanged until the next re-measure | | 2026-10-11 | minor | Live boolean editing, Design panel arc fields, the shape builder on strokes and open paths and one-sided variable widths landed (gap 13): Shapes & vector breadth 92% → 97%, ready 66% → 68%, 8–14 h → 1–3 h; feature breadth ~78% → ~79%; ready for real work unchanged (~47%); full parity ~390–745 h → ~385–730 h | | 2026-10-11 | minor | Collaboration wording after the collaboration polish: styles, variables, collections, modes and comments created in a live session take ids from their creator's id space (concurrent creations no longer overwrite each other; the server checks it), others' text carets and selections show on the canvas, and typing in a layer someone else is typing in says so; percentages unchanged | diff --git a/crates/collab/src/entry_ids_tests.rs b/crates/collab/src/entry_ids_tests.rs index 109d49d8..81050c5e 100644 --- a/crates/collab/src/entry_ids_tests.rs +++ b/crates/collab/src/entry_ids_tests.rs @@ -103,6 +103,7 @@ fn create_everything(doc: &mut Document, who: usize, round: usize) { id: cid.clone(), name: name.clone(), modes: vec![Mode { id: format!("{cid}/mode1"), name: "Light".into() }], + extends: None, }); if let Some(m) = doc.new_mode_id(&cid) && let Some(c) = doc.variables.collections.iter_mut().find(|c| c.id == cid) @@ -119,6 +120,8 @@ fn create_everything(doc: &mut Document, who: usize, round: usize) { description: String::new(), scopes: None, hidden_from_publishing: false, + extends: None, + overrides: Default::default(), }); let k = doc.new_comment_id(); let c = comment(k, doc); diff --git a/crates/doc/src/colors_tests.rs b/crates/doc/src/colors_tests.rs index 96c2d69f..2a6ace82 100644 --- a/crates/doc/src/colors_tests.rs +++ b/crates/doc/src/colors_tests.rs @@ -41,7 +41,12 @@ fn colourful() -> (Document, NodeId) { description: String::new(), kind: StyleKind::Paint { paints: vec![Paint::solid(RED)] }, }); - d.variables.collections.push(Collection { id: "c1".into(), name: "C".into(), modes: vec![Mode { id: "m1".into(), name: "M".into() }] }); + d.variables.collections.push(Collection { + id: "c1".into(), + name: "C".into(), + modes: vec![Mode { id: "m1".into(), name: "M".into() }], + extends: None, + }); d.variables.variables.push(Variable { id: "v1".into(), name: "red".into(), @@ -51,6 +56,8 @@ fn colourful() -> (Document, NodeId) { description: String::new(), scopes: None, hidden_from_publishing: false, + extends: None, + overrides: Default::default(), }); d.document_colors.push(RED); (d, r) diff --git a/crates/doc/src/ids_tests.rs b/crates/doc/src/ids_tests.rs index 8565b4f0..d94d3408 100644 --- a/crates/doc/src/ids_tests.rs +++ b/crates/doc/src/ids_tests.rs @@ -15,7 +15,7 @@ fn comment(id: u64, page: NodeId) -> Comment { } fn collection(id: &str, modes: &[&str]) -> Collection { - Collection { id: id.into(), name: id.into(), modes: modes.iter().map(|m| Mode { id: (*m).into(), name: (*m).into() }).collect() } + Collection { id: id.into(), name: id.into(), modes: modes.iter().map(|m| Mode { id: (*m).into(), name: (*m).into() }).collect(), extends: None } } #[test] diff --git a/crates/doc/src/proto_expr_mode_tests.rs b/crates/doc/src/proto_expr_mode_tests.rs index 590c29ac..0d90ca08 100644 --- a/crates/doc/src/proto_expr_mode_tests.rs +++ b/crates/doc/src/proto_expr_mode_tests.rs @@ -16,6 +16,7 @@ fn more_vars() -> Variables { id: "cs".into(), name: "Color scheme".into(), modes: vec![Mode { id: "hc".into(), name: "High contrast".into() }, Mode { id: "odd".into(), name: "a]b".into() }], + extends: None, }); v.variables.push(Variable { id: "k".into(), @@ -26,6 +27,8 @@ fn more_vars() -> Variables { description: String::new(), scopes: None, hidden_from_publishing: false, + extends: None, + overrides: Default::default(), }); v } diff --git a/crates/doc/src/proto_expr_tests.rs b/crates/doc/src/proto_expr_tests.rs index 062063ba..ecc4e621 100644 --- a/crates/doc/src/proto_expr_tests.rs +++ b/crates/doc/src/proto_expr_tests.rs @@ -17,12 +17,15 @@ pub(crate) fn vars() -> Variables { description: String::new(), scopes: None, hidden_from_publishing: false, + extends: None, + overrides: Default::default(), }; Variables { collections: vec![Collection { id: "c".into(), name: "Theme".into(), modes: vec![Mode { id: "light".into(), name: "Light".into() }, Mode { id: "dark".into(), name: "Dark".into() }], + extends: None, }], variables: vec![ var("n", VariableType::Number, values(VariableValue::Number(3.0), VariableValue::Number(7.0))), diff --git a/crates/doc/src/styles.rs b/crates/doc/src/styles.rs index 6d8f34cb..fbf2a4cc 100644 --- a/crates/doc/src/styles.rs +++ b/crates/doc/src/styles.rs @@ -4,7 +4,7 @@ use crate::props::{Effect, LayoutGrid}; use crate::text::TextStyle; use figcraft_color::{Color, Paint}; use serde::{Deserialize, Serialize}; -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; pub type StyleId = String; pub type VariableId = String; @@ -79,6 +79,16 @@ pub enum VariableValue { } impl VariableValue { + /// The value a new variable of `kind` starts with. + pub fn default_for(kind: VariableType) -> Self { + match kind { + VariableType::Color => VariableValue::Color(Color::BLACK), + VariableType::Number => VariableValue::Number(0.0), + VariableType::String => VariableValue::String(String::new()), + VariableType::Boolean => VariableValue::Boolean(true), + } + } + pub fn type_of(&self) -> Option { match self { VariableValue::Color(_) => Some(VariableType::Color), @@ -110,6 +120,10 @@ pub struct Collection { pub id: CollectionId, pub name: String, pub modes: Vec, + /// An extended collection: the collection (a library copy) it extends. It has that + /// collection's modes (same ids) and one inheriting variable per variable of it, plus its own. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub extends: Option, } impl Collection { @@ -148,6 +162,13 @@ pub struct Variable { /// uses it). #[serde(default, rename = "hiddenFromPublishing", skip_serializing_if = "std::ops::Not::not")] pub hidden_from_publishing: bool, + /// In an extended collection: the variable it inherits from. Its values follow that variable's + /// in every mode not in `overrides`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub extends: Option, + /// The modes whose value an inheriting variable overrides. + #[serde(default, skip_serializing_if = "BTreeSet::is_empty")] + pub overrides: BTreeSet, } impl Variable { diff --git a/crates/doc/src/tests.rs b/crates/doc/src/tests.rs index d20d1df1..b5f864a8 100644 --- a/crates/doc/src/tests.rs +++ b/crates/doc/src/tests.rs @@ -204,6 +204,7 @@ fn variables_resolve_modes_and_aliases() { id: "c1".into(), name: "Theme".into(), modes: vec![Mode { id: "light".into(), name: "Light".into() }, Mode { id: "dark".into(), name: "Dark".into() }], + extends: None, }); let mut vals = std::collections::BTreeMap::new(); vals.insert("light".to_string(), VariableValue::Color(Color::WHITE)); @@ -217,6 +218,8 @@ fn variables_resolve_modes_and_aliases() { description: String::new(), scopes: None, hidden_from_publishing: false, + extends: None, + overrides: Default::default(), }); let mut alias = std::collections::BTreeMap::new(); alias.insert("light".to_string(), VariableValue::Alias("bg".into())); @@ -229,6 +232,8 @@ fn variables_resolve_modes_and_aliases() { description: String::new(), scopes: None, hidden_from_publishing: false, + extends: None, + overrides: Default::default(), }); let mut modes = std::collections::BTreeMap::new(); assert_eq!(v.resolve("surface", &modes), Some(VariableValue::Color(Color::WHITE))); @@ -246,6 +251,8 @@ fn variables_resolve_modes_and_aliases() { description: String::new(), scopes: None, hidden_from_publishing: false, + extends: None, + overrides: Default::default(), }); assert_eq!(v.resolve("loop", &std::collections::BTreeMap::new()), None); } diff --git a/crates/engine/src/cmd/component.rs b/crates/engine/src/cmd/component.rs index 603363f4..52df5615 100644 --- a/crates/engine/src/cmd/component.rs +++ b/crates/engine/src/cmd/component.rs @@ -827,11 +827,14 @@ pub(crate) fn swap_at(s: &mut Session, p: &Value) -> Result { } /// Whether instance `id` has a component property `name` that a `kind` variable can drive (a -/// boolean property takes a boolean variable, a text property a string one). +/// boolean property takes a boolean variable, a text or variant property a string one). pub(crate) fn check_prop_binding(doc: &Document, id: NodeId, name: &str, kind: VariableType) -> std::result::Result<(), String> { let Some(NodeKind::Instance { component, .. }) = doc.node(id).map(|n| &n.kind) else { return Err(format!("{id} is not an instance (component properties are bound on instances)")); }; + if variant_set(doc, *component).is_some_and(|set| variant_options(doc, set).iter().any(|(o, _)| o == name)) { + return if kind == VariableType::String { Ok(()) } else { Err(format!("`{name}` is a variant property: bind a string variable")) }; + } match (prop_defs(doc, *component).get(name), kind) { (Some(PropDef::Boolean { .. }), VariableType::Boolean) | (Some(PropDef::Text { .. }), VariableType::String) => Ok(()), (Some(PropDef::Boolean { .. }), _) => Err(format!("`{name}` is a boolean property: bind a boolean variable")), @@ -841,9 +844,31 @@ pub(crate) fn check_prop_binding(doc: &Document, id: NodeId, name: &str, kind: V } } -/// Write bound variable values into instance `id`'s boolean and text property values (only what -/// changed; the instance then follows its new values like after `instance.setProperties`). +/// Write bound variable values into instance `id`'s boolean and text property values, and switch +/// it to the variant its bound variant properties name (only what changed; the instance then +/// follows like after `instance.setProperties`). A value that names no variant changes nothing. pub(crate) fn apply_prop_bindings(doc: &mut Document, id: NodeId, sets: &[(String, VariableValue)]) { + let Some(NodeKind::Instance { component, .. }) = doc.node(id).map(|n| &n.kind) else { return }; + let component = *component; + if let Some(set) = variant_set(doc, component) { + let options = variant_options(doc, set); + let current = doc.node(component).map(|c| variant_props(&c.name)).unwrap_or_default(); + let mut wanted = current.clone(); + for (name, val) in sets { + if let VariableValue::String(t) = val + && options.iter().any(|(o, vals)| o == name && vals.contains(t)) + { + wanted.insert(name.clone(), t.clone()); + } + } + if wanted != current + && let Some(target) = find_variant(doc, set, &wanted) + && let Ok(n) = doc.get_mut(id) + && let NodeKind::Instance { component: c, .. } = &mut n.kind + { + *c = target; + } + } let Some(NodeKind::Instance { component, props }) = doc.node(id).map(|n| &n.kind) else { return }; let Some(NodeKind::Component { props: defs, .. }) = doc.node(*component).map(|c| &c.kind) else { return }; let mut next = props.clone(); diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index cabb8e20..a78a5c16 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -1036,6 +1036,7 @@ fn paint_set(s: &mut Session, p: &Value, fills: bool) -> Result { list.push(figcraft_color::Paint::solid(if fills { figcraft_color::Color::DEFAULT_SHAPE } else { figcraft_color::Color::BLACK })); } let Some(slot) = list.get_mut(at) else { return Err(bad(cmd, format!("no paint at index {at}"))) }; + let old = slot.clone(); if let Some(np) = &new_paint { *slot = np.clone(); } @@ -1054,6 +1055,7 @@ fn paint_set(s: &mut Session, p: &Value, fills: bool) -> Result { if let Some(b) = blend(p, "blend") { slot.blend = b; } + super::lists::remap_stop_bindings(n, fills, at, &old); if fills { n.styles.fill = None; } else { diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 8453aa88..f6d69815 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -356,14 +356,14 @@ fn read(path: &str) -> Result> { } #[cfg(not(target_arch = "wasm32"))] -fn write(path: &str, bytes: &[u8]) -> Result<()> { +pub(crate) fn write(path: &str, bytes: &[u8]) -> Result<()> { let tmp = format!("{path}.tmp-figcraft"); std::fs::write(&tmp, bytes).map_err(|e| EngineError::Other(format!("{tmp}: {e}")))?; std::fs::rename(&tmp, path).map_err(|e| EngineError::Other(format!("{path}: {e}"))) } #[cfg(target_arch = "wasm32")] -fn write(path: &str, _bytes: &[u8]) -> Result<()> { +pub(crate) fn write(path: &str, _bytes: &[u8]) -> Result<()> { Err(EngineError::Other(format!("{path}: no file system in the browser"))) } @@ -502,7 +502,7 @@ fn sibling_variables(_path: &str) -> Option { } /// The local variables JSON a command passes as `variables`: an object, JSON text or a file path. -fn variables_param(cmd: &str, p: &Value) -> Result> { +pub(crate) fn variables_param(cmd: &str, p: &Value) -> Result> { let parse = |text: &[u8]| serde_json::from_slice::(text).map_err(|e| bad(cmd, format!("variables: {e}"))); let v = match get(p, "variables") { None | Some(Value::Null) => return Ok(None), diff --git a/crates/engine/src/cmd/library.rs b/crates/engine/src/cmd/library.rs index aa490b96..eaae638e 100644 --- a/crates/engine/src/cmd/library.rs +++ b/crates/engine/src/cmd/library.rs @@ -131,6 +131,13 @@ pub fn specs() -> Vec { true, set_mode ), + spec!( + "library.extendCollection", + "Extend library collection", + "{library, collection (id or name of a published collection), name? (default \" (extended)\")} → {id}: a collection of this file with the library collection's modes and variables (they follow its updates); override their values per mode with variable.set (variable.resetOverride goes back to the inherited value) and add variables of its own", + true, + extend + ), ] } @@ -219,6 +226,28 @@ fn set_mode(s: &mut Session, p: &Value) -> Result { Ok(json!({"collection": local})) } +fn extend(s: &mut Session, p: &Value) -> Result { + let cmd = "library.extendCollection"; + let (link, lib) = library(s, cmd, p)?; + let key = req_string(cmd, p, "collection")?; + let coll = published_collections(&lib) + .find(|c| c.id == key) + .or_else(|| published_collections(&lib).find(|c| c.name == key)) + .cloned() + .ok_or_else(|| bad(cmd, format!("\"{}\" publishes no variable collection `{key}`", link.name)))?; + let vars: BTreeSet = published_variables(&lib).filter(|v| v.collection == coll.id).map(|v| v.id.clone()).collect(); + let doc = s.doc_mut()?; + let map = import_variables(doc, &lib, &vars, &BTreeSet::from([coll.id.clone()]), false); + let parent = map.collections.get(&coll.id).cloned().ok_or_else(|| EngineError::Other("the collection didn't import".into()))?; + let base = string(p, "name").map(|n| n.trim().chars().take(200).collect::()).filter(|n| !n.is_empty()); + let base = base.unwrap_or_else(|| format!("{} (extended)", coll.name)); + let name = super::variables::free_collection_name(doc, &base); + let id = doc.new_collection_id(); + doc.variables.collections.push(Collection { id: id.clone(), name, modes: Vec::new(), extends: Some(parent) }); + super::variables::sync_extensions(doc); + Ok(json!({"id": id})) +} + fn publish_cmd(s: &mut Session, p: &Value) -> Result { let cmd = "library.publish"; let doc = s.doc()?; diff --git a/crates/engine/src/cmd/lists.rs b/crates/engine/src/cmd/lists.rs index 12f5df75..f7cbb25b 100644 --- a/crates/engine/src/cmd/lists.rs +++ b/crates/engine/src/cmd/lists.rs @@ -1,6 +1,7 @@ //! A layer's ordered lists (fills, strokes, effects, layout grids): reordering them, and keeping -//! the variable bindings on their items (`fills//color`, `effects//radius`, -//! `layoutGrids//gutter`, …) with their items when a list is reordered or shrinks. +//! the variable bindings on their items (`fills//color`, `fills//stops//color`, +//! `effects//radius`, `layoutGrids//gutter`, …) with their items when a list is reordered +//! or shrinks, and on gradient stops when a stop is removed. //! //! Every list is stored bottom first: index 0 paints (or applies) first, the last one is on top; //! the Design panel shows them in reverse, the topmost row first. @@ -99,6 +100,58 @@ pub fn paint_binding_key(n: &Node, fills: bool, index: usize) -> Option<&str> { n.bound.keys().map(String::as_str).find(|k| list_binding(k) == Some((list, index, "color"))) } +/// The gradient stop a paint field names (`stops//color` → j). +pub fn stop_binding(field: &str) -> Option { + let j = field.strip_prefix("stops/")?.strip_suffix("/color")?; + if j.is_empty() || !j.bytes().all(|b| b.is_ascii_digit()) { + return None; + } + j.parse().ok() +} + +/// Keep the gradient stop bindings of paint `index` of `n`'s fills (or strokes) on their stops after +/// the paint changed from `old`: one stop removed shifts the later ones down (the removed one's +/// binding goes); a paint that is no longer a gradient drops them. Other edits keep stop indices. +pub(crate) fn remap_stop_bindings(n: &mut Node, fills: bool, index: usize, old: &figcraft_color::Paint) { + use figcraft_color::PaintKind; + let list = if fills { BoundList::Fills } else { BoundList::Strokes }; + let new = if fills { n.fills.get(index) } else { n.strokes.get(index) }; + // Some(k): stop k was removed; None: the paint has no stops any more. + let removed = match (&old.kind, new.map(|p| &p.kind)) { + (PaintKind::Gradient(a), Some(PaintKind::Gradient(b))) if a.stops.len() == b.stops.len() + 1 => { + // The first stop that differs is the removed one when the rest moved down unchanged. + let k = a.stops.iter().zip(&b.stops).position(|(x, y)| x != y).unwrap_or(b.stops.len()); + if a.stops.get(k + 1..) != b.stops.get(k..) { + return; + } + Some(k) + } + (_, Some(PaintKind::Gradient(_))) => return, + _ => None, + }; + let keys: Vec<(String, Option)> = n + .bound + .keys() + .filter_map(|key| { + let (l, i, field) = list_binding(key).filter(|(l, i, _)| *l == list && *i == index)?; + let j = stop_binding(field)?; + match removed.and_then(|k| removed_index(j, k)) { + Some(to) if to == j => None, + to => Some((key.clone(), to.map(|to| l.key(i, &format!("stops/{to}/color"))))), + } + }) + .collect(); + let mut kept = Vec::new(); + for (old_key, new_key) in keys { + if let Some(var) = n.bound.remove(&old_key) + && let Some(k) = new_key + { + kept.push((k, var)); + } + } + n.bound.extend(kept); +} + /// Where item `k` lands when the item at `from` moves to `to`. pub fn moved_index(k: usize, from: usize, to: usize) -> usize { if k == from { diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 8c2029ab..fed09ccc 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -19,6 +19,7 @@ pub mod structure; pub mod styles; pub mod text; pub mod variables; +pub mod variables_io; pub mod view; use std::collections::HashMap; @@ -81,6 +82,7 @@ fn registry() -> &'static Registry { list.extend(styles::specs()); list.extend(image::specs()); list.extend(variables::specs()); + list.extend(variables_io::specs()); list.extend(component::specs()); list.extend(library::specs()); list.extend(proto::specs()); diff --git a/crates/engine/src/cmd/styles.rs b/crates/engine/src/cmd/styles.rs index ce31bb0d..9374aedf 100644 --- a/crates/engine/src/cmd/styles.rs +++ b/crates/engine/src/cmd/styles.rs @@ -96,7 +96,7 @@ pub fn specs() -> Vec { _ => vec!["Mode 1".into()], }; let modes = modes.into_iter().enumerate().map(|(i, m)| Mode { id: format!("{id}/mode{}", i + 1), name: m }).collect(); - doc.variables.collections.push(Collection { id: id.clone(), name, modes }); + doc.variables.collections.push(Collection { id: id.clone(), name, modes, extends: None }); Ok(json!({"id": id})) }), spec!("variable.addMode", "Add mode", "{collection, name}", true, |s, p| { @@ -105,6 +105,7 @@ pub fn specs() -> Vec { let missing = || bad("variable.addMode", "no such collection"); let doc = s.doc_mut()?; let cid = doc.variables.collections.iter().find(|x| x.id == c || x.name == c).map(|x| x.id.clone()).ok_or_else(missing)?; + super::variables::check_modes_editable(doc, "variable.addMode", &cid)?; // A fresh id (modes can be deleted, so the count alone may repeat one; in a live // session it comes from this client's id space). let mid = doc.new_mode_id(&cid).ok_or_else(missing)?; @@ -136,14 +137,18 @@ pub fn specs() -> Vec { spec!("variable.delete", "Delete variable", "{variable}", true, |s, p| { let v = req_string("variable.delete", p, "variable")?; let doc = s.doc_mut()?; - let id = doc.variables.get(&v).map(|x| x.id.clone()).ok_or_else(|| bad("variable.delete", "no such variable"))?; + let var = doc.variables.get(&v).ok_or_else(|| bad("variable.delete", "no such variable"))?; + if var.extends.is_some() { + return Err(bad("variable.delete", "an inherited variable stays while the collection it extends has it")); + } + let id = var.id.clone(); doc.variables.variables.retain(|x| x.id != id); ok() }), spec!( "variable.bind", "Bind variable", - "{ids?, field (fill|stroke (first paint)|fills//color|strokes//color|effects//|layoutGrids//|componentProperties/ (instances: boolean/text properties)|width|height|opacity (percent)|cornerRadius|gap|paddingHorizontal|characters|visible|fontSize…; list indices bottom first), variable (null unbinds)}", + "{ids?, field (fill|stroke (first paint)|fills//color|strokes//color|fills//stops//color (gradient stops)|effects//|layoutGrids//|componentProperties/ (instances: boolean/text properties; variant properties take string variables naming a value)|width|height|opacity (percent)|cornerRadius|gap|paddingHorizontal|characters|visible|fontSize…; list indices bottom first), variable (null unbinds)}", true, bind ), @@ -591,12 +596,7 @@ fn var_create(s: &mut Session, p: &Value) -> Result { values.entry(m.id.clone()).or_insert_with(|| val.clone()); } } - let default = match kind { - VariableType::Color => VariableValue::Color(Color::BLACK), - VariableType::Number => VariableValue::Number(0.0), - VariableType::String => VariableValue::String(String::new()), - VariableType::Boolean => VariableValue::Boolean(true), - }; + let default = VariableValue::default_for(kind); for m in &coll.modes { values.entry(m.id.clone()).or_insert_with(|| default.clone()); } @@ -610,6 +610,8 @@ fn var_create(s: &mut Session, p: &Value) -> Result { description: string(p, "description").unwrap_or_default(), scopes: None, hidden_from_publishing: false, + extends: None, + overrides: Default::default(), }); Ok(json!({"id": id})) } @@ -629,6 +631,10 @@ pub fn var_set(s: &mut Session, p: &Value) -> Result { v => v, }; if let Some(slot) = doc.variables.variables.iter_mut().find(|x| x.id == var.id) { + // An inherited variable's value of its own overrides the inherited one in that mode. + if slot.extends.is_some() { + slot.overrides.insert(mode.clone()); + } slot.values.insert(mode, val); } ok() @@ -655,7 +661,7 @@ pub fn apply_bindings_to(doc: &mut Document, only: Option<&std::collections::BTr let binds = n.bound.clone(); let mut sets = serde_json::Map::new(); let mut effect_sets: Vec<(usize, String, VariableValue)> = Vec::new(); - let mut paint_sets: Vec<(bool, usize, Color)> = Vec::new(); + let mut paint_sets: Vec<(bool, usize, Option, Color)> = Vec::new(); let mut grid_sets: Vec<(usize, String, f64)> = Vec::new(); let mut prop_sets: Vec<(String, VariableValue)> = Vec::new(); for (field, var) in binds { @@ -664,7 +670,12 @@ pub fn apply_bindings_to(doc: &mut Document, only: Option<&std::collections::BTr if let Some((list, i, prop)) = list_binding(&field) { match (list, &val) { (BoundList::Fills | BoundList::Strokes, VariableValue::Color(c)) if prop == "color" => { - paint_sets.push((list == BoundList::Fills, i, *c)); + paint_sets.push((list == BoundList::Fills, i, None, *c)); + } + (BoundList::Fills | BoundList::Strokes, VariableValue::Color(c)) => { + if let Some(j) = super::lists::stop_binding(prop) { + paint_sets.push((list == BoundList::Fills, i, Some(j), *c)); + } } (BoundList::Effects, _) => effect_sets.push((i, prop.to_string(), val)), (BoundList::Grids, VariableValue::Number(x)) => grid_sets.push((i, prop.to_string(), *x)), @@ -722,18 +733,26 @@ pub fn apply_bindings_to(doc: &mut Document, only: Option<&std::collections::BTr } } -/// Write bound colours into a node's fills (`true`) or strokes by index, keeping the rest of the -/// list and each paint's opacity, visibility and blend mode (only what changed, so unchanged layers -/// stay shared). A first-paint binding on an empty list adds the paint. -fn apply_paint_bindings(doc: &mut Document, id: NodeId, sets: &[(bool, usize, Color)]) { +/// Write bound colours into a node's fills (`true`) or strokes by index (or into a gradient +/// paint's stop), keeping the rest of the list and each paint's opacity, visibility and blend mode +/// (only what changed, so unchanged layers stay shared). A first-paint binding on an empty list +/// adds the paint. +fn apply_paint_bindings(doc: &mut Document, id: NodeId, sets: &[(bool, usize, Option, Color)]) { let Some(n) = doc.node(id) else { return }; let (mut fills, mut strokes) = (n.fills.clone(), n.strokes.clone()); - for (is_fill, i, c) in sets { + for (is_fill, i, stop, c) in sets { let list = if *is_fill { &mut fills } else { &mut strokes }; - match list.get_mut(*i) { - Some(p) => p.kind = PaintKind::Solid { color: *c }, - None if *i == 0 => list.push(Paint::solid(*c)), - None => {} + match (list.get_mut(*i), stop) { + (Some(p), Some(j)) => { + if let PaintKind::Gradient(g) = &mut p.kind + && let Some(s) = g.stops.get_mut(*j) + { + s.color = *c; + } + } + (Some(p), None) => p.kind = PaintKind::Solid { color: *c }, + (None, None) if *i == 0 => list.push(Paint::solid(*c)), + (None, _) => {} } } if doc.node(id).is_some_and(|n| n.fills != fills || n.strokes != strokes) diff --git a/crates/engine/src/cmd/variables.rs b/crates/engine/src/cmd/variables.rs index 2940617b..75afeb49 100644 --- a/crates/engine/src/cmd/variables.rs +++ b/crates/engine/src/cmd/variables.rs @@ -1,5 +1,6 @@ //! Editing variables, modes and collections (the Variables editor): rename, describe, scope, -//! duplicate and delete; alias checks; and which scope a bindable property belongs to. +//! duplicate and delete; extended collections (overrides, keeping them in step with what they +//! extend); alias checks; and which scope a bindable property belongs to. use std::collections::BTreeSet; @@ -31,9 +32,37 @@ pub fn specs() -> Vec { true, delete_collection ), + spec!( + "variable.resetOverride", + "Reset variable override", + "{variable (a variable of an extended collection inherited from the collection it extends), mode? (id or name; every overridden mode when omitted)} the value follows the inherited one again", + true, + reset_override + ), ] } +/// The first of `base`, `base 2`, `base 3`… that no collection is named. +pub(crate) fn free_collection_name(doc: &Document, base: &str) -> String { + (1..1000) + .map(|k| if k == 1 { base.to_string() } else { format!("{base} {k}") }) + .find(|n| !doc.variables.collections.iter().any(|c| c.name == *n)) + .unwrap_or_else(|| base.to_string()) +} + +/// The error for editing what an extended collection inherits. +fn inherited(cmd: &str, what: &str) -> crate::EngineError { + bad(cmd, format!("{what} comes from the collection it extends (edit it there)")) +} + +/// 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) { + Some(c) if c.extends.is_some() => Err(inherited(cmd, "an extended collection's modes")), + _ => Ok(()), + } +} + /// A variable name with each `/`-separated group part trimmed; None when nothing is left. fn clean_name(name: &str) -> Option { let parts: Vec<&str> = name.split('/').map(str::trim).filter(|s| !s.is_empty()).collect(); @@ -47,6 +76,7 @@ fn update(s: &mut Session, p: &Value) -> Result { let doc = s.doc_mut()?; let var = doc.variables.get(&v).cloned().ok_or_else(|| bad(cmd, "no such variable"))?; let name = match string(p, "name") { + Some(_) if var.extends.is_some() => return Err(inherited(cmd, "an inherited variable's name")), Some(n) => { let n = clean_name(&n).ok_or_else(|| bad(cmd, "name can't be empty"))?; if doc.variables.variables.iter().any(|x| x.id != var.id && x.collection == var.collection && x.name == n) { @@ -102,6 +132,9 @@ fn duplicate(s: &mut Session, p: &Value) -> Result { .ok_or_else(|| bad(cmd, "too many copies"))?; copy.id = doc.new_variable_id(); copy.name = name; + // A copy of an inherited variable is the extended collection's own. + copy.extends = None; + copy.overrides.clear(); let id = copy.id.clone(); doc.variables.variables.insert(pos + 1, copy); Ok(json!({"id": id})) @@ -123,6 +156,7 @@ fn mode_of(doc: &Document, cmd: &str, p: &Value) -> Result<(usize, ModeId)> { .and_then(|c| c.modes.iter().find(|x| x.id == m || x.name == m)) .map(|x| x.id.clone()) .ok_or_else(|| bad(cmd, "no such mode"))?; + check_modes_editable(doc, cmd, doc.variables.collections.get(ci).map_or("", |c| &c.id))?; Ok((ci, mode)) } @@ -196,6 +230,105 @@ fn delete_collection(s: &mut Session, p: &Value) -> Result { Ok(json!({"deletedVariables": gone.len()})) } +fn reset_override(s: &mut Session, p: &Value) -> Result { + let cmd = "variable.resetOverride"; + 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 = 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"))?; + vec![coll.find_mode(&m).ok_or_else(|| bad(cmd, "no such mode"))?.id.clone()] + } + None => var.overrides.iter().cloned().collect(), + }; + if let Some(slot) = doc.variables.variables.iter_mut().find(|x| x.id == var.id) { + for m in modes { + slot.overrides.remove(&m); + if let Some(val) = parent.values.get(&m) { + slot.values.insert(m, val.clone()); + } + } + } + ok() +} + +/// Keep extended collections in step with the collections they extend (after extending one or +/// updating library copies): the same modes, one inheriting variable per variable of the parent +/// (named like it, with its values in every mode not overridden) and values in every mode for +/// their own variables. When a parent collection or variable is gone, what extended it becomes a +/// plain collection or variable, keeping its values. +pub fn sync_extensions(doc: &mut Document) { + let exts: Vec<(CollectionId, Option)> = doc + .variables + .collections + .iter() + .filter_map(|c| Some((c.id.clone(), c.extends.as_ref().map(|p| doc.variables.collections.iter().find(|x| x.id == *p).cloned())?))) + .collect(); + for (cid, parent) in exts { + let vars = &mut doc.variables.variables; + // shortcut: an extension of an extension isn't followed (extending is offered for library + // collections only), upgrade if extended collections get published and extended again. + let Some(parent) = parent.filter(|p| p.extends.is_none()) else { + for v in vars.iter_mut().filter(|v| v.collection == cid) { + v.extends = None; + v.overrides.clear(); + } + if let Some(c) = doc.variables.collections.iter_mut().find(|c| c.id == cid) { + c.extends = None; + } + continue; + }; + let parents: Vec = vars.iter().filter(|v| v.collection == parent.id).cloned().collect(); + let modes: Vec = parent.modes.iter().map(|m| m.id.clone()).collect(); + for v in vars.iter_mut().filter(|v| v.collection == cid) { + match v.extends.as_ref().map(|e| parents.iter().find(|p| p.id == *e)) { + Some(Some(p)) => { + if v.kind != p.kind { + v.kind = p.kind; + v.overrides.clear(); + } + v.name.clone_from(&p.name); + v.overrides.retain(|m| modes.contains(m)); + let own = std::mem::take(&mut v.values); + v.values = + p.values.iter().map(|(m, x)| (m.clone(), own.get(m).filter(|_| v.overrides.contains(m)).unwrap_or(x).clone())).collect(); + } + Some(None) => { + v.extends = None; + v.overrides.clear(); + } + None => {} + } + // Its own variables (and orphans) keep a value in every mode: new modes start with the + // default mode's. + if v.extends.is_none() { + let first = modes.iter().find_map(|m| v.values.get(m)).or_else(|| v.values.values().next()).cloned(); + v.values.retain(|m, _| modes.contains(m)); + if let Some(f) = first { + for m in &modes { + v.values.entry(m.clone()).or_insert_with(|| f.clone()); + } + } + } + } + let missing: Vec = + parents.into_iter().filter(|p| !vars.iter().any(|v| v.collection == cid && v.extends.as_ref() == Some(&p.id))).collect(); + if let Some(c) = doc.variables.collections.iter_mut().find(|c| c.id == cid) + && c.modes != parent.modes + { + c.modes = parent.modes.clone(); + } + for p in missing { + let id = doc.new_variable_id(); + let extends = Some(p.id.clone()); + doc.variables.variables.push(Variable { id, collection: cid.clone(), extends, hidden_from_publishing: false, ..p }); + } + } +} + /// Check that `var` may alias `target` (id or name): it exists, has the same type and doesn't /// lead back to `var`. Returns the target's id. pub fn alias_target(vars: &Variables, var: &Variable, target: &str) -> std::result::Result { diff --git a/crates/engine/src/cmd/variables_io.rs b/crates/engine/src/cmd/variables_io.rs new file mode 100644 index 00000000..625f6c9f --- /dev/null +++ b/crates/engine/src/cmd/variables_io.rs @@ -0,0 +1,206 @@ +//! Variables import and export: a file's collections as the local variables JSON (the shape the +//! REST importer reads, so exports round-trip) or as CSS custom properties, and such JSON read +//! back into a file. + +use std::collections::{BTreeMap, BTreeSet}; + +use figcraft_doc::*; +use serde_json::{Value, json}; + +use super::CommandSpec; +use crate::codegen::css_var_name; +use crate::library::{is_remote_collection, local_collections}; +use crate::params::*; +use crate::{EngineError, Result, Session, bad, spec}; + +pub fn specs() -> Vec { + vec![ + spec!( + "variable.export", + "Export variables", + "{collections? [ids or names; default every collection of this file, not library copies], format? (json (default): the local variables JSON that variable.import reads | css: custom properties, the default mode on :root and each other mode on [data-=\"\"]), path? (write it there)} → {text}, or {path} when written", + false, + export + ), + spec!( + "variable.import", + "Import variables", + "{variables (the local variables JSON, as variable.export writes it: an object, JSON text or a path)} collections join this file's collection of the same name (values by mode name; new modes and variables are added, a variable of another type is skipped), others are added → {collections (added), variables (added), updated, warnings}", + true, + import + ), + ] +} + +fn export(s: &mut Session, p: &Value) -> Result { + let cmd = "variable.export"; + let doc = s.doc()?; + let colls: Vec<&Collection> = match get(p, "collections") { + None | Some(Value::Null) => local_collections(doc).collect(), + Some(Value::Array(a)) => a + .iter() + .map(|x| x.as_str().and_then(|k| doc.variables.collection(k)).ok_or_else(|| bad(cmd, format!("no collection {x}")))) + .collect::>()?, + Some(_) => return Err(bad(cmd, "collections: [collection ids or names]")), + }; + let text = match string(p, "format").as_deref().unwrap_or("json") { + "json" => serde_json::to_string_pretty(&figcraft_format::rest::export_variables(&doc.variables, &colls)) + .map_err(|e| EngineError::Other(e.to_string()))?, + "css" => css(&doc.variables, &colls), + _ => return Err(bad(cmd, "format: json|css")), + }; + match string(p, "path") { + Some(path) => { + super::file::write(&path, text.as_bytes())?; + Ok(json!({"path": path})) + } + None => Ok(json!({"text": text})), + } +} + +/// `text` as a CSS comment or quoted string body. +fn css_safe(text: &str) -> String { + text.replace("*/", "* /").replace(['"', '\\', '\n'], " ") +} + +/// Each collection's variables as custom properties (named as generated code names them): the +/// default mode's values on `:root`, every other mode's on `[data-=""]`; +/// aliases are `var(--name)`. +fn css(vars: &Variables, colls: &[&Collection]) -> String { + let mut out = String::new(); + for c in colls { + let attr = css_var_name(&c.name); + for (i, m) in c.modes.iter().enumerate() { + let sel = if i == 0 { ":root".to_string() } else { format!("[data-{}=\"{}\"]", attr.trim_start_matches('-'), css_safe(&m.name)) }; + out.push_str(&format!("/* {} · {} */\n{sel} {{\n", css_safe(&c.name), css_safe(&m.name))); + for v in vars.variables.iter().filter(|v| v.collection == c.id) { + let value = match v.values.get(&m.id) { + Some(VariableValue::Color(col)) => col.to_css(), + Some(VariableValue::Number(x)) => 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(t) => format!("var({})", css_var_name(&t.name)), + None => continue, + }, + None => continue, + }; + out.push_str(&format!(" {}: {value};\n", css_var_name(&v.name))); + } + out.push_str("}\n"); + } + } + out +} + +fn import(s: &mut Session, p: &Value) -> Result { + let cmd = "variable.import"; + let v = + super::file::variables_param(cmd, p)?.ok_or_else(|| bad(cmd, "`variables` (the variables JSON: an object, JSON text or a path) required"))?; + let (src, mut warnings) = figcraft_format::rest::read_variables(&v).ok_or_else(|| bad(cmd, "not a variables JSON"))?; + let doc = s.doc_mut()?; + // Imported collection → (this file's collection, imported mode → its mode). + let mut colls: BTreeMap)> = BTreeMap::new(); + let mut added_colls = 0usize; + for c in &src.collections { + // Never a library copy (read-only) or an extended collection (its modes are inherited). + let same = doc.variables.collections.iter().find(|x| x.name == c.name && x.extends.is_none() && !is_remote_collection(doc, &x.id)); + let cid = match same.map(|x| x.id.clone()) { + Some(id) => id, + None => { + let id = doc.new_collection_id(); + let name = super::variables::free_collection_name(doc, &c.name); + doc.variables.collections.push(Collection { id: id.clone(), name, modes: Vec::new(), extends: None }); + added_colls += 1; + id + } + }; + let mut modes = BTreeMap::new(); + for m in &c.modes { + let coll = doc.variables.collections.iter().find(|x| x.id == cid); + let mid = match coll.and_then(|x| x.modes.iter().find(|y| y.name == m.name)) { + Some(y) => y.id.clone(), + None => { + let first = coll.and_then(|x| x.default_mode()).cloned(); + let Some(id) = doc.new_mode_id(&cid) else { continue }; + if let Some(x) = doc.variables.collections.iter_mut().find(|x| x.id == cid) { + x.modes.push(Mode { id: id.clone(), name: m.name.clone() }); + } + // Its variables start with their default mode's values (as with variable.addMode). + for var in doc.variables.variables.iter_mut().filter(|x| x.collection == cid) { + if let Some(val) = first.as_ref().and_then(|f| var.values.get(f)).cloned() { + var.values.insert(id.clone(), val); + } + } + id + } + }; + modes.insert(m.id.clone(), mid); + } + colls.insert(c.id.clone(), (cid, modes)); + } + // Every variable's id here first (aliases point at any of them), then the values. + let mut ids: BTreeMap = BTreeMap::new(); + let mut added = 0usize; + let mut updated = 0usize; + for v in &src.variables { + let Some((cid, _)) = colls.get(&v.collection) else { continue }; + match doc.variables.variables.iter().find(|x| x.collection == *cid && x.name == v.name) { + Some(x) if x.kind != v.kind => { + warnings.push(format!("`{}` is a {} variable in this file: the {} one was skipped", v.name, x.kind.id(), v.kind.id())); + } + Some(x) => { + ids.insert(v.id.clone(), x.id.clone()); + updated += 1; + } + None => { + let id = doc.new_variable_id(); + let blank = Variable { + id: id.clone(), + collection: cid.clone(), + values: BTreeMap::new(), + extends: None, + overrides: BTreeSet::new(), + ..v.clone() + }; + doc.variables.variables.push(blank); + ids.insert(v.id.clone(), id); + added += 1; + } + } + } + for v in &src.variables { + let (Some(local), Some((cid, modes))) = (ids.get(&v.id), colls.get(&v.collection)) else { continue }; + for (m, val) in &v.values { + let Some(lm) = modes.get(m) else { continue }; + let val = match val { + 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)) { + Ok(t) => VariableValue::Alias(t), + Err(e) => { + warnings.push(format!("`{}`: {e}", v.name)); + continue; + } + } + } + other => other.clone(), + }; + if let Some(slot) = doc.variables.variables.iter_mut().find(|x| x.id == *local) { + slot.values.insert(lm.clone(), val); + } + } + // A value in every mode of its collection: modes the JSON lacks take the default mode's + // (a new variable whose values all failed starts like one made with variable.create). + let all: Vec = + doc.variables.collections.iter().find(|x| x.id == *cid).map(|x| x.modes.iter().map(|m| m.id.clone()).collect()).unwrap_or_default(); + if let Some(slot) = doc.variables.variables.iter_mut().find(|x| x.id == *local) { + let first = all.iter().find_map(|m| slot.values.get(m)).cloned().unwrap_or_else(|| VariableValue::default_for(slot.kind)); + for m in all { + slot.values.entry(m).or_insert_with(|| first.clone()); + } + } + } + Ok(json!({"collections": added_colls, "variables": added, "updated": updated, "warnings": warnings})) +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index d24edbdf..c9d327d0 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1257,6 +1257,8 @@ mod variable_tests; #[cfg(test)] mod variables_editor_tests; #[cfg(test)] +mod variables_io_tests; +#[cfg(test)] mod version_preview_tests; #[cfg(test)] mod version_tests; diff --git a/crates/engine/src/library.rs b/crates/engine/src/library.rs index 48029458..3e670ee8 100644 --- a/crates/engine/src/library.rs +++ b/crates/engine/src/library.rs @@ -598,14 +598,14 @@ pub fn import_variables( && version > r.version && let Some(slot) = doc.variables.collections.iter_mut().find(|x| x.id == r.local) { - *slot = Collection { id: r.local.clone(), ..c.clone() }; + *slot = Collection { id: r.local.clone(), extends: None, ..c.clone() }; set_record(&mut doc.libraries_mut().collections, ImportedRecord { version, ..r.clone() }); } r.local } None => { let local = local_key(lid, key, |l| exists(doc, l)); - doc.variables.collections.push(Collection { id: local.clone(), ..c.clone() }); + doc.variables.collections.push(Collection { id: local.clone(), extends: None, ..c.clone() }); let rec = ImportedRecord { library: lid.to_string(), key: key.clone(), local: local.clone(), version }; set_record(&mut doc.libraries_mut().collections, rec); local @@ -649,7 +649,7 @@ pub fn import_variables( }; values.insert(mode.clone(), val); } - let copy = Variable { id: local.clone(), collection, values, ..v.clone() }; + let copy = Variable { id: local.clone(), collection, values, extends: None, overrides: Default::default(), ..v.clone() }; match doc.variables.variables.iter_mut().find(|x| x.id == local).filter(|_| !new) { Some(slot) => *slot = copy, None => doc.variables.variables.push(copy), @@ -853,10 +853,26 @@ pub fn accept_updates(doc: &mut Document, lib: &PublishedLibrary) -> Result = doc + .variables + .collections + .iter() + .filter_map(|c| doc.libraries.collection_import(c.extends.as_deref()?)) + .filter(|r| r.library == lib.manifest.id) + .map(|r| r.key.as_str()) + .collect(); + let extended: BTreeSet = + published_variables(lib).filter(|v| extended.contains(v.collection.as_str())).map(|v| v.id.clone()).collect(); + if !p.variables.is_empty() || !p.collections.is_empty() || !extended.is_empty() { let keys = |list: &[ImportedRecord]| list.iter().map(|r| r.key.clone()).collect::>(); - import_variables(doc, lib, &keys(&p.variables), &keys(&p.collections), true); + let mut vars = keys(&p.variables); + vars.extend(extended); + import_variables(doc, lib, &vars, &keys(&p.collections), true); } + crate::cmd::variables::sync_extensions(doc); let m = &lib.manifest; if let Some(l) = doc.libraries_mut().enabled.iter_mut().find(|l| l.id == m.id) && (l.version != m.version || l.name != m.name) @@ -901,10 +917,11 @@ pub fn prune(doc: &mut Document, library: &str) -> Result<()> { doc.styles.retain(|s| !unused.contains(&s.id)); doc.libraries_mut().styles.retain(|r| !unused.contains(&r.local)); } - // Variables layers bind or set, and local variables alias (with what those alias). + // Variables layers bind or set, local variables alias or extend (with what those alias). let seeds = doc .nodes() .flat_map(|n| n.bound.values().map(String::as_str).chain(action_variables(n))) + .chain(doc.variables.variables.iter().filter_map(|v| v.extends.as_deref())) .map(str::to_string) .chain(doc.variables.variables.iter().filter(|v| !is_remote_variable(doc, &v.id)).flat_map(|v| v.values.values()).filter_map(|x| match x { VariableValue::Alias(a) => Some(a.clone()), @@ -930,6 +947,7 @@ pub fn prune(doc: &mut Document, library: &str) -> Result<()> { .iter() .map(|v| &v.collection) .chain(doc.nodes().flat_map(|n| n.var_modes.keys())) + .chain(doc.variables.collections.iter().filter_map(|c| c.extends.as_ref())) .chain(action_modes) .cloned() .collect(); diff --git a/crates/engine/src/synthetic_ds.rs b/crates/engine/src/synthetic_ds.rs index 30d95e55..618b7d59 100644 --- a/crates/engine/src/synthetic_ds.rs +++ b/crates/engine/src/synthetic_ds.rs @@ -198,6 +198,7 @@ fn foundations(doc: &mut Document) -> (CollectionId, ModeId, ModeId) { id: coll.clone(), name: "Theme".into(), modes: vec![Mode { id: light.clone(), name: "Light".into() }, Mode { id: dark.clone(), name: "Dark".into() }], + extends: None, }); let var = |name: &str, kind: VariableType, values: [VariableValue; 2]| Variable { id: var_id(name), @@ -208,6 +209,8 @@ fn foundations(doc: &mut Document) -> (CollectionId, ModeId, ModeId) { description: String::new(), scopes: None, hidden_from_publishing: false, + extends: None, + overrides: Default::default(), }; for (name, l, d) in COLORS { doc.variables.variables.push(var(name, VariableType::Color, [VariableValue::Color(rgb(l)), VariableValue::Color(rgb(d))])); diff --git a/crates/engine/src/variables_io_tests.rs b/crates/engine/src/variables_io_tests.rs new file mode 100644 index 00000000..5c7305bb --- /dev/null +++ b/crates/engine/src/variables_io_tests.rs @@ -0,0 +1,373 @@ +//! Extended variable collections (overrides, library updates, reset), variables import and +//! export (JSON round trip, merging by name, CSS, junk), gradient stops bound to variables and +//! variant properties driven by string variables. + +use figcraft_color::{Color, ColorStop, Gradient, GradientKind, Paint, PaintKind}; +use figcraft_doc::*; +use serde_json::{Value, json}; + +use crate::Session; +use crate::params::id_of; + +fn ex(s: &mut Session, cmd: &str, p: Value) -> Value { + match s.execute(cmd, &p) { + Ok(v) => v, + Err(e) => panic!("{cmd} {p}: {e}"), + } +} + +fn idv(v: &Value) -> NodeId { + id_of(&v["id"]).unwrap() +} + +fn var<'a>(s: &'a Session, collection: &str, name: &str) -> &'a Variable { + let doc = s.doc().unwrap(); + doc.variables.variables.iter().find(|v| v.collection == collection && v.name == name).unwrap() +} + +fn hex(v: Option<&VariableValue>) -> String { + match v { + Some(VariableValue::Color(c)) => c.to_hex(), + other => format!("{other:?}"), + } +} + +fn fill_hex(s: &Session, id: NodeId) -> String { + s.doc().unwrap().node(id).unwrap().fills.first().and_then(|p| p.solid_color()).map(|c| c.to_hex()).unwrap_or_default() +} + +fn healthy(s: &Session) { + let doc = s.doc().unwrap(); + assert!(doc.validate().is_empty(), "{:?}", doc.validate()); +} + +#[test] +fn extended_collections_override_follow_updates_and_reset() { + let d = std::env::temp_dir().join(format!("figcraft-extvars-{}", std::process::id())); + std::fs::create_dir_all(&d).unwrap(); + let lib_path = d.join("theme.figcraft").to_string_lossy().into_owned(); + let mut lib = Session::new(); + ex(&mut lib, "variable.createCollection", json!({"name": "Theme", "modes": ["Light", "Dark"]})); + ex( + &mut lib, + "variable.create", + json!({"collection": "Theme", "name": "primary", "type": "color", "values": {"Light": "#0000ff", "Dark": "#ffffff"}}), + ); + ex(&mut lib, "variable.create", json!({"collection": "Theme", "name": "radius", "type": "number", "value": 4})); + let lid = ex(&mut lib, "library.publish", json!({}))["id"].as_str().unwrap().to_string(); + ex(&mut lib, "file.saveAs", json!({"path": lib_path})); + + let mut s = Session::new(); + ex(&mut s, "library.add", json!({"path": lib_path})); + let ext = ex(&mut s, "library.extendCollection", json!({"library": lid, "collection": "Theme"}))["id"].as_str().unwrap().to_string(); + let (coll, parent) = { + let doc = s.doc().unwrap(); + let c = doc.variables.collections.iter().find(|c| c.id == ext).unwrap().clone(); + let p = doc.variables.collections.iter().find(|p| Some(&p.id) == c.extends.as_ref()).unwrap().clone(); + assert!(crate::library::is_remote_collection(doc, &p.id), "it extends the library's copy"); + (c, p) + }; + assert_eq!(coll.name, "Theme (extended)"); + assert_eq!(coll.modes, parent.modes, "the same modes"); + let (light, dark) = (coll.modes[0].id.clone(), coll.modes[1].id.clone()); + let primary = var(&s, &ext, "primary").clone(); + assert!(primary.extends.is_some() && primary.overrides.is_empty()); + assert_eq!(hex(primary.values.get(&dark)), "FFFFFF"); + assert_eq!(var(&s, &ext, "radius").values.get(&light), Some(&VariableValue::Number(4.0))); + + // An override in one mode; bound layers see it in that mode. + ex(&mut s, "variable.set", json!({"variable": primary.id, "mode": "Dark", "value": "#ff0000"})); + assert_eq!(var(&s, &ext, "primary").overrides.iter().collect::>(), [&dark]); + let r = idv(&ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10}))); + ex(&mut s, "variable.bind", json!({"ids": [r], "field": "fill", "variable": primary.id})); + ex(&mut s, "variable.setMode", json!({"ids": [r], "collection": ext, "mode": "Dark"})); + assert_eq!(fill_hex(&s, r), "FF0000"); + // Variables of its own are fine; modes and inherited names and variables aren't its to change. + ex(&mut s, "variable.create", json!({"collection": ext, "name": "extra", "type": "number", "value": 1})); + for (cmd, p) in [ + ("variable.addMode", json!({"collection": ext, "name": "Dim"})), + ("variable.renameMode", json!({"collection": ext, "mode": "Dark", "name": "Night"})), + ("variable.removeMode", json!({"collection": ext, "mode": "Dark"})), + ("variable.update", json!({"variable": primary.id, "name": "mine"})), + ("variable.delete", json!({"variable": primary.id})), + ("variable.resetOverride", json!({"variable": "extra"})), + ] { + assert!(s.execute(cmd, &p).is_err(), "{cmd}"); + } + let dup = ex(&mut s, "variable.duplicate", json!({"variable": primary.id}))["id"].as_str().unwrap().to_string(); + assert!(s.doc().unwrap().variables.get(&dup).is_some_and(|v| v.extends.is_none() && v.overrides.is_empty()), "a copy is its own"); + + // The library changes a value and gains a variable: inherited values follow, overrides stay. + ex(&mut lib, "variable.set", json!({"variable": "primary", "mode": "Light", "value": "#00ff00"})); + ex(&mut lib, "variable.set", json!({"variable": "primary", "mode": "Dark", "value": "#222222"})); + ex(&mut lib, "variable.create", json!({"collection": "Theme", "name": "gap", "type": "number", "value": 8})); + ex(&mut lib, "library.publish", json!({})); + ex(&mut lib, "file.save", json!({})); + ex(&mut s, "library.acceptUpdates", json!({})); + let p = var(&s, &ext, "primary"); + assert_eq!((hex(p.values.get(&light)), hex(p.values.get(&dark))), ("00FF00".into(), "FF0000".into())); + assert!(var(&s, &ext, "gap").extends.is_some(), "the new library variable is inherited"); + assert_eq!(fill_hex(&s, r), "FF0000"); + // Reset: the inherited value again. + ex(&mut s, "variable.resetOverride", json!({"variable": primary.id, "mode": "Dark"})); + assert!(var(&s, &ext, "primary").overrides.is_empty()); + assert_eq!(fill_hex(&s, r), "222222"); + healthy(&s); + + // Saved and reopened, it is the same; removing the library keeps what it extends. + let user = d.join("app.figcraft").to_string_lossy().into_owned(); + ex(&mut s, "file.saveAs", json!({"path": user})); + let mut t = Session::empty(); + ex(&mut t, "file.open", json!({"path": user})); + assert_eq!(t.doc().unwrap().variables, s.doc().unwrap().variables); + ex(&mut s, "library.remove", json!({"id": lid})); + assert!(s.doc().unwrap().variables.collections.iter().any(|c| c.id == parent.id)); + healthy(&s); + assert!(s.execute("library.extendCollection", &json!({"library": lid, "collection": "Theme"})).is_err()); + let _ = std::fs::remove_dir_all(&d); +} + +/// A "Theme" collection (Light/Dark) with one variable of each type and an alias. +fn theme(s: &mut Session) { + ex(s, "variable.createCollection", json!({"name": "Theme", "modes": ["Light", "Dark"]})); + ex( + s, + "variable.create", + json!({"collection": "Theme", "name": "color/primary", "type": "color", "values": {"Light": "#0000ff80", "Dark": "#ffffff"}}), + ); + ex(s, "variable.create", json!({"collection": "Theme", "name": "radius", "type": "number", "values": {"Light": 4, "Dark": 6.5}})); + ex(s, "variable.create", json!({"collection": "Theme", "name": "label", "type": "string", "value": "Say \"hi\""})); + ex(s, "variable.create", json!({"collection": "Theme", "name": "on", "type": "boolean", "value": false})); + ex(s, "variable.create", json!({"collection": "Theme", "name": "accent", "type": "color", "value": "#000000"})); + ex(s, "variable.set", json!({"variable": "accent", "mode": "Dark", "value": {"alias": "color/primary"}})); + ex(s, "variable.update", json!({"variable": "radius", "scopes": ["CORNER_RADIUS"], "description": "Corners"})); +} + +/// (collection, variable, [(mode, value)], scopes), all by name. +type Row = (String, String, Vec<(String, String)>, Option>); + +/// Collections and variables by name with their values by mode name (aliases by target name). +fn by_name(s: &Session) -> Vec { + let vars = &s.doc().unwrap().variables; + let mut out: Vec<_> = vars + .variables + .iter() + .map(|v| { + let c = vars.collection(&v.collection).unwrap(); + let values = c + .modes + .iter() + .map(|m| { + let val = match v.values.get(&m.id) { + Some(VariableValue::Alias(a)) => format!("→ {}", vars.get(a).unwrap().name), + Some(x) => format!("{x:?}"), + None => "-".into(), + }; + (m.name.clone(), val) + }) + .collect(); + (c.name.clone(), v.name.clone(), values, v.scopes.clone()) + }) + .collect(); + out.sort(); + out +} + +#[test] +fn variables_export_and_import_round_trip() { + let mut s = Session::new(); + theme(&mut s); + let text = ex(&mut s, "variable.export", json!({}))["text"].as_str().unwrap().to_string(); + let parsed: Value = serde_json::from_str(&text).unwrap(); + assert!(figcraft_format::rest::is_variables_json(&parsed)); + + // Into another file: the same collections, variables, values, aliases and scopes. + let mut t = Session::new(); + let r = ex(&mut t, "variable.import", json!({"variables": text})); + assert_eq!((r["collections"].as_u64(), r["variables"].as_u64()), (Some(1), Some(5)), "{r}"); + assert_eq!(by_name(&t), by_name(&s)); + assert_eq!(t.doc().unwrap().variables.get("radius").unwrap().description, "Corners"); + + // Back into the same file: nothing new, nothing changed; edited values update in place. + let before = s.doc().unwrap().variables.clone(); + let r = ex(&mut s, "variable.import", json!({"variables": parsed})); + assert_eq!((r["collections"].as_u64(), r["variables"].as_u64(), r["updated"].as_u64()), (Some(0), Some(0), Some(5)), "{r}"); + assert_eq!(s.doc().unwrap().variables, before); + let mut edited = parsed.clone(); + let rid = before.get("radius").unwrap().id.clone(); + let light = before.collections[0].modes[0].id.clone(); + edited["meta"]["variables"][&rid]["valuesByMode"][&light] = json!(12); + edited["meta"]["variableCollections"][&before.collections[0].id]["modes"].as_array_mut().unwrap().push(json!({"modeId": "x", "name": "Dim"})); + edited["meta"]["variables"][&rid]["valuesByMode"]["x"] = json!(1); + ex(&mut s, "variable.import", json!({"variables": edited})); + let radius = s.doc().unwrap().variables.get("radius").unwrap().clone(); + assert_eq!(radius.id, rid, "the same variable (bindings keep working)"); + assert_eq!(radius.values.get(&light), Some(&VariableValue::Number(12.0))); + let coll = s.doc().unwrap().variables.collections[0].clone(); + assert_eq!(coll.modes.len(), 3, "the new mode is added"); + assert_eq!(radius.values.get(&coll.modes[2].id), Some(&VariableValue::Number(1.0))); + assert_eq!(s.doc().unwrap().variables.get("on").unwrap().values.len(), 3, "the others get a value in it too"); + ex(&mut s, "edit.undo", json!({})); + assert_eq!(s.doc().unwrap().variables, before, "one undo step"); + + // CSS: the default mode on :root, the others by attribute; aliases refer to their target. + let css = ex(&mut s, "variable.export", json!({"format": "css", "collections": ["Theme"]}))["text"].as_str().unwrap().to_string(); + for want in [ + ":root {\n", + " --radius: 4;\n", + "[data-theme=\"Dark\"] {\n", + " --radius: 6.5;\n", + " --accent: var(--color-primary);\n", + " --label: \"Say hi \";\n", + ] { + assert!(css.contains(want), "{want:?} in\n{css}"); + } + // Written to a file and read back from it. + let path = std::env::temp_dir().join(format!("figcraft-vars-io-{}.json", std::process::id())).to_string_lossy().into_owned(); + ex(&mut s, "variable.export", json!({"path": path})); + let mut u = Session::new(); + ex(&mut u, "variable.import", json!({"variables": path})); + assert_eq!(by_name(&u), by_name(&s)); + let _ = std::fs::remove_file(&path); +} + +#[test] +fn variables_import_checks_what_it_reads() { + let mut s = Session::new(); + ex(&mut s, "variable.createCollection", json!({"name": "Theme"})); + ex(&mut s, "variable.create", json!({"collection": "Theme", "name": "size", "type": "color", "value": "#000000"})); + let before = s.doc().unwrap().variables.clone(); + for p in + [json!({}), json!({"variables": 3}), json!({"variables": "{"}), json!({"variables": {"nope": 1}}), json!({"variables": "/no/such/file.json"})] + { + assert!(s.execute("variable.import", &p).is_err(), "{p}"); + } + for p in [json!({"collections": "x"}), json!({"collections": ["nope"]}), json!({"format": "xml"}), json!({"collections": [3]})] { + assert!(s.execute("variable.export", &p).is_err(), "{p}"); + } + assert_eq!(s.doc().unwrap().variables, before); + // Junk entries are skipped with warnings; a type clash and an alias loop don't get in. + let mode = json!([{"modeId": "m", "name": "Mode 1"}]); + let alias = |id: &str| json!({"type": "VARIABLE_ALIAS", "id": id}); + let junk = json!({"meta": { + "variableCollections": {"c": {"id": "c", "name": "Theme", "modes": mode}, "bad": {"name": "x", "modes": "no"}}, + "variables": { + "size": {"id": "size", "name": "size", "variableCollectionId": "c", "resolvedType": "FLOAT", "valuesByMode": {"m": 3}}, + "a": {"id": "a", "name": "a", "variableCollectionId": "c", "resolvedType": "FLOAT", "valuesByMode": {"m": alias("b")}}, + "b": {"id": "b", "name": "b", "variableCollectionId": "c", "resolvedType": "FLOAT", "valuesByMode": {"m": alias("a")}}, + "w": {"id": "w", "name": "w", "variableCollectionId": "c", "resolvedType": "WEIRD", "valuesByMode": {"m": 1}}, + "n": {"id": "n", "name": "n", "variableCollectionId": "c", "resolvedType": "FLOAT", "valuesByMode": {"m": "NaN"}} + }}}); + let r = ex(&mut s, "variable.import", json!({"variables": junk})); + let warnings = r["warnings"].to_string(); + assert!(warnings.contains("`size` is a color variable"), "{r}"); + assert!(warnings.contains("refer back to itself"), "{r}"); + let vars = &s.doc().unwrap().variables; + assert!(matches!(vars.get("size").unwrap().values.values().next(), Some(VariableValue::Color(_))), "the clash left it alone"); + assert_eq!(vars.collections.len(), 1); + let (a, b) = (vars.get("a").unwrap(), vars.get("b").unwrap()); + assert!(!a.values.values().chain(b.values.values()).all(|v| matches!(v, VariableValue::Alias(_))), "no loop"); + healthy(&s); +} + +/// A rectangle filled with a three-stop linear gradient, and colour variables `brand` and `ink`. +fn gradient_rect(s: &mut Session) -> NodeId { + let r = idv(&ex(s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 40, "height": 40}))); + let mut g = Gradient::default_for(GradientKind::Linear, Color::BLACK); + g.stops = vec![ + ColorStop { position: 0.0, color: Color::BLACK }, + ColorStop { position: 0.5, color: Color::WHITE }, + ColorStop { position: 1.0, color: Color::BLACK }, + ]; + ex(s, "fill.set", json!({"ids": [r], "index": 0, "paint": Paint::gradient(g)})); + ex(s, "variable.createCollection", json!({"name": "T"})); + ex(s, "variable.create", json!({"collection": "T", "name": "brand", "type": "color", "value": "#ff0000"})); + ex(s, "variable.create", json!({"collection": "T", "name": "ink", "type": "color", "value": "#00ff00"})); + r +} + +fn stops(s: &Session, r: NodeId) -> Vec { + match &s.doc().unwrap().node(r).unwrap().fills[0].kind { + PaintKind::Gradient(g) => g.stops.iter().map(|st| st.color.to_hex()).collect(), + k => panic!("{k:?}"), + } +} + +fn bound_keys(s: &Session, r: NodeId) -> Vec { + s.doc().unwrap().node(r).unwrap().bound.keys().cloned().collect() +} + +#[test] +fn gradient_stops_bind_to_variables_and_follow_their_stops() { + let mut s = Session::new(); + let r = gradient_rect(&mut s); + ex(&mut s, "variable.bind", json!({"ids": [r], "field": "fills/0/stops/2/color", "variable": "brand"})); + ex(&mut s, "variable.bind", json!({"ids": [r], "field": "fills/0/stops/1/color", "variable": "ink"})); + assert_eq!(stops(&s, r), ["000000", "00FF00", "FF0000"]); + ex(&mut s, "variable.set", json!({"variable": "brand", "value": "#0000ff"})); + assert_eq!(stops(&s, r), ["000000", "00FF00", "0000FF"]); + // Removing the first stop moves the bindings down with their stops. + let cur = |s: &Session| s.doc().unwrap().node(r).unwrap().fills[0].clone(); + let mut p = cur(&s); + if let PaintKind::Gradient(g) = &mut p.kind { + g.stops.remove(0); + } + ex(&mut s, "fill.set", json!({"ids": [r], "index": 0, "paint": p})); + assert_eq!(bound_keys(&s, r), ["fills/0/stops/0/color", "fills/0/stops/1/color"]); + assert_eq!(stops(&s, r), ["00FF00", "0000FF"]); + // Moving a stop (or recolouring another paint) keeps them; removing a bound stop drops its binding. + let mut p = cur(&s); + if let PaintKind::Gradient(g) = &mut p.kind { + g.stops[0].position = 0.9; + g.stops.push(ColorStop { position: 0.1, color: Color::WHITE }); + } + ex(&mut s, "fill.set", json!({"ids": [r], "index": 0, "paint": p})); + assert_eq!(bound_keys(&s, r).len(), 2); + let mut p = cur(&s); + if let PaintKind::Gradient(g) = &mut p.kind { + g.stops.remove(1); + } + ex(&mut s, "fill.set", json!({"ids": [r], "index": 0, "paint": p})); + assert_eq!(bound_keys(&s, r), ["fills/0/stops/0/color"]); + // The paint moves up the list with its binding; a solid paint has no stops to bind. + ex(&mut s, "fill.add", json!({"ids": [r], "color": "#cccccc"})); + ex(&mut s, "fill.move", json!({"ids": [r], "from": 0, "to": 1})); + assert_eq!(bound_keys(&s, r), ["fills/1/stops/0/color"]); + ex(&mut s, "fill.set", json!({"ids": [r], "index": 1, "color": "#123456"})); + assert!(bound_keys(&s, r).is_empty()); + // Only colour variables, and junk stop keys bind nothing. + assert!(crate::cmd::lists::stop_binding("stops/-1/color").is_none() && crate::cmd::lists::stop_binding("stops/1/color") == Some(1)); + healthy(&s); +} + +#[test] +fn variant_properties_follow_string_variables() { + let mut s = Session::new(); + let mut variants = Vec::new(); + for (k, (state, color)) in [("Default", "#ff0000"), ("Hover", "#0000ff")].iter().enumerate() { + let f = + idv(&ex(&mut s, "frame.create", json!({"x": k as f64 * 150.0, "y": 0, "width": 100, "height": 40, "name": format!("State={state}")}))); + ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 100, "height": 40, "parent": f, "props": {"name": "Bg", "fill": color}})); + variants.push(idv(&ex(&mut s, "component.create", json!({"ids": [f]})))); + } + let set = idv(&ex(&mut s, "component.combineVariants", json!({"ids": variants.clone()}))); + let i = idv(&ex(&mut s, "instance.create", json!({"component": set, "x": 0, "y": 200}))); + ex(&mut s, "variable.createCollection", json!({"name": "UI", "modes": ["Calm", "Busy"]})); + ex(&mut s, "variable.create", json!({"collection": "UI", "name": "state", "type": "string", "values": {"Calm": "Default", "Busy": "Hover"}})); + ex(&mut s, "variable.create", json!({"collection": "UI", "name": "count", "type": "number", "value": 1})); + let main = |s: &Session| match s.doc().unwrap().node(i).unwrap().kind { + NodeKind::Instance { component, .. } => component, + _ => panic!(), + }; + assert!(s.execute("variable.bind", &json!({"ids": [i], "field": "componentProperties/State", "variable": "count"})).is_err()); + ex(&mut s, "variable.bind", json!({"ids": [i], "field": "componentProperties/State", "variable": "state"})); + assert_eq!(main(&s), variants[0]); + ex(&mut s, "variable.setMode", json!({"ids": [i], "collection": "UI", "mode": "Busy"})); + assert_eq!(main(&s), variants[1]); + let bg = s.doc().unwrap().node(i).unwrap().children[0]; + assert_eq!(fill_hex(&s, bg), "0000FF", "the instance shows the variant"); + // A value naming no variant changes nothing. + ex(&mut s, "variable.set", json!({"variable": "state", "mode": "Busy", "value": "Pressed"})); + assert_eq!(main(&s), variants[1]); + healthy(&s); +} diff --git a/crates/format/src/rest/mod.rs b/crates/format/src/rest/mod.rs index e7d756a6..c944bae9 100644 --- a/crates/format/src/rest/mod.rs +++ b/crates/format/src/rest/mod.rs @@ -45,6 +45,7 @@ use serde_json::Value; use crate::FormatError; use props::{MAX_COORD, MAX_SIZE, clip_str, flag, list, num, text}; +pub use variables::export as export_variables; /// Most layers imported from one file. pub const MAX_NODES: usize = 200_000; @@ -90,6 +91,15 @@ pub fn is_variables_json(v: &Value) -> bool { variables::source(v).is_some() } +/// The collections and variables of a local variables response (or its `meta`), checked like a +/// file's, with the warnings about what was skipped; None when `v` isn't one. +pub fn read_variables(v: &Value) -> Option<(figcraft_doc::Variables, Vec)> { + let src = variables::source(v)?; + let mut cx = Ctx::new(Document::new("Variables"), &no_images); + cx.variables(src); + Some((std::mem::take(&mut cx.doc.variables), cx.warnings.finish())) +} + /// Import `bytes` when they are REST API file JSON; `None` when they are something else (the /// caller then tries its other formats). pub fn try_import(bytes: &[u8], images: ImageResolver) -> Option), FormatError>> { diff --git a/crates/format/src/rest/variables.rs b/crates/format/src/rest/variables.rs index 6cd00fea..41ee4d47 100644 --- a/crates/format/src/rest/variables.rs +++ b/crates/format/src/rest/variables.rs @@ -1,11 +1,12 @@ //! Variables: the local variables response (`GET /v1/files/:key/variables/local`, whose `meta` //! holds `variableCollections` and `variables`), embedded next to the file JSON or passed on its -//! own, and the layers' `boundVariables` and `explicitVariableModes`. +//! own, and the layers' `boundVariables` and `explicitVariableModes`. [`export`] writes +//! collections back in the same shape. use std::collections::{BTreeMap, BTreeSet}; -use figcraft_doc::{Collection, Mode, Node, NodeKind, Variable, VariableType, VariableValue, normalize_scopes}; -use serde_json::{Map, Value}; +use figcraft_doc::{ALL_SCOPES, Collection, Mode, Node, NodeKind, Variable, VariableType, VariableValue, Variables, normalize_scopes}; +use serde_json::{Map, Value, json}; use super::Ctx; use super::props::{clip_str, color, list, text}; @@ -51,6 +52,64 @@ pub(super) fn value(kind: VariableType, v: &Value) -> Option { }) } +/// `collections` of `vars` (with their variables) as a local variables response, which +/// [`Ctx::variables`] reads back. Aliases to variables outside them are written as the value they +/// resolve to in the default modes. +pub fn export(vars: &Variables, collections: &[&Collection]) -> Value { + let ids: BTreeSet<&str> = collections.iter().map(|c| c.id.as_str()).collect(); + let members: Vec<&Variable> = vars.variables.iter().filter(|v| ids.contains(v.collection.as_str())).collect(); + let exported: BTreeSet<&str> = members.iter().map(|v| v.id.as_str()).collect(); + let mut colls = Map::new(); + for c in collections { + let modes: Vec = c.modes.iter().map(|m| json!({"modeId": m.id, "name": m.name})).collect(); + let var_ids: Vec<&str> = members.iter().filter(|v| v.collection == c.id).map(|v| v.id.as_str()).collect(); + colls.insert( + c.id.clone(), + json!({"id": c.id, "name": c.name, "key": c.id, "modes": modes, "defaultModeId": c.default_mode(), "remote": false, + "hiddenFromPublishing": false, "variableIds": var_ids}), + ); + } + let mut out = Map::new(); + for v in members { + let mut values = Map::new(); + for (mode, val) in &v.values { + let val = match val { + VariableValue::Alias(a) if !exported.contains(a.as_str()) => match vars.resolve(a, &BTreeMap::new()) { + Some(r) => r, + None => continue, + }, + other => other.clone(), + }; + values.insert(mode.clone(), raw(&val)); + } + let kind = match v.kind { + VariableType::Color => "COLOR", + VariableType::Number => "FLOAT", + VariableType::String => "STRING", + VariableType::Boolean => "BOOLEAN", + }; + let scopes = v.scopes.clone().unwrap_or_else(|| vec![ALL_SCOPES.to_string()]); + out.insert( + v.id.clone(), + json!({"id": v.id, "name": v.name, "key": v.id, "variableCollectionId": v.collection, "resolvedType": kind, "valuesByMode": values, + "remote": false, "description": v.description, "hiddenFromPublishing": v.hidden_from_publishing, "scopes": scopes, + "codeSyntax": {}}), + ); + } + json!({"status": 200, "error": false, "meta": {"variableCollections": colls, "variables": out}}) +} + +/// A value as the variables JSON writes it (the inverse of [`value`]). +fn raw(v: &VariableValue) -> Value { + match v { + VariableValue::Color(c) => json!({"r": c.r, "g": c.g, "b": c.b, "a": c.a}), + VariableValue::Number(x) => json!(x), + VariableValue::String(s) => json!(s), + VariableValue::Boolean(b) => json!(b), + VariableValue::Alias(a) => json!({"type": "VARIABLE_ALIAS", "id": a}), + } +} + fn objects<'v>(v: &'v Value, key: &str, max: usize) -> impl Iterator { v.get(key).and_then(Value::as_object).into_iter().flat_map(Map::iter).take(max) } @@ -102,7 +161,7 @@ impl Ctx<'_> { } let name = text(c, "name").filter(|s| !s.trim().is_empty()).map_or("Collection", clip_str).to_string(); self.var_modes.insert(id.clone(), modes.iter().map(|m| m.id.clone()).collect()); - self.doc.variables.collections.push(Collection { id, name, modes }); + self.doc.variables.collections.push(Collection { id, name, modes, extends: None }); } fn variable(&mut self, key: &str, v: &Value) -> Option { @@ -146,6 +205,8 @@ impl Ctx<'_> { description: text(v, "description").map(|s| s.chars().take(MAX_STRING).collect()).unwrap_or_default(), scopes: scopes.flatten(), hidden_from_publishing: v.get("hiddenFromPublishing").and_then(Value::as_bool).unwrap_or(false), + extends: None, + overrides: Default::default(), }) } diff --git a/crates/ui-egui/src/colorpicker.rs b/crates/ui-egui/src/colorpicker.rs index b31eaa42..2d5a572a 100644 --- a/crates/ui-egui/src/colorpicker.rs +++ b/crates/ui-egui/src/colorpicker.rs @@ -468,6 +468,8 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) { } let mut changed = false; let mut done = false; + // The bar first, so the stops on it take their own clicks. + let bresp = ui.interact(bar, Id::new("cp_bar"), Sense::click()); for (i, s) in g.stops.clone().iter().enumerate() { let x = bar.min.x + s.position * bar.width(); let r = Rect::from_center_size(pos2(x, bar.center().y), vec2(12.0, 18.0)); @@ -494,7 +496,6 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) { } } // Click on the bar adds a stop. - let bresp = ui.interact(bar, Id::new("cp_bar"), Sense::click()); if bresp.clicked() && let Some(p) = bresp.interact_pointer_pos() { @@ -537,6 +538,24 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) { write(app, &target, &paint, false); } }); + // The selected stop's colour can come from a colour variable. + if let PickerTarget::Paint { fills, index, ids } = &target { + let field = format!("{}/{index}/stops/{sel}/color", if *fills { "fills" } else { "strokes" }); + let stop = g.stops.get(sel).map(|s| s.color); + ui.horizontal(|ui| { + ui.add_space(16.0); + crate::variable_ui::bindable(app, ui, ids, &field, figcraft_doc::VariableType::Color, 208.0, |_, ui| { + let (r, _) = ui.allocate_exact_size(vec2(208.0, widgets::FIELD_H), Sense::hover()); + ui.painter().rect_filled(r, CornerRadius::same(5), t.field); + let sw = Rect::from_center_size(pos2(r.min.x + 13.0, r.center().y), vec2(14.0, 14.0)); + if let Some(c) = stop { + widgets::paint_swatch(ui.painter(), sw, &Paint::solid(c)); + let text = format!("Stop {} {}", sel + 1, c.to_hex()); + ui.painter().text(pos2(sw.max.x + 8.0, r.center().y), Align2::LEFT_CENTER, text, regular(11.0), t.text); + } + }); + }); + } } if let PaintKind::Image(img) = &paint.kind { let mut img = img.clone(); diff --git a/crates/ui-egui/src/component_panel.rs b/crates/ui-egui/src/component_panel.rs index cd486448..9bf9a57a 100644 --- a/crates/ui-egui/src/component_panel.rs +++ b/crates/ui-egui/src/component_panel.rs @@ -621,14 +621,17 @@ fn instance_props(app: &mut FigApp, ui: &mut Ui, doc: &Document, n: &Node, comps row(ui, 32.0, |ui| { prop_label(ui, &k, Icon::ComponentSet); let w = ui.available_width().max(60.0); - let resp = widgets::dropdown(ui, &cur, w, None); - menu_popup(&resp).show(|ui| { - for v in &vals { - if menu_item(ui, v, "") { - app.run("instance.setProperties", json!({"ids": [id], "properties": {k.clone(): v}})); - ui.close(); + // A string variable naming one of the values can choose it. + crate::variable_ui::bindable(app, ui, &[id], &format!("{PROP_BINDING}{k}"), VariableType::String, w, |app, ui| { + let resp = widgets::dropdown(ui, &cur, w, None); + menu_popup(&resp).show(|ui| { + for v in &vals { + if menu_item(ui, v, "") { + app.run("instance.setProperties", json!({"ids": [id], "properties": {k.clone(): v}})); + ui.close(); + } } - } + }); }); }); } diff --git a/crates/ui-egui/src/variables_panel.rs b/crates/ui-egui/src/variables_panel.rs index 7b22eb51..de1e0094 100644 --- a/crates/ui-egui/src/variables_panel.rs +++ b/crates/ui-egui/src/variables_panel.rs @@ -2,7 +2,10 @@ //! read-only under the library's name); the selected collection's variables on the right as a //! table with one value column per mode. Double-click a name to rename it, right-click for more; a //! value's variable button turns it into an alias of another variable (a library one too). -//! Everything goes through `variable.*` commands, so it's undoable and the same over MCP. +//! A library collection can be extended: a collection of this file with its modes and variables, +//! whose overridden values are marked (with a reset). Import and export read and write the +//! variables JSON (or CSS). Everything goes through `variable.*` / `library.*` commands, so it's +//! undoable and the same over MCP. use egui::{Align2, Context, CornerRadius, Id, Rect, Sense, Stroke, StrokeKind, Ui, UiBuilder, pos2, vec2}; use figcraft_doc::{Collection, Document, PublishedLibrary, Variable, VariableType, VariableValue, Variables, scopes_for}; @@ -153,7 +156,7 @@ pub fn show(app: &mut FigApp, ctx: &Context) { .show(ctx, |ui| { ui.set_min_size(size); ui.spacing_mut().item_spacing = vec2(0.0, 0.0); - header(ui, &mut open); + header(app, ui, &mut open); let h = (size.y - 44.0).max(160.0); let top_down = egui::Layout::top_down(egui::Align::Min); ui.horizontal_top(|ui| { @@ -167,9 +170,24 @@ pub fn show(app: &mut FigApp, ctx: &Context) { app.ui.variables = open.then_some(st); } -fn header(ui: &mut Ui, open: &mut bool) { +fn header(app: &mut FigApp, ui: &mut Ui, open: &mut bool) { let t = Tokens::get(ui.ctx()); let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 44.0), Sense::hover()); + // Import and export, left of Close. + let bar = Rect::from_min_max(pos2(r.max.x - 280.0, r.min.y + 10.0), pos2(r.max.x - 40.0, r.max.y - 10.0)); + let mut bar = ui.new_child(UiBuilder::new().max_rect(bar).layout(egui::Layout::right_to_left(egui::Align::Center))); + let export = widgets::button(&mut bar, "Export…", false, true); + crate::right::menu_popup(&export).show(|ui| { + for (label, format) in [("Variables JSON…", "json"), ("CSS custom properties…", "css")] { + if menu_item(ui, label, "") { + export_dialog(app, format); + ui.close(); + } + } + }); + if widgets::button(&mut bar, "Import…", false, true).on_hover_text("Add or update collections from a variables JSON").clicked() { + import_dialog(app); + } ui.painter().text(pos2(r.min.x + 16.0, r.center().y), Align2::LEFT_CENTER, "Local variables", semibold(13.0), t.text); if widgets::icon_button_at( ui, @@ -183,6 +201,36 @@ fn header(ui: &mut Ui, open: &mut bool) { ui.painter().line_segment([r.left_bottom(), r.right_bottom()], Stroke::new(1.0, t.border)); } +/// Export this file's collections through a save dialog. +fn export_dialog(app: &mut FigApp, format: &str) { + let name = app.session.doc().map(|d| format!("{}.variables.{format}", d.name)).unwrap_or_else(|_| format!("variables.{format}")); + let Some(p) = app.services.pick_save("Export variables", &name, &[format]) else { return }; + if app.run("variable.export", json!({"format": format, "path": p.to_string_lossy()})).is_some() { + app.toast(format!("Exported {}", p.file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or_default())); + } +} + +/// Import a variables JSON picked in an open dialog. +fn import_dialog(app: &mut FigApp) { + let Some(p) = app.services.pick_open("Import variables", &["json"]) else { return }; + let Some(r) = app.run("variable.import", json!({"variables": p.to_string_lossy()})) else { return }; + let (added, updated) = (r["variables"].as_u64().unwrap_or(0), r["updated"].as_u64().unwrap_or(0)); + let skipped = r["warnings"].as_array().map_or(0, Vec::len); + let note = if skipped > 0 { format!(" ({skipped} warnings: some entries were skipped)") } else { String::new() }; + app.toast(format!("Imported {added} new variables, updated {updated}{note}")); +} + +/// Extend library `lib`'s collection `c` and show the new collection. +fn extend(app: &mut FigApp, lib: &str, c: &str, st: &mut Editor) { + if let Some(id) = app.run("library.extendCollection", json!({"library": lib, "collection": c})).and_then(|r| r["id"].as_str().map(str::to_string)) + { + st.library = None; + st.collection = Some(id); + st.group = None; + st.query.clear(); + } +} + fn rename_id(target: &Rename) -> Id { Id::new(("var_rename", target)) } @@ -291,6 +339,13 @@ fn collection_row(app: &mut FigApp, ui: &mut Ui, view: &View, c: &Collection, st ui.close(); } }); + } else if let Some(lib) = view.id() { + crate::right::context_popup(&resp).show(|ui| { + if menu_item(ui, "Extend collection", "") { + extend(app, lib, &c.id, st); + ui.close(); + } + }); } if selected { group_rows(app, ui, view, c, st); @@ -464,10 +519,22 @@ fn table(app: &mut FigApp, ui: &mut Ui, view: &View, st: &mut Editor) { .font(regular(11.0)) .vertical_align(egui::Align::Center), ); - if let Some((_, name)) = view.library { - let note = Rect::from_min_max(pos2(field.max.x + 16.0, sr.min.y), pos2(sr.max.x - 16.0, sr.max.y)); - let text = format!("From the library \"{name}\": read-only"); - ui.painter().with_clip_rect(note).text(note.right_center(), Align2::RIGHT_CENTER, text, regular(11.0), t.text_secondary); + let note = Rect::from_min_max(pos2(field.max.x + 16.0, sr.min.y + 8.0), pos2(sr.max.x - 16.0, sr.max.y - 8.0)); + let mut bar = ui.new_child(UiBuilder::new().max_rect(note).layout(egui::Layout::right_to_left(egui::Align::Center))); + let text = match (view.library, coll.extends.as_deref().and_then(|p| view.vars.collection(p))) { + (Some((lib, name)), _) => { + let tip = "A collection of this file with these modes and variables: override their values per mode and add variables of its own"; + if widgets::button(&mut bar, "Extend collection", false, true).on_hover_text(tip).clicked() { + extend(app, lib, &coll.id, st); + } + bar.add_space(8.0); + Some(format!("From the library \"{name}\": read-only")) + } + (None, Some(parent)) => Some(format!("Extends \"{}\": overridden values are marked", parent.name)), + _ => None, + }; + if let Some(text) = text { + bar.add(egui::Label::new(egui::RichText::new(text).font(regular(11.0)).color(t.text_secondary)).truncate()); } let q = st.query.trim().to_lowercase(); let in_group = |v: &Variable| st.group.as_ref().is_none_or(|g| v.name.starts_with(&format!("{g}/"))); @@ -502,20 +569,22 @@ fn table(app: &mut FigApp, ui: &mut Ui, view: &View, st: &mut Editor) { fn header_row(app: &mut FigApp, ui: &mut Ui, view: &View, coll: &Collection, st: &mut Editor, width: f32) { let t = Tokens::get(ui.ctx()); + // An extended collection has the modes of the one it extends. + let editable = view.editable() && coll.extends.is_none(); let (r, _) = ui.allocate_exact_size(vec2(width, 32.0), Sense::hover()); ui.painter().text(pos2(r.min.x + 16.0, r.center().y), Align2::LEFT_CENTER, "Name", regular(11.0), t.text_secondary); for (i, m) in coll.modes.iter().enumerate() { let x = r.min.x + NAME_W + i as f32 * MODE_W; let cell = Rect::from_min_max(pos2(x + 4.0, r.min.y + 4.0), pos2(x + MODE_W - 8.0, r.max.y - 4.0)); let target = Rename::Mode(coll.id.clone(), m.id.clone()); - if view.editable() && st.rename.as_ref() == Some(&target) { + if editable && st.rename.as_ref() == Some(&target) { if let Some(name) = rename_field(ui, st, &target, &m.name, cell.shrink2(vec2(4.0, 2.0)), regular(11.0)) { app.run("variable.renameMode", json!({"collection": coll.id, "mode": m.id, "name": name})); } continue; } let name_pos = pos2(cell.min.x + 4.0, cell.center().y); - if !view.editable() { + if !editable { ui.painter().with_clip_rect(cell).text(name_pos, Align2::LEFT_CENTER, &m.name, regular(11.0), t.text_secondary); continue; } @@ -539,7 +608,7 @@ fn header_row(app: &mut FigApp, ui: &mut Ui, view: &View, coll: &Collection, st: }); } let x = r.min.x + NAME_W + coll.modes.len() as f32 * MODE_W; - if view.editable() + if editable && widgets::icon_button_at( ui, Id::new("vars_add_mode"), @@ -567,6 +636,8 @@ fn var_row(app: &mut FigApp, ui: &mut Ui, view: &View, coll: &Collection, v: &Va kind_glyph(ui, view.vars, v, coll, Rect::from_center_size(pos2(r.min.x + 24.0, r.center().y), vec2(14.0, 14.0))); let name_r = Rect::from_min_max(pos2(r.min.x + 40.0, r.min.y + 8.0), pos2(r.min.x + NAME_W - 8.0, r.max.y - 8.0)); let target = Rename::Variable(v.id.clone()); + // An inherited variable is named (and deleted) in the collection it extends. + let inherited = v.extends.is_some(); if editable && st.rename.as_ref() == Some(&target) { if let Some(name) = rename_field(ui, st, &target, &v.name, name_r, regular(11.0)) { app.run("variable.update", json!({"variable": v.id, "name": name})); @@ -582,7 +653,7 @@ fn var_row(app: &mut FigApp, ui: &mut Ui, view: &View, coll: &Collection, v: &Va value_cell(app, ui, view, v, coll, &m.id, cell); } if editable { - if resp.double_clicked() && ui.input(|i| i.pointer.interact_pos()).is_some_and(|p| p.x < name_r.max.x) { + if !inherited && resp.double_clicked() && ui.input(|i| i.pointer.interact_pos()).is_some_and(|p| p.x < name_r.max.x) { start_rename(ui, st, target.clone()); } crate::right::context_popup(&resp).show(|ui| { @@ -590,15 +661,19 @@ fn var_row(app: &mut FigApp, ui: &mut Ui, view: &View, coll: &Collection, v: &Va st.details = Some(v.id.clone()); ui.close(); } - if menu_item(ui, "Rename", "") { + if !inherited && menu_item(ui, "Rename", "") { start_rename(ui, st, target.clone()); ui.close(); } + if !v.overrides.is_empty() && menu_item(ui, "Reset overrides", "") { + app.run("variable.resetOverride", json!({"variable": v.id})); + ui.close(); + } if menu_item(ui, "Duplicate", "") { app.run("variable.duplicate", json!({"variable": v.id})); ui.close(); } - if menu_item(ui, "Delete variable", "") { + if !inherited && menu_item(ui, "Delete variable", "") { app.run("variable.delete", json!({"variable": v.id})); ui.close(); } @@ -652,10 +727,23 @@ fn value_cell(app: &mut FigApp, ui: &mut Ui, view: &View, v: &Variable, coll: &C let Some(val) = v.values.get(mode) else { return }; let editable = view.editable(); let id = Id::new(("var_cell", &v.id, mode)); - // The value editor leaves room for the alias button. - let body = Rect::from_min_max(rect.min, pos2(rect.max.x - 26.0, rect.max.y)); + // The value editor leaves room for the alias button (and in extended collections an + // override's reset button, so the column stays even). + let overridden = v.overrides.contains(mode); + let body = Rect::from_min_max(rect.min, pos2(rect.max.x - if coll.extends.is_some() { 50.0 } else { 26.0 }, rect.max.y)); let alias_btn = Rect::from_center_size(pos2(rect.max.x - 11.0, rect.center().y), vec2(22.0, 22.0)); let hovered = editable && ui.rect_contains_pointer(rect.expand2(vec2(4.0, 6.0))); + if overridden { + let mark = Rect::from_min_size(pos2(rect.min.x - 4.0, rect.min.y + 3.0), vec2(2.0, rect.height() - 6.0)); + ui.painter().rect_filled(mark, CornerRadius::same(1), t.accent); + ui.interact(mark.expand2(vec2(2.0, 0.0)), id.with("mark"), Sense::hover()) + .tip("Overridden here (the collection it extends has another value)"); + if hovered + && widgets::icon_button_at(ui, id.with("reset"), alias_btn.translate(vec2(-24.0, 0.0)), Icon::Reset, "Reset to the inherited value") + { + app.run("variable.resetOverride", json!({"variable": v.id, "mode": mode})); + } + } if let VariableValue::Alias(target) = val { let full = view.vars.get(target).map(|x| x.name.as_str()).unwrap_or("Missing variable"); let resp = ui.interact(body, id.with("alias"), if editable { Sense::click() } else { Sense::hover() }); diff --git a/docs/components-parity.md b/docs/components-parity.md index 9d9c4f1e..db8898e9 100644 --- a/docs/components-parity.md +++ b/docs/components-parity.md @@ -1,11 +1,11 @@ # Components, variants, styles and variables parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (created from the ROADMAP's components and variables items) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (extended collections, variables import/export, gradient stops and variant properties bound to variables) · **Target:** Figma Design (desktop app 126.10.7) Design systems are why teams adopt Figma. **Components: breadth ~95%, ready ~68%. Styles and -variables: breadth ~85%, ready ~70% (estimated).** QA'd with a synthetic design system of ~300 +variables: breadth ~92%, ready ~72% (estimated).** QA'd with a synthetic design system of ~300 components used 2,000 times (`crates/engine/src/synthetic_ds.rs`, `components_scale_tests.rs`); -not with real libraries. Remaining: 10–20 h here, plus shared libraries (10–20 h after +not with real libraries. Remaining: 7–14 h here, plus shared libraries (10–20 h after multiplayer). ## Components and variants @@ -42,13 +42,14 @@ multiplayer). | Variables: colour, number, string, boolean; collections, modes, aliases, scopes, groups | done | | | Bindings: fills, strokes, effects, opacity, padding, gaps, sizes, radius, font size, line height, letter spacing, text, visibility, layout grids, instance properties | done | | | Explicit modes per frame, library collections' modes | done | | -| Gradient stops and variant properties bound to variables | missing | | -| Extended collections | missing | [gaps](gaps.md) #14 | -| Variables import/export (JSON) | missing | REST variables JSON is read on import | +| 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; overrides per mode with reset; follow library updates; modes inherited. New library variables arrive with the next other update ([gaps](gaps.md) #14) | +| Variables import/export (JSON) | done | the REST local variables shape (round-trips; merges by collection, mode and variable name); CSS custom properties export | | Expressions in prototype set-variable actions | done | see [prototyping-parity](prototyping-parity.md) | ## Revision history | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | Extended collections, variables import/export (JSON, CSS) and gradient stop and variant property bindings landed: styles and variables breadth ~85% → ~92%, ready ~70% → ~72%; remaining here 10–20 h → 7–14 h | | 2026-10-10 | major | Created from the 2026-10-10 re-measure | diff --git a/docs/gaps.md b/docs/gaps.md index 463f4795..f86d102e 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where FigCraft falls short of Figma -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (the standalone server with share links to edit or view, wording only; live boolean editing and the vector tail: feature breadth ~79%) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail) · **Target:** Figma Design (desktop app 126.10.7) Every known shortfall against Figma, one entry each, ranked by how much it stops a Figma user from doing real work in FigCraft. Agents: pick work from the top unless the owner says otherwise. The @@ -27,7 +27,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe | 11 | Spell check | Feature | 4–8 | [target-app-parity](target-app-parity.md) | | 12 | Video and animated GIF fills; HEIC/TIFF images | File format | 6–12 | [file-format-parity](file-format-parity.md) | | 13 | Boolean groups hit-tested by their operands (the rest of the vector tail landed) | Feature | 1–3 | [target-app-parity](target-app-parity.md) | -| 14 | Variables: extended collections, import/export | Feature | 4–8 | [components-parity](components-parity.md) | +| 14 | Variables: extended collections' tail | Feature | 1–2 | [components-parity](components-parity.md) | | 15 | Prototyping: media triggers and actions, gamepad | Feature | 3–6 | [prototyping-parity](prototyping-parity.md) | | 16 | Dev Mode: Code Connect, editor integrations | Feature | 4–8 | [dev-mode-parity](dev-mode-parity.md) | | 17 | Branching and merging | Feature | 8–15 | [collaboration-parity](collaboration-parity.md) | @@ -176,10 +176,16 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe a subtracted hole still picks the group. - **Estimate:** 1–3 h. -### 14. Variables: extended collections, import/export -- **Missing:** extended collections (a library collection extended with overrides), variables - import/export, variant properties and gradient stops bound to variables. -- **Estimate:** 4–8 h. +### 14. Variables: extended collections' tail +- **Landed:** extended collections (`library.extendCollection`: a library collection's modes and + variables in a collection of this file, values overridden per mode with reset, following library + updates, variables of its own), variables import/export (`variable.export` / `variable.import`: + the REST local variables JSON, merged by name; CSS custom properties), gradient stops and variant + properties bound to variables. +- **Missing:** variables a library adds to an extended collection arrive only with the library's + next other update (they don't count as an update of their own); an extended collection can't be + extended again; checked with synthetic files only. +- **Estimate:** 1–2 h. ### 15. Prototyping: media triggers and actions, gamepad - **Missing:** video triggers (on media hit/end) and play/pause/mute/seek actions; gamepad input. @@ -230,6 +236,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | Extended collections, variables import/export and gradient stop and variant property bindings landed (gap 14): Styles & variables breadth 85% → 92%, ready 70% → 72%, 4–8 h → 1–2 h; feature breadth unchanged (~79%); ready for real work unchanged (~47%); full parity ~385–730 h → ~380–725 h | | 2026-10-11 | minor | Collaboration wording after the standalone server: `figcraft-server` keeps many files, persisted as a snapshot plus a log of what changed (compacted, checksummed, damaged files skipped and reported), restarts lose nothing confirmed and apps resume where they were; the app publishes its file to it; share links per file let people edit or only view (the server refuses a viewer's edits), are revocable and stored as hashes; TLS comes from a reverse proxy (the desktop app has none of its own yet); percentages and hours unchanged until the next re-measure | | 2026-10-11 | minor | Live boolean editing, Design panel arc fields, the shape builder on strokes and open paths and one-sided variable widths landed (gap 13): Shapes & vector breadth 92% → 97%, ready 66% → 68%, 8–14 h → 1–3 h; feature breadth ~78% → ~79%; ready for real work unchanged (~47%); full parity ~390–745 h → ~385–730 h | | 2026-10-11 | minor | Collaboration wording after the collaboration polish: styles, variables, collections, modes and comments created in a live session take ids from their creator's id space (concurrent creations no longer overwrite each other; the server checks it), others' text carets and selections show on the canvas, and typing in a layer someone else is typing in says so; percentages unchanged | diff --git a/docs/mcp.md b/docs/mcp.md index 74abd6d5..23a19c73 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -331,6 +331,16 @@ name and sets them this way); `library.importVariable {library, variable}` only copies it (then `variable.bind`, `variable.set {value: {alias}}`). The copies render without the library file. Editing the copies (library components, styles, variables or collections) is refused; edit the library file and publish. +- **Extended collections**: `library.extendCollection {library, collection, name?}` → `{id}` makes + a collection of this file (default name " (extended)") with the library collection's modes + and one inheriting variable per variable of it. `variable.set` on an inheriting variable + overrides its value in that mode (the variable lists it in `overrides`); + `variable.resetOverride {variable, mode?}` goes back to the inherited value (every mode when + `mode` is left out). Variables of its own come from `variable.create`. Modes, and the names of + inheriting variables, come from the library collection (renaming, adding or deleting them is + refused); accepting library updates brings new values (overrides stay), renames and new + variables. The Variables editor offers Extend collection on a library collection and marks + overridden values, with a reset button. - **Update**: `library.updates` lists the components, styles, variable collections and variables with a newer published version; `library.acceptUpdates {ids? (library ids)}` pulls them in, keeping instance overrides; bound layers, aliases and explicit modes follow the updated values. @@ -465,6 +475,24 @@ visibility, text, font properties, single solid fills and strokes) and `explicit link to them, so switching a mode with `variable.setMode` restyles the layers (`mode: null` sets a frame back to Auto, inheriting its parent's mode). +The same JSON carries variables between FigCraft files: `variable.export {collections?, format?, +path?}` writes this file's collections (default: all but library copies) as a local variables +response (`format: "json"`, the default) or as CSS custom properties (`format: "css"`: the default +mode on `:root`, each other mode on `[data-=""]`, aliases as `var(--name)`), and +returns `{text}` (or writes `path` and returns `{path}`). `variable.import {variables}` (an object, +JSON text or a path) reads one back: a collection joins this file's collection of the same name +(values matched by mode name, new modes and variables added, a same-named variable of another type +skipped with a warning; library copies and extended collections are never targets), others are +added; aliases are checked for loops; it returns `{collections, variables, updated, warnings}`. The +Variables editor's Import… and Export… buttons do the same through file dialogs. + +`variable.bind` also binds gradient stops (`fills//stops//color`, `strokes//stops//color`; +the binding follows its stop when an earlier stop is removed and its paint when the list is +reordered) and variant properties of instances (`componentProperties/` with a string +variable: the instance shows the variant its value names; a value naming no variant changes +nothing). The colour picker binds the selected stop; the Design panel's variant dropdowns take a +string variable. + ### Comments Comment threads live in the file, pinned to a page point or to a layer (`anchor`: a node id, or diff --git a/docs/roadmap.md b/docs/roadmap.md index 242adaa5..e1851f3d 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-11 · **Change:** minor (the standalone server with share links to edit or view, wording only; live boolean editing and the vector tail: feature breadth ~79%) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~79%) · **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 @@ -49,7 +49,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) | ~75% | Open recent, outline mode variants, keyboard box selection, interaction pass | 8–14 + pass | | M2 Layout depth | ~80% | grid `fr` rows, real-file QA | 6–12 | -| M3 Components & styles depth | ~75% | extended collections, variables import/export, real libraries | 10–20 | +| 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 | | M4 Vector & text depth | ~75% (live booleans, arc fields, shape builder on strokes, one-sided widths landed 2026-10-11) | spell check, Figma Draw (brushes, pressure, text on a path, patterns), boolean hit testing on the result | 18–32 | | M5 Prototype & present | ~85% | media, gamepad, separate window | 5–9 | | M6 Interop & Dev Mode | ~55% | `.fig` import, video/HEIC/TIFF, CMYK intent, Code Connect | 40–80 | @@ -71,6 +71,7 @@ blockers. | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | Extended collections, variables import/export and gradient stop and variant property bindings landed (gap 14): Styles & variables breadth 85% → 92%, ready 70% → 72%, 4–8 h → 1–2 h; feature breadth unchanged (~79%); ready for real work unchanged (~47%); full parity ~385–730 h → ~380–725 h | | 2026-10-11 | minor | Collaboration wording after the standalone server: `figcraft-server` keeps many files, persisted as a snapshot plus a log of what changed (compacted, checksummed, damaged files skipped and reported), restarts lose nothing confirmed and apps resume where they were; the app publishes its file to it; share links per file let people edit or only view (the server refuses a viewer's edits), are revocable and stored as hashes; TLS comes from a reverse proxy (the desktop app has none of its own yet); percentages and hours unchanged until the next re-measure | | 2026-10-11 | minor | Live boolean editing, Design panel arc fields, the shape builder on strokes and open paths and one-sided variable widths landed (gap 13): Shapes & vector breadth 92% → 97%, ready 66% → 68%, 8–14 h → 1–3 h; feature breadth ~78% → ~79%; ready for real work unchanged (~47%); full parity ~390–745 h → ~385–730 h | | 2026-10-11 | minor | Collaboration wording after the collaboration polish: styles, variables, collections, modes and comments created in a live session take ids from their creator's id space (concurrent creations no longer overwrite each other; the server checks it), others' text carets and selections show on the canvas, and typing in a layer someone else is typing in says so; percentages unchanged | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index f4387ce3..f985922a 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # FigCraft parity with Figma -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (the standalone server with share links to edit or view, wording only; live boolean editing and the vector tail: feature breadth ~79%; ready ~47%; last major: full re-measure against Figma desktop 126.10.7) · **Target:** Figma Design (desktop app 126.10.7, macOS) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~79%; ready ~47%; last major: full re-measure against Figma desktop 126.10.7) · **Target:** Figma Design (desktop app 126.10.7, macOS) The authoritative assessment of how close FigCraft is to Figma Design. [`ROADMAP.md`](../ROADMAP.md) summarizes it; [`gaps.md`](gaps.md) is the ranked work list; the measured menu checklist is @@ -19,9 +19,9 @@ summarizes it; [`gaps.md`](gaps.md) is the ranked work list; the measured menu c | Feature breadth (does each Figma Design feature exist, weighted) | **~79%** (core editor areas 1–16: ~90%) | estimated from the area table below | | Ready for real work | **~47%** | estimated (dimension table below: written weights × written per-dimension scores) | | Essentials user | **~53%** | estimated ([below](#essentials-user): core features 77.8% × discounts 0.92 × 0.92 × 0.8) | -| Mainstream practitioner | **~43%** | estimated ([below](#mainstream-practitioner): weekly areas 66.7% × discounts 0.9 × 0.9 × 0.8) | +| Mainstream practitioner | **~43%** | estimated ([below](#mainstream-practitioner): weekly areas 66.8% × discounts 0.9 × 0.9 × 0.8) | | Remaining to beta | **~200–365 h** one Opus 5.5 agent (~40–80 h wall-clock with 5 parallel agents) | estimated | -| Remaining to full parity | **~385–730 h** one agent (~95–165 h wall-clock with 5 parallel agents) | estimated | +| Remaining to full parity | **~380–725 h** one agent (~95–165 h wall-clock with 5 parallel agents) | estimated | | Stage | **alpha**: the alpha gate passes ([roadmap](roadmap.md#alpha-gate)); `.fig` import and multiplayer are beta gaps (see [ROADMAP](../ROADMAP.md#stage)) | | The previous figures (ROADMAP of 2026-10-10, "feature depth ~79%", "full parity 115–195 h") were @@ -35,8 +35,8 @@ tool; presence was being graded as behaviour in places. | Audience | Ready % | Opus 5.5 agent wall-clock hours to ~95% | Work that dominates | |---|---|---|---| -| Full target (ready for real work) | ~47% | ~370–695 h | multiplayer (45–90), localization (60–100), plugins (25–55), the interaction pass (40–60), `.fig` import (30–60), GPU canvas (30–60), AI (30–60) | -| Mainstream practitioner | ~43% | ~175–320 h | core editor depth incl. `.fig` import (91–177), the interaction pass (40–60), real-file corpus and platform QA (25–50), `.fig` export for handing work back (15–30) | +| Full target (ready for real work) | ~47% | ~365–690 h | multiplayer (45–90), localization (60–100), plugins (25–55), the interaction pass (40–60), `.fig` import (30–60), GPU canvas (30–60), AI (30–60) | +| Mainstream practitioner | ~43% | ~170–315 h | core editor depth incl. `.fig` import (88–171), the interaction pass (40–60), real-file corpus and platform QA (25–50), `.fig` export for handing work back (15–30) | | Essentials user | ~53% | ~85–160 h | `.fig` import so files people send open (30–60), core tool polish (25–45), interaction pass on the core tools (15–25), stability QA (10–20), onboarding and starter templates (5–10) | Hours are one agent working sequentially, calibrated from this repo's history (about 1 agent-hour @@ -105,7 +105,7 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects | 9 | Auto layout & constraints | 9 | 95% | 68% | Grid `fr` rows around fixed children; behaviour against real Figma files ([auto-layout-parity](auto-layout-parity.md)) | 6–12 | | 10 | Text | 8 | 85% | 64% | Spell check, text on a path, CJK text QA, real-file text metrics | 8–14 | | 11 | Components & variants | 9 | 95% | 68% | Real (not synthetic) libraries, `.fig` libraries, component playground ([components-parity](components-parity.md)) | 6–12 | -| 12 | Styles & variables | 5 | 85% | 70% | Extended collections, variables import/export, gradient stops and variant properties bound | 4–8 | +| 12 | Styles & variables | 5 | 92% | 72% | New library variables reaching extended collections on their own, extending an extended collection, real-library QA (extended collections, variables import/export, gradient stops and variant properties bound landed) | 1–2 | | 13 | Prototyping | 6 | 88% | 70% | Video triggers and media actions, gamepad triggers, real prototype QA ([prototyping-parity](prototyping-parity.md)) | 5–9 | | 14 | Import/export | 6 | 65% | 35% | **`.fig` import** (the blocking gap), `.fig` export, HEIC/TIFF/video import, CMYK output intent ([file-format-parity](file-format-parity.md)) | 30–60 | | 15 | Dev Mode / inspect | 2 | 80% | 62% | Code Connect, plugins in Dev Mode, editor integrations ([dev-mode-parity](dev-mode-parity.md)) | 4–8 | @@ -115,14 +115,14 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects | 19 | Team libraries & organization | 2 | 40% | 25% | Local library files exist; no shared/cloud library, no library analytics | 10–20 | | 20 | Figma Draw extras | 2 | 45% | 30% | Have: variable width, lasso, shape builder, noise/texture. Missing: brushes (stretch/scatter), pressure, text on a path, pattern fills, multi-edit | 10–18 | | 21 | AI features | 3 | 0% | 0% | First draft, rename layers, image edit/remove background, visual search, Make: needs local models (owner decision) | 30–60 | -| | **Weighted (÷113)** | 113 | **~79%** | **~57%** | | **211–420** | -| | Core editor only (1–16, ÷94) | 94 | ~90% | ~67% | | 91–177 | +| | **Weighted (÷113)** | 113 | **~79%** | **~57%** | | **208–414** | +| | Core editor only (1–16, ÷94) | 94 | ~90% | ~67% | | 88–171 | ## By dimension (ready for real work) | Dimension | Weight | Ready | Hours | Evidence | Doc | |---|---|---|---|---|---| -| Features (depth) | 35 | 57% | 71–135 (areas 1–13, 15, 16, 20) | The table above; core editor ~67% | this file | +| Features (depth) | 35 | 57% | 68–129 (areas 1–13, 15, 16, 20) | The table above; core editor ~67% | this file | | UI/UX fidelity | 15 | 58% | 40–60 | Look & feel compared side by side with UI3 (~65% by the builders); tool-by-tool interaction (cursors, modifiers while dragging, hover states, popovers) not yet compared; accessibility names and keyboard navigation landed | [ui-parity](ui-parity.md) | | File formats | 12 | 40% | 30–60 | No `.fig` read or write; REST JSON import, SVG in/out, PNG/JPG/WebP/PDF export solid | [file-format-parity](file-format-parity.md) | | Collaboration | 10 | 12% | 55–110 | Local comments and version history; the multiplayer core (sync model, server ordering, client reconciliation, convergence tests) landed in #103, and live sessions (preview) co-edit a file hosted by a desktop app or kept on a self-hosted `figcraft-server` with edit and view links, joined from the app or the browser (`docs/collab-protocol.md`) | [collaboration-parity](collaboration-parity.md) | @@ -133,7 +133,7 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects | Platforms | 3 | 75% | 10–20 | macOS, Windows (x64/x86/arm64), Linux, FreeBSD, web all packaged (v0.2.0); Figma also has iOS/Android apps; non-macOS QA thin | [hardware-parity](hardware-parity.md) | | Ecosystem/plugins | 3 | 20% | 25–55 | Own WASM plugins with a Rust SDK and two examples (#101); no Figma plugin compatibility, no widgets, no distribution or community files; the CLI, control channel and MCP server are ahead of Figma for agents | [gaps](gaps.md) | | AI features | 2 | 0% | 30–60 | None | [gaps](gaps.md) | -| **Ready for real work** | 100 | **~47%** | **~385–730** | | | +| **Ready for real work** | 100 | **~47%** | **~380–725** | | | Dimension hours add up to the full-parity total without double counting: the feature-area rows for import/export (14), multiplayer and team libraries (17, 19), plugins (18) and AI (21) are @@ -187,7 +187,7 @@ libraries (19, cloud/team admin), Figma Draw extras (20, not weekly for a produc localization beyond English, specialist hardware. Real-time multiplayer (17) is cloud collaboration and is left out as an area, but its effect on working with Figma users is counted in discount 3. -**2. Average depth** ("Ready" column, weighted): **66.7%**. +**2. Average depth** ("Ready" column, weighted): **66.8%**. **3. Discounts for what still stops real work:** @@ -197,7 +197,7 @@ and is left out as an area, but its effect on working with Figma users is counte | Stability on real machines | ×0.90 | Never-crash lints, command guard with rollback, recovery copies and 963 tests, but no field record at all: the repository is private, v0.2.0 has had no outside users or issue reports, CI tests run on Linux only and hands-on QA is on macOS. Harsher than VectorCraft's ×0.92 because there is no outside evidence either way | | File exchange with Figma users | ×0.80 | A Figma designer's work lives in shared Figma files. FigCraft can't open `.fig` or write anything Figma opens as a document; incoming work can come through the REST API JSON (one way, needs an access token), outgoing only as SVG, PDF, PNG or code. Teammates, reviewers and developers would all need FigCraft ([file-format-parity](file-format-parity.md), [collaboration-parity](collaboration-parity.md)) | -66.7% × 0.90 × 0.90 × 0.80 = **~43%** (estimated). +66.8% × 0.90 × 0.90 × 0.80 = **~43%** (estimated). **4. User evidence (2026-10-10):** 0 issues, 0 praise or "switched from Figma" reports, 0 comments from anyone but the maintainers (`gh issue list`, `gh api .../issues/comments`; the repository is @@ -577,6 +577,7 @@ Carried over from the ROADMAP's "Shipped so far" (2026-10-10). The test count th | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | Extended collections, variables import/export and gradient stop and variant property bindings landed (gap 14): Styles & variables breadth 85% → 92%, ready 70% → 72%, 4–8 h → 1–2 h; feature breadth unchanged (~79%); ready for real work unchanged (~47%); full parity ~385–730 h → ~380–725 h | | 2026-10-11 | minor | Collaboration wording after the standalone server: `figcraft-server` keeps many files, persisted as a snapshot plus a log of what changed (compacted, checksummed, damaged files skipped and reported), restarts lose nothing confirmed and apps resume where they were; the app publishes its file to it; share links per file let people edit or only view (the server refuses a viewer's edits), are revocable and stored as hashes; TLS comes from a reverse proxy (the desktop app has none of its own yet); percentages and hours unchanged until the next re-measure | | 2026-10-11 | minor | Live boolean editing, Design panel arc fields, the shape builder on strokes and open paths and one-sided variable widths landed (gap 13): Shapes & vector breadth 92% → 97%, ready 66% → 68%, 8–14 h → 1–3 h; feature breadth ~78% → ~79%; ready for real work unchanged (~47%); full parity ~390–745 h → ~385–730 h | | 2026-10-11 | minor | Wording only: live sessions (#104), then the browser joining them and `figcraft://` invite links opening the desktop app; percentages and hours unchanged until the next re-measure |