mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 21:01:08 +02:00
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>
This commit is contained in:
parent
db2023a77f
commit
7d6a340b13
44 changed files with 747 additions and 460 deletions
184
scripts/migrate-theme-tokens.mjs
Normal file
184
scripts/migrate-theme-tokens.mjs
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
#!/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();
|
||||
|
|
@ -1,30 +1,42 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* Validate that module ListView.svelte files use theme tokens instead of
|
||||
* raw white-alpha Tailwind utilities.
|
||||
* 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-white/5`, `border-white/10` ignore the active theme and can render
|
||||
* white-on-white in light variants. The theme tokens (`text-foreground`,
|
||||
* `bg-muted`, `border-border`, etc.) are the canonical replacements —
|
||||
* they're generated by Tailwind v4 from `packages/shared-tailwind/src/themes.css`
|
||||
* `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.
|
||||
*
|
||||
* Rule: `src/lib/modules/**\/ListView.svelte` must not contain
|
||||
* - `bg-white/` (e.g. bg-white/5, bg-white/10, hover:bg-white/5)
|
||||
* - `text-white/` (e.g. text-white/80, hover:text-white/90)
|
||||
* - `border-white/` (e.g. border-white/10, focus:border-white/20)
|
||||
* Rules (enforced across `apps/mana/apps/web/src/lib/modules/**` and
|
||||
* `apps/mana/apps/web/src/routes/(app)/**`):
|
||||
*
|
||||
* Suggested replacements:
|
||||
* 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
|
||||
* 1. No white-alpha utilities:
|
||||
* `bg-white/N`, `text-white/N`, `border-white/N` (and hover:/focus:/etc. variants)
|
||||
*
|
||||
* `text-white` without an opacity modifier is allowed when it sits on a
|
||||
* guaranteed-dark surface (mood gradient overlays, photo viewer) — those
|
||||
* are brand literals per the themes.css policy.
|
||||
* 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.
|
||||
|
|
@ -38,14 +50,41 @@ import { dirname, join } from 'node:path';
|
|||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const REPO_ROOT = join(__dirname, '..');
|
||||
|
||||
const LIST_VIEW_GLOB = 'apps/mana/apps/web/src/lib/modules/*/ListView.svelte';
|
||||
const SCAN_GLOBS = [
|
||||
'apps/mana/apps/web/src/lib/modules/**/*.svelte',
|
||||
'apps/mana/apps/web/src/routes/(app)/**/*.svelte',
|
||||
];
|
||||
|
||||
// `\b` before ensures we catch `hover:bg-white/`, `focus:border-white/`,
|
||||
// etc., without matching unrelated class names like `off-white`.
|
||||
const FORBIDDEN = /(?:^|[\s:"'`])(bg|text|border)-white\//g;
|
||||
/**
|
||||
* 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',
|
||||
]);
|
||||
|
||||
function listListViews() {
|
||||
const out = execSync(`git ls-files "${LIST_VIEW_GLOB}"`, {
|
||||
// `(?:^|[\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',
|
||||
});
|
||||
|
|
@ -56,7 +95,7 @@ function listListViews() {
|
|||
}
|
||||
|
||||
function validate() {
|
||||
const paths = listListViews();
|
||||
const paths = listFiles();
|
||||
const violations = [];
|
||||
|
||||
for (const rel of paths) {
|
||||
|
|
@ -64,40 +103,60 @@ function validate() {
|
|||
const src = readFileSync(abs, 'utf8');
|
||||
const lines = src.split('\n');
|
||||
|
||||
const brandOverlay = BRAND_OVERLAY_FILES.has(rel);
|
||||
lines.forEach((line, i) => {
|
||||
FORBIDDEN.lastIndex = 0;
|
||||
let match;
|
||||
while ((match = FORBIDDEN.exec(line)) !== null) {
|
||||
violations.push({
|
||||
file: rel,
|
||||
line: i + 1,
|
||||
token: `${match[1]}-white/`,
|
||||
snippet: line.trim().slice(0, 120),
|
||||
});
|
||||
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) {
|
||||
console.error(`\n✗ Theme-token check FAILED (${violations.length} violation(s)):\n`);
|
||||
// Group by file for readability.
|
||||
const byFile = new Map();
|
||||
for (const v of violations) {
|
||||
console.error(` • ${v.file}:${v.line} [${v.token}]`);
|
||||
console.error(` ${v.snippet}`);
|
||||
if (!byFile.has(v.file)) byFile.set(v.file, []);
|
||||
byFile.get(v.file).push(v);
|
||||
}
|
||||
console.error(
|
||||
`\nReplace raw white-alpha utilities 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\n` +
|
||||
`Rationale: raw white utilities ignore theme variants (Lume, Nature, ...)\n` +
|
||||
`and can render white-on-white under light themes. See\n` +
|
||||
`packages/shared-tailwind/src/themes.css for the full token set.\n`
|
||||
`\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} ListView(s) use theme tokens correctly.`);
|
||||
console.log(`✓ Theme-token check: ${paths.length} files use theme tokens correctly.`);
|
||||
}
|
||||
|
||||
validate();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue