refactor(theming): re-apply theme validator suite after parallel rebase

The plan-doc commits 129971ffc + 9db044178 dropped the
audit-theme-tokens → validate-theme-variables rename, the
validate-theme-tokens → validate-theme-utilities rename, the new
validate-theme-parity script, brand-literals.md, and the corresponding
package.json + lint-staged.config.js + themes.css wiring. The files
still existed on disk (git mv changes survived) but were untracked.

Restore the validator suite so `pnpm run validate:all` works again:
  - validate:theme-variables (CSS var names: --muted → --color-muted)
  - validate:theme-utilities (Tailwind: no white/N, no neutral palette)
  - validate:theme-parity    (every --color-* in :root ⇔ .dark + each
                              [data-theme="..."])

All three wired into validate:all and lint-staged. `pnpm run validate:all`
is clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-22 17:07:48 +02:00
parent 766ad2ea8f
commit 430aa30cbf
7 changed files with 411 additions and 17 deletions

View file

@ -0,0 +1,219 @@
#!/usr/bin/env node
/**
* Validate theme CSS variable names.
*
* The Mana theme system standardizes on `--color-*` CSS custom properties
* (defined in `packages/shared-tailwind/src/themes.css`). Earlier, components
* ported from shadcn drifted into bare names (`--muted`, `--primary`, ) and
* `--theme-*` prefixes, neither of which exist in the theme. Those
* references silently fell back to nothing (or to literal fallbacks) and
* stopped tracking the active theme variant.
*
* This check greps Svelte/CSS/TS source files for those legacy patterns
* and fails if any remain. Run in CI and lint-staged so the drift can't
* sneak back in.
*
* Companion check: `validate-theme-utilities.mjs` enforces theme-token
* Tailwind classes (e.g. `text-foreground` instead of `text-white/80`).
*
* Usage:
* node scripts/validate-theme-variables.mjs
*/
import { readdirSync, statSync, readFileSync } from 'fs';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT = join(__dirname, '..');
const RED = '\x1b[31m';
const GREEN = '\x1b[32m';
const YELLOW = '\x1b[33m';
const DIM = '\x1b[2m';
const RESET = '\x1b[0m';
const BOLD = '\x1b[1m';
const SCAN_ROOTS = ['apps', 'packages'];
const SCAN_EXTS = new Set(['.svelte', '.css', '.ts', '.tsx']);
const SKIP_DIRS = new Set([
'node_modules',
'dist',
'build',
'.svelte-kit',
'.next',
'.turbo',
'.vercel',
'.vite',
]);
// Shadcn-convention names that should be `--color-*` in this repo.
const SHADCN_TOKENS = [
'muted',
'muted-foreground',
'primary',
'primary-foreground',
'secondary',
'secondary-foreground',
'accent',
'accent-foreground',
'foreground',
'background',
'border',
'border-strong',
'card',
'card-foreground',
'popover',
'popover-foreground',
'destructive',
'destructive-foreground',
'input',
'ring',
'surface',
'surface-hover',
'surface-elevated',
'error',
'success',
'warning',
];
// Per-element inline custom properties and unrelated local vars that happen
// to collide on a word. Keeping this list narrow — add entries with care.
const ALLOWED_LOCAL_NAMES = new Set([
'primary-color', // shared-auth-ui inline prop carrier
'app-color',
'btn-color',
'tag-color',
'content-bg',
'ring-color',
]);
// Files we know carry module-literal brand accents (see themes.css §4 —
// brand/domain semantics stay as literals, not theme tokens).
const ALLOWED_FILES = new Set([
'apps/mana/apps/web/src/lib/modules/news-research/ListView.svelte',
'apps/mana/apps/web/src/routes/(app)/news-research/+page.svelte',
// Agent template colors are set per-element via style="--accent: ..."
'apps/mana/apps/web/src/routes/(app)/agents/templates/+page.svelte',
]);
const bareTokenRe = new RegExp(`var\\(--(${SHADCN_TOKENS.join('|')})\\s*[,)]`, 'g');
const themePrefixRe = /var\(--theme-[a-z-]+\s*[,)]/g;
function* walk(dir) {
let entries;
try {
entries = readdirSync(dir);
} catch {
return;
}
for (const name of entries) {
if (SKIP_DIRS.has(name)) continue;
const full = join(dir, name);
let st;
try {
st = statSync(full);
} catch {
continue;
}
if (st.isDirectory()) {
yield* walk(full);
} else if (SCAN_EXTS.has(name.slice(name.lastIndexOf('.')))) {
yield full;
}
}
}
function* findViolations(filePath) {
const rel = filePath.slice(ROOT.length + 1);
if (ALLOWED_FILES.has(rel)) return;
let content;
try {
content = readFileSync(filePath, 'utf8');
} catch {
return;
}
const lines = content.split('\n');
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
// Skip var(--color-*) — those are correct. We also skip the
// redefinition block inside @theme in themes.css itself.
for (const m of line.matchAll(bareTokenRe)) {
const match = m[0];
// Ignore if this is actually var(--color-X) with a shared suffix
// (regex doesn't match because of the leading `--color-`, but be
// doubly safe in case of future edits).
if (match.startsWith('var(--color-')) continue;
yield { line: i + 1, col: m.index + 1, text: line.trim(), kind: 'shadcn' };
}
for (const m of line.matchAll(themePrefixRe)) {
yield { line: i + 1, col: m.index + 1, text: line.trim(), kind: 'theme-prefix' };
}
}
}
let totalFiles = 0;
let violationCount = 0;
const byFile = new Map();
for (const sub of SCAN_ROOTS) {
for (const file of walk(join(ROOT, sub))) {
totalFiles++;
const hits = [...findViolations(file)];
if (hits.length === 0) continue;
// Second-level filter: ALLOWED_LOCAL_NAMES (collision guard).
const filtered = hits.filter((h) => {
if (h.kind !== 'shadcn') return true;
const nameMatch = h.text.match(/var\(--([a-z-]+)/);
if (!nameMatch) return true;
return !ALLOWED_LOCAL_NAMES.has(nameMatch[1]);
});
if (filtered.length === 0) continue;
byFile.set(file.slice(ROOT.length + 1), filtered);
violationCount += filtered.length;
}
}
if (violationCount === 0) {
console.log(
`${GREEN}${RESET} Theme tokens clean — scanned ${totalFiles} files, no bare ${BOLD}--muted${RESET}/${BOLD}--primary${RESET}/${BOLD}--theme-*${RESET} references.`
);
process.exit(0);
}
console.log(
`${RED}${RESET} Found ${BOLD}${violationCount}${RESET} theme-token violation${violationCount === 1 ? '' : 's'} across ${byFile.size} file${byFile.size === 1 ? '' : 's'}:\n`
);
for (const [file, hits] of byFile) {
console.log(`${BOLD}${file}${RESET}`);
for (const h of hits) {
const tag =
h.kind === 'theme-prefix' ? `${YELLOW}theme-prefix${RESET}` : `${YELLOW}shadcn${RESET}`;
console.log(` ${DIM}${h.line}:${h.col}${RESET} [${tag}] ${h.text}`);
}
console.log();
}
console.log(
`${DIM}Fix: replace bare tokens with --color-* equivalents. Example:${RESET}
${RED}-${RESET} background: hsl(var(--muted));
${GREEN}+${RESET} background: hsl(var(--color-muted));
${DIM}Or for shadcn-style inline fallbacks:${RESET}
${RED}-${RESET} color: var(--foreground, #111);
${GREEN}+${RESET} color: hsl(var(--color-foreground));
${DIM}See packages/shared-tailwind/src/themes.css for the full token list.${RESET}
`
);
process.exit(1);