From 17845674976fad2c31bb238d5c0bc6658d9c129f Mon Sep 17 00:00:00 2001 From: Christopher Linnett Date: Fri, 30 Oct 2026 16:24:57 +0800 Subject: [PATCH] M3: Penpot tokens never reference a calculation a set leaves out (#1280) A colour calculation that resolves to nothing in a mode is left out of that mode's token set, but an alias to it still went out as a {name} reference to the missing token. Such an alias, and any alias leading to one, now goes out as the colour it shows in the mode, or is left out with the calculation's warning when it shows nothing. A calculation left out in only one mode of its collection counts too: a reference to it from another collection would dangle whenever that mode's theme is active. --- crates/format/src/penpot/export.rs | 57 +++++++++++++++++++++++- crates/format/src/penpot/export_tests.rs | 28 +++++++++++- 2 files changed, 81 insertions(+), 4 deletions(-) diff --git a/crates/format/src/penpot/export.rs b/crates/format/src/penpot/export.rs index 5f9adc10..4e143212 100644 --- a/crates/format/src/penpot/export.rs +++ b/crates/format/src/penpot/export.rs @@ -8,7 +8,7 @@ use figcraft_doc::proto::{Action, Direction, Easing, Interaction, OverlayPositio use figcraft_doc::{ BoolOp, CellAlign, ContentAlign, CrossAlign, Document, EffectKind, GridAlign, GridKind, GridTrack, HConstraint, LayoutMode, LetterSpacing, LineHeight, MainAlign, Node, NodeId, NodeKind, Sizing, StrokeAlign, StrokeCap, TextAlignH, TextAlignV, TextAutoResize, TextCase, TextDecoration, - TextStyle, VConstraint, VariableType, VariableValue, + TextStyle, VConstraint, Variable, VariableType, VariableValue, Variables, }; use serde_json::{Map, Value, json}; @@ -1701,6 +1701,11 @@ impl<'a> Exporter<'a> { (!out.is_empty()).then_some(Value::Object(out)) } + /// The archive's `tokens.json`: the file's own Penpot tokens when it came from Penpot and its + /// variables are unchanged since, else one token set per collection (per mode, with a theme + /// for each, when it has several). Aliases go out as `{name}` references and colour + /// calculations as the colour they show; a token that resolves to nothing is left out with a + /// warning, so no reference may name it. fn tokens_json(&mut self) -> Option { let first = self.doc.pages.first().and_then(|p| self.node(*p)); let source = first.and_then(|p| p.plugin_data.iter().find(|(k, _)| k.starts_with("penpot.tokens.")).map(|(_, v)| v.clone())); @@ -1715,6 +1720,7 @@ impl<'a> Exporter<'a> { if variables.variables.is_empty() { return None; } + let unreferenceable = unreferenceable(variables); let mut root = Map::new(); let mut themes = Vec::new(); let mut order = Vec::new(); @@ -1726,13 +1732,26 @@ impl<'a> Exporter<'a> { let set_name = if many { format!("{}/{}", collection.name, mode.name) } else { collection.name.clone() }; let mut tokens = Map::new(); for var in variables.variables.iter().filter(|v| v.collection == collection.id) { - let Some(value) = var.values.get(&mode.id).or_else(|| var.values.values().next()) else { continue }; + let Some(value) = mode_value(var, &mode.id) else { continue }; // Penpot tokens have no shape for colour calculations: the colour they resolve // to in this mode goes out as a plain colour token. let Some(value) = variables.flattened(var, &mode.id, value) else { self.warn(crate::CALCULATIONS_LEFT_OUT); continue; }; + // A reference to a token some set leaves out would dangle whenever that set is + // active: such an alias goes out as the colour it shows here instead. + let value = match value { + VariableValue::Alias(target) if unreferenceable.contains(target.as_str()) => { + let here = BTreeMap::from([(collection.id.clone(), mode.id.clone())]); + let Some(shown) = variables.resolve(&var.id, &here) else { + self.warn(crate::CALCULATIONS_LEFT_OUT); + continue; + }; + shown + } + other => other, + }; let (kind, value) = match &value { VariableValue::Alias(target) => { let Some(target_name) = self.token_name(target) else { continue }; @@ -1786,6 +1805,40 @@ impl<'a> Exporter<'a> { } } +/// The value a variable's token takes in `mode` (its first value when it has none for that mode). +fn mode_value<'v>(var: &'v Variable, mode: &str) -> Option<&'v VariableValue> { + var.values.get(mode).or_else(|| var.values.values().next()) +} + +/// Variables a token reference must not name: colour calculations some token set leaves out (they +/// resolve to nothing in a mode of their collection) and every alias that leads to one. +fn unreferenceable(variables: &Variables) -> BTreeSet<&str> { + let mut out = BTreeSet::new(); + for var in &variables.variables { + let Some(coll) = variables.collection_by_id(&var.collection) else { continue }; + if coll.modes.iter().any(|m| mode_value(var, &m.id).is_some_and(|v| variables.flattened(var, &m.id, v).is_none())) { + out.insert(var.id.as_str()); + } + } + let mut readers: BTreeMap<&str, Vec<&str>> = BTreeMap::new(); + for var in &variables.variables { + for value in var.values.values() { + if let VariableValue::Alias(target) = value { + readers.entry(target.as_str()).or_default().push(var.id.as_str()); + } + } + } + let mut stack: Vec<&str> = out.iter().copied().collect(); + while let Some(id) = stack.pop() { + for reader in readers.get(id).into_iter().flatten() { + if out.insert(reader) { + stack.push(reader); + } + } + } + out +} + /// A layer's visibility, lock, opacity, blend, proportion lock and constraints as Penpot fields. fn appearance_fields(node: &Node, fields: &mut Map) { if !node.visible { diff --git a/crates/format/src/penpot/export_tests.rs b/crates/format/src/penpot/export_tests.rs index 576574e0..4dacd4ea 100644 --- a/crates/format/src/penpot/export_tests.rs +++ b/crates/format/src/penpot/export_tests.rs @@ -588,7 +588,8 @@ fn native_variables_become_token_sets_themes_and_applied_tokens() { } /// Penpot tokens have no shape for a colour calculation: each mode's token is the colour it shows, -/// and one that resolves to nothing is left out with a warning. +/// and one that resolves to nothing is left out with a warning. No reference names a token a set +/// leaves out: an alias to one goes out as the colour it shows, or is left out with it. #[test] fn colour_calculations_export_as_the_colour_they_show() { let mut doc = Document::new("Calculated"); @@ -614,11 +615,34 @@ fn colour_calculations_export_as_the_colour_they_show() { &[("light", VariableValue::ColorAlpha { variable: "v1".into(), opacity: 0.5 }), ("dark", mix)], )); let gone = VariableValue::ColorAlpha { variable: "v-gone".into(), opacity: 0.5 }; - doc.variables.variables.push(variable("v3", "brand/lost", "c1", VariableType::Color, &[("light", gone.clone()), ("dark", gone)])); + doc.variables.variables.push(variable("v3", "brand/lost", "c1", VariableType::Color, &[("light", gone.clone()), ("dark", gone.clone())])); + // Aliases: to the lost one (same collection), to one lost only in Dark (from a collection with + // one mode, which reads Color in its default mode, Light), to an alias that is left out, and to + // one that always resolves. + let alias = |to: &str| VariableValue::Alias(to.into()); + doc.variables.variables.push(variable("v4", "brand/lost-too", "c1", VariableType::Color, &[("light", alias("v3")), ("dark", alias("v3"))])); + let half = VariableValue::ColorAlpha { variable: "v1".into(), opacity: 0.5 }; + doc.variables.variables.push(variable("v5", "brand/half", "c1", VariableType::Color, &[("light", half), ("dark", gone)])); + doc.variables.collections.push(Collection { + id: "c2".into(), + name: "Theme".into(), + modes: vec![Mode { id: "one".into(), name: "One".into() }], + extends: None, + }); + doc.variables.variables.push(variable("v6", "theme/accent", "c2", VariableType::Color, &[("one", alias("v5"))])); + doc.variables.variables.push(variable("v7", "theme/veil", "c2", VariableType::Color, &[("one", alias("v2"))])); + doc.variables.variables.push(variable("v8", "theme/deep", "c2", VariableType::Color, &[("one", alias("v4"))])); let (bytes, warnings) = export(&doc).unwrap(); assert!(warnings.iter().any(|w| w == crate::CALCULATIONS_LEFT_OUT), "{warnings:?}"); let tokens = tokens(&bytes); assert!(["Color/Light", "Color/Dark"].iter().all(|set| tokens[set]["brand"].get("lost").is_none()), "left out, not written"); + assert!(["Color/Light", "Color/Dark"].iter().all(|set| tokens[set]["brand"].get("lost-too").is_none()), "an alias to it too"); + assert!(tokens["Color/Dark"]["brand"].get("half").is_none() && tokens["Color/Light"]["brand"]["half"]["$value"] == "#ff000080"); + assert_eq!(tokens["Theme"]["theme"]["accent"]["$value"], "#ff000080", "the colour it shows, not {{brand.half}}"); + assert_eq!(tokens["Theme"]["theme"]["veil"]["$value"], "{brand.veil}", "a reference that always resolves stays one"); + assert!(tokens["Theme"]["theme"].get("deep").is_none(), "an alias to an alias that is left out is left out too"); + let text = tokens.to_string(); + assert!(!text.contains("{brand.lost") && !text.contains("{brand.half}"), "no reference to a token a set leaves out: {text}"); assert_eq!(tokens["Color/Light"]["brand"]["veil"]["$value"], "#ff000080"); assert_eq!(tokens["Color/Dark"]["brand"]["veil"]["$value"], "#008080c0"); assert_eq!(tokens["Color/Dark"]["brand"]["veil"]["$type"], "color");