diff --git a/e2e/next-app/package.json b/e2e/next-app/package.json index df6b98ba..e96f9c3f 100644 --- a/e2e/next-app/package.json +++ b/e2e/next-app/package.json @@ -7,8 +7,9 @@ "build": "next build", "verify:build": "bash ../../scripts/verify/build-consumer.sh", "verify:assert": "bash ../../scripts/verify/assert-consumer.sh e2e/next-app/.next e2e/next-app/scripts/assert-build.ts", - "verify": "vp run @animus-ui/next-app#verify:build && vp run @animus-ui/next-app#verify:assert", - "start": "next start" + "verify": "vp run @animus-ui/next-app#verify:build && vp run @animus-ui/next-app#verify:tsc && vp run @animus-ui/next-app#verify:assert", + "start": "next start", + "verify:tsc": "bash ../../scripts/verify/tsc-consumer.sh e2e/next-app" }, "dependencies": { "@animus-ui/next-plugin": "workspace:*", diff --git a/e2e/next-app/scripts/assert-build.ts b/e2e/next-app/scripts/assert-build.ts index 33914b87..3a6d0694 100644 --- a/e2e/next-app/scripts/assert-build.ts +++ b/e2e/next-app/scripts/assert-build.ts @@ -1,6 +1,7 @@ import { AssertionError, assertClassNameFormat, + assertConditionsInsideLayers, assertKeyframesExtracted, assertLayerOrder, assertNoEmotionImports, @@ -104,6 +105,11 @@ async function main(): Promise { assertNoPlaceholders(css); + // Guardrail G2 (modern-css-surface): condition at-rules must nest inside a + // named @layer block. Non-vacuous here — the imported test-ds Card emits raw + // @container / @media / @supports rules into this build's CSS. + assertConditionsInsideLayers(css); + // Keyframes extracted through the webpack adapter — the fixture declares // `animations = keyframes({ fadeIn, pulse })` in src/ds.ts; the assertion // proves both blocks land in @layer anm-global, both animation-name refs diff --git a/e2e/vite-app/package.json b/e2e/vite-app/package.json index c8dc426a..0b3300c7 100644 --- a/e2e/vite-app/package.json +++ b/e2e/vite-app/package.json @@ -8,11 +8,12 @@ "verify:build": "bash ../../scripts/verify/build-consumer.sh", "verify:assert": "bash ../../scripts/verify/assert-consumer.sh e2e/vite-app/dist e2e/vite-app/scripts/assert-build.ts", "verify:dry-run": "bash ../../scripts/verify/dry-run-worker.sh e2e/vite-app '@animus-ui/vite-app' e2e/vite-app/dist animus-vite-canary '@animus-ui/vite-app#verify:build'", - "verify": "vp run @animus-ui/vite-app#verify:build && vp run @animus-ui/vite-app#verify:assert && vp run @animus-ui/vite-app#verify:dry-run", + "verify": "vp run @animus-ui/vite-app#verify:build && vp run @animus-ui/vite-app#verify:tsc && vp run @animus-ui/vite-app#verify:assert && vp run @animus-ui/vite-app#verify:dry-run", "preview": "vite preview", "cf:deploy": "wrangler deploy", "cf:upload": "wrangler versions upload", - "cf:dry-run": "wrangler deploy --dry-run" + "cf:dry-run": "wrangler deploy --dry-run", + "verify:tsc": "bash ../../scripts/verify/tsc-consumer.sh e2e/vite-app" }, "dependencies": { "@animus-ui/system": "workspace:*", diff --git a/e2e/vite-app/scripts/assert-build.ts b/e2e/vite-app/scripts/assert-build.ts index 1a2faa80..ecd4b482 100644 --- a/e2e/vite-app/scripts/assert-build.ts +++ b/e2e/vite-app/scripts/assert-build.ts @@ -1,6 +1,7 @@ import { AssertionError, assertClassNameFormat, + assertConditionsInsideLayers, assertKeyframesExtracted, assertLayerOrder, assertNoEmotionImports, @@ -164,6 +165,38 @@ async function main(): Promise { assertClassNameFormat(css, { prefix: 'animus-' }); assertGlobalBaseline(css); + // Guardrail G2 (modern-css-surface): every @container / @supports / + // non-breakpoint @media condition at-rule must nest inside a named @layer + // block. Runs NON-VACUOUSLY here — the test-ds Card (raw @container/@media/ + // @supports) and the app Card (registered `_motionReduce` alias) both emit + // condition rules into this dist. + assertConditionsInsideLayers(css); + + // Container-unit emission pin (inc 11, spec "Container-relative units on + // scale-typed properties"): the test-ds Card authors `gap: '2cqi'` on a + // strict space-scale prop inside a nested @container block — the unit + // string must ship verbatim (the resolver emits it; minifiers may reformat + // the prelude but not the declaration value). + if (!css.includes('gap:2cqi') && !css.includes('gap: 2cqi')) { + throw new AssertionError( + 'container-unit emission pin: expected `gap:2cqi` (verbatim container unit on a strict scale prop) in the dist CSS', + { probe: 'gap:2cqi' } + ); + } + + // Built-in condition composite witness (inc 06): the app Card authors + // `_osDark` WITHOUT registering it — it must resolve through the DEFAULT + // built-in set across the full registry → manifest → plugin → engine wire. + if ( + !css.includes('prefers-color-scheme:dark') && + !css.includes('prefers-color-scheme: dark') + ) { + throw new AssertionError( + 'built-in condition pin: expected an unregistered `_osDark` block to emit `@media (prefers-color-scheme: dark)` via the default built-in set', + { probe: 'prefers-color-scheme:dark' } + ); + } + // Keyframes extracted through the rollup (Vite) adapter — fixture declares // `animations = keyframes({ fadeIn, pulse })` in src/ds.ts; the assertion // proves both blocks land in @layer anm-global, both animation-name refs diff --git a/e2e/vite-app/src/components/Card.tsx b/e2e/vite-app/src/components/Card.tsx index 112f7337..7d1bc856 100644 --- a/e2e/vite-app/src/components/Card.tsx +++ b/e2e/vite-app/src/components/Card.tsx @@ -8,5 +8,18 @@ export const Card = ds border: '1px solid', borderColor: 'border', p: 16, + // Registered condition-alias block (modern-css-surface inc 03). Resolves + // through the app's `_motionReduce` registration to + // `@media (prefers-reduced-motion: reduce)` — the aliased-emission proof. + _motionReduce: { + transition: 'none', + }, + // UNREGISTERED built-in (inc 06 composite witness): this app never + // registers `_osDark` — it resolves through the DEFAULT built-in set via + // the full SystemBuilder → manifest → plugin glue → engine path. The + // assert lane pins its emission. + _osDark: { + borderColor: 'border', + }, }) .asElement('div'); diff --git a/e2e/vite-app/src/components/Pulse.tsx b/e2e/vite-app/src/components/Pulse.tsx index 2f986e54..c9c80332 100644 --- a/e2e/vite-app/src/components/Pulse.tsx +++ b/e2e/vite-app/src/components/Pulse.tsx @@ -6,7 +6,7 @@ export const Pulse = ds color: 'text', px: 16, py: 8, - borderRadius: 4, + borderRadius: '4px', animationName: animations.pulse, animationDuration: '2s', animationTimingFunction: 'ease-in-out', @@ -20,7 +20,7 @@ export const Fade = ds color: 'text', px: 16, py: 8, - borderRadius: 4, + borderRadius: '4px', animationName: animations.fadeIn, animationDuration: '1s', animationFillMode: 'forwards', diff --git a/e2e/vite-app/src/ds.ts b/e2e/vite-app/src/ds.ts index 4a758fe8..12f9ce79 100644 --- a/e2e/vite-app/src/ds.ts +++ b/e2e/vite-app/src/ds.ts @@ -79,6 +79,13 @@ export const { .addGroup('text', typography) .addGroup('surface', { ...color, ...border }) .addGroup('positioning', positioning) + // Condition alias registry (modern-css-surface inc 03). Registered here so + // aliased condition blocks (e.g. `_motionReduce`) resolve during THIS app's + // extraction — proving the manifest `conditionAliases` field flows through + // the full plugin glue (loadSystemConfig → analyze args → engine adapter). + .addConditions({ + _motionReduce: '@media (prefers-reduced-motion: reduce)', + }) .build(); export const globalStyles = createGlobalStyles({ diff --git a/openspec/specs/arch-css-structural-gates/spec.md b/openspec/specs/arch-css-structural-gates/spec.md new file mode 100644 index 00000000..e9fea481 --- /dev/null +++ b/openspec/specs/arch-css-structural-gates/spec.md @@ -0,0 +1,45 @@ +# arch-css-structural-gates Specification + +## Purpose +TBD - created by archiving change modern-css-surface. Update Purpose after archive. +## Requirements +### Requirement: Condition at-rules gated inside layer blocks + +Consumer verification lanes SHALL run a structural assertion that every `@container`, `@supports`, and non-breakpoint `@media` at-rule in assembled dist CSS sits inside a named `@layer` block, and the vite-app and next-app assert lanes SHALL invoke it non-vacuously (their dists contain condition at-rules). + +#### Scenario: Vite-app lane runs the containment check + +- **WHEN** the following command is run + +```bash +vp run "@animus-ui/vite-app#verify:assert" +``` + +- **THEN** it exits 0 with `assertConditionsInsideLayers` invoked against a dist whose CSS contains at least one `@container`, one `@supports`, and one non-breakpoint `@media` rule +- **AND** a hoisted (outside-`@layer`) condition at-rule in that dist causes the command to exit non-zero + +#### Scenario: Next-app lane runs the containment check + +- **WHEN** the following command is run + +```bash +vp run "@animus-ui/next-app#verify:assert" +``` + +- **THEN** it exits 0 with the same assertion invoked against the Next dist CSS + +### Requirement: Registered property rules gated in the variables part + +The showcase assert lane SHALL pin the registered-`@property` wire end-to-end: the dist CSS contains the theme's registered `@property` rule, positioned before the first `@layer` block. + +#### Scenario: Showcase lane pins @property placement + +- **WHEN** the following command is run + +```bash +vp run "@animus-ui/showcase#verify:assert" +``` + +- **THEN** it exits 0 with the dist CSS containing `@property --current-bg` before the first `@layer` block +- **AND** removing the registration from the showcase theme (or the wire dropping it) causes the command to exit non-zero + diff --git a/openspec/specs/container-query-support/spec.md b/openspec/specs/container-query-support/spec.md new file mode 100644 index 00000000..5df55d74 --- /dev/null +++ b/openspec/specs/container-query-support/spec.md @@ -0,0 +1,91 @@ +# container-query-support Specification + +## Purpose +TBD - created by archiving change modern-css-surface. Update Purpose after archive. +## Requirements +### Requirement: Container condition block emission + +Style objects SHALL accept raw `@container` condition strings as block keys, and each such block SHALL emit a `@container` rule wrapping the component's class selector inside the same `@layer` block that owns the component's base rule. + +#### Scenario: Basic container condition + +- **WHEN** a style object contains `'@container (min-width: 400px)': { fontSize: '18px' }` +- **THEN** the emitted CSS contains `@container (min-width: 400px) { .ClassName { font-size: 18px; } }` +- **AND** the `@container` rule appears inside the same `@layer` block as the component's base rule + +#### Scenario: Token and shorthand resolution inside container blocks + +- **WHEN** a style object contains `'@container (min-width: 400px)': { bg: 'primary', p: 16 }` +- **THEN** the emitted container rule contains `background-color: var(--color-primary)` and `padding: 1rem` + +#### Scenario: No rule emitted for empty container block + +- **WHEN** a style object contains `'@container (min-width: 400px)': {}` +- **THEN** no `@container` rule is emitted for that key + +### Requirement: Named container conditions + +Container condition keys SHALL support an optional container name, and the name SHALL be preserved verbatim in the emitted at-rule prelude. + +#### Scenario: Named container query + +- **WHEN** a style object contains `'@container card (min-width: 400px)': { display: 'grid' }` +- **THEN** the emitted CSS contains `@container card (min-width: 400px) { ... }` + +### Requirement: Container establishment via declarations + +Container establishment properties SHALL be accepted as ordinary style declarations: `containerType`, `containerName`, and the `container` shorthand SHALL emit their corresponding CSS declarations on the establishing element's rule. + +#### Scenario: Establishing a named container + +- **WHEN** a style object contains `{ containerType: 'inline-size', containerName: 'card' }` +- **THEN** the emitted base rule contains `container-type: inline-size;` and `container-name: card;` + +#### Scenario: Container shorthand + +- **WHEN** a style object contains `{ container: 'card / inline-size' }` +- **THEN** the emitted base rule contains `container: card / inline-size;` + +### Requirement: Container conditions compose with selector blocks + +Container condition blocks SHALL admit nested selector-alias and raw-selector blocks, and the emitted rule SHALL nest the composed selector inside the `@container` at-rule. + +#### Scenario: Selector alias inside a container block + +- **WHEN** a style object contains `'@container (min-width: 400px)': { _hover: { bg: 'primary' } }` +- **THEN** the emitted CSS contains `@container (min-width: 400px) { .ClassName:hover { background-color: var(--color-primary); } }` + +### Requirement: Container-relative units on scale-typed properties + +Strict scale-typed props (props bound to a token scale without `strict: false`) SHALL accept the six container-relative length units — `cqw`, `cqi`, `cqh`, `cqb`, `cqmin`, `cqmax` — as string values carrying a numeric prefix, at the type level, in both plain and responsive-value-map positions, and each such value SHALL be emitted verbatim. Admission SHALL NOT widen a strict scale prop to arbitrary strings: only these six unit suffixes with a numeric prefix are accepted; other unit strings and bare (numberless) suffixes SHALL remain rejected, and scale keys SHALL continue to resolve. + +#### Scenario: Container unit on a strict scale prop + +- **WHEN** a style object contains `p: '2cqi'` on a strict space-scale prop +- **THEN** the style object typechecks +- **AND** the emitted CSS contains `padding: 2cqi;` (the unit string emitted verbatim) + +#### Scenario: Container unit in a responsive-map slot + +- **WHEN** a style object contains `p: { _: 8, sm: '2cqi' }` on a strict space-scale prop +- **THEN** the style object typechecks, with the scale key `8` resolving to its token value and `2cqi` carried into the `sm` slot verbatim + +#### Scenario: Non-container unit string rejected + +- **WHEN** a style object contains `p: '2vw'` on a strict space-scale prop +- **THEN** the style object fails to typecheck (viewport units are not admitted; strictness is preserved) + +#### Scenario: Bare container-unit suffix rejected + +- **WHEN** a style object contains `p: 'cqi'` (no numeric part) on a strict space-scale prop +- **THEN** the style object fails to typecheck + +### Requirement: Registered container condition aliases + +Condition aliases registered with a `@container` value SHALL resolve as block keys to the registered container condition. + +#### Scenario: Container alias in a style object + +- **WHEN** the system is configured with `.addConditions({ _cardSm: '@container card (min-width: 400px)' })` and a style object contains `_cardSm: { display: 'grid' }` +- **THEN** the emitted CSS contains `@container card (min-width: 400px) { .ClassName { display: grid; } }` + diff --git a/openspec/specs/contextual-vars/spec.md b/openspec/specs/contextual-vars/spec.md index ddb958d7..637ac504 100644 --- a/openspec/specs/contextual-vars/spec.md +++ b/openspec/specs/contextual-vars/spec.md @@ -1,52 +1,19 @@ ## Purpose Requirements for the `contextual-vars` capability: ThemeBuilder addContextualVars method; Phantom type merging; Contextual var serialization; and 2 more. - ## Requirements - -### Requirement: ThemeBuilder addContextualVars method - -The ThemeBuilder SHALL provide an `addContextualVars` method that declares phantom scale members resolving to CSS custom properties. The method SHALL accept a config object with `scale` (an existing scale name in `keyof T`) and `vars` (an object mapping contextual var names to CSS custom property names). Object keys SHALL narrow to literal types without requiring `as const` at the callsite. - -#### Scenario: Basic contextual var declaration - -- **WHEN** the theme chain calls `.addContextualVars({ scale: 'colors', vars: { 'current-bg': '--current-bg' } })` -- **THEN** `keyof TokenScales['colors']` SHALL include `'current-bg'` -- **AND** existing color keys SHALL remain unchanged - -#### Scenario: Multiple contextual vars on one scale - -- **WHEN** the theme chain calls `.addContextualVars({ scale: 'colors', vars: { 'current-bg': '--current-bg', 'current-border-color': '--current-border-color' } })` -- **THEN** `keyof TokenScales['colors']` SHALL include both `'current-bg'` and `'current-border-color'` - -#### Scenario: Type narrowing without as const - -- **WHEN** a user writes `.addContextualVars({ scale: 'colors', vars: { 'current-bg': '--current-bg' } })` without `as const` -- **THEN** the var names SHALL be inferred as literal string types, not widened to `string` - -#### Scenario: Scale must exist - -- **WHEN** `.addContextualVars({ scale: 'bogus', vars: { ... } })` is called with a scale not in `keyof T` -- **THEN** TypeScript SHALL produce a type error on `scale` - -#### Scenario: Chainable with checkpoint - -- **WHEN** `.addContextualVars()` is called in the builder chain -- **THEN** it SHALL return a new `ThemeBuilder` instance via `#checkpoint` with the widened type -- **AND** subsequent chain methods SHALL see the updated type - ### Requirement: Phantom type merging Contextual var names SHALL exist as keys in the scale's type but SHALL NOT exist as values in the runtime theme object. They are type-only (phantom) members. #### Scenario: Type includes phantom keys -- **WHEN** `addContextualVars` adds `'current-bg'` to the colors scale +- **WHEN** `declareContextualVars` adds `'current-bg'` to the colors scale - **THEN** `'current-bg'` SHALL be a valid value for any prop with `scale: 'colors'` (e.g., `bg`, `color`, `borderColor`, `fill`) #### Scenario: Runtime theme unchanged -- **WHEN** `addContextualVars` is called +- **WHEN** `declareContextualVars` is called - **THEN** the runtime theme object passed to `#checkpoint` SHALL be identical to the theme before the call — no new keys added to the runtime object #### Scenario: Phantom keys do not appear in manifest @@ -56,18 +23,23 @@ Contextual var names SHALL exist as keys in the scale's type but SHALL NOT exist ### Requirement: Contextual var serialization -The theme serialization SHALL include a `contextualVars` registry mapping scale names to their contextual var entries (name → CSS custom property). +The theme serialization SHALL include a `contextualVars` registry mapping scale names to readonly arrays of contextual var names (names only; each CSS custom property is derived as `--{name}` at consumption time). #### Scenario: Serialized output includes registry -- **WHEN** the theme has `.addContextualVars({ scale: 'colors', vars: { 'current-bg': '--current-bg' } })` -- **THEN** `theme._contextualVars` SHALL contain `{ colors: { 'current-bg': '--current-bg' } }` +- **WHEN** the theme has `.declareContextualVars({ colors: ['current-bg'] })` +- **THEN** the serialized contextual-vars registry SHALL contain `{ "colors": ["current-bg"] }` #### Scenario: Registry available to Rust extractor - **WHEN** the vite plugin evaluates the theme and passes data to the Rust extractor - **THEN** the contextual var registry SHALL be included in the serialized theme data alongside `scalesJson` and `variableMapJson` +#### Scenario: Rust resolver function signatures + +- **WHEN** the contextual vars registry is loaded from theme data +- **THEN** it SHALL be accessible to `resolve_value`, `resolve_flat_styles`, and `resolve_single_alias` via a shared resolver context — not individual function parameters + ### Requirement: Rust contextual var resolution The Rust extractor SHALL resolve contextual var names to their CSS custom property values when encountered as style values. @@ -104,19 +76,51 @@ The Rust extractor SHALL resolve contextual var names to their CSS custom proper ### Requirement: Ordering constraint -`addContextualVars` SHALL only accept scales that already exist in the theme type. It MUST be called after the target scale is added (e.g., after `addColors` for the colors scale). +`declareContextualVars` SHALL only accept scales that already exist in the theme type. It MUST be called after the target scale is added (e.g., after `addColors` for the colors scale). #### Scenario: Called after addColors -- **WHEN** `addContextualVars({ scale: 'colors', ... })` is called after `addColors()` +- **WHEN** `declareContextualVars({ colors: [...] })` is called after `addColors()` - **THEN** it SHALL compile and add phantom keys to the colors scale type #### Scenario: Called before addColors -- **WHEN** `addContextualVars({ scale: 'colors', ... })` is called before `addColors()` -- **THEN** TypeScript SHALL produce a type error because `'colors'` is not yet in `keyof T` +- **WHEN** `declareContextualVars({ colors: [...] })` is called before the colors scale exists on the theme type +- **THEN** TypeScript SHALL produce a type error on the scale key #### Scenario: Runtime ordering guard -- **WHEN** `addContextualVars` is called with a scale name that does not exist at runtime -- **THEN** it SHALL throw an error: `"addContextualVars: scale 'X' not found — call addColors or addScale first"` +- **WHEN** `declareContextualVars` is called with a scale name that does not exist at runtime +- **THEN** it SHALL throw an error: `"declareContextualVars: scale 'X' not found — call addColors or addScale first"` + +### Requirement: ThemeBuilder declareContextualVars method + +The ThemeBuilder SHALL provide a `declareContextualVars` method that declares phantom scale members resolving to CSS custom properties. The method SHALL accept a map from existing scale names (`keyof T`) to readonly arrays of contextual var names, with each var's CSS custom property derived as `--{name}`; it SHALL accept an optional second argument of per-var `@property` registration metadata whose keys are constrained to the declared var names. Var names SHALL narrow to literal types without requiring `as const` at the callsite. + +#### Scenario: Basic contextual var declaration + +- **WHEN** the theme chain calls `.declareContextualVars({ colors: ['current-bg'] })` +- **THEN** `keyof TokenScales['colors']` SHALL include `'current-bg'` +- **AND** existing color keys SHALL remain unchanged + +#### Scenario: Multiple contextual vars on one scale + +- **WHEN** the theme chain calls `.declareContextualVars({ colors: ['current-bg', 'current-border-color'] })` +- **THEN** `keyof TokenScales['colors']` SHALL include both `'current-bg'` and `'current-border-color'` + +#### Scenario: Type narrowing without as const + +- **WHEN** a user writes `.declareContextualVars({ colors: ['current-bg'] })` without `as const` +- **THEN** the var names SHALL be inferred as literal string types, not widened to `string` + +#### Scenario: Scale must exist + +- **WHEN** `.declareContextualVars({ bogus: ['x'] })` is called with a scale not in `keyof T` +- **THEN** TypeScript SHALL produce a type error on the scale key + +#### Scenario: Chainable with checkpoint + +- **WHEN** `.declareContextualVars()` is called in the builder chain +- **THEN** it SHALL return a new `ThemeBuilder` instance via `#checkpoint` with the widened type +- **AND** subsequent chain methods SHALL see the updated type + diff --git a/openspec/specs/media-condition-aliases/spec.md b/openspec/specs/media-condition-aliases/spec.md new file mode 100644 index 00000000..835d78af --- /dev/null +++ b/openspec/specs/media-condition-aliases/spec.md @@ -0,0 +1,95 @@ +# media-condition-aliases Specification + +## Purpose +TBD - created by archiving change modern-css-surface. Update Purpose after archive. +## Requirements +### Requirement: Raw media query block keys + +Style objects SHALL accept raw `@media` query strings as block keys beyond breakpoint-derived min-width queries — including media-feature queries (`prefers-*`), print, max-width, and range syntax — and each SHALL emit a matching `@media` rule wrapping the component's class selector inside the owning `@layer` block. + +#### Scenario: Reduced motion query + +- **WHEN** a style object contains `'@media (prefers-reduced-motion: reduce)': { transition: 'none' }` +- **THEN** the emitted CSS contains `@media (prefers-reduced-motion: reduce) { .ClassName { transition: none; } }` + +#### Scenario: Print query + +- **WHEN** a style object contains `'@media print': { display: 'none' }` +- **THEN** the emitted CSS contains `@media print { .ClassName { display: none; } }` + +#### Scenario: Range syntax query + +- **WHEN** a style object contains `'@media (400px <= width < 800px)': { gap: 8 }` +- **THEN** the emitted CSS contains `@media (400px <= width < 800px) { .ClassName { gap: 0.5rem; } }` + +### Requirement: Built-in media-feature condition aliases + +The system SHALL ship a default set of `_`-prefixed media-feature condition aliases covering motion, print, orientation, contrast, and OS color-scheme preferences: `_motionReduce`, `_motionSafe`, `_print`, `_portrait`, `_landscape`, `_moreContrast`, `_lessContrast`, `_osDark`, `_osLight`. Each SHALL map to its corresponding media-feature query. + +#### Scenario: Built-in motion alias + +- **WHEN** a style object contains `_motionReduce: { transition: 'none' }` with no user condition registrations +- **THEN** the emitted CSS contains `@media (prefers-reduced-motion: reduce) { .ClassName { transition: none; } }` + +#### Scenario: Built-in OS color-scheme alias + +- **WHEN** a style object contains `_osDark: { colorScheme: 'dark' }` +- **THEN** the emitted CSS contains `@media (prefers-color-scheme: dark) { .ClassName { color-scheme: dark; } }` + +#### Scenario: Built-in print alias + +- **WHEN** a style object contains `_print: { display: 'none' }` +- **THEN** the emitted CSS contains `@media print { .ClassName { display: none; } }` + +### Requirement: Registered media condition aliases + +Condition aliases registered with a `@media` value SHALL resolve as `_`-prefixed block keys to the registered media query. + +#### Scenario: Media alias in a style object + +- **WHEN** the system is configured with `.addConditions({ _motionReduce: '@media (prefers-reduced-motion: reduce)' })` and a style object contains `_motionReduce: { animation: 'none' }` +- **THEN** the emitted CSS contains `@media (prefers-reduced-motion: reduce) { .ClassName { animation: none; } }` + +#### Scenario: Token resolution inside alias blocks + +- **WHEN** a style object contains `_motionReduce: { bg: 'surface' }` with `_motionReduce` registered as a media condition alias +- **THEN** the emitted media rule contains `background-color: var(--color-surface)` + +### Requirement: Condition blocks recognized at every chain level + +Condition blocks — registered aliases and raw at-rule keys — SHALL be recognized in style objects at every builder chain position (`.styles()`, `.variant()`, `.compound()`, `.states()`), and the `@layer` for condition-emitted rules SHALL follow the chain position where the block appears. + +#### Scenario: Condition block in a variant + +- **WHEN** a variant definition contains `compact: { p: 4, '@media (prefers-reduced-motion: reduce)': { transition: 'none' } }` +- **THEN** the media rule for the variant's class emits in the variants layer alongside the variant's base styles + +### Requirement: Condition authoring is block-position only + +Condition aliases and raw at-rule keys SHALL be recognized only in block position. Value-position maps SHALL continue to admit only `_` and theme breakpoint keys, and a value-position object containing condition-alias keys SHALL NOT produce a condition-wrapped rule. + +#### Scenario: Breakpoint value maps unchanged + +- **WHEN** a style object contains `fontSize: { _: 14, sm: 16 }` +- **THEN** the emitted CSS contains the default declaration and a min-width media rule for `sm`, identical in shape to existing responsive emission + +#### Scenario: Condition alias in value position produces no media rule + +- **WHEN** a style object contains `fontSize: { _motionReduce: 12 }` +- **THEN** no `@media` rule is emitted for that value + +### Requirement: Breakpoint value maps on pass-through CSS properties + +Value-position breakpoint maps SHALL be accepted — at the type level and in emission — on pass-through CSS properties (properties not registered in the system's propConfig but typable as themed CSS props), with emission identical in shape to registered-prop responsive emission. + +#### Scenario: Responsive map on a pass-through property + +- **WHEN** a style object contains `outlineWidth: { _: '1px', sm: '2px' }` and `outlineWidth` is not registered in propConfig +- **THEN** the style object typechecks +- **AND** the emitted CSS contains `outline-width: 1px;` in the base rule and `outline-width: 2px;` in the `sm` min-width media rule + +#### Scenario: Pass-through responsive slot values emit as authored + +- **WHEN** a style object contains `outlineColor: { _: '{colors.primary}', sm: 'rebeccapurple' }` +- **THEN** the delimited token reference resolves to its `var(--color-…)` reference and the literal CSS color ships verbatim — responsive slots on pass-through properties carry values exactly as the same values would emit in plain position + diff --git a/openspec/specs/nested-selector-resolution/spec.md b/openspec/specs/nested-selector-resolution/spec.md new file mode 100644 index 00000000..ca667d84 --- /dev/null +++ b/openspec/specs/nested-selector-resolution/spec.md @@ -0,0 +1,63 @@ +# nested-selector-resolution Specification + +## Purpose +TBD - created by archiving change modern-css-surface. Update Purpose after archive. +## Requirements +### Requirement: Multi-level selector nesting resolves by parent composition + +Selector blocks nested inside other selector blocks SHALL resolve by composing the inner selector against the outer composed selector, with `&` referring to the outer composition. Content past one level of nesting SHALL emit under its fully composed selector rather than flattening into the parent's declarations. + +#### Scenario: Alias nested inside an alias + +- **WHEN** a style object contains `_hover: { _before: { opacity: 1 } }` +- **THEN** the emitted CSS contains a rule for `.ClassName:hover::before` containing `opacity: 1` +- **AND** the declaration does NOT appear directly in the `.ClassName:hover` rule + +#### Scenario: Raw descendant selector with nested alias + +- **WHEN** a style object contains `'& .icon': { _hover: { color: 'primary' } }` +- **THEN** the emitted CSS contains a rule for `.ClassName .icon:hover` containing `color: var(--color-primary)` + +#### Scenario: Alias with nested descendant selector + +- **WHEN** a style object contains `_hover: { '& .icon': { color: 'primary' } }` +- **THEN** the emitted CSS contains a rule for `.ClassName:hover .icon` containing `color: var(--color-primary)` + +### Requirement: Single-level nesting output shape preserved + +Style objects whose selector nesting is at most one level deep SHALL emit the same rule structure as they do without multi-level resolution: one rule per composed selector, in the same cascade order. + +#### Scenario: One-level alias unchanged + +- **WHEN** a style object contains `_hover: { bg: 'primary' }` and no deeper nesting +- **THEN** the emitted CSS contains exactly one `.ClassName:hover` rule with `background-color: var(--color-primary)` + +### Requirement: Conditions and selectors nest in both orders + +Condition blocks SHALL be accepted inside selector blocks and selector blocks inside condition blocks. In both authoring orders, the emitted at-rule SHALL wrap the rule for the fully composed selector. + +#### Scenario: Condition inside a selector block + +- **WHEN** a style object contains `_hover: { '@container (min-width: 400px)': { gap: 8 } }` +- **THEN** the emitted CSS contains `@container (min-width: 400px) { .ClassName:hover { gap: 0.5rem; } }` + +#### Scenario: Responsive value map inside a condition block + +- **WHEN** a style object contains `'@container (min-width: 400px)': { fontSize: { _: '14px', sm: '16px' } }` +- **THEN** the emitted CSS contains, inside the `@container` block, `.ClassName { font-size: 14px; }` followed by the `sm` min-width media rule containing `font-size: 16px` +- **AND** the breakpoint media rule nests inside the `@container` block, never the reverse + +#### Scenario: Stacked conditions wrap outermost-first + +- **WHEN** a style object contains `'@supports (display: grid)': { '@container (min-width: 400px)': { display: 'grid' } }` +- **THEN** the emitted CSS contains `@supports (display: grid) { @container (min-width: 400px) { .ClassName { display: grid; } } }` + +### Requirement: Deterministic ordering of nested rules + +Rules produced by nested selector resolution SHALL emit in a deterministic order: composed selectors are ordered by the OUTER selector segment's cascade position (known pseudo heads follow the existing selector cascade order regardless of authoring order); composed selectors sharing the same outer segment, and those with unrecognized outer segments, keep authoring order. + +#### Scenario: Nested rules follow outer cascade order + +- **WHEN** a style object contains `_active: { _before: { opacity: 0.5 } }` followed by `_hover: { _before: { opacity: 1 } }` +- **THEN** the `.ClassName:hover::before` rule emits before the `.ClassName:active::before` rule, regardless of the authoring order + diff --git a/openspec/specs/selector-alias-registry/spec.md b/openspec/specs/selector-alias-registry/spec.md index 5b3281e4..fe701760 100644 --- a/openspec/specs/selector-alias-registry/spec.md +++ b/openspec/specs/selector-alias-registry/spec.md @@ -3,9 +3,7 @@ ## Purpose TBD - created by archiving change selector-aliases. Update Purpose after archive. - ## Requirements - ### Requirement: Built-in selector alias set The system SHALL ship a default set of `_`-prefixed selector aliases covering interactive pseudo-states, ARIA/data attribute states, pseudo-elements, and positional pseudo-classes. Each alias SHALL map to one or more CSS selectors. @@ -134,3 +132,77 @@ This requirement exists because propConfig-registered props (e.g. `color`, `bg`) - **WHEN** a style object contains `_focusVisible: { outline: '2px solid {colors.primary}' }` (delimited `{scale.key}` token reference inside a shorthand string value) - **THEN** the emitted CSS contains `&:focus-visible { outline: 2px solid var(--color-primary) }` — this is the existing behavior preserved by this requirement. The new behavior is scoped to bare scale keys on pass-through props, NOT delimited token refs which already resolve. + +### Requirement: Condition alias registration via addConditions() + +`createSystem()` SHALL provide an `addConditions()` method that accepts a `Record` mapping `_`-prefixed alias names to at-rule condition strings beginning with `@media`, `@container`, or `@supports`. Registered condition aliases SHALL be recognized as block keys in style objects at every builder chain level. User-provided condition aliases SHALL merge with built-in condition aliases, and user aliases SHALL override built-ins of the same name. + +#### Scenario: Register and author with a condition alias + +- **WHEN** the system is configured with `.addConditions({ _motionReduce: '@media (prefers-reduced-motion: reduce)' })` and a style object contains `_motionReduce: { animation: 'none' }` +- **THEN** the emitted CSS contains `@media (prefers-reduced-motion: reduce) { .ClassName { animation: none; } }` + +#### Scenario: Override a built-in condition alias + +- **WHEN** a built-in condition alias `_print` exists and the system is configured with `.addConditions({ _print: '@media print and (min-resolution: 300dpi)' })` +- **THEN** `_print` blocks resolve to the user-provided query + +#### Scenario: Condition kind follows the at-rule prefix + +- **WHEN** `.addConditions()` registers values beginning with `@media`, `@container`, and `@supports` +- **THEN** each alias emits its block wrapped in the at-rule named by its value's prefix + +### Requirement: Non-condition values rejected by addConditions() + +`addConditions()` SHALL reject values that do not begin with a supported at-rule name (`@media`, `@container`, `@supports`) with a compile-time type error. + +#### Scenario: Selector string rejected + +- **WHEN** a system author writes `.addConditions({ _open: '&[data-state="open"]' })` +- **THEN** TypeScript produces a type error on the value + +### Requirement: Cross-registry name clashes rejected by addConditions() + +`addConditions()` SHALL fail loudly at system construction when an alias name is already present in the selector alias registry (built-in or user-registered). A name SHALL resolve through exactly one registry. + +#### Scenario: Condition alias clashing with a built-in selector alias + +- **WHEN** a system author writes `.addConditions({ _hover: '@media (hover: hover)' })` and `_hover` is a registered selector alias +- **THEN** system construction throws an error naming `_hover` and the registry it already belongs to + +### Requirement: Unregistered condition keys rejected at type level + +Style objects SHALL produce a compile-time type error for `_`-prefixed block keys that are neither registered condition aliases nor registered selector aliases, and for `@`-prefixed block keys that do not match the accepted at-rule key shapes. The error type SHALL name the offending key. + +#### Scenario: Misspelled condition alias rejected + +- **WHEN** `_motionReduce` is a registered condition alias and a style object contains `_motionReduc: { transition: 'none' }` +- **THEN** TypeScript produces a type error on the `_motionReduc` key + +#### Scenario: Misspelled at-rule prefix rejected + +- **WHEN** a style object contains `'@containr card (min-width: 400px)': { p: 8 }` +- **THEN** TypeScript produces a type error on that key + +#### Scenario: Registered aliases accepted at depth + +- **WHEN** `_cardSm` is a registered condition alias and a style object contains `_hover: { _cardSm: { p: 4 } }` +- **THEN** the style object typechecks +- **AND** scale-typed props inside the nested block retain their scale key validation + +### Requirement: Merged condition map available to extraction + +The system manifest SHALL include the full merged condition alias map — each entry carrying its condition string and cascade order — as a field distinct from the serialized selector map. The serialized selector map SHALL remain unchanged in shape and content for systems that register no conditions. + +#### Scenario: Manifest carries registered conditions + +- **WHEN** a system has custom condition aliases registered +- **THEN** the manifest includes the merged condition map for the extraction pipeline to consume +- **AND** the manifest's selector alias field is identical to what it would be without condition registrations + +#### Scenario: No user registrations serializes exactly the built-in set + +- **WHEN** a system registers no condition aliases +- **THEN** the manifest's condition map contains exactly the built-in condition alias set +- **AND** every other manifest field is byte-identical to the output produced before condition support existed + diff --git a/openspec/specs/stylesheet-assembly/spec.md b/openspec/specs/stylesheet-assembly/spec.md index ce48d9b3..7d67d8f1 100644 --- a/openspec/specs/stylesheet-assembly/spec.md +++ b/openspec/specs/stylesheet-assembly/spec.md @@ -1,9 +1,7 @@ ## Purpose Requirements for the `stylesheet-assembly` capability: Stylesheet assembly produces structured parts for selective post-processing; Backward compatibility for assembleStylesheet consumers. - ## Requirements - ### Requirement: Stylesheet assembly produces structured parts for selective post-processing The `assembleStylesheet` function SHALL support a `split` option that returns structured parts enabling callers to protect the `@layer` declaration and variable CSS from post-processing transforms that destructively rewrite CSS structure. The three-part split separates declaration, variables, and body so that only body content (which benefits from autoprefixing) passes through CSS post-processors. @@ -49,3 +47,34 @@ The `assembleStylesheet` function SHALL maintain backward compatibility for exis - **WHEN** `assembleStylesheet` is called without a `split` option - **THEN** it SHALL return a single concatenated CSS string (declaration + variables + global + component) - **AND** existing consumers SHALL continue to work without changes + +### Requirement: Deterministic condition ordering within layer blocks + +Within a component's rules in a `@layer` block, conditioned rules SHALL emit in a deterministic total order: unconditioned declarations first, then pseudo-selector rules in the existing cascade order, then breakpoint-derived media rules in ascending pixel order, then aliased condition rules in registry cascade order, then raw condition-key rules in source order. This order and the scenario strings below bind the extractor's emitted CSS, prior to any consumer build post-processing (minifiers may rewrite preludes and merge shorthands downstream). + +#### Scenario: Breakpoint rules precede media-feature rules + +- **WHEN** a style object contains `fontSize: { _: 14, sm: 16 }` and `'@media (prefers-reduced-motion: reduce)': { transition: 'none' }` +- **THEN** the min-width media rule for `sm` emits before the reduced-motion media rule + +#### Scenario: Aliased conditions precede raw condition keys + +- **WHEN** a style object contains a registered condition alias block and a raw `'@supports (display: grid)'` block +- **THEN** the aliased condition's rule emits before the raw supports rule + +#### Scenario: Raw condition keys keep source order + +- **WHEN** a style object contains `'@supports (display: grid)': { ... }` followed by `'@container (min-width: 400px)': { ... }` +- **THEN** the supports rule emits before the container rule + +### Requirement: Property registration rules contained in the variables part + +The `assembleStylesheet` split contract SHALL place `@property` rules in the `variables` part. The `body` part SHALL NOT contain `@property` rules, and the `declaration` part SHALL remain only the `@layer` ordering statement. + +#### Scenario: Split isolates @property with variables + +- **WHEN** `assembleStylesheet` is called with `{ split: true }` and the variable CSS includes `@property` rules +- **THEN** the `variables` part contains the `@property` rules +- **AND** the `body` part contains none +- **AND** joining `declaration + '\n' + variables + '\n' + body` equals the non-split return + diff --git a/openspec/specs/supports-condition-blocks/spec.md b/openspec/specs/supports-condition-blocks/spec.md new file mode 100644 index 00000000..62c77659 --- /dev/null +++ b/openspec/specs/supports-condition-blocks/spec.md @@ -0,0 +1,38 @@ +# supports-condition-blocks Specification + +## Purpose +TBD - created by archiving change modern-css-surface. Update Purpose after archive. +## Requirements +### Requirement: Supports condition block emission + +Style objects SHALL accept raw `@supports` condition strings as block keys, and each such block SHALL emit a `@supports` rule wrapping the component's class selector inside the owning `@layer` block, with full token and shorthand resolution inside the block. + +#### Scenario: Basic supports condition + +- **WHEN** a style object contains `'@supports (display: grid)': { display: 'grid', gap: 8 }` +- **THEN** the emitted CSS contains `@supports (display: grid) { .ClassName { display: grid; gap: 0.5rem; } }` +- **AND** the `@supports` rule appears inside the same `@layer` block as the component's base rule + +#### Scenario: Negated supports condition + +- **WHEN** a style object contains `'@supports not (backdrop-filter: blur(4px))': { bg: 'primary' }` +- **THEN** the emitted CSS contains `@supports not (backdrop-filter: blur(4px)) { .ClassName { background-color: var(--color-primary); } }` + +### Requirement: Registered supports condition aliases + +Condition aliases registered with a `@supports` value SHALL resolve as `_`-prefixed block keys to the registered supports condition. + +#### Scenario: Supports alias in a style object + +- **WHEN** the system is configured with `.addConditions({ _hasBackdrop: '@supports (backdrop-filter: blur(4px))' })` and a style object contains `_hasBackdrop: { backdropFilter: 'blur(4px)' }` +- **THEN** the emitted CSS contains `@supports (backdrop-filter: blur(4px)) { .ClassName { backdrop-filter: blur(4px); } }` + +### Requirement: Supports conditions compose with selector blocks + +Supports condition blocks SHALL admit nested selector-alias and raw-selector blocks, and the emitted rule SHALL nest the composed selector inside the `@supports` at-rule. + +#### Scenario: Selector alias inside a supports block + +- **WHEN** a style object contains `'@supports (display: grid)': { _hover: { gap: 12 } }` +- **THEN** the emitted CSS contains `@supports (display: grid) { .ClassName:hover { gap: 0.75rem; } }` + diff --git a/openspec/specs/typed-property-registration/spec.md b/openspec/specs/typed-property-registration/spec.md new file mode 100644 index 00000000..93c4415a --- /dev/null +++ b/openspec/specs/typed-property-registration/spec.md @@ -0,0 +1,39 @@ +# typed-property-registration Specification + +## Purpose +TBD - created by archiving change modern-css-surface. Update Purpose after archive. +## Requirements +### Requirement: Property registration emission for contextual vars + +Contextual vars declared with registration metadata SHALL emit an `@property` rule for their custom property, containing the declared `syntax`, `inherits`, and `initial-value` descriptors. `@property` rules SHALL appear in the variables part of the assembled stylesheet, before any `@layer` block. + +#### Scenario: Registered contextual var emits @property + +- **WHEN** the theme chain declares a contextual var `'current-bg': '--current-bg'` with registration metadata `{ syntax: '', inherits: true, initialValue: 'transparent' }` +- **THEN** the extracted CSS contains `@property --current-bg { syntax: ""; inherits: true; initial-value: transparent; }` +- **AND** the `@property` rule appears in the variables part, before any `@layer` block + +### Requirement: Property registration is opt-in + +Custom properties without registration metadata SHALL NOT emit `@property` rules. Existing themes SHALL produce output without any `@property` rule. + +#### Scenario: Unregistered contextual var unchanged + +- **WHEN** the theme chain declares a contextual var without registration metadata +- **THEN** the extracted CSS contains no `@property` rule for that custom property + +### Requirement: Registration metadata accepted at contextual var declaration + +The theme builder's contextual var declaration method (`declareContextualVars`) SHALL accept optional per-var registration metadata (`syntax`, `inherits`, `initialValue`) alongside the existing declaration mapping, without changing the phantom type behavior of the declared var names. Registration metadata keys SHALL be constrained to the declared var names at the type level. + +#### Scenario: Metadata does not alter type narrowing + +- **WHEN** the theme chain calls `declareContextualVars` with registration metadata for `'current-bg'` +- **THEN** `'current-bg'` remains a literal-typed member of the target scale's keys +- **AND** the runtime theme object remains unchanged by the call + +#### Scenario: Metadata keys constrained to declared names + +- **WHEN** registration metadata names a var that was not declared in the same call +- **THEN** TypeScript produces a type error on that metadata key + diff --git a/packages/_assertions/__tests__/conditions-inside-layers.test.ts b/packages/_assertions/__tests__/conditions-inside-layers.test.ts new file mode 100644 index 00000000..c2192dea --- /dev/null +++ b/packages/_assertions/__tests__/conditions-inside-layers.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it } from 'vitest'; + +import { + AssertionError, + assertConditionsInsideLayers, +} from '../src/assert-css'; + +/** + * Guardrail G2 (modern-css-surface) — FIRST ARMED RUN. + * + * The structural check `assertConditionsInsideLayers` lands with the first + * condition-emitting increment (03). These cases arm it: a passing shape that + * mirrors the extractor's real emission (condition at-rules nested inside the + * owning @layer block), a failing shape (a condition at-rule hoisted to the + * top level), and a vacuous shape (no conditions) that must stay green. + */ + +// Real emission shape: every condition at-rule nests inside a named @layer. +const CONDITIONS_IN_LAYERS = ` +@layer anm-global, anm-base, anm-variants, anm-system; +:root { --color-primary: #abc; } +@layer anm-base { + .animus-Card-abcd1234 { + display: flex; + } + @media (min-width: 768px) { + .animus-Card-abcd1234 { + gap: 1rem; + } + } + @container card (min-width: 400px) { + .animus-Card-abcd1234 { + font-size: 18px; + } + } + @media (prefers-reduced-motion: reduce) { + .animus-Card-abcd1234 { + transition: none; + } + } + @supports (display: grid) { + .animus-Card-abcd1234 { + display: grid; + } + } +} +@layer anm-variants { + @layer standalone, composed; + @layer composed { + @container (min-width: 600px) { + .animus-Card-abcd1234--size-lg { padding: 2rem; } + } + } +} +`.trim(); + +describe('assertConditionsInsideLayers (Guardrail G2)', () => { + it('passes when every condition at-rule nests inside a named @layer block', () => { + expect(() => + assertConditionsInsideLayers(CONDITIONS_IN_LAYERS) + ).not.toThrow(); + }); + + it('passes even for nested sublayers (@layer composed inside @layer anm-variants)', () => { + // The `@container (min-width: 600px)` lives inside `@layer composed`, itself + // nested in `@layer anm-variants` — still "inside a named @layer block". + expect(() => + assertConditionsInsideLayers(CONDITIONS_IN_LAYERS, { + atRules: ['@container'], + }) + ).not.toThrow(); + }); + + it('fails when a @container rule appears outside any @layer block', () => { + const hoisted = ` +@layer anm-base { .animus-Card { display: flex; } } +@container (min-width: 400px) { .animus-Card { font-size: 18px; } } +`.trim(); + expect(() => assertConditionsInsideLayers(hoisted)).toThrow(AssertionError); + }); + + it('fails when a @supports rule is hoisted to the top level', () => { + const hoisted = ` +@supports (display: grid) { .animus-Card { display: grid; } } +@layer anm-base { .animus-Card { display: flex; } } +`.trim(); + expect(() => assertConditionsInsideLayers(hoisted)).toThrow(AssertionError); + }); + + it('fails when a non-breakpoint @media is outside a layer', () => { + const hoisted = ` +@layer anm-base { .animus-Card { color: red; } } +@media (prefers-reduced-motion: reduce) { .animus-Card { transition: none; } } +`.trim(); + expect(() => assertConditionsInsideLayers(hoisted)).toThrow(AssertionError); + }); + + it('is vacuously green on output with no condition at-rules', () => { + const plain = ` +@layer anm-base { .animus-Card { display: flex; padding: 8px; } } +`.trim(); + expect(() => assertConditionsInsideLayers(plain)).not.toThrow(); + }); + + it('reports the offending at-rule family and offset in the error', () => { + const hoisted = + '@layer anm-base { .x { color: red; } } @container (min-width: 400px) { .x { gap: 1rem; } }'; + try { + assertConditionsInsideLayers(hoisted); + throw new Error('expected assertion to throw'); + } catch (err) { + expect(err).toBeInstanceOf(AssertionError); + expect((err as AssertionError).message).toContain('@container'); + expect((err as AssertionError).details).toHaveProperty('offenders'); + } + }); +}); diff --git a/packages/_assertions/__tests__/property-registration-split.test.ts b/packages/_assertions/__tests__/property-registration-split.test.ts new file mode 100644 index 00000000..77986e80 --- /dev/null +++ b/packages/_assertions/__tests__/property-registration-split.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from 'vitest'; + +import { + AssertionError, + assertPropertyRegistrationSplit, + type SplitStylesheetParts, +} from '../src/assert-css'; + +// Shapes mirror `assembleStylesheet({ split: true })`: declaration carries the +// @layer ordering line, variables carries the pre-@layer custom-property CSS +// (now including @property registration rules), body carries global+component. +const DECLARATION = '@layer anm-global, anm-base, anm-variants;\n'; +const VARIABLES = [ + '@property --current-bg { syntax: ""; inherits: true; initial-value: transparent; }', + '', + ':root {\n --color-primary: #abc;\n}', +].join('\n'); +const BODY = '@layer anm-base { .animus-card { padding: 8px; } }'; + +function makeParts( + overrides: Partial = {} +): SplitStylesheetParts { + return { + declaration: DECLARATION, + variables: VARIABLES, + body: BODY, + ...overrides, + }; +} + +function rejoin(parts: SplitStylesheetParts): string { + return [parts.declaration, parts.variables, parts.body] + .filter(Boolean) + .join('\n'); +} + +describe('assertPropertyRegistrationSplit', () => { + it('passes when @property is in variables, absent from body, and the concat holds', () => { + const parts = makeParts(); + expect(() => + assertPropertyRegistrationSplit(parts, rejoin(parts)) + ).not.toThrow(); + }); + + it('throws when the variables part has no @property rule', () => { + const parts = makeParts({ + variables: ':root {\n --color-primary: #abc;\n}', + }); + expect(() => assertPropertyRegistrationSplit(parts, rejoin(parts))).toThrow( + AssertionError + ); + }); + + it('throws when @property leaks into the body part', () => { + const parts = makeParts({ + body: `${BODY}\n@property --leak { syntax: "*"; inherits: false; }`, + }); + expect(() => assertPropertyRegistrationSplit(parts, rejoin(parts))).toThrow( + /body part must contain no @property/ + ); + }); + + it('throws when @property leaks into the declaration part', () => { + const parts = makeParts({ + declaration: `${DECLARATION}@property --leak { syntax: "*"; inherits: false; }`, + }); + expect(() => assertPropertyRegistrationSplit(parts, rejoin(parts))).toThrow( + /declaration part must contain no @property/ + ); + }); + + it('throws when the declaration lacks the @layer ordering statement', () => { + const parts = makeParts({ declaration: '/* no layer decl */\n' }); + // A declaration without @layer also contains no @property, so the failure + // is specifically the missing @layer ordering statement. + expect(() => assertPropertyRegistrationSplit(parts, rejoin(parts))).toThrow( + /@layer ordering statement/ + ); + }); + + it('throws when the rejoined parts do not equal the non-split output', () => { + const parts = makeParts(); + expect(() => + assertPropertyRegistrationSplit(parts, `${rejoin(parts)}/* drift */`) + ).toThrow(/do not equal the non-split output/); + }); +}); diff --git a/packages/_assertions/src/assert-css.ts b/packages/_assertions/src/assert-css.ts index dbceeecc..c7f8689c 100644 --- a/packages/_assertions/src/assert-css.ts +++ b/packages/_assertions/src/assert-css.ts @@ -127,6 +127,161 @@ export function assertNoUnresolvedTokens( } } +/** The three structured parts returned by `assembleStylesheet({ split: true })`. */ +export interface SplitStylesheetParts { + declaration: string; + variables: string; + body: string; +} + +/** + * Assert the property-registration split contract (stylesheet-assembly delta, + * "Property registration rules contained in the variables part"): + * + * - `@property` rules live in the `variables` part (at least one present), + * - the `body` part contains none, + * - the `declaration` part is only the `@layer` ordering statement (no + * `@property`), and + * - rejoining the parts reproduces the non-split output — the same + * `[declaration, variables, body].filter(Boolean).join('\n')` that + * `assembleStylesheet` returns without `split`. + * + * Pure over the split parts + the non-split string; no I/O. + */ +export function assertPropertyRegistrationSplit( + parts: SplitStylesheetParts, + nonSplit: string +): void { + const countProperties = (css: string): number => + (css.match(/@property\b/g) ?? []).length; + + const inVariables = countProperties(parts.variables); + const inBody = countProperties(parts.body); + const inDeclaration = countProperties(parts.declaration); + + if (inVariables < 1) { + throw new AssertionError( + 'assertPropertyRegistrationSplit: expected @property rule(s) in the variables part, found none', + { inVariables, inBody, inDeclaration } + ); + } + if (inBody !== 0) { + throw new AssertionError( + `assertPropertyRegistrationSplit: body part must contain no @property rules, found ${inBody}`, + { inBody } + ); + } + if (inDeclaration !== 0) { + throw new AssertionError( + `assertPropertyRegistrationSplit: declaration part must contain no @property rules, found ${inDeclaration}`, + { inDeclaration } + ); + } + if (!LAYER_DECLARATION_RE.test(parts.declaration)) { + throw new AssertionError( + 'assertPropertyRegistrationSplit: declaration part must contain the @layer ordering statement', + { declaration: parts.declaration } + ); + } + // "Only the @layer ordering statement": nothing may remain once the + // ordering statement is removed (spec: declaration part SHALL remain + // only the @layer ordering statement). + if (parts.declaration.replace(LAYER_DECLARATION_RE, '').trim() !== '') { + throw new AssertionError( + 'assertPropertyRegistrationSplit: declaration part must contain ONLY the @layer ordering statement', + { declaration: parts.declaration } + ); + } + + const rejoined = [parts.declaration, parts.variables, parts.body] + .filter(Boolean) + .join('\n'); + if (rejoined !== nonSplit) { + throw new AssertionError( + 'assertPropertyRegistrationSplit: rejoined split parts do not equal the non-split output', + { rejoined, nonSplit } + ); + } +} + +/** All `@layer { … }` block spans (single-name block opens, brace- + * matched). The layer DECLARATION statement (`@layer a, b, c;`) is not a block + * and is excluded. Nested sublayers (`@layer composed { … }`) are included. */ +function allLayerBlockSpans(css: string): [number, number][] { + const openRe = /@layer\s+[\w-]+\s*\{/g; + const spans: [number, number][] = []; + for (const m of css.matchAll(openRe)) { + if (m.index === undefined) continue; + const afterOpen = m.index + m[0].length; + let depth = 1; + let cursor = afterOpen; + while (cursor < css.length && depth > 0) { + const ch = css[cursor]; + if (ch === '{') depth += 1; + else if (ch === '}') depth -= 1; + if (depth > 0) cursor += 1; + } + spans.push([m.index, cursor]); + } + return spans; +} + +export interface ConditionsInsideLayersConfig { + /** + * At-rule families that must not appear outside a named `@layer` block. + * Default covers the modern-css-surface conditions: `@container`, + * `@supports`, and `@media` (breakpoint AND non-breakpoint — all conditioned + * rules emit inside a layer, so the check is uniform). + */ + atRules?: readonly string[]; +} + +/** + * Assert Guardrail G2 (modern-css-surface): new condition at-rules SHALL NOT + * appear outside a named `@layer` block in any emitted sheet. Every + * `@container` / `@supports` / `@media` at-rule occurrence must fall inside a + * `@layer { … }` span. Position-aware (character-index containment), so + * a correctly-named-but-misplaced at-rule fails fast — the whole reason this + * package exists over `grep`. + * + * Vacuously green on output with no condition at-rules (arming, not asserting + * presence). Pure over the CSS string; no I/O. + */ +export function assertConditionsInsideLayers( + css: string, + config?: ConditionsInsideLayersConfig +): void { + const atRules = config?.atRules ?? ['@container', '@supports', '@media']; + const spans = allLayerBlockSpans(css); + const isInsideALayer = (index: number): boolean => + spans.some(([start, end]) => index >= start && index <= end); + + const offenders: Array<{ atRule: string; index: number; context: string }> = + []; + for (const atRule of atRules) { + const re = new RegExp(`${escapeForRegExp(atRule)}\\b`, 'g'); + for (const m of css.matchAll(re)) { + if (m.index === undefined) continue; + if (!isInsideALayer(m.index)) { + offenders.push({ + atRule, + index: m.index, + context: css.slice(Math.max(0, m.index - 40), m.index + 60), + }); + } + } + } + + if (offenders.length > 0) { + throw new AssertionError( + `assertConditionsInsideLayers: found ${offenders.length} condition at-rule(s) outside any @layer block: ${offenders + .map((o) => `${o.atRule}@${o.index}`) + .join(', ')}`, + { offenders } + ); + } +} + export function assertNoEmotionImports(jsContent: string): void { const idx = jsContent.indexOf('@emotion'); if (idx !== -1) { diff --git a/packages/_parity/baseline-intents.md b/packages/_parity/baseline-intents.md index 974bb7dd..3ef0791e 100644 --- a/packages/_parity/baseline-intents.md +++ b/packages/_parity/baseline-intents.md @@ -21,3 +21,32 @@ committed production/development pair. Ordinary parity runs never write it. code, and observables drift to `integration/transforms.tsx` in both modes. The atomic pair also resnapshots two reviewed, AST-equivalent selector fixture comment corrections without changing their non-code surfaces. +- [x] `modern-css-surface-inc03-conditions-20260722` — refresh after the + reviewed condition-emission increment (K=3 adversarial pass + fix round) + added four condition-surface corpus units: raw container/media/supports + block keys plus a registered-alias case supplied via the harness + condition-alias map. The same run holds every pre-existing unit + byte-identical (the change's G1 guardrail); these are the oracle's first + non-breakpoint condition groups. +- [x] `modern-css-surface-inc06-builtins-20260722` — refresh after the + reviewed built-in condition increment added four builtin-alias corpus + units (`condition-builtin-{motion,osdark,print,order}`): the nine D8 + built-ins ship at reserved orders 300–380, and the harness alias map + gained `_osDark`/`_print` at real built-in orders. Every pre-existing + unit stays byte-identical in the same run (G1), including the blessed + inc-03 `condition-aliased` unit whose harness `_motionReduce` entry is + unchanged. +- [x] `modern-css-surface-inc08-container-20260722` — refresh after the + reviewed ergonomics-survey increment landed the compose-slot container + card (Root establishes `container-name: card`, slots respond) and the + registered-`@property` contextual-var consumer — the oracle's first + compose×container and registered-var units. Every pre-existing unit + stays byte-identical in the same run (G1). +- [x] `modern-css-surface-corpus-headers-20260722` — comment-only refresh: the + ten condition/container corpus fixtures' "NOT yet blessed" staging + headers were stale after their blessings (inc 03/06/08), one fixture + cited a consumer-lane assertion that did not exist at authoring time + (now real: the showcase @property pin), and the builtin-motion header + overclaimed band provenance. No emission-affecting bytes change; every + unit's css/observables stay byte-identical — only the embedded `code` + artifacts move. diff --git a/packages/_parity/baselines/v2/development.json b/packages/_parity/baselines/v2/development.json index 0c6b5865..60cdce16 100644 --- a/packages/_parity/baselines/v2/development.json +++ b/packages/_parity/baselines/v2/development.json @@ -1,8 +1,8 @@ { - "corpusSha256": "231dd7127e27f85c1d860c058a4abe4b593c75f86c936787bb1d6117bdf62e06", + "corpusSha256": "c4049c6284797f7f2ad3eecd7468155afff9965ce5eca5244636cf7e299434f0", "engine": "v2", "mode": "development", - "refreshIntent": "embedded-transform-fixture-20260719", + "refreshIntent": "modern-css-surface-corpus-headers-20260722", "surfaceSchemaSha256": "43eb1265e97aab8497c11ca05e8a4159a6d4f4b69a6837a704e1d96fa75eb108", "units": { "extract-all": { @@ -748,6 +748,29 @@ }, "parseCount": 1 }, + "parity/compose-container-card.tsx": { + "code": { + "compose-container-card.tsx": "import { createComponent, createComposedFamily } from '@animus-ui/system';\nimport 'virtual:animus/styles.css';\n// Corpus fixture (modern-css-surface inc 08) — blessed into the committed oracle.\n// Canonical compose-slot CONTAINER pattern: the Root slot establishes a named\n// query container (container-name/container-type — design D7 pass-through\n// declarations) and slotted children respond via raw `@container card (…)`\n// block keys (design D2, no registration). Mirrors the landed test-ds\n// `ContainerCard` family; here as a parity oracle unit so the compose ×\n// container-establishment × @container-response combination is byte-pinned.\n\nimport { ds } from '../test-system';\n\nconst Root = createComponent('article', 'animus-Root-b4b4101f', {});\n\nconst Media = createComponent('div', 'animus-Media-cdf8bb75', {});\n\nconst Body = createComponent('div', 'animus-Body-c5ef664b', {});\n\nexport const ContainerCard = createComposedFamily({ Root: Root, Media: Media, Body: Body }, { name: \"ContainerCard\" });\nexport const App = () => (\n \n \n \n \n);\n\n" + }, + "css": "@layer anm-global, anm-base, anm-variants, anm-compounds, anm-states, anm-system, anm-custom;\n\n@layer anm-base {\n @container card (min-width: 400px) {\n .animus-Body-c5ef664b {\n padding: 1rem;\n }\n }\n .animus-Media-cdf8bb75 {\n width: 100%;\n }\n @container card (min-width: 400px) {\n .animus-Media-cdf8bb75 {\n padding: 1rem;\n width: 50cqw;\n }\n }\n .animus-Root-b4b4101f {\n padding: 0.5rem;\n display: flex;\n flex-direction: column;\n container-type: inline-size;\n container-name: card;\n }\n}\n\n@layer anm-variants {\n @layer standalone, composed;\n @layer composed {\n }\n}\n\n", + "diagnostics": [], + "hasComponents": { + "compose-container-card.tsx": true + }, + "observables": { + "componentFragmentKeys": [ + "compose-container-card.tsx::Body", + "compose-container-card.tsx::Media", + "compose-container-card.tsx::Root" + ], + "componentFragmentsJson": "{\"compose-container-card.tsx::Body\":{\"base\":\" @container card (min-width: 400px) {\\n .animus-Body-c5ef664b {\\n padding: 1rem;\\n }\\n }\\n\"},\"compose-container-card.tsx::Media\":{\"base\":\" .animus-Media-cdf8bb75 {\\n width: 100%;\\n }\\n @container card (min-width: 400px) {\\n .animus-Media-cdf8bb75 {\\n padding: 1rem;\\n width: 50cqw;\\n }\\n }\\n\"},\"compose-container-card.tsx::Root\":{\"base\":\" .animus-Root-b4b4101f {\\n padding: 0.5rem;\\n display: flex;\\n flex-direction: column;\\n container-type: inline-size;\\n container-name: card;\\n }\\n\"}}", + "dynamicPropsJson": "{}", + "reverseProvenanceEdges": [], + "sheetsJson": "{\"base\":\"@layer anm-base {\\n @container card (min-width: 400px) {\\n .animus-Body-c5ef664b {\\n padding: 1rem;\\n }\\n }\\n .animus-Media-cdf8bb75 {\\n width: 100%;\\n }\\n @container card (min-width: 400px) {\\n .animus-Media-cdf8bb75 {\\n padding: 1rem;\\n width: 50cqw;\\n }\\n }\\n .animus-Root-b4b4101f {\\n padding: 0.5rem;\\n display: flex;\\n flex-direction: column;\\n container-type: inline-size;\\n container-name: card;\\n }\\n}\\n\",\"compounds\":\"\",\"custom\":\"\",\"declaration\":\"@layer anm-global, anm-base, anm-variants, anm-compounds, anm-states, anm-system, anm-custom;\\n\",\"global\":\"@layer anm-global {\\nbody {\\n font-family: {fonts.base};\\n margin: 0;\\n}\\n@keyframes anm-ember {\\n 0% {\\n opacity: 0;\\n }\\n 100% {\\n opacity: 1;\\n }\\n}\\n}\\n\",\"states\":\"\",\"system\":\"\",\"variants\":\"@layer anm-variants {\\n @layer standalone, composed;\\n @layer composed {\\n }\\n}\\n\"}", + "systemPropMapJson": "{}" + }, + "parseCount": 1 + }, "parity/compose-only": { "code": { "family.tsx": "// Review F5 witness: a file whose ONLY animus construct is a compose()\n// call over IMPORTED slots has no surviving components of its own —\n// v1 returns it UNCHANGED (lib.rs 950-958, before compose handling).\nimport { compose } from '@animus-ui/system/compose';\n\nimport { Root, Body } from './slots';\n\nexport const Fam = compose({ Root, Body }, { name: 'Card', shared: {} });\nexport const App = () => ;\n", @@ -794,6 +817,195 @@ }, "parseCount": 1 }, + "parity/condition-aliased.tsx": { + "code": { + "condition-aliased.tsx": "import { createComponent } from '@animus-ui/system';\nimport 'virtual:animus/styles.css';\n// Corpus fixture (modern-css-surface inc 03) — blessed into the committed oracle.\n// Registered condition-alias block key. `_motionReduce` resolves through the\n// harness-supplied condition registry (HARNESS_CONDITION_ALIASES in\n// packages/_parity/src/engine-run.ts) to\n// `@media (prefers-reduced-motion: reduce)`.\nimport { ds } from '../test-system';\n\nexport const AliasedCard = createComponent('div', 'animus-AliasedCard-86f7ff22', {});\nexport const App = () => ;\n\n" + }, + "css": "@layer anm-global, anm-base, anm-variants, anm-compounds, anm-states, anm-system, anm-custom;\n\n@layer anm-base {\n .animus-AliasedCard-86f7ff22 {\n padding: 0.5rem;\n }\n @media (prefers-reduced-motion: reduce) {\n .animus-AliasedCard-86f7ff22 {\n display: none;\n }\n }\n}\n\n@layer anm-variants {\n @layer standalone, composed;\n @layer composed {\n }\n}\n\n", + "diagnostics": [], + "hasComponents": { + "condition-aliased.tsx": true + }, + "observables": { + "componentFragmentKeys": [ + "condition-aliased.tsx::AliasedCard" + ], + "componentFragmentsJson": "{\"condition-aliased.tsx::AliasedCard\":{\"base\":\" .animus-AliasedCard-86f7ff22 {\\n padding: 0.5rem;\\n }\\n @media (prefers-reduced-motion: reduce) {\\n .animus-AliasedCard-86f7ff22 {\\n display: none;\\n }\\n }\\n\"}}", + "dynamicPropsJson": "{}", + "reverseProvenanceEdges": [], + "sheetsJson": "{\"base\":\"@layer anm-base {\\n .animus-AliasedCard-86f7ff22 {\\n padding: 0.5rem;\\n }\\n @media (prefers-reduced-motion: reduce) {\\n .animus-AliasedCard-86f7ff22 {\\n display: none;\\n }\\n }\\n}\\n\",\"compounds\":\"\",\"custom\":\"\",\"declaration\":\"@layer anm-global, anm-base, anm-variants, anm-compounds, anm-states, anm-system, anm-custom;\\n\",\"global\":\"@layer anm-global {\\nbody {\\n font-family: {fonts.base};\\n margin: 0;\\n}\\n@keyframes anm-ember {\\n 0% {\\n opacity: 0;\\n }\\n 100% {\\n opacity: 1;\\n }\\n}\\n}\\n\",\"states\":\"\",\"system\":\"\",\"variants\":\"@layer anm-variants {\\n @layer standalone, composed;\\n @layer composed {\\n }\\n}\\n\"}", + "systemPropMapJson": "{}" + }, + "parseCount": 1 + }, + "parity/condition-builtin-motion.tsx": { + "code": { + "condition-builtin-motion.tsx": "import { createComponent } from '@animus-ui/system';\nimport 'virtual:animus/styles.css';\n// Corpus fixture (modern-css-surface inc 06) — blessed into the committed oracle.\n// Built-in media-feature condition alias `_motionReduce` (design D8). Unlike\n// condition-aliased.tsx (which proves a USER-registered alias), this proves the\n// BUILT-IN ships in the manifest with no explicit registration: the harness\n// condition map carries the built-in set, so `_motionReduce` resolves to\n// `@media (prefers-reduced-motion: reduce)`.\n// Spec: media-condition-aliases §\"Built-in media-feature condition aliases\" —\n// scenario \"Built-in motion alias\".\nimport { ds } from '../test-system';\n\nexport const MotionCard = createComponent('div', 'animus-MotionCard-d2881c34', {});\nexport const App = () => ;\n\n" + }, + "css": "@layer anm-global, anm-base, anm-variants, anm-compounds, anm-states, anm-system, anm-custom;\n\n@layer anm-base {\n .animus-MotionCard-d2881c34 {\n padding: 0.5rem;\n }\n @media (prefers-reduced-motion: reduce) {\n .animus-MotionCard-d2881c34 {\n transition: none;\n }\n }\n}\n\n@layer anm-variants {\n @layer standalone, composed;\n @layer composed {\n }\n}\n\n", + "diagnostics": [], + "hasComponents": { + "condition-builtin-motion.tsx": true + }, + "observables": { + "componentFragmentKeys": [ + "condition-builtin-motion.tsx::MotionCard" + ], + "componentFragmentsJson": "{\"condition-builtin-motion.tsx::MotionCard\":{\"base\":\" .animus-MotionCard-d2881c34 {\\n padding: 0.5rem;\\n }\\n @media (prefers-reduced-motion: reduce) {\\n .animus-MotionCard-d2881c34 {\\n transition: none;\\n }\\n }\\n\"}}", + "dynamicPropsJson": "{}", + "reverseProvenanceEdges": [], + "sheetsJson": "{\"base\":\"@layer anm-base {\\n .animus-MotionCard-d2881c34 {\\n padding: 0.5rem;\\n }\\n @media (prefers-reduced-motion: reduce) {\\n .animus-MotionCard-d2881c34 {\\n transition: none;\\n }\\n }\\n}\\n\",\"compounds\":\"\",\"custom\":\"\",\"declaration\":\"@layer anm-global, anm-base, anm-variants, anm-compounds, anm-states, anm-system, anm-custom;\\n\",\"global\":\"@layer anm-global {\\nbody {\\n font-family: {fonts.base};\\n margin: 0;\\n}\\n@keyframes anm-ember {\\n 0% {\\n opacity: 0;\\n }\\n 100% {\\n opacity: 1;\\n }\\n}\\n}\\n\",\"states\":\"\",\"system\":\"\",\"variants\":\"@layer anm-variants {\\n @layer standalone, composed;\\n @layer composed {\\n }\\n}\\n\"}", + "systemPropMapJson": "{}" + }, + "parseCount": 1 + }, + "parity/condition-builtin-order.tsx": { + "code": { + "condition-builtin-order.tsx": "import { createComponent } from '@animus-ui/system';\nimport 'virtual:animus/styles.css';\n// Corpus fixture (modern-css-surface inc 06) — blessed into the committed oracle.\n// ORDERING PROBE (design D4 + ORDER BAND): a built-in condition alias and a\n// user-band alias in the same style object must emit by registry `order`. The\n// built-in `_osDark` sits in the reserved built-in band (order 370); the\n// harness-registered `_motionReduce` sits in the user band (order 500). The\n// emitted CSS must therefore wrap `@media (prefers-color-scheme: dark)` BEFORE\n// `@media (prefers-reduced-motion: reduce)` — built-in band below user band.\nimport { ds } from '../test-system';\n\nexport const OrderProbe = createComponent('div', 'animus-OrderProbe-ca42ff82', {});\nexport const App = () => ;\n\n" + }, + "css": "@layer anm-global, anm-base, anm-variants, anm-compounds, anm-states, anm-system, anm-custom;\n\n@layer anm-base {\n .animus-OrderProbe-ca42ff82 {\n padding: 0.5rem;\n }\n @media (prefers-color-scheme: dark) {\n .animus-OrderProbe-ca42ff82 {\n color-scheme: dark;\n }\n }\n @media (prefers-reduced-motion: reduce) {\n .animus-OrderProbe-ca42ff82 {\n transition: none;\n }\n }\n}\n\n@layer anm-variants {\n @layer standalone, composed;\n @layer composed {\n }\n}\n\n", + "diagnostics": [], + "hasComponents": { + "condition-builtin-order.tsx": true + }, + "observables": { + "componentFragmentKeys": [ + "condition-builtin-order.tsx::OrderProbe" + ], + "componentFragmentsJson": "{\"condition-builtin-order.tsx::OrderProbe\":{\"base\":\" .animus-OrderProbe-ca42ff82 {\\n padding: 0.5rem;\\n }\\n @media (prefers-color-scheme: dark) {\\n .animus-OrderProbe-ca42ff82 {\\n color-scheme: dark;\\n }\\n }\\n @media (prefers-reduced-motion: reduce) {\\n .animus-OrderProbe-ca42ff82 {\\n transition: none;\\n }\\n }\\n\"}}", + "dynamicPropsJson": "{}", + "reverseProvenanceEdges": [], + "sheetsJson": "{\"base\":\"@layer anm-base {\\n .animus-OrderProbe-ca42ff82 {\\n padding: 0.5rem;\\n }\\n @media (prefers-color-scheme: dark) {\\n .animus-OrderProbe-ca42ff82 {\\n color-scheme: dark;\\n }\\n }\\n @media (prefers-reduced-motion: reduce) {\\n .animus-OrderProbe-ca42ff82 {\\n transition: none;\\n }\\n }\\n}\\n\",\"compounds\":\"\",\"custom\":\"\",\"declaration\":\"@layer anm-global, anm-base, anm-variants, anm-compounds, anm-states, anm-system, anm-custom;\\n\",\"global\":\"@layer anm-global {\\nbody {\\n font-family: {fonts.base};\\n margin: 0;\\n}\\n@keyframes anm-ember {\\n 0% {\\n opacity: 0;\\n }\\n 100% {\\n opacity: 1;\\n }\\n}\\n}\\n\",\"states\":\"\",\"system\":\"\",\"variants\":\"@layer anm-variants {\\n @layer standalone, composed;\\n @layer composed {\\n }\\n}\\n\"}", + "systemPropMapJson": "{}" + }, + "parseCount": 1 + }, + "parity/condition-builtin-osdark.tsx": { + "code": { + "condition-builtin-osdark.tsx": "import { createComponent } from '@animus-ui/system';\nimport 'virtual:animus/styles.css';\n// Corpus fixture (modern-css-surface inc 06) — blessed into the committed oracle.\n// Built-in OS color-scheme condition alias `_osDark` (design D8) — resolves to\n// `@media (prefers-color-scheme: dark)` through the shipped built-in set, no\n// user registration.\n// Spec: media-condition-aliases §\"Built-in media-feature condition aliases\" —\n// scenario \"Built-in OS color-scheme alias\".\nimport { ds } from '../test-system';\n\nexport const OsDarkCard = createComponent('div', 'animus-OsDarkCard-b2808cb4', {});\nexport const App = () => ;\n\n" + }, + "css": "@layer anm-global, anm-base, anm-variants, anm-compounds, anm-states, anm-system, anm-custom;\n\n@layer anm-base {\n .animus-OsDarkCard-b2808cb4 {\n display: flex;\n }\n @media (prefers-color-scheme: dark) {\n .animus-OsDarkCard-b2808cb4 {\n color-scheme: dark;\n }\n }\n}\n\n@layer anm-variants {\n @layer standalone, composed;\n @layer composed {\n }\n}\n\n", + "diagnostics": [], + "hasComponents": { + "condition-builtin-osdark.tsx": true + }, + "observables": { + "componentFragmentKeys": [ + "condition-builtin-osdark.tsx::OsDarkCard" + ], + "componentFragmentsJson": "{\"condition-builtin-osdark.tsx::OsDarkCard\":{\"base\":\" .animus-OsDarkCard-b2808cb4 {\\n display: flex;\\n }\\n @media (prefers-color-scheme: dark) {\\n .animus-OsDarkCard-b2808cb4 {\\n color-scheme: dark;\\n }\\n }\\n\"}}", + "dynamicPropsJson": "{}", + "reverseProvenanceEdges": [], + "sheetsJson": "{\"base\":\"@layer anm-base {\\n .animus-OsDarkCard-b2808cb4 {\\n display: flex;\\n }\\n @media (prefers-color-scheme: dark) {\\n .animus-OsDarkCard-b2808cb4 {\\n color-scheme: dark;\\n }\\n }\\n}\\n\",\"compounds\":\"\",\"custom\":\"\",\"declaration\":\"@layer anm-global, anm-base, anm-variants, anm-compounds, anm-states, anm-system, anm-custom;\\n\",\"global\":\"@layer anm-global {\\nbody {\\n font-family: {fonts.base};\\n margin: 0;\\n}\\n@keyframes anm-ember {\\n 0% {\\n opacity: 0;\\n }\\n 100% {\\n opacity: 1;\\n }\\n}\\n}\\n\",\"states\":\"\",\"system\":\"\",\"variants\":\"@layer anm-variants {\\n @layer standalone, composed;\\n @layer composed {\\n }\\n}\\n\"}", + "systemPropMapJson": "{}" + }, + "parseCount": 1 + }, + "parity/condition-builtin-print.tsx": { + "code": { + "condition-builtin-print.tsx": "import { createComponent } from '@animus-ui/system';\nimport 'virtual:animus/styles.css';\n// Corpus fixture (modern-css-surface inc 06) — blessed into the committed oracle.\n// Built-in print condition alias `_print` (design D8) — resolves to\n// `@media print` through the shipped built-in set, no user registration.\n// Spec: media-condition-aliases §\"Built-in media-feature condition aliases\" —\n// scenario \"Built-in print alias\".\nimport { ds } from '../test-system';\n\nexport const PrintCard = createComponent('div', 'animus-PrintCard-a553cb08', {});\nexport const App = () => ;\n\n" + }, + "css": "@layer anm-global, anm-base, anm-variants, anm-compounds, anm-states, anm-system, anm-custom;\n\n@layer anm-base {\n .animus-PrintCard-a553cb08 {\n display: block;\n }\n @media print {\n .animus-PrintCard-a553cb08 {\n display: none;\n }\n }\n}\n\n@layer anm-variants {\n @layer standalone, composed;\n @layer composed {\n }\n}\n\n", + "diagnostics": [], + "hasComponents": { + "condition-builtin-print.tsx": true + }, + "observables": { + "componentFragmentKeys": [ + "condition-builtin-print.tsx::PrintCard" + ], + "componentFragmentsJson": "{\"condition-builtin-print.tsx::PrintCard\":{\"base\":\" .animus-PrintCard-a553cb08 {\\n display: block;\\n }\\n @media print {\\n .animus-PrintCard-a553cb08 {\\n display: none;\\n }\\n }\\n\"}}", + "dynamicPropsJson": "{}", + "reverseProvenanceEdges": [], + "sheetsJson": "{\"base\":\"@layer anm-base {\\n .animus-PrintCard-a553cb08 {\\n display: block;\\n }\\n @media print {\\n .animus-PrintCard-a553cb08 {\\n display: none;\\n }\\n }\\n}\\n\",\"compounds\":\"\",\"custom\":\"\",\"declaration\":\"@layer anm-global, anm-base, anm-variants, anm-compounds, anm-states, anm-system, anm-custom;\\n\",\"global\":\"@layer anm-global {\\nbody {\\n font-family: {fonts.base};\\n margin: 0;\\n}\\n@keyframes anm-ember {\\n 0% {\\n opacity: 0;\\n }\\n 100% {\\n opacity: 1;\\n }\\n}\\n}\\n\",\"states\":\"\",\"system\":\"\",\"variants\":\"@layer anm-variants {\\n @layer standalone, composed;\\n @layer composed {\\n }\\n}\\n\"}", + "systemPropMapJson": "{}" + }, + "parseCount": 1 + }, + "parity/condition-raw-container.tsx": { + "code": { + "condition-raw-container.tsx": "import { createComponent } from '@animus-ui/system';\nimport 'virtual:animus/styles.css';\n// Corpus fixture (modern-css-surface inc 03) — blessed into the committed oracle.\n// Raw @container block key (named container + declarations). Resolves through\n// the test-system config; no condition-alias registration needed.\nimport { ds } from '../test-system';\n\nexport const ContainerCard = createComponent('div', 'animus-ContainerCard-739b1003', {});\nexport const App = () => ;\n\n" + }, + "css": "@layer anm-global, anm-base, anm-variants, anm-compounds, anm-states, anm-system, anm-custom;\n\n@layer anm-base {\n .animus-ContainerCard-739b1003 {\n padding: 0.5rem;\n }\n @container card (min-width: 400px) {\n .animus-ContainerCard-739b1003 {\n padding: 1rem;\n display: grid;\n }\n }\n}\n\n@layer anm-variants {\n @layer standalone, composed;\n @layer composed {\n }\n}\n\n", + "diagnostics": [], + "hasComponents": { + "condition-raw-container.tsx": true + }, + "observables": { + "componentFragmentKeys": [ + "condition-raw-container.tsx::ContainerCard" + ], + "componentFragmentsJson": "{\"condition-raw-container.tsx::ContainerCard\":{\"base\":\" .animus-ContainerCard-739b1003 {\\n padding: 0.5rem;\\n }\\n @container card (min-width: 400px) {\\n .animus-ContainerCard-739b1003 {\\n padding: 1rem;\\n display: grid;\\n }\\n }\\n\"}}", + "dynamicPropsJson": "{}", + "reverseProvenanceEdges": [], + "sheetsJson": "{\"base\":\"@layer anm-base {\\n .animus-ContainerCard-739b1003 {\\n padding: 0.5rem;\\n }\\n @container card (min-width: 400px) {\\n .animus-ContainerCard-739b1003 {\\n padding: 1rem;\\n display: grid;\\n }\\n }\\n}\\n\",\"compounds\":\"\",\"custom\":\"\",\"declaration\":\"@layer anm-global, anm-base, anm-variants, anm-compounds, anm-states, anm-system, anm-custom;\\n\",\"global\":\"@layer anm-global {\\nbody {\\n font-family: {fonts.base};\\n margin: 0;\\n}\\n@keyframes anm-ember {\\n 0% {\\n opacity: 0;\\n }\\n 100% {\\n opacity: 1;\\n }\\n}\\n}\\n\",\"states\":\"\",\"system\":\"\",\"variants\":\"@layer anm-variants {\\n @layer standalone, composed;\\n @layer composed {\\n }\\n}\\n\"}", + "systemPropMapJson": "{}" + }, + "parseCount": 1 + }, + "parity/condition-raw-media.tsx": { + "code": { + "condition-raw-media.tsx": "import { createComponent } from '@animus-ui/system';\nimport 'virtual:animus/styles.css';\n// Corpus fixture (modern-css-surface inc 03) — blessed into the committed oracle.\n// Raw non-breakpoint @media block key (media feature, not a breakpoint map).\nimport { ds } from '../test-system';\n\nexport const MotionCard = createComponent('div', 'animus-MotionCard-fee1dd01', {});\nexport const App = () => ;\n\n" + }, + "css": "@layer anm-global, anm-base, anm-variants, anm-compounds, anm-states, anm-system, anm-custom;\n\n@layer anm-base {\n .animus-MotionCard-fee1dd01 {\n display: flex;\n }\n @media (prefers-reduced-motion: reduce) {\n .animus-MotionCard-fee1dd01 {\n display: none;\n }\n }\n}\n\n@layer anm-variants {\n @layer standalone, composed;\n @layer composed {\n }\n}\n\n", + "diagnostics": [], + "hasComponents": { + "condition-raw-media.tsx": true + }, + "observables": { + "componentFragmentKeys": [ + "condition-raw-media.tsx::MotionCard" + ], + "componentFragmentsJson": "{\"condition-raw-media.tsx::MotionCard\":{\"base\":\" .animus-MotionCard-fee1dd01 {\\n display: flex;\\n }\\n @media (prefers-reduced-motion: reduce) {\\n .animus-MotionCard-fee1dd01 {\\n display: none;\\n }\\n }\\n\"}}", + "dynamicPropsJson": "{}", + "reverseProvenanceEdges": [], + "sheetsJson": "{\"base\":\"@layer anm-base {\\n .animus-MotionCard-fee1dd01 {\\n display: flex;\\n }\\n @media (prefers-reduced-motion: reduce) {\\n .animus-MotionCard-fee1dd01 {\\n display: none;\\n }\\n }\\n}\\n\",\"compounds\":\"\",\"custom\":\"\",\"declaration\":\"@layer anm-global, anm-base, anm-variants, anm-compounds, anm-states, anm-system, anm-custom;\\n\",\"global\":\"@layer anm-global {\\nbody {\\n font-family: {fonts.base};\\n margin: 0;\\n}\\n@keyframes anm-ember {\\n 0% {\\n opacity: 0;\\n }\\n 100% {\\n opacity: 1;\\n }\\n}\\n}\\n\",\"states\":\"\",\"system\":\"\",\"variants\":\"@layer anm-variants {\\n @layer standalone, composed;\\n @layer composed {\\n }\\n}\\n\"}", + "systemPropMapJson": "{}" + }, + "parseCount": 1 + }, + "parity/condition-raw-supports.tsx": { + "code": { + "condition-raw-supports.tsx": "import { createComponent } from '@animus-ui/system';\nimport 'virtual:animus/styles.css';\n// Corpus fixture (modern-css-surface inc 03) — blessed into the committed oracle.\n// Raw @supports block key (feature query, incl. token resolution in the body).\nimport { ds } from '../test-system';\n\nexport const SupportsCard = createComponent('div', 'animus-SupportsCard-8b4f83eb', {});\nexport const App = () => ;\n\n" + }, + "css": "@layer anm-global, anm-base, anm-variants, anm-compounds, anm-states, anm-system, anm-custom;\n\n@layer anm-base {\n .animus-SupportsCard-8b4f83eb {\n display: block;\n }\n @supports (display: grid) {\n .animus-SupportsCard-8b4f83eb {\n display: grid;\n color: var(--color-primary);\n }\n }\n}\n\n@layer anm-variants {\n @layer standalone, composed;\n @layer composed {\n }\n}\n\n", + "diagnostics": [], + "hasComponents": { + "condition-raw-supports.tsx": true + }, + "observables": { + "componentFragmentKeys": [ + "condition-raw-supports.tsx::SupportsCard" + ], + "componentFragmentsJson": "{\"condition-raw-supports.tsx::SupportsCard\":{\"base\":\" .animus-SupportsCard-8b4f83eb {\\n display: block;\\n }\\n @supports (display: grid) {\\n .animus-SupportsCard-8b4f83eb {\\n display: grid;\\n color: var(--color-primary);\\n }\\n }\\n\"}}", + "dynamicPropsJson": "{}", + "reverseProvenanceEdges": [], + "sheetsJson": "{\"base\":\"@layer anm-base {\\n .animus-SupportsCard-8b4f83eb {\\n display: block;\\n }\\n @supports (display: grid) {\\n .animus-SupportsCard-8b4f83eb {\\n display: grid;\\n color: var(--color-primary);\\n }\\n }\\n}\\n\",\"compounds\":\"\",\"custom\":\"\",\"declaration\":\"@layer anm-global, anm-base, anm-variants, anm-compounds, anm-states, anm-system, anm-custom;\\n\",\"global\":\"@layer anm-global {\\nbody {\\n font-family: {fonts.base};\\n margin: 0;\\n}\\n@keyframes anm-ember {\\n 0% {\\n opacity: 0;\\n }\\n 100% {\\n opacity: 1;\\n }\\n}\\n}\\n\",\"states\":\"\",\"system\":\"\",\"variants\":\"@layer anm-variants {\\n @layer standalone, composed;\\n @layer composed {\\n }\\n}\\n\"}", + "systemPropMapJson": "{}" + }, + "parseCount": 1 + }, + "parity/contextual-var-consumer.tsx": { + "code": { + "contextual-var-consumer.tsx": "import { createComponent } from '@animus-ui/system';\nimport 'virtual:animus/styles.css';\n// Corpus fixture (modern-css-surface inc 08) — blessed into the committed oracle.\n// Registered-@property contextual-var pattern, COMPONENT side. The corpus\n// theme (packages/extract/tests/test-system.ts) declares the contextual var\n// `background-current` (colors scale); the `bg` prop carries\n// `currentVar: '--current-bg'`, so a component that sets `bg` writes the\n// contextual custom property that the theme's `@property` registration types.\n// This unit pins the resolver's contextual-var emission path; the `@property`\n// RULE itself is a theme-build artifact (createTheme variableCss) proven by the\n// showcase assert-lane @property pin (inc 08) + inc-07 unit tests; the Rust\n// oracle pins the component-side consumption below.\nimport { ds } from '../test-system';\n\nexport const ContextualCard = createComponent('div', 'animus-ContextualCard-cadc3e7d', {});\nexport const App = () => ;\n\n" + }, + "css": "@layer anm-global, anm-base, anm-variants, anm-compounds, anm-states, anm-system, anm-custom;\n\n@layer anm-base {\n .animus-ContextualCard-cadc3e7d {\n padding: 0.5rem;\n background-color: var(--color-background-current);\n --current-bg: var(--color-background-current);\n }\n @container card (min-width: 400px) {\n .animus-ContextualCard-cadc3e7d {\n border-top-color: var(--current-bg);\n border-top-style: solid;\n border-top-width: 1px;\n }\n }\n}\n\n@layer anm-variants {\n @layer standalone, composed;\n @layer composed {\n }\n}\n\n", + "diagnostics": [], + "hasComponents": { + "contextual-var-consumer.tsx": true + }, + "observables": { + "componentFragmentKeys": [ + "contextual-var-consumer.tsx::ContextualCard" + ], + "componentFragmentsJson": "{\"contextual-var-consumer.tsx::ContextualCard\":{\"base\":\" .animus-ContextualCard-cadc3e7d {\\n padding: 0.5rem;\\n background-color: var(--color-background-current);\\n --current-bg: var(--color-background-current);\\n }\\n @container card (min-width: 400px) {\\n .animus-ContextualCard-cadc3e7d {\\n border-top-color: var(--current-bg);\\n border-top-style: solid;\\n border-top-width: 1px;\\n }\\n }\\n\"}}", + "dynamicPropsJson": "{}", + "reverseProvenanceEdges": [], + "sheetsJson": "{\"base\":\"@layer anm-base {\\n .animus-ContextualCard-cadc3e7d {\\n padding: 0.5rem;\\n background-color: var(--color-background-current);\\n --current-bg: var(--color-background-current);\\n }\\n @container card (min-width: 400px) {\\n .animus-ContextualCard-cadc3e7d {\\n border-top-color: var(--current-bg);\\n border-top-style: solid;\\n border-top-width: 1px;\\n }\\n }\\n}\\n\",\"compounds\":\"\",\"custom\":\"\",\"declaration\":\"@layer anm-global, anm-base, anm-variants, anm-compounds, anm-states, anm-system, anm-custom;\\n\",\"global\":\"@layer anm-global {\\nbody {\\n font-family: {fonts.base};\\n margin: 0;\\n}\\n@keyframes anm-ember {\\n 0% {\\n opacity: 0;\\n }\\n 100% {\\n opacity: 1;\\n }\\n}\\n}\\n\",\"states\":\"\",\"system\":\"\",\"variants\":\"@layer anm-variants {\\n @layer standalone, composed;\\n @layer composed {\\n }\\n}\\n\"}", + "systemPropMapJson": "{}" + }, + "parseCount": 1 + }, "parity/cyclic-extension": { "code": { "a.tsx": "import { ds } from '../test-system';\nimport { B } from './b';\n\nexport const A = B.extend().styles({ p: 4 }).asElement('div');\n", diff --git a/packages/_parity/baselines/v2/production.json b/packages/_parity/baselines/v2/production.json index b3219c68..5bca081f 100644 --- a/packages/_parity/baselines/v2/production.json +++ b/packages/_parity/baselines/v2/production.json @@ -1,8 +1,8 @@ { - "corpusSha256": "231dd7127e27f85c1d860c058a4abe4b593c75f86c936787bb1d6117bdf62e06", + "corpusSha256": "c4049c6284797f7f2ad3eecd7468155afff9965ce5eca5244636cf7e299434f0", "engine": "v2", "mode": "production", - "refreshIntent": "embedded-transform-fixture-20260719", + "refreshIntent": "modern-css-surface-corpus-headers-20260722", "surfaceSchemaSha256": "43eb1265e97aab8497c11ca05e8a4159a6d4f4b69a6837a704e1d96fa75eb108", "units": { "extract-all": { @@ -710,6 +710,29 @@ }, "parseCount": 1 }, + "parity/compose-container-card.tsx": { + "code": { + "compose-container-card.tsx": "import { createComponent, createComposedFamily } from '@animus-ui/system';\nimport 'virtual:animus/styles.css';\n// Corpus fixture (modern-css-surface inc 08) — blessed into the committed oracle.\n// Canonical compose-slot CONTAINER pattern: the Root slot establishes a named\n// query container (container-name/container-type — design D7 pass-through\n// declarations) and slotted children respond via raw `@container card (…)`\n// block keys (design D2, no registration). Mirrors the landed test-ds\n// `ContainerCard` family; here as a parity oracle unit so the compose ×\n// container-establishment × @container-response combination is byte-pinned.\n\nimport { ds } from '../test-system';\n\nconst Root = createComponent('article', 'animus-Root-b4b4101f', {});\n\nconst Media = createComponent('div', 'animus-Media-cdf8bb75', {});\n\nconst Body = createComponent('div', 'animus-Body-c5ef664b', {});\n\nexport const ContainerCard = createComposedFamily({ Root: Root, Media: Media, Body: Body }, { name: \"ContainerCard\" });\nexport const App = () => (\n \n \n \n \n);\n\n" + }, + "css": "@layer anm-global, anm-base, anm-variants, anm-compounds, anm-states, anm-system, anm-custom;\n\n@layer anm-base {\n @container card (min-width: 400px) {\n .animus-Body-c5ef664b {\n padding: 1rem;\n }\n }\n .animus-Media-cdf8bb75 {\n width: 100%;\n }\n @container card (min-width: 400px) {\n .animus-Media-cdf8bb75 {\n padding: 1rem;\n width: 50cqw;\n }\n }\n .animus-Root-b4b4101f {\n padding: 0.5rem;\n display: flex;\n flex-direction: column;\n container-type: inline-size;\n container-name: card;\n }\n}\n\n@layer anm-variants {\n @layer standalone, composed;\n @layer composed {\n }\n}\n\n", + "diagnostics": [], + "hasComponents": { + "compose-container-card.tsx": true + }, + "observables": { + "componentFragmentKeys": [ + "compose-container-card.tsx::Body", + "compose-container-card.tsx::Media", + "compose-container-card.tsx::Root" + ], + "componentFragmentsJson": "{\"compose-container-card.tsx::Body\":{\"base\":\" @container card (min-width: 400px) {\\n .animus-Body-c5ef664b {\\n padding: 1rem;\\n }\\n }\\n\"},\"compose-container-card.tsx::Media\":{\"base\":\" .animus-Media-cdf8bb75 {\\n width: 100%;\\n }\\n @container card (min-width: 400px) {\\n .animus-Media-cdf8bb75 {\\n padding: 1rem;\\n width: 50cqw;\\n }\\n }\\n\"},\"compose-container-card.tsx::Root\":{\"base\":\" .animus-Root-b4b4101f {\\n padding: 0.5rem;\\n display: flex;\\n flex-direction: column;\\n container-type: inline-size;\\n container-name: card;\\n }\\n\"}}", + "dynamicPropsJson": "{}", + "reverseProvenanceEdges": [], + "sheetsJson": "{\"base\":\"@layer anm-base {\\n @container card (min-width: 400px) {\\n .animus-Body-c5ef664b {\\n padding: 1rem;\\n }\\n }\\n .animus-Media-cdf8bb75 {\\n width: 100%;\\n }\\n @container card (min-width: 400px) {\\n .animus-Media-cdf8bb75 {\\n padding: 1rem;\\n width: 50cqw;\\n }\\n }\\n .animus-Root-b4b4101f {\\n padding: 0.5rem;\\n display: flex;\\n flex-direction: column;\\n container-type: inline-size;\\n container-name: card;\\n }\\n}\\n\",\"compounds\":\"\",\"custom\":\"\",\"declaration\":\"@layer anm-global, anm-base, anm-variants, anm-compounds, anm-states, anm-system, anm-custom;\\n\",\"global\":\"@layer anm-global {\\nbody {\\n font-family: {fonts.base};\\n margin: 0;\\n}\\n@keyframes anm-ember {\\n 0% {\\n opacity: 0;\\n }\\n 100% {\\n opacity: 1;\\n }\\n}\\n}\\n\",\"states\":\"\",\"system\":\"\",\"variants\":\"@layer anm-variants {\\n @layer standalone, composed;\\n @layer composed {\\n }\\n}\\n\"}", + "systemPropMapJson": "{}" + }, + "parseCount": 1 + }, "parity/compose-only": { "code": { "family.tsx": "// Review F5 witness: a file whose ONLY animus construct is a compose()\n// call over IMPORTED slots has no surviving components of its own —\n// v1 returns it UNCHANGED (lib.rs 950-958, before compose handling).\nimport { compose } from '@animus-ui/system/compose';\n\nimport { Root, Body } from './slots';\n\nexport const Fam = compose({ Root, Body }, { name: 'Card', shared: {} });\nexport const App = () => ;\n", @@ -756,6 +779,195 @@ }, "parseCount": 1 }, + "parity/condition-aliased.tsx": { + "code": { + "condition-aliased.tsx": "import { createComponent } from '@animus-ui/system';\nimport 'virtual:animus/styles.css';\n// Corpus fixture (modern-css-surface inc 03) — blessed into the committed oracle.\n// Registered condition-alias block key. `_motionReduce` resolves through the\n// harness-supplied condition registry (HARNESS_CONDITION_ALIASES in\n// packages/_parity/src/engine-run.ts) to\n// `@media (prefers-reduced-motion: reduce)`.\nimport { ds } from '../test-system';\n\nexport const AliasedCard = createComponent('div', 'animus-AliasedCard-86f7ff22', {});\nexport const App = () => ;\n\n" + }, + "css": "@layer anm-global, anm-base, anm-variants, anm-compounds, anm-states, anm-system, anm-custom;\n\n@layer anm-base {\n .animus-AliasedCard-86f7ff22 {\n padding: 0.5rem;\n }\n @media (prefers-reduced-motion: reduce) {\n .animus-AliasedCard-86f7ff22 {\n display: none;\n }\n }\n}\n\n@layer anm-variants {\n @layer standalone, composed;\n @layer composed {\n }\n}\n\n", + "diagnostics": [], + "hasComponents": { + "condition-aliased.tsx": true + }, + "observables": { + "componentFragmentKeys": [ + "condition-aliased.tsx::AliasedCard" + ], + "componentFragmentsJson": "{\"condition-aliased.tsx::AliasedCard\":{\"base\":\" .animus-AliasedCard-86f7ff22 {\\n padding: 0.5rem;\\n }\\n @media (prefers-reduced-motion: reduce) {\\n .animus-AliasedCard-86f7ff22 {\\n display: none;\\n }\\n }\\n\"}}", + "dynamicPropsJson": "{}", + "reverseProvenanceEdges": [], + "sheetsJson": "{\"base\":\"@layer anm-base {\\n .animus-AliasedCard-86f7ff22 {\\n padding: 0.5rem;\\n }\\n @media (prefers-reduced-motion: reduce) {\\n .animus-AliasedCard-86f7ff22 {\\n display: none;\\n }\\n }\\n}\\n\",\"compounds\":\"\",\"custom\":\"\",\"declaration\":\"@layer anm-global, anm-base, anm-variants, anm-compounds, anm-states, anm-system, anm-custom;\\n\",\"global\":\"@layer anm-global {\\nbody {\\n font-family: {fonts.base};\\n margin: 0;\\n}\\n@keyframes anm-ember {\\n 0% {\\n opacity: 0;\\n }\\n 100% {\\n opacity: 1;\\n }\\n}\\n}\\n\",\"states\":\"\",\"system\":\"\",\"variants\":\"@layer anm-variants {\\n @layer standalone, composed;\\n @layer composed {\\n }\\n}\\n\"}", + "systemPropMapJson": "{}" + }, + "parseCount": 1 + }, + "parity/condition-builtin-motion.tsx": { + "code": { + "condition-builtin-motion.tsx": "import { createComponent } from '@animus-ui/system';\nimport 'virtual:animus/styles.css';\n// Corpus fixture (modern-css-surface inc 06) — blessed into the committed oracle.\n// Built-in media-feature condition alias `_motionReduce` (design D8). Unlike\n// condition-aliased.tsx (which proves a USER-registered alias), this proves the\n// BUILT-IN ships in the manifest with no explicit registration: the harness\n// condition map carries the built-in set, so `_motionReduce` resolves to\n// `@media (prefers-reduced-motion: reduce)`.\n// Spec: media-condition-aliases §\"Built-in media-feature condition aliases\" —\n// scenario \"Built-in motion alias\".\nimport { ds } from '../test-system';\n\nexport const MotionCard = createComponent('div', 'animus-MotionCard-d2881c34', {});\nexport const App = () => ;\n\n" + }, + "css": "@layer anm-global, anm-base, anm-variants, anm-compounds, anm-states, anm-system, anm-custom;\n\n@layer anm-base {\n .animus-MotionCard-d2881c34 {\n padding: 0.5rem;\n }\n @media (prefers-reduced-motion: reduce) {\n .animus-MotionCard-d2881c34 {\n transition: none;\n }\n }\n}\n\n@layer anm-variants {\n @layer standalone, composed;\n @layer composed {\n }\n}\n\n", + "diagnostics": [], + "hasComponents": { + "condition-builtin-motion.tsx": true + }, + "observables": { + "componentFragmentKeys": [ + "condition-builtin-motion.tsx::MotionCard" + ], + "componentFragmentsJson": "{\"condition-builtin-motion.tsx::MotionCard\":{\"base\":\" .animus-MotionCard-d2881c34 {\\n padding: 0.5rem;\\n }\\n @media (prefers-reduced-motion: reduce) {\\n .animus-MotionCard-d2881c34 {\\n transition: none;\\n }\\n }\\n\"}}", + "dynamicPropsJson": "{}", + "reverseProvenanceEdges": [], + "sheetsJson": "{\"base\":\"@layer anm-base {\\n .animus-MotionCard-d2881c34 {\\n padding: 0.5rem;\\n }\\n @media (prefers-reduced-motion: reduce) {\\n .animus-MotionCard-d2881c34 {\\n transition: none;\\n }\\n }\\n}\\n\",\"compounds\":\"\",\"custom\":\"\",\"declaration\":\"@layer anm-global, anm-base, anm-variants, anm-compounds, anm-states, anm-system, anm-custom;\\n\",\"global\":\"@layer anm-global {\\nbody {\\n font-family: {fonts.base};\\n margin: 0;\\n}\\n@keyframes anm-ember {\\n 0% {\\n opacity: 0;\\n }\\n 100% {\\n opacity: 1;\\n }\\n}\\n}\\n\",\"states\":\"\",\"system\":\"\",\"variants\":\"@layer anm-variants {\\n @layer standalone, composed;\\n @layer composed {\\n }\\n}\\n\"}", + "systemPropMapJson": "{}" + }, + "parseCount": 1 + }, + "parity/condition-builtin-order.tsx": { + "code": { + "condition-builtin-order.tsx": "import { createComponent } from '@animus-ui/system';\nimport 'virtual:animus/styles.css';\n// Corpus fixture (modern-css-surface inc 06) — blessed into the committed oracle.\n// ORDERING PROBE (design D4 + ORDER BAND): a built-in condition alias and a\n// user-band alias in the same style object must emit by registry `order`. The\n// built-in `_osDark` sits in the reserved built-in band (order 370); the\n// harness-registered `_motionReduce` sits in the user band (order 500). The\n// emitted CSS must therefore wrap `@media (prefers-color-scheme: dark)` BEFORE\n// `@media (prefers-reduced-motion: reduce)` — built-in band below user band.\nimport { ds } from '../test-system';\n\nexport const OrderProbe = createComponent('div', 'animus-OrderProbe-ca42ff82', {});\nexport const App = () => ;\n\n" + }, + "css": "@layer anm-global, anm-base, anm-variants, anm-compounds, anm-states, anm-system, anm-custom;\n\n@layer anm-base {\n .animus-OrderProbe-ca42ff82 {\n padding: 0.5rem;\n }\n @media (prefers-color-scheme: dark) {\n .animus-OrderProbe-ca42ff82 {\n color-scheme: dark;\n }\n }\n @media (prefers-reduced-motion: reduce) {\n .animus-OrderProbe-ca42ff82 {\n transition: none;\n }\n }\n}\n\n@layer anm-variants {\n @layer standalone, composed;\n @layer composed {\n }\n}\n\n", + "diagnostics": [], + "hasComponents": { + "condition-builtin-order.tsx": true + }, + "observables": { + "componentFragmentKeys": [ + "condition-builtin-order.tsx::OrderProbe" + ], + "componentFragmentsJson": "{\"condition-builtin-order.tsx::OrderProbe\":{\"base\":\" .animus-OrderProbe-ca42ff82 {\\n padding: 0.5rem;\\n }\\n @media (prefers-color-scheme: dark) {\\n .animus-OrderProbe-ca42ff82 {\\n color-scheme: dark;\\n }\\n }\\n @media (prefers-reduced-motion: reduce) {\\n .animus-OrderProbe-ca42ff82 {\\n transition: none;\\n }\\n }\\n\"}}", + "dynamicPropsJson": "{}", + "reverseProvenanceEdges": [], + "sheetsJson": "{\"base\":\"@layer anm-base {\\n .animus-OrderProbe-ca42ff82 {\\n padding: 0.5rem;\\n }\\n @media (prefers-color-scheme: dark) {\\n .animus-OrderProbe-ca42ff82 {\\n color-scheme: dark;\\n }\\n }\\n @media (prefers-reduced-motion: reduce) {\\n .animus-OrderProbe-ca42ff82 {\\n transition: none;\\n }\\n }\\n}\\n\",\"compounds\":\"\",\"custom\":\"\",\"declaration\":\"@layer anm-global, anm-base, anm-variants, anm-compounds, anm-states, anm-system, anm-custom;\\n\",\"global\":\"@layer anm-global {\\nbody {\\n font-family: {fonts.base};\\n margin: 0;\\n}\\n@keyframes anm-ember {\\n 0% {\\n opacity: 0;\\n }\\n 100% {\\n opacity: 1;\\n }\\n}\\n}\\n\",\"states\":\"\",\"system\":\"\",\"variants\":\"@layer anm-variants {\\n @layer standalone, composed;\\n @layer composed {\\n }\\n}\\n\"}", + "systemPropMapJson": "{}" + }, + "parseCount": 1 + }, + "parity/condition-builtin-osdark.tsx": { + "code": { + "condition-builtin-osdark.tsx": "import { createComponent } from '@animus-ui/system';\nimport 'virtual:animus/styles.css';\n// Corpus fixture (modern-css-surface inc 06) — blessed into the committed oracle.\n// Built-in OS color-scheme condition alias `_osDark` (design D8) — resolves to\n// `@media (prefers-color-scheme: dark)` through the shipped built-in set, no\n// user registration.\n// Spec: media-condition-aliases §\"Built-in media-feature condition aliases\" —\n// scenario \"Built-in OS color-scheme alias\".\nimport { ds } from '../test-system';\n\nexport const OsDarkCard = createComponent('div', 'animus-OsDarkCard-b2808cb4', {});\nexport const App = () => ;\n\n" + }, + "css": "@layer anm-global, anm-base, anm-variants, anm-compounds, anm-states, anm-system, anm-custom;\n\n@layer anm-base {\n .animus-OsDarkCard-b2808cb4 {\n display: flex;\n }\n @media (prefers-color-scheme: dark) {\n .animus-OsDarkCard-b2808cb4 {\n color-scheme: dark;\n }\n }\n}\n\n@layer anm-variants {\n @layer standalone, composed;\n @layer composed {\n }\n}\n\n", + "diagnostics": [], + "hasComponents": { + "condition-builtin-osdark.tsx": true + }, + "observables": { + "componentFragmentKeys": [ + "condition-builtin-osdark.tsx::OsDarkCard" + ], + "componentFragmentsJson": "{\"condition-builtin-osdark.tsx::OsDarkCard\":{\"base\":\" .animus-OsDarkCard-b2808cb4 {\\n display: flex;\\n }\\n @media (prefers-color-scheme: dark) {\\n .animus-OsDarkCard-b2808cb4 {\\n color-scheme: dark;\\n }\\n }\\n\"}}", + "dynamicPropsJson": "{}", + "reverseProvenanceEdges": [], + "sheetsJson": "{\"base\":\"@layer anm-base {\\n .animus-OsDarkCard-b2808cb4 {\\n display: flex;\\n }\\n @media (prefers-color-scheme: dark) {\\n .animus-OsDarkCard-b2808cb4 {\\n color-scheme: dark;\\n }\\n }\\n}\\n\",\"compounds\":\"\",\"custom\":\"\",\"declaration\":\"@layer anm-global, anm-base, anm-variants, anm-compounds, anm-states, anm-system, anm-custom;\\n\",\"global\":\"@layer anm-global {\\nbody {\\n font-family: {fonts.base};\\n margin: 0;\\n}\\n@keyframes anm-ember {\\n 0% {\\n opacity: 0;\\n }\\n 100% {\\n opacity: 1;\\n }\\n}\\n}\\n\",\"states\":\"\",\"system\":\"\",\"variants\":\"@layer anm-variants {\\n @layer standalone, composed;\\n @layer composed {\\n }\\n}\\n\"}", + "systemPropMapJson": "{}" + }, + "parseCount": 1 + }, + "parity/condition-builtin-print.tsx": { + "code": { + "condition-builtin-print.tsx": "import { createComponent } from '@animus-ui/system';\nimport 'virtual:animus/styles.css';\n// Corpus fixture (modern-css-surface inc 06) — blessed into the committed oracle.\n// Built-in print condition alias `_print` (design D8) — resolves to\n// `@media print` through the shipped built-in set, no user registration.\n// Spec: media-condition-aliases §\"Built-in media-feature condition aliases\" —\n// scenario \"Built-in print alias\".\nimport { ds } from '../test-system';\n\nexport const PrintCard = createComponent('div', 'animus-PrintCard-a553cb08', {});\nexport const App = () => ;\n\n" + }, + "css": "@layer anm-global, anm-base, anm-variants, anm-compounds, anm-states, anm-system, anm-custom;\n\n@layer anm-base {\n .animus-PrintCard-a553cb08 {\n display: block;\n }\n @media print {\n .animus-PrintCard-a553cb08 {\n display: none;\n }\n }\n}\n\n@layer anm-variants {\n @layer standalone, composed;\n @layer composed {\n }\n}\n\n", + "diagnostics": [], + "hasComponents": { + "condition-builtin-print.tsx": true + }, + "observables": { + "componentFragmentKeys": [ + "condition-builtin-print.tsx::PrintCard" + ], + "componentFragmentsJson": "{\"condition-builtin-print.tsx::PrintCard\":{\"base\":\" .animus-PrintCard-a553cb08 {\\n display: block;\\n }\\n @media print {\\n .animus-PrintCard-a553cb08 {\\n display: none;\\n }\\n }\\n\"}}", + "dynamicPropsJson": "{}", + "reverseProvenanceEdges": [], + "sheetsJson": "{\"base\":\"@layer anm-base {\\n .animus-PrintCard-a553cb08 {\\n display: block;\\n }\\n @media print {\\n .animus-PrintCard-a553cb08 {\\n display: none;\\n }\\n }\\n}\\n\",\"compounds\":\"\",\"custom\":\"\",\"declaration\":\"@layer anm-global, anm-base, anm-variants, anm-compounds, anm-states, anm-system, anm-custom;\\n\",\"global\":\"@layer anm-global {\\nbody {\\n font-family: {fonts.base};\\n margin: 0;\\n}\\n@keyframes anm-ember {\\n 0% {\\n opacity: 0;\\n }\\n 100% {\\n opacity: 1;\\n }\\n}\\n}\\n\",\"states\":\"\",\"system\":\"\",\"variants\":\"@layer anm-variants {\\n @layer standalone, composed;\\n @layer composed {\\n }\\n}\\n\"}", + "systemPropMapJson": "{}" + }, + "parseCount": 1 + }, + "parity/condition-raw-container.tsx": { + "code": { + "condition-raw-container.tsx": "import { createComponent } from '@animus-ui/system';\nimport 'virtual:animus/styles.css';\n// Corpus fixture (modern-css-surface inc 03) — blessed into the committed oracle.\n// Raw @container block key (named container + declarations). Resolves through\n// the test-system config; no condition-alias registration needed.\nimport { ds } from '../test-system';\n\nexport const ContainerCard = createComponent('div', 'animus-ContainerCard-739b1003', {});\nexport const App = () => ;\n\n" + }, + "css": "@layer anm-global, anm-base, anm-variants, anm-compounds, anm-states, anm-system, anm-custom;\n\n@layer anm-base {\n .animus-ContainerCard-739b1003 {\n padding: 0.5rem;\n }\n @container card (min-width: 400px) {\n .animus-ContainerCard-739b1003 {\n padding: 1rem;\n display: grid;\n }\n }\n}\n\n@layer anm-variants {\n @layer standalone, composed;\n @layer composed {\n }\n}\n\n", + "diagnostics": [], + "hasComponents": { + "condition-raw-container.tsx": true + }, + "observables": { + "componentFragmentKeys": [ + "condition-raw-container.tsx::ContainerCard" + ], + "componentFragmentsJson": "{\"condition-raw-container.tsx::ContainerCard\":{\"base\":\" .animus-ContainerCard-739b1003 {\\n padding: 0.5rem;\\n }\\n @container card (min-width: 400px) {\\n .animus-ContainerCard-739b1003 {\\n padding: 1rem;\\n display: grid;\\n }\\n }\\n\"}}", + "dynamicPropsJson": "{}", + "reverseProvenanceEdges": [], + "sheetsJson": "{\"base\":\"@layer anm-base {\\n .animus-ContainerCard-739b1003 {\\n padding: 0.5rem;\\n }\\n @container card (min-width: 400px) {\\n .animus-ContainerCard-739b1003 {\\n padding: 1rem;\\n display: grid;\\n }\\n }\\n}\\n\",\"compounds\":\"\",\"custom\":\"\",\"declaration\":\"@layer anm-global, anm-base, anm-variants, anm-compounds, anm-states, anm-system, anm-custom;\\n\",\"global\":\"@layer anm-global {\\nbody {\\n font-family: {fonts.base};\\n margin: 0;\\n}\\n@keyframes anm-ember {\\n 0% {\\n opacity: 0;\\n }\\n 100% {\\n opacity: 1;\\n }\\n}\\n}\\n\",\"states\":\"\",\"system\":\"\",\"variants\":\"@layer anm-variants {\\n @layer standalone, composed;\\n @layer composed {\\n }\\n}\\n\"}", + "systemPropMapJson": "{}" + }, + "parseCount": 1 + }, + "parity/condition-raw-media.tsx": { + "code": { + "condition-raw-media.tsx": "import { createComponent } from '@animus-ui/system';\nimport 'virtual:animus/styles.css';\n// Corpus fixture (modern-css-surface inc 03) — blessed into the committed oracle.\n// Raw non-breakpoint @media block key (media feature, not a breakpoint map).\nimport { ds } from '../test-system';\n\nexport const MotionCard = createComponent('div', 'animus-MotionCard-fee1dd01', {});\nexport const App = () => ;\n\n" + }, + "css": "@layer anm-global, anm-base, anm-variants, anm-compounds, anm-states, anm-system, anm-custom;\n\n@layer anm-base {\n .animus-MotionCard-fee1dd01 {\n display: flex;\n }\n @media (prefers-reduced-motion: reduce) {\n .animus-MotionCard-fee1dd01 {\n display: none;\n }\n }\n}\n\n@layer anm-variants {\n @layer standalone, composed;\n @layer composed {\n }\n}\n\n", + "diagnostics": [], + "hasComponents": { + "condition-raw-media.tsx": true + }, + "observables": { + "componentFragmentKeys": [ + "condition-raw-media.tsx::MotionCard" + ], + "componentFragmentsJson": "{\"condition-raw-media.tsx::MotionCard\":{\"base\":\" .animus-MotionCard-fee1dd01 {\\n display: flex;\\n }\\n @media (prefers-reduced-motion: reduce) {\\n .animus-MotionCard-fee1dd01 {\\n display: none;\\n }\\n }\\n\"}}", + "dynamicPropsJson": "{}", + "reverseProvenanceEdges": [], + "sheetsJson": "{\"base\":\"@layer anm-base {\\n .animus-MotionCard-fee1dd01 {\\n display: flex;\\n }\\n @media (prefers-reduced-motion: reduce) {\\n .animus-MotionCard-fee1dd01 {\\n display: none;\\n }\\n }\\n}\\n\",\"compounds\":\"\",\"custom\":\"\",\"declaration\":\"@layer anm-global, anm-base, anm-variants, anm-compounds, anm-states, anm-system, anm-custom;\\n\",\"global\":\"@layer anm-global {\\nbody {\\n font-family: {fonts.base};\\n margin: 0;\\n}\\n@keyframes anm-ember {\\n 0% {\\n opacity: 0;\\n }\\n 100% {\\n opacity: 1;\\n }\\n}\\n}\\n\",\"states\":\"\",\"system\":\"\",\"variants\":\"@layer anm-variants {\\n @layer standalone, composed;\\n @layer composed {\\n }\\n}\\n\"}", + "systemPropMapJson": "{}" + }, + "parseCount": 1 + }, + "parity/condition-raw-supports.tsx": { + "code": { + "condition-raw-supports.tsx": "import { createComponent } from '@animus-ui/system';\nimport 'virtual:animus/styles.css';\n// Corpus fixture (modern-css-surface inc 03) — blessed into the committed oracle.\n// Raw @supports block key (feature query, incl. token resolution in the body).\nimport { ds } from '../test-system';\n\nexport const SupportsCard = createComponent('div', 'animus-SupportsCard-8b4f83eb', {});\nexport const App = () => ;\n\n" + }, + "css": "@layer anm-global, anm-base, anm-variants, anm-compounds, anm-states, anm-system, anm-custom;\n\n@layer anm-base {\n .animus-SupportsCard-8b4f83eb {\n display: block;\n }\n @supports (display: grid) {\n .animus-SupportsCard-8b4f83eb {\n display: grid;\n color: var(--color-primary);\n }\n }\n}\n\n@layer anm-variants {\n @layer standalone, composed;\n @layer composed {\n }\n}\n\n", + "diagnostics": [], + "hasComponents": { + "condition-raw-supports.tsx": true + }, + "observables": { + "componentFragmentKeys": [ + "condition-raw-supports.tsx::SupportsCard" + ], + "componentFragmentsJson": "{\"condition-raw-supports.tsx::SupportsCard\":{\"base\":\" .animus-SupportsCard-8b4f83eb {\\n display: block;\\n }\\n @supports (display: grid) {\\n .animus-SupportsCard-8b4f83eb {\\n display: grid;\\n color: var(--color-primary);\\n }\\n }\\n\"}}", + "dynamicPropsJson": "{}", + "reverseProvenanceEdges": [], + "sheetsJson": "{\"base\":\"@layer anm-base {\\n .animus-SupportsCard-8b4f83eb {\\n display: block;\\n }\\n @supports (display: grid) {\\n .animus-SupportsCard-8b4f83eb {\\n display: grid;\\n color: var(--color-primary);\\n }\\n }\\n}\\n\",\"compounds\":\"\",\"custom\":\"\",\"declaration\":\"@layer anm-global, anm-base, anm-variants, anm-compounds, anm-states, anm-system, anm-custom;\\n\",\"global\":\"@layer anm-global {\\nbody {\\n font-family: {fonts.base};\\n margin: 0;\\n}\\n@keyframes anm-ember {\\n 0% {\\n opacity: 0;\\n }\\n 100% {\\n opacity: 1;\\n }\\n}\\n}\\n\",\"states\":\"\",\"system\":\"\",\"variants\":\"@layer anm-variants {\\n @layer standalone, composed;\\n @layer composed {\\n }\\n}\\n\"}", + "systemPropMapJson": "{}" + }, + "parseCount": 1 + }, + "parity/contextual-var-consumer.tsx": { + "code": { + "contextual-var-consumer.tsx": "import { createComponent } from '@animus-ui/system';\nimport 'virtual:animus/styles.css';\n// Corpus fixture (modern-css-surface inc 08) — blessed into the committed oracle.\n// Registered-@property contextual-var pattern, COMPONENT side. The corpus\n// theme (packages/extract/tests/test-system.ts) declares the contextual var\n// `background-current` (colors scale); the `bg` prop carries\n// `currentVar: '--current-bg'`, so a component that sets `bg` writes the\n// contextual custom property that the theme's `@property` registration types.\n// This unit pins the resolver's contextual-var emission path; the `@property`\n// RULE itself is a theme-build artifact (createTheme variableCss) proven by the\n// showcase assert-lane @property pin (inc 08) + inc-07 unit tests; the Rust\n// oracle pins the component-side consumption below.\nimport { ds } from '../test-system';\n\nexport const ContextualCard = createComponent('div', 'animus-ContextualCard-cadc3e7d', {});\nexport const App = () => ;\n\n" + }, + "css": "@layer anm-global, anm-base, anm-variants, anm-compounds, anm-states, anm-system, anm-custom;\n\n@layer anm-base {\n .animus-ContextualCard-cadc3e7d {\n padding: 0.5rem;\n background-color: var(--color-background-current);\n --current-bg: var(--color-background-current);\n }\n @container card (min-width: 400px) {\n .animus-ContextualCard-cadc3e7d {\n border-top-color: var(--current-bg);\n border-top-style: solid;\n border-top-width: 1px;\n }\n }\n}\n\n@layer anm-variants {\n @layer standalone, composed;\n @layer composed {\n }\n}\n\n", + "diagnostics": [], + "hasComponents": { + "contextual-var-consumer.tsx": true + }, + "observables": { + "componentFragmentKeys": [ + "contextual-var-consumer.tsx::ContextualCard" + ], + "componentFragmentsJson": "{\"contextual-var-consumer.tsx::ContextualCard\":{\"base\":\" .animus-ContextualCard-cadc3e7d {\\n padding: 0.5rem;\\n background-color: var(--color-background-current);\\n --current-bg: var(--color-background-current);\\n }\\n @container card (min-width: 400px) {\\n .animus-ContextualCard-cadc3e7d {\\n border-top-color: var(--current-bg);\\n border-top-style: solid;\\n border-top-width: 1px;\\n }\\n }\\n\"}}", + "dynamicPropsJson": "{}", + "reverseProvenanceEdges": [], + "sheetsJson": "{\"base\":\"@layer anm-base {\\n .animus-ContextualCard-cadc3e7d {\\n padding: 0.5rem;\\n background-color: var(--color-background-current);\\n --current-bg: var(--color-background-current);\\n }\\n @container card (min-width: 400px) {\\n .animus-ContextualCard-cadc3e7d {\\n border-top-color: var(--current-bg);\\n border-top-style: solid;\\n border-top-width: 1px;\\n }\\n }\\n}\\n\",\"compounds\":\"\",\"custom\":\"\",\"declaration\":\"@layer anm-global, anm-base, anm-variants, anm-compounds, anm-states, anm-system, anm-custom;\\n\",\"global\":\"@layer anm-global {\\nbody {\\n font-family: {fonts.base};\\n margin: 0;\\n}\\n@keyframes anm-ember {\\n 0% {\\n opacity: 0;\\n }\\n 100% {\\n opacity: 1;\\n }\\n}\\n}\\n\",\"states\":\"\",\"system\":\"\",\"variants\":\"@layer anm-variants {\\n @layer standalone, composed;\\n @layer composed {\\n }\\n}\\n\"}", + "systemPropMapJson": "{}" + }, + "parseCount": 1 + }, "parity/cyclic-extension": { "code": { "a.tsx": "import { ds } from '../test-system';\nimport { B } from './b';\n\nexport const A = B.extend().styles({ p: 4 }).asElement('div');\n", diff --git a/packages/_parity/corpus/compose-container-card.tsx b/packages/_parity/corpus/compose-container-card.tsx new file mode 100644 index 00000000..42bf6f2f --- /dev/null +++ b/packages/_parity/corpus/compose-container-card.tsx @@ -0,0 +1,44 @@ +// Corpus fixture (modern-css-surface inc 08) — blessed into the committed oracle. +// Canonical compose-slot CONTAINER pattern: the Root slot establishes a named +// query container (container-name/container-type — design D7 pass-through +// declarations) and slotted children respond via raw `@container card (…)` +// block keys (design D2, no registration). Mirrors the landed test-ds +// `ContainerCard` family; here as a parity oracle unit so the compose × +// container-establishment × @container-response combination is byte-pinned. +import { compose } from '@animus-ui/system/compose'; + +import { ds } from '../test-system'; + +const Root = ds + .styles({ + display: 'flex', + flexDirection: 'column', + p: 8, + containerType: 'inline-size', + containerName: 'card', + }) + .asElement('article'); + +const Media = ds + .styles({ + width: '100%', + '@container card (min-width: 400px)': { width: '50cqw', p: 16 }, + }) + .asElement('div'); + +const Body = ds + .styles({ + '@container card (min-width: 400px)': { p: 16 }, + }) + .asElement('div'); + +export const ContainerCard = compose( + { Root, Media, Body }, + { name: 'ContainerCard', shared: {} } +); +export const App = () => ( + + + + +); diff --git a/packages/_parity/corpus/condition-aliased.tsx b/packages/_parity/corpus/condition-aliased.tsx new file mode 100644 index 00000000..5108aec9 --- /dev/null +++ b/packages/_parity/corpus/condition-aliased.tsx @@ -0,0 +1,14 @@ +// Corpus fixture (modern-css-surface inc 03) — blessed into the committed oracle. +// Registered condition-alias block key. `_motionReduce` resolves through the +// harness-supplied condition registry (HARNESS_CONDITION_ALIASES in +// packages/_parity/src/engine-run.ts) to +// `@media (prefers-reduced-motion: reduce)`. +import { ds } from '../test-system'; + +export const AliasedCard = ds + .styles({ + p: 8, + _motionReduce: { display: 'none' }, + }) + .asElement('div'); +export const App = () => ; diff --git a/packages/_parity/corpus/condition-builtin-motion.tsx b/packages/_parity/corpus/condition-builtin-motion.tsx new file mode 100644 index 00000000..72681c7b --- /dev/null +++ b/packages/_parity/corpus/condition-builtin-motion.tsx @@ -0,0 +1,17 @@ +// Corpus fixture (modern-css-surface inc 06) — blessed into the committed oracle. +// Built-in media-feature condition alias `_motionReduce` (design D8). Unlike +// condition-aliased.tsx (which proves a USER-registered alias), this proves the +// BUILT-IN ships in the manifest with no explicit registration: the harness +// condition map carries the built-in set, so `_motionReduce` resolves to +// `@media (prefers-reduced-motion: reduce)`. +// Spec: media-condition-aliases §"Built-in media-feature condition aliases" — +// scenario "Built-in motion alias". +import { ds } from '../test-system'; + +export const MotionCard = ds + .styles({ + p: 8, + _motionReduce: { transition: 'none' }, + }) + .asElement('div'); +export const App = () => ; diff --git a/packages/_parity/corpus/condition-builtin-order.tsx b/packages/_parity/corpus/condition-builtin-order.tsx new file mode 100644 index 00000000..5192b5fb --- /dev/null +++ b/packages/_parity/corpus/condition-builtin-order.tsx @@ -0,0 +1,18 @@ +// Corpus fixture (modern-css-surface inc 06) — blessed into the committed oracle. +// ORDERING PROBE (design D4 + ORDER BAND): a built-in condition alias and a +// user-band alias in the same style object must emit by registry `order`. The +// built-in `_osDark` sits in the reserved built-in band (order 370); the +// harness-registered `_motionReduce` sits in the user band (order 500). The +// emitted CSS must therefore wrap `@media (prefers-color-scheme: dark)` BEFORE +// `@media (prefers-reduced-motion: reduce)` — built-in band below user band. +import { ds } from '../test-system'; + +export const OrderProbe = ds + .styles({ + p: 8, + // authored user-first to prove emission sorts by order, not source order + _motionReduce: { transition: 'none' }, + _osDark: { colorScheme: 'dark' }, + }) + .asElement('div'); +export const App = () => ; diff --git a/packages/_parity/corpus/condition-builtin-osdark.tsx b/packages/_parity/corpus/condition-builtin-osdark.tsx new file mode 100644 index 00000000..9f750b37 --- /dev/null +++ b/packages/_parity/corpus/condition-builtin-osdark.tsx @@ -0,0 +1,15 @@ +// Corpus fixture (modern-css-surface inc 06) — blessed into the committed oracle. +// Built-in OS color-scheme condition alias `_osDark` (design D8) — resolves to +// `@media (prefers-color-scheme: dark)` through the shipped built-in set, no +// user registration. +// Spec: media-condition-aliases §"Built-in media-feature condition aliases" — +// scenario "Built-in OS color-scheme alias". +import { ds } from '../test-system'; + +export const OsDarkCard = ds + .styles({ + display: 'flex', + _osDark: { colorScheme: 'dark' }, + }) + .asElement('div'); +export const App = () => ; diff --git a/packages/_parity/corpus/condition-builtin-print.tsx b/packages/_parity/corpus/condition-builtin-print.tsx new file mode 100644 index 00000000..26496f23 --- /dev/null +++ b/packages/_parity/corpus/condition-builtin-print.tsx @@ -0,0 +1,14 @@ +// Corpus fixture (modern-css-surface inc 06) — blessed into the committed oracle. +// Built-in print condition alias `_print` (design D8) — resolves to +// `@media print` through the shipped built-in set, no user registration. +// Spec: media-condition-aliases §"Built-in media-feature condition aliases" — +// scenario "Built-in print alias". +import { ds } from '../test-system'; + +export const PrintCard = ds + .styles({ + display: 'block', + _print: { display: 'none' }, + }) + .asElement('div'); +export const App = () => ; diff --git a/packages/_parity/corpus/condition-raw-container.tsx b/packages/_parity/corpus/condition-raw-container.tsx new file mode 100644 index 00000000..996cfc8b --- /dev/null +++ b/packages/_parity/corpus/condition-raw-container.tsx @@ -0,0 +1,12 @@ +// Corpus fixture (modern-css-surface inc 03) — blessed into the committed oracle. +// Raw @container block key (named container + declarations). Resolves through +// the test-system config; no condition-alias registration needed. +import { ds } from '../test-system'; + +export const ContainerCard = ds + .styles({ + p: 8, + '@container card (min-width: 400px)': { p: 16, display: 'grid' }, + }) + .asElement('div'); +export const App = () => ; diff --git a/packages/_parity/corpus/condition-raw-media.tsx b/packages/_parity/corpus/condition-raw-media.tsx new file mode 100644 index 00000000..ae33ef0a --- /dev/null +++ b/packages/_parity/corpus/condition-raw-media.tsx @@ -0,0 +1,11 @@ +// Corpus fixture (modern-css-surface inc 03) — blessed into the committed oracle. +// Raw non-breakpoint @media block key (media feature, not a breakpoint map). +import { ds } from '../test-system'; + +export const MotionCard = ds + .styles({ + display: 'flex', + '@media (prefers-reduced-motion: reduce)': { display: 'none' }, + }) + .asElement('div'); +export const App = () => ; diff --git a/packages/_parity/corpus/condition-raw-supports.tsx b/packages/_parity/corpus/condition-raw-supports.tsx new file mode 100644 index 00000000..f9531ffe --- /dev/null +++ b/packages/_parity/corpus/condition-raw-supports.tsx @@ -0,0 +1,11 @@ +// Corpus fixture (modern-css-surface inc 03) — blessed into the committed oracle. +// Raw @supports block key (feature query, incl. token resolution in the body). +import { ds } from '../test-system'; + +export const SupportsCard = ds + .styles({ + display: 'block', + '@supports (display: grid)': { display: 'grid', color: 'primary' }, + }) + .asElement('div'); +export const App = () => ; diff --git a/packages/_parity/corpus/contextual-var-consumer.tsx b/packages/_parity/corpus/contextual-var-consumer.tsx new file mode 100644 index 00000000..fdc12af6 --- /dev/null +++ b/packages/_parity/corpus/contextual-var-consumer.tsx @@ -0,0 +1,26 @@ +// Corpus fixture (modern-css-surface inc 08) — blessed into the committed oracle. +// Registered-@property contextual-var pattern, COMPONENT side. The corpus +// theme (packages/extract/tests/test-system.ts) declares the contextual var +// `background-current` (colors scale); the `bg` prop carries +// `currentVar: '--current-bg'`, so a component that sets `bg` writes the +// contextual custom property that the theme's `@property` registration types. +// This unit pins the resolver's contextual-var emission path; the `@property` +// RULE itself is a theme-build artifact (createTheme variableCss) proven by the +// showcase assert-lane @property pin (inc 08) + inc-07 unit tests; the Rust +// oracle pins the component-side consumption below. +import { ds } from '../test-system'; + +export const ContextualCard = ds + .styles({ + bg: 'background-current', + p: 8, + // A child slot inside a container reading the same contextual var by + // var() reference — the portable form a foreign theme also honors. + '@container card (min-width: 400px)': { + borderTopColor: 'var(--current-bg)', + borderTopStyle: 'solid', + borderTopWidth: '1px', + }, + }) + .asElement('div'); +export const App = () => ; diff --git a/packages/_parity/last-failure.txt b/packages/_parity/last-failure.txt index 9bb62eb3..47c9bd7d 100644 --- a/packages/_parity/last-failure.txt +++ b/packages/_parity/last-failure.txt @@ -1,14 +1,17 @@ parity baseline — engines: baseline:v2 vs v2 — devMode: false -Units passed: 47/48 (97.92%) -Divergences: 5 (5 unregistered) +Units passed: 56/58 (96.55%) +Divergences: 8 (8 unregistered) Failing units (sorted): - integration/transforms.tsx · css [selector] (UNREGISTERED) [a8f689d51f6b832c1a3024e00cb15f83130e3c78cd8c708ccafc25b25803a622 -> 760b26c47722f7c7936d9c45120631dc685c7474eeb36469f1ef84deb0ed9f58] — CSS bytes differ (411 vs 242) - integration/transforms.tsx · code (UNREGISTERED) [22790ac78746ab5eba70735939a34d61af00b8f061895ead6d3f869cc1b0a33c -> a6384cae245bef8af0e374e6c9313432242da435e5585ae390bbaafaf0bf946c] — transforms.tsx: transformed code not AST-equivalent - integration/transforms.tsx · observables (UNREGISTERED) [8edb3872e21f031bd4bd19a9427af186509395e9bcbd3878ef6304445d127d94 -> d2e51fab188d4f910184cc5c80651d21b8adeb9701a67129f092934659950841] — systemPropMapJson differs - integration/transforms.tsx · observables (UNREGISTERED) [8edb3872e21f031bd4bd19a9427af186509395e9bcbd3878ef6304445d127d94 -> d2e51fab188d4f910184cc5c80651d21b8adeb9701a67129f092934659950841] — sheetsJson differs - integration/transforms.tsx · observables (UNREGISTERED) [8edb3872e21f031bd4bd19a9427af186509395e9bcbd3878ef6304445d127d94 -> d2e51fab188d4f910184cc5c80651d21b8adeb9701a67129f092934659950841] — componentFragmentsJson differs + parity/compose-container-card.tsx · css (UNREGISTERED) [7dc386e22fccb03994ed06cb561164205272a488be8d121895fb86de2c654f39 -> e08ee182bbeb8ad3f3d85ca575348309116ccd729971a43eb058daf227c1be9b] — unit missing from baseline + parity/compose-container-card.tsx · code (UNREGISTERED) [7dc386e22fccb03994ed06cb561164205272a488be8d121895fb86de2c654f39 -> d4d6739101de036df2c85f1595a2519e8ae7b122482431bfadb537c32e21711a] — unit missing from baseline + parity/compose-container-card.tsx · observables (UNREGISTERED) [7dc386e22fccb03994ed06cb561164205272a488be8d121895fb86de2c654f39 -> 0063274266bec99df28d1f429f7f4c3a0a3f560eb6d91c80f5aabf59a8d3b8cd] — unit missing from baseline + parity/compose-container-card.tsx · diagnostics (UNREGISTERED) [7dc386e22fccb03994ed06cb561164205272a488be8d121895fb86de2c654f39 -> 4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945] — unit missing from baseline + parity/contextual-var-consumer.tsx · css (UNREGISTERED) [7dc386e22fccb03994ed06cb561164205272a488be8d121895fb86de2c654f39 -> b8c877ecc6c41beb735589844862130faf27797f567a2715ff05292b7b33e15e] — unit missing from baseline + parity/contextual-var-consumer.tsx · code (UNREGISTERED) [7dc386e22fccb03994ed06cb561164205272a488be8d121895fb86de2c654f39 -> dc77bcac641311fec0323abd869a00154ac1561614d0331ae3f22f156c6d4d15] — unit missing from baseline + parity/contextual-var-consumer.tsx · observables (UNREGISTERED) [7dc386e22fccb03994ed06cb561164205272a488be8d121895fb86de2c654f39 -> 50bd914d6943f9cbc982d2b9f9ba1f8179cce3f419de9305273695f9321fd2c7] — unit missing from baseline + parity/contextual-var-consumer.tsx · diagnostics (UNREGISTERED) [7dc386e22fccb03994ed06cb561164205272a488be8d121895fb86de2c654f39 -> 4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945] — unit missing from baseline Usage-case families: ok mdx-provider-scope — expected identical, observed identical @@ -28,15 +31,18 @@ Baseline metadata errors: parity baseline — engines: baseline:v2 vs v2 — devMode: true -Units passed: 47/48 (97.92%) -Divergences: 5 (5 unregistered) +Units passed: 56/58 (96.55%) +Divergences: 8 (8 unregistered) Failing units (sorted): - integration/transforms.tsx · css [selector] (UNREGISTERED) [a8f689d51f6b832c1a3024e00cb15f83130e3c78cd8c708ccafc25b25803a622 -> 760b26c47722f7c7936d9c45120631dc685c7474eeb36469f1ef84deb0ed9f58] — CSS bytes differ (411 vs 242) - integration/transforms.tsx · code (UNREGISTERED) [22790ac78746ab5eba70735939a34d61af00b8f061895ead6d3f869cc1b0a33c -> a6384cae245bef8af0e374e6c9313432242da435e5585ae390bbaafaf0bf946c] — transforms.tsx: transformed code not AST-equivalent - integration/transforms.tsx · observables (UNREGISTERED) [8edb3872e21f031bd4bd19a9427af186509395e9bcbd3878ef6304445d127d94 -> d2e51fab188d4f910184cc5c80651d21b8adeb9701a67129f092934659950841] — systemPropMapJson differs - integration/transforms.tsx · observables (UNREGISTERED) [8edb3872e21f031bd4bd19a9427af186509395e9bcbd3878ef6304445d127d94 -> d2e51fab188d4f910184cc5c80651d21b8adeb9701a67129f092934659950841] — sheetsJson differs - integration/transforms.tsx · observables (UNREGISTERED) [8edb3872e21f031bd4bd19a9427af186509395e9bcbd3878ef6304445d127d94 -> d2e51fab188d4f910184cc5c80651d21b8adeb9701a67129f092934659950841] — componentFragmentsJson differs + parity/compose-container-card.tsx · css (UNREGISTERED) [7dc386e22fccb03994ed06cb561164205272a488be8d121895fb86de2c654f39 -> e08ee182bbeb8ad3f3d85ca575348309116ccd729971a43eb058daf227c1be9b] — unit missing from baseline + parity/compose-container-card.tsx · code (UNREGISTERED) [7dc386e22fccb03994ed06cb561164205272a488be8d121895fb86de2c654f39 -> d4d6739101de036df2c85f1595a2519e8ae7b122482431bfadb537c32e21711a] — unit missing from baseline + parity/compose-container-card.tsx · observables (UNREGISTERED) [7dc386e22fccb03994ed06cb561164205272a488be8d121895fb86de2c654f39 -> 0063274266bec99df28d1f429f7f4c3a0a3f560eb6d91c80f5aabf59a8d3b8cd] — unit missing from baseline + parity/compose-container-card.tsx · diagnostics (UNREGISTERED) [7dc386e22fccb03994ed06cb561164205272a488be8d121895fb86de2c654f39 -> 4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945] — unit missing from baseline + parity/contextual-var-consumer.tsx · css (UNREGISTERED) [7dc386e22fccb03994ed06cb561164205272a488be8d121895fb86de2c654f39 -> b8c877ecc6c41beb735589844862130faf27797f567a2715ff05292b7b33e15e] — unit missing from baseline + parity/contextual-var-consumer.tsx · code (UNREGISTERED) [7dc386e22fccb03994ed06cb561164205272a488be8d121895fb86de2c654f39 -> dc77bcac641311fec0323abd869a00154ac1561614d0331ae3f22f156c6d4d15] — unit missing from baseline + parity/contextual-var-consumer.tsx · observables (UNREGISTERED) [7dc386e22fccb03994ed06cb561164205272a488be8d121895fb86de2c654f39 -> 50bd914d6943f9cbc982d2b9f9ba1f8179cce3f419de9305273695f9321fd2c7] — unit missing from baseline + parity/contextual-var-consumer.tsx · diagnostics (UNREGISTERED) [7dc386e22fccb03994ed06cb561164205272a488be8d121895fb86de2c654f39 -> 4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945] — unit missing from baseline Usage-case families: ok mdx-provider-scope — expected identical, observed identical diff --git a/packages/_parity/scoreboard.snap b/packages/_parity/scoreboard.snap index 9238991d..d964d0f6 100644 --- a/packages/_parity/scoreboard.snap +++ b/packages/_parity/scoreboard.snap @@ -1,6 +1,6 @@ parity baseline — engines: baseline:v2 vs v2 — devMode: false -Units passed: 48/48 (100.00%) +Units passed: 58/58 (100.00%) Divergences: 0 (0 unregistered) Usage-case families: @@ -19,7 +19,7 @@ Usage-case families: parity baseline — engines: baseline:v2 vs v2 — devMode: true -Units passed: 48/48 (100.00%) +Units passed: 58/58 (100.00%) Divergences: 0 (0 unregistered) Usage-case families: diff --git a/packages/_parity/self-check.snap b/packages/_parity/self-check.snap index de932f0b..734239db 100644 --- a/packages/_parity/self-check.snap +++ b/packages/_parity/self-check.snap @@ -1,6 +1,6 @@ parity self-check — engines: v2 vs v2 — devMode: false -Units passed: 48/48 (100.00%) +Units passed: 58/58 (100.00%) Divergences: 0 (0 unregistered) Usage-case families: @@ -19,7 +19,7 @@ Usage-case families: parity self-check — engines: v2 vs v2 — devMode: true -Units passed: 48/48 (100.00%) +Units passed: 58/58 (100.00%) Divergences: 0 (0 unregistered) Usage-case families: diff --git a/packages/_parity/src/engine-run.ts b/packages/_parity/src/engine-run.ts index e9019d4b..7562fa71 100644 --- a/packages/_parity/src/engine-run.ts +++ b/packages/_parity/src/engine-run.ts @@ -57,7 +57,8 @@ function loadEngine(name: string): EngineApi { _selectorOrder: unknown, globalStyleBlocks: unknown, pathAliases: unknown, - keyframes: unknown + keyframes: unknown, + conditionAliases: unknown ) => { // NAPI Option fields: undefined → None; null is a conversion error. instance = new native.ExtractEngine({ @@ -67,6 +68,7 @@ function loadEngine(name: string): EngineApi { configJson: propConfig, groupRegistryJson: groupRegistry, selectorAliasesJson: selectorAliases ?? undefined, + conditionAliasesJson: conditionAliases ?? undefined, // Caller positions 12/13/14 (row-13 review A6): preserve the // supplied harness inputs rather than re-asserting constants. globalStyleBlocksJson: globalStyleBlocks ?? undefined, @@ -104,6 +106,39 @@ const HARNESS_KEYFRAMES = JSON.stringify({ }, }, }); +/** Harness-supplied condition alias registry (modern-css-surface inc 03/06). + * The test system registers none, so — exactly like HARNESS_GLOBAL_BLOCKS / + * HARNESS_KEYFRAMES above — the harness supplies one so the condition corpus + * fixtures resolve instead of remaining green-by-vacuity. Shape mirrors the + * serialized `conditionAliases` manifest field: alias → {value,order,kind}. + * + * `_motionReduce` (order 500) is the pre-existing inc-03 user-band entry that + * the blessed `condition-aliased` unit depends on — left BYTE-IDENTICAL so + * existing baselines do not move. Inc 06 ADDS the built-in-band entries the + * new `condition-builtin-*` staging fixtures reference (`_osDark`, `_print`) + * at their real built-in cascade orders (design D8, band 300–380). Additive + * only: existing units are keyed by alias name and never touch these keys. */ +const HARNESS_CONDITION_ALIASES = JSON.stringify({ + _motionReduce: { + value: '@media (prefers-reduced-motion: reduce)', + order: 500, + kind: 'media', + }, + // Built-in condition aliases (inc 06, design D8) at their reserved-band + // orders — the `condition-builtin-*` fixtures prove built-ins resolve with no + // user registration, and `condition-builtin-order` proves the built-in band + // (370) emits before the user band (500). + _osDark: { + value: '@media (prefers-color-scheme: dark)', + order: 370, + kind: 'media', + }, + _print: { + value: '@media print', + order: 320, + kind: 'media', + }, +}); function fragmentsOf(manifest: Record) { return (manifest.component_fragments ?? @@ -161,7 +196,7 @@ async function main() { HARNESS_GLOBAL_BLOCKS, null, HARNESS_KEYFRAMES, - null + HARNESS_CONDITION_ALIASES ); const manifest = JSON.parse(manifestJson); diff --git a/packages/extract/crates/extract-v2/index.d.ts b/packages/extract/crates/extract-v2/index.d.ts index d95208ab..5ddd2dd6 100644 --- a/packages/extract/crates/extract-v2/index.d.ts +++ b/packages/extract/crates/extract-v2/index.d.ts @@ -57,6 +57,12 @@ export interface EngineOptions { groupRegistryJson?: string /** Selector aliases JSON (v1 `selector_aliases_json`). */ selectorAliasesJson?: string + /** + * Condition aliases JSON (inc 03 — `conditionAliases` manifest field): + * `{ "_motionReduce": { "value": "@media …", "order": 500, "kind": + * "media" } }`. Absent = no registrations. + */ + conditionAliasesJson?: string /** Global style blocks JSON (v1 `global_style_blocks_json`). */ globalStyleBlocksJson?: string /** @@ -104,6 +110,11 @@ export interface NapiSystemConfig { contextualVarsJson: string selectorAliases?: string selectorOrder?: string + /** + * Condition alias map JSON (inc 03 — `conditionAliases`): alias → + * `{ value, order, kind }`. Absent when the system registers none. + */ + conditionAliases?: string globalStyleBlocks?: string keyframesBlocks?: string } diff --git a/packages/extract/crates/extract-v2/src/analyze_css.rs b/packages/extract/crates/extract-v2/src/analyze_css.rs index 474520bb..fdb95825 100644 --- a/packages/extract/crates/extract-v2/src/analyze_css.rs +++ b/packages/extract/crates/extract-v2/src/analyze_css.rs @@ -45,8 +45,8 @@ use crate::jsx_scan::{ComponentUsageConfig, DynamicPropUsage, SystemPropUsage, U use crate::pipeline::process_chain_facts; use crate::reconcile::{build_ledger, identify_prospective_eliminations, reconcile, VariantConfigMap}; use crate::theme::{ - ContextualVarsMap, CssDeclaration, FlatTheme, PropConfigMap, ResolveContext, ResolvedStyles, - SelectorAliasesMap, VariableMap, + ConditionAliasesMap, ContextualVarsMap, CssDeclaration, FlatTheme, PropConfigMap, + ResolveContext, ResolvedStyles, SelectorAliasesMap, VariableMap, }; use crate::usage_facts::{ImportFact, UsageResidueRecord}; @@ -104,6 +104,9 @@ pub struct CssInputs { pub config: PropConfigMap, pub group_registry: FxHashMap>, pub selector_aliases: SelectorAliasesMap, + /// Condition alias registry (`conditionAliases` manifest field, inc 03): + /// `_motionReduce` → { value, order, kind }. Empty = no registrations. + pub condition_aliases: ConditionAliasesMap, /// v1 `global_style_blocks_json` (resolved into sheets.global). pub global_style_blocks: Option, /// v1 `keyframes_blocks_json` (keyframes registry + global CSS). @@ -126,6 +129,7 @@ impl CssInputs { config_json: Option<&str>, group_registry_json: Option<&str>, selector_aliases_json: Option<&str>, + condition_aliases_json: Option<&str>, global_style_blocks_json: Option<&str>, keyframes_json: Option<&str>, package_resolution_json: Option<&str>, @@ -188,6 +192,7 @@ impl CssInputs { config: parse("configJson", config_json)?, group_registry: parse("groupRegistryJson", group_registry_json)?, selector_aliases: parse("selectorAliasesJson", selector_aliases_json)?, + condition_aliases: parse("conditionAliasesJson", condition_aliases_json)?, global_style_blocks: parse_opt_value( "globalStyleBlocksJson", global_style_blocks_json, @@ -433,13 +438,8 @@ fn shed_unresolved_aliases_in_styles( for (_, decls) in &mut styles.pseudo_selectors { shed_unresolved_alias_decls(decls, file, component, diagnostics); } - for (_, decls) in &mut styles.responsive { - shed_unresolved_alias_decls(decls, file, component, diagnostics); - } - for (_, pseudos) in &mut styles.responsive_pseudos { - for (_, decls) in pseudos { - shed_unresolved_alias_decls(decls, file, component, diagnostics); - } + for group in &mut styles.conditioned { + shed_unresolved_alias_decls(&mut group.declarations, file, component, diagnostics); } } @@ -719,6 +719,7 @@ fn run_with_system_floor( contextual_vars: &inputs.contextual_vars, breakpoint_keys: &bp_keys, selector_aliases: &inputs.selector_aliases, + condition_aliases: &inputs.condition_aliases, transform_evaluator: Some(&evaluator), }; @@ -913,23 +914,51 @@ fn run_with_system_floor( } } - let mut merged_responsive = parent_base.responsive.clone(); - for (bp, decls) in &child_base.responsive { - if let Some(entry) = - merged_responsive.iter_mut().find(|(b, _)| b == bp) + // Extension merge: start from the parent's condition + // groups, then let the child's groups replace-by-key. The + // legacy two-bucket bug-compat only ever licensed dropping + // the child's SELECTOR-BEARING groups (nested selectors are + // inc 05); the child's selectorless breakpoint AND + // non-breakpoint (Media/Container/Supports) groups both + // carry through — breakpoints by name, conditions by + // (conditions, selector). Byte-safe: pre-inc-03 fixtures + // have no non-breakpoint groups, so this loop is a no-op + // for them (G1). + let mut merged = ResolvedStyles { + declarations: merged_decls, + pseudo_selectors: merged_pseudos, + conditioned: parent_base.conditioned.clone(), + }; + for (bp, decls) in child_base.breakpoint_groups() { + let slot = merged.breakpoint_decls_mut(bp); + *slot = decls.clone(); + } + for child_group in &child_base.conditioned { + // Selectorless single-breakpoint groups merged via + // breakpoint_decls_mut above; every other shape — + // incl. [Breakpoint]+selector (inc 05 review F2) — + // replaces-by-(conditions, selector) or appends. + let plain_breakpoint = matches!( + child_group.emit_order, + crate::theme::ConditionEmitOrder::Breakpoint + ) && child_group.selector.is_none() + && child_group.conditions.len() == 1; + if plain_breakpoint { + continue; + } + if let Some(existing) = + merged.conditioned.iter_mut().find(|g| { + g.conditions == child_group.conditions + && g.selector == child_group.selector + }) { - entry.1 = decls.clone(); + *existing = child_group.clone(); } else { - merged_responsive.push((bp.clone(), decls.clone())); + merged.conditioned.push(child_group.clone()); } } - component_css.base = Some(ResolvedStyles { - declarations: merged_decls, - pseudo_selectors: merged_pseudos, - responsive: merged_responsive, - responsive_pseudos: parent_base.responsive_pseudos.clone(), - }); + component_css.base = Some(merged); } (Some(parent_base), None) => { component_css.base = Some(parent_base.clone()); @@ -1971,6 +2000,7 @@ mod tests { Some(r#"{"p": {"property": "padding", "scale": "space"}, "display": {"property": "display"}}"#), Some(r#"{"space": ["p", "m"]}"#), None, + None, // condition_aliases_json None, None, None, @@ -2646,6 +2676,75 @@ mod tests { ); } + #[test] + fn extension_child_condition_block_carries_through_merge() { + // Regression (inc 03): the extend-merge previously dropped the CHILD's + // selectorless Media/Container/Supports groups (only the parent's + // carried through). A child's own condition block must survive into the + // child's emitted rule, wrapping the child's class inside its @layer. + let out = analyze( + &[ + ( + "base.tsx", + "export const Parent = ds.styles({ display: 'flex' }).asElement('div');\nexport const A = () => ;\n", + ), + ( + "child.tsx", + "import { Parent } from './base';\nexport const Child = Parent.extend().styles({ '@container (min-width: 400px)': { p: 8 } }).asElement('div');\nexport const B = () => ;\n", + ), + ], + &test_inputs(), + ); + let ci = out + .sheets + .base + .find("@container (min-width: 400px)") + .unwrap_or_else(|| panic!("child container block dropped:\n{}", out.sheets.base)); + let after = &out.sheets.base[ci..]; + assert!( + after.contains("animus-Child-"), + "container must wrap the child class:\n{}", + out.sheets.base + ); + assert!(after.contains("padding: 0.5rem"), "{}", out.sheets.base); + // Parent's inherited base declaration still present on the child rule. + assert!(out.sheets.base.contains("display: flex"), "{}", out.sheets.base); + } + + #[test] + fn extension_child_responsive_selector_group_carries() { + // F2 (inc-05 review): the child's [Breakpoint]+selector group + // (responsive map inside a selector block) must survive the + // extend-merge — same silent-drop family as the inc-03 fix above. + let out = analyze( + &[ + ( + "base.tsx", + "export const Parent = ds.styles({ display: 'flex' }).asElement('div');\nexport const A = () => ;\n", + ), + ( + "child.tsx", + "import { Parent } from './base';\nexport const Child = Parent.extend().styles({ '&:hover': { p: { _: 8, sm: 16 } } }).asElement('div');\nexport const B = () => ;\n", + ), + ], + &test_inputs(), + ); + let hover_base = out.sheets.base.contains(":hover"); + assert!(hover_base, "hover pseudo present:\n{}", out.sheets.base); + let mi = out + .sheets + .base + .find("@media (min-width: 480px)") + .unwrap_or_else(|| panic!("child bp+selector group dropped:\n{}", out.sheets.base)); + let after = &out.sheets.base[mi..]; + assert!( + after.contains(":hover"), + "selector must compose inside the media wrapper:\n{}", + out.sheets.base + ); + assert!(after.contains("padding: 16"), "{}", out.sheets.base); + } + #[test] fn named_transform_registers_and_applies() { let mut inputs = test_inputs(); diff --git a/packages/extract/crates/extract-v2/src/css.rs b/packages/extract/crates/extract-v2/src/css.rs index b554b924..a472f918 100644 --- a/packages/extract/crates/extract-v2/src/css.rs +++ b/packages/extract/crates/extract-v2/src/css.rs @@ -15,7 +15,7 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; -use crate::theme::{CssDeclaration, PropConfigMap, ResolveContext, ResolvedStyles, ResponsivePseudoGroups, resolve_styles}; +use crate::theme::{ConditionedGroup, CssDeclaration, PropConfigMap, ResolveContext, ResolvedStyles, resolve_styles}; /// v1 project_analyzer::camel_to_kebab, inlined VERBATIM for the v2 port. pub fn camel_to_kebab(s: &str) -> String { @@ -583,8 +583,8 @@ fn write_rule_block( // Responsive declarations — sorted by breakpoint pixel value (ascending) // to ensure correct cascade: smaller breakpoints first, larger override later. - let mut sorted_responsive: Vec<&(String, Vec)> = - styles.responsive.iter().collect(); + let mut sorted_responsive: Vec<(&String, &Vec)> = + styles.breakpoint_groups().collect(); sorted_responsive.sort_by_key(|(bp_name, _)| { breakpoints.breakpoints.get(bp_name.as_str()).copied().unwrap_or(0) }); @@ -602,6 +602,34 @@ fn write_rule_block( } } } + + // Responsive selector groups (inc 05: responsive value maps inside + // selector blocks) — px ascending, after the selectorless breakpoint + // rules; one @media wrapper per (breakpoint, selector) group (the + // per-triple granularity decided at population time — journal R7/R8). + let mut sorted_responsive_selectors: Vec<(&String, &String, &Vec)> = + styles.breakpoint_selector_groups().collect(); + sorted_responsive_selectors.sort_by_key(|(bp_name, _, _)| { + breakpoints.breakpoints.get(bp_name.as_str()).copied().unwrap_or(0) + }); + for (bp_name, sel, declarations) in sorted_responsive_selectors { + if let Some(mq) = breakpoints.media_query(bp_name) { + if !declarations.is_empty() { + writeln!(output, " {} {{", mq).unwrap(); + write_declarations_indented( + output, + &format_pseudo_selector(selector, sel), + declarations, + 4, + ); + writeln!(output, " }}").unwrap(); + } + } + } + + // Condition blocks (Media/Container/Supports) — after breakpoints, in + // registry/source order (design D4). Nested inside the owning @layer. + write_condition_blocks(output, &[format!(".{}", selector)], styles, breakpoints); } /// Sort order for pseudo-selectors within a single rule block. @@ -610,7 +638,7 @@ fn write_rule_block( fn pseudo_sort_order(selector: &str) -> u32 { // Extract the first selector segment for compound selectors let first = selector.split(',').next().unwrap_or(selector).trim(); - match first { + let exact = match first { ":link" => 10, ":visited" => 20, ":hover" => 30, @@ -638,7 +666,41 @@ fn pseudo_sort_order(selector: &str) -> u32 { ":empty" => 440, // Unknown selectors sort to end (preserve insertion order among unknowns) _ => 900, + }; + if exact != 900 { + return exact; + } + // Composed selectors (inc 05): order by the OUTER segment's cascade + // position — the longest known pseudo head wins; unknown heads keep the + // 900/insertion bucket. Exact matches above are untouched (depth-1 + // byte-identity); pre-inc-05 output has no composed producers. + const KNOWN_HEADS: &[(&str, u32)] = &[ + (":focus-within", 40), + (":focus-visible", 60), + (":first-child", 400), + (":last-child", 410), + ("::placeholder", 320), + ("::selection", 330), + ("::before", 300), + ("::after", 310), + (":visited", 20), + (":hover", 30), + (":active", 70), + (":target", 80), + (":focus", 50), + (":empty", 440), + (":link", 10), + ]; + let mut best: Option<(usize, u32)> = None; + for (head, ord) in KNOWN_HEADS { + if first.starts_with(head) + && first.len() > head.len() + && best.is_none_or(|(len, _)| head.len() > len) + { + best = Some((head.len(), *ord)); + } } + best.map_or(900, |(_, ord)| ord) } /// Format a pseudo-selector with the base class, handling comma-separated selectors. @@ -677,6 +739,71 @@ fn write_declarations_indented( writeln!(output, "{}}}", pad).unwrap(); } +/// Emit non-breakpoint condition blocks (Media/Container/Supports) wrapping +/// one or more inner selectors, in deterministic emission order (design D4: +/// aliased conditions by registry order, then raw keys in source order). Each +/// at-rule nests INSIDE the caller's `@layer` block; the class selector nests +/// inside the at-rule. `inner_selectors` are the fully-formed, dot-prefixed +/// selector strings (one for base/variant/state/utility rules; two for the +/// composed inheritance/override pair). Callers invoke this AFTER pseudos and +/// breakpoint media queries so the total within-rule order holds. +fn write_condition_blocks( + output: &mut String, + inner_selectors: &[String], + styles: &ResolvedStyles, + breakpoints: &BreakpointMap, +) { + for group in styles.conditioned_emission_order() { + if group.declarations.is_empty() { + continue; + } + // Resolve every prelude in the stack (inc 05: stacks wrap + // outermost-first; inner Breakpoint conditions resolve through the + // BreakpointMap — e.g. a responsive value map inside a container + // block). A stack with an unresolvable member emits nothing. + let mut preludes: Vec = Vec::with_capacity(group.conditions.len()); + let mut resolvable = true; + for condition in &group.conditions { + match condition { + crate::theme::Condition::Breakpoint(bp) => match breakpoints.media_query(bp) { + Some(mq) => preludes.push(mq), + None => { + resolvable = false; + break; + } + }, + other => match other.prelude() { + Some(p) => preludes.push(p.to_string()), + None => { + resolvable = false; + break; + } + }, + } + } + if !resolvable || preludes.is_empty() { + continue; + } + for (depth, prelude) in preludes.iter().enumerate() { + writeln!(output, "{}{} {{", " ".repeat(depth + 1), prelude).unwrap(); + } + let decl_indent = 2 * (preludes.len() + 1); + for inner in inner_selectors { + // Nested selector within the condition (inc 05). Known edge: a + // comma-list selector whose parts are descendants loses the + // descendant space to format_composed_pseudo's part-trim. + let sel = match &group.selector { + Some(s) => format_composed_pseudo(inner, s), + None => inner.clone(), + }; + write_declarations_indented(output, &sel, &group.declarations, decl_indent); + } + for depth in (0..preludes.len()).rev() { + writeln!(output, "{}}}", " ".repeat(depth + 1)).unwrap(); + } + } +} + // --------------------------------------------------------------------------- // Composed variant CSS — two-rule model for CSS-only shared propagation // --------------------------------------------------------------------------- @@ -788,8 +915,8 @@ fn write_composed_rule_pair( } // Responsive declarations — sorted by breakpoint pixel value (ascending) - let mut sorted_responsive: Vec<&(String, Vec)> = - styles.responsive.iter().collect(); + let mut sorted_responsive: Vec<(&String, &Vec)> = + styles.breakpoint_groups().collect(); sorted_responsive.sort_by_key(|(bp_name, _)| { breakpoints.breakpoints.get(bp_name.as_str()).copied().unwrap_or(0) }); @@ -805,25 +932,32 @@ fn write_composed_rule_pair( } // Responsive pseudo-selectors — sorted by breakpoint pixel value (ascending) - let mut sorted_responsive_pseudos: Vec<&(String, ResponsivePseudoGroups)> = - styles.responsive_pseudos.iter().collect(); - sorted_responsive_pseudos.sort_by_key(|(bp_name, _)| { + let mut sorted_responsive_pseudos: Vec<(&String, &String, &Vec)> = + styles.breakpoint_selector_groups().collect(); + sorted_responsive_pseudos.sort_by_key(|(bp_name, _, _)| { breakpoints.breakpoints.get(bp_name.as_str()).copied().unwrap_or(0) }); - for (bp_name, pseudo_groups) in sorted_responsive_pseudos { + for (bp_name, pseudo, declarations) in sorted_responsive_pseudos { if let Some(mq) = breakpoints.media_query(bp_name) { - for (pseudo, declarations) in pseudo_groups { - if !declarations.is_empty() { - let inh_pseudo = format_composed_pseudo(&inheritance_selector, pseudo); - let ovr_pseudo = format_composed_pseudo(&override_selector, pseudo); - writeln!(output, " {} {{", mq).unwrap(); - write_declarations_indented(output, &inh_pseudo, declarations, 4); - write_declarations_indented(output, &ovr_pseudo, declarations, 4); - writeln!(output, " }}").unwrap(); - } + if !declarations.is_empty() { + let inh_pseudo = format_composed_pseudo(&inheritance_selector, pseudo); + let ovr_pseudo = format_composed_pseudo(&override_selector, pseudo); + writeln!(output, " {} {{", mq).unwrap(); + write_declarations_indented(output, &inh_pseudo, declarations, 4); + write_declarations_indented(output, &ovr_pseudo, declarations, 4); + writeln!(output, " }}").unwrap(); } } } + + // Condition blocks — both inheritance and override selectors nest inside + // each at-rule (design D4), after the breakpoint media queries. + write_condition_blocks( + output, + &[inheritance_selector.clone(), override_selector.clone()], + styles, + breakpoints, + ); } /// Format a pseudo-selector appended to a full composed selector. @@ -919,12 +1053,61 @@ fn canonical_css_for_hash(styles: &ResolvedStyles) -> String { write!(out, "{}:{};", d.property, d.value).unwrap(); } - // Responsive blocks — sort by breakpoint name - let mut responsive = styles.responsive.clone(); - responsive.sort_by(|(a, _), (b, _)| a.cmp(b)); + // Responsive blocks — sort by breakpoint name. + let mut responsive: Vec<(&String, &Vec)> = + styles.breakpoint_groups().collect(); + responsive.sort_by_key(|(a, _)| *a); for (bp, bp_decls) in &responsive { write!(out, "@{}{{", bp).unwrap(); - let mut sorted = bp_decls.clone(); + let mut sorted = (*bp_decls).clone(); + sorted.sort_by(|a, b| a.property.cmp(&b.property)); + for d in &sorted { + write!(out, "{}:{};", d.property, d.value).unwrap(); + } + write!(out, "}}").unwrap(); + } + + // Non-breakpoint condition blocks (Media/Container/Supports) — admitted + // into the hash (inc 03) so two usages differing ONLY by a condition + // block hash to distinct classes instead of colliding into one. Sorted + // by (prelude, selector) for order-independence; declarations sorted by + // property for insertion-order stability, matching the base/breakpoint + // treatment above. + // inc 05: the hash key is the FULL condition stack (inner Breakpoint + // members rendered as `bp:`) plus the nested selector, so usages + // differing in any stack member or selector hash to distinct classes. + // The legacy selectorless single-breakpoint groups stay in the `@bp` + // section above; everything else is admitted here. + fn hash_stack_key(g: &ConditionedGroup) -> String { + g.conditions + .iter() + .map(|c| match c { + crate::theme::Condition::Breakpoint(bp) => format!("bp:{}", bp), + other => other.prelude().unwrap_or("").to_string(), + }) + .collect::>() + .join("|") + } + let mut conditioned: Vec<&ConditionedGroup> = styles + .conditioned + .iter() + .filter(|g| { + !(g.selector.is_none() + && matches!( + g.conditions.as_slice(), + [crate::theme::Condition::Breakpoint(_)] + )) + }) + .collect(); + conditioned.sort_by(|a, b| { + hash_stack_key(a) + .cmp(&hash_stack_key(b)) + .then_with(|| a.selector.cmp(&b.selector)) + }); + for g in &conditioned { + let sel = g.selector.as_deref().unwrap_or(""); + write!(out, "@cond:{}|{}{{", hash_stack_key(g), sel).unwrap(); + let mut sorted = g.declarations.clone(); sorted.sort_by(|a, b| a.property.cmp(&b.property)); for d in &sorted { write!(out, "{}:{};", d.property, d.value).unwrap(); @@ -964,8 +1147,8 @@ fn write_utility_rule( } // Responsive declarations — sorted by breakpoint pixel value (ascending) - let mut sorted_responsive: Vec<&(String, Vec)> = - styles.responsive.iter().collect(); + let mut sorted_responsive: Vec<(&String, &Vec)> = + styles.breakpoint_groups().collect(); sorted_responsive.sort_by_key(|(bp_name, _)| { breakpoints.breakpoints.get(bp_name.as_str()).copied().unwrap_or(0) }); @@ -983,6 +1166,11 @@ fn write_utility_rule( } } } + + // Condition blocks — after breakpoints (design D4). Utility usages are + // single system-prop values today and carry none, but the writer stays + // uniform for when condition-bearing styles route through here. + write_condition_blocks(layer_body, &[format!(".{}", class_name)], styles, breakpoints); } /// Core implementation used by both `generate_utility_css` and @@ -1059,7 +1247,7 @@ fn generate_utility_css_impl( if let Some(d) = s.declarations.first() { return d.property.clone(); } - if let Some((_, decls)) = s.responsive.first() { + if let Some((_, decls)) = s.breakpoint_groups().next() { if let Some(d) = decls.first() { return d.property.clone(); } @@ -1069,8 +1257,8 @@ fn generate_utility_css_impl( // Breakpoint sort order: base/static → 0, per-bp → pixel value. let bp_order = |s: &ResolvedStyles| -> u32 { if !s.declarations.is_empty() { return 0; } - if let Some((bp_name, _)) = s.responsive.first() { - return *breakpoints.breakpoints.get(bp_name).unwrap_or(&0); + if let Some((bp_name, _)) = s.breakpoint_groups().next() { + return *breakpoints.breakpoints.get(bp_name.as_str()).unwrap_or(&0); } 0 }; @@ -1125,6 +1313,7 @@ pub fn generate_custom_prop_css( contextual_vars: ctx.contextual_vars, breakpoint_keys: ctx.breakpoint_keys, selector_aliases: ctx.selector_aliases, + condition_aliases: ctx.condition_aliases, transform_evaluator: ctx.transform_evaluator, }; let layer_name = layer_name("custom"); @@ -1211,8 +1400,7 @@ pub fn build_variable_slot_entries( let styles = ResolvedStyles { declarations: base_declarations, pseudo_selectors: vec![], - responsive: vec![], - responsive_pseudos: vec![], + conditioned: vec![], }; entries.push((meta.slot_class.clone(), styles, css_property.clone())); @@ -1242,8 +1430,7 @@ pub fn build_variable_slot_entries( let bp_styles = ResolvedStyles { declarations: vec![], pseudo_selectors: vec![], - responsive: vec![(bp_name.to_string(), bp_decls)], - responsive_pseudos: vec![], + conditioned: vec![ConditionedGroup::breakpoint(bp_name.to_string(), bp_decls)], }; entries.push((bp_class, bp_styles, css_property.clone())); @@ -1258,7 +1445,10 @@ mod tests { use rustc_hash::FxHashSet; use super::*; - use crate::theme::{ContextualVarsMap, SelectorAliasesMap, VariableMap}; + use crate::theme::{ + Condition, ConditionAliasesMap, ConditionEmitOrder, ConditionedGroup, ContextualVarsMap, + SelectorAliasesMap, VariableMap, + }; fn empty_vars() -> VariableMap { FxHashMap::default() @@ -1282,6 +1472,7 @@ mod tests { ctx_vars: ContextualVarsMap, bp_keys: FxHashSet, aliases: SelectorAliasesMap, + conditions: ConditionAliasesMap, } impl TestUtilCtx { @@ -1293,6 +1484,7 @@ mod tests { ctx_vars: ContextualVarsMap::default(), bp_keys: bp.breakpoints.keys().cloned().collect(), aliases: SelectorAliasesMap::default(), + conditions: ConditionAliasesMap::default(), } } @@ -1304,11 +1496,147 @@ mod tests { contextual_vars: &self.ctx_vars, breakpoint_keys: &self.bp_keys, selector_aliases: &self.aliases, + condition_aliases: &self.conditions, transform_evaluator: None, } } } + // ------------------------------------------------------------------ + // Class-hash admission + condition emission (inc 03 — D4) + // ------------------------------------------------------------------ + + fn container_group(prelude: &str, prop: &str, value: &str) -> ConditionedGroup { + ConditionedGroup::single( + Condition::Container(prelude.to_string()), + vec![CssDeclaration { property: prop.to_string(), value: value.to_string() }], + ConditionEmitOrder::Raw(0), + ) + } + + #[test] + fn condition_block_admitted_into_class_hash() { + // MANDATORY: two usages differing ONLY by a condition block must hash + // to distinct canonical strings (else they collide into one class). + let base = ResolvedStyles { + declarations: vec![CssDeclaration { property: "display".into(), value: "grid".into() }], + ..Default::default() + }; + let with_condition = ResolvedStyles { + declarations: vec![CssDeclaration { property: "display".into(), value: "grid".into() }], + conditioned: vec![container_group("@container (min-width: 400px)", "padding", "1rem")], + ..Default::default() + }; + let h_base = canonical_css_for_hash(&base); + let h_cond = canonical_css_for_hash(&with_condition); + assert_ne!(h_base, h_cond, "condition block must change the canonical hash input"); + assert_ne!(content_hash(&h_base), content_hash(&h_cond)); + } + + #[test] + fn different_condition_preludes_hash_differently() { + let a = ResolvedStyles { + conditioned: vec![container_group("@container (min-width: 400px)", "padding", "1rem")], + ..Default::default() + }; + let b = ResolvedStyles { + conditioned: vec![container_group("@container (min-width: 800px)", "padding", "1rem")], + ..Default::default() + }; + assert_ne!(canonical_css_for_hash(&a), canonical_css_for_hash(&b)); + } + + #[test] + fn write_rule_block_nests_condition_inside_layer_order() { + // Emission proof: declarations → pseudo → breakpoint MQ → condition, + // condition wrapping the class selector. + let styles = ResolvedStyles { + declarations: vec![CssDeclaration { property: "display".into(), value: "flex".into() }], + pseudo_selectors: vec![( + ":hover".into(), + vec![CssDeclaration { property: "color".into(), value: "red".into() }], + )], + conditioned: vec![ + ConditionedGroup::breakpoint( + "sm", + vec![CssDeclaration { property: "gap".into(), value: "1rem".into() }], + ), + container_group("@container (min-width: 400px)", "font-size", "18px"), + ], + }; + let mut out = String::new(); + write_rule_block(&mut out, "animus-Box-abcd", &styles, &test_breakpoints()); + + let p_decl = out.find("display: flex").unwrap(); + let p_hover = out.find(":hover").unwrap(); + let p_mq = out.find("@media (min-width: 768px)").unwrap(); + let p_cont = out.find("@container (min-width: 400px)").unwrap(); + assert!(p_decl < p_hover, "declarations before pseudos"); + assert!(p_hover < p_mq, "pseudos before breakpoint MQ"); + assert!(p_mq < p_cont, "breakpoint MQ before condition"); + // Condition wraps the class selector. + assert!(out.contains("@container (min-width: 400px) {\n .animus-Box-abcd {\n font-size: 18px;")); + } + + #[test] + fn emission_ordering_proof_declarations_pseudo_breakpoint_aliased_raw() { + // FULL D4 total order in one rule (RETURN item #5): unconditioned + // declarations → pseudo → breakpoint MQ (px asc) → aliased condition + // (registry order) → raw condition (source order). `conditioned` is + // deliberately given out of emission order to prove the sort. + let styles = ResolvedStyles { + declarations: vec![CssDeclaration { property: "display".into(), value: "flex".into() }], + pseudo_selectors: vec![( + ":hover".into(), + vec![CssDeclaration { property: "color".into(), value: "red".into() }], + )], + conditioned: vec![ + // raw (source idx 0) — must emit LAST despite being first here + ConditionedGroup::single( + Condition::Supports("@supports (display: grid)".into()), + vec![CssDeclaration { property: "display".into(), value: "grid".into() }], + ConditionEmitOrder::Raw(0), + ), + // aliased (order 500) — must emit before the raw one + ConditionedGroup::single( + Condition::Media("@media (prefers-reduced-motion: reduce)".into()), + vec![CssDeclaration { property: "transition".into(), value: "none".into() }], + ConditionEmitOrder::Aliased(500), + ), + // breakpoint — must emit before both condition groups + ConditionedGroup::breakpoint( + "sm", + vec![CssDeclaration { property: "gap".into(), value: "1rem".into() }], + ), + ], + }; + let mut out = String::new(); + write_rule_block(&mut out, "animus-Box-abcd", &styles, &test_breakpoints()); + + let ordered_markers = [ + "display: flex", // 1. declarations + ":hover", // 2. pseudo + "@media (min-width: 768px)", // 3. breakpoint MQ + "@media (prefers-reduced-motion: reduce)", // 4. aliased condition + "@supports (display: grid)", // 5. raw condition + ]; + let mut last = 0usize; + for marker in ordered_markers { + let pos = out.find(marker).unwrap_or_else(|| panic!("missing marker {marker} in:\n{out}")); + assert!(pos >= last, "marker {marker} out of order in:\n{out}"); + last = pos; + } + // Snapshot the exact emitted rule for the report excerpt. + assert_eq!( + out, + " .animus-Box-abcd {\n display: flex;\n }\n\ + \x20 .animus-Box-abcd:hover {\n color: red;\n }\n\ + \x20 @media (min-width: 768px) {\n .animus-Box-abcd {\n gap: 1rem;\n }\n }\n\ + \x20 @media (prefers-reduced-motion: reduce) {\n .animus-Box-abcd {\n transition: none;\n }\n }\n\ + \x20 @supports (display: grid) {\n .animus-Box-abcd {\n display: grid;\n }\n }\n" + ); + } + #[test] fn generates_base_layer() { let components = vec![ComponentCss { @@ -1325,8 +1653,7 @@ mod tests { }, ], pseudo_selectors: vec![], - responsive: vec![], - responsive_pseudos: vec![], + conditioned: vec![], }), variants: vec![], compounds: vec![], @@ -1358,8 +1685,7 @@ mod tests { value: "var(--colors-background)".to_string(), }], pseudo_selectors: vec![], - responsive: vec![], - responsive_pseudos: vec![], + conditioned: vec![], }, ), ( @@ -1370,8 +1696,7 @@ mod tests { value: "1px solid".to_string(), }], pseudo_selectors: vec![], - responsive: vec![], - responsive_pseudos: vec![], + conditioned: vec![], }, ), ], @@ -1401,8 +1726,7 @@ mod tests { value: "0".to_string(), }], pseudo_selectors: vec![], - responsive: vec![], - responsive_pseudos: vec![], + conditioned: vec![], }, )], }]; @@ -1426,8 +1750,7 @@ mod tests { value: "var(--colors-primary)".to_string(), }], )], - responsive: vec![], - responsive_pseudos: vec![], + conditioned: vec![], }), variants: vec![], compounds: vec![], @@ -1449,14 +1772,13 @@ mod tests { value: "1rem".to_string(), }], pseudo_selectors: vec![], - responsive: vec![( - "sm".to_string(), + conditioned: vec![ConditionedGroup::breakpoint( + "sm", vec![CssDeclaration { property: "font-size".to_string(), value: "1.125rem".to_string(), }], )], - responsive_pseudos: vec![], }), variants: vec![], compounds: vec![], @@ -1693,22 +2015,23 @@ mod tests { assert_eq!(entries[0].1.declarations[0].property, "padding"); assert_eq!(entries[0].1.declarations[0].value, "var(--animus-p)"); // Base slot class has no responsive entries (standalone) - assert!(entries[0].1.responsive.is_empty()); + assert!(entries[0].1.breakpoint_groups().next().is_none()); // Per-breakpoint slot classes are separate entries // 5 breakpoints → 5 additional entries (total 6 including base) assert_eq!(entries.len(), 6); // xs entry: own class, wrapped in @media assert_eq!(entries[1].0, "animus-dyn-p-xs"); assert!(entries[1].1.declarations.is_empty()); // no base decls - assert_eq!(entries[1].1.responsive.len(), 1); - assert_eq!(entries[1].1.responsive[0].0, "xs"); - assert_eq!(entries[1].1.responsive[0].1[0].value, "var(--animus-p-xs)"); + let bps1: Vec<_> = entries[1].1.breakpoint_groups().collect(); + assert_eq!(bps1.len(), 1); + assert_eq!(bps1[0].0, "xs"); + assert_eq!(bps1[0].1[0].value, "var(--animus-p-xs)"); // sm entry assert_eq!(entries[2].0, "animus-dyn-p-sm"); - assert_eq!(entries[2].1.responsive[0].1[0].value, "var(--animus-p-sm)"); + assert_eq!(entries[2].1.breakpoint_groups().next().unwrap().1[0].value, "var(--animus-p-sm)"); // xl entry (last) assert_eq!(entries[5].0, "animus-dyn-p-xl"); - assert_eq!(entries[5].1.responsive[0].1[0].value, "var(--animus-p-xl)"); + assert_eq!(entries[5].1.breakpoint_groups().next().unwrap().1[0].value, "var(--animus-p-xl)"); } #[test] @@ -1736,8 +2059,9 @@ mod tests { assert_eq!(entries[0].1.declarations[1].property, "padding-right"); // Per-bp slot classes also have multi-property declarations assert_eq!(entries[1].0, "animus-dyn-px-xs"); - assert_eq!(entries[1].1.responsive[0].1.len(), 2); - assert_eq!(entries[1].1.responsive[0].1[0].value, "var(--animus-px-xs)"); + let bps1: Vec<_> = entries[1].1.breakpoint_groups().collect(); + assert_eq!(bps1[0].1.len(), 2); + assert_eq!(bps1[0].1[0].value, "var(--animus-px-xs)"); } #[test] @@ -1997,4 +2321,285 @@ mod tests { // Pseudo declarations present assert!(css.contains("background-color: blue")); } + + #[test] + fn composed_emits_selector_breakpoint_and_conditioned_groups() { + // inc-05 review F6: two `ResolvedStyles` shapes went live for composed + // slots but had no composed-level test — + // (1) a SELECTOR-BEARING breakpoint group, which resolves through + // `breakpoint_selector_groups()` (the legacy bucket that carried + // a "no producer today" note until nested resolution populated + // it), and + // (2) a non-breakpoint CONDITIONED group, which resolves through + // `write_condition_blocks` at the composed level. + // Each must wrap BOTH composed selectors (inheritance + override), and + // the breakpoint MQ must emit before the condition (D4 within-rule + // order). + let child = ComponentCss { + class_name: "animus-Child-def".to_string(), + base: None, + variants: vec![VariantCss { + prop: "size".to_string(), + default_option: None, + options: vec![( + "sm".to_string(), + ResolvedStyles { + declarations: vec![CssDeclaration { + property: "padding".to_string(), + value: "4px".to_string(), + }], + conditioned: vec![ + // (1) selector-bearing breakpoint group + ConditionedGroup { + conditions: vec![Condition::Breakpoint("sm".to_string())], + selector: Some(":hover".to_string()), + declarations: vec![CssDeclaration { + property: "gap".to_string(), + value: "1rem".to_string(), + }], + emit_order: ConditionEmitOrder::Breakpoint, + }, + // (2) non-breakpoint conditioned group (no selector) + container_group( + "@container (min-width: 400px)", + "font-size", + "18px", + ), + ], + ..Default::default() + }, + )], + }], + compounds: vec![], + states: vec![], + }; + let root = make_component_css("animus-Root-abc", "size", &[("sm", "padding", "4px")]); + let components = vec![root, child]; + + let shared = vec![String::from("size")]; + let families = vec![ComposeFamilyRef { + root_class: "animus-Root-abc", + child_slots: vec![("Child", "animus-Child-def")], + shared_keys: &shared, + }]; + + let bp = test_breakpoints(); + let css = generate_composed_variant_css(&families, &components, &bp); + + // (1) The selector-bearing breakpoint group nests both composed + // selectors (with the `:hover` pseudo appended) inside ONE sm media + // query — `gap: 1rem` emits twice (inheritance + override). + assert_eq!( + css.matches("@media (min-width: 768px)").count(), + 1, + "exactly one sm breakpoint MQ:\n{css}" + ); + assert!( + css.contains(".animus-Root-abc--size-sm .animus-Child-def:hover"), + "inheritance selector + :hover:\n{css}" + ); + assert!( + css.contains(".animus-Root-abc .animus-Child-def.animus-Child-def--size-sm:hover"), + "override selector + :hover:\n{css}" + ); + assert_eq!( + css.matches("gap: 1rem").count(), + 2, + "responsive-selector decl wraps both composed selectors:\n{css}" + ); + + // (2) The conditioned group nests both composed selectors inside the + // @container block — `font-size: 18px` emits twice. + assert_eq!( + css.matches("@container (min-width: 400px)").count(), + 1, + "exactly one @container block:\n{css}" + ); + assert_eq!( + css.matches("font-size: 18px").count(), + 2, + "conditioned decl wraps both composed selectors:\n{css}" + ); + + // D4 within-rule order: breakpoint MQ before the condition block. + let mq_pos = css.find("@media (min-width: 768px)").unwrap(); + let cond_pos = css.find("@container (min-width: 400px)").unwrap(); + assert!(mq_pos < cond_pos, "breakpoint MQ before condition (D4):\n{css}"); + } + + // ---- inc 05: nested emission (design D5/D4) ---- + + fn decls(pairs: &[(&str, &str)]) -> Vec { + pairs.iter().map(|(p, v)| CssDeclaration { property: p.to_string(), value: v.to_string() }).collect() + } + + #[test] + fn emits_stacked_condition_wrappers_outermost_first() { + let styles = ResolvedStyles { + declarations: vec![], + pseudo_selectors: vec![], + conditioned: vec![ConditionedGroup { + conditions: vec![ + Condition::Supports("@supports (display: grid)".into()), + Condition::Container("@container (min-width: 400px)".into()), + ], + selector: None, + declarations: decls(&[("display", "grid")]), + emit_order: ConditionEmitOrder::Raw(0), + }], + }; + let mut out = String::new(); + write_rule_block(&mut out, "animus-X-1", &styles, &test_breakpoints()); + let si = out.find("@supports (display: grid) {").expect("outer wrapper"); + let ci = out.find("@container (min-width: 400px) {").expect("inner wrapper"); + assert!(si < ci, "outermost-first:\n{}", out); + assert!(out.contains(".animus-X-1")); + assert!(out.contains("display: grid;")); + } + + #[test] + fn emits_condition_group_with_nested_selector() { + let styles = ResolvedStyles { + declarations: vec![], + pseudo_selectors: vec![], + conditioned: vec![ConditionedGroup { + conditions: vec![Condition::Container("@container (min-width: 400px)".into())], + selector: Some(":hover".into()), + declarations: decls(&[("gap", "0.5rem")]), + emit_order: ConditionEmitOrder::Raw(0), + }], + }; + let mut out = String::new(); + write_rule_block(&mut out, "animus-X-2", &styles, &test_breakpoints()); + assert!(out.contains("@container (min-width: 400px) {")); + assert!(out.contains(".animus-X-2:hover"), "selector composes inside at-rule:\n{}", out); + } + + #[test] + fn emits_breakpoint_member_inside_condition_stack() { + let styles = ResolvedStyles { + declarations: vec![], + pseudo_selectors: vec![], + conditioned: vec![ConditionedGroup { + conditions: vec![ + Condition::Container("@container (min-width: 400px)".into()), + Condition::Breakpoint("sm".into()), + ], + selector: None, + declarations: decls(&[("font-size", "16px")]), + emit_order: ConditionEmitOrder::Raw(0), + }], + }; + let mut out = String::new(); + write_rule_block(&mut out, "animus-X-3", &styles, &test_breakpoints()); + let ci = out.find("@container (min-width: 400px) {").expect("container wrapper"); + let mi = out.find("@media (min-width: 768px) {").expect("inner breakpoint wrapper"); + assert!(ci < mi, "breakpoint nests INSIDE the container block:\n{}", out); + } + + #[test] + fn rule_block_emits_responsive_selector_groups() { + let styles = ResolvedStyles { + declarations: decls(&[("display", "flex")]), + pseudo_selectors: vec![(":hover".to_string(), decls(&[("padding", "0.5rem")]))], + conditioned: vec![ConditionedGroup { + conditions: vec![Condition::Breakpoint("sm".into())], + selector: Some(":hover".into()), + declarations: decls(&[("padding", "1rem")]), + emit_order: ConditionEmitOrder::Breakpoint, + }], + }; + let mut out = String::new(); + write_rule_block(&mut out, "animus-X-4", &styles, &test_breakpoints()); + let mq = out.find("@media (min-width: 768px) {").expect("mq wrapper"); + let sel = out.find(".animus-X-4:hover {").expect("plain pseudo rule"); + let cond_sel = out[mq..].find(".animus-X-4:hover").expect("selector inside mq"); + assert!(sel < mq, "plain pseudo before responsive-selector group:\n{}", out); + let _ = cond_sel; + assert!(out.contains("padding: 1rem;")); + } + + #[test] + fn hash_distinguishes_stack_members_and_selector() { + let base = ResolvedStyles { + declarations: decls(&[("display", "grid")]), + pseudo_selectors: vec![], + conditioned: vec![ConditionedGroup { + conditions: vec![Condition::Supports("@supports (display: grid)".into())], + selector: None, + declarations: decls(&[("gap", "1rem")]), + emit_order: ConditionEmitOrder::Raw(0), + }], + }; + let mut stacked = base.clone(); + stacked.conditioned[0].conditions.push(Condition::Container("@container (min-width: 400px)".into())); + let mut with_selector = base.clone(); + with_selector.conditioned[0].selector = Some(":hover".into()); + let mut with_bp_selector = base.clone(); + with_bp_selector.conditioned[0].conditions = vec![Condition::Breakpoint("sm".into())]; + with_bp_selector.conditioned[0].selector = Some(":hover".into()); + with_bp_selector.conditioned[0].emit_order = ConditionEmitOrder::Breakpoint; + + let h0 = canonical_css_for_hash(&base); + let h1 = canonical_css_for_hash(&stacked); + let h2 = canonical_css_for_hash(&with_selector); + let h3 = canonical_css_for_hash(&with_bp_selector); + assert_ne!(h0, h1, "inner stack member must change the hash"); + assert_ne!(h0, h2, "nested selector must change the hash"); + assert_ne!(h0, h3, "selector-bearing breakpoint group must be admitted"); + // And the selector-bearing breakpoint group is NOT invisible: + assert!(h3.contains("@cond:bp:sm|:hover{"), "hash admits bp+selector: {}", h3); + } + + #[test] + fn composed_selectors_emit_in_outer_cascade_order() { + // F4 (inc-05 review): authored ACTIVE-first, but hover (cascade 30) + // must emit before active (cascade 70) — non-vacuous: insertion and + // cascade predictions diverge. + let styles = ResolvedStyles { + declarations: vec![], + pseudo_selectors: vec![ + (":active::before".to_string(), decls(&[("opacity", "0.5")])), + (":hover::before".to_string(), decls(&[("opacity", "1")])), + ], + conditioned: vec![], + }; + let mut out = String::new(); + write_rule_block(&mut out, "animus-X-5", &styles, &test_breakpoints()); + let h = out.find(".animus-X-5:hover::before").expect("hover rule"); + let a = out.find(".animus-X-5:active::before").expect("active rule"); + assert!(h < a, "outer cascade order must beat authoring order:\n{}", out); + } + + #[test] + fn condition_base_group_emits_before_breakpoint_child() { + // F1 (inc-05 review): emission regression — base wrapper precedes the + // stacked-breakpoint wrapper for the same outer prelude. + let styles = ResolvedStyles { + declarations: vec![], + pseudo_selectors: vec![], + conditioned: vec![ + ConditionedGroup { + conditions: vec![Condition::Container("@container (min-width: 400px)".into())], + selector: None, + declarations: decls(&[("font-size", "14px")]), + emit_order: ConditionEmitOrder::Raw(0), + }, + ConditionedGroup { + conditions: vec![ + Condition::Container("@container (min-width: 400px)".into()), + Condition::Breakpoint("sm".into()), + ], + selector: None, + declarations: decls(&[("font-size", "16px")]), + emit_order: ConditionEmitOrder::Raw(0), + }, + ], + }; + let mut out = String::new(); + write_rule_block(&mut out, "animus-X-6", &styles, &test_breakpoints()); + let base = out.find("font-size: 14px").expect("base decl"); + let bp = out.find("font-size: 16px").expect("bp override"); + assert!(base < bp, "base before breakpoint override:\n{}", out); + } } diff --git a/packages/extract/crates/extract-v2/src/engine.rs b/packages/extract/crates/extract-v2/src/engine.rs index 3556ef42..355a151d 100644 --- a/packages/extract/crates/extract-v2/src/engine.rs +++ b/packages/extract/crates/extract-v2/src/engine.rs @@ -86,6 +86,10 @@ pub struct EngineOptions { pub group_registry_json: Option, /// Selector aliases JSON (v1 `selector_aliases_json`). pub selector_aliases_json: Option, + /// Condition aliases JSON (inc 03 — `conditionAliases` manifest field): + /// `{ "_motionReduce": { "value": "@media …", "order": 500, "kind": + /// "media" } }`. Absent = no registrations. + pub condition_aliases_json: Option, /// Global style blocks JSON (v1 `global_style_blocks_json`). pub global_style_blocks_json: Option, /// Keyframes blocks JSON (v1 `keyframes_blocks_json` — feeds BOTH the @@ -201,6 +205,7 @@ impl ExtractEngine { o.config_json.as_deref(), o.group_registry_json.as_deref(), o.selector_aliases_json.as_deref(), + o.condition_aliases_json.as_deref(), o.global_style_blocks_json.as_deref(), o.keyframes_json.as_deref(), o.package_resolution_json.as_deref(), diff --git a/packages/extract/crates/extract-v2/src/lib.rs b/packages/extract/crates/extract-v2/src/lib.rs index a9d140d9..47b21b95 100644 --- a/packages/extract/crates/extract-v2/src/lib.rs +++ b/packages/extract/crates/extract-v2/src/lib.rs @@ -67,6 +67,9 @@ pub struct NapiSystemConfig { pub contextual_vars_json: String, pub selector_aliases: Option, pub selector_order: Option, + /// Condition alias map JSON (inc 03 — `conditionAliases`): alias → + /// `{ value, order, kind }`. Absent when the system registers none. + pub condition_aliases: Option, pub global_style_blocks: Option, pub keyframes_blocks: Option, } @@ -93,6 +96,7 @@ pub fn load_system_module( contextual_vars_json: config.contextual_vars_json, selector_aliases: config.selector_aliases, selector_order: config.selector_order, + condition_aliases: config.condition_aliases, global_style_blocks: config.global_style_blocks, keyframes_blocks: config.keyframes_blocks, }) diff --git a/packages/extract/crates/extract-v2/src/pipeline.rs b/packages/extract/crates/extract-v2/src/pipeline.rs index b1ec7791..81666c55 100644 --- a/packages/extract/crates/extract-v2/src/pipeline.rs +++ b/packages/extract/crates/extract-v2/src/pipeline.rs @@ -15,8 +15,8 @@ use serde_json::Value; use crate::css::{ComponentCss, VariantCss}; use crate::facts::{ChainFacts, StageFacts}; use crate::theme::{ - merge_pseudo_selectors, resolve_styles, CssDeclaration, PropConfigMap, ResolveContext, - ResolvedStyles, + merge_pseudo_selectors, resolve_styles, ConditionEmitOrder, CssDeclaration, PropConfigMap, + ResolveContext, ResolvedStyles, }; /// Facts-level ComponentReplacement precursor (emission/config fields the @@ -164,9 +164,29 @@ fn merge_variant_base( declarations.clone(), ); } - for (breakpoint, declarations) in &base.responsive { + // Iterate the base breakpoint groups directly (reviewer R9): `base` and + // `resolved` are disjoint borrows, so the intermediate owned clone the + // earlier code collected was unnecessary. `merge_responsive_base` copies + // per-group via `to_vec`, so no borrow of `base` outlives the loop body. + for (breakpoint, declarations) in base.breakpoint_groups() { merge_responsive_base(&mut resolved, breakpoint, declarations); } + // Carry the base's condition groups into each option — a variant + // `base`'s condition block would otherwise be dropped from every option + // (spec: "Condition block in a variant"). D4's no-cross-rule-merge + // choice makes a plain append cascade-correct; the emission sorter + // re-orders by kind/registry/source. Selectorless single-breakpoint + // groups already merged via `breakpoint_groups()` above; everything + // else — non-breakpoint groups AND `[Breakpoint]`+selector groups + // (responsive maps inside selector blocks, inc 05 review F2) — appends. + for group in &base.conditioned { + let is_plain_breakpoint = matches!(group.emit_order, ConditionEmitOrder::Breakpoint) + && group.selector.is_none() + && group.conditions.len() == 1; + if !is_plain_breakpoint { + resolved.conditioned.push(group.clone()); + } + } resolved } @@ -175,19 +195,11 @@ fn merge_responsive_base( breakpoint: &str, declarations: &[CssDeclaration], ) { - if let Some((_, existing)) = resolved - .responsive - .iter_mut() - .find(|(candidate, _)| candidate == breakpoint) - { - let mut merged = declarations.to_vec(); - merged.append(existing); - *existing = merged; - } else { - resolved - .responsive - .push((breakpoint.to_string(), declarations.to_vec())); - } + // Base declarations sort before existing ones within the breakpoint group. + let existing = resolved.breakpoint_decls_mut(breakpoint); + let mut merged = declarations.to_vec(); + merged.append(existing); + *existing = merged; } fn resolve_state_stage(value: &Value, ctx: &ResolveContext) -> Vec<(String, ResolvedStyles)> { @@ -267,7 +279,9 @@ mod tests { use super::*; use crate::facts::extract_file_facts; use crate::owned_ast::{OwnedAst, ParseCounter}; - use crate::theme::{ContextualVarsMap, FlatTheme, SelectorAliasesMap, VariableMap}; + use crate::theme::{ + ConditionAliasesMap, ContextualVarsMap, FlatTheme, SelectorAliasesMap, VariableMap, + }; fn resolve_fixture(source: &str) -> ComponentPipelineOutput { let counter = ParseCounter::new(0); @@ -288,6 +302,7 @@ mod tests { let contextual: ContextualVarsMap = ContextualVarsMap::default(); let bp: FxHashSet = FxHashSet::default(); let aliases: SelectorAliasesMap = SelectorAliasesMap::default(); + let conditions: ConditionAliasesMap = ConditionAliasesMap::default(); let ctx = ResolveContext { config: &config, theme: &theme, @@ -295,6 +310,7 @@ mod tests { contextual_vars: &contextual, breakpoint_keys: &bp, selector_aliases: &aliases, + condition_aliases: &conditions, transform_evaluator: None, }; let registry: FxHashMap> = FxHashMap::default(); @@ -344,6 +360,73 @@ mod tests { assert_eq!(props, vec!["display", "padding"], "base first, option after"); } + #[test] + fn variant_base_condition_block_merges_into_each_option() { + // media-condition-aliases: "Condition block in a variant" — a condition + // block on the variant `base` must reach EVERY option (variants layer), + // not be dropped by merge_variant_base. + let out = resolve_fixture( + r#"export const C = ds.variant({ prop: 'size', base: { '@media (prefers-reduced-motion: reduce)': { display: 'none' } }, variants: { sm: { p: 8 }, lg: { fontSize: 14 } } }).asElement('div');"#, + ); + let vc = &out.component_css.variants[0]; + assert_eq!(vc.options.len(), 2); + for (name, resolved) in &vc.options { + assert_eq!( + resolved.conditioned.len(), + 1, + "base condition must reach option {name}" + ); + let g = &resolved.conditioned[0]; + assert!(matches!( + g.conditions.as_slice(), + [crate::theme::Condition::Media(q)] if q == "@media (prefers-reduced-motion: reduce)" + )); + assert_eq!(g.declarations[0].property, "display"); + assert_eq!(g.declarations[0].value, "none"); + } + } + + #[test] + fn variant_base_carries_breakpoint_selector_groups() { + // F2 (inc-05 review): a responsive map inside a selector block on the + // variant `base` ([Breakpoint]+selector group) must reach every + // option — previously silently dropped by the Breakpoint-order skip. + use crate::theme::{Condition, ConditionEmitOrder, ConditionedGroup}; + let base = ResolvedStyles { + declarations: vec![], + pseudo_selectors: vec![( + ":hover".to_string(), + vec![CssDeclaration { property: "padding".into(), value: "0.5rem".into() }], + )], + conditioned: vec![ConditionedGroup { + conditions: vec![Condition::Breakpoint("sm".into())], + selector: Some(":hover".into()), + declarations: vec![CssDeclaration { property: "padding".into(), value: "1rem".into() }], + emit_order: ConditionEmitOrder::Breakpoint, + }], + }; + let option = ResolvedStyles::default(); + let merged = merge_variant_base(option, Some(&base)); + assert_eq!(merged.conditioned.len(), 1, "bp+selector group must carry"); + assert_eq!(merged.conditioned[0].selector.as_deref(), Some(":hover")); + assert!(merged.pseudo_selectors.iter().any(|(s, _)| s == ":hover")); + } + + #[test] + fn variant_option_condition_block_resolves() { + // The option's own condition block flows straight through resolve_styles. + let out = resolve_fixture( + r#"export const C = ds.variant({ prop: 'size', variants: { sm: { p: 8, '@supports (display: grid)': { display: 'flex' } } } }).asElement('div');"#, + ); + let (_n, resolved) = &out.component_css.variants[0].options[0]; + assert_eq!(resolved.conditioned.len(), 1); + assert!(matches!( + resolved.conditioned[0].conditions.as_slice(), + [crate::theme::Condition::Supports(q)] if q == "@supports (display: grid)" + )); + assert_eq!(resolved.conditioned[0].declarations[0].value, "flex"); + } + #[test] fn system_groups_expand_and_props_parse() { let counter = ParseCounter::new(0); @@ -361,6 +444,7 @@ mod tests { let contextual = ContextualVarsMap::default(); let bp: FxHashSet = FxHashSet::default(); let aliases = SelectorAliasesMap::default(); + let conditions = ConditionAliasesMap::default(); let ctx = ResolveContext { config: &config, theme: &theme, @@ -368,6 +452,7 @@ mod tests { contextual_vars: &contextual, breakpoint_keys: &bp, selector_aliases: &aliases, + condition_aliases: &conditions, transform_evaluator: None, }; let mut registry: FxHashMap> = FxHashMap::default(); diff --git a/packages/extract/crates/extract-v2/src/reconcile.rs b/packages/extract/crates/extract-v2/src/reconcile.rs index a11d4d76..bda40c94 100644 --- a/packages/extract/crates/extract-v2/src/reconcile.rs +++ b/packages/extract/crates/extract-v2/src/reconcile.rs @@ -351,12 +351,7 @@ mod tests { // ------------------------------------------------------------------ fn empty_styles() -> ResolvedStyles { - ResolvedStyles { - declarations: vec![], - pseudo_selectors: vec![], - responsive: vec![], - responsive_pseudos: vec![], - } + ResolvedStyles::default() } /// Build a simple ComponentCss with named variants and states. diff --git a/packages/extract/crates/extract-v2/src/theme.rs b/packages/extract/crates/extract-v2/src/theme.rs index 5b5c2cfb..c3844699 100644 --- a/packages/extract/crates/extract-v2/src/theme.rs +++ b/packages/extract/crates/extract-v2/src/theme.rs @@ -132,6 +132,50 @@ pub type ContextualVarsMap = FxHashMap>; /// Selector alias map: "_hover" → "&:hover", "_disabled" → "&:disabled, &[disabled], ..." pub type SelectorAliasesMap = FxHashMap; +/// One registered condition alias (design D3). Mirror of the TS +/// `ConditionAlias { value, order, kind }` serialized into the manifest +/// `conditionAliases` field. `value` is the full at-rule string, `kind` is +/// `"media" | "container" | "supports"` (inferred TS-side from the prefix), +/// `order` is the registry cascade order. +#[derive(Debug, Clone, Deserialize)] +pub struct ConditionAliasEntry { + pub value: String, + pub order: u32, + pub kind: String, +} + +impl ConditionAliasEntry { + /// Build the axis `Condition` for this alias from its kind + value. + /// An unrecognized kind falls back to `Media` (kind is TS-inferred and + /// therefore always one of the three, but the resolver stays total). + pub fn to_condition(&self) -> Condition { + match self.kind.as_str() { + "container" => Condition::Container(self.value.clone()), + "supports" => Condition::Supports(self.value.clone()), + _ => Condition::Media(self.value.clone()), + } + } +} + +/// Condition alias registry: "_motionReduce" → { value, order, kind }. +pub type ConditionAliasesMap = FxHashMap; + +/// Infer the axis `Condition` from a RAW `@`-prefixed block key (design D2/D3): +/// the at-rule prefix names the kind, and the full key is the verbatim +/// prelude. Returns `None` for unknown prefixes (the type layer rejects them +/// in inc 04; the resolver silently ignores them here). +pub fn condition_from_raw_key(key: &str) -> Option { + if key.starts_with("@media") { + Some(Condition::Media(key.to_string())) + } else if key.starts_with("@container") { + Some(Condition::Container(key.to_string())) + } else if key.starts_with("@supports") { + Some(Condition::Supports(key.to_string())) + } else { + None + } +} + /// Shared immutable context for style resolution. Constructed once per extraction run /// and threaded by reference through every `resolve_styles` call. pub struct ResolveContext<'a> { @@ -141,6 +185,8 @@ pub struct ResolveContext<'a> { pub contextual_vars: &'a ContextualVarsMap, pub breakpoint_keys: &'a FxHashSet, pub selector_aliases: &'a SelectorAliasesMap, + /// Registered condition aliases (`_motionReduce` → { value, order, kind }). + pub condition_aliases: &'a ConditionAliasesMap, pub transform_evaluator: Option<&'a crate::evaluator::TransformEvaluator>, } @@ -151,7 +197,141 @@ pub struct CssDeclaration { pub value: String, } -pub type ResponsivePseudoGroups = Vec<(String, Vec)>; +/// A condition under which a declaration group applies — the single ordered +/// condition axis (design D1). `Breakpoint` resolves through `BreakpointMap`; +/// the `Media`/`Container`/`Supports` kinds each carry the FULL at-rule +/// prelude string (e.g. `@container card (min-width: 400px)`) and are emitted +/// verbatim (design D2/D4, inc 03). +#[derive(Debug, Clone, PartialEq)] +pub enum Condition { + /// Theme-derived breakpoint name (emitted via `BreakpointMap`). + Breakpoint(String), + /// Raw or alias-resolved `@media` at-rule prelude (full string). + Media(String), + /// Raw or alias-resolved `@container` at-rule prelude (full string). + Container(String), + /// Raw or alias-resolved `@supports` at-rule prelude (full string). + Supports(String), +} + +impl Condition { + /// The verbatim at-rule prelude for a non-breakpoint condition + /// (`@media …` / `@container …` / `@supports …`). Breakpoints resolve + /// through `BreakpointMap` and return `None` here. + pub fn prelude(&self) -> Option<&str> { + match self { + Condition::Breakpoint(_) => None, + Condition::Media(q) | Condition::Container(q) | Condition::Supports(q) => Some(q), + } + } +} + +/// Emission-ordering discriminator for a conditioned group (design D4). +/// Within a rule, the total order is: declarations → pseudos → breakpoint +/// media queries (px ascending) → aliased conditions (registry `order`) → +/// raw condition keys (source order). +#[derive(Debug, Clone, PartialEq)] +pub enum ConditionEmitOrder { + /// Breakpoint-kind group — ordered by px ascending via `BreakpointMap`. + Breakpoint, + /// Registered condition alias — ordered by its registry `order`. + Aliased(u32), + /// Raw at-rule block key — ordered by source appearance index. + Raw(usize), +} + +/// One conditioned declaration group: a condition stack (outermost first), +/// an optional selector inside the conditions, and the declarations. +#[derive(Debug, Clone, PartialEq)] +pub struct ConditionedGroup { + pub conditions: Vec, + /// Selector within the conditions (`None` = the component's own rule). + pub selector: Option, + pub declarations: Vec, + /// How this group orders against its siblings at emission (design D4). + pub emit_order: ConditionEmitOrder, +} + +impl ConditionedGroup { + /// Single-breakpoint group with no selector — the shape every legacy + /// `responsive` entry maps onto. + pub fn breakpoint(bp: impl Into, declarations: Vec) -> Self { + Self { + conditions: vec![Condition::Breakpoint(bp.into())], + selector: None, + declarations, + emit_order: ConditionEmitOrder::Breakpoint, + } + } + + /// A single non-breakpoint condition group (aliased or raw), no nested + /// selector (nesting lands with inc 05). + pub fn single(condition: Condition, declarations: Vec, emit_order: ConditionEmitOrder) -> Self { + Self { + conditions: vec![condition], + selector: None, + declarations, + emit_order, + } + } +} + +/// Compose a nested selector against an outer composed selector context +/// (inc 05, design D5). Both sides may be comma-separated; composition is +/// the cartesian product of the parts. Inner parts follow the same grammar +/// as top-level selector keys/alias values: `&`-prefixed (the `&` replaced +/// by the outer composition, preserving whatever follows — including a +/// descendant space) or bare `:`/`::` pseudo (appended). The result is in +/// the STORED normalized form (no leading `&`, class anchor added at +/// emission time). +fn compose_selectors(outer: &str, inner_raw: &str) -> String { + let outer_parts: Vec<&str> = outer.split(", ").collect(); + let mut composed: Vec = Vec::new(); + for inner_part in inner_raw.split(',') { + let inner_norm = normalize_pseudo_selector(inner_part); + for outer_part in &outer_parts { + composed.push(format!("{}{}", outer_part, inner_norm)); + } + } + composed.join(", ") +} + +/// The resolution frame for nested block descent (inc 05): the composed +/// selector context and condition stack under which declarations sink. +#[derive(Clone, Default)] +struct NestFrame { + /// Composed selector in stored normalized form (`":hover .icon"`). + selector: Option, + /// Condition stack, outermost first. + conditions: Vec, + /// Emission order of the OUTERMOST non-breakpoint condition — the + /// whole stack orders by its outermost block (design D4). + emit_order: Option, +} + +impl NestFrame { + fn with_selector(&self, inner_raw: &str) -> Self { + let composed = match &self.selector { + Some(outer) => compose_selectors(outer, inner_raw), + None => normalize_pseudo_selector(inner_raw), + }; + Self { + selector: Some(composed), + conditions: self.conditions.clone(), + emit_order: self.emit_order.clone(), + } + } + + fn with_condition(&self, condition: Condition, order: ConditionEmitOrder) -> Self { + let mut conditions = self.conditions.clone(); + conditions.push(condition); + Self { + selector: self.selector.clone(), + conditions, + emit_order: Some(self.emit_order.clone().unwrap_or(order)), + } + } +} /// Result of resolving a style object. #[derive(Debug, Clone, Default, PartialEq)] @@ -159,11 +339,85 @@ pub struct ResolvedStyles { /// Regular CSS declarations. pub declarations: Vec, /// Pseudo-selector groups: selector → declarations. + /// + /// SINGLE-HOME RULE (inc 05): UNCONDITIONED selector groups live here + /// and ONLY here — `ConditionedGroup { conditions: [], selector: Some }` + /// is a forbidden second representation (unconstructible today: every + /// group constructor pushes ≥ 1 condition, and condition-free selector + /// frames sink here). Rehoming would change class-hash coverage + /// (pseudo content is deliberately unhashed; condition groups are + /// hashed) and break byte-identity. pub pseudo_selectors: Vec<(String, Vec)>, - /// Responsive declarations: breakpoint_name → declarations. - pub responsive: Vec<(String, Vec)>, - /// Responsive pseudo-selectors: breakpoint_name → (selector → declarations). - pub responsive_pseudos: Vec<(String, ResponsivePseudoGroups)>, + /// Conditioned declaration groups, insertion-ordered (design D1). + pub conditioned: Vec, +} + +impl ResolvedStyles { + /// Legacy `responsive` view: breakpoint-kind groups with no selector, + /// in insertion order. + pub fn breakpoint_groups(&self) -> impl Iterator)> { + self.conditioned.iter().filter_map(|g| match (g.conditions.as_slice(), &g.selector) { + ([Condition::Breakpoint(bp)], None) => Some((bp, &g.declarations)), + _ => None, + }) + } + + /// Legacy `responsive_pseudos` view: breakpoint-kind groups that carry a + /// selector, in insertion order. + /// + /// No producer today — the legacy bucket was dead-write and nothing + /// constructs selector-bearing groups yet; the first real producer is + /// nested resolution (inc 05). Note for that increment: the composed + /// emitter now wraps per-(breakpoint, selector) triple, not per-bp as + /// the legacy nested shape grouped — revisit deliberately at + /// population time. + pub fn breakpoint_selector_groups( + &self, + ) -> impl Iterator)> { + self.conditioned.iter().filter_map(|g| match (g.conditions.as_slice(), &g.selector) { + ([Condition::Breakpoint(bp)], Some(sel)) => Some((bp, sel, &g.declarations)), + _ => None, + }) + } + + /// Non-breakpoint conditioned groups (Media/Container/Supports) in + /// deterministic emission order (design D4): aliased conditions first, + /// sorted by registry `order`, then raw condition keys in source order. + /// Breakpoint-kind groups are excluded (they emit before these via + /// `breakpoint_groups`). + pub fn conditioned_emission_order(&self) -> Vec<&ConditionedGroup> { + let mut groups: Vec<&ConditionedGroup> = self + .conditioned + .iter() + .filter(|g| !matches!(g.emit_order, ConditionEmitOrder::Breakpoint)) + .collect(); + // Stable sort by (is_raw, key): aliased(order) < raw(source_index). + groups.sort_by_key(|g| match &g.emit_order { + ConditionEmitOrder::Aliased(order) => (0usize, *order as usize), + ConditionEmitOrder::Raw(idx) => (1usize, *idx), + ConditionEmitOrder::Breakpoint => (2usize, 0usize), + }); + groups + } + + /// Find-or-insert the selectorless group for `bp`, returning its + /// declarations for in-place extension (legacy merge semantics). + pub fn breakpoint_decls_mut(&mut self, bp: &str) -> &mut Vec { + let pos = self.conditioned.iter().position(|g| { + matches!( + (g.conditions.as_slice(), &g.selector), + ([Condition::Breakpoint(b)], None) if b == bp + ) + }); + let idx = match pos { + Some(i) => i, + None => { + self.conditioned.push(ConditionedGroup::breakpoint(bp, vec![])); + self.conditioned.len() - 1 + } + }; + &mut self.conditioned[idx].declarations + } } /// Resolve a style value map against the config and theme. @@ -190,30 +444,39 @@ pub fn resolve_styles( prop_cascade_tier(a, ctx.config).cmp(&prop_cascade_tier(b, ctx.config)) }); + // Source-order index for RAW `@`-prefixed condition keys (design D4: + // raw condition keys emit in source order). Incremented as each raw + // at-rule block key is encountered in cascade-tier-stable iteration + // order (all `@`/`_` keys share tier 3, so their relative order is + // source order). + let mut raw_condition_index = 0usize; + for (key, value) in entries { - // Check if this is a selector alias (_hover, _disabled, etc.) + // Check if this is a selector alias (_hover, _disabled, etc.) or a + // registered condition alias (_motionReduce, _cardSm, …). Selector + // aliases take precedence when a name is registered as both. if key.starts_with('_') { if let Some(alias_selector) = ctx.selector_aliases.get(key) { if let Some(nested_obj) = value.as_object() { - let mut nested_styles = - resolve_flat_styles(nested_obj, ctx.config, ctx.theme, ctx.variable_map, ctx.contextual_vars, ctx.transform_evaluator); - - // Auto-default content: "" for _before / _after (base only) - if auto_content - && (key == "_before" || key == "_after") - && !nested_styles.iter().any(|d| d.property == "content") - { - nested_styles.insert( - 0, - CssDeclaration { - property: "content".to_string(), - value: "\"\"".to_string(), - }, - ); - } - - let selector = normalize_pseudo_selector(alias_selector); - merge_pseudo_selectors(&mut result.pseudo_selectors, selector, nested_styles); + // Recursive descent (inc 05, D5): the block body may nest + // further selectors, conditions, and responsive maps. + let frame = NestFrame::default().with_selector(alias_selector); + let inject = auto_content && (key == "_before" || key == "_after"); + resolve_block_entries( + nested_obj, ctx, &frame, auto_content, inject, &mut result, &mut raw_condition_index, + ); + } + } else if let Some(cond_alias) = ctx.condition_aliases.get(key) { + // Registered condition alias → condition axis, ordered by + // its registry `order` (design D4). Body recurses (D5). + if let Some(nested_obj) = value.as_object() { + let frame = NestFrame::default().with_condition( + cond_alias.to_condition(), + ConditionEmitOrder::Aliased(cond_alias.order), + ); + resolve_block_entries( + nested_obj, ctx, &frame, auto_content, false, &mut result, &mut raw_condition_index, + ); } } continue; @@ -221,11 +484,30 @@ pub fn resolve_styles( // Check if this is a raw pseudo-selector if key.starts_with('&') || key.starts_with(':') { - let selector = normalize_pseudo_selector(key); if let Some(nested_obj) = value.as_object() { - let nested_styles = - resolve_flat_styles(nested_obj, ctx.config, ctx.theme, ctx.variable_map, ctx.contextual_vars, ctx.transform_evaluator); - merge_pseudo_selectors(&mut result.pseudo_selectors, selector, nested_styles); + let frame = NestFrame::default().with_selector(key); + resolve_block_entries( + nested_obj, ctx, &frame, auto_content, false, &mut result, &mut raw_condition_index, + ); + } + continue; + } + + // Check if this is a RAW at-rule condition block key (design D2): + // `@media …` / `@container …` / `@supports …`. The kind is inferred + // from the prefix and the full key is the verbatim prelude. Unknown + // prefixes are ignored here (type layer rejects them — inc 04). + if key.starts_with('@') { + if let Some(condition) = condition_from_raw_key(key) { + let idx = raw_condition_index; + raw_condition_index += 1; + if let Some(nested_obj) = value.as_object() { + let frame = NestFrame::default() + .with_condition(condition, ConditionEmitOrder::Raw(idx)); + resolve_block_entries( + nested_obj, ctx, &frame, auto_content, false, &mut result, &mut raw_condition_index, + ); + } } continue; } @@ -254,6 +536,194 @@ pub fn resolve_styles( result } +/// Recursive block resolution (inc 05, design D5): resolve one nested block's +/// entries under a `NestFrame`, descending into further selector/condition +/// blocks and sinking this block's own declarations at the end. +/// +/// Sink rules (byte-compat with the pre-recursion depth-1 behavior): +/// - frame has NO conditions (pure selector): merge into `pseudo_selectors`, +/// even when empty (legacy merged empty blocks too). +/// - frame has conditions: push one `ConditionedGroup` per block instance, +/// skipping empty declaration sets (legacy skipped empty condition blocks). +/// - nested responsive values: the `_` slot joins this block's declarations; +/// breakpoint slots form `[frame.conditions…, Breakpoint(bp)]` groups under +/// the frame's selector — the at-rule nests INSIDE the outer block +/// (spec: "Responsive value map inside a condition block"). +#[allow(clippy::too_many_arguments)] +fn resolve_block_entries( + obj: &Map, + ctx: &ResolveContext, + frame: &NestFrame, + auto_content: bool, + inject_content: bool, + result: &mut ResolvedStyles, + raw_condition_index: &mut usize, +) { + // Same cascade-tier ordering as the top level. + let mut entries: Vec<(&String, &Value)> = obj.iter().collect(); + entries.sort_by(|(a, _), (b, _)| { + prop_cascade_tier(a, ctx.config).cmp(&prop_cascade_tier(b, ctx.config)) + }); + + // F1 (inc-05 review): children push their groups into `result` during + // iteration, but this block's OWN declaration group must precede them + // (spec: base declarations "followed by" their breakpoint overrides + // inside a condition block — otherwise the override is cascade-dead at + // equal specificity). Remember where this block's children start so the + // frame's own group can be inserted before them at sink time. + let child_groups_start = result.conditioned.len(); + + let mut plain_decls: Vec = Vec::new(); + + for (key, value) in entries { + if key.starts_with('_') { + if let Some(alias_selector) = ctx.selector_aliases.get(key) { + if let Some(nested_obj) = value.as_object() { + let child = frame.with_selector(alias_selector); + let inject = auto_content && (key == "_before" || key == "_after"); + resolve_block_entries( + nested_obj, ctx, &child, auto_content, inject, result, raw_condition_index, + ); + } + } else if let Some(cond_alias) = ctx.condition_aliases.get(key) { + if let Some(nested_obj) = value.as_object() { + let child = frame.with_condition( + cond_alias.to_condition(), + ConditionEmitOrder::Aliased(cond_alias.order), + ); + resolve_block_entries( + nested_obj, ctx, &child, auto_content, false, result, raw_condition_index, + ); + } + } + continue; + } + + if key.starts_with('&') || key.starts_with(':') { + if let Some(nested_obj) = value.as_object() { + let child = frame.with_selector(key); + resolve_block_entries( + nested_obj, ctx, &child, auto_content, false, result, raw_condition_index, + ); + } + continue; + } + + if key.starts_with('@') { + if let Some(condition) = condition_from_raw_key(key) { + let idx = *raw_condition_index; + *raw_condition_index += 1; + if let Some(nested_obj) = value.as_object() { + let child = frame.with_condition(condition, ConditionEmitOrder::Raw(idx)); + resolve_block_entries( + nested_obj, ctx, &child, auto_content, false, result, raw_condition_index, + ); + } + } + continue; + } + + if is_responsive_value(value, ctx.breakpoint_keys) { + if let Some(vobj) = value.as_object() { + for (bp_key, bp_value) in vobj { + let declarations = resolve_single_prop( + key, bp_value, ctx.config, ctx.theme, ctx.variable_map, ctx.contextual_vars, ctx.transform_evaluator, + ); + if bp_key == "_" { + plain_decls.extend(declarations); + } else if !declarations.is_empty() { + push_nested_breakpoint_group(result, frame, bp_key, declarations); + } + } + } + continue; + } + + // Nested-block pass-through for color-family CSS props (same rule as + // the legacy flat resolver): not in propConfig but in the color + // family → resolve via the `colors` scale per the ThemedCSSProps + // contract; unknown keys fall through to normal pass-through. + if !ctx.config.contains_key(key.as_str()) + && COLOR_FAMILY_PASS_THROUGH.contains(&key.as_str()) + { + if let Some(resolved) = resolve_color_family_pass_through( + value, ctx.theme, ctx.variable_map, ctx.contextual_vars, + ) { + plain_decls.push(CssDeclaration { + property: camel_to_kebab(key), + value: resolved, + }); + continue; + } + } + + let declarations = resolve_single_prop( + key, value, ctx.config, ctx.theme, ctx.variable_map, ctx.contextual_vars, ctx.transform_evaluator, + ); + plain_decls.extend(declarations); + } + + // Auto-default content: "" for _before / _after blocks (any depth, base + // definitions only — same rule as the legacy depth-1 injection). + if inject_content && !plain_decls.iter().any(|d| d.property == "content") { + plain_decls.insert( + 0, + CssDeclaration { + property: "content".to_string(), + value: "\"\"".to_string(), + }, + ); + } + + if frame.conditions.is_empty() { + if let Some(sel) = &frame.selector { + merge_pseudo_selectors(&mut result.pseudo_selectors, sel.clone(), plain_decls); + } + } else if !plain_decls.is_empty() { + result.conditioned.insert( + child_groups_start, + ConditionedGroup { + conditions: frame.conditions.clone(), + selector: frame.selector.clone(), + declarations: plain_decls, + emit_order: frame + .emit_order + .clone() + .unwrap_or(ConditionEmitOrder::Breakpoint), + }, + ); + } +} + +/// Find-or-create the `[frame.conditions…, Breakpoint(bp)]` group under the +/// frame's selector and extend its declarations (one group per composed +/// target, merged across props within the same block). +fn push_nested_breakpoint_group( + result: &mut ResolvedStyles, + frame: &NestFrame, + bp: &str, + declarations: Vec, +) { + let mut conditions = frame.conditions.clone(); + conditions.push(Condition::Breakpoint(bp.to_string())); + let emit_order = frame + .emit_order + .clone() + .unwrap_or(ConditionEmitOrder::Breakpoint); + let pos = result.conditioned.iter().position(|g| { + g.conditions == conditions && g.selector == frame.selector && g.emit_order == emit_order + }); + match pos { + Some(i) => result.conditioned[i].declarations.extend(declarations), + None => result.conditioned.push(ConditionedGroup { + conditions, + selector: frame.selector.clone(), + declarations, + emit_order, + }), + } +} + /// Merge declarations into the pseudo_selectors list. /// If the selector already exists, merge declarations (last-write-wins per property). /// If not, append a new entry. @@ -316,13 +786,8 @@ fn resolve_responsive_prop( // Default (no media query) result.declarations.extend(declarations); } else { - // Find existing responsive entry for this breakpoint or create new - let existing = result.responsive.iter_mut().find(|(k, _)| k == bp_key); - if let Some((_, decls)) = existing { - decls.extend(declarations); - } else { - result.responsive.push((bp_key.clone(), declarations)); - } + // Find existing breakpoint group or create new (insertion order kept) + result.breakpoint_decls_mut(bp_key).extend(declarations); } } } @@ -1186,6 +1651,7 @@ mod tests { contextual_vars: ContextualVarsMap, breakpoint_keys: FxHashSet, selector_aliases: SelectorAliasesMap, + condition_aliases: ConditionAliasesMap, } impl TestCtxOwner { @@ -1197,6 +1663,7 @@ mod tests { contextual_vars: ContextualVarsMap::default(), breakpoint_keys: test_bp_keys(), selector_aliases: empty_selector_aliases(), + condition_aliases: ConditionAliasesMap::default(), } } @@ -1206,6 +1673,12 @@ mod tests { self } + fn with_conditions(mut self) -> Self { + self.condition_aliases = test_condition_aliases(); + self.selector_aliases = test_selector_aliases(); + self + } + fn ctx(&self) -> ResolveContext<'_> { ResolveContext { config: &self.config, @@ -1214,11 +1687,41 @@ mod tests { contextual_vars: &self.contextual_vars, breakpoint_keys: &self.breakpoint_keys, selector_aliases: &self.selector_aliases, + condition_aliases: &self.condition_aliases, transform_evaluator: None, } } } + fn test_condition_aliases() -> ConditionAliasesMap { + let mut m = ConditionAliasesMap::default(); + m.insert( + "_motionReduce".to_string(), + ConditionAliasEntry { + value: "@media (prefers-reduced-motion: reduce)".to_string(), + order: 500, + kind: "media".to_string(), + }, + ); + m.insert( + "_cardSm".to_string(), + ConditionAliasEntry { + value: "@container card (min-width: 400px)".to_string(), + order: 510, + kind: "container".to_string(), + }, + ); + m.insert( + "_hasGrid".to_string(), + ConditionAliasEntry { + value: "@supports (display: grid)".to_string(), + order: 520, + kind: "supports".to_string(), + }, + ); + m + } + #[test] fn resolve_scale_lookup() { let owner = TestCtxOwner::new(); @@ -1288,9 +1791,10 @@ mod tests { assert_eq!(resolved.declarations.len(), 1); assert_eq!(resolved.declarations[0].value, "0.5rem"); // Breakpoint value - assert_eq!(resolved.responsive.len(), 1); - assert_eq!(resolved.responsive[0].0, "sm"); - assert_eq!(resolved.responsive[0].1[0].value, "1rem"); + let bps: Vec<_> = resolved.breakpoint_groups().collect(); + assert_eq!(bps.len(), 1); + assert_eq!(bps[0].0, "sm"); + assert_eq!(bps[0].1[0].value, "1rem"); } #[test] @@ -1728,4 +2232,339 @@ mod tests { assert_eq!(hover_decls[0].property, "outline-color"); assert_eq!(hover_decls[0].value, "var(--colors-primary)"); } + + // ------------------------------------------------------------------ + // Condition-block resolution (inc 03 — D2/D3/D4) + // ------------------------------------------------------------------ + + fn only_cond(resolved: &ResolvedStyles) -> &ConditionedGroup { + assert_eq!(resolved.conditioned.len(), 1, "expected one conditioned group"); + &resolved.conditioned[0] + } + + #[test] + fn raw_container_block_resolves_kind_and_prelude() { + // container-query-support: "Basic container condition". + let owner = TestCtxOwner::new(); + let styles = json!({ "@container (min-width: 400px)": { "p": 16 } }); + let resolved = resolve_styles(&styles, &owner.ctx(), true); + let g = only_cond(&resolved); + assert_eq!(g.conditions, vec![Condition::Container("@container (min-width: 400px)".to_string())]); + assert!(g.selector.is_none()); + assert_eq!(g.declarations.len(), 1); + assert_eq!(g.declarations[0].property, "padding"); + assert_eq!(g.declarations[0].value, "1rem"); + assert!(matches!(g.emit_order, ConditionEmitOrder::Raw(0))); + } + + #[test] + fn raw_named_container_prelude_preserved_verbatim() { + // container-query-support: "Named container query". + let owner = TestCtxOwner::new(); + let styles = json!({ "@container card (min-width: 400px)": { "display": "grid" } }); + let resolved = resolve_styles(&styles, &owner.ctx(), true); + let g = only_cond(&resolved); + assert_eq!(g.conditions[0].prelude(), Some("@container card (min-width: 400px)")); + assert_eq!(g.declarations[0].value, "grid"); + } + + #[test] + fn container_unit_transits_pass_through_verbatim() { + // inc 01 spike: container-relative units transit unchanged (D11). + let owner = TestCtxOwner::new(); + let styles = json!({ "@container card (min-width: 400px)": { "width": "50cqw" } }); + let resolved = resolve_styles(&styles, &owner.ctx(), true); + // `width` has a `size` transform (no evaluator in tests → placeholder), + // proving the cq-unit string enters the transform seam intact. + let g = only_cond(&resolved); + assert_eq!(g.declarations[0].property, "width"); + assert_eq!(g.declarations[0].value, "__TRANSFORM__size__50cqw__"); + } + + #[test] + fn empty_container_block_emits_no_group() { + // container-query-support: "No rule emitted for empty container block". + let owner = TestCtxOwner::new(); + let styles = json!({ "@container (min-width: 400px)": {} }); + let resolved = resolve_styles(&styles, &owner.ctx(), true); + assert!(resolved.conditioned.is_empty()); + } + + #[test] + fn raw_media_feature_block_resolves() { + // media-condition-aliases: "Reduced motion query". + let owner = TestCtxOwner::new(); + let styles = json!({ "@media (prefers-reduced-motion: reduce)": { "display": "none" } }); + let resolved = resolve_styles(&styles, &owner.ctx(), true); + let g = only_cond(&resolved); + assert_eq!(g.conditions, vec![Condition::Media("@media (prefers-reduced-motion: reduce)".to_string())]); + assert_eq!(g.declarations[0].value, "none"); + } + + #[test] + fn raw_supports_block_resolves_tokens_and_shorthands() { + // supports-condition-blocks: "Basic supports condition" (token + shorthand). + let owner = TestCtxOwner::new(); + let styles = json!({ "@supports (display: grid)": { "color": "primary", "p": 8 } }); + let resolved = resolve_styles(&styles, &owner.ctx(), true); + let g = only_cond(&resolved); + assert_eq!(g.conditions, vec![Condition::Supports("@supports (display: grid)".to_string())]); + // token resolution (color → var) + shorthand (p → padding 0.5rem) + assert!(g.declarations.iter().any(|d| d.property == "color" && d.value == "var(--colors-primary)")); + assert!(g.declarations.iter().any(|d| d.property == "padding" && d.value == "0.5rem")); + } + + #[test] + fn unknown_at_rule_prefix_ignored() { + // selector-alias-registry: "Misspelled at-rule prefix" — resolver + // silently drops it (the type layer errors in inc 04). + let owner = TestCtxOwner::new(); + let styles = json!({ "@containr card (min-width: 400px)": { "p": 8 }, "p": 4 }); + let resolved = resolve_styles(&styles, &owner.ctx(), true); + assert!(resolved.conditioned.is_empty()); + assert_eq!(resolved.declarations.len(), 1); // the valid `p: 4` survives + } + + #[test] + fn registered_media_alias_resolves() { + // media-condition-aliases: "Media alias in a style object" + token body. + let owner = TestCtxOwner::new().with_conditions(); + let styles = json!({ "_motionReduce": { "color": "primary" } }); + let resolved = resolve_styles(&styles, &owner.ctx(), true); + let g = only_cond(&resolved); + assert_eq!(g.conditions, vec![Condition::Media("@media (prefers-reduced-motion: reduce)".to_string())]); + assert_eq!(g.declarations[0].value, "var(--colors-primary)"); + assert!(matches!(g.emit_order, ConditionEmitOrder::Aliased(500))); + } + + #[test] + fn registered_container_alias_resolves() { + // container-query-support: "Container alias in a style object". + let owner = TestCtxOwner::new().with_conditions(); + let styles = json!({ "_cardSm": { "display": "grid" } }); + let resolved = resolve_styles(&styles, &owner.ctx(), true); + let g = only_cond(&resolved); + assert_eq!(g.conditions, vec![Condition::Container("@container card (min-width: 400px)".to_string())]); + assert!(matches!(g.emit_order, ConditionEmitOrder::Aliased(510))); + } + + #[test] + fn registered_supports_alias_resolves() { + // supports-condition-blocks: "Supports alias in a style object". + let owner = TestCtxOwner::new().with_conditions(); + let styles = json!({ "_hasGrid": { "display": "grid" } }); + let resolved = resolve_styles(&styles, &owner.ctx(), true); + let g = only_cond(&resolved); + assert_eq!(g.conditions, vec![Condition::Supports("@supports (display: grid)".to_string())]); + assert!(matches!(g.emit_order, ConditionEmitOrder::Aliased(520))); + } + + #[test] + fn unregistered_condition_alias_ignored() { + let owner = TestCtxOwner::new().with_conditions(); + // _notRegistered is neither a selector nor a condition alias → dropped. + let styles = json!({ "_notRegistered": { "p": 8 }, "p": 4 }); + let resolved = resolve_styles(&styles, &owner.ctx(), true); + assert!(resolved.conditioned.is_empty()); + assert_eq!(resolved.declarations.len(), 1); + } + + #[test] + fn selector_alias_wins_over_condition_alias_same_name() { + // Precedence: a `_` name registered as a selector alias resolves as a + // pseudo, not a condition (selector_aliases checked first). + let mut owner = TestCtxOwner::new().with_conditions(); + owner.selector_aliases.insert("_dual".to_string(), "&:hover".to_string()); + owner.condition_aliases.insert( + "_dual".to_string(), + ConditionAliasEntry { value: "@media print".to_string(), order: 530, kind: "media".to_string() }, + ); + let styles = json!({ "_dual": { "color": "primary" } }); + let resolved = resolve_styles(&styles, &owner.ctx(), true); + assert_eq!(resolved.pseudo_selectors.len(), 1); + assert!(resolved.conditioned.is_empty()); + } + + #[test] + fn value_position_condition_key_produces_no_condition_group() { + // media-condition-aliases: "Condition alias in value position produces + // no media rule" — value-position maps admit only `_`/breakpoint keys. + let owner = TestCtxOwner::new().with_conditions(); + let styles = json!({ "p": { "_motionReduce": 12 } }); + let resolved = resolve_styles(&styles, &owner.ctx(), true); + // `{ _motionReduce: 12 }` is not a responsive value (key not `_`/bp), + // so `p`'s value is a non-stringifiable object → dropped, no condition. + assert!(resolved.conditioned.is_empty()); + } + + #[test] + fn container_establishment_longhands_emit_as_pass_through_declarations() { + // container-query-support: "Establishing a named container" (design D7 — + // plain pass-through declarations, no dedicated machinery). Evidence of + // record for the inc-07 typecheck-only landing. + let owner = TestCtxOwner::new(); + let styles = json!({ "containerType": "inline-size", "containerName": "card" }); + let resolved = resolve_styles(&styles, &owner.ctx(), true); + assert!(resolved.conditioned.is_empty()); + let decls: Vec<(&str, &str)> = resolved + .declarations + .iter() + .map(|d| (d.property.as_str(), d.value.as_str())) + .collect(); + assert!(decls.contains(&("container-type", "inline-size")), "{decls:?}"); + assert!(decls.contains(&("container-name", "card")), "{decls:?}"); + } + + #[test] + fn container_establishment_shorthand_emits_as_pass_through_declaration() { + // container-query-support: "Container shorthand" (design D7). + let owner = TestCtxOwner::new(); + let styles = json!({ "container": "card / inline-size" }); + let resolved = resolve_styles(&styles, &owner.ctx(), true); + assert_eq!(resolved.declarations.len(), 1); + assert_eq!(resolved.declarations[0].property, "container"); + assert_eq!(resolved.declarations[0].value, "card / inline-size"); + } + + #[test] + fn condition_emission_order_alias_before_raw_and_by_registry() { + // stylesheet-assembly: aliased conditions precede raw keys; raw keys + // keep source order; aliased sort by registry order. + let owner = TestCtxOwner::new().with_conditions(); + let styles = json!({ + "@supports (display: grid)": { "display": "grid" }, + "@container (min-width: 400px)": { "p": 8 }, + "_cardSm": { "display": "grid" }, + "_motionReduce": { "display": "none" }, + }); + let resolved = resolve_styles(&styles, &owner.ctx(), true); + let ordered = resolved.conditioned_emission_order(); + assert_eq!(ordered.len(), 4); + // aliased first, by registry order: _motionReduce(500) then _cardSm(510) + assert!(matches!(ordered[0].emit_order, ConditionEmitOrder::Aliased(500))); + assert!(matches!(ordered[1].emit_order, ConditionEmitOrder::Aliased(510))); + // then raw, in source order: @supports (idx 0) then @container (idx 1) + assert_eq!(ordered[2].conditions[0].prelude(), Some("@supports (display: grid)")); + assert_eq!(ordered[3].conditions[0].prelude(), Some("@container (min-width: 400px)")); + } + + // ---- inc 05: recursive nested resolution (design D5) ---- + + #[test] + fn nested_alias_in_alias_composes_selector() { + let owner = TestCtxOwner::new(); + let mut aliases = SelectorAliasesMap::default(); + aliases.insert("_hover".into(), "&:hover".into()); + aliases.insert("_before".into(), "&::before".into()); + aliases.insert("_active".into(), "&:active".into()); + let owner = TestCtxOwner { selector_aliases: aliases, ..owner }; + let styles = json!({ "_hover": { "_before": { "opacity": 1 } }, "_active": { "_before": { "opacity": 0.5 } } }); + let resolved = resolve_styles(&styles, &owner.ctx(), true); + let hover: Vec<_> = resolved.pseudo_selectors.iter().filter(|(s, _)| s == ":hover::before").collect(); + assert_eq!(hover.len(), 1, "composed :hover::before entry: {:?}", resolved.pseudo_selectors); + // Auto-content applies to nested _before under base definitions. + assert_eq!(hover[0].1[0].property, "content"); + assert!(hover[0].1.iter().any(|d| d.property == "opacity" && d.value == "1")); + // Ordering: hover-composed entry precedes active-composed entry (insertion). + let hi = resolved.pseudo_selectors.iter().position(|(s, _)| s == ":hover::before").unwrap(); + let ai = resolved.pseudo_selectors.iter().position(|(s, _)| s == ":active::before").unwrap(); + assert!(hi < ai); + // Depth-2 content did NOT flatten into :hover itself. + assert!(!resolved.pseudo_selectors.iter().any(|(s, d)| s == ":hover" && d.iter().any(|x| x.property == "opacity"))); + } + + #[test] + fn nested_raw_descendant_with_alias_and_reverse() { + let owner = TestCtxOwner::new(); + let mut aliases = SelectorAliasesMap::default(); + aliases.insert("_hover".into(), "&:hover".into()); + let owner = TestCtxOwner { selector_aliases: aliases, ..owner }; + let styles = json!({ + "& .icon": { "_hover": { "color": "primary" } }, + "_hover": { "& .icon2": { "color": "primary" } } + }); + let resolved = resolve_styles(&styles, &owner.ctx(), true); + assert!(resolved.pseudo_selectors.iter().any(|(s, d)| s == " .icon:hover" && d[0].value == "var(--colors-primary)")); + assert!(resolved.pseudo_selectors.iter().any(|(s, _)| s == ":hover .icon2")); + } + + #[test] + fn nested_condition_inside_selector_and_reverse() { + let owner = TestCtxOwner::new(); + let mut aliases = SelectorAliasesMap::default(); + aliases.insert("_hover".into(), "&:hover".into()); + let owner = TestCtxOwner { selector_aliases: aliases, ..owner }; + let styles = json!({ + "_hover": { "@container (min-width: 400px)": { "p": 16 } }, + "@supports (display: grid)": { "_hover": { "p": 8 } } + }); + let resolved = resolve_styles(&styles, &owner.ctx(), true); + assert_eq!(resolved.conditioned.len(), 2); + let container = resolved.conditioned.iter().find(|g| matches!(g.conditions.as_slice(), [Condition::Container(_)])).unwrap(); + assert_eq!(container.selector.as_deref(), Some(":hover")); + assert_eq!(container.declarations[0].value, "1rem"); + let supports = resolved.conditioned.iter().find(|g| matches!(g.conditions.as_slice(), [Condition::Supports(_)])).unwrap(); + assert_eq!(supports.selector.as_deref(), Some(":hover")); + assert_eq!(supports.declarations[0].value, "0.5rem"); + } + + #[test] + fn nested_stacked_conditions_outermost_first() { + let owner = TestCtxOwner::new(); + let styles = json!({ + "@supports (display: grid)": { "@container (min-width: 400px)": { "display": "grid" } } + }); + let resolved = resolve_styles(&styles, &owner.ctx(), true); + assert_eq!(resolved.conditioned.len(), 1); + let g = &resolved.conditioned[0]; + assert!(matches!(g.conditions.as_slice(), [Condition::Supports(_), Condition::Container(_)])); + assert_eq!(g.emit_order, ConditionEmitOrder::Raw(0)); + assert!(g.selector.is_none()); + } + + #[test] + fn nested_responsive_map_inside_condition_block() { + let owner = TestCtxOwner::new(); + let styles = json!({ + "@container (min-width: 400px)": { "fontSize": { "_": "14px", "sm": "16px" } } + }); + let resolved = resolve_styles(&styles, &owner.ctx(), true); + let base = resolved.conditioned.iter().find(|g| g.conditions.len() == 1).unwrap(); + assert!(base.declarations.iter().any(|d| d.property == "font-size" && d.value == "14px")); + let nested = resolved.conditioned.iter().find(|g| g.conditions.len() == 2).unwrap(); + assert!(matches!(&nested.conditions[1], Condition::Breakpoint(bp) if bp == "sm")); + assert_eq!(nested.declarations[0].value, "16px"); + assert_eq!(nested.emit_order, ConditionEmitOrder::Raw(0)); + // F1 (inc-05 review): the block's own declarations group must PRECEDE + // its breakpoint child, or the override is cascade-dead. + let base_idx = resolved.conditioned.iter().position(|g| g.conditions.len() == 1).unwrap(); + let nested_idx = resolved.conditioned.iter().position(|g| g.conditions.len() == 2).unwrap(); + assert!(base_idx < nested_idx, "base group must precede its breakpoint override"); + } + + #[test] + fn nested_responsive_map_inside_selector_block() { + let owner = TestCtxOwner::new(); + let mut aliases = SelectorAliasesMap::default(); + aliases.insert("_hover".into(), "&:hover".into()); + let owner = TestCtxOwner { selector_aliases: aliases, ..owner }; + let styles = json!({ "_hover": { "p": { "_": 8, "sm": 16 } } }); + let resolved = resolve_styles(&styles, &owner.ctx(), true); + assert!(resolved.pseudo_selectors.iter().any(|(s, d)| s == ":hover" && d[0].value == "0.5rem")); + let groups: Vec<_> = resolved.breakpoint_selector_groups().collect(); + assert_eq!(groups.len(), 1); + assert_eq!(groups[0].0, "sm"); + assert_eq!(groups[0].1, ":hover"); + assert_eq!(groups[0].2[0].value, "1rem"); + } + + #[test] + fn nested_depth_eight_composes_without_loss() { + let owner = TestCtxOwner::new(); + let styles = json!({ + "&:a1": { "&:a2": { "&:a3": { "&:a4": { "&:a5": { "&:a6": { "&:a7": { "&:a8": { "color": "primary" } } } } } } } } + }); + let resolved = resolve_styles(&styles, &owner.ctx(), true); + assert!(resolved.pseudo_selectors.iter().any(|(s, d)| s == ":a1:a2:a3:a4:a5:a6:a7:a8" && d[0].value == "var(--colors-primary)")); + } } diff --git a/packages/extract/crates/system-loader/src/lib.rs b/packages/extract/crates/system-loader/src/lib.rs index 7a67e458..e3bd67a0 100644 --- a/packages/extract/crates/system-loader/src/lib.rs +++ b/packages/extract/crates/system-loader/src/lib.rs @@ -29,6 +29,11 @@ pub struct SystemConfig { pub contextual_vars_json: String, pub selector_aliases: Option, pub selector_order: Option, + /// Condition alias map JSON (inc 03 — `conditionAliases`): alias → + /// `{ value, order, kind }`. `None` when the system registers no + /// condition aliases (and the built-in set is empty this increment), + /// keeping every existing manifest byte-identical. + pub condition_aliases: Option, pub global_style_blocks: Option, /// Keyframes exports — collections produced by the top-level `keyframes()` /// factory (objects with `__brand === 'Keyframes'`). JSON shape: @@ -941,6 +946,7 @@ fn extract_system_config( .map_err(|e| format!("groupRegistry not found in config: {}", e))?; let selector_aliases: Option = config_obj.get("selectorAliases").ok(); let selector_order: Option = config_obj.get("selectorOrder").ok(); + let condition_aliases: Option = config_obj.get("conditionAliases").ok(); // Find theme (export named 'tokens' or 'theme' with .serialize()) let theme_obj: Object = namespace @@ -983,6 +989,7 @@ fn extract_system_config( contextual_vars_json, selector_aliases, selector_order, + condition_aliases, global_style_blocks, keyframes_blocks, }) diff --git a/packages/extract/pipeline/analyze-project-args.ts b/packages/extract/pipeline/analyze-project-args.ts index eff8e5cf..92cba482 100644 --- a/packages/extract/pipeline/analyze-project-args.ts +++ b/packages/extract/pipeline/analyze-project-args.ts @@ -24,6 +24,9 @@ export type AnalyzeProjectArgs = [ pathAliasesJson: string | null, keyframesJson: string | null, staticCssJson: string | null, + // Appended slot (modern-css-surface inc 03): condition alias map JSON. + // Appended (not inserted mid-tuple) so existing slot positions are stable. + conditionAliasesJson: string | null, ]; /** @internal */ @@ -43,6 +46,8 @@ export interface AnalyzeProjectInputs { keyframesJson: string | null; /** Forced-emission declarations (spec: static-emission-overrides). */ staticCssJson: string | null; + /** Condition alias map JSON (modern-css-surface inc 03), or null. */ + conditionAliasesJson: string | null; } /** @internal */ @@ -65,5 +70,6 @@ export function buildAnalyzeProjectArgs( inputs.pathAliasesJson, inputs.keyframesJson, inputs.staticCssJson, + inputs.conditionAliasesJson, ]; } diff --git a/packages/extract/pipeline/engine-adapter.ts b/packages/extract/pipeline/engine-adapter.ts index db89b941..2e2d2019 100644 --- a/packages/extract/pipeline/engine-adapter.ts +++ b/packages/extract/pipeline/engine-adapter.ts @@ -61,7 +61,8 @@ export interface EngineApi { globalStyleBlocksJson: string | null, pathAliasesJson: string | null, keyframesJson: string | null, - staticCssJson: string | null + staticCssJson: string | null, + conditionAliasesJson: string | null ) => string; transformFile: ( source: string, @@ -104,6 +105,7 @@ interface V2ExtractEngineConfig { configJson: string; groupRegistryJson: string; selectorAliasesJson?: string; + conditionAliasesJson?: string; globalStyleBlocksJson?: string; keyframesJson?: string; packageResolutionJson?: string; @@ -167,7 +169,8 @@ export function createV2EngineApi(deps: V2EngineAdapterDeps): () => EngineApi { globalStyleBlocksJson, pathAliasesJson, keyframesJson, - staticCssJson + staticCssJson, + conditionAliasesJson ) => { const filesJson = deps.rehydrateFilesJson ? deps.rehydrateFilesJson(filesJsonRaw) @@ -209,6 +212,7 @@ export function createV2EngineApi(deps: V2EngineAdapterDeps): () => EngineApi { configJson: propConfigJson, groupRegistryJson, selectorAliasesJson: selectorAliasesJson ?? undefined, + conditionAliasesJson: conditionAliasesJson ?? undefined, globalStyleBlocksJson: globalStyleBlocksJson ?? undefined, keyframesJson: keyframesJson ?? undefined, packageResolutionJson: packageResolutionJson ?? undefined, diff --git a/packages/extract/pipeline/run-analysis.ts b/packages/extract/pipeline/run-analysis.ts index 4d158761..59094ef6 100644 --- a/packages/extract/pipeline/run-analysis.ts +++ b/packages/extract/pipeline/run-analysis.ts @@ -72,6 +72,7 @@ export function buildAnalysisInputs( pathAliasesJson: opts.pathAliasesJson, keyframesJson: opts.system.keyframesJson, staticCssJson: opts.staticCssJson ?? null, + conditionAliasesJson: opts.system.conditionAliasesJson ?? null, }; } diff --git a/packages/extract/pipeline/system-config.ts b/packages/extract/pipeline/system-config.ts index 5e96826a..21c21bc2 100644 --- a/packages/extract/pipeline/system-config.ts +++ b/packages/extract/pipeline/system-config.ts @@ -15,6 +15,11 @@ export interface SystemConfig { variableCss: string; contextualVarsJson: string | null; selectorAliasesJson: string | null; + /** Condition alias map JSON (modern-css-surface inc 03), mirroring + * `selectorAliasesJson` — `null` when the system registers none. + * Optional so the plugins' pre-load `emptySystemConfig()` default need not + * restate it; `loadSystemConfig` always populates it after a real load. */ + conditionAliasesJson?: string | null; globalStyleBlocksJson: string | null; keyframesJson: string | null; } @@ -66,6 +71,7 @@ export function loadSystemConfig( variableCss, contextualVarsJson, selectorAliasesJson: config.selectorAliases || null, + conditionAliasesJson: config.conditionAliases || null, globalStyleBlocksJson: config.globalStyleBlocks || null, keyframesJson: config.keyframesBlocks || null, }; diff --git a/packages/next-plugin/tests/analyze-project-args.test.ts b/packages/next-plugin/tests/analyze-project-args.test.ts index 44712e01..a196419b 100644 --- a/packages/next-plugin/tests/analyze-project-args.test.ts +++ b/packages/next-plugin/tests/analyze-project-args.test.ts @@ -15,10 +15,11 @@ const inputs = { pathAliasesJson: 'next-path-aliases', keyframesJson: 'next-keyframes', staticCssJson: 'next-static-css', + conditionAliasesJson: 'next-condition-aliases', }; describe('Next analyzeProject argument construction', () => { - test('pins all 15 production NAPI slots', () => { + test('pins all 16 production NAPI slots', () => { expect(buildAnalyzeProjectArgs({ ...inputs, devMode: false })).toEqual([ 'next-files', 'next-scales', @@ -35,10 +36,11 @@ describe('Next analyzeProject argument construction', () => { 'next-path-aliases', 'next-keyframes', 'next-static-css', + 'next-condition-aliases', ]); }); - test('pins all 15 HMR NAPI slots', () => { + test('pins all 16 HMR NAPI slots', () => { expect(buildAnalyzeProjectArgs({ ...inputs, devMode: true })).toEqual([ 'next-files', 'next-scales', @@ -55,6 +57,7 @@ describe('Next analyzeProject argument construction', () => { 'next-path-aliases', 'next-keyframes', 'next-static-css', + 'next-condition-aliases', ]); }); }); diff --git a/packages/showcase/src/constants/docsNav.ts b/packages/showcase/src/constants/docsNav.ts index 239652ed..549e4565 100644 --- a/packages/showcase/src/constants/docsNav.ts +++ b/packages/showcase/src/constants/docsNav.ts @@ -28,6 +28,7 @@ export const DOCS_NAV: NavEntry[] = [ path: '/docs/authoring/variants-states', }, { label: 'Selectors & Nesting', path: '/docs/authoring/selectors' }, + { label: 'Conditions', path: '/docs/authoring/conditions' }, { label: 'System Props', path: '/docs/authoring/system-props' }, { label: 'Custom Props & Transforms', diff --git a/packages/showcase/src/content/authoring/conditions.mdx b/packages/showcase/src/content/authoring/conditions.mdx new file mode 100644 index 00000000..5cbf5e85 --- /dev/null +++ b/packages/showcase/src/content/authoring/conditions.mdx @@ -0,0 +1,144 @@ +import { Callout } from '../../components/docs/Callout'; + +# Conditions + +Animus authors media, container, and feature conditions as **block keys** — +object-nested, never string-concatenated. Two forms coexist, and which you +reach for is a portability decision. + +## Raw at-rule keys (start here — portable) + +Any `@media` / `@container` / `@supports` string is a valid block key: + +```tsx +ds.styles({ + '@media (prefers-reduced-motion: reduce)': { transition: 'none' }, + '@container card (min-width: 400px)': { p: 24 }, + '@supports (display: grid)': { display: 'grid' }, +}); +``` + +Raw keys need **no registration** and emit through **any** extracting system — +including a foreign design system that never declared them. They are also the +only condition keys the type layer validates _by default_: a misspelled prefix +(`@medai …`) is rejected with a self-explaining `UnknownAtRule` error in every +app. The type layer stops at the prefix: a malformed query tail fails the +consumer build in CSS post-processing, and a syntactically valid but +meaningless tail ships as a never-matching rule — the extractor carries the +prelude verbatim. + +## Built-in aliases + +Nine media-feature aliases ship built in — if you know Tailwind or Panda you +can read them without docs: + +| Alias | Expands to | +| --------------------------------- | ------------------------------------------------ | +| `_motionReduce` | `@media (prefers-reduced-motion: reduce)` | +| `_motionSafe` | `@media (prefers-reduced-motion: no-preference)` | +| `_print` | `@media print` | +| `_portrait` / `_landscape` | `@media (orientation: …)` | +| `_moreContrast` / `_lessContrast` | `@media (prefers-contrast: …)` | +| `_osDark` / `_osLight` | `@media (prefers-color-scheme: …)` | + +```tsx +ds.styles({ _motionReduce: { transition: 'none' } }); +``` + +Color-mode aliases (`_dark` / `_light`) are deliberately NOT in this set — +color modes are selector-kind surface owned by the color-mode system. + +## Registered aliases + augmentation + +Register your own conditions on the system: + +```tsx +createSystem().addConditions({ + _cardSm: '@container card (min-width: 400px)', + _hasGrid: '@supports (display: grid)', +}); +``` + +To get **autocomplete and typo-rejection** on `_`-aliases, publish them once — +the same one-time step as the augmented `Theme`: + +```tsx +declare module '@animus-ui/system' { + interface Conditions extends Record, true> {} + interface Selectors extends Record, true> {} +} +``` + + + Without this publication, `_`-alias keys stay permissive: they still work, but + a typo (`_motionReduc`) is silently accepted and dropped at build time. Raw + at-rule keys are validated with or without the publication. Publishing EITHER + interface flips the whole `_` namespace to validating — augment both together + if you register both kinds. + + +## Container patterns — the compose-slot card + +Establish a named container on the Root slot (plain declarations), and let +children respond: + +```tsx +const Root = ds + .styles({ + containerType: 'inline-size', + containerName: 'card', + }) + .variant({ + prop: 'size', + variants: { sm: { p: 8 }, lg: { p: 24 } }, + }) + .asElement('article'); + +const Media = ds + .styles({ + '@container card (min-width: 400px)': { width: '50cqw' }, + }) + .asElement('div'); + +export const Card = compose({ Root, Media, Body }, { shared: { size: true } }); +``` + +Container-relative units (`cqw`, `cqi`, `cqh`, `cqb`, `cqmin`, `cqmax`) are +accepted on scale-typed props and passed through unconverted (numeric text is +normalized — `0.50cqw` emits as `0.5cqw`, like every unit). Prefer the **raw** +`@container card (…)` key in shared or library components — it emits against +any consumer theme. Reach for a registered `_cardSm` alias only inside a +single app where the terseness pays and the container name is stable. + +## Responsive maps and conditions together + +Breakpoint maps stay in **value position**; conditions are **block keys** — +they compose without colliding: + +```tsx +ds.styles({ + fontSize: { _: 14, md: 16 }, + '@container card (min-width: 400px)': { + fontSize: { _: 14, sm: 16 }, + }, + _print: { fontSize: 12 }, +}); +``` + +Inside a condition block, a breakpoint map nests its media query INSIDE the +outer at-rule. Condition keys are never valid in value position. + +## Portability asymmetry (read this) + +Raw keys and aliases are NOT symmetric: + +- **Raw keys emit everywhere** — through foreign, non-registering extraction + systems, into every consumer's CSS. +- **Aliases are extraction-system-scoped** — an aliased block only resolves + when the _extracting_ system carries the registration. A library that + authors `_cardSm` blocks but is consumed by an app that didn't register + `_cardSm` emits nothing for those blocks. (Built-in aliases are carried by + every system, so they are safe everywhere.) + +Lead with raw keys in shared code. Use registered aliases as an in-app +convenience. diff --git a/packages/showcase/src/content/authoring/selectors.mdx b/packages/showcase/src/content/authoring/selectors.mdx index 76a9a6b3..620965c0 100644 --- a/packages/showcase/src/content/authoring/selectors.mdx +++ b/packages/showcase/src/content/authoring/selectors.mdx @@ -281,7 +281,19 @@ const { system: ds } = createSystem() .build(); ``` -Custom aliases registered via `addSelectors()` are serialized into the system config and passed to the Rust extractor at build time. They work identically to built-in aliases — the extractor has no distinction between them. +Custom aliases registered via `addSelectors()` are serialized into the system config and passed to the Rust extractor at build time. They work identically to built-in aliases — the extractor has no distinction between them. Custom selector aliases are also **typed at the call site**: publish them once via module augmentation and your `_`-keys gain autocomplete and typo-rejection alongside the built-ins: + +```tsx +declare module '@animus-ui/system' { + interface Selectors extends Record, true> {} +} +``` + +Note the **joint namespace** — publishing either `Selectors` OR `Conditions` flips the _whole_ `_` block-key namespace from permissive to validating, so augment both together if you register both kinds. + +### Raw selector keys vs aliases + +A raw `'&…'` selector key (`'&:hover'`, `'&[data-state="open"]'`, `'& > *'`) is always accepted and emitted verbatim. Unlike raw _at-rule_ keys, raw selector keys are NOT shape-validated, so prefer a built-in or registered alias when one exists: the compound aliases (`_disabled` covers `:disabled`, `[disabled]`, `[aria-disabled="true"]`, and `[data-disabled]`) also spare you from silently under-covering ARIA/data variants that a hand-written `'&:disabled'` would drop. --- diff --git a/packages/showcase/src/ds.ts b/packages/showcase/src/ds.ts index 20344d3e..048952c3 100644 --- a/packages/showcase/src/ds.ts +++ b/packages/showcase/src/ds.ts @@ -609,9 +609,26 @@ export const tokens = createTheme() drawerWidth: '280px', }, }) - .declareContextualVars({ - colors: ['current-bg'], - }) + .declareContextualVars( + { + colors: ['current-bg'], + }, + // `@property` registration for the contextual var (design D6). Emits + // `@property --current-bg { syntax: ""; inherits: false; + // initial-value: transparent; }` at the head of the variables part, before + // any `@layer` block. Opt-in metadata — absent it, variable CSS is + // byte-identical. This is the first end-to-end registered-var fixture + // through the full NAPI pipeline (inc-07 V9), consumed by the test-ds + // `ContainerCard` family's `var(--current-bg)` reference rendered on the + // Examples page. + { + 'current-bg': { + syntax: '', + inherits: false, + initialValue: 'transparent', + }, + } + ) .build(); export type ShowcaseTheme = typeof tokens; diff --git a/packages/showcase/src/pages/Examples.tsx b/packages/showcase/src/pages/Examples.tsx index 7d94b210..61b2b422 100644 --- a/packages/showcase/src/pages/Examples.tsx +++ b/packages/showcase/src/pages/Examples.tsx @@ -1,6 +1,10 @@ import { useCallback, useEffect, useRef, useState } from 'react'; -import { Button as TestDsButton, Card as TestDsCard } from '@animus-ui/test-ds'; +import { + Button as TestDsButton, + Card as TestDsCard, + ContainerCard, +} from '@animus-ui/test-ds'; import { Button, ButtonDisplay, ButtonLink } from '../components/docs/Button'; import { Heading } from '../components/docs/Heading'; @@ -629,6 +633,31 @@ export default function Examples() { +
+ + Container Queries + + + The ContainerCard family from @animus-ui/test-ds. The + Root slot establishes a named query container ( + container-name: card; container-type: inline-size); the + Media and Body slots respond via raw{' '} + @container card (min-width: 400px) block keys — portable, + no registration required. The Media slot fills with the{' '} + @property-registered --current-bg contextual + var. + +
+ + + + Resize the container — padding, media height, and body font size + respond to the card's own inline size, not the viewport. + + +
+
+
Theme Composition diff --git a/packages/system/__tests__/declaration-specifiers.test.ts b/packages/system/__tests__/declaration-specifiers.test.ts new file mode 100644 index 00000000..07f72247 --- /dev/null +++ b/packages/system/__tests__/declaration-specifiers.test.ts @@ -0,0 +1,33 @@ +import { spawnSync } from 'node:child_process'; +import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { resolve } from 'node:path'; +import { expect, test } from 'vitest'; + +const ROOT = resolve(import.meta.dirname, '../../..'); + +test('emits a Node ESM-resolvable conditions re-export', () => { + const outDir = mkdtempSync(resolve(tmpdir(), 'animus-system-dts-')); + + try { + const result = spawnSync( + resolve(ROOT, 'node_modules/.bin/tsc'), + [ + '-p', + 'packages/system/tsconfig.build.json', + '--outDir', + outDir, + '--declarationMap', + 'false', + ], + { cwd: ROOT, encoding: 'utf8' } + ); + + expect(result.status, result.stderr || result.stdout).toBe(0); + + const declaration = readFileSync(resolve(outDir, 'index.d.ts'), 'utf8'); + expect(declaration).toContain("from './conditions.js';"); + } finally { + rmSync(outDir, { recursive: true, force: true }); + } +}); diff --git a/packages/system/__tests__/serialized-config.test.ts b/packages/system/__tests__/serialized-config.test.ts index 9b1a12ee..eb8e6961 100644 --- a/packages/system/__tests__/serialized-config.test.ts +++ b/packages/system/__tests__/serialized-config.test.ts @@ -70,6 +70,61 @@ const BUILT_IN_SELECTOR_ALIASES: Record = { _empty: '&:empty', }; +// The complete built-in CONDITION-alias contract, in cascade (order-index) +// order (modern-css-surface inc 06, design D8). `conditionAliases` is +// `JSON.stringify` of exactly this map for any system that registers no +// conditions — the "No user registrations serializes exactly the built-in set" +// spec scenario. Built-ins occupy the reserved order band 300–380 (BELOW the +// user band, which starts at 500 via mergeConditions' 490 floor). A +// rename/removal of any built-in alias, an order shift, or a query change must +// fail the golden deep-equals below. +const BUILT_IN_CONDITION_ALIASES: Record< + string, + { value: string; order: number; kind: string } +> = { + _motionReduce: { + value: '@media (prefers-reduced-motion: reduce)', + order: 300, + kind: 'media', + }, + _motionSafe: { + value: '@media (prefers-reduced-motion: no-preference)', + order: 310, + kind: 'media', + }, + _print: { value: '@media print', order: 320, kind: 'media' }, + _portrait: { + value: '@media (orientation: portrait)', + order: 330, + kind: 'media', + }, + _landscape: { + value: '@media (orientation: landscape)', + order: 340, + kind: 'media', + }, + _moreContrast: { + value: '@media (prefers-contrast: more)', + order: 350, + kind: 'media', + }, + _lessContrast: { + value: '@media (prefers-contrast: less)', + order: 360, + kind: 'media', + }, + _osDark: { + value: '@media (prefers-color-scheme: dark)', + order: 370, + kind: 'media', + }, + _osLight: { + value: '@media (prefers-color-scheme: light)', + order: 380, + kind: 'media', + }, +}; + /** * A system that exercises EVERY serialized prop feature: * - a group (`layout`) so `groupRegistry` is non-empty, @@ -114,21 +169,26 @@ function buildFeatureSystem() { } describe('serializeInstance contract', () => { - it('emits exactly four top-level keys', () => { + it('emits exactly five top-level keys', () => { const config = buildFeatureSystem(); - // ASSERTION 1: the exact top-level key set of SerializedConfig. + // ASSERTION 1: the exact top-level key set of SerializedConfig. The + // `conditionAliases` field was ADDED in the modern-css-surface change + // (inc 03) as a coordinated cross-language field — the Rust extractor + // mirror-parses it. `selectorAliases` is UNCHANGED alongside it. expect(Object.keys(config).sort()).toEqual([ + 'conditionAliases', 'groupRegistry', 'propConfig', 'selectorAliases', 'transforms', ]); - // Carrier types: three are JSON *strings*, transforms is a live JS object. + // Carrier types: four are JSON *strings*, transforms is a live JS object. expect(typeof config.propConfig).toBe('string'); expect(typeof config.groupRegistry).toBe('string'); expect(typeof config.selectorAliases).toBe('string'); + expect(typeof config.conditionAliases).toBe('string'); expect(typeof config.transforms).toBe('object'); }); @@ -232,16 +292,21 @@ describe('serializeInstance contract', () => { const config = system.toConfig(); - // Normalize the wire form: parse the three JSON-string fields, keep the + // Normalize the wire form: parse the four JSON-string fields, keep the // live `transforms` object as-is (it is `{}` for a transform-free system). const normalized = { propConfig: JSON.parse(config.propConfig), groupRegistry: JSON.parse(config.groupRegistry), transforms: config.transforms, selectorAliases: JSON.parse(config.selectorAliases), + conditionAliases: JSON.parse(config.conditionAliases), }; - // ASSERTION 4: one full deep-equal against the golden literal. + // ASSERTION 4: one full deep-equal against the golden literal. A system + // that registers no conditions serializes exactly the BUILT-IN condition + // set (built-in conditions ship as of inc 06 — design D8); `selectorAliases` + // is unchanged. Guardrail G1: every field except `conditionAliases` is + // byte-identical to the pre-condition-support output. expect(normalized).toEqual({ propConfig: { m: { property: 'margin', scale: 'space' }, @@ -252,6 +317,178 @@ describe('serializeInstance contract', () => { }, transforms: {}, selectorAliases: BUILT_IN_SELECTOR_ALIASES, + conditionAliases: BUILT_IN_CONDITION_ALIASES, + }); + }); + + it('serializes registered condition aliases as { value, order, kind } and leaves selectorAliases byte-identical', () => { + // WITHOUT any condition registration: conditionAliases is EXACTLY the + // built-in set (inc 06 — design D8), and selectorAliases is exactly the + // built-in selector set (byte-for-byte). + const { system: bare } = createSystem() + .addSelectors({ _brand: '&[data-brand]' }) + .build(); + const bareConfig = bare.toConfig(); + expect(JSON.parse(bareConfig.conditionAliases)).toEqual( + BUILT_IN_CONDITION_ALIASES + ); + + // WITH condition registration across all three kinds. `addConditions` + // infers `kind` from the at-rule prefix and assigns cascade `order` in + // registration sequence — NEW aliases allocate in the user band (500+, + // floored past the built-in 300–380 band), parallel to + // `addSelectors`/`mergeSelectors`. `_motionReduce` is a built-in, so + // registering it (identical value) OVERRIDES in place, preserving its + // built-in order 300 rather than allocating a new one. + const { system: withConds } = createSystem() + .addSelectors({ _brand: '&[data-brand]' }) + .addConditions({ + _motionReduce: '@media (prefers-reduced-motion: reduce)', + _cardSm: '@container card (min-width: 400px)', + _hasGrid: '@supports (display: grid)', + }) + .build(); + const condConfig = withConds.toConfig(); + + expect(JSON.parse(condConfig.conditionAliases)).toEqual({ + // built-ins carried through, `_motionReduce` overridden in place (same + // value, built-in order 300 preserved) + ...BUILT_IN_CONDITION_ALIASES, + // new user aliases land in the user band, starting at 500 + _cardSm: { + value: '@container card (min-width: 400px)', + order: 500, + kind: 'container', + }, + _hasGrid: { + value: '@supports (display: grid)', + order: 510, + kind: 'supports', + }, }); + + // LOAD-BEARING PROOF (inc 03 output contract): registering conditions does + // NOT perturb the serialized selector map — same bytes with and without. + expect(condConfig.selectorAliases).toBe(bareConfig.selectorAliases); + }); + + it('serializes exactly the built-in condition set for a system that registers no conditions', () => { + // Spec scenario (selector-alias-registry §"No user registrations serializes + // exactly the built-in set"): the manifest's condition map contains exactly + // the built-in condition alias set, and every other manifest field is + // byte-identical to the pre-condition-support output (Guardrail G1). + const { system } = createSystem() + .addGroup('space', { + m: { property: 'margin', scale: 'space' } as Prop, + }) + .build(); + const config = system.toConfig(); + expect(JSON.parse(config.conditionAliases)).toEqual( + BUILT_IN_CONDITION_ALIASES + ); + // selectorAliases still the bare built-in set (no custom selectors here). + expect(JSON.parse(config.selectorAliases)).toEqual( + BUILT_IN_SELECTOR_ALIASES + ); + }); + + it('lets a user condition alias override a built-in of the same name, preserving the built-in order', () => { + // Spec scenario (selector-alias-registry §"Override a built-in condition + // alias"): `_print` is a BUILT-IN at order 320. Re-registering it replaces + // the value while preserving the built-in cascade order (mirrors + // mergeSelectors override) — the override does NOT reallocate into the user + // band, so it never reorders relative to sibling built-ins. + const { system } = createSystem() + .addConditions({ _print: '@media print and (min-resolution: 300dpi)' }) + .build(); + const conditions = JSON.parse(system.toConfig().conditionAliases) as Record< + string, + { value: string; order: number; kind: string } + >; + expect(conditions._print.value).toBe( + '@media print and (min-resolution: 300dpi)' + ); + // built-in order 320 preserved, NOT a fresh 500-band order + expect(conditions._print.order).toBe(320); + // exactly one _print entry — override replaces, never appends + const printEntries = Object.keys(conditions).filter((k) => k === '_print'); + expect(printEntries).toHaveLength(1); + }); + + it('proves the vite-app override interaction: a user _motionReduce with the built-in value carries ONE entry at the built-in order', () => { + // Mirrors e2e/vite-app/src/ds.ts, which registers + // `_motionReduce: '@media (prefers-reduced-motion: reduce)'` — the SAME + // value the built-in already ships. mergeConditions' override branch keeps + // the built-in order (300) and replaces the value (a no-op here since the + // values match). The serialized manifest must therefore carry EXACTLY ONE + // `_motionReduce` entry, at order 300 — no double-emit, no reorder. + const { system } = createSystem() + .addConditions({ + _motionReduce: '@media (prefers-reduced-motion: reduce)', + }) + .build(); + const conditions = JSON.parse(system.toConfig().conditionAliases) as Record< + string, + { value: string; order: number; kind: string } + >; + expect(conditions._motionReduce).toEqual({ + value: '@media (prefers-reduced-motion: reduce)', + order: 300, + kind: 'media', + }); + // still exactly nine entries (the built-in set), NOT ten + expect(Object.keys(conditions)).toHaveLength(9); + }); + + it('allocates new user condition orders starting at 500, skipping the built-in band, without collision', () => { + // Regression + ORDER BAND proof (inc-03 full-pass): built-ins occupy + // 300–380. Each addConditions() call floors allocation at 490, so the FIRST + // new user alias lands at 500 (NOT max(built-in)=380 + 10 = 390, which would + // collide with / sit below the built-in band), and chained calls continue + // upward without restarting at 500. + const { system } = createSystem() + .addConditions({ _reducedData: '@media (prefers-reduced-data: reduce)' }) + .addConditions({ _cardSm: '@container card (min-width: 400px)' }) + .addConditions({ _hasGrid: '@supports (display: grid)' }) + .build(); + const conditions = JSON.parse(system.toConfig().conditionAliases) as Record< + string, + { order: number } + >; + // new user aliases: 500, 510, 520 — above the built-in band's top (380) + expect(conditions._reducedData.order).toBe(500); + expect(conditions._cardSm.order).toBe(510); + expect(conditions._hasGrid.order).toBe(520); + // built-ins keep their reserved band, all below 500 + expect(conditions._motionReduce.order).toBe(300); + expect(conditions._osLight.order).toBe(380); + // every allocated order is distinct + const orders = Object.values(conditions).map((c) => c.order); + expect(new Set(orders).size).toBe(orders.length); + }); + + it('throws when a condition alias name clashes with a built-in selector alias', () => { + // Spec scenario: "Condition alias clashing with a built-in selector alias". + // `_hover` is a built-in SELECTOR alias — registering it as a condition + // must fail loud at construction, naming the alias and both registries. + expect(() => + createSystem().addConditions({ _hover: '@media print' }) + ).toThrow(/_hover.*selector alias registry/); + }); + + it('throws when a condition alias name clashes with a custom selector alias', () => { + expect(() => + createSystem() + .addSelectors({ _brand: '&[data-brand]' }) + .addConditions({ _brand: '@container (min-width: 400px)' }) + ).toThrow(/_brand.*selector alias registry/); + }); + + it('throws in the REVERSE order too — selector registered after the condition (F-1.4)', () => { + expect(() => + createSystem() + .addConditions({ _open: '@media (min-width: 1px)' }) + .addSelectors({ _open: '&[data-open]' }) + ).toThrow(/_open.*condition alias/); }); }); diff --git a/packages/system/__tests__/test-system.ts b/packages/system/__tests__/test-system.ts index f6ef221e..fbe142c1 100644 --- a/packages/system/__tests__/test-system.ts +++ b/packages/system/__tests__/test-system.ts @@ -11,6 +11,8 @@ import { createSystem, createTheme } from '../src'; import { color, layout, space, typography } from '../src/groups'; +import type { ConditionsOf, SelectorsOf } from '../src'; + export const tokens = createTheme() .addBreakpoints({ xs: 480, sm: 768, md: 1024, lg: 1200, xl: 1440 }) .addScale({ @@ -49,4 +51,30 @@ export const { .addProps({ ratio: { property: 'aspectRatio' } as const, }) + // Condition alias registry (modern-css-surface inc 04): one of each kind so + // the §14 fixtures exercise media / container / supports block keys against a + // POPULATED publication (below). Registered keys accumulate into the phantom + // `Conds` union surfaced on the built system. + .addConditions({ + _motionReduce: '@media (prefers-reduced-motion: reduce)', + _cardSm: '@container card (min-width: 400px)', + _supportsGrid: '@supports (display: grid)', + }) + // Custom SELECTOR alias — folds into the same publication (design D9), making + // it a typed block key AND a typed component callsite prop (§14g). + .addSelectors({ + _hoverChild: '&:hover > *', + }) .build(); + +// Publish the registered condition + selector aliases through module +// augmentation (design D9 — the same mechanism as the augmented `Theme` +// below). This flips the `ThemedCSSProps` arms from permissive to VALIDATING: +// unknown `_` keys now resolve to branded `UnknownConditionAlias`. A system +// that skips this augmentation (e.g. the vite-app fixture) stays permissive. +declare module '../src' { + // eslint-disable-next-line @typescript-eslint/no-empty-interface + interface Conditions extends Record, true> {} + // eslint-disable-next-line @typescript-eslint/no-empty-interface + interface Selectors extends Record, true> {} +} diff --git a/packages/system/__tests__/theme.test.ts b/packages/system/__tests__/theme.test.ts index 9b2ade95..0426a583 100644 --- a/packages/system/__tests__/theme.test.ts +++ b/packages/system/__tests__/theme.test.ts @@ -509,6 +509,146 @@ describe('declareContextualVars', () => { }); }); +// ─── Tests: @property registration (D6) ───────────────────── +// +// Spec list: typed-property-registration delta + the contextual-vars phantom +// invariants those additions must preserve. + +describe('declareContextualVars @property registration', () => { + function buildRegistered() { + return createTheme() + .addBreakpoints(breakpoints) + .addColors({ bg: '#000' }) + .declareContextualVars( + { colors: ['current-bg'] }, + { + 'current-bg': { + syntax: '', + inherits: true, + initialValue: 'transparent', + }, + } + ) + .build(); + } + + // typed-property-registration › "Registered contextual var emits @property" + it('emits an @property rule with syntax, inherits, and initial-value', () => { + const css = buildRegistered().serialize().variableCss; + expect(css).toContain( + '@property --current-bg { syntax: ""; inherits: true; initial-value: transparent; }' + ); + }); + + it('places @property rules before the :root variable block', () => { + const css = buildRegistered().serialize().variableCss; + const propIdx = css.indexOf('@property'); + const rootIdx = css.indexOf(':root'); + expect(propIdx).toBeGreaterThanOrEqual(0); + expect(rootIdx).toBeGreaterThan(propIdx); + }); + + // stylesheet-assembly relies on ALL of variableCss preceding @layer; there is + // no @layer inside variableCss itself, so guard that invariant here. + it('emits no @layer inside the variables part', () => { + expect(buildRegistered().serialize().variableCss).not.toContain('@layer'); + }); + + // typed-property-registration › "Property registration is opt-in" + it('emits no @property when a contextual var is declared without metadata', () => { + const css = createTheme() + .addBreakpoints(breakpoints) + .addColors({ bg: '#000' }) + .declareContextualVars({ colors: ['current-bg'] }) + .build() + .serialize().variableCss; + expect(css).not.toContain('@property'); + }); + + // Byte-identical guarantee (G1): a metadata-free theme's variable CSS is + // untouched by the registration feature. + it('produces variable CSS identical to the pre-feature output when no metadata is supplied', () => { + const withoutFeatureUsage = createTheme() + .addBreakpoints(breakpoints) + .addColors({ bg: '#000000', ink: '#ffffff' }) + .build() + .serialize().variableCss; + const withUnregisteredCtxVar = createTheme() + .addBreakpoints(breakpoints) + .addColors({ bg: '#000000', ink: '#ffffff' }) + .declareContextualVars({ colors: ['current-bg'] }) + .build() + .serialize().variableCss; + // Contextual vars are phantom (no runtime var), so declaring one without + // registration metadata changes nothing in the emitted variable CSS. + expect(withUnregisteredCtxVar).toBe(withoutFeatureUsage); + }); + + it('omits the initial-value descriptor when initialValue is not supplied', () => { + const css = createTheme() + .addBreakpoints(breakpoints) + .addColors({ bg: '#000' }) + .declareContextualVars( + { colors: ['current-accent'] }, + { 'current-accent': { syntax: '*', inherits: false } } + ) + .build() + .serialize().variableCss; + expect(css).toContain( + '@property --current-accent { syntax: "*"; inherits: false; }' + ); + expect(css).not.toContain('initial-value'); + }); + + it('emits one @property block per registered var in declaration order', () => { + const css = createTheme() + .addBreakpoints(breakpoints) + .addColors({ bg: '#000' }) + .declareContextualVars( + { colors: ['current-bg', 'current-border'] }, + { + 'current-bg': { syntax: '', inherits: true }, + 'current-border': { syntax: '', inherits: false }, + } + ) + .build() + .serialize().variableCss; + const bgIdx = css.indexOf('@property --current-bg'); + const borderIdx = css.indexOf('@property --current-border'); + expect(bgIdx).toBeGreaterThanOrEqual(0); + expect(borderIdx).toBeGreaterThan(bgIdx); + }); + + // ── Phantom-typing invariants (contextual-vars spec) preserved ── + + // contextual-vars › "Runtime theme unchanged" + it('leaves the runtime theme object free of the registered var name', () => { + const theme = buildRegistered(); + expect(theme.colors).not.toHaveProperty('current-bg'); + expect(Object.keys(theme.colors as object)).toEqual(['bg']); + }); + + // contextual-vars › "Phantom keys do not appear in manifest" + it('does not surface the registered var as an emitted token in the manifest', () => { + const theme = buildRegistered(); + expect(theme.manifest.variableMap).not.toHaveProperty('colors.current-bg'); + expect(theme.manifest.tokenMap).not.toHaveProperty('colors.current-bg'); + }); + + // contextual-vars › "Serialized output includes registry" — and the wire + // shape the Rust extractor consumes (names-only) must NOT change. + it('keeps contextualVarsJson as the names-only registry shape', () => { + const ctx = JSON.parse(buildRegistered().serialize().contextualVarsJson); + expect(ctx).toEqual({ colors: ['current-bg'] }); + }); + + it('keeps manifest.contextualVars names-only despite registration metadata', () => { + expect(buildRegistered().manifest.contextualVars).toEqual({ + colors: ['current-bg'], + }); + }); +}); + // ─── Tests: extendScale ───────────────────────────────────── describe('extendScale', () => { diff --git a/packages/system/__tests__/types.test-d.tsx b/packages/system/__tests__/types.test-d.tsx index dba14a6d..dd7db3b9 100644 --- a/packages/system/__tests__/types.test-d.tsx +++ b/packages/system/__tests__/types.test-d.tsx @@ -760,6 +760,58 @@ function TypeTests() { 'current-border' extends keyof CtxColors ? true : false >; + // ── 12b. @property registration metadata (D6) ──────────────── + + // ✅ Registration metadata is accepted and does NOT alter name narrowing: + // 'current-bg' remains a literal-typed member of the colors scale. + const _ctxRegistered = createTheme() + .addBreakpoints({ xs: 480, sm: 768, md: 1024, lg: 1200, xl: 1440 }) + .addColors({ red: '#f00' }) + .declareContextualVars( + { colors: ['current-bg'] }, + { + 'current-bg': { + syntax: '', + inherits: true, + initialValue: 'transparent', + }, + } + ); + type CtxRegTheme = ReturnType<(typeof _ctxRegistered)['build']>; + type CtxRegColors = TokenScales['colors']; + type _CtxRegHasBg = Assert< + 'current-bg' extends keyof CtxRegColors ? true : false + >; + + // ✅ Metadata is optional — the initial-value descriptor may be omitted. + createTheme() + .addBreakpoints({ xs: 480, sm: 768, md: 1024, lg: 1200, xl: 1440 }) + .addColors({ red: '#f00' }) + .declareContextualVars( + { colors: ['current-accent'] }, + { 'current-accent': { syntax: '*', inherits: false } } + ); + + // ❌ Registration keys are constrained to the declared var names. + createTheme() + .addBreakpoints({ xs: 480, sm: 768, md: 1024, lg: 1200, xl: 1440 }) + .addColors({ red: '#f00' }) + .declareContextualVars( + { colors: ['current-bg'] }, + // @ts-expect-error — 'not-declared' is not a declared contextual var name + { 'not-declared': { syntax: '', inherits: true } } + ); + + // ── 12c. Container establishment props (D7) ────────────────── + // Establishment is plain pass-through CSS declarations (typed via csstype); + // no dedicated API. These must typecheck through the styles() surface. + ds.styles({ containerType: 'inline-size' }).asElement('div'); + ds.styles({ containerName: 'card' }).asElement('div'); + ds.styles({ container: 'card / inline-size' }).asElement('div'); + ds.styles({ containerType: 'inline-size', containerName: 'card' }).asElement( + 'section' + ); + // ── 13. .system() Mixed Namespace & Regression ───────────── // Guard: Extract must resolve to literal @@ -1125,6 +1177,286 @@ type AssertAllKeysAreAliases = { // Force evaluation — if any key maps to `never`, this assignment fails void (0 as unknown as AssertAllKeysAreAliases); +// ─── 14. Condition typing (D9) + pass-through responsive maps (D10) ────── +// +// test-system.ts registers three condition aliases (`_motionReduce` /`_cardSm`/ +// `_supportsGrid`, one per kind) plus a custom selector alias (`_hoverChild`) +// and PUBLISHES them via `declare module` augmentation — so this whole file +// compiles in the VALIDATING mode: unknown `_`/`@` block keys resolve to the +// branded rejection arms. The complementary PERMISSIVE mode (a system that +// registers aliases but does NOT augment) is proved live by the vite-app +// fixture's build (`e2e/vite-app` authors `_motionReduce` blocks with no +// `Conditions` augmentation) — it cannot be shown in-file because the +// augmentation is global to this compilation. + +import type { ConditionsOf, SelectorsOf } from '../src'; + +// Publication reaches the arms: the phantom brand surfaces exactly the +// registered keys (proves the SystemBuilder `Conds`/`Sels` accumulation). +type _CondsPublished = Assert< + IsExact< + ConditionsOf, + '_motionReduce' | '_cardSm' | '_supportsGrid' + > +>; +type _SelsPublished = Assert, '_hoverChild'>>; + +// ── 14a. Registered condition aliases at every chain position ────────────── +// (media-condition-aliases §"Condition blocks recognized at every chain level") + +// .styles() +ds.styles({ _motionReduce: { transition: 'none' } }); +// .variant() base + variant bodies +ds.styles({ display: 'flex' }).variant({ + prop: 'size', + base: { _cardSm: { p: 8 } }, + variants: { + sm: { _motionReduce: { transition: 'none' } }, + lg: { _supportsGrid: { display: 'grid' } }, + }, +}); +// .compound() +ds.styles({ display: 'flex' }) + .variant({ prop: 'size', variants: { sm: { p: 4 }, lg: { p: 16 } } }) + .compound({ size: 'sm' }, { _supportsGrid: { display: 'grid' } }); +// .states() +ds.styles({ display: 'flex' }).states({ + loading: { _motionReduce: { transition: 'none' } }, +}); + +// ── 14b. Unknown-alias + malformed-at-rule negatives (branded) ───────────── +// (selector-alias-registry §"Unregistered condition keys rejected at type +// level" — the branded type name states the offending key + remedy) + +// @ts-expect-error — _motionReduc is not a registered condition/selector alias +ds.styles({ _motionReduc: { transition: 'none' } }); +// @ts-expect-error — _bogusAlias is unregistered (UnknownConditionAlias) +ds.styles({ _bogusAlias: { p: 4 } }); +// @ts-expect-error — '@containr …' is a misspelled at-rule prefix (UnknownAtRule) +ds.styles({ '@containr card (min-width: 400px)': { p: 8 } }); +// @ts-expect-error — '@medai …' misspelled prefix +ds.styles({ '@medai (min-width: 400px)': { p: 8 } }); + +// ── 14c. Scale narrowing at depth 1 and 2 ────────────────────────────────── +// (selector-alias-registry §"Registered aliases accepted at depth" — the +// nested block typechecks AND scale-typed props retain scale-key validation) + +// depth-1 positive / negative +ds.styles({ _motionReduce: { p: 8 } }); +// @ts-expect-error — 199 is not in the space scale, inside a condition alias +ds.styles({ _motionReduce: { p: 199 } }); +// depth-2 positive: condition alias nested inside a selector alias +ds.styles({ _hover: { _cardSm: { p: 4 } } }); +// @ts-expect-error — 199 not in scale at depth 2 (checking survives recursion) +ds.styles({ _hover: { _cardSm: { p: 199 } } }); + +// ── 14d. Depth-8 mixed condition/selector stress ─────────────────────────── +// (design D12 — no depth cap; D9 — one instantiation per authored level, +// far under the TS2589 limit; checking survives to the deepest leaf) +ds.styles({ + _hover: { + _cardSm: { + '&:focus-visible': { + _supportsGrid: { + _motionReduce: { + '&::after': { + _hoverChild: { + // depth-8 leaf — scale key still validated here + p: 4, + }, + }, + }, + }, + }, + }, + }, +}); +// off-scale value at the depth-8 leaf still rejected (directive sits at the +// leaf — `@ts-expect-error` suppresses only the immediately following line) +ds.styles({ + _hover: { + _cardSm: { + '&:focus-visible': { + _supportsGrid: { + _motionReduce: { + '&::after': { + _hoverChild: { + // @ts-expect-error — 199 not in space scale at depth 8 + p: 199, + }, + }, + }, + }, + }, + }, + }, +}); + +// ── 14e. Raw-key accept + reject ─────────────────────────────────────────── +// (media-condition-aliases §"Raw media query block keys"; container-query +// support). Raw at-rule keys need no registration — validated by SHALLOW +// prefix+tail shape only. Container NAMES are NOT a closed union in this +// increment (no container-name registry — D9 "when available"), so any name is +// accepted by shape; deep query-grammar validation stays out (the TS2589 zone). +ds.styles({ + '@media (prefers-reduced-motion: reduce)': { transition: 'none' }, +}); +ds.styles({ '@media print': { display: 'none' } }); +ds.styles({ '@media (400px <= width < 800px)': { p: 8 } }); +ds.styles({ '@supports (display: grid)': { display: 'grid' } }); +ds.styles({ '@container card (min-width: 400px)': { p: 8 } }); +ds.styles({ '@container (min-width: 400px)': { p: 8 } }); // anonymous container +ds.styles({ '&[data-state="open"]': { p: 4, display: 'block' } }); +// @ts-expect-error — '@supprts …' misspelled prefix rejected by shape +ds.styles({ '@supprts (display: grid)': { display: 'grid' } }); + +// ── 14f. Custom SELECTOR alias — typed block key AND callsite prop ────────── +// (selector-alias-callsite: registered custom selectors fold into the same +// publication as built-ins; the typo is rejected the same way) + +// block-key position +ds.styles({ _hoverChild: { p: 4, bg: 'primary' } }); +// callsite position (AliasBox exposes space + surface groups) +void (); +void (); +// @ts-expect-error — _hoverChil is a typo of the registered custom selector +ds.styles({ _hoverChil: { p: 4 } }); +// @ts-expect-error — _hoverChil typo rejected at the callsite too +void (); + +// ── 14g. D10: value-position breakpoint maps on pass-through CSS props ────── +// (media-condition-aliases §"Breakpoint value maps on pass-through CSS +// properties"). `outlineWidth`/`outlineColor` are NOT in propConfig. + +// responsive map on a pass-through property typechecks +ds.styles({ outlineWidth: { _: '1px', sm: '2px' } }); +// bare pass-through value still works (map is additive, not required) +ds.styles({ outlineWidth: '1px' }); +// pass-through color prop accepts a responsive map of CSS color strings +ds.styles({ outlineColor: { _: 'red', sm: 'blue' } }); +// @ts-expect-error — 'xxl' is not a configured breakpoint key (map is +// breakpoint-narrowed on pass-through props, just like system props) +ds.styles({ outlineWidth: { _: '1px', xxl: '2px' } }); +// @ts-expect-error — boolean is not a valid pass-through value in a responsive slot +ds.styles({ outlineWidth: { _: '1px', sm: true } }); + +// ── 14h. addConditions value/key constraints ──────────────────────────────── +// (selector-alias-registry §"Non-condition values rejected by addConditions()") + +// @ts-expect-error — selector string rejected as a condition value at the type level +void createSystem().addConditions({ _open: '&[data-state="open"]' }); +// @ts-expect-error — unsupported at-rule (@keyframes) rejected as a condition value +void createSystem().addConditions({ _spin: '@keyframes spin' }); +// supported prefixes accepted +void createSystem().addConditions({ + _fineHover: '@media (hover: hover) and (pointer: fine)', +}); + +// ── 14i. Conditions are BLOCK-position only — never callsite props ────────── +// (design D2; registered SELECTOR aliases DO become callsite props — §14f — +// condition aliases must not.) +{ + const CondBox = ds.styles({ display: 'flex' }).asElement('div'); + // @ts-expect-error — a registered condition alias is not a component prop + void (); +} + +// ── 14j. Container-relative units on strict scale-typed props ──────────────── +// (container-query-support §"Container-relative units on scale-typed +// properties"; registry row 11 / design D11). `p` and `m` are STRICT space- +// scale props registered on this harness's `ds` (padding/margin — no +// `strict: false`; `gap` is NOT a registered prop here, so it resolves through +// the pass-through arm and is not a strict-scale witness — `p`/`m` are). The +// resolver accepts and emits the six container units verbatim (D11); the type +// surface admits them via `ContainerUnitValue` WITHOUT widening strict props to +// arbitrary strings — non-container unit strings and bare suffixes stay +// rejected. + +// container unit as a plain value on a strict scale prop +ds.styles({ p: '2cqi' }); +// a second container unit on a strict space prop +ds.styles({ p: '50cqw' }); +// admission generalizes across strict scale props (margin, negative-capable) +ds.styles({ m: '2cqi' }); +// container unit in a responsive-map value slot — the union lands once in +// ThemedScaleValue, and ResponsiveProp carries it into every breakpoint slot +// alongside the scale key (`8`) +ds.styles({ p: { _: 8, sm: '2cqi' } }); +// @ts-expect-error — '2vw' is a viewport unit, not one of the six container +// units; a strict scale prop rejects non-container unit strings +ds.styles({ p: '2vw' }); +// @ts-expect-error — 'cqi' has no numeric part; the `${number}` prefix is +// load-bearing, so a bare container-unit suffix is not a value +ds.styles({ p: 'cqi' }); + +// ── 14k. Container units at depth + pass-through responsive at depth ──────── +// (inc-11 full-pass F-1.1/F-1.2: the corpus must pin what Card.tsx proves.) + +// container unit on a strict scale prop INSIDE a condition block +ds.styles({ '@container card (min-width: 400px)': { p: '2cqi' } }); +// container unit at depth 2 (condition inside selector) +ds.styles({ _motionReduce: { p: '50cqw' } }); +// @ts-expect-error — non-container unit still rejected at depth +ds.styles({ '@container card (min-width: 400px)': { p: '2vw' } }); +// pass-through responsive map INSIDE a nested block (D10 wrapper at depth) +ds.styles({ '&:hover': { outlineWidth: { _: '1px', sm: '2px' } } }); +ds.styles({ + '@supports (display: grid)': { outlineWidth: { _: '1px', sm: '2px' } }, +}); +// @ts-expect-error — bad breakpoint key rejected in a nested pass-through map +ds.styles({ '&:hover': { outlineWidth: { _: '1px', xxl: '2px' } } }); + +// ── 14l. Built-in condition aliases typed with ZERO registration ──────────── +// (media-condition-aliases §"Built-in media-feature condition aliases"; design +// D8, inc 06). Built-ins are a STATIC `BuiltInConditionAlias` union in +// `KnownUnderscoreKey`'s validating branch — NOT members of the augmentable +// `Conditions` interface. So EVERY built-in types as a valid block key here, +// in this AUGMENTED compilation, WITHOUT being registered on `ds`: test-system +// registers only `_motionReduce`/`_cardSm`/`_supportsGrid`, so the eight others +// below (`_motionSafe`, `_print`, `_portrait`, `_landscape`, `_moreContrast`, +// `_lessContrast`, `_osDark`, `_osLight`) can ONLY be typed via the static +// union — the structural proof that built-ins survive publication without +// flipping non-augmenting consumers to branded-rejection. (The PERMISSIVE-mode +// counterpart — built-ins accepted when nothing is published — rests on the +// permissive `` `_${string}` `` branch being byte-untouched by inc 06; the +// vite-app build proves EMISSION through that path, not typing (vite never +// typechecks — augmentation is compilation-global, so an in-project +// permissive fixture is impossible; a separate unaugmented tsc project would +// be the real instrument if ever needed.) +ds.styles({ _motionReduce: { transition: 'none' } }); +ds.styles({ _motionSafe: { transition: 'none' } }); +ds.styles({ _print: { display: 'none' } }); +ds.styles({ _portrait: { display: 'block' } }); +ds.styles({ _landscape: { display: 'flex' } }); +ds.styles({ _moreContrast: { outline: '2px solid' } }); +ds.styles({ _lessContrast: { outline: 'none' } }); +ds.styles({ _osDark: { colorScheme: 'dark' } }); +ds.styles({ _osLight: { colorScheme: 'light' } }); + +// Built-in block recurses into the full themed body — scale-typed props inside +// retain scale-key validation (checking survives the recursion, same as a +// registered alias). +ds.styles({ _osDark: { p: 8 } }); +// @ts-expect-error — 199 is not in the space scale, inside a built-in condition +ds.styles({ _osDark: { p: 199 } }); +// built-in condition nested inside a selector alias (depth 2) still typechecks +ds.styles({ _hover: { _print: { p: 4 } } }); + +// A near-miss of a built-in name is still an unknown alias (branded rejection); +// the static union does not open the namespace to typos. +// @ts-expect-error — _osDrak is a typo of the built-in _osDark +ds.styles({ _osDrak: { display: 'none' } }); + +// Overriding a built-in condition by re-registering its name compiles at the +// type level (key matches `_${string}`, value is a valid at-rule) — the runtime +// override-preserves-order behavior is pinned in serialized-config.test.ts. +void createSystem().addConditions({ + _print: '@media print and (min-resolution: 300dpi)', +}); +void createSystem().addConditions({ + _osDark: '@media (prefers-color-scheme: dark)', +}); + // ─── Theme-typed builder-bound factories ────────────────────── // Proves createKeyframes + createGlobalStyles inherit the system's theme // context for prop validation and scale-token narrowing. diff --git a/packages/system/src/SystemBuilder.ts b/packages/system/src/SystemBuilder.ts index 95e72dd3..21d47ed6 100644 --- a/packages/system/src/SystemBuilder.ts +++ b/packages/system/src/SystemBuilder.ts @@ -1,4 +1,11 @@ import { Animus } from './Animus'; +import { + BUILT_IN_CONDITIONS, + type ConditionAliasMap, + mergeConditions, + type RegistryBrand, + serializeConditionMap, +} from './conditions'; import { type KeyframeFrameMap, type Keyframes, @@ -83,40 +90,108 @@ function arePropDefinitionsEqual(existing: Prop, incoming: Prop): boolean { export class SystemBuilder< PropReg extends Record = {}, GroupReg extends Record = {}, + Conds extends string = never, + Sels extends string = never, > { #propRegistry: PropReg; #groupRegistry: GroupReg; #selectorRegistry: SelectorAliasMap; #includesRegistry: readonly IncludableSystem[]; + #conditionRegistry: ConditionAliasMap; constructor( propRegistry?: PropReg, groupRegistry?: GroupReg, selectorRegistry?: SelectorAliasMap, - includesRegistry?: readonly IncludableSystem[] + includesRegistry?: readonly IncludableSystem[], + conditionRegistry?: ConditionAliasMap ) { this.#propRegistry = propRegistry || ({} as PropReg); this.#groupRegistry = groupRegistry || ({} as GroupReg); this.#selectorRegistry = selectorRegistry || { ...BUILT_IN_SELECTORS }; this.#includesRegistry = includesRegistry || []; + this.#conditionRegistry = conditionRegistry || { ...BUILT_IN_CONDITIONS }; } - addSelectors( - selectors: Record - ): SystemBuilder { + addSelectors>( + selectors: S + ): SystemBuilder> { + // Cross-registry clash guard, REVERSE direction (inc-11 full-pass F-1.4): + // a name already registered as a CONDITION alias must not be re-registered + // as a selector — Rust dispatch prefers selector aliases, so the condition + // would silently never resolve. addConditions guards the other direction. + for (const name of Object.keys(selectors)) { + if (name in this.#conditionRegistry) { + throw new Error( + `addSelectors: "${name}" is already registered as a condition alias; ` + + 'a name resolves through exactly one registry. Pick a distinct name.' + ); + } + } const merged = mergeSelectors(this.#selectorRegistry, selectors); + // Conds/Sels are phantom type-state (no runtime constructor slot); the + // constructor infers them as `never`, so the accumulated union is applied + // by this cast. return new SystemBuilder( this.#propRegistry, this.#groupRegistry, merged, - this.#includesRegistry + this.#includesRegistry, + this.#conditionRegistry + ) as SystemBuilder< + PropReg, + GroupReg, + Conds, + Sels | Extract + >; + } + + /** + * Register condition aliases (`_motionReduce`, `_cardSm`, …) → at-rule + * condition strings (`@media …` / `@container …` / `@supports …`). + * Recognized as block keys in style objects; user aliases override built-ins + * of the same name (design D3). Keys are constrained to `_`-prefixed aliases + * and values to `@`-prefixed at-rule strings — a value that does not begin + * with an at-rule name is a compile-time type error (design D9; the runtime + * `inferConditionKind` throw is defense-in-depth). The registered keys are + * accumulated into the phantom `Conds` union and surfaced on `build()`. + */ + addConditions< + C extends Record< + `_${string}`, + `@media${string}` | `@container${string}` | `@supports${string}` + >, + >( + conditions: C + ): SystemBuilder, Sels> { + const merged = mergeConditions( + this.#conditionRegistry, + conditions, + new Set(Object.keys(this.#selectorRegistry)) ); + return new SystemBuilder( + this.#propRegistry, + this.#groupRegistry, + this.#selectorRegistry, + this.#includesRegistry, + merged + ) as SystemBuilder< + PropReg, + GroupReg, + Conds | Extract, + Sels + >; } addGroup>( name: Name extends keyof PropReg ? never : Name, config: Conf - ): SystemBuilder> { + ): SystemBuilder< + PropReg & Conf, + GroupReg & Record, + Conds, + Sels + > { // Collision check: group name must not collide with any registered prop name if (name in this.#propRegistry) { throw new Error( @@ -150,14 +225,20 @@ export class SystemBuilder< nextProps, nextGroups, this.#selectorRegistry, - this.#includesRegistry - ); + this.#includesRegistry, + this.#conditionRegistry + ) as SystemBuilder< + PropReg & Conf, + GroupReg & Record, + Conds, + Sels + >; } addProps< Conf extends Record & Partial, never>>, - >(config: Conf): SystemBuilder { + >(config: Conf): SystemBuilder { // Collision check: prop names must not collide with any registered group name for (const key of Object.keys(config)) { if (key in this.#groupRegistry) { @@ -186,12 +267,13 @@ export class SystemBuilder< nextProps, this.#groupRegistry, this.#selectorRegistry, - this.#includesRegistry - ); + this.#includesRegistry, + this.#conditionRegistry + ) as SystemBuilder; } build(): { - system: SystemInstance; + system: SystemInstance; createGlobalStyles: GlobalStylesFactory; createKeyframes: CreateKeyframesFactory; } { @@ -203,12 +285,18 @@ export class SystemBuilder< const propRegistry = this.#propRegistry; const groupRegistry = this.#groupRegistry; const selectorRegistry = this.#selectorRegistry; + const conditionRegistry = this.#conditionRegistry; const system = Object.assign(animus, { toConfig: (): SerializedConfig => { - return serializeInstance(propRegistry, groupRegistry, selectorRegistry); + return serializeInstance( + propRegistry, + groupRegistry, + selectorRegistry, + conditionRegistry + ); }, - }) as SystemInstance; + }) as SystemInstance; const createGlobalStyles = ((styles: GlobalStyleMap): GlobalStyleBlock => ({ __brand: 'GlobalStyleBlock' as const, @@ -225,15 +313,24 @@ export class SystemBuilder< export type SystemInstance< PropReg extends Record, GroupReg extends Record, + Conds extends string = never, + Sels extends string = never, > = Animus & { toConfig(): SerializedConfig; -}; +} & RegistryBrand; export interface SerializedConfig { propConfig: string; groupRegistry: string; transforms: Record; selectorAliases: string; + /** + * Condition alias map JSON (inc 03 — NEW field): `alias → { value, order, + * kind }`. `"{}"` when the system registers no conditions (built-ins are + * empty this increment). Distinct from `selectorAliases`, which stays + * byte-for-byte unchanged. + */ + conditionAliases: string; } function serializeInstance< @@ -242,7 +339,8 @@ function serializeInstance< >( propRegistry: PropReg, groupRegistry: GroupReg, - selectorRegistry: SelectorAliasMap + selectorRegistry: SelectorAliasMap, + conditionRegistry: ConditionAliasMap ): SerializedConfig { const serialized: Record = {}; const transforms: Record = {}; @@ -282,12 +380,14 @@ function serializeInstance< } const { selectors } = serializeSelectorMap(selectorRegistry); + const conditions = serializeConditionMap(conditionRegistry); return { propConfig: JSON.stringify(serialized), groupRegistry: JSON.stringify(groupRegistry), transforms, selectorAliases: JSON.stringify(selectors), + conditionAliases: JSON.stringify(conditions), }; } diff --git a/packages/system/src/conditions.ts b/packages/system/src/conditions.ts new file mode 100644 index 00000000..9b0ba6df --- /dev/null +++ b/packages/system/src/conditions.ts @@ -0,0 +1,294 @@ +/** + * Condition alias registry — maps `_`-prefixed alias keys to at-rule condition + * strings (`@media` / `@container` / `@supports`). Parallel in shape to the + * selector alias registry (`selectors.ts`): alias → `{ value, order, kind }`. + * + * `order` determines cascade precedence within a layer (aliased conditions emit + * in registry order, per design D4). `kind` is inferred from the at-rule prefix + * of `value` (design D3) and drives which at-rule the extractor wraps the block + * in. Serialized into the manifest as the NEW `conditionAliases` field, leaving + * `selectorAliases` byte-for-byte unchanged. + * + * This module hosts BOTH the runtime registry and the authoring TYPE surface + * (inc 04): the augmentable `Conditions`/`Selectors` publication, the + * `ConditionsOf`/`SelectorsOf` extractors, the branded rejection types + * (`UnknownConditionAlias`/`UnknownAtRule`), and the shallow `RawAtRuleKey` + * shapes consumed by `ThemedCSSProps`' kind-dispatched arms. `addConditions()` + * constrains values to `@media`/`@container`/`@supports`-prefixed strings at + * the type level; `inferConditionKind` remains the runtime fail-loud check. + */ + +/** The three condition kinds, inferred from the at-rule prefix of the value. */ +export type ConditionKind = 'media' | 'container' | 'supports'; + +// ───────────────────────────────────────────────────────────────────────── +// Authoring TYPE surface (increment 04, design D9) +// +// Registered condition aliases publish their literal key types through module +// augmentation — an augmentable `interface Conditions` the consumer extends +// once with `ConditionsOf`, mirroring the augmented `Theme` for +// breakpoints. No generic threads through the `Animus` class family; the +// `ThemedCSSProps` arms read this global interface directly. +// ───────────────────────────────────────────────────────────────────────── + +/** + * Augmentable registry of registered condition alias keys. Empty by default; + * a consumer publishes its registrations with: + * + * ```ts + * declare module '@animus-ui/system' { + * interface Conditions extends Record, true> {} + * } + * ``` + * + * When this interface is empty (no publication), `ThemedCSSProps` keeps + * `_`-prefixed block keys permissive — the same graceful degradation the + * augmentable `Theme` uses (empty theme ⇒ raw CSS values). Once populated, the + * validating arms engage: unknown `_` keys resolve to `UnknownConditionAlias`. + * + * JOINT NAMESPACE (inc-04 F8/F9): condition and selector aliases share the one + * `_` block-key namespace, so publishing EITHER `Conditions` OR `Selectors` + * flips BOTH namespaces from permissive to validating (see `KnownUnderscoreKey` + * in `types/config.ts`, whose gate reads `keyof Conditions | keyof Selectors`). + * + * Built-in condition aliases (`BUILT_IN_CONDITIONS`, design D8) are NOT members + * of this interface — they live in the static `BuiltInConditionAlias` union + * (`types/config.ts`), mirroring `BuiltInSelectorAlias`. Defaulting members here + * would make publication permanently non-empty and destroy the degradation + * contract above (a non-augmenting consumer with custom aliases would flip from + * permissive to branded-rejection the day built-ins shipped). + */ +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface Conditions {} + +declare const CONDS_BRAND: unique symbol; +declare const SELS_BRAND: unique symbol; + +/** + * Phantom brand carried on `build()`'s system output, threading the accumulated + * registered condition (`C`) and custom selector (`S`) alias-key unions to the + * consumer's augmentation site. Type-only — never present at runtime. + */ +export interface RegistryBrand< + C extends string = never, + S extends string = never, +> { + readonly [CONDS_BRAND]?: C; + readonly [SELS_BRAND]?: S; +} + +/** Extract the registered condition alias keys from a built system. */ +export type ConditionsOf = + Sys extends RegistryBrand ? C : never; + +/** Extract the registered custom selector alias keys from a built system. */ +export type SelectorsOf = + Sys extends RegistryBrand ? S : never; + +/** + * Branded rejection for a `_`-prefixed block key that is neither a registered + * condition alias, a registered selector alias, a built-in selector alias, + * nor a built-in condition alias. + * The type name carries the offending key; the `hint` states the remedy. Never + * a bare `never` arm — `never` produces a misleading "not assignable to + * 'undefined'" (design D9, measured). + */ +export interface UnknownConditionAlias { + readonly __unknownConditionAlias: K; + readonly hint: `"${K}" is not a registered condition or selector alias. Register it with .addConditions() / .addSelectors(), or use a raw "&…" selector or "@media|@container|@supports (…)" key.`; +} + +/** + * Branded rejection for an `@`-prefixed block key that does not match an + * accepted at-rule shape (`@media …` / `@container …` / `@supports …`). The + * type name carries the offending key; the `hint` states the remedy. + */ +export interface UnknownAtRule { + readonly __unknownAtRule: K; + readonly hint: `"${K}" is not a valid at-rule block key. At-rule keys must begin with "@media ", "@container ", or "@supports " followed by a parenthesized/feature tail.`; +} + +/** + * SHALLOW at-rule key shapes (design D9): prefix + tail only. Deep query-grammar + * template literals are the repo's one known TS2589 zone (string-embedded + * unions) and stay out — Rust validates queries for real; the type layer stops + * at the prefix. The trailing space after each at-rule name is load-bearing: it + * rejects prefix typos (`@containr …`, `@medi …`) without a closed grammar. + */ +export type RawAtRuleKey = + | `@media ${string}` + | `@container ${string}` + | `@supports ${string}`; + +export interface ConditionAlias { + /** Full at-rule condition string, e.g. `@media (prefers-reduced-motion: reduce)`. */ + value: string; + /** Sort index for cascade ordering within a layer. */ + order: number; + /** Condition kind, inferred from the at-rule prefix of `value`. */ + kind: ConditionKind; +} + +export type ConditionAliasMap = Record; + +/** + * Built-in condition aliases (design D8, increment 06). + * + * The Panda-compatible media-feature set: motion, print, orientation, contrast, + * and OS color-scheme preferences. Every alias is a pure `@media` feature query + * (`kind: 'media'`). Color-mode aliases (`_dark` / `_light`) are DELIBERATELY + * excluded — they are selector-kind (`[data-color-mode]`) surface owned by the + * `system-color-scheme` change, not media features. + * + * ORDER BAND (inc-03 full-pass): built-ins occupy a reserved band BELOW the user + * band — one slot of 10 per alias in table row order, 300–380. The user band + * starts at 500 (`mergeConditions` floors allocation at 490 → first user alias + * is 500), so built-ins and user registrations never interleave on collision. + * This mirrors the selector registry's real layout (built-ins 10–440, users + * 500+). + * + * The static type-level mirror is `BuiltInConditionAlias` in `types/config.ts` + * (added to `KnownUnderscoreKey`'s VALIDATING branch, exactly like + * `BuiltInSelectorAlias`). Built-ins are NOT members of the augmentable + * `Conditions` interface: interface members would make publication permanently + * non-empty, killing inc-04's graceful-degradation contract. The + * `types.test-d.tsx` drift positives keep the two in sync. + */ +export const BUILT_IN_CONDITIONS: ConditionAliasMap = { + _motionReduce: { + value: '@media (prefers-reduced-motion: reduce)', + order: 300, + kind: 'media', + }, + _motionSafe: { + value: '@media (prefers-reduced-motion: no-preference)', + order: 310, + kind: 'media', + }, + _print: { value: '@media print', order: 320, kind: 'media' }, + _portrait: { + value: '@media (orientation: portrait)', + order: 330, + kind: 'media', + }, + _landscape: { + value: '@media (orientation: landscape)', + order: 340, + kind: 'media', + }, + _moreContrast: { + value: '@media (prefers-contrast: more)', + order: 350, + kind: 'media', + }, + _lessContrast: { + value: '@media (prefers-contrast: less)', + order: 360, + kind: 'media', + }, + _osDark: { + value: '@media (prefers-color-scheme: dark)', + order: 370, + kind: 'media', + }, + _osLight: { + value: '@media (prefers-color-scheme: light)', + order: 380, + kind: 'media', + }, +}; + +/** The supported at-rule prefixes, longest-first so `@media`/`@container`/ + * `@supports` match unambiguously. */ +const CONDITION_PREFIXES: readonly [string, ConditionKind][] = [ + ['@media', 'media'], + ['@container', 'container'], + ['@supports', 'supports'], +]; + +/** + * Infer the condition kind from an at-rule value's prefix (design D3). + * Throws (fail-loud, build time) when the value does not begin with a + * supported at-rule name. The compile-time complement is `addConditions()`'s + * value constraint (`@media`/`@container`/`@supports`-prefixed template + * literals); this runtime throw remains the backstop for untyped callers. + */ +export function inferConditionKind(value: string): ConditionKind { + for (const [prefix, kind] of CONDITION_PREFIXES) { + if (value.startsWith(prefix)) { + return kind; + } + } + throw new Error( + `addConditions: value "${value}" must begin with @media, @container, or @supports` + ); +} + +/** + * Merge user-provided condition aliases with a base map. + * + * - User aliases override built-ins of the same name, preserving the existing + * order (mirrors `mergeSelectors`). + * - New aliases allocate orders CONTINUING from the highest existing order + * (floored at 490, so the first user alias lands at 500) rather than + * restarting at 500 each call — chained `.addConditions()` calls must not + * collide on order 500. + * - Fails loud when a condition alias name is already reserved by the SELECTOR + * alias registry (`reservedSelectorNames`): the two registries share the `_` + * namespace at the call site and their names MUST be disjoint, else a block + * key would be ambiguous. The error names the offending alias and both + * registries. + */ +export function mergeConditions( + base: ConditionAliasMap, + custom: Record, + reservedSelectorNames: ReadonlySet = new Set() +): ConditionAliasMap { + const merged = { ...base }; + // Continue order allocation past every existing entry (floor 490 → first + // user alias is 500), instead of restarting at 500 per call. + let nextOrder = + Math.max(490, ...Object.values(merged).map((c) => c.order)) + 10; + + for (const [key, value] of Object.entries(custom)) { + if (reservedSelectorNames.has(key)) { + throw new Error( + `addConditions: alias "${key}" is already registered in the selector ` + + `alias registry — condition and selector alias names must be ` + + `disjoint (rename the condition alias or the selector alias).` + ); + } + const kind = inferConditionKind(value); + if (key in merged) { + // Override: preserve the existing order, replace value + re-infer kind. + merged[key] = { value, order: merged[key].order, kind }; + } else { + merged[key] = { value, order: nextOrder, kind }; + nextOrder += 10; + } + } + + return merged; +} + +/** Get the sorted alias keys for deterministic cascade ordering. */ +export function getSortedConditionKeys(map: ConditionAliasMap): string[] { + return Object.keys(map).sort((a, b) => map[a].order - map[b].order); +} + +/** + * Serialize the condition map for the extraction pipeline. + * Emits `alias → { value, order, kind }` in registry (cascade) order so the + * JSON is deterministic. Distinct in SHAPE from the selector map (which + * flattens to `alias → selector`) — the object entry is forward-compatible + * with a future multi-valued `values: string[]` property (design D3). + */ +export function serializeConditionMap( + map: ConditionAliasMap +): Record { + const conditions: Record = {}; + for (const key of getSortedConditionKeys(map)) { + conditions[key] = map[key]; + } + return conditions; +} diff --git a/packages/system/src/index.ts b/packages/system/src/index.ts index 489c2046..9353c783 100644 --- a/packages/system/src/index.ts +++ b/packages/system/src/index.ts @@ -29,11 +29,26 @@ export { numericScale, stringScale, } from './scales/createScale'; +// Condition aliases — runtime registry + augmentable authoring type surface +export { + BUILT_IN_CONDITIONS, + type ConditionAlias, + type ConditionAliasMap, + type ConditionKind, + type Conditions, + type ConditionsOf, + type RawAtRuleKey, + type RegistryBrand, + type SelectorsOf, + type UnknownAtRule, + type UnknownConditionAlias, +} from './conditions.js'; // Selector aliases export { BUILT_IN_SELECTORS, type SelectorAlias, type SelectorAliasMap, + type Selectors, } from './selectors'; export type { Assign, @@ -73,6 +88,7 @@ export type { } from './types/component'; export type { AbstractParser, + BuiltInConditionAlias, BuiltInSelectorAlias, CompoundEntry, CSSPropMap, @@ -105,6 +121,7 @@ export type { AbstractTheme, BaseTheme, ColorTokenRef, + ContextualVarRegistration, CSSColorValue, EmittedScales, EmittedTokenPaths, diff --git a/packages/system/src/selectors.ts b/packages/system/src/selectors.ts index 0b56a997..24cdde9a 100644 --- a/packages/system/src/selectors.ts +++ b/packages/system/src/selectors.ts @@ -16,6 +16,29 @@ export interface SelectorAlias { export type SelectorAliasMap = Record; +/** + * Augmentable registry of registered CUSTOM selector alias keys (design D9). + * Built-in selector aliases stay in the static `BuiltInSelectorAlias` union; + * this interface publishes user registrations (`.addSelectors({ … })`) so they + * become typed both as `ThemedCSSProps` block keys AND as component callsite + * props (`SelectorAliasProps`) — making the `selector-alias-callsite` + * custom-alias promise actually true. A consumer publishes with: + * + * ```ts + * declare module '@animus-ui/system' { + * interface Selectors extends Record, true> {} + * } + * ``` + * + * Empty by default. JOINT NAMESPACE: publishing EITHER `Selectors` or + * `Conditions` flips the WHOLE `_` block-key namespace to validating — + * augmenting one without the other rejects the other's registered aliases. + * When both are empty, `_` block keys stay fully permissive; built-in + * selector aliases are the only `_` keys typed at CALLSITE-prop position. + */ +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface Selectors {} + /** * Built-in selector aliases. * diff --git a/packages/system/src/theme/createTheme.ts b/packages/system/src/theme/createTheme.ts index b4a50cfc..323ea0fd 100644 --- a/packages/system/src/theme/createTheme.ts +++ b/packages/system/src/theme/createTheme.ts @@ -1,4 +1,9 @@ -import { CSSColorValue, SerializedTheme, ThemeManifest } from '../types/theme'; +import { + ContextualVarRegistration, + CSSColorValue, + SerializedTheme, + ThemeManifest, +} from '../types/theme'; import { LiteralPaths } from './flattenScale'; import { dotToDash, @@ -124,6 +129,18 @@ function validateColors(colors: Record): void { /** Flatten a type to prevent MergeTheme depth accumulation (TS2589). Exported for use in consumer themes. */ export type Flatten = { [K in keyof T]: T[K] }; +/** + * The union of contextual var NAMES declared across all scales in a + * `declareContextualVars` config. Read only for the optional registration + * parameter's key constraint — it derives FROM the already-inferred `Vars`, so + * it can never feed back into `Vars` inference or perturb literal-key narrowing. + */ +type ContextualVarNames = { + [K in keyof Vars]: Vars[K] extends readonly (infer N extends string)[] + ? N + : never; +}[keyof Vars]; + /** The built theme: nested raw data + non-enumerable boundary methods */ type BuiltTheme = { [K in keyof T]: T[K]; @@ -147,6 +164,13 @@ interface BuilderState { theme: Record; emittedScales: Set; contextualVars: Map; + /** + * `@property` registration metadata keyed by contextual var NAME (not the + * `--` custom property). Opt-in — empty unless a declaration supplies it. + * Kept separate from `contextualVars` so the names-only registry the Rust + * extractor consumes (`contextualVarsJson`) never changes shape. + */ + contextualVarRegistrations: Map; } function createState(theme?: Record): BuilderState { @@ -154,6 +178,7 @@ function createState(theme?: Record): BuilderState { theme: theme || { breakpoints: {} }, emittedScales: new Set(), contextualVars: new Map(), + contextualVarRegistrations: new Map(), }; } @@ -165,6 +190,7 @@ function copyState( theme: nextTheme, emittedScales: new Set(state.emittedScales), contextualVars: new Map(), + contextualVarRegistrations: new Map(state.contextualVarRegistrations), }; for (const [scale, vars] of state.contextualVars) { next.contextualVars.set(scale, [...vars]); @@ -203,6 +229,11 @@ export class ThemeBuilder< return new ThemeBuilder(copyState(this._state, nextTheme)); } + // KNOWN DROP (DEF-6, openspec change modern-css-surface): @property + // registration metadata does not survive from() — the manifest wire is + // names-only, so a composed theme loses its registrations until + // re-declared. Deliberate deferral, not an oversight; resolve with the + // first real from()-composed consumer that registers metadata. from>(builtTheme: Source) { const raw: Record = {}; for (const key of Object.keys(builtTheme)) { @@ -319,7 +350,16 @@ export class ThemeBuilder< const Vars extends Partial<{ [K in keyof T & string]: readonly string[]; }>, - >(vars: Vars) { + >( + vars: Vars, + // Optional `@property` registration metadata keyed by declared var name. + // A SEPARATE parameter (not folded into `vars`) so the literal-key + // narrowing of `Vars` above is byte-identical whether or not it is passed — + // the phantom typing of the declared var names is untouched by metadata. + registrations?: Partial< + Record, ContextualVarRegistration> + > + ) { for (const scale of Object.keys(vars)) { if (!(scale in this._state.theme)) { throw new Error( @@ -347,6 +387,16 @@ export class ThemeBuilder< ...(names as readonly string[]), ]); } + if (registrations) { + for (const [name, registration] of Object.entries(registrations)) { + if (registration) { + next._state.contextualVarRegistrations.set( + name, + registration as ContextualVarRegistration + ); + } + } + } return next; } @@ -399,7 +449,25 @@ export class ThemeBuilder< } // ── Assemble manifest ────────────────────────────────── - const variableCss = buildVariableCss(variables, bpVariables, modeVariables); + // `@property` registration rules ride at the head of the variables part — + // they are custom-property-owned and unlayered, so they land before the + // `@layer` declaration via the existing pre-`@layer` variable emission with + // no assembly change. Opt-in: absent metadata ⇒ empty string ⇒ the variable + // CSS is byte-identical to a theme that never registered anything. + const propertyCss = buildPropertyRegistrationCss( + contextualVars, + this._state.contextualVarRegistrations + ); + const baseVariableCss = buildVariableCss( + variables, + bpVariables, + modeVariables + ); + const variableCss = propertyCss + ? baseVariableCss + ? `${propertyCss}\n\n${baseVariableCss}` + : propertyCss + : baseVariableCss; const manifest: ThemeManifest = { tokenMap: { @@ -660,6 +728,39 @@ function resolveTokenRefs( } } +/** + * Build `@property` registration rules for registered contextual vars. + * The emitted custom property is `--${name}` — the same name the Rust resolver + * maps a bare contextual var value to. Rules emit in declaration order and only + * for names that are genuinely declared contextual vars. Returns `''` when no + * registration metadata was supplied (opt-in / byte-identical guarantee). + */ +function buildPropertyRegistrationCss( + contextualVars: Map, + registrations: Map +): string { + if (registrations.size === 0) return ''; + + const declaredNames = new Set(); + for (const names of contextualVars.values()) { + for (const name of names) declaredNames.add(name); + } + + const blocks: string[] = []; + for (const [name, registration] of registrations) { + if (!declaredNames.has(name)) continue; + const descriptors = [ + `syntax: "${registration.syntax}";`, + `inherits: ${registration.inherits};`, + ]; + if (registration.initialValue !== undefined) { + descriptors.push(`initial-value: ${registration.initialValue};`); + } + blocks.push(`@property --${name} { ${descriptors.join(' ')} }`); + } + return blocks.join('\n'); +} + /** Build CSS variable blocks from flattened data. */ function buildVariableCss( rootVariables: Record, diff --git a/packages/system/src/types/config.ts b/packages/system/src/types/config.ts index de15682b..e6409600 100644 --- a/packages/system/src/types/config.ts +++ b/packages/system/src/types/config.ts @@ -1,4 +1,11 @@ +import { + type Conditions, + type RawAtRuleKey, + type UnknownAtRule, + type UnknownConditionAlias, +} from '../conditions'; import { KeyframeRef } from '../keyframes'; +import { type Selectors } from '../selectors'; import { PropertyTypes } from './properties'; import { AbstractProps, ResponsiveProp, ThemeProps } from './props'; import { ArrayScale, MapScale } from './scales'; @@ -156,6 +163,25 @@ type ColorOpacityRef = Config['scale'] extends 'colors' : never : never; +/** + * Container-relative length units (design D11): the six container-query units, + * admitted verbatim as string values on strict scale-typed props. The resolver + * already accepts and emits these opaquely (D11 — container units transit the + * scale-lookup/transform/pass-through paths with no special awareness); this + * union closes the type-side gap so `gap: '2cqi'` typechecks WITHOUT widening a + * strict scale prop to arbitrary strings (`'2vw'` stays rejected — admission is + * these six suffixes only). Shallow, flat union — no cross-products (the repo's + * TS2589 zone is string-embedded unions, design D9). The `${number}` prefix is + * load-bearing: a bare `'cqi'` (no numeric part) is not assignable. + */ +export type ContainerUnitValue = + | `${number}cqw` + | `${number}cqi` + | `${number}cqh` + | `${number}cqb` + | `${number}cqmin` + | `${number}cqmax`; + export type ThemedScaleValue = Config['scale'] extends keyof TokenScales ? @@ -166,11 +192,13 @@ export type ThemedScaleValue = StrictOrEmpty[Config['scale']]> > | ColorOpacityRef + | ContainerUnitValue : Config['scale'] extends MapScale ? | keyof Config['scale'] | NegativeOf | PropertyValues> + | ContainerUnitValue : Config['scale'] extends ArrayScale ? | Config['scale'][number] @@ -181,28 +209,132 @@ export type ThemedScale = ResponsiveProp< ThemedScaleValue >; +/** Raw nested-selector block keys (`'&:hover'`, `'&[data-state]'`, `'& > *'`). */ +type RawSelectorKey = `&${string}`; + +/** + * Published alias keys (design D9): registered condition aliases + registered + * custom selector aliases, drawn from the augmentable `Conditions`/`Selectors` + * interfaces. `never` until a consumer augments. + * + * JOINT NAMESPACE (inc-04 F8/F9): conditions and selectors share the single `_` + * block-key namespace, so this gate reads BOTH interfaces. Publishing EITHER + * `Conditions` OR `Selectors` makes this non-`never`, which flips + * `KnownUnderscoreKey` (below) from permissive to validating for the WHOLE `_` + * namespace — not just the published side. This joint gate is documented here + * and at the `Conditions` interface declaration (`conditions.ts`); the + * `Selectors` interface (`selectors.ts`) feeds the same `keyof … | keyof …` + * union. + */ +type PublishedAliasKeys = Extract< + keyof Conditions | keyof Selectors, + `_${string}` +>; + /** - * Theme-aware CSS props — uses the augmentable Theme interface - * to constrain values per-key. No generic T needed. + * The `_`-prefixed keys accepted as recursing block keys. * - * When Theme is augmented, props with scales get constrained to scale keys. - * When Theme is NOT augmented (empty), falls back to standard CSS values. + * Graceful degradation (mirrors the empty-`Theme` fallback and + * `MediaQueryMap`'s `string extends BreakpointKeys` branch): when no condition + * or selector aliases are published, ALL `_${string}` keys stay permissive — + * a system that registered aliases via `.addConditions()` but did not augment + * `Conditions` (e.g. the vite-app fixture) still authors its aliased blocks + * without error. Once a publication exists, built-in selector aliases AND + * built-in condition aliases plus the published keys recurse; every other `_` + * key falls to the branded `UnknownConditionAlias` arm. * - * `animationName` is widened to accept `KeyframeRef` in addition to - * its standard string type, so `animationName: motion.ember` (a branded - * reference returned by the top-level `keyframes()` factory) type-checks - * directly alongside the legacy string form. + * Built-ins (`BuiltInSelectorAlias | BuiltInConditionAlias`) are STATIC unions + * in the validating branch, never members of the augmentable interfaces (design + * D8; see `BUILT_IN_CONDITIONS` in `conditions.ts`) — so `_motionReduce`, + * `_osDark`, … type as valid block keys with ZERO condition registrations, yet + * an empty publication keeps the whole namespace permissive (graceful + * degradation preserved). + */ +type KnownUnderscoreKey = [PublishedAliasKeys] extends [never] + ? `_${string}` + : BuiltInSelectorAlias | BuiltInConditionAlias | PublishedAliasKeys; + +/** Pass-through CSS property value (design D10 + `animationName` widening). */ +type PassThroughProp = K extends 'animationName' + ? ResponsiveProp | PropertyTypes[K]> + : ResponsiveProp; + +/** + * The `_`-prefixed MEMBERS of a recursing block body. Optional members over a + * closed set (built-ins + published) reject unknown `_` keys at depth as excess + * properties; when nothing is published the set opens to `` `_${string}` `` — + * a pattern index signature — keeping non-augmenting systems permissive. + */ +type UnderscoreBlockMembers> = { + [K in KnownUnderscoreKey]?: ThemedBlockBody; +}; + +/** + * The recursive body of a selector/condition block (design D9, full recursion — + * inc 05's resolver is fully recursive, so the type advertises exactly what the + * build emits). Deliberately a FIXED type (no reference to the outer inferred + * `Props`): a `ThemedCSSProps` arm would be reverse-mapped-inferred + * away at the `.styles()` call boundary and silently stop CHECKING nested values + * (booleans/off-scale keys would pass). Structuring the body as a fixed + * intersection restores structural checking at every depth — scale-typed props + * inside a nested block retain their scale-key validation. Raw `'&…'`/at-rule + * keys and `_`-aliases recurse; every other nested key is a pass-through CSS + * prop or an excess property. + */ +type ThemedBlockBody> = { + // Pass-through members carry the SAME D10 responsive wrapper (+ + // `animationName` KeyframeRef widening) as the top-level arm — the + // resolver resolves responsive maps and keyframe refs at every depth, + // so the type must too (inc-11 full-pass F-1.2: bare `Omit` + // members rejected `outlineWidth: { _, sm }` and `animationName: ref` + // inside nested blocks while the build emitted them). + [K in Exclude]?: PassThroughProp; +} & { + [P in keyof Config]?: ThemedScale; +} & { + [K in RawSelectorKey | RawAtRuleKey]?: ThemedBlockBody; +} & UnderscoreBlockMembers; + +/** + * Theme-aware CSS props — uses the augmentable Theme interface to constrain + * values per-key, plus kind-dispatched arms for block keys (design D9/D10). + * No generic T and no `Conditions`/`Selectors` generic thread through the + * `Animus` class family — the arms read the augmentable interfaces directly, + * the same publication mechanism as the augmented `Theme`. + * + * Arm dispatch per key `K`: + * 1. registered system prop (`keyof Config`) → `ThemedScale` (scale-narrowed). + * 2. block keys — raw `'&…'` selectors, valid raw `'@media|@container| + * @supports …'` at-rules, and known/permissive `_`-aliases — recurse into + * `ThemedBlockBody` (the complete, checked, themed surface). + * 3. pass-through CSS property (`keyof PropertyTypes`) → `ResponsiveProp<…>` + * so breakpoint value maps work on every themed prop (D10), not only + * propConfig-registered ones. `animationName` keeps its `KeyframeRef` + * widening (`animationName: motion.ember`). + * 4. unknown `_`-prefixed key (publication present) → branded + * `UnknownConditionAlias` naming the key + remedy. + * 5. unknown `@`-prefixed key (malformed at-rule) → branded `UnknownAtRule`. + * 6. anything else → the legacy accept-object (non-`_`/`@` junk keys are out + * of D9's scope; unchanged behavior). */ export type ThemedCSSProps> = { [K in keyof Props]?: K extends keyof Config ? ThemedScale - : K extends keyof PropertyTypes - ? K extends 'animationName' - ? KeyframeRef | PropertyTypes[K] - : PropertyTypes[K] - : Omit & { - [P in keyof Config]?: ThemedScale; - }; + : K extends RawSelectorKey + ? ThemedBlockBody + : K extends RawAtRuleKey + ? ThemedBlockBody + : K extends KnownUnderscoreKey + ? ThemedBlockBody + : K extends keyof PropertyTypes + ? PassThroughProp + : K extends `_${string}` + ? UnknownConditionAlias + : K extends `@${string}` + ? UnknownAtRule + : Omit & { + [P in keyof Config]?: ThemedScale; + }; }; export type ThemedCSSPropMap> = { @@ -264,11 +396,45 @@ export type BuiltInSelectorAlias = | '_odd' | '_empty'; +/** + * Built-in condition alias keys (design D8, increment 06). The Panda-compatible + * media-feature set — motion, print, orientation, contrast, OS color-scheme. + * Each maps to a `@media` feature query (see `BUILT_IN_CONDITIONS` in + * `conditions.ts`, the runtime mirror this must stay in sync with — the + * `types.test-d.tsx` positives are the drift guard). + * + * This is a STATIC union folded into `KnownUnderscoreKey`'s validating branch, + * exactly like `BuiltInSelectorAlias` — NOT default members of the augmentable + * `Conditions` interface. Interface members would make publication permanently + * non-empty and break inc-04's graceful-degradation contract (a non-augmenting + * consumer with custom aliases would flip from permissive to branded-rejection + * the day built-ins shipped). Color-mode aliases (`_dark` / `_light`) are OUT — + * they are selector-kind surface owned by the `system-color-scheme` change. + */ +export type BuiltInConditionAlias = + | '_motionReduce' + | '_motionSafe' + | '_print' + | '_portrait' + | '_landscape' + | '_moreContrast' + | '_lessContrast' + | '_osDark' + | '_osLight'; + /** * Selector alias props for component callsite. * Each alias key accepts the same prop interface as the component's * system groups, wrapped in Partial<>. + * + * Built-in selector aliases come from the static `BuiltInSelectorAlias` union; + * registered CUSTOM selector aliases fold in from the augmentable `Selectors` + * interface (design D9), making the `selector-alias-callsite` custom-alias + * promise true. Condition aliases are deliberately NOT included — conditions + * are block-position only (media-condition-aliases spec), never callsite props. */ export type SelectorAliasProps = { - [K in BuiltInSelectorAlias]?: Partial; + [K in + | BuiltInSelectorAlias + | Extract]?: Partial; }; diff --git a/packages/system/src/types/theme.ts b/packages/system/src/types/theme.ts index be4d0d9e..edc45384 100644 --- a/packages/system/src/types/theme.ts +++ b/packages/system/src/types/theme.ts @@ -105,6 +105,22 @@ export type ColorTokenRef = Theme extends { colors: infer C } : never : never; +/** + * Optional `@property` registration metadata for a contextual var. + * A 1:1 mirror of the CSS `@property` descriptors — `initialValue` (never bare + * `initial`, which collides with the CSS-wide keyword). Supplied alongside the + * contextual var declaration; purely additive — it does NOT alter the phantom + * typing of the declared var names or the runtime theme object. + */ +export interface ContextualVarRegistration { + /** CSS `syntax` descriptor, e.g. `''` or `'*'`. Emitted quoted. */ + syntax: string; + /** CSS `inherits` descriptor. */ + inherits: boolean; + /** CSS `initial-value` descriptor. Omitted from output when absent. */ + initialValue?: string; +} + /** Pipeline-ready JSON strings returned by `.serialize()` on a built theme. */ export interface SerializedTheme { /** Flattened token map as JSON: { "space.8": "0.5rem", "breakpoints.sm": "768" } */ diff --git a/packages/test-ds/src/components/Card.tsx b/packages/test-ds/src/components/Card.tsx index a265971f..8e1d9535 100644 --- a/packages/test-ds/src/components/Card.tsx +++ b/packages/test-ds/src/components/Card.tsx @@ -1,11 +1,48 @@ import { ds } from '../system'; +// Condition-block fixtures (modern-css-surface inc 03 + 05). RAW at-rule keys +// need no registration, so they emit through any extracting system — one per +// envelope scenario family: named container (+ container-relative unit), +// media-feature (non-breakpoint), and @supports, plus (inc 05) a stacked +// condition, a selector nested inside a condition, and a responsive value map +// inside a condition. Each must emit inside the owning @layer block, wrapping +// the class selector (Guardrail G2). export const Card = ds .styles({ bg: 'surface', p: 16, borderRadius: '8px', color: 'text', + // Container establishment (design D7): plain pass-through declarations, + // emitted verbatim in the base rule (`container-type` / `container-name`). + containerType: 'inline-size', + containerName: 'card', + '@container card (min-width: 400px)': { + p: 24, + width: '50cqw', + }, + '@media (prefers-reduced-motion: reduce)': { + transition: 'none', + }, + '@supports (display: grid)': { + display: 'grid', + // inc 04 (D9 recursive arms) now TYPES these nested block keys — the + // resolver has handled them since inc 05, and the type surface has + // caught up. The former broad `@ts-expect-error` (above `&:focus-visible`) + // is removed: nested selector + stacked-condition KEYS are valid now. + '&:focus-visible': { outline: '2px solid' }, + // inc 05: stacked conditions (container nests inside supports). + '@container card (min-width: 600px)': { + // Container-relative unit on a STRICT space-scale prop. Registry row 11 + // (modern-css-surface inc 11) admits the six container units on strict + // scale-typed props at the type level (`ContainerUnitValue`), matching + // the resolver, which accepts and emits them verbatim (design D11). The + // former `@ts-expect-error` bridge is removed — this now typechecks. + gap: '2cqi', + }, + // inc 05: responsive value map inside a condition block (scale keys). + fontSize: { _: 14, sm: 16 }, + }, }) .system({ m: true, mx: true, my: true }) .asElement('div'); diff --git a/packages/test-ds/src/components/ContainerCard.tsx b/packages/test-ds/src/components/ContainerCard.tsx new file mode 100644 index 00000000..eec8383c --- /dev/null +++ b/packages/test-ds/src/components/ContainerCard.tsx @@ -0,0 +1,91 @@ +import { compose } from '@animus-ui/system'; + +import { ds } from '../system'; + +// Canonical compose-slot CONTAINER pattern (modern-css-surface inc 08, +// cross-cutting 2.1). The Root slot ESTABLISHES a named query container +// (`container-name: card; container-type: inline-size` — design D7, plain +// pass-through declarations), and slotted children RESPOND to it via raw +// `@container card (…)` block keys. Raw at-rule keys need no registration, so +// this family emits its container CSS through ANY extracting system (portable +// across the consumer lanes — design D2 / inc-03 portability datum). +// +// The `Media` slot references the theme-registered `@property --current-bg` +// contextual var by `var(--current-bg)` (design D6). The registration lives in +// the CONSUMING app's theme (e.g. showcase `ds.ts` registers `current-bg` with +// `{ syntax, inherits, initialValue }`), so the family is the first end-to-end +// consumer that exercises a registered contextual var through the full NAPI +// pipeline (the owed inc-07 V9 obligation). + +const ContainerCardRoot = ds + .styles({ + display: 'flex', + flexDirection: 'column', + gap: 8, + p: 16, + borderRadius: '8px', + bg: 'surface', + color: 'text', + // Container establishment — ordinary declarations, emitted verbatim in the + // base rule (design D7). No dedicated establishment API. + containerType: 'inline-size', + containerName: 'card', + }) + .variant({ + prop: 'size', + defaultVariant: 'md', + variants: { + md: {}, + lg: { p: 24 }, + }, + }) + .asElement('article'); + +const ContainerCardMedia = ds + .styles({ + display: 'block', + width: '100%', + minHeight: '64px', + borderRadius: '4px', + // Consume the registered @property contextual var by var() reference — + // a pass-through value, emitted verbatim, portable to any theme that + // registers `--current-bg`. + background: 'var(--current-bg)', + // Respond to the named container: at >= 400px inline-size the media grows + // and switches to a container-relative width (design D11 unit transit). + '@container card (min-width: 400px)': { + minHeight: '120px', + width: '50cqw', + }, + }) + .variant({ + prop: 'size', + defaultVariant: 'md', + variants: { + md: {}, + lg: {}, + }, + }) + .asElement('div'); + +const ContainerCardBody = ds + .styles({ + fontSize: 14, + lineHeight: '1.5', + color: 'text', + // A second slot responding to the same container — proves multiple + // children key off one Root-established container. + '@container card (min-width: 400px)': { + fontSize: 16, + }, + }) + .asElement('div'); + +export const ContainerCard = compose( + { + Root: ContainerCardRoot, + Media: ContainerCardMedia, + Body: ContainerCardBody, + }, + { shared: { size: true }, name: 'ContainerCard' } +); diff --git a/packages/test-ds/src/index.ts b/packages/test-ds/src/index.ts index 8f0e3be2..e621ab37 100644 --- a/packages/test-ds/src/index.ts +++ b/packages/test-ds/src/index.ts @@ -2,5 +2,6 @@ export { Alert } from './components/Alert'; export { Badge } from './components/Badge'; export { Button } from './components/Button'; export { Card } from './components/Card'; +export { ContainerCard } from './components/ContainerCard'; export { ds } from './system'; export { referenceTokens } from './theme'; diff --git a/packages/test-ds/src/system.ts b/packages/test-ds/src/system.ts index 6267b801..bb6d66d9 100644 --- a/packages/test-ds/src/system.ts +++ b/packages/test-ds/src/system.ts @@ -22,4 +22,15 @@ export const { system: ds } = createSystem() .addGroup('text', typography) .addGroup('surface', { ...color, ...border }) .addGroup('positioning', positioning) + // Condition alias registry (modern-css-surface inc 03). Exercises the + // `addConditions()` builder + the `conditionAliases` manifest field across + // all three kinds. Aliased blocks only emit when the EXTRACTING system + // carries these registrations, so the component fixtures below use RAW + // at-rule keys (which need no registration); these aliases document the API + // shape and keep the serialized manifest populated. + .addConditions({ + _motionReduce: '@media (prefers-reduced-motion: reduce)', + _cardSm: '@container card (min-width: 400px)', + _hasGrid: '@supports (display: grid)', + }) .build(); diff --git a/packages/vite-plugin/tests/analyze-project-args.test.ts b/packages/vite-plugin/tests/analyze-project-args.test.ts index 0407664b..d08cc43e 100644 --- a/packages/vite-plugin/tests/analyze-project-args.test.ts +++ b/packages/vite-plugin/tests/analyze-project-args.test.ts @@ -2,7 +2,7 @@ import { buildAnalyzeProjectArgs } from '@animus-ui/extract/pipeline'; import { describe, expect, test } from 'vitest'; describe('Vite analyzeProject argument construction', () => { - test('pins all 15 production NAPI slots', () => { + test('pins all 16 production NAPI slots', () => { expect( buildAnalyzeProjectArgs({ filesJson: 'vite-production-files', @@ -19,6 +19,7 @@ describe('Vite analyzeProject argument construction', () => { pathAliasesJson: 'vite-production-path-aliases', keyframesJson: 'vite-production-keyframes', staticCssJson: 'vite-production-static-css', + conditionAliasesJson: 'vite-production-condition-aliases', }) ).toEqual([ 'vite-production-files', @@ -36,10 +37,11 @@ describe('Vite analyzeProject argument construction', () => { 'vite-production-path-aliases', 'vite-production-keyframes', 'vite-production-static-css', + 'vite-production-condition-aliases', ]); }); - test('pins all 15 dev NAPI slots', () => { + test('pins all 16 dev NAPI slots', () => { expect( buildAnalyzeProjectArgs({ filesJson: 'vite-dev-files', @@ -56,6 +58,7 @@ describe('Vite analyzeProject argument construction', () => { pathAliasesJson: 'vite-dev-path-aliases', keyframesJson: 'vite-dev-keyframes', staticCssJson: null, + conditionAliasesJson: null, }) ).toEqual([ 'vite-dev-files', @@ -73,6 +76,7 @@ describe('Vite analyzeProject argument construction', () => { 'vite-dev-path-aliases', 'vite-dev-keyframes', null, + null, ]); }); }); diff --git a/packages/vite-plugin/tests/property-registration-split.test.ts b/packages/vite-plugin/tests/property-registration-split.test.ts new file mode 100644 index 00000000..6b09cb97 --- /dev/null +++ b/packages/vite-plugin/tests/property-registration-split.test.ts @@ -0,0 +1,83 @@ +import { assembleStylesheet } from '@animus-ui/extract/pipeline'; +import { describe, expect, test } from 'vitest'; + +/** + * End-to-end proof of the property-registration split contract through the REAL + * shared `assembleStylesheet` — the same function both the Vite and Next plugins + * call (packages/vite-plugin/src/virtual-modules.ts and + * packages/next-plugin/src/extraction-session.ts import it identically). + * + * `@property` registration rules ride at the head of the theme's variable CSS + * (emitted by createTheme's build()). This asserts assembleStylesheet keeps them + * in the `variables` part — before the `@layer` declaration owns the cascade — + * with no assembly change: they flow through purely because variableCss → the + * variables part. + */ + +// Exactly the shape createTheme's serialize().variableCss produces for a +// registered contextual var (see packages/system/__tests__/theme.test.ts). +const VARIABLE_CSS = [ + '@property --current-bg { syntax: ""; inherits: true; initial-value: transparent; }', + '', + ':root {\n --color-primary: #abc;\n}', +].join('\n'); + +const COMPONENT_CSS = '@layer anm-base { .animus-card { padding: 8px; } }'; + +describe('assembleStylesheet: @property registration split contract', () => { + test('places @property in the variables part, absent from body/declaration', () => { + const { declaration, variables, body } = assembleStylesheet({ + variableCss: VARIABLE_CSS, + componentCss: COMPONENT_CSS, + split: true, + }); + + expect(variables).toContain('@property --current-bg'); + expect(body).not.toContain('@property'); + expect(declaration).not.toContain('@property'); + // declaration remains only the @layer ordering statement. + expect(declaration).toMatch(/@layer\s+[\w-]+(\s*,\s*[\w-]+)*\s*;/); + }); + + test('concatenation invariant: rejoined split equals the non-split output', () => { + const split = assembleStylesheet({ + variableCss: VARIABLE_CSS, + componentCss: COMPONENT_CSS, + split: true, + }); + const nonSplit = assembleStylesheet({ + variableCss: VARIABLE_CSS, + componentCss: COMPONENT_CSS, + }); + + const rejoined = [split.declaration, split.variables, split.body] + .filter(Boolean) + .join('\n'); + expect(rejoined).toBe(nonSplit); + }); + + test('@property appears before the @layer declaration in assembled output', () => { + const nonSplit = assembleStylesheet({ + variableCss: VARIABLE_CSS, + componentCss: COMPONENT_CSS, + }); + + const propIdx = nonSplit.indexOf('@property --current-bg'); + const layerBaseIdx = nonSplit.indexOf('@layer anm-base {'); + const declIdx = nonSplit.search(/@layer\s+[\w-]+(\s*,\s*[\w-]+)*\s*;/); + + expect(propIdx).toBeGreaterThanOrEqual(0); + // @property sits in the variables part, after the ordering declaration line + // but before any component @layer block. + expect(propIdx).toBeGreaterThan(declIdx); + expect(layerBaseIdx).toBeGreaterThan(propIdx); + }); + + test('opt-in: variableCss without @property yields no @property anywhere', () => { + const nonSplit = assembleStylesheet({ + variableCss: ':root {\n --color-primary: #abc;\n}', + componentCss: COMPONENT_CSS, + }); + expect(nonSplit).not.toContain('@property'); + }); +}); diff --git a/scripts/assert-showcase-build.ts b/scripts/assert-showcase-build.ts index 887f9f9d..100f486d 100644 --- a/scripts/assert-showcase-build.ts +++ b/scripts/assert-showcase-build.ts @@ -95,6 +95,24 @@ async function main(): Promise { ); } + // Registered @property wire pin (modern-css-surface inc 08, closing the + // inc-07 V9 gap): the showcase theme registers `current-bg` with + // registration metadata, so the dist MUST carry its @property rule in the + // variables part — before the first @layer block. If the ds.ts → NAPI → + // dist registration wire breaks, this fails loud. + const propertyIdx = css.indexOf('@property --current-bg'); + if (propertyIdx === -1) { + throw new AssertionError( + 'registered @property pin: expected `@property --current-bg` in the dist CSS (theme registers it with metadata)' + ); + } + const firstLayerBlockIdx = css.search(/@layer [\w-]+\s*\{/); + if (firstLayerBlockIdx !== -1 && propertyIdx > firstLayerBlockIdx) { + throw new AssertionError( + 'registered @property pin: `@property --current-bg` must precede the first @layer block (variables part)' + ); + } + assertNoPlaceholders(css); assertClassNameFormat(css, { prefix: 'animus-' }); diff --git a/scripts/verify/owner-graph.test.ts b/scripts/verify/owner-graph.test.ts index 097afb9d..a4c34a20 100644 --- a/scripts/verify/owner-graph.test.ts +++ b/scripts/verify/owner-graph.test.ts @@ -545,10 +545,14 @@ fi VP_CALLS: callLog, }, }); - const expectedCalls = [ - `run ${owner.manifest.name}#verify:build`, - `run ${owner.manifest.name}#verify:assert`, - ]; + const expectedCalls = [`run ${owner.manifest.name}#verify:build`]; + // Consumer apps with their own tsconfig carry a verify:tsc step + // (added 2026-07-23: no gate type-checked the e2e apps, which let + // fixture-component type drift hide — see scripts/verify/tsc-consumer.sh). + if (scripts['verify:tsc']) { + expectedCalls.push(`run ${owner.manifest.name}#verify:tsc`); + } + expectedCalls.push(`run ${owner.manifest.name}#verify:assert`); if (owner.worker) { expectedCalls.push(`run ${owner.manifest.name}#verify:dry-run`); } @@ -582,6 +586,7 @@ fi expect(failed.status).toBe(19); expect(readFileSync(callLog, 'utf8').trim().split('\n')).toEqual([ 'run @animus-ui/vite-app#verify:build', + 'run @animus-ui/vite-app#verify:tsc', 'run @animus-ui/vite-app#verify:assert', ]); }); diff --git a/scripts/verify/tsc-consumer.sh b/scripts/verify/tsc-consumer.sh new file mode 100755 index 00000000..174f027c --- /dev/null +++ b/scripts/verify/tsc-consumer.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +set -euo pipefail + +# verify:tsc (consumer apps) — tsc --noEmit over an e2e app's own tsconfig. +# Closes the gap where no gate type-checked the e2e apps (vite build strips +# types unchecked; verify:compile covers packages/* only), which let real +# type drift hide in fixture components (Pulse.tsx, found 2026-07-22). +# Usage: tsc-consumer.sh + +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +cd "$ROOT" + +source "$ROOT/scripts/verify/_preconditions.sh" + +require_bun_install + +APP_DIR="${1:?usage: tsc-consumer.sh }" +if [ ! -f "$APP_DIR/tsconfig.json" ]; then + echo "ERROR: $APP_DIR/tsconfig.json missing" >&2 + exit 1 +fi + +exec node_modules/.bin/tsc -p "$APP_DIR/tsconfig.json" --noEmit