mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 19:21:10 +02:00
chore(lint): audit:theme-tokens guard against bare --muted / --theme-* drift
Three naming conventions had drifted through the monorepo (--muted, --theme-*,
--color-*). Only the last is defined in the Mana theme; the others silently
fell back to nothing and stopped tracking theme variants. Today's cleanup
migrated ~100 files, but nothing stopped the drift from creeping back.
- scripts/audit-theme-tokens.mjs scans ~3k source files and fails if any
references a bare shadcn token or a --theme-* prefix, with an allowlist
for known-literal module brand colors (news-research, agent templates)
- wire into pnpm script and lint-staged (runs once per commit touching
*.{svelte,css}, ignores per-file args)
- design-ux.md guideline: fix stale --color-destructive entry (Mana uses
--color-error), add explicit "never bare tokens" warning with examples
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
8a5d200c84
commit
fc028fa8f0
4 changed files with 246 additions and 2 deletions
|
|
@ -114,10 +114,32 @@ background: white;
|
|||
| `--color-muted-foreground` | Sekundärtext, Platzhalter |
|
||||
| `--color-surface` | Hintergründe |
|
||||
| `--color-border` | Rahmen, Trennlinien |
|
||||
| `--color-destructive` | Lösch-Aktionen, Fehler |
|
||||
| `--color-error` | Lösch-Aktionen, Fehler |
|
||||
| `--color-success` | Erfolgsmeldungen |
|
||||
| `--color-warning` | Warnungen |
|
||||
|
||||
### ⚠️ Nie bare shadcn-Tokens
|
||||
|
||||
Manche Komponenten aus shadcn- oder theme-Vorlagen sprechen bare Token-Namen
|
||||
an (`--muted`, `--primary`, `--theme-muted`). **Diese existieren im Mana-Theme
|
||||
nicht** und fallen stumm aus — das Theme-Wechseln wird nicht mehr mitgezogen.
|
||||
Immer den `--color-*`-Präfix nutzen:
|
||||
|
||||
```css
|
||||
/* Falsch */
|
||||
background: hsl(var(--muted));
|
||||
color: var(--foreground, #111);
|
||||
border: 1px solid var(--theme-border);
|
||||
|
||||
/* Richtig */
|
||||
background: hsl(var(--color-muted));
|
||||
color: hsl(var(--color-foreground));
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
```
|
||||
|
||||
Der Audit `pnpm audit:theme-tokens` (läuft auch in `lint-staged`) schlägt
|
||||
jeden Drift an.
|
||||
|
||||
### Dark Mode
|
||||
|
||||
Immer beide Modi berücksichtigen:
|
||||
|
|
|
|||
|
|
@ -4,6 +4,11 @@ export default {
|
|||
'prettier --config .prettierrc.json --write',
|
||||
],
|
||||
'*.{json,md,svelte,astro}': ['prettier --config .prettierrc.json --write'],
|
||||
// Theme-token audit whenever styles-bearing files change. Runs on the
|
||||
// whole tree (ignores staged filenames), scans ~3k files in <1s, and
|
||||
// fails if any file re-introduces bare --muted / --theme-* references
|
||||
// instead of Mana's canonical --color-* tokens.
|
||||
'*.{svelte,css}': () => 'node scripts/audit-theme-tokens.mjs',
|
||||
// Validate the tunnel config locally so a malformed ingress map can
|
||||
// never reach main. The validator runs entirely in node (no
|
||||
// cloudflared CLI dependency on the dev box) and catches the same
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@
|
|||
"audit:coupling": "node scripts/audit-module-coupling.mjs",
|
||||
"audit:complexity": "node scripts/audit-complexity.mjs",
|
||||
"audit:map": "node scripts/build-complexity-map.mjs",
|
||||
"audit:theme-tokens": "node scripts/audit-theme-tokens.mjs",
|
||||
"generate:dockerfiles": "node scripts/generate-dockerfiles.mjs",
|
||||
"setup:env": "node scripts/generate-env.mjs",
|
||||
"setup:secrets": "node scripts/setup-secrets.mjs",
|
||||
|
|
|
|||
216
scripts/audit-theme-tokens.mjs
Normal file
216
scripts/audit-theme-tokens.mjs
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Audit Theme Tokens
|
||||
*
|
||||
* The Mana theme system standardizes on `--color-*` CSS custom properties
|
||||
* (defined in `packages/shared-tailwind/src/themes.css`). Earlier, components
|
||||
* ported from shadcn drifted into bare names (`--muted`, `--primary`, …) and
|
||||
* `--theme-*` prefixes, neither of which exist in the theme. Those
|
||||
* references silently fell back to nothing (or to literal fallbacks) and
|
||||
* stopped tracking the active theme variant.
|
||||
*
|
||||
* This audit greps Svelte/CSS/TS source files for those legacy patterns
|
||||
* and fails if any remain. Run in CI and lint-staged so the drift can't
|
||||
* sneak back in.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/audit-theme-tokens.mjs
|
||||
*/
|
||||
|
||||
import { readdirSync, statSync, readFileSync } from 'fs';
|
||||
import { join, dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = join(__dirname, '..');
|
||||
|
||||
const RED = '\x1b[31m';
|
||||
const GREEN = '\x1b[32m';
|
||||
const YELLOW = '\x1b[33m';
|
||||
const DIM = '\x1b[2m';
|
||||
const RESET = '\x1b[0m';
|
||||
const BOLD = '\x1b[1m';
|
||||
|
||||
const SCAN_ROOTS = ['apps', 'packages'];
|
||||
const SCAN_EXTS = new Set(['.svelte', '.css', '.ts', '.tsx']);
|
||||
const SKIP_DIRS = new Set([
|
||||
'node_modules',
|
||||
'dist',
|
||||
'build',
|
||||
'.svelte-kit',
|
||||
'.next',
|
||||
'.turbo',
|
||||
'.vercel',
|
||||
'.vite',
|
||||
]);
|
||||
|
||||
// Shadcn-convention names that should be `--color-*` in this repo.
|
||||
const SHADCN_TOKENS = [
|
||||
'muted',
|
||||
'muted-foreground',
|
||||
'primary',
|
||||
'primary-foreground',
|
||||
'secondary',
|
||||
'secondary-foreground',
|
||||
'accent',
|
||||
'accent-foreground',
|
||||
'foreground',
|
||||
'background',
|
||||
'border',
|
||||
'border-strong',
|
||||
'card',
|
||||
'card-foreground',
|
||||
'popover',
|
||||
'popover-foreground',
|
||||
'destructive',
|
||||
'destructive-foreground',
|
||||
'input',
|
||||
'ring',
|
||||
'surface',
|
||||
'surface-hover',
|
||||
'surface-elevated',
|
||||
'error',
|
||||
'success',
|
||||
'warning',
|
||||
];
|
||||
|
||||
// Per-element inline custom properties and unrelated local vars that happen
|
||||
// to collide on a word. Keeping this list narrow — add entries with care.
|
||||
const ALLOWED_LOCAL_NAMES = new Set([
|
||||
'primary-color', // shared-auth-ui inline prop carrier
|
||||
'app-color',
|
||||
'btn-color',
|
||||
'tag-color',
|
||||
'content-bg',
|
||||
'ring-color',
|
||||
]);
|
||||
|
||||
// Files we know carry module-literal brand accents (see themes.css §4 —
|
||||
// brand/domain semantics stay as literals, not theme tokens).
|
||||
const ALLOWED_FILES = new Set([
|
||||
'apps/mana/apps/web/src/lib/modules/news-research/ListView.svelte',
|
||||
'apps/mana/apps/web/src/routes/(app)/news-research/+page.svelte',
|
||||
// Agent template colors are set per-element via style="--accent: ..."
|
||||
'apps/mana/apps/web/src/routes/(app)/agents/templates/+page.svelte',
|
||||
]);
|
||||
|
||||
const bareTokenRe = new RegExp(`var\\(--(${SHADCN_TOKENS.join('|')})\\s*[,)]`, 'g');
|
||||
const themePrefixRe = /var\(--theme-[a-z-]+\s*[,)]/g;
|
||||
|
||||
function* walk(dir) {
|
||||
let entries;
|
||||
try {
|
||||
entries = readdirSync(dir);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
for (const name of entries) {
|
||||
if (SKIP_DIRS.has(name)) continue;
|
||||
const full = join(dir, name);
|
||||
let st;
|
||||
try {
|
||||
st = statSync(full);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (st.isDirectory()) {
|
||||
yield* walk(full);
|
||||
} else if (SCAN_EXTS.has(name.slice(name.lastIndexOf('.')))) {
|
||||
yield full;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function* findViolations(filePath) {
|
||||
const rel = filePath.slice(ROOT.length + 1);
|
||||
if (ALLOWED_FILES.has(rel)) return;
|
||||
|
||||
let content;
|
||||
try {
|
||||
content = readFileSync(filePath, 'utf8');
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
const lines = content.split('\n');
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
|
||||
// Skip var(--color-*) — those are correct. We also skip the
|
||||
// redefinition block inside @theme in themes.css itself.
|
||||
for (const m of line.matchAll(bareTokenRe)) {
|
||||
const match = m[0];
|
||||
// Ignore if this is actually var(--color-X) with a shared suffix
|
||||
// (regex doesn't match because of the leading `--color-`, but be
|
||||
// doubly safe in case of future edits).
|
||||
if (match.startsWith('var(--color-')) continue;
|
||||
yield { line: i + 1, col: m.index + 1, text: line.trim(), kind: 'shadcn' };
|
||||
}
|
||||
|
||||
for (const m of line.matchAll(themePrefixRe)) {
|
||||
yield { line: i + 1, col: m.index + 1, text: line.trim(), kind: 'theme-prefix' };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let totalFiles = 0;
|
||||
let violationCount = 0;
|
||||
const byFile = new Map();
|
||||
|
||||
for (const sub of SCAN_ROOTS) {
|
||||
for (const file of walk(join(ROOT, sub))) {
|
||||
totalFiles++;
|
||||
const hits = [...findViolations(file)];
|
||||
if (hits.length === 0) continue;
|
||||
|
||||
// Second-level filter: ALLOWED_LOCAL_NAMES (collision guard).
|
||||
const filtered = hits.filter((h) => {
|
||||
if (h.kind !== 'shadcn') return true;
|
||||
const nameMatch = h.text.match(/var\(--([a-z-]+)/);
|
||||
if (!nameMatch) return true;
|
||||
return !ALLOWED_LOCAL_NAMES.has(nameMatch[1]);
|
||||
});
|
||||
if (filtered.length === 0) continue;
|
||||
|
||||
byFile.set(file.slice(ROOT.length + 1), filtered);
|
||||
violationCount += filtered.length;
|
||||
}
|
||||
}
|
||||
|
||||
if (violationCount === 0) {
|
||||
console.log(
|
||||
`${GREEN}✓${RESET} Theme tokens clean — scanned ${totalFiles} files, no bare ${BOLD}--muted${RESET}/${BOLD}--primary${RESET}/${BOLD}--theme-*${RESET} references.`
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`${RED}✗${RESET} Found ${BOLD}${violationCount}${RESET} theme-token violation${violationCount === 1 ? '' : 's'} across ${byFile.size} file${byFile.size === 1 ? '' : 's'}:\n`
|
||||
);
|
||||
|
||||
for (const [file, hits] of byFile) {
|
||||
console.log(`${BOLD}${file}${RESET}`);
|
||||
for (const h of hits) {
|
||||
const tag =
|
||||
h.kind === 'theme-prefix' ? `${YELLOW}theme-prefix${RESET}` : `${YELLOW}shadcn${RESET}`;
|
||||
console.log(` ${DIM}${h.line}:${h.col}${RESET} [${tag}] ${h.text}`);
|
||||
}
|
||||
console.log();
|
||||
}
|
||||
|
||||
console.log(
|
||||
`${DIM}Fix: replace bare tokens with --color-* equivalents. Example:${RESET}
|
||||
${RED}-${RESET} background: hsl(var(--muted));
|
||||
${GREEN}+${RESET} background: hsl(var(--color-muted));
|
||||
|
||||
${DIM}Or for shadcn-style inline fallbacks:${RESET}
|
||||
${RED}-${RESET} color: var(--foreground, #111);
|
||||
${GREEN}+${RESET} color: hsl(var(--color-foreground));
|
||||
|
||||
${DIM}See packages/shared-tailwind/src/themes.css for the full token list.${RESET}
|
||||
`
|
||||
);
|
||||
|
||||
process.exit(1);
|
||||
Loading…
Add table
Add a link
Reference in a new issue