diff --git a/e2e/next-app/appearance-bootstrap.ts b/e2e/next-app/appearance-bootstrap.ts
new file mode 100644
index 00000000..f833d896
--- /dev/null
+++ b/e2e/next-app/appearance-bootstrap.ts
@@ -0,0 +1,26 @@
+/**
+ * Build/server-side appearance bootstrap artifact (openspec: system-color-scheme, D6).
+ *
+ * The Next plugin deliberately injects NOTHING — Next delivery is
+ * application-owned. This module is the app's config-time seam: it runs in a
+ * Node context (imported only by `pages/_document.tsx`, which Next renders on
+ * the server and never ships to the browser) and hands `_document` the
+ * pre-generated `{ code, cspHash }` pair.
+ *
+ * It must never be imported from a client component. The generator reaches
+ * `node:crypto` and embeds the storage-access snippet as a string; either one
+ * inside a client bundle would violate "Bootstrap entry-point isolation".
+ * `scripts/assert-build.ts` pins the isolation as a build-output fact by
+ * scanning `.next/static` for the storage keys.
+ *
+ * `cspHash` is exported alongside `code` because the two must be derived from
+ * the SAME generation: any theme edit that changes the declared mode names
+ * changes `code` and therefore the hash, and a hand-copied literal in a CSP
+ * header would silently block the script — which is exactly the flash the
+ * bootstrap exists to prevent.
+ */
+import { createAppearanceBootstrap } from '@animus-ui/system/bootstrap';
+
+import { tokens } from './src/ds';
+
+export const appearanceBootstrap = createAppearanceBootstrap(tokens);
diff --git a/e2e/next-app/pages/_document.tsx b/e2e/next-app/pages/_document.tsx
new file mode 100644
index 00000000..89622678
--- /dev/null
+++ b/e2e/next-app/pages/_document.tsx
@@ -0,0 +1,43 @@
+import { Head, Html, Main, NextScript } from 'next/document';
+
+import { appearanceBootstrap } from '../appearance-bootstrap';
+
+/**
+ * Application-owned bootstrap placement (openspec: system-color-scheme, D6).
+ *
+ * The Animus Next plugin injects no script of its own — the app places the
+ * artifact itself, which is what makes a static-hash CSP or a per-request nonce
+ * possible without the plugin knowing anything about appearance.
+ *
+ * Placement: as a child of `
`. Next renders `_document`'s head children
+ * BEFORE `getCssLinks()` (see `next/dist/pages/_document.js` — `children` is
+ * emitted ahead of `!optimizeCss && this.getCssLinks(files)`), so the snippet
+ * runs before the first stylesheet is even requested. `scripts/assert-build.ts`
+ * asserts that ordering on the emitted HTML rather than trusting it.
+ *
+ * `suppressHydrationWarning` on `` is required, not cosmetic: the snippet
+ * mutates `data-color-mode` on the root element between SSR and hydration, so
+ * the client tree legitimately disagrees with the server markup on exactly that
+ * attribute.
+ *
+ * This is the PAGES router document. The App Router (`app/layout.tsx`) is
+ * intentionally left without a bootstrap: it is this build's live negative
+ * witness for "the Next.js plugin SHALL NOT inject the bootstrap script", and
+ * the assert script proves the App Router HTML carries no bootstrap marker.
+ */
+export default function Document() {
+ return (
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/e2e/next-app/scripts/assert-build.ts b/e2e/next-app/scripts/assert-build.ts
index 3a6d0694..88a30e94 100644
--- a/e2e/next-app/scripts/assert-build.ts
+++ b/e2e/next-app/scripts/assert-build.ts
@@ -1,15 +1,22 @@
import {
AssertionError,
assertClassNameFormat,
+ assertColorSchemeEmission,
assertConditionsInsideLayers,
+ assertHeadInjectionContract,
assertKeyframesExtracted,
assertLayerOrder,
+ assertNoBootstrapScript,
assertNoEmotionImports,
assertNoPlaceholders,
+ assertSystemFallbackParity,
+ assertSystemSchemeGuard,
+ findBuildAssets,
findCssFiles,
findJsFiles,
layerBlock,
readAllConcat,
+ systemSchemeVariableSpans,
writeLaneReceipt,
} from '@animus-ui/assertions';
import { readFileSync } from 'node:fs';
@@ -17,6 +24,8 @@ import { readdir, readFile, stat } from 'node:fs/promises';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
+import { appearanceBootstrap } from '../appearance-bootstrap';
+
const APP_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const NEXT_DIR = resolve(APP_ROOT, '.next');
const STATIC_JS = resolve(NEXT_DIR, 'static');
@@ -108,7 +117,14 @@ async function main(): Promise {
// 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);
+ //
+ // Exempt: the theme's variable-level system fallback blocks (openspec:
+ // system-color-scheme), which live in the UNLAYERED variables part beside
+ // `:root`. The exemption is earned per block — see
+ // `systemSchemeVariableSpans`.
+ assertConditionsInsideLayers(css, {
+ exemptSpans: systemSchemeVariableSpans(css),
+ });
// Keyframes extracted through the webpack adapter — the fixture declares
// `animations = keyframes({ fadeIn, pulse })` in src/ds.ts; the assertion
@@ -120,6 +136,28 @@ async function main(): Promise {
minReferences: 2,
});
+ // ── System color scheme (openspec: system-color-scheme, D2/D6) ──────────
+ //
+ // Guardrail G2: every root-targeting rule inside a prefers-color-scheme
+ // block carries the `:root:not([data-color-mode])` guard, and both guarded
+ // blocks actually exist with custom properties (non-vacuous).
+ assertSystemSchemeGuard(css, { expectSchemes: ['light', 'dark'] });
+
+ // Classification reaches `:root` (initial mode `dark`), each explicit mode
+ // block, and each guarded block, so native surfaces follow the active mode
+ // including the OS-driven one.
+ assertColorSchemeEmission(css, {
+ root: 'dark',
+ modes: { dark: 'dark', light: 'light' },
+ system: { light: 'light', dark: 'dark' },
+ });
+
+ // OS path and explicit path are the same rendering: the guarded block's
+ // declarations equal the mapped mode block's, and `:root` precedes both.
+ assertSystemFallbackParity(css, {
+ mapping: { light: 'light', dark: 'dark' },
+ });
+
// Class-name assertion runs on the full build output (JS + HTML emitted by
// Next may include the class names, not just the CSS).
const jsFiles = await findJsFiles(STATIC_JS);
@@ -129,6 +167,23 @@ async function main(): Promise {
for (const jsFile of jsFiles) {
const js = await readFile(jsFile, 'utf8');
assertNoEmotionImports(js);
+
+ // Bootstrap entry-point isolation: `_document.tsx` is server-only, so
+ // neither the generator nor the storage key it embeds may appear in a
+ // CLIENT chunk under .next/static. The snippet reaches the browser as HTML
+ // text and nothing else.
+ for (const identifier of [
+ 'createAppearanceBootstrap',
+ 'animus:appearance',
+ ]) {
+ const offset = js.indexOf(identifier);
+ if (offset !== -1) {
+ throw new AssertionError(
+ `bootstrap entry-point isolation: client chunk ${jsFile} contains '${identifier}' at offset ${offset}`,
+ { jsFile, identifier, offset }
+ );
+ }
+ }
}
// Router coverage — same checks as the prior shell script.
@@ -149,8 +204,47 @@ async function main(): Promise {
);
}
+ // ── No-flash delivery, application-owned (D6) ───────────────────────────
+ //
+ // The Animus Next plugin injects nothing. `pages/_document.tsx` places the
+ // artifact itself, so this lane witnesses BOTH halves of that contract in a
+ // single build:
+ //
+ // • Pages Router — the script is present in and precedes the first
+ // stylesheet reference (Next emits `` before the
+ // stylesheet link, so the preload is the real bar to clear). Comparing the
+ // emitted text to `appearanceBootstrap.code` and re-hashing it proves the
+ // delivery path did not re-encode the snippet: a CSP assembled from
+ // `cspHash` authorizes exactly these bytes.
+ //
+ // • App Router — `app/layout.tsx` deliberately places nothing, so its
+ // prerendered documents must come out with no bootstrap marker at all.
+ // That is the live negative witness for "no automatic injection"; without
+ // it, a plugin that started injecting would still pass every check above.
+ // The composite also gates the charset byte budget: application-placed
+ // injection spends it exactly like plugin injection does.
+ const legacyHtml = await readFile(resolve(pagesDir, 'legacy.html'), 'utf8');
+ assertHeadInjectionContract(legacyHtml, {
+ code: appearanceBootstrap.code,
+ cspHash: appearanceBootstrap.cspHash,
+ });
+
+ const appHtmlFiles = await findBuildAssets({
+ dir: resolve(NEXT_DIR, 'server', 'app'),
+ extensions: ['.html'],
+ });
+ if (appHtmlFiles.length === 0) {
+ throw new AssertionError(
+ 'no App Router HTML found — the no-automatic-injection witness would be vacuous',
+ { dir: resolve(NEXT_DIR, 'server', 'app') }
+ );
+ }
+ for (const htmlFile of appHtmlFiles) {
+ assertNoBootstrapScript(await readFile(htmlFile, 'utf8'));
+ }
+
console.log(
- `[next-app:assert] ${cssFiles.length} CSS file(s), ${jsFiles.length} JS file(s), App+Pages routers present — all assertions passed`
+ `[next-app:assert] ${cssFiles.length} CSS file(s), ${jsFiles.length} JS file(s), App+Pages routers present, bootstrap placed in Pages Router only (${appHtmlFiles.length} App Router document(s) clean) — all assertions passed`
);
emitLaneReceipt();
diff --git a/e2e/next-app/src/ds.ts b/e2e/next-app/src/ds.ts
index 9640ecc6..38a7f448 100644
--- a/e2e/next-app/src/ds.ts
+++ b/e2e/next-app/src/ds.ts
@@ -70,30 +70,47 @@ export const tokens = createTheme()
900: '#78350f',
},
})
- .addColorModes('dark', {
- dark: {
- primary: { _: 'blue.500', hover: 'blue.700' },
- secondary: 'green.500',
- accent: 'amber.500',
- danger: { _: 'red.500', hover: 'red.700' },
- background: 'gray.950',
- surface: { _: 'gray.800', hover: 'gray.700' },
- text: { _: 'gray.100', muted: 'gray.400' },
- border: { _: 'gray.600', strong: 'gray.500' },
- code: { _: 'gray.800', text: 'amber.300' },
+ // System participation (openspec: system-color-scheme, D2). App-LOCAL theme —
+ // nothing else in the workspace consumes it, so opting in here cannot move a
+ // shared parity baseline (the parity harness builds
+ // `packages/extract/tests/test-system.ts`).
+ //
+ // `systemPreference` emits the two guarded
+ // `@media (prefers-color-scheme: …) { :root:not([data-color-mode]) { … } }`
+ // blocks; `browserColorScheme` is total over the declared modes (a missing
+ // entry is a compile error) and sources the `color-scheme` declarations on
+ // `:root`, on each `[data-color-mode]` block, and inside each media block.
+ .addColorModes(
+ 'dark',
+ {
+ dark: {
+ primary: { _: 'blue.500', hover: 'blue.700' },
+ secondary: 'green.500',
+ accent: 'amber.500',
+ danger: { _: 'red.500', hover: 'red.700' },
+ background: 'gray.950',
+ surface: { _: 'gray.800', hover: 'gray.700' },
+ text: { _: 'gray.100', muted: 'gray.400' },
+ border: { _: 'gray.600', strong: 'gray.500' },
+ code: { _: 'gray.800', text: 'amber.300' },
+ },
+ light: {
+ primary: { _: 'blue.700', hover: 'blue.500' },
+ secondary: 'green.700',
+ accent: 'amber.700',
+ danger: { _: 'red.700', hover: 'red.500' },
+ background: 'gray.50',
+ surface: { _: 'gray.200', hover: 'gray.300' },
+ text: { _: 'gray.900', muted: 'gray.500' },
+ border: { _: 'gray.300', strong: 'gray.400' },
+ code: { _: 'gray.100', text: 'blue.700' },
+ },
},
- light: {
- primary: { _: 'blue.700', hover: 'blue.500' },
- secondary: 'green.700',
- accent: 'amber.700',
- danger: { _: 'red.700', hover: 'red.500' },
- background: 'gray.50',
- surface: { _: 'gray.200', hover: 'gray.300' },
- text: { _: 'gray.900', muted: 'gray.500' },
- border: { _: 'gray.300', strong: 'gray.400' },
- code: { _: 'gray.100', text: 'blue.700' },
- },
- })
+ {
+ systemPreference: { light: 'light', dark: 'dark' },
+ browserColorScheme: { light: 'light', dark: 'dark' },
+ }
+ )
.addScale({
name: 'space',
values: {
diff --git a/e2e/vite-app/scripts/assert-build.ts b/e2e/vite-app/scripts/assert-build.ts
index ecd4b482..36e735dd 100644
--- a/e2e/vite-app/scripts/assert-build.ts
+++ b/e2e/vite-app/scripts/assert-build.ts
@@ -1,22 +1,30 @@
import {
AssertionError,
assertClassNameFormat,
+ assertColorSchemeEmission,
assertConditionsInsideLayers,
+ assertHeadInjectionContract,
assertKeyframesExtracted,
assertLayerOrder,
assertNoEmotionImports,
assertNoPlaceholders,
+ assertSystemFallbackParity,
+ assertSystemSchemeGuard,
findCssFiles,
findJsFiles,
layerBlock,
readAllConcat,
+ systemSchemeVariableSpans,
writeLaneReceipt,
} from '@animus-ui/assertions';
+import { createAppearanceBootstrap } from '@animus-ui/system/bootstrap';
import { readFileSync } from 'node:fs';
import { readFile } from 'node:fs/promises';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
+import { tokens } from '../src/ds';
+
const APP_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const DIST = resolve(APP_ROOT, 'dist');
@@ -170,7 +178,17 @@ async function main(): Promise {
// 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);
+ //
+ // The one exemption is the theme's variable-level system fallback blocks
+ // (openspec: system-color-scheme): they belong to the UNLAYERED variables
+ // part, beside `:root` and the `[data-color-mode]` blocks, so that an
+ // explicit mode can override the OS fallback at the same cascade level.
+ // `systemSchemeVariableSpans` grants the exemption only to blocks whose every
+ // rule is the root guard — a component condition at-rule outside a layer
+ // still trips this gate.
+ assertConditionsInsideLayers(css, {
+ exemptSpans: systemSchemeVariableSpans(css),
+ });
// Container-unit emission pin (inc 11, spec "Container-relative units on
// scale-typed properties"): the test-ds Card authors `gap: '2cqi'` on a
@@ -197,6 +215,50 @@ async function main(): Promise {
);
}
+ // ── System color scheme (openspec: system-color-scheme, D2/D6) ──────────
+ //
+ // This lane is the VITE delivery witness: the theme opts in via
+ // `systemPreference` + `browserColorScheme` (src/ds.ts) and the plugin
+ // injects the bootstrap via the `appearanceBootstrap` option (vite.config.ts).
+ //
+ // Guardrail G2. Non-vacuous in BOTH directions here: `expectSchemes` demands
+ // the two guarded theme blocks exist and assign custom properties, while the
+ // app Card's unregistered `_osDark` condition puts an UNGUARDED
+ // `@media (prefers-color-scheme: dark) { .animus-Card-… { … } }` block in the
+ // same sheet — which must not trip the gate. Only ROOT-targeting rules owe
+ // the guard.
+ assertSystemSchemeGuard(css, { expectSchemes: ['light', 'dark'] });
+
+ // Classification reaches every surface a native control can read: `:root`
+ // (initial mode `dark`), each explicit mode block, and each guarded block.
+ assertColorSchemeEmission(css, {
+ root: 'dark',
+ modes: { dark: 'dark', light: 'light' },
+ system: { light: 'light', dark: 'dark' },
+ });
+
+ // The OS path and the explicit path are the SAME rendering, not two copies
+ // kept in sync by hand — declaration lists compare byte-for-byte through
+ // Lightning CSS (which injects its `--lightningcss-*` pair into both blocks
+ // alike). Also pins `:root` ahead of both fallbacks.
+ assertSystemFallbackParity(css, {
+ mapping: { light: 'light', dark: 'dark' },
+ });
+
+ // No-flash delivery. Regenerating the artifact from the SAME built theme and
+ // byte-comparing it against the shipped script proves three things at once:
+ // the plugin embedded the code verbatim, generation is deterministic
+ // (identical inputs → identical bytes), and a CSP assembled from `cspHash`
+ // would authorize exactly this script. Ordering is the actual no-flash
+ // contract — the script must precede the plugin's own `@layer` style tag AND
+ // the stylesheet link.
+ const artifact = createAppearanceBootstrap(tokens);
+ const indexHtml = await readFile(resolve(DIST, 'index.html'), 'utf8');
+ assertHeadInjectionContract(indexHtml, {
+ code: artifact.code,
+ cspHash: artifact.cspHash,
+ });
+
// 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
@@ -211,6 +273,23 @@ async function main(): Promise {
for (const jsFile of jsFiles) {
const js = await readFile(jsFile, 'utf8');
assertNoEmotionImports(js);
+
+ // Bootstrap entry-point isolation: the generator lives behind the
+ // `@animus-ui/system/bootstrap` subpath and is reached ONLY from
+ // vite.config.ts. Neither it nor its storage keys may reach the client
+ // bundle — the snippet ships as HTML text, never as application code.
+ for (const identifier of [
+ 'createAppearanceBootstrap',
+ 'animus:appearance',
+ ]) {
+ const offset = js.indexOf(identifier);
+ if (offset !== -1) {
+ throw new AssertionError(
+ `bootstrap entry-point isolation: client bundle ${jsFile} contains '${identifier}' at offset ${offset}`,
+ { jsFile, identifier, offset }
+ );
+ }
+ }
}
console.log(
diff --git a/e2e/vite-app/src/ds.ts b/e2e/vite-app/src/ds.ts
index 12f9ce79..22de5811 100644
--- a/e2e/vite-app/src/ds.ts
+++ b/e2e/vite-app/src/ds.ts
@@ -18,26 +18,48 @@ export const tokens = createTheme()
red: { 500: '#ef4444', 700: '#b91c1c' },
green: { 500: '#22c55e' },
})
- .addColorModes('dark', {
- dark: {
- primary: { _: 'blue.500', hover: 'blue.700' },
- secondary: 'green.500',
- danger: 'red.500',
- background: 'gray.900',
- surface: 'gray.700',
- text: { _: 'gray.100', muted: 'gray.500' },
- border: 'gray.700',
+ // System participation (openspec: system-color-scheme, D2). App-LOCAL theme,
+ // shared with nothing — the parity harness builds
+ // `packages/extract/tests/test-system.ts`, not this module.
+ //
+ // This lane is the VITE delivery witness: the guarded media blocks emitted
+ // here travel the same build as an UNGUARDED author-written `_osDark`
+ // condition block (src/components/Card.tsx), which is exactly what keeps the
+ // G2 guard assertion honest — it must accept the author block and still
+ // require the guard on every root-targeting rule.
+ .addColorModes(
+ 'dark',
+ {
+ dark: {
+ primary: { _: 'blue.500', hover: 'blue.700' },
+ secondary: 'green.500',
+ danger: 'red.500',
+ background: 'gray.900',
+ surface: 'gray.700',
+ text: { _: 'gray.100', muted: 'gray.500' },
+ border: 'gray.700',
+ },
+ light: {
+ primary: { _: 'blue.700', hover: 'blue.500' },
+ secondary: 'green.500',
+ danger: 'red.700',
+ background: 'gray.100',
+ surface: 'gray.100',
+ text: { _: 'gray.900', muted: 'gray.500' },
+ border: 'gray.100',
+ },
},
- light: {
- primary: { _: 'blue.700', hover: 'blue.500' },
- secondary: 'green.500',
- danger: 'red.700',
- background: 'gray.100',
- surface: 'gray.100',
- text: { _: 'gray.900', muted: 'gray.500' },
- border: 'gray.100',
- },
- })
+ {
+ systemPreference: { light: 'light', dark: 'dark' },
+ // Empty on purpose — this lane is the end-to-end witness for the D3
+ // amendment: both modes are mapping-named, so their classifications
+ // default to light/dark and the emission must be identical to spelling
+ // them out (the assert lane pins color-scheme on :root, both mode
+ // blocks, and both guarded blocks). next-app keeps explicit entries, so
+ // both spellings stay covered.
+ browserColorScheme: {},
+ }
+ )
.addScale({
name: 'space',
values: {
diff --git a/e2e/vite-app/vite.config.ts b/e2e/vite-app/vite.config.ts
index b7eff716..862b0778 100644
--- a/e2e/vite-app/vite.config.ts
+++ b/e2e/vite-app/vite.config.ts
@@ -1,13 +1,29 @@
+import { createAppearanceBootstrap } from '@animus-ui/system/bootstrap';
import { animusExtract } from '@animus-ui/vite-plugin';
import { cloudflare } from '@cloudflare/vite-plugin';
import react from '@vitejs/plugin-react';
import { defineConfig } from 'vite';
+import { tokens } from './src/ds';
+
+// Config-time only (openspec: system-color-scheme, D6 — the Vite path is
+// plugin-injected opt-in). The generator reads the built theme's declared mode
+// names and returns `{ code, cspHash }`; the plugin embeds `code` as an inline
+// ``;
+
+describe('assertBootstrapScriptFirst', () => {
+ it('accepts a script placed ahead of a preload/stylesheet pair', () => {
+ expect(() => assertBootstrapScriptFirst(NEXT_SHAPED)).not.toThrow();
+ });
+
+ it('compares the emitted text to the artifact code and its CSP hash', () => {
+ // sha256 of CODE, base64 — recomputed the way a browser would.
+ const cspHash = `sha256-${createHash('sha256').update(CODE, 'utf8').digest('base64')}`;
+ expect(() =>
+ assertBootstrapScriptFirst(NEXT_SHAPED, { code: CODE, cspHash })
+ ).not.toThrow();
+ expect(() =>
+ assertBootstrapScriptFirst(NEXT_SHAPED, { cspHash: 'sha256-stale' })
+ ).toThrow(AssertionError);
+ });
+
+ it('rejects a script that trails a stylesheet reference', () => {
+ const trailing = ``;
+ expect(() => assertBootstrapScriptFirst(trailing)).toThrow(AssertionError);
+ });
+
+ it('rejects a script that trails an inline