mirror of
https://github.com/jhd3197/ServerKit.git
synced 2026-10-29 16:17:25 +09:00
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>
102 lines
5.2 KiB
JavaScript
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.');
|