From 9d421a13fb831836c8e2d2d7b3fc2be0e782e5bb Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Wed, 15 Jul 2026 13:37:41 -0600 Subject: [PATCH] feat(theme-contract): exportable token/utility contract checker + bin (#188 Phase 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Consumer apps ship UI that references theme tokens/utilities their own build never emits, so the surface resolves to nothing and paints transparent with no error at runtime (tax-agent's invisible model dropdown; the whole bg-surface-container-* family). tests/theme/tokens-contract.test.ts only guards agent-app's OWN components, so every consumer was unprotected. Lift the referenced-vs-defined token walk out of that test into an exported checkThemeContract(), and ship a bin every consumer runs in CI over ITS OWN source. Two checks: - var(--…): COMPLETE. Every literal custom-property reference (inline styles, bg-[var(--…)] arbitrary Tailwind values, CSS strings) vs the shipped tokens.css + any extra app CSS. - Tailwind utilities: INTENTIONALLY PARTIAL. Bare classes carry no var() so the first check can't see them; fully resolving Tailwind is out of scope. Check the specific known-dangerous families the preset maps to elevation tokens (surface-container*/card/popover) — exactly the ones that shipped invisible. The canvas/sequence aliases are consumed as bg-[var(--…)] and so are already covered by check 1. The checker reads fs, so it lives in a new ./theme-contract subpath; ./theme stays browser-clean (browser-safe manifest test still green). tokens-contract. test.ts now consumes the export as the single source of truth for the walk. --- AGENTS.md | 1 + package.json | 8 + src/index.ts | 4 + src/theme-contract/cli.ts | 106 ++++++++ src/theme-contract/index.ts | 240 ++++++++++++++++++ tests/theme/contract.test.ts | 66 +++++ .../consumer-src/BadUtilityComponent.tsx | 8 + .../fixtures/consumer-src/BadVarComponent.tsx | 6 + .../fixtures/consumer-src/GoodComponent.tsx | 10 + tests/theme/fixtures/extra-tokens.css | 7 + tests/theme/fixtures/tokens.css | 9 + tests/theme/tokens-contract.test.ts | 46 +--- tsup.config.ts | 2 + 13 files changed, 479 insertions(+), 34 deletions(-) create mode 100644 src/theme-contract/cli.ts create mode 100644 src/theme-contract/index.ts create mode 100644 tests/theme/contract.test.ts create mode 100644 tests/theme/fixtures/consumer-src/BadUtilityComponent.tsx create mode 100644 tests/theme/fixtures/consumer-src/BadVarComponent.tsx create mode 100644 tests/theme/fixtures/consumer-src/GoodComponent.tsx create mode 100644 tests/theme/fixtures/extra-tokens.css create mode 100644 tests/theme/fixtures/tokens.css diff --git a/AGENTS.md b/AGENTS.md index ef953eb..6c2c85c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,6 +36,7 @@ Corollary — **extend, never duplicate.** Before writing anything that complete | `/web-react` | shared chat-shell + observability components: ModelPicker/EffortPicker/ChatMessages/RunDrillIn, `MissionActivityLane` (per-step delegated-run sub-rows → web waterfall), `AgentActivityPanel` (cross-context delegation surface over a `fetchActivity(cursor)` data port, missionRef link slot), `FlowWaterfall` + pure `waterfallLayout`/`mergeActivityPages` helpers, `InteractionQuestionCard`/`InteractionPlanCard` + `useChatInteractions`/`createInteractionAnswerSubmitter` (the client half of `/interactions`) | react peer; renders `/missions` lanes via `/trace` converters and `/interactions` asks via its contract | | `/crypto` `/web` `/redact` `/stream` | AES-GCM field crypto · web boundary utils (body/context/rate-limit/headers) · PII redaction · SSE normalization + turn identity | — | | `/theme` `/styles` `/tailwind-preset` | single design-token source for every React surface: `tokens.css` (`:root` + `[data-theme="dark"]`/`.dark`, shadcn channel triples with canvas/sequences `--bg-input`/`--text-primary`/`--border-default`/… aliases resolving to them), typed `AgentAppTheme` + `lightTheme`/`darkTheme`/`themeToCssVars`/`themeColor`, and a Tailwind preset mapping shadcn names (`bg-card`, `text-muted-foreground`…) to the vars. Consumers: `import '@tangle-network/agent-app/styles'` + add the preset. Enforced by `tests/theme/tokens-contract.test.ts` — every `var(--…)` a component references must be defined here, else it ships transparent. | — (pure CSS/data; no peer) | +| `/theme-contract` | node-only token-completeness checker consumers run in CI over THEIR OWN source (`checkThemeContract` + the `agent-app-theme-check` bin): a full `var(--…)` reference scan plus a check of the known-dangerous preset utilities (`surface-container*`/`card`/`popover`) against the shipped `tokens.css` + any extra app CSS — catches the invisible-popover/transparent-dropdown class. Split from `/theme` (which stays browser-clean) because it reads `fs`. Single source of truth for the token walk in `tokens-contract.test.ts`. | — (pure `fs` mechanism; no peer) | ## Agent-native principles (products on the sandbox) diff --git a/package.json b/package.json index ed19af8..5d417c4 100644 --- a/package.json +++ b/package.json @@ -354,8 +354,16 @@ "import": "./dist/theme/tailwind-preset.js", "default": "./dist/theme/tailwind-preset.js" }, + "./theme-contract": { + "types": "./dist/theme-contract/index.d.ts", + "import": "./dist/theme-contract/index.js", + "default": "./dist/theme-contract/index.js" + }, "./styles": "./dist/theme/tokens.css" }, + "bin": { + "agent-app-theme-check": "./dist/theme-contract/cli.js" + }, "scripts": { "build": "tsup", "dev": "tsup --watch", diff --git a/src/index.ts b/src/index.ts index 5e483e4..93d6503 100644 --- a/src/index.ts +++ b/src/index.ts @@ -31,6 +31,10 @@ export * from './web/index' export * from './redact/index' export * from './assets/index' export * from './theme/index' +// `/theme-contract` (the CI token-completeness checker) reads the filesystem +// (node:fs) — server-only, same as the other node-touching modules re-exported +// here. `/theme` itself stays browser-clean (it's in the browser-safe manifest). +export * from './theme-contract/index' // `/app-auth` is intentionally NOT re-exported here: it imports the optional // better-auth peer at module top (same rule as `/platform`, which stays // subpath-only for its structural seams). diff --git a/src/theme-contract/cli.ts b/src/theme-contract/cli.ts new file mode 100644 index 0000000..8558aee --- /dev/null +++ b/src/theme-contract/cli.ts @@ -0,0 +1,106 @@ +#!/usr/bin/env node +/** + * agent-app-theme-check — CI guard against the invisible-surface incident class. + * + * A consumer app runs this over its own source; it fails (exit 1) when a + * component references a theme token — `var(--popover)` or a preset-mapped + * utility like `bg-surface-container-high` — that the app's shipped CSS never + * defines, which would paint that surface transparent with no error at runtime. + * + * agent-app-theme-check --src src --src packages/ui/src \ + * --extra-css src/app-tokens.css + * + * Flags (all repeatable except --tokens): + * --src source dir to scan for token references (required, 1+) + * --extra-css extra CSS whose --name: definitions also count as defined + * --tokens override the base tokens.css (defaults to the one + * agent-app ships as `@tangle-network/agent-app/styles`) + * --allow <--var> suppress a token name from the missing report + * + * Wire it as a CI step: `"theme-check": "agent-app-theme-check --src src"`. + */ + +import { checkThemeContract } from './index' + +interface ParsedArgs { + srcDirs: string[] + extraCss: string[] + allow: string[] + tokens?: string +} + +function parseArgs(argv: string[]): ParsedArgs { + const out: ParsedArgs = { srcDirs: [], extraCss: [], allow: [] } + for (let i = 0; i < argv.length; i++) { + const flag = argv[i] + const take = () => { + const v = argv[++i] + if (v === undefined) fail(`${flag} needs a value`) + return v! + } + switch (flag) { + case '--src': + out.srcDirs.push(take()) + break + case '--extra-css': + out.extraCss.push(take()) + break + case '--allow': + out.allow.push(take()) + break + case '--tokens': + out.tokens = take() + break + case '-h': + case '--help': + printUsage() + process.exit(0) + break + default: + fail(`unknown argument: ${flag}`) + } + } + return out +} + +function printUsage(): void { + process.stdout.write( + 'Usage: agent-app-theme-check --src [--src …] ' + + '[--extra-css …] [--tokens ] [--allow <--var>…]\n', + ) +} + +function fail(msg: string): never { + process.stderr.write(`agent-app-theme-check: ${msg}\n`) + printUsage() + process.exit(2) +} + +function main(): void { + const args = parseArgs(process.argv.slice(2)) + if (args.srcDirs.length === 0) fail('at least one --src is required') + + const { ok, missing } = checkThemeContract({ + srcDirs: args.srcDirs, + tokensCss: args.tokens, + extraTokensCss: args.extraCss, + allowlist: args.allow, + }) + + if (ok) { + process.stdout.write(`theme contract OK — every referenced token is defined (${args.srcDirs.join(', ')})\n`) + process.exit(0) + } + + process.stderr.write( + `theme contract FAILED — ${missing.length} token reference(s) resolve to nothing (surface ships transparent):\n\n`, + ) + for (const m of missing) process.stderr.write(` ${m.varName}\n referenced in ${m.referencedIn}\n`) + process.stderr.write( + '\nDefine these in your tokens.css (or `import \'@tangle-network/agent-app/styles\'`),\n' + + 'pass the defining CSS via --extra-css, or suppress a deliberately-external one with --allow.\n', + ) + process.exit(1) +} + +main() diff --git a/src/theme-contract/index.ts b/src/theme-contract/index.ts new file mode 100644 index 0000000..122833a --- /dev/null +++ b/src/theme-contract/index.ts @@ -0,0 +1,240 @@ +/** + * Exportable theme-token contract checker — the incident guard for the + * invisible-popover class of bugs. + * + * The failure mode (tax-agent's transparent model dropdown; the whole + * `bg-surface-container-*` family): a consumer app ships a component that + * references a theme token — either as `var(--popover)` or as a Tailwind class + * like `bg-surface-container-high` that the agent-app preset maps to + * `hsl(var(--popover))` — but the app's OWN build never emits that custom + * property (it forgot `import '@tangle-network/agent-app/styles'`, or dropped a + * token in its local tokens.css). CSS resolves the missing var to nothing, the + * surface paints transparent, and NOTHING errors. It ships invisible. + * + * `tests/theme/tokens-contract.test.ts` guards agent-app's OWN components. This + * module lifts that walking logic into a function every CONSUMER app can run + * against ITS OWN source in CI, comparing references to the tokens.css agent-app + * ships plus any extra CSS the app defines. + * + * ── What each check covers (scope is deliberately honest) ──────────────────── + * + * 1. var(--…) check — COMPLETE. Every `var(--name)` literal in the scanned + * source (inline styles, `bg-[var(--name)]` arbitrary Tailwind values, CSS + * template strings) is matched and compared against the defined token set. + * This is exact: a `var(--x)` reference is unambiguous. It is a raw-text + * scan (no AST), so a `var(--x)` written inside a comment or string literal + * counts too — deliberate: it keeps the single-source logic identical to the + * agent-app self-test, and a dangling `var(--x)` in a comment is a smell + * worth surfacing. Suppress a deliberate one with `allowlist`. + * + * 2. Tailwind-utility check — INTENTIONALLY PARTIAL. Bare classes like + * `bg-card` carry no `var(--)` and so are invisible to check 1; Tailwind + * resolves them to `hsl(var(--card))` at build via the preset. Fully + * resolving arbitrary Tailwind config is out of scope (it would mean + * re-implementing Tailwind). Instead we check the SPECIFIC known-dangerous + * families that have actually shipped invisible: the MD3 surface ladder + * (`surface-container` / `-high` / `-highest`) and the `card` / `popover` + * elevation pairs — exactly the utilities the agent-app tailwind-preset + * registers onto elevation tokens (see src/theme/tailwind-preset.ts, the + * source of truth for this mapping). The canvas/sequence aliases + * (`--bg-input`, `--text-primary`, …) are consumed as `bg-[var(--…)]` + * arbitrary values and so are already covered fully by check 1 — they need + * no entry here. + * + * Node-only (reads the filesystem) → this lives in the `./theme-contract` + * subpath, NOT `./theme`, which must stay browser-clean (it's in the + * browser-safe manifest test). + */ + +import { type Dirent, existsSync, readFileSync, readdirSync } from 'node:fs' +import { join, relative } from 'node:path' +import { fileURLToPath } from 'node:url' + +export interface ThemeContractOptions { + /** Consumer source directories to scan for token references (recursively). */ + srcDirs: string[] + /** + * Path to the base tokens.css whose `--name:` definitions are the ground + * truth. Defaults to the tokens.css agent-app ships (`./styles`) — the set a + * consumer gets from `import '@tangle-network/agent-app/styles'`. + */ + tokensCss?: string + /** + * Additional CSS files whose `--name:` definitions also count as defined — + * the app's own overrides/extensions layered on top of the base tokens. + */ + extraTokensCss?: string[] + /** + * Token names (e.g. `--my-app-accent`) to treat as always-defined, suppressing + * them from the missing list. For app-specific vars defined outside any CSS + * the checker can see (injected at runtime, from a third-party stylesheet, …). + */ + allowlist?: string[] +} + +export interface ThemeContractMiss { + /** The undefined custom property, e.g. `--popover`. */ + varName: string + /** + * Where it was referenced: `path/to/file.tsx`, or + * `path/to/file.tsx (via bg-surface-container-high)` when the reference is a + * Tailwind utility that resolves to the token rather than a literal var(). + */ + referencedIn: string +} + +export interface ThemeContractResult { + ok: boolean + missing: ThemeContractMiss[] +} + +/** Source extensions scanned for token references. */ +const SOURCE_RE = /\.(ts|tsx|js|jsx|mjs|cjs)$/ +const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'build', '.next', 'coverage']) + +/** + * Known-dangerous Tailwind utility families and the elevation token each + * resolves to, mirroring src/theme/tailwind-preset.ts. Ordered longest-suffix + * first so `surface-container-highest` is matched before `surface-container`. + * The negative look-around in {@link buildUtilityRe} makes ordering belt-and- + * suspenders rather than load-bearing. + */ +const DANGEROUS_UTILITIES: ReadonlyArray<{ suffix: string; varName: string }> = [ + { suffix: 'surface-container-highest', varName: '--secondary' }, + { suffix: 'surface-container-high', varName: '--popover' }, + { suffix: 'surface-container', varName: '--card' }, + { suffix: 'card-foreground', varName: '--card-foreground' }, + { suffix: 'popover-foreground', varName: '--popover-foreground' }, + { suffix: 'card', varName: '--card' }, + { suffix: 'popover', varName: '--popover' }, +] + +/** Tailwind color-utility prefixes that can carry a background/text/border color. */ +const UTILITY_PREFIXES = 'bg|text|border|ring|fill|stroke' + +/** + * Match a whole utility class for `suffix`, tolerant of variants (`hover:`, + * `dark:`) and opacity (`/95`) but not of longer siblings: the trailing + * `(?![\w-])` stops `bg-surface-container` from matching inside + * `bg-surface-container-high`, and `bg-card` from matching inside + * `bg-card-foreground`. + */ +function buildUtilityRe(suffix: string): RegExp { + return new RegExp(`(? { + if (e.isDirectory()) return SKIP_DIRS.has(e.name) ? [] : walkSources(join(dir, e.name)) + return SOURCE_RE.test(e.name) && !e.name.endsWith('.d.ts') ? [join(dir, e.name)] : [] + }) +} + +/** + * Every `--name:` DEFINITION across the given CSS files. A definition is + * `--name:` at the start of a (trimmed) line; RHS references like + * `hsl(var(--card))` are mid-line and are never counted as definitions. + */ +function definedVars(cssFiles: string[]): Set { + const defs = new Set() + for (const file of cssFiles) { + let css: string + try { + css = readFileSync(file, 'utf8') + } catch { + continue + } + for (const m of css.matchAll(/^\s*(--[a-z0-9-]+)\s*:/gim)) if (m[1]) defs.add(m[1]) + } + return defs +} + +/** + * Default tokens.css: the one agent-app ships as `./styles`. Resolved relative + * to this module's URL, but tolerant of where the bundler lands the running + * code — tsup code-splits shared logic into a chunk at the dist ROOT, so the + * tokens.css sits one directory DIFFERENTLY depending on layout: + * - source (src/theme-contract/index.ts) → ../theme/tokens.css (src/theme) + * - split chunk (dist/contract-*.js) → ./theme/tokens.css (dist/theme) + * - unsplit entry (dist/theme-contract/index.js) → ../theme/tokens.css + * Probe both and return the one that exists; fall back to the first for a + * sensible error path if neither is present. + */ +function defaultTokensCss(): string { + const candidates = ['../theme/tokens.css', './theme/tokens.css'].map((rel) => + fileURLToPath(new URL(rel, import.meta.url)), + ) + return candidates.find((p) => existsSync(p)) ?? candidates[0]! +} + +/** + * Check that every theme token a consumer's source references is actually + * defined in the CSS that consumer ships. Returns the full missing set; the + * caller decides how to fail (the bin exits non-zero on any miss). + */ +export function checkThemeContract(opts: ThemeContractOptions): ThemeContractResult { + const tokensCss = opts.tokensCss ?? defaultTokensCss() + const defined = definedVars([tokensCss, ...(opts.extraTokensCss ?? [])]) + const allow = new Set(opts.allowlist ?? []) + const isDefined = (name: string) => defined.has(name) || allow.has(name) + + const files = opts.srcDirs.flatMap(walkSources) + const utilityMatchers = DANGEROUS_UTILITIES.map((u) => ({ ...u, re: buildUtilityRe(u.suffix) })) + + // Dedupe by varName (literal check) and by varName+utility (utility check), + // keeping the FIRST referencing file — enough to locate the offender without + // drowning the report when one token is referenced across many files. + const seenVar = new Map() + const seenUtility = new Map() + + for (const file of files) { + let text: string + try { + text = readFileSync(file, 'utf8') + } catch { + continue + } + const where = displayPath(file) + + // Check 1 — literal var(--…) references. + for (const m of text.matchAll(/var\(\s*(--[a-z0-9-]+)/gi)) { + const name = m[1] + if (!name || isDefined(name) || seenVar.has(name)) continue + seenVar.set(name, where) + } + + // Check 2 — known-dangerous Tailwind utility classes. + for (const u of utilityMatchers) { + if (isDefined(u.varName)) continue + const key = `${u.varName}::${u.suffix}` + if (seenUtility.has(key)) continue + u.re.lastIndex = 0 + if (u.re.test(text)) seenUtility.set(key, `${where} (via ${firstUtilityHit(text, u.suffix)})`) + } + } + + const missing: ThemeContractMiss[] = [ + ...[...seenVar].map(([varName, referencedIn]) => ({ varName, referencedIn })), + ...[...seenUtility].map(([key, referencedIn]) => ({ varName: key.split('::')[0]!, referencedIn })), + ] + return { ok: missing.length === 0, missing } +} + +/** The literal utility class (with prefix) first seen in `text` for `suffix`, for the report. */ +function firstUtilityHit(text: string, suffix: string): string { + const m = buildUtilityRe(suffix).exec(text) + return m?.[0] ?? `-${suffix}` +} + +/** Path relative to cwd when it stays inside it, else the path as given — for readable reports. */ +function displayPath(file: string): string { + const rel = relative(process.cwd(), file) + return rel && !rel.startsWith('..') ? rel : file +} diff --git a/tests/theme/contract.test.ts b/tests/theme/contract.test.ts new file mode 100644 index 0000000..6d5c3a3 --- /dev/null +++ b/tests/theme/contract.test.ts @@ -0,0 +1,66 @@ +/** + * Exercises the exportable checkThemeContract() a CONSUMER app runs against its + * own source (../../src/theme-contract). Fixtures under fixtures/ stand in for a + * consumer: a controlled tokens.css that omits --popover, plus components that + * reference defined tokens (pass), an undefined var (fail), and an undefined + * dangerous utility (fail) — the invisible-surface incident in miniature. + */ +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' + +import { describe, expect, it } from 'vitest' + +import { checkThemeContract } from '../../src/theme-contract/index' + +const here = dirname(fileURLToPath(import.meta.url)) +const fixtures = join(here, 'fixtures') +const srcDir = join(fixtures, 'consumer-src') +const tokensCss = join(fixtures, 'tokens.css') +const extraCss = join(fixtures, 'extra-tokens.css') + +describe('checkThemeContract', () => { + it('flags an undefined var() reference and attributes it to the file', () => { + const { ok, missing } = checkThemeContract({ srcDirs: [srcDir], tokensCss }) + expect(ok).toBe(false) + const miss = missing.find((m) => m.varName === '--nonexistent-token') + expect(miss, 'should report the undefined --nonexistent-token').toBeDefined() + expect(miss!.referencedIn).toContain('BadVarComponent.tsx') + }) + + it('flags an undefined dangerous Tailwind utility and names the class', () => { + const { missing } = checkThemeContract({ srcDirs: [srcDir], tokensCss }) + // bg-surface-container-high → --popover, which the fixture tokens.css omits. + const miss = missing.find((m) => m.varName === '--popover') + expect(miss, 'should report --popover behind bg-surface-container-high').toBeDefined() + expect(miss!.referencedIn).toContain('BadUtilityComponent.tsx') + expect(miss!.referencedIn).toContain('via bg-surface-container-high') + }) + + it('does not flag references to defined tokens (var(--card) + bg-card)', () => { + const { missing } = checkThemeContract({ srcDirs: [srcDir], tokensCss }) + // --card is defined; neither the literal var(--card) nor bg-card should miss. + expect(missing.some((m) => m.varName === '--card')).toBe(false) + expect(missing.some((m) => m.referencedIn.includes('GoodComponent'))).toBe(false) + }) + + it('allowlist suppresses named tokens from the missing report', () => { + const { ok, missing } = checkThemeContract({ + srcDirs: [srcDir], + tokensCss, + allowlist: ['--nonexistent-token', '--popover'], + }) + expect(missing).toEqual([]) + expect(ok).toBe(true) + }) + + it('extra CSS files count as defined tokens', () => { + // extra-tokens.css defines both --popover and --nonexistent-token. + const { ok, missing } = checkThemeContract({ + srcDirs: [srcDir], + tokensCss, + extraTokensCss: [extraCss], + }) + expect(missing).toEqual([]) + expect(ok).toBe(true) + }) +}) diff --git a/tests/theme/fixtures/consumer-src/BadUtilityComponent.tsx b/tests/theme/fixtures/consumer-src/BadUtilityComponent.tsx new file mode 100644 index 0000000..1381d38 --- /dev/null +++ b/tests/theme/fixtures/consumer-src/BadUtilityComponent.tsx @@ -0,0 +1,8 @@ +// Uses bg-surface-container-high — a bare Tailwind class carrying no literal +// reference, so the first check can't see it. The agent-app preset maps that +// class to the popover elevation token, which the fixture tokens.css omits → +// the utility check must flag it and name the class that resolves to it. This +// is the exact shape of the tax-agent transparent-dropdown incident. +export function BadUtilityComponent() { + return
transparent panel
+} diff --git a/tests/theme/fixtures/consumer-src/BadVarComponent.tsx b/tests/theme/fixtures/consumer-src/BadVarComponent.tsx new file mode 100644 index 0000000..fba705d --- /dev/null +++ b/tests/theme/fixtures/consumer-src/BadVarComponent.tsx @@ -0,0 +1,6 @@ +// References --nonexistent-token via a literal var(), which the fixture +// tokens.css never defines → the var(--…) check must flag it, attributing the +// miss to this file. +export function BadVarComponent() { + return invisible text +} diff --git a/tests/theme/fixtures/consumer-src/GoodComponent.tsx b/tests/theme/fixtures/consumer-src/GoodComponent.tsx new file mode 100644 index 0000000..42117a0 --- /dev/null +++ b/tests/theme/fixtures/consumer-src/GoodComponent.tsx @@ -0,0 +1,10 @@ +// References only tokens the fixture tokens.css defines: var(--card) directly +// and `bg-card`/`text-foreground` utilities (bg-card → --card, defined). The +// checker must report NO miss for this file. +export function GoodComponent() { + return ( +
+ defined tokens only +
+ ) +} diff --git a/tests/theme/fixtures/extra-tokens.css b/tests/theme/fixtures/extra-tokens.css new file mode 100644 index 0000000..befd536 --- /dev/null +++ b/tests/theme/fixtures/extra-tokens.css @@ -0,0 +1,7 @@ +/* An app's own override stylesheet, layered on top of the base tokens.css and + * passed via --extra-css / extraTokensCss. Defines the tokens the base file + * omits, so the checker should treat them as satisfied. */ +:root { + --popover: 0 0% 100%; + --nonexistent-token: 12 34% 56%; +} diff --git a/tests/theme/fixtures/tokens.css b/tests/theme/fixtures/tokens.css new file mode 100644 index 0000000..dbfacaa --- /dev/null +++ b/tests/theme/fixtures/tokens.css @@ -0,0 +1,9 @@ +/* Minimal fixture tokens.css standing in for what a CONSUMER app ships. + * Deliberately defines --card / --foreground / --secondary but OMITS --popover + * and any --nonexistent-token, so the checker's two halves have something to + * catch. Not agent-app's real tokens.css — this is a controlled ground truth. */ +:root { + --card: 240 7% 97%; + --foreground: 0 0% 5%; + --secondary: 240 6% 93%; +} diff --git a/tests/theme/tokens-contract.test.ts b/tests/theme/tokens-contract.test.ts index 3718dc6..47b0201 100644 --- a/tests/theme/tokens-contract.test.ts +++ b/tests/theme/tokens-contract.test.ts @@ -12,6 +12,8 @@ import { fileURLToPath } from 'node:url' import { describe, expect, it } from 'vitest' +import { checkThemeContract } from '../../src/theme-contract/index' + const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '..', '..') const cssPath = join(repoRoot, 'src', 'theme', 'tokens.css') const REACT_PKGS = ['design-canvas-react', 'sequences-react', 'studio-react', 'web-react'] @@ -28,32 +30,6 @@ function walk(dir: string): string[] { }) } -function referencedVars(): Map { - const map = new Map() - for (const pkg of REACT_PKGS) { - for (const file of walk(join(repoRoot, 'src', pkg))) { - const text = readFileSync(file, 'utf8') - for (const m of text.matchAll(/var\(\s*(--[a-z0-9-]+)/gi)) { - const name = m[1] - if (!name) continue - const list = map.get(name) ?? [] - list.push(file.replace(repoRoot + '/', '')) - map.set(name, list) - } - } - } - return map -} - -function definedVars(): Set { - const css = readFileSync(cssPath, 'utf8') - const defs = new Set() - // A definition is `--name:` at the start of a (trimmed) line; RHS references - // like `hsl(var(--card))` are mid-line and never counted as definitions. - for (const m of css.matchAll(/^\s*(--[a-z0-9-]+)\s*:/gim)) if (m[1]) defs.add(m[1]) - return defs -} - /** Extract the body of the first brace-balanced block whose header matches `re`. */ function blockBody(css: string, re: RegExp): string { const start = css.search(re) @@ -79,17 +55,19 @@ const isAlias = (value: string) => /hsl\(\s*var\(/i.test(value) describe('theme token contract', () => { it('every CSS var referenced by a React surface is defined in tokens.css', () => { - const used = referencedVars() - const defined = definedVars() - const missing = [...used.keys()] - .filter((v) => !defined.has(v)) - .map((v) => `${v} (used in ${used.get(v)?.[0]})`) - expect(missing, `Undefined CSS vars referenced in components:\n${missing.join('\n')}`).toEqual([]) + // Consumes the exported checker (single source of truth) against agent-app's + // OWN React surfaces — the same walk a consumer app runs on its own source. + const { missing } = checkThemeContract({ + srcDirs: REACT_PKGS.map((p) => join(repoRoot, 'src', p)), + tokensCss: cssPath, + }) + const rendered = missing.map((m) => `${m.varName} (used in ${m.referencedIn})`) + expect(rendered, `Undefined CSS vars referenced in components:\n${rendered.join('\n')}`).toEqual([]) }) it('tokens.css defines every canonical alias the canvas/sequences packages consume', () => { - const defined = definedVars() - expect(REQUIRED_ALIASES.filter((v) => !defined.has(v))).toEqual([]) + const root = blockDefs(blockBody(readFileSync(cssPath, 'utf8'), /:root\s*\{/)) + expect(REQUIRED_ALIASES.filter((v) => !root.has(v))).toEqual([]) }) // Every CONCRETE :root token (a literal value, not an `hsl(var(--…))` alias) diff --git a/tsup.config.ts b/tsup.config.ts index 062ea15..208c8ac 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -64,6 +64,8 @@ export default defineConfig({ 'vault/lazy': 'src/vault/lazy.tsx', 'theme/index': 'src/theme/index.ts', 'theme/tailwind-preset': 'src/theme/tailwind-preset.ts', + 'theme-contract/index': 'src/theme-contract/index.ts', + 'theme-contract/cli': 'src/theme-contract/cli.ts', 'studio/index': 'src/studio/index.ts', 'studio-react/index': 'src/studio-react/index.tsx', },