Skip to main content

velox_style/
lib.rs

1//! Velox Style - CSS Styling System
2//!
3//! This crate provides comprehensive CSS support for Velox:
4//! - CSS property parsing and computed styles
5//! - Unit handling (px, %, rem, em, vw, vh)
6//! - Color parsing (hex, rgb, rgba, named colors)
7//! - Flexbox layout properties
8//! - Style inheritance and cascading
9
10pub mod fonts;
11pub mod ua;
12pub mod visual_effects;
13
14// Re-export types from velox-dom
15pub use velox_dom::style::*;
16// Re-export the font types. (`FontMetrics` used to be re-exported here and collided
17// with `velox_dom::style`; it had no users, so it is deleted rather than aliased.)
18pub use fonts::{FontDescriptor, FontFamily, FontStyle, GenericFamily, LineHeight};
19// Avoid collision with velox_dom::style::BoxShadow by aliasing visual effects type.
20pub use visual_effects::{BorderRadius, BoxShadow as VisualBoxShadow, TextShadow};
21
22use cssparser::ToCss;
23use std::collections::HashMap;
24use velox_dom::{Props, VNode};
25
26// --- CSS Parser types (module-level for rust-analyzer compatibility) ---
27
28/// How a selector part is connected to the part on its left in the source
29/// selector (e.g. the `>` between `div` and `.card` in `div > .card`).
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
31pub enum Combinator {
32    /// No combinator precedes this part — it is the leftmost part of the chain.
33    #[default]
34    None,
35    /// Descendant combinator (whitespace): this part may match any ancestor of
36    /// the part on its right.
37    Descendant,
38    /// Child combinator (`>`): this part must match the element exactly one
39    /// level above the part on its right.
40    Child,
41}
42
43/// Attribute the cascade writes `::placeholder` declarations onto.
44///
45/// A pseudo-element needs its own attribute because the element's own `style`
46/// is the value-text style. `velox-renderer` reads this one only when the
47/// field is empty, so the two can never be confused.
48pub const PLACEHOLDER_STYLE_ATTR: &str = "style:placeholder";
49
50/// Whether a `::placeholder` selector's target is a form control that is
51/// currently SHOWING its placeholder.
52///
53/// Three conditions, all of them load-bearing:
54/// * it is an `<input>` — `textarea::placeholder` is not supported, because the
55///   painter has no placeholder pass for the multi-line control and claiming it
56///   would be a false parity claim;
57/// * it carries a non-empty `placeholder`;
58/// * its `value` is empty or absent — a filled field shows the value, not the
59///   placeholder, so a `::placeholder` rule must not win there.
60fn shows_placeholder(tag: &str, props: &Props) -> bool {
61    tag == "input"
62        && props
63            .attrs
64            .get("placeholder")
65            .is_some_and(|p| !p.trim().is_empty())
66        && props.attrs.get("value").is_none_or(|v| v.is_empty())
67}
68
69/// A single part of a CSS selector (e.g., `h1`, `.class`, `h1.class`, `[attr]`).
70#[derive(Debug, Clone, PartialEq)]
71pub struct SelectorPart {
72    pub tag: String,
73    pub class: String,
74    pub hover: bool,
75    /// This part targets the `::placeholder` pseudo-element of a form control,
76    /// i.e. the selector ended in `::placeholder`.
77    ///
78    /// Why a flag and not a general pseudo-element enum: `::placeholder` is
79    /// the ONLY pseudo-element Velox has any notion of, because an `<input>`
80    /// has no child node for a general pseudo-element to style — the
81    /// placeholder is not a child, it is a string the painter draws when the
82    /// value is empty. A general enum would advertise support that does not
83    /// exist.
84    pub placeholder: bool,
85    pub attr_name: String,
86    pub attr_value: Option<String>,
87    /// Combinator that connected this part to the part on its left in the
88    /// source selector. `Combinator::None` for the leftmost part.
89    pub combinator: Combinator,
90}
91
92impl SelectorPart {
93    fn matches_element(&self, tag: &str, props: &Props, hovered: bool) -> bool {
94        if self.hover && !hovered {
95            return false;
96        }
97        // `::placeholder` matches only where a placeholder is actually
98        // VISIBLE. Before this flag existed the selector was silently
99        // truncated to `input` (see `parse_selector_part`), so
100        // `input::placeholder { color: red }` matched every input and painted
101        // the VALUE red — the opposite of what the author wrote, and the more
102        // damaging of the two failure modes because it looks intentional.
103        if self.placeholder && !shows_placeholder(tag, props) {
104            return false;
105        }
106        let tag_ok = self.tag.is_empty() || self.tag == "*" || self.tag == tag;
107        let class_ok = if self.class.is_empty() {
108            true
109        } else if let Some(classes) = props.attrs.get("class") {
110            classes.split_whitespace().any(|x| x == self.class)
111        } else {
112            false
113        };
114        let attr_ok = if self.attr_name.is_empty() {
115            true
116        } else if let Some(val) = props.attrs.get(&self.attr_name) {
117            if let Some(expected) = &self.attr_value {
118                val == expected
119            } else {
120                true
121            }
122        } else {
123            false
124        };
125        tag_ok && class_ok && attr_ok
126    }
127}
128
129/// A compound CSS selector — a chain of `SelectorPart`s connected by
130/// combinators (whitespace for descendant, `>` for child). The rightmost part
131/// matches the target element; preceding parts must match ancestors according
132/// to the combinator stored on each part.
133///
134/// Examples: `.header h1` → `[SelectorPart(class="header", None), SelectorPart(tag="h1", Descendant)]`,
135/// `div > .card` → `[SelectorPart(tag="div", None), SelectorPart(class="card", Child)]`
136#[derive(Debug, Clone, PartialEq)]
137pub struct CompoundSelector {
138    pub parts: Vec<SelectorPart>,
139}
140
141impl CompoundSelector {
142    fn new(parts: Vec<SelectorPart>) -> Self {
143        Self { parts }
144    }
145
146    /// Whether any part of this selector names the `::placeholder`
147    /// pseudo-element (e.g. `input::placeholder`, `.field::placeholder`).
148    fn has_placeholder(&self) -> bool {
149        self.parts.iter().any(|p| p.placeholder)
150    }
151
152    /// Whether this selector's `::placeholder` part is the selector's SUBJECT:
153    /// its only `::placeholder` part, and the rightmost one.
154    ///
155    /// `::placeholder` is a pseudo-ELEMENT, and a pseudo-element is not a node.
156    /// It is the string the painter draws inside an empty control, so it has no
157    /// box of its own and therefore cannot be an ANCESTOR of anything. CSS lets
158    /// it appear as the subject only, so `.wrap input::placeholder` is real CSS
159    /// (the placeholder of an input inside `.wrap`) and stays accepted, while
160    /// `.field::placeholder .row` is not: its subject is `.row` and the
161    /// placeholder is a link in the chain, which no node can satisfy.
162    ///
163    /// The distinction matters because the cascade writes a placeholder rule's
164    /// declarations to a DIFFERENT attribute from the element's own (see
165    /// `PLACEHOLDER_STYLE_ATTR`). A rule whose placeholder part is not the
166    /// subject matched an ordinary element and would land in that element's own
167    /// `style`, repainting it — the opposite of what the author wrote, and
168    /// silently so, because the rule looks like it was honoured.
169    fn placeholder_is_subject(&self) -> bool {
170        self.parts.last().is_some_and(|p| p.placeholder)
171            && self.parts.iter().filter(|p| p.placeholder).count() == 1
172    }
173}
174
175#[derive(Debug, Clone, PartialEq)]
176pub struct Rule {
177    pub selector: CompoundSelector,
178    pub decls: HashMap<String, String>,
179}
180
181struct SheetParser {
182    rules: Vec<Rule>,
183}
184
185impl<'i> cssparser::QualifiedRuleParser<'i> for &mut SheetParser {
186    type Prelude = String;
187    type QualifiedRule = ();
188    type Error = ();
189
190    fn parse_prelude<'t>(
191        &mut self,
192        input: &mut cssparser::Parser<'i, 't>,
193    ) -> Result<String, cssparser::ParseError<'i, ()>> {
194        // Use slice_from to preserve attribute selectors and complex selectors exactly.
195        // `to_css` for SquareBracketBlock loses inner content (emits ".btn[ ").
196        let start = input.position();
197        while let Ok(token) = input.next_including_whitespace() {
198            let _ = token;
199        }
200        Ok(input.slice_from(start).trim().to_string())
201    }
202
203    fn parse_block<'t>(
204        &mut self,
205        prelude: String,
206        _start: &cssparser::ParserState,
207        input: &mut cssparser::Parser<'i, 't>,
208    ) -> Result<(), cssparser::ParseError<'i, ()>> {
209        let mut decls = HashMap::new();
210        for (name, value) in
211            cssparser::DeclarationListParser::new(input, DeclarationParser).flatten()
212        {
213            if !name.is_empty() {
214                decls.insert(name, value);
215            }
216        }
217        if decls.is_empty() {
218            return Ok(());
219        }
220        for selector in parse_selector_list(&prelude) {
221            self.rules.push(Rule {
222                selector,
223                decls: decls.clone(),
224            });
225        }
226        Ok(())
227    }
228}
229
230impl<'i> cssparser::AtRuleParser<'i> for &mut SheetParser {
231    type Prelude = String;
232    type AtRule = ();
233    type Error = ();
234
235    fn parse_prelude<'t>(
236        &mut self,
237        name: cssparser::CowRcStr<'i>,
238        input: &mut cssparser::Parser<'i, 't>,
239    ) -> Result<String, cssparser::ParseError<'i, ()>> {
240        let mut prelude = String::new();
241        // Same nested-block handling as a declaration value: a media query's
242        // condition list is parenthesised (`@media (min-width: 700px)`), so the
243        // old bare-token loop truncated every at-rule prelude at its first `(`.
244        // The only consumer of this string is the `starts_with` dispatch in
245        // `parse_block`, whose `@keyframes`/`@font-face` prefixes are unaffected
246        // by a more complete prelude.
247        write_component_values(input, &mut prelude)?;
248        Ok(format!("@{} {}", name, prelude.trim()))
249    }
250
251    fn parse_block<'t>(
252        &mut self,
253        prelude: String,
254        _start: &cssparser::ParserState,
255        input: &mut cssparser::Parser<'i, 't>,
256    ) -> Result<(), cssparser::ParseError<'i, ()>> {
257        // Handle @media / @supports by parsing nested rules recursively.
258        // @keyframes and other at-rules are ignored (inner selectors are not style rules).
259        let prelude_trim = prelude.trim().to_lowercase();
260        if prelude_trim.starts_with("@keyframes")
261            || prelude_trim.starts_with("@-webkit-keyframes")
262            || prelude_trim.starts_with("@-moz-keyframes")
263            || prelude_trim.starts_with("@font-face")
264        {
265            return Ok(());
266        }
267        // For @media / @supports / other, parse inner qualified rules as flattened rules.
268        {
269            use cssparser::RuleListParser;
270            let mut tmp = SheetParser { rules: Vec::new() };
271            {
272                let mut rule_list = RuleListParser::new_for_nested_rule(input, &mut tmp);
273                for rule in &mut rule_list {
274                    let _ = rule;
275                }
276            }
277            self.rules.extend(tmp.rules);
278        }
279        Ok(())
280    }
281
282    fn rule_without_block(
283        &mut self,
284        prelude: String,
285        _start: &cssparser::ParserState,
286    ) -> Result<(), ()> {
287        let _ = prelude;
288        Err(())
289    }
290}
291
292/// Append every component value remaining in `input` to `dest`.
293///
294/// This is the CSS "consume a component value list" loop, and the one thing a
295/// declaration parser cannot do without: the CONTENTS of a function or block
296/// live in a nested block that the outer token stream does not reach. Per
297/// cssparser's own docs on `Parser::next` (cssparser-0.28.1/src/parser.rs:546)
298/// the call after a `Function`/`ParenthesisBlock`/`SquareBracketBlock`/
299/// `CurlyBracketBlock` token "will skip until after the matching
300/// `CloseParenthesis`…" — so a plain `while let Ok(token) = input.next()`
301/// loses everything between the brackets.
302///
303/// That is exactly the defect this function exists to remove. The old loop
304/// ended on the `Function` token having written only its opening `(` (see
305/// `ToCss for Token`, cssparser-0.28.1/src/serializer.rs:135-141, which writes
306/// `name(` and NOT the closing delimiter), so every function value was
307/// truncated: `background: rgba(20, 24, 27, 0.34)` became `background:
308/// rgba(`, `width: calc(100% - 8px)` became `width: calc(`, and
309/// `color: var(--brand)` became `color: var(`.
310///
311/// So for every block-opening token we emit the arguments ourselves via
312/// `parse_nested_block`, then write the matching closing delimiter — once.
313/// Whitespace tokens are passed through as-is, so author spacing survives
314/// verbatim (`rgba(calc(1 + 2), 0, 0, 1)` round-trips unchanged).
315///
316/// A `parse_nested_block` error cannot discard what was already written: the
317/// value stays best-effort rather than failing the whole declaration, which
318/// is what this parser has always done for a value it cannot model.
319fn write_component_values<'i, 't>(
320    input: &mut cssparser::Parser<'i, 't>,
321    dest: &mut String,
322) -> Result<(), cssparser::ParseError<'i, ()>> {
323    while let Ok(token) = input.next_including_whitespace() {
324        let closing = match *token {
325            cssparser::Token::Function(_) => Some(')'),
326            cssparser::Token::ParenthesisBlock => Some(')'),
327            cssparser::Token::SquareBracketBlock => Some(']'),
328            cssparser::Token::CurlyBracketBlock => Some('}'),
329            _ => None,
330        };
331        // The borrow of `token` must end here: `parse_nested_block` needs
332        // `&mut input`, and the token is borrowed from it.
333        let _ = token.to_css(dest);
334        let Some(closing) = closing else {
335            continue;
336        };
337        // `parse_nested_block` takes `parser.at_start_of` and `.expect()`s that
338        // it is `Some` (cssparser-0.28.1/src/parser.rs:1023). It is, because
339        // `next_including_whitespace` set it when it returned this opening
340        // token (cssparser-0.28.1/src/parser.rs:619-621) and we have touched
341        // nothing in between.
342        let _ = input.parse_nested_block(|input| write_component_values(input, dest));
343        dest.push(closing);
344    }
345    Ok(())
346}
347
348struct DeclarationParser;
349
350impl<'i> cssparser::DeclarationParser<'i> for DeclarationParser {
351    type Declaration = (String, String);
352    type Error = ();
353
354    fn parse_value<'t>(
355        &mut self,
356        name: cssparser::CowRcStr<'i>,
357        input: &mut cssparser::Parser<'i, 't>,
358    ) -> Result<(String, String), cssparser::ParseError<'i, ()>> {
359        let mut value = String::new();
360        write_component_values(input, &mut value)?;
361        Ok((name.to_string(), value.trim().to_string()))
362    }
363}
364
365impl<'i> cssparser::AtRuleParser<'i> for DeclarationParser {
366    type Prelude = ();
367    type AtRule = (String, String);
368    type Error = ();
369}
370
371/// Parse a single selector part (tag, .class, tag.class, [attr], [attr="val"], etc.) with optional :hover.
372/// Attribute selectors: `[attr]`, `[attr="value"]`, `[attr='value']` appended to tag/class (e.g. `.btn[data-v-abc]`).
373/// Split a selector part's trailing pseudos off its name.
374///
375/// Returns `(name, hover, placeholder)`. Handles the single-colon form
376/// (`:hover`) and the DOUBLE-colon pseudo-element form (`::placeholder`)
377/// separately, which the previous `split_once(':')` could not: for
378/// `input::placeholder` it produced `pseudo == ":placeholder"`, which matched
379/// neither `"hover"` nor anything else, so the pseudo was dropped and the rule
380/// silently became a plain `input` rule.
381///
382/// An unknown pseudo is NOT an error and does not reject the selector — the
383/// previous behaviour for `:focus`, `::before` and friends was to ignore them,
384/// and silently dropping an unknown pseudo is strictly better than discarding
385/// the rule an author wrote around it. What changed is only that the one
386/// pseudo-element Velox actually implements is now seen.
387///
388/// Returns the base name borrowed when there is no pseudo to strip, so the
389/// common no-pseudo selector parses without an allocation here (the owner
390/// materialises it with `into_owned` only if it keeps the part).
391fn split_pseudos(base_trimmed: &str) -> (std::borrow::Cow<'_, str>, bool, bool) {
392    use std::borrow::Cow;
393    let Some(colon) = base_trimmed.find(':') else {
394        return (Cow::Borrowed(base_trimmed), false, false);
395    };
396    let name = base_trimmed[..colon].trim().to_string();
397    // Everything after the first colon, then peel one leading colon per
398    // pseudo: `::placeholder` -> `:placeholder` -> `placeholder`.
399    let mut rest = base_trimmed[colon + 1..].trim_start_matches(':');
400    let mut hover = false;
401    let mut placeholder = false;
402    // Only two pseudos exist, so one pass over the `:`-separated tail is
403    // enough; the loop only exists so `::placeholder:hover` (harmless) does
404    // not lose either flag.
405    while !rest.is_empty() {
406        let (pseudo, tail) = match rest.split_once(':') {
407            Some((p, t)) => (p, Some(t)),
408            None => (rest, None),
409        };
410        match pseudo.trim().to_ascii_lowercase().as_str() {
411            "hover" => hover = true,
412            "placeholder" => placeholder = true,
413            _ => {}
414        }
415        match tail {
416            Some(t) => rest = t.trim_start_matches(':'),
417            None => break,
418        }
419    }
420    (Cow::Owned(name), hover, placeholder)
421}
422
423fn parse_selector_part(raw: &str) -> Option<SelectorPart> {
424    // Robustly extract attribute selector: find '[' and matching ']' (first ']' after '[')
425    // Allows trailing pseudo like `.btn[data-v-x]:hover`.
426    let (base_raw, attr_raw): (String, Option<String>) = if let Some(lb) = raw.find('[') {
427        if let Some(rb_rel) = raw[lb..].find(']') {
428            let rb = lb + rb_rel;
429            let attr_inner = raw[lb + 1..rb].to_string();
430            let before = &raw[..lb];
431            let after = &raw[rb + 1..];
432            // base without attr is before + after (after may contain :hover)
433            let base = format!("{}{}", before, after);
434            (base, Some(attr_inner))
435        } else {
436            return None;
437        }
438    } else {
439        (raw.to_string(), None)
440    };
441
442    let (attr_name, attr_value) = if let Some(inner) = attr_raw {
443        let inner = inner.trim().to_string();
444        if inner.is_empty() {
445            return None;
446        }
447        if let Some(eq) = inner.find('=') {
448            let name = inner[..eq].trim().to_string();
449            if name.is_empty() {
450                return None;
451            }
452            let mut val = inner[eq + 1..].trim().to_string();
453            // Strip surrounding quotes
454            if (val.starts_with('"') && val.ends_with('"') && val.len() >= 2)
455                || (val.starts_with('\'') && val.ends_with('\'') && val.len() >= 2)
456            {
457                val = val[1..val.len() - 1].to_string();
458            }
459            (name, Some(val))
460        } else {
461            (inner, None)
462        }
463    } else {
464        (String::new(), None)
465    };
466
467    // Handle base being empty (pure attribute selector like [data-v-x])
468    let base_trimmed = base_raw.trim();
469    if base_trimmed.is_empty() {
470        if attr_name.is_empty() {
471            return None;
472        }
473        // Pure attribute selector may have hover pseudo in after part already removed? but if base empty, check hover not applicable
474        // However `[data-v-x]:hover` would have base ":hover" not empty - handled below
475        if base_trimmed.is_empty() {
476            return Some(SelectorPart {
477                tag: String::new(),
478                class: String::new(),
479                hover: false,
480                placeholder: false,
481                attr_name,
482                attr_value,
483                combinator: Combinator::None,
484            });
485        }
486    }
487
488    let (name_raw, hover, placeholder) = split_pseudos(base_trimmed);
489    // Allow `*` universal selector: treat like empty tag (matches any tag)
490    if name_raw.as_ref() == "*" {
491        return Some(SelectorPart {
492            tag: "*".to_string(),
493            class: String::new(),
494            hover,
495            placeholder,
496            attr_name,
497            attr_value,
498            combinator: Combinator::None,
499        });
500    }
501    if name_raw.is_empty() {
502        if attr_name.is_empty() {
503            return None;
504        }
505        // e.g. `[data-v-x]:hover` => name_raw is "" but attr present, hover true
506        return Some(SelectorPart {
507            tag: String::new(),
508            class: String::new(),
509            hover,
510            placeholder,
511            attr_name,
512            attr_value,
513            combinator: Combinator::None,
514        });
515    }
516    if let Some(rest) = name_raw.strip_prefix('.') {
517        let class = rest.trim();
518        if class.is_empty() {
519            return None;
520        }
521        Some(SelectorPart {
522            tag: String::new(),
523            class: class.to_string(),
524            hover,
525            placeholder,
526            attr_name,
527            attr_value,
528            combinator: Combinator::None,
529        })
530    } else if let Some((tag, class)) = name_raw.split_once('.') {
531        let tag = tag.trim();
532        let class = class.trim();
533        if tag.is_empty() || class.is_empty() {
534            return None;
535        }
536        Some(SelectorPart {
537            tag: tag.to_string(),
538            class: class.to_string(),
539            hover,
540            placeholder,
541            attr_name,
542            attr_value,
543            combinator: Combinator::None,
544        })
545    } else {
546        Some(SelectorPart {
547            tag: name_raw.into_owned(),
548            class: String::new(),
549            hover,
550            placeholder,
551            attr_name,
552            attr_value,
553            combinator: Combinator::None,
554        })
555    }
556}
557
558/// Re-space `>` so the whitespace tokenizer sees it as a standalone token,
559/// which makes `div>.card` behave like `div > .card`. A `>` inside an
560/// attribute selector (e.g. `[data-x="a>b"]`) is left untouched.
561fn pad_child_combinators(raw: &str) -> String {
562    let mut out = String::with_capacity(raw.len() + 4);
563    let mut bracket_depth = 0usize;
564    let mut quote: Option<char> = None;
565    for ch in raw.chars() {
566        if let Some(q) = quote {
567            out.push(ch);
568            if ch == q {
569                quote = None;
570            }
571            continue;
572        }
573        match ch {
574            '[' => {
575                bracket_depth += 1;
576                out.push(ch);
577            }
578            ']' => {
579                bracket_depth = bracket_depth.saturating_sub(1);
580                out.push(ch);
581            }
582            '"' | '\'' if bracket_depth > 0 => {
583                quote = Some(ch);
584                out.push(ch);
585            }
586            '>' if bracket_depth == 0 => out.push_str(" > "),
587            _ => out.push(ch),
588        }
589    }
590    out
591}
592
593fn parse_selector_list(selector: &str) -> Vec<CompoundSelector> {
594    let mut out = Vec::new();
595    for part in selector.split(',') {
596        let raw = part.trim();
597        if raw.is_empty() {
598            continue;
599        }
600        // Tokenize on whitespace. A `>` token (padded so `div>.card` also
601        // works) sets the combinator of the compound that follows it instead
602        // of being silently dropped; plain whitespace between compounds is a
603        // descendant combinator.
604        let mut parts: Vec<SelectorPart> = Vec::new();
605        let mut pending: Option<Combinator> = None;
606        let mut valid = true;
607        for token in pad_child_combinators(raw).split_whitespace() {
608            if token == ">" {
609                if parts.is_empty() {
610                    // Leading `>` (e.g. `> .card`): there is no left compound
611                    // to be the child of — invalid selector, drop the rule.
612                    valid = false;
613                    break;
614                }
615                pending = Some(Combinator::Child);
616                continue;
617            }
618            match parse_selector_part(token) {
619                Some(mut sp) => {
620                    sp.combinator = pending.take().unwrap_or(if parts.is_empty() {
621                        Combinator::None
622                    } else {
623                        Combinator::Descendant
624                    });
625                    parts.push(sp);
626                }
627                None => {
628                    valid = false;
629                    break;
630                }
631            }
632        }
633        // A trailing combinator (`div >`) is ignored: the compounds parsed so
634        // far still form a usable selector (previous behavior).
635        if valid && !parts.is_empty() {
636            out.push(CompoundSelector::new(parts));
637        }
638    }
639    out
640}
641
642// --- Stylesheet ---
643
644#[derive(Debug, Default, Clone, PartialEq)]
645pub struct Stylesheet {
646    pub rules: Vec<Rule>,
647}
648
649impl Stylesheet {
650    pub fn parse(css: &str) -> Self {
651        use cssparser::{Parser, ParserInput, RuleListParser};
652
653        let mut input = ParserInput::new(css);
654        let mut parser = Parser::new(&mut input);
655        let mut sheet_parser = SheetParser { rules: Vec::new() };
656        let mut rule_list = RuleListParser::new_for_stylesheet(&mut parser, &mut sheet_parser);
657        for rule in &mut rule_list {
658            let _ = rule;
659        }
660
661        Stylesheet {
662            rules: sheet_parser.rules,
663        }
664    }
665}
666
667/// Match a compound selector against a VNode element, walking ancestors as needed.
668///
669/// For a single-part selector (e.g., `.app`, `button`), only the target element
670/// is checked. For a multi-part selector (e.g., `.header h1`, `div > .card`),
671/// the rightmost part matches the target and preceding parts must match
672/// ancestor VNodes: a `Descendant` combinator may match any ancestor, while a
673/// `Child` (`>`) combinator must match exactly one level above the part on
674/// its right. `ancestors[0]` is the target's parent, `ancestors[1]` the
675/// grandparent, and so on.
676fn matches_selector(
677    sel: &CompoundSelector,
678    tag: &str,
679    props: &Props,
680    hovered: bool,
681    ancestors: &[&VNode],
682) -> bool {
683    let Some((last, prefix)) = sel.parts.split_last() else {
684        return false;
685    };
686    if !last.matches_element(tag, props, hovered) {
687        return false;
688    }
689    // The last part matched the target element itself — "position -1" in the
690    // ancestor chain. Match the remaining chain, constrained by the
691    // combinator that connected the last part to the part on its left.
692    match_prefix(prefix, ancestors, -1, last.combinator)
693}
694
695/// Whether every part in `prefix` (the chain left of an already-matched part)
696/// can match the ancestor chain.
697///
698/// `below` is the ancestor position of the element matched by the part just
699/// right of `prefix`'s last part (the target element itself sits at position
700/// -1). `comb` is the combinator that connected that rightward part to
701/// `prefix`'s last part: `Child` pins it exactly one level above, while
702/// `Descendant` (and a leftmost `None`) allow any strictly-higher ancestor.
703fn match_prefix(
704    prefix: &[SelectorPart],
705    ancestors: &[&VNode],
706    below: isize,
707    comb: Combinator,
708) -> bool {
709    let Some((last, rest)) = prefix.split_last() else {
710        return true;
711    };
712    let candidates: Box<dyn Iterator<Item = usize> + '_> = match comb {
713        Combinator::Child => {
714            let pos = below + 1;
715            if pos < 0 {
716                return false;
717            }
718            Box::new(pos as usize..pos as usize + 1)
719        }
720        _ => Box::new((below + 1).max(0) as usize..ancestors.len()),
721    };
722    for pos in candidates {
723        if let VNode::Element {
724            tag: a_tag,
725            props: a_props,
726            ..
727        } = ancestors[pos]
728            && last.matches_element(a_tag, a_props, false)
729            && match_prefix(rest, ancestors, pos as isize, last.combinator)
730        {
731            return true;
732        }
733    }
734    false
735}
736
737fn merge_styles(existing: Option<&str>, new_map: &HashMap<String, String>) -> String {
738    let mut map: HashMap<String, String> = HashMap::new();
739    // Apply stylesheet styles first (lower precedence)
740    for (k, v) in new_map {
741        map.insert(k.clone(), v.clone());
742    }
743    // Apply inline styles second (highest precedence - they override stylesheet)
744    if let Some(s) = existing {
745        for decl in s.split(';') {
746            let decl = decl.trim();
747            if decl.is_empty() {
748                continue;
749            }
750            if let Some((k, v)) = decl.split_once(':') {
751                map.insert(k.trim().to_string(), v.trim().to_string());
752            }
753        }
754    }
755    // Borrow the keys for ordering: the merged string is built from `&str`
756    // lookups, so no key or value string is cloned just to be sorted.
757    let mut keys: Vec<&String> = map.keys().collect();
758    keys.sort();
759    let mut out = String::new();
760    for (i, k) in keys.iter().enumerate() {
761        if i > 0 {
762            out.push(' ');
763        }
764        out.push_str(k);
765        out.push_str(": ");
766        out.push_str(&map[*k]);
767        out.push(';');
768    }
769    out
770}
771
772/// Apply stylesheet to a VNode recursively, returning a new VNode
773/// with inline `style` attributes populated.
774pub fn apply_styles(node: &VNode, sheet: &Stylesheet) -> VNode {
775    apply_styles_with_hover(node, sheet, &|_, _| false)
776}
777
778/// Apply the 3-layer cascade UA < author < inline.
779///
780/// Borrows the user-agent sheet (`ua::ua_sheet()`, a `OnceLock` singleton) and
781/// `author` as two ordered layers instead of cloning both rule vecs into one
782/// merged sheet per call. The cascade runs on every frame, so the old merge
783/// allocated two full rule vecs plus every declaration string on each pass.
784pub fn apply_with_cascade(node: &VNode, author: &Stylesheet) -> VNode {
785    apply_with_cascade_with_hover(node, author, &|_, _| false)
786}
787
788/// Cascade with a custom hover predicate.
789pub fn apply_with_cascade_with_hover<F>(node: &VNode, author: &Stylesheet, is_hovered: &F) -> VNode
790where
791    F: Fn(&str, &Props) -> bool,
792{
793    let ua = crate::ua::ua_sheet();
794    apply_with_sheets(node, &[ua, author], is_hovered)
795}
796
797/// Properties that inherit to descendants, per browser CSS behavior.
798/// The original filter carried `color`, `font-size`, `font-weight`,
799/// `text-decoration`, `line-height`; this set only ADDS properties
800/// (font-family, font-style, letter-spacing, text-align, vertical-align,
801/// visibility, cursor) — `text-decoration` is retained to avoid regressing
802/// behavior.
803///
804/// Pinned deliberately by this list's ABSENCE: `clip-path` (Masking 1 §3.1)
805/// and the renderer's `img-filter` (an image filter, not the CSS `filter`
806/// property, which is Filter Effects 1 §2.1) are both non-inherited in real
807/// CSS, so each element reads its own declaration and a descendant of a
808/// clipped box is not clipped unless it says so.
809const INHERITABLE: &[&str] = &[
810    "color",
811    "font-size",
812    "font-family",
813    "font-weight",
814    "font-style",
815    "line-height",
816    "letter-spacing",
817    "text-align",
818    // `vertical-align` is an inherited property (CSS 2.1 §10.8.1): it aligns an
819    // inline-level box within its line box, and the line box belongs to the
820    // parent block, so a value set on an ancestor is the one that applies.
821    "vertical-align",
822    // `white-space` is an inherited property (CSS 2.1 §16.6): it describes how
823    // a box's inline content is wrapped, and a nested element's own content is
824    // wrapped by the same rules as the text that flows into it. Without this,
825    // a `white-space: pre` on an ancestor is dropped at the first element
826    // boundary between the ancestor and its nested text.
827    "white-space",
828    "visibility",
829    "cursor",
830    "text-decoration",
831];
832
833/// Apply stylesheet with a custom hover predicate
834pub fn apply_styles_with_hover<F>(node: &VNode, sheet: &Stylesheet, is_hovered: &F) -> VNode
835where
836    F: Fn(&str, &Props) -> bool,
837{
838    apply_with_sheets(node, &[sheet], is_hovered)
839}
840
841/// Cascade core: style `node` against `sheets` in layer order (earlier sheets
842/// lose to later ones, inline `style` wins over everything).
843///
844/// Sheets are only borrowed: matching reads `&rule.decls` straight out of each
845/// layer, so no merged rule vec is ever built.
846fn apply_with_sheets<F>(node: &VNode, sheets: &[&Stylesheet], is_hovered: &F) -> VNode
847where
848    F: Fn(&str, &Props) -> bool,
849{
850    /// Overlay the inheritable declarations of a `style` attribute onto `map`.
851    ///
852    /// This is the threaded-accumulator half of the cascade: instead of
853    /// serialising the element's resolved style to a string and re-parsing it
854    /// for the inheritable subset (a serialize→parse round-trip per element),
855    /// the children inherit straight from the accumulator this element already
856    /// built, plus the inline declarations that override it — the same sources
857    /// `merge_styles` combined, so the result is identical.
858    fn overlay_inheritable(map: &mut HashMap<String, String>, style: &str) {
859        for decl in style.split(';') {
860            let d = decl.trim();
861            if d.is_empty() {
862                continue;
863            }
864            if let Some((k, v)) = d.split_once(':') {
865                let k = k.trim();
866                let v = v.trim();
867                if INHERITABLE.contains(&k) {
868                    map.insert(k.to_string(), v.to_string());
869                }
870            }
871        }
872    }
873
874    fn apply_rec<FN>(
875        node: &VNode,
876        sheets: &[&Stylesheet],
877        is_hovered: &FN,
878        inherited: &HashMap<String, String>,
879        ancestors: &[&VNode],
880    ) -> VNode
881    where
882        FN: Fn(&str, &Props) -> bool,
883    {
884        match node {
885            VNode::Text(_) => node.clone(),
886            VNode::Element {
887                tag,
888                props,
889                children,
890            } => {
891                let hovered = is_hovered(tag, props);
892                let mut acc: HashMap<String, String> = inherited.clone();
893                // Declarations from `::placeholder` rules, kept APART from the
894                // element's own. They cannot go into `style`: an `<input>`'s
895                // `style` is what the painter reads the VALUE's colour and
896                // geometry from, so `input::placeholder { color: red }` landing
897                // there would paint the value red. They are not inherited
898                // either — a placeholder never inherits from its own element's
899                // `color` through this path, because the element's colour is
900                // exactly what a placeholder is dimmed FROM.
901                let mut pseudo_acc: HashMap<String, String> = HashMap::new();
902                // Match all selectors against this element, passing the ancestor chain
903                // so compound selectors (e.g., `.header h1`) can walk up the tree.
904                // Layers apply in order so a later sheet wins over an earlier one.
905                for sheet in sheets {
906                    for rule in &sheet.rules {
907                        if matches_selector(&rule.selector, tag, props, hovered, ancestors) {
908                            let target = if rule.selector.has_placeholder() {
909                                // A selector that names `::placeholder` anywhere but
910                                // on its own subject matches no element in real CSS,
911                                // so its declarations must reach NOBODY. Keying off
912                                // the last part alone is what made `.a::placeholder
913                                // .b` repaint `.b`: the placeholder part sat in the
914                                // chain, `.b` was the subject, the last part had no
915                                // placeholder flag, and the declarations fell into
916                                // `.b`'s own `style`.
917                                if !rule.selector.placeholder_is_subject() {
918                                    continue;
919                                }
920                                &mut pseudo_acc
921                            } else {
922                                &mut acc
923                            };
924                            for (k, v) in &rule.decls {
925                                target.insert(k.clone(), v.clone());
926                            }
927                        }
928                    }
929                }
930                let inline_style: Option<&str> = props.attrs.get("style").map(String::as_str);
931                let mut new_props = props.clone();
932                let merged = merge_styles(inline_style, &acc);
933                if !merged.is_empty() {
934                    new_props = new_props.set("style", merged);
935                }
936                if !pseudo_acc.is_empty() {
937                    let merged_pseudo = merge_styles(
938                        new_props
939                            .attrs
940                            .get(PLACEHOLDER_STYLE_ATTR)
941                            .map(String::as_str),
942                        &pseudo_acc,
943                    );
944                    new_props = new_props.set(PLACEHOLDER_STYLE_ATTR, merged_pseudo);
945                }
946                // Thread the accumulator into the children's inherited map
947                // without re-parsing the serialised `style` string: the
948                // inheritable subset of the merged style is exactly the
949                // inheritable subset of `acc` overlaid with the inheritable
950                // subset of the inline declarations (which win).
951                let mut inherit_next: HashMap<String, String> = HashMap::with_capacity(acc.len());
952                for (k, v) in &acc {
953                    if INHERITABLE.contains(&k.as_str()) {
954                        inherit_next.insert(k.clone(), v.clone());
955                    }
956                }
957                if let Some(inline) = inline_style {
958                    overlay_inheritable(&mut inherit_next, inline);
959                }
960                // Build child ancestors: this element + current ancestors
961                let mut child_ancestors: Vec<&VNode> = Vec::with_capacity(ancestors.len() + 1);
962                child_ancestors.push(node);
963                child_ancestors.extend_from_slice(ancestors);
964                let new_children = children
965                    .iter()
966                    .map(|c| apply_rec(c, sheets, is_hovered, &inherit_next, &child_ancestors))
967                    .collect();
968                VNode::Element {
969                    tag: tag.clone(),
970                    props: new_props,
971                    children: new_children,
972                }
973            }
974        }
975    }
976
977    let inherited_root: HashMap<String, String> = HashMap::new();
978    apply_rec(node, sheets, is_hovered, &inherited_root, &[])
979}
980
981/// Compute styles for a VNode given inline styles and optional stylesheet
982/// Returns a ComputedStyle with all properties resolved.
983/// `ancestors` provides the VNode ancestor chain for compound selector matching.
984pub fn compute_styles_for_node(
985    node: &VNode,
986    inline_style: Option<&str>,
987    sheet: Option<&Stylesheet>,
988    is_hovered: bool,
989    ancestors: &[&VNode],
990) -> ComputedStyle {
991    let mut computed = ComputedStyle::new();
992
993    // Apply stylesheet styles first (lower precedence)
994    if let Some(sheet) = sheet
995        && let VNode::Element { tag, props, .. } = node
996    {
997        // Apply matching rules from stylesheet
998        for rule in &sheet.rules {
999            if matches_selector(&rule.selector, tag, props, is_hovered, ancestors) {
1000                // `ComputedStyle` is the element's OWN resolved style, so it can
1001                // never hold `::placeholder` declarations — the painter reads the
1002                // placeholder's colour and geometry from
1003                // `PLACEHOLDER_STYLE_ATTR` instead, and a placeholder's colour
1004                // here would paint the VALUE. A rule whose placeholder part is
1005                // not the selector's subject matches nothing at all, so it is
1006                // skipped too. `apply_styles_with_hover` makes both distinctions
1007                // by routing into a second accumulator; this function has one
1008                // accumulator, so both cases collapse into "drop the rule".
1009                if rule.selector.has_placeholder() {
1010                    continue;
1011                }
1012                for (prop, value) in &rule.decls {
1013                    computed.set_property(prop, value);
1014                }
1015            }
1016        }
1017    }
1018
1019    // Apply inline styles last (highest precedence - they override stylesheet)
1020    if let Some(style) = inline_style {
1021        computed.apply_inline_style(style);
1022    }
1023
1024    computed
1025}
1026
1027#[cfg(test)]
1028mod placeholder_tests {
1029    use super::*;
1030    use velox_dom::h;
1031
1032    // ── split_pseudos ────────────────────────────────────────────────────
1033    //
1034    // The regression that mattered: the old parser used `split_once(':')`,
1035    // which turns `input::placeholder` into `pseudo == ":placeholder"` — one
1036    // colon too many, matching neither `"hover"` nor anything else. The pseudo
1037    // was silently dropped and `input::placeholder { color: red }` degraded
1038    // into a plain `input` rule, which paints the VALUE text red. Every one of
1039    // these is the shape that bug took.
1040
1041    #[test]
1042    fn a_double_colon_pseudo_element_is_not_mistaken_for_a_single_colon_one() {
1043        let (name, hover, placeholder) = split_pseudos("input::placeholder");
1044        assert_eq!(name.into_owned(), "input");
1045        assert!(placeholder, "`::placeholder` was not recognised");
1046        assert!(!hover, "`::placeholder` must not also set :hover");
1047    }
1048
1049    #[test]
1050    fn the_single_colon_pseudo_classes_still_parse() {
1051        let (name, hover, placeholder) = split_pseudos("div:hover");
1052        assert_eq!(
1053            name.into_owned(),
1054            "div",
1055            ":hover regressed while ::placeholder was being added"
1056        );
1057        assert!(hover);
1058        assert!(!placeholder);
1059    }
1060
1061    #[test]
1062    fn a_selector_with_no_pseudo_is_untouched() {
1063        // No pseudo means the name is borrowed, not allocated: the Cow stays
1064        // `Borrowed` so a plain `input` parses without a heap allocation here.
1065        let (field, hover, placeholder) = split_pseudos(".field");
1066        assert!(matches!(field, std::borrow::Cow::Borrowed(".field")));
1067        assert!(!hover);
1068        assert!(!placeholder);
1069        let (tag, hover, placeholder) = split_pseudos("input");
1070        assert!(matches!(tag, std::borrow::Cow::Borrowed("input")));
1071        assert!(!hover);
1072        assert!(!placeholder);
1073    }
1074
1075    #[test]
1076    fn a_class_with_a_placeholder_pseudo_keeps_its_class() {
1077        let (name, hover, placeholder) = split_pseudos(".field::placeholder");
1078        assert_eq!(name.into_owned(), ".field");
1079        assert!(placeholder);
1080        assert!(!hover);
1081    }
1082
1083    #[test]
1084    fn two_pseudos_in_one_selector_keep_both_flags() {
1085        let (name, hover, placeholder) = split_pseudos("input::placeholder:hover");
1086        assert_eq!(name.into_owned(), "input");
1087        assert!(placeholder);
1088        assert!(hover);
1089    }
1090
1091    #[test]
1092    fn an_unknown_pseudo_is_ignored_rather_than_rejecting_the_selector() {
1093        // The pre-existing behaviour for `:focus` / `::before` was to ignore
1094        // them, and dropping the whole rule would be a regression.
1095        let (name, hover, placeholder) = split_pseudos("input::before");
1096        assert_eq!(name.into_owned(), "input");
1097        assert!(!placeholder);
1098        assert!(!hover);
1099    }
1100
1101    #[test]
1102    fn the_placeholder_pseudo_is_case_insensitive() {
1103        assert!(
1104            split_pseudos("input::PLACEHOLDER").2,
1105            "CSS pseudo-element names are ASCII case-insensitive"
1106        );
1107    }
1108
1109    // ── shows_placeholder ────────────────────────────────────────────────
1110
1111    fn props(pairs: &[(&str, &str)]) -> Props {
1112        let mut p = Props::new();
1113        for (k, v) in pairs {
1114            p = p.set(*k, *v);
1115        }
1116        p
1117    }
1118
1119    #[test]
1120    fn a_placeholder_only_matches_a_control_that_is_showing_it() {
1121        assert!(shows_placeholder(
1122            "input",
1123            &props(&[("placeholder", "hint")])
1124        ));
1125        assert!(shows_placeholder(
1126            "input",
1127            &props(&[("placeholder", "hint"), ("value", "")])
1128        ));
1129    }
1130
1131    #[test]
1132    fn a_filled_input_is_not_showing_its_placeholder() {
1133        assert!(
1134            !shows_placeholder("input", &props(&[("placeholder", "hint"), ("value", "x")])),
1135            "a ::placeholder rule that matched a filled field would repaint the \
1136             VALUE text, which is how the double-colon bug presented"
1137        );
1138    }
1139
1140    #[test]
1141    fn an_input_with_no_placeholder_attribute_does_not_match() {
1142        assert!(!shows_placeholder("input", &props(&[])));
1143        assert!(!shows_placeholder(
1144            "input",
1145            &props(&[("placeholder", "   ")])
1146        ));
1147    }
1148
1149    #[test]
1150    fn a_non_input_never_shows_a_placeholder() {
1151        assert!(!shows_placeholder(
1152            "div",
1153            &props(&[("placeholder", "hint")])
1154        ));
1155        assert!(!shows_placeholder(
1156            "textarea",
1157            &props(&[("placeholder", "hint")])
1158        ));
1159    }
1160
1161    // ── end to end through the cascade ───────────────────────────────────
1162
1163    fn styled_input(pairs: &[(&str, &str)], sheet: &Stylesheet) -> Props {
1164        let tree = h("div", Props::new(), vec![h("input", props(pairs), vec![])]);
1165        let mut out = None;
1166        fn walk(node: &VNode, out: &mut Option<Props>) {
1167            if let VNode::Element {
1168                tag,
1169                props,
1170                children,
1171            } = node
1172            {
1173                if tag == "input" {
1174                    *out = Some(props.clone());
1175                }
1176                for c in children {
1177                    walk(c, out);
1178                }
1179            }
1180        }
1181        walk(&apply_with_cascade(&tree, sheet), &mut out);
1182        out.expect("the input must survive the cascade")
1183    }
1184
1185    #[test]
1186    fn a_placeholder_rule_lands_on_its_own_attribute_and_not_on_style() {
1187        let sheet = Stylesheet::parse("input::placeholder { color: #ff0000; }");
1188        let p = styled_input(&[("placeholder", "hint")], &sheet);
1189        assert_eq!(
1190            p.attrs.get(PLACEHOLDER_STYLE_ATTR).map(String::as_str),
1191            Some("color: #ff0000;"),
1192            "the ::placeholder declarations did not reach {PLACEHOLDER_STYLE_ATTR}"
1193        );
1194        assert!(
1195            p.attrs.get("style").is_none_or(|s| !s.contains("ff0000")),
1196            "the ::placeholder colour leaked into the element's own `style`, \
1197             which is the VALUE text's style: the two would fight"
1198        );
1199    }
1200
1201    #[test]
1202    fn a_placeholder_rule_does_not_land_on_a_filled_field() {
1203        let sheet = Stylesheet::parse("input::placeholder { color: #ff0000; }");
1204        let p = styled_input(&[("placeholder", "hint"), ("value", "typed")], &sheet);
1205        assert!(
1206            !p.attrs.contains_key(PLACEHOLDER_STYLE_ATTR),
1207            "the ::placeholder rule applied to a field showing a value"
1208        );
1209    }
1210
1211    #[test]
1212    fn a_placeholder_rule_is_not_inherited_by_descendants() {
1213        // The declarations live on a separate attribute precisely so they
1214        // cannot flow down the tree the way `style` does.
1215        let sheet = Stylesheet::parse("input::placeholder { color: #ff0000; }");
1216        let tree = h(
1217            "div",
1218            Props::new(),
1219            vec![h(
1220                "input",
1221                props(&[("placeholder", "hint")]),
1222                vec![h("span", Props::new(), vec![])],
1223            )],
1224        );
1225        let styled = apply_with_cascade(&tree, &sheet);
1226        let child = match &styled {
1227            VNode::Element { children, .. } => match &children[0] {
1228                VNode::Element { children, .. } => match &children[0] {
1229                    VNode::Element { props, .. } => props.clone(),
1230                    _ => panic!("expected an element"),
1231                },
1232                _ => panic!("expected an element"),
1233            },
1234            _ => panic!("expected an element"),
1235        };
1236        assert!(
1237            !child.attrs.contains_key(PLACEHOLDER_STYLE_ATTR),
1238            "the placeholder declarations were inherited by a child element"
1239        );
1240    }
1241
1242    // ── has_placeholder / placeholder_is_subject ──────────────────────────
1243    //
1244    // The routing table these two decide. `apply_styles_with_hover` asks them
1245    // once per matching rule, so a `false` here is the difference between a
1246    // declaration painting a placeholder and painting an ordinary element.
1247
1248    fn sel(css: &str) -> CompoundSelector {
1249        Stylesheet::parse(&format!("{css} {{ color: red; }}"))
1250            .rules
1251            .into_iter()
1252            .next()
1253            .expect("the fixture must parse to exactly one rule")
1254            .selector
1255    }
1256
1257    #[test]
1258    fn a_selector_with_no_placeholder_part_is_not_a_placeholder_selector() {
1259        let s = sel(".wrap input.b .c");
1260        assert!(!s.has_placeholder(), "no part names ::placeholder");
1261        assert!(
1262            !s.placeholder_is_subject(),
1263            "a selector with no ::placeholder has no placeholder subject"
1264        );
1265    }
1266
1267    #[test]
1268    fn a_placeholder_as_the_sole_subject_part_is_accepted() {
1269        for css in [
1270            "input::placeholder",
1271            ".field::placeholder",
1272            "*::placeholder",
1273        ] {
1274            let s = sel(css);
1275            assert!(s.has_placeholder(), "{css}: no part named ::placeholder");
1276            assert!(
1277                s.placeholder_is_subject(),
1278                "{css}: a sole ::placeholder part on the subject is real CSS"
1279            );
1280        }
1281    }
1282
1283    #[test]
1284    fn a_placeholder_after_a_descendant_chain_is_still_the_subject() {
1285        // `.wrap` is a chain link, `input::placeholder` is the subject. This is
1286        // valid CSS and must keep routing to the placeholder attribute; the
1287        // rejection below is about the placeholder being the LINK, not about the
1288        // selector having more than one part.
1289        for css in [".wrap input::placeholder", ".wrap > input::placeholder"] {
1290            let s = sel(css);
1291            assert!(
1292                s.placeholder_is_subject(),
1293                "{css}: the rightmost part is the ::placeholder subject, so this \
1294                 is valid CSS and must be accepted"
1295            );
1296        }
1297    }
1298
1299    #[test]
1300    fn a_placeholder_that_is_not_the_subject_is_rejected() {
1301        for css in [".a::placeholder .b", ".a::placeholder > .b"] {
1302            let s = sel(css);
1303            assert!(s.has_placeholder(), "{css}: a part does name ::placeholder");
1304            assert!(
1305                !s.placeholder_is_subject(),
1306                "{css}: the ::placeholder part is a chain LINK, not the subject. \
1307                 A pseudo-element has no box, so it cannot be an ancestor; real \
1308                 CSS matches nothing here and Velox must not paint the subject."
1309            );
1310        }
1311    }
1312
1313    #[test]
1314    fn a_placeholder_in_the_chain_order_does_not_change_the_subject() {
1315        // The mirror of the case above, and the one that shows which end of the
1316        // chain decides. `.a .b::placeholder` still ends in the placeholder, so
1317        // it styles `.b`'s placeholder and is valid CSS — it is the ORDER that
1318        // matters, not the presence of another part.
1319        let s = sel(".a .b::placeholder");
1320        assert!(
1321            s.placeholder_is_subject(),
1322            ".a .b::placeholder ends in the subject and is real CSS"
1323        );
1324    }
1325
1326    #[test]
1327    fn a_second_placeholder_part_disqualifies_the_selector() {
1328        // Two ::placeholder parts: the subject carries one, but a LINK carries
1329        // one too, and the link alone is already illegal. Guarding only on the
1330        // last part would accept this and reintroduce the original defect.
1331        let s = sel(".a::placeholder .b::placeholder");
1332        assert!(
1333            !s.placeholder_is_subject(),
1334            "a ::placeholder in the chain disqualifies the selector even when \
1335             the subject also has one"
1336        );
1337    }
1338}