managarten/scripts/validate-theme-utilities.mjs
Till JS 430aa30cbf 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>
2026-04-22 17:07:48 +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();