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

851 lines
26 KiB

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<ColorRoleSlot, string> = {
track: '1',
element: '3',
hover: '4',
active: '5',
border: '6',
solid: '9',
solidHover: '10',
text: '11',
contrast: '12'
}
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[] = []
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)]
feat(eidos foundation): emit --style-{name}-* tokens for named typography styles `STATIC_TYPOGRAPHY.styles` already defined 11 named typography styles (hero, h1..h6, body, prose, label, caption, code) but the renderer only emitted families / sizes / weights — the style entries lived as data with no CSS reflection. Extend `appendTypographyDeclarations` so each named style emits: - `--style-{name}-font-family` → `var(--font-family-{family})` - `--style-{name}-font-size` → `var(--font-size-{size})` - `--style-{name}-line-height` → literal or `var(--font-line-height-{size})` - `--style-{name}-letter-spacing` → literal or `var(--font-letter-spacing-{size})` - `--style-{name}-font-weight` → `var(--font-weight-{weight})` (or numeric) - `--style-{name}-color` → `var(--color-{dot.path → dash-path})` Responsive sizes (e.g. `hero.size = { base: 'xxl', md: 'xxxl' }`) emit the base value into `:root` and per-breakpoint `@media (min-width: …)` blocks that override the same variables. Breakpoint thresholds match `$libs/dom/responsive` (sm: 480, md: 768, lg: 1024, xl: 1280, xxl: 1536). `renderStaticCss` now iterates `STYLE_BREAKPOINT_ORDER` after the main `:root` block and appends one media-query block per breakpoint that has at least one responsive override. The `indentBlock` helper preserves indentation inside the wrapper. `base.css` regenerated. svelte-check 0 errors, `npm run component:audit` 81 / 81 PASS unchanged. This is foundation-only; no components consume the new tokens yet — that lands in the typography port (Text + Heading + Display + Code + CodeBlock + Kbd + Mark + Highlight + Link). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
5 months ago
// 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')
}
feat(eidos foundation): emit --style-{name}-* tokens for named typography styles `STATIC_TYPOGRAPHY.styles` already defined 11 named typography styles (hero, h1..h6, body, prose, label, caption, code) but the renderer only emitted families / sizes / weights — the style entries lived as data with no CSS reflection. Extend `appendTypographyDeclarations` so each named style emits: - `--style-{name}-font-family` → `var(--font-family-{family})` - `--style-{name}-font-size` → `var(--font-size-{size})` - `--style-{name}-line-height` → literal or `var(--font-line-height-{size})` - `--style-{name}-letter-spacing` → literal or `var(--font-letter-spacing-{size})` - `--style-{name}-font-weight` → `var(--font-weight-{weight})` (or numeric) - `--style-{name}-color` → `var(--color-{dot.path → dash-path})` Responsive sizes (e.g. `hero.size = { base: 'xxl', md: 'xxxl' }`) emit the base value into `:root` and per-breakpoint `@media (min-width: …)` blocks that override the same variables. Breakpoint thresholds match `$libs/dom/responsive` (sm: 480, md: 768, lg: 1024, xl: 1280, xxl: 1536). `renderStaticCss` now iterates `STYLE_BREAKPOINT_ORDER` after the main `:root` block and appends one media-query block per breakpoint that has at least one responsive override. The `indentBlock` helper preserves indentation inside the wrapper. `base.css` regenerated. svelte-check 0 errors, `npm run component:audit` 81 / 81 PASS unchanged. This is foundation-only; no components consume the new tokens yet — that lands in the typography port (Text + Heading + Display + Code + CodeBlock + Kbd + Mark + Highlight + Link). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
5 months ago
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<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-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))
}
feat(eidos foundation): emit --style-{name}-* tokens for named typography styles `STATIC_TYPOGRAPHY.styles` already defined 11 named typography styles (hero, h1..h6, body, prose, label, caption, code) but the renderer only emitted families / sizes / weights — the style entries lived as data with no CSS reflection. Extend `appendTypographyDeclarations` so each named style emits: - `--style-{name}-font-family` → `var(--font-family-{family})` - `--style-{name}-font-size` → `var(--font-size-{size})` - `--style-{name}-line-height` → literal or `var(--font-line-height-{size})` - `--style-{name}-letter-spacing` → literal or `var(--font-letter-spacing-{size})` - `--style-{name}-font-weight` → `var(--font-weight-{weight})` (or numeric) - `--style-{name}-color` → `var(--color-{dot.path → dash-path})` Responsive sizes (e.g. `hero.size = { base: 'xxl', md: 'xxxl' }`) emit the base value into `:root` and per-breakpoint `@media (min-width: …)` blocks that override the same variables. Breakpoint thresholds match `$libs/dom/responsive` (sm: 480, md: 768, lg: 1024, xl: 1280, xxl: 1536). `renderStaticCss` now iterates `STYLE_BREAKPOINT_ORDER` after the main `:root` block and appends one media-query block per breakpoint that has at least one responsive override. The `indentBlock` helper preserves indentation inside the wrapper. `base.css` regenerated. svelte-check 0 errors, `npm run component:audit` 81 / 81 PASS unchanged. This is foundation-only; no components consume the new tokens yet — that lands in the typography port (Text + Heading + Display + Code + CodeBlock + Kbd + Mark + Highlight + Link). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
5 months ago
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<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)'))
}
}
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)'))
declarations.push(cssVar('font-ui', '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)'))
}
declarations.push(cssVar('leading-ui', '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<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))
eidos: theming fixes + size/variant parity batch + docs - theme: add surface.muted + content.muted to color contract (neutral-3 + neutral-10). Plugs 17 broken --color-content-muted and 3 broken --color-surface-muted references in recipes/components. - form.css: fix --color-neutral-element-hover typo → --color-neutral-hover. - archetypes.css + events.css: replace raw hsl/rgba indigo with color-mix(var(--color-primary-solid) …) — no raw colors left in eidos. - combobox dark scrollbar: unscope ::-webkit-scrollbar rules in uix.css and duplicate --uix-line on :root + :root[data-mode='dark'] so portaled overlays (Combobox listbox, Popover, Dialog, Drawer) inherit the theme. - sizes: 20 components expand from sm/md/lg to xs..xl (form controls, text inputs, progress/meter, field/form) or xs..lg (nav controls: breadcrumb, pagination, tag-group, toolbar). Composite panels keep sm/md/lg deliberately. - variants: field + toolbar drop arbitrary ControlVariant narrowings; both expose all 3 (surface | outline | ghost) with new outline CSS. - pagination demo: disambiguate siblingCount/boundaryCount as "per side" in label + API table (Radix/MUI convention). - docs: CHECKLIST §C-2.6 (contract token presence) + §D-7.4 (chip parity) + §D-7.5 (size category) added. DEMO_AUTHORING §12.7 (chip parity) + §12.8 (size category cheatsheet) added. eidos/README + active_architecture.md sync texts/migration nomenclature. - PENDIENTES.md: normas N-1..N-5 implantadas en esta sesión. Verification: 88/88 eidos tests, 0 type errors, 67/67 component:audit PASS. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
5 months ago
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))
eidos: theming fixes + size/variant parity batch + docs - theme: add surface.muted + content.muted to color contract (neutral-3 + neutral-10). Plugs 17 broken --color-content-muted and 3 broken --color-surface-muted references in recipes/components. - form.css: fix --color-neutral-element-hover typo → --color-neutral-hover. - archetypes.css + events.css: replace raw hsl/rgba indigo with color-mix(var(--color-primary-solid) …) — no raw colors left in eidos. - combobox dark scrollbar: unscope ::-webkit-scrollbar rules in uix.css and duplicate --uix-line on :root + :root[data-mode='dark'] so portaled overlays (Combobox listbox, Popover, Dialog, Drawer) inherit the theme. - sizes: 20 components expand from sm/md/lg to xs..xl (form controls, text inputs, progress/meter, field/form) or xs..lg (nav controls: breadcrumb, pagination, tag-group, toolbar). Composite panels keep sm/md/lg deliberately. - variants: field + toolbar drop arbitrary ControlVariant narrowings; both expose all 3 (surface | outline | ghost) with new outline CSS. - pagination demo: disambiguate siblingCount/boundaryCount as "per side" in label + API table (Radix/MUI convention). - docs: CHECKLIST §C-2.6 (contract token presence) + §D-7.4 (chip parity) + §D-7.5 (size category) added. DEMO_AUTHORING §12.7 (chip parity) + §12.8 (size category cheatsheet) added. eidos/README + active_architecture.md sync texts/migration nomenclature. - PENDIENTES.md: normas N-1..N-5 implantadas en esta sesión. Verification: 88/88 eidos tests, 0 type errors, 67/67 component:audit PASS. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
5 months ago
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<string, string | number> | 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<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.