diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml
index 2940fd18..2efd6d78 100644
--- a/.github/workflows/ci.yaml
+++ b/.github/workflows/ci.yaml
@@ -27,9 +27,9 @@ jobs:
lint:
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v7
- - uses: actions/setup-node@v4
+ - uses: actions/setup-node@v7
with:
node-version-file: .tool-versions
@@ -54,7 +54,7 @@ jobs:
test-rust:
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@stable
@@ -77,7 +77,7 @@ jobs:
clippy:
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@1.97.0
with:
@@ -102,7 +102,7 @@ jobs:
hygiene-rust:
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@stable
@@ -142,7 +142,7 @@ jobs:
runs-on: ${{ matrix.runner }}
continue-on-error: ${{ matrix.optional || false }}
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@stable
@@ -165,7 +165,7 @@ jobs:
bunx @napi-rs/cli build --platform --release --target ${{ matrix.target }}
- name: Upload v2 binary
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@v7
with:
name: napi-v2-${{ matrix.target }}
path: packages/extract/crates/extract-v2/*.node
@@ -176,9 +176,9 @@ jobs:
needs: [build-extract, test-rust]
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v7
- - uses: actions/setup-node@v4
+ - uses: actions/setup-node@v7
with:
node-version-file: .tool-versions
@@ -189,7 +189,7 @@ jobs:
- name: Download v2 linux binary
# @animus-ui/showcase#verify:build runs on engine v2 (default since the
# 2026-07-13 flip) — the v2 binary is a hard precondition.
- uses: actions/download-artifact@v4
+ uses: actions/download-artifact@v8
with:
name: napi-v2-x86_64-unknown-linux-gnu
path: packages/extract/crates/extract-v2/
@@ -233,19 +233,20 @@ jobs:
bunx vp run @animus-ui/showcase#verify:assert
- name: Upload lane receipts
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@v7
with:
name: receipts-showcase
path: packages/showcase/.receipts/
+ if-no-files-found: error
# ─── Next consumer lane (build + assert on every push) ──
verify-next:
needs: [build-extract]
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v7
- - uses: actions/setup-node@v4
+ - uses: actions/setup-node@v7
with:
node-version-file: .tool-versions
@@ -254,7 +255,7 @@ jobs:
bun-version-file: .tool-versions
- name: Download v2 linux binary
- uses: actions/download-artifact@v4
+ uses: actions/download-artifact@v8
with:
name: napi-v2-x86_64-unknown-linux-gnu
path: packages/extract/crates/extract-v2/
@@ -270,19 +271,20 @@ jobs:
bunx vp run @animus-ui/next-app#verify:assert
- name: Upload lane receipts
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@v7
with:
name: receipts-next
path: e2e/next-app/.receipts/
+ if-no-files-found: error
# ─── Vite consumer lane (build + assert on every push) ──
verify-vite:
needs: [build-extract]
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v7
- - uses: actions/setup-node@v4
+ - uses: actions/setup-node@v7
with:
node-version-file: .tool-versions
@@ -291,7 +293,7 @@ jobs:
bun-version-file: .tool-versions
- name: Download v2 linux binary
- uses: actions/download-artifact@v4
+ uses: actions/download-artifact@v8
with:
name: napi-v2-x86_64-unknown-linux-gnu
path: packages/extract/crates/extract-v2/
@@ -306,8 +308,14 @@ jobs:
bunx vp run @animus-ui/vite-app#verify:build
bunx vp run @animus-ui/vite-app#verify:assert
+ - name: List lane receipts (diagnosing empty artifact)
+ # The receipt writes locally and next-app's analog uploads fine;
+ # this ls decides where the CI-side write goes missing. Flip the
+ # upload below to if-no-files-found: error once diagnosed.
+ run: ls -la e2e/vite-app/.receipts/ || echo "receipts dir absent"
+
- name: Upload lane receipts
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@v7
with:
name: receipts-vite
path: e2e/vite-app/.receipts/
@@ -317,9 +325,9 @@ jobs:
needs: [build-extract]
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v7
- - uses: actions/setup-node@v4
+ - uses: actions/setup-node@v7
with:
node-version-file: .tool-versions
@@ -328,7 +336,7 @@ jobs:
bun-version-file: .tool-versions
- name: Download v2 linux binary
- uses: actions/download-artifact@v4
+ uses: actions/download-artifact@v8
with:
name: napi-v2-x86_64-unknown-linux-gnu
path: packages/extract/crates/extract-v2/
@@ -349,9 +357,9 @@ jobs:
needs: [build-extract]
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v7
- - uses: actions/setup-node@v4
+ - uses: actions/setup-node@v7
with:
node-version-file: .tool-versions
@@ -360,7 +368,7 @@ jobs:
bun-version-file: .tool-versions
- name: Download v2 linux binary
- uses: actions/download-artifact@v4
+ uses: actions/download-artifact@v8
with:
name: napi-v2-x86_64-unknown-linux-gnu
path: packages/extract/crates/extract-v2/
@@ -374,10 +382,11 @@ jobs:
run: bunx vp run verify:packed
- name: Upload lane receipts
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@v7
with:
name: receipts-packed
path: e2e/packed-app/.staging/receipts/
+ if-no-files-found: error
# ─── Worker deployment (main push, schedule, or explicit dispatch) ──
deploy-workers:
@@ -404,9 +413,9 @@ jobs:
cancel-in-progress: false
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v7
- - uses: actions/setup-node@v4
+ - uses: actions/setup-node@v7
with:
node-version-file: .tool-versions
@@ -415,7 +424,7 @@ jobs:
bun-version-file: .tool-versions
- name: Download v2 linux binary
- uses: actions/download-artifact@v4
+ uses: actions/download-artifact@v8
with:
name: napi-v2-x86_64-unknown-linux-gnu
path: packages/extract/crates/extract-v2/
@@ -449,27 +458,40 @@ jobs:
(github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')) ||
(github.event_name == 'workflow_dispatch' && inputs.publish_packages == true)
runs-on: ubuntu-latest
- env:
- NODE_AUTH_TOKEN: ${{ secrets.NODE_AUTH_TOKEN }}
+ # Trusted Publishing (OIDC): npm mints short-lived credentials from the
+ # workflow's identity token — no NODE_AUTH_TOKEN secret, no expiring
+ # bypass-2FA tokens (which lose direct publish ~Jan 2027). Each package
+ # must have this workflow registered as its trusted publisher on
+ # npmjs.com (owner-side, per package: org codecaaron, repo animus,
+ # workflow filename ci.yaml).
+ permissions:
+ contents: read
+ id-token: write
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v7
- uses: oven-sh/setup-bun@v2
with:
bun-version-file: .tool-versions
- - uses: actions/setup-node@v4
+ - uses: actions/setup-node@v7
with:
node-version-file: .tool-versions
registry-url: 'https://registry.npmjs.org'
scope: 'animus-ui'
- env:
- NODE_AUTH_TOKEN: ${{ secrets.NODE_AUTH_TOKEN }}
- run: bun install
- - name: Verify npm auth
- run: npm whoami
+ - name: Verify npm toolchain supports trusted publishing
+ # npm >= 11.5.1 is the OIDC floor; whoami is meaningless pre-publish
+ # under OIDC (credentials are minted at publish time).
+ run: |
+ node --version
+ npm --version
+ if [ "$(printf '%s\n' 11.5.1 "$(npm --version)" | sort -V | head -n1)" != "11.5.1" ]; then
+ echo "ERROR: npm $(npm --version) < 11.5.1 — trusted publishing unsupported" >&2
+ exit 1
+ fi
# ── Parse version from tag ──
- name: Set version from tag
@@ -513,19 +535,19 @@ jobs:
# A target missing its v2 binary fails the release job —
# never publish a partial matrix (engine-release-packaging).
- name: Download darwin-arm64 v2 binary
- uses: actions/download-artifact@v4
+ uses: actions/download-artifact@v8
with:
name: napi-v2-aarch64-apple-darwin
path: packages/extract/crates/extract-v2/
- name: Download linux-x64 v2 binary
- uses: actions/download-artifact@v4
+ uses: actions/download-artifact@v8
with:
name: napi-v2-x86_64-unknown-linux-gnu
path: packages/extract/crates/extract-v2/
- name: Download linux-arm64 v2 binary
- uses: actions/download-artifact@v4
+ uses: actions/download-artifact@v8
with:
name: napi-v2-aarch64-unknown-linux-gnu
path: packages/extract/crates/extract-v2/
@@ -542,9 +564,12 @@ jobs:
exit 1
fi
for pkg in properties system extract vite-plugin next-plugin; do
- # ./ prefix is load-bearing: a bare packages/$pkg is parsed by npm
- # as a github:owner/repo shorthand, not a local folder.
- npm pack "./packages/$pkg" --pack-destination "$RELEASE_BUNDLE" --ignore-scripts
+ # bun pm pack — the SAME packer verify:packed proves on every
+ # push (packer parity: npm pack shipped two release-only bugs in
+ # v0.1.2 because the lane packed differently). bun also resolves
+ # workspace: specifiers in every dependency block at pack time;
+ # the jq rewrite above stays as the version bump + belt.
+ (cd "packages/$pkg" && bun pm pack --destination "$RELEASE_BUNDLE")
done
for artifact in \
"animus-ui-extract-${VERSION}.tgz" \
diff --git a/packages/_parity/baseline-intents.md b/packages/_parity/baseline-intents.md
index f1b92a82..86c7c2df 100644
--- a/packages/_parity/baseline-intents.md
+++ b/packages/_parity/baseline-intents.md
@@ -66,3 +66,13 @@ committed production/development pair. Ordinary parity runs never write it.
concordance + semantic differential, registering the expected
`duplicate-binding` drift when it does. Every pre-existing unit stays
byte-identical in the same run.
+- [x] `ani-closeout-fixture-batch-20260803` — refresh once after adding the
+ two audit-gap corpus fixtures for the ledger closeout change
+ (openspec: ani-ledger-closeout, increment 03):
+ `inline-asserted-targets.tsx` (ANI-015 — an `as const` tag and an
+ `as`-typed component target extract exactly like their bare forms
+ after the chain_walk assertion-unwrap fix) and
+ `color-family-pass-through.tsx` (ANI-009 — `backgroundColor`/`color`
+ longhands resolve semantic tokens at top level and in responsive
+ slots; a `borderTopColor` literal passes through). New units only —
+ every pre-existing unit stays byte-identical in the same run.
diff --git a/packages/_parity/baselines/v2/development.json b/packages/_parity/baselines/v2/development.json
index 5ae11cd7..3c786118 100644
--- a/packages/_parity/baselines/v2/development.json
+++ b/packages/_parity/baselines/v2/development.json
@@ -1,8 +1,8 @@
{
- "corpusSha256": "dcc22235874fcc95d2bc65d0dc00ce11b61100c0a80e46fe14c34a9b9ac7116b",
+ "corpusSha256": "017faab6c9e37c032c38dbdec1a86e3df3bdf1726fdbc53fc287f20246da817a",
"engine": "v2",
"mode": "development",
- "refreshIntent": "ani-fix-witness-fixtures-20260803",
+ "refreshIntent": "ani-closeout-fixture-batch-20260803",
"surfaceSchemaSha256": "43eb1265e97aab8497c11ca05e8a4159a6d4f4b69a6837a704e1d96fa75eb108",
"units": {
"extract-all": {
@@ -748,6 +748,27 @@
},
"parseCount": 1
},
+ "parity/color-family-pass-through.tsx": {
+ "code": {
+ "color-family-pass-through.tsx": "import { createComponent } from '@animus-ui/system';\nimport 'virtual:animus/styles.css';\n// ANI-009 witness: raw CSS color-property names resolve semantic tokens via\n// COLOR_FAMILY_PASS_THROUGH — the DS registers `bg`, not `backgroundColor`,\n// yet the longhand must reach the colors scale at top level and in\n// responsive slots, while non-token values pass through literally.\nexport const PassThrough = createComponent('section', 'animus-PassThrough-a05c8778', {});\n\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-PassThrough-a05c8778 {\n border-top-color: rgb(1 2 3);\n color: var(--color-primary);\n background-color: var(--color-primary);\n }\n @media (min-width: 768px) {\n .animus-PassThrough-a05c8778 {\n color: var(--color-secondary);\n }\n }\n}\n\n@layer anm-variants {\n @layer standalone, composed;\n @layer composed {\n }\n}\n\n",
+ "diagnostics": [],
+ "hasComponents": {
+ "color-family-pass-through.tsx": true
+ },
+ "observables": {
+ "componentFragmentKeys": [
+ "color-family-pass-through.tsx::PassThrough"
+ ],
+ "componentFragmentsJson": "{\"color-family-pass-through.tsx::PassThrough\":{\"base\":\" .animus-PassThrough-a05c8778 {\\n border-top-color: rgb(1 2 3);\\n color: var(--color-primary);\\n background-color: var(--color-primary);\\n }\\n @media (min-width: 768px) {\\n .animus-PassThrough-a05c8778 {\\n color: var(--color-secondary);\\n }\\n }\\n\"}}",
+ "dynamicPropsJson": "{}",
+ "reverseProvenanceEdges": [],
+ "sheetsJson": "{\"base\":\"@layer anm-base {\\n .animus-PassThrough-a05c8778 {\\n border-top-color: rgb(1 2 3);\\n color: var(--color-primary);\\n background-color: var(--color-primary);\\n }\\n @media (min-width: 768px) {\\n .animus-PassThrough-a05c8778 {\\n color: var(--color-secondary);\\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/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"
@@ -1222,6 +1243,28 @@
},
"parseCount": 2
},
+ "parity/inline-asserted-targets.tsx": {
+ "code": {
+ "inline-asserted-targets.tsx": "import { createComponent } from '@animus-ui/system';\nimport 'virtual:animus/styles.css';\n// ANI-015 witness: inline type assertions on terminal targets extract\n// exactly like their bare forms — the walker unwraps as/satisfies/non-null\n// wrappers and the emitter compiles the unwrapped identifier or tag, never\n// a placeholder (`createComponent(unknown, …)` was a browser ReferenceError\n// before the chain_walk fix).\nconst Plain = (props: { className?: string }) => ;\n\nexport const AssertedBox = createComponent('div', 'animus-AssertedBox-4eb5588e', {});\n\nexport const AssertedLink = createComponent(Plain, 'animus-AssertedLink-4cf7ebf5', {});\n\nexport const App = () => (\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 .animus-AssertedBox-4eb5588e {\n padding: 0.5rem;\n display: flex;\n }\n .animus-AssertedLink-4cf7ebf5 {\n font-weight: 600;\n }\n}\n\n@layer anm-variants {\n @layer standalone, composed;\n @layer composed {\n }\n}\n\n",
+ "diagnostics": [],
+ "hasComponents": {
+ "inline-asserted-targets.tsx": true
+ },
+ "observables": {
+ "componentFragmentKeys": [
+ "inline-asserted-targets.tsx::AssertedBox",
+ "inline-asserted-targets.tsx::AssertedLink"
+ ],
+ "componentFragmentsJson": "{\"inline-asserted-targets.tsx::AssertedBox\":{\"base\":\" .animus-AssertedBox-4eb5588e {\\n padding: 0.5rem;\\n display: flex;\\n }\\n\"},\"inline-asserted-targets.tsx::AssertedLink\":{\"base\":\" .animus-AssertedLink-4cf7ebf5 {\\n font-weight: 600;\\n }\\n\"}}",
+ "dynamicPropsJson": "{}",
+ "reverseProvenanceEdges": [],
+ "sheetsJson": "{\"base\":\"@layer anm-base {\\n .animus-AssertedBox-4eb5588e {\\n padding: 0.5rem;\\n display: flex;\\n }\\n .animus-AssertedLink-4cf7ebf5 {\\n font-weight: 600;\\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/keyframes-import": {
"code": {
"anim.tsx": "import { createComponent } from '@animus-ui/system';\nimport 'virtual:animus/styles.css';\nimport { motion } from './system';\n\nexport const Pulse = createComponent('div', 'animus-Pulse-cbd4b392', {});\nexport const App = () => ;\n\n",
diff --git a/packages/_parity/baselines/v2/production.json b/packages/_parity/baselines/v2/production.json
index 6499b8e9..215c21fe 100644
--- a/packages/_parity/baselines/v2/production.json
+++ b/packages/_parity/baselines/v2/production.json
@@ -1,8 +1,8 @@
{
- "corpusSha256": "dcc22235874fcc95d2bc65d0dc00ce11b61100c0a80e46fe14c34a9b9ac7116b",
+ "corpusSha256": "017faab6c9e37c032c38dbdec1a86e3df3bdf1726fdbc53fc287f20246da817a",
"engine": "v2",
"mode": "production",
- "refreshIntent": "ani-fix-witness-fixtures-20260803",
+ "refreshIntent": "ani-closeout-fixture-batch-20260803",
"surfaceSchemaSha256": "43eb1265e97aab8497c11ca05e8a4159a6d4f4b69a6837a704e1d96fa75eb108",
"units": {
"extract-all": {
@@ -710,6 +710,27 @@
},
"parseCount": 1
},
+ "parity/color-family-pass-through.tsx": {
+ "code": {
+ "color-family-pass-through.tsx": "import { createComponent } from '@animus-ui/system';\nimport 'virtual:animus/styles.css';\n// ANI-009 witness: raw CSS color-property names resolve semantic tokens via\n// COLOR_FAMILY_PASS_THROUGH — the DS registers `bg`, not `backgroundColor`,\n// yet the longhand must reach the colors scale at top level and in\n// responsive slots, while non-token values pass through literally.\nexport const PassThrough = createComponent('section', 'animus-PassThrough-a05c8778', {});\n\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-PassThrough-a05c8778 {\n border-top-color: rgb(1 2 3);\n color: var(--color-primary);\n background-color: var(--color-primary);\n }\n @media (min-width: 768px) {\n .animus-PassThrough-a05c8778 {\n color: var(--color-secondary);\n }\n }\n}\n\n@layer anm-variants {\n @layer standalone, composed;\n @layer composed {\n }\n}\n\n",
+ "diagnostics": [],
+ "hasComponents": {
+ "color-family-pass-through.tsx": true
+ },
+ "observables": {
+ "componentFragmentKeys": [
+ "color-family-pass-through.tsx::PassThrough"
+ ],
+ "componentFragmentsJson": "{\"color-family-pass-through.tsx::PassThrough\":{\"base\":\" .animus-PassThrough-a05c8778 {\\n border-top-color: rgb(1 2 3);\\n color: var(--color-primary);\\n background-color: var(--color-primary);\\n }\\n @media (min-width: 768px) {\\n .animus-PassThrough-a05c8778 {\\n color: var(--color-secondary);\\n }\\n }\\n\"}}",
+ "dynamicPropsJson": "{}",
+ "reverseProvenanceEdges": [],
+ "sheetsJson": "{\"base\":\"@layer anm-base {\\n .animus-PassThrough-a05c8778 {\\n border-top-color: rgb(1 2 3);\\n color: var(--color-primary);\\n background-color: var(--color-primary);\\n }\\n @media (min-width: 768px) {\\n .animus-PassThrough-a05c8778 {\\n color: var(--color-secondary);\\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/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"
@@ -1184,6 +1205,28 @@
},
"parseCount": 2
},
+ "parity/inline-asserted-targets.tsx": {
+ "code": {
+ "inline-asserted-targets.tsx": "import { createComponent } from '@animus-ui/system';\nimport 'virtual:animus/styles.css';\n// ANI-015 witness: inline type assertions on terminal targets extract\n// exactly like their bare forms — the walker unwraps as/satisfies/non-null\n// wrappers and the emitter compiles the unwrapped identifier or tag, never\n// a placeholder (`createComponent(unknown, …)` was a browser ReferenceError\n// before the chain_walk fix).\nconst Plain = (props: { className?: string }) => ;\n\nexport const AssertedBox = createComponent('div', 'animus-AssertedBox-4eb5588e', {});\n\nexport const AssertedLink = createComponent(Plain, 'animus-AssertedLink-4cf7ebf5', {});\n\nexport const App = () => (\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 .animus-AssertedBox-4eb5588e {\n padding: 0.5rem;\n display: flex;\n }\n .animus-AssertedLink-4cf7ebf5 {\n font-weight: 600;\n }\n}\n\n@layer anm-variants {\n @layer standalone, composed;\n @layer composed {\n }\n}\n\n",
+ "diagnostics": [],
+ "hasComponents": {
+ "inline-asserted-targets.tsx": true
+ },
+ "observables": {
+ "componentFragmentKeys": [
+ "inline-asserted-targets.tsx::AssertedBox",
+ "inline-asserted-targets.tsx::AssertedLink"
+ ],
+ "componentFragmentsJson": "{\"inline-asserted-targets.tsx::AssertedBox\":{\"base\":\" .animus-AssertedBox-4eb5588e {\\n padding: 0.5rem;\\n display: flex;\\n }\\n\"},\"inline-asserted-targets.tsx::AssertedLink\":{\"base\":\" .animus-AssertedLink-4cf7ebf5 {\\n font-weight: 600;\\n }\\n\"}}",
+ "dynamicPropsJson": "{}",
+ "reverseProvenanceEdges": [],
+ "sheetsJson": "{\"base\":\"@layer anm-base {\\n .animus-AssertedBox-4eb5588e {\\n padding: 0.5rem;\\n display: flex;\\n }\\n .animus-AssertedLink-4cf7ebf5 {\\n font-weight: 600;\\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/keyframes-import": {
"code": {
"anim.tsx": "import { createComponent } from '@animus-ui/system';\nimport 'virtual:animus/styles.css';\nimport { motion } from './system';\n\nexport const Pulse = createComponent('div', 'animus-Pulse-cbd4b392', {});\nexport const App = () => ;\n\n",
diff --git a/packages/_parity/corpus/color-family-pass-through.tsx b/packages/_parity/corpus/color-family-pass-through.tsx
new file mode 100644
index 00000000..61387b71
--- /dev/null
+++ b/packages/_parity/corpus/color-family-pass-through.tsx
@@ -0,0 +1,13 @@
+// ANI-009 witness: raw CSS color-property names resolve semantic tokens via
+// COLOR_FAMILY_PASS_THROUGH — the DS registers `bg`, not `backgroundColor`,
+// yet the longhand must reach the colors scale at top level and in
+// responsive slots, while non-token values pass through literally.
+export const PassThrough = ds
+ .styles({
+ backgroundColor: 'primary',
+ borderTopColor: 'rgb(1 2 3)',
+ color: { _: 'primary', sm: 'secondary' },
+ })
+ .asElement('section');
+
+export const App = () => ;
diff --git a/packages/_parity/corpus/inline-asserted-targets.tsx b/packages/_parity/corpus/inline-asserted-targets.tsx
new file mode 100644
index 00000000..b27fec89
--- /dev/null
+++ b/packages/_parity/corpus/inline-asserted-targets.tsx
@@ -0,0 +1,20 @@
+// ANI-015 witness: inline type assertions on terminal targets extract
+// exactly like their bare forms — the walker unwraps as/satisfies/non-null
+// wrappers and the emitter compiles the unwrapped identifier or tag, never
+// a placeholder (`createComponent(unknown, …)` was a browser ReferenceError
+// before the chain_walk fix).
+const Plain = (props: { className?: string }) => ;
+
+export const AssertedBox = ds
+ .styles({ display: 'flex', p: 8 })
+ .asElement('div' as const);
+
+export const AssertedLink = ds
+ .styles({ fontWeight: 600 })
+ .asComponent(Plain as typeof Plain);
+
+export const App = () => (
+
+
+
+);
diff --git a/packages/_parity/scoreboard.snap b/packages/_parity/scoreboard.snap
index a7106015..daf4b594 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: 62/62 (100.00%)
+Units passed: 64/64 (100.00%)
Divergences: 0 (0 unregistered)
Usage-case families:
@@ -23,7 +23,7 @@ Usage-case families:
parity baseline — engines: baseline:v2 vs v2 — devMode: true
-Units passed: 62/62 (100.00%)
+Units passed: 64/64 (100.00%)
Divergences: 0 (0 unregistered)
Usage-case families:
diff --git a/packages/_parity/self-check.snap b/packages/_parity/self-check.snap
index d4aa68c4..50fec24b 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: 62/62 (100.00%)
+Units passed: 64/64 (100.00%)
Divergences: 0 (0 unregistered)
Usage-case families:
@@ -23,7 +23,7 @@ Usage-case families:
parity self-check — engines: v2 vs v2 — devMode: true
-Units passed: 62/62 (100.00%)
+Units passed: 64/64 (100.00%)
Divergences: 0 (0 unregistered)
Usage-case families:
diff --git a/packages/extract/crates/extract-v2/src/chain_walk.rs b/packages/extract/crates/extract-v2/src/chain_walk.rs
index 4e30f773..fc4bfd23 100644
--- a/packages/extract/crates/extract-v2/src/chain_walk.rs
+++ b/packages/extract/crates/extract-v2/src/chain_walk.rs
@@ -99,11 +99,18 @@ fn try_walk_chain(call: &CallExpression<'_>, binding: String) -> Option return None,
};
- let tag = extract_terminal_arg(call, &terminal).unwrap_or_default();
-
let mut stages = Vec::new();
let mut extractable = true;
let mut bail_reason: Option = None;
+
+ let tag = match extract_terminal_arg(call, &terminal) {
+ TerminalArg::Resolved(tag) => tag,
+ TerminalArg::Unresolvable(reason) => {
+ extractable = false;
+ bail_reason = Some(format!("{}: {}", method_name, reason));
+ String::new()
+ }
+ };
let mut has_extend_marker = false;
let chain_end = call.span;
@@ -205,21 +212,57 @@ fn match_static_member<'a, 'b>(expr: &'a Expression<'b>) -> Option<(&'a Expressi
}
}
-fn extract_terminal_arg(call: &CallExpression<'_>, terminal: &TerminalKind) -> Option {
+/// What the terminal argument resolved to: a static name the emitter may
+/// compile into the replacement, or a bail. Emitting a placeholder for an
+/// unresolvable target is never an option — `createComponent(unknown, …)`
+/// is a ReferenceError in the browser (ANI-015).
+enum TerminalArg {
+ Resolved(String),
+ Unresolvable(String),
+}
+
+/// Peel TS type-assertion wrappers and parentheses from a terminal argument:
+/// `asComponent(Link as ComponentType)` names the same runtime value as
+/// `asComponent(Link)`, and `asElement('div' as const)` the same tag as
+/// `asElement('div')`.
+fn unwrap_type_assertions<'a, 'b>(expr: &'a Expression<'b>) -> &'a Expression<'b> {
+ match expr {
+ Expression::TSAsExpression(x) => unwrap_type_assertions(&x.expression),
+ Expression::TSSatisfiesExpression(x) => unwrap_type_assertions(&x.expression),
+ Expression::TSNonNullExpression(x) => unwrap_type_assertions(&x.expression),
+ Expression::ParenthesizedExpression(x) => unwrap_type_assertions(&x.expression),
+ _ => expr,
+ }
+}
+
+fn extract_terminal_arg(call: &CallExpression<'_>, terminal: &TerminalKind) -> TerminalArg {
match terminal {
- TerminalKind::AsClass => Some(String::new()),
- _ => {
- let first_arg = call.arguments.first()?;
- match terminal {
- TerminalKind::AsElement => match first_arg {
- Argument::StringLiteral(lit) => Some(lit.value.to_string()),
- _ => None,
- },
- TerminalKind::AsComponent => match first_arg {
- Argument::Identifier(id) => Some(id.name.to_string()),
- _ => Some("unknown".to_string()),
- },
- TerminalKind::AsClass => unreachable!(),
+ TerminalKind::AsClass => TerminalArg::Resolved(String::new()),
+ TerminalKind::AsElement => {
+ // v1 parity: a missing or non-literal tag keeps the empty tag.
+ match call
+ .arguments
+ .first()
+ .and_then(|arg| arg.as_expression())
+ .map(unwrap_type_assertions)
+ {
+ Some(Expression::StringLiteral(lit)) => {
+ TerminalArg::Resolved(lit.value.to_string())
+ }
+ _ => TerminalArg::Resolved(String::new()),
+ }
+ }
+ TerminalKind::AsComponent => {
+ match call
+ .arguments
+ .first()
+ .and_then(|arg| arg.as_expression())
+ .map(unwrap_type_assertions)
+ {
+ Some(Expression::Identifier(id)) => TerminalArg::Resolved(id.name.to_string()),
+ _ => TerminalArg::Unresolvable(
+ "target has no static identifier name".to_string(),
+ ),
}
}
}
@@ -354,6 +397,74 @@ mod tests {
assert_eq!(chains[0].extends_from, None);
}
+ // ── ANI-015: inline-asserted terminal targets ─────────────────────────────
+
+ #[test]
+ fn extracts_as_component_with_inline_as_assertion() {
+ let chains = parse_chains(
+ r#"
+ import { animus } from '@animus-ui/core';
+ const FlowLink = animus
+ .styles({ fontWeight: 400 })
+ .asComponent(Link as React.ComponentType);
+ "#,
+ );
+ assert_eq!(chains.len(), 1);
+ assert!(chains[0].extractable);
+ assert_eq!(chains[0].tag, "Link");
+ }
+
+ #[test]
+ fn extracts_as_component_with_satisfies_assertion() {
+ let chains = parse_chains(
+ r#"
+ import { animus } from '@animus-ui/core';
+ const FlowLink = animus
+ .styles({ fontWeight: 400 })
+ .asComponent(Link satisfies LinkLike);
+ "#,
+ );
+ assert_eq!(chains.len(), 1);
+ assert!(chains[0].extractable);
+ assert_eq!(chains[0].tag, "Link");
+ }
+
+ #[test]
+ fn extracts_as_element_with_const_assertion() {
+ let chains = parse_chains(
+ r#"
+ import { animus } from '@animus-ui/core';
+ const Box = animus.styles({ display: 'flex' }).asElement('div' as const);
+ "#,
+ );
+ assert_eq!(chains.len(), 1);
+ assert!(chains[0].extractable);
+ assert_eq!(chains[0].tag, "div");
+ }
+
+ #[test]
+ fn bails_on_unresolvable_as_component_target() {
+ // A computed target has no static name to emit; the chain must bail
+ // to the runtime path, never emit a placeholder identifier
+ // (`createComponent(unknown, …)` is a ReferenceError in the browser).
+ let chains = parse_chains(
+ r#"
+ import { animus } from '@animus-ui/core';
+ const FlowLink = animus
+ .styles({ fontWeight: 400 })
+ .asComponent(withRouter(Link));
+ "#,
+ );
+ assert_eq!(chains.len(), 1);
+ assert!(!chains[0].extractable);
+ assert!(chains[0]
+ .bail_reason
+ .as_deref()
+ .unwrap_or_default()
+ .contains("asComponent"));
+ assert_ne!(chains[0].tag, "unknown");
+ }
+
#[test]
fn finds_multiple_chains() {
let chains = parse_chains(
diff --git a/packages/extract/crates/extract-v2/src/theme.rs b/packages/extract/crates/extract-v2/src/theme.rs
index 1386aa09..99b4a1dc 100644
--- a/packages/extract/crates/extract-v2/src/theme.rs
+++ b/packages/extract/crates/extract-v2/src/theme.rs
@@ -1394,7 +1394,25 @@ pub fn resolve_all_global_blocks(
let mut parts: Vec = Vec::new();
for (_name, block) in block_map {
- let css = resolve_global_block(block, ctx);
+ // Wrapped loader form: { styles, fontFaces } — typed font faces
+ // render AHEAD of the block's selector rules (global-styles-system).
+ // A legacy bare selector map passes through unchanged.
+ let (styles, faces) = match block.as_object() {
+ Some(obj)
+ if obj.get("styles").map(|s| s.is_object()).unwrap_or(false)
+ && obj.keys().all(|k| k == "styles" || k == "fontFaces") =>
+ {
+ (obj.get("styles").unwrap(), obj.get("fontFaces"))
+ }
+ _ => (block, None),
+ };
+ if let Some(faces) = faces {
+ let css = render_font_faces(faces, ctx);
+ if !css.is_empty() {
+ parts.push(css);
+ }
+ }
+ let css = resolve_global_block(styles, ctx);
if !css.is_empty() {
parts.push(css);
}
@@ -1403,6 +1421,76 @@ pub fn resolve_all_global_blocks(
parts.join("\n\n")
}
+/// Render a wrapped block's typed `@font-face` descriptors
+/// (global-styles-system). `src` urls are emitted byte-exact as authored —
+/// asset resolution belongs to the host bundler's CSS pipeline. `family`
+/// resolves through the token vocabulary; every other descriptor is a CSS
+/// literal. Descriptors missing `family` or a non-empty `src` are skipped.
+fn render_font_faces(faces: &Value, ctx: &ResolveContext) -> String {
+ let list = match faces.as_array() {
+ Some(l) => l,
+ None => return String::new(),
+ };
+ let mut blocks: Vec = Vec::new();
+ for face in list {
+ let obj = match face.as_object() {
+ Some(o) => o,
+ None => continue,
+ };
+ let family = match obj.get("family").and_then(|v| v.as_str()) {
+ Some(f) => f,
+ None => continue,
+ };
+ let srcs = match obj.get("src").and_then(|v| v.as_array()) {
+ Some(s) if !s.is_empty() => s,
+ _ => continue,
+ };
+ let family = resolve_token_aliases(
+ family,
+ ctx.theme,
+ ctx.variable_map,
+ ctx.contextual_vars,
+ );
+ let mut src_parts: Vec = Vec::new();
+ for entry in srcs {
+ let entry = match entry.as_object() {
+ Some(e) => e,
+ None => continue,
+ };
+ let url = match entry.get("url").and_then(|v| v.as_str()) {
+ Some(u) => u,
+ None => continue,
+ };
+ match entry.get("format").and_then(|v| v.as_str()) {
+ Some(fmt) => {
+ src_parts.push(format!("url('{url}') format('{fmt}')"))
+ }
+ None => src_parts.push(format!("url('{url}')")),
+ }
+ }
+ if src_parts.is_empty() {
+ continue;
+ }
+ let mut decls = vec![
+ format!("font-family: {family};"),
+ format!("src: {};", src_parts.join(", ")),
+ ];
+ for (key, css_name) in [
+ ("style", "font-style"),
+ ("weight", "font-weight"),
+ ("stretch", "font-stretch"),
+ ("display", "font-display"),
+ ("unicodeRange", "unicode-range"),
+ ] {
+ if let Some(v) = obj.get(key).and_then(|v| v.as_str()) {
+ decls.push(format!("{css_name}: {v};"));
+ }
+ }
+ blocks.push(format!("@font-face {{ {} }}", decls.join(" ")));
+ }
+ blocks.join("\n")
+}
+
/// Resolve a single keyframes block (from the top-level `keyframes()` primitive)
/// into `@keyframes { ... }` CSS. The block shape is `{ name, frames }`
/// where `frames` is `{ "0%" → { prop → value }, ... }`. Each frame's styles
@@ -2186,6 +2274,82 @@ mod tests {
assert_eq!(resolved.declarations[0].value, "rgb(1 2 3)");
}
+ // ── ani-ledger-closeout: typed @font-face resources ──────────────────
+
+ #[test]
+ fn font_faces_render_ahead_of_selector_rules_in_wrapped_blocks() {
+ let owner = TestCtxOwner::new();
+ let blocks = json!({
+ "globals": {
+ "styles": { "body": { "color": "red" } },
+ "fontFaces": [{
+ "family": "Inter",
+ "src": [{ "url": "/fonts/inter.woff2", "format": "woff2" }],
+ "weight": "100 900",
+ "display": "swap"
+ }]
+ }
+ });
+ let css = resolve_all_global_blocks(&blocks, &owner.ctx());
+ let face = css.find("@font-face").expect("font-face rendered");
+ let rule = css.find("body").expect("selector rule rendered");
+ assert!(face < rule, "font-face must precede selector rules:\n{css}");
+ assert!(css.contains(
+ "@font-face { font-family: Inter; src: url('/fonts/inter.woff2') format('woff2'); font-weight: 100 900; font-display: swap; }"
+ ), "unexpected font-face rendering:\n{css}");
+ }
+
+ #[test]
+ fn font_face_urls_pass_through_byte_exact() {
+ let owner = TestCtxOwner::new();
+ let blocks = json!({
+ "globals": {
+ "styles": {},
+ "fontFaces": [{
+ "family": "Inter",
+ "src": [{ "url": "./assets/inter.woff2" }]
+ }]
+ }
+ });
+ let css = resolve_all_global_blocks(&blocks, &owner.ctx());
+ assert!(css.contains("src: url('./assets/inter.woff2');"));
+ }
+
+ #[test]
+ fn font_face_family_resolves_font_scale_token() {
+ let mut owner = TestCtxOwner::new();
+ owner
+ .theme
+ .insert("fonts.body".to_string(), "Inter, sans-serif".to_string());
+ let blocks = json!({
+ "globals": {
+ "styles": {},
+ "fontFaces": [{
+ "family": "{fonts.body}",
+ "src": [{ "url": "/fonts/inter.woff2" }]
+ }]
+ }
+ });
+ let css = resolve_all_global_blocks(&blocks, &owner.ctx());
+ assert!(
+ css.contains("font-family: Inter, sans-serif;"),
+ "family token unresolved:\n{css}"
+ );
+ }
+
+ #[test]
+ fn legacy_bare_selector_map_blocks_resolve_unchanged() {
+ let owner = TestCtxOwner::new();
+ let wrapped = json!({
+ "globals": { "styles": { "body": { "color": "red" } }, "fontFaces": [] }
+ });
+ let legacy = json!({ "globals": { "body": { "color": "red" } } });
+ let wrapped_css = resolve_all_global_blocks(&wrapped, &owner.ctx());
+ let legacy_css = resolve_all_global_blocks(&legacy, &owner.ctx());
+ assert_eq!(wrapped_css, legacy_css);
+ assert!(!legacy_css.contains("@font-face"));
+ }
+
#[test]
fn dotted_literal_on_non_color_prop_stays_untouched() {
// `fontFamily` is pass-through but outside the color family — a dotted
diff --git a/packages/extract/crates/system-loader/src/lib.rs b/packages/extract/crates/system-loader/src/lib.rs
index 96149d39..e3dc2bcb 100644
--- a/packages/extract/crates/system-loader/src/lib.rs
+++ b/packages/extract/crates/system-loader/src/lib.rs
@@ -1287,8 +1287,12 @@ fn extract_global_style_blocks(namespace: &Object<'_>) -> Option {
if let Ok(obj) = namespace.get::<_, Object>(key.as_str()) {
if let Ok(brand) = obj.get::<_, String>("__brand") {
if brand == "GlobalStyleBlock" {
- // Use eval to call JSON.stringify on the styles property
- let script = format!("JSON.stringify(globalThis.__ns_ref[\"{}\"].styles)", key);
+ // Wrapped form: selector map plus the block's typed
+ // font-face descriptors (global-styles-system). The
+ // extractor renders fontFaces ahead of selector rules.
+ let script = format!(
+ "JSON.stringify({{styles: globalThis.__ns_ref[\"{key}\"].styles, fontFaces: globalThis.__ns_ref[\"{key}\"].fontFaces || []}})"
+ );
// Temporarily assign namespace to globalThis for access
let _ = ctx.globals().set("__ns_ref", namespace.clone());
if let Ok(json_str) = ctx.eval::(script.as_bytes()) {
diff --git a/packages/extract/pipeline/discover-packages.ts b/packages/extract/pipeline/discover-packages.ts
index 82301e36..f46fc57e 100644
--- a/packages/extract/pipeline/discover-packages.ts
+++ b/packages/extract/pipeline/discover-packages.ts
@@ -273,6 +273,22 @@ export async function collectExternalPackageSources(opts: {
*
* Falls back to empty array if no `includes` declaration is found.
*/
+/**
+ * The message for the strict/warn gate over unresolvable includes, or null
+ * when every declared specifier resolved (external-package-file-discovery:
+ * silence is never an outcome — non-strict consumers warn with this line,
+ * strict consumers throw it).
+ */
+export function unresolvableIncludesMessage(
+ outcomes: ExternalPackageOutcome[]
+): string | null {
+ const unresolvable = outcomes
+ .filter((record) => record.outcome === 'unresolvable')
+ .map((record) => record.specifier);
+ if (unresolvable.length === 0) return null;
+ return `[animus-extract] unresolvable include specifier(s): ${unresolvable.join(', ')}`;
+}
+
export function extractSystemFilePackages(systemFilePath: string): string[] {
let source: string;
try {
diff --git a/packages/extract/pipeline/index.ts b/packages/extract/pipeline/index.ts
index 083d4774..fcc08ad5 100644
--- a/packages/extract/pipeline/index.ts
+++ b/packages/extract/pipeline/index.ts
@@ -35,6 +35,7 @@ export {
collectExternalPackageSources,
extractSystemFilePackages,
findPackageRoot,
+ unresolvableIncludesMessage,
} from './discover-packages';
export { buildPathAliasesJson } from './path-aliases';
export type { LightningTargets } from './post-process-css';
diff --git a/packages/extract/tests/collect-external-packages.test.ts b/packages/extract/tests/collect-external-packages.test.ts
index 23e7dc29..b8ae0dc3 100644
--- a/packages/extract/tests/collect-external-packages.test.ts
+++ b/packages/extract/tests/collect-external-packages.test.ts
@@ -3,7 +3,10 @@ import { tmpdir } from 'os';
import { join, relative } from 'path';
import { afterEach, describe, expect, test } from 'vitest';
-import { collectExternalPackageSources } from '../pipeline/discover-packages';
+import {
+ collectExternalPackageSources,
+ unresolvableIncludesMessage,
+} from '../pipeline/discover-packages';
const tempRoots: string[] = [];
@@ -240,6 +243,24 @@ describe('collectExternalPackageSources', () => {
]);
});
+ test('unresolvableIncludesMessage names every unresolvable specifier, null when all resolve', () => {
+ expect(
+ unresolvableIncludesMessage([
+ { specifier: '@x/missing', outcome: 'unresolvable', fileCount: 0 },
+ { specifier: '@x/ds', outcome: 'resolved', fileCount: 2 },
+ { specifier: '@x/typo', outcome: 'unresolvable', fileCount: 0 },
+ ])
+ ).toBe(
+ '[animus-extract] unresolvable include specifier(s): @x/missing, @x/typo'
+ );
+ expect(
+ unresolvableIncludesMessage([
+ { specifier: '@x/ds', outcome: 'resolved', fileCount: 2 },
+ { specifier: '@x/empty', outcome: 'empty', fileCount: 0 },
+ ])
+ ).toBeNull();
+ });
+
test('records an empty outcome when a resolved package contributes no sources', async () => {
const root = makeRoot();
const pkg = makePackage(join(root, 'packages', 'ds'), {
diff --git a/packages/next-plugin/src/extraction-session.ts b/packages/next-plugin/src/extraction-session.ts
index 4ca1277d..ed668fc8 100644
--- a/packages/next-plugin/src/extraction-session.ts
+++ b/packages/next-plugin/src/extraction-session.ts
@@ -14,6 +14,7 @@ import {
runProjectAnalysis,
serializeStaticCss,
toWatchKeys,
+ unresolvableIncludesMessage,
} from '@animus-ui/extract/pipeline';
import {
existsSync,
@@ -487,6 +488,16 @@ export class ExtractionSession {
);
}
}
+ // external-package-file-discovery: silence is never an outcome — an
+ // unresolvable include warns in non-strict mode and fails the build
+ // under strict, naming every offending specifier (vite-plugin parity).
+ const unresolvableMessage = unresolvableIncludesMessage(collected.outcomes);
+ if (unresolvableMessage !== null) {
+ if (this.options.strict) {
+ throw new Error(unresolvableMessage);
+ }
+ this.warn(unresolvableMessage);
+ }
const packageMap = collected.packageMap;
this.lastPackageMap = packageMap;
diff --git a/packages/system/__tests__/global-styles-font-faces.test.ts b/packages/system/__tests__/global-styles-font-faces.test.ts
new file mode 100644
index 00000000..d9e582fe
--- /dev/null
+++ b/packages/system/__tests__/global-styles-font-faces.test.ts
@@ -0,0 +1,57 @@
+import { describe, expect, it } from 'vitest';
+
+import { createGlobalStyles } from './test-system';
+
+/**
+ * ani-ledger-closeout: typed @font-face resources (global-styles-system).
+ * The factory carries descriptors on the block; the loader serializes them
+ * and the extractor renders them ahead of selector rules — those halves are
+ * pinned in Rust (theme.rs font_face tests). Here: the authoring surface.
+ */
+describe('createGlobalStyles fontFaces', () => {
+ it('carries typed descriptors on the block', () => {
+ const block = createGlobalStyles(
+ { body: { m: 0 } },
+ {
+ fontFaces: [
+ {
+ family: 'Inter',
+ src: [{ url: '/fonts/inter.woff2', format: 'woff2' }],
+ weight: '100 900',
+ display: 'swap',
+ },
+ ],
+ }
+ );
+
+ expect(block.__brand).toBe('GlobalStyleBlock');
+ expect(block.fontFaces).toEqual([
+ {
+ family: 'Inter',
+ src: [{ url: '/fonts/inter.woff2', format: 'woff2' }],
+ weight: '100 900',
+ display: 'swap',
+ },
+ ]);
+ });
+
+ it('omits the field entirely without descriptors (byte-identical legacy blocks)', () => {
+ expect('fontFaces' in createGlobalStyles({ body: { m: 0 } })).toBe(false);
+ expect(
+ 'fontFaces' in createGlobalStyles({ body: { m: 0 } }, { fontFaces: [] })
+ ).toBe(false);
+ });
+
+ it('copies the descriptor array so later caller mutation cannot leak in', () => {
+ const authored = [
+ { family: 'Inter', src: [{ url: '/fonts/inter.woff2' }] },
+ ];
+ const block = createGlobalStyles(
+ { body: { m: 0 } },
+ { fontFaces: authored }
+ );
+ authored.push({ family: 'Mono', src: [{ url: '/fonts/mono.woff2' }] });
+
+ expect(block.fontFaces).toHaveLength(1);
+ });
+});
diff --git a/packages/system/__tests__/types.test-d.tsx b/packages/system/__tests__/types.test-d.tsx
index 9468d14b..cf3963bd 100644
--- a/packages/system/__tests__/types.test-d.tsx
+++ b/packages/system/__tests__/types.test-d.tsx
@@ -1553,6 +1553,32 @@ void createGlobalStyles({
body: { p: 16 },
});
+// Positive: typed font-face descriptors ride the optional second argument
+void createGlobalStyles(
+ { body: { p: 16 } },
+ {
+ fontFaces: [
+ {
+ family: 'Inter',
+ src: [{ url: '/fonts/inter.woff2', format: 'woff2' }],
+ weight: '100 900',
+ display: 'swap',
+ },
+ ],
+ }
+);
+
+// Negative: a font-face descriptor rejects unknown keys
+void createGlobalStyles(
+ { body: { p: 16 } },
+ {
+ fontFaces: [
+ // @ts-expect-error — 'variant' is not a FontFace descriptor
+ { family: 'Inter', src: [{ url: '/f.woff2' }], variant: 'small-caps' },
+ ],
+ }
+);
+
// Negative: unknown scale key rejected in global style body
// @ts-expect-error — 'nonexistent' is not a key of the colors scale
void createGlobalStyles({ body: { bg: 'nonexistent' } });
diff --git a/packages/system/src/SystemBuilder.ts b/packages/system/src/SystemBuilder.ts
index 21d47ed6..d67137ce 100644
--- a/packages/system/src/SystemBuilder.ts
+++ b/packages/system/src/SystemBuilder.ts
@@ -32,16 +32,47 @@ interface SerializedPropEntry {
export type GlobalStyleMap = Record>;
+/** One `src` descriptor of a font-face resource. */
+export interface FontFaceSrc {
+ /**
+ * Emitted byte-exact as authored — asset resolution and rewriting belong
+ * to the host bundler's CSS asset pipeline, not to extraction.
+ */
+ url: string;
+ /** Format hint (`woff2`, `woff`, …), rendered as `format('…')`. */
+ format?: string;
+}
+
+/**
+ * A typed `@font-face` descriptor (global-styles-system). `family` may use a
+ * font-scale token reference (`{fonts.body}`); other descriptors take CSS
+ * literals only.
+ */
+export interface FontFace {
+ family: string;
+ src: FontFaceSrc[];
+ weight?: string;
+ style?: string;
+ display?: string;
+ unicodeRange?: string;
+ stretch?: string;
+}
+
export interface GlobalStyleBlock {
__brand: 'GlobalStyleBlock';
styles: GlobalStyleMap;
+ /** Rendered ahead of the block's selector rules in `@layer anm-global`. */
+ fontFaces?: FontFace[];
}
export type GlobalStylesFactory<
PropReg extends Record = Record,
-> =