Skip to main content

pathland_render_html/
lib.rs

1//! # pathland-render-html
2//!
3//! A Pathland renderer that maps opcode frames onto **declarative HTML**
4//! elements. Like every Pathland renderer it is a pure function of the opcode
5//! stream: it retains only its own decoded output tree (a cache), never
6//! application state, and emits `WHAT` the UI is — never layout rects. Native
7//! browser layout does the positioning.
8//!
9//! This is the "server-side render" / remote-projection target (Goal #15): the
10//! same opcode stream that drives a native backend drives this HTML document.
11//!
12//! Frames are **self-contained**: `apply(opcodes, strings)` resolves `SET_TEXT`
13//! by a *relative* offset into the frame's own string section (no mirrored
14//! arena), and every rendered element carries a stable `data-pathland-id` so a
15//! client can hydrate it and apply later deltas in place.
16
17use std::collections::BTreeMap;
18
19use pathland_core::{
20    Opcode, border_edges, category, component_type, parameter, property_id, size,
21    tokens::TokenValue, tree, value_type,
22};
23
24mod capi;
25mod css;
26mod debug;
27pub mod icons;
28pub mod role_spec;
29pub mod token_spec;
30
31/// The renderer-owned default typography scale for a `TEXT_STYLE` code
32/// (spec/MODIFIERS.md §2): `(size_px, weight_100_900)`. `None` for an unknown
33/// code. Explicit `FONT_SIZE`/`FONT_WEIGHT` override these.
34fn text_style_scale(code: u8) -> Option<(f32, f32)> {
35    Some(match code {
36        0 => (34.0, 700.0),  // LargeTitle
37        1 => (28.0, 700.0),  // Title
38        2 => (22.0, 700.0),  // Title2
39        3 => (20.0, 600.0),  // Title3
40        4 => (17.0, 600.0),  // Headline
41        5 => (15.0, 400.0),  // Subheadline
42        6 => (15.0, 400.0),  // Body
43        7 => (14.0, 400.0),  // Callout
44        8 => (13.0, 400.0),  // Footnote
45        9 => (12.0, 400.0),  // Caption
46        10 => (11.0, 400.0), // Caption2
47        _ => return None,
48    })
49}
50
51/// A generic CSS family for a `FONT_DESIGN` code (Default → the renderer's UI
52/// font, i.e. no override).
53fn font_design_family(code: u8) -> Option<&'static str> {
54    match code {
55        1 => Some("serif"),
56        2 => Some("sans-serif"),
57        3 => Some("ui-monospace, SFMono-Regular, Menlo, monospace"),
58        _ => None,
59    }
60}
61
62/// A decoded node in the retained description.
63#[derive(Debug, Clone, PartialEq)]
64pub struct Node {
65    /// Protocol component type id (see `pathland_core::component_type`).
66    pub component: u16,
67    /// Text content set via `PARAMETER::SET_TEXT`, if any.
68    pub text: Option<String>,
69    /// Constraint/style properties (`propertyId → value`).
70    pub properties: BTreeMap<u16, u32>,
71    /// `STRING`-typed properties resolved at apply time (`propertyId → text`).
72    pub strings: BTreeMap<u16, String>,
73    /// `DESIGN_TOKEN`-typed properties (`propertyId → token path`).
74    pub token_refs: BTreeMap<u16, String>,
75    /// Date value from `PARAMETER::SET_DATE`: (days since epoch, millis of day).
76    pub date: Option<(i32, u32)>,
77    /// Child node ids in insertion order.
78    pub children: Vec<u32>,
79    /// A `FIT_QUERY` threshold table, decoded numerically (ascending f32s) — the
80    /// candidate `minWidth`s the web renderer turns into `@container` band rules
81    /// for the SSR first paint (issue #141).
82    pub fit_thresholds: Vec<f32>,
83}
84
85/// Whether a `SIZE_THAT_FITS` node satisfies the **candidate-identity contract**
86/// (spec/PRIMITIVES.md §SizeThatFits): a full-snapshot slot has MORE THAN ONE
87/// child, exactly one ascending `FIT_QUERY` threshold per child (so child `i` ⇔
88/// candidate `i`). A frame that violates this (table length ≠ child count, or a
89/// non-ascending table) cannot be CSS-gated and is rendered as the threshold-0
90/// fallback child only — never as ungated overlapping candidates.
91fn fit_candidates_valid(node: &Node) -> bool {
92    node.children.len() > 1
93        && node.fit_thresholds.len() == node.children.len()
94        && node.fit_thresholds.windows(2).all(|w| w[0] <= w[1])
95}
96
97/// The `@container` band rules for every multi-candidate fit slot, rendered into
98/// a `<style data-pathland-fit>` element in the document head (issue #141). For
99/// each distinct threshold `T_i` (ascending) the rule is **self-contained**: at
100/// `width >= T_i` show candidate `i` and hide every OTHER candidate of this slot,
101/// so exactly one candidate is visible at any container width — mirroring
102/// `pathland_core::fit::fit_index` (equal thresholds pick the lower index, so
103/// later duplicates never get a rule).
104///
105/// Bands use `min-width`, so at a width past several thresholds every matching
106/// rule applies; all selectors are one attribute deep (equal specificity) and
107/// `!important`, and the bands are emitted **ascending**, so the highest matching
108/// band wins the cascade. Below the first threshold no rule matches → candidate 0
109/// keeps its default `display:contents` (fallback) and candidates ≥ 1 keep
110/// `display:none`.
111///
112/// The child at position `i` is candidate `i` (spec/PRIMITIVES.md §SizeThatFits
113/// candidate-identity contract): the slot's child order equals the `FIT_QUERY`
114/// order, so the band index and the child index are the same.
115fn fit_band_styles(nodes: &BTreeMap<u32, Node>) -> String {
116    let mut rules = String::new();
117    for (id, node) in nodes {
118        if node.component != component_type::SIZE_THAT_FITS || !fit_candidates_valid(node) {
119            continue;
120        }
121        let n = node.children.len();
122        let mut seen = f32::NEG_INFINITY;
123        for (i, &t) in node.fit_thresholds.iter().enumerate() {
124            if i == 0 {
125                seen = t;
126                continue; // candidate 0 is the default-visible fallback
127            }
128            if t <= seen {
129                continue; // equal threshold → lower index already owns the band
130            }
131            seen = t;
132            let mut band = format!("@container pf-{id} (min-width: {t}px) {{");
133            for j in 0..n {
134                if j == i {
135                    continue; // never hide the winner
136                }
137                band.push_str(&format!(
138                    " [data-pathland-id=\"{id}\"] > [data-pathland-fit-cand=\"{j}\"] {{ display: none !important; }}"
139                ));
140            }
141            band.push_str(&format!(
142                " [data-pathland-id=\"{id}\"] > [data-pathland-fit-cand=\"{i}\"] {{ display: contents !important; }} }} \n"
143            ));
144            rules.push_str(&band);
145        }
146    }
147    rules
148}
149
150impl Node {
151    fn new(component: u16) -> Self {
152        Self {
153            component,
154            text: None,
155            properties: BTreeMap::new(),
156            strings: BTreeMap::new(),
157            token_refs: BTreeMap::new(),
158            date: None,
159            children: Vec::new(),
160            fit_thresholds: Vec::new(),
161        }
162    }
163
164    /// The `SELECTED` checked state, decoded from its `U8` wire value.
165    fn checked(&self) -> bool {
166        self.properties
167            .get(&property_id::SELECTED)
168            .copied()
169            .unwrap_or(0)
170            != 0
171    }
172
173    /// The decoded `ROLE` semantic code (0 = none when absent), from its `F32`
174    /// wire value.
175    fn role_code(&self) -> u8 {
176        self.properties
177            .get(&property_id::ROLE)
178            .map(|b| f32::from_bits(*b) as u8)
179            .unwrap_or(0)
180    }
181
182    /// The decoded `TEXT_STYLE` typography code (None when absent), from its
183    /// `F32` wire value.
184    fn text_style_code(&self) -> Option<u8> {
185        self.properties
186            .get(&property_id::TEXT_STYLE)
187            .map(|b| f32::from_bits(*b) as u8)
188    }
189
190    /// An `F32` property value, or a default when absent.
191    fn f32_property(&self, prop: u16, default: f32) -> f32 {
192        self.properties
193            .get(&prop)
194            .copied()
195            .map(f32::from_bits)
196            .unwrap_or(default)
197    }
198
199    /// `CONTENT_MARGINS` (F32), if present.
200    fn content_margins(&self) -> Option<f32> {
201        self.properties
202            .get(&property_id::CONTENT_MARGINS)
203            .map(|bits| f32::from_bits(*bits))
204    }
205
206    /// A raw `U32` property value (no f32 reinterpretation), or a default.
207    fn u32_property(&self, prop: u16, default: u32) -> u32 {
208        self.properties.get(&prop).copied().unwrap_or(default)
209    }
210
211    /// If the property carries a `DESIGN_TOKEN` reference, its CSS expression
212    /// (`var(--pl-…)` or `calc(var(--pl-space-base) * N)` for the generative
213    /// `space.<N>` family). Resolved at render time — the browser resolves the
214    /// variable against the token rules in the document head.
215    fn token_ref(&self, prop: u16) -> Option<String> {
216        self.token_refs.get(&prop).map(|path| resolve_token_ref(path))
217    }
218
219    /// Inline CSS for the node. Literal colors and string font-family, plus all
220    /// **arbitrary-number (dp/point)** styling — spacing, padding, size, offset,
221    /// rotation, scale, filters, opacity, border-radius, z-index — which render
222    /// inline because Tailwind can only statically safelist finite enum classes
223    /// (dp values are unbounded). 1 dp = 1 CSS px (a renderer interpretation).
224    fn style_css(&self) -> String {
225        let mut css = String::new();
226        let f32p = |prop: u16| self.properties.get(&prop).map(|b| f32::from_bits(*b));
227        let px = |v: f32| format!("{v}px");
228
229        // Colors + font family.
230        if let Some(v) = self.token_ref(property_id::COLOR) {
231            css.push_str(&format!("color:{v};"));
232        } else if let Some(bits) = self.properties.get(&property_id::COLOR) {
233            css.push_str(&format!("color:{};", rgba(*bits)));
234        }
235        if let Some(v) = self.token_ref(property_id::BACKGROUND_COLOR) {
236            css.push_str(&format!("background-color:{v};"));
237        } else if let Some(bits) = self.properties.get(&property_id::BACKGROUND_COLOR) {
238            css.push_str(&format!("background-color:{};", rgba(*bits)));
239        }
240        if let Some(bits) = self.properties.get(&property_id::FONT_DESIGN) {
241            if let Some(family) = font_design_family(f32::from_bits(*bits) as u8) {
242                css.push_str(&format!("font-family:{family};"));
243            }
244        }
245        if let Some(family) = self.strings.get(&property_id::FONT_FAMILY) {
246            css.push_str(&format!("font-family:'{}';", family.replace('\'', "\\'")));
247        }
248
249        // Layout / distance (dp → px).
250        if let Some(v) = self.token_ref(property_id::SPACING) {
251            css.push_str(&format!("gap:{v};"));
252        } else if let Some(v) = f32p(property_id::SPACING) {
253            if v > 0.0 {
254                css.push_str(&format!("gap:{};", px(v)));
255            }
256        }
257        if let Some(v) = self.token_ref(property_id::CONTENT_MARGINS) {
258            css.push_str(&format!("padding:{v};"));
259        } else if let Some(m) = self.content_margins() {
260            if m > 0.0 {
261                css.push_str(&format!("padding:{};", px(m)));
262            }
263        }
264        if let Some(v) = self.token_ref(property_id::PADDING) {
265            css.push_str(&format!("padding:{v};"));
266        } else if let Some(v) = f32p(property_id::PADDING) {
267            if v > 0.0 {
268                css.push_str(&format!("padding:{};", px(v)));
269            }
270        }
271        for (prop, side) in [
272            (property_id::PADDING_TOP, "padding-top"),
273            (property_id::PADDING_RIGHT, "padding-right"),
274            (property_id::PADDING_BOTTOM, "padding-bottom"),
275            (property_id::PADDING_LEFT, "padding-left"),
276        ] {
277            if let Some(v) = self.token_ref(prop) {
278                css.push_str(&format!("{side}:{v};"));
279            } else if let Some(v) = f32p(prop) {
280                if v > 0.0 {
281                    css.push_str(&format!("{side}:{};", px(v)));
282                }
283            }
284        }
285        // WIDTH/HEIGHT: fixed values inline as px; FILL (-1) → `100%` (expand to
286        // the available space in a flex parent — SwiftUI `maxWidth/maxHeight:
287        // .infinity`). HUG_CONTENT (-2) is left to the intrinsic size. (A grid's
288        // track counts live in `GRID_COLUMNS`/`GRID_ROWS`, never here.)
289        if let Some(v) = self.token_ref(property_id::WIDTH) {
290            css.push_str(&format!("width:{v};"));
291        } else if let Some(v) = f32p(property_id::WIDTH) {
292            if v != pathland_core::size::HUG_CONTENT {
293                css.push_str(&format!("width:{};", size_css(v)));
294            }
295        }
296        if let Some(v) = self.token_ref(property_id::HEIGHT) {
297            css.push_str(&format!("height:{v};"));
298        } else if let Some(v) = f32p(property_id::HEIGHT) {
299            if v != pathland_core::size::HUG_CONTENT {
300                css.push_str(&format!("height:{};", size_css(v)));
301            }
302        }
303        if let (Some(x), Some(y)) = (f32p(property_id::OFFSET_X), f32p(property_id::OFFSET_Y)) {
304            if x != 0.0 || y != 0.0 {
305                css.push_str(&format!("transform:translate({}px, {}px);", x, y));
306            }
307        } else {
308            if let Some(v) = f32p(property_id::POSITION_X) {
309                if v != 0.0 {
310                    css.push_str(&format!("left:{};", px(v)));
311                }
312            }
313            if let Some(v) = f32p(property_id::POSITION_Y) {
314                if v != 0.0 {
315                    css.push_str(&format!("top:{};", px(v)));
316                }
317            }
318        }
319
320        // Typography (arbitrary size/weight inline). A predefined `TEXT_STYLE`
321        // supplies the default size/weight; explicit `FONT_SIZE`/`FONT_WEIGHT`
322        // below override it (raw modifiers layer on top).
323        if let Some(style) = self.text_style_code() {
324            if let Some((size, weight)) = text_style_scale(style) {
325                css.push_str(&format!("font-size:{size}px;font-weight:{weight};"));
326            }
327        }
328        if let Some(v) = self.token_ref(property_id::FONT_SIZE) {
329            css.push_str(&format!("font-size:{v};"));
330        } else if let Some(v) = f32p(property_id::FONT_SIZE) {
331            if v > 0.0 {
332                css.push_str(&format!("font-size:{};", px(v)));
333            }
334        }
335        if let Some(v) = self.token_ref(property_id::FONT_WEIGHT) {
336            css.push_str(&format!("font-weight:{v};"));
337        } else if let Some(v) = f32p(property_id::FONT_WEIGHT) {
338            if v > 0.0 {
339                css.push_str(&format!("font-weight:{v};"));
340            }
341        }
342        if let Some(v) = self.token_ref(property_id::BORDER_RADIUS) {
343            css.push_str(&format!("border-radius:{v};"));
344        } else if let Some(v) = f32p(property_id::BORDER_RADIUS) {
345            if v != 0.0 {
346                css.push_str(&format!("border-radius:{};", px(v)));
347            }
348        }
349
350        // Effects (filters, rotation, scale, shadow, opacity, z-index) inline.
351        let mut filters: Vec<String> = Vec::new();
352        if let Some(v) = f32p(property_id::BLUR_RADIUS) {
353            filters.push(format!("blur({}px)", v));
354        }
355        if let Some(v) = f32p(property_id::SATURATION) {
356            filters.push(format!("saturate({v})"));
357        }
358        if let Some(v) = f32p(property_id::CONTRAST) {
359            filters.push(format!("contrast({v})"));
360        }
361        if let Some(v) = f32p(property_id::BRIGHTNESS) {
362            filters.push(format!("brightness({v})"));
363        }
364        if let Some(v) = f32p(property_id::GRAYSCALE) {
365            filters.push(format!("grayscale({v})"));
366        }
367        if let Some(v) = f32p(property_id::HUE_ROTATION) {
368            filters.push(format!("hue-rotate({v}deg)"));
369        }
370        if !filters.is_empty() {
371            css.push_str(&format!("filter:{};", filters.join(" ")));
372        }
373        if let Some(v) = f32p(property_id::ROTATION_DEGREES) {
374            if v != 0.0 {
375                css.push_str(&format!("transform:rotate({v}deg);"));
376            }
377        }
378        if let Some(v) = f32p(property_id::SCALE) {
379            if v != 1.0 {
380                css.push_str(&format!("transform:scale({v});"));
381            }
382        }
383        // Shadow (accumulate x/y/radius/color — simplified to radius+color inline).
384        if let (Some(r), Some(color_bits)) = (
385            f32p(property_id::SHADOW_RADIUS),
386            self.properties.get(&property_id::SHADOW_COLOR).copied(),
387        ) {
388            if r != 0.0 {
389                let x = f32p(property_id::SHADOW_X).unwrap_or(0.0);
390                let y = f32p(property_id::SHADOW_Y).unwrap_or(0.0);
391                let color = self
392                    .token_ref(property_id::SHADOW_COLOR)
393                    .unwrap_or_else(|| rgba(color_bits));
394                css.push_str(&format!("box-shadow:{x}px {y}px {r}px {color};"));
395            }
396        }
397        if let Some(v) = self.token_ref(property_id::OPACITY) {
398            css.push_str(&format!("opacity:{v};"));
399        } else if let Some(v) = f32p(property_id::OPACITY) {
400            if v < 1.0 {
401                css.push_str(&format!("opacity:{v};"));
402            }
403        }
404        if let Some(v) = f32p(property_id::Z_INDEX) {
405            css.push_str(&format!("z-index:{};", v as i32));
406        }
407
408        // Enum-derived properties (previously Tailwind classes) now inline.
409        // ALIGNMENT cross-axis position (Leading=0, Center=1, Trailing=2,
410        // → start). Positions children; the default is hug (flex-start), never
411        // CSS stretch — only FILL-sized children stretch (LAYOUT.md). A
412        // `ZSTACK`/grid positions on BOTH axes (its own branch emits the
413        // per-axis values), so the single-axis `align-items` is skipped there.
414        if let Some(v) = f32p(property_id::ALIGNMENT) {
415            if !matches!(
416                self.component,
417                component_type::ZSTACK
418                    | component_type::GRID
419                    | component_type::LAZY_VGRID
420                    | component_type::LAZY_HGRID
421            ) {
422                css.push_str(&format!(
423                    "align-items:{};",
424                    match v as u8 {
425                        0 => "flex-start",
426                        1 => "center",
427                        2 => "flex-end",
428                        _ => "flex-start",
429                    }
430                ));
431            }
432        }
433        // TEXT_ALIGNMENT (Leading=0, Center=1, Trailing=2).
434        if let Some(v) = f32p(property_id::TEXT_ALIGNMENT) {
435            css.push_str(&format!(
436                "text-align:{};",
437                match (v as u8).min(2) {
438                    0 => "left",
439                    1 => "center",
440                    _ => "right",
441                }
442            ));
443        }
444        // TEXT_CASE (None=0, Uppercase=1, Lowercase=2).
445        if let Some(v) = f32p(property_id::TEXT_CASE) {
446            css.push_str(&format!(
447                "text-transform:{};",
448                match v as u8 {
449                    1 => "uppercase",
450                    2 => "lowercase",
451                    _ => "none",
452                }
453            ));
454        }
455        // VISIBLE=0 → hidden.
456        if let Some(bits) = self.properties.get(&property_id::VISIBLE) {
457            if *bits == 0 {
458                css.push_str("display:none;");
459            }
460        }
461        // FONT_STYLE italic.
462        if let Some(v) = f32p(property_id::FONT_STYLE) {
463            if v != 0.0 {
464                css.push_str("font-style:italic;");
465            }
466        }
467        // FONT_DESIGN (Default=0, Serif=1, Rounded=2, Monospaced=3).
468        if let Some(v) = f32p(property_id::FONT_DESIGN) {
469            css.push_str(&format!(
470                "font-family:{};",
471                match v as u8 {
472                    1 => "Georgia, 'Times New Roman', serif",
473                    2 => "ui-rounded, system-ui, sans-serif",
474                    3 => "ui-monospace, monospace",
475                    _ => "inherit",
476                }
477            ));
478        }
479        // UNDERLINE / STRIKETHROUGH.
480        let mut decoration = Vec::new();
481        if let Some(v) = f32p(property_id::UNDERLINE) {
482            if v != 0.0 {
483                decoration.push("underline");
484            }
485        }
486        if let Some(v) = f32p(property_id::STRIKETHROUGH) {
487            if v != 0.0 {
488                decoration.push("line-through");
489            }
490        }
491        if !decoration.is_empty() {
492            css.push_str(&format!("text-decoration:{};", decoration.join(" ")));
493        }
494        // CLIPS_TO_BOUNDS → overflow hidden.
495        if let Some(v) = f32p(property_id::CLIPS_TO_BOUNDS) {
496            if v != 0.0 {
497                css.push_str("overflow:hidden;");
498            }
499        }
500        // ALLOWS_HIT_TESTING (inverted: absent = allowed).
501        if let Some(v) = f32p(property_id::ALLOWS_HIT_TESTING) {
502            if v == 0.0 {
503                css.push_str("pointer-events:none;");
504            }
505        }
506        // COLOR_INVERT.
507        if let Some(v) = f32p(property_id::COLOR_INVERT) {
508            if v != 0.0 {
509                css.push_str("filter:invert(1);");
510            }
511        }
512        // TRUNCATION_MODE alone has no observable effect (spec LAYOUT.md §content
513        // fitting, SwiftUI-aligned): it only positions the ellipsis under a
514        // `LINE_LIMIT` clamp, which the renderer tail-ellipsizes via `line-clamp`
515        // (renderer-owned fidelity: CSS cannot place a head/middle ellipsis).
516        // CONTENT_MODE → object-fit (Fit=contain, Fill=cover); ASPECT_RATIO.
517        if let Some(v) = f32p(property_id::CONTENT_MODE) {
518            css.push_str(&format!(
519                "object-fit:{};",
520                if (v as u8) == 1 { "cover" } else { "contain" }
521            ));
522        }
523        if let Some(v) = f32p(property_id::ASPECT_RATIO) {
524            css.push_str(&format!("aspect-ratio:{v};"));
525        }
526
527        css
528    }
529
530    /// Border CSS, decoded from `BORDER_WIDTH`/`BORDER_COLOR`/`BORDER_RADIUS`/
531    /// `BORDER_EDGES` (empty when the node has no border).
532    fn border_style(&self) -> String {
533        let width = match self.token_ref(property_id::BORDER_WIDTH) {
534            Some(v) => v,
535            None => {
536                let Some(width_bits) = self.properties.get(&property_id::BORDER_WIDTH) else {
537                    return String::new();
538                };
539                format!("{}px", f32::from_bits(*width_bits))
540            }
541        };
542        let color = match self.token_ref(property_id::BORDER_COLOR) {
543            Some(v) => v,
544            None => rgba(
545                self.properties
546                    .get(&property_id::BORDER_COLOR)
547                    .copied()
548                    .unwrap_or(0xFF00_0000),
549            ),
550        };
551        let edges = self
552            .properties
553            .get(&property_id::BORDER_EDGES)
554            .copied()
555            .unwrap_or(border_edges::ALL);
556
557        let mut css = String::new();
558        for (flag, side) in [
559            (border_edges::TOP, "top"),
560            (border_edges::LEADING, "left"),
561            (border_edges::BOTTOM, "bottom"),
562            (border_edges::TRAILING, "right"),
563        ] {
564            if edges & flag != 0 {
565                css.push_str(&format!("border-{side}:{width} solid {color};"));
566            }
567        }
568        css
569    }
570}
571
572/// Unpack a `0xAARRGGBB` color into `(r, g, b, alpha)` where `alpha` is `0..1`.
573fn unpack_color(argb: u32) -> (u32, u32, u32, f32) {
574    let r = (argb >> 16) & 0xFF;
575    let g = (argb >> 8) & 0xFF;
576    let b = argb & 0xFF;
577    let a = ((argb >> 24) & 0xFF) as f32 / 255.0;
578    (r, g, b, a)
579}
580
581/// A `0xAARRGGBB` color as a CSS `rgba(r,g,b,a)` string.
582fn rgba(argb: u32) -> String {
583    let (r, g, b, a) = unpack_color(argb);
584    format!("rgba({r},{g},{b},{a})")
585}
586
587/// Whether an `AUDIO`/`VIDEO` node is **app-driven**: it binds at least one
588/// A `WIDTH`/`HEIGHT` hint as CSS: `FILL` (-1) → `100%`, `HUG_CONTENT` (-2) →
589/// `fit-content`, otherwise pixels.
590fn size_css(v: f32) -> String {
591    if v == pathland_core::size::FILL {
592        "100%".to_string()
593    } else if v == pathland_core::size::HUG_CONTENT {
594        "fit-content".to_string()
595    } else {
596        format!("{v}px")
597    }
598}
599
600/// The stack's main axis as a boolean (horizontal) when `component` is a flex
601/// stack; `None` otherwise.
602fn stack_main_horizontal(component: u16) -> Option<bool> {
603    match component {
604        component_type::HSTACK | component_type::LAZY_HSTACK => Some(true),
605        component_type::VSTACK | component_type::LAZY_VSTACK => Some(false),
606        _ => None,
607    }
608}
609
610/// Whether a node is **effectively `FILL`-sized on an axis** (LAYOUT.md §fill
611/// propagation, SwiftUI/Compose parity): it is `FILL` itself, a layout-greedy
612/// primitive that fills the axis by nature, or a Hug-sized container whose
613/// subtree carries such a child. A Fixed box bounds its subtree.
614///
615/// `axis_horizontal` selects `WIDTH` (true) or `HEIGHT`; `main_axis` tells
616/// whether the axis is the node's parent's main axis (governs `SPACER`/`DIVIDER`
617/// greediness).
618fn fills_axis(
619    nodes: &BTreeMap<u32, Node>,
620    id: u32,
621    axis_horizontal: bool,
622    main_axis: bool,
623) -> bool {
624    let Some(node) = nodes.get(&id) else {
625        return false;
626    };
627    let prop = if axis_horizontal {
628        property_id::WIDTH
629    } else {
630        property_id::HEIGHT
631    };
632    let hint = node.properties.get(&prop).map(|b| f32::from_bits(*b));
633    match hint {
634        Some(v) if v == size::FILL => return true,
635        Some(v) if v.is_finite() && v > 0.0 => return false, // Fixed bounds
636        _ => {}
637    }
638    let greedy = match node.component {
639        component_type::COLOR | component_type::SCROLLVIEW => true,
640        component_type::SPACER => main_axis,
641        component_type::DIVIDER => !main_axis,
642        _ => false,
643    };
644    if greedy {
645        return true;
646    }
647    if !matches!(
648        node.component,
649        component_type::VSTACK
650            | component_type::HSTACK
651            | component_type::ZSTACK
652            | component_type::LAZY_VSTACK
653            | component_type::LAZY_HSTACK
654    ) {
655        return false;
656    }
657    // A child's greedy nature is relative to its own parent (this node).
658    let child_main = stack_main_horizontal(node.component)
659        .map(|main_h| main_h == axis_horizontal)
660        .unwrap_or(false); // ZSTACK has no main axis
661    node.children
662        .iter()
663        .any(|&child| fills_axis(nodes, child, axis_horizontal, child_main))
664}
665
666/// Fill-propagation sizing for a flex stack: a Hug-sized stack that contains an
667/// effectively-`FILL` descendant on an axis becomes `FILL` on that axis (emit
668/// `100%`), matching SwiftUI/Compose. Returns the extra CSS, empty when the
669/// stack is Fixed/FILL-sized itself (its own frame already covers it).
670fn fill_propagation(
671    nodes: &BTreeMap<u32, Node>,
672    id: u32,
673    node: &Node,
674    main_horizontal: bool,
675) -> String {
676    let mut extra = String::new();
677    for (axis_h, prop, css) in [
678        (true, property_id::WIDTH, "width:100%;"),
679        (false, property_id::HEIGHT, "height:100%;"),
680    ] {
681        let hint = node.properties.get(&prop).map(|b| f32::from_bits(*b));
682        let hug = hint.is_none() || hint == Some(size::HUG_CONTENT);
683        if hug && fills_axis(nodes, id, axis_h, main_horizontal == axis_h) {
684            extra.push_str(css);
685        }
686    }
687    extra
688}
689/// A grid's fixed track count from `prop`: a positive finite Fixed value pins
690/// the count; `FILL`/absent → auto-fit (`None`).
691fn grid_count(node: &Node, prop: u16) -> Option<u32> {
692    let v = node.f32_property(prop, -1.0);
693    if v <= 0.0 {
694        None
695    } else {
696        Some(v.round() as u32)
697    }
698}
699
700/// A grid's column count from its `GRID_COLUMNS` constructor property
701/// (cell-axis count; `FILL`/absent → auto-fit).
702fn grid_columns(node: &Node) -> Option<u32> {
703    grid_count(node, property_id::GRID_COLUMNS)
704}
705
706/// A grid's row count from its `GRID_ROWS` constructor property (cell-axis
707/// count; a `LAZY_HGRID`'s fixed track).
708fn grid_rows(node: &Node) -> Option<u32> {
709    grid_count(node, property_id::GRID_ROWS)
710}
711
712/// The horizontal position component of an `ALIGNMENT` 2D code (0–8, spec
713/// PRIMITIVES.md §ZStack).
714fn align_h(code: u8) -> &'static str {
715    match code {
716        1 | 3 | 4 => "center",
717        2 | 6 | 7 => "end",
718        _ => "start",
719    }
720}
721
722/// The vertical position component of an `ALIGNMENT` 2D code (0–8).
723fn align_v(code: u8) -> &'static str {
724    match code {
725        1 | 5 | 6 => "center",
726        2 | 4 | 8 => "end",
727        _ => "start",
728    }
729}
730
731/// A grid's `GRID_TRACKS` tokens (comma-separated `flex`/`fixed:<pts>`/
732/// `adaptive:<pts>`, spec §grid model). `None` when absent.
733fn grid_tracks(node: &Node) -> Option<Vec<&str>> {
734    node.strings
735        .get(&property_id::GRID_TRACKS)
736        .map(|s| s.split(',').map(str::trim).collect())
737}
738
739/// The CSS track template for a `GRID_TRACKS` spec: `flex` → `1fr`,
740/// `fixed:<pts>` → `<pts>px`, `adaptive:<pts>` → `minmax(<pts>px,1fr)`.
741fn grid_tracks_css(node: &Node) -> Option<String> {
742    let tracks = grid_tracks(node)?;
743    let mut out = String::new();
744    for t in tracks {
745        if !out.is_empty() {
746            out.push(' ');
747        }
748        if let Some(pts) = t.strip_prefix("fixed:") {
749            out.push_str(&format!("{pts}px"));
750        } else if let Some(pts) = t.strip_prefix("adaptive:") {
751            out.push_str(&format!("minmax({pts}px,1fr)"));
752        } else {
753            out.push_str("1fr");
754        }
755    }
756    Some(out)
757}
758
759/// Convert days since the Unix epoch (negative = before 1970) to a Gregorian
760/// `(year, month, day)` using the civil-from-days algorithm.
761fn days_to_date(days: i32) -> (i32, u32, u32) {
762    let z = i64::from(days) + 719_468;
763    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
764    let doe = z - era * 146_097;
765    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
766    let y = yoe + era * 400;
767    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
768    let mp = (5 * doy + 2) / 153;
769    let d = doy - (153 * mp + 2) / 5 + 1;
770    let m = if mp < 10 { mp + 3 } else { mp - 9 };
771    let y = if m <= 2 { y + 1 } else { y };
772    (y as i32, m as u32, d as u32)
773}
774
775/// Millis of day → `(hours, minutes, seconds)`.
776fn millis_to_hms(millis: u32) -> (u32, u32, u32) {
777    let total = millis / 1000;
778    (total / 3600, (total % 3600) / 60, total % 60)
779}
780
781/// Event-surfacing `data-*` attributes for a hydration client, from
782/// `EVENT_LISTENERS` / `ACTION_ID` / `BINDING_ID`.
783fn event_attrs(node: &Node) -> String {
784    let mut attrs = String::new();
785    if let Some(m) = node.properties.get(&property_id::EVENT_LISTENERS) {
786        if *m != 0 {
787            attrs.push_str(&format!(" data-event-listeners=\"{m}\""));
788        }
789    }
790    if let Some(a) = node.properties.get(&property_id::ACTION_ID) {
791        attrs.push_str(&format!(" data-action-id=\"{a}\""));
792    }
793    if let Some(b) = node.properties.get(&property_id::BINDING_ID) {
794        attrs.push_str(&format!(" data-binding-id=\"{b}\""));
795    }
796    attrs
797}
798
799/// ARIA attributes from `ROLE` / `STATE` / `ENABLED`. The `ROLE` map is the
800/// canonical `role_spec` (also generated into the DOM client), so a role
801/// resolves to the same ARIA attribute on both renderers; roles that render as
802/// a semantic element (or are conveyed by a native control element) emit no
803/// attribute.
804fn aria_attrs(node: &Node) -> String {
805    let mut attrs = String::new();
806    if let Some(bits) = node.properties.get(&property_id::ROLE) {
807        let code = f32::from_bits(*bits) as u8;
808        let kind = role_spec::shell_kind(node.component);
809        if let Some(role) = role_spec::aria_role(kind, code) {
810            attrs.push_str(&format!(" role=\"{role}\""));
811        }
812    }
813    if let Some(bits) = node.properties.get(&property_id::STATE) {
814        match f32::from_bits(*bits) as u8 {
815            1 => attrs.push_str(" aria-disabled=\"true\""),
816            3 => attrs.push_str(" aria-pressed=\"true\""),
817            4 => attrs.push_str(" aria-selected=\"true\""),
818            5 => attrs.push_str(" aria-expanded=\"true\""),
819            6 => attrs.push_str(" aria-busy=\"true\""),
820            _ => {}
821        }
822    }
823    if let Some(bits) = node.properties.get(&property_id::ENABLED) {
824        if *bits == 0 {
825            attrs.push_str(" disabled");
826        }
827    }
828    attrs
829}
830
831/// Decode a self-contained snapshot batch (opcodes + string section) into a
832/// **transient** `id → Node` map. The map lives only for the duration of one
833/// render call — the renderer retains nothing between calls.
834fn decode(opcodes: &[Opcode], strings: &[u8]) -> (BTreeMap<u32, Node>, Tokens) {
835    let mut nodes = BTreeMap::new();
836    let mut tokens = Tokens::default();
837    for op in opcodes {
838        match op.category() {
839            category::TREE => apply_tree(&mut nodes, op.command(), *op),
840            category::PARAMETER => {
841                if op.command() == parameter::SET_DESIGN_TOKEN {
842                    // Global override: A = arenaRef (token path), B = valueType,
843                    // C = value (for STRING, an arenaRef to the value string).
844                    if let Some(path) = strings_str(strings, op.a()) {
845                        let vt = (op.b() & 0xFF) as u8;
846                        let value = if vt == value_type::STRING {
847                            strings_str(strings, op.c())
848                                .map(TokenValue::Str)
849                                .unwrap_or(TokenValue::U32(0))
850                        } else {
851                            TokenValue::from_wire(vt, op.c()).unwrap_or(TokenValue::U32(op.c()))
852                        };
853                        tokens.set(&path, vt, value);
854                    }
855                } else {
856                    apply_style(&mut nodes, op.command(), *op, strings);
857                }
858            }
859            _ => {}
860        }
861    }
862    (nodes, tokens)
863}
864
865/// Read a `[u32 len][utf8]` entry from the batch's string section at a relative offset.
866fn strings_str(strings: &[u8], offset: u32) -> Option<String> {
867    let offset = offset as usize;
868    if offset + 4 > strings.len() {
869        return None;
870    }
871    let len = u32::from_le_bytes(strings[offset..offset + 4].try_into().ok()?) as usize;
872    let start = offset + 4;
873    if start + len > strings.len() {
874        return None;
875    }
876    std::str::from_utf8(&strings[start..start + len]).ok().map(str::to_owned)
877}
878
879/// Decode a `LIST` arena entry (`[u32 count][f32 × count]`) as a CSV string with
880/// trimmed thresholds (e.g. `"0,640"`) — the `FIT_QUERY` mirror the SSR shell
881/// emits in its `data-pathland-fit` attribute.
882fn list_csv(strings: &[u8], offset: u32) -> Option<String> {
883    let offset = offset as usize;
884    if offset + 4 > strings.len() {
885        return None;
886    }
887    let count = u32::from_le_bytes(strings[offset..offset + 4].try_into().ok()?) as usize;
888    let start = offset + 4;
889    if start + count * 4 > strings.len() {
890        return None;
891    }
892    let mut parts = Vec::with_capacity(count);
893    for i in 0..count {
894        let bits = u32::from_le_bytes(
895            strings[start + i * 4..start + i * 4 + 4].try_into().ok()?,
896        );
897        let value = f32::from_bits(bits);
898        // Trim: whole values render without a fraction ("0,640").
899        parts.push(if value.fract() == 0.0 {
900            format!("{:.0}", value)
901        } else {
902            format!("{:.1}", value)
903        });
904    }
905    Some(parts.join(","))
906}
907
908/// Read a length-prefixed f32 array (`[u32 count][f32 × count]`) from the arena —
909/// the numeric form of a `LIST`-typed value (today the `FIT_QUERY` threshold table).
910fn list_f32(strings: &[u8], offset: u32) -> Option<Vec<f32>> {
911    let offset = offset as usize;
912    if offset + 4 > strings.len() {
913        return None;
914    }
915    let count = u32::from_le_bytes(strings[offset..offset + 4].try_into().ok()?) as usize;
916    let start = offset + 4;
917    if start + count * 4 > strings.len() {
918        return None;
919    }
920    let mut values = Vec::with_capacity(count);
921    for i in 0..count {
922        let bits = u32::from_le_bytes(
923            strings[start + i * 4..start + i * 4 + 4].try_into().ok()?,
924        );
925        values.push(f32::from_bits(bits));
926    }
927    Some(values)
928}
929
930// --- Design tokens (spec/TOKENS.md) ---
931
932/// `SET_DESIGN_TOKEN` overrides collected from a snapshot batch. `base` holds
933/// light tokens (keyed by full path); `dark` holds `dark.`-prefixed overrides
934/// (keyed by the bare path). Values are resolved [`TokenValue`]s (STRING values
935/// are resolved from the batch's string section at decode time).
936#[derive(Debug, Default, Clone)]
937struct Tokens {
938    base: BTreeMap<String, (u8, TokenValue)>,
939    dark: BTreeMap<String, (u8, TokenValue)>,
940}
941
942impl Tokens {
943    fn set(&mut self, path: &str, value_type: u8, value: TokenValue) {
944        if let Some(bare) = path.strip_prefix("dark.") {
945            self.dark.insert(bare.to_string(), (value_type, value));
946        } else {
947            self.base.insert(path.to_string(), (value_type, value));
948        }
949    }
950
951    /// Render the overrides as CSS rules: base tokens as `:root` rules, dark
952    /// overrides inside `@media (prefers-color-scheme: dark)` — the browser
953    /// resolves the scheme natively, so SSR needs no client scheme knowledge.
954    fn css(&self) -> String {
955        let mut css = String::new();
956        for (path, (vt, value)) in &self.base {
957            css.push_str(&format!(
958                ":root{{{}:{};}}",
959                token_to_css_var(path),
960                token_css_value(path, *vt, value)
961            ));
962        }
963        if !self.dark.is_empty() {
964            let rules: String = self
965                .dark
966                .iter()
967                .map(|(path, (vt, value))| {
968                    format!(":root{{{}:{};}}", token_to_css_var(path), token_css_value(path, *vt, value))
969                })
970                .collect();
971            css.push_str(&format!("@media (prefers-color-scheme: dark){{{rules}}}"));
972        }
973        css
974    }
975}
976
977/// The canonical CSS custom property for a token path: `--pl-` prefix, `.` →
978/// `-`. The `dark.` scheme prefix is stripped — the dark variant overrides the
979/// same variable inside the media query (spec/TOKENS.md; the naming rules live
980/// in [`token_spec`], which `pathland-ts-codegen` mirrors into the DOM client).
981fn token_to_css_var(path: &str) -> String {
982    let bare = path.strip_prefix(token_spec::DARK_PREFIX).unwrap_or(path);
983    let name: String = bare
984        .chars()
985        .map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '-' })
986        .collect();
987    format!("{}{name}", token_spec::VAR_PREFIX)
988}
989
990/// Token paths whose F32 values are CSS lengths (emitted with `px`).
991fn is_length_token(path: &str) -> bool {
992    for prefix in token_spec::LENGTH_PREFIXES {
993        if path.starts_with(prefix) {
994            return true;
995        }
996    }
997    for (prefix, suffix) in token_spec::LENGTH_PREFIX_SUFFIX {
998        if path.starts_with(prefix) && path.ends_with(suffix) {
999            return true;
1000        }
1001    }
1002    if token_spec::LENGTH_EXACT.contains(&path) {
1003        return true;
1004    }
1005    path.starts_with("elevation.")
1006        && token_spec::LENGTH_ELEVATION_SUFFIXES
1007            .iter()
1008            .any(|s| path.ends_with(s))
1009}
1010
1011/// Render a token override value as a CSS value string: `STRING` values are
1012/// single-quoted (escaped); `F32` length tokens append `px`.
1013fn token_css_value(path: &str, _value_type: u8, value: &TokenValue) -> String {
1014    match value {
1015        TokenValue::Str(s) => format!("'{}'", s.replace('\\', "\\\\").replace('\'', "\\'")),
1016        TokenValue::Color(argb) => rgba(*argb),
1017        TokenValue::F32(v) => {
1018            if is_length_token(path) {
1019                format!("{v}px")
1020            } else {
1021                format!("{v}")
1022            }
1023        }
1024        TokenValue::I32(v) => format!("{v}"),
1025        TokenValue::U32(v) => format!("{v}"),
1026        TokenValue::U8(v) => format!("{v}"),
1027    }
1028}
1029
1030/// Resolve a `DESIGN_TOKEN` property reference to a CSS expression. The
1031/// generative `space.<N>` family resolves to `calc(var(--pl-space-base) * N)`;
1032/// every other token resolves to `var(--pl-<path>)`.
1033fn resolve_token_ref(path: &str) -> String {
1034    let bare = path.strip_prefix(token_spec::DARK_PREFIX).unwrap_or(path);
1035    if let Some(rest) = bare.strip_prefix(token_spec::SPACE_FAMILY) {
1036        if rest.parse::<f64>().is_ok() {
1037            return format!("calc(var(--pl-space-base) * {rest})");
1038        }
1039    }
1040    format!("var({})", token_to_css_var(bare))
1041}
1042
1043fn apply_tree(nodes: &mut BTreeMap<u32, Node>, command: u8, op: Opcode) {
1044    match command {
1045        tree::CREATE_NODE => {
1046            let id = op.a();
1047            let component = op.b() as u16;
1048            nodes.insert(id, Node::new(component));
1049        }
1050        tree::DELETE_NODE => {
1051            nodes.remove(&op.a());
1052        }
1053        tree::INSERT_CHILD => {
1054            let (parent, child) = (op.a(), op.b());
1055            if let Some(node) = nodes.get_mut(&parent) {
1056                let index = op.c();
1057                if index == pathland_core::APPEND {
1058                    node.children.push(child);
1059                } else {
1060                    let index = (index as usize).min(node.children.len());
1061                    node.children.insert(index, child);
1062                }
1063            }
1064        }
1065        tree::REMOVE_CHILD => {
1066            let (parent, child) = (op.a(), op.b());
1067            if let Some(node) = nodes.get_mut(&parent) {
1068                node.children.retain(|&c| c != child);
1069            }
1070        }
1071        tree::MOVE_CHILD => {
1072            let (parent, child, index) = (op.a(), op.b(), op.c());
1073            if let Some(node) = nodes.get_mut(&parent) {
1074                node.children.retain(|&c| c != child);
1075                let index = (index as usize).min(node.children.len());
1076                node.children.insert(index, child);
1077            }
1078        }
1079        _ => {}
1080    }
1081}
1082
1083fn apply_style(nodes: &mut BTreeMap<u32, Node>, command: u8, op: Opcode, strings: &[u8]) {
1084    match command {
1085        parameter::SET_TEXT => {
1086            if let Some(text) = strings_str(strings, op.b()) {
1087                if let Some(node) = nodes.get_mut(&op.a()) {
1088                    node.text = Some(text);
1089                }
1090            }
1091        }
1092        parameter::SET_PROPERTY => {
1093            let property = (op.b() & 0xFFFF) as u16;
1094            if let Some(node) = nodes.get_mut(&op.a()) {
1095                let vt = (op.b() >> 16) as u8;
1096                if vt == value_type::STRING {
1097                    if let Some(text) = strings_str(strings, op.c()) {
1098                        node.strings.insert(property, text);
1099                    }
1100                } else if vt == value_type::DESIGN_TOKEN {
1101                    // C = arenaRef to the token path; resolved at render time to
1102                    // `var(--pl-…)` / `calc(...)` (see `Node::token_ref`).
1103                    if let Some(path) = strings_str(strings, op.c()) {
1104                        node.token_refs.insert(property, path);
1105                    }
1106                } else if vt == value_type::LIST {
1107                    // A length-prefixed f32 array — today a `FIT_QUERY` threshold
1108                    // table; stored as its CSV form so the SSR shell can mirror it
1109                    // in a `data-pathland-fit` attribute for the DOM client, and as
1110                    // the numeric table for the `@container` band rules.
1111                    if let Some(csv) = list_csv(strings, op.c()) {
1112                        node.strings.insert(property, csv);
1113                    }
1114                    if let Some(values) = list_f32(strings, op.c()) {
1115                        node.fit_thresholds = values;
1116                    }
1117                } else {
1118                    node.properties.insert(property, op.c());
1119                }
1120            }
1121        }
1122        parameter::SET_DATE => {
1123            if let Some(node) = nodes.get_mut(&op.a()) {
1124                // B = days since epoch (I32), C = millis of day (U32).
1125                node.date = Some((op.b() as i32, op.c()));
1126            }
1127        }
1128        _ => {}
1129    }
1130}
1131
1132/// A **stateless, streaming** HTML renderer. Each render call decodes a
1133/// self-contained snapshot batch into a transient map, walks it once, and
1134/// streams HTML text out — zero state retained across calls. A pure function
1135/// of the opcode stream (Renderer Statelessness).
1136#[derive(Debug, Clone, Copy)]
1137pub struct HtmlRenderer {
1138    /// When enabled, every rendered node is prefixed with an HTML comment
1139    /// describing its component type and the modifiers (style properties)
1140    /// applied to it (`<!-- #1 VStack: spacing=4, alignment=Fill -->`). A
1141    /// debugging aid — off by default so production SSR stays lean.
1142    debug_comments: bool,
1143}
1144
1145impl HtmlRenderer {
1146    /// Create a renderer (stateless; the same value serves every render).
1147    #[must_use]
1148    pub fn new() -> Self {
1149        Self {
1150            debug_comments: false,
1151        }
1152    }
1153
1154    /// Enable/disable debug comments. When on, each rendered node is prefixed
1155    /// with an HTML comment naming its component and the modifiers applied
1156    /// (see [`crate::debug`]).
1157    #[must_use]
1158    pub fn with_debug_comments(mut self, on: bool) -> Self {
1159        self.debug_comments = on;
1160        self
1161    }
1162
1163    /// Whether debug comments are enabled.
1164    #[must_use]
1165    pub fn debug_comments(&self) -> bool {
1166        self.debug_comments
1167    }
1168
1169    /// Render the subtree rooted at `root` as a full HTML document.
1170    #[must_use]
1171    pub fn render_document(&self, opcodes: &[Opcode], strings: &[u8], root: u32) -> String {
1172        let (nodes, tokens) = decode(opcodes, strings);
1173        let body = self.render_node(&nodes, root, None);
1174        // Token overrides render as their own `<style data-pathland-tokens>`
1175        // element AFTER the built-in block so their `:root` variables win the
1176        // cascade (same specificity, later wins) — matching the JS DOM client's
1177        // `style[data-pathland-tokens]` element. No overrides → no element.
1178        let token_rules = tokens.css();
1179        let token_style = if token_rules.is_empty() {
1180            String::new()
1181        } else {
1182            format!("<style data-pathland-tokens>{token_rules}</style>\n")
1183        };
1184        // Multi-candidate fit slots (issue #141) get their `@container` band rules
1185        // in a dedicated `data-pathland-fit` element (the SSR visibility contract;
1186        // the DOM client drops them on hydration). None in live/single-child
1187        // frames → no element.
1188        let fit_rules = fit_band_styles(&nodes);
1189        let fit_style = if fit_rules.is_empty() {
1190            String::new()
1191        } else {
1192            format!("<style data-pathland-fit>{fit_rules}</style>\n")
1193        };
1194        format!(
1195            "<!DOCTYPE html>\n<html>\n<head>\n<meta charset=\"utf-8\">\n\
1196             <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n\
1197             <title>Pathland</title>\n\
1198             {}{}{}\n</head>\n<body>{body}</body>\n</html>\n",
1199            css::STYLE,
1200            token_style,
1201            fit_style
1202        )
1203    }
1204
1205    /// Render the subtree rooted at `root` as an HTML fragment (no `<html>`).
1206    /// `SET_DESIGN_TOKEN` overrides are applied to the document head by
1207    /// `render_document`; a fragment is assumed to be embedded in a document
1208    /// that already defines the token variables.
1209    #[must_use]
1210    pub fn render_fragment(&self, opcodes: &[Opcode], strings: &[u8], root: u32) -> String {
1211        let (nodes, _) = decode(opcodes, strings);
1212        self.render_node(&nodes, root, None)
1213    }
1214
1215    fn render_node(
1216        &self,
1217        nodes: &BTreeMap<u32, Node>,
1218        id: u32,
1219        parent_component: Option<u16>,
1220    ) -> String {
1221        let Some(node) = nodes.get(&id) else {
1222            return String::new();
1223        };
1224        let mut children: String = node
1225            .children
1226            .iter()
1227            .map(|&child| self.render_node(nodes, child, Some(node.component)))
1228            .collect();
1229        let mut data_id = format!(" data-pathland-id=\"{id}\"");
1230        data_id.push_str(&slot_attrs(node));
1231        // An app-driven media container (a custom AudioStyle/VideoStyle body, e.g.
1232        // a VStack carrying AUDIO_SOURCE): inject a hidden, control-less media
1233        // element as its first child and mark the container so the DOM client
1234        // wires playback events (spec/EVENTS.md Media). The style body's own
1235        // component is preserved, so the container keeps its layout (e.g. a
1236        // flex-column VStack). Native media (no children) render `<audio
1237        // controls>` via the AUDIO/VIDEO cases below.
1238        let mut media_attr = String::new();
1239        if !node.children.is_empty() {
1240            if let Some(src) = node.strings.get(&property_id::AUDIO_SOURCE) {
1241                media_attr.push_str(" data-pathland-media");
1242                children = format!(
1243                    "<audio src=\"{}\" data-pathland-media></audio>{children}",
1244                    escape(src)
1245                );
1246            } else if let Some(src) = node.strings.get(&property_id::VIDEO_SOURCE) {
1247                media_attr.push_str(" data-pathland-media");
1248                children = format!(
1249                    "<video src=\"{}\" data-pathland-media></video>{children}",
1250                    escape(src)
1251                );
1252            }
1253        }
1254        data_id.push_str(&media_attr);
1255        let mut css = format!("{}{}", node.style_css(), node.border_style());
1256        // Derived layout (LAYOUT.md), separate from the node's own css so
1257        // components with hardcoded shells (e.g. DIVIDER) can append it without
1258        // duplicating their base styles.
1259        let mut derived = String::new();
1260        // Cross-axis FILL: a child that is effectively FILL on its parent
1261        // stack's cross axis stretches via `align-self:stretch` (fills the
1262        // cross size even when the container's cross size is content-driven).
1263        if let Some(parent) = parent_component {
1264            if let Some(main_horizontal) = stack_main_horizontal(parent) {
1265                if fills_axis(nodes, id, !main_horizontal, false) {
1266                    derived.push_str("align-self:stretch;");
1267                }
1268            }
1269        }
1270        // LINE_LIMIT truncation: a positive line limit clamps the text to N lines
1271        // (mirrors the DOM client's PROP_LINE_LIMIT application, classes.ts).
1272        // `overflow:hidden` is REQUIRED: -webkit-line-clamp sizes the box to N
1273        // lines and draws the ellipsis, but does not clip the overflow on its
1274        // own — without it Safari/WebKit paint the overflowed text below the
1275        // clamped box (the ellipsis AND the extra line both show).
1276        let line_limit = node.u32_property(property_id::LINE_LIMIT, 0);
1277        if line_limit > 0 {
1278            css.push_str(&format!(
1279                "display:-webkit-box;overflow:hidden;-webkit-line-clamp:{line_limit};-webkit-box-orient:vertical;"
1280            ));
1281        }
1282        let style = style_attr(&format!("{css}{derived}"));
1283        let event = event_attrs(node);
1284        let aria = aria_attrs(node);
1285        // A semantic role may retag a generic div/span shell (role_spec);
1286        // control components keep their native element.
1287        let kind = role_spec::shell_kind(node.component);
1288        let semantic = role_spec::semantic_tag(kind, node.role_code());
1289
1290        let element = match node.component {
1291            component_type::VSTACK => {
1292                wrap_stack(id, "column", semantic, node, &media_attr, &children, &format!("{css}{}{derived}", fill_propagation(nodes, id, node, false)), &event, &aria)
1293            }
1294            component_type::HSTACK => {
1295                wrap_stack(id, "row", semantic, node, &media_attr, &children, &format!("{css}{}{derived}", fill_propagation(nodes, id, node, true)), &event, &aria)
1296            }
1297            component_type::LAZY_VSTACK => {
1298                wrap_stack(id, "column", semantic, node, &media_attr, &children, &format!("{css}{}{derived}", fill_propagation(nodes, id, node, false)), &event, &aria)
1299            }
1300            component_type::LAZY_HSTACK => {
1301                wrap_stack(id, "row", semantic, node, &media_attr, &children, &format!("{css}{}{derived}", fill_propagation(nodes, id, node, true)), &event, &aria)
1302            }
1303            component_type::SIZE_THAT_FITS => {
1304                // A fit slot (spec/PRIMITIVES.md §SizeThatFits): size-taking — it
1305                // fills the parent's proposal, and that measured width is the unit
1306                // of fitting. `data-pathland-fit` mirrors the FIT_QUERY threshold
1307                // table so the DOM client can derive the fit locally (and only
1308                // report FIT_CHANGED on transitions).
1309                //
1310                // A SINGLE child (the live form) renders exactly as before. When the
1311                // slot carries MULTIPLE children it is the SSR full-snapshot form
1312                // (issue #141): EVERY candidate is laid out and gated by CSS
1313                // container queries, so a no-JS first paint already shows the
1314                // candidate matching the slot's width.
1315                //
1316                // The slot is a LAYOUT-TRANSPARENT container: it forwards the
1317                // parent flex container's flex-direction/align-items/justify-content
1318                // (`inherit`) to its own box, so its child is laid out exactly
1319                // as if it sat directly in the parent — e.g. a `VStack(CENTER)` keeps
1320                // centering a candidate the slot wraps, instead of the slot's
1321                // `align-self:stretch` box pinning the child to the start. Under a
1322                // non-flex parent `inherit` degrades to the CSS initial values.
1323                // `align-self:stretch` + `flex:1 1 auto` keep the slot filling/
1324                // measuring the proposal. In the snapshot form the slot also becomes
1325                // the `@container` for the band rules and wraps each candidate in a
1326                // `display:none`-by-default wrapper carrying the candidate's id + a
1327                // `data-pathland-fit-cand` index; the head `<style data-pathland-fit>`
1328                // (built by `fit_band_style`) flips the matching band to
1329                // `display:contents`.
1330                let fit_attr = node
1331                    .strings
1332                    .get(&property_id::FIT_QUERY)
1333                    .map(|csv| format!(" data-pathland-fit=\"{}\"", escape(csv)))
1334                    .unwrap_or_default();
1335                if fit_candidates_valid(node) {
1336                    let fit_cands = format!(" data-pathland-fit-cands=\"{}\"", node.children.len());
1337                    let mut boxed = String::new();
1338                    for (i, &child) in node.children.iter().enumerate() {
1339                        // Candidate content keeps its own id (the DOM client's byId
1340                        // maps property deltas to the correct element); the wrapper is
1341                        // a pure CSS gate — no id — hidden by default except the
1342                        // threshold-0 fallback (candidate 0). The head
1343                        // `<style data-pathland-fit>` `@container` rules (built by
1344                        // `fit_band_styles`) swap which band is visible.
1345                        let inner = self.render_node(nodes, child, Some(node.component));
1346                        let default = if i == 0 { "display:contents" } else { "display:none" };
1347                        boxed.push_str(&format!(
1348                            "<div data-pathland-fit-cand=\"{i}\" data-pathland-fit-cand-id=\"{child}\" style=\"{default}\">{inner}</div>"
1349                        ));
1350                    }
1351                    let slot_base = format!(
1352                        "display:flex;flex-direction:inherit;align-items:inherit;justify-content:inherit;flex:1 1 auto;align-self:stretch;min-width:0;min-height:0;container-type:inline-size;container-name:pf-{id};{css}{derived}"
1353                    );
1354                    format!(
1355                        "<div{data_id} class=\"pathland-ftf\"{fit_attr}{fit_cands}{event}{aria} style=\"{slot_base}\">{boxed}</div>"
1356                    )
1357                } else if node.children.len() > 1 {
1358                    // A multi-child slot whose FIT_QUERY table does not satisfy the
1359                    // candidate-identity contract (length mismatch / non-ascending)
1360                    // cannot be gated: degrade to the threshold-0 fallback child
1361                    // only, never render ungated overlapping candidates.
1362                    let only = self.render_node(nodes, node.children[0], Some(node.component));
1363                    format!(
1364                        "<div{data_id} class=\"pathland-ftf\"{fit_attr}{event}{aria} style=\"display:flex;flex-direction:inherit;align-items:inherit;justify-content:inherit;flex:1 1 auto;align-self:stretch;min-width:0;min-height:0;{css}{derived}\">{only}</div>"
1365                    )
1366                } else {
1367                    format!(
1368                        "<div{data_id} class=\"pathland-ftf\"{fit_attr}{event}{aria} style=\"display:flex;flex-direction:inherit;align-items:inherit;justify-content:inherit;flex:1 1 auto;align-self:stretch;min-width:0;min-height:0;{css}{derived}\">{children}</div>"
1369                    )
1370                }
1371            }
1372            component_type::TEXT => {
1373                // A TEXT's tag is resolved by typography + role: a heading
1374                // `TEXT_STYLE` (LargeTitle…Headline) is always a heading; else
1375                // `ROLE_HEADER` → `<h2>`, `ROLE_PARAGRAPH` → `<p>`; else `<span>`.
1376                let tag = role_spec::text_tag(kind, node.role_code(), node.text_style_code())
1377                    .unwrap_or("span");
1378                let text = escape(node.text.as_deref().unwrap_or_default());
1379                format!("<{tag}{data_id}{event}{aria}{style}>{text}</{tag}>")
1380            }
1381            component_type::ICON => {
1382                // A semantic icon: the canonical `ICON_NAME` maps to a **filled**
1383                // Remix glyph (inline SVG, single-sourced). An unknown name renders
1384                // the fallback glyph. A `LABEL` makes it presentable
1385                // (`role="img"`); without one it is decorative (`aria-hidden`).
1386                let icon_name = node
1387                    .strings
1388                    .get(&property_id::ICON_NAME)
1389                    .map(|n| format!(" data-pathland-icon=\"{}\"", escape(n)))
1390                    .unwrap_or_default();
1391                let inner = node
1392                    .strings
1393                    .get(&property_id::ICON_NAME)
1394                    .and_then(|n| icons::web_inner(n))
1395                    .unwrap_or_else(icons::web_fallback_svg);
1396                let icon_aria = match node.strings.get(&property_id::LABEL) {
1397                    Some(label) if !label.is_empty() => {
1398                        format!(" role=\"img\" aria-label=\"{}\"", escape(label))
1399                    }
1400                    _ => " aria-hidden=\"true\" focusable=\"false\"".to_string(),
1401                };
1402                // Open the svg tag, splice the node attrs before `>`, then the
1403                // Remix inner markup (`WEB_SVG_OPEN` ends with `>`). The
1404                // `data-pathland-icon` name is the hook the DOM client's lazy
1405                // resolver uses to seed a glyph from an already-embedded svg.
1406                let open = icons::WEB_SVG_OPEN.trim_end_matches('>');
1407                format!("{open}{data_id}{icon_name}{icon_aria}{event}{style}>{inner}</svg>")
1408            }
1409            component_type::BUTTON => {
1410                // Composite Override Mode: children present → custom body.
1411                let body = if node.children.is_empty() {
1412                    escape(node.text.as_deref().unwrap_or_default())
1413                } else {
1414                    children
1415                };
1416                let button_class = class_attr("pathland-button");
1417                format!("<button{data_id}{event}{aria}{button_class}{style}>{body}</button>")
1418            }
1419            component_type::TOGGLE => {
1420                let toggle_style = node.f32_property(property_id::TOGGLE_STYLE, 0.0).round() as u8;
1421                let checked = if node.checked() { " checked" } else { "" };
1422                let body = if node.children.is_empty() {
1423                    format!(
1424                        "<span class=\"pathland-text\">{}</span>",
1425                        escape(node.text.as_deref().unwrap_or_default())
1426                    )
1427                } else {
1428                    format!("<div class=\"pathland-custom-body\">{children}</div>")
1429                };
1430                let toggle_class = class_attr("pathland-toggle");
1431                match toggle_style {
1432                    1 => format!(
1433                        "<label{data_id}{event}{aria}{toggle_class}{style}><input type=\"checkbox\"{checked}>{body}</label>"
1434                    ),
1435                    2 => {
1436                        let pressed = if node.checked() { "true" } else { "false" };
1437                        format!(
1438                            "<button{data_id}{event}{aria} type=\"button\" aria-pressed=\"{pressed}\"{style}>{body}</button>"
1439                        )
1440                    }
1441                    _ => format!(
1442                        "<label{data_id}{event}{aria}{toggle_class}{style}><input type=\"checkbox\" role=\"switch\"{checked}>{body}</label>"
1443                    ),
1444                }
1445            }
1446            component_type::SPACER => {
1447                let tag = semantic.unwrap_or("div");
1448                let combined = format!("flex:1;{css}");
1449                let spacer_style = style_attr(&combined);
1450                format!("<{tag}{data_id}{event}{aria}{spacer_style}></{tag}>")
1451            }
1452            component_type::SLIDER => {
1453                let min = node.f32_property(property_id::MIN_VALUE, 0.0);
1454                let max = node.f32_property(property_id::MAX_VALUE, 1.0);
1455                let value = node.f32_property(property_id::VALUE, min);
1456                let body = if node.children.is_empty() {
1457                    format!(
1458                        "<span class=\"pathland-text\">{}</span>",
1459                        escape(node.text.as_deref().unwrap_or_default())
1460                    )
1461                } else {
1462                    format!("<div class=\"pathland-custom-body\">{children}</div>")
1463                };
1464                let slider_class = class_attr("pathland-slider");
1465                format!(
1466                    "<label{data_id}{event}{aria}{slider_class}{style}><input type=\"range\" min=\"{min}\" max=\"{max}\" step=\"any\" value=\"{value}\">{body}</label>"
1467                )
1468            }
1469            component_type::TEXT_FIELD => {
1470                let value = escape(node.text.as_deref().unwrap_or_default());
1471                let label = escape(
1472                    node.strings
1473                        .get(&property_id::LABEL)
1474                        .map(String::as_str)
1475                        .unwrap_or_default(),
1476                );
1477                let prompt = escape(
1478                    node.strings
1479                        .get(&property_id::PROMPT)
1480                        .map(String::as_str)
1481                        .unwrap_or_default(),
1482                );
1483                let input_type = if node.f32_property(property_id::IS_SECURE, 0.0) != 0.0 {
1484                    "password"
1485                } else {
1486                    "text"
1487                };
1488                let textfield_class = class_attr("pathland-textfield");
1489                format!(
1490                    "<label{data_id}{event}{aria}{textfield_class}{style}><span class=\"pathland-label\">{label}</span><input class=\"pathland-input\" type=\"{input_type}\" value=\"{value}\" placeholder=\"{prompt}\"></label>"
1491                )
1492            }
1493            component_type::TEXT_EDITOR => {
1494                let value = escape(node.text.as_deref().unwrap_or_default());
1495                format!("<textarea{data_id}{event}{aria} class=\"pathland-input\" rows=\"4\"{style}>{value}</textarea>")
1496            }
1497            component_type::IMAGE => {
1498                let src = escape(
1499                    node.strings
1500                        .get(&property_id::IMAGE_SOURCE)
1501                        .map(String::as_str)
1502                        .unwrap_or_default(),
1503                );
1504                // The accessibility label (`LABEL` / `.accessibilityLabel`) is the
1505                // image's `alt` text (empty = decorative).
1506                let alt = escape(
1507                    node.strings
1508                        .get(&property_id::LABEL)
1509                        .map(String::as_str)
1510                        .unwrap_or_default(),
1511                );
1512                format!("<img{data_id}{event}{aria} src=\"{src}\" alt=\"{alt}\"{style}>")
1513            }
1514            component_type::AUDIO => {
1515                let src = escape(
1516                    node.strings
1517                        .get(&property_id::AUDIO_SOURCE)
1518                        .map(String::as_str)
1519                        .unwrap_or_default(),
1520                );
1521                if node.children.is_empty() {
1522                    // Renderer-native controls (the default `AudioStyle` emits no
1523                    // children); the app supplies only the source.
1524                    format!("<audio{data_id}{event}{aria} src=\"{src}\" controls{style}></audio>")
1525                } else {
1526                    // Defensive: an `AUDIO` node with custom children — the central
1527                    // injection above already added the hidden media + marker.
1528                    format!(
1529                        "<div{data_id}{event}{aria} class=\"pathland-media\"{style}>{children}</div>"
1530                    )
1531                }
1532            }
1533            component_type::VIDEO => {
1534                let src = escape(
1535                    node.strings
1536                        .get(&property_id::VIDEO_SOURCE)
1537                        .map(String::as_str)
1538                        .unwrap_or_default(),
1539                );
1540                if node.children.is_empty() {
1541                    // Renderer-native controls (the default `VideoStyle`).
1542                    format!("<video{data_id}{event}{aria} src=\"{src}\" controls{style}></video>")
1543                } else {
1544                    // Defensive: a `VIDEO` node with custom children.
1545                    format!(
1546                        "<div{data_id}{event}{aria} class=\"pathland-media\"{style}>{children}</div>"
1547                    )
1548                }
1549            }
1550            component_type::COLOR => {
1551                let tag = semantic.unwrap_or("div");
1552                let color = node.u32_property(property_id::COLOR, 0xFF00_0000);
1553                // Layout-greedy (SwiftUI Color): expands to the available space
1554                // unless a size modifier constrains it.
1555                format!(
1556                    "<{tag}{data_id}{event}{aria} style=\"flex:1 1 auto;align-self:stretch;background-color:{};{css}\">{children}</{tag}>",
1557                    rgba(color)
1558                )
1559            }
1560            component_type::SHAPE => {
1561                let kind = node.f32_property(property_id::SHAPE_KIND, 0.0).round() as u8;
1562                let fill = node.u32_property(property_id::COLOR, 0xFF00_0000);
1563                let w = node.f32_property(property_id::WIDTH, 100.0);
1564                let h = node.f32_property(property_id::HEIGHT, 100.0);
1565                let base = format!(
1566                    "width:{};height:{};background-color:{};{css}",
1567                    size_css(w),
1568                    size_css(h),
1569                    rgba(fill)
1570                );
1571                // A semantic role retags the CSS-shape divs; the Path branch is an
1572                // `<svg>` and ignores the role.
1573                let tag = semantic.unwrap_or("div");
1574                match kind {
1575                    0 | 4 => format!("<{tag}{data_id}{event}{aria} style=\"{base}border-radius:50%;\"></{tag}>"),
1576                    2 => {
1577                        let r = node.f32_property(property_id::BORDER_RADIUS, 8.0);
1578                        format!("<{tag}{data_id}{event}{aria} style=\"{base}border-radius:{r}px;\"></{tag}>")
1579                    }
1580                    3 => format!("<{tag}{data_id}{event}{aria} style=\"{base}border-radius:9999px;\"></{tag}>"),
1581                    5 => format!(
1582                        "<svg{data_id}{event}{aria} width=\"100\" height=\"100\" viewBox=\"0 0 100 100\"><rect width=\"100\" height=\"100\" fill=\"{}\"/></svg>",
1583                        rgba(fill)
1584                    ),
1585                    _ => format!("<{tag}{data_id}{event}{aria} style=\"{base}\"></{tag}>"),
1586                }
1587            }
1588            component_type::DIVIDER => {
1589                let width = node.f32_property(property_id::BORDER_WIDTH, 1.0);
1590                let color = node
1591                    .properties
1592                    .get(&property_id::COLOR)
1593                    .copied()
1594                    .map(rgba)
1595                    .unwrap_or_else(|| "rgba(0,0,0,0.2)".to_string());
1596                // Layout-greedy on the cross axis (LAYOUT.md): the separator
1597                // spans the stack's available cross size unless a size frame
1598                // overrides it (SwiftUI `Divider()` semantics). The derived css
1599                // (e.g. cross-axis `align-self:stretch`) is appended so the
1600                // divider participates in stack layout like any other child.
1601                format!(
1602                    "<div{data_id}{event}{aria} style=\"height:0;width:100%;border-top:{width}px solid {color};{derived}\"></div>"
1603                )
1604            }
1605            component_type::PROGRESS_VIEW => {
1606                let indeterminate = node.f32_property(property_id::IS_INDETERMINATE, 0.0) != 0.0
1607                    || node.f32_property(property_id::PROGRESS, -1.0) < 0.0;
1608if indeterminate {
1609                    let spinner_class = class_attr("pathland-spinner");
1610                    format!("<div{data_id}{spinner_class}{style}></div>")
1611                } else {
1612                    let value = node.f32_property(property_id::PROGRESS, 0.0);
1613                    let max = node.f32_property(property_id::MAX_VALUE, 1.0);
1614                    format!(
1615                        "<progress{data_id}{event}{aria} value=\"{value}\" max=\"{max}\"{style}></progress>"
1616                    )
1617                }
1618            }
1619            component_type::GAUGE => {
1620                let min = node.f32_property(property_id::MIN_VALUE, 0.0);
1621                let max = node.f32_property(property_id::MAX_VALUE, 1.0);
1622                let value = node.f32_property(property_id::VALUE, min);
1623                let pct = if max > min {
1624                    ((value - min) / (max - min)).clamp(0.0, 1.0) * 100.0
1625                } else {
1626                    0.0
1627                };
1628                let gauge_class = class_attr("pathland-gauge");
1629                // The bounds ride as `data-min`/`data-max` so the DOM client can
1630                // recompute the percentage when VALUE changes (its gauge handler
1631                // reads them), mirroring the stepper's `.pathland-stepper-range`.
1632                format!(
1633                    "<div{data_id}{event}{aria}{gauge_class} data-min=\"{min}\" data-max=\"{max}\"{style}><div style=\"width:{pct}%\"></div></div>"
1634                )
1635            }
1636            component_type::GRID | component_type::LAZY_VGRID | component_type::LAZY_HGRID => {
1637                let tag = semantic.unwrap_or("div");
1638                // Explicit rows (spec §GridRow): a grid's children are cells or
1639                // `GRID_ROW`s; a `GRID_ROW`'s children are one row's cells.
1640                let explicit_rows = node
1641                    .children
1642                    .iter()
1643                    .any(|&c| nodes.get(&c).is_some_and(|n| n.component == component_type::GRID_ROW));
1644                // The effective 1fr column count: the `GRID_TRACKS` token count, else
1645                // `GRID_COLUMNS`, else the widest row when rows are explicit
1646                // (a short row leaves trailing columns empty — never pulls the
1647                // next row's cells forward).
1648                let track_count = grid_tracks(node).map(|t| t.len() as u32);
1649                let base_columns = grid_columns(node);
1650                let effective_columns = if let Some(n) = track_count {
1651                    Some(n)
1652                } else if explicit_rows {
1653                    let mut width = base_columns.map(|c| c as usize).unwrap_or(0);
1654                    let mut run = 0usize;
1655                    for &child in &node.children {
1656                        if nodes.get(&child).is_some_and(|n| n.component == component_type::GRID_ROW) {
1657                            width = width.max(nodes.get(&child).map(|n| n.children.len()).unwrap_or(0));
1658                            run = 0;
1659                        } else {
1660                            run += 1;
1661                            width = width.max(run);
1662                        }
1663                    }
1664                    base_columns.or(Some((width.max(1)) as u32))
1665                } else {
1666                    base_columns
1667                };
1668                let grid_css = grid_style(node, effective_columns);
1669                let combined = format!("{grid_css}{css}");
1670                let grid_style = style_attr(&combined);
1671                // Per-cell alignment (spec/PRIMITIVES.md §grid model): each cell
1672                // keeps its own size (Fixed/Hug) and is positioned by the grid's
1673                // ALIGNMENT on both axes; a `FILL`-sized cell (or a greedy
1674                // filler like `COLOR`/`SCROLLVIEW`) stretches to fill its track.
1675                let align = node.f32_property(property_id::ALIGNMENT, 0.0) as u8;
1676                let pos_h = |stretch: bool| if stretch { "stretch" } else { align_h(align) };
1677                let pos_v = |stretch: bool| if stretch { "stretch" } else { align_v(align) };
1678                let cell_shell = |cell_html: String, row: usize, col: usize, cell: u32| {
1679                    let placement = if explicit_rows {
1680                        format!("grid-row:{};grid-column:{};", row + 1, col + 1)
1681                    } else {
1682                        String::new()
1683                    };
1684                    // The cell's Z_INDEX orders its shell (the grid item) among
1685                    // overlapping siblings; see the ZSTACK wrapper note.
1686                    let z = nodes
1687                        .get(&cell)
1688                        .map(|cn| cn.f32_property(property_id::Z_INDEX, 0.0))
1689                        .filter(|z| *z != 0.0)
1690                        .map(|z| format!("z-index:{};", z as i32))
1691                        .unwrap_or_default();
1692                    format!(
1693                        "<div style=\"{placement}{z}justify-self:{};align-self:{};\">{cell_html}</div>",
1694                        pos_h(fills_axis(nodes, cell, true, false)),
1695                        pos_v(fills_axis(nodes, cell, false, false)),
1696                    )
1697                };
1698                let inner: String = if explicit_rows {
1699                    // Flatten the explicit row structure into (row, col) cells,
1700                    // keeping each GRID_ROW as a transparent `display:contents`
1701                    // wrapper (its id hydrates the DOM client's registry).
1702                    let columns = effective_columns.unwrap_or(1) as usize;
1703                    let mut out = String::new();
1704                    let mut row = 0usize;
1705                    let mut col = 0usize;
1706                    for &child in &node.children {
1707                        let Some(cn) = nodes.get(&child) else { continue };
1708                        if cn.component == component_type::GRID_ROW {
1709                            // A GRID_ROW starts a new row: advance past an
1710                            // in-progress bare-cell run (col > 0); a prior
1711                            // GRID_ROW already advanced `row`.
1712                            if col > 0 {
1713                                row += 1;
1714                            }
1715                            col = 0;
1716                            let mut cells = String::new();
1717                            for &cell in &cn.children {
1718                                let cell_html = self.render_node(nodes, cell, Some(node.component));
1719                                if !cell_html.is_empty() {
1720                                    cells.push_str(&cell_shell(cell_html, row, col, cell));
1721                                }
1722                                col += 1;
1723                            }
1724                            out.push_str(&format!(
1725                                "<div data-pathland-id=\"{child}\" style=\"display:contents\">{cells}</div>"
1726                            ));
1727                            row += 1;
1728                            col = 0;
1729                        } else {
1730                            if col >= columns {
1731                                row += 1;
1732                                col = 0;
1733                            }
1734                            let cell_html = self.render_node(nodes, child, Some(node.component));
1735                            if !cell_html.is_empty() {
1736                                out.push_str(&cell_shell(cell_html, row, col, child));
1737                                col += 1;
1738                            }
1739                        }
1740                    }
1741                    out
1742                } else {
1743                    node.children
1744                        .iter()
1745                        .filter_map(|&child| {
1746                            let cell_html = self.render_node(nodes, child, Some(node.component));
1747                            if cell_html.is_empty() {
1748                                None
1749                            } else {
1750                                Some(cell_shell(cell_html, 0, 0, child))
1751                            }
1752                        })
1753                        .collect()
1754                };
1755                format!("<{tag}{data_id}{event}{aria}{grid_style}>{inner}</{tag}>")
1756            }
1757            component_type::SCROLLVIEW => {
1758                let tag = semantic.unwrap_or("div");
1759                // Layout-greedy on both axes (LAYOUT.md): a scroll region fills
1760                // the available space (SwiftUI `ScrollView` semantics) unless a
1761                // size frame overrides it. The **first child** is the content
1762                // (spec/PRIMITIVES.md §ScrollView); additional children are not
1763                // rendered.
1764                let combined = format!("flex:1 1 auto;align-self:stretch;overflow:auto;{css}");
1765                let scroll_style = style_attr(&combined);
1766                let content = node
1767                    .children
1768                    .first()
1769                    .map(|&c| self.render_node(nodes, c, Some(node.component)))
1770                    .unwrap_or_default();
1771                format!("<{tag}{data_id}{event}{aria}{scroll_style}>{content}</{tag}>")
1772            }
1773            component_type::ZSTACK => {
1774                let tag = semantic.unwrap_or("div");
1775                // ZStack (SwiftUI `ZStack` / Compose `Box`): children overlap in
1776                // one box. The container hugs to its largest child by default
1777                // (grid `max-content` tracks); a Fixed/FILL frame sizes it
1778                // exactly; a FILL child propagates (`100%`). Each child keeps
1779                // its own size and is positioned by `ALIGNMENT` on both axes
1780                // (spec/PRIMITIVES.md §ZStack).
1781                let align = node.f32_property(property_id::ALIGNMENT, 0.0) as u8;
1782                let pos_h = |stretch: bool| if stretch { "stretch" } else { align_h(align) };
1783                let pos_v = |stretch: bool| if stretch { "stretch" } else { align_v(align) };
1784                let inner: String = node
1785                    .children
1786                    .iter()
1787                    .filter_map(|&child| {
1788                        let child_html = self.render_node(nodes, child, Some(node.component));
1789                        if child_html.is_empty() {
1790                            None
1791                        } else {
1792                            // The child's Z_INDEX orders its shell (the grid item)
1793                            // among the overlapping siblings — z-index on the child
1794                            // itself is inert (it is not a grid/flex item). EVERY
1795                            // shell carries an explicit z-index (the child's Z_INDEX,
1796                            // else 0) so overlapping grid items paint deterministically
1797                            // in every browser (WebKit paints a non-fill grid item
1798                            // below a full-area sibling when both are z-index:auto).
1799                            let z = nodes
1800                                .get(&child)
1801                                .map(|cn| cn.f32_property(property_id::Z_INDEX, 0.0) as i32)
1802                                .unwrap_or(0);
1803                            Some(format!(
1804                                "<div style=\"grid-area:1/1;z-index:{z};justify-self:{};align-self:{};\">{child_html}</div>",
1805                                pos_h(fills_axis(nodes, child, true, false)),
1806                                pos_v(fills_axis(nodes, child, false, false)),
1807                            ))
1808                        }
1809                    })
1810                    .collect();
1811                // Container sizing: Hug → max-content, or `100%` on an axis a
1812                // FILL child propagates; Fixed/FILL frames come from `css`.
1813                let mut zcss = format!(
1814                    "display:grid;isolation:isolate;grid-template-columns:minmax(0,1fr);grid-template-rows:minmax(0,1fr);justify-items:{};align-items:{};",
1815                    align_h(align),
1816                    align_v(align)
1817                );
1818                for (axis_h, prop, hug_css, fill_css) in [
1819                    (true, property_id::WIDTH, "width:max-content;", "width:100%;"),
1820                    (false, property_id::HEIGHT, "height:max-content;", "height:100%;"),
1821                ] {
1822                    let hint = node.properties.get(&prop).map(|b| f32::from_bits(*b));
1823                    let hug = hint.is_none() || hint == Some(size::HUG_CONTENT);
1824                    if hug {
1825                        zcss.push_str(if fills_axis(nodes, id, axis_h, false) {
1826                            fill_css
1827                        } else {
1828                            hug_css
1829                        });
1830                    }
1831                }
1832                let combined = format!("{zcss}{css}{derived}");
1833                let zstyle = style_attr(&combined);
1834                format!("<{tag}{data_id}{event}{aria}{zstyle}>{inner}</{tag}>")
1835            }
1836            component_type::STEPPER => {
1837                let min = node.f32_property(property_id::MIN_VALUE, 0.0);
1838                let max = node.f32_property(property_id::MAX_VALUE, 100.0);
1839                let value = node.f32_property(property_id::VALUE, min);
1840                let step = node.f32_property(property_id::STEP_VALUE, 1.0);
1841                // Composite shell matching the DOM client's `stepperShell()`
1842                // (lib/typescript/src/elements.ts): the +/- buttons and a hidden
1843                // `.pathland-stepper-range` span carrying the bounds for the
1844                // client's click math. The value is displayed in the first span,
1845                // rounded to 2 decimals like the client.
1846                let display = format!("{}", (value * 100.0).round() / 100.0);
1847                let stepper_class = class_attr("pathland-stepper");
1848                format!(
1849                    "<div{data_id}{event}{aria}{stepper_class}{style}><button type=\"button\" data-step=\"-1\">−</button><span>{display}</span><button type=\"button\" data-step=\"1\">+</button><span class=\"pathland-stepper-range\" hidden data-min=\"{min}\" data-max=\"{max}\" data-step=\"{step}\"></span></div>"
1850                )
1851            }
1852            component_type::DATE_PICKER => {
1853                let mode = node.f32_property(property_id::DATE_PICKER_MODE, 0.0).round() as u8;
1854                let (t, value) = match node.date {
1855                    Some((days, millis)) => match mode {
1856                        1 => {
1857                            let (hh, mm, _) = millis_to_hms(millis);
1858                            ("time", format!("{hh:02}:{mm:02}"))
1859                        }
1860                        2 => {
1861                            let (y, m, d) = days_to_date(days);
1862                            let (hh, mm, _) = millis_to_hms(millis);
1863                            (
1864                                "datetime-local",
1865                                format!("{y:04}-{m:02}-{d:02}T{hh:02}:{mm:02}"),
1866                            )
1867                        }
1868                        _ => {
1869                            let (y, m, d) = days_to_date(days);
1870                            ("date", format!("{y:04}-{m:02}-{d:02}"))
1871                        }
1872                    },
1873                    None => ("date", String::new()),
1874                };
1875                format!("<input{data_id}{event}{aria} type=\"{t}\" value=\"{value}\"{style}>")
1876            }
1877            component_type::PICKER => {
1878                let selected = node.u32_property(property_id::SELECTION, 0);
1879                let options: String = node
1880                    .children
1881                    .iter()
1882                    .enumerate()
1883                    .map(|(i, &child)| {
1884                        let label = nodes
1885                            .get(&child)
1886                            .and_then(|n| n.text.clone())
1887                            .unwrap_or_default();
1888                        let sel = if i as u32 == selected { " selected" } else { "" };
1889                        // Each option carries the child node's id so the DOM
1890                        // client can hydrate/reconcile it against the child's
1891                        // own TREE/PARAMETER deltas.
1892                        format!(
1893                            "<option data-pathland-id=\"{child}\" value=\"{i}\"{sel}>{}</option>",
1894                            escape(&label)
1895                        )
1896                    })
1897                    .collect();
1898                format!("<select{data_id}{event}{aria}{style}>{options}</select>")
1899            }
1900            component_type::MENU => {
1901                // Composite shell matching the DOM client's `menuShell()`
1902                // (lib/typescript/src/elements.ts): the trigger holds the label,
1903                // and TREE children are routed into `.pathland-menu-items` (the
1904                // client's `childrenContainer`). `role="menu"` is the control's
1905                // intrinsic semantics (not a `ROLE` property).
1906                let label = escape(node.text.as_deref().unwrap_or_default());
1907                let menu_class = class_attr("pathland-menu");
1908                format!(
1909                    "<div{data_id}{event}{aria}{menu_class} role=\"menu\"{style}><div class=\"pathland-menu-trigger\">{label}</div><div class=\"pathland-menu-items\">{children}</div></div>"
1910                )
1911            }
1912            component_type::COLOR_PICKER => {
1913                let color = node.u32_property(property_id::COLOR_VALUE, 0xFF00_0000);
1914                let hex = format!(
1915                    "#{:02x}{:02x}{:02x}",
1916                    (color >> 16) & 0xFF,
1917                    (color >> 8) & 0xFF,
1918                    color & 0xFF
1919                );
1920                format!("<input{data_id}{event}{aria} type=\"color\" value=\"{hex}\"{style}>")
1921            }
1922            component_type::COMMENT => String::new(),
1923            _ => String::new(),
1924        };
1925        if self.debug_comments {
1926            format!("{}{}", debug::node_comment(id, node), element)
1927        } else {
1928            element
1929        }
1930    }
1931}
1932
1933    fn wrap_stack(
1934        id: u32,
1935        direction: &str,
1936        semantic: Option<&'static str>,
1937        node: &Node,
1938        media_attr: &str,
1939        children: &str,
1940        css: &str,
1941        event: &str,
1942        aria: &str,
1943    ) -> String {
1944        let tag = semantic.unwrap_or("div");
1945        let alignment = node
1946            .properties
1947            .get(&property_id::ALIGNMENT)
1948            .map(|bits| f32::from_bits(*bits) as u8)
1949            .unwrap_or(3);
1950        // ALIGNMENT positions cross-axis children; the default is hug
1951        // (flex-start), never CSS `align-items: stretch` — only FILL-sized
1952        // children stretch (they carry `align-self:stretch`/`100%`), LAYOUT.md.
1953        let align = match alignment {
1954            0 => "flex-start",
1955            1 => "center",
1956            2 => "flex-end",
1957            _ => "flex-start",
1958        };
1959        let combined = format!(
1960            "display:flex;flex-direction:{direction};align-items:{align};{css}"
1961        );
1962        let stack_style = style_attr(&combined);
1963        format!(
1964            "<{tag} data-pathland-id=\"{id}\"{}{media_attr}{event}{aria}{stack_style}>{children}</{tag}>",
1965            slot_attrs(node),
1966        )
1967    }
1968
1969/// Inline grid CSS: `display:grid` + `justify-items`/`align-items` from the
1970/// grid `ALIGNMENT` (cells position within their tracks; default Leading/start),
1971/// plus the fixed track templates — a `GRID_TRACKS` spec (per-track sizes) or
1972/// `columns` (GRID/LAZY_VGRID; the effective count) and `HEIGHT` rows
1973/// (GRID/LAZY_HGRID) as equal `1fr` tracks. `FILL`/absent count = auto-fit (no
1974/// template; `LAZY_HGRID` columns auto-flow).
1975fn grid_style(node: &Node, columns: Option<u32>) -> String {
1976    let align = node.f32_property(property_id::ALIGNMENT, 0.0) as u8;
1977    let mut css = format!(
1978        "display:grid;justify-items:{};align-items:{};",
1979        align_h(align),
1980        align_v(align)
1981    );
1982    let tracks_css = grid_tracks_css(node);
1983    match node.component {
1984        component_type::GRID => {
1985            if let Some(t) = &tracks_css {
1986                css.push_str(&format!("grid-template-columns:{t};"));
1987            } else if let Some(n) = columns {
1988                css.push_str(&format!("grid-template-columns:repeat({n},minmax(0,1fr));"));
1989            }
1990            if let Some(n) = grid_rows(node) {
1991                css.push_str(&format!("grid-template-rows:repeat({n},minmax(0,1fr));"));
1992            }
1993        }
1994        component_type::LAZY_HGRID => {
1995            if let Some(t) = &tracks_css {
1996                css.push_str(&format!("grid-template-rows:{t};"));
1997            } else if let Some(n) = grid_rows(node) {
1998                css.push_str(&format!("grid-template-rows:repeat({n},minmax(0,1fr));"));
1999            }
2000            css.push_str("grid-auto-flow:column;grid-auto-columns:minmax(0,1fr);");
2001        }
2002        _ => {
2003            if let Some(t) = &tracks_css {
2004                css.push_str(&format!("grid-template-columns:{t};"));
2005            } else if let Some(n) = columns {
2006                css.push_str(&format!("grid-template-columns:repeat({n},minmax(0,1fr));"));
2007            }
2008        }
2009    }
2010    css
2011}
2012
2013/// A `style="…"` attribute for a non-empty CSS fragment.
2014fn style_attr(css: &str) -> String {
2015    if css.is_empty() {
2016        String::new()
2017    } else {
2018        format!(" style=\"{css}\"")
2019    }
2020}
2021
2022/// A `class="…"` attribute for a non-empty Tailwind class list.
2023fn class_attr(classes: &str) -> String {
2024    let classes = classes.trim();
2025    if classes.is_empty() {
2026        String::new()
2027    } else {
2028        format!(" class=\"{classes}\"")
2029    }
2030}
2031
2032/// Escape text for inclusion in an HTML body.
2033fn escape(input: &str) -> String {
2034    input
2035        .replace('&', "&amp;")
2036        .replace('<', "&lt;")
2037        .replace('>', "&gt;")
2038        .replace('"', "&quot;")
2039        .replace('\'', "&#39;")
2040}
2041
2042/// Slot attributes for a navigation/conditional slot node: the current path as
2043/// `data-pathland-route` (the DOM client mirrors it into the URL), the
2044/// `data-pathland-transition` swap hint (`platform`/`fade`/`slide`/`scale`),
2045/// and the `data-pathland-nav-chrome` chrome mode (`custom` only — the default
2046/// is the renderer's own chrome, so it is omitted). Empty for ordinary nodes.
2047/// See `spec/DSL.md` §4.5 and `spec/MODIFIERS.md`.
2048fn slot_attrs(node: &Node) -> String {
2049    let mut attrs = String::new();
2050    if let Some(path) = node.strings.get(&property_id::ROUTE) {
2051        attrs.push_str(&format!(" data-pathland-route=\"{}\"", escape(path)));
2052    }
2053    let chrome = node.f32_property(property_id::NAV_CHROME, 0.0).round() as u8;
2054    if chrome == 1 {
2055        attrs.push_str(" data-pathland-nav-chrome=\"custom\"");
2056    }
2057    // Back-stack depth: lets the DOM client hydrate the renderer's default back
2058    // button (shown when a PlatformDefault slot is deeper than its root) from the
2059    // SSR HTML before any delta frame arrives.
2060    let depth = node.u32_property(property_id::NAV_DEPTH, 1).max(1);
2061    if depth > 1 {
2062        attrs.push_str(&format!(" data-pathland-depth=\"{depth}\""));
2063    }
2064    let transition = node.f32_property(property_id::TRANSITION, 0.0).round() as u8;
2065    if transition != 0 {
2066        let name = match transition {
2067            2 => "fade",
2068            3 => "slide",
2069            4 => "scale",
2070            _ => "platform",
2071        };
2072        attrs.push_str(&format!(" data-pathland-transition=\"{name}\""));
2073    }
2074    attrs
2075}
2076
2077#[cfg(test)]
2078mod tests {
2079    use super::*;
2080    use pathland_core::Opcode;
2081
2082    #[test]
2083    fn renders_vstack_of_text() {
2084        // Hand-craft a self-contained frame: VSTACK(1) with a TEXT(2) child.
2085        let mut opcodes = Vec::new();
2086        let mut strings = Vec::new();
2087        opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 1, component_type::VSTACK as u32, 0));
2088        opcodes.push(Opcode::new(category::TREE, tree::INSERT_CHILD, 0, 0, 1, pathland_core::APPEND));
2089        opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 2, component_type::TEXT as u32, 0));
2090        opcodes.push(Opcode::new(category::TREE, tree::INSERT_CHILD, 0, 1, 2, pathland_core::APPEND));
2091        strings.extend_from_slice(&(5u32).to_le_bytes());
2092        strings.extend_from_slice(b"Hello");
2093        opcodes.push(Opcode::new(category::PARAMETER, parameter::SET_TEXT, 0, 2, 0, 0));
2094
2095        let renderer = HtmlRenderer::new();
2096        let html = renderer.render_document(&opcodes, &strings, 1);
2097        assert!(html.contains("<!DOCTYPE html>"));
2098        assert!(html.contains("flex-direction:column"), "stack inline flex");
2099        assert!(html.contains("<span data-pathland-id=\"2\">Hello</span>"));
2100        assert!(html.contains("data-pathland-id=\"1\""));
2101    }
2102
2103    #[test]
2104    fn escapes_text() {
2105        assert_eq!(escape("<a & b>"), "&lt;a &amp; b&gt;");
2106    }
2107
2108    #[test]
2109    fn renders_border() {
2110        use pathland_core::value_type;
2111
2112        let mut opcodes = Vec::new();
2113        let mut strings = Vec::new();
2114        opcodes.push(Opcode::new(
2115            category::TREE,
2116            tree::CREATE_NODE,
2117            0,
2118            1,
2119            component_type::TEXT as u32,
2120            0,
2121        ));
2122        strings.extend_from_slice(&(5u32).to_le_bytes());
2123        strings.extend_from_slice(b"Hello");
2124        opcodes.push(Opcode::new(category::PARAMETER, parameter::SET_TEXT, 0, 1, 0, 0));
2125        // border: width 2, opaque red, all edges
2126        opcodes.push(Opcode::new(
2127            category::PARAMETER,
2128            parameter::SET_PROPERTY,
2129            0,
2130            1,
2131            ((value_type::F32 as u32) << 16) | property_id::BORDER_WIDTH as u32,
2132            2.0f32.to_bits(),
2133        ));
2134        opcodes.push(Opcode::new(
2135            category::PARAMETER,
2136            parameter::SET_PROPERTY,
2137            0,
2138            1,
2139            ((value_type::COLOR as u32) << 16) | property_id::BORDER_COLOR as u32,
2140            0xFFFF_0000,
2141        ));
2142        opcodes.push(Opcode::new(
2143            category::PARAMETER,
2144            parameter::SET_PROPERTY,
2145            0,
2146            1,
2147            ((value_type::U32 as u32) << 16) | property_id::BORDER_EDGES as u32,
2148            border_edges::ALL,
2149        ));
2150
2151        let renderer = HtmlRenderer::new();
2152        let html = renderer.render_document(&opcodes, &strings, 1);
2153
2154        assert!(html.contains("border-top:2px solid rgba(255,0,0,1)"));
2155        assert!(html.contains("border-left:2px solid rgba(255,0,0,1)"));
2156        assert!(html.contains("border-bottom:2px solid rgba(255,0,0,1)"));
2157        assert!(html.contains("border-right:2px solid rgba(255,0,0,1)"));
2158    }
2159
2160    #[test]
2161    fn renders_toggle_switch_checked() {
2162        use pathland_core::value_type;
2163
2164        let mut opcodes = Vec::new();
2165        let mut strings = Vec::new();
2166        opcodes.push(Opcode::new(
2167            category::TREE,
2168            tree::CREATE_NODE,
2169            0,
2170            1,
2171            component_type::TOGGLE as u32,
2172            0,
2173        ));
2174        strings.extend_from_slice(&(7u32).to_le_bytes());
2175        strings.extend_from_slice(b"Enabled");
2176        opcodes.push(Opcode::new(category::PARAMETER, parameter::SET_TEXT, 0, 1, 0, 0));
2177        opcodes.push(Opcode::new(
2178            category::PARAMETER,
2179            parameter::SET_PROPERTY,
2180            0,
2181            1,
2182            ((value_type::F32 as u32) << 16) | property_id::TOGGLE_STYLE as u32,
2183            0.0f32.to_bits(),
2184        ));
2185        opcodes.push(Opcode::new(
2186            category::PARAMETER,
2187            parameter::SET_PROPERTY,
2188            0,
2189            1,
2190            ((value_type::U8 as u32) << 16) | property_id::SELECTED as u32,
2191            1,
2192        ));
2193
2194        let renderer = HtmlRenderer::new();
2195        let html = renderer.render_document(&opcodes, &strings, 1);
2196
2197        assert!(html.contains("<input type=\"checkbox\" role=\"switch\" checked>"));
2198        assert!(html.contains("<span class=\"pathland-text\">Enabled</span>"));
2199    }
2200
2201    #[test]
2202    fn renders_toggle_checkbox_unchecked() {
2203        use pathland_core::value_type;
2204
2205        let mut opcodes = Vec::new();
2206        let mut strings = Vec::new();
2207        opcodes.push(Opcode::new(
2208            category::TREE,
2209            tree::CREATE_NODE,
2210            0,
2211            1,
2212            component_type::TOGGLE as u32,
2213            0,
2214        ));
2215        strings.extend_from_slice(&(5u32).to_le_bytes());
2216        strings.extend_from_slice(b"Email");
2217        opcodes.push(Opcode::new(category::PARAMETER, parameter::SET_TEXT, 0, 1, 0, 0));
2218        opcodes.push(Opcode::new(
2219            category::PARAMETER,
2220            parameter::SET_PROPERTY,
2221            0,
2222            1,
2223            ((value_type::F32 as u32) << 16) | property_id::TOGGLE_STYLE as u32,
2224            1.0f32.to_bits(),
2225        ));
2226        opcodes.push(Opcode::new(
2227            category::PARAMETER,
2228            parameter::SET_PROPERTY,
2229            0,
2230            1,
2231            ((value_type::U8 as u32) << 16) | property_id::SELECTED as u32,
2232            0,
2233        ));
2234
2235        let renderer = HtmlRenderer::new();
2236        let html = renderer.render_document(&opcodes, &strings, 1);
2237
2238        assert!(html.contains("<input type=\"checkbox\">"));
2239        assert!(!html.contains("<input type=\"checkbox\" role=\"switch\""));
2240        assert!(html.contains("<span class=\"pathland-text\">Email</span>"));
2241    }
2242
2243    #[test]
2244    fn renders_toggle_button_pressed() {
2245        use pathland_core::value_type;
2246
2247        let mut opcodes = Vec::new();
2248        let mut strings = Vec::new();
2249        opcodes.push(Opcode::new(
2250            category::TREE,
2251            tree::CREATE_NODE,
2252            0,
2253            1,
2254            component_type::TOGGLE as u32,
2255            0,
2256        ));
2257        strings.extend_from_slice(&(4u32).to_le_bytes());
2258        strings.extend_from_slice(b"Mute");
2259        opcodes.push(Opcode::new(category::PARAMETER, parameter::SET_TEXT, 0, 1, 0, 0));
2260        opcodes.push(Opcode::new(
2261            category::PARAMETER,
2262            parameter::SET_PROPERTY,
2263            0,
2264            1,
2265            ((value_type::F32 as u32) << 16) | property_id::TOGGLE_STYLE as u32,
2266            2.0f32.to_bits(),
2267        ));
2268        opcodes.push(Opcode::new(
2269            category::PARAMETER,
2270            parameter::SET_PROPERTY,
2271            0,
2272            1,
2273            ((value_type::U8 as u32) << 16) | property_id::SELECTED as u32,
2274            1,
2275        ));
2276
2277        let renderer = HtmlRenderer::new();
2278        let html = renderer.render_document(&opcodes, &strings, 1);
2279
2280        assert!(html.contains("<button") && html.contains("type=\"button\""));
2281        assert!(html.contains("aria-pressed=\"true\""));
2282        assert!(html.contains("<span class=\"pathland-text\">Mute</span>"));
2283        assert!(html.contains("</button>"));
2284    }
2285
2286    #[test]
2287    fn renders_slider() {
2288        use pathland_core::value_type;
2289
2290        let mut opcodes = Vec::new();
2291        let mut strings = Vec::new();
2292        opcodes.push(Opcode::new(
2293            category::TREE,
2294            tree::CREATE_NODE,
2295            0,
2296            1,
2297            component_type::SLIDER as u32,
2298            0,
2299        ));
2300        strings.extend_from_slice(&(6u32).to_le_bytes());
2301        strings.extend_from_slice(b"Volume");
2302        opcodes.push(Opcode::new(category::PARAMETER, parameter::SET_TEXT, 0, 1, 0, 0));
2303        opcodes.push(Opcode::new(
2304            category::PARAMETER,
2305            parameter::SET_PROPERTY,
2306            0,
2307            1,
2308            ((value_type::F32 as u32) << 16) | property_id::MIN_VALUE as u32,
2309            0.0f32.to_bits(),
2310        ));
2311        opcodes.push(Opcode::new(
2312            category::PARAMETER,
2313            parameter::SET_PROPERTY,
2314            0,
2315            1,
2316            ((value_type::F32 as u32) << 16) | property_id::MAX_VALUE as u32,
2317            1.0f32.to_bits(),
2318        ));
2319        opcodes.push(Opcode::new(
2320            category::PARAMETER,
2321            parameter::SET_PROPERTY,
2322            0,
2323            1,
2324            ((value_type::F32 as u32) << 16) | property_id::VALUE as u32,
2325            0.5f32.to_bits(),
2326        ));
2327
2328        let renderer = HtmlRenderer::new();
2329        let html = renderer.render_document(&opcodes, &strings, 1);
2330
2331        assert!(html.contains(
2332            "<input type=\"range\" min=\"0\" max=\"1\" step=\"any\" value=\"0.5\">"
2333        ));
2334        assert!(html.contains("<span class=\"pathland-text\">Volume</span>"));
2335    }
2336
2337    #[test]
2338    fn renders_text_field() {
2339        use pathland_core::value_type;
2340
2341        let mut opcodes = Vec::new();
2342        let mut strings = Vec::new();
2343        opcodes.push(Opcode::new(
2344            category::TREE,
2345            tree::CREATE_NODE,
2346            0,
2347            1,
2348            component_type::TEXT_FIELD as u32,
2349            0,
2350        ));
2351        // value (node text)
2352        strings.extend_from_slice(&(3u32).to_le_bytes());
2353        strings.extend_from_slice(b"Bob");
2354        opcodes.push(Opcode::new(category::PARAMETER, parameter::SET_TEXT, 0, 1, 0, 0));
2355        // label (STRING property)
2356        strings.extend_from_slice(&(5u32).to_le_bytes());
2357        strings.extend_from_slice(b"Name:");
2358        opcodes.push(Opcode::new(
2359            category::PARAMETER,
2360            parameter::SET_PROPERTY,
2361            0,
2362            1,
2363            ((value_type::STRING as u32) << 16) | property_id::LABEL as u32,
2364            7, // relative offset of the label entry in the string section
2365        ));
2366        // prompt (STRING property)
2367        strings.extend_from_slice(&(5u32).to_le_bytes());
2368        strings.extend_from_slice(b"Enter");
2369        opcodes.push(Opcode::new(
2370            category::PARAMETER,
2371            parameter::SET_PROPERTY,
2372            0,
2373            1,
2374            ((value_type::STRING as u32) << 16) | property_id::PROMPT as u32,
2375            16, // relative offset of the prompt entry
2376        ));
2377
2378        let renderer = HtmlRenderer::new();
2379        let html = renderer.render_document(&opcodes, &strings, 1);
2380
2381        assert!(html.contains("class=\"pathland-textfield\""));
2382        assert!(html.contains("<span class=\"pathland-label\">Name:</span>"));
2383        assert!(html.contains(
2384            "<input class=\"pathland-input\" type=\"text\" value=\"Bob\" placeholder=\"Enter\">"
2385        ));
2386    }
2387
2388    #[test]
2389    fn applies_network_decoded_frame() {
2390        // The renderer consumes a self-contained opcode + string section directly
2391        // (the transport layer's encode/decode round-trip is covered by the
2392        // transport crate's own tests).
2393        use pathland_core_transport::encode_frame;
2394
2395        let mut opcodes = Vec::new();
2396        let mut strings = Vec::new();
2397        opcodes.push(Opcode::new(
2398            category::TREE,
2399            tree::CREATE_NODE,
2400            0,
2401            1,
2402            component_type::TEXT as u32,
2403            0,
2404        ));
2405        strings.extend_from_slice(&(5u32).to_le_bytes());
2406        strings.extend_from_slice(b"Hello");
2407        opcodes.push(Opcode::new(category::PARAMETER, parameter::SET_TEXT, 0, 1, 0, 0));
2408
2409        let bytes = encode_frame(&opcodes, &strings);
2410        assert!(bytes.len() > 16);
2411
2412        let renderer = HtmlRenderer::new();
2413        let html = renderer.render_document(&opcodes, &strings, 1);
2414        assert!(html.contains("<span data-pathland-id=\"1\">Hello</span>"));
2415    }
2416
2417    #[test]
2418    fn days_to_date_maps_epoch_and_pre_epoch() {
2419        assert_eq!(days_to_date(0), (1970, 1, 1));
2420        assert_eq!(days_to_date(19_723), (2024, 1, 1));
2421        // Pre-1970 (negative days).
2422        assert_eq!(days_to_date(-1), (1969, 12, 31));
2423        assert_eq!(days_to_date(-365), (1969, 1, 1));
2424    }
2425
2426    #[test]
2427    fn renders_image_with_source() {
2428        use pathland_core::value_type;
2429
2430        let mut opcodes = Vec::new();
2431        let mut strings = Vec::new();
2432        opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 1, component_type::IMAGE as u32, 0));
2433        strings.extend_from_slice(&(15u32).to_le_bytes());
2434        strings.extend_from_slice(b"assets/logo.png");
2435        opcodes.push(Opcode::new(
2436            category::PARAMETER,
2437            parameter::SET_PROPERTY,
2438            0,
2439            1,
2440            ((value_type::STRING as u32) << 16) | property_id::IMAGE_SOURCE as u32,
2441            0,
2442        ));
2443
2444        let renderer = HtmlRenderer::new();
2445        let html = renderer.render_document(&opcodes, &strings, 1);
2446        assert!(html.contains("<img data-pathland-id=\"1\" src=\"assets/logo.png\" alt=\"\">"));
2447    }
2448
2449    #[test]
2450    fn renders_semantic_icon_with_filled_remix_markup() {
2451        use pathland_core::value_type;
2452
2453        let mut opcodes = Vec::new();
2454        let mut strings = Vec::new();
2455        opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 1, component_type::ICON as u32, 0));
2456        strings.extend_from_slice(&(4u32).to_le_bytes());
2457        strings.extend_from_slice(b"play");
2458        opcodes.push(Opcode::new(
2459            category::PARAMETER,
2460            parameter::SET_PROPERTY,
2461            0,
2462            1,
2463            ((value_type::STRING as u32) << 16) | property_id::ICON_NAME as u32,
2464            0,
2465        ));
2466
2467        let renderer = HtmlRenderer::new();
2468        let html = renderer.render_document(&opcodes, &strings, 1);
2469        assert!(html.contains("<svg class=\"pathland-icon\""), "icon svg shell: {}", html);
2470        assert!(html.contains("fill=\"currentColor\""), "filled shell: {}", html);
2471        assert!(!html.contains("stroke="), "no stroke on a filled icon: {}", html);
2472        assert!(html.contains("data-pathland-id=\"1\""), "icon keeps its node id: {}", html);
2473        assert!(html.contains("aria-hidden=\"true\""), "decorative by default: {}", html);
2474        assert!(html.contains("d=\"M19.376 12.4161L8.77735 19.4818"), "play remix path: {}", html);
2475        assert!(html.ends_with("</svg>") || html.contains("</svg>"), "svg closed: {}", html);
2476    }
2477
2478    #[test]
2479    fn labeled_icon_is_presentable_and_unknown_name_falls_back() {
2480        use pathland_core::value_type;
2481
2482        // A labeled icon → role="img" + aria-label instead of aria-hidden.
2483        let icon = |name: &str, label: Option<&str>| -> String {
2484            let mut opcodes = Vec::new();
2485            let mut strings = Vec::new();
2486            opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 1, component_type::ICON as u32, 0));
2487            let name_offset = 0usize;
2488            strings.extend_from_slice(&(name.len() as u32).to_le_bytes());
2489            strings.extend_from_slice(name.as_bytes());
2490            opcodes.push(Opcode::new(
2491                category::PARAMETER,
2492                parameter::SET_PROPERTY,
2493                0,
2494                1,
2495                ((value_type::STRING as u32) << 16) | property_id::ICON_NAME as u32,
2496                name_offset as u32,
2497            ));
2498            let label_offset = 4usize + name.len();
2499            if let Some(label) = label {
2500                strings.extend_from_slice(&(label.len() as u32).to_le_bytes());
2501                strings.extend_from_slice(label.as_bytes());
2502                opcodes.push(Opcode::new(
2503                    category::PARAMETER,
2504                    parameter::SET_PROPERTY,
2505                    0,
2506                    1,
2507                    ((value_type::STRING as u32) << 16) | property_id::LABEL as u32,
2508                    label_offset as u32,
2509                ));
2510            }
2511            HtmlRenderer::new().render_document(&opcodes, &strings, 1)
2512        };
2513
2514        let labeled = icon("music", Some("Playlist"));
2515        assert!(labeled.contains("role=\"img\""), "labeled icon is an image: {}", labeled);
2516        assert!(labeled.contains("aria-label=\"Playlist\""), "aria label: {}", labeled);
2517        assert!(!labeled.contains("aria-hidden"), "no aria-hidden when labeled: {}", labeled);
2518
2519        let unknown = icon("definitely-not-an-icon", None);
2520        assert!(unknown.contains("aria-hidden=\"true\""), "unlabeled stays decorative: {}", unknown);
2521        assert!(unknown.contains("cx=\"12\" cy=\"12\" r=\"8\""), "fallback glyph: {}", unknown);
2522    }
2523
2524    #[test]
2525    fn renders_media_and_image_alt() {
2526        use pathland_core::value_type;
2527
2528        let mut opcodes = Vec::new();
2529        let mut strings = Vec::new();
2530        // IMAGE with an accessibility label → alt; CONTENT_MODE Fill → cover.
2531        opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 1, component_type::IMAGE as u32, 0));
2532        strings.extend_from_slice(&(32u32).to_le_bytes());
2533        strings.extend_from_slice(b"/_pathland/assets/icons/home.svg");
2534        opcodes.push(Opcode::new(
2535            category::PARAMETER,
2536            parameter::SET_PROPERTY,
2537            0,
2538            1,
2539            ((value_type::STRING as u32) << 16) | property_id::IMAGE_SOURCE as u32,
2540            0,
2541        ));
2542        strings.extend_from_slice(&(4u32).to_le_bytes());
2543        strings.extend_from_slice(b"Home");
2544        opcodes.push(Opcode::new(
2545            category::PARAMETER,
2546            parameter::SET_PROPERTY,
2547            0,
2548            1,
2549            ((value_type::STRING as u32) << 16) | property_id::LABEL as u32,
2550            36,
2551        ));
2552        opcodes.push(Opcode::new(
2553            category::PARAMETER,
2554            parameter::SET_PROPERTY,
2555            0,
2556            1,
2557            ((value_type::F32 as u32) << 16) | property_id::CONTENT_MODE as u32,
2558            1f32.to_bits(),
2559        ));
2560        // VIDEO + AUDIO with renderer-native controls.
2561        opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 2, component_type::VIDEO as u32, 0));
2562        strings.extend_from_slice(&(30u32).to_le_bytes());
2563        strings.extend_from_slice(b"https://example.com/sample.mp4");
2564        opcodes.push(Opcode::new(
2565            category::PARAMETER,
2566            parameter::SET_PROPERTY,
2567            0,
2568            2,
2569            ((value_type::STRING as u32) << 16) | property_id::VIDEO_SOURCE as u32,
2570            44,
2571        ));
2572        opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 3, component_type::AUDIO as u32, 0));
2573        strings.extend_from_slice(&(30u32).to_le_bytes());
2574        strings.extend_from_slice(b"https://example.com/sample.mp3");
2575        opcodes.push(Opcode::new(
2576            category::PARAMETER,
2577            parameter::SET_PROPERTY,
2578            0,
2579            3,
2580            ((value_type::STRING as u32) << 16) | property_id::AUDIO_SOURCE as u32,
2581            78,
2582        ));
2583
2584        let renderer = HtmlRenderer::new();
2585        let img = renderer.render_fragment(&opcodes, &strings, 1);
2586        assert!(img.contains("alt=\"Home\""), "image alt from LABEL");
2587        assert!(img.contains("object-fit:cover;"), "ContentMode Fill -> cover");
2588        let video = renderer.render_fragment(&opcodes, &strings, 2);
2589        assert!(video.contains("<video data-pathland-id=\"2\" src=\"https://example.com/sample.mp4\" controls"), "video native controls");
2590        let audio = renderer.render_fragment(&opcodes, &strings, 3);
2591        assert!(audio.contains("<audio data-pathland-id=\"3\" src=\"https://example.com/sample.mp3\" controls"), "audio native controls");
2592    }
2593
2594    /// A custom `AudioStyle` body is a container (e.g. a VStack) carrying the media
2595    /// source: the renderer preserves its layout and injects a hidden, control-less
2596    /// media element + the `data-pathland-media` marker.
2597    #[test]
2598    fn custom_controls_media_preserves_container_with_hidden_audio() {
2599        use pathland_core::value_type;
2600
2601        let mut opcodes = Vec::new();
2602        // VSTACK node 1 (the custom style body) with a BUTTON child 2.
2603        opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 1, component_type::VSTACK as u32, 0));
2604        opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 2, component_type::BUTTON as u32, 0));
2605        opcodes.push(Opcode::new(category::TREE, tree::INSERT_CHILD, 0, 1, 2, 0));
2606        let mut strings = Vec::new();
2607        strings.extend_from_slice(&(29u32).to_le_bytes());
2608        strings.extend_from_slice(b"https://example.com/track.mp3");
2609        opcodes.push(Opcode::new(
2610            category::PARAMETER,
2611            parameter::SET_PROPERTY,
2612            0,
2613            1,
2614            ((value_type::STRING as u32) << 16) | property_id::AUDIO_SOURCE as u32,
2615            0,
2616        ));
2617        // The app-bound media control properties ride the node.
2618        opcodes.push(Opcode::new(
2619            category::PARAMETER,
2620            parameter::SET_PROPERTY,
2621            0,
2622            1,
2623            ((value_type::U32 as u32) << 16) | property_id::PLAYBACK_STATE as u32,
2624            1,
2625        ));
2626        opcodes.push(Opcode::new(
2627            category::PARAMETER,
2628            parameter::SET_PROPERTY,
2629            0,
2630            1,
2631            ((value_type::F32 as u32) << 16) | property_id::MEDIA_POSITION as u32,
2632            12.5f32.to_bits(),
2633        ));
2634        opcodes.push(Opcode::new(
2635            category::PARAMETER,
2636            parameter::SET_PROPERTY,
2637            0,
2638            1,
2639            ((value_type::F32 as u32) << 16) | property_id::MEDIA_VOLUME as u32,
2640            0.5f32.to_bits(),
2641        ));
2642
2643        let renderer = HtmlRenderer::new();
2644        let html = renderer.render_fragment(&opcodes, &strings, 1);
2645        assert!(
2646            html.contains("data-pathland-id=\"1\" data-pathland-media"),
2647            "the container is marked app-driven: {html}"
2648        );
2649        assert!(
2650            html.contains("display:flex;flex-direction:column"),
2651            "the VStack body keeps its flex layout: {html}"
2652        );
2653        assert!(
2654            html.contains("<audio src=\"https://example.com/track.mp3\" data-pathland-media></audio>"),
2655            "the container holds a hidden, control-less audio element"
2656        );
2657        assert!(
2658            !html.contains("controls"),
2659            "the injected media element has no native controls"
2660        );
2661        assert!(html.contains("<button"), "the custom control children render");
2662        let debug = renderer.with_debug_comments(true).render_fragment(&opcodes, &strings, 1);
2663        assert!(debug.contains("<!-- #1 VStack:"), "debug comment names the VStack: {debug}");
2664    }
2665
2666    /// Wrapper model (spec DSL.md §5.7): an `AUDIO` node whose custom style body is
2667    /// its child keeps the `AUDIO` component and renders a `.pathland-media` wrapper
2668    /// holding the hidden, control-less media element + the style's control children.
2669    #[test]
2670    fn wrapper_media_node_keeps_audio_component_with_custom_children() {
2671        use pathland_core::value_type;
2672
2673        let mut opcodes = Vec::new();
2674        opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 1, component_type::AUDIO as u32, 0));
2675        opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 2, component_type::HSTACK as u32, 0));
2676        opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 3, component_type::BUTTON as u32, 0));
2677        opcodes.push(Opcode::new(category::TREE, tree::INSERT_CHILD, 0, 1, 2, 0));
2678        opcodes.push(Opcode::new(category::TREE, tree::INSERT_CHILD, 0, 2, 3, 0));
2679        let mut strings = Vec::new();
2680        strings.extend_from_slice(&(29u32).to_le_bytes());
2681        strings.extend_from_slice(b"https://example.com/track.mp3");
2682        opcodes.push(Opcode::new(
2683            category::PARAMETER,
2684            parameter::SET_PROPERTY,
2685            0,
2686            1,
2687            ((value_type::STRING as u32) << 16) | property_id::AUDIO_SOURCE as u32,
2688            0,
2689        ));
2690
2691        let renderer = HtmlRenderer::new();
2692        let html = renderer.render_fragment(&opcodes, &strings, 1);
2693        assert!(
2694            html.contains("data-pathland-id=\"1\" data-pathland-media"),
2695            "the AUDIO shell carries the app-driven media marker: {html}"
2696        );
2697        assert!(
2698            html.contains("class=\"pathland-media\""),
2699            "the AUDIO node renders the media wrapper, not a bare <audio>: {html}"
2700        );
2701        assert!(
2702            html.contains("<audio src=\"https://example.com/track.mp3\" data-pathland-media></audio>"),
2703            "a hidden, control-less audio is injected first: {html}"
2704        );
2705        assert!(
2706            html.contains("data-pathland-id=\"2\""),
2707            "the style body (control children) renders inside the wrapper: {html}"
2708        );
2709    }
2710
2711    #[test]
2712    fn renders_grid_with_columns() {
2713        use pathland_core::value_type;
2714
2715        let mut opcodes = Vec::new();
2716        opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 1, component_type::GRID as u32, 0));
2717        opcodes.push(Opcode::new(
2718            category::PARAMETER,
2719            parameter::SET_PROPERTY,
2720            0,
2721            1,
2722            ((value_type::F32 as u32) << 16) | property_id::GRID_COLUMNS as u32,
2723            2.0f32.to_bits(),
2724        ));
2725        opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 2, component_type::TEXT as u32, 0));
2726        opcodes.push(Opcode::new(category::TREE, tree::INSERT_CHILD, 0, 1, 2, 0));
2727
2728        let renderer = HtmlRenderer::new();
2729        let html = renderer.render_document(&opcodes, &[], 1);
2730        assert!(html.contains("grid-template-columns:repeat(2,minmax(0,1fr))"), "grid inline");
2731        assert!(!html.contains("width:2px"), "GRID_COLUMNS is a count, never a pixel box: {html}");
2732        assert!(html.contains("justify-items:start;align-items:start"), "default cell alignment");
2733        assert!(html.contains("justify-self:start;align-self:start;"), "cell wrapper positions per ALIGNMENT");
2734    }
2735
2736    #[test]
2737    fn grid_rows_and_lazy_hgrid_tracks() {
2738        use pathland_core::value_type;
2739        use pathland_core::size;
2740
2741        let build = |component: u16, columns: f32, rows: f32| {
2742            let mut opcodes = Vec::new();
2743            opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 1, component as u32, 0));
2744            if columns != pathland_core::size::HUG_CONTENT {
2745                opcodes.push(Opcode::new(category::PARAMETER, parameter::SET_PROPERTY, 0, 1,
2746                    ((value_type::F32 as u32) << 16) | property_id::GRID_COLUMNS as u32, columns.to_bits()));
2747            }
2748            if rows != pathland_core::size::HUG_CONTENT {
2749                opcodes.push(Opcode::new(category::PARAMETER, parameter::SET_PROPERTY, 0, 1,
2750                    ((value_type::F32 as u32) << 16) | property_id::GRID_ROWS as u32, rows.to_bits()));
2751            }
2752            let renderer = HtmlRenderer::new();
2753            renderer.render_document(&opcodes, &[], 1)
2754        };
2755
2756        // GRID with both counts: columns from GRID_COLUMNS, rows from GRID_ROWS.
2757        let g = build(component_type::GRID, 2.0, 3.0);
2758        assert!(g.contains("grid-template-columns:repeat(2,minmax(0,1fr))"), "grid columns: {g}");
2759        assert!(g.contains("grid-template-rows:repeat(3,minmax(0,1fr))"), "grid rows: {g}");
2760        assert!(!g.contains("width:2px") && !g.contains("height:3px"), "counts never pixels: {g}");
2761
2762        // LAZY_HGRID: the fixed track is GRID_ROWS; columns auto-flow; GRID_COLUMNS is ignored.
2763        let h = build(component_type::LAZY_HGRID, 2.0, 3.0);
2764        assert!(h.contains("grid-template-rows:repeat(3,minmax(0,1fr))"), "hgrid rows: {h}");
2765        assert!(h.contains("grid-auto-flow:column;grid-auto-columns:minmax(0,1fr)"), "hgrid auto columns: {h}");
2766        assert!(!h.contains("grid-template-columns:repeat(2,minmax(0,1fr))"), "hgrid ignores GRID_COLUMNS: {h}");
2767
2768        // FILL count = auto-fit (no template); a FILL WIDTH frame still expands (100%).
2769        let mut opcodes = Vec::new();
2770        opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 1, component_type::GRID as u32, 0));
2771        opcodes.push(Opcode::new(category::PARAMETER, parameter::SET_PROPERTY, 0, 1,
2772            ((value_type::F32 as u32) << 16) | property_id::GRID_COLUMNS as u32, size::FILL.to_bits()));
2773        opcodes.push(Opcode::new(category::PARAMETER, parameter::SET_PROPERTY, 0, 1,
2774            ((value_type::F32 as u32) << 16) | property_id::WIDTH as u32, size::FILL.to_bits()));
2775        let renderer = HtmlRenderer::new();
2776        let f = renderer.render_document(&opcodes, &[], 1);
2777        assert!(!f.contains("grid-template-columns:repeat"), "FILL count = auto-fit: {f}");
2778        assert!(f.contains("width:100%"), "FILL WIDTH frame expands: {f}");
2779    }
2780
2781    #[test]
2782    fn grid_rows_flatten_explicit_rows() {
2783        let mut opcodes = Vec::new();
2784        opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 1, component_type::GRID as u32, 0));
2785        // Row 0: a 2-cell GRID_ROW.
2786        opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 2, component_type::GRID_ROW as u32, 0));
2787        opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 3, component_type::TEXT as u32, 0));
2788        opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 4, component_type::TEXT as u32, 0));
2789        // Row 1: a short 1-cell GRID_ROW.
2790        opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 5, component_type::GRID_ROW as u32, 0));
2791        opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 6, component_type::TEXT as u32, 0));
2792        // A bare cell auto-flows into row 2.
2793        opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 7, component_type::TEXT as u32, 0));
2794        opcodes.push(Opcode::new(category::TREE, tree::INSERT_CHILD, 0, 1, 2, u32::MAX));
2795        opcodes.push(Opcode::new(category::TREE, tree::INSERT_CHILD, 0, 2, 3, u32::MAX));
2796        opcodes.push(Opcode::new(category::TREE, tree::INSERT_CHILD, 0, 2, 4, u32::MAX));
2797        opcodes.push(Opcode::new(category::TREE, tree::INSERT_CHILD, 0, 1, 5, u32::MAX));
2798        opcodes.push(Opcode::new(category::TREE, tree::INSERT_CHILD, 0, 5, 6, u32::MAX));
2799        opcodes.push(Opcode::new(category::TREE, tree::INSERT_CHILD, 0, 1, 7, u32::MAX));
2800
2801        let renderer = HtmlRenderer::new();
2802        let html = renderer.render_document(&opcodes, &[], 1);
2803        // The widest row (2 cells) defines the equal-1fr columns.
2804        assert!(html.contains("grid-template-columns:repeat(2,minmax(0,1fr))"), "widest row defines columns: {html}");
2805        // Explicit placements: row 0 = [A,B], row 1 = [C], row 2 = [D].
2806        assert!(html.contains("grid-row:1;grid-column:1;"), "cell at (0,0): {html}");
2807        assert!(html.contains("grid-row:1;grid-column:2;"), "cell at (0,1): {html}");
2808        assert!(html.contains("grid-row:2;grid-column:1;"), "short row's cell at (1,0): {html}");
2809        assert!(html.contains("grid-row:3;grid-column:1;"), "bare cell auto-flows to row 2: {html}");
2810        // The GRID_ROW nodes themselves render nothing.
2811        assert!(!html.contains("GRID_ROW"), "GRID_ROW is structural: {html}");
2812    }
2813
2814    #[test]
2815    fn grid_tracks_render_per_track_template() {
2816        use pathland_core::value_type;
2817
2818        let mut strings = Vec::new();
2819        strings.extend_from_slice(&(25u32).to_le_bytes());
2820        strings.extend_from_slice(b"flex,fixed:80,adaptive:50");
2821        let mut opcodes = Vec::new();
2822        opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 1, component_type::GRID as u32, 0));
2823        opcodes.push(Opcode::new(category::PARAMETER, parameter::SET_PROPERTY, 0, 1,
2824            ((value_type::STRING as u32) << 16) | property_id::GRID_TRACKS as u32, 0));
2825        opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 2, component_type::TEXT as u32, 0));
2826        opcodes.push(Opcode::new(category::TREE, tree::INSERT_CHILD, 0, 1, 2, u32::MAX));
2827
2828        let renderer = HtmlRenderer::new();
2829        let html = renderer.render_document(&opcodes, &strings, 1);
2830        assert!(html.contains("grid-template-columns:1fr 80px minmax(50px,1fr);"), "track spec: {html}");
2831        assert!(!html.contains("grid-template-columns:repeat"), "tracks override the count: {html}");
2832    }
2833
2834    #[test]
2835    fn zstack_2d_alignment_splits_h_and_v() {
2836        use pathland_core::value_type;
2837        let mut opcodes = Vec::new();
2838        opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 1, component_type::ZSTACK as u32, 0));
2839        // topTrailing = 7 → horizontal end, vertical start.
2840        opcodes.push(Opcode::new(category::PARAMETER, parameter::SET_PROPERTY, 0, 1,
2841            ((value_type::F32 as u32) << 16) | property_id::ALIGNMENT as u32, 7.0f32.to_bits()));
2842        opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 2, component_type::TEXT as u32, 0));
2843        opcodes.push(Opcode::new(category::TREE, tree::INSERT_CHILD, 0, 1, 2, u32::MAX));
2844
2845        let renderer = HtmlRenderer::new();
2846        let html = renderer.render_document(&opcodes, &[], 1);
2847        assert!(html.contains("justify-self:end;align-self:start;"), "topTrailing splits h/v: {html}");
2848    }
2849
2850    #[test]
2851    fn grid_cells_stretch_when_fill_or_greedy() {
2852        use pathland_core::value_type;
2853
2854        let mut opcodes = Vec::new();
2855        opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 1, component_type::GRID as u32, 0));
2856        opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 2, component_type::TEXT as u32, 0));
2857        opcodes.push(Opcode::new(category::PARAMETER, parameter::SET_PROPERTY, 0, 2,
2858            ((value_type::F32 as u32) << 16) | property_id::WIDTH as u32, pathland_core::size::FILL.to_bits()));
2859        opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 3, component_type::COLOR as u32, 0));
2860        opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 4, component_type::TEXT as u32, 0));
2861        opcodes.push(Opcode::new(category::TREE, tree::INSERT_CHILD, 0, 1, 2, u32::MAX));
2862        opcodes.push(Opcode::new(category::TREE, tree::INSERT_CHILD, 0, 1, 3, u32::MAX));
2863        opcodes.push(Opcode::new(category::TREE, tree::INSERT_CHILD, 0, 1, 4, u32::MAX));
2864
2865        let renderer = HtmlRenderer::new();
2866        let html = renderer.render_document(&opcodes, &[], 1);
2867        // A FILL-width cell stretches horizontally but keeps content height
2868        // (start vertically); a greedy COLOR stretches on both axes; a Hug cell
2869        // keeps its size and is positioned at start on both axes.
2870        assert!(html.contains("justify-self:stretch;align-self:stretch;"), "greedy COLOR stretches: {html}");
2871        assert!(html.contains("justify-self:stretch;align-self:start;"), "FILL-width cell: {html}");
2872        assert!(html.contains("justify-self:start;align-self:start;"), "Hug cell positioned: {html}");
2873    }
2874
2875    #[test]
2876    fn scrollview_renders_only_the_first_child() {
2877        let mut opcodes = Vec::new();
2878        opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 1, component_type::SCROLLVIEW as u32, 0));
2879        opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 2, component_type::TEXT as u32, 0));
2880        opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 3, component_type::TEXT as u32, 0));
2881        opcodes.push(Opcode::new(category::TREE, tree::INSERT_CHILD, 0, 1, 2, u32::MAX));
2882        opcodes.push(Opcode::new(category::TREE, tree::INSERT_CHILD, 0, 1, 3, u32::MAX));
2883
2884        let renderer = HtmlRenderer::new();
2885        let html = renderer.render_document(&opcodes, &[], 1);
2886        assert!(html.contains("data-pathland-id=\"2\""), "first child is the content: {html}");
2887        assert!(!html.contains("data-pathland-id=\"3\""), "extra children not rendered: {html}");
2888    }
2889
2890    #[test]
2891    fn renders_scrollview_and_zstack_containers() {
2892        let mut opcodes = Vec::new();
2893        opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 1, component_type::SCROLLVIEW as u32, 0));
2894        opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 2, component_type::TEXT as u32, 0));
2895        opcodes.push(Opcode::new(category::TREE, tree::INSERT_CHILD, 0, 1, 2, 0));
2896        opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 3, component_type::ZSTACK as u32, 0));
2897        opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 4, component_type::TEXT as u32, 0));
2898        opcodes.push(Opcode::new(category::TREE, tree::INSERT_CHILD, 0, 3, 4, 0));
2899
2900        let renderer = HtmlRenderer::new();
2901        let scroll = renderer.render_document(&opcodes, &[], 1);
2902        assert!(scroll.contains("overflow:auto"), "scrollview inline");
2903        assert!(scroll.contains("<span data-pathland-id=\"2\"></span>"));
2904        let zstack = renderer.render_document(&opcodes, &[], 3);
2905        assert!(zstack.contains("display:grid"), "zstack grid inline");
2906        assert!(zstack.contains("isolation:isolate"), "zstack scopes shell z-indexes: {zstack}");
2907        assert!(zstack.contains("width:max-content"), "zstack hugs to its largest child");
2908        assert!(zstack.contains("grid-area:1/1;z-index:0;"), "shell carries an explicit default z-index: {zstack}");
2909        assert!(zstack.contains("justify-self:start"), "zstack child positioned per ALIGNMENT");
2910        assert!(zstack.contains("grid-template-columns:minmax(0,1fr);grid-template-rows:minmax(0,1fr);"),
2911            "zstack tracks shrink below content min-height (minmax(0,1fr)): {zstack}");
2912    }
2913
2914    #[test]
2915    fn zstack_child_z_index_lands_on_the_shell() {
2916        use pathland_core::value_type;
2917        let mut opcodes = Vec::new();
2918        opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 1, component_type::ZSTACK as u32, 0));
2919        opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 2, component_type::TEXT as u32, 0));
2920        opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 3, component_type::TEXT as u32, 0));
2921        // The FIRST child has z-index 5: its shell must carry it so it draws
2922        // above the later sibling (draw-order override, spec/PRIMITIVES.md §ZStack).
2923        opcodes.push(Opcode::new(category::PARAMETER, parameter::SET_PROPERTY, 0, 2,
2924            ((value_type::F32 as u32) << 16) | property_id::Z_INDEX as u32, 5.0f32.to_bits()));
2925        opcodes.push(Opcode::new(category::TREE, tree::INSERT_CHILD, 0, 1, 2, u32::MAX));
2926        opcodes.push(Opcode::new(category::TREE, tree::INSERT_CHILD, 0, 1, 3, u32::MAX));
2927
2928        let renderer = HtmlRenderer::new();
2929        let html = renderer.render_document(&opcodes, &[], 1);
2930        assert!(html.contains("grid-area:1/1;z-index:5;"), "z-index on the child shell: {html}");
2931        assert!(html.contains("z-index:5"), "the child keeps its own z-index: {html}");
2932        // A sibling without Z_INDEX still carries an explicit default z-index.
2933        assert!(html.contains("grid-area:1/1;z-index:0;"), "sibling shell defaults to 0: {html}");
2934    }
2935
2936    #[test]
2937    fn renders_progress_view_and_spinner() {
2938        use pathland_core::value_type;
2939
2940        let mut opcodes = Vec::new();
2941        opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 1, component_type::PROGRESS_VIEW as u32, 0));
2942        opcodes.push(Opcode::new(
2943            category::PARAMETER,
2944            parameter::SET_PROPERTY,
2945            0,
2946            1,
2947            ((value_type::F32 as u32) << 16) | property_id::PROGRESS as u32,
2948            0.5f32.to_bits(),
2949        ));
2950        opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 2, component_type::PROGRESS_VIEW as u32, 0));
2951        opcodes.push(Opcode::new(
2952            category::PARAMETER,
2953            parameter::SET_PROPERTY,
2954            0,
2955            2,
2956            ((value_type::U8 as u32) << 16) | property_id::IS_INDETERMINATE as u32,
2957            1,
2958        ));
2959
2960        let renderer = HtmlRenderer::new();
2961        assert!(renderer.render_document(&opcodes, &[], 1).contains("<progress"));
2962        assert!(renderer.render_document(&opcodes, &[], 2).contains("pathland-spinner"));
2963    }
2964
2965    #[test]
2966    fn renders_date_picker_from_set_date() {
2967        let mut opcodes = Vec::new();
2968        opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 1, component_type::DATE_PICKER as u32, 0));
2969        // days=19723 → 2024-01-01; millis of day = 0.
2970        opcodes.push(Opcode::new(category::PARAMETER, parameter::SET_DATE, 0, 1, 19_723, 0));
2971
2972        let renderer = HtmlRenderer::new();
2973        let html = renderer.render_document(&opcodes, &[], 1);
2974        assert!(html.contains("<input data-pathland-id=\"1\" type=\"date\" value=\"2024-01-01\">"));
2975    }
2976
2977    #[test]
2978    fn renders_picker_from_child_options() {
2979        use pathland_core::value_type;
2980
2981        let mut opcodes = Vec::new();
2982        opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 1, component_type::PICKER as u32, 0));
2983        let mut strings = Vec::new();
2984        strings.extend_from_slice(&(3u32).to_le_bytes());
2985        strings.extend_from_slice(b"Red");
2986        opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 2, component_type::TEXT as u32, 0));
2987        opcodes.push(Opcode::new(category::PARAMETER, parameter::SET_TEXT, 0, 2, 0, 0));
2988        opcodes.push(Opcode::new(category::TREE, tree::INSERT_CHILD, 0, 1, 2, 0));
2989        opcodes.push(Opcode::new(
2990            category::PARAMETER,
2991            parameter::SET_PROPERTY,
2992            0,
2993            1,
2994            ((value_type::U32 as u32) << 16) | property_id::SELECTION as u32,
2995            0,
2996        ));
2997
2998        let renderer = HtmlRenderer::new();
2999        let html = renderer.render_document(&opcodes, &strings, 1);
3000        assert!(html.contains("<select"));
3001        assert!(html.contains("<option data-pathland-id=\"2\" value=\"0\" selected>Red</option>"));
3002    }
3003
3004    #[test]
3005    fn renders_color_picker_and_color_node() {
3006        use pathland_core::value_type;
3007
3008        let mut opcodes = Vec::new();
3009        opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 1, component_type::COLOR_PICKER as u32, 0));
3010        opcodes.push(Opcode::new(
3011            category::PARAMETER,
3012            parameter::SET_PROPERTY,
3013            0,
3014            1,
3015            ((value_type::COLOR as u32) << 16) | property_id::COLOR_VALUE as u32,
3016            0xFF00_00FF,
3017        ));
3018        opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 2, component_type::COLOR as u32, 0));
3019        opcodes.push(Opcode::new(
3020            category::PARAMETER,
3021            parameter::SET_PROPERTY,
3022            0,
3023            2,
3024            ((value_type::COLOR as u32) << 16) | property_id::COLOR as u32,
3025            0xFF00_00FF,
3026        ));
3027
3028        let renderer = HtmlRenderer::new();
3029        assert!(renderer.render_document(&opcodes, &[], 1).contains("<input data-pathland-id=\"1\" type=\"color\" value=\"#0000ff\">"));
3030        assert!(renderer.render_document(&opcodes, &[], 2).contains("background-color:rgba(0,0,255,1)"));
3031    }
3032
3033    #[test]
3034    fn button_composite_renders_custom_body() {
3035        let mut opcodes = Vec::new();
3036        opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 1, component_type::BUTTON as u32, 0));
3037        opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 2, component_type::TEXT as u32, 0));
3038        opcodes.push(Opcode::new(category::TREE, tree::INSERT_CHILD, 0, 1, 2, 0));
3039        let mut strings = Vec::new();
3040        strings.extend_from_slice(&(1u32).to_le_bytes());
3041        strings.extend_from_slice(b"A");
3042        opcodes.push(Opcode::new(category::PARAMETER, parameter::SET_TEXT, 0, 2, 0, 0));
3043
3044        let renderer = HtmlRenderer::new();
3045        let html = renderer.render_document(&opcodes, &strings, 1);
3046        assert!(html.contains("<button data-pathland-id=\"1\" class=\"pathland-button\">"), "button uses the design-system class");
3047        assert!(html.contains("<span data-pathland-id=\"2\">A</span>"));
3048    }
3049
3050    #[test]
3051    fn line_limit_clamps_text_to_n_lines() {
3052        use pathland_core::value_type;
3053
3054        let mut opcodes = Vec::new();
3055        opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 1, component_type::TEXT as u32, 0));
3056        opcodes.push(Opcode::new(
3057            category::PARAMETER,
3058            parameter::SET_PROPERTY,
3059            0,
3060            1,
3061            ((value_type::U32 as u32) << 16) | property_id::LINE_LIMIT as u32,
3062            1,
3063        ));
3064        let mut strings = Vec::new();
3065        strings.extend_from_slice(&(2u32).to_le_bytes());
3066        strings.extend_from_slice(b"Hi");
3067        opcodes.push(Opcode::new(category::PARAMETER, parameter::SET_TEXT, 0, 1, 0, 0));
3068
3069        let html = HtmlRenderer::new().render_document(&opcodes, &strings, 1);
3070        assert!(
3071            html.contains("style=\"display:-webkit-box;overflow:hidden;-webkit-line-clamp:1;-webkit-box-orient:vertical;\""),
3072            "a positive LINE_LIMIT clamps the text to N lines: {html}"
3073        );
3074        // 0 = unlimited: no clamp style (the unset case above is unchanged).
3075        let mut no_limit = Vec::new();
3076        no_limit.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 1, component_type::TEXT as u32, 0));
3077        no_limit.push(Opcode::new(category::PARAMETER, parameter::SET_TEXT, 0, 1, 0, 0));
3078        assert!(
3079            !HtmlRenderer::new()
3080                .render_document(&no_limit, &strings, 1)
3081                .contains("line-clamp"),
3082            "no LINE_LIMIT means unlimited (no clamp)"
3083        );
3084    }
3085
3086    #[test]
3087    fn content_fitting_contract_c7_c10() {
3088        use pathland_core::value_type;
3089        // C7 (wrap at a Fixed width), C8 (LINE_LIMIT → line-clamp tail ellipsis),
3090        // C9 (TRUNCATION_MODE positions the ellipsis under a clamp, never forces
3091        // single-line by itself), C10 (CLIPS_TO_BOUNDS → overflow:hidden).
3092        let mut opcodes = Vec::new();
3093        opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 1, component_type::VSTACK as u32, 0));
3094        opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 2, component_type::TEXT as u32, 0));
3095        opcodes.push(Opcode::new(category::TREE, tree::INSERT_CHILD, 0, 1, 2, pathland_core::APPEND));
3096        // Fixed width → wraps at the box (C7).
3097        opcodes.push(Opcode::new(
3098            category::PARAMETER,
3099            parameter::SET_PROPERTY,
3100            0,
3101            2,
3102            ((value_type::F32 as u32) << 16) | property_id::WIDTH as u32,
3103            120.0f32.to_bits(),
3104        ));
3105        // LINE_LIMIT=2 → clamp to 2 lines with a tail ellipsis (C8).
3106        opcodes.push(Opcode::new(
3107            category::PARAMETER,
3108            parameter::SET_PROPERTY,
3109            0,
3110            2,
3111            ((value_type::U32 as u32) << 16) | property_id::LINE_LIMIT as u32,
3112            2,
3113        ));
3114        // TRUNCATION_MODE=Head → still a 2-line clamp, no nowrap/ellipsis CSS
3115        // (CSS line-clamp tail-ellipsizes; head/middle are renderer-owned — C9).
3116        opcodes.push(Opcode::new(
3117            category::PARAMETER,
3118            parameter::SET_PROPERTY,
3119            0,
3120            2,
3121            ((value_type::F32 as u32) << 16) | property_id::TRUNCATION_MODE as u32,
3122            0.0f32.to_bits(),
3123        ));
3124        // CLIPS_TO_BOUNDS=1 → overflow:hidden (C10).
3125        opcodes.push(Opcode::new(
3126            category::PARAMETER,
3127            parameter::SET_PROPERTY,
3128            0,
3129            2,
3130            ((value_type::U8 as u32) << 16) | property_id::CLIPS_TO_BOUNDS as u32,
3131            1,
3132        ));
3133        let html = HtmlRenderer::new().render_document(&opcodes, &[], 1);
3134        assert!(html.contains("width:120px"), "C7 fixed-width wraps: {html}");
3135        assert!(html.contains("line-clamp:2"), "C8 LINE_LIMIT clamps: {html}");
3136        assert!(html.contains("overflow:hidden"), "C10 clips to bounds: {html}");
3137        assert!(!html.contains("white-space:nowrap"), "C9 no forced single line");
3138        assert!(!html.contains("text-overflow"), "C9 no standalone ellipsis");
3139
3140        // TRUNCATION_MODE ALONE (no LINE_LIMIT) has no observable effect.
3141        let mut trunc_alone = Vec::new();
3142        trunc_alone.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 1, component_type::TEXT as u32, 0));
3143        trunc_alone.push(Opcode::new(
3144            category::PARAMETER,
3145            parameter::SET_PROPERTY,
3146            0,
3147            1,
3148            ((value_type::F32 as u32) << 16) | property_id::TRUNCATION_MODE as u32,
3149            0.0f32.to_bits(),
3150        ));
3151        let html = HtmlRenderer::new().render_document(&trunc_alone, &[], 1);
3152        assert!(!html.contains("line-clamp"), "TRUNCATION alone does not clamp");
3153        assert!(!html.contains("white-space:nowrap"), "TRUNCATION alone does not force single-line");
3154        assert!(!html.contains("text-overflow"), "TRUNCATION alone emits no ellipsis");
3155    }
3156
3157    #[test]
3158    fn event_attrs_and_aria_are_emitted() {
3159        use pathland_core::value_type;
3160
3161        let mut opcodes = Vec::new();
3162        opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 1, component_type::BUTTON as u32, 0));
3163        opcodes.push(Opcode::new(
3164            category::PARAMETER,
3165            parameter::SET_PROPERTY,
3166            0,
3167            1,
3168            ((value_type::U32 as u32) << 16) | property_id::EVENT_LISTENERS as u32,
3169            pathland_core::listener::POINTER_DOWN | pathland_core::listener::POINTER_UP,
3170        ));
3171        opcodes.push(Opcode::new(
3172            category::PARAMETER,
3173            parameter::SET_PROPERTY,
3174            0,
3175            1,
3176            ((value_type::U32 as u32) << 16) | property_id::ACTION_ID as u32,
3177            42,
3178        ));
3179        opcodes.push(Opcode::new(
3180            category::PARAMETER,
3181            parameter::SET_PROPERTY,
3182            0,
3183            1,
3184            ((value_type::U8 as u32) << 16) | property_id::ENABLED as u32,
3185            0,
3186        ));
3187
3188        let renderer = HtmlRenderer::new();
3189        let html = renderer.render_document(&opcodes, &[], 1);
3190        assert!(html.contains("data-event-listeners=\"5\""));
3191        assert!(html.contains("data-action-id=\"42\""));
3192        assert!(html.contains(" disabled"));
3193    }
3194
3195    #[test]
3196    fn alignment_maps_to_flex_cross_axis() {
3197        use pathland_core::value_type;
3198
3199        let mut opcodes = Vec::new();
3200        opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 1, component_type::VSTACK as u32, 0));
3201        opcodes.push(Opcode::new(
3202            category::PARAMETER,
3203            parameter::SET_PROPERTY,
3204            0,
3205            1,
3206            ((value_type::F32 as u32) << 16) | property_id::ALIGNMENT as u32,
3207            0.0f32.to_bits(), // Leading
3208        ));
3209
3210        let renderer = HtmlRenderer::new();
3211        let html = renderer.render_document(&opcodes, &[], 1);
3212        assert!(html.contains("flex-direction:column"), "stack inline flex"); assert!(html.contains("align-items:flex-start"), "alignment inline");
3213    }
3214
3215    #[test]
3216    fn layout_default_cross_axis_is_hug_not_stretch() {
3217        // A stack with NO ALIGNMENT must default to hug (flex-start), never
3218        // CSS `align-items: stretch` (LAYOUT.md conformance C5).
3219        let mut opcodes = Vec::new();
3220        opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 1, component_type::VSTACK as u32, 0));
3221        let renderer = HtmlRenderer::new();
3222        let html = renderer.render_document(&opcodes, &[], 1);
3223        assert!(html.contains("align-items:flex-start"), "default is hug: {}", html);
3224        assert!(!html.contains("align-items:stretch"), "no implicit stretch");
3225    }
3226
3227    #[test]
3228    fn layout_fill_alignment_is_hug_positioning() {
3229        use pathland_core::value_type;
3230
3231        // ALIGNMENT=Fill(3) means default (hug) positioning, not stretch.
3232        let mut opcodes = Vec::new();
3233        opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 1, component_type::VSTACK as u32, 0));
3234        opcodes.push(Opcode::new(
3235            category::PARAMETER,
3236            parameter::SET_PROPERTY,
3237            0,
3238            1,
3239            ((value_type::F32 as u32) << 16) | property_id::ALIGNMENT as u32,
3240            3.0f32.to_bits(),
3241        ));
3242        let renderer = HtmlRenderer::new();
3243        let html = renderer.render_document(&opcodes, &[], 1);
3244        assert!(html.contains("align-items:flex-start"), "Fill=3 → hug: {}", html);
3245    }
3246
3247    #[test]
3248    fn fit_slot_forwards_parent_alignment_into_its_box() {
3249        use pathland_core::value_type;
3250
3251        // A `VStack(CENTER)` > SizeThatFits > HSTACK: the slot is a layout-transparent
3252        // container, so it must forward the parent's cross-axis alignment (`inherit`)
3253        // into its own box — otherwise its `align-self:stretch` box pins the candidate
3254        // to the start, regressing the centered player-bar groups (the reasons the
3255        // player bar moved its centered controls behind a fit slot). It keeps the
3256        // size-taking `flex:1 1 auto;align-self:stretch` + `min-width/height:0`
3257        // (the fitted width is measured from this box).
3258        let mut opcodes = Vec::new();
3259        opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 1, component_type::VSTACK as u32, 0));
3260        // HorizontalAlignment.CENTER = 1.
3261        opcodes.push(Opcode::new(
3262            category::PARAMETER,
3263            parameter::SET_PROPERTY,
3264            0,
3265            1,
3266            ((value_type::F32 as u32) << 16) | property_id::ALIGNMENT as u32,
3267            1.0f32.to_bits(),
3268        ));
3269        opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 2, component_type::SIZE_THAT_FITS as u32, 0));
3270        // FIT_QUERY (LIST) → arena `[count=2][0][640]` → `data-pathland-fit="0,640"`.
3271        let mut strings = Vec::new();
3272        strings.extend_from_slice(&2u32.to_le_bytes());
3273        strings.extend_from_slice(&0.0f32.to_le_bytes());
3274        strings.extend_from_slice(&640.0f32.to_le_bytes());
3275        opcodes.push(Opcode::new(
3276            category::PARAMETER,
3277            parameter::SET_PROPERTY,
3278            0,
3279            2,
3280            ((value_type::LIST as u32) << 16) | property_id::FIT_QUERY as u32,
3281            0,
3282        ));
3283        opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 3, component_type::HSTACK as u32, 0));
3284        opcodes.push(Opcode::new(category::TREE, tree::INSERT_CHILD, 0, 1, 2, 0));
3285        opcodes.push(Opcode::new(category::TREE, tree::INSERT_CHILD, 0, 2, 3, 0));
3286
3287        let renderer = HtmlRenderer::new();
3288        let html = renderer.render_document(&opcodes, &strings, 1);
3289        assert!(html.contains("align-items:center"), "parent VStack centers: {}", html);
3290        assert!(html.contains("data-pathland-fit=\"0,640\""), "fit mirror rides the slot: {}", html);
3291        let slot = "class=\"pathland-ftf\"";
3292        let pos_before = html.find(slot).expect("slot div");
3293        let snippet = &html[pos_before..html[pos_before..].find('>').unwrap() + pos_before + 1];
3294        assert!(
3295            snippet.contains("display:flex;")
3296                && snippet.contains("flex-direction:inherit;")
3297                && snippet.contains("align-items:inherit;")
3298                && snippet.contains("justify-content:inherit;")
3299                && snippet.contains("flex:1 1 auto;")
3300                && snippet.contains("align-self:stretch;")
3301                && snippet.contains("min-width:0;")
3302                && snippet.contains("min-height:0;"),
3303            "slot forwards the parent flex settings while staying size-taking: {snippet}"
3304        );
3305        assert!(!snippet.contains("align-items:stretch;"), "forwarded, not stretch-only: {snippet}");
3306        assert!(html.contains("display:flex;flex-direction:row;"), "the candidate HSTACK stays a row");
3307    }
3308
3309    #[test]
3310    fn snapshot_fit_slot_renders_all_candidates_with_container_query_rules() {
3311        use pathland_core::value_type;
3312
3313        // The SSR full-snapshot form (issue #141): a `SIZE_THAT_FITS` carrying
3314        // MULTIPLE children (every candidate) renders each branch in a
3315        // `data-pathland-fit-cand`-gated wrapper, makes the slot a `@container`,
3316        // and emits the band rules in a `<style data-pathland-fit>` element — so a
3317        // no-JS first paint shows the candidate matching the slot's width. The
3318        // child at position `i` is candidate `i` (child order = FIT_QUERY order).
3319        // The single-child (live) form stays a plain `pathland-ftf` (tested above).
3320        let mut opcodes = Vec::new();
3321        opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 1, component_type::SIZE_THAT_FITS as u32, 0));
3322        // FIT_QUERY (LIST) → `[count=2][0][640]` → `data-pathland-fit="0,640"`.
3323        let mut strings = Vec::new();
3324        strings.extend_from_slice(&2u32.to_le_bytes());
3325        strings.extend_from_slice(&0.0f32.to_le_bytes());
3326        strings.extend_from_slice(&640.0f32.to_le_bytes());
3327        opcodes.push(Opcode::new(
3328            category::PARAMETER,
3329            parameter::SET_PROPERTY,
3330            0,
3331            1,
3332            ((value_type::LIST as u32) << 16) | property_id::FIT_QUERY as u32,
3333            0,
3334        ));
3335        // Two candidate branches: HSTACK(2) "wide" and VSTACK(3) "compact".
3336        opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 2, component_type::HSTACK as u32, 0));
3337        opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 3, component_type::VSTACK as u32, 0));
3338        opcodes.push(Opcode::new(category::TREE, tree::INSERT_CHILD, 0, 1, 2, 0));
3339        opcodes.push(Opcode::new(category::TREE, tree::INSERT_CHILD, 0, 1, 3, 1));
3340
3341        let renderer = HtmlRenderer::new();
3342        let html = renderer.render_document(&opcodes, &strings, 1);
3343        // The slot becomes a container with a unique name.
3344        assert!(html.contains("container-type:inline-size;"), "slot is a container: {}", html);
3345        assert!(html.contains("container-name:pf-1;"), "slot has a unique container name: {}", html);
3346        // Both candidates ride the snapshot (gated, mirroring the fit table + count).
3347        assert!(html.contains("data-pathland-fit-cands=\"2\""), "candidate count mirrors: {}", html);
3348        assert!(html.contains("data-pathland-fit-cand=\"0\""), "candidate 0 wrapper: {}", html);
3349        assert!(html.contains("data-pathland-fit-cand=\"1\""), "candidate 1 wrapper: {}", html);
3350        assert!(html.contains("data-pathland-fit-cand-id=\"2\""), "wrapper carries candidate id 2: {}", html);
3351        assert!(html.contains("data-pathland-fit-cand-id=\"3\""), "wrapper carries candidate id 3: {}", html);
3352        // Candidate 0 (threshold 0) is the default-visible fallback; candidate 1 hidden.
3353        assert!(html.contains("data-pathland-fit-cand=\"0\" data-pathland-fit-cand-id=\"2\" style=\"display:contents\""));
3354        assert!(html.contains("data-pathland-fit-cand=\"1\" data-pathland-fit-cand-id=\"3\" style=\"display:none\""));
3355        // The candidate content keeps its own id (property deltas land correctly).
3356        assert!(html.contains("<div data-pathland-id=\"2\" class=") || html.contains("<div data-pathland-id=\"2\""),
3357            "candidate content keeps its id: {}", html);
3358        // The band rules live in a dedicated head element.
3359        let fit_style = html.find("<style data-pathland-fit>").expect("band rules style element");
3360        let style_close = html[fit_style..].find("</style>").unwrap() + fit_style;
3361        let rules = &html[fit_style..style_close];
3362        assert!(rules.contains("@container pf-1 (min-width: 640px)"), "band rule for candidate 1: {rules}");
3363        // The winner's band hides the OTHER candidates only (never the winner).
3364        assert!(rules.contains("[data-pathland-id=\"1\"] > [data-pathland-fit-cand=\"0\"] { display: none !important; }"), "{rules}");
3365        assert!(rules.contains("[data-pathland-id=\"1\"] > [data-pathland-fit-cand=\"1\"] { display: contents !important; }"), "{rules}");
3366        assert!(!rules.contains("[data-pathland-fit-cand] { display: none"), "the hide selector never targets all candidates: {rules}");
3367        // The live/single-child form still produces NO band rules.
3368        let mut single = Vec::new();
3369        single.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 1, component_type::SIZE_THAT_FITS as u32, 0));
3370        single.push(Opcode::new(category::TREE, tree::INSERT_CHILD, 0, 1, 3, 0));
3371        single.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 3, component_type::VSTACK as u32, 0));
3372        let html_single = renderer.render_document(&single, &strings, 1);
3373        assert!(!html_single.contains("data-pathland-fit-cands"), "single-child form unchanged: {}", html_single);
3374        assert!(!html_single.contains("data-pathland-fit-cand"), "single-child form unchanged: {}", html_single);
3375        assert!(!html_single.contains("<style data-pathland-fit>"), "no band rules for a single child");
3376    }
3377
3378    #[test]
3379    fn snapshot_fit_slot_three_candidates_one_visible_ascending_bands() {
3380        use pathland_core::value_type;
3381
3382        // Three candidates with thresholds [0, 600, 900] (ascending): exactly one
3383        // band per distinct non-fallback threshold, each showing its candidate and
3384        // hiding the other two; no band for T_0 (the inline fallback).
3385        let mut opcodes = Vec::new();
3386        opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 1, component_type::SIZE_THAT_FITS as u32, 0));
3387        let mut strings = Vec::new();
3388        strings.extend_from_slice(&3u32.to_le_bytes());
3389        strings.extend_from_slice(&0.0f32.to_le_bytes());
3390        strings.extend_from_slice(&600.0f32.to_le_bytes());
3391        strings.extend_from_slice(&900.0f32.to_le_bytes());
3392        opcodes.push(Opcode::new(category::PARAMETER, parameter::SET_PROPERTY, 0, 1, ((value_type::LIST as u32) << 16) | property_id::FIT_QUERY as u32, 0));
3393        for (child, idx) in [(2u32, 0u32), (3, 1), (4, 2)] {
3394            opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, child, component_type::VSTACK as u32, 0));
3395            opcodes.push(Opcode::new(category::TREE, tree::INSERT_CHILD, 0, 1, child, idx));
3396        }
3397
3398        let html = HtmlRenderer::new().render_document(&opcodes, &strings, 1);
3399        // Wrapper order = FIT_QUERY order; only candidate 0 is default-visible.
3400        assert!(html.contains("data-pathland-fit-cands=\"3\""), "{html}");
3401        assert!(html.contains("data-pathland-fit-cand=\"0\" data-pathland-fit-cand-id=\"2\" style=\"display:contents\""), "{html}");
3402        assert!(html.contains("data-pathland-fit-cand=\"1\" data-pathland-fit-cand-id=\"3\" style=\"display:none\""), "{html}");
3403        assert!(html.contains("data-pathland-fit-cand=\"2\" data-pathland-fit-cand-id=\"4\" style=\"display:none\""), "{html}");
3404
3405        let fit_style = html.find("<style data-pathland-fit>").expect("band style element");
3406        let close = html[fit_style..].find("</style>").unwrap() + fit_style;
3407        let rules = &html[fit_style..close];
3408        assert!(rules.contains("@container pf-1 (min-width: 600px)"), "{rules}");
3409        assert!(rules.contains("@container pf-1 (min-width: 900px)"), "{rules}");
3410        assert!(!rules.contains("min-width: 0px"), "no band for T_0: {rules}");
3411        assert!(
3412            rules.find("min-width: 600px").unwrap() < rules.find("min-width: 900px").unwrap(),
3413            "bands ascending: {rules}"
3414        );
3415        // Band 600: show candidate 1, hide 0 and 2.
3416        assert!(rules.contains("[data-pathland-id=\"1\"] > [data-pathland-fit-cand=\"1\"] { display: contents !important; }"), "{rules}");
3417        assert!(rules.contains("[data-pathland-id=\"1\"] > [data-pathland-fit-cand=\"0\"] { display: none !important; }"), "{rules}");
3418        assert!(rules.contains("[data-pathland-id=\"1\"] > [data-pathland-fit-cand=\"2\"] { display: none !important; }"), "{rules}");
3419        // Band 900: show candidate 2, hide 0 and 1.
3420        assert!(rules.contains("[data-pathland-id=\"1\"] > [data-pathland-fit-cand=\"2\"] { display: contents !important; }"), "{rules}");
3421        assert!(!rules.contains("[data-pathland-fit-cand] { display: none"), "never a hide-all selector: {rules}");
3422    }
3423
3424    #[test]
3425    fn snapshot_fit_slot_invalid_table_degrades_to_fallback_child() {
3426        use pathland_core::value_type;
3427
3428        // A multi-child slot whose FIT_QUERY table violates the candidate-identity
3429        // contract (here: one threshold for two children) cannot be gated. The
3430        // renderer must NOT emit wrappers/bands and must render the threshold-0
3431        // fallback child only — never ungated overlapping candidates.
3432        let mut opcodes = Vec::new();
3433        opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 1, component_type::SIZE_THAT_FITS as u32, 0));
3434        let mut strings = Vec::new();
3435        strings.extend_from_slice(&1u32.to_le_bytes());
3436        strings.extend_from_slice(&0.0f32.to_le_bytes());
3437        opcodes.push(Opcode::new(category::PARAMETER, parameter::SET_PROPERTY, 0, 1, ((value_type::LIST as u32) << 16) | property_id::FIT_QUERY as u32, 0));
3438        opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 2, component_type::VSTACK as u32, 0));
3439        opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 3, component_type::VSTACK as u32, 0));
3440        opcodes.push(Opcode::new(category::TREE, tree::INSERT_CHILD, 0, 1, 2, 0));
3441        opcodes.push(Opcode::new(category::TREE, tree::INSERT_CHILD, 0, 1, 3, 1));
3442
3443        let html = HtmlRenderer::new().render_document(&opcodes, &strings, 1);
3444        assert!(!html.contains("data-pathland-fit-cands"), "no gating: {html}");
3445        assert!(!html.contains("data-pathland-fit-cand"), "no wrappers: {html}");
3446        assert!(!html.contains("<style data-pathland-fit>"), "no bands: {html}");
3447        assert!(html.contains("data-pathland-id=\"2\""), "fallback child renders: {html}");
3448        assert!(!html.contains("data-pathland-id=\"3\""), "invalid candidates are not rendered ungated: {html}");
3449    }
3450
3451    #[test]
3452    fn fixed_child_keeps_points_box_in_hug_container() {
3453        use pathland_core::value_type;
3454
3455        // C2: a 220×220 image in a vertical stack keeps its exact points box
3456        // (width/height in px), positioned (flex-start) — never stretched.
3457        let mut opcodes = Vec::new();
3458        opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 1, component_type::VSTACK as u32, 0));
3459        opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 2, component_type::IMAGE as u32, 0));
3460        opcodes.push(Opcode::new(category::TREE, tree::INSERT_CHILD, 0, 1, 2, pathland_core::APPEND));
3461        opcodes.push(Opcode::new(
3462            category::PARAMETER,
3463            parameter::SET_PROPERTY,
3464            0,
3465            2,
3466            ((value_type::F32 as u32) << 16) | property_id::WIDTH as u32,
3467            220.0f32.to_bits(),
3468        ));
3469        opcodes.push(Opcode::new(
3470            category::PARAMETER,
3471            parameter::SET_PROPERTY,
3472            0,
3473            2,
3474            ((value_type::F32 as u32) << 16) | property_id::HEIGHT as u32,
3475            220.0f32.to_bits(),
3476        ));
3477        let renderer = HtmlRenderer::new();
3478        let html = renderer.render_document(&opcodes, &[], 1);
3479        assert!(html.contains("align-items:flex-start"), "hug container");
3480        assert!(html.contains("width:220px;height:220px"), "exact box: {}", html);
3481    }
3482
3483    #[test]
3484    fn divider_spans_cross_axis_and_scrollview_is_greedy() {
3485        // DIVIDER is layout-greedy on the cross axis (width:100%); SCROLLVIEW
3486        // is greedy on both axes (flex + align-self:stretch) — LAYOUT.md.
3487        let mut opcodes = Vec::new();
3488        opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 1, component_type::VSTACK as u32, 0));
3489        opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 2, component_type::DIVIDER as u32, 0));
3490        opcodes.push(Opcode::new(category::TREE, tree::INSERT_CHILD, 0, 1, 2, pathland_core::APPEND));
3491        opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 3, component_type::SCROLLVIEW as u32, 0));
3492        opcodes.push(Opcode::new(category::TREE, tree::INSERT_CHILD, 0, 1, 3, pathland_core::APPEND));
3493        let renderer = HtmlRenderer::new();
3494        let html = renderer.render_document(&opcodes, &[], 1);
3495        assert!(html.contains("width:100%"), "divider greedy: {}", html);
3496        assert!(
3497            html.contains("flex:1 1 auto;align-self:stretch;overflow:auto"),
3498            "scrollview greedy: {}",
3499            html
3500        );
3501    }
3502
3503    #[test]
3504    fn fill_propagates_through_a_hug_stack() {
3505        use pathland_core::{size, value_type};
3506        // A Hug VStack with a FILL-width child: the child stretches on the
3507        // cross axis (`align-self:stretch`) and the stack itself becomes
3508        // full-width (`width:100%` propagation) — SwiftUI/Compose parity.
3509        let mut opcodes = Vec::new();
3510        opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 1, component_type::VSTACK as u32, 0));
3511        opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 2, component_type::BUTTON as u32, 0));
3512        opcodes.push(Opcode::new(category::TREE, tree::INSERT_CHILD, 0, 1, 2, pathland_core::APPEND));
3513        opcodes.push(Opcode::new(
3514            category::PARAMETER,
3515            parameter::SET_PROPERTY,
3516            0,
3517            2,
3518            ((value_type::F32 as u32) << 16) | property_id::WIDTH as u32,
3519            size::FILL.to_bits(),
3520        ));
3521        let renderer = HtmlRenderer::new();
3522        let html = renderer.render_document(&opcodes, &[], 1);
3523        assert!(html.contains("width:100%"), "fill propagation: {}", html);
3524        assert!(html.contains("align-self:stretch"), "cross-axis fill: {}", html);
3525        // A Hug stack with no FILL child does NOT propagate.
3526        let mut hug = Vec::new();
3527        hug.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 1, component_type::VSTACK as u32, 0));
3528        hug.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 2, component_type::TEXT as u32, 0));
3529        hug.push(Opcode::new(category::TREE, tree::INSERT_CHILD, 0, 1, 2, pathland_core::APPEND));
3530        let html_hug = renderer.render_document(&hug, &[], 1);
3531        assert!(!html_hug.contains("width:100%"), "no propagation: {}", html_hug);
3532    }
3533
3534    #[test]
3535    fn text_field_secure_uses_password_input() {
3536        use pathland_core::value_type;
3537
3538        let mut opcodes = Vec::new();
3539        opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 1, component_type::TEXT_FIELD as u32, 0));
3540        opcodes.push(Opcode::new(
3541            category::PARAMETER,
3542            parameter::SET_PROPERTY,
3543            0,
3544            1,
3545            ((value_type::U8 as u32) << 16) | property_id::IS_SECURE as u32,
3546            1,
3547        ));
3548
3549        let renderer = HtmlRenderer::new();
3550        let html = renderer.render_document(&opcodes, &[], 1);
3551        assert!(html.contains("<input class=\"pathland-input\" type=\"password\""));
3552    }
3553
3554    #[test]
3555    fn style_css_applies_color_font_and_visibility() {
3556        use pathland_core::value_type;
3557
3558        let mut opcodes = Vec::new();
3559        opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 1, component_type::TEXT as u32, 0));
3560        opcodes.push(Opcode::new(
3561            category::PARAMETER,
3562            parameter::SET_PROPERTY,
3563            0,
3564            1,
3565            ((value_type::COLOR as u32) << 16) | property_id::COLOR as u32,
3566            0xFF11_2233,
3567        ));
3568        opcodes.push(Opcode::new(
3569            category::PARAMETER,
3570            parameter::SET_PROPERTY,
3571            0,
3572            1,
3573            ((value_type::F32 as u32) << 16) | property_id::FONT_SIZE as u32,
3574            18.0f32.to_bits(),
3575        ));
3576        opcodes.push(Opcode::new(
3577            category::PARAMETER,
3578            parameter::SET_PROPERTY,
3579            0,
3580            1,
3581            ((value_type::U8 as u32) << 16) | property_id::VISIBLE as u32,
3582            0,
3583        ));
3584
3585        let renderer = HtmlRenderer::new();
3586        let html = renderer.render_document(&opcodes, &[], 1);
3587        assert!(html.contains("color:rgba(17,34,51,1)"));
3588        assert!(html.contains("font-size:18px"), "font-size inline (dp): {}", html);
3589        assert!(html.contains("hidden"), "visible=0 -> hidden class");
3590    }
3591
3592    #[test]
3593    fn text_style_applies_default_typography_and_explicit_overrides_win() {
3594        use pathland_core::text_style;
3595        use pathland_core::value_type;
3596
3597        let render = |props: &[(u16, f32)]| {
3598            let mut opcodes = Vec::new();
3599            opcodes.push(Opcode::new(
3600                category::TREE,
3601                tree::CREATE_NODE,
3602                0,
3603                1,
3604                component_type::TEXT as u32,
3605                0,
3606            ));
3607            for (prop, value) in props {
3608                opcodes.push(Opcode::new(
3609                    category::PARAMETER,
3610                    parameter::SET_PROPERTY,
3611                    0,
3612                    1,
3613                    ((value_type::F32 as u32) << 16) | *prop as u32,
3614                    value.to_bits(),
3615                ));
3616            }
3617            HtmlRenderer::new().render_document(&opcodes, &[], 1)
3618        };
3619
3620        // A predefined typography emits its renderer-owned size/weight.
3621        let html = render(&[(property_id::TEXT_STYLE, text_style::TITLE2 as f32)]);
3622        assert!(html.contains("<h3"), "TITLE2 is a heading: {}", html);
3623        assert!(html.contains("font-size:22px"), "style size: {}", html);
3624        assert!(html.contains("font-weight:700"), "style weight: {}", html);
3625
3626        // An explicit FONT_SIZE overrides the typography's size.
3627        let html = render(&[
3628            (property_id::TEXT_STYLE, text_style::TITLE2 as f32),
3629            (property_id::FONT_SIZE, 11.0),
3630        ]);
3631        assert!(html.contains("font-size:11px"), "explicit size wins: {}", html);
3632    }
3633
3634    #[test]
3635    fn style_css_expands_fill_hints_to_percent() {
3636        use pathland_core::size;
3637
3638        let mut opcodes = Vec::new();
3639        opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 1, component_type::TEXT as u32, 0));
3640        opcodes.push(Opcode::new(
3641            category::PARAMETER,
3642            parameter::SET_PROPERTY,
3643            0,
3644            1,
3645            ((value_type::F32 as u32) << 16) | property_id::WIDTH as u32,
3646            size::FILL.to_bits(),
3647        ));
3648        opcodes.push(Opcode::new(
3649            category::PARAMETER,
3650            parameter::SET_PROPERTY,
3651            0,
3652            1,
3653            ((value_type::F32 as u32) << 16) | property_id::HEIGHT as u32,
3654            size::FILL.to_bits(),
3655        ));
3656
3657        // A FILL hint expands to the available space (`100%`); a fixed value
3658        // stays in px (see `size_css`), and the HUG_CONTENT sentinel is left
3659        // intrinsic.
3660        let renderer = HtmlRenderer::new();
3661        let html = renderer.render_document(&opcodes, &[], 1);
3662        assert!(html.contains("width:100%;height:100%;"), "FILL expands: {}", html);
3663    }
3664
3665    #[test]
3666    fn debug_comments_are_opt_in() {
3667        let mut opcodes = Vec::new();
3668        opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 1, component_type::VSTACK as u32, 0));
3669        opcodes.push(Opcode::new(
3670            category::PARAMETER,
3671            parameter::SET_PROPERTY,
3672            0,
3673            1,
3674            ((value_type::F32 as u32) << 16) | property_id::SPACING as u32,
3675            4.0f32.to_bits(),
3676        ));
3677        opcodes.push(Opcode::new(
3678            category::PARAMETER,
3679            parameter::SET_PROPERTY,
3680            0,
3681            1,
3682            ((value_type::F32 as u32) << 16) | property_id::ALIGNMENT as u32,
3683            3.0f32.to_bits(),
3684        ));
3685        opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 2, component_type::TEXT as u32, 0));
3686        opcodes.push(Opcode::new(category::TREE, tree::INSERT_CHILD, 0, 1, 2, 0));
3687
3688        // Default renderer: no comments.
3689        let renderer = HtmlRenderer::new();
3690        let html = renderer.render_document(&opcodes, &[], 1);
3691        assert!(!html.contains("<!--"), "comments off by default: {html}");
3692
3693        // with_debug_comments(true): a comment before each node naming its
3694        // component and the modifiers applied (sentinels + enums decoded).
3695        let renderer = HtmlRenderer::new().with_debug_comments(true);
3696        assert!(renderer.debug_comments());
3697        let html = renderer.render_document(&opcodes, &[], 1);
3698        assert!(
3699            html.contains("<!-- #1 VStack: spacing=4, alignment=Fill -->"),
3700            "{html}"
3701        );
3702        assert!(html.contains("<!-- #2 Text -->"), "{html}");
3703    }
3704
3705    #[test]
3706    fn builtin_css_block_contains_reset_tokens_and_components() {
3707        let css = css::STYLE;
3708        // Preflight reset.
3709        assert!(css.contains("box-sizing: border-box"), "preflight reset present");
3710        // Design tokens on :root (canonical spec paths → --pl-* vars).
3711        assert!(css.contains("--pl-color-primary"), "primary token");
3712        assert!(css.contains("--pl-color-background"), "background token");
3713        assert!(css.contains("--pl-color-surface"), "surface token");
3714        assert!(css.contains("--pl-color-text-primary"), "text token");
3715        assert!(css.contains("--pl-color-text-secondary"), "muted/secondary text token");
3716        assert!(css.contains("--pl-color-border"), "border token");
3717        assert!(css.contains("--pl-font-sans"), "font token");
3718        // Component defaults.
3719        assert!(css.contains(".pathland-button"), "button component");
3720        assert!(css.contains(".pathland-toggle"), "toggle component");
3721        assert!(css.contains(".pathland-spinner"), "spinner component");
3722        assert!(css.contains(".pathland-gauge"), "gauge component");
3723        // Dark mode media query.
3724        assert!(css.contains("prefers-color-scheme: dark"), "dark mode");
3725        // System font stack (no external font loading; the family resolves to
3726        // the platform's UI font).
3727        assert!(css.contains("font-feature-settings: 'liga' 1, 'calt' 1"), "Chrome ligature fix");
3728        assert!(css.contains("--pl-font-body-family: system-ui, -apple-system"), "system font stack");
3729        assert!(!css.contains("Inter"), "no bundled/external font family");
3730        // Document background + input field styling (Tailwind-style inset outline).
3731        assert!(css.contains("--pl-color-background"), "page background token");
3732        assert!(css.contains("color-scheme: light dark"), "native form controls follow the theme");
3733        assert!(css.contains(".pathland-input"), "input component");
3734        assert!(css.contains("--pl-input-border"), "input border token");
3735        assert!(css.contains("outline: 1px solid var(--pl-input-border)"), "inset outline instead of border");
3736        assert!(css.contains("outline-offset: -1px"), "inset ring");
3737        assert!(css.contains("--pl-input-focus"), "input focus token");
3738    }
3739
3740    #[test]
3741    fn builtin_css_covers_tier1_default_tokens() {
3742        let css = css::STYLE;
3743        // Generative spacing base — without it `space.<N>` refs resolve to an
3744        // undefined variable unless the app overrides `space.base`.
3745        assert!(css.contains("--pl-space-base"), "space.base token");
3746        assert_eq!(count(css, "--pl-space-base"), 2, "space.base has a light + dark default");
3747        // Canonical typography tokens (spec/TOKENS.md font.body.*).
3748        for var in [
3749            "--pl-font-body-size",
3750            "--pl-font-body-weight",
3751            "--pl-font-body-family",
3752        ] {
3753            assert!(css.contains(var), "missing {var}");
3754        }
3755        // Canonical control/button/input tokens so app overrides retheme components.
3756        for var in [
3757            "--pl-control-background",
3758            "--pl-control-foreground",
3759            "--pl-control-border",
3760            "--pl-control-border-width",
3761            "--pl-control-radius",
3762            "--pl-control-height",
3763            "--pl-control-accent",
3764            "--pl-button-background",
3765            "--pl-button-background-hover",
3766            "--pl-button-foreground",
3767            "--pl-button-border",
3768            "--pl-button-radius",
3769            "--pl-button-padding",
3770            "--pl-button-font-size",
3771            "--pl-button-font-weight",
3772            "--pl-input-background",
3773            "--pl-input-foreground",
3774            "--pl-input-placeholder",
3775            "--pl-input-border",
3776            "--pl-input-radius",
3777            "--pl-input-font-size",
3778        ] {
3779            assert!(css.contains(var), "missing canonical token {var}");
3780        }
3781        // Radius scale + hairline border width (Tier 1).
3782        for var in [
3783            "--pl-radius-xs",
3784            "--pl-radius-sm",
3785            "--pl-radius-md",
3786            "--pl-radius-lg",
3787            "--pl-radius-xl",
3788            "--pl-radius-full",
3789            "--pl-border-width-thin",
3790        ] {
3791            assert!(css.contains(var), "missing {var}");
3792        }
3793        // Elevation (composite shadows) — the shadow-sm replacement.
3794        for var in [
3795            "--pl-elevation-low-color",
3796            "--pl-elevation-low-x",
3797            "--pl-elevation-low-y",
3798            "--pl-elevation-low-blur",
3799            "--pl-elevation-high-color",
3800        ] {
3801            assert!(css.contains(var), "missing {var}");
3802        }
3803        assert!(!css.contains("--pl-color-focus-ring"), "focus ring now maps to control.accent");
3804        assert!(!css.contains("--pl-button-bg"), "internal button vars mapped onto button.*");
3805        assert!(!css.contains("--pl-shadow-sm"), "shadow-sm mapped onto elevation.low.*");
3806    }
3807
3808    #[test]
3809    fn builtin_css_canonical_rules_reference_the_catalog() {
3810        let css = css::STYLE;
3811        assert!(css.contains("border-radius: var(--pl-button-radius)"), "button radius from button.*");
3812        assert!(css.contains("background-color: var(--pl-button-background)"), "button bg from button.*");
3813        assert!(css.contains("color: var(--pl-button-foreground)"), "button fg from button.*");
3814        assert!(css.contains("background-color: var(--pl-input-background)"), "input bg from input.*");
3815        assert!(css.contains("color: var(--pl-input-foreground)"), "input fg from input.*");
3816        assert!(css.contains("outline: 2px solid var(--pl-control-accent)"), "focus ring uses control.accent");
3817        assert!(css.contains("var(--pl-elevation-low-x)"), "button shadow uses elevation.low.*");
3818        assert!(css.contains("var(--pl-elevation-high-x)"), "menu shadow uses elevation.high.*");
3819    }
3820
3821    fn count(haystack: &str, needle: &str) -> usize {
3822        haystack.matches(needle).count()
3823    }
3824
3825    #[test]
3826    fn document_head_loads_no_external_fonts() {
3827        let renderer = HtmlRenderer::new();
3828        let html = renderer.render_document(&[], &[], 0);
3829        assert!(!html.contains("rsms.me"), "no font CDN link: {html}");
3830        assert!(!html.contains("preconnect"), "no preconnect: {html}");
3831        assert!(
3832            !html.contains("<link rel=\"stylesheet\""),
3833            "no external stylesheets (fonts are system-native): {html}"
3834        );
3835        // The built-in <style> block still arrives before the body.
3836        let style = html.find("<style>").unwrap();
3837        let body = html.find("<body>").unwrap();
3838        assert!(style < body, "style block before body");
3839    }
3840
3841    #[test]
3842    fn renders_stack_with_gap_and_structural_child_inline() {
3843        use pathland_core::value_type;
3844
3845        let mut opcodes = Vec::new();
3846        let mut strings = Vec::new();
3847        opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 1, component_type::VSTACK as u32, 0));
3848        opcodes.push(Opcode::new(
3849            category::PARAMETER,
3850            parameter::SET_PROPERTY,
3851            0,
3852            1,
3853            ((value_type::F32 as u32) << 16) | property_id::SPACING as u32,
3854            12.0f32.to_bits(),
3855        ));
3856        opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 2, component_type::TEXT as u32, 0));
3857        opcodes.push(Opcode::new(category::TREE, tree::INSERT_CHILD, 0, 1, 2, 0));
3858        opcodes.push(Opcode::new(
3859            category::PARAMETER,
3860            parameter::SET_PROPERTY,
3861            0,
3862            2,
3863            ((value_type::F32 as u32) << 16) | property_id::PADDING as u32,
3864            8.0f32.to_bits(),
3865        ));
3866        strings.extend_from_slice(&(2u32).to_le_bytes());
3867        strings.extend_from_slice(b"Hi");
3868        opcodes.push(Opcode::new(category::PARAMETER, parameter::SET_TEXT, 0, 2, 0, 0));
3869
3870        let html = HtmlRenderer::new().render_document(&opcodes, &strings, 1);
3871        assert!(html.contains("flex-direction:column"), "stack inline flex");
3872        assert!(html.contains("gap:12px"), "spacing inline (dp): {}", html);
3873        assert!(html.contains("padding:8px"), "padding inline (dp): {}", html);
3874    }
3875
3876    #[test]
3877    fn renders_decorative_typography_inline_and_enum_class() {
3878        use pathland_core::value_type;
3879
3880        let mut opcodes = Vec::new();
3881        let mut strings = Vec::new();
3882        opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 1, component_type::TEXT as u32, 0));
3883        for (prop, vt, value) in [
3884            (property_id::FONT_SIZE, value_type::F32, 18.0f32.to_bits()),
3885            (property_id::FONT_WEIGHT, value_type::F32, 700.0f32.to_bits()),
3886            (property_id::TEXT_ALIGNMENT, value_type::F32, 1.0f32.to_bits()),
3887            (property_id::BORDER_RADIUS, value_type::F32, 8.0f32.to_bits()),
3888            (property_id::BLUR_RADIUS, value_type::F32, 3.0f32.to_bits()),
3889            (property_id::ROTATION_DEGREES, value_type::F32, 90.0f32.to_bits()),
3890        ] {
3891            opcodes.push(Opcode::new(
3892                category::PARAMETER,
3893                parameter::SET_PROPERTY,
3894                0,
3895                1,
3896                ((vt as u32) << 16) | prop as u32,
3897                value,
3898            ));
3899        }
3900        strings.extend_from_slice(&(2u32).to_le_bytes());
3901        strings.extend_from_slice(b"Hi");
3902        opcodes.push(Opcode::new(category::PARAMETER, parameter::SET_TEXT, 0, 1, 0, 0));
3903
3904        let html = HtmlRenderer::new().render_document(&opcodes, &strings, 1);
3905        // Arbitrary numbers inline; enum-derived stays a Tailwind class.
3906        assert!(html.contains("font-size:18px"), "font-size inline: {}", html);
3907        assert!(html.contains("font-weight:700"), "font-weight inline");
3908        assert!(html.contains("border-radius:8px"), "border-radius inline");
3909        assert!(html.contains("blur(3px)"), "blur inline filter");
3910        assert!(html.contains("rotate(90deg)"), "rotation inline transform");
3911        assert!(html.contains("text-align:center"), "text-align inline");
3912    }
3913
3914    fn string_section(entries: &[&str]) -> Vec<u8> {
3915        let mut v = Vec::new();
3916        for s in entries {
3917            v.extend_from_slice(&(s.len() as u32).to_le_bytes());
3918            v.extend_from_slice(s.as_bytes());
3919        }
3920        v
3921    }
3922
3923    #[test]
3924    fn set_design_token_emits_base_and_dark_override_css() {
3925        let strings = string_section(&["color.primary", "dark.color.primary"]);
3926        let dark_offset = (4 + 13) as u32; // entry 0: [len=13]"color.primary"
3927        let opcodes = vec![
3928            Opcode::new(category::PARAMETER, parameter::SET_DESIGN_TOKEN, 0, 0, value_type::COLOR as u32, 0xFF_2563EB),
3929            Opcode::new(
3930                category::PARAMETER,
3931                parameter::SET_DESIGN_TOKEN,
3932                0,
3933                dark_offset,
3934                value_type::COLOR as u32,
3935                0xFF_60A5FA,
3936            ),
3937        ];
3938        let html = HtmlRenderer::new().render_document(&opcodes, &strings, 1);
3939        assert!(html.contains(":root{--pl-color-primary:rgba(37,99,235,1);}"), "base override: {html}");
3940        assert!(
3941            html.contains("@media (prefers-color-scheme: dark){:root{--pl-color-primary:rgba(96,165,250,1);}}"),
3942            "dark override scoped in media query: {html}"
3943        );
3944    }
3945
3946    #[test]
3947    fn design_token_css_renders_inside_a_style_element() {
3948        // The token overrides must render as their own <style data-pathland-tokens>
3949        // element in <head> — NOT as bare rules after the built-in </style>.
3950        let strings = string_section(&["color.primary", "dark.color.primary"]);
3951        let dark_offset = (4 + 13) as u32; // entry 0: [len=13]"color.primary"
3952        let opcodes = vec![
3953            Opcode::new(category::PARAMETER, parameter::SET_DESIGN_TOKEN, 0, 0, value_type::COLOR as u32, 0xFF_2563EB),
3954            Opcode::new(
3955                category::PARAMETER,
3956                parameter::SET_DESIGN_TOKEN,
3957                0,
3958                dark_offset,
3959                value_type::COLOR as u32,
3960                0xFF_60A5FA,
3961            ),
3962        ];
3963        let html = HtmlRenderer::new().render_document(&opcodes, &strings, 1);
3964
3965        // The rules sit inside a dedicated style element, after the built-in block.
3966        assert!(
3967            html.contains("<style data-pathland-tokens>:root{--pl-color-primary:rgba(37,99,235,1);}"),
3968            "base rule inside the token style element: {html}"
3969        );
3970        assert!(
3971            html.contains("rgba(96,165,250,1);}}</style>"),
3972            "token style element is closed after the dark media query: {html}"
3973        );
3974        assert!(
3975            !html.contains("</style>:root") && !html.contains("</style>@media"),
3976            "no bare token rules outside a style element: {html}"
3977        );
3978
3979        // No overrides → no token style element at all.
3980        let plain = HtmlRenderer::new().render_document(&[], &[], 0);
3981        assert!(
3982            !plain.contains("data-pathland-tokens"),
3983            "no token style element without overrides: {plain}"
3984        );
3985    }
3986
3987    #[test]
3988    fn set_design_token_registers_a_length_token_with_px() {
3989        let strings = string_section(&["space.base"]);
3990        let opcodes = vec![Opcode::new(
3991            category::PARAMETER,
3992            parameter::SET_DESIGN_TOKEN,
3993            0,
3994            0,
3995            value_type::F32 as u32,
3996            4.0f32.to_bits(),
3997        )];
3998        let html = HtmlRenderer::new().render_document(&opcodes, &strings, 1);
3999        assert!(html.contains(":root{--pl-space-base:4px;}"), "length token px: {html}");
4000    }
4001
4002    #[test]
4003    fn string_valued_design_token_emits_quoted_css() {
4004        // Path "font.body.family" (16 chars → entry = 20 bytes) at offset 0,
4005        // value "Inter" at offset 20 (conformance vector 20 wire shape).
4006        let strings = string_section(&["font.body.family", "Inter"]);
4007        let opcodes = vec![Opcode::new(
4008            category::PARAMETER,
4009            parameter::SET_DESIGN_TOKEN,
4010            0,
4011            0,
4012            value_type::STRING as u32,
4013            20,
4014        )];
4015        let html = HtmlRenderer::new().render_document(&opcodes, &strings, 1);
4016        assert!(html.contains(":root{--pl-font-body-family:'Inter';}"), "string token: {html}");
4017    }
4018
4019    #[test]
4020    fn design_token_property_ref_renders_var() {
4021        let strings = string_section(&["color.primary"]);
4022        let opcodes = vec![
4023            Opcode::new(category::TREE, tree::CREATE_NODE, 0, 1, component_type::TEXT as u32, 0),
4024            Opcode::new(
4025                category::PARAMETER,
4026                parameter::SET_PROPERTY,
4027                0,
4028                1,
4029                ((value_type::DESIGN_TOKEN as u32) << 16) | property_id::COLOR as u32,
4030                0,
4031            ),
4032        ];
4033        let html = HtmlRenderer::new().render_fragment(&opcodes, &strings, 1);
4034        assert!(html.contains("color:var(--pl-color-primary);"), "token ref: {html}");
4035    }
4036
4037    #[test]
4038    fn generative_space_ref_renders_calc() {
4039        let strings = string_section(&["space.2"]);
4040        let opcodes = vec![
4041            Opcode::new(category::TREE, tree::CREATE_NODE, 0, 1, component_type::VSTACK as u32, 0),
4042            Opcode::new(
4043                category::PARAMETER,
4044                parameter::SET_PROPERTY,
4045                0,
4046                1,
4047                ((value_type::DESIGN_TOKEN as u32) << 16) | property_id::SPACING as u32,
4048                0,
4049            ),
4050        ];
4051        let html = HtmlRenderer::new().render_fragment(&opcodes, &strings, 1);
4052        assert!(
4053            html.contains("gap:calc(var(--pl-space-base) * 2);"),
4054            "generative space ref: {html}"
4055        );
4056    }
4057}