diff --git a/crates/engine/src/cmd/variables_io.rs b/crates/engine/src/cmd/variables_io.rs index 0f55ea2c..eee312c2 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,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-=""]`; /// 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"); } diff --git a/crates/engine/src/codegen.rs b/crates/engine/src/codegen.rs index 0a4a5d2e..c26084f5 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,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, +} 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 `"}), + ); + 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 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 + 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