M6: SwiftUI and Compose code name a bound variable by its code syntax (#1716)

Bound variables with an iOS or Android code syntax are named in generated SwiftUI and Compose in place of their value: paints, stroke weight, sizes, corner radius, gaps, padding, opacity and text. A variable with none for the platform keeps the value.
This commit is contained in:
BFlat
2026-10-31 06:49:41 +07:00
committed by GitHub
parent a1a198239e
commit 2e92d36af0
9 changed files with 181 additions and 60 deletions
+2 -1
View File
File diff suppressed because one or more lines are too long
+24 -2
View File
@@ -2,8 +2,9 @@
//! layer itself, HTML or React for its subtree, and SwiftUI, Jetpack Compose, ArkUI (HarmonyOS) or
//! Flutter (stacks for auto layout, absolute offsets otherwise). Components become components
//! whose properties are parameters and instances become calls passing their values (see
//! [`crate::component_codegen`]); variables bound to a layer become CSS custom properties. The
//! output is a starting point, not a pixel-exact translation.
//! [`crate::component_codegen`]); variables bound to a layer become CSS custom properties, and
//! their iOS and Android code syntax in SwiftUI and Compose. The output is a starting point, not a
//! pixel-exact translation.
use figcraft_color::{Color, PaintKind};
use figcraft_doc::{
@@ -373,6 +374,27 @@ fn drop_shadow(n: &Node) -> Option<&Shadow> {
})
}
/// The fields a padding's top, right, bottom and left can be bound through, closest first.
const PADDING: [[&str; 3]; 4] = [
["paddingTop", "paddingVertical", "padding"],
["paddingRight", "paddingHorizontal", "padding"],
["paddingBottom", "paddingVertical", "padding"],
["paddingLeft", "paddingHorizontal", "padding"],
];
/// What code for `platform` (a [`figcraft_doc::CODE_PLATFORMS`] key) writes for a value of `n`: the
/// code syntax of the variable bound to the first of `fields` that has one, else `value`. Without a
/// code syntax the value stays: a name made up here wouldn't exist in the code it lands in.
fn named(doc: &Document, n: &Node, platform: &str, fields: &[&str], value: String) -> String {
fields
.iter()
.find_map(|f| crate::devinspect::bound_variable(doc, n, f))
.and_then(|v| v.code_syntax.get(platform))
.map(|name| name.trim().chars().filter(|c| !c.is_control()).collect::<String>())
.filter(|name| !name.is_empty())
.unwrap_or(value)
}
fn radius(n: &Node) -> f64 {
n.corners.uniform().unwrap_or_else(|| n.corners.radii.iter().copied().fold(0.0, f64::max))
}
+43 -21
View File
@@ -1,5 +1,8 @@
use super::swift::one_line;
use super::{CodeLang, Color, Ctx, LayoutMode, Node, NodeId, NodeKind, drop_shadow, first_fill, indent, literal, num, radius, stroke_color};
use super::{
CodeLang, Color, Ctx, LayoutMode, Node, NodeId, NodeKind, PADDING, drop_shadow, first_fill, indent, literal, named, num, radius, stroke_color,
};
use figcraft_doc::Document;
// ------------------------------------------------------------------ Jetpack Compose
@@ -9,25 +12,40 @@ pub(crate) fn kotlin_color(c: Color) -> String {
format!("Color(0x{a:02X}{r:02X}{g:02X}{b:02X})")
}
/// A bound variable's Android code syntax, else `value`.
fn bound(doc: &Document, n: &Node, fields: &[&str], value: String) -> String {
named(doc, n, "ANDROID", fields, value)
}
/// [`bound`] for a length: `value` in `dp`.
fn bound_dp(doc: &Document, n: &Node, fields: &[&str], value: f64) -> String {
bound(doc, n, fields, format!("{}.dp", num(value)))
}
/// The Modifier chain for a layer (size, shadow, background, border, alpha, padding).
fn modifier(n: &Node, shape: &str, padding: bool) -> String {
let mut m = format!("Modifier.size({}.dp, {}.dp)", num(n.width), num(n.height));
fn modifier(doc: &Document, n: &Node, shape: &str, padding: bool) -> String {
let mut m = format!("Modifier.size({}, {})", bound_dp(doc, n, &["width"], n.width), bound_dp(doc, n, &["height"], n.height));
if let Some(s) = drop_shadow(n) {
m.push_str(&format!(".shadow({}.dp, {shape})", num(s.radius / 2.0)));
}
if let Some(c) = first_fill(n) {
m.push_str(&format!(".background({}, {shape})", kotlin_color(c)));
m.push_str(&format!(".background({}, {shape})", bound(doc, n, &["fill"], kotlin_color(c))));
}
if let Some(c) = stroke_color(n) {
m.push_str(&format!(".border({}.dp, {}, {shape})", num(n.stroke.weight), kotlin_color(c)));
m.push_str(&format!(
".border({}, {}, {shape})",
bound_dp(doc, n, &["strokeWeight"], n.stroke.weight),
bound(doc, n, &["stroke"], kotlin_color(c))
));
}
if n.opacity < 1.0 {
m.push_str(&format!(".alpha({}f)", num(f64::from(n.opacity))));
m.push_str(&format!(".alpha({})", bound(doc, n, &["opacity"], format!("{}f", num(f64::from(n.opacity))))));
}
if padding && let Some(l) = &n.layout {
let [t, r, b, lft] = l.padding;
if [t, r, b, lft].iter().any(|v| *v > 0.0) {
m.push_str(&format!(".padding(start = {}.dp, top = {}.dp, end = {}.dp, bottom = {}.dp)", num(lft), num(t), num(r), num(b)));
let side = |at: usize, v: f64| bound_dp(doc, n, &PADDING[at], v);
m.push_str(&format!(".padding(start = {}, top = {}, end = {}, bottom = {})", side(3, lft), side(0, t), side(1, r), side(2, b)));
}
}
m
@@ -65,33 +83,37 @@ fn compose_layer(ctx: &Ctx, n: &Node, depth: usize, out: &mut String) {
out.push_str(&call);
return;
}
let shape = if radius(n) > 0.0 { format!("RoundedCornerShape({}.dp)", num(radius(n))) } else { "RectangleShape".to_string() };
let shape = if radius(n) > 0.0 {
format!("RoundedCornerShape({})", bound_dp(doc, n, &["cornerRadius"], radius(n)))
} else {
"RectangleShape".to_string()
};
match &n.kind {
NodeKind::Text(t) => {
let color = first_fill(n).map(kotlin_color).unwrap_or_else(|| "Color.Unspecified".into());
let text = ctx.param(n, "characters").map_or_else(|| literal(&t.characters, true), |p| p.ident.clone());
let color = first_fill(n).map_or_else(|| "Color.Unspecified".into(), |c| bound(doc, n, &["fill"], kotlin_color(c)));
let text = ctx.param(n, "characters").map_or_else(|| bound(doc, n, &["characters"], literal(&t.characters, true)), |p| p.ident.clone());
out.push_str(&format!(
"{pad}Text(\n{pad} text = {text},\n{pad} fontSize = {}.sp,\n{pad} fontWeight = FontWeight.W{},\n{pad} color = {color},\n{pad})\n",
num(t.style.font_size),
"{pad}Text(\n{pad} text = {text},\n{pad} fontSize = {},\n{pad} fontWeight = FontWeight.W{},\n{pad} color = {color},\n{pad})\n",
bound(doc, n, &["fontSize"], format!("{}.sp", num(t.style.font_size))),
(t.style.weight().clamp(100, 900) / 100) * 100
));
}
NodeKind::Ellipse { .. } => out.push_str(&format!("{pad}Box(modifier = {})\n", modifier(n, "CircleShape", false))),
NodeKind::Rectangle => out.push_str(&format!("{pad}Box(modifier = {})\n", modifier(n, &shape, false))),
NodeKind::Ellipse { .. } => out.push_str(&format!("{pad}Box(modifier = {})\n", modifier(doc, n, "CircleShape", false))),
NodeKind::Rectangle => out.push_str(&format!("{pad}Box(modifier = {})\n", modifier(doc, n, &shape, false))),
k if k.is_frame_like() || matches!(k, NodeKind::Group) => {
let children = ctx.kids(n);
let open = match n.layout.as_ref().map(|l| (l.mode, l)) {
Some((LayoutMode::Vertical, l)) => format!(
"Column(\n{pad} modifier = {},\n{pad} verticalArrangement = Arrangement.spacedBy({}.dp),\n{pad}) {{",
modifier(n, &shape, true),
num(l.gap)
"Column(\n{pad} modifier = {},\n{pad} verticalArrangement = Arrangement.spacedBy({}),\n{pad}) {{",
modifier(doc, n, &shape, true),
bound_dp(doc, n, &["gap"], l.gap)
),
Some((_, l)) => format!(
"Row(\n{pad} modifier = {},\n{pad} horizontalArrangement = Arrangement.spacedBy({}.dp),\n{pad} verticalAlignment = Alignment.CenterVertically,\n{pad}) {{",
modifier(n, &shape, true),
num(l.gap)
"Row(\n{pad} modifier = {},\n{pad} horizontalArrangement = Arrangement.spacedBy({}),\n{pad} verticalAlignment = Alignment.CenterVertically,\n{pad}) {{",
modifier(doc, n, &shape, true),
bound_dp(doc, n, &["gap"], l.gap)
),
None => format!("Box(modifier = {}) {{", modifier(n, &shape, false)),
None => format!("Box(modifier = {}) {{", modifier(doc, n, &shape, false)),
};
out.push_str(&format!("{pad}{open}\n"));
// A slot shows the instance's content, or else the main's.
+6 -6
View File
@@ -1,6 +1,6 @@
use super::{
ContentAlign, CrossAlign, Document, EffectKind, LayoutMode, LetterSpacing, LineHeight, MainAlign, Node, NodeId, NodeKind, PaintKind, TextAlignH,
TextCase, TextDecoration, Variable, css_borders, num,
ContentAlign, CrossAlign, Document, EffectKind, LayoutMode, LetterSpacing, LineHeight, MainAlign, Node, NodeId, NodeKind, PADDING, PaintKind,
TextAlignH, TextCase, TextDecoration, Variable, css_borders, num,
};
// ------------------------------------------------------------------ CSS
@@ -146,10 +146,10 @@ fn layout_decls(doc: &Document, n: &Node, l: &figcraft_doc::AutoLayout, t: &mut
put("gap", tok(doc, n, &["gap"], px(l.gap), t));
let [pt, pr, pb, pl] = l.padding;
let sides = [
tok(doc, n, &["paddingTop", "paddingVertical", "padding"], px(pt), t),
tok(doc, n, &["paddingRight", "paddingHorizontal", "padding"], px(pr), t),
tok(doc, n, &["paddingBottom", "paddingVertical", "padding"], px(pb), t),
tok(doc, n, &["paddingLeft", "paddingHorizontal", "padding"], px(pl), t),
tok(doc, n, &PADDING[0], px(pt), t),
tok(doc, n, &PADDING[1], px(pr), t),
tok(doc, n, &PADDING[2], px(pb), t),
tok(doc, n, &PADDING[3], px(pl), t),
];
put("padding", sides.join(" "));
let justify = match l.main_align {
+39 -26
View File
@@ -1,4 +1,6 @@
use super::{CodeLang, Color, Ctx, LayoutMode, Node, NodeId, NodeKind, drop_shadow, first_fill, indent, literal, num, radius, stroke_color};
use super::{
CodeLang, Color, Ctx, LayoutMode, Node, NodeId, NodeKind, PADDING, drop_shadow, first_fill, indent, literal, named, num, radius, stroke_color,
};
// ------------------------------------------------------------------ SwiftUI
@@ -60,40 +62,45 @@ fn swift_layer(ctx: &Ctx, n: &Node, depth: usize, out: &mut String, extra: &[Str
}
return;
}
// A bound variable's iOS code syntax in place of the value.
let bound = |fields: &[&str], value: String| named(doc, n, "iOS", fields, value);
let fill = first_fill(n).map(|c| bound(&["fill"], swift_color(c)));
let (w, h) = (bound(&["width"], num(n.width)), bound(&["height"], num(n.height)));
let corner = bound(&["cornerRadius"], num(radius(n)));
let mut mods: Vec<String> = Vec::new();
match &n.kind {
NodeKind::Text(t) => {
let text = ctx.param(n, "characters").map_or_else(|| literal(&t.characters, false), |p| p.ident.clone());
let text = ctx.param(n, "characters").map_or_else(|| bound(&["characters"], literal(&t.characters, false)), |p| p.ident.clone());
out.push_str(&format!("{pad}Text({text})\n"));
mods.push(format!(
".font(.custom({}, size: {}).weight({}))",
literal(&t.style.font_family, false),
num(t.style.font_size),
bound(&["fontFamily"], literal(&t.style.font_family, false)),
bound(&["fontSize"], num(t.style.font_size)),
swift_weight(t.style.weight())
));
if let Some(c) = first_fill(n) {
mods.push(format!(".foregroundColor({})", swift_color(c)));
if let Some(c) = &fill {
mods.push(format!(".foregroundColor({c})"));
}
}
NodeKind::Ellipse { .. } => {
out.push_str(&format!("{pad}Ellipse()\n"));
if let Some(c) = first_fill(n) {
mods.push(format!(".fill({})", swift_color(c)));
if let Some(c) = &fill {
mods.push(format!(".fill({c})"));
}
mods.push(format!(".frame(width: {}, height: {})", num(n.width), num(n.height)));
mods.push(format!(".frame(width: {w}, height: {h})"));
}
NodeKind::Rectangle => {
out.push_str(&format!("{pad}RoundedRectangle(cornerRadius: {})\n", num(radius(n))));
if let Some(c) = first_fill(n) {
mods.push(format!(".fill({})", swift_color(c)));
out.push_str(&format!("{pad}RoundedRectangle(cornerRadius: {corner})\n"));
if let Some(c) = &fill {
mods.push(format!(".fill({c})"));
}
mods.push(format!(".frame(width: {}, height: {})", num(n.width), num(n.height)));
mods.push(format!(".frame(width: {w}, height: {h})"));
}
k if k.is_frame_like() || matches!(k, NodeKind::Group) => {
let children = ctx.kids(n);
let open = match n.layout.as_ref().map(|l| (l.mode, l)) {
Some((LayoutMode::Vertical, l)) => format!("VStack(alignment: .leading, spacing: {}) {{", num(l.gap)),
Some((_, l)) => format!("HStack(alignment: .center, spacing: {}) {{", num(l.gap)),
Some((LayoutMode::Vertical, l)) => format!("VStack(alignment: .leading, spacing: {}) {{", bound(&["gap"], num(l.gap))),
Some((_, l)) => format!("HStack(alignment: .center, spacing: {}) {{", bound(&["gap"], num(l.gap))),
None => "ZStack(alignment: .topLeading) {".to_string(),
};
out.push_str(&format!("{pad}{open}\n"));
@@ -124,29 +131,35 @@ fn swift_layer(ctx: &Ctx, n: &Node, depth: usize, out: &mut String, extra: &[Str
if let Some(l) = &n.layout {
let [t, r, b, lft] = l.padding;
if [t, r, b, lft].iter().any(|v| *v > 0.0) {
mods.push(format!(".padding(EdgeInsets(top: {}, leading: {}, bottom: {}, trailing: {}))", num(t), num(lft), num(b), num(r)));
let side = |at: usize, v: f64| bound(&PADDING[at], num(v));
mods.push(format!(
".padding(EdgeInsets(top: {}, leading: {}, bottom: {}, trailing: {}))",
side(0, t),
side(3, lft),
side(2, b),
side(1, r)
));
}
}
mods.push(format!(".frame(width: {}, height: {}, alignment: .topLeading)", num(n.width), num(n.height)));
if let Some(c) = first_fill(n) {
mods.push(format!(".background({})", swift_color(c)));
mods.push(format!(".frame(width: {w}, height: {h}, alignment: .topLeading)"));
if let Some(c) = &fill {
mods.push(format!(".background({c})"));
}
if radius(n) > 0.0 {
mods.push(format!(".clipShape(RoundedRectangle(cornerRadius: {}))", num(radius(n))));
mods.push(format!(".clipShape(RoundedRectangle(cornerRadius: {corner}))"));
}
}
_ => {
out.push_str(&format!("{pad}// {}: export as an image asset\n{pad}Image({})\n", n.kind.label(), literal(&n.name, false)));
mods.push(".resizable()".into());
mods.push(format!(".frame(width: {}, height: {})", num(n.width), num(n.height)));
mods.push(format!(".frame(width: {w}, height: {h})"));
}
}
if let Some(c) = stroke_color(n) {
mods.push(format!(
".overlay(RoundedRectangle(cornerRadius: {}).stroke({}, lineWidth: {}))",
num(radius(n)),
swift_color(c),
num(n.stroke.weight)
".overlay(RoundedRectangle(cornerRadius: {corner}).stroke({}, lineWidth: {}))",
bound(&["stroke"], swift_color(c)),
bound(&["strokeWeight"], num(n.stroke.weight))
));
}
if let Some(s) = drop_shadow(n) {
@@ -159,7 +172,7 @@ fn swift_layer(ctx: &Ctx, n: &Node, depth: usize, out: &mut String, extra: &[Str
));
}
if n.opacity < 1.0 {
mods.push(format!(".opacity({})", num(f64::from(n.opacity))));
mods.push(format!(".opacity({})", bound(&["opacity"], num(f64::from(n.opacity)))));
}
mods.extend(extra.iter().cloned());
for m in mods {
@@ -342,6 +342,66 @@ fn deleting_a_bound_variable_keeps_the_last_value() {
assert!(matches!(o.values.values().next(), Some(VariableValue::Color(c)) if c.to_hex() == "FFFFFF"), "{:?}", o.values);
}
#[test]
fn swiftui_and_compose_name_variables_by_their_code_syntax() {
// Documented: Dev Mode's code snippets show a bound variable's code syntax for the platform,
// in CSS, SwiftUI and Compose. A variable without one keeps the value: no made-up name.
use crate::codegen::{CodeLang, code_for};
let mut s = Session::new();
run(&mut s, "variable.createCollection", json!({"name": "Tokens"}));
let names = [("brand", "color", json!("#0d99ff"), "Color.brand", "AppColors.brand"), ("space", "number", json!(16), "Spacing.md", "Spacing.md")];
for (name, kind, value, ios, android) in names {
run(&mut s, "variable.create", json!({"collection": "Tokens", "name": name, "type": kind, "value": value}));
run(&mut s, "variable.update", json!({"variable": name, "codeSyntax": {"iOS": ios, "ANDROID": android}}));
}
run(&mut s, "variable.create", json!({"collection": "Tokens", "name": "web only", "type": "number", "value": 9}));
run(&mut s, "variable.update", json!({"variable": "web only", "codeSyntax": {"WEB": "--r", "iOS": "\u{7}"}}));
let f = frame(&mut s, None);
run(&mut s, "layout.add", json!({"ids": [f]}));
let bind = |s: &mut Session, id: NodeId, field: &str, variable: &str| {
run(s, "variable.bind", json!({"ids": [id], "field": field, "variable": variable}));
};
for (field, variable) in [("gap", "space"), ("padding", "space"), ("fill", "brand"), ("cornerRadius", "web only")] {
bind(&mut s, f, field, variable);
}
let code = |s: &Session, lang| code_for(s.doc().unwrap(), f, lang);
let swift = code(&s, CodeLang::SwiftUi);
assert!(swift.contains("spacing: Spacing.md"), "{swift}");
assert!(swift.contains("EdgeInsets(top: Spacing.md, leading: Spacing.md"), "{swift}");
assert!(swift.contains(".background(Color.brand)"), "{swift}");
assert!(swift.contains("RoundedRectangle(cornerRadius: 9)"), "a name of nothing keeps the value: {swift}");
assert!(!swift.contains("Color(red"), "{swift}");
let kotlin = code(&s, CodeLang::Compose);
assert!(kotlin.contains("Arrangement.spacedBy(Spacing.md)"), "{kotlin}");
assert!(kotlin.contains(".padding(start = Spacing.md, top = Spacing.md"), "{kotlin}");
assert!(kotlin.contains(".background(AppColors.brand, "), "{kotlin}");
assert!(kotlin.contains("RoundedCornerShape(9.dp)"), "no Android name: {kotlin}");
// Shapes and text too: sizes, strokes, the font and its colour.
let r = crate::params::id_of(&run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 40, "height": 40, "parent": f}))["id"]).unwrap();
run(&mut s, "stroke.add", json!({"ids": [r], "color": "#000000"}));
for (field, variable) in [("width", "space"), ("height", "space"), ("stroke", "brand"), ("strokeWeight", "space")] {
bind(&mut s, r, field, variable);
}
let t = crate::params::id_of(&run(&mut s, "text.create", json!({"text": "Hi", "x": 0, "y": 0, "parent": f}))["id"]).unwrap();
bind(&mut s, t, "fontSize", "space");
bind(&mut s, t, "fill", "brand");
let swift = code(&s, CodeLang::SwiftUi);
assert!(swift.contains(".frame(width: Spacing.md, height: Spacing.md)"), "{swift}");
assert!(swift.contains(".stroke(Color.brand, lineWidth: Spacing.md)"), "{swift}");
assert!(swift.contains("size: Spacing.md)") && swift.contains(".foregroundColor(Color.brand)"), "{swift}");
let kotlin = code(&s, CodeLang::Compose);
assert!(kotlin.contains("Modifier.size(Spacing.md, Spacing.md)"), "{kotlin}");
assert!(kotlin.contains(".border(Spacing.md, AppColors.brand, "), "{kotlin}");
assert!(kotlin.contains("fontSize = Spacing.md,") && kotlin.contains("color = AppColors.brand,"), "{kotlin}");
// Code for another platform doesn't take iOS or Android names.
for lang in [CodeLang::Flutter, CodeLang::Css] {
let other = code(&s, lang);
assert!(!other.contains("Spacing.md") && !other.contains("AppColors"), "{other}");
}
}
#[test]
fn code_syntax_per_platform() {
// Documented: a variable can have a code name per platform (Web, iOS, Android) for handoff
+3 -2
View File
@@ -1,6 +1,6 @@
# Components, variants, styles and variables parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (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-10-30 · **Change:** minor (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
@@ -66,7 +66,7 @@ updates with overrides kept: `library_tests.rs`, `components_scale_tests.rs`.
| 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); colour calculations per mode (`{alpha}`: a colour variable with its alpha replaced, `{mix}`: a per-channel blend of two colour variables or literals; they compose, can't loop, and bound layers, modes and undo follow them; REST, CSS, Penpot and `.fig` exports write the colour they show in each of their collection's modes, other collections in their default modes; read-only in the panel, which can swap one for an alias or detach it; `variables_colour_tests.rs`), beyond the incumbent, whose variables only alias; 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 and colour calculations reading it keep the colour they show |
| Code syntax per platform (Web, iOS, Android, Flutter) | done | generated CSS uses the Web name; in the variables JSON; SwiftUI/Compose/ArkUI/Flutter code doesn't name variables yet |
| 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; 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 |
@@ -78,6 +78,7 @@ updates with overrides kept: `library_tests.rs`, `components_scale_tests.rs`.
| Date | Change | Summary |
|---|---|---|
| 2026-10-31 | minor | M6: as the help center describes code syntax, Dev Mode's SwiftUI and Compose code now writes a bound variable's iOS or Android code syntax in place of its value, as CSS already wrote the Web one: fills, strokes and their weight, sizes, corner radius, gaps, padding, opacity, the font family and size and the characters. A variable with no code syntax for the platform keeps the value, since a name made up here wouldn't exist in the code it lands in. ArkUI and Flutter have no code syntax platform and are unchanged. Test: `variables_behaviour_tests::swiftui_and_compose_name_variables_by_their_code_syntax`. Numbers unchanged. Grade unchanged |
| 2026-10-31 | minor | M3, gap 10: library analytics over time. A file counts the instances of library components it inserts (placed, created, pasted or duplicated, Alt-dragged copies too; not those nested in another instance) and reports them like its detaches (`ReportInserted`). The server counts what a file's insertion and detach totals grow past their highest in the current week, a Monday at 00:00 UTC, so an undo and a redo count once, and keeps a year of weeks in `actions.json` (`Actions`). `library.usage {days?}` adds `insertions`, `detachments` and `weeks`, and per component `insertions` and `detachments`; Show usage has a 30 days, 90 days or 1 year choice, the period's totals, and each component's counts on a line of their own. The new variants are appended, so older messages keep their bytes; an older server refuses them and Show usage says it doesn't count insertions. A file's first report after this counts its total in that week. Tests: `library_actions_tests`, `inserting_library_instances_counts_them`. Grade unchanged |
| 2026-10-30 | minor | M3, gap 14: a collection of this file can be extended, as the help center shows (right-click a collection, Extend collection); before, only a library's could. `variable.extendCollection {collection, name?}` makes "<name> (extended)" with its modes and one inheriting variable per variable, and the Variables editor's collection menu offers it beside Rename and Delete collection. Edits of the collection it extends reach it at once (values in modes it doesn't override, new variables and modes, renames, mode order), so normalising keeps extensions in step whenever variables change, not only on library updates; undo takes both back. An extended collection extends again (parents settle first; an extension doesn't take its parent's overrides as its own), and a loop of extensions from a hostile file comes apart into plain collections keeping their values. Deleting the parent leaves a plain collection with the values it showed, as for libraries. Tests: `extended_chain_tests` (engine), `variables_rename_tests::a_collection_of_this_file_extends_from_its_context_menu` (headless UI). Grade unchanged |
| 2026-10-30 | minor | Library analytics count detaches: a file that detaches an instance of a library component counts it (kept with the file) and its app reports the counts to the library's server beside its usage; **Show usage** and `library.usage` list them per component and per file (`detached`, `detachesKnown`). Older servers and apps are unaffected: the reports are new messages and the old ones keep their encoding. Still nothing over time. Numbers unchanged |
+2 -1
View File
@@ -339,7 +339,8 @@ Or in a project's `.mcp.json`:
left as they were stay bound.
`variable.addMode {from}` duplicates a mode, `variable.moveMode {collection, mode, to}` reorders
(index 0 is the default mode, which layers on Auto follow). `variable.update {codeSyntax: {WEB?,
iOS?, ANDROID?}}` sets code names (`""` clears); generated CSS uses the Web one.
iOS?, ANDROID?}}` sets code names (`""` clears); generated CSS uses the Web one, SwiftUI the iOS one and
Compose the Android one (a variable without one keeps its value there).
- **Live previews**: `text.preview {command, params}` shows what `style.apply`,
`library.applyStyle`, `text.setVariations` or `text.style` would do on the canvas without an undo
step (in place of an earlier preview); `text.endPreview` puts the document back. Any other command
+2 -1
View File
File diff suppressed because one or more lines are too long