Skip to main content

lumen_ir/
css.rs

1//! CSS data model (AST) + Cascade-5 application, extracted from `lumenc`'s
2//! `parse::css` front-end so the parser-free runtime and every other IR
3//! consumer share the same cascade without pulling in the markup parser.
4//!
5//! The hand-rolled front-end (`parse_css` and its lexer) still lives in
6//! `lumenc::parse::css` and produces the [`Stylesheet`] type defined here;
7//! this crate owns the *data* + the code that *applies* it.
8//!
9//! ## Cascade ordering
10//!
11//! Per CSS Cascade-5 section 6.4: origin -> importance -> specificity -> source
12//! order, and **later** rules win at equal weight (section 6.4.4). Within
13//! [`apply_css`], HTML inline attrs (origin: inline) beat CSS attrs
14//! (origin: user/UA) - preserving the long-standing rule that
15//! `<tile width="50px"/>` overrides `.t { width: 100px }`. Inline
16//! `!important` is not authorable; user `!important` lifts a CSS
17//! declaration above its origin's normal block.
18
19use crate::layout_ir::{
20    Attributes, BgSpec, DisplaySpec, EasingIr, Edges, Element, FlexAlign, FlexJustify, LayoutIR,
21    LengthSpec, LineHeightSpec, OverflowSpec, ParseError, PositionSpec, Rgba, ScrollAxisSpec,
22    ScrollbarWidthSpec, ShadowSpec, TextAlignSpec, TextWrapSpec, TrackSizeSpec, TransitionIr,
23    TransitionPropertyIr,
24};
25use crate::values::{
26    bad, parse_bg, parse_color, parse_edges, parse_f32, parse_i32, parse_inset_edges,
27    parse_inset_term, parse_length,
28};
29use std::rc::Rc;
30// ---------------------------------------------------------------------------
31// Public AST
32// ---------------------------------------------------------------------------
33
34/// Synthetic CSS source for the built-in [`lumen_core::palette::Palette`]
35/// light/dark theme: a base `:root` block from `Palette::adwaita_light`
36/// plus its differences wrapped in `@media (prefers-color-scheme: dark)`
37/// from `Palette::adwaita_dark` - the same light-first-with-dark-override
38/// shape every shipped skin file uses (`core/runtime/src/skins/*.css`).
39///
40/// Shared by both compile paths that build a combined stylesheet
41/// (`lumen_runtime::run::loading::load_ir` and `lumenc::compile::compile_dir`)
42/// so the two can never drift: each parses this once via its own front-end
43/// and prepends the result to its cascade, at the lowest precedence -
44/// beneath the always-on UA baseline, beneath any opted-in skin, and
45/// beneath the app's own `main.css`. Sorted so the generated source (and
46/// therefore `source_order`) is deterministic across runs.
47///
48/// This is a snapshot, not a live source: nothing re-runs the cascade when
49/// a `Palette` value changes later. See [`lumen_core::palette`]'s module
50/// doc comment for why (parity with the precompiled-artifact run path,
51/// which ships with no CSS parser to re-run this against).
52pub fn palette_root_css() -> String {
53    use lumen_core::palette::Palette;
54    let mut light: Vec<(String, String)> =
55        Palette::adwaita_light().root_vars().into_iter().collect();
56    light.sort();
57    let mut dark: Vec<(String, String)> = Palette::adwaita_dark().root_vars().into_iter().collect();
58    dark.sort();
59    let mut out = String::from(":root {\n");
60    for (name, value) in &light {
61        out.push_str(&format!("  --{name}: {value};\n"));
62    }
63    out.push_str("}\n@media (prefers-color-scheme: dark) {\n  :root {\n");
64    for (name, value) in &dark {
65        out.push_str(&format!("    --{name}: {value};\n"));
66    }
67    out.push_str("  }\n}\n");
68    out
69}
70
71/// An at-rule the cascade does not implement, kept as authored for a
72/// consumer that does. The desktop cascade reads none of these; the web
73/// target writes them back out.
74#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
75pub struct AtRule {
76    /// Lowercased, without the `@`: `keyframes`, `font-face`.
77    pub name: String,
78    /// Everything between the name and the `{`, trimmed: `spin`. Empty for
79    /// an at-rule that takes no prelude, such as `@font-face`.
80    pub prelude: String,
81    /// Everything between the braces, verbatim.
82    pub body: String,
83    /// The `@media` block this was written inside, if any.
84    pub media: Option<MediaQuery>,
85}
86
87/// At-rule names a [`Stylesheet`] carries verbatim instead of dropping.
88/// Every other at-rule warns and is skipped.
89///
90/// A carried body is written in browser CSS and read by nothing here, so a
91/// name belongs on this list only when Lumen reads no property inside it
92/// differently from the way a browser does.
93pub const CARRIED_AT_RULES: &[&str] = &["keyframes", "font-face"];
94
95/// A parsed stylesheet.
96#[derive(Debug, Default, Clone, serde::Serialize, serde::Deserialize)]
97pub struct Stylesheet {
98    /// Rules in source order. Each rule may live inside a `@media`
99    /// block (recorded via [`Rule::media`]).
100    pub rules: Vec<Rule>,
101    /// At-rules the cascade does not implement, in source order. See
102    /// [`CARRIED_AT_RULES`] for which ones are kept.
103    pub at_rules: Vec<AtRule>,
104}
105
106impl Stylesheet {
107    /// Returns the union of class names appearing in any selector.
108    /// The runtime class-change reapply path uses this as a fast-rejection
109    /// set: when none of the changed classes are in the returned set, the
110    /// respawn is skipped.
111    pub fn class_invalidation_set(&self) -> std::collections::HashSet<String> {
112        let mut set = std::collections::HashSet::new();
113        for rule in &self.rules {
114            for sel in &rule.selectors {
115                sel.collect_classes(&mut set);
116            }
117        }
118        set
119    }
120
121    /// Whether any selector steps across siblings (`+` or `~`), at the top
122    /// level or inside `:is()` / `:where()` / `:not()`. Only then does a
123    /// walk that re-resolves elements need to hand each one its siblings.
124    pub fn uses_sibling_combinators(&self) -> bool {
125        fn walk(sel: &SelectorBuf) -> bool {
126            sel.chain.iter().any(|(link, compound)| {
127                matches!(
128                    link,
129                    Combinator::AdjacentSibling | Combinator::GeneralSibling
130                ) || compound.pseudo_classes.iter().any(|p| match p {
131                    PseudoClass::Is(args) | PseudoClass::Where(args) | PseudoClass::Not(args) => {
132                        args.iter().any(walk)
133                    }
134                    _ => false,
135                })
136            })
137        }
138        self.rules.iter().flat_map(|r| &r.selectors).any(walk)
139    }
140
141    /// Every top-level `:root { --name: value }` custom-property
142    /// declaration in the stylesheet, folded in rule order (last wins).
143    /// Structural variants (`:root.dark { ... }`) are excluded - only the
144    /// plain, unconditional `:root` selector counts - so the result never
145    /// depends on runtime class state. That is what a caller resolving
146    /// `var()` before any element exists to run a real cascade against
147    /// needs: parse-time inline-attribute substitution, and the window's
148    /// initial GPU clear color.
149    ///
150    /// A `:root` rule inside `@media (...)` is excluded entirely, not
151    /// folded in regardless of the query: there is no live `MediaContext`
152    /// yet at this point (before any element, let alone a window, exists),
153    /// so the honest answer is "unknown", under which no query matches -
154    /// matching how the rest of the pre-window initial load already treats
155    /// an unresolved `MediaContext` (`lumen_runtime::run::app_build`'s own
156    /// comment: "no real OS theme / viewport yet ... apply with the
157    /// best-guess default context"). Picking whichever `@media` variant
158    /// happens to sort last in the source (typically the dark override,
159    /// since skins write light-first) would silently prefer dark
160    /// regardless of the real OS preference. The real per-element cascade
161    /// (`apply_to_element`) is what actually honours `@media`, once
162    /// elements exist for it to re-resolve against the live context.
163    pub fn root_vars(&self) -> std::collections::HashMap<String, String> {
164        let mut out = std::collections::HashMap::new();
165        for rule in &self.rules {
166            // Match the `:root` pseudo-class, not the `root` element. They
167            // are different selectors and both appear in every skin: `:root`
168            // carries the custom properties, `root { }` styles the `<root>`
169            // tag. Testing the tag name matched the element rule, which has
170            // no custom properties, so this returned nothing at all for
171            // every shipped skin.
172            let is_root_pseudo = rule.selectors.iter().any(|sel| {
173                sel.chain.len() == 1
174                    && sel.chain.iter().all(|(_, c)| {
175                        c.tag.is_none()
176                            && c.id.is_none()
177                            && c.classes.is_empty()
178                            && c.pseudo_classes.len() == 1
179                            && matches!(c.pseudo_classes[0], PseudoClass::Root)
180                    })
181            });
182            if rule.media.is_some() || !is_root_pseudo {
183                continue;
184            }
185            for decl in &rule.declarations {
186                if let Some(name) = decl.name.strip_prefix("--") {
187                    out.insert(name.to_string(), decl.value.clone());
188                }
189            }
190        }
191        out
192    }
193
194    /// Resolve a single `:root` custom property to its fully-substituted
195    /// value (following any nested `var()` chain via [`crate::css_vars`]),
196    /// or `None` when no rule in the stylesheet defines it. See
197    /// [`Self::root_vars`] for what counts as a defining rule.
198    pub fn resolve_root_var(&self, name: &str) -> Option<String> {
199        let vars = self.root_vars();
200        crate::css_vars::resolve(vars.get(name)?, &vars, "").ok()
201    }
202}
203
204/// Cascade origin per CSS Cascade 4 section 6.1. Lumen models the two
205/// origins it actually mixes: the built-in skin sheet ships as the
206/// **user-agent** origin, and the app's own CSS is the **author**
207/// origin. For normal (non-`!important`) declarations author beats
208/// user-agent regardless of specificity, so the variants are ordered
209/// `UserAgent < Author` and the cascade sort keys on origin first.
210///
211/// (Lumen has no user origin, and `!important` origin inversion is not
212/// modelled here.)
213#[derive(
214    Debug,
215    Default,
216    Clone,
217    Copy,
218    PartialEq,
219    Eq,
220    PartialOrd,
221    Ord,
222    Hash,
223    serde::Serialize,
224    serde::Deserialize,
225)]
226pub enum Origin {
227    /// Built-in skin sheet. Loses to author CSS for normal declarations.
228    UserAgent,
229    /// The app's own CSS. Wins over the skin for normal declarations.
230    #[default]
231    Author,
232}
233
234/// One CSS rule (one `selector_list { decl* }` block).
235#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
236pub struct Rule {
237    /// Compound selector list (each entry is one selector in the
238    /// comma-separated list).
239    pub selectors: Vec<SelectorBuf>,
240    /// `property: value` pairs in source order, with `!important`
241    /// resolved at parse time.
242    pub declarations: Vec<Declaration>,
243    /// Cascade origin. Defaults to [`Origin::Author`]; the runtime tags
244    /// the built-in skin sheet as [`Origin::UserAgent`] before the
245    /// combined cascade pass. The first term of the cascade sort.
246    #[serde(default)]
247    pub origin: Origin,
248    /// Source ordinal across the stylesheet (the last tiebreaker in
249    /// the cascade sort).
250    pub source_order: usize,
251    /// `@media (...)` enclosing query, if any.
252    pub media: Option<MediaQuery>,
253    /// Back-compat shim that exposes the leftmost compound of the
254    /// first selector under the old single-`Selector` shape - the
255    /// [`Stylesheet::root_vars`] and `StyleInvalidationCache` paths
256    /// still poke at `rule.selector.classes` / `rule.selector.tag`.
257    pub selector: LegacySelectorShim,
258}
259
260/// Back-compat shim - see [`Rule::selector`].
261#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
262pub struct LegacySelectorShim {
263    /// Tag of the leftmost compound in the first selector.
264    pub tag: Option<String>,
265    /// Classes of the leftmost compound in the first selector.
266    pub classes: Vec<String>,
267}
268
269/// One `property: value` pair.
270#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
271pub struct Declaration {
272    /// Property name.
273    pub name: String,
274    /// Raw textual value, trimmed and var()-unresolved.
275    pub value: String,
276    /// `true` when the source ended with `!important`.
277    pub important: bool,
278}
279
280// ---------------------------------------------------------------------------
281// Selector AST
282// ---------------------------------------------------------------------------
283
284/// Compiled selector: a chain of compound selectors joined by combinators.
285///
286/// The chain is stored *left-to-right* in CSS source order - so
287/// `.outer .inner > .x` becomes:
288///
289/// `[(Subject, .outer), (Descendant, .inner), (Child, .x)]`
290///
291/// The *subject* of the selector (the element that the rule applies
292/// to) is the **last** compound in the chain. The first entry's
293/// combinator is always [`Combinator::Subject`] and records that
294/// there is nothing to its left.
295#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
296pub struct SelectorBuf {
297    /// Compound chain in CSS source order (subject is the last entry).
298    pub chain: Vec<(Combinator, CompoundSelector)>,
299}
300
301impl SelectorBuf {
302    /// Specificity per Selectors-4 section 17 - `(a, b, c)` where
303    /// `a` = #ids, `b` = #classes + #pseudo-classes + #attr selectors,
304    /// `c` = #tags + #pseudo-elements.
305    pub fn specificity(&self) -> Specificity {
306        let mut spec = Specificity::default();
307        for (_, c) in &self.chain {
308            spec = spec.add(c.specificity());
309        }
310        spec
311    }
312
313    fn collect_classes(&self, out: &mut std::collections::HashSet<String>) {
314        for (_, c) in &self.chain {
315            c.collect_classes(out);
316        }
317    }
318}
319
320/// `(a, b, c)` triple, lexicographically ordered for cascade sort.
321#[derive(
322    Debug,
323    Default,
324    Clone,
325    Copy,
326    PartialEq,
327    Eq,
328    PartialOrd,
329    Ord,
330    serde::Serialize,
331    serde::Deserialize,
332)]
333pub struct Specificity {
334    /// IDs.
335    pub a: u32,
336    /// Classes + pseudo-classes + attribute selectors.
337    pub b: u32,
338    /// Element types + pseudo-elements.
339    pub c: u32,
340}
341
342impl Specificity {
343    /// Component-wise sum.
344    pub const fn add(self, o: Self) -> Self {
345        Self {
346            a: self.a + o.a,
347            b: self.b + o.b,
348            c: self.c + o.c,
349        }
350    }
351
352    /// Promote to the larger of the two. Used by `:is()` / `:not()`
353    /// argument specificity per Selectors-4 section 17.
354    fn promote(self, o: Self) -> Self {
355        if self > o { self } else { o }
356    }
357}
358
359/// Connector between two adjacent compounds in a selector chain.
360#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
361pub enum Combinator {
362    /// The first compound - nothing to its left.
363    Subject,
364    /// `A B` - descendant.
365    Descendant,
366    /// `A > B` - direct child.
367    Child,
368    /// `A + B` - adjacent sibling.
369    AdjacentSibling,
370    /// `A ~ B` - general sibling.
371    GeneralSibling,
372}
373
374/// One compound selector - `tag#id.cls:pseudo`.
375#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
376pub struct CompoundSelector {
377    /// Tag name. `None` matches any tag (universal `*`).
378    pub tag: Option<String>,
379    /// `#id`.
380    pub id: Option<String>,
381    /// Required class names.
382    pub classes: Vec<String>,
383    /// Required pseudo-classes (each is a [`PseudoClass`]).
384    pub pseudo_classes: Vec<PseudoClass>,
385}
386
387impl CompoundSelector {
388    fn specificity(&self) -> Specificity {
389        let mut spec = Specificity::default();
390        if self.id.is_some() {
391            spec.a += 1;
392        }
393        spec.b += self.classes.len() as u32;
394        for p in &self.pseudo_classes {
395            spec = spec.add(p.specificity());
396        }
397        if self.tag.is_some() {
398            spec.c += 1;
399        }
400        spec
401    }
402
403    fn collect_classes(&self, out: &mut std::collections::HashSet<String>) {
404        for c in &self.classes {
405            out.insert(c.clone());
406        }
407        for p in &self.pseudo_classes {
408            p.collect_classes(out);
409        }
410    }
411
412    /// `true` when the compound has no tag, id, classes, or pseudo-classes
413    /// (the empty universal). The parser front-end uses this to reject
414    /// `.a  .b` with a dangling combinator.
415    pub fn is_empty(&self) -> bool {
416        self.tag.is_none()
417            && self.id.is_none()
418            && self.classes.is_empty()
419            && self.pseudo_classes.is_empty()
420    }
421}
422
423/// All pseudo-classes the engine recognises.
424#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
425pub enum PseudoClass {
426    /// `:hover` - pointer over the element.
427    Hover,
428    /// `:focus` - focus from any input source (pointer or keyboard).
429    Focus,
430    /// `:focus-visible` - keyboard-only focus (Tab / Shift-Tab, roving
431    /// arrows, assistive tech). The runtime tracks how focus arrived
432    /// via the `FocusVisible` marker; pointer clicks focus without it.
433    FocusVisible,
434    /// `:active` - pressed.
435    Active,
436    /// `:disabled` - routes `bg` to the disabled fill; the runtime
437    /// `Disabled` marker gates input.
438    Disabled,
439    /// `:checked` - routes `bg` to the `<toggle>` checked track fill.
440    Checked,
441    /// `:selected` - the active member of a selection group (tab
442    /// strip button). Routes `bg` to `Attributes::selected_bg`;
443    /// `lumen_primitives::tabs::sync_tab_button_visuals` swaps the fill
444    /// at runtime as the `Selected` marker moves between siblings.
445    Selected,
446    /// `:drag-over` - an in-app drag is hovering this drop target with an
447    /// acceptable payload (HTML5 `dragover` parity). The runtime
448    /// `DropHovered` marker, maintained by `lumen-os-dnd`, gates it.
449    DragOver,
450    /// `:root` - matches only the root element.
451    Root,
452    /// `:first-child`.
453    FirstChild,
454    /// `:last-child`.
455    LastChild,
456    /// `:only-child`.
457    OnlyChild,
458    /// `:empty` - no element children, no non-whitespace text.
459    Empty,
460    /// `:nth-child(an+b)`.
461    NthChild(AnB),
462    /// `:is(...)`.
463    Is(Vec<SelectorBuf>),
464    /// `:where(...)`.
465    Where(Vec<SelectorBuf>),
466    /// `:not(...)`.
467    Not(Vec<SelectorBuf>),
468}
469
470impl PseudoClass {
471    fn specificity(&self) -> Specificity {
472        match self {
473            Self::Hover
474            | Self::Focus
475            | Self::FocusVisible
476            | Self::Active
477            | Self::Disabled
478            | Self::Checked
479            | Self::Selected
480            | Self::DragOver
481            | Self::Root
482            | Self::FirstChild
483            | Self::LastChild
484            | Self::OnlyChild
485            | Self::Empty
486            | Self::NthChild(_) => Specificity { a: 0, b: 1, c: 0 },
487            Self::Is(args) | Self::Not(args) => {
488                let mut acc = Specificity::default();
489                for s in args {
490                    acc = acc.promote(s.specificity());
491                }
492                acc
493            }
494            Self::Where(_) => Specificity::default(),
495        }
496    }
497
498    fn collect_classes(&self, out: &mut std::collections::HashSet<String>) {
499        match self {
500            Self::Is(args) | Self::Where(args) | Self::Not(args) => {
501                for s in args {
502                    s.collect_classes(out);
503                }
504            }
505            _ => {}
506        }
507    }
508}
509
510/// `:nth-child(an+b)` coefficients.
511#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
512pub struct AnB {
513    /// `a` (step).
514    pub a: i32,
515    /// `b` (offset).
516    pub b: i32,
517}
518
519impl AnB {
520    /// Test whether the 1-based child index matches `an + b`.
521    pub fn matches(self, index: i32) -> bool {
522        if self.a == 0 {
523            return index == self.b;
524        }
525        // Widen to i64: `index - self.b` overflows when `b == i32::MIN`.
526        let diff = index as i64 - self.b as i64;
527        let a = self.a as i64;
528        if (diff < 0 && a > 0) || (diff > 0 && a < 0) {
529            return false;
530        }
531        diff % a == 0
532    }
533}
534
535// ---------------------------------------------------------------------------
536// @media - MQ-5 subset
537// ---------------------------------------------------------------------------
538
539/// `@media` condition.
540#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
541pub struct MediaQuery {
542    /// `and`-joined feature list.
543    pub features: Vec<MediaFeature>,
544}
545
546/// One `(feature: value)` test inside an `@media` query.
547#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
548pub enum MediaFeature {
549    /// `(prefers-color-scheme: dark | light | no-preference)`.
550    PrefersColorScheme(ColorSchemePreference),
551    /// `(prefers-reduced-motion: reduce | no-preference)`.
552    PrefersReducedMotion(MotionPreference),
553    /// `(prefers-contrast: more | less | custom | no-preference)`.
554    PrefersContrast(ContrastPreference),
555    /// `(min-width: <px>)`.
556    MinWidth(f32),
557    /// `(max-width: <px>)`.
558    MaxWidth(f32),
559    /// `(width: <px>)`.
560    Width(f32),
561}
562
563/// `prefers-color-scheme` value.
564#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
565pub enum ColorSchemePreference {
566    /// `dark`.
567    Dark,
568    /// `light`.
569    Light,
570    /// `no-preference`.
571    NoPreference,
572}
573
574/// `prefers-reduced-motion` value.
575#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
576pub enum MotionPreference {
577    /// `reduce`.
578    Reduce,
579    /// `no-preference`.
580    NoPreference,
581}
582
583/// `prefers-contrast` value.
584#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
585pub enum ContrastPreference {
586    /// `more`.
587    More,
588    /// `less`.
589    Less,
590    /// `custom`.
591    Custom,
592    /// `no-preference`.
593    NoPreference,
594}
595
596/// Runtime view of the OS-level theme + viewport state. Passed to
597/// [`apply_css_with_media`] so `@media` blocks resolve consistently.
598#[derive(Debug, Clone, Copy)]
599pub struct MediaContext {
600    /// OS color scheme. `None` = unknown.
601    pub color_scheme: Option<ColorSchemePreference>,
602    /// User motion preference.
603    pub reduced_motion: MotionPreference,
604    /// User contrast preference.
605    pub contrast: ContrastPreference,
606    /// Viewport width in CSS pixels (used for width / min-width /
607    /// max-width queries). `None` = no width-MQ matches.
608    pub viewport_width: Option<f32>,
609}
610
611impl Default for MediaContext {
612    fn default() -> Self {
613        Self {
614            color_scheme: None,
615            reduced_motion: MotionPreference::NoPreference,
616            contrast: ContrastPreference::NoPreference,
617            viewport_width: None,
618        }
619    }
620}
621
622impl MediaQuery {
623    /// `true` when every feature matches the given context.
624    pub fn matches(&self, ctx: &MediaContext) -> bool {
625        self.features.iter().all(|f| f.matches(ctx))
626    }
627}
628
629impl MediaFeature {
630    fn matches(&self, ctx: &MediaContext) -> bool {
631        match self {
632            Self::PrefersColorScheme(want) => match (ctx.color_scheme, *want) {
633                (Some(cs), w) => cs == w,
634                (None, ColorSchemePreference::NoPreference) => true,
635                (None, _) => false,
636            },
637            Self::PrefersReducedMotion(want) => ctx.reduced_motion == *want,
638            Self::PrefersContrast(want) => ctx.contrast == *want,
639            Self::MinWidth(min) => ctx.viewport_width.is_some_and(|w| w >= *min),
640            Self::MaxWidth(max) => ctx.viewport_width.is_some_and(|w| w <= *max),
641            Self::Width(w) => ctx.viewport_width.is_some_and(|vp| (vp - *w).abs() < 0.5),
642        }
643    }
644}
645// ---------------------------------------------------------------------------
646// Cascade application
647// ---------------------------------------------------------------------------
648
649/// Apply a stylesheet to a `LayoutIR`. Each property follows
650/// `(origin, !important, specificity, source_order)` ordering and the
651/// **last** matching declaration wins. HTML inline attrs always beat
652/// CSS (origin precedence: inline > user/UA).
653pub fn apply_css(ir: &mut LayoutIR, css: &Stylesheet) -> Result<Vec<CssWarning>, ParseError> {
654    apply_css_with_media(ir, css, &MediaContext::default())
655}
656
657/// Same as [`apply_css`] but honours `@media` blocks against the given
658/// [`MediaContext`].
659///
660/// A bad declaration (unknown property, unparseable value, unresolvable
661/// `var()`) is skipped and reported as a [`CssWarning`] - it never takes
662/// down the rest of the stylesheet, matching CSS error-recovery
663/// semantics. The returned list is deduplicated, so a rule that matches
664/// a thousand elements reports each defect once.
665pub fn apply_css_with_media(
666    ir: &mut LayoutIR,
667    css: &Stylesheet,
668    media: &MediaContext,
669) -> Result<Vec<CssWarning>, ParseError> {
670    let parents: Vec<ElementRef> = Vec::new();
671    let mut warnings = Vec::new();
672    apply_to_element(
673        &mut ir.root,
674        css,
675        media,
676        &Scope::default(),
677        &parents,
678        1,
679        1,
680        None,
681        &mut warnings,
682    );
683    let mut seen = std::collections::HashSet::new();
684    warnings.retain(|w| seen.insert((w.selector.clone(), w.property.clone(), w.message.clone())));
685    Ok(warnings)
686}
687
688/// A skipped declaration, surfaced to the CLI / hot-reload log / LSP
689/// instead of aborting the stylesheet.
690#[derive(Debug, Clone, PartialEq, Eq)]
691pub struct CssWarning {
692    /// Selector text of the rule the declaration lives in.
693    pub selector: String,
694    /// Property name as written.
695    pub property: String,
696    /// Human-readable reason the declaration was skipped.
697    pub message: String,
698}
699
700impl std::fmt::Display for CssWarning {
701    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
702        write!(
703            f,
704            "css warning: {} {{ {} }} - {}",
705            self.selector, self.property, self.message
706        )
707    }
708}
709
710/// Lightweight ancestor reference for parent/sibling matching. Avoids
711/// cloning whole subtrees during cascade resolution.
712#[derive(Debug, Clone)]
713struct ElementRef {
714    tag: String,
715    classes: Vec<String>,
716    id: Option<String>,
717    /// 1-based position among element siblings.
718    child_index: i32,
719    /// Total siblings (including self).
720    sibling_count: i32,
721    /// Every element sibling of this node, self included, in document
722    /// order; `child_index` locates the node inside it. `None` when the
723    /// caller has no sibling identity to give, and `+` / `~` steps then
724    /// fail rather than guess. Entries hold `None` here themselves: a
725    /// sibling's sibling list is this same list, which the matcher
726    /// carries in its cursor.
727    siblings: Option<Rc<[ElementRef]>>,
728}
729
730impl ElementRef {
731    fn from_element(el: &Element, child_index: i32, sibling_count: i32) -> Self {
732        Self {
733            tag: el.tag.clone(),
734            classes: el.attrs.classes.clone(),
735            id: el.attrs.id.clone(),
736            child_index,
737            sibling_count,
738            siblings: None,
739        }
740    }
741
742    /// Attach the sibling list this node belongs to.
743    fn with_siblings(mut self, siblings: Option<Rc<[ElementRef]>>) -> Self {
744        self.siblings = siblings;
745        self
746    }
747
748    /// 0-based position inside [`Self::siblings`].
749    fn sibling_index(&self) -> usize {
750        (self.child_index.max(1) - 1) as usize
751    }
752}
753
754/// A node the matcher is currently standing on, with everything a
755/// compound selector can ask about it: its identity, its ancestors
756/// (root-first), and where it sits among its siblings.
757#[derive(Clone, Copy)]
758struct NodeCtx<'a> {
759    el: &'a ElementRef,
760    /// Ancestors of `el`, root-first.
761    ancestors: &'a [ElementRef],
762    /// Sibling list of `el` and the 0-based index of `el` inside it.
763    /// `None` list = sibling identity unavailable.
764    siblings: Option<&'a [ElementRef]>,
765    sibling_index: usize,
766    has_element_children: bool,
767    text_body: Option<&'a str>,
768    is_root: bool,
769}
770
771impl<'a> NodeCtx<'a> {
772    /// Context for the ancestor at index `k` of a root-first chain. The
773    /// ancestor pass assumes element children and no inline text, so
774    /// `:empty` matches conservatively there.
775    fn ancestor(parents: &'a [ElementRef], k: usize) -> Self {
776        let a = &parents[k];
777        Self {
778            el: a,
779            ancestors: &parents[..k],
780            siblings: a.siblings.as_deref(),
781            sibling_index: a.sibling_index(),
782            has_element_children: true,
783            text_body: None,
784            is_root: k == 0,
785        }
786    }
787}
788
789#[allow(clippy::too_many_arguments)]
790fn apply_to_element(
791    el: &mut Element,
792    css: &Stylesheet,
793    media: &MediaContext,
794    scope: &Scope,
795    parents: &[ElementRef],
796    child_index: i32,
797    sibling_count: i32,
798    siblings: Option<Rc<[ElementRef]>>,
799    warnings: &mut Vec<CssWarning>,
800) {
801    let me = ElementRef::from_element(el, child_index, sibling_count).with_siblings(siblings);
802    let has_element_children = !el.children.is_empty();
803    // Out of the attributes for the pass, so the cascade can borrow them
804    // whole while `:empty` reads the text; no rule writes it.
805    let text_body = el.attrs.text.take();
806    let child_scope = cascade_one(
807        &mut el.attrs,
808        css,
809        media,
810        scope,
811        parents,
812        &me,
813        has_element_children,
814        text_body.as_deref(),
815        warnings,
816    );
817    el.attrs.text = text_body;
818
819    // Recurse into children with the up-to-date var + text scope.
820    let mut new_parents: Vec<ElementRef> = parents.to_vec();
821    new_parents.push(me);
822    let n = el.children.len() as i32;
823    // One identity list per parent, shared by reference with every child,
824    // so `+` / `~` steps can walk leftwards through real siblings.
825    let child_refs: Option<Rc<[ElementRef]>> = if el.children.is_empty() {
826        None
827    } else {
828        Some(
829            el.children
830                .iter()
831                .enumerate()
832                .map(|(i, c)| ElementRef::from_element(c, (i as i32) + 1, n))
833                .collect(),
834        )
835    };
836    for (i, child) in el.children.iter_mut().enumerate() {
837        apply_to_element(
838            child,
839            css,
840            media,
841            &child_scope,
842            &new_parents,
843            (i as i32) + 1,
844            n,
845            child_refs.clone(),
846            warnings,
847        );
848    }
849}
850
851/// What an element hands its children in the cascade: the custom
852/// properties in force under it and the text properties they inherit.
853#[derive(Debug, Clone, Default)]
854struct Scope {
855    vars: std::collections::HashMap<String, String>,
856    text: InheritedText,
857}
858
859/// Resolve one element's attributes against the stylesheet, in place, and
860/// return the scope its children inherit.
861///
862/// `attrs` comes in holding what the element itself declares, which is
863/// inline origin: it beats every rule, and the restore at the end puts it
864/// back over them. `me` is the element's identity for selector matching,
865/// `parents` its ancestors root-first, and `scope` what its parent hands
866/// down.
867#[allow(clippy::too_many_arguments)]
868fn cascade_one(
869    attrs: &mut Attributes,
870    css: &Stylesheet,
871    media: &MediaContext,
872    scope: &Scope,
873    parents: &[ElementRef],
874    me: &ElementRef,
875    has_element_children: bool,
876    text_body: Option<&str>,
877    warnings: &mut Vec<CssWarning>,
878) -> Scope {
879    let inherited_vars = &scope.vars;
880    let inherited_text = &scope.text;
881    let is_root = parents.is_empty();
882
883    // Snapshot pre-CSS (inline-set) attrs so origin precedence still
884    // beats user CSS on a per-field basis.
885    let inline_snapshot = attrs.clone();
886
887    // Inheritance base (CSS-inherited text props): apply the parent's
888    // computed value as a specificity-0 base, only where this element
889    // has no inline value yet. Any matching rule (applied below) or the
890    // inline origin (restored below) overrides it.
891    inherited_text.apply_base(attrs);
892
893    // Collect matching rules, cascade-sorted (ascending - later wins).
894    let matched = collect_matching_rules(
895        css,
896        media,
897        me,
898        parents,
899        has_element_children,
900        text_body,
901        is_root,
902    );
903
904    // Var pass: cascade-ordered, per-declaration importance, last-wins.
905    // Custom-property declarations are ordered by their own `!important`
906    // flag (a stable sort floats important decls after normal ones), so
907    // an `!important` var beats a normal one at equal position.
908    let mut vars = inherited_vars.clone();
909    {
910        let mut var_units: Vec<&Declaration> = Vec::new();
911        for m in &matched {
912            for decl in &css.rules[m.rule_idx].declarations {
913                if decl.name.starts_with("--") {
914                    var_units.push(decl);
915                }
916            }
917        }
918        var_units.sort_by_key(|d| d.important);
919        for decl in var_units {
920            if let Some(name) = decl.name.strip_prefix("--") {
921                vars.insert(name.to_string(), decl.value.clone());
922            }
923        }
924    }
925
926    // Apply pass: per-declaration cascade. CSS Cascade section 6.4 makes
927    // `!important` participate at the declaration level, not the rule
928    // level - a normal declaration sitting next to an important sibling
929    // must not be promoted with it. We flatten (rule, decl) units in
930    // cascade order (specificity, source_order, selector_idx) then
931    // stable-sort by importance so all `!important` decls float to the
932    // end (last-wins) while normal decls keep their relative order.
933    let mut units: Vec<(&MatchedRule, &Declaration)> = Vec::new();
934    for m in &matched {
935        for decl in &css.rules[m.rule_idx].declarations {
936            if decl.name.starts_with("--") {
937                continue;
938            }
939            units.push((m, decl));
940        }
941    }
942    units.sort_by_key(|(_, decl)| decl.important);
943
944    // Which origin last set the plain `bg`, and which last set a
945    // pointer-state `bg`. The two land in different `Attributes` slots, so
946    // the cascade sort above never gets to compare them; see the cleanup
947    // below the loop.
948    let mut plain_bg_origin: Option<Origin> = None;
949    let mut hover_bg_origin: Option<Origin> = None;
950    let mut press_bg_origin: Option<Origin> = None;
951
952    // A bad declaration is skipped with a warning - never fatal.
953    for (m, decl) in units {
954        let ctx = describe_selector_for_error(&css.rules[m.rule_idx].selectors, m.selector_idx);
955        let resolved = match resolve_vars(&ctx, &decl.name, &decl.value, &vars) {
956            Ok(v) => v,
957            Err(e) => {
958                warnings.push(CssWarning {
959                    selector: ctx.clone(),
960                    property: decl.name.clone(),
961                    message: e.to_string(),
962                });
963                continue;
964            }
965        };
966        match apply_decl_for_pseudo(&ctx, &decl.name, &resolved, &m.matched_pseudo, attrs) {
967            Ok(true) => {
968                if canonical_property_name(&decl.name) == "bg" {
969                    let p = &m.matched_pseudo;
970                    if !p.any() {
971                        plain_bg_origin = Some(m.origin);
972                    } else if p.active {
973                        press_bg_origin = Some(m.origin);
974                    } else if !(p.checked || p.selected || p.disabled || p.drag_over) {
975                        hover_bg_origin = Some(m.origin);
976                    }
977                }
978            }
979            Ok(false) => warnings.push(CssWarning {
980                selector: ctx.clone(),
981                property: decl.name.clone(),
982                message: "unknown property (declaration ignored)".to_string(),
983            }),
984            Err(e) => warnings.push(CssWarning {
985                selector: ctx.clone(),
986                property: decl.name.clone(),
987                message: e.to_string(),
988            }),
989        }
990    }
991
992    // Origin precedence: inline > CSS. Restore any field the inline
993    // pass had populated.
994    restore_inline_origin(attrs, &inline_snapshot);
995
996    // An author background wins over a user-agent one, whatever state the
997    // skin attached its rule to. `bg` and `:hover { bg }` live in separate
998    // `Attributes` slots, so the cascade sort above compares neither
999    // against the other and both survive; the hover slot then repaints the
1000    // element as soon as the pointer arrives. On a text field the pointer
1001    // is over the element the whole time the user types, so the skin's
1002    // fill, not the author's, is what gets seen.
1003    //
1004    // Only the transient pointer states yield. `:checked` / `:selected` /
1005    // `:disabled` / `:drag-over` describe a different element state rather
1006    // than a different rendering of the resting one, so the skin's default
1007    // for those stands until the author names that state.
1008    let authored_bg =
1009        matches!(plain_bg_origin, Some(Origin::Author)) || inline_snapshot.bg.is_some();
1010    if authored_bg {
1011        if hover_bg_origin == Some(Origin::UserAgent) {
1012            attrs.hover_bg = None;
1013        }
1014        if press_bg_origin == Some(Origin::UserAgent) {
1015            attrs.press_bg = None;
1016        }
1017    }
1018
1019    // Tooltip skin tokens: `<tooltip>` collapses onto its trigger at
1020    // parse time, so no selector can reach it - the dwell / cursor-gap
1021    // metrics are instead routed through the `--lumen-tooltip-delay` /
1022    // `--lumen-tooltip-offset` custom properties (declared in the
1023    // built-in skins' `:root`, overridable by any app `:root` block).
1024    // Inline `delay=` / `offset=` attrs parsed as `Some` and win; the
1025    // runtime default (500 ms / 12 px) is the single Rust-side
1026    // fallback when neither the author nor a skin supplied a value.
1027    if let Some(tooltip) = attrs.tooltip.as_mut() {
1028        if tooltip.delay_ms.is_none() {
1029            tooltip.delay_ms = vars
1030                .get("lumen-tooltip-delay")
1031                .and_then(|v| v.trim().parse::<u32>().ok());
1032        }
1033        if tooltip.offset.is_none() {
1034            tooltip.offset = vars
1035                .get("lumen-tooltip-offset")
1036                .and_then(|v| v.trim().parse::<f32>().ok());
1037        }
1038    }
1039
1040    // Compute the inherited text scope for children from this element's
1041    // now-resolved values (which already fold in the inherited base).
1042    let text = InheritedText::from_computed(attrs, inherited_text);
1043    Scope { vars, text }
1044}
1045
1046/// CSS-inherited text properties carried down the element tree. Per the
1047/// CSS cascade, `color`/`font-size`/`text-align`/`text-wrap`/`max-lines`/
1048/// `line-height` inherit from parent to child; custom properties inherit
1049/// separately via the var map. Only slots that [`Attributes`] actually
1050/// supports are modelled.
1051#[derive(Debug, Clone, Default)]
1052struct InheritedText {
1053    text_color: Option<Rgba>,
1054    selection_color: Option<Rgba>,
1055    caret_color: Option<Rgba>,
1056    selection_text_color: Option<Rgba>,
1057    font_size: Option<f32>,
1058    font_family: Option<String>,
1059    font_weight: Option<u16>,
1060    text_align: Option<TextAlignSpec>,
1061    text_wrap: Option<TextWrapSpec>,
1062    max_lines: Option<u32>,
1063    line_height: Option<LineHeightSpec>,
1064}
1065
1066impl InheritedText {
1067    /// Write inherited values as a specificity-0 base - only into fields
1068    /// the element hasn't already set inline. Matching rules (applied
1069    /// afterwards) and the inline-origin restore both override this.
1070    fn apply_base(&self, attrs: &mut Attributes) {
1071        if attrs.text_color.is_none() {
1072            attrs.text_color = self.text_color;
1073        }
1074        if attrs.selection_color.is_none() {
1075            attrs.selection_color = self.selection_color;
1076        }
1077        if attrs.caret_color.is_none() {
1078            attrs.caret_color = self.caret_color;
1079        }
1080        if attrs.selection_text_color.is_none() {
1081            attrs.selection_text_color = self.selection_text_color;
1082        }
1083        if attrs.font_size.is_none() {
1084            attrs.font_size = self.font_size;
1085        }
1086        if attrs.font_family.is_none() {
1087            attrs.font_family = self.font_family.clone();
1088        }
1089        if attrs.font_weight.is_none() {
1090            attrs.font_weight = self.font_weight;
1091        }
1092        if attrs.text_align.is_none() {
1093            attrs.text_align = self.text_align;
1094        }
1095        if attrs.text_wrap.is_none() {
1096            attrs.text_wrap = self.text_wrap;
1097        }
1098        if attrs.max_lines.is_none() {
1099            attrs.max_lines = self.max_lines;
1100        }
1101        if attrs.line_height.is_none() {
1102            attrs.line_height = self.line_height;
1103        }
1104    }
1105
1106    /// The inherited scope handed to children: the element's computed
1107    /// value for each prop, falling back to what this element itself
1108    /// inherited when it set nothing.
1109    fn from_computed(attrs: &Attributes, parent: &InheritedText) -> Self {
1110        Self {
1111            text_color: attrs.text_color.or(parent.text_color),
1112            selection_color: attrs.selection_color.or(parent.selection_color),
1113            caret_color: attrs.caret_color.or(parent.caret_color),
1114            selection_text_color: attrs.selection_text_color.or(parent.selection_text_color),
1115            font_size: attrs.font_size.or(parent.font_size),
1116            font_family: attrs
1117                .font_family
1118                .clone()
1119                .or_else(|| parent.font_family.clone()),
1120            font_weight: attrs.font_weight.or(parent.font_weight),
1121            text_align: attrs.text_align.or(parent.text_align),
1122            text_wrap: attrs.text_wrap.or(parent.text_wrap),
1123            max_lines: attrs.max_lines.or(parent.max_lines),
1124            line_height: attrs.line_height.or(parent.line_height),
1125        }
1126    }
1127}
1128
1129#[derive(Debug, Clone)]
1130struct MatchedRule {
1131    rule_idx: usize,
1132    selector_idx: usize,
1133    origin: Origin,
1134    source_order: usize,
1135    specificity: Specificity,
1136    matched_pseudo: SubjectPseudo,
1137}
1138
1139#[derive(Debug, Clone, Default)]
1140struct SubjectPseudo {
1141    hover: bool,
1142    focus: bool,
1143    focus_visible: bool,
1144    active: bool,
1145    disabled: bool,
1146    checked: bool,
1147    selected: bool,
1148    drag_over: bool,
1149}
1150
1151impl SubjectPseudo {
1152    fn any(&self) -> bool {
1153        self.hover
1154            || self.focus
1155            || self.focus_visible
1156            || self.active
1157            || self.disabled
1158            || self.checked
1159            || self.selected
1160            || self.drag_over
1161    }
1162}
1163
1164fn collect_matching_rules(
1165    css: &Stylesheet,
1166    media: &MediaContext,
1167    me: &ElementRef,
1168    parents: &[ElementRef],
1169    has_element_children: bool,
1170    text_body: Option<&str>,
1171    is_root: bool,
1172) -> Vec<MatchedRule> {
1173    let subject_ctx = NodeCtx {
1174        el: me,
1175        ancestors: parents,
1176        siblings: me.siblings.as_deref(),
1177        sibling_index: me.sibling_index(),
1178        has_element_children,
1179        text_body,
1180        is_root,
1181    };
1182    let mut matched: Vec<MatchedRule> = Vec::new();
1183    for (rule_idx, rule) in css.rules.iter().enumerate() {
1184        if let Some(mq) = &rule.media
1185            && !mq.matches(media)
1186        {
1187            continue;
1188        }
1189        for (sel_idx, sel) in rule.selectors.iter().enumerate() {
1190            if let Some(subject) = match_selector(sel, &subject_ctx) {
1191                matched.push(MatchedRule {
1192                    rule_idx,
1193                    selector_idx: sel_idx,
1194                    origin: rule.origin,
1195                    source_order: rule.source_order,
1196                    specificity: sel.specificity(),
1197                    matched_pseudo: subject,
1198                });
1199            }
1200        }
1201    }
1202    // Position order: (origin, specificity, source_order, selector_idx),
1203    // ascending, so the last write wins. Origin dominates - a
1204    // user-agent (skin) rule loses to an author rule regardless of
1205    // specificity, per CSS Cascade section 6.1 (so an author `.editor` beats a
1206    // skin `textarea:hover`). Importance is not folded in here - per
1207    // CSS Cascade section 6.4 it participates at the declaration level, and the
1208    // apply/var passes stable-sort each declaration by its own
1209    // `!important` flag on top of this base ordering.
1210    matched.sort_by(|a, b| {
1211        a.origin
1212            .cmp(&b.origin)
1213            .then_with(|| a.specificity.cmp(&b.specificity))
1214            .then_with(|| a.source_order.cmp(&b.source_order))
1215            .then_with(|| a.selector_idx.cmp(&b.selector_idx))
1216    });
1217    matched
1218}
1219
1220/// Try to match `sel` against `subject`, walking leftwards through the
1221/// chain: ancestors for descendant / child steps, the sibling list for
1222/// `+` / `~` steps. Returns the subject compound's pseudo
1223/// classification.
1224///
1225/// Each step takes the nearest candidate that matches and does not
1226/// backtrack, so a chain whose left half could only match via a farther
1227/// candidate can miss. This is the same greediness the descendant walk
1228/// has always had.
1229fn match_selector(sel: &SelectorBuf, subject: &NodeCtx) -> Option<SubjectPseudo> {
1230    let last = sel.chain.last()?;
1231    let subject_compound = &last.1;
1232    let subject_pseudo = extract_subject_pseudo(subject_compound);
1233    if !match_compound(subject_compound, subject) {
1234        return None;
1235    }
1236    let parents = subject.ancestors;
1237    // `anc_cursor` is the index of the next ancestor to try (counting
1238    // from the IMMEDIATE parent = parents.len() - 1 and going up).
1239    // `sibs` / `sib_idx` track where the cursor stands among its
1240    // siblings; a `+` / `~` step moves inside that list, and a
1241    // descendant / child step replaces it with the ancestor's own.
1242    let mut anc_cursor: isize = parents.len() as isize - 1;
1243    let mut sibs: Option<&[ElementRef]> = subject.siblings;
1244    let mut sib_idx: usize = subject.sibling_index;
1245    for i in (0..sel.chain.len().saturating_sub(1)).rev() {
1246        // The combinator that linked the *previous* compound on the
1247        // right side (sel.chain[i+1]) to this compound on the left
1248        // (sel.chain[i]) is stored on sel.chain[i+1].0.
1249        let link = sel.chain[i + 1].0;
1250        let compound = &sel.chain[i].1;
1251        match link {
1252            Combinator::Subject => unreachable!("only leftmost is Subject"),
1253            Combinator::Descendant => {
1254                let mut found = false;
1255                while anc_cursor >= 0 {
1256                    let k = anc_cursor as usize;
1257                    anc_cursor -= 1;
1258                    let ctx = NodeCtx::ancestor(parents, k);
1259                    if match_compound(compound, &ctx) {
1260                        sibs = ctx.siblings;
1261                        sib_idx = ctx.sibling_index;
1262                        found = true;
1263                        break;
1264                    }
1265                }
1266                if !found {
1267                    return None;
1268                }
1269            }
1270            Combinator::Child => {
1271                if anc_cursor < 0 {
1272                    return None;
1273                }
1274                let k = anc_cursor as usize;
1275                anc_cursor -= 1;
1276                let ctx = NodeCtx::ancestor(parents, k);
1277                if !match_compound(compound, &ctx) {
1278                    return None;
1279                }
1280                sibs = ctx.siblings;
1281                sib_idx = ctx.sibling_index;
1282            }
1283            Combinator::AdjacentSibling | Combinator::GeneralSibling => {
1284                // Siblings share the cursor's ancestors, so the ancestor
1285                // cursor stays put; only the position inside the sibling
1286                // list moves.
1287                let list = sibs?;
1288                if sib_idx == 0 || sib_idx > list.len() {
1289                    return None;
1290                }
1291                let anc_len = (anc_cursor + 1).max(0) as usize;
1292                let mut hit: Option<usize> = None;
1293                let mut j = sib_idx;
1294                while j > 0 {
1295                    j -= 1;
1296                    let ctx = NodeCtx {
1297                        el: &list[j],
1298                        ancestors: &parents[..anc_len],
1299                        siblings: Some(list),
1300                        sibling_index: j,
1301                        has_element_children: true,
1302                        text_body: None,
1303                        is_root: false,
1304                    };
1305                    if match_compound(compound, &ctx) {
1306                        hit = Some(j);
1307                        break;
1308                    }
1309                    if link == Combinator::AdjacentSibling {
1310                        break;
1311                    }
1312                }
1313                sib_idx = hit?;
1314            }
1315        }
1316    }
1317    Some(subject_pseudo)
1318}
1319
1320fn extract_subject_pseudo(c: &CompoundSelector) -> SubjectPseudo {
1321    let mut sp = SubjectPseudo::default();
1322    for p in &c.pseudo_classes {
1323        match p {
1324            PseudoClass::Hover => sp.hover = true,
1325            PseudoClass::Focus => sp.focus = true,
1326            PseudoClass::FocusVisible => sp.focus_visible = true,
1327            PseudoClass::Active => sp.active = true,
1328            PseudoClass::Disabled => sp.disabled = true,
1329            PseudoClass::Checked => sp.checked = true,
1330            PseudoClass::Selected => sp.selected = true,
1331            PseudoClass::DragOver => sp.drag_over = true,
1332            _ => {}
1333        }
1334    }
1335    sp
1336}
1337
1338fn match_compound(compound: &CompoundSelector, node: &NodeCtx) -> bool {
1339    let el = node.el;
1340    if let Some(t) = &compound.tag
1341        && t != &el.tag
1342    {
1343        return false;
1344    }
1345    if let Some(id) = &compound.id
1346        && el.id.as_deref() != Some(id.as_str())
1347    {
1348        return false;
1349    }
1350    for c in &compound.classes {
1351        if !el.classes.iter().any(|x| x == c) {
1352            return false;
1353        }
1354    }
1355    for p in &compound.pseudo_classes {
1356        if !matches_pseudo(p, node) {
1357            return false;
1358        }
1359    }
1360    true
1361}
1362
1363fn matches_pseudo(p: &PseudoClass, node: &NodeCtx) -> bool {
1364    let el = node.el;
1365    let is_root = node.is_root;
1366    let has_element_children = node.has_element_children;
1367    let text_body = node.text_body;
1368    match p {
1369        // State-conditional pseudos: matched as true at parse time;
1370        // the runtime ECS attaches the appropriate state component.
1371        // The subject side feeds `SubjectPseudo` for property routing.
1372        PseudoClass::Hover
1373        | PseudoClass::Focus
1374        | PseudoClass::FocusVisible
1375        | PseudoClass::Active
1376        | PseudoClass::Disabled
1377        | PseudoClass::Checked
1378        | PseudoClass::Selected
1379        | PseudoClass::DragOver => true,
1380        PseudoClass::Root => is_root || el.tag == "root",
1381        PseudoClass::FirstChild => el.child_index == 1,
1382        PseudoClass::LastChild => el.child_index == el.sibling_count,
1383        PseudoClass::OnlyChild => el.sibling_count == 1,
1384        PseudoClass::Empty => {
1385            let no_text = text_body.is_none_or(|s| s.trim().is_empty());
1386            !has_element_children && no_text
1387        }
1388        PseudoClass::NthChild(anb) => anb.matches(el.child_index),
1389        // Selectors-4: the argument of `:is()` / `:where()` / `:not()` is
1390        // a full complex-selector list whose subject is this element, so
1391        // each argument runs back through the matcher with this node's
1392        // own ancestor / sibling context. `.a:not(.b > .a)` therefore
1393        // asks whether the element is a child of `.b`, not merely
1394        // whether it carries `.b`.
1395        PseudoClass::Is(args) => args.iter().any(|s| match_selector(s, node).is_some()),
1396        PseudoClass::Where(args) => args.iter().any(|s| match_selector(s, node).is_some()),
1397        PseudoClass::Not(args) => !args.iter().any(|s| match_selector(s, node).is_some()),
1398    }
1399}
1400
1401// ---------------------------------------------------------------------------
1402// Public query surface (read side of the dynamic DOM API)
1403// ---------------------------------------------------------------------------
1404
1405/// Parse a CSS selector list (`"#save, .row > .cell"`) into the same
1406/// [`SelectorBuf`] chain the stylesheet front-end builds. The runtime
1407/// query engine (`query(sel)`) calls this so a script selector runs
1408/// through the one selector grammar Lumen already speaks; there is no
1409/// second engine.
1410///
1411/// The grammar is the Selectors-4 subset the CSS parser accepts:
1412/// tag / `#id` / `.class` / pseudo-classes, descendant / `>` / `+` / `~`
1413/// combinators, and `:is()` / `:where()` / `:not()` / `:nth-child()`
1414/// nesting. Errors return a message string.
1415pub fn parse_selector_list(src: &str) -> Result<Vec<SelectorBuf>, String> {
1416    sel_parse_list(src, 0)
1417}
1418
1419/// Test whether `sel` matches `subject`, consulting `ancestors`
1420/// (root-first, the same order [`RestyleWalk::new`] takes) for
1421/// descendant / child combinators. Wraps the cascade matcher
1422/// ([`match_selector`]) so `query()` and traversal share the exact match
1423/// semantics of the live cascade.
1424///
1425/// Sibling combinators (`+`, `~`) walk `subject.siblings` and each
1426/// ancestor's; without them a selector with a sibling step never matches.
1427/// `:empty` reads `subject.has_element_children` and `subject.text`.
1428pub fn selector_matches(
1429    sel: &SelectorBuf,
1430    subject: &AncestorInfo,
1431    ancestors: &[AncestorInfo],
1432) -> bool {
1433    let me = subject.to_ref();
1434    let parents: Vec<ElementRef> = ancestors.iter().map(AncestorInfo::to_ref).collect();
1435    let is_root = ancestors.is_empty();
1436    let ctx = NodeCtx {
1437        el: &me,
1438        ancestors: &parents,
1439        siblings: me.siblings.as_deref(),
1440        sibling_index: me.sibling_index(),
1441        has_element_children: subject.has_element_children,
1442        text_body: subject.text.as_deref(),
1443        is_root,
1444    };
1445    match_selector(sel, &ctx).is_some()
1446}
1447
1448/// Selector-nesting depth cap for `:is()` / `:where()` / `:not()`,
1449/// mirroring the CSS parser's own guard.
1450const SELECTOR_MAX_NEST_DEPTH: u32 = 8;
1451
1452fn sel_parse_list(src: &str, depth: u32) -> Result<Vec<SelectorBuf>, String> {
1453    if depth > SELECTOR_MAX_NEST_DEPTH {
1454        return Err(format!(
1455            "css: selector nesting (:not/:is/:where) exceeded depth {SELECTOR_MAX_NEST_DEPTH}"
1456        ));
1457    }
1458    let trimmed = src.trim();
1459    if trimmed.is_empty() {
1460        return Err("css: empty selector".into());
1461    }
1462    let mut out = Vec::new();
1463    for piece in split_top_level_commas(trimmed) {
1464        let p = piece.trim();
1465        if p.is_empty() {
1466            return Err("css: empty selector in list".into());
1467        }
1468        out.push(sel_parse_one(p, depth)?);
1469    }
1470    Ok(out)
1471}
1472
1473fn sel_parse_one(src: &str, depth: u32) -> Result<SelectorBuf, String> {
1474    let tokens = sel_tokenize(src)?;
1475    if tokens.is_empty() {
1476        return Err(format!("css: empty selector '{src}'"));
1477    }
1478    let mut chain: Vec<(Combinator, CompoundSelector)> = Vec::new();
1479    let mut pending = Combinator::Subject;
1480    let mut i = 0usize;
1481    loop {
1482        while matches!(tokens.get(i), Some(SelTok::Whitespace)) {
1483            i += 1;
1484        }
1485        if i >= tokens.len() {
1486            break;
1487        }
1488        let (compound, consumed) = sel_read_compound(&tokens[i..], depth)?;
1489        if compound.tag.is_none()
1490            && compound.id.is_none()
1491            && compound.classes.is_empty()
1492            && compound.pseudo_classes.is_empty()
1493        {
1494            return Err(format!("css: empty compound in '{src}'"));
1495        }
1496        chain.push((pending, compound));
1497        i += consumed;
1498        let mut ws_seen = false;
1499        while matches!(tokens.get(i), Some(SelTok::Whitespace)) {
1500            ws_seen = true;
1501            i += 1;
1502        }
1503        if i >= tokens.len() {
1504            break;
1505        }
1506        pending = match tokens.get(i) {
1507            Some(SelTok::ChildCombinator) => {
1508                i += 1;
1509                Combinator::Child
1510            }
1511            Some(SelTok::AdjacentSibling) => {
1512                i += 1;
1513                Combinator::AdjacentSibling
1514            }
1515            Some(SelTok::GeneralSibling) => {
1516                i += 1;
1517                Combinator::GeneralSibling
1518            }
1519            _ if ws_seen => Combinator::Descendant,
1520            _ => Combinator::Descendant,
1521        };
1522        while matches!(tokens.get(i), Some(SelTok::Whitespace)) {
1523            i += 1;
1524        }
1525    }
1526    if chain.is_empty() {
1527        return Err(format!("css: empty selector '{src}'"));
1528    }
1529    Ok(SelectorBuf { chain })
1530}
1531
1532#[derive(Debug, Clone)]
1533enum SelTok {
1534    Whitespace,
1535    Tag(String),
1536    Class(String),
1537    Id(String),
1538    Pseudo(String, Option<String>),
1539    ChildCombinator,
1540    AdjacentSibling,
1541    GeneralSibling,
1542}
1543
1544fn sel_tokenize(src: &str) -> Result<Vec<SelTok>, String> {
1545    let bytes = src.as_bytes();
1546    let mut out = Vec::new();
1547    let mut i = 0;
1548    while i < bytes.len() {
1549        let c = bytes[i];
1550        match c {
1551            b' ' | b'\t' | b'\n' | b'\r' => {
1552                out.push(SelTok::Whitespace);
1553                while i < bytes.len() && matches!(bytes[i], b' ' | b'\t' | b'\n' | b'\r') {
1554                    i += 1;
1555                }
1556            }
1557            b'>' => {
1558                out.push(SelTok::ChildCombinator);
1559                i += 1;
1560            }
1561            b'+' => {
1562                out.push(SelTok::AdjacentSibling);
1563                i += 1;
1564            }
1565            b'~' => {
1566                out.push(SelTok::GeneralSibling);
1567                i += 1;
1568            }
1569            b'.' => {
1570                i += 1;
1571                let s = sel_read_ident(bytes, &mut i)?;
1572                out.push(SelTok::Class(s));
1573            }
1574            b'#' => {
1575                i += 1;
1576                let s = sel_read_ident(bytes, &mut i)?;
1577                out.push(SelTok::Id(s));
1578            }
1579            b':' => {
1580                i += 1;
1581                if i < bytes.len() && bytes[i] == b':' {
1582                    return Err(format!(
1583                        "css: pseudo-elements not supported (got '::') in '{src}'"
1584                    ));
1585                }
1586                let name = sel_read_ident(bytes, &mut i)?;
1587                let args = if i < bytes.len() && bytes[i] == b'(' {
1588                    let close = sel_find_matching_paren(&src[i..])
1589                        .ok_or_else(|| format!("css: unterminated '(' in pseudo ':{name}'"))?;
1590                    let inner = src[i + 1..i + close].to_string();
1591                    i += close + 1;
1592                    Some(inner)
1593                } else {
1594                    None
1595                };
1596                out.push(SelTok::Pseudo(name, args));
1597            }
1598            b'*' => {
1599                out.push(SelTok::Tag("*".into()));
1600                i += 1;
1601            }
1602            _ if sel_is_ident_start(c) => {
1603                let s = sel_read_ident(bytes, &mut i)?;
1604                out.push(SelTok::Tag(s));
1605            }
1606            other => {
1607                return Err(format!(
1608                    "css: unexpected char '{}' in selector '{src}'",
1609                    other as char
1610                ));
1611            }
1612        }
1613    }
1614    Ok(out)
1615}
1616
1617fn sel_is_ident_start(c: u8) -> bool {
1618    c.is_ascii_alphabetic() || c == b'_' || c == b'-'
1619}
1620
1621fn sel_is_ident_cont(c: u8) -> bool {
1622    c.is_ascii_alphanumeric() || c == b'_' || c == b'-'
1623}
1624
1625fn sel_read_ident(bytes: &[u8], i: &mut usize) -> Result<String, String> {
1626    let start = *i;
1627    while *i < bytes.len() && sel_is_ident_cont(bytes[*i]) {
1628        *i += 1;
1629    }
1630    if *i == start {
1631        return Err(format!("css: expected identifier at byte {start}"));
1632    }
1633    Ok(std::str::from_utf8(&bytes[start..*i])
1634        .map_err(|e| format!("css: non-utf8 selector: {e}"))?
1635        .to_string())
1636}
1637
1638fn sel_find_matching_paren(s: &str) -> Option<usize> {
1639    let bytes = s.as_bytes();
1640    let mut depth: i32 = 0;
1641    for (i, &c) in bytes.iter().enumerate() {
1642        match c {
1643            b'(' => depth += 1,
1644            b')' => {
1645                depth -= 1;
1646                if depth == 0 {
1647                    return Some(i);
1648                }
1649            }
1650            _ => {}
1651        }
1652    }
1653    None
1654}
1655
1656fn sel_read_compound(toks: &[SelTok], depth: u32) -> Result<(CompoundSelector, usize), String> {
1657    let mut out = CompoundSelector::default();
1658    let mut i = 0;
1659    while i < toks.len() {
1660        match &toks[i] {
1661            SelTok::Tag(t) => {
1662                if out.tag.is_some() || !out.classes.is_empty() || out.id.is_some() {
1663                    break;
1664                }
1665                out.tag = if t == "*" { None } else { Some(t.clone()) };
1666                i += 1;
1667            }
1668            SelTok::Class(c) => {
1669                out.classes.push(c.clone());
1670                i += 1;
1671            }
1672            SelTok::Id(id) => {
1673                out.id = Some(id.clone());
1674                i += 1;
1675            }
1676            SelTok::Pseudo(name, args) => {
1677                let p = sel_parse_pseudo(name, args.as_deref(), depth)?;
1678                out.pseudo_classes.push(p);
1679                i += 1;
1680            }
1681            _ => break,
1682        }
1683    }
1684    Ok((out, i))
1685}
1686
1687fn sel_parse_pseudo(name: &str, args: Option<&str>, depth: u32) -> Result<PseudoClass, String> {
1688    match name {
1689        "hover" => sel_no_args(name, args).map(|_| PseudoClass::Hover),
1690        "focus" => sel_no_args(name, args).map(|_| PseudoClass::Focus),
1691        "focus-visible" => sel_no_args(name, args).map(|_| PseudoClass::FocusVisible),
1692        "active" => sel_no_args(name, args).map(|_| PseudoClass::Active),
1693        "disabled" => sel_no_args(name, args).map(|_| PseudoClass::Disabled),
1694        "checked" => sel_no_args(name, args).map(|_| PseudoClass::Checked),
1695        "selected" => sel_no_args(name, args).map(|_| PseudoClass::Selected),
1696        "drag-over" => sel_no_args(name, args).map(|_| PseudoClass::DragOver),
1697        "root" => sel_no_args(name, args).map(|_| PseudoClass::Root),
1698        "first-child" => sel_no_args(name, args).map(|_| PseudoClass::FirstChild),
1699        "last-child" => sel_no_args(name, args).map(|_| PseudoClass::LastChild),
1700        "only-child" => sel_no_args(name, args).map(|_| PseudoClass::OnlyChild),
1701        "empty" => sel_no_args(name, args).map(|_| PseudoClass::Empty),
1702        "nth-child" => {
1703            let a = args.ok_or_else(|| "css: :nth-child requires '(an+b)'".to_string())?;
1704            Ok(PseudoClass::NthChild(sel_parse_anb(a)?))
1705        }
1706        "is" => {
1707            let a = args.ok_or_else(|| "css: :is requires '(selector-list)'".to_string())?;
1708            Ok(PseudoClass::Is(sel_parse_list(a, depth + 1)?))
1709        }
1710        "where" => {
1711            let a = args.ok_or_else(|| "css: :where requires '(selector-list)'".to_string())?;
1712            Ok(PseudoClass::Where(sel_parse_list(a, depth + 1)?))
1713        }
1714        "not" => {
1715            let a = args.ok_or_else(|| "css: :not requires '(selector-list)'".to_string())?;
1716            Ok(PseudoClass::Not(sel_parse_list(a, depth + 1)?))
1717        }
1718        other => Err(format!(
1719            "css: unknown pseudo-class ':{other}' (supported: :hover, :focus, :focus-visible, :active, :disabled, :checked, :selected, :drag-over, :root, :first-child, :last-child, :only-child, :empty, :nth-child, :is, :where, :not)"
1720        )),
1721    }
1722}
1723
1724fn sel_no_args(name: &str, args: Option<&str>) -> Result<(), String> {
1725    if args.is_some() {
1726        Err(format!("css: ':{name}' takes no arguments"))
1727    } else {
1728        Ok(())
1729    }
1730}
1731
1732fn sel_parse_anb(src: &str) -> Result<AnB, String> {
1733    let s = src.trim().to_ascii_lowercase();
1734    match s.as_str() {
1735        "odd" => return Ok(AnB { a: 2, b: 1 }),
1736        "even" => return Ok(AnB { a: 2, b: 0 }),
1737        _ => {}
1738    }
1739    if let Some(idx) = s.find('n') {
1740        let a_part = s[..idx].trim();
1741        let b_part = s[idx + 1..].trim();
1742        let a = if a_part.is_empty() || a_part == "+" {
1743            1
1744        } else if a_part == "-" {
1745            -1
1746        } else {
1747            a_part
1748                .parse::<i32>()
1749                .map_err(|e| format!("css: bad :nth-child a-coefficient '{a_part}': {e}"))?
1750        };
1751        let b = if b_part.is_empty() {
1752            0
1753        } else {
1754            let bp = b_part.replace(' ', "");
1755            bp.parse::<i32>()
1756                .map_err(|e| format!("css: bad :nth-child b-coefficient '{b_part}': {e}"))?
1757        };
1758        Ok(AnB { a, b })
1759    } else {
1760        let b = s
1761            .parse::<i32>()
1762            .map_err(|e| format!("css: bad :nth-child '{s}': {e}"))?;
1763        Ok(AnB { a: 0, b })
1764    }
1765}
1766
1767/// Inline-set fields (origin: inline) beat CSS. Restore any field the
1768/// inline snapshot had populated.
1769fn restore_inline_origin(target: &mut Attributes, inline: &Attributes) {
1770    overlay_set_fields(target, inline);
1771}
1772
1773/// Copy every style field `src` sets onto `target`, leaving the fields
1774/// `src` leaves unset as they are.
1775///
1776/// Every property the cascade can write is listed here, and both the
1777/// inline-origin restore and the restyle copy-back go through it, so
1778/// "markup beats CSS" and "a restyle reaches what the first resolution
1779/// reached" hold for the whole attribute surface rather than for two
1780/// lists that drift apart.
1781fn overlay_set_fields(target: &mut Attributes, inline: &Attributes) {
1782    if inline.flex.is_some() {
1783        target.flex = inline.flex;
1784    }
1785    if inline.width.is_some() {
1786        target.width = inline.width;
1787    }
1788    if inline.height.is_some() {
1789        target.height = inline.height;
1790    }
1791    if inline.bg.is_some() {
1792        target.bg = inline.bg.clone();
1793    }
1794    if inline.radius.is_some() {
1795        target.radius = inline.radius;
1796    }
1797    if inline.padding.is_some() {
1798        target.padding = inline.padding;
1799    }
1800    if inline.margin.is_some() {
1801        target.margin = inline.margin;
1802    }
1803    if inline.text_color.is_some() {
1804        target.text_color = inline.text_color;
1805    }
1806    if inline.selection_color.is_some() {
1807        target.selection_color = inline.selection_color;
1808    }
1809    if inline.caret_color.is_some() {
1810        target.caret_color = inline.caret_color;
1811    }
1812    if inline.selection_text_color.is_some() {
1813        target.selection_text_color = inline.selection_text_color;
1814    }
1815    if inline.hover_bg.is_some() {
1816        target.hover_bg = inline.hover_bg;
1817    }
1818    if inline.scroll.is_some() {
1819        target.scroll = inline.scroll;
1820    }
1821    if inline.sensitivity.is_some() {
1822        target.sensitivity = inline.sensitivity;
1823    }
1824    if inline.inertia.is_some() {
1825        target.inertia = inline.inertia;
1826    }
1827    if inline.tab_index.is_some() {
1828        target.tab_index = inline.tab_index;
1829    }
1830    if inline.press_bg.is_some() {
1831        target.press_bg = inline.press_bg;
1832    }
1833    if inline.font_size.is_some() {
1834        target.font_size = inline.font_size;
1835    }
1836    if inline.font_family.is_some() {
1837        target.font_family = inline.font_family.clone();
1838    }
1839    if inline.font_weight.is_some() {
1840        target.font_weight = inline.font_weight;
1841    }
1842    if inline.knob_color.is_some() {
1843        target.knob_color = inline.knob_color;
1844    }
1845    if inline.radius_corners.is_some() {
1846        target.radius_corners = inline.radius_corners;
1847    }
1848    if inline.gap.is_some() {
1849        target.gap = inline.gap;
1850    }
1851    if inline.gap_row.is_some() {
1852        target.gap_row = inline.gap_row;
1853    }
1854    if inline.gap_column.is_some() {
1855        target.gap_column = inline.gap_column;
1856    }
1857    if inline.display.is_some() {
1858        target.display = inline.display;
1859    }
1860    if inline.grid_template.is_some() {
1861        target.grid_template = inline.grid_template.clone();
1862    }
1863    if inline.grid_row.is_some() {
1864        target.grid_row = inline.grid_row;
1865    }
1866    if inline.grid_column.is_some() {
1867        target.grid_column = inline.grid_column;
1868    }
1869    if inline.align_self.is_some() {
1870        target.align_self = inline.align_self;
1871    }
1872    if inline.justify_items.is_some() {
1873        target.justify_items = inline.justify_items;
1874    }
1875    if inline.justify_self.is_some() {
1876        target.justify_self = inline.justify_self;
1877    }
1878    if inline.grow.is_some() {
1879        target.grow = inline.grow;
1880    }
1881    if inline.align.is_some() {
1882        target.align = inline.align;
1883    }
1884    if inline.justify.is_some() {
1885        target.justify = inline.justify;
1886    }
1887    if inline.text_align.is_some() {
1888        target.text_align = inline.text_align;
1889    }
1890    if inline.focus_outline.is_some() {
1891        target.focus_outline = inline.focus_outline;
1892    }
1893    if inline.border_width.is_some() {
1894        target.border_width = inline.border_width;
1895    }
1896    if inline.border_color.is_some() {
1897        target.border_color = inline.border_color;
1898    }
1899    if inline.border_style.is_some() {
1900        target.border_style = inline.border_style;
1901    }
1902    if inline.box_sizing.is_some() {
1903        target.box_sizing = inline.box_sizing;
1904    }
1905    if inline.hover_border.is_some() {
1906        target.hover_border = inline.hover_border;
1907    }
1908    if inline.focus_border.is_some() {
1909        target.focus_border = inline.focus_border;
1910    }
1911    if inline.shrink.is_some() {
1912        target.shrink = inline.shrink;
1913    }
1914    if inline.basis.is_some() {
1915        target.basis = inline.basis;
1916    }
1917    if inline.flex_wrap.is_some() {
1918        target.flex_wrap = inline.flex_wrap;
1919    }
1920    if inline.align_content.is_some() {
1921        target.align_content = inline.align_content;
1922    }
1923    if inline.z_index.is_some() {
1924        target.z_index = inline.z_index;
1925    }
1926    if inline.gap_pct.is_some() {
1927        target.gap_pct = inline.gap_pct;
1928    }
1929    if inline.gap_row_pct.is_some() {
1930        target.gap_row_pct = inline.gap_row_pct;
1931    }
1932    if inline.gap_column_pct.is_some() {
1933        target.gap_column_pct = inline.gap_column_pct;
1934    }
1935    if inline.text_wrap.is_some() {
1936        target.text_wrap = inline.text_wrap;
1937    }
1938    if inline.max_lines.is_some() {
1939        target.max_lines = inline.max_lines;
1940    }
1941    if inline.position.is_some() {
1942        target.position = inline.position;
1943    }
1944    if inline.inset.is_some() {
1945        target.inset = inline.inset;
1946    }
1947    if inline.min_width.is_some() {
1948        target.min_width = inline.min_width;
1949    }
1950    if inline.min_height.is_some() {
1951        target.min_height = inline.min_height;
1952    }
1953    if inline.max_width.is_some() {
1954        target.max_width = inline.max_width;
1955    }
1956    if inline.max_height.is_some() {
1957        target.max_height = inline.max_height;
1958    }
1959    if inline.aspect_ratio.is_some() {
1960        target.aspect_ratio = inline.aspect_ratio;
1961    }
1962    if inline.opacity.is_some() {
1963        target.opacity = inline.opacity;
1964    }
1965    if inline.image_fit.is_some() {
1966        target.image_fit = inline.image_fit;
1967    }
1968    if inline.bg_fit.is_some() {
1969        target.bg_fit = inline.bg_fit;
1970    }
1971    if inline.style_role.is_some() {
1972        target.style_role = inline.style_role.clone();
1973    }
1974    if inline.draggable {
1975        target.draggable = true;
1976    }
1977    if !inline.shadows.is_empty() {
1978        target.shadows = inline.shadows.clone();
1979    }
1980    if !inline.transitions.is_empty() {
1981        target.transitions = inline.transitions.clone();
1982    }
1983    if inline.transition_property.is_some() {
1984        target.transition_property = inline.transition_property.clone();
1985    }
1986    if inline.transition_duration.is_some() {
1987        target.transition_duration = inline.transition_duration.clone();
1988    }
1989    if inline.transition_timing.is_some() {
1990        target.transition_timing = inline.transition_timing.clone();
1991    }
1992    if inline.scrollbar_color.is_some() {
1993        target.scrollbar_color = inline.scrollbar_color;
1994    }
1995    if inline.scrollbar_width.is_some() {
1996        target.scrollbar_width = inline.scrollbar_width;
1997    }
1998    if inline.overflow.is_some() {
1999        target.overflow = inline.overflow;
2000    }
2001    if inline.overflow_x.is_some() {
2002        target.overflow_x = inline.overflow_x;
2003    }
2004    if inline.overflow_y.is_some() {
2005        target.overflow_y = inline.overflow_y;
2006    }
2007    if inline.line_height.is_some() {
2008        target.line_height = inline.line_height;
2009    }
2010    if inline.text_overflow.is_some() {
2011        target.text_overflow = inline.text_overflow;
2012    }
2013    if inline.layout_boundary {
2014        target.layout_boundary = true;
2015    }
2016    if inline.border_color_top.is_some() {
2017        target.border_color_top = inline.border_color_top;
2018    }
2019    if inline.border_color_right.is_some() {
2020        target.border_color_right = inline.border_color_right;
2021    }
2022    if inline.border_color_bottom.is_some() {
2023        target.border_color_bottom = inline.border_color_bottom;
2024    }
2025    if inline.border_color_left.is_some() {
2026        target.border_color_left = inline.border_color_left;
2027    }
2028    if inline.outline_offset.is_some() {
2029        target.outline_offset = inline.outline_offset;
2030    }
2031    if inline.focus_visible_outline.is_some() {
2032        target.focus_visible_outline = inline.focus_visible_outline;
2033    }
2034    if inline.caret_width.is_some() {
2035        target.caret_width = inline.caret_width;
2036    }
2037    if inline.caret_blink_ms.is_some() {
2038        target.caret_blink_ms = inline.caret_blink_ms;
2039    }
2040    if inline.password_character.is_some() {
2041        target.password_character = inline.password_character;
2042    }
2043    if inline.knob_inset.is_some() {
2044        target.knob_inset = inline.knob_inset;
2045    }
2046    if inline.thumb_size.is_some() {
2047        target.thumb_size = inline.thumb_size;
2048    }
2049    if inline.popup_gap.is_some() {
2050        target.popup_gap = inline.popup_gap;
2051    }
2052    if inline.disabled_opacity_default.is_some() {
2053        target.disabled_opacity_default = inline.disabled_opacity_default;
2054    }
2055    if inline.progress_duration.is_some() {
2056        target.progress_duration = inline.progress_duration;
2057    }
2058    if inline.progress_chunk.is_some() {
2059        target.progress_chunk = inline.progress_chunk;
2060    }
2061    if inline.scrollbar_thickness.is_some() {
2062        target.scrollbar_thickness = inline.scrollbar_thickness;
2063    }
2064    if inline.scrollbar_thickness_thin.is_some() {
2065        target.scrollbar_thickness_thin = inline.scrollbar_thickness_thin;
2066    }
2067    if inline.scrollbar_margin.is_some() {
2068        target.scrollbar_margin = inline.scrollbar_margin;
2069    }
2070    if inline.scrollbar_min_thumb.is_some() {
2071        target.scrollbar_min_thumb = inline.scrollbar_min_thumb;
2072    }
2073    if inline.scrollbar_track_hover.is_some() {
2074        target.scrollbar_track_hover = inline.scrollbar_track_hover;
2075    }
2076    if inline.scrollbar_hover_boost.is_some() {
2077        target.scrollbar_hover_boost = inline.scrollbar_hover_boost;
2078    }
2079    if inline.scrollbar_fade_delay_ms.is_some() {
2080        target.scrollbar_fade_delay_ms = inline.scrollbar_fade_delay_ms;
2081    }
2082    if inline.scrollbar_fade_duration_ms.is_some() {
2083        target.scrollbar_fade_duration_ms = inline.scrollbar_fade_duration_ms;
2084    }
2085    // State slots (`:hover` / `:active` / `:focus` / `:focus-visible` /
2086    // `:checked` / `:selected` / `:disabled` / `:drag-over`). Markup can
2087    // write `hover-bg` / `press-bg` / `focus-outline` directly; the rest
2088    // are reachable only from CSS today, and are listed so a future
2089    // inline spelling inherits the same precedence.
2090    if inline.hover_text_color.is_some() {
2091        target.hover_text_color = inline.hover_text_color;
2092    }
2093    if inline.active_text_color.is_some() {
2094        target.active_text_color = inline.active_text_color;
2095    }
2096    if inline.focus_text_color.is_some() {
2097        target.focus_text_color = inline.focus_text_color;
2098    }
2099    if inline.focus_visible_text_color.is_some() {
2100        target.focus_visible_text_color = inline.focus_visible_text_color;
2101    }
2102    if inline.disabled_text_color.is_some() {
2103        target.disabled_text_color = inline.disabled_text_color;
2104    }
2105    if inline.drag_over_text_color.is_some() {
2106        target.drag_over_text_color = inline.drag_over_text_color;
2107    }
2108    if inline.hover_opacity.is_some() {
2109        target.hover_opacity = inline.hover_opacity;
2110    }
2111    if inline.active_opacity.is_some() {
2112        target.active_opacity = inline.active_opacity;
2113    }
2114    if inline.focus_opacity.is_some() {
2115        target.focus_opacity = inline.focus_opacity;
2116    }
2117    if inline.focus_visible_opacity.is_some() {
2118        target.focus_visible_opacity = inline.focus_visible_opacity;
2119    }
2120    if inline.disabled_opacity.is_some() {
2121        target.disabled_opacity = inline.disabled_opacity;
2122    }
2123    if inline.drag_over_opacity.is_some() {
2124        target.drag_over_opacity = inline.drag_over_opacity;
2125    }
2126    if inline.hover_shadows.is_some() {
2127        target.hover_shadows = inline.hover_shadows.clone();
2128    }
2129    if inline.active_shadows.is_some() {
2130        target.active_shadows = inline.active_shadows.clone();
2131    }
2132    if inline.focus_shadows.is_some() {
2133        target.focus_shadows = inline.focus_shadows.clone();
2134    }
2135    if inline.focus_visible_shadows.is_some() {
2136        target.focus_visible_shadows = inline.focus_visible_shadows.clone();
2137    }
2138    if inline.disabled_shadows.is_some() {
2139        target.disabled_shadows = inline.disabled_shadows.clone();
2140    }
2141    if inline.drag_over_shadows.is_some() {
2142        target.drag_over_shadows = inline.drag_over_shadows.clone();
2143    }
2144    if inline.checked_bg.is_some() {
2145        target.checked_bg = inline.checked_bg;
2146    }
2147    if inline.selected_bg.is_some() {
2148        target.selected_bg = inline.selected_bg;
2149    }
2150    if inline.disabled_bg.is_some() {
2151        target.disabled_bg = inline.disabled_bg;
2152    }
2153    if inline.drag_over_bg.is_some() {
2154        target.drag_over_bg = inline.drag_over_bg;
2155    }
2156}
2157
2158/// Resolve a detached element (one with no ancestors to hand it a scope,
2159/// such as a `<for>` row being built before it joins the tree) against the
2160/// stylesheet, and write what the cascade resolved over its attributes.
2161///
2162/// The element's own declarations ([`Attributes::markup_styles`]) rank above
2163/// every rule, as they did in the first cascade. Every `--*` property any
2164/// rule declares seeds the scope, first value wins, so a `var()` still
2165/// resolves against globals the missing ancestors would have carried. A
2166/// property no rule and no declaration sets keeps the value `el` already
2167/// had: the element's place in the tree decides the rest, and the restyle
2168/// that runs once it is in place resolves it whole.
2169pub fn reapply_detached(el: &mut Element, css: &Stylesheet, media: &MediaContext) {
2170    let mut seed = Scope::default();
2171    for rule in &css.rules {
2172        for decl in &rule.declarations {
2173            if let Some(name) = decl.name.strip_prefix("--") {
2174                seed.vars
2175                    .entry(name.to_string())
2176                    .or_insert_with(|| decl.value.clone());
2177            }
2178        }
2179    }
2180    let node = AncestorInfo::new(
2181        el.tag.clone(),
2182        el.attrs.classes.clone(),
2183        el.attrs.id.clone(),
2184    )
2185    .with_content(!el.children.is_empty(), el.attrs.text.clone())
2186    .with_declarations(el.attrs.markup_styles.clone());
2187    let mut walk = RestyleWalk {
2188        css,
2189        media,
2190        parents: Vec::new(),
2191        scopes: vec![seed],
2192        last: None,
2193        warnings: Vec::new(),
2194    };
2195    let resolved = walk.resolve(&node);
2196    overlay_set_fields(&mut el.attrs, &resolved);
2197}
2198
2199/// Where an element sits among its element siblings. The structural
2200/// pseudo-classes (`:first-child`, `:last-child`, `:only-child`,
2201/// `:nth-child()`) are decided from this, so a caller that re-runs the
2202/// cascade for a single element has to supply it: a wrong position is a
2203/// rule matching the wrong sibling, not a rule that quietly fails.
2204#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2205pub struct SiblingPosition {
2206    /// 1-based position among element siblings.
2207    pub child_index: i32,
2208    /// Total element siblings, self included.
2209    pub sibling_count: i32,
2210}
2211
2212impl SiblingPosition {
2213    /// Position `child_index` of `sibling_count`, both 1-based.
2214    pub fn new(child_index: i32, sibling_count: i32) -> Self {
2215        Self {
2216            child_index,
2217            sibling_count,
2218        }
2219    }
2220}
2221
2222impl Default for SiblingPosition {
2223    /// An only child. This is the position of a document root, and the
2224    /// honest answer for a caller with no sibling information at all.
2225    fn default() -> Self {
2226        Self::new(1, 1)
2227    }
2228}
2229
2230/// The element siblings sharing one parent, in document order: what a `+`
2231/// or `~` step walks leftwards through. Built once per parent and shared by
2232/// every child of it.
2233#[derive(Clone)]
2234pub struct Siblings(Rc<[ElementRef]>);
2235
2236impl Siblings {
2237    /// The siblings' identities, `(tag, classes, id)` in document order.
2238    pub fn new(items: impl IntoIterator<Item = (String, Vec<String>, Option<String>)>) -> Self {
2239        let items: Vec<(String, Vec<String>, Option<String>)> = items.into_iter().collect();
2240        let count = items.len() as i32;
2241        Self(
2242            items
2243                .into_iter()
2244                .enumerate()
2245                .map(|(i, (tag, classes, id))| ElementRef {
2246                    tag,
2247                    classes,
2248                    id,
2249                    child_index: i as i32 + 1,
2250                    sibling_count: count,
2251                    siblings: None,
2252                })
2253                .collect(),
2254        )
2255    }
2256}
2257
2258impl std::fmt::Debug for Siblings {
2259    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2260        f.debug_list()
2261            .entries(self.0.iter().map(|s| (&s.tag, &s.classes, &s.id)))
2262            .finish()
2263    }
2264}
2265
2266/// One element of a live tree as the cascade sees it: what selectors match
2267/// on, and what the element itself declares. The runtime builds these from
2268/// an entity's components to re-resolve it, and to walk the ancestors of
2269/// the element a selector query or `computed_style` asks about.
2270#[derive(Debug, Clone)]
2271pub struct AncestorInfo {
2272    /// Markup tag (`root`, `tile`, ...). Empty for a plain layout
2273    /// container that carries no `LumenTag`; such an ancestor then
2274    /// matches only tagless compound selectors, which is correct.
2275    pub tag: String,
2276    /// Class list assigned in markup via `class="..."`.
2277    pub classes: Vec<String>,
2278    /// Stable `id="..."`, if any.
2279    pub id: Option<String>,
2280    /// 1-based position among element siblings. Defaults to 1 when the
2281    /// caller can't cheaply supply it - `:first-child` / `:nth-child`
2282    /// on ancestors then match conservatively.
2283    pub child_index: i32,
2284    /// Total sibling count including self. Defaults to 1.
2285    pub sibling_count: i32,
2286    /// Every element sibling, self included, for `+` / `~`. `None` makes
2287    /// those steps fail rather than guess.
2288    pub siblings: Option<Siblings>,
2289    /// Whether the element has element children, for `:empty`.
2290    pub has_element_children: bool,
2291    /// The element's own text, for `:empty`.
2292    pub text: Option<String>,
2293    /// What the element declares about itself, `(property, value)` in the
2294    /// order they apply: its markup's styling attributes, then what a
2295    /// script set with `set_style`. These outrank every rule.
2296    pub declarations: Vec<(String, String)>,
2297}
2298
2299impl AncestorInfo {
2300    /// Construct from tag / classes / id with sibling position defaulted
2301    /// to `1 of 1`, element children assumed and no declarations of its
2302    /// own. The `with_*` builders fill in the rest when the caller has it.
2303    pub fn new(tag: impl Into<String>, classes: Vec<String>, id: Option<String>) -> Self {
2304        Self {
2305            tag: tag.into(),
2306            classes,
2307            id,
2308            child_index: 1,
2309            sibling_count: 1,
2310            siblings: None,
2311            has_element_children: true,
2312            text: None,
2313            declarations: Vec::new(),
2314        }
2315    }
2316
2317    /// Builder: set the sibling position, for `:nth-child` /
2318    /// `:first-child` ancestor matching.
2319    pub fn with_position(mut self, position: SiblingPosition) -> Self {
2320        self.child_index = position.child_index;
2321        self.sibling_count = position.sibling_count;
2322        self
2323    }
2324
2325    /// Builder: set the sibling list, for `+` / `~`.
2326    pub fn with_siblings(mut self, siblings: Option<Siblings>) -> Self {
2327        self.siblings = siblings;
2328        self
2329    }
2330
2331    /// Builder: say whether the element has element children and what text
2332    /// it holds, for `:empty`.
2333    pub fn with_content(mut self, has_element_children: bool, text: Option<String>) -> Self {
2334        self.has_element_children = has_element_children;
2335        self.text = text;
2336        self
2337    }
2338
2339    /// Builder: set what the element declares about itself.
2340    pub fn with_declarations(mut self, declarations: Vec<(String, String)>) -> Self {
2341        self.declarations = declarations;
2342        self
2343    }
2344
2345    fn to_ref(&self) -> ElementRef {
2346        ElementRef {
2347            tag: self.tag.clone(),
2348            classes: self.classes.clone(),
2349            id: self.id.clone(),
2350            child_index: self.child_index,
2351            sibling_count: self.sibling_count,
2352            siblings: self.siblings.as_ref().map(|s| s.0.clone()),
2353        }
2354    }
2355}
2356
2357/// A walk down a live tree that resolves each element from scratch: from
2358/// its identity, its place in the tree, and what it declares about itself,
2359/// against the stylesheet. It is the first cascade's walk over the document,
2360/// one element at a time, so an element resolved here comes out as it did
2361/// then, and a property nothing sets any more is unset.
2362///
2363/// Resolve an element, [`enter`](Self::enter) it to resolve its children,
2364/// and [`leave`](Self::leave) it after. Each child sees the custom
2365/// properties and inherited text its parent resolved, and ancestor
2366/// selectors match on the chain entered so far.
2367pub struct RestyleWalk<'a> {
2368    css: &'a Stylesheet,
2369    media: &'a MediaContext,
2370    /// The elements entered, root-first.
2371    parents: Vec<ElementRef>,
2372    /// What each level hands down; the last is the current one's.
2373    scopes: Vec<Scope>,
2374    /// The element resolved last, and the scope its children inherit.
2375    last: Option<(ElementRef, Scope)>,
2376    warnings: Vec<CssWarning>,
2377}
2378
2379impl<'a> RestyleWalk<'a> {
2380    /// A walk whose next element is a child of `ancestors` (root-first);
2381    /// with none, the next element is a document root. Each ancestor is
2382    /// resolved on the way down, so the scope it hands on is the one the
2383    /// first cascade handed on.
2384    pub fn new(css: &'a Stylesheet, media: &'a MediaContext, ancestors: &[AncestorInfo]) -> Self {
2385        let mut walk = Self {
2386            css,
2387            media,
2388            parents: Vec::with_capacity(ancestors.len() + 8),
2389            scopes: vec![Scope::default()],
2390            last: None,
2391            warnings: Vec::new(),
2392        };
2393        for ancestor in ancestors {
2394            walk.resolve(ancestor);
2395            walk.enter();
2396        }
2397        walk
2398    }
2399
2400    /// Resolve `node`, a child of the element entered last.
2401    ///
2402    /// The result holds the element's identity and every style property
2403    /// the cascade gave it, and nothing else.
2404    pub fn resolve(&mut self, node: &AncestorInfo) -> Attributes {
2405        let mut attrs = Attributes {
2406            classes: node.classes.clone(),
2407            id: node.id.clone(),
2408            ..Default::default()
2409        };
2410        for (property, value) in &node.declarations {
2411            match apply_declaration("markup", property, value, &mut attrs) {
2412                Ok(true) => {}
2413                Ok(false) => self.warnings.push(CssWarning {
2414                    selector: format!("<{}>", node.tag),
2415                    property: property.clone(),
2416                    message: "unknown property (declaration ignored)".to_string(),
2417                }),
2418                Err(e) => self.warnings.push(CssWarning {
2419                    selector: format!("<{}>", node.tag),
2420                    property: property.clone(),
2421                    message: e.to_string(),
2422                }),
2423            }
2424        }
2425        let me = node.to_ref();
2426        let scope = cascade_one(
2427            &mut attrs,
2428            self.css,
2429            self.media,
2430            self.scopes.last().expect("a walk always has a scope"),
2431            &self.parents,
2432            &me,
2433            node.has_element_children,
2434            node.text.as_deref(),
2435            &mut self.warnings,
2436        );
2437        self.last = Some((me, scope));
2438        attrs
2439    }
2440
2441    /// Descend into the element resolved last.
2442    pub fn enter(&mut self) {
2443        let (me, scope) = self
2444            .last
2445            .take()
2446            .expect("enter() follows the resolve() of the element entered");
2447        self.parents.push(me);
2448        self.scopes.push(scope);
2449    }
2450
2451    /// Climb back out of the element entered last.
2452    pub fn leave(&mut self) {
2453        self.parents.pop();
2454        if self.scopes.len() > 1 {
2455            self.scopes.pop();
2456        }
2457        self.last = None;
2458    }
2459
2460    /// The declarations skipped so far, deduplicated, emptying the list.
2461    pub fn take_warnings(&mut self) -> Vec<CssWarning> {
2462        let mut seen = std::collections::HashSet::new();
2463        let mut out = std::mem::take(&mut self.warnings);
2464        out.retain(|w| seen.insert((w.selector.clone(), w.property.clone(), w.message.clone())));
2465        out
2466    }
2467}
2468
2469/// Resolve one element of a live tree from scratch, given its ancestors
2470/// root-first; see [`RestyleWalk`]. What the dynamic DOM's
2471/// `computed_style` answers with.
2472pub fn resolve_element(
2473    node: &AncestorInfo,
2474    css: &Stylesheet,
2475    media: &MediaContext,
2476    ancestors: &[AncestorInfo],
2477) -> Attributes {
2478    RestyleWalk::new(css, media, ancestors).resolve(node)
2479}
2480
2481/// Apply one inline-style declaration (`element.style` layer) onto
2482/// `attrs`, overriding whatever the stylesheet cascade resolved. A restyle
2483/// passes these to [`RestyleWalk`] after the markup's own, so an inline
2484/// value beats every author / UA rule, mirroring DOM inline-style
2485/// precedence.
2486/// Returns `Ok(true)` when the property was recognized and applied,
2487/// `Ok(false)` for an unknown property (ignored), `Err` for an
2488/// unparseable value.
2489pub fn apply_inline_declaration(
2490    name: &str,
2491    value: &str,
2492    attrs: &mut Attributes,
2493) -> Result<bool, ParseError> {
2494    apply_declaration("inline-style", name, value, attrs)
2495}
2496
2497/// Give an element a style as its markup would: the value lands in `attrs`,
2498/// and the declaration joins [`Attributes::markup_styles`] under the
2499/// spelling the cascade files it under.
2500///
2501/// For the boxes a compiler builds itself (the parts of a `<dropdown>` or a
2502/// `<menu>`, a page's gate) rather than ones an author wrote. The record is
2503/// what a restyle rebuilds an element's markup tier from, so a value written
2504/// into an `Attributes` field alone is gone the first time a class, a theme
2505/// or a breakpoint re-resolves the element. Returns what
2506/// [`apply_inline_declaration`] returns; nothing is recorded unless the
2507/// declaration applied.
2508pub fn apply_markup_declaration(
2509    name: &str,
2510    value: &str,
2511    attrs: &mut Attributes,
2512) -> Result<bool, ParseError> {
2513    let applied = apply_declaration("markup", name, value, attrs)?;
2514    if applied {
2515        let property = canonical_style_property(name).unwrap_or(name);
2516        attrs
2517            .markup_styles
2518            .push((property.to_string(), value.to_string()));
2519    }
2520    Ok(applied)
2521}
2522
2523/// Read one resolved CSS property off `attrs` as a canonical string, for
2524/// the dynamic DOM `computed_style("prop")` getter. Covers the common
2525/// layout / paint / text properties; an unmodeled property returns `None`.
2526/// Colors render as `#rrggbb` / `#rrggbbaa`, lengths as `Npx` / `N%` /
2527/// `auto`, scalars as their decimal form.
2528pub fn computed_property(attrs: &Attributes, name: &str) -> Option<String> {
2529    fn hex(c: &Rgba) -> String {
2530        let to = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8;
2531        let (r, g, b, a) = (to(c.r), to(c.g), to(c.b), to(c.a));
2532        if a == 0xff {
2533            format!("#{r:02x}{g:02x}{b:02x}")
2534        } else {
2535            format!("#{r:02x}{g:02x}{b:02x}{a:02x}")
2536        }
2537    }
2538    fn len(l: &LengthSpec) -> String {
2539        match l {
2540            LengthSpec::Auto => "auto".to_string(),
2541            LengthSpec::Px(v) => format!("{v}px"),
2542            LengthSpec::Percent(v) => format!("{v}%"),
2543        }
2544    }
2545    fn edges(e: &Edges) -> String {
2546        format!("{}px {}px {}px {}px", e.top, e.right, e.bottom, e.left)
2547    }
2548    match canonical_property_name(name) {
2549        "text-color" => attrs.text_color.as_ref().map(hex),
2550        "selection-color" => attrs.selection_color.as_ref().map(hex),
2551        "caret-color" => attrs.caret_color.as_ref().map(hex),
2552        "bg" => attrs.bg.as_ref().and_then(|b| match b {
2553            BgSpec::Solid(c) => Some(hex(c)),
2554            BgSpec::Image(path) => Some(format!("url(\"{path}\")")),
2555            _ => None,
2556        }),
2557        "width" => attrs.width.as_ref().map(len),
2558        "height" => attrs.height.as_ref().map(len),
2559        "padding" => attrs.padding.as_ref().map(edges),
2560        "margin" => attrs.margin.as_ref().map(edges),
2561        "font-size" => attrs.font_size.map(|v| format!("{v}px")),
2562        "font-weight" => attrs.font_weight.map(|v| v.to_string()),
2563        "font-family" => attrs.font_family.clone(),
2564        "text-align" => attrs.text_align.map(|a| match a {
2565            TextAlignSpec::Start => "start".to_string(),
2566            TextAlignSpec::Center => "center".to_string(),
2567            TextAlignSpec::End => "end".to_string(),
2568        }),
2569        "opacity" => attrs.opacity.map(|v| v.to_string()),
2570        "radius" => attrs.radius.map(|v| format!("{v}px")),
2571        _ => None,
2572    }
2573}
2574
2575/// Property names [`computed_style_map`] enumerates for the full-map
2576/// `computed_style()` getter. Covers the layout / paint / text properties
2577/// the cascade models; unmodeled CSS properties are absent from the map.
2578pub const COMPUTED_STYLE_PROPERTIES: &[&str] = &[
2579    "width",
2580    "height",
2581    "padding",
2582    "margin",
2583    "bg",
2584    "text-color",
2585    "selection-color",
2586    "caret-color",
2587    "font-size",
2588    "font-weight",
2589    "font-family",
2590    "text-align",
2591    "opacity",
2592    "radius",
2593];
2594
2595/// Every resolved property in [`COMPUTED_STYLE_PROPERTIES`] that the
2596/// cascade set on `attrs`, as `(name, value)` pairs: the full-map form
2597/// of [`computed_property`] backing the dynamic DOM `computed_style()`
2598/// getter. An inspection call, not a per-frame path.
2599pub fn computed_style_map(attrs: &Attributes) -> Vec<(String, String)> {
2600    COMPUTED_STYLE_PROPERTIES
2601        .iter()
2602        .filter_map(|&p| computed_property(attrs, p).map(|v| (p.to_string(), v)))
2603        .collect()
2604}
2605
2606/// The names a browser knows a Lumen element by.
2607///
2608/// Two things Lumen spells in the selector itself have no browser
2609/// equivalent: an element's tag, which survives into the document only as
2610/// a class, and the four states no CSS pseudo-class covers. The web target
2611/// owns both names, and this crate must not depend on it, so
2612/// [`selector_to_web`] takes them from the caller. Hand it
2613/// `lumen_html::web_names()` rather than writing the strings out again.
2614#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2615pub struct WebNames<'a> {
2616    /// Prefix of the class every element carries for its tag: with
2617    /// `lm-`, a `<row>` is `.lm-row`.
2618    pub tag_class_prefix: &'a str,
2619    /// Attribute mirroring `:selected`.
2620    pub selected: &'a str,
2621    /// Attribute mirroring `:checked` on an element that is not a real
2622    /// HTML checkbox.
2623    pub checked: &'a str,
2624    /// Attribute mirroring `:disabled` on an element that takes no HTML
2625    /// `disabled`.
2626    pub disabled: &'a str,
2627    /// Attribute mirroring `:drag-over`.
2628    pub drag_over: &'a str,
2629    /// Class of the indicator a `<checkbox>` is drawn with, which is the
2630    /// element its checked state lands on.
2631    pub checkbox_part: &'a str,
2632    /// Class of the indicator a `<radio>` is drawn with.
2633    pub radio_part: &'a str,
2634}
2635
2636/// Which spelling of a selector [`write_selector`] writes.
2637enum Spelling<'a> {
2638    /// Lumen's own, as authored.
2639    Lumen,
2640    /// The browser's, for the web target.
2641    Web(WebNames<'a>),
2642}
2643
2644/// Serialize a compiled [`SelectorBuf`] back to CSS-ish text for the
2645/// dynamic DOM `matched_rules()` provenance view. Reconstructs
2646/// `tag#id.class:pseudo` compounds joined by their combinators. Pseudo
2647/// arguments (`:nth-child(2n)`, `:is(...)`) render their canonical form.
2648pub fn selector_to_css(sel: &SelectorBuf) -> String {
2649    let mut out = String::new();
2650    write_selector(&mut out, sel, &Spelling::Lumen);
2651    out
2652}
2653
2654/// Serialize a compiled [`SelectorBuf`] as the selector that picks the
2655/// same elements out of an emitted HTML document.
2656///
2657/// Three things change and nothing else does. A tag becomes the class the
2658/// document carries it as, wrapped in `:where()` so it keeps contributing
2659/// nothing to specificity: Lumen orders a tag rule under a class rule, and
2660/// without the wrapper the shift from `row` to `.lm-row` would invert that.
2661/// The four states the browser has no selector for become the attributes
2662/// the runtime mirrors them onto. `:checked` and `:disabled` become
2663/// `:is(<the real one>, <the mirror>)`, because a real checkbox is checked
2664/// in the browser's own eyes and a `<toggle>` is not.
2665///
2666/// A `<checkbox>` and a `<radio>` are two elements here, a row around the
2667/// control the browser draws, so a selector for one of them says which of
2668/// the two it means: the checked state moves to the control, the way it
2669/// routes into the indicator's fill on the desktop, and focus is read
2670/// through the row, because the browser focuses the control inside it.
2671///
2672/// Everything else, structural pseudo-classes and selector-list arguments
2673/// included, is the same text [`selector_to_css`] writes.
2674pub fn selector_to_web(sel: &SelectorBuf, names: &WebNames<'_>) -> String {
2675    let mut out = String::new();
2676    write_selector(&mut out, sel, &Spelling::Web(*names));
2677    out
2678}
2679
2680fn write_selector(out: &mut String, sel: &SelectorBuf, spelling: &Spelling<'_>) {
2681    for (comb, comp) in &sel.chain {
2682        match comb {
2683            Combinator::Subject => {}
2684            Combinator::Descendant => out.push(' '),
2685            Combinator::Child => out.push_str(" > "),
2686            Combinator::AdjacentSibling => out.push_str(" + "),
2687            Combinator::GeneralSibling => out.push_str(" ~ "),
2688        }
2689        write_compound(out, comp, spelling);
2690    }
2691}
2692
2693fn write_compound(out: &mut String, c: &CompoundSelector, spelling: &Spelling<'_>) {
2694    // The class of the indicator this compound's tag is drawn with, for the
2695    // two tags that have one. It is the element the state sits on, and the
2696    // rest of the compound still describes the row around it.
2697    let part = match (spelling, c.tag.as_deref()) {
2698        (Spelling::Web(names), Some("checkbox")) => Some(names.checkbox_part),
2699        (Spelling::Web(names), Some("radio")) => Some(names.radio_part),
2700        _ => None,
2701    };
2702    if let Some(tag) = &c.tag {
2703        match spelling {
2704            Spelling::Lumen => out.push_str(tag),
2705            Spelling::Web(names) => {
2706                out.push_str(":where(.");
2707                out.push_str(names.tag_class_prefix);
2708                out.push_str(tag);
2709                out.push(')');
2710            }
2711        }
2712    } else if c.id.is_none() && c.classes.is_empty() && c.pseudo_classes.is_empty() {
2713        out.push('*');
2714    }
2715    if let Some(id) = &c.id {
2716        out.push('#');
2717        out.push_str(id);
2718    }
2719    for class in &c.classes {
2720        out.push('.');
2721        out.push_str(class);
2722    }
2723    let (on_control, on_element): (Vec<&PseudoClass>, Vec<&PseudoClass>) = c
2724        .pseudo_classes
2725        .iter()
2726        .partition(|p| part.is_some() && matches!(p, PseudoClass::Checked | PseudoClass::Selected));
2727    for p in on_element {
2728        match p {
2729            // The browser focuses the control, so a rule the desktop draws
2730            // on the row is read from the row through what is inside it.
2731            PseudoClass::Focus if part.is_some() => out.push_str(":focus-within"),
2732            PseudoClass::FocusVisible if part.is_some() => out.push_str(":has(:focus-visible)"),
2733            _ => write_pseudo(out, p, spelling),
2734        }
2735    }
2736    if let Some(part) = part.filter(|_| !on_control.is_empty()) {
2737        out.push_str(" > .");
2738        out.push_str(part);
2739        for p in on_control {
2740            write_pseudo(out, p, spelling);
2741        }
2742    }
2743}
2744
2745fn write_pseudo(out: &mut String, p: &PseudoClass, spelling: &Spelling<'_>) {
2746    let names = match spelling {
2747        Spelling::Lumen => None,
2748        Spelling::Web(names) => Some(names),
2749    };
2750    match p {
2751        PseudoClass::Hover => out.push_str(":hover"),
2752        PseudoClass::Focus => out.push_str(":focus"),
2753        PseudoClass::FocusVisible => out.push_str(":focus-visible"),
2754        PseudoClass::Active => out.push_str(":active"),
2755        PseudoClass::Disabled => match names {
2756            Some(names) => {
2757                out.push_str(":is(:disabled, [");
2758                out.push_str(names.disabled);
2759                out.push_str("])");
2760            }
2761            None => out.push_str(":disabled"),
2762        },
2763        PseudoClass::Checked => match names {
2764            Some(names) => {
2765                out.push_str(":is(:checked, [");
2766                out.push_str(names.checked);
2767                out.push_str("])");
2768            }
2769            None => out.push_str(":checked"),
2770        },
2771        PseudoClass::Selected => match names {
2772            Some(names) => {
2773                out.push('[');
2774                out.push_str(names.selected);
2775                out.push(']');
2776            }
2777            None => out.push_str(":selected"),
2778        },
2779        PseudoClass::DragOver => match names {
2780            Some(names) => {
2781                out.push('[');
2782                out.push_str(names.drag_over);
2783                out.push(']');
2784            }
2785            None => out.push_str(":drag-over"),
2786        },
2787        PseudoClass::Root => out.push_str(":root"),
2788        PseudoClass::FirstChild => out.push_str(":first-child"),
2789        PseudoClass::LastChild => out.push_str(":last-child"),
2790        PseudoClass::OnlyChild => out.push_str(":only-child"),
2791        PseudoClass::Empty => out.push_str(":empty"),
2792        PseudoClass::NthChild(anb) => {
2793            out.push_str(":nth-child(");
2794            out.push_str(&anb_to_css(*anb));
2795            out.push(')');
2796        }
2797        PseudoClass::Is(args) => write_pseudo_list(out, ":is(", args, spelling),
2798        PseudoClass::Where(args) => write_pseudo_list(out, ":where(", args, spelling),
2799        PseudoClass::Not(args) => write_pseudo_list(out, ":not(", args, spelling),
2800    }
2801}
2802
2803fn write_pseudo_list(out: &mut String, open: &str, args: &[SelectorBuf], spelling: &Spelling<'_>) {
2804    out.push_str(open);
2805    for (i, arg) in args.iter().enumerate() {
2806        if i > 0 {
2807            out.push_str(", ");
2808        }
2809        write_selector(out, arg, spelling);
2810    }
2811    out.push(')');
2812}
2813
2814/// `an+b` as CSS writes it: no `n` term when `a` is zero, and a negative
2815/// `b` written as the subtraction it is rather than as `+-`.
2816fn anb_to_css(anb: AnB) -> String {
2817    if anb.a == 0 {
2818        return anb.b.to_string();
2819    }
2820    let sign = if anb.b < 0 { '-' } else { '+' };
2821    format!("{}n{sign}{}", anb.a, anb.b.unsigned_abs())
2822}
2823
2824/// Serialize a [`MediaQuery`] back to the condition text that follows
2825/// `@media`, the at-rule counterpart of [`selector_to_css`]. Features keep
2826/// the spelling the parser accepts and are `and`-joined in source order, so
2827/// the result parses back to the same query.
2828///
2829/// A query with no features serializes to `all`: the condition that always
2830/// matches, which is what [`MediaQuery::matches`] answers for an empty
2831/// feature list.
2832pub fn media_query_to_css(mq: &MediaQuery) -> String {
2833    fn feature(f: &MediaFeature) -> String {
2834        match f {
2835            MediaFeature::PrefersColorScheme(v) => {
2836                let v = match v {
2837                    ColorSchemePreference::Dark => "dark",
2838                    ColorSchemePreference::Light => "light",
2839                    ColorSchemePreference::NoPreference => "no-preference",
2840                };
2841                format!("(prefers-color-scheme: {v})")
2842            }
2843            MediaFeature::PrefersReducedMotion(v) => {
2844                let v = match v {
2845                    MotionPreference::Reduce => "reduce",
2846                    MotionPreference::NoPreference => "no-preference",
2847                };
2848                format!("(prefers-reduced-motion: {v})")
2849            }
2850            MediaFeature::PrefersContrast(v) => {
2851                let v = match v {
2852                    ContrastPreference::More => "more",
2853                    ContrastPreference::Less => "less",
2854                    ContrastPreference::Custom => "custom",
2855                    ContrastPreference::NoPreference => "no-preference",
2856                };
2857                format!("(prefers-contrast: {v})")
2858            }
2859            MediaFeature::MinWidth(px) => format!("(min-width: {px}px)"),
2860            MediaFeature::MaxWidth(px) => format!("(max-width: {px}px)"),
2861            MediaFeature::Width(px) => format!("(width: {px}px)"),
2862        }
2863    }
2864    if mq.features.is_empty() {
2865        return "all".to_string();
2866    }
2867    mq.features
2868        .iter()
2869        .map(feature)
2870        .collect::<Vec<_>>()
2871        .join(" and ")
2872}
2873
2874/// One stylesheet rule that matched an element, with its cascade
2875/// provenance, for the dynamic DOM `matched_rules()` inspection getter.
2876#[derive(Debug, Clone)]
2877pub struct MatchedRuleInfo {
2878    /// The specific selector (of the rule's selector list) that matched,
2879    /// serialized back to CSS text.
2880    pub selector: String,
2881    /// Selectors-4 specificity of the matched selector.
2882    pub specificity: Specificity,
2883    /// Cascade origin (user-agent skin vs author sheet).
2884    pub origin: Origin,
2885    /// Source order of the rule within the stylesheet.
2886    pub source_order: usize,
2887    /// The rule's declarations as `(property, value)` pairs (raw,
2888    /// var()-unresolved), in source order.
2889    pub declarations: Vec<(String, String)>,
2890}
2891
2892/// Rules that match `subject` given its `ancestors` (root-first), with
2893/// cascade provenance, ascending in cascade order (last wins). Reuses the
2894/// cascade matcher's own `collect_matching_rules`, so this is the same
2895/// match set the restyle pass resolves; no second selector engine. An
2896/// inspection call backing `matched_rules()`.
2897pub fn matched_rules_for(
2898    subject: &AncestorInfo,
2899    css: &Stylesheet,
2900    media: &MediaContext,
2901    ancestors: &[AncestorInfo],
2902    has_element_children: bool,
2903    text_body: Option<&str>,
2904) -> Vec<MatchedRuleInfo> {
2905    let me = subject.to_ref();
2906    let parents: Vec<ElementRef> = ancestors.iter().map(AncestorInfo::to_ref).collect();
2907    let is_root = ancestors.is_empty();
2908    let matched = collect_matching_rules(
2909        css,
2910        media,
2911        &me,
2912        &parents,
2913        has_element_children,
2914        text_body,
2915        is_root,
2916    );
2917    matched
2918        .into_iter()
2919        .map(|m| {
2920            let rule = &css.rules[m.rule_idx];
2921            MatchedRuleInfo {
2922                selector: selector_to_css(&rule.selectors[m.selector_idx]),
2923                specificity: m.specificity,
2924                origin: m.origin,
2925                source_order: m.source_order,
2926                declarations: rule
2927                    .declarations
2928                    .iter()
2929                    .map(|d| (d.name.clone(), d.value.clone()))
2930                    .collect(),
2931            }
2932        })
2933        .collect()
2934}
2935
2936fn resolve_vars(
2937    ctx: &str,
2938    decl_name: &str,
2939    value: &str,
2940    vars: &std::collections::HashMap<String, String>,
2941) -> Result<String, ParseError> {
2942    let prefix = format!("{ctx} '{decl_name}' = '{value}': ");
2943    crate::css_vars::resolve(value, vars, &prefix).map_err(ParseError::Xml)
2944}
2945
2946fn describe_selector_for_error(sels: &[SelectorBuf], idx: usize) -> String {
2947    let Some(sel) = sels.get(idx) else {
2948        return "*".into();
2949    };
2950    let mut out = String::new();
2951    for (i, (combinator, c)) in sel.chain.iter().enumerate() {
2952        if i > 0 {
2953            match combinator {
2954                Combinator::Descendant => out.push(' '),
2955                Combinator::Child => out.push_str(" > "),
2956                Combinator::AdjacentSibling => out.push_str(" + "),
2957                Combinator::GeneralSibling => out.push_str(" ~ "),
2958                Combinator::Subject => {}
2959            }
2960        }
2961        if let Some(t) = &c.tag {
2962            out.push_str(t);
2963        }
2964        if let Some(id) = &c.id {
2965            out.push('#');
2966            out.push_str(id);
2967        }
2968        for cls in &c.classes {
2969            out.push('.');
2970            out.push_str(cls);
2971        }
2972        for p in &c.pseudo_classes {
2973            out.push(':');
2974            out.push_str(pseudo_name(p));
2975        }
2976    }
2977    if out.is_empty() {
2978        out = "*".into();
2979    }
2980    out
2981}
2982
2983fn pseudo_name(p: &PseudoClass) -> &'static str {
2984    match p {
2985        PseudoClass::Hover => "hover",
2986        PseudoClass::Focus => "focus",
2987        PseudoClass::FocusVisible => "focus-visible",
2988        PseudoClass::Active => "active",
2989        PseudoClass::Disabled => "disabled",
2990        PseudoClass::Checked => "checked",
2991        PseudoClass::Selected => "selected",
2992        PseudoClass::DragOver => "drag-over",
2993        PseudoClass::Root => "root",
2994        PseudoClass::FirstChild => "first-child",
2995        PseudoClass::LastChild => "last-child",
2996        PseudoClass::OnlyChild => "only-child",
2997        PseudoClass::Empty => "empty",
2998        PseudoClass::NthChild(_) => "nth-child",
2999        PseudoClass::Is(_) => "is",
3000        PseudoClass::Where(_) => "where",
3001        PseudoClass::Not(_) => "not",
3002    }
3003}
3004
3005/// Route a CSS declaration to the correct [`Attributes`] field based on
3006/// the subject compound's matched pseudo state.
3007/// Returns `Ok(true)` when the declaration was recognised (applied or
3008/// deliberately consumed for the pseudo state), `Ok(false)` for an
3009/// unknown property so the caller can warn.
3010fn apply_decl_for_pseudo(
3011    ctx: &str,
3012    name: &str,
3013    value: &str,
3014    pseudo: &SubjectPseudo,
3015    attrs: &mut Attributes,
3016) -> Result<bool, ParseError> {
3017    let name = canonical_property_name(name);
3018    if !pseudo.any() {
3019        return apply_declaration(ctx, name, value, attrs);
3020    }
3021    // State-routed properties. `:focus-visible` shares the `:focus`
3022    // slots for everything except `outline` (which gets its own
3023    // keyboard-only slot); `:checked` / `:selected` route `bg` only.
3024    match name {
3025        "bg" => {
3026            let c = parse_color(ctx, name, value)?;
3027            if pseudo.active {
3028                attrs.press_bg = Some(c);
3029            } else if pseudo.checked {
3030                attrs.checked_bg = Some(c);
3031            } else if pseudo.selected {
3032                attrs.selected_bg = Some(c);
3033            } else if pseudo.disabled {
3034                attrs.disabled_bg = Some(c);
3035            } else if pseudo.drag_over {
3036                attrs.drag_over_bg = Some(c);
3037            } else {
3038                // hover / focus / focus-visible share one slot -
3039                // preserves the existing API surface.
3040                attrs.hover_bg = Some(c);
3041            }
3042            Ok(true)
3043        }
3044        "outline" if pseudo.focus || pseudo.focus_visible => {
3045            let parts: Vec<&str> = value.split_whitespace().collect();
3046            if parts.len() != 2 {
3047                return Err(crate::values::bad(
3048                    ctx,
3049                    name,
3050                    value,
3051                    "expected '<width-px> <#color>'".into(),
3052                ));
3053            }
3054            let spec = crate::layout_ir::OutlineSpec {
3055                width: parse_f32(ctx, name, parts[0])?,
3056                color: parse_color(ctx, name, parts[1])?,
3057                offset: attrs.outline_offset.unwrap_or(0.0),
3058            };
3059            if pseudo.focus_visible {
3060                attrs.focus_visible_outline = Some(spec);
3061            } else {
3062                attrs.focus_outline = Some(spec);
3063            }
3064            Ok(true)
3065        }
3066        "outline-offset" if pseudo.focus || pseudo.focus_visible => {
3067            let off = parse_f32(ctx, name, value)?;
3068            attrs.outline_offset = Some(off);
3069            // Retro-apply onto outlines already parsed in this or an
3070            // earlier rule (declaration order within a rule is free).
3071            if let Some(o) = attrs.focus_outline.as_mut() {
3072                o.offset = off;
3073            }
3074            if let Some(o) = attrs.focus_visible_outline.as_mut() {
3075                o.offset = off;
3076            }
3077            Ok(true)
3078        }
3079        "border" if pseudo.hover || pseudo.focus || pseudo.focus_visible => {
3080            // `:hover { border: ... }` / `:focus { border: ... }` - state
3081            // border swap, resolved to concrete widths + color here.
3082            let sh = crate::values::parse_border_shorthand(ctx, name, value)?;
3083            let spec = border_shorthand_to_paint(&sh);
3084            if pseudo.focus || pseudo.focus_visible {
3085                attrs.focus_border = spec;
3086            } else {
3087                attrs.hover_border = spec;
3088            }
3089            Ok(true)
3090        }
3091        "text-color" => {
3092            let c = parse_color(ctx, name, value)?;
3093            if pseudo.active {
3094                attrs.active_text_color = Some(c);
3095            } else if pseudo.disabled {
3096                attrs.disabled_text_color = Some(c);
3097            } else if pseudo.drag_over {
3098                attrs.drag_over_text_color = Some(c);
3099            } else if pseudo.focus_visible {
3100                attrs.focus_visible_text_color = Some(c);
3101            } else if pseudo.focus {
3102                attrs.focus_text_color = Some(c);
3103            } else if pseudo.hover {
3104                attrs.hover_text_color = Some(c);
3105            }
3106            Ok(true)
3107        }
3108        "opacity" => {
3109            let v = parse_f32(ctx, name, value)?.clamp(0.0, 1.0);
3110            if pseudo.active {
3111                attrs.active_opacity = Some(v);
3112            } else if pseudo.disabled {
3113                attrs.disabled_opacity = Some(v);
3114            } else if pseudo.drag_over {
3115                attrs.drag_over_opacity = Some(v);
3116            } else if pseudo.focus_visible {
3117                attrs.focus_visible_opacity = Some(v);
3118            } else if pseudo.focus {
3119                attrs.focus_opacity = Some(v);
3120            } else if pseudo.hover {
3121                attrs.hover_opacity = Some(v);
3122            }
3123            Ok(true)
3124        }
3125        "shadow" | "box-shadow" => {
3126            let shadows = parse_box_shadow(ctx, name, value)?;
3127            if pseudo.active {
3128                attrs.active_shadows = Some(shadows);
3129            } else if pseudo.disabled {
3130                attrs.disabled_shadows = Some(shadows);
3131            } else if pseudo.drag_over {
3132                attrs.drag_over_shadows = Some(shadows);
3133            } else if pseudo.focus_visible {
3134                attrs.focus_visible_shadows = Some(shadows);
3135            } else if pseudo.focus {
3136                attrs.focus_shadows = Some(shadows);
3137            } else if pseudo.hover {
3138                attrs.hover_shadows = Some(shadows);
3139            }
3140            Ok(true)
3141        }
3142        // Any other property under a state pseudo is consumed silently
3143        // (not routable yet) - matches the pre-existing behavior.
3144        _ => Ok(true),
3145    }
3146}
3147
3148/// Every property name [`apply_declaration`] applies, in the canonical
3149/// spelling its match arms use; alias spellings reach this list through
3150/// [`canonical_property_name`]. A property added to the cascade is added
3151/// here in the same change, or a consumer that sorts declarations by kind
3152/// takes it for something the cascade ignores.
3153///
3154/// Names here are canonical, so a consumer walking the list to prove it
3155/// handles every property runs its own input through
3156/// [`canonical_property_name`] first.
3157pub const STYLE_PROPERTIES: &[&str] = &[
3158    "width",
3159    "height",
3160    "bg",
3161    "radius",
3162    "border-top-left-radius",
3163    "border-top-right-radius",
3164    "border-bottom-right-radius",
3165    "border-bottom-left-radius",
3166    "padding",
3167    "margin",
3168    "text-color",
3169    "selection-color",
3170    "caret-color",
3171    "selection-text-color",
3172    "caret-width",
3173    "caret-blink",
3174    "password-character",
3175    "hover-bg",
3176    "scroll",
3177    "sensitivity",
3178    "inertia",
3179    "tab-index",
3180    "draggable",
3181    "press-bg",
3182    "font-size",
3183    "font-family",
3184    "font-weight",
3185    "line-height",
3186    "gap",
3187    "row-gap",
3188    "column-gap",
3189    "grow",
3190    "flex-shrink",
3191    "flex-basis",
3192    "flex-wrap",
3193    "align-content",
3194    "flex-direction",
3195    "flex",
3196    "z-index",
3197    "border",
3198    "border-width",
3199    "border-color",
3200    "border-top-color",
3201    "border-right-color",
3202    "border-bottom-color",
3203    "border-left-color",
3204    "border-top",
3205    "border-right",
3206    "border-bottom",
3207    "border-left",
3208    "border-style",
3209    "border-top-width",
3210    "border-right-width",
3211    "border-bottom-width",
3212    "border-left-width",
3213    "box-sizing",
3214    "hover-border",
3215    "focus-border",
3216    "focus-outline",
3217    "outline-offset",
3218    "knob-color",
3219    "knob-inset",
3220    "thumb-size",
3221    "popup-gap",
3222    "display",
3223    "grid-template-rows",
3224    "grid-template-columns",
3225    "grid-row",
3226    "grid-column",
3227    "align",
3228    "align-items",
3229    "align-self",
3230    "justify-items",
3231    "justify-self",
3232    "justify",
3233    "text-align",
3234    "wrap",
3235    "max-lines",
3236    "progress-duration",
3237    "progress-chunk",
3238    "text-overflow",
3239    "position",
3240    "inset",
3241    "min-width",
3242    "min-height",
3243    "max-width",
3244    "max-height",
3245    "aspect-ratio",
3246    "opacity",
3247    "disabled-opacity",
3248    "shadow",
3249    "box-shadow",
3250    "fit",
3251    "bg-fit",
3252    "transition",
3253    "transition-property",
3254    "transition-delay",
3255    "transition-duration",
3256    "transition-timing-function",
3257    "animation",
3258    "animation-name",
3259    "animation-duration",
3260    "animation-timing-function",
3261    "animation-delay",
3262    "animation-iteration-count",
3263    "animation-direction",
3264    "animation-fill-mode",
3265    "animation-play-state",
3266    "scrollbar-color",
3267    "scrollbar-width",
3268    "scrollbar-thickness",
3269    "scrollbar-thickness-thin",
3270    "scrollbar-margin",
3271    "scrollbar-min-thumb",
3272    "scrollbar-track-hover",
3273    "scrollbar-hover-boost",
3274    "scrollbar-fade-delay",
3275    "scrollbar-fade-duration",
3276    "padding-inline-start",
3277    "padding-inline-end",
3278    "padding-block-start",
3279    "padding-block-end",
3280    "margin-inline-start",
3281    "margin-inline-end",
3282    "margin-block-start",
3283    "margin-block-end",
3284    "inset-inline-start",
3285    "inset-inline-end",
3286    "inset-block-start",
3287    "inset-block-end",
3288    "border-inline-start-width",
3289    "border-inline-end-width",
3290    "border-block-start-width",
3291    "border-block-end-width",
3292    "overflow",
3293    "overflow-x",
3294    "overflow-y",
3295    "layout-boundary",
3296];
3297
3298/// `true` when `name` is a style property the cascade applies, under either
3299/// its Lumen spelling or its standard-CSS one. Consumers that sort authored
3300/// declarations by kind ask this before they hand one to the cascade;
3301/// anything else in a rule (a `--custom` property, a typo) answers `false`.
3302pub fn is_style_property(name: &str) -> bool {
3303    canonical_style_property(name).is_some()
3304}
3305
3306/// The one spelling the cascade files `name` under, or `None` when it is not
3307/// a style property at all.
3308///
3309/// A property has two accepted spellings whenever Lumen's short name and the
3310/// standard CSS one differ (`bg` / `background`, `radius` / `border-radius`).
3311/// Both answer with the same string here, so a consumer recording what an
3312/// element was styled with records one name per property rather than whichever
3313/// the author happened to type.
3314pub fn canonical_style_property(name: &str) -> Option<&'static str> {
3315    let canonical = canonical_property_name(name);
3316    STYLE_PROPERTIES.iter().copied().find(|p| *p == canonical)
3317}
3318
3319/// Map standard CSS property names onto Lumen's native slots so real-world
3320/// stylesheets work as written. The Lumen short names stay accepted.
3321pub fn canonical_property_name(name: &str) -> &str {
3322    match name {
3323        "color" => "text-color",
3324        "background" | "background-color" => "bg",
3325        "border-radius" => "radius",
3326        "flex-grow" => "grow",
3327        // The house name and the standard name are interchangeable in
3328        // both directions: `justify` / `justify-content`, `fit` /
3329        // `object-fit`, `shrink` / `flex-shrink`. Each pair collapses
3330        // onto the spelling the property table below matches on.
3331        "justify-content" => "justify",
3332        "object-fit" => "fit",
3333        "shrink" => "flex-shrink",
3334        // `white-space: nowrap | normal` shares the wrap slot - the
3335        // `wrap` parser already accepts both spellings as values.
3336        "white-space" => "wrap",
3337        n => n,
3338    }
3339}
3340
3341/// Resolve a parsed `border` shorthand into the state-border paint spec
3342/// used by `hover-border:` / `focus-border:` / `:hover { border }`.
3343/// `border: none` (or width 0) clears the state override.
3344fn border_shorthand_to_paint(
3345    sh: &crate::values::BorderShorthand,
3346) -> Option<crate::layout_ir::BorderPaintSpec> {
3347    use crate::layout_ir::{BorderStyleSpec, Edges, Rgba};
3348    match sh.style {
3349        Some(BorderStyleSpec::Solid) => {
3350            let w = sh.width.unwrap_or(3.0);
3351            if w <= 0.0 {
3352                return None;
3353            }
3354            Some(crate::layout_ir::BorderPaintSpec {
3355                widths: Edges::all(w),
3356                color: sh.color.unwrap_or(Rgba {
3357                    r: 0.0,
3358                    g: 0.0,
3359                    b: 0.0,
3360                    a: 1.0,
3361                }),
3362            })
3363        }
3364        _ => None,
3365    }
3366}
3367
3368/// Returns `Ok(true)` when the property is known, `Ok(false)` when it
3369/// isn't (caller warns; declaration is ignored).
3370#[allow(clippy::collapsible_match, clippy::collapsible_if)]
3371fn apply_declaration(
3372    ctx: &str,
3373    name: &str,
3374    value: &str,
3375    attrs: &mut Attributes,
3376) -> Result<bool, ParseError> {
3377    let name = canonical_property_name(name);
3378    // No more `is_none()` guards - cascade ordering at the caller has
3379    // already arranged last-wins per CSS Cascade-5 section 6.4.4.
3380    match name {
3381        "width" => attrs.width = Some(parse_length(ctx, name, value)?),
3382        "height" => attrs.height = Some(parse_length(ctx, name, value)?),
3383        "bg" => attrs.bg = Some(parse_bg(ctx, name, value)?),
3384        "radius" => {
3385            // CSS `border-radius` shorthand: 1 value = uniform; 2-4
3386            // values map onto per-corner radii in the standard
3387            // [tl, tr, br, bl] rotation. The uniform slot always
3388            // carries the max corner for uniform-only consumers.
3389            let parts: Vec<&str> = value.split_whitespace().collect();
3390            match parts.as_slice() {
3391                [one] => {
3392                    attrs.radius = Some(parse_f32(ctx, name, one)?);
3393                    attrs.radius_corners = None;
3394                }
3395                _ => {
3396                    let corners = crate::values::parse_corner_radii(ctx, name, value)?;
3397                    attrs.radius = Some(corners.iter().copied().fold(0.0_f32, f32::max));
3398                    attrs.radius_corners = Some(corners);
3399                }
3400            }
3401        }
3402        "border-top-left-radius"
3403        | "border-top-right-radius"
3404        | "border-bottom-right-radius"
3405        | "border-bottom-left-radius" => {
3406            let v = parse_f32(ctx, name, value)?;
3407            let base = attrs.radius.unwrap_or(0.0);
3408            let corners = attrs.radius_corners.get_or_insert([base; 4]);
3409            match name {
3410                "border-top-left-radius" => corners[0] = v,
3411                "border-top-right-radius" => corners[1] = v,
3412                "border-bottom-right-radius" => corners[2] = v,
3413                "border-bottom-left-radius" => corners[3] = v,
3414                _ => unreachable!(),
3415            }
3416            attrs.radius = Some(corners.iter().copied().fold(0.0_f32, f32::max));
3417        }
3418        "padding" => attrs.padding = Some(parse_edges(ctx, name, value)?),
3419        "margin" => attrs.margin = Some(parse_edges(ctx, name, value)?),
3420        "text-color" => attrs.text_color = Some(parse_color(ctx, name, value)?),
3421        "selection-color" => attrs.selection_color = Some(parse_color(ctx, name, value)?),
3422        "caret-color" => attrs.caret_color = Some(parse_color(ctx, name, value)?),
3423        "selection-text-color" => attrs.selection_text_color = Some(parse_color(ctx, name, value)?),
3424        // Lumen-native caret geometry/timing - sibling of `caret-color`,
3425        // only meaningful on `<input>` / `<textarea>`.
3426        "caret-width" => attrs.caret_width = Some(parse_f32(ctx, name, value)?),
3427        "caret-blink" => attrs.caret_blink_ms = Some(parse_duration_ms(ctx, name, value.trim())?),
3428        // The glyph substituted for every character of a masked text
3429        // input. Exactly one Unicode scalar value; multi-char strings
3430        // (including multi-codepoint graphemes) are rejected rather than
3431        // silently truncated.
3432        "password-character" => {
3433            let mut chars = value.chars();
3434            let c = chars
3435                .next()
3436                .ok_or_else(|| bad(ctx, name, value, "expected a single character".into()))?;
3437            if chars.next().is_some() {
3438                return Err(bad(
3439                    ctx,
3440                    name,
3441                    value,
3442                    "expected exactly one character".into(),
3443                ));
3444            }
3445            attrs.password_character = Some(c);
3446        }
3447        "hover-bg" => attrs.hover_bg = Some(parse_color(ctx, name, value)?),
3448        "scroll" => {
3449            attrs.scroll = Some(match value {
3450                "y" => ScrollAxisSpec::Y,
3451                "x" => ScrollAxisSpec::X,
3452                "both" => ScrollAxisSpec::Both,
3453                other => {
3454                    return Err(bad(
3455                        ctx,
3456                        name,
3457                        value,
3458                        format!("unknown scroll axis '{other}'"),
3459                    ));
3460                }
3461            });
3462        }
3463        "sensitivity" => attrs.sensitivity = Some(parse_f32(ctx, name, value)?),
3464        "inertia" => attrs.inertia = Some(parse_f32(ctx, name, value)?),
3465        "tab-index" => attrs.tab_index = Some(parse_i32(ctx, name, value)?),
3466        "draggable" => attrs.draggable = matches!(value, "true" | "yes"),
3467        "press-bg" => attrs.press_bg = Some(parse_color(ctx, name, value)?),
3468        "font-size" => attrs.font_size = Some(parse_f32(ctx, name, value)?),
3469        "font-family" => {
3470            attrs.font_family = Some(value.trim().to_string());
3471        }
3472        "font-weight" => {
3473            attrs.font_weight = Some(crate::values::parse_font_weight(ctx, name, value)?);
3474        }
3475        // CSS `line-height`: a bare number is a unitless multiplier of
3476        // the element's own font size (scales with it); a `px` value is
3477        // a fixed line box height (does not). Kept as two
3478        // `LineHeightSpec` variants rather than one number - see the
3479        // type's doc comment.
3480        "line-height" => {
3481            let v = value.trim();
3482            attrs.line_height = Some(if let Some(rest) = v.strip_suffix("px") {
3483                LineHeightSpec::Px(parse_f32(ctx, name, rest)?)
3484            } else {
3485                let n = parse_f32(ctx, name, v)?;
3486                if n < 0.0 {
3487                    return Err(bad(
3488                        ctx,
3489                        name,
3490                        value,
3491                        "line-height must be \u{2265} 0".into(),
3492                    ));
3493                }
3494                LineHeightSpec::Multiplier(n)
3495            });
3496        }
3497        "gap" => {
3498            // W5.9: `gap: <r> <c>` shorthand. One value -> both axes via
3499            // the legacy `attrs.gap` slot. Two values -> split into
3500            // `gap_row` + `gap_column` so the per-axis path wins over
3501            // the shorthand in `From<&Attributes> for Style`. Each term
3502            // may be a percent (`gap: 5%`) which resolves against the
3503            // container's content box per CSS.
3504            let one_term = |attr_px: &mut Option<f32>, attr_pct: &mut Option<f32>, s: &str| {
3505                if let Some(rest) = s.trim().strip_suffix('%') {
3506                    let p = parse_f32(ctx, name, rest)?;
3507                    *attr_pct = Some(p);
3508                } else {
3509                    *attr_px = Some(parse_f32(ctx, name, s)?);
3510                }
3511                Ok::<(), ParseError>(())
3512            };
3513            let parts: Vec<&str> = value.split_whitespace().collect();
3514            match parts.as_slice() {
3515                [one] => one_term(&mut attrs.gap, &mut attrs.gap_pct, one)?,
3516                [r, c] => {
3517                    one_term(&mut attrs.gap_row, &mut attrs.gap_row_pct, r)?;
3518                    one_term(&mut attrs.gap_column, &mut attrs.gap_column_pct, c)?;
3519                }
3520                _ => {
3521                    return Err(bad(
3522                        ctx,
3523                        name,
3524                        value,
3525                        "expected '<row+col>' or '<row> <col>'".into(),
3526                    ));
3527                }
3528            }
3529        }
3530        "row-gap" => {
3531            if let Some(rest) = value.trim().strip_suffix('%') {
3532                attrs.gap_row_pct = Some(parse_f32(ctx, name, rest)?);
3533            } else {
3534                attrs.gap_row = Some(parse_f32(ctx, name, value)?);
3535            }
3536        }
3537        "column-gap" => {
3538            if let Some(rest) = value.trim().strip_suffix('%') {
3539                attrs.gap_column_pct = Some(parse_f32(ctx, name, rest)?);
3540            } else {
3541                attrs.gap_column = Some(parse_f32(ctx, name, value)?);
3542            }
3543        }
3544        "grow" => attrs.grow = Some(parse_f32(ctx, name, value)?),
3545        "flex-shrink" => attrs.shrink = Some(parse_f32(ctx, name, value)?),
3546        "flex-basis" => attrs.basis = Some(parse_length(ctx, name, value)?),
3547        "flex-wrap" => {
3548            attrs.flex_wrap = Some(match value.trim() {
3549                "nowrap" => crate::layout_ir::FlexWrapSpec::NoWrap,
3550                "wrap" => crate::layout_ir::FlexWrapSpec::Wrap,
3551                "wrap-reverse" => crate::layout_ir::FlexWrapSpec::WrapReverse,
3552                other => {
3553                    return Err(bad(
3554                        ctx,
3555                        name,
3556                        value,
3557                        format!("unknown flex-wrap '{other}'"),
3558                    ));
3559                }
3560            });
3561        }
3562        "align-content" => {
3563            use crate::layout_ir::AlignContentSpec as A;
3564            attrs.align_content = Some(match value.trim() {
3565                "start" | "flex-start" => A::Start,
3566                "end" | "flex-end" => A::End,
3567                "center" => A::Center,
3568                "stretch" | "normal" => A::Stretch,
3569                "space-between" => A::SpaceBetween,
3570                "space-around" => A::SpaceAround,
3571                "space-evenly" => A::SpaceEvenly,
3572                other => {
3573                    return Err(bad(
3574                        ctx,
3575                        name,
3576                        value,
3577                        format!("unknown align-content '{other}'"),
3578                    ));
3579                }
3580            });
3581        }
3582        "flex-direction" => {
3583            attrs.flex = Some(match value.trim() {
3584                "row" => crate::layout_ir::FlexAxis::Row,
3585                "column" => crate::layout_ir::FlexAxis::Column,
3586                "row-reverse" => crate::layout_ir::FlexAxis::RowReverse,
3587                "column-reverse" => crate::layout_ir::FlexAxis::ColumnReverse,
3588                other => {
3589                    return Err(bad(
3590                        ctx,
3591                        name,
3592                        value,
3593                        format!("unknown flex-direction '{other}'"),
3594                    ));
3595                }
3596            });
3597        }
3598        "flex" => {
3599            // CSS `flex: <grow> [<shrink>] [<basis>]` plus the keyword
3600            // forms. Per spec, a unitless single value sets grow with
3601            // shrink=1 and basis=0%.
3602            let v = value.trim();
3603            match v {
3604                "none" => {
3605                    attrs.grow = Some(0.0);
3606                    attrs.shrink = Some(0.0);
3607                    attrs.basis = Some(crate::layout_ir::LengthSpec::Auto);
3608                }
3609                "auto" => {
3610                    attrs.grow = Some(1.0);
3611                    attrs.shrink = Some(1.0);
3612                    attrs.basis = Some(crate::layout_ir::LengthSpec::Auto);
3613                }
3614                "initial" => {
3615                    attrs.grow = Some(0.0);
3616                    attrs.shrink = Some(1.0);
3617                    attrs.basis = Some(crate::layout_ir::LengthSpec::Auto);
3618                }
3619                _ => {
3620                    let parts: Vec<&str> = v.split_whitespace().collect();
3621                    match parts.as_slice() {
3622                        [g] => {
3623                            attrs.grow = Some(parse_f32(ctx, name, g)?);
3624                            attrs.shrink = Some(1.0);
3625                            attrs.basis = Some(crate::layout_ir::LengthSpec::Percent(0.0));
3626                        }
3627                        [g, s] => {
3628                            attrs.grow = Some(parse_f32(ctx, name, g)?);
3629                            // Second term is either <shrink> (number) or
3630                            // <basis> (length with unit / auto).
3631                            if let Ok(shrink) = s.parse::<f32>() {
3632                                attrs.shrink = Some(shrink);
3633                                attrs.basis = Some(crate::layout_ir::LengthSpec::Percent(0.0));
3634                            } else {
3635                                attrs.shrink = Some(1.0);
3636                                attrs.basis = Some(parse_length(ctx, name, s)?);
3637                            }
3638                        }
3639                        [g, s, b] => {
3640                            attrs.grow = Some(parse_f32(ctx, name, g)?);
3641                            attrs.shrink = Some(parse_f32(ctx, name, s)?);
3642                            attrs.basis = Some(parse_length(ctx, name, b)?);
3643                        }
3644                        _ => {
3645                            return Err(bad(
3646                                ctx,
3647                                name,
3648                                value,
3649                                "expected 'flex: <grow> [<shrink>] [<basis>]'".into(),
3650                            ));
3651                        }
3652                    }
3653                }
3654            }
3655        }
3656        "z-index" => {
3657            attrs.z_index = Some(if value.trim() == "auto" {
3658                0
3659            } else {
3660                parse_i32(ctx, name, value)?
3661            });
3662        }
3663        "border" => {
3664            let sh = crate::values::parse_border_shorthand(ctx, name, value)?;
3665            // CSS shorthand semantics: reset all three longhands, then
3666            // apply the authored terms (omitted width -> medium, omitted
3667            // color -> currentColor, resolved at spawn).
3668            attrs.border_style = sh.style;
3669            attrs.border_width = sh.width.map(crate::layout_ir::Edges::all);
3670            attrs.border_color = sh.color;
3671        }
3672        "border-width" => {
3673            attrs.border_width = Some(crate::values::parse_border_width_edges(ctx, name, value)?);
3674        }
3675        "border-color" => attrs.border_color = Some(parse_color(ctx, name, value)?),
3676        "border-top-color" => attrs.border_color_top = Some(parse_color(ctx, name, value)?),
3677        "border-right-color" => attrs.border_color_right = Some(parse_color(ctx, name, value)?),
3678        "border-bottom-color" => attrs.border_color_bottom = Some(parse_color(ctx, name, value)?),
3679        "border-left-color" => attrs.border_color_left = Some(parse_color(ctx, name, value)?),
3680        "border-top" | "border-right" | "border-bottom" | "border-left" => {
3681            // Per-side border shorthand: sets that side's width + color
3682            // (and the shared solid style). Approximation of CSS's
3683            // per-side border-style: Lumen has one style slot, so
3684            // `border-bottom: 1px solid X` on an otherwise borderless
3685            // element yields widths {bottom: 1, rest: 0}.
3686            let sh = crate::values::parse_border_shorthand(ctx, name, value)?;
3687            let is_none = matches!(sh.style, Some(crate::layout_ir::BorderStyleSpec::None));
3688            let w = if is_none {
3689                0.0
3690            } else {
3691                sh.width.unwrap_or(3.0)
3692            };
3693            let edges = attrs
3694                .border_width
3695                .get_or_insert_with(crate::layout_ir::Edges::default);
3696            let side_color = if is_none { None } else { sh.color };
3697            match name {
3698                "border-top" => {
3699                    edges.top = w;
3700                    if let Some(c) = side_color {
3701                        attrs.border_color_top = Some(c);
3702                    }
3703                }
3704                "border-right" => {
3705                    edges.right = w;
3706                    if let Some(c) = side_color {
3707                        attrs.border_color_right = Some(c);
3708                    }
3709                }
3710                "border-bottom" => {
3711                    edges.bottom = w;
3712                    if let Some(c) = side_color {
3713                        attrs.border_color_bottom = Some(c);
3714                    }
3715                }
3716                "border-left" => {
3717                    edges.left = w;
3718                    if let Some(c) = side_color {
3719                        attrs.border_color_left = Some(c);
3720                    }
3721                }
3722                _ => unreachable!(),
3723            }
3724            if !is_none {
3725                attrs.border_style = Some(crate::layout_ir::BorderStyleSpec::Solid);
3726            }
3727        }
3728        "border-style" => {
3729            attrs.border_style = Some(crate::values::parse_border_style(ctx, name, value)?);
3730        }
3731        "border-top-width" | "border-right-width" | "border-bottom-width" | "border-left-width" => {
3732            let px = crate::values::parse_border_width_term(ctx, name, value, value)?;
3733            let edges = attrs
3734                .border_width
3735                .get_or_insert_with(crate::layout_ir::Edges::default);
3736            match name {
3737                "border-top-width" => edges.top = px,
3738                "border-right-width" => edges.right = px,
3739                "border-bottom-width" => edges.bottom = px,
3740                "border-left-width" => edges.left = px,
3741                _ => unreachable!(),
3742            }
3743        }
3744        "box-sizing" => {
3745            attrs.box_sizing = Some(match value.trim() {
3746                "border-box" => crate::layout_ir::BoxSizingSpec::BorderBox,
3747                "content-box" => crate::layout_ir::BoxSizingSpec::ContentBox,
3748                other => {
3749                    return Err(bad(
3750                        ctx,
3751                        name,
3752                        value,
3753                        format!("unknown box-sizing '{other}'"),
3754                    ));
3755                }
3756            });
3757        }
3758        // Lumen-native state-border / focus-ring properties (match the
3759        // markup attr spellings; also reachable via `:hover`/`:focus`
3760        // pseudo rules).
3761        "hover-border" => {
3762            let sh = crate::values::parse_border_shorthand(ctx, name, value)?;
3763            attrs.hover_border = border_shorthand_to_paint(&sh);
3764        }
3765        "focus-border" => {
3766            let sh = crate::values::parse_border_shorthand(ctx, name, value)?;
3767            attrs.focus_border = border_shorthand_to_paint(&sh);
3768        }
3769        "focus-outline" => {
3770            let parts: Vec<&str> = value.split_whitespace().collect();
3771            if parts.len() != 2 {
3772                return Err(crate::values::bad(
3773                    ctx,
3774                    name,
3775                    value,
3776                    "expected '<width-px> <#color>'".into(),
3777                ));
3778            }
3779            attrs.focus_outline = Some(crate::layout_ir::OutlineSpec {
3780                width: parse_f32(ctx, name, parts[0])?,
3781                color: parse_color(ctx, name, parts[1])?,
3782                offset: attrs.outline_offset.unwrap_or(0.0),
3783            });
3784        }
3785        "outline-offset" => {
3786            let off = parse_f32(ctx, name, value)?;
3787            attrs.outline_offset = Some(off);
3788            if let Some(o) = attrs.focus_outline.as_mut() {
3789                o.offset = off;
3790            }
3791            if let Some(o) = attrs.focus_visible_outline.as_mut() {
3792                o.offset = off;
3793            }
3794        }
3795        "knob-color" => {
3796            attrs.knob_color = Some(parse_color(ctx, name, value)?);
3797        }
3798        // Lumen-native widget geometry - like `knob-color`, no real CSS
3799        // pseudo-element target exists for these parts.
3800        "knob-inset" => attrs.knob_inset = Some(parse_f32(ctx, name, value)?),
3801        "thumb-size" => attrs.thumb_size = Some(parse_f32(ctx, name, value)?),
3802        "popup-gap" => attrs.popup_gap = Some(parse_f32(ctx, name, value)?),
3803        "display" => {
3804            attrs.display = Some(match value.trim() {
3805                "flex" => DisplaySpec::Flex,
3806                "grid" => DisplaySpec::Grid,
3807                "none" => DisplaySpec::None,
3808                other => {
3809                    return Err(bad(ctx, name, value, format!("unknown display '{other}'")));
3810                }
3811            });
3812        }
3813        "grid-template-rows" => {
3814            let rows = parse_track_list(ctx, name, value)?;
3815            let mut gt = attrs.grid_template.clone().unwrap_or_default();
3816            gt.rows = rows;
3817            attrs.grid_template = Some(gt);
3818        }
3819        "grid-template-columns" => {
3820            let cols = parse_track_list(ctx, name, value)?;
3821            let mut gt = attrs.grid_template.clone().unwrap_or_default();
3822            gt.columns = cols;
3823            attrs.grid_template = Some(gt);
3824        }
3825        "grid-row" => attrs.grid_row = Some(parse_grid_line_pair(ctx, name, value)?),
3826        "grid-column" => attrs.grid_column = Some(parse_grid_line_pair(ctx, name, value)?),
3827        "align" | "align-items" => {
3828            attrs.align = Some(parse_flex_align(ctx, name, value)?);
3829        }
3830        "align-self" => {
3831            attrs.align_self = Some(parse_flex_align(ctx, name, value)?);
3832        }
3833        "justify-items" => {
3834            attrs.justify_items = Some(parse_flex_align(ctx, name, value)?);
3835        }
3836        "justify-self" => {
3837            attrs.justify_self = Some(parse_flex_align(ctx, name, value)?);
3838        }
3839        "justify" => {
3840            attrs.justify = Some(match value {
3841                "start" => FlexJustify::Start,
3842                "end" => FlexJustify::End,
3843                "center" => FlexJustify::Center,
3844                "between" | "space-between" => FlexJustify::SpaceBetween,
3845                "around" | "space-around" => FlexJustify::SpaceAround,
3846                "evenly" | "space-evenly" => FlexJustify::SpaceEvenly,
3847                other => {
3848                    return Err(bad(ctx, name, value, format!("unknown justify '{other}'")));
3849                }
3850            });
3851        }
3852        "text-align" => {
3853            attrs.text_align = Some(match value {
3854                "start" | "left" => TextAlignSpec::Start,
3855                "center" => TextAlignSpec::Center,
3856                "end" | "right" => TextAlignSpec::End,
3857                other => {
3858                    return Err(bad(
3859                        ctx,
3860                        name,
3861                        value,
3862                        format!("unknown text-align '{other}'"),
3863                    ));
3864                }
3865            });
3866        }
3867        "wrap" => {
3868            attrs.text_wrap = Some(match value {
3869                "none" | "nowrap" => TextWrapSpec::None,
3870                "word" | "normal" => TextWrapSpec::Word,
3871                "glyph" | "char" => TextWrapSpec::Glyph,
3872                other => return Err(bad(ctx, name, value, format!("unknown wrap '{other}'"))),
3873            });
3874        }
3875        "max-lines" => {
3876            let n: i64 = value
3877                .trim()
3878                .parse()
3879                .map_err(|e: std::num::ParseIntError| bad(ctx, name, value, e.to_string()))?;
3880            if n < 0 {
3881                return Err(bad(
3882                    ctx,
3883                    name,
3884                    value,
3885                    "max-lines must be \u{2265} 0".to_string(),
3886                ));
3887            }
3888            attrs.max_lines = Some(n as u32);
3889        }
3890        // Lumen-native analog property (like `knob-color`): the
3891        // indeterminate `<progress>` sweep period in ms. Skins route it
3892        // through `--lumen-progress-period`.
3893        "progress-duration" => {
3894            let n: i64 = value
3895                .trim()
3896                .parse()
3897                .map_err(|e: std::num::ParseIntError| bad(ctx, name, value, e.to_string()))?;
3898            if n <= 0 {
3899                return Err(bad(
3900                    ctx,
3901                    name,
3902                    value,
3903                    "progress-duration must be > 0 (ms)".to_string(),
3904                ));
3905            }
3906            attrs.progress_duration = Some(n as u32);
3907        }
3908        // Lumen-native: fraction of the track width covered by the
3909        // moving chunk of an indeterminate `<progress>` sweep.
3910        "progress-chunk" => {
3911            let v = parse_f32(ctx, name, value)?;
3912            if !(0.0..=1.0).contains(&v) {
3913                return Err(bad(
3914                    ctx,
3915                    name,
3916                    value,
3917                    "progress-chunk must be between 0.0 and 1.0".to_string(),
3918                ));
3919            }
3920            attrs.progress_chunk = Some(v);
3921        }
3922        "text-overflow" => {
3923            attrs.text_overflow = Some(match value.trim() {
3924                "clip" => crate::layout_ir::TextOverflowSpec::Clip,
3925                "ellipsis" => crate::layout_ir::TextOverflowSpec::Ellipsis,
3926                other => {
3927                    return Err(bad(
3928                        ctx,
3929                        name,
3930                        value,
3931                        format!("unknown text-overflow '{other}' (supported: clip, ellipsis)"),
3932                    ));
3933                }
3934            });
3935        }
3936        "position" => {
3937            attrs.position = Some(match value {
3938                "relative" => PositionSpec::Relative,
3939                "absolute" => PositionSpec::Absolute,
3940                other => {
3941                    return Err(bad(ctx, name, value, format!("unknown position '{other}'")));
3942                }
3943            });
3944        }
3945        "inset" => attrs.inset = Some(parse_inset_edges(ctx, name, value)?),
3946        "min-width" => attrs.min_width = Some(parse_length(ctx, name, value)?),
3947        "min-height" => attrs.min_height = Some(parse_length(ctx, name, value)?),
3948        "max-width" => attrs.max_width = Some(parse_length(ctx, name, value)?),
3949        "max-height" => attrs.max_height = Some(parse_length(ctx, name, value)?),
3950        "aspect-ratio" => attrs.aspect_ratio = Some(parse_f32(ctx, name, value)?),
3951        "opacity" => {
3952            let v = parse_f32(ctx, name, value)?;
3953            attrs.opacity = Some(v.clamp(0.0, 1.0));
3954        }
3955        // CSS-authored replacement for the runtime's generic
3956        // `:disabled` dimming fallback - the amount used when the
3957        // author set neither `disabled-bg` nor an explicit
3958        // `:disabled { opacity }`. Not routed through
3959        // `apply_decl_for_pseudo`: it is a plain, always-applicable
3960        // property, not a state-pseudo swap.
3961        "disabled-opacity" => {
3962            let v = parse_f32(ctx, name, value)?;
3963            attrs.disabled_opacity_default = Some(v.clamp(0.0, 1.0));
3964        }
3965        "shadow" | "box-shadow" => {
3966            attrs.shadows = parse_box_shadow(ctx, name, value)?;
3967        }
3968        "fit" => attrs.image_fit = Some(crate::values::parse_image_fit(ctx, name, value)?),
3969        "bg-fit" => attrs.bg_fit = Some(crate::values::parse_image_fit(ctx, name, value)?),
3970        "transition" => {
3971            attrs.transitions = parse_transition(ctx, name, value)?;
3972        }
3973        // Longhand trio - combined by `Attributes::effective_transitions`
3974        // (duration / timing cycle over the property list per CSS).
3975        "transition-property" => {
3976            let v = value.trim();
3977            if v == "none" {
3978                attrs.transition_property = Some(Vec::new());
3979            } else {
3980                let mut props = Vec::new();
3981                for entry in v.split(',') {
3982                    let entry = entry.trim();
3983                    if entry.is_empty() {
3984                        continue;
3985                    }
3986                    props.extend(transition_properties_for(entry));
3987                }
3988                attrs.transition_property = Some(props);
3989            }
3990        }
3991        // Parsed and ignored: a transition always starts on the tick the
3992        // value changes. Kept out of the unknown-property path so the
3993        // warning says what happened, and so it matches the shorthand,
3994        // which also warns on a delay term and drops it.
3995        "transition-delay" => {
3996            tracing::warn!(
3997                target: "lumenc::css",
3998                "transition-delay '{}' ignored - transitions start immediately",
3999                value.trim()
4000            );
4001        }
4002        "transition-duration" => {
4003            let mut out = Vec::new();
4004            for entry in value.split(',') {
4005                let entry = entry.trim();
4006                if entry.is_empty() {
4007                    continue;
4008                }
4009                out.push(parse_duration_ms(ctx, name, entry)?);
4010            }
4011            attrs.transition_duration = Some(out);
4012        }
4013        "transition-timing-function" => {
4014            let mut out = Vec::new();
4015            for entry in split_top_level_commas(value) {
4016                let entry = entry.trim();
4017                if entry.is_empty() {
4018                    continue;
4019                }
4020                out.push(parse_easing(ctx, name, &[entry])?);
4021            }
4022            attrs.transition_timing = Some(out);
4023        }
4024        // Keyframe animations run on the web target, where the browser
4025        // plays the `@keyframes` block the stylesheet carries alongside
4026        // these declarations. The desktop cascade has no keyframe engine,
4027        // so it accepts the family and stores nothing; `transition` is the
4028        // property that animates a desktop app.
4029        "animation"
4030        | "animation-name"
4031        | "animation-duration"
4032        | "animation-timing-function"
4033        | "animation-delay"
4034        | "animation-iteration-count"
4035        | "animation-direction"
4036        | "animation-fill-mode"
4037        | "animation-play-state" => {}
4038        // CSS Scrollbars Styling Level 1 - overlay-bar styling for
4039        // `<scroll>` containers. `scrollbar-color: auto` clears back to
4040        // the runtime default.
4041        "scrollbar-color" => {
4042            let v = value.trim();
4043            if v == "auto" {
4044                attrs.scrollbar_color = None;
4045            } else {
4046                let parts: Vec<&str> = v.split_whitespace().collect();
4047                if parts.is_empty() || parts.len() > 2 {
4048                    return Err(bad(
4049                        ctx,
4050                        name,
4051                        value,
4052                        "expected 'auto' or '<thumb-color> [<track-color>]'".into(),
4053                    ));
4054                }
4055                let thumb = parse_color(ctx, name, parts[0])?;
4056                let track = match parts.get(1) {
4057                    Some(t) => Some(parse_color(ctx, name, t)?),
4058                    None => None,
4059                };
4060                attrs.scrollbar_color = Some((thumb, track));
4061            }
4062        }
4063        "scrollbar-width" => {
4064            attrs.scrollbar_width = Some(match value.trim() {
4065                "auto" => ScrollbarWidthSpec::Auto,
4066                "thin" => ScrollbarWidthSpec::Thin,
4067                "none" => ScrollbarWidthSpec::None,
4068                other => {
4069                    return Err(bad(
4070                        ctx,
4071                        name,
4072                        value,
4073                        format!("unknown scrollbar-width '{other}' (supported: auto, thin, none)"),
4074                    ));
4075                }
4076            });
4077        }
4078        // Lumen-native overlay-scrollbar geometry/timing, alongside the
4079        // standard `scrollbar-color` / `scrollbar-width` above. Not a
4080        // real CSS property family; there is no standard equivalent.
4081        "scrollbar-thickness" => attrs.scrollbar_thickness = Some(parse_f32(ctx, name, value)?),
4082        "scrollbar-thickness-thin" => {
4083            attrs.scrollbar_thickness_thin = Some(parse_f32(ctx, name, value)?);
4084        }
4085        "scrollbar-margin" => attrs.scrollbar_margin = Some(parse_f32(ctx, name, value)?),
4086        "scrollbar-min-thumb" => attrs.scrollbar_min_thumb = Some(parse_f32(ctx, name, value)?),
4087        // Named like `hover-bg`: a distinct property, not a
4088        // `:hover { ... }` pseudo rule, since the hover target is the
4089        // scrollbar part rather than the whole element.
4090        "scrollbar-track-hover" => {
4091            attrs.scrollbar_track_hover = Some(parse_color(ctx, name, value)?);
4092        }
4093        "scrollbar-hover-boost" => {
4094            attrs.scrollbar_hover_boost = Some(parse_f32(ctx, name, value)?);
4095        }
4096        "scrollbar-fade-delay" => {
4097            attrs.scrollbar_fade_delay_ms = Some(parse_duration_ms(ctx, name, value.trim())?);
4098        }
4099        "scrollbar-fade-duration" => {
4100            attrs.scrollbar_fade_duration_ms = Some(parse_duration_ms(ctx, name, value.trim())?);
4101        }
4102        // W5.5: CSS Logical Properties Level 1 subset. Each value
4103        // parses as an f32 in px (so typos surface immediately) and
4104        // is written into the matching logical-edge field of the
4105        // owning `Edges` (padding / margin / inset) IR slot. The
4106        // lumen-core `Edges::resolved` then maps these onto the
4107        // physical sides per the entity's `ResolvedDirection` at
4108        // layout time.
4109        "padding-inline-start"
4110        | "padding-inline-end"
4111        | "padding-block-start"
4112        | "padding-block-end" => {
4113            let px = parse_f32(ctx, name, value)?;
4114            let edges = attrs
4115                .padding
4116                .get_or_insert_with(crate::layout_ir::Edges::default);
4117            match name {
4118                "padding-inline-start" => edges.inline_start = Some(px),
4119                "padding-inline-end" => edges.inline_end = Some(px),
4120                "padding-block-start" => edges.block_start = Some(px),
4121                "padding-block-end" => edges.block_end = Some(px),
4122                _ => unreachable!(),
4123            }
4124        }
4125        "margin-inline-start" | "margin-inline-end" | "margin-block-start" | "margin-block-end" => {
4126            let px = parse_f32(ctx, name, value)?;
4127            let edges = attrs
4128                .margin
4129                .get_or_insert_with(crate::layout_ir::Edges::default);
4130            match name {
4131                "margin-inline-start" => edges.inline_start = Some(px),
4132                "margin-inline-end" => edges.inline_end = Some(px),
4133                "margin-block-start" => edges.block_start = Some(px),
4134                "margin-block-end" => edges.block_end = Some(px),
4135                _ => unreachable!(),
4136            }
4137        }
4138        "inset-inline-start" | "inset-inline-end" | "inset-block-start" | "inset-block-end" => {
4139            let px = parse_inset_term(ctx, name, value)?;
4140            // The sides this longhand does not name stay `auto`.
4141            let edges = attrs
4142                .inset
4143                .get_or_insert_with(crate::layout_ir::Edges::auto);
4144            match name {
4145                "inset-inline-start" => edges.inline_start = Some(px),
4146                "inset-inline-end" => edges.inline_end = Some(px),
4147                "inset-block-start" => edges.block_start = Some(px),
4148                "inset-block-end" => edges.block_end = Some(px),
4149                _ => unreachable!(),
4150            }
4151        }
4152        "border-inline-start-width"
4153        | "border-inline-end-width"
4154        | "border-block-start-width"
4155        | "border-block-end-width" => {
4156            let px = crate::values::parse_border_width_term(ctx, name, value, value)?;
4157            let edges = attrs
4158                .border_width
4159                .get_or_insert_with(crate::layout_ir::Edges::default);
4160            match name {
4161                "border-inline-start-width" => edges.inline_start = Some(px),
4162                "border-inline-end-width" => edges.inline_end = Some(px),
4163                "border-block-start-width" => edges.block_start = Some(px),
4164                "border-block-end-width" => edges.block_end = Some(px),
4165                _ => unreachable!(),
4166            }
4167        }
4168        "overflow" | "overflow-x" | "overflow-y" => {
4169            let o = match value.trim() {
4170                "visible" => OverflowSpec::Visible,
4171                "hidden" => OverflowSpec::Hidden,
4172                "scroll" => OverflowSpec::Scroll,
4173                other => {
4174                    return Err(bad(
4175                        ctx,
4176                        name,
4177                        value,
4178                        format!("unknown overflow '{other}' (supported: visible, hidden, scroll)"),
4179                    ));
4180                }
4181            };
4182            match name {
4183                "overflow" => attrs.overflow = Some(o),
4184                "overflow-x" => attrs.overflow_x = Some(o),
4185                "overflow-y" => attrs.overflow_y = Some(o),
4186                _ => unreachable!(),
4187            }
4188        }
4189        "layout-boundary" => attrs.layout_boundary = matches!(value.trim(), "true" | "yes"),
4190        _ => return Ok(false),
4191    }
4192    Ok(true)
4193}
4194
4195// ---------------------------------------------------------------------------
4196// W5.9: grid / baseline parsers
4197// ---------------------------------------------------------------------------
4198
4199/// Parse `align-items` / `align-self` / `justify-items` / `justify-self`
4200/// /  `align` values. CSS Grid + W5.9 adds `baseline` to the legacy
4201/// `start/end/center/stretch` set.
4202fn parse_flex_align(ctx: &str, name: &str, value: &str) -> Result<FlexAlign, ParseError> {
4203    Ok(match value.trim() {
4204        "start" | "flex-start" => FlexAlign::Start,
4205        "end" | "flex-end" => FlexAlign::End,
4206        "center" => FlexAlign::Center,
4207        "stretch" => FlexAlign::Stretch,
4208        "baseline" => FlexAlign::Baseline,
4209        other => return Err(bad(ctx, name, value, format!("unknown align '{other}'"))),
4210    })
4211}
4212
4213/// Parse a single CSS Grid track size - `<N>px`, `<N>fr`, `auto`,
4214/// `min-content`, `max-content`, or `minmax(<min>, <max>)`. Returns
4215/// the IR-side [`TrackSizeSpec`].
4216fn parse_track_size(ctx: &str, name: &str, value: &str) -> Result<TrackSizeSpec, ParseError> {
4217    let v = value.trim();
4218    if let Some(inner) = v.strip_prefix("minmax(").and_then(|s| s.strip_suffix(')')) {
4219        // Split on first top-level comma. minmax() never nests deeper
4220        // than one level in CSS Grid L1, so a flat split suffices.
4221        let mut depth = 0_i32;
4222        let mut split: Option<usize> = None;
4223        for (i, c) in inner.char_indices() {
4224            match c {
4225                '(' => depth += 1,
4226                ')' => depth -= 1,
4227                ',' if depth == 0 => {
4228                    split = Some(i);
4229                    break;
4230                }
4231                _ => {}
4232            }
4233        }
4234        let Some(comma) = split else {
4235            return Err(bad(
4236                ctx,
4237                name,
4238                value,
4239                "minmax() requires two arguments separated by ','".into(),
4240            ));
4241        };
4242        let lhs = parse_track_size(ctx, name, &inner[..comma])?;
4243        let rhs = parse_track_size(ctx, name, &inner[comma + 1..])?;
4244        return Ok(TrackSizeSpec::MinMax(Box::new(lhs), Box::new(rhs)));
4245    }
4246    if v == "auto" {
4247        return Ok(TrackSizeSpec::Auto);
4248    }
4249    if v == "min-content" {
4250        return Ok(TrackSizeSpec::MinContent);
4251    }
4252    if v == "max-content" {
4253        return Ok(TrackSizeSpec::MaxContent);
4254    }
4255    if let Some(num) = v.strip_suffix("fr") {
4256        let f = parse_f32(ctx, name, num.trim())?;
4257        return Ok(TrackSizeSpec::Fr(f));
4258    }
4259    if let Some(num) = v.strip_suffix("px") {
4260        let n = parse_f32(ctx, name, num.trim())?;
4261        return Ok(TrackSizeSpec::Fixed(n));
4262    }
4263    // Bare number -> pixels.
4264    if v.parse::<f32>().is_ok() {
4265        let n = parse_f32(ctx, name, v)?;
4266        return Ok(TrackSizeSpec::Fixed(n));
4267    }
4268    Err(bad(ctx, name, value, format!("unknown track size '{v}'")))
4269}
4270
4271/// Parse a `grid-template-rows` / `grid-template-columns` value into
4272/// a vector of track-size terms. Whitespace-separated; the W5.9
4273/// minimal subset doesn't yet support named lines or `repeat()`.
4274fn parse_track_list(ctx: &str, name: &str, value: &str) -> Result<Vec<TrackSizeSpec>, ParseError> {
4275    // Tokenise on whitespace, but keep `minmax(a, b)` together so the
4276    // commas inside the function don't split the outer list. The
4277    // tokeniser walks chars and tracks paren depth.
4278    let mut tokens: Vec<String> = Vec::new();
4279    let mut cur = String::new();
4280    let mut depth = 0_i32;
4281    for ch in value.chars() {
4282        match ch {
4283            '(' => {
4284                depth += 1;
4285                cur.push(ch);
4286            }
4287            ')' => {
4288                depth -= 1;
4289                cur.push(ch);
4290            }
4291            c if c.is_whitespace() && depth == 0 => {
4292                if !cur.is_empty() {
4293                    tokens.push(std::mem::take(&mut cur));
4294                }
4295            }
4296            _ => cur.push(ch),
4297        }
4298    }
4299    if !cur.is_empty() {
4300        tokens.push(cur);
4301    }
4302    let mut out = Vec::with_capacity(tokens.len());
4303    for t in tokens {
4304        out.push(parse_track_size(ctx, name, &t)?);
4305    }
4306    Ok(out)
4307}
4308
4309/// Parse `grid-row` / `grid-column` `<start>[/<end>]` values. A
4310/// single integer = start only, end auto-placed. A pair separated
4311/// by `/` = explicit start + end line numbers. CSS `span <N>` and
4312/// named lines are out of scope for the W5.9 subset.
4313///
4314/// `pub` so `lumenc`'s grid tests (which drive the parser front-end and
4315/// therefore stay in `lumenc`) can exercise it via the `parse::css`
4316/// re-export.
4317pub fn parse_grid_line_pair(ctx: &str, name: &str, value: &str) -> Result<(i16, i16), ParseError> {
4318    let v = value.trim();
4319    if let Some(idx) = v.find('/') {
4320        let lhs = v[..idx].trim();
4321        let rhs = v[idx + 1..].trim();
4322        let s = grid_line_to_i16(ctx, name, parse_i32(ctx, name, lhs)?);
4323        let e = grid_line_to_i16(ctx, name, parse_i32(ctx, name, rhs)?);
4324        return Ok((s, e));
4325    }
4326    let s = grid_line_to_i16(ctx, name, parse_i32(ctx, name, v)?);
4327    Ok((s, 0))
4328}
4329
4330/// Grid line numbers are stored as `i16`. A value outside that range is
4331/// clamped (with a warning) rather than silently wrapping via `as i16`
4332/// (e.g. `grid-row: 40000` would have wrapped to a negative line).
4333fn grid_line_to_i16(ctx: &str, name: &str, v: i32) -> i16 {
4334    if v < i16::MIN as i32 || v > i16::MAX as i32 {
4335        let clamped = v.clamp(i16::MIN as i32, i16::MAX as i32) as i16;
4336        tracing::warn!(
4337            target: "lumenc::css",
4338            "{ctx} {{ {name} }}: grid line {v} out of range [{}, {}] - clamped to {clamped}",
4339            i16::MIN,
4340            i16::MAX,
4341        );
4342        clamped
4343    } else {
4344        v as i16
4345    }
4346}
4347
4348// ---------------------------------------------------------------------------
4349// transition / shadow helpers (unchanged semantics)
4350// ---------------------------------------------------------------------------
4351
4352/// Every property a transition can animate. `transition: all` and
4353/// `transition-property: all` expand to this list.
4354const ALL_TRANSITION_PROPERTIES: [TransitionPropertyIr; 4] = [
4355    TransitionPropertyIr::Opacity,
4356    TransitionPropertyIr::BackgroundColor,
4357    TransitionPropertyIr::TextColor,
4358    TransitionPropertyIr::BorderColor,
4359];
4360
4361/// CSS `ease`, the initial `transition-timing-function`, used when an
4362/// entry names no curve.
4363const DEFAULT_EASING: EasingIr = EasingIr::CubicBezier(0.25, 0.1, 0.25, 1.0);
4364
4365/// Expand one `transition` / `transition-property` term into the
4366/// properties it names. `all` covers the whole animatable set; an
4367/// unanimatable or unknown name yields an empty list and a warning,
4368/// per the CSS rule that such entries are ignored rather than fatal.
4369/// Geometry props (width / height / padding ...) are excluded on
4370/// purpose: animating them would re-run layout every frame.
4371fn transition_properties_for(term: &str) -> Vec<TransitionPropertyIr> {
4372    if term == "all" {
4373        return ALL_TRANSITION_PROPERTIES.to_vec();
4374    }
4375    match TransitionPropertyIr::from_css_name(term) {
4376        Some(p) => vec![p],
4377        None => {
4378            tracing::warn!(
4379                target: "lumenc::css",
4380                "transition: property '{term}' is not animatable (animatable: all, opacity, \
4381                 background-color, color, border-color) - entry ignored"
4382            );
4383            Vec::new()
4384        }
4385    }
4386}
4387
4388fn parse_transition(ctx: &str, name: &str, value: &str) -> Result<Vec<TransitionIr>, ParseError> {
4389    let mut out = Vec::new();
4390    for entry in split_top_level_commas(value) {
4391        let entry = entry.trim();
4392        if entry.is_empty() {
4393            continue;
4394        }
4395        let parts: Vec<&str> = entry.split_whitespace().collect();
4396        if parts.len() < 2 {
4397            return Err(bad(
4398                ctx,
4399                name,
4400                value,
4401                format!("expected '<property> <duration> [<easing>]' in entry '{entry}'"),
4402            ));
4403        }
4404        let properties = transition_properties_for(parts[0]);
4405        if properties.is_empty() {
4406            continue;
4407        }
4408        // Terms after the property come in any order: the first time
4409        // value is the duration, a second one is the delay, and whatever
4410        // is left names the curve (`cubic-bezier(...)` survives the
4411        // whitespace split because none of its pieces read as a
4412        // duration).
4413        let mut duration_ms: Option<u32> = None;
4414        let mut delay_term: Option<&str> = None;
4415        let mut easing_terms: Vec<&str> = Vec::new();
4416        for term in &parts[1..] {
4417            match parse_duration_ms(ctx, name, term) {
4418                Ok(ms) if duration_ms.is_none() => duration_ms = Some(ms),
4419                Ok(_) => delay_term = Some(term),
4420                Err(_) => easing_terms.push(term),
4421            }
4422        }
4423        let Some(duration_ms) = duration_ms else {
4424            return Err(bad(
4425                ctx,
4426                name,
4427                value,
4428                format!("expected a duration ('Nms' or 'Ns') in entry '{entry}'"),
4429            ));
4430        };
4431        if let Some(delay) = delay_term {
4432            tracing::warn!(
4433                target: "lumenc::css",
4434                "transition: delay '{delay}' ignored - transitions start immediately"
4435            );
4436        }
4437        let easing = if easing_terms.is_empty() {
4438            DEFAULT_EASING
4439        } else {
4440            parse_easing(ctx, name, &easing_terms)?
4441        };
4442        for property in properties {
4443            out.push(TransitionIr {
4444                property,
4445                duration_ms,
4446                easing,
4447            });
4448        }
4449    }
4450    Ok(out)
4451}
4452
4453fn parse_box_shadow(ctx: &str, name: &str, value: &str) -> Result<Vec<ShadowSpec>, ParseError> {
4454    let mut out = Vec::new();
4455    for entry in split_top_level_commas(value) {
4456        let entry = entry.trim();
4457        if entry.is_empty() {
4458            continue;
4459        }
4460        let mut toks: Vec<&str> = entry.split_whitespace().collect();
4461        let inner = if toks.first().is_some_and(|t| *t == "inset") {
4462            toks.remove(0);
4463            true
4464        } else {
4465            false
4466        };
4467        if toks.len() != 4 && toks.len() != 5 {
4468            return Err(bad(
4469                ctx,
4470                name,
4471                value,
4472                format!("expected '[inset] <x> <y> <blur> [<spread>] <#color>' in entry '{entry}'"),
4473            ));
4474        }
4475        let spread = if toks.len() == 5 {
4476            parse_f32(ctx, name, toks[3])?
4477        } else {
4478            0.0
4479        };
4480        out.push(ShadowSpec {
4481            offset_x: parse_f32(ctx, name, toks[0])?,
4482            offset_y: parse_f32(ctx, name, toks[1])?,
4483            blur: parse_f32(ctx, name, toks[2])?,
4484            spread,
4485            color: parse_color(ctx, name, toks[if toks.len() == 5 { 4 } else { 3 }])?,
4486            inner,
4487        });
4488    }
4489    Ok(out)
4490}
4491
4492/// Parse a CSS duration (`Nms` or `Ns`) into whole milliseconds. Shared
4493/// by every duration-valued property in the cascade (`transition-duration`,
4494/// `caret-blink`, `scrollbar-fade-delay`, `scrollbar-fade-duration`, ...);
4495/// `pub` so the markup parser (`lumenc::parse::html`, a separate crate)
4496/// reuses the same parser instead of duplicating the unit handling for
4497/// its mirrored inline-attribute table.
4498pub fn parse_duration_ms(ctx: &str, name: &str, raw: &str) -> Result<u32, ParseError> {
4499    if let Some(stripped) = raw.strip_suffix("ms") {
4500        let v: f32 = stripped
4501            .parse()
4502            .map_err(|e: std::num::ParseFloatError| bad(ctx, name, raw, e.to_string()))?;
4503        if v < 0.0 {
4504            return Err(bad(ctx, name, raw, "duration must be \u{2265} 0".into()));
4505        }
4506        return Ok(v.round() as u32);
4507    }
4508    if let Some(stripped) = raw.strip_suffix('s') {
4509        let v: f32 = stripped
4510            .parse()
4511            .map_err(|e: std::num::ParseFloatError| bad(ctx, name, raw, e.to_string()))?;
4512        if v < 0.0 {
4513            return Err(bad(ctx, name, raw, "duration must be \u{2265} 0".into()));
4514        }
4515        return Ok((v * 1000.0).round() as u32);
4516    }
4517    Err(bad(
4518        ctx,
4519        name,
4520        raw,
4521        format!("expected 'Nms' or 'Ns'; got '{raw}'"),
4522    ))
4523}
4524
4525fn parse_easing(ctx: &str, name: &str, parts: &[&str]) -> Result<EasingIr, ParseError> {
4526    let joined = parts.join(" ");
4527    let raw = joined.trim();
4528    match raw {
4529        "linear" => Ok(EasingIr::Linear),
4530        "ease" => Ok(DEFAULT_EASING),
4531        "ease-in" => Ok(EasingIr::EaseIn),
4532        "ease-out" => Ok(EasingIr::EaseOut),
4533        "ease-in-out" => Ok(EasingIr::EaseInOut),
4534        other if other.starts_with("cubic-bezier(") && other.ends_with(')') => {
4535            let inside = &other["cubic-bezier(".len()..other.len() - 1];
4536            let nums: Vec<f32> = inside
4537                .split(',')
4538                .map(|p| {
4539                    p.trim()
4540                        .parse::<f32>()
4541                        .map_err(|e| bad(ctx, name, raw, e.to_string()))
4542                })
4543                .collect::<Result<_, _>>()?;
4544            if nums.len() != 4 {
4545                return Err(bad(
4546                    ctx,
4547                    name,
4548                    raw,
4549                    "cubic-bezier expects 4 comma-separated numbers".into(),
4550                ));
4551            }
4552            Ok(EasingIr::CubicBezier(nums[0], nums[1], nums[2], nums[3]))
4553        }
4554        other => Err(bad(ctx, name, raw, format!("unknown easing '{other}'"))),
4555    }
4556}
4557
4558// ---------------------------------------------------------------------------
4559// Lint mode helper - `lumenc lint --css-cascade` reports rules whose
4560// resolved value flips between legacy first-wins and new last-wins.
4561// ---------------------------------------------------------------------------
4562
4563/// One diff finding emitted by [`cascade_lint`].
4564#[derive(Debug, Clone, PartialEq)]
4565pub struct CascadeDivergence {
4566    /// Selector text whose result flips between the two orderings.
4567    pub selector: String,
4568    /// Property name whose resolved value flipped.
4569    pub property: String,
4570    /// Value picked under legacy first-wins.
4571    pub first_wins: String,
4572    /// Value picked under conformant last-wins.
4573    pub last_wins: String,
4574}
4575
4576/// Scan a parsed stylesheet and emit one [`CascadeDivergence`] per
4577/// (selector x property) pair whose resolved value differs between
4578/// the old first-wins ordering and the new last-wins ordering.
4579///
4580/// This is the audit's "compat regression escape valve": authors who
4581/// relied on the broken first-wins behaviour can run
4582/// `lumenc lint --css-cascade <dir>` and patch flagged rules ahead of
4583/// the next compiler upgrade.
4584pub fn cascade_lint(css: &Stylesheet) -> Vec<CascadeDivergence> {
4585    use std::collections::BTreeMap;
4586    let mut buckets: BTreeMap<String, BTreeMap<String, Vec<String>>> = BTreeMap::new();
4587    for rule in &css.rules {
4588        for (sel_idx, _) in rule.selectors.iter().enumerate() {
4589            let sel_text = describe_selector_for_error(&rule.selectors, sel_idx);
4590            let entry = buckets.entry(sel_text).or_default();
4591            for decl in &rule.declarations {
4592                if decl.name.starts_with("--") {
4593                    continue;
4594                }
4595                entry
4596                    .entry(decl.name.clone())
4597                    .or_default()
4598                    .push(decl.value.clone());
4599            }
4600        }
4601    }
4602    let mut out = Vec::new();
4603    for (selector, props) in buckets {
4604        for (property, values) in props {
4605            if values.len() < 2 {
4606                continue;
4607            }
4608            let first = values.first().unwrap();
4609            let last = values.last().unwrap();
4610            if first != last {
4611                out.push(CascadeDivergence {
4612                    selector: selector.clone(),
4613                    property,
4614                    first_wins: first.clone(),
4615                    last_wins: last.clone(),
4616                });
4617            }
4618        }
4619    }
4620    out
4621}
4622
4623// Split `s` on top-level commas (commas not nested inside parentheses),
4624// returning trimmed-inclusive slices. Duplicated from `lumenc`'s parser
4625// front-end (`parse_selector_list` needs it there); the value-list helpers
4626// (`parse_transition` / `parse_box_shadow`) and `apply_declaration` need it
4627// here.
4628fn split_top_level_commas(s: &str) -> Vec<&str> {
4629    let mut out = Vec::new();
4630    let bytes = s.as_bytes();
4631    let mut depth: i32 = 0;
4632    let mut start = 0usize;
4633    let mut i = 0usize;
4634    while i < bytes.len() {
4635        match bytes[i] {
4636            b'(' | b'[' => depth += 1,
4637            b')' | b']' => depth -= 1,
4638            b',' if depth == 0 => {
4639                out.push(&s[start..i]);
4640                start = i + 1;
4641            }
4642            _ => {}
4643        }
4644        i += 1;
4645    }
4646    out.push(&s[start..]);
4647    out
4648}
4649
4650#[cfg(test)]
4651mod transition_tests {
4652    use super::*;
4653
4654    #[test]
4655    fn parses_single_opacity_transition() {
4656        let out = parse_transition("ctx", "transition", "opacity 200ms ease-out").unwrap();
4657        assert_eq!(out.len(), 1);
4658        assert_eq!(out[0].property, TransitionPropertyIr::Opacity);
4659        assert_eq!(out[0].duration_ms, 200);
4660        assert_eq!(out[0].easing, EasingIr::EaseOut);
4661    }
4662
4663    #[test]
4664    fn parses_comma_separated_entries() {
4665        let out = parse_transition(
4666            "ctx",
4667            "transition",
4668            "opacity 100ms linear, opacity 300ms ease-in",
4669        )
4670        .unwrap();
4671        assert_eq!(out.len(), 2);
4672        assert_eq!(out[1].easing, EasingIr::EaseIn);
4673        assert_eq!(out[1].duration_ms, 300);
4674    }
4675
4676    #[test]
4677    fn drops_unsupported_property() {
4678        // Layout props are deliberately not animatable in v1 - they
4679        // parse-drop with a warn, never error.
4680        let out = parse_transition(
4681            "ctx",
4682            "transition",
4683            "width 100ms ease-out, opacity 200ms ease-out",
4684        )
4685        .unwrap();
4686        assert_eq!(out.len(), 1);
4687        assert_eq!(out[0].property, TransitionPropertyIr::Opacity);
4688    }
4689
4690    #[test]
4691    fn parses_color_properties() {
4692        let out = parse_transition(
4693            "ctx",
4694            "transition",
4695            "bg 130ms ease, color 100ms linear, border-color 100ms linear",
4696        )
4697        .unwrap();
4698        assert_eq!(out.len(), 3);
4699        assert_eq!(out[0].property, TransitionPropertyIr::BackgroundColor);
4700        assert_eq!(out[1].property, TransitionPropertyIr::TextColor);
4701        assert_eq!(out[2].property, TransitionPropertyIr::BorderColor);
4702        // `ease` maps to the CSS ease cubic-bezier.
4703        assert!(matches!(out[0].easing, EasingIr::CubicBezier(..)));
4704    }
4705
4706    #[test]
4707    fn longhands_combine_with_cycled_durations() {
4708        use crate::layout_ir::Attributes;
4709        let mut attrs = Attributes::default();
4710        apply_declaration(
4711            "ctx",
4712            "transition-property",
4713            "opacity, bg, border-color",
4714            &mut attrs,
4715        )
4716        .unwrap();
4717        apply_declaration("ctx", "transition-duration", "100ms, 200ms", &mut attrs).unwrap();
4718        apply_declaration("ctx", "transition-timing-function", "linear", &mut attrs).unwrap();
4719        let eff = attrs.effective_transitions();
4720        assert_eq!(eff.len(), 3);
4721        // Durations cycle over the property list (CSS repeat rule).
4722        assert_eq!(eff[0].duration_ms, 100);
4723        assert_eq!(eff[1].duration_ms, 200);
4724        assert_eq!(eff[2].duration_ms, 100);
4725        assert!(eff.iter().all(|t| t.easing == EasingIr::Linear));
4726    }
4727
4728    #[test]
4729    fn shorthand_wins_over_longhands() {
4730        use crate::layout_ir::Attributes;
4731        let mut attrs = Attributes::default();
4732        apply_declaration("ctx", "transition-property", "bg", &mut attrs).unwrap();
4733        apply_declaration("ctx", "transition-duration", "999ms", &mut attrs).unwrap();
4734        apply_declaration("ctx", "transition", "opacity 150ms ease-out", &mut attrs).unwrap();
4735        let eff = attrs.effective_transitions();
4736        assert_eq!(eff.len(), 1);
4737        assert_eq!(eff[0].property, TransitionPropertyIr::Opacity);
4738        assert_eq!(eff[0].duration_ms, 150);
4739    }
4740
4741    #[test]
4742    fn accepts_seconds_duration() {
4743        let out = parse_transition("ctx", "transition", "opacity 0.25s").unwrap();
4744        assert_eq!(out[0].duration_ms, 250);
4745    }
4746
4747    #[test]
4748    fn defaults_easing_to_ease() {
4749        // CSS initial `transition-timing-function`.
4750        let out = parse_transition("ctx", "transition", "opacity 200ms").unwrap();
4751        assert_eq!(out[0].easing, DEFAULT_EASING);
4752        assert_eq!(out[0].easing, parse_easing("ctx", "t", &["ease"]).unwrap());
4753    }
4754
4755    #[test]
4756    fn all_expands_to_every_animatable_property() {
4757        let out = parse_transition("ctx", "transition", "all 200ms linear").unwrap();
4758        assert_eq!(out.len(), ALL_TRANSITION_PROPERTIES.len());
4759        let props: Vec<_> = out.iter().map(|t| t.property).collect();
4760        assert_eq!(props, ALL_TRANSITION_PROPERTIES.to_vec());
4761        assert!(out.iter().all(|t| t.duration_ms == 200));
4762        assert!(out.iter().all(|t| t.easing == EasingIr::Linear));
4763    }
4764
4765    #[test]
4766    fn all_shorthand_overrides_longhands() {
4767        // An `all` shorthand used to parse to an empty list, which then
4768        // lost to whatever the longhands said.
4769        let mut attrs = Attributes {
4770            transition_property: Some(vec![TransitionPropertyIr::Opacity]),
4771            transition_duration: Some(vec![900]),
4772            ..Default::default()
4773        };
4774        apply_declaration("ctx", "transition", "all 120ms", &mut attrs).unwrap();
4775        let eff = attrs.effective_transitions();
4776        assert_eq!(eff.len(), ALL_TRANSITION_PROPERTIES.len());
4777        assert!(eff.iter().all(|t| t.duration_ms == 120));
4778    }
4779
4780    #[test]
4781    fn longhand_property_accepts_all() {
4782        let mut attrs = Attributes::default();
4783        apply_declaration("ctx", "transition-property", "all", &mut attrs).unwrap();
4784        assert_eq!(
4785            attrs.transition_property.as_deref(),
4786            Some(&ALL_TRANSITION_PROPERTIES[..])
4787        );
4788    }
4789
4790    #[test]
4791    fn delay_term_is_ignored_not_fatal() {
4792        // `transition: opacity 200ms 50ms` used to fail the whole
4793        // declaration on the delay term.
4794        let out = parse_transition("ctx", "transition", "opacity 200ms 50ms").unwrap();
4795        assert_eq!(out.len(), 1);
4796        assert_eq!(out[0].duration_ms, 200);
4797        assert_eq!(out[0].easing, DEFAULT_EASING);
4798
4799        let out = parse_transition("ctx", "transition", "opacity 200ms ease-in 50ms").unwrap();
4800        assert_eq!(out[0].duration_ms, 200);
4801        assert_eq!(out[0].easing, EasingIr::EaseIn);
4802    }
4803
4804    #[test]
4805    fn delay_longhand_is_recognized_and_ignored() {
4806        let mut attrs = Attributes::default();
4807        assert!(apply_declaration("ctx", "transition-delay", "50ms", &mut attrs).unwrap());
4808        assert!(attrs.transitions.is_empty());
4809    }
4810
4811    #[test]
4812    fn the_animation_family_is_recognized_and_stores_nothing() {
4813        // The web target plays a keyframe animation; the desktop cascade
4814        // has no keyframe engine, so it takes the declaration without
4815        // calling it unknown and without setting anything.
4816        let mut attrs = Attributes::default();
4817        for name in [
4818            "animation",
4819            "animation-name",
4820            "animation-duration",
4821            "animation-timing-function",
4822            "animation-delay",
4823            "animation-iteration-count",
4824            "animation-direction",
4825            "animation-fill-mode",
4826            "animation-play-state",
4827        ] {
4828            assert!(
4829                apply_declaration("ctx", name, "spin 2s linear infinite", &mut attrs).unwrap(),
4830                "`{name}` must be a property the cascade recognizes"
4831            );
4832        }
4833        assert!(attrs.transitions.is_empty());
4834    }
4835
4836    #[test]
4837    fn cubic_bezier_with_spaces_survives_the_term_scan() {
4838        let out = parse_transition(
4839            "ctx",
4840            "transition",
4841            "opacity 200ms cubic-bezier(0.4, 0, 0.2, 1)",
4842        )
4843        .unwrap();
4844        assert_eq!(out[0].easing, EasingIr::CubicBezier(0.4, 0.0, 0.2, 1.0));
4845    }
4846
4847    #[test]
4848    fn rejects_missing_duration() {
4849        assert!(parse_transition("ctx", "transition", "opacity").is_err());
4850    }
4851
4852    #[test]
4853    fn parses_cubic_bezier() {
4854        let out = parse_transition(
4855            "ctx",
4856            "transition",
4857            "opacity 200ms cubic-bezier(0.4, 0, 0.2, 1)",
4858        )
4859        .unwrap();
4860        assert_eq!(out[0].easing, EasingIr::CubicBezier(0.4, 0.0, 0.2, 1.0));
4861    }
4862}
4863
4864#[cfg(test)]
4865mod cascade_origin_tests {
4866    use super::*;
4867
4868    fn subject(compound: CompoundSelector) -> SelectorBuf {
4869        SelectorBuf {
4870            chain: vec![(Combinator::Subject, compound)],
4871        }
4872    }
4873
4874    fn rule(selector: SelectorBuf, origin: Origin, source_order: usize) -> Rule {
4875        Rule {
4876            selectors: vec![selector],
4877            declarations: Vec::new(),
4878            origin,
4879            source_order,
4880            media: None,
4881            selector: LegacySelectorShim::default(),
4882        }
4883    }
4884
4885    /// A user-agent (skin) `textarea:hover` rule has higher specificity
4886    /// (0,1,1) than an author `.editor` rule (0,1,0), yet author origin
4887    /// dominates the cascade: the author rule must sort last (win). This
4888    /// is the notes-app "dark blue unreadable hover" fix: author
4889    /// `.editor { bg: var(--surface) }` beats skin `textarea:hover`.
4890    #[test]
4891    fn author_class_beats_user_agent_pseudo_despite_specificity() {
4892        // Skin rule: `textarea:hover` at the user-agent origin.
4893        let ua = rule(
4894            subject(CompoundSelector {
4895                tag: Some("textarea".to_string()),
4896                id: None,
4897                classes: Vec::new(),
4898                pseudo_classes: vec![PseudoClass::Hover],
4899            }),
4900            Origin::UserAgent,
4901            0,
4902        );
4903        // Author rule: `.editor` at the author origin.
4904        let author = rule(
4905            subject(CompoundSelector {
4906                tag: None,
4907                id: None,
4908                classes: vec!["editor".to_string()],
4909                pseudo_classes: Vec::new(),
4910            }),
4911            Origin::Author,
4912            1,
4913        );
4914
4915        // Sanity: the UA rule really is more specific, so without the
4916        // origin term it would (wrongly) win the sort.
4917        assert!(
4918            ua.selectors[0].specificity() > author.selectors[0].specificity(),
4919            "test premise: UA textarea:hover must out-specific author .editor"
4920        );
4921
4922        let css = Stylesheet {
4923            rules: vec![ua, author],
4924            ..Default::default()
4925        };
4926        // A hovered <textarea class="editor"> element.
4927        let me = ElementRef {
4928            tag: "textarea".to_string(),
4929            classes: vec!["editor".to_string()],
4930            id: None,
4931            child_index: 1,
4932            sibling_count: 1,
4933            siblings: None,
4934        };
4935        let matched =
4936            collect_matching_rules(&css, &MediaContext::default(), &me, &[], false, None, false);
4937
4938        // Both rules match the element.
4939        assert_eq!(matched.len(), 2, "both rules should match");
4940        // Cascade sort is ascending -> last wins. Author (rule_idx 1)
4941        // must sort LAST despite the UA rule's higher specificity.
4942        assert_eq!(
4943            matched.last().unwrap().rule_idx,
4944            1,
4945            "author .editor must win over UA textarea:hover"
4946        );
4947        assert_eq!(matched.last().unwrap().origin, Origin::Author);
4948    }
4949
4950    /// Within a single origin the existing (specificity, source_order)
4951    /// tie-break still applies; origin only dominates across origins.
4952    #[test]
4953    fn specificity_still_wins_within_same_origin() {
4954        let low = rule(
4955            subject(CompoundSelector {
4956                tag: Some("textarea".to_string()),
4957                id: None,
4958                classes: Vec::new(),
4959                pseudo_classes: Vec::new(),
4960            }),
4961            Origin::Author,
4962            0,
4963        );
4964        let high = rule(
4965            subject(CompoundSelector {
4966                tag: None,
4967                id: None,
4968                classes: vec!["editor".to_string()],
4969                pseudo_classes: Vec::new(),
4970            }),
4971            Origin::Author,
4972            1,
4973        );
4974        let css = Stylesheet {
4975            rules: vec![low, high],
4976            ..Default::default()
4977        };
4978        let me = ElementRef {
4979            tag: "textarea".to_string(),
4980            classes: vec!["editor".to_string()],
4981            id: None,
4982            child_index: 1,
4983            sibling_count: 1,
4984            siblings: None,
4985        };
4986        let matched =
4987            collect_matching_rules(&css, &MediaContext::default(), &me, &[], false, None, false);
4988        assert_eq!(matched.len(), 2);
4989        // Higher specificity `.editor` (rule_idx 1) wins within origin.
4990        assert_eq!(matched.last().unwrap().rule_idx, 1);
4991    }
4992}
4993
4994#[cfg(test)]
4995mod query_surface_tests {
4996    use super::*;
4997
4998    fn anc(tag: &str, classes: &[&str], id: Option<&str>) -> AncestorInfo {
4999        AncestorInfo::new(
5000            tag,
5001            classes.iter().map(|s| s.to_string()).collect(),
5002            id.map(|s| s.to_string()),
5003        )
5004    }
5005
5006    fn one(src: &str) -> SelectorBuf {
5007        let list = parse_selector_list(src).unwrap();
5008        assert_eq!(list.len(), 1, "expected single selector for '{src}'");
5009        list.into_iter().next().unwrap()
5010    }
5011
5012    #[test]
5013    fn parse_round_trips_basic_shapes() {
5014        assert_eq!(parse_selector_list("#save").unwrap().len(), 1);
5015        assert_eq!(parse_selector_list(".row").unwrap().len(), 1);
5016        assert_eq!(parse_selector_list("button").unwrap().len(), 1);
5017        assert_eq!(parse_selector_list("button.primary").unwrap().len(), 1);
5018        // Descendant and child chains yield multi-compound chains.
5019        assert_eq!(one(".card .row").chain.len(), 2);
5020        assert_eq!(one(".card > .row").chain.len(), 2);
5021        // Selector list splits on top-level commas.
5022        assert_eq!(parse_selector_list("#a, .b, c").unwrap().len(), 3);
5023    }
5024
5025    #[test]
5026    fn parse_rejects_garbage() {
5027        assert!(parse_selector_list("").is_err());
5028        assert!(parse_selector_list("   ").is_err());
5029        assert!(parse_selector_list("#a,").is_err());
5030    }
5031
5032    #[test]
5033    fn matches_id_class_tag() {
5034        let subject = anc("button", &["primary", "big"], Some("save"));
5035        assert!(selector_matches(&one("#save"), &subject, &[]));
5036        assert!(selector_matches(&one(".primary"), &subject, &[]));
5037        assert!(selector_matches(&one("button"), &subject, &[]));
5038        assert!(selector_matches(&one("button.primary"), &subject, &[]));
5039        assert!(selector_matches(&one(".primary.big"), &subject, &[]));
5040        assert!(!selector_matches(&one("#other"), &subject, &[]));
5041        assert!(!selector_matches(&one(".missing"), &subject, &[]));
5042        assert!(!selector_matches(&one("label"), &subject, &[]));
5043    }
5044
5045    #[test]
5046    fn descendant_and_child_combinators() {
5047        // Tree: root.app > .card > button#save  (ancestors root-first).
5048        let ancestors = [anc("root", &["app"], None), anc("div", &["card"], None)];
5049        let subject = anc("button", &[], Some("save"));
5050        assert!(selector_matches(&one(".card button"), &subject, &ancestors));
5051        assert!(selector_matches(
5052            &one(".app .card #save"),
5053            &subject,
5054            &ancestors
5055        ));
5056        assert!(selector_matches(
5057            &one(".card > #save"),
5058            &subject,
5059            &ancestors
5060        ));
5061        // Child combinator binds to the immediate parent only.
5062        assert!(!selector_matches(
5063            &one(".app > #save"),
5064            &subject,
5065            &ancestors
5066        ));
5067        // Descendant that names a non-ancestor fails.
5068        assert!(!selector_matches(
5069            &one(".sidebar #save"),
5070            &subject,
5071            &ancestors
5072        ));
5073    }
5074
5075    #[test]
5076    fn selector_matches_agrees_with_cascade_match_selector() {
5077        // Cross-check the public wrapper against the private matcher on
5078        // the same hand-built chain.
5079        let ancestors = [anc("root", &["theme-dark"], None)];
5080        let subject = anc("div", &["card"], None);
5081        let sel = one(".theme-dark .card");
5082        let me = subject.to_ref();
5083        let parents: Vec<ElementRef> = ancestors.iter().map(AncestorInfo::to_ref).collect();
5084        let ctx = NodeCtx {
5085            el: &me,
5086            ancestors: &parents,
5087            siblings: None,
5088            sibling_index: 0,
5089            has_element_children: true,
5090            text_body: None,
5091            is_root: false,
5092        };
5093        let via_private = match_selector(&sel, &ctx).is_some();
5094        let via_public = selector_matches(&sel, &subject, &ancestors);
5095        assert_eq!(via_private, via_public);
5096        assert!(via_public);
5097    }
5098
5099    #[test]
5100    fn sibling_combinators_conservatively_fail() {
5101        let subject = anc("button", &["b"], None);
5102        let ancestors = [anc("root", &[], None)];
5103        // An `AncestorInfo` chain carries no sibling identity, so a
5104        // sibling step fails here. The full-tree cascade matches them.
5105        assert!(!selector_matches(&one(".a + .b"), &subject, &ancestors));
5106        assert!(!selector_matches(&one(".a ~ .b"), &subject, &ancestors));
5107    }
5108
5109    #[test]
5110    fn root_pseudo_matches_only_root() {
5111        let root = anc("root", &[], None);
5112        assert!(selector_matches(&one(":root"), &root, &[]));
5113        let child = anc("div", &[], None);
5114        assert!(!selector_matches(
5115            &one(":root"),
5116            &child,
5117            &[anc("root", &[], None)]
5118        ));
5119    }
5120}
5121
5122#[cfg(test)]
5123mod inline_style_tests {
5124    use super::*;
5125
5126    #[test]
5127    fn inline_declaration_overrides_a_cascaded_value() {
5128        // Simulate the stylesheet cascade having resolved a blue color.
5129        let mut attrs = Attributes::default();
5130        apply_declaration("rule", "color", "#0000ff", &mut attrs).unwrap();
5131        assert_eq!(
5132            computed_property(&attrs, "color").as_deref(),
5133            Some("#0000ff")
5134        );
5135        // The inline layer (highest tier) beats it.
5136        assert!(apply_inline_declaration("color", "#ff0000", &mut attrs).unwrap());
5137        assert_eq!(
5138            computed_property(&attrs, "color").as_deref(),
5139            Some("#ff0000")
5140        );
5141    }
5142
5143    #[test]
5144    fn computed_property_renders_common_props() {
5145        let mut attrs = Attributes::default();
5146        apply_inline_declaration("width", "120px", &mut attrs).unwrap();
5147        apply_inline_declaration("opacity", "0.5", &mut attrs).unwrap();
5148        assert_eq!(computed_property(&attrs, "width").as_deref(), Some("120px"));
5149        assert_eq!(computed_property(&attrs, "opacity").as_deref(), Some("0.5"));
5150        // An unmodeled property returns None rather than panicking.
5151        assert_eq!(computed_property(&attrs, "z-index"), None);
5152    }
5153}
5154
5155/// Stylesheet-declaration coverage for the skin-tokens property batch
5156/// (widget geometry, caret/text, scrollbar). Each property here also has
5157/// an inline-markup-attribute counterpart in
5158/// `public/lumenc/tests/parse_skin_tokens.rs`; a property landing in only one of
5159/// the two tables is the exact bug this batch is guarding against.
5160#[cfg(test)]
5161mod skin_token_property_tests {
5162    use super::*;
5163
5164    #[test]
5165    fn knob_inset_parses_px() {
5166        let mut attrs = Attributes::default();
5167        apply_declaration("ctx", "knob-inset", "2px", &mut attrs).unwrap();
5168        assert_eq!(attrs.knob_inset, Some(2.0));
5169    }
5170
5171    #[test]
5172    fn thumb_size_parses_bare_number_as_px() {
5173        let mut attrs = Attributes::default();
5174        apply_declaration("ctx", "thumb-size", "20", &mut attrs).unwrap();
5175        assert_eq!(attrs.thumb_size, Some(20.0));
5176    }
5177
5178    #[test]
5179    fn popup_gap_parses_px() {
5180        let mut attrs = Attributes::default();
5181        apply_declaration("ctx", "popup-gap", "4px", &mut attrs).unwrap();
5182        assert_eq!(attrs.popup_gap, Some(4.0));
5183    }
5184
5185    #[test]
5186    fn length_px_rejects_non_numeric() {
5187        // Malformed case for the "length px" value shape shared by
5188        // knob-inset / thumb-size / popup-gap / caret-width /
5189        // scrollbar-thickness / scrollbar-margin / scrollbar-min-thumb.
5190        let mut attrs = Attributes::default();
5191        assert!(apply_declaration("ctx", "knob-inset", "wide", &mut attrs).is_err());
5192    }
5193
5194    #[test]
5195    fn progress_chunk_parses_fraction() {
5196        let mut attrs = Attributes::default();
5197        apply_declaration("ctx", "progress-chunk", "0.35", &mut attrs).unwrap();
5198        assert_eq!(attrs.progress_chunk, Some(0.35));
5199    }
5200
5201    #[test]
5202    fn progress_chunk_rejects_out_of_range() {
5203        // Malformed case for the "fraction 0.0-1.0" shape: out of range
5204        // is a hard error here (Lumen-native, no CSS clamping precedent).
5205        let mut attrs = Attributes::default();
5206        assert!(apply_declaration("ctx", "progress-chunk", "1.5", &mut attrs).is_err());
5207    }
5208
5209    #[test]
5210    fn progress_chunk_rejects_non_numeric() {
5211        let mut attrs = Attributes::default();
5212        assert!(apply_declaration("ctx", "progress-chunk", "lots", &mut attrs).is_err());
5213    }
5214
5215    #[test]
5216    fn disabled_opacity_parses_and_clamps() {
5217        // Mirrors the existing `opacity` property: out-of-range values
5218        // clamp rather than error, since this is the CSS-authored
5219        // stand-in for a plain alpha multiplier.
5220        let mut attrs = Attributes::default();
5221        apply_declaration("ctx", "disabled-opacity", "0.4", &mut attrs).unwrap();
5222        assert_eq!(attrs.disabled_opacity_default, Some(0.4));
5223        apply_declaration("ctx", "disabled-opacity", "2.0", &mut attrs).unwrap();
5224        assert_eq!(attrs.disabled_opacity_default, Some(1.0));
5225    }
5226
5227    #[test]
5228    fn disabled_opacity_is_distinct_from_pseudo_disabled_opacity() {
5229        // `disabled-opacity` (the generic fallback) and `:disabled {
5230        // opacity }` (the explicit state override, already modeled as
5231        // `disabled_opacity`) must not collide on one field.
5232        let mut attrs = Attributes::default();
5233        apply_declaration("ctx", "disabled-opacity", "0.3", &mut attrs).unwrap();
5234        assert_eq!(attrs.disabled_opacity_default, Some(0.3));
5235        assert_eq!(attrs.disabled_opacity, None);
5236    }
5237
5238    #[test]
5239    fn caret_width_parses_px() {
5240        let mut attrs = Attributes::default();
5241        apply_declaration("ctx", "caret-width", "2px", &mut attrs).unwrap();
5242        assert_eq!(attrs.caret_width, Some(2.0));
5243    }
5244
5245    #[test]
5246    fn caret_blink_parses_ms() {
5247        let mut attrs = Attributes::default();
5248        apply_declaration("ctx", "caret-blink", "530ms", &mut attrs).unwrap();
5249        assert_eq!(attrs.caret_blink_ms, Some(530));
5250    }
5251
5252    #[test]
5253    fn caret_blink_parses_seconds() {
5254        let mut attrs = Attributes::default();
5255        apply_declaration("ctx", "caret-blink", "0.5s", &mut attrs).unwrap();
5256        assert_eq!(attrs.caret_blink_ms, Some(500));
5257    }
5258
5259    #[test]
5260    fn duration_rejects_missing_unit() {
5261        // Malformed case for the "duration" shape shared by caret-blink
5262        // and the scrollbar-fade-* properties: a bare number without
5263        // `ms`/`s` is rejected, matching `transition-duration`.
5264        let mut attrs = Attributes::default();
5265        assert!(apply_declaration("ctx", "caret-blink", "500", &mut attrs).is_err());
5266    }
5267
5268    #[test]
5269    fn password_character_parses_single_char() {
5270        let mut attrs = Attributes::default();
5271        apply_declaration("ctx", "password-character", "*", &mut attrs).unwrap();
5272        assert_eq!(attrs.password_character, Some('*'));
5273    }
5274
5275    #[test]
5276    fn password_character_accepts_non_ascii_scalar() {
5277        let mut attrs = Attributes::default();
5278        apply_declaration("ctx", "password-character", "\u{2022}", &mut attrs).unwrap();
5279        assert_eq!(attrs.password_character, Some('\u{2022}'));
5280    }
5281
5282    #[test]
5283    fn password_character_rejects_multiple_characters() {
5284        // Malformed case for the "single character" shape.
5285        let mut attrs = Attributes::default();
5286        assert!(apply_declaration("ctx", "password-character", "**", &mut attrs).is_err());
5287    }
5288
5289    #[test]
5290    fn password_character_rejects_empty() {
5291        let mut attrs = Attributes::default();
5292        assert!(apply_declaration("ctx", "password-character", "", &mut attrs).is_err());
5293    }
5294
5295    #[test]
5296    fn line_height_parses_unitless_multiplier() {
5297        let mut attrs = Attributes::default();
5298        apply_declaration("ctx", "line-height", "1.2", &mut attrs).unwrap();
5299        assert_eq!(attrs.line_height, Some(LineHeightSpec::Multiplier(1.2)));
5300    }
5301
5302    #[test]
5303    fn line_height_parses_px() {
5304        let mut attrs = Attributes::default();
5305        apply_declaration("ctx", "line-height", "19px", &mut attrs).unwrap();
5306        assert_eq!(attrs.line_height, Some(LineHeightSpec::Px(19.0)));
5307    }
5308
5309    #[test]
5310    fn line_height_unitless_and_px_are_distinct_variants() {
5311        // The whole point of `LineHeightSpec`: "1.2" and "1.2px" carry
5312        // different meanings and must not collapse to the same value.
5313        let mut a = Attributes::default();
5314        let mut b = Attributes::default();
5315        apply_declaration("ctx", "line-height", "1.2", &mut a).unwrap();
5316        apply_declaration("ctx", "line-height", "1.2px", &mut b).unwrap();
5317        assert_ne!(a.line_height, b.line_height);
5318    }
5319
5320    #[test]
5321    fn line_height_rejects_negative() {
5322        // Malformed case for the "unitless-vs-px" shape.
5323        let mut attrs = Attributes::default();
5324        assert!(apply_declaration("ctx", "line-height", "-1", &mut attrs).is_err());
5325    }
5326
5327    #[test]
5328    fn line_height_inherits_to_children() {
5329        // `line-height` set on a container must cascade down to a
5330        // non-matching child, mirroring the existing `font-size` /
5331        // `text-color` inheritance behavior modeled by `InheritedText`.
5332        let mut ir = LayoutIR {
5333            root: Element {
5334                tag: "column".to_string(),
5335                children: vec![Element {
5336                    tag: "label".to_string(),
5337                    ..Default::default()
5338                }],
5339                ..Default::default()
5340            },
5341            ..Default::default()
5342        };
5343        let css = Stylesheet {
5344            rules: vec![Rule {
5345                selectors: vec![SelectorBuf {
5346                    chain: vec![(
5347                        Combinator::Subject,
5348                        CompoundSelector {
5349                            tag: Some("column".to_string()),
5350                            id: None,
5351                            classes: Vec::new(),
5352                            pseudo_classes: Vec::new(),
5353                        },
5354                    )],
5355                }],
5356                declarations: vec![Declaration {
5357                    name: "line-height".to_string(),
5358                    value: "1.5".to_string(),
5359                    important: false,
5360                }],
5361                origin: Origin::Author,
5362                source_order: 0,
5363                media: None,
5364                selector: LegacySelectorShim::default(),
5365            }],
5366            ..Default::default()
5367        };
5368        apply_css(&mut ir, &css).unwrap();
5369        assert_eq!(
5370            ir.root.children[0].attrs.line_height,
5371            Some(LineHeightSpec::Multiplier(1.5))
5372        );
5373    }
5374
5375    #[test]
5376    fn scrollbar_thickness_parses_px() {
5377        let mut attrs = Attributes::default();
5378        apply_declaration("ctx", "scrollbar-thickness", "10px", &mut attrs).unwrap();
5379        assert_eq!(attrs.scrollbar_thickness, Some(10.0));
5380    }
5381
5382    #[test]
5383    fn scrollbar_thickness_thin_parses_px() {
5384        let mut attrs = Attributes::default();
5385        apply_declaration("ctx", "scrollbar-thickness-thin", "6px", &mut attrs).unwrap();
5386        assert_eq!(attrs.scrollbar_thickness_thin, Some(6.0));
5387    }
5388
5389    #[test]
5390    fn scrollbar_margin_parses_px() {
5391        let mut attrs = Attributes::default();
5392        apply_declaration("ctx", "scrollbar-margin", "2px", &mut attrs).unwrap();
5393        assert_eq!(attrs.scrollbar_margin, Some(2.0));
5394    }
5395
5396    #[test]
5397    fn scrollbar_min_thumb_parses_px() {
5398        let mut attrs = Attributes::default();
5399        apply_declaration("ctx", "scrollbar-min-thumb", "24px", &mut attrs).unwrap();
5400        assert_eq!(attrs.scrollbar_min_thumb, Some(24.0));
5401    }
5402
5403    #[test]
5404    fn scrollbar_track_hover_parses_color() {
5405        let mut attrs = Attributes::default();
5406        apply_declaration("ctx", "scrollbar-track-hover", "#334455", &mut attrs).unwrap();
5407        let c = attrs.scrollbar_track_hover.unwrap();
5408        assert!((c.g - (0x44 as f32 / 255.0)).abs() < 1e-6);
5409    }
5410
5411    #[test]
5412    fn color_rejects_non_hex() {
5413        // Malformed case for the "colour" shape.
5414        let mut attrs = Attributes::default();
5415        assert!(apply_declaration("ctx", "scrollbar-track-hover", "blue", &mut attrs).is_err());
5416    }
5417
5418    #[test]
5419    fn scrollbar_hover_boost_parses_number() {
5420        let mut attrs = Attributes::default();
5421        apply_declaration("ctx", "scrollbar-hover-boost", "1.4", &mut attrs).unwrap();
5422        assert_eq!(attrs.scrollbar_hover_boost, Some(1.4));
5423    }
5424
5425    #[test]
5426    fn scrollbar_fade_delay_parses_ms() {
5427        let mut attrs = Attributes::default();
5428        apply_declaration("ctx", "scrollbar-fade-delay", "800ms", &mut attrs).unwrap();
5429        assert_eq!(attrs.scrollbar_fade_delay_ms, Some(800));
5430    }
5431
5432    #[test]
5433    fn scrollbar_fade_duration_parses_seconds() {
5434        let mut attrs = Attributes::default();
5435        apply_declaration("ctx", "scrollbar-fade-duration", "0.2s", &mut attrs).unwrap();
5436        assert_eq!(attrs.scrollbar_fade_duration_ms, Some(200));
5437    }
5438
5439    #[test]
5440    fn none_of_the_batch_is_pseudo_routed() {
5441        // All seventeen properties in this batch are plain,
5442        // always-applicable declarations - none represent a per-state
5443        // color/text/opacity/shadow/border swap, so none get a match arm
5444        // in `apply_decl_for_pseudo`. Confirm the fallthrough (silently
5445        // consumed, matching every other non-routed property) rather than
5446        // an error, so a stray `:hover { knob-inset: 2px }` doesn't break
5447        // the whole rule.
5448        let mut attrs = Attributes::default();
5449        let pseudo = SubjectPseudo {
5450            hover: true,
5451            ..Default::default()
5452        };
5453        assert!(apply_decl_for_pseudo("ctx", "knob-inset", "2px", &pseudo, &mut attrs).unwrap());
5454        assert_eq!(attrs.knob_inset, None);
5455    }
5456}
5457
5458#[cfg(test)]
5459mod root_vars_tests {
5460    use super::*;
5461
5462    /// A `:root { --name: value; ... }` rule, source-ordered at `order`
5463    /// within `sheet`. This is the `:root` pseudo-class, which is what
5464    /// carries custom properties in every shipped skin. It is a different
5465    /// selector from the `root` element that styles the `<root>` tag, and
5466    /// building the latter here is what let a bug in `root_vars` go
5467    /// unnoticed: it matched the element rule, which never holds a `--`
5468    /// declaration.
5469    fn root_rule(decls: &[(&str, &str)], order: usize) -> Rule {
5470        Rule {
5471            selectors: vec![SelectorBuf {
5472                chain: vec![(
5473                    Combinator::Subject,
5474                    CompoundSelector {
5475                        tag: None,
5476                        id: None,
5477                        classes: Vec::new(),
5478                        pseudo_classes: vec![PseudoClass::Root],
5479                    },
5480                )],
5481            }],
5482            declarations: decls
5483                .iter()
5484                .map(|(name, value)| Declaration {
5485                    name: name.to_string(),
5486                    value: value.to_string(),
5487                    important: false,
5488                })
5489                .collect(),
5490            origin: Origin::UserAgent,
5491            source_order: order,
5492            media: None,
5493            selector: LegacySelectorShim {
5494                tag: Some("root".to_string()),
5495                classes: Vec::new(),
5496            },
5497        }
5498    }
5499
5500    #[test]
5501    fn root_vars_collects_custom_properties() {
5502        let sheet = Stylesheet {
5503            rules: vec![root_rule(&[("--lumen-window-bg", "#101012")], 0)],
5504            ..Default::default()
5505        };
5506        let vars = sheet.root_vars();
5507        assert_eq!(
5508            vars.get("lumen-window-bg").map(String::as_str),
5509            Some("#101012")
5510        );
5511    }
5512
5513    #[test]
5514    fn later_root_rule_wins_over_earlier_for_same_name() {
5515        // Mirrors UA -> skin -> author precedence: `combined_rules` appends
5516        // layers in that order, so a later rule for the same `:root`
5517        // custom property overrides an earlier one - last wins.
5518        let sheet = Stylesheet {
5519            rules: vec![
5520                root_rule(&[("--lumen-window-bg", "#111111")], 0), // UA / skin
5521                root_rule(&[("--lumen-window-bg", "#222222")], 1), // author override
5522            ],
5523            ..Default::default()
5524        };
5525        assert_eq!(
5526            sheet.resolve_root_var("lumen-window-bg").as_deref(),
5527            Some("#222222")
5528        );
5529    }
5530
5531    #[test]
5532    fn resolve_root_var_follows_nested_var_chain() {
5533        let sheet = Stylesheet {
5534            rules: vec![root_rule(
5535                &[
5536                    ("--lumen-accent", "#33c7ce"),
5537                    ("--lumen-window-bg", "var(--lumen-accent)"),
5538                ],
5539                0,
5540            )],
5541            ..Default::default()
5542        };
5543        assert_eq!(
5544            sheet.resolve_root_var("lumen-window-bg").as_deref(),
5545            Some("#33c7ce")
5546        );
5547    }
5548
5549    #[test]
5550    fn resolve_root_var_none_when_undefined() {
5551        let sheet = Stylesheet {
5552            rules: vec![root_rule(&[("--lumen-accent", "#33c7ce")], 0)],
5553            ..Default::default()
5554        };
5555        assert_eq!(sheet.resolve_root_var("lumen-window-bg"), None);
5556    }
5557
5558    #[test]
5559    fn structural_root_variant_is_not_a_defining_rule() {
5560        // `:root.dark { --x }` is a class-scoped structural override, not
5561        // the plain unconditional `:root` this helper resolves against -
5562        // it stays confined to the real per-element cascade.
5563        let mut rule = root_rule(&[("--lumen-window-bg", "#000000")], 0);
5564        // Push the class onto the real selector chain, not the legacy
5565        // shim: `root_vars` reads the chain, so a shim-only mutation would
5566        // leave the rule looking like a plain `:root` and the test would
5567        // pass without exercising anything.
5568        rule.selectors[0].chain[0]
5569            .1
5570            .classes
5571            .push("dark".to_string());
5572        let sheet = Stylesheet {
5573            rules: vec![rule],
5574            ..Default::default()
5575        };
5576        assert_eq!(sheet.resolve_root_var("lumen-window-bg"), None);
5577    }
5578
5579    #[test]
5580    fn media_guarded_root_rule_is_excluded_even_when_it_sorts_last() {
5581        // Skins write light-first with a `@media (prefers-color-scheme:
5582        // dark)` override declared AFTER the base `:root` block, so a
5583        // naive "fold every :root rule, last wins" would silently prefer
5584        // dark regardless of the real OS preference - there is no live
5585        // `MediaContext` at the point this is consulted (see the doc
5586        // comment). The light (unconditioned) rule must still resolve.
5587        let base = root_rule(&[("--lumen-window-bg", "#fafafb")], 0);
5588        let mut dark = root_rule(&[("--lumen-window-bg", "#222226")], 1);
5589        dark.media = Some(MediaQuery {
5590            features: vec![MediaFeature::PrefersColorScheme(
5591                ColorSchemePreference::Dark,
5592            )],
5593        });
5594        let sheet = Stylesheet {
5595            rules: vec![base, dark],
5596            ..Default::default()
5597        };
5598        assert_eq!(
5599            sheet.resolve_root_var("lumen-window-bg").as_deref(),
5600            Some("#fafafb"),
5601            "a @media-guarded :root rule must not win over the unconditioned one"
5602        );
5603    }
5604}
5605
5606#[cfg(test)]
5607mod palette_root_css_tests {
5608    //! `lumen-ir` has no CSS parser of its own (it lives in `lumenc`), so
5609    //! these check `palette_root_css`'s generated text structurally rather
5610    //! than by parsing it; `public/lumenc/tests/palette_theme.rs` and
5611    //! `public/lumenc/tests/window_clear_color.rs` exercise it through the real
5612    //! parser + cascade.
5613    use super::*;
5614
5615    #[test]
5616    fn contains_a_base_root_block_and_a_dark_media_override() {
5617        let css = palette_root_css();
5618        assert!(
5619            css.starts_with(":root {"),
5620            "expected the light theme as the base :root block: {css}"
5621        );
5622        assert!(
5623            css.contains("@media (prefers-color-scheme: dark)"),
5624            "expected a dark override block: {css}"
5625        );
5626    }
5627
5628    #[test]
5629    fn every_palette_role_appears_as_a_hyphenated_custom_property() {
5630        let css = palette_root_css();
5631        assert!(css.contains("--accent-color:"), "{css}");
5632        assert!(css.contains("--window-bg-color:"), "{css}");
5633        assert!(
5634            !css.contains("accent_color"),
5635            "role names must be hyphenated, not underscored"
5636        );
5637    }
5638
5639    #[test]
5640    fn is_deterministic_across_calls() {
5641        // Iteration order over `Palette::colors` (a `HashMap`) is not
5642        // stable; the generator must sort before formatting so two calls -
5643        // and therefore two `combined_rules` `source_order` sequences in
5644        // separate `load_ir` / `compile_dir` runs - never disagree.
5645        assert_eq!(palette_root_css(), palette_root_css());
5646    }
5647
5648    #[test]
5649    fn light_and_dark_blocks_disagree_on_window_bg() {
5650        // Sanity that the two variants actually carry different values -
5651        // otherwise the dark block would be a no-op and the media-exclusion
5652        // behavior in `Stylesheet::root_vars` would be untestable from the
5653        // generated source.
5654        let css = palette_root_css();
5655        let (light_part, dark_part) = css.split_once("@media").expect("has a dark block");
5656        assert!(light_part.contains("--window-bg-color: #fafafbff"));
5657        assert!(dark_part.contains("--window-bg-color: #222226ff"));
5658    }
5659}
5660
5661#[cfg(test)]
5662mod media_query_text_tests {
5663    use super::*;
5664
5665    #[test]
5666    fn every_feature_serializes_to_the_spelling_the_parser_reads() {
5667        let mq = MediaQuery {
5668            features: vec![
5669                MediaFeature::PrefersColorScheme(ColorSchemePreference::Dark),
5670                MediaFeature::PrefersReducedMotion(MotionPreference::Reduce),
5671                MediaFeature::PrefersContrast(ContrastPreference::More),
5672                MediaFeature::MinWidth(600.0),
5673                MediaFeature::MaxWidth(1200.5),
5674                MediaFeature::Width(800.0),
5675            ],
5676        };
5677        assert_eq!(
5678            media_query_to_css(&mq),
5679            "(prefers-color-scheme: dark) and (prefers-reduced-motion: reduce) and \
5680             (prefers-contrast: more) and (min-width: 600px) and (max-width: 1200.5px) and \
5681             (width: 800px)"
5682        );
5683    }
5684
5685    #[test]
5686    fn the_no_preference_values_keep_their_own_spelling() {
5687        let one = |f: MediaFeature| media_query_to_css(&MediaQuery { features: vec![f] });
5688        assert_eq!(
5689            one(MediaFeature::PrefersColorScheme(
5690                ColorSchemePreference::NoPreference
5691            )),
5692            "(prefers-color-scheme: no-preference)"
5693        );
5694        assert_eq!(
5695            one(MediaFeature::PrefersColorScheme(
5696                ColorSchemePreference::Light
5697            )),
5698            "(prefers-color-scheme: light)"
5699        );
5700        assert_eq!(
5701            one(MediaFeature::PrefersReducedMotion(
5702                MotionPreference::NoPreference
5703            )),
5704            "(prefers-reduced-motion: no-preference)"
5705        );
5706        assert_eq!(
5707            one(MediaFeature::PrefersContrast(ContrastPreference::Less)),
5708            "(prefers-contrast: less)"
5709        );
5710        assert_eq!(
5711            one(MediaFeature::PrefersContrast(ContrastPreference::Custom)),
5712            "(prefers-contrast: custom)"
5713        );
5714        assert_eq!(
5715            one(MediaFeature::PrefersContrast(
5716                ContrastPreference::NoPreference
5717            )),
5718            "(prefers-contrast: no-preference)"
5719        );
5720    }
5721
5722    /// A featureless query matches everything, and `all` is how CSS says so.
5723    #[test]
5724    fn a_query_with_no_features_is_all() {
5725        let mq = MediaQuery { features: vec![] };
5726        assert!(mq.matches(&MediaContext::default()));
5727        assert_eq!(media_query_to_css(&mq), "all");
5728    }
5729}
5730
5731#[cfg(test)]
5732mod style_property_tests {
5733    use super::*;
5734
5735    /// The table answers for the cascade, so every name in it must reach a
5736    /// match arm. A rejected value is fine (the name was recognized); only
5737    /// `Ok(false)`, the unknown-property answer, means the two disagree.
5738    #[test]
5739    fn style_property_table_matches_the_cascade() {
5740        for &name in STYLE_PROPERTIES {
5741            let mut attrs = Attributes::default();
5742            let applied = apply_declaration("test", name, "1", &mut attrs);
5743            assert!(
5744                !matches!(applied, Ok(false)),
5745                "`{name}` is in STYLE_PROPERTIES but the cascade does not apply it"
5746            );
5747            assert!(
5748                is_style_property(name),
5749                "`{name}` is in STYLE_PROPERTIES but is_style_property says otherwise"
5750            );
5751        }
5752    }
5753
5754    #[test]
5755    fn standard_css_spellings_answer_for_their_lumen_slot() {
5756        for name in [
5757            "color",
5758            "background",
5759            "background-color",
5760            "border-radius",
5761            "flex-grow",
5762            "justify-content",
5763            "object-fit",
5764            "shrink",
5765            "white-space",
5766        ] {
5767            assert!(is_style_property(name), "`{name}` is a style property");
5768        }
5769    }
5770
5771    #[test]
5772    fn anything_the_cascade_ignores_is_not_a_style_property() {
5773        for name in ["--brand", "float", "content", "", "bg-color"] {
5774            assert!(
5775                !is_style_property(name),
5776                "`{name}` is not a property the cascade applies"
5777            );
5778        }
5779    }
5780}
5781
5782#[cfg(test)]
5783mod selector_text_tests {
5784    use super::*;
5785
5786    /// The names `lumen-html` hands in. They are spelled out again here so
5787    /// a change on either side shows up as a failing test on the other;
5788    /// the crate that owns them checks this copy against its constants.
5789    const WEB: WebNames<'static> = WebNames {
5790        tag_class_prefix: "lm-",
5791        selected: "data-lm-selected",
5792        checked: "data-lm-checked",
5793        disabled: "data-lm-disabled",
5794        drag_over: "data-lm-drag-over",
5795        checkbox_part: "checkbox-box",
5796        radio_part: "radio-dot",
5797    };
5798
5799    fn one(src: &str) -> SelectorBuf {
5800        parse_selector_list(src)
5801            .unwrap_or_else(|e| panic!("`{src}` does not parse: {e}"))
5802            .remove(0)
5803    }
5804
5805    fn web(src: &str) -> String {
5806        selector_to_web(&one(src), &WEB)
5807    }
5808
5809    /// The universal selector has no authored form the parser accepts on
5810    /// its own, so it is built rather than parsed.
5811    fn universal() -> SelectorBuf {
5812        SelectorBuf {
5813            chain: vec![(Combinator::Subject, CompoundSelector::default())],
5814        }
5815    }
5816
5817    #[test]
5818    fn the_universal_selector_is_a_star_in_both_spellings() {
5819        assert_eq!(selector_to_css(&universal()), "*");
5820        assert_eq!(selector_to_web(&universal(), &WEB), "*");
5821    }
5822
5823    #[test]
5824    fn lumen_spelling_round_trips_what_was_authored() {
5825        for src in [
5826            "button",
5827            "button#save.primary:hover",
5828            ".outer .inner > .x",
5829            ".a + .b ~ .c",
5830            ".row:not(.list > .row)",
5831            ":is(button, a):focus-visible",
5832        ] {
5833            assert_eq!(selector_to_css(&one(src)), src);
5834        }
5835    }
5836
5837    #[test]
5838    fn a_tag_becomes_its_class_at_no_specificity() {
5839        assert_eq!(web("button"), ":where(.lm-button)");
5840        assert_eq!(web("root"), ":where(.lm-root)");
5841        assert_eq!(web("row > label"), ":where(.lm-row) > :where(.lm-label)");
5842        // The wrapper is what keeps a tag rule under a class rule: the
5843        // tag counts as `c` here and would count as a class without it.
5844        assert_eq!(
5845            one("button").specificity(),
5846            Specificity { a: 0, b: 0, c: 1 }
5847        );
5848    }
5849
5850    #[test]
5851    fn classes_ids_and_structural_pseudos_are_left_alone() {
5852        assert_eq!(web("#save"), "#save");
5853        assert_eq!(web(".card:first-child"), ".card:first-child");
5854        assert_eq!(
5855            web("tile.card#main:hover"),
5856            ":where(.lm-tile)#main.card:hover"
5857        );
5858        for src in [":focus", ":focus-visible", ":active", ":empty", ":root"] {
5859            assert_eq!(web(&format!(".x{src}")), format!(".x{src}"));
5860        }
5861    }
5862
5863    #[test]
5864    fn the_states_the_browser_cannot_see_become_their_mirrors() {
5865        assert_eq!(web(".tab:selected"), ".tab[data-lm-selected]");
5866        assert_eq!(web(".drop:drag-over"), ".drop[data-lm-drag-over]");
5867        // A real checkbox is checked in the browser's own eyes; a
5868        // `<toggle>` is a button, and only the mirror says so.
5869        assert_eq!(web(".t:checked"), ".t:is(:checked, [data-lm-checked])");
5870        assert_eq!(web(".t:disabled"), ".t:is(:disabled, [data-lm-disabled])");
5871    }
5872
5873    #[test]
5874    fn a_checkbox_state_follows_the_element_it_is_on() {
5875        // The fill is the indicator's on the desktop, so it is the
5876        // control's here; the row is what the browser focuses through.
5877        assert_eq!(
5878            web("checkbox:checked"),
5879            ":where(.lm-checkbox) > .checkbox-box:is(:checked, [data-lm-checked])"
5880        );
5881        assert_eq!(
5882            web("radio:selected"),
5883            ":where(.lm-radio) > .radio-dot[data-lm-selected]"
5884        );
5885        assert_eq!(web("checkbox:focus"), ":where(.lm-checkbox):focus-within");
5886        assert_eq!(
5887            web("radio:focus-visible"),
5888            ":where(.lm-radio):has(:focus-visible)"
5889        );
5890        // What is left describes the row, and everything a rule says about
5891        // the row stays on it.
5892        assert_eq!(web("checkbox:hover"), ":where(.lm-checkbox):hover");
5893        assert_eq!(
5894            web("checkbox:disabled"),
5895            ":where(.lm-checkbox):is(:disabled, [data-lm-disabled])"
5896        );
5897        assert_eq!(
5898            web("checkbox.dense:hover:checked"),
5899            ":where(.lm-checkbox).dense:hover > .checkbox-box:is(:checked, [data-lm-checked])"
5900        );
5901        // A state written without one of those tags is untouched.
5902        assert_eq!(web(".t:checked"), ".t:is(:checked, [data-lm-checked])");
5903    }
5904
5905    #[test]
5906    fn a_redirected_state_keeps_the_rest_of_the_selector() {
5907        // The subject moves to the control, so what stands to the left of
5908        // it still has to reach the row.
5909        assert_eq!(
5910            web("column > checkbox:checked"),
5911            ":where(.lm-column) > :where(.lm-checkbox) > .checkbox-box:is(:checked, [data-lm-checked])"
5912        );
5913        assert_eq!(
5914            web(".panel radio:checked"),
5915            ".panel :where(.lm-radio) > .radio-dot:is(:checked, [data-lm-checked])"
5916        );
5917        // A row state written after the one that moves stays on the row,
5918        // whatever order the author wrote them in.
5919        assert_eq!(
5920            web("radio:selected:disabled"),
5921            ":where(.lm-radio):is(:disabled, [data-lm-disabled]) > .radio-dot[data-lm-selected]"
5922        );
5923        // Both states the indicator carries land on it together.
5924        assert_eq!(
5925            web("checkbox:checked:selected"),
5926            ":where(.lm-checkbox) > .checkbox-box:is(:checked, [data-lm-checked])[data-lm-selected]"
5927        );
5928    }
5929
5930    #[test]
5931    fn selector_list_arguments_are_rewritten_too() {
5932        assert_eq!(
5933            web(":is(button, a)"),
5934            ":is(:where(.lm-button), :where(.lm-a))"
5935        );
5936        assert_eq!(
5937            web(".row:not(.list > row:selected)"),
5938            ".row:not(.list > :where(.lm-row)[data-lm-selected])"
5939        );
5940        assert_eq!(
5941            web(":where(:not(label))"),
5942            ":where(:not(:where(.lm-label)))"
5943        );
5944    }
5945
5946    #[test]
5947    fn nth_child_is_written_the_way_css_reads_it() {
5948        assert_eq!(web(":nth-child(2n+1)"), ":nth-child(2n+1)");
5949        assert_eq!(web(":nth-child(odd)"), ":nth-child(2n+1)");
5950        assert_eq!(web(":nth-child(3)"), ":nth-child(3)");
5951        // `2n+-1` is not something a browser reads.
5952        assert_eq!(web(":nth-child(2n-1)"), ":nth-child(2n-1)");
5953        assert_eq!(
5954            selector_to_css(&one(":nth-child(2n-1)")),
5955            ":nth-child(2n-1)"
5956        );
5957    }
5958}