import { ASPECT_RATIO_KEYS, BORDER_COLOR_KEYS, BORDER_STYLE_KEYS, BORDER_WIDTH_KEYS, COLOR_ROLES, COLOR_ROLE_SLOTS, COLOR_SCALE_STEPS, CONTAINER_WIDTH_KEYS, CONTENT_COLOR_KEYS, CONTENT_WIDTH_KEYS, DENSITY_KEYS, DURATION_KEYS, EASE_KEYS, FOCUS_COLOR_KEYS, ICON_STROKE_WIDTH_KEYS, MOTION_DISTANCE_KEYS, MOTION_SCALE_KEYS, OPACITY_KEYS, RADIUS_KEYS, SHADOW_KEYS, SIZE_PRIMITIVE_KEYS, SURFACE_COLOR_KEYS, TEXT_SIZES, Z_INDEX_KEYS, type BorderPrimitiveSet, type ColorSemanticSet, type ColorRole, type ColorRoleDefinition, type ColorScale, type EidosCssContract, type EidosValidationIssue, type EidosValidationReport, type EidosConfig, type EidosConfigSnapshot, type RecipeTokenMap, type ThemeDefinition } from './config-types' import { createEidosCssContract } from './contract' import { createEidosConfigDocument as createEidosConfigDocumentEnvelope, parseEidosConfigDocument as parseEidosConfigDocumentEnvelope, readEidosConfigDocument as readEidosConfigDocumentEnvelope, serializeEidosConfigDocument as serializeEidosConfigDocumentEnvelope, type EidosConfigDocument } from './persistence' import { clonePlain, hasOwn, isPlainRecord } from './utils' import { EidosConfigValidationError } from '../errors' const COLOR_ROLE_SET = new Set(COLOR_ROLES) const ASPECT_RATIO_KEY_SET = new Set(ASPECT_RATIO_KEYS) const BORDER_WIDTH_KEY_SET = new Set(BORDER_WIDTH_KEYS) const BORDER_STYLE_KEY_SET = new Set(BORDER_STYLE_KEYS) const BORDER_COLOR_KEY_SET = new Set(BORDER_COLOR_KEYS) const CONTENT_COLOR_KEY_SET = new Set(CONTENT_COLOR_KEYS) const COLOR_ROLE_SLOT_SET = new Set(COLOR_ROLE_SLOTS) const COLOR_SCALE_STEP_SET = new Set(COLOR_SCALE_STEPS) const CONTAINER_WIDTH_KEY_SET = new Set(CONTAINER_WIDTH_KEYS) const CONTENT_WIDTH_KEY_SET = new Set(CONTENT_WIDTH_KEYS) const DENSITY_KEY_SET = new Set(DENSITY_KEYS) const DURATION_KEY_SET = new Set(DURATION_KEYS) const EASE_KEY_SET = new Set(EASE_KEYS) const FOCUS_COLOR_KEY_SET = new Set(FOCUS_COLOR_KEYS) const ICON_STROKE_WIDTH_KEY_SET = new Set(ICON_STROKE_WIDTH_KEYS) const MOTION_DISTANCE_KEY_SET = new Set(MOTION_DISTANCE_KEYS) const MOTION_SCALE_KEY_SET = new Set(MOTION_SCALE_KEYS) const OPACITY_KEY_SET = new Set(OPACITY_KEYS) const RADIUS_KEY_SET = new Set(RADIUS_KEYS) const SHADOW_KEY_SET = new Set(SHADOW_KEYS) const SIZE_PRIMITIVE_KEY_SET = new Set(SIZE_PRIMITIVE_KEYS) const SURFACE_COLOR_KEY_SET = new Set(SURFACE_COLOR_KEYS) const TEXT_SIZE_SET = new Set(TEXT_SIZES) const Z_INDEX_KEY_SET = new Set(Z_INDEX_KEYS) const RESPONSIVE_KEY_SET = new Set(['base', 'sm', 'md', 'lg', 'xl', 'xxl']) export function snapshotEidosConfig(options: EidosConfig): EidosConfigSnapshot { return clonePlain(options) } export function readEidosConfigFromDocument(document: unknown): EidosConfig { const config = snapshotEidosConfig(readEidosConfigDocumentEnvelope(document).options) assertValidEidosConfig(config) return config } export function parseEidosConfigFromJson(json: string): EidosConfig { const config = snapshotEidosConfig(parseEidosConfigDocumentEnvelope(json).options) assertValidEidosConfig(config) return config } export function createEidosConfigDocumentFromConfig(options: EidosConfig): EidosConfigDocument { assertValidEidosConfig(options) return createEidosConfigDocumentEnvelope(options) } export function serializeEidosConfig(options: EidosConfig): string { return serializeEidosConfigDocumentEnvelope(createEidosConfigDocumentFromConfig(options)) } export function validateEidosConfig(options: EidosConfig): EidosValidationReport { const issues: EidosValidationIssue[] = [] if (!validateEidosConfigShape(options, issues)) { return { ok: false, issues } } validateThemeKeys(options, issues) validateColorScales(options, issues) validateColorRoles(options, issues) validateColorSemanticGroups(options, issues) validateTypography(options, issues) validateSizePrimitives(options, issues) validateStaticPrimitives(options, issues) validateBorderPrimitives(options, issues) validateLayoutPrimitives(options, issues) validateDensityPrimitives(options, issues) validateMotionPrimitives(options, issues) validateIconPrimitives(options, issues) validateRecipeTokens(options, issues) validateRecordPrimitive( 'primitives.opacity', options.primitives.opacity, OPACITY_KEYS, OPACITY_KEY_SET, issues ) validateRecordPrimitive( 'primitives.zIndex', options.primitives.zIndex, Z_INDEX_KEYS, Z_INDEX_KEY_SET, issues ) validateThemeShadows(options, issues) return { ok: issues.length === 0, issues } } export function assertValidEidosConfig(options: EidosConfig): void { const report = validateEidosConfig(options) if (report.ok) return throw new EidosConfigValidationError(report.issues) } function validateEidosConfigShape( options: EidosConfig, issues: EidosValidationIssue[] ): options is EidosConfig { const input = options as unknown if (!isPlainRecord(input)) { issues.push({ path: '', message: 'Eidos config must be a plain object' }) return false } let ok = true if (!isPlainRecord(input.primitives)) { issues.push({ path: 'primitives', message: 'Eidos config must define a primitives object' }) ok = false } else { const primitiveKeys = [ 'color', 'size', 'space', 'controlHeight', 'radius', 'border', 'focusRing', 'layout', 'density', 'typography', 'motion', 'icon', 'opacity', 'zIndex', 'shadow' ] as const for (const key of primitiveKeys) { ok = validateOptionalPlainRecord(input.primitives[key], `primitives.${key}`, issues) && ok } } const semantics = input.semantics const color = isPlainRecord(semantics) ? semantics.color : undefined const roles = isPlainRecord(color) ? color.roles : undefined if (!isPlainRecord(roles)) { issues.push({ path: 'semantics.color.roles', message: 'Eidos config must define semantic color roles' }) ok = false } if (input.themes !== undefined) { if (!isPlainRecord(input.themes)) { issues.push({ path: 'themes', message: 'Eidos config themes must be a plain object' }) ok = false } else { for (const [themeId, theme] of Object.entries(input.themes)) { if (!isPlainRecord(theme)) { issues.push({ path: `themes.${themeId}`, message: 'Eidos config theme entries must be plain objects' }) ok = false continue } ok = validateOptionalPlainRecord(theme.color, `themes.${themeId}.color`, issues) && ok ok = validateOptionalPlainRecord(theme.shadow, `themes.${themeId}.shadow`, issues) && ok } } } if (input.recipes !== undefined) { if (!isPlainRecord(input.recipes)) { issues.push({ path: 'recipes', message: 'Eidos config recipes must be a plain object' }) ok = false } else { for (const [component, tokens] of Object.entries(input.recipes)) { ok = validateOptionalPlainRecord(tokens, `recipes.${component}`, issues) && ok } } } return ok } function validateOptionalPlainRecord( value: unknown, path: string, issues: EidosValidationIssue[] ): boolean { if (value === undefined) return true if (isPlainRecord(value)) return true issues.push({ path, message: 'Eidos config entries must be plain objects' }) return false } export function listEidosColorScales(options: EidosConfig): readonly string[] { return Object.keys(options.primitives.color?.scales ?? {}) } export function listEidosThemes(options: EidosConfig): readonly string[] { return Object.keys(options.themes ?? {}) } export function listEidosRecipes(options: EidosConfig): readonly string[] { return Object.keys(options.recipes ?? {}) } export function getEidosTheme(options: EidosConfig, id: string): ThemeDefinition | undefined { const theme = options.themes?.[id] return theme ? clonePlain(theme) : undefined } export function getEidosRecipeTokens( options: EidosConfig, component: string ): RecipeTokenMap | undefined { const tokens = options.recipes?.[component] return tokens ? clonePlain(tokens) : undefined } export function listEidosColorRoles(): readonly ColorRole[] { return [...COLOR_ROLES] } export function getEidosColorScale(options: EidosConfig, name: string): ColorScale | undefined { const scale = options.primitives.color?.scales?.[name] return scale ? clonePlain(scale) : undefined } export function getEidosColorRoleScale( options: EidosConfig, role: ColorRole, themeId?: string ): ColorScale | undefined { const theme = themeId ? options.themes?.[themeId] : undefined const roleDef = theme?.color?.roles?.[role] ?? options.semantics.color.roles[role] const scaleName = resolveRoleScaleName(roleDef) if (!scaleName) return undefined const themeScale = theme?.color?.scales?.[scaleName] if (themeScale) return clonePlain(themeScale) return getEidosColorScale(options, scaleName) } export function getEidosCssContract(options: EidosConfig): EidosCssContract { assertValidEidosConfig(options) return createEidosCssContract(options) } function validateColorScales(options: EidosConfig, issues: EidosValidationIssue[]): void { const scales = options.primitives.color?.scales ?? {} for (const [name, scale] of Object.entries(scales)) { validateCssTokenKey(`primitives.color.scales.${name}`, name, 'color scale', issues) validateColorScale(`primitives.color.scales.${name}`, name, scale, issues) } for (const [themeId, theme] of Object.entries(options.themes ?? {})) { for (const [name, scale] of Object.entries(theme.color?.scales ?? {})) { validateCssTokenKey(`themes.${themeId}.color.scales.${name}`, name, 'color scale', issues) validateColorScale(`themes.${themeId}.color.scales.${name}`, name, scale, issues) } for (const [name, scale] of Object.entries(theme.color?.alphaScales ?? {})) { validateCssTokenKey( `themes.${themeId}.color.alphaScales.${name}`, name, 'alpha color scale', issues ) validateColorScale(`themes.${themeId}.color.alphaScales.${name}`, name, scale, issues) if ( !hasOwn(options.primitives.color?.scales ?? {}, name) && !hasOwn(theme.color?.scales ?? {}, name) ) { issues.push({ path: `themes.${themeId}.color.alphaScales.${name}`, message: `alpha scale "${name}" must match a configured color scale` }) } } } } function validateThemeKeys(options: EidosConfig, issues: EidosValidationIssue[]): void { for (const themeId of Object.keys(options.themes ?? {})) { validateCssTokenKey(`themes.${themeId}`, themeId, 'theme id', issues) } } function validateColorRoles(options: EidosConfig, issues: EidosValidationIssue[]): void { const roles = options.semantics.color.roles const globalScales = options.primitives.color?.scales ?? {} const themes = options.themes ?? {} for (const role of COLOR_ROLES) { if (!hasOwn(roles, role)) { issues.push({ path: `semantics.color.roles.${role}`, message: 'every canonical color role must be mapped to a primitive scale' }) } } for (const [role, roleDef] of Object.entries(roles)) { validateColorRole(`semantics.color.roles.${role}`, role, roleDef, globalScales, themes, issues) } for (const [themeId, theme] of Object.entries(themes)) { for (const [role, roleDef] of Object.entries(theme.color?.roles ?? {})) { validateColorRole( `themes.${themeId}.color.roles.${role}`, role, roleDef, globalScales, { [themeId]: theme }, issues ) } } } function validateColorSemanticGroups(options: EidosConfig, issues: EidosValidationIssue[]): void { validateColorSemanticSet('semantics.color', options.semantics.color, issues) for (const [themeId, theme] of Object.entries(options.themes ?? {})) { if (!theme.color) continue validateColorSemanticSet(`themes.${themeId}.color`, theme.color, issues) } } function validateColorSemanticSet( path: string, color: Partial< Pick >, issues: EidosValidationIssue[] ): void { validateColorSemanticGroup( `${path}.surface`, color.surface, SURFACE_COLOR_KEYS, SURFACE_COLOR_KEY_SET, issues ) validateColorSemanticGroup( `${path}.content`, color.content, CONTENT_COLOR_KEYS, CONTENT_COLOR_KEY_SET, issues ) validateColorSemanticGroup( `${path}.border`, color.border, BORDER_COLOR_KEYS, BORDER_COLOR_KEY_SET, issues ) validateColorSemanticGroup( `${path}.focus`, color.focus, FOCUS_COLOR_KEYS, FOCUS_COLOR_KEY_SET, issues ) } function validateColorSemanticGroup( path: string, group: unknown, keys: readonly string[], keySet: Set, issues: EidosValidationIssue[] ): void { if (group === undefined) return if (!isPlainRecord(group)) { issues.push({ path, message: 'color semantic group must be a plain object' }) return } for (const key of keys) { if (!hasOwn(group, key)) { issues.push({ path: `${path}.${key}`, message: 'color semantic groups must define every canonical key' }) continue } const value = group[key] if (typeof value !== 'string' || value.trim() === '') { issues.push({ path: `${path}.${key}`, message: 'color semantic CSS value cannot be empty' }) continue } validateNonEmptyCssValue(`${path}.${key}`, value, issues) } for (const key of Object.keys(group)) { if (keySet.has(key)) continue issues.push({ path: `${path}.${key}`, message: 'unknown color semantic key' }) } } function validateColorScale( path: string, name: string, scale: ColorScale, issues: EidosValidationIssue[] ): void { if (!isPlainRecord(scale)) { issues.push({ path, message: 'color scale must be a plain object' }) return } for (const step of COLOR_SCALE_STEPS) { if (!hasOwn(scale, step)) { issues.push({ path: `${path}.${step}`, message: 'color scales must define every step from 1 to 12' }) continue } validateNonEmptyCssValue(`${path}.${step}`, scale[step], issues) } } function validateColorRole( path: string, role: string, roleDef: string | ColorRoleDefinition | undefined, globalScales: Record, themes: Record, issues: EidosValidationIssue[] ): void { if (!COLOR_ROLE_SET.has(role)) { issues.push({ path, message: 'unknown color role' }) return } if (roleDef !== undefined && typeof roleDef !== 'string' && !isPlainRecord(roleDef)) { issues.push({ path, message: 'color role definition must be a scale name or plain object' }) return } const scaleName = resolveRoleScaleName(roleDef) if (!scaleName) { issues.push({ path, message: 'color role must point to a primitive scale' }) return } if (typeof roleDef !== 'string' && roleDef?.slots !== undefined) { validateColorRoleSlots(`${path}.slots`, roleDef.slots, issues) } const themeEntries = Object.entries(themes) if (themeEntries.length === 0) { if (!hasOwn(globalScales, scaleName)) { issues.push({ path, message: `color role points to missing primitive scale "${scaleName}"` }) } return } for (const [themeId, theme] of themeEntries) { if (!hasOwn(globalScales, scaleName) && !hasOwn(theme.color?.scales ?? {}, scaleName)) { issues.push({ path: `themes.${themeId}.color.scales.${scaleName}`, message: `color role "${role}" points to a scale missing in this theme` }) } } } function validateColorRoleSlots( path: string, slots: unknown, issues: EidosValidationIssue[] ): void { if (!isPlainRecord(slots)) { issues.push({ path, message: 'color role slots must be a plain object' }) return } for (const [slot, step] of Object.entries(slots)) { if (!COLOR_ROLE_SLOT_SET.has(slot)) { issues.push({ path: `${path}.${slot}`, message: 'unknown color role slot' }) continue } if (typeof step !== 'string' || !COLOR_SCALE_STEP_SET.has(step)) { issues.push({ path: `${path}.${slot}`, message: 'color role slot must point to a scale step from 1 to 12' }) } } } function validateTypography(options: EidosConfig, issues: EidosValidationIssue[]): void { const typography = options.primitives.typography if (!typography) return const sizes = readRequiredPlainRecord( 'primitives.typography.sizes', typography.sizes, issues ) const families = readRequiredPlainRecord( 'primitives.typography.families', typography.families, issues ) const weights = readRequiredPlainRecord( 'primitives.typography.weights', typography.weights, issues ) const styles = readRequiredPlainRecord( 'primitives.typography.styles', typography.styles, issues ) if (sizes) { for (const size of TEXT_SIZES) { const metric = sizes[size] if (!metric) { issues.push({ path: `primitives.typography.sizes.${size}`, message: 'every canonical text size must be defined' }) continue } validateTextMetric(`primitives.typography.sizes.${size}`, metric, issues) } for (const name of Object.keys(sizes)) { validateCssTokenSuffix( `primitives.typography.sizes.${name}`, name, 'typography size', issues ) if (!TEXT_SIZE_SET.has(name)) { issues.push({ path: `primitives.typography.sizes.${name}`, message: 'unknown typography size key' }) } } } if (families) { for (const [name, family] of Object.entries(families)) { validateCssTokenSuffix( `primitives.typography.families.${name}`, name, 'typography family', issues ) if (!isPlainRecord(family)) { issues.push({ path: `primitives.typography.families.${name}`, message: 'typography family must be a plain object' }) continue } validateNonEmptyCssValue( `primitives.typography.families.${name}.family`, family.family, issues ) } } if (weights) { for (const name of Object.keys(weights)) { validateCssTokenSuffix( `primitives.typography.weights.${name}`, name, 'typography weight', issues ) validateNonEmptyCssValue(`primitives.typography.weights.${name}`, weights[name], issues) } } if (styles) { for (const [name, style] of Object.entries(styles)) { const path = `primitives.typography.styles.${name}` validateCssTokenSuffix(path, name, 'typography style', issues) if (!isPlainRecord(style)) { issues.push({ path, message: 'typography style must be a plain object' }) continue } const familyRef = style.family if ( familyRef !== undefined && (typeof familyRef !== 'string' || !families || !hasOwn(families, familyRef)) ) { issues.push({ path: `${path}.family`, message: `typography style points to missing family "${String(familyRef)}"` }) } const weightRef = style.weight if ( typeof weightRef === 'string' && weightRef.trim() !== '' && (!weights || !hasOwn(weights, weightRef)) ) { issues.push({ path: `${path}.weight`, message: `typography style points to missing weight "${weightRef}"` }) } validateTypographySizeValue(`${path}.size`, style.size, sizes, issues) } } } function readRequiredPlainRecord( path: string, value: unknown, issues: EidosValidationIssue[] ): Record | undefined { if (isPlainRecord(value)) return value issues.push({ path, message: 'Eidos config entries must be plain objects' }) return undefined } function validateSizePrimitives(options: EidosConfig, issues: EidosValidationIssue[]): void { const sizes = options.primitives.size if (!sizes) return for (const size of SIZE_PRIMITIVE_KEYS) { if (!hasOwn(sizes, size)) { issues.push({ path: `primitives.size.${size}`, message: 'every canonical physical size must be defined' }) } } for (const [name, size] of Object.entries(sizes)) { const path = `primitives.size.${name}` if (!SIZE_PRIMITIVE_KEY_SET.has(name)) { issues.push({ path, message: 'unknown size primitive key' }) continue } validateSizePrimitive(path, size, options, issues) } } function validateSizePrimitive( path: string, size: unknown, options: EidosConfig, issues: EidosValidationIssue[] ): void { if (!isPlainRecord(size)) { issues.push({ path, message: 'size primitive must be a plain object' }) return } const controlHeight = size.controlHeight if ( typeof controlHeight !== 'string' || controlHeight.trim() === '' || !hasOwn(options.primitives.controlHeight ?? {}, controlHeight) ) { issues.push({ path: `${path}.controlHeight`, message: `size primitive points to missing control height "${String(controlHeight)}"` }) } const typography = options.primitives.typography const fontSize = size.fontSize if ( typeof fontSize !== 'string' || !TEXT_SIZE_SET.has(fontSize) || !hasOwn(typography?.sizes ?? {}, fontSize) ) { issues.push({ path: `${path}.fontSize`, message: `size primitive points to missing font size "${String(fontSize)}"` }) } const iconSize = size.iconSize if ( typeof iconSize !== 'string' || iconSize.trim() === '' || !hasOwn(options.primitives.icon?.size ?? {}, iconSize) ) { issues.push({ path: `${path}.iconSize`, message: `size primitive points to missing icon size "${String(iconSize)}"` }) } const radius = size.radius if ( typeof radius !== 'string' || radius.trim() === '' || !hasOwn(options.primitives.radius ?? {}, radius) ) { issues.push({ path: `${path}.radius`, message: `size primitive points to missing radius "${String(radius)}"` }) } validateNonEmptyCssValue(`${path}.paddingInline`, size.paddingInline, issues) validateNonEmptyCssValue(`${path}.paddingBlock`, size.paddingBlock, issues) validateNonEmptyCssValue(`${path}.gap`, size.gap, issues) } function validateStaticPrimitives(options: EidosConfig, issues: EidosValidationIssue[]): void { validateOpenScalePrimitive('primitives.space', options.primitives.space, 'space', issues) validateRecordPrimitive( 'primitives.controlHeight', options.primitives.controlHeight, SIZE_PRIMITIVE_KEYS, SIZE_PRIMITIVE_KEY_SET, issues ) validateRecordPrimitive( 'primitives.radius', options.primitives.radius, RADIUS_KEYS, RADIUS_KEY_SET, issues ) const focusRing = options.primitives.focusRing if (!focusRing) return validateNonEmptyCssValue('primitives.focusRing.offset', focusRing.offset, issues) validateNonEmptyCssValue('primitives.focusRing.width', focusRing.width, issues) } function validateBorderPrimitives(options: EidosConfig, issues: EidosValidationIssue[]): void { const border = options.primitives.border if (!border) return validateRecordPrimitive( 'primitives.border.width', border.width, BORDER_WIDTH_KEYS, BORDER_WIDTH_KEY_SET, issues ) validateRecordPrimitive( 'primitives.border.style', border.style, BORDER_STYLE_KEYS, BORDER_STYLE_KEY_SET, issues ) validateBorderDefault( 'primitives.border.defaultWidth', border.defaultWidth, border, 'width', issues ) validateBorderDefault( 'primitives.border.defaultStyle', border.defaultStyle, border, 'style', issues ) } function validateBorderDefault( path: string, value: string, border: BorderPrimitiveSet, field: 'width' | 'style', issues: EidosValidationIssue[] ): void { if (hasOwn(border[field], value)) return issues.push({ path, message: `border default points to missing ${field} "${value}"` }) } function validateLayoutPrimitives(options: EidosConfig, issues: EidosValidationIssue[]): void { const layout = options.primitives.layout if (!layout) return validateRecordPrimitive( 'primitives.layout.containerWidth', layout.containerWidth, CONTAINER_WIDTH_KEYS, CONTAINER_WIDTH_KEY_SET, issues ) validateRecordPrimitive( 'primitives.layout.contentWidth', layout.contentWidth, CONTENT_WIDTH_KEYS, CONTENT_WIDTH_KEY_SET, issues ) validateRecordPrimitive( 'primitives.layout.aspectRatio', layout.aspectRatio, ASPECT_RATIO_KEYS, ASPECT_RATIO_KEY_SET, issues ) validateNonEmptyCssValue( 'primitives.layout.containerPaddingInline', layout.containerPaddingInline, issues ) } function validateDensityPrimitives(options: EidosConfig, issues: EidosValidationIssue[]): void { const density = options.primitives.density if (!density) return validateRecordPrimitive( 'primitives.density.scale', density.scale, DENSITY_KEYS, DENSITY_KEY_SET, issues ) validateRecordPrimitive( 'primitives.density.spaceScale', density.spaceScale, DENSITY_KEYS, DENSITY_KEY_SET, issues ) validateRecordPrimitive( 'primitives.density.controlScale', density.controlScale, DENSITY_KEYS, DENSITY_KEY_SET, issues ) validateRecordPrimitive( 'primitives.density.contentScale', density.contentScale, DENSITY_KEYS, DENSITY_KEY_SET, issues ) } function validateMotionPrimitives(options: EidosConfig, issues: EidosValidationIssue[]): void { const motion = options.primitives.motion if (!motion) return validateRecordPrimitive( 'primitives.motion.duration', motion.duration, DURATION_KEYS, DURATION_KEY_SET, issues ) validateRecordPrimitive('primitives.motion.ease', motion.ease, EASE_KEYS, EASE_KEY_SET, issues) validateRecordPrimitive( 'primitives.motion.distance', motion.distance, MOTION_DISTANCE_KEYS, MOTION_DISTANCE_KEY_SET, issues ) validateRecordPrimitive( 'primitives.motion.scale', motion.scale, MOTION_SCALE_KEYS, MOTION_SCALE_KEY_SET, issues ) validateNonEmptyCssValue('primitives.motion.stagger', motion.stagger, issues) } function validateIconPrimitives(options: EidosConfig, issues: EidosValidationIssue[]): void { const icon = options.primitives.icon if (!icon) return validateRecordPrimitive( 'primitives.icon.size', icon.size, SIZE_PRIMITIVE_KEYS, SIZE_PRIMITIVE_KEY_SET, issues ) validateRecordPrimitive( 'primitives.icon.strokeWidth', icon.strokeWidth, ICON_STROKE_WIDTH_KEYS, ICON_STROKE_WIDTH_KEY_SET, issues ) } function validateRecipeTokens(options: EidosConfig, issues: EidosValidationIssue[]): void { for (const [component, tokens] of Object.entries(options.recipes ?? {})) { if (!isCssTokenSegment(component)) { issues.push({ path: `recipes.${component}`, message: 'recipe component keys must be kebab-case CSS token segments' }) continue } for (const [name, value] of Object.entries(tokens)) { // `composition` is a reserved sibling key (TSC v2.2 cross-recipe // overrides), not a token. Validate its shape separately and // move on. if (name === 'composition') { validateRecipeComposition(`recipes.${component}.composition`, value, issues) continue } // Recipe token keys are kebab-case CSS token segments. A // leading underscore (`_palette-solid`) marks a private // token — emitted by the generator as `--_{c}-{rest}`. // See `lib/render-css.ts > appendRecipeDeclarations`. const nameWithoutPrivatePrefix = name.startsWith('_') ? name.slice(1) : name if (!isCssTokenSegment(nameWithoutPrivatePrefix)) { issues.push({ path: `recipes.${component}.${name}`, message: 'recipe token keys must be kebab-case CSS token segments (optionally with a leading "_" for private tokens)' }) continue } // TSC v2 supports three value forms for a recipe entry: // - bare string/number → scope:'root' shorthand // - { value, scope?, depends? } → single declaration with TSC metadata // - { declarations: [...] } → multi-scope declarations // Validate each shape independently; the renderer's TSC pass // runs scope-algebra checks separately when emitting CSS. validateRecipeTokenValue(`recipes.${component}.${name}`, value, issues) } } } /** * Validate a recipe's `composition` block (TSC v2.2). * * Shape: * ``` * composition: { * [foreignComponent]: { * targetSelector: string, * tokens: { [tokenName]: { declarations: [{ value, scope?, depends? }] } } * } * } * ``` * * Each composition token MUST use the multi-declaration form (the * recipe is declaring overrides scoped to specific conditions — the * single-declaration short form is rejected to keep the intent * explicit). */ function validateRecipeComposition( path: string, value: unknown, issues: EidosValidationIssue[] ): void { if (typeof value !== 'object' || value === null) { issues.push({ path, message: 'composition must be an object' }) return } for (const [foreignComponent, entry] of Object.entries(value as Record)) { const entryPath = `${path}.${foreignComponent}` if (!isCssTokenSegment(foreignComponent)) { issues.push({ path: entryPath, message: 'composition foreign-component keys must be kebab-case CSS token segments' }) continue } if (typeof entry !== 'object' || entry === null) { issues.push({ path: entryPath, message: 'composition entry must be { targetSelector, tokens }' }) continue } const obj = entry as Record if (typeof obj.targetSelector !== 'string' || obj.targetSelector.trim() === '') { issues.push({ path: `${entryPath}.targetSelector`, message: 'targetSelector must be a non-empty CSS selector string' }) } if (typeof obj.tokens !== 'object' || obj.tokens === null) { issues.push({ path: `${entryPath}.tokens`, message: 'tokens must be an object' }) continue } for (const [tokenName, tokenValue] of Object.entries(obj.tokens as Record)) { const tokenPath = `${entryPath}.tokens.${tokenName}` const nameWithoutPrivatePrefix = tokenName.startsWith('_') ? tokenName.slice(1) : tokenName if (!isCssTokenSegment(nameWithoutPrivatePrefix)) { issues.push({ path: tokenPath, message: 'composition token keys must be kebab-case CSS token segments (optionally with leading "_")' }) continue } if (typeof tokenValue !== 'object' || tokenValue === null || !('declarations' in tokenValue)) { issues.push({ path: tokenPath, message: 'composition tokens must use the multi-declaration form { declarations: [...] }' }) continue } validateRecipeTokenValue(tokenPath, tokenValue, issues) } } } function validateRecipeTokenValue( path: string, value: unknown, issues: EidosValidationIssue[] ): void { if (typeof value === 'string' || typeof value === 'number') { validateNonEmptyCssValue(path, value, issues) return } if (typeof value === 'object' && value !== null) { const obj = value as Record if ('declarations' in obj) { const decls = obj.declarations if (!Array.isArray(decls) || decls.length === 0) { issues.push({ path, message: 'declarations[] must be a non-empty array' }) return } for (let i = 0; i < decls.length; i++) { const d = decls[i] as Record if (d == null || typeof d !== 'object') { issues.push({ path: `${path}.declarations[${i}]`, message: 'declaration must be { value, scope?, depends? }' }) continue } validateNonEmptyCssValue(`${path}.declarations[${i}].value`, d.value, issues) } return } if ('value' in obj) { validateNonEmptyCssValue(`${path}.value`, obj.value, issues) return } } issues.push({ path, message: 'recipe value must be a CSS string/number or a TSC entry ({value} or {declarations[]})' }) } function validateThemeShadows(options: EidosConfig, issues: EidosValidationIssue[]): void { for (const [themeId, theme] of Object.entries(options.themes ?? {})) { validateRecordPrimitive( `themes.${themeId}.shadow`, theme.shadow, SHADOW_KEYS, SHADOW_KEY_SET, issues ) } } function validateRecordPrimitive( path: string, record: Record | undefined, keys: readonly string[], keySet: Set, issues: EidosValidationIssue[] ): void { if (!record) return for (const key of keys) { if (!hasOwn(record, key)) { issues.push({ path: `${path}.${key}`, message: 'every canonical token key must be defined' }) } } for (const [key, value] of Object.entries(record)) { if (!keySet.has(key)) { issues.push({ path: `${path}.${key}`, message: 'unknown token key' }) continue } validateNonEmptyCssValue(`${path}.${key}`, value, issues) } } function validateOpenScalePrimitive( path: string, record: Record | undefined, label: string, issues: EidosValidationIssue[] ): void { if (!record) return for (const [key, value] of Object.entries(record)) { validateCssTokenSuffix(`${path}.${key}`, key, label, issues) validateNonEmptyCssValue(`${path}.${key}`, value, issues) } } function validateTypographySizeValue( path: string, value: unknown, sizes: Record | undefined, issues: EidosValidationIssue[] ): void { if (value === undefined) return if (typeof value === 'string') { if (!TEXT_SIZE_SET.has(value) || !sizes || !hasOwn(sizes, value)) { issues.push({ path, message: `typography style points to missing text size "${value}"` }) } return } if (isPlainRecord(value) && isTextMetricLike(value)) { validateTextMetric(path, value, issues) return } if (isPlainRecord(value)) { for (const [key, item] of Object.entries(value)) { if (!RESPONSIVE_KEY_SET.has(key)) { issues.push({ path: `${path}.${key}`, message: `unknown responsive breakpoint "${key}"` }) } validateTypographySizeValue(`${path}.${key}`, item, sizes, issues) } return } issues.push({ path, message: 'typography size must be a canonical size, metric, or responsive map' }) } function validateNonEmptyCssValue( path: string, value: unknown, issues: EidosValidationIssue[] ): void { if (typeof value !== 'string' && typeof value !== 'number') { issues.push({ path, message: 'CSS value must be a string or number' }) return } const text = String(value) if (text.trim() === '') { issues.push({ path, message: 'CSS value cannot be empty' }) return } if (!/[;{}]/.test(text)) return issues.push({ path, message: 'CSS value cannot contain declaration or block delimiters' }) } function validateTextMetric( path: string, metric: unknown, issues: EidosValidationIssue[] ): void { if (!isPlainRecord(metric)) { issues.push({ path, message: 'text metric must be a plain object' }) return } validateNonEmptyCssValue(`${path}.size`, metric.size, issues) validateNonEmptyCssValue(`${path}.lineHeight`, metric.lineHeight, issues) validateNonEmptyCssValue(`${path}.letterSpacing`, metric.letterSpacing, issues) } function isTextMetricLike(value: Record): boolean { return hasOwn(value, 'size') || hasOwn(value, 'lineHeight') || hasOwn(value, 'letterSpacing') } function validateCssTokenKey( path: string, value: string, label: string, issues: EidosValidationIssue[] ): void { if (isCssTokenSegment(value)) return issues.push({ path, message: `${label} must be a kebab-case CSS token segment` }) } function validateCssTokenSuffix( path: string, value: string, label: string, issues: EidosValidationIssue[] ): void { if (isCssTokenSuffix(value)) return issues.push({ path, message: `${label} must be a kebab-case CSS token suffix` }) } function resolveRoleScaleName( roleDef: string | ColorRoleDefinition | undefined ): string | undefined { if (typeof roleDef === 'string') return roleDef return roleDef?.scale } function isCssTokenSegment(value: string): boolean { return /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/.test(value) } function isCssTokenSuffix(value: string): boolean { return /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(value) }