|
|
|
|
import {
|
|
|
|
|
COLOR_ALPHA_STEPS,
|
|
|
|
|
COLOR_SCALE_STEPS,
|
|
|
|
|
COLOR_ROLES,
|
|
|
|
|
DENSITY_KEYS,
|
Theming: add `scaling` zoom axis (Radix parity), separate from density
Introduce a global zoom axis independent of density, in parity with Radix
Themes' `scaling` (90/95/100/105/110%). Scaling zooms px metrics INCLUDING
typography (font-size, icon-size, space, control-height); density only moves
layout rhythm + control height and leaves text fixed. The two axes compose
multiplicatively.
- config-types: SCALING_KEYS / ScalingKey / DEFAULT_SCALING; DensityPrimitiveSet
drops the dead `scale` + `contentScale` (kept spaceScale, controlScale).
- primitives/static: STATIC_SCALING (0.9..1.1).
- render-css: appendScaledMetricDeclarations wraps metrics in
calc(<raw>[ * var(--density-x-scale)] * var(--scaling)); appendScalingDeclarations
emits --scaling-{key} + --scaling default; renderScalingBlocks emits
[data-scaling] blocks. line-height/radius/border/shadow excluded.
- config + contract: prune the removed density scalars.
- active-eidos: `scaling` / `scalingSource` options, getScaling() on the
preference source, data-scaling projection + dispose cleanup.
- docs: THEMING.md section 23 + 20.1 reconcile; README density/scaling; SCALING_RFC.md.
Verified: npm run check (0 new errors), vitest eidos (0 new regressions),
browser cascade at 90/100/110 scales font/space/control x0.9/x1.1 and leaves
radius/border fixed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
4 months ago
|
|
|
SCALING_KEYS,
|
|
|
|
|
DEFAULT_SCALING,
|
|
|
|
|
type ColorRoleDefinition,
|
|
|
|
|
type ColorRoleSlot,
|
|
|
|
|
type ColorRoleMap,
|
|
|
|
|
type ColorAlphaStep,
|
|
|
|
|
type ColorAlphaScale,
|
|
|
|
|
type ColorScale,
|
|
|
|
|
type ColorScales,
|
|
|
|
|
type BorderColorRoles,
|
|
|
|
|
type BorderPrimitiveSet,
|
|
|
|
|
type ContentColorRoles,
|
|
|
|
|
type DensityPrimitiveSet,
|
|
|
|
|
type EidosCssContract,
|
|
|
|
|
type EidosCssContractToken,
|
|
|
|
|
type EidosCssVariableMap,
|
|
|
|
|
type EidosCssVariableValue,
|
|
|
|
|
type EidosConfig,
|
|
|
|
|
type FontFamily,
|
|
|
|
|
type FocusColorRoles,
|
|
|
|
|
type LayoutPrimitiveSet,
|
|
|
|
|
type PrimitiveSet,
|
feat(eidos): TSC v2.2 (parts + composition) + universal theming coverage
Token Scope Contract universal — no más excepciones arquitectónicas.
Los 3 componentes que vivían fuera de TSC v2.1 (select, avatar,
toggle-group) ahora están dentro del contrato via dos extensiones nuevas.
TSC v2.2 extensiones (lib/config-types.ts + render-css.ts + config.ts):
- `parts: readonly string[]` en RecipeTokenMultiDeclaration — emite
selectores comma-separados (`[data-{c}-x], [data-{c}-y]`) para
componentes con data-color cascadeado per-part. Consumer: select.
- `composition: { foreignRecipe: { targetSelector, tokens } }` sibling
key — overrides cross-recipe scoped a la cascade del host. Consumer:
toggle-group modifica `--toggle-palette-*` en sus items.
Migraciones:
- select: 3 private `_accent-{track,border,text}` con parts: ['trigger',
'content'] + 7 cascades color:X each. Removed orphan `_accent-solid`
(CSS no consumía).
- avatar: 6 tokens via composite scopes `['variant:X', 'color:Y']` con
matrix helper inline. Badge usa parts: ['badge']. Reemplaza 24 bloques
CSS × 2 partes.
- toggle-group: composition block con 8 palette tokens × 4 colors.
Reemplaza 4 bloques CSS per-color.
Bug toggle-group post-composition (encontrado y arreglado):
Tras la composition migration, el cascade del toggle-group seguía roto
porque los tokens derivados (`--toggle-solid-on-bg`, `--toggle-outline-fg`,
etc.) viven en scope `[data-toggle]`. El `[data-toggle-group-item]` es
sibling (no descendant), así que `var(--toggle-solid-on-bg)` resolvía
undefined. Fix: inlined derivation expressions directamente en
`[data-toggle-group-item]` y sus variant cascades (solid/outline/ghost),
referenciando palette tokens en su propio scope local.
Validador + emisor + contract:
- `validateRecipeComposition` valida el shape `{ targetSelector, tokens }`
y rechaza composition entries con scope='root'.
- `stripCompositionKey` + `emitComposition` separan el pipeline.
- `appendRecipeContractTokens` skip-list para `composition` (no aparece
como fake `--{c}-composition` knob).
- `tokenKeys`/`tokenEntries` helpers en recipe-css-contract.test.ts
filtran composition en todos los iteradores.
Documentación:
- THEMING.md §18 reescrito como "Cobertura universal de TSC". §7
extendido con subsecciones "Multi-part scope" y "Cross-recipe
composition" + ejemplos completos. TOC actualizado.
- eidos/README.md tabla de referencia ampliada con TSC v2.2 + §18.
- CLAUDE.md gana hand-off "2026-05-27 #5" (TSC v2.2 + cobertura universal).
- CONTINUE.md reescrito al estado actual de la sesión.
Working tree también incluye sprint Words en paralelo (multiple authors):
slash menu, find/replace regex, code language picker, table audit,
toolbar family menu, code highlight engine.
Tests: 786/786 pass en src/uix/{eidos,morfo,soma,sema}. `npm run check`:
6 errores pre-existentes (lib/_demo, soma/components/internal,
web/routes/active) no relacionados.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
5 months ago
|
|
|
type RecipeComposition,
|
|
|
|
|
type RecipeTokenSet,
|
feat(eidos): TSC v2.2 (parts + composition) + universal theming coverage
Token Scope Contract universal — no más excepciones arquitectónicas.
Los 3 componentes que vivían fuera de TSC v2.1 (select, avatar,
toggle-group) ahora están dentro del contrato via dos extensiones nuevas.
TSC v2.2 extensiones (lib/config-types.ts + render-css.ts + config.ts):
- `parts: readonly string[]` en RecipeTokenMultiDeclaration — emite
selectores comma-separados (`[data-{c}-x], [data-{c}-y]`) para
componentes con data-color cascadeado per-part. Consumer: select.
- `composition: { foreignRecipe: { targetSelector, tokens } }` sibling
key — overrides cross-recipe scoped a la cascade del host. Consumer:
toggle-group modifica `--toggle-palette-*` en sus items.
Migraciones:
- select: 3 private `_accent-{track,border,text}` con parts: ['trigger',
'content'] + 7 cascades color:X each. Removed orphan `_accent-solid`
(CSS no consumía).
- avatar: 6 tokens via composite scopes `['variant:X', 'color:Y']` con
matrix helper inline. Badge usa parts: ['badge']. Reemplaza 24 bloques
CSS × 2 partes.
- toggle-group: composition block con 8 palette tokens × 4 colors.
Reemplaza 4 bloques CSS per-color.
Bug toggle-group post-composition (encontrado y arreglado):
Tras la composition migration, el cascade del toggle-group seguía roto
porque los tokens derivados (`--toggle-solid-on-bg`, `--toggle-outline-fg`,
etc.) viven en scope `[data-toggle]`. El `[data-toggle-group-item]` es
sibling (no descendant), así que `var(--toggle-solid-on-bg)` resolvía
undefined. Fix: inlined derivation expressions directamente en
`[data-toggle-group-item]` y sus variant cascades (solid/outline/ghost),
referenciando palette tokens en su propio scope local.
Validador + emisor + contract:
- `validateRecipeComposition` valida el shape `{ targetSelector, tokens }`
y rechaza composition entries con scope='root'.
- `stripCompositionKey` + `emitComposition` separan el pipeline.
- `appendRecipeContractTokens` skip-list para `composition` (no aparece
como fake `--{c}-composition` knob).
- `tokenKeys`/`tokenEntries` helpers en recipe-css-contract.test.ts
filtran composition en todos los iteradores.
Documentación:
- THEMING.md §18 reescrito como "Cobertura universal de TSC". §7
extendido con subsecciones "Multi-part scope" y "Cross-recipe
composition" + ejemplos completos. TOC actualizado.
- eidos/README.md tabla de referencia ampliada con TSC v2.2 + §18.
- CLAUDE.md gana hand-off "2026-05-27 #5" (TSC v2.2 + cobertura universal).
- CONTINUE.md reescrito al estado actual de la sesión.
Working tree también incluye sprint Words en paralelo (multiple authors):
slash menu, find/replace regex, code language picker, table audit,
toolbar family menu, code highlight engine.
Tests: 786/786 pass en src/uix/{eidos,morfo,soma,sema}. `npm run check`:
6 errores pre-existentes (lib/_demo, soma/components/internal,
web/routes/active) no relacionados.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
5 months ago
|
|
|
type RecipeTokenValue,
|
|
|
|
|
type RecipeTokenDeclaration,
|
|
|
|
|
type RecipeTokenMultiDeclaration,
|
|
|
|
|
type SizePrimitiveSet,
|
feat(eidos): TSC v2.2 (parts + composition) + universal theming coverage
Token Scope Contract universal — no más excepciones arquitectónicas.
Los 3 componentes que vivían fuera de TSC v2.1 (select, avatar,
toggle-group) ahora están dentro del contrato via dos extensiones nuevas.
TSC v2.2 extensiones (lib/config-types.ts + render-css.ts + config.ts):
- `parts: readonly string[]` en RecipeTokenMultiDeclaration — emite
selectores comma-separados (`[data-{c}-x], [data-{c}-y]`) para
componentes con data-color cascadeado per-part. Consumer: select.
- `composition: { foreignRecipe: { targetSelector, tokens } }` sibling
key — overrides cross-recipe scoped a la cascade del host. Consumer:
toggle-group modifica `--toggle-palette-*` en sus items.
Migraciones:
- select: 3 private `_accent-{track,border,text}` con parts: ['trigger',
'content'] + 7 cascades color:X each. Removed orphan `_accent-solid`
(CSS no consumía).
- avatar: 6 tokens via composite scopes `['variant:X', 'color:Y']` con
matrix helper inline. Badge usa parts: ['badge']. Reemplaza 24 bloques
CSS × 2 partes.
- toggle-group: composition block con 8 palette tokens × 4 colors.
Reemplaza 4 bloques CSS per-color.
Bug toggle-group post-composition (encontrado y arreglado):
Tras la composition migration, el cascade del toggle-group seguía roto
porque los tokens derivados (`--toggle-solid-on-bg`, `--toggle-outline-fg`,
etc.) viven en scope `[data-toggle]`. El `[data-toggle-group-item]` es
sibling (no descendant), así que `var(--toggle-solid-on-bg)` resolvía
undefined. Fix: inlined derivation expressions directamente en
`[data-toggle-group-item]` y sus variant cascades (solid/outline/ghost),
referenciando palette tokens en su propio scope local.
Validador + emisor + contract:
- `validateRecipeComposition` valida el shape `{ targetSelector, tokens }`
y rechaza composition entries con scope='root'.
- `stripCompositionKey` + `emitComposition` separan el pipeline.
- `appendRecipeContractTokens` skip-list para `composition` (no aparece
como fake `--{c}-composition` knob).
- `tokenKeys`/`tokenEntries` helpers en recipe-css-contract.test.ts
filtran composition en todos los iteradores.
Documentación:
- THEMING.md §18 reescrito como "Cobertura universal de TSC". §7
extendido con subsecciones "Multi-part scope" y "Cross-recipe
composition" + ejemplos completos. TOC actualizado.
- eidos/README.md tabla de referencia ampliada con TSC v2.2 + §18.
- CLAUDE.md gana hand-off "2026-05-27 #5" (TSC v2.2 + cobertura universal).
- CONTINUE.md reescrito al estado actual de la sesión.
Working tree también incluye sprint Words en paralelo (multiple authors):
slash menu, find/replace regex, code language picker, table audit,
toolbar family menu, code highlight engine.
Tests: 786/786 pass en src/uix/{eidos,morfo,soma,sema}. `npm run check`:
6 errores pre-existentes (lib/_demo, soma/components/internal,
web/routes/active) no relacionados.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
5 months ago
|
|
|
type TokenScope,
|
|
|
|
|
type AtomicScope,
|
|
|
|
|
type LeafScope,
|
|
|
|
|
type SurfaceColorRoles,
|
|
|
|
|
type ThemeColorSet,
|
|
|
|
|
type TypographyPrimitiveSet
|
|
|
|
|
} from './config-types'
|
|
|
|
|
import { createEidosCssContract } from './contract'
|
|
|
|
|
import { toKebab } from './utils'
|
Theming: add `scaling` zoom axis (Radix parity), separate from density
Introduce a global zoom axis independent of density, in parity with Radix
Themes' `scaling` (90/95/100/105/110%). Scaling zooms px metrics INCLUDING
typography (font-size, icon-size, space, control-height); density only moves
layout rhythm + control height and leaves text fixed. The two axes compose
multiplicatively.
- config-types: SCALING_KEYS / ScalingKey / DEFAULT_SCALING; DensityPrimitiveSet
drops the dead `scale` + `contentScale` (kept spaceScale, controlScale).
- primitives/static: STATIC_SCALING (0.9..1.1).
- render-css: appendScaledMetricDeclarations wraps metrics in
calc(<raw>[ * var(--density-x-scale)] * var(--scaling)); appendScalingDeclarations
emits --scaling-{key} + --scaling default; renderScalingBlocks emits
[data-scaling] blocks. line-height/radius/border/shadow excluded.
- config + contract: prune the removed density scalars.
- active-eidos: `scaling` / `scalingSource` options, getScaling() on the
preference source, data-scaling projection + dispose cleanup.
- docs: THEMING.md section 23 + 20.1 reconcile; README density/scaling; SCALING_RFC.md.
Verified: npm run check (0 new errors), vitest eidos (0 new regressions),
browser cascade at 90/100/110 scales font/space/control x0.9/x1.1 and leaves
radius/border fixed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
4 months ago
|
|
|
import { STATIC_SCALING } from './primitives/static'
|
|
|
|
|
import { EidosCssVariableError, EidosThemeNotFoundError } from '../errors'
|
|
|
|
|
|
|
|
|
|
export interface RenderThemeCssOptions {
|
|
|
|
|
readonly selector?: string
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface RenderContractCssOptions {
|
|
|
|
|
readonly staticSelector?: string
|
|
|
|
|
readonly themeSelector?: string
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface RenderCssVariablesOptions {
|
|
|
|
|
readonly selector?: string
|
|
|
|
|
readonly contract?: EidosCssContract | readonly EidosCssContractToken[]
|
|
|
|
|
readonly strict?: boolean
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const DEFAULT_COLOR_ROLE_SLOT_STEPS: Record<ColorRoleSlot, string> = {
|
|
|
|
|
track: '1',
|
|
|
|
|
element: '3',
|
|
|
|
|
hover: '4',
|
|
|
|
|
active: '5',
|
|
|
|
|
border: '6',
|
|
|
|
|
solid: '9',
|
|
|
|
|
solidHover: '10',
|
|
|
|
|
text: '11',
|
|
|
|
|
contrast: '12'
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const DEFAULT_COLOR_ALPHA_PERCENTAGES: Record<ColorAlphaStep, string> = {
|
|
|
|
|
'1': '3%',
|
|
|
|
|
'2': '5%',
|
|
|
|
|
'3': '8%',
|
|
|
|
|
'4': '12%',
|
|
|
|
|
'5': '16%',
|
|
|
|
|
'6': '22%',
|
|
|
|
|
'7': '30%',
|
|
|
|
|
'8': '40%',
|
|
|
|
|
'9': '56%',
|
|
|
|
|
'10': '68%',
|
|
|
|
|
'11': '80%',
|
|
|
|
|
'12': '92%'
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const GENERIC_FONT_FAMILIES = new Set([
|
|
|
|
|
'serif',
|
|
|
|
|
'sans-serif',
|
|
|
|
|
'monospace',
|
|
|
|
|
'cursive',
|
|
|
|
|
'fantasy',
|
|
|
|
|
'system-ui',
|
|
|
|
|
'ui-serif',
|
|
|
|
|
'ui-sans-serif',
|
|
|
|
|
'ui-monospace',
|
|
|
|
|
'emoji',
|
|
|
|
|
'math',
|
|
|
|
|
'fangsong'
|
|
|
|
|
])
|
|
|
|
|
|
|
|
|
|
export function renderStaticCss(options: EidosConfig): string {
|
|
|
|
|
const { primitives } = options
|
|
|
|
|
const declarations: string[] = []
|
|
|
|
|
|
Theming: add `scaling` zoom axis (Radix parity), separate from density
Introduce a global zoom axis independent of density, in parity with Radix
Themes' `scaling` (90/95/100/105/110%). Scaling zooms px metrics INCLUDING
typography (font-size, icon-size, space, control-height); density only moves
layout rhythm + control height and leaves text fixed. The two axes compose
multiplicatively.
- config-types: SCALING_KEYS / ScalingKey / DEFAULT_SCALING; DensityPrimitiveSet
drops the dead `scale` + `contentScale` (kept spaceScale, controlScale).
- primitives/static: STATIC_SCALING (0.9..1.1).
- render-css: appendScaledMetricDeclarations wraps metrics in
calc(<raw>[ * var(--density-x-scale)] * var(--scaling)); appendScalingDeclarations
emits --scaling-{key} + --scaling default; renderScalingBlocks emits
[data-scaling] blocks. line-height/radius/border/shadow excluded.
- config + contract: prune the removed density scalars.
- active-eidos: `scaling` / `scalingSource` options, getScaling() on the
preference source, data-scaling projection + dispose cleanup.
- docs: THEMING.md section 23 + 20.1 reconcile; README density/scaling; SCALING_RFC.md.
Verified: npm run check (0 new errors), vitest eidos (0 new regressions),
browser cascade at 90/100/110 scales font/space/control x0.9/x1.1 and leaves
radius/border fixed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
4 months ago
|
|
|
// Spacing + control heights scale with BOTH the active density and the global
|
|
|
|
|
// `--scaling` zoom (multiplicative): `calc(value * density-scale * scaling)`.
|
|
|
|
|
// The `[data-density]` / `[data-scaling]` blocks rebind those vars. At
|
|
|
|
|
// comfortable + scaling=100 both are 1, so output equals the raw value.
|
|
|
|
|
appendScaledMetricDeclarations(declarations, 'space', primitives.space, 'density-space-scale')
|
|
|
|
|
appendScaledMetricDeclarations(
|
|
|
|
|
declarations,
|
|
|
|
|
'control-height',
|
|
|
|
|
primitives.controlHeight,
|
|
|
|
|
'density-control-scale'
|
|
|
|
|
)
|
|
|
|
|
appendRecordDeclarations(declarations, 'radius', primitives.radius)
|
|
|
|
|
|
|
|
|
|
if (primitives.border) {
|
|
|
|
|
appendBorderDeclarations(declarations, primitives.border)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (primitives.focusRing) {
|
|
|
|
|
declarations.push(cssVar('focus-ring-offset', primitives.focusRing.offset))
|
|
|
|
|
declarations.push(cssVar('focus-ring-width', primitives.focusRing.width))
|
|
|
|
|
declarations.push(
|
|
|
|
|
cssVar(
|
|
|
|
|
'focus-ring',
|
|
|
|
|
'0 0 0 var(--focus-ring-offset) var(--color-surface-default), ' +
|
|
|
|
|
'0 0 0 calc(var(--focus-ring-offset) + var(--focus-ring-width)) ' +
|
|
|
|
|
'var(--focus-ring-color)'
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
declarations.push(
|
|
|
|
|
cssVar(
|
|
|
|
|
'focus-ring-error',
|
|
|
|
|
'0 0 0 var(--focus-ring-offset) var(--color-surface-default), ' +
|
|
|
|
|
'0 0 0 calc(var(--focus-ring-offset) + var(--focus-ring-width)) ' +
|
|
|
|
|
'var(--focus-ring-color-error)'
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (primitives.layout) {
|
|
|
|
|
appendLayoutDeclarations(declarations, primitives.layout)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (primitives.density) {
|
|
|
|
|
appendDensityDeclarations(declarations, primitives.density)
|
|
|
|
|
}
|
|
|
|
|
|
Theming: add `scaling` zoom axis (Radix parity), separate from density
Introduce a global zoom axis independent of density, in parity with Radix
Themes' `scaling` (90/95/100/105/110%). Scaling zooms px metrics INCLUDING
typography (font-size, icon-size, space, control-height); density only moves
layout rhythm + control height and leaves text fixed. The two axes compose
multiplicatively.
- config-types: SCALING_KEYS / ScalingKey / DEFAULT_SCALING; DensityPrimitiveSet
drops the dead `scale` + `contentScale` (kept spaceScale, controlScale).
- primitives/static: STATIC_SCALING (0.9..1.1).
- render-css: appendScaledMetricDeclarations wraps metrics in
calc(<raw>[ * var(--density-x-scale)] * var(--scaling)); appendScalingDeclarations
emits --scaling-{key} + --scaling default; renderScalingBlocks emits
[data-scaling] blocks. line-height/radius/border/shadow excluded.
- config + contract: prune the removed density scalars.
- active-eidos: `scaling` / `scalingSource` options, getScaling() on the
preference source, data-scaling projection + dispose cleanup.
- docs: THEMING.md section 23 + 20.1 reconcile; README density/scaling; SCALING_RFC.md.
Verified: npm run check (0 new errors), vitest eidos (0 new regressions),
browser cascade at 90/100/110 scales font/space/control x0.9/x1.1 and leaves
radius/border fixed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
4 months ago
|
|
|
// 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) {
|
Theming: add `scaling` zoom axis (Radix parity), separate from density
Introduce a global zoom axis independent of density, in parity with Radix
Themes' `scaling` (90/95/100/105/110%). Scaling zooms px metrics INCLUDING
typography (font-size, icon-size, space, control-height); density only moves
layout rhythm + control height and leaves text fixed. The two axes compose
multiplicatively.
- config-types: SCALING_KEYS / ScalingKey / DEFAULT_SCALING; DensityPrimitiveSet
drops the dead `scale` + `contentScale` (kept spaceScale, controlScale).
- primitives/static: STATIC_SCALING (0.9..1.1).
- render-css: appendScaledMetricDeclarations wraps metrics in
calc(<raw>[ * var(--density-x-scale)] * var(--scaling)); appendScalingDeclarations
emits --scaling-{key} + --scaling default; renderScalingBlocks emits
[data-scaling] blocks. line-height/radius/border/shadow excluded.
- config + contract: prune the removed density scalars.
- active-eidos: `scaling` / `scalingSource` options, getScaling() on the
preference source, data-scaling projection + dispose cleanup.
- docs: THEMING.md section 23 + 20.1 reconcile; README density/scaling; SCALING_RFC.md.
Verified: npm run check (0 new errors), vitest eidos (0 new regressions),
browser cascade at 90/100/110 scales font/space/control x0.9/x1.1 and leaves
radius/border fixed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
4 months ago
|
|
|
appendScaledMetricDeclarations(declarations, 'icon-size', primitives.icon.size)
|
|
|
|
|
appendRecordDeclarations(declarations, 'icon-stroke-width', primitives.icon.strokeWidth)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
appendRecordDeclarations(declarations, 'opacity', primitives.opacity)
|
|
|
|
|
appendRecordDeclarations(declarations, 'z-index', primitives.zIndex)
|
|
|
|
|
|
|
|
|
|
if (primitives.typography) {
|
|
|
|
|
appendTypographyDeclarations(declarations, primitives.typography)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (primitives.size) {
|
|
|
|
|
appendSizeDeclarations(declarations, primitives.size)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
appendTransitionAliasDeclarations(declarations, options)
|
feat(eidos): TSC v2.2 (parts + composition) + universal theming coverage
Token Scope Contract universal — no más excepciones arquitectónicas.
Los 3 componentes que vivían fuera de TSC v2.1 (select, avatar,
toggle-group) ahora están dentro del contrato via dos extensiones nuevas.
TSC v2.2 extensiones (lib/config-types.ts + render-css.ts + config.ts):
- `parts: readonly string[]` en RecipeTokenMultiDeclaration — emite
selectores comma-separados (`[data-{c}-x], [data-{c}-y]`) para
componentes con data-color cascadeado per-part. Consumer: select.
- `composition: { foreignRecipe: { targetSelector, tokens } }` sibling
key — overrides cross-recipe scoped a la cascade del host. Consumer:
toggle-group modifica `--toggle-palette-*` en sus items.
Migraciones:
- select: 3 private `_accent-{track,border,text}` con parts: ['trigger',
'content'] + 7 cascades color:X each. Removed orphan `_accent-solid`
(CSS no consumía).
- avatar: 6 tokens via composite scopes `['variant:X', 'color:Y']` con
matrix helper inline. Badge usa parts: ['badge']. Reemplaza 24 bloques
CSS × 2 partes.
- toggle-group: composition block con 8 palette tokens × 4 colors.
Reemplaza 4 bloques CSS per-color.
Bug toggle-group post-composition (encontrado y arreglado):
Tras la composition migration, el cascade del toggle-group seguía roto
porque los tokens derivados (`--toggle-solid-on-bg`, `--toggle-outline-fg`,
etc.) viven en scope `[data-toggle]`. El `[data-toggle-group-item]` es
sibling (no descendant), así que `var(--toggle-solid-on-bg)` resolvía
undefined. Fix: inlined derivation expressions directamente en
`[data-toggle-group-item]` y sus variant cascades (solid/outline/ghost),
referenciando palette tokens en su propio scope local.
Validador + emisor + contract:
- `validateRecipeComposition` valida el shape `{ targetSelector, tokens }`
y rechaza composition entries con scope='root'.
- `stripCompositionKey` + `emitComposition` separan el pipeline.
- `appendRecipeContractTokens` skip-list para `composition` (no aparece
como fake `--{c}-composition` knob).
- `tokenKeys`/`tokenEntries` helpers en recipe-css-contract.test.ts
filtran composition en todos los iteradores.
Documentación:
- THEMING.md §18 reescrito como "Cobertura universal de TSC". §7
extendido con subsecciones "Multi-part scope" y "Cross-recipe
composition" + ejemplos completos. TOC actualizado.
- eidos/README.md tabla de referencia ampliada con TSC v2.2 + §18.
- CLAUDE.md gana hand-off "2026-05-27 #5" (TSC v2.2 + cobertura universal).
- CONTINUE.md reescrito al estado actual de la sesión.
Working tree también incluye sprint Words en paralelo (multiple authors):
slash menu, find/replace regex, code language picker, table audit,
toolbar family menu, code highlight engine.
Tests: 786/786 pass en src/uix/{eidos,morfo,soma,sema}. `npm run check`:
6 errores pre-existentes (lib/_demo, soma/components/internal,
web/routes/active) no relacionados.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
5 months ago
|
|
|
const scopedRecipeBlocks = appendRecipeDeclarations(declarations, options.recipes)
|
|
|
|
|
|
feat(eidos): TSC v2.2 (parts + composition) + universal theming coverage
Token Scope Contract universal — no más excepciones arquitectónicas.
Los 3 componentes que vivían fuera de TSC v2.1 (select, avatar,
toggle-group) ahora están dentro del contrato via dos extensiones nuevas.
TSC v2.2 extensiones (lib/config-types.ts + render-css.ts + config.ts):
- `parts: readonly string[]` en RecipeTokenMultiDeclaration — emite
selectores comma-separados (`[data-{c}-x], [data-{c}-y]`) para
componentes con data-color cascadeado per-part. Consumer: select.
- `composition: { foreignRecipe: { targetSelector, tokens } }` sibling
key — overrides cross-recipe scoped a la cascade del host. Consumer:
toggle-group modifica `--toggle-palette-*` en sus items.
Migraciones:
- select: 3 private `_accent-{track,border,text}` con parts: ['trigger',
'content'] + 7 cascades color:X each. Removed orphan `_accent-solid`
(CSS no consumía).
- avatar: 6 tokens via composite scopes `['variant:X', 'color:Y']` con
matrix helper inline. Badge usa parts: ['badge']. Reemplaza 24 bloques
CSS × 2 partes.
- toggle-group: composition block con 8 palette tokens × 4 colors.
Reemplaza 4 bloques CSS per-color.
Bug toggle-group post-composition (encontrado y arreglado):
Tras la composition migration, el cascade del toggle-group seguía roto
porque los tokens derivados (`--toggle-solid-on-bg`, `--toggle-outline-fg`,
etc.) viven en scope `[data-toggle]`. El `[data-toggle-group-item]` es
sibling (no descendant), así que `var(--toggle-solid-on-bg)` resolvía
undefined. Fix: inlined derivation expressions directamente en
`[data-toggle-group-item]` y sus variant cascades (solid/outline/ghost),
referenciando palette tokens en su propio scope local.
Validador + emisor + contract:
- `validateRecipeComposition` valida el shape `{ targetSelector, tokens }`
y rechaza composition entries con scope='root'.
- `stripCompositionKey` + `emitComposition` separan el pipeline.
- `appendRecipeContractTokens` skip-list para `composition` (no aparece
como fake `--{c}-composition` knob).
- `tokenKeys`/`tokenEntries` helpers en recipe-css-contract.test.ts
filtran composition en todos los iteradores.
Documentación:
- THEMING.md §18 reescrito como "Cobertura universal de TSC". §7
extendido con subsecciones "Multi-part scope" y "Cross-recipe
composition" + ejemplos completos. TOC actualizado.
- eidos/README.md tabla de referencia ampliada con TSC v2.2 + §18.
- CLAUDE.md gana hand-off "2026-05-27 #5" (TSC v2.2 + cobertura universal).
- CONTINUE.md reescrito al estado actual de la sesión.
Working tree también incluye sprint Words en paralelo (multiple authors):
slash menu, find/replace regex, code language picker, table audit,
toolbar family menu, code highlight engine.
Tests: 786/786 pass en src/uix/{eidos,morfo,soma,sema}. `npm run check`:
6 errores pre-existentes (lib/_demo, soma/components/internal,
web/routes/active) no relacionados.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
5 months ago
|
|
|
const blocks = [renderBlock(':root', declarations), ...scopedRecipeBlocks]
|
feat(eidos foundation): emit --style-{name}-* tokens for named typography styles
`STATIC_TYPOGRAPHY.styles` already defined 11 named typography styles
(hero, h1..h6, body, prose, label, caption, code) but the renderer
only emitted families / sizes / weights — the style entries lived
as data with no CSS reflection.
Extend `appendTypographyDeclarations` so each named style emits:
- `--style-{name}-font-family` → `var(--font-family-{family})`
- `--style-{name}-font-size` → `var(--font-size-{size})`
- `--style-{name}-line-height` → literal or `var(--font-line-height-{size})`
- `--style-{name}-letter-spacing` → literal or `var(--font-letter-spacing-{size})`
- `--style-{name}-font-weight` → `var(--font-weight-{weight})` (or numeric)
- `--style-{name}-color` → `var(--color-{dot.path → dash-path})`
Responsive sizes (e.g. `hero.size = { base: 'xxl', md: 'xxxl' }`)
emit the base value into `:root` and per-breakpoint
`@media (min-width: …)` blocks that override the same variables.
Breakpoint thresholds match `$libs/dom/responsive` (sm: 480, md: 768,
lg: 1024, xl: 1280, xxl: 1536).
`renderStaticCss` now iterates `STYLE_BREAKPOINT_ORDER` after the
main `:root` block and appends one media-query block per breakpoint
that has at least one responsive override. The `indentBlock` helper
preserves indentation inside the wrapper.
`base.css` regenerated. svelte-check 0 errors, `npm run component:audit`
81 / 81 PASS unchanged. This is foundation-only; no components consume
the new tokens yet — that lands in the typography port (Text + Heading
+ Display + Code + CodeBlock + Kbd + Mark + Highlight + Link).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
5 months ago
|
|
|
|
|
|
|
|
// Responsive typography style overrides — one media-query block per
|
|
|
|
|
// breakpoint that has at least one responsive size override.
|
|
|
|
|
const styleResponsive = collectTypographyStyleResponsive(primitives.typography)
|
|
|
|
|
for (const bp of STYLE_BREAKPOINT_ORDER) {
|
|
|
|
|
const lines = styleResponsive[bp]
|
|
|
|
|
if (!lines || lines.length === 0) continue
|
|
|
|
|
const width = STYLE_BREAKPOINT_WIDTHS[bp]
|
|
|
|
|
const rootBlock = renderBlock(':root', lines)
|
|
|
|
|
blocks.push(`@media (min-width: ${width}px) {\n${indentBlock(rootBlock)}\n}`)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (primitives.density) blocks.push(renderDensityBlocks())
|
Theming: add `scaling` zoom axis (Radix parity), separate from density
Introduce a global zoom axis independent of density, in parity with Radix
Themes' `scaling` (90/95/100/105/110%). Scaling zooms px metrics INCLUDING
typography (font-size, icon-size, space, control-height); density only moves
layout rhythm + control height and leaves text fixed. The two axes compose
multiplicatively.
- config-types: SCALING_KEYS / ScalingKey / DEFAULT_SCALING; DensityPrimitiveSet
drops the dead `scale` + `contentScale` (kept spaceScale, controlScale).
- primitives/static: STATIC_SCALING (0.9..1.1).
- render-css: appendScaledMetricDeclarations wraps metrics in
calc(<raw>[ * var(--density-x-scale)] * var(--scaling)); appendScalingDeclarations
emits --scaling-{key} + --scaling default; renderScalingBlocks emits
[data-scaling] blocks. line-height/radius/border/shadow excluded.
- config + contract: prune the removed density scalars.
- active-eidos: `scaling` / `scalingSource` options, getScaling() on the
preference source, data-scaling projection + dispose cleanup.
- docs: THEMING.md section 23 + 20.1 reconcile; README density/scaling; SCALING_RFC.md.
Verified: npm run check (0 new errors), vitest eidos (0 new regressions),
browser cascade at 90/100/110 scales font/space/control x0.9/x1.1 and leaves
radius/border fixed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
4 months ago
|
|
|
blocks.push(renderScalingBlocks())
|
|
|
|
|
|
|
|
|
|
return blocks.join('\n\n')
|
|
|
|
|
}
|
|
|
|
|
|
feat(eidos foundation): emit --style-{name}-* tokens for named typography styles
`STATIC_TYPOGRAPHY.styles` already defined 11 named typography styles
(hero, h1..h6, body, prose, label, caption, code) but the renderer
only emitted families / sizes / weights — the style entries lived
as data with no CSS reflection.
Extend `appendTypographyDeclarations` so each named style emits:
- `--style-{name}-font-family` → `var(--font-family-{family})`
- `--style-{name}-font-size` → `var(--font-size-{size})`
- `--style-{name}-line-height` → literal or `var(--font-line-height-{size})`
- `--style-{name}-letter-spacing` → literal or `var(--font-letter-spacing-{size})`
- `--style-{name}-font-weight` → `var(--font-weight-{weight})` (or numeric)
- `--style-{name}-color` → `var(--color-{dot.path → dash-path})`
Responsive sizes (e.g. `hero.size = { base: 'xxl', md: 'xxxl' }`)
emit the base value into `:root` and per-breakpoint
`@media (min-width: …)` blocks that override the same variables.
Breakpoint thresholds match `$libs/dom/responsive` (sm: 480, md: 768,
lg: 1024, xl: 1280, xxl: 1536).
`renderStaticCss` now iterates `STYLE_BREAKPOINT_ORDER` after the
main `:root` block and appends one media-query block per breakpoint
that has at least one responsive override. The `indentBlock` helper
preserves indentation inside the wrapper.
`base.css` regenerated. svelte-check 0 errors, `npm run component:audit`
81 / 81 PASS unchanged. This is foundation-only; no components consume
the new tokens yet — that lands in the typography port (Text + Heading
+ Display + Code + CodeBlock + Kbd + Mark + Highlight + Link).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
5 months ago
|
|
|
function indentBlock(block: string): string {
|
|
|
|
|
return block
|
|
|
|
|
.split('\n')
|
|
|
|
|
.map((line) => (line ? `\t${line}` : line))
|
|
|
|
|
.join('\n')
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function renderContractCss(
|
|
|
|
|
options: EidosConfig,
|
|
|
|
|
renderOptions: RenderContractCssOptions = {}
|
|
|
|
|
): string {
|
|
|
|
|
const contract = createEidosCssContract(options)
|
|
|
|
|
const staticDeclarations = contract.static.map((token) => emptyCssVar(token.name))
|
|
|
|
|
const themeDeclarations = contract.theme.map((token) => emptyCssVar(token.name))
|
|
|
|
|
|
|
|
|
|
return [
|
|
|
|
|
renderBlock(renderOptions.staticSelector ?? ':root', staticDeclarations),
|
|
|
|
|
renderBlock(renderOptions.themeSelector ?? '[data-theme]', themeDeclarations)
|
|
|
|
|
].join('\n\n')
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function renderCssVariables(
|
|
|
|
|
variables: EidosCssVariableMap,
|
|
|
|
|
renderOptions: RenderCssVariablesOptions = {}
|
|
|
|
|
): string {
|
|
|
|
|
const declarations: string[] = []
|
|
|
|
|
const strict = renderOptions.strict ?? renderOptions.contract !== undefined
|
|
|
|
|
const contractNames = renderOptions.contract
|
|
|
|
|
? getContractVariableNames(renderOptions.contract)
|
|
|
|
|
: undefined
|
|
|
|
|
const unknownNames: string[] = []
|
|
|
|
|
|
|
|
|
|
for (const [rawName, value] of Object.entries(variables)) {
|
|
|
|
|
if (value === null || value === undefined) continue
|
|
|
|
|
|
|
|
|
|
const name = normalizeCssVariableName(rawName)
|
|
|
|
|
if (!name) {
|
|
|
|
|
unknownNames.push(rawName)
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (contractNames && !contractNames.has(name)) {
|
|
|
|
|
unknownNames.push(`--${name}`)
|
|
|
|
|
if (strict) continue
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
declarations.push(cssVar(name, assertCssVariableValue(`--${name}`, value)))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (unknownNames.length && strict) {
|
|
|
|
|
throw new EidosCssVariableError(unknownNames)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return declarations.length ? renderBlock(renderOptions.selector ?? ':root', declarations) : ''
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function renderThemeCss(
|
|
|
|
|
options: EidosConfig,
|
|
|
|
|
themeId: string,
|
|
|
|
|
renderOptions: RenderThemeCssOptions = {}
|
|
|
|
|
): string {
|
|
|
|
|
const theme = options.themes?.[themeId]
|
|
|
|
|
if (!theme) {
|
|
|
|
|
throw new EidosThemeNotFoundError(themeId)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const color = mergeThemeColor(
|
|
|
|
|
options.primitives.color?.scales,
|
|
|
|
|
options.semantics.color,
|
|
|
|
|
theme.color
|
|
|
|
|
)
|
|
|
|
|
const declarations: string[] = []
|
|
|
|
|
|
|
|
|
|
const scaleNames = new Set([...Object.keys(color.scales), ...Object.keys(color.alphaScales)])
|
|
|
|
|
|
|
|
|
|
for (const scaleName of scaleNames) {
|
|
|
|
|
const scale = color.scales[scaleName]
|
|
|
|
|
if (scale) appendColorScaleDeclarations(declarations, scaleName, scale)
|
|
|
|
|
appendColorAlphaScaleDeclarations(declarations, scaleName, scale, color.alphaScales[scaleName])
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for (const role of COLOR_ROLES) {
|
|
|
|
|
const definition = color.roles[role]
|
|
|
|
|
const scaleName = getColorRoleScaleName(definition)
|
|
|
|
|
|
|
|
|
|
for (const step of COLOR_SCALE_STEPS) {
|
|
|
|
|
declarations.push(cssVar(`primitive-${role}-${step}`, `var(--scale-${scaleName}-${step})`))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for (const step of COLOR_ALPHA_STEPS) {
|
|
|
|
|
declarations.push(cssVar(`primitive-${role}-a${step}`, `var(--scale-${scaleName}-a${step})`))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const slots = {
|
|
|
|
|
...DEFAULT_COLOR_ROLE_SLOT_STEPS,
|
|
|
|
|
...(typeof definition === 'string' ? undefined : definition.slots)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for (const [slot, step] of Object.entries(slots)) {
|
|
|
|
|
// The `contrast` slot is the color drawn ON the solid fill (button /
|
|
|
|
|
// badge / banner / card text, etc.). Defaulting it to step 12
|
|
|
|
|
// ("high-contrast text") produced illegible dark-on-dark — e.g. a
|
|
|
|
|
// purple solid button rendered purple-12 (#402060) text on purple-9
|
|
|
|
|
// (#8e4ec6). The contrasting color over a saturated step-9 fill is
|
|
|
|
|
// the theme's on-solid color, so default `contrast` resolves to
|
|
|
|
|
// `--color-content-on-solid` (step 12 kept as fallback). An explicit
|
|
|
|
|
// per-role `slots.contrast` override is honored verbatim, so roles
|
|
|
|
|
// that intentionally invert (e.g. a monochrome role pointing at
|
|
|
|
|
// step 1) keep working.
|
|
|
|
|
const isDefaultContrast =
|
|
|
|
|
slot === 'contrast' &&
|
|
|
|
|
(typeof definition === 'string' || definition.slots?.contrast === undefined)
|
|
|
|
|
const value = isDefaultContrast
|
|
|
|
|
? `var(--color-content-on-solid, var(--primitive-${role}-${step}))`
|
|
|
|
|
: `var(--primitive-${role}-${step})`
|
|
|
|
|
declarations.push(cssVar(`color-${role}-${toKebab(slot)}`, value))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
appendThemeColorDeclarations(declarations, color)
|
|
|
|
|
|
|
|
|
|
if (theme.shadow) {
|
|
|
|
|
appendRecordDeclarations(declarations, 'shadow', theme.shadow)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return renderBlock(renderOptions.selector ?? getThemeSelector(themeId), declarations)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export { EidosCssVariableError } from '../errors'
|
|
|
|
|
|
|
|
|
|
function appendBorderDeclarations(declarations: string[], border: BorderPrimitiveSet): void {
|
|
|
|
|
appendRecordDeclarations(declarations, 'border-width', border.width)
|
|
|
|
|
appendRecordDeclarations(declarations, 'border-style', border.style)
|
|
|
|
|
declarations.push(cssVar('border-width', `var(--border-width-${border.defaultWidth})`))
|
|
|
|
|
declarations.push(cssVar('border-style', `var(--border-style-${border.defaultStyle})`))
|
|
|
|
|
declarations.push(
|
|
|
|
|
cssVar('border', 'var(--border-width) var(--border-style) var(--color-border-default)')
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function appendLayoutDeclarations(declarations: string[], layout: LayoutPrimitiveSet): void {
|
|
|
|
|
appendRecordDeclarations(declarations, 'container-width', layout.containerWidth)
|
|
|
|
|
declarations.push(cssVar('container-padding-inline', layout.containerPaddingInline))
|
|
|
|
|
appendRecordDeclarations(declarations, 'content-width', layout.contentWidth)
|
|
|
|
|
appendRecordDeclarations(declarations, 'aspect-ratio', layout.aspectRatio)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function appendDensityDeclarations(declarations: string[], density: DensityPrimitiveSet): void {
|
|
|
|
|
appendDensityRecordDeclarations(declarations, density.spaceScale, 'space-scale')
|
|
|
|
|
appendDensityRecordDeclarations(declarations, density.controlScale, 'control-scale')
|
|
|
|
|
appendActiveDensityDeclarations(declarations, 'comfortable')
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function appendDensityRecordDeclarations(
|
|
|
|
|
declarations: string[],
|
|
|
|
|
record: Record<string, string | number>,
|
|
|
|
|
suffix: string
|
|
|
|
|
): void {
|
|
|
|
|
for (const [name, value] of Object.entries(record)) {
|
|
|
|
|
declarations.push(cssVar(`density-${name}-${suffix}`, value))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function renderDensityBlocks(): string {
|
|
|
|
|
return DENSITY_KEYS.filter((densityKey) => densityKey !== 'comfortable')
|
|
|
|
|
.map((densityKey) => {
|
|
|
|
|
const declarations: string[] = []
|
|
|
|
|
appendActiveDensityDeclarations(declarations, densityKey)
|
|
|
|
|
return renderBlock(`[data-density='${densityKey}']`, declarations)
|
|
|
|
|
})
|
|
|
|
|
.join('\n\n')
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function appendActiveDensityDeclarations(declarations: string[], densityKey: string): void {
|
|
|
|
|
declarations.push(cssVar('density-space-scale', `var(--density-${densityKey}-space-scale)`))
|
|
|
|
|
declarations.push(cssVar('density-control-scale', `var(--density-${densityKey}-control-scale)`))
|
Theming: add `scaling` zoom axis (Radix parity), separate from density
Introduce a global zoom axis independent of density, in parity with Radix
Themes' `scaling` (90/95/100/105/110%). Scaling zooms px metrics INCLUDING
typography (font-size, icon-size, space, control-height); density only moves
layout rhythm + control height and leaves text fixed. The two axes compose
multiplicatively.
- config-types: SCALING_KEYS / ScalingKey / DEFAULT_SCALING; DensityPrimitiveSet
drops the dead `scale` + `contentScale` (kept spaceScale, controlScale).
- primitives/static: STATIC_SCALING (0.9..1.1).
- render-css: appendScaledMetricDeclarations wraps metrics in
calc(<raw>[ * var(--density-x-scale)] * var(--scaling)); appendScalingDeclarations
emits --scaling-{key} + --scaling default; renderScalingBlocks emits
[data-scaling] blocks. line-height/radius/border/shadow excluded.
- config + contract: prune the removed density scalars.
- active-eidos: `scaling` / `scalingSource` options, getScaling() on the
preference source, data-scaling projection + dispose cleanup.
- docs: THEMING.md section 23 + 20.1 reconcile; README density/scaling; SCALING_RFC.md.
Verified: npm run check (0 new errors), vitest eidos (0 new regressions),
browser cascade at 90/100/110 scales font/space/control x0.9/x1.1 and leaves
radius/border fixed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
4 months ago
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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')
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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)) {
|
Theming: add `scaling` zoom axis (Radix parity), separate from density
Introduce a global zoom axis independent of density, in parity with Radix
Themes' `scaling` (90/95/100/105/110%). Scaling zooms px metrics INCLUDING
typography (font-size, icon-size, space, control-height); density only moves
layout rhythm + control height and leaves text fixed. The two axes compose
multiplicatively.
- config-types: SCALING_KEYS / ScalingKey / DEFAULT_SCALING; DensityPrimitiveSet
drops the dead `scale` + `contentScale` (kept spaceScale, controlScale).
- primitives/static: STATIC_SCALING (0.9..1.1).
- render-css: appendScaledMetricDeclarations wraps metrics in
calc(<raw>[ * var(--density-x-scale)] * var(--scaling)); appendScalingDeclarations
emits --scaling-{key} + --scaling default; renderScalingBlocks emits
[data-scaling] blocks. line-height/radius/border/shadow excluded.
- config + contract: prune the removed density scalars.
- active-eidos: `scaling` / `scalingSource` options, getScaling() on the
preference source, data-scaling projection + dispose cleanup.
- docs: THEMING.md section 23 + 20.1 reconcile; README density/scaling; SCALING_RFC.md.
Verified: npm run check (0 new errors), vitest eidos (0 new regressions),
browser cascade at 90/100/110 scales font/space/control x0.9/x1.1 and leaves
radius/border fixed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
4 months ago
|
|
|
// font-size scales with the global `--scaling` zoom (px). line-height is a
|
|
|
|
|
// unitless ratio — it scales implicitly via font-size; scaling it here too
|
|
|
|
|
// would double-scale. letter-spacing stays as authored.
|
|
|
|
|
declarations.push(cssVar(`font-size-${name}`, `calc(${metric.size} * var(--scaling))`))
|
|
|
|
|
declarations.push(cssVar(`font-line-height-${name}`, metric.lineHeight))
|
|
|
|
|
declarations.push(cssVar(`font-letter-spacing-${name}`, metric.letterSpacing))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for (const [name, weight] of Object.entries(typography.weights)) {
|
|
|
|
|
declarations.push(cssVar(`font-weight-${name}`, weight))
|
|
|
|
|
}
|
feat(eidos foundation): emit --style-{name}-* tokens for named typography styles
`STATIC_TYPOGRAPHY.styles` already defined 11 named typography styles
(hero, h1..h6, body, prose, label, caption, code) but the renderer
only emitted families / sizes / weights — the style entries lived
as data with no CSS reflection.
Extend `appendTypographyDeclarations` so each named style emits:
- `--style-{name}-font-family` → `var(--font-family-{family})`
- `--style-{name}-font-size` → `var(--font-size-{size})`
- `--style-{name}-line-height` → literal or `var(--font-line-height-{size})`
- `--style-{name}-letter-spacing` → literal or `var(--font-letter-spacing-{size})`
- `--style-{name}-font-weight` → `var(--font-weight-{weight})` (or numeric)
- `--style-{name}-color` → `var(--color-{dot.path → dash-path})`
Responsive sizes (e.g. `hero.size = { base: 'xxl', md: 'xxxl' }`)
emit the base value into `:root` and per-breakpoint
`@media (min-width: …)` blocks that override the same variables.
Breakpoint thresholds match `$libs/dom/responsive` (sm: 480, md: 768,
lg: 1024, xl: 1280, xxl: 1536).
`renderStaticCss` now iterates `STYLE_BREAKPOINT_ORDER` after the
main `:root` block and appends one media-query block per breakpoint
that has at least one responsive override. The `indentBlock` helper
preserves indentation inside the wrapper.
`base.css` regenerated. svelte-check 0 errors, `npm run component:audit`
81 / 81 PASS unchanged. This is foundation-only; no components consume
the new tokens yet — that lands in the typography port (Text + Heading
+ Display + Code + CodeBlock + Kbd + Mark + Highlight + Link).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
5 months ago
|
|
|
|
|
|
|
|
if (typography.styles) {
|
|
|
|
|
appendTypographyStyleDeclarations(declarations, typography)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Breakpoint min-width thresholds matching `$libs/dom/responsive`.
|
|
|
|
|
* `base` is the implicit 0 fallback — emitted into `:root`, not as a
|
|
|
|
|
* media query.
|
|
|
|
|
*/
|
|
|
|
|
const STYLE_BREAKPOINT_WIDTHS: Record<string, number> = {
|
|
|
|
|
sm: 480,
|
|
|
|
|
md: 768,
|
|
|
|
|
lg: 1024,
|
|
|
|
|
xl: 1280,
|
|
|
|
|
xxl: 1536
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const STYLE_BREAKPOINT_ORDER = ['sm', 'md', 'lg', 'xl', 'xxl'] as const
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Emit the named typography styles (hero, h1..h6, body, prose, label,
|
|
|
|
|
* caption, code, …) as `--style-{name}-{font-family,font-size,
|
|
|
|
|
* font-weight,line-height,letter-spacing,color}` custom properties.
|
|
|
|
|
*
|
|
|
|
|
* Each style entry references primitives the foundation already emits
|
|
|
|
|
* (`var(--font-family-X)`, `var(--font-size-Y)`, etc.). Components
|
|
|
|
|
* consume these tokens directly — `<Heading level=1>` reads
|
|
|
|
|
* `var(--style-h1-font-size)` and friends.
|
|
|
|
|
*
|
|
|
|
|
* Responsive sizes (e.g. `hero.size = { base: 'xxl', md: 'xxxl' }`)
|
|
|
|
|
* are emitted as base declarations in `:root` plus per-breakpoint
|
|
|
|
|
* `@media (min-width: …)` overrides appended after the main block.
|
|
|
|
|
* Non-size fields (family, weight, line-height, letter-spacing, color)
|
|
|
|
|
* are not responsive in this contract.
|
|
|
|
|
*/
|
|
|
|
|
function appendTypographyStyleDeclarations(
|
|
|
|
|
declarations: string[],
|
|
|
|
|
typography: TypographyPrimitiveSet
|
|
|
|
|
): void {
|
|
|
|
|
const styles = typography.styles
|
|
|
|
|
if (!styles) return
|
|
|
|
|
|
|
|
|
|
// Capture responsive size overrides per breakpoint. Same shape as
|
|
|
|
|
// `declarations` but keyed by the active breakpoint so we can emit
|
|
|
|
|
// them as media-query blocks after the base.
|
|
|
|
|
for (const [name, style] of Object.entries(styles)) {
|
|
|
|
|
if (style.family) {
|
|
|
|
|
declarations.push(
|
|
|
|
|
cssVar(`style-${name}-font-family`, `var(--font-family-${style.family})`)
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const sizeRes = style.size
|
|
|
|
|
if (sizeRes !== undefined) {
|
|
|
|
|
const baseSize = pickResponsiveBase(sizeRes)
|
|
|
|
|
if (baseSize !== undefined) {
|
|
|
|
|
declarations.push(
|
|
|
|
|
cssVar(`style-${name}-font-size`, resolveStyleSizeRef(baseSize))
|
|
|
|
|
)
|
|
|
|
|
declarations.push(
|
|
|
|
|
cssVar(
|
|
|
|
|
`style-${name}-line-height`,
|
|
|
|
|
style.lineHeight ?? resolveStyleLineHeightRef(baseSize)
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
declarations.push(
|
|
|
|
|
cssVar(
|
|
|
|
|
`style-${name}-letter-spacing`,
|
|
|
|
|
style.letterSpacing ?? resolveStyleLetterSpacingRef(baseSize)
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
} else if (style.lineHeight) {
|
|
|
|
|
declarations.push(cssVar(`style-${name}-line-height`, style.lineHeight))
|
|
|
|
|
}
|
|
|
|
|
} else if (style.lineHeight) {
|
|
|
|
|
declarations.push(cssVar(`style-${name}-line-height`, style.lineHeight))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (style.weight !== undefined) {
|
|
|
|
|
const weightRef =
|
|
|
|
|
typeof style.weight === 'number'
|
|
|
|
|
? String(style.weight)
|
|
|
|
|
: `var(--font-weight-${style.weight})`
|
|
|
|
|
declarations.push(cssVar(`style-${name}-font-weight`, weightRef))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (style.color) {
|
|
|
|
|
declarations.push(
|
|
|
|
|
cssVar(`style-${name}-color`, `var(--color-${style.color.replace(/\./g, '-')})`)
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Collect per-breakpoint style size overrides. Returns a record mapping
|
|
|
|
|
* breakpoint -> declaration list. Empty buckets are dropped by the
|
|
|
|
|
* caller before emitting media-query blocks.
|
|
|
|
|
*/
|
|
|
|
|
function collectTypographyStyleResponsive(
|
|
|
|
|
typography: TypographyPrimitiveSet | undefined
|
|
|
|
|
): Record<string, string[]> {
|
|
|
|
|
const result: Record<string, string[]> = {}
|
|
|
|
|
if (!typography?.styles) return result
|
|
|
|
|
|
|
|
|
|
for (const [name, style] of Object.entries(typography.styles)) {
|
|
|
|
|
const sizeRes = style.size
|
|
|
|
|
if (sizeRes === undefined) continue
|
|
|
|
|
if (typeof sizeRes !== 'object' || sizeRes === null || isTextMetric(sizeRes)) continue
|
|
|
|
|
const responsive = sizeRes as Partial<Record<string, unknown>>
|
|
|
|
|
for (const bp of STYLE_BREAKPOINT_ORDER) {
|
|
|
|
|
const value = responsive[bp]
|
|
|
|
|
if (value === undefined) continue
|
|
|
|
|
const list = result[bp] ?? (result[bp] = [])
|
|
|
|
|
list.push(cssVar(`style-${name}-font-size`, resolveStyleSizeRef(value)))
|
|
|
|
|
list.push(cssVar(`style-${name}-line-height`, resolveStyleLineHeightRef(value)))
|
|
|
|
|
list.push(cssVar(`style-${name}-letter-spacing`, resolveStyleLetterSpacingRef(value)))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return result
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function pickResponsiveBase(value: unknown): unknown {
|
|
|
|
|
if (value === undefined || value === null) return undefined
|
|
|
|
|
if (typeof value === 'string' || typeof value === 'number') return value
|
|
|
|
|
if (isTextMetric(value)) return value
|
|
|
|
|
if (typeof value === 'object') {
|
|
|
|
|
const obj = value as Partial<Record<string, unknown>>
|
|
|
|
|
if (obj.base !== undefined) return obj.base
|
|
|
|
|
// Fallback: take the first defined breakpoint in order.
|
|
|
|
|
for (const bp of STYLE_BREAKPOINT_ORDER) {
|
|
|
|
|
if (obj[bp] !== undefined) return obj[bp]
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return undefined
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function isTextMetric(value: unknown): boolean {
|
|
|
|
|
return (
|
|
|
|
|
typeof value === 'object' &&
|
|
|
|
|
value !== null &&
|
|
|
|
|
'size' in value &&
|
|
|
|
|
'lineHeight' in value &&
|
|
|
|
|
'letterSpacing' in value
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function resolveStyleSizeRef(value: unknown): string {
|
|
|
|
|
if (typeof value === 'string') return `var(--font-size-${value})`
|
|
|
|
|
if (isTextMetric(value)) return (value as { size: string }).size
|
|
|
|
|
return 'inherit'
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function resolveStyleLineHeightRef(value: unknown): string {
|
|
|
|
|
if (typeof value === 'string') return `var(--font-line-height-${value})`
|
|
|
|
|
if (isTextMetric(value)) return (value as { lineHeight: string }).lineHeight
|
|
|
|
|
return 'inherit'
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function resolveStyleLetterSpacingRef(value: unknown): string {
|
|
|
|
|
if (typeof value === 'string') return `var(--font-letter-spacing-${value})`
|
|
|
|
|
if (isTextMetric(value)) return (value as { letterSpacing: string }).letterSpacing
|
|
|
|
|
return 'normal'
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function appendSizeDeclarations(declarations: string[], sizes: SizePrimitiveSet): void {
|
|
|
|
|
for (const [name, size] of Object.entries(sizes)) {
|
|
|
|
|
declarations.push(
|
|
|
|
|
cssVar(`size-${name}-control-height`, `var(--control-height-${size.controlHeight})`)
|
|
|
|
|
)
|
|
|
|
|
declarations.push(cssVar(`size-${name}-font-size`, `var(--font-size-${size.fontSize})`))
|
|
|
|
|
declarations.push(
|
|
|
|
|
cssVar(`size-${name}-font-line-height`, `var(--font-line-height-${size.fontSize})`)
|
|
|
|
|
)
|
|
|
|
|
declarations.push(
|
|
|
|
|
cssVar(`size-${name}-font-letter-spacing`, `var(--font-letter-spacing-${size.fontSize})`)
|
|
|
|
|
)
|
|
|
|
|
declarations.push(cssVar(`size-${name}-icon-size`, `var(--icon-size-${size.iconSize})`))
|
|
|
|
|
declarations.push(cssVar(`size-${name}-padding-inline`, size.paddingInline))
|
|
|
|
|
declarations.push(cssVar(`size-${name}-padding-block`, size.paddingBlock))
|
|
|
|
|
declarations.push(cssVar(`size-${name}-gap`, size.gap))
|
|
|
|
|
declarations.push(cssVar(`size-${name}-radius`, `var(--radius-${size.radius})`))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function appendTransitionAliasDeclarations(
|
|
|
|
|
declarations: string[],
|
|
|
|
|
options: EidosConfig
|
|
|
|
|
): void {
|
|
|
|
|
const { primitives } = options
|
|
|
|
|
|
|
|
|
|
if (primitives.controlHeight?.xxs) {
|
|
|
|
|
declarations.push(cssVar('control-height-2xs', 'var(--control-height-xxs)'))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (primitives.radius?.sm) {
|
|
|
|
|
declarations.push(cssVar('radius-xs', 'var(--radius-sm)'))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
appendFontFamilyAliases(declarations, primitives.typography?.families)
|
|
|
|
|
appendTypographyAliases(declarations, primitives.typography)
|
|
|
|
|
|
|
|
|
|
if (primitives.motion) {
|
|
|
|
|
declarations.push(cssVar('motion-spin-duration', '800ms'))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (hasFocusColor(options)) {
|
|
|
|
|
declarations.push(cssVar('color-focus-ring', 'var(--focus-ring-color)'))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
feat(eidos): TSC v2.2 (parts + composition) + universal theming coverage
Token Scope Contract universal — no más excepciones arquitectónicas.
Los 3 componentes que vivían fuera de TSC v2.1 (select, avatar,
toggle-group) ahora están dentro del contrato via dos extensiones nuevas.
TSC v2.2 extensiones (lib/config-types.ts + render-css.ts + config.ts):
- `parts: readonly string[]` en RecipeTokenMultiDeclaration — emite
selectores comma-separados (`[data-{c}-x], [data-{c}-y]`) para
componentes con data-color cascadeado per-part. Consumer: select.
- `composition: { foreignRecipe: { targetSelector, tokens } }` sibling
key — overrides cross-recipe scoped a la cascade del host. Consumer:
toggle-group modifica `--toggle-palette-*` en sus items.
Migraciones:
- select: 3 private `_accent-{track,border,text}` con parts: ['trigger',
'content'] + 7 cascades color:X each. Removed orphan `_accent-solid`
(CSS no consumía).
- avatar: 6 tokens via composite scopes `['variant:X', 'color:Y']` con
matrix helper inline. Badge usa parts: ['badge']. Reemplaza 24 bloques
CSS × 2 partes.
- toggle-group: composition block con 8 palette tokens × 4 colors.
Reemplaza 4 bloques CSS per-color.
Bug toggle-group post-composition (encontrado y arreglado):
Tras la composition migration, el cascade del toggle-group seguía roto
porque los tokens derivados (`--toggle-solid-on-bg`, `--toggle-outline-fg`,
etc.) viven en scope `[data-toggle]`. El `[data-toggle-group-item]` es
sibling (no descendant), así que `var(--toggle-solid-on-bg)` resolvía
undefined. Fix: inlined derivation expressions directamente en
`[data-toggle-group-item]` y sus variant cascades (solid/outline/ghost),
referenciando palette tokens en su propio scope local.
Validador + emisor + contract:
- `validateRecipeComposition` valida el shape `{ targetSelector, tokens }`
y rechaza composition entries con scope='root'.
- `stripCompositionKey` + `emitComposition` separan el pipeline.
- `appendRecipeContractTokens` skip-list para `composition` (no aparece
como fake `--{c}-composition` knob).
- `tokenKeys`/`tokenEntries` helpers en recipe-css-contract.test.ts
filtran composition en todos los iteradores.
Documentación:
- THEMING.md §18 reescrito como "Cobertura universal de TSC". §7
extendido con subsecciones "Multi-part scope" y "Cross-recipe
composition" + ejemplos completos. TOC actualizado.
- eidos/README.md tabla de referencia ampliada con TSC v2.2 + §18.
- CLAUDE.md gana hand-off "2026-05-27 #5" (TSC v2.2 + cobertura universal).
- CONTINUE.md reescrito al estado actual de la sesión.
Working tree también incluye sprint Words en paralelo (multiple authors):
slash menu, find/replace regex, code language picker, table audit,
toolbar family menu, code highlight engine.
Tests: 786/786 pass en src/uix/{eidos,morfo,soma,sema}. `npm run check`:
6 errores pre-existentes (lib/_demo, soma/components/internal,
web/routes/active) no relacionados.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
5 months ago
|
|
|
/**
|
|
|
|
|
* Append `:root`-scoped recipe declarations to the caller's list and return
|
|
|
|
|
* additional CSS blocks for scoped tokens.
|
|
|
|
|
*
|
|
|
|
|
* Token Scope Contract (TSC) v2 — three guarantees:
|
|
|
|
|
*
|
|
|
|
|
* 1. **Automatic dependency inference**: the generator parses
|
|
|
|
|
* `var(--{component}-XXX)` references in every token's value and treats
|
|
|
|
|
* `XXX` as a dependency. Adding `depends: [...]` is a manual supplement;
|
|
|
|
|
* you do not need it to catch the eager-resolution bug.
|
|
|
|
|
*
|
|
|
|
|
* 2. **Scope algebra**: a token's scope must "cover" every dependency's
|
|
|
|
|
* scope. `root` covers nothing but root; `host` covers root+host;
|
|
|
|
|
* leaf scope `axis:value` covers `root`, `host`, and the same
|
|
|
|
|
* `axis:value`. Composite leaf scopes (arrays) cover any subset.
|
|
|
|
|
*
|
|
|
|
|
* 3. **Cross-axis collision detection**: if a token has multiple atomic
|
|
|
|
|
* declarations on incomparable axes (e.g. `color:affirm` AND
|
|
|
|
|
* `state:on`), the generator requires an explicit composite
|
|
|
|
|
* declaration `[color:affirm, state:on]` to disambiguate cascade
|
|
|
|
|
* order when an element matches both. Without that composite, the
|
|
|
|
|
* cascade winner depends on declaration source order — a silent
|
|
|
|
|
* correctness bug.
|
|
|
|
|
*
|
|
|
|
|
* All three are enforced at generation time. Violations throw with a
|
|
|
|
|
* clear message naming the offending tokens.
|
|
|
|
|
*/
|
|
|
|
|
function appendRecipeDeclarations(
|
|
|
|
|
declarations: string[],
|
|
|
|
|
recipes: RecipeTokenSet | undefined
|
feat(eidos): TSC v2.2 (parts + composition) + universal theming coverage
Token Scope Contract universal — no más excepciones arquitectónicas.
Los 3 componentes que vivían fuera de TSC v2.1 (select, avatar,
toggle-group) ahora están dentro del contrato via dos extensiones nuevas.
TSC v2.2 extensiones (lib/config-types.ts + render-css.ts + config.ts):
- `parts: readonly string[]` en RecipeTokenMultiDeclaration — emite
selectores comma-separados (`[data-{c}-x], [data-{c}-y]`) para
componentes con data-color cascadeado per-part. Consumer: select.
- `composition: { foreignRecipe: { targetSelector, tokens } }` sibling
key — overrides cross-recipe scoped a la cascade del host. Consumer:
toggle-group modifica `--toggle-palette-*` en sus items.
Migraciones:
- select: 3 private `_accent-{track,border,text}` con parts: ['trigger',
'content'] + 7 cascades color:X each. Removed orphan `_accent-solid`
(CSS no consumía).
- avatar: 6 tokens via composite scopes `['variant:X', 'color:Y']` con
matrix helper inline. Badge usa parts: ['badge']. Reemplaza 24 bloques
CSS × 2 partes.
- toggle-group: composition block con 8 palette tokens × 4 colors.
Reemplaza 4 bloques CSS per-color.
Bug toggle-group post-composition (encontrado y arreglado):
Tras la composition migration, el cascade del toggle-group seguía roto
porque los tokens derivados (`--toggle-solid-on-bg`, `--toggle-outline-fg`,
etc.) viven en scope `[data-toggle]`. El `[data-toggle-group-item]` es
sibling (no descendant), así que `var(--toggle-solid-on-bg)` resolvía
undefined. Fix: inlined derivation expressions directamente en
`[data-toggle-group-item]` y sus variant cascades (solid/outline/ghost),
referenciando palette tokens en su propio scope local.
Validador + emisor + contract:
- `validateRecipeComposition` valida el shape `{ targetSelector, tokens }`
y rechaza composition entries con scope='root'.
- `stripCompositionKey` + `emitComposition` separan el pipeline.
- `appendRecipeContractTokens` skip-list para `composition` (no aparece
como fake `--{c}-composition` knob).
- `tokenKeys`/`tokenEntries` helpers en recipe-css-contract.test.ts
filtran composition en todos los iteradores.
Documentación:
- THEMING.md §18 reescrito como "Cobertura universal de TSC". §7
extendido con subsecciones "Multi-part scope" y "Cross-recipe
composition" + ejemplos completos. TOC actualizado.
- eidos/README.md tabla de referencia ampliada con TSC v2.2 + §18.
- CLAUDE.md gana hand-off "2026-05-27 #5" (TSC v2.2 + cobertura universal).
- CONTINUE.md reescrito al estado actual de la sesión.
Working tree también incluye sprint Words en paralelo (multiple authors):
slash menu, find/replace regex, code language picker, table audit,
toolbar family menu, code highlight engine.
Tests: 786/786 pass en src/uix/{eidos,morfo,soma,sema}. `npm run check`:
6 errores pre-existentes (lib/_demo, soma/components/internal,
web/routes/active) no relacionados.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
5 months ago
|
|
|
): string[] {
|
|
|
|
|
if (!recipes) return []
|
|
|
|
|
|
|
|
|
|
const blocks: string[] = []
|
|
|
|
|
const allViolations: string[] = []
|
|
|
|
|
|
|
|
|
|
for (const [component, tokens] of Object.entries(recipes)) {
|
feat(eidos): TSC v2.2 (parts + composition) + universal theming coverage
Token Scope Contract universal — no más excepciones arquitectónicas.
Los 3 componentes que vivían fuera de TSC v2.1 (select, avatar,
toggle-group) ahora están dentro del contrato via dos extensiones nuevas.
TSC v2.2 extensiones (lib/config-types.ts + render-css.ts + config.ts):
- `parts: readonly string[]` en RecipeTokenMultiDeclaration — emite
selectores comma-separados (`[data-{c}-x], [data-{c}-y]`) para
componentes con data-color cascadeado per-part. Consumer: select.
- `composition: { foreignRecipe: { targetSelector, tokens } }` sibling
key — overrides cross-recipe scoped a la cascade del host. Consumer:
toggle-group modifica `--toggle-palette-*` en sus items.
Migraciones:
- select: 3 private `_accent-{track,border,text}` con parts: ['trigger',
'content'] + 7 cascades color:X each. Removed orphan `_accent-solid`
(CSS no consumía).
- avatar: 6 tokens via composite scopes `['variant:X', 'color:Y']` con
matrix helper inline. Badge usa parts: ['badge']. Reemplaza 24 bloques
CSS × 2 partes.
- toggle-group: composition block con 8 palette tokens × 4 colors.
Reemplaza 4 bloques CSS per-color.
Bug toggle-group post-composition (encontrado y arreglado):
Tras la composition migration, el cascade del toggle-group seguía roto
porque los tokens derivados (`--toggle-solid-on-bg`, `--toggle-outline-fg`,
etc.) viven en scope `[data-toggle]`. El `[data-toggle-group-item]` es
sibling (no descendant), así que `var(--toggle-solid-on-bg)` resolvía
undefined. Fix: inlined derivation expressions directamente en
`[data-toggle-group-item]` y sus variant cascades (solid/outline/ghost),
referenciando palette tokens en su propio scope local.
Validador + emisor + contract:
- `validateRecipeComposition` valida el shape `{ targetSelector, tokens }`
y rechaza composition entries con scope='root'.
- `stripCompositionKey` + `emitComposition` separan el pipeline.
- `appendRecipeContractTokens` skip-list para `composition` (no aparece
como fake `--{c}-composition` knob).
- `tokenKeys`/`tokenEntries` helpers en recipe-css-contract.test.ts
filtran composition en todos los iteradores.
Documentación:
- THEMING.md §18 reescrito como "Cobertura universal de TSC". §7
extendido con subsecciones "Multi-part scope" y "Cross-recipe
composition" + ejemplos completos. TOC actualizado.
- eidos/README.md tabla de referencia ampliada con TSC v2.2 + §18.
- CLAUDE.md gana hand-off "2026-05-27 #5" (TSC v2.2 + cobertura universal).
- CONTINUE.md reescrito al estado actual de la sesión.
Working tree también incluye sprint Words en paralelo (multiple authors):
slash menu, find/replace regex, code language picker, table audit,
toolbar family menu, code highlight engine.
Tests: 786/786 pass en src/uix/{eidos,morfo,soma,sema}. `npm run check`:
6 errores pre-existentes (lib/_demo, soma/components/internal,
web/routes/active) no relacionados.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
5 months ago
|
|
|
// `composition` is a reserved sibling key on the recipe map (TSC
|
|
|
|
|
// v2.2 cross-recipe overrides) — not a regular token. Strip it
|
|
|
|
|
// before normalizing and process it separately at the end of
|
|
|
|
|
// this iteration so it composes onto the same `blocks` array.
|
|
|
|
|
const composition = (tokens as { composition?: Readonly<Record<string, RecipeComposition>> })
|
|
|
|
|
.composition
|
|
|
|
|
const tokensOnly = stripCompositionKey(tokens)
|
|
|
|
|
|
|
|
|
|
// Phase 1: normalize every token to a list of declarations.
|
|
|
|
|
const normalized = normalizeRecipeTokens(component, tokensOnly)
|
|
|
|
|
|
|
|
|
|
// Phase 2: collect scope-algebra violations across all declarations.
|
|
|
|
|
allViolations.push(...collectScopeViolations(component, normalized))
|
|
|
|
|
|
|
|
|
|
// Phase 3: detect cross-axis collisions per token.
|
|
|
|
|
allViolations.push(...collectCrossAxisCollisions(component, normalized))
|
|
|
|
|
|
|
|
|
|
// Phase 4: emit declarations by scope. The 'root' bucket appends
|
|
|
|
|
// to the caller's :root declarations; everything else is a block.
|
|
|
|
|
//
|
|
|
|
|
// Token visibility convention (TSC v2.1):
|
|
|
|
|
// key starts with '_' → private token, emitted as `--_{c}-{rest}`
|
|
|
|
|
// key does not → public token, emitted as `--{c}-{name}`
|
|
|
|
|
//
|
|
|
|
|
// Private tokens stay out of the public CSS contract (contract.ts
|
|
|
|
|
// skips them) and follow the `--_{c}-*` naming convention used by
|
|
|
|
|
// recipes to mark "internal machinery, do not consume externally".
|
|
|
|
|
//
|
|
|
|
|
// Multi-part scope (TSC v2.2): when a normalized declaration carries
|
|
|
|
|
// `parts: [...]`, the scope selector is built from a comma-separated
|
|
|
|
|
// list of `[data-{c}-{part}]` instead of the default `[data-{c}]`
|
|
|
|
|
// root. Used for components whose `data-color` attribute lives on
|
|
|
|
|
// parts (Select cascades via Trigger + Content), not on the root.
|
|
|
|
|
// Bucket-key includes parts so two scope-identical declarations
|
|
|
|
|
// targeting different parts emit as separate blocks.
|
|
|
|
|
const byScopeKey = new Map<
|
|
|
|
|
string,
|
|
|
|
|
{ scope: TokenScope; parts: readonly string[] | undefined; lines: string[] }
|
|
|
|
|
>()
|
|
|
|
|
for (const [name, decls] of normalized) {
|
|
|
|
|
const cssVarName = name.startsWith('_')
|
|
|
|
|
? `_${component}-${name.slice(1)}`
|
|
|
|
|
: `${component}-${name}`
|
|
|
|
|
for (const decl of decls) {
|
|
|
|
|
const key = `${scopeKey(decl.scope)}@${(decl.parts ?? []).join(',')}`
|
|
|
|
|
const bucket = byScopeKey.get(key) ?? {
|
|
|
|
|
scope: decl.scope,
|
|
|
|
|
parts: decl.parts,
|
|
|
|
|
lines: []
|
|
|
|
|
}
|
|
|
|
|
bucket.lines.push(cssVar(cssVarName, decl.value))
|
|
|
|
|
byScopeKey.set(key, bucket)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const rootKey = `${scopeKey('root')}@`
|
|
|
|
|
const rootBucket = byScopeKey.get(rootKey)
|
|
|
|
|
if (rootBucket) declarations.push(...rootBucket.lines)
|
|
|
|
|
|
|
|
|
|
for (const [key, { scope, parts, lines }] of byScopeKey) {
|
|
|
|
|
if (key === rootKey) continue
|
|
|
|
|
blocks.push(renderBlock(recipeScopeSelector(component, scope, parts), lines))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Phase 5: cross-recipe composition (TSC v2.2). The host recipe
|
|
|
|
|
// declares overrides of FOREIGN tokens scoped to its own
|
|
|
|
|
// conditions, with a `targetSelector` to reach the foreign part.
|
|
|
|
|
if (composition) {
|
|
|
|
|
blocks.push(...emitComposition(component, composition))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (allViolations.length) {
|
|
|
|
|
throw new Error(
|
|
|
|
|
`Eidos recipe scope contract violations:\n - ${allViolations.join('\n - ')}`
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return blocks
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Strip the reserved `composition` key from a recipe token map so the
|
|
|
|
|
* normalizer only sees regular token entries. Returns a plain object —
|
|
|
|
|
* the original `Readonly` shape is irrelevant at runtime.
|
|
|
|
|
*/
|
|
|
|
|
function stripCompositionKey(
|
|
|
|
|
tokens: Readonly<Record<string, unknown>>
|
|
|
|
|
): Readonly<Record<string, RecipeTokenValue>> {
|
|
|
|
|
const out: Record<string, RecipeTokenValue> = {}
|
|
|
|
|
for (const [key, value] of Object.entries(tokens)) {
|
|
|
|
|
if (key === 'composition') continue
|
|
|
|
|
out[key] = value as RecipeTokenValue
|
|
|
|
|
}
|
|
|
|
|
return out
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Emit blocks for a recipe's cross-recipe composition (TSC v2.2).
|
|
|
|
|
*
|
|
|
|
|
* For each `{foreignComponent: { targetSelector, tokens }}` entry:
|
|
|
|
|
* - the CSS variable names are derived from the FOREIGN component
|
|
|
|
|
* (`--{foreign}-{tokenName}` for public, `--_{foreign}-{tokenName}`
|
|
|
|
|
* for private — leading `_` in the token key).
|
|
|
|
|
* - the selector is the HOST component's scope (e.g.
|
|
|
|
|
* `[data-toggle-group][data-color='X']`) combined with the
|
|
|
|
|
* `targetSelector` as a descendant.
|
|
|
|
|
*
|
|
|
|
|
* Composition tokens MUST declare a non-root scope — if they applied at
|
|
|
|
|
* `:root` they'd be regular foundation tokens of the foreign recipe, not
|
|
|
|
|
* composition overrides. The host's purpose is to RESTRICT the override
|
|
|
|
|
* to its own cascade.
|
|
|
|
|
*/
|
|
|
|
|
function emitComposition(
|
|
|
|
|
hostComponent: string,
|
|
|
|
|
composition: Readonly<Record<string, RecipeComposition>>
|
|
|
|
|
): string[] {
|
|
|
|
|
const blocks: string[] = []
|
|
|
|
|
for (const [foreignComponent, entry] of Object.entries(composition)) {
|
|
|
|
|
const { targetSelector, tokens } = entry
|
|
|
|
|
// Group by host-scope so a single rule covers all foreign-token
|
|
|
|
|
// overrides declared at the same host scope.
|
|
|
|
|
const byScopeKey = new Map<string, { scope: TokenScope; lines: string[] }>()
|
|
|
|
|
for (const [tokenName, multi] of Object.entries(tokens)) {
|
|
|
|
|
const cssVarName = tokenName.startsWith('_')
|
|
|
|
|
? `_${foreignComponent}-${tokenName.slice(1)}`
|
|
|
|
|
: `${foreignComponent}-${tokenName}`
|
|
|
|
|
for (const decl of multi.declarations) {
|
|
|
|
|
const normalizedScope = normalizeScopeAlias(decl.scope ?? 'root')
|
|
|
|
|
if (scopeKey(normalizedScope) === scopeKey('root')) {
|
|
|
|
|
throw new Error(
|
|
|
|
|
`Eidos recipe composition: ${hostComponent}.composition.${foreignComponent}.${tokenName} ` +
|
|
|
|
|
`has a 'root' declaration. Composition overrides MUST be scoped — ` +
|
|
|
|
|
`a root override belongs in the foreign recipe itself.`
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
const key = scopeKey(normalizedScope)
|
|
|
|
|
const bucket = byScopeKey.get(key) ?? { scope: normalizedScope, lines: [] }
|
|
|
|
|
bucket.lines.push(cssVar(cssVarName, decl.value))
|
|
|
|
|
byScopeKey.set(key, bucket)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
for (const { scope, lines } of byScopeKey.values()) {
|
|
|
|
|
const hostSelector = recipeScopeSelector(hostComponent, scope)
|
|
|
|
|
blocks.push(renderBlock(`${hostSelector} ${targetSelector}`, lines))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return blocks
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Map of token name → list of normalized declarations (one per scope). */
|
|
|
|
|
type NormalizedDeclarations = Map<string, NormalizedDeclaration[]>
|
|
|
|
|
interface NormalizedDeclaration {
|
|
|
|
|
readonly value: string
|
|
|
|
|
readonly scope: TokenScope
|
|
|
|
|
readonly depends: readonly string[]
|
|
|
|
|
/**
|
|
|
|
|
* TSC v2.2 — parts the declaration cascades on. When set, the scope
|
|
|
|
|
* selector is built from comma-joined `[data-{c}-{part}]` instead of
|
|
|
|
|
* the default `[data-{c}]`. Inherited from the `RecipeTokenMultiDeclaration`
|
|
|
|
|
* level (all declarations of a multi-decl token share the same parts).
|
|
|
|
|
*/
|
|
|
|
|
readonly parts?: readonly string[]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Normalize a component's tokens: convert each entry to its list of
|
|
|
|
|
* declarations, infer var()-driven dependencies, merge explicit deps.
|
|
|
|
|
*/
|
|
|
|
|
function normalizeRecipeTokens(
|
|
|
|
|
component: string,
|
|
|
|
|
tokens: Readonly<Record<string, RecipeTokenValue>>
|
|
|
|
|
): NormalizedDeclarations {
|
|
|
|
|
const result: NormalizedDeclarations = new Map()
|
|
|
|
|
// Pre-collect the set of names declared in this recipe so dep inference
|
|
|
|
|
// only counts refs to fellow recipe tokens. A `var(--icon-stroke-width-md)`
|
|
|
|
|
// inside the icon recipe is NOT a dep on `stroke-width-md` if that name
|
|
|
|
|
// belongs to the primitives layer (not to the icon recipe itself).
|
|
|
|
|
const declaredNames = new Set(Object.keys(tokens))
|
|
|
|
|
for (const [name, raw] of Object.entries(tokens)) {
|
|
|
|
|
const { declarations, parts } = expandToDeclarationsWithParts(raw)
|
|
|
|
|
const decls: NormalizedDeclaration[] = declarations.map((decl) => ({
|
|
|
|
|
value: decl.value,
|
|
|
|
|
scope: normalizeScopeAlias(decl.scope ?? 'root'),
|
|
|
|
|
depends: mergeInferredAndExplicitDeps(
|
|
|
|
|
decl.value,
|
|
|
|
|
component,
|
|
|
|
|
decl.depends ?? [],
|
|
|
|
|
declaredNames
|
|
|
|
|
),
|
|
|
|
|
parts
|
|
|
|
|
}))
|
|
|
|
|
result.set(name, decls)
|
|
|
|
|
}
|
|
|
|
|
return result
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Expand a recipe token entry into its list of raw declarations + parts. */
|
|
|
|
|
function expandToDeclarationsWithParts(raw: RecipeTokenValue): {
|
|
|
|
|
declarations: RecipeTokenDeclaration[]
|
|
|
|
|
parts: readonly string[] | undefined
|
|
|
|
|
} {
|
|
|
|
|
if (typeof raw !== 'object') {
|
|
|
|
|
return {
|
|
|
|
|
declarations: [{ value: String(raw), scope: 'root', depends: [] }],
|
|
|
|
|
parts: undefined
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if ('declarations' in raw) {
|
|
|
|
|
return {
|
|
|
|
|
declarations: [...raw.declarations],
|
|
|
|
|
parts: (raw as RecipeTokenMultiDeclaration).parts
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return { declarations: [raw], parts: undefined }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* `palette:X` is a deprecated alias for `color:X`. Both target the same
|
|
|
|
|
* `data-color` DOM attribute. The canonical name is `color:*` because it
|
|
|
|
|
* names the DOM axis, not the internal "palette" indirection.
|
|
|
|
|
*/
|
|
|
|
|
function normalizeScopeAlias(scope: TokenScope): TokenScope {
|
|
|
|
|
if (typeof scope === 'string' && scope.startsWith('palette:')) {
|
|
|
|
|
return `color:${scope.slice('palette:'.length)}` as AtomicScope
|
|
|
|
|
}
|
|
|
|
|
if (Array.isArray(scope)) {
|
|
|
|
|
return scope.map((atom) =>
|
|
|
|
|
atom.startsWith('palette:')
|
|
|
|
|
? (`color:${atom.slice('palette:'.length)}` as LeafScope)
|
|
|
|
|
: atom
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
return scope
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Find every `var(--{component}-XXX)` and `var(--_{component}-XXX)` in
|
|
|
|
|
* a value and return the `XXX` names (prefixed with `_` for private refs).
|
|
|
|
|
* Combined with explicit `depends`, this is the full dependency set the
|
|
|
|
|
* generator validates.
|
|
|
|
|
*
|
|
|
|
|
* Examples for component `toggle`:
|
|
|
|
|
* value `'var(--toggle-palette-solid)'` → infers `palette-solid` (public)
|
|
|
|
|
* value `'var(--_toggle-bg)'` → infers `_bg` (private)
|
|
|
|
|
* value `'var(--color-affirm-solid)'` → infers nothing (external prefix)
|
|
|
|
|
*/
|
|
|
|
|
const VAR_REF_REGEX = /var\(\s*--([a-z_][a-z0-9_-]*)/g
|
|
|
|
|
function inferDepsFromValue(value: string, componentPrefix: string): string[] {
|
|
|
|
|
const publicPrefix = `${componentPrefix}-`
|
|
|
|
|
const privatePrefix = `_${componentPrefix}-`
|
|
|
|
|
const inferred: string[] = []
|
|
|
|
|
for (const match of value.matchAll(VAR_REF_REGEX)) {
|
|
|
|
|
const fullName = match[1]
|
|
|
|
|
if (fullName.startsWith(publicPrefix)) {
|
|
|
|
|
inferred.push(fullName.slice(publicPrefix.length))
|
|
|
|
|
} else if (fullName.startsWith(privatePrefix)) {
|
|
|
|
|
// Private dep — the recipe key is `_{rest}` (with leading underscore)
|
|
|
|
|
inferred.push(`_${fullName.slice(privatePrefix.length)}`)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return inferred
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function mergeInferredAndExplicitDeps(
|
|
|
|
|
value: string,
|
|
|
|
|
component: string,
|
|
|
|
|
explicit: readonly string[],
|
|
|
|
|
declaredNames: ReadonlySet<string>
|
|
|
|
|
): readonly string[] {
|
|
|
|
|
const merged = new Set<string>()
|
|
|
|
|
for (const inferred of inferDepsFromValue(value, component)) {
|
|
|
|
|
// Only count it as a dep if the referenced name actually belongs
|
|
|
|
|
// to THIS recipe. Otherwise the ref points to a primitive / theme
|
|
|
|
|
// token / cross-recipe alias, none of which TSC tracks.
|
|
|
|
|
if (declaredNames.has(inferred)) merged.add(inferred)
|
|
|
|
|
}
|
|
|
|
|
for (const dep of explicit) merged.add(dep)
|
|
|
|
|
return [...merged]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** A canonical string key for de-duplicating scopes in maps. */
|
|
|
|
|
function scopeKey(scope: TokenScope): string {
|
|
|
|
|
if (typeof scope === 'string') return scope
|
|
|
|
|
return [...scope].sort().join('+')
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Build the CSS selector that materialises a TSC scope for a component.
|
|
|
|
|
*
|
|
|
|
|
* When `parts` is provided (TSC v2.2 multi-part scope), the root selector
|
|
|
|
|
* becomes a comma-separated list of `[data-{component}-{part}]` instead of
|
|
|
|
|
* the default `[data-{component}]`. Each part gets the same leaf
|
|
|
|
|
* fragments appended. Example:
|
|
|
|
|
*
|
|
|
|
|
* recipeScopeSelector('select', 'color:affirm', ['trigger', 'content'])
|
|
|
|
|
* → "[data-select-trigger][data-color='affirm'], [data-select-content][data-color='affirm']"
|
|
|
|
|
*/
|
|
|
|
|
function recipeScopeSelector(
|
|
|
|
|
component: string,
|
|
|
|
|
scope: TokenScope,
|
|
|
|
|
parts?: readonly string[]
|
|
|
|
|
): string {
|
|
|
|
|
if (scope === 'root') return ':root'
|
|
|
|
|
const hosts = parts && parts.length > 0
|
|
|
|
|
? parts.map((part) => `[data-${component}-${part}]`)
|
|
|
|
|
: [`[data-${component}]`]
|
|
|
|
|
if (scope === 'host') return hosts.join(', ')
|
|
|
|
|
const fragments =
|
|
|
|
|
typeof scope === 'string'
|
|
|
|
|
? [atomicLeafFragment(scope as LeafScope)]
|
|
|
|
|
: [...scope].sort().map((atom) => atomicLeafFragment(atom))
|
|
|
|
|
const tail = fragments.join('')
|
|
|
|
|
return hosts.map((host) => `${host}${tail}`).join(', ')
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function atomicLeafFragment(scope: LeafScope): string {
|
|
|
|
|
const sepIdx = scope.indexOf(':')
|
|
|
|
|
const axis = scope.slice(0, sepIdx)
|
|
|
|
|
const value = scope.slice(sepIdx + 1)
|
|
|
|
|
// `palette` is the deprecated alias for `color` — normalization above
|
|
|
|
|
// should have converted it, but be defensive.
|
|
|
|
|
const dataAttr = axis === 'palette' ? 'color' : axis
|
|
|
|
|
return `[data-${dataAttr}='${value}']`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Scope algebra ─────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Normalize a TokenScope into a set of axis constraints. Each axis can
|
|
|
|
|
* appear at most once. `root` is the empty set; `host` is `{host:true}`;
|
|
|
|
|
* leaves add their axis; composites merge multiple axes.
|
|
|
|
|
*/
|
|
|
|
|
interface ScopeSet {
|
|
|
|
|
host: boolean
|
|
|
|
|
color?: string
|
|
|
|
|
variant?: string
|
|
|
|
|
state?: string
|
|
|
|
|
size?: string
|
|
|
|
|
event?: string
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function toScopeSet(scope: TokenScope): ScopeSet {
|
|
|
|
|
const set: ScopeSet = { host: false }
|
|
|
|
|
const atoms: LeafScope[] = []
|
|
|
|
|
if (scope === 'root') return set
|
|
|
|
|
if (scope === 'host') {
|
|
|
|
|
set.host = true
|
|
|
|
|
return set
|
|
|
|
|
}
|
|
|
|
|
if (typeof scope === 'string') {
|
|
|
|
|
atoms.push(scope as LeafScope)
|
|
|
|
|
} else {
|
|
|
|
|
atoms.push(...scope)
|
|
|
|
|
}
|
|
|
|
|
set.host = true
|
|
|
|
|
for (const atom of atoms) {
|
|
|
|
|
const sepIdx = atom.indexOf(':')
|
|
|
|
|
const axis = atom.slice(0, sepIdx) as keyof Omit<ScopeSet, 'host'>
|
|
|
|
|
const value = atom.slice(sepIdx + 1)
|
|
|
|
|
if (set[axis] !== undefined && set[axis] !== value) {
|
|
|
|
|
throw new Error(
|
|
|
|
|
`composite scope cannot bind axis '${axis}' to two values: ` +
|
|
|
|
|
`'${set[axis]}' and '${value}'`
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
set[axis] = value
|
|
|
|
|
}
|
|
|
|
|
return set
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* True iff the consumer's scope is at least as specific as the dep's —
|
|
|
|
|
* meaning: every constraint the dep places on an element must also be
|
|
|
|
|
* placed by the consumer (or be trivially satisfied because dep is at
|
|
|
|
|
* root/host).
|
|
|
|
|
*
|
|
|
|
|
* consumer scopeCovers dep ⇔ consumer's elements ⊆ dep's elements
|
|
|
|
|
*/
|
|
|
|
|
function scopeCovers(consumer: TokenScope, dep: TokenScope): boolean {
|
|
|
|
|
const consumerSet = toScopeSet(consumer)
|
|
|
|
|
const depSet = toScopeSet(dep)
|
|
|
|
|
if (depSet.host && !consumerSet.host) return false
|
|
|
|
|
for (const axis of ['color', 'variant', 'state', 'size', 'event'] as const) {
|
|
|
|
|
const depValue = depSet[axis]
|
|
|
|
|
if (depValue === undefined) continue
|
|
|
|
|
if (consumerSet[axis] !== depValue) return false
|
|
|
|
|
}
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function describeScope(scope: TokenScope): string {
|
|
|
|
|
if (typeof scope === 'string') return scope
|
|
|
|
|
return `[${[...scope].sort().join(', ')}]`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* For every declaration of every token, check that all its dependencies
|
|
|
|
|
* are reachable from its scope.
|
|
|
|
|
*/
|
|
|
|
|
function collectScopeViolations(
|
|
|
|
|
component: string,
|
|
|
|
|
normalized: NormalizedDeclarations
|
|
|
|
|
): string[] {
|
|
|
|
|
const issues: string[] = []
|
|
|
|
|
for (const [name, decls] of normalized) {
|
|
|
|
|
for (const decl of decls) {
|
|
|
|
|
for (const depName of decl.depends) {
|
|
|
|
|
const depDecls = normalized.get(depName)
|
|
|
|
|
if (!depDecls) {
|
|
|
|
|
issues.push(
|
|
|
|
|
`${component}.${name} (scope ${describeScope(decl.scope)}): depends on ` +
|
|
|
|
|
`'${depName}' which is not declared in the recipe`
|
|
|
|
|
)
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
// At least ONE declaration of the dep must cover the consumer's scope.
|
|
|
|
|
// (The dep can be split across multiple scopes — we only need ONE that
|
|
|
|
|
// applies wherever the consumer applies.)
|
|
|
|
|
const covered = depDecls.some((depDecl) => scopeCovers(decl.scope, depDecl.scope))
|
|
|
|
|
if (!covered) {
|
|
|
|
|
const depScopes = depDecls.map((d) => describeScope(d.scope)).join(', ')
|
|
|
|
|
issues.push(
|
|
|
|
|
`${component}.${name} (scope ${describeScope(decl.scope)}): ` +
|
|
|
|
|
`dependency '${depName}' is only declared at scopes [${depScopes}], ` +
|
|
|
|
|
`none of which is reachable from the consumer's scope. ` +
|
|
|
|
|
`The var() reference would resolve in a context where the dep is missing.`
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
feat(eidos): TSC v2.2 (parts + composition) + universal theming coverage
Token Scope Contract universal — no más excepciones arquitectónicas.
Los 3 componentes que vivían fuera de TSC v2.1 (select, avatar,
toggle-group) ahora están dentro del contrato via dos extensiones nuevas.
TSC v2.2 extensiones (lib/config-types.ts + render-css.ts + config.ts):
- `parts: readonly string[]` en RecipeTokenMultiDeclaration — emite
selectores comma-separados (`[data-{c}-x], [data-{c}-y]`) para
componentes con data-color cascadeado per-part. Consumer: select.
- `composition: { foreignRecipe: { targetSelector, tokens } }` sibling
key — overrides cross-recipe scoped a la cascade del host. Consumer:
toggle-group modifica `--toggle-palette-*` en sus items.
Migraciones:
- select: 3 private `_accent-{track,border,text}` con parts: ['trigger',
'content'] + 7 cascades color:X each. Removed orphan `_accent-solid`
(CSS no consumía).
- avatar: 6 tokens via composite scopes `['variant:X', 'color:Y']` con
matrix helper inline. Badge usa parts: ['badge']. Reemplaza 24 bloques
CSS × 2 partes.
- toggle-group: composition block con 8 palette tokens × 4 colors.
Reemplaza 4 bloques CSS per-color.
Bug toggle-group post-composition (encontrado y arreglado):
Tras la composition migration, el cascade del toggle-group seguía roto
porque los tokens derivados (`--toggle-solid-on-bg`, `--toggle-outline-fg`,
etc.) viven en scope `[data-toggle]`. El `[data-toggle-group-item]` es
sibling (no descendant), así que `var(--toggle-solid-on-bg)` resolvía
undefined. Fix: inlined derivation expressions directamente en
`[data-toggle-group-item]` y sus variant cascades (solid/outline/ghost),
referenciando palette tokens en su propio scope local.
Validador + emisor + contract:
- `validateRecipeComposition` valida el shape `{ targetSelector, tokens }`
y rechaza composition entries con scope='root'.
- `stripCompositionKey` + `emitComposition` separan el pipeline.
- `appendRecipeContractTokens` skip-list para `composition` (no aparece
como fake `--{c}-composition` knob).
- `tokenKeys`/`tokenEntries` helpers en recipe-css-contract.test.ts
filtran composition en todos los iteradores.
Documentación:
- THEMING.md §18 reescrito como "Cobertura universal de TSC". §7
extendido con subsecciones "Multi-part scope" y "Cross-recipe
composition" + ejemplos completos. TOC actualizado.
- eidos/README.md tabla de referencia ampliada con TSC v2.2 + §18.
- CLAUDE.md gana hand-off "2026-05-27 #5" (TSC v2.2 + cobertura universal).
- CONTINUE.md reescrito al estado actual de la sesión.
Working tree también incluye sprint Words en paralelo (multiple authors):
slash menu, find/replace regex, code language picker, table audit,
toolbar family menu, code highlight engine.
Tests: 786/786 pass en src/uix/{eidos,morfo,soma,sema}. `npm run check`:
6 errores pre-existentes (lib/_demo, soma/components/internal,
web/routes/active) no relacionados.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
5 months ago
|
|
|
return issues
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* A token with multiple atomic declarations on different leaf axes whose
|
|
|
|
|
* elements could co-apply must declare a composite override for the
|
|
|
|
|
* intersection — otherwise cascade winner is source-order-dependent
|
|
|
|
|
* (silent correctness bug).
|
|
|
|
|
*
|
|
|
|
|
* Example: `bg` declared at `color:affirm` AND `state:on`. An element
|
|
|
|
|
* with both `data-color='affirm' data-state='on'` matches both blocks;
|
|
|
|
|
* which wins depends on which `[data-c][data-color='affirm']` vs
|
|
|
|
|
* `[data-c][data-state='on']` appears later in the stylesheet. The
|
|
|
|
|
* generator demands a composite `[color:affirm, state:on]` decl so
|
|
|
|
|
* intent is explicit.
|
|
|
|
|
*/
|
|
|
|
|
function collectCrossAxisCollisions(
|
|
|
|
|
component: string,
|
|
|
|
|
normalized: NormalizedDeclarations
|
|
|
|
|
): string[] {
|
|
|
|
|
const issues: string[] = []
|
|
|
|
|
for (const [name, decls] of normalized) {
|
|
|
|
|
const leafSets = decls
|
|
|
|
|
.map((d) => toScopeSet(d.scope))
|
|
|
|
|
.filter((s) => s.host && hasAnyLeafAxis(s))
|
|
|
|
|
|
|
|
|
|
for (let i = 0; i < leafSets.length; i++) {
|
|
|
|
|
for (let j = i + 1; j < leafSets.length; j++) {
|
|
|
|
|
const a = leafSets[i]
|
|
|
|
|
const b = leafSets[j]
|
|
|
|
|
if (areIncomparableLeaves(a, b)) {
|
|
|
|
|
// Need a composite that covers the union of a's and b's axes.
|
|
|
|
|
const needed = unionAxes(a, b)
|
|
|
|
|
const hasComposite = leafSets.some((set) => coversAllAxes(set, needed))
|
|
|
|
|
if (!hasComposite) {
|
|
|
|
|
issues.push(
|
|
|
|
|
`${component}.${name}: declarations at scopes ${describeScopeSet(a)} and ` +
|
|
|
|
|
`${describeScopeSet(b)} can both apply to the same element. ` +
|
|
|
|
|
`Add an explicit composite declaration ${describeAxisSet(needed)} ` +
|
|
|
|
|
`to disambiguate cascade order.`
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return issues
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function hasAnyLeafAxis(set: ScopeSet): boolean {
|
|
|
|
|
return Boolean(set.color || set.variant || set.state || set.size || set.event)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function areIncomparableLeaves(a: ScopeSet, b: ScopeSet): boolean {
|
|
|
|
|
// They're incomparable when they constrain DIFFERENT axes (so an
|
|
|
|
|
// element with both constraints satisfies both rules — but neither
|
|
|
|
|
// rule's selector subsumes the other).
|
|
|
|
|
const axes = ['color', 'variant', 'state', 'size', 'event'] as const
|
|
|
|
|
let sharedAxes = 0
|
|
|
|
|
let exclusiveAxes = 0
|
|
|
|
|
for (const axis of axes) {
|
|
|
|
|
const aHas = a[axis] !== undefined
|
|
|
|
|
const bHas = b[axis] !== undefined
|
|
|
|
|
if (aHas && bHas) {
|
|
|
|
|
if (a[axis] !== b[axis]) return false // contradictory; no overlap
|
|
|
|
|
sharedAxes++
|
|
|
|
|
} else if (aHas !== bHas) {
|
|
|
|
|
exclusiveAxes++
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
// Incomparable when both rules add at least one constraint the other
|
|
|
|
|
// doesn't — and exclusiveAxes from both sides exists. The simplest test
|
|
|
|
|
// is: there's at least one axis in a not in b AND vice versa.
|
|
|
|
|
const aOnly = axes.some((ax) => a[ax] !== undefined && b[ax] === undefined)
|
|
|
|
|
const bOnly = axes.some((ax) => b[ax] !== undefined && a[ax] === undefined)
|
|
|
|
|
return aOnly && bOnly
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function unionAxes(a: ScopeSet, b: ScopeSet): ScopeSet {
|
|
|
|
|
const merged: ScopeSet = { host: true }
|
|
|
|
|
for (const axis of ['color', 'variant', 'state', 'size', 'event'] as const) {
|
|
|
|
|
const aVal = a[axis]
|
|
|
|
|
const bVal = b[axis]
|
|
|
|
|
if (aVal !== undefined) merged[axis] = aVal
|
|
|
|
|
else if (bVal !== undefined) merged[axis] = bVal
|
|
|
|
|
}
|
|
|
|
|
return merged
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function coversAllAxes(candidate: ScopeSet, required: ScopeSet): boolean {
|
|
|
|
|
for (const axis of ['color', 'variant', 'state', 'size', 'event'] as const) {
|
|
|
|
|
const reqVal = required[axis]
|
|
|
|
|
if (reqVal === undefined) continue
|
|
|
|
|
if (candidate[axis] !== reqVal) return false
|
|
|
|
|
}
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function describeScopeSet(set: ScopeSet): string {
|
|
|
|
|
const atoms: string[] = []
|
|
|
|
|
for (const axis of ['color', 'variant', 'state', 'size', 'event'] as const) {
|
|
|
|
|
if (set[axis] !== undefined) atoms.push(`${axis}:${set[axis]}`)
|
|
|
|
|
}
|
|
|
|
|
return atoms.length === 1 ? atoms[0] : `[${atoms.join(', ')}]`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function describeAxisSet(set: ScopeSet): string {
|
|
|
|
|
const atoms: string[] = []
|
|
|
|
|
for (const axis of ['color', 'variant', 'state', 'size', 'event'] as const) {
|
|
|
|
|
if (set[axis] !== undefined) atoms.push(`${axis}:${set[axis]}`)
|
|
|
|
|
}
|
|
|
|
|
return `[${atoms.join(', ')}]`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function appendFontFamilyAliases(
|
|
|
|
|
declarations: string[],
|
|
|
|
|
families: TypographyPrimitiveSet['families'] | undefined
|
|
|
|
|
): void {
|
|
|
|
|
if (!families) return
|
|
|
|
|
|
|
|
|
|
if (families.primary) {
|
|
|
|
|
declarations.push(cssVar('font-sans', 'var(--font-family-primary)'))
|
feat(eidos foundation): vertebrate typography via named-style aliases + R-2.7
Single source of truth for typography values that the recipe layer
consumes. The foundation aliases `--font-ui` and `--leading-ui` (read
by ~30 recipe tokens in `lib/recipes/base.ts`) now derive from the
canonical `label` named style instead of carrying duplicate literals:
--style-label-font-family: var(--font-family-primary);
--style-label-line-height: 1.25;
--font-ui: var(--style-label-font-family, var(--font-family-primary));
--leading-ui: var(--style-label-line-height, 1.25);
Chain: typography.ts styles → --style-{name}-* → --leading-ui / --font-ui
→ recipe tokens → component CSS. Editing
`STATIC_TYPOGRAPHY.styles.label.lineHeight` now propagates to every
recipe in one go.
Why not push recipes to consume `--style-{name}-*` directly:
- t-shirt sizes (xs/sm/md/lg/xl) don't map to four semantic buckets
- per-component matices (description/caption/hint) need their own
color / weight / letter-spacing
- ref libraries (Radix Themes, Mantine, MUI, Chakra) all keep
numerical scale for component internals; semantic layer is only for
user-facing typography primitives (`<Text variant="body2">`)
Audit rule R-2.7 (warn): detects literal font-size / font-weight /
line-height / letter-spacing in eidos component CSS. Escape valves:
var(...), numeric identities (0/0px/1), keywords (inherit/initial/
unset), or trailing `/* literal: <reason> */` comment. Current run
flags 6 components with letter-spacing/font-size literals (all
intentional micro-tracking and em-relative; can be annotated case by
case).
Documentation:
- src/uix/eidos/README.md § "Vertebración tipográfica" — two-layer
architecture rationale, alias chain diagram, comparison vs Radix
Themes / Chakra / Mantine / MUI, escape valves
- web/routes/uix/lib/COMPONENT_AUDIT_GUIDE.md § 4.11 — pointer to
R-2.7 + cross-link to the foundation doc
Verified end-to-end in browser at /uix/components/field:
--font-ui → 'Instrument Sans', system-ui, sans-serif
--style-label-font-family → 'Instrument Sans', system-ui, sans-serif
--leading-ui → 1.25
--style-label-line-height → 1.25
computed [data-field-label].line-height → 17.5px (= 14 × 1.25)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
5 months ago
|
|
|
// `--font-ui` is the alias every recipe token leans on for
|
|
|
|
|
// "UI typeface" (Field labels, Combobox triggers, Toolbar
|
|
|
|
|
// buttons, …). Anchor it to the canonical `label` named style
|
|
|
|
|
// so changing the label's family propagates everywhere; fall
|
|
|
|
|
// back to the primary family if the foundation doesn't emit
|
|
|
|
|
// the named style.
|
|
|
|
|
declarations.push(
|
|
|
|
|
cssVar('font-ui', 'var(--style-label-font-family, var(--font-family-primary))')
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (families.secondary) {
|
|
|
|
|
declarations.push(cssVar('font-serif', 'var(--font-family-secondary)'))
|
|
|
|
|
declarations.push(cssVar('font-prose', 'var(--font-family-secondary)'))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (families.display) {
|
|
|
|
|
declarations.push(cssVar('font-heading', 'var(--font-family-display)'))
|
|
|
|
|
declarations.push(cssVar('font-display', 'var(--font-family-display)'))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (families.mono) {
|
|
|
|
|
declarations.push(cssVar('font-mono', 'var(--font-family-mono)'))
|
|
|
|
|
declarations.push(cssVar('font-code', 'var(--font-family-mono)'))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function appendTypographyAliases(
|
|
|
|
|
declarations: string[],
|
|
|
|
|
typography: PrimitiveSet['typography']
|
|
|
|
|
): void {
|
|
|
|
|
if (!typography) return
|
|
|
|
|
|
|
|
|
|
const textAliasMap = {
|
|
|
|
|
'1': 'xxs',
|
|
|
|
|
'2': 'xs',
|
|
|
|
|
'3': 'sm',
|
|
|
|
|
'4': 'md',
|
|
|
|
|
'5': 'lg',
|
|
|
|
|
'6': 'xl'
|
|
|
|
|
} as const
|
|
|
|
|
|
|
|
|
|
for (const [alias, size] of Object.entries(textAliasMap)) {
|
|
|
|
|
if (!typography.sizes[size]) continue
|
|
|
|
|
|
|
|
|
|
declarations.push(cssVar(`text-${alias}-size`, `var(--font-size-${size})`))
|
|
|
|
|
declarations.push(cssVar(`text-${alias}-lh`, `var(--font-line-height-${size})`))
|
|
|
|
|
declarations.push(cssVar(`text-${alias}-ls`, `var(--font-letter-spacing-${size})`))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (typography.sizes.md) declarations.push(cssVar('font-size-base', 'var(--font-size-md)'))
|
|
|
|
|
if (typography.sizes.xxl) declarations.push(cssVar('font-size-2xl', 'var(--font-size-xxl)'))
|
|
|
|
|
if (typography.sizes.xxxl) declarations.push(cssVar('font-size-3xl', 'var(--font-size-xxxl)'))
|
|
|
|
|
|
|
|
|
|
if (typography.weights.regular) {
|
|
|
|
|
declarations.push(cssVar('font-weight-normal', 'var(--font-weight-regular)'))
|
|
|
|
|
}
|
|
|
|
|
|
feat(eidos foundation): vertebrate typography via named-style aliases + R-2.7
Single source of truth for typography values that the recipe layer
consumes. The foundation aliases `--font-ui` and `--leading-ui` (read
by ~30 recipe tokens in `lib/recipes/base.ts`) now derive from the
canonical `label` named style instead of carrying duplicate literals:
--style-label-font-family: var(--font-family-primary);
--style-label-line-height: 1.25;
--font-ui: var(--style-label-font-family, var(--font-family-primary));
--leading-ui: var(--style-label-line-height, 1.25);
Chain: typography.ts styles → --style-{name}-* → --leading-ui / --font-ui
→ recipe tokens → component CSS. Editing
`STATIC_TYPOGRAPHY.styles.label.lineHeight` now propagates to every
recipe in one go.
Why not push recipes to consume `--style-{name}-*` directly:
- t-shirt sizes (xs/sm/md/lg/xl) don't map to four semantic buckets
- per-component matices (description/caption/hint) need their own
color / weight / letter-spacing
- ref libraries (Radix Themes, Mantine, MUI, Chakra) all keep
numerical scale for component internals; semantic layer is only for
user-facing typography primitives (`<Text variant="body2">`)
Audit rule R-2.7 (warn): detects literal font-size / font-weight /
line-height / letter-spacing in eidos component CSS. Escape valves:
var(...), numeric identities (0/0px/1), keywords (inherit/initial/
unset), or trailing `/* literal: <reason> */` comment. Current run
flags 6 components with letter-spacing/font-size literals (all
intentional micro-tracking and em-relative; can be annotated case by
case).
Documentation:
- src/uix/eidos/README.md § "Vertebración tipográfica" — two-layer
architecture rationale, alias chain diagram, comparison vs Radix
Themes / Chakra / Mantine / MUI, escape valves
- web/routes/uix/lib/COMPONENT_AUDIT_GUIDE.md § 4.11 — pointer to
R-2.7 + cross-link to the foundation doc
Verified end-to-end in browser at /uix/components/field:
--font-ui → 'Instrument Sans', system-ui, sans-serif
--style-label-font-family → 'Instrument Sans', system-ui, sans-serif
--leading-ui → 1.25
--style-label-line-height → 1.25
computed [data-field-label].line-height → 17.5px (= 14 × 1.25)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
5 months ago
|
|
|
// `--leading-ui` is the canonical leading every recipe token uses
|
|
|
|
|
// for short UI text (Field labels, captions, controls). Anchor it
|
|
|
|
|
// to the `label` named style so a designer changing
|
|
|
|
|
// `STATIC_TYPOGRAPHY.styles.label.lineHeight` propagates through
|
|
|
|
|
// `--style-label-line-height` → `--leading-ui` → every recipe.
|
|
|
|
|
// Fall back to the literal `1.25` when the named style isn't
|
|
|
|
|
// emitted (so unusual foundation overrides still produce valid CSS).
|
|
|
|
|
declarations.push(cssVar('leading-ui', 'var(--style-label-line-height, 1.25)'))
|
|
|
|
|
declarations.push(cssVar('leading-prose', '1.6'))
|
|
|
|
|
declarations.push(cssVar('leading-text', 'var(--leading-prose)'))
|
|
|
|
|
declarations.push(cssVar('leading-heading', '1.2'))
|
|
|
|
|
declarations.push(cssVar('leading-display', '1.05'))
|
|
|
|
|
declarations.push(cssVar('tracking-badge', '0'))
|
|
|
|
|
declarations.push(cssVar('tracking-label', '0'))
|
|
|
|
|
declarations.push(cssVar('tracking-ui', '0'))
|
|
|
|
|
declarations.push(cssVar('tracking-prose', '0'))
|
|
|
|
|
declarations.push(cssVar('tracking-heading', '0'))
|
|
|
|
|
declarations.push(cssVar('tracking-display', '0'))
|
|
|
|
|
declarations.push(cssVar('tracking-tight', '0'))
|
|
|
|
|
declarations.push(cssVar('tracking-normal', '0'))
|
|
|
|
|
declarations.push(cssVar('tracking-wide', '0'))
|
|
|
|
|
declarations.push(cssVar('tracking-wider', '0'))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function hasFocusColor(options: EidosConfig): boolean {
|
|
|
|
|
if (options.semantics.color.focus) return true
|
|
|
|
|
|
|
|
|
|
return Object.values(options.themes ?? {}).some((theme) => theme.color?.focus)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function appendThemeColorDeclarations(
|
|
|
|
|
declarations: string[],
|
|
|
|
|
color: ReturnType<typeof mergeThemeColor>
|
|
|
|
|
): void {
|
|
|
|
|
if (color.surface) {
|
|
|
|
|
declarations.push(cssVar('color-surface-default', color.surface.default))
|
|
|
|
|
declarations.push(cssVar('color-surface-raised', color.surface.raised))
|
|
|
|
|
declarations.push(cssVar('color-surface-overlay', color.surface.overlay))
|
eidos: theming fixes + size/variant parity batch + docs
- theme: add surface.muted + content.muted to color contract (neutral-3
+ neutral-10). Plugs 17 broken --color-content-muted and 3 broken
--color-surface-muted references in recipes/components.
- form.css: fix --color-neutral-element-hover typo → --color-neutral-hover.
- archetypes.css + events.css: replace raw hsl/rgba indigo with
color-mix(var(--color-primary-solid) …) — no raw colors left in eidos.
- combobox dark scrollbar: unscope ::-webkit-scrollbar rules in uix.css
and duplicate --uix-line on :root + :root[data-mode='dark'] so portaled
overlays (Combobox listbox, Popover, Dialog, Drawer) inherit the theme.
- sizes: 20 components expand from sm/md/lg to xs..xl (form controls,
text inputs, progress/meter, field/form) or xs..lg (nav controls:
breadcrumb, pagination, tag-group, toolbar). Composite panels keep
sm/md/lg deliberately.
- variants: field + toolbar drop arbitrary ControlVariant narrowings;
both expose all 3 (surface | outline | ghost) with new outline CSS.
- pagination demo: disambiguate siblingCount/boundaryCount as "per side"
in label + API table (Radix/MUI convention).
- docs: CHECKLIST §C-2.6 (contract token presence) + §D-7.4 (chip parity)
+ §D-7.5 (size category) added. DEMO_AUTHORING §12.7 (chip parity) +
§12.8 (size category cheatsheet) added. eidos/README +
active_architecture.md sync texts/migration nomenclature.
- PENDIENTES.md: normas N-1..N-5 implantadas en esta sesión.
Verification: 88/88 eidos tests, 0 type errors, 67/67 component:audit PASS.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
5 months ago
|
|
|
declarations.push(cssVar('color-surface-muted', color.surface.muted))
|
|
|
|
|
declarations.push(cssVar('color-overlay', color.surface.backdrop))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (color.content) {
|
|
|
|
|
declarations.push(cssVar('color-content-primary', color.content.primary))
|
|
|
|
|
declarations.push(cssVar('color-content-secondary', color.content.secondary))
|
eidos: theming fixes + size/variant parity batch + docs
- theme: add surface.muted + content.muted to color contract (neutral-3
+ neutral-10). Plugs 17 broken --color-content-muted and 3 broken
--color-surface-muted references in recipes/components.
- form.css: fix --color-neutral-element-hover typo → --color-neutral-hover.
- archetypes.css + events.css: replace raw hsl/rgba indigo with
color-mix(var(--color-primary-solid) …) — no raw colors left in eidos.
- combobox dark scrollbar: unscope ::-webkit-scrollbar rules in uix.css
and duplicate --uix-line on :root + :root[data-mode='dark'] so portaled
overlays (Combobox listbox, Popover, Dialog, Drawer) inherit the theme.
- sizes: 20 components expand from sm/md/lg to xs..xl (form controls,
text inputs, progress/meter, field/form) or xs..lg (nav controls:
breadcrumb, pagination, tag-group, toolbar). Composite panels keep
sm/md/lg deliberately.
- variants: field + toolbar drop arbitrary ControlVariant narrowings;
both expose all 3 (surface | outline | ghost) with new outline CSS.
- pagination demo: disambiguate siblingCount/boundaryCount as "per side"
in label + API table (Radix/MUI convention).
- docs: CHECKLIST §C-2.6 (contract token presence) + §D-7.4 (chip parity)
+ §D-7.5 (size category) added. DEMO_AUTHORING §12.7 (chip parity) +
§12.8 (size category cheatsheet) added. eidos/README +
active_architecture.md sync texts/migration nomenclature.
- PENDIENTES.md: normas N-1..N-5 implantadas en esta sesión.
Verification: 88/88 eidos tests, 0 type errors, 67/67 component:audit PASS.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
5 months ago
|
|
|
declarations.push(cssVar('color-content-muted', color.content.muted))
|
|
|
|
|
declarations.push(cssVar('color-content-disabled', color.content.disabled))
|
|
|
|
|
declarations.push(cssVar('color-content-on-solid', color.content.onSolid))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (color.border) {
|
|
|
|
|
declarations.push(cssVar('color-border-subtle', color.border.subtle))
|
|
|
|
|
declarations.push(cssVar('color-border-default', color.border.default))
|
|
|
|
|
declarations.push(cssVar('color-border-strong', color.border.strong))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (color.focus) {
|
|
|
|
|
declarations.push(cssVar('focus-ring-color', color.focus.ring))
|
|
|
|
|
declarations.push(cssVar('focus-ring-color-error', color.focus.ringError))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function appendColorScaleDeclarations(
|
|
|
|
|
declarations: string[],
|
|
|
|
|
scaleName: string,
|
|
|
|
|
scale: ColorScale
|
|
|
|
|
): void {
|
|
|
|
|
for (const step of COLOR_SCALE_STEPS) {
|
|
|
|
|
declarations.push(cssVar(`scale-${scaleName}-${step}`, scale[step]))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function parseHexColor(value: string): { r: number; g: number; b: number } | null {
|
|
|
|
|
const text = value.trim()
|
|
|
|
|
if (!text.startsWith('#')) return null
|
|
|
|
|
const hex = text.slice(1)
|
|
|
|
|
let r: number, g: number, b: number
|
|
|
|
|
if (hex.length === 3) {
|
|
|
|
|
r = parseInt(hex[0] + hex[0], 16)
|
|
|
|
|
g = parseInt(hex[1] + hex[1], 16)
|
|
|
|
|
b = parseInt(hex[2] + hex[2], 16)
|
|
|
|
|
} else if (hex.length === 6 || hex.length === 8) {
|
|
|
|
|
r = parseInt(hex.slice(0, 2), 16)
|
|
|
|
|
g = parseInt(hex.slice(2, 4), 16)
|
|
|
|
|
b = parseInt(hex.slice(4, 6), 16)
|
|
|
|
|
} else {
|
|
|
|
|
return null
|
|
|
|
|
}
|
|
|
|
|
if ([r, g, b].some(Number.isNaN)) return null
|
|
|
|
|
return { r, g, b }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** sRGB relative luminance (0..1) — enough to pick the alpha background polarity. */
|
|
|
|
|
function srgbLuminance({ r, g, b }: { r: number; g: number; b: number }): number {
|
|
|
|
|
return (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Compositing inverse: the translucent color that, painted over a solid
|
|
|
|
|
* background `bg` (255 = white, 0 = black), reproduces the opaque `solidHex`.
|
|
|
|
|
* Solves `solid = a·c + (1-a)·bg` for the minimum alpha `a` (max saturation),
|
|
|
|
|
* per channel. Returns `null` when the solid isn't a plain hex (caller keeps
|
|
|
|
|
* the synthetic ramp for non-hex values).
|
|
|
|
|
*/
|
|
|
|
|
function alphaColorOverBackground(solidHex: string, bg: 0 | 255): string | null {
|
|
|
|
|
const t = parseHexColor(solidHex)
|
|
|
|
|
if (!t) return null
|
|
|
|
|
let a = 0
|
|
|
|
|
for (const c of [t.r, t.g, t.b]) {
|
|
|
|
|
if (c === bg) continue
|
|
|
|
|
const channelAlpha = bg === 255 ? (255 - c) / 255 : c / 255
|
|
|
|
|
if (channelAlpha > a) a = channelAlpha
|
|
|
|
|
}
|
|
|
|
|
if (a <= 0) return `rgb(${t.r} ${t.g} ${t.b} / 0)`
|
|
|
|
|
const resolve = (c: number) =>
|
|
|
|
|
Math.min(255, Math.max(0, Math.round((c - bg * (1 - a)) / a)))
|
|
|
|
|
const alpha = Math.round(a * 10000) / 10000
|
|
|
|
|
return `rgb(${resolve(t.r)} ${resolve(t.g)} ${resolve(t.b)} / ${alpha})`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Alpha (translucent) variants of a color scale.
|
|
|
|
|
*
|
|
|
|
|
* If a theme provides explicit `alphaScales` for this scale they win (verbatim).
|
|
|
|
|
* Otherwise each `aN` is GENERATED as the compositing inverse of the solid step
|
|
|
|
|
* `N` over the scale's background polarity (white for light scales, black for
|
|
|
|
|
* dark — decided by step 1's luminance): `aN` painted over that background
|
|
|
|
|
* reproduces solid `N`. This keeps the alpha ramp consistent-by-construction
|
|
|
|
|
* with the solid scale (no drift, Radix-style fidelity) for every theme, not
|
|
|
|
|
* just the base. Non-hex solids (e.g. `var()`) fall back to the legacy
|
|
|
|
|
* step-9-at-opacity `color-mix` ramp. (audit P1-1)
|
|
|
|
|
*/
|
|
|
|
|
function appendColorAlphaScaleDeclarations(
|
|
|
|
|
declarations: string[],
|
|
|
|
|
scaleName: string,
|
|
|
|
|
solidScale: ColorScale | undefined,
|
|
|
|
|
alphaScale: ColorAlphaScale | undefined
|
|
|
|
|
): void {
|
|
|
|
|
const step1 = solidScale?.['1']
|
|
|
|
|
const parsedStep1 = step1 ? parseHexColor(step1) : null
|
|
|
|
|
const bg: 0 | 255 = parsedStep1 && srgbLuminance(parsedStep1) < 0.5 ? 0 : 255
|
|
|
|
|
|
|
|
|
|
for (const step of COLOR_ALPHA_STEPS) {
|
|
|
|
|
const authored = alphaScale?.[step]
|
|
|
|
|
const solid = solidScale?.[step]
|
|
|
|
|
const computed =
|
|
|
|
|
authored === undefined && solid ? alphaColorOverBackground(solid, bg) : null
|
|
|
|
|
const value =
|
|
|
|
|
authored ??
|
|
|
|
|
computed ??
|
|
|
|
|
`color-mix(in srgb, var(--scale-${scaleName}-9) ${
|
|
|
|
|
DEFAULT_COLOR_ALPHA_PERCENTAGES[step]
|
|
|
|
|
}, transparent)`
|
|
|
|
|
declarations.push(cssVar(`scale-${scaleName}-a${step}`, value))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function appendRecordDeclarations(
|
|
|
|
|
declarations: string[],
|
|
|
|
|
prefix: string,
|
|
|
|
|
record: Record<string, string | number> | undefined
|
|
|
|
|
): void {
|
|
|
|
|
if (!record) return
|
|
|
|
|
|
|
|
|
|
for (const [name, value] of Object.entries(record)) {
|
|
|
|
|
declarations.push(cssVar(`${prefix}-${name}`, value))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Like {@link appendRecordDeclarations} but multiplies each value by the
|
|
|
|
|
* active density scalar (`var(--{scaleVar})`) via `calc()`, so the density
|
|
|
|
|
* axis (compact / comfortable / spacious) actually rescales the token.
|
|
|
|
|
*
|
|
|
|
|
* The density block (`renderDensityBlocks`) redeclares `--density-*-scale`
|
|
|
|
|
* on `[data-density='…']` (the same element where these tokens are
|
|
|
|
|
* authored — `:root` / `<html>`), so the `var()` here resolves against the
|
|
|
|
|
* active density. Zero values are emitted verbatim (`calc(0 * x)` is
|
|
|
|
|
* pointless and `0px` must remain a valid length). At the `comfortable`
|
|
|
|
|
* default the scalar is `1`, so the emitted value is numerically identical
|
|
|
|
|
* to the raw token — no change for consumers that never switch density.
|
|
|
|
|
*/
|
Theming: add `scaling` zoom axis (Radix parity), separate from density
Introduce a global zoom axis independent of density, in parity with Radix
Themes' `scaling` (90/95/100/105/110%). Scaling zooms px metrics INCLUDING
typography (font-size, icon-size, space, control-height); density only moves
layout rhythm + control height and leaves text fixed. The two axes compose
multiplicatively.
- config-types: SCALING_KEYS / ScalingKey / DEFAULT_SCALING; DensityPrimitiveSet
drops the dead `scale` + `contentScale` (kept spaceScale, controlScale).
- primitives/static: STATIC_SCALING (0.9..1.1).
- render-css: appendScaledMetricDeclarations wraps metrics in
calc(<raw>[ * var(--density-x-scale)] * var(--scaling)); appendScalingDeclarations
emits --scaling-{key} + --scaling default; renderScalingBlocks emits
[data-scaling] blocks. line-height/radius/border/shadow excluded.
- config + contract: prune the removed density scalars.
- active-eidos: `scaling` / `scalingSource` options, getScaling() on the
preference source, data-scaling projection + dispose cleanup.
- docs: THEMING.md section 23 + 20.1 reconcile; README density/scaling; SCALING_RFC.md.
Verified: npm run check (0 new errors), vitest eidos (0 new regressions),
browser cascade at 90/100/110 scales font/space/control x0.9/x1.1 and leaves
radius/border fixed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
4 months ago
|
|
|
// 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,
|
Theming: add `scaling` zoom axis (Radix parity), separate from density
Introduce a global zoom axis independent of density, in parity with Radix
Themes' `scaling` (90/95/100/105/110%). Scaling zooms px metrics INCLUDING
typography (font-size, icon-size, space, control-height); density only moves
layout rhythm + control height and leaves text fixed. The two axes compose
multiplicatively.
- config-types: SCALING_KEYS / ScalingKey / DEFAULT_SCALING; DensityPrimitiveSet
drops the dead `scale` + `contentScale` (kept spaceScale, controlScale).
- primitives/static: STATIC_SCALING (0.9..1.1).
- render-css: appendScaledMetricDeclarations wraps metrics in
calc(<raw>[ * var(--density-x-scale)] * var(--scaling)); appendScalingDeclarations
emits --scaling-{key} + --scaling default; renderScalingBlocks emits
[data-scaling] blocks. line-height/radius/border/shadow excluded.
- config + contract: prune the removed density scalars.
- active-eidos: `scaling` / `scalingSource` options, getScaling() on the
preference source, data-scaling projection + dispose cleanup.
- docs: THEMING.md section 23 + 20.1 reconcile; README density/scaling; SCALING_RFC.md.
Verified: npm run check (0 new errors), vitest eidos (0 new regressions),
browser cascade at 90/100/110 scales font/space/control x0.9/x1.1 and leaves
radius/border fixed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
4 months ago
|
|
|
densityScaleVar?: string
|
|
|
|
|
): void {
|
|
|
|
|
if (!record) return
|
|
|
|
|
|
|
|
|
|
for (const [name, value] of Object.entries(record)) {
|
|
|
|
|
const raw = String(value).trim()
|
|
|
|
|
const isZero = parseFloat(raw) === 0
|
Theming: add `scaling` zoom axis (Radix parity), separate from density
Introduce a global zoom axis independent of density, in parity with Radix
Themes' `scaling` (90/95/100/105/110%). Scaling zooms px metrics INCLUDING
typography (font-size, icon-size, space, control-height); density only moves
layout rhythm + control height and leaves text fixed. The two axes compose
multiplicatively.
- config-types: SCALING_KEYS / ScalingKey / DEFAULT_SCALING; DensityPrimitiveSet
drops the dead `scale` + `contentScale` (kept spaceScale, controlScale).
- primitives/static: STATIC_SCALING (0.9..1.1).
- render-css: appendScaledMetricDeclarations wraps metrics in
calc(<raw>[ * var(--density-x-scale)] * var(--scaling)); appendScalingDeclarations
emits --scaling-{key} + --scaling default; renderScalingBlocks emits
[data-scaling] blocks. line-height/radius/border/shadow excluded.
- config + contract: prune the removed density scalars.
- active-eidos: `scaling` / `scalingSource` options, getScaling() on the
preference source, data-scaling projection + dispose cleanup.
- docs: THEMING.md section 23 + 20.1 reconcile; README density/scaling; SCALING_RFC.md.
Verified: npm run check (0 new errors), vitest eidos (0 new regressions),
browser cascade at 90/100/110 scales font/space/control x0.9/x1.1 and leaves
radius/border fixed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
4 months ago
|
|
|
const density = densityScaleVar ? ` * var(--${densityScaleVar})` : ''
|
|
|
|
|
declarations.push(
|
Theming: add `scaling` zoom axis (Radix parity), separate from density
Introduce a global zoom axis independent of density, in parity with Radix
Themes' `scaling` (90/95/100/105/110%). Scaling zooms px metrics INCLUDING
typography (font-size, icon-size, space, control-height); density only moves
layout rhythm + control height and leaves text fixed. The two axes compose
multiplicatively.
- config-types: SCALING_KEYS / ScalingKey / DEFAULT_SCALING; DensityPrimitiveSet
drops the dead `scale` + `contentScale` (kept spaceScale, controlScale).
- primitives/static: STATIC_SCALING (0.9..1.1).
- render-css: appendScaledMetricDeclarations wraps metrics in
calc(<raw>[ * var(--density-x-scale)] * var(--scaling)); appendScalingDeclarations
emits --scaling-{key} + --scaling default; renderScalingBlocks emits
[data-scaling] blocks. line-height/radius/border/shadow excluded.
- config + contract: prune the removed density scalars.
- active-eidos: `scaling` / `scalingSource` options, getScaling() on the
preference source, data-scaling projection + dispose cleanup.
- docs: THEMING.md section 23 + 20.1 reconcile; README density/scaling; SCALING_RFC.md.
Verified: npm run check (0 new errors), vitest eidos (0 new regressions),
browser cascade at 90/100/110 scales font/space/control x0.9/x1.1 and leaves
radius/border fixed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
4 months ago
|
|
|
cssVar(`${prefix}-${name}`, isZero ? raw : `calc(${raw}${density} * var(--scaling))`)
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function mergeThemeColor(
|
|
|
|
|
primitiveScales: ColorScales | undefined,
|
|
|
|
|
semanticColor: EidosConfig['semantics']['color'],
|
|
|
|
|
themeColor: ThemeColorSet | undefined
|
|
|
|
|
) {
|
|
|
|
|
return {
|
|
|
|
|
scales: {
|
|
|
|
|
...primitiveScales,
|
|
|
|
|
...themeColor?.scales
|
|
|
|
|
},
|
|
|
|
|
alphaScales: {
|
|
|
|
|
...themeColor?.alphaScales
|
|
|
|
|
},
|
|
|
|
|
roles: {
|
|
|
|
|
...semanticColor.roles,
|
|
|
|
|
...themeColor?.roles
|
|
|
|
|
} as ColorRoleMap,
|
|
|
|
|
surface:
|
|
|
|
|
semanticColor.surface || themeColor?.surface
|
|
|
|
|
? ({
|
|
|
|
|
...semanticColor.surface,
|
|
|
|
|
...themeColor?.surface
|
|
|
|
|
} as SurfaceColorRoles)
|
|
|
|
|
: undefined,
|
|
|
|
|
content:
|
|
|
|
|
semanticColor.content || themeColor?.content
|
|
|
|
|
? ({
|
|
|
|
|
...semanticColor.content,
|
|
|
|
|
...themeColor?.content
|
|
|
|
|
} as ContentColorRoles)
|
|
|
|
|
: undefined,
|
|
|
|
|
border:
|
|
|
|
|
semanticColor.border || themeColor?.border
|
|
|
|
|
? ({
|
|
|
|
|
...semanticColor.border,
|
|
|
|
|
...themeColor?.border
|
|
|
|
|
} as BorderColorRoles)
|
|
|
|
|
: undefined,
|
|
|
|
|
focus:
|
|
|
|
|
semanticColor.focus || themeColor?.focus
|
|
|
|
|
? ({
|
|
|
|
|
...semanticColor.focus,
|
|
|
|
|
...themeColor?.focus
|
|
|
|
|
} as FocusColorRoles)
|
|
|
|
|
: undefined
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getThemeSelector(themeId: string): string {
|
|
|
|
|
return `[data-theme='${themeId}']`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getColorRoleScaleName(definition: string | ColorRoleDefinition): string {
|
|
|
|
|
if (typeof definition === 'string') return definition
|
|
|
|
|
|
|
|
|
|
return definition.scale
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function cssVar(name: string, value: string | number): string {
|
|
|
|
|
return `--${name}: ${value};`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function emptyCssVar(name: string): string {
|
|
|
|
|
return `--${name}: ;`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function renderBlock(selector: string, declarations: string[]): string {
|
|
|
|
|
return `${selector} {\n${declarations.map((declaration) => `\t${declaration}`).join('\n')}\n}`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function formatFontFamily(family: FontFamily): string {
|
|
|
|
|
return [family.family, ...(family.fallbacks ?? [])].map(formatFontName).join(', ')
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function formatFontName(name: string): string {
|
|
|
|
|
if (GENERIC_FONT_FAMILIES.has(name)) return name
|
|
|
|
|
if (name.startsWith('"') || name.startsWith("'")) return name
|
|
|
|
|
if (!/\s/.test(name)) return name
|
|
|
|
|
|
|
|
|
|
return `'${name.replace(/'/g, "\\'")}'`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getContractVariableNames(
|
|
|
|
|
contract: EidosCssContract | readonly EidosCssContractToken[]
|
|
|
|
|
): Set<string> {
|
|
|
|
|
const tokens = isEidosCssContract(contract) ? [...contract.static, ...contract.theme] : contract
|
|
|
|
|
return new Set(tokens.map((token) => token.name))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function isEidosCssContract(
|
|
|
|
|
contract: EidosCssContract | readonly EidosCssContractToken[]
|
|
|
|
|
): contract is EidosCssContract {
|
|
|
|
|
return !Array.isArray(contract)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function normalizeCssVariableName(name: string): string | undefined {
|
|
|
|
|
const normalized = name.startsWith('--') ? name.slice(2) : name
|
|
|
|
|
if (/^[a-zA-Z0-9_-]+$/.test(normalized)) return normalized
|
|
|
|
|
return undefined
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function assertCssVariableValue(name: string, value: EidosCssVariableValue): string | number {
|
|
|
|
|
if (typeof value === 'number') return value
|
|
|
|
|
if (!/[;{}]/.test(value)) return value
|
|
|
|
|
|
|
|
|
|
throw new EidosCssVariableError([name])
|
|
|
|
|
}
|