Files
vendoo/app/core/themes/theme-contract.mjs
T

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 });
}