|
|
|
|
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)'))
|
feat(eidos foundation): vertebrate typography via named-style aliases + R-2.7
Single source of truth for typography values that the recipe layer
consumes. The foundation aliases `--font-ui` and `--leading-ui` (read
by ~30 recipe tokens in `lib/recipes/base.ts`) now derive from the
canonical `label` named style instead of carrying duplicate literals:
--style-label-font-family: var(--font-family-primary);
--style-label-line-height: 1.25;
--font-ui: var(--style-label-font-family, var(--font-family-primary));
--leading-ui: var(--style-label-line-height, 1.25);
Chain: typography.ts styles → --style-{name}-* → --leading-ui / --font-ui
→ recipe tokens → component CSS. Editing
`STATIC_TYPOGRAPHY.styles.label.lineHeight` now propagates to every
recipe in one go.
Why not push recipes to consume `--style-{name}-*` directly:
- t-shirt sizes (xs/sm/md/lg/xl) don't map to four semantic buckets
- per-component matices (description/caption/hint) need their own
color / weight / letter-spacing
- ref libraries (Radix Themes, Mantine, MUI, Chakra) all keep
numerical scale for component internals; semantic layer is only for
user-facing typography primitives (`<Text variant="body2">`)
Audit rule R-2.7 (warn): detects literal font-size / font-weight /
line-height / letter-spacing in eidos component CSS. Escape valves:
var(...), numeric identities (0/0px/1), keywords (inherit/initial/
unset), or trailing `/* literal: <reason> */` comment. Current run
flags 6 components with letter-spacing/font-size literals (all
intentional micro-tracking and em-relative; can be annotated case by
case).
Documentation:
- src/uix/eidos/README.md § "Vertebración tipográfica" — two-layer
architecture rationale, alias chain diagram, comparison vs Radix
Themes / Chakra / Mantine / MUI, escape valves
- web/routes/uix/lib/COMPONENT_AUDIT_GUIDE.md § 4.11 — pointer to
R-2.7 + cross-link to the foundation doc
Verified end-to-end in browser at /uix/components/field:
--font-ui → 'Instrument Sans', system-ui, sans-serif
--style-label-font-family → 'Instrument Sans', system-ui, sans-serif
--leading-ui → 1.25
--style-label-line-height → 1.25
computed [data-field-label].line-height → 17.5px (= 14 × 1.25)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
5 months ago
|
|
|
// `--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)'))
|
|
|
|
|
}
|
|
|
|
|
|
feat(eidos foundation): vertebrate typography via named-style aliases + R-2.7
Single source of truth for typography values that the recipe layer
consumes. The foundation aliases `--font-ui` and `--leading-ui` (read
by ~30 recipe tokens in `lib/recipes/base.ts`) now derive from the
canonical `label` named style instead of carrying duplicate literals:
--style-label-font-family: var(--font-family-primary);
--style-label-line-height: 1.25;
--font-ui: var(--style-label-font-family, var(--font-family-primary));
--leading-ui: var(--style-label-line-height, 1.25);
Chain: typography.ts styles → --style-{name}-* → --leading-ui / --font-ui
→ recipe tokens → component CSS. Editing
`STATIC_TYPOGRAPHY.styles.label.lineHeight` now propagates to every
recipe in one go.
Why not push recipes to consume `--style-{name}-*` directly:
- t-shirt sizes (xs/sm/md/lg/xl) don't map to four semantic buckets
- per-component matices (description/caption/hint) need their own
color / weight / letter-spacing
- ref libraries (Radix Themes, Mantine, MUI, Chakra) all keep
numerical scale for component internals; semantic layer is only for
user-facing typography primitives (`<Text variant="body2">`)
Audit rule R-2.7 (warn): detects literal font-size / font-weight /
line-height / letter-spacing in eidos component CSS. Escape valves:
var(...), numeric identities (0/0px/1), keywords (inherit/initial/
unset), or trailing `/* literal: <reason> */` comment. Current run
flags 6 components with letter-spacing/font-size literals (all
intentional micro-tracking and em-relative; can be annotated case by
case).
Documentation:
- src/uix/eidos/README.md § "Vertebración tipográfica" — two-layer
architecture rationale, alias chain diagram, comparison vs Radix
Themes / Chakra / Mantine / MUI, escape valves
- web/routes/uix/lib/COMPONENT_AUDIT_GUIDE.md § 4.11 — pointer to
R-2.7 + cross-link to the foundation doc
Verified end-to-end in browser at /uix/components/field:
--font-ui → 'Instrument Sans', system-ui, sans-serif
--style-label-font-family → 'Instrument Sans', system-ui, sans-serif
--leading-ui → 1.25
--style-label-line-height → 1.25
computed [data-field-label].line-height → 17.5px (= 14 × 1.25)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
5 months ago
|
|
|
// `--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<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])
|
|
|
|
|
}
|