diff --git a/ROADMAP.md b/ROADMAP.md index 63dd370..0bd801f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (core editor leftovers: inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; core editor leftovers: command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; core editor leftovers: a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; core editor leftovers: delays on mouse triggers and manual overlay offsets, wrapped-line alignment; core editor leftovers: wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (core editor leftovers: variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; core editor leftovers: inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; core editor leftovers: command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; core editor leftovers: a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; core editor leftovers: delays on mouse triggers and manual overlay offsets, wrapped-line alignment; core editor leftovers: wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7) FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow, native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through @@ -131,6 +131,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). ## Progress log +- **2026-10-13** · Variables inside styles (M3): a shared style can bind variables to fields of its value (`style.bind {style, field, variable?}`: a paint style's colours and gradient stops, an effect style's colour, radius, spread and offsets, a grid style's size, count, gutter and offset, a text style's font size, line height, letter spacing, family and style). Every layer using the style shows those fields resolved in its own modes and stays linked when a variable or a mode changes; the style keeps them resolved in the default modes. A style made from a layer keeps that layer's bindings; a new value drops bindings to what is gone. The style editor binds from its property rows (paint colour, shadow colour, blur radius, grid size) and detaches from the variable's chip; the style picker marks styles that use variables. `.fig` export and import carry the bindings on the style, REST layers hand theirs to the style they use. Left: binding text style fields and the other effect and grid fields from the editor (commands only), library variables in the picker, a style's library copy. Numbers unchanged. - **2026-10-13** · Effects on groups, inner shadows and background blur (M3): a group is no outline of its own, so its inner shadows are cast inwards from around what its layers draw together (their pixels, over them: no shade at a seam between two layers) and its background blur and glass show the backdrop through the outlines of its layers (under them). Canvas and PNG; SVG embeds a group with an inner shadow as a render and its background blur as an image under the layers; PDF places both as images, the blur matching the PNG. Where the group holds one shape the pixels match that shape, or a frame filled like it, carrying the effect (`group_effects_tests` in render, svg and pdf). Offsets and spreads of a group's inner shadow go by whole pixels. Numbers unchanged. - **2026-10-13** · Text made by commands on a dark page (M1): in the app, `text.create` and `node.create {type: text}` that name no fill give white text on a dark page, by the same rule as the Text tool (the page's colour decides, also inside a frame); a named `fill`, `fills` or `fillStyle` is kept. Sessions without an interface (CLI, MCP, exports) still make black text everywhere. Numbers unchanged. - **2026-10-13** · Frames and groups, a one-step convert (M1): `object.convertToGroup` and `object.convertToFrame` (the Design panel's type menu on a frame or a group) change the layer in place, its layers staying where they are on the page. A frame turned into a group loses what only a frame has (auto layout, with the positions it gave kept and fill children fixed; fills, strokes, corner radius, clipping, layout grids, guides, scrolling) and takes its layers' box; a group turned into a frame keeps its box, with no fill and no clipping. Instances, main components, layers inside instances and empty frames are refused with the reason, all or nothing; one undo step. Numbers unchanged. @@ -302,6 +303,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-13 | minor | Variables inside styles (M3): a shared style can bind variables to fields of its value (`style.bind {style, field, variable?}`: a paint style's colours and gradient stops, an effect style's colour, radius, spread and offsets, a grid style's size, count, gutter and offset, a text style's font size, line height, letter spacing, family and style). Every layer using the style shows those fields resolved in its own modes and stays linked when a variable or a mode changes; the style keeps them resolved in the default modes. A style made from a layer keeps that layer's bindings; a new value drops bindings to what is gone. The style editor binds from its property rows (paint colour, shadow colour, blur radius, grid size) and detaches from the variable's chip; the style picker marks styles that use variables. `.fig` export and import carry the bindings on the style, REST layers hand theirs to the style they use. Left: binding text style fields and the other effect and grid fields from the editor (commands only), library variables in the picker, a style's library copy. Numbers unchanged. | | 2026-10-13 | minor | Effects on groups, inner shadows and background blur (M3): a group is no outline of its own, so its inner shadows are cast inwards from around what its layers draw together (their pixels, over them: no shade at a seam between two layers) and its background blur and glass show the backdrop through the outlines of its layers (under them). Canvas and PNG; SVG embeds a group with an inner shadow as a render and its background blur as an image under the layers; PDF places both as images, the blur matching the PNG. Where the group holds one shape the pixels match that shape, or a frame filled like it, carrying the effect (`group_effects_tests` in render, svg and pdf). Offsets and spreads of a group's inner shadow go by whole pixels. Numbers unchanged. | | 2026-10-13 | minor | Text made by commands on a dark page (M1): in the app, `text.create` and `node.create {type: text}` that name no fill give white text on a dark page, by the same rule as the Text tool (the page's colour decides, also inside a frame); a named `fill`, `fills` or `fillStyle` is kept. Sessions without an interface (CLI, MCP, exports) still make black text everywhere. Numbers unchanged. | | 2026-10-13 | minor | Frames and groups, a one-step convert (M1): `object.convertToGroup` and `object.convertToFrame` (the Design panel's type menu on a frame or a group) change the layer in place, its layers staying where they are on the page. A frame turned into a group loses what only a frame has (auto layout, with the positions it gave kept and fill children fixed; fills, strokes, corner radius, clipping, layout grids, guides, scrolling) and takes its layers' box; a group turned into a frame keeps its box, with no fill and no clipping. Instances, main components, layers inside instances and empty frames are refused with the reason, all or nothing; one undo step. Numbers unchanged. | diff --git a/crates/collab/src/diff_tests.rs b/crates/collab/src/diff_tests.rs index 0bda561..6141be2 100644 --- a/crates/collab/src/diff_tests.rs +++ b/crates/collab/src/diff_tests.rs @@ -109,7 +109,13 @@ fn kind_changes_and_transforms_round_trip() { } fn style(id: &str) -> Style { - Style { id: id.into(), name: format!("S {id}"), description: String::new(), kind: StyleKind::Paint { paints: vec![Paint::solid(Color::BLACK)] } } + Style { + id: id.into(), + name: format!("S {id}"), + description: String::new(), + kind: StyleKind::Paint { paints: vec![Paint::solid(Color::BLACK)] }, + bound: Default::default(), + } } #[test] diff --git a/crates/collab/src/entry_ids_tests.rs b/crates/collab/src/entry_ids_tests.rs index 4d6151e..db58bb2 100644 --- a/crates/collab/src/entry_ids_tests.rs +++ b/crates/collab/src/entry_ids_tests.rs @@ -10,7 +10,7 @@ use crate::sim::Sim; use crate::{Client, Limits, NoRecompute, Outgoing, Server}; fn style(id: String, name: &str) -> Style { - Style { id, name: name.into(), description: String::new(), kind: StyleKind::Paint { paints: Vec::new() } } + Style { id, name: name.into(), description: String::new(), kind: StyleKind::Paint { paints: Vec::new() }, bound: Default::default() } } fn comment(id: u64, doc: &Document) -> Comment { diff --git a/crates/collab/src/merge_tests.rs b/crates/collab/src/merge_tests.rs index 2a517f6..8894445 100644 --- a/crates/collab/src/merge_tests.rs +++ b/crates/collab/src/merge_tests.rs @@ -27,7 +27,13 @@ fn files() -> Files { } fn style(id: &str, c: Color) -> Style { - Style { id: id.into(), name: format!("Style {id}"), description: String::new(), kind: StyleKind::Paint { paints: vec![Paint::solid(c)] } } + Style { + id: id.into(), + name: format!("Style {id}"), + description: String::new(), + kind: StyleKind::Paint { paints: vec![Paint::solid(c)] }, + bound: Default::default(), + } } fn plan_of(f: &Files) -> Plan { diff --git a/crates/collab/src/sim_tests.rs b/crates/collab/src/sim_tests.rs index 93fcc2c..b330e4a 100644 --- a/crates/collab/src/sim_tests.rs +++ b/crates/collab/src/sim_tests.rs @@ -109,7 +109,13 @@ pub(crate) fn random_edit(doc: &mut Document, rng: &mut Rng, derive: &dyn Recomp doc.styles.remove(i); } Some(i) => doc.styles[i].name = format!("Renamed {}", rng.below(100)), - None => doc.styles.push(Style { id, name: "S".into(), description: String::new(), kind: StyleKind::Paint { paints: vec![] } }), + None => doc.styles.push(Style { + id, + name: "S".into(), + description: String::new(), + kind: StyleKind::Paint { paints: vec![] }, + bound: Default::default(), + }), } } } diff --git a/crates/doc/src/colors_tests.rs b/crates/doc/src/colors_tests.rs index d20a757..3a97a5d 100644 --- a/crates/doc/src/colors_tests.rs +++ b/crates/doc/src/colors_tests.rs @@ -47,6 +47,7 @@ fn colourful() -> (Document, NodeId) { name: "Red".into(), description: String::new(), kind: StyleKind::Paint { paints: vec![Paint::solid(RED)] }, + bound: Default::default(), }); d.variables.collections.push(Collection { id: "c1".into(), diff --git a/crates/doc/src/ids_tests.rs b/crates/doc/src/ids_tests.rs index d94d340..4f48988 100644 --- a/crates/doc/src/ids_tests.rs +++ b/crates/doc/src/ids_tests.rs @@ -7,7 +7,13 @@ use crate::{Collection, Comment, Document, Message, Mode, NodeId, NodeKind, Styl const C3: u64 = 3 << 32; fn style(id: &str) -> Style { - Style { id: id.into(), name: format!("Style {id}"), description: String::new(), kind: StyleKind::Paint { paints: Vec::new() } } + Style { + id: id.into(), + name: format!("Style {id}"), + description: String::new(), + kind: StyleKind::Paint { paints: Vec::new() }, + bound: Default::default(), + } } fn comment(id: u64, page: NodeId) -> Comment { diff --git a/crates/doc/src/styles.rs b/crates/doc/src/styles.rs index ad32425..a9cad14 100644 --- a/crates/doc/src/styles.rs +++ b/crates/doc/src/styles.rs @@ -40,6 +40,12 @@ pub struct Style { pub description: String, #[serde(flatten)] pub kind: StyleKind, + /// Variables bound to fields of the value, by the keys a layer's bindings use + /// (`fills/0/color`, `effects/0/radius`, `layoutGrids/0/size`, a text style's `fontSize`…). + /// A layer using the style sees them resolved in its own modes; `kind` holds them resolved in + /// the default modes. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub bound: BTreeMap, } /// A node's links to shared styles. diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 8e2158b..8a347c9 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -528,6 +528,7 @@ fn load_with( /// Link imported instances to their mains (layer sources, overrides found by comparing values), /// innermost first so outer instances settle last. fn link_instances(doc: &mut Document) { + crate::style_vars::after_load(doc); let mut insts: Vec<(usize, NodeId)> = doc.nodes().filter(|n| matches!(n.kind, NodeKind::Instance { .. })).map(|n| (doc.ancestors(n.id).len(), n.id)).collect(); insts.sort_by_key(|a| std::cmp::Reverse(a.0)); diff --git a/crates/engine/src/cmd/lists.rs b/crates/engine/src/cmd/lists.rs index f7cbb25..769d0a2 100644 --- a/crates/engine/src/cmd/lists.rs +++ b/crates/engine/src/cmd/lists.rs @@ -256,19 +256,24 @@ pub(crate) fn remove_item(n: &mut Node, list: BoundList, index: usize) -> bool { true } +/// Set `field` (`size|count|gutter|offset`) of layout grid `i` to a bound number. +pub(crate) fn set_grid_field(grids: &mut [LayoutGrid], i: usize, field: &str, x: f64) { + let Some(g) = grids.get_mut(i).filter(|_| x.is_finite()) else { return }; + match field { + "size" => g.size = x.max(0.0), + "count" => g.count = x.round().clamp(1.0, 1000.0) as u32, + "gutter" => g.gutter = x.max(0.0), + "offset" => g.offset = x, + _ => {} + } +} + /// Write bound numbers into a node's layout grids (`layoutGrids//`; /// only what changed, so unchanged layers stay shared). pub(crate) fn apply_grid_bindings(doc: &mut Document, id: NodeId, sets: &[(usize, String, f64)]) { let Some(mut grids) = doc.node(id).map(|n| n.grids.clone()) else { return }; for (i, field, x) in sets { - let Some(g) = grids.get_mut(*i).filter(|_| x.is_finite()) else { continue }; - match field.as_str() { - "size" => g.size = x.max(0.0), - "count" => g.count = x.round().clamp(1.0, 1000.0) as u32, - "gutter" => g.gutter = x.max(0.0), - "offset" => g.offset = *x, - _ => {} - } + set_grid_field(&mut grids, *i, field, *x); } if doc.node(id).is_some_and(|n| n.grids != grids) && let Ok(n) = doc.get_mut(id) diff --git a/crates/engine/src/cmd/styles.rs b/crates/engine/src/cmd/styles.rs index e4103a2..a5f2b78 100644 --- a/crates/engine/src/cmd/styles.rs +++ b/crates/engine/src/cmd/styles.rs @@ -47,6 +47,13 @@ pub fn specs() -> Vec { detach ), spec!("style.update", "Edit style", "{style, name?, description?, value?, from? (node id: redefine from it)}", true, update), + spec!( + "style.bind", + "Bind variable in style", + "{style, field, variable? (id or name; none: unbind)} bind a variable to a field of the style's value: a paint style's `fills//color` (or `fill`: its first paint) and `fills//stops//color`, an effect style's `effects//`, a grid style's `layoutGrids//`, a text style's `fontSize|lineHeight|letterSpacing` (numbers, px) and `fontFamily|fontStyle` (strings). Every layer using the style shows the field resolved in its own modes; the style keeps it resolved in the default modes", + true, + bind_style + ), spec!("style.duplicate", "Duplicate style", "{style} → {id} (right below it, named \" copy\")", true, duplicate), spec!( "style.move", @@ -306,7 +313,19 @@ fn create(s: &mut Session, p: &Value) -> Result { kind_from_node(&t, n).ok_or_else(|| bad(cmd, "that layer has no such property"))? }; let sid = doc.new_style_id(); - doc.styles.push(Style { id: sid.clone(), name, description: string(p, "description").unwrap_or_default(), kind: kind.clone() }); + let field = match t.as_str() { + "stroke" => "strokeStyle", + "text" => "textStyle", + "effect" => "effectStyle", + "grid" => "gridStyle", + _ => "fillStyle", + }; + // A style made from a layer keeps the variables bound to what it takes. + let bound = match from.and_then(|f| doc.node(f)).filter(|_| get(p, "value").is_none() && range.is_none()) { + Some(n) => crate::style_vars::from_layer(n, field, &kind), + None => BTreeMap::new(), + }; + doc.styles.push(Style { id: sid.clone(), name, description: string(p, "description").unwrap_or_default(), kind: kind.clone(), bound }); // The source layer (or the selected chars) becomes linked to the new style. if let (Some((tid, a, b)), StyleKind::Text { style }) = (range, &kind) { if let NodeKind::Text(td) = &mut doc.get_mut(tid)?.kind { @@ -315,13 +334,6 @@ fn create(s: &mut Session, p: &Value) -> Result { } else if let Some(f) = from && get(p, "value").is_none() { - let field = match t.as_str() { - "stroke" => "strokeStyle", - "text" => "textStyle", - "effect" => "effectStyle", - "grid" => "gridStyle", - _ => "fillStyle", - }; apply_style_ref(doc, f, field, Some(&sid))?; } Ok(json!({"id": sid})) @@ -330,7 +342,11 @@ fn create(s: &mut Session, p: &Value) -> Result { /// Link node `id` to style `sid` for `field` (fillStyle, strokeStyle, …) and copy its value in. pub fn apply_style_ref(doc: &mut Document, id: NodeId, field: &str, sid: Option<&str>) -> Result<()> { let style = match sid { - Some(s) => Some(doc.style(s).cloned().ok_or_else(|| EngineError::Other(format!("no such style `{s}`")))?), + Some(s) => { + let st = doc.style(s).ok_or_else(|| EngineError::Other(format!("no such style `{s}`")))?; + // Fields bound to variables come resolved in this layer's modes. + Some(Style { kind: crate::style_vars::kind_for(doc, st, id).into_owned(), ..st.clone() }) + } None => None, }; let n = doc.get_mut(id)?; @@ -420,6 +436,38 @@ pub(crate) fn apply(s: &mut Session, p: &Value) -> Result { ok() } +fn bind_style(s: &mut Session, p: &Value) -> Result { + let cmd = "style.bind"; + let st = req_string(cmd, p, "style")?; + let field = req_string(cmd, p, "field")?; + let doc = s.doc_mut()?; + let idx = doc.styles.iter().position(|x| x.id == st || x.name == st).ok_or_else(|| bad(cmd, "no such style"))?; + if doc.styles.get(idx).is_some_and(|x| crate::library::is_remote_style(doc, &x.id)) { + return Err(EngineError::Disabled(cmd.into(), crate::library::READ_ONLY.into())); + } + let var = match string(p, "variable") { + Some(v) => Some(doc.variables.get(&v).map(|x| (x.id.clone(), x.kind)).ok_or_else(|| bad(cmd, "no such variable"))?), + None => None, + }; + let Some(style) = doc.styles.get_mut(idx) else { return Err(bad(cmd, "no such style")) }; + let want = crate::style_vars::field_type(&style.kind, &field).ok_or_else(|| bad(cmd, format!("the style has no field `{field}` to bind")))?; + let key = crate::style_vars::canonical(&field); + match var { + Some((_, kind)) if kind != want => return Err(bad(cmd, format!("`{field}` takes a {} variable, not a {} one", want.id(), kind.id()))), + Some((id, _)) => { + style.bound.insert(key, id); + } + None => { + style.bound.remove(&key); + } + } + // The style takes its default-mode value, and each layer using it its own. + let sid = style.id.clone(); + crate::style_vars::settle(doc); + propagate_style(doc, &sid); + ok() +} + fn update(s: &mut Session, p: &Value) -> Result { let cmd = "style.update"; let st = req_string(cmd, p, "style")?; @@ -453,6 +501,7 @@ fn update(s: &mut Session, p: &Value) -> Result { } if let Some(k) = new_kind { style.kind = k; + crate::style_vars::prune(style); } let sid = style.id.clone(); propagate_style(doc, &sid); @@ -584,11 +633,16 @@ pub fn propagate_style(doc: &mut Document, sid: &str) { for i in ids { let Some(n) = doc.node(i) else { continue }; if let Some(def) = &text_def { - if n.kind.text().is_some_and(|t| t.links_text_style(sid)) - && let Ok(n) = doc.get_mut(i) - && let NodeKind::Text(t) = &mut n.kind - { - t.update_text_style(sid, def); + if n.kind.text().is_some_and(|t| t.links_text_style(sid)) { + let def = match doc.style(sid).map(|st| crate::style_vars::kind_for(doc, st, i).into_owned()) { + Some(StyleKind::Text { style }) => style, + _ => def.clone(), + }; + if let Ok(n) = doc.get_mut(i) + && let NodeKind::Text(t) = &mut n.kind + { + t.update_text_style(sid, &def); + } } continue; } @@ -620,9 +674,18 @@ pub fn sync_text_styles(doc: &mut Document, ids: &[NodeId]) { _ => None, }) .collect(); + let varying: Vec<&Style> = doc.styles.iter().filter(|s| !s.bound.is_empty() && matches!(s.kind, StyleKind::Text { .. })).collect(); + let mut updates: Vec<(NodeId, Option, TextData)> = Vec::new(); for id in ids { let Some(n) = doc.node(id) else { continue }; let NodeKind::Text(t) = &n.kind else { continue }; + // Styles with bound fields read as this layer sees them. + let mut defs = std::borrow::Cow::Borrowed(&defs); + for st in &varying { + if let StyleKind::Text { style } = crate::style_vars::kind_for(doc, st, id).into_owned() { + defs.to_mut().insert(st.id.clone(), style); + } + } let mut t2 = t.clone(); // A layer linked as a whole whose chars carry no link yet (imported): they link now. if let Some(sid) = &n.styles.text @@ -635,9 +698,12 @@ pub fn sync_text_styles(doc: &mut Document, ids: &[NodeId]) { } t2.check_text_styles(|sid| defs.get(sid)); let link = t2.text_style_link().flatten().map(str::to_string); - if (t2 != *t || link != n.styles.text) - && let Ok(n) = doc.get_mut(id) - { + if t2 != *t || link != n.styles.text { + updates.push((id, link, t2)); + } + } + for (id, link, t2) in updates { + if let Ok(n) = doc.get_mut(id) { n.styles.text = link; n.kind = NodeKind::Text(t2); } @@ -653,7 +719,10 @@ pub fn sync_list_styles(doc: &mut Document, before: &Document, ids: &[NodeId]) { if n.styles.is_empty() { continue; } - let off = |r: &Option, same: &dyn Fn(&StyleKind) -> bool| r.as_deref().and_then(|s| doc.style(s)).is_some_and(|st| !same(&st.kind)); + // A style's bound fields count as this layer sees them (in its modes). + let off = |r: &Option, same: &dyn Fn(&StyleKind) -> bool| { + r.as_deref().and_then(|s| doc.style(s)).is_some_and(|st| !same(&crate::style_vars::kind_for(doc, st, id))) + }; let fill = n.fills != b.fills && off(&n.styles.fill, &|k| matches!(k, StyleKind::Paint { paints } if *paints == n.fills)); let stroke = n.strokes != b.strokes && off(&n.styles.stroke, &|k| matches!(k, StyleKind::Paint { paints } if *paints == n.strokes)); let effect = n.effects != b.effects && off(&n.styles.effect, &|k| matches!(k, StyleKind::Effect { effects } if *effects == n.effects)); @@ -845,6 +914,7 @@ pub fn apply_bindings_to(doc: &mut Document, only: Option<&std::collections::BTr if doc.variables.variables.is_empty() { return; } + let bound: Vec = match only { Some(ids) => ids.iter().copied().filter(|i| doc.node(*i).is_some_and(|n| !n.bound.is_empty())).collect(), None => doc.nodes().filter(|n| !n.bound.is_empty()).map(|n| n.id).collect(), @@ -928,6 +998,42 @@ pub fn apply_bindings_to(doc: &mut Document, only: Option<&std::collections::BTr } } +/// Set paint `i`'s colour (or its gradient stop `stop`'s) to a bound colour; a first paint that +/// isn't there yet is added. +pub(crate) fn set_paint_color(list: &mut Vec, i: usize, stop: Option, c: Color) { + 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, _) => {} + } +} + +/// Set `prop` of effect `i` to a bound value. +pub(crate) fn set_effect_field(effects: &mut [Effect], i: usize, prop: &str, val: &VariableValue) { + let Some(e) = effects.get_mut(i) else { return }; + let num = match val { + VariableValue::Number(x) if x.is_finite() => Some(*x), + _ => None, + }; + match (&mut e.kind, prop, num, val) { + (EffectKind::DropShadow(s) | EffectKind::InnerShadow(s), "offsetX", Some(x), _) => s.offset.0 = x, + (EffectKind::DropShadow(s) | EffectKind::InnerShadow(s), "offsetY", Some(x), _) => s.offset.1 = x, + (EffectKind::DropShadow(s) | EffectKind::InnerShadow(s), "radius", Some(x), _) => s.radius = x.max(0.0), + (EffectKind::DropShadow(s) | EffectKind::InnerShadow(s), "spread", Some(x), _) => s.spread = x, + (EffectKind::DropShadow(s) | EffectKind::InnerShadow(s), "color", _, VariableValue::Color(c)) => s.color = *c, + (EffectKind::LayerBlur(b) | EffectKind::BackgroundBlur(b), "radius", Some(x), _) => b.radius = x.max(0.0), + (EffectKind::LayerBlur(b) | EffectKind::BackgroundBlur(b), "startRadius", Some(x), _) => b.start_radius = x.max(0.0), + _ => {} + } +} + /// 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 @@ -936,19 +1042,7 @@ fn apply_paint_bindings(doc: &mut Document, id: NodeId, sets: &[(bool, usize, Op let Some(n) = doc.node(id) else { return }; let (mut fills, mut strokes) = (n.fills.clone(), n.strokes.clone()); for (is_fill, i, stop, c) in sets { - let list = if *is_fill { &mut fills } else { &mut strokes }; - 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, _) => {} - } + set_paint_color(if *is_fill { &mut fills } else { &mut strokes }, *i, *stop, *c); } if doc.node(id).is_some_and(|n| n.fills != fills || n.strokes != strokes) && let Ok(n) = doc.get_mut(id) @@ -962,21 +1056,7 @@ fn apply_paint_bindings(doc: &mut Document, id: NodeId, sets: &[(bool, usize, Op fn apply_effect_bindings(doc: &mut Document, id: NodeId, sets: &[(usize, String, VariableValue)]) { let Some(mut effects) = doc.node(id).map(|n| n.effects.clone()) else { return }; for (i, prop, val) in sets { - let Some(e) = effects.get_mut(*i) else { continue }; - let num = match val { - VariableValue::Number(x) if x.is_finite() => Some(*x), - _ => None, - }; - match (&mut e.kind, prop.as_str(), num, val) { - (EffectKind::DropShadow(s) | EffectKind::InnerShadow(s), "offsetX", Some(x), _) => s.offset.0 = x, - (EffectKind::DropShadow(s) | EffectKind::InnerShadow(s), "offsetY", Some(x), _) => s.offset.1 = x, - (EffectKind::DropShadow(s) | EffectKind::InnerShadow(s), "radius", Some(x), _) => s.radius = x.max(0.0), - (EffectKind::DropShadow(s) | EffectKind::InnerShadow(s), "spread", Some(x), _) => s.spread = x, - (EffectKind::DropShadow(s) | EffectKind::InnerShadow(s), "color", _, VariableValue::Color(c)) => s.color = *c, - (EffectKind::LayerBlur(b) | EffectKind::BackgroundBlur(b), "radius", Some(x), _) => b.radius = x.max(0.0), - (EffectKind::LayerBlur(b) | EffectKind::BackgroundBlur(b), "startRadius", Some(x), _) => b.start_radius = x.max(0.0), - _ => {} - } + set_effect_field(&mut effects, *i, prop, val); } if doc.node(id).is_some_and(|n| n.effects != effects) && let Ok(n) = doc.get_mut(id) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 038408c..a66652c 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -45,6 +45,7 @@ pub mod rotation_origin; pub mod shape_handles; pub mod shapebuilder; pub mod snap; +pub mod style_vars; pub mod synthetic; pub mod synthetic_ds; pub mod templates; @@ -1124,7 +1125,11 @@ pub fn normalize(doc: &mut Document, before: &Document) { let m = measure::TextMeasure; // 0. Variable bindings and style links feed node values. let scope = binding_scope(doc, before, &touched); + if doc.variables != before.variables || doc.styles != before.styles { + style_vars::settle(doc); + } cmd::styles::apply_bindings_to(doc, scope.as_ref()); + style_vars::refresh(doc, Some(before), scope.as_ref()); if doc.styles != before.styles { let changed_styles: Vec = doc.styles.iter().filter(|s| !before.styles.contains(s)).map(|s| s.id.clone()).collect(); for sid in changed_styles { @@ -1161,6 +1166,7 @@ pub fn normalize(doc: &mut Document, before: &Document) { let synced = doc.take_dirty(); if !synced.is_empty() { cmd::styles::apply_bindings_to(doc, Some(&synced)); + style_vars::refresh(doc, Some(before), Some(&synced)); touched.extend(synced); } touched.extend(doc.take_dirty()); @@ -1520,6 +1526,8 @@ mod stroke_style_tests; #[cfg(test)] mod style_tests; #[cfg(test)] +mod style_vars_tests; +#[cfg(test)] mod styles_behaviour_tests; #[cfg(test)] mod svg_import_tests; diff --git a/crates/engine/src/proto_run.rs b/crates/engine/src/proto_run.rs index c6d7fed..3fa374e 100644 --- a/crates/engine/src/proto_run.rs +++ b/crates/engine/src/proto_run.rs @@ -57,6 +57,7 @@ pub fn set_variable(doc: &mut Document, node: NodeId, variable: &str, value: &Va *val = v.clone(); } apply_bindings(doc); + crate::style_vars::refresh(doc, None, None); true } @@ -81,6 +82,7 @@ pub fn set_mode(doc: &mut Document, frame: NodeId, collection: &str, mode: &str) } } apply_bindings(doc); + crate::style_vars::refresh(doc, None, None); true } diff --git a/crates/engine/src/style_vars.rs b/crates/engine/src/style_vars.rs new file mode 100644 index 0000000..97226ae --- /dev/null +++ b/crates/engine/src/style_vars.rs @@ -0,0 +1,194 @@ +//! Variables bound inside shared styles. A style's `bound` map names fields of its own value in +//! the keys a layer's bindings use (`fills//color`, `fills//stops//color`, +//! `effects//`, `layoutGrids//`, a text style's `fontSize`, `lineHeight`, +//! `letterSpacing`, `fontFamily`, `fontStyle`). Each layer using the style sees those fields +//! resolved in its own modes; the style itself stores them resolved in the default modes. + +use std::borrow::Cow; +use std::collections::{BTreeMap, BTreeSet}; + +use figcraft_color::PaintKind; +use figcraft_doc::*; + +use crate::cmd::lists::{BoundList, list_binding, stop_binding}; + +type Modes = BTreeMap; + +/// The type of variable a style of `kind` takes on `key`; None when it has no such field. +pub fn field_type(kind: &StyleKind, key: &str) -> Option { + use VariableType::{Color, Number, String}; + match (kind, list_binding(key)) { + (StyleKind::Paint { paints }, Some((BoundList::Fills, i, field))) => { + let p = paints.get(i)?; + let ok = match stop_binding(field) { + Some(j) => matches!(&p.kind, PaintKind::Gradient(g) if j < g.stops.len()), + None => field == "color" && matches!(p.kind, PaintKind::Solid { .. }), + }; + ok.then_some(Color) + } + (StyleKind::Effect { effects }, Some((BoundList::Effects, i, field))) => match (&effects.get(i)?.kind, field) { + (EffectKind::DropShadow(_) | EffectKind::InnerShadow(_), "color") => Some(Color), + (EffectKind::DropShadow(_) | EffectKind::InnerShadow(_), "radius" | "spread" | "offsetX" | "offsetY") => Some(Number), + (EffectKind::LayerBlur(_) | EffectKind::BackgroundBlur(_), "radius" | "startRadius") => Some(Number), + _ => None, + }, + (StyleKind::Grid { grids }, Some((BoundList::Grids, i, "size" | "count" | "gutter" | "offset"))) => grids.get(i).map(|_| Number), + (StyleKind::Text { .. }, None) => match key { + "fontSize" | "lineHeight" | "letterSpacing" => Some(Number), + "fontFamily" | "fontStyle" => Some(String), + _ => None, + }, + _ => None, + } +} + +/// The key a style stores a binding of `key` under: the first paint's colour has one spelling. +pub fn canonical(key: &str) -> String { + match list_binding(key) { + Some((list, i, field)) => list.key(i, field), + None => key.to_string(), + } +} + +/// Bindings that no longer name a field of the style's value (an item removed, a paint turned +/// into a gradient) go. +pub fn prune(st: &mut Style) { + let kind = &st.kind; + st.bound.retain(|k, _| field_type(kind, k).is_some()); +} + +/// The bindings a new style of `kind` takes over from the layer it was made from (`field`: the +/// link the layer gets to it). +pub fn from_layer(n: &Node, field: &str, kind: &StyleKind) -> BTreeMap { + let mut out = BTreeMap::new(); + for (k, v) in &n.bound { + let key = match (field, list_binding(k)) { + ("fillStyle", Some((BoundList::Fills, i, f))) | ("strokeStyle", Some((BoundList::Strokes, i, f))) => BoundList::Fills.key(i, f), + ("effectStyle", Some((BoundList::Effects, ..))) | ("gridStyle", Some((BoundList::Grids, ..))) | ("textStyle", None) => k.clone(), + _ => continue, + }; + if field_type(kind, &key).is_some() { + out.insert(key, v.clone()); + } + } + out +} + +/// `st`'s value with its bound fields resolved in `modes`. +fn resolved(vars: &Variables, st: &Style, modes: &Modes) -> StyleKind { + let mut kind = st.kind.clone(); + for (key, var) in &st.bound { + let Some(val) = vars.resolve(var, modes) else { continue }; + match (&mut kind, list_binding(key), &val) { + (StyleKind::Paint { paints }, Some((BoundList::Fills, i, field)), VariableValue::Color(c)) => { + crate::cmd::styles::set_paint_color(paints, i, stop_binding(field), *c); + } + (StyleKind::Effect { effects }, Some((BoundList::Effects, i, field)), _) => crate::cmd::styles::set_effect_field(effects, i, field, &val), + (StyleKind::Grid { grids }, Some((BoundList::Grids, i, field)), VariableValue::Number(x)) => { + crate::cmd::lists::set_grid_field(grids, i, field, *x); + } + (StyleKind::Text { style }, None, _) => set_text_field(style, key, &val), + _ => {} + } + } + kind +} + +fn set_text_field(style: &mut TextStyle, key: &str, val: &VariableValue) { + match (key, val) { + ("fontSize", VariableValue::Number(x)) if x.is_finite() => style.font_size = x.clamp(1.0, 5000.0), + ("lineHeight", VariableValue::Number(x)) if x.is_finite() => style.line_height = LineHeight::Pixels(x.clamp(0.0, 100_000.0)), + ("letterSpacing", VariableValue::Number(x)) if x.is_finite() => style.letter_spacing = LetterSpacing::Pixels(x.clamp(-10_000.0, 10_000.0)), + ("fontFamily", VariableValue::String(f)) if !f.trim().is_empty() => style.font_family = f.clone(), + ("fontStyle", VariableValue::String(f)) if !f.trim().is_empty() => style.font_style = f.clone(), + _ => {} + } +} + +/// `st`'s value as layer `id` shows it: bound fields resolved in the layer's modes. +pub fn kind_for<'a>(doc: &Document, st: &'a Style, id: NodeId) -> Cow<'a, StyleKind> { + if st.bound.is_empty() { Cow::Borrowed(&st.kind) } else { Cow::Owned(resolved(&doc.variables, st, &doc.modes_for(id))) } +} + +/// Each style with bindings stores its value resolved in the default modes (what pickers, exports +/// and other software show for it). +pub fn settle(doc: &mut Document) { + let none = Modes::new(); + for i in 0..doc.styles.len() { + let Some(st) = doc.styles.get(i).filter(|s| !s.bound.is_empty()) else { continue }; + let mut next = st.clone(); + prune(&mut next); + next.kind = resolved(&doc.variables, &next, &none); + if next != *st + && let Some(slot) = doc.styles.get_mut(i) + { + *slot = next; + } + } +} + +/// A document just read from a file: styles with bindings hold their default-mode values and the +/// layers using them show their own modes' (files give a styled layer the style's stored value). +pub fn after_load(doc: &mut Document) { + settle(doc); + refresh(doc, None, None); +} + +/// The layers `only` (every layer when `None`) that use styles with bindings show them resolved in +/// their own modes (only what changed, so unchanged layers stay shared). What an edit since +/// `before` changed on a layer is left as edited: that unlinks it from the style instead. +pub fn refresh(doc: &mut Document, before: Option<&Document>, only: Option<&BTreeSet>) { + let bound: BTreeSet<&str> = doc.styles.iter().filter(|s| !s.bound.is_empty()).map(|s| s.id.as_str()).collect(); + if bound.is_empty() { + return; + } + let uses = |n: &Node| { + let r = &n.styles; + [&r.fill, &r.stroke, &r.effect, &r.grid, &r.text].into_iter().flatten().any(|s| bound.contains(s.as_str())) + || matches!(&n.kind, NodeKind::Text(t) if t.spans().iter().any(|(_, _, s)| s.style_id.as_deref().is_some_and(|i| bound.contains(i)))) + }; + let ids: Vec = match only { + Some(ids) => ids.iter().copied().filter(|i| doc.node(*i).is_some_and(uses)).collect(), + None => doc.nodes().filter(|n| uses(n)).map(|n| n.id).collect(), + }; + for id in ids { + let Some(n) = doc.node(id) else { continue }; + let mut next = n.clone(); + let was = before.and_then(|b| b.node(id)); + let of = |r: &Option| r.as_deref().and_then(|s| doc.style(s)).filter(|s| !s.bound.is_empty()).map(|s| kind_for(doc, s, id)); + if let Some(StyleKind::Paint { paints }) = of(&n.styles.fill).as_deref() + && was.is_none_or(|w| w.fills == n.fills) + { + next.fills.clone_from(paints); + } + if let Some(StyleKind::Paint { paints }) = of(&n.styles.stroke).as_deref() + && was.is_none_or(|w| w.strokes == n.strokes) + { + next.strokes.clone_from(paints); + } + if let Some(StyleKind::Effect { effects }) = of(&n.styles.effect).as_deref() + && was.is_none_or(|w| w.effects == n.effects) + { + next.effects.clone_from(effects); + } + if let Some(StyleKind::Grid { grids }) = of(&n.styles.grid).as_deref() + && was.is_none_or(|w| w.grids == n.grids) + { + next.grids.clone_from(grids); + } + if let NodeKind::Text(t) = &mut next.kind + && was.is_none_or(|w| w.kind == n.kind) + { + for st in doc.styles.iter().filter(|s| !s.bound.is_empty()) { + if let StyleKind::Text { style } = kind_for(doc, st, id).as_ref() { + t.update_text_style(&st.id, style); + } + } + } + if next != *n + && let Ok(slot) = doc.get_mut(id) + { + *slot = next; + } + } +} diff --git a/crates/engine/src/style_vars_tests.rs b/crates/engine/src/style_vars_tests.rs new file mode 100644 index 0000000..dae9ef5 --- /dev/null +++ b/crates/engine/src/style_vars_tests.rs @@ -0,0 +1,206 @@ +//! Variables bound inside shared styles: `style.bind`, what each style type takes, a layer using +//! the style showing it resolved in its own modes (and staying linked), the style's own value in +//! the default modes, variables and modes changing afterwards, styles made from bound layers, +//! undo, the REST and `.fig` mappings, junk. + +use serde_json::{Value, json}; + +use crate::params::id_of; +use crate::{EngineError, Session}; +use figcraft_doc::{Node, NodeId, StyleKind}; + +fn id(v: Value) -> NodeId { + id_of(&v["id"]).unwrap() +} + +fn run(s: &mut Session, cmd: &str, p: Value) -> Value { + s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd} {p}: {e}")) +} + +fn node(s: &Session, n: NodeId) -> Node { + s.doc().unwrap().node(n).unwrap().clone() +} + +fn fill(s: &Session, n: NodeId) -> String { + node(s, n).fills[0].solid_color().unwrap().to_css_hex().to_uppercase() +} + +fn style_fill(s: &Session, sid: &str) -> String { + match &s.doc().unwrap().style(sid).unwrap().kind { + StyleKind::Paint { paints } => paints[0].solid_color().unwrap().to_css_hex().to_uppercase(), + k => panic!("{k:?}"), + } +} + +/// A Theme collection (Light, Dark) with a colour `bg` (white, black) and numbers `size` (16, 20) +/// and `blur` (4, 12). +fn theme(s: &mut Session) { + run(s, "variable.createCollection", json!({"name": "Theme", "modes": ["Light", "Dark"]})); + run(s, "variable.create", json!({"collection": "Theme", "name": "bg", "type": "color", "values": {"Light": "#FFFFFF", "Dark": "#000000"}})); + run(s, "variable.create", json!({"collection": "Theme", "name": "size", "type": "number", "values": {"Light": 16, "Dark": 20}})); + run(s, "variable.create", json!({"collection": "Theme", "name": "blur", "type": "number", "values": {"Light": 4, "Dark": 12}})); +} + +/// Two frames (the second set to Dark), a red rectangle in each, and a paint style "Surface" +/// (red) both rectangles use. +fn two_modes(s: &mut Session) -> (NodeId, NodeId, NodeId, NodeId, String) { + theme(s); + let frame = |s: &mut Session, x: f64| id(run(s, "frame.create", json!({"x": x, "y": 0, "width": 100, "height": 100}))); + let (light, dark) = (frame(s, 0.0), frame(s, 200.0)); + run(s, "variable.setMode", json!({"ids": [dark], "collection": "Theme", "mode": "Dark"})); + let rect = |s: &mut Session, f: NodeId| { + id(run(s, "shape.rectangle", json!({"parent": f, "x": 10, "y": 10, "width": 40, "height": 40, "props": {"fill": "#FF0000"}}))) + }; + let (a, b) = (rect(s, light), rect(s, dark)); + let sid = run(s, "style.create", json!({"type": "paint", "name": "Surface", "from": a}))["id"].as_str().unwrap().to_string(); + run(s, "style.apply", json!({"ids": [b], "style": sid})); + (light, dark, a, b, sid) +} + +#[test] +fn a_bound_paint_style_shows_in_each_layers_mode() { + let mut s = Session::new(); + let (_, dark, a, b, sid) = two_modes(&mut s); + run(&mut s, "style.bind", json!({"style": sid, "field": "fill", "variable": "bg"})); + // The layer in the Light frame is white, the one in the Dark frame black; both stay linked. + assert_eq!((fill(&s, a), fill(&s, b)), ("#FFFFFF".to_string(), "#000000".to_string())); + assert_eq!((node(&s, a).styles.fill, node(&s, b).styles.fill), (Some(sid.clone()), Some(sid.clone()))); + // The style itself holds the default mode's value, under the one spelling of the key. + assert_eq!(style_fill(&s, &sid), "#FFFFFF"); + let st = s.doc().unwrap().style(&sid).unwrap().clone(); + assert_eq!(st.bound.keys().collect::>(), ["fills/0/color"]); + // The variable's value changes: every user follows, in its mode. + run(&mut s, "variable.set", json!({"variable": "bg", "mode": "Dark", "value": "#112233"})); + assert_eq!((fill(&s, a), fill(&s, b)), ("#FFFFFF".to_string(), "#112233".to_string())); + // A frame's mode changes: the layers in it follow, still linked. + run(&mut s, "variable.setMode", json!({"ids": [dark], "collection": "Theme", "mode": "Light"})); + assert_eq!((fill(&s, b), node(&s, b).styles.fill), ("#FFFFFF".to_string(), Some(sid.clone()))); + run(&mut s, "edit.undo", json!({})); + assert_eq!(fill(&s, b), "#112233"); + // A layer moved into the other frame takes that frame's mode. + run(&mut s, "node.reparent", json!({"id": a, "parent": dark})); + assert_eq!((fill(&s, a), node(&s, a).styles.fill), ("#112233".to_string(), Some(sid.clone()))); + // Unbound: the style and its users keep the value they showed last by default. + run(&mut s, "style.bind", json!({"style": sid, "field": "fills/0/color"})); + assert!(s.doc().unwrap().style(&sid).unwrap().bound.is_empty()); + assert_eq!((style_fill(&s, &sid), fill(&s, a), fill(&s, b)), ("#FFFFFF".to_string(), "#FFFFFF".to_string(), "#FFFFFF".to_string())); +} + +#[test] +fn editing_a_bound_style_or_its_users() { + let mut s = Session::new(); + let (_, _, a, b, sid) = two_modes(&mut s); + run(&mut s, "style.bind", json!({"style": sid, "field": "fill", "variable": "bg"})); + // An edit of the layer's own fill still unlinks it (the documented rule for styled properties). + run(&mut s, "node.set", json!({"id": b, "props": {"fill": "#00FF00"}})); + assert_eq!((fill(&s, b), node(&s, b).styles.fill), ("#00FF00".to_string(), None)); + // Applying the style again gives the layer's mode's value, not the stored one. + run(&mut s, "style.apply", json!({"ids": [b], "style": sid})); + assert_eq!(fill(&s, b), "#000000"); + // A new value without the bound paint drops the binding with it. + run(&mut s, "style.update", json!({"style": sid, "value": []})); + assert!(s.doc().unwrap().style(&sid).unwrap().bound.is_empty()); + assert!(node(&s, a).fills.is_empty() && node(&s, a).styles.fill == Some(sid)); +} + +#[test] +fn effect_grid_and_text_styles_take_variables_too() { + let mut s = Session::new(); + let (_, dark, a, b, _) = two_modes(&mut s); + // Effect: a layer blur's radius. + run(&mut s, "effect.add", json!({"ids": [a], "type": "layerBlur"})); + let fx = run(&mut s, "style.create", json!({"type": "effect", "name": "Soft", "from": a}))["id"].as_str().unwrap().to_string(); + run(&mut s, "style.apply", json!({"ids": [b], "style": fx})); + run(&mut s, "style.bind", json!({"style": fx, "field": "effects/0/radius", "variable": "blur"})); + let radius = |s: &Session, n: NodeId| node(s, n).effects[0].blur().unwrap().radius; + assert_eq!((radius(&s, a), radius(&s, b)), (4.0, 12.0)); + assert_eq!((node(&s, a).styles.effect.is_some(), node(&s, b).styles.effect.is_some()), (true, true)); + // Grid: the frames' layout grid size. + let light = node(&s, a).parent.unwrap(); + run(&mut s, "grid.add", json!({"ids": [light]})); + let grid = run(&mut s, "style.create", json!({"type": "grid", "name": "Base", "from": light}))["id"].as_str().unwrap().to_string(); + run(&mut s, "style.apply", json!({"ids": [dark], "style": grid})); + run(&mut s, "style.bind", json!({"style": grid, "field": "layoutGrids/0/size", "variable": "size"})); + assert_eq!((node(&s, light).grids[0].size, node(&s, dark).grids[0].size), (16.0, 20.0)); + // Text: the font size, for chars linked to the style. + let text = |s: &mut Session, f: NodeId| id(run(s, "text.create", json!({"text": "Hello", "x": 0, "y": 60, "parent": f}))); + let (t1, t2) = (text(&mut s, light), text(&mut s, dark)); + let body = run(&mut s, "style.create", json!({"type": "text", "name": "Body", "from": t1}))["id"].as_str().unwrap().to_string(); + run(&mut s, "style.apply", json!({"ids": [t2], "style": body})); + run(&mut s, "style.bind", json!({"style": body, "field": "fontSize", "variable": "size"})); + let size = |s: &Session, n: NodeId| node(s, n).kind.text().unwrap().style.font_size; + assert_eq!((size(&s, t1), size(&s, t2)), (16.0, 20.0)); + assert_eq!((node(&s, t1).styles.text.clone(), node(&s, t2).styles.text.clone()), (Some(body.clone()), Some(body.clone()))); + run(&mut s, "variable.set", json!({"variable": "size", "mode": "Dark", "value": 28})); + assert_eq!((size(&s, t2), node(&s, t2).styles.text.clone(), node(&s, dark).grids[0].size), (28.0, Some(body), 28.0)); +} + +#[test] +fn a_style_made_from_a_bound_layer_keeps_the_binding() { + let mut s = Session::new(); + theme(&mut s); + let r = + id(run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 40, "height": 40, "props": {"fill": "#FF0000", "strokes": ["#00FF00"]}}))); + run(&mut s, "variable.bind", json!({"ids": [r], "field": "stroke", "variable": "bg"})); + let sid = run(&mut s, "style.create", json!({"type": "stroke", "name": "Edge", "from": r}))["id"].as_str().unwrap().to_string(); + let st = s.doc().unwrap().style(&sid).unwrap().clone(); + assert_eq!(st.bound.keys().collect::>(), ["fills/0/color"], "a paint style's paints are its fills"); + // The layer's own binding gave way to the style's. + let n = node(&s, r); + assert!(n.bound.is_empty() && n.styles.stroke == Some(sid) && n.strokes[0].solid_color().unwrap().to_css_hex().to_uppercase() == "#FFFFFF"); +} + +#[test] +fn what_a_style_cannot_bind_is_refused() { + let mut s = Session::new(); + let (_, _, a, _, sid) = two_modes(&mut s); + // (field, variable, what the message says) + for (field, var, says) in [ + ("fill", "size", "takes a color variable"), + ("fills/3/color", "bg", "no field"), + ("effects/0/radius", "blur", "no field"), + ("fontSize", "size", "no field"), + ("opacity", "size", "no field"), + ("fill", "nope", "no such variable"), + ] { + let e = s.execute("style.bind", &json!({"style": sid, "field": field, "variable": var})).unwrap_err().to_string(); + assert!(e.contains(says), "{field} ← {var}: {e}"); + } + assert!(s.execute("style.bind", &json!({"style": "nope", "field": "fill", "variable": "bg"})).is_err()); + assert!(s.doc().unwrap().style(&sid).unwrap().bound.is_empty()); + assert_eq!(fill(&s, a), "#FF0000"); + // Junk never takes the engine down. + for j in [json!(null), json!(true), json!(-1), json!([1]), json!({"a": 1}), json!("x".repeat(5000)), json!("fills/99999999999999999999/color")] { + for p in [ + json!({"style": j, "field": "fill"}), + json!({"style": sid, "field": j, "variable": "bg"}), + json!({"style": sid, "field": "fill", "variable": j}), + ] { + let r = s.execute("style.bind", &p); + assert!(!matches!(r, Err(EngineError::Internal { .. })), "{p}: {r:?}"); + } + } +} + +#[test] +fn rest_and_fig_carry_style_bindings() { + let mut s = Session::new(); + let (_, _, a, b, sid) = two_modes(&mut s); + run(&mut s, "style.bind", json!({"style": sid, "field": "fill", "variable": "bg"})); + let doc = s.doc().unwrap().clone(); + let (bytes, _) = figcraft_format::fig::export(&doc, None).unwrap(); + let (mut back, warnings) = figcraft_format::fig::import(&bytes, "x").unwrap(); + assert!(warnings.is_empty(), "{warnings:?}"); + // The file gives a styled layer its style's value; opening it resolves that per layer. + crate::style_vars::after_load(&mut back); + let st = back.styles.iter().find(|x| x.name == "Surface").unwrap(); + assert_eq!(st.bound.len(), 1, "{st:?}"); + assert!(st.bound.contains_key("fills/0/color")); + // The layers using it carry no binding of their own on what the style sets, and still show + // their modes' values. + let named = |n: NodeId| back.nodes().find(|x| x.name == doc.node(n).unwrap().name && x.fills == doc.node(n).unwrap().fills).unwrap(); + for n in [a, b] { + let got = named(n); + assert!(got.bound.is_empty() && got.styles.fill.as_deref() == Some(st.id.as_str()), "{got:?}"); + } +} diff --git a/crates/engine/src/synthetic_ds.rs b/crates/engine/src/synthetic_ds.rs index 5f2f6cb..c3beacd 100644 --- a/crates/engine/src/synthetic_ds.rs +++ b/crates/engine/src/synthetic_ds.rs @@ -223,6 +223,7 @@ fn foundations(doc: &mut Document) -> (CollectionId, ModeId, ModeId) { name: name.into(), description: String::new(), kind: StyleKind::Paint { paints: vec![Paint::solid(rgb(hex))] }, + bound: Default::default(), }); } for (name, size, weight) in [ @@ -234,7 +235,13 @@ fn foundations(doc: &mut Document) -> (CollectionId, ModeId, ModeId) { ("Label/L", 15.0, "Medium"), ] { let style = TextStyle { font_size: size, font_style: weight.into(), ..TextStyle::default() }; - doc.styles.push(Style { id: format!("ds:text:{name}"), name: name.into(), description: String::new(), kind: StyleKind::Text { style } }); + doc.styles.push(Style { + id: format!("ds:text:{name}"), + name: name.into(), + description: String::new(), + kind: StyleKind::Text { style }, + bound: Default::default(), + }); } (coll, light, dark) } diff --git a/crates/format/src/fig/assets_tests.rs b/crates/format/src/fig/assets_tests.rs index 502fcfe..f3b869f 100644 --- a/crates/format/src/fig/assets_tests.rs +++ b/crates/format/src/fig/assets_tests.rs @@ -386,7 +386,13 @@ fn styled() -> Document { line_height: LineHeight::Pixels(32.0), ..TextStyle::default() }; - let style = |id: &str, name: &str, kind: StyleKind| Style { id: id.into(), name: name.into(), description: String::new(), kind }; + let style = |id: &str, name: &str, kind: StyleKind| Style { + id: id.into(), + name: name.into(), + description: String::new(), + kind, + bound: Default::default(), + }; doc.styles = vec![ style("s-text", "Heading", StyleKind::Text { style: heading.clone() }), style("s-brand", "Brand/Primary", StyleKind::Paint { paints: vec![solid(0.25, 0.5, 1.0)] }), @@ -896,9 +902,13 @@ fn junk_style_definitions_and_orders_never_panic() { let file = json!({"name": "x", "document": {"id": "0:0", "type": "DOCUMENT", "children": [page.clone()]}, "meta": meta.clone(), "styleDefinitions": d}); let (doc, _) = crate::rest::import(&file, &|_| None).unwrap(); assert!(doc.styles.len() <= 10_001); - // The bound paint, stop, effect and grid fields come across. + // The bound paint, stop, effect and grid fields come across: on the layer, or (the fill it + // takes from a shared style) on that style. let r = doc.nodes().find(|n| n.name == "R").unwrap(); - assert_eq!(r.bound.keys().map(String::as_str).collect::>(), ["effects/0/radius", "fills/0/color", "layoutGrids/0/count"]); + let mut keys: Vec<&str> = r.bound.keys().map(String::as_str).collect(); + keys.extend(doc.styles.iter().filter(|s| s.id == "1:9").flat_map(|s| s.bound.keys().map(String::as_str))); + keys.sort_unstable(); + assert_eq!(keys, ["effects/0/radius", "fills/0/color", "layoutGrids/0/count"]); export(&doc, None).unwrap(); } } diff --git a/crates/format/src/fig/export.rs b/crates/format/src/fig/export.rs index 09cf5bf..2224335 100644 --- a/crates/format/src/fig/export.rs +++ b/crates/format/src/fig/export.rs @@ -609,7 +609,6 @@ impl<'d> Exporter<'d> { ("sortPosition", s(&position(index))), ] }; - let none = Bound::default(); for st in &doc.styles { let Some(&local) = self.style_ids.get(st.id.as_str()) else { continue }; // A sample layer that shows the style: a square for paints and effects, a frame for @@ -634,6 +633,21 @@ impl<'d> Exporter<'d> { if !st.description.is_empty() { f.push(("description", s(&st.description))); } + // The style's variable bindings ride on its sample layer, as a layer's do. + let none = if st.bound.is_empty() { + Bound::default() + } else { + let mut sample = Node::new(NodeId(0), NodeKind::Rectangle, "", 0.0, 0.0); + if let StyleKind::Text { style } = &st.kind { + sample.kind = NodeKind::Text(TextData { style: style.as_text_style(), ..TextData::new("Ag") }); + } + sample.bound.clone_from(&st.bound); + self.bound(&sample) + }; + let entries = none.entries.clone(); + if !entries.is_empty() { + f.push(("variableConsumptionMap", o(vec![("entries", arr(entries))]))); + } match &st.kind { StyleKind::Paint { paints } => f.push(("fillPaints", self.paints(paints, true, &none))), StyleKind::Effect { effects } => { diff --git a/crates/format/src/rest/mod.rs b/crates/format/src/rest/mod.rs index 5c4ed79..798b264 100644 --- a/crates/format/src/rest/mod.rs +++ b/crates/format/src/rest/mod.rs @@ -185,11 +185,28 @@ pub fn import_with(v: &Value, variables: Option<&Value>, images: ImageResolver) let _ = cx.doc.remove(p); } cx.doc.current_page = cx.doc.first_page(); + cx.styles_take_bindings(); cx.finish(); let warnings = cx.warnings.finish(); Ok((cx.doc, warnings)) } +/// The key a style keeps a layer's binding `key` under, when the layer's `slot` link (fill, +/// stroke, effect, grid, text) to the style covers that field: a paint style's paints are its +/// `fills`, whichever list of the layer shows them. +fn style_binding(slot: &str, key: &str) -> Option { + let paints = |rest: &str| format!("fills/{rest}"); + match (slot, key) { + ("fill", "fill" | "fills" | "fills/0") | ("stroke", "stroke") => Some("fills/0/color".into()), + ("fill", k) => k.strip_prefix("fills/").map(paints), + ("stroke", k) => k.strip_prefix("strokes/").map(paints), + ("effect", k) if k.starts_with("effects/") => Some(k.into()), + ("grid", k) if k.starts_with("layoutGrids/") => Some(k.into()), + ("text", "fontSize" | "lineHeight" | "letterSpacing" | "fontFamily" | "fontStyle") => Some(key.into()), + _ => None, + } +} + /// Put back the shape a layer was before the `.fig` export wrote it as the outline of its stroke /// (`outlinedStroke`: `{kind, fills, strokes, stroke}` as the document holds them). Anything that /// isn't such a shape is ignored. @@ -669,7 +686,30 @@ impl<'a> Ctx<'a> { }; let name = text(d, "name").filter(|s| !s.is_empty()).map_or_else(|| format!("Style {id}"), |s| clip_str(s).to_string()); let description = text(d, "description").map(|s| clip_str(s).to_string()).unwrap_or_default(); - self.doc.styles.push(Style { id: id.to_string(), name, description, kind }); + // The sample layer's variable bindings on what the style holds are the style's. + let mut sample = Node::new(NodeId(0), NodeKind::Rectangle, "", 0.0, 0.0); + let slot = match &kind { + StyleKind::Paint { paints } => { + sample.fills.clone_from(paints); + "fill" + } + StyleKind::Effect { effects } => { + sample.effects.clone_from(effects); + "effect" + } + StyleKind::Grid { grids } => { + sample.kind = NodeKind::Frame; + sample.grids.clone_from(grids); + "grid" + } + StyleKind::Text { .. } => { + sample.kind = NodeKind::Text(text::text_data(self, node)); + "text" + } + }; + self.bindings(&mut sample, node); + let bound = sample.bound.iter().filter_map(|(k, v)| Some((style_binding(slot, k)?, v.clone()))).collect(); + self.doc.styles.push(Style { id: id.to_string(), name, description, kind, bound }); } } @@ -696,7 +736,42 @@ impl<'a> Ctx<'a> { let name = meta.and_then(|m| text(m, "name")).filter(|s| !s.is_empty()).map_or_else(|| format!("Style {sid}"), |s| clip_str(s).to_string()); let description = meta.and_then(|m| text(m, "description")).map(|s| clip_str(s).to_string()).unwrap_or_default(); - self.doc.styles.push(Style { id: sid.to_string(), name, description, kind }); + self.doc.styles.push(Style { id: sid.to_string(), name, description, kind, bound: BTreeMap::new() }); + } + } + + /// Variables bound to what a layer takes from a shared style belong to the style: a style + /// without bindings of its own takes them from the first layer that has some, and the layers + /// keep none on those fields (the style gives them their values, each in its own modes). + fn styles_take_bindings(&mut self) { + let ids: Vec = self.doc.nodes().filter(|n| !n.styles.is_empty() && !n.bound.is_empty()).map(|n| n.id).collect(); + for id in ids { + let Some((refs, bound)) = self.doc.node(id).map(|n| (n.styles.clone(), n.bound.clone())) else { continue }; + let mut moved: Vec = Vec::new(); + for (slot, sid) in [("fill", refs.fill), ("stroke", refs.stroke), ("effect", refs.effect), ("grid", refs.grid), ("text", refs.text)] { + let Some(sid) = sid else { continue }; + let found: Vec<(String, String, String)> = + bound.iter().filter_map(|(k, v)| Some((k.clone(), style_binding(slot, k)?, v.clone()))).collect(); + if found.is_empty() { + continue; + } + // A style that has bindings already keeps them: only what agrees leaves the layer. + let Some(st) = self.doc.styles.iter_mut().find(|s| s.id == sid) else { continue }; + let takes = st.bound.is_empty(); + for (key, style_key, var) in found { + if takes { + st.bound.insert(style_key, var); + } else if st.bound.get(&style_key) != Some(&var) { + continue; + } + moved.push(key); + } + } + if !moved.is_empty() + && let Ok(n) = self.doc.get_mut(id) + { + n.bound.retain(|k, _| !moved.contains(k)); + } } } diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index b68b8a4..c2b7169 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -174,6 +174,7 @@ name = "English" "Branch updated from main" = "Branch updated from main" "Branch {name}" = "Branch {name}" "Branches" = "Branches" +"Bound to {name}. Click to detach the variable." = "Bound to {name}. Click to detach the variable." "Bring forward" = "Bring forward" "Bring the main file's changes into the branch" = "Bring the main file's changes into the branch" "Bring to front" = "Bring to front" @@ -1369,6 +1370,7 @@ name = "English" "Used in {files} · {instances}" = "Used in {files} · {instances}" "Username" = "Username" "Value" = "Value" +"Uses variables: each layer shows them in its own mode" = "Uses variables: each layer shows them in its own mode" "Variable" = "Variable" "Variable collections" = "Variable collections" "Variable modes" = "Variable modes" diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index 996602a..a8731af 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -175,6 +175,7 @@ status = "draft — needs native review" "Branch updated from main" = "Rama actualizada desde la principal" "Branch {name}" = "Rama {name}" "Branches" = "Ramas" +"Bound to {name}. Click to detach the variable." = "Vinculado a {name}. Haz clic para separar la variable." "Bring forward" = "Traer adelante" "Bring the main file's changes into the branch" = "Trae a la rama los cambios del archivo principal" "Bring to front" = "Traer al frente" @@ -1370,6 +1371,7 @@ status = "draft — needs native review" "Used in {files} · {instances}" = "Usado en {files} · {instances}" "Username" = "Usuario" "Value" = "Valor" +"Uses variables: each layer shows them in its own mode" = "Usa variables: cada capa las muestra en su propio modo" "Variable" = "Variable" "Variable collections" = "Colecciones de variables" "Variable modes" = "Modos de variables" @@ -1897,6 +1899,7 @@ status = "draft — needs native review" "stroke.set" = "Establecer trazo" "stroke.widthProfile" = "Perfil de ancho" "style.apply" = "Aplicar estilo" +"style.bind" = "Vincular variable en estilo" "style.create" = "Crear estilo" "style.delete" = "Eliminar estilo" "style.detach" = "Separar estilo" diff --git a/crates/ui-egui/src/style_picker.rs b/crates/ui-egui/src/style_picker.rs index bc88307..3a4e7b1 100644 --- a/crates/ui-egui/src/style_picker.rs +++ b/crates/ui-egui/src/style_picker.rs @@ -9,7 +9,7 @@ //! it. Colour slots also list colour variables to bind (this file's, then each enabled library's). use egui::{Align2, Color32, CornerRadius, Id, Rect, Response, Sense, Stroke, Ui, pos2, vec2}; -use figcraft_color::Paint; +use figcraft_color::{Paint, PaintKind}; use figcraft_doc::{Document, GridKind, Node, NodeId, PublishedLibrary, Style, StyleKind, TextStyle, VariableType, trim_num}; use figcraft_engine::library; use serde_json::{Value, json}; @@ -269,6 +269,8 @@ enum Act { Edit(String), Back, EditPaint(String, usize), + /// Unbind the variable on a field of a style. + Unbind(String, String), /// A text style's new definition (from the editor's fields). SetText(String, TextStyle), UpdateFromSelection(String), @@ -424,6 +426,10 @@ fn style_row(ui: &mut Ui, s: &Style, key: Key, slot: StyleSlot, applied: bool, e crate::bidi::paint_name(ui.painter(), pos2(r.min.x + 26.0, r.center().y), leaf(&s.name), regular(11.0), t.menu_text, W - 64.0); } } + // A style with variables bound inside it is marked (its users follow their own modes). + if !s.bound.is_empty() { + icons::paint(ui.painter(), Rect::from_center_size(pos2(edit_r.min.x - 10.0, r.center().y), vec2(11.0, 11.0)), Icon::Variable, t.menu_dim); + } if hovered && editable { if menu_icon(ui, edit_r, Icon::Adjust, tr("Edit style"), false).clicked() { out.act = Some(Act::Edit(s.id.clone())); @@ -433,6 +439,7 @@ fn style_row(ui: &mut Ui, s: &Style, key: Key, slot: StyleSlot, applied: bool, e icons::paint(ui.painter(), edit_r.shrink(5.0), Icon::Check, t.menu_text); } let tip = match (s.description.is_empty(), text_def(s)) { + (true, None) if !s.bound.is_empty() => Some(format!("{}\n{}", s.name, tr("Uses variables: each layer shows them in its own mode"))), (true, None) => None, (true, Some(def)) => Some(format!("{}\n{} {} · {}", s.name, def.font_family, def.font_style, text_meta(def))), (false, _) => Some(s.description.clone()), @@ -642,6 +649,47 @@ fn property_row(ui: &mut Ui, label: &str, paint: Option<&Paint>, slot: StyleSlot paint.is_some() && resp.clicked() } +/// The variable on `field` of the style a [`property_row`] just drew: its name at the row's end +/// (a click unbinds it), or, with the pointer over the row, the button that opens the variable +/// picker for it. True when the pointer is on it (the row's own click isn't meant then). +fn binding_cell(ui: &mut Ui, doc: &Document, style: &Style, field: &str, kind: figcraft_doc::VariableType, out: &mut Frame) -> bool { + let t = Tokens::get(ui.ctx()); + let row = ui.min_rect(); + let row = Rect::from_min_max(pos2(row.min.x, row.max.y - ROW_H), row.max); + let Some(name) = style.bound.get(field).map(|v| doc.variables.get(v).map_or(v.as_str(), |x| x.name.as_str())) else { + let b = Rect::from_center_size(pos2(row.max.x - 13.0, row.center().y), vec2(20.0, 20.0)); + if ui.rect_contains_pointer(row) && menu_icon(ui, b, Icon::Variable, tr("Apply variable"), false).clicked() { + crate::variable_ui::open_style(ui, &style.id, field, kind, row.left_bottom()); + } + return ui.rect_contains_pointer(b); + }; + let id = Id::new(("style_binding", &style.id, field)); + let shown = widgets::elide_font(ui, leaf(name), 90.0, regular(10.0)); + let w = ui.painter().layout_no_wrap(shown.clone(), regular(10.0), t.menu_dim).size().x + 22.0; + let chip = Rect::from_min_max(pos2(row.max.x - w - 4.0, row.min.y + 4.0), pos2(row.max.x - 4.0, row.max.y - 4.0)); + let resp = + ui.interact(chip, id, Sense::click()).on_hover_text(crate::i18n::trf("Bound to {name}. Click to detach the variable.", &[("name", &name)])); + ui.painter().rect_filled(chip, CornerRadius::same(4), if resp.hovered() { t.menu_hover } else { t.menu }); + ui.painter().rect_stroke(chip, CornerRadius::same(4), Stroke::new(1.0, t.menu_dim.gamma_multiply(0.4)), egui::StrokeKind::Inside); + icons::paint(ui.painter(), Rect::from_center_size(pos2(chip.min.x + 9.0, chip.center().y), vec2(11.0, 11.0)), Icon::Variable, t.menu_dim); + ui.painter().text(pos2(chip.min.x + 18.0, chip.center().y), Align2::LEFT_CENTER, shown, regular(10.0), t.menu_text); + if resp.clicked() { + out.act = Some(Act::Unbind(style.id.clone(), field.to_string())); + } + resp.hovered() +} + +/// The field of effect `fx` (item `i` of an effect style) a variable binds from its row, and its +/// type: a shadow's colour, a blur's radius. +fn effect_binding(fx: &figcraft_doc::Effect, i: usize) -> Option<(String, figcraft_doc::VariableType)> { + use figcraft_doc::{EffectKind as K, VariableType as V}; + match fx.kind { + K::DropShadow(_) | K::InnerShadow(_) => Some((format!("effects/{i}/color"), V::Color)), + K::LayerBlur(_) | K::BackgroundBlur(_) => Some((format!("effects/{i}/radius"), V::Number)), + _ => None, + } +} + /// A layout grid's summary ("Columns 12", "Grid 8"). fn grid_label(g: &figcraft_doc::LayoutGrid) -> String { match g.kind { @@ -764,19 +812,27 @@ fn editor(ui: &mut Ui, doc: &Document, p: &mut Picker, out: &mut Frame) { // Top of the list paints on top: show in reverse like the Fill section. for (i, paint) in paints.iter().enumerate().rev() { let label = format!("{} {:.0}%", tr(&paint.label()), paint.opacity * 100.0); - if property_row(ui, &label, Some(paint), slot) { + let clicked = property_row(ui, &label, Some(paint), slot); + // A solid paint's colour takes a variable. + let on_binding = matches!(paint.kind, PaintKind::Solid { .. }) + && binding_cell(ui, doc, &style, &format!("fills/{i}/color"), figcraft_doc::VariableType::Color, out); + if clicked && !on_binding { out.act = Some(Act::EditPaint(style.id.clone(), i)); } } } StyleKind::Effect { effects } => { - for fx in effects.iter().rev() { + for (i, fx) in effects.iter().enumerate().rev() { property_row(ui, tr(fx.label()), None, slot); + if let Some((field, kind)) = effect_binding(fx, i) { + binding_cell(ui, doc, &style, &field, kind, out); + } } } StyleKind::Grid { grids } => { - for g in grids { + for (i, g) in grids.iter().enumerate() { property_row(ui, &grid_label(g), None, slot); + binding_cell(ui, doc, &style, &format!("layoutGrids/{i}/size"), figcraft_doc::VariableType::Number, out); } } StyleKind::Text { style: def } => text_properties(ui, &style.id, def, out), @@ -851,7 +907,9 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) { // keep it open. let picker_rect = app.ui.picker.as_ref().filter(|t| matches!(t, PickerTarget::Style { .. })).and(ctx.memory(|m| m.area_rect(Id::new("color_picker")))); - let in_popup = egui::Popup::is_any_open(ctx); + // So do clicks in the variable picker binding one of its fields (Escape closes that first). + let variables = crate::variable_ui::picker_rect(ctx); + let in_popup = egui::Popup::is_any_open(ctx) || variables.is_some(); let outside = ctx.input(|i| { i.pointer.any_pressed() && i.pointer.interact_pos().is_some_and(|pos| !rect.contains(pos) && !picker_rect.is_some_and(|r| r.contains(pos))) }) && !in_popup; @@ -900,6 +958,9 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) { } app.ui.picker = Some(PickerTarget::Style { id, index }); } + Some(Act::Unbind(id, field)) => { + app.run("style.bind", json!({"style": id, "field": field})); + } Some(Act::SetText(id, def)) => { app.run("style.update", json!({"style": id, "value": def})); } diff --git a/crates/ui-egui/src/style_picker_tests.rs b/crates/ui-egui/src/style_picker_tests.rs index 9dcfcd1..6567e42 100644 --- a/crates/ui-egui/src/style_picker_tests.rs +++ b/crates/ui-egui/src/style_picker_tests.rs @@ -16,7 +16,13 @@ fn names_split_into_folders() { let mut d = Document::new("d"); assert_eq!(next_name(&d, "Color"), "Color 1"); for n in ["Color 1", "Color 2"] { - d.styles.push(Style { id: n.into(), name: n.into(), description: String::new(), kind: StyleKind::Paint { paints: vec![] } }); + d.styles.push(Style { + id: n.into(), + name: n.into(), + description: String::new(), + kind: StyleKind::Paint { paints: vec![] }, + bound: Default::default(), + }); } assert_eq!(next_name(&d, "Color"), "Color 3"); assert!(StyleSlot::Fill.holds(&StyleKind::Paint { paints: vec![] }) && !StyleSlot::Effect.holds(&StyleKind::Paint { paints: vec![] })); @@ -160,7 +166,13 @@ fn chars_linked_to_several_styles_show_mixed() { #[test] fn local_style_drops_move_into_the_folder_above() { - let st = |id: &str, name: &str| Style { id: id.into(), name: name.into(), description: String::new(), kind: StyleKind::Paint { paints: vec![] } }; + let st = |id: &str, name: &str| Style { + id: id.into(), + name: name.into(), + description: String::new(), + kind: StyleKind::Paint { paints: vec![] }, + bound: Default::default(), + }; let (a, b, c) = (st("a", "Top"), st("b", "Brand/One"), st("c", "Brand/Two")); let shown = [&a, &b, &c]; // `Top` dropped between One and Two: into Brand, before Two. diff --git a/crates/ui-egui/src/variable_ui.rs b/crates/ui-egui/src/variable_ui.rs index 17e1b22..48fecb7 100644 --- a/crates/ui-egui/src/variable_ui.rs +++ b/crates/ui-egui/src/variable_ui.rs @@ -28,6 +28,8 @@ enum Target { Alias { variable: VariableId, mode: ModeId }, /// Use it in a prototype action (a set variable's variable, a condition's operand). Proto(crate::proto_editor::VarSlot), + /// Bind it to `field` of the shared style `style`. + Style { style: String, field: String }, } /// The open variable picker: what it does, the variable type (None: any), where it opens. @@ -188,6 +190,17 @@ pub fn open_proto(ui: &Ui, slot: crate::proto_editor::VarSlot, kind: Option Option { + picker_open(ctx).then(|| ctx.memory(|m| m.area_rect(Id::new("variable_picker_area")))).flatten() +} + /// Whether the picker is open (Escape closes it before what opened it). pub fn picker_open(ctx: &egui::Context) -> bool { ctx.data(|d| d.get_temp::(picker_id()).is_some()) @@ -362,10 +375,10 @@ pub fn picker(app: &mut FigApp, ctx: &egui::Context) { let (scope, own) = match &p.target { Target::Bind { ids, field } => (ids.first().and_then(|id| figcraft_engine::cmd::variables::field_scope(&doc, *id, field)), None), Target::Alias { variable, .. } => (None, doc.variables.get(variable)), - Target::Proto(_) => (None, None), + Target::Proto(_) | Target::Style { .. } => (None, None), }; - // Prototypes use this file's variables. - let local_only = matches!(p.target, Target::Proto(_)); + // Prototypes and styles use this file's variables. + let local_only = matches!(p.target, Target::Proto(_) | Target::Style { .. }); let keep = |lib: Option<&PublishedLibrary>, v: &Variable| { p.kind.is_none_or(|k| v.kind == k) && !(local_only && lib.is_some()) @@ -442,6 +455,9 @@ pub fn picker(app: &mut FigApp, ctx: &egui::Context) { app.run("library.aliasVariable", json!({"library": lib, "alias": pick.id, "variable": variable, "mode": mode})); } (Target::Proto(slot), _) => crate::proto_editor::pick_variable(app, slot, &pick.id), + (Target::Style { style, field }, _) => { + app.run("style.bind", json!({"style": style, "field": field, "variable": pick.id})); + } } close = true; } diff --git a/docs/components-parity.md b/docs/components-parity.md index 78ef2c3..777bd0f 100644 --- a/docs/components-parity.md +++ b/docs/components-parity.md @@ -1,6 +1,6 @@ # Components, variants, styles and variables parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (layers and assets panels behaviour suite and fixes; extended collections tail; styles and variables behaviour suite and fixes; components behaviour suite and fixes; team libraries on the server, library usage and swapping a library; extended collections, variables import/export, gradient stops and variant properties bound to variables) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (core editor leftovers: variables inside styles; layers and assets panels behaviour suite and fixes; extended collections tail; styles and variables behaviour suite and fixes; components behaviour suite and fixes; team libraries on the server, library usage and swapping a library; extended collections, variables import/export, gradient stops and variant properties bound to variables) · **Target:** Figma Design (desktop app 126.10.7) Design systems are why teams adopt Figma. **Components: breadth ~96%, ready ~72%. Styles and variables: breadth ~95%, ready ~76% (estimated).** QA'd with a synthetic design system of ~300 @@ -60,7 +60,7 @@ updates with overrides kept: `library_tests.rs`, `components_scale_tests.rs`. | Paint, text, effect, grid styles; style picker; text styles on character ranges | done | behaviour suite: `styles_behaviour_tests.rs` | | Editing a style updates its layers; detach and delete keep values; editing a styled property unlinks that style | done | an edited fill/stroke/effect/grid list or text chars unlink; applying a style replaces variables bound to what it sets; text styles on text layers only | | Organising styles: folders by `/` names, order, duplicate, rename folder, Local styles list | done | `style.move` (into a folder renames), `style.duplicate` (right below), `style.renameFolder`; the page's Local styles list by type and folder (drag to reorder, right-click) | -| Variables inside styles (colour variables in paint and shadow styles) | missing | 2–3 h | +| Variables inside styles (paint, effect, grid and text styles; each user resolves them in its own modes) | done | `style.bind`, `style_vars_tests`; the editor binds paint colours, shadow colours, blur radii and grid sizes, the rest through the command | | Variables: colour, number, string, boolean; collections, modes, aliases, scopes, groups | done | behaviour suite: `variables_behaviour_tests.rs`; aliases same type, no loops (also on create); scopes filter pickers only | | Modes: add, duplicate, rename, delete, set as default / reorder | done | layers on Auto follow a new default; explicit modes stay | | Deleting a bound variable or its collection | done | layers keep their last value and are unbound; aliases to it take its values | @@ -76,6 +76,7 @@ updates with overrides kept: `library_tests.rs`, `components_scale_tests.rs`. | Date | Change | Summary | |---|---|---| +| 2026-10-13 | minor | Variables inside styles (M3): a shared style can bind variables to fields of its value (`style.bind {style, field, variable?}`: a paint style's colours and gradient stops, an effect style's colour, radius, spread and offsets, a grid style's size, count, gutter and offset, a text style's font size, line height, letter spacing, family and style). Every layer using the style shows those fields resolved in its own modes and stays linked when a variable or a mode changes; the style keeps them resolved in the default modes. A style made from a layer keeps that layer's bindings; a new value drops bindings to what is gone. The style editor binds from its property rows (paint colour, shadow colour, blur radius, grid size) and detaches from the variable's chip; the style picker marks styles that use variables. `.fig` export and import carry the bindings on the style, REST layers hand theirs to the style they use. Left: binding text style fields and the other effect and grid fields from the editor (commands only), library variables in the picker, a style's library copy. Numbers unchanged. | | 2026-10-12 | minor | Layers and Assets panels behaviour suite: Assets folders by page, frame and slash names; a leading `.` or `_` keeps a component out of the library even after it was published, and removing it brings the component back | | 2026-10-12 | minor | Extended collections' tail (gap 14): variables a library adds to a collection this file extends now count as a library update of their own (listed by name without a copy yet) and arrive inheriting when accepted, instead of waiting for the library's next other update; a library collection that is itself an extended collection can be extended again, values flowing down the chain as each file accepts updates with every level's overrides kept (checked end to end). Styles & variables breadth 94% → 95%, ready unchanged (76%), 3–5 h → 2–4 h; gap 14 1–2 h → 0–1 h (real-library checks left); feature breadth (~82%) and ready for real work (~51%) unchanged at this rounding; full parity ~305–594 h → ~304–593 h | | 2026-10-11 | minor | Styles and variables behaviour suite from the public documentation (18 tests) and its fixes (styled properties edited away unlink, deleted variables unbind with values kept, type-checked bindings, alias checks on create, text styles on text only); style folders, order and duplicates with a Local styles list; mode duplicate and default; code syntax. Found missing: variables inside styles. Styles and variables breadth ~92% → ~94%, ready ~72% → ~76%; remaining here 7–14 h → 9–17 h | diff --git a/docs/mcp.md b/docs/mcp.md index 87bd54f..20f91c7 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1260,6 +1260,13 @@ permissions: without it nothing is downloaded); `plugin.directory.publish {path ### Effects +`style.bind {style, field, variable?}` binds a variable inside a shared style (no `variable`: +unbinds): `fill` or `fills//color` and `fills//stops//color` on a paint style, +`effects//` on an effect style, +`layoutGrids//` on a grid style, `fontSize`, `lineHeight`, +`letterSpacing`, `fontFamily`, `fontStyle` on a text style. Each layer using the style shows the +field in its own modes and stays linked; `style.list` gives a style's `bound` map. + `effect.add {ids?, type? | effect?}` adds an effect (`dropShadow`, `innerShadow`, `layerBlur`, `backgroundBlur`, `noise`, `texture`, `glass`) with its defaults, or the full `effect` object; `effect.set {ids?, index, effect}` replaces one. A layer paints its drop shadows first, then its diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 3ec17bf..aae5777 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-13 · **Change:** minor (core editor leftovers: inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; core editor leftovers: command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; core editor leftovers: a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; core editor leftovers: delays on mouse triggers and manual overlay offsets, wrapped-line alignment; core editor leftovers: wrapped-line alignment; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%; last major: full re-measure against Figma desktop 126.10.7) · **Target:** Figma Design (desktop app 126.10.7, macOS) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (core editor leftovers: variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; core editor leftovers: inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; core editor leftovers: command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; core editor leftovers: a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; core editor leftovers: delays on mouse triggers and manual overlay offsets, wrapped-line alignment; core editor leftovers: wrapped-line alignment; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%; last major: full re-measure against Figma desktop 126.10.7) · **Target:** Figma Design (desktop app 126.10.7, macOS) The authoritative assessment of how close FigCraft is to Figma Design. [`ROADMAP.md`](../ROADMAP.md) summarizes it; [`gaps.md`](gaps.md) is the ranked work list; the measured menu checklist is @@ -105,7 +105,7 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects | 9 | Auto layout & constraints | 9 | 98% | 74% | Behaviour against real Figma files (behaviour suite from the documentation, `fr` tracks like CSS, vertical wrap, auto gaps evenly/around, cell alignment and wrapped-line alignment landed) ([auto-layout-parity](auto-layout-parity.md)) | 3–6 | | 10 | Text | 8 | 90% | 70% | Spell check, text metrics on real files and in other fonts (text box sizes measured against documents re-saved by other software: all 230 self-sizing boxes in 7 documents come out to the pixel, one font family; behaviour suite with CJK line breaking, vertical trim and carets by grapheme cluster landed; lists per paragraph with nesting and list spacing, hanging punctuation and lists, decoration style, thickness, offset, skip ink and colour landed) | 3–7 | | 11 | Components & variants | 9 | 96% | 72% | Real (not synthetic) libraries, `.fig` libraries, component playground (behaviour suite from the documentation, overrides of padding, gap, grids, exports and interactions, refused geometry overrides, variant-switch and Assets-swap override rules, top-left default variant landed) ([components-parity](components-parity.md)) | 4–9 | -| 12 | Styles & variables | 5 | 95% | 76% | Variables inside styles, real-library QA (behaviour suite from the documentation, style folders and order, mode order, code syntax, extended collections with their tail (new library variables as updates, chains through libraries) and variables import/export landed) ([components-parity](components-parity.md)) | 2–4 | +| 12 | Styles & variables | 5 | 95% | 76% | Real-library QA (variables inside styles, behaviour suite from the documentation, style folders and order, mode order, code syntax, extended collections with their tail (new library variables as updates, chains through libraries) and variables import/export landed) ([components-parity](components-parity.md)) | 2–4 | | 13 | Prototyping | 6 | 92% | 74% | Video triggers and media actions, controllers on macOS, Linux and FreeBSD, real prototype QA (behaviour suite from the documentation, delays on mouse triggers, manual overlay offsets, key combinations, controller buttons on key/gamepad triggers (Windows and the browser), widgets reacting in prototypes, interactive components playing their variants, drag-driven animations, physical springs landed) ([prototyping-parity](prototyping-parity.md)) | 2–5 | | 14 | Import/export | 6 | 86% | 58% | `.fig` import's tail (best-effort import, editable vector networks, stand-ins for non-design layers and variable prototype actions landed; no real-file checks yet), checking the rest of the experimental `.fig` export (landed; written files open and draw in other software) on effects, images, prototypes and real files, HEIC/video import, CMYK output intent ([file-format-parity](file-format-parity.md)) | 5–10 | | 15 | Dev Mode / inspect | 2 | 96% | 72% | Import lines for connected instances inside generated layer trees; an editor extension is left to the MCP server (inspect behaviour suite with colour formats, rem and icon and image assets, code connect, plugins in Dev Mode and dev resources links landed) ([dev-mode-parity](dev-mode-parity.md)) | 0–1 | @@ -625,6 +625,7 @@ Carried over from the ROADMAP's "Shipped so far" (2026-10-10). The test count th | Date | Change | Summary | |---|---|---| +| 2026-10-13 | minor | Variables inside styles (M3): a shared style can bind variables to fields of its value (`style.bind {style, field, variable?}`: a paint style's colours and gradient stops, an effect style's colour, radius, spread and offsets, a grid style's size, count, gutter and offset, a text style's font size, line height, letter spacing, family and style). Every layer using the style shows those fields resolved in its own modes and stays linked when a variable or a mode changes; the style keeps them resolved in the default modes. A style made from a layer keeps that layer's bindings; a new value drops bindings to what is gone. The style editor binds from its property rows (paint colour, shadow colour, blur radius, grid size) and detaches from the variable's chip; the style picker marks styles that use variables. `.fig` export and import carry the bindings on the style, REST layers hand theirs to the style they use. Left: binding text style fields and the other effect and grid fields from the editor (commands only), library variables in the picker, a style's library copy. Numbers unchanged. | | 2026-10-13 | minor | Effects on groups, inner shadows and background blur (M3): a group is no outline of its own, so its inner shadows are cast inwards from around what its layers draw together (their pixels, over them: no shade at a seam between two layers) and its background blur and glass show the backdrop through the outlines of its layers (under them). Canvas and PNG; SVG embeds a group with an inner shadow as a render and its background blur as an image under the layers; PDF places both as images, the blur matching the PNG. Where the group holds one shape the pixels match that shape, or a frame filled like it, carrying the effect (`group_effects_tests` in render, svg and pdf). Offsets and spreads of a group's inner shadow go by whole pixels. Numbers unchanged. | | 2026-10-13 | minor | Text made by commands on a dark page (M1): in the app, `text.create` and `node.create {type: text}` that name no fill give white text on a dark page, by the same rule as the Text tool (the page's colour decides, also inside a frame); a named `fill`, `fills` or `fillStyle` is kept. Sessions without an interface (CLI, MCP, exports) still make black text everywhere. Numbers unchanged. | | 2026-10-13 | minor | Frames and groups, a one-step convert (M1): `object.convertToGroup` and `object.convertToFrame` (the Design panel's type menu on a frame or a group) change the layer in place, its layers staying where they are on the page. A frame turned into a group loses what only a frame has (auto layout, with the positions it gave kept and fill children fixed; fills, strokes, corner radius, clipping, layout grids, guides, scrolling) and takes its layers' box; a group turned into a frame keeps its box, with no fill and no clipping. Instances, main components, layers inside instances and empty frames are refused with the reason, all or nothing; one undo step. Numbers unchanged. |