openpencil/crates/op-codegen/src/lib.rs

736 lines
26 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! Code generation — Rust ports of `packages/pen-codegen/src/`.
//! v1 ships the simplest of the 9 TS generators: `css-variables`,
//! which emits the design's variable table as CSS custom properties.
//! Future generators (React + Tailwind, HTML, Vue, Svelte, Flutter,
//! SwiftUI, Compose, React Native) live behind the same `Codegen`
//! trait so a single dispatcher (eventually a `op codegen <target>`
//! CLI) can fan out by target enum.
//!
//! All generators consume the canonical `jian_ops_schema::PenDocument`
//! and walk `PenNode` via `op_editor_core::PenNodeExt`. The spine here
//! holds the `Codegen` trait, the `CssVariables` generator, shared
//! node-accessor helpers, and the test suite; per-framework markup
//! generators live in the sibling `codegen_targets` module.
/// AI-driven code-generation pipeline (planning → chunk → assembly).
/// Gated behind the `ai` feature so the deterministic generators stay
/// dependency-light.
#[cfg(feature = "ai")]
pub mod ai;
mod codegen_targets;
pub use crate::codegen_targets::{
Compose, Flutter, Html, React, ReactNative, Svelte, SwiftUi, Vue,
};
use jian_ops_schema::node::PenNode;
use jian_ops_schema::style::PenFill;
use jian_ops_schema::variable::{VariableScalar, VariableValue};
use jian_ops_schema::PenDocument;
use op_editor_core::pen_node_ext::PenNodeExt;
/// One generator. Implementations are pure (read `PenDocument`,
/// return `String`); the CLI / panel layer owns file-write side
/// effects.
pub trait Codegen {
fn target_label(&self) -> &'static str;
fn generate(&self, doc: &PenDocument) -> String;
}
/// CSS custom properties generator. Walks `doc.variables` and emits
/// `:root { --name: value; }`. Color variables stay as their hex
/// string; number/bool/string variables stringify their scalar.
/// Themed values emit ONE entry per theme combination using a
/// `:root[data-axis="value"]` selector so the same CSS reflows
/// across theme switches with no JS.
pub struct CssVariables;
impl Codegen for CssVariables {
fn target_label(&self) -> &'static str {
"css-variables"
}
fn generate(&self, doc: &PenDocument) -> String {
let mut out = String::new();
out.push_str("/* Generated by OpenPencil — codegen::CssVariables */\n");
let Some(variables) = doc.variables.as_ref() else {
return out;
};
// Pass 1: scalar (theme-agnostic) variables go under :root.
let mut root_scalars: Vec<(String, String)> = Vec::new();
for (name, def) in variables {
if let VariableValue::Scalar(s) = &def.value {
root_scalars.push((name.clone(), scalar_to_css(s)));
}
}
if !root_scalars.is_empty() {
out.push_str(":root {\n");
for (name, value) in &root_scalars {
out.push_str(&format!(" --{}: {};\n", css_ident(name), value));
}
out.push_str("}\n");
}
// Pass 2: themed variables → one block per (axis, value) combo.
// Group entries by their theme map signature so a "mode=dark"
// entry from variable A and another from B share one block.
use std::collections::BTreeMap;
// Maps a theme-map signature (sorted axis/value pairs) to the
// (variable-name, css-value) entries that share that signature.
type ThemeSignature = Vec<(String, String)>;
type ThemedBlock = Vec<(String, String)>;
let mut themed: BTreeMap<ThemeSignature, ThemedBlock> = BTreeMap::new();
for (name, def) in variables {
if let VariableValue::Themed(entries) = &def.value {
for e in entries {
let key: Vec<(String, String)> = e
.theme
.as_ref()
.map(|m| m.iter().map(|(k, v)| (k.clone(), v.clone())).collect())
.unwrap_or_default();
themed
.entry(key)
.or_default()
.push((name.clone(), scalar_to_css(&e.value)));
}
}
}
for (axes, decls) in &themed {
if axes.is_empty() {
out.push_str(":root {\n");
} else {
let sel = axes
.iter()
.map(|(k, v)| format!("[data-{}=\"{}\"]", k, v))
.collect::<Vec<_>>()
.join("");
out.push_str(&format!(":root{} {{\n", sel));
}
for (name, value) in decls {
out.push_str(&format!(" --{}: {};\n", css_ident(name), value));
}
out.push_str("}\n");
}
out
}
}
// --- Shared node-accessor helpers (canonical-schema flavour) -------
//
// shell-core's flat `Node` exposed `bounds` / `fill` / `stroke` /
// `corner_radius` / `text` / `hidden` directly. The canonical
// `PenNode` is a 12-variant enum: these helpers give the markup
// generators the same flat view through `PenNodeExt`.
/// Top-level node forest the generators walk: the first page's
/// children when the document is paged, otherwise the bare
/// `children` fallback.
pub(crate) fn root_nodes(doc: &PenDocument) -> &[PenNode] {
match doc.pages.as_ref().and_then(|p| p.first()) {
Some(page) => &page.children,
None => &doc.children,
}
}
/// True when the node is explicitly hidden (`visible == false`).
/// A missing `visible` flag means shown.
pub(crate) fn node_hidden(node: &PenNode) -> bool {
node.base().visible == Some(false)
}
/// Document-space origin of the node (`base.x` / `base.y`, 0 default).
pub(crate) fn node_origin(node: &PenNode) -> (f64, f64) {
let base = node.base();
(base.x.unwrap_or(0.0), base.y.unwrap_or(0.0))
}
/// Literal pixel size of the node. Keyword / expression sizing is not
/// a concrete number, so it reads as 0 — same degrade the TS
/// generators apply for `fit_content` / `$var` widths.
pub(crate) fn node_size(node: &PenNode) -> (f64, f64) {
match node {
PenNode::Line(n) => {
// A line has no width/height field — derive its box from
// the (x,y)→(x2,y2) span so absolute layout still works.
let base = node.base();
let x1 = base.x.unwrap_or(0.0);
let y1 = base.y.unwrap_or(0.0);
let x2 = n.x2.unwrap_or(x1);
let y2 = n.y2.unwrap_or(y1);
((x2 - x1).abs(), (y2 - y1).abs())
}
_ => (
node.width_px().unwrap_or(0.0),
node.height_px().unwrap_or(0.0),
),
}
}
/// Node rotation in radians (`base.rotation`, 0 default). The
/// canonical schema stores rotation in degrees; the editor / TS
/// renderer treat that field as degrees too, so emit it verbatim and
/// let the per-target helper pick `deg` vs `rad`.
pub(crate) fn node_rotation_deg(node: &PenNode) -> f64 {
node.base().rotation.unwrap_or(0.0)
}
/// Corner radius in document px. Uniform `CornerRadius` reads as-is;
/// per-corner radii collapse to their first value (CSS shorthand is
/// a follow-up). Variants without a corner-radius field read 0.
pub(crate) fn node_corner_radius(node: &PenNode) -> f64 {
use jian_ops_schema::node::CornerRadius;
let uniform = |c: &Option<CornerRadius>| match c {
Some(CornerRadius::Uniform(r)) => *r,
Some(CornerRadius::PerCorner(arr)) => arr.first().copied().unwrap_or(0.0),
None => 0.0,
};
match node {
PenNode::Frame(n) => uniform(&n.container.corner_radius),
PenNode::Group(n) => uniform(&n.container.corner_radius),
PenNode::Rectangle(n) => uniform(&n.container.corner_radius),
PenNode::Ellipse(n) => n.corner_radius.unwrap_or(0.0),
PenNode::Polygon(n) => n.corner_radius.unwrap_or(0.0),
_ => 0.0,
}
}
/// Children of the node (empty slice for leaf variants).
pub(crate) fn node_children(node: &PenNode) -> &[PenNode] {
node.children().map(|v| v.as_slice()).unwrap_or(&[])
}
/// True when the node renders as text (`Text` variant).
pub(crate) fn node_is_text(node: &PenNode) -> bool {
matches!(node, PenNode::Text(_))
}
/// True when the node renders as an ellipse.
pub(crate) fn node_is_ellipse(node: &PenNode) -> bool {
matches!(node, PenNode::Ellipse(_))
}
/// Plain-text body of a node. `Text` variants flatten their
/// `TextContent` (joining styled segments); every other variant has
/// no inline body.
pub(crate) fn node_text(node: &PenNode) -> Option<String> {
use jian_ops_schema::node::TextContent;
match node {
PenNode::Text(n) => Some(match &n.content {
TextContent::Plain(s) => s.clone(),
TextContent::Styled(segs) => segs.iter().map(|s| s.text.as_str()).collect::<String>(),
}),
PenNode::TextInput(n) => n.value.clone().or_else(|| n.placeholder.clone()),
_ => None,
}
}
/// First solid-fill color of a node as a `#hex` / `rgba()` string,
/// when the node carries one. Gradient / image fills are not a flat
/// color — they read as `None` (same degrade as the TS generators).
pub(crate) fn node_fill_css(node: &PenNode) -> Option<String> {
let fills = node_fills(node)?;
first_solid_color(fills)
}
/// Borrow the node's fill list, when its variant carries fills.
fn node_fills(node: &PenNode) -> Option<&Vec<PenFill>> {
match node {
PenNode::Frame(n) => n.container.fill.as_ref(),
PenNode::Group(n) => n.container.fill.as_ref(),
PenNode::Rectangle(n) => n.container.fill.as_ref(),
PenNode::Ellipse(n) => n.fill.as_ref(),
PenNode::Polygon(n) => n.fill.as_ref(),
PenNode::Path(n) => n.fill.as_ref(),
PenNode::Text(n) => n.fill.as_ref(),
PenNode::TextInput(n) => n.fill.as_ref(),
PenNode::IconFont(n) => n.fill.as_ref(),
PenNode::Line(_) | PenNode::Image(_) | PenNode::Ref(_) => None,
}
}
/// First `Solid` fill's color string in a fill list.
fn first_solid_color(fills: &[PenFill]) -> Option<String> {
fills.iter().find_map(|f| match f {
PenFill::Solid(body) => Some(body.color.clone()),
_ => None,
})
}
/// Stroke description of a node: `(color, width)`. Present only when
/// the variant carries a `PenStroke` with a solid fill and a uniform
/// thickness. Per-side thickness collapses to its first value.
pub(crate) fn node_stroke_css(node: &PenNode) -> Option<(String, f32)> {
use jian_ops_schema::style::StrokeThickness;
let stroke = match node {
PenNode::Frame(n) => n.container.stroke.as_ref(),
PenNode::Group(n) => n.container.stroke.as_ref(),
PenNode::Rectangle(n) => n.container.stroke.as_ref(),
PenNode::Ellipse(n) => n.stroke.as_ref(),
PenNode::Polygon(n) => n.stroke.as_ref(),
PenNode::Path(n) => n.stroke.as_ref(),
PenNode::Line(n) => n.stroke.as_ref(),
PenNode::TextInput(n) => n.stroke.as_ref(),
PenNode::IconFont(n) => n.stroke.as_ref(),
PenNode::Text(_) | PenNode::Image(_) | PenNode::Ref(_) => None,
}?;
let width = match &stroke.thickness {
StrokeThickness::Uniform(w) => *w,
StrokeThickness::PerSide(arr) => arr.first().copied().unwrap_or(0.0),
StrokeThickness::Sided(s) => s.top.or(s.right).or(s.bottom).or(s.left).unwrap_or(0.0),
};
let color = stroke
.fill
.as_ref()
.and_then(|fills| first_solid_color(fills))
.unwrap_or_else(|| "#000000".to_string());
Some((color, width))
}
// --- CSS scalar / colour helpers -----------------------------------
/// Parse a `#rgb` / `#rrggbb` / `#rrggbbaa` / `rgba(...)` colour
/// string into normalized `(r, g, b, a)` floats in `0.0..=1.0`.
/// Returns `None` for anything not understood — callers fall back to
/// emitting the raw string verbatim, which is valid CSS for named
/// colours or `var(--ref)`.
pub(crate) fn parse_color(s: &str) -> Option<(f32, f32, f32, f32)> {
let t = s.trim();
if let Some(hex) = t.strip_prefix('#') {
let h = hex.as_bytes();
let nyb = |c: u8| -> Option<u8> {
match c {
b'0'..=b'9' => Some(c - b'0'),
b'a'..=b'f' => Some(c - b'a' + 10),
b'A'..=b'F' => Some(c - b'A' + 10),
_ => None,
}
};
let pair =
|hi: u8, lo: u8| -> Option<f32> { Some((nyb(hi)? * 16 + nyb(lo)?) as f32 / 255.0) };
return match h.len() {
3 => Some((pair(h[0], h[0])?, pair(h[1], h[1])?, pair(h[2], h[2])?, 1.0)),
6 => Some((pair(h[0], h[1])?, pair(h[2], h[3])?, pair(h[4], h[5])?, 1.0)),
8 => Some((
pair(h[0], h[1])?,
pair(h[2], h[3])?,
pair(h[4], h[5])?,
pair(h[6], h[7])?,
)),
_ => None,
};
}
None
}
/// Normalize a colour string for emission as a CSS value. Hex /
/// rgba inputs round-trip through `parse_color`; anything else
/// (named colour, `var(--ref)`) passes through verbatim.
pub(crate) fn color_to_css(s: &str) -> String {
match parse_color(s) {
Some((r, g, b, a)) => {
let to_u8 = |c: f32| (c.clamp(0.0, 1.0) * 255.0).round() as u8;
if a >= 0.999 {
format!("rgb({},{},{})", to_u8(r), to_u8(g), to_u8(b))
} else {
format!("rgba({},{},{},{:.3})", to_u8(r), to_u8(g), to_u8(b), a)
}
}
None => s.to_string(),
}
}
pub(crate) fn html_escape(s: &str) -> String {
s.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
}
fn scalar_to_css(s: &VariableScalar) -> String {
match s {
VariableScalar::Bool(b) => b.to_string(),
VariableScalar::Num(n) => fmt_num(*n),
VariableScalar::Str(s) => s.clone(),
}
}
/// Format an `f64` the way the TS generators print numbers — drop a
/// trailing `.0` so `12.0` renders as `12`.
pub(crate) fn fmt_num(n: f64) -> String {
if n.fract() == 0.0 && n.is_finite() {
format!("{}", n as i64)
} else {
format!("{}", n)
}
}
fn css_ident(name: &str) -> String {
name.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '-' {
c
} else {
'-'
}
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use jian_ops_schema::node::{EllipseNode, PenNodeBase, RectangleNode, TextContent, TextNode};
use jian_ops_schema::page::PenPage;
use jian_ops_schema::sizing::SizingBehavior;
use jian_ops_schema::style::{PenFill, SolidFillBody};
use jian_ops_schema::variable::{
ThemedValue, VariableDefinition, VariableKind, VariableScalar, VariableValue,
};
use std::collections::BTreeMap;
/// Bare empty document — no variables, no pages, no children.
fn empty_doc() -> PenDocument {
PenDocument {
version: "0.8.0".into(),
name: None,
themes: None,
variables: None,
pages: None,
children: Vec::new(),
format_version: None,
id: None,
app: None,
routes: None,
state: None,
lifecycle: None,
logic_modules: None,
design_md: None,
}
}
/// Replace the document's node forest with a single page holding
/// `nodes`, mirroring the old `page.children` test helper.
fn doc_with_nodes(nodes: Vec<PenNode>) -> PenDocument {
let mut doc = empty_doc();
doc.pages = Some(vec![PenPage {
id: "p1".into(),
name: "Page 1".into(),
children: nodes,
state: None,
lifecycle: None,
}]);
doc
}
fn axis(name: &str, value: &str) -> BTreeMap<String, String> {
let mut m = BTreeMap::new();
m.insert(name.to_string(), value.to_string());
m
}
fn solid(color: &str) -> Vec<PenFill> {
vec![PenFill::Solid(SolidFillBody {
color: color.into(),
explain: None,
opacity: None,
blend_mode: None,
})]
}
/// A rectangle at `(x,y)` sized `w×h` with an optional fill.
fn rect(id: &str, x: f64, y: f64, w: f64, h: f64, fill: Option<&str>) -> PenNode {
let container = jian_ops_schema::node::ContainerProps {
width: Some(SizingBehavior::Number(w)),
height: Some(SizingBehavior::Number(h)),
fill: fill.map(solid),
..Default::default()
};
PenNode::Rectangle(RectangleNode {
base: PenNodeBase {
id: id.into(),
x: Some(x),
y: Some(y),
..Default::default()
},
container,
children: None,
state: None,
bindings: None,
events: None,
lifecycle: None,
semantics: None,
gestures: None,
route: None,
})
}
fn ellipse(id: &str, x: f64, y: f64, w: f64, h: f64) -> PenNode {
PenNode::Ellipse(EllipseNode {
base: PenNodeBase {
id: id.into(),
x: Some(x),
y: Some(y),
..Default::default()
},
width: Some(SizingBehavior::Number(w)),
height: Some(SizingBehavior::Number(h)),
corner_radius: None,
inner_radius: None,
start_angle: None,
sweep_angle: None,
fill: None,
stroke: None,
effects: None,
state: None,
bindings: None,
events: None,
lifecycle: None,
semantics: None,
gestures: None,
route: None,
})
}
fn text(id: &str, x: f64, y: f64, w: f64, h: f64, body: &str) -> PenNode {
PenNode::Text(TextNode {
base: PenNodeBase {
id: id.into(),
x: Some(x),
y: Some(y),
..Default::default()
},
width: Some(SizingBehavior::Number(w)),
height: Some(SizingBehavior::Number(h)),
content: TextContent::Plain(body.into()),
font_family: None,
font_size: None,
font_weight: None,
font_style: None,
letter_spacing: None,
line_height: None,
text_align: None,
text_align_vertical: None,
text_growth: None,
underline: None,
strikethrough: None,
fill: None,
effects: None,
state: None,
bindings: None,
events: None,
lifecycle: None,
semantics: None,
gestures: None,
route: None,
})
}
fn color_def(value: &str) -> VariableDefinition {
VariableDefinition {
kind: VariableKind::Color,
value: VariableValue::Scalar(VariableScalar::Str(value.into())),
}
}
#[test]
fn css_variables_emits_root_scalars() {
let mut doc = empty_doc();
let mut vars = BTreeMap::new();
vars.insert("primary".to_string(), color_def("#0066ff"));
vars.insert(
"spacing".to_string(),
VariableDefinition {
kind: VariableKind::Number,
value: VariableValue::Scalar(VariableScalar::Num(12.0)),
},
);
doc.variables = Some(vars);
let css = CssVariables.generate(&doc);
assert!(css.contains(":root {"));
assert!(css.contains("--primary: #0066ff;"));
assert!(css.contains("--spacing: 12;"));
}
#[test]
fn css_variables_emits_per_theme_block() {
let mut doc = empty_doc();
let mut vars = BTreeMap::new();
vars.insert(
"bg".to_string(),
VariableDefinition {
kind: VariableKind::Color,
value: VariableValue::Themed(vec![
ThemedValue {
value: VariableScalar::Str("#ffffff".into()),
theme: Some(axis("mode", "light")),
},
ThemedValue {
value: VariableScalar::Str("#000000".into()),
theme: Some(axis("mode", "dark")),
},
]),
},
);
doc.variables = Some(vars);
let css = CssVariables.generate(&doc);
assert!(css.contains(":root[data-mode=\"light\"]"));
assert!(css.contains(":root[data-mode=\"dark\"]"));
assert!(css.contains("--bg: #ffffff;"));
assert!(css.contains("--bg: #000000;"));
}
#[test]
fn css_variables_sanitises_non_ident_chars() {
let mut doc = empty_doc();
let mut vars = BTreeMap::new();
vars.insert("primary.color".to_string(), color_def("#f00"));
doc.variables = Some(vars);
let css = CssVariables.generate(&doc);
// Dot replaced with dash so the result is a valid CSS ident.
assert!(css.contains("--primary-color: #f00;"));
}
#[test]
fn html_emits_doctype_and_body() {
let doc = empty_doc();
let html = Html.generate(&doc);
assert!(html.contains("<!DOCTYPE html>"));
assert!(html.contains("<body>"));
assert!(html.contains("</body>"));
assert!(html.contains("OpenPencil"));
}
#[test]
fn html_emits_div_per_node_with_position_and_fill() {
let doc = doc_with_nodes(vec![rect("n10", 20.0, 30.0, 100.0, 50.0, Some("#ff0000"))]);
let html = Html.generate(&doc);
assert!(html.contains("left:20px"));
assert!(html.contains("top:30px"));
assert!(html.contains("width:100px"));
assert!(html.contains("rgb(255,0,0)"));
}
#[test]
fn html_emits_span_for_text_and_escapes_body() {
let doc = doc_with_nodes(vec![text("n10", 0.0, 0.0, 100.0, 24.0, "Hello & <world>")]);
let html = Html.generate(&doc);
assert!(html.contains("<span"));
assert!(html.contains("Hello &amp; &lt;world&gt;"));
}
#[test]
fn vue_emits_template_script_style_blocks() {
let doc = empty_doc();
let s = Vue.generate(&doc);
assert!(s.contains("<template>"));
assert!(s.contains("</template>"));
assert!(s.contains("<script setup"));
assert!(s.contains("<style scoped>"));
}
#[test]
fn svelte_emits_script_then_markup_then_style() {
let doc = empty_doc();
let s = Svelte.generate(&doc);
let script_pos = s.find("<script").unwrap();
let style_pos = s.find("<style>").unwrap();
// Script before style — Svelte SFC convention.
assert!(script_pos < style_pos);
}
#[test]
fn vue_includes_variable_css_in_style_block() {
let mut doc = empty_doc();
let mut vars = BTreeMap::new();
vars.insert("primary".to_string(), color_def("#abc"));
doc.variables = Some(vars);
let s = Vue.generate(&doc);
assert!(s.contains("--primary: #abc;"));
}
#[test]
fn react_emits_functional_component_with_jsx_fragment() {
let doc = empty_doc();
let s = React.generate(&doc);
assert!(s.contains("import React from 'react';"));
assert!(s.contains("export default function Page()"));
assert!(s.contains("<>"));
assert!(s.contains("</>"));
}
#[test]
fn flutter_emits_stack_with_positioned_children() {
let doc = doc_with_nodes(vec![rect("n10", 5.0, 10.0, 100.0, 50.0, Some("#ff0000"))]);
let s = Flutter.generate(&doc);
assert!(s.contains("import 'package:flutter/material.dart';"));
assert!(s.contains("Stack(children:"));
assert!(s.contains("Positioned(left: 5, top: 10"));
assert!(s.contains("Container(width: 100, height: 50"));
assert!(s.contains("Color.fromARGB(255,255,0,0)"));
}
#[test]
fn swiftui_emits_zstack_with_positioned_views() {
let doc = doc_with_nodes(vec![rect("n10", 0.0, 0.0, 100.0, 50.0, None)]);
let s = SwiftUi.generate(&doc);
assert!(s.contains("import SwiftUI"));
assert!(s.contains("ZStack {"));
assert!(s.contains("Rectangle()"));
assert!(s.contains(".frame(width: 100, height: 50)"));
}
#[test]
fn swiftui_emits_ellipse_for_ellipse_kind() {
let doc = doc_with_nodes(vec![ellipse("n10", 0.0, 0.0, 20.0, 20.0)]);
let s = SwiftUi.generate(&doc);
assert!(s.contains("Ellipse()"));
}
#[test]
fn compose_emits_composable_box_with_offset_size() {
let doc = doc_with_nodes(vec![rect("n10", 5.0, 10.0, 100.0, 50.0, Some("#ff0000"))]);
let s = Compose.generate(&doc);
assert!(s.contains("@Composable"));
assert!(s.contains("Modifier.offset(x = 5.dp, y = 10.dp)"));
assert!(s.contains("size(width = 100.dp, height = 50.dp)"));
assert!(s.contains("Color(255, 0, 0, 255)"));
}
#[test]
fn react_native_emits_view_tree_with_absolute_positions() {
let doc = doc_with_nodes(vec![rect("n10", 0.0, 0.0, 80.0, 40.0, None)]);
let s = ReactNative.generate(&doc);
assert!(s.contains("import { View, Text } from 'react-native';"));
assert!(s.contains("export default function Page()"));
assert!(s.contains("<View style={{"));
assert!(s.contains("position: 'absolute'"));
assert!(s.contains("width: 80, height: 40"));
}
#[test]
fn html_skips_hidden_nodes() {
let mut hidden = rect("n10", 0.0, 0.0, 10.0, 10.0, None);
hidden.base_mut().visible = Some(false);
hidden.base_mut().name = Some("hidden".into());
let doc = doc_with_nodes(vec![hidden]);
let html = Html.generate(&doc);
assert!(!html.contains("hidden"));
}
#[test]
fn css_variables_empty_doc_emits_header_only() {
let doc = empty_doc();
let css = CssVariables.generate(&doc);
assert!(css.contains("Generated by OpenPencil"));
assert!(!css.contains(":root"));
}
#[test]
fn falls_back_to_bare_children_when_unpaged() {
let mut doc = empty_doc();
doc.children = vec![rect("n10", 1.0, 2.0, 30.0, 40.0, None)];
let html = Html.generate(&doc);
assert!(html.contains("left:1px"));
assert!(html.contains("width:30px"));
}
}