Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,10 @@ tsconfig.tsbuildinfo
.claude
.roadmap
.playwright-mcp
# Per-machine MCP server wiring: the entries carry absolute paths to this
# checkout and assume locally-installed binaries, so committing one breaks
# every other clone and CI.
.mcp.json

tmp

Expand Down
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ This map routes an edit to the smallest sufficient claim plus any source-owned d
| `packages/_integration/__tests__/**` | `vp run verify:integration` |
| `packages/test-ds/src/**` | `vp run verify:unit:ts && vp run --fail-if-no-match -F '...@animus-ui/test-ds' verify` |
| `e2e/packed-app/**` or `scripts/verify/packed.sh` | `vp run verify:packed` |
| `scripts/verify/topology.*` | `bunx vp test run scripts/verify/topology.test.ts && vp run verify:lint` |
| `scripts/verify/topology.*` | `vp run verify:lint` |
| `packages/{properties,system,extract,vite-plugin,next-plugin}/package.json` (deps, peers, exports, files) | `vp run verify:packed` |
| `.github/workflows/ci.yaml`, `scripts/**`, `.tool-versions` | `vp run verify:full` |
| Worker orchestration (`vite.config.ts`, `scripts/verify/**`, root deploy scripts, Worker ignores) | `vp run verify:full` |
Expand Down
5 changes: 5 additions & 0 deletions e2e/next-app/scripts/assert-build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
assertKeyframesExtracted,
assertLayerOrder,
assertNoBootstrapScript,
assertNoDevDiagnostics,
assertNoEmotionImports,
assertNoPlaceholders,
assertSystemFallbackParity,
Expand Down Expand Up @@ -184,6 +185,10 @@ async function main(): Promise<void> {
);
}
}

// Production diagnostic elimination — the Next plugin's DefinePlugin entry
// is the fold's input here; see is-dev.ts for the define/fold story.
assertNoDevDiagnostics(js);
}

// Router coverage — same checks as the prior shell script.
Expand Down
5 changes: 5 additions & 0 deletions e2e/vite-app/scripts/assert-build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
assertHeadInjectionContract,
assertKeyframesExtracted,
assertLayerOrder,
assertNoDevDiagnostics,
assertNoEmotionImports,
assertNoPlaceholders,
assertSystemFallbackParity,
Expand Down Expand Up @@ -338,6 +339,10 @@ async function main(): Promise<void> {
);
}
}

// Production diagnostic elimination — see is-dev.ts for the define/fold
// story.
assertNoDevDiagnostics(js);
}

// Root-import transform witness (extraction-dx remediation): App.tsx
Expand Down
5 changes: 5 additions & 0 deletions openspec/specs/builder-chain/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,11 @@ Terminal methods (`.asElement()`, `.asComponent()`, `.build()`) SHALL produce co
- **WHEN** `.extend()` is called on a terminal component
- **THEN** the AnimusExtended instance SHALL carry the same T, enabling scale-resolved autocomplete in extension styles

#### Scenario: Non-terminal builder stages reject extend()

- **WHEN** `.extend()` is called on a builder stage that is not a terminal, such as `ds.styles({ ... }).extend()`
- **THEN** TypeScript SHALL produce a type error — `extend()` is offered only by terminal output, since a stage that was never materialized into a component has no configuration for the extraction pipeline to resolve

#### Scenario: build() produces a raw style function

- **WHEN** `.build()` is called instead of a terminal
Expand Down
47 changes: 43 additions & 4 deletions openspec/specs/compose-css-propagation/spec.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
## Purpose

Requirements for the `compose-css-propagation` capability: Two-rule composed variant emission; Default option propagation for shared variants; Composed rules reuse existing declarations; and 7 more.
Requirements for the `compose-css-propagation` capability: Two-rule composed variant emission; Default option propagation for shared variants; Shared-axis compound expansion; and 8 more.

## Requirements

Expand All @@ -20,8 +20,10 @@ For each shared variant option on each child slot in a composed family, the extr

#### Scenario: Specificity contract within composed sublayer

- **WHEN** both the inheritance rule and override rule are emitted for the same variant option
- **THEN** the inheritance rule SHALL have specificity (0,2,0) and the override rule SHALL have specificity (0,3,0) — a structural invariant of the selector shapes
- **WHEN** both the inheritance rule and override rule are emitted for the same shared VARIANT option in `@layer variants.composed`
- **THEN** the inheritance rule SHALL have specificity (0,2,0) and the override rule SHALL have specificity (0,3,0) — a structural invariant of the two selector shapes this rule pair takes
- **AND** the invariant SHALL be scoped to that pair: shared-axis compound expansion emits into `@layer compounds`, where a selector's class count grows with the predicate's arity, and that growth SHALL NOT affect cross-layer precedence
- **AND** within `@layer compounds` the ordering consequence SHALL be understood: flat compound rules all tie at (0,1,0) and resolve by source order, while ancestor forms rank by class count (axes plus exclusions), so two overlapping compounds on one slot can resolve in a different order composed than they do standalone

#### Scenario: Override beats inheritance by specificity

Expand All @@ -42,6 +44,43 @@ When a shared variant axis declares a default option on the Root, the pipeline S
- **WHEN** the default inheritance rule above is emitted
- **THEN** no `.{child-class}--size-default` selector SHALL appear — the default axis emits exactly one rule, and a defaulted child cannot outrank Root inheritance

### Requirement: Shared-axis compound expansion

Under the CSS-only transport a shared axis reaches a child slot as a CSS selector, not as a prop — the slot's runtime resolves classes from its OWN props only — so a slot compound whose predicate (its `conditions` map) requires a shared axis cannot activate from its flat rule. For every child-slot compound whose predicate references at least one shared axis, the pipeline SHALL additionally emit an ancestor-form rule inside `@layer compounds`, reusing the compound's already-resolved declarations: the shared half of the predicate SHALL be expressed as Root classes on the ancestor side and the child-only half as the slot's own classes on the descendant side. Emission SHALL be unconditional — a `context: true` family also transports the prop, so the slot's flat rule may activate with the same declarations. Each axis SHALL contribute exactly one POSITIVE piece to its owner's side of the selector: the bare class when it accepts a single value, `:is(...)` over the alternatives when it accepts several (equal summed specificity, and linear in the number of accepted values). Every SHARED axis SHALL additionally contribute one `:not(.{child-class}--{axis}-{option})` per option the slot declares on that axis and the predicate does not accept, so a slot that sets the axis directly keeps its own flat compound. Axis order SHALL follow the predicate's stored (name-sorted) order, alternatives within an axis SHALL run value-order then the default-keyed class last, and exclusions SHALL follow the slot's declaration order after the positive pieces. Option names are interpolated verbatim into selectors, as everywhere else in the emitter — a malformed option name invalidates the rule that carries it. The flat `.{child-class}--compound-{N}` rules, their ordinals, their source order, and the runtime compound config lists SHALL be unchanged: the expansion reads compound data and never rewrites it.

#### Scenario: Fully shared predicate

- **WHEN** a family shares `size` and `tone` and a child slot declares `.compound({ size: 'sm', tone: 'loud' }, styles)`
- **THEN** the pipeline SHALL emit `.{root-class}--size-sm.{root-class}--tone-loud .{child-class} { ...styles... }` inside `@layer compounds`

#### Scenario: Mixed shared and child-only predicate

- **WHEN** a family shares `size` and a child slot with its own `weight` variant declares `.compound({ size: 'sm', weight: 'bold' }, styles)`
- **THEN** the pipeline SHALL emit `.{root-class}--size-sm .{child-class}.{child-class}--weight-bold { ...styles... }` — the shared axis on the ancestor, the slot's own axis chained on the slot, where its runtime writes that class
- **AND** when the slot declares a default for that child-only axis equal to the required value, that axis SHALL group both classes — `:is(.{child-class}--weight-bold,.{child-class}--weight-default)` — the mirror of the Root-default rule, since an omitted child prop makes the slot's own runtime write the `-default` class

#### Scenario: Accepted value list groups the axis

- **WHEN** a child slot declares `.compound({ size: ['sm', 'lg'] }, styles)` on a shared `size` axis
- **THEN** the emitted rule SHALL group that axis as `:is(.{root-class}--size-sm,.{root-class}--size-lg) .{child-class}` — one rule, one declaration block, no per-value selector list

#### Scenario: Root default activates a shared-axis compound

- **WHEN** the Root declares `size` defaulting to `sm`, a child slot declares `.compound({ size: 'sm' }, styles)`, and the callsite renders Root WITHOUT a `size` prop
- **THEN** the shared axis SHALL group as `:is(.{root-class}--size-sm,.{root-class}--size-default)`, matching the sidecar class the Root's runtime writes for an omitted prop
- **AND** no `.{child-class}--size-default` selector SHALL be emitted for the shared axis — the suppression invariant carried from the composed default rule keeps a defaulted slot losing to Root inheritance

#### Scenario: Explicit slot override suppresses the ancestor form

- **WHEN** a child slot declares its own variant on a shared axis and a callsite sets that prop directly on the slot
- **THEN** the ancestor form SHALL carry `:not(.{child-class}--{axis}-{option})` for every option the slot declares on that axis and the predicate does not accept, so a slot rendering a non-accepted option keeps its own flat compound instead of the Root's
- **AND** a slot that renders an ACCEPTED option, or that only defaults the axis, SHALL still receive the ancestor form — the accepted options and the `-default` class are never excluded

#### Scenario: Predicate free of shared axes is untouched

- **WHEN** a child slot's compound predicate references only the slot's own props
- **THEN** only the flat `.{child-class}--compound-{N}` rule SHALL be emitted — the slot's runtime already writes the classes that activate it

### Requirement: Composed rules reuse existing declarations

The extraction pipeline SHALL NOT re-resolve styles for composed variant rules. Composed rules SHALL reuse the already-resolved variant declarations from the per-component extraction pass.
Expand Down Expand Up @@ -130,7 +169,7 @@ Portal-mounted child slots (e.g., Radix Dialog content, Tooltip content) render
#### Scenario: Non-portaled slots in context family use both mechanisms

- **WHEN** a composed family has `context: true` and a child slot renders within the Root's DOM subtree
- **THEN** the slot SHALL receive shared variant styling via BOTH CSS descendant selectors AND React context — CSS cascade is primary, context is redundant but harmless
- **THEN** the slot SHALL receive shared variant styling via BOTH CSS descendant selectors AND React context — CSS cascade is primary and carries the whole shared surface for an in-DOM slot: shared variant options through the composed rule pair, and compounds whose predicates reference a shared axis through the ancestor forms in `@layer compounds`. Both activate from the Root's own classes under either transport, so context adds no styling the cascade does not already deliver

#### Scenario: Context-free families remain CSS-only

Expand Down
3 changes: 2 additions & 1 deletion openspec/specs/compose-slot-composition/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,8 @@ The Root slot SHALL be identified by the exact key `"Root"` (PascalCase, case-se
#### Scenario: Lowercase root NOT detected as Root

- **WHEN** `compose({ root: RootComp, child: ChildComp }, { shared: {} })` is called
- **THEN** no slot SHALL be treated as Root — `root` is treated as a regular child slot
- **THEN** `root` SHALL NOT be recognized as the Root slot — the key must match `"Root"` exactly, case-sensitively — and it SHALL NOT be demoted to a regular child slot; the call SHALL be rejected
- **AND** the rejection SHALL be a TypeScript error for typed consumers and a thrown error at family construction for untyped ones, in the source forms (`compose`, `composeWithContext`) and the extracted forms (`createComposedFamily`, `createComposedFamilyWithContext`) alike

#### Scenario: Type-level Root extraction

Expand Down
16 changes: 12 additions & 4 deletions openspec/specs/dev-stylesheet-management/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,13 +40,20 @@ In dev mode, component CSS SHALL be delivered via a Constructable StyleSheet man

### Requirement: HMR bridge auto-injected in dev mode

The plugin SHALL inject the HMR bridge via the `transformIndexHtml` hook. The bridge MUST NOT be injected during production builds.
The plugin SHALL deliver the HMR bridge through two dev-mode paths: a `<script type="module">` tag injected via the `transformIndexHtml` hook, and a bridge import prepended to every component-bearing module's transform output. The module-graph path SHALL be unconditional per transform, so delivery survives any transform-cache invalidation and reaches document-rendering SSR hosts that never invoke `transformIndexHtml`. The bridge module SHALL evaluate as a no-op where no `document` exists. The bridge MUST NOT be injected during production builds by either path.

#### Scenario: Bridge present in dev HTML

- **WHEN** the dev server serves index.html
- **THEN** a `<script type="module">` tag is present that imports the bridge module

#### Scenario: SSR host without index.html

- **WHEN** a document-rendering SSR host (e.g. Remix or React Router) serves a route in dev
- **THEN** every transformed component-bearing module imports the bridge
- **AND** hydrating any such module adopts the component stylesheet in the browser
- **AND** the server-side evaluation of the bridge module is a no-op

#### Scenario: Bridge absent in prod build

- **WHEN** a production build is performed
Expand Down Expand Up @@ -89,10 +96,11 @@ A virtual JS module (`virtual:animus/components.js`) SHALL export the component

### Requirement: Transform emitter unchanged

The Rust `transform_emitter` SHALL continue to emit `import 'virtual:animus/styles.css'` in transformed files. No changes to per-file transform output.
The Rust `transform_emitter` SHALL continue to emit `import 'virtual:animus/styles.css'` in transformed files and SHALL NOT emit any bridge or component-JS import itself; the dev-only bridge import is prepended by the plugin layer on top of the engine's output. Production per-file transform output SHALL be exactly the engine's.

#### Scenario: Transformed file imports unchanged

- **WHEN** a file containing an extractable builder chain is transformed
- **THEN** the transformed output includes `import 'virtual:animus/styles.css'`
- **AND** it does NOT import the bridge or component JS module
- **THEN** the engine's transformed output includes `import 'virtual:animus/styles.css'`
- **AND** the engine's output does NOT import the bridge or component JS module
- **AND** in a production build the served output is the engine's output verbatim
24 changes: 24 additions & 0 deletions packages/_assertions/__tests__/assert-css.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
assertClassNameFormat,
assertKeyframesExtracted,
assertLayerOrder,
assertNoDevDiagnostics,
assertNoEmotionImports,
assertNoPlaceholders,
assertNoUnresolvedTokens,
Expand Down Expand Up @@ -120,6 +121,29 @@ describe('assertNoEmotionImports', () => {
});
});

describe('assertNoDevDiagnostics', () => {
it('passes for a folded production bundle', () => {
expect(() =>
assertNoDevDiagnostics('const a=1;console.log(a);')
).not.toThrow();
});

it('fails when the drop-diagnostic marker survived the fold', () => {
expect(() =>
assertNoDevDiagnostics('console.warn("[animus:drop] Card: value 8");')
).toThrow(AssertionError);
});

it('honors a custom marker', () => {
expect(() =>
assertNoDevDiagnostics(
'const w=__ANIMUS_WITNESS__;',
'__ANIMUS_WITNESS__'
)
).toThrow(AssertionError);
});
});

describe('assertKeyframesExtracted', () => {
const GOOD_CSS = `
@layer anm-global {
Expand Down
22 changes: 22 additions & 0 deletions packages/_assertions/src/assert-css.ts
Original file line number Diff line number Diff line change
Expand Up @@ -327,6 +327,28 @@ export function assertNoEmotionImports(jsContent: string): void {
}
}

/**
* The production-fold witness: no development-only diagnostic string survives
* in a production bundle. The runtime's dev paths are gated on the
* `__ANIMUS_DEV__` define the Animus plugins supply, which a production build
* sets to false so the minifier drops the gated code and its strings — the
* drop warning's prefix is the stable marker. Its reappearance means the fold
* stopped working and every gated diagnostic is shipping to users. See
* packages/system/src/runtime/is-dev.ts for which hosts fold and which do not.
*/
export function assertNoDevDiagnostics(
jsContent: string,
marker = 'animus:drop'
): void {
const offset = jsContent.indexOf(marker);
if (offset !== -1) {
throw new AssertionError(
`assertNoDevDiagnostics: bundle still contains the dev-diagnostic marker '${marker}' at offset ${offset} — the __ANIMUS_DEV__ define did not fold`,
{ marker, offset }
);
}
}

export interface KeyframesAssertionConfig {
minBlocks?: number;
minReferences?: number;
Expand Down
Loading