170 lines
7.9 KiB
JavaScript
170 lines
7.9 KiB
JavaScript
import { readFileSync } from 'fs';
|
|
import { join } from 'path';
|
|
import { APP_ROOT } from '../../../lib/runtime-paths.mjs';
|
|
import { PlatformError } from '../../kernel/errors.mjs';
|
|
|
|
const source = JSON.parse(readFileSync(join(APP_ROOT, 'public', 'design-system', 'tokens', 'source.json'), 'utf8'));
|
|
const BUILTIN_THEME_IDS = new Set([...source.themes, 'system', 'inherit']);
|
|
const HEX_COLOR = /^#(?:[0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})$/i;
|
|
const RGB_COLOR = /^rgba?\(\s*(\d+(?:\.\d+)?)\s*,\s*(\d+(?:\.\d+)?)\s*,\s*(\d+(?:\.\d+)?)(?:\s*,\s*(0|1|0?\.\d+))?\s*\)$/i;
|
|
const DIMENSION = /^-?\d+(?:\.\d+)?px$/i;
|
|
const THEME_ID = /^[a-z][a-z0-9-]{2,39}$/;
|
|
|
|
function expandHex(value) {
|
|
const hex = value.replace('#', '');
|
|
if (hex.length === 3 || hex.length === 4) return hex.split('').map(char => char + char).join('');
|
|
return hex;
|
|
}
|
|
|
|
function parseColor(value) {
|
|
const raw = String(value || '').trim();
|
|
if (HEX_COLOR.test(raw)) {
|
|
const hex = expandHex(raw);
|
|
return [0, 2, 4].map(index => Number.parseInt(hex.slice(index, index + 2), 16));
|
|
}
|
|
const match = raw.match(RGB_COLOR);
|
|
if (!match) return null;
|
|
const channels = match.slice(1, 4).map(Number);
|
|
return channels.every(channel => Number.isFinite(channel) && channel >= 0 && channel <= 255) ? channels : null;
|
|
}
|
|
|
|
function validColor(value) {
|
|
if (!parseColor(value)) return false;
|
|
const match = String(value).trim().match(RGB_COLOR);
|
|
if (!match) return true;
|
|
const alpha = match[4] === undefined ? 1 : Number(match[4]);
|
|
return Number.isFinite(alpha) && alpha >= 0 && alpha <= 1;
|
|
}
|
|
|
|
function validateValue(token, value) {
|
|
const raw = String(value ?? '').trim();
|
|
if (token.type === 'color') return validColor(raw);
|
|
if (token.type === 'dimension') {
|
|
if (!DIMENSION.test(raw)) return false;
|
|
const numeric = Number.parseFloat(raw);
|
|
if (token.min !== undefined && numeric < Number(token.min)) return false;
|
|
if (token.max !== undefined && numeric > Number(token.max)) return false;
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function relativeLuminance(value) {
|
|
const rgb = parseColor(value);
|
|
if (!rgb) return null;
|
|
const linear = rgb.map(channel => {
|
|
const normalized = channel / 255;
|
|
return normalized <= 0.03928 ? normalized / 12.92 : ((normalized + 0.055) / 1.055) ** 2.4;
|
|
});
|
|
return 0.2126 * linear[0] + 0.7152 * linear[1] + 0.0722 * linear[2];
|
|
}
|
|
|
|
export function contrastRatio(foreground, background) {
|
|
const first = relativeLuminance(foreground);
|
|
const second = relativeLuminance(background);
|
|
if (first === null || second === null) return null;
|
|
const lighter = Math.max(first, second);
|
|
const darker = Math.min(first, second);
|
|
return (lighter + 0.05) / (darker + 0.05);
|
|
}
|
|
|
|
export function getBaseThemeValues(themeId = 'light') {
|
|
const base = source.themes.includes(themeId) ? themeId : 'light';
|
|
return Object.fromEntries(Object.entries(source.tokens).map(([name, token]) => [name, token[base]]));
|
|
}
|
|
|
|
export function resolveThemeValues({ baseTheme = 'light', values = {} } = {}) {
|
|
return Object.freeze({ ...getBaseThemeValues(baseTheme), ...values });
|
|
}
|
|
|
|
export function evaluateThemeAccessibility({ baseTheme = 'light', values = {} } = {}) {
|
|
const resolved = resolveThemeValues({ baseTheme, values });
|
|
const checks = [
|
|
['primary-on-canvas', 'color.ink', 'color.canvas', 4.5],
|
|
['primary-on-surface', 'color.ink', 'color.surface', 4.5],
|
|
['secondary-on-surface', 'color.inkSecondary', 'color.surface', 4.5],
|
|
['muted-on-surface', 'color.inkMuted', 'color.surface', 3],
|
|
['brand-on-surface', 'color.brand', 'color.surface', 3],
|
|
['danger-on-surface', 'color.danger', 'color.surface', 3],
|
|
['line-on-surface', 'color.lineStrong', 'color.surface', 3],
|
|
].map(([id, foregroundToken, backgroundToken, minimum]) => {
|
|
const ratio = contrastRatio(resolved[foregroundToken], resolved[backgroundToken]);
|
|
return {
|
|
id,
|
|
foregroundToken,
|
|
backgroundToken,
|
|
ratio: ratio === null ? null : Number(ratio.toFixed(2)),
|
|
minimum,
|
|
ok: ratio !== null && ratio >= minimum,
|
|
};
|
|
});
|
|
return Object.freeze({
|
|
ok: checks.every(check => check.ok),
|
|
checks: Object.freeze(checks),
|
|
failures: Object.freeze(checks.filter(check => !check.ok)),
|
|
});
|
|
}
|
|
|
|
export function getThemeContract() {
|
|
return {
|
|
schemaVersion: source.schemaVersion,
|
|
contractVersion: source.contractVersion,
|
|
themes: [...source.themes],
|
|
tokens: Object.fromEntries(Object.entries(source.tokens).map(([name, token]) => [name, {
|
|
css: token.css,
|
|
type: token.type,
|
|
customizable: Boolean(token.customizable),
|
|
label: token.label || name,
|
|
group: token.group || name.split('.')[0],
|
|
...(token.min !== undefined ? { min: token.min } : {}),
|
|
...(token.max !== undefined ? { max: token.max } : {}),
|
|
}])),
|
|
};
|
|
}
|
|
|
|
export function getBuiltinThemes() {
|
|
return source.themes.map(id => ({
|
|
id,
|
|
name: ({ light: 'Hell', dark: 'Dunkel', contrast: 'Hoher Kontrast' })[id] || id,
|
|
base_theme: id,
|
|
builtin: true,
|
|
values: {},
|
|
resolved_values: getBaseThemeValues(id),
|
|
accessibility: evaluateThemeAccessibility({ baseTheme: id }),
|
|
}));
|
|
}
|
|
|
|
export function validateThemeDefinition(theme, { allowExistingId = false } = {}) {
|
|
if (!theme || typeof theme !== 'object' || Array.isArray(theme)) {
|
|
throw new PlatformError('Theme-Definition ist ungültig.', { code: 'THEME_INVALID', status: 400, expose: true });
|
|
}
|
|
const id = String(theme.id || '').trim().toLowerCase();
|
|
const name = String(theme.name || '').trim();
|
|
const baseTheme = String(theme.base_theme || theme.baseTheme || 'light').trim().toLowerCase();
|
|
if (!THEME_ID.test(id) || BUILTIN_THEME_IDS.has(id)) throw new PlatformError('Theme-ID ist ungültig oder reserviert.', { code: 'THEME_ID_INVALID', status: 400, expose: true });
|
|
if (!allowExistingId && !id.startsWith('custom-')) throw new PlatformError('Eigene Theme-IDs müssen mit custom- beginnen.', { code: 'THEME_ID_PREFIX', status: 400, expose: true });
|
|
if (!name || name.length > 80) throw new PlatformError('Theme-Name ist ungültig.', { code: 'THEME_NAME_INVALID', status: 400, expose: true });
|
|
if (!source.themes.includes(baseTheme)) throw new PlatformError('Basis-Theme ist ungültig.', { code: 'THEME_BASE_INVALID', status: 400, expose: true });
|
|
const values = {};
|
|
for (const [tokenName, value] of Object.entries(theme.values || {})) {
|
|
const token = source.tokens[tokenName];
|
|
if (!token || !token.customizable) throw new PlatformError(`Token ist nicht anpassbar: ${tokenName}`, { code: 'THEME_TOKEN_FORBIDDEN', status: 400, expose: true });
|
|
if (!validateValue(token, value)) throw new PlatformError(`Ungültiger Wert für ${tokenName}.`, { code: 'THEME_VALUE_INVALID', status: 400, expose: true });
|
|
values[tokenName] = String(value).trim();
|
|
}
|
|
const accessibility = evaluateThemeAccessibility({ baseTheme, values });
|
|
return Object.freeze({ schemaVersion: 1, id, name, base_theme: baseTheme, values: Object.freeze(values), accessibility });
|
|
}
|
|
|
|
export function validateThemePreference(input, { customThemeExists = () => false } = {}) {
|
|
const themeId = String(input?.theme_id || input?.themeId || 'inherit').trim().toLowerCase();
|
|
const density = String(input?.density || 'comfortable').trim().toLowerCase();
|
|
const reducedMotion = String(input?.reduced_motion || input?.reducedMotion || 'system').trim().toLowerCase();
|
|
if (![...BUILTIN_THEME_IDS].includes(themeId) && !customThemeExists(themeId)) {
|
|
throw new PlatformError('Gewähltes Theme existiert nicht.', { code: 'THEME_NOT_FOUND', status: 404, expose: true });
|
|
}
|
|
if (!['compact', 'comfortable', 'spacious'].includes(density)) throw new PlatformError('UI-Dichte ist ungültig.', { code: 'THEME_DENSITY_INVALID', status: 400, expose: true });
|
|
if (!['system', 'reduce', 'allow'].includes(reducedMotion)) throw new PlatformError('Bewegungseinstellung ist ungültig.', { code: 'THEME_MOTION_INVALID', status: 400, expose: true });
|
|
return Object.freeze({ theme_id: themeId, density, reduced_motion: reducedMotion });
|
|
}
|