mirror of
https://github.com/nexu-io/open-design.git
synced 2026-05-31 19:04:39 +07:00
* feat(design-systems): add structured tokens.css schema (default + kami) Compile each brand's DESIGN.md prose into a machine-readable :root block agents paste verbatim, removing the "Primary → --accent" translation step where most token misuse happens. Daemon prompt injection lands in a follow-up; lint-artifact already enforces the shared token vocabulary so no rule changes needed. Schema validated across two contrasting aesthetics: - default (sans-serif, cobalt, B2B utility) — stress test the shallow form, 2-level fg / 2-level surface - kami (serif, parchment, ink-blue, print-first) — stress test the rich form, 4-level fg ramp, 3-level surface, ring elevation, i18n font stacks, and solid-hex tag tints (print renderers double-paint alpha) Schema growth from kami's stress test (5 new optional slots, all backward-compatible — default aliases via var() to existing tokens): - --fg-2 / --meta (4-level fg ramp) - --surface-warm (3-level surface) - --border-soft (2-level border) - --elev-ring (ring elevation as first-class level) Brand-specific extensions live in tokens.css with explicit "NOT in shared schema" labels and a documented promotion path (≥2 brands need it → promote to schema slot). components.html in each brand is a self-contained reference fixture that exercises every token through real layouts. Both fixtures lint clean against apps/daemon/src/lint-artifact.ts. Co-authored-by: Cursor <cursoragent@cursor.com> * feat(design-systems): add token-fixture drift guard Each design system in design-systems/<brand>/ ships two files agents consume in tandem: tokens.css (canonical token bindings) and components.html (a self-contained fixture whose first <style> embeds the same :root paste so the file renders standalone). The fixture's :root block is a copy of tokens.css's :root block, kept in sync only by an inline comment. This adds scripts/check-tokens-fixture-sync.ts and registers it in pnpm guard. The check pairs each brand's tokens.css with its components.html and asserts the unscoped :root block is byte-equivalent after canonical normalization (CSS comments stripped, whitespace collapsed, separator spacing normalized). Brands missing one half of the pair, or with no :root rule in either file, fail the guard. Scoped overrides like :root[lang="zh-CN"] are not required to appear in the fixture (per the kami fixture's inline comment they are pasted only when an artifact's <html lang> matches), so the check only compares the unscoped :root block. Verified: pnpm guard passes for default + kami, fails on intentional value drift, fails on missing token, tolerates whitespace-only formatting differences. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(design-systems): point fixture CTAs to real files Both default and kami components.html advertised in-page anchors (#tokens, #spec, #surface, #accent, #type, #components) but defined no matching ids, so every CTA was a no-op when the fixture was opened locally — flagged by mrcfps in #1231. Re-point each link to a real artifact in the same brand directory: - "View tokens" / "Inspect tokens" / "Inspect typography" → ./tokens.css - "Read the spec" / "Read the rule" → ./DESIGN.md Browsers render these as raw source views, which is the desired UX for a reference fixture: clicking the CTA shows the underlying contract instead of jumping to nothing. Agents copying the fixture also learn the pattern of "buttons link to actual sibling resources". The :root token block is unchanged, so the token-fixture drift guard still passes for both brands. Co-authored-by: Cursor <cursoragent@cursor.com> * feat(design-systems): codify token schema (A1/A2/B/C layers) The two-brand pilot (default + kami) settled the shape of the shared token schema; this commit codifies it as a machine-readable contract and enforces it in pnpm guard, addressing lefarcen's review on #1231: > the optional-vs-required split won't generalize cleanly when brand > #3 needs different Layer A tokens or when multiple brands converge > on the same extension (promoting C→B→A). Consider surfacing that > limitation in the PR narrative or in a future SCHEMA.md. Schema lives under design-systems/_schema/ as three files: - tokens.schema.ts — TypeScript declaration of every shared token with its layer (A1-identity / A1-structure / A2 / B-slot), plus per-brand C-extension allowlists and a global C-prefix allowlist - defaults.css — CSS mirror of A2 fallback values, used as the human-readable contract reviewer's-eye copy and the future input to the derive script - AGENTS.md — schema layer model, C → B-slot → A2 promotion rules, when-not-to-add-a-token guidance Layer model: A1-identity 8 tokens — bg/surface/fg/muted/border/accent + font-display/font-body. The brand IS these values; no fallback is defensible. A1-structure 18 tokens — type scale (8), leading (2), tracking (1), section-y (3), container (4). Structural decisions vary per brand by design and have no cross-brand default. A2 26 tokens — accent states, semantic colors, motion, base spacing scale, radius, elevation, focus, font-mono. Required in every tokens.css; fallback lives in defaults.css for the future derive script to inline when DESIGN.md does not specify the value. B-slot 4 tokens — fg-2 / meta / surface-warm / border-soft. Brand may bind independently or alias the named sibling via var(...) for components that target the richer ramp. C-extension n tokens — brand-specific names (kami's tag-bg-*, leading-display, accent-light, etc.). Allowlisted per-brand in BRAND_EXTENSIONS or globally by prefix in BRAND_EXTENSION_PREFIXES. Promote when a second brand adopts the same name. Why A2 fails the guard today: Artifacts are generated by agents pasting one brand's :root block into a single <style>; there is no global stylesheet that supplies fallbacks at runtime. A tokens.css missing an A2 declaration would silently break any var() reference in the fixture. Until the derive script (PR-B) lands and inlines defaults, every brand's tokens.css must declare every A2 token directly. The guard enforces this strictly. Why --font-mono lands in A2 (not A1): 149 brands' DESIGN.md files were surveyed: 87 (58%) declare a monospace stack, 62 (42%) do not — including major brands like bmw / nike / apple / notion / mastercard / meta. Agent paste cannot rely on the brand author having written it down; a defaultable A2 fallback (with CJK brands like kami overriding) is safer than forcing every brand author to add a field they may not realize their kbd / code-block components need. Five guard checks, each registered as its own entry in scripts/guard.ts so failures attribute to a specific contract: 1. token-fixture sync — components.html :root ↔ tokens.css :root byte-equivalent (existing) 2. A1 required tokens — every brand declares every A1 token 3. A2 required tokens — every brand declares every A2 token 4. unknown token allowlist — every declared token is in schema or brand-extension allowlist 5. A2 defaults parity — defaults.css ↔ tokens.schema.ts fallback byte-equivalent Verified on default + kami: - 26 A1 tokens declared in both brands - 26 A2 tokens declared in both brands - 129 total declarations, all match shared schema or brand extensions - defaults.css ↔ tokens.schema.ts parity holds - sanity test: drifting --motion-fast in defaults.css fails check 5 with a clear divergence message The PR description originally listed "Dedicated SCHEMA.md" as explicitly NOT in this PR ("Once 3+ brands ship, extracting a single source of truth becomes worthwhile"). That boundary moves: lefarcen's review surfaced the schema-generalization risk, and the schema must exist as a machine-enforced contract before the derive script can read it. The TS file replaces the markdown that was deferred. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web/tests): pass missing designTemplates prop to ProjectView Pre-existing typecheck regression on main: PR #955 (b5eb8c16, "generic skills + split skills/design-templates + finalize-design API") added required `designTemplates: SkillSummary[]` to ProjectView Props but updated only two of the three test fixtures that render ProjectView directly. The third — ProjectView.api-empty-response.test.tsx — was missed, so `pnpm typecheck` (and CI on any PR merging into main) fails on: apps/web/tests/components/ProjectView.api-empty-response.test.tsx (168,6): error TS2741: Property 'designTemplates' is missing in type ... The other two ProjectView tests already pass `designTemplates={[]}`, so this aligns this fixture with the existing pattern. Out of scope for #1231 strictly, but the regression blocks the merged-state typecheck CI runs that #1231 triggers, and the one-line fix here restores main's typecheck health for everyone. Co-authored-by: Cursor <cursoragent@cursor.com> * feat(design-systems): enforce B-slot required tokens in pnpm guard Closes mrcfps + lefarcen review comment thread on #1231: > The guard validates A2 required tokens here, but there's no > sibling check for B-slot aliases (--fg-2, --meta, --surface-warm, > --border-soft). Per the schema docs, every brand must declare > A1 + A2 + B-slot names so shared components can safely read > var(--fg-2) etc. Without a B-slot guard, a brand can omit those > aliases, pass pnpm guard, and break any artifact that references > them. Same artifact-paste constraint as A2: agents render artifacts by pasting one brand's :root block into a single <style>; there is no runtime cascade, so a missing B-slot makes any var(--fg-2) reference resolve to nothing. Until now the schema narrative claimed B-slots were optional with a var() default, but no machine check enforced declaration — a contract gap reviewers reasonably refused to merge. This commit closes the gap in three places so machine and narrative agree: 1. scripts/check-tokens-fixture-sync.ts - Add checkDesignSystemBSlotRequiredTokens, mirroring the A2 check but using getBSlotNames() from the schema. - Failure message names each missing slot AND the schema-suggested alias (--fg-2 (default alias: var(--fg))) so a brand author fixing the failure has a copy-pasteable resolution. - Renumber section comments: 5 checks → 6 checks. 2. scripts/guard.ts - Register the new check between A2 required and unknown allowlist so failures attribute to a specific contract. 3. design-systems/_schema/AGENTS.md - Update the layer table: B-slot row's "If omitted" column changes from "resolves via var() to a richer sibling" to "guard fails — brand must declare, either as var(--sibling) (collapsed) or independent value (richer)". - Add a "Why B-slot is required (and what the alias is for)" section that distinguishes the schema-suggested alias from a runtime fallback, with worked examples for default (alias) and kami (independent bind). Verified on default + kami: - pnpm guard passes all 6 design-system checks - 4 B-slot tokens declared in both brands (default aliases via var(), kami binds independently — both forms satisfy the contract) - pnpm typecheck clean across the workspace - Sanity test: removing --fg-2 + --meta from default/tokens.css fires the new guard with a precise per-token alias hint: [default] design-systems/default/tokens.css is missing 2 B-slot tokens (alias the named sibling via var(...) or bind independently): --fg-2 (default alias: var(--fg)), --meta (default alias: var(--muted)) The schema contract is now machine-enforced end-to-end (A1 + A2 + B-slot all required-with-fixed-form-of-fallback). The derive script in PR-B can rely on every brand's tokens.css containing every shared slot name. Co-authored-by: Cursor <cursoragent@cursor.com> * test(e2e): skip leading-underscore meta-directories under design-systems/ CI for #1231 went red on `Validate workspace` after merging origin/main. Cause is a clean collision between two recently-landed changes: - main #1270 (be77dc03"Default English resource i18n fallback") tightened tests/localized-content.test.ts so every directory under design-systems/ is run through assertResourceId() with the strict RESOURCE_ID_PATTERN /^[a-z0-9][a-z0-9-]*$/. - this branch #1231 introduced design-systems/_schema/ as the home of the shared token contract (tokens.schema.ts, defaults.css, AGENTS.md). The leading underscore signals "meta-directory, not brand" — the same convention SCSS partials, Jekyll, Hugo all use. The two changes never met until CI built the merge commit, where assertResourceId('_schema') deterministically failed: Error: Design system directory _schema has malformed resource id: _schema at invariant tests/localized-content.test.ts:66:11 at assertResourceId tests/localized-content.test.ts:71:3 at readDesignSystemResources tests/localized-content.test.ts:202:8 Fix tightens readDesignSystemResources's directory filter so the leading-underscore convention is recognised explicitly: .filter((entry) => entry.isDirectory() && !entry.name.startsWith('_')) This aligns with what apps/daemon/src/design-systems.ts:listDesignSystems already does implicitly — it requires DESIGN.md per directory, so _schema/ was always invisible at runtime; the test was the only place that surfaced it. Verified locally on the post-merge tree: - pnpm test (e2e vitest) — tests/localized-content.test.ts: 4 passed - pnpm guard — all 6 design-system checks pass on default + kami - pnpm typecheck — clean across the workspace (after pnpm install to pull deps for tools/pr that arrived with main) The fix is intentionally narrow (one filter line in one test) and documents the convention inline so future meta-directories under design-systems/ (e.g. _archive/, _drafts/) are covered for free. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: chaoxiaoche <chaoxiaoche@192.168.10.16> Co-authored-by: Cursor <cursoragent@cursor.com>
506 lines
20 KiB
TypeScript
506 lines
20 KiB
TypeScript
/* ─────────────────────────────────────────────────────────────────────────
|
|
* scripts/check-tokens-fixture-sync.ts
|
|
*
|
|
* Guard checks that enforce the design-system token contract.
|
|
*
|
|
* The shared schema lives in `design-systems/_schema/tokens.schema.ts`;
|
|
* its A2 fallback values mirror into `design-systems/_schema/defaults.css`.
|
|
* Every brand under `design-systems/<brand>/` ships two consumer-facing
|
|
* artifacts:
|
|
*
|
|
* - tokens.css — canonical token bindings (`:root { ... }`)
|
|
* - components.html — self-contained fixture whose first <style>
|
|
* embeds the same `:root` so the file renders
|
|
* standalone in any browser.
|
|
*
|
|
* This file exports six check functions, each registered as its own
|
|
* entry in `pnpm guard` so failures attribute to a specific contract.
|
|
*
|
|
* 1. checkDesignSystemTokenFixtureSync
|
|
* components.html `:root` is byte-equivalent to tokens.css
|
|
* `:root` after canonical normalization.
|
|
*
|
|
* 2. checkDesignSystemA1RequiredTokens
|
|
* Every brand declares every A1-identity / A1-structure token
|
|
* from the schema. Missing → fail.
|
|
*
|
|
* 3. checkDesignSystemA2RequiredTokens
|
|
* Every brand declares every A2 token from the schema. Missing
|
|
* → fail (until the derive script lands; see _schema/AGENTS.md).
|
|
*
|
|
* 4. checkDesignSystemBSlotRequiredTokens
|
|
* Every brand declares every B-slot token. The brand may bind
|
|
* independently or alias the named sibling via `var(...)`, but
|
|
* it must appear in `:root`; artifacts paste a single `:root`
|
|
* block, so a missing slot resolves to nothing at runtime.
|
|
*
|
|
* 5. checkDesignSystemUnknownTokens
|
|
* Every token a brand declares is either in the shared schema
|
|
* or explicitly allowed by `BRAND_EXTENSIONS` /
|
|
* `BRAND_EXTENSION_PREFIXES`. Stray names → fail.
|
|
*
|
|
* 6. checkDesignSystemA2DefaultsParity
|
|
* Each A2 declaration in `_schema/defaults.css` matches the
|
|
* `fallback` field on the matching entry in `tokens.schema.ts`.
|
|
*
|
|
* Run standalone: `pnpm exec tsx scripts/check-tokens-fixture-sync.ts`
|
|
* Or as part of `pnpm guard` (registered in scripts/guard.ts).
|
|
* ─────────────────────────────────────────────────────────────────── */
|
|
|
|
import { readFile, readdir } from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
import {
|
|
BRAND_EXTENSIONS,
|
|
BRAND_EXTENSION_PREFIXES,
|
|
TOKEN_SCHEMA,
|
|
getAllSchemaNames,
|
|
getBSlotNames,
|
|
getRequiredA1Names,
|
|
getRequiredA2Names,
|
|
isAllowedExtension,
|
|
} from "../design-systems/_schema/tokens.schema.ts";
|
|
|
|
const repoRoot = path.resolve(import.meta.dirname, "..");
|
|
const designSystemsRoot = path.join(repoRoot, "design-systems");
|
|
const schemaRoot = path.join(designSystemsRoot, "_schema");
|
|
const defaultsCssPath = path.join(schemaRoot, "defaults.css");
|
|
|
|
const SKIPPED_BRAND_DIRECTORIES = new Set(["_schema"]);
|
|
|
|
function toRepositoryPath(filePath: string): string {
|
|
return path.relative(repoRoot, filePath).split(path.sep).join("/");
|
|
}
|
|
|
|
// ─── CSS parsing utilities ──────────────────────────────────────────
|
|
|
|
function stripCssComments(css: string): string {
|
|
return css.replace(/\/\*[\s\S]*?\*\//g, "");
|
|
}
|
|
|
|
function extractUnscopedRootBlockBody(commentlessCss: string): string | null {
|
|
const match = commentlessCss.match(/:root(?!\[)\s*\{([\s\S]*?)\}/);
|
|
return match == null ? null : (match[1] ?? null);
|
|
}
|
|
|
|
function canonicalizeRootBlockBody(body: string): string {
|
|
const declarations = body
|
|
.split(";")
|
|
.map((decl) =>
|
|
decl
|
|
.trim()
|
|
.replace(/\s+/g, " ")
|
|
.replace(/\s*:\s*/, ": "),
|
|
)
|
|
.filter((decl) => decl.length > 0);
|
|
return declarations.map((decl) => `${decl};`).join("\n");
|
|
}
|
|
|
|
/** Parse a normalized `:root` body into a name→value map for tokens (--*). */
|
|
function parseTokenDeclarations(commentlessRootBody: string): Map<string, string> {
|
|
const declarations = new Map<string, string>();
|
|
for (const rawDecl of commentlessRootBody.split(";")) {
|
|
const decl = rawDecl.trim();
|
|
if (decl.length === 0) continue;
|
|
const colonIndex = decl.indexOf(":");
|
|
if (colonIndex === -1) continue;
|
|
const name = decl.slice(0, colonIndex).trim();
|
|
if (!name.startsWith("--")) continue;
|
|
const value = decl
|
|
.slice(colonIndex + 1)
|
|
.trim()
|
|
.replace(/\s+/g, " ");
|
|
declarations.set(name, value);
|
|
}
|
|
return declarations;
|
|
}
|
|
|
|
/** Normalize a CSS expression for byte-level comparison. */
|
|
function normalizeCssValue(value: string): string {
|
|
return value.trim().replace(/\s+/g, " ");
|
|
}
|
|
|
|
// ─── Brand discovery ────────────────────────────────────────────────
|
|
|
|
type BrandSources = {
|
|
brand: string;
|
|
tokensPath: string;
|
|
fixturePath: string;
|
|
tokensCss: string;
|
|
fixtureHtml: string;
|
|
};
|
|
|
|
async function fileExists(filePath: string): Promise<boolean> {
|
|
try {
|
|
await readFile(filePath, "utf8");
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
type BrandDiscovery = { sources: BrandSources[]; pairingErrors: string[] };
|
|
|
|
async function discoverBrandSources(): Promise<BrandDiscovery> {
|
|
let designSystemEntries;
|
|
try {
|
|
designSystemEntries = await readdir(designSystemsRoot, { withFileTypes: true });
|
|
} catch {
|
|
return { sources: [], pairingErrors: [] };
|
|
}
|
|
|
|
const sources: BrandSources[] = [];
|
|
const pairingErrors: string[] = [];
|
|
|
|
for (const entry of designSystemEntries) {
|
|
if (!entry.isDirectory()) continue;
|
|
if (SKIPPED_BRAND_DIRECTORIES.has(entry.name)) continue;
|
|
|
|
const brand = entry.name;
|
|
const brandRoot = path.join(designSystemsRoot, brand);
|
|
const tokensPath = path.join(brandRoot, "tokens.css");
|
|
const fixturePath = path.join(brandRoot, "components.html");
|
|
|
|
const [tokensExists, fixtureExists] = await Promise.all([fileExists(tokensPath), fileExists(fixturePath)]);
|
|
|
|
if (!tokensExists && !fixtureExists) continue;
|
|
|
|
if (tokensExists !== fixtureExists) {
|
|
const present = tokensExists ? tokensPath : fixturePath;
|
|
const missing = tokensExists ? fixturePath : tokensPath;
|
|
pairingErrors.push(
|
|
`${toRepositoryPath(present)} exists but ${toRepositoryPath(missing)} does not — ` +
|
|
`token / fixture pairs must travel together so agents always have both the values and a working example.`,
|
|
);
|
|
continue;
|
|
}
|
|
|
|
const [tokensCss, fixtureHtml] = await Promise.all([readFile(tokensPath, "utf8"), readFile(fixturePath, "utf8")]);
|
|
|
|
sources.push({ brand, tokensPath, fixturePath, tokensCss, fixtureHtml });
|
|
}
|
|
|
|
sources.sort((a, b) => a.brand.localeCompare(b.brand));
|
|
return { sources, pairingErrors };
|
|
}
|
|
|
|
function reportFailure(checkLabel: string, violations: string[], remediation?: string): boolean {
|
|
if (violations.length === 0) return true;
|
|
console.error(`${checkLabel} violations:`);
|
|
for (const violation of violations) {
|
|
console.error(`- ${violation}`);
|
|
}
|
|
if (remediation != null) console.error(remediation);
|
|
return false;
|
|
}
|
|
|
|
// ─── 1. Sync between tokens.css and components.html ──────────────────
|
|
|
|
function describeFirstDivergence(canonicalTokens: string, canonicalFixture: string): string {
|
|
const tokenLines = canonicalTokens.split("\n");
|
|
const fixtureLines = canonicalFixture.split("\n");
|
|
const longest = Math.max(tokenLines.length, fixtureLines.length);
|
|
for (let index = 0; index < longest; index += 1) {
|
|
if (tokenLines[index] !== fixtureLines[index]) {
|
|
const left = tokenLines[index] ?? "(missing — fixture has extra declarations beyond tokens.css)";
|
|
const right = fixtureLines[index] ?? "(missing — tokens.css has extra declarations beyond fixture)";
|
|
return [
|
|
` first divergence at declaration ${index + 1}:`,
|
|
` tokens.css → ${left}`,
|
|
` components.html → ${right}`,
|
|
].join("\n");
|
|
}
|
|
}
|
|
return " declarations align by index but the canonical strings still differ — inspect manually";
|
|
}
|
|
|
|
export async function checkDesignSystemTokenFixtureSync(): Promise<boolean> {
|
|
const { sources, pairingErrors } = await discoverBrandSources();
|
|
const violations = [...pairingErrors];
|
|
let pairsChecked = 0;
|
|
|
|
for (const { brand, tokensPath, fixturePath, tokensCss, fixtureHtml } of sources) {
|
|
const tokensRootBody = extractUnscopedRootBlockBody(stripCssComments(tokensCss));
|
|
const fixtureRootBody = extractUnscopedRootBlockBody(stripCssComments(fixtureHtml));
|
|
|
|
if (tokensRootBody == null) {
|
|
violations.push(`${toRepositoryPath(tokensPath)} contains no \`:root { ... }\` rule.`);
|
|
continue;
|
|
}
|
|
if (fixtureRootBody == null) {
|
|
violations.push(
|
|
`${toRepositoryPath(fixturePath)} contains no \`:root { ... }\` rule — fixture must paste the canonical token bindings into a <style>.`,
|
|
);
|
|
continue;
|
|
}
|
|
|
|
const canonicalTokens = canonicalizeRootBlockBody(tokensRootBody);
|
|
const canonicalFixture = canonicalizeRootBlockBody(fixtureRootBody);
|
|
|
|
pairsChecked += 1;
|
|
|
|
if (canonicalTokens !== canonicalFixture) {
|
|
violations.push(
|
|
[
|
|
`[${brand}] ${toRepositoryPath(fixturePath)} :root drifted from ${toRepositoryPath(tokensPath)} :root.`,
|
|
describeFirstDivergence(canonicalTokens, canonicalFixture),
|
|
` Re-paste the canonical block from tokens.css (declarations only — comments and whitespace are normalized).`,
|
|
].join("\n"),
|
|
);
|
|
}
|
|
}
|
|
|
|
const passed = reportFailure(
|
|
"Design system token-fixture sync",
|
|
violations,
|
|
"Each design-systems/<brand>/components.html must keep its first `:root { ... }` block byte-equivalent (after comment / whitespace normalization) to the same brand's tokens.css `:root` block.",
|
|
);
|
|
if (passed) {
|
|
console.log(
|
|
`Design system token-fixture sync passed: ${pairsChecked} brand pair${pairsChecked === 1 ? "" : "s"} aligned (components.html :root matches tokens.css :root).`,
|
|
);
|
|
}
|
|
return passed;
|
|
}
|
|
|
|
// ─── 2. A1 required tokens ──────────────────────────────────────────
|
|
|
|
export async function checkDesignSystemA1RequiredTokens(): Promise<boolean> {
|
|
const { sources } = await discoverBrandSources();
|
|
const requiredA1 = getRequiredA1Names();
|
|
const violations: string[] = [];
|
|
|
|
for (const { brand, tokensPath, tokensCss } of sources) {
|
|
const rootBody = extractUnscopedRootBlockBody(stripCssComments(tokensCss));
|
|
if (rootBody == null) continue; // sync check covers this case
|
|
const declared = parseTokenDeclarations(rootBody);
|
|
|
|
const missing = requiredA1.filter((name) => !declared.has(name));
|
|
if (missing.length > 0) {
|
|
violations.push(
|
|
`[${brand}] ${toRepositoryPath(tokensPath)} is missing ${missing.length} A1 token${missing.length === 1 ? "" : "s"} (brand identity / structure must be explicit per brand):\n ${missing.join(", ")}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
const passed = reportFailure(
|
|
"Design system A1 required tokens",
|
|
violations,
|
|
"A1 tokens (identity + structure) have no defensible cross-brand fallback. Every brand must declare them explicitly. See design-systems/_schema/AGENTS.md for the layer model.",
|
|
);
|
|
if (passed) {
|
|
console.log(
|
|
`Design system A1 required tokens passed: ${sources.length} brand${sources.length === 1 ? "" : "s"} declare all ${requiredA1.length} A1 tokens.`,
|
|
);
|
|
}
|
|
return passed;
|
|
}
|
|
|
|
// ─── 3. A2 required tokens ──────────────────────────────────────────
|
|
|
|
export async function checkDesignSystemA2RequiredTokens(): Promise<boolean> {
|
|
const { sources } = await discoverBrandSources();
|
|
const requiredA2 = getRequiredA2Names();
|
|
const violations: string[] = [];
|
|
|
|
for (const { brand, tokensPath, tokensCss } of sources) {
|
|
const rootBody = extractUnscopedRootBlockBody(stripCssComments(tokensCss));
|
|
if (rootBody == null) continue;
|
|
const declared = parseTokenDeclarations(rootBody);
|
|
|
|
const missing = requiredA2.filter((name) => !declared.has(name));
|
|
if (missing.length > 0) {
|
|
violations.push(
|
|
`[${brand}] ${toRepositoryPath(tokensPath)} is missing ${missing.length} A2 token${missing.length === 1 ? "" : "s"} (default values exist in design-systems/_schema/defaults.css; copy or override):\n ${missing.join(", ")}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
const passed = reportFailure(
|
|
"Design system A2 required tokens",
|
|
violations,
|
|
"A2 tokens carry sensible cross-brand defaults but artifacts paste a single :root block — agents that paste a tokens.css missing an A2 declaration will produce broken artifacts. Every brand's tokens.css must declare every A2 token (until the derive script lands and inlines fallbacks automatically).",
|
|
);
|
|
if (passed) {
|
|
console.log(
|
|
`Design system A2 required tokens passed: ${sources.length} brand${sources.length === 1 ? "" : "s"} declare all ${requiredA2.length} A2 tokens.`,
|
|
);
|
|
}
|
|
return passed;
|
|
}
|
|
|
|
// ─── 4. B-slot required tokens ──────────────────────────────────────
|
|
|
|
export async function checkDesignSystemBSlotRequiredTokens(): Promise<boolean> {
|
|
const { sources } = await discoverBrandSources();
|
|
const bSlotNames = getBSlotNames();
|
|
const violations: string[] = [];
|
|
|
|
for (const { brand, tokensPath, tokensCss } of sources) {
|
|
const rootBody = extractUnscopedRootBlockBody(stripCssComments(tokensCss));
|
|
if (rootBody == null) continue;
|
|
const declared = parseTokenDeclarations(rootBody);
|
|
|
|
const missing = bSlotNames.filter((name) => !declared.has(name));
|
|
if (missing.length > 0) {
|
|
const hints = missing
|
|
.map((name) => {
|
|
const spec = TOKEN_SCHEMA.find((t) => t.name === name);
|
|
return spec?.aliasTo != null ? `${name} (default alias: ${spec.aliasTo})` : name;
|
|
})
|
|
.join(", ");
|
|
violations.push(
|
|
`[${brand}] ${toRepositoryPath(tokensPath)} is missing ${missing.length} B-slot token${missing.length === 1 ? "" : "s"} (alias the named sibling via var(...) or bind independently):\n ${hints}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
const passed = reportFailure(
|
|
"Design system B-slot required tokens",
|
|
violations,
|
|
"B-slot tokens (--fg-2, --meta, --surface-warm, --border-soft) let shared components target richer tiers without forking. Artifacts paste a single :root block — a missing slot resolves to nothing at runtime, so every brand must declare every B-slot, either as `var(--sibling)` (collapsed brand) or an independent value (richer brand). See design-systems/_schema/AGENTS.md.",
|
|
);
|
|
if (passed) {
|
|
console.log(
|
|
`Design system B-slot required tokens passed: ${sources.length} brand${sources.length === 1 ? "" : "s"} declare all ${bSlotNames.length} B-slot tokens.`,
|
|
);
|
|
}
|
|
return passed;
|
|
}
|
|
|
|
// ─── 5. Unknown token allowlist ─────────────────────────────────────
|
|
|
|
export async function checkDesignSystemUnknownTokens(): Promise<boolean> {
|
|
const { sources } = await discoverBrandSources();
|
|
const schemaNames = new Set(getAllSchemaNames());
|
|
const violations: string[] = [];
|
|
|
|
for (const { brand, tokensPath, tokensCss } of sources) {
|
|
const rootBody = extractUnscopedRootBlockBody(stripCssComments(tokensCss));
|
|
if (rootBody == null) continue;
|
|
const declared = parseTokenDeclarations(rootBody);
|
|
|
|
const unknown: string[] = [];
|
|
for (const name of declared.keys()) {
|
|
if (schemaNames.has(name)) continue;
|
|
if (isAllowedExtension(brand, name)) continue;
|
|
unknown.push(name);
|
|
}
|
|
|
|
if (unknown.length > 0) {
|
|
violations.push(
|
|
`[${brand}] ${toRepositoryPath(tokensPath)} declares ${unknown.length} unknown token${unknown.length === 1 ? "" : "s"} (not in shared schema, not in BRAND_EXTENSIONS["${brand}"], not matching any BRAND_EXTENSION_PREFIXES):\n ${unknown.join(", ")}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
const passed = reportFailure(
|
|
"Design system unknown token allowlist",
|
|
violations,
|
|
'Every token must be declared in design-systems/_schema/tokens.schema.ts (shared schema), or listed in BRAND_EXTENSIONS["<brand>"] (brand-specific), or match a prefix in BRAND_EXTENSION_PREFIXES. See _schema/AGENTS.md for the C → B-slot → A2 promotion path before adding new shared tokens.',
|
|
);
|
|
if (passed) {
|
|
const totalTokens = sources.reduce((sum, source) => {
|
|
const body = extractUnscopedRootBlockBody(stripCssComments(source.tokensCss));
|
|
return sum + (body == null ? 0 : parseTokenDeclarations(body).size);
|
|
}, 0);
|
|
console.log(
|
|
`Design system unknown token allowlist passed: ${totalTokens} declarations across ${sources.length} brand${sources.length === 1 ? "" : "s"} all match shared schema or brand extensions.`,
|
|
);
|
|
}
|
|
return passed;
|
|
}
|
|
|
|
// ─── 6. A2 defaults parity (schema fallback ↔ defaults.css) ─────────
|
|
|
|
export async function checkDesignSystemA2DefaultsParity(): Promise<boolean> {
|
|
let defaultsCss: string;
|
|
try {
|
|
defaultsCss = await readFile(defaultsCssPath, "utf8");
|
|
} catch {
|
|
return reportFailure(
|
|
"Design system A2 defaults parity",
|
|
[`${toRepositoryPath(defaultsCssPath)} does not exist — A2 fallback contract requires a CSS mirror of tokens.schema.ts.`],
|
|
);
|
|
}
|
|
|
|
const rootBody = extractUnscopedRootBlockBody(stripCssComments(defaultsCss));
|
|
if (rootBody == null) {
|
|
return reportFailure(
|
|
"Design system A2 defaults parity",
|
|
[`${toRepositoryPath(defaultsCssPath)} contains no \`:root { ... }\` rule.`],
|
|
);
|
|
}
|
|
|
|
const declared = parseTokenDeclarations(rootBody);
|
|
const violations: string[] = [];
|
|
|
|
const a2Specs = TOKEN_SCHEMA.filter((spec) => spec.layer === "A2");
|
|
|
|
for (const spec of a2Specs) {
|
|
const fallback = spec.fallback;
|
|
if (fallback == null) {
|
|
violations.push(
|
|
`tokens.schema.ts entry ${spec.name} has layer "A2" but no \`fallback\` field — every A2 token must specify the value the derive script will inline.`,
|
|
);
|
|
continue;
|
|
}
|
|
const actual = declared.get(spec.name);
|
|
if (actual == null) {
|
|
violations.push(
|
|
`${toRepositoryPath(defaultsCssPath)} is missing a declaration for ${spec.name} (schema fallback is \`${fallback}\`).`,
|
|
);
|
|
continue;
|
|
}
|
|
if (normalizeCssValue(actual) !== normalizeCssValue(fallback)) {
|
|
violations.push(
|
|
[
|
|
`${spec.name} drifted between schema and defaults.css:`,
|
|
` tokens.schema.ts → ${normalizeCssValue(fallback)}`,
|
|
` defaults.css → ${normalizeCssValue(actual)}`,
|
|
].join("\n"),
|
|
);
|
|
}
|
|
}
|
|
|
|
const a2Names = new Set(a2Specs.map((spec) => spec.name));
|
|
for (const declaredName of declared.keys()) {
|
|
if (!a2Names.has(declaredName)) {
|
|
violations.push(
|
|
`${toRepositoryPath(defaultsCssPath)} declares ${declaredName}, which is not an A2 token in tokens.schema.ts. defaults.css mirrors only A2 fallbacks.`,
|
|
);
|
|
}
|
|
}
|
|
|
|
const passed = reportFailure(
|
|
"Design system A2 defaults parity",
|
|
violations,
|
|
"Update both tokens.schema.ts and defaults.css together. defaults.css exists as a human-readable mirror of A2 fallback fields and is the future input to the derive script.",
|
|
);
|
|
if (passed) {
|
|
console.log(
|
|
`Design system A2 defaults parity passed: ${a2Specs.length} A2 fallback${a2Specs.length === 1 ? "" : "s"} match tokens.schema.ts ↔ defaults.css byte-for-byte.`,
|
|
);
|
|
}
|
|
return passed;
|
|
}
|
|
|
|
// ─── Standalone entrypoint ───────────────────────────────────────────
|
|
|
|
const isInvokedDirectly = process.argv[1] != null && fileURLToPath(import.meta.url) === path.resolve(process.argv[1]);
|
|
|
|
if (isInvokedDirectly) {
|
|
const checks = [
|
|
checkDesignSystemTokenFixtureSync,
|
|
checkDesignSystemA1RequiredTokens,
|
|
checkDesignSystemA2RequiredTokens,
|
|
checkDesignSystemBSlotRequiredTokens,
|
|
checkDesignSystemUnknownTokens,
|
|
checkDesignSystemA2DefaultsParity,
|
|
];
|
|
const results = await Promise.all(checks.map((check) => check()));
|
|
if (results.some((passed) => !passed)) {
|
|
process.exitCode = 1;
|
|
}
|
|
}
|