managarten/scripts/migrate-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

184 lines
7.5 KiB
JavaScript

#!/usr/bin/env node
/**
* One-shot migration: replace raw Tailwind neutral-palette + white-alpha
* utilities with theme tokens across the unified Mana web app.
*
* This is a surgical codemod, not a general-purpose tool. The mappings
* encode a specific design decision: `bg-gray-800` = `bg-card`, etc.
* Re-running is a no-op once the codebase is clean.
*
* Usage:
* node scripts/migrate-theme-tokens.mjs [--dry-run]
*
* The mappings are ordered by specificity — longer patterns first so
* `bg-gray-700/50` is tried before `bg-gray-700`.
*/
import { execSync } from 'node:child_process';
import { readFileSync, writeFileSync } 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 DRY_RUN = process.argv.includes('--dry-run');
const SCAN_GLOBS = [
'apps/mana/apps/web/src/lib/modules/**/*.svelte',
'apps/mana/apps/web/src/routes/(app)/**/*.svelte',
];
/**
* Files where `bg-white/N`, `text-white/N`, etc. are brand-literal overlays
* on vivid gradient backgrounds, not theme-intent. These stay untouched
* and are allowlisted in the validator via scoped <style> migration.
*/
const EXCLUDE_PATHS = 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',
]);
/**
* Each entry: [regex, replacement]. Regex MUST use `\b` or class-boundary
* anchors so we don't accidentally rewrite inside longer identifiers.
* The boundary pattern `(?<=^|[\s:"'\`])` ensures the match starts at a
* class boundary (space, colon for variant prefixes, quote char).
*/
const B = String.raw`(?<=^|[\s:"'\`])`; // class-boundary lookbehind
const MAPPINGS = [
// ─── Backgrounds: surfaces ────────────────────────────────────
// Dark modals/cards use gray-800/900 as surface.
[new RegExp(`${B}bg-gray-900\\b`, 'g'), 'bg-card'],
[new RegExp(`${B}bg-gray-800\\b`, 'g'), 'bg-card'],
[new RegExp(`${B}bg-neutral-900\\b`, 'g'), 'bg-card'],
[new RegExp(`${B}bg-neutral-800\\b`, 'g'), 'bg-card'],
[new RegExp(`${B}bg-slate-900\\b`, 'g'), 'bg-card'],
[new RegExp(`${B}bg-slate-800\\b`, 'g'), 'bg-card'],
[new RegExp(`${B}bg-zinc-900\\b`, 'g'), 'bg-card'],
[new RegExp(`${B}bg-zinc-800\\b`, 'g'), 'bg-card'],
[new RegExp(`${B}bg-stone-900\\b`, 'g'), 'bg-card'],
[new RegExp(`${B}bg-stone-800\\b`, 'g'), 'bg-card'],
// Mid-grays are muted surfaces. Preserve opacity suffix.
[new RegExp(`${B}bg-gray-(?:700|600)\\/(\\d+)\\b`, 'g'), 'bg-muted/$1'],
[new RegExp(`${B}bg-gray-(?:700|600)\\b`, 'g'), 'bg-muted'],
[new RegExp(`${B}bg-neutral-(?:700|600)\\/(\\d+)\\b`, 'g'), 'bg-muted/$1'],
[new RegExp(`${B}bg-neutral-(?:700|600)\\b`, 'g'), 'bg-muted'],
[new RegExp(`${B}bg-slate-(?:700|600)\\/(\\d+)\\b`, 'g'), 'bg-muted/$1'],
[new RegExp(`${B}bg-slate-(?:700|600)\\b`, 'g'), 'bg-muted'],
[new RegExp(`${B}bg-zinc-(?:700|600)\\/(\\d+)\\b`, 'g'), 'bg-muted/$1'],
[new RegExp(`${B}bg-zinc-(?:700|600)\\b`, 'g'), 'bg-muted'],
[new RegExp(`${B}bg-stone-(?:700|600)\\/(\\d+)\\b`, 'g'), 'bg-muted/$1'],
[new RegExp(`${B}bg-stone-(?:700|600)\\b`, 'g'), 'bg-muted'],
// Light grays 100-500 — same bucket (rarely used in dark-first design).
[
new RegExp(`${B}bg-(?:gray|slate|zinc|neutral|stone)-(?:500|400|300|200|100|50)\\b`, 'g'),
'bg-muted',
],
// ─── Borders ─────────────────────────────────────────────────
[
new RegExp(`${B}border-(?:gray|slate|zinc|neutral|stone)-(?:700|800|900)\\/(\\d+)\\b`, 'g'),
'border-border/$1',
],
[
new RegExp(`${B}border-(?:gray|slate|zinc|neutral|stone)-(?:700|800|900)\\b`, 'g'),
'border-border',
],
[new RegExp(`${B}border-(?:gray|slate|zinc|neutral|stone)-(?:600)\\b`, 'g'), 'border-border'],
[
new RegExp(`${B}border-(?:gray|slate|zinc|neutral|stone)-(?:500|400|300|200|100)\\b`, 'g'),
'border-border-strong',
],
// ─── Text ─────────────────────────────────────────────────────
// Dark text on light bg (gray-900/800) = foreground.
[new RegExp(`${B}text-(?:gray|slate|zinc|neutral|stone)-(?:900|800)\\b`, 'g'), 'text-foreground'],
// text-gray-200/100 on dark bg = foreground
[new RegExp(`${B}text-(?:gray|slate|zinc|neutral|stone)-(?:200|100)\\b`, 'g'), 'text-foreground'],
// text-gray-300 = foreground/90 (slightly muted primary text)
[new RegExp(`${B}text-(?:gray|slate|zinc|neutral|stone)-300\\b`, 'g'), 'text-foreground/90'],
// text-gray-400/500/600/700 = muted-foreground (labels, captions)
[
new RegExp(`${B}text-(?:gray|slate|zinc|neutral|stone)-(?:400|500|700)\\b`, 'g'),
'text-muted-foreground',
],
[
new RegExp(`${B}text-(?:gray|slate|zinc|neutral|stone)-600\\b`, 'g'),
'text-muted-foreground/70',
],
// ─── Placeholders ────────────────────────────────────────────
[
new RegExp(`${B}placeholder-(?:gray|slate|zinc|neutral|stone)-(?:400|500|600)\\b`, 'g'),
'placeholder:text-muted-foreground/60',
],
[
new RegExp(`${B}placeholder:text-(?:gray|slate|zinc|neutral|stone)-(?:400|500|600)\\b`, 'g'),
'placeholder:text-muted-foreground/60',
],
// ─── Hover/Focus variants ────────────────────────────────────
// Handled uniformly because the boundary lookbehind already matches
// after `hover:`, `focus:`, `active:`, `group-hover:`, etc.
// ─── White-alpha utilities ───────────────────────────────────
// Preserve opacity modifier as given.
[new RegExp(`${B}bg-white\\/(\\d+)\\b`, 'g'), 'bg-muted/$1'],
[new RegExp(`${B}border-white\\/(\\d+)\\b`, 'g'), 'border-border/$1'],
// Text: /70+ = foreground, /30-60 = muted-foreground
[new RegExp(`${B}text-white\\/(?:90|80|70)\\b`, 'g'), 'text-foreground'],
[new RegExp(`${B}text-white\\/(?:60|50|40)\\b`, 'g'), 'text-muted-foreground'],
[new RegExp(`${B}text-white\\/(?:30|20|10)\\b`, 'g'), 'text-muted-foreground/70'],
];
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 migrate() {
const paths = listFiles();
let changedFiles = 0;
let totalSubs = 0;
for (const rel of paths) {
if (EXCLUDE_PATHS.has(rel)) continue;
const abs = join(REPO_ROOT, rel);
let src = readFileSync(abs, 'utf8');
let fileSubs = 0;
for (const [pattern, replacement] of MAPPINGS) {
const before = src;
src = src.replace(pattern, replacement);
if (src !== before) {
// Count replacements this rule made.
const matches = before.match(pattern);
fileSubs += matches ? matches.length : 0;
}
}
if (fileSubs > 0) {
changedFiles++;
totalSubs += fileSubs;
if (!DRY_RUN) writeFileSync(abs, src, 'utf8');
console.log(` ${fileSubs.toString().padStart(4)} subs ${rel}`);
}
}
const verb = DRY_RUN ? 'Would migrate' : 'Migrated';
console.log(`\n${verb} ${totalSubs} token(s) across ${changedFiles} file(s).`);
if (DRY_RUN) console.log('Run without --dry-run to apply.');
}
migrate();