From 2118480e7af3c50334bf0b4c599ecb6a3ee010bd Mon Sep 17 00:00:00 2001 From: vetlucasmartins Date: Wed, 21 Oct 2026 20:03:46 +0100 Subject: [PATCH 1/5] Dev Mode: React + Tailwind page code and Tailwind theme export MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - inspect.code {language: "tailwind"}: a layer tree as a React component styled with Tailwind CSS v4 utilities, no stylesheet. Auto layout maps to flex utilities on the spacing scale; fixed-width auto layout frames, text and wide layers become fluid (w-full max-w-[…]); instances are inlined with their overrides, except those connected to code (codeConnect), which are their snippet with one import each; bound variables are theme utilities (bg-accent, rounded-card). - mobile: (same layer names): mobile-first code, desktop classes that differ behind lg:, resets for desktop defaults, padding compared per side; layers only on desktop are hidden below lg, those only on mobile are listed in the header. - variable.export {format: "tailwind"}: @import "tailwindcss" and an @theme block (--color-*, --radius-*, --text-*, --spacing-*, --font-*), names unique per file and shared with page code. Builds on the token roles of the CSS export px fix (#1008). --- crates/engine/src/cmd/variables_io.rs | 24 +- crates/engine/src/codegen.rs | 87 ++++- crates/engine/src/lib.rs | 3 + crates/engine/src/tailwind.rs | 490 ++++++++++++++++++++++++++ crates/engine/src/tailwind_tests.rs | 335 ++++++++++++++++++ docs/mcp.md | 16 + 6 files changed, 934 insertions(+), 21 deletions(-) create mode 100644 crates/engine/src/tailwind.rs create mode 100644 crates/engine/src/tailwind_tests.rs diff --git a/crates/engine/src/cmd/variables_io.rs b/crates/engine/src/cmd/variables_io.rs index 0f55ea2c..c74c46fe 100644 --- a/crates/engine/src/cmd/variables_io.rs +++ b/crates/engine/src/cmd/variables_io.rs @@ -18,7 +18,7 @@ pub fn specs() -> Vec { 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-=\"\"]; numbers in px unless layers or scopes use them as opacity or font weight), 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-=\"\"]; numbers in px unless layers or scopes use them as opacity or font weight | tailwind: a Tailwind CSS v4 stylesheet, the default mode as an @theme block named the way inspect.code's tailwind utilities read them: --color-*, --radius-*, --text-*, --spacing-*, --font-*, unique per file), path? (write it there)} → {text}, or {path} when written", false, export ), @@ -46,8 +46,9 @@ fn export(s: &mut Session, p: &Value) -> Result { 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, &colls), - _ => return Err(bad(cmd, "format: json|css")), + "css" => css(doc, &colls, false), + "tailwind" => css(doc, &colls, true), + _ => return Err(bad(cmd, "format: json|css|tailwind")), }; match string(p, "path") { Some(path) => { @@ -66,16 +67,23 @@ 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-=""]`; /// aliases are `var(--name)`. -fn css(doc: &Document, colls: &[&Collection]) -> String { +fn css(doc: &Document, colls: &[&Collection], tailwind: bool) -> String { let vars = &doc.variables; + // Tailwind theme names, unique per file and the same ones page code reads. + let theme = crate::tailwind::Theme::new(doc); + let name = |v: &Variable| if tailwind { theme.name(v) } else { css_var_of(v) }; // 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(); + let mut out = if tailwind { "@import \"tailwindcss\";\n\n".to_string() } else { String::new() }; for c in colls { let attr = css_var_name(&c.name); for (i, m) in c.modes.iter().enumerate() { - let sel = if i == 0 { ":root".to_string() } else { format!("[data-{}=\"{}\"]", attr.trim_start_matches('-'), css_safe(&m.name)) }; + let sel = match i { + 0 if tailwind => "@theme".to_string(), + 0 => ":root".to_string(), + _ => format!("[data-{}=\"{}\"]", attr.trim_start_matches('-'), css_safe(&m.name)), + }; out.push_str(&format!("/* {} · {} */\n{sel} {{\n", css_safe(&c.name), css_safe(&m.name))); for v in vars.variables.iter().filter(|v| v.collection == c.id) { let value = match v.values.get(&m.id) { @@ -85,12 +93,12 @@ fn css(doc: &Document, colls: &[&Collection]) -> String { Some(VariableValue::String(t)) => format!("\"{}\"", css_safe(t)), Some(VariableValue::Boolean(b)) => b.to_string(), Some(VariableValue::Alias(a)) => match vars.get(a) { - Some(t) => format!("var({})", css_var_of(t)), + Some(t) => format!("var({})", name(t)), None => continue, }, None => continue, }; - out.push_str(&format!(" {}: {value};\n", css_var_of(v))); + out.push_str(&format!(" {}: {value};\n", name(v))); } out.push_str("}\n"); } diff --git a/crates/engine/src/codegen.rs b/crates/engine/src/codegen.rs index 0871f886..8997587c 100644 --- a/crates/engine/src/codegen.rs +++ b/crates/engine/src/codegen.rs @@ -18,7 +18,7 @@ pub fn specs() -> Vec { vec![spec!( "inspect.code", "Code for layer", - "{id?, language?: css|html|react|leptos|swiftui|compose (default css; html, react and leptos are the whole layer tree; components take their properties as parameters, instances pass their values; a component connected to code (codeConnect.set) gives its import and snippet instead), unit?: px|rem (CSS, HTML, React and Leptos; default px), rootFontSize? (rem: pixels per rem, default 16)} → {code, language, connected}", + "{id?, language?: css|html|react|leptos|tailwind|swiftui|compose (default css; html, react and leptos are the whole layer tree; components take their properties as parameters, instances pass their values; a component connected to code (codeConnect.set) gives its import and snippet instead; tailwind is React with Tailwind CSS v4 utilities for pages: fluid auto layout boxes, instances inlined with their overrides, bound variables as theme utilities), mobile? (tailwind: a mobile frame with the same layer names; the code is mobile first with the desktop layer's classes behind lg:), unit?: px|rem (CSS, HTML, React and Leptos; default px), rootFontSize? (rem: pixels per rem, default 16)} → {code, language, connected}", false, |s, p| { let cmd = "inspect.code"; @@ -40,7 +40,14 @@ pub fn specs() -> Vec { return Err(bad(cmd, "no such layer")); } let connected = crate::code_connect::link(doc, id, lang).is_some(); - let code = code_for(doc, id, lang); + let mobile = match get(p, "mobile") { + None | Some(serde_json::Value::Null) => None, + Some(v) => Some(id_of(v).filter(|m| doc.contains(*m)).ok_or_else(|| bad(cmd, "mobile: the id of a mobile frame"))?), + }; + let code = match (lang, mobile) { + (CodeLang::Tailwind, Some(m)) => crate::tailwind::code(doc, id, Some(m)), + _ => code_for(doc, id, lang), + }; let code = match rem { Some(root) if lang.is_web() => px_to_rem(&code, root), _ => code, @@ -61,18 +68,22 @@ pub enum CodeLang { React, /// A Leptos component (`view!` markup) with its stylesheet. Leptos, + /// A React function component styled with Tailwind CSS v4 utilities (no stylesheet). + Tailwind, SwiftUi, Compose, } impl CodeLang { - pub const ALL: [CodeLang; 6] = [CodeLang::Css, CodeLang::Html, CodeLang::React, CodeLang::Leptos, CodeLang::SwiftUi, CodeLang::Compose]; + pub const ALL: [CodeLang; 7] = + [CodeLang::Css, CodeLang::Html, CodeLang::React, CodeLang::Leptos, CodeLang::Tailwind, CodeLang::SwiftUi, CodeLang::Compose]; pub fn id(self) -> &'static str { match self { CodeLang::Css => "css", CodeLang::Html => "html", CodeLang::React => "react", CodeLang::Leptos => "leptos", + CodeLang::Tailwind => "tailwind", CodeLang::SwiftUi => "swiftui", CodeLang::Compose => "compose", } @@ -83,13 +94,14 @@ impl CodeLang { CodeLang::Html => "HTML + CSS", CodeLang::React => "React", CodeLang::Leptos => "Leptos", + CodeLang::Tailwind => "React + Tailwind", CodeLang::SwiftUi => "SwiftUI", CodeLang::Compose => "Compose", } } /// Languages whose lengths are CSS pixels (they can be shown in rem). pub fn is_web(self) -> bool { - matches!(self, CodeLang::Css | CodeLang::Html | CodeLang::React | CodeLang::Leptos) + matches!(self, CodeLang::Css | CodeLang::Html | CodeLang::React | CodeLang::Leptos | CodeLang::Tailwind) } pub fn from_id(s: &str) -> Option { if s.eq_ignore_ascii_case("tsx") || s.eq_ignore_ascii_case("jsx") { @@ -194,6 +206,7 @@ pub fn code_for(doc: &Document, id: NodeId, lang: CodeLang) -> String { match lang { CodeLang::Css => css(doc, id), CodeLang::Html => html(doc, id), + CodeLang::Tailwind => crate::tailwind::code(doc, id, None), CodeLang::React | CodeLang::Leptos | CodeLang::SwiftUi | CodeLang::Compose => crate::component_codegen::code(doc, id, lang), } } @@ -316,25 +329,32 @@ pub(crate) fn indent(depth: usize) -> String { /// The CSS custom properties generated code uses: name → fallback value, in first-use order. #[derive(Default)] -pub(crate) struct Tokens(pub Vec<(String, String)>); +pub(crate) struct Tokens { + vars: Vec<(String, String)>, + /// Name variables as Tailwind theme variables (`--color-accent`), not by their CSS name. + pub tailwind: bool, + /// The file's theme names (computed on first use). + theme: Option, +} impl Tokens { /// `var(--name, value)`, remembering the variable. - fn var(&mut self, variable: &Variable, value: String) -> String { - let name = css_var_of(variable); + fn var(&mut self, doc: &Document, variable: &Variable, value: String) -> String { + let name = + if self.tailwind { self.theme.get_or_insert_with(|| crate::tailwind::Theme::new(doc)).name(variable) } else { css_var_of(variable) }; let out = format!("var({name}, {value})"); - if !self.0.iter().any(|(n, _)| *n == name) { - self.0.push((name, value)); + if !self.vars.iter().any(|(n, _)| *n == name) { + self.vars.push((name, value)); } out } /// A `:root` rule declaring the variables (empty without any). pub fn root_rule(&self) -> String { - if self.0.is_empty() { + if self.vars.is_empty() { return String::new(); } - let decls: String = self.0.iter().map(|(n, v)| format!(" {n}: {v};\n")).collect(); + let decls: String = self.vars.iter().map(|(n, v)| format!(" {n}: {v};\n")).collect(); format!(":root {{\n{decls}}}\n") } } @@ -365,7 +385,7 @@ pub fn css_var_of(v: &Variable) -> String { /// `value`, or a reference to the variable bound to the first of `fields` that has one. fn tok(doc: &Document, n: &Node, fields: &[&str], value: String, t: &mut Tokens) -> String { match fields.iter().find_map(|f| crate::devinspect::bound_variable(doc, n, f)) { - Some(v) => t.var(v, value), + Some(v) => t.var(doc, v, value), None => value, } } @@ -659,6 +679,14 @@ pub(crate) struct Markup<'c, 'a> { /// Each layer's path in the tree (names with sibling counts), class and declarations, so /// variants can be compared layer by layer. pub rules: Vec<(String, String, String)>, + /// Instances as their own (overridden) layers instead of component calls, except those + /// connected to code (page code). + pub inline: bool, + /// Auto layout frames and text as fluid boxes (`width: 100%` up to their width) for pages. + pub fluid: bool, + /// With `fluid`, other layers at least this share of the root's width are fluid too (a + /// divider, a banner image); smaller ones (icons) keep their size. Default: none. + pub fluid_leaves: f64, } impl<'c, 'a> Markup<'c, 'a> { @@ -673,6 +701,9 @@ impl<'c, 'a> Markup<'c, 'a> { root_class: None, root_attrs: String::new(), rules: Vec::new(), + inline: false, + fluid: false, + fluid_leaves: f64::INFINITY, } } @@ -754,7 +785,10 @@ impl<'c, 'a> Markup<'c, 'a> { } } // Nested instances are calls (component code), in a wrapper that places them when needed. - if self.code() && !root && matches!(n.kind, NodeKind::Instance { .. }) { + // Page code inlines instances, except those connected to code (codeConnect.set): their + // snippet, e.g. a design-system `"}), + ); + let p = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 300, "width": 1440, "height": 400, "name": "Landing"}))); + ex(&mut s, "layout.add", json!({"ids": [p], "direction": "vertical"})); + for text in ["Agendar diagnóstico", "Ver exemplos"] { + let i = idv(&ex(&mut s, "instance.create", json!({"component": comp, "x": 0, "y": 0, "parent": p}))); + ex(&mut s, "instance.setProperties", json!({"id": i, "properties": {"Label": text}})); + } + let out = code(&mut s, p, json!({})); + assert_eq!(out.matches("import { Button } from \"@/components/ui/button\";").count(), 1, "{out}"); + assert!(out.contains("`, with each import once at the top. A design + system mapped to shadcn/ui this way exports pages that use the project's own components. Bound variables become theme + utilities (`bg-accent`, `rounded-card`, `text-muted`) that `variable.export {format: "tailwind"}` + declares: `@import "tailwindcss";` and an `@theme` block named by namespace (`--color-*`, + `--radius-*`, `--text-*`, `--spacing-*`, `--font-*`; a number's namespace and unit come from what + layers bind it to), other modes on `[data-=""]`. With `mobile: ` (the + same layer names, mobile layout) the code is mobile first: the mobile frame's classes, the + desktop frame's that differ behind `lg:`, and layers only on desktop `hidden` plus `lg:flex` + (auto layout frames) or `lg:block` (other layers). - **Component code:** `inspect.code {id, language: react|leptos|swiftui|compose}` on a component, a component set or a variant writes the component as a reusable unit: React (TSX) with a props interface, Leptos with a `#[component]` function (`#[prop]` defaults, `view!` markup), SwiftUI From 9f2470b5bd132ac0fb889b21fbf8cdb4726b5f2e Mon Sep 17 00:00:00 2001 From: vetlucasmartins Date: Wed, 21 Oct 2026 21:00:02 +0100 Subject: [PATCH 2/5] Tailwind theme names: resolve collisions in linear time Clash detection compared every variable with every other, and suffixes rescanned the taken list, so a file with very many variables made the export quadratic. Counts in a HashMap, taken names in a HashSet and a next suffix per base keep it linear. --- crates/engine/src/tailwind.rs | 32 ++++++++++++++++++++++++-------- 1 file changed, 24 insertions(+), 8 deletions(-) diff --git a/crates/engine/src/tailwind.rs b/crates/engine/src/tailwind.rs index 7f54c8aa..2a6453e4 100644 --- a/crates/engine/src/tailwind.rs +++ b/crates/engine/src/tailwind.rs @@ -32,14 +32,30 @@ impl Theme { let mut t = Theme { roles, names: HashMap::new() }; // Short names ("accent") where they are unique, the full path ("palette-accent") where two // variables would share one, and a numeric suffix as a last resort. - let short: Vec<(VariableId, String)> = vars.iter().map(|v| (v.id.clone(), t.candidate(v, false))).collect(); - let mut taken: Vec = Vec::new(); - for (v, (id, s)) in vars.iter().zip(&short) { - let clash = short.iter().any(|(o, n)| o != id && n == s); - let base = if clash { t.candidate(v, true) } else { s.clone() }; - let name = (1..).map(|k| if k == 1 { base.clone() } else { format!("{base}-{k}") }).find(|n| !taken.contains(n)).unwrap_or(base); - taken.push(name.clone()); - t.names.insert(id.clone(), name); + let short: Vec = vars.iter().map(|v| t.candidate(v, false)).collect(); + // Linear in the number of variables: counts for clashes, a set of names taken and the next + // suffix per base, so a file with very many variables can't make this quadratic. + let mut count: HashMap<&str, usize> = HashMap::new(); + for n in &short { + *count.entry(n.as_str()).or_default() += 1; + } + let mut taken: std::collections::HashSet = std::collections::HashSet::new(); + let mut next: HashMap = HashMap::new(); + for (v, s) in vars.iter().zip(&short) { + let base = if count.get(s.as_str()).copied().unwrap_or(0) > 1 { t.candidate(v, true) } else { s.clone() }; + let mut name = base.clone(); + if taken.contains(&name) { + let k = next.entry(base.clone()).or_insert(1); + loop { + *k += 1; + name = format!("{base}-{k}"); + if !taken.contains(&name) { + break; + } + } + } + taken.insert(name.clone()); + t.names.insert(v.id.clone(), name); } t } From 7a2568fe55a26fd609dc4335343162e16f498187 Mon Sep 17 00:00:00 2001 From: vetlucasmartins Date: Sat, 24 Oct 2026 00:50:53 +0100 Subject: [PATCH 3/5] tailwind: emit @theme static; fix assertion precedence in tests Tailwind v4 drops theme variables no utility uses, but page code also reads tokens through var() and (--name) utilities, so the export keeps them all. Two test assertions mixed && and || without parentheses. --- crates/engine/src/cmd/variables_io.rs | 4 +++- crates/engine/src/tailwind_tests.rs | 6 +++--- docs/mcp.md | 2 +- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/cmd/variables_io.rs b/crates/engine/src/cmd/variables_io.rs index c74c46fe..eee312c2 100644 --- a/crates/engine/src/cmd/variables_io.rs +++ b/crates/engine/src/cmd/variables_io.rs @@ -80,7 +80,9 @@ fn css(doc: &Document, colls: &[&Collection], tailwind: bool) -> String { let attr = css_var_name(&c.name); for (i, m) in c.modes.iter().enumerate() { let sel = match i { - 0 if tailwind => "@theme".to_string(), + // `static`: Tailwind otherwise drops theme variables no utility uses, and pages also + // read them through `var()` and `(--name)` utilities. + 0 if tailwind => "@theme static".to_string(), 0 => ":root".to_string(), _ => format!("[data-{}=\"{}\"]", attr.trim_start_matches('-'), css_safe(&m.name)), }; diff --git a/crates/engine/src/tailwind_tests.rs b/crates/engine/src/tailwind_tests.rs index 615a6cec..3080a15d 100644 --- a/crates/engine/src/tailwind_tests.rs +++ b/crates/engine/src/tailwind_tests.rs @@ -66,7 +66,7 @@ fn per_side_strokes_are_borders_on_those_sides() { let css = ex(&mut s, "inspect.code", json!({"id": f, "language": "css"}))["code"].as_str().unwrap_or_default().to_string(); assert!(css.contains("border-bottom: 1px solid") && !css.contains("border:"), "{css}"); let tw = code(&mut s, f, json!({})); - assert!(tw.contains("border-b ") && tw.contains("border-[#262a31]") || tw.contains("border-[#262A31]"), "{tw}"); + assert!(tw.contains("border-b ") && (tw.contains("border-[#262a31]") || tw.contains("border-[#262A31]")), "{tw}"); // Without per-side weights it is one border. ex(&mut s, "node.set", json!({"id": f, "props": {"strokeSides": null, "strokeWeight": 2}})); let css = ex(&mut s, "inspect.code", json!({"id": f, "language": "css"}))["code"].as_str().unwrap_or_default().to_string(); @@ -140,7 +140,7 @@ fn variables_export_with_units_and_as_a_tailwind_theme() { let css = ex(&mut s, "variable.export", json!({"format": "css"}))["text"].as_str().unwrap_or_default().to_string(); assert!(css.contains("--card: 12px;") && css.contains("--fade: 0.5;"), "{css}"); let tw = ex(&mut s, "variable.export", json!({"format": "tailwind"}))["text"].as_str().unwrap_or_default().to_string(); - for want in ["@import \"tailwindcss\";", "@theme {", "--color-accent: #3ddc97;", "--radius-card: 12px;", "--fade: 0.5;"] { + for want in ["@import \"tailwindcss\";", "@theme static {", "--color-accent: #3ddc97;", "--radius-card: 12px;", "--fade: 0.5;"] { assert!(tw.to_lowercase().contains(&want.to_lowercase()), "{want} in {tw}"); } } @@ -192,7 +192,7 @@ fn slashed_layer_names_and_phone_widths_merge_right() { assert!(button.contains("self-stretch") && button.contains("lg:self-auto"), "the slashed name keeps its mobile classes: {out}"); assert!(!out.contains("max-w-[342px] ") || out.contains("lg:max-w-[342px]"), "no phone-width cap below lg: {out}"); assert!(out.contains("Only in the mobile frame") && out.contains("Tag"), "{out}"); - let divider = out.lines().filter(|l| l.contains("{"See examples"}`, with each import once at the top. A design system mapped to shadcn/ui this way exports pages that use the project's own components. Bound variables become theme utilities (`bg-accent`, `rounded-card`, `text-muted`) that `variable.export {format: "tailwind"}` - declares: `@import "tailwindcss";` and an `@theme` block named by namespace (`--color-*`, + declares: `@import "tailwindcss";` and an `@theme static` block (every token is emitted, also those only read through `var()`) named by namespace (`--color-*`, `--radius-*`, `--text-*`, `--spacing-*`, `--font-*`; a number's namespace and unit come from what layers bind it to), other modes on `[data-=""]`. With `mobile: ` (the same layer names, mobile layout) the code is mobile first: the mobile frame's classes, the From 9361b230b55773782141151ee4f8d2030c7073ff Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 24 Oct 2026 12:57:43 +0700 Subject: [PATCH 4/5] #1011: explicit imports, a method reference --- crates/engine/src/tailwind.rs | 4 ++-- crates/engine/src/tailwind_tests.rs | 2 +- crates/engine/src/token_roles.rs | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/tailwind.rs b/crates/engine/src/tailwind.rs index 2a6453e4..d52aa29c 100644 --- a/crates/engine/src/tailwind.rs +++ b/crates/engine/src/tailwind.rs @@ -7,7 +7,7 @@ use std::collections::{BTreeMap, HashMap}; -use figcraft_doc::*; +use figcraft_doc::{Document, NodeId, NodeKind, Variable, VariableId, VariableType}; use crate::codegen::{CodeLang, Ctx, Markup, css_var_name, css_var_of, num}; use crate::component_codegen::component_type_name; @@ -454,7 +454,7 @@ pub fn code(doc: &Document, id: NodeId, mobile: Option) -> String { // ponytail: layers only on mobile are listed, not merged in; their place in the tree is ambiguous. let only_mobile: Vec = small .iter() - .flat_map(|m| m.keys()) + .flat_map(BTreeMap::keys) .filter(|p| !rules.iter().any(|(d, _, _)| d == *p)) .map(|p| p.trim_start_matches('/').split('/').map(|seg| seg.rsplit_once('#').map_or(seg, |(n, _)| n)).collect::>().join(" › ")) .take(20) diff --git a/crates/engine/src/tailwind_tests.rs b/crates/engine/src/tailwind_tests.rs index 3080a15d..224c4928 100644 --- a/crates/engine/src/tailwind_tests.rs +++ b/crates/engine/src/tailwind_tests.rs @@ -5,7 +5,7 @@ use crate::Session; use crate::components::tests::{ex, idv}; use crate::tailwind::{classes, merge}; -use figcraft_doc::*; +use figcraft_doc::NodeId; use serde_json::json; fn code(s: &mut Session, id: NodeId, extra: serde_json::Value) -> String { diff --git a/crates/engine/src/token_roles.rs b/crates/engine/src/token_roles.rs index dc609640..3827ecbe 100644 --- a/crates/engine/src/token_roles.rs +++ b/crates/engine/src/token_roles.rs @@ -6,7 +6,7 @@ use std::collections::HashMap; -use figcraft_doc::*; +use figcraft_doc::{Document, Variable, VariableId, VariableType, VariableValue}; use crate::codegen::num; From 647e5f17d905fa4f84032e9202afc46085c1e71c Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 24 Oct 2026 12:57:52 +0700 Subject: [PATCH 5/5] #1011: single-call Tailwind variables, rem only where it converts --- crates/engine/src/codegen.rs | 5 +++-- crates/engine/src/tailwind.rs | 15 ++++++++++++++- crates/engine/src/tailwind_tests.rs | 7 +++++++ 3 files changed, 24 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/codegen.rs b/crates/engine/src/codegen.rs index c0b8e891..18db85c8 100644 --- a/crates/engine/src/codegen.rs +++ b/crates/engine/src/codegen.rs @@ -99,9 +99,10 @@ impl CodeLang { CodeLang::Compose => "Compose", } } - /// Languages whose lengths are CSS pixels (they can be shown in rem). + /// Languages whose lengths are CSS pixels that can be shown in rem (not Tailwind: its lengths + /// are utility classes). pub fn is_web(self) -> bool { - matches!(self, CodeLang::Css | CodeLang::Html | CodeLang::React | CodeLang::Leptos | CodeLang::Tailwind) + matches!(self, CodeLang::Css | CodeLang::Html | CodeLang::React | CodeLang::Leptos) } pub fn from_id(s: &str) -> Option { if s.eq_ignore_ascii_case("tsx") || s.eq_ignore_ascii_case("jsx") { diff --git a/crates/engine/src/tailwind.rs b/crates/engine/src/tailwind.rs index d52aa29c..70a562a9 100644 --- a/crates/engine/src/tailwind.rs +++ b/crates/engine/src/tailwind.rs @@ -105,9 +105,22 @@ fn arb(v: &str) -> String { format!("[{}]", v.trim().replace('_', "\\_").replace(' ', "_")) } -/// The custom property a `var(--name, fallback)` value reads. +/// The custom property a `var(--name, fallback)` value reads, when that one call is the whole +/// value (not `var(--a, 4px) 8px`, four corners of which only the first is a variable). fn var_name(v: &str) -> Option<&str> { let inner = v.trim().strip_prefix("var(")?; + let mut depth = 1usize; + let close = inner.char_indices().find_map(|(i, c)| { + match c { + '(' => depth += 1, + ')' => depth -= 1, + _ => {} + } + (depth == 0).then_some(i) + })?; + if close + 1 != inner.len() { + return None; + } Some(inner.split([',', ')']).next()?.trim()) } diff --git a/crates/engine/src/tailwind_tests.rs b/crates/engine/src/tailwind_tests.rs index 224c4928..9e41fecc 100644 --- a/crates/engine/src/tailwind_tests.rs +++ b/crates/engine/src/tailwind_tests.rs @@ -333,3 +333,10 @@ fn review_desktop_without_fill_clears_a_mobile_image_or_gradient() { let got = merge(Some(&[("background".to_string(), "[background:url(image.png)_center_/_cover]".to_string())]), &[]); assert!(got.contains(&"lg:bg-none".to_string()) && got.contains(&"lg:bg-transparent".to_string()), "{got:?}"); } + +#[test] +fn per_corner_radii_with_a_variable_first_keep_every_corner() { + let got: Vec = + classes("border-radius: var(--radius-a, 4px) 8px 8px 8px;\nborder-radius: var(--radius-card, 4px);\n").into_iter().map(|(_, c)| c).collect(); + assert_eq!(got, ["rounded-[var(--radius-a,_4px)_8px_8px_8px]", "rounded-card"]); +}