managarten/scripts/validate-theme-parity.mjs
Till JS ad3b99fe6d refactor(cards): Phase A + C — adopt @mana/shared-theme + per-app accent
Phase A — Cards joins the unified theme system:
- Drop placeholder --color-cards-* palette; app.css imports
  @mana/shared-tailwind/themes.css + sources.css.
- Remove hardcoded class="dark" from app.html; body uses
  bg-background text-foreground.
- New $lib/stores/theme.ts: createThemeStore({ appId: 'cards' }).
  ThemeToggle from @mana/shared-theme-ui in the header next to
  the streak chip.
- Sweep all neutral / red / emerald / amber / indigo utilities in
  apps/cards/apps/web/src to semantic tokens (560 substitutions
  across 19 files): bg-neutral-900 → bg-card, text-neutral-400 →
  text-muted-foreground, bg-red-500 → bg-error, etc. Domain
  literals kept (FSRS grade colors red/orange/green/blue, GitHub-
  violet PR-merged badge, marketplace-amber Buy button, admin-
  inbox category palette).
- Cards added to validate-theme-utilities scope so future drift
  fails CI.

Phase C — per-app accent token:
- New --color-app-accent in shared-tailwind/themes.css. Theme-
  agnostic (registered in validate-theme-parity's THEME_AGNOSTIC
  regex), so it stays the same across light/dark/lume/etc. Defaults
  to Mana indigo at :root.
- Cards layout writes 258 90% 66% (= #8b5cf6 violet, from
  MANA_APPS.cards.color) onto documentElement at boot via
  applyCardsAccent(). All Cards CTAs (Lernen, Abonnieren, Senden,
  links inside cloze cards) flow through bg-app-accent /
  text-app-accent now.

Net effect: Cards gets light/dark + 4 palette variants + a11y
toggles for free, and any future app can drop in by setting its
own --color-app-accent without touching shared-tailwind.
2026-05-08 01:54:16 +02:00

186 lines
6.2 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env node
/**
* Validate that every --color-* token defined for the theme is present in
* every variant block. A token defined only in :root (but missing from
* .dark, or from [data-theme="ocean"]) silently falls back to `inherit`
* or `undefined` under that variant, producing invisible text or
* miscoloured surfaces in ways that are easy to miss during dev.
*
* This is the parity invariant: if one variant has `--color-X`, every
* variant MUST have `--color-X`.
*
* Scope: the "canonical" token set is derived from the default :root
* block (after the base :root block for domain accents). Each
* theme-variant block is compared to it.
*
* Exclusions: `--color-branch-*` and `--color-mana` are declared once
* (intentionally theme-agnostic brand accents, see themes.css §Domain
* Accent Colors) and do not need per-variant definitions.
*
* Usage:
* node scripts/validate-theme-parity.mjs
*/
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 THEMES_CSS = join(REPO_ROOT, 'packages/shared-tailwind/src/themes.css');
/** Tokens defined once at :root that do NOT participate in parity. */
const THEME_AGNOSTIC = /^--color-(?:branch-|mana$|app-accent$)/;
/**
* Parse themes.css into selector → Set<tokenName>. A block starts at a
* line matching a selector followed by `{` and ends at the matching `}`.
* The base `@theme` / `@theme inline` blocks are skipped — they define
* Tailwind's utility generator, not the per-variant surfaces.
*/
function parseBlocks(src) {
const blocks = new Map();
const lines = src.split('\n');
let currentSelector = null;
let depth = 0;
let inBase = false; // true while inside @theme {} / @theme inline {}
for (const raw of lines) {
const line = raw.trim();
// Track @theme blocks so we skip their contents.
if (/^@theme\b/.test(line)) {
inBase = true;
if (line.includes('{')) depth++;
continue;
}
if (inBase) {
if (line.includes('{')) depth++;
if (line.includes('}')) {
depth--;
if (depth === 0) inBase = false;
}
continue;
}
// Look for block open: `selector {` or `selector,` continued.
if (!currentSelector) {
const selectorMatch = raw.match(
/^(:root(?:\.dark)?|\.dark|\[data-theme=['"](?:[a-z-]+)['"]\](?:\.dark)?|\.dark\[data-theme=['"](?:[a-z-]+)['"]\])\s*(?:,|\{)/
);
if (selectorMatch) {
// Normalize variant key so `.dark, :root.dark` both fold into "dark".
const sel = selectorMatch[1];
currentSelector = normalizeSelector(sel);
if (line.includes('{')) depth = 1;
continue;
}
// Comma-chained selector continuation: if a previous line had
// `selector,`, the next line is another selector that shares the
// same block.
continue;
}
// Inside a tracked block.
if (line.includes('{')) depth++;
if (line.includes('}')) {
depth--;
if (depth === 0) currentSelector = null;
continue;
}
// Token definition: `--color-X: value;`
const tokenMatch = line.match(/^(--color-[a-z0-9-]+)\s*:/);
if (tokenMatch && currentSelector) {
if (!blocks.has(currentSelector)) blocks.set(currentSelector, new Set());
blocks.get(currentSelector).add(tokenMatch[1]);
}
}
return blocks;
}
function normalizeSelector(sel) {
// `:root.dark` and `.dark` collapse to the same variant key.
if (sel === '.dark' || sel === ':root.dark') return 'dark';
const variantMatch = sel.match(/\[data-theme=['"]([a-z-]+)['"]\]/);
if (variantMatch) {
return sel.includes('.dark') ? `${variantMatch[1]}.dark` : variantMatch[1];
}
return sel;
}
/**
* Collapse blocks that share a canonical identity (e.g. `.dark` and
* `:root.dark` both represent "dark"; each per-variant block and its
* `.dark` counterpart stay separate).
*/
function mergeBlocks(blocks) {
const merged = new Map();
for (const [sel, tokens] of blocks) {
const existing = merged.get(sel) ?? new Set();
for (const t of tokens) existing.add(t);
merged.set(sel, existing);
}
return merged;
}
function validate() {
let src;
try {
src = readFileSync(THEMES_CSS, 'utf8');
} catch {
// themes.css not on disk (e.g. mid-rename). Skip silently so we
// don't block commits for files in transit.
console.log('✓ Theme parity: themes.css not readable — skipped.');
return;
}
const blocks = mergeBlocks(parseBlocks(src));
if (!blocks.has(':root')) {
console.error('✗ Could not find :root block in themes.css — parser broken?');
process.exit(2);
}
// Canonical token set = :root tokens, minus theme-agnostic ones.
const canonical = new Set([...blocks.get(':root')].filter((t) => !THEME_AGNOSTIC.test(t)));
const violations = [];
for (const [selector, tokens] of blocks) {
if (selector === ':root') continue;
const filtered = new Set([...tokens].filter((t) => !THEME_AGNOSTIC.test(t)));
const missing = [...canonical].filter((t) => !filtered.has(t));
const extra = [...filtered].filter((t) => !canonical.has(t));
if (missing.length > 0) violations.push({ selector, kind: 'missing', tokens: missing });
if (extra.length > 0) violations.push({ selector, kind: 'extra', tokens: extra });
}
if (violations.length > 0) {
console.error(`\n✗ Theme parity check FAILED (${violations.length} block-level issue(s)):\n`);
for (const v of violations) {
const marker = v.kind === 'missing' ? 'missing from' : 'extra in';
console.error(` ${v.tokens.length} token(s) ${marker} [${v.selector}]:`);
for (const t of v.tokens.slice(0, 10)) console.error(` - ${t}`);
if (v.tokens.length > 10) console.error(` … +${v.tokens.length - 10} more`);
}
console.error(
`\nEvery --color-* token must be defined in :root AND .dark AND every\n` +
`[data-theme="..."] block. A missing token silently falls back to\n` +
`inherit (or undefined), which produces invisible text in the affected\n` +
`variant. Theme-agnostic accents (--color-branch-*, --color-mana) are\n` +
`exempt. See packages/shared-tailwind/src/themes.css.\n`
);
process.exit(1);
}
const tokenCount = canonical.size;
const variantCount = blocks.size - 1; // minus :root
console.log(`✓ Theme parity: ${tokenCount} tokens × ${variantCount} variants — all aligned.`);
}
validate();