You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
svelte-kit-vice/src/uix/eidos/lib/render-css.ts

2586 lines
101 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

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 DepthPrimitiveSet,
type ShapePrimitiveSet,
type FontFace,
type FontFallback,
type FontFamily,
type FocusColorRoles,
type LayoutPrimitiveSet,
type PrimitiveSet,
type RecipeComposition,
type RecipeContainerQueries,
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 { BUILTIN_LOOP_PRESETS } from './motion/presets/css'
import { STATIC_SCALING, STATIC_Z_INDEX_OVERLAY } from './primitives/static'
import type {
CssStatePreset,
CssPhase,
EventSignature,
KeyframeName,
KeyframeStops,
MotionConfig
} from '$motion'
import { apcaLc, oklchToCss, oklchToGammaRgb, safeParseColor, wcagContrastRatio } from '$color'
import { resolveTypeSize } from './type-scale'
import { EidosCssVariableError, EidosThemeNotFoundError } from '../errors'
export interface RenderThemeCssOptions {
readonly selector?: string
/**
* Which raw `--scale-*` donor tokens to emit. `'roleReferenced'`
* (default) emits only the scales the theme's roles reference — the slim
* foundation. `'all'` emits the full donor palette (used by the opt-in
* `palette.css`). Roles reference scales via `var()`, so the
* role-referenced subset is sufficient for the foundation; runtime theming
* builds from JS scale data and writes resolved values, so it never depends
* on the unemitted scales.
*/
readonly scales?: 'roleReferenced' | 'all'
}
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<ColorRoleSlot, string> = {
track: '1',
bg2: '2',
element: '3',
hover: '4',
active: '5',
separator: '6',
border: '7',
solid: '9',
solidHover: '10',
text: '11',
textStrong: '12',
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<ColorAlphaStep, string> = {
'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[] = []
// Responsive breakpoints — the configured set (threaded from the runtime
// service via `ActiveEidos`, which reads `dom.breakpoints.current`) merged over
// the fallback defaults. Emitted as `--breakpoint-*` tokens AND used for the
// responsive `@media` blocks below, so both track ONE source instead of a
// frozen const drifting from the developer-configured breakpoints.
const breakpoints = { ...STYLE_BREAKPOINT_WIDTHS, ...options.breakpoints }
for (const bp of STYLE_BREAKPOINT_ORDER) {
declarations.push(cssVar(`breakpoint-${bp}`, `${breakpoints[bp]}px`))
}
// 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'
)
appendRadiusDeclarations(declarations, 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-inner-width', primitives.focusRing.innerWidth))
// Two-ring focus model: inner `inset` ring (width from token, 0 by
// default → invisible) + the outer ring. Every input component shares
// this so the focus affordance is uniform and theme-parameterised.
declarations.push(
cssVar(
'focus-ring',
'inset 0 0 0 var(--focus-ring-inner-width) var(--focus-ring-color), ' +
'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',
'inset 0 0 0 var(--focus-ring-inner-width) var(--focus-ring-color-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.press) {
declarations.push(cssVar('press-scale', primitives.press.scale))
declarations.push(cssVar('press-duration', primitives.press.duration))
}
// Active-segment highlight — single foundation source for the date/time/
// color field active segment. `-bg` is the full background colour (bake in
// whatever transparency you want, e.g. via `color-mix`) and `-text` is the
// segment text colour, so a theme retunes colour + opacity + text from one
// place. Defaults derive from the primary role → theme-driven.
declarations.push(
cssVar(
'field-segment-active-bg',
'color-mix(in srgb, var(--color-primary-border) 40%, transparent)'
)
)
declarations.push(cssVar('field-segment-active-text', 'var(--color-primary-text)'))
// Hover affordance for segments — the faint "you can edit here" tint, shared
// by every date / time / color field segment (distinct from the active fill).
declarations.push(
cssVar(
'field-segment-hover-bg',
'color-mix(in srgb, var(--color-content-primary) 6%, transparent)'
)
)
// Control-trigger hover — uniform neutral tint for the affordance that opens a
// field overlay (calendar / clock / swatch) or toggles password visibility. NOT
// the field's accent: every field's control reads the same on hover.
declarations.push(
cssVar(
'field-control-trigger-hover-bg',
'color-mix(in srgb, var(--color-content-primary) 8%, transparent)'
)
)
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)
}
if (primitives.blur) {
// Blur radii scale with --scaling (zoom-consistent), like icon-size.
appendScaledMetricDeclarations(declarations, 'blur', primitives.blur)
}
if (primitives.gradientAngle) {
appendRecordDeclarations(declarations, 'gradient-angle', primitives.gradientAngle)
}
if (primitives.gradients) {
// Named gradients compose role/surface tokens → mode-aware via the references.
appendRecordDeclarations(declarations, 'gradient', primitives.gradients)
}
appendRecordDeclarations(declarations, 'opacity', primitives.opacity)
appendRecordDeclarations(declarations, 'z-index', primitives.zIndex)
// Overlay micro-band — a fixed structural scale, not a theme knob (reordering
// the overlay stacking is a system invariant). See STATIC_Z_INDEX_OVERLAY.
appendRecordDeclarations(declarations, 'z-index-overlay', STATIC_Z_INDEX_OVERLAY)
if (primitives.typography) {
appendTypographyDeclarations(declarations, primitives.typography)
// Variable fonts: enable optical sizing globally when any family declares an `opsz`
// axis, so the font's optical-size axis tracks the rendered font-size. (RFC §5)
if (Object.values(primitives.typography.families).some((f) => f.axes?.opsz)) {
declarations.push('font-optical-sizing: auto;')
}
}
if (primitives.size) {
appendSizeDeclarations(declarations, primitives.size)
}
if (primitives.depth) {
appendDepthDeclarations(declarations, primitives.depth)
}
if (primitives.shape) {
appendShapeDeclarations(declarations, primitives.shape)
}
appendTransitionAliasDeclarations(declarations, options)
const scopedRecipeBlocks = appendRecipeDeclarations(declarations, options.recipes, breakpoints)
const depthBlocks = primitives.depth ? renderDepthBlocks(primitives.depth) : []
const shapeBlocks = primitives.shape ? renderShapeBlocks(primitives.shape) : []
const blocks: string[] = []
// @font-face first (config-driven loading); the family-stack tokens reference them.
if (primitives.typography) {
const fontFaceCss = renderFontFaceBlocks(primitives.typography)
if (fontFaceCss) blocks.push(fontFaceCss)
}
blocks.push(
renderBlock(':root', declarations),
...scopedRecipeBlocks,
...depthBlocks,
...shapeBlocks
)
// 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 = breakpoints[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())
blocks.push(renderPrefersContrastBlock())
// Floating gap — the trigger→panel separation, canonized. floating-ui's numeric
// `sideOffset` can't take a token, so the shared positioner (soma's
// FloatingContent) reads THIS `<length>` @property off the content and feeds it as
// the offset — token-driven (follows density × scaling) and arrow-safe (the arrow
// rides with the offset). Two archetypes: menus get a small gap, panels a larger one.
// A content opts in by stamping `data-floating-gap`; otherwise the positioner falls
// back to its numeric `sideOffset` (legacy, per-component).
blocks.push(`@property --floating-gap {\n\tsyntax: '<length>';\n\tinherits: false;\n\tinitial-value: 0px;\n}`)
blocks.push(
renderBlock(':root', [
cssVar('floating-gap-menu', 'var(--space-1)'),
cssVar('floating-gap-panel', 'var(--space-1-5)')
])
)
blocks.push(
renderBlock("[data-floating-gap='menu']", [cssVar('floating-gap', 'var(--floating-gap-menu)')])
)
blocks.push(
renderBlock("[data-floating-gap='panel']", [cssVar('floating-gap', 'var(--floating-gap-panel)')])
)
// Native CSS Anchor Positioning — the primary path, behind @supports. Twins
// the JS `selectPositioningStrategy` dispatcher; see renderFloatingNativeBlock.
blocks.push(renderFloatingNativeBlock())
// Container-query opt-in: marking an element `data-container` makes it a query
// container, so its descendants' recipe `@container` overrides resolve against
// its inline size. (The `@container` blocks themselves are emitted per-recipe.)
blocks.push(renderBlock('[data-container]', ['container-type: inline-size;']))
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')
}
/**
* Native CSS Anchor Positioning — the PRIMARY path of the in-house `$ethereal`
* positioner, behind `@supports`. When a baseline-2026 engine supports anchor
* positioning AND the overlay has no JS-forcing feature (shift / sticky / arrow /
* virtual anchor / explicit boundary — see `selectPositioningStrategy`), the soma
* wrapper stamps `data-floating-native`, links the wrapper to its trigger via an
* inline `anchor-name`/`position-anchor` pair, and stops running the JS positioner.
* The browser then places it declaratively from these rules. Everything else (and
* every non-supporting engine) keeps using the JS engine.
*
* The `@supports` probe is the byte-twin of `supportsCssAnchor` — both gate on
* `anchor-name` + `position-area`, so the CSS rules and the JS dispatcher light up
* on exactly the same engines.
*
* `position-area` is derived from the morfo `data-side` / `data-align` contract the
* recipes already key on (validated against the spec: logical axes only — mixing
* physical `bottom` with logical `span-inline-end` is invalid). `flip` collision is
* `position-try-fallbacks` (opt-in via `data-floating-flip` so it matches the JS
* path's `avoidCollisions`). The gap rides on a per-side `margin` fed by
* `--floating-native-offset` (the wrapper writes the resolved offset).
*/
function renderFloatingNativeBlock(): string {
const sideArea: Record<string, string> = {
top: 'block-start',
bottom: 'block-end',
left: 'inline-start',
right: 'inline-end'
}
const gapMargin: Record<string, string> = {
top: 'margin-bottom',
bottom: 'margin-top',
left: 'margin-right',
right: 'margin-left'
}
const sides = ['top', 'bottom', 'left', 'right'] as const
const vertical = new Set(['top', 'bottom'])
const alignSpan = (side: string, align: string): string => {
if (align === 'center') return ''
if (vertical.has(side)) return align === 'start' ? ' span-inline-end' : ' span-inline-start'
return align === 'start' ? ' span-block-end' : ' span-block-start'
}
const w = "[data-floating-wrapper][data-floating-native]"
const lines: string[] = []
lines.push(`\t${w}[data-floating-flip] { position-try-fallbacks: flip-block, flip-inline; }`)
for (const side of sides) {
lines.push(`\t${w}[data-side='${side}'] { ${gapMargin[side]}: var(--floating-native-offset, 0px); }`)
}
for (const side of sides) {
for (const align of ['center', 'start', 'end']) {
lines.push(
`\t${w}[data-side='${side}'][data-align='${align}'] { position-area: ${sideArea[side]}${alignSpan(side, align)}; }`
)
}
}
return `@supports (anchor-name: --x) and (position-area: top) {\n${lines.join('\n')}\n}`
}
/**
* Accessibility — `prefers-contrast: more` (macOS "Increase contrast", Windows, etc.).
* Strengthen the neutral CHROME for users who ask for more contrast: darker/stronger
* borders + higher-contrast de-emphasized text. Solid fills + primary text are already
* high-contrast, so they stay. Strictly additive (only active under the media query)
* and strictly STRONGER (never weaker), so it can't regress the default look.
*
* `:root:root` (specificity 0,2,0) so these win over the theme's `:root` (0,1,0)
* regardless of stylesheet order. Values reference the theme's `--primitive-neutral-*`,
* which resolve from the cascade; if a theme omits them the declaration is simply
* ignored (graceful — keeps the theme's own chrome).
*/
function renderPrefersContrastBlock(): string {
return [
'@media (prefers-contrast: more) {',
'\t:root:root {',
'\t\t--color-border-subtle: var(--primitive-neutral-7);',
'\t\t--color-border-default: var(--primitive-neutral-8);',
'\t\t--color-border-strong: var(--primitive-neutral-9);',
'\t\t--color-content-secondary: var(--primitive-neutral-12);',
'\t\t--color-content-muted: var(--primitive-neutral-11);',
'\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 allScaleNames = new Set([...Object.keys(color.scales), ...Object.keys(color.alphaScales)])
// 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)
// Foundation slimming: emit only the donor scales the roles actually
// reference (the role loop below binds `--primitive-{role}-*` to
// `var(--scale-{name}-*)`). The full 33-scale palette ships opt-in via
// `renderColorPaletteCss` → `generated/palette.css`. Runtime theming writes
// RESOLVED values from JS scale data, so the unemitted scales are never
// needed at the foundation.
const scaleNames =
renderOptions.scales === 'all'
? allScaleNames
: roleReferencedScaleNames(completedRoles, allScaleNames)
for (const scaleName of scaleNames) {
const scale = color.scales[scaleName]
if (scale) appendColorScaleDeclarations(declarations, scaleName, scale)
appendColorAlphaScaleDeclarations(declarations, scaleName, scale, color.alphaScales[scaleName])
}
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)
}
/** The donor scales a theme's roles reference (incl. canonically-derived
* intents). The foundation emits only these; the full palette ships opt-in. */
function roleReferencedScaleNames(
completedRoles: ReturnType<typeof completeColorRoleMap>,
available: ReadonlySet<string>
): Set<string> {
const used = new Set<string>()
for (const role of COLOR_ROLES) {
const definition = completedRoles[role]
if (definition === undefined) continue
const name = getColorRoleScaleName(definition)
if (available.has(name)) used.add(name)
}
return used
}
/**
* Render ONLY the full `--scale-*` donor palette (all scales) for a theme,
* wrapped in the theme selector. Opt-in companion to the slim foundation:
* `renderThemeCss` emits only role-referenced scales, while apps/tools that
* reference raw `--scale-{name}-{step}` tokens import the palette generated
* from this (see `generated/palette.css`). The framework itself never needs
* it — components consume `--color-{role}-*`; runtime theming uses JS scale
* data and writes resolved values.
*/
export function renderColorPaletteCss(
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])
}
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)')
)
// Inset-ring width — default for crisp inner rings drawn as `box-shadow`
// (`inset 0 0 0 var(--ring-inset-width) var(--ring-inset-color)`). Defaults to the
// `medium` (2px) border step; a theme retunes all inset rings here, a component overrides
// per-use. The full expression lives at the use site (CSS resolves nested var() in
// the declaring scope, so a single pre-baked `--ring-inset` shorthand can't pick up
// per-element color/width). Distinct from the soft `--shadow-inset-*` tier.
declarations.push(cssVar('ring-inset-width', 'var(--border-width-medium)'))
}
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<string, string | number>,
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: '<integer>';\n\tinherits: false;\n\tinitial-value: 0;\n}"
)
blocks.push(
"@property --motion-stagger-index-rev {\n\tsyntax: '<integer>';\n\tinherits: false;\n\tinitial-value: 0;\n}"
)
// Structural stagger index: a container marks itself `[data-stagger]` and its direct
// children get `--motion-stagger-index` from `:nth-child` (forward, for enter) and
// `--motion-stagger-index-rev` from `:nth-last-child` (reverse, for exit — last leaves
// first) — NO JS writer (the deleted DomCascade wrote it from soma, the root violation).
// Every css preset's enter/exit rule consumes index × `--motion-stagger-each` (default
// 0 → parallel, N → cascade). Capped at 24; past the cap items share the last index.
for (let n = 1; n <= 24; n++) {
blocks.push(`[data-stagger] > *:nth-child(${n}) {\n\t--motion-stagger-index: ${n - 1};\n}`)
blocks.push(`[data-stagger] > *:nth-last-child(${n}) {\n\t--motion-stagger-index-rev: ${n - 1};\n}`)
}
// Debug affordance — a dev sets `[data-debug-stagger]` on a stagger container to
// materialize each child's index as a corner badge (the CSS analog of
// `UIX_DEBUG_MOTION`, RFC §D.13.3). Opt-in: nothing renders without the attr, so
// it ships at zero cost. The counter mirrors `:nth-child - 1` EXACTLY (reset to
// -1, +1 per child, counting ALL siblings) = the child's `--motion-stagger-index`.
// Multiply by `--motion-stagger-each` for the delay; `animation-delay`/`-name`
// stay inspectable in Computed for the rest. Colors are debug literals (not
// themed — this is a tool, never production chrome).
blocks.push('[data-debug-stagger] {\n\tcounter-reset: motion-stagger -1;\n}')
blocks.push('[data-debug-stagger] > * {\n\tcounter-increment: motion-stagger;\n}')
blocks.push('[data-debug-stagger] > [data-animation-style] {\n\tposition: relative;\n}')
blocks.push(
'[data-debug-stagger] > [data-animation-style]::after {\n' +
'\tcontent: counter(motion-stagger);\n' +
'\tposition: absolute;\n' +
'\tinset-block-start: 0;\n' +
'\tinset-inline-start: 0;\n' +
'\tz-index: 2147483647;\n' +
'\tpadding: 0 4px;\n' +
'\tfont: 600 10px / 1.5 ui-monospace, SFMono-Regular, monospace;\n' +
'\tcolor: #fff;\n' +
'\tbackground: #d6409f;\n' +
'\tborder-end-end-radius: 4px;\n' +
'\tpointer-events: none;\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))
}
// Momento --content: LOOPS (infinite). Un-gated `[data-animation-style]` rules so
// the element animates continuously while present (no data-state). Themeable per
// loop via `--motion-loop-{name}`; reduced motion stops them — the element rests
// in its static frame.
//
// The per-loop durations are canonical `:root` tokens (declared here, like the
// `--duration-*` scale) so they're discoverable + theme-overridable; the rules
// below consume them (the literal fallback stays as a defensive default).
blocks.push(
renderBlock(
':root',
Object.entries(BUILTIN_LOOP_PRESETS).map(
([name, loop]) => `--motion-loop-${name}: ${loop.duration};`
)
)
)
for (const [name, loop] of Object.entries(BUILTIN_LOOP_PRESETS)) {
blocks.push(
renderBlock(`[data-animation-style='${name}']`, [
`animation: ${loop.keyframes} var(--motion-loop-${name}, ${loop.duration}) ${loop.timing} infinite;`
])
)
const off = renderBlock(`[data-animation-style='${name}']`, ['animation: none !important;'])
blocks.push(
renderBlock(`[data-motion='reduce'] [data-animation-style='${name}']`, [
'animation: none !important;'
])
)
blocks.push(`@media (prefers-reduced-motion: reduce) {\n${indentBlock(off)}\n}`)
}
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}`
}
// Open-state ALIASES: soma machines with a richer open vocabulary still drive
// the state presets. Tooltip opens into `delayed-open` (the hover-delay path) —
// an alias of `open`; `instant-open` is deliberately NOT an alias (its
// semantics ARE "appear with no entrance"). Exit stays the single `closed`.
const PRESET_STATE_ALIASES: Readonly<Record<string, readonly string[]>> = {
open: ['open', 'delayed-open'],
closed: ['closed']
}
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
const stateValues = PRESET_STATE_ALIASES[state] ?? [state]
// Two selector forms share the SAME declarations:
// · self-driven — a lone surface carries its own `data-state` (Content
// panel, dialog, a `<Cascade.Item>`).
// · container-driven — the EVENT-DRIVEN cascade: direct children of a
// `[data-stagger]` container inherit the container's `data-state`, indexed
// FROM STRUCTURE (`:nth-child`). No per-item `data-state` (a `menuitem` isn't
// open/closed); the container's state — which soma writes via the morfo
// `commits` — drives the children. ENTER cascades in with
// `--motion-stagger-index` (first item first); EXIT cascades OUT in reverse
// with `--motion-stagger-index-rev` (last leaves first) — `phaseDeclarations`
// picks the right index per phase.
//
// EXIT is the pragmatic CSS bridge (§D.13.2): the container must be RETAINED
// long enough for the children to finish (give it an exit `duration ≥` the
// stagger window). Without retention the exit is cut off on unmount — the
// correct "parent waits for children" needs JS lifecycle (the retired
// `PresenceGroup`, still deferred); this bridge covers BOUNDED lists.
const selectors = stateValues.map((s) => `[data-animation-style='${name}'][data-state='${s}']`)
selectors.push(`[data-stagger][data-state='${state}'] > [data-animation-style='${name}']`)
if (phase === 'enter') {
// · state-domain — a component with its OWN data-state machine (Card
// selected/idle, Switch on/off) keeps its semantic `data-state` and drives
// motion via the dedicated `data-motion-state='open'` when it ENTERS its
// active state. ENTER-ONLY (this branch): the enter animation settles to the
// natural style (no holding fill), and the inactive state has no rule — so an
// idle element never animates on mount. Pair with an EMPHASIS preset
// (`select-pop`); a presence preset would flash the still-visible element.
selectors.push(`[data-animation-style='${name}'][data-motion-state='${state}']`)
}
rules.push(
renderBlock(
selectors.join(',\n'),
phaseDeclarations(cssPhase, cssPhase.keyframes, fill, dur, ease, phase)
)
)
for (const [side, keyframes] of Object.entries(cssPhase.bySide ?? {})) {
rules.push(
renderBlock(
stateValues
.map((s) => `[data-animation-style='${name}'][data-side='${side}'][data-state='${s}']`)
.join(',\n'),
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 gets its index FROM STRUCTURE
// (foundation `[data-stagger]` writer). Enter counts UP (--motion-stagger-index);
// exit counts DOWN (--motion-stagger-index-rev) so the last item leaves first. Both
// default 0 → no stagger (a lone element is unaffected); the `backwards` fill holds
// the enter `from` state until each item's turn.
const indexVar = phase === 'enter' ? '--motion-stagger-index' : '--motion-stagger-index-rev'
declarations.push(`animation-delay: calc(var(${indexVar}, 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 stateValues = PRESET_STATE_ALIASES[state] ?? [state]
const selectors = stateValues.map((s) => `[data-animation-style='${name}'][data-state='${s}']`)
// State-domain (enter-only): the dedicated `data-motion-state` path honors reduce too.
if (phase === 'enter') {
selectors.push(`[data-animation-style='${name}'][data-motion-state='${state}']`)
}
// `[data-motion='reduce']` must prefix EACH selector (in a comma-list it would bind
// only to the first), so project the list explicitly.
const projected = selectors.map((s) => `[data-motion='reduce'] ${s}`).join(',\n')
rules.push(renderBlock(projected, inner))
rules.push(
`@media (prefers-reduced-motion: reduce) {\n${indentBlock(renderBlock(selectors.join(',\n'), 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. A fluid size resolves to a
// rem-based `clamp(...)` (Utopia, viewport-fluid); a fixed size passes through.
// `--scaling` composes on top via calc(). 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(${resolveTypeSize(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))
}
// Phase 3 scales — tracking / leading / features / measure. Additive tokens that
// recipes + components consume (var(--tracking-tight), etc.).
for (const [name, value] of Object.entries(typography.tracking ?? {})) {
declarations.push(cssVar(`tracking-${name}`, value))
}
for (const [name, value] of Object.entries(typography.leading ?? {})) {
declarations.push(cssVar(`leading-${name}`, value))
}
for (const [name, value] of Object.entries(typography.features ?? {})) {
declarations.push(cssVar(`font-feature-${name}`, value))
}
for (const [name, value] of Object.entries(typography.measure ?? {})) {
declarations.push(cssVar(`measure-${name}`, value))
}
if (typography.styles) {
appendTypographyStyleDeclarations(declarations, typography)
}
}
// ── Depth (DEPTH_ENGINE_RFC) ─────────────────────────────────────────────────
/** Emit `--depth-{plane}-{cue}` tokens — each plane's cue values composed into `:root`. */
function appendDepthDeclarations(declarations: string[], depth: DepthPrimitiveSet): void {
for (const [plane, cues] of Object.entries(depth.planes)) {
for (const cue of ['surface', 'border', 'shadow', 'halo', 'z', 'blur', 'translucency', 'scrim'] as const) {
const value = cues[cue]
if (value !== undefined) declarations.push(cssVar(`depth-${plane}-${cue}`, value))
}
}
}
/**
* `[data-depth='{plane}']` paints the VISUAL elevation bundle — surface + border + box-shadow
* (drop `shadow` + rim-light `halo`) — referencing the plane tokens (so a theme / `applyDepth`
* can retune them). One attribute → the elevation chrome (Apple-materials / Chakra-`layerStyle`
* model; Decisión 8). The halo survives in dark mode (oklab). A component adopting `data-depth`
* drops its own surface/border/shadow and lets the plane own them; it keeps layout + radius
* (a separate axis — the Radix radius factor, not the plane).
*
* `z-index` is NOT painted here: stacking is a POSITIONING concern owned by whoever positions
* the element (the floating-wrapper for floating overlays), which consumes the `--depth-{plane}-z`
* token directly. Painting z on the surface would force a redundant stacking context on elements
* already stacked by their wrapper. `blur`/`scrim` (glass) stay opt-in via `data-frost`. (Decisión 8)
*/
function renderDepthBlocks(depth: DepthPrimitiveSet): string[] {
const blocks: string[] = []
for (const [plane, cues] of Object.entries(depth.planes)) {
const lines: string[] = []
if (cues.surface !== undefined) {
lines.push(`background: var(--depth-${plane}-surface);`)
// On-surface typography (Decisión 8): a portaled surface does NOT inherit
// the page font, so unstyled body text falls to the browser serif (Times
// New Roman). Anchor the UI font + leading on the surface so every
// elevated/portaled overlay reads correctly regardless of the portal.
// Parts that want a different face (e.g. a serif `--font-heading` title)
// override it on their own element.
lines.push(`font-family: var(--font-ui);`)
lines.push(`line-height: var(--leading-ui);`)
}
if (cues.border !== undefined)
lines.push(`border: var(--border-width) solid var(--depth-${plane}-border);`)
const shadowParts: string[] = []
if (cues.shadow !== undefined) shadowParts.push(`var(--depth-${plane}-shadow)`)
if (cues.halo !== undefined) shadowParts.push(`var(--depth-${plane}-halo)`)
if (shadowParts.length) lines.push(`box-shadow: ${shadowParts.join(', ')};`)
if (lines.length) blocks.push(renderBlock(`[data-depth='${plane}']`, lines))
// Atmosphere (Fase 4) — frost is opt-in via `data-frost` so it never turns an opaque
// overlay translucent by default. Gated on the plane attribute → reliably overrides the
// component's own background (specificity 0,2,0). Needs a surface to dilute + a blur.
if (cues.blur !== undefined) {
const frost: string[] = []
if (cues.surface !== undefined)
frost.push(
// Opacity is a function of elevation (`--depth-{plane}-translucency`), like
// shadow — higher planes more opaque. `80%` fallback when undeclared.
`background-color: color-mix(in srgb, var(--depth-${plane}-surface) var(--depth-${plane}-translucency, 80%), transparent);`
)
frost.push(`backdrop-filter: blur(var(--depth-${plane}-blur));`)
frost.push(`-webkit-backdrop-filter: blur(var(--depth-${plane}-blur));`)
blocks.push(renderBlock(`[data-depth='${plane}'][data-frost]`, frost))
}
}
return blocks
}
// ── Shape (SHAPE_ENGINE_RFC) ─────────────────────────────────────────────────
/** Emit the shape tokens — `--shape-smoothing` (superellipse exponent) + `--shape-nest-gap`. */
function appendShapeDeclarations(declarations: string[], shape: ShapePrimitiveSet): void {
declarations.push(cssVar('shape-smoothing', shape.smoothing))
if (shape.nestGap !== undefined) declarations.push(cssVar('shape-nest-gap', shape.nestGap))
}
/**
* `[data-shape='{family}']` sets `corner-shape` (continuity / perceptual family) — opt-in, so the
* magnitude (`border-radius`) stays universal and the corners degrade to the plain arc where
* `corner-shape` is unsupported. `[data-shape-nest]` adds the concentric harmony: a child's
* radius = the parent's `--shape-outer-radius` minus the nest gap, so nested corners stay
* parallel. Magnitude is never touched on the family rules. (SHAPE_ENGINE_RFC)
*/
function renderShapeBlocks(shape: ShapePrimitiveSet): string[] {
const blocks: string[] = []
// Register --shape-smoothing as a typed number so the eventful corner morph (the press-squeeze
// firma, SHAPE_ENGINE_RFC Fase 3) interpolates smoothly instead of jumping. inherits:true keeps
// the :root default + any theme override cascading to every element.
blocks.push(
`@property --shape-smoothing {\n\tsyntax: '<number>';\n\tinherits: true;\n\tinitial-value: ${shape.smoothing};\n}`
)
for (const [family, value] of Object.entries(shape.families)) {
blocks.push(renderBlock(`[data-shape='${family}']`, [`corner-shape: ${value};`]))
}
// Surface tier — the hybrid shape doctrine's default. SURFACES (overlay panels +
// non-floating cards) carry the continuous (squircle) corner by default — the
// framework's signature; CONTROLS (button/field/toggle) stay arc. At surface-scale
// radii (≥~10px) arc and squircle visibly diverge (premium); at control radii they're
// indistinguishable, so the split costs no coherence. Enumerated, not hooked on
// `[data-archetype='content']` (that archetype is also on tabs/accordion/table content —
// non-surfaces — so a hook would over-apply). `:where()` (specificity 0) lets a
// `[data-shape]` override (the `shape` escape-hatch prop) win. `--shape-surface-default`
// is the theme knob: unset → squircle; set to `round` to revert the whole tier. Degrades
// to the arc where `corner-shape` is unsupported (the `border-radius` magnitude is universal).
const surfaceTierSelectors = [
// Tier A — floating overlay panels (`data-{component}-content`); pickers inherit via Popover.
'[data-dialog-content]',
'[data-drawer-content]',
'[data-popover-content]',
'[data-dropdown-menu-content]',
'[data-context-menu-content]',
'[data-menubar-content]',
'[data-navigation-menu-content]',
'[data-select-content]',
'[data-combobox-content]',
'[data-tooltip-content]',
'[data-link-preview-content]',
'[data-command]',
// Tier B — non-floating surfaces (no shared marker → enumerated).
'[data-card]',
'[data-banner]',
'[data-radio-cards-item]'
]
blocks.push(
renderBlock(`:where(${surfaceTierSelectors.join(', ')})`, [
'corner-shape: var(--shape-surface-default, superellipse(var(--shape-smoothing)));'
])
)
// Concentric nesting (Fase 2): inner radius = outer − gap, clamped ≥ 0. The parent exposes
// its radius via `--shape-outer-radius` (inherited); the gap defaults to the nest token.
if (shape.nestGap !== undefined) {
blocks.push(
renderBlock(`[data-shape-nest]`, [
`border-radius: max(0px, calc(var(--shape-outer-radius, var(--radius-lg)) - var(--shape-nest-gap)));`
])
)
}
return blocks
}
/**
* Fallback breakpoint min-width thresholds (mirrors `$libs/dom/responsive`
* `BREAKPOINTS_DEFAULT`). Used only when `options.breakpoints` is absent
* (build-time / no runtime service). At runtime `ActiveEidos` passes the
* developer-configured `dom.breakpoints.current`, which overrides these — so the
* emitted `--breakpoint-*` tokens + `@media` blocks track the live source.
* `base` is the implicit 0 fallback — not emitted as a media query.
*/
const STYLE_BREAKPOINT_WIDTHS: Record<string, number> = {
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 — `<Heading level=1>` 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<string, string[]> {
const result: Record<string, string[]> = {}
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<Record<string, unknown>>
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<Record<string, unknown>>
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,
breakpoints: Record<string, number>
): 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<Record<string, RecipeComposition>> })
.composition
// `container` is the reserved sibling key for container-query overrides
// (emitted inside `@container`); stripped before normalizing, like `composition`.
const container = (tokens as { container?: RecipeContainerQueries }).container
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))
}
// Phase 6: container queries. Per-breakpoint token overrides emitted
// inside `@container (min-width: <configured-bp>px)`, scoped to the
// component. Uses the SAME configured breakpoints as `@media`.
if (container) {
blocks.push(...emitContainerQueries(component, container, breakpoints))
}
}
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<Record<string, unknown>>
): Readonly<Record<string, RecipeTokenValue>> {
const out: Record<string, RecipeTokenValue> = {}
for (const [key, value] of Object.entries(tokens)) {
if (key === 'composition' || key === 'container') continue
out[key] = value as RecipeTokenValue
}
return out
}
/**
* Emit `@container` blocks for a recipe's container-query overrides.
*
* For each breakpoint with token entries, emits
* `@container (min-width: <configured-bp>px) { [data-{c}] { --{c}-{token}: value } }`.
* The breakpoints are the SAME configured source as `@media` (threaded from
* `EidosConfig.breakpoints`), so a container override tracks the runtime scale.
* CSS forbids `var()` in `@container` conditions, so the px is emitted literally —
* the generator is the only place this can stay synced with the configured set.
*
* The queried element (`[data-{c}]`) responds to its nearest ANCESTOR query
* container — an ancestor opts in via `data-container` (`container-type: inline-size`).
*/
function emitContainerQueries(
component: string,
container: RecipeContainerQueries,
breakpoints: Record<string, number>
): string[] {
const blocks: string[] = []
for (const bp of STYLE_BREAKPOINT_ORDER) {
const tokens = container[bp]
if (!tokens) continue
const lines = Object.entries(tokens).map(([name, value]) =>
cssVar(name.startsWith('_') ? `_${component}-${name.slice(1)}` : `${component}-${name}`, value)
)
if (!lines.length) continue
const inner = renderBlock(`[data-${component}]`, lines)
blocks.push(`@container (min-width: ${breakpoints[bp]}px) {\n${indentBlock(inner)}\n}`)
}
return blocks
}
/**
* 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<Record<string, RecipeComposition>>
): 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<string, { scope: TokenScope; lines: string[] }>()
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<string, NormalizedDeclaration[]>
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<Record<string, RecipeTokenValue>>
): 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<string>
): readonly string[] {
const merged = new Set<string>()
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<ScopeSet, 'host'>
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)'))
}
// Per-role semantic leading/tracking (config-driven) → --leading-{role} /
// --tracking-{role}. Distinct keys from the SCALES (tighter/tight/… · none/tight/…)
// emitted in appendTypographyDeclarations. `--leading-ui` anchors to the label named
// style through its config value (var(--style-label-line-height, …)), so the canonical
// UI leading still follows the foundation. A theme retunes any of these via
// `typography.semanticLeading`.
for (const [name, value] of Object.entries(typography.semanticLeading ?? {})) {
declarations.push(cssVar(`leading-${name}`, value))
}
}
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<typeof mergeThemeColor>
): 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<string, string | number> | 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` / `<html>`), 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<string, string | number> | undefined,
densityScaleVar?: string
): void {
if (!record) return
for (const [name, value] of Object.entries(record)) {
const raw = String(value).trim()
// Only multiply finite, non-zero numeric lengths. Zero stays verbatim (`0px`
// must remain a valid length, `calc(0 * x)` is pointless) and so does any
// non-numeric value (keyword like `auto`, a `var()` or `calc()`) — wrapping
// those in `calc(x * …)` would emit invalid CSS.
const num = parseFloat(raw)
const scalable = Number.isFinite(num) && num !== 0
const density = densityScaleVar ? ` * var(--${densityScaleVar})` : ''
declarations.push(
cssVar(`${prefix}-${name}`, scalable ? `calc(${raw}${density} * var(--scaling))` : raw)
)
}
}
// Radius takes the global `--radius-factor` (Radix-style roundness knob, default 1)
// AND the `--scaling` zoom, multiplicatively — corners stay proportional when a theme
// dials roundness or the user zooms. At factor=1 + scaling=100 both are 1, so output
// equals the raw value (no regression). `none` (0) and `full` (the pill sentinel) are
// emitted verbatim — a factor/zoom must not curve a square corner nor shrink the pill.
// Also emits `--radius-default` (= md), the archetype default for components without a
// radius reason of their own — md is the eidos control center-of-gravity (button /
// field / select-trigger), like Bootstrap / Chakra / Radix-medium (Decisión 2 / §13).
function appendRadiusDeclarations(
declarations: string[],
radius: Record<string, string | number> | undefined
): void {
if (!radius) return
declarations.push(cssVar('radius-factor', '1'))
for (const [name, value] of Object.entries(radius)) {
const raw = String(value).trim()
const num = parseFloat(raw)
const scalable = name !== 'none' && name !== 'full' && Number.isFinite(num) && num !== 0
declarations.push(
cssVar(
`radius-${name}`,
scalable ? `calc(${raw} * var(--radius-factor) * var(--scaling))` : raw
)
)
}
declarations.push(cssVar('radius-default', 'var(--radius-md)'))
}
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 {
const names = [family.family]
// Inject the metric-override fallback right after the real family so it renders
// (matched to the webfont's metrics) until the webfont loads — zero CLS.
if (family.fallback) names.push(`${family.family} Fallback`)
names.push(...(family.fallbacks ?? []))
return names.map(formatFontName).join(', ')
}
/**
* Generate `@font-face` blocks from the theme's font families (config-driven loading,
* next/font model). Deduped by the real font name — a font shared across slots
* (e.g. Lora as both `secondary` and `display`) emits its faces once. Each family may
* also declare a metric-override `fallback` (anti-CLS). (TYPOGRAPHY_ENGINE_RFC §5)
*/
function renderFontFaceBlocks(typography: TypographyPrimitiveSet): string {
const seen = new Set<string>()
const blocks: string[] = []
for (const family of Object.values(typography.families)) {
if (!family.faces || family.faces.length === 0) continue
if (seen.has(family.family)) continue
seen.add(family.family)
for (const face of family.faces) {
blocks.push(renderFontFace(family.family, face, family.display, family.axes))
}
if (family.fallback) blocks.push(renderFallbackFontFace(family.family, family.fallback))
}
return blocks.join('\n\n')
}
function renderFontFace(
family: string,
face: FontFace,
defaultDisplay: string | undefined,
axes?: FontFamily['axes']
): string {
const src = face.sources
.map((s) => `url('${s.url}')${s.format ? ` format('${s.format}')` : ''}`)
.join(', ')
// Variable fonts: when the face omits an explicit weight and the family declares a
// `wght` axis range, emit the range (`font-weight: 100 900`) so a single face spans the
// whole weight axis. (TYPOGRAPHY_ENGINE_RFC §5 — variable fonts)
const weight = face.weight ?? (axes?.wght ? `${axes.wght[0]} ${axes.wght[1]}` : 400)
const lines = [
'@font-face {',
`\tfont-family: '${family}';`,
`\tsrc: ${src};`,
`\tfont-weight: ${weight};`,
`\tfont-style: ${face.style ?? 'normal'};`,
`\tfont-display: ${face.display ?? defaultDisplay ?? 'swap'};`
]
if (face.unicodeRange) lines.push(`\tunicode-range: ${face.unicodeRange};`)
lines.push('}')
return lines.join('\n')
}
function renderFallbackFontFace(family: string, fallback: FontFallback): string {
const lines = [
'@font-face {',
`\tfont-family: '${family} Fallback';`,
`\tsrc: local('${fallback.local}');`
]
if (fallback.sizeAdjust) lines.push(`\tsize-adjust: ${fallback.sizeAdjust};`)
if (fallback.ascentOverride) lines.push(`\tascent-override: ${fallback.ascentOverride};`)
if (fallback.descentOverride) lines.push(`\tdescent-override: ${fallback.descentOverride};`)
if (fallback.lineGapOverride) lines.push(`\tline-gap-override: ${fallback.lineGapOverride};`)
lines.push('}')
return lines.join('\n')
}
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<string> {
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])
}

Powered by TurnKey Linux.