Merge pull request #1008 from lucasmartins-ai/feat/css-export-px

variable.export css: number lengths in px
This commit is contained in:
BFlat
2026-10-24 14:16:29 +07:00
committed by GitHub
5 changed files with 150 additions and 7 deletions
+9 -4
View File
@@ -18,7 +18,7 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"variable.export",
"Export variables",
"{collections? [ids or names; default every collection of this file, not library copies], format? (json (default): the local variables JSON that variable.import reads | css: custom properties, the default mode on :root and each other mode on [data-<collection>=\"<mode>\"]), path? (write it there)} → {text}, or {path} when written",
"{collections? [ids or names; default every collection of this file, not library copies], format? (json (default): the local variables JSON that variable.import reads | css: custom properties, the default mode on :root and each other mode on [data-<collection>=\"<mode>\"]; numbers in px unless layers or scopes use them as opacity or font weight), path? (write it there)} → {text}, or {path} when written",
false,
export
),
@@ -46,7 +46,7 @@ fn export(s: &mut Session, p: &Value) -> Result<Value> {
let text = match string(p, "format").as_deref().unwrap_or("json") {
"json" => serde_json::to_string_pretty(&figcraft_format::rest::export_variables(&doc.variables, &colls))
.map_err(|e| EngineError::Other(e.to_string()))?,
"css" => css(&doc.variables, &colls),
"css" => css(doc, &colls),
_ => return Err(bad(cmd, "format: json|css")),
};
match string(p, "path") {
@@ -66,7 +66,11 @@ fn css_safe(text: &str) -> String {
/// Each collection's variables as custom properties (named as generated code names them): the
/// default mode's values on `:root`, every other mode's on `[data-<collection>="<mode>"]`;
/// aliases are `var(--name)`.
fn css(vars: &Variables, colls: &[&Collection]) -> String {
fn css(doc: &Document, colls: &[&Collection]) -> String {
let vars = &doc.variables;
// Lengths need a unit where CSS reads them (`border-radius: var(--radius)`); opacity and
// font weight don't.
let roles = crate::token_roles::Roles::new(doc);
let mut out = String::new();
for c in colls {
let attr = css_var_name(&c.name);
@@ -76,7 +80,8 @@ fn css(vars: &Variables, colls: &[&Collection]) -> String {
for v in vars.variables.iter().filter(|v| v.collection == c.id) {
let value = match v.values.get(&m.id) {
Some(VariableValue::Color(col)) => col.to_css(),
Some(VariableValue::Number(x)) => trim_num(*x),
Some(VariableValue::Number(x)) if roles.role(v) == crate::token_roles::NumberRole::Unitless => trim_num(*x),
Some(VariableValue::Number(x)) => format!("{}px", trim_num(*x)),
Some(VariableValue::String(t)) => format!("\"{}\"", css_safe(t)),
Some(VariableValue::Boolean(b)) => b.to_string(),
Some(VariableValue::Alias(a)) => match vars.get(a) {
+1
View File
@@ -62,6 +62,7 @@ pub mod synthetic_ds;
pub mod templates;
pub mod textedit;
pub mod textpreview;
pub mod token_roles;
pub mod tools;
pub mod vectoredit;
pub mod widgets;
+99
View File
@@ -0,0 +1,99 @@
//! 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
//! 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.
use std::collections::HashMap;
use figcraft_doc::{Document, Variable, VariableId, VariableType, VariableValue};
use crate::codegen::num;
/// What a number variable measures.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum NumberRole {
Radius,
FontSize,
Spacing,
/// Another length (stroke, line height, letter spacing, effects): pixels.
Length,
/// Opacity or font weight: no unit.
Unitless,
}
fn role_of_field(field: &str) -> Option<NumberRole> {
Some(match field {
"cornerRadius" | "topLeftRadius" | "topRightRadius" | "bottomRightRadius" | "bottomLeftRadius" => NumberRole::Radius,
"fontSize" => NumberRole::FontSize,
"opacity" | "fontWeight" => NumberRole::Unitless,
f if f == "gap" || f == "counterAxisSpacing" || f.starts_with("padding") || f == "width" || f == "height" => NumberRole::Spacing,
f if f.starts_with("min") || f.starts_with("max") => NumberRole::Spacing,
"strokeWeight" | "lineHeight" | "letterSpacing" | "paragraphSpacing" | "paragraphIndent" => NumberRole::Length,
_ => return None,
})
}
fn role_of_scope(scope: &str) -> Option<NumberRole> {
Some(match scope {
"CORNER_RADIUS" => NumberRole::Radius,
"FONT_SIZE" => NumberRole::FontSize,
"GAP" | "WIDTH_HEIGHT" => NumberRole::Spacing,
"OPACITY" | "FONT_WEIGHT" => NumberRole::Unitless,
"STROKE_FLOAT" | "EFFECT_FLOAT" | "LINE_HEIGHT" | "LETTER_SPACING" | "PARAGRAPH_SPACING" | "PARAGRAPH_INDENT" => NumberRole::Length,
_ => return None,
})
}
/// The roles of a file's number variables, computed once per export.
#[derive(Default)]
pub struct Roles(HashMap<VariableId, NumberRole>);
impl Roles {
pub fn new(doc: &Document) -> Self {
let vars = &doc.variables.variables;
let mut roles: HashMap<VariableId, NumberRole> = HashMap::new();
for n in doc.nodes() {
for (field, id) in &n.bound {
if let Some(r) = role_of_field(field) {
roles.entry(id.clone()).or_insert(r);
}
}
}
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);
}
}
// Until a pass adds nothing: each pass adds a role or stops, so this ends within one pass
// per variable, however long a chain is.
loop {
let mut changed = false;
for v in vars {
let Some(r) = roles.get(&v.id).copied() else { continue };
for value in v.values.values() {
if let VariableValue::Alias(target) = value
&& !roles.contains_key(target)
{
roles.insert(target.clone(), r);
changed = true;
}
}
}
if !changed {
break;
}
}
Roles(roles)
}
/// What `v` measures; a number nothing tells about is a length.
pub fn role(&self, v: &Variable) -> NumberRole {
self.0.get(&v.id).copied().unwrap_or(NumberRole::Length)
}
/// A number variable's CSS value: pixels for lengths, bare for opacity and font weight.
pub fn number_css(&self, v: &Variable, x: f64) -> String {
if self.role(v) == NumberRole::Unitless { num(x) } else { format!("{}px", num(x)) }
}
}
+38 -2
View File
@@ -214,9 +214,9 @@ fn variables_export_and_import_round_trip() {
let css = ex(&mut s, "variable.export", json!({"format": "css", "collections": ["Theme"]}))["text"].as_str().unwrap().to_string();
for want in [
":root {\n",
" --radius: 4;\n",
" --radius: 4px;\n",
"[data-theme=\"Dark\"] {\n",
" --radius: 6.5;\n",
" --radius: 6.5px;\n",
" --accent: var(--color-primary);\n",
" --label: \"Say hi \";\n",
] {
@@ -397,3 +397,39 @@ fn paints_replaced_through_node_set_keep_unchanged_stop_bindings() {
assert_eq!(stops(&s, r), ["FFFFFF", "0000FF"]);
healthy(&s);
}
#[test]
fn css_export_gives_lengths_a_unit_and_keeps_opacity_bare() {
let mut s = Session::new();
ex(&mut s, "variable.createCollection", json!({"name": "Brand"}));
ex(&mut s, "variable.create", json!({"collection": "Brand", "name": "card", "type": "number", "value": 12}));
ex(&mut s, "variable.create", json!({"collection": "Brand", "name": "fade", "type": "number", "value": 0.5}));
ex(&mut s, "variable.create", json!({"collection": "Brand", "name": "half", "type": "number", "value": 0.5}));
ex(&mut s, "variable.create", json!({"collection": "Brand", "name": "dim", "type": "number", "value": 1}));
ex(&mut s, "variable.set", json!({"variable": "dim", "value": {"alias": "half"}}));
let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 100, "height": 100})));
let g = idv(&ex(&mut s, "frame.create", json!({"x": 200, "y": 0, "width": 100, "height": 100})));
ex(&mut s, "node.set", json!({"id": f, "props": {"cornerRadius": 12, "opacity": 0.5}}));
ex(&mut s, "variable.bind", json!({"ids": [f], "field": "cornerRadius", "variable": "card"}));
ex(&mut s, "variable.bind", json!({"ids": [f], "field": "opacity", "variable": "fade"}));
ex(&mut s, "variable.bind", json!({"ids": [g], "field": "opacity", "variable": "dim"}));
let css = ex(&mut s, "variable.export", json!({"format": "css"}))["text"].as_str().unwrap_or_default().to_string();
// A radius is a length; opacity stays a number, also through an alias it reads.
assert!(css.contains("--card: 12px;") && css.contains("--fade: 0.5;") && css.contains("--half: 0.5;"), "{css}");
}
#[test]
fn review_a_long_alias_chain_keeps_its_role_to_the_end() {
let mut s = Session::new();
ex(&mut s, "variable.createCollection", json!({"name": "Brand"}));
ex(&mut s, "variable.create", json!({"collection": "Brand", "name": "o0", "type": "number", "value": 0.5}));
// o12 -> o11 -> … -> o0: longer than any fixed number of rounds would reach.
for i in 1..=12 {
ex(&mut s, "variable.create", json!({"collection": "Brand", "name": format!("o{i}"), "type": "number", "value": 1}));
ex(&mut s, "variable.set", json!({"variable": format!("o{i}"), "value": {"alias": format!("o{}", i - 1)}}));
}
let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 100, "height": 100})));
ex(&mut s, "variable.bind", json!({"ids": [f], "field": "opacity", "variable": "o12"}));
let css = ex(&mut s, "variable.export", json!({"format": "css"}))["text"].as_str().unwrap_or_default().to_string();
assert!(css.contains("--o0: 0.5;"), "{css}");
}
+3 -1
View File
@@ -965,7 +965,9 @@ frame back to Auto, inheriting its parent's mode).
The same JSON carries variables between FigCraft files: `variable.export {collections?, format?,
path?}` writes this file's collections (default: all but library copies) as a local variables
response (`format: "json"`, the default) or as CSS custom properties (`format: "css"`: the default
mode on `:root`, each other mode on `[data-<collection>="<mode>"]`, aliases as `var(--name)`), and
mode on `:root`, each other mode on `[data-<collection>="<mode>"]`, aliases as `var(--name)`, numbers
in px unless the layers bound to them, their scopes or the variables aliasing them make them an
opacity or a font weight, so `border-radius: var(--radius)` reads `12px`), and
returns `{text}` (or writes `path` and returns `{path}`). `variable.import {variables}` (an object,
JSON text or a path; `variableCollections` and `variables` under `meta` or at the top level) reads
one back: a collection joins this file's collection of the same name