1use 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
35pub const MEDIA_CONTROLLER_SHELL_STYLE: &str =
43 "position:absolute;width:1px;height:1px;opacity:0;overflow:hidden;pointer-events:none";
44
45fn text_style_scale(code: u8) -> Option<(f32, f32)> {
49 Some(match code {
50 0 => (34.0, 700.0), 1 => (28.0, 700.0), 2 => (22.0, 700.0), 3 => (20.0, 600.0), 4 => (17.0, 600.0), 5 => (15.0, 400.0), 6 => (15.0, 400.0), 7 => (14.0, 400.0), 8 => (13.0, 400.0), 9 => (12.0, 400.0), 10 => (11.0, 400.0), _ => return None,
62 })
63}
64
65fn 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#[derive(Debug, Clone, PartialEq)]
78pub struct Node {
79 pub component: u16,
81 pub text: Option<String>,
83 pub attributed: Option<pathland_core::AttributedString>,
85 pub properties: BTreeMap<u16, u32>,
87 pub strings: BTreeMap<u16, String>,
89 pub token_refs: BTreeMap<u16, String>,
91 pub date: Option<(i32, u32)>,
93 pub children: Vec<u32>,
95 pub fit_thresholds: Vec<f32>,
99}
100
101fn 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
113fn 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; }
144 if t <= seen {
145 continue; }
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; }
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 fn checked(&self) -> bool {
183 self.properties
184 .get(&property_id::SELECTED)
185 .copied()
186 .unwrap_or(0)
187 != 0
188 }
189
190 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 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 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 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 fn u32_property(&self, prop: u16, default: u32) -> u32 {
225 self.properties.get(&prop).copied().unwrap_or(default)
226 }
227
228 fn token_ref(&self, prop: u16) -> Option<String> {
233 self.token_refs.get(&prop).map(|path| resolve_token_ref(path))
234 }
235
236 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 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 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 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 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 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 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 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 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 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 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 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 if let Some(bits) = self.properties.get(&property_id::VISIBLE) {
504 if *bits == 0 {
505 css.push_str("display:none;");
506 }
507 }
508 if let Some(v) = f32p(property_id::FONT_STYLE) {
510 if v != 0.0 {
511 css.push_str("font-style:italic;");
512 }
513 }
514 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 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 if let Some(v) = f32p(property_id::CLIPS_TO_BOUNDS) {
543 if v != 0.0 {
544 css.push_str("overflow:hidden;");
545 }
546 }
547 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 if let Some(v) = f32p(property_id::COLOR_INVERT) {
555 if v != 0.0 {
556 css.push_str("filter:invert(1);");
557 }
558 }
559 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 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
619fn 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
628fn rgba(argb: u32) -> String {
630 let (r, g, b, a) = unpack_color(argb);
631 format!("rgba({r},{g},{b},{a})")
632}
633
634fn 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
646fn 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
656fn 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 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, Some(v) if v.is_finite() && v > 0.0 => return false, _ => {}
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; }
712 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 let child_main = stack_main_horizontal(node.component)
740 .map(|main_h| main_h == axis_horizontal)
741 .unwrap_or(false); node.children
743 .iter()
744 .any(|&child| fills_axis(nodes, child, axis_horizontal, child_main))
745}
746
747fn 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}
770fn 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
781fn grid_columns(node: &Node) -> Option<u32> {
784 grid_count(node, property_id::GRID_COLUMNS)
785}
786
787fn grid_rows(node: &Node) -> Option<u32> {
790 grid_count(node, property_id::GRID_ROWS)
791}
792
793fn align_h(code: u8) -> &'static str {
796 match code {
797 1 | 3 | 4 => "center",
798 2 | 6 | 7 => "end",
799 _ => "start",
800 }
801}
802
803fn align_v(code: u8) -> &'static str {
805 match code {
806 1 | 5 | 6 => "center",
807 2 | 4 | 8 => "end",
808 _ => "start",
809 }
810}
811
812fn 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
820fn 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
840fn 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
856fn millis_to_hms(millis: u32) -> (u32, u32, u32) {
858 let total = millis / 1000;
859 (total / 3600, (total % 3600) / 60, total % 60)
860}
861
862fn 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
880fn 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 if !matches!(
912 node.component,
913 component_type::ICON
914 | component_type::IMAGE
915 | component_type::SLIDER
916 | 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
928fn 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 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 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
981fn 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
996fn 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
1010fn 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 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
1039fn 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#[derive(Debug, Default, Clone)]
1068struct Tokens {
1069 base: BTreeMap<String, (u8, TokenValue)>,
1070 dark: BTreeMap<String, (u8, TokenValue)>,
1071 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 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 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 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 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
1149fn 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
1155fn 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
1164fn 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
1185fn 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
1204fn 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
1232fn 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 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 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 node.date = Some((op.b() as i32, op.c()));
1334 }
1335 }
1336 _ => {}
1337 }
1338}
1339
1340#[derive(Debug, Clone, Copy)]
1345pub struct HtmlRenderer {
1346 debug_comments: bool,
1351}
1352
1353pub(crate) fn escape_html_attr(value: &str) -> String {
1355 value
1356 .replace('&', "&")
1357 .replace('"', """)
1358 .replace('<', "<")
1359 .replace('>', ">")
1360}
1361
1362fn 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 #[must_use]
1374 pub fn new() -> Self {
1375 Self {
1376 debug_comments: false,
1377 }
1378 }
1379
1380 #[must_use]
1384 pub fn with_debug_comments(mut self, on: bool) -> Self {
1385 self.debug_comments = on;
1386 self
1387 }
1388
1389 #[must_use]
1391 pub fn debug_comments(&self) -> bool {
1392 self.debug_comments
1393 }
1394
1395 #[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 #[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 #[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 #[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 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 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 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 #[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 let mut derived = String::new();
1567 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 format!(
1873 "<div{data_id} aria-hidden=\"true\" style=\"{MEDIA_CONTROLLER_SHELL_STYLE}\">{media}</div>"
1874 )
1875 }
1876 component_type::VIDEO => {
1877 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 format!(
1889 "<video{data_id}{event}{aria} class=\"pathland-video\"{binding} controls{style}></video>"
1890 )
1891 } else {
1892 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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
2328fn 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
2372fn style_attr(css: &str) -> String {
2374 if css.is_empty() {
2375 String::new()
2376 } else {
2377 format!(" style=\"{css}\"")
2378 }
2379}
2380
2381fn 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
2391fn escape(input: &str) -> String {
2393 input
2394 .replace('&', "&")
2395 .replace('<', "<")
2396 .replace('>', ">")
2397 .replace('"', """)
2398 .replace('\'', "'")
2399}
2400
2401fn 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
2425fn 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
2477fn 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 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 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>"), "<a & b>");
2563 }
2564
2565 #[test]
2566 fn renders_proxy_view_as_a_plain_container_with_its_spliced_subtree() {
2567 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 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 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 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 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, ));
2888 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, ));
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 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 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 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 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 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 #[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 #[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 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 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 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 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 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 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 assert!(html.contains("grid-template-columns:repeat(2,minmax(0,1fr))"), "widest row defines columns: {html}");
3279 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 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 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 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 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 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 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 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 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 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 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 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 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 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(), ));
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 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 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 let mut opcodes = Vec::new();
3736 opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 1, component_type::VSTACK as u32, 0));
3737 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 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 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 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 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 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 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 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 assert!(html.contains("<div data-pathland-id=\"2\" class=") || html.contains("<div data-pathland-id=\"2\""),
3834 "candidate content keeps its id: {}", html);
3835 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 let renderer = HtmlRenderer::new();
4283 let html = renderer.render_document(&opcodes, &[], 1);
4284 assert!(!html.contains("<!--"), "comments off by default: {html}");
4285
4286 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 assert!(css.contains("box-sizing: border-box"), "preflight reset present");
4303 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 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 assert!(css.contains("prefers-color-scheme: dark"), "dark mode");
4318 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 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 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 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 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 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 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 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 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 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 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 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 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; 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 let strings = string_section(&["color.primary", "dark.color.primary"]);
4607 let dark_offset = (4 + 13) as u32; 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 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 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 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 & <fun></title>"), "escaped title: {html}");
4807 assert!(
4808 html.contains("<meta name=\"description\" content=\"8 songs "great"\">"),
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}