mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-12 08:51:11 +00:00
Merge pull request #2223 from TrevorBrown-dev/feat/text-style-font-weight-2174
M7: Text styles: bind a number variable to the weight (#2174)
This commit is contained in:
11 files changed
+256
-17
No files matched your search
+1
-1
@@ -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-11-10 · **Change:** minor (mind maps, first step: a first node with three children on curved branches, laid out again after every edit (`board.mindMap`, `board.mindMapAdd`, #2226); the handoff mode reads Developer Mode in the interface, its ids and saved fields unchanged; the left rail's and tab strip's icons are larger, 20 pt (#553); Suggest auto layout adds nested auto layout frames by rows and columns and keeps the placement of the design, as the help center describes (#1823); the Local variables window selects several variables (click, Shift+click for a range, Cmd/Ctrl+click to toggle, Escape to clear) and delete, move to a group and scope act on the selected local variables in one undo step (#2002); the desktop UI takes a system face for Ethiopic, Devanagari, Gurmukhi, Gujarati, Tamil, Telugu, Kannada and Malayalam, Sinhala, Lao, Myanmar, Khmer, Armenian and Georgian as it did for Hebrew and Arabic, so a String variable's Amharic value draws in the panels instead of boxes (#2014); a layer's effect binding takes only a field its effect has; ; the boolean operation menu leads each entry with an original icon (#2003); glass takes one sample instead of its dispersion window where the colours split under 1/256 px; glass bends towards its core, and rounded rectangles work it out in closed form; glass's light is a line on its bevel and splay turns the bend towards the centre; glass bends with a thick-lens profile and splits its colours over 8 steps; glass effects take variables and keep their frost within 0–100; web host bridge: `?host=parent` or `data-host="page"` hands a page the app's files, documented in the hosting guide; a connector end moved with Cmd/Ctrl held goes on any part of an object, and stays there as the object moves or resizes; a tap on a plugin panel's button keeps what was typed in its fields; a Dash cap setting (none, round or square) on Dash and Custom strokes, drawn on both ends of every dash; library analytics list how many layers and files use each style and variable; the Controller menu names each button as PlayStation and Switch controllers do; widgets draw lines and SVG pictures (plugin API 6); a connector end dropped along a side of a shape attaches to that side (#1708); regression tests pin a rounded shape's one-sided stroke and SVG ids and font families without XML-forbidden characters (#1351, #1124); a damaged image is refused with a reason instead of showing as an empty box (#1474); an image copied in another app pastes again on the desktop, and in the browser for the first time (#492); SwiftUI and Compose code names a bound variable by its iOS or Android code syntax; the colour picker's Image tab shows a placeholder and Upload from computer, an asset preview, and a file dropped on it replaces the image, keeping the fill mode, crop and position (#1682); the Frame tool's Phone presets have the iPhone 17 family (#1691); Show in folder works in the Flatpak build and selects the file on Linux (#1701); a dashed line starts and ends with a half-length dash, on the canvas and in exports; the Pages, Layers and Assets search fields keep their text in the middle of the field (#1703); a board connector's path can be reshaped by dragging its path handles (#1710); boards have 16 more shapes (arrows, polygons, star, plus and flowchart symbols), in pickers that lay them out in a grid; library analytics count insertions and show insertions and detachments over the past 30 days, 90 days or year; a stroke's Custom style takes a dash pattern of any length as a comma-separated list (#1681); a control request can set its own timeout, up to 10 minutes (`timeoutMs`; `figcraft-cli mcp --attach PORT --timeout SECONDS`) (#1696); gap 6: a widget opens its plugin's panel (plugin API 5), whose callbacks redraw it; the keyboard's Menu key opens the context menu, as Shift+F10 does; an Open link action can open in place of the prototype, with "Open in new tab" off (gap 15); 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; the eyedropper with Cmd/Ctrl+Shift makes a colour style or variable of what it picks; a live session's welcome sends the document compressed to clients that read it, about seven times smaller (#1665); a control request that times out says truthfully whether it was dropped or is still running and will take effect, and only screenshots and input blame the window (#1673); a board keeps earlier votes' results, listed newest first in Results and deleted one at a time; the object a stamp will stick to is outlined while the stamp tool is over it or a stamp is dragged over it, and board objects take no drops (gap 24); a server can list the files shared from it now for everyone who reaches it, Home ▸ Online shows them and their links carry no secret (`team_files`, part of #742); team library Line truncated
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (a text style binds a number variable to its weight (#2174); mind maps, first step: a first node with three children on curved branches, laid out again after every edit (`board.mindMap`, `board.mindMapAdd`, #2226); the handoff mode reads Developer Mode in the interface, its ids and saved fields unchanged; the left rail's and tab strip's icons are larger, 20 pt (#553); Suggest auto layout adds nested auto layout frames by rows and columns and keeps the placement of the design, as the help center describes (#1823); the Local variables window selects several variables (click, Shift+click for a range, Cmd/Ctrl+click to toggle, Escape to clear) and delete, move to a group and scope act on the selected local variables in one undo step (#2002); the desktop UI takes a system face for Ethiopic, Devanagari, Gurmukhi, Gujarati, Tamil, Telugu, Kannada and Malayalam, Sinhala, Lao, Myanmar, Khmer, Armenian and Georgian as it did for Hebrew and Arabic, so a String variable's Amharic value draws in the panels instead of boxes (#2014); a layer's effect binding takes only a field its effect has; ; the boolean operation menu leads each entry with an original icon (#2003); glass takes one sample instead of its dispersion window where the colours split under 1/256 px; glass bends towards its core, and rounded rectangles work it out in closed form; glass's light is a line on its bevel and splay turns the bend towards the centre; glass bends with a thick-lens profile and splits its colours over 8 steps; glass effects take variables and keep their frost within 0–100; web host bridge: `?host=parent` or `data-host="page"` hands a page the app's files, documented in the hosting guide; a connector end moved with Cmd/Ctrl held goes on any part of an object, and stays there as the object moves or resizes; a tap on a plugin panel's button keeps what was typed in its fields; a Dash cap setting (none, round or square) on Dash and Custom strokes, drawn on both ends of every dash; library analytics list how many layers and files use each style and variable; the Controller menu names each button as PlayStation and Switch controllers do; widgets draw lines and SVG pictures (plugin API 6); a connector end dropped along a side of a shape attaches to that side (#1708); regression tests pin a rounded shape's one-sided stroke and SVG ids and font families without XML-forbidden characters (#1351, #1124); a damaged image is refused with a reason instead of showing as an empty box (#1474); an image copied in another app pastes again on the desktop, and in the browser for the first time (#492); SwiftUI and Compose code names a bound variable by its iOS or Android code syntax; the colour picker's Image tab shows a placeholder and Upload from computer, an asset preview, and a file dropped on it replaces the image, keeping the fill mode, crop and position (#1682); the Frame tool's Phone presets have the iPhone 17 family (#1691); Show in folder works in the Flatpak build and selects the file on Linux (#1701); a dashed line starts and ends with a half-length dash, on the canvas and in exports; the Pages, Layers and Assets search fields keep their text in the middle of the field (#1703); a board connector's path can be reshaped by dragging its path handles (#1710); boards have 16 more shapes (arrows, polygons, star, plus and flowchart symbols), in pickers that lay them out in a grid; library analytics count insertions and show insertions and detachments over the past 30 days, 90 days or year; a stroke's Custom style takes a dash pattern of any length as a comma-separated list (#1681); a control request can set its own timeout, up to 10 minutes (`timeoutMs`; `figcraft-cli mcp --attach PORT --timeout SECONDS`) (#1696); gap 6: a widget opens its plugin's panel (plugin API 5), whose callbacks redraw it; the keyboard's Menu key opens the context menu, as Shift+F10 does; an Open link action can open in place of the prototype, with "Open in new tab" off (gap 15); 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; the eyedropper with Cmd/Ctrl+Shift makes a colour style or variable of what it picks; a live session's welcome sends the document compressed to clients that read it, about seven times smaller (#1665); a control request that times out says truthfully whether it was dropped or is still running and will take effect, and only screenshots and input blame the window (#1673); a board keeps earlier votes' results, listed newest first in Results and deleted one at a time; the object a stamp will stick to is outlined while the stamp tool is over it or a stamp is dragged over it, and board objects take no drops (gap 24); a server can list the files shared from it now for everyone who reaches it, Home ▸ Online shows them and their linksLine truncated
|
||||
|
||||
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
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
//! 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/<i>/color`, `fills/<i>/stops/<j>/color`,
|
||||
//! `effects/<i>/<field>`, `layoutGrids/<i>/<field>`, a text style's `fontSize`, `lineHeight`,
|
||||
//! `letterSpacing`, `fontFamily`, `fontStyle`). Each layer using the style sees those fields
|
||||
//! `letterSpacing`, `fontWeight`, `fontFamily`, `fontStyle`; a number weight and a style name set the
|
||||
//! same property, the weight wins). 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;
|
||||
@@ -32,7 +33,7 @@ pub fn field_type(kind: &StyleKind, key: &str) -> Option<VariableType> {
|
||||
(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),
|
||||
"fontSize" | "lineHeight" | "letterSpacing" | "fontWeight" => Some(Number),
|
||||
"fontFamily" | "fontStyle" => Some(String),
|
||||
_ => None,
|
||||
},
|
||||
@@ -70,6 +71,7 @@ pub fn field_scopes(kind: &StyleKind, key: &str) -> Option<&'static [&'static st
|
||||
"letterSpacing" => &["LETTER_SPACING"],
|
||||
"fontFamily" => &["FONT_FAMILY"],
|
||||
"fontStyle" => &["FONT_STYLE"],
|
||||
"fontWeight" => &["FONT_WEIGHT"],
|
||||
_ => return None,
|
||||
},
|
||||
_ => return None,
|
||||
@@ -111,6 +113,8 @@ pub fn from_layer(n: &Node, field: &str, kind: &StyleKind) -> BTreeMap<String, V
|
||||
/// `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();
|
||||
// `bound` is a BTreeMap, so `fontStyle` applies before `fontWeight` and a weight wins when a file carries both
|
||||
// (pinned by `font_weight_wins_over_font_style_when_a_file_carries_both`).
|
||||
for (key, var) in &st.bound {
|
||||
let Some(val) = vars.resolve(var, modes) else { continue };
|
||||
match (&mut kind, list_binding(key), &val) {
|
||||
@@ -134,7 +138,14 @@ fn set_text_field(style: &mut TextStyle, key: &str, val: &VariableValue) {
|
||||
("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(),
|
||||
("fontStyle", VariableValue::String(f)) if !f.trim().is_empty() => style.set_font_style(f.clone()),
|
||||
("fontWeight", VariableValue::Number(x)) if x.is_finite() => {
|
||||
// The face nearest the number (a static family) and the `wght` axis (a variable font).
|
||||
let w = x.clamp(1.0, 1000.0);
|
||||
let italic = style.italic();
|
||||
style.set_font_style(crate::cmd::props::styled_name(crate::cmd::props::weight_name(w as u16), italic));
|
||||
style.variations.insert("wght".to_string(), w);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! What a file's number variables measure, so exported tokens carry the right unit: a radius,
|
||||
//! font size, spacing or other length is in pixels, opacity and font weight are bare numbers.
|
||||
//! A number's role comes from the layer fields bound to it, else its scopes, and an alias target
|
||||
//! A number's role comes from the layer fields bound to it (and a text style's weight), else its scopes, and an alias target
|
||||
//! with no role of its own takes the role of the variables that read it (the target of an opacity
|
||||
//! alias stays unitless). A number nothing tells about is a length: design tokens mostly are.
|
||||
|
||||
@@ -60,6 +60,13 @@ impl Roles {
|
||||
}
|
||||
}
|
||||
}
|
||||
for st in &doc.styles {
|
||||
for (field, id) in &st.bound {
|
||||
if field == "fontWeight" {
|
||||
roles.entry(id.clone()).or_insert(NumberRole::Unitless);
|
||||
}
|
||||
}
|
||||
}
|
||||
for v in vars.iter().filter(|v| v.kind == VariableType::Number) {
|
||||
if let Some(r) = v.scopes.as_ref().and_then(|s| s.iter().find_map(|s| role_of_scope(s))) {
|
||||
roles.entry(v.id.clone()).or_insert(r);
|
||||
|
||||
@@ -54,7 +54,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
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/<i>/color` (or `fill`: its first paint) and `fills/<i>/stops/<j>/color`, an effect style's `effects/<i>/<color|radius|spread|offsetX|offsetY|startRadius>` (glass: `lightAngle|lightIntensity|refraction|depth|dispersion|radius|splay`), a grid style's `layoutGrids/<i>/<size|count|gutter|offset>`, 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",
|
||||
"{style, field, variable? (id or name; none: unbind)} bind a variable to a field of the style's value: a paint style's `fills/<i>/color` (or `fill`: its first paint) and `fills/<i>/stops/<j>/color`, an effect style's `effects/<i>/<color|radius|spread|offsetX|offsetY|startRadius>` (glass: `lightAngle|lightIntensity|refraction|depth|dispersion|radius|splay`), a grid style's `layoutGrids/<i>/<size|count|gutter|offset>`, a text style's `fontSize|lineHeight|letterSpacing|fontWeight` (numbers; line height and letter spacing in px, as the reference tool applies them; a weight picks the family's face nearest it and the `wght` axis of a variable font) and `fontFamily|fontStyle` (strings; a weight bound as a number replaces a bound style name and the reverse). 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
|
||||
),
|
||||
@@ -520,6 +520,11 @@ fn bind_style(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
{
|
||||
rebind_paint_alpha(paints, &mut [], &key, true);
|
||||
}
|
||||
match key.as_str() {
|
||||
"fontWeight" => style.bound.remove("fontStyle"),
|
||||
"fontStyle" => style.bound.remove("fontWeight"),
|
||||
_ => None,
|
||||
};
|
||||
style.bound.insert(key, id);
|
||||
}
|
||||
None => {
|
||||
|
||||
@@ -160,6 +160,7 @@ fn what_a_style_cannot_bind_is_refused() {
|
||||
("fills/3/color", "bg", "no field"),
|
||||
("effects/0/radius", "blur", "no field"),
|
||||
("fontSize", "size", "no field"),
|
||||
("fontWeight", "bg", "no field"),
|
||||
("opacity", "size", "no field"),
|
||||
("fill", "nope", "no such variable"),
|
||||
] {
|
||||
@@ -209,6 +210,7 @@ fn style_fields_offer_their_properties_scopes() {
|
||||
(&body, "letterSpacing", Some(&["LETTER_SPACING"][..])),
|
||||
(&body, "fontFamily", Some(&["FONT_FAMILY"][..])),
|
||||
(&body, "fontStyle", Some(&["FONT_STYLE"][..])),
|
||||
(&body, "fontWeight", Some(&["FONT_WEIGHT"][..])),
|
||||
// Grid fields take any number variable, as on layers; fields a style lacks take none.
|
||||
(&grid, "layoutGrids/0/size", None),
|
||||
(&sid, "fills/3/color", None),
|
||||
@@ -244,3 +246,118 @@ fn rest_and_fig_carry_style_bindings() {
|
||||
assert!(got.bound.is_empty() && got.styles.fill.as_deref() == Some(st.id.as_str()), "{got:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A text style "Body" (made from a layer, or from `value`) used by a layer in the Light frame
|
||||
/// and one in the Dark one; returns (light layer, dark layer, style id).
|
||||
fn body_style(s: &mut Session, value: Option<Value>) -> (NodeId, NodeId, String) {
|
||||
let (_, dark, a, _, _) = two_modes(s);
|
||||
let light = node(s, a).parent.unwrap();
|
||||
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(s, light), text(s, dark));
|
||||
let from = match &value {
|
||||
Some(v) => json!({"type": "text", "name": "Body", "value": v}),
|
||||
None => json!({"type": "text", "name": "Body", "from": t1}),
|
||||
};
|
||||
let body = run(s, "style.create", from)["id"].as_str().unwrap().to_string();
|
||||
run(s, "style.apply", json!({"ids": [t1, t2], "style": body}));
|
||||
(t1, t2, body)
|
||||
}
|
||||
|
||||
fn text_of(s: &Session, n: NodeId) -> figcraft_doc::TextStyle {
|
||||
node(s, n).kind.text().unwrap().style.clone()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_number_weight_picks_the_face_and_the_wght_axis() {
|
||||
let mut s = Session::new();
|
||||
let (t1, t2, body) = body_style(&mut s, None);
|
||||
run(&mut s, "variable.create", json!({"collection": "Theme", "name": "weight", "type": "number", "values": {"Light": 700, "Dark": 400}}));
|
||||
run(&mut s, "style.bind", json!({"style": body, "field": "fontWeight", "variable": "weight"}));
|
||||
let face = |s: &Session, n: NodeId| {
|
||||
let t = text_of(s, n);
|
||||
(t.font_style, t.variations.get("wght").copied())
|
||||
};
|
||||
assert_eq!(face(&s, t1), ("Bold".to_string(), Some(700.0)));
|
||||
assert_eq!(face(&s, t2), ("Regular".to_string(), Some(400.0)));
|
||||
run(&mut s, "variable.set", json!({"variable": "weight", "mode": "Dark", "value": 650}));
|
||||
assert_eq!(face(&s, t2), ("Bold".to_string(), Some(650.0)));
|
||||
let e = s.execute("style.bind", &json!({"style": body, "field": "fontWeight", "variable": "bg"})).unwrap_err().to_string();
|
||||
assert!(e.contains("takes a number variable"), "{e}");
|
||||
// A style name bound afterwards replaces the number binding.
|
||||
run(&mut s, "variable.createCollection", json!({"name": "Names", "modes": ["Only"]}));
|
||||
run(&mut s, "variable.create", json!({"collection": "Names", "name": "face", "type": "string", "values": {"Only": "Light"}}));
|
||||
run(&mut s, "style.bind", json!({"style": body, "field": "fontStyle", "variable": "face"}));
|
||||
let bound = s.doc().unwrap().style(&body).unwrap().bound.clone();
|
||||
assert!(bound.contains_key("fontStyle") && !bound.contains_key("fontWeight"), "{bound:?}");
|
||||
// The name replaces the number's weight: no stale `wght` axis keeps drawing 700.
|
||||
assert_eq!(face(&s, t1), ("Light".to_string(), None));
|
||||
assert_eq!(text_of(&s, t1).weight(), 300);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn junk_weight_values_clamp_to_the_lightest_and_heaviest_face() {
|
||||
let mut s = Session::new();
|
||||
let (t1, _, body) = body_style(&mut s, None);
|
||||
run(&mut s, "variable.create", json!({"collection": "Theme", "name": "weight", "type": "number", "values": {"Light": -50, "Dark": 5000}}));
|
||||
run(&mut s, "style.bind", json!({"style": body, "field": "fontWeight", "variable": "weight"}));
|
||||
let face = |s: &Session| {
|
||||
let t = text_of(s, t1);
|
||||
(t.font_style, t.variations.get("wght").copied())
|
||||
};
|
||||
assert_eq!(face(&s), ("Thin".to_string(), Some(1.0)));
|
||||
run(&mut s, "variable.set", json!({"variable": "weight", "mode": "Light", "value": 5000}));
|
||||
assert_eq!(face(&s), ("Black".to_string(), Some(1000.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn font_weight_wins_over_font_style_when_a_file_carries_both() {
|
||||
let mut s = Session::new();
|
||||
let (t1, _, body) = body_style(&mut s, None);
|
||||
run(&mut s, "variable.create", json!({"collection": "Theme", "name": "weight", "type": "number", "values": {"Light": 700, "Dark": 400}}));
|
||||
run(&mut s, "variable.createCollection", json!({"name": "Names", "modes": ["Only"]}));
|
||||
run(&mut s, "variable.create", json!({"collection": "Names", "name": "face", "type": "string", "values": {"Only": "Light"}}));
|
||||
let doc = s.doc_mut().unwrap();
|
||||
let (w, f) = (doc.variables.get("weight").unwrap().id.clone(), doc.variables.get("face").unwrap().id.clone());
|
||||
let st = doc.styles.iter_mut().find(|x| x.id == body).unwrap();
|
||||
st.bound.insert("fontStyle".into(), f);
|
||||
st.bound.insert("fontWeight".into(), w);
|
||||
crate::style_vars::after_load(doc);
|
||||
assert_eq!(text_of(&s, t1).font_style, "Bold");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_number_bound_to_a_percent_line_height_applies_as_pixels() {
|
||||
let mut s = Session::new();
|
||||
let (t1, _, body) = body_style(&mut s, Some(json!({"fontSize": 24, "lineHeight": "150%"})));
|
||||
run(&mut s, "variable.create", json!({"collection": "Theme", "name": "lh", "type": "number", "values": {"Light": 150, "Dark": 36}}));
|
||||
run(&mut s, "style.bind", json!({"style": body, "field": "lineHeight", "variable": "lh"}));
|
||||
let lh = |s: &Session| match &s.doc().unwrap().style(&body).unwrap().kind {
|
||||
StyleKind::Text { style, .. } => style.line_height,
|
||||
k => panic!("{k:?}"),
|
||||
};
|
||||
assert_eq!(lh(&s), figcraft_doc::LineHeight::Pixels(150.0));
|
||||
assert_eq!(text_of(&s, t1).line_height, figcraft_doc::LineHeight::Pixels(150.0));
|
||||
assert_eq!(node(&s, t1).height, 150.0);
|
||||
run(&mut s, "variable.set", json!({"variable": "lh", "mode": "Light", "value": 36}));
|
||||
assert_eq!((lh(&s), node(&s, t1).height), (figcraft_doc::LineHeight::Pixels(36.0), 36.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_text_style_binding_round_trips_through_fig() {
|
||||
let mut s = Session::new();
|
||||
let (_, _, body) = body_style(&mut s, None);
|
||||
run(&mut s, "variable.create", json!({"collection": "Theme", "name": "weight", "type": "number", "values": {"Light": 700, "Dark": 400}}));
|
||||
for (field, var) in [("fontSize", "size"), ("lineHeight", "size"), ("fontWeight", "weight")] {
|
||||
run(&mut s, "style.bind", json!({"style": body, "field": field, "variable": var}));
|
||||
}
|
||||
let doc = s.doc().unwrap().clone();
|
||||
let (bytes, warnings) = figcraft_format::fig::export(&doc, None).unwrap();
|
||||
let (mut back, _) = figcraft_format::fig::import(&bytes, "x").unwrap();
|
||||
crate::style_vars::after_load(&mut back);
|
||||
let st = back.styles.iter().find(|x| x.name == "Body").unwrap();
|
||||
assert!(st.bound.contains_key("fontSize") && st.bound.contains_key("lineHeight"), "{st:?}");
|
||||
// The format has no weight field yet: the settled name comes back, not the binding (the file has no `wght` axis field yet).
|
||||
assert!(warnings.iter().any(|w| w.contains("no field for")), "{warnings:?}");
|
||||
let StyleKind::Text { style, .. } = &st.kind else { panic!("{st:?}") };
|
||||
assert_eq!(style.font_style, "Bold");
|
||||
}
|
||||
@@ -562,6 +562,19 @@ fn css_export_gives_lengths_a_unit_and_keeps_opacity_bare() {
|
||||
assert!(css.contains("--card: 12px;") && css.contains("--fade: 0.5;") && css.contains("--half: 0.5;"), "{css}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn css_export_keeps_a_style_weight_bare() {
|
||||
let mut s = Session::new();
|
||||
ex(&mut s, "variable.createCollection", json!({"name": "Brand"}));
|
||||
ex(&mut s, "variable.create", json!({"collection": "Brand", "name": "weight", "type": "number", "value": 700}));
|
||||
let t = idv(&ex(&mut s, "text.create", json!({"text": "Hi", "x": 0, "y": 0})));
|
||||
let st = ex(&mut s, "style.create", json!({"type": "text", "name": "Body", "from": t}))["id"].as_str().unwrap_or_default().to_string();
|
||||
ex(&mut s, "style.bind", json!({"style": st, "field": "fontWeight", "variable": "weight"}));
|
||||
let css = ex(&mut s, "variable.export", json!({"format": "css"}))["text"].as_str().unwrap_or_default().to_string();
|
||||
// A weight bound only to a text style is still a bare number, not a length.
|
||||
assert!(css.contains("--weight: 700;") && !css.contains("700px"), "{css}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn review_a_long_alias_chain_keeps_its_role_to_the_end() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -30,6 +30,9 @@ enum Target {
|
||||
Proto(crate::proto_editor::VarSlot),
|
||||
/// Bind it to `field` of the shared style `style`.
|
||||
Style { style: String, field: String },
|
||||
/// Bind it to the weight of the shared text style `style`: a string variable names the face
|
||||
/// (`fontStyle`), a number one sets the weight (`fontWeight`).
|
||||
StyleWeight { style: String },
|
||||
}
|
||||
|
||||
/// The open variable picker: what it does, the variable type (None: any), where it opens.
|
||||
@@ -197,6 +200,13 @@ pub fn open_style(ui: &Ui, style: &str, field: &str, kind: VariableType, at: Pos
|
||||
ui.data_mut(|d| d.insert_temp(picker_id(), Picker { target, kind: Some(kind), at }));
|
||||
}
|
||||
|
||||
/// Open the picker at `at` to bind a string (face name) or number (weight) variable to the weight of
|
||||
/// the shared text style `style`.
|
||||
pub fn open_style_weight(ui: &Ui, style: &str, at: Pos2) {
|
||||
let target = Target::StyleWeight { style: style.to_string() };
|
||||
ui.data_mut(|d| d.insert_temp(picker_id(), Picker { target, kind: None, at }));
|
||||
}
|
||||
|
||||
/// Where the open picker is on screen (clicks in it don't close what opened it).
|
||||
pub fn picker_rect(ctx: &egui::Context) -> Option<Rect> {
|
||||
picker_open(ctx).then(|| ctx.memory(|m| m.area_rect(Id::unique("variable_picker_area")))).flatten()
|
||||
@@ -398,7 +408,7 @@ 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_by_id(variable)),
|
||||
Target::Proto(_) | Target::Style { .. } => (None, None),
|
||||
Target::Proto(_) | Target::Style { .. } | Target::StyleWeight { .. } => (None, None),
|
||||
};
|
||||
// A style's fields offer variables scoped to one of the properties the field sets.
|
||||
let style_scopes = match &p.target {
|
||||
@@ -406,7 +416,8 @@ pub fn picker(app: &mut FigApp, ctx: &egui::Context) {
|
||||
_ => None,
|
||||
};
|
||||
// Prototypes and styles use this file's variables.
|
||||
let local_only = matches!(p.target, Target::Proto(_) | Target::Style { .. });
|
||||
let local_only = matches!(p.target, Target::Proto(_) | Target::Style { .. } | Target::StyleWeight { .. });
|
||||
let weight_target = matches!(p.target, Target::StyleWeight { .. });
|
||||
let field = match &p.target {
|
||||
Target::Bind { field, .. } => Some(field.as_str()),
|
||||
_ => None,
|
||||
@@ -417,6 +428,12 @@ pub fn picker(app: &mut FigApp, ctx: &egui::Context) {
|
||||
&& (q.is_empty() || v.name.to_lowercase().contains(&q))
|
||||
&& scope.is_none_or(|s| v.in_scope(s))
|
||||
&& style_scopes.is_none_or(|list| list.iter().any(|s| v.in_scope(s)))
|
||||
&& (!weight_target
|
||||
|| match v.kind {
|
||||
VariableType::String => v.in_scope("FONT_STYLE"),
|
||||
VariableType::Number => v.in_scope("FONT_WEIGHT"),
|
||||
_ => false,
|
||||
})
|
||||
&& (lib.is_some() || own.is_none_or(|o| figcraft_engine::cmd::variables::alias_target(&doc.variables, o, &v.id).is_ok()))
|
||||
};
|
||||
let groups = groups(&doc, &libs, keep);
|
||||
@@ -495,6 +512,11 @@ pub fn picker(app: &mut FigApp, ctx: &egui::Context) {
|
||||
(Target::Style { style, field }, _) => {
|
||||
app.run("style.bind", json!({"style": style, "field": field, "variable": pick.id}));
|
||||
}
|
||||
(Target::StyleWeight { style }, _) => {
|
||||
let number = app.session.ds().is_ok_and(|d| d.doc.variables.get_by_id(&pick.id).is_some_and(|v| v.kind == VariableType::Number));
|
||||
let field = if number { "fontWeight" } else { "fontStyle" };
|
||||
app.run("style.bind", json!({"style": style, "field": field, "variable": pick.id}));
|
||||
}
|
||||
}
|
||||
close = true;
|
||||
}
|
||||
|
||||
@@ -726,6 +726,8 @@ struct TextBinding<'a> {
|
||||
fn text_binding(ui: &mut Ui, binding: TextBinding<'_>, out: &mut Frame) {
|
||||
let TextBinding { style, field, kind, rect, name, lead } = binding;
|
||||
match widgets::bound_field(ui, Id::unique(("ts_bound", style, field)), lead, rect, name) {
|
||||
// The weight slot takes a face name or a number, so its chip opens the picker that offers both.
|
||||
Some(widgets::BindEvent::Open(at)) if matches!(field, "fontWeight" | "fontStyle") => crate::variable_ui::open_style_weight(ui, style, at),
|
||||
Some(widgets::BindEvent::Open(at)) => crate::variable_ui::open_style(ui, style, field, kind, at),
|
||||
Some(widgets::BindEvent::Detach) => out.act = Some(Act::Unbind(style.to_string(), field.to_string())),
|
||||
None => {}
|
||||
@@ -787,15 +789,16 @@ fn font_style_size_row(ui: &mut Ui, doc: &Document, style: &Style, def: &TextSty
|
||||
let id = style.id.as_str();
|
||||
let mut style_resp = None;
|
||||
ui.horizontal(|ui| {
|
||||
match bound_name(doc, style, "fontStyle") {
|
||||
let weight_field = if style.bound.contains_key("fontWeight") { "fontWeight" } else { "fontStyle" };
|
||||
match bound_name(doc, style, weight_field) {
|
||||
Some(name) => {
|
||||
let (r, _) = ui.allocate_exact_size(vec2(half, widgets::FIELD_H), Sense::hover());
|
||||
text_binding(
|
||||
ui,
|
||||
TextBinding {
|
||||
style: id,
|
||||
field: "fontStyle",
|
||||
kind: VariableType::String,
|
||||
field: weight_field,
|
||||
kind: if weight_field == "fontWeight" { VariableType::Number } else { VariableType::String },
|
||||
rect: r,
|
||||
name: &name,
|
||||
lead: widgets::Lead::Named(tr("Font style")),
|
||||
@@ -944,7 +947,7 @@ fn font_family_menu(ui: &Ui, id: &str, def: &TextStyle, family_resp: &Response,
|
||||
fn font_style_menu(id: &str, def: &TextStyle, style_resp: &Response, out: &mut Frame) {
|
||||
crate::right::menu_popup(style_resp).width(200.0).show(|ui| {
|
||||
if crate::right::menu_item(ui, tr("Apply variable"), "") {
|
||||
crate::variable_ui::open_style(ui, id, "fontStyle", VariableType::String, style_resp.rect.left_bottom());
|
||||
crate::variable_ui::open_style_weight(ui, id, style_resp.rect.left_bottom());
|
||||
ui.close();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -362,9 +362,19 @@ fn text_variables_session(bind: bool) -> crate::layers_drag_tests::Ui {
|
||||
("Body tracking", "number", json!(1)),
|
||||
("Body font", "string", json!("Inter")),
|
||||
("Body weight", "string", json!("Bold")),
|
||||
("Body weight number", "number", json!(700)),
|
||||
] {
|
||||
s.execute("variable.create", &json!({"collection": "Type", "name": name, "type": ty, "values": {"Base": value}})).unwrap();
|
||||
}
|
||||
for (name, scope) in [
|
||||
("Body size", "FONT_SIZE"),
|
||||
("Body leading", "LINE_HEIGHT"),
|
||||
("Body tracking", "LETTER_SPACING"),
|
||||
("Body font", "FONT_FAMILY"),
|
||||
("Body weight number", "FONT_WEIGHT"),
|
||||
] {
|
||||
s.execute("variable.update", &json!({"variable": name, "scopes": [scope]})).unwrap();
|
||||
}
|
||||
if bind {
|
||||
for (field, var) in [
|
||||
("fontSize", "Body size"),
|
||||
@@ -454,6 +464,54 @@ fn text_style_editor_shows_bound_fields_as_variables_and_detaches_them() {
|
||||
assert!(u.app.ui.last_error.is_none(), "{:?}", u.app.ui.last_error);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_weight_menu_binds_a_number_variable_as_font_weight() {
|
||||
let mut u = text_variables_session(false);
|
||||
let left = editor_left(&mut u);
|
||||
let in_editor = |u: &mut crate::layers_drag_tests::Ui| -> Vec<(String, Pos2)> {
|
||||
drawn_texts(u).into_iter().filter(|(_, p)| p.x > left && p.x < left + W).collect()
|
||||
};
|
||||
let texts = in_editor(&mut u);
|
||||
click(&mut u, text_at(&texts, "Regular"));
|
||||
let texts = drawn_texts(&mut u);
|
||||
click(&mut u, text_at(&texts, "Apply variable"));
|
||||
assert!(crate::variable_ui::picker_open(&u.ctx), "the variable picker opens");
|
||||
let texts = drawn_texts(&mut u);
|
||||
assert!(texts.iter().any(|(t, _)| t == "Body weight"), "string variables are offered");
|
||||
assert!(!texts.iter().any(|(t, _)| t == "Body font" || t == "Body leading"), "other fields' variables are not");
|
||||
click(&mut u, text_at(&texts, "Body weight number"));
|
||||
assert_eq!(bound_to(&u, "fontWeight").as_deref(), Some("Body weight number"));
|
||||
assert_eq!(bound_to(&u, "fontStyle"), None);
|
||||
let font_style = || {
|
||||
let doc = u.app.session.doc().unwrap();
|
||||
let Some(StyleKind::Text { style: def, .. }) = doc.style("Body").map(|s| s.kind.clone()) else { panic!("a text style") };
|
||||
def.font_style
|
||||
};
|
||||
assert_eq!(font_style(), "Bold");
|
||||
// The chip shows it in the style slot; clicking it lists the string variable too, and picking that
|
||||
// replaces the number: fontStyle is bound and fontWeight is not.
|
||||
for _ in 0..2 {
|
||||
u.frame(vec![]);
|
||||
}
|
||||
let texts = in_editor(&mut u);
|
||||
click(&mut u, text_at(&texts, "Body weight number"));
|
||||
assert!(crate::variable_ui::picker_open(&u.ctx), "the chip opens the weight picker");
|
||||
let texts = drawn_texts(&mut u);
|
||||
assert!(texts.iter().any(|(t, _)| t == "Body weight"), "the string variable is offered beside the number one");
|
||||
click(&mut u, text_at(&texts, "Body weight"));
|
||||
assert_eq!(bound_to(&u, "fontStyle").as_deref(), Some("Body weight"));
|
||||
assert_eq!(bound_to(&u, "fontWeight"), None, "the name replaced the number");
|
||||
// The chip's detach button clears the binding.
|
||||
for _ in 0..2 {
|
||||
u.frame(vec![]);
|
||||
}
|
||||
let texts = in_editor(&mut u);
|
||||
let chip = text_at(&texts, "Body weight");
|
||||
click(&mut u, Pos2::new(left + (W - 8.0) / 2.0 - 11.0, chip.y));
|
||||
assert_eq!(bound_to(&u, "fontStyle"), None, "the face name is detached");
|
||||
assert!(u.app.ui.last_error.is_none(), "{:?}", u.app.ui.last_error);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_style_editor_binds_variables_from_its_fields() {
|
||||
let mut u = text_variables_session(false);
|
||||
@@ -477,7 +535,7 @@ fn text_style_editor_binds_variables_from_its_fields() {
|
||||
click(&mut u, Pos2::new(x, y));
|
||||
assert!(crate::variable_ui::picker_open(&u.ctx), "{field}: the variable picker opens");
|
||||
let texts = drawn_texts(&mut u);
|
||||
assert!(!texts.iter().any(|(t, _)| t == "Body font"), "{field} offers number variables only");
|
||||
assert!(!texts.iter().any(|(t, _)| t == "Body weight"), "{field} offers number variables only");
|
||||
click(&mut u, text_at(&texts, name));
|
||||
assert_eq!(bound_to(&u, field).as_deref(), Some(name));
|
||||
}
|
||||
@@ -491,7 +549,7 @@ fn text_style_editor_binds_variables_from_its_fields() {
|
||||
assert!(crate::variable_ui::picker_open(&u.ctx), "{field}: the variable picker opens");
|
||||
let texts = drawn_texts(&mut u);
|
||||
// "Body size" shows once, on the size field's chip: the picker doesn't list it.
|
||||
assert_eq!(texts.iter().filter(|(t, _)| t == "Body size").count(), 1, "{field} offers string variables only");
|
||||
assert_eq!(texts.iter().filter(|(t, _)| t == "Body size").count(), 1, "{field} does not offer the size variable");
|
||||
click(&mut u, text_at(&texts, name));
|
||||
assert_eq!(bound_to(&u, field).as_deref(), Some(name));
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Components, variants, styles and variables parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-07 · **Change:** minor (an instance lists only the properties its variant's layers use and long property names are cut to one line (#2022); with several layers selected the Design panel header's component button offers Create multiple components (#2088); the Local variables window selects several variables (click, Shift+click for a range, Cmd/Ctrl+click to toggle, Escape to clear) and delete, move to a group and scope act on the selected local variables in one undo step (#2002); text styles carry paragraph spacing and indent, the list style and the OpenType features, as the Supported in text styles table of Create and apply text styles says (#1829); a layer's effect binding takes only a field its effect has; Go to main component on an instance of a library component opens the library file in a tab and selects the main there; 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-07 · **Change:** minor (a text style binds a number variable to its weight (#2174); an instance lists only the properties its variant's layers use and long property names are cut to one line (#2022); with several layers selected the Design panel header's component button offers Create multiple components (#2088); the Local variables window selects several variables (click, Shift+click for a range, Cmd/Ctrl+click to toggle, Escape to clear) and delete, move to a group and scope act on the selected local variables in one undo step (#2002); text styles carry paragraph spacing and indent, the list style and the OpenType features, as the Supported in text styles table of Create and apply text styles says (#1829); a layer's effect binding takes only a field its effect has; Go to main component on an instance of a library component opens the library file in a tab and selects the main there; 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
|
||||
@@ -65,7 +65,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`; a text style carries paragraph spacing and indent, the list style and the OpenType features beside the character properties (`paragraph` next to `style` in `style.list`): applied with it, moved by `style.update`, unlinking when edited, kept by `.fig` files (#1829) |
|
||||
| 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 (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, grid sizes and a text style's font, font style, size, line height and letter spacing, the rest through the command |
|
||||
| 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, grid sizes and a text style's font, font style (a face name from a string variable, or a numeric weight from a number variable, which picks the nearest face and a variable font's `wght` axis), size, line height and letter spacing, the rest through the command; a bound line height is applied in pixels, as the reference tool does (measured 2026-11-10) |
|
||||
| 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; several variables can be selected in the editor (Shift and Cmd/Ctrl click) and deleted, moved to a group or rescoped at once |
|
||||
| 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 |
|
||||
@@ -81,6 +81,7 @@ updates with overrides kept: `library_tests.rs`, `components_scale_tests.rs`.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-10 | minor | M7, #2174: a text style binds a number variable to its weight (`style.bind {field: "fontWeight"}`): the style takes the face nearest the number and sets the `wght` axis, so static and variable fonts both follow; a face-name (string) binding and a weight binding replace each other, and the weight wins in a file carrying both. The style editor's weight menu offers number variables beside string ones. A number bound to a line height is applied in pixels, as the reference tool does, which a test now pins. Numbers unchanged. Tests: `style_vars_tests`, `style_picker::tests`. |
|
||||
| 2026-11-07 | patch | M3, #2088: with two or more layers selected the Design panel header's component button opens a menu with Create component, Create multiple components and Create component set (`component.createSet`, naming measured in Figma), as Figma's does; one layer keeps the plain button. Tests: `create_multiple_menu_tests`, `components_behaviour_tests::create_multiple_components_keeps_names_and_places_and_is_one_undo_step`, `components_behaviour_tests::create_set_*`. Grade unchanged. |
|
||||
| 2026-11-05 | minor | M3, #2002: the Local variables window selects several variables, as the Layers panel selects layers. A click selects one row, Shift+click the range from the anchor through the rows shown, Cmd/Ctrl+click toggles a row, Escape or a click on the empty space below the rows clears; selected rows use the sidebar's selected-row colour. Right-clicking a row that is part of a selection of several offers Delete, Move to group and Scope (all supported properties, hidden from pickers, or only one scope) for the selected local variables, each sent as one `command.batch`, so one undo step; no engine command changed. Renaming and value edits stay single-row. Tests: `variables_select_tests` (headless UI). Grade unchanged |
|
||||
| 2026-11-02 | minor | M3, #1829: text styles carry paragraph spacing and indent, the list style and the OpenType features ("Supported in text styles": "Paragraph spacing and indentation", "Lists", "OpenType features", Create and apply text styles) beside the character properties: `style.create` takes them from the layer (or the selected paragraphs' list) or from a `value` (`paragraphSpacing`, `paragraphIndent`, `listStyle`, `features`), `style.apply` sets them (on a range, the list goes to the paragraphs touched; the rest is the layer's, as in Figma), `style.update` moves them on every linked layer, editing one on a styled text unlinks it as editing its font does, the style editor lists them, and a `.fig` file keeps them with the style (its sample layer). Grade unchanged. |
|
||||
|
||||
+3
-1
@@ -1886,7 +1886,9 @@ properties with their values, its code and, with `path`, a PNG of it. The docume
|
||||
unbinds): `fill` or `fills/<i>/color` and `fills/<i>/stops/<j>/color` on a paint style,
|
||||
`effects/<i>/<color|radius|spread|offsetX|offsetY|startRadius>` on an effect style,
|
||||
`layoutGrids/<i>/<size|count|gutter|offset>` on a grid style, `fontSize`, `lineHeight`,
|
||||
`letterSpacing`, `fontFamily`, `fontStyle` on a text style. Each layer using the style shows the
|
||||
`letterSpacing`, `fontWeight` (numbers; a weight picks the family's face nearest it and sets the
|
||||
`wght` axis of a variable font), `fontFamily`, `fontStyle` (strings; a bound weight and a bound style
|
||||
name replace each other) 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`,
|
||||
|
||||
Reference in new issue
Block a user