mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-15 01:41:08 +02:00
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>
169 lines
5.8 KiB
JavaScript
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();
|