Skip to main content

fallow_extract/css_in_js/
object.rs

1//! CSS-in-JS OBJECT-notation lifter for the styling-health analytics pipeline
2//! (CSS program Phase 3c).
3//!
4//! The object-only / zero-runtime camp of CSS-in-JS (vanilla-extract, StyleX,
5//! Panda, plus emotion's object form) writes its CSS as a JS OBJECT LITERAL
6//! passed to a library call (`style({ color: 'red' })`,
7//! `stylex.create({ root: {...} })`, `css({...})`, `styled.div({...})`) rather
8//! than a tagged template. The Phase 3b lexical lifter
9//! ([`crate::css_in_js::css_in_js_virtual_stylesheet`]) only handles the template
10//! form, so an object-notation app (the libraries every new RSC / compile-time
11//! project picks) got `null` styling analytics. This module is the object-form
12//! analogue: it parses the JS/TS with oxc, walks the AST for import-gated
13//! object-literal style calls, and SERIALIZES each style bucket into the SAME
14//! blank-line-padded virtual stylesheet 3b emits, so both forms converge on one
15//! [`crate::compute_css_analytics`] + styling-health pipeline (no forked metric
16//! logic). The object -> CSS transform is unavoidable (it happens in the bundler
17//! fallow does not run); the AST just removes the lexing pain and hands us a
18//! structured object.
19//!
20//! It is health-time-only, like 3b: it runs over file SOURCE in the engine's CSS
21//! walk and persists nothing to the extraction cache (no `CACHE_VERSION` bump).
22//! The second oxc parse it costs (the extraction pass already parsed the file,
23//! but that AST is ephemeral and unreachable in the health walk) is bounded by
24//! the same dep gate + `--css` gate 3b uses.
25//!
26//! # Provenance: import-binding, not name (no false positives)
27//!
28//! `style` / `css` / `cva` are generic names a project may define locally or
29//! import from an UNRELATED library (`cva` from `class-variance-authority` is a
30//! class-string helper, not CSS). Recognition is therefore gated on IMPORT
31//! BINDING: a call only serializes when its callee name was imported from a
32//! recognized CSS-in-JS module in THIS file. A local `const style = ...` or a
33//! `css` / `cva` from an unrelated package never fires.
34//!
35//! # Static-only serialization
36//!
37//! Only static string / number values are emitted (camelCase -> kebab-case,
38//! implicit `px` on numbers outside the unitless set, selector-shaped keys become
39//! nested rules). DYNAMIC values (identifier / member / call), SPREAD, COMPUTED keys,
40//! and objects under a NON-selector key (a `cva` `variants` map, not a style
41//! block) are DROPPED, never guessed: there is no JS interpreter and no value
42//! evaluation, so a `color: theme.primary` contributes nothing rather than a
43//! fabricated token. A bucket that drops to zero static declarations is omitted
44//! entirely (no empty synthetic rule).
45//!
46//! # Three sheets: atomic / structural-partial / structural
47//!
48//! StyleX and Panda compile to ATOMIC CSS (one declaration per class, flat by
49//! construction), so the structure of their lifted source rules is not
50//! representative: a flat synthetic rule would trivially score a structural A and
51//! dilute a mixed project's `!important` / nesting density. Separately, a bucket
52//! that DROPPED a dynamic declaration could collapse onto another bucket's
53//! fingerprint (the dropped declaration is exactly what distinguished them), a
54//! false duplicate. The serializer therefore returns THREE virtual stylesheets so
55//! the engine can apply the right policy to each:
56//!
57//! - [`CssInJsObjectSheets::structural`]: vanilla-extract + emotion buckets with
58//!   NO dropped declarations. Full analytics, including duplicate-block
59//!   fingerprints and the styling-health structural grade inputs.
60//! - [`CssInJsObjectSheets::structural_partial`]: vanilla-extract + emotion
61//!   buckets that dropped a dynamic / spread / computed declaration. Their tokens
62//!   and metrics still count, but the engine suppresses their duplicate-block
63//!   fingerprints (a dropped declaration could have distinguished two otherwise
64//!   identical blocks).
65//! - [`CssInJsObjectSheets::atomic`]: StyleX + Panda buckets. Token-sprawl only;
66//!   the engine excludes them from the structural grade inputs and from
67//!   duplicate-block fingerprints (flat by construction; their structure is a
68//!   build-output property, not authored).
69//!
70//! Note: numeric values outside the unitless set gain a synthetic `px` the author
71//! did not literally type (`fontSize: 14` -> `font-size: 14px`); this is correct
72//! for the font-size-unit-MIX smell (a unit IS implied) but the synthesized unit
73//! is an analytic convenience, not authored text.
74
75use std::path::Path;
76
77use oxc_allocator::Allocator;
78use oxc_ast::ast::{
79    Argument, Expression, ImportDeclarationSpecifier, NumericLiteral, ObjectExpression,
80    ObjectPropertyKind, Program, PropertyKey, Statement, UnaryOperator,
81};
82use oxc_ast_visit::{Visit, walk};
83use oxc_parser::Parser;
84use oxc_span::{GetSpan, SourceType};
85use rustc_hash::FxHashMap;
86
87use super::shared::{WRAPPER, count_newlines};
88
89/// CSS property names (camelCase) whose numeric values are UNITLESS: a bare
90/// number is the value, not a `px` length. Mirrors React's well-known unitless
91/// set (`CSSProperty.js`), so `lineHeight: 1.5` -> `line-height: 1.5` while
92/// `padding: 8` -> `padding: 8px`. Comparison is against the camelCase key as
93/// authored (before kebab conversion).
94const UNITLESS_PROPERTIES: &[&str] = &[
95    "animationIterationCount",
96    "aspectRatio",
97    "borderImageOutset",
98    "borderImageSlice",
99    "borderImageWidth",
100    "boxFlex",
101    "boxFlexGroup",
102    "boxOrdinalGroup",
103    "columnCount",
104    "columns",
105    "flex",
106    "flexGrow",
107    "flexPositive",
108    "flexShrink",
109    "flexNegative",
110    "flexOrder",
111    "gridArea",
112    "gridRow",
113    "gridRowEnd",
114    "gridRowSpan",
115    "gridRowStart",
116    "gridColumn",
117    "gridColumnEnd",
118    "gridColumnSpan",
119    "gridColumnStart",
120    "fontWeight",
121    "lineClamp",
122    "lineHeight",
123    "opacity",
124    "order",
125    "orphans",
126    "scale",
127    "tabSize",
128    "widows",
129    "zIndex",
130    "zoom",
131    "fillOpacity",
132    "floodOpacity",
133    "stopOpacity",
134    "strokeDasharray",
135    "strokeDashoffset",
136    "strokeMiterlimit",
137    "strokeOpacity",
138    "strokeWidth",
139];
140
141/// The recognized object-notation CSS-in-JS libraries. The atomic split drives
142/// whether a library's synthetic rules count toward the styling-health structural
143/// grade and duplicate-block fingerprints.
144#[derive(Clone, Copy, PartialEq, Eq)]
145pub(super) enum Lib {
146    /// vanilla-extract (`@vanilla-extract/css` / `/recipes`): real selectors via
147    /// `globalStyle` / `selectors`, structure is meaningful.
148    VanillaExtract,
149    /// emotion `css(...)` object form (`@emotion/react` / `@emotion/css`).
150    Emotion,
151    /// emotion `styled.div(...)` object form (`@emotion/styled`); member calls.
152    EmotionStyled,
153    /// StyleX (`@stylexjs/stylex`): compile-time atomic CSS, flat by construction.
154    StyleX,
155    /// Panda (`styled-system` codegen, gated on `@pandacss/dev`): atomic CSS.
156    Panda,
157}
158
159impl Lib {
160    /// Whether the library compiles to flat atomic CSS whose source-rule
161    /// structure is not representative (excluded from the styling-health
162    /// structural grade and duplicate fingerprints).
163    const fn is_atomic(self) -> bool {
164        matches!(self, Self::StyleX | Self::Panda)
165    }
166}
167
168/// The three virtual stylesheets lifted from a source's object-notation
169/// CSS-in-JS, each blank-line-padded so CSS metric line numbers map back onto the
170/// real source. Each is `None` when the source has no object CSS-in-JS of that
171/// class (so callers skip it; no `files_analyzed` inflation). See the module docs
172/// for the per-sheet engine policy.
173#[derive(Debug, Default, PartialEq, Eq)]
174pub struct CssInJsObjectSheets {
175    /// vanilla-extract + emotion buckets with no dropped declarations: full
176    /// analytics incl. duplicate fingerprints + structural grade inputs.
177    pub structural: Option<String>,
178    /// vanilla-extract + emotion buckets that dropped a dynamic declaration:
179    /// tokens + metrics count, duplicate fingerprints suppressed by the engine.
180    pub structural_partial: Option<String>,
181    /// StyleX + Panda atomic buckets: token-sprawl only; excluded from the
182    /// structural grade inputs and duplicate fingerprints.
183    pub atomic: Option<String>,
184}
185
186impl CssInJsObjectSheets {
187    /// Whether all three sheets are empty (no recognized object CSS-in-JS).
188    #[must_use]
189    pub const fn is_empty(&self) -> bool {
190        self.structural.is_none() && self.structural_partial.is_none() && self.atomic.is_none()
191    }
192}
193
194/// Which sheet a lifted bucket belongs to.
195#[derive(Clone, Copy, PartialEq, Eq)]
196enum Stream {
197    Structural,
198    StructuralPartial,
199    Atomic,
200}
201
202/// A single lifted style bucket awaiting emission: the byte offset to pad to (the
203/// property key for a multi-bucket call, so duplicate / notable findings land on
204/// the right line), the serialized rule (`<selector>{<decls>}`), and its sheet.
205struct Bucket {
206    offset: u32,
207    rule: String,
208    stream: Stream,
209}
210
211/// Lift the object-notation CSS-in-JS in a JS/TS source into the structural /
212/// structural-partial / atomic virtual stylesheets. Parses with oxc (source type
213/// inferred from `path`), maps import bindings to recognized libraries, walks for
214/// style calls, serializes each bucket, and pads each to its source line. All
215/// sheets are `None` when the source has no recognized object CSS-in-JS import.
216#[must_use]
217pub fn css_in_js_object_sheets(source: &str, path: &Path) -> CssInJsObjectSheets {
218    let source_type = SourceType::from_path(path).unwrap_or_default();
219    let allocator = Allocator::default();
220    // A best-effort parse: even with recoverable syntax errors oxc returns a
221    // partial program, and the walk lifts whatever object styles it can reach
222    // (matching `compute_css_analytics`'s error-recovery philosophy).
223    let ret = Parser::new(&allocator, source, source_type).parse();
224
225    let mut collector = ObjectStyleCollector::new(source);
226    collector.build_import_map(&ret.program);
227    if collector.imports.is_empty() {
228        // No recognized CSS-in-JS import binding: provenance gate is closed, so
229        // nothing can fire. Cheap exit before the call walk.
230        return CssInJsObjectSheets::default();
231    }
232    collector.visit_program(&ret.program);
233    collector.finish()
234}
235
236/// Walks a parsed program collecting object-notation style buckets, gated on
237/// import provenance.
238struct ObjectStyleCollector<'a> {
239    source: &'a str,
240    /// local-binding name -> (library, canonical function role). The role is the
241    /// IMPORTED (canonical) name for a named import, so an alias
242    /// (`import { style as s }`) still dispatches on `style`; the local name for a
243    /// default / namespace binding (those route through the member-call arms,
244    /// where only the library matters).
245    imports: FxHashMap<&'a str, (Lib, &'a str)>,
246    buckets: Vec<Bucket>,
247}
248
249impl<'a> ObjectStyleCollector<'a> {
250    fn new(source: &'a str) -> Self {
251        Self {
252            source,
253            imports: FxHashMap::default(),
254            buckets: Vec::new(),
255        }
256    }
257
258    /// Map each import binding from a recognized CSS-in-JS module to its library.
259    /// Named bindings (`import { style } from '@vanilla-extract/css'`) map the
260    /// local alias; default / namespace bindings (`import stylex from
261    /// '@stylexjs/stylex'`, `import styled from '@emotion/styled'`) map the
262    /// binding for later member-call recognition (`stylex.create`, `styled.div`).
263    fn build_import_map(&mut self, program: &Program<'a>) {
264        for stmt in &program.body {
265            let Statement::ImportDeclaration(decl) = stmt else {
266                continue;
267            };
268            if decl.import_kind.is_type() {
269                continue;
270            }
271            let Some(lib) = module_library(decl.source.value.as_str()) else {
272                continue;
273            };
274            let Some(specifiers) = &decl.specifiers else {
275                continue;
276            };
277            for specifier in specifiers {
278                let (local, role) = match specifier {
279                    // A named import dispatches on its CANONICAL imported name, so
280                    // `import { style as s }` still matches the `style` arm.
281                    ImportDeclarationSpecifier::ImportSpecifier(s) => {
282                        (s.local.name.as_str(), s.imported.name().as_str())
283                    }
284                    // A default import routes through the member-call / call arms.
285                    // For emotion the default export IS the `css` function, so
286                    // canonicalize its role to `css` and let any local alias fire
287                    // (mirrors how EmotionStyled member calls ignore the binding
288                    // name). Other libs keep the local name for member-call
289                    // recognition (`import stylex from ...` -> `stylex.create`).
290                    ImportDeclarationSpecifier::ImportDefaultSpecifier(s) => {
291                        let role = if lib == Lib::Emotion {
292                            "css"
293                        } else {
294                            s.local.name.as_str()
295                        };
296                        (s.local.name.as_str(), role)
297                    }
298                    ImportDeclarationSpecifier::ImportNamespaceSpecifier(s) => {
299                        (s.local.name.as_str(), s.local.name.as_str())
300                    }
301                };
302                self.imports.insert(local, (lib, role));
303            }
304        }
305    }
306
307    fn finish(self) -> CssInJsObjectSheets {
308        let source = self.source;
309        let mut buckets = self.buckets;
310        // Emit in source order so the incremental blank-line padding only ever
311        // moves forward (the AST walk can surface a nested call before an earlier
312        // sibling depending on tree shape).
313        buckets.sort_by_key(|b| b.offset);
314        CssInJsObjectSheets {
315            structural: render(source, &buckets, Stream::Structural),
316            structural_partial: render(source, &buckets, Stream::StructuralPartial),
317            atomic: render(source, &buckets, Stream::Atomic),
318        }
319    }
320
321    /// Resolve a call's callee to `(library, kind)` if it is a recognized
322    /// object-notation style call. `kind` selects how the arguments become
323    /// buckets.
324    fn recognize(&self, callee: &Expression<'a>) -> Option<(Lib, CallKind)> {
325        match callee {
326            Expression::Identifier(id) => {
327                let (lib, role) = *self.imports.get(id.name.as_str())?;
328                let kind = match (lib, role) {
329                    // `style(obj)` / `css(obj)`: one object -> one bucket.
330                    (Lib::VanillaExtract, "style") | (Lib::Emotion | Lib::Panda, "css") => {
331                        CallKind::SingleObject
332                    }
333                    // `styleVariants({ k: obj })`: one bucket per key.
334                    (Lib::VanillaExtract, "styleVariants") => CallKind::ObjectOfObjects,
335                    // `globalStyle('sel', obj)`: real-selector rule.
336                    (Lib::VanillaExtract, "globalStyle") => CallKind::GlobalStyle,
337                    // `recipe({ base, variants })` / `cva({...})`: lift `base` only.
338                    (Lib::VanillaExtract, "recipe") | (Lib::Panda, "cva") => CallKind::RecipeBase,
339                    _ => return None,
340                };
341                Some((lib, kind))
342            }
343            // `styled.div({...})` / `stylex.create({...})`: member call on a bound
344            // namespace / default import.
345            Expression::StaticMemberExpression(member) => {
346                let Expression::Identifier(obj) = &member.object else {
347                    return None;
348                };
349                let (lib, _) = *self.imports.get(obj.name.as_str())?;
350                let kind = match (lib, member.property.name.as_str()) {
351                    (Lib::EmotionStyled, _) => CallKind::SingleObject,
352                    (Lib::StyleX, "create") => CallKind::ObjectOfObjects,
353                    _ => return None,
354                };
355                Some((lib, kind))
356            }
357            // `styled(Component)({...})`: callee is itself a `styled(...)` call.
358            Expression::CallExpression(inner) => {
359                let Expression::Identifier(id) = &inner.callee else {
360                    return None;
361                };
362                matches!(
363                    self.imports.get(id.name.as_str()),
364                    Some((Lib::EmotionStyled, _))
365                )
366                .then_some((Lib::EmotionStyled, CallKind::SingleObject))
367            }
368            _ => None,
369        }
370    }
371
372    /// Turn a recognized call's arguments into buckets and record them.
373    fn collect_call(&mut self, callee: &Expression<'a>, args: &[Argument<'a>]) {
374        let Some((lib, kind)) = self.recognize(callee) else {
375            return;
376        };
377        let atomic = lib.is_atomic();
378        match kind {
379            CallKind::SingleObject => {
380                if let Some(obj) = object_arg(args, 0) {
381                    self.push_bucket(obj, WRAPPER, atomic, obj.span().start);
382                }
383            }
384            CallKind::ObjectOfObjects => {
385                // `stylex.create({ root: {...} })` / `styleVariants({ a: {...} })`:
386                // one bucket per key (padded to the key line). Only the
387                // single-object form; the functional `styleVariants(data, fn)`
388                // overload returns styles dynamically and is skipped.
389                if args.len() != 1 {
390                    return;
391                }
392                let Some(obj) = object_arg(args, 0) else {
393                    return;
394                };
395                for prop in &obj.properties {
396                    if let ObjectPropertyKind::ObjectProperty(p) = prop
397                        && let Expression::ObjectExpression(inner) = &p.value
398                    {
399                        self.push_bucket(inner, WRAPPER, atomic, p.key.span().start);
400                    }
401                }
402            }
403            CallKind::RecipeBase => {
404                // `recipe({ base: {...}, variants: {...} })` / `cva({...})`: only
405                // the `base` style object is plain declarations; `variants` /
406                // `compoundVariants` / `defaultVariants` are config maps, not style
407                // blocks, and are skipped (deferred).
408                let Some(obj) = object_arg(args, 0) else {
409                    return;
410                };
411                for prop in &obj.properties {
412                    if let ObjectPropertyKind::ObjectProperty(p) = prop
413                        && static_key(&p.key).as_deref() == Some("base")
414                        && let Expression::ObjectExpression(inner) = &p.value
415                    {
416                        self.push_bucket(inner, WRAPPER, atomic, p.key.span().start);
417                    }
418                }
419            }
420            CallKind::GlobalStyle => {
421                // `globalStyle('selector', { ... })`: real selector, structural.
422                let (Some(selector), Some(obj)) = (string_arg(args, 0), object_arg(args, 1)) else {
423                    return;
424                };
425                let selector = sanitize_selector(&selector);
426                if !selector.is_empty() {
427                    self.push_bucket(obj, &selector, atomic, obj.span().start);
428                }
429            }
430        }
431    }
432
433    /// Serialize one object literal into a `<selector>{<decls>}` rule and record
434    /// it, dropping the bucket when no static declaration survives and routing it
435    /// to the right sheet (atomic, or structural / structural-partial by whether
436    /// any declaration was dropped).
437    fn push_bucket(
438        &mut self,
439        obj: &ObjectExpression<'a>,
440        selector: &str,
441        atomic: bool,
442        offset: u32,
443    ) {
444        let mut body = String::new();
445        let mut dropped = false;
446        serialize_object_body(obj, &mut body, &mut dropped);
447        if body.is_empty() {
448            return;
449        }
450        let stream = if atomic {
451            Stream::Atomic
452        } else if dropped {
453            Stream::StructuralPartial
454        } else {
455            Stream::Structural
456        };
457        self.buckets.push(Bucket {
458            offset,
459            rule: format!("{selector}{{{body}}}"),
460            stream,
461        });
462    }
463}
464
465impl<'a> Visit<'a> for ObjectStyleCollector<'a> {
466    fn visit_call_expression(&mut self, call: &oxc_ast::ast::CallExpression<'a>) {
467        self.collect_call(&call.callee, &call.arguments);
468        walk::walk_call_expression(self, call);
469    }
470}
471
472/// Render the buckets of one stream into a blank-line-padded sheet, or `None` if
473/// there are none. Each bucket is padded to its source line so CSS metric line
474/// numbers map back onto the source.
475fn render(source: &str, buckets: &[Bucket], stream: Stream) -> Option<String> {
476    let mut out = String::new();
477    let mut current_line: usize = 1;
478    let mut found = false;
479    for bucket in buckets.iter().filter(|b| b.stream == stream) {
480        let block_line = 1 + count_newlines(&source[..bucket.offset as usize]);
481        while current_line < block_line {
482            out.push('\n');
483            current_line += 1;
484        }
485        out.push_str(&bucket.rule);
486        current_line += count_newlines(&bucket.rule);
487        found = true;
488    }
489    found.then_some(out)
490}
491
492/// How a recognized call's arguments map to style buckets.
493enum CallKind {
494    /// The first object argument is one style bucket (`style(obj)`, `css(obj)`,
495    /// `styled.div(obj)`).
496    SingleObject,
497    /// The first object argument is a map of key -> style object; each value
498    /// object is its own bucket (`stylex.create({...})`, `styleVariants({...})`).
499    ObjectOfObjects,
500    /// The first object argument is a recipe (`{ base, variants, ... }`); only
501    /// `base` is a style bucket (`recipe({...})`, `cva({...})`).
502    RecipeBase,
503    /// `globalStyle('selector', obj)`: the second arg is a style bucket emitted
504    /// under the real first-arg selector.
505    GlobalStyle,
506}
507
508/// The recognized library for an import module specifier, or `None`. Panda's
509/// runtime `css` / `cva` is imported from a generated `styled-system` path rather
510/// than a package name, so any specifier whose path contains a `styled-system`
511/// segment is treated as Panda (still behind the engine's `@pandacss/dev` dep
512/// gate, which decides whether the file is scanned at all).
513pub(super) fn module_library(specifier: &str) -> Option<Lib> {
514    match specifier {
515        "@pandacss/dev" => Some(Lib::Panda),
516        "@vanilla-extract/css" | "@vanilla-extract/recipes" => Some(Lib::VanillaExtract),
517        "@emotion/react" | "@emotion/css" => Some(Lib::Emotion),
518        "@emotion/styled" => Some(Lib::EmotionStyled),
519        "@stylexjs/stylex" => Some(Lib::StyleX),
520        _ if specifier
521            .split(['/', '\\'])
522            .any(|segment| segment == "styled-system") =>
523        {
524            Some(Lib::Panda)
525        }
526        _ => None,
527    }
528}
529
530/// The object-expression argument at `index`, if present and an object literal.
531fn object_arg<'a, 'b>(args: &'b [Argument<'a>], index: usize) -> Option<&'b ObjectExpression<'a>> {
532    match args.get(index) {
533        Some(Argument::ObjectExpression(obj)) => Some(obj),
534        _ => None,
535    }
536}
537
538/// The string-literal argument at `index`, if present.
539fn string_arg(args: &[Argument<'_>], index: usize) -> Option<String> {
540    match args.get(index) {
541        Some(Argument::StringLiteral(lit)) => Some(lit.value.to_string()),
542        _ => None,
543    }
544}
545
546/// Serialize an object literal's static declarations into a CSS rule body. A
547/// selector-shaped key with an object value (`:hover`, `&:hover`, `@media ...`,
548/// vanilla-extract `selectors: {...}`) becomes a nested rule and recurses through
549/// further selector-shaped keys, so authored selector nesting depth is reflected
550/// (a real structural signal); dynamic values, spreads, computed keys, and
551/// objects under a NON-selector key (a `cva` `variants` map) are dropped and flip
552/// `dropped`.
553fn serialize_object_body(obj: &ObjectExpression<'_>, out: &mut String, dropped: &mut bool) {
554    for prop in &obj.properties {
555        let ObjectPropertyKind::ObjectProperty(prop) = prop else {
556            // Spread (`...base`) carries no statically-known declarations.
557            *dropped = true;
558            continue;
559        };
560        let Some(key) = static_key(&prop.key) else {
561            // Computed key (`[prop]: v`): cannot be resolved statically.
562            *dropped = true;
563            continue;
564        };
565        match &prop.value {
566            Expression::ObjectExpression(nested) if is_selector_key(&key) => {
567                serialize_nested(&key, nested, out, dropped);
568            }
569            Expression::ObjectExpression(_) => {
570                // An object under a non-selector key (`variants: {...}`, a StyleX
571                // conditional value): not a style block, drop it.
572                *dropped = true;
573            }
574            value => {
575                if let Some(rendered) = serialize_value(&key, value) {
576                    out.push_str(&rendered);
577                } else {
578                    *dropped = true;
579                }
580            }
581        }
582    }
583}
584
585/// Serialize a nested object under a selector- or at-rule-shaped key into a
586/// nested rule (one level). vanilla-extract's `selectors: { '&:hover': {...} }`
587/// wrapper is unwrapped so each inner selector becomes its own nested rule.
588fn serialize_nested(
589    key: &str,
590    nested: &ObjectExpression<'_>,
591    out: &mut String,
592    dropped: &mut bool,
593) {
594    // `selectors: { '&:hover': {...}, ... }` is a wrapper, not a selector: emit
595    // each inner key as its own nested rule.
596    if key == "selectors" {
597        for prop in &nested.properties {
598            match prop {
599                ObjectPropertyKind::ObjectProperty(p) => {
600                    if let (Some(inner_key), Expression::ObjectExpression(inner)) =
601                        (static_key(&p.key), &p.value)
602                    {
603                        serialize_nested(&inner_key, inner, out, dropped);
604                    } else {
605                        *dropped = true;
606                    }
607                }
608                ObjectPropertyKind::SpreadProperty(_) => *dropped = true,
609            }
610        }
611        return;
612    }
613
614    let mut body = String::new();
615    serialize_object_body(nested, &mut body, dropped);
616    if body.is_empty() {
617        return;
618    }
619    out.push_str(&nested_selector(key));
620    out.push('{');
621    out.push_str(&body);
622    out.push('}');
623}
624
625/// Whether an object-property key introduces a nested SELECTOR / at-rule (so its
626/// object value is a nested rule) rather than a CSS property. Selector-shaped:
627/// the vanilla-extract `selectors` wrapper, an at-rule (`@media`), or a key
628/// starting with a selector character (`:`, `&`, a combinator, `.`, `#`, `[`, `*`,
629/// or a leading space for a descendant). A plain CSS property name (`color`,
630/// `backgroundColor`, `--custom`) is NOT a selector.
631fn is_selector_key(key: &str) -> bool {
632    if key == "selectors" {
633        return true;
634    }
635    matches!(
636        key.trim_start().chars().next(),
637        Some(':' | '&' | '@' | '>' | '+' | '~' | '.' | '#' | '[' | '*')
638    ) || key.starts_with(' ')
639}
640
641/// Map a nested object key to a CSS nested-rule prelude. At-rule keys
642/// (`@media ...`) and `&`-anchored selectors pass through; a bare pseudo /
643/// selector is prefixed with `&` so it parses as relative nesting.
644fn nested_selector(key: &str) -> String {
645    let trimmed = key.trim();
646    if trimmed.starts_with('@') || trimmed.starts_with('&') {
647        return trimmed.to_string();
648    }
649    format!("&{trimmed}")
650}
651
652/// Render a single static declaration `key: value` (with trailing `;`), or `None`
653/// when the value is not a static string / number (dynamic values are dropped).
654fn serialize_value(key: &str, value: &Expression<'_>) -> Option<String> {
655    let rendered = static_value(key, value)?;
656    Some(format!("{}:{rendered};", kebab_case(key)))
657}
658
659/// The CSS text of a static string / number expression for property `key`, or
660/// `None` for any dynamic / non-literal value. Numbers outside the unitless set
661/// gain an implicit `px`; negative numbers (`-8` as a unary minus) are handled.
662fn static_value(key: &str, value: &Expression<'_>) -> Option<String> {
663    match value {
664        Expression::StringLiteral(lit) => {
665            let text = lit.value.as_str().trim();
666            (!text.is_empty()).then(|| text.to_string())
667        }
668        Expression::NumericLiteral(num) => Some(render_number(key, num)),
669        Expression::UnaryExpression(unary) if unary.operator == UnaryOperator::UnaryNegation => {
670            if let Expression::NumericLiteral(num) = &unary.argument {
671                Some(format!("-{}", render_number(key, num)))
672            } else {
673                None
674            }
675        }
676        _ => None,
677    }
678}
679
680/// Render a numeric literal for property `key`, appending `px` unless the
681/// property is unitless, a custom property, or the value is zero. The number is
682/// rendered from its PARSED value, not the raw source text, so a hex / octal /
683/// binary / scientific literal (`0xFF`, `1e3`) becomes a valid CSS decimal
684/// (`255`, `1000`) rather than a non-CSS token; `format_f64` preserves `1.5` /
685/// `700` exactly. The custom-property carve-out matches the real compiled output:
686/// emotion (`!isCustomProperty(key)` in `@emotion/serialize`) and React both
687/// leave a numeric `--x` value unitless, so adding `px` would fabricate a unit
688/// the bundler never emits.
689fn render_number(key: &str, num: &NumericLiteral<'_>) -> String {
690    let value = format_f64(num.value);
691    if is_unitless(key) || key.starts_with("--") || num.value == 0.0 {
692        value
693    } else {
694        format!("{value}px")
695    }
696}
697
698fn format_f64(value: f64) -> String {
699    if value.fract() == 0.0 {
700        format!("{value:.0}")
701    } else {
702        value.to_string()
703    }
704}
705
706/// Whether `key` (camelCase) is a unitless CSS property.
707fn is_unitless(key: &str) -> bool {
708    UNITLESS_PROPERTIES.contains(&key)
709}
710
711/// The static name of an object-property key (string literal or identifier), or
712/// `None` for a computed / dynamic key.
713fn static_key(key: &PropertyKey<'_>) -> Option<String> {
714    key.static_name().map(|name| name.to_string())
715}
716
717/// Convert a camelCase CSS property name to kebab-case. A leading uppercase
718/// (vendor prefix `WebkitBoxShadow`) becomes a leading `-` (`-webkit-box-shadow`),
719/// and the lowercase `ms` Microsoft prefix (`msFlexAlign`, the one React/emotion
720/// write lowercase) becomes `-ms-`. Custom properties (`--x`) and already-kebab
721/// names pass through unchanged.
722fn kebab_case(name: &str) -> String {
723    if name.starts_with("--") || name.contains('-') {
724        return name.to_string();
725    }
726    let mut out = String::with_capacity(name.len() + 2);
727    // The `ms` vendor prefix is authored lowercase (unlike `Webkit`/`Moz`/`O`),
728    // so prepend the leading `-` an uppercase boundary would otherwise add
729    // (`msTransform` -> `-ms-transform`).
730    if let Some(rest) = name.strip_prefix("ms")
731        && rest.chars().next().is_some_and(|c| c.is_ascii_uppercase())
732    {
733        out.push('-');
734    }
735    for ch in name.chars() {
736        if ch.is_ascii_uppercase() {
737            out.push('-');
738            out.push(ch.to_ascii_lowercase());
739        } else {
740            out.push(ch);
741        }
742    }
743    out
744}
745
746/// Drop any character from a `globalStyle` selector that could break out of the
747/// synthetic rule context (`{`, `}`) or split a declaration (`;`). The selector
748/// is authored CSS, kept as-is otherwise so its specificity / complexity are
749/// measured for real.
750fn sanitize_selector(selector: &str) -> String {
751    selector
752        .chars()
753        .filter(|&c| c != '{' && c != '}' && c != ';')
754        .collect::<String>()
755        .trim()
756        .to_string()
757}
758
759#[cfg(all(test, not(miri)))]
760mod tests {
761    use super::*;
762    use crate::compute_css_analytics;
763
764    fn sheets(source: &str) -> CssInJsObjectSheets {
765        css_in_js_object_sheets(source, Path::new("styles.ts"))
766    }
767
768    #[test]
769    fn vanilla_extract_style_lifts_to_parseable_css() {
770        let src = "import { style } from '@vanilla-extract/css';\n\
771                   export const box = style({\n\
772                   backgroundColor: 'red',\n\
773                   padding: 8,\n\
774                   });\n";
775        let s = sheets(src);
776        let css = s.structural.expect("vanilla-extract style is structural");
777        // camelCase -> kebab, implicit px on the numeric value.
778        assert!(css.contains("background-color:red;"), "css={css:?}");
779        assert!(css.contains("padding:8px;"), "px default: css={css:?}");
780        let a = compute_css_analytics(&css).expect("lifted CSS parses");
781        assert!(a.total_declarations >= 2, "declarations counted: {a:?}");
782        assert!(s.atomic.is_none(), "vanilla-extract is not atomic");
783    }
784
785    #[test]
786    fn unitless_properties_keep_bare_number() {
787        let src = "import { style } from '@vanilla-extract/css';\n\
788                   const x = style({ lineHeight: 1.5, zIndex: 10, fontWeight: 700, padding: 4 });\n";
789        let css = sheets(src).structural.expect("structural");
790        assert!(css.contains("line-height:1.5;"), "css={css:?}");
791        assert!(css.contains("z-index:10;"), "css={css:?}");
792        assert!(css.contains("font-weight:700;"), "css={css:?}");
793        assert!(css.contains("padding:4px;"), "css={css:?}");
794    }
795
796    #[test]
797    fn one_level_nesting_via_relative_selector() {
798        let src = "import { style } from '@vanilla-extract/css';\n\
799                   const x = style({ color: 'red', ':hover': { color: 'blue' } });\n";
800        let css = sheets(src).structural.expect("structural");
801        assert!(
802            css.contains("&:hover{color:blue;}"),
803            "nested rule: css={css:?}"
804        );
805        let a = compute_css_analytics(&css).expect("nested parses");
806        assert!(a.rule_count >= 2, "nested rule counted: {a:?}");
807    }
808
809    #[test]
810    fn vanilla_extract_selectors_wrapper_unwrapped() {
811        let src = "import { style } from '@vanilla-extract/css';\n\
812                   const x = style({ color: 'red', selectors: { '&:hover': { color: 'blue' } } });\n";
813        let css = sheets(src).structural.expect("structural");
814        assert!(
815            css.contains("&:hover{color:blue;}"),
816            "selectors wrapper unwrapped: css={css:?}"
817        );
818        // The `selectors` key itself must NOT become a `&selectors{}` rule.
819        assert!(
820            !css.contains("selectors{"),
821            "no literal selectors rule: css={css:?}"
822        );
823    }
824
825    #[test]
826    fn global_style_keeps_real_selector() {
827        let src = "import { globalStyle } from '@vanilla-extract/css';\n\
828                   globalStyle('html, body', { margin: 0 });\n";
829        let css = sheets(src).structural.expect("structural");
830        assert!(
831            css.contains("html, body{margin:0;}"),
832            "real selector: css={css:?}"
833        );
834        let a = compute_css_analytics(&css).expect("parses");
835        assert_eq!(a.rule_count, 1);
836    }
837
838    #[test]
839    fn stylex_create_is_atomic_one_bucket_per_key() {
840        let src = "import * as stylex from '@stylexjs/stylex';\n\
841                   export const styles = stylex.create({\n\
842                   root: { color: 'red', padding: 16 },\n\
843                   card: { color: 'blue' },\n\
844                   });\n";
845        let s = sheets(src);
846        assert!(s.structural.is_none(), "stylex is atomic, not structural");
847        let css = s.atomic.expect("stylex.create is atomic");
848        assert!(css.contains("color:red;"), "css={css:?}");
849        assert!(css.contains("padding:16px;"), "css={css:?}");
850        assert!(css.contains("color:blue;"), "second bucket: css={css:?}");
851        let a = compute_css_analytics(&css).expect("parses");
852        assert!(a.rule_count >= 2, "two buckets: {a:?}");
853    }
854
855    #[test]
856    fn panda_css_from_styled_system_is_atomic() {
857        let src = "import { css } from '../styled-system/css';\n\
858                   const c = css({ display: 'flex', gap: 8 });\n";
859        let s = sheets(src);
860        let css = s.atomic.expect("panda css is atomic");
861        assert!(css.contains("display:flex;"), "css={css:?}");
862        assert!(css.contains("gap:8px;"), "css={css:?}");
863    }
864
865    #[test]
866    fn emotion_css_and_styled_are_structural() {
867        let src = "import { css } from '@emotion/react';\n\
868                   import styled from '@emotion/styled';\n\
869                   const a = css({ color: 'red' });\n\
870                   const B = styled.div({ fontWeight: 700 });\n";
871        let css = sheets(src).structural.expect("emotion is structural");
872        assert!(css.contains("color:red;"), "css={css:?}");
873        assert!(css.contains("font-weight:700;"), "styled.div: css={css:?}");
874    }
875
876    #[test]
877    fn styled_call_form_is_lifted() {
878        let src = "import styled from '@emotion/styled';\n\
879                   const Primary = styled(Button)({ fontWeight: 700 });\n";
880        let css = sheets(src)
881            .structural
882            .expect("styled(Component)({}) lifted");
883        assert!(css.contains("font-weight:700;"), "css={css:?}");
884    }
885
886    #[test]
887    fn dynamic_value_is_dropped_to_structural_partial() {
888        let src = "import { style } from '@vanilla-extract/css';\n\
889                   import { theme } from './theme';\n\
890                   const x = style({ color: theme.primary, padding: 8, margin: 4, top: 1, left: 2 });\n";
891        let s = sheets(src);
892        // The dynamic `color` is dropped; the bucket has a dropped decl so it
893        // lands in structural_partial (duplicate-fingerprint suppressed by the
894        // engine), NOT the clean structural sheet.
895        assert!(s.structural.is_none(), "bucket had a drop: {s:?}");
896        let css = s.structural_partial.expect("partial");
897        assert!(
898            !css.contains("fallowinterp"),
899            "no placeholder, value dropped: {css:?}"
900        );
901        assert!(
902            !css.contains("primary"),
903            "dynamic member not serialized: {css:?}"
904        );
905        assert!(css.contains("padding:8px;"), "static survives: {css:?}");
906        let a = compute_css_analytics(&css).expect("must parse, not None");
907        assert_eq!(a.important_declarations, 0, "no invented !important: {a:?}");
908    }
909
910    #[test]
911    fn spread_and_computed_key_dropped() {
912        let src = "import { style } from '@vanilla-extract/css';\n\
913                   const base = {};\n\
914                   const k = 'color';\n\
915                   const x = style({ ...base, [k]: 'red', padding: 8, margin: 4, top: 1 });\n";
916        let s = sheets(src);
917        // Spread + computed key are drops -> structural_partial.
918        let css = s.structural_partial.expect("partial");
919        assert!(css.contains("padding:8px;"), "static survives: {css:?}");
920    }
921
922    #[test]
923    fn cva_variants_map_is_not_serialized_as_css() {
924        // `cva` from class-variance-authority is NOT a recognized CSS-in-JS
925        // import, so it must not fire at all even though `cva` is a Panda name.
926        let cva = "import { cva } from 'class-variance-authority';\n\
927                   const button = cva('base', { variants: { size: { sm: 'text-sm' } } });\n";
928        assert!(
929            sheets(cva).is_empty(),
930            "unrelated cva must not fire: {:?}",
931            sheets(cva)
932        );
933
934        // Panda `cva` from styled-system: only `base` is CSS; `variants` (a config
935        // map of class objects) must be dropped, never serialized as garbage.
936        let panda = "import { cva } from '../styled-system/css';\n\
937                     const button = cva({ base: { color: 'red', padding: 8, margin: 4, top: 1 }, variants: { size: { sm: { fontSize: 12 } } } });\n";
938        let s = sheets(panda);
939        let css = s.atomic.expect("panda cva base is atomic");
940        assert!(css.contains("color:red;"), "base serialized: {css:?}");
941        assert!(
942            !css.contains("size"),
943            "variants config not serialized: {css:?}"
944        );
945        let a = compute_css_analytics(&css).expect("parses cleanly");
946        assert!(
947            a.notable_rules.is_empty(),
948            "no garbled structural finding: {a:?}"
949        );
950    }
951
952    #[test]
953    fn panda_cva_and_class_variance_authority_cva_coexist() {
954        // Both `cva` names can appear in one file only under distinct local
955        // aliases (duplicate bindings are a JS error). class-variance-authority is
956        // filtered out before the import map, so only Panda's binding is tracked:
957        // its `base` lifts to atomic CSS while the class-name builder stays inert.
958        let src = "import { cva } from '../styled-system/css';\n\
959                   import { cva as cn } from 'class-variance-authority';\n\
960                   const a = cva({ base: { color: 'red' } });\n\
961                   const b = cn('base', { variants: { size: { sm: 'text-sm' } } });\n";
962        let css = sheets(src).atomic.expect("panda cva base is atomic");
963        assert!(css.contains("color:red;"), "panda base lifted: {css:?}");
964        assert!(!css.contains("text-sm"), "cva-lib not serialized: {css:?}");
965    }
966
967    #[test]
968    fn local_helper_with_recognized_name_does_not_fire() {
969        // A local `const css = ...` with no recognized import must never fire,
970        // even though `css` is a recognized library call name.
971        let src = "const css = (o) => o;\n\
972                   const x = css({ color: 'red', padding: 8 });\n";
973        assert!(
974            sheets(src).is_empty(),
975            "local css helper must not fire: {:?}",
976            sheets(src)
977        );
978    }
979
980    #[test]
981    fn type_only_import_does_not_open_the_gate() {
982        let src = "import type { style } from '@vanilla-extract/css';\n\
983                   const x = style({ color: 'red' });\n";
984        assert!(
985            sheets(src).is_empty(),
986            "type-only import must not open provenance: {:?}",
987            sheets(src)
988        );
989    }
990
991    #[test]
992    fn all_dynamic_bucket_emits_no_empty_rule() {
993        let src = "import { style } from '@vanilla-extract/css';\n\
994                   import { v } from './v';\n\
995                   const x = style({ color: v.a, background: v.b });\n";
996        let s = sheets(src);
997        // Every value dynamic -> body empty -> bucket dropped entirely, no empty
998        // `.fallow-css-in-js{}` rule in any sheet.
999        assert!(s.is_empty(), "all-dynamic bucket dropped entirely: {s:?}");
1000    }
1001
1002    #[test]
1003    fn aliased_named_import_still_recognized() {
1004        // `import { style as s }` dispatches on the canonical name, not the alias.
1005        let src = "import { style as s, globalStyle as gs } from '@vanilla-extract/css';\n\
1006                   export const a = s({ color: 'red' });\n\
1007                   gs('html', { margin: 0 });\n";
1008        let s = sheets(src);
1009        let css = s.structural.expect("aliased style/globalStyle recognized");
1010        assert!(css.contains("color:red;"), "aliased style fired: {css:?}");
1011        assert!(
1012            css.contains("html{margin:0;}"),
1013            "aliased globalStyle fired: {css:?}"
1014        );
1015    }
1016
1017    #[test]
1018    fn emotion_css_default_import_recognized() {
1019        // `@emotion/css` default export IS the css function.
1020        let src = "import css from '@emotion/css';\n\
1021                   const a = css({ color: 'red' });\n";
1022        let css = sheets(src)
1023            .structural
1024            .expect("default css import recognized");
1025        assert!(css.contains("color:red;"), "css={css:?}");
1026    }
1027
1028    #[test]
1029    fn emotion_css_default_import_aliased_recognized() {
1030        // The `@emotion/css` default css function fires under ANY local alias, not
1031        // only the conventional `css` name (canonical-role dispatch on the lib).
1032        let src = "import emo from '@emotion/css';\n\
1033                   const a = emo({ color: 'red' });\n";
1034        let css = sheets(src)
1035            .structural
1036            .expect("aliased default css import recognized");
1037        assert!(css.contains("color:red;"), "css={css:?}");
1038    }
1039
1040    #[test]
1041    fn non_decimal_numeric_literals_become_valid_css() {
1042        // Hex / scientific literals render from their parsed value, never the raw
1043        // `0xFF` / `1e3` source text (which the CSS parser would reject).
1044        let src = "import { style } from '@vanilla-extract/css';\n\
1045                   const x = style({ padding: 0xFF, zIndex: 1e3 });\n";
1046        let css = sheets(src).structural.expect("structural");
1047        assert!(
1048            css.contains("padding:255px;"),
1049            "hex -> decimal px: css={css:?}"
1050        );
1051        assert!(
1052            css.contains("z-index:1000;"),
1053            "scientific -> decimal: css={css:?}"
1054        );
1055        assert!(compute_css_analytics(&css).is_some(), "valid CSS");
1056    }
1057
1058    #[test]
1059    fn custom_property_numeric_value_keeps_no_unit() {
1060        // A numeric custom-property value must NOT gain an implicit `px`: emotion
1061        // (`!isCustomProperty`) and React both leave `--x: 8` unitless, so adding
1062        // `px` would fabricate a unit the bundler never emits.
1063        let src = "import { css } from '@emotion/react';\n\
1064                   const g = css({ ':root': { '--space': 8, '--ratio': 1.5 }, padding: 8 });\n";
1065        // `:root` is a selector key -> nested rule with the custom properties.
1066        let sheet = sheets(src)
1067            .structural
1068            .or_else(|| sheets(src).structural_partial)
1069            .expect("structural output");
1070        assert!(
1071            sheet.contains("--space:8;"),
1072            "custom prop keeps no unit: {sheet:?}"
1073        );
1074        assert!(
1075            sheet.contains("--ratio:1.5;"),
1076            "custom prop float unchanged: {sheet:?}"
1077        );
1078        // A normal property still gets px.
1079        assert!(
1080            sheet.contains("padding:8px;"),
1081            "normal prop still px: {sheet:?}"
1082        );
1083    }
1084
1085    #[test]
1086    fn ms_vendor_prefix_kebabs_with_leading_dash() {
1087        assert_eq!(kebab_case("msFlexAlign"), "-ms-flex-align");
1088        assert_eq!(kebab_case("WebkitBoxShadow"), "-webkit-box-shadow");
1089        assert_eq!(kebab_case("backgroundColor"), "background-color");
1090        // `msg`-prefixed non-vendor names are not mangled.
1091        assert_eq!(kebab_case("msgType"), "msg-type");
1092    }
1093
1094    #[test]
1095    fn negative_numbers_handled() {
1096        let src = "import { style } from '@vanilla-extract/css';\n\
1097                   const x = style({ marginTop: -8, zIndex: -1 });\n";
1098        let css = sheets(src).structural.expect("structural");
1099        assert!(css.contains("margin-top:-8px;"), "css={css:?}");
1100        assert!(
1101            css.contains("z-index:-1;"),
1102            "unitless negative: css={css:?}"
1103        );
1104    }
1105
1106    #[test]
1107    fn none_without_any_object_css_in_js() {
1108        assert!(sheets("const x = 1; function f() {}").is_empty());
1109        assert!(sheets("import React from 'react'; const x = <div/>;").is_empty());
1110    }
1111
1112    #[test]
1113    fn line_numbers_map_back_to_source() {
1114        // The `color` declaration's bucket is the `style({...})` object starting on
1115        // source line 3; the lifted sheet must keep a non-blank token at line 3.
1116        let src = "import { style } from '@vanilla-extract/css';\n\
1117                   \n\
1118                   const a = style({\n\
1119                   color: 'red',\n\
1120                   });\n";
1121        let css = sheets(src).structural.expect("structural");
1122        let pos = css.find("color").expect("color present");
1123        let css_line = 1 + css[..pos].bytes().filter(|&b| b == b'\n').count();
1124        assert_eq!(
1125            css_line, 3,
1126            "bucket maps to the style() object line: css={css:?}"
1127        );
1128    }
1129
1130    #[test]
1131    fn multibyte_content_value_preserved() {
1132        let src = "import { style } from '@vanilla-extract/css';\n\
1133                   const x = style({ content: '\"café 日本 €\"', fontFamily: '\"Ñoño\"' });\n";
1134        let css = sheets(src).structural.expect("structural");
1135        assert!(
1136            css.contains("café 日本 €"),
1137            "multibyte preserved: css={css:?}"
1138        );
1139        assert!(
1140            compute_css_analytics(&css).is_some(),
1141            "valid UTF-8 / parses"
1142        );
1143    }
1144
1145    #[test]
1146    fn distinct_colors_fall_out_of_object_styles() {
1147        let src = "import * as stylex from '@stylexjs/stylex';\n\
1148                   const s = stylex.create({ a: { color: 'red' }, b: { color: 'blue' }, c: { color: 'red' } });\n";
1149        let css = sheets(src).atomic.expect("atomic");
1150        let a = compute_css_analytics(&css).expect("parses");
1151        assert_eq!(a.colors.len(), 2, "distinct colors counted: {:?}", a.colors);
1152    }
1153
1154    #[test]
1155    fn multi_bucket_padding_uses_key_line() {
1156        // Each stylex.create bucket pads to its KEY line, so two buckets do not
1157        // collapse onto the call line.
1158        let src = "import * as stylex from '@stylexjs/stylex';\n\
1159                   const s = stylex.create({\n\
1160                   root: { color: 'red' },\n\
1161                   card: { color: 'blue' },\n\
1162                   });\n";
1163        let css = sheets(src).atomic.expect("atomic");
1164        let red = css.find("color:red").expect("root present");
1165        let blue = css.find("color:blue").expect("card present");
1166        let red_line = 1 + css[..red].bytes().filter(|&b| b == b'\n').count();
1167        let blue_line = 1 + css[..blue].bytes().filter(|&b| b == b'\n').count();
1168        assert_eq!(red_line, 3, "root on its key line: css={css:?}");
1169        assert_eq!(blue_line, 4, "card on its own key line: css={css:?}");
1170    }
1171}