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