M3: text styles carry paragraph spacing and indent, the list style and the OpenType features (#1829)

ParagraphStyle beside TextStyle in StyleKind::Text: style.create takes it from the layer or a value, style.apply sets it (the list on the paragraphs touched for a range), style.update moves it, editing it unlinks, .fig files keep it with the style; tests in styles_behaviour_tests and fig_export_assets_tests.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Trevor BrownandClaude Fable 5.1 committed 2026-11-02 06:43:02 -05:00
1 parent e65ce42000
commit 695ca07528
26 files changed
+346 -66

No files matched your search

+1 -1
View File
@@ -79,7 +79,7 @@ impl Style {
pub fn for_each_color_mut(&mut self, f: &mut ColorVisitor) {
match &mut self.kind {
StyleKind::Paint { paints } => paints.iter_mut().for_each(|p| p.for_each_color_mut(f)),
StyleKind::Text { style } => style.for_each_color_mut(f),
StyleKind::Text { style, .. } => style.for_each_color_mut(f),
StyleKind::Effect { effects } => effects.iter_mut().for_each(|e| e.for_each_color_mut(f)),
StyleKind::Grid { grids } => grids.iter_mut().for_each(|g| f(&mut g.color)),
}
+1 -1
View File
@@ -1206,7 +1206,7 @@ impl Document {
.styles
.iter()
.filter_map(|s| match &s.kind {
StyleKind::Text { style } => Some((s.id.clone(), style.clone())),
StyleKind::Text { style, .. } => Some((s.id.clone(), style.clone())),
_ => None,
})
.collect();
+17 -5
View File
@@ -1,7 +1,7 @@
//! Shared styles and variables.
use crate::props::{Effect, LayoutGrid};
use crate::text::TextStyle;
use crate::text::{ParagraphStyle, TextStyle};
use figcraft_color::{Color, Paint};
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet};
@@ -14,10 +14,22 @@ pub type ModeId = String;
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum StyleKind {
Paint { paints: Vec<Paint> },
Text { style: TextStyle },
Effect { effects: Vec<Effect> },
Grid { grids: Vec<LayoutGrid> },
Paint {
paints: Vec<Paint>,
},
Text {
style: TextStyle,
/// What the style sets at the paragraph and layer level (#1829); boxed to keep the enum
/// small.
#[serde(default, skip_serializing_if = "crate::node::is_default")]
paragraph: Box<ParagraphStyle>,
},
Effect {
effects: Vec<Effect>,
},
Grid {
grids: Vec<LayoutGrid>,
},
}
impl StyleKind {
+68
View File
@@ -291,6 +291,23 @@ pub struct TextStyle {
pub style_id: Option<String>,
}
/// What a text style sets at the paragraph and layer level, beside its character style
/// ([`TextStyle`]): 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).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
#[serde(default)]
pub struct ParagraphStyle {
#[serde(skip_serializing_if = "crate::node::is_default")]
pub paragraph_spacing: f64,
#[serde(skip_serializing_if = "crate::node::is_default")]
pub paragraph_indent: f64,
#[serde(skip_serializing_if = "crate::node::is_default")]
pub list: ListStyle,
#[serde(skip_serializing_if = "crate::node::is_default")]
pub features: std::collections::BTreeMap<String, bool>,
}
/// The font new text starts in. A build can name another (`FIGCRAFT_DEFAULT_FONT="Noto Sans Thai"`,
/// for a distribution whose users mostly write a script Inter lacks); empty means Inter.
pub const DEFAULT_FONT: &str = match option_env!("FIGCRAFT_DEFAULT_FONT") {
@@ -843,6 +860,57 @@ impl TextData {
(a..=b).all(|p| self.para_list(p).list == first.list && self.para_list(p).indent == first.indent).then_some(first)
}
/// What a text style made from this text sets at the paragraph and layer level: the paragraph
/// spacing and indent, the list style (the one every paragraph shares; the base one when they
/// differ) and the OpenType features.
pub fn paragraph_style(&self) -> ParagraphStyle {
let list = match self.paragraphs.first() {
Some(first) if self.paragraphs.iter().all(|p| p.list == first.list) => first.list,
_ => self.list,
};
ParagraphStyle { paragraph_spacing: self.paragraph_spacing, paragraph_indent: self.paragraph_indent, list, features: self.features.clone() }
}
/// Take what text style `ps` sets at the paragraph and layer level: the spacing, indent and
/// features on the layer, the list style on every paragraph.
pub fn take_paragraph_style(&mut self, ps: &ParagraphStyle) {
let len = self.char_count();
self.take_paragraph_style_in(0, len, ps);
}
/// As [`take_paragraph_style`](Self::take_paragraph_style), with the list style going to the
/// paragraphs chars `start..end` touch only; the spacing, indent and features are the
/// layer's, as in Figma.
pub fn take_paragraph_style_in(&mut self, start: usize, end: usize, ps: &ParagraphStyle) {
self.paragraph_spacing = ps.paragraph_spacing;
self.paragraph_indent = ps.paragraph_indent;
self.features.clone_from(&ps.features);
self.set_para_lists(start, end, |pl| pl.list = ps.list);
}
/// Follow text style `id`'s paragraph-level definition wherever chars link to it.
pub fn update_paragraph_style(&mut self, id: &str, ps: &ParagraphStyle) {
let linked: Vec<(usize, usize)> =
self.spans().into_iter().filter(|(_, _, s)| s.style_id.as_deref() == Some(id)).map(|(a, b, _)| (a, b)).collect();
for (a, b) in linked {
self.take_paragraph_style_in(a, b, ps);
}
}
/// Whether chars `start..end` show what text style `ps` sets at the paragraph and layer
/// level: the layer's spacing, indent and features, and their paragraphs' list style.
pub fn shows_paragraph_style_in(&self, start: usize, end: usize, ps: &ParagraphStyle) -> bool {
self.paragraph_spacing == ps.paragraph_spacing
&& self.paragraph_indent == ps.paragraph_indent
&& self.features == ps.features
&& self.common_para_list(start, end).is_some_and(|pl| pl.list == ps.list)
}
/// Whether the whole text shows what text style `ps` sets at the paragraph and layer level.
pub fn shows_paragraph_style(&self, ps: &ParagraphStyle) -> bool {
self.shows_paragraph_style_in(0, self.char_count(), ps)
}
/// Link chars `start..end` (all of them when the range is empty or the whole text) to text
/// style `id`, defined as `def`.
pub fn link_text_style(&mut self, start: usize, end: usize, def: &TextStyle, id: &str) {
+2 -2
View File
@@ -113,7 +113,7 @@ fn resolved(vars: &Variables, st: &Style, modes: &Modes) -> StyleKind {
(StyleKind::Grid { grids }, Some((BoundList::Grids, i, field)), VariableValue::Number(x)) => {
crate::cmd::lists::set_grid_field(grids, i, field, *x);
}
(StyleKind::Text { style }, None, _) => set_text_field(style, key, &val),
(StyleKind::Text { style, .. }, None, _) => set_text_field(style, key, &val),
_ => {}
}
}
@@ -206,7 +206,7 @@ pub fn refresh(doc: &mut Document, before: Option<&Document>, only: Option<&BTre
&& was.is_none_or(|w| w.kind == n.kind)
{
for st in doc.styles.iter().filter(|s| !s.bound.is_empty()) {
if let StyleKind::Text { style } = kind_for(doc, st, id).as_ref() {
if let StyleKind::Text { style, .. } = kind_for(doc, st, id).as_ref() {
t.update_text_style(&st.id, style);
}
}
+3 -3
View File
@@ -250,7 +250,7 @@ fn match_font_families(doc: &mut Document) {
}
// Shared text styles too, so they stay like the layers linked to them.
for st in &mut doc.styles {
if let StyleKind::Text { style } = &mut st.kind {
if let StyleKind::Text { style, .. } = &mut st.kind {
fix(style);
}
}
@@ -294,7 +294,7 @@ fn platform_tracking(doc: &mut Document) {
}
}
for st in &mut doc.styles {
if let StyleKind::Text { style } = &mut st.kind {
if let StyleKind::Text { style, .. } = &mut st.kind {
track(style);
}
}
@@ -365,7 +365,7 @@ fn sketch_line_heights(doc: &mut Document) {
}
}
for st in &mut doc.styles {
if let StyleKind::Text { style } = &mut st.kind {
if let StyleKind::Text { style, .. } = &mut st.kind {
set(style);
}
}
+80 -14
View File
@@ -3,6 +3,7 @@
use std::collections::BTreeMap;
use figcraft_color::{Color, Paint, PaintKind};
use figcraft_doc::ParagraphStyle;
use figcraft_doc::{
Collection, CollectionId, Document, Effect, EffectKind, Mode, ModeId, Node, NodeId, NodeKind, Style, StyleId, StyleKind, TextData, TextStyle,
Variable, VariableId, VariableType, VariableValue, Variables, trim_num,
@@ -261,6 +262,41 @@ fn text_style_of(n: &Node, at: Option<usize>) -> Option<TextStyle> {
Some(at.map_or(&t.style, |a| t.style_at(a)).as_text_style())
}
/// What a text style definition from JSON sets at the paragraph level, in the `node.set`
/// spellings (`paragraphSpacing`, `paragraphIndent`, `listStyle`: none|bulleted|numbered,
/// `features` or `openTypeFeatures`: `{tag: bool}`); keys left out are the defaults.
fn paragraph_style_json(cmd: &str, v: &Value) -> Result<ParagraphStyle> {
let Value::Object(m) = v else { return Ok(ParagraphStyle::default()) };
let mut ps = ParagraphStyle::default();
let length =
|k: &str, x: &Value| f64_of(x).filter(|v| v.is_finite()).map(|v| v.clamp(0.0, 10_000.0)).ok_or_else(|| bad(cmd, format!("{k}: a number")));
for (k, x) in m {
match k.as_str() {
"paragraphSpacing" | "paragraph_spacing" => ps.paragraph_spacing = length(k, x)?,
"paragraphIndent" | "paragraph_indent" => ps.paragraph_indent = length(k, x)?,
"listStyle" | "list" => ps.list = super::props::enum_of(x).ok_or_else(|| bad(cmd, "listStyle: none|bulleted|numbered"))?,
"features" | "openTypeFeatures" | "fontFeatures" | "textFeatures" => {
ps.features = super::props::features_of(cmd, x)?.into_iter().filter_map(|(tag, on)| Some((tag, on?))).collect();
}
_ => {}
}
}
Ok(ps)
}
/// What a layer's text sets at the paragraph level, the list style being that of the paragraphs
/// chars `range` touch when given (the layer's when they differ).
fn paragraph_style_of(n: &Node, range: Option<(usize, usize)>) -> Option<ParagraphStyle> {
let t = n.kind.text()?;
let mut ps = t.paragraph_style();
if let Some((a, b)) = range
&& let Some(pl) = t.common_para_list(a, b)
{
ps.list = pl.list;
}
Some(ps)
}
fn detach(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "style.detach";
let which = req_string(cmd, p, "which")?;
@@ -295,7 +331,7 @@ fn kind_from_node(t: &str, n: &Node) -> Option<StyleKind> {
Some(match t {
"paint" | "color" | "fill" => StyleKind::Paint { paints: n.fills.clone() },
"stroke" => StyleKind::Paint { paints: n.strokes.clone() },
"text" => StyleKind::Text { style: text_style_of(n, None)? },
"text" => StyleKind::Text { style: text_style_of(n, None)?, paragraph: Box::new(paragraph_style_of(n, None)?) },
"effect" => StyleKind::Effect { effects: n.effects.clone() },
"grid" => StyleKind::Grid { grids: n.grids.clone() },
_ => return None,
@@ -317,13 +353,17 @@ fn create(s: &mut Session, p: &Value) -> Result<Value> {
let kind = if let Some(v) = get(p, "value") {
match t.as_str() {
"paint" | "color" | "fill" => StyleKind::Paint { paints: paints_of(v).ok_or_else(|| bad(cmd, "value: paints"))? },
"text" => StyleKind::Text { style: text_style_json(cmd, v)? },
"text" => StyleKind::Text { style: text_style_json(cmd, v)?, paragraph: Box::new(paragraph_style_json(cmd, v)?) },
"effect" => StyleKind::Effect { effects: serde_json::from_value(v.clone()).map_err(|e| bad(cmd, e.to_string()))? },
"grid" => StyleKind::Grid { grids: serde_json::from_value(v.clone()).map_err(|e| bad(cmd, e.to_string()))? },
_ => return Err(bad(cmd, "type: paint|text|effect|grid")),
}
} else if let Some((tid, a, _)) = range {
StyleKind::Text { style: doc.node(tid).and_then(|n| text_style_of(n, Some(a))).ok_or_else(|| bad(cmd, "not a text layer"))? }
} else if let Some((tid, a, b)) = range {
let n = doc.node(tid).ok_or_else(|| bad(cmd, "not a text layer"))?;
StyleKind::Text {
style: text_style_of(n, Some(a)).ok_or_else(|| bad(cmd, "not a text layer"))?,
paragraph: Box::new(paragraph_style_of(n, Some((a, b))).ok_or_else(|| bad(cmd, "not a text layer"))?),
}
} else {
let n = from.and_then(|f| doc.node(f)).ok_or_else(|| bad(cmd, "pass `value` or select a layer to take it from"))?;
kind_from_node(&t, n).ok_or_else(|| bad(cmd, "that layer has no such property"))?
@@ -343,7 +383,7 @@ fn create(s: &mut Session, p: &Value) -> Result<Value> {
};
doc.styles.push(Style { id: sid.clone(), name, description: string(p, "description").unwrap_or_default(), kind: kind.clone(), bound });
// The source layer (or the selected chars) becomes linked to the new style.
if let (Some((tid, a, b)), StyleKind::Text { style }) = (range, &kind) {
if let (Some((tid, a, b)), StyleKind::Text { style, .. }) = (range, &kind) {
if let NodeKind::Text(td) = &mut doc.get_mut(tid)?.kind {
td.link_text_style(a, b, style, &sid);
}
@@ -387,11 +427,13 @@ pub fn apply_style_ref(doc: &mut Document, id: NodeId, field: &str, sid: Option<
n.grids = grids.clone();
n.styles.grid = Some(id.clone());
}
("textStyle", Some(Style { kind: StyleKind::Text { style }, id, .. })) => {
// Every char takes the style's typography (their fills and links stay).
("textStyle", Some(Style { kind: StyleKind::Text { style, paragraph }, id, .. })) => {
// Every char takes the style's typography (their fills and links stay), and the layer
// what the style sets at the paragraph level (#1829).
if let NodeKind::Text(t) = &mut n.kind {
let len = t.char_count();
t.link_text_style(0, len, style, id);
t.take_paragraph_style(paragraph);
}
n.styles.text = Some(id.clone());
}
@@ -416,7 +458,7 @@ pub(crate) fn apply(s: &mut Session, p: &Value) -> Result<Value> {
let st = req_string(cmd, p, "style")?;
let style = s.doc()?.style(&st).cloned().ok_or_else(|| bad(cmd, format!("no style `{st}`")))?;
// Text styles go on chars: a range, the selected chars of the edited text, or whole layers.
if let StyleKind::Text { style: def } = &style.kind {
if let StyleKind::Text { style: def, paragraph } = &style.kind {
// A text style is no property of other layers: they're left alone.
let doc = s.doc()?;
let targets: Vec<TextTarget> =
@@ -430,6 +472,8 @@ pub(crate) fn apply(s: &mut Session, p: &Value) -> Result<Value> {
Some((a, b)) => {
if let NodeKind::Text(t) = &mut doc.get_mut(i)?.kind {
t.link_text_style(a, b, def, &style.id);
// The list goes to the paragraphs touched; the rest is the layer's.
t.take_paragraph_style_in(a, b, paragraph);
}
}
None => apply_style_ref(doc, i, "textStyle", Some(&style.id))?,
@@ -506,7 +550,7 @@ fn update(s: &mut Session, p: &Value) -> Result<Value> {
let cur = doc.styles.get(idx).map(|x| x.kind.type_name()).unwrap_or("paint");
Some(match cur {
"paint" => StyleKind::Paint { paints: paints_of(v).ok_or_else(|| bad(cmd, "value: paints"))? },
"text" => StyleKind::Text { style: text_style_json(cmd, v)? },
"text" => StyleKind::Text { style: text_style_json(cmd, v)?, paragraph: Box::new(paragraph_style_json(cmd, v)?) },
"effect" => StyleKind::Effect { effects: serde_json::from_value(v.clone()).map_err(|e| bad(cmd, e.to_string()))? },
_ => StyleKind::Grid { grids: serde_json::from_value(v.clone()).map_err(|e| bad(cmd, e.to_string()))? },
})
@@ -651,22 +695,23 @@ fn rename_folder(s: &mut Session, p: &Value) -> Result<Value> {
/// Re-apply style `sid` to every node (and text chars) linked to it.
pub fn propagate_style(doc: &mut Document, sid: &str) {
let text_def = match doc.style(sid).map(|s| &s.kind) {
Some(StyleKind::Text { style }) => Some(style.clone()),
Some(StyleKind::Text { style, paragraph }) => Some((style.clone(), paragraph.clone())),
_ => None,
};
let ids: Vec<NodeId> = doc.ids().collect();
for i in ids {
let Some(n) = doc.node(i) else { continue };
if let Some(def) = &text_def {
if let Some((def, paragraph)) = &text_def {
if n.kind.text().is_some_and(|t| t.links_text_style(sid)) {
let def = match doc.style(sid).map(|st| crate::style_vars::kind_for(doc, st, i).into_owned()) {
Some(StyleKind::Text { style }) => style,
Some(StyleKind::Text { style, .. }) => style,
_ => def.clone(),
};
if let Ok(n) = doc.get_mut(i)
&& let NodeKind::Text(t) = &mut n.kind
{
t.update_text_style(sid, &def);
t.update_paragraph_style(sid, paragraph);
}
}
continue;
@@ -695,7 +740,16 @@ pub fn sync_text_styles(doc: &mut Document, ids: &[NodeId]) {
.styles
.iter()
.filter_map(|s| match &s.kind {
StyleKind::Text { style } => Some((s.id.clone(), style.clone())),
StyleKind::Text { style, .. } => Some((s.id.clone(), style.clone())),
_ => None,
})
.collect();
// What each text style sets at the paragraph level (#1829); no variable binds those.
let paras: BTreeMap<&str, &ParagraphStyle> = doc
.styles
.iter()
.filter_map(|s| match &s.kind {
StyleKind::Text { paragraph, .. } => Some((s.id.as_str(), paragraph.as_ref())),
_ => None,
})
.collect();
@@ -707,7 +761,7 @@ pub fn sync_text_styles(doc: &mut Document, ids: &[NodeId]) {
// Styles with bound fields read as this layer sees them.
let mut defs = std::borrow::Cow::Borrowed(&defs);
for st in &varying {
if let StyleKind::Text { style } = crate::style_vars::kind_for(doc, st, id).into_owned() {
if let StyleKind::Text { style, .. } = crate::style_vars::kind_for(doc, st, id).into_owned() {
defs.to_mut().insert(st.id.clone(), style);
}
}
@@ -717,11 +771,23 @@ pub fn sync_text_styles(doc: &mut Document, ids: &[NodeId]) {
&& t2.text_style_link() == Some(None)
&& let Some(def) = defs.get(sid)
&& t2.spans().iter().all(|(_, _, s)| s.shows_text_style(def))
&& paras.get(sid.as_str()).is_some_and(|ps| t2.shows_paragraph_style(ps))
{
let len = t2.char_count();
t2.restyle(0, len, |s| s.style_id = Some(sid.clone()));
}
t2.check_text_styles(|sid| defs.get(sid));
// Chars whose paragraphs left their style's list, and every char of a layer whose
// paragraph spacing, indent or features were edited, unlink too (#1829).
let gone: Vec<(usize, usize)> = t2
.spans()
.iter()
.filter(|(a, b, s)| s.style_id.as_deref().is_some_and(|sid| paras.get(sid).is_none_or(|ps| !t2.shows_paragraph_style_in(*a, *b, ps))))
.map(|(a, b, _)| (*a, *b))
.collect();
for (a, b) in gone {
t2.unlink_text_style(a, b);
}
let link = t2.text_style_link().flatten().map(str::to_string);
if t2 != *t || link != n.styles.text {
updates.push((id, link, t2));
+2 -2
View File
@@ -154,7 +154,7 @@ fn auto_layout(doc: &mut Document, id: NodeId, mode: LayoutMode, gap: f64, pad:
fn styled_text(doc: &mut Document, parent: NodeId, chars: &str, name: &str, style: &str) -> DocResult<NodeId> {
let id = doc.create(parent, text(chars), name, 0.0, 0.0, 60.0, 16.0)?;
let def = doc.styles.iter().find(|s| s.id == style).and_then(|s| match &s.kind {
StyleKind::Text { style } => Some(style.clone()),
StyleKind::Text { style, .. } => Some(style.clone()),
_ => None,
});
set_node(doc, id, |n| {
@@ -242,7 +242,7 @@ fn foundations(doc: &mut Document) -> (CollectionId, ModeId, ModeId) {
id: format!("ds:text:{name}"),
name: name.into(),
description: String::new(),
kind: StyleKind::Text { style },
kind: StyleKind::Text { style, paragraph: Default::default() },
bound: Default::default(),
});
}
@@ -122,6 +122,27 @@ fn variables_styles_and_instance_sizes_come_back() {
assert!(after.iter().any(|l| l.contains("\"In the dark\"") && l.contains("FFFFFF")), "{after:#?}");
}
/// #1829: a text style's paragraph spacing and indent and its list style go through a `.fig`
/// file with it (the style's sample layer carries them, as a Figma file's does).
#[test]
fn a_text_styles_paragraph_values_come_back_from_a_fig_file() {
let mut s = Session::new();
let t = id(&run(&mut s, "text.create", json!({"text": "One\nTwo", "x": 0, "y": 0})));
run(&mut s, "node.set", json!({"ids": [t], "paragraphSpacing": 12, "paragraphIndent": 8, "listStyle": "bulleted"}));
run(&mut s, "style.create", json!({"type": "text", "name": "Audit", "from": t}));
let path = std::env::temp_dir().join(format!("figcraft-fig-text-style-{}.fig", std::process::id()));
run(&mut s, "file.export", json!({"format": "fig", "path": path.to_string_lossy()}));
run(&mut s, "file.open", json!({"path": path.to_string_lossy()}));
let _ = std::fs::remove_file(&path);
let doc = s.doc().unwrap();
let st = doc.styles.iter().find(|x| x.name == "Audit").unwrap();
let figcraft_doc::StyleKind::Text { paragraph, .. } = &st.kind else { panic!("{:?}", st.kind) };
assert_eq!((paragraph.paragraph_spacing, paragraph.paragraph_indent, paragraph.list), (12.0, 8.0, figcraft_doc::ListStyle::Bulleted));
// The layer the style was made from is linked to it again, showing the same values.
let layer = doc.nodes().find(|n| n.kind.text().is_some()).unwrap();
assert_eq!(layer.styles.text.as_deref(), Some(st.id.as_str()));
}
#[test]
fn every_example_keeps_its_variables_and_styles() {
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../examples");
+1 -1
View File
@@ -54,7 +54,7 @@ pub fn missing(doc: &Document) -> Vec<Missing> {
}
}
for st in &doc.styles {
if let StyleKind::Text { style } = &st.kind {
if let StyleKind::Text { style, .. } = &st.kind {
add(&style.font_family, false);
}
if let Some(var) = st.bound.get("fontFamily") {
+4 -1
View File
@@ -37,7 +37,10 @@ fn lists_families_of_layers_ranges_styles_and_variables_once_each() {
id: "S:1".into(),
name: "Heading".into(),
description: String::new(),
kind: StyleKind::Text { style: TextStyle { font_family: "FigCraft Missing C".into(), ..TextStyle::default() } },
kind: StyleKind::Text {
style: TextStyle { font_family: "FigCraft Missing C".into(), ..TextStyle::default() },
paragraph: Default::default(),
},
bound: Default::default(),
});
s.execute("variable.createCollection", &json!({"name": "Type", "modes": ["Light", "Dark"]})).unwrap();
+1 -1
View File
@@ -520,7 +520,7 @@ fn sketch_shared_text_styles_match_their_layers() {
.styles
.iter()
.find_map(|st| match &st.kind {
figcraft_doc::StyleKind::Text { style } if st.name == "Body" => Some(style.clone()),
figcraft_doc::StyleKind::Text { style, .. } if st.name == "Body" => Some(style.clone()),
_ => None,
})
.unwrap();
+64 -1
View File
@@ -5,10 +5,11 @@
//! line; "docs silent" marks behaviour the documentation doesn't describe (pinned as it is).
use serde_json::{Value, json};
use std::collections::BTreeMap;
use crate::Session;
use crate::params::id_of;
use figcraft_doc::{Node, NodeId};
use figcraft_doc::{ListStyle, Node, NodeId, ParagraphStyle, StyleKind, TextData};
fn id(v: Value) -> NodeId {
id_of(&v["id"]).unwrap()
@@ -355,6 +356,68 @@ fn text_styles_go_on_text_layers_and_chars() {
assert_eq!(tx.styles.text, None, "mixed: the layer isn't linked as a whole");
}
#[test]
fn text_styles_carry_paragraph_spacing_indent_lists_and_features() {
// "Supported in text styles": "Paragraph spacing and indentation", "Lists" and "OpenType
// features", beside the character properties (Create and apply text styles). A style made
// from a text takes them, applying it sets them, editing one on a styled text unlinks it.
let mut s = Session::new();
let td = |s: &Session, n: NodeId| -> TextData { node(s, n).kind.text().unwrap().clone() };
let t = text(&mut s, 0.0);
let props = json!({"characters": "One\nTwo", "paragraphSpacing": 12, "paragraphIndent": 8, "listStyle": "bulleted", "features": {"tnum": true, "liga": false}});
run(&mut s, "node.set", json!({"id": t, "props": props}));
let st = sid(run(&mut s, "style.create", json!({"type": "text", "name": "Audit", "from": t})));
let listed = run(&mut s, "style.list", json!({"type": "text"}));
let audit = listed.as_array().unwrap().iter().find(|x| x["id"] == json!(st)).unwrap();
assert_eq!(
audit["paragraph"],
json!({"paragraph_spacing": 12.0, "paragraph_indent": 8.0, "list": "bulleted", "features": {"liga": false, "tnum": true}})
);
// Applying it sets them on a plain text, every paragraph in the list; the layer links.
let t2 = text(&mut s, 300.0);
run(&mut s, "node.set", json!({"id": t2, "props": {"characters": "A\nB\nC"}}));
run(&mut s, "style.apply", json!({"ids": [t2], "style": st}));
let d = td(&s, t2);
assert_eq!((d.paragraph_spacing, d.paragraph_indent), (12.0, 8.0));
assert!((0..3).all(|p| d.para_list(p).list == ListStyle::Bulleted), "{:?}", d.paragraphs);
assert_eq!(d.features, BTreeMap::from([("liga".to_string(), false), ("tnum".to_string(), true)]));
assert_eq!(node(&s, t2).styles.text.as_deref(), Some(st.as_str()));
// Redefining the style moves its layers' paragraph values with the rest.
let value = json!({"fontSize": 22, "paragraphSpacing": 20, "listStyle": "numbered", "openTypeFeatures": {"tnum": true}});
run(&mut s, "style.update", json!({"style": st, "value": value}));
let d = td(&s, t2);
assert_eq!((d.paragraph_spacing, d.paragraph_indent, d.para_list(1).list), (20.0, 0.0, ListStyle::Numbered));
assert_eq!(d.features, BTreeMap::from([("tnum".to_string(), true)]));
assert_eq!(font_size(&s, t2), 22.0);
assert_eq!(node(&s, t2).styles.text.as_deref(), Some(st.as_str()), "still linked after the update");
// Editing a paragraph value the style sets unlinks the text, as editing its font does.
run(&mut s, "node.set", json!({"id": t2, "props": {"paragraphSpacing": 5}}));
assert_eq!(node(&s, t2).styles.text, None, "paragraph spacing edited: unlinked");
assert!(td(&s, t2).spans().iter().all(|(_, _, x)| x.style_id.is_none()));
// On a range, the list goes to the paragraphs touched; the spacing is the layer's. Taking
// the paragraph out of the style's list unlinks its chars.
let t3 = text(&mut s, 600.0);
run(&mut s, "node.set", json!({"id": t3, "props": {"characters": "A\nB\nC"}}));
run(&mut s, "style.apply", json!({"ids": [t3], "style": st, "start": 0, "end": 1}));
let d = td(&s, t3);
assert_eq!((d.para_list(0).list, d.para_list(1).list, d.paragraph_spacing), (ListStyle::Numbered, ListStyle::None, 20.0));
assert_eq!(d.style_at(0).style_id.as_deref(), Some(st.as_str()));
run(&mut s, "node.set", json!({"id": t3, "props": {"start": 0, "end": 1, "listStyle": "none"}}));
assert_eq!(td(&s, t3).style_at(0).style_id, None, "its paragraph left the style's list");
// A definition given as a value takes the node.set spellings; junk is refused.
let value = json!({"fontSize": 11, "paragraphSpacing": 4, "listStyle": "bulleted", "features": {"liga": false}});
let st2 = sid(run(&mut s, "style.create", json!({"type": "text", "name": "Note", "value": value})));
let want = ParagraphStyle {
paragraph_spacing: 4.0,
list: ListStyle::Bulleted,
features: BTreeMap::from([("liga".to_string(), false)]),
..Default::default()
};
assert!(matches!(&s.doc().unwrap().style(&st2).unwrap().kind, StyleKind::Text { paragraph, .. } if **paragraph == want));
assert!(s.execute("style.create", &json!({"type": "text", "name": "Bad", "value": {"listStyle": "dotted"}})).is_err());
assert!(s.execute("style.create", &json!({"type": "text", "name": "Bad", "value": {"paragraphSpacing": "wide"}})).is_err());
}
#[test]
fn styles_are_organised_by_slash_names_order_and_duplicates() {
// Documented: "/" in a name groups styles in folders; moving a style to another folder renames
+2 -2
View File
@@ -86,7 +86,7 @@ fn editing_text_applies_to_the_selected_chars() {
s.execute("text.style", &json!({"id": t, "start": 6, "end": 11, "fontSize": 20})).unwrap();
let c = s.execute("style.create", &json!({"type": "text", "name": "Type/Caption"})).unwrap()["id"].as_str().unwrap().to_string();
let style = s.doc().unwrap().style(&c).cloned().unwrap();
let StyleKind::Text { style: def } = style.kind else { panic!("not a text style") };
let StyleKind::Text { style: def, .. } = style.kind else { panic!("not a text style") };
assert_eq!(def.font_size, 20.0);
assert_eq!(def.style_id, None, "definitions link to nothing");
assert_eq!(links(&s, t)[8].as_deref(), Some(c.as_str()));
@@ -117,7 +117,7 @@ fn edits_unlink_and_style_edits_follow() {
assert_eq!(layer_link(&s, a).as_deref(), Some(h.as_str()));
// Redefining the style moves linked chars only.
let mut def = match &s.doc().unwrap().style(&h).unwrap().kind {
StyleKind::Text { style } => style.clone(),
StyleKind::Text { style, .. } => style.clone(),
_ => panic!("not a text style"),
};
def.font_size = 40.0;
+2 -2
View File
@@ -225,7 +225,7 @@ fn same_after_round_trip(doc: &Document, what: &str) -> Vec<String> {
normalize(&mut n, &|i| i);
*paints = n.fills;
}
StyleKind::Text { style } => {
StyleKind::Text { style, .. } => {
if let NodeKind::Text(td) = &mut n.kind {
td.style = style.clone();
}
@@ -444,7 +444,7 @@ fn styled() -> Document {
bound: Default::default(),
};
doc.styles = vec![
style("s-text", "Heading", StyleKind::Text { style: heading.clone() }),
style("s-text", "Heading", StyleKind::Text { style: heading.clone(), paragraph: Default::default() }),
style("s-brand", "Brand/Primary", StyleKind::Paint { paints: vec![solid(0.25, 0.5, 1.0)] }),
style("s-unused", "Brand/Unused", StyleKind::Paint { paints: vec![solid(1.0, 0.0, 0.0), solid(0.0, 1.0, 0.0)] }),
style("s-shadow", "Elevation/1", StyleKind::Effect { effects: vec![shadow.clone()] }),
+4 -3
View File
@@ -159,7 +159,7 @@ impl<'d> Exporter<'d> {
Bound::default()
} else {
let mut sample = Node::new(NodeId(0), NodeKind::Rectangle, "", 0.0, 0.0);
if let StyleKind::Text { style } = &st.kind {
if let StyleKind::Text { style, .. } = &st.kind {
sample.kind = NodeKind::Text(TextData { style: style.as_text_style(), ..TextData::new("Ag") });
}
sample.bound.clone_from(&st.bound);
@@ -179,8 +179,9 @@ impl<'d> Exporter<'d> {
let list = grids.iter().enumerate().map(|(i, g)| layout_grid(g, i, &none)).collect();
f.extend([("fillPaints", arr(Vec::new())), ("layoutGrids", arr(list))]);
}
StyleKind::Text { style } => {
let td = TextData { style: style.as_text_style(), ..TextData::new("Ag") };
StyleKind::Text { style, paragraph } => {
let mut td = TextData { style: style.as_text_style(), ..TextData::new("Ag") };
td.take_paragraph_style(paragraph);
let fills = [Paint::solid(Color::BLACK)];
f.push(("fillPaints", self.paints(style.fills.as_deref().unwrap_or(&fills), true, &none)));
self.text(&td, &fills, &mut f);
+3 -3
View File
@@ -113,7 +113,7 @@ fn local_skip_ink_override_preserves_shared_style_and_omitted_value_inherits() {
let node = doc.nodes().find(|n| n.name == "Label").unwrap();
assert_eq!(node.kind.text().unwrap().style.look().skip_ink, expected);
let linked = doc.style(node.styles.text.as_deref().unwrap()).unwrap();
let StyleKind::Text { style } = &linked.kind else { panic!("linked definition must remain text") };
let StyleKind::Text { style, .. } = &linked.kind else { panic!("linked definition must remain text") };
assert!(style.look().skip_ink, "local value must not alter definition");
}
}
@@ -223,7 +223,7 @@ fn export_writes_one_actual_node_value_and_complete_run_values_without_skip_only
id: "body".into(),
name: "Body".into(),
description: String::new(),
kind: StyleKind::Text { style: shared },
kind: StyleKind::Text { style: shared, paragraph: Default::default() },
bound: Default::default(),
});
let id = linked_doc
@@ -237,7 +237,7 @@ fn export_writes_one_actual_node_value_and_complete_run_values_without_skip_only
let node = back.nodes().find(|n| n.name == "Label").unwrap();
assert!(!node.kind.text().unwrap().style.look().skip_ink);
let linked = back.style(node.styles.text.as_deref().unwrap()).unwrap();
let StyleKind::Text { style } = &linked.kind else { panic!("shared style link must remain text") };
let StyleKind::Text { style, .. } = &linked.kind else { panic!("shared style link must remain text") };
assert!(style.look().skip_ink, "local false must not alter the shared definition");
}
+1 -1
View File
@@ -85,7 +85,7 @@ fn seen(doc: &Document) -> Seen {
assert_eq!((text.style.font_family.as_str(), text.style.font_size), ("Inter", 20.0));
let linked = doc.style(label.styles.text.as_deref().unwrap()).unwrap();
assert_eq!(linked.name, "Shared");
let StyleKind::Text { style } = &linked.kind else { panic!("the linked definition must stay a text style") };
let StyleKind::Text { style, .. } = &linked.kind else { panic!("the linked definition must stay a text style") };
Seen {
case: text.style.case,
decoration: text.style.decoration,
+5 -7
View File
@@ -1531,7 +1531,7 @@ fn import_typography_styles(
id: format!("penpot:text:{base}:{id}"),
name: name.chars().take(256).collect(),
description: "Imported from Penpot typography library".into(),
kind: StyleKind::Text { style },
kind: StyleKind::Text { style, paragraph: Default::default() },
bound: BTreeMap::new(),
});
}
@@ -1596,7 +1596,7 @@ fn composite_token_style(token: &Value) -> Option<StyleKind> {
}
}
let parsed = text_style(&Value::Object(fields), &style);
Some(StyleKind::Text { style: parsed })
Some(StyleKind::Text { style: parsed, paragraph: Default::default() })
}
"boxShadow" | "shadow" => {
let parts: Vec<&Value> = if let Some(items) = value.as_array() { items.iter().take(32).collect() } else { vec![value] };
@@ -5927,7 +5927,7 @@ mod tests {
let (doc, warnings) = import(&archive_with_design_system(tokens, None, None, None)).unwrap();
assert_eq!(doc.styles.len(), 2);
assert!(doc.styles.iter().any(|s| s.name == "Penpot tokens/Core/title"
&& matches!(&s.kind, StyleKind::Text { style }
&& matches!(&s.kind, StyleKind::Text { style, .. }
if style.font_family == "Spicy Rice" && style.font_size == 24.0 && style.line_height == LineHeight::Pixels(32.0))));
assert!(doc.styles.iter().any(|s| s.name == "Penpot tokens/Core/elevation"
&& matches!(&s.kind, StyleKind::Effect { effects }
@@ -5983,10 +5983,8 @@ mod tests {
let (doc, warnings) = import(&archive_with_design_system(serde_json::json!({"sets":{}}), Some(typ), Some(color), None)).unwrap();
assert!(warnings.is_empty(), "{warnings:?}");
assert_eq!(doc.styles.len(), 2);
assert!(
doc.styles.iter().any(|s| s.name == "Brand/Heading"
&& matches!(&s.kind, StyleKind::Text { style } if style.font_family == "Inter" && style.font_size == 24.0))
);
assert!(doc.styles.iter().any(|s| s.name == "Brand/Heading"
&& matches!(&s.kind, StyleKind::Text { style, .. } if style.font_family == "Inter" && style.font_size == 24.0)));
assert!(doc.styles.iter().any(|s| s.name == "Brand/Primary" && matches!(s.kind, StyleKind::Paint { .. })));
assert_eq!(doc.document_colors.len(), 1);
}
+8 -2
View File
@@ -913,7 +913,11 @@ impl<'a> Ctx<'a> {
Some("FILL") => StyleKind::Paint { paints: props::paints(self, node, "fills").unwrap_or_default() },
Some("EFFECT") => StyleKind::Effect { effects: props::effects(self, node) },
Some("GRID") => StyleKind::Grid { grids: props::layout_grids(node) },
Some("TEXT") => StyleKind::Text { style: text::text_data(self, node).style.as_text_style() },
Some("TEXT") => {
// The sample layer carries the paragraph-level values too (#1829).
let td = text::text_data(self, node);
StyleKind::Text { style: td.style.as_text_style(), paragraph: Box::new(td.paragraph_style()) }
}
_ => continue,
};
let name = text(d, "name").filter(|s| !s.is_empty()).map_or_else(|| format!("Style {id}"), |s| clip_str(s).to_string());
@@ -955,7 +959,9 @@ impl<'a> Ctx<'a> {
"effect" | "effects" => (StyleKind::Effect { effects: n.effects.clone() }, &mut n.styles.effect),
"grid" | "grids" => (StyleKind::Grid { grids: n.grids.clone() }, &mut n.styles.grid),
"text" => match &n.kind {
NodeKind::Text(t) => (StyleKind::Text { style: t.style.as_text_style() }, &mut n.styles.text),
NodeKind::Text(t) => {
(StyleKind::Text { style: t.style.as_text_style(), paragraph: Box::new(t.paragraph_style()) }, &mut n.styles.text)
}
_ => continue,
},
_ => continue,
+1 -1
View File
@@ -530,7 +530,7 @@ fn shared_styles_link_with_metadata_names() {
let fill = doc.style("S:1").unwrap();
assert_eq!((fill.name.as_str(), fill.description.as_str()), ("Brand/Primary", "Main"));
assert!(matches!(&fill.kind, StyleKind::Paint { paints } if paints.len() == 1));
assert!(matches!(&doc.style("S:2").unwrap().kind, StyleKind::Text { style } if style.font_size == 32.0));
assert!(matches!(&doc.style("S:2").unwrap().kind, StyleKind::Text { style, .. } if style.font_size == 32.0));
assert!(matches!(&doc.style("Soft").unwrap().kind, StyleKind::Effect { effects } if effects.len() == 1));
let r = named(&doc, "R");
assert_eq!((r.styles.fill.as_deref(), r.styles.effect.as_deref()), (Some("S:1"), Some("S:4")));
+1 -1
View File
@@ -91,7 +91,7 @@ fn text_style_links() {
let sid = n.styles.text.clone().expect("text style linked");
let s = style(&doc, &sid);
assert_eq!(s.name, "Heading/H1");
let StyleKind::Text { style: ts } = &s.kind else { panic!("not a text style") };
let StyleKind::Text { style: ts, .. } = &s.kind else { panic!("not a text style") };
assert_eq!((ts.font_family.as_str(), ts.font_style.as_str(), ts.font_size), ("Helvetica", "Bold", 32.0));
assert!(n.fills.iter().any(|p| matches!(p.kind, PaintKind::Solid { color } if color.r == 1.0 && color.g == 0.0)));
}
+38 -3
View File
@@ -10,7 +10,9 @@
use egui::{Align2, Color32, CornerRadius, Id, Rect, Response, Sense, Stroke, Ui, pos2, vec2};
use figcraft_color::{Paint, PaintKind};
use figcraft_doc::{Document, GridKind, Node, NodeId, PublishedLibrary, Style, StyleKind, TextStyle, VariableType, trim_num};
use figcraft_doc::{
Document, GridKind, ListStyle, Node, NodeId, ParagraphStyle, PublishedLibrary, Style, StyleKind, TextStyle, VariableType, trim_num,
};
use figcraft_engine::library;
use serde_json::{Value, json};
@@ -354,7 +356,7 @@ fn by_folder<'a>(styles: impl Iterator<Item = &'a Style>) -> Vec<(&'a str, Vec<&
/// A text style's definition, if `s` is one.
fn text_def(s: &Style) -> Option<&TextStyle> {
match &s.kind {
StyleKind::Text { style } => Some(style),
StyleKind::Text { style, .. } => Some(style),
_ => None,
}
}
@@ -974,6 +976,39 @@ fn text_properties(ui: &mut Ui, doc: &Document, style: &Style, def: &TextStyle,
if let Some(sr) = style_resp {
font_style_menu(id, def, &sr, out);
}
if let StyleKind::Text { paragraph, .. } = &style.kind {
paragraph_rows(ui, paragraph);
}
}
/// What a text style sets at the paragraph level (#1829), when it sets anything: the spacing,
/// indent, list style and OpenType features (as `+tnum -liga`). A layer's Type settings edit
/// them; Update from selection takes them into the style.
fn paragraph_rows(ui: &mut Ui, ps: &ParagraphStyle) {
let mut rows: Vec<String> = Vec::new();
if ps.paragraph_spacing != 0.0 {
let label = tr("Paragraph spacing");
rows.push(format!("{label}: {}", trim_num(ps.paragraph_spacing)));
}
if ps.paragraph_indent != 0.0 {
let label = tr("Paragraph indent");
rows.push(format!("{label}: {}", trim_num(ps.paragraph_indent)));
}
match ps.list {
ListStyle::None => {}
ListStyle::Bulleted => rows.push(tr("Bulleted list").into()),
ListStyle::Numbered => rows.push(tr("Numbered list").into()),
}
if !ps.features.is_empty() {
rows.push(ps.features.iter().map(|(tag, on)| format!("{}{tag}", if *on { '+' } else { '-' })).collect::<Vec<_>>().join(" "));
}
if rows.is_empty() {
return;
}
ui.add_space(4.0);
for row in rows {
caption(ui, &row);
}
}
/// Editing one style: name, description, its values (paints open the colour picker; text styles
@@ -1038,7 +1073,7 @@ fn editor(ui: &mut Ui, doc: &Document, p: &mut Picker, out: &mut Frame) {
binding_cell(ui, doc, &style, &format!("layoutGrids/{i}/size"), figcraft_doc::VariableType::Number, out);
}
}
StyleKind::Text { style: def } => text_properties(ui, doc, &style, def, out),
StyleKind::Text { style: def, .. } => text_properties(ui, doc, &style, def, out),
}
});
ui.add_space(8.0);
@@ -29,7 +29,7 @@ fn names_split_into_folders() {
for s in [StyleSlot::Fill, StyleSlot::Stroke, StyleSlot::Effect, StyleSlot::Grid, StyleSlot::Text] {
assert_eq!(StyleSlot::parse(s.key()), Some(s));
}
assert!(StyleSlot::Text.holds(&StyleKind::Text { style: Default::default() }) && StyleSlot::Text.has_grid());
assert!(StyleSlot::Text.holds(&StyleKind::Text { style: Default::default(), paragraph: Default::default() }) && StyleSlot::Text.has_grid());
}
fn frame(ctx: &egui::Context, app: &mut FigApp, t: &mut f64) {
@@ -255,7 +255,7 @@ fn commit_text_field(def: &TextStyle, field: &str, typed: &str) -> Option<TextSt
id: "S".into(),
name: "S".into(),
description: String::new(),
kind: StyleKind::Text { style: def.clone() },
kind: StyleKind::Text { style: def.clone(), paragraph: Default::default() },
bound: Default::default(),
};
let mut out = ctx.run_ui(input, |ui| text_properties(ui, &doc, &style, def, &mut f));
@@ -401,7 +401,7 @@ fn text_style_editor_binds_variables_from_its_fields() {
let size_row = text_at(&texts, "Size").y;
let spacing = {
let doc = u.app.session.doc().unwrap();
let Some(StyleKind::Text { style: def }) = doc.style("Body").map(|s| &s.kind) else { panic!("a text style") };
let Some(StyleKind::Text { style: def, .. }) = doc.style("Body").map(|s| &s.kind) else { panic!("a text style") };
def.letter_spacing.label()
};
let in_editor: Vec<_> = texts.iter().filter(|(_, p)| p.x > left && p.x < left + W).cloned().collect();
@@ -434,7 +434,7 @@ fn text_style_editor_binds_variables_from_its_fields() {
assert_eq!(bound_to(&u, field).as_deref(), Some(name));
}
let doc = u.app.session.doc().unwrap();
let Some(StyleKind::Text { style: def }) = doc.style("Body").map(|s| &s.kind) else { panic!("a text style") };
let Some(StyleKind::Text { style: def, .. }) = doc.style("Body").map(|s| &s.kind) else { panic!("a text style") };
assert_eq!((def.font_size, def.font_style.as_str()), (18.0, "Bold"), "the style takes the variables' values");
assert!(u.app.ui.last_error.is_none(), "{:?}", u.app.ui.last_error);
}
+3 -2
View File
@@ -1,6 +1,6 @@
# Components, variants, styles and variables parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-01 · **Change:** minor (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-02 · **Change:** minor (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); 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
@@ -59,7 +59,7 @@ updates with overrides kept: `library_tests.rs`, `components_scale_tests.rs`.
| Feature | Status | Notes |
|---|---|---|
| Paint, text, effect, grid styles; style picker; text styles on character ranges | done | behaviour suite: `styles_behaviour_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 |
@@ -78,6 +78,7 @@ updates with overrides kept: `library_tests.rs`, `components_scale_tests.rs`.
| Date | Change | Summary |
|---|---|---|
| 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. |
| 2026-11-01 | patch | M3: Delete on a layer inside an instance hides it (an override) instead of being refused ("You can't delete a layer or object from an instance. If you try, Figma will only toggle the layer's visibility instead of removing it", Guide to auto layout); `edit.delete` reports those ids under `hidden`, the structure and the main component stay as they were, one undo step; deleting the instance itself still removes it. Found by a documentation audit of the auto layout page. Test: `components_behaviour_tests::deleting_a_layer_inside_an_instance_hides_it_instead` (the refusal row for it is gone from `what_instances_cannot_override_is_refused`). Numbers unchanged |
| 2026-10-31 | minor | M3, gap 10: library analytics cover styles and variables. A file reports how many of its layers use each of a library's styles and variables, and Show usage (`library.usage`) lists them beside the components, by a Type choice, with their layers and files. Layers inside instances count for their component. Insertions, detachments and modes aren't counted for them yet. Numbers unchanged. Tests: `library_assets_tests`. |
| 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 |
+9 -3
View File
@@ -312,10 +312,16 @@ Or in a project's `.mcp.json`:
`style.detach {which: "text", start?, end?}` unlinks them (values stay). A layer's `styles.text` is
the style all its chars share (none when they differ); `get_node` lists the linked chars as
`textStyleRanges` (`[start, end, style id]`).
A text style also carries paragraph spacing and indent, the list style and the OpenType features
(`paragraph` beside `style` in `style.list`): applying it sets them (on a range, the list goes to
the paragraphs touched; the rest is the layer's), `style.update` moves them too, and a `.fig`
file keeps them with the style.
Changing what a text style sets (font, size, line height, letter spacing, decoration, case, axis
values) on linked chars unlinks them; `style.update` moves every linked char. `style.create
{type: "text", name}` takes the selected chars while editing; a `value` may use the `node.set`
spellings (`{"fontSize": 32, "lineHeight": "120%", "letterSpacing": "2%"}`).
values; at the paragraph level the spacing, indent, list and features) on linked chars unlinks
them; `style.update` moves every linked char. `style.create {type: "text", name}` takes the
selected chars while editing; a `value` may use the `node.set` spellings (`{"fontSize": 32,
"lineHeight": "120%", "letterSpacing": "2%", "paragraphSpacing": 12, "listStyle": "bulleted",
"features": {"liga": false}}`).
- **Styles stay true to their layers**: editing a styled fill, stroke, effect or layout grid list
(`node.set`, `fill.set`, `fill.add`, a bound variable…) unlinks that style (`styles.fill` etc. go
`null`), so a later `style.update` doesn't overwrite the edit; `style.apply` replaces variables