diff --git a/crates/engine/src/binding_lists_tests.rs b/crates/engine/src/binding_lists_tests.rs index 493c8f60..805b0afe 100644 --- a/crates/engine/src/binding_lists_tests.rs +++ b/crates/engine/src/binding_lists_tests.rs @@ -46,7 +46,7 @@ fn a_bound_fill_colour_keeps_the_other_fills() { let fills = s.doc().unwrap().node(r).unwrap().fills.clone(); assert_eq!(fills.len(), 2, "the second fill stays"); assert_eq!(fills[0].kind, PaintKind::Solid { color: Color::from_rgb8_const(0xFF, 0, 0) }); - assert_eq!(fills[0].opacity, 0.5, "the bound paint keeps its opacity"); + assert_eq!(fills[0].opacity, 1.0, "the variable brings the whole colour, alpha included"); assert_eq!(fills[1].kind, PaintKind::Solid { color: Color::from_rgb8_const(0, 0xFF, 0) }); // Removing the bound (first) fill takes the binding with it. ex(&mut s, "fill.remove", json!({"ids": [r], "index": 0})); diff --git a/crates/engine/src/bound_alpha_tests.rs b/crates/engine/src/bound_alpha_tests.rs new file mode 100644 index 00000000..b7210f0f --- /dev/null +++ b/crates/engine/src/bound_alpha_tests.rs @@ -0,0 +1,131 @@ +//! A colour variable's alpha applies once to the paint it is bound to (#802): a `#RRGGBBAA` fill keeps +//! its alpha in the paint opacity, and binding a variable with alpha hands the variable the whole +//! colour, so the two alphas don't multiply (canvas, SVG, `.fig` round trips, mode switches). + +use figcraft_doc::NodeId; +use serde_json::json; + +use crate::Session; + +/// 0x80 / 255 and 0x40 / 255. +const HALF: f32 = 128.0 / 255.0; +const QUARTER: f32 = 64.0 / 255.0; + +fn ex(s: &mut Session, cmd: &str, p: serde_json::Value) { + s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}")); +} + +/// A rectangle filled `#0D6EFD80` and a colour variable `tint` (Light `light`, Dark `dark`). +fn setup(light: &str, dark: &str) -> (Session, NodeId) { + let mut s = Session::new(); + let r = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10, "props": {"fill": "#0D6EFD80"}})).unwrap(); + let r = NodeId(r["id"].as_u64().unwrap()); + ex(&mut s, "variable.createCollection", json!({"name": "Tokens", "modes": ["Light", "Dark"]})); + ex(&mut s, "variable.create", json!({"collection": "Tokens", "name": "tint", "type": "color", "value": light})); + ex(&mut s, "variable.set", json!({"variable": "tint", "mode": "Dark", "value": dark})); + (s, r) +} + +/// The fill's effective alpha (colour alpha times paint opacity). +fn alpha(s: &Session, r: NodeId) -> f32 { + s.doc().unwrap().node(r).unwrap().fills[0].solid_color().unwrap().a +} + +/// The alpha byte of the rectangle rendered on its own (transparent backdrop). +fn pixel_alpha(s: &Session, r: NodeId) -> u8 { + let pix = figcraft_render::render_node(s.doc().unwrap(), r, 1.0).unwrap(); + let i = ((5 * pix.width() + 5) * 4 + 3) as usize; + pix.data()[i] +} + +fn near(a: f32, b: f32) -> bool { + (a - b).abs() < 2e-3 +} + +#[test] +fn a_hex_with_alpha_keeps_its_alpha_in_the_opacity() { + let (s, r) = setup("#0D6EFD80", "#0D6EFD40"); + let p = &s.doc().unwrap().node(r).unwrap().fills[0]; + assert_eq!(p.kind, figcraft_color::PaintKind::Solid { color: figcraft_color::Color::from_rgb8_const(0x0D, 0x6E, 0xFD) }); + assert!(near(p.opacity, HALF)); + assert_eq!(pixel_alpha(&s, r), 128); +} + +#[test] +fn a_bound_variable_alpha_applies_once() { + // The fill and the variable are both #0D6EFD80: the result is 50%, not 25%. + let (mut s, r) = setup("#0D6EFD80", "#0D6EFD40"); + ex(&mut s, "variable.bind", json!({"ids": [r.0], "field": "fill", "variable": "tint"})); + assert!(near(alpha(&s, r), HALF), "{}", alpha(&s, r)); + assert_eq!(pixel_alpha(&s, r), 128); + let svg = figcraft_svg::export_svg(s.doc().unwrap(), &[r], &figcraft_svg::SvgOptions::default()).unwrap(); + assert!(svg.contains("fill=\"#0D6EFD\" fill-opacity=\"0.502\""), "{svg}"); + // Binding the same variable again changes nothing. + ex(&mut s, "variable.bind", json!({"ids": [r.0], "field": "fill", "variable": "tint"})); + assert!(near(alpha(&s, r), HALF)); +} + +#[test] +fn unbinding_keeps_the_colour_with_its_alpha_in_the_opacity() { + let (mut s, r) = setup("#0D6EFD40", "#0D6EFD"); + ex(&mut s, "variable.bind", json!({"ids": [r.0], "field": "fill", "variable": "tint"})); + assert_eq!(pixel_alpha(&s, r), 64); + ex(&mut s, "variable.bind", json!({"ids": [r.0], "field": "fill", "variable": null})); + let p = &s.doc().unwrap().node(r).unwrap().fills[0]; + assert_eq!(p.kind, figcraft_color::PaintKind::Solid { color: figcraft_color::Color::from_rgb8_const(0x0D, 0x6E, 0xFD) }); + assert!(near(p.opacity, QUARTER), "{}", p.opacity); + assert_eq!(pixel_alpha(&s, r), 64); + // Undo restores the binding and its paint. + ex(&mut s, "edit.undo", json!({})); + assert!(!s.doc().unwrap().node(r).unwrap().bound.is_empty()); + assert_eq!(pixel_alpha(&s, r), 64); +} + +#[test] +fn modes_switch_the_alpha_without_accumulating() { + let (mut s, r) = setup("#0D6EFD40", "#0D6EFD80"); + ex(&mut s, "variable.bind", json!({"ids": [r.0], "field": "fill", "variable": "tint"})); + for (mode, want) in [("Dark", HALF), ("Light", QUARTER), ("Dark", HALF), ("Light", QUARTER)] { + ex(&mut s, "variable.setMode", json!({"collection": "Tokens", "mode": mode})); + assert!(near(alpha(&s, r), want), "{mode}: {}", alpha(&s, r)); + } + // An opacity set while bound multiplies on top and survives mode switches. + ex(&mut s, "fill.set", json!({"ids": [r.0], "index": 0, "opacity": 0.5})); + ex(&mut s, "variable.setMode", json!({"collection": "Tokens", "mode": "Dark"})); + assert!(near(alpha(&s, r), HALF * 0.5)); +} + +#[test] +fn node_set_and_style_bindings_apply_the_alpha_once() { + let (mut s, r) = setup("#0D6EFD80", "#0D6EFD40"); + let var = s.doc().unwrap().variables.get("tint").unwrap().id.clone(); + ex(&mut s, "node.set", json!({"id": r.0, "props": {"boundVariables": {"fill": var}}})); + assert!(near(alpha(&s, r), HALF)); + ex(&mut s, "node.set", json!({"id": r.0, "props": {"boundVariables": {"fill": null}}})); + assert!(near(s.doc().unwrap().node(r).unwrap().fills[0].opacity, HALF)); + // A paint style made from a `#RRGGBBAA` colour, bound to the variable. + let st = s.execute("style.create", &json!({"type": "paint", "name": "Tint", "value": "#0D6EFD80"})).unwrap()["id"].clone(); + ex(&mut s, "style.bind", json!({"style": st, "field": "fills/0/color", "variable": "tint"})); + ex(&mut s, "style.apply", json!({"ids": [r.0], "style": st})); + assert!(near(alpha(&s, r), HALF), "{}", alpha(&s, r)); +} + +#[test] +fn a_fig_round_trip_keeps_the_alpha_once() { + let (mut s, r) = setup("#0D6EFD80", "#0D6EFD40"); + ex(&mut s, "variable.bind", json!({"ids": [r.0], "field": "fill", "variable": "tint"})); + ex(&mut s, "fill.set", json!({"ids": [r.0], "index": 0, "opacity": 0.5})); + let (bytes, _) = figcraft_format::fig::export(s.doc().unwrap(), None).unwrap(); + let back = crate::cmd::file::load_fig(&bytes, "x").unwrap().unwrap().doc; + let n = back.nodes().find(|n| !n.bound.is_empty()).unwrap(); + let a = n.fills[0].solid_color().unwrap().a; + assert!(near(a, HALF * 0.5), "{a}"); +} + +#[test] +fn code_gives_a_translucent_stroke_its_alpha_once() { + let (mut s, r) = setup("#0D6EFD80", "#0D6EFD40"); + ex(&mut s, "stroke.add", json!({"ids": [r.0], "color": "#FF000080"})); + let code = s.execute("inspect.code", &json!({"id": r.0})).unwrap()["code"].as_str().unwrap_or_default().to_string(); + assert!(code.contains("solid rgba(255, 0, 0, 0.50)"), "{code}"); +} diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 511e0fc6..bc13bc70 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -548,6 +548,7 @@ pub(crate) fn load_fig(bytes: &[u8], name: &str) -> Option = diff --git a/crates/engine/src/cmd/props.rs b/crates/engine/src/cmd/props.rs index ca62962d..8a1f4e1c 100644 --- a/crates/engine/src/cmd/props.rs +++ b/crates/engine/src/cmd/props.rs @@ -775,10 +775,15 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map { + if n.bound.get(field).map(String::as_str) != Some(vid) { + crate::cmd::styles::rebind_paint_alpha(&mut n.fills, &mut n.strokes, field, true); + } n.bound.insert(field.clone(), vid.to_string()); } None => { - n.bound.remove(field); + if n.bound.remove(field).is_some() { + crate::cmd::styles::rebind_paint_alpha(&mut n.fills, &mut n.strokes, field, false); + } } } } diff --git a/crates/engine/src/cmd/styles.rs b/crates/engine/src/cmd/styles.rs index 25e36e3a..adeb2250 100644 --- a/crates/engine/src/cmd/styles.rs +++ b/crates/engine/src/cmd/styles.rs @@ -458,10 +458,19 @@ fn bind_style(s: &mut Session, p: &Value) -> Result { 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, _)) => { + if style.bound.get(&key) != Some(&id) + && let StyleKind::Paint { paints } = &mut style.kind + { + rebind_paint_alpha(paints, &mut [], &key, true); + } style.bound.insert(key, id); } None => { - style.bound.remove(&key); + if style.bound.remove(&key).is_some() + && let StyleKind::Paint { paints } = &mut style.kind + { + rebind_paint_alpha(paints, &mut [], &key, false); + } } } // The style takes its default-mode value, and each layer using it its own. @@ -770,16 +779,41 @@ pub fn bind(s: &mut Session, p: &Value) -> Result { let n = doc.get_mut(i)?; match &vid { Some(v) => { + if n.bound.get(&field) != Some(v) { + rebind_paint_alpha(&mut n.fills, &mut n.strokes, &field, true); + } n.bound.insert(field.clone(), v.clone()); } None => { - n.bound.remove(&field); + if n.bound.remove(&field).is_some() { + rebind_paint_alpha(&mut n.fills, &mut n.strokes, &field, false); + } } } } ok() } +/// A colour variable bound to a solid paint owns its whole colour, alpha included. Binding restarts +/// the opacity (which holds the alpha a typed colour had, see `Paint::solid`) at 1, so the +/// variable's alpha applies once; unbinding moves the colour's alpha back into the opacity, where a +/// typed colour keeps it. An opacity set while bound multiplies on top and stays. +pub(crate) fn rebind_paint_alpha(fills: &mut [Paint], strokes: &mut [Paint], field: &str, bind: bool) { + use super::lists::{BoundList, list_binding}; + let Some((list, i, "color")) = list_binding(field) else { return }; + let paints = match list { + BoundList::Fills => fills, + BoundList::Strokes => strokes, + _ => return, + }; + if let Some(p) = paints.get_mut(i) + && let PaintKind::Solid { color } = &mut p.kind + { + p.opacity = if bind { 1.0 } else { p.opacity * color.a }; + color.a = 1.0; + } +} + /// `variable.setMode`: choose a collection's mode on frames (the page when none are given). pub fn set_mode(s: &mut Session, p: &Value) -> Result { let cmd = "variable.setMode"; @@ -916,6 +950,49 @@ pub fn apply_bindings_to(doc: &mut Document, only: Option<&std::collections::BTr apply_bindings_matching(doc, only, |_| true); } +/// Imported files (`.fig`, REST) keep a solid paint's alpha in its opacity, a bound paint's too, +/// while the bound colour brings its own alpha: take the variable's alpha back out of the opacity +/// (styles' paints and layers') before the bindings are first resolved, so it applies once. +pub(crate) fn unfold_imported_bound_alpha(doc: &mut Document) { + use super::lists::{BoundList, list_binding}; + fn alphas(vars: &Variables, bound: &BTreeMap, modes: &BTreeMap) -> Vec<(BoundList, usize, f32)> { + let found = bound.iter().filter_map(|(field, var)| match (list_binding(field)?, vars.resolve(var, modes)?) { + ((list @ (BoundList::Fills | BoundList::Strokes), i, "color"), VariableValue::Color(c)) if c.a > 0.0 && c.a < 1.0 => Some((list, i, c.a)), + _ => None, + }); + found.collect() + } + fn unfold(p: Option<&mut Paint>, a: f32) { + if let Some(p) = p + && matches!(p.kind, PaintKind::Solid { .. }) + { + p.opacity = (p.opacity / a).min(1.0); + } + } + let pending = doc.take_dirty(); + for i in 0..doc.styles.len() { + let found = doc.styles.get(i).map(|s| alphas(&doc.variables, &s.bound, &BTreeMap::new())).unwrap_or_default(); + if let Some(StyleKind::Paint { paints }) = doc.styles.get_mut(i).filter(|_| !found.is_empty()).map(|s| &mut s.kind) { + for (_, j, a) in found { + unfold(paints.get_mut(j), a); + } + } + } + let ids: Vec = doc.nodes().filter(|n| !n.bound.is_empty()).map(|n| n.id).collect(); + for id in ids { + let found = doc.node(id).map(|n| alphas(&doc.variables, &n.bound, &doc.modes_for(id))).unwrap_or_default(); + if found.is_empty() { + continue; + } + let Ok(n) = doc.get_mut(id) else { continue }; + for (list, j, a) in found { + unfold(if list == BoundList::Fills { n.fills.get_mut(j) } else { n.strokes.get_mut(j) }, a); + } + } + doc.take_dirty(); + doc.mark_dirty(pending); +} + /// Settle imported paint colours before the document is first shown. Other bindings can affect /// layout or component structure, which must be resolved through the normal edit pipeline. pub(crate) fn apply_paint_bindings_on_load(doc: &mut Document) { @@ -1104,7 +1181,6 @@ pub fn document_colors(doc: &Document, page: NodeId) -> Vec<(Color, usize)> { stack.extend(n.children.iter().copied()); for paint in n.fills.iter().chain(n.strokes.iter()).filter(|p| p.visible) { if let Some(c) = paint.solid_color() { - let c = c.with_alpha(c.a * paint.opacity); counts.entry(c.to_rgba8()).or_insert((c, 0)).1 += 1; } } diff --git a/crates/engine/src/cmd/variables.rs b/crates/engine/src/cmd/variables.rs index 3e6dc69d..53b62ee4 100644 --- a/crates/engine/src/cmd/variables.rs +++ b/crates/engine/src/cmd/variables.rs @@ -288,7 +288,11 @@ pub(crate) fn forget_variables(doc: &mut Document, gone: &BTreeSet) doc.variables.variables.retain(|v| !gone.contains(&v.id)); let users: Vec = doc.nodes().filter(|n| n.bound.values().any(|v| gone.contains(v))).map(|n| n.id).collect(); for id in users { - doc.get_mut(id)?.bound.retain(|_, v| !gone.contains(v)); + let n = doc.get_mut(id)?; + for (field, _) in n.bound.iter().filter(|(_, v)| gone.contains(*v)) { + super::styles::rebind_paint_alpha(&mut n.fills, &mut n.strokes, field, false); + } + n.bound.retain(|_, v| !gone.contains(v)); } Ok(()) } diff --git a/crates/engine/src/codegen.rs b/crates/engine/src/codegen.rs index 7d35229f..6dd80646 100644 --- a/crates/engine/src/codegen.rs +++ b/crates/engine/src/codegen.rs @@ -256,7 +256,7 @@ fn stroke_color(n: &Node) -> Option { if !n.has_visible_stroke() { return None; } - n.strokes.iter().filter(|p| p.visible).find_map(|p| p.solid_color().map(|c| c.with_alpha(c.a * p.opacity))) + n.strokes.iter().filter(|p| p.visible).find_map(|p| p.solid_color()) } fn drop_shadow(n: &Node) -> Option<&Shadow> { diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index c32a99bb..312c2e87 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1554,6 +1554,8 @@ mod binding_lists_tests; #[cfg(test)] mod booleans_tests; #[cfg(test)] +mod bound_alpha_tests; +#[cfg(test)] mod branch_tests; #[cfg(test)] mod branch_versions_tests; diff --git a/crates/ui-egui/src/colorpicker.rs b/crates/ui-egui/src/colorpicker.rs index f4a27c5e..be164835 100644 --- a/crates/ui-egui/src/colorpicker.rs +++ b/crates/ui-egui/src/colorpicker.rs @@ -120,8 +120,7 @@ fn current_paint(app: &FigApp, target: &PickerTarget) -> Option { /// Write `paint` to the target. `live` edits are part of an ongoing drag. fn write(app: &mut FigApp, target: &PickerTarget, paint: &Paint, live: bool) { if let PickerTarget::PluginField { plugin, control, .. } = target { - let c = paint.solid_color().unwrap_or(Color::BLACK); - let hex = c.with_alpha(c.a * paint.opacity).to_css_hex(); + let hex = paint.solid_color().unwrap_or(Color::BLACK).to_css_hex(); let (plugin, control) = (plugin.clone(), control.clone()); if !live { crate::plugins_ui::set_field(app, &plugin, &control, &hex); @@ -153,7 +152,7 @@ fn write(app: &mut FigApp, target: &PickerTarget, paint: &Paint, live: bool) { } PickerTarget::Variable { id, mode } => { let c = paint.solid_color().unwrap_or(Color::BLACK); - app.session.execute("variable.set", &json!({"variable": id, "mode": mode, "value": c.with_alpha(c.a * paint.opacity).to_css_hex()})) + app.session.execute("variable.set", &json!({"variable": id, "mode": mode, "value": c.to_css_hex()})) } PickerTarget::PluginField { .. } => return, PickerTarget::Style { id, index } => { @@ -1181,6 +1180,22 @@ mod selection_color_tests { assert_eq!(app.session.doc().unwrap().node(a).unwrap().fills[0].solid_color().unwrap().to_hex(), "FF0000"); } + #[test] + fn writing_a_variable_keeps_its_alpha() { + // The picker holds a colour variable's alpha as the paint opacity; writing it back applies it once. + let (mut app, _, _) = setup(); + app.session.execute("variable.createCollection", &json!({"name":"T"})).unwrap(); + let v = app.session.execute("variable.create", &json!({"collection":"T","name":"tint","type":"color","value":"#0D6EFD80"})).unwrap(); + let var = app.session.doc().unwrap().variables.get(v["id"].as_str().unwrap()).unwrap().clone(); + let target = PickerTarget::Variable { id: var.id.clone(), mode: var.values.keys().next().unwrap().clone() }; + for _ in 0..2 { + let paint = current_paint(&app, &target).unwrap(); + write(&mut app, &target, &paint, false); + } + let c = current_paint(&app, &target).unwrap().solid_color().unwrap(); + assert_eq!(c.to_css_hex().to_uppercase(), "#0D6EFD80"); + } + #[test] fn picker_does_not_follow_selection_or_document_switches() { let (mut app, _, _) = setup(); diff --git a/docs/components-parity.md b/docs/components-parity.md index 2640ed1c..46bc0fab 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-18 · **Change:** minor (variables import reads `variables` outside `meta` and warns when it imports none (#790); variant properties reorder by drag; library files saved right after a read are read again; Hide when publishing note corrected; size and resizing overrides of layers inside instances (#731); variables export in the browser as a download; core editor leftovers: component playground, variables inside styles; layers and assets panels behaviour suite and fixes; extended collections tail; styles and variables behaviour suite and fixes; components behaviour suite and fixes; team libraries on the server, library usage and swapping a library; extended collections, variables import/export, gradient stops and variant properties bound to variables) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** minor (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 @@ -65,7 +65,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 | | Code syntax per platform (Web, iOS, Android) | done | generated CSS uses the Web name; in the variables JSON; SwiftUI/Compose code doesn't name variables yet | -| Bindings: fills, strokes, effects, opacity, padding, gaps, sizes, radius, font size/weight, line height, letter spacing, paragraph spacing/indent, text, visibility, layout grids, instance properties | done | type-checked per field; number variables fill text content | +| Bindings: fills, strokes, effects, opacity, padding, gaps, sizes, radius, font size/weight, line height, letter spacing, paragraph spacing/indent, text, visibility, layout grids, instance properties | done | type-checked per field; number variables fill text content; 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 | | | Gradient stops and variant properties bound to variables | done | a stop's binding follows it when earlier stops are removed | | Extended collections | done | library collections, including a library's own extended collections (chains through libraries); overrides per mode with reset; follow library updates, variables new to the library collection being an update of their own; modes inherited. Checked with synthetic libraries only ([gaps](gaps.md) #14) | @@ -76,6 +76,7 @@ updates with overrides kept: `library_tests.rs`, `components_scale_tests.rs`. | Date | Change | Summary | |---|---|---| +| 2026-10-18 | minor | A bound colour variable's alpha applies once (#802): a `#RRGGBBAA` paint keeps its alpha in the paint opacity, and binding a variable with alpha left that opacity in place, so a 50% fill bound to a 50% variable drew at 25%. Binding (`variable.bind`, `node.set` `boundVariables`, `style.bind`) now restarts the paint's opacity at 100% and unbinding (also deleting the variable) moves the colour's alpha back into the opacity; `.fig` and REST imports take the variable's alpha back out of a bound paint's opacity, so `.fig` round trips no longer square it. Colour-picker edits of a colour variable or a plugin colour field, generated CSS strokes and the document colours list counted the paint opacity twice and now count it once (`bound_alpha_tests`). Numbers unchanged | | 2026-10-18 | minor | Variables import (#790): a `variables` map outside `meta` (or `meta.variables` next to top-level collections) is read, and an import that finds no variables, or fewer than the collections' `variableIds` list, says so in `warnings` instead of succeeding silently | | 2026-10-17 | minor | Variant properties reorder by dragging them in the Properties section, one undo step (#679; was "no drag in the panel yet"); a library file saved again right after it was read is read again, so a quick republish isn't missed (#652); the Hidden from publishing row no longer says the Hide when publishing item is missing (it landed 2026-10-12). Numbers unchanged | | 2026-10-17 | minor | Size and resizing overrides of layers inside instances (#731): a layer in an instance can hug, fill or keep a size of its own (group `resizing`: hug/fill/fixed with min and max; `size`), from the Layout panel, the canvas handles and `node.set`; they were refused, and imported ones were dropped at the first relayout. Kept through main edits of other properties, rebuilds, undo, save, `.fig` and REST import (sizes a `.fig` instance gives its layers and the REST `overrides` fields), `.fig` export and detach; reset per group and with Reset all changes (`instance_resizing_tests`). The row that listed them as not overridable was wrong. Numbers unchanged. |