Merge pull request #1011 from lucasmartins-ai/feat/tailwind-output

Dev Mode: React + Tailwind page code and Tailwind theme export
This commit is contained in:
BFlat
2026-10-26 09:04:31 +07:00
committed by GitHub
6 changed files with 973 additions and 21 deletions
+18 -8
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>\"]; 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-<collection>=\"<mode>\"]; 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<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, &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,25 @@ 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(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 {
// `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)),
};
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 +95,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");
}
+75 -13
View File
@@ -18,7 +18,7 @@ pub fn specs() -> Vec<CommandSpec> {
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<CommandSpec> {
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,11 +94,13 @@ 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).
/// 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)
}
@@ -199,6 +212,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),
}
}
@@ -321,25 +335,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<crate::tailwind::Theme>,
}
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")
}
}
@@ -370,7 +391,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,
}
}
@@ -664,6 +685,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> {
@@ -678,6 +707,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,
}
}
@@ -759,7 +791,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 `<Button>`.
let connected = self.inline && crate::code_connect::link(doc, n.id, CodeLang::React).is_some();
if self.code() && (!self.inline || connected) && !root && matches!(n.kind, NodeKind::Instance { .. }) {
let at = depth + usize::from(!decls.is_empty());
let run = if self.lang == CodeLang::Leptos { ".run()" } else { "" };
let inner = match self.ctx.param(n, "mainComponent") {
@@ -779,6 +814,11 @@ impl<'c, 'a> Markup<'c, 'a> {
let hugs = n.layout.is_some() || matches!(n.kind, NodeKind::Text(_));
let hug = (hugs && n.child_layout.sizing_h == Sizing::Hug, hugs && n.child_layout.sizing_v == Sizing::Hug);
let mut own = css_decls(doc, n, &mut self.tokens, hug);
let page = self.ctx.doc.node(self.ctx.root).map_or(f64::INFINITY, |r| r.width);
if self.fluid && (n.layout.is_some() || matches!(n.kind, NodeKind::Text(_)) || (!root && n.width >= page * self.fluid_leaves)) {
// Full width, or sized by the flex row (`flex: 1`): no cap needed.
own = fluid(&own, n.width >= page - 0.5 || decls.contains("flex: 1 1 0;"), n.layout.is_some());
}
if n.layout.is_none() && !n.children.is_empty() && !root && in_flow {
own.push_str("position: relative;\n");
}
@@ -845,6 +885,28 @@ impl<'c, 'a> Markup<'c, 'a> {
}
}
/// `decls` for a box that follows the page: a fixed width becomes `width: 100%` capped at that
/// width (or just the full width for boxes as wide as the page), and a frame's fixed height a
/// minimum, so content can wrap and grow.
// ponytail: width-based heuristic; fixed-size leaves (icons, images, shapes) keep their pixels.
fn fluid(decls: &str, full: bool, frame: bool) -> String {
let mut out = String::with_capacity(decls.len() + 32);
for line in decls.lines() {
if let Some(w) = line.strip_prefix("width: ") {
out.push_str("width: 100%;\n");
if !full {
out.push_str(&format!("max-width: {w}\n"));
}
} else if let Some(h) = line.strip_prefix("height: ").filter(|_| frame) {
out.push_str(&format!("min-height: {h}\n"));
} else {
out.push_str(line);
out.push('\n');
}
}
out
}
// ------------------------------------------------------------------ SwiftUI
fn swift_color(c: Color) -> String {
+3
View File
@@ -61,6 +61,7 @@ pub mod snap;
pub mod style_vars;
pub mod synthetic;
pub mod synthetic_ds;
pub mod tailwind;
pub mod templates;
pub mod textedit;
pub mod textfit;
@@ -1863,6 +1864,8 @@ mod styles_behaviour_tests;
#[cfg(test)]
mod svg_import_tests;
#[cfg(test)]
mod tailwind_tests;
#[cfg(test)]
mod templates_tests;
#[cfg(test)]
mod tests;
+519
View File
@@ -0,0 +1,519 @@
//! React + Tailwind CSS v4 for a layer tree, ready to drop into a web project: every layer's look
//! as utility classes instead of a stylesheet, bound variables as theme utilities (`bg-accent`,
//! `rounded-card`) that `variable.export {format: "tailwind"}` declares in an `@theme` block, auto
//! layout frames and text that grow with the page (`w-full max-w-[…]`) instead of fixed pixel
//! boxes, and instances inlined with their overrides. Given a mobile frame too, the code is mobile
//! first: the mobile frame's classes, and the desktop frame's where they differ behind `lg:`.
use std::collections::{BTreeMap, HashMap};
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;
use crate::token_roles::{NumberRole, Roles};
/// The breakpoint desktop classes sit behind in responsive code.
// ponytail: one breakpoint (lg = 1024px); a tablet frame would need a second pass and `md:`.
const BREAKPOINT: &str = "lg";
/// The roles and Tailwind theme names of a file's variables, computed once per export so code
/// and the `@theme` block always agree.
#[derive(Default)]
pub struct Theme {
roles: Roles,
names: HashMap<VariableId, String>,
}
impl Theme {
pub fn new(doc: &Document) -> Self {
let vars = &doc.variables.variables;
let roles = Roles::new(doc);
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<String> = 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<String> = std::collections::HashSet::new();
let mut next: HashMap<String, usize> = 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
}
/// What a number variable measures; a number nothing tells about is a length.
pub fn role(&self, v: &Variable) -> NumberRole {
self.roles.role(v)
}
/// The theme variable for `v`, unique in the file (see [`Theme::new`]).
pub fn name(&self, v: &Variable) -> String {
self.names.get(&v.id).cloned().unwrap_or_else(|| self.candidate(v, false))
}
/// `--color-*` for colours, `--radius-*`, `--text-*`, `--spacing-*` for numbers by role,
/// `--font-*` for font families, others their CSS name; from the last path segment, or the
/// whole path (`full`). A name already in the namespace isn't prefixed twice.
fn candidate(&self, v: &Variable, full: bool) -> String {
let ns = match v.kind {
VariableType::Color => Some("color"),
VariableType::Number => match self.role(v) {
NumberRole::Radius => Some("radius"),
NumberRole::FontSize => Some("text"),
NumberRole::Spacing => Some("spacing"),
NumberRole::Length | NumberRole::Unitless => None,
},
VariableType::String if v.scopes.as_ref().is_some_and(|s| s.iter().any(|s| s == "FONT_FAMILY")) => Some("font"),
_ => None,
};
let Some(ns) = ns else {
return if full { css_var_name(&v.name) } else { css_var_of(v) };
};
let source = if full { v.name.as_str() } else { v.name.rsplit('/').next().unwrap_or(&v.name) };
let slug = css_var_name(source);
let slug = slug.trim_start_matches('-');
let slug = [format!("{ns}s-"), format!("{ns}-")].iter().find_map(|p| slug.strip_prefix(p.as_str())).unwrap_or(slug);
// A variable named after its namespace ("radius", "colors") is the namespace's base value.
let slug = if slug == ns || slug == format!("{ns}s") { "base" } else { slug };
format!("--{ns}-{slug}")
}
}
// ------------------------------------------------------------------ declarations → classes
/// An arbitrary value: spaces become underscores (Tailwind's escape), underscores are escaped.
fn arb(v: &str) -> String {
format!("[{}]", v.trim().replace('_', "\\_").replace(' ', "_"))
}
/// 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())
}
/// `util` with `value`: a theme utility when the value reads a variable of the namespace
/// (`bg-accent`), else the variable in parentheses (`bg-(--x)`, with a type hint for `text-`
/// sizes), else an arbitrary value.
fn util(util: &str, ns: &str, value: &str, hint: &str) -> String {
match var_name(value) {
Some(name) => match name.strip_prefix(&format!("--{ns}-")) {
Some(key) if !ns.is_empty() => format!("{util}-{key}"),
_ => format!("{util}-({hint}{name})"),
},
None => format!("{util}-{}", arb(value)),
}
}
/// A length on the spacing scale (4px steps) when it fits one, else arbitrary.
fn spacing(prefix: &str, value: &str) -> String {
if var_name(value).is_some() {
return util(prefix, "spacing", value, "");
}
match value.strip_suffix("px").and_then(|n| n.parse::<f64>().ok()) {
Some(0.0) => format!("{prefix}-0"),
Some(px) if px > 0.0 && px <= 384.0 && (px / 4.0).fract() == 0.0 => format!("{prefix}-{}", num(px / 4.0)),
Some(px) if px < 0.0 && (px / 4.0).fract() == 0.0 => format!("-{prefix}-{}", num(-px / 4.0)),
_ => format!("{prefix}-{}", arb(value)),
}
}
/// The values of a `padding` shorthand (top right bottom left), as utilities.
fn padding(value: &str) -> Vec<(String, String)> {
let mut parts = Vec::new();
let mut depth = 0usize;
let mut cur = String::new();
for c in value.chars() {
match c {
'(' => depth += 1,
')' => depth = depth.saturating_sub(1),
' ' if depth == 0 => {
parts.push(std::mem::take(&mut cur));
continue;
}
_ => {}
}
cur.push(c);
}
parts.push(cur);
let [t, r, b, l] = match parts.as_slice() {
[a] => [a, a, a, a],
[t, r, b, l] => [t, r, b, l],
_ => return vec![("padding".into(), format!("p-{}", arb(value)))],
};
let zero = |v: &str| v == "0px" || v == "0";
let mut out = Vec::new();
if t == r && r == b && b == l {
if !zero(t) {
out.push(("padding".into(), spacing("p", t)));
}
} else if t == b && l == r {
for (k, p, v) in [("padding-y", "py", t), ("padding-x", "px", l)] {
if !zero(v) {
out.push((k.into(), spacing(p, v)));
}
}
} else {
for (k, p, v) in [("padding-top", "pt", t), ("padding-right", "pr", r), ("padding-bottom", "pb", b), ("padding-left", "pl", l)] {
if !zero(v) {
out.push((k.into(), spacing(p, v)));
}
}
}
out
}
fn font_weight(v: &str) -> String {
let named = match v.trim() {
"100" => "thin",
"200" => "extralight",
"300" => "light",
"400" => "normal",
"500" => "medium",
"600" => "semibold",
"700" => "bold",
"800" => "extrabold",
"900" => "black",
_ => return format!("font-{}", arb(v)),
};
format!("font-{named}")
}
/// A border shorthand (`1px solid c`) on `side` ("" or "t", "r", "b", "l") as width + colour.
fn border(side: &str, value: &str) -> Vec<(String, String)> {
// `<width> solid <colour>`, where the width may be `var(--w, 2px)`: split at the style.
let (width, color) = value.split_once(" solid ").unwrap_or((value, ""));
let (width, color) = (width.trim(), color.trim());
let b = if side.is_empty() { "border".to_string() } else { format!("border-{side}") };
let key = format!("border{}", if side.is_empty() { String::new() } else { format!("-{side}") });
let w = match width {
"1px" => b.clone(),
"0px" => format!("{b}-0"),
w => match var_name(w) {
// `border-(--w)` would read as a colour; the hint makes it a width.
Some(name) => format!("{b}-(length:{name})"),
None => format!("{b}-{}", arb(w)),
},
};
let mut out = vec![(format!("{key}-width"), w)];
if !color.is_empty() {
out.push(("border-color".into(), util("border", "color", color, "")));
}
out
}
/// One CSS declaration as utilities, keyed by the property they set (so responsive code can tell
/// which mobile class a desktop class replaces).
fn decl(key: &str, value: &str) -> Vec<(String, String)> {
let one = |c: String| vec![(key.to_string(), c)];
let kw = |c: &str| vec![(key.to_string(), c.to_string())];
match (key, value) {
("display", "flex") => kw("flex"),
("display", "block") => kw("block"),
("flex-direction", "column") => kw("flex-col"),
("flex-direction", _) => kw("flex-row"),
("flex-wrap", "wrap") => kw("flex-wrap"),
// Defaults flexbox already has.
("justify-content", "flex-start") | ("gap", "0px") => Vec::new(),
("justify-content", v) => one(format!("justify-{}", v.trim_start_matches("flex-").trim_start_matches("space-"))),
("align-items", v) => one(format!("items-{}", v.trim_start_matches("flex-"))),
("align-content", v) => one(format!("content-{}", v.trim_start_matches("flex-").trim_start_matches("space-"))),
("align-self", "stretch") => kw("self-stretch"),
("flex", "1 1 0") => kw("flex-1"),
("position", v) => one(v.to_string()),
("left" | "top" | "right" | "bottom", v) => one(spacing(key, v)),
("width", "100%") => kw("w-full"),
("width", v) => one(spacing("w", v)),
("height", v) => one(spacing("h", v)),
("max-width", v) => one(format!("max-w-{}", arb(v))),
("min-height", v) => one(format!("min-h-{}", arb(v))),
("margin-inline", "auto") => kw("mx-auto"),
("gap", v) => one(spacing("gap", v)),
("padding", v) => padding(v),
("border-radius", "50%") => kw("rounded-full"),
("border-radius", v) if v.strip_suffix("px").and_then(|n| n.parse::<f64>().ok()).is_some_and(|r| r >= 999.0) => kw("rounded-full"),
("border-radius", v) if v.contains(' ') && var_name(v).is_none() => one(format!("rounded-{}", arb(v))),
("border-radius", v) => one(util("rounded", "radius", v, "")),
("opacity", v) => match v.parse::<f64>() {
Ok(o) if ((o * 100.0).round() - o * 100.0).abs() < 1e-9 => one(format!("opacity-{}", num(o * 100.0))),
_ => one(util("opacity", "", v, "")),
},
("background", v) if v.contains("gradient(") => one(format!("bg-{}", arb(v))),
// An image shorthand (`url(…) center / cover`) is a whole `background`, not a bg colour.
("background", v) if v.starts_with("url(") => one(format!("[background:{}]", arb(v).trim_matches(['[', ']']))),
("background", v) => one(util("bg", "color", v, "color:")),
("color", v) => one(util("text", "color", v, "color:")),
("border", v) => border("", v),
("border-top", v) => border("t", v),
("border-right", v) => border("r", v),
("border-bottom", v) => border("b", v),
("border-left", v) => border("l", v),
("box-shadow", v) => one(format!("shadow-{}", arb(v))),
("filter", v) => one(match v.strip_prefix("blur(").and_then(|r| r.strip_suffix(')')) {
Some(r) => format!("blur-{}", arb(r)),
None => format!("[filter:{}]", arb(v).trim_matches(['[', ']'])),
}),
("backdrop-filter", v) => one(match v.strip_prefix("blur(").and_then(|r| r.strip_suffix(')')) {
Some(r) => format!("backdrop-blur-{}", arb(r)),
None => format!("[backdrop-filter:{}]", arb(v).trim_matches(['[', ']'])),
}),
("mix-blend-mode", v) => one(format!("mix-blend-{v}")),
("transform", v) => match v.strip_prefix("rotate(").and_then(|r| r.strip_suffix(')')) {
Some(r) => one(format!("rotate-{}", arb(r))),
None => one(format!("[transform:{}]", arb(v).trim_matches(['[', ']']))),
},
("transform-origin", "top left" | "0 0") => kw("origin-top-left"),
// `font-(--x)` would read as a weight; the hint makes an un-namespaced variable a family.
("font-family", v) if var_name(v).is_some() => one(util("font", "font", v, "family-name:")),
("font-family", v) => one(format!("font-{}", arb(&format!("'{}'", v.trim_matches('"'))))),
("font-size", v) => one(util("text", "text", v, "length:")),
("font-weight", v) => one(font_weight(v)),
("font-style", "italic") => kw("italic"),
("line-height", v) => match v.strip_suffix('%').and_then(|p| p.parse::<f64>().ok()) {
Some(p) => one(format!("leading-{}", arb(&num(p / 100.0)))),
None => one(util("leading", "", v, "")),
},
("letter-spacing", v) => one(util("tracking", "", v, "")),
("text-align", v) => one(format!("text-{v}")),
("text-transform", "uppercase") => kw("uppercase"),
("text-transform", "lowercase") => kw("lowercase"),
("text-transform", "capitalize") => kw("capitalize"),
("text-decoration", "underline") => kw("underline"),
("text-decoration", "line-through") => kw("line-through"),
_ => one(format!("[{key}:{}]", arb(value).trim_matches(['[', ']']))),
}
}
/// A rule's declarations (`key: value;` lines) as (property, utility) pairs in order.
pub fn classes(decls: &str) -> Vec<(String, String)> {
decls
.lines()
.filter_map(|l| {
let (k, v) = l.trim().trim_end_matches(';').split_once(':')?;
Some(decl(k.trim(), v.trim()))
})
.flatten()
.collect()
}
/// The utility that undoes a mobile class at the breakpoint when desktop sets nothing for it.
fn reset(key: &str) -> Option<String> {
// Border widths by side: `border-t-width` → `border-t-0`.
if let Some(side) = key.strip_prefix("border").and_then(|k| k.strip_suffix("-width")) {
return Some(format!("border{side}-0"));
}
Some(String::from(match key {
"display" => "block",
"flex-direction" => "flex-row",
"flex-wrap" => "flex-nowrap",
"flex" => "flex-initial",
"align-self" => "self-auto",
"width" => "w-auto",
"height" => "h-auto",
"max-width" => "max-w-none",
"min-height" => "min-h-0",
"text-align" => "text-left",
"justify-content" => "justify-start",
"gap" => "gap-0",
"position" => "static",
"text-transform" => "normal-case",
"opacity" => "opacity-100",
"border-radius" => "rounded-none",
"box-shadow" => "shadow-none",
"font-style" => "not-italic",
// The whole background: a mobile image or gradient would survive a colour-only reset.
"background" => "bg-none bg-transparent",
"border-color" => "border-current",
"letter-spacing" => "tracking-normal",
"line-height" => "leading-normal",
"text-decoration" => "no-underline",
"align-items" => "items-stretch",
"filter" => "filter-none",
"backdrop-filter" => "backdrop-filter-none",
"transform" => "rotate-0",
"left" | "top" | "right" | "bottom" => return Some(format!("{key}-auto")),
"mix-blend-mode" => "mix-blend-normal",
_ => return None,
}))
}
/// The sides (top, right, bottom, left) a padding utility's property sets.
fn padding_sides(key: &str) -> [bool; 4] {
match key {
"padding" => [true; 4],
"padding-x" => [false, true, false, true],
"padding-y" => [true, false, true, false],
"padding-top" => [true, false, false, false],
"padding-right" => [false, true, false, false],
"padding-bottom" => [false, false, true, false],
"padding-left" => [false, false, false, true],
_ => [false; 4],
}
}
/// Utilities zeroing `sides` (as few as cover them: `py-0`, `px-0`, else per side).
fn zero_padding(sides: [bool; 4]) -> Vec<&'static str> {
let [t, r, b, l] = sides;
let mut out = Vec::new();
match (t, b) {
(true, true) => out.push("py-0"),
(true, false) => out.push("pt-0"),
(false, true) => out.push("pb-0"),
_ => {}
}
match (r, l) {
(true, true) => out.push("px-0"),
(true, false) => out.push("pr-0"),
(false, true) => out.push("pl-0"),
_ => {}
}
out
}
/// Mobile classes plus, behind the breakpoint, the desktop ones that differ (and resets for what
/// desktop leaves unset). A layer missing on mobile is hidden there.
pub(crate) fn merge(mobile: Option<&[(String, String)]>, desktop: &[(String, String)]) -> Vec<String> {
let hidden = [("display".to_string(), "hidden".to_string())];
// A mobile frame's widths are the phone's, not caps: below the breakpoint boxes fill the page.
let fluid: Vec<(String, String)>;
let mobile = match mobile {
Some(m) => {
fluid = m.iter().filter(|(k, _)| k != "max-width").cloned().collect();
fluid.as_slice()
}
None => &hidden,
};
let mut out: Vec<String> = mobile.iter().map(|(_, c)| c.clone()).collect();
let bp = |c: &str| format!("{BREAKPOINT}:{c}");
for (_, c) in desktop {
if !mobile.iter().any(|(_, m)| m == c) {
out.push(bp(c));
}
}
for (k, _) in mobile.iter().filter(|(k, _)| !k.starts_with("padding")) {
if !desktop.iter().any(|(d, _)| d == k)
&& let Some(r) = reset(k)
{
out.extend(r.split_whitespace().map(bp));
}
}
// Padding utilities overlap (p, px/py, pt…): compare side by side, and zero on desktop the
// sides mobile pads and desktop doesn't.
let cover = |list: &[(String, String)]| {
list.iter().fold([false; 4], |mut acc, (k, _)| {
for (a, s) in acc.iter_mut().zip(padding_sides(k)) {
*a |= s;
}
acc
})
};
let (m, d) = (cover(mobile), cover(desktop));
let missing = [m[0] && !d[0], m[1] && !d[1], m[2] && !d[2], m[3] && !d[3]];
out.extend(zero_padding(missing).into_iter().map(bp));
out.dedup();
out
}
/// The markup walk for `id` in Tailwind mode: (markup, rules by path).
fn walk(doc: &Document, id: NodeId) -> (String, Vec<(String, String, String)>) {
let ctx = Ctx::new(doc, id);
let mut m = Markup::new(&ctx, CodeLang::React);
m.inline = true;
m.fluid = true;
m.fluid_leaves = 0.5;
m.tokens.tailwind = true;
m.node(id, None, 2, "");
(m.markup, m.rules)
}
/// React + Tailwind for `id` (with `mobile`, mobile first with desktop classes behind `lg:`).
pub fn code(doc: &Document, id: NodeId, mobile: Option<NodeId>) -> String {
let Some(n) = doc.node(id) else { return String::new() };
let (mut markup, rules) = walk(doc, id);
let small: Option<BTreeMap<String, Vec<(String, String)>>> =
mobile.filter(|m| doc.contains(*m) && *m != id).map(|m| walk(doc, m).1.into_iter().map(|(p, _, d)| (p, classes(&d))).collect());
// ponytail: layers only on mobile are listed, not merged in; their place in the tree is ambiguous.
let only_mobile: Vec<String> = small
.iter()
.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::<Vec<_>>().join(" › "))
.take(20)
.collect();
// Longest class names first, so "card" never rewrites inside "card-2"'s attribute.
let mut rules = rules;
rules.sort_by_key(|(_, c, _)| std::cmp::Reverse(c.len()));
let paths: Vec<String> = rules.iter().map(|(p, _, _)| p.clone()).collect();
for (path, class, decls) in &rules {
let own = classes(decls);
// Layer names may contain "/": the parent is the longest other path this one extends.
let parent =
paths.iter().filter(|p| p.len() < path.len() && path.starts_with(&format!("{p}/"))).max_by_key(|p| p.len()).map_or("", String::as_str);
let list = match &small {
// Inside a layer hidden on mobile: desktop's classes as they are.
Some(m) if !path.is_empty() && !m.contains_key(parent) => own.into_iter().map(|(_, c)| c).collect(),
Some(m) => merge(m.get(path).map(Vec::as_slice), &own),
None => own.into_iter().map(|(_, c)| c).collect(),
};
markup = markup.replace(&format!("className=\"{class}\""), &format!("className=\"{}\"", list.join(" ")));
}
// Imports of the instances written as their connected code component, once each.
let mut imports: Vec<String> = Vec::new();
for i in doc.subtree(id).into_iter().take(20_000) {
if i != id
&& doc.node(i).is_some_and(|k| matches!(k.kind, NodeKind::Instance { .. }))
&& let Some(l) = crate::code_connect::link(doc, i, CodeLang::React)
{
let line = crate::code_connect::import_line(l, CodeLang::React);
if !line.is_empty() && !imports.contains(&line) {
imports.push(line);
}
}
}
let imports = if imports.is_empty() { String::new() } else { format!("{}\n\n", imports.join("\n")) };
let name = component_type_name(&n.name);
let mut mode = if small.is_some() { format!(", mobile first; desktop from {BREAKPOINT}") } else { String::new() };
if !only_mobile.is_empty() {
mode.push_str(&format!(". Only in the mobile frame, add by hand with {BREAKPOINT}:hidden: {}", only_mobile.join(", ")));
}
let header = comment_safe(&format!("{} as React + Tailwind CSS v4{mode}. Theme: variable.export {{format: \"tailwind\"}}.", n.name));
format!("// {header}\n{imports}export function {name}() {{\n return (\n{markup} );\n}}\n")
}
/// `text` for one `//` comment line: every JS line terminator (control characters, U+2028,
/// U+2029) becomes a space, so layer names in it can't end the comment and inject code.
fn comment_safe(text: &str) -> String {
text.chars().take(2_000).map(|c| if c.is_control() || matches!(c, '\u{2028}' | '\u{2029}') { ' ' } else { c }).collect()
}
+342
View File
@@ -0,0 +1,342 @@
//! React + Tailwind code for pages: utilities instead of a stylesheet, theme utilities for bound
//! variables, fluid boxes, inlined instances, per-side borders, mobile-first responsive merges and
//! the Tailwind theme export.
use crate::Session;
use crate::components::tests::{ex, idv};
use crate::tailwind::{classes, merge};
use figcraft_doc::NodeId;
use serde_json::json;
fn code(s: &mut Session, id: NodeId, extra: serde_json::Value) -> String {
let mut p = json!({"id": id, "language": "tailwind"});
if let (Some(p), Some(e)) = (p.as_object_mut(), extra.as_object()) {
p.extend(e.clone());
}
ex(s, "inspect.code", p)["code"].as_str().unwrap_or_default().to_string()
}
/// A 1440 "Page" (vertical auto layout, bg bound to Brand/Accent) holding a 960 "Hero" text and a
/// horizontal "Row" of two "Card" frames that fill it. Returns (page, row).
fn page(s: &mut Session, name: &str, width: f64, row_dir: &str) -> (NodeId, NodeId) {
let p = idv(&ex(s, "frame.create", json!({"x": 0, "y": 0, "width": width, "height": 900, "name": name})));
ex(s, "layout.add", json!({"ids": [p], "direction": "vertical"}));
ex(s, "node.set", json!({"id": p, "props": {"gap": 24, "padding": 32}}));
let t = idv(&ex(s, "text.create", json!({"text": "Seu site em 7 dias", "x": 0, "y": 0, "parent": p, "props": {"name": "Hero"}})));
ex(s, "node.set", json!({"id": t, "props": {"width": 960, "textAutoResize": "height"}}));
let row = idv(&ex(s, "frame.create", json!({"x": 0, "y": 0, "width": 800, "height": 200, "parent": p, "name": "Row"})));
ex(s, "layout.add", json!({"ids": [row], "direction": row_dir}));
for _ in 0..2 {
let c = idv(&ex(s, "frame.create", json!({"x": 0, "y": 0, "width": 100, "height": 100, "parent": row, "name": "Card"})));
ex(s, "node.set", json!({"id": c, "props": {"sizingH": "fill"}}));
}
(p, row)
}
fn brand(s: &mut Session) {
ex(s, "variable.createCollection", json!({"name": "Brand"}));
ex(s, "variable.create", json!({"collection": "Brand", "name": "Brand/Accent", "type": "color", "value": "#3DDC97"}));
ex(s, "variable.create", json!({"collection": "Brand", "name": "card", "type": "number", "value": 12}));
ex(s, "variable.create", json!({"collection": "Brand", "name": "fade", "type": "number", "value": 0.5}));
}
#[test]
fn utilities_replace_the_stylesheet_and_variables_become_theme_utilities() {
let mut s = Session::new();
brand(&mut s);
let (p, row) = page(&mut s, "Landing", 1440.0, "horizontal");
ex(&mut s, "variable.bind", json!({"ids": [p], "field": "fill", "variable": "Brand/Accent"}));
ex(&mut s, "node.set", json!({"id": row, "props": {"cornerRadius": 12}}));
ex(&mut s, "variable.bind", json!({"ids": [row], "field": "cornerRadius", "variable": "card"}));
let out = code(&mut s, p, json!({}));
assert!(out.contains("export function Landing() {"), "{out}");
assert!(!out.contains("import") && !out.contains("<style") && !out.contains(".css"), "no stylesheet: {out}");
for want in ["flex flex-col", "gap-6", "p-8", "bg-accent", "rounded-card", "flex-1", "w-full"] {
assert!(out.contains(want), "{want} in {out}");
}
// The page is as wide as itself: just full width; the 960 text is capped, not fixed.
assert!(out.contains("max-w-[960px]") && !out.contains("w-[1440px]") && !out.contains("max-w-[1440px]"), "{out}");
}
#[test]
fn per_side_strokes_are_borders_on_those_sides() {
let mut s = Session::new();
let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 300, "height": 60, "name": "Nav"})));
ex(&mut s, "node.set", json!({"id": f, "props": {"strokes": ["#262A31"], "strokeWeight": 1, "strokeSides": [0, 0, 1, 0]}}));
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}");
// 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();
assert!(css.contains("border: 2px solid"), "{css}");
}
#[test]
fn instances_are_inlined_with_their_overrides() {
let mut s = Session::new();
let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 200, "height": 100, "name": "Benefit"})));
ex(&mut s, "layout.add", json!({"ids": [f], "direction": "vertical"}));
ex(&mut s, "text.create", json!({"text": "Original", "x": 0, "y": 0, "parent": f, "props": {"name": "Title"}}));
let comp = idv(&ex(&mut s, "component.create", json!({"ids": [f]})));
let holder = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 400, "width": 600, "height": 200, "name": "Benefits"})));
ex(&mut s, "layout.add", json!({"ids": [holder], "direction": "horizontal"}));
let inst = idv(&ex(&mut s, "instance.create", json!({"component": comp, "x": 0, "y": 0, "parent": holder})));
let doc = s.doc().unwrap();
let title = doc.node(inst).unwrap().children[0];
ex(&mut s, "text.set", json!({"id": title, "characters": "Agenda sozinha"}));
let out = code(&mut s, holder, json!({}));
assert!(out.contains("Agenda sozinha") && !out.contains("<Benefit"), "inlined with override: {out}");
}
#[test]
fn a_mobile_frame_makes_the_code_mobile_first() {
let mut s = Session::new();
let (desk, _) = page(&mut s, "Landing", 1440.0, "horizontal");
let (mob, _) = page(&mut s, "Landing mobile", 390.0, "vertical");
// A layer only on desktop is hidden on mobile.
ex(&mut s, "text.create", json!({"text": "Só no desktop", "x": 0, "y": 0, "parent": desk, "props": {"name": "Aside"}}));
let out = code(&mut s, desk, json!({"mobile": mob}));
assert!(out.contains("mobile first"), "{out}");
assert!(out.contains("flex flex-col") && out.contains("lg:flex-row"), "row stacks on mobile: {out}");
assert!(out.contains("hidden") && out.contains("lg:block"), "desktop-only layer: {out}");
assert!(s.execute("inspect.code", &json!({"id": desk, "language": "tailwind", "mobile": 999_999})).is_err(), "a missing mobile frame is refused");
}
#[test]
fn declarations_map_to_idiomatic_utilities() {
let got: Vec<String> = classes(
"display: flex;\njustify-content: space-between;\nalign-items: center;\npadding: 8px 16px 8px 16px;\nfont-weight: 600;\nfont-size: 18px;\nline-height: 120%;\nopacity: 0.5;\ncolor: var(--color-muted, #9aa3ae);\nbox-shadow: 0px 4px 12px 0px rgba(0, 0, 0, 0.25);\nwhite-space: nowrap;\n",
)
.into_iter()
.map(|(_, c)| c)
.collect();
let want = [
"flex",
"justify-between",
"items-center",
"py-2",
"px-4",
"font-semibold",
"text-[18px]",
"leading-[1.2]",
"opacity-50",
"text-muted",
"shadow-[0px_4px_12px_0px_rgba(0,_0,_0,_0.25)]",
"[white-space:nowrap]",
];
assert_eq!(got, want);
}
#[test]
fn variables_export_with_units_and_as_a_tailwind_theme() {
let mut s = Session::new();
brand(&mut s);
let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "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"}));
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 static {", "--color-accent: #3ddc97;", "--radius-card: 12px;", "--fade: 0.5;"] {
assert!(tw.to_lowercase().contains(&want.to_lowercase()), "{want} in {tw}");
}
}
#[test]
fn desktop_padding_on_other_sides_is_not_reset_away() {
let mut s = Session::new();
let (desk, _) = page(&mut s, "Landing", 1440.0, "horizontal");
let (mob, _) = page(&mut s, "Landing mobile", 390.0, "vertical");
ex(&mut s, "node.set", json!({"id": desk, "props": {"paddingTop": 24, "paddingBottom": 24, "paddingLeft": 64, "paddingRight": 64}}));
ex(&mut s, "node.set", json!({"id": mob, "props": {"padding": 24}}));
let out = code(&mut s, desk, json!({"mobile": mob}));
assert!(out.contains("p-6 ") && out.contains("lg:py-6 lg:px-16"), "{out}");
assert!(!out.contains("lg:p-0"), "a reset would undo lg:px-16: {out}");
}
#[test]
fn a_layer_name_cant_break_out_of_the_header_comment() {
let mut s = Session::new();
let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 100, "height": 100, "name": "A\rB\u{2028}alert(1)\u{2029}C\nD"})));
let out = code(&mut s, f, json!({}));
let header = out.lines().next().unwrap_or_default();
assert!(header.starts_with("// ") && header.contains("alert(1)"), "{out}");
assert!(!out.contains(['\r', '\u{2028}', '\u{2029}']), "{out:?}");
let name = out.lines().nth(1).and_then(|l| l.strip_prefix("export function ")).and_then(|l| l.strip_suffix("() {")).unwrap_or_default();
assert!(!name.is_empty() && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_'), "an identifier: {name:?}");
}
#[test]
fn slashed_layer_names_and_phone_widths_merge_right() {
let mut s = Session::new();
let mut frames = Vec::new();
for (name, width, dir, sizing) in [("Landing", 1440.0, "horizontal", "hug"), ("Landing mobile", 390.0, "vertical", "fill")] {
let p = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": width, "height": 600, "name": name})));
ex(&mut s, "layout.add", json!({"ids": [p], "direction": "vertical"}));
let ctas = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 342, "height": 120, "parent": p, "name": "CTAs"})));
ex(&mut s, "layout.add", json!({"ids": [ctas], "direction": dir}));
let b = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 200, "height": 48, "parent": ctas, "name": "Button/Primary"})));
ex(&mut s, "layout.add", json!({"ids": [b], "direction": "horizontal"}));
ex(&mut s, "node.set", json!({"id": b, "props": {"sizingH": sizing}}));
ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": width * 0.8, "height": 1, "parent": p, "props": {"name": "Divider"}}));
frames.push(p);
}
// A layer only the mobile frame has.
ex(&mut s, "text.create", json!({"text": "7 dias", "x": 0, "y": 0, "parent": frames[1], "props": {"name": "Tag"}}));
let out = code(&mut s, frames[0], json!({"mobile": frames[1]}));
let button =
out.lines().find(|l| l.contains("lg:") && l.contains("rounded") || l.contains("self-stretch") && l.contains("flex-row")).unwrap_or_default();
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("<div") && (l.contains("h-[1px]") || l.contains("h-px"))).count();
assert!(out.contains("w-full") && divider <= 2, "{out}");
}
#[test]
fn a_variable_named_after_its_namespace_is_its_base() {
let mut s = Session::new();
ex(&mut s, "variable.createCollection", json!({"name": "Brand"}));
ex(&mut s, "variable.create", json!({"collection": "Brand", "name": "radius", "type": "number", "value": 12}));
let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 100, "height": 100})));
ex(&mut s, "node.set", json!({"id": f, "props": {"cornerRadius": 12}}));
ex(&mut s, "variable.bind", json!({"ids": [f], "field": "cornerRadius", "variable": "radius"}));
assert!(code(&mut s, f, json!({})).contains("rounded-base"));
let tw = ex(&mut s, "variable.export", json!({"format": "tailwind"}))["text"].as_str().unwrap_or_default().to_string();
assert!(tw.contains("--radius-base: 12px;") && !tw.contains("--radius-radius"), "{tw}");
}
#[test]
fn mobile_only_layer_names_cant_break_out_of_the_header_comment() {
let mut s = Session::new();
let (desk, _) = page(&mut s, "Landing", 1440.0, "horizontal");
let (mob, _) = page(&mut s, "Landing mobile", 390.0, "vertical");
ex(&mut s, "text.create", json!({"text": "x", "x": 0, "y": 0, "parent": mob, "props": {"name": "Tag\nalert(1)\u{2028}y"}}));
let out = code(&mut s, desk, json!({"mobile": mob}));
let header = out.lines().next().unwrap_or_default();
assert!(header.starts_with("// ") && header.contains("alert(1)"), "listed inside the comment: {out}");
assert!(out.lines().nth(1).is_some_and(|l| l.starts_with("export function ")), "{out}");
assert!(!out.contains(['\r', '\u{2028}', '\u{2029}']), "{out:?}");
}
#[test]
fn page_code_leaves_resets_to_tailwind_preflight() {
let mut s = Session::new();
let (p, _) = page(&mut s, "Landing", 1440.0, "horizontal");
let out = code(&mut s, p, json!({}));
for leak in ["box-sizing", "[margin:0]", "text-decoration", "cursor"] {
assert!(!out.contains(leak), "{leak} in {out}");
}
}
#[test]
fn instances_connected_to_code_are_their_snippet_with_one_import() {
let mut s = Session::new();
let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 160, "height": 44, "name": "Button"})));
ex(&mut s, "layout.add", json!({"ids": [f], "direction": "horizontal"}));
let label = idv(&ex(&mut s, "text.create", json!({"text": "Botão", "x": 0, "y": 0, "parent": f, "props": {"name": "Label"}})));
let comp = idv(&ex(&mut s, "component.create", json!({"ids": [f]})));
ex(&mut s, "component.addProperty", json!({"id": comp, "name": "Label", "type": "text", "layer": label}));
ex(
&mut s,
"codeConnect.set",
json!({"id": comp, "language": "react", "importPath": "@/components/ui/button", "name": "Button", "template": "<Button size=\"lg\">{Label}</Button>"}),
);
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("<Button size=\"lg\">") && out.contains("Agendar diagnóstico") && out.contains("Ver exemplos"), "{out}");
assert!(!out.contains(">Botão<"), "the component's own layers aren't inlined: {out}");
// Unconnected instances are still inlined.
ex(&mut s, "codeConnect.remove", json!({"id": comp, "language": "react"}));
let out = code(&mut s, p, json!({}));
assert!(!out.contains("import ") && out.contains(">Agendar diagnóstico<"), "{out}");
}
#[test]
fn review_same_named_variables_keep_distinct_theme_names() {
let mut s = Session::new();
ex(&mut s, "variable.createCollection", json!({"name": "Brand"}));
ex(&mut s, "variable.create", json!({"collection": "Brand", "name": "Palette/Accent", "type": "color", "value": "#3DDC97"}));
ex(&mut s, "variable.create", json!({"collection": "Brand", "name": "Semantic/Accent", "type": "color", "value": "#7C5CFF"}));
ex(&mut s, "variable.create", json!({"collection": "Brand", "name": "Muted", "type": "color", "value": "#9AA3AE"}));
let p = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 400, "height": 200, "name": "Page"})));
ex(&mut s, "layout.add", json!({"ids": [p], "direction": "vertical"}));
let a = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 100, "height": 50, "parent": p, "name": "A"})));
let b = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 100, "height": 50, "parent": p, "name": "B"})));
ex(&mut s, "variable.bind", json!({"ids": [a], "field": "fill", "variable": "Palette/Accent"}));
ex(&mut s, "variable.bind", json!({"ids": [b], "field": "fill", "variable": "Semantic/Accent"}));
let out = code(&mut s, p, json!({}));
assert!(out.contains("bg-palette-accent") && out.contains("bg-semantic-accent"), "{out}");
let tw = ex(&mut s, "variable.export", json!({"format": "tailwind"}))["text"].as_str().unwrap_or_default().to_lowercase();
assert!(tw.contains("--color-palette-accent: #3ddc97;") && tw.contains("--color-semantic-accent: #7c5cff;"), "{tw}");
assert!(tw.contains("--color-muted:"), "unique short names stay short: {tw}");
}
#[test]
fn review_an_alias_target_takes_its_readers_role() {
let mut s = Session::new();
ex(&mut s, "variable.createCollection", json!({"name": "Brand"}));
ex(&mut s, "variable.create", json!({"collection": "Brand", "name": "half", "type": "number", "value": 0.5}));
ex(&mut s, "variable.create", json!({"collection": "Brand", "name": "fade", "type": "number", "value": 1}));
ex(&mut s, "variable.set", json!({"variable": "fade", "value": {"alias": "half"}}));
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": "fade"}));
let css = ex(&mut s, "variable.export", json!({"format": "css"}))["text"].as_str().unwrap_or_default().to_string();
assert!(css.contains("--half: 0.5;") && !css.contains("0.5px"), "{css}");
}
#[test]
fn review_borders_images_and_fonts_from_variables_are_valid_utilities() {
let got: Vec<String> = classes(
"border: var(--stroke, 2px) solid #000000;\nborder-bottom: var(--hair, 1px) solid var(--color-border, #262a31);\nbackground: url(image.png) center / cover;\nfont-family: var(--brand-font, \"Inter\");\n",
)
.into_iter()
.map(|(_, c)| c)
.collect();
assert_eq!(
got,
[
"border-(length:--stroke)",
"border-[#000000]",
"border-b-(length:--hair)",
"border-border",
"[background:url(image.png)_center_/_cover]",
"font-(family-name:--brand-font)"
]
);
}
#[test]
fn review_desktop_defaults_reset_mobile_values_and_padding_per_side() {
let mut s = Session::new();
let (desk, _) = page(&mut s, "Landing", 1440.0, "horizontal");
let (mob, _) = page(&mut s, "Landing mobile", 390.0, "vertical");
ex(&mut s, "node.set", json!({"id": mob, "props": {"opacity": 0.5, "padding": 24}}));
ex(&mut s, "node.set", json!({"id": desk, "props": {"paddingTop": 0, "paddingBottom": 0, "paddingLeft": 64, "paddingRight": 64}}));
let out = code(&mut s, desk, json!({"mobile": mob}));
let root = out.lines().nth(3).unwrap_or_default();
assert!(root.contains("opacity-50") && root.contains("lg:opacity-100"), "{root}");
assert!(root.contains("p-6") && root.contains("lg:px-16") && root.contains("lg:py-0"), "{root}");
}
#[test]
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<String> =
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"]);
}
+16
View File
@@ -1408,6 +1408,22 @@ permissions: without it nothing is downloaded); `plugin.directory.publish {path
### Dev Mode
- **Pages as React + Tailwind:** `inspect.code {id, language: "tailwind"}` writes a layer tree as a
React function component styled with Tailwind CSS v4 utilities and no stylesheet. Auto layout
becomes `flex`/`flex-col`/`gap-*`/`p-*` on the 4px spacing scale, fixed-width auto layout frames
and text become fluid (`w-full max-w-[960px]`; as wide as the frame: `w-full`) and a frame's
fixed height a minimum, per-side strokes become `border-b` and the like, and instances are
inlined with their overrides (component code is `language: react`), except instances whose component
is connected to code (`codeConnect.set`/`codeConnect.import`, React): those are their snippet, e.g.
`<Button variant="outline">{"See examples"}</Button>`, 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 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-<collection>="<mode>"]`. With `mobile: <frame id>` (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