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/recipe-css-contract.test.ts

1200 lines
49 KiB

import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
import { THEME_BASE_RECIPE_TOKENS } from './lib/recipes/base'
import {
COLOR_ROLES,
type EidosConfig,
type RecipeTokenSet,
type RecipeTokenValue
} from './lib/config-types'
import { createEidosCssContract } from './lib/contract'
import { renderStaticCss } from './lib/render-css'
import { createThemeBaseEidosConfig } from './lib/themes/base'
import { EIDOS_VARIANTS, EIDOS_VARIANT_VALUES, PALETTE_SCALES } from './lib/types'
/**
* Build a minimal EidosConfig whose recipes are the given map. Reuses the
* base config's primitives/semantics so the generator has the same
* surface to render against as the production base.
*/
function configWithRecipes(recipes: RecipeTokenSet): EidosConfig {
const base = createThemeBaseEidosConfig()
return { ...base, recipes }
}
const COMPONENTS_DIR = 'src/uix/eidos/components'
const LIB_DIR = 'src/uix/eidos/lib'
const CSS_CUSTOM_PROPERTY = /--([a-z][a-z0-9]*-[a-z0-9-]+)/g
const COMPONENT_SOURCE_FILE = /\.(css|svelte|ts)$/
const RAW_COLOR_LITERAL = /#[0-9a-f]{3,8}\b|\b(?:rgb|rgba|hsl|hsla)\(/i
const RAW_FONT_SIZE_LITERAL = /font-size\s*:\s*[0-9.]+(?:px|rem)\b/i
// TSC v2.2 widened `RecipeTokenMap` to allow `composition` as a sibling
// of regular tokens. Cast through `unknown` so the test still treats
// the recipe map as a flat `Record<string, RecipeTokenValue>` — the
// `tokenKeys` / `tokenEntries` helpers below filter `composition` out
// of every iteration so the broader type doesn't leak into assertions.
const RECIPE_TOKENS = THEME_BASE_RECIPE_TOKENS as unknown as Readonly<
Record<string, Readonly<Record<string, RecipeTokenValue>>>
>
// Active parallel dev tracks — UNFINISHED components deliberately outside the
// recipe contract + completion machinery (same exclusion `component-audit`
// applies). One shared set so every check in this file agrees; previously only
// the font-size guard excluded them and the other checks red-flagged their WIP.
// Their recipes/CSS re-enter the contract when the tracks land.
const WIP_TRACKS = new Set(['palabras', 'chronos'])
/** Extract all CSS values from a recipe entry. Supports the three TSC
* forms: bare string/number, RecipeTokenSingle (one declaration), and
* RecipeTokenMultiDeclaration (declarations[]). Returns the values
* joined with newlines so the orphan-detector can do substring scans. */
function entryValues(entry: RecipeTokenValue): string {
if (typeof entry !== 'object') return String(entry)
if ('declarations' in entry) {
return entry.declarations.map((d) => d.value).join('\n')
}
return entry.value
}
/** Token keys from a recipe map, excluding TSC v2.2 reserved sibling
* keys (currently just `composition`). The orphan + missing tests
* iterate token keys; `composition` is a structural block, not a
* regular token, and must be skipped to avoid false positives like
* `--toggle-group-composition`. */
function tokenKeys(tokens: Readonly<Record<string, RecipeTokenValue>>): readonly string[] {
return Object.keys(tokens).filter((key) => key !== 'composition')
}
/** Token entries from a recipe map, with the `composition` reserved
* key filtered out. Companion to `tokenKeys` for value-based scans. */
function tokenEntries(
tokens: Readonly<Record<string, RecipeTokenValue>>
): readonly [string, RecipeTokenValue][] {
return Object.entries(tokens).filter(([key]) => key !== 'composition') as readonly [
string,
RecipeTokenValue
][]
}
function stripCssComments(css: string): string {
return css.replace(/\/\*[\s\S]*?\*\//g, '')
}
function recipeTokenName(component: string, key: string): string {
// Private tokens: key starts with `_` → emitted as `--_{c}-{rest}`.
// Public tokens: emitted as `--{c}-{key}`.
if (key.startsWith('_')) return `--_${component}-${key.slice(1)}`
return `--${component}-${key}`
}
function readComponentCssFiles(): readonly { component: string; css: string }[] {
// EVERY stylesheet the component ships, not just `{c}.css` — secondary
// sheets (calendar-select.css, color-picker-spectrum.css, …) used to be
// invisible to these guards, which made "move the literal to a sibling
// file" a scanner evasion (clean-room 2026-07-10, THM-1). Main recipe
// first, then siblings in stable order.
return readdirSync(COMPONENTS_DIR, { withFileTypes: true })
.filter((entry) => entry.isDirectory() && !WIP_TRACKS.has(entry.name))
.filter((entry) => existsSync(join(COMPONENTS_DIR, entry.name, `${entry.name}.css`)))
.map((entry) => {
const dir = join(COMPONENTS_DIR, entry.name)
const files = [
`${entry.name}.css`,
...readdirSync(dir)
.filter((f) => f.endsWith('.css') && f !== `${entry.name}.css`)
.sort()
]
return {
component: entry.name,
css: stripCssComments(
files.map((f) => readFileSync(join(dir, f), 'utf8')).join('\n')
)
}
})
}
function readTextFilesRecursive(dir: string): readonly string[] {
return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
const path = join(dir, entry.name)
if (entry.isDirectory()) return readTextFilesRecursive(path)
if (!COMPONENT_SOURCE_FILE.test(entry.name)) return []
return [readFileSync(path, 'utf8')]
})
}
function readComponentSource(component: string): string {
const dir = join(COMPONENTS_DIR, component)
return existsSync(dir) ? readTextFilesRecursive(dir).join('\n') : ''
}
function readRecipeValueReferences(): string {
return Object.values(RECIPE_TOKENS)
.flatMap((tokens) => tokenEntries(tokens).map(([, value]) => entryValues(value)))
.join('\n')
}
function collectOwnPublicVariables(component: string, css: string): readonly string[] {
const ownPrefix = `--${component}-`
// Only vars consumed WITHOUT a fallback must be recipe-declared. A
// `var(--x, default)` is runtime-optional by construction — the fallback IS
// the default — which is how soma writes dynamic values into the recipe
// surface (e.g. the nav-menu indicator height, `translateY(var(--…-h, 0px))`;
// the STUMBLES #7 soma→eidos var class). A bare `var(--x)` breaks if
// undeclared, so those still require a declaration.
const NO_FALLBACK = /var\(\s*(--[a-z][a-z0-9-]+)\s*\)/g
return [
...new Set(
[...css.matchAll(NO_FALLBACK)]
.map((match) => match[1])
.filter((name) => name.startsWith(ownPrefix))
)
].sort()
}
/**
* Public variables a SHARED LAYER declares (`lib/*.css`). A layer whose
* vocabulary does NOT share a component prefix (`--list-*`,
* `--viewport-placement-*`) is invisible to the check below; one that DOES is
* not, and `calendar-surface` does on purpose — the calendar family's
* vocabulary is `--calendar-*` and the layer resolves four of those names per
* size on its hook (2026-08-21). Those ARE declared, just not by a recipe
* entry, which is what `viewport-placement` documents as the mark of a layer.
* Reading them here keeps the guard honest without weakening it: a name nobody
* declares — recipe OR layer — still fails.
*/
function collectLayerDeclaredVariables(): ReadonlySet<string> {
const out = new Set<string>()
if (!existsSync(LIB_DIR)) return out
for (const entry of readdirSync(LIB_DIR)) {
if (!entry.endsWith('.css')) continue
const css = readFileSync(join(LIB_DIR, entry), 'utf8')
for (const match of css.matchAll(/^\s*(--[a-z][a-z0-9-]+)\s*:/gm)) out.add(match[1])
}
return out
}
describe('Eidos recipe CSS contract', () => {
it('declares every public component variable consumed by component CSS', () => {
const missing: string[] = []
const layerDeclared = collectLayerDeclaredVariables()
for (const { component, css } of readComponentCssFiles()) {
const declared = new Set(
tokenKeys(RECIPE_TOKENS[component] ?? {}).map((key) =>
recipeTokenName(component, key)
)
)
for (const name of layerDeclared) declared.add(name)
for (const name of collectOwnPublicVariables(component, css)) {
if (!declared.has(name)) missing.push(`${component}: ${name}`)
}
}
expect(missing).toEqual([])
})
it('does not declare recipes for missing component CSS files', () => {
const cssComponents = new Set(
readComponentCssFiles().map(({ component }) => component)
)
const extra = Object.keys(THEME_BASE_RECIPE_TOKENS)
.filter((component) => !WIP_TRACKS.has(component))
.filter((component) => !cssComponents.has(component))
expect(extra).toEqual([])
})
it('loads every component CSS recipe exactly once (foundation @import XOR self-import)', () => {
// Bundle architecture (see optimize-bundle.md): recipes are code-split.
// Each component CSS must be loaded via EXACTLY ONE mechanism:
// - foundation: `@import` in index.css (only the layout primitives stay
// there — they're used pervasively and recipes layer on them), OR
// - self-import: `import './{c}.css'` in its own `{c}.svelte`, so Vite
// emits a per-component chunk loaded only when the component mounts.
// Both at once = double-load (the CSS ships in the aggregate AND a chunk);
// neither = the recipe never loads. Either is a violation.
const indexCss = readFileSync('src/uix/eidos/index.css', 'utf8')
const violations: string[] = []
for (const { component } of readComponentCssFiles()) {
const inFoundation = indexCss.includes(`./components/${component}/${component}.css`)
const ownSvelte = join(COMPONENTS_DIR, component, `${component}.svelte`)
const selfImports =
existsSync(ownSvelte) &&
readFileSync(ownSvelte, 'utf8').includes(`import './${component}.css'`)
if (inFoundation && selfImports) {
violations.push(`${component}: double-loaded (in index.css AND self-imported)`)
} else if (!inFoundation && !selfImports) {
violations.push(`${component}: not loaded (neither index.css nor self-import)`)
}
}
expect(violations).toEqual([])
})
// Fixed-tone exception: a component that DEPICTS a physical thing whose
// look must read identically in every theme declares those values as
// annotated literals (never theme-driven). Same doctrine as the Palabras
// rail. `natural-time-picker` paints a daylight band (sky gradient +
// sun/moon knob) — its `--_natural-time-picker-sky-*` tokens are theme-independent by
// design, documented in the recipe header. `color-picker` paints the HSL
// wheel itself — its `--color-picker-hue-*` anchors
// (color-picker-spectrum.css) are the wheel, annotated per line.
// `proof-of-human` depicts the messenger pigeon + letter (identity hues)
// and sizes its SVG text in viewBox user units — annotated per line.
// `skin-media-player` depicts four physical objects (record, turntable,
// cassette, deck): mahogany, brushed aluminium, chrome, ivory keys and a
// wine label are the depiction, not theme roles — a turntable does not turn
// blue because the theme does.
// The exemption covers BOTH depiction hues and viewBox-unit font sizes.
const FIXED_TONE_COMPONENTS = new Set([
'natural-time-picker',
'color-picker',
'proof-of-human',
'skin-media-player'
])
it('keeps raw color values out of component CSS', () => {
const violations = readComponentCssFiles()
.filter(({ component }) => !FIXED_TONE_COMPONENTS.has(component))
.filter(({ css }) => RAW_COLOR_LITERAL.test(css))
.map(({ component }) => component)
expect(violations).toEqual([])
})
it('keeps raw font-size literals out of component CSS', () => {
const violations = readComponentCssFiles()
.filter(({ component }) => !FIXED_TONE_COMPONENTS.has(component))
.filter(({ css }) => RAW_FONT_SIZE_LITERAL.test(css))
.map(({ component }) => component)
expect(violations).toEqual([])
})
it('keeps every component `*Color` prop open (THM-2 — the cage stays open)', () => {
// The 2026-07-18 design decision reverted THM-2's per-component subsets:
// a component `color` prop accepts the FULL system — role / intent / 33
// donor scales / raw CSS value — on EVERY component. This guard scans the
// `export type XColor = …` aliases in component types and fails when one
// narrows below `ComponentColorProp`. Additive unions (`| 'muted'`,
// `| 'inherit'`, `| 'absent'`, …) pass — they extend the open surface,
// never narrow it. Aliases resolve transitively (`DatePickerColor =
// CalendarColor` is open because CalendarColor is). WIP_TRACKS components
// re-enter when their tracks land, same as the rest of this file.
const EXEMPT: Readonly<Record<string, string>> = {
// Plain `string` — WIDER than the cage (accepts everything, but loses
// the canonical autocomplete). Not a narrowing; normalize to
// `ComponentColorProp` when onion-menu gets its coherence pass.
OnionColor: 'plain string (wider than the cage, pending normalization)'
}
const declarations = new Map<string, { component: string; rhs: string }>()
for (const component of readdirSync(COMPONENTS_DIR)) {
if (WIP_TRACKS.has(component)) continue
const typesPath = join(COMPONENTS_DIR, component, 'types.ts')
if (!existsSync(typesPath)) continue
const source = readFileSync(typesPath, 'utf8')
for (const match of source.matchAll(/export type (\w+Color) = ([^\n;]+)/g)) {
declarations.set(match[1], { component, rhs: match[2].trim() })
}
}
const isOpen = (name: string, seen = new Set<string>()): boolean => {
if (seen.has(name)) return false
seen.add(name)
const decl = declarations.get(name)
if (!decl) return false
if (decl.rhs.includes('ComponentColorProp')) return true
const alias = decl.rhs.match(/^(\w+Color)$/)
return alias ? isOpen(alias[1], seen) : false
}
const violations = [...declarations.keys()]
.filter((name) => !(name in EXEMPT))
.filter((name) => !isOpen(name))
.map((name) => `${declarations.get(name)!.component}: ${name} = ${declarations.get(name)!.rhs}`)
expect(violations).toEqual([])
})
it('derives the shared colour vocabulary from the const (no hand-spelled role unions)', () => {
// C1 (2026-07-30): `lib/types.ts` re-spelled `ColorRole` by hand and drifted
// to 8 members — `tertiary` fell out — while `config-types.ts` kept the
// const-derived 9. TWO exported types with the SAME name; the import path
// decided which one a file got. The runtime never diverged
// (`resolveComponentColor` unions `COLOR_ROLES` + `PALETTE_SCALES`), so the
// drift was type-only: it rejected at compile time a value the engine
// resolves and the shared palette layer already emits a row for.
//
// A shared vocabulary DERIVES from its const. Re-spelling one as a literal
// union is how it silently falls behind. `config-types.ts` is the canonical
// declaration site and is therefore exempt.
const LIB_DIR = 'src/uix/eidos/lib'
const roleNames = COLOR_ROLES as readonly string[]
const violations: string[] = []
for (const file of readdirSync(LIB_DIR)) {
if (!file.endsWith('.ts') || file.endsWith('.test.ts')) continue
if (file === 'config-types.ts') continue
const source = readFileSync(join(LIB_DIR, file), 'utf8')
for (const match of source.matchAll(/export type (\w+) =([^;]*);/g)) {
// `Extract<ColorRole, 'a' | 'b'>` SELECTS from the derived union, so it
// tracks the const by construction. Only a fresh literal union
// re-declares the vocabulary, which is the shape that fell behind.
const rhs = match[2].replace(/Extract<[\s\S]*?>/g, '')
const spelled = [...rhs.matchAll(/'([a-z-]+)'/g)]
.map((literal) => literal[1])
.filter((literal) => roleNames.includes(literal))
if (spelled.length >= 2) {
violations.push(
`${file}: ${match[1]} re-spells [${spelled.join(', ')}] — derive from COLOR_ROLES`
)
}
}
}
expect(violations).toEqual([])
})
it('pairs a dynamic `data-color` stamp with `data-color-custom` (runtime openness)', () => {
// The type guard above proves the *type* stays open, but is blind to the
// RUNTIME: a wrapper that stamps `data-color={value}` without also stamping
// `data-color-custom` silently drops raw CSS colours (they match no role /
// scale selector and fall to the host default) — the class of defect that
// slipped past on card-group-item. This guard fails when any component
// `.svelte` stamps a DYNAMIC `data-color={…}` yet never stamps
// `data-color-custom` in the same file. Note the pairing is what opens the
// value-by-CSS path via `resolveComponentColor` (or the isCanonical split).
// A STATIC `data-color="neutral"` literal (e.g. avatar-group's overflow
// badge) is a fixed value, not an open prop, and is intentionally exempt.
const violations: string[] = []
for (const component of readdirSync(COMPONENTS_DIR)) {
if (WIP_TRACKS.has(component)) continue
const dir = join(COMPONENTS_DIR, component)
if (!statSync(dir).isDirectory()) continue
for (const file of readdirSync(dir)) {
if (!file.endsWith('.svelte')) continue
const src = readFileSync(join(dir, file), 'utf8')
if (/data-color=\{/.test(src) && !src.includes('data-color-custom')) {
violations.push(`${component}/${file}: dynamic data-color={…} without data-color-custom`)
}
}
}
expect(violations).toEqual([])
})
// Phantom-theme-token guard (STUMBLES #7, soma→eidos CSS-var drift). A recipe
// that consumes a theme token which the foundation never declares fails
// SILENTLY: the `var(--x)` resolves to nothing (empty / transparent), only
// visible in the render. Building the Knob hit exactly this
// (`--color-neutral-content`, `--state-hover`, `--color-neutral-bg` — all
// non-existent). Validate every theme-token reference against the tokens the
// foundation actually emits. Component-own vars (`--{c}-*` / `--_{c}-*`) and
// sibling-composed vars (date-range → `--calendar-*`) are NOT theme tokens —
// they're skipped by the component-prefix test.
it('resolves every no-fallback var() to a declared token (no phantom tokens)', () => {
// Every custom property DECLARED (`--x:`) anywhere in the eidos CSS tree —
// foundation, shared layers, and each component's own recipe. A recipe may
// legitimately consume its own private vars (any prefix), foundation
// tokens, shared-layer tokens, and a sibling's public tokens; all appear
// here as declarations.
const declared = new Set<string>()
const collectDeclarations = (dir: string) => {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const p = join(dir, entry.name)
if (entry.isDirectory()) collectDeclarations(p)
else if (entry.name.endsWith('.css')) {
for (const m of stripCssComments(readFileSync(p, 'utf8')).matchAll(
/(--[a-z][a-z0-9-]+)\s*:/g
)) {
declared.add(m[1])
}
}
}
}
collectDeclarations('src/uix/eidos')
// Runtime-written vars (provider / floating engine) live in JS, not CSS,
// and are ALWAYS consumed with a fallback (the fallback IS the default —
// same rule as `collectOwnPublicVariables`). So only a NO-FALLBACK
// `var(--x)` — where the author asserts the token exists — must resolve to
// a declaration; a `var(--x, default)` is runtime-optional by construction.
const NO_FALLBACK = /var\(\s*(--[a-z][a-z0-9-]+)\s*\)/g
const phantoms: string[] = []
for (const { component, css } of readComponentCssFiles()) {
for (const match of stripCssComments(css).matchAll(NO_FALLBACK)) {
const token = match[1]
if (!declared.has(token)) phantoms.push(`${component}: ${token}`)
}
}
expect([...new Set(phantoms)].sort()).toEqual([])
})
it('recipe token values reference only vocabulary that EXISTS (no phantom public refs)', () => {
// B.3's class (audit 2026-07-07): `var(--color-content-tertiary)`
// shipped for a month referencing a slot that never existed — frozen
// invalid at :root, the read-out inherited its color. The derived
// contract (A.9) IS the emitted vocabulary, so this can now be checked
// at test time: every NO-FALLBACK public reference in a recipe value
// must resolve. Private `--_*` refs are the scope rule's domain (they
// live in component CSS); `var(--x, fallback)` is runtime-optional by
// construction (same rule as the CSS phantom guard above).
const contract = createEidosCssContract(createThemeBaseEidosConfig())
const known = new Set<string>(
[...contract.static, ...contract.theme].map((token) => token.cssVar)
)
const NO_FALLBACK = /var\(\s*(--[a-z][a-z0-9-]+)\s*\)/g
const phantoms: string[] = []
for (const [component, tokens] of Object.entries(RECIPE_TOKENS)) {
if (WIP_TRACKS.has(component)) continue
for (const key of tokenKeys(tokens)) {
for (const match of entryValues(tokens[key]).matchAll(NO_FALLBACK)) {
const ref = match[1]
if (ref.startsWith('--_')) continue
if (!known.has(ref)) phantoms.push(`${component}.${key}: ${ref}`)
}
}
}
expect([...new Set(phantoms)].sort()).toEqual([])
})
it('recipe values referencing component privates (--_*) declare a covering scope', () => {
// B.1/B.2's class (audit 2026-07-07): a :root-emitted token whose value
// references a `--_*` private (declared only in component CSS) computes
// at :root where the private does NOT exist and freezes
// guaranteed-invalid — descendants inherit the frozen result even
// though the private cascades lower. The field segment heights and the
// calendar holiday/event marks shipped broken this way. A private
// reference is legal ONLY under a scope that puts the declaration where
// the private actually cascades (host / leaf) — never at :root.
const PRIVATE_REF = /var\(\s*--_/
const violations: string[] = []
for (const [component, tokens] of Object.entries(RECIPE_TOKENS)) {
if (WIP_TRACKS.has(component)) continue
for (const key of tokenKeys(tokens)) {
const entry = tokens[key]
const declarations =
typeof entry !== 'object'
? [{ value: String(entry), scope: undefined }]
: 'declarations' in entry
? entry.declarations
: [entry]
for (const declaration of declarations) {
if (!PRIVATE_REF.test(String(declaration.value))) continue
if ((declaration.scope ?? 'root') === 'root') {
violations.push(`${component}.${key}`)
}
}
}
}
expect(violations).toEqual([])
})
// Coherence guard (Fase 7 — theming audit): the type/size canon is only worth
// having if components CONSUME it. Recipe `font-size-*` / `icon-size-*` tokens
// MUST reference the `--font-size-*` / `--icon-size-*` scale (or another token),
// never a raw `px`/`rem` literal — else the text/icon stops responding to the
// type scale, `--scaling` and `applyTypeScale()`. The earlier CSS-only guard
// missed this (recipe-token values become custom properties, not `font-size:`).
// `words` / `palabras` / `chronos` are excluded (WIP_TRACKS, shared file-wide).
it('keeps raw px/rem literals out of recipe font-size / icon-size tokens', () => {
const RAW_LENGTH = /^-?[0-9.]+(px|rem)$/
const violations: string[] = []
for (const [component, tokens] of Object.entries(RECIPE_TOKENS)) {
if (WIP_TRACKS.has(component)) continue
for (const [key, value] of tokenEntries(tokens)) {
if (!/(^|-)(font-size|icon-size)(-|$)/.test(key)) continue
for (const resolved of entryValues(value)) {
if (typeof resolved === 'string' && RAW_LENGTH.test(resolved.trim())) {
violations.push(`${component}.${key}: ${resolved}`)
}
}
}
}
expect(violations).toEqual([])
})
it('does not leave declared public recipe variables orphaned', () => {
// Composition: a component's public tokens may be consumed by a SIBLING
// that composes its chrome — date-picker / date-range-picker / month-grid
// consume calendar's `--calendar-control-*`; a per-component source check
// would false-flag those. Check the WHOLE component corpus + all recipe
// values once.
const componentDirs = readdirSync(COMPONENTS_DIR, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name)
const corpus = `${componentDirs
.map((component) => readComponentSource(component))
.join('\n')}\n${readRecipeValueReferences()}`
const orphaned: string[] = []
for (const [component, tokens] of Object.entries(RECIPE_TOKENS)) {
if (WIP_TRACKS.has(component)) continue
for (const key of tokenKeys(tokens)) {
const name = recipeTokenName(component, key)
if (!corpus.includes(name)) orphaned.push(`${component}: ${name}`)
}
}
expect(orphaned).toEqual([])
})
/**
* Universal anti-eager-resolution guard.
*
* Closes the gap left by TSC: TSC only validates tokens in
* `recipes/base.ts`. Components that declare their palette logic
* directly in their `.css` file bypass TSC entirely. This guard
* runs at the CSS-output level and catches the bug pattern in
* EITHER source:
*
* :root {
* --{c}-derived: var(--{c}-palette-X); ← FORBIDDEN
* }
*
* Because CSS custom property substitution is eager, `--{c}-derived`
* would freeze at the :root value of `--{c}-palette-X` (the neutral
* default), and any per-instance override of `--{c}-palette-X` in
* `[data-{c}][data-color='affirm']` would be silently lost.
*
* The palette tokens themselves (`--{c}-palette-X` declared at
* `:root` as default) are NOT violations — those ARE supposed to
* have a root-level fallback. Only tokens that consume them at root
* are forbidden.
*
* Sources scanned:
* - `generated/base.css` (recipes config materialised)
* - every `src/uix/eidos/components/{c}/{c}.css` (manual recipes)
*/
it('forbids palette-derived tokens at :root scope (universal anti-eager-resolution)', () => {
const violations: string[] = []
const ROOT_BLOCK = /(?:^|\n)\s*:root\s*\{([^}]*)\}/g
const PALETTE_REF = /--([a-z][a-z0-9-]*)-palette-/g
const DECL = /(?:^|;)\s*--([a-z][a-z0-9-]*?)\s*:\s*([^;]+?)(?=;|$)/g
function scanFile(filename: string, css: string) {
const stripped = stripCssComments(css)
for (const rootMatch of stripped.matchAll(ROOT_BLOCK)) {
const block = rootMatch[1]
for (const declMatch of block.matchAll(DECL)) {
const name = declMatch[1]
const value = declMatch[2].trim()
// Skip the palette-* tokens themselves — those are
// supposed to live at :root as their neutral default.
if (/-palette-/.test(name)) continue
// Find palette refs in the value.
for (const refMatch of value.matchAll(PALETTE_REF)) {
const refComponent = refMatch[1]
violations.push(
`${filename}: \`--${name}: ${value}\` references ` +
`\`var(--${refComponent}-palette-*)\` from :root scope. ` +
`This freezes the token at the palette's root default. ` +
`Move the declaration to \`[data-${refComponent}]\` scope ` +
`(TSC scope:'host' if going through recipes/base.ts).`
)
}
}
}
}
// 1. The generator output.
scanFile(
'generated/base.css',
readFileSync('src/uix/eidos/generated/base.css', 'utf8')
)
// 2. Each component CSS recipe.
for (const { component, css } of readComponentCssFiles()) {
scanFile(`components/${component}/${component}.css`, css)
}
expect(violations).toEqual([])
})
/**
* Size-bundle adoption (C7, 2026-07-03 — theming/reference §5).
*
* Recipes consume the CANONICAL size coordinate (`--size-{k}-control-height`
* / `-font-size` / `-icon-size`), never the raw primitive of the same
* coordinate — retuning a size's bundle must reach every consumer. The
* bundle vars are pure aliases of the primitives, so adoption is
* computed-value identical; deviations stay visible as a key mapping to a
* DIFFERENT size's coordinate. `base`/`xxxl` have no bundle and stay on the
* typographic primitive.
*/
it('recipes consume the size bundle, not the raw size-coordinate primitives', () => {
const RAW_COORDINATE =
/var\(--(control-height|font-size|icon-size)-(xxs|xs|sm|md|lg|xl|xxl)\)/g
const violations: string[] = []
function scanValue(component: string, tokenName: string, value: string) {
for (const m of value.matchAll(RAW_COORDINATE)) {
violations.push(
`${component}.${tokenName}: \`${m[0]}\` — consume the bundle coordinate ` +
`\`var(--size-${m[2]}-${m[1]})\` instead (theming/reference §5)`
)
}
}
for (const [component, tokens] of Object.entries(RECIPE_TOKENS)) {
if (WIP_TRACKS.has(component)) continue
for (const [name, raw] of tokenEntries(tokens)) {
if (typeof raw === 'string') scanValue(component, name, raw)
else if (raw && typeof raw === 'object') {
const decls =
'declarations' in raw
? (raw.declarations as readonly { value: string }[])
: [raw as { value: string }]
for (const d of decls) if (typeof d.value === 'string') scanValue(component, name, d.value)
}
}
}
expect(violations).toEqual([])
})
/**
* Universal palette layer (THM-2, 2026-07-12 — `renderSharedPaletteLayer`
* + `renderRecipePaletteForward`, render-css.ts).
*
* The 33-scale cascade is now emitted ONCE, component-agnostic:
* `[data-color='{scale}'] { --palette-{slot}: … }`. A recipe that declares
* `palette-{slot}` tokens gets a presence-guarded FORWARD
* (`[data-{c}][data-color] { --{c}-palette-{slot}: var(--palette-{slot}, …) }`)
* routing to that layer, so `<X color="steel">` resolves without a
* per-component copy of the cascade. Two invariants:
* (a) the shared layer covers the LAST scale of PALETTE_SCALES — a grown
* palette that outruns the generator fails here (the 31-vs-33 drift
* class);
* (b) every recipe with palette-* tokens emits its forward — so it is
* actually wired to the shared layer, not dangling.
*/
it('emits the shared palette layer + a forward for every palette-* recipe', () => {
const generated = readFileSync('src/uix/eidos/generated/base.css', 'utf8')
const lastScale = PALETTE_SCALES[PALETTE_SCALES.length - 1]
const missing: string[] = []
// (a) shared layer covers the full scale range.
if (!generated.includes(`[data-color='${lastScale}'] {`)) {
missing.push(
`shared palette layer lacks [data-color='${lastScale}'] — renderSharedPaletteLayer ` +
`did not cover the full PALETTE_SCALES range`
)
}
// (a2) shared layer emits the custom derivation row (the "por valor" seam):
// `[data-color-custom] { --palette-{slot}: color-mix(… var(--color-custom) …) }`.
if (!generated.includes(`[data-color-custom] {`)) {
missing.push(
`shared palette layer lacks [data-color-custom] — renderSharedPaletteLayer did not ` +
`emit the custom color-mix derivation (the raw-value "por valor" path)`
)
}
// (b) every recipe with palette-* tokens forwards to it — on BOTH the
// canonical `[data-color]` and the custom `[data-color-custom]` presence.
for (const [component, tokens] of Object.entries(RECIPE_TOKENS)) {
if (WIP_TRACKS.has(component)) continue
const hasPalette = tokenKeys(tokens).some((k) =>
/^palette-(track|element|border|solid|solid-hover|text|contrast)$/.test(k)
)
if (!hasPalette) continue
const forward = `[data-${component}][data-color], [data-${component}][data-color-custom] {`
if (!generated.includes(forward)) {
missing.push(
`${component}: generated/base.css lacks the ${forward} forward — ` +
`its palette-* tokens are not wired to the shared layer`
)
}
}
expect(missing).toEqual([])
})
/**
* Variant canon enforcement (per-component).
*
* For every component CSS, the `[data-{component}][data-variant='X']`
* selectors must use variant values that the component's own
* `types.ts` declares — no typos, no unauthorized extensions.
*
* **Why this matters**: variants are part of the framework's
* perceptual canon (see THEMING.md §19). They are NOT
* theme-extensible — a `<Toggle variant="outline">` must render
* coherently in every theme. The 5 archetypes in `lib/types.ts >
* EIDOS_VARIANTS` cover the common cross-component vocabularies;
* component-specific variants (e.g. Banner's `inline`/`overlay`)
* are declared in each component's own `types.ts` and validated
* by this lint.
*
* **What this catches**:
* - CSS uses `data-variant='outlne'` (typo) → fails: not in type union
* - Adds `[data-toggle][data-variant='neon']` without updating
* `ToggleVariant` → fails: not in type
* - Removes `'ghost'` from type union but leaves the CSS rule →
* fails: type ⊃ CSS check
*
* **Type parsing**: extracts the union literal values from
* `export type {Pascal}Variant = ...` patterns. Handles direct
* literal unions and aliases to the 5 archetypes. Components with
* complex types (Extract / Omit / unions of multiple archetypes —
* Button, AlertDialog actions, etc.) are skipped with the variant
* pool defaulting to ALL canonical values + the component's own
* literal union; the lint runs in advisory mode there.
*/
it('variant CSS selectors per component match the declared type union', () => {
const violations: string[] = []
for (const { component } of readComponentCssFiles()) {
const cssVariants = collectComponentVariantValues(component)
if (cssVariants.size === 0) continue
const typeVariants = extractComponentVariantTypeValues(component)
if (!typeVariants) continue // type file missing or complex pattern; skip strict check
for (const variant of cssVariants) {
if (!typeVariants.has(variant)) {
violations.push(
`components/${component}/${component}.css uses ` +
`[data-${component}][data-variant='${variant}'] but ` +
`${component}/types.ts declares only [${[...typeVariants].sort().join(', ')}]`
)
}
}
}
expect(violations).toEqual([])
})
/**
* Token Scope Contract (TSC) — the production recipes pass the
* generator's scope validation. If a recipe author mis-scopes a
* derived token (e.g. puts a palette-dependent token at :root),
* `renderStaticCss` throws and this test fails.
*
* The generator's algebra checks (a) deps inferred from `var()`
* refs and (b) cross-axis collisions across multi-declaration
* tokens. Both run on the base config below.
*/
it('the base recipe config passes the generator scope contract', () => {
expect(() => renderStaticCss(createThemeBaseEidosConfig())).not.toThrow()
})
})
/**
* Pull every `[data-{component}][data-variant='X']` selector out of
* the component CSS, returning the set of X values. Strips comments
* first so commented-out selectors don't count.
*/
function collectComponentVariantValues(component: string): Set<string> {
return collectComponentAttrValues(component, 'variant')
}
/**
* Generalised collector: pull every `[data-{attr}='X']` value out of the
* component CSS, scoped to selectors that also mention the component itself.
* Comments are stripped first, so a commented-out rule does not count.
*/
function collectComponentAttrValues(component: string, attr: string): Set<string> {
const file = join(COMPONENTS_DIR, component, `${component}.css`)
if (!existsSync(file)) return new Set()
const css = stripCssComments(readFileSync(file, 'utf8'))
const ownPrefixes = [`[data-${component}]`, `[data-${component}-`]
const variants = new Set<string>()
const RE = new RegExp(`\\[data-${attr}='([^']+)'\\]`, 'g')
for (const match of css.matchAll(RE)) {
// Only consider matches where the variant selector follows a
// selector that scopes the component itself (root or any of its
// parts) — avoids picking up other component's selectors that
// happen to be in the same file (e.g. a banner CSS that styles
// a button inside it).
const ctx = css.slice(Math.max(0, match.index - 200), match.index)
if (ownPrefixes.some((pre) => ctx.includes(pre))) {
variants.add(match[1])
}
}
return variants
}
/**
* Parse `src/uix/eidos/components/{component}/types.ts` and extract
* the variant value set declared for that component. Handles:
* - Direct literal union: `export type FooVariant = 'a' | 'b' | 'c'`
* - Archetype alias: `export type FooVariant = ControlVariant`
* - Multiple variant types in one file (root + sub-parts) — merged.
*
* Returns `undefined` if the file doesn't exist OR the variant type
* uses a pattern the parser doesn't handle (Extract / Omit / unions
* of types). The caller skips strict validation for those components.
*/
function extractComponentVariantTypeValues(component: string): Set<string> | undefined {
const file = join(COMPONENTS_DIR, component, 'types.ts')
if (!existsSync(file)) return undefined
const src = readFileSync(file, 'utf8')
// Match any `export type FooVariant = ...;` declaration. The
// component might declare multiple (root + sub-part), e.g. Avatar
// has AvatarVariant + AvatarBadgeVariant. We merge all variant
// types found in the file.
const DECL = /export type \w*Variant(?:\w*)?\s*=\s*([^;]+?);/gs
const result = new Set<string>()
let sawAny = false
let sawUnknownPattern = false
for (const match of src.matchAll(DECL)) {
sawAny = true
const body = match[1].trim()
// Direct literal union
const literals = [...body.matchAll(/'([^']+)'/g)].map((m) => m[1])
if (literals.length > 0 && /^\s*'[^']+'(?:\s*\|\s*'[^']+')*\s*$/.test(body)) {
for (const v of literals) result.add(v)
continue
}
// Direct alias to a known archetype
const aliasMatch = body.match(/^\s*(ControlVariant|SelectionVariant|ChipVariant|MarkerVariant|TabsVariant)\s*$/)
if (aliasMatch) {
const archetype = aliasMatch[1].replace('Variant', '').toLowerCase() as keyof typeof EIDOS_VARIANTS
for (const v of EIDOS_VARIANTS[archetype]) result.add(v)
continue
}
// Unrecognised pattern — fall back to advisory mode.
sawUnknownPattern = true
}
if (!sawAny) return undefined
if (sawUnknownPattern) {
// Complex types (Extract<>, unions of archetypes, etc.). Be
// permissive: union all canonical values + any literals we
// did manage to extract. The check becomes "the variant must
// be canonical somewhere" rather than "it must be in THIS
// component's specific set".
return new Set([...EIDOS_VARIANT_VALUES, ...result])
}
return result
}
/**
* Parse `types.ts` for the value set of ONE axis, keyed by the PascalCase
* suffix its type carries (`size` → `Size`, `cell-shape` → `CellShape`). Every
* matching declaration is unioned, so a component that types its root and its
* group separately (`AvatarSize`, and an `AvatarGroupSize` the day it needs one)
* contributes both.
*
* Handles, beyond the direct literal union:
* - `Extract<Base, 'a' | 'b'>` — the narrowing lives in the SECOND argument,
* which is where five of these axes keep their values (`AvatarSize`,
* `ImageSize`, `SkeletonSize`, `QrCodeSize`, `AvatarBadgePosition`).
* - a `boolean` member (`AvatarRing = boolean | 'solid' | 'soft'`), which
* reaches the DOM stamped as `data-ring="true"`.
*
* Returns `undefined` when no declaration yields a literal — an alias into a
* vocabulary defined in another module. Following the import is deliberately out
* of scope; those axes are named in `ALIASED_KNOB_AXES` instead of vanishing.
*/
function extractComponentAxisValues(component: string, suffix: string): Set<string> | undefined {
const file = join(COMPONENTS_DIR, component, 'types.ts')
if (!existsSync(file)) return undefined
const src = readFileSync(file, 'utf8')
const DECL = new RegExp(`export type \\w*${suffix}\\s*=\\s*([^;]+?);`, 'gs')
const result = new Set<string>()
for (const match of src.matchAll(DECL)) {
const body = match[1].trim()
const extract = body.match(/^Extract<[^,]+,([\s\S]+)>$/)
const scope = extract ? extract[1] : body
const literals = [...scope.matchAll(/'([^']+)'/g)].map((m) => m[1])
if (literals.length === 0) continue
for (const value of literals) result.add(value)
if (/\bboolean\b/.test(scope)) {
result.add('true')
result.add('false')
}
}
return result.size > 0 ? result : undefined
}
/**
* KNOB AXES — the enum validation that left the morfos on 2026-08-15.
*
* Twenty-five `data-*` entries were commented out across six morfos: paint knobs
* read by ONE layer (the component's own recipe), which is precisely what morfo,
* the CROSS-layer contract, does not carry. What they DID carry was an enum —
* `values: [...]` — that `eidos-lint` could read to reject `[data-size='huge']`.
* This table restores that check from where the enum actually lives: the TS union
* the prop is typed with.
*
* `component` is the EIDOS DIRECTORY, not the morfo kebab. AvatarGroup has its
* own morfo but ships inside `avatar/` — CSS and types included — so its
* `stacking` axis is looked up there.
*/
const KNOB_AXES = [
{ component: 'avatar', attr: 'size', suffix: 'Size' },
{ component: 'avatar', attr: 'radius', suffix: 'Radius' },
{ component: 'avatar', attr: 'ring', suffix: 'Ring' },
{ component: 'avatar', attr: 'position', suffix: 'Position' },
{ component: 'avatar', attr: 'stacking', suffix: 'Stacking' },
{ component: 'image', attr: 'fit', suffix: 'Fit' },
{ component: 'image', attr: 'position', suffix: 'Position' },
{ component: 'image', attr: 'radius', suffix: 'Radius' },
{ component: 'image', attr: 'size', suffix: 'Size' },
{ component: 'image', attr: 'placeholder', suffix: 'Placeholder' },
{ component: 'skeleton', attr: 'shape', suffix: 'Shape' }
// NOT here: qr-code's `cell-shape`. It stamps `data-cell-shape` on the root
// `<svg>`, but no CSS rule reads it — the shape is drawn into the `<path>`
// geometry by the encoder. A row for it would pass having inspected an empty
// set, which is the failure this file's `used.size` assertion exists to catch.
// The prop is still typed `QrCodeCellShape`; tsc guards it at the call site.
] as const
describe('knob axes — enum canon from the TS union', () => {
for (const axis of KNOB_AXES) {
it(`${axis.component}: every [data-${axis.attr}] value in CSS is declared in types.ts`, () => {
const declared = extractComponentAxisValues(axis.component, axis.suffix)
// Not a skip. These twelve parsed on the day the enums left the
// morfos; an axis that stops parsing has had its union rewritten into
// something this guard cannot read, and the validation would go quiet
// while appearing to pass.
expect(
declared,
`no parseable *${axis.suffix} union in ${axis.component}/types.ts — the axis lost its enum guard`
).toBeDefined()
const used = collectComponentAttrValues(axis.component, axis.attr)
// An empty set passes every containment check ever written. Measured
// before this line existed: 11 of 12 axes inspected 44 real values and
// the twelfth inspected nothing — and was green. A blind axis means the
// rules were renamed or deleted, or the knob was never CSS-driven; both
// are answers, and neither is "pass".
expect(
used.size,
`no [data-${axis.attr}='…'] rule in ${axis.component}.css — this axis inspects nothing`
).toBeGreaterThan(0)
const undeclared = [...used].filter((value) => !declared!.has(value))
expect(
undeclared,
`${axis.component}.css styles [data-${axis.attr}] values absent from the union: ${undeclared.join(', ')}`
).toEqual([])
})
}
})
/**
* Token Scope Contract — generator-level scenario tests.
*
* These exercise the validation algebra directly through `renderStaticCss`
* with synthetic recipes. They lock in the contract semantics so any
* future regression in `appendRecipeDeclarations` (or in the algebra
* helpers) surfaces as a clear test failure.
*/
describe('Token Scope Contract — scope algebra', () => {
it('rejects: root token depending on host token (the original Toggle bug)', () => {
expect(() =>
renderStaticCss(
configWithRecipes({
synth: {
'palette-solid': {
value: 'var(--color-neutral-solid)',
scope: 'host'
},
// Inferred dep on palette-solid. Consumer at root → fails.
'solid-on-bg': 'var(--synth-palette-solid)'
}
})
)
).toThrow(/synth\.solid-on-bg.*scope root/)
})
it('accepts: host token depending on host token', () => {
expect(() =>
renderStaticCss(
configWithRecipes({
synth: {
'palette-solid': { value: 'var(--color-neutral-solid)', scope: 'host' },
'solid-on-bg': { value: 'var(--synth-palette-solid)', scope: 'host' }
}
})
)
).not.toThrow()
})
it('rejects: host token depending on color-only token', () => {
expect(() =>
renderStaticCss(
configWithRecipes({
synth: {
'palette-solid': {
value: 'var(--color-affirm-solid)',
scope: 'color:affirm'
},
// Dep only exists at color:affirm. Host scope is broader,
// applies to elements without data-color='affirm'. Fail.
'solid-on-bg': { value: 'var(--synth-palette-solid)', scope: 'host' }
}
})
)
).toThrow(/synth\.solid-on-bg.*dependency .palette-solid./)
})
it('accepts: color token depending on host token', () => {
expect(() =>
renderStaticCss(
configWithRecipes({
synth: {
'palette-solid': { value: 'var(--color-neutral-solid)', scope: 'host' },
'affirm-bg': {
value: 'var(--synth-palette-solid)',
scope: 'color:affirm'
}
}
})
)
).not.toThrow()
})
it('accepts: declarations[] shape with one host + one color override', () => {
expect(() =>
renderStaticCss(
configWithRecipes({
synth: {
'palette-solid': {
declarations: [
{ value: 'var(--color-neutral-solid)', scope: 'host' },
{ value: 'var(--color-affirm-solid)', scope: 'color:affirm' }
]
},
'solid-on-bg': { value: 'var(--synth-palette-solid)', scope: 'host' }
}
})
)
).not.toThrow()
})
it('rejects: cross-axis collision without composite declaration', () => {
expect(() =>
renderStaticCss(
configWithRecipes({
synth: {
bg: {
declarations: [
{ value: 'red', scope: 'color:affirm' },
{ value: 'blue', scope: 'state:on' }
]
}
}
})
)
).toThrow(/can both apply to the same element.*composite/)
})
it('accepts: cross-axis declarations with explicit composite intersection', () => {
expect(() =>
renderStaticCss(
configWithRecipes({
synth: {
bg: {
declarations: [
{ value: 'red', scope: 'color:affirm' },
{ value: 'blue', scope: 'state:on' },
{ value: 'purple', scope: ['color:affirm', 'state:on'] }
]
}
}
})
)
).not.toThrow()
})
it('accepts: palette:* legacy alias is normalized to color:*', () => {
expect(() =>
renderStaticCss(
configWithRecipes({
synth: {
'palette-solid': {
declarations: [
{ value: 'var(--color-neutral-solid)', scope: 'host' },
{ value: 'var(--color-affirm-solid)', scope: 'palette:affirm' }
]
},
'solid-on-bg': { value: 'var(--synth-palette-solid)', scope: 'host' }
}
})
)
).not.toThrow()
})
it('infers deps from var() without explicit depends array', () => {
// No `depends: [...]` on solid-on-bg — the generator infers it
// from var(--synth-palette-solid). Without inference, this would
// pass spuriously (bare-string at root is the v1 escape hatch).
expect(() =>
renderStaticCss(
configWithRecipes({
synth: {
'palette-solid': { value: 'var(--color-neutral-solid)', scope: 'host' },
'solid-on-bg': 'var(--synth-palette-solid)' // bare string → root
}
})
)
).toThrow(/synth\.solid-on-bg.*scope root/)
})
it('ignores var() refs to tokens outside the same recipe', () => {
// `var(--color-neutral-solid)` references a primitive/theme token,
// not a fellow recipe token. Should NOT be treated as a dep.
expect(() =>
renderStaticCss(
configWithRecipes({
synth: {
bg: 'var(--color-neutral-solid)' // external ref, root scope OK
}
})
)
).not.toThrow()
})
})
// The foundation must obey the discipline it imposes on recipes (EID-2,
// clean-room 2026-07-10: `archetypes.css` shipped `opacity: 0.5/0.85`
// literals — exactly what R-4.2 flags as error in a recipe — because no
// guard scanned it). Scope: the hand-written foundation sheets, i.e. every
// `src/uix/eidos/*.css` + `src/uix/eidos/lib/*.css`, enumerated dynamically
// so future foundation files enter automatically. Shared component-shaped
// layers (picker-shell, spin-field) already enter the component scans above
// via `readComponentCssFiles`; `generated/*` is validated at its source.
describe('Foundation CSS discipline (value rules)', () => {
const FOUNDATION_DIRS = ['src/uix/eidos', 'src/uix/eidos/lib']
function readFoundationCssFiles(): readonly { file: string; css: string }[] {
return FOUNDATION_DIRS.flatMap((dir) =>
readdirSync(dir, { withFileTypes: true })
.filter((entry) => entry.isFile() && entry.name.endsWith('.css'))
.map((entry) => ({
file: join(dir, entry.name),
css: stripCssComments(readFileSync(join(dir, entry.name), 'utf8'))
}))
)
}
it('keeps raw color values out of the foundation', () => {
const violations = readFoundationCssFiles()
.filter(({ css }) => RAW_COLOR_LITERAL.test(css))
.map(({ file }) => file)
expect(violations).toEqual([])
})
it('keeps raw font-size literals out of the foundation', () => {
const violations = readFoundationCssFiles()
.filter(({ css }) => RAW_FONT_SIZE_LITERAL.test(css))
.map(({ file }) => file)
expect(violations).toEqual([])
})
it('keeps fractional opacity literals out of the foundation (use --opacity-*)', () => {
const OPACITY_LITERAL = /(?<![\w-])opacity\s*:\s*0?\.\d+/
const violations = readFoundationCssFiles()
.filter(({ css }) => OPACITY_LITERAL.test(css))
.map(({ file }) => file)
expect(violations).toEqual([])
})
})

Powered by TurnKey Linux.