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

656 lines
20 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 SizePrimitiveSet,
type SurfaceColorRoles,
type ThemeColorSet,
type TypographyPrimitiveSet
} from './config-types'
import { createEidosCssContract } from './contract'
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)
const blocks = [renderBlock(':root', declarations)]
if (primitives.density) blocks.push(renderDensityBlocks())
return blocks.join('\n\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))
}
}
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 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))
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-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 {
if (themeId === 'base-light') {
return `:root,\n[data-theme='${themeId}']`
}
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 toKebab(value: string): string {
return value.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`)
}
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.