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