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        "@vanilla-extract/css" | "@vanilla-extract/recipes" => Some(Lib::VanillaExtract),
516        "@emotion/react" | "@emotion/css" => Some(Lib::Emotion),
517        "@emotion/styled" => Some(Lib::EmotionStyled),
518        "@stylexjs/stylex" => Some(Lib::StyleX),
519        _ if specifier
520            .split(['/', '\\'])
521            .any(|segment| segment == "styled-system") =>
522        {
523            Some(Lib::Panda)
524        }
525        _ => None,
526    }
527}
528
529/// The object-expression argument at `index`, if present and an object literal.
530fn object_arg<'a, 'b>(args: &'b [Argument<'a>], index: usize) -> Option<&'b ObjectExpression<'a>> {
531    match args.get(index) {
532        Some(Argument::ObjectExpression(obj)) => Some(obj),
533        _ => None,
534    }
535}
536
537/// The string-literal argument at `index`, if present.
538fn string_arg(args: &[Argument<'_>], index: usize) -> Option<String> {
539    match args.get(index) {
540        Some(Argument::StringLiteral(lit)) => Some(lit.value.to_string()),
541        _ => None,
542    }
543}
544
545/// Serialize an object literal's static declarations into a CSS rule body. A
546/// selector-shaped key with an object value (`:hover`, `&:hover`, `@media ...`,
547/// vanilla-extract `selectors: {...}`) becomes a nested rule and recurses through
548/// further selector-shaped keys, so authored selector nesting depth is reflected
549/// (a real structural signal); dynamic values, spreads, computed keys, and
550/// objects under a NON-selector key (a `cva` `variants` map) are dropped and flip
551/// `dropped`.
552fn serialize_object_body(obj: &ObjectExpression<'_>, out: &mut String, dropped: &mut bool) {
553    for prop in &obj.properties {
554        let ObjectPropertyKind::ObjectProperty(prop) = prop else {
555            // Spread (`...base`) carries no statically-known declarations.
556            *dropped = true;
557            continue;
558        };
559        let Some(key) = static_key(&prop.key) else {
560            // Computed key (`[prop]: v`): cannot be resolved statically.
561            *dropped = true;
562            continue;
563        };
564        match &prop.value {
565            Expression::ObjectExpression(nested) if is_selector_key(&key) => {
566                serialize_nested(&key, nested, out, dropped);
567            }
568            Expression::ObjectExpression(_) => {
569                // An object under a non-selector key (`variants: {...}`, a StyleX
570                // conditional value): not a style block, drop it.
571                *dropped = true;
572            }
573            value => {
574                if let Some(rendered) = serialize_value(&key, value) {
575                    out.push_str(&rendered);
576                } else {
577                    *dropped = true;
578                }
579            }
580        }
581    }
582}
583
584/// Serialize a nested object under a selector- or at-rule-shaped key into a
585/// nested rule (one level). vanilla-extract's `selectors: { '&:hover': {...} }`
586/// wrapper is unwrapped so each inner selector becomes its own nested rule.
587fn serialize_nested(
588    key: &str,
589    nested: &ObjectExpression<'_>,
590    out: &mut String,
591    dropped: &mut bool,
592) {
593    // `selectors: { '&:hover': {...}, ... }` is a wrapper, not a selector: emit
594    // each inner key as its own nested rule.
595    if key == "selectors" {
596        for prop in &nested.properties {
597            match prop {
598                ObjectPropertyKind::ObjectProperty(p) => {
599                    if let (Some(inner_key), Expression::ObjectExpression(inner)) =
600                        (static_key(&p.key), &p.value)
601                    {
602                        serialize_nested(&inner_key, inner, out, dropped);
603                    } else {
604                        *dropped = true;
605                    }
606                }
607                ObjectPropertyKind::SpreadProperty(_) => *dropped = true,
608            }
609        }
610        return;
611    }
612
613    let mut body = String::new();
614    serialize_object_body(nested, &mut body, dropped);
615    if body.is_empty() {
616        return;
617    }
618    out.push_str(&nested_selector(key));
619    out.push('{');
620    out.push_str(&body);
621    out.push('}');
622}
623
624/// Whether an object-property key introduces a nested SELECTOR / at-rule (so its
625/// object value is a nested rule) rather than a CSS property. Selector-shaped:
626/// the vanilla-extract `selectors` wrapper, an at-rule (`@media`), or a key
627/// starting with a selector character (`:`, `&`, a combinator, `.`, `#`, `[`, `*`,
628/// or a leading space for a descendant). A plain CSS property name (`color`,
629/// `backgroundColor`, `--custom`) is NOT a selector.
630fn is_selector_key(key: &str) -> bool {
631    if key == "selectors" {
632        return true;
633    }
634    matches!(
635        key.trim_start().chars().next(),
636        Some(':' | '&' | '@' | '>' | '+' | '~' | '.' | '#' | '[' | '*')
637    ) || key.starts_with(' ')
638}
639
640/// Map a nested object key to a CSS nested-rule prelude. At-rule keys
641/// (`@media ...`) and `&`-anchored selectors pass through; a bare pseudo /
642/// selector is prefixed with `&` so it parses as relative nesting.
643fn nested_selector(key: &str) -> String {
644    let trimmed = key.trim();
645    if trimmed.starts_with('@') || trimmed.starts_with('&') {
646        return trimmed.to_string();
647    }
648    format!("&{trimmed}")
649}
650
651/// Render a single static declaration `key: value` (with trailing `;`), or `None`
652/// when the value is not a static string / number (dynamic values are dropped).
653fn serialize_value(key: &str, value: &Expression<'_>) -> Option<String> {
654    let rendered = static_value(key, value)?;
655    Some(format!("{}:{rendered};", kebab_case(key)))
656}
657
658/// The CSS text of a static string / number expression for property `key`, or
659/// `None` for any dynamic / non-literal value. Numbers outside the unitless set
660/// gain an implicit `px`; negative numbers (`-8` as a unary minus) are handled.
661fn static_value(key: &str, value: &Expression<'_>) -> Option<String> {
662    match value {
663        Expression::StringLiteral(lit) => {
664            let text = lit.value.as_str().trim();
665            (!text.is_empty()).then(|| text.to_string())
666        }
667        Expression::NumericLiteral(num) => Some(render_number(key, num)),
668        Expression::UnaryExpression(unary) if unary.operator == UnaryOperator::UnaryNegation => {
669            if let Expression::NumericLiteral(num) = &unary.argument {
670                Some(format!("-{}", render_number(key, num)))
671            } else {
672                None
673            }
674        }
675        _ => None,
676    }
677}
678
679/// Render a numeric literal for property `key`, appending `px` unless the
680/// property is unitless, a custom property, or the value is zero. The number is
681/// rendered from its PARSED value, not the raw source text, so a hex / octal /
682/// binary / scientific literal (`0xFF`, `1e3`) becomes a valid CSS decimal
683/// (`255`, `1000`) rather than a non-CSS token; `format_f64` preserves `1.5` /
684/// `700` exactly. The custom-property carve-out matches the real compiled output:
685/// emotion (`!isCustomProperty(key)` in `@emotion/serialize`) and React both
686/// leave a numeric `--x` value unitless, so adding `px` would fabricate a unit
687/// the bundler never emits.
688fn render_number(key: &str, num: &NumericLiteral<'_>) -> String {
689    let value = format_f64(num.value);
690    if is_unitless(key) || key.starts_with("--") || num.value == 0.0 {
691        value
692    } else {
693        format!("{value}px")
694    }
695}
696
697fn format_f64(value: f64) -> String {
698    if value.fract() == 0.0 {
699        format!("{value:.0}")
700    } else {
701        value.to_string()
702    }
703}
704
705/// Whether `key` (camelCase) is a unitless CSS property.
706fn is_unitless(key: &str) -> bool {
707    UNITLESS_PROPERTIES.contains(&key)
708}
709
710/// The static name of an object-property key (string literal or identifier), or
711/// `None` for a computed / dynamic key.
712fn static_key(key: &PropertyKey<'_>) -> Option<String> {
713    key.static_name().map(|name| name.to_string())
714}
715
716/// Convert a camelCase CSS property name to kebab-case. A leading uppercase
717/// (vendor prefix `WebkitBoxShadow`) becomes a leading `-` (`-webkit-box-shadow`),
718/// and the lowercase `ms` Microsoft prefix (`msFlexAlign`, the one React/emotion
719/// write lowercase) becomes `-ms-`. Custom properties (`--x`) and already-kebab
720/// names pass through unchanged.
721fn kebab_case(name: &str) -> String {
722    if name.starts_with("--") || name.contains('-') {
723        return name.to_string();
724    }
725    let mut out = String::with_capacity(name.len() + 2);
726    // The `ms` vendor prefix is authored lowercase (unlike `Webkit`/`Moz`/`O`),
727    // so prepend the leading `-` an uppercase boundary would otherwise add
728    // (`msTransform` -> `-ms-transform`).
729    if let Some(rest) = name.strip_prefix("ms")
730        && rest.chars().next().is_some_and(|c| c.is_ascii_uppercase())
731    {
732        out.push('-');
733    }
734    for ch in name.chars() {
735        if ch.is_ascii_uppercase() {
736            out.push('-');
737            out.push(ch.to_ascii_lowercase());
738        } else {
739            out.push(ch);
740        }
741    }
742    out
743}
744
745/// Drop any character from a `globalStyle` selector that could break out of the
746/// synthetic rule context (`{`, `}`) or split a declaration (`;`). The selector
747/// is authored CSS, kept as-is otherwise so its specificity / complexity are
748/// measured for real.
749fn sanitize_selector(selector: &str) -> String {
750    selector
751        .chars()
752        .filter(|&c| c != '{' && c != '}' && c != ';')
753        .collect::<String>()
754        .trim()
755        .to_string()
756}
757
758#[cfg(all(test, not(miri)))]
759mod tests {
760    use super::*;
761    use crate::compute_css_analytics;
762
763    fn sheets(source: &str) -> CssInJsObjectSheets {
764        css_in_js_object_sheets(source, Path::new("styles.ts"))
765    }
766
767    #[test]
768    fn vanilla_extract_style_lifts_to_parseable_css() {
769        let src = "import { style } from '@vanilla-extract/css';\n\
770                   export const box = style({\n\
771                   backgroundColor: 'red',\n\
772                   padding: 8,\n\
773                   });\n";
774        let s = sheets(src);
775        let css = s.structural.expect("vanilla-extract style is structural");
776        // camelCase -> kebab, implicit px on the numeric value.
777        assert!(css.contains("background-color:red;"), "css={css:?}");
778        assert!(css.contains("padding:8px;"), "px default: css={css:?}");
779        let a = compute_css_analytics(&css).expect("lifted CSS parses");
780        assert!(a.total_declarations >= 2, "declarations counted: {a:?}");
781        assert!(s.atomic.is_none(), "vanilla-extract is not atomic");
782    }
783
784    #[test]
785    fn unitless_properties_keep_bare_number() {
786        let src = "import { style } from '@vanilla-extract/css';\n\
787                   const x = style({ lineHeight: 1.5, zIndex: 10, fontWeight: 700, padding: 4 });\n";
788        let css = sheets(src).structural.expect("structural");
789        assert!(css.contains("line-height:1.5;"), "css={css:?}");
790        assert!(css.contains("z-index:10;"), "css={css:?}");
791        assert!(css.contains("font-weight:700;"), "css={css:?}");
792        assert!(css.contains("padding:4px;"), "css={css:?}");
793    }
794
795    #[test]
796    fn one_level_nesting_via_relative_selector() {
797        let src = "import { style } from '@vanilla-extract/css';\n\
798                   const x = style({ color: 'red', ':hover': { color: 'blue' } });\n";
799        let css = sheets(src).structural.expect("structural");
800        assert!(
801            css.contains("&:hover{color:blue;}"),
802            "nested rule: css={css:?}"
803        );
804        let a = compute_css_analytics(&css).expect("nested parses");
805        assert!(a.rule_count >= 2, "nested rule counted: {a:?}");
806    }
807
808    #[test]
809    fn vanilla_extract_selectors_wrapper_unwrapped() {
810        let src = "import { style } from '@vanilla-extract/css';\n\
811                   const x = style({ color: 'red', selectors: { '&:hover': { color: 'blue' } } });\n";
812        let css = sheets(src).structural.expect("structural");
813        assert!(
814            css.contains("&:hover{color:blue;}"),
815            "selectors wrapper unwrapped: css={css:?}"
816        );
817        // The `selectors` key itself must NOT become a `&selectors{}` rule.
818        assert!(
819            !css.contains("selectors{"),
820            "no literal selectors rule: css={css:?}"
821        );
822    }
823
824    #[test]
825    fn global_style_keeps_real_selector() {
826        let src = "import { globalStyle } from '@vanilla-extract/css';\n\
827                   globalStyle('html, body', { margin: 0 });\n";
828        let css = sheets(src).structural.expect("structural");
829        assert!(
830            css.contains("html, body{margin:0;}"),
831            "real selector: css={css:?}"
832        );
833        let a = compute_css_analytics(&css).expect("parses");
834        assert_eq!(a.rule_count, 1);
835    }
836
837    #[test]
838    fn stylex_create_is_atomic_one_bucket_per_key() {
839        let src = "import * as stylex from '@stylexjs/stylex';\n\
840                   export const styles = stylex.create({\n\
841                   root: { color: 'red', padding: 16 },\n\
842                   card: { color: 'blue' },\n\
843                   });\n";
844        let s = sheets(src);
845        assert!(s.structural.is_none(), "stylex is atomic, not structural");
846        let css = s.atomic.expect("stylex.create is atomic");
847        assert!(css.contains("color:red;"), "css={css:?}");
848        assert!(css.contains("padding:16px;"), "css={css:?}");
849        assert!(css.contains("color:blue;"), "second bucket: css={css:?}");
850        let a = compute_css_analytics(&css).expect("parses");
851        assert!(a.rule_count >= 2, "two buckets: {a:?}");
852    }
853
854    #[test]
855    fn panda_css_from_styled_system_is_atomic() {
856        let src = "import { css } from '../styled-system/css';\n\
857                   const c = css({ display: 'flex', gap: 8 });\n";
858        let s = sheets(src);
859        let css = s.atomic.expect("panda css is atomic");
860        assert!(css.contains("display:flex;"), "css={css:?}");
861        assert!(css.contains("gap:8px;"), "css={css:?}");
862    }
863
864    #[test]
865    fn emotion_css_and_styled_are_structural() {
866        let src = "import { css } from '@emotion/react';\n\
867                   import styled from '@emotion/styled';\n\
868                   const a = css({ color: 'red' });\n\
869                   const B = styled.div({ fontWeight: 700 });\n";
870        let css = sheets(src).structural.expect("emotion is structural");
871        assert!(css.contains("color:red;"), "css={css:?}");
872        assert!(css.contains("font-weight:700;"), "styled.div: css={css:?}");
873    }
874
875    #[test]
876    fn styled_call_form_is_lifted() {
877        let src = "import styled from '@emotion/styled';\n\
878                   const Primary = styled(Button)({ fontWeight: 700 });\n";
879        let css = sheets(src)
880            .structural
881            .expect("styled(Component)({}) lifted");
882        assert!(css.contains("font-weight:700;"), "css={css:?}");
883    }
884
885    #[test]
886    fn dynamic_value_is_dropped_to_structural_partial() {
887        let src = "import { style } from '@vanilla-extract/css';\n\
888                   import { theme } from './theme';\n\
889                   const x = style({ color: theme.primary, padding: 8, margin: 4, top: 1, left: 2 });\n";
890        let s = sheets(src);
891        // The dynamic `color` is dropped; the bucket has a dropped decl so it
892        // lands in structural_partial (duplicate-fingerprint suppressed by the
893        // engine), NOT the clean structural sheet.
894        assert!(s.structural.is_none(), "bucket had a drop: {s:?}");
895        let css = s.structural_partial.expect("partial");
896        assert!(
897            !css.contains("fallowinterp"),
898            "no placeholder, value dropped: {css:?}"
899        );
900        assert!(
901            !css.contains("primary"),
902            "dynamic member not serialized: {css:?}"
903        );
904        assert!(css.contains("padding:8px;"), "static survives: {css:?}");
905        let a = compute_css_analytics(&css).expect("must parse, not None");
906        assert_eq!(a.important_declarations, 0, "no invented !important: {a:?}");
907    }
908
909    #[test]
910    fn spread_and_computed_key_dropped() {
911        let src = "import { style } from '@vanilla-extract/css';\n\
912                   const base = {};\n\
913                   const k = 'color';\n\
914                   const x = style({ ...base, [k]: 'red', padding: 8, margin: 4, top: 1 });\n";
915        let s = sheets(src);
916        // Spread + computed key are drops -> structural_partial.
917        let css = s.structural_partial.expect("partial");
918        assert!(css.contains("padding:8px;"), "static survives: {css:?}");
919    }
920
921    #[test]
922    fn cva_variants_map_is_not_serialized_as_css() {
923        // `cva` from class-variance-authority is NOT a recognized CSS-in-JS
924        // import, so it must not fire at all even though `cva` is a Panda name.
925        let cva = "import { cva } from 'class-variance-authority';\n\
926                   const button = cva('base', { variants: { size: { sm: 'text-sm' } } });\n";
927        assert!(
928            sheets(cva).is_empty(),
929            "unrelated cva must not fire: {:?}",
930            sheets(cva)
931        );
932
933        // Panda `cva` from styled-system: only `base` is CSS; `variants` (a config
934        // map of class objects) must be dropped, never serialized as garbage.
935        let panda = "import { cva } from '../styled-system/css';\n\
936                     const button = cva({ base: { color: 'red', padding: 8, margin: 4, top: 1 }, variants: { size: { sm: { fontSize: 12 } } } });\n";
937        let s = sheets(panda);
938        let css = s.atomic.expect("panda cva base is atomic");
939        assert!(css.contains("color:red;"), "base serialized: {css:?}");
940        assert!(
941            !css.contains("size"),
942            "variants config not serialized: {css:?}"
943        );
944        let a = compute_css_analytics(&css).expect("parses cleanly");
945        assert!(
946            a.notable_rules.is_empty(),
947            "no garbled structural finding: {a:?}"
948        );
949    }
950
951    #[test]
952    fn panda_cva_and_class_variance_authority_cva_coexist() {
953        // Both `cva` names can appear in one file only under distinct local
954        // aliases (duplicate bindings are a JS error). class-variance-authority is
955        // filtered out before the import map, so only Panda's binding is tracked:
956        // its `base` lifts to atomic CSS while the class-name builder stays inert.
957        let src = "import { cva } from '../styled-system/css';\n\
958                   import { cva as cn } from 'class-variance-authority';\n\
959                   const a = cva({ base: { color: 'red' } });\n\
960                   const b = cn('base', { variants: { size: { sm: 'text-sm' } } });\n";
961        let css = sheets(src).atomic.expect("panda cva base is atomic");
962        assert!(css.contains("color:red;"), "panda base lifted: {css:?}");
963        assert!(!css.contains("text-sm"), "cva-lib not serialized: {css:?}");
964    }
965
966    #[test]
967    fn local_helper_with_recognized_name_does_not_fire() {
968        // A local `const css = ...` with no recognized import must never fire,
969        // even though `css` is a recognized library call name.
970        let src = "const css = (o) => o;\n\
971                   const x = css({ color: 'red', padding: 8 });\n";
972        assert!(
973            sheets(src).is_empty(),
974            "local css helper must not fire: {:?}",
975            sheets(src)
976        );
977    }
978
979    #[test]
980    fn type_only_import_does_not_open_the_gate() {
981        let src = "import type { style } from '@vanilla-extract/css';\n\
982                   const x = style({ color: 'red' });\n";
983        assert!(
984            sheets(src).is_empty(),
985            "type-only import must not open provenance: {:?}",
986            sheets(src)
987        );
988    }
989
990    #[test]
991    fn all_dynamic_bucket_emits_no_empty_rule() {
992        let src = "import { style } from '@vanilla-extract/css';\n\
993                   import { v } from './v';\n\
994                   const x = style({ color: v.a, background: v.b });\n";
995        let s = sheets(src);
996        // Every value dynamic -> body empty -> bucket dropped entirely, no empty
997        // `.fallow-css-in-js{}` rule in any sheet.
998        assert!(s.is_empty(), "all-dynamic bucket dropped entirely: {s:?}");
999    }
1000
1001    #[test]
1002    fn aliased_named_import_still_recognized() {
1003        // `import { style as s }` dispatches on the canonical name, not the alias.
1004        let src = "import { style as s, globalStyle as gs } from '@vanilla-extract/css';\n\
1005                   export const a = s({ color: 'red' });\n\
1006                   gs('html', { margin: 0 });\n";
1007        let s = sheets(src);
1008        let css = s.structural.expect("aliased style/globalStyle recognized");
1009        assert!(css.contains("color:red;"), "aliased style fired: {css:?}");
1010        assert!(
1011            css.contains("html{margin:0;}"),
1012            "aliased globalStyle fired: {css:?}"
1013        );
1014    }
1015
1016    #[test]
1017    fn emotion_css_default_import_recognized() {
1018        // `@emotion/css` default export IS the css function.
1019        let src = "import css from '@emotion/css';\n\
1020                   const a = css({ color: 'red' });\n";
1021        let css = sheets(src)
1022            .structural
1023            .expect("default css import recognized");
1024        assert!(css.contains("color:red;"), "css={css:?}");
1025    }
1026
1027    #[test]
1028    fn emotion_css_default_import_aliased_recognized() {
1029        // The `@emotion/css` default css function fires under ANY local alias, not
1030        // only the conventional `css` name (canonical-role dispatch on the lib).
1031        let src = "import emo from '@emotion/css';\n\
1032                   const a = emo({ color: 'red' });\n";
1033        let css = sheets(src)
1034            .structural
1035            .expect("aliased default css import recognized");
1036        assert!(css.contains("color:red;"), "css={css:?}");
1037    }
1038
1039    #[test]
1040    fn non_decimal_numeric_literals_become_valid_css() {
1041        // Hex / scientific literals render from their parsed value, never the raw
1042        // `0xFF` / `1e3` source text (which the CSS parser would reject).
1043        let src = "import { style } from '@vanilla-extract/css';\n\
1044                   const x = style({ padding: 0xFF, zIndex: 1e3 });\n";
1045        let css = sheets(src).structural.expect("structural");
1046        assert!(
1047            css.contains("padding:255px;"),
1048            "hex -> decimal px: css={css:?}"
1049        );
1050        assert!(
1051            css.contains("z-index:1000;"),
1052            "scientific -> decimal: css={css:?}"
1053        );
1054        assert!(compute_css_analytics(&css).is_some(), "valid CSS");
1055    }
1056
1057    #[test]
1058    fn custom_property_numeric_value_keeps_no_unit() {
1059        // A numeric custom-property value must NOT gain an implicit `px`: emotion
1060        // (`!isCustomProperty`) and React both leave `--x: 8` unitless, so adding
1061        // `px` would fabricate a unit the bundler never emits.
1062        let src = "import { css } from '@emotion/react';\n\
1063                   const g = css({ ':root': { '--space': 8, '--ratio': 1.5 }, padding: 8 });\n";
1064        // `:root` is a selector key -> nested rule with the custom properties.
1065        let sheet = sheets(src)
1066            .structural
1067            .or_else(|| sheets(src).structural_partial)
1068            .expect("structural output");
1069        assert!(
1070            sheet.contains("--space:8;"),
1071            "custom prop keeps no unit: {sheet:?}"
1072        );
1073        assert!(
1074            sheet.contains("--ratio:1.5;"),
1075            "custom prop float unchanged: {sheet:?}"
1076        );
1077        // A normal property still gets px.
1078        assert!(
1079            sheet.contains("padding:8px;"),
1080            "normal prop still px: {sheet:?}"
1081        );
1082    }
1083
1084    #[test]
1085    fn ms_vendor_prefix_kebabs_with_leading_dash() {
1086        assert_eq!(kebab_case("msFlexAlign"), "-ms-flex-align");
1087        assert_eq!(kebab_case("WebkitBoxShadow"), "-webkit-box-shadow");
1088        assert_eq!(kebab_case("backgroundColor"), "background-color");
1089        // `msg`-prefixed non-vendor names are not mangled.
1090        assert_eq!(kebab_case("msgType"), "msg-type");
1091    }
1092
1093    #[test]
1094    fn negative_numbers_handled() {
1095        let src = "import { style } from '@vanilla-extract/css';\n\
1096                   const x = style({ marginTop: -8, zIndex: -1 });\n";
1097        let css = sheets(src).structural.expect("structural");
1098        assert!(css.contains("margin-top:-8px;"), "css={css:?}");
1099        assert!(
1100            css.contains("z-index:-1;"),
1101            "unitless negative: css={css:?}"
1102        );
1103    }
1104
1105    #[test]
1106    fn none_without_any_object_css_in_js() {
1107        assert!(sheets("const x = 1; function f() {}").is_empty());
1108        assert!(sheets("import React from 'react'; const x = <div/>;").is_empty());
1109    }
1110
1111    #[test]
1112    fn line_numbers_map_back_to_source() {
1113        // The `color` declaration's bucket is the `style({...})` object starting on
1114        // source line 3; the lifted sheet must keep a non-blank token at line 3.
1115        let src = "import { style } from '@vanilla-extract/css';\n\
1116                   \n\
1117                   const a = style({\n\
1118                   color: 'red',\n\
1119                   });\n";
1120        let css = sheets(src).structural.expect("structural");
1121        let pos = css.find("color").expect("color present");
1122        let css_line = 1 + css[..pos].bytes().filter(|&b| b == b'\n').count();
1123        assert_eq!(
1124            css_line, 3,
1125            "bucket maps to the style() object line: css={css:?}"
1126        );
1127    }
1128
1129    #[test]
1130    fn multibyte_content_value_preserved() {
1131        let src = "import { style } from '@vanilla-extract/css';\n\
1132                   const x = style({ content: '\"café 日本 €\"', fontFamily: '\"Ñoño\"' });\n";
1133        let css = sheets(src).structural.expect("structural");
1134        assert!(
1135            css.contains("café 日本 €"),
1136            "multibyte preserved: css={css:?}"
1137        );
1138        assert!(
1139            compute_css_analytics(&css).is_some(),
1140            "valid UTF-8 / parses"
1141        );
1142    }
1143
1144    #[test]
1145    fn distinct_colors_fall_out_of_object_styles() {
1146        let src = "import * as stylex from '@stylexjs/stylex';\n\
1147                   const s = stylex.create({ a: { color: 'red' }, b: { color: 'blue' }, c: { color: 'red' } });\n";
1148        let css = sheets(src).atomic.expect("atomic");
1149        let a = compute_css_analytics(&css).expect("parses");
1150        assert_eq!(a.colors.len(), 2, "distinct colors counted: {:?}", a.colors);
1151    }
1152
1153    #[test]
1154    fn multi_bucket_padding_uses_key_line() {
1155        // Each stylex.create bucket pads to its KEY line, so two buckets do not
1156        // collapse onto the call line.
1157        let src = "import * as stylex from '@stylexjs/stylex';\n\
1158                   const s = stylex.create({\n\
1159                   root: { color: 'red' },\n\
1160                   card: { color: 'blue' },\n\
1161                   });\n";
1162        let css = sheets(src).atomic.expect("atomic");
1163        let red = css.find("color:red").expect("root present");
1164        let blue = css.find("color:blue").expect("card present");
1165        let red_line = 1 + css[..red].bytes().filter(|&b| b == b'\n').count();
1166        let blue_line = 1 + css[..blue].bytes().filter(|&b| b == b'\n').count();
1167        assert_eq!(red_line, 3, "root on its key line: css={css:?}");
1168        assert_eq!(blue_line, 4, "card on its own key line: css={css:?}");
1169    }
1170}