From b02ac0a1151305e6752a33e3ceb7b050a8d20de8 Mon Sep 17 00:00:00 2001 From: BFlat Date: Sat, 10 Oct 2026 06:57:35 +0700 Subject: [PATCH] M6: HTML + CSS code for whole frames (#50) The Inspect tab's code menu gets "HTML + CSS": the selected layer and everything under it as nested elements with a stylesheet. Auto layout frames become flexboxes (fill children flex or stretch, hugging sides drop their fixed size), other children are placed absolutely in their parent, text becomes escaped paragraphs, and each layer gets a unique class named after it. inspect.code takes `language: "html"`. Co-authored-by: Claude Opus 5.5 --- ROADMAP.md | 2 +- crates/engine/src/codegen.rs | 147 +++++++++++++++++++++--- crates/engine/src/html_codegen_tests.rs | 59 ++++++++++ crates/engine/src/lib.rs | 2 + docs/mcp.md | 2 +- 5 files changed, 196 insertions(+), 16 deletions(-) create mode 100644 crates/engine/src/html_codegen_tests.rs diff --git a/ROADMAP.md b/ROADMAP.md index 48263d4..88fb822 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -172,7 +172,7 @@ Scores by behaviour, 0–100%. Hours are what remains (one agent). | Styles & variables | 5 | 72% | binding effects, line height and letter spacing, variable groups in the editor sidebar | 3–5 | | Prototyping | 6 | 76% | device rotation, presentation scaling options, scroll momentum | 1–3 | | Import/export | 6 | 58% | PDF (in progress), .fig import, REST import of conditional and variable-mode prototype actions | 13–25 | -| Dev mode / inspect | 2 | 72% | code for whole frames as components | 1–3 | +| Dev mode / inspect | 2 | 78% | component code with properties as parameters, visual QA | 2–3 | | Comments, versions, misc | 3 | 45% | comment polish, plugins (WASM) | 9–15 | Weighted feature depth ≈ **58%** (self-graded; the table's arithmetic gives ~59%). diff --git a/crates/engine/src/codegen.rs b/crates/engine/src/codegen.rs index 34e0307..2d5ac21 100644 --- a/crates/engine/src/codegen.rs +++ b/crates/engine/src/codegen.rs @@ -11,18 +11,24 @@ use crate::params::*; use crate::{bad, spec}; pub fn specs() -> Vec { - vec![spec!("inspect.code", "Code for layer", "{id?, language?: css|swiftui|compose (default css)} → {code}", false, |s, p| { - let id = targets(s, p).first().copied().ok_or_else(|| bad("inspect.code", "select a layer"))?; - let lang = match string(p, "language") { - Some(l) => CodeLang::from_id(&l).ok_or_else(|| bad("inspect.code", "language: css|swiftui|compose"))?, - None => CodeLang::Css, - }; - let doc = s.doc()?; - if !doc.contains(id) { - return Err(bad("inspect.code", "no such layer")); + vec![spec!( + "inspect.code", + "Code for layer", + "{id?, language?: css|html|swiftui|compose (default css; html is the whole layer tree)} → {code}", + false, + |s, p| { + let id = targets(s, p).first().copied().ok_or_else(|| bad("inspect.code", "select a layer"))?; + let lang = match string(p, "language") { + Some(l) => CodeLang::from_id(&l).ok_or_else(|| bad("inspect.code", "language: css|swiftui|compose"))?, + None => CodeLang::Css, + }; + let doc = s.doc()?; + if !doc.contains(id) { + return Err(bad("inspect.code", "no such layer")); + } + Ok(json!({"code": code_for(doc, id, lang), "language": lang.id()})) } - Ok(json!({"code": code_for(doc, id, lang), "language": lang.id()})) - })] + )] } /// Languages the Inspect panel offers. @@ -30,15 +36,18 @@ pub fn specs() -> Vec { pub enum CodeLang { #[default] Css, + /// The whole layer tree as nested HTML elements with a stylesheet. + Html, SwiftUi, Compose, } impl CodeLang { - pub const ALL: [CodeLang; 3] = [CodeLang::Css, CodeLang::SwiftUi, CodeLang::Compose]; + pub const ALL: [CodeLang; 4] = [CodeLang::Css, CodeLang::Html, CodeLang::SwiftUi, CodeLang::Compose]; pub fn id(self) -> &'static str { match self { CodeLang::Css => "css", + CodeLang::Html => "html", CodeLang::SwiftUi => "swiftui", CodeLang::Compose => "compose", } @@ -46,6 +55,7 @@ impl CodeLang { pub fn label(self) -> &'static str { match self { CodeLang::Css => "CSS", + CodeLang::Html => "HTML + CSS", CodeLang::SwiftUi => "SwiftUI", CodeLang::Compose => "Compose", } @@ -71,6 +81,7 @@ fn num(v: f64) -> String { pub fn code_for(doc: &Document, id: NodeId, lang: CodeLang) -> String { match lang { CodeLang::Css => css(doc, id), + CodeLang::Html => html(doc, id), CodeLang::SwiftUi => { let mut out = String::new(); swift(doc, id, 0, &mut out); @@ -142,8 +153,12 @@ fn indent(depth: usize) -> String { fn css(doc: &Document, id: NodeId) -> String { let Some(n) = doc.node(id) else { return String::new() }; - let mut out = format!("/* {} */\n", n.name.replace("*/", "* /")); - out.push_str(&format!("width: {}px;\nheight: {}px;\n", num(n.width), num(n.height))); + format!("/* {} */\n{}", n.name.replace("*/", "* /"), css_decls(n)) +} + +/// The CSS declarations for `n`'s own look and size, one per line. +fn css_decls(n: &Node) -> String { + let mut out = format!("width: {}px;\nheight: {}px;\n", num(n.width), num(n.height)); if let Some(l) = &n.layout { out.push_str("display: flex;\n"); out.push_str(&format!("flex-direction: {};\n", if l.mode == LayoutMode::Vertical { "column" } else { "row" })); @@ -242,6 +257,110 @@ fn css(doc: &Document, id: NodeId) -> String { out } +// ------------------------------------------------------------------ HTML + +/// A CSS class name from a layer name ("Card / Title" → "card-title"), unique in `used`. +fn class_name(name: &str, used: &mut Vec) -> String { + let mut base = String::new(); + for c in name.chars().flat_map(char::to_lowercase) { + if c.is_ascii_alphanumeric() { + base.push(c); + } else if !base.ends_with('-') && !base.is_empty() { + base.push('-'); + } + } + let base = base.trim_end_matches('-').chars().take(40).collect::(); + let base = if base.is_empty() || base.starts_with(|c: char| c.is_ascii_digit()) { format!("layer-{base}") } else { base }; + let name = (1..).map(|k| if k == 1 { base.clone() } else { format!("{base}-{k}") }).find(|n| !used.contains(n)).unwrap_or(base); + used.push(name.clone()); + name +} + +fn html_escape(s: &str) -> String { + s.replace('&', "&").replace('<', "<").replace('>', ">").replace('\n', "
") +} + +/// The whole tree under `id` as nested HTML with a stylesheet: auto layout frames become +/// flexboxes, other children are placed absolutely in their parent, text becomes paragraphs. +fn html(doc: &Document, id: NodeId) -> String { + let mut out = Html { used: Vec::new(), markup: String::new(), styles: String::new() }; + out.node(doc, id, None, 0); + format!("{}\n\n", out.markup, out.styles) +} + +/// HTML output being built: the class names taken, the markup and the stylesheet. +struct Html { + used: Vec, + markup: String, + styles: String, +} + +impl Html { + fn node(&mut self, doc: &Document, id: NodeId, parent: Option<&Node>, depth: usize) { + let Some(n) = doc.node(id) else { return }; + let class = class_name(&n.name, &mut self.used); + let pad = indent(depth); + // How it sits in its parent: in the flex flow, or placed at its position. + let mut decls = String::new(); + let in_flow = parent.is_some_and(|p| p.layout.is_some()) && !n.child_layout.absolute; + if depth == 0 { + decls.push_str("position: relative;\n"); + } else if in_flow { + let vertical = parent.and_then(|p| p.layout.as_ref()).is_some_and(|l| l.mode == LayoutMode::Vertical); + let (main, cross) = + if vertical { (n.child_layout.sizing_v, n.child_layout.sizing_h) } else { (n.child_layout.sizing_h, n.child_layout.sizing_v) }; + if main == Sizing::Fill { + decls.push_str("flex: 1 1 0;\n"); + } + if cross == Sizing::Fill { + decls.push_str("align-self: stretch;\n"); + } + } else { + let c = n.transform.as_coeffs(); + decls.push_str(&format!("position: absolute;\nleft: {}px;\ntop: {}px;\n", num(c[4]), num(c[5]))); + let rot = figcraft_geom::rotation_degrees(n.transform); + if rot.abs() > 0.01 { + decls.push_str(&format!("transform: rotate({}deg);\ntransform-origin: 0 0;\n", num(-rot))); + } + } + let mut own = css_decls(n); + if n.layout.is_none() && !n.children.is_empty() && depth > 0 && in_flow { + own.push_str("position: relative;\n"); + } + // Hugging sides take their content's size. + if n.layout.is_some() || matches!(n.kind, NodeKind::Text(_)) { + if n.child_layout.sizing_h == Sizing::Hug { + own = own.replace(&format!("width: {}px;\n", num(n.width)), ""); + } + if n.child_layout.sizing_v == Sizing::Hug { + own = own.replace(&format!("height: {}px;\n", num(n.height)), ""); + } + } + decls.push_str(&own); + self.styles.push_str(&format!(".{class} {{\n")); + for line in decls.lines() { + self.styles.push_str(&format!(" {line}\n")); + } + self.styles.push_str("}\n"); + match &n.kind { + NodeKind::Text(t) => self.markup.push_str(&format!("{pad}

{}

\n", html_escape(&t.characters))), + _ => { + let kids = kids(doc, n); + if kids.is_empty() || depth >= MAX_DEPTH { + self.markup.push_str(&format!("{pad}
\n")); + } else { + self.markup.push_str(&format!("{pad}
\n")); + // Bottom layer first: later elements paint on top, like the layer order. + for k in kids { + self.node(doc, k, Some(n), depth + 1); + } + self.markup.push_str(&format!("{pad}
\n")); + } + } + } + } +} + // ------------------------------------------------------------------ SwiftUI fn swift_color(c: Color) -> String { diff --git a/crates/engine/src/html_codegen_tests.rs b/crates/engine/src/html_codegen_tests.rs new file mode 100644 index 0000000..e93e88c --- /dev/null +++ b/crates/engine/src/html_codegen_tests.rs @@ -0,0 +1,59 @@ +//! Code for whole frames: nested HTML with a stylesheet (flexbox for auto layout, absolute +//! positions elsewhere), unique class names, escaped text. + +use serde_json::{Value, json}; + +use crate::Session; +use crate::codegen::{CodeLang, code_for}; +use crate::params::id_of; + +fn id(v: Value) -> figcraft_doc::NodeId { + id_of(&v["id"]).unwrap() +} + +#[test] +fn frames_become_nested_html_with_flexbox_and_absolute_children() { + let mut s = Session::new(); + let card = id(s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 200, "height": 120})).unwrap()); + s.execute("node.set", &json!({"id": card, "props": {"name": "Card"}})).unwrap(); + let a = id(s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 50, "height": 20, "parent": card})).unwrap()); + let b = id(s.execute("shape.rectangle", &json!({"x": 0, "y": 30, "width": 50, "height": 20, "parent": card})).unwrap()); + for r in [a, b] { + s.execute("node.set", &json!({"id": r, "props": {"name": "Row", "fill": "#0D99FF"}})).unwrap(); + } + let t = id(s.execute("text.create", &json!({"text": "Hi & ", "x": 0, "y": 60})).unwrap()); + s.execute("node.reparent", &json!({"ids": [t], "parent": card})).unwrap(); + s.execute("layout.add", &json!({"ids": [card]})).unwrap(); + s.execute("node.set", &json!({"id": card, "props": {"layoutMode": "vertical", "gap": 8}})).unwrap(); + + let code = code_for(s.doc().unwrap(), card, CodeLang::Html); + assert!(code.starts_with("
"), "{code}"); + assert!(code.contains("
") && code.contains("
"), "unique classes: {code}"); + assert!(code.contains("& <bye>

"), "escaped text: {code}"); + assert!(code.contains(".card {\n position: relative;\n") && code.contains("display: flex;") && code.contains("flex-direction: column;")); + assert!(!code.contains(".row {\n position: absolute"), "flex items flow"); + assert!(code.trim_end().ends_with("")); + + // Without auto layout, children sit at their positions. + s.execute("layout.remove", &json!({"ids": [card]})).unwrap(); + let code = code_for(s.doc().unwrap(), card, CodeLang::Html); + assert!(code.contains(".row-2 {\n position: absolute;\n left: "), "{code}"); + // The command takes the language by id. + let out = s.execute("inspect.code", &json!({"id": card, "language": "html"})).unwrap(); + assert!(out["code"].as_str().unwrap().contains("