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/active-eidos-config.test.ts

1090 lines
38 KiB

import { describe, expect, it } from 'vitest'
import {
COLOR_ALPHA_STEPS,
COLOR_SCALE_STEPS,
EIDOS_CONFIG_DOCUMENT_KIND,
EIDOS_CONFIG_DOCUMENT_VERSION,
createActiveEidos,
createThemeBaseEidosConfig,
defineEidosConfig,
EidosCssVariableError,
EidosConfigDocumentError,
EidosThemeNotFoundError,
EidosConfigValidationError,
extendEidosConfig,
parseEidosConfigFromJson,
parseEidosConfigDocument,
renderThemeCss,
STATIC_CONTROL_HEIGHT,
STATIC_DENSITY,
STATIC_LAYOUT,
STATIC_OPACITY,
STATIC_PRIMITIVES,
STATIC_RADIUS,
STATIC_SIZE,
STATIC_SPACE,
STATIC_TYPOGRAPHY,
STATIC_Z_INDEX,
SHADOW_KEYS,
SIZE_PRIMITIVE_KEYS,
TEXT_SIZES,
THEME_BASE_DARK_COLOR_SCALES,
THEME_BASE_DARK_SHADOW,
THEME_BASE_LIGHT_COLOR_SCALES,
THEME_BASE_LIGHT_COLOR_SEMANTICS,
THEME_BASE_OPTIONS,
THEME_BASE_RECIPE_TOKENS,
type ColorScale,
type EidosConfig,
type EidosConfigDocument,
type EidosConfigPatch
} from '.'
function createEidos(config: EidosConfig) {
return createActiveEidos({ config: config, applyDom: false, themeSource: 'config' })
}
function createThemeBaseEidos(patch?: EidosConfigPatch) {
return createActiveEidos({ themeBase: patch, applyDom: false, themeSource: 'config' })
}
function createEidosFromDocument(document: unknown) {
return createActiveEidos({
config: document as EidosConfigDocument,
applyDom: false,
themeSource: 'config'
})
}
function createEidosFromJson(json: string) {
return createEidosFromDocument(parseEidosConfigDocument(json))
}
function cssVariableDeclarations(css: string): Set<string> {
return new Set([...css.matchAll(/--[a-z0-9_-]+(?=\s*:)/g)].map(([name]) => name))
}
function colorScale(seed: string): ColorScale {
return Object.fromEntries(
COLOR_SCALE_STEPS.map((step) => [step, `${seed}-${step}`])
) as ColorScale
}
function options(): EidosConfig {
return {
primitives: {
color: {
scales: {
gray: colorScale('gray'),
violet: colorScale('violet'),
slate: colorScale('slate'),
teal: colorScale('teal'),
green: colorScale('green'),
orange: colorScale('orange'),
red: colorScale('red'),
purple: colorScale('purple')
}
}
},
semantics: {
color: {
roles: {
primary: 'violet',
secondary: 'slate',
tertiary: 'gray',
neutral: 'gray',
affirm: 'teal',
fulfill: 'green',
risk: 'orange',
threat: 'red',
loss: 'purple'
}
}
}
}
}
describe('ActiveEidos config', () => {
it('accepts the canonical 3 hierarchy + 6 intent color roles', () => {
const eidos = createEidos(options())
expect(eidos.validate()).toEqual({ ok: true, issues: [] })
expect(eidos.listColorRoles()).toEqual([
'primary',
'secondary',
'tertiary',
'neutral',
'affirm',
'fulfill',
'risk',
'threat',
'loss'
])
})
it('ships a base theme pair with 31 physical color scales of 12 steps', () => {
const eidos = createThemeBaseEidos()
expect(eidos.validate()).toEqual({ ok: true, issues: [] })
expect(eidos.listThemes()).toEqual(['base-light', 'base-dark'])
expect(eidos.listRecipes()).toContain('tooltip')
expect(eidos.getRecipeTokens('tooltip')).toEqual(THEME_BASE_RECIPE_TOKENS.tooltip)
expect(eidos.getRecipeTokens('missing')).toBeUndefined()
const tooltipTokens = eidos.getRecipeTokens('tooltip') as Record<string, string>
tooltipTokens['content-z'] = '1'
expect(eidos.getRecipeTokens('tooltip')?.['content-z']).toBe('76')
// Palette library at Radix parity: 12 original + 19 added Radix scales.
expect(Object.keys(THEME_BASE_LIGHT_COLOR_SCALES)).toHaveLength(31)
expect(Object.keys(THEME_BASE_DARK_COLOR_SCALES)).toHaveLength(31)
for (const scale of [
...Object.values(THEME_BASE_LIGHT_COLOR_SCALES),
...Object.values(THEME_BASE_DARK_COLOR_SCALES)
]) {
expect(Object.keys(scale)).toHaveLength(12)
}
expect(eidos.getColorRoleScale('primary', 'base-light')).toEqual(
THEME_BASE_LIGHT_COLOR_SCALES.purple
)
expect(eidos.getTheme('base-light')?.color?.surface).toEqual(
THEME_BASE_LIGHT_COLOR_SEMANTICS.surface
)
expect(eidos.getTheme('base-dark')?.shadow).toEqual(THEME_BASE_DARK_SHADOW)
})
it('ships the canonical static primitives in the base options', () => {
const eidos = createThemeBaseEidos()
const snapshot = eidos.snapshot()
expect(snapshot.primitives.space).toEqual(STATIC_SPACE)
expect(snapshot.primitives.controlHeight).toEqual(STATIC_CONTROL_HEIGHT)
expect(snapshot.primitives.radius).toEqual(STATIC_RADIUS)
expect(snapshot.primitives.size).toEqual(STATIC_SIZE)
expect(snapshot.primitives.typography).toEqual(STATIC_TYPOGRAPHY)
expect(snapshot.primitives.icon).toEqual(STATIC_PRIMITIVES.icon)
expect(snapshot.primitives.layout).toEqual(STATIC_LAYOUT)
expect(snapshot.primitives.density).toEqual(STATIC_DENSITY)
expect(snapshot.primitives.opacity).toEqual(STATIC_OPACITY)
expect(snapshot.primitives.zIndex).toEqual(STATIC_Z_INDEX)
expect(snapshot.primitives.border).toEqual(STATIC_PRIMITIVES.border)
expect(snapshot.recipes).toEqual(THEME_BASE_RECIPE_TOKENS)
expect(snapshot.recipes?.tooltip?.['content-z']).toBe('76')
expect(Object.keys(STATIC_SIZE)).toEqual([...SIZE_PRIMITIVE_KEYS])
expect(new Set(Object.keys(THEME_BASE_DARK_SHADOW))).toEqual(new Set(SHADOW_KEYS))
expect(Object.keys(STATIC_SPACE)).toEqual([
'0',
'1',
'2',
'3',
'4',
'5',
'6',
'7',
'8',
'10',
'12',
'16',
'0-5',
'1-5',
'2-5',
'3-5',
'4-5',
'5-5'
])
})
it('extends theme base options without mutating the base constants', () => {
const patch = {
primitives: {
space: {
'4': '18px',
'20': '80px'
},
typography: {
families: {
primary: {
family: 'Inter'
}
}
}
},
semantics: {
color: {
roles: {
primary: {
scale: 'blue',
slots: {
solid: '10'
}
}
}
}
},
themes: {
'base-light': {
color: {
surface: {
default: 'Canvas'
}
}
}
}
} satisfies EidosConfigPatch
const cfg = createThemeBaseEidosConfig(patch)
expect(cfg.primitives.space?.['4']).toBe('18px')
expect(cfg.primitives.space?.['20']).toBe('80px')
expect(THEME_BASE_OPTIONS.primitives.space?.['4']).toBe('16px')
expect(THEME_BASE_OPTIONS.primitives.space?.['20']).toBeUndefined()
expect(cfg.primitives.typography?.families.primary).toEqual({
family: 'Inter',
fallbacks: ['system-ui', 'sans-serif'],
source: 'local'
})
expect(cfg.semantics.color.roles.primary).toEqual({
scale: 'blue',
slots: {
solid: '10'
}
})
expect(cfg.themes?.['base-light'].color?.surface?.default).toBe('Canvas')
expect(cfg.themes?.['base-light'].color?.surface?.raised).toBe(
THEME_BASE_LIGHT_COLOR_SEMANTICS.surface?.raised
)
})
it('creates a theme base config from a partial patch', () => {
const eidos = createThemeBaseEidos({
semantics: {
color: {
roles: {
primary: 'blue'
}
}
}
})
expect(eidos.validate()).toEqual({ ok: true, issues: [] })
expect(eidos.getColorRoleScale('primary', 'base-light')).toEqual(
THEME_BASE_LIGHT_COLOR_SCALES.blue
)
})
it('clones authored options before storing them in ActiveEidos', () => {
const cfg = defineEidosConfig(options())
const extended = extendEidosConfig(cfg, {
semantics: {
color: {
roles: {
primary: 'blue'
}
}
}
})
expect(cfg.semantics.color.roles.primary).toBe('violet')
expect(extended.semantics.color.roles.primary).toBe('blue')
})
it('serializes a versioned document for persistence', () => {
const eidos = createThemeBaseEidos()
const document = eidos.toDocument()
const json = eidos.serialize()
const parsed = parseEidosConfigDocument(json)
expect(document.kind).toBe(EIDOS_CONFIG_DOCUMENT_KIND)
expect(document.version).toBe(EIDOS_CONFIG_DOCUMENT_VERSION)
expect(parsed.options).toEqual(eidos.snapshot())
expect(createEidosFromDocument(document).snapshot()).toEqual(eidos.snapshot())
expect(createEidosFromJson(json).snapshot()).toEqual(eidos.snapshot())
;(document.options.semantics.color.roles as { primary: string }).primary = 'blue'
expect(eidos.snapshot().semantics.color.roles.primary).toBe('purple')
})
it('rejects unsupported persisted document versions', () => {
const document = createThemeBaseEidos().toDocument()
const nextVersion = {
...document,
version: 2
}
expect(() => createEidosFromDocument(nextVersion)).toThrow(EidosConfigDocumentError)
expect(() => parseEidosConfigDocument('{')).toThrow(EidosConfigDocumentError)
})
it('rejects persisted documents with invalid Eidos config options', () => {
const invalid = structuredClone(createThemeBaseEidos().toDocument()) as EidosConfigDocument
;(invalid.options.semantics.color.roles as Record<string, unknown>).primary =
'missing-scale'
expect(() => createEidosFromDocument(invalid)).toThrow(EidosConfigValidationError)
expect(() => parseEidosConfigFromJson(JSON.stringify(invalid))).toThrow(
EidosConfigValidationError
)
})
it('reports malformed persisted config options as validation issues', () => {
const malformed = {
kind: EIDOS_CONFIG_DOCUMENT_KIND,
version: EIDOS_CONFIG_DOCUMENT_VERSION,
options: {}
}
try {
createEidosFromDocument(malformed)
throw new Error('expected malformed config to fail')
} catch (error) {
expect(error).toBeInstanceOf(EidosConfigValidationError)
expect((error as EidosConfigValidationError).issues.map((issue) => issue.path)).toEqual(
expect.arrayContaining(['primitives', 'semantics.color.roles'])
)
}
expect(() => parseEidosConfigFromJson(JSON.stringify(malformed))).toThrow(
EidosConfigValidationError
)
})
it('reports malformed optional config groups before deep validation', () => {
const malformed = structuredClone(createThemeBaseEidos().toDocument())
;(malformed.options.primitives as unknown as Record<string, unknown>).typography = 'bad'
;(malformed.options.primitives as unknown as Record<string, unknown>).shadow = 'bad'
;(malformed.options.themes!['base-light'] as unknown as Record<string, unknown>).color = 'bad'
;(malformed.options.recipes as unknown as Record<string, unknown>).tooltip = 'bad'
try {
createEidosFromDocument(malformed)
throw new Error('expected malformed optional groups to fail')
} catch (error) {
expect(error).toBeInstanceOf(EidosConfigValidationError)
expect((error as EidosConfigValidationError).issues.map((issue) => issue.path)).toEqual(
expect.arrayContaining([
'primitives.typography',
'primitives.shadow',
'themes.base-light.color',
'recipes.tooltip'
])
)
}
})
it('reports malformed typography primitive groups without throwing', () => {
const cfg = structuredClone(THEME_BASE_OPTIONS)
const typography = cfg.primitives.typography as unknown as Record<string, unknown>
delete typography.sizes
typography.families = 'bad'
typography.weights = null
typography.styles = ['bad']
const report = createEidos(cfg).validate()
expect(report.ok).toBe(false)
expect(report.issues.map((issue) => issue.path)).toEqual(
expect.arrayContaining([
'primitives.typography.sizes',
'primitives.typography.families',
'primitives.typography.weights',
'primitives.typography.styles'
])
)
})
it('reports incomplete primitive values without throwing', () => {
const cfg = structuredClone(THEME_BASE_OPTIONS)
delete (cfg.primitives.size!.md as unknown as Record<string, unknown>).paddingInline
delete (cfg.primitives.typography!.sizes.md as unknown as Record<string, unknown>).lineHeight
;(cfg.themes!['base-light'].color!.scales!.purple as unknown as Record<
string,
unknown
>)['9'] = null
const report = createEidos(cfg).validate()
expect(report.ok).toBe(false)
expect(report.issues.map((issue) => issue.path)).toEqual(
expect.arrayContaining([
'primitives.size.md.paddingInline',
'primitives.typography.sizes.md.lineHeight',
'themes.base-light.color.scales.purple.9'
])
)
})
it('ships typography families separately from typography styles', () => {
const typography = createThemeBaseEidos().snapshot().primitives.typography!
expect(Object.keys(typography.families)).toEqual(['primary', 'secondary', 'mono', 'display'])
expect(Object.keys(typography.sizes)).toEqual([...TEXT_SIZES])
expect(typography.styles.hero.size).toEqual({ base: 'xxl', md: 'xxxl' })
expect(typography.styles.h1.family).toBe('display')
expect(typography.styles.body.family).toBe('primary')
})
it('validates typography style references', () => {
const cfg = structuredClone(THEME_BASE_OPTIONS)
const typography = cfg.primitives.typography!
;(typography.styles.body as { family: string }).family = 'missing-family'
;(typography.styles.code as { weight: string }).weight = 'heavy'
;(typography.styles.h1 as { size: string }).size = 'huge'
;(typography.styles.hero as { size: Record<string, string> }).size = {
tablet: 'xxl'
}
const report = createEidos(cfg).validate()
expect(report.ok).toBe(false)
expect(
report.issues.some((issue) => issue.path === 'primitives.typography.styles.body.family')
).toBe(true)
expect(
report.issues.some((issue) => issue.path === 'primitives.typography.styles.code.weight')
).toBe(true)
expect(
report.issues.some((issue) => issue.path === 'primitives.typography.styles.h1.size')
).toBe(true)
expect(
report.issues.some((issue) => issue.path === 'primitives.typography.styles.hero.size.tablet')
).toBe(true)
})
it('validates typography keys that become CSS custom properties', () => {
const cfg = structuredClone(THEME_BASE_OPTIONS)
const typography = cfg.primitives.typography!
;(typography.families as Record<string, unknown>)['Bad Family'] = { family: 'Bad' }
;(typography.sizes as Record<string, unknown>)['mega'] = {
size: '96px',
lineHeight: '1',
letterSpacing: '0'
}
;(typography.weights as Record<string, unknown>)['semi bold'] = 600
;(typography.styles as Record<string, unknown>)['Hero Display'] = {
size: 'xxl'
}
const report = createEidos(cfg).validate()
expect(report.ok).toBe(false)
expect(
report.issues.some((issue) => issue.path === 'primitives.typography.families.Bad Family')
).toBe(true)
expect(report.issues.some((issue) => issue.path === 'primitives.typography.sizes.mega')).toBe(
true
)
expect(
report.issues.some((issue) => issue.path === 'primitives.typography.weights.semi bold')
).toBe(true)
expect(
report.issues.some((issue) => issue.path === 'primitives.typography.styles.Hero Display')
).toBe(true)
})
it('validates size primitive references', () => {
const cfg = structuredClone(THEME_BASE_OPTIONS)
const size = cfg.primitives.size!
;(size.md as { controlHeight: string }).controlHeight = 'missing-height'
;(size.md as { fontSize: string }).fontSize = 'huge'
;(size.md as { iconSize: string }).iconSize = 'missing-icon'
;(size.md as { radius: string }).radius = 'missing-radius'
;(size.md as { gap: string }).gap = ''
const report = createEidos(cfg).validate()
expect(report.ok).toBe(false)
expect(report.issues.some((issue) => issue.path === 'primitives.size.md.controlHeight')).toBe(
true
)
expect(report.issues.some((issue) => issue.path === 'primitives.size.md.fontSize')).toBe(true)
expect(report.issues.some((issue) => issue.path === 'primitives.size.md.iconSize')).toBe(true)
expect(report.issues.some((issue) => issue.path === 'primitives.size.md.radius')).toBe(true)
expect(report.issues.some((issue) => issue.path === 'primitives.size.md.gap')).toBe(true)
})
it('validates border, opacity, z-index and shadow primitive keys', () => {
const cfg = structuredClone(THEME_BASE_OPTIONS)
delete (cfg.primitives.border!.width as Record<string, string>)['thin']
;(cfg.primitives.border as { defaultWidth: string }).defaultWidth = 'missing-width'
;(cfg.primitives.opacity as Record<string, string>)['ghost'] = '0.4'
delete (cfg.primitives.zIndex as Record<string, number>)['toast']
delete (cfg.themes!['base-light'].shadow as Record<string, string>)['6']
const report = createEidos(cfg).validate()
expect(report.ok).toBe(false)
expect(report.issues.some((issue) => issue.path === 'primitives.border.width.thin')).toBe(true)
expect(report.issues.some((issue) => issue.path === 'primitives.border.defaultWidth')).toBe(
true
)
expect(report.issues.some((issue) => issue.path === 'primitives.opacity.ghost')).toBe(true)
expect(report.issues.some((issue) => issue.path === 'primitives.zIndex.toast')).toBe(true)
expect(report.issues.some((issue) => issue.path === 'themes.base-light.shadow.6')).toBe(true)
})
it('validates static primitive keys and focus ring values', () => {
const cfg = structuredClone(THEME_BASE_OPTIONS)
;(cfg.primitives.space as Record<string, string>)['bad space'] = '4px'
delete (cfg.primitives.controlHeight as Record<string, string>)['md']
;(cfg.primitives.radius as Record<string, string>)['huge'] = '32px'
;(cfg.primitives.focusRing as { width: string }).width = ''
const report = createEidos(cfg).validate()
expect(report.ok).toBe(false)
expect(report.issues.some((issue) => issue.path === 'primitives.space.bad space')).toBe(true)
expect(report.issues.some((issue) => issue.path === 'primitives.controlHeight.md')).toBe(
true
)
expect(report.issues.some((issue) => issue.path === 'primitives.radius.huge')).toBe(true)
expect(report.issues.some((issue) => issue.path === 'primitives.focusRing.width')).toBe(true)
})
it('validates layout primitive keys', () => {
const cfg = structuredClone(THEME_BASE_OPTIONS)
delete (cfg.primitives.layout!.containerWidth as Record<string, string>)['md']
;(cfg.primitives.layout!.contentWidth as Record<string, string>)['mega'] = '96rem'
;(cfg.primitives.layout!.aspectRatio as Record<string, string>)['video'] = ''
;(cfg.primitives.layout as { containerPaddingInline: string }).containerPaddingInline = ''
const report = createEidos(cfg).validate()
expect(report.ok).toBe(false)
expect(
report.issues.some((issue) => issue.path === 'primitives.layout.containerWidth.md')
).toBe(true)
expect(
report.issues.some((issue) => issue.path === 'primitives.layout.contentWidth.mega')
).toBe(true)
expect(
report.issues.some((issue) => issue.path === 'primitives.layout.aspectRatio.video')
).toBe(true)
expect(
report.issues.some((issue) => issue.path === 'primitives.layout.containerPaddingInline')
).toBe(true)
})
it('validates density primitive keys', () => {
const cfg = structuredClone(THEME_BASE_OPTIONS)
;(cfg.primitives.density!.spaceScale as Record<string, string>)['dense'] = '0.7'
;(cfg.primitives.density!.controlScale as Record<string, string>)['spacious'] = ''
const report = createEidos(cfg).validate()
expect(report.ok).toBe(false)
expect(
report.issues.some((issue) => issue.path === 'primitives.density.spaceScale.dense')
).toBe(true)
expect(
report.issues.some((issue) => issue.path === 'primitives.density.controlScale.spacious')
).toBe(true)
})
it('validates motion and icon primitive keys', () => {
const cfg = structuredClone(THEME_BASE_OPTIONS)
delete (cfg.primitives.motion!.duration as Record<string, string>)['fast']
;(cfg.primitives.motion!.ease as Record<string, string>)['bounce'] = 'ease-out'
;(cfg.primitives.motion as { stagger: string }).stagger = ''
delete (cfg.primitives.icon!.size as Record<string, string>)['md']
;(cfg.primitives.icon!.strokeWidth as Record<string, number>)['hairline'] = 1
const report = createEidos(cfg).validate()
expect(report.ok).toBe(false)
expect(report.issues.some((issue) => issue.path === 'primitives.motion.duration.fast')).toBe(
true
)
expect(report.issues.some((issue) => issue.path === 'primitives.motion.ease.bounce')).toBe(
true
)
expect(report.issues.some((issue) => issue.path === 'primitives.motion.stagger')).toBe(true)
expect(report.issues.some((issue) => issue.path === 'primitives.icon.size.md')).toBe(true)
expect(
report.issues.some((issue) => issue.path === 'primitives.icon.strokeWidth.hairline')
).toBe(true)
})
it('validates recipe token keys and values', () => {
const cfg = structuredClone(THEME_BASE_OPTIONS)
;(cfg.recipes as Record<string, Record<string, string>>).BadRecipe = {
'ok-token': '1px'
}
;(cfg.recipes!.tooltip as Record<string, string>).BadToken = '1px'
;(cfg.recipes!.tooltip as Record<string, string>)['empty-token'] = ''
const report = createEidos(cfg).validate()
expect(report.ok).toBe(false)
expect(report.issues.some((issue) => issue.path === 'recipes.BadRecipe')).toBe(true)
expect(report.issues.some((issue) => issue.path === 'recipes.tooltip.BadToken')).toBe(true)
expect(report.issues.some((issue) => issue.path === 'recipes.tooltip.empty-token')).toBe(true)
})
it('rejects config CSS values that contain declaration delimiters', () => {
const cfg = structuredClone(THEME_BASE_OPTIONS)
;(cfg.recipes!.tooltip as Record<string, string>)['content-bg'] =
'red; color: transparent'
;(cfg.themes!['base-light'].color!.surface as unknown as Record<string, string>).default =
'Canvas { color: red }'
const report = createEidos(cfg).validate()
expect(report.ok).toBe(false)
expect(report.issues.some((issue) => issue.path === 'recipes.tooltip.content-bg')).toBe(true)
expect(
report.issues.some((issue) => issue.path === 'themes.base-light.color.surface.default')
).toBe(true)
})
it('validates role mappings against every configured theme', () => {
const cfg: EidosConfig = {
...THEME_BASE_OPTIONS,
themes: {
...THEME_BASE_OPTIONS.themes,
brand: {
color: {
scales: {
gray: colorScale('gray')
}
}
}
}
}
const report = createEidos(cfg).validate()
expect(report.ok).toBe(false)
expect(report.issues.some((issue) => issue.path === 'themes.brand.color.scales.purple')).toBe(
true
)
})
it('validates semantic color role slot overrides', () => {
const cfg = structuredClone(THEME_BASE_OPTIONS)
;(cfg.semantics.color.roles as unknown as Record<string, unknown>).primary = {
scale: 'purple',
slots: {
solid: '13',
madeUp: '9'
}
}
const report = createEidos(cfg).validate()
expect(report.ok).toBe(false)
expect(
report.issues.some((issue) => issue.path === 'semantics.color.roles.primary.slots.solid')
).toBe(true)
expect(
report.issues.some((issue) => issue.path === 'semantics.color.roles.primary.slots.madeUp')
).toBe(true)
})
it('validates CSS-facing theme and color scale keys', () => {
const cfg = structuredClone(THEME_BASE_OPTIONS)
;(cfg.primitives as { color: { scales: Record<string, ColorScale> } }).color = {
scales: {
'bad scale': colorScale('bad-scale')
}
}
;(cfg.themes as Record<string, unknown>)['bad theme'] = cfg.themes!['base-light']
;(cfg.themes!['base-light'].color!.scales as Record<string, ColorScale>)['BadScale'] =
colorScale('bad-scale')
const report = createEidos(cfg).validate()
expect(report.ok).toBe(false)
expect(
report.issues.some((issue) => issue.path === 'primitives.color.scales.bad scale')
).toBe(true)
expect(report.issues.some((issue) => issue.path === 'themes.bad theme')).toBe(true)
expect(
report.issues.some((issue) => issue.path === 'themes.base-light.color.scales.BadScale')
).toBe(true)
})
it('validates semantic color groups before rendering theme CSS', () => {
const cfg = structuredClone(THEME_BASE_OPTIONS)
;(cfg.semantics.color as unknown as Record<string, unknown>).surface = {
default: 'Canvas'
}
;(cfg.themes!['base-light'].color!.content as unknown as Record<string, string>).disabled =
''
;(cfg.themes!['base-light'].color!.border as unknown as Record<string, string>).ghost =
'transparent'
const report = createEidos(cfg).validate()
expect(report.ok).toBe(false)
expect(
report.issues.some((issue) => issue.path === 'semantics.color.surface.raised')
).toBe(true)
expect(
report.issues.some((issue) => issue.path === 'themes.base-light.color.content.disabled')
).toBe(true)
expect(
report.issues.some((issue) => issue.path === 'themes.base-light.color.border.ghost')
).toBe(true)
})
it('validates explicit alpha color scales against their physical scale', () => {
const cfg = structuredClone(THEME_BASE_OPTIONS)
const color = cfg.themes!['base-light'].color as {
alphaScales: Record<string, ColorScale>
}
color.alphaScales = {
purple: colorScale('purple-alpha'),
ghost: colorScale('ghost-alpha')
}
delete (color.alphaScales.purple as Partial<ColorScale>)['12']
const report = createEidos(cfg).validate()
expect(report.ok).toBe(false)
expect(
report.issues.some((issue) => issue.path === 'themes.base-light.color.alphaScales.purple.12')
).toBe(true)
expect(
report.issues.some((issue) => issue.path === 'themes.base-light.color.alphaScales.ghost')
).toBe(true)
})
it('throws with the validation report when asserted', () => {
const cfg = options()
delete (cfg.primitives.color!.scales.red as Partial<ColorScale>)['12']
const eidos = createEidos(cfg)
expect(() => eidos.assertValid()).toThrow(EidosConfigValidationError)
})
it('renders static primitive CSS without persisting it', () => {
const css = createThemeBaseEidos().renderStaticCss()
expect(css).toContain(':root {')
// Spacing + control heights scale by BOTH density and the global scaling
// zoom; font-size scales by scaling only. At comfortable + scaling=100 all
// factors are 1, so rendered sizes are unchanged — the tokens must carry
// the vars so [data-density]/[data-scaling] can rebind them.
expect(css).toContain('--space-4: calc(16px * var(--density-space-scale) * var(--scaling));')
expect(css).toContain("--font-family-primary: 'Instrument Sans', system-ui, sans-serif;")
expect(css).toContain('--font-size-xxxl: calc(80px * var(--scaling));')
expect(css).toContain(
'--control-height-xxs: calc(22px * var(--density-control-scale) * var(--scaling));'
)
expect(css).toContain('--border-width-thin: 1px;')
expect(css).toContain('--border-width: var(--border-width-thin);')
expect(css).toContain('--border-style: var(--border-style-solid);')
expect(css).toContain('--container-width-md: 688px;')
expect(css).toContain('--container-padding-inline: var(--space-4);')
expect(css).toContain('--content-width-prose: 65ch;')
expect(css).toContain('--aspect-ratio-video: 16 / 9;')
expect(css).toContain('--density-compact-space-scale: 0.84;')
expect(css).toContain('--density-space-scale: var(--density-comfortable-space-scale);')
expect(css).toContain("[data-density='compact'] {")
// The compact block REBINDS the per-axis scalars that drive rescaling.
// Combined with `--space-4: calc(16px * var(--density-space-scale) * …)` and
// the 0.84 compact scalar, this is the chain that makes a compact `--space-4`
// compute to 13.44px (regression guard for the density-inert bug).
expect(css).toContain('--density-space-scale: var(--density-compact-space-scale);')
expect(css).toContain('--density-control-scale: var(--density-compact-control-scale);')
expect(css).toContain('--density-compact-control-scale: 0.9;')
// Global scaling (zoom) axis — Radix parity, orthogonal to density.
expect(css).toContain('--scaling-110: 1.1;')
expect(css).toContain('--scaling: var(--scaling-100);')
expect(css).toContain("[data-scaling='110'] {")
expect(css).toContain('--opacity-disabled: 0.48;')
expect(css).toContain('--z-index-modal: 700;')
expect(css).toContain('--size-md-control-height: var(--control-height-md);')
expect(css).toContain('--size-md-font-size: var(--font-size-sm);')
expect(css).toContain('--size-md-icon-size: var(--icon-size-md);')
expect(css).toContain('--size-md-padding-inline: var(--space-3);')
expect(css).toContain('--tooltip-content-z: 76;')
expect(css).toContain('--dialog-overlay-bg: var(--color-overlay);')
expect(css).toContain('--toggle-height-md: var(--control-height-md);')
expect(css).not.toContain('--size-full-control-height')
})
it('renders the CSS custom-property contract for external themes', () => {
const eidos = createThemeBaseEidos()
const contract = eidos.getCssContract()
const css = eidos.renderContractCss({
staticSelector: ':root',
themeSelector: "[data-theme='external']"
})
expect(contract.static).toContainEqual({
name: 'size-md-control-height',
cssVar: '--size-md-control-height',
scope: 'static',
category: 'size',
path: 'primitives.size.md.controlHeight'
})
expect(contract.static).toContainEqual({
name: 'size-md-font-line-height',
cssVar: '--size-md-font-line-height',
scope: 'static',
category: 'size',
path: `primitives.typography.sizes.${STATIC_SIZE.md.fontSize}.lineHeight`
})
expect(contract.static).toContainEqual({
name: 'size-md-font-letter-spacing',
cssVar: '--size-md-font-letter-spacing',
scope: 'static',
category: 'size',
path: `primitives.typography.sizes.${STATIC_SIZE.md.fontSize}.letterSpacing`
})
expect(contract.static).toContainEqual({
name: 'container-width-md',
cssVar: '--container-width-md',
scope: 'static',
category: 'layout.container-width',
path: 'primitives.layout.containerWidth.md'
})
expect(contract.static).toContainEqual({
name: 'aspect-ratio-video',
cssVar: '--aspect-ratio-video',
scope: 'static',
category: 'layout.aspect-ratio',
path: 'primitives.layout.aspectRatio.video'
})
expect(contract.static).toContainEqual({
name: 'density-compact-space-scale',
cssVar: '--density-compact-space-scale',
scope: 'static',
category: 'density.space-scale',
path: 'primitives.density.spaceScale.compact'
})
expect(contract.static).toContainEqual({
name: 'density-space-scale',
cssVar: '--density-space-scale',
scope: 'static',
category: 'density.active',
path: 'primitives.density.spaceScale'
})
expect(contract.static).toContainEqual({
name: 'tooltip-content-z',
cssVar: '--tooltip-content-z',
scope: 'static',
category: 'recipe.tooltip',
path: 'recipes.tooltip.content-z'
})
expect(contract.theme).toContainEqual({
name: 'color-primary-solid',
cssVar: '--color-primary-solid',
scope: 'theme',
category: 'color.role',
path: 'semantics.color.roles.primary.slots.solid'
})
expect(contract.theme).toContainEqual({
name: 'primitive-primary-a9',
cssVar: '--primitive-primary-a9',
scope: 'theme',
category: 'color.primitive.alpha',
path: 'semantics.color.roles.primary.alpha.9'
})
expect(contract.static.some((token) => token.name === 'size-full-control-height')).toBe(false)
expect(css).toContain(':root {')
expect(css).toContain("[data-theme='external'] {")
expect(css).toContain('--space-4: ;')
expect(css).toContain('--font-size-xxxl: ;')
expect(css).toContain('--size-md-control-height: ;')
expect(css).toContain('--container-width-md: ;')
expect(css).toContain('--content-width-prose: ;')
expect(css).toContain('--aspect-ratio-video: ;')
expect(css).toContain('--density-compact-space-scale: ;')
expect(css).toContain('--density-space-scale: ;')
expect(css).toContain('--opacity-disabled: ;')
expect(css).toContain('--z-index-modal: ;')
expect(css).toContain('--border-width-thin: ;')
expect(css).toContain('--tooltip-content-z: ;')
expect(css).toContain('--scale-purple-9: ;')
expect(css).toContain('--scale-purple-a9: ;')
expect(css).toContain('--primitive-primary-9: ;')
expect(css).toContain('--primitive-primary-a9: ;')
expect(css).toContain('--color-primary-solid-hover: ;')
expect(css).toContain('--color-surface-default: ;')
expect(css).toContain('--focus-ring-color-error: ;')
expect(css).toContain('--shadow-overlay: ;')
expect(css).toContain('--shadow-6: ;')
expect(css).not.toContain('--size-full-control-height')
})
it('keeps the generated CSS renderers aligned with the CSS contract', () => {
const eidos = createThemeBaseEidos()
const contract = eidos.getCssContract()
const staticDeclarations = cssVariableDeclarations(eidos.renderStaticCss())
const themeDeclarations = cssVariableDeclarations(eidos.renderThemeCss('base-light'))
for (const token of contract.static) {
expect(staticDeclarations.has(token.cssVar), token.cssVar).toBe(true)
}
for (const token of contract.theme) {
expect(themeDeclarations.has(token.cssVar), token.cssVar).toBe(true)
}
})
it('renders contract-aware runtime CSS variables', () => {
const eidos = createThemeBaseEidos()
const css = eidos.renderCssVariables(
{
'--color-primary-solid': 'rebeccapurple',
'size-md-control-height': '40px'
},
{ selector: ':root' }
)
expect(css).toContain(':root {')
expect(css).toContain('--color-primary-solid: rebeccapurple;')
expect(css).toContain('--size-md-control-height: 40px;')
expect(() => eidos.renderCssVariables({ '--unknown-token': '1px' })).toThrow(
EidosCssVariableError
)
expect(eidos.renderCssVariables({ '--app-local-token': '1px' }, { strict: false })).toContain(
'--app-local-token: 1px;'
)
})
it('renders themed color CSS from physical palettes and semantic roles', () => {
const css = createThemeBaseEidos().renderThemeCss('base-light')
expect(css).toContain(':root {')
expect(css).toContain('--scale-purple-9: #8e4ec6;')
// Alpha is GENERATED as the compositing inverse of the solid step over the
// scale's background (white for light scales): rgb(92 0 173 / 0.6941) over
// white reproduces purple-9 (#8e4ec6). (audit P1-1)
expect(css).toContain('--scale-purple-a9: rgb(92 0 173 / 0.6941);')
expect(css).toContain('--primitive-primary-9: var(--scale-purple-9);')
expect(css).toContain('--primitive-primary-a9: var(--scale-purple-a9);')
expect(css).toContain('--primitive-tertiary-9: var(--scale-gray-9);')
expect(css).toContain('--color-primary-solid: var(--primitive-primary-9);')
expect(css).toContain('--color-primary-solid-hover: var(--primitive-primary-10);')
expect(css).toContain('--color-surface-default: var(--primitive-neutral-1);')
expect(css).toContain('--focus-ring-color: color-mix(in srgb, var(--primitive-primary-8)')
expect(css).toContain('--shadow-3: 0 8px 24px rgb(15 23 42 / 0.12);')
expect(css).toContain('--shadow-raised: var(--shadow-3);')
})
it('resolves the per-role contrast slot to on-solid by default and to an explicit step when overridden', () => {
const css = createThemeBaseEidos().renderThemeCss('base-light')
// Default `contrast` → the theme's on-solid ink (white-on-solid that
// inverts per mode), NOT the illegible step-12 fill text. Regression guard
// for the contrast-illegibility fix (THEMING §20.2).
expect(css).toContain(
'--color-primary-contrast: var(--color-content-on-solid, var(--primitive-primary-12));'
)
// `neutral` overrides `slots.contrast: '12'` because gray-9 is a mid-tone
// solid: the contrast ink must invert per mode (near-black light /
// near-white dark) to stay legible — AA in both modes (audit P0-2).
expect(css).toContain('--color-neutral-contrast: var(--primitive-neutral-12);')
})
it('renders dark-theme color CSS with the dark palette and role tokens', () => {
const css = createThemeBaseEidos().renderThemeCss('base-dark')
// The dark palette is genuinely dark-specific: gray-9 is darker than light
// (#8d8d8d) and the text ink gray-12 inverts to near-white (light is #202020).
expect(css).toContain('--scale-gray-9: #6e6e6e;')
expect(css).toContain('--scale-gray-12: #eeeeee;')
// Role tokens derive from the dark palette; the neutral contrast override
// holds in dark too (var ref resolves to ~#eee — the P0-2 fix).
expect(css).toContain('--primitive-neutral-9: var(--scale-gray-9);')
expect(css).toContain('--color-neutral-contrast: var(--primitive-neutral-12);')
})
it('rejects a malformed hex color value in a scale step', () => {
const report = createThemeBaseEidos({
themes: {
'base-light': {
color: {
scales: {
purple: { ...THEME_BASE_LIGHT_COLOR_SCALES.purple, '9': '#ggg' }
}
}
}
}
}).validate()
expect(report.ok).toBe(false)
expect(report.issues.some((issue) => issue.message.includes('invalid hex color'))).toBe(true)
})
it('rejects a theme whose effective surface colors are undefined', () => {
const cfg = structuredClone(THEME_BASE_OPTIONS)
// base-light defines surface; the global semantics.color does not. Removing
// it from the theme leaves the effective surface set undefined → the render
// would emit dangling `var(--color-surface-*)`. Validation must catch it.
delete (cfg.themes!['base-light'].color as unknown as Record<string, unknown>).surface
const report = createEidos(cfg).validate()
expect(report.ok).toBe(false)
expect(report.issues.map((issue) => issue.path)).toEqual(
expect.arrayContaining(['themes.base-light.color.surface'])
)
})
it('emits TSC v2.2 multi-part and cross-recipe composition selectors', () => {
const css = createThemeBaseEidos().renderStaticCss()
// Multi-part scope: a `parts: ['trigger','content']` token emits a
// comma-joined selector targeting both parts (select's content portals out
// of the trigger's subtree, so its color must scope to both). (audit B4)
expect(css).toContain(
"[data-select-trigger][data-color='affirm'], [data-select-content][data-color='affirm']"
)
// Cross-recipe composition: toggle-group overrides toggle's palette tokens,
// scoped to its own cascade + a descendant target selector.
expect(css).toContain("[data-toggle-group][data-color='primary'] [data-toggle-group-item]")
})
it('renders raw theme CSS with a selector derived only from the theme id', () => {
const css = renderThemeCss(THEME_BASE_OPTIONS, 'base-light')
expect(css).toContain("[data-theme='base-light'] {")
expect(css).not.toContain(':root')
})
it('renders explicit alpha color scales when a theme provides them', () => {
const eidos = createThemeBaseEidos({
themes: {
'base-light': {
color: {
alphaScales: {
purple: colorScale('purple-alpha')
}
}
}
}
})
const css = eidos.renderThemeCss('base-light')
expect(COLOR_ALPHA_STEPS).toEqual(COLOR_SCALE_STEPS)
expect(css).toContain('--scale-purple-a4: purple-alpha-4;')
expect(css).toContain('--primitive-primary-a4: var(--scale-purple-a4);')
})
it('throws when rendering a missing theme', () => {
const eidos = createThemeBaseEidos()
expect(() => eidos.renderThemeCss('missing')).toThrow(EidosThemeNotFoundError)
})
})

Powered by TurnKey Linux.