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',
},