You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
svelte-kit-vice/src/uix/eidos/lib/config.ts

1416 lines
37 KiB

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<string>(COLOR_ROLES)
const ASPECT_RATIO_KEY_SET = new Set<string>(ASPECT_RATIO_KEYS)
const BORDER_WIDTH_KEY_SET = new Set<string>(BORDER_WIDTH_KEYS)
const BORDER_STYLE_KEY_SET = new Set<string>(BORDER_STYLE_KEYS)
const BORDER_COLOR_KEY_SET = new Set<string>(BORDER_COLOR_KEYS)
const CONTENT_COLOR_KEY_SET = new Set<string>(CONTENT_COLOR_KEYS)
const COLOR_ROLE_SLOT_SET = new Set<string>(COLOR_ROLE_SLOTS)
const COLOR_SCALE_STEP_SET = new Set<string>(COLOR_SCALE_STEPS)
const CONTAINER_WIDTH_KEY_SET = new Set<string>(CONTAINER_WIDTH_KEYS)
const CONTENT_WIDTH_KEY_SET = new Set<string>(CONTENT_WIDTH_KEYS)
const DENSITY_KEY_SET = new Set<string>(DENSITY_KEYS)
const DURATION_KEY_SET = new Set<string>(DURATION_KEYS)
const EASE_KEY_SET = new Set<string>(EASE_KEYS)
const FOCUS_COLOR_KEY_SET = new Set<string>(FOCUS_COLOR_KEYS)
const ICON_STROKE_WIDTH_KEY_SET = new Set<string>(ICON_STROKE_WIDTH_KEYS)
const MOTION_DISTANCE_KEY_SET = new Set<string>(MOTION_DISTANCE_KEYS)
const MOTION_SCALE_KEY_SET = new Set<string>(MOTION_SCALE_KEYS)
const OPACITY_KEY_SET = new Set<string>(OPACITY_KEYS)
const RADIUS_KEY_SET = new Set<string>(RADIUS_KEYS)
const SHADOW_KEY_SET = new Set<string>(SHADOW_KEYS)
const SIZE_PRIMITIVE_KEY_SET = new Set<string>(SIZE_PRIMITIVE_KEYS)
const SURFACE_COLOR_KEY_SET = new Set<string>(SURFACE_COLOR_KEYS)
const TEXT_SIZE_SET = new Set<string>(TEXT_SIZES)
const Z_INDEX_KEY_SET = new Set<string>(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)
}
validateColorSemanticCompleteness(options, issues)
}
// The per-set validation above enforces completeness *when a group is present*,
// but each group is optional — so a theme that omits `surface`/`content`/`border`/
// `focus` entirely (and isn't covered by the global `semantics.color`) silently
// emits hundreds of undefined `var(--color-…)` references. Validate the EFFECTIVE
// merged set per theme: every group must resolve from the global semantics or the
// theme itself. (audit P1-3)
function validateColorSemanticCompleteness(
options: EidosConfig,
issues: EidosValidationIssue[]
): void {
const groups = ['surface', 'content', 'border', 'focus'] as const
const globalColor = options.semantics.color
// Only themed configs render the `--color-{surface,content,border,focus}-*`
// vars; a themeless config (e.g. role-only validation fixtures) renders
// nothing, so it isn't flagged.
for (const [themeId, theme] of Object.entries(options.themes ?? {})) {
for (const group of groups) {
if (globalColor[group] !== undefined) continue
if (theme.color?.[group] !== undefined) continue
issues.push({
path: `themes.${themeId}.color.${group}`,
message: `effective "${group}" colors are undefined for theme "${themeId}" — define them in semantics.color or this theme`
})
}
}
}
function validateColorSemanticSet(
path: string,
color: Partial<
Pick<ColorSemanticSet, 'surface' | 'content' | 'border' | 'focus'>
>,
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<string>,
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<string, ColorScale>,
themes: Record<string, ThemeDefinition>,
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<string, unknown> | 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<string, unknown>)) {
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<string, unknown>
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<string, unknown>)) {
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<string, unknown>
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<string, unknown>
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<string, string | number> | undefined,
keys: readonly string[],
keySet: Set<string>,
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<string, string | number> | 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<string, unknown> | 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)
const trimmed = text.trim()
if (trimmed === '') {
issues.push({
path,
message: 'CSS value cannot be empty'
})
return
}
if (/[;{}]/.test(text)) {
issues.push({
path,
message: 'CSS value cannot contain declaration or block delimiters'
})
return
}
// A `#`-prefixed value is a hex color; reject malformed hex (e.g. `#ggg`,
// `#12`) that would otherwise pass through and be silently dropped by the
// browser, leaving the role/scale step unstyled. Functions (`var()`,
// `rgb()`, `color-mix()`) and bare tokens don't start with `#`, so they're
// unaffected. (audit P1-4)
if (trimmed.startsWith('#') && !/^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i.test(trimmed)) {
issues.push({
path,
message: `invalid hex color "${trimmed}" (expected #rgb, #rgba, #rrggbb or #rrggbbaa)`
})
}
}
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<string, unknown>): 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)
}

Powered by TurnKey Linux.