import { COLOR_ALPHA_STEPS, COLOR_SCALE_STEPS, COLOR_ROLES, DENSITY_KEYS, type ColorRoleDefinition, type ColorRoleSlot, type ColorRoleMap, type ColorAlphaStep, type ColorAlphaScale, type ColorScale, type ColorScales, type BorderColorRoles, type BorderPrimitiveSet, type ContentColorRoles, type DensityPrimitiveSet, type EidosCssContract, type EidosCssContractToken, type EidosCssVariableMap, type EidosCssVariableValue, type EidosConfig, type FontFamily, type FocusColorRoles, type LayoutPrimitiveSet, type PrimitiveSet, type RecipeTokenSet, type SizePrimitiveSet, type SurfaceColorRoles, type ThemeColorSet, type TypographyPrimitiveSet } from './config-types' import { createEidosCssContract } from './contract' import { toKebab } from './utils' import { EidosCssVariableError, EidosThemeNotFoundError } from '../errors' export interface RenderThemeCssOptions { readonly selector?: string } export interface RenderContractCssOptions { readonly staticSelector?: string readonly themeSelector?: string } export interface RenderCssVariablesOptions { readonly selector?: string readonly contract?: EidosCssContract | readonly EidosCssContractToken[] readonly strict?: boolean } const DEFAULT_COLOR_ROLE_SLOT_STEPS: Record = { track: '1', element: '3', hover: '4', active: '5', border: '6', solid: '9', solidHover: '10', text: '11', contrast: '12' } const DEFAULT_COLOR_ALPHA_PERCENTAGES: Record = { '1': '3%', '2': '5%', '3': '8%', '4': '12%', '5': '16%', '6': '22%', '7': '30%', '8': '40%', '9': '56%', '10': '68%', '11': '80%', '12': '92%' } const GENERIC_FONT_FAMILIES = new Set([ 'serif', 'sans-serif', 'monospace', 'cursive', 'fantasy', 'system-ui', 'ui-serif', 'ui-sans-serif', 'ui-monospace', 'emoji', 'math', 'fangsong' ]) export function renderStaticCss(options: EidosConfig): string { const { primitives } = options const declarations: string[] = [] appendRecordDeclarations(declarations, 'space', primitives.space) appendRecordDeclarations(declarations, 'control-height', primitives.controlHeight) appendRecordDeclarations(declarations, 'radius', primitives.radius) if (primitives.border) { appendBorderDeclarations(declarations, primitives.border) } if (primitives.focusRing) { declarations.push(cssVar('focus-ring-offset', primitives.focusRing.offset)) declarations.push(cssVar('focus-ring-width', primitives.focusRing.width)) declarations.push( cssVar( 'focus-ring', '0 0 0 var(--focus-ring-offset) var(--color-surface-default), ' + '0 0 0 calc(var(--focus-ring-offset) + var(--focus-ring-width)) ' + 'var(--focus-ring-color)' ) ) declarations.push( cssVar( 'focus-ring-error', '0 0 0 var(--focus-ring-offset) var(--color-surface-default), ' + '0 0 0 calc(var(--focus-ring-offset) + var(--focus-ring-width)) ' + 'var(--focus-ring-color-error)' ) ) } if (primitives.layout) { appendLayoutDeclarations(declarations, primitives.layout) } if (primitives.density) { appendDensityDeclarations(declarations, primitives.density) } 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) { appendRecordDeclarations(declarations, 'icon-size', primitives.icon.size) appendRecordDeclarations(declarations, 'icon-stroke-width', primitives.icon.strokeWidth) } appendRecordDeclarations(declarations, 'opacity', primitives.opacity) appendRecordDeclarations(declarations, 'z-index', primitives.zIndex) if (primitives.typography) { appendTypographyDeclarations(declarations, primitives.typography) } if (primitives.size) { appendSizeDeclarations(declarations, primitives.size) } appendTransitionAliasDeclarations(declarations, options) appendRecipeDeclarations(declarations, options.recipes) const blocks = [renderBlock(':root', declarations)] // Responsive typography style overrides — one media-query block per // breakpoint that has at least one responsive size override. const styleResponsive = collectTypographyStyleResponsive(primitives.typography) for (const bp of STYLE_BREAKPOINT_ORDER) { const lines = styleResponsive[bp] if (!lines || lines.length === 0) continue const width = STYLE_BREAKPOINT_WIDTHS[bp] const rootBlock = renderBlock(':root', lines) blocks.push(`@media (min-width: ${width}px) {\n${indentBlock(rootBlock)}\n}`) } if (primitives.density) blocks.push(renderDensityBlocks()) return blocks.join('\n\n') } function indentBlock(block: string): string { return block .split('\n') .map((line) => (line ? `\t${line}` : line)) .join('\n') } export function renderContractCss( options: EidosConfig, renderOptions: RenderContractCssOptions = {} ): string { const contract = createEidosCssContract(options) const staticDeclarations = contract.static.map((token) => emptyCssVar(token.name)) const themeDeclarations = contract.theme.map((token) => emptyCssVar(token.name)) return [ renderBlock(renderOptions.staticSelector ?? ':root', staticDeclarations), renderBlock(renderOptions.themeSelector ?? '[data-theme]', themeDeclarations) ].join('\n\n') } export function renderCssVariables( variables: EidosCssVariableMap, renderOptions: RenderCssVariablesOptions = {} ): string { const declarations: string[] = [] const strict = renderOptions.strict ?? renderOptions.contract !== undefined const contractNames = renderOptions.contract ? getContractVariableNames(renderOptions.contract) : undefined const unknownNames: string[] = [] for (const [rawName, value] of Object.entries(variables)) { if (value === null || value === undefined) continue const name = normalizeCssVariableName(rawName) if (!name) { unknownNames.push(rawName) continue } if (contractNames && !contractNames.has(name)) { unknownNames.push(`--${name}`) if (strict) continue } declarations.push(cssVar(name, assertCssVariableValue(`--${name}`, value))) } if (unknownNames.length && strict) { throw new EidosCssVariableError(unknownNames) } return declarations.length ? renderBlock(renderOptions.selector ?? ':root', declarations) : '' } export function renderThemeCss( options: EidosConfig, themeId: string, renderOptions: RenderThemeCssOptions = {} ): string { const theme = options.themes?.[themeId] if (!theme) { throw new EidosThemeNotFoundError(themeId) } const color = mergeThemeColor( options.primitives.color?.scales, options.semantics.color, theme.color ) const declarations: string[] = [] const scaleNames = new Set([...Object.keys(color.scales), ...Object.keys(color.alphaScales)]) for (const scaleName of scaleNames) { const scale = color.scales[scaleName] if (scale) appendColorScaleDeclarations(declarations, scaleName, scale) appendColorAlphaScaleDeclarations(declarations, scaleName, color.alphaScales[scaleName]) } for (const role of COLOR_ROLES) { const definition = color.roles[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) } for (const [slot, step] of Object.entries(slots)) { declarations.push( cssVar(`color-${role}-${toKebab(slot)}`, `var(--primitive-${role}-${step})`) ) } } appendThemeColorDeclarations(declarations, color) if (theme.shadow) { appendRecordDeclarations(declarations, 'shadow', theme.shadow) } return renderBlock(renderOptions.selector ?? getThemeSelector(themeId), declarations) } export { EidosCssVariableError } from '../errors' function appendBorderDeclarations(declarations: string[], border: BorderPrimitiveSet): void { appendRecordDeclarations(declarations, 'border-width', border.width) appendRecordDeclarations(declarations, 'border-style', border.style) declarations.push(cssVar('border-width', `var(--border-width-${border.defaultWidth})`)) declarations.push(cssVar('border-style', `var(--border-style-${border.defaultStyle})`)) declarations.push( cssVar('border', 'var(--border-width) var(--border-style) var(--color-border-default)') ) } function appendLayoutDeclarations(declarations: string[], layout: LayoutPrimitiveSet): void { appendRecordDeclarations(declarations, 'container-width', layout.containerWidth) declarations.push(cssVar('container-padding-inline', layout.containerPaddingInline)) appendRecordDeclarations(declarations, 'content-width', layout.contentWidth) appendRecordDeclarations(declarations, 'aspect-ratio', layout.aspectRatio) } function appendDensityDeclarations(declarations: string[], density: DensityPrimitiveSet): void { appendDensityRecordDeclarations(declarations, density.scale, 'scale') appendDensityRecordDeclarations(declarations, density.spaceScale, 'space-scale') appendDensityRecordDeclarations(declarations, density.controlScale, 'control-scale') appendDensityRecordDeclarations(declarations, density.contentScale, 'content-scale') appendActiveDensityDeclarations(declarations, 'comfortable') } function appendDensityRecordDeclarations( declarations: string[], record: Record, suffix: string ): void { for (const [name, value] of Object.entries(record)) { declarations.push(cssVar(`density-${name}-${suffix}`, value)) } } function renderDensityBlocks(): string { return DENSITY_KEYS.filter((densityKey) => densityKey !== 'comfortable') .map((densityKey) => { const declarations: string[] = [] appendActiveDensityDeclarations(declarations, densityKey) return renderBlock(`[data-density='${densityKey}']`, declarations) }) .join('\n\n') } function appendActiveDensityDeclarations(declarations: string[], densityKey: string): void { declarations.push(cssVar('density-scale', `var(--density-${densityKey}-scale)`)) declarations.push(cssVar('density-space-scale', `var(--density-${densityKey}-space-scale)`)) declarations.push(cssVar('density-control-scale', `var(--density-${densityKey}-control-scale)`)) declarations.push(cssVar('density-content-scale', `var(--density-${densityKey}-content-scale)`)) } 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)) { declarations.push(cssVar(`font-size-${name}`, metric.size)) declarations.push(cssVar(`font-line-height-${name}`, metric.lineHeight)) declarations.push(cssVar(`font-letter-spacing-${name}`, metric.letterSpacing)) } for (const [name, weight] of Object.entries(typography.weights)) { declarations.push(cssVar(`font-weight-${name}`, weight)) } if (typography.styles) { appendTypographyStyleDeclarations(declarations, typography) } } /** * Breakpoint min-width thresholds matching `$libs/dom/responsive`. * `base` is the implicit 0 fallback — emitted into `:root`, not as a * media query. */ const STYLE_BREAKPOINT_WIDTHS: Record = { sm: 480, md: 768, lg: 1024, xl: 1280, xxl: 1536 } const STYLE_BREAKPOINT_ORDER = ['sm', 'md', 'lg', 'xl', 'xxl'] as const /** * Emit the named typography styles (hero, h1..h6, body, prose, label, * caption, code, …) as `--style-{name}-{font-family,font-size, * font-weight,line-height,letter-spacing,color}` custom properties. * * Each style entry references primitives the foundation already emits * (`var(--font-family-X)`, `var(--font-size-Y)`, etc.). Components * consume these tokens directly — `` reads * `var(--style-h1-font-size)` and friends. * * Responsive sizes (e.g. `hero.size = { base: 'xxl', md: 'xxxl' }`) * are emitted as base declarations in `:root` plus per-breakpoint * `@media (min-width: …)` overrides appended after the main block. * Non-size fields (family, weight, line-height, letter-spacing, color) * are not responsive in this contract. */ function appendTypographyStyleDeclarations( declarations: string[], typography: TypographyPrimitiveSet ): void { const styles = typography.styles if (!styles) return // Capture responsive size overrides per breakpoint. Same shape as // `declarations` but keyed by the active breakpoint so we can emit // them as media-query blocks after the base. for (const [name, style] of Object.entries(styles)) { if (style.family) { declarations.push( cssVar(`style-${name}-font-family`, `var(--font-family-${style.family})`) ) } const sizeRes = style.size if (sizeRes !== undefined) { const baseSize = pickResponsiveBase(sizeRes) if (baseSize !== undefined) { declarations.push( cssVar(`style-${name}-font-size`, resolveStyleSizeRef(baseSize)) ) declarations.push( cssVar( `style-${name}-line-height`, style.lineHeight ?? resolveStyleLineHeightRef(baseSize) ) ) declarations.push( cssVar( `style-${name}-letter-spacing`, style.letterSpacing ?? resolveStyleLetterSpacingRef(baseSize) ) ) } else if (style.lineHeight) { declarations.push(cssVar(`style-${name}-line-height`, style.lineHeight)) } } else if (style.lineHeight) { declarations.push(cssVar(`style-${name}-line-height`, style.lineHeight)) } if (style.weight !== undefined) { const weightRef = typeof style.weight === 'number' ? String(style.weight) : `var(--font-weight-${style.weight})` declarations.push(cssVar(`style-${name}-font-weight`, weightRef)) } if (style.color) { declarations.push( cssVar(`style-${name}-color`, `var(--color-${style.color.replace(/\./g, '-')})`) ) } } } /** * Collect per-breakpoint style size overrides. Returns a record mapping * breakpoint -> declaration list. Empty buckets are dropped by the * caller before emitting media-query blocks. */ function collectTypographyStyleResponsive( typography: TypographyPrimitiveSet | undefined ): Record { const result: Record = {} if (!typography?.styles) return result for (const [name, style] of Object.entries(typography.styles)) { const sizeRes = style.size if (sizeRes === undefined) continue if (typeof sizeRes !== 'object' || sizeRes === null || isTextMetric(sizeRes)) continue const responsive = sizeRes as Partial> for (const bp of STYLE_BREAKPOINT_ORDER) { const value = responsive[bp] if (value === undefined) continue const list = result[bp] ?? (result[bp] = []) list.push(cssVar(`style-${name}-font-size`, resolveStyleSizeRef(value))) list.push(cssVar(`style-${name}-line-height`, resolveStyleLineHeightRef(value))) list.push(cssVar(`style-${name}-letter-spacing`, resolveStyleLetterSpacingRef(value))) } } return result } function pickResponsiveBase(value: unknown): unknown { if (value === undefined || value === null) return undefined if (typeof value === 'string' || typeof value === 'number') return value if (isTextMetric(value)) return value if (typeof value === 'object') { const obj = value as Partial> if (obj.base !== undefined) return obj.base // Fallback: take the first defined breakpoint in order. for (const bp of STYLE_BREAKPOINT_ORDER) { if (obj[bp] !== undefined) return obj[bp] } } return undefined } function isTextMetric(value: unknown): boolean { return ( typeof value === 'object' && value !== null && 'size' in value && 'lineHeight' in value && 'letterSpacing' in value ) } function resolveStyleSizeRef(value: unknown): string { if (typeof value === 'string') return `var(--font-size-${value})` if (isTextMetric(value)) return (value as { size: string }).size return 'inherit' } function resolveStyleLineHeightRef(value: unknown): string { if (typeof value === 'string') return `var(--font-line-height-${value})` if (isTextMetric(value)) return (value as { lineHeight: string }).lineHeight return 'inherit' } function resolveStyleLetterSpacingRef(value: unknown): string { if (typeof value === 'string') return `var(--font-letter-spacing-${value})` if (isTextMetric(value)) return (value as { letterSpacing: string }).letterSpacing return 'normal' } function appendSizeDeclarations(declarations: string[], sizes: SizePrimitiveSet): void { for (const [name, size] of Object.entries(sizes)) { declarations.push( cssVar(`size-${name}-control-height`, `var(--control-height-${size.controlHeight})`) ) declarations.push(cssVar(`size-${name}-font-size`, `var(--font-size-${size.fontSize})`)) declarations.push( cssVar(`size-${name}-font-line-height`, `var(--font-line-height-${size.fontSize})`) ) declarations.push( cssVar(`size-${name}-font-letter-spacing`, `var(--font-letter-spacing-${size.fontSize})`) ) declarations.push(cssVar(`size-${name}-icon-size`, `var(--icon-size-${size.iconSize})`)) declarations.push(cssVar(`size-${name}-padding-inline`, size.paddingInline)) declarations.push(cssVar(`size-${name}-padding-block`, size.paddingBlock)) declarations.push(cssVar(`size-${name}-gap`, size.gap)) declarations.push(cssVar(`size-${name}-radius`, `var(--radius-${size.radius})`)) } } function appendTransitionAliasDeclarations( declarations: string[], options: EidosConfig ): void { const { primitives } = options if (primitives.controlHeight?.xxs) { declarations.push(cssVar('control-height-2xs', 'var(--control-height-xxs)')) } if (primitives.radius?.sm) { declarations.push(cssVar('radius-xs', 'var(--radius-sm)')) } appendFontFamilyAliases(declarations, primitives.typography?.families) appendTypographyAliases(declarations, primitives.typography) if (primitives.motion) { declarations.push(cssVar('motion-spin-duration', '800ms')) } if (hasFocusColor(options)) { declarations.push(cssVar('color-focus-ring', 'var(--focus-ring-color)')) } } function appendRecipeDeclarations( declarations: string[], recipes: RecipeTokenSet | undefined ): void { if (!recipes) return for (const [component, tokens] of Object.entries(recipes)) { for (const [name, value] of Object.entries(tokens)) { declarations.push(cssVar(`${component}-${name}`, value)) } } } function appendFontFamilyAliases( declarations: string[], families: TypographyPrimitiveSet['families'] | undefined ): void { if (!families) return if (families.primary) { declarations.push(cssVar('font-sans', 'var(--font-family-primary)')) // `--font-ui` is the alias every recipe token leans on for // "UI typeface" (Field labels, Combobox triggers, Toolbar // buttons, …). Anchor it to the canonical `label` named style // so changing the label's family propagates everywhere; fall // back to the primary family if the foundation doesn't emit // the named style. declarations.push( cssVar('font-ui', 'var(--style-label-font-family, var(--font-family-primary))') ) } if (families.secondary) { declarations.push(cssVar('font-serif', 'var(--font-family-secondary)')) declarations.push(cssVar('font-prose', 'var(--font-family-secondary)')) } if (families.display) { declarations.push(cssVar('font-heading', 'var(--font-family-display)')) declarations.push(cssVar('font-display', 'var(--font-family-display)')) } if (families.mono) { declarations.push(cssVar('font-mono', 'var(--font-family-mono)')) declarations.push(cssVar('font-code', 'var(--font-family-mono)')) } } function appendTypographyAliases( declarations: string[], typography: PrimitiveSet['typography'] ): void { if (!typography) return const textAliasMap = { '1': 'xxs', '2': 'xs', '3': 'sm', '4': 'md', '5': 'lg', '6': 'xl' } as const for (const [alias, size] of Object.entries(textAliasMap)) { if (!typography.sizes[size]) continue declarations.push(cssVar(`text-${alias}-size`, `var(--font-size-${size})`)) declarations.push(cssVar(`text-${alias}-lh`, `var(--font-line-height-${size})`)) declarations.push(cssVar(`text-${alias}-ls`, `var(--font-letter-spacing-${size})`)) } if (typography.sizes.md) declarations.push(cssVar('font-size-base', 'var(--font-size-md)')) if (typography.sizes.xxl) declarations.push(cssVar('font-size-2xl', 'var(--font-size-xxl)')) if (typography.sizes.xxxl) declarations.push(cssVar('font-size-3xl', 'var(--font-size-xxxl)')) if (typography.weights.regular) { declarations.push(cssVar('font-weight-normal', 'var(--font-weight-regular)')) } // `--leading-ui` is the canonical leading every recipe token uses // for short UI text (Field labels, captions, controls). Anchor it // to the `label` named style so a designer changing // `STATIC_TYPOGRAPHY.styles.label.lineHeight` propagates through // `--style-label-line-height` → `--leading-ui` → every recipe. // Fall back to the literal `1.25` when the named style isn't // emitted (so unusual foundation overrides still produce valid CSS). declarations.push(cssVar('leading-ui', 'var(--style-label-line-height, 1.25)')) declarations.push(cssVar('leading-prose', '1.6')) declarations.push(cssVar('leading-text', 'var(--leading-prose)')) declarations.push(cssVar('leading-heading', '1.2')) declarations.push(cssVar('leading-display', '1.05')) declarations.push(cssVar('tracking-badge', '0')) declarations.push(cssVar('tracking-label', '0')) declarations.push(cssVar('tracking-ui', '0')) declarations.push(cssVar('tracking-prose', '0')) declarations.push(cssVar('tracking-heading', '0')) declarations.push(cssVar('tracking-display', '0')) declarations.push(cssVar('tracking-tight', '0')) declarations.push(cssVar('tracking-normal', '0')) declarations.push(cssVar('tracking-wide', '0')) declarations.push(cssVar('tracking-wider', '0')) } function hasFocusColor(options: EidosConfig): boolean { if (options.semantics.color.focus) return true return Object.values(options.themes ?? {}).some((theme) => theme.color?.focus) } function appendThemeColorDeclarations( declarations: string[], color: ReturnType ): void { if (color.surface) { declarations.push(cssVar('color-surface-default', color.surface.default)) declarations.push(cssVar('color-surface-raised', color.surface.raised)) declarations.push(cssVar('color-surface-overlay', color.surface.overlay)) declarations.push(cssVar('color-surface-muted', color.surface.muted)) declarations.push(cssVar('color-overlay', color.surface.backdrop)) } if (color.content) { declarations.push(cssVar('color-content-primary', color.content.primary)) declarations.push(cssVar('color-content-secondary', color.content.secondary)) declarations.push(cssVar('color-content-muted', color.content.muted)) declarations.push(cssVar('color-content-disabled', color.content.disabled)) declarations.push(cssVar('color-content-on-solid', color.content.onSolid)) } if (color.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) { declarations.push(cssVar(`scale-${scaleName}-${step}`, scale[step])) } } function appendColorAlphaScaleDeclarations( declarations: string[], scaleName: string, scale: ColorAlphaScale | undefined ): void { for (const step of COLOR_ALPHA_STEPS) { declarations.push( cssVar( `scale-${scaleName}-a${step}`, scale?.[step] ?? `color-mix(in srgb, var(--scale-${scaleName}-9) ${ DEFAULT_COLOR_ALPHA_PERCENTAGES[step] }, transparent)` ) ) } } function appendRecordDeclarations( declarations: string[], prefix: string, record: Record | undefined ): void { if (!record) return for (const [name, value] of Object.entries(record)) { declarations.push(cssVar(`${prefix}-${name}`, value)) } } function mergeThemeColor( primitiveScales: ColorScales | undefined, semanticColor: EidosConfig['semantics']['color'], themeColor: ThemeColorSet | undefined ) { return { scales: { ...primitiveScales, ...themeColor?.scales }, alphaScales: { ...themeColor?.alphaScales }, roles: { ...semanticColor.roles, ...themeColor?.roles } as ColorRoleMap, surface: semanticColor.surface || themeColor?.surface ? ({ ...semanticColor.surface, ...themeColor?.surface } as SurfaceColorRoles) : undefined, content: semanticColor.content || themeColor?.content ? ({ ...semanticColor.content, ...themeColor?.content } as ContentColorRoles) : undefined, border: semanticColor.border || themeColor?.border ? ({ ...semanticColor.border, ...themeColor?.border } as BorderColorRoles) : undefined, focus: semanticColor.focus || themeColor?.focus ? ({ ...semanticColor.focus, ...themeColor?.focus } as FocusColorRoles) : undefined } } function getThemeSelector(themeId: string): string { return `[data-theme='${themeId}']` } function getColorRoleScaleName(definition: string | ColorRoleDefinition): string { if (typeof definition === 'string') return definition return definition.scale } function cssVar(name: string, value: string | number): string { return `--${name}: ${value};` } function emptyCssVar(name: string): string { return `--${name}: ;` } function renderBlock(selector: string, declarations: string[]): string { return `${selector} {\n${declarations.map((declaration) => `\t${declaration}`).join('\n')}\n}` } function formatFontFamily(family: FontFamily): string { return [family.family, ...(family.fallbacks ?? [])].map(formatFontName).join(', ') } function formatFontName(name: string): string { if (GENERIC_FONT_FAMILIES.has(name)) return name if (name.startsWith('"') || name.startsWith("'")) return name if (!/\s/.test(name)) return name return `'${name.replace(/'/g, "\\'")}'` } function getContractVariableNames( contract: EidosCssContract | readonly EidosCssContractToken[] ): Set { const tokens = isEidosCssContract(contract) ? [...contract.static, ...contract.theme] : contract return new Set(tokens.map((token) => token.name)) } function isEidosCssContract( contract: EidosCssContract | readonly EidosCssContractToken[] ): contract is EidosCssContract { return !Array.isArray(contract) } function normalizeCssVariableName(name: string): string | undefined { const normalized = name.startsWith('--') ? name.slice(2) : name if (/^[a-zA-Z0-9_-]+$/.test(normalized)) return normalized return undefined } function assertCssVariableValue(name: string, value: EidosCssVariableValue): string | number { if (typeof value === 'number') return value if (!/[;{}]/.test(value)) return value throw new EidosCssVariableError([name]) }