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

1139 lines
40 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

import { describe, expect, it } from 'vitest'
import { renderThemeCss } from './lib/render-css'
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,
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()
// The patch overrides only `family`; fallbacks/source/faces deep-merge from the
// base (a real font swap would also override `faces` with its own files).
const mergedPrimary = cfg.primitives.typography?.families.primary
expect(mergedPrimary?.family).toBe('Inter')
expect(mergedPrimary?.fallbacks).toEqual(['system-ui', 'sans-serif'])
expect(mergedPrimary?.source).toBe('local')
expect(mergedPrimary?.faces?.length).toBe(3)
// Base constant not mutated.
expect(THEME_BASE_OPTIONS.primitives.typography?.families.primary.family).toBe(
'Instrument Sans'
)
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).toBe('xxxl')
expect(typography.styles.h1.family).toBe('display')
expect(typography.styles.body.family).toBe('primary')
})
it('generates @font-face blocks from the typography config, deduped by font', () => {
const css = createThemeBaseEidos().renderStaticCss()
expect(css).toContain('@font-face')
expect(css).toContain("font-family: 'Instrument Sans';")
expect(css).toContain("src: url('/fonts/air/Lora-Bold.ttf') format('truetype');")
expect(css).toContain('font-style: italic;')
// Lora is shared by `secondary` + `display` → its 8 faces emit once, not 16.
expect((css.match(/font-family: 'Lora';/g) ?? []).length).toBe(8)
})
it('emits variable-font output from family axes (weight range + optical sizing)', () => {
const cfg = structuredClone(THEME_BASE_OPTIONS)
const t = cfg.primitives.typography!
// Declare `primary` as a variable font: one face spanning the wght axis + an opsz
// axis (enables optical sizing). The face omits an explicit weight on purpose so the
// range is derived from the axis.
;(t.families as Record<string, unknown>).primary = {
...t.families.primary,
axes: { wght: [100, 900], opsz: [8, 144] },
faces: [
{ sources: [{ url: '/fonts/variable/InstrumentSans.woff2', format: 'woff2-variations' }] }
]
}
const css = createEidos(cfg).renderStaticCss()
// wght axis → @font-face weight range (a single face spans 100–900)
expect(css).toContain('font-weight: 100 900;')
// opsz axis → optical sizing enabled in :root
expect(css).toContain('font-optical-sizing: auto')
})
it('emits the Phase 3 typography scales + optical tracking', () => {
const css = createThemeBaseEidos().renderStaticCss()
expect(css).toContain('--tracking-tight: -0.02em;')
expect(css).toContain('--leading-normal: 1.5;')
expect(css).toContain('--font-feature-tabular: "tnum" 1, "lnum" 1;')
expect(css).toContain('--measure-normal: 66ch;')
// optical tracking baked into the size tokens (display negative, small positive)
expect(css).toContain('--font-letter-spacing-xxxl: -0.02em;')
expect(css).toContain('--font-letter-spacing-xxs: 0.01em;')
})
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(clamp(2.5rem, calc(1rem + 5vw), 5rem) * 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-indigo-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.