Skip to main content

rux_style/
lib.rs

1//! Rux styling, milestone M2.
2//!
3//! Parses the `<style>` CSS with `lightningcss` (literal CSS, per Law 4), matches
4//! rules against the template tree with our own small selector engine, applies
5//! the cascade, and produces a styled `rux_layout::Node` tree. This is Stage 2
6//! of `docs/04-architecture.md`, narrowed to the honored subset.
7//!
8//! Selector support: tag, `.class`, `#id`, `[role="…"]`, compound
9//! (`view.card`), and all four combinators, descendant (`.a .b`), child
10//! (`.a > .b`), next-sibling (`.a + .b`) and subsequent-sibling (`.a ~ .b`).
11//! Specificity and source order resolve conflicts, as in CSS.
12
13use std::collections::{HashMap, HashSet};
14use std::rc::Rc;
15
16use lightningcss::rules::CssRule;
17use lightningcss::stylesheet::{ParserOptions, PrinterOptions, StyleSheet};
18use lightningcss::traits::ToCss;
19use rux_layout::{
20    Access, AccessRole, Align, Axis, Background, BoxShadow, Cursor, Display, Gradient, GridPlace, ImageContent, Justify,
21    Len, Node as LayoutNode, Overflow, Position, Rgba, Sides, Style, TextAlign, TextContent,
22    TextWrap, Track, TrackSide,
23};
24use rux_layout::{GradientKind, GridFlow, Transform};
25use rux_parser::{Element, Node as TplNode, Sfc};
26use rux_reactive::Value;
27/// Re-exported so the runtime and the shell can name a warning without
28/// depending on `rux-reactive` directly, the same way `Viewport` travels.
29pub use rux_reactive::Warning;
30use rux_script::Engine;
31
32/// Loop-variable bindings introduced by `r-for`, layered as a scope stack and
33/// injected into the script engine for each evaluation.
34type Locals = Vec<(String, Value)>;
35
36// ── Warning collection ──────────────────────────────────────────────────────
37
38thread_local! {
39    /// Warnings raised while building the current tree, unhonored properties,
40    /// unknown pseudo-classes, undefined `var()`s, unsupported `@media`.
41    ///
42    /// Collected per build so the runtime can show them *in the window*. The
43    /// stderr lines keep their own process-wide dedupe (a rebuild shouldn't spam
44    /// the terminal on every keystroke), but the overlay must list everything the
45    /// current document has wrong, every build, so this sink dedupes only within
46    /// itself and is drained by [`take_warnings`].
47    static WARNINGS: std::cell::RefCell<Vec<Warning>> = const { std::cell::RefCell::new(Vec::new()) };
48
49    /// The file line currently being cascaded, when it is known.
50    ///
51    /// Set around one rule's collection, so a warning raised anywhere beneath
52    /// can say where it came from without every function in between carrying a
53    /// line it does not otherwise care about. The alternative was threading a
54    /// parameter through selector parsing and pseudo-class parsing, neither of
55    /// which has any other reason to know what a file is.
56    static AT_LINE: std::cell::Cell<Option<usize>> = const { std::cell::Cell::new(None) };
57}
58
59/// Run `f` with any warnings it raises attributed to `line`. Restores whatever
60/// was set before, so nesting (a rule inside an `@media`) unwinds correctly.
61fn located<T>(line: Option<usize>, f: impl FnOnce() -> T) -> T {
62    let previous = AT_LINE.with(|l| l.replace(line));
63    let out = f();
64    AT_LINE.with(|l| l.set(previous));
65    out
66}
67
68fn warn(message: String) {
69    let warning = Warning::maybe_at(message, AT_LINE.with(|l| l.get()));
70    WARNINGS.with(|w| {
71        let mut w = w.borrow_mut();
72        // Deduped by message *and* line: the same unhonored property on two
73        // different rules is two places to go and fix, and an editor wants a
74        // squiggle on each. Twice on one line is still once.
75        if !w.contains(&warning) {
76            w.push(warning);
77        }
78    });
79}
80
81/// Raise a stylesheet warning from outside the cascade.
82///
83/// The runtime resolves `<style src="…">`, so it is the only layer that can
84/// notice an include that cannot be read at all. The warning still belongs in
85/// this sink, because to everyone downstream it is one more thing wrong with
86/// the document's styling, and having two places to drain would mean the
87/// overlay and `rux check` could disagree about what was said.
88pub fn warn_stylesheet(message: impl Into<String>) {
89    warn(message.into());
90}
91
92/// Take the warnings raised since the last call, emptying the sink.
93pub fn take_warnings() -> Vec<Warning> {
94    WARNINGS.with(|w| std::mem::take(&mut *w.borrow_mut()))
95}
96
97thread_local! {
98    /// Whether to mirror warnings to stderr as they are raised.
99    ///
100    /// On for anyone running the window, where stderr was the only place a
101    /// warning could go before the overlay existed. Off for a tool that drains
102    /// the sink and formats it itself: printing each warning twice, once as
103    /// prose and once as a diagnostic, is what makes machine-readable output
104    /// unpipeable.
105    static ECHO: std::cell::Cell<bool> = const { std::cell::Cell::new(true) };
106}
107
108/// Stop (or resume) mirroring warnings to stderr.
109///
110/// On by default, which suits anyone running the window, where stderr was the
111/// only place a warning could go before the overlay existed. A tool that drains
112/// the sink and formats the warnings itself turns it off: printing each one
113/// twice, once as prose and once as a diagnostic, is what makes machine-readable
114/// output unpipeable.
115pub fn set_stderr_echo(on: bool) {
116    ECHO.with(|e| e.set(on));
117}
118
119/// Mirror one already-deduped warning to stderr, unless that has been turned off.
120fn echo(message: &str) {
121    if ECHO.with(|e| e.get()) {
122        eprintln!("rux: {message}");
123    }
124}
125
126/// A text `{{ }}` binding recorded during build: where its node lives in the
127/// final tree, the raw text (with `{{ }}` intact) to re-interpolate, the
128/// `r-for` locals in scope when it was built, and the signals it reads. Lets the
129/// runtime recompute just this node's text when one of `deps` changes, instead of
130/// rebuilding the whole tree.
131#[derive(Clone, Debug)]
132pub struct TextBinding {
133    /// Child-index path from the tree root to the text node.
134    pub path: Vec<usize>,
135    /// The element's raw text template, still containing `{{ expr }}` spans.
136    pub template: String,
137    /// `r-for` loop locals captured at build time (empty for ordinary text).
138    pub locals: Vec<(String, Value)>,
139    /// Signals this binding reads, its subscription set.
140    pub deps: HashSet<String>,
141}
142
143/// An `<input>`'s displayed value, patchable in place: the input node's path, the
144/// bound signal, and the placeholder/colors needed to render an empty vs filled
145/// field. The shown text lives in the input's first child; a change to `model`
146/// rewrites it without a rebuild, so keystrokes don't throw the tree away.
147#[derive(Clone, Debug)]
148pub struct ValueBinding {
149    /// Path to the `<input>` node; its first child holds the shown text.
150    pub path: Vec<usize>,
151    /// The `r-model` signal expression.
152    pub model: String,
153    /// The `r-key` of the row this input is in, when it is in one. With
154    /// [`ValueBinding::locals`] it is what lets the value be read and written in
155    /// the row's own scope: the expression is recorded as written, so a model
156    /// like `items[item.at].note` means nothing without `item` in scope.
157    pub row: Option<String>,
158    /// Shown (dim) when the value is empty.
159    pub placeholder: String,
160    /// Text colour when the field has a value.
161    pub color: Rgba,
162    /// Text colour for the placeholder.
163    pub placeholder_color: Rgba,
164    /// `r-for` locals captured at build (empty for ordinary inputs).
165    pub locals: Vec<(String, Value)>,
166    /// Signals the value reads, normally just `model`.
167    pub deps: HashSet<String>,
168}
169
170/// An `r-show` condition, patchable in place: it only flips the node's `hidden`
171/// flag (paint on/off), never the tree shape, so a change rewrites one bool with
172/// no rebuild and no path invalidation.
173#[derive(Clone, Debug)]
174pub struct ShowBinding {
175    /// Path to the node whose `hidden` flag this controls.
176    pub path: Vec<usize>,
177    /// The `r-show` condition expression; the node is hidden when it is falsy.
178    pub cond: String,
179    /// `r-for` locals captured at build.
180    pub locals: Vec<(String, Value)>,
181    /// Signals the condition reads.
182    pub deps: HashSet<String>,
183}
184
185/// A parent element that holds structural directives (`r-if`/`r-elif`/`r-else`/
186/// `r-for`) among its children. Recorded so a change to one of `deps` can rebuild
187/// just this parent's children and splice them in, instead of rebuilding the whole
188/// tree (the reconciliation engine, slice 2b). `tpl_path` re-finds the parent
189/// element in the template; `tree_path` locates its node in the built tree.
190#[derive(Clone, Debug)]
191pub struct StructuralParent {
192    /// Child-index path from the tree root to the parent node.
193    pub tree_path: Vec<usize>,
194    /// Element-child index path from the template root to the parent element.
195    pub tpl_path: Vec<usize>,
196    /// Signals read by the structural directives directly under this parent.
197    pub deps: HashSet<String>,
198}
199
200/// A checkbox/radio `<input>` node, reconcilable in place: toggling its bound
201/// signal changes only this node (its `checked`-class style + mark child), never
202/// the tree shape, so the node is re-built and spliced at `path` on a change.
203#[derive(Clone, Debug)]
204pub struct ToggleBinding {
205    /// Path to the toggle `<input>` node.
206    pub path: Vec<usize>,
207    /// Signals the checked state reads, normally just the `r-model` signal.
208    pub deps: HashSet<String>,
209}
210
211/// An expanded component instance, reconcilable in place: a change to a prop's
212/// signals re-expands this subtree. The node's whole subtree is re-built and
213/// spliced at `path` (with focus re-applied, since a component may hold inputs).
214#[derive(Clone, Debug)]
215pub struct ComponentBinding {
216    /// Path to the component's root node.
217    pub path: Vec<usize>,
218    /// Signals its props read.
219    pub deps: HashSet<String>,
220}
221
222/// A node with a dynamic `:class` / `:style` that reads signals: a change
223/// re-cascades/re-interprets it, so it reconciles in place (node splice) like a
224/// component. (A `:style` that reads only an `r-for` local has no signal deps and
225/// is handled by the loop's own reconcile, so it isn't recorded here.)
226#[derive(Clone, Debug)]
227pub struct StyledBinding {
228    /// Path to the node.
229    pub path: Vec<usize>,
230    /// Signals `:class` / `:style` read.
231    pub deps: HashSet<String>,
232}
233
234/// A reactive attribute whose change rewrites one field of a node in place, with
235/// no shape change: `:src` on an `<image>` or `:options` on a `<select>`.
236#[derive(Clone, Debug)]
237pub struct AttrBinding {
238    /// Path to the node the attribute is on.
239    pub path: Vec<usize>,
240    /// The attribute expression.
241    pub expr: String,
242    /// `r-for` locals captured at build.
243    pub locals: Vec<(String, Value)>,
244    /// Signals the expression reads.
245    pub deps: HashSet<String>,
246}
247
248/// What a build discovered about reactivity: the patchable bindings (text `{{ }}`,
249/// input values, `r-show` visibility, `:src`/`:options` attributes), the parents
250/// that hold structural directives and the toggle nodes (for reconciliation), and
251/// the signals whose change can *not* be handled in place at all (component props)
252/// and so require a full rebuild.
253#[derive(Clone, Debug, Default)]
254pub struct BindingRegistry {
255    pub text: Vec<TextBinding>,
256    pub value: Vec<ValueBinding>,
257    pub show: Vec<ShowBinding>,
258    /// `:src` on `<image>`, rewrites the image source.
259    pub src: Vec<AttrBinding>,
260    /// `:options` on `<select>`, rewrites the option list.
261    pub options: Vec<AttrBinding>,
262    pub structural_parents: Vec<StructuralParent>,
263    pub toggles: Vec<ToggleBinding>,
264    pub components: Vec<ComponentBinding>,
265    pub styled: Vec<StyledBinding>,
266    /// Signals read by any non-patchable, non-reconcilable site. A change touching
267    /// one of these means the runtime must rebuild rather than patch. (Empty now,
268    /// kept as a safety net for any future non-reconcilable binding.)
269    pub structural: HashSet<String>,
270}
271
272
273/// Bake the active `r-for` loop bindings into a handler as a `let` prelude, so it
274/// still resolves them when it runs later in global scope (the loop variables are
275/// gone by then). With no locals the handler is returned unchanged.
276fn bind_locals(src: &str, locals: &Locals) -> String {
277    if locals.is_empty() {
278        return src.to_string();
279    }
280    let mut out = String::new();
281    for (name, value) in locals {
282        out.push_str("let ");
283        out.push_str(name);
284        out.push_str(" = ");
285        out.push_str(&value.to_rhai_literal());
286        out.push_str("; ");
287    }
288    out.push_str(src);
289    out
290}
291
292/// A compiled component: its template root, its own CSS rules, and the
293/// top-level script that gives each instance its private state.
294struct Component {
295    template: Element,
296    rules: Vec<Rule>,
297    /// The component's own `let` declarations, run once per instance. Its `fn`
298    /// definitions are not here: those are shared, merged into the one engine,
299    /// because a function is code and state is not.
300    script: String,
301}
302
303/// One component instance's private world: the state its own script declared,
304/// and the props the caller passed in.
305///
306/// State persists across rebuilds, keyed by where the instance sits in the
307/// *template* rather than in the tree, so it survives a list reordering around
308/// it. Props are re-derived on every build, since they are the caller's to
309/// decide.
310#[derive(Clone, Debug, Default)]
311pub struct Instance {
312    pub state: Vec<(String, Value)>,
313    pub props: Vec<(String, Value)>,
314    /// What the caller wrote as `@event="…"` on the tag: the bodies to run when
315    /// this instance emits. The caller's `r-for` locals are already baked in,
316    /// the same treatment an `@tap` gets, since the body runs long after the
317    /// build that read them.
318    pub listeners: Vec<(String, String)>,
319    /// The instance the caller itself sits in, `None` at document level. A
320    /// listener body is the caller's code and must run in the caller's scope:
321    /// anything else writes to the wrong variables and looks like it worked.
322    pub caller: Option<String>,
323    /// The path this instance was expanded under, when a `<router>` chose it.
324    /// Leaving that route drops the instance, so a view visited a second time
325    /// starts fresh rather than resuming where it was left. Anything meant to
326    /// outlive a visit belongs in a document signal, which is the same rule
327    /// components already follow for anything the caller needs to see.
328    pub route: Option<String>,
329    /// Whether the build now running has expanded this instance.
330    ///
331    /// Every build walks the whole template, including the one a reconcile does
332    /// before splicing, so an instance the current build never reached is one
333    /// that is no longer on screen: an `r-if` closed over it, or its `r-for` row
334    /// went away. Cleared at the start of a build and checked at the end, which
335    /// is the only moment both facts are known.
336    pub touched: bool,
337}
338
339/// Every live component instance, by identity. Owned by the runtime so it
340/// outlives the tree, which is rebuilt constantly.
341pub type Instances = HashMap<String, Instance>;
342
343/// A component instance's identity: where it sits in the template, and which
344/// `r-for` row it is in.
345///
346/// Deliberately not the tree path, which moves when a list reorders. Two
347/// `<panel>` elements written in one file are two instances; the same `<panel>`
348/// repeated by `r-for` is one instance per row.
349fn instance_key(tpl_path: &[usize], row: Option<&str>) -> String {
350    let mut key = String::new();
351    for step in tpl_path {
352        key.push_str(&step.to_string());
353        key.push('.');
354    }
355    if let Some(row) = row {
356        key.push('#');
357        key.push_str(row);
358    }
359    key
360}
361
362/// A component's own top-level statements: its script with `fn` blocks and
363/// `use` lines removed.
364///
365/// The functions are merged into the shared engine (code is shared; two
366/// instances calling one function is right), while what is left declares the
367/// state each instance gets its own copy of.
368fn component_statements(script: &str) -> String {
369    let mut out = String::new();
370    let lines: Vec<&str> = script.lines().collect();
371    let mut i = 0;
372    while i < lines.len() {
373        let trimmed = lines[i].trim();
374        if trimmed.starts_with("use ") {
375            i += 1;
376            continue;
377        }
378        if trimmed.starts_with("fn ") {
379            // Skip the whole definition by counting braces, so a function with
380            // an `if` inside does not end early.
381            let mut depth = 0i32;
382            let mut seen = false;
383            while i < lines.len() {
384                for c in lines[i].chars() {
385                    match c {
386                        '{' => {
387                            depth += 1;
388                            seen = true;
389                        }
390                        '}' => depth -= 1,
391                        _ => {}
392                    }
393                }
394                i += 1;
395                if seen && depth <= 0 {
396                    break;
397                }
398            }
399            continue;
400        }
401        out.push_str(lines[i]);
402        out.push('\n');
403        i += 1;
404    }
405    out
406}
407
408/// What a `<slot />` inside a component renders: the children written at the
409/// call site, and the context they were written in.
410///
411/// The context travels because slot content belongs to the *caller*, not to the
412/// component. It reads the caller's scope, including the caller's own instance
413/// state, which the component has no access to, and
414/// is styled by the caller's stylesheet (the component's rules are written for
415/// markup the component itself wrote). Only its position in the tree comes from
416/// the component.
417#[derive(Clone, Copy)]
418struct Slot<'a> {
419    children: &'a [&'a Element],
420    locals: &'a Locals,
421    rules: &'a [Rule],
422}
423
424/// An element's element children, skipping text nodes. Text between tags is
425/// handled by the text-binding path, not by the child builder.
426fn element_children(el: &Element) -> Vec<&Element> {
427    el.children
428        .iter()
429        .filter_map(|n| match n {
430            TplNode::Element(child) => Some(child),
431            TplNode::Text(_) => None,
432        })
433        .collect()
434}
435
436/// Registered components, keyed by custom-element tag.
437type Components = HashMap<String, Component>;
438
439/// Default inherited text colour (`#cdd6f4`) and font size, used at the root
440/// before any `color` / `font-size` rule applies. Text properties inherit.
441const DEFAULT_COLOR: Rgba = Rgba::new(0.804, 0.839, 0.957, 1.0);
442const DEFAULT_FONT_SIZE: f32 = 16.0;
443
444/// The text properties that inherit down the tree: an element uses its own
445/// `color`/`font-size`/`font-family` if set, else its parent's resolved value.
446#[derive(Clone)]
447struct Inherited {
448    color: Rgba,
449    font_size: f32,
450    font_family: Option<String>,
451    /// Custom properties (`--name`) in scope. They inherit like the text
452    /// properties above, see [`Vars`].
453    vars: Vars,
454}
455
456/// CSS custom properties (`--name: value`) in scope for an element: its own
457/// declarations layered over everything it inherited. Custom properties inherit
458/// like `color` does, which is what lets a palette be declared once on the root
459/// and read by `var()` anywhere below.
460///
461/// Shared by `Rc` because the overwhelmingly common case is a subtree that
462/// declares none of its own, those nodes hand the very same map to their
463/// children instead of copying it.
464type Vars = Rc<HashMap<String, String>>;
465
466/// How many `var()` hops to follow before giving up. A custom property may be
467/// defined in terms of another (`--accent: var(--blue)`), so resolution
468/// recurses; this is what stops `--a: var(--b); --b: var(--a)` from hanging.
469const MAX_VAR_DEPTH: usize = 16;
470
471/// Substitute every `var(--name[, fallback])` in `value`.
472///
473/// An undefined variable with no fallback leaves the reference in place, which
474/// makes the declaration unparseable and so ignored, which is CSS's own "invalid at
475/// computed-value time" behaviour. [`warn_undefined_var`] says so out loud,
476/// because a silently dropped declaration is the failure mode this project keeps
477/// trying to design away.
478fn resolve_vars(value: &str, vars: &HashMap<String, String>, depth: usize) -> String {
479    if depth >= MAX_VAR_DEPTH || !value.contains("var(") {
480        return value.to_string();
481    }
482    let mut out = String::with_capacity(value.len());
483    let mut rest = value;
484    while let Some(start) = rest.find("var(") {
485        out.push_str(&rest[..start]);
486        let after = &rest[start + 4..];
487        // Find this var()'s closing paren, allowing nested parens in a fallback
488        // (`var(--x, rgb(0, 0, 0))`).
489        let mut depth_parens = 1i32;
490        let mut end = None;
491        for (i, c) in after.char_indices() {
492            match c {
493                '(' => depth_parens += 1,
494                ')' => {
495                    depth_parens -= 1;
496                    if depth_parens == 0 {
497                        end = Some(i);
498                        break;
499                    }
500                }
501                _ => {}
502            }
503        }
504        let Some(end) = end else {
505            // Unclosed `var(`, emit the rest verbatim rather than looping.
506            out.push_str("var(");
507            out.push_str(after);
508            return out;
509        };
510        let inner = &after[..end];
511        let (name, fallback) = match inner.split_once(',') {
512            Some((n, f)) => (n.trim(), Some(f.trim())),
513            None => (inner.trim(), None),
514        };
515        match vars.get(name) {
516            // The substituted value may itself contain var().
517            Some(v) => out.push_str(&resolve_vars(v, vars, depth + 1)),
518            None => match fallback {
519                Some(f) => out.push_str(&resolve_vars(f, vars, depth + 1)),
520                None => {
521                    warn_undefined_var(name);
522                    out.push_str("var(");
523                    out.push_str(inner);
524                    out.push(')');
525                }
526            },
527        }
528        rest = &after[end + 1..];
529    }
530    out.push_str(rest);
531    out
532}
533
534/// Pull this element's `--name` declarations out of `props` and layer them over
535/// the inherited ones, returning what `var()` resolves against here and in the
536/// subtree below.
537///
538/// Declaring none, the common case, returns the inherited map by `Rc` clone, so
539/// only elements that actually define a variable pay for a copy. A custom
540/// property's own value may reference other variables, so it is resolved as it is
541/// inserted; that also means a `--x: var(--x)` self-reference resolves against
542/// the *outer* scope, as in CSS.
543fn take_vars(props: &mut HashMap<String, String>, inherited: &Vars) -> Vars {
544    let declared: Vec<String> = props.keys().filter(|k| k.starts_with("--")).cloned().collect();
545    if declared.is_empty() {
546        return Rc::clone(inherited);
547    }
548    let mut vars = (**inherited).clone();
549    for name in declared {
550        // Remove it: a custom property is not a real property, and leaving it in
551        // would only be fed to `interpret` (which ignores it) as noise.
552        let Some(value) = props.remove(&name) else { continue };
553        let value = resolve_vars(&value, &vars, 0);
554        vars.insert(name, value);
555    }
556    Rc::new(vars)
557}
558
559/// Warn once per name that a `var()` referenced an undefined custom property.
560fn warn_undefined_var(name: &str) {
561    use std::sync::{Mutex, OnceLock};
562    static SEEN: OnceLock<Mutex<HashSet<String>>> = OnceLock::new();
563    let message = format!(
564        "custom property `{name}` is not defined, the declaration using var({name}) is \
565         ignored (give it a fallback: `var({name}, …)`)"
566    );
567    warn(message.clone());
568    let seen = SEEN.get_or_init(|| Mutex::new(HashSet::new()));
569    let Ok(mut seen) = seen.lock() else { return };
570    if seen.insert(name.to_string()) {
571        echo(&message);
572    }
573}
574
575/// A radius larger than any sane box; kurbo clamps it to half the shorter side,
576/// which makes the box a circle/pill whatever its size.
577const CIRCLE: f32 = 9999.0;
578
579/// An `<input type=checkbox|radio>`: whether it is currently checked, and the
580/// signals its checked state reads (so a change can reconcile just this node).
581#[derive(Clone)]
582struct Toggle {
583    radio: bool,
584    checked: bool,
585    deps: HashSet<String>,
586}
587
588impl Toggle {
589    fn of(el: &Element, engine: &mut Engine, locals: &Locals) -> Option<Self> {
590        if el.tag != "input" {
591            return None;
592        }
593        let radio = match el.attr("type") {
594            Some("radio") => true,
595            Some("checkbox") => false,
596            _ => return None,
597        };
598        let model = el.attr("r-model").unwrap_or_default();
599        // The checked state reads the model signal; a change to it reconciles this
600        // toggle node in place (the `checked` class flips its style + mark).
601        let (checked, deps) = if model.is_empty() {
602            (false, HashSet::new())
603        } else if radio {
604            let (v, deps) = engine.eval_display_tracked(model, locals);
605            (v == el.attr("value").unwrap_or_default(), deps)
606        } else {
607            engine.eval_bool_tracked(model, locals)
608        };
609        Some(Self { radio, checked, deps })
610    }
611}
612
613/// Build the styled layout tree from a parsed SFC. `components` maps a custom
614/// element tag to the imported component's source; those are compiled and
615/// expanded in place with their props bound. `{{ }}` and directive expressions
616/// evaluate against the script engine's current state.
617pub fn build_styled_tree(
618    sfc: &Sfc,
619    components: &HashMap<String, Sfc>,
620    engine: &mut Engine,
621) -> Result<LayoutNode, String> {
622    let mut instances = Instances::new();
623    build_styled_tree_tracked(sfc, components, engine, &mut instances).map(|(node, _)| node)
624}
625
626/// Recompute a text binding's string against the engine's current state, what
627/// the runtime writes into the node at `binding.path` when a dependency changes.
628pub fn eval_text_binding(binding: &TextBinding, engine: &mut Engine) -> String {
629    interpolate_tracked(&binding.template, engine, &binding.locals).0
630}
631
632/// The class names a `:class` value contributes: a string splits on whitespace; a
633/// list contributes each item (also whitespace-split). Object/conditional form
634/// (`#{ active: cond }`) needs a `Value::Map` and isn't handled yet.
635fn class_list(value: &Value) -> Vec<String> {
636    match value {
637        Value::Text(s) => s.split_whitespace().map(str::to_string).collect(),
638        Value::List(items) => items
639            .iter()
640            .flat_map(|i| i.to_display().split_whitespace().map(str::to_string).collect::<Vec<_>>())
641            .collect(),
642        // Object/conditional form: keys whose value is truthy.
643        Value::Map(entries) => entries
644            .iter()
645            .filter(|(_, v)| v.is_truthy())
646            .flat_map(|(k, _)| k.split_whitespace().map(str::to_string).collect::<Vec<_>>())
647            .collect(),
648        _ => Vec::new(),
649    }
650}
651
652/// Merge an inline CSS declaration string (`"background: red; color: white"`) into
653/// the resolved props at highest priority (inline wins over the cascade). A simple
654/// `;`/`:` split, enough for the flat declaration lists inline styles carry.
655fn merge_inline_style(props: &mut HashMap<String, String>, css: &str) {
656    for decl in css.split(';') {
657        if let Some((name, value)) = decl.split_once(':') {
658            let name = name.trim().to_ascii_lowercase();
659            let value = value.trim();
660            if !name.is_empty() && !value.is_empty() {
661                props.insert(name, value.to_string());
662            }
663        }
664    }
665}
666
667/// Resolve `for=` labels: a node with `label_for` and no `@tap` of its own inherits
668/// the `@tap` of the input whose `id` it targets, so tapping the label activates
669/// that input (toggles a checkbox/radio) exactly as tapping the input would. A
670/// build-time link, no shell plumbing, and it survives a reconcile (which rebuilds).
671/// A `for=` target's tap handler (toggles/buttons) or bound model (text inputs).
672type LabelTarget = (Option<String>, Option<String>);
673
674/// An explicit `role="…"` mapped to an accessibility role. `role=` already drives
675/// selector matching (`[role="heading"]`); this makes it mean something to a
676/// screen reader too, which is what it was always for.
677///
678/// An unrecognised role still counts as a meaningful grouping rather than being
679/// dropped, the author said this element *is* something.
680fn explicit_access_role(el: &Element) -> Option<AccessRole> {
681    let role = el.role()?.to_ascii_lowercase();
682    Some(match role.as_str() {
683        "heading" => AccessRole::Heading,
684        "button" => AccessRole::Button,
685        "label" | "text" | "paragraph" => AccessRole::Label,
686        "link" => AccessRole::Link,
687        "checkbox" => AccessRole::CheckBox,
688        "radio" => AccessRole::RadioButton,
689        "textbox" | "textfield" => AccessRole::TextInput,
690        "combobox" | "listbox" | "select" => AccessRole::ComboBox,
691        "image" | "img" => AccessRole::Image,
692        _ => AccessRole::Group,
693    })
694}
695
696/// The author-supplied accessible name, if any: `label="…"` (or `alt="…"` on an
697/// image). When absent the name comes from the element's own text, or from a
698/// `<text for="…">` label pointing at it (see [`link_labels`]).
699fn authored_label(el: &Element) -> Option<String> {
700    el.attr("label")
701        .or_else(|| el.attr("alt"))
702        .filter(|v| !v.trim().is_empty())
703        .map(str::to_string)
704}
705
706/// All the text under a node, joined, the accessible name for a control whose
707/// label is its own content, like a `<view @tap>` acting as a button.
708fn subtree_text(node: &LayoutNode) -> String {
709    let mut out = String::new();
710    collect_subtree_text(node, &mut out);
711    out
712}
713
714fn collect_subtree_text(node: &LayoutNode, out: &mut String) {
715    if let Some(text) = &node.text {
716        if !text.text.trim().is_empty() {
717            if !out.is_empty() {
718                out.push(' ');
719            }
720            out.push_str(text.text.trim());
721        }
722    }
723    for child in &node.children {
724        collect_subtree_text(child, out);
725    }
726}
727
728fn link_labels(root: &mut LayoutNode) {
729    let mut targets: HashMap<String, LabelTarget> = HashMap::new();
730    collect_label_targets(root, &mut targets);
731    if !targets.is_empty() {
732        apply_label_targets(root, &targets);
733    }
734    // A `<text for="email">Email</text>` names the control it points at, which is
735    // the accessible name a screen reader announces for it. Collected in the same
736    // pass that makes such a label tappable, so the two can't drift apart.
737    let mut names: HashMap<String, String> = HashMap::new();
738    collect_label_names(root, &mut names);
739    if !names.is_empty() {
740        apply_label_names(root, &names);
741    }
742}
743
744/// Map each `for=` target id to the labelling element's text.
745fn collect_label_names(node: &LayoutNode, names: &mut HashMap<String, String>) {
746    if let Some(target) = &node.label_for {
747        let text = subtree_text(node);
748        if !text.is_empty() {
749            names.entry(target.clone()).or_insert(text);
750        }
751    }
752    for child in &node.children {
753        collect_label_names(child, names);
754    }
755}
756
757/// Give each labelled control its label's text as an accessible name. An
758/// authored `label=` on the control itself wins, it is the more specific
759/// statement of intent.
760fn apply_label_names(node: &mut LayoutNode, names: &HashMap<String, String>) {
761    if node.access.label.is_none() {
762        if let Some(name) = node.id.as_ref().and_then(|id| names.get(id)) {
763            node.access.label = Some(name.clone());
764        }
765    }
766    for child in &mut node.children {
767        apply_label_names(child, names);
768    }
769}
770
771fn collect_label_targets(node: &LayoutNode, targets: &mut HashMap<String, LabelTarget>) {
772    if let Some(id) = &node.id {
773        targets
774            .entry(id.clone())
775            .or_insert_with(|| (node.on_tap.clone(), node.model.clone()));
776    }
777    for child in &node.children {
778        collect_label_targets(child, targets);
779    }
780}
781
782fn apply_label_targets(node: &mut LayoutNode, targets: &HashMap<String, LabelTarget>) {
783    if node.on_tap.is_none() && node.focus_model.is_none() {
784        if let Some((tap, model)) = node.label_for.as_ref().and_then(|t| targets.get(t)) {
785            if let Some(tap) = tap {
786                // A tappable target (checkbox/radio/button): tap the label to run it.
787                node.on_tap = Some(tap.clone());
788            } else if let Some(model) = model {
789                // A text input: tap the label to focus it.
790                node.focus_model = Some(model.clone());
791            }
792        }
793    }
794    for child in &mut node.children {
795        apply_label_targets(child, targets);
796    }
797}
798
799/// Recompute a `:src` attribute to the (unresolved) image source string.
800pub fn eval_src_binding(binding: &AttrBinding, engine: &mut Engine) -> String {
801    engine.eval_display(&binding.expr, &binding.locals)
802}
803
804/// Recompute a `:options` attribute to the option strings.
805pub fn eval_options_binding(binding: &AttrBinding, engine: &mut Engine) -> Vec<String> {
806    engine
807        .eval_value(&binding.expr, &binding.locals)
808        .and_then(|v| v.as_list().map(|items| items.iter().map(Value::to_display).collect()))
809        .unwrap_or_default()
810}
811
812/// Recompute an input's shown text and colour against the engine's current state:
813/// the value in the normal colour, or the placeholder in the dim colour when empty.
814pub fn eval_value_binding(binding: &ValueBinding, engine: &mut Engine) -> (String, Rgba) {
815    let value = engine.eval_display(&binding.model, &binding.locals);
816    if value.is_empty() {
817        (binding.placeholder.clone(), binding.placeholder_color)
818    } else {
819        (value, binding.color)
820    }
821}
822
823/// Like [`build_styled_tree`], but also returns the [`BindingRegistry`], where
824/// each patchable text binding lives and which signals force a rebuild. The
825/// runtime uses it to update in place instead of rebuilding the whole tree.
826pub fn build_styled_tree_tracked(
827    sfc: &Sfc,
828    components: &HashMap<String, Sfc>,
829    engine: &mut Engine,
830    instances: &mut Instances,
831) -> Result<(LayoutNode, BindingRegistry), String> {
832    build_styled_tree_stateful(
833        sfc,
834        components,
835        engine,
836        instances,
837        &InteractionState::default(),
838        Viewport::default(),
839    )
840}
841
842/// Like [`build_styled_tree_tracked`], but matches pseudo-class selectors against
843/// the shell's current [`InteractionState`], what is hovered, pressed, focused,
844/// and `@media` queries against the current [`Viewport`]. The runtime passes its
845/// live state on every build so a reconcile reproduces the same styling.
846pub fn build_styled_tree_stateful(
847    sfc: &Sfc,
848    components: &HashMap<String, Sfc>,
849    engine: &mut Engine,
850    instances: &mut Instances,
851    state: &InteractionState,
852    viewport: Viewport,
853) -> Result<(LayoutNode, BindingRegistry), String> {
854    // The document's own `<style>` knows where it is in its file, so its
855    // warnings get a line. A component's does not: its rules live in a
856    // *different* file, and every consumer of a warning attributes it to the
857    // document being built, so a line from the component's coordinate space
858    // would point confidently at the wrong place. Unplaced is the honest answer
859    // until warnings carry a file as well as a line.
860    let rules = parse_document_rules(sfc, viewport);
861    let comps: Components = components
862        .iter()
863        .map(|(tag, c)| {
864            (
865                tag.clone(),
866                Component {
867                    template: c.template.clone(),
868                    rules: parse_component_rules(c, viewport),
869                    script: component_statements(&c.script),
870                },
871            )
872        })
873        .collect();
874
875    // An instance lives as long as it is on screen, and until now nothing ever
876    // said it had left: the only removal anywhere was the router's, so an
877    // `r-if` that closed over a component kept its state forever and gave it
878    // back on the way in, and the map only ever grew. A build walks the whole
879    // template, so what it does not reach is what is gone.
880    for instance in instances.values_mut() {
881        instance.touched = false;
882    }
883
884    let mut ancestors: Vec<AncNode> = Vec::new();
885    let locals = Locals::new();
886    let mut reg = BindingRegistry::default();
887    let mut node = build_node(
888        &sfc.template,
889        &rules,
890        &comps,
891        &mut ancestors,
892        &[],
893        &Inherited {
894            color: DEFAULT_COLOR,
895            font_size: DEFAULT_FONT_SIZE,
896            font_family: None,
897            vars: Vars::default(),
898        },
899        engine,
900        &locals,
901        &[],
902        &[],
903        &mut reg,
904        state,
905        instances,
906        None, // the root is not inside a component
907        None, // the document root has no caller, so no slot content
908        None, // and is not inside any row
909    );
910    link_labels(&mut node);
911    instances.retain(|_, instance| instance.touched);
912    Ok((node, reg))
913}
914
915/// Replace `{{ expr }}` spans in `text` with values evaluated by the engine, and
916/// return the union of signals read across all spans, the text binding's
917/// dependency set. Literal text has its HTML entities (`&amp;`, `&lt;`, …)
918/// decoded; interpolated values are inserted verbatim (already runtime strings).
919fn interpolate_tracked(
920    text: &str,
921    engine: &mut Engine,
922    locals: &Locals,
923) -> (String, HashSet<String>) {
924    let mut out = String::new();
925    let mut deps = HashSet::new();
926    let mut rest = text;
927    while let Some(start) = rest.find("{{") {
928        out.push_str(&decode_entities(&rest[..start]));
929        let after = &rest[start + 2..];
930        match after.find("}}") {
931            Some(end) => {
932                let (value, d) = engine.eval_display_tracked(after[..end].trim(), locals);
933                out.push_str(&value);
934                deps.extend(d);
935                rest = &after[end + 2..];
936            }
937            None => {
938                out.push_str("{{");
939                rest = after;
940            }
941        }
942    }
943    out.push_str(&decode_entities(rest));
944    (out, deps)
945}
946
947/// The raw concatenated text of an element's direct text children, `{{ }}` spans
948/// left intact, the template a [`TextBinding`] re-interpolates on change.
949fn text_template(el: &Element) -> String {
950    el.children
951        .iter()
952        .filter_map(|c| match c {
953            TplNode::Text(t) => Some(t.trim()),
954            _ => None,
955        })
956        .filter(|t| !t.is_empty())
957        .collect::<Vec<_>>()
958        .join(" ")
959}
960
961// Entity decoding for text lives in `rux_parser::decode_entities`, the parser
962// applies it to attribute values as it reads them, and text goes through the same
963// function, so there is one table, not two.
964use rux_parser::decode_entities;
965
966// ── Selector model ──────────────────────────────────────────────────────────
967
968/// One compound selector, e.g. `view.card#main[role="section"]:hover`.
969#[derive(Debug, Clone, Default)]
970struct Compound {
971    tag: Option<String>,
972    id: Option<String>,
973    classes: Vec<String>,
974    role: Option<String>,
975    pseudos: Vec<Pseudo>,
976}
977
978/// A pseudo-class in a compound selector. Each one tests a bit of interaction
979/// state carried on [`ElemStates`], *not* the element's markup.
980///
981/// An unrecognised pseudo-class becomes [`Pseudo::Unknown`], which **never
982/// matches**. That is deliberate: before pseudo-classes existed, `parse_compound`
983/// stopped at the `:` and dropped it, so `.box:hover` parsed as plain `.box` and
984/// the rule applied *unconditionally*. Failing closed means an unsupported
985/// pseudo-class does nothing instead of styling everything.
986#[derive(Debug, Clone, PartialEq, Eq)]
987enum Pseudo {
988    Hover,
989    Focus,
990    Active,
991    Checked,
992    Current,
993    Unknown(String),
994}
995
996/// Interaction state for one element, tested by the pseudo-classes above. It is
997/// not part of the element's markup: `checked` is resolved at build time from the
998/// toggle's `r-model`, while `hover`/`focus`/`active` are threaded in from the
999/// shell (see [`InteractionState`]).
1000#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
1001pub struct ElemStates {
1002    pub hover: bool,
1003    pub focus: bool,
1004    pub active: bool,
1005    pub checked: bool,
1006    /// This element's `to` names the path we are on. Resolved at build time from
1007    /// the `route` signal, like `checked` and unlike the pointer states.
1008    pub current: bool,
1009}
1010
1011/// The interaction state the *shell* owns, handed to the build so pseudo-class
1012/// selectors can match against it. Elements are identified by their tree path,
1013/// the same child-index path the [`BindingRegistry`] uses, because that is what
1014/// survives a reconcile and what the layout's state regions report back.
1015///
1016/// `checked` is not here: it is resolved from the toggle's `r-model` during the
1017/// build, not tracked by the shell.
1018#[derive(Clone, Debug, Default, PartialEq, Eq)]
1019pub struct InteractionState {
1020    /// Path of the innermost element under the pointer.
1021    pub hovered: Option<Vec<usize>>,
1022    /// Path of the element currently pressed (pointer down on it).
1023    pub active: Option<Vec<usize>>,
1024    /// `r-model` of the focused input, the shell tracks focus by model, not path.
1025    pub focused_model: Option<String>,
1026    /// The `r-key` of the row that input is in, when it is inside an `r-for`.
1027    /// Without it `:focus` matches every row of a list at once, since they all
1028    /// carry the same `r-model` text.
1029    pub focused_row: Option<String>,
1030}
1031
1032impl InteractionState {
1033    /// Does `path` name the hovered element or one of its ancestors? CSS `:hover`
1034    /// matches the whole chain from the root down to the pointer, not just the
1035    /// innermost element, a hovered button inside a hovered card leaves both
1036    /// hovered.
1037    fn hovers(&self, path: &[usize]) -> bool {
1038        self.hovered.as_ref().is_some_and(|h| h.starts_with(path))
1039    }
1040
1041    /// Same containment rule as [`Self::hovers`], for `:active`.
1042    fn activates(&self, path: &[usize]) -> bool {
1043        self.active.as_ref().is_some_and(|a| a.starts_with(path))
1044    }
1045}
1046
1047impl Pseudo {
1048    /// Is this a state the *shell* supplies (as opposed to `:checked`, resolved
1049    /// during the build)? Such an element needs a layout region so the shell can
1050    /// tell when the pointer enters or leaves it.
1051    fn is_pointer_state(&self) -> bool {
1052        matches!(self, Self::Hover | Self::Active)
1053    }
1054
1055    fn parse(name: &str) -> Self {
1056        match name.to_ascii_lowercase().as_str() {
1057            "hover" => Self::Hover,
1058            "focus" => Self::Focus,
1059            "active" => Self::Active,
1060            "checked" => Self::Checked,
1061            "current" => Self::Current,
1062            other => Self::Unknown(other.to_string()),
1063        }
1064    }
1065
1066    fn holds(&self, s: &ElemStates) -> bool {
1067        match self {
1068            Self::Hover => s.hover,
1069            Self::Focus => s.focus,
1070            Self::Active => s.active,
1071            Self::Checked => s.checked,
1072            Self::Current => s.current,
1073            // Fails closed, see the type docs.
1074            Self::Unknown(_) => false,
1075        }
1076    }
1077}
1078
1079/// How one compound relates to the compound on its left in a selector.
1080#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1081enum Combinator {
1082    /// `a b`: b is any descendant of a.
1083    Descendant,
1084    /// `a > b`: b is a direct child of a.
1085    Child,
1086    /// `a + b`: b is the element immediately following sibling a.
1087    NextSibling,
1088    /// `a ~ b`: b is any following sibling of a.
1089    SubsequentSibling,
1090}
1091
1092/// A full selector: a chain of compounds joined by combinators, plus its
1093/// specificity. `combs[i]` links `chain[i]` to `chain[i + 1]`, so it always has
1094/// one fewer entry than `chain`.
1095#[derive(Debug, Clone)]
1096struct Rule {
1097    chain: Vec<Compound>,
1098    combs: Vec<Combinator>,
1099    specificity: (u32, u32, u32),
1100    order: usize,
1101    decls: Vec<(String, String)>,
1102}
1103
1104/// The matchable identity of a template element.
1105#[derive(Debug, Clone)]
1106struct ElemDesc {
1107    tag: String,
1108    id: Option<String>,
1109    classes: Vec<String>,
1110    role: Option<String>,
1111    states: ElemStates,
1112}
1113
1114/// An ancestor in the match context: its identity plus the identities of the
1115/// rendered siblings that precede it. The preceding siblings are needed so a
1116/// sibling combinator (`+`/`~`) sitting above a descendant/child hop
1117/// (e.g. `.a ~ .b .c`) can still be resolved correctly.
1118#[derive(Debug, Clone)]
1119struct AncNode {
1120    desc: ElemDesc,
1121    prev: Vec<ElemDesc>,
1122}
1123
1124impl ElemDesc {
1125    fn of(el: &Element) -> Self {
1126        Self {
1127            tag: el.tag.clone(),
1128            id: el.id().map(str::to_string),
1129            classes: el.classes().into_iter().map(str::to_string).collect(),
1130            role: el.role().map(str::to_string),
1131            states: ElemStates::default(),
1132        }
1133    }
1134}
1135
1136// ── Media queries ───────────────────────────────────────────────────────────
1137
1138/// The viewport `@media` queries are evaluated against, the window's logical
1139/// size. It reaches the build the same way interaction state does, because a
1140/// resize can change which rules apply, not just where boxes land.
1141#[derive(Clone, Copy, Debug, PartialEq)]
1142pub struct Viewport {
1143    pub width: f32,
1144    pub height: f32,
1145}
1146
1147impl Default for Viewport {
1148    /// A desktop-ish window, so headless builds evaluate `@media` the way the
1149    /// default window would.
1150    fn default() -> Self {
1151        Self { width: 1280.0, height: 800.0 }
1152    }
1153}
1154
1155/// A comparison in a media feature. `min-width: 600px` is `Ge(600)`, and the
1156/// Level-4 range spelling `(width < 600px)` is `Lt(600)`.
1157#[derive(Debug, Clone, Copy, PartialEq)]
1158enum Cmp {
1159    Le,
1160    Lt,
1161    Ge,
1162    Gt,
1163    Eq,
1164}
1165
1166impl Cmp {
1167    fn holds(self, actual: f32, bound: f32) -> bool {
1168        match self {
1169            Self::Le => actual <= bound,
1170            Self::Lt => actual < bound,
1171            Self::Ge => actual >= bound,
1172            Self::Gt => actual > bound,
1173            Self::Eq => (actual - bound).abs() < f32::EPSILON,
1174        }
1175    }
1176
1177    /// The same comparison read right-to-left, for `(600px >= width)`.
1178    fn flipped(self) -> Self {
1179        match self {
1180            Self::Le => Self::Ge,
1181            Self::Lt => Self::Gt,
1182            Self::Ge => Self::Le,
1183            Self::Gt => Self::Lt,
1184            Self::Eq => Self::Eq,
1185        }
1186    }
1187}
1188
1189/// One media feature we understand. Anything else parses to [`Feature::Never`],
1190/// so an unsupported query hides its rules rather than applying them
1191/// unconditionally, the same fail-closed choice as an unknown pseudo-class.
1192#[derive(Debug, Clone, Copy, PartialEq)]
1193enum Feature {
1194    Width(Cmp, f32),
1195    Height(Cmp, f32),
1196    Portrait,
1197    Landscape,
1198    /// A media *type* we're always in (`screen`, `all`).
1199    Always,
1200    /// Unsupported, never matches.
1201    Never,
1202}
1203
1204impl Feature {
1205    fn holds(&self, vp: Viewport) -> bool {
1206        match *self {
1207            Self::Width(cmp, v) => cmp.holds(vp.width, v),
1208            Self::Height(cmp, v) => cmp.holds(vp.height, v),
1209            Self::Portrait => vp.height >= vp.width,
1210            Self::Landscape => vp.width > vp.height,
1211            Self::Always => true,
1212            Self::Never => false,
1213        }
1214    }
1215}
1216
1217/// A parsed media condition: a comma-separated list of alternatives (OR), each a
1218/// chain of `and`-ed features.
1219#[derive(Debug, Clone, Default)]
1220struct MediaCond {
1221    any: Vec<Vec<Feature>>,
1222}
1223
1224impl MediaCond {
1225    fn holds(&self, vp: Viewport) -> bool {
1226        self.any.iter().any(|all| all.iter().all(|f| f.holds(vp)))
1227    }
1228
1229    /// Parse a serialized media query list, e.g.
1230    /// `screen and (width <= 600px), (orientation: portrait)`.
1231    fn parse(text: &str) -> Self {
1232        let any = text
1233            .split(',')
1234            .map(|alternative| {
1235                alternative
1236                    .split(" and ")
1237                    .flat_map(|token| parse_media_feature(token.trim()))
1238                    .collect()
1239            })
1240            .collect();
1241        Self { any }
1242    }
1243}
1244
1245/// Parse one media feature. Returns several when the source is a double-ended
1246/// range (`(400px <= width <= 600px)` is two bounds `and`-ed).
1247///
1248/// **Both spellings have to work.** An author writes `(min-width: 600px)`, but
1249/// lightningcss normalizes it to the Media Queries Level 4 range form
1250/// `(width >= 600px)` before we ever see it, so the range form is in fact the
1251/// one that arrives in practice, and the `min-`/`max-` arm is the compatibility
1252/// path, not the other way round.
1253fn parse_media_feature(token: &str) -> Vec<Feature> {
1254    let inner = token.trim();
1255    // A bare media type.
1256    if !inner.starts_with('(') {
1257        return vec![match inner.to_ascii_lowercase().as_str() {
1258            "screen" | "all" => Feature::Always,
1259            // `print`/`speech` never apply to a window; `not …` and `only …` are
1260            // unsupported rather than wrong.
1261            other => {
1262                warn_unsupported_media(other);
1263                Feature::Never
1264            }
1265        }];
1266    }
1267    let body = inner.trim_start_matches('(').trim_end_matches(')').trim();
1268
1269    // Range syntax: `width <= 600px`, `600px >= width`, `400px <= width <= 600px`.
1270    let parts = split_on_comparators(body);
1271    if parts.len() >= 3 {
1272        return parse_range(&parts);
1273    }
1274
1275    let Some((name, value)) = body.split_once(':') else {
1276        // A boolean feature like `(hover)`, not something we can answer.
1277        warn_unsupported_media(body);
1278        return vec![Feature::Never];
1279    };
1280    let name = name.trim().to_ascii_lowercase();
1281    let value = value.trim();
1282    vec![match name.as_str() {
1283        "orientation" => match value.to_ascii_lowercase().as_str() {
1284            "portrait" => Feature::Portrait,
1285            "landscape" => Feature::Landscape,
1286            _ => Feature::Never,
1287        },
1288        "min-width" | "max-width" | "min-height" | "max-height" => {
1289            // Media lengths are absolute; the viewport-relative units a
1290            // stylesheet can use elsewhere would be circular here.
1291            let Some(px) = parse_px(value) else {
1292                warn_unsupported_media(&format!("{name}: {value}"));
1293                return vec![Feature::Never];
1294            };
1295            match name.as_str() {
1296                "min-width" => Feature::Width(Cmp::Ge, px),
1297                "max-width" => Feature::Width(Cmp::Le, px),
1298                "min-height" => Feature::Height(Cmp::Ge, px),
1299                _ => Feature::Height(Cmp::Le, px),
1300            }
1301        }
1302        other => {
1303            warn_unsupported_media(other);
1304            Feature::Never
1305        }
1306    }]
1307}
1308
1309/// One token of a range-syntax feature: an operand or a comparator.
1310enum RangePart {
1311    Operand(String),
1312    Op(Cmp),
1313}
1314
1315/// Split `width <= 600px` into operands and comparators. Returns an empty vec
1316/// when there is no comparator, so the caller falls through to `name: value`.
1317fn split_on_comparators(body: &str) -> Vec<RangePart> {
1318    let mut parts = Vec::new();
1319    let mut current = String::new();
1320    let mut chars = body.chars().peekable();
1321    let mut saw_op = false;
1322    while let Some(c) = chars.next() {
1323        let op = match c {
1324            '<' if chars.peek() == Some(&'=') => {
1325                chars.next();
1326                Some(Cmp::Le)
1327            }
1328            '>' if chars.peek() == Some(&'=') => {
1329                chars.next();
1330                Some(Cmp::Ge)
1331            }
1332            '<' => Some(Cmp::Lt),
1333            '>' => Some(Cmp::Gt),
1334            '=' => Some(Cmp::Eq),
1335            _ => None,
1336        };
1337        match op {
1338            Some(op) => {
1339                parts.push(RangePart::Operand(current.trim().to_string()));
1340                parts.push(RangePart::Op(op));
1341                current = String::new();
1342                saw_op = true;
1343            }
1344            None => current.push(c),
1345        }
1346    }
1347    if !saw_op {
1348        return Vec::new();
1349    }
1350    parts.push(RangePart::Operand(current.trim().to_string()));
1351    parts
1352}
1353
1354/// Turn a split range into features: `[operand, op, operand]` or the double-ended
1355/// `[operand, op, operand, op, operand]`.
1356fn parse_range(parts: &[RangePart]) -> Vec<Feature> {
1357    // Which side is the axis name decides how the comparison reads.
1358    let feature = |axis: &str, cmp: Cmp, value: &str| -> Feature {
1359        let Some(px) = parse_px(value) else {
1360            warn_unsupported_media(value);
1361            return Feature::Never;
1362        };
1363        match axis {
1364            "width" => Feature::Width(cmp, px),
1365            "height" => Feature::Height(cmp, px),
1366            other => {
1367                warn_unsupported_media(other);
1368                Feature::Never
1369            }
1370        }
1371    };
1372    let operand = |i: usize| match &parts[i] {
1373        RangePart::Operand(s) => s.to_ascii_lowercase(),
1374        RangePart::Op(_) => String::new(),
1375    };
1376    let op = |i: usize| match &parts[i] {
1377        RangePart::Op(c) => *c,
1378        RangePart::Operand(_) => Cmp::Eq,
1379    };
1380
1381    match parts.len() {
1382        3 => {
1383            let (left, right) = (operand(0), operand(2));
1384            if left == "width" || left == "height" {
1385                vec![feature(&left, op(1), &right)]
1386            } else {
1387                // `600px >= width`: same relation, read the other way.
1388                vec![feature(&right, op(1).flipped(), &left)]
1389            }
1390        }
1391        // `400px <= width <= 600px`: both bounds, `and`-ed.
1392        5 => {
1393            let axis = operand(2);
1394            vec![
1395                feature(&axis, op(1).flipped(), &operand(0)),
1396                feature(&axis, op(3), &operand(4)),
1397            ]
1398        }
1399        _ => vec![Feature::Never],
1400    }
1401}
1402
1403/// Warn once per unsupported media feature, an `@media` block that silently
1404/// never applies is exactly the failure mode the unhonored-property warning
1405/// exists to prevent.
1406fn warn_unsupported_media(what: &str) {
1407    use std::sync::{Mutex, OnceLock};
1408    static SEEN: OnceLock<Mutex<HashSet<String>>> = OnceLock::new();
1409    let message = format!(
1410        "`@media` condition `{what}` is not supported, its rules will never apply \
1411         (supported: screen/all, min-/max-width, min-/max-height, orientation)"
1412    );
1413    warn(message.clone());
1414    let seen = SEEN.get_or_init(|| Mutex::new(HashSet::new()));
1415    let Ok(mut seen) = seen.lock() else { return };
1416    if seen.insert(what.to_string()) {
1417        echo(&message);
1418    }
1419}
1420
1421/// Whether each `@media` block in `css` applies at `vp`, in source order. The
1422/// runtime compares this across a resize: if it is unchanged, no rule set changed
1423/// and the tree does not need re-cascading.
1424pub fn media_matches(css: &str, vp: Viewport) -> Vec<bool> {
1425    let Ok(sheet) = StyleSheet::parse(css, ParserOptions::default()) else {
1426        return Vec::new();
1427    };
1428    let mut out = Vec::new();
1429    collect_media_matches(&sheet.rules.0, vp, &mut out);
1430    out
1431}
1432
1433fn collect_media_matches(rules: &[CssRule], vp: Viewport, out: &mut Vec<bool>) {
1434    for rule in rules {
1435        if let CssRule::Media(media) = rule {
1436            let text = media
1437                .query
1438                .to_css_string(PrinterOptions::default())
1439                .unwrap_or_default();
1440            out.push(MediaCond::parse(&text).holds(vp));
1441            collect_media_matches(&media.rules.0, vp, out);
1442        }
1443    }
1444}
1445
1446// ── Parsing the stylesheet ──────────────────────────────────────────────────
1447
1448/// `base` is the 1-based file line the `<style>` block's first character sits
1449/// on, used to lift lightningcss's section-relative positions onto the file's
1450/// own lines. `None` means "do not claim to know": see [`parse_rules_at`].
1451fn parse_rules(css: &str, vp: Viewport) -> Vec<Rule> {
1452    parse_rules_at(css, vp, None)
1453}
1454
1455/// Every sheet a document styles with: what it included, then its own
1456/// `<style>` body.
1457///
1458/// The included sheets come first so the document wins a tie, which is what
1459/// including a palette is for. That is source order doing the work, exactly as
1460/// it would if the file had been pasted in.
1461///
1462/// Included rules are parsed unplaced. They live in a different file, and every
1463/// consumer attributes a warning to the document being built, so a line number
1464/// from the include's coordinate space would point confidently at the wrong
1465/// place. Same reasoning as a component's `<style>` above.
1466fn parse_document_rules(sfc: &Sfc, vp: Viewport) -> Vec<Rule> {
1467    if sfc.style_includes.is_empty() {
1468        // The overwhelmingly common case, and it keeps the `order` values
1469        // exactly as they were before includes existed.
1470        return parse_rules_at(&sfc.style, vp, Some(sfc.style_line));
1471    }
1472    let mut rules = Vec::new();
1473    for include in &sfc.style_includes {
1474        rules.extend(parse_rules(&include.css, vp));
1475    }
1476    rules.extend(parse_rules_at(&sfc.style, vp, Some(sfc.style_line)));
1477    renumber(&mut rules);
1478    rules
1479}
1480
1481/// [`parse_document_rules`] for a component, whose own `<style>` is unplaced
1482/// too.
1483fn parse_component_rules(sfc: &Sfc, vp: Viewport) -> Vec<Rule> {
1484    if sfc.style_includes.is_empty() {
1485        return parse_rules(&sfc.style, vp);
1486    }
1487    let mut rules = Vec::new();
1488    for include in &sfc.style_includes {
1489        rules.extend(parse_rules(&include.css, vp));
1490    }
1491    rules.extend(parse_rules(&sfc.style, vp));
1492    renumber(&mut rules);
1493    rules
1494}
1495
1496/// Restate `order` across concatenated sheets.
1497///
1498/// `order` is per-sheet and breaks specificity ties, so two sheets each
1499/// starting at 0 would make the tie-break meaningless and let an included rule
1500/// beat the document's own. Renumbering makes the concatenation read as one
1501/// sheet, which is what it is.
1502fn renumber(rules: &mut [Rule]) {
1503    for (i, rule) in rules.iter_mut().enumerate() {
1504        rule.order = i;
1505    }
1506}
1507
1508fn parse_rules_at(css: &str, vp: Viewport, base: Option<usize>) -> Vec<Rule> {
1509    let sheet = match StyleSheet::parse(css, ParserOptions::default()) {
1510        Ok(s) => s,
1511        Err(_) => return Vec::new(),
1512    };
1513
1514    let mut rules = Vec::new();
1515    let mut order = 0usize;
1516    collect_rules(&sheet.rules.0, vp, &mut rules, &mut order, base, css);
1517    rules
1518}
1519
1520/// Walk the rule list, descending into `@media` blocks whose condition holds at
1521/// `vp`. A block that doesn't hold contributes nothing, so everything
1522/// downstream (matching, cascade, specificity) is untouched by media queries.
1523/// `order` keeps counting across blocks, which is what makes a later `@media`
1524/// rule win over an earlier plain rule of equal specificity, as in CSS.
1525fn collect_rules(
1526    rules: &[CssRule],
1527    vp: Viewport,
1528    out: &mut Vec<Rule>,
1529    order: &mut usize,
1530    base: Option<usize>,
1531    css: &str,
1532) {
1533    for rule in rules {
1534        match rule {
1535            CssRule::Media(media) => {
1536                let text = media
1537                    .query
1538                    .to_css_string(PrinterOptions::default())
1539                    .unwrap_or_default();
1540                // An unsupported condition is reported against the `@media` line.
1541                let holds = located(file_line(base, media.loc.line), || {
1542                    MediaCond::parse(&text).holds(vp)
1543                });
1544                if holds {
1545                    collect_rules(&media.rules.0, vp, out, order, base, css);
1546                }
1547            }
1548            CssRule::Style(style) => collect_style_rule(style, out, order, base, css),
1549            _ => {}
1550        }
1551    }
1552}
1553
1554/// Lift a section-relative line (lightningcss counts from 0) onto the file's
1555/// own 1-based numbering. `None` in, `None` out: a document whose base is not
1556/// known reports no line rather than a wrong one.
1557fn file_line(base: Option<usize>, relative: u32) -> Option<usize> {
1558    base.map(|b| b + relative as usize)
1559}
1560
1561/// The section-relative line `property` is declared on, scanning forward from
1562/// `rule_line` (the line the rule's selector sits on).
1563///
1564/// lightningcss records a location for a *rule* but none for the declarations
1565/// inside it, so this is the only way back to the line a reader can see. The
1566/// scan stops at the brace that closes the rule, so a property absent from this
1567/// rule reports `None` rather than borrowing a line from the next one.
1568///
1569/// Matching is deliberately loose: the property name at the start of a line,
1570/// then optional whitespace, then a colon. That is how a declaration is written
1571/// in practice, and a miss costs the rule's line, which is what the caller
1572/// would have used anyway.
1573fn decl_line(css: &str, rule_line: u32, property: &str) -> Option<u32> {
1574    let mut depth = 0usize;
1575    let mut entered = false;
1576    for (offset, text) in css.lines().enumerate().skip(rule_line as usize) {
1577        // Inside the block, a line whose first token is the property is it.
1578        if entered {
1579            let trimmed = text.trim_start();
1580            if let Some(rest) = trimmed.strip_prefix(property) {
1581                if rest.trim_start().starts_with(':') {
1582                    return u32::try_from(offset).ok();
1583                }
1584            }
1585        }
1586        for ch in text.chars() {
1587            match ch {
1588                '{' => {
1589                    depth += 1;
1590                    entered = true;
1591                }
1592                '}' => {
1593                    depth = depth.saturating_sub(1);
1594                    // The rule has closed without the property turning up.
1595                    if entered && depth == 0 {
1596                        return None;
1597                    }
1598                }
1599                _ => {}
1600            }
1601        }
1602    }
1603    None
1604}
1605
1606fn collect_style_rule(
1607    style: &lightningcss::rules::style::StyleRule,
1608    out: &mut Vec<Rule>,
1609    order: &mut usize,
1610    base: Option<usize>,
1611    css: &str,
1612) {
1613    located(file_line(base, style.loc.line), || {
1614        // Serialize each declaration to "prop: value" and split it.
1615        let mut decls = Vec::new();
1616        for prop in &style.declarations.declarations {
1617            if let Ok(text) = prop.to_css_string(false, PrinterOptions::default()) {
1618                if let Some((k, v)) = text.split_once(':') {
1619                    let key = k.trim().to_lowercase();
1620                    // Silent ignoring is the worst failure mode we have: valid CSS
1621                    // that does nothing with no explanation. Say so, once per name.
1622                    //
1623                    // Against the declaration's own line, not the rule's: a
1624                    // selector and the property under it can be many lines
1625                    // apart, and a warning that points at the selector sends
1626                    // the reader somewhere the named property does not appear.
1627                    let at = decl_line(css, style.loc.line, &key).unwrap_or(style.loc.line);
1628                    located(file_line(base, at), || warn_if_unhonored(&key));
1629                    decls.push((
1630                        key,
1631                        v.trim().trim_end_matches(';').trim().to_string(),
1632                    ));
1633                }
1634            }
1635        }
1636
1637        // One Rule per selector in the list (they share the declarations).
1638        for selector in &style.selectors.0 {
1639            if let Ok(text) = selector.to_css_string(PrinterOptions::default()) {
1640                if let Some((chain, combs, specificity)) = parse_selector(&text) {
1641                    out.push(Rule {
1642                        chain,
1643                        combs,
1644                        specificity,
1645                        order: *order,
1646                        decls: decls.clone(),
1647                    });
1648                }
1649            }
1650            *order += 1;
1651        }
1652    });
1653}
1654
1655/// The CSS properties the runtime actually interprets today. Anything outside
1656/// this set is parsed and then dropped, so [`warn_if_unhonored`] flags it. When
1657/// a new property is honored in `interpret` (or the text/border helpers), add it
1658/// here too, or authors will be told a working property does nothing.
1659const HONORED_PROPERTIES: &[&str] = &[
1660    // Box / display
1661    "display", "width", "height", "gap",
1662    "min-width", "max-width", "min-height", "max-height",
1663    "padding", "padding-top", "padding-right", "padding-bottom", "padding-left",
1664    "margin", "margin-top", "margin-right", "margin-bottom", "margin-left",
1665    "border", "border-width", "border-color", "border-radius",
1666    "border-top-left-radius", "border-top-right-radius",
1667    "border-bottom-right-radius", "border-bottom-left-radius",
1668    "border-top", "border-right", "border-bottom", "border-left",
1669    "border-top-width", "border-right-width", "border-bottom-width", "border-left-width",
1670    "overflow", "overflow-x", "overflow-y", "opacity", "cursor", "box-shadow", "transform",
1671    // Flex / grid
1672    "flex", "flex-grow", "flex-shrink", "flex-basis", "flex-wrap", "flex-direction",
1673    "justify-content", "align-items", "align-self", "justify-self", "justify-items",
1674    "align-content", "row-gap", "column-gap",
1675    "grid-template-columns", "grid-template-rows",
1676    "grid-column", "grid-row",
1677    "grid-column-start", "grid-column-end", "grid-row-start", "grid-row-end",
1678    "grid-auto-flow", "grid-auto-rows", "grid-auto-columns",
1679    // Positioning
1680    "position", "top", "right", "bottom", "left", "aspect-ratio",
1681    // Background
1682    "background", "background-color", "background-image",
1683    // Text
1684    "color", "font-size", "font-weight", "font-family", "font-style", "text-align",
1685    "letter-spacing", "word-spacing", "line-height", "white-space",
1686    "text-decoration", "text-decoration-line",
1687    "overflow-wrap", "word-wrap", "word-break",
1688];
1689
1690fn is_honored(property: &str) -> bool {
1691    HONORED_PROPERTIES.contains(&property)
1692}
1693
1694/// Warn, once per property name, for the life of the process, that a parsed
1695/// declaration is not honored. Deduped so a whole-tree rebuild (which reparses
1696/// every sheet) doesn't repeat the same line on every keystroke.
1697fn warn_if_unhonored(property: &str) {
1698    use std::collections::HashSet;
1699    use std::sync::{Mutex, OnceLock};
1700    static SEEN: OnceLock<Mutex<HashSet<String>>> = OnceLock::new();
1701
1702    // A custom property is not a property we could fail to honor, it is storage
1703    // for `var()`, and any name is legal.
1704    if property.starts_with("--") || is_honored(property) {
1705        return;
1706    }
1707    let message =
1708        format!("CSS property `{property}` is parsed but not yet honored, it will have no effect");
1709    warn(message.clone());
1710    let seen = SEEN.get_or_init(|| Mutex::new(HashSet::new()));
1711    let Ok(mut seen) = seen.lock() else { return };
1712    if seen.insert(property.to_string()) {
1713        echo(&message);
1714    }
1715}
1716
1717/// Parse a selector string into a chain of compounds, the combinators joining
1718/// them, and its specificity. Combinator tokens (`>`, `+`, `~`) are recognised
1719/// with or without surrounding whitespace; a bare space is the descendant
1720/// combinator. `[…]` attribute segments are skipped so a `~=` inside one is not
1721/// mistaken for a combinator.
1722fn parse_selector(text: &str) -> Option<(Vec<Compound>, Vec<Combinator>, (u32, u32, u32))> {
1723    let chars: Vec<char> = text.chars().collect();
1724    let mut i = 0;
1725    let mut chain = Vec::new();
1726    let mut combs = Vec::new();
1727    let mut spec = (0u32, 0u32, 0u32);
1728    // A combinator waiting to be attached to the next compound we read.
1729    let mut pending: Option<Combinator> = None;
1730
1731    while i < chars.len() {
1732        let c = chars[i];
1733        if c.is_whitespace() {
1734            i += 1;
1735            continue;
1736        }
1737        if let Some(comb) = combinator_of(c) {
1738            pending = Some(comb);
1739            i += 1;
1740            continue;
1741        }
1742        // Read one compound: everything up to the next top-level whitespace or
1743        // combinator, treating `[…]` as opaque.
1744        let start = i;
1745        let mut depth = 0i32;
1746        while i < chars.len() {
1747            let d = chars[i];
1748            if d == '[' || d == '(' {
1749                depth += 1;
1750            } else if d == ']' || d == ')' {
1751                depth -= 1;
1752            } else if depth == 0 && (d.is_whitespace() || combinator_of(d).is_some()) {
1753                break;
1754            }
1755            i += 1;
1756        }
1757        let token: String = chars[start..i].iter().collect();
1758        let compound = parse_compound(&token, &mut spec)?;
1759        if !chain.is_empty() {
1760            // A space with no explicit combinator is the descendant combinator.
1761            combs.push(pending.take().unwrap_or(Combinator::Descendant));
1762        }
1763        pending = None;
1764        chain.push(compound);
1765    }
1766    if chain.is_empty() {
1767        return None;
1768    }
1769    Some((chain, combs, spec))
1770}
1771
1772fn combinator_of(c: char) -> Option<Combinator> {
1773    match c {
1774        '>' => Some(Combinator::Child),
1775        '+' => Some(Combinator::NextSibling),
1776        '~' => Some(Combinator::SubsequentSibling),
1777        _ => None,
1778    }
1779}
1780
1781fn parse_compound(token: &str, spec: &mut (u32, u32, u32)) -> Option<Compound> {
1782    let mut c = Compound::default();
1783    let chars: Vec<char> = token.chars().collect();
1784    let mut i = 0;
1785
1786    // Optional leading type/universal selector.
1787    let mut tag = String::new();
1788    while i < chars.len() && (chars[i].is_alphanumeric() || chars[i] == '-' || chars[i] == '*') {
1789        tag.push(chars[i]);
1790        i += 1;
1791    }
1792    if !tag.is_empty() && tag != "*" {
1793        c.tag = Some(tag);
1794        spec.2 += 1;
1795    }
1796
1797    while i < chars.len() {
1798        match chars[i] {
1799            '.' => {
1800                i += 1;
1801                let mut cls = String::new();
1802                while i < chars.len() && (chars[i].is_alphanumeric() || chars[i] == '-' || chars[i] == '_') {
1803                    cls.push(chars[i]);
1804                    i += 1;
1805                }
1806                if !cls.is_empty() {
1807                    c.classes.push(cls);
1808                    spec.1 += 1;
1809                }
1810            }
1811            '#' => {
1812                i += 1;
1813                let mut id = String::new();
1814                while i < chars.len() && (chars[i].is_alphanumeric() || chars[i] == '-' || chars[i] == '_') {
1815                    id.push(chars[i]);
1816                    i += 1;
1817                }
1818                if !id.is_empty() {
1819                    c.id = Some(id);
1820                    spec.0 += 1;
1821                }
1822            }
1823            '[' => {
1824                // Only `[role="…"]` / `[role=…]` is understood in M2.
1825                let end = token.find(']')?;
1826                let inner = &token[i + 1..end];
1827                if let Some(rest) = inner.strip_prefix("role") {
1828                    let val = rest
1829                        .trim_start_matches('=')
1830                        .trim_matches(|ch| ch == '"' || ch == '\'');
1831                    c.role = Some(val.to_string());
1832                    spec.1 += 1;
1833                }
1834                i = end + 1;
1835            }
1836            ':' => {
1837                // `:hover`: and `::selection`, whose second colon just falls into
1838                // the name and makes it an Unknown (never-matching) pseudo, which
1839                // is the right answer for a pseudo-*element* we don't support.
1840                i += 1;
1841                let mut name = String::new();
1842                // A second colon means a pseudo-*element* (`::selection`). Keep it
1843                // in the name so it stays Unknown rather than colliding with the
1844                // same-named pseudo-class.
1845                if i < chars.len() && chars[i] == ':' {
1846                    name.push(':');
1847                    i += 1;
1848                }
1849                while i < chars.len() && (chars[i].is_alphanumeric() || chars[i] == '-') {
1850                    name.push(chars[i]);
1851                    i += 1;
1852                }
1853                // A functional pseudo (`:not(…)`) keeps its argument in the name so
1854                // it stays Unknown rather than matching as a bare `:not`.
1855                if i < chars.len() && chars[i] == '(' {
1856                    let mut depth = 0i32;
1857                    while i < chars.len() {
1858                        if chars[i] == '(' {
1859                            depth += 1;
1860                        } else if chars[i] == ')' {
1861                            depth -= 1;
1862                        }
1863                        name.push(chars[i]);
1864                        i += 1;
1865                        if depth == 0 {
1866                            break;
1867                        }
1868                    }
1869                }
1870                if !name.is_empty() {
1871                    let pseudo = Pseudo::parse(&name);
1872                    if let Pseudo::Unknown(n) = &pseudo {
1873                        warn_unknown_pseudo(n);
1874                    }
1875                    c.pseudos.push(pseudo);
1876                    // A pseudo-class has class-level specificity.
1877                    spec.1 += 1;
1878                }
1879            }
1880            _ => break,
1881        }
1882    }
1883    Some(c)
1884}
1885
1886/// Warn once per unknown pseudo-class. Same reasoning as `warn_if_unhonored`:
1887/// valid CSS that quietly does nothing is the worst failure mode we have, and
1888/// since an unknown pseudo now *fails closed*, the rule disappears entirely.
1889fn warn_unknown_pseudo(name: &str) {
1890    use std::sync::{Mutex, OnceLock};
1891    static SEEN: OnceLock<Mutex<HashSet<String>>> = OnceLock::new();
1892    let message = format!(
1893        "pseudo-class `:{name}` is not supported, rules using it will never match \
1894         (supported: :hover, :focus, :active, :checked)"
1895    );
1896    warn(message.clone());
1897    let seen = SEEN.get_or_init(|| Mutex::new(HashSet::new()));
1898    let Ok(mut seen) = seen.lock() else { return };
1899    if seen.insert(name.to_string()) {
1900        echo(&message);
1901    }
1902}
1903
1904// ── Matching & cascade ──────────────────────────────────────────────────────
1905
1906fn matches_compound(c: &Compound, el: &ElemDesc) -> bool {
1907    if let Some(t) = &c.tag {
1908        if *t != el.tag {
1909            return false;
1910        }
1911    }
1912    if let Some(id) = &c.id {
1913        if Some(id.as_str()) != el.id.as_deref() {
1914            return false;
1915        }
1916    }
1917    for cls in &c.classes {
1918        if !el.classes.iter().any(|x| x == cls) {
1919            return false;
1920        }
1921    }
1922    if let Some(r) = &c.role {
1923        // Roles match case-insensitively (role="Heading" ~ [role="heading"]).
1924        if !el.role.as_deref().is_some_and(|er| er.eq_ignore_ascii_case(r)) {
1925            return false;
1926        }
1927    }
1928    // Every pseudo-class in the compound must hold (`.btn:hover:active`).
1929    if !c.pseudos.iter().all(|p| p.holds(&el.states)) {
1930        return false;
1931    }
1932    true
1933}
1934
1935/// Does the selector `chain` (joined by `combs`) match the element `el`, whose
1936/// ancestors are `ancestors` (root-first) and whose preceding rendered siblings
1937/// are `prev` (document order)?
1938///
1939/// Matches right-to-left with backtracking: the rightmost compound must match
1940/// `el`, then the combinator to its left dictates where the remaining prefix is
1941/// sought, up the ancestor chain (descendant/child) or across the preceding
1942/// siblings (`+`/`~`). Siblings share `el`'s ancestors; an ancestor's own
1943/// preceding siblings ride along in [`AncNode::prev`], so a sibling combinator
1944/// above a descendant hop still resolves.
1945fn matches_chain(
1946    chain: &[Compound],
1947    combs: &[Combinator],
1948    el: &ElemDesc,
1949    ancestors: &[AncNode],
1950    prev: &[ElemDesc],
1951) -> bool {
1952    let Some((last, rest)) = chain.split_last() else {
1953        return false;
1954    };
1955    if !matches_compound(last, el) {
1956        return false;
1957    }
1958    if rest.is_empty() {
1959        return true;
1960    }
1961    // `combs` has one fewer entry than `chain`; the last one links `last` to the
1962    // compound now at the end of `rest`.
1963    let (comb, rest_combs) = combs.split_last().expect("combs matches chain length");
1964    match comb {
1965        Combinator::Descendant => (0..ancestors.len()).rev().any(|i| {
1966            matches_chain(rest, rest_combs, &ancestors[i].desc, &ancestors[..i], &ancestors[i].prev)
1967        }),
1968        Combinator::Child => {
1969            let Some((parent, up)) = ancestors.split_last() else {
1970                return false;
1971            };
1972            matches_chain(rest, rest_combs, &parent.desc, up, &parent.prev)
1973        }
1974        Combinator::NextSibling => {
1975            let Some((sib, earlier)) = prev.split_last() else {
1976                return false;
1977            };
1978            matches_chain(rest, rest_combs, sib, ancestors, earlier)
1979        }
1980        Combinator::SubsequentSibling => (0..prev.len())
1981            .rev()
1982            .any(|i| matches_chain(rest, rest_combs, &prev[i], ancestors, &prev[..i])),
1983    }
1984}
1985
1986/// Could any `:hover` / `:active` rule apply to this element? If so the layout
1987/// emits a [`rux_layout::StateRegion`] for it, which is how the shell learns the
1988/// pointer entered or left it.
1989///
1990/// Only the *pseudo-carrying compound* is tested, and only against this element,
1991/// the rest of the chain is ignored, so this over-approximates (a `.card:hover`
1992/// rule flags every `.card`, even one no full selector reaches). Over-flagging
1993/// costs one region; under-flagging would mean a `:hover` rule that silently never
1994/// fires, so the bias is deliberate.
1995///
1996/// Note `.card:hover .icon` flags the **card**, not the icon: the card is what the
1997/// pointer is over, and its subtree, icon included, is re-cascaded when its hover
1998/// state flips.
1999fn pointer_state_sensitive(desc: &ElemDesc, rules: &[Rule]) -> bool {
2000    // Probe with both pointer states on: we're asking "could this ever match",
2001    // not "does it match now".
2002    let probe = ElemDesc {
2003        states: ElemStates { hover: true, active: true, ..desc.states },
2004        ..desc.clone()
2005    };
2006    rules.iter().any(|rule| {
2007        rule.chain.iter().any(|compound| {
2008            compound.pseudos.iter().any(Pseudo::is_pointer_state)
2009                && matches_compound(compound, &probe)
2010        })
2011    })
2012}
2013
2014/// Collect the matching rules' declarations for an element, in cascade order.
2015fn matched_props(
2016    desc: &ElemDesc,
2017    ancestors: &[AncNode],
2018    prev: &[ElemDesc],
2019    rules: &[Rule],
2020) -> HashMap<String, String> {
2021    let mut matched: Vec<&Rule> = rules
2022        .iter()
2023        .filter(|r| matches_chain(&r.chain, &r.combs, desc, ancestors, prev))
2024        .collect();
2025    matched.sort_by(|a, b| a.specificity.cmp(&b.specificity).then(a.order.cmp(&b.order)));
2026
2027    let mut props: HashMap<String, String> = HashMap::new();
2028    for rule in matched {
2029        for (k, v) in &rule.decls {
2030            props.insert(k.clone(), v.clone());
2031        }
2032    }
2033    props
2034}
2035
2036/// Build one element into a layout node. Structural directives on the element
2037/// itself (`r-for`, `r-if`, `r-elif`, `r-else`) are handled by the parent in
2038/// [`build_children`]; this function handles per-node concerns (`r-show`) and
2039/// recurses into children.
2040///
2041/// `inherited` carries the resolved text properties (`color`/`font-size`/
2042/// `font-family`, which inherit); `locals` carries `r-for` loop bindings.
2043#[allow(clippy::too_many_arguments)]
2044fn build_node(
2045    el: &Element,
2046    rules: &[Rule],
2047    comps: &Components,
2048    ancestors: &mut Vec<AncNode>,
2049    prev: &[ElemDesc],
2050    inherited: &Inherited,
2051    engine: &mut Engine,
2052    locals: &Locals,
2053    path: &[usize],
2054    tpl_path: &[usize],
2055    reg: &mut BindingRegistry,
2056    state: &InteractionState,
2057    instances: &mut Instances,
2058    // The component instance this element is inside, None at document level.
2059    instance: Option<&str>,
2060    slot: Option<Slot>,
2061    // The `r-key` of the `r-for` row this element is inside, `None` outside one.
2062    // Half the identity of anything in a list: `r-model` is recorded as written,
2063    // so every row of a list otherwise looks like the same input.
2064    row: Option<&str>,
2065) -> LayoutNode {
2066    // A custom-element tag expands its imported component in place.
2067    if let Some(component) = comps.get(&el.tag) {
2068        return expand_component(
2069            el, component, comps, inherited, engine, locals, path, tpl_path, reg, state, rules,
2070            instances, instance, row, &Locals::new(), None,
2071        );
2072    }
2073
2074    let mut desc = ElemDesc::of(el);
2075    // A ticked checkbox / selected radio is matched by `:checked`. It *also* still
2076    // carries the synthetic `checked` class, the pre-pseudo-class hack, so
2077    // stylesheets written against `.box.checked` keep working for one release.
2078    // Deprecated: prefer `.box:checked`.
2079    let toggle = Toggle::of(el, engine, locals);
2080    if toggle.as_ref().is_some_and(|t| t.checked) {
2081        desc.states.checked = true;
2082        desc.classes.push("checked".to_string());
2083    }
2084    // Pointer state comes from the shell, keyed by this node's tree path. Focus is
2085    // keyed by `r-model` instead, that is how the shell tracks it, and it is what
2086    // survives a reconcile that moves nodes around.
2087    desc.states.hover = state.hovers(path);
2088    desc.states.active = state.activates(path);
2089    // Both halves, or every row of a list matches at once: they all carry the
2090    // same `r-model` text, so the model alone cannot pick one out.
2091    desc.states.focus = match (&state.focused_model, el.attr("r-model")) {
2092        (Some(focused), Some(model)) => focused == model && state.focused_row.as_deref() == row,
2093        _ => false,
2094    };
2095    // `:class`: dynamic classes fed into the cascade (the `checked` pattern,
2096    // generalized). Signals it reads are collected for reconcile.
2097    let mut dyn_deps: HashSet<String> = HashSet::new();
2098    // Where this element links to, if anywhere. `:to` is the computed form, which
2099    // is what a list of rows needs: every row links somewhere different, and the
2100    // path is built from the row's own data.
2101    let to = el.attr("to").map(str::to_string).or_else(|| {
2102        let expr = el.attr(":to")?;
2103        let (value, deps) = engine.eval_value_tracked(expr, locals);
2104        dyn_deps.extend(deps);
2105        value.map(|v| v.to_display())
2106    });
2107    // `:current` is the link pointing at the path we are already on, so a nav bar
2108    // can show where you are. Reading the route here also subscribes this node to
2109    // it, which is what lets navigation restyle the link in place instead of
2110    // rebuilding: the same trick `:class` uses.
2111    if let Some(to) = &to {
2112        let (value, deps) = engine.eval_value_tracked(rux_script::ROUTE_SIGNAL, locals);
2113        dyn_deps.extend(deps);
2114        desc.states.current =
2115            value.is_some_and(|v| match_route(to, &v.to_display()).is_some());
2116    }
2117    if let Some(expr) = el.attr(":class") {
2118        let (value, deps) = engine.eval_value_tracked(expr, locals);
2119        dyn_deps.extend(deps);
2120        if let Some(v) = value {
2121            desc.classes.extend(class_list(&v));
2122        }
2123    }
2124
2125    // Set on every node this build produces, so the shell gets a region to hit-test
2126    // (see `pointer_state_sensitive`). Computed after `:class`, so dynamically
2127    // applied classes count too.
2128    let state_path = pointer_state_sensitive(&desc, rules).then(|| path.to_vec());
2129
2130    let mut props = matched_props(&desc, ancestors, prev, rules);
2131    // Inline styles override the cascade: static `style=` first, then dynamic
2132    // `:style` (which may interpolate, rhai backtick strings evaluate here).
2133    if let Some(s) = el.attr("style") {
2134        merge_inline_style(&mut props, s);
2135    }
2136    if let Some(expr) = el.attr(":style") {
2137        let (value, deps) = engine.eval_value_tracked(expr, locals);
2138        dyn_deps.extend(deps);
2139        match value {
2140            // Object form: `#{ background: c }` → each entry a declaration.
2141            Some(Value::Map(entries)) => {
2142                for (k, v) in entries {
2143                    props.insert(k.to_ascii_lowercase(), v.to_display());
2144                }
2145            }
2146            // String form: `"background: red"` (possibly interpolated).
2147            Some(v) => merge_inline_style(&mut props, &v.to_display()),
2148            None => {}
2149        }
2150    }
2151    // A `:class`/`:style` that reads a signal reconciles this node on change.
2152    if !dyn_deps.is_empty() {
2153        reg.styled.push(StyledBinding { path: path.to_vec(), deps: dyn_deps });
2154    }
2155
2156    // Custom properties: this element's own `--name` declarations (from the
2157    // cascade and from inline styles alike) layer over what it inherited, and the
2158    // result is what `var()` sees here *and* below. A node that declares none,
2159    // most of them, passes the inherited map straight down without copying it.
2160    let vars = take_vars(&mut props, &inherited.vars);
2161    // Substitute var() everywhere before interpreting, so every property gets it
2162    // for free rather than each parser learning about variables. Runs even with no
2163    // variables in scope: `var(--x, 12px)` is a legitimate way to write a default,
2164    // and skipping the pass would leave the fallback unresolved.
2165    for value in props.values_mut() {
2166        if value.contains("var(") {
2167            *value = resolve_vars(value, &vars, 0);
2168        }
2169    }
2170
2171    let style = interpret(&props);
2172    // A `@tap` handler runs later, in global scope, where the `r-for` loop
2173    // variable no longer exists, so `@tap="picked = item"` would see `item`
2174    // undefined and silently do nothing. Bake the current loop bindings into the
2175    // handler as a `let` prelude so it reproduces them when it runs.
2176    // `to="/path"` is a navigation intent, which is a tap like any other once it
2177    // is written as a handler: it then travels the one route a tap already
2178    // takes, through the hit region and `apply_handler`. An explicit `@tap`
2179    // wins, so a link can still do something else on the way.
2180    let on_tap = el.attr("@tap").map(|h| bind_locals(h, locals)).or_else(|| {
2181        to.as_ref().map(|p| format!("navigate({})", Value::Text(p.clone()).to_rhai_literal()))
2182    });
2183    // r-show="false" keeps the layout slot but paints nothing. It only flips
2184    // `hidden`, never the shape, so it's patchable: record it and a change rewrites
2185    // the bool in place.
2186    let hidden = el.attr("r-show").is_some_and(|e| {
2187        let (v, deps) = engine.eval_bool_tracked(e, locals);
2188        reg.show.push(ShowBinding {
2189            path: path.to_vec(),
2190            cond: e.to_string(),
2191            locals: locals.clone(),
2192            deps,
2193        });
2194        !v
2195    });
2196
2197    // Resolve inheritable text properties (own value, else inherited).
2198    let color = props
2199        .get("color")
2200        .and_then(|v| parse_color(v))
2201        .unwrap_or(inherited.color);
2202    let font_size = props
2203        .get("font-size")
2204        .and_then(|v| parse_px(first(v)))
2205        .unwrap_or(inherited.font_size);
2206    // `font-family` is stored as the raw CSS list; parley parses it and does the
2207    // fallback. An empty/`inherit` value falls back to the inherited family.
2208    let font_family = props
2209        .get("font-family")
2210        .filter(|v| !v.trim().is_empty() && v.trim() != "inherit")
2211        .map(|v| v.trim().to_string())
2212        .or_else(|| inherited.font_family.clone());
2213    // Non-inheriting shaping props, resolved from this node's own rules (as
2214    // `font-weight`/`text-align` already are).
2215    let letter_spacing = props.get("letter-spacing").and_then(|v| parse_spacing(v));
2216    let word_spacing = props.get("word-spacing").and_then(|v| parse_spacing(v));
2217    // `line-height`: a unitless number multiplies the font size; a length is
2218    // absolute; `normal` keeps the font metrics.
2219    let line_height = props.get("line-height").and_then(|v| parse_line_height(v, font_size));
2220    let italic = props
2221        .get("font-style")
2222        .is_some_and(|v| matches!(v.trim(), "italic" | "oblique"));
2223    // `text-decoration[-line]`: underline / line-through (space-separated list).
2224    let decoration = props.get("text-decoration-line").or_else(|| props.get("text-decoration"));
2225    let underline = decoration.is_some_and(|v| v.split_whitespace().any(|t| t == "underline"));
2226    let strikethrough = decoration.is_some_and(|v| v.split_whitespace().any(|t| t == "line-through"));
2227    // `white-space: nowrap|pre` stops line breaking. (We don't preserve `pre`
2228    // whitespace runs yet; the no-wrap half is what matters for layout.)
2229    let nowrap = props
2230        .get("white-space")
2231        .is_some_and(|v| matches!(v.trim(), "nowrap" | "pre"));
2232
2233    if el.tag == "text" {
2234        let weight = props.get("font-weight").and_then(|v| parse_weight(v)).unwrap_or(400);
2235        let align = props
2236            .get("text-align")
2237            .map(|v| parse_text_align(v))
2238            .unwrap_or_default();
2239        let wrap = style.text_wrap;
2240        // Recompute this text on change instead of rebuilding: record the raw
2241        // template, the locals, and the signals it reads, keyed by this node's
2242        // path. Only text that actually interpolates is registered.
2243        let template = text_template(el);
2244        let (text, deps) = interpolate_tracked(&template, engine, locals);
2245        if template.contains("{{") {
2246            reg.text.push(TextBinding {
2247                path: path.to_vec(),
2248                template,
2249                locals: locals.clone(),
2250                deps,
2251            });
2252        }
2253        let mut node = LayoutNode::text(
2254            style,
2255            TextContent {
2256                text,
2257                font_size,
2258                weight,
2259                color,
2260                align,
2261                wrap,
2262                font_family: font_family.clone(),
2263                letter_spacing,
2264                word_spacing,
2265                line_height,
2266                italic,
2267                underline,
2268                strikethrough,
2269                nowrap,
2270                caret: None,
2271                selection: None,
2272                preedit: None,
2273            },
2274        );
2275        node.on_tap = on_tap;
2276        node.hidden = hidden;
2277        node.id = el.attr("id").map(str::to_string);
2278        node.label_for = el.attr("for").map(str::to_string);
2279        node.state_path = state_path.clone();
2280        // Static text reads as a label; `role="heading"` promotes it. Text that is
2281        // itself tappable is a button whose name is its own words, unless it is a
2282        // link, which a screen reader announces differently and should.
2283        node.access = Access {
2284            role: explicit_access_role(el).unwrap_or(if to.is_some() {
2285                AccessRole::Link
2286            } else if node.on_tap.is_some() {
2287                AccessRole::Button
2288            } else {
2289                AccessRole::Label
2290            }),
2291            label: authored_label(el).or_else(|| {
2292                node.text.as_ref().map(|t| t.text.trim().to_string()).filter(|t| !t.is_empty())
2293            }),
2294            ..Access::default()
2295        };
2296        return node;
2297    }
2298
2299    // <image src=…>: a leaf that paints its pixels. The `src` here is still the
2300    // author's string; the runtime resolves it against the .rux file's directory
2301    // and fills in the intrinsic size.
2302    if el.tag == "image" {
2303        let src = el
2304            .attr(":src")
2305            .map(|e| {
2306                // `:src` rewrites the image source in place on change, no shape
2307                // change, so it's patchable rather than a rebuild.
2308                let (v, deps) = engine.eval_display_tracked(e, locals);
2309                reg.src.push(AttrBinding {
2310                    path: path.to_vec(),
2311                    expr: e.to_string(),
2312                    locals: locals.clone(),
2313                    deps,
2314                });
2315                v
2316            })
2317            .or_else(|| el.attr("src").map(str::to_string))
2318            .unwrap_or_default();
2319        let mut node = LayoutNode::image(
2320            style,
2321            ImageContent {
2322                src,
2323                intrinsic: (0.0, 0.0),
2324            },
2325        );
2326        node.on_tap = on_tap;
2327        node.hidden = hidden;
2328        node.id = el.attr("id").map(str::to_string);
2329        node.label_for = el.attr("for").map(str::to_string);
2330        node.state_path = state_path.clone();
2331        // An image with no `alt` has no accessible name, deliberately left None
2332        // rather than announcing a file path, which is noise, not information.
2333        node.access = Access {
2334            role: explicit_access_role(el).unwrap_or(AccessRole::Image),
2335            label: authored_label(el),
2336            ..Access::default()
2337        };
2338        return node;
2339    }
2340
2341    // <input>: a box bound to a signal via r-model.
2342    //
2343    // `type=checkbox|radio` are tap-toggles, not text fields: they get no focus
2344    // and no keyboard, they just write the bound signal through the ordinary
2345    // handler path (`sig = !sig` / `sig = "value"`). An authored @tap wins.
2346    if let Some(Toggle { radio, checked, deps }) = toggle {
2347        // Recorded so a change to the bound signal reconciles just this node.
2348        reg.toggles.push(ToggleBinding { path: path.to_vec(), deps });
2349        let model = el.attr("r-model").unwrap_or_default().to_string();
2350        let value = el.attr("value").unwrap_or_default().to_string();
2351
2352        let mut style = style;
2353        // Centre the mark inside the box unless the author says otherwise.
2354        if style.display == Display::Block {
2355            style.display = Display::Flex;
2356        }
2357        style.justify.get_or_insert(Justify::Center);
2358        style.align.get_or_insert(Align::Center);
2359        // A radio is round unless it was given its own radius.
2360        if radio && style.radius == [0.0; 4] {
2361            style.radius = [CIRCLE; 4];
2362        }
2363
2364        let mut node = LayoutNode::new(style);
2365        if checked {
2366            node.children.push(if radio {
2367                // A dot, in the box's text colour.
2368                LayoutNode::new(Style {
2369                    display: Display::Flex,
2370                    width: Some(Len::Pct(0.5)),
2371                    height: Some(Len::Pct(0.5)),
2372                    background: Some(Background::Color(color)),
2373                    radius: [CIRCLE; 4],
2374                    ..Default::default()
2375                })
2376            } else {
2377                // A stroked checkmark, in the box's text colour. Style the checked
2378                // box itself with `.yourclass.checked { … }`.
2379                let mut mark = LayoutNode::new(Style {
2380                    display: Display::Flex,
2381                    width: Some(Len::Pct(0.68)),
2382                    height: Some(Len::Pct(0.68)),
2383                    ..Default::default()
2384                });
2385                mark.tick = Some(color);
2386                mark
2387                        });
2388        }
2389        node.on_tap = on_tap.or_else(|| {
2390            if model.is_empty() {
2391                None
2392            } else if radio {
2393                Some(format!("{model} = \"{value}\""))
2394            } else {
2395                Some(format!("{model} = !{model}"))
2396            }
2397        });
2398        node.hidden = hidden;
2399        node.id = el.attr("id").map(str::to_string);
2400        node.label_for = el.attr("for").map(str::to_string);
2401        node.state_path = state_path.clone();
2402        // The checked state is what a screen reader announces alongside the name,
2403        // so it has to be the resolved boolean, not the class hack.
2404        node.access = Access {
2405            role: if radio { AccessRole::RadioButton } else { AccessRole::CheckBox },
2406            label: authored_label(el),
2407            placeholder: None,
2408            checked: Some(checked),
2409            value: None,
2410        };
2411        return node;
2412    }
2413
2414    // A text input: shows the bound value (or a dim placeholder when empty). The
2415    // shell focuses it on tap and edits the bound signal on keystrokes.
2416    // `type="textarea"` is the same, but `Enter` inserts a newline.
2417    if el.tag == "input" {
2418        let mut style = style;
2419        let multiline = el.attr("type") == Some("textarea");
2420        // Inputs are form controls: they fill their slot rather than hug their
2421        // text (else the box would shrink as you type). A single line clips; a
2422        // textarea scrolls, so text past the bottom stays reachable.
2423        if style.width.is_none() {
2424            style.width = Some(Len::Pct(1.0));
2425        }
2426        if style.overflow == Overflow::Visible {
2427            style.overflow = if multiline { Overflow::Scroll } else { Overflow::Clip };
2428        }
2429        // `type="select"`: evaluate the bound `:options` collection to strings so
2430        // the shell can render a dropdown.
2431        let options = (el.attr("type") == Some("select"))
2432            .then(|| {
2433                el.attr(":options")
2434                    .and_then(|e| {
2435                        // `:options` rewrites the option list in place on change.
2436                        let (v, deps) = engine.eval_value_tracked(e, locals);
2437                        reg.options.push(AttrBinding {
2438                            path: path.to_vec(),
2439                            expr: e.to_string(),
2440                            locals: locals.clone(),
2441                            deps,
2442                        });
2443                        v
2444                    })
2445                    .and_then(|v| v.as_list().map(|items| items.iter().map(Value::to_display).collect()))
2446                    .unwrap_or_default()
2447            });
2448        let model = el.attr("r-model").map(str::to_string);
2449        let placeholder = el.attr("placeholder").unwrap_or_default().to_string();
2450        const PLACEHOLDER_COLOR: Rgba = Rgba::new(0.42, 0.44, 0.52, 1.0); // #6c7086
2451        // The value display is patchable: record where it lives and how to render
2452        // it, so a keystroke rewrites this input's text in place instead of
2453        // rebuilding. (If `model` is *also* read structurally, e.g. by an `r-if`,
2454        // that read marks it structural elsewhere, and the change rebuilds anyway.)
2455        let value = model
2456            .as_deref()
2457            .map(|m| {
2458                let (v, deps) = engine.eval_display_tracked(m, locals);
2459                reg.value.push(ValueBinding {
2460                    path: path.to_vec(),
2461                    model: m.to_string(),
2462                    row: row.map(str::to_string),
2463                    placeholder: placeholder.clone(),
2464                    color,
2465                    placeholder_color: PLACEHOLDER_COLOR,
2466                    locals: locals.clone(),
2467                    deps,
2468                });
2469                v
2470            })
2471            .unwrap_or_default();
2472        let (shown, shown_color) = if value.is_empty() {
2473            (placeholder.clone(), PLACEHOLDER_COLOR)
2474        } else {
2475            (value, color)
2476        };
2477        let text_child = LayoutNode::text(
2478            Style::default(),
2479            TextContent {
2480                text: shown,
2481                font_size,
2482                weight: 400,
2483                color: shown_color,
2484                align: TextAlign::Start,
2485                wrap: style.text_wrap,
2486                font_family: font_family.clone(),
2487                letter_spacing,
2488                word_spacing,
2489                line_height,
2490                italic,
2491                underline,
2492                strikethrough,
2493                // A single-line input never wraps; a textarea does.
2494                nowrap: !multiline,
2495                // The runtime marks the focused input's caret and selection.
2496                caret: None,
2497                selection: None,
2498                preedit: None,
2499            },
2500        );
2501        let mut node = LayoutNode::new(style);
2502        node.children.push(text_child);
2503        node.model = model;
2504        node.multiline = multiline;
2505        node.options = options;
2506        node.on_tap = on_tap;
2507        node.hidden = hidden;
2508        node.id = el.attr("id").map(str::to_string);
2509        node.label_for = el.attr("for").map(str::to_string);
2510        node.state_path = state_path.clone();
2511        // The *value* is the signal's text, never the placeholder, a placeholder
2512        // is a hint, and announcing it as the content would be a lie. It becomes
2513        // the fallback *name* instead, when nothing else labels the field.
2514        node.access = Access {
2515            role: explicit_access_role(el).unwrap_or(if node.options.is_some() {
2516                AccessRole::ComboBox
2517            } else if multiline {
2518                AccessRole::MultilineTextInput
2519            } else {
2520                AccessRole::TextInput
2521            }),
2522            label: authored_label(el),
2523            // Only a fallback name, a `<text for="…">` label linked after the
2524            // build must outrank it.
2525            placeholder: (!placeholder.is_empty()).then(|| placeholder.clone()),
2526            value: node
2527                .model
2528                .as_deref()
2529                .map(|m| engine.eval_display(m, locals))
2530                .filter(|v| !v.is_empty()),
2531            checked: None,
2532        };
2533        return node;
2534    }
2535
2536    ancestors.push(AncNode { desc, prev: prev.to_vec() });
2537    let element_children = element_children(el);
2538    let (children, structural_deps) = build_children(
2539        &element_children,
2540        rules,
2541        comps,
2542        ancestors,
2543        &Inherited { color, font_size, font_family, vars: Rc::clone(&vars) },
2544        engine,
2545        locals,
2546        path,
2547        tpl_path,
2548        reg,
2549        state,
2550        instances,
2551        instance,
2552        // A `<slot>` deeper inside a component still fills from the same call
2553        // site, so the slot travels down with everything else.
2554        slot,
2555        row,
2556    );
2557    ancestors.pop();
2558    // If any child carried a structural directive, this parent can be reconciled
2559    // in place (rebuild just its children) on a change to those signals.
2560    if !structural_deps.is_empty() {
2561        reg.structural_parents.push(StructuralParent {
2562            tree_path: path.to_vec(),
2563            tpl_path: tpl_path.to_vec(),
2564            deps: structural_deps,
2565        });
2566    }
2567
2568    let mut node = LayoutNode {
2569        style,
2570        text: None,
2571        image: None,
2572        tick: None,
2573        children,
2574        on_tap,
2575        model: None,
2576        multiline: false,
2577        options: None,
2578        hidden,
2579        id: el.attr("id").map(str::to_string),
2580        label_for: el.attr("for").map(str::to_string),
2581        focus_model: None,
2582        state_path,
2583        access: Access::default(),
2584        // Set when this node is inside a component, so a handler on it knows
2585        // whose state it is running against.
2586        instance: instance.map(str::to_string),
2587        // Filled in by the `r-for` expansion, which is the only place that knows
2588        // an element is a row and which item it stands for.
2589        key: None,
2590    };
2591    // A tappable box is a button, named by the text inside it, that is how
2592    // `<view @tap><text>Save</text></view>` announces as "Save, button". A
2593    // scroller is worth exposing so its content can be reached; anything else is
2594    // structure, and only appears if the author gave it a `role=`.
2595    let role = explicit_access_role(el).unwrap_or(if to.is_some() {
2596        AccessRole::Link
2597    } else if node.on_tap.is_some() {
2598        AccessRole::Button
2599    } else if node.style.overflow == Overflow::Scroll {
2600        AccessRole::ScrollView
2601    } else {
2602        AccessRole::None
2603    });
2604    if role.is_meaningful() {
2605        let label = authored_label(el).or_else(|| {
2606            let text = subtree_text(&node);
2607            (!text.is_empty()).then_some(text)
2608        });
2609        node.access = Access { role, label, ..Access::default() };
2610    }
2611    node
2612}
2613
2614/// Expand a `<custom-element :prop="expr" …>` into its component's tree. Props
2615/// (attributes prefixed `:`) are evaluated in the caller's scope and become the
2616/// only locals visible inside the component (component instances are isolated).
2617#[allow(clippy::too_many_arguments)]
2618fn expand_component(
2619    el: &Element,
2620    component: &Component,
2621    comps: &Components,
2622    inherited: &Inherited,
2623    engine: &mut Engine,
2624    parent_locals: &Locals,
2625    path: &[usize],
2626    tpl_path: &[usize],
2627    reg: &mut BindingRegistry,
2628    state: &InteractionState,
2629    // The caller's stylesheet, for whatever it wrote between the tags.
2630    caller_rules: &[Rule],
2631    instances: &mut Instances,
2632    // The instance the *caller* is in, which is where a listener body belongs.
2633    caller: Option<&str>,
2634    row: Option<&str>,
2635    // Props resolved by the caller rather than written as attributes: the route
2636    // parameters a `<router>` captured. Empty for an ordinary component tag.
2637    extra_props: &Locals,
2638    // The path this was expanded under, when a `<router>` chose it. Recorded so
2639    // leaving the route can drop the instance's state.
2640    route: Option<&str>,
2641) -> LayoutNode {
2642    let mut props: Locals = Vec::new();
2643    let mut prop_deps: HashSet<String> = HashSet::new();
2644    let mut listeners: Vec<(String, String)> = Vec::new();
2645    for (key, expr) in &el.attrs {
2646        // `@event="body"` is a listener, not a prop: the body is the caller's
2647        // code to run *later*, so it is carried as text and never evaluated
2648        // here. Evaluating it would run the caller's statements at build time,
2649        // once per build, which is the opposite of an event.
2650        if let Some(name) = key.strip_prefix('@') {
2651            listeners.push((name.to_string(), bind_locals(expr, parent_locals)));
2652            continue;
2653        }
2654        if let Some(name) = key.strip_prefix(':') {
2655            // Props are evaluated in the caller's scope and become the component's
2656            // only locals, a prop change re-expands this subtree (a reconcile).
2657            let (value, deps) = engine.eval_value_tracked(expr, parent_locals);
2658            prop_deps.extend(deps);
2659            if let Some(value) = value {
2660                props.push((name.to_string(), value));
2661            }
2662        }
2663    }
2664    // Route parameters are already values, so they are appended rather than
2665    // evaluated. Last, so on a name collision the captured segment wins: it is
2666    // what the path actually says, and a `:prop` of the same name is more likely
2667    // a leftover than an override.
2668    props.extend(extra_props.iter().cloned());
2669    // Reconcile this component instance in place when a prop's signals change.
2670    if !prop_deps.is_empty() {
2671        reg.components.push(ComponentBinding {
2672            path: path.to_vec(),
2673            deps: prop_deps,
2674        });
2675    }
2676
2677    // Whatever was written between the tags becomes this instance's slot
2678    // content. It stays in the caller's rules and the caller's scope: the
2679    // component decides *where* it goes, not what it means.
2680    let supplied = element_children(el);
2681    let slot = Slot { children: &supplied, locals: parent_locals, rules: caller_rules };
2682
2683    // This instance's own state. Created the first time it is expanded, by
2684    // running the component's script in a scope of its own, and kept across
2685    // rebuilds: the tree is thrown away constantly, and a component's state
2686    // must not be.
2687    let key = instance_key(tpl_path, row);
2688    let entry = instances.entry(key.clone()).or_insert_with(|| Instance {
2689        state: engine.init_scope(&component.script),
2690        ..Instance::default()
2691    });
2692    entry.touched = true;
2693    entry.props = props.clone();
2694    // Listeners and the calling instance are the caller's, so like props they
2695    // are re-derived on every build rather than kept.
2696    entry.listeners = listeners;
2697    entry.caller = caller.map(str::to_string);
2698    entry.route = route.map(str::to_string);
2699    // State first, so a prop of the same name wins: the caller's input is more
2700    // specific than the component's own default.
2701    let mut locals: Locals = entry.state.clone();
2702    locals.extend(props);
2703
2704    // The component expands in place at this element's path, so its root node
2705    // takes the same path; its bindings are recorded relative to it.
2706    let mut ancestors: Vec<AncNode> = Vec::new();
2707    build_node(
2708        &component.template,
2709        &component.rules,
2710        comps,
2711        &mut ancestors,
2712        &[],
2713        inherited,
2714        engine,
2715        &locals,
2716        path,
2717        tpl_path,
2718        reg,
2719        state,
2720        instances,
2721        Some(key.as_str()),
2722        Some(slot),
2723        // A component expanded inside a row is still inside that row.
2724        row,
2725    )
2726}
2727
2728/// Match a `<route path="…">` pattern against the current path, capturing the
2729/// `:name` segments as parameters.
2730///
2731/// `None` means the pattern does not apply. `Some(params)` means it does, with
2732/// whatever it captured (often nothing). Segment counts have to agree, because a
2733/// pattern is a whole path and not a prefix: matching on a prefix would make
2734/// `/` match every path there is, and the first route would always win.
2735///
2736/// A trailing slash is not a difference, so `/settings` and `/settings/` are one
2737/// path. Anyone typing a URL by hand will produce both.
2738fn match_route(pattern: &str, path: &str) -> Option<Locals> {
2739    fn segments(s: &str) -> Vec<&str> {
2740        s.split('/').filter(|p| !p.is_empty()).collect()
2741    }
2742    let pat = segments(pattern);
2743    let cur = segments(path);
2744    if pat.len() != cur.len() {
2745        return None;
2746    }
2747    let mut params: Locals = Vec::new();
2748    for (p, c) in pat.iter().zip(cur.iter()) {
2749        match p.strip_prefix(':') {
2750            // A parameter takes whatever sits in that position, under its name,
2751            // decoded: a value that had to be escaped to go into the URL has to
2752            // come back out as what it was, or a view is handed `a%2Fb`.
2753            Some(name) => params.push((
2754                name.to_string(),
2755                Value::Text(rux_script::percent_decode(c)),
2756            )),
2757            // A literal segment has to be exactly itself.
2758            None if p == c => {}
2759            None => return None,
2760        }
2761    }
2762    Some(params)
2763}
2764
2765/// What the routes in `template` capture from `path`, without building anything.
2766///
2767/// The matched view already receives its parameters as props, and that is
2768/// enough for the view itself. It is not enough for anything *around* the
2769/// router: a title bar or a breadcrumb sits in the document's own layout, is
2770/// not the matched view, and so had no way to see the `id` in `/crew/grace`.
2771/// This is what backs the `params` signal, which fills that gap.
2772///
2773/// It has to run *before* the build rather than fall out of it, or `{{ params.id }}`
2774/// written outside the router would render one navigation behind.
2775///
2776/// The first `<router>` in the template wins. Nested routers are not built, and
2777/// when they are, the parameters of an inner one belong to it rather than to
2778/// the document.
2779pub fn route_params(template: &Element, path: &str) -> Vec<(String, Value)> {
2780    let Some(router) = find_router(template) else { return Vec::new() };
2781    element_children(router)
2782        .into_iter()
2783        .filter(|r| r.tag == "route")
2784        // A fallback route captures nothing, which is why this looks only at
2785        // patterns: there is nothing in `/nowhere` to name.
2786        .find_map(|r| match_route(r.attr("path")?, path))
2787        .unwrap_or_default()
2788}
2789
2790/// The document's `<router>`, wherever in the template it was written.
2791fn find_router(el: &Element) -> Option<&Element> {
2792    if el.tag == "router" {
2793        return Some(el);
2794    }
2795    element_children(el).into_iter().find_map(find_router)
2796}
2797
2798/// Every `<route name="…" path="…">` in the template, in the order written.
2799///
2800/// What `path_for("crew-detail", #{ id: "grace" })` builds a path from. A name
2801/// is worth having because a path is written in every link that leads to it,
2802/// and a URL scheme that can never be changed afterwards is not much of a
2803/// scheme.
2804pub fn named_routes(template: &Element) -> Vec<(String, String)> {
2805    let Some(router) = find_router(template) else { return Vec::new() };
2806    element_children(router)
2807        .into_iter()
2808        .filter(|r| r.tag == "route")
2809        .filter_map(|r| Some((r.attr("name")?.to_string(), r.attr("path")?.to_string())))
2810        .collect()
2811}
2812
2813/// Whether the router remembers where each page was scrolled to.
2814///
2815/// `<router restore-scroll="false">` turns it off; anything else, including no
2816/// attribute at all, leaves it on. On by default because remembering is what a
2817/// user expects from Back, and a document with no `<router>` is unaffected
2818/// either way.
2819pub fn restore_scroll(template: &Element) -> bool {
2820    find_router(template)
2821        .and_then(|r| r.attr("restore-scroll"))
2822        .is_none_or(|v| v.trim() != "false")
2823}
2824
2825/// Parse `r-for="item in items"` into `(binding, collection_expr)`.
2826fn parse_for(expr: &str) -> Option<(&str, &str)> {
2827    let (var, coll) = expr.split_once(" in ")?;
2828    Some((var.trim(), coll.trim()))
2829}
2830
2831/// Build a sequence of element children, applying the structural directives
2832/// `r-for` (repeat) and `r-if`/`r-elif`/`r-else` (conditional chains).
2833#[allow(clippy::too_many_arguments)]
2834fn build_children(
2835    elements: &[&Element],
2836    rules: &[Rule],
2837    comps: &Components,
2838    ancestors: &mut Vec<AncNode>,
2839    inherited: &Inherited,
2840    engine: &mut Engine,
2841    locals: &Locals,
2842    path: &[usize],
2843    tpl_path: &[usize],
2844    reg: &mut BindingRegistry,
2845    state: &InteractionState,
2846    instances: &mut Instances,
2847    instance: Option<&str>,
2848    slot: Option<Slot>,
2849    row: Option<&str>,
2850) -> (Vec<LayoutNode>, HashSet<String>) {
2851    let mut out = Vec::new();
2852    // Signals read by structural directives at this level, returned so the parent
2853    // can register itself as reconcilable.
2854    let mut structural_deps: HashSet<String> = HashSet::new();
2855    // The identities of the rendered siblings so far, so `+`/`~` combinators can
2856    // see the elements preceding the one being built. (The synthetic `checked`
2857    // class is not reflected here, sibling combinators don't see checked state.)
2858    let mut prev: Vec<ElemDesc> = Vec::new();
2859    // Tracks an active r-if/r-elif/r-else chain and whether a branch was taken.
2860    let mut in_chain = false;
2861    let mut chain_satisfied = false;
2862
2863    // The tree path to the child about to be pushed, its index is its position in
2864    // `out`. The template path uses the element's index `ti`, shared by r-for items.
2865    let child_path = |out: &Vec<LayoutNode>| -> Vec<usize> {
2866        path.iter().copied().chain(std::iter::once(out.len())).collect()
2867    };
2868    let child_tpl = |ti: usize| -> Vec<usize> {
2869        tpl_path.iter().copied().chain(std::iter::once(ti)).collect()
2870    };
2871
2872    for (ti, el) in elements.iter().enumerate() {
2873        let ctp = child_tpl(ti);
2874        if el.attr("r-key").is_some() && el.attr("r-for").is_none() {
2875            // A key with nothing to identify. Silently ignoring it would let
2876            // someone believe their list was keyed when it was not.
2877            warn(format!(
2878                "`r-key` on <{}> does nothing without `r-for` on the same element",
2879                el.tag
2880            ));
2881        }
2882        // `<slot />` is where the caller's children land. It is not an element
2883        // of its own: it renders them (or its own children as a fallback) and
2884        // leaves nothing behind, so a component adds no wrapper box the author
2885        // did not write.
2886        if el.tag == "slot" {
2887            in_chain = false;
2888            let ctp = child_tpl(ti);
2889            let filled = slot.filter(|s| !s.children.is_empty());
2890            match filled {
2891                Some(s) => {
2892                    for (si, child) in s.children.iter().enumerate() {
2893                        let cp = child_path(&out);
2894                        let mut ctp = ctp.clone();
2895                        ctp.push(si);
2896                        // The caller's rules and the caller's scope: this markup
2897                        // was written out there, and reads what is in scope out
2898                        // there. `slot: None` inside, since a component's own
2899                        // `<slot>` is not a place to put the outer one's content.
2900                        out.push(build_node(
2901                            child, s.rules, comps, ancestors, &prev, inherited, engine, s.locals,
2902                            &cp, &ctp, reg, state, instances, instance, None, row,
2903                        ));
2904                        prev.push(ElemDesc::of(child));
2905                    }
2906                }
2907                None => {
2908                    // Nothing supplied: the slot's own children are the default,
2909                    // and they belong to the component, so they build in its
2910                    // rules and its scope.
2911                    if slot.is_none() {
2912                        warn(
2913                            "`<slot>` outside a component renders its own children and nothing \
2914                             else; only a component has a caller to take content from"
2915                                .to_string(),
2916                        );
2917                    }
2918                    for (si, child) in element_children(el).iter().enumerate() {
2919                        let cp = child_path(&out);
2920                        let mut ctp = ctp.clone();
2921                        ctp.push(si);
2922                        out.push(build_node(
2923                            child, rules, comps, ancestors, &prev, inherited, engine, locals, &cp,
2924                            &ctp, reg, state, instances, instance, slot, row,
2925                        ));
2926                        prev.push(ElemDesc::of(child));
2927                    }
2928                }
2929            }
2930            continue;
2931        }
2932
2933        // `<router>` renders the one `<route>` whose path matches, and nothing
2934        // else. Like `<slot>` it leaves no box of its own behind, so a router
2935        // adds nothing to the layout: the matched view expands in its place.
2936        if el.tag == "router" {
2937            in_chain = false;
2938            // Reading the route here is what subscribes the enclosing parent to
2939            // it, so navigating reconciles this subtree the way a changed `r-for`
2940            // collection does, rather than forcing a whole rebuild.
2941            let (value, deps) = engine.eval_value_tracked(rux_script::ROUTE_SIGNAL, locals);
2942            structural_deps.extend(deps);
2943            let current = value.map(|v| v.to_display()).unwrap_or_default();
2944
2945            let routes = element_children(el);
2946            for r in &routes {
2947                if r.tag != "route" {
2948                    warn(format!(
2949                        "<{}> inside <router> is ignored; a router's children are <route> \
2950                         elements",
2951                        r.tag
2952                    ));
2953                }
2954            }
2955            // First match wins, so routes are tried in the order they are
2956            // written and a fallback can sit anywhere among them.
2957            let matched = routes.iter().enumerate().find_map(|(ri, r)| {
2958                if r.tag != "route" {
2959                    return None;
2960                }
2961                let params = match_route(r.attr("path")?, &current)?;
2962                Some((ri, *r, params))
2963            });
2964            let chosen = matched.or_else(|| {
2965                routes
2966                    .iter()
2967                    .enumerate()
2968                    .find(|(_, r)| r.tag == "route" && r.attr("fallback").is_some())
2969                    .map(|(ri, r)| (ri, *r, Locals::new()))
2970            });
2971
2972            match chosen {
2973                Some((ri, route_el, params)) => {
2974                    let Some(view) = route_el.attr("view") else {
2975                        warn(format!(
2976                            "<route path=\"{current}\"> has no `view`, so there is nothing to \
2977                             render for it"
2978                        ));
2979                        continue;
2980                    };
2981                    let Some(component) = comps.get(view) else {
2982                        warn(format!(
2983                            "<route> names the view `{view}`, which is not imported; add \
2984                             `use components::{view};` to the script"
2985                        ));
2986                        continue;
2987                    };
2988                    let cp = child_path(&out);
2989                    let mut rtp = ctp.clone();
2990                    rtp.push(ri);
2991                    // The `<route>` element stands in for the component tag, so
2992                    // any `:prop` written on it is passed through as well, and
2993                    // the captured parameters join them.
2994                    out.push(expand_component(
2995                        route_el, component, comps, inherited, engine, locals, &cp, &rtp, reg,
2996                        state, rules, instances, instance, row, &params, Some(&current),
2997                    ));
2998                    prev.push(ElemDesc::of(route_el));
2999                }
3000                None => {
3001                    // Every path should render something. A router with nothing
3002                    // to show is far more likely to be a missing route than an
3003                    // intended blank screen.
3004                    warn(format!(
3005                        "no <route> matches `{current}`, and there is no `fallback` route, so \
3006                         the router rendered nothing"
3007                    ));
3008                }
3009            }
3010            continue;
3011        }
3012
3013        // r-for expands the element once per collection item; it ends any chain.
3014        // The collection is a structural read, a change re-diffs the list.
3015        if let Some(for_expr) = el.attr("r-for") {
3016            in_chain = false;
3017            if let Some((var, coll)) = parse_for(for_expr) {
3018                // The collection is a reconcilable read, not a force-rebuild one:
3019                // it flows to the parent's structural deps (via the return), not to
3020                // `reg.structural`.
3021                let (value, deps) = engine.eval_value_tracked(coll, locals);
3022                structural_deps.extend(deps);
3023                let items = value.and_then(|v| v.as_list().map(<[Value]>::to_vec));
3024                if let Some(items) = items {
3025                    // `r-key` names what a row *is*, so the runtime can follow it
3026                    // when the list reorders instead of tracking slots.
3027                    let key_expr = el.attr("r-key");
3028                    let mut seen_keys: Vec<String> = Vec::new();
3029                    for item in items {
3030                        let mut child_locals = locals.clone();
3031                        child_locals.push((var.to_string(), item));
3032                        let cp = child_path(&out);
3033                        // The key is evaluated *before* the row is built, because
3034                        // everything inside the row is identified by it: an
3035                        // input's focus, its `:focus` styling and the binding
3036                        // that reads its value all need to know which row they
3037                        // are in while they are being recorded.
3038                        let key = key_expr.map(|expr| {
3039                            let key = engine.eval_display(expr, &child_locals);
3040                            if key.is_empty() {
3041                                warn(format!(
3042                                    "`r-key=\"{expr}\"` evaluated to nothing on one row, so that \
3043                                     row has no identity and will be treated as a new one"
3044                                ));
3045                            } else if seen_keys.contains(&key) {
3046                                // Two rows claiming one identity is worse than
3047                                // none: whatever follows a key follows the wrong
3048                                // row, silently.
3049                                warn(format!(
3050                                    "`r-key=\"{expr}\"` produced the duplicate key `{key}`; keys \
3051                                     must be unique within a list, or rows cannot be told apart"
3052                                ));
3053                            } else {
3054                                seen_keys.push(key.clone());
3055                            }
3056                            key
3057                        });
3058                        let mut node = build_node(
3059                            el, rules, comps, ancestors, &prev, inherited, engine, &child_locals,
3060                            &cp, &ctp, reg, state, instances, instance, slot,
3061                            // An unkeyed row inherits whatever row it is nested
3062                            // in, which is normally nothing.
3063                            key.as_deref().or(row),
3064                        );
3065                        node.key = key;
3066                        out.push(node);
3067                        prev.push(ElemDesc::of(el));
3068                    }
3069                }
3070            }
3071            continue;
3072        }
3073
3074        // r-if / r-elif conditions are structural reads too.
3075        if let Some(cond) = el.attr("r-if") {
3076            in_chain = true;
3077            let (v, deps) = engine.eval_bool_tracked(cond, locals);
3078            structural_deps.extend(deps);
3079            chain_satisfied = v;
3080            if chain_satisfied {
3081                let cp = child_path(&out);
3082                out.push(build_node(el, rules, comps, ancestors, &prev, inherited, engine, locals, &cp, &ctp, reg, state, instances, instance, slot, row));
3083                prev.push(ElemDesc::of(el));
3084            }
3085            continue;
3086        }
3087        if let Some(cond) = el.attr("r-elif") {
3088            let taken = if in_chain && !chain_satisfied {
3089                let (v, deps) = engine.eval_bool_tracked(cond, locals);
3090                structural_deps.extend(deps);
3091                v
3092            } else {
3093                false
3094            };
3095            if taken {
3096                chain_satisfied = true;
3097                let cp = child_path(&out);
3098                out.push(build_node(el, rules, comps, ancestors, &prev, inherited, engine, locals, &cp, &ctp, reg, state, instances, instance, slot, row));
3099                prev.push(ElemDesc::of(el));
3100            }
3101            continue;
3102        }
3103        if el.attr("r-else").is_some() {
3104            if in_chain && !chain_satisfied {
3105                let cp = child_path(&out);
3106                out.push(build_node(el, rules, comps, ancestors, &prev, inherited, engine, locals, &cp, &ctp, reg, state, instances, instance, slot, row));
3107                prev.push(ElemDesc::of(el));
3108            }
3109            in_chain = false;
3110            continue;
3111        }
3112
3113        // A plain element ends any active chain.
3114        in_chain = false;
3115        let cp = child_path(&out);
3116        out.push(build_node(el, rules, comps, ancestors, &prev, inherited, engine, locals, &cp, &ctp, reg, state, instances, instance, slot, row));
3117        prev.push(ElemDesc::of(el));
3118    }
3119    (out, structural_deps)
3120}
3121
3122// ── Value interpretation (honored subset) ───────────────────────────────────
3123
3124fn interpret(p: &HashMap<String, String>) -> Style {
3125    let mut st = Style::default();
3126    if let Some(v) = p.get("display") {
3127        st.display = match v.trim() {
3128            "flex" => Display::Flex,
3129            "grid" => Display::Grid,
3130            "inline" => Display::Inline,
3131            "none" => Display::None,
3132            _ => Display::Block,
3133        };
3134    }
3135    if let Some(v) = p.get("width") {
3136        st.width = parse_len(first(v));
3137    }
3138    if let Some(v) = p.get("height") {
3139        st.height = parse_len(first(v));
3140    }
3141    st.padding = box_sides(p, "padding");
3142    st.margin = box_sides(p, "margin");
3143    interpret_border(p, &mut st);
3144    if let Some(v) = p.get("gap") {
3145        if let Some(px) = parse_px(first(v)) {
3146            st.gap = px;
3147        }
3148    }
3149    if let Some(v) = p.get("min-width") {
3150        st.min_width = parse_len(first(v));
3151    }
3152    if let Some(v) = p.get("max-width") {
3153        st.max_width = parse_len(first(v));
3154    }
3155    if let Some(v) = p.get("min-height") {
3156        st.min_height = parse_len(first(v));
3157    }
3158    if let Some(v) = p.get("max-height") {
3159        st.max_height = parse_len(first(v));
3160    }
3161    if let Some(v) = p.get("grid-template-columns") {
3162        st.grid_columns = parse_tracks(v);
3163    }
3164    if let Some(v) = p.get("grid-template-rows") {
3165        st.grid_rows = parse_tracks(v);
3166    }
3167    // Grid item placement: `grid-column: 1 / 3`, `grid-row: span 2`, and the
3168    // -start/-end longhands.
3169    if let Some(v) = p.get("grid-column") {
3170        st.grid_column = parse_grid_shorthand(v);
3171    }
3172    if let Some(v) = p.get("grid-row") {
3173        st.grid_row = parse_grid_shorthand(v);
3174    }
3175    if let Some(v) = p.get("grid-column-start") {
3176        st.grid_column.0 = parse_grid_place(v);
3177    }
3178    if let Some(v) = p.get("grid-column-end") {
3179        st.grid_column.1 = parse_grid_place(v);
3180    }
3181    if let Some(v) = p.get("grid-row-start") {
3182        st.grid_row.0 = parse_grid_place(v);
3183    }
3184    if let Some(v) = p.get("grid-row-end") {
3185        st.grid_row.1 = parse_grid_place(v);
3186    }
3187    if let Some(v) = p.get("grid-auto-flow") {
3188        let v = v.trim();
3189        let dense = v.contains("dense");
3190        st.grid_auto_flow = if v.contains("column") {
3191            if dense { GridFlow::ColumnDense } else { GridFlow::Column }
3192        } else if dense {
3193            GridFlow::RowDense
3194        } else {
3195            GridFlow::Row
3196        };
3197    }
3198    if let Some(v) = p.get("grid-auto-rows") {
3199        st.grid_auto_rows = parse_tracks(v);
3200    }
3201    if let Some(v) = p.get("grid-auto-columns") {
3202        st.grid_auto_columns = parse_tracks(v);
3203    }
3204    // `flex: grow [shrink [basis]]` first, so the longhands can override it.
3205    if let Some(v) = p.get("flex") {
3206        interpret_flex_shorthand(v.trim(), &mut st);
3207    }
3208    if let Some(v) = p.get("flex-grow") {
3209        if let Ok(g) = first(v).parse::<f32>() {
3210            st.grow = g;
3211        }
3212    }
3213    if let Some(v) = p.get("flex-shrink") {
3214        if let Ok(s) = first(v).parse::<f32>() {
3215            st.shrink = s.max(0.0);
3216        }
3217    }
3218    if let Some(v) = p.get("flex-basis") {
3219        st.basis = match first(v) {
3220            "auto" | "content" => None,
3221            l => parse_len(l),
3222        };
3223    }
3224    if let Some(v) = p.get("flex-wrap") {
3225        st.wrap = matches!(v.trim(), "wrap" | "wrap-reverse");
3226    }
3227    if let Some(v) = p.get("overflow-wrap").or_else(|| p.get("word-wrap")) {
3228        st.text_wrap = match v.trim() {
3229            "break-word" | "anywhere" => TextWrap::BreakWord,
3230            _ => TextWrap::Normal,
3231        };
3232    }
3233    // word-break: break-all is stronger, it breaks anywhere, not just to avoid
3234    // an overflow.
3235    if let Some(v) = p.get("word-break") {
3236        if v.trim() == "break-all" {
3237            st.text_wrap = TextWrap::Anywhere;
3238        }
3239    }
3240    if let Some(v) = p.get("opacity") {
3241        if let Ok(o) = first(v).parse::<f32>() {
3242            st.opacity = o.clamp(0.0, 1.0);
3243        }
3244    }
3245    if let Some(v) = p.get("flex-direction") {
3246        st.axis = if v.trim() == "column" { Axis::Column } else { Axis::Row };
3247    }
3248    if let Some(v) = p.get("justify-content") {
3249        st.justify = parse_justify(v);
3250    }
3251    if let Some(v) = p.get("align-items") {
3252        st.align = parse_align(v);
3253    }
3254    // Cross-/inline-axis self and content alignment (flex + grid).
3255    if let Some(v) = p.get("align-self") {
3256        st.align_self = parse_align(v);
3257    }
3258    if let Some(v) = p.get("justify-self") {
3259        st.justify_self = parse_align(v);
3260    }
3261    if let Some(v) = p.get("justify-items") {
3262        st.justify_items = parse_align(v);
3263    }
3264    if let Some(v) = p.get("align-content") {
3265        st.align_content = parse_justify(v);
3266    }
3267    // `row-gap` / `column-gap` override the `gap` shorthand per axis.
3268    if let Some(px) = p.get("row-gap").and_then(|v| parse_px(first(v))) {
3269        st.row_gap = Some(px);
3270    }
3271    if let Some(px) = p.get("column-gap").and_then(|v| parse_px(first(v))) {
3272        st.column_gap = Some(px);
3273    }
3274    if let Some(v) = p.get("position") {
3275        st.position = match v.trim() {
3276            "absolute" | "fixed" => Position::Absolute,
3277            _ => Position::Relative,
3278        };
3279    }
3280    for (i, side) in ["top", "right", "bottom", "left"].iter().enumerate() {
3281        if let Some(v) = p.get(*side) {
3282            st.inset[i] = if first(v) == "auto" { None } else { parse_len(first(v)) };
3283        }
3284    }
3285    if let Some(v) = p.get("aspect-ratio") {
3286        st.aspect_ratio = parse_aspect_ratio(v);
3287    }
3288    // `background` (shorthand, may be a gradient) → `background-image` (gradient
3289    // or url, url not yet supported) → `background-color` (colour only).
3290    if let Some(v) = p
3291        .get("background")
3292        .or_else(|| p.get("background-image"))
3293        .or_else(|| p.get("background-color"))
3294    {
3295        st.background = parse_background(v);
3296    }
3297    if let Some(v) = p.get("transform") {
3298        st.transform = parse_transform(v);
3299    }
3300    if let Some(v) = p.get("box-shadow") {
3301        st.box_shadow = parse_box_shadow(v);
3302    }
3303    // `border-radius` shorthand (1–4 values, CSS diagonal grouping), then the
3304    // per-corner longhands override.
3305    if let Some(v) = p.get("border-radius") {
3306        st.radius = parse_border_radius(v);
3307    }
3308    for (i, corner) in [
3309        "border-top-left-radius",
3310        "border-top-right-radius",
3311        "border-bottom-right-radius",
3312        "border-bottom-left-radius",
3313    ]
3314    .iter()
3315    .enumerate()
3316    {
3317        if let Some(px) = p.get(*corner).and_then(|v| parse_px(first(v))) {
3318            st.radius[i] = px;
3319        }
3320    }
3321    // `auto`/`scroll` scroll (and clip); `hidden`/`clip` only clip. Any axis
3322    // saying so is enough, we have no per-axis overflow yet.
3323    let values = ["overflow", "overflow-x", "overflow-y"]
3324        .iter()
3325        .filter_map(|k| p.get(*k))
3326        .map(|v| v.trim());
3327    for v in values {
3328        match v {
3329            "auto" | "scroll" => st.overflow = Overflow::Scroll,
3330            "hidden" | "clip" if st.overflow != Overflow::Scroll => st.overflow = Overflow::Clip,
3331            _ => {}
3332        }
3333    }
3334    if let Some(v) = p.get("cursor") {
3335        // Only `pointer` maps to a distinct shape today; everything else keeps
3336        // the default arrow. The shell applies this on hover for tappable boxes.
3337        st.cursor = match v.trim() {
3338            "pointer" => Cursor::Pointer,
3339            _ => Cursor::Default,
3340        };
3341    }
3342    st
3343}
3344
3345/// `flex: <grow> [<shrink> [<basis>]]`, plus the CSS keywords. Note the
3346/// shorthand's defaults differ from the initial values: `flex: 1` means
3347/// `1 1 0%`, not `1 1 auto`.
3348fn interpret_flex_shorthand(v: &str, st: &mut Style) {
3349    match v {
3350        "none" => {
3351            st.grow = 0.0;
3352            st.shrink = 0.0;
3353            st.basis = None;
3354            return;
3355        }
3356        "auto" => {
3357            st.grow = 1.0;
3358            st.shrink = 1.0;
3359            st.basis = None;
3360            return;
3361        }
3362        "initial" => {
3363            st.grow = 0.0;
3364            st.shrink = 1.0;
3365            st.basis = None;
3366            return;
3367        }
3368        _ => {}
3369    }
3370
3371    let parts: Vec<&str> = v.split_whitespace().collect();
3372    let Some(grow) = parts.first().and_then(|g| g.parse::<f32>().ok()) else {
3373        return;
3374    };
3375    st.grow = grow;
3376    st.shrink = parts
3377        .get(1)
3378        .and_then(|s| s.parse::<f32>().ok())
3379        .unwrap_or(1.0)
3380        .max(0.0);
3381    st.basis = match parts.get(2) {
3382        Some(&"auto") | Some(&"content") => None,
3383        Some(b) => parse_len(b),
3384        // A bare `flex: 1` sizes purely from the free space.
3385        None => Some(Len::Px(0.0)),
3386    };
3387}
3388
3389/// `align-items` / `align-self` / `justify-self` / `justify-items` keyword.
3390fn parse_align(v: &str) -> Option<Align> {
3391    match v.trim() {
3392        "center" => Some(Align::Center),
3393        "flex-end" | "end" => Some(Align::End),
3394        "stretch" => Some(Align::Stretch),
3395        "flex-start" | "start" => Some(Align::Start),
3396        _ => None,
3397    }
3398}
3399
3400/// `justify-content` / `align-content` keyword.
3401fn parse_justify(v: &str) -> Option<Justify> {
3402    match v.trim() {
3403        "center" => Some(Justify::Center),
3404        "flex-end" | "end" => Some(Justify::End),
3405        "space-between" => Some(Justify::SpaceBetween),
3406        "space-around" => Some(Justify::SpaceAround),
3407        "flex-start" | "start" => Some(Justify::Start),
3408        _ => None,
3409    }
3410}
3411
3412/// Parse a `background` / `background-image` / `background-color` value into a
3413/// solid colour or a gradient. `url(…)` and other image sources aren't handled.
3414fn parse_background(value: &str) -> Option<Background> {
3415    let v = value.trim();
3416    if let Some(inner) = gradient_args(v, "linear-gradient") {
3417        return parse_linear_gradient(inner).map(Background::Gradient);
3418    }
3419    if let Some(inner) = gradient_args(v, "radial-gradient") {
3420        return parse_radial_gradient(inner).map(Background::Gradient);
3421    }
3422    if let Some(inner) = gradient_args(v, "url") {
3423        // Strip surrounding quotes from the url; the runtime resolves the path.
3424        let src = inner.trim().trim_matches(|c| c == '"' || c == '\'');
3425        if !src.is_empty() {
3426            return Some(Background::Image(src.to_string()));
3427        }
3428    }
3429    parse_color(v).map(Background::Color)
3430}
3431
3432/// The comma-separated argument text inside `<name>( … )`, if `v` is that call.
3433fn gradient_args<'a>(v: &'a str, name: &str) -> Option<&'a str> {
3434    v.strip_prefix(name)?.trim_start().strip_prefix('(')?.strip_suffix(')')
3435}
3436
3437/// `linear-gradient([<angle> | to <side>,]? <stop>, <stop> …)`. Defaults to
3438/// `to bottom` (180°). Stops without a position are spread evenly.
3439fn parse_linear_gradient(inner: &str) -> Option<Gradient> {
3440    let mut parts = split_top_level_commas(inner);
3441    if parts.is_empty() {
3442        return None;
3443    }
3444    // A leading angle / `to <side>` sets the direction; otherwise it's a stop.
3445    let angle = parse_gradient_angle(parts[0].trim());
3446    if angle.is_some() {
3447        parts.remove(0);
3448    }
3449    let stops = parse_stops(&parts)?;
3450    Some(Gradient {
3451        kind: GradientKind::Linear {
3452            angle: angle.unwrap_or(std::f32::consts::PI), // default: to bottom
3453        },
3454        stops,
3455    })
3456}
3457
3458/// `radial-gradient([shape/size/at …,]? <stop>, <stop> …)`. The prelude before
3459/// the first stop (shape, `at …`) is accepted and ignored, we always draw a
3460/// centred circle to the nearest edge.
3461fn parse_radial_gradient(inner: &str) -> Option<Gradient> {
3462    let mut parts = split_top_level_commas(inner);
3463    if parts.is_empty() {
3464        return None;
3465    }
3466    // If the first segment isn't a colour stop, treat it as the (ignored) config.
3467    if parse_color(first(parts[0].trim())).is_none() && !parts[0].trim().is_empty() {
3468        parts.remove(0);
3469    }
3470    let stops = parse_stops(&parts)?;
3471    Some(Gradient { kind: GradientKind::Radial, stops })
3472}
3473
3474/// Parse the direction of a linear gradient: `<n>deg` (CSS: 0 = to top,
3475/// clockwise) or `to <side>`. Returns radians, or `None` if it's not a direction.
3476fn parse_gradient_angle(tok: &str) -> Option<f32> {
3477    if let Some(deg) = tok.strip_suffix("deg") {
3478        return deg.trim().parse::<f32>().ok().map(f32::to_radians);
3479    }
3480    if tok == "turn" {
3481        return None;
3482    }
3483    if let Some(rest) = tok.strip_suffix("turn") {
3484        return rest.trim().parse::<f32>().ok().map(|t| t * std::f32::consts::TAU);
3485    }
3486    let side = tok.strip_prefix("to ")?.trim();
3487    // CSS angles: to top = 0, to right = 90, to bottom = 180, to left = 270.
3488    let deg = match side {
3489        "top" => 0.0,
3490        "right" => 90.0,
3491        "bottom" => 180.0,
3492        "left" => 270.0,
3493        "top right" | "right top" => 45.0,
3494        "bottom right" | "right bottom" => 135.0,
3495        "bottom left" | "left bottom" => 225.0,
3496        "top left" | "left top" => 315.0,
3497        _ => return None,
3498    };
3499    Some(f32::to_radians(deg))
3500}
3501
3502/// Parse `<color> [<pos>%]` stops. Missing positions are filled by spreading the
3503/// unspecified stops evenly between their specified neighbours (ends default to
3504/// 0% and 100%).
3505fn parse_stops(parts: &[&str]) -> Option<Vec<(Rgba, f32)>> {
3506    let mut colors = Vec::new();
3507    let mut positions: Vec<Option<f32>> = Vec::new();
3508    for part in parts {
3509        let part = part.trim();
3510        let mut toks = part.split_whitespace();
3511        let color = parse_color(toks.next()?)?;
3512        let pos = toks
3513            .next()
3514            .and_then(|p| p.strip_suffix('%'))
3515            .and_then(|p| p.trim().parse::<f32>().ok())
3516            .map(|p| (p / 100.0).clamp(0.0, 1.0));
3517        colors.push(color);
3518        positions.push(pos);
3519    }
3520    if colors.len() < 2 {
3521        return None;
3522    }
3523    // Fill missing positions: ends anchor to 0 and 1, interior gaps interpolate.
3524    let n = positions.len();
3525    positions[0].get_or_insert(0.0);
3526    positions[n - 1].get_or_insert(1.0);
3527    let mut i = 0;
3528    while i < n {
3529        if positions[i].is_some() {
3530            i += 1;
3531            continue;
3532        }
3533        let start = i - 1;
3534        let mut j = i;
3535        while j < n && positions[j].is_none() {
3536            j += 1;
3537        }
3538        let p0 = positions[start].unwrap();
3539        let p1 = positions[j].unwrap();
3540        let gap = j - start;
3541        for (k, slot) in (start + 1..j).enumerate() {
3542            positions[slot] = Some(p0 + (p1 - p0) * (k as f32 + 1.0) / gap as f32);
3543        }
3544        i = j;
3545    }
3546    Some(colors.into_iter().zip(positions.into_iter().map(Option::unwrap)).collect())
3547}
3548
3549/// Split on top-level commas (ignoring commas inside `rgb( … )` etc.).
3550fn split_top_level_commas(value: &str) -> Vec<&str> {
3551    let mut out = Vec::new();
3552    let mut depth = 0i32;
3553    let mut start = 0;
3554    for (i, c) in value.char_indices() {
3555        match c {
3556            '(' => depth += 1,
3557            ')' => depth -= 1,
3558            ',' if depth == 0 => {
3559                out.push(value[start..i].trim());
3560                start = i + 1;
3561            }
3562            _ => {}
3563        }
3564    }
3565    let last = value[start..].trim();
3566    if !last.is_empty() {
3567        out.push(last);
3568    }
3569    out
3570}
3571
3572/// Parse a `transform` function list (`rotate(15deg) translate(4px, 0)`) into a
3573/// single affine `[a, b, c, d, e, f]`. Functions compose left-to-right (the
3574/// leftmost is outermost). `translate` percentages aren't supported. `None` if
3575/// nothing parsed.
3576fn parse_transform(value: &str) -> Option<Transform> {
3577    let mut m = [1.0, 0.0, 0.0, 1.0, 0.0, 0.0]; // identity
3578    let mut any = false;
3579    let mut rest = value.trim();
3580    while let Some(open) = rest.find('(') {
3581        let name = rest[..open].trim().to_ascii_lowercase();
3582        let close = rest[open..].find(')')? + open;
3583        let args = &rest[open + 1..close];
3584        if let Some(f) = transform_fn(&name, args) {
3585            m = mat_mul(m, f);
3586            any = true;
3587        }
3588        rest = rest[close + 1..].trim_start();
3589    }
3590    any.then_some(m)
3591}
3592
3593/// One `transform` function to an affine, or `None` if unrecognised.
3594fn transform_fn(name: &str, args: &str) -> Option<Transform> {
3595    let nums: Vec<&str> = args.split(',').map(str::trim).filter(|s| !s.is_empty()).collect();
3596    let num = |i: usize| nums.get(i).and_then(|s| s.parse::<f32>().ok());
3597    match name {
3598        "translate" => {
3599            let tx = parse_px(nums.first()?)?;
3600            let ty = nums.get(1).and_then(|s| parse_px(s)).unwrap_or(0.0);
3601            Some([1.0, 0.0, 0.0, 1.0, tx, ty])
3602        }
3603        "translatex" => Some([1.0, 0.0, 0.0, 1.0, parse_px(nums.first()?)?, 0.0]),
3604        "translatey" => Some([1.0, 0.0, 0.0, 1.0, 0.0, parse_px(nums.first()?)?]),
3605        "scale" => {
3606            let sx = num(0)?;
3607            let sy = num(1).unwrap_or(sx);
3608            Some([sx, 0.0, 0.0, sy, 0.0, 0.0])
3609        }
3610        "scalex" => Some([num(0)?, 0.0, 0.0, 1.0, 0.0, 0.0]),
3611        "scaley" => Some([1.0, 0.0, 0.0, num(0)?, 0.0, 0.0]),
3612        "rotate" => {
3613            let (sin, cos) = parse_angle(nums.first()?)?.sin_cos();
3614            Some([cos, sin, -sin, cos, 0.0, 0.0])
3615        }
3616        _ => None,
3617    }
3618}
3619
3620/// Multiply two affines (`a` applied after `b`): `mat_mul(a, b)(p) = a(b(p))`.
3621fn mat_mul(a: Transform, b: Transform) -> Transform {
3622    let [a1, b1, c1, d1, e1, f1] = a;
3623    let [a2, b2, c2, d2, e2, f2] = b;
3624    [
3625        a1 * a2 + c1 * b2,
3626        b1 * a2 + d1 * b2,
3627        a1 * c2 + c1 * d2,
3628        b1 * c2 + d1 * d2,
3629        a1 * e2 + c1 * f2 + e1,
3630        b1 * e2 + d1 * f2 + f1,
3631    ]
3632}
3633
3634/// An angle in `deg` (default), `rad`, `turn`, or `grad`, returned in radians.
3635fn parse_angle(s: &str) -> Option<f32> {
3636    let s = s.trim();
3637    if let Some(v) = s.strip_suffix("deg") {
3638        return v.trim().parse::<f32>().ok().map(f32::to_radians);
3639    }
3640    if let Some(v) = s.strip_suffix("grad") {
3641        return v.trim().parse::<f32>().ok().map(|g| g * std::f32::consts::PI / 200.0);
3642    }
3643    if let Some(v) = s.strip_suffix("turn") {
3644        return v.trim().parse::<f32>().ok().map(|t| t * std::f32::consts::TAU);
3645    }
3646    if let Some(v) = s.strip_suffix("rad") {
3647        return v.trim().parse::<f32>().ok();
3648    }
3649    s.parse::<f32>().ok().map(f32::to_radians)
3650}
3651
3652/// `box-shadow: <dx> <dy> <blur>? <spread>? <color>?`, optionally `inset`.
3653/// A single shadow only; multiple comma-separated shadows take the first.
3654/// `none` yields no shadow.
3655fn parse_box_shadow(value: &str) -> Option<BoxShadow> {
3656    let first = value.split(',').next().unwrap_or(value).trim();
3657    if first.is_empty() || first == "none" {
3658        return None;
3659    }
3660    let mut lengths = Vec::new();
3661    let mut color_parts = Vec::new();
3662    let mut inset = false;
3663    for tok in first.split_whitespace() {
3664        if tok == "inset" {
3665            inset = true;
3666        } else if let Some(px) = parse_px(tok) {
3667            lengths.push(px);
3668        } else {
3669            color_parts.push(tok);
3670        }
3671    }
3672    // Offsets are required; blur and spread default to 0, colour to black.
3673    if lengths.len() < 2 {
3674        return None;
3675    }
3676    let color = parse_color(&color_parts.join(" ")).unwrap_or(Rgba::new(0.0, 0.0, 0.0, 1.0));
3677    Some(BoxShadow {
3678        dx: lengths[0],
3679        dy: lengths[1],
3680        blur: lengths.get(2).copied().unwrap_or(0.0),
3681        spread: lengths.get(3).copied().unwrap_or(0.0),
3682        color,
3683        inset,
3684    })
3685}
3686
3687/// `line-height`: a unitless number (× font size), a length, or `normal` (→
3688/// `None`, keep the font's own metrics).
3689fn parse_line_height(v: &str, font_size: f32) -> Option<f32> {
3690    let s = first(v);
3691    if s == "normal" {
3692        return None;
3693    }
3694    if s.ends_with("px") || s.ends_with("rem") || s.ends_with("em") {
3695        // `em` is relative to font size; `parse_len` handles px/rem.
3696        if let Some(em) = s.strip_suffix("em").filter(|e| !e.ends_with('r')) {
3697            return em.parse::<f32>().ok().map(|n| n * font_size);
3698        }
3699        return parse_len(s).and_then(|l| match l {
3700            Len::Px(px) => Some(px),
3701            _ => None,
3702        });
3703    }
3704    // A bare number multiplies the font size (the usual CSS form).
3705    s.parse::<f32>().ok().map(|n| n * font_size)
3706}
3707
3708/// `letter-spacing` / `word-spacing`: a px length, or `normal` (→ no extra).
3709fn parse_spacing(v: &str) -> Option<f32> {
3710    match first(v) {
3711        "normal" => None,
3712        s => parse_px(s),
3713    }
3714}
3715
3716/// `aspect-ratio`: a plain number, or a `<w> / <h>` ratio.
3717fn parse_aspect_ratio(v: &str) -> Option<f32> {
3718    if let Some((w, h)) = v.split_once('/') {
3719        let (w, h) = (w.trim().parse::<f32>().ok()?, h.trim().parse::<f32>().ok()?);
3720        return (h != 0.0).then_some(w / h);
3721    }
3722    v.trim().parse::<f32>().ok().filter(|r| *r > 0.0)
3723}
3724
3725fn first(s: &str) -> &str {
3726    s.split_whitespace().next().unwrap_or(s)
3727}
3728
3729fn parse_px(s: &str) -> Option<f32> {
3730    let s = s.trim();
3731    let s = s.strip_suffix("px").unwrap_or(s);
3732    s.parse::<f32>().ok()
3733}
3734
3735/// One `rem` in pixels (root font size).
3736const REM_PX: f32 = 16.0;
3737
3738/// Parse a length: `px`, `%`, `rem`, `vw`, `vh`/`dvh`. (`rem` resolves to px;
3739/// `dvh` is treated as `vh` since we have no dynamic browser chrome.)
3740fn parse_len(s: &str) -> Option<Len> {
3741    let s = s.trim();
3742    if let Some(pct) = s.strip_suffix('%') {
3743        return pct.trim().parse::<f32>().ok().map(|v| Len::Pct(v / 100.0));
3744    }
3745    if let Some(n) = s.strip_suffix("dvh").or_else(|| s.strip_suffix("vh")) {
3746        return n.trim().parse::<f32>().ok().map(Len::Vh);
3747    }
3748    if let Some(n) = s.strip_suffix("vw") {
3749        return n.trim().parse::<f32>().ok().map(Len::Vw);
3750    }
3751    if let Some(n) = s.strip_suffix("rem") {
3752        return n.trim().parse::<f32>().ok().map(|v| Len::Px(v * REM_PX));
3753    }
3754    let n = s.strip_suffix("px").unwrap_or(s);
3755    n.parse::<f32>().ok().map(Len::Px)
3756}
3757
3758/// Parse a `grid-template-columns`/`-rows` value into tracks: `1fr`, `100px`,
3759/// `auto`, and `minmax(min, max)` (e.g. `minmax(0, 1fr)`, which lets a track
3760/// shrink below its content instead of overflowing the grid).
3761/// `grid-column` / `grid-row` shorthand: `<start> [/ <end>]`. A missing end is
3762/// `auto` (span 1). Each side is a line index, `span <n>`, or `auto`.
3763fn parse_grid_shorthand(value: &str) -> (GridPlace, GridPlace) {
3764    let mut parts = value.splitn(2, '/');
3765    let start = parts.next().map(parse_grid_place).unwrap_or_default();
3766    let end = parts.next().map(parse_grid_place).unwrap_or_default();
3767    (start, end)
3768}
3769
3770/// One placement endpoint: `auto`, a (possibly negative) line index, or
3771/// `span <n>`. Named lines aren't supported.
3772fn parse_grid_place(side: &str) -> GridPlace {
3773    let s = side.trim();
3774    if let Some(rest) = s.strip_prefix("span") {
3775        return rest.trim().parse::<u16>().ok().map_or(GridPlace::Auto, GridPlace::Span);
3776    }
3777    match s.parse::<i16>() {
3778        Ok(i) if i != 0 => GridPlace::Line(i),
3779        _ => GridPlace::Auto,
3780    }
3781}
3782
3783fn parse_tracks(value: &str) -> Vec<Track> {
3784    split_top_level(value)
3785        .into_iter()
3786        .map(|tok| {
3787            if let Some(args) = tok
3788                .strip_prefix("minmax(")
3789                .and_then(|s| s.strip_suffix(')'))
3790            {
3791                let mut parts = args.split(',');
3792                let lo = parts.next().map(parse_track_side).unwrap_or(TrackSide::Auto);
3793                let hi = parts.next().map(parse_track_side).unwrap_or(TrackSide::Auto);
3794                Track::MinMax(lo, hi)
3795            } else {
3796                match parse_track_side(tok) {
3797                    TrackSide::Px(v) => Track::Px(v),
3798                    TrackSide::Fr(f) => Track::Fr(f),
3799                    TrackSide::Auto => Track::Auto,
3800                }
3801            }
3802        })
3803        .collect()
3804}
3805
3806/// A single track value: `Nfr`, `auto`, or a length (default `auto`).
3807fn parse_track_side(tok: &str) -> TrackSide {
3808    let tok = tok.trim();
3809    if let Some(fr) = tok.strip_suffix("fr") {
3810        TrackSide::Fr(fr.trim().parse().unwrap_or(1.0))
3811    } else if tok == "auto" {
3812        TrackSide::Auto
3813    } else {
3814        parse_px(tok).map(TrackSide::Px).unwrap_or(TrackSide::Auto)
3815    }
3816}
3817
3818/// Split a track list on whitespace, but keep a `minmax( … )` group, which
3819/// contains its own spaces and comma, together as one token.
3820fn split_top_level(value: &str) -> Vec<&str> {
3821    let mut out = Vec::new();
3822    let mut depth = 0i32;
3823    let mut start: Option<usize> = None;
3824    for (i, c) in value.char_indices() {
3825        if c == '(' {
3826            depth += 1;
3827        } else if c == ')' {
3828            depth -= 1;
3829        }
3830        if c.is_whitespace() && depth == 0 {
3831            if let Some(s) = start.take() {
3832                out.push(value[s..i].trim());
3833            }
3834        } else if start.is_none() {
3835            start = Some(i);
3836        }
3837    }
3838    if let Some(s) = start {
3839        out.push(value[s..].trim());
3840    }
3841    out.into_iter().filter(|t| !t.is_empty()).collect()
3842}
3843
3844/// Expand a 1–4 value shorthand (`5px`, `5px 10px`, `1 2 3`, `1 2 3 4`) into
3845/// per-side lengths, CSS order (top, right, bottom, left).
3846fn parse_shorthand_sides(value: &str) -> Sides {
3847    let v: Vec<f32> = value
3848        .split_whitespace()
3849        .filter_map(parse_px)
3850        .collect();
3851    match v.len() {
3852        1 => Sides::uniform(v[0]),
3853        2 => Sides {
3854            top: v[0],
3855            right: v[1],
3856            bottom: v[0],
3857            left: v[1],
3858        },
3859        3 => Sides {
3860            top: v[0],
3861            right: v[1],
3862            bottom: v[2],
3863            left: v[1],
3864        },
3865        n if n >= 4 => Sides {
3866            top: v[0],
3867            right: v[1],
3868            bottom: v[2],
3869            left: v[3],
3870        },
3871        _ => Sides::default(),
3872    }
3873}
3874
3875/// Parse the `border-radius` shorthand into `[TL, TR, BR, BL]`. Unlike the box
3876/// shorthands, border-radius groups by diagonal: 1 value = all; 2 = TL/BR, TR/BL;
3877/// 3 = TL, TR/BL, BR; 4 = TL, TR, BR, BL. An elliptical `h / v` form is reduced
3878/// to its horizontal radii (we only draw circular corners).
3879fn parse_border_radius(value: &str) -> [f32; 4] {
3880    let horizontal = value.split('/').next().unwrap_or(value);
3881    let v: Vec<f32> = horizontal.split_whitespace().filter_map(parse_px).collect();
3882    match v.len() {
3883        1 => [v[0]; 4],
3884        2 => [v[0], v[1], v[0], v[1]],
3885        3 => [v[0], v[1], v[2], v[1]],
3886        n if n >= 4 => [v[0], v[1], v[2], v[3]],
3887        _ => [0.0; 4],
3888    }
3889}
3890
3891/// Resolve `padding`/`margin` from the shorthand plus any `-top/-right/-bottom/
3892/// -left` longhand overrides.
3893fn box_sides(p: &HashMap<String, String>, prop: &str) -> Sides {
3894    let mut sides = p
3895        .get(prop)
3896        .map(|v| parse_shorthand_sides(v))
3897        .unwrap_or_default();
3898    for side in ["top", "right", "bottom", "left"] {
3899        if let Some(v) = p.get(&format!("{prop}-{side}")) {
3900            if let Some(px) = parse_px(first(v)) {
3901                set_side(&mut sides, side, px);
3902            }
3903        }
3904    }
3905    sides
3906}
3907
3908/// Parse `border` box-model props: `border`, `border-width`, `border-color`,
3909/// `border-<side>`, `border-<side>-width`.
3910fn interpret_border(p: &HashMap<String, String>, st: &mut Style) {
3911    // `border: <width> <style> <color>` shorthand.
3912    if let Some(v) = p.get("border") {
3913        let (w, c) = parse_border(v);
3914        st.border = Sides::uniform(w);
3915        if c.is_some() {
3916            st.border_color = c;
3917        }
3918    }
3919    if let Some(v) = p.get("border-width") {
3920        st.border = parse_shorthand_sides(v);
3921    }
3922    if let Some(v) = p.get("border-color") {
3923        st.border_color = parse_color(v);
3924    }
3925    for side in ["top", "right", "bottom", "left"] {
3926        if let Some(v) = p.get(&format!("border-{side}")) {
3927            let (w, c) = parse_border(v);
3928            set_side(&mut st.border, side, w);
3929            if c.is_some() {
3930                st.border_color = c;
3931            }
3932        }
3933        if let Some(v) = p.get(&format!("border-{side}-width")) {
3934            if let Some(px) = parse_px(first(v)) {
3935                set_side(&mut st.border, side, px);
3936            }
3937        }
3938    }
3939}
3940
3941fn set_side(sides: &mut Sides, side: &str, value: f32) {
3942    match side {
3943        "top" => sides.top = value,
3944        "right" => sides.right = value,
3945        "bottom" => sides.bottom = value,
3946        "left" => sides.left = value,
3947        _ => {}
3948    }
3949}
3950
3951/// Parse a `border` value into `(width, color)`; the line style token is ignored.
3952fn parse_border(value: &str) -> (f32, Option<Rgba>) {
3953    let mut width = 0.0;
3954    let mut color = None;
3955    for token in value.split_whitespace() {
3956        if let Some(px) = parse_px(token) {
3957            width = px;
3958        } else if let Some(c) = parse_color(token) {
3959            color = Some(c);
3960        }
3961    }
3962    (width, color)
3963}
3964
3965/// Parse `font-weight`: keywords or a numeric 100–900.
3966fn parse_weight(s: &str) -> Option<u16> {
3967    match s.trim() {
3968        "normal" => Some(400),
3969        "bold" => Some(700),
3970        "lighter" => Some(300),
3971        "bolder" => Some(800),
3972        other => other.parse::<u16>().ok(),
3973    }
3974}
3975
3976/// Parse `text-align`.
3977fn parse_text_align(s: &str) -> TextAlign {
3978    match s.trim() {
3979        "center" => TextAlign::Center,
3980        "right" | "end" => TextAlign::End,
3981        "justify" => TextAlign::Justify,
3982        _ => TextAlign::Start,
3983    }
3984}
3985
3986fn parse_color(s: &str) -> Option<Rgba> {
3987    let s = s.trim();
3988    if let Some(hex) = s.strip_prefix('#') {
3989        return parse_hex(hex);
3990    }
3991    if s.starts_with("rgb") {
3992        return parse_rgb(s);
3993    }
3994    if s.eq_ignore_ascii_case("transparent") {
3995        return Some(Rgba::new(0.0, 0.0, 0.0, 0.0));
3996    }
3997    // Named colors. This matters more than it looks: lightningcss *minifies* hex
3998    // to the shorter keyword (`#ff0000` → `red`), so without this table a plain
3999    // `color: #ff0000` would silently fall back to the default.
4000    named_color(&s.to_ascii_lowercase()).and_then(parse_hex)
4001}
4002
4003/// The CSS named colors, as their hex value (without `#`). Covers the full CSS
4004/// Color Level 4 keyword list so any keyword lightningcss emits round-trips.
4005fn named_color(name: &str) -> Option<&'static str> {
4006    let hex = match name {
4007        "aliceblue" => "f0f8ff", "antiquewhite" => "faebd7", "aqua" => "00ffff",
4008        "aquamarine" => "7fffd4", "azure" => "f0ffff", "beige" => "f5f5dc",
4009        "bisque" => "ffe4c4", "black" => "000000", "blanchedalmond" => "ffebcd",
4010        "blue" => "0000ff", "blueviolet" => "8a2be2", "brown" => "a52a2a",
4011        "burlywood" => "deb887", "cadetblue" => "5f9ea0", "chartreuse" => "7fff00",
4012        "chocolate" => "d2691e", "coral" => "ff7f50", "cornflowerblue" => "6495ed",
4013        "cornsilk" => "fff8dc", "crimson" => "dc143c", "cyan" => "00ffff",
4014        "darkblue" => "00008b", "darkcyan" => "008b8b", "darkgoldenrod" => "b8860b",
4015        "darkgray" | "darkgrey" => "a9a9a9", "darkgreen" => "006400",
4016        "darkkhaki" => "bdb76b", "darkmagenta" => "8b008b", "darkolivegreen" => "556b2f",
4017        "darkorange" => "ff8c00", "darkorchid" => "9932cc", "darkred" => "8b0000",
4018        "darksalmon" => "e9967a", "darkseagreen" => "8fbc8f", "darkslateblue" => "483d8b",
4019        "darkslategray" | "darkslategrey" => "2f4f4f", "darkturquoise" => "00ced1",
4020        "darkviolet" => "9400d3", "deeppink" => "ff1493", "deepskyblue" => "00bfff",
4021        "dimgray" | "dimgrey" => "696969", "dodgerblue" => "1e90ff",
4022        "firebrick" => "b22222", "floralwhite" => "fffaf0", "forestgreen" => "228b22",
4023        "fuchsia" => "ff00ff", "gainsboro" => "dcdcdc", "ghostwhite" => "f8f8ff",
4024        "gold" => "ffd700", "goldenrod" => "daa520", "gray" | "grey" => "808080",
4025        "green" => "008000", "greenyellow" => "adff2f", "honeydew" => "f0fff0",
4026        "hotpink" => "ff69b4", "indianred" => "cd5c5c", "indigo" => "4b0082",
4027        "ivory" => "fffff0", "khaki" => "f0e68c", "lavender" => "e6e6fa",
4028        "lavenderblush" => "fff0f5", "lawngreen" => "7cfc00", "lemonchiffon" => "fffacd",
4029        "lightblue" => "add8e6", "lightcoral" => "f08080", "lightcyan" => "e0ffff",
4030        "lightgoldenrodyellow" => "fafad2", "lightgray" | "lightgrey" => "d3d3d3",
4031        "lightgreen" => "90ee90", "lightpink" => "ffb6c1", "lightsalmon" => "ffa07a",
4032        "lightseagreen" => "20b2aa", "lightskyblue" => "87cefa", "lightslategray" | "lightslategrey" => "778899",
4033        "lightsteelblue" => "b0c4de", "lightyellow" => "ffffe0", "lime" => "00ff00",
4034        "limegreen" => "32cd32", "linen" => "faf0e6", "magenta" => "ff00ff",
4035        "maroon" => "800000", "mediumaquamarine" => "66cdaa", "mediumblue" => "0000cd",
4036        "mediumorchid" => "ba55d3", "mediumpurple" => "9370db", "mediumseagreen" => "3cb371",
4037        "mediumslateblue" => "7b68ee", "mediumspringgreen" => "00fa9a", "mediumturquoise" => "48d1cc",
4038        "mediumvioletred" => "c71585", "midnightblue" => "191970", "mintcream" => "f5fffa",
4039        "mistyrose" => "ffe4e1", "moccasin" => "ffe4b5", "navajowhite" => "ffdead",
4040        "navy" => "000080", "oldlace" => "fdf5e6", "olive" => "808000",
4041        "olivedrab" => "6b8e23", "orange" => "ffa500", "orangered" => "ff4500",
4042        "orchid" => "da70d6", "palegoldenrod" => "eee8aa", "palegreen" => "98fb98",
4043        "paleturquoise" => "afeeee", "palevioletred" => "db7093", "papayawhip" => "ffefd5",
4044        "peachpuff" => "ffdab9", "peru" => "cd853f", "pink" => "ffc0cb",
4045        "plum" => "dda0dd", "powderblue" => "b0e0e6", "purple" => "800080",
4046        "rebeccapurple" => "663399", "red" => "ff0000", "rosybrown" => "bc8f8f",
4047        "royalblue" => "4169e1", "saddlebrown" => "8b4513", "salmon" => "fa8072",
4048        "sandybrown" => "f4a460", "seagreen" => "2e8b57", "seashell" => "fff5ee",
4049        "sienna" => "a0522d", "silver" => "c0c0c0", "skyblue" => "87ceeb",
4050        "slateblue" => "6a5acd", "slategray" | "slategrey" => "708090", "snow" => "fffafa",
4051        "springgreen" => "00ff7f", "steelblue" => "4682b4", "tan" => "d2b48c",
4052        "teal" => "008080", "thistle" => "d8bfd8", "tomato" => "ff6347",
4053        "turquoise" => "40e0d0", "violet" => "ee82ee", "wheat" => "f5deb3",
4054        "white" => "ffffff", "whitesmoke" => "f5f5f5", "yellow" => "ffff00",
4055        "yellowgreen" => "9acd32",
4056        _ => return None,
4057    };
4058    Some(hex)
4059}
4060
4061fn parse_hex(hex: &str) -> Option<Rgba> {
4062    let expand = |c: char| -> u8 { u8::from_str_radix(&format!("{c}{c}"), 16).unwrap_or(0) };
4063    let bytes: Vec<char> = hex.chars().collect();
4064    let (r, g, b, a) = match bytes.len() {
4065        3 => (expand(bytes[0]), expand(bytes[1]), expand(bytes[2]), 255),
4066        6 => (
4067            u8::from_str_radix(&hex[0..2], 16).ok()?,
4068            u8::from_str_radix(&hex[2..4], 16).ok()?,
4069            u8::from_str_radix(&hex[4..6], 16).ok()?,
4070            255,
4071        ),
4072        8 => (
4073            u8::from_str_radix(&hex[0..2], 16).ok()?,
4074            u8::from_str_radix(&hex[2..4], 16).ok()?,
4075            u8::from_str_radix(&hex[4..6], 16).ok()?,
4076            u8::from_str_radix(&hex[6..8], 16).ok()?,
4077        ),
4078        _ => return None,
4079    };
4080    Some(Rgba::new(
4081        r as f32 / 255.0,
4082        g as f32 / 255.0,
4083        b as f32 / 255.0,
4084        a as f32 / 255.0,
4085    ))
4086}
4087
4088#[cfg(test)]
4089mod tests {
4090    use super::{build_styled_tree, build_styled_tree_tracked, interpolate_tracked, interpret, Len, Locals};
4091    use rux_script::{Builder, Engine};
4092    use std::collections::HashMap;
4093
4094    /// Every kind of CSS warning must land on the line the reader can see, not
4095    /// on a line counted from the start of the `<style>` block. Getting this
4096    /// wrong sends someone confidently to the wrong part of their file, which is
4097    /// why the offset is carried rather than assumed to be zero.
4098    #[test]
4099    fn css_warnings_carry_the_line_of_the_file() {
4100        let src = "<template>\n  <screen class=\"a\"></screen>\n</template>\n\n<style>\n  .a { display: flex; }\n  .b { float: left; }\n\n  .c:nope { color: red; }\n\n  @media (hover: hover) { .a { gap: 4px; } }\n</style>\n";
4101        let sfc = rux_parser::parse_sfc(src).expect("parses");
4102        let mut engine = Builder::new().build("").expect("engine");
4103
4104        let _ = super::take_warnings(); // start from a clean sink
4105        let _ = build_styled_tree(&sfc, &HashMap::new(), &mut engine).expect("builds");
4106        let warnings = super::take_warnings();
4107
4108        let line_for = |needle: &str| {
4109            warnings
4110                .iter()
4111                .find(|w| w.message.contains(needle))
4112                .unwrap_or_else(|| panic!("no warning mentioning {needle}: {warnings:?}"))
4113                .line
4114        };
4115        assert_eq!(line_for("float"), Some(7));
4116        assert_eq!(line_for(":nope"), Some(9));
4117        assert_eq!(line_for("@media"), Some(11));
4118
4119        // And each reported line really does contain what was complained about.
4120        let line_of = |n: usize| src.lines().nth(n - 1).unwrap();
4121        assert!(line_of(7).contains("float"));
4122        assert!(line_of(9).contains(":nope"));
4123        assert!(line_of(11).contains("@media"));
4124    }
4125
4126    /// The fixture above writes every rule on one line, which makes the rule's
4127    /// line and its declarations' lines the same and hides the difference. A
4128    /// real stylesheet is written expanded, and a warning must name the line the
4129    /// property is actually on, not the line the selector is on.
4130    #[test]
4131    fn a_warning_in_an_expanded_rule_names_the_declaration_not_the_selector() {
4132        let src = concat!(
4133            "<template>\n",
4134            "  <screen class=\"a\"></screen>\n",
4135            "</template>\n",
4136            "\n",
4137            "<style>\n",
4138            "  .a {\n",
4139            "    display: flex;\n",
4140            "    padding: 8px;\n",
4141            "    float: left;\n",
4142            "  }\n",
4143            "\n",
4144            "  .b {\n",
4145            "    color: red;\n",
4146            "    zoom: 2;\n",
4147            "  }\n",
4148            "</style>\n",
4149        );
4150        let sfc = rux_parser::parse_sfc(src).expect("parses");
4151        let mut engine = Builder::new().build("").expect("engine");
4152
4153        let _ = super::take_warnings();
4154        let _ = build_styled_tree(&sfc, &HashMap::new(), &mut engine).expect("builds");
4155        let warnings = super::take_warnings();
4156
4157        let line_for = |needle: &str| {
4158            warnings
4159                .iter()
4160                .find(|w| w.message.contains(needle))
4161                .unwrap_or_else(|| panic!("no warning mentioning {needle}: {warnings:?}"))
4162                .line
4163        };
4164        // `float` is on line 9; `.a` opens on line 6, which is what the rule's
4165        // own location would have reported.
4166        assert_eq!(line_for("float"), Some(9));
4167        // And the scan must not run past the closing brace into the next rule.
4168        assert_eq!(line_for("zoom"), Some(14));
4169
4170        let line_of = |n: usize| src.lines().nth(n - 1).unwrap();
4171        assert!(line_of(9).contains("float"));
4172        assert!(line_of(14).contains("zoom"));
4173    }
4174
4175    /// A component's CSS lives in a different file, and a warning carries no
4176    /// file, so claiming a line would point into whichever document happened to
4177    /// import it. Unplaced is the honest answer until warnings carry a file too.
4178    #[test]
4179    fn a_components_css_warning_is_left_unplaced() {
4180        let main = rux_parser::parse_sfc(
4181            "<template>\n  <screen><my-row /></screen>\n</template>\n<script>\nuse components::row;\n</script>\n",
4182        )
4183        .expect("parses");
4184        let component = rux_parser::parse_sfc(
4185            "<template>\n  <view class=\"r\"></view>\n</template>\n<style>\n  .r { float: left; }\n</style>\n",
4186        )
4187        .expect("parses");
4188        let mut components = HashMap::new();
4189        components.insert("my-row".to_string(), component);
4190        let mut engine = Builder::new().build("").expect("engine");
4191
4192        let _ = super::take_warnings();
4193        let _ = build_styled_tree(&main, &components, &mut engine).expect("builds");
4194        let warnings = super::take_warnings();
4195
4196        let float = warnings
4197            .iter()
4198            .find(|w| w.message.contains("float"))
4199            .expect("the component's unhonored property is still reported");
4200        assert_eq!(float.line, None, "but without a line from another file");
4201    }
4202
4203    #[test]
4204    fn box_model_shorthand_sides_and_border() {
4205        let mut p = HashMap::new();
4206        p.insert("padding".to_string(), "4px 8px".to_string()); // vertical | horizontal
4207        p.insert("padding-left".to_string(), "20px".to_string()); // longhand override
4208        p.insert("margin".to_string(), "10px".to_string());
4209        p.insert("border".to_string(), "2px solid #ff0000".to_string());
4210        p.insert("border-bottom-width".to_string(), "5px".to_string());
4211
4212        let st = interpret(&p);
4213        assert_eq!((st.padding.top, st.padding.right, st.padding.bottom, st.padding.left), (4.0, 8.0, 4.0, 20.0));
4214        assert_eq!(st.margin.top, 10.0);
4215        assert_eq!(st.border.top, 2.0);
4216        assert_eq!(st.border.bottom, 5.0); // per-side width override
4217        assert_eq!(st.border_color.map(|c| c.r), Some(1.0)); // #ff0000 → red
4218    }
4219
4220    #[test]
4221    fn flex_longhands_and_shorthand() {
4222        let flex = |v: &str| {
4223            let mut p = HashMap::new();
4224            p.insert("flex".to_string(), v.to_string());
4225            let st = interpret(&p);
4226            (st.grow, st.shrink, st.basis)
4227        };
4228        // The shorthand's omitted basis is 0, not auto, a bare `flex: 1` sizes
4229        // purely from the free space.
4230        assert_eq!(flex("1"), (1.0, 1.0, Some(Len::Px(0.0))));
4231        assert_eq!(flex("1 0 auto"), (1.0, 0.0, None));
4232        assert_eq!(flex("2 3 120px"), (2.0, 3.0, Some(Len::Px(120.0))));
4233        assert_eq!(flex("none"), (0.0, 0.0, None));
4234
4235        let mut p = HashMap::new();
4236        p.insert("flex".to_string(), "1".to_string());
4237        p.insert("flex-shrink".to_string(), "0".to_string()); // longhand wins
4238        p.insert("flex-wrap".to_string(), "wrap".to_string());
4239        p.insert("opacity".to_string(), "0.45".to_string());
4240        let st = interpret(&p);
4241        assert_eq!(st.shrink, 0.0);
4242        assert!(st.wrap);
4243        assert_eq!(st.opacity, 0.45);
4244    }
4245
4246    #[test]
4247    fn border_radius_shorthand_diagonal_grouping_and_longhands() {
4248        // border-radius groups by diagonal, unlike padding/margin: 2 values are
4249        // TL/BR then TR/BL; 3 are TL, TR/BL, BR.
4250        assert_eq!(super::parse_border_radius("8px"), [8.0, 8.0, 8.0, 8.0]);
4251        assert_eq!(super::parse_border_radius("8px 4px"), [8.0, 4.0, 8.0, 4.0]);
4252        assert_eq!(super::parse_border_radius("1px 2px 3px"), [1.0, 2.0, 3.0, 2.0]);
4253        assert_eq!(super::parse_border_radius("1px 2px 3px 4px"), [1.0, 2.0, 3.0, 4.0]);
4254        // Elliptical `h / v` reduces to the horizontal radii.
4255        assert_eq!(super::parse_border_radius("10px / 20px"), [10.0, 10.0, 10.0, 10.0]);
4256
4257        // A per-corner longhand overrides just its corner (index 1 = top-right).
4258        let mut p = HashMap::new();
4259        p.insert("border-radius".to_string(), "5px".to_string());
4260        p.insert("border-top-right-radius".to_string(), "12px".to_string());
4261        assert_eq!(interpret(&p).radius, [5.0, 12.0, 5.0, 5.0]);
4262    }
4263
4264    #[test]
4265    fn grid_placement_parses_lines_and_spans() {
4266        use super::GridPlace;
4267        let place = |css: &str| {
4268            let mut p = HashMap::new();
4269            p.insert("grid-column".to_string(), css.to_string());
4270            interpret(&p).grid_column
4271        };
4272        assert_eq!(place("1 / 3"), (GridPlace::Line(1), GridPlace::Line(3)));
4273        assert_eq!(place("2"), (GridPlace::Line(2), GridPlace::Auto));
4274        assert_eq!(place("span 2"), (GridPlace::Span(2), GridPlace::Auto));
4275        assert_eq!(place("1 / span 2"), (GridPlace::Line(1), GridPlace::Span(2)));
4276        assert_eq!(place("-1"), (GridPlace::Line(-1), GridPlace::Auto));
4277
4278        // The -end longhand overrides just the end of the shorthand.
4279        let mut p = HashMap::new();
4280        p.insert("grid-row".to_string(), "1 / 2".to_string());
4281        p.insert("grid-row-end".to_string(), "span 3".to_string());
4282        assert_eq!(interpret(&p).grid_row, (GridPlace::Line(1), GridPlace::Span(3)));
4283    }
4284
4285    #[test]
4286    fn named_and_hex_colors_resolve() {
4287        use super::parse_color;
4288        // The landmine: lightningcss minifies `#ff0000` to `red`, so the keyword
4289        // path has to work or a plain red silently falls back to the default.
4290        assert_eq!(parse_color("red").map(|c| (c.r, c.g, c.b)), Some((1.0, 0.0, 0.0)));
4291        assert!(parse_color("REBECCApurple").is_some()); // case-insensitive
4292        assert_eq!(parse_color("#000000").map(|c| c.r), Some(0.0));
4293        assert_eq!(parse_color("transparent").map(|c| c.a), Some(0.0));
4294        assert!(parse_color("notacolor").is_none());
4295    }
4296
4297    #[test]
4298    fn decodes_html_entities_in_text() {
4299        use super::decode_entities;
4300        assert_eq!(decode_entities("A &amp; B"), "A & B");
4301        assert_eq!(decode_entities("&lt;tag&gt; &quot;q&quot;"), "<tag> \"q\"");
4302        assert_eq!(decode_entities("&#38; &#x26;"), "& &");
4303        assert_eq!(decode_entities("plain text"), "plain text");
4304        // An unrecognised or malformed entity is left as written.
4305        assert_eq!(decode_entities("R&D, AT&T"), "R&D, AT&T");
4306        assert_eq!(decode_entities("&notanentity;"), "&notanentity;");
4307    }
4308
4309    #[test]
4310    fn parses_and_composes_transforms() {
4311        use super::parse_transform;
4312        assert_eq!(parse_transform("translate(10px, 20px)").unwrap(), [1.0, 0.0, 0.0, 1.0, 10.0, 20.0]);
4313        assert_eq!(parse_transform("scale(2, 3)").unwrap(), [2.0, 0.0, 0.0, 3.0, 0.0, 0.0]);
4314
4315        // rotate(90deg) maps (x, y) → (-y, x): a≈0, b≈1, c≈-1, d≈0.
4316        let r = parse_transform("rotate(90deg)").unwrap();
4317        assert!(r[0].abs() < 1e-4 && (r[1] - 1.0).abs() < 1e-4);
4318        assert!((r[2] + 1.0).abs() < 1e-4 && r[3].abs() < 1e-4);
4319
4320        // Left-to-right composition: rotate(90) ∘ translate(10,0) moves the
4321        // translation into the rotated frame, so it ends up as (0, 10).
4322        let c = parse_transform("rotate(90deg) translate(10px, 0)").unwrap();
4323        assert!(c[4].abs() < 1e-3 && (c[5] - 10.0).abs() < 1e-3);
4324
4325        assert!(parse_transform("none").is_none());
4326    }
4327
4328    /// A parent holding structural directives is recorded (with its tree path,
4329    /// template path, and the signals its directives read) so the runtime can
4330    /// reconcile just that parent instead of rebuilding the whole tree.
4331    #[test]
4332    fn records_structural_parent_for_reconcile() {
4333        let src = r#"
4334            <template>
4335              <screen>
4336                <text>title</text>
4337                <view r-for="n in nums"><text>{{ n }}</text></view>
4338                <text r-if="level < 5">low</text>
4339              </screen>
4340            </template>
4341            <script> let nums = signal([1, 2, 3]); let level = signal(10); </script>
4342        "#;
4343        let sfc = rux_parser::parse_sfc(src).unwrap();
4344        let mut engine = Builder::new().build(&sfc.script).unwrap();
4345        let mut instances = super::Instances::new();
4346        let (_root, reg) =
4347            build_styled_tree_tracked(&sfc, &HashMap::new(), &mut engine, &mut instances).unwrap();
4348
4349        assert_eq!(reg.structural_parents.len(), 1, "the screen is the one structural parent");
4350        let sp = &reg.structural_parents[0];
4351        assert_eq!(sp.tree_path, Vec::<usize>::new(), "screen is the root");
4352        assert_eq!(sp.tpl_path, Vec::<usize>::new());
4353        let mut deps: Vec<&str> = sp.deps.iter().map(String::as_str).collect();
4354        deps.sort_unstable();
4355        assert_eq!(deps, ["level", "nums"], "both directive signals are captured");
4356    }
4357
4358    #[test]
4359    fn parses_gradients_direction_and_stops() {
4360        use super::parse_background;
4361        use rux_layout::{Background, GradientKind};
4362        use std::f32::consts::{FRAC_PI_2, PI};
4363
4364        let grad = |css: &str| match parse_background(css) {
4365            Some(Background::Gradient(g)) => g,
4366            other => panic!("expected a gradient, got {other:?}"),
4367        };
4368
4369        // 90deg → to the right; two stops anchor to 0 and 1.
4370        let g = grad("linear-gradient(90deg, red, blue)");
4371        assert!(matches!(g.kind, GradientKind::Linear { angle } if (angle - FRAC_PI_2).abs() < 1e-4));
4372        assert_eq!(g.stops.len(), 2);
4373        assert_eq!(g.stops[0].1, 0.0);
4374        assert_eq!(g.stops[1].1, 1.0);
4375        assert_eq!(g.stops[0].0.r, 1.0); // red
4376        assert_eq!(g.stops[1].0.b, 1.0); // blue
4377
4378        // No direction → default `to bottom` (π); the middle stop spreads to 50%.
4379        let g = grad("linear-gradient(red, lime, blue)");
4380        assert!(matches!(g.kind, GradientKind::Linear { angle } if (angle - PI).abs() < 1e-4));
4381        assert!((g.stops[1].1 - 0.5).abs() < 1e-4);
4382
4383        // Explicit positions are honoured; `to right` is 90°.
4384        let g = grad("linear-gradient(to right, red 10%, blue 80%)");
4385        assert!(matches!(g.kind, GradientKind::Linear { angle } if (angle - FRAC_PI_2).abs() < 1e-4));
4386        assert!((g.stops[0].1 - 0.1).abs() < 1e-4);
4387        assert!((g.stops[1].1 - 0.8).abs() < 1e-4);
4388
4389        // Radial: the shape prelude is ignored; stops still parse.
4390        let g = grad("radial-gradient(circle, red, blue)");
4391        assert!(matches!(g.kind, GradientKind::Radial));
4392        assert_eq!(g.stops.len(), 2);
4393
4394        // A plain colour is still a colour, not a gradient.
4395        assert!(matches!(parse_background("#123456"), Some(Background::Color(_))));
4396
4397        // `url(…)` is an image background; quotes are stripped.
4398        assert!(matches!(parse_background("url(assets/logo.png)"), Some(Background::Image(s)) if s == "assets/logo.png"));
4399        assert!(matches!(parse_background("url('a b.png')"), Some(Background::Image(s)) if s == "a b.png"));
4400    }
4401
4402    #[test]
4403    fn maps_alignment_gap_position_and_aspect_ratio() {
4404        use super::{Align, Justify, Len, Position};
4405        let mut p = HashMap::new();
4406        p.insert("align-self".to_string(), "center".to_string());
4407        p.insert("justify-self".to_string(), "end".to_string());
4408        p.insert("align-content".to_string(), "space-between".to_string());
4409        p.insert("row-gap".to_string(), "8px".to_string());
4410        p.insert("column-gap".to_string(), "12px".to_string());
4411        p.insert("position".to_string(), "absolute".to_string());
4412        p.insert("top".to_string(), "10px".to_string());
4413        p.insert("left".to_string(), "auto".to_string());
4414        p.insert("aspect-ratio".to_string(), "16 / 9".to_string());
4415
4416        let st = interpret(&p);
4417        assert!(matches!(st.align_self, Some(Align::Center)));
4418        assert!(matches!(st.justify_self, Some(Align::End)));
4419        assert!(matches!(st.align_content, Some(Justify::SpaceBetween)));
4420        assert_eq!(st.row_gap, Some(8.0));
4421        assert_eq!(st.column_gap, Some(12.0));
4422        assert!(matches!(st.position, Position::Absolute));
4423        assert!(matches!(st.inset[0], Some(Len::Px(v)) if v == 10.0)); // top
4424        assert!(st.inset[3].is_none()); // left: auto
4425        assert!(st.aspect_ratio.is_some_and(|r| (r - 16.0 / 9.0).abs() < 1e-4));
4426    }
4427
4428    #[test]
4429    fn parses_grid_tracks_including_minmax() {
4430        use super::{parse_tracks, Track, TrackSide};
4431        let tracks = parse_tracks("minmax(0, 1fr) 100px auto minmax(120px, 1fr)");
4432        assert_eq!(tracks.len(), 4);
4433        assert!(matches!(
4434            tracks[0],
4435            Track::MinMax(TrackSide::Px(0.0), TrackSide::Fr(f)) if f == 1.0
4436        ));
4437        assert!(matches!(tracks[1], Track::Px(v) if v == 100.0));
4438        assert!(matches!(tracks[2], Track::Auto));
4439        assert!(matches!(
4440            tracks[3],
4441            Track::MinMax(TrackSide::Px(v), TrackSide::Fr(_)) if v == 120.0
4442        ));
4443    }
4444
4445    #[test]
4446    fn image_element_carries_its_src() {
4447        let src = r#"<template><screen><image src="assets/logo.png" /></screen></template>"#;
4448        let sfc = rux_parser::parse_sfc(src).unwrap();
4449        let mut e = Builder::new().build("").unwrap();
4450        let root = build_styled_tree(&sfc, &HashMap::new(), &mut e).unwrap();
4451        let img = root.children[0].image.as_ref().expect("image node");
4452        assert_eq!(img.src, "assets/logo.png");
4453    }
4454
4455    #[test]
4456    fn interpolates_bindings() {
4457        let mut e = Builder::new()
4458            .build(r#"let level = signal(82); let who = signal("Cam");"#)
4459            .unwrap();
4460        let locals = Locals::new();
4461        let interp = |e: &mut Engine, s: &str| interpolate_tracked(s, e, &locals).0;
4462        assert_eq!(interp(&mut e, "{{ level }}%"), "82%");
4463        assert_eq!(interp(&mut e, "Hi {{ who }}!"), "Hi Cam!");
4464        assert_eq!(interp(&mut e, "plain text"), "plain text");
4465        assert_eq!(interp(&mut e, "{{ missing }}!"), "!"); // unknown → empty
4466    }
4467
4468    #[test]
4469    fn expands_r_for_and_r_if_chain() {
4470        let src = r#"
4471            <template>
4472              <screen>
4473                <view r-for="n in nums"><text>{{ n }}</text></view>
4474                <text r-if="level < 5">low</text>
4475                <text r-elif="level < 50">mid</text>
4476                <text r-else>high</text>
4477              </screen>
4478            </template>
4479            <script> let nums = signal([1, 2, 3]); let level = signal(10); </script>
4480        "#;
4481        let sfc = rux_parser::parse_sfc(src).unwrap();
4482        let mut engine = Builder::new().build(&sfc.script).unwrap();
4483        let root = build_styled_tree(&sfc, &HashMap::new(), &mut engine).unwrap();
4484
4485        // 3 views from r-for + exactly one branch (level=10 → the r-elif "mid").
4486        assert_eq!(root.children.len(), 4);
4487        let mid = root.children[3].text.as_ref().unwrap();
4488        assert_eq!(mid.text, "mid");
4489    }
4490
4491    #[test]
4492    fn r_for_tap_handler_captures_the_loop_variable() {
4493        let src = r#"
4494            <template>
4495              <screen>
4496                <view r-for="item in items" @tap="picked = item">
4497                  <text>{{ item }}</text>
4498                </view>
4499              </screen>
4500            </template>
4501            <script> let items = signal(["Alpha", "Bravo", "Charlie"]); let picked = signal(""); </script>
4502        "#;
4503        let sfc = rux_parser::parse_sfc(src).unwrap();
4504        let mut engine = Builder::new().build(&sfc.script).unwrap();
4505        let root = build_styled_tree(&sfc, &HashMap::new(), &mut engine).unwrap();
4506
4507        // The second row's handler must carry its own loop value baked in, not a
4508        // bare `item` that resolves to nothing when it runs in global scope.
4509        let handler = root.children[1].on_tap.clone().expect("row has @tap");
4510        assert!(
4511            handler.contains("let item = \"Bravo\""),
4512            "loop value not baked into handler: {handler}"
4513        );
4514
4515        // End to end: picked starts empty, running the third row's handler sets
4516        // it to that row's item (the bug was that it stayed empty forever).
4517        assert_eq!(engine.get_string("picked"), "");
4518        let third = root.children[2].on_tap.clone().unwrap();
4519        assert!(engine.run_handler(&third), "handler ran");
4520        assert_eq!(engine.get_string("picked"), "Charlie");
4521    }
4522
4523    #[test]
4524    fn input_binds_model_and_shows_placeholder_then_value() {
4525        let src = r#"<template><screen>
4526                       <input r-model="name" placeholder="Type here" />
4527                     </screen></template>
4528                     <script> let name = signal(""); </script>"#;
4529        let sfc = rux_parser::parse_sfc(src).unwrap();
4530        let mut engine = Builder::new().build(&sfc.script).unwrap();
4531
4532        let root = build_styled_tree(&sfc, &HashMap::new(), &mut engine).unwrap();
4533        let input = &root.children[0];
4534        assert_eq!(input.model.as_deref(), Some("name"), "r-model bound");
4535        // Empty signal → the placeholder is shown.
4536        assert_eq!(input.children[0].text.as_ref().unwrap().text, "Type here");
4537
4538        // Simulate the shell editing the focused input, then rebuild.
4539        engine.set_string("name", "Cam");
4540        let root = build_styled_tree(&sfc, &HashMap::new(), &mut engine).unwrap();
4541        let input = &root.children[0];
4542        assert_eq!(input.children[0].text.as_ref().unwrap().text, "Cam");
4543    }
4544
4545    #[test]
4546    fn select_carries_options_and_textarea_is_multiline() {
4547        let src = r#"<template><screen>
4548                       <input type="select" r-model="fruit" :options="fruits" />
4549                       <input type="textarea" r-model="notes" />
4550                     </screen></template>
4551                     <script>
4552                       let fruit = signal("pear");
4553                       let fruits = signal(["apple", "pear", "plum"]);
4554                       let notes = signal("");
4555                     </script>"#;
4556        let sfc = rux_parser::parse_sfc(src).unwrap();
4557        let mut engine = Builder::new().build(&sfc.script).unwrap();
4558        let root = build_styled_tree(&sfc, &HashMap::new(), &mut engine).unwrap();
4559
4560        // The select evaluates :options to strings and shows the bound value.
4561        let select = &root.children[0];
4562        assert_eq!(select.model.as_deref(), Some("fruit"));
4563        assert_eq!(
4564            select.options.as_ref().expect("select has options"),
4565            &vec!["apple".to_string(), "pear".to_string(), "plum".to_string()]
4566        );
4567        assert!(!select.multiline);
4568        assert_eq!(select.children[0].text.as_ref().unwrap().text, "pear");
4569
4570        // The textarea is a multiline input (Enter → newline in the shell).
4571        let textarea = &root.children[1];
4572        assert!(textarea.multiline);
4573        assert!(textarea.options.is_none());
4574    }
4575
4576    #[test]
4577    fn expands_component_with_props() {
4578        let main = rux_parser::parse_sfc(
4579            r#"<template>
4580                 <screen><stat :label="title" :value="level" /></screen>
4581               </template>
4582               <script> let level = signal(82); let title = signal("Battery"); </script>"#,
4583        )
4584        .unwrap();
4585        let stat = rux_parser::parse_sfc(
4586            r#"<template>
4587                 <view><text>{{ label }}: {{ value }}</text></view>
4588               </template>"#,
4589        )
4590        .unwrap();
4591
4592        let mut components = HashMap::new();
4593        components.insert("stat".to_string(), stat);
4594
4595        let mut engine = Builder::new().build(&main.script).unwrap();
4596        let root = build_styled_tree(&main, &components, &mut engine).unwrap();
4597
4598        // screen → (expanded stat) view → text "Battery: 82"
4599        let view = &root.children[0];
4600        let text = view.children[0].text.as_ref().unwrap();
4601        assert_eq!(text.text, "Battery: 82");
4602    }
4603
4604    // ── Combinators ─────────────────────────────────────────────────────────
4605    //
4606    // These test `matches_chain` directly so both the positive and the negative
4607    // case are asserted: the bug being fixed here made `>`, `+` and `~` behave
4608    // as descendant, i.e. match elements they must NOT match.
4609    use super::{matches_chain, parse_selector, AncNode, ElemDesc, ElemStates};
4610
4611    fn el(spec: &str) -> ElemDesc {
4612        // "tag.class.class#id", tag optional, order flexible enough for tests.
4613        let mut d = ElemDesc {
4614            tag: String::new(),
4615            id: None,
4616            classes: Vec::new(),
4617            role: None,
4618            states: ElemStates::default(),
4619        };
4620        let mut rest = spec;
4621        while let Some(pos) = rest.find(['.', '#']) {
4622            if pos > 0 {
4623                d.tag = rest[..pos].to_string();
4624            }
4625            let marker = rest.as_bytes()[pos];
4626            let after = &rest[pos + 1..];
4627            let end = after.find(['.', '#']).unwrap_or(after.len());
4628            let name = after[..end].to_string();
4629            if marker == b'.' {
4630                d.classes.push(name);
4631            } else {
4632                d.id = Some(name);
4633            }
4634            rest = &after[end..];
4635        }
4636        if !rest.is_empty() && d.tag.is_empty() {
4637            d.tag = rest.to_string();
4638        }
4639        d
4640    }
4641
4642    // ── Accessibility ───────────────────────────────────────────────────────
4643
4644    use super::AccessRole;
4645
4646    fn built(src: &str) -> rux_layout::Node {
4647        let sfc = rux_parser::parse_sfc(src).unwrap();
4648        let mut engine = Builder::new().build(&sfc.script).unwrap();
4649        build_styled_tree(&sfc, &HashMap::new(), &mut engine).unwrap()
4650    }
4651
4652    /// Every control gets a role a screen reader can announce, derived from what
4653    /// it is rather than guessed from how it paints.
4654    #[test]
4655    fn controls_get_their_implicit_roles() {
4656        let root = built(
4657            r#"<template><screen>
4658                 <text>a heading</text>
4659                 <input r-model="name" />
4660                 <input type="textarea" r-model="notes" />
4661                 <input type="checkbox" r-model="agree" />
4662                 <input type="radio" r-model="plan" value="pro" />
4663                 <view @tap="n = n + 1"><text>Save</text></view>
4664                 <image src="logo.png" alt="the logo" />
4665               </screen></template>
4666               <script>let name = signal(""); let notes = signal(""); let agree = signal(false);
4667                       let plan = signal("free"); let n = signal(0);</script>"#,
4668        );
4669        let roles: Vec<AccessRole> = root.children.iter().map(|c| c.access.role).collect();
4670        assert_eq!(
4671            roles,
4672            vec![
4673                AccessRole::Label,
4674                AccessRole::TextInput,
4675                AccessRole::MultilineTextInput,
4676                AccessRole::CheckBox,
4677                AccessRole::RadioButton,
4678                AccessRole::Button,
4679                AccessRole::Image,
4680            ]
4681        );
4682    }
4683
4684    /// A tappable box is named by the text inside it, so it announces as
4685    /// "Save, button" rather than as an anonymous control.
4686    #[test]
4687    fn a_tappable_box_is_named_by_its_content() {
4688        let root = built(
4689            r#"<template><screen><view @tap="n = n + 1"><text>Save</text></view></screen></template>
4690               <script>let n = signal(0);</script>"#,
4691        );
4692        let button = &root.children[0];
4693        assert_eq!(button.access.role, AccessRole::Button);
4694        assert_eq!(button.access.label.as_deref(), Some("Save"));
4695    }
4696
4697    /// A `for=` label names the control it points at, the same link that already
4698    /// makes the label tappable.
4699    #[test]
4700    fn a_for_label_names_its_control() {
4701        let root = built(
4702            r#"<template><screen>
4703                 <text for="email">Email address</text>
4704                 <input id="email" r-model="email" />
4705               </screen></template>
4706               <script>let email = signal("");</script>"#,
4707        );
4708        let input = &root.children[1];
4709        assert_eq!(input.access.role, AccessRole::TextInput);
4710        assert_eq!(input.access.label.as_deref(), Some("Email address"));
4711    }
4712
4713    /// An explicit `role=` wins over the implicit one, and an authored `label=`
4714    /// wins over content, both are the author being specific.
4715    #[test]
4716    fn explicit_role_and_label_win() {
4717        let root = built(
4718            r#"<template><screen>
4719                 <text role="heading">Dashboard</text>
4720                 <view @tap="n = n + 1" label="Save changes"><text>OK</text></view>
4721               </screen></template>
4722               <script>let n = signal(0);</script>"#,
4723        );
4724        assert_eq!(root.children[0].access.role, AccessRole::Heading);
4725        assert_eq!(root.children[1].access.label.as_deref(), Some("Save changes"));
4726    }
4727
4728    /// A toggle reports its checked state, and reports it *live*, that is what a
4729    /// screen reader announces alongside the name.
4730    #[test]
4731    fn a_toggle_reports_its_checked_state() {
4732        let root = built(
4733            r#"<template><screen>
4734                 <input type="checkbox" r-model="on" />
4735                 <input type="checkbox" r-model="off" />
4736               </screen></template>
4737               <script>let on = signal(true); let off = signal(false);</script>"#,
4738        );
4739        assert_eq!(root.children[0].access.checked, Some(true));
4740        assert_eq!(root.children[1].access.checked, Some(false));
4741    }
4742
4743    /// An input exposes its value, but a placeholder is a hint, not content, so
4744    /// it names the field instead of pretending to be what's typed in it.
4745    #[test]
4746    fn an_input_exposes_value_but_not_its_placeholder_as_value() {
4747        let root = built(
4748            r#"<template><screen>
4749                 <input r-model="name" placeholder="Your name" />
4750                 <input r-model="city" placeholder="Your city" />
4751               </screen></template>
4752               <script>let name = signal("Ada"); let city = signal("");</script>"#,
4753        );
4754        let filled = &root.children[0];
4755        assert_eq!(filled.access.value.as_deref(), Some("Ada"));
4756        assert_eq!(
4757            filled.access.name(),
4758            Some("Your name"),
4759            "an unlabelled field falls back to its placeholder for a name"
4760        );
4761
4762        let empty = &root.children[1];
4763        assert_eq!(empty.access.value, None, "an empty field has no value");
4764        assert_eq!(empty.access.name(), Some("Your city"));
4765    }
4766
4767    /// A real label outranks a placeholder: the placeholder is only the fallback
4768    /// name for a field nobody labelled.
4769    #[test]
4770    fn a_for_label_outranks_a_placeholder() {
4771        let root = built(
4772            r#"<template><screen>
4773                 <text for="notes">Notes</text>
4774                 <input id="notes" r-model="notes" placeholder="Type a few lines…" />
4775               </screen></template>
4776               <script>let notes = signal("");</script>"#,
4777        );
4778        let input = &root.children[1];
4779        assert_eq!(input.access.name(), Some("Notes"), "the label wins");
4780        assert_eq!(
4781            input.access.placeholder.as_deref(),
4782            Some("Type a few lines…"),
4783            "the placeholder is still available as a hint"
4784        );
4785    }
4786
4787    /// Plain layout boxes stay out of the tree, an assistive tree full of
4788    /// anonymous groups is worse than a short one.
4789    #[test]
4790    fn plain_boxes_are_not_exposed() {
4791        let root = built(
4792            r#"<template><screen><view class="row"><view class="col" /></view></screen></template>"#,
4793        );
4794        assert_eq!(root.children[0].access.role, AccessRole::None);
4795        assert_eq!(root.children[0].children[0].access.role, AccessRole::None);
4796        assert!(!AccessRole::None.is_meaningful());
4797    }
4798
4799    // ── @media ──────────────────────────────────────────────────────────────
4800
4801    use super::{media_matches, parse_rules, InteractionState, Viewport};
4802
4803    fn vp(width: f32, height: f32) -> Viewport {
4804        Viewport { width, height }
4805    }
4806
4807    /// Build at a given viewport and report the target's background.
4808    fn bg_at_vp(src: &str, viewport: Viewport) -> Option<Background> {
4809        let sfc = rux_parser::parse_sfc(src).unwrap();
4810        let mut engine = Builder::new().build(&sfc.script).unwrap();
4811        let mut instances = super::Instances::new();
4812        let root = super::build_styled_tree_stateful(
4813            &sfc,
4814            &HashMap::new(),
4815            &mut engine,
4816            &mut instances,
4817            &InteractionState::default(),
4818            viewport,
4819        )
4820        .unwrap();
4821        root.0.children[0].style.background.clone()
4822    }
4823
4824    const MEDIA_DOC: &str = r#"<template><screen><view class="target" /></screen></template>
4825        <style>
4826          .target { background: #00ff00; }
4827          @media (max-width: 600px) { .target { background: #ff0000; } }
4828        </style>"#;
4829
4830    /// The rules inside a matching `@media` apply; outside it they don't exist.
4831    #[test]
4832    fn media_query_gates_its_rules_on_the_viewport() {
4833        assert!(is_red(&bg_at_vp(MEDIA_DOC, vp(480.0, 800.0))), "narrow → the @media rule");
4834        let wide = bg_at_vp(MEDIA_DOC, vp(1200.0, 800.0));
4835        assert!(
4836            matches!(&wide, Some(Background::Color(c)) if c.g == 1.0),
4837            "wide → the base rule, as if the block weren't there"
4838        );
4839    }
4840
4841    /// A media rule beats an equally-specific earlier rule by source order, and
4842    /// loses to a more specific one, media adds no specificity, as in CSS.
4843    #[test]
4844    fn media_rules_cascade_by_order_not_by_being_in_a_block() {
4845        let src = r#"<template><screen><view class="target" id="t" /></screen></template>
4846            <style>
4847              #t { background: #00ff00; }
4848              @media (max-width: 600px) { .target { background: #ff0000; } }
4849            </style>"#;
4850        let narrow = bg_at_vp(src, vp(480.0, 800.0));
4851        assert!(
4852            matches!(&narrow, Some(Background::Color(c)) if c.g == 1.0),
4853            "#id still beats a .class inside @media"
4854        );
4855    }
4856
4857    /// `and`, comma alternatives, orientation, and a media type all evaluate.
4858    #[test]
4859    fn media_conditions_evaluate() {
4860        let and = r#"<template><screen><view class="target" /></screen></template>
4861            <style>@media screen and (min-width: 400px) and (max-width: 600px) {
4862              .target { background: #ff0000; } }</style>"#;
4863        assert!(is_red(&bg_at_vp(and, vp(500.0, 800.0))), "inside the band");
4864        assert!(bg_at_vp(and, vp(700.0, 800.0)).is_none(), "outside the band");
4865
4866        let either = r#"<template><screen><view class="target" /></screen></template>
4867            <style>@media (max-width: 400px), (min-width: 1000px) {
4868              .target { background: #ff0000; } }</style>"#;
4869        assert!(is_red(&bg_at_vp(either, vp(300.0, 800.0))), "first alternative");
4870        assert!(is_red(&bg_at_vp(either, vp(1200.0, 800.0))), "second alternative");
4871        assert!(bg_at_vp(either, vp(600.0, 800.0)).is_none(), "neither");
4872
4873        let portrait = r#"<template><screen><view class="target" /></screen></template>
4874            <style>@media (orientation: portrait) { .target { background: #ff0000; } }</style>"#;
4875        assert!(is_red(&bg_at_vp(portrait, vp(400.0, 800.0))), "taller than wide");
4876        assert!(bg_at_vp(portrait, vp(800.0, 400.0)).is_none(), "wider than tall");
4877    }
4878
4879    /// An unsupported condition hides its rules rather than applying them, the
4880    /// same fail-closed rule as an unknown pseudo-class.
4881    #[test]
4882    fn unsupported_media_condition_never_applies() {
4883        let src = r#"<template><screen><view class="target" /></screen></template>
4884            <style>@media (min-resolution: 2dppx) { .target { background: #ff0000; } }</style>"#;
4885        assert!(bg_at_vp(src, vp(800.0, 600.0)).is_none());
4886    }
4887
4888    /// `media_matches` is what lets the runtime skip work on a resize that crosses
4889    /// no breakpoint: same answers, no re-cascade.
4890    #[test]
4891    fn media_matches_reports_each_block() {
4892        let css = "@media (max-width: 600px) { .a { color: red } } \
4893                   @media (min-width: 1000px) { .b { color: red } }";
4894        assert_eq!(media_matches(css, vp(500.0, 800.0)), vec![true, false]);
4895        assert_eq!(media_matches(css, vp(800.0, 800.0)), vec![false, false]);
4896        assert_eq!(media_matches(css, vp(1200.0, 800.0)), vec![false, true]);
4897        // Two sizes on the same side of every breakpoint look identical, which is
4898        // exactly the "don't rebuild" signal.
4899        assert_eq!(media_matches(css, vp(700.0, 800.0)), media_matches(css, vp(900.0, 800.0)));
4900        assert!(media_matches(".a { color: red }", vp(800.0, 600.0)).is_empty());
4901    }
4902
4903    /// A rule outside any block is unaffected by the viewport.
4904    #[test]
4905    fn plain_rules_are_viewport_independent() {
4906        let css = ".a { color: red }";
4907        assert_eq!(parse_rules(css, vp(320.0, 480.0)).len(), parse_rules(css, vp(1600.0, 900.0)).len());
4908    }
4909
4910    // ── Custom properties + var() ───────────────────────────────────────────
4911
4912    use super::{Background, Vars};
4913
4914    /// Build a document and return the background of the node at `path`.
4915    fn bg_at(src: &str, path: &[usize]) -> Option<Background> {
4916        let sfc = rux_parser::parse_sfc(src).unwrap();
4917        let mut engine = Builder::new().build(&sfc.script).unwrap();
4918        let root = build_styled_tree(&sfc, &HashMap::new(), &mut engine).unwrap();
4919        let mut node = &root;
4920        for i in path {
4921            node = &node.children[*i];
4922        }
4923        node.style.background.clone()
4924    }
4925
4926    fn is_red(bg: &Option<Background>) -> bool {
4927        matches!(bg, Some(Background::Color(c)) if c.r == 1.0 && c.g == 0.0 && c.b == 0.0)
4928    }
4929
4930    /// A variable declared on an ancestor is visible to `var()` far below it,
4931    /// the whole point of a palette declared once at the root.
4932    #[test]
4933    fn custom_property_inherits_down_the_tree() {
4934        let bg = bg_at(
4935            r#"<template><screen class="app"><view><view class="target" /></view></screen></template>
4936               <style>
4937                 .app { --brand: #ff0000; }
4938                 .target { background: var(--brand); }
4939               </style>"#,
4940            &[0, 0],
4941        );
4942        assert!(is_red(&bg), "var() resolved from an ancestor's declaration");
4943    }
4944
4945    /// A nearer declaration wins over a farther one, and only within its subtree.
4946    #[test]
4947    fn nearer_declaration_shadows_the_inherited_one() {
4948        let src = r#"<template><screen class="app">
4949                       <view class="panel"><view class="target" /></view>
4950                       <view><view class="target" /></view>
4951                     </screen></template>
4952                     <style>
4953                       .app { --brand: #00ff00; }
4954                       .panel { --brand: #ff0000; }
4955                       .target { background: var(--brand); }
4956                     </style>"#;
4957        assert!(is_red(&bg_at(src, &[0, 0])), "inside .panel the nearer value wins");
4958        let outside = bg_at(src, &[1, 0]);
4959        assert!(
4960            matches!(&outside, Some(Background::Color(c)) if c.g == 1.0),
4961            "outside .panel the root value still applies, the override didn't leak"
4962        );
4963    }
4964
4965    /// A variable may be defined in terms of another.
4966    #[test]
4967    fn custom_property_can_reference_another() {
4968        let bg = bg_at(
4969            r#"<template><screen class="app"><view class="target" /></screen></template>
4970               <style>
4971                 .app { --red: #ff0000; --brand: var(--red); }
4972                 .target { background: var(--brand); }
4973               </style>"#,
4974            &[0],
4975        );
4976        assert!(is_red(&bg));
4977    }
4978
4979    /// An undefined variable falls back when given one, including a fallback that
4980    /// itself contains parentheses.
4981    #[test]
4982    fn var_falls_back_when_undefined() {
4983        let bg = bg_at(
4984            r#"<template><screen><view class="target" /></screen></template>
4985               <style>.target { background: var(--nope, #ff0000); }</style>"#,
4986            &[0],
4987        );
4988        assert!(is_red(&bg), "the fallback is used");
4989
4990        let bg = bg_at(
4991            r#"<template><screen><view class="target" /></screen></template>
4992               <style>.target { background: var(--nope, rgb(255, 0, 0)); }</style>"#,
4993            &[0],
4994        );
4995        assert!(is_red(&bg), "a fallback with its own parens survives");
4996    }
4997
4998    /// An undefined variable with no fallback leaves the declaration invalid, so
4999    /// it is dropped, it must not paint something arbitrary.
5000    #[test]
5001    fn undefined_var_without_fallback_drops_the_declaration() {
5002        let bg = bg_at(
5003            r#"<template><screen><view class="target" /></screen></template>
5004               <style>.target { background: var(--nope); }</style>"#,
5005            &[0],
5006        );
5007        assert!(bg.is_none(), "no background, rather than a wrong one");
5008    }
5009
5010    /// A cycle must terminate rather than hang.
5011    #[test]
5012    fn circular_variables_terminate() {
5013        let bg = bg_at(
5014            r#"<template><screen class="app"><view class="target" /></screen></template>
5015               <style>
5016                 .app { --a: var(--b); --b: var(--a); }
5017                 .target { background: var(--a); }
5018               </style>"#,
5019            &[0],
5020        );
5021        assert!(bg.is_none(), "a cycle resolves to nothing, and returns");
5022    }
5023
5024    /// `var()` works in inline `style=` too, since substitution happens after the
5025    /// cascade and inline styles are merged.
5026    #[test]
5027    fn var_resolves_in_inline_style() {
5028        let bg = bg_at(
5029            r#"<template><screen class="app"><view style="background: var(--brand)" /></screen></template>
5030               <style>.app { --brand: #ff0000; }</style>"#,
5031            &[0],
5032        );
5033        assert!(is_red(&bg));
5034    }
5035
5036    /// A custom property is not a real property: it must not reach `interpret`,
5037    /// and must not be reported as an unhonored one.
5038    #[test]
5039    fn custom_property_is_not_treated_as_a_property() {
5040        assert!(!super::is_honored("--brand"));
5041        let mut props: HashMap<String, String> = HashMap::new();
5042        props.insert("--brand".into(), "#ff0000".into());
5043        props.insert("background".into(), "var(--brand)".into());
5044        let vars = super::take_vars(&mut props, &Vars::default());
5045        assert!(!props.contains_key("--brand"), "stripped out of the property map");
5046        assert_eq!(vars.get("--brand").map(String::as_str), Some("#ff0000"));
5047    }
5048
5049    // ── Pseudo-classes ──────────────────────────────────────────────────────
5050    //
5051    // The negative case matters most here. Before pseudo-classes existed,
5052    // `parse_compound` stopped at the `:` and threw it away, so `.box:hover`
5053    // parsed as `.box` and matched *always*. Every test below that asserts a
5054    // rule does NOT match is guarding that regression.
5055
5056    /// `selector` against an element with the given states and no ancestors.
5057    fn hits_state(selector: &str, target: &str, states: ElemStates) -> bool {
5058        let (chain, combs, _) = parse_selector(selector).expect("selector parses");
5059        let mut d = el(target);
5060        d.states = states;
5061        matches_chain(&chain, &combs, &d, &[], &[])
5062    }
5063
5064    fn hovered() -> ElemStates {
5065        ElemStates { hover: true, ..ElemStates::default() }
5066    }
5067
5068    #[test]
5069    fn pseudo_class_matches_only_in_that_state() {
5070        assert!(hits_state(".box:hover", ".box", hovered()));
5071        assert!(
5072            !hits_state(".box:hover", ".box", ElemStates::default()),
5073            "an unhovered element must NOT match :hover (it used to match always)"
5074        );
5075        // The un-suffixed rule still matches in either state.
5076        assert!(hits_state(".box", ".box", hovered()));
5077    }
5078
5079    #[test]
5080    fn each_pseudo_reads_its_own_state() {
5081        let s = ElemStates {
5082            hover: false,
5083            focus: true,
5084            active: false,
5085            checked: true,
5086            current: false,
5087        };
5088        assert!(hits_state("input:focus", "input", s));
5089        assert!(hits_state("input:checked", "input", s));
5090        assert!(!hits_state("input:hover", "input", s));
5091        assert!(!hits_state("input:active", "input", s));
5092        assert!(!hits_state("input:current", "input", s));
5093    }
5094
5095    #[test]
5096    fn stacked_pseudos_all_have_to_hold() {
5097        let hover_only = hovered();
5098        let both = ElemStates { hover: true, active: true, ..ElemStates::default() };
5099        assert!(!hits_state(".btn:hover:active", ".btn", hover_only));
5100        assert!(hits_state(".btn:hover:active", ".btn", both));
5101    }
5102
5103    /// An unsupported pseudo-class fails *closed*, the rule never matches,
5104    /// rather than being dropped and matching everything.
5105    #[test]
5106    fn unknown_pseudo_never_matches() {
5107        let all_on = ElemStates {
5108            hover: true,
5109            focus: true,
5110            active: true,
5111            checked: true,
5112            current: true,
5113        };
5114        assert!(!hits_state(".box:disabled", ".box", all_on));
5115        assert!(!hits_state(".box:nth-child(2)", ".box", all_on));
5116        assert!(!hits_state(".box::selection", ".box", all_on));
5117    }
5118
5119    /// A pseudo-class carries class-level specificity, so `.box:hover` beats
5120    /// `.box` regardless of source order.
5121    #[test]
5122    fn pseudo_class_adds_class_specificity() {
5123        let (_, _, plain) = parse_selector(".box").unwrap();
5124        let (_, _, with_pseudo) = parse_selector(".box:hover").unwrap();
5125        assert_eq!(plain, (0, 1, 0));
5126        assert_eq!(with_pseudo, (0, 2, 0));
5127        assert!(with_pseudo > plain);
5128    }
5129
5130    /// A pseudo-class sits inside one compound, it must not be mistaken for a
5131    /// new compound or split the selector.
5132    #[test]
5133    fn pseudo_class_stays_within_its_compound() {
5134        let (chain, combs, _) = parse_selector(".card > .btn:hover").unwrap();
5135        assert_eq!(chain.len(), 2, "two compounds, not three");
5136        assert_eq!(combs.len(), 1);
5137        // The state is on the *right* compound: a hovered .btn inside .card.
5138        let hover = hovered();
5139        let mut btn = el(".btn");
5140        btn.states = hover;
5141        let card = anc(".card", &[]);
5142        assert!(matches_chain(&chain, &combs, &btn, &[card.clone()], &[]));
5143        let plain_btn = el(".btn");
5144        assert!(!matches_chain(&chain, &combs, &plain_btn, &[card], &[]));
5145    }
5146
5147    /// End-to-end: a ticked checkbox is styled by `:checked` through the real
5148    /// cascade, and an unticked one is not.
5149    #[test]
5150    fn checked_pseudo_styles_a_ticked_toggle() {
5151        let src = r#"
5152            <template>
5153              <screen>
5154                <input type="checkbox" class="box" r-model="on" />
5155                <input type="checkbox" class="box" r-model="off" />
5156              </screen>
5157            </template>
5158            <style>
5159              .box { background: #000000; }
5160              .box:checked { background: #00ff00; }
5161            </style>
5162            <script> let on = signal(true); let off = signal(false); </script>
5163        "#;
5164        let sfc = rux_parser::parse_sfc(src).unwrap();
5165        let mut engine = Builder::new().build(&sfc.script).unwrap();
5166        let root = build_styled_tree(&sfc, &HashMap::new(), &mut engine).unwrap();
5167
5168        let green = |n: &rux_layout::Node| {
5169            matches!(&n.style.background, Some(rux_layout::Background::Color(c)) if c.g == 1.0)
5170        };
5171        assert!(green(&root.children[0]), "ticked box matches .box:checked");
5172        assert!(!green(&root.children[1]), "unticked box does not");
5173    }
5174
5175    fn anc(spec: &str, prev: &[&str]) -> AncNode {
5176        AncNode { desc: el(spec), prev: prev.iter().map(|s| el(s)).collect() }
5177    }
5178
5179    /// `selector` against element `target` with the given ancestor chain
5180    /// (root-first) and preceding siblings (document order).
5181    fn hits(selector: &str, target: &str, ancestors: &[AncNode], prev: &[&str]) -> bool {
5182        let (chain, combs, _) = parse_selector(selector).expect("selector parses");
5183        let prev: Vec<ElemDesc> = prev.iter().map(|s| el(s)).collect();
5184        matches_chain(&chain, &combs, &el(target), ancestors, &prev)
5185    }
5186
5187    #[test]
5188    fn lightningcss_serialization_round_trips_to_our_combinators() {
5189        // Guards the seam between lightningcss's selector serialization and our
5190        // `parse_selector`: if that serialization ever changes shape, this catches
5191        // it before it silently degrades matching back to descendant-only.
5192        use super::{parse_rules, Combinator};
5193        let css = ".card > text { color: #111 } .a + .b { color: #222 } .a ~ .b { color: #333 }";
5194        let rules = parse_rules(css, Viewport::default());
5195        let combs: Vec<&[Combinator]> = rules.iter().map(|r| r.combs.as_slice()).collect();
5196        assert_eq!(combs[0], &[Combinator::Child]);
5197        assert_eq!(combs[1], &[Combinator::NextSibling]);
5198        assert_eq!(combs[2], &[Combinator::SubsequentSibling]);
5199    }
5200
5201    #[test]
5202    fn child_combinator_styles_the_right_element_end_to_end() {
5203        // `> text` must reach the direct child, not the grandchild. Before the
5204        // fix both were colored; now only the direct child is.
5205        // `#080808` is used because lightningcss minifies e.g. `#ff0000` to the
5206        // keyword `red`, which `parse_color` doesn't (yet) resolve; this hex has
5207        // no shorter form and survives serialization unchanged.
5208        let src = r#"
5209            <template>
5210              <screen>
5211                <text>direct</text>
5212                <view><text>nested</text></view>
5213              </screen>
5214            </template>
5215            <style>
5216              screen > text { color: #080808 }
5217            </style>
5218        "#;
5219        let sfc = rux_parser::parse_sfc(src).unwrap();
5220        let mut engine = Builder::new().build("").unwrap();
5221        let root = build_styled_tree(&sfc, &HashMap::new(), &mut engine).unwrap();
5222
5223        let direct = root.children[0].text.as_ref().unwrap();
5224        let nested = root.children[1].children[0].text.as_ref().unwrap();
5225        assert!(direct.color.r < 0.1, "direct child of screen got the #080808 color");
5226        assert!(nested.color.r > 0.5, "grandchild is NOT matched by `screen > text`");
5227    }
5228
5229    #[test]
5230    fn child_combinator_only_matches_direct_children() {
5231        // The bug's own example: `.card > text` must select a text that is a
5232        // direct child of `.card`, and must NOT select one nested a level deeper.
5233        assert!(hits("*.card > text", "text", &[anc("view.card", &[])], &[]));
5234        assert!(!hits(
5235            "*.card > text",
5236            "text",
5237            &[anc("view.card", &[]), anc("view.inner", &[])],
5238            &[],
5239        ));
5240        // Descendant (`.card text`) still matches the nested one, the control.
5241        assert!(hits(
5242            "*.card text",
5243            "text",
5244            &[anc("view.card", &[]), anc("view.inner", &[])],
5245            &[],
5246        ));
5247    }
5248
5249    #[test]
5250    fn next_sibling_combinator_needs_immediate_predecessor() {
5251        // `.a + .b`: matches only when `.a` is the element right before `.b`.
5252        assert!(hits("*.a + *.b", "view.b", &[], &["view.a"]));
5253        assert!(hits("*.a + *.b", "view.b", &[], &["view.x", "view.a"]));
5254        // `.a` present but not immediately before → no match (was matched by bug).
5255        assert!(!hits("*.a + *.b", "view.b", &[], &["view.a", "view.x"]));
5256        assert!(!hits("*.a + *.b", "view.b", &[], &[]));
5257    }
5258
5259    #[test]
5260    fn subsequent_sibling_combinator_matches_any_earlier_sibling() {
5261        // `.a ~ .b`: any preceding sibling `.a`, not just the immediate one.
5262        assert!(hits("*.a ~ *.b", "view.b", &[], &["view.a", "view.x"]));
5263        assert!(hits("*.a ~ *.b", "view.b", &[], &["view.a"]));
5264        assert!(!hits("*.a ~ *.b", "view.b", &[], &["view.x"]));
5265    }
5266
5267    #[test]
5268    fn combinators_compose() {
5269        // `.card > .a + .b`: `.b` is a child of `.card`, right after sibling `.a`.
5270        let ancestors = [anc("view.card", &[])];
5271        assert!(hits("*.card > *.a + *.b", "view.b", &ancestors, &["view.a"]));
5272        // A sibling combinator sitting above a descendant hop resolves via the
5273        // ancestor's own preceding siblings: `.a ~ .b .c`.
5274        let ancestors = [anc("view.b", &["view.a"])];
5275        assert!(hits("*.a ~ *.b *.c", "view.c", &ancestors, &[]));
5276        // …and fails when that ancestor has no preceding `.a`.
5277        let ancestors = [anc("view.b", &["view.x"])];
5278        assert!(!hits("*.a ~ *.b *.c", "view.c", &ancestors, &[]));
5279    }
5280}
5281
5282fn parse_rgb(s: &str) -> Option<Rgba> {
5283    let inner = s.trim_start_matches("rgba").trim_start_matches("rgb");
5284    let inner = inner.trim().trim_start_matches('(').trim_end_matches(')');
5285    let parts: Vec<&str> = inner.split([',', ' ', '/']).filter(|p| !p.is_empty()).collect();
5286    if parts.len() < 3 {
5287        return None;
5288    }
5289    let r = parts[0].parse::<f32>().ok()? / 255.0;
5290    let g = parts[1].parse::<f32>().ok()? / 255.0;
5291    let b = parts[2].parse::<f32>().ok()? / 255.0;
5292    let a = parts.get(3).and_then(|v| v.parse::<f32>().ok()).unwrap_or(1.0);
5293    Some(Rgba::new(r, g, b, a))
5294}