import { COLOR_ALPHA_STEPS, COLOR_SCALE_STEPS, COLOR_ROLES, DENSITY_KEYS, DURATION_KEYS, SCALING_KEYS, DEFAULT_SCALING, completeColorRoleMap, type ColorRoleDefinition, type ColorRoleSlot, type ColorRoleMap, type ColorAlphaStep, type ColorAlphaScale, type ColorScale, type ColorScales, type BorderColorRoles, type BorderPrimitiveSet, type ContentColorRoles, type DensityPrimitiveSet, type EidosCssContract, type EidosCssContractToken, type EidosCssVariableMap, type EidosCssVariableValue, type EidosConfig, type FontFamily, type FocusColorRoles, type LayoutPrimitiveSet, type PrimitiveSet, type RecipeComposition, type RecipeTokenSet, type RecipeTokenValue, type RecipeTokenDeclaration, type RecipeTokenMultiDeclaration, type SizePrimitiveSet, type TokenScope, type AtomicScope, type LeafScope, type SurfaceColorRoles, type ThemeColorSet, type TypographyPrimitiveSet } from './config-types' import { createEidosCssContract } from './contract' import { toKebab } from './utils' import { STATIC_SCALING } from './primitives/static' import type { CssStatePreset, CssPhase, EventSignature, KeyframeName, KeyframeStops, MotionConfig } from '$motion' import { apcaLc, oklchToCss, oklchToGammaRgb, safeParseColor, wcagContrastRatio } from '$color' import { EidosCssVariableError, EidosThemeNotFoundError } from '../errors' export interface RenderThemeCssOptions { readonly selector?: string } export interface RenderContractCssOptions { readonly staticSelector?: string readonly themeSelector?: string } export interface RenderCssVariablesOptions { readonly selector?: string readonly contract?: EidosCssContract | readonly EidosCssContractToken[] readonly strict?: boolean } // Radix functional steps: 1-2 backgrounds · 3-5 component bg (rest/hover/active) · // 6 subtle separator · 7 UI element border · 8 hovered element border · 9 solid · // 10 solid hover · 11-12 text. The role `border` slot maps to step 7 (Radix's "UI // element border") — step 6 reads as a washed-out separator on real element borders. const DEFAULT_COLOR_ROLE_SLOT_STEPS: Record = { track: '1', element: '3', hover: '4', active: '5', border: '7', solid: '9', solidHover: '10', text: '11', contrast: '12' } /** * On-solid legibility floor. The theme's `onSolid` text (white) must clear THIS on a * role's solid to stay; below it the contrast slot flips to `onSolidContrast` (dark). * APCA decides (|Lc| ≥ floor, ≈ the WCAG 3:1 UI minimum) with a WCAG 2 ratio as a * conservative cross-check — white must clear BOTH. (COLOR_ENGINE_RFC §8) */ const ON_SOLID_APCA_FLOOR = 60 const ON_SOLID_WCAG_FLOOR = 3 const DEFAULT_COLOR_ALPHA_PERCENTAGES: Record = { '1': '3%', '2': '5%', '3': '8%', '4': '12%', '5': '16%', '6': '22%', '7': '30%', '8': '40%', '9': '56%', '10': '68%', '11': '80%', '12': '92%' } const GENERIC_FONT_FAMILIES = new Set([ 'serif', 'sans-serif', 'monospace', 'cursive', 'fantasy', 'system-ui', 'ui-serif', 'ui-sans-serif', 'ui-monospace', 'emoji', 'math', 'fangsong' ]) export function renderStaticCss(options: EidosConfig): string { const { primitives } = options const declarations: string[] = [] // Spacing + control heights scale with BOTH the active density and the global // `--scaling` zoom (multiplicative): `calc(value * density-scale * scaling)`. // The `[data-density]` / `[data-scaling]` blocks rebind those vars. At // comfortable + scaling=100 both are 1, so output equals the raw value. appendScaledMetricDeclarations(declarations, 'space', primitives.space, 'density-space-scale') appendScaledMetricDeclarations( declarations, 'control-height', primitives.controlHeight, 'density-control-scale' ) appendRecordDeclarations(declarations, 'radius', primitives.radius) if (primitives.border) { appendBorderDeclarations(declarations, primitives.border) } if (primitives.focusRing) { declarations.push(cssVar('focus-ring-offset', primitives.focusRing.offset)) declarations.push(cssVar('focus-ring-width', primitives.focusRing.width)) declarations.push( cssVar( 'focus-ring', '0 0 0 var(--focus-ring-offset) var(--color-surface-default), ' + '0 0 0 calc(var(--focus-ring-offset) + var(--focus-ring-width)) ' + 'var(--focus-ring-color)' ) ) declarations.push( cssVar( 'focus-ring-error', '0 0 0 var(--focus-ring-offset) var(--color-surface-default), ' + '0 0 0 calc(var(--focus-ring-offset) + var(--focus-ring-width)) ' + 'var(--focus-ring-color-error)' ) ) } if (primitives.layout) { appendLayoutDeclarations(declarations, primitives.layout) } if (primitives.density) { appendDensityDeclarations(declarations, primitives.density) } // Scaling (global zoom) is a system constant, not part of the themeable // primitives — always emitted. appendScalingDeclarations(declarations) if (primitives.motion) { appendRecordDeclarations(declarations, 'duration', primitives.motion.duration) appendRecordDeclarations(declarations, 'ease', primitives.motion.ease) appendRecordDeclarations(declarations, 'motion-distance', primitives.motion.distance) appendRecordDeclarations(declarations, 'motion-scale', primitives.motion.scale) declarations.push(cssVar('motion-stagger', primitives.motion.stagger)) } if (primitives.icon) { appendScaledMetricDeclarations(declarations, 'icon-size', primitives.icon.size) appendRecordDeclarations(declarations, 'icon-stroke-width', primitives.icon.strokeWidth) } appendRecordDeclarations(declarations, 'opacity', primitives.opacity) appendRecordDeclarations(declarations, 'z-index', primitives.zIndex) if (primitives.typography) { appendTypographyDeclarations(declarations, primitives.typography) } if (primitives.size) { appendSizeDeclarations(declarations, primitives.size) } appendTransitionAliasDeclarations(declarations, options) const scopedRecipeBlocks = appendRecipeDeclarations(declarations, options.recipes) const blocks = [renderBlock(':root', declarations), ...scopedRecipeBlocks] // Responsive typography style overrides — one media-query block per // breakpoint that has at least one responsive size override. const styleResponsive = collectTypographyStyleResponsive(primitives.typography) for (const bp of STYLE_BREAKPOINT_ORDER) { const lines = styleResponsive[bp] if (!lines || lines.length === 0) continue const width = STYLE_BREAKPOINT_WIDTHS[bp] const rootBlock = renderBlock(':root', lines) blocks.push(`@media (min-width: ${width}px) {\n${indentBlock(rootBlock)}\n}`) } if (primitives.density) blocks.push(renderDensityBlocks()) blocks.push(renderScalingBlocks()) // Expressive motion set (Carbon's productive/expressive split): a scoped // override of the easing / duration tokens under `[data-motion-set='expressive']`. // Productive (the default) lives in `:root`; only the deltas are emitted here. const expressive = primitives.motion?.expressive if (expressive) { const exDeclarations: string[] = [] for (const [key, value] of Object.entries(expressive.ease ?? {})) { if (value) exDeclarations.push(cssVar(`ease-${key}`, value)) } for (const [key, value] of Object.entries(expressive.duration ?? {})) { if (value) exDeclarations.push(cssVar(`duration-${key}`, value)) } if (exDeclarations.length) { blocks.push(renderBlock("[data-motion-set='expressive']", exDeclarations)) } } if (options.motion) blocks.push(renderMotionBlocks(options.motion)) blocks.push(renderForcedColorsBlock()) return blocks.join('\n\n') } /** * Accessibility — Windows High Contrast / `forced-colors`. The OS forces colors via * `forced-color-adjust: auto`, which auto-maps borders / text / backgrounds to system * colors BUT drops `box-shadow` entirely — so the box-shadow focus ring (`--focus-ring`) * vanishes. Guarantee a visible keyboard-focus indicator with a system-colored * `outline` (which forced-colors preserve). Components that already focus via `outline` * (e.g. Button) keep theirs by higher specificity; this is the fallback for the * box-shadow ones. Emitted always — the `@media` gate makes it inert otherwise. */ function renderForcedColorsBlock(): string { return [ '@media (forced-colors: active) {', '\t:focus-visible {', '\t\toutline: 2px solid Highlight;', '\t\toutline-offset: 2px;', '\t}', '}' ].join('\n') } function indentBlock(block: string): string { return block .split('\n') .map((line) => (line ? `\t${line}` : line)) .join('\n') } 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') } export function renderCssVariables( variables: EidosCssVariableMap, renderOptions: RenderCssVariablesOptions = {} ): string { const declarations: string[] = [] const strict = renderOptions.strict ?? renderOptions.contract !== undefined const contractNames = renderOptions.contract ? getContractVariableNames(renderOptions.contract) : undefined const unknownNames: string[] = [] for (const [rawName, value] of Object.entries(variables)) { if (value === null || value === undefined) continue const name = normalizeCssVariableName(rawName) if (!name) { unknownNames.push(rawName) continue } if (contractNames && !contractNames.has(name)) { unknownNames.push(`--${name}`) if (strict) continue } declarations.push(cssVar(name, assertCssVariableValue(`--${name}`, value))) } if (unknownNames.length && strict) { throw new EidosCssVariableError(unknownNames) } return declarations.length ? renderBlock(renderOptions.selector ?? ':root', declarations) : '' } export function renderThemeCss( options: EidosConfig, themeId: string, renderOptions: RenderThemeCssOptions = {} ): string { const theme = options.themes?.[themeId] if (!theme) { throw new EidosThemeNotFoundError(themeId) } const color = mergeThemeColor( options.primitives.color?.scales, options.semantics.color, theme.color ) const declarations: string[] = [] const scaleNames = new Set([...Object.keys(color.scales), ...Object.keys(color.alphaScales)]) for (const scaleName of scaleNames) { const scale = color.scales[scaleName] if (scale) appendColorScaleDeclarations(declarations, scaleName, scale) appendColorAlphaScaleDeclarations(declarations, scaleName, scale, color.alphaScales[scaleName]) } // Intents omitted from the theme's role map auto-derive from the palette by // the book's canonical convention (CANONICAL_INTENT_SCALES). Identity is the // solid (step 9); slots derive normally below. (color grammar) const completedRoles = completeColorRoleMap(color.roles) for (const role of COLOR_ROLES) { const definition = completedRoles[role] const scaleName = getColorRoleScaleName(definition) for (const step of COLOR_SCALE_STEPS) { declarations.push(cssVar(`primitive-${role}-${step}`, `var(--scale-${scaleName}-${step})`)) } for (const step of COLOR_ALPHA_STEPS) { declarations.push(cssVar(`primitive-${role}-a${step}`, `var(--scale-${scaleName}-a${step})`)) } const slots = { ...DEFAULT_COLOR_ROLE_SLOT_STEPS, ...(typeof definition === 'string' ? undefined : definition.slots) } // The default `contrast` slot is the color drawn ON the solid fill // (button / badge / banner text). The contrasting color over a saturated // step-9 fill is the theme's `onSolid` (white) — but on a LIGHT solid // (amber / yellow / orange, step-9 luminance high) white is illegible // (~2:1). When `onSolid` fails the APCA floor (WCAG cross-checked), the // engine swaps to `onSolidContrast` (dark). Step-12 stays as the var // fallback. An explicit per-role `slots.contrast` override is honored // verbatim, so roles that intentionally invert keep working. (audit P2-2) const solidColor = color.scales[scaleName]?.['9'] const parsedSolid = solidColor ? safeParseColor(solidColor) : null const parsedOnSolid = color.content?.onSolid ? safeParseColor(color.content.onSolid) : null let onSolidFailsContrast = false if (parsedSolid && parsedOnSolid) { const solidRgb = oklchToGammaRgb(parsedSolid) const onSolidRgb = oklchToGammaRgb(parsedOnSolid) // APCA decides; WCAG 2 is a conservative cross-check — white must clear BOTH. onSolidFailsContrast = Math.abs(apcaLc(onSolidRgb, solidRgb)) < ON_SOLID_APCA_FLOOR || wcagContrastRatio(onSolidRgb, solidRgb) < ON_SOLID_WCAG_FLOOR } const defaultContrastToken = onSolidFailsContrast ? 'color-content-on-solid-contrast' : 'color-content-on-solid' for (const [slot, step] of Object.entries(slots)) { const isDefaultContrast = slot === 'contrast' && (typeof definition === 'string' || definition.slots?.contrast === undefined) const value = isDefaultContrast ? `var(--${defaultContrastToken}, var(--primitive-${role}-${step}))` : `var(--primitive-${role}-${step})` declarations.push(cssVar(`color-${role}-${toKebab(slot)}`, value)) } // Translucent role surface (P2-4): the soft / surface variant tint. // Built from the compositing-inverse alpha steps so it composites // correctly over non-uniform backgrounds (striped rows, images, // gradients) — unlike an opaque step-1 / color-mix tint. declarations.push(cssVar(`color-${role}-surface`, `var(--primitive-${role}-a2)`)) declarations.push(cssVar(`color-${role}-surface-hover`, `var(--primitive-${role}-a3)`)) } appendThemeColorDeclarations(declarations, color) if (theme.shadow) { appendRecordDeclarations(declarations, 'shadow', theme.shadow) } return renderBlock(renderOptions.selector ?? getThemeSelector(themeId), declarations) } export { EidosCssVariableError } from '../errors' function appendBorderDeclarations(declarations: string[], border: BorderPrimitiveSet): void { appendRecordDeclarations(declarations, 'border-width', border.width) appendRecordDeclarations(declarations, 'border-style', border.style) declarations.push(cssVar('border-width', `var(--border-width-${border.defaultWidth})`)) declarations.push(cssVar('border-style', `var(--border-style-${border.defaultStyle})`)) declarations.push( cssVar('border', 'var(--border-width) var(--border-style) var(--color-border-default)') ) } function appendLayoutDeclarations(declarations: string[], layout: LayoutPrimitiveSet): void { appendRecordDeclarations(declarations, 'container-width', layout.containerWidth) declarations.push(cssVar('container-padding-inline', layout.containerPaddingInline)) appendRecordDeclarations(declarations, 'content-width', layout.contentWidth) appendRecordDeclarations(declarations, 'aspect-ratio', layout.aspectRatio) } function appendDensityDeclarations(declarations: string[], density: DensityPrimitiveSet): void { appendDensityRecordDeclarations(declarations, density.spaceScale, 'space-scale') appendDensityRecordDeclarations(declarations, density.controlScale, 'control-scale') appendActiveDensityDeclarations(declarations, 'comfortable') } function appendDensityRecordDeclarations( declarations: string[], record: Record, suffix: string ): void { for (const [name, value] of Object.entries(record)) { declarations.push(cssVar(`density-${name}-${suffix}`, value)) } } function renderDensityBlocks(): string { return DENSITY_KEYS.filter((densityKey) => densityKey !== 'comfortable') .map((densityKey) => { const declarations: string[] = [] appendActiveDensityDeclarations(declarations, densityKey) return renderBlock(`[data-density='${densityKey}']`, declarations) }) .join('\n\n') } function appendActiveDensityDeclarations(declarations: string[], densityKey: string): void { declarations.push(cssVar('density-space-scale', `var(--density-${densityKey}-space-scale)`)) declarations.push(cssVar('density-control-scale', `var(--density-${densityKey}-control-scale)`)) } // Scaling = global zoom (Radix parity), orthogonal to density. Emits the // `--scaling-{90..110}` constants + the active `--scaling` (default 100). // `[data-scaling]` blocks (renderScalingBlocks) rebind `--scaling`. function appendScalingDeclarations(declarations: string[]): void { for (const [key, value] of Object.entries(STATIC_SCALING)) { declarations.push(cssVar(`scaling-${key}`, value)) } declarations.push(cssVar('scaling', `var(--scaling-${DEFAULT_SCALING})`)) } function renderScalingBlocks(): string { return SCALING_KEYS.filter((key) => key !== DEFAULT_SCALING) .map((key) => renderBlock(`[data-scaling='${key}']`, [cssVar('scaling', `var(--scaling-${key})`)]) ) .join('\n\n') } // ── Motion ───────────────────────────────────────────────────────────────── // Emits the registered `@keyframes` + per-preset rules selected by // `data-animation-style` × `data-state` (enter→open / exit→closed), with // `data-side`-aware variants. Only `css`-driver presets generate CSS; JS // presets run through the motion engine (`uix.motion`). Reduced motion is honored per preset // policy. Enter uses `fill: backwards` (no initial flash, no forwards // accumulation); exit uses `fill: forwards` (hold the end state until soma's // Presence unmounts the element). const MOTION_PHASES = [ { phase: 'enter', state: 'open', fill: 'backwards', dur: 'moderate', ease: 'out' }, { phase: 'exit', state: 'closed', fill: 'forwards', dur: 'fast', ease: 'in' } ] as const function renderMotionBlocks(motion: MotionConfig): string { const blocks: string[] = [] // Per-component duration + easing overrides. A preset owns the SHAPE + a // default timing/curve; a component (or theme) tunes its own enter/exit by // setting these on its animated element — `phaseDeclarations` reads them with // the preset token as fallback. `inherits: false` so a preset component nested // inside another doesn't inherit the ancestor's values. for (const phase of ['enter', 'exit'] as const) { blocks.push(`@property --motion-duration-${phase} {\n\tsyntax: '*';\n\tinherits: false;\n}`) blocks.push(`@property --motion-ease-${phase} {\n\tsyntax: '*';\n\tinherits: false;\n}`) } // Stagger index is per-item — `inherits: false` stops it leaking into nested // groups; the rhythm (`--motion-stagger-each`) inherits from the container. blocks.push( "@property --motion-stagger-index {\n\tsyntax: '';\n\tinherits: false;\n\tinitial-value: 0;\n}" ) for (const [name, stops] of Object.entries(motion.keyframes ?? {})) { blocks.push(renderKeyframes(name, stops)) } // Momento --event: la firma perceptiva (genérica por family/intent/event). for (const [name, signature] of Object.entries(motion.signatures ?? {})) { blocks.push(...renderSignatureRules(name, signature)) } // Momento --state: presets de transición per-componente (data-state). for (const [name, preset] of Object.entries(motion.presets ?? {})) { if (preset.driver !== 'css') continue blocks.push(...renderCssPresetRules(name, preset)) blocks.push(...renderReducedMotionRules(name, preset)) } return blocks.join('\n\n') } function renderKeyframes(name: KeyframeName, stops: KeyframeStops): string { const body = Object.entries(stops) .map(([stop, decls]) => { const inner = Object.entries(decls) .map(([prop, value]) => `\t\t${prop}: ${value};`) .join('\n') return `\t${stop} {\n${inner}\n\t}` }) .join('\n') return `@keyframes ${name} {\n${body}\n}` } function renderCssPresetRules(name: string, preset: CssStatePreset): string[] { const rules: string[] = [] for (const { phase, state, fill, dur, ease } of MOTION_PHASES) { const cssPhase = preset[phase] if (!cssPhase) continue rules.push( renderBlock( `[data-animation-style='${name}'][data-state='${state}']`, phaseDeclarations(cssPhase, cssPhase.keyframes, fill, dur, ease, phase) ) ) for (const [side, keyframes] of Object.entries(cssPhase.bySide ?? {})) { rules.push( renderBlock( `[data-animation-style='${name}'][data-side='${side}'][data-state='${state}']`, phaseDeclarations(cssPhase, keyframes, fill, dur, ease, phase) ) ) } } return rules } function phaseDeclarations( cssPhase: CssPhase, keyframes: KeyframeName | readonly KeyframeName[], fill: string, defaultDuration: string, defaultEase: string, phase: string ): string[] { const names = typeof keyframes === 'string' ? [keyframes] : keyframes // Per-component overrides (`--motion-{duration,ease}-{enter,exit}`) → preset token. const dur = `var(--motion-duration-${phase}, var(--duration-${cssPhase.duration ?? defaultDuration}))` const ease = `var(--motion-ease-${phase}, var(--ease-${cssPhase.ease ?? defaultEase}))` const animation = names.map((kf) => `${kf} ${dur} ${ease} ${fill}`).join(', ') const declarations = [`animation: ${animation};`] // Stagger (Material list choreography, zero JS orchestration): a container // sets `--motion-stagger-each` (rhythm) and each item `--motion-stagger-index`, // so each item's enter is delayed by index × each. Default 0 → no stagger; the // `backwards` fill holds the `from` state until each item's turn. if (phase === 'enter') { declarations.push( 'animation-delay: calc(var(--motion-stagger-index, 0) * var(--motion-stagger-each, 0ms));' ) } if (cssPhase.transformOrigin) { declarations.push(`transform-origin: ${cssPhase.transformOrigin};`) } return declarations } // Reduced motion: 'none' runs as-is (a pure fade is acceptable); 'instant' // kills the animation (snap to the steady `[data-state]` style); 'opacity-only' // keeps a brief fade and drops transforms. Emitted both under the projected // `[data-motion='reduce']` and the media query, so it works with or without // the prefs DOM projection. `!important` beats the bySide variants. function renderReducedMotionRules(name: string, preset: CssStatePreset): string[] { const policy = preset.reduce ?? 'none' if (policy === 'none') return [] const rules: string[] = [] for (const { phase, state } of MOTION_PHASES) { if (!preset[phase]) continue const inner = policy === 'instant' ? ['animation: none !important;'] : phase === 'enter' ? ['animation: fade-in var(--duration-fast) var(--ease-out) backwards !important;'] : ['animation: fade-out var(--duration-fast) var(--ease-in) forwards !important;'] const selector = `[data-animation-style='${name}'][data-state='${state}']` rules.push(renderBlock(`[data-motion='reduce'] ${selector}`, inner)) rules.push( `@media (prefers-reduced-motion: reduce) {\n${indentBlock(renderBlock(selector, inner))}\n}` ) } return rules } // ── Momento --event: la firma perceptiva (genérica por evento) ─────────────── // Reacciona a `data-event-*` durante el hold de sema. El selector se construye // por `family` / `intent` / `event`; es lo que hoy hace `events.css` a mano // (F2 migrará su contenido aquí). Es el canal `motion` de la firma (GUIA §11). function signatureSelectorList(sig: EventSignature): string[] { const suffix = `${sig.family ? `[data-event-family='${sig.family}']` : ''}` + `${sig.intent ? `[data-event-intent='${sig.intent}']` : ''}` + `[data-event-phase='active']` const events = sig.event === undefined ? [undefined] : typeof sig.event === 'string' ? [sig.event] : sig.event return events.map((e) => `${e ? `[data-event^='${e}']` : ''}${suffix}`) } // Duración: un token conocido → `var(--duration-X)`; cualquier otra cosa (un // hold crudo como `600ms`) se emite tal cual. F6 tokenizará los crudos. function resolveDurationValue(value: string): string { return (DURATION_KEYS as readonly string[]).includes(value) ? `var(--duration-${value})` : value } function renderSignatureRules(_name: string, sig: EventSignature): string[] { const names = typeof sig.keyframes === 'string' ? [sig.keyframes] : sig.keyframes const dur = resolveDurationValue(sig.duration ?? 'moderate') const ease = `var(--ease-${sig.ease ?? 'out'})` const fill = sig.fill && sig.fill !== 'none' ? ` ${sig.fill}` : '' const animation = names.map((kf) => `${kf} ${dur} ${ease}${fill}`).join(', ') const selectors = signatureSelectorList(sig) const rules = [renderBlock(selectors.join(',\n'), [`animation: ${animation};`])] const policy = sig.reduce ?? 'none' if (policy !== 'none') { const inner = policy === 'instant' ? ['animation: none !important;'] : ['animation: fade-in var(--duration-fast) var(--ease-out) !important;'] rules.push( renderBlock(selectors.map((s) => `[data-motion='reduce'] ${s}`).join(',\n'), inner) ) rules.push( `@media (prefers-reduced-motion: reduce) {\n${indentBlock(renderBlock(selectors.join(',\n'), inner))}\n}` ) } return rules } function appendTypographyDeclarations( declarations: string[], typography: TypographyPrimitiveSet ): void { for (const [name, family] of Object.entries(typography.families)) { declarations.push(cssVar(`font-family-${name}`, formatFontFamily(family))) } for (const [name, metric] of Object.entries(typography.sizes)) { // font-size scales with the global `--scaling` zoom (px). line-height is a // unitless ratio — it scales implicitly via font-size; scaling it here too // would double-scale. letter-spacing stays as authored. declarations.push(cssVar(`font-size-${name}`, `calc(${metric.size} * var(--scaling))`)) declarations.push(cssVar(`font-line-height-${name}`, metric.lineHeight)) declarations.push(cssVar(`font-letter-spacing-${name}`, metric.letterSpacing)) } for (const [name, weight] of Object.entries(typography.weights)) { declarations.push(cssVar(`font-weight-${name}`, weight)) } if (typography.styles) { appendTypographyStyleDeclarations(declarations, typography) } } /** * Breakpoint min-width thresholds matching `$libs/dom/responsive`. * `base` is the implicit 0 fallback — emitted into `:root`, not as a * media query. */ const STYLE_BREAKPOINT_WIDTHS: Record = { sm: 480, md: 768, lg: 1024, xl: 1280, xxl: 1536 } const STYLE_BREAKPOINT_ORDER = ['sm', 'md', 'lg', 'xl', 'xxl'] as const /** * Emit the named typography styles (hero, h1..h6, body, prose, label, * caption, code, …) as `--style-{name}-{font-family,font-size, * font-weight,line-height,letter-spacing,color}` custom properties. * * Each style entry references primitives the foundation already emits * (`var(--font-family-X)`, `var(--font-size-Y)`, etc.). Components * consume these tokens directly — `` reads * `var(--style-h1-font-size)` and friends. * * Responsive sizes (e.g. `hero.size = { base: 'xxl', md: 'xxxl' }`) * are emitted as base declarations in `:root` plus per-breakpoint * `@media (min-width: …)` overrides appended after the main block. * Non-size fields (family, weight, line-height, letter-spacing, color) * are not responsive in this contract. */ function appendTypographyStyleDeclarations( declarations: string[], typography: TypographyPrimitiveSet ): void { const styles = typography.styles if (!styles) return // Capture responsive size overrides per breakpoint. Same shape as // `declarations` but keyed by the active breakpoint so we can emit // them as media-query blocks after the base. for (const [name, style] of Object.entries(styles)) { if (style.family) { declarations.push( cssVar(`style-${name}-font-family`, `var(--font-family-${style.family})`) ) } const sizeRes = style.size if (sizeRes !== undefined) { const baseSize = pickResponsiveBase(sizeRes) if (baseSize !== undefined) { declarations.push( cssVar(`style-${name}-font-size`, resolveStyleSizeRef(baseSize)) ) declarations.push( cssVar( `style-${name}-line-height`, style.lineHeight ?? resolveStyleLineHeightRef(baseSize) ) ) declarations.push( cssVar( `style-${name}-letter-spacing`, style.letterSpacing ?? resolveStyleLetterSpacingRef(baseSize) ) ) } else if (style.lineHeight) { declarations.push(cssVar(`style-${name}-line-height`, style.lineHeight)) } } else if (style.lineHeight) { declarations.push(cssVar(`style-${name}-line-height`, style.lineHeight)) } if (style.weight !== undefined) { const weightRef = typeof style.weight === 'number' ? String(style.weight) : `var(--font-weight-${style.weight})` declarations.push(cssVar(`style-${name}-font-weight`, weightRef)) } if (style.color) { declarations.push( cssVar(`style-${name}-color`, `var(--color-${style.color.replace(/\./g, '-')})`) ) } } } /** * Collect per-breakpoint style size overrides. Returns a record mapping * breakpoint -> declaration list. Empty buckets are dropped by the * caller before emitting media-query blocks. */ function collectTypographyStyleResponsive( typography: TypographyPrimitiveSet | undefined ): Record { const result: Record = {} if (!typography?.styles) return result for (const [name, style] of Object.entries(typography.styles)) { const sizeRes = style.size if (sizeRes === undefined) continue if (typeof sizeRes !== 'object' || sizeRes === null || isTextMetric(sizeRes)) continue const responsive = sizeRes as Partial> for (const bp of STYLE_BREAKPOINT_ORDER) { const value = responsive[bp] if (value === undefined) continue const list = result[bp] ?? (result[bp] = []) list.push(cssVar(`style-${name}-font-size`, resolveStyleSizeRef(value))) list.push(cssVar(`style-${name}-line-height`, resolveStyleLineHeightRef(value))) list.push(cssVar(`style-${name}-letter-spacing`, resolveStyleLetterSpacingRef(value))) } } return result } function pickResponsiveBase(value: unknown): unknown { if (value === undefined || value === null) return undefined if (typeof value === 'string' || typeof value === 'number') return value if (isTextMetric(value)) return value if (typeof value === 'object') { const obj = value as Partial> if (obj.base !== undefined) return obj.base // Fallback: take the first defined breakpoint in order. for (const bp of STYLE_BREAKPOINT_ORDER) { if (obj[bp] !== undefined) return obj[bp] } } return undefined } function isTextMetric(value: unknown): boolean { return ( typeof value === 'object' && value !== null && 'size' in value && 'lineHeight' in value && 'letterSpacing' in value ) } function resolveStyleSizeRef(value: unknown): string { if (typeof value === 'string') return `var(--font-size-${value})` if (isTextMetric(value)) return (value as { size: string }).size return 'inherit' } function resolveStyleLineHeightRef(value: unknown): string { if (typeof value === 'string') return `var(--font-line-height-${value})` if (isTextMetric(value)) return (value as { lineHeight: string }).lineHeight return 'inherit' } function resolveStyleLetterSpacingRef(value: unknown): string { if (typeof value === 'string') return `var(--font-letter-spacing-${value})` if (isTextMetric(value)) return (value as { letterSpacing: string }).letterSpacing return 'normal' } function appendSizeDeclarations(declarations: string[], sizes: SizePrimitiveSet): void { for (const [name, size] of Object.entries(sizes)) { declarations.push( cssVar(`size-${name}-control-height`, `var(--control-height-${size.controlHeight})`) ) declarations.push(cssVar(`size-${name}-font-size`, `var(--font-size-${size.fontSize})`)) declarations.push( cssVar(`size-${name}-font-line-height`, `var(--font-line-height-${size.fontSize})`) ) declarations.push( cssVar(`size-${name}-font-letter-spacing`, `var(--font-letter-spacing-${size.fontSize})`) ) declarations.push(cssVar(`size-${name}-icon-size`, `var(--icon-size-${size.iconSize})`)) declarations.push(cssVar(`size-${name}-padding-inline`, size.paddingInline)) declarations.push(cssVar(`size-${name}-padding-block`, size.paddingBlock)) declarations.push(cssVar(`size-${name}-gap`, size.gap)) declarations.push(cssVar(`size-${name}-radius`, `var(--radius-${size.radius})`)) } } function appendTransitionAliasDeclarations( declarations: string[], options: EidosConfig ): void { const { primitives } = options if (primitives.controlHeight?.xxs) { declarations.push(cssVar('control-height-2xs', 'var(--control-height-xxs)')) } if (primitives.radius?.sm) { declarations.push(cssVar('radius-xs', 'var(--radius-sm)')) } appendFontFamilyAliases(declarations, primitives.typography?.families) appendTypographyAliases(declarations, primitives.typography) if (primitives.motion) { declarations.push(cssVar('motion-spin-duration', '800ms')) } if (hasFocusColor(options)) { declarations.push(cssVar('color-focus-ring', 'var(--focus-ring-color)')) } } /** * Append `:root`-scoped recipe declarations to the caller's list and return * additional CSS blocks for scoped tokens. * * Token Scope Contract (TSC) v2 — three guarantees: * * 1. **Automatic dependency inference**: the generator parses * `var(--{component}-XXX)` references in every token's value and treats * `XXX` as a dependency. Adding `depends: [...]` is a manual supplement; * you do not need it to catch the eager-resolution bug. * * 2. **Scope algebra**: a token's scope must "cover" every dependency's * scope. `root` covers nothing but root; `host` covers root+host; * leaf scope `axis:value` covers `root`, `host`, and the same * `axis:value`. Composite leaf scopes (arrays) cover any subset. * * 3. **Cross-axis collision detection**: if a token has multiple atomic * declarations on incomparable axes (e.g. `color:affirm` AND * `state:on`), the generator requires an explicit composite * declaration `[color:affirm, state:on]` to disambiguate cascade * order when an element matches both. Without that composite, the * cascade winner depends on declaration source order — a silent * correctness bug. * * All three are enforced at generation time. Violations throw with a * clear message naming the offending tokens. */ function appendRecipeDeclarations( declarations: string[], recipes: RecipeTokenSet | undefined ): string[] { if (!recipes) return [] const blocks: string[] = [] const allViolations: string[] = [] for (const [component, tokens] of Object.entries(recipes)) { // `composition` is a reserved sibling key on the recipe map (TSC // v2.2 cross-recipe overrides) — not a regular token. Strip it // before normalizing and process it separately at the end of // this iteration so it composes onto the same `blocks` array. const composition = (tokens as { composition?: Readonly> }) .composition const tokensOnly = stripCompositionKey(tokens) // Phase 1: normalize every token to a list of declarations. const normalized = normalizeRecipeTokens(component, tokensOnly) // Phase 2: collect scope-algebra violations across all declarations. allViolations.push(...collectScopeViolations(component, normalized)) // Phase 3: detect cross-axis collisions per token. allViolations.push(...collectCrossAxisCollisions(component, normalized)) // Phase 4: emit declarations by scope. The 'root' bucket appends // to the caller's :root declarations; everything else is a block. // // Token visibility convention (TSC v2.1): // key starts with '_' → private token, emitted as `--_{c}-{rest}` // key does not → public token, emitted as `--{c}-{name}` // // Private tokens stay out of the public CSS contract (contract.ts // skips them) and follow the `--_{c}-*` naming convention used by // recipes to mark "internal machinery, do not consume externally". // // Multi-part scope (TSC v2.2): when a normalized declaration carries // `parts: [...]`, the scope selector is built from a comma-separated // list of `[data-{c}-{part}]` instead of the default `[data-{c}]` // root. Used for components whose `data-color` attribute lives on // parts (Select cascades via Trigger + Content), not on the root. // Bucket-key includes parts so two scope-identical declarations // targeting different parts emit as separate blocks. const byScopeKey = new Map< string, { scope: TokenScope; parts: readonly string[] | undefined; lines: string[] } >() for (const [name, decls] of normalized) { const cssVarName = name.startsWith('_') ? `_${component}-${name.slice(1)}` : `${component}-${name}` for (const decl of decls) { const key = `${scopeKey(decl.scope)}@${(decl.parts ?? []).join(',')}` const bucket = byScopeKey.get(key) ?? { scope: decl.scope, parts: decl.parts, lines: [] } bucket.lines.push(cssVar(cssVarName, decl.value)) byScopeKey.set(key, bucket) } } const rootKey = `${scopeKey('root')}@` const rootBucket = byScopeKey.get(rootKey) if (rootBucket) declarations.push(...rootBucket.lines) for (const [key, { scope, parts, lines }] of byScopeKey) { if (key === rootKey) continue blocks.push(renderBlock(recipeScopeSelector(component, scope, parts), lines)) } // Phase 5: cross-recipe composition (TSC v2.2). The host recipe // declares overrides of FOREIGN tokens scoped to its own // conditions, with a `targetSelector` to reach the foreign part. if (composition) { blocks.push(...emitComposition(component, composition)) } } if (allViolations.length) { throw new Error( `Eidos recipe scope contract violations:\n - ${allViolations.join('\n - ')}` ) } return blocks } /** * Strip the reserved `composition` key from a recipe token map so the * normalizer only sees regular token entries. Returns a plain object — * the original `Readonly` shape is irrelevant at runtime. */ function stripCompositionKey( tokens: Readonly> ): Readonly> { const out: Record = {} for (const [key, value] of Object.entries(tokens)) { if (key === 'composition') continue out[key] = value as RecipeTokenValue } return out } /** * Emit blocks for a recipe's cross-recipe composition (TSC v2.2). * * For each `{foreignComponent: { targetSelector, tokens }}` entry: * - the CSS variable names are derived from the FOREIGN component * (`--{foreign}-{tokenName}` for public, `--_{foreign}-{tokenName}` * for private — leading `_` in the token key). * - the selector is the HOST component's scope (e.g. * `[data-toggle-group][data-color='X']`) combined with the * `targetSelector` as a descendant. * * Composition tokens MUST declare a non-root scope — if they applied at * `:root` they'd be regular foundation tokens of the foreign recipe, not * composition overrides. The host's purpose is to RESTRICT the override * to its own cascade. */ function emitComposition( hostComponent: string, composition: Readonly> ): string[] { const blocks: string[] = [] for (const [foreignComponent, entry] of Object.entries(composition)) { const { targetSelector, tokens } = entry // Group by host-scope so a single rule covers all foreign-token // overrides declared at the same host scope. const byScopeKey = new Map() for (const [tokenName, multi] of Object.entries(tokens)) { const cssVarName = tokenName.startsWith('_') ? `_${foreignComponent}-${tokenName.slice(1)}` : `${foreignComponent}-${tokenName}` for (const decl of multi.declarations) { const normalizedScope = normalizeScopeAlias(decl.scope ?? 'root') if (scopeKey(normalizedScope) === scopeKey('root')) { throw new Error( `Eidos recipe composition: ${hostComponent}.composition.${foreignComponent}.${tokenName} ` + `has a 'root' declaration. Composition overrides MUST be scoped — ` + `a root override belongs in the foreign recipe itself.` ) } const key = scopeKey(normalizedScope) const bucket = byScopeKey.get(key) ?? { scope: normalizedScope, lines: [] } bucket.lines.push(cssVar(cssVarName, decl.value)) byScopeKey.set(key, bucket) } } for (const { scope, lines } of byScopeKey.values()) { const hostSelector = recipeScopeSelector(hostComponent, scope) blocks.push(renderBlock(`${hostSelector} ${targetSelector}`, lines)) } } return blocks } /** Map of token name → list of normalized declarations (one per scope). */ type NormalizedDeclarations = Map interface NormalizedDeclaration { readonly value: string readonly scope: TokenScope readonly depends: readonly string[] /** * TSC v2.2 — parts the declaration cascades on. When set, the scope * selector is built from comma-joined `[data-{c}-{part}]` instead of * the default `[data-{c}]`. Inherited from the `RecipeTokenMultiDeclaration` * level (all declarations of a multi-decl token share the same parts). */ readonly parts?: readonly string[] } /** * Normalize a component's tokens: convert each entry to its list of * declarations, infer var()-driven dependencies, merge explicit deps. */ function normalizeRecipeTokens( component: string, tokens: Readonly> ): NormalizedDeclarations { const result: NormalizedDeclarations = new Map() // Pre-collect the set of names declared in this recipe so dep inference // only counts refs to fellow recipe tokens. A `var(--icon-stroke-width-md)` // inside the icon recipe is NOT a dep on `stroke-width-md` if that name // belongs to the primitives layer (not to the icon recipe itself). const declaredNames = new Set(Object.keys(tokens)) for (const [name, raw] of Object.entries(tokens)) { const { declarations, parts } = expandToDeclarationsWithParts(raw) const decls: NormalizedDeclaration[] = declarations.map((decl) => ({ value: decl.value, scope: normalizeScopeAlias(decl.scope ?? 'root'), depends: mergeInferredAndExplicitDeps( decl.value, component, decl.depends ?? [], declaredNames ), parts })) result.set(name, decls) } return result } /** Expand a recipe token entry into its list of raw declarations + parts. */ function expandToDeclarationsWithParts(raw: RecipeTokenValue): { declarations: RecipeTokenDeclaration[] parts: readonly string[] | undefined } { if (typeof raw !== 'object') { return { declarations: [{ value: String(raw), scope: 'root', depends: [] }], parts: undefined } } if ('declarations' in raw) { return { declarations: [...raw.declarations], parts: (raw as RecipeTokenMultiDeclaration).parts } } return { declarations: [raw], parts: undefined } } /** * `palette:X` is a deprecated alias for `color:X`. Both target the same * `data-color` DOM attribute. The canonical name is `color:*` because it * names the DOM axis, not the internal "palette" indirection. */ function normalizeScopeAlias(scope: TokenScope): TokenScope { if (typeof scope === 'string' && scope.startsWith('palette:')) { return `color:${scope.slice('palette:'.length)}` as AtomicScope } if (Array.isArray(scope)) { return scope.map((atom) => atom.startsWith('palette:') ? (`color:${atom.slice('palette:'.length)}` as LeafScope) : atom ) } return scope } /** * Find every `var(--{component}-XXX)` and `var(--_{component}-XXX)` in * a value and return the `XXX` names (prefixed with `_` for private refs). * Combined with explicit `depends`, this is the full dependency set the * generator validates. * * Examples for component `toggle`: * value `'var(--toggle-palette-solid)'` → infers `palette-solid` (public) * value `'var(--_toggle-bg)'` → infers `_bg` (private) * value `'var(--color-affirm-solid)'` → infers nothing (external prefix) */ const VAR_REF_REGEX = /var\(\s*--([a-z_][a-z0-9_-]*)/g function inferDepsFromValue(value: string, componentPrefix: string): string[] { const publicPrefix = `${componentPrefix}-` const privatePrefix = `_${componentPrefix}-` const inferred: string[] = [] for (const match of value.matchAll(VAR_REF_REGEX)) { const fullName = match[1] if (fullName.startsWith(publicPrefix)) { inferred.push(fullName.slice(publicPrefix.length)) } else if (fullName.startsWith(privatePrefix)) { // Private dep — the recipe key is `_{rest}` (with leading underscore) inferred.push(`_${fullName.slice(privatePrefix.length)}`) } } return inferred } function mergeInferredAndExplicitDeps( value: string, component: string, explicit: readonly string[], declaredNames: ReadonlySet ): readonly string[] { const merged = new Set() for (const inferred of inferDepsFromValue(value, component)) { // Only count it as a dep if the referenced name actually belongs // to THIS recipe. Otherwise the ref points to a primitive / theme // token / cross-recipe alias, none of which TSC tracks. if (declaredNames.has(inferred)) merged.add(inferred) } for (const dep of explicit) merged.add(dep) return [...merged] } /** A canonical string key for de-duplicating scopes in maps. */ function scopeKey(scope: TokenScope): string { if (typeof scope === 'string') return scope return [...scope].sort().join('+') } /** * Build the CSS selector that materialises a TSC scope for a component. * * When `parts` is provided (TSC v2.2 multi-part scope), the root selector * becomes a comma-separated list of `[data-{component}-{part}]` instead of * the default `[data-{component}]`. Each part gets the same leaf * fragments appended. Example: * * recipeScopeSelector('select', 'color:affirm', ['trigger', 'content']) * → "[data-select-trigger][data-color='affirm'], [data-select-content][data-color='affirm']" */ function recipeScopeSelector( component: string, scope: TokenScope, parts?: readonly string[] ): string { if (scope === 'root') return ':root' const hosts = parts && parts.length > 0 ? parts.map((part) => `[data-${component}-${part}]`) : [`[data-${component}]`] if (scope === 'host') return hosts.join(', ') const fragments = typeof scope === 'string' ? [atomicLeafFragment(scope as LeafScope)] : [...scope].sort().map((atom) => atomicLeafFragment(atom)) const tail = fragments.join('') return hosts.map((host) => `${host}${tail}`).join(', ') } function atomicLeafFragment(scope: LeafScope): string { const sepIdx = scope.indexOf(':') const axis = scope.slice(0, sepIdx) const value = scope.slice(sepIdx + 1) // `palette` is the deprecated alias for `color` — normalization above // should have converted it, but be defensive. const dataAttr = axis === 'palette' ? 'color' : axis return `[data-${dataAttr}='${value}']` } // ── Scope algebra ───────────────────────────────────────────────────────── /** * Normalize a TokenScope into a set of axis constraints. Each axis can * appear at most once. `root` is the empty set; `host` is `{host:true}`; * leaves add their axis; composites merge multiple axes. */ interface ScopeSet { host: boolean color?: string variant?: string state?: string size?: string event?: string } function toScopeSet(scope: TokenScope): ScopeSet { const set: ScopeSet = { host: false } const atoms: LeafScope[] = [] if (scope === 'root') return set if (scope === 'host') { set.host = true return set } if (typeof scope === 'string') { atoms.push(scope as LeafScope) } else { atoms.push(...scope) } set.host = true for (const atom of atoms) { const sepIdx = atom.indexOf(':') const axis = atom.slice(0, sepIdx) as keyof Omit const value = atom.slice(sepIdx + 1) if (set[axis] !== undefined && set[axis] !== value) { throw new Error( `composite scope cannot bind axis '${axis}' to two values: ` + `'${set[axis]}' and '${value}'` ) } set[axis] = value } return set } /** * True iff the consumer's scope is at least as specific as the dep's — * meaning: every constraint the dep places on an element must also be * placed by the consumer (or be trivially satisfied because dep is at * root/host). * * consumer scopeCovers dep ⇔ consumer's elements ⊆ dep's elements */ function scopeCovers(consumer: TokenScope, dep: TokenScope): boolean { const consumerSet = toScopeSet(consumer) const depSet = toScopeSet(dep) if (depSet.host && !consumerSet.host) return false for (const axis of ['color', 'variant', 'state', 'size', 'event'] as const) { const depValue = depSet[axis] if (depValue === undefined) continue if (consumerSet[axis] !== depValue) return false } return true } function describeScope(scope: TokenScope): string { if (typeof scope === 'string') return scope return `[${[...scope].sort().join(', ')}]` } /** * For every declaration of every token, check that all its dependencies * are reachable from its scope. */ function collectScopeViolations( component: string, normalized: NormalizedDeclarations ): string[] { const issues: string[] = [] for (const [name, decls] of normalized) { for (const decl of decls) { for (const depName of decl.depends) { const depDecls = normalized.get(depName) if (!depDecls) { issues.push( `${component}.${name} (scope ${describeScope(decl.scope)}): depends on ` + `'${depName}' which is not declared in the recipe` ) continue } // At least ONE declaration of the dep must cover the consumer's scope. // (The dep can be split across multiple scopes — we only need ONE that // applies wherever the consumer applies.) const covered = depDecls.some((depDecl) => scopeCovers(decl.scope, depDecl.scope)) if (!covered) { const depScopes = depDecls.map((d) => describeScope(d.scope)).join(', ') issues.push( `${component}.${name} (scope ${describeScope(decl.scope)}): ` + `dependency '${depName}' is only declared at scopes [${depScopes}], ` + `none of which is reachable from the consumer's scope. ` + `The var() reference would resolve in a context where the dep is missing.` ) } } } } return issues } /** * A token with multiple atomic declarations on different leaf axes whose * elements could co-apply must declare a composite override for the * intersection — otherwise cascade winner is source-order-dependent * (silent correctness bug). * * Example: `bg` declared at `color:affirm` AND `state:on`. An element * with both `data-color='affirm' data-state='on'` matches both blocks; * which wins depends on which `[data-c][data-color='affirm']` vs * `[data-c][data-state='on']` appears later in the stylesheet. The * generator demands a composite `[color:affirm, state:on]` decl so * intent is explicit. */ function collectCrossAxisCollisions( component: string, normalized: NormalizedDeclarations ): string[] { const issues: string[] = [] for (const [name, decls] of normalized) { const leafSets = decls .map((d) => toScopeSet(d.scope)) .filter((s) => s.host && hasAnyLeafAxis(s)) for (let i = 0; i < leafSets.length; i++) { for (let j = i + 1; j < leafSets.length; j++) { const a = leafSets[i] const b = leafSets[j] if (areIncomparableLeaves(a, b)) { // Need a composite that covers the union of a's and b's axes. const needed = unionAxes(a, b) const hasComposite = leafSets.some((set) => coversAllAxes(set, needed)) if (!hasComposite) { issues.push( `${component}.${name}: declarations at scopes ${describeScopeSet(a)} and ` + `${describeScopeSet(b)} can both apply to the same element. ` + `Add an explicit composite declaration ${describeAxisSet(needed)} ` + `to disambiguate cascade order.` ) } } } } } return issues } function hasAnyLeafAxis(set: ScopeSet): boolean { return Boolean(set.color || set.variant || set.state || set.size || set.event) } function areIncomparableLeaves(a: ScopeSet, b: ScopeSet): boolean { // They're incomparable when they constrain DIFFERENT axes (so an // element with both constraints satisfies both rules — but neither // rule's selector subsumes the other). const axes = ['color', 'variant', 'state', 'size', 'event'] as const let sharedAxes = 0 let exclusiveAxes = 0 for (const axis of axes) { const aHas = a[axis] !== undefined const bHas = b[axis] !== undefined if (aHas && bHas) { if (a[axis] !== b[axis]) return false // contradictory; no overlap sharedAxes++ } else if (aHas !== bHas) { exclusiveAxes++ } } // Incomparable when both rules add at least one constraint the other // doesn't — and exclusiveAxes from both sides exists. The simplest test // is: there's at least one axis in a not in b AND vice versa. const aOnly = axes.some((ax) => a[ax] !== undefined && b[ax] === undefined) const bOnly = axes.some((ax) => b[ax] !== undefined && a[ax] === undefined) return aOnly && bOnly } function unionAxes(a: ScopeSet, b: ScopeSet): ScopeSet { const merged: ScopeSet = { host: true } for (const axis of ['color', 'variant', 'state', 'size', 'event'] as const) { const aVal = a[axis] const bVal = b[axis] if (aVal !== undefined) merged[axis] = aVal else if (bVal !== undefined) merged[axis] = bVal } return merged } function coversAllAxes(candidate: ScopeSet, required: ScopeSet): boolean { for (const axis of ['color', 'variant', 'state', 'size', 'event'] as const) { const reqVal = required[axis] if (reqVal === undefined) continue if (candidate[axis] !== reqVal) return false } return true } function describeScopeSet(set: ScopeSet): string { const atoms: string[] = [] for (const axis of ['color', 'variant', 'state', 'size', 'event'] as const) { if (set[axis] !== undefined) atoms.push(`${axis}:${set[axis]}`) } return atoms.length === 1 ? atoms[0] : `[${atoms.join(', ')}]` } function describeAxisSet(set: ScopeSet): string { const atoms: string[] = [] for (const axis of ['color', 'variant', 'state', 'size', 'event'] as const) { if (set[axis] !== undefined) atoms.push(`${axis}:${set[axis]}`) } return `[${atoms.join(', ')}]` } function appendFontFamilyAliases( declarations: string[], families: TypographyPrimitiveSet['families'] | undefined ): void { if (!families) return if (families.primary) { declarations.push(cssVar('font-sans', 'var(--font-family-primary)')) // `--font-ui` is the alias every recipe token leans on for // "UI typeface" (Field labels, Combobox triggers, Toolbar // buttons, …). Anchor it to the canonical `label` named style // so changing the label's family propagates everywhere; fall // back to the primary family if the foundation doesn't emit // the named style. declarations.push( cssVar('font-ui', 'var(--style-label-font-family, var(--font-family-primary))') ) } if (families.secondary) { declarations.push(cssVar('font-serif', 'var(--font-family-secondary)')) declarations.push(cssVar('font-prose', 'var(--font-family-secondary)')) } if (families.display) { declarations.push(cssVar('font-heading', 'var(--font-family-display)')) declarations.push(cssVar('font-display', 'var(--font-family-display)')) } if (families.mono) { declarations.push(cssVar('font-mono', 'var(--font-family-mono)')) declarations.push(cssVar('font-code', 'var(--font-family-mono)')) } } function appendTypographyAliases( declarations: string[], typography: PrimitiveSet['typography'] ): void { if (!typography) return const textAliasMap = { '1': 'xxs', '2': 'xs', '3': 'sm', '4': 'md', '5': 'lg', '6': 'xl' } as const for (const [alias, size] of Object.entries(textAliasMap)) { if (!typography.sizes[size]) continue declarations.push(cssVar(`text-${alias}-size`, `var(--font-size-${size})`)) declarations.push(cssVar(`text-${alias}-lh`, `var(--font-line-height-${size})`)) declarations.push(cssVar(`text-${alias}-ls`, `var(--font-letter-spacing-${size})`)) } if (typography.sizes.md) declarations.push(cssVar('font-size-base', 'var(--font-size-md)')) if (typography.sizes.xxl) declarations.push(cssVar('font-size-2xl', 'var(--font-size-xxl)')) if (typography.sizes.xxxl) declarations.push(cssVar('font-size-3xl', 'var(--font-size-xxxl)')) if (typography.weights.regular) { declarations.push(cssVar('font-weight-normal', 'var(--font-weight-regular)')) } // `--leading-ui` is the canonical leading every recipe token uses // for short UI text (Field labels, captions, controls). Anchor it // to the `label` named style so a designer changing // `STATIC_TYPOGRAPHY.styles.label.lineHeight` propagates through // `--style-label-line-height` → `--leading-ui` → every recipe. // Fall back to the literal `1.25` when the named style isn't // emitted (so unusual foundation overrides still produce valid CSS). declarations.push(cssVar('leading-ui', 'var(--style-label-line-height, 1.25)')) declarations.push(cssVar('leading-prose', '1.6')) declarations.push(cssVar('leading-text', 'var(--leading-prose)')) declarations.push(cssVar('leading-heading', '1.2')) declarations.push(cssVar('leading-display', '1.05')) declarations.push(cssVar('tracking-badge', '0')) declarations.push(cssVar('tracking-label', '0')) declarations.push(cssVar('tracking-ui', '0')) declarations.push(cssVar('tracking-prose', '0')) declarations.push(cssVar('tracking-heading', '0')) declarations.push(cssVar('tracking-display', '0')) declarations.push(cssVar('tracking-tight', '0')) declarations.push(cssVar('tracking-normal', '0')) declarations.push(cssVar('tracking-wide', '0')) declarations.push(cssVar('tracking-wider', '0')) } function hasFocusColor(options: EidosConfig): boolean { if (options.semantics.color.focus) return true return Object.values(options.themes ?? {}).some((theme) => theme.color?.focus) } function appendThemeColorDeclarations( declarations: string[], color: ReturnType ): void { if (color.surface) { declarations.push(cssVar('color-surface-default', color.surface.default)) declarations.push(cssVar('color-surface-raised', color.surface.raised)) declarations.push(cssVar('color-surface-overlay', color.surface.overlay)) declarations.push(cssVar('color-surface-muted', color.surface.muted)) declarations.push(cssVar('color-overlay', color.surface.backdrop)) } if (color.content) { declarations.push(cssVar('color-content-primary', color.content.primary)) declarations.push(cssVar('color-content-secondary', color.content.secondary)) declarations.push(cssVar('color-content-muted', color.content.muted)) declarations.push(cssVar('color-content-disabled', color.content.disabled)) declarations.push(cssVar('color-content-on-solid', color.content.onSolid)) if (color.content.onSolidContrast) { declarations.push( cssVar('color-content-on-solid-contrast', color.content.onSolidContrast) ) } } if (color.border) { declarations.push(cssVar('color-border-subtle', color.border.subtle)) declarations.push(cssVar('color-border-default', color.border.default)) declarations.push(cssVar('color-border-strong', color.border.strong)) } if (color.focus) { declarations.push(cssVar('focus-ring-color', color.focus.ring)) declarations.push(cssVar('focus-ring-color-error', color.focus.ringError)) } } function appendColorScaleDeclarations( declarations: string[], scaleName: string, scale: ColorScale ): void { for (const step of COLOR_SCALE_STEPS) { const value = scale[step] // Wide-gamut output (RFC §7, strategy A): the hex stays as the universal // fallback, then an `oklch()` sibling overrides it where supported — so the // color uses the display's gamut (more saturated on P3) automatically, with // NO `@media`, and renders identically in sRGB. Only opaque, parseable steps // get the sibling; empty / non-color values keep just the fallback line. declarations.push(cssVar(`scale-${scaleName}-${step}`, value)) const oklch = safeParseColor(value) if (oklch) declarations.push(cssVar(`scale-${scaleName}-${step}`, oklchToCss(oklch))) } } function parseHexColor(value: string): { r: number; g: number; b: number } | null { const text = value.trim() if (!text.startsWith('#')) return null const hex = text.slice(1) let r: number, g: number, b: number if (hex.length === 3) { r = parseInt(hex[0] + hex[0], 16) g = parseInt(hex[1] + hex[1], 16) b = parseInt(hex[2] + hex[2], 16) } else if (hex.length === 6 || hex.length === 8) { r = parseInt(hex.slice(0, 2), 16) g = parseInt(hex.slice(2, 4), 16) b = parseInt(hex.slice(4, 6), 16) } else { return null } if ([r, g, b].some(Number.isNaN)) return null return { r, g, b } } /** sRGB relative luminance (0..1) — enough to pick the alpha background polarity. */ function srgbLuminance({ r, g, b }: { r: number; g: number; b: number }): number { return (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255 } /** * Compositing inverse: the translucent color that, painted over a solid * background `bg` (255 = white, 0 = black), reproduces the opaque `solidHex`. * Solves `solid = a·c + (1-a)·bg` for the minimum alpha `a` (max saturation), * per channel. Returns `null` when the solid isn't a plain hex (caller keeps * the synthetic ramp for non-hex values). */ function alphaColorOverBackground(solidHex: string, bg: 0 | 255): string | null { const t = parseHexColor(solidHex) if (!t) return null let a = 0 for (const c of [t.r, t.g, t.b]) { if (c === bg) continue const channelAlpha = bg === 255 ? (255 - c) / 255 : c / 255 if (channelAlpha > a) a = channelAlpha } if (a <= 0) return `rgb(${t.r} ${t.g} ${t.b} / 0)` const resolve = (c: number) => Math.min(255, Math.max(0, Math.round((c - bg * (1 - a)) / a))) const alpha = Math.round(a * 10000) / 10000 return `rgb(${resolve(t.r)} ${resolve(t.g)} ${resolve(t.b)} / ${alpha})` } /** * Alpha (translucent) variants of a color scale. * * If a theme provides explicit `alphaScales` for this scale they win (verbatim). * Otherwise each `aN` is GENERATED as the compositing inverse of the solid step * `N` over the scale's background polarity (white for light scales, black for * dark — decided by step 1's luminance): `aN` painted over that background * reproduces solid `N`. This keeps the alpha ramp consistent-by-construction * with the solid scale (no drift, Radix-style fidelity) for every theme, not * just the base. Non-hex solids (e.g. `var()`) fall back to the legacy * step-9-at-opacity `color-mix` ramp. (audit P1-1) */ function appendColorAlphaScaleDeclarations( declarations: string[], scaleName: string, solidScale: ColorScale | undefined, alphaScale: ColorAlphaScale | undefined ): void { const step1 = solidScale?.['1'] const parsedStep1 = step1 ? parseHexColor(step1) : null const bg: 0 | 255 = parsedStep1 && srgbLuminance(parsedStep1) < 0.5 ? 0 : 255 for (const step of COLOR_ALPHA_STEPS) { const authored = alphaScale?.[step] const solid = solidScale?.[step] const computed = authored === undefined && solid ? alphaColorOverBackground(solid, bg) : null const value = authored ?? computed ?? `color-mix(in srgb, var(--scale-${scaleName}-9) ${ DEFAULT_COLOR_ALPHA_PERCENTAGES[step] }, transparent)` declarations.push(cssVar(`scale-${scaleName}-a${step}`, value)) } } function appendRecordDeclarations( declarations: string[], prefix: string, record: Record | undefined ): void { if (!record) return for (const [name, value] of Object.entries(record)) { declarations.push(cssVar(`${prefix}-${name}`, value)) } } /** * Like {@link appendRecordDeclarations} but multiplies each value by the * active density scalar (`var(--{scaleVar})`) via `calc()`, so the density * axis (compact / comfortable / spacious) actually rescales the token. * * The density block (`renderDensityBlocks`) redeclares `--density-*-scale` * on `[data-density='…']` (the same element where these tokens are * authored — `:root` / ``), so the `var()` here resolves against the * active density. Zero values are emitted verbatim (`calc(0 * x)` is * pointless and `0px` must remain a valid length). At the `comfortable` * default the scalar is `1`, so the emitted value is numerically identical * to the raw token — no change for consumers that never switch density. */ // Emit a px metric scaled by the global `--scaling` zoom, and optionally by a // per-axis density scalar (space / control-height). At scaling=100 + comfortable // density both factors are 1, so output equals the raw value (no regression). function appendScaledMetricDeclarations( declarations: string[], prefix: string, record: Record | undefined, densityScaleVar?: string ): void { if (!record) return for (const [name, value] of Object.entries(record)) { const raw = String(value).trim() const isZero = parseFloat(raw) === 0 const density = densityScaleVar ? ` * var(--${densityScaleVar})` : '' declarations.push( cssVar(`${prefix}-${name}`, isZero ? raw : `calc(${raw}${density} * var(--scaling))`) ) } } function mergeThemeColor( primitiveScales: ColorScales | undefined, semanticColor: EidosConfig['semantics']['color'], themeColor: ThemeColorSet | undefined ) { return { scales: { ...primitiveScales, ...themeColor?.scales }, alphaScales: { ...themeColor?.alphaScales }, roles: { ...semanticColor.roles, ...themeColor?.roles } as ColorRoleMap, surface: semanticColor.surface || themeColor?.surface ? ({ ...semanticColor.surface, ...themeColor?.surface } as SurfaceColorRoles) : undefined, content: semanticColor.content || themeColor?.content ? ({ ...semanticColor.content, ...themeColor?.content } as ContentColorRoles) : undefined, border: semanticColor.border || themeColor?.border ? ({ ...semanticColor.border, ...themeColor?.border } as BorderColorRoles) : undefined, focus: semanticColor.focus || themeColor?.focus ? ({ ...semanticColor.focus, ...themeColor?.focus } as FocusColorRoles) : undefined } } function getThemeSelector(themeId: string): string { return `[data-theme='${themeId}']` } function getColorRoleScaleName(definition: string | ColorRoleDefinition): string { if (typeof definition === 'string') return definition return definition.scale } function cssVar(name: string, value: string | number): string { return `--${name}: ${value};` } function emptyCssVar(name: string): string { return `--${name}: ;` } function renderBlock(selector: string, declarations: string[]): string { return `${selector} {\n${declarations.map((declaration) => `\t${declaration}`).join('\n')}\n}` } function formatFontFamily(family: FontFamily): string { return [family.family, ...(family.fallbacks ?? [])].map(formatFontName).join(', ') } function formatFontName(name: string): string { if (GENERIC_FONT_FAMILIES.has(name)) return name if (name.startsWith('"') || name.startsWith("'")) return name if (!/\s/.test(name)) return name return `'${name.replace(/'/g, "\\'")}'` } function getContractVariableNames( contract: EidosCssContract | readonly EidosCssContractToken[] ): Set { const tokens = isEidosCssContract(contract) ? [...contract.static, ...contract.theme] : contract return new Set(tokens.map((token) => token.name)) } function isEidosCssContract( contract: EidosCssContract | readonly EidosCssContractToken[] ): contract is EidosCssContract { return !Array.isArray(contract) } function normalizeCssVariableName(name: string): string | undefined { const normalized = name.startsWith('--') ? name.slice(2) : name if (/^[a-zA-Z0-9_-]+$/.test(normalized)) return normalized return undefined } function assertCssVariableValue(name: string, value: EidosCssVariableValue): string | number { if (typeof value === 'number') return value if (!/[;{}]/.test(value)) return value throw new EidosCssVariableError([name]) }