Files
Juan DenisandClaude Opus 4.8 0016b1ff22 feat(themes): theme gallery + runtime skins (plan 60 Phase 1)
A ServerKit theme is data, not code: a validated map of CSS custom-property
tokens. This lands the client-side foundation.

- themeTokens.js: canonical token whitelist, legacy-alias table, and strict
  value validators (reject url()/@/;/}/expression, type-check per group)
- applySkin.js: runtime skin applier via setProperty (injection-safe); dark/
  light resolution with stock-light fallback for dark-only themes
- ThemeContext: per-user skin selection (localStorage), live hover preview,
  and accent precedence workspace > user > skin > default
- 6 bundled seed themes (default/paper/nord-deep/gruvbox/phosphor/high-contrast)
- Theme Gallery in Settings > Appearance (instant apply, 4-swatch cards) +
  command-palette index entry
- hardcoded-hex audit: 3 exact token-value leaks fixed to var(--token)
- check-theme-tokens.mjs proving guard wired into the lint chain

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 23:55:19 -04:00

102 lines
5.2 KiB
JavaScript

#!/usr/bin/env node
// Proving guard for the themes platform (plan 60), wired into the frontend
// `lint` script (the frontend has no unit-test runner, so a lint-stage guard is
// the house-consistent check). Exercises the token whitelist + validators, the
// alias expansion, the dark/light skin resolution fallback, and asserts every
// bundled seed theme is valid. Dependency-free ESM.
import { readFileSync, readdirSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, resolve } from 'node:path';
const here = dirname(fileURLToPath(import.meta.url));
const root = resolve(here, '..');
const {
validateTokenValue,
sanitizeTokens,
expandAliases,
CANONICAL_TOKENS,
ALIAS_MAP,
} = await import(new URL('../src/data/themeTokens.js', import.meta.url));
const { resolveSkinTokens } = await import(new URL('../src/utils/applySkin.js', import.meta.url));
let failures = 0;
function check(name, cond) {
if (!cond) {
console.error(` ✖ ${name}`);
failures += 1;
}
}
// --- value validation: accepts good values ---
check('hex color accepted', validateTokenValue('--surface', '#101218') === '#101218');
check('rgba wash accepted', validateTokenValue('--green-bg', 'rgba(61,220,151,0.12)') !== null);
check('length accepted', validateTokenValue('--radius', '10px') === '10px');
check('font stack accepted', validateTokenValue('--mono', '"IBM Plex Mono", monospace') !== null);
check('shadow accepted', validateTokenValue('--shadow-md', '0 8px 24px -8px rgba(0,0,0,0.55)') !== null);
check('named color accepted', validateTokenValue('--text', 'white') === 'white');
// --- value validation: rejects malicious / malformed / out-of-whitelist ---
check('unknown token rejected', validateTokenValue('--evil', '#fff') === null);
check('url() rejected', validateTokenValue('--surface', 'url(https://x/y.png)') === null);
check('semicolon rejected', validateTokenValue('--surface', '#fff;color:red') === null);
check('brace rejected', validateTokenValue('--surface', '#fff}html{display:none') === null);
check('at-rule rejected', validateTokenValue('--surface', '@import x') === null);
check('expression() rejected', validateTokenValue('--surface', 'expression(alert(1))') === null);
check('non-color in color slot rejected', validateTokenValue('--surface', '10px') === null);
check('non-string rejected', validateTokenValue('--surface', 123) === null);
// --- sanitizeTokens drops unknown/invalid, keeps valid ---
const cleaned = sanitizeTokens({
'--surface': '#101218',
'--evil': 'url(x)',
'--radius': 'not-a-length',
'--text': '#e9ebf0',
});
check('sanitize keeps valid', cleaned['--surface'] === '#101218' && cleaned['--text'] === '#e9ebf0');
check('sanitize drops unknown', !('--evil' in cleaned));
check('sanitize drops invalid value', !('--radius' in cleaned));
// --- alias expansion fans canonical names out to legacy names ---
const expanded = expandAliases({ '--surface': '#111', '--text': '#eee', '--border': '#222' });
check('alias --surface -> --bg-card', expanded['--bg-card'] === '#111');
check('alias --text -> --text-primary', expanded['--text-primary'] === '#eee');
check('alias --border -> --border-default', expanded['--border-default'] === '#222');
check('canonical retained after expand', expanded['--surface'] === '#111');
// --- every alias target is NOT itself a canonical token (no self-collision) ---
const canonicalSet = new Set(CANONICAL_TOKENS);
for (const [token, aliases] of Object.entries(ALIAS_MAP)) {
check(`alias source ${token} is canonical`, canonicalSet.has(token));
for (const a of aliases) check(`alias target ${a} not canonical`, !canonicalSet.has(a));
}
// --- skin resolution + light fallback ---
const darkOnly = { tokens: { dark: { '--surface': '#111' } } };
check('dark resolves dark tokens', resolveSkinTokens(darkOnly, 'dark')?.['--surface'] === '#111');
check('light falls back to stock when no light tokens', resolveSkinTokens(darkOnly, 'light') === null);
const bothModes = { tokens: { dark: { '--surface': '#111' }, light: { '--surface': '#fff' } } };
check('light resolves light tokens', resolveSkinTokens(bothModes, 'light')?.['--surface'] === '#fff');
check('null theme resolves null', resolveSkinTokens(null, 'dark') === null);
// --- every bundled seed theme validates end-to-end ---
const themesDir = resolve(root, 'src/data/themes');
for (const file of readdirSync(themesDir).filter((f) => f.endsWith('.json'))) {
const theme = JSON.parse(readFileSync(resolve(themesDir, file), 'utf8'));
check(`${file}: has slug`, typeof theme.slug === 'string' && theme.slug.length > 0);
check(`${file}: preview has 4 swatches`, Array.isArray(theme.preview) && theme.preview.length === 4);
for (const mode of Object.keys(theme.tokens || {})) {
const raw = theme.tokens[mode];
const clean = sanitizeTokens(raw);
const dropped = Object.keys(raw).filter((k) => !(k in clean));
check(`${file}[${mode}]: all tokens valid (dropped: ${dropped.join(',') || 'none'})`, dropped.length === 0);
}
}
if (failures) {
console.error(`\n✖ theme-tokens check failed: ${failures} assertion(s)\n`);
process.exit(1);
}
console.log('✓ theme-tokens: validators, alias expansion, skin resolution, and all bundled themes pass.');