managarten/scripts/validate-theme-tokens.mjs
Till JS 7d6a340b13 refactor(theming): migrate remaining 738 token violations across routes + components
Expand validate-theme-tokens.mjs scope from ListViews only to all
lib/modules/**/*.svelte and routes/(app)/**/*.svelte. Add a second rule
banning the neutral Tailwind palette (gray/slate/zinc/neutral/stone-N)
— these should be theme tokens (bg-card, bg-muted, text-foreground,
text-muted-foreground, border-border) instead.

Apply one-shot codemod (scripts/migrate-theme-tokens.mjs) that
replaces:
  bg-gray-800/900        → bg-card
  bg-gray-600/700        → bg-muted (with opacity preserved)
  border-gray-600..900   → border-border
  text-gray-800/900      → text-foreground
  text-gray-300          → text-foreground/90
  text-gray-400/500/700  → text-muted-foreground
  placeholder-gray-*     → placeholder:text-muted-foreground/60
  bg/border-white/N      → bg-muted/N, border-border/N
  text-white/70-90       → text-foreground
  text-white/40-60       → text-muted-foreground
  text-white/10-30       → text-muted-foreground/70

42 files touched; biggest: presi/deck/[id] (91 subs), uload/analytics
(58), uload/+page (53), presi/+page (47), who/PlayView (35),
skilltree/Edit+AddXpModal (28 each), context/* (115 across 4 pages),
uload/links+tags (50 across 2).

Brand-literal overlays in moodlit/components/mood/{MoodFullscreen,
MoodCard,CreateMoodDialog}.svelte stay unmigrated — they render on
vivid colour gradients. Validator exempts these 3 files from the
white-alpha rule; they still obey the neutral-palette rule.

Result: 527 files pass validate:theme-tokens; svelte-check clean with
0 errors.

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

162 lines
5.6 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);
const src = readFileSync(abs, 'utf8');
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();