// Path: Frontend/src/theme/schema.ts export const THEME_SCHEMA_VERSION = "1.0.0"; export const THEME_MODE_NAMES = ["light", "dark"] as const; export const THEME_PALETTE_KEYS = { gray: ["0", "50", "100", "200", "300", "400", "500", "600", "700", "800", "900"], blue: ["400", "500", "600"], green: ["500"], red: ["500"], amber: ["500"], } as const; export const THEME_SPACE_KEYS = ["1", "2", "3", "4", "5", "6", "8", "10", "12"] as const; export const THEME_RADIUS_KEYS = ["sm", "md", "lg", "xl", "pill"] as const; export const THEME_SIZE_KEYS = ["controlMd", "controlLg", "contentWidthWide", "blurOverlay"] as const; export const THEME_SHADOW_KEYS = ["soft", "strong"] as const; export const THEME_Z_INDEX_KEYS = ["base", "dropdown", "sticky", "overlay", "modal", "toast"] as const; export const THEME_MOTION_KEYS = ["durationFast", "durationBase", "durationSlow", "easeStandard"] as const; export const THEME_TYPE_SCALE_KEYS = ["caption", "label", "body", "title", "heading", "display"] as const; export const THEME_FONT_FAMILY_KEYS = ["sans", "heading", "display", "serif", "mono"] as const; export const THEME_MODE_COLOR_KEYS = ["canvas", "surface", "surfaceMuted", "surfaceHover", "border", "borderStrong", "text", "textMuted", "accent", "accentStrong", "accentSoft", "accentContrast", "primaryOne", "primaryTwo", "primaryThree", "success", "danger", "warning", "focusRing"] as const; export type ThemeModeName = (typeof THEME_MODE_NAMES)[number]; type ThemeTokenMap = Record; export type ThemeSharedTokens = { palette: { gray: ThemeTokenMap<(typeof THEME_PALETTE_KEYS.gray)[number]>; blue: ThemeTokenMap<(typeof THEME_PALETTE_KEYS.blue)[number]>; green: ThemeTokenMap<(typeof THEME_PALETTE_KEYS.green)[number]>; red: ThemeTokenMap<(typeof THEME_PALETTE_KEYS.red)[number]>; amber: ThemeTokenMap<(typeof THEME_PALETTE_KEYS.amber)[number]>; }; space: ThemeTokenMap<(typeof THEME_SPACE_KEYS)[number]>; radius: ThemeTokenMap<(typeof THEME_RADIUS_KEYS)[number]>; size: ThemeTokenMap<(typeof THEME_SIZE_KEYS)[number]>; shadow: ThemeTokenMap<(typeof THEME_SHADOW_KEYS)[number]>; zIndex: ThemeTokenMap<(typeof THEME_Z_INDEX_KEYS)[number]>; motion: ThemeTokenMap<(typeof THEME_MOTION_KEYS)[number]>; typography: { fontFamily: ThemeTokenMap<(typeof THEME_FONT_FAMILY_KEYS)[number]>; fontSize: ThemeTokenMap<(typeof THEME_TYPE_SCALE_KEYS)[number]>; lineHeight: ThemeTokenMap<(typeof THEME_TYPE_SCALE_KEYS)[number]>; fontWeight: ThemeTokenMap<(typeof THEME_TYPE_SCALE_KEYS)[number]>; letterSpacing: ThemeTokenMap<(typeof THEME_TYPE_SCALE_KEYS)[number]>; }; }; export type ThemeModeTokens = { colorScheme: ThemeModeName; colors: ThemeTokenMap<(typeof THEME_MODE_COLOR_KEYS)[number]>; shadow?: Partial>; }; export type ThemeDefinition = { schemaVersion: typeof THEME_SCHEMA_VERSION; id: string; name: string; description?: string; author?: string; tokens: { shared: ThemeSharedTokens; modes: Record; }; }; export type ThemeValidationResult = | { success: true; data: ThemeDefinition; } | { success: false; errors: string[]; }; const isRecord = (value: unknown): value is Record => typeof value === "object" && value !== null && !Array.isArray(value); const isNonEmptyString = (value: unknown): value is string => typeof value === "string" && value.trim().length > 0; const getNestedRecord = (value: unknown, path: string, errors: string[]): Record | null => { if (!isRecord(value)) { errors.push(`${path} must be an object.`); return null; } return value; }; const validateRequiredStringMap = (record: Record, path: string, requiredKeys: readonly string[], errors: string[]): void => { for (const key of requiredKeys) { if (!isNonEmptyString(record[key])) { errors.push(`${path}.${key} must be a non-empty string token value.`); } } }; export const isThemeModeName = (value: unknown): value is ThemeModeName => typeof value === "string" && THEME_MODE_NAMES.includes(value as ThemeModeName); export const validateThemeDefinition = (candidate: unknown): ThemeValidationResult => { const errors: string[] = []; const root = getNestedRecord(candidate, "theme", errors); if (!root) { return { success: false, errors }; } if (root.schemaVersion !== THEME_SCHEMA_VERSION) { errors.push(`theme.schemaVersion must be ${THEME_SCHEMA_VERSION}.`); } if (!isNonEmptyString(root.id)) { errors.push("theme.id must be a non-empty string."); } if (!isNonEmptyString(root.name)) { errors.push("theme.name must be a non-empty string."); } if (root.description !== undefined && !isNonEmptyString(root.description)) { errors.push("theme.description must be a non-empty string when provided."); } if (root.author !== undefined && !isNonEmptyString(root.author)) { errors.push("theme.author must be a non-empty string when provided."); } const tokens = getNestedRecord(root.tokens, "theme.tokens", errors); const shared = tokens ? getNestedRecord(tokens.shared, "theme.tokens.shared", errors) : null; const modes = tokens ? getNestedRecord(tokens.modes, "theme.tokens.modes", errors) : null; if (shared) { const palette = getNestedRecord(shared.palette, "theme.tokens.shared.palette", errors); const space = getNestedRecord(shared.space, "theme.tokens.shared.space", errors); const radius = getNestedRecord(shared.radius, "theme.tokens.shared.radius", errors); const size = getNestedRecord(shared.size, "theme.tokens.shared.size", errors); const shadow = getNestedRecord(shared.shadow, "theme.tokens.shared.shadow", errors); const zIndex = getNestedRecord(shared.zIndex, "theme.tokens.shared.zIndex", errors); const motion = getNestedRecord(shared.motion, "theme.tokens.shared.motion", errors); const typography = getNestedRecord(shared.typography, "theme.tokens.shared.typography", errors); if (palette) { for (const [paletteName, keys] of Object.entries(THEME_PALETTE_KEYS)) { const paletteScale = getNestedRecord(palette[paletteName], `theme.tokens.shared.palette.${paletteName}`, errors); if (paletteScale) { validateRequiredStringMap(paletteScale, `theme.tokens.shared.palette.${paletteName}`, keys, errors); } } } if (space) { validateRequiredStringMap(space, "theme.tokens.shared.space", THEME_SPACE_KEYS, errors); } if (radius) { validateRequiredStringMap(radius, "theme.tokens.shared.radius", THEME_RADIUS_KEYS, errors); } if (size) { validateRequiredStringMap(size, "theme.tokens.shared.size", THEME_SIZE_KEYS, errors); } if (shadow) { validateRequiredStringMap(shadow, "theme.tokens.shared.shadow", THEME_SHADOW_KEYS, errors); } if (zIndex) { validateRequiredStringMap(zIndex, "theme.tokens.shared.zIndex", THEME_Z_INDEX_KEYS, errors); } if (motion) { validateRequiredStringMap(motion, "theme.tokens.shared.motion", THEME_MOTION_KEYS, errors); } if (typography) { const fontFamily = getNestedRecord(typography.fontFamily, "theme.tokens.shared.typography.fontFamily", errors); const fontSize = getNestedRecord(typography.fontSize, "theme.tokens.shared.typography.fontSize", errors); const lineHeight = getNestedRecord(typography.lineHeight, "theme.tokens.shared.typography.lineHeight", errors); const fontWeight = getNestedRecord(typography.fontWeight, "theme.tokens.shared.typography.fontWeight", errors); const letterSpacing = getNestedRecord(typography.letterSpacing, "theme.tokens.shared.typography.letterSpacing", errors); if (fontFamily) { validateRequiredStringMap(fontFamily, "theme.tokens.shared.typography.fontFamily", THEME_FONT_FAMILY_KEYS, errors); } if (fontSize) { validateRequiredStringMap(fontSize, "theme.tokens.shared.typography.fontSize", THEME_TYPE_SCALE_KEYS, errors); } if (lineHeight) { validateRequiredStringMap(lineHeight, "theme.tokens.shared.typography.lineHeight", THEME_TYPE_SCALE_KEYS, errors); } if (fontWeight) { validateRequiredStringMap(fontWeight, "theme.tokens.shared.typography.fontWeight", THEME_TYPE_SCALE_KEYS, errors); } if (letterSpacing) { validateRequiredStringMap(letterSpacing, "theme.tokens.shared.typography.letterSpacing", THEME_TYPE_SCALE_KEYS, errors); } } } if (modes) { for (const modeName of THEME_MODE_NAMES) { const mode = getNestedRecord(modes[modeName], `theme.tokens.modes.${modeName}`, errors); if (!mode) { continue; } if (mode.colorScheme !== modeName) { errors.push(`theme.tokens.modes.${modeName}.colorScheme must be ${modeName}.`); } const colors = getNestedRecord(mode.colors, `theme.tokens.modes.${modeName}.colors`, errors); if (colors) { validateRequiredStringMap(colors, `theme.tokens.modes.${modeName}.colors`, THEME_MODE_COLOR_KEYS, errors); } if (mode.shadow !== undefined) { const modeShadow = getNestedRecord(mode.shadow, `theme.tokens.modes.${modeName}.shadow`, errors); if (modeShadow) { for (const key of THEME_SHADOW_KEYS) { if (modeShadow[key] !== undefined && !isNonEmptyString(modeShadow[key])) { errors.push(`theme.tokens.modes.${modeName}.shadow.${key} must be a non-empty string token value when provided.`); } } } } } } if (errors.length > 0) { return { success: false, errors }; } return { success: true, data: candidate as ThemeDefinition }; }; export const createCssVariableMap = (theme: ThemeDefinition, mode: ThemeModeName): Record => { const shared = theme.tokens.shared; const modeTokens = theme.tokens.modes[mode]; return { "--gray-0": shared.palette.gray["0"], "--gray-50": shared.palette.gray["50"], "--gray-100": shared.palette.gray["100"], "--gray-200": shared.palette.gray["200"], "--gray-300": shared.palette.gray["300"], "--gray-400": shared.palette.gray["400"], "--gray-500": shared.palette.gray["500"], "--gray-600": shared.palette.gray["600"], "--gray-700": shared.palette.gray["700"], "--gray-800": shared.palette.gray["800"], "--gray-900": shared.palette.gray["900"], "--blue-400": shared.palette.blue["400"], "--blue-500": shared.palette.blue["500"], "--blue-600": shared.palette.blue["600"], "--green-500": shared.palette.green["500"], "--red-500": shared.palette.red["500"], "--amber-500": shared.palette.amber["500"], "--space-1": shared.space["1"], "--space-2": shared.space["2"], "--space-3": shared.space["3"], "--space-4": shared.space["4"], "--space-5": shared.space["5"], "--space-6": shared.space["6"], "--space-8": shared.space["8"], "--space-10": shared.space["10"], "--space-12": shared.space["12"], "--radius-sm": shared.radius.sm, "--radius-md": shared.radius.md, "--radius-lg": shared.radius.lg, "--radius-xl": shared.radius.xl, "--radius-pill": shared.radius.pill, "--control-size-md": shared.size.controlMd, "--control-size-lg": shared.size.controlLg, "--content-width-wide": shared.size.contentWidthWide, "--blur-overlay": shared.size.blurOverlay, "--shadow-soft": modeTokens.shadow?.soft ?? shared.shadow.soft, "--shadow-strong": modeTokens.shadow?.strong ?? shared.shadow.strong, "--z-base": shared.zIndex.base, "--z-dropdown": shared.zIndex.dropdown, "--z-sticky": shared.zIndex.sticky, "--z-overlay": shared.zIndex.overlay, "--z-modal": shared.zIndex.modal, "--z-toast": shared.zIndex.toast, "--motion-duration-fast": shared.motion.durationFast, "--motion-duration-base": shared.motion.durationBase, "--motion-duration-slow": shared.motion.durationSlow, "--motion-ease-standard": shared.motion.easeStandard, "--font-family-sans": shared.typography.fontFamily.sans, "--font-family-heading": shared.typography.fontFamily.heading, "--font-family-display": shared.typography.fontFamily.display, "--font-family-serif": shared.typography.fontFamily.serif, "--font-family-mono": shared.typography.fontFamily.mono, "--font-size-caption": shared.typography.fontSize.caption, "--font-size-label": shared.typography.fontSize.label, "--font-size-body": shared.typography.fontSize.body, "--font-size-title": shared.typography.fontSize.title, "--font-size-heading": shared.typography.fontSize.heading, "--font-size-display": shared.typography.fontSize.display, "--line-height-caption": shared.typography.lineHeight.caption, "--line-height-label": shared.typography.lineHeight.label, "--line-height-body": shared.typography.lineHeight.body, "--line-height-title": shared.typography.lineHeight.title, "--line-height-heading": shared.typography.lineHeight.heading, "--line-height-display": shared.typography.lineHeight.display, "--font-weight-caption": shared.typography.fontWeight.caption, "--font-weight-label": shared.typography.fontWeight.label, "--font-weight-body": shared.typography.fontWeight.body, "--font-weight-title": shared.typography.fontWeight.title, "--font-weight-heading": shared.typography.fontWeight.heading, "--font-weight-display": shared.typography.fontWeight.display, "--letter-spacing-caption": shared.typography.letterSpacing.caption, "--letter-spacing-label": shared.typography.letterSpacing.label, "--letter-spacing-body": shared.typography.letterSpacing.body, "--letter-spacing-title": shared.typography.letterSpacing.title, "--letter-spacing-heading": shared.typography.letterSpacing.heading, "--letter-spacing-display": shared.typography.letterSpacing.display, "--color-canvas": modeTokens.colors.canvas, "--color-surface": modeTokens.colors.surface, "--color-surface-muted": modeTokens.colors.surfaceMuted, "--color-surface-hover": modeTokens.colors.surfaceHover, "--color-border": modeTokens.colors.border, "--color-border-strong": modeTokens.colors.borderStrong, "--color-text": modeTokens.colors.text, "--color-text-muted": modeTokens.colors.textMuted, "--color-accent": modeTokens.colors.accent, "--color-accent-strong": modeTokens.colors.accentStrong, "--color-accent-soft": modeTokens.colors.accentSoft, "--color-accent-contrast": modeTokens.colors.accentContrast, "--color-primary-1": modeTokens.colors.primaryOne, "--color-primary-2": modeTokens.colors.primaryTwo, "--color-primary-3": modeTokens.colors.primaryThree, "--color-success": modeTokens.colors.success, "--color-danger": modeTokens.colors.danger, "--color-warning": modeTokens.colors.warning, "--color-focus-ring": modeTokens.colors.focusRing, }; };