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
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
8 changes: 8 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 4 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
106 changes: 106 additions & 0 deletions src/theme-contract/cli.ts
Original file line number Diff line number Diff line change
@@ -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 <dir> source dir to scan for token references (required, 1+)
* --extra-css <file> extra CSS whose --name: definitions also count as defined
* --tokens <file> 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 <dir> [--src <dir>…] ' +
'[--extra-css <file>…] [--tokens <file>] [--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 <dir> 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()
240 changes: 240 additions & 0 deletions src/theme-contract/index.ts
Original file line number Diff line number Diff line change
@@ -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(`(?<![\\w-])(?:${UTILITY_PREFIXES})-${suffix}(?![\\w-])`, 'g')
}

/** Recursively collect scannable source files under a directory. */
function walkSources(dir: string): string[] {
let entries: Dirent[]
try {
entries = readdirSync(dir, { withFileTypes: true })
} catch {
return []
}
return entries.flatMap((e) => {
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<string> {
const defs = new Set<string>()
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<string, string>()
const seenUtility = new Map<string, string>()

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] ?? `<utility>-${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
}
Loading