Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
26 changes: 26 additions & 0 deletions e2e/next-app/appearance-bootstrap.ts
Original file line number Diff line number Diff line change
@@ -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);
43 changes: 43 additions & 0 deletions e2e/next-app/pages/_document.tsx
Original file line number Diff line number Diff line change
@@ -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 `<Head>`. 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 `<Html>` 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 (
<Html lang="en" suppressHydrationWarning>
<Head>
<script
data-animus-bootstrap=""
dangerouslySetInnerHTML={{ __html: appearanceBootstrap.code }}
/>
</Head>
<body>
<Main />
<NextScript />
</body>
</Html>
);
}
98 changes: 96 additions & 2 deletions e2e/next-app/scripts/assert-build.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,31 @@
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';
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');
Expand Down Expand Up @@ -108,7 +117,14 @@ async function main(): Promise<void> {
// 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
Expand All @@ -120,6 +136,28 @@ async function main(): Promise<void> {
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);
Expand All @@ -129,6 +167,23 @@ async function main(): Promise<void> {
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.
Expand All @@ -149,8 +204,47 @@ async function main(): Promise<void> {
);
}

// ── 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 <head> and precedes the first
// stylesheet reference (Next emits `<link as="style">` 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();
Expand Down
63 changes: 40 additions & 23 deletions e2e/next-app/src/ds.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
Loading
Loading