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 <noreply@anthropic.com>
This commit is contained in:
parent
77a0e33b61
commit
b02ac0a115
|
|
@ -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%).
|
||||
|
|
|
|||
|
|
@ -11,18 +11,24 @@ use crate::params::*;
|
|||
use crate::{bad, spec};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
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<CommandSpec> {
|
|||
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>) -> 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::<String>();
|
||||
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', "<br>")
|
||||
}
|
||||
|
||||
/// 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<style>\n{}</style>\n", out.markup, out.styles)
|
||||
}
|
||||
|
||||
/// HTML output being built: the class names taken, the markup and the stylesheet.
|
||||
struct Html {
|
||||
used: Vec<String>,
|
||||
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}<p class=\"{class}\">{}</p>\n", html_escape(&t.characters))),
|
||||
_ => {
|
||||
let kids = kids(doc, n);
|
||||
if kids.is_empty() || depth >= MAX_DEPTH {
|
||||
self.markup.push_str(&format!("{pad}<div class=\"{class}\"></div>\n"));
|
||||
} else {
|
||||
self.markup.push_str(&format!("{pad}<div class=\"{class}\">\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}</div>\n"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ SwiftUI
|
||||
|
||||
fn swift_color(c: Color) -> String {
|
||||
|
|
|
|||
|
|
@ -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 & <bye>", "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("<div class=\"card\">"), "{code}");
|
||||
assert!(code.contains("<div class=\"row\"></div>") && code.contains("<div class=\"row-2\"></div>"), "unique classes: {code}");
|
||||
assert!(code.contains("& <bye></p>"), "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("</style>"));
|
||||
|
||||
// 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("<style>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn class_names_are_valid_and_deep_trees_are_bounded() {
|
||||
let mut s = Session::new();
|
||||
let mut parent = id(s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 100, "height": 100})).unwrap());
|
||||
s.execute("node.set", &json!({"id": parent, "props": {"name": "1 Ünïcode / Name!!"}})).unwrap();
|
||||
let root = parent;
|
||||
for _ in 0..30 {
|
||||
parent = id(s.execute("frame.create", &json!({"x": 1, "y": 1, "width": 90, "height": 90, "parent": parent})).unwrap());
|
||||
}
|
||||
let code = code_for(s.doc().unwrap(), root, CodeLang::Html);
|
||||
assert!(code.starts_with("<div class=\"layer-1-n-code-name\">"), "{}", code.lines().next().unwrap());
|
||||
// Nesting stops at the depth limit instead of running away.
|
||||
assert!(code.matches("<div").count() <= 14, "{}", code.matches("<div").count());
|
||||
}
|
||||
|
|
@ -881,6 +881,8 @@ mod devinspect_tests;
|
|||
#[cfg(test)]
|
||||
mod frame_tests;
|
||||
#[cfg(test)]
|
||||
mod html_codegen_tests;
|
||||
#[cfg(test)]
|
||||
mod import_export_tests;
|
||||
#[cfg(test)]
|
||||
mod mask_tests;
|
||||
|
|
|
|||
|
|
@ -100,7 +100,7 @@ Or in a project's `.mcp.json`:
|
|||
| Tool | What it does |
|
||||
|---|---|
|
||||
| `list_commands {filter?}` | Every command: id, label, menu, shortcut, undoable, params doc. |
|
||||
| `run_command {command, params}` | Run any command (e.g. `align.left`, `layout.add`, `component.create`, `instance.place` (drop an instance at a page point into the frame or auto layout slot under it), `component.exposeInstance` / `instance.exposed` (nested instance properties on instances), `inspect.code` (CSS, SwiftUI or Jetpack Compose for a layer), `inspect.properties` (layout, auto layout, typography, fills, strokes and effects with their style and variable names), `inspect.assets` (layers under the selection with export settings), `style.create`, `variable.bind`, `variable.update` (rename, describe, scope), `variable.set` with `{alias}`, `variable.renameMode` / `variable.removeMode`, `variable.deleteCollection`, `proto.interaction.add`). |
|
||||
| `run_command {command, params}` | Run any command (e.g. `align.left`, `layout.add`, `component.create`, `instance.place` (drop an instance at a page point into the frame or auto layout slot under it), `component.exposeInstance` / `instance.exposed` (nested instance properties on instances), `inspect.code` (CSS, HTML + CSS for a whole frame, SwiftUI or Jetpack Compose for a layer), `inspect.properties` (layout, auto layout, typography, fills, strokes and effects with their style and variable names), `inspect.assets` (layers under the selection with export settings), `style.create`, `variable.bind`, `variable.update` (rename, describe, scope), `variable.set` with `{alias}`, `variable.renameMode` / `variable.removeMode`, `variable.deleteCollection`, `proto.interaction.add`). |
|
||||
| `batch {commands: [{command, params}], label?}` | Several commands as one undo step (`command.batch`); all-or-nothing. |
|
||||
| `create_node {type, x, y, width, height, parent, index, name, text, path, props}` | Create a frame, rectangle, ellipse, polygon, star, line, arrow, text, vector, section, component or slice; returns `{id, node}`. |
|
||||
| `set_properties {ids, props}` | `node.set` on layers (geometry, fills, strokes, effects, corner radius, auto layout, sizing, constraints, masks (`isMask`, `maskType: alpha\|vector\|luminance`), handoff status (`devStatus: none\|readyForDev\|completed`), text…); returns the updated layers. |
|
||||
|
|
|
|||
Loading…
Reference in New Issue