managarten/scripts/validate-theme-utilities.mjs
Till JS 52af8c0cec refactor(theming): migrate who semantic colours to theme tokens
PlayView used Tailwind palette classes for game-status feedback:

  bg-emerald-500/10 + text-emerald-300   (won)    → bg-success/10 + text-success
  bg-amber-500/10 + text-amber-300       (lost)   → bg-warning/10 + text-warning
  border-red-500/20 + bg-red-500/10 +
    text-red-300                         (error)  → border-error/20 + bg-error/10 + text-error
  placeholder-white/30 focus:border-purple-400/50 → placeholder:text-muted-foreground/60 focus:border-primary/50

Semantic status now tracks the theme (errors are red in dark, darker red
in light, etc.) instead of being fixed hex ramps.

The `bg-purple-500` / `bg-purple-500/30` / `hover:bg-purple-600` classes
on the user's chat bubble and submit buttons STAY — purple is the who
module's primary identity colour (historical-deck accent `#a855f7` is
semantically the same hue). Documented in brand-literals.md §who.

Also harden two validators against mid-rename states where git ls-files
returns paths that aren't on disk yet — both now skip unreadable files
instead of crashing the pre-commit hook (caught while migrating who).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 17:19:53 +02:00

169 lines
5.8 KiB
JavaScript

#!/usr/bin/env node
/**
* Validate that the unified Mana web app uses theme tokens instead of raw
* Tailwind utilities that bypass the theme system.
*
* Background: the unified Mana app supports multiple theme variants (Lume,
* Nature, Stone, Ocean, plus dark). Utilities like `text-white/80`,
* `bg-gray-800`, `border-neutral-700` ignore the active theme and can
* render white-on-white (or dark-on-dark) under the wrong variant. The
* theme tokens (`text-foreground`, `bg-muted`, `border-border`, etc.) are
* generated by Tailwind v4 from `packages/shared-tailwind/src/themes.css`
* and resolve per-theme automatically.
*
* Rules (enforced across `apps/mana/apps/web/src/lib/modules/**` and
* `apps/mana/apps/web/src/routes/(app)/**`):
*
* 1. No white-alpha utilities:
* `bg-white/N`, `text-white/N`, `border-white/N` (and hover:/focus:/etc. variants)
*
* 2. No neutral-palette utilities:
* `(bg|text|border)-(gray|slate|zinc|neutral|stone)-N` (with or without /N)
* — these should be theme tokens instead.
*
* Replacement cheat-sheet:
* bg-white/N → bg-muted/N or bg-card
* text-white → text-foreground
* text-white/40..60 → text-muted-foreground
* border-white/N → border-border
* bg-gray-800 → bg-card or bg-muted
* bg-neutral-900 → bg-card
* text-gray-400 → text-muted-foreground
* text-slate-300 → text-foreground/90
* border-gray-700 → border-border
*
* Brand-literal escape hatch: if a color must stay literal (overlay on a
* vivid gradient, photo viewer backdrop, domain-semantic palette), move
* it into a scoped <style> block — the validator only scans class
* attributes. See `lib/modules/moodlit/ListView.svelte:.mood-card:hover`
* for the pattern.
*
* Zero deps — runs as plain Node ESM. Uses `git ls-files` to respect
* .gitignore.
*/
import { execSync } from 'node:child_process';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const __dirname = dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = join(__dirname, '..');
const SCAN_GLOBS = [
'apps/mana/apps/web/src/lib/modules/**/*.svelte',
'apps/mana/apps/web/src/routes/(app)/**/*.svelte',
];
/**
* Files where white-alpha utilities are intentional brand literals —
* they render overlays on vivid mood-gradient backgrounds, not theme
* surfaces. Exempt from the `white/` rule but still must follow the
* neutral-palette rule.
*/
const BRAND_OVERLAY_FILES = new Set([
'apps/mana/apps/web/src/lib/modules/moodlit/components/mood/MoodFullscreen.svelte',
'apps/mana/apps/web/src/lib/modules/moodlit/components/mood/MoodCard.svelte',
'apps/mana/apps/web/src/lib/modules/moodlit/components/mood/CreateMoodDialog.svelte',
]);
// `(?:^|[\s:"'`])` anchors so `hover:bg-white/5` and class=`bg-white/10`
// both match, without tripping on unrelated substrings like `off-white`.
const RULES = [
{
name: 'white-alpha',
pattern: /(?:^|[\s:"'`])(bg|text|border)-white\/\d/g,
describe: (m) => `${m[1]}-white/`,
},
{
name: 'neutral-palette',
pattern: /(?:^|[\s:"'`])(bg|text|border)-(gray|slate|zinc|neutral|stone)-\d/g,
describe: (m) => `${m[1]}-${m[2]}-`,
},
];
function listFiles() {
const args = SCAN_GLOBS.map((g) => `"${g}"`).join(' ');
const out = execSync(`git ls-files ${args}`, {
cwd: REPO_ROOT,
encoding: 'utf8',
});
return out
.split('\n')
.map((p) => p.trim())
.filter(Boolean);
}
function validate() {
const paths = listFiles();
const violations = [];
for (const rel of paths) {
const abs = join(REPO_ROOT, rel);
// Skip files that git knows about but haven't landed on disk yet —
// common mid-rename/mid-move state in multi-terminal sessions.
let src;
try {
src = readFileSync(abs, 'utf8');
} catch {
continue;
}
const lines = src.split('\n');
const brandOverlay = BRAND_OVERLAY_FILES.has(rel);
lines.forEach((line, i) => {
for (const rule of RULES) {
if (brandOverlay && rule.name === 'white-alpha') continue;
rule.pattern.lastIndex = 0;
let match;
while ((match = rule.pattern.exec(line)) !== null) {
violations.push({
file: rel,
line: i + 1,
rule: rule.name,
token: rule.describe(match),
snippet: line.trim().slice(0, 120),
});
}
}
});
}
if (violations.length > 0) {
// Group by file for readability.
const byFile = new Map();
for (const v of violations) {
if (!byFile.has(v.file)) byFile.set(v.file, []);
byFile.get(v.file).push(v);
}
console.error(
`\n✗ Theme-token check FAILED (${violations.length} violation(s) in ${byFile.size} file(s)):\n`
);
for (const [file, vs] of byFile) {
console.error(` ${file} (${vs.length})`);
for (const v of vs.slice(0, 5)) {
console.error(` :${v.line} [${v.rule}: ${v.token}] ${v.snippet.slice(0, 80)}`);
}
if (vs.length > 5) console.error(` … +${vs.length - 5} more`);
}
console.error(
`\nReplace with theme tokens:\n` +
` bg-white/N → bg-muted/N or bg-card\n` +
` text-white → text-foreground\n` +
` text-white/40..60 → text-muted-foreground\n` +
` border-white/N → border-border\n` +
` bg-gray/slate/neutral-N → bg-muted / bg-card\n` +
` text-gray/slate-N → text-foreground / text-muted-foreground\n` +
` border-gray/slate-N → border-border\n\n` +
`Rationale: raw color utilities ignore theme variants (Lume, Nature, ...)\n` +
`and can render invisible under other themes. See\n` +
`packages/shared-tailwind/src/themes.css for the full token set.\n` +
`Brand-literal overlays may move into scoped <style> blocks.\n`
);
process.exit(1);
}
console.log(`✓ Theme-token check: ${paths.length} files use theme tokens correctly.`);
}
validate();