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

2305 lines
87 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 DepthPrimitiveSet,
type ShapePrimitiveSet,
feat(eidos): config-driven @font-face (Phase 2) — fonts as theme data (next/font model) TYPOGRAPHY_ENGINE_RFC Phase 2. Fonts are theme data (each theme owns its families), so @font-face becomes config-driven + generated — like the color palette — instead of a separate hand-written CSS file. This matches next/font / Fontaine (config -> @font-face), above the token-only frameworks (Radix/Tailwind/MUI) that leave loading to you. - config-types: FontFamily += faces (FontFace[]) / axes (FontAxes) / fallback (FontFallback, metric-override) / display / preload. Additive — the family stack still works from `family`+`fallbacks`. - render-css: renderFontFaceBlocks generates @font-face per face from the config, deduped by the real font name (a font shared across slots — Lora as secondary+display — emits once). Optional metric-override fallback @font-face (anti-CLS) injected into the stack as `'{family} Fallback'` when declared. Emitted first in renderStaticCss. - typography.ts: the BASE THEME's 14 @font-face migrated from fonts.css into the config (faces). TTF today (the theme's choice); a theme swaps to woff2/variable + fallback metrics by editing config only. - index.css: drops `@import './themes/fonts.css'` — the @font-face now ships in generated/base.css. (fonts.css superseded; left in place, no longer imported.) - generated/base.css regenerated (14 @font-face, Lora deduped). test: @font-face generation + dedup; merge-without-mutation assertion updated for the faces field. Verified in browser: 3 families registered, files resolve (200), fonts load on demand (swap). check 0 errors; eidos suite green (3 pre-existing words failures only). Deferred (capability typed, theme adopts when it has the assets): variable woff2, metric-override numbers (need fontkit/precompute), <link rel=preload> (head markup). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
4 months ago
type FontFace,
type FontFallback,
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 RecipeContainerQueries,
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,
CoordinatedPreset,
CssPhase,
EventSignature,
KeyframeName,
KeyframeStops,
MotionConfig
} from '$motion'
import { apcaLc, oklchToCss, oklchToGammaRgb, safeParseColor, wcagContrastRatio } from '$color'
feat(eidos): fluid typography engine (Phase 1) — Utopia clamp, rem, fluid headings TYPOGRAPHY_ENGINE_RFC Phase 1. Additive on TypographyPrimitiveSet, behind the frozen token contract (--font-size-X keeps its name; only the value formula changes, like color --scale-* hex -> oklch()). - type-scale.ts (pure, isomorphic, no canvas): the Utopia clamp() formula. fluidClamp / resolveTypeSize / isFluidSize. rem-based (a11y: scales with browser font-zoom). - config-types: TextMetric.size accepts `string | FluidSize` ({min,max,minVw?,maxVw?}). Plain length strings still valid -> backward-compatible. - render-css appendTypographyDeclarations: emits calc(resolveTypeSize(size) * --scaling) -> a fixed rem or a fluid clamp; the --scaling axis composes on top. - config.ts: validateSizeValue accepts a FluidSize (validates min/max/minVw/maxVw) so the base config validates (was the cascade root — FluidSize objects failed the string-only CSS-value check). - typography.ts: sizes in rem; headings (lg/xl/xxl/xxxl) fluid (min @480px -> max @1280px, max = previous fixed px so desktop is unchanged); body (md) fixed. hero/h1/h2 drop the manual { base, md } responsive sizes — the clamp covers the viewport. - generated/base.css regenerated. type-scale.test.ts (6 tests). 2 config-test assertions updated to the new rem/clamp values. Verified in browser: --font-size-xxxl 40px @480 -> 80px @1280; xxl 32->48; lg 18->20; md 16 fixed. check 0 errors; eidos suite green (3 pre-existing words failures only). canvas-text/<SText> unaffected (reads getComputedStyle real font, measures the clamp). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
4 months ago
import { resolveTypeSize } from './type-scale'
import { EidosCssVariableError, EidosThemeNotFoundError } from '../errors'
export interface RenderThemeCssOptions {
readonly selector?: string
/**
* Which raw `--scale-*` donor tokens to emit. `'roleReferenced'`
* (default) emits only the scales the theme's roles reference — the slim
* foundation. `'all'` emits the full donor palette (used by the opt-in
* `palette.css`). Roles reference scales via `var()`, so the
* role-referenced subset is sufficient for the foundation; runtime theming
* builds from JS scale data and writes resolved values, so it never depends
* on the unemitted scales.
*/
readonly scales?: 'roleReferenced' | 'all'
}
export interface RenderContractCssOptions {
readonly staticSelector?: string
readonly themeSelector?: string
}
export interface RenderCssVariablesOptions {
readonly selector?: string
readonly contract?: EidosCssContract | readonly EidosCssContractToken[]
readonly strict?: boolean
}
feat(eidos): forced-colors focus a11y (P3-2) + role border ramp 6->7 (P3-3) Closes the two color-quality items from THEMING_AUDIT P3. P3-2 · forced-colors (Windows High Contrast): under @media (forced-colors: active) the browser auto-maps borders/text/backgrounds to system colors BUT drops box-shadow — so the box-shadow focus ring (--focus-ring) vanishes and keyboard focus disappears. The foundation now always emits a system-colored outline fallback: @media (forced-colors: active) { :focus-visible { outline: 2px solid Highlight; outline-offset: 2px; } } Components that already focus via outline (e.g. Button) keep theirs by specificity; this is the fallback for the box-shadow ones. renderForcedColorsBlock in render-css.ts. P3-3 · role border ramp: the per-role `border` slot moved step 6 -> 7. In Radix's functional scale 6 is a subtle separator and 7 is the UI element border; step 6 read washed-out on real element borders (outline/surface/controls). element/hover/active (3/4/5) stay — Radix-canonical for component bg. DEFAULT_COLOR_ROLE_SLOT_STEPS. - generated/base.css regenerated (forced-colors block + --color-{role}-border -> step 7). - Verified in browser: --color-primary-border now resolves to primitive-7 (oklch 0.80 0.092 vs the softer step-6 0.86 0.072); checkbox borders render defined, not broken. - test: renderStaticCss emits the forced-colors outline block. - docs: THEMING §28 + audit P3-1/2/3 marked resolved + README ref row. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
4 months ago
// Radix functional steps: 1-2 backgrounds · 3-5 component bg (rest/hover/active) ·
// 6 subtle separator · 7 UI element border · 8 hovered element border · 9 solid ·
// 10 solid hover · 11-12 text. The role `border` slot maps to step 7 (Radix's "UI
// element border") — step 6 reads as a washed-out separator on real element borders.
const DEFAULT_COLOR_ROLE_SLOT_STEPS: Record<ColorRoleSlot, string> = {
track: '1',
element: '3',
hover: '4',
active: '5',
feat(eidos): forced-colors focus a11y (P3-2) + role border ramp 6->7 (P3-3) Closes the two color-quality items from THEMING_AUDIT P3. P3-2 · forced-colors (Windows High Contrast): under @media (forced-colors: active) the browser auto-maps borders/text/backgrounds to system colors BUT drops box-shadow — so the box-shadow focus ring (--focus-ring) vanishes and keyboard focus disappears. The foundation now always emits a system-colored outline fallback: @media (forced-colors: active) { :focus-visible { outline: 2px solid Highlight; outline-offset: 2px; } } Components that already focus via outline (e.g. Button) keep theirs by specificity; this is the fallback for the box-shadow ones. renderForcedColorsBlock in render-css.ts. P3-3 · role border ramp: the per-role `border` slot moved step 6 -> 7. In Radix's functional scale 6 is a subtle separator and 7 is the UI element border; step 6 read washed-out on real element borders (outline/surface/controls). element/hover/active (3/4/5) stay — Radix-canonical for component bg. DEFAULT_COLOR_ROLE_SLOT_STEPS. - generated/base.css regenerated (forced-colors block + --color-{role}-border -> step 7). - Verified in browser: --color-primary-border now resolves to primitive-7 (oklch 0.80 0.092 vs the softer step-6 0.86 0.072); checkbox borders render defined, not broken. - test: renderStaticCss emits the forced-colors outline block. - docs: THEMING §28 + audit P3-1/2/3 marked resolved + README ref row. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
4 months ago
border: '7',
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[] = []
// Responsive breakpoints — the configured set (threaded from the runtime
// service via `ActiveEidos`, which reads `dom.breakpoints.current`) merged over
// the fallback defaults. Emitted as `--breakpoint-*` tokens AND used for the
// responsive `@media` blocks below, so both track ONE source instead of a
// frozen const drifting from the developer-configured breakpoints.
const breakpoints = { ...STYLE_BREAKPOINT_WIDTHS, ...options.breakpoints }
for (const bp of STYLE_BREAKPOINT_ORDER) {
declarations.push(cssVar(`breakpoint-${bp}`, `${breakpoints[bp]}px`))
}
// 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-inner-width', primitives.focusRing.innerWidth))
// Two-ring focus model: inner `inset` ring (width from token, 0 by
// default → invisible) + the outer ring. Every input component shares
// this so the focus affordance is uniform and theme-parameterised.
declarations.push(
cssVar(
'focus-ring',
'inset 0 0 0 var(--focus-ring-inner-width) var(--focus-ring-color), ' +
'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',
'inset 0 0 0 var(--focus-ring-inner-width) var(--focus-ring-color-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)
}
if (primitives.blur) {
// Blur radii scale with --scaling (zoom-consistent), like icon-size.
appendScaledMetricDeclarations(declarations, 'blur', primitives.blur)
}
if (primitives.gradientAngle) {
appendRecordDeclarations(declarations, 'gradient-angle', primitives.gradientAngle)
}
if (primitives.gradients) {
// Named gradients compose role/surface tokens → mode-aware via the references.
appendRecordDeclarations(declarations, 'gradient', primitives.gradients)
}
appendRecordDeclarations(declarations, 'opacity', primitives.opacity)
appendRecordDeclarations(declarations, 'z-index', primitives.zIndex)
if (primitives.typography) {
appendTypographyDeclarations(declarations, primitives.typography)
// Variable fonts: enable optical sizing globally when any family declares an `opsz`
// axis, so the font's optical-size axis tracks the rendered font-size. (RFC §5)
if (Object.values(primitives.typography.families).some((f) => f.axes?.opsz)) {
declarations.push('font-optical-sizing: auto;')
}
}
if (primitives.size) {
appendSizeDeclarations(declarations, primitives.size)
}
if (primitives.depth) {
appendDepthDeclarations(declarations, primitives.depth)
}
if (primitives.shape) {
appendShapeDeclarations(declarations, primitives.shape)
}
appendTransitionAliasDeclarations(declarations, options)
const scopedRecipeBlocks = appendRecipeDeclarations(declarations, options.recipes, breakpoints)
const depthBlocks = primitives.depth ? renderDepthBlocks(primitives.depth) : []
const shapeBlocks = primitives.shape ? renderShapeBlocks(primitives.shape) : []
feat(eidos): config-driven @font-face (Phase 2) — fonts as theme data (next/font model) TYPOGRAPHY_ENGINE_RFC Phase 2. Fonts are theme data (each theme owns its families), so @font-face becomes config-driven + generated — like the color palette — instead of a separate hand-written CSS file. This matches next/font / Fontaine (config -> @font-face), above the token-only frameworks (Radix/Tailwind/MUI) that leave loading to you. - config-types: FontFamily += faces (FontFace[]) / axes (FontAxes) / fallback (FontFallback, metric-override) / display / preload. Additive — the family stack still works from `family`+`fallbacks`. - render-css: renderFontFaceBlocks generates @font-face per face from the config, deduped by the real font name (a font shared across slots — Lora as secondary+display — emits once). Optional metric-override fallback @font-face (anti-CLS) injected into the stack as `'{family} Fallback'` when declared. Emitted first in renderStaticCss. - typography.ts: the BASE THEME's 14 @font-face migrated from fonts.css into the config (faces). TTF today (the theme's choice); a theme swaps to woff2/variable + fallback metrics by editing config only. - index.css: drops `@import './themes/fonts.css'` — the @font-face now ships in generated/base.css. (fonts.css superseded; left in place, no longer imported.) - generated/base.css regenerated (14 @font-face, Lora deduped). test: @font-face generation + dedup; merge-without-mutation assertion updated for the faces field. Verified in browser: 3 families registered, files resolve (200), fonts load on demand (swap). check 0 errors; eidos suite green (3 pre-existing words failures only). Deferred (capability typed, theme adopts when it has the assets): variable woff2, metric-override numbers (need fontkit/precompute), <link rel=preload> (head markup). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
4 months ago
const blocks: string[] = []
// @font-face first (config-driven loading); the family-stack tokens reference them.
if (primitives.typography) {
const fontFaceCss = renderFontFaceBlocks(primitives.typography)
if (fontFaceCss) blocks.push(fontFaceCss)
}
blocks.push(
renderBlock(':root', declarations),
...scopedRecipeBlocks,
...depthBlocks,
...shapeBlocks
)
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 = breakpoints[bp]
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
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))
feat(eidos): forced-colors focus a11y (P3-2) + role border ramp 6->7 (P3-3) Closes the two color-quality items from THEMING_AUDIT P3. P3-2 · forced-colors (Windows High Contrast): under @media (forced-colors: active) the browser auto-maps borders/text/backgrounds to system colors BUT drops box-shadow — so the box-shadow focus ring (--focus-ring) vanishes and keyboard focus disappears. The foundation now always emits a system-colored outline fallback: @media (forced-colors: active) { :focus-visible { outline: 2px solid Highlight; outline-offset: 2px; } } Components that already focus via outline (e.g. Button) keep theirs by specificity; this is the fallback for the box-shadow ones. renderForcedColorsBlock in render-css.ts. P3-3 · role border ramp: the per-role `border` slot moved step 6 -> 7. In Radix's functional scale 6 is a subtle separator and 7 is the UI element border; step 6 read washed-out on real element borders (outline/surface/controls). element/hover/active (3/4/5) stay — Radix-canonical for component bg. DEFAULT_COLOR_ROLE_SLOT_STEPS. - generated/base.css regenerated (forced-colors block + --color-{role}-border -> step 7). - Verified in browser: --color-primary-border now resolves to primitive-7 (oklch 0.80 0.092 vs the softer step-6 0.86 0.072); checkbox borders render defined, not broken. - test: renderStaticCss emits the forced-colors outline block. - docs: THEMING §28 + audit P3-1/2/3 marked resolved + README ref row. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
4 months ago
blocks.push(renderForcedColorsBlock())
blocks.push(renderPrefersContrastBlock())
feat(eidos): forced-colors focus a11y (P3-2) + role border ramp 6->7 (P3-3) Closes the two color-quality items from THEMING_AUDIT P3. P3-2 · forced-colors (Windows High Contrast): under @media (forced-colors: active) the browser auto-maps borders/text/backgrounds to system colors BUT drops box-shadow — so the box-shadow focus ring (--focus-ring) vanishes and keyboard focus disappears. The foundation now always emits a system-colored outline fallback: @media (forced-colors: active) { :focus-visible { outline: 2px solid Highlight; outline-offset: 2px; } } Components that already focus via outline (e.g. Button) keep theirs by specificity; this is the fallback for the box-shadow ones. renderForcedColorsBlock in render-css.ts. P3-3 · role border ramp: the per-role `border` slot moved step 6 -> 7. In Radix's functional scale 6 is a subtle separator and 7 is the UI element border; step 6 read washed-out on real element borders (outline/surface/controls). element/hover/active (3/4/5) stay — Radix-canonical for component bg. DEFAULT_COLOR_ROLE_SLOT_STEPS. - generated/base.css regenerated (forced-colors block + --color-{role}-border -> step 7). - Verified in browser: --color-primary-border now resolves to primitive-7 (oklch 0.80 0.092 vs the softer step-6 0.86 0.072); checkbox borders render defined, not broken. - test: renderStaticCss emits the forced-colors outline block. - docs: THEMING §28 + audit P3-1/2/3 marked resolved + README ref row. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
4 months ago
// Container-query opt-in: marking an element `data-container` makes it a query
// container, so its descendants' recipe `@container` overrides resolve against
// its inline size. (The `@container` blocks themselves are emitted per-recipe.)
blocks.push(renderBlock('[data-container]', ['container-type: inline-size;']))
return blocks.join('\n\n')
}
feat(eidos): forced-colors focus a11y (P3-2) + role border ramp 6->7 (P3-3) Closes the two color-quality items from THEMING_AUDIT P3. P3-2 · forced-colors (Windows High Contrast): under @media (forced-colors: active) the browser auto-maps borders/text/backgrounds to system colors BUT drops box-shadow — so the box-shadow focus ring (--focus-ring) vanishes and keyboard focus disappears. The foundation now always emits a system-colored outline fallback: @media (forced-colors: active) { :focus-visible { outline: 2px solid Highlight; outline-offset: 2px; } } Components that already focus via outline (e.g. Button) keep theirs by specificity; this is the fallback for the box-shadow ones. renderForcedColorsBlock in render-css.ts. P3-3 · role border ramp: the per-role `border` slot moved step 6 -> 7. In Radix's functional scale 6 is a subtle separator and 7 is the UI element border; step 6 read washed-out on real element borders (outline/surface/controls). element/hover/active (3/4/5) stay — Radix-canonical for component bg. DEFAULT_COLOR_ROLE_SLOT_STEPS. - generated/base.css regenerated (forced-colors block + --color-{role}-border -> step 7). - Verified in browser: --color-primary-border now resolves to primitive-7 (oklch 0.80 0.092 vs the softer step-6 0.86 0.072); checkbox borders render defined, not broken. - test: renderStaticCss emits the forced-colors outline block. - docs: THEMING §28 + audit P3-1/2/3 marked resolved + README ref row. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
4 months ago
/**
* Accessibility — Windows High Contrast / `forced-colors`. The OS forces colors via
* `forced-color-adjust: auto`, which auto-maps borders / text / backgrounds to system
* colors BUT drops `box-shadow` entirely — so the box-shadow focus ring (`--focus-ring`)
* vanishes. Guarantee a visible keyboard-focus indicator with a system-colored
* `outline` (which forced-colors preserve). Components that already focus via `outline`
* (e.g. Button) keep theirs by higher specificity; this is the fallback for the
* box-shadow ones. Emitted always — the `@media` gate makes it inert otherwise.
*/
function renderForcedColorsBlock(): string {
return [
'@media (forced-colors: active) {',
'\t:focus-visible {',
'\t\toutline: 2px solid Highlight;',
'\t\toutline-offset: 2px;',
'\t}',
'}'
].join('\n')
}
/**
* Accessibility — `prefers-contrast: more` (macOS "Increase contrast", Windows, etc.).
* Strengthen the neutral CHROME for users who ask for more contrast: darker/stronger
* borders + higher-contrast de-emphasized text. Solid fills + primary text are already
* high-contrast, so they stay. Strictly additive (only active under the media query)
* and strictly STRONGER (never weaker), so it can't regress the default look.
*
* `:root:root` (specificity 0,2,0) so these win over the theme's `:root` (0,1,0)
* regardless of stylesheet order. Values reference the theme's `--primitive-neutral-*`,
* which resolve from the cascade; if a theme omits them the declaration is simply
* ignored (graceful — keeps the theme's own chrome).
*/
function renderPrefersContrastBlock(): string {
return [
'@media (prefers-contrast: more) {',
'\t:root:root {',
'\t\t--color-border-subtle: var(--primitive-neutral-7);',
'\t\t--color-border-default: var(--primitive-neutral-8);',
'\t\t--color-border-strong: var(--primitive-neutral-9);',
'\t\t--color-content-secondary: var(--primitive-neutral-12);',
'\t\t--color-content-muted: var(--primitive-neutral-11);',
'\t}',
'}'
].join('\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 allScaleNames = new Set([...Object.keys(color.scales), ...Object.keys(color.alphaScales)])
// 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)
// Foundation slimming: emit only the donor scales the roles actually
// reference (the role loop below binds `--primitive-{role}-*` to
// `var(--scale-{name}-*)`). The full 31-scale palette ships opt-in via
// `renderColorPaletteCss` → `generated/palette.css`. Runtime theming writes
// RESOLVED values from JS scale data, so the unemitted scales are never
// needed at the foundation.
const scaleNames =
renderOptions.scales === 'all'
? allScaleNames
: roleReferencedScaleNames(completedRoles, allScaleNames)
for (const scaleName of scaleNames) {
const scale = color.scales[scaleName]
if (scale) appendColorScaleDeclarations(declarations, scaleName, scale)
appendColorAlphaScaleDeclarations(declarations, scaleName, scale, color.alphaScales[scaleName])
}
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)
}
/** The donor scales a theme's roles reference (incl. canonically-derived
* intents). The foundation emits only these; the full palette ships opt-in. */
function roleReferencedScaleNames(
completedRoles: ReturnType<typeof completeColorRoleMap>,
available: ReadonlySet<string>
): Set<string> {
const used = new Set<string>()
for (const role of COLOR_ROLES) {
const definition = completedRoles[role]
if (definition === undefined) continue
const name = getColorRoleScaleName(definition)
if (available.has(name)) used.add(name)
}
return used
}
/**
* Render ONLY the full `--scale-*` donor palette (all scales) for a theme,
* wrapped in the theme selector. Opt-in companion to the slim foundation:
* `renderThemeCss` emits only role-referenced scales, while apps/tools that
* reference raw `--scale-{name}-{step}` tokens import the palette generated
* from this (see `generated/palette.css`). The framework itself never needs
* it — components consume `--color-{role}-*`; runtime theming uses JS scale
* data and writes resolved values.
*/
export function renderColorPaletteCss(
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])
}
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)')
)
// Inset-ring width — default for crisp inner rings drawn as `box-shadow`
// (`inset 0 0 0 var(--ring-inset-width) var(--ring-inset-color)`). Defaults to the
// `medium` (2px) border step; a theme retunes all inset rings here, a component overrides
// per-use. The full expression lives at the use site (CSS resolves nested var() in
// the declaring scope, so a single pre-baked `--ring-inset` shorthand can't pick up
// per-element color/width). Distinct from the soft `--shadow-inset-*` tier.
declarations.push(cssVar('ring-inset-width', 'var(--border-width-medium)'))
}
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))
}
// M6: coordinated presets — transition-based, gated on the Presence's
// data-starting/ending-style (NOT data-state). The motion engine never runs these.
for (const [name, preset] of Object.entries(motion.coordinated ?? {})) {
blocks.push(...renderCoordinatedPresetRules(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
}
// M6: a coordinated preset is COMPLETE CSS for a `PresenceGroup`-driven surface —
// the off-state (gated on the Presence's data-starting/ending-style), the transition
// over exactly the off properties, and the reversible canonical stagger. A component
// just sets `animation="<name>"` + the rhythm (`--motion-stagger-each` / `-count`);
// no per-component animation CSS. Duration/ease are overridable via `--motion-cascade-*`
// (inheriting, so a container sets them once for its whole cascade).
function renderCoordinatedPresetRules(name: string, preset: CoordinatedPreset): string[] {
const dur = `var(--motion-cascade-duration, var(--duration-${preset.duration ?? 'moderate'}))`
const ease = `var(--motion-cascade-ease, var(--ease-${preset.ease ?? 'out'}))`
const sel = `[data-animation-style='${name}']`
const transition = Object.keys(preset.off)
.map((prop) => `${prop} ${dur} ${ease}`)
.join(', ')
const offDecls = Object.entries(preset.off).map(([prop, value]) => `${prop}: ${value};`)
return [
renderBlock(sel, [
`transition: ${transition};`,
'transition-delay: calc(var(--motion-stagger-index, 0) * var(--motion-stagger-each, 0ms));'
]),
renderBlock(`${sel}[data-ending-style]`, [
'transition-delay: calc((var(--motion-stagger-count, 1) - 1 - var(--motion-stagger-index, 0)) * var(--motion-stagger-each, 0ms));'
]),
feat(motion): modo (c) — composición sema + motion (opt-out visual en eidos) Un componente coordinado puede componer la cascada (motion) con sonido/háptico (sema) en el mismo evento. El cabo: el meta-canal visual de sema estampa data-event-* siempre que el evento tenga algún canal (channels != []), así que channels:['sound'] arrastraba la firma visual genérica (present-rise), que pelea con la cascada. Solución EN MOTION (sema intacto): eidos neutraliza su PROPIA firma visual sobre las superficies coordinadas. render-css.ts > renderCoordinatedPresetRules emite `[data-animation-style='cascade-X'][data-event-phase='active'] { animation: none !important }`. La firma usa `animation` -> muere; la cascada es `transition` -> sobrevive. Aditivo: inerte hasta que un coordinado dispare un evento sema. Ejemplo: Reveal pasa a modo (c) — open y close declaran channels:['sound'] (emerge suena, pitch 600) + expression:'family-default'; suena al abrir y al cerrar a la vez que la cascada, sin pelea visual. Rail queda como modo (b) puro. Layout de /temas/animations con events:{ sound: true }; la demo /reveal explica el modo (c). Honestidad: la versión inicial del RFC Apéndice B afirmaba que el modo (c) componía "gratis" — falso (verificado en engine.ts/visual.ts). Corregido: el Apéndice B documenta ahora el acoplamiento real y el opt-out en motion. Tests: eidos/motion 25/25 (neutralización) · morfo 70/70 · morfo:vocabulary limpio. Audio verificado en navegador (suena al abrir y cerrar). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
4 months ago
renderBlock(`${sel}[data-starting-style], ${sel}[data-ending-style]`, offDecls),
// A coordinated surface OPTS OUT of sema's generic VISUAL firma: if it ALSO fires a
// sema event (so `data-event-*` is stamped — the engine stamps it for any non-empty
// `channels`), the generic `present-rise`/`dismiss-fade` keyframe would fight the
// cascade. Neutralise it HERE — eidos owns the visual axis, so the opt-out lives in
// MOTION, never in sema. `animation: none` kills the firma's keyframe; the cascade is
// a `transition`, so it survives. `!important` because an intent-specific firma
// selector (3 attrs) outranks this 2-attr one. This is the modo (c) enabler: a
// coordinated component can compose sound/haptic with the cascade (RFC Apéndice B).
renderBlock(`${sel}[data-event-phase='active']`, ['animation: none !important;'])
]
}
// 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)) {
feat(eidos): fluid typography engine (Phase 1) — Utopia clamp, rem, fluid headings TYPOGRAPHY_ENGINE_RFC Phase 1. Additive on TypographyPrimitiveSet, behind the frozen token contract (--font-size-X keeps its name; only the value formula changes, like color --scale-* hex -> oklch()). - type-scale.ts (pure, isomorphic, no canvas): the Utopia clamp() formula. fluidClamp / resolveTypeSize / isFluidSize. rem-based (a11y: scales with browser font-zoom). - config-types: TextMetric.size accepts `string | FluidSize` ({min,max,minVw?,maxVw?}). Plain length strings still valid -> backward-compatible. - render-css appendTypographyDeclarations: emits calc(resolveTypeSize(size) * --scaling) -> a fixed rem or a fluid clamp; the --scaling axis composes on top. - config.ts: validateSizeValue accepts a FluidSize (validates min/max/minVw/maxVw) so the base config validates (was the cascade root — FluidSize objects failed the string-only CSS-value check). - typography.ts: sizes in rem; headings (lg/xl/xxl/xxxl) fluid (min @480px -> max @1280px, max = previous fixed px so desktop is unchanged); body (md) fixed. hero/h1/h2 drop the manual { base, md } responsive sizes — the clamp covers the viewport. - generated/base.css regenerated. type-scale.test.ts (6 tests). 2 config-test assertions updated to the new rem/clamp values. Verified in browser: --font-size-xxxl 40px @480 -> 80px @1280; xxl 32->48; lg 18->20; md 16 fixed. check 0 errors; eidos suite green (3 pre-existing words failures only). canvas-text/<SText> unaffected (reads getComputedStyle real font, measures the clamp). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
4 months ago
// font-size scales with the global `--scaling` zoom. A fluid size resolves to a
// rem-based `clamp(...)` (Utopia, viewport-fluid); a fixed size passes through.
// `--scaling` composes on top via calc(). 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(${resolveTypeSize(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
// Phase 3 scales — tracking / leading / features / measure. Additive tokens that
// recipes + components consume (var(--tracking-tight), etc.).
for (const [name, value] of Object.entries(typography.tracking ?? {})) {
declarations.push(cssVar(`tracking-${name}`, value))
}
for (const [name, value] of Object.entries(typography.leading ?? {})) {
declarations.push(cssVar(`leading-${name}`, value))
}
for (const [name, value] of Object.entries(typography.features ?? {})) {
declarations.push(cssVar(`font-feature-${name}`, value))
}
for (const [name, value] of Object.entries(typography.measure ?? {})) {
declarations.push(cssVar(`measure-${name}`, value))
}
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)
}
}
// ── Depth (DEPTH_ENGINE_RFC) ─────────────────────────────────────────────────
/** Emit `--depth-{plane}-{cue}` tokens — each plane's cue values composed into `:root`. */
function appendDepthDeclarations(declarations: string[], depth: DepthPrimitiveSet): void {
for (const [plane, cues] of Object.entries(depth.planes)) {
feat(eidos): depth Fase 2 (oklab rim halo) + reference-grade /temas/profundidad Depth engine — Fase 2 (mode-adaptive mezcla): - New `halo` cue per plane: a top-edge rim-light computed in oklab (color-mix(in oklab, white N%, transparent); 5/7/8% on raised/overlay/modal). The `[data-depth]` box-shadow now composes `shadow, halo`. Invisible on light surfaces (the drop shadow leads), the lift cue on dark surfaces (where the drop shadow barely shows) — the mode-adaptive answer to "shadow lies in dark", scoped to the depth channel (global --shadow-* untouched). - Wired through config-types (DepthPlane.halo) + render-css (declare + compose) + config validation + STATIC_DEPTH + regenerated generated/base.css. Showcase — /temas/profundidad to reference depth (4 -> 9 sections): matches Material elevation catalog breadth and adds the two axes it lacks (eventful + open cage): - Responde a cada estado — dynamic elevation, live interactive control - La escalera de planos — z-stack of the 5 planes - Catalogo de planos en reposo — the resting-elevation spec table, our vocabulary - Luz vs sombra — light/dark side-by-side showing the halo mechanism - Accesibilidad — never the only channel, reduced-motion, forced-colors, contrast - Fix: mirror data-theme onto <html> so :root depth tokens stay mode-aware Docs: DEPTH_ENGINE_RFC (Fase 2 + 5 done, token contract + halo), THEMING 29. Verified: npm run check 0 errors; depth tests 50/50 (updated the box-shadow assertion to the shadow,halo composition). Pre-existing `words` recipe-contract failures unrelated. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
4 months ago
for (const cue of ['surface', 'shadow', 'halo', 'z', 'blur', 'scrim'] as const) {
const value = cues[cue]
if (value !== undefined) declarations.push(cssVar(`depth-${plane}-${cue}`, value))
}
}
}
/**
* `[data-depth='{plane}']` applies the *safe additive* depth cues — box-shadow + z-index —
feat(eidos): depth Fase 2 (oklab rim halo) + reference-grade /temas/profundidad Depth engine — Fase 2 (mode-adaptive mezcla): - New `halo` cue per plane: a top-edge rim-light computed in oklab (color-mix(in oklab, white N%, transparent); 5/7/8% on raised/overlay/modal). The `[data-depth]` box-shadow now composes `shadow, halo`. Invisible on light surfaces (the drop shadow leads), the lift cue on dark surfaces (where the drop shadow barely shows) — the mode-adaptive answer to "shadow lies in dark", scoped to the depth channel (global --shadow-* untouched). - Wired through config-types (DepthPlane.halo) + render-css (declare + compose) + config validation + STATIC_DEPTH + regenerated generated/base.css. Showcase — /temas/profundidad to reference depth (4 -> 9 sections): matches Material elevation catalog breadth and adds the two axes it lacks (eventful + open cage): - Responde a cada estado — dynamic elevation, live interactive control - La escalera de planos — z-stack of the 5 planes - Catalogo de planos en reposo — the resting-elevation spec table, our vocabulary - Luz vs sombra — light/dark side-by-side showing the halo mechanism - Accesibilidad — never the only channel, reduced-motion, forced-colors, contrast - Fix: mirror data-theme onto <html> so :root depth tokens stay mode-aware Docs: DEPTH_ENGINE_RFC (Fase 2 + 5 done, token contract + halo), THEMING 29. Verified: npm run check 0 errors; depth tests 50/50 (updated the box-shadow assertion to the shadow,halo composition). Pre-existing `words` recipe-contract failures unrelated. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
4 months ago
* referencing the plane tokens (so `setCssVariables` / a theme can retune them). The
* box-shadow composes the drop `shadow` with the rim-light `halo` (comma-joined) when both
* are declared, so the lift cue survives in dark mode. `surface` (background) + `blur`/`scrim`
* (atmosphere) stay opt-in tokens, never forced here, so the rule never fights a component's
* own background. (DEPTH_ENGINE_RFC §5)
*/
function renderDepthBlocks(depth: DepthPrimitiveSet): string[] {
const blocks: string[] = []
for (const [plane, cues] of Object.entries(depth.planes)) {
const lines: string[] = []
feat(eidos): depth Fase 2 (oklab rim halo) + reference-grade /temas/profundidad Depth engine — Fase 2 (mode-adaptive mezcla): - New `halo` cue per plane: a top-edge rim-light computed in oklab (color-mix(in oklab, white N%, transparent); 5/7/8% on raised/overlay/modal). The `[data-depth]` box-shadow now composes `shadow, halo`. Invisible on light surfaces (the drop shadow leads), the lift cue on dark surfaces (where the drop shadow barely shows) — the mode-adaptive answer to "shadow lies in dark", scoped to the depth channel (global --shadow-* untouched). - Wired through config-types (DepthPlane.halo) + render-css (declare + compose) + config validation + STATIC_DEPTH + regenerated generated/base.css. Showcase — /temas/profundidad to reference depth (4 -> 9 sections): matches Material elevation catalog breadth and adds the two axes it lacks (eventful + open cage): - Responde a cada estado — dynamic elevation, live interactive control - La escalera de planos — z-stack of the 5 planes - Catalogo de planos en reposo — the resting-elevation spec table, our vocabulary - Luz vs sombra — light/dark side-by-side showing the halo mechanism - Accesibilidad — never the only channel, reduced-motion, forced-colors, contrast - Fix: mirror data-theme onto <html> so :root depth tokens stay mode-aware Docs: DEPTH_ENGINE_RFC (Fase 2 + 5 done, token contract + halo), THEMING 29. Verified: npm run check 0 errors; depth tests 50/50 (updated the box-shadow assertion to the shadow,halo composition). Pre-existing `words` recipe-contract failures unrelated. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
4 months ago
const shadowParts: string[] = []
if (cues.shadow !== undefined) shadowParts.push(`var(--depth-${plane}-shadow)`)
if (cues.halo !== undefined) shadowParts.push(`var(--depth-${plane}-halo)`)
if (shadowParts.length) lines.push(`box-shadow: ${shadowParts.join(', ')};`)
if (cues.z !== undefined) lines.push(`z-index: var(--depth-${plane}-z);`)
if (lines.length) blocks.push(renderBlock(`[data-depth='${plane}']`, lines))
// Atmosphere (Fase 4) — frost is opt-in via `data-frost` so it never turns an opaque
// overlay translucent by default. Gated on the plane attribute → reliably overrides the
// component's own background (specificity 0,2,0). Needs a surface to dilute + a blur.
if (cues.blur !== undefined) {
const frost: string[] = []
if (cues.surface !== undefined)
frost.push(
`background-color: color-mix(in srgb, var(--depth-${plane}-surface) 80%, transparent);`
)
frost.push(`backdrop-filter: blur(var(--depth-${plane}-blur));`)
frost.push(`-webkit-backdrop-filter: blur(var(--depth-${plane}-blur));`)
blocks.push(renderBlock(`[data-depth='${plane}'][data-frost]`, frost))
}
}
return blocks
}
// ── Shape (SHAPE_ENGINE_RFC) ─────────────────────────────────────────────────
/** Emit the shape tokens — `--shape-smoothing` (superellipse exponent) + `--shape-nest-gap`. */
function appendShapeDeclarations(declarations: string[], shape: ShapePrimitiveSet): void {
declarations.push(cssVar('shape-smoothing', shape.smoothing))
if (shape.nestGap !== undefined) declarations.push(cssVar('shape-nest-gap', shape.nestGap))
}
/**
* `[data-shape='{family}']` sets `corner-shape` (continuity / perceptual family) — opt-in, so the
* magnitude (`border-radius`) stays universal and the corners degrade to the plain arc where
* `corner-shape` is unsupported. `[data-shape-nest]` adds the concentric harmony: a child's
* radius = the parent's `--shape-outer-radius` minus the nest gap, so nested corners stay
* parallel. Magnitude is never touched on the family rules. (SHAPE_ENGINE_RFC)
*/
function renderShapeBlocks(shape: ShapePrimitiveSet): string[] {
const blocks: string[] = []
// Register --shape-smoothing as a typed number so the eventful corner morph (the press-squeeze
// firma, SHAPE_ENGINE_RFC Fase 3) interpolates smoothly instead of jumping. inherits:true keeps
// the :root default + any theme override cascading to every element.
blocks.push(
`@property --shape-smoothing {\n\tsyntax: '<number>';\n\tinherits: true;\n\tinitial-value: ${shape.smoothing};\n}`
)
for (const [family, value] of Object.entries(shape.families)) {
blocks.push(renderBlock(`[data-shape='${family}']`, [`corner-shape: ${value};`]))
}
// Concentric nesting (Fase 2): inner radius = outer − gap, clamped ≥ 0. The parent exposes
// its radius via `--shape-outer-radius` (inherited); the gap defaults to the nest token.
if (shape.nestGap !== undefined) {
blocks.push(
renderBlock(`[data-shape-nest]`, [
`border-radius: max(0px, calc(var(--shape-outer-radius, var(--radius-lg)) - var(--shape-nest-gap)));`
])
)
}
return blocks
}
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
/**
* Fallback breakpoint min-width thresholds (mirrors `$libs/dom/responsive`
* `BREAKPOINTS_DEFAULT`). Used only when `options.breakpoints` is absent
* (build-time / no runtime service). At runtime `ActiveEidos` passes the
* developer-configured `dom.breakpoints.current`, which overrides these — so the
* emitted `--breakpoint-*` tokens + `@media` blocks track the live source.
* `base` is the implicit 0 fallback — not emitted as a media query.
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
*/
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,
breakpoints: Record<string, number>
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
// `container` is the reserved sibling key for container-query overrides
// (emitted inside `@container`); stripped before normalizing, like `composition`.
const container = (tokens as { container?: RecipeContainerQueries }).container
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 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))
}
// Phase 6: container queries. Per-breakpoint token overrides emitted
// inside `@container (min-width: <configured-bp>px)`, scoped to the
// component. Uses the SAME configured breakpoints as `@media`.
if (container) {
blocks.push(...emitContainerQueries(component, container, breakpoints))
}
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
}
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' || key === 'container') continue
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
out[key] = value as RecipeTokenValue
}
return out
}
/**
* Emit `@container` blocks for a recipe's container-query overrides.
*
* For each breakpoint with token entries, emits
* `@container (min-width: <configured-bp>px) { [data-{c}] { --{c}-{token}: value } }`.
* The breakpoints are the SAME configured source as `@media` (threaded from
* `EidosConfig.breakpoints`), so a container override tracks the runtime scale.
* CSS forbids `var()` in `@container` conditions, so the px is emitted literally —
* the generator is the only place this can stay synced with the configured set.
*
* The queried element (`[data-{c}]`) responds to its nearest ANCESTOR query
* container — an ancestor opts in via `data-container` (`container-type: inline-size`).
*/
function emitContainerQueries(
component: string,
container: RecipeContainerQueries,
breakpoints: Record<string, number>
): string[] {
const blocks: string[] = []
for (const bp of STYLE_BREAKPOINT_ORDER) {
const tokens = container[bp]
if (!tokens) continue
const lines = Object.entries(tokens).map(([name, value]) =>
cssVar(name.startsWith('_') ? `_${component}-${name.slice(1)}` : `${component}-${name}`, value)
)
if (!lines.length) continue
const inner = renderBlock(`[data-${component}]`, lines)
blocks.push(`@container (min-width: ${breakpoints[bp]}px) {\n${indentBlock(inner)}\n}`)
}
return blocks
}
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
/**
* 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)'))
}
// Per-role semantic leading/tracking (config-driven) → --leading-{role} /
// --tracking-{role}. Distinct keys from the SCALES (tighter/tight/… · none/tight/…)
// emitted in appendTypographyDeclarations. `--leading-ui` anchors to the label named
// style through its config value (var(--style-label-line-height, …)), so the canonical
// UI leading still follows the foundation. A theme retunes any of these via
// `typography.semanticLeading` / `semanticTracking`.
for (const [name, value] of Object.entries(typography.semanticLeading ?? {})) {
declarations.push(cssVar(`leading-${name}`, value))
}
for (const [name, value] of Object.entries(typography.semanticTracking ?? {})) {
declarations.push(cssVar(`tracking-${name}`, value))
}
}
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) {
const value = scale[step]
// Wide-gamut output (RFC §7, strategy A): the hex stays as the universal
// fallback, then an `oklch()` sibling overrides it where supported — so the
// color uses the display's gamut (more saturated on P3) automatically, with
// NO `@media`, and renders identically in sRGB. Only opaque, parseable steps
// get the sibling; empty / non-color values keep just the fallback line.
declarations.push(cssVar(`scale-${scaleName}-${step}`, value))
const oklch = safeParseColor(value)
if (oklch) declarations.push(cssVar(`scale-${scaleName}-${step}`, oklchToCss(oklch)))
}
}
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()
chore(eidos): close theming-engine backlog — P3-5 guard + P3-8 API tidy + triage Finishes the THEMING_AUDIT P3 backlog. P3-5 (fixed) · appendScaledMetricDeclarations: the `parseFloat(raw) === 0` guard let non-numeric values (auto / var() / calc()) fall into `calc(x * …)` = invalid CSS. Now only finite, non-zero numbers are scaled; zero + non-numeric emit verbatim. No change to the base config output (all values numeric) — pure robustness. P3-8 (fixed) · index.ts no longer re-exports the raw render-* fns. The public render API is the ActiveEidos class (gated by assertValid() + active config); ./lib/render-css stays reachable for internal/tooling use. Redirected the one internal consumer (active-eidos-config.test.ts) to import renderThemeCss from the module. Triaged the rest with rationale (audit updated): - P3-4 deferred · density wins by deterministic source order (stable); the :where(:root) restructure to also support scoped density is high-cost for a theoretical nit. - P3-6 already resolved · dispose() routes documentElement via dom (no direct access). - P3-7 deferred · ActiveEidos reactivity is callback-driven (apply() on pref change) by design; a full runes conversion is a risky refactor with no bug to justify it. - P3-9 deferred · orphan _accent forwarders — low-value recipe surgery with cascade risk. All P3 now fixed-or-decided; only the P2 secondary halves (contract pruning + bare identifier color validation, both edge-case) remain, deferred as low-value. check 0 errors · eidos suite green (3 pre-existing words-track failures unrelated). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
4 months ago
// Only multiply finite, non-zero numeric lengths. Zero stays verbatim (`0px`
// must remain a valid length, `calc(0 * x)` is pointless) and so does any
// non-numeric value (keyword like `auto`, a `var()` or `calc()`) — wrapping
// those in `calc(x * …)` would emit invalid CSS.
const num = parseFloat(raw)
const scalable = Number.isFinite(num) && num !== 0
const density = densityScaleVar ? ` * var(--${densityScaleVar})` : ''
declarations.push(
chore(eidos): close theming-engine backlog — P3-5 guard + P3-8 API tidy + triage Finishes the THEMING_AUDIT P3 backlog. P3-5 (fixed) · appendScaledMetricDeclarations: the `parseFloat(raw) === 0` guard let non-numeric values (auto / var() / calc()) fall into `calc(x * …)` = invalid CSS. Now only finite, non-zero numbers are scaled; zero + non-numeric emit verbatim. No change to the base config output (all values numeric) — pure robustness. P3-8 (fixed) · index.ts no longer re-exports the raw render-* fns. The public render API is the ActiveEidos class (gated by assertValid() + active config); ./lib/render-css stays reachable for internal/tooling use. Redirected the one internal consumer (active-eidos-config.test.ts) to import renderThemeCss from the module. Triaged the rest with rationale (audit updated): - P3-4 deferred · density wins by deterministic source order (stable); the :where(:root) restructure to also support scoped density is high-cost for a theoretical nit. - P3-6 already resolved · dispose() routes documentElement via dom (no direct access). - P3-7 deferred · ActiveEidos reactivity is callback-driven (apply() on pref change) by design; a full runes conversion is a risky refactor with no bug to justify it. - P3-9 deferred · orphan _accent forwarders — low-value recipe surgery with cascade risk. All P3 now fixed-or-decided; only the P2 secondary halves (contract pruning + bare identifier color validation, both edge-case) remain, deferred as low-value. check 0 errors · eidos suite green (3 pre-existing words-track failures unrelated). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
4 months ago
cssVar(`${prefix}-${name}`, scalable ? `calc(${raw}${density} * var(--scaling))` : raw)
)
}
}
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 {
feat(eidos): config-driven @font-face (Phase 2) — fonts as theme data (next/font model) TYPOGRAPHY_ENGINE_RFC Phase 2. Fonts are theme data (each theme owns its families), so @font-face becomes config-driven + generated — like the color palette — instead of a separate hand-written CSS file. This matches next/font / Fontaine (config -> @font-face), above the token-only frameworks (Radix/Tailwind/MUI) that leave loading to you. - config-types: FontFamily += faces (FontFace[]) / axes (FontAxes) / fallback (FontFallback, metric-override) / display / preload. Additive — the family stack still works from `family`+`fallbacks`. - render-css: renderFontFaceBlocks generates @font-face per face from the config, deduped by the real font name (a font shared across slots — Lora as secondary+display — emits once). Optional metric-override fallback @font-face (anti-CLS) injected into the stack as `'{family} Fallback'` when declared. Emitted first in renderStaticCss. - typography.ts: the BASE THEME's 14 @font-face migrated from fonts.css into the config (faces). TTF today (the theme's choice); a theme swaps to woff2/variable + fallback metrics by editing config only. - index.css: drops `@import './themes/fonts.css'` — the @font-face now ships in generated/base.css. (fonts.css superseded; left in place, no longer imported.) - generated/base.css regenerated (14 @font-face, Lora deduped). test: @font-face generation + dedup; merge-without-mutation assertion updated for the faces field. Verified in browser: 3 families registered, files resolve (200), fonts load on demand (swap). check 0 errors; eidos suite green (3 pre-existing words failures only). Deferred (capability typed, theme adopts when it has the assets): variable woff2, metric-override numbers (need fontkit/precompute), <link rel=preload> (head markup). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
4 months ago
const names = [family.family]
// Inject the metric-override fallback right after the real family so it renders
// (matched to the webfont's metrics) until the webfont loads — zero CLS.
if (family.fallback) names.push(`${family.family} Fallback`)
names.push(...(family.fallbacks ?? []))
return names.map(formatFontName).join(', ')
}
/**
* Generate `@font-face` blocks from the theme's font families (config-driven loading,
* next/font model). Deduped by the real font name — a font shared across slots
* (e.g. Lora as both `secondary` and `display`) emits its faces once. Each family may
* also declare a metric-override `fallback` (anti-CLS). (TYPOGRAPHY_ENGINE_RFC §5)
*/
function renderFontFaceBlocks(typography: TypographyPrimitiveSet): string {
const seen = new Set<string>()
const blocks: string[] = []
for (const family of Object.values(typography.families)) {
if (!family.faces || family.faces.length === 0) continue
if (seen.has(family.family)) continue
seen.add(family.family)
for (const face of family.faces) {
blocks.push(renderFontFace(family.family, face, family.display, family.axes))
feat(eidos): config-driven @font-face (Phase 2) — fonts as theme data (next/font model) TYPOGRAPHY_ENGINE_RFC Phase 2. Fonts are theme data (each theme owns its families), so @font-face becomes config-driven + generated — like the color palette — instead of a separate hand-written CSS file. This matches next/font / Fontaine (config -> @font-face), above the token-only frameworks (Radix/Tailwind/MUI) that leave loading to you. - config-types: FontFamily += faces (FontFace[]) / axes (FontAxes) / fallback (FontFallback, metric-override) / display / preload. Additive — the family stack still works from `family`+`fallbacks`. - render-css: renderFontFaceBlocks generates @font-face per face from the config, deduped by the real font name (a font shared across slots — Lora as secondary+display — emits once). Optional metric-override fallback @font-face (anti-CLS) injected into the stack as `'{family} Fallback'` when declared. Emitted first in renderStaticCss. - typography.ts: the BASE THEME's 14 @font-face migrated from fonts.css into the config (faces). TTF today (the theme's choice); a theme swaps to woff2/variable + fallback metrics by editing config only. - index.css: drops `@import './themes/fonts.css'` — the @font-face now ships in generated/base.css. (fonts.css superseded; left in place, no longer imported.) - generated/base.css regenerated (14 @font-face, Lora deduped). test: @font-face generation + dedup; merge-without-mutation assertion updated for the faces field. Verified in browser: 3 families registered, files resolve (200), fonts load on demand (swap). check 0 errors; eidos suite green (3 pre-existing words failures only). Deferred (capability typed, theme adopts when it has the assets): variable woff2, metric-override numbers (need fontkit/precompute), <link rel=preload> (head markup). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
4 months ago
}
if (family.fallback) blocks.push(renderFallbackFontFace(family.family, family.fallback))
}
return blocks.join('\n\n')
}
function renderFontFace(
family: string,
face: FontFace,
defaultDisplay: string | undefined,
axes?: FontFamily['axes']
): string {
feat(eidos): config-driven @font-face (Phase 2) — fonts as theme data (next/font model) TYPOGRAPHY_ENGINE_RFC Phase 2. Fonts are theme data (each theme owns its families), so @font-face becomes config-driven + generated — like the color palette — instead of a separate hand-written CSS file. This matches next/font / Fontaine (config -> @font-face), above the token-only frameworks (Radix/Tailwind/MUI) that leave loading to you. - config-types: FontFamily += faces (FontFace[]) / axes (FontAxes) / fallback (FontFallback, metric-override) / display / preload. Additive — the family stack still works from `family`+`fallbacks`. - render-css: renderFontFaceBlocks generates @font-face per face from the config, deduped by the real font name (a font shared across slots — Lora as secondary+display — emits once). Optional metric-override fallback @font-face (anti-CLS) injected into the stack as `'{family} Fallback'` when declared. Emitted first in renderStaticCss. - typography.ts: the BASE THEME's 14 @font-face migrated from fonts.css into the config (faces). TTF today (the theme's choice); a theme swaps to woff2/variable + fallback metrics by editing config only. - index.css: drops `@import './themes/fonts.css'` — the @font-face now ships in generated/base.css. (fonts.css superseded; left in place, no longer imported.) - generated/base.css regenerated (14 @font-face, Lora deduped). test: @font-face generation + dedup; merge-without-mutation assertion updated for the faces field. Verified in browser: 3 families registered, files resolve (200), fonts load on demand (swap). check 0 errors; eidos suite green (3 pre-existing words failures only). Deferred (capability typed, theme adopts when it has the assets): variable woff2, metric-override numbers (need fontkit/precompute), <link rel=preload> (head markup). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
4 months ago
const src = face.sources
.map((s) => `url('${s.url}')${s.format ? ` format('${s.format}')` : ''}`)
.join(', ')
// Variable fonts: when the face omits an explicit weight and the family declares a
// `wght` axis range, emit the range (`font-weight: 100 900`) so a single face spans the
// whole weight axis. (TYPOGRAPHY_ENGINE_RFC §5 — variable fonts)
const weight = face.weight ?? (axes?.wght ? `${axes.wght[0]} ${axes.wght[1]}` : 400)
feat(eidos): config-driven @font-face (Phase 2) — fonts as theme data (next/font model) TYPOGRAPHY_ENGINE_RFC Phase 2. Fonts are theme data (each theme owns its families), so @font-face becomes config-driven + generated — like the color palette — instead of a separate hand-written CSS file. This matches next/font / Fontaine (config -> @font-face), above the token-only frameworks (Radix/Tailwind/MUI) that leave loading to you. - config-types: FontFamily += faces (FontFace[]) / axes (FontAxes) / fallback (FontFallback, metric-override) / display / preload. Additive — the family stack still works from `family`+`fallbacks`. - render-css: renderFontFaceBlocks generates @font-face per face from the config, deduped by the real font name (a font shared across slots — Lora as secondary+display — emits once). Optional metric-override fallback @font-face (anti-CLS) injected into the stack as `'{family} Fallback'` when declared. Emitted first in renderStaticCss. - typography.ts: the BASE THEME's 14 @font-face migrated from fonts.css into the config (faces). TTF today (the theme's choice); a theme swaps to woff2/variable + fallback metrics by editing config only. - index.css: drops `@import './themes/fonts.css'` — the @font-face now ships in generated/base.css. (fonts.css superseded; left in place, no longer imported.) - generated/base.css regenerated (14 @font-face, Lora deduped). test: @font-face generation + dedup; merge-without-mutation assertion updated for the faces field. Verified in browser: 3 families registered, files resolve (200), fonts load on demand (swap). check 0 errors; eidos suite green (3 pre-existing words failures only). Deferred (capability typed, theme adopts when it has the assets): variable woff2, metric-override numbers (need fontkit/precompute), <link rel=preload> (head markup). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
4 months ago
const lines = [
'@font-face {',
`\tfont-family: '${family}';`,
`\tsrc: ${src};`,
`\tfont-weight: ${weight};`,
feat(eidos): config-driven @font-face (Phase 2) — fonts as theme data (next/font model) TYPOGRAPHY_ENGINE_RFC Phase 2. Fonts are theme data (each theme owns its families), so @font-face becomes config-driven + generated — like the color palette — instead of a separate hand-written CSS file. This matches next/font / Fontaine (config -> @font-face), above the token-only frameworks (Radix/Tailwind/MUI) that leave loading to you. - config-types: FontFamily += faces (FontFace[]) / axes (FontAxes) / fallback (FontFallback, metric-override) / display / preload. Additive — the family stack still works from `family`+`fallbacks`. - render-css: renderFontFaceBlocks generates @font-face per face from the config, deduped by the real font name (a font shared across slots — Lora as secondary+display — emits once). Optional metric-override fallback @font-face (anti-CLS) injected into the stack as `'{family} Fallback'` when declared. Emitted first in renderStaticCss. - typography.ts: the BASE THEME's 14 @font-face migrated from fonts.css into the config (faces). TTF today (the theme's choice); a theme swaps to woff2/variable + fallback metrics by editing config only. - index.css: drops `@import './themes/fonts.css'` — the @font-face now ships in generated/base.css. (fonts.css superseded; left in place, no longer imported.) - generated/base.css regenerated (14 @font-face, Lora deduped). test: @font-face generation + dedup; merge-without-mutation assertion updated for the faces field. Verified in browser: 3 families registered, files resolve (200), fonts load on demand (swap). check 0 errors; eidos suite green (3 pre-existing words failures only). Deferred (capability typed, theme adopts when it has the assets): variable woff2, metric-override numbers (need fontkit/precompute), <link rel=preload> (head markup). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
4 months ago
`\tfont-style: ${face.style ?? 'normal'};`,
`\tfont-display: ${face.display ?? defaultDisplay ?? 'swap'};`
]
if (face.unicodeRange) lines.push(`\tunicode-range: ${face.unicodeRange};`)
lines.push('}')
return lines.join('\n')
}
function renderFallbackFontFace(family: string, fallback: FontFallback): string {
const lines = [
'@font-face {',
`\tfont-family: '${family} Fallback';`,
`\tsrc: local('${fallback.local}');`
]
if (fallback.sizeAdjust) lines.push(`\tsize-adjust: ${fallback.sizeAdjust};`)
if (fallback.ascentOverride) lines.push(`\tascent-override: ${fallback.ascentOverride};`)
if (fallback.descentOverride) lines.push(`\tdescent-override: ${fallback.descentOverride};`)
if (fallback.lineGapOverride) lines.push(`\tline-gap-override: ${fallback.lineGapOverride};`)
lines.push('}')
return lines.join('\n')
}
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.