mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 18:41:08 +02:00
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.
186 lines
6.2 KiB
JavaScript
186 lines
6.2 KiB
JavaScript
#!/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();
|