|
|
|
|
|
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'
|
|
|
|
|
|
])
|
|
|
|
|
|
})
|
|
|
|
|
|
|
Theming: Radix-parity palette + intent auto-derivation + color-model docs
Two-level color model settled (THEMING.md section 25), replacing the anchor RFC:
the palette is the source (scales, directly usable, designable); hierarchy roles
alias scales explicitly; intents auto-derive from the palette by the book's
canonical convention.
- Palette library expanded 12 -> 31 scales at Radix Colors parity (exact values):
radix-scales.ts (19 added: mauve/sage/olive/sand/tomato/ruby/crimson/plum/
violet/iris/indigo/jade/grass/brown/sky/mint/lime/gold/bronze) spread into
base.ts. Each directly usable as --scale-{name}-{step}.
- Intent auto-derivation: CANONICAL_INTENT_SCALES (neutral->gray, affirm->teal,
fulfill->green, risk->amber, threat->red, loss->plum) + completeColorRoleMap.
Intents omitted from a theme role map fill from the convention (identity =
step 9); slots derive normally; override optional. ColorRoleMap: hierarchy
required, intents optional.
- Validation: hierarchy roles required; omitted intents validate the canonical
scale exists in the palette.
- index: export ScalingKey / SCALING_KEYS.
- docs: THEMING.md section 25 (full color model + decisions), section 21 marked
resolved, COLOR_MODEL_RFC resolved (anchor rejected).
- test: base library asserts 31 scales x 12 steps.
Verified: npm run check (0 new errors), vitest eidos (0 new regressions),
browser (intents auto-derive: affirm=teal #0E9384, risk=amber #DC6803,
loss=plum #7A3AAD at step 9).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
4 months ago
|
|
|
|
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')
|
Theming: Radix-parity palette + intent auto-derivation + color-model docs
Two-level color model settled (THEMING.md section 25), replacing the anchor RFC:
the palette is the source (scales, directly usable, designable); hierarchy roles
alias scales explicitly; intents auto-derive from the palette by the book's
canonical convention.
- Palette library expanded 12 -> 31 scales at Radix Colors parity (exact values):
radix-scales.ts (19 added: mauve/sage/olive/sand/tomato/ruby/crimson/plum/
violet/iris/indigo/jade/grass/brown/sky/mint/lime/gold/bronze) spread into
base.ts. Each directly usable as --scale-{name}-{step}.
- Intent auto-derivation: CANONICAL_INTENT_SCALES (neutral->gray, affirm->teal,
fulfill->green, risk->amber, threat->red, loss->plum) + completeColorRoleMap.
Intents omitted from a theme role map fill from the convention (identity =
step 9); slots derive normally; override optional. ColorRoleMap: hierarchy
required, intents optional.
- Validation: hierarchy roles required; omitted intents validate the canonical
scale exists in the palette.
- index: export ScalingKey / SCALING_KEYS.
- docs: THEMING.md section 25 (full color model + decisions), section 21 marked
resolved, COLOR_MODEL_RFC resolved (anchor rejected).
- test: base library asserts 31 scales x 12 steps.
Verified: npm run check (0 new errors), vitest eidos (0 new regressions),
browser (intents auto-derive: affirm=teal #0E9384, risk=amber #DC6803,
loss=plum #7A3AAD at step 9).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
4 months ago
|
|
|
|
// 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()
|
feat(eidos): config-driven @font-face (Phase 2) — fonts as theme data (next/font model)
TYPOGRAPHY_ENGINE_RFC Phase 2. Fonts are theme data (each theme owns its families), so
@font-face becomes config-driven + generated — like the color palette — instead of a
separate hand-written CSS file. This matches next/font / Fontaine (config -> @font-face),
above the token-only frameworks (Radix/Tailwind/MUI) that leave loading to you.
- config-types: FontFamily += faces (FontFace[]) / axes (FontAxes) / fallback
(FontFallback, metric-override) / display / preload. Additive — the family stack still
works from `family`+`fallbacks`.
- render-css: renderFontFaceBlocks generates @font-face per face from the config, deduped
by the real font name (a font shared across slots — Lora as secondary+display — emits
once). Optional metric-override fallback @font-face (anti-CLS) injected into the stack
as `'{family} Fallback'` when declared. Emitted first in renderStaticCss.
- typography.ts: the BASE THEME's 14 @font-face migrated from fonts.css into the config
(faces). TTF today (the theme's choice); a theme swaps to woff2/variable + fallback
metrics by editing config only.
- index.css: drops `@import './themes/fonts.css'` — the @font-face now ships in
generated/base.css. (fonts.css superseded; left in place, no longer imported.)
- generated/base.css regenerated (14 @font-face, Lora deduped). test: @font-face
generation + dedup; merge-without-mutation assertion updated for the faces field.
Verified in browser: 3 families registered, files resolve (200), fonts load on demand
(swap). check 0 errors; eidos suite green (3 pre-existing words failures only).
Deferred (capability typed, theme adopts when it has the assets): variable woff2,
metric-override numbers (need fontkit/precompute), <link rel=preload> (head markup).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
4 months ago
|
|
|
|
// 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])
|
feat(eidos): fluid typography engine (Phase 1) — Utopia clamp, rem, fluid headings
TYPOGRAPHY_ENGINE_RFC Phase 1. Additive on TypographyPrimitiveSet, behind the frozen
token contract (--font-size-X keeps its name; only the value formula changes, like
color --scale-* hex -> oklch()).
- type-scale.ts (pure, isomorphic, no canvas): the Utopia clamp() formula. fluidClamp /
resolveTypeSize / isFluidSize. rem-based (a11y: scales with browser font-zoom).
- config-types: TextMetric.size accepts `string | FluidSize` ({min,max,minVw?,maxVw?}).
Plain length strings still valid -> backward-compatible.
- render-css appendTypographyDeclarations: emits calc(resolveTypeSize(size) * --scaling)
-> a fixed rem or a fluid clamp; the --scaling axis composes on top.
- config.ts: validateSizeValue accepts a FluidSize (validates min/max/minVw/maxVw) so
the base config validates (was the cascade root — FluidSize objects failed the
string-only CSS-value check).
- typography.ts: sizes in rem; headings (lg/xl/xxl/xxxl) fluid (min @480px -> max
@1280px, max = previous fixed px so desktop is unchanged); body (md) fixed. hero/h1/h2
drop the manual { base, md } responsive sizes — the clamp covers the viewport.
- generated/base.css regenerated. type-scale.test.ts (6 tests). 2 config-test assertions
updated to the new rem/clamp values.
Verified in browser: --font-size-xxxl 40px @480 -> 80px @1280; xxl 32->48; lg 18->20;
md 16 fixed. check 0 errors; eidos suite green (3 pre-existing words failures only).
canvas-text/<SText> unaffected (reads getComputedStyle real font, measures the clamp).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
4 months ago
|
|
|
|
expect(typography.styles.hero.size).toBe('xxxl')
|
|
|
|
|
|
expect(typography.styles.h1.family).toBe('display')
|
|
|
|
|
|
expect(typography.styles.body.family).toBe('primary')
|
|
|
|
|
|
})
|
|
|
|
|
|
|
feat(eidos): config-driven @font-face (Phase 2) — fonts as theme data (next/font model)
TYPOGRAPHY_ENGINE_RFC Phase 2. Fonts are theme data (each theme owns its families), so
@font-face becomes config-driven + generated — like the color palette — instead of a
separate hand-written CSS file. This matches next/font / Fontaine (config -> @font-face),
above the token-only frameworks (Radix/Tailwind/MUI) that leave loading to you.
- config-types: FontFamily += faces (FontFace[]) / axes (FontAxes) / fallback
(FontFallback, metric-override) / display / preload. Additive — the family stack still
works from `family`+`fallbacks`.
- render-css: renderFontFaceBlocks generates @font-face per face from the config, deduped
by the real font name (a font shared across slots — Lora as secondary+display — emits
once). Optional metric-override fallback @font-face (anti-CLS) injected into the stack
as `'{family} Fallback'` when declared. Emitted first in renderStaticCss.
- typography.ts: the BASE THEME's 14 @font-face migrated from fonts.css into the config
(faces). TTF today (the theme's choice); a theme swaps to woff2/variable + fallback
metrics by editing config only.
- index.css: drops `@import './themes/fonts.css'` — the @font-face now ships in
generated/base.css. (fonts.css superseded; left in place, no longer imported.)
- generated/base.css regenerated (14 @font-face, Lora deduped). test: @font-face
generation + dedup; merge-without-mutation assertion updated for the faces field.
Verified in browser: 3 families registered, files resolve (200), fonts load on demand
(swap). check 0 errors; eidos suite green (3 pre-existing words failures only).
Deferred (capability typed, theme adopts when it has the assets): variable woff2,
metric-override numbers (need fontkit/precompute), <link rel=preload> (head markup).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
4 months ago
|
|
|
|
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('applyTypeScale returns a built scale (runtime type-scale builder)', () => {
|
|
|
|
|
|
const eidos = createThemeBaseEidos();
|
|
|
|
|
|
const result = eidos.applyTypeScale({ base: 16, ratio: 1.25 });
|
|
|
|
|
|
expect(result.sizes).toHaveLength(8);
|
|
|
|
|
|
expect(result.variables).toContain('--font-size-md: calc(1rem * var(--scaling));');
|
|
|
|
|
|
eidos.clearTypeScale(); // reverts cleanly, no throw
|
|
|
|
|
|
});
|
|
|
|
|
|
|
feat(eidos): depth Fase 2 (oklab rim halo) + reference-grade /temas/profundidad
Depth engine — Fase 2 (mode-adaptive mezcla):
- New `halo` cue per plane: a top-edge rim-light computed in oklab
(color-mix(in oklab, white N%, transparent); 5/7/8% on raised/overlay/modal).
The `[data-depth]` box-shadow now composes `shadow, halo`. Invisible on light
surfaces (the drop shadow leads), the lift cue on dark surfaces (where the drop
shadow barely shows) — the mode-adaptive answer to "shadow lies in dark",
scoped to the depth channel (global --shadow-* untouched).
- Wired through config-types (DepthPlane.halo) + render-css (declare + compose)
+ config validation + STATIC_DEPTH + regenerated generated/base.css.
Showcase — /temas/profundidad to reference depth (4 -> 9 sections): matches
Material elevation catalog breadth and adds the two axes it lacks (eventful +
open cage):
- Responde a cada estado — dynamic elevation, live interactive control
- La escalera de planos — z-stack of the 5 planes
- Catalogo de planos en reposo — the resting-elevation spec table, our vocabulary
- Luz vs sombra — light/dark side-by-side showing the halo mechanism
- Accesibilidad — never the only channel, reduced-motion, forced-colors, contrast
- Fix: mirror data-theme onto <html> so :root depth tokens stay mode-aware
Docs: DEPTH_ENGINE_RFC (Fase 2 + 5 done, token contract + halo), THEMING 29.
Verified: npm run check 0 errors; depth tests 50/50 (updated the box-shadow
assertion to the shadow,halo composition). Pre-existing `words` recipe-contract
failures unrelated.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
4 months ago
|
|
|
|
it('emits depth plane tokens + [data-depth] rules (composing surface/shadow/halo/z)', () => {
|
|
|
|
|
|
const css = createThemeBaseEidos().renderStaticCss();
|
feat(eidos): depth Fase 2 (oklab rim halo) + reference-grade /temas/profundidad
Depth engine — Fase 2 (mode-adaptive mezcla):
- New `halo` cue per plane: a top-edge rim-light computed in oklab
(color-mix(in oklab, white N%, transparent); 5/7/8% on raised/overlay/modal).
The `[data-depth]` box-shadow now composes `shadow, halo`. Invisible on light
surfaces (the drop shadow leads), the lift cue on dark surfaces (where the drop
shadow barely shows) — the mode-adaptive answer to "shadow lies in dark",
scoped to the depth channel (global --shadow-* untouched).
- Wired through config-types (DepthPlane.halo) + render-css (declare + compose)
+ config validation + STATIC_DEPTH + regenerated generated/base.css.
Showcase — /temas/profundidad to reference depth (4 -> 9 sections): matches
Material elevation catalog breadth and adds the two axes it lacks (eventful +
open cage):
- Responde a cada estado — dynamic elevation, live interactive control
- La escalera de planos — z-stack of the 5 planes
- Catalogo de planos en reposo — the resting-elevation spec table, our vocabulary
- Luz vs sombra — light/dark side-by-side showing the halo mechanism
- Accesibilidad — never the only channel, reduced-motion, forced-colors, contrast
- Fix: mirror data-theme onto <html> so :root depth tokens stay mode-aware
Docs: DEPTH_ENGINE_RFC (Fase 2 + 5 done, token contract + halo), THEMING 29.
Verified: npm run check 0 errors; depth tests 50/50 (updated the box-shadow
assertion to the shadow,halo composition). Pre-existing `words` recipe-contract
failures unrelated.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
4 months ago
|
|
|
|
// surface/shadow/z compose the existing primitives — no new math there
|
|
|
|
|
|
expect(css).toContain('--depth-overlay-shadow: var(--shadow-overlay);');
|
|
|
|
|
|
expect(css).toContain('--depth-overlay-z: var(--z-index-popover);');
|
|
|
|
|
|
expect(css).toContain('--depth-raised-surface: var(--color-surface-raised);');
|
feat(eidos): depth Fase 2 (oklab rim halo) + reference-grade /temas/profundidad
Depth engine — Fase 2 (mode-adaptive mezcla):
- New `halo` cue per plane: a top-edge rim-light computed in oklab
(color-mix(in oklab, white N%, transparent); 5/7/8% on raised/overlay/modal).
The `[data-depth]` box-shadow now composes `shadow, halo`. Invisible on light
surfaces (the drop shadow leads), the lift cue on dark surfaces (where the drop
shadow barely shows) — the mode-adaptive answer to "shadow lies in dark",
scoped to the depth channel (global --shadow-* untouched).
- Wired through config-types (DepthPlane.halo) + render-css (declare + compose)
+ config validation + STATIC_DEPTH + regenerated generated/base.css.
Showcase — /temas/profundidad to reference depth (4 -> 9 sections): matches
Material elevation catalog breadth and adds the two axes it lacks (eventful +
open cage):
- Responde a cada estado — dynamic elevation, live interactive control
- La escalera de planos — z-stack of the 5 planes
- Catalogo de planos en reposo — the resting-elevation spec table, our vocabulary
- Luz vs sombra — light/dark side-by-side showing the halo mechanism
- Accesibilidad — never the only channel, reduced-motion, forced-colors, contrast
- Fix: mirror data-theme onto <html> so :root depth tokens stay mode-aware
Docs: DEPTH_ENGINE_RFC (Fase 2 + 5 done, token contract + halo), THEMING 29.
Verified: npm run check 0 errors; depth tests 50/50 (updated the box-shadow
assertion to the shadow,halo composition). Pre-existing `words` recipe-contract
failures unrelated.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
4 months ago
|
|
|
|
// Fase 2 — the rim-light halo is the one computed cue (oklab), the dark-mode lift signal
|
|
|
|
|
|
expect(css).toContain(
|
|
|
|
|
|
'--depth-overlay-halo: inset 0 1px 0 0 color-mix(in oklab, white 7%, transparent);'
|
|
|
|
|
|
);
|
|
|
|
|
|
// the rule applies the safe additive cues; box-shadow composes the drop shadow + the rim
|
|
|
|
|
|
expect(css).toContain("[data-depth='overlay'] {");
|
feat(eidos): depth Fase 2 (oklab rim halo) + reference-grade /temas/profundidad
Depth engine — Fase 2 (mode-adaptive mezcla):
- New `halo` cue per plane: a top-edge rim-light computed in oklab
(color-mix(in oklab, white N%, transparent); 5/7/8% on raised/overlay/modal).
The `[data-depth]` box-shadow now composes `shadow, halo`. Invisible on light
surfaces (the drop shadow leads), the lift cue on dark surfaces (where the drop
shadow barely shows) — the mode-adaptive answer to "shadow lies in dark",
scoped to the depth channel (global --shadow-* untouched).
- Wired through config-types (DepthPlane.halo) + render-css (declare + compose)
+ config validation + STATIC_DEPTH + regenerated generated/base.css.
Showcase — /temas/profundidad to reference depth (4 -> 9 sections): matches
Material elevation catalog breadth and adds the two axes it lacks (eventful +
open cage):
- Responde a cada estado — dynamic elevation, live interactive control
- La escalera de planos — z-stack of the 5 planes
- Catalogo de planos en reposo — the resting-elevation spec table, our vocabulary
- Luz vs sombra — light/dark side-by-side showing the halo mechanism
- Accesibilidad — never the only channel, reduced-motion, forced-colors, contrast
- Fix: mirror data-theme onto <html> so :root depth tokens stay mode-aware
Docs: DEPTH_ENGINE_RFC (Fase 2 + 5 done, token contract + halo), THEMING 29.
Verified: npm run check 0 errors; depth tests 50/50 (updated the box-shadow
assertion to the shadow,halo composition). Pre-existing `words` recipe-contract
failures unrelated.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
4 months ago
|
|
|
|
expect(css).toContain('box-shadow: var(--depth-overlay-shadow), var(--depth-overlay-halo);');
|
|
|
|
|
|
expect(css).toContain('z-index: var(--depth-overlay-z);');
|
feat(eidos): depth Fase 2 (oklab rim halo) + reference-grade /temas/profundidad
Depth engine — Fase 2 (mode-adaptive mezcla):
- New `halo` cue per plane: a top-edge rim-light computed in oklab
(color-mix(in oklab, white N%, transparent); 5/7/8% on raised/overlay/modal).
The `[data-depth]` box-shadow now composes `shadow, halo`. Invisible on light
surfaces (the drop shadow leads), the lift cue on dark surfaces (where the drop
shadow barely shows) — the mode-adaptive answer to "shadow lies in dark",
scoped to the depth channel (global --shadow-* untouched).
- Wired through config-types (DepthPlane.halo) + render-css (declare + compose)
+ config validation + STATIC_DEPTH + regenerated generated/base.css.
Showcase — /temas/profundidad to reference depth (4 -> 9 sections): matches
Material elevation catalog breadth and adds the two axes it lacks (eventful +
open cage):
- Responde a cada estado — dynamic elevation, live interactive control
- La escalera de planos — z-stack of the 5 planes
- Catalogo de planos en reposo — the resting-elevation spec table, our vocabulary
- Luz vs sombra — light/dark side-by-side showing the halo mechanism
- Accesibilidad — never the only channel, reduced-motion, forced-colors, contrast
- Fix: mirror data-theme onto <html> so :root depth tokens stay mode-aware
Docs: DEPTH_ENGINE_RFC (Fase 2 + 5 done, token contract + halo), THEMING 29.
Verified: npm run check 0 errors; depth tests 50/50 (updated the box-shadow
assertion to the shadow,halo composition). Pre-existing `words` recipe-contract
failures unrelated.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
4 months ago
|
|
|
|
// a plane without a halo (flush) keeps a single-value box-shadow — no dangling comma
|
|
|
|
|
|
expect(css).toContain('box-shadow: var(--depth-flush-shadow);');
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('depth planes are config-driven — a theme can add a plane (jaula abierta)', () => {
|
|
|
|
|
|
const cfg = structuredClone(THEME_BASE_OPTIONS);
|
|
|
|
|
|
;(cfg.primitives as Record<string, unknown>).depth = {
|
|
|
|
|
|
planes: { sheet: { shadow: '0 -8px 30px rgba(0, 0, 0, 0.2)', z: 'var(--z-index-modal)' } }
|
|
|
|
|
|
};
|
|
|
|
|
|
const css = createEidos(cfg).renderStaticCss();
|
|
|
|
|
|
expect(css).toContain('--depth-sheet-shadow: 0 -8px 30px rgba(0, 0, 0, 0.2);');
|
|
|
|
|
|
expect(css).toContain("[data-depth='sheet'] {");
|
|
|
|
|
|
});
|
|
|
|
|
|
|
feat(eidos): typography scales — tracking/leading/features/measure + optical tracking (Phase 3a)
TYPOGRAPHY_ENGINE_RFC Phase 3 (engine tokens). Additive scales, pure CSS, no binaries.
- config-types: TypographyPrimitiveSet += tracking / leading / features / measure
(optional Record<string,string>).
- render-css: emits --tracking-{k} / --leading-{k} / --font-feature-{k} / --measure-{k}.
- typography.ts defaults: tracking (tighter…wider), leading (none…loose), features
(tabular = "tnum"+"lnum" for data, oldstyle/smallcaps/ligatures), measure (54/66/78ch).
+ optical tracking baked into the size tokens: small text slightly looser
(xxs +0.01em), display tighter (xxxl -0.02em) — was all 0.
- config.ts: validates the 4 scales (CSS-value maps).
- generated/base.css regenerated. test: scales + optical tracking emission.
Additive tokens (recipes/components consume var(--tracking-tight) etc.) so the scales
are zero-risk; the only rendered change is the gentle optical tracking on headings/small
text. check 0 errors; eidos suite green (3 pre-existing words failures only).
Next (Phase 3b): wire the component props (wrap: balance/pretty, numeric: tabular,
tracking/leading/measure) on <Text>/<Heading>.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
4 months ago
|
|
|
|
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 {')
|
Theming: add `scaling` zoom axis (Radix parity), separate from density
Introduce a global zoom axis independent of density, in parity with Radix
Themes' `scaling` (90/95/100/105/110%). Scaling zooms px metrics INCLUDING
typography (font-size, icon-size, space, control-height); density only moves
layout rhythm + control height and leaves text fixed. The two axes compose
multiplicatively.
- config-types: SCALING_KEYS / ScalingKey / DEFAULT_SCALING; DensityPrimitiveSet
drops the dead `scale` + `contentScale` (kept spaceScale, controlScale).
- primitives/static: STATIC_SCALING (0.9..1.1).
- render-css: appendScaledMetricDeclarations wraps metrics in
calc(<raw>[ * var(--density-x-scale)] * var(--scaling)); appendScalingDeclarations
emits --scaling-{key} + --scaling default; renderScalingBlocks emits
[data-scaling] blocks. line-height/radius/border/shadow excluded.
- config + contract: prune the removed density scalars.
- active-eidos: `scaling` / `scalingSource` options, getScaling() on the
preference source, data-scaling projection + dispose cleanup.
- docs: THEMING.md section 23 + 20.1 reconcile; README density/scaling; SCALING_RFC.md.
Verified: npm run check (0 new errors), vitest eidos (0 new regressions),
browser cascade at 90/100/110 scales font/space/control x0.9/x1.1 and leaves
radius/border fixed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
4 months ago
|
|
|
|
// 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;")
|
feat(eidos): fluid typography engine (Phase 1) — Utopia clamp, rem, fluid headings
TYPOGRAPHY_ENGINE_RFC Phase 1. Additive on TypographyPrimitiveSet, behind the frozen
token contract (--font-size-X keeps its name; only the value formula changes, like
color --scale-* hex -> oklch()).
- type-scale.ts (pure, isomorphic, no canvas): the Utopia clamp() formula. fluidClamp /
resolveTypeSize / isFluidSize. rem-based (a11y: scales with browser font-zoom).
- config-types: TextMetric.size accepts `string | FluidSize` ({min,max,minVw?,maxVw?}).
Plain length strings still valid -> backward-compatible.
- render-css appendTypographyDeclarations: emits calc(resolveTypeSize(size) * --scaling)
-> a fixed rem or a fluid clamp; the --scaling axis composes on top.
- config.ts: validateSizeValue accepts a FluidSize (validates min/max/minVw/maxVw) so
the base config validates (was the cascade root — FluidSize objects failed the
string-only CSS-value check).
- typography.ts: sizes in rem; headings (lg/xl/xxl/xxxl) fluid (min @480px -> max
@1280px, max = previous fixed px so desktop is unchanged); body (md) fixed. hero/h1/h2
drop the manual { base, md } responsive sizes — the clamp covers the viewport.
- generated/base.css regenerated. type-scale.test.ts (6 tests). 2 config-test assertions
updated to the new rem/clamp values.
Verified in browser: --font-size-xxxl 40px @480 -> 80px @1280; xxl 32->48; lg 18->20;
md 16 fixed. check 0 errors; eidos suite green (3 pre-existing words failures only).
canvas-text/<SText> unaffected (reads getComputedStyle real font, measures the clamp).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
4 months ago
|
|
|
|
expect(css).toContain(
|
|
|
|
|
|
'--font-size-xxxl: calc(clamp(2.5rem, calc(1rem + 5vw), 5rem) * var(--scaling));'
|
|
|
|
|
|
)
|
Theming: add `scaling` zoom axis (Radix parity), separate from density
Introduce a global zoom axis independent of density, in parity with Radix
Themes' `scaling` (90/95/100/105/110%). Scaling zooms px metrics INCLUDING
typography (font-size, icon-size, space, control-height); density only moves
layout rhythm + control height and leaves text fixed. The two axes compose
multiplicatively.
- config-types: SCALING_KEYS / ScalingKey / DEFAULT_SCALING; DensityPrimitiveSet
drops the dead `scale` + `contentScale` (kept spaceScale, controlScale).
- primitives/static: STATIC_SCALING (0.9..1.1).
- render-css: appendScaledMetricDeclarations wraps metrics in
calc(<raw>[ * var(--density-x-scale)] * var(--scaling)); appendScalingDeclarations
emits --scaling-{key} + --scaling default; renderScalingBlocks emits
[data-scaling] blocks. line-height/radius/border/shadow excluded.
- config + contract: prune the removed density scalars.
- active-eidos: `scaling` / `scalingSource` options, getScaling() on the
preference source, data-scaling projection + dispose cleanup.
- docs: THEMING.md section 23 + 20.1 reconcile; README density/scaling; SCALING_RFC.md.
Verified: npm run check (0 new errors), vitest eidos (0 new regressions),
browser cascade at 90/100/110 scales font/space/control x0.9/x1.1 and leaves
radius/border fixed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
4 months ago
|
|
|
|
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'] {")
|
Theming: add `scaling` zoom axis (Radix parity), separate from density
Introduce a global zoom axis independent of density, in parity with Radix
Themes' `scaling` (90/95/100/105/110%). Scaling zooms px metrics INCLUDING
typography (font-size, icon-size, space, control-height); density only moves
layout rhythm + control height and leaves text fixed. The two axes compose
multiplicatively.
- config-types: SCALING_KEYS / ScalingKey / DEFAULT_SCALING; DensityPrimitiveSet
drops the dead `scale` + `contentScale` (kept spaceScale, controlScale).
- primitives/static: STATIC_SCALING (0.9..1.1).
- render-css: appendScaledMetricDeclarations wraps metrics in
calc(<raw>[ * var(--density-x-scale)] * var(--scaling)); appendScalingDeclarations
emits --scaling-{key} + --scaling default; renderScalingBlocks emits
[data-scaling] blocks. line-height/radius/border/shadow excluded.
- config + contract: prune the removed density scalars.
- active-eidos: `scaling` / `scalingSource` options, getScaling() on the
preference source, data-scaling projection + dispose cleanup.
- docs: THEMING.md section 23 + 20.1 reconcile; README density/scaling; SCALING_RFC.md.
Verified: npm run check (0 new errors), vitest eidos (0 new regressions),
browser cascade at 90/100/110 scales font/space/control x0.9/x1.1 and leaves
radius/border fixed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
4 months ago
|
|
|
|
// 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;')
|
Theming: add `scaling` zoom axis (Radix parity), separate from density
Introduce a global zoom axis independent of density, in parity with Radix
Themes' `scaling` (90/95/100/105/110%). Scaling zooms px metrics INCLUDING
typography (font-size, icon-size, space, control-height); density only moves
layout rhythm + control height and leaves text fixed. The two axes compose
multiplicatively.
- config-types: SCALING_KEYS / ScalingKey / DEFAULT_SCALING; DensityPrimitiveSet
drops the dead `scale` + `contentScale` (kept spaceScale, controlScale).
- primitives/static: STATIC_SCALING (0.9..1.1).
- render-css: appendScaledMetricDeclarations wraps metrics in
calc(<raw>[ * var(--density-x-scale)] * var(--scaling)); appendScalingDeclarations
emits --scaling-{key} + --scaling default; renderScalingBlocks emits
[data-scaling] blocks. line-height/radius/border/shadow excluded.
- config + contract: prune the removed density scalars.
- active-eidos: `scaling` / `scalingSource` options, getScaling() on the
preference source, data-scaling projection + dispose cleanup.
- docs: THEMING.md section 23 + 20.1 reconcile; README density/scaling; SCALING_RFC.md.
Verified: npm run check (0 new errors), vitest eidos (0 new regressions),
browser cascade at 90/100/110 scales font/space/control x0.9/x1.1 and leaves
radius/border fixed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
4 months ago
|
|
|
|
// 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)
|
|
|
|
|
|
})
|
|
|
|
|
|
})
|