mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-09 11:54:10 +00:00
M3: a layer binds an effect's field only when its effect has it
`variable.bind` on a layer checked only the variable's type against the field's scope, so `effects/0/refraction` on a drop shadow, or a binding on an effect the layer doesn't have (`effects/9/radius`), was kept and did nothing. An effect style already refuses both. The layer now uses the same check: effect `i` must exist and have the field, and the field's type comes from that effect. A binding an older file has on such a field still does nothing, and still unbinds.
This commit is contained in:
+2
-1
File diff suppressed because one or more lines are too long
@@ -29,13 +29,7 @@ pub fn field_type(kind: &StyleKind, key: &str) -> Option<VariableType> {
|
||||
};
|
||||
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),
|
||||
(EffectKind::Glass(_), f) if figcraft_doc::Glass::BOUND_FIELDS.contains(&f) => Some(Number),
|
||||
_ => None,
|
||||
},
|
||||
(StyleKind::Effect { effects }, Some((BoundList::Effects, i, field))) => effect_field_type(&effects.get(i)?.kind, field),
|
||||
(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),
|
||||
@@ -46,6 +40,19 @@ pub fn field_type(kind: &StyleKind, key: &str) -> Option<VariableType> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The type of variable an effect of `kind` takes on `field` (the last part of an
|
||||
/// `effects/<i>/<field>` key), on a layer or in an effect style; None when it has no such field.
|
||||
pub fn effect_field_type(kind: &EffectKind, field: &str) -> Option<VariableType> {
|
||||
use VariableType::{Color, Number};
|
||||
match (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),
|
||||
(EffectKind::Glass(_), f) if figcraft_doc::Glass::BOUND_FIELDS.contains(&f) => Some(Number),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The variable scopes a picker offers for `key` of a style of `kind`: a variable scoped to any one
|
||||
/// of them fits. A paint style's colors take fill and stroke variables, an effect style's fields
|
||||
/// take effect variables, and a text style's fields take the variables of their font property.
|
||||
|
||||
@@ -129,6 +129,37 @@ fn glass_fields_take_number_variables() {
|
||||
assert!(s.execute("style.bind", &json!({"style": fx, "field": "effects/0/spread", "variable": "large"})).is_err(), "glass has no spread");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_layer_binds_only_fields_its_effects_have() {
|
||||
let (mut s, r) = setup();
|
||||
ex(&mut s, "effect.add", json!({"ids": [r], "type": "dropShadow"}));
|
||||
ex(&mut s, "effect.add", json!({"ids": [r], "type": "glass"}));
|
||||
// As an effect style does: a drop shadow has no refraction, glass no spread, and there is no
|
||||
// effect 5. Nothing is bound.
|
||||
for (field, var, says) in [
|
||||
("effects/0/refraction", "large", "no field"),
|
||||
("effects/1/spread", "large", "no field"),
|
||||
("effects/5/radius", "large", "no field"),
|
||||
("effects/1/lightAngle", "brand", "doesn't take a color variable"),
|
||||
("effects/0/color", "large", "doesn't take a number variable"),
|
||||
] {
|
||||
let e = s.execute("variable.bind", &json!({"ids": [r], "field": field, "variable": var})).unwrap_err().to_string();
|
||||
assert!(e.contains(says), "{field} ← {var}: {e}");
|
||||
}
|
||||
assert!(s.doc().unwrap().node(r).unwrap().bound.is_empty());
|
||||
// The fields each effect has still bind.
|
||||
ex(&mut s, "variable.bind", json!({"ids": [r], "field": "effects/0/spread", "variable": "small"}));
|
||||
ex(&mut s, "variable.bind", json!({"ids": [r], "field": "effects/1/refraction", "variable": "large"}));
|
||||
assert_eq!(s.doc().unwrap().node(r).unwrap().bound.len(), 2);
|
||||
// A binding an older file already has on a field its effect lacks does nothing, and unbinds.
|
||||
let large = s.doc().unwrap().variables.get("large").unwrap().id.clone();
|
||||
s.doc_mut().unwrap().get_mut(r).unwrap().bound.insert("effects/0/refraction".into(), large);
|
||||
ex(&mut s, "variable.set", json!({"variable": "large", "value": 40}));
|
||||
assert!(matches!(&s.doc().unwrap().node(r).unwrap().effects[0].kind, EffectKind::DropShadow(_)));
|
||||
ex(&mut s, "variable.bind", json!({"ids": [r], "field": "effects/0/refraction"}));
|
||||
assert!(!s.doc().unwrap().node(r).unwrap().bound.contains_key("effects/0/refraction"));
|
||||
}
|
||||
|
||||
fn ex_value(s: &mut Session, cmd: &str, p: serde_json::Value) -> serde_json::Value {
|
||||
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}"))
|
||||
}
|
||||
|
||||
@@ -788,6 +788,9 @@ pub(crate) fn bind_variable(cmd: &str, doc: &mut Document, ids: &[NodeId], field
|
||||
for i in ids {
|
||||
match field.strip_prefix(crate::components::PROP_BINDING) {
|
||||
Some(name) => super::component::check_prop_binding(doc, *i, name, v.kind).map_err(|e| bad(cmd, e))?,
|
||||
None if !super::variables::has_field(doc, *i, field) => {
|
||||
return Err(bad(cmd, format!("the layer has no field `{field}` to bind")));
|
||||
}
|
||||
None if !super::variables::field_takes(doc, *i, field, v.kind) => {
|
||||
return Err(bad(cmd, format!("`{field}` doesn't take a {} variable", v.kind.id())));
|
||||
}
|
||||
|
||||
@@ -654,10 +654,28 @@ pub fn field_scope(doc: &Document, node: NodeId, field: &str) -> Option<&'static
|
||||
})
|
||||
}
|
||||
|
||||
/// The type of variable effect `i` of `node` takes on `field`, as an effect style's does: None when
|
||||
/// the layer has no effect `i` or that effect has no such field.
|
||||
fn effect_field(doc: &Document, node: NodeId, i: usize, field: &str) -> Option<VariableType> {
|
||||
crate::style_vars::effect_field_type(&doc.node(node)?.effects.get(i)?.kind, field)
|
||||
}
|
||||
|
||||
/// Whether `node` has the field a binding key names. Only effect fields are checked: a layer has
|
||||
/// `effects/<i>/<field>` when its effect `i` has that field (a drop shadow has no `refraction`).
|
||||
pub fn has_field(doc: &Document, node: NodeId, field: &str) -> bool {
|
||||
match super::lists::list_binding(field) {
|
||||
Some((BoundList::Effects, i, f)) => effect_field(doc, node, i, f).is_some(),
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether property `field` of `node` takes a variable of type `kind`: the type its scope belongs
|
||||
/// to (number variables also fill text content), booleans for visibility, numbers for layout grid
|
||||
/// fields; fields without a known type take any.
|
||||
/// fields, an effect's field the type its effect takes there; fields without a known type take any.
|
||||
pub fn field_takes(doc: &Document, node: NodeId, field: &str, kind: VariableType) -> bool {
|
||||
if let Some((BoundList::Effects, i, f)) = super::lists::list_binding(field) {
|
||||
return effect_field(doc, node, i, f) == Some(kind);
|
||||
}
|
||||
match (field, field_scope(doc, node, field)) {
|
||||
("visible", _) => kind == VariableType::Boolean,
|
||||
(_, Some(scope)) => scope_known(kind, scope),
|
||||
|
||||
@@ -172,7 +172,8 @@ fn effects_follow_their_variables() {
|
||||
bind(&mut s, "effects/0/offsetY", "y");
|
||||
bind(&mut s, "effects/0/color", "shadow");
|
||||
bind(&mut s, "effects/1/radius", "blur");
|
||||
bind(&mut s, "effects/9/radius", "blur");
|
||||
// There is no effect 9 to bind (see `binding_lists_tests::a_layer_binds_only_fields_its_effects_have`).
|
||||
assert!(s.execute("variable.bind", &json!({"ids": [r], "field": "effects/9/radius", "variable": "blur"})).is_err());
|
||||
let fx = |s: &Session| s.doc().unwrap().node(r).unwrap().effects.clone();
|
||||
match &fx(&s)[0].kind {
|
||||
figcraft_doc::EffectKind::DropShadow(sh) => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Components, variants, styles and variables parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-02 · **Change:** minor (glass effects take variables; Delete on a layer inside an instance hides it as an override instead of being refused, as the help center says: "Figma will only toggle the layer's visibility instead of removing it"; library analytics list how many layers and files use each style and variable; SwiftUI and Compose code names a bound variable by its iOS or Android code syntax; library analytics count insertions and show insertions and detachments over the past 30 days, 90 days or year; a collection of this file can be extended (Extend collection on its row, `variable.extendCollection`), its extension following its edits at once, and extended collections extend again in the file; a typed or stored variable, collection or mode id names only the object with that id (#1613); colour variables can be calculated from other colours per mode — `{alpha}` replaces a colour's alpha, `{mix}` blends two — and follow them (#1280); stored variable and collection references keep their identity when another object is named like their id (follow-up to #1542); a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; opening a file contacts no team library server it names until you check it (#1097); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); components can't contain their own instances, directly or through others (#1084); the instance-swap menus have a search field (#1338); an instance-swap property's default menu scrolls (#1338); variables on a text style's fields from the style editor; instance-swap properties in the component playground; duplicating or Alt-dragging a main component makes an instance of it (#892); a bound colour variable's alpha applies once (#802); variables import reads `variables` outside `meta` and warns when it imports none (#790); variant properties reorder by drag; library files saved right after a read are read again; Hide when publishing note corrected; size and resizing overrides of layers inside instances (#731); variables export in the browser as a download; core editor leftovers: component playground, variables inside styles; layers and assets panels behaviour suite and fixes; extended collections tail; styles and variables behaviour suite and fixes; components behaviour suite and fixes; team libraries on the server, library usage and swapping a library; extended collections, variables import/export, gradient stops and variant properties bound to variables) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (a layer's effect binding takes only a field its effect has; glass effects take variables; Delete on a layer inside an instance hides it as an override instead of being refused, as the help center says: "Figma will only toggle the layer's visibility instead of removing it"; library analytics list how many layers and files use each style and variable; SwiftUI and Compose code names a bound variable by its iOS or Android code syntax; library analytics count insertions and show insertions and detachments over the past 30 days, 90 days or year; a collection of this file can be extended (Extend collection on its row, `variable.extendCollection`), its extension following its edits at once, and extended collections extend again in the file; a typed or stored variable, collection or mode id names only the object with that id (#1613); colour variables can be calculated from other colours per mode — `{alpha}` replaces a colour's alpha, `{mix}` blends two — and follow them (#1280); stored variable and collection references keep their identity when another object is named like their id (follow-up to #1542); a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; opening a file contacts no team library server it names until you check it (#1097); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); components can't contain their own instances, directly or through others (#1084); the instance-swap menus have a search field (#1338); an instance-swap property's default menu scrolls (#1338); variables on a text style's fields from the style editor; instance-swap properties in the component playground; duplicating or Alt-dragging a main component makes an instance of it (#892); a bound colour variable's alpha applies once (#802); variables import reads `variables` outside `meta` and warns when it imports none (#790); variant properties reorder by drag; library files saved right after a read are read again; Hide when publishing note corrected; size and resizing overrides of layers inside instances (#731); variables export in the browser as a download; core editor leftovers: component playground, variables inside styles; layers and assets panels behaviour suite and fixes; extended collections tail; styles and variables behaviour suite and fixes; components behaviour suite and fixes; team libraries on the server, library usage and swapping a library; extended collections, variables import/export, gradient stops and variant properties bound to variables) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
|
||||
Design systems are why teams adopt Figma. **Components: breadth ~96%, ready ~72%. Styles and
|
||||
variables: breadth ~95%, ready ~76% (estimated).** QA'd with a synthetic design system of ~300
|
||||
@@ -67,7 +67,7 @@ updates with overrides kept: `library_tests.rs`, `components_scale_tests.rs`.
|
||||
| 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 and colour calculations reading it keep the colour they show |
|
||||
| Code syntax per platform (Web, iOS, Android) | done | generated CSS uses the Web name, SwiftUI the iOS one and Compose the Android one (a variable without one keeps its value there); in the variables JSON; ArkUI and Flutter have no platform of their own and write values |
|
||||
| Bindings: fills, strokes, effects, opacity, padding, gaps, sizes, radius, font family/style, font size/weight, line height, letter spacing, paragraph spacing/indent, text, visibility, layout grids, instance properties | done | type-checked per field; glass fields too (light angle, intensity, refraction, depth, dispersion, frost and splay, percentages as the Design panel shows them); number variables fill text content; the Design panel's font family and font style menus start with Apply variable (string variables), and a bound one shows its variable in place of the menu; a colour variable bound to a solid paint brings its whole colour, alpha included, once (the paint's opacity restarts at 100% on binding, an opacity set while bound multiplies on top; unbinding moves the alpha back into the opacity) |
|
||||
| Bindings: fills, strokes, effects, opacity, padding, gaps, sizes, radius, font family/style, font size/weight, line height, letter spacing, paragraph spacing/indent, text, visibility, layout grids, instance properties | done | type-checked per field, an effect's only where its effect has the field (as in effect styles); glass fields too (light angle, intensity, refraction, depth, dispersion, frost and splay, percentages as the Design panel shows them); number variables fill text content; the Design panel's font family and font style menus start with Apply variable (string variables), and a bound one shows its variable in place of the menu; a colour variable bound to a solid paint brings its whole colour, alpha included, once (the paint's opacity restarts at 100% on binding, an opacity set while bound multiplies on top; unbinding moves the alpha back into the opacity) |
|
||||
| Explicit modes per frame, library collections' modes | done | stored variable and collection references resolve by exact id in export, library copies and swaps, imported aliases, alias deletion and cycle checks, Dev Inspect, the library mode picker and prototype playback; a collection or variable named like another's id no longer stands in for it there |
|
||||
| Gradient stops and variant properties bound to variables | done | a stop's binding follows it when earlier stops are removed |
|
||||
| Extended collections | done | this file's collections (Extend collection on a collection's row; following its edits at once, chains included) and library collections, including a library's own extended collections (chains through libraries); overrides per mode with reset; follow library updates, variables new to the library collection being an update of their own; modes inherited. Checked with synthetic libraries only ([gaps](gaps.md) #14) |
|
||||
@@ -78,6 +78,7 @@ updates with overrides kept: `library_tests.rs`, `components_scale_tests.rs`.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-06 | patch | M3: a layer's effect binding (`variable.bind`, `effects/<i>/<field>`) takes only a field its effect has, as an effect style's does: `effects/0/refraction` on a drop shadow, or a binding on an effect the layer doesn't have, is refused instead of being kept and doing nothing (a binding an older file has there still does nothing, and unbinds). Tests: `binding_lists_tests::a_layer_binds_only_fields_its_effects_have`. Grade unchanged. |
|
||||
| 2026-11-02 | minor | M3, #1885: glass effects take variables, as the reference tool's do: its light angle, intensity, refraction, depth, dispersion, frost and splay bind to number variables on a layer (`variable.bind`, `effects/<i>/<lightAngle\|lightIntensity\|refraction\|depth\|dispersion\|radius\|splay>`, `radius` being the frost) and in an effect style (`style.bind`), and the Design panel's glass fields take a variable like the shadow and blur fields. A variable reads as the field shows it: intensity, refraction, dispersion and splay in percent. Tests: `binding_lists_tests::glass_fields_take_number_variables`, `effects_tests::glass_fields_take_bound_numbers_as_the_panel_shows_them`. Grade unchanged. |
|
||||
| 2026-11-01 | patch | M3: Delete on a layer inside an instance hides it (an override) instead of being refused ("You can't delete a layer or object from an instance. If you try, Figma will only toggle the layer's visibility instead of removing it", Guide to auto layout); `edit.delete` reports those ids under `hidden`, the structure and the main component stay as they were, one undo step; deleting the instance itself still removes it. Found by a documentation audit of the auto layout page. Test: `components_behaviour_tests::deleting_a_layer_inside_an_instance_hides_it_instead` (the refusal row for it is gone from `what_instances_cannot_override_is_refused`). Numbers unchanged |
|
||||
| 2026-10-31 | minor | M3, gap 10: library analytics cover styles and variables. A file reports how many of its layers use each of a library's styles and variables, and Show usage (`library.usage`) lists them beside the components, by a Type choice, with their layers and files. Layers inside instances count for their component. Insertions, detachments and modes aren't counted for them yet. Numbers unchanged. Tests: `library_assets_tests`. |
|
||||
|
||||
Reference in New Issue
Block a user