mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-26 11:26:50 -06:00
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:
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user