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}