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

1846 lines
65 KiB

import {
COLOR_ALPHA_STEPS,
COLOR_SCALE_STEPS,
COLOR_ROLES,
DENSITY_KEYS,
DURATION_KEYS,
SCALING_KEYS,
DEFAULT_SCALING,
Theming: Radix-parity palette + intent auto-derivation + color-model docs Two-level color model settled (THEMING.md section 25), replacing the anchor RFC: the palette is the source (scales, directly usable, designable); hierarchy roles alias scales explicitly; intents auto-derive from the palette by the book's canonical convention. - Palette library expanded 12 -> 31 scales at Radix Colors parity (exact values): radix-scales.ts (19 added: mauve/sage/olive/sand/tomato/ruby/crimson/plum/ violet/iris/indigo/jade/grass/brown/sky/mint/lime/gold/bronze) spread into base.ts. Each directly usable as --scale-{name}-{step}. - Intent auto-derivation: CANONICAL_INTENT_SCALES (neutral->gray, affirm->teal, fulfill->green, risk->amber, threat->red, loss->plum) + completeColorRoleMap. Intents omitted from a theme role map fill from the convention (identity = step 9); slots derive normally; override optional. ColorRoleMap: hierarchy required, intents optional. - Validation: hierarchy roles required; omitted intents validate the canonical scale exists in the palette. - index: export ScalingKey / SCALING_KEYS. - docs: THEMING.md section 25 (full color model + decisions), section 21 marked resolved, COLOR_MODEL_RFC resolved (anchor rejected). - test: base library asserts 31 scales x 12 steps. Verified: npm run check (0 new errors), vitest eidos (0 new regressions), browser (intents auto-derive: affirm=teal #0E9384, risk=amber #DC6803, loss=plum #7A3AAD at step 9). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
4 months ago
completeColorRoleMap,
type ColorRoleDefinition,
type ColorRoleSlot,
type ColorRoleMap,
type ColorAlphaStep,
type ColorAlphaScale,
type ColorScale,
type ColorScales,
type BorderColorRoles,
type BorderPrimitiveSet,
type ContentColorRoles,
type DensityPrimitiveSet,
type EidosCssContract,
type EidosCssContractToken,
type EidosCssVariableMap,
type EidosCssVariableValue,
type EidosConfig,
type FontFamily,
type FocusColorRoles,
type LayoutPrimitiveSet,
type PrimitiveSet,
feat(eidos): TSC v2.2 (parts + composition) + universal theming coverage Token Scope Contract universal — no más excepciones arquitectónicas. Los 3 componentes que vivían fuera de TSC v2.1 (select, avatar, toggle-group) ahora están dentro del contrato via dos extensiones nuevas. TSC v2.2 extensiones (lib/config-types.ts + render-css.ts + config.ts): - `parts: readonly string[]` en RecipeTokenMultiDeclaration — emite selectores comma-separados (`[data-{c}-x], [data-{c}-y]`) para componentes con data-color cascadeado per-part. Consumer: select. - `composition: { foreignRecipe: { targetSelector, tokens } }` sibling key — overrides cross-recipe scoped a la cascade del host. Consumer: toggle-group modifica `--toggle-palette-*` en sus items. Migraciones: - select: 3 private `_accent-{track,border,text}` con parts: ['trigger', 'content'] + 7 cascades color:X each. Removed orphan `_accent-solid` (CSS no consumía). - avatar: 6 tokens via composite scopes `['variant:X', 'color:Y']` con matrix helper inline. Badge usa parts: ['badge']. Reemplaza 24 bloques CSS × 2 partes. - toggle-group: composition block con 8 palette tokens × 4 colors. Reemplaza 4 bloques CSS per-color. Bug toggle-group post-composition (encontrado y arreglado): Tras la composition migration, el cascade del toggle-group seguía roto porque los tokens derivados (`--toggle-solid-on-bg`, `--toggle-outline-fg`, etc.) viven en scope `[data-toggle]`. El `[data-toggle-group-item]` es sibling (no descendant), así que `var(--toggle-solid-on-bg)` resolvía undefined. Fix: inlined derivation expressions directamente en `[data-toggle-group-item]` y sus variant cascades (solid/outline/ghost), referenciando palette tokens en su propio scope local. Validador + emisor + contract: - `validateRecipeComposition` valida el shape `{ targetSelector, tokens }` y rechaza composition entries con scope='root'. - `stripCompositionKey` + `emitComposition` separan el pipeline. - `appendRecipeContractTokens` skip-list para `composition` (no aparece como fake `--{c}-composition` knob). - `tokenKeys`/`tokenEntries` helpers en recipe-css-contract.test.ts filtran composition en todos los iteradores. Documentación: - THEMING.md §18 reescrito como "Cobertura universal de TSC". §7 extendido con subsecciones "Multi-part scope" y "Cross-recipe composition" + ejemplos completos. TOC actualizado. - eidos/README.md tabla de referencia ampliada con TSC v2.2 + §18. - CLAUDE.md gana hand-off "2026-05-27 #5" (TSC v2.2 + cobertura universal). - CONTINUE.md reescrito al estado actual de la sesión. Working tree también incluye sprint Words en paralelo (multiple authors): slash menu, find/replace regex, code language picker, table audit, toolbar family menu, code highlight engine. Tests: 786/786 pass en src/uix/{eidos,morfo,soma,sema}. `npm run check`: 6 errores pre-existentes (lib/_demo, soma/components/internal, web/routes/active) no relacionados. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
5 months ago
type RecipeComposition,
type RecipeTokenSet,
feat(eidos): TSC v2.2 (parts + composition) + universal theming coverage Token Scope Contract universal — no más excepciones arquitectónicas. Los 3 componentes que vivían fuera de TSC v2.1 (select, avatar, toggle-group) ahora están dentro del contrato via dos extensiones nuevas. TSC v2.2 extensiones (lib/config-types.ts + render-css.ts + config.ts): - `parts: readonly string[]` en RecipeTokenMultiDeclaration — emite selectores comma-separados (`[data-{c}-x], [data-{c}-y]`) para componentes con data-color cascadeado per-part. Consumer: select. - `composition: { foreignRecipe: { targetSelector, tokens } }` sibling key — overrides cross-recipe scoped a la cascade del host. Consumer: toggle-group modifica `--toggle-palette-*` en sus items. Migraciones: - select: 3 private `_accent-{track,border,text}` con parts: ['trigger', 'content'] + 7 cascades color:X each. Removed orphan `_accent-solid` (CSS no consumía). - avatar: 6 tokens via composite scopes `['variant:X', 'color:Y']` con matrix helper inline. Badge usa parts: ['badge']. Reemplaza 24 bloques CSS × 2 partes. - toggle-group: composition block con 8 palette tokens × 4 colors. Reemplaza 4 bloques CSS per-color. Bug toggle-group post-composition (encontrado y arreglado): Tras la composition migration, el cascade del toggle-group seguía roto porque los tokens derivados (`--toggle-solid-on-bg`, `--toggle-outline-fg`, etc.) viven en scope `[data-toggle]`. El `[data-toggle-group-item]` es sibling (no descendant), así que `var(--toggle-solid-on-bg)` resolvía undefined. Fix: inlined derivation expressions directamente en `[data-toggle-group-item]` y sus variant cascades (solid/outline/ghost), referenciando palette tokens en su propio scope local. Validador + emisor + contract: - `validateRecipeComposition` valida el shape `{ targetSelector, tokens }` y rechaza composition entries con scope='root'. - `stripCompositionKey` + `emitComposition` separan el pipeline. - `appendRecipeContractTokens` skip-list para `composition` (no aparece como fake `--{c}-composition` knob). - `tokenKeys`/`tokenEntries` helpers en recipe-css-contract.test.ts filtran composition en todos los iteradores. Documentación: - THEMING.md §18 reescrito como "Cobertura universal de TSC". §7 extendido con subsecciones "Multi-part scope" y "Cross-recipe composition" + ejemplos completos. TOC actualizado. - eidos/README.md tabla de referencia ampliada con TSC v2.2 + §18. - CLAUDE.md gana hand-off "2026-05-27 #5" (TSC v2.2 + cobertura universal). - CONTINUE.md reescrito al estado actual de la sesión. Working tree también incluye sprint Words en paralelo (multiple authors): slash menu, find/replace regex, code language picker, table audit, toolbar family menu, code highlight engine. Tests: 786/786 pass en src/uix/{eidos,morfo,soma,sema}. `npm run check`: 6 errores pre-existentes (lib/_demo, soma/components/internal, web/routes/active) no relacionados. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
5 months ago
type RecipeTokenValue,
type RecipeTokenDeclaration,
type RecipeTokenMultiDeclaration,
type SizePrimitiveSet,
feat(eidos): TSC v2.2 (parts + composition) + universal theming coverage Token Scope Contract universal — no más excepciones arquitectónicas. Los 3 componentes que vivían fuera de TSC v2.1 (select, avatar, toggle-group) ahora están dentro del contrato via dos extensiones nuevas. TSC v2.2 extensiones (lib/config-types.ts + render-css.ts + config.ts): - `parts: readonly string[]` en RecipeTokenMultiDeclaration — emite selectores comma-separados (`[data-{c}-x], [data-{c}-y]`) para componentes con data-color cascadeado per-part. Consumer: select. - `composition: { foreignRecipe: { targetSelector, tokens } }` sibling key — overrides cross-recipe scoped a la cascade del host. Consumer: toggle-group modifica `--toggle-palette-*` en sus items. Migraciones: - select: 3 private `_accent-{track,border,text}` con parts: ['trigger', 'content'] + 7 cascades color:X each. Removed orphan `_accent-solid` (CSS no consumía). - avatar: 6 tokens via composite scopes `['variant:X', 'color:Y']` con matrix helper inline. Badge usa parts: ['badge']. Reemplaza 24 bloques CSS × 2 partes. - toggle-group: composition block con 8 palette tokens × 4 colors. Reemplaza 4 bloques CSS per-color. Bug toggle-group post-composition (encontrado y arreglado): Tras la composition migration, el cascade del toggle-group seguía roto porque los tokens derivados (`--toggle-solid-on-bg`, `--toggle-outline-fg`, etc.) viven en scope `[data-toggle]`. El `[data-toggle-group-item]` es sibling (no descendant), así que `var(--toggle-solid-on-bg)` resolvía undefined. Fix: inlined derivation expressions directamente en `[data-toggle-group-item]` y sus variant cascades (solid/outline/ghost), referenciando palette tokens en su propio scope local. Validador + emisor + contract: - `validateRecipeComposition` valida el shape `{ targetSelector, tokens }` y rechaza composition entries con scope='root'. - `stripCompositionKey` + `emitComposition` separan el pipeline. - `appendRecipeContractTokens` skip-list para `composition` (no aparece como fake `--{c}-composition` knob). - `tokenKeys`/`tokenEntries` helpers en recipe-css-contract.test.ts filtran composition en todos los iteradores. Documentación: - THEMING.md §18 reescrito como "Cobertura universal de TSC". §7 extendido con subsecciones "Multi-part scope" y "Cross-recipe composition" + ejemplos completos. TOC actualizado. - eidos/README.md tabla de referencia ampliada con TSC v2.2 + §18. - CLAUDE.md gana hand-off "2026-05-27 #5" (TSC v2.2 + cobertura universal). - CONTINUE.md reescrito al estado actual de la sesión. Working tree también incluye sprint Words en paralelo (multiple authors): slash menu, find/replace regex, code language picker, table audit, toolbar family menu, code highlight engine. Tests: 786/786 pass en src/uix/{eidos,morfo,soma,sema}. `npm run check`: 6 errores pre-existentes (lib/_demo, soma/components/internal, web/routes/active) no relacionados. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
5 months ago
type TokenScope,
type AtomicScope,
type LeafScope,
type SurfaceColorRoles,
type ThemeColorSet,
type TypographyPrimitiveSet
} from './config-types'
import { createEidosCssContract } from './contract'
import { toKebab } from './utils'
import { STATIC_SCALING } from './primitives/static'
import type {
CssStatePreset,
CssPhase,
EventSignature,
KeyframeName,
KeyframeStops,
MotionConfig
} from '$motion'
import { apcaLc, oklchToGammaRgb, safeParseColor, wcagContrastRatio } from '$color'
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'
}
/**
* On-solid legibility floor. The theme's `onSolid` text (white) must clear THIS on a
* role's solid to stay; below it the contrast slot flips to `onSolidContrast` (dark).
* APCA decides (|Lc| ≥ floor, ≈ the WCAG 3:1 UI minimum) with a WCAG 2 ratio as a
* conservative cross-check — white must clear BOTH. (COLOR_ENGINE_RFC §8)
*/
const ON_SOLID_APCA_FLOOR = 60
const ON_SOLID_WCAG_FLOOR = 3
const DEFAULT_COLOR_ALPHA_PERCENTAGES: Record<ColorAlphaStep, string> = {
'1': '3%',
'2': '5%',
'3': '8%',
'4': '12%',
'5': '16%',
'6': '22%',
'7': '30%',
'8': '40%',
'9': '56%',
'10': '68%',
'11': '80%',
'12': '92%'
}
const GENERIC_FONT_FAMILIES = new Set([
'serif',
'sans-serif',
'monospace',
'cursive',
'fantasy',
'system-ui',
'ui-serif',
'ui-sans-serif',
'ui-monospace',
'emoji',
'math',
'fangsong'
])
export function renderStaticCss(options: EidosConfig): string {
const { primitives } = options
const declarations: string[] = []
// Spacing + control heights scale with BOTH the active density and the global
// `--scaling` zoom (multiplicative): `calc(value * density-scale * scaling)`.
// The `[data-density]` / `[data-scaling]` blocks rebind those vars. At
// comfortable + scaling=100 both are 1, so output equals the raw value.
appendScaledMetricDeclarations(declarations, 'space', primitives.space, 'density-space-scale')
appendScaledMetricDeclarations(
declarations,
'control-height',
primitives.controlHeight,
'density-control-scale'
)
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)
}
// Scaling (global zoom) is a system constant, not part of the themeable
// primitives — always emitted.
appendScalingDeclarations(declarations)
if (primitives.motion) {
appendRecordDeclarations(declarations, 'duration', primitives.motion.duration)
appendRecordDeclarations(declarations, 'ease', primitives.motion.ease)
appendRecordDeclarations(declarations, 'motion-distance', primitives.motion.distance)
appendRecordDeclarations(declarations, 'motion-scale', primitives.motion.scale)
declarations.push(cssVar('motion-stagger', primitives.motion.stagger))
}
if (primitives.icon) {
appendScaledMetricDeclarations(declarations, 'icon-size', primitives.icon.size)
appendRecordDeclarations(declarations, 'icon-stroke-width', primitives.icon.strokeWidth)
}
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)
feat(eidos): TSC v2.2 (parts + composition) + universal theming coverage Token Scope Contract universal — no más excepciones arquitectónicas. Los 3 componentes que vivían fuera de TSC v2.1 (select, avatar, toggle-group) ahora están dentro del contrato via dos extensiones nuevas. TSC v2.2 extensiones (lib/config-types.ts + render-css.ts + config.ts): - `parts: readonly string[]` en RecipeTokenMultiDeclaration — emite selectores comma-separados (`[data-{c}-x], [data-{c}-y]`) para componentes con data-color cascadeado per-part. Consumer: select. - `composition: { foreignRecipe: { targetSelector, tokens } }` sibling key — overrides cross-recipe scoped a la cascade del host. Consumer: toggle-group modifica `--toggle-palette-*` en sus items. Migraciones: - select: 3 private `_accent-{track,border,text}` con parts: ['trigger', 'content'] + 7 cascades color:X each. Removed orphan `_accent-solid` (CSS no consumía). - avatar: 6 tokens via composite scopes `['variant:X', 'color:Y']` con matrix helper inline. Badge usa parts: ['badge']. Reemplaza 24 bloques CSS × 2 partes. - toggle-group: composition block con 8 palette tokens × 4 colors. Reemplaza 4 bloques CSS per-color. Bug toggle-group post-composition (encontrado y arreglado): Tras la composition migration, el cascade del toggle-group seguía roto porque los tokens derivados (`--toggle-solid-on-bg`, `--toggle-outline-fg`, etc.) viven en scope `[data-toggle]`. El `[data-toggle-group-item]` es sibling (no descendant), así que `var(--toggle-solid-on-bg)` resolvía undefined. Fix: inlined derivation expressions directamente en `[data-toggle-group-item]` y sus variant cascades (solid/outline/ghost), referenciando palette tokens en su propio scope local. Validador + emisor + contract: - `validateRecipeComposition` valida el shape `{ targetSelector, tokens }` y rechaza composition entries con scope='root'. - `stripCompositionKey` + `emitComposition` separan el pipeline. - `appendRecipeContractTokens` skip-list para `composition` (no aparece como fake `--{c}-composition` knob). - `tokenKeys`/`tokenEntries` helpers en recipe-css-contract.test.ts filtran composition en todos los iteradores. Documentación: - THEMING.md §18 reescrito como "Cobertura universal de TSC". §7 extendido con subsecciones "Multi-part scope" y "Cross-recipe composition" + ejemplos completos. TOC actualizado. - eidos/README.md tabla de referencia ampliada con TSC v2.2 + §18. - CLAUDE.md gana hand-off "2026-05-27 #5" (TSC v2.2 + cobertura universal). - CONTINUE.md reescrito al estado actual de la sesión. Working tree también incluye sprint Words en paralelo (multiple authors): slash menu, find/replace regex, code language picker, table audit, toolbar family menu, code highlight engine. Tests: 786/786 pass en src/uix/{eidos,morfo,soma,sema}. `npm run check`: 6 errores pre-existentes (lib/_demo, soma/components/internal, web/routes/active) no relacionados. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
5 months ago
const scopedRecipeBlocks = appendRecipeDeclarations(declarations, options.recipes)
feat(eidos): TSC v2.2 (parts + composition) + universal theming coverage Token Scope Contract universal — no más excepciones arquitectónicas. Los 3 componentes que vivían fuera de TSC v2.1 (select, avatar, toggle-group) ahora están dentro del contrato via dos extensiones nuevas. TSC v2.2 extensiones (lib/config-types.ts + render-css.ts + config.ts): - `parts: readonly string[]` en RecipeTokenMultiDeclaration — emite selectores comma-separados (`[data-{c}-x], [data-{c}-y]`) para componentes con data-color cascadeado per-part. Consumer: select. - `composition: { foreignRecipe: { targetSelector, tokens } }` sibling key — overrides cross-recipe scoped a la cascade del host. Consumer: toggle-group modifica `--toggle-palette-*` en sus items. Migraciones: - select: 3 private `_accent-{track,border,text}` con parts: ['trigger', 'content'] + 7 cascades color:X each. Removed orphan `_accent-solid` (CSS no consumía). - avatar: 6 tokens via composite scopes `['variant:X', 'color:Y']` con matrix helper inline. Badge usa parts: ['badge']. Reemplaza 24 bloques CSS × 2 partes. - toggle-group: composition block con 8 palette tokens × 4 colors. Reemplaza 4 bloques CSS per-color. Bug toggle-group post-composition (encontrado y arreglado): Tras la composition migration, el cascade del toggle-group seguía roto porque los tokens derivados (`--toggle-solid-on-bg`, `--toggle-outline-fg`, etc.) viven en scope `[data-toggle]`. El `[data-toggle-group-item]` es sibling (no descendant), así que `var(--toggle-solid-on-bg)` resolvía undefined. Fix: inlined derivation expressions directamente en `[data-toggle-group-item]` y sus variant cascades (solid/outline/ghost), referenciando palette tokens en su propio scope local. Validador + emisor + contract: - `validateRecipeComposition` valida el shape `{ targetSelector, tokens }` y rechaza composition entries con scope='root'. - `stripCompositionKey` + `emitComposition` separan el pipeline. - `appendRecipeContractTokens` skip-list para `composition` (no aparece como fake `--{c}-composition` knob). - `tokenKeys`/`tokenEntries` helpers en recipe-css-contract.test.ts filtran composition en todos los iteradores. Documentación: - THEMING.md §18 reescrito como "Cobertura universal de TSC". §7 extendido con subsecciones "Multi-part scope" y "Cross-recipe composition" + ejemplos completos. TOC actualizado. - eidos/README.md tabla de referencia ampliada con TSC v2.2 + §18. - CLAUDE.md gana hand-off "2026-05-27 #5" (TSC v2.2 + cobertura universal). - CONTINUE.md reescrito al estado actual de la sesión. Working tree también incluye sprint Words en paralelo (multiple authors): slash menu, find/replace regex, code language picker, table audit, toolbar family menu, code highlight engine. Tests: 786/786 pass en src/uix/{eidos,morfo,soma,sema}. `npm run check`: 6 errores pre-existentes (lib/_demo, soma/components/internal, web/routes/active) no relacionados. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
5 months ago
const blocks = [renderBlock(':root', declarations), ...scopedRecipeBlocks]
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())
blocks.push(renderScalingBlocks())
// Expressive motion set (Carbon's productive/expressive split): a scoped
// override of the easing / duration tokens under `[data-motion-set='expressive']`.
// Productive (the default) lives in `:root`; only the deltas are emitted here.
const expressive = primitives.motion?.expressive
if (expressive) {
const exDeclarations: string[] = []
for (const [key, value] of Object.entries(expressive.ease ?? {})) {
if (value) exDeclarations.push(cssVar(`ease-${key}`, value))
}
for (const [key, value] of Object.entries(expressive.duration ?? {})) {
if (value) exDeclarations.push(cssVar(`duration-${key}`, value))
}
if (exDeclarations.length) {
blocks.push(renderBlock("[data-motion-set='expressive']", exDeclarations))
}
}
if (options.motion) blocks.push(renderMotionBlocks(options.motion))
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, scale, color.alphaScales[scaleName])
}
Theming: Radix-parity palette + intent auto-derivation + color-model docs Two-level color model settled (THEMING.md section 25), replacing the anchor RFC: the palette is the source (scales, directly usable, designable); hierarchy roles alias scales explicitly; intents auto-derive from the palette by the book's canonical convention. - Palette library expanded 12 -> 31 scales at Radix Colors parity (exact values): radix-scales.ts (19 added: mauve/sage/olive/sand/tomato/ruby/crimson/plum/ violet/iris/indigo/jade/grass/brown/sky/mint/lime/gold/bronze) spread into base.ts. Each directly usable as --scale-{name}-{step}. - Intent auto-derivation: CANONICAL_INTENT_SCALES (neutral->gray, affirm->teal, fulfill->green, risk->amber, threat->red, loss->plum) + completeColorRoleMap. Intents omitted from a theme role map fill from the convention (identity = step 9); slots derive normally; override optional. ColorRoleMap: hierarchy required, intents optional. - Validation: hierarchy roles required; omitted intents validate the canonical scale exists in the palette. - index: export ScalingKey / SCALING_KEYS. - docs: THEMING.md section 25 (full color model + decisions), section 21 marked resolved, COLOR_MODEL_RFC resolved (anchor rejected). - test: base library asserts 31 scales x 12 steps. Verified: npm run check (0 new errors), vitest eidos (0 new regressions), browser (intents auto-derive: affirm=teal #0E9384, risk=amber #DC6803, loss=plum #7A3AAD at step 9). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
4 months ago
// Intents omitted from the theme's role map auto-derive from the palette by
// the book's canonical convention (CANONICAL_INTENT_SCALES). Identity is the
// solid (step 9); slots derive normally below. (color grammar)
const completedRoles = completeColorRoleMap(color.roles)
for (const role of COLOR_ROLES) {
Theming: Radix-parity palette + intent auto-derivation + color-model docs Two-level color model settled (THEMING.md section 25), replacing the anchor RFC: the palette is the source (scales, directly usable, designable); hierarchy roles alias scales explicitly; intents auto-derive from the palette by the book's canonical convention. - Palette library expanded 12 -> 31 scales at Radix Colors parity (exact values): radix-scales.ts (19 added: mauve/sage/olive/sand/tomato/ruby/crimson/plum/ violet/iris/indigo/jade/grass/brown/sky/mint/lime/gold/bronze) spread into base.ts. Each directly usable as --scale-{name}-{step}. - Intent auto-derivation: CANONICAL_INTENT_SCALES (neutral->gray, affirm->teal, fulfill->green, risk->amber, threat->red, loss->plum) + completeColorRoleMap. Intents omitted from a theme role map fill from the convention (identity = step 9); slots derive normally; override optional. ColorRoleMap: hierarchy required, intents optional. - Validation: hierarchy roles required; omitted intents validate the canonical scale exists in the palette. - index: export ScalingKey / SCALING_KEYS. - docs: THEMING.md section 25 (full color model + decisions), section 21 marked resolved, COLOR_MODEL_RFC resolved (anchor rejected). - test: base library asserts 31 scales x 12 steps. Verified: npm run check (0 new errors), vitest eidos (0 new regressions), browser (intents auto-derive: affirm=teal #0E9384, risk=amber #DC6803, loss=plum #7A3AAD at step 9). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
4 months ago
const definition = completedRoles[role]
const scaleName = getColorRoleScaleName(definition)
for (const step of COLOR_SCALE_STEPS) {
declarations.push(cssVar(`primitive-${role}-${step}`, `var(--scale-${scaleName}-${step})`))
}
for (const step of COLOR_ALPHA_STEPS) {
declarations.push(cssVar(`primitive-${role}-a${step}`, `var(--scale-${scaleName}-a${step})`))
}
const slots = {
...DEFAULT_COLOR_ROLE_SLOT_STEPS,
...(typeof definition === 'string' ? undefined : definition.slots)
}
Theming: on-solid contrast by luminance (P2-2) + translucent role surfaces (P2-4) Two audit P2 quality defects, fixed at engine level. P2-2 - on-solid text illegible on light solids: The `contrast` slot defaulted to `--color-content-on-solid` (white) for every role. On light solids (amber/yellow, risk=orange ~2.3:1) white is sub-AA. The engine now picks by WCAG contrast (gamma-linearized) of the role step-9: when white fails (<3:1) it uses `--color-content-on-solid-contrast` (a dark, new OPTIONAL `content.onSolidContrast` semantic, #1c1917 in base). Only risk flips to dark in base (5.89:1); purple/red/teal/green keep white (convention, >=3:1). An explicit `slots.contrast` override is still honored verbatim. P2-4 - opaque tinted soft surfaces: The soft variant tint (Button + Badge `{role}-soft-bg`) was opaque (step-1 track + opaque color-mix hover) so it did not composite over non-uniform backgrounds. New derived tokens `--color-{role}-surface` (= a2) + `--color-{role}-surface-hover` (= a3) are translucent by construction (compositing-inverse alpha). Button/Badge soft consume them. Toast/Tabs untouched - they are cards, opacity is correct. - config-types: optional onSolidContrast + CONTENT_COLOR_OPTIONAL_KEYS. - config: content keySet allows the optional key; validator value-checks it. - render-css: wcagContrastRatio/wcagRelativeLuminance; luminance pick; emit on-solid-contrast + surface/surface-hover. - contract: on-solid-contrast + surface tokens per role. - themes/base: onSolidContrast #1c1917 (light + dark). - recipes/base: Button + Badge soft-bg -> surface, soft-bg-hover -> surface-hover. - docs: THEMING.md section 24 + section 22 item 2; audit P2-2/P2-4 resolved. Verified: npm run check (0 new errors), vitest eidos (0 new regressions), browser runtime (risk 5.89:1 dark text, surfaces translucent rgba). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
4 months ago
// The default `contrast` slot is the color drawn ON the solid fill
// (button / badge / banner text). The contrasting color over a saturated
// step-9 fill is the theme's `onSolid` (white) — but on a LIGHT solid
// (amber / yellow / orange, step-9 luminance high) white is illegible
// (~2:1). When `onSolid` fails the APCA floor (WCAG cross-checked), the
Theming: on-solid contrast by luminance (P2-2) + translucent role surfaces (P2-4) Two audit P2 quality defects, fixed at engine level. P2-2 - on-solid text illegible on light solids: The `contrast` slot defaulted to `--color-content-on-solid` (white) for every role. On light solids (amber/yellow, risk=orange ~2.3:1) white is sub-AA. The engine now picks by WCAG contrast (gamma-linearized) of the role step-9: when white fails (<3:1) it uses `--color-content-on-solid-contrast` (a dark, new OPTIONAL `content.onSolidContrast` semantic, #1c1917 in base). Only risk flips to dark in base (5.89:1); purple/red/teal/green keep white (convention, >=3:1). An explicit `slots.contrast` override is still honored verbatim. P2-4 - opaque tinted soft surfaces: The soft variant tint (Button + Badge `{role}-soft-bg`) was opaque (step-1 track + opaque color-mix hover) so it did not composite over non-uniform backgrounds. New derived tokens `--color-{role}-surface` (= a2) + `--color-{role}-surface-hover` (= a3) are translucent by construction (compositing-inverse alpha). Button/Badge soft consume them. Toast/Tabs untouched - they are cards, opacity is correct. - config-types: optional onSolidContrast + CONTENT_COLOR_OPTIONAL_KEYS. - config: content keySet allows the optional key; validator value-checks it. - render-css: wcagContrastRatio/wcagRelativeLuminance; luminance pick; emit on-solid-contrast + surface/surface-hover. - contract: on-solid-contrast + surface tokens per role. - themes/base: onSolidContrast #1c1917 (light + dark). - recipes/base: Button + Badge soft-bg -> surface, soft-bg-hover -> surface-hover. - docs: THEMING.md section 24 + section 22 item 2; audit P2-2/P2-4 resolved. Verified: npm run check (0 new errors), vitest eidos (0 new regressions), browser runtime (risk 5.89:1 dark text, surfaces translucent rgba). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
4 months ago
// engine swaps to `onSolidContrast` (dark). Step-12 stays as the var
// fallback. An explicit per-role `slots.contrast` override is honored
// verbatim, so roles that intentionally invert keep working. (audit P2-2)
const solidColor = color.scales[scaleName]?.['9']
const parsedSolid = solidColor ? safeParseColor(solidColor) : null
const parsedOnSolid = color.content?.onSolid ? safeParseColor(color.content.onSolid) : null
let onSolidFailsContrast = false
if (parsedSolid && parsedOnSolid) {
const solidRgb = oklchToGammaRgb(parsedSolid)
const onSolidRgb = oklchToGammaRgb(parsedOnSolid)
// APCA decides; WCAG 2 is a conservative cross-check — white must clear BOTH.
onSolidFailsContrast =
Math.abs(apcaLc(onSolidRgb, solidRgb)) < ON_SOLID_APCA_FLOOR ||
wcagContrastRatio(onSolidRgb, solidRgb) < ON_SOLID_WCAG_FLOOR
}
Theming: on-solid contrast by luminance (P2-2) + translucent role surfaces (P2-4) Two audit P2 quality defects, fixed at engine level. P2-2 - on-solid text illegible on light solids: The `contrast` slot defaulted to `--color-content-on-solid` (white) for every role. On light solids (amber/yellow, risk=orange ~2.3:1) white is sub-AA. The engine now picks by WCAG contrast (gamma-linearized) of the role step-9: when white fails (<3:1) it uses `--color-content-on-solid-contrast` (a dark, new OPTIONAL `content.onSolidContrast` semantic, #1c1917 in base). Only risk flips to dark in base (5.89:1); purple/red/teal/green keep white (convention, >=3:1). An explicit `slots.contrast` override is still honored verbatim. P2-4 - opaque tinted soft surfaces: The soft variant tint (Button + Badge `{role}-soft-bg`) was opaque (step-1 track + opaque color-mix hover) so it did not composite over non-uniform backgrounds. New derived tokens `--color-{role}-surface` (= a2) + `--color-{role}-surface-hover` (= a3) are translucent by construction (compositing-inverse alpha). Button/Badge soft consume them. Toast/Tabs untouched - they are cards, opacity is correct. - config-types: optional onSolidContrast + CONTENT_COLOR_OPTIONAL_KEYS. - config: content keySet allows the optional key; validator value-checks it. - render-css: wcagContrastRatio/wcagRelativeLuminance; luminance pick; emit on-solid-contrast + surface/surface-hover. - contract: on-solid-contrast + surface tokens per role. - themes/base: onSolidContrast #1c1917 (light + dark). - recipes/base: Button + Badge soft-bg -> surface, soft-bg-hover -> surface-hover. - docs: THEMING.md section 24 + section 22 item 2; audit P2-2/P2-4 resolved. Verified: npm run check (0 new errors), vitest eidos (0 new regressions), browser runtime (risk 5.89:1 dark text, surfaces translucent rgba). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
4 months ago
const defaultContrastToken = onSolidFailsContrast
? 'color-content-on-solid-contrast'
: 'color-content-on-solid'
for (const [slot, step] of Object.entries(slots)) {
const isDefaultContrast =
slot === 'contrast' &&
(typeof definition === 'string' || definition.slots?.contrast === undefined)
const value = isDefaultContrast
Theming: on-solid contrast by luminance (P2-2) + translucent role surfaces (P2-4) Two audit P2 quality defects, fixed at engine level. P2-2 - on-solid text illegible on light solids: The `contrast` slot defaulted to `--color-content-on-solid` (white) for every role. On light solids (amber/yellow, risk=orange ~2.3:1) white is sub-AA. The engine now picks by WCAG contrast (gamma-linearized) of the role step-9: when white fails (<3:1) it uses `--color-content-on-solid-contrast` (a dark, new OPTIONAL `content.onSolidContrast` semantic, #1c1917 in base). Only risk flips to dark in base (5.89:1); purple/red/teal/green keep white (convention, >=3:1). An explicit `slots.contrast` override is still honored verbatim. P2-4 - opaque tinted soft surfaces: The soft variant tint (Button + Badge `{role}-soft-bg`) was opaque (step-1 track + opaque color-mix hover) so it did not composite over non-uniform backgrounds. New derived tokens `--color-{role}-surface` (= a2) + `--color-{role}-surface-hover` (= a3) are translucent by construction (compositing-inverse alpha). Button/Badge soft consume them. Toast/Tabs untouched - they are cards, opacity is correct. - config-types: optional onSolidContrast + CONTENT_COLOR_OPTIONAL_KEYS. - config: content keySet allows the optional key; validator value-checks it. - render-css: wcagContrastRatio/wcagRelativeLuminance; luminance pick; emit on-solid-contrast + surface/surface-hover. - contract: on-solid-contrast + surface tokens per role. - themes/base: onSolidContrast #1c1917 (light + dark). - recipes/base: Button + Badge soft-bg -> surface, soft-bg-hover -> surface-hover. - docs: THEMING.md section 24 + section 22 item 2; audit P2-2/P2-4 resolved. Verified: npm run check (0 new errors), vitest eidos (0 new regressions), browser runtime (risk 5.89:1 dark text, surfaces translucent rgba). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
4 months ago
? `var(--${defaultContrastToken}, var(--primitive-${role}-${step}))`
: `var(--primitive-${role}-${step})`
declarations.push(cssVar(`color-${role}-${toKebab(slot)}`, value))
}
Theming: on-solid contrast by luminance (P2-2) + translucent role surfaces (P2-4) Two audit P2 quality defects, fixed at engine level. P2-2 - on-solid text illegible on light solids: The `contrast` slot defaulted to `--color-content-on-solid` (white) for every role. On light solids (amber/yellow, risk=orange ~2.3:1) white is sub-AA. The engine now picks by WCAG contrast (gamma-linearized) of the role step-9: when white fails (<3:1) it uses `--color-content-on-solid-contrast` (a dark, new OPTIONAL `content.onSolidContrast` semantic, #1c1917 in base). Only risk flips to dark in base (5.89:1); purple/red/teal/green keep white (convention, >=3:1). An explicit `slots.contrast` override is still honored verbatim. P2-4 - opaque tinted soft surfaces: The soft variant tint (Button + Badge `{role}-soft-bg`) was opaque (step-1 track + opaque color-mix hover) so it did not composite over non-uniform backgrounds. New derived tokens `--color-{role}-surface` (= a2) + `--color-{role}-surface-hover` (= a3) are translucent by construction (compositing-inverse alpha). Button/Badge soft consume them. Toast/Tabs untouched - they are cards, opacity is correct. - config-types: optional onSolidContrast + CONTENT_COLOR_OPTIONAL_KEYS. - config: content keySet allows the optional key; validator value-checks it. - render-css: wcagContrastRatio/wcagRelativeLuminance; luminance pick; emit on-solid-contrast + surface/surface-hover. - contract: on-solid-contrast + surface tokens per role. - themes/base: onSolidContrast #1c1917 (light + dark). - recipes/base: Button + Badge soft-bg -> surface, soft-bg-hover -> surface-hover. - docs: THEMING.md section 24 + section 22 item 2; audit P2-2/P2-4 resolved. Verified: npm run check (0 new errors), vitest eidos (0 new regressions), browser runtime (risk 5.89:1 dark text, surfaces translucent rgba). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
4 months ago
// Translucent role surface (P2-4): the soft / surface variant tint.
// Built from the compositing-inverse alpha steps so it composites
// correctly over non-uniform backgrounds (striped rows, images,
// gradients) — unlike an opaque step-1 / color-mix tint.
declarations.push(cssVar(`color-${role}-surface`, `var(--primitive-${role}-a2)`))
declarations.push(cssVar(`color-${role}-surface-hover`, `var(--primitive-${role}-a3)`))
}
appendThemeColorDeclarations(declarations, color)
if (theme.shadow) {
appendRecordDeclarations(declarations, 'shadow', theme.shadow)
}
return renderBlock(renderOptions.selector ?? getThemeSelector(themeId), declarations)
}
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.spaceScale, 'space-scale')
appendDensityRecordDeclarations(declarations, density.controlScale, 'control-scale')
appendActiveDensityDeclarations(declarations, 'comfortable')
}
function appendDensityRecordDeclarations(
declarations: string[],
record: Record<string, string | number>,
suffix: string
): void {
for (const [name, value] of Object.entries(record)) {
declarations.push(cssVar(`density-${name}-${suffix}`, value))
}
}
function renderDensityBlocks(): string {
return DENSITY_KEYS.filter((densityKey) => densityKey !== 'comfortable')
.map((densityKey) => {
const declarations: string[] = []
appendActiveDensityDeclarations(declarations, densityKey)
return renderBlock(`[data-density='${densityKey}']`, declarations)
})
.join('\n\n')
}
function appendActiveDensityDeclarations(declarations: string[], densityKey: string): void {
declarations.push(cssVar('density-space-scale', `var(--density-${densityKey}-space-scale)`))
declarations.push(cssVar('density-control-scale', `var(--density-${densityKey}-control-scale)`))
}
// Scaling = global zoom (Radix parity), orthogonal to density. Emits the
// `--scaling-{90..110}` constants + the active `--scaling` (default 100).
// `[data-scaling]` blocks (renderScalingBlocks) rebind `--scaling`.
function appendScalingDeclarations(declarations: string[]): void {
for (const [key, value] of Object.entries(STATIC_SCALING)) {
declarations.push(cssVar(`scaling-${key}`, value))
}
declarations.push(cssVar('scaling', `var(--scaling-${DEFAULT_SCALING})`))
}
function renderScalingBlocks(): string {
return SCALING_KEYS.filter((key) => key !== DEFAULT_SCALING)
.map((key) =>
renderBlock(`[data-scaling='${key}']`, [cssVar('scaling', `var(--scaling-${key})`)])
)
.join('\n\n')
}
// ── Motion ─────────────────────────────────────────────────────────────────
// Emits the registered `@keyframes` + per-preset rules selected by
// `data-animation-style` × `data-state` (enter→open / exit→closed), with
// `data-side`-aware variants. Only `css`-driver presets generate CSS; JS
// presets run through the motion engine (`uix.motion`). Reduced motion is honored per preset
// policy. Enter uses `fill: backwards` (no initial flash, no forwards
// accumulation); exit uses `fill: forwards` (hold the end state until soma's
// Presence unmounts the element).
const MOTION_PHASES = [
{ phase: 'enter', state: 'open', fill: 'backwards', dur: 'moderate', ease: 'out' },
{ phase: 'exit', state: 'closed', fill: 'forwards', dur: 'fast', ease: 'in' }
] as const
function renderMotionBlocks(motion: MotionConfig): string {
const blocks: string[] = []
// Per-component duration + easing overrides. A preset owns the SHAPE + a
// default timing/curve; a component (or theme) tunes its own enter/exit by
// setting these on its animated element — `phaseDeclarations` reads them with
// the preset token as fallback. `inherits: false` so a preset component nested
// inside another doesn't inherit the ancestor's values.
for (const phase of ['enter', 'exit'] as const) {
blocks.push(`@property --motion-duration-${phase} {\n\tsyntax: '*';\n\tinherits: false;\n}`)
blocks.push(`@property --motion-ease-${phase} {\n\tsyntax: '*';\n\tinherits: false;\n}`)
}
// Stagger index is per-item — `inherits: false` stops it leaking into nested
// groups; the rhythm (`--motion-stagger-each`) inherits from the container.
blocks.push(
"@property --motion-stagger-index {\n\tsyntax: '<integer>';\n\tinherits: false;\n\tinitial-value: 0;\n}"
)
for (const [name, stops] of Object.entries(motion.keyframes ?? {})) {
blocks.push(renderKeyframes(name, stops))
}
// Momento --event: la firma perceptiva (genérica por family/intent/event).
for (const [name, signature] of Object.entries(motion.signatures ?? {})) {
blocks.push(...renderSignatureRules(name, signature))
}
// Momento --state: presets de transición per-componente (data-state).
for (const [name, preset] of Object.entries(motion.presets ?? {})) {
if (preset.driver !== 'css') continue
blocks.push(...renderCssPresetRules(name, preset))
blocks.push(...renderReducedMotionRules(name, preset))
}
return blocks.join('\n\n')
}
function renderKeyframes(name: KeyframeName, stops: KeyframeStops): string {
const body = Object.entries(stops)
.map(([stop, decls]) => {
const inner = Object.entries(decls)
.map(([prop, value]) => `\t\t${prop}: ${value};`)
.join('\n')
return `\t${stop} {\n${inner}\n\t}`
})
.join('\n')
return `@keyframes ${name} {\n${body}\n}`
}
function renderCssPresetRules(name: string, preset: CssStatePreset): string[] {
const rules: string[] = []
for (const { phase, state, fill, dur, ease } of MOTION_PHASES) {
const cssPhase = preset[phase]
if (!cssPhase) continue
rules.push(
renderBlock(
`[data-animation-style='${name}'][data-state='${state}']`,
phaseDeclarations(cssPhase, cssPhase.keyframes, fill, dur, ease, phase)
)
)
for (const [side, keyframes] of Object.entries(cssPhase.bySide ?? {})) {
rules.push(
renderBlock(
`[data-animation-style='${name}'][data-side='${side}'][data-state='${state}']`,
phaseDeclarations(cssPhase, keyframes, fill, dur, ease, phase)
)
)
}
}
return rules
}
function phaseDeclarations(
cssPhase: CssPhase,
keyframes: KeyframeName | readonly KeyframeName[],
fill: string,
defaultDuration: string,
defaultEase: string,
phase: string
): string[] {
const names = typeof keyframes === 'string' ? [keyframes] : keyframes
// Per-component overrides (`--motion-{duration,ease}-{enter,exit}`) → preset token.
const dur = `var(--motion-duration-${phase}, var(--duration-${cssPhase.duration ?? defaultDuration}))`
const ease = `var(--motion-ease-${phase}, var(--ease-${cssPhase.ease ?? defaultEase}))`
const animation = names.map((kf) => `${kf} ${dur} ${ease} ${fill}`).join(', ')
const declarations = [`animation: ${animation};`]
// Stagger (Material list choreography, zero JS orchestration): a container
// sets `--motion-stagger-each` (rhythm) and each item `--motion-stagger-index`,
// so each item's enter is delayed by index × each. Default 0 → no stagger; the
// `backwards` fill holds the `from` state until each item's turn.
if (phase === 'enter') {
declarations.push(
'animation-delay: calc(var(--motion-stagger-index, 0) * var(--motion-stagger-each, 0ms));'
)
}
if (cssPhase.transformOrigin) {
declarations.push(`transform-origin: ${cssPhase.transformOrigin};`)
}
return declarations
}
// Reduced motion: 'none' runs as-is (a pure fade is acceptable); 'instant'
// kills the animation (snap to the steady `[data-state]` style); 'opacity-only'
// keeps a brief fade and drops transforms. Emitted both under the projected
// `[data-motion='reduce']` and the media query, so it works with or without
// the prefs DOM projection. `!important` beats the bySide variants.
function renderReducedMotionRules(name: string, preset: CssStatePreset): string[] {
const policy = preset.reduce ?? 'none'
if (policy === 'none') return []
const rules: string[] = []
for (const { phase, state } of MOTION_PHASES) {
if (!preset[phase]) continue
const inner =
policy === 'instant'
? ['animation: none !important;']
: phase === 'enter'
? ['animation: fade-in var(--duration-fast) var(--ease-out) backwards !important;']
: ['animation: fade-out var(--duration-fast) var(--ease-in) forwards !important;']
const selector = `[data-animation-style='${name}'][data-state='${state}']`
rules.push(renderBlock(`[data-motion='reduce'] ${selector}`, inner))
rules.push(
`@media (prefers-reduced-motion: reduce) {\n${indentBlock(renderBlock(selector, inner))}\n}`
)
}
return rules
}
// ── Momento --event: la firma perceptiva (genérica por evento) ───────────────
// Reacciona a `data-event-*` durante el hold de sema. El selector se construye
// por `family` / `intent` / `event`; es lo que hoy hace `events.css` a mano
// (F2 migrará su contenido aquí). Es el canal `motion` de la firma (GUIA §11).
function signatureSelectorList(sig: EventSignature): string[] {
const suffix =
`${sig.family ? `[data-event-family='${sig.family}']` : ''}` +
`${sig.intent ? `[data-event-intent='${sig.intent}']` : ''}` +
`[data-event-phase='active']`
const events =
sig.event === undefined ? [undefined] : typeof sig.event === 'string' ? [sig.event] : sig.event
return events.map((e) => `${e ? `[data-event^='${e}']` : ''}${suffix}`)
}
// Duración: un token conocido → `var(--duration-X)`; cualquier otra cosa (un
// hold crudo como `600ms`) se emite tal cual. F6 tokenizará los crudos.
function resolveDurationValue(value: string): string {
return (DURATION_KEYS as readonly string[]).includes(value) ? `var(--duration-${value})` : value
}
function renderSignatureRules(_name: string, sig: EventSignature): string[] {
const names = typeof sig.keyframes === 'string' ? [sig.keyframes] : sig.keyframes
const dur = resolveDurationValue(sig.duration ?? 'moderate')
const ease = `var(--ease-${sig.ease ?? 'out'})`
const fill = sig.fill && sig.fill !== 'none' ? ` ${sig.fill}` : ''
const animation = names.map((kf) => `${kf} ${dur} ${ease}${fill}`).join(', ')
const selectors = signatureSelectorList(sig)
const rules = [renderBlock(selectors.join(',\n'), [`animation: ${animation};`])]
const policy = sig.reduce ?? 'none'
if (policy !== 'none') {
const inner =
policy === 'instant'
? ['animation: none !important;']
: ['animation: fade-in var(--duration-fast) var(--ease-out) !important;']
rules.push(
renderBlock(selectors.map((s) => `[data-motion='reduce'] ${s}`).join(',\n'), inner)
)
rules.push(
`@media (prefers-reduced-motion: reduce) {\n${indentBlock(renderBlock(selectors.join(',\n'), inner))}\n}`
)
}
return rules
}
function appendTypographyDeclarations(
declarations: string[],
typography: TypographyPrimitiveSet
): void {
for (const [name, family] of Object.entries(typography.families)) {
declarations.push(cssVar(`font-family-${name}`, formatFontFamily(family)))
}
for (const [name, metric] of Object.entries(typography.sizes)) {
// font-size scales with the global `--scaling` zoom (px). line-height is a
// unitless ratio — it scales implicitly via font-size; scaling it here too
// would double-scale. letter-spacing stays as authored.
declarations.push(cssVar(`font-size-${name}`, `calc(${metric.size} * var(--scaling))`))
declarations.push(cssVar(`font-line-height-${name}`, metric.lineHeight))
declarations.push(cssVar(`font-letter-spacing-${name}`, metric.letterSpacing))
}
for (const [name, weight] of Object.entries(typography.weights)) {
declarations.push(cssVar(`font-weight-${name}`, weight))
}
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)'))
}
}
feat(eidos): TSC v2.2 (parts + composition) + universal theming coverage Token Scope Contract universal — no más excepciones arquitectónicas. Los 3 componentes que vivían fuera de TSC v2.1 (select, avatar, toggle-group) ahora están dentro del contrato via dos extensiones nuevas. TSC v2.2 extensiones (lib/config-types.ts + render-css.ts + config.ts): - `parts: readonly string[]` en RecipeTokenMultiDeclaration — emite selectores comma-separados (`[data-{c}-x], [data-{c}-y]`) para componentes con data-color cascadeado per-part. Consumer: select. - `composition: { foreignRecipe: { targetSelector, tokens } }` sibling key — overrides cross-recipe scoped a la cascade del host. Consumer: toggle-group modifica `--toggle-palette-*` en sus items. Migraciones: - select: 3 private `_accent-{track,border,text}` con parts: ['trigger', 'content'] + 7 cascades color:X each. Removed orphan `_accent-solid` (CSS no consumía). - avatar: 6 tokens via composite scopes `['variant:X', 'color:Y']` con matrix helper inline. Badge usa parts: ['badge']. Reemplaza 24 bloques CSS × 2 partes. - toggle-group: composition block con 8 palette tokens × 4 colors. Reemplaza 4 bloques CSS per-color. Bug toggle-group post-composition (encontrado y arreglado): Tras la composition migration, el cascade del toggle-group seguía roto porque los tokens derivados (`--toggle-solid-on-bg`, `--toggle-outline-fg`, etc.) viven en scope `[data-toggle]`. El `[data-toggle-group-item]` es sibling (no descendant), así que `var(--toggle-solid-on-bg)` resolvía undefined. Fix: inlined derivation expressions directamente en `[data-toggle-group-item]` y sus variant cascades (solid/outline/ghost), referenciando palette tokens en su propio scope local. Validador + emisor + contract: - `validateRecipeComposition` valida el shape `{ targetSelector, tokens }` y rechaza composition entries con scope='root'. - `stripCompositionKey` + `emitComposition` separan el pipeline. - `appendRecipeContractTokens` skip-list para `composition` (no aparece como fake `--{c}-composition` knob). - `tokenKeys`/`tokenEntries` helpers en recipe-css-contract.test.ts filtran composition en todos los iteradores. Documentación: - THEMING.md §18 reescrito como "Cobertura universal de TSC". §7 extendido con subsecciones "Multi-part scope" y "Cross-recipe composition" + ejemplos completos. TOC actualizado. - eidos/README.md tabla de referencia ampliada con TSC v2.2 + §18. - CLAUDE.md gana hand-off "2026-05-27 #5" (TSC v2.2 + cobertura universal). - CONTINUE.md reescrito al estado actual de la sesión. Working tree también incluye sprint Words en paralelo (multiple authors): slash menu, find/replace regex, code language picker, table audit, toolbar family menu, code highlight engine. Tests: 786/786 pass en src/uix/{eidos,morfo,soma,sema}. `npm run check`: 6 errores pre-existentes (lib/_demo, soma/components/internal, web/routes/active) no relacionados. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
5 months ago
/**
* Append `:root`-scoped recipe declarations to the caller's list and return
* additional CSS blocks for scoped tokens.
*
* Token Scope Contract (TSC) v2 — three guarantees:
*
* 1. **Automatic dependency inference**: the generator parses
* `var(--{component}-XXX)` references in every token's value and treats
* `XXX` as a dependency. Adding `depends: [...]` is a manual supplement;
* you do not need it to catch the eager-resolution bug.
*
* 2. **Scope algebra**: a token's scope must "cover" every dependency's
* scope. `root` covers nothing but root; `host` covers root+host;
* leaf scope `axis:value` covers `root`, `host`, and the same
* `axis:value`. Composite leaf scopes (arrays) cover any subset.
*
* 3. **Cross-axis collision detection**: if a token has multiple atomic
* declarations on incomparable axes (e.g. `color:affirm` AND
* `state:on`), the generator requires an explicit composite
* declaration `[color:affirm, state:on]` to disambiguate cascade
* order when an element matches both. Without that composite, the
* cascade winner depends on declaration source order — a silent
* correctness bug.
*
* All three are enforced at generation time. Violations throw with a
* clear message naming the offending tokens.
*/
function appendRecipeDeclarations(
declarations: string[],
recipes: RecipeTokenSet | undefined
feat(eidos): TSC v2.2 (parts + composition) + universal theming coverage Token Scope Contract universal — no más excepciones arquitectónicas. Los 3 componentes que vivían fuera de TSC v2.1 (select, avatar, toggle-group) ahora están dentro del contrato via dos extensiones nuevas. TSC v2.2 extensiones (lib/config-types.ts + render-css.ts + config.ts): - `parts: readonly string[]` en RecipeTokenMultiDeclaration — emite selectores comma-separados (`[data-{c}-x], [data-{c}-y]`) para componentes con data-color cascadeado per-part. Consumer: select. - `composition: { foreignRecipe: { targetSelector, tokens } }` sibling key — overrides cross-recipe scoped a la cascade del host. Consumer: toggle-group modifica `--toggle-palette-*` en sus items. Migraciones: - select: 3 private `_accent-{track,border,text}` con parts: ['trigger', 'content'] + 7 cascades color:X each. Removed orphan `_accent-solid` (CSS no consumía). - avatar: 6 tokens via composite scopes `['variant:X', 'color:Y']` con matrix helper inline. Badge usa parts: ['badge']. Reemplaza 24 bloques CSS × 2 partes. - toggle-group: composition block con 8 palette tokens × 4 colors. Reemplaza 4 bloques CSS per-color. Bug toggle-group post-composition (encontrado y arreglado): Tras la composition migration, el cascade del toggle-group seguía roto porque los tokens derivados (`--toggle-solid-on-bg`, `--toggle-outline-fg`, etc.) viven en scope `[data-toggle]`. El `[data-toggle-group-item]` es sibling (no descendant), así que `var(--toggle-solid-on-bg)` resolvía undefined. Fix: inlined derivation expressions directamente en `[data-toggle-group-item]` y sus variant cascades (solid/outline/ghost), referenciando palette tokens en su propio scope local. Validador + emisor + contract: - `validateRecipeComposition` valida el shape `{ targetSelector, tokens }` y rechaza composition entries con scope='root'. - `stripCompositionKey` + `emitComposition` separan el pipeline. - `appendRecipeContractTokens` skip-list para `composition` (no aparece como fake `--{c}-composition` knob). - `tokenKeys`/`tokenEntries` helpers en recipe-css-contract.test.ts filtran composition en todos los iteradores. Documentación: - THEMING.md §18 reescrito como "Cobertura universal de TSC". §7 extendido con subsecciones "Multi-part scope" y "Cross-recipe composition" + ejemplos completos. TOC actualizado. - eidos/README.md tabla de referencia ampliada con TSC v2.2 + §18. - CLAUDE.md gana hand-off "2026-05-27 #5" (TSC v2.2 + cobertura universal). - CONTINUE.md reescrito al estado actual de la sesión. Working tree también incluye sprint Words en paralelo (multiple authors): slash menu, find/replace regex, code language picker, table audit, toolbar family menu, code highlight engine. Tests: 786/786 pass en src/uix/{eidos,morfo,soma,sema}. `npm run check`: 6 errores pre-existentes (lib/_demo, soma/components/internal, web/routes/active) no relacionados. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
5 months ago
): string[] {
if (!recipes) return []
const blocks: string[] = []
const allViolations: string[] = []
for (const [component, tokens] of Object.entries(recipes)) {
feat(eidos): TSC v2.2 (parts + composition) + universal theming coverage Token Scope Contract universal — no más excepciones arquitectónicas. Los 3 componentes que vivían fuera de TSC v2.1 (select, avatar, toggle-group) ahora están dentro del contrato via dos extensiones nuevas. TSC v2.2 extensiones (lib/config-types.ts + render-css.ts + config.ts): - `parts: readonly string[]` en RecipeTokenMultiDeclaration — emite selectores comma-separados (`[data-{c}-x], [data-{c}-y]`) para componentes con data-color cascadeado per-part. Consumer: select. - `composition: { foreignRecipe: { targetSelector, tokens } }` sibling key — overrides cross-recipe scoped a la cascade del host. Consumer: toggle-group modifica `--toggle-palette-*` en sus items. Migraciones: - select: 3 private `_accent-{track,border,text}` con parts: ['trigger', 'content'] + 7 cascades color:X each. Removed orphan `_accent-solid` (CSS no consumía). - avatar: 6 tokens via composite scopes `['variant:X', 'color:Y']` con matrix helper inline. Badge usa parts: ['badge']. Reemplaza 24 bloques CSS × 2 partes. - toggle-group: composition block con 8 palette tokens × 4 colors. Reemplaza 4 bloques CSS per-color. Bug toggle-group post-composition (encontrado y arreglado): Tras la composition migration, el cascade del toggle-group seguía roto porque los tokens derivados (`--toggle-solid-on-bg`, `--toggle-outline-fg`, etc.) viven en scope `[data-toggle]`. El `[data-toggle-group-item]` es sibling (no descendant), así que `var(--toggle-solid-on-bg)` resolvía undefined. Fix: inlined derivation expressions directamente en `[data-toggle-group-item]` y sus variant cascades (solid/outline/ghost), referenciando palette tokens en su propio scope local. Validador + emisor + contract: - `validateRecipeComposition` valida el shape `{ targetSelector, tokens }` y rechaza composition entries con scope='root'. - `stripCompositionKey` + `emitComposition` separan el pipeline. - `appendRecipeContractTokens` skip-list para `composition` (no aparece como fake `--{c}-composition` knob). - `tokenKeys`/`tokenEntries` helpers en recipe-css-contract.test.ts filtran composition en todos los iteradores. Documentación: - THEMING.md §18 reescrito como "Cobertura universal de TSC". §7 extendido con subsecciones "Multi-part scope" y "Cross-recipe composition" + ejemplos completos. TOC actualizado. - eidos/README.md tabla de referencia ampliada con TSC v2.2 + §18. - CLAUDE.md gana hand-off "2026-05-27 #5" (TSC v2.2 + cobertura universal). - CONTINUE.md reescrito al estado actual de la sesión. Working tree también incluye sprint Words en paralelo (multiple authors): slash menu, find/replace regex, code language picker, table audit, toolbar family menu, code highlight engine. Tests: 786/786 pass en src/uix/{eidos,morfo,soma,sema}. `npm run check`: 6 errores pre-existentes (lib/_demo, soma/components/internal, web/routes/active) no relacionados. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
5 months ago
// `composition` is a reserved sibling key on the recipe map (TSC
// v2.2 cross-recipe overrides) — not a regular token. Strip it
// before normalizing and process it separately at the end of
// this iteration so it composes onto the same `blocks` array.
const composition = (tokens as { composition?: Readonly<Record<string, RecipeComposition>> })
.composition
const tokensOnly = stripCompositionKey(tokens)
// Phase 1: normalize every token to a list of declarations.
const normalized = normalizeRecipeTokens(component, tokensOnly)
// Phase 2: collect scope-algebra violations across all declarations.
allViolations.push(...collectScopeViolations(component, normalized))
// Phase 3: detect cross-axis collisions per token.
allViolations.push(...collectCrossAxisCollisions(component, normalized))
// Phase 4: emit declarations by scope. The 'root' bucket appends
// to the caller's :root declarations; everything else is a block.
//
// Token visibility convention (TSC v2.1):
// key starts with '_' → private token, emitted as `--_{c}-{rest}`
// key does not → public token, emitted as `--{c}-{name}`
//
// Private tokens stay out of the public CSS contract (contract.ts
// skips them) and follow the `--_{c}-*` naming convention used by
// recipes to mark "internal machinery, do not consume externally".
//
// Multi-part scope (TSC v2.2): when a normalized declaration carries
// `parts: [...]`, the scope selector is built from a comma-separated
// list of `[data-{c}-{part}]` instead of the default `[data-{c}]`
// root. Used for components whose `data-color` attribute lives on
// parts (Select cascades via Trigger + Content), not on the root.
// Bucket-key includes parts so two scope-identical declarations
// targeting different parts emit as separate blocks.
const byScopeKey = new Map<
string,
{ scope: TokenScope; parts: readonly string[] | undefined; lines: string[] }
>()
for (const [name, decls] of normalized) {
const cssVarName = name.startsWith('_')
? `_${component}-${name.slice(1)}`
: `${component}-${name}`
for (const decl of decls) {
const key = `${scopeKey(decl.scope)}@${(decl.parts ?? []).join(',')}`
const bucket = byScopeKey.get(key) ?? {
scope: decl.scope,
parts: decl.parts,
lines: []
}
bucket.lines.push(cssVar(cssVarName, decl.value))
byScopeKey.set(key, bucket)
}
}
const rootKey = `${scopeKey('root')}@`
const rootBucket = byScopeKey.get(rootKey)
if (rootBucket) declarations.push(...rootBucket.lines)
for (const [key, { scope, parts, lines }] of byScopeKey) {
if (key === rootKey) continue
blocks.push(renderBlock(recipeScopeSelector(component, scope, parts), lines))
}
// Phase 5: cross-recipe composition (TSC v2.2). The host recipe
// declares overrides of FOREIGN tokens scoped to its own
// conditions, with a `targetSelector` to reach the foreign part.
if (composition) {
blocks.push(...emitComposition(component, composition))
}
}
if (allViolations.length) {
throw new Error(
`Eidos recipe scope contract violations:\n - ${allViolations.join('\n - ')}`
)
}
return blocks
}
/**
* Strip the reserved `composition` key from a recipe token map so the
* normalizer only sees regular token entries. Returns a plain object —
* the original `Readonly` shape is irrelevant at runtime.
*/
function stripCompositionKey(
tokens: Readonly<Record<string, unknown>>
): Readonly<Record<string, RecipeTokenValue>> {
const out: Record<string, RecipeTokenValue> = {}
for (const [key, value] of Object.entries(tokens)) {
if (key === 'composition') continue
out[key] = value as RecipeTokenValue
}
return out
}
/**
* Emit blocks for a recipe's cross-recipe composition (TSC v2.2).
*
* For each `{foreignComponent: { targetSelector, tokens }}` entry:
* - the CSS variable names are derived from the FOREIGN component
* (`--{foreign}-{tokenName}` for public, `--_{foreign}-{tokenName}`
* for private — leading `_` in the token key).
* - the selector is the HOST component's scope (e.g.
* `[data-toggle-group][data-color='X']`) combined with the
* `targetSelector` as a descendant.
*
* Composition tokens MUST declare a non-root scope — if they applied at
* `:root` they'd be regular foundation tokens of the foreign recipe, not
* composition overrides. The host's purpose is to RESTRICT the override
* to its own cascade.
*/
function emitComposition(
hostComponent: string,
composition: Readonly<Record<string, RecipeComposition>>
): string[] {
const blocks: string[] = []
for (const [foreignComponent, entry] of Object.entries(composition)) {
const { targetSelector, tokens } = entry
// Group by host-scope so a single rule covers all foreign-token
// overrides declared at the same host scope.
const byScopeKey = new Map<string, { scope: TokenScope; lines: string[] }>()
for (const [tokenName, multi] of Object.entries(tokens)) {
const cssVarName = tokenName.startsWith('_')
? `_${foreignComponent}-${tokenName.slice(1)}`
: `${foreignComponent}-${tokenName}`
for (const decl of multi.declarations) {
const normalizedScope = normalizeScopeAlias(decl.scope ?? 'root')
if (scopeKey(normalizedScope) === scopeKey('root')) {
throw new Error(
`Eidos recipe composition: ${hostComponent}.composition.${foreignComponent}.${tokenName} ` +
`has a 'root' declaration. Composition overrides MUST be scoped — ` +
`a root override belongs in the foreign recipe itself.`
)
}
const key = scopeKey(normalizedScope)
const bucket = byScopeKey.get(key) ?? { scope: normalizedScope, lines: [] }
bucket.lines.push(cssVar(cssVarName, decl.value))
byScopeKey.set(key, bucket)
}
}
for (const { scope, lines } of byScopeKey.values()) {
const hostSelector = recipeScopeSelector(hostComponent, scope)
blocks.push(renderBlock(`${hostSelector} ${targetSelector}`, lines))
}
}
return blocks
}
/** Map of token name → list of normalized declarations (one per scope). */
type NormalizedDeclarations = Map<string, NormalizedDeclaration[]>
interface NormalizedDeclaration {
readonly value: string
readonly scope: TokenScope
readonly depends: readonly string[]
/**
* TSC v2.2 — parts the declaration cascades on. When set, the scope
* selector is built from comma-joined `[data-{c}-{part}]` instead of
* the default `[data-{c}]`. Inherited from the `RecipeTokenMultiDeclaration`
* level (all declarations of a multi-decl token share the same parts).
*/
readonly parts?: readonly string[]
}
/**
* Normalize a component's tokens: convert each entry to its list of
* declarations, infer var()-driven dependencies, merge explicit deps.
*/
function normalizeRecipeTokens(
component: string,
tokens: Readonly<Record<string, RecipeTokenValue>>
): NormalizedDeclarations {
const result: NormalizedDeclarations = new Map()
// Pre-collect the set of names declared in this recipe so dep inference
// only counts refs to fellow recipe tokens. A `var(--icon-stroke-width-md)`
// inside the icon recipe is NOT a dep on `stroke-width-md` if that name
// belongs to the primitives layer (not to the icon recipe itself).
const declaredNames = new Set(Object.keys(tokens))
for (const [name, raw] of Object.entries(tokens)) {
const { declarations, parts } = expandToDeclarationsWithParts(raw)
const decls: NormalizedDeclaration[] = declarations.map((decl) => ({
value: decl.value,
scope: normalizeScopeAlias(decl.scope ?? 'root'),
depends: mergeInferredAndExplicitDeps(
decl.value,
component,
decl.depends ?? [],
declaredNames
),
parts
}))
result.set(name, decls)
}
return result
}
/** Expand a recipe token entry into its list of raw declarations + parts. */
function expandToDeclarationsWithParts(raw: RecipeTokenValue): {
declarations: RecipeTokenDeclaration[]
parts: readonly string[] | undefined
} {
if (typeof raw !== 'object') {
return {
declarations: [{ value: String(raw), scope: 'root', depends: [] }],
parts: undefined
}
}
if ('declarations' in raw) {
return {
declarations: [...raw.declarations],
parts: (raw as RecipeTokenMultiDeclaration).parts
}
}
return { declarations: [raw], parts: undefined }
}
/**
* `palette:X` is a deprecated alias for `color:X`. Both target the same
* `data-color` DOM attribute. The canonical name is `color:*` because it
* names the DOM axis, not the internal "palette" indirection.
*/
function normalizeScopeAlias(scope: TokenScope): TokenScope {
if (typeof scope === 'string' && scope.startsWith('palette:')) {
return `color:${scope.slice('palette:'.length)}` as AtomicScope
}
if (Array.isArray(scope)) {
return scope.map((atom) =>
atom.startsWith('palette:')
? (`color:${atom.slice('palette:'.length)}` as LeafScope)
: atom
)
}
return scope
}
/**
* Find every `var(--{component}-XXX)` and `var(--_{component}-XXX)` in
* a value and return the `XXX` names (prefixed with `_` for private refs).
* Combined with explicit `depends`, this is the full dependency set the
* generator validates.
*
* Examples for component `toggle`:
* value `'var(--toggle-palette-solid)'` → infers `palette-solid` (public)
* value `'var(--_toggle-bg)'` → infers `_bg` (private)
* value `'var(--color-affirm-solid)'` → infers nothing (external prefix)
*/
const VAR_REF_REGEX = /var\(\s*--([a-z_][a-z0-9_-]*)/g
function inferDepsFromValue(value: string, componentPrefix: string): string[] {
const publicPrefix = `${componentPrefix}-`
const privatePrefix = `_${componentPrefix}-`
const inferred: string[] = []
for (const match of value.matchAll(VAR_REF_REGEX)) {
const fullName = match[1]
if (fullName.startsWith(publicPrefix)) {
inferred.push(fullName.slice(publicPrefix.length))
} else if (fullName.startsWith(privatePrefix)) {
// Private dep — the recipe key is `_{rest}` (with leading underscore)
inferred.push(`_${fullName.slice(privatePrefix.length)}`)
}
}
return inferred
}
function mergeInferredAndExplicitDeps(
value: string,
component: string,
explicit: readonly string[],
declaredNames: ReadonlySet<string>
): readonly string[] {
const merged = new Set<string>()
for (const inferred of inferDepsFromValue(value, component)) {
// Only count it as a dep if the referenced name actually belongs
// to THIS recipe. Otherwise the ref points to a primitive / theme
// token / cross-recipe alias, none of which TSC tracks.
if (declaredNames.has(inferred)) merged.add(inferred)
}
for (const dep of explicit) merged.add(dep)
return [...merged]
}
/** A canonical string key for de-duplicating scopes in maps. */
function scopeKey(scope: TokenScope): string {
if (typeof scope === 'string') return scope
return [...scope].sort().join('+')
}
/**
* Build the CSS selector that materialises a TSC scope for a component.
*
* When `parts` is provided (TSC v2.2 multi-part scope), the root selector
* becomes a comma-separated list of `[data-{component}-{part}]` instead of
* the default `[data-{component}]`. Each part gets the same leaf
* fragments appended. Example:
*
* recipeScopeSelector('select', 'color:affirm', ['trigger', 'content'])
* → "[data-select-trigger][data-color='affirm'], [data-select-content][data-color='affirm']"
*/
function recipeScopeSelector(
component: string,
scope: TokenScope,
parts?: readonly string[]
): string {
if (scope === 'root') return ':root'
const hosts = parts && parts.length > 0
? parts.map((part) => `[data-${component}-${part}]`)
: [`[data-${component}]`]
if (scope === 'host') return hosts.join(', ')
const fragments =
typeof scope === 'string'
? [atomicLeafFragment(scope as LeafScope)]
: [...scope].sort().map((atom) => atomicLeafFragment(atom))
const tail = fragments.join('')
return hosts.map((host) => `${host}${tail}`).join(', ')
}
function atomicLeafFragment(scope: LeafScope): string {
const sepIdx = scope.indexOf(':')
const axis = scope.slice(0, sepIdx)
const value = scope.slice(sepIdx + 1)
// `palette` is the deprecated alias for `color` — normalization above
// should have converted it, but be defensive.
const dataAttr = axis === 'palette' ? 'color' : axis
return `[data-${dataAttr}='${value}']`
}
// ── Scope algebra ─────────────────────────────────────────────────────────
/**
* Normalize a TokenScope into a set of axis constraints. Each axis can
* appear at most once. `root` is the empty set; `host` is `{host:true}`;
* leaves add their axis; composites merge multiple axes.
*/
interface ScopeSet {
host: boolean
color?: string
variant?: string
state?: string
size?: string
event?: string
}
function toScopeSet(scope: TokenScope): ScopeSet {
const set: ScopeSet = { host: false }
const atoms: LeafScope[] = []
if (scope === 'root') return set
if (scope === 'host') {
set.host = true
return set
}
if (typeof scope === 'string') {
atoms.push(scope as LeafScope)
} else {
atoms.push(...scope)
}
set.host = true
for (const atom of atoms) {
const sepIdx = atom.indexOf(':')
const axis = atom.slice(0, sepIdx) as keyof Omit<ScopeSet, 'host'>
const value = atom.slice(sepIdx + 1)
if (set[axis] !== undefined && set[axis] !== value) {
throw new Error(
`composite scope cannot bind axis '${axis}' to two values: ` +
`'${set[axis]}' and '${value}'`
)
}
set[axis] = value
}
return set
}
/**
* True iff the consumer's scope is at least as specific as the dep's —
* meaning: every constraint the dep places on an element must also be
* placed by the consumer (or be trivially satisfied because dep is at
* root/host).
*
* consumer scopeCovers dep ⇔ consumer's elements ⊆ dep's elements
*/
function scopeCovers(consumer: TokenScope, dep: TokenScope): boolean {
const consumerSet = toScopeSet(consumer)
const depSet = toScopeSet(dep)
if (depSet.host && !consumerSet.host) return false
for (const axis of ['color', 'variant', 'state', 'size', 'event'] as const) {
const depValue = depSet[axis]
if (depValue === undefined) continue
if (consumerSet[axis] !== depValue) return false
}
return true
}
function describeScope(scope: TokenScope): string {
if (typeof scope === 'string') return scope
return `[${[...scope].sort().join(', ')}]`
}
/**
* For every declaration of every token, check that all its dependencies
* are reachable from its scope.
*/
function collectScopeViolations(
component: string,
normalized: NormalizedDeclarations
): string[] {
const issues: string[] = []
for (const [name, decls] of normalized) {
for (const decl of decls) {
for (const depName of decl.depends) {
const depDecls = normalized.get(depName)
if (!depDecls) {
issues.push(
`${component}.${name} (scope ${describeScope(decl.scope)}): depends on ` +
`'${depName}' which is not declared in the recipe`
)
continue
}
// At least ONE declaration of the dep must cover the consumer's scope.
// (The dep can be split across multiple scopes — we only need ONE that
// applies wherever the consumer applies.)
const covered = depDecls.some((depDecl) => scopeCovers(decl.scope, depDecl.scope))
if (!covered) {
const depScopes = depDecls.map((d) => describeScope(d.scope)).join(', ')
issues.push(
`${component}.${name} (scope ${describeScope(decl.scope)}): ` +
`dependency '${depName}' is only declared at scopes [${depScopes}], ` +
`none of which is reachable from the consumer's scope. ` +
`The var() reference would resolve in a context where the dep is missing.`
)
}
}
}
}
feat(eidos): TSC v2.2 (parts + composition) + universal theming coverage Token Scope Contract universal — no más excepciones arquitectónicas. Los 3 componentes que vivían fuera de TSC v2.1 (select, avatar, toggle-group) ahora están dentro del contrato via dos extensiones nuevas. TSC v2.2 extensiones (lib/config-types.ts + render-css.ts + config.ts): - `parts: readonly string[]` en RecipeTokenMultiDeclaration — emite selectores comma-separados (`[data-{c}-x], [data-{c}-y]`) para componentes con data-color cascadeado per-part. Consumer: select. - `composition: { foreignRecipe: { targetSelector, tokens } }` sibling key — overrides cross-recipe scoped a la cascade del host. Consumer: toggle-group modifica `--toggle-palette-*` en sus items. Migraciones: - select: 3 private `_accent-{track,border,text}` con parts: ['trigger', 'content'] + 7 cascades color:X each. Removed orphan `_accent-solid` (CSS no consumía). - avatar: 6 tokens via composite scopes `['variant:X', 'color:Y']` con matrix helper inline. Badge usa parts: ['badge']. Reemplaza 24 bloques CSS × 2 partes. - toggle-group: composition block con 8 palette tokens × 4 colors. Reemplaza 4 bloques CSS per-color. Bug toggle-group post-composition (encontrado y arreglado): Tras la composition migration, el cascade del toggle-group seguía roto porque los tokens derivados (`--toggle-solid-on-bg`, `--toggle-outline-fg`, etc.) viven en scope `[data-toggle]`. El `[data-toggle-group-item]` es sibling (no descendant), así que `var(--toggle-solid-on-bg)` resolvía undefined. Fix: inlined derivation expressions directamente en `[data-toggle-group-item]` y sus variant cascades (solid/outline/ghost), referenciando palette tokens en su propio scope local. Validador + emisor + contract: - `validateRecipeComposition` valida el shape `{ targetSelector, tokens }` y rechaza composition entries con scope='root'. - `stripCompositionKey` + `emitComposition` separan el pipeline. - `appendRecipeContractTokens` skip-list para `composition` (no aparece como fake `--{c}-composition` knob). - `tokenKeys`/`tokenEntries` helpers en recipe-css-contract.test.ts filtran composition en todos los iteradores. Documentación: - THEMING.md §18 reescrito como "Cobertura universal de TSC". §7 extendido con subsecciones "Multi-part scope" y "Cross-recipe composition" + ejemplos completos. TOC actualizado. - eidos/README.md tabla de referencia ampliada con TSC v2.2 + §18. - CLAUDE.md gana hand-off "2026-05-27 #5" (TSC v2.2 + cobertura universal). - CONTINUE.md reescrito al estado actual de la sesión. Working tree también incluye sprint Words en paralelo (multiple authors): slash menu, find/replace regex, code language picker, table audit, toolbar family menu, code highlight engine. Tests: 786/786 pass en src/uix/{eidos,morfo,soma,sema}. `npm run check`: 6 errores pre-existentes (lib/_demo, soma/components/internal, web/routes/active) no relacionados. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
5 months ago
return issues
}
/**
* A token with multiple atomic declarations on different leaf axes whose
* elements could co-apply must declare a composite override for the
* intersection — otherwise cascade winner is source-order-dependent
* (silent correctness bug).
*
* Example: `bg` declared at `color:affirm` AND `state:on`. An element
* with both `data-color='affirm' data-state='on'` matches both blocks;
* which wins depends on which `[data-c][data-color='affirm']` vs
* `[data-c][data-state='on']` appears later in the stylesheet. The
* generator demands a composite `[color:affirm, state:on]` decl so
* intent is explicit.
*/
function collectCrossAxisCollisions(
component: string,
normalized: NormalizedDeclarations
): string[] {
const issues: string[] = []
for (const [name, decls] of normalized) {
const leafSets = decls
.map((d) => toScopeSet(d.scope))
.filter((s) => s.host && hasAnyLeafAxis(s))
for (let i = 0; i < leafSets.length; i++) {
for (let j = i + 1; j < leafSets.length; j++) {
const a = leafSets[i]
const b = leafSets[j]
if (areIncomparableLeaves(a, b)) {
// Need a composite that covers the union of a's and b's axes.
const needed = unionAxes(a, b)
const hasComposite = leafSets.some((set) => coversAllAxes(set, needed))
if (!hasComposite) {
issues.push(
`${component}.${name}: declarations at scopes ${describeScopeSet(a)} and ` +
`${describeScopeSet(b)} can both apply to the same element. ` +
`Add an explicit composite declaration ${describeAxisSet(needed)} ` +
`to disambiguate cascade order.`
)
}
}
}
}
}
return issues
}
function hasAnyLeafAxis(set: ScopeSet): boolean {
return Boolean(set.color || set.variant || set.state || set.size || set.event)
}
function areIncomparableLeaves(a: ScopeSet, b: ScopeSet): boolean {
// They're incomparable when they constrain DIFFERENT axes (so an
// element with both constraints satisfies both rules — but neither
// rule's selector subsumes the other).
const axes = ['color', 'variant', 'state', 'size', 'event'] as const
let sharedAxes = 0
let exclusiveAxes = 0
for (const axis of axes) {
const aHas = a[axis] !== undefined
const bHas = b[axis] !== undefined
if (aHas && bHas) {
if (a[axis] !== b[axis]) return false // contradictory; no overlap
sharedAxes++
} else if (aHas !== bHas) {
exclusiveAxes++
}
}
// Incomparable when both rules add at least one constraint the other
// doesn't — and exclusiveAxes from both sides exists. The simplest test
// is: there's at least one axis in a not in b AND vice versa.
const aOnly = axes.some((ax) => a[ax] !== undefined && b[ax] === undefined)
const bOnly = axes.some((ax) => b[ax] !== undefined && a[ax] === undefined)
return aOnly && bOnly
}
function unionAxes(a: ScopeSet, b: ScopeSet): ScopeSet {
const merged: ScopeSet = { host: true }
for (const axis of ['color', 'variant', 'state', 'size', 'event'] as const) {
const aVal = a[axis]
const bVal = b[axis]
if (aVal !== undefined) merged[axis] = aVal
else if (bVal !== undefined) merged[axis] = bVal
}
return merged
}
function coversAllAxes(candidate: ScopeSet, required: ScopeSet): boolean {
for (const axis of ['color', 'variant', 'state', 'size', 'event'] as const) {
const reqVal = required[axis]
if (reqVal === undefined) continue
if (candidate[axis] !== reqVal) return false
}
return true
}
function describeScopeSet(set: ScopeSet): string {
const atoms: string[] = []
for (const axis of ['color', 'variant', 'state', 'size', 'event'] as const) {
if (set[axis] !== undefined) atoms.push(`${axis}:${set[axis]}`)
}
return atoms.length === 1 ? atoms[0] : `[${atoms.join(', ')}]`
}
function describeAxisSet(set: ScopeSet): string {
const atoms: string[] = []
for (const axis of ['color', 'variant', 'state', 'size', 'event'] as const) {
if (set[axis] !== undefined) atoms.push(`${axis}:${set[axis]}`)
}
return `[${atoms.join(', ')}]`
}
function appendFontFamilyAliases(
declarations: string[],
families: TypographyPrimitiveSet['families'] | undefined
): void {
if (!families) return
if (families.primary) {
declarations.push(cssVar('font-sans', 'var(--font-family-primary)'))
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))
Theming: on-solid contrast by luminance (P2-2) + translucent role surfaces (P2-4) Two audit P2 quality defects, fixed at engine level. P2-2 - on-solid text illegible on light solids: The `contrast` slot defaulted to `--color-content-on-solid` (white) for every role. On light solids (amber/yellow, risk=orange ~2.3:1) white is sub-AA. The engine now picks by WCAG contrast (gamma-linearized) of the role step-9: when white fails (<3:1) it uses `--color-content-on-solid-contrast` (a dark, new OPTIONAL `content.onSolidContrast` semantic, #1c1917 in base). Only risk flips to dark in base (5.89:1); purple/red/teal/green keep white (convention, >=3:1). An explicit `slots.contrast` override is still honored verbatim. P2-4 - opaque tinted soft surfaces: The soft variant tint (Button + Badge `{role}-soft-bg`) was opaque (step-1 track + opaque color-mix hover) so it did not composite over non-uniform backgrounds. New derived tokens `--color-{role}-surface` (= a2) + `--color-{role}-surface-hover` (= a3) are translucent by construction (compositing-inverse alpha). Button/Badge soft consume them. Toast/Tabs untouched - they are cards, opacity is correct. - config-types: optional onSolidContrast + CONTENT_COLOR_OPTIONAL_KEYS. - config: content keySet allows the optional key; validator value-checks it. - render-css: wcagContrastRatio/wcagRelativeLuminance; luminance pick; emit on-solid-contrast + surface/surface-hover. - contract: on-solid-contrast + surface tokens per role. - themes/base: onSolidContrast #1c1917 (light + dark). - recipes/base: Button + Badge soft-bg -> surface, soft-bg-hover -> surface-hover. - docs: THEMING.md section 24 + section 22 item 2; audit P2-2/P2-4 resolved. Verified: npm run check (0 new errors), vitest eidos (0 new regressions), browser runtime (risk 5.89:1 dark text, surfaces translucent rgba). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
4 months ago
if (color.content.onSolidContrast) {
declarations.push(
cssVar('color-content-on-solid-contrast', color.content.onSolidContrast)
)
}
}
if (color.border) {
declarations.push(cssVar('color-border-subtle', color.border.subtle))
declarations.push(cssVar('color-border-default', color.border.default))
declarations.push(cssVar('color-border-strong', color.border.strong))
}
if (color.focus) {
declarations.push(cssVar('focus-ring-color', color.focus.ring))
declarations.push(cssVar('focus-ring-color-error', color.focus.ringError))
}
}
function appendColorScaleDeclarations(
declarations: string[],
scaleName: string,
scale: ColorScale
): void {
for (const step of COLOR_SCALE_STEPS) {
declarations.push(cssVar(`scale-${scaleName}-${step}`, scale[step]))
}
}
function parseHexColor(value: string): { r: number; g: number; b: number } | null {
const text = value.trim()
if (!text.startsWith('#')) return null
const hex = text.slice(1)
let r: number, g: number, b: number
if (hex.length === 3) {
r = parseInt(hex[0] + hex[0], 16)
g = parseInt(hex[1] + hex[1], 16)
b = parseInt(hex[2] + hex[2], 16)
} else if (hex.length === 6 || hex.length === 8) {
r = parseInt(hex.slice(0, 2), 16)
g = parseInt(hex.slice(2, 4), 16)
b = parseInt(hex.slice(4, 6), 16)
} else {
return null
}
if ([r, g, b].some(Number.isNaN)) return null
return { r, g, b }
}
/** sRGB relative luminance (0..1) — enough to pick the alpha background polarity. */
function srgbLuminance({ r, g, b }: { r: number; g: number; b: number }): number {
return (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255
}
/**
* Compositing inverse: the translucent color that, painted over a solid
* background `bg` (255 = white, 0 = black), reproduces the opaque `solidHex`.
* Solves `solid = a·c + (1-a)·bg` for the minimum alpha `a` (max saturation),
* per channel. Returns `null` when the solid isn't a plain hex (caller keeps
* the synthetic ramp for non-hex values).
*/
function alphaColorOverBackground(solidHex: string, bg: 0 | 255): string | null {
const t = parseHexColor(solidHex)
if (!t) return null
let a = 0
for (const c of [t.r, t.g, t.b]) {
if (c === bg) continue
const channelAlpha = bg === 255 ? (255 - c) / 255 : c / 255
if (channelAlpha > a) a = channelAlpha
}
if (a <= 0) return `rgb(${t.r} ${t.g} ${t.b} / 0)`
const resolve = (c: number) =>
Math.min(255, Math.max(0, Math.round((c - bg * (1 - a)) / a)))
const alpha = Math.round(a * 10000) / 10000
return `rgb(${resolve(t.r)} ${resolve(t.g)} ${resolve(t.b)} / ${alpha})`
}
/**
* Alpha (translucent) variants of a color scale.
*
* If a theme provides explicit `alphaScales` for this scale they win (verbatim).
* Otherwise each `aN` is GENERATED as the compositing inverse of the solid step
* `N` over the scale's background polarity (white for light scales, black for
* dark — decided by step 1's luminance): `aN` painted over that background
* reproduces solid `N`. This keeps the alpha ramp consistent-by-construction
* with the solid scale (no drift, Radix-style fidelity) for every theme, not
* just the base. Non-hex solids (e.g. `var()`) fall back to the legacy
* step-9-at-opacity `color-mix` ramp. (audit P1-1)
*/
function appendColorAlphaScaleDeclarations(
declarations: string[],
scaleName: string,
solidScale: ColorScale | undefined,
alphaScale: ColorAlphaScale | undefined
): void {
const step1 = solidScale?.['1']
const parsedStep1 = step1 ? parseHexColor(step1) : null
const bg: 0 | 255 = parsedStep1 && srgbLuminance(parsedStep1) < 0.5 ? 0 : 255
for (const step of COLOR_ALPHA_STEPS) {
const authored = alphaScale?.[step]
const solid = solidScale?.[step]
const computed =
authored === undefined && solid ? alphaColorOverBackground(solid, bg) : null
const value =
authored ??
computed ??
`color-mix(in srgb, var(--scale-${scaleName}-9) ${
DEFAULT_COLOR_ALPHA_PERCENTAGES[step]
}, transparent)`
declarations.push(cssVar(`scale-${scaleName}-a${step}`, value))
}
}
function appendRecordDeclarations(
declarations: string[],
prefix: string,
record: Record<string, string | number> | undefined
): void {
if (!record) return
for (const [name, value] of Object.entries(record)) {
declarations.push(cssVar(`${prefix}-${name}`, value))
}
}
/**
* Like {@link appendRecordDeclarations} but multiplies each value by the
* active density scalar (`var(--{scaleVar})`) via `calc()`, so the density
* axis (compact / comfortable / spacious) actually rescales the token.
*
* The density block (`renderDensityBlocks`) redeclares `--density-*-scale`
* on `[data-density='…']` (the same element where these tokens are
* authored — `:root` / `<html>`), so the `var()` here resolves against the
* active density. Zero values are emitted verbatim (`calc(0 * x)` is
* pointless and `0px` must remain a valid length). At the `comfortable`
* default the scalar is `1`, so the emitted value is numerically identical
* to the raw token — no change for consumers that never switch density.
*/
// Emit a px metric scaled by the global `--scaling` zoom, and optionally by a
// per-axis density scalar (space / control-height). At scaling=100 + comfortable
// density both factors are 1, so output equals the raw value (no regression).
function appendScaledMetricDeclarations(
declarations: string[],
prefix: string,
record: Record<string, string | number> | undefined,
densityScaleVar?: string
): void {
if (!record) return
for (const [name, value] of Object.entries(record)) {
const raw = String(value).trim()
const isZero = parseFloat(raw) === 0
const density = densityScaleVar ? ` * var(--${densityScaleVar})` : ''
declarations.push(
cssVar(`${prefix}-${name}`, isZero ? raw : `calc(${raw}${density} * var(--scaling))`)
)
}
}
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.