import { COLOR_ROLES, type EidosConfig } from './config-types' import type { EidosCssContract, EidosCssContractScope, EidosCssContractToken } from './config-types' import { emptyCssVar, renderBlock, renderStaticCss, renderThemeCss } from './render-css' /** * The CSS contract is DERIVED from the emission itself (census-as-data, * 2026-07-06, theming audit A.9): `createEidosCssContract` renders the same * static + theme CSS the generators ship and parses every public custom * property out of it. There is no second hand-maintained enumeration to * drift — a token exists in the contract iff the generators emit it * (private `--_*` recipe machinery excluded by the same rule as always). * * The previous contract was a parallel config-walk and had drifted by 211 * tokens: whole families born after it (depth, shape, scaling, breakpoints, * named styles, the overlay z band, …) never got a branch, so * `setCssVariables` (strict) rejected legitimately-emitted vocabulary and * the `renderContractCss` skeleton under-reported the theming surface. * * Metadata (`category` / `path`) is resolved from the token NAME plus the * config by an ordered rule table. A family without a rule still enters the * contract — it just gets its first segment as category and `(derived)` as * path. Coverage can never silently regress; only metadata richness can. */ const contractCache = new WeakMap() export function createEidosCssContract(options: EidosConfig): EidosCssContract { const cached = contractCache.get(options) if (cached) return cached const staticNames = extractPublicVariableNames(renderStaticCss(options)) const themeNames = new Set() const themeIds = Object.keys(options.themes ?? {}) if (themeIds.length === 0) { // CSS-only setups (themeSource: 'css') configure no themes, but the // theme-scope vocabulary (scales, roles, surfaces, shadows) still // exists — derive it from a synthetic empty theme so the fillable // `renderContractCss` skeleton stays complete. const probe: EidosConfig = { ...options, themes: { 'contract-probe': {} } } for (const name of extractPublicVariableNames( renderThemeCss(probe, 'contract-probe', { scales: 'all' }) )) { themeNames.add(name) } } else { for (const themeId of themeIds) { for (const name of extractPublicVariableNames( renderThemeCss(options, themeId, { scales: 'all' }) )) { themeNames.add(name) } } } const resolver = createMetadataResolver(options) const staticTokens = [...staticNames].map((name) => createToken(name, 'static', resolver)) const themeTokens = [...themeNames] .filter((name) => !staticNames.has(name)) .map((name) => createToken(name, 'theme', resolver)) const contract = Object.freeze({ static: Object.freeze(staticTokens), theme: Object.freeze(themeTokens) }) contractCache.set(options, contract) return contract } export function flattenEidosCssContract( contract: EidosCssContract ): readonly EidosCssContractToken[] { return Object.freeze([...contract.static, ...contract.theme]) } export interface RenderContractCssOptions { readonly staticSelector?: string readonly themeSelector?: string } /** Fillable skeleton (`--token: ;`) for external CSS-only themes and tooling. */ export function renderContractCss( options: EidosConfig, renderOptions: RenderContractCssOptions = {} ): string { const contract = createEidosCssContract(options) const staticDeclarations = contract.static.map((token) => emptyCssVar(token.name)) const themeDeclarations = contract.theme.map((token) => emptyCssVar(token.name)) return [ renderBlock(renderOptions.staticSelector ?? ':root', staticDeclarations), renderBlock(renderOptions.themeSelector ?? '[data-theme]', themeDeclarations) ].join('\n\n') } // Matches `--name:` declarations (a `var(--name)` reference is followed by // `)`/`,`, never `:`, so consumption sites don't match). const DECLARATION_PATTERN = /--([a-zA-Z0-9_][a-zA-Z0-9_-]*)\s*:/g function extractPublicVariableNames(css: string): Set { const names = new Set() for (const match of css.matchAll(DECLARATION_PATTERN)) { const name = match[1] if (!name.startsWith('_')) names.add(name) } return names } interface TokenMetadata { readonly category: string readonly path: string } type MetadataResolver = (name: string, scope: EidosCssContractScope) => TokenMetadata function createToken( name: string, scope: EidosCssContractScope, resolve: MetadataResolver ): EidosCssContractToken { const { category, path } = resolve(name, scope) return Object.freeze({ name, cssVar: `--${name}`, scope, category, path }) } function camelize(kebab: string): string { return kebab.replace(/-([a-z0-9])/g, (_, ch: string) => ch.toUpperCase()) } /** Longest-first so multi-segment keys win over shorter homonym prefixes. */ function byLengthDesc(a: string, b: string): number { return b.length - a.length } function createMetadataResolver(options: EidosConfig): MetadataResolver { // Exact recipe-token names, straight from the config (same exclusions as // the emitter: reserved sibling keys and private `_` keys never emit // public names). const recipeTokenPaths = new Map() for (const [component, tokens] of Object.entries(options.recipes ?? {})) { for (const key of Object.keys(tokens)) { if (key === 'composition' || key === 'container' || key.startsWith('_')) continue recipeTokenPaths.set(`${component}-${key}`, { category: `recipe.${component}`, path: `recipes.${component}.${key}` }) } } const sizeRecord = options.primitives.size const sizeKeys = Object.keys(sizeRecord ?? {}).sort(byLengthDesc) const sizeFontKey = (k: string): string => String(sizeRecord?.[k as keyof typeof sizeRecord]?.fontSize) const styleKeys = Object.keys(options.primitives.typography?.styles ?? {}).sort(byLengthDesc) const rolesByLength = [...COLOR_ROLES].sort(byLengthDesc) const SIZE_SLOT_PATHS: Record string> = { 'control-height': (k) => `primitives.size.${k}.controlHeight`, 'font-size': (k) => `primitives.size.${k}.fontSize`, 'font-line-height': (k) => `primitives.typography.sizes.${sizeFontKey(k)}.lineHeight`, 'font-letter-spacing': (k) => `primitives.typography.sizes.${sizeFontKey(k)}.letterSpacing`, 'icon-size': (k) => `primitives.size.${k}.iconSize`, 'padding-inline': (k) => `primitives.size.${k}.paddingInline`, 'padding-block': (k) => `primitives.size.${k}.paddingBlock`, gap: (k) => `primitives.size.${k}.gap`, radius: (k) => `primitives.size.${k}.radius` } const STYLE_PROP_PATHS: Record = { 'font-family': 'family', 'font-size': 'size', 'line-height': 'lineHeight', 'letter-spacing': 'letterSpacing', 'font-weight': 'weight', color: 'color' } return (name, scope) => { const recipeMeta = recipeTokenPaths.get(name) if (recipeMeta) return recipeMeta if (scope === 'theme') { let match = /^scale-(.+)-a(\d+)$/.exec(name) if (match) { return { category: 'color.scale.alpha', path: `themes.*.color.alphaScales.${match[1]}.${match[2]}` } } match = /^scale-(.+)-(\d+)$/.exec(name) if (match) { return { category: 'color.scale', path: `themes.*.color.scales.${match[1]}.${match[2]}` } } match = /^primitive-(.+)-a(\d+)$/.exec(name) if (match) { return { category: 'color.primitive.alpha', path: `semantics.color.roles.${match[1]}.alpha.${match[2]}` } } match = /^primitive-(.+)-(\d+)$/.exec(name) if (match) { return { category: 'color.primitive', path: `semantics.color.roles.${match[1]}.${match[2]}` } } for (const role of rolesByLength) { if (!name.startsWith(`color-${role}-`)) continue const slot = name.slice(`color-${role}-`.length) // P2-4 translucent role surfaces are derived from the alpha steps, // not authored slots — mirror their provenance. if (slot === 'surface') { return { category: 'color.role', path: `(derived) primitive-${role}-a2` } } if (slot === 'surface-hover') { return { category: 'color.role', path: `(derived) primitive-${role}-a3` } } return { category: 'color.role', path: `semantics.color.roles.${role}.slots.${camelize(slot)}` } } if (name === 'color-overlay') { return { category: 'color.surface', path: 'themes.*.color.surface.backdrop' } } if (name.startsWith('color-surface-')) { return { category: 'color.surface', path: `themes.*.color.surface.${camelize(name.slice('color-surface-'.length))}` } } if (name.startsWith('color-content-')) { return { category: 'color.content', path: `themes.*.color.content.${camelize(name.slice('color-content-'.length))}` } } if (name.startsWith('color-border-')) { return { category: 'color.border', path: `themes.*.color.border.${camelize(name.slice('color-border-'.length))}` } } if (name === 'focus-ring-color') { return { category: 'color.focus', path: 'themes.*.color.focus.ring' } } if (name === 'focus-ring-color-error') { return { category: 'color.focus', path: 'themes.*.color.focus.ringError' } } if (name.startsWith('shadow-')) { return { category: 'shadow', path: `themes.*.shadow.${name.slice('shadow-'.length)}` } } return { category: name.split('-')[0], path: '(derived)' } } for (const sizeKey of sizeKeys) { const prefix = `size-${sizeKey}-` if (!name.startsWith(prefix)) continue const slot = name.slice(prefix.length) const pathFor = SIZE_SLOT_PATHS[slot] return { category: 'size', path: pathFor ? pathFor(sizeKey) : '(derived)' } } for (const styleKey of styleKeys) { const prefix = `style-${styleKey}-` if (!name.startsWith(prefix)) continue const prop = STYLE_PROP_PATHS[name.slice(prefix.length)] return { category: 'typography.style', path: prop ? `primitives.typography.styles.${styleKey}.${prop}` : '(derived)' } } if (name.startsWith('space-')) { return { category: 'space', path: `primitives.space.${name.slice('space-'.length)}` } } if (name.startsWith('control-height-')) { return { category: 'control-height', path: `primitives.controlHeight.${name.slice('control-height-'.length)}` } } if (name === 'radius-factor') { return { category: 'radius', path: 'primitives.radiusFactor' } } if (name === 'radius-default') { return { category: 'radius', path: 'primitives.radiusDefault' } } if (name.startsWith('radius-')) { return { category: 'radius', path: `primitives.radius.${name.slice('radius-'.length)}` } } if (name.startsWith('border-width-')) { return { category: 'border.width', path: `primitives.border.width.${name.slice('border-width-'.length)}` } } if (name.startsWith('border-style-')) { return { category: 'border.style', path: `primitives.border.style.${name.slice('border-style-'.length)}` } } if (name === 'border-width') return { category: 'border', path: 'primitives.border.defaultWidth' } if (name === 'border-style') return { category: 'border', path: 'primitives.border.defaultStyle' } if (name === 'border') return { category: 'border', path: 'primitives.border' } if (name === 'ring-inset-width') { return { category: 'ring-inset', path: 'primitives.border.insetRingWidth' } } if (name === 'focus-ring-offset') { return { category: 'focus-ring', path: 'primitives.focusRing.offset' } } if (name === 'focus-ring-width') { return { category: 'focus-ring', path: 'primitives.focusRing.width' } } if (name === 'focus-ring-inner-width') { return { category: 'focus-ring', path: 'primitives.focusRing.innerWidth' } } if (name === 'focus-ring-error') { return { category: 'focus-ring', path: 'primitives.focusRing.error' } } if (name === 'focus-ring') return { category: 'focus-ring', path: 'primitives.focusRing' } if (name === 'press-scale') return { category: 'press', path: 'primitives.press.scale' } if (name === 'press-duration') return { category: 'press', path: 'primitives.press.duration' } if (name.startsWith('state-')) { return { category: 'state', path: `primitives.state.${name.slice('state-'.length)}` } } if (name === 'floating-gap-menu') { return { category: 'floating', path: 'primitives.floating.gapMenu' } } if (name === 'floating-gap-panel') { return { category: 'floating', path: 'primitives.floating.gapPanel' } } if (name.startsWith('container-width-')) { return { category: 'layout.container-width', path: `primitives.layout.containerWidth.${name.slice('container-width-'.length)}` } } if (name === 'container-padding-inline') { return { category: 'layout.container-padding', path: 'primitives.layout.containerPaddingInline' } } if (name.startsWith('content-width-')) { return { category: 'layout.content-width', path: `primitives.layout.contentWidth.${name.slice('content-width-'.length)}` } } if (name.startsWith('aspect-ratio-')) { return { category: 'layout.aspect-ratio', path: `primitives.layout.aspectRatio.${name.slice('aspect-ratio-'.length)}` } } if (name === 'density-space-scale') { return { category: 'density.active', path: 'primitives.density.spaceScale' } } if (name === 'density-control-scale') { return { category: 'density.active', path: 'primitives.density.controlScale' } } let match = /^density-(.+)-space-scale$/.exec(name) if (match) { return { category: 'density.space-scale', path: `primitives.density.spaceScale.${match[1]}` } } match = /^density-(.+)-control-scale$/.exec(name) if (match) { return { category: 'density.control-scale', path: `primitives.density.controlScale.${match[1]}` } } if (name.startsWith('duration-')) { return { category: 'motion.duration', path: `primitives.motion.duration.${name.slice('duration-'.length)}` } } if (name.startsWith('ease-')) { return { category: 'motion.ease', path: `primitives.motion.ease.${name.slice('ease-'.length)}` } } if (name.startsWith('motion-distance-')) { return { category: 'motion.distance', path: `primitives.motion.distance.${name.slice('motion-distance-'.length)}` } } if (name.startsWith('motion-scale-')) { return { category: 'motion.scale', path: `primitives.motion.scale.${name.slice('motion-scale-'.length)}` } } if (name === 'motion-stagger') return { category: 'motion', path: 'primitives.motion.stagger' } if (name.startsWith('motion-loop-')) return { category: 'motion.loop', path: '(derived)' } if (name.startsWith('motion-')) return { category: 'motion', path: '(derived)' } if (name.startsWith('icon-size-')) { return { category: 'icon.size', path: `primitives.icon.size.${name.slice('icon-size-'.length)}` } } if (name.startsWith('icon-stroke-width-')) { return { category: 'icon.stroke-width', path: `primitives.icon.strokeWidth.${name.slice('icon-stroke-width-'.length)}` } } if (name.startsWith('opacity-')) { return { category: 'opacity', path: `primitives.opacity.${name.slice('opacity-'.length)}` } } if (name.startsWith('z-index-')) { const key = name.slice('z-index-'.length) const declared = options.primitives.zIndex && key in options.primitives.zIndex return { category: 'z-index', path: declared ? `primitives.zIndex.${key}` : '(derived)' } } if (name.startsWith('font-family-')) { return { category: 'typography.family', path: `primitives.typography.families.${name.slice('font-family-'.length)}` } } if (name.startsWith('font-size-')) { return { category: 'typography.size', path: `primitives.typography.sizes.${name.slice('font-size-'.length)}.size` } } if (name.startsWith('font-line-height-')) { return { category: 'typography.size', path: `primitives.typography.sizes.${name.slice('font-line-height-'.length)}.lineHeight` } } if (name.startsWith('font-letter-spacing-')) { return { category: 'typography.size', path: `primitives.typography.sizes.${name.slice('font-letter-spacing-'.length)}.letterSpacing` } } if (name.startsWith('font-weight-')) { return { category: 'typography.weight', path: `primitives.typography.weights.${name.slice('font-weight-'.length)}` } } if (name.startsWith('font-feature-')) { return { category: 'typography.features', path: `primitives.typography.features.${name.slice('font-feature-'.length)}` } } if (name.startsWith('leading-')) { // Two config records share the prefix: the leading SCALE and the // per-role semantic leading — membership decides. const key = name.slice('leading-'.length) const semantic = options.primitives.typography?.semanticLeading return { category: 'typography.leading', path: semantic && key in semantic ? `primitives.typography.semanticLeading.${key}` : `primitives.typography.leading.${key}` } } if (name.startsWith('tracking-')) { return { category: 'typography.tracking', path: `primitives.typography.tracking.${name.slice('tracking-'.length)}` } } if (name.startsWith('measure-')) { return { category: 'typography.measure', path: `primitives.typography.measure.${name.slice('measure-'.length)}` } } if (name.startsWith('depth-')) return { category: 'depth', path: 'primitives.depth' } if (name.startsWith('shape-')) return { category: 'shape', path: 'primitives.shape' } if (name === 'scaling' || name.startsWith('scaling-')) { return { category: 'scaling', path: '(derived)' } } if (name.startsWith('breakpoint-')) return { category: 'breakpoint', path: '(derived)' } if (name.startsWith('blur-')) { return { category: 'blur', path: `primitives.blur.${name.slice('blur-'.length)}` } } if (name.startsWith('gradient-angle-')) { return { category: 'gradient', path: `primitives.gradientAngle.${name.slice('gradient-angle-'.length)}` } } if (name.startsWith('gradient-')) return { category: 'gradient', path: '(derived)' } if (name.startsWith('floating-')) return { category: 'floating', path: '(derived)' } return { category: name.split('-')[0], path: '(derived)' } } }