import { existsSync, readdirSync, readFileSync } from 'node:fs' import { join } from 'node:path' import { describe, expect, it } from 'vitest' import { THEME_BASE_RECIPE_TOKENS } from './lib/recipes/base' import type { EidosConfig, RecipeTokenSet, RecipeTokenValue } from './lib/config-types' import { renderStaticCss } from './lib/render-css' import { createThemeBaseEidosConfig } from './lib/themes/base' import { EIDOS_VARIANTS, EIDOS_VARIANT_VALUES } 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 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` — 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>> > /** 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>): 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> ): 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 }[] { return readdirSync(COMPONENTS_DIR, { withFileTypes: true }) .filter((entry) => entry.isDirectory()) .map((entry) => { const file = join(COMPONENTS_DIR, entry.name, `${entry.name}.css`) return { component: entry.name, file } }) .filter(({ file }) => existsSync(file)) .map(({ component, file }) => ({ component, css: stripCssComments(readFileSync(file, 'utf8')) })) } 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}-` return [ ...new Set( [...css.matchAll(CSS_CUSTOM_PROPERTY)] .map((match) => `--${match[1]}`) .filter((name) => name.startsWith(ownPrefix)) ) ].sort() } describe('Eidos recipe CSS contract', () => { it('declares every public component variable consumed by component CSS', () => { const missing: string[] = [] for (const { component, css } of readComponentCssFiles()) { const declared = new Set( tokenKeys(RECIPE_TOKENS[component] ?? {}).map((key) => recipeTokenName(component, key) ) ) 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) => !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([]) }) it('keeps raw color values out of component CSS', () => { const violations = readComponentCssFiles() .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(({ css }) => RAW_FONT_SIZE_LITERAL.test(css)) .map(({ component }) => component) 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 (active dev tracks, per the audit). it('keeps raw px/rem literals out of recipe font-size / icon-size tokens', () => { const EXCLUDED = new Set(['words', 'palabras', 'chronos']) const RAW_LENGTH = /^-?[0-9.]+(px|rem)$/ const violations: string[] = [] for (const [component, tokens] of Object.entries(RECIPE_TOKENS)) { if (EXCLUDED.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', () => { const recipeValueReferences = readRecipeValueReferences() const orphaned: string[] = [] for (const [component, tokens] of Object.entries(RECIPE_TOKENS)) { const source = `${readComponentSource(component)}\n${recipeValueReferences}` for (const key of tokenKeys(tokens)) { const name = recipeTokenName(component, key) if (!source.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([]) }) /** * 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 `` 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 { 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() const RE = /\[data-variant='([^']+)'\]/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 | 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() 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 } /** * 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() }) })