|
|
|
|
|
import {
|
|
|
|
|
|
COLOR_ALPHA_STEPS,
|
|
|
|
|
|
COLOR_SCALE_STEPS,
|
|
|
|
|
|
COLOR_ROLES,
|
|
|
|
|
|
DENSITY_KEYS,
|
feat(motion): motion engine as arts/motion service + F6 tokens + F7 typegen
Relocate the motion runtime out of eidos into a runtime art (src/arts/motion,
$motion), exposed as uix.motion and consumed by BOTH soma (Presence.motion ->
motion.run) and eidos (delegates + registers presets) -- dissolving the
soma->eidos coupling. Remove DialogProps.runMotion / eidos.motionRunner; the
bridge is now EngineMotion.run (reads data-animation-style). Delete the 4
relocated dead files (lib/motion/{types,runtime,runner,presets/js}.ts); the
preset DATA (presets/css.ts) stays in eidos. Regenerate generated/base.css.
F6 - token rigor (Carbon): tokenize the raw firma durations (slower/deliberate/
emphatic/sustained holds, escalating by announce intent severity), add
--motion-distance-xl (30px shared-axis), --motion-scale-through (0.92), the
emphasized easing, and productive/expressive sets ([data-motion-set=expressive]).
F7 - extensibility + typegen: app-extensible, type-safe preset-name registry
(EidosMotionPresets, mirroring SemaChannelSignatures); MotionPresetName =
keyof EidosMotionPresets | none | (string & {}).
Also sweeps other in-progress working-tree edits (web/routes/temas/grafito).
Verify: npm run check -> 1 pre-existing error (grafito), 0 new; motion 22/22,
active-uix 25/25, Presence 2/2, Dialog 3/3. Pre-existing words-track failures
unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
4 months ago
|
|
|
|
DURATION_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,
|
Theming: Radix-parity palette + intent auto-derivation + color-model docs
Two-level color model settled (THEMING.md section 25), replacing the anchor RFC:
the palette is the source (scales, directly usable, designable); hierarchy roles
alias scales explicitly; intents auto-derive from the palette by the book's
canonical convention.
- Palette library expanded 12 -> 31 scales at Radix Colors parity (exact values):
radix-scales.ts (19 added: mauve/sage/olive/sand/tomato/ruby/crimson/plum/
violet/iris/indigo/jade/grass/brown/sky/mint/lime/gold/bronze) spread into
base.ts. Each directly usable as --scale-{name}-{step}.
- Intent auto-derivation: CANONICAL_INTENT_SCALES (neutral->gray, affirm->teal,
fulfill->green, risk->amber, threat->red, loss->plum) + completeColorRoleMap.
Intents omitted from a theme role map fill from the convention (identity =
step 9); slots derive normally; override optional. ColorRoleMap: hierarchy
required, intents optional.
- Validation: hierarchy roles required; omitted intents validate the canonical
scale exists in the palette.
- index: export ScalingKey / SCALING_KEYS.
- docs: THEMING.md section 25 (full color model + decisions), section 21 marked
resolved, COLOR_MODEL_RFC resolved (anchor rejected).
- test: base library asserts 31 scales x 12 steps.
Verified: npm run check (0 new errors), vitest eidos (0 new regressions),
browser (intents auto-derive: affirm=teal #0E9384, risk=amber #DC6803,
loss=plum #7A3AAD at step 9).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
4 months ago
|
|
|
|
completeColorRoleMap,
|
|
|
|
|
|
type ColorRoleDefinition,
|
|
|
|
|
|
type ColorRoleSlot,
|
|
|
|
|
|
type ColorRoleMap,
|
|
|
|
|
|
type ColorAlphaStep,
|
|
|
|
|
|
type ColorAlphaScale,
|
|
|
|
|
|
type ColorScale,
|
|
|
|
|
|
type ColorScales,
|
|
|
|
|
|
type BorderColorRoles,
|
|
|
|
|
|
type BorderPrimitiveSet,
|
|
|
|
|
|
type ContentColorRoles,
|
|
|
|
|
|
type DensityPrimitiveSet,
|
|
|
|
|
|
type EidosCssContract,
|
|
|
|
|
|
type EidosCssContractToken,
|
|
|
|
|
|
type EidosCssVariableMap,
|
|
|
|
|
|
type EidosCssVariableValue,
|
|
|
|
|
|
type EidosConfig,
|
|
|
|
|
|
type DepthPrimitiveSet,
|
|
|
|
|
|
type ShapePrimitiveSet,
|
feat(eidos): config-driven @font-face (Phase 2) — fonts as theme data (next/font model)
TYPOGRAPHY_ENGINE_RFC Phase 2. Fonts are theme data (each theme owns its families), so
@font-face becomes config-driven + generated — like the color palette — instead of a
separate hand-written CSS file. This matches next/font / Fontaine (config -> @font-face),
above the token-only frameworks (Radix/Tailwind/MUI) that leave loading to you.
- config-types: FontFamily += faces (FontFace[]) / axes (FontAxes) / fallback
(FontFallback, metric-override) / display / preload. Additive — the family stack still
works from `family`+`fallbacks`.
- render-css: renderFontFaceBlocks generates @font-face per face from the config, deduped
by the real font name (a font shared across slots — Lora as secondary+display — emits
once). Optional metric-override fallback @font-face (anti-CLS) injected into the stack
as `'{family} Fallback'` when declared. Emitted first in renderStaticCss.
- typography.ts: the BASE THEME's 14 @font-face migrated from fonts.css into the config
(faces). TTF today (the theme's choice); a theme swaps to woff2/variable + fallback
metrics by editing config only.
- index.css: drops `@import './themes/fonts.css'` — the @font-face now ships in
generated/base.css. (fonts.css superseded; left in place, no longer imported.)
- generated/base.css regenerated (14 @font-face, Lora deduped). test: @font-face
generation + dedup; merge-without-mutation assertion updated for the faces field.
Verified in browser: 3 families registered, files resolve (200), fonts load on demand
(swap). check 0 errors; eidos suite green (3 pre-existing words failures only).
Deferred (capability typed, theme adopts when it has the assets): variable woff2,
metric-override numbers (need fontkit/precompute), <link rel=preload> (head markup).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
4 months ago
|
|
|
|
type FontFace,
|
|
|
|
|
|
type FontFallback,
|
|
|
|
|
|
type FontFamily,
|
|
|
|
|
|
type FocusColorRoles,
|
|
|
|
|
|
type LayoutPrimitiveSet,
|
|
|
|
|
|
type PrimitiveSet,
|
feat(eidos): TSC v2.2 (parts + composition) + universal theming coverage
Token Scope Contract universal — no más excepciones arquitectónicas.
Los 3 componentes que vivían fuera de TSC v2.1 (select, avatar,
toggle-group) ahora están dentro del contrato via dos extensiones nuevas.
TSC v2.2 extensiones (lib/config-types.ts + render-css.ts + config.ts):
- `parts: readonly string[]` en RecipeTokenMultiDeclaration — emite
selectores comma-separados (`[data-{c}-x], [data-{c}-y]`) para
componentes con data-color cascadeado per-part. Consumer: select.
- `composition: { foreignRecipe: { targetSelector, tokens } }` sibling
key — overrides cross-recipe scoped a la cascade del host. Consumer:
toggle-group modifica `--toggle-palette-*` en sus items.
Migraciones:
- select: 3 private `_accent-{track,border,text}` con parts: ['trigger',
'content'] + 7 cascades color:X each. Removed orphan `_accent-solid`
(CSS no consumía).
- avatar: 6 tokens via composite scopes `['variant:X', 'color:Y']` con
matrix helper inline. Badge usa parts: ['badge']. Reemplaza 24 bloques
CSS × 2 partes.
- toggle-group: composition block con 8 palette tokens × 4 colors.
Reemplaza 4 bloques CSS per-color.
Bug toggle-group post-composition (encontrado y arreglado):
Tras la composition migration, el cascade del toggle-group seguía roto
porque los tokens derivados (`--toggle-solid-on-bg`, `--toggle-outline-fg`,
etc.) viven en scope `[data-toggle]`. El `[data-toggle-group-item]` es
sibling (no descendant), así que `var(--toggle-solid-on-bg)` resolvía
undefined. Fix: inlined derivation expressions directamente en
`[data-toggle-group-item]` y sus variant cascades (solid/outline/ghost),
referenciando palette tokens en su propio scope local.
Validador + emisor + contract:
- `validateRecipeComposition` valida el shape `{ targetSelector, tokens }`
y rechaza composition entries con scope='root'.
- `stripCompositionKey` + `emitComposition` separan el pipeline.
- `appendRecipeContractTokens` skip-list para `composition` (no aparece
como fake `--{c}-composition` knob).
- `tokenKeys`/`tokenEntries` helpers en recipe-css-contract.test.ts
filtran composition en todos los iteradores.
Documentación:
- THEMING.md §18 reescrito como "Cobertura universal de TSC". §7
extendido con subsecciones "Multi-part scope" y "Cross-recipe
composition" + ejemplos completos. TOC actualizado.
- eidos/README.md tabla de referencia ampliada con TSC v2.2 + §18.
- CLAUDE.md gana hand-off "2026-05-27 #5" (TSC v2.2 + cobertura universal).
- CONTINUE.md reescrito al estado actual de la sesión.
Working tree también incluye sprint Words en paralelo (multiple authors):
slash menu, find/replace regex, code language picker, table audit,
toolbar family menu, code highlight engine.
Tests: 786/786 pass en src/uix/{eidos,morfo,soma,sema}. `npm run check`:
6 errores pre-existentes (lib/_demo, soma/components/internal,
web/routes/active) no relacionados.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
5 months ago
|
|
|
|
type RecipeComposition,
|
|
|
|
|
|
type 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'
|
feat(motion): motion engine as arts/motion service + F6 tokens + F7 typegen
Relocate the motion runtime out of eidos into a runtime art (src/arts/motion,
$motion), exposed as uix.motion and consumed by BOTH soma (Presence.motion ->
motion.run) and eidos (delegates + registers presets) -- dissolving the
soma->eidos coupling. Remove DialogProps.runMotion / eidos.motionRunner; the
bridge is now EngineMotion.run (reads data-animation-style). Delete the 4
relocated dead files (lib/motion/{types,runtime,runner,presets/js}.ts); the
preset DATA (presets/css.ts) stays in eidos. Regenerate generated/base.css.
F6 - token rigor (Carbon): tokenize the raw firma durations (slower/deliberate/
emphatic/sustained holds, escalating by announce intent severity), add
--motion-distance-xl (30px shared-axis), --motion-scale-through (0.92), the
emphasized easing, and productive/expressive sets ([data-motion-set=expressive]).
F7 - extensibility + typegen: app-extensible, type-safe preset-name registry
(EidosMotionPresets, mirroring SemaChannelSignatures); MotionPresetName =
keyof EidosMotionPresets | none | (string & {}).
Also sweeps other in-progress working-tree edits (web/routes/temas/grafito).
Verify: npm run check -> 1 pre-existing error (grafito), 0 new; motion 22/22,
active-uix 25/25, Presence 2/2, Dialog 3/3. Pre-existing words-track failures
unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
4 months ago
|
|
|
|
import type {
|
|
|
|
|
|
CssStatePreset,
|
|
|
|
|
|
CssPhase,
|
|
|
|
|
|
EventSignature,
|
|
|
|
|
|
KeyframeName,
|
|
|
|
|
|
KeyframeStops,
|
|
|
|
|
|
MotionConfig
|
|
|
|
|
|
} from '$motion'
|
|
|
|
|
|
import { apcaLc, oklchToCss, oklchToGammaRgb, safeParseColor, wcagContrastRatio } from '$color'
|
feat(eidos): fluid typography engine (Phase 1) — Utopia clamp, rem, fluid headings
TYPOGRAPHY_ENGINE_RFC Phase 1. Additive on TypographyPrimitiveSet, behind the frozen
token contract (--font-size-X keeps its name; only the value formula changes, like
color --scale-* hex -> oklch()).
- type-scale.ts (pure, isomorphic, no canvas): the Utopia clamp() formula. fluidClamp /
resolveTypeSize / isFluidSize. rem-based (a11y: scales with browser font-zoom).
- config-types: TextMetric.size accepts `string | FluidSize` ({min,max,minVw?,maxVw?}).
Plain length strings still valid -> backward-compatible.
- render-css appendTypographyDeclarations: emits calc(resolveTypeSize(size) * --scaling)
-> a fixed rem or a fluid clamp; the --scaling axis composes on top.
- config.ts: validateSizeValue accepts a FluidSize (validates min/max/minVw/maxVw) so
the base config validates (was the cascade root — FluidSize objects failed the
string-only CSS-value check).
- typography.ts: sizes in rem; headings (lg/xl/xxl/xxxl) fluid (min @480px -> max
@1280px, max = previous fixed px so desktop is unchanged); body (md) fixed. hero/h1/h2
drop the manual { base, md } responsive sizes — the clamp covers the viewport.
- generated/base.css regenerated. type-scale.test.ts (6 tests). 2 config-test assertions
updated to the new rem/clamp values.
Verified in browser: --font-size-xxxl 40px @480 -> 80px @1280; xxl 32->48; lg 18->20;
md 16 fixed. check 0 errors; eidos suite green (3 pre-existing words failures only).
canvas-text/<SText> unaffected (reads getComputedStyle real font, measures the clamp).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
4 months ago
|
|
|
|
import { resolveTypeSize } from './type-scale'
|
|
|
|
|
|
import { EidosCssVariableError, EidosThemeNotFoundError } from '../errors'
|
|
|
|
|
|
|
|
|
|
|
|
export interface RenderThemeCssOptions {
|
|
|
|
|
|
readonly selector?: string
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Which raw `--scale-*` donor tokens to emit. `'roleReferenced'`
|
|
|
|
|
|
* (default) emits only the scales the theme's roles reference — the slim
|
|
|
|
|
|
* foundation. `'all'` emits the full donor palette (used by the opt-in
|
|
|
|
|
|
* `palette.css`). Roles reference scales via `var()`, so the
|
|
|
|
|
|
* role-referenced subset is sufficient for the foundation; runtime theming
|
|
|
|
|
|
* builds from JS scale data and writes resolved values, so it never depends
|
|
|
|
|
|
* on the unemitted scales.
|
|
|
|
|
|
*/
|
|
|
|
|
|
readonly scales?: 'roleReferenced' | 'all'
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export interface RenderContractCssOptions {
|
|
|
|
|
|
readonly staticSelector?: string
|
|
|
|
|
|
readonly themeSelector?: string
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export interface RenderCssVariablesOptions {
|
|
|
|
|
|
readonly selector?: string
|
|
|
|
|
|
readonly contract?: EidosCssContract | readonly EidosCssContractToken[]
|
|
|
|
|
|
readonly strict?: boolean
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Radix functional steps: 1-2 backgrounds · 3-5 component bg (rest/hover/active) ·
|
|
|
|
|
|
// 6 subtle separator · 7 UI element border · 8 hovered element border · 9 solid ·
|
|
|
|
|
|
// 10 solid hover · 11-12 text. The role `border` slot maps to step 7 (Radix's "UI
|
|
|
|
|
|
// element border") — step 6 reads as a washed-out separator on real element borders.
|
|
|
|
|
|
const DEFAULT_COLOR_ROLE_SLOT_STEPS: Record<ColorRoleSlot, string> = {
|
|
|
|
|
|
track: '1',
|
|
|
|
|
|
element: '3',
|
|
|
|
|
|
hover: '4',
|
|
|
|
|
|
active: '5',
|
|
|
|
|
|
border: '7',
|
|
|
|
|
|
solid: '9',
|
|
|
|
|
|
solidHover: '10',
|
|
|
|
|
|
text: '11',
|
|
|
|
|
|
contrast: '12'
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* On-solid legibility floor. The theme's `onSolid` text (white) must clear THIS on a
|
|
|
|
|
|
* role's solid to stay; below it the contrast slot flips to `onSolidContrast` (dark).
|
|
|
|
|
|
* APCA decides (|Lc| ≥ floor, ≈ the WCAG 3:1 UI minimum) with a WCAG 2 ratio as a
|
|
|
|
|
|
* conservative cross-check — white must clear BOTH. (COLOR_ENGINE_RFC §8)
|
|
|
|
|
|
*/
|
|
|
|
|
|
const ON_SOLID_APCA_FLOOR = 60
|
|
|
|
|
|
const ON_SOLID_WCAG_FLOOR = 3
|
|
|
|
|
|
|
|
|
|
|
|
const DEFAULT_COLOR_ALPHA_PERCENTAGES: Record<ColorAlphaStep, string> = {
|
|
|
|
|
|
'1': '3%',
|
|
|
|
|
|
'2': '5%',
|
|
|
|
|
|
'3': '8%',
|
|
|
|
|
|
'4': '12%',
|
|
|
|
|
|
'5': '16%',
|
|
|
|
|
|
'6': '22%',
|
|
|
|
|
|
'7': '30%',
|
|
|
|
|
|
'8': '40%',
|
|
|
|
|
|
'9': '56%',
|
|
|
|
|
|
'10': '68%',
|
|
|
|
|
|
'11': '80%',
|
|
|
|
|
|
'12': '92%'
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const GENERIC_FONT_FAMILIES = new Set([
|
|
|
|
|
|
'serif',
|
|
|
|
|
|
'sans-serif',
|
|
|
|
|
|
'monospace',
|
|
|
|
|
|
'cursive',
|
|
|
|
|
|
'fantasy',
|
|
|
|
|
|
'system-ui',
|
|
|
|
|
|
'ui-serif',
|
|
|
|
|
|
'ui-sans-serif',
|
|
|
|
|
|
'ui-monospace',
|
|
|
|
|
|
'emoji',
|
|
|
|
|
|
'math',
|
|
|
|
|
|
'fangsong'
|
|
|
|
|
|
])
|
|
|
|
|
|
|
|
|
|
|
|
export function renderStaticCss(options: EidosConfig): string {
|
|
|
|
|
|
const { primitives } = options
|
|
|
|
|
|
const declarations: string[] = []
|
|
|
|
|
|
|
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)
|
|
|
|
|
|
// Variable fonts: enable optical sizing globally when any family declares an `opsz`
|
|
|
|
|
|
// axis, so the font's optical-size axis tracks the rendered font-size. (RFC §5)
|
|
|
|
|
|
if (Object.values(primitives.typography.families).some((f) => f.axes?.opsz)) {
|
|
|
|
|
|
declarations.push('font-optical-sizing: auto;')
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (primitives.size) {
|
|
|
|
|
|
appendSizeDeclarations(declarations, primitives.size)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (primitives.depth) {
|
|
|
|
|
|
appendDepthDeclarations(declarations, primitives.depth)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (primitives.shape) {
|
|
|
|
|
|
appendShapeDeclarations(declarations, primitives.shape)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
appendTransitionAliasDeclarations(declarations, options)
|
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)
|
|
|
|
|
|
const depthBlocks = primitives.depth ? renderDepthBlocks(primitives.depth) : []
|
|
|
|
|
|
const shapeBlocks = primitives.shape ? renderShapeBlocks(primitives.shape) : []
|
|
|
|
|
|
|
feat(eidos): config-driven @font-face (Phase 2) — fonts as theme data (next/font model)
TYPOGRAPHY_ENGINE_RFC Phase 2. Fonts are theme data (each theme owns its families), so
@font-face becomes config-driven + generated — like the color palette — instead of a
separate hand-written CSS file. This matches next/font / Fontaine (config -> @font-face),
above the token-only frameworks (Radix/Tailwind/MUI) that leave loading to you.
- config-types: FontFamily += faces (FontFace[]) / axes (FontAxes) / fallback
(FontFallback, metric-override) / display / preload. Additive — the family stack still
works from `family`+`fallbacks`.
- render-css: renderFontFaceBlocks generates @font-face per face from the config, deduped
by the real font name (a font shared across slots — Lora as secondary+display — emits
once). Optional metric-override fallback @font-face (anti-CLS) injected into the stack
as `'{family} Fallback'` when declared. Emitted first in renderStaticCss.
- typography.ts: the BASE THEME's 14 @font-face migrated from fonts.css into the config
(faces). TTF today (the theme's choice); a theme swaps to woff2/variable + fallback
metrics by editing config only.
- index.css: drops `@import './themes/fonts.css'` — the @font-face now ships in
generated/base.css. (fonts.css superseded; left in place, no longer imported.)
- generated/base.css regenerated (14 @font-face, Lora deduped). test: @font-face
generation + dedup; merge-without-mutation assertion updated for the faces field.
Verified in browser: 3 families registered, files resolve (200), fonts load on demand
(swap). check 0 errors; eidos suite green (3 pre-existing words failures only).
Deferred (capability typed, theme adopts when it has the assets): variable woff2,
metric-override numbers (need fontkit/precompute), <link rel=preload> (head markup).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
4 months ago
|
|
|
|
const blocks: string[] = []
|
|
|
|
|
|
// @font-face first (config-driven loading); the family-stack tokens reference them.
|
|
|
|
|
|
if (primitives.typography) {
|
|
|
|
|
|
const fontFaceCss = renderFontFaceBlocks(primitives.typography)
|
|
|
|
|
|
if (fontFaceCss) blocks.push(fontFaceCss)
|
|
|
|
|
|
}
|
|
|
|
|
|
blocks.push(
|
|
|
|
|
|
renderBlock(':root', declarations),
|
|
|
|
|
|
...scopedRecipeBlocks,
|
|
|
|
|
|
...depthBlocks,
|
|
|
|
|
|
...shapeBlocks
|
|
|
|
|
|
)
|
feat(eidos foundation): emit --style-{name}-* tokens for named typography styles
`STATIC_TYPOGRAPHY.styles` already defined 11 named typography styles
(hero, h1..h6, body, prose, label, caption, code) but the renderer
only emitted families / sizes / weights — the style entries lived
as data with no CSS reflection.
Extend `appendTypographyDeclarations` so each named style emits:
- `--style-{name}-font-family` → `var(--font-family-{family})`
- `--style-{name}-font-size` → `var(--font-size-{size})`
- `--style-{name}-line-height` → literal or `var(--font-line-height-{size})`
- `--style-{name}-letter-spacing` → literal or `var(--font-letter-spacing-{size})`
- `--style-{name}-font-weight` → `var(--font-weight-{weight})` (or numeric)
- `--style-{name}-color` → `var(--color-{dot.path → dash-path})`
Responsive sizes (e.g. `hero.size = { base: 'xxl', md: 'xxxl' }`)
emit the base value into `:root` and per-breakpoint
`@media (min-width: …)` blocks that override the same variables.
Breakpoint thresholds match `$libs/dom/responsive` (sm: 480, md: 768,
lg: 1024, xl: 1280, xxl: 1536).
`renderStaticCss` now iterates `STYLE_BREAKPOINT_ORDER` after the
main `:root` block and appends one media-query block per breakpoint
that has at least one responsive override. The `indentBlock` helper
preserves indentation inside the wrapper.
`base.css` regenerated. svelte-check 0 errors, `npm run component:audit`
81 / 81 PASS unchanged. This is foundation-only; no components consume
the new tokens yet — that lands in the typography port (Text + Heading
+ Display + Code + CodeBlock + Kbd + Mark + Highlight + Link).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
5 months ago
|
|
|
|
|
|
|
|
|
|
// Responsive typography style overrides — one media-query block per
|
|
|
|
|
|
// breakpoint that has at least one responsive size override.
|
|
|
|
|
|
const styleResponsive = collectTypographyStyleResponsive(primitives.typography)
|
|
|
|
|
|
for (const bp of STYLE_BREAKPOINT_ORDER) {
|
|
|
|
|
|
const lines = styleResponsive[bp]
|
|
|
|
|
|
if (!lines || lines.length === 0) continue
|
|
|
|
|
|
const width = 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())
|
|
|
|
|
|
|
feat(motion): motion engine as arts/motion service + F6 tokens + F7 typegen
Relocate the motion runtime out of eidos into a runtime art (src/arts/motion,
$motion), exposed as uix.motion and consumed by BOTH soma (Presence.motion ->
motion.run) and eidos (delegates + registers presets) -- dissolving the
soma->eidos coupling. Remove DialogProps.runMotion / eidos.motionRunner; the
bridge is now EngineMotion.run (reads data-animation-style). Delete the 4
relocated dead files (lib/motion/{types,runtime,runner,presets/js}.ts); the
preset DATA (presets/css.ts) stays in eidos. Regenerate generated/base.css.
F6 - token rigor (Carbon): tokenize the raw firma durations (slower/deliberate/
emphatic/sustained holds, escalating by announce intent severity), add
--motion-distance-xl (30px shared-axis), --motion-scale-through (0.92), the
emphasized easing, and productive/expressive sets ([data-motion-set=expressive]).
F7 - extensibility + typegen: app-extensible, type-safe preset-name registry
(EidosMotionPresets, mirroring SemaChannelSignatures); MotionPresetName =
keyof EidosMotionPresets | none | (string & {}).
Also sweeps other in-progress working-tree edits (web/routes/temas/grafito).
Verify: npm run check -> 1 pre-existing error (grafito), 0 new; motion 22/22,
active-uix 25/25, Presence 2/2, Dialog 3/3. Pre-existing words-track failures
unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
4 months ago
|
|
|
|
// Expressive motion set (Carbon's productive/expressive split): a scoped
|
|
|
|
|
|
// override of the easing / duration tokens under `[data-motion-set='expressive']`.
|
|
|
|
|
|
// Productive (the default) lives in `:root`; only the deltas are emitted here.
|
|
|
|
|
|
const expressive = primitives.motion?.expressive
|
|
|
|
|
|
if (expressive) {
|
|
|
|
|
|
const exDeclarations: string[] = []
|
|
|
|
|
|
for (const [key, value] of Object.entries(expressive.ease ?? {})) {
|
|
|
|
|
|
if (value) exDeclarations.push(cssVar(`ease-${key}`, value))
|
|
|
|
|
|
}
|
|
|
|
|
|
for (const [key, value] of Object.entries(expressive.duration ?? {})) {
|
|
|
|
|
|
if (value) exDeclarations.push(cssVar(`duration-${key}`, value))
|
|
|
|
|
|
}
|
|
|
|
|
|
if (exDeclarations.length) {
|
|
|
|
|
|
blocks.push(renderBlock("[data-motion-set='expressive']", exDeclarations))
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (options.motion) blocks.push(renderMotionBlocks(options.motion))
|
|
|
|
|
|
|
|
|
|
|
|
blocks.push(renderForcedColorsBlock())
|
|
|
|
|
|
blocks.push(renderPrefersContrastBlock())
|
|
|
|
|
|
|
|
|
|
|
|
return blocks.join('\n\n')
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Accessibility — Windows High Contrast / `forced-colors`. The OS forces colors via
|
|
|
|
|
|
* `forced-color-adjust: auto`, which auto-maps borders / text / backgrounds to system
|
|
|
|
|
|
* colors BUT drops `box-shadow` entirely — so the box-shadow focus ring (`--focus-ring`)
|
|
|
|
|
|
* vanishes. Guarantee a visible keyboard-focus indicator with a system-colored
|
|
|
|
|
|
* `outline` (which forced-colors preserve). Components that already focus via `outline`
|
|
|
|
|
|
* (e.g. Button) keep theirs by higher specificity; this is the fallback for the
|
|
|
|
|
|
* box-shadow ones. Emitted always — the `@media` gate makes it inert otherwise.
|
|
|
|
|
|
*/
|
|
|
|
|
|
function renderForcedColorsBlock(): string {
|
|
|
|
|
|
return [
|
|
|
|
|
|
'@media (forced-colors: active) {',
|
|
|
|
|
|
'\t:focus-visible {',
|
|
|
|
|
|
'\t\toutline: 2px solid Highlight;',
|
|
|
|
|
|
'\t\toutline-offset: 2px;',
|
|
|
|
|
|
'\t}',
|
|
|
|
|
|
'}'
|
|
|
|
|
|
].join('\n')
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Accessibility — `prefers-contrast: more` (macOS "Increase contrast", Windows, etc.).
|
|
|
|
|
|
* Strengthen the neutral CHROME for users who ask for more contrast: darker/stronger
|
|
|
|
|
|
* borders + higher-contrast de-emphasized text. Solid fills + primary text are already
|
|
|
|
|
|
* high-contrast, so they stay. Strictly additive (only active under the media query)
|
|
|
|
|
|
* and strictly STRONGER (never weaker), so it can't regress the default look.
|
|
|
|
|
|
*
|
|
|
|
|
|
* `:root:root` (specificity 0,2,0) so these win over the theme's `:root` (0,1,0)
|
|
|
|
|
|
* regardless of stylesheet order. Values reference the theme's `--primitive-neutral-*`,
|
|
|
|
|
|
* which resolve from the cascade; if a theme omits them the declaration is simply
|
|
|
|
|
|
* ignored (graceful — keeps the theme's own chrome).
|
|
|
|
|
|
*/
|
|
|
|
|
|
function renderPrefersContrastBlock(): string {
|
|
|
|
|
|
return [
|
|
|
|
|
|
'@media (prefers-contrast: more) {',
|
|
|
|
|
|
'\t:root:root {',
|
|
|
|
|
|
'\t\t--color-border-subtle: var(--primitive-neutral-7);',
|
|
|
|
|
|
'\t\t--color-border-default: var(--primitive-neutral-8);',
|
|
|
|
|
|
'\t\t--color-border-strong: var(--primitive-neutral-9);',
|
|
|
|
|
|
'\t\t--color-content-secondary: var(--primitive-neutral-12);',
|
|
|
|
|
|
'\t\t--color-content-muted: var(--primitive-neutral-11);',
|
|
|
|
|
|
'\t}',
|
|
|
|
|
|
'}'
|
|
|
|
|
|
].join('\n')
|
|
|
|
|
|
}
|
|
|
|
|
|
|
feat(eidos foundation): emit --style-{name}-* tokens for named typography styles
`STATIC_TYPOGRAPHY.styles` already defined 11 named typography styles
(hero, h1..h6, body, prose, label, caption, code) but the renderer
only emitted families / sizes / weights — the style entries lived
as data with no CSS reflection.
Extend `appendTypographyDeclarations` so each named style emits:
- `--style-{name}-font-family` → `var(--font-family-{family})`
- `--style-{name}-font-size` → `var(--font-size-{size})`
- `--style-{name}-line-height` → literal or `var(--font-line-height-{size})`
- `--style-{name}-letter-spacing` → literal or `var(--font-letter-spacing-{size})`
- `--style-{name}-font-weight` → `var(--font-weight-{weight})` (or numeric)
- `--style-{name}-color` → `var(--color-{dot.path → dash-path})`
Responsive sizes (e.g. `hero.size = { base: 'xxl', md: 'xxxl' }`)
emit the base value into `:root` and per-breakpoint
`@media (min-width: …)` blocks that override the same variables.
Breakpoint thresholds match `$libs/dom/responsive` (sm: 480, md: 768,
lg: 1024, xl: 1280, xxl: 1536).
`renderStaticCss` now iterates `STYLE_BREAKPOINT_ORDER` after the
main `:root` block and appends one media-query block per breakpoint
that has at least one responsive override. The `indentBlock` helper
preserves indentation inside the wrapper.
`base.css` regenerated. svelte-check 0 errors, `npm run component:audit`
81 / 81 PASS unchanged. This is foundation-only; no components consume
the new tokens yet — that lands in the typography port (Text + Heading
+ Display + Code + CodeBlock + Kbd + Mark + Highlight + Link).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
5 months ago
|
|
|
|
function indentBlock(block: string): string {
|
|
|
|
|
|
return block
|
|
|
|
|
|
.split('\n')
|
|
|
|
|
|
.map((line) => (line ? `\t${line}` : line))
|
|
|
|
|
|
.join('\n')
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function renderContractCss(
|
|
|
|
|
|
options: EidosConfig,
|
|
|
|
|
|
renderOptions: RenderContractCssOptions = {}
|
|
|
|
|
|
): string {
|
|
|
|
|
|
const contract = createEidosCssContract(options)
|
|
|
|
|
|
const staticDeclarations = contract.static.map((token) => emptyCssVar(token.name))
|
|
|
|
|
|
const themeDeclarations = contract.theme.map((token) => emptyCssVar(token.name))
|
|
|
|
|
|
|
|
|
|
|
|
return [
|
|
|
|
|
|
renderBlock(renderOptions.staticSelector ?? ':root', staticDeclarations),
|
|
|
|
|
|
renderBlock(renderOptions.themeSelector ?? '[data-theme]', themeDeclarations)
|
|
|
|
|
|
].join('\n\n')
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function renderCssVariables(
|
|
|
|
|
|
variables: EidosCssVariableMap,
|
|
|
|
|
|
renderOptions: RenderCssVariablesOptions = {}
|
|
|
|
|
|
): string {
|
|
|
|
|
|
const declarations: string[] = []
|
|
|
|
|
|
const strict = renderOptions.strict ?? renderOptions.contract !== undefined
|
|
|
|
|
|
const contractNames = renderOptions.contract
|
|
|
|
|
|
? getContractVariableNames(renderOptions.contract)
|
|
|
|
|
|
: undefined
|
|
|
|
|
|
const unknownNames: string[] = []
|
|
|
|
|
|
|
|
|
|
|
|
for (const [rawName, value] of Object.entries(variables)) {
|
|
|
|
|
|
if (value === null || value === undefined) continue
|
|
|
|
|
|
|
|
|
|
|
|
const name = normalizeCssVariableName(rawName)
|
|
|
|
|
|
if (!name) {
|
|
|
|
|
|
unknownNames.push(rawName)
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (contractNames && !contractNames.has(name)) {
|
|
|
|
|
|
unknownNames.push(`--${name}`)
|
|
|
|
|
|
if (strict) continue
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
declarations.push(cssVar(name, assertCssVariableValue(`--${name}`, value)))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (unknownNames.length && strict) {
|
|
|
|
|
|
throw new EidosCssVariableError(unknownNames)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return declarations.length ? renderBlock(renderOptions.selector ?? ':root', declarations) : ''
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function renderThemeCss(
|
|
|
|
|
|
options: EidosConfig,
|
|
|
|
|
|
themeId: string,
|
|
|
|
|
|
renderOptions: RenderThemeCssOptions = {}
|
|
|
|
|
|
): string {
|
|
|
|
|
|
const theme = options.themes?.[themeId]
|
|
|
|
|
|
if (!theme) {
|
|
|
|
|
|
throw new EidosThemeNotFoundError(themeId)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const color = mergeThemeColor(
|
|
|
|
|
|
options.primitives.color?.scales,
|
|
|
|
|
|
options.semantics.color,
|
|
|
|
|
|
theme.color
|
|
|
|
|
|
)
|
|
|
|
|
|
const declarations: string[] = []
|
|
|
|
|
|
|
|
|
|
|
|
const allScaleNames = new Set([...Object.keys(color.scales), ...Object.keys(color.alphaScales)])
|
|
|
|
|
|
|
|
|
|
|
|
// Intents omitted from the theme's role map auto-derive from the palette by
|
|
|
|
|
|
// the book's canonical convention (CANONICAL_INTENT_SCALES). Identity is the
|
|
|
|
|
|
// solid (step 9); slots derive normally below. (color grammar)
|
|
|
|
|
|
const completedRoles = completeColorRoleMap(color.roles)
|
|
|
|
|
|
|
|
|
|
|
|
// Foundation slimming: emit only the donor scales the roles actually
|
|
|
|
|
|
// reference (the role loop below binds `--primitive-{role}-*` to
|
|
|
|
|
|
// `var(--scale-{name}-*)`). The full 31-scale palette ships opt-in via
|
|
|
|
|
|
// `renderColorPaletteCss` → `generated/palette.css`. Runtime theming writes
|
|
|
|
|
|
// RESOLVED values from JS scale data, so the unemitted scales are never
|
|
|
|
|
|
// needed at the foundation.
|
|
|
|
|
|
const scaleNames =
|
|
|
|
|
|
renderOptions.scales === 'all'
|
|
|
|
|
|
? allScaleNames
|
|
|
|
|
|
: roleReferencedScaleNames(completedRoles, allScaleNames)
|
|
|
|
|
|
|
|
|
|
|
|
for (const scaleName of scaleNames) {
|
|
|
|
|
|
const scale = color.scales[scaleName]
|
|
|
|
|
|
if (scale) appendColorScaleDeclarations(declarations, scaleName, scale)
|
|
|
|
|
|
appendColorAlphaScaleDeclarations(declarations, scaleName, scale, color.alphaScales[scaleName])
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
for (const role of COLOR_ROLES) {
|
Theming: Radix-parity palette + intent auto-derivation + color-model docs
Two-level color model settled (THEMING.md section 25), replacing the anchor RFC:
the palette is the source (scales, directly usable, designable); hierarchy roles
alias scales explicitly; intents auto-derive from the palette by the book's
canonical convention.
- Palette library expanded 12 -> 31 scales at Radix Colors parity (exact values):
radix-scales.ts (19 added: mauve/sage/olive/sand/tomato/ruby/crimson/plum/
violet/iris/indigo/jade/grass/brown/sky/mint/lime/gold/bronze) spread into
base.ts. Each directly usable as --scale-{name}-{step}.
- Intent auto-derivation: CANONICAL_INTENT_SCALES (neutral->gray, affirm->teal,
fulfill->green, risk->amber, threat->red, loss->plum) + completeColorRoleMap.
Intents omitted from a theme role map fill from the convention (identity =
step 9); slots derive normally; override optional. ColorRoleMap: hierarchy
required, intents optional.
- Validation: hierarchy roles required; omitted intents validate the canonical
scale exists in the palette.
- index: export ScalingKey / SCALING_KEYS.
- docs: THEMING.md section 25 (full color model + decisions), section 21 marked
resolved, COLOR_MODEL_RFC resolved (anchor rejected).
- test: base library asserts 31 scales x 12 steps.
Verified: npm run check (0 new errors), vitest eidos (0 new regressions),
browser (intents auto-derive: affirm=teal #0E9384, risk=amber #DC6803,
loss=plum #7A3AAD at step 9).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
4 months ago
|
|
|
|
const definition = completedRoles[role]
|
|
|
|
|
|
const scaleName = getColorRoleScaleName(definition)
|
|
|
|
|
|
|
|
|
|
|
|
for (const step of COLOR_SCALE_STEPS) {
|
|
|
|
|
|
declarations.push(cssVar(`primitive-${role}-${step}`, `var(--scale-${scaleName}-${step})`))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
for (const step of COLOR_ALPHA_STEPS) {
|
|
|
|
|
|
declarations.push(cssVar(`primitive-${role}-a${step}`, `var(--scale-${scaleName}-a${step})`))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const slots = {
|
|
|
|
|
|
...DEFAULT_COLOR_ROLE_SLOT_STEPS,
|
|
|
|
|
|
...(typeof definition === 'string' ? undefined : definition.slots)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
Theming: on-solid contrast by luminance (P2-2) + translucent role surfaces (P2-4)
Two audit P2 quality defects, fixed at engine level.
P2-2 - on-solid text illegible on light solids:
The `contrast` slot defaulted to `--color-content-on-solid` (white) for every
role. On light solids (amber/yellow, risk=orange ~2.3:1) white is sub-AA. The
engine now picks by WCAG contrast (gamma-linearized) of the role step-9: when
white fails (<3:1) it uses `--color-content-on-solid-contrast` (a dark, new
OPTIONAL `content.onSolidContrast` semantic, #1c1917 in base). Only risk flips
to dark in base (5.89:1); purple/red/teal/green keep white (convention, >=3:1).
An explicit `slots.contrast` override is still honored verbatim.
P2-4 - opaque tinted soft surfaces:
The soft variant tint (Button + Badge `{role}-soft-bg`) was opaque (step-1 track
+ opaque color-mix hover) so it did not composite over non-uniform backgrounds.
New derived tokens `--color-{role}-surface` (= a2) + `--color-{role}-surface-hover`
(= a3) are translucent by construction (compositing-inverse alpha). Button/Badge
soft consume them. Toast/Tabs untouched - they are cards, opacity is correct.
- config-types: optional onSolidContrast + CONTENT_COLOR_OPTIONAL_KEYS.
- config: content keySet allows the optional key; validator value-checks it.
- render-css: wcagContrastRatio/wcagRelativeLuminance; luminance pick; emit
on-solid-contrast + surface/surface-hover.
- contract: on-solid-contrast + surface tokens per role.
- themes/base: onSolidContrast #1c1917 (light + dark).
- recipes/base: Button + Badge soft-bg -> surface, soft-bg-hover -> surface-hover.
- docs: THEMING.md section 24 + section 22 item 2; audit P2-2/P2-4 resolved.
Verified: npm run check (0 new errors), vitest eidos (0 new regressions),
browser runtime (risk 5.89:1 dark text, surfaces translucent rgba).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
4 months ago
|
|
|
|
// The default `contrast` slot is the color drawn ON the solid fill
|
|
|
|
|
|
// (button / badge / banner text). The contrasting color over a saturated
|
|
|
|
|
|
// step-9 fill is the theme's `onSolid` (white) — but on a LIGHT solid
|
|
|
|
|
|
// (amber / yellow / orange, step-9 luminance high) white is illegible
|
|
|
|
|
|
// (~2:1). When `onSolid` fails the APCA floor (WCAG cross-checked), the
|
Theming: on-solid contrast by luminance (P2-2) + translucent role surfaces (P2-4)
Two audit P2 quality defects, fixed at engine level.
P2-2 - on-solid text illegible on light solids:
The `contrast` slot defaulted to `--color-content-on-solid` (white) for every
role. On light solids (amber/yellow, risk=orange ~2.3:1) white is sub-AA. The
engine now picks by WCAG contrast (gamma-linearized) of the role step-9: when
white fails (<3:1) it uses `--color-content-on-solid-contrast` (a dark, new
OPTIONAL `content.onSolidContrast` semantic, #1c1917 in base). Only risk flips
to dark in base (5.89:1); purple/red/teal/green keep white (convention, >=3:1).
An explicit `slots.contrast` override is still honored verbatim.
P2-4 - opaque tinted soft surfaces:
The soft variant tint (Button + Badge `{role}-soft-bg`) was opaque (step-1 track
+ opaque color-mix hover) so it did not composite over non-uniform backgrounds.
New derived tokens `--color-{role}-surface` (= a2) + `--color-{role}-surface-hover`
(= a3) are translucent by construction (compositing-inverse alpha). Button/Badge
soft consume them. Toast/Tabs untouched - they are cards, opacity is correct.
- config-types: optional onSolidContrast + CONTENT_COLOR_OPTIONAL_KEYS.
- config: content keySet allows the optional key; validator value-checks it.
- render-css: wcagContrastRatio/wcagRelativeLuminance; luminance pick; emit
on-solid-contrast + surface/surface-hover.
- contract: on-solid-contrast + surface tokens per role.
- themes/base: onSolidContrast #1c1917 (light + dark).
- recipes/base: Button + Badge soft-bg -> surface, soft-bg-hover -> surface-hover.
- docs: THEMING.md section 24 + section 22 item 2; audit P2-2/P2-4 resolved.
Verified: npm run check (0 new errors), vitest eidos (0 new regressions),
browser runtime (risk 5.89:1 dark text, surfaces translucent rgba).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
4 months ago
|
|
|
|
// engine swaps to `onSolidContrast` (dark). Step-12 stays as the var
|
|
|
|
|
|
// fallback. An explicit per-role `slots.contrast` override is honored
|
|
|
|
|
|
// verbatim, so roles that intentionally invert keep working. (audit P2-2)
|
|
|
|
|
|
const solidColor = color.scales[scaleName]?.['9']
|
|
|
|
|
|
const parsedSolid = solidColor ? safeParseColor(solidColor) : null
|
|
|
|
|
|
const parsedOnSolid = color.content?.onSolid ? safeParseColor(color.content.onSolid) : null
|
|
|
|
|
|
let onSolidFailsContrast = false
|
|
|
|
|
|
if (parsedSolid && parsedOnSolid) {
|
|
|
|
|
|
const solidRgb = oklchToGammaRgb(parsedSolid)
|
|
|
|
|
|
const onSolidRgb = oklchToGammaRgb(parsedOnSolid)
|
|
|
|
|
|
// APCA decides; WCAG 2 is a conservative cross-check — white must clear BOTH.
|
|
|
|
|
|
onSolidFailsContrast =
|
|
|
|
|
|
Math.abs(apcaLc(onSolidRgb, solidRgb)) < ON_SOLID_APCA_FLOOR ||
|
|
|
|
|
|
wcagContrastRatio(onSolidRgb, solidRgb) < ON_SOLID_WCAG_FLOOR
|
|
|
|
|
|
}
|
Theming: on-solid contrast by luminance (P2-2) + translucent role surfaces (P2-4)
Two audit P2 quality defects, fixed at engine level.
P2-2 - on-solid text illegible on light solids:
The `contrast` slot defaulted to `--color-content-on-solid` (white) for every
role. On light solids (amber/yellow, risk=orange ~2.3:1) white is sub-AA. The
engine now picks by WCAG contrast (gamma-linearized) of the role step-9: when
white fails (<3:1) it uses `--color-content-on-solid-contrast` (a dark, new
OPTIONAL `content.onSolidContrast` semantic, #1c1917 in base). Only risk flips
to dark in base (5.89:1); purple/red/teal/green keep white (convention, >=3:1).
An explicit `slots.contrast` override is still honored verbatim.
P2-4 - opaque tinted soft surfaces:
The soft variant tint (Button + Badge `{role}-soft-bg`) was opaque (step-1 track
+ opaque color-mix hover) so it did not composite over non-uniform backgrounds.
New derived tokens `--color-{role}-surface` (= a2) + `--color-{role}-surface-hover`
(= a3) are translucent by construction (compositing-inverse alpha). Button/Badge
soft consume them. Toast/Tabs untouched - they are cards, opacity is correct.
- config-types: optional onSolidContrast + CONTENT_COLOR_OPTIONAL_KEYS.
- config: content keySet allows the optional key; validator value-checks it.
- render-css: wcagContrastRatio/wcagRelativeLuminance; luminance pick; emit
on-solid-contrast + surface/surface-hover.
- contract: on-solid-contrast + surface tokens per role.
- themes/base: onSolidContrast #1c1917 (light + dark).
- recipes/base: Button + Badge soft-bg -> surface, soft-bg-hover -> surface-hover.
- docs: THEMING.md section 24 + section 22 item 2; audit P2-2/P2-4 resolved.
Verified: npm run check (0 new errors), vitest eidos (0 new regressions),
browser runtime (risk 5.89:1 dark text, surfaces translucent rgba).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
4 months ago
|
|
|
|
const defaultContrastToken = onSolidFailsContrast
|
|
|
|
|
|
? 'color-content-on-solid-contrast'
|
|
|
|
|
|
: 'color-content-on-solid'
|
|
|
|
|
|
|
|
|
|
|
|
for (const [slot, step] of Object.entries(slots)) {
|
|
|
|
|
|
const isDefaultContrast =
|
|
|
|
|
|
slot === 'contrast' &&
|
|
|
|
|
|
(typeof definition === 'string' || definition.slots?.contrast === undefined)
|
|
|
|
|
|
const value = isDefaultContrast
|
Theming: on-solid contrast by luminance (P2-2) + translucent role surfaces (P2-4)
Two audit P2 quality defects, fixed at engine level.
P2-2 - on-solid text illegible on light solids:
The `contrast` slot defaulted to `--color-content-on-solid` (white) for every
role. On light solids (amber/yellow, risk=orange ~2.3:1) white is sub-AA. The
engine now picks by WCAG contrast (gamma-linearized) of the role step-9: when
white fails (<3:1) it uses `--color-content-on-solid-contrast` (a dark, new
OPTIONAL `content.onSolidContrast` semantic, #1c1917 in base). Only risk flips
to dark in base (5.89:1); purple/red/teal/green keep white (convention, >=3:1).
An explicit `slots.contrast` override is still honored verbatim.
P2-4 - opaque tinted soft surfaces:
The soft variant tint (Button + Badge `{role}-soft-bg`) was opaque (step-1 track
+ opaque color-mix hover) so it did not composite over non-uniform backgrounds.
New derived tokens `--color-{role}-surface` (= a2) + `--color-{role}-surface-hover`
(= a3) are translucent by construction (compositing-inverse alpha). Button/Badge
soft consume them. Toast/Tabs untouched - they are cards, opacity is correct.
- config-types: optional onSolidContrast + CONTENT_COLOR_OPTIONAL_KEYS.
- config: content keySet allows the optional key; validator value-checks it.
- render-css: wcagContrastRatio/wcagRelativeLuminance; luminance pick; emit
on-solid-contrast + surface/surface-hover.
- contract: on-solid-contrast + surface tokens per role.
- themes/base: onSolidContrast #1c1917 (light + dark).
- recipes/base: Button + Badge soft-bg -> surface, soft-bg-hover -> surface-hover.
- docs: THEMING.md section 24 + section 22 item 2; audit P2-2/P2-4 resolved.
Verified: npm run check (0 new errors), vitest eidos (0 new regressions),
browser runtime (risk 5.89:1 dark text, surfaces translucent rgba).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
4 months ago
|
|
|
|
? `var(--${defaultContrastToken}, var(--primitive-${role}-${step}))`
|
|
|
|
|
|
: `var(--primitive-${role}-${step})`
|
|
|
|
|
|
declarations.push(cssVar(`color-${role}-${toKebab(slot)}`, value))
|
|
|
|
|
|
}
|
Theming: on-solid contrast by luminance (P2-2) + translucent role surfaces (P2-4)
Two audit P2 quality defects, fixed at engine level.
P2-2 - on-solid text illegible on light solids:
The `contrast` slot defaulted to `--color-content-on-solid` (white) for every
role. On light solids (amber/yellow, risk=orange ~2.3:1) white is sub-AA. The
engine now picks by WCAG contrast (gamma-linearized) of the role step-9: when
white fails (<3:1) it uses `--color-content-on-solid-contrast` (a dark, new
OPTIONAL `content.onSolidContrast` semantic, #1c1917 in base). Only risk flips
to dark in base (5.89:1); purple/red/teal/green keep white (convention, >=3:1).
An explicit `slots.contrast` override is still honored verbatim.
P2-4 - opaque tinted soft surfaces:
The soft variant tint (Button + Badge `{role}-soft-bg`) was opaque (step-1 track
+ opaque color-mix hover) so it did not composite over non-uniform backgrounds.
New derived tokens `--color-{role}-surface` (= a2) + `--color-{role}-surface-hover`
(= a3) are translucent by construction (compositing-inverse alpha). Button/Badge
soft consume them. Toast/Tabs untouched - they are cards, opacity is correct.
- config-types: optional onSolidContrast + CONTENT_COLOR_OPTIONAL_KEYS.
- config: content keySet allows the optional key; validator value-checks it.
- render-css: wcagContrastRatio/wcagRelativeLuminance; luminance pick; emit
on-solid-contrast + surface/surface-hover.
- contract: on-solid-contrast + surface tokens per role.
- themes/base: onSolidContrast #1c1917 (light + dark).
- recipes/base: Button + Badge soft-bg -> surface, soft-bg-hover -> surface-hover.
- docs: THEMING.md section 24 + section 22 item 2; audit P2-2/P2-4 resolved.
Verified: npm run check (0 new errors), vitest eidos (0 new regressions),
browser runtime (risk 5.89:1 dark text, surfaces translucent rgba).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
4 months ago
|
|
|
|
|
|
|
|
|
|
// Translucent role surface (P2-4): the soft / surface variant tint.
|
|
|
|
|
|
// Built from the compositing-inverse alpha steps so it composites
|
|
|
|
|
|
// correctly over non-uniform backgrounds (striped rows, images,
|
|
|
|
|
|
// gradients) — unlike an opaque step-1 / color-mix tint.
|
|
|
|
|
|
declarations.push(cssVar(`color-${role}-surface`, `var(--primitive-${role}-a2)`))
|
|
|
|
|
|
declarations.push(cssVar(`color-${role}-surface-hover`, `var(--primitive-${role}-a3)`))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
appendThemeColorDeclarations(declarations, color)
|
|
|
|
|
|
|
|
|
|
|
|
if (theme.shadow) {
|
|
|
|
|
|
appendRecordDeclarations(declarations, 'shadow', theme.shadow)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return renderBlock(renderOptions.selector ?? getThemeSelector(themeId), declarations)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** The donor scales a theme's roles reference (incl. canonically-derived
|
|
|
|
|
|
* intents). The foundation emits only these; the full palette ships opt-in. */
|
|
|
|
|
|
function roleReferencedScaleNames(
|
|
|
|
|
|
completedRoles: ReturnType<typeof completeColorRoleMap>,
|
|
|
|
|
|
available: ReadonlySet<string>
|
|
|
|
|
|
): Set<string> {
|
|
|
|
|
|
const used = new Set<string>()
|
|
|
|
|
|
for (const role of COLOR_ROLES) {
|
|
|
|
|
|
const definition = completedRoles[role]
|
|
|
|
|
|
if (definition === undefined) continue
|
|
|
|
|
|
const name = getColorRoleScaleName(definition)
|
|
|
|
|
|
if (available.has(name)) used.add(name)
|
|
|
|
|
|
}
|
|
|
|
|
|
return used
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Render ONLY the full `--scale-*` donor palette (all scales) for a theme,
|
|
|
|
|
|
* wrapped in the theme selector. Opt-in companion to the slim foundation:
|
|
|
|
|
|
* `renderThemeCss` emits only role-referenced scales, while apps/tools that
|
|
|
|
|
|
* reference raw `--scale-{name}-{step}` tokens import the palette generated
|
|
|
|
|
|
* from this (see `generated/palette.css`). The framework itself never needs
|
|
|
|
|
|
* it — components consume `--color-{role}-*`; runtime theming uses JS scale
|
|
|
|
|
|
* data and writes resolved values.
|
|
|
|
|
|
*/
|
|
|
|
|
|
export function renderColorPaletteCss(
|
|
|
|
|
|
options: EidosConfig,
|
|
|
|
|
|
themeId: string,
|
|
|
|
|
|
renderOptions: RenderThemeCssOptions = {}
|
|
|
|
|
|
): string {
|
|
|
|
|
|
const theme = options.themes?.[themeId]
|
|
|
|
|
|
if (!theme) {
|
|
|
|
|
|
throw new EidosThemeNotFoundError(themeId)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const color = mergeThemeColor(
|
|
|
|
|
|
options.primitives.color?.scales,
|
|
|
|
|
|
options.semantics.color,
|
|
|
|
|
|
theme.color
|
|
|
|
|
|
)
|
|
|
|
|
|
const declarations: string[] = []
|
|
|
|
|
|
const scaleNames = new Set([...Object.keys(color.scales), ...Object.keys(color.alphaScales)])
|
|
|
|
|
|
for (const scaleName of scaleNames) {
|
|
|
|
|
|
const scale = color.scales[scaleName]
|
|
|
|
|
|
if (scale) appendColorScaleDeclarations(declarations, scaleName, scale)
|
|
|
|
|
|
appendColorAlphaScaleDeclarations(declarations, scaleName, scale, color.alphaScales[scaleName])
|
|
|
|
|
|
}
|
|
|
|
|
|
return renderBlock(renderOptions.selector ?? getThemeSelector(themeId), declarations)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export { EidosCssVariableError } from '../errors'
|
|
|
|
|
|
|
|
|
|
|
|
function appendBorderDeclarations(declarations: string[], border: BorderPrimitiveSet): void {
|
|
|
|
|
|
appendRecordDeclarations(declarations, 'border-width', border.width)
|
|
|
|
|
|
appendRecordDeclarations(declarations, 'border-style', border.style)
|
|
|
|
|
|
declarations.push(cssVar('border-width', `var(--border-width-${border.defaultWidth})`))
|
|
|
|
|
|
declarations.push(cssVar('border-style', `var(--border-style-${border.defaultStyle})`))
|
|
|
|
|
|
declarations.push(
|
|
|
|
|
|
cssVar('border', 'var(--border-width) var(--border-style) var(--color-border-default)')
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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')
|
|
|
|
|
|
}
|
|
|
|
|
|
|
feat(motion): motion engine as arts/motion service + F6 tokens + F7 typegen
Relocate the motion runtime out of eidos into a runtime art (src/arts/motion,
$motion), exposed as uix.motion and consumed by BOTH soma (Presence.motion ->
motion.run) and eidos (delegates + registers presets) -- dissolving the
soma->eidos coupling. Remove DialogProps.runMotion / eidos.motionRunner; the
bridge is now EngineMotion.run (reads data-animation-style). Delete the 4
relocated dead files (lib/motion/{types,runtime,runner,presets/js}.ts); the
preset DATA (presets/css.ts) stays in eidos. Regenerate generated/base.css.
F6 - token rigor (Carbon): tokenize the raw firma durations (slower/deliberate/
emphatic/sustained holds, escalating by announce intent severity), add
--motion-distance-xl (30px shared-axis), --motion-scale-through (0.92), the
emphasized easing, and productive/expressive sets ([data-motion-set=expressive]).
F7 - extensibility + typegen: app-extensible, type-safe preset-name registry
(EidosMotionPresets, mirroring SemaChannelSignatures); MotionPresetName =
keyof EidosMotionPresets | none | (string & {}).
Also sweeps other in-progress working-tree edits (web/routes/temas/grafito).
Verify: npm run check -> 1 pre-existing error (grafito), 0 new; motion 22/22,
active-uix 25/25, Presence 2/2, Dialog 3/3. Pre-existing words-track failures
unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
4 months ago
|
|
|
|
// ── Motion ─────────────────────────────────────────────────────────────────
|
|
|
|
|
|
// Emits the registered `@keyframes` + per-preset rules selected by
|
|
|
|
|
|
// `data-animation-style` × `data-state` (enter→open / exit→closed), with
|
|
|
|
|
|
// `data-side`-aware variants. Only `css`-driver presets generate CSS; JS
|
|
|
|
|
|
// presets run through the motion engine (`uix.motion`). Reduced motion is honored per preset
|
|
|
|
|
|
// policy. Enter uses `fill: backwards` (no initial flash, no forwards
|
|
|
|
|
|
// accumulation); exit uses `fill: forwards` (hold the end state until soma's
|
|
|
|
|
|
// Presence unmounts the element).
|
|
|
|
|
|
const MOTION_PHASES = [
|
|
|
|
|
|
{ phase: 'enter', state: 'open', fill: 'backwards', dur: 'moderate', ease: 'out' },
|
|
|
|
|
|
{ phase: 'exit', state: 'closed', fill: 'forwards', dur: 'fast', ease: 'in' }
|
|
|
|
|
|
] as const
|
|
|
|
|
|
|
|
|
|
|
|
function renderMotionBlocks(motion: MotionConfig): string {
|
|
|
|
|
|
const blocks: string[] = []
|
|
|
|
|
|
|
|
|
|
|
|
// Per-component duration + easing overrides. A preset owns the SHAPE + a
|
|
|
|
|
|
// default timing/curve; a component (or theme) tunes its own enter/exit by
|
|
|
|
|
|
// setting these on its animated element — `phaseDeclarations` reads them with
|
|
|
|
|
|
// the preset token as fallback. `inherits: false` so a preset component nested
|
|
|
|
|
|
// inside another doesn't inherit the ancestor's values.
|
|
|
|
|
|
for (const phase of ['enter', 'exit'] as const) {
|
|
|
|
|
|
blocks.push(`@property --motion-duration-${phase} {\n\tsyntax: '*';\n\tinherits: false;\n}`)
|
|
|
|
|
|
blocks.push(`@property --motion-ease-${phase} {\n\tsyntax: '*';\n\tinherits: false;\n}`)
|
|
|
|
|
|
}
|
|
|
|
|
|
// Stagger index is per-item — `inherits: false` stops it leaking into nested
|
|
|
|
|
|
// groups; the rhythm (`--motion-stagger-each`) inherits from the container.
|
|
|
|
|
|
blocks.push(
|
|
|
|
|
|
"@property --motion-stagger-index {\n\tsyntax: '<integer>';\n\tinherits: false;\n\tinitial-value: 0;\n}"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
for (const [name, stops] of Object.entries(motion.keyframes ?? {})) {
|
|
|
|
|
|
blocks.push(renderKeyframes(name, stops))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Momento --event: la firma perceptiva (genérica por family/intent/event).
|
|
|
|
|
|
for (const [name, signature] of Object.entries(motion.signatures ?? {})) {
|
|
|
|
|
|
blocks.push(...renderSignatureRules(name, signature))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Momento --state: presets de transición per-componente (data-state).
|
|
|
|
|
|
for (const [name, preset] of Object.entries(motion.presets ?? {})) {
|
|
|
|
|
|
if (preset.driver !== 'css') continue
|
|
|
|
|
|
blocks.push(...renderCssPresetRules(name, preset))
|
|
|
|
|
|
blocks.push(...renderReducedMotionRules(name, preset))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return blocks.join('\n\n')
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function renderKeyframes(name: KeyframeName, stops: KeyframeStops): string {
|
|
|
|
|
|
const body = Object.entries(stops)
|
|
|
|
|
|
.map(([stop, decls]) => {
|
|
|
|
|
|
const inner = Object.entries(decls)
|
|
|
|
|
|
.map(([prop, value]) => `\t\t${prop}: ${value};`)
|
|
|
|
|
|
.join('\n')
|
|
|
|
|
|
return `\t${stop} {\n${inner}\n\t}`
|
|
|
|
|
|
})
|
|
|
|
|
|
.join('\n')
|
|
|
|
|
|
return `@keyframes ${name} {\n${body}\n}`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function renderCssPresetRules(name: string, preset: CssStatePreset): string[] {
|
|
|
|
|
|
const rules: string[] = []
|
|
|
|
|
|
for (const { phase, state, fill, dur, ease } of MOTION_PHASES) {
|
|
|
|
|
|
const cssPhase = preset[phase]
|
|
|
|
|
|
if (!cssPhase) continue
|
|
|
|
|
|
rules.push(
|
|
|
|
|
|
renderBlock(
|
|
|
|
|
|
`[data-animation-style='${name}'][data-state='${state}']`,
|
|
|
|
|
|
phaseDeclarations(cssPhase, cssPhase.keyframes, fill, dur, ease, phase)
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
for (const [side, keyframes] of Object.entries(cssPhase.bySide ?? {})) {
|
|
|
|
|
|
rules.push(
|
|
|
|
|
|
renderBlock(
|
|
|
|
|
|
`[data-animation-style='${name}'][data-side='${side}'][data-state='${state}']`,
|
|
|
|
|
|
phaseDeclarations(cssPhase, keyframes, fill, dur, ease, phase)
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return rules
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function phaseDeclarations(
|
|
|
|
|
|
cssPhase: CssPhase,
|
|
|
|
|
|
keyframes: KeyframeName | readonly KeyframeName[],
|
|
|
|
|
|
fill: string,
|
|
|
|
|
|
defaultDuration: string,
|
|
|
|
|
|
defaultEase: string,
|
|
|
|
|
|
phase: string
|
|
|
|
|
|
): string[] {
|
|
|
|
|
|
const names = typeof keyframes === 'string' ? [keyframes] : keyframes
|
|
|
|
|
|
// Per-component overrides (`--motion-{duration,ease}-{enter,exit}`) → preset token.
|
|
|
|
|
|
const dur = `var(--motion-duration-${phase}, var(--duration-${cssPhase.duration ?? defaultDuration}))`
|
|
|
|
|
|
const ease = `var(--motion-ease-${phase}, var(--ease-${cssPhase.ease ?? defaultEase}))`
|
|
|
|
|
|
const animation = names.map((kf) => `${kf} ${dur} ${ease} ${fill}`).join(', ')
|
|
|
|
|
|
const declarations = [`animation: ${animation};`]
|
|
|
|
|
|
// Stagger (Material list choreography, zero JS orchestration): a container
|
|
|
|
|
|
// sets `--motion-stagger-each` (rhythm) and each item `--motion-stagger-index`,
|
|
|
|
|
|
// so each item's enter is delayed by index × each. Default 0 → no stagger; the
|
|
|
|
|
|
// `backwards` fill holds the `from` state until each item's turn.
|
|
|
|
|
|
if (phase === 'enter') {
|
|
|
|
|
|
declarations.push(
|
|
|
|
|
|
'animation-delay: calc(var(--motion-stagger-index, 0) * var(--motion-stagger-each, 0ms));'
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
if (cssPhase.transformOrigin) {
|
|
|
|
|
|
declarations.push(`transform-origin: ${cssPhase.transformOrigin};`)
|
|
|
|
|
|
}
|
|
|
|
|
|
return declarations
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Reduced motion: 'none' runs as-is (a pure fade is acceptable); 'instant'
|
|
|
|
|
|
// kills the animation (snap to the steady `[data-state]` style); 'opacity-only'
|
|
|
|
|
|
// keeps a brief fade and drops transforms. Emitted both under the projected
|
|
|
|
|
|
// `[data-motion='reduce']` and the media query, so it works with or without
|
|
|
|
|
|
// the prefs DOM projection. `!important` beats the bySide variants.
|
|
|
|
|
|
function renderReducedMotionRules(name: string, preset: CssStatePreset): string[] {
|
|
|
|
|
|
const policy = preset.reduce ?? 'none'
|
|
|
|
|
|
if (policy === 'none') return []
|
|
|
|
|
|
const rules: string[] = []
|
|
|
|
|
|
for (const { phase, state } of MOTION_PHASES) {
|
|
|
|
|
|
if (!preset[phase]) continue
|
|
|
|
|
|
const inner =
|
|
|
|
|
|
policy === 'instant'
|
|
|
|
|
|
? ['animation: none !important;']
|
|
|
|
|
|
: phase === 'enter'
|
|
|
|
|
|
? ['animation: fade-in var(--duration-fast) var(--ease-out) backwards !important;']
|
|
|
|
|
|
: ['animation: fade-out var(--duration-fast) var(--ease-in) forwards !important;']
|
|
|
|
|
|
const selector = `[data-animation-style='${name}'][data-state='${state}']`
|
|
|
|
|
|
rules.push(renderBlock(`[data-motion='reduce'] ${selector}`, inner))
|
|
|
|
|
|
rules.push(
|
|
|
|
|
|
`@media (prefers-reduced-motion: reduce) {\n${indentBlock(renderBlock(selector, inner))}\n}`
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
return rules
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── Momento --event: la firma perceptiva (genérica por evento) ───────────────
|
|
|
|
|
|
// Reacciona a `data-event-*` durante el hold de sema. El selector se construye
|
|
|
|
|
|
// por `family` / `intent` / `event`; es lo que hoy hace `events.css` a mano
|
|
|
|
|
|
// (F2 migrará su contenido aquí). Es el canal `motion` de la firma (GUIA §11).
|
|
|
|
|
|
function signatureSelectorList(sig: EventSignature): string[] {
|
|
|
|
|
|
const suffix =
|
|
|
|
|
|
`${sig.family ? `[data-event-family='${sig.family}']` : ''}` +
|
|
|
|
|
|
`${sig.intent ? `[data-event-intent='${sig.intent}']` : ''}` +
|
|
|
|
|
|
`[data-event-phase='active']`
|
|
|
|
|
|
const events =
|
|
|
|
|
|
sig.event === undefined ? [undefined] : typeof sig.event === 'string' ? [sig.event] : sig.event
|
|
|
|
|
|
return events.map((e) => `${e ? `[data-event^='${e}']` : ''}${suffix}`)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Duración: un token conocido → `var(--duration-X)`; cualquier otra cosa (un
|
|
|
|
|
|
// hold crudo como `600ms`) se emite tal cual. F6 tokenizará los crudos.
|
|
|
|
|
|
function resolveDurationValue(value: string): string {
|
|
|
|
|
|
return (DURATION_KEYS as readonly string[]).includes(value) ? `var(--duration-${value})` : value
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function renderSignatureRules(_name: string, sig: EventSignature): string[] {
|
|
|
|
|
|
const names = typeof sig.keyframes === 'string' ? [sig.keyframes] : sig.keyframes
|
|
|
|
|
|
const dur = resolveDurationValue(sig.duration ?? 'moderate')
|
|
|
|
|
|
const ease = `var(--ease-${sig.ease ?? 'out'})`
|
|
|
|
|
|
const fill = sig.fill && sig.fill !== 'none' ? ` ${sig.fill}` : ''
|
|
|
|
|
|
const animation = names.map((kf) => `${kf} ${dur} ${ease}${fill}`).join(', ')
|
|
|
|
|
|
const selectors = signatureSelectorList(sig)
|
|
|
|
|
|
const rules = [renderBlock(selectors.join(',\n'), [`animation: ${animation};`])]
|
|
|
|
|
|
const policy = sig.reduce ?? 'none'
|
|
|
|
|
|
if (policy !== 'none') {
|
|
|
|
|
|
const inner =
|
|
|
|
|
|
policy === 'instant'
|
|
|
|
|
|
? ['animation: none !important;']
|
|
|
|
|
|
: ['animation: fade-in var(--duration-fast) var(--ease-out) !important;']
|
|
|
|
|
|
rules.push(
|
|
|
|
|
|
renderBlock(selectors.map((s) => `[data-motion='reduce'] ${s}`).join(',\n'), inner)
|
|
|
|
|
|
)
|
|
|
|
|
|
rules.push(
|
|
|
|
|
|
`@media (prefers-reduced-motion: reduce) {\n${indentBlock(renderBlock(selectors.join(',\n'), inner))}\n}`
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
return rules
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function appendTypographyDeclarations(
|
|
|
|
|
|
declarations: string[],
|
|
|
|
|
|
typography: TypographyPrimitiveSet
|
|
|
|
|
|
): void {
|
|
|
|
|
|
for (const [name, family] of Object.entries(typography.families)) {
|
|
|
|
|
|
declarations.push(cssVar(`font-family-${name}`, formatFontFamily(family)))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
for (const [name, metric] of Object.entries(typography.sizes)) {
|
feat(eidos): fluid typography engine (Phase 1) — Utopia clamp, rem, fluid headings
TYPOGRAPHY_ENGINE_RFC Phase 1. Additive on TypographyPrimitiveSet, behind the frozen
token contract (--font-size-X keeps its name; only the value formula changes, like
color --scale-* hex -> oklch()).
- type-scale.ts (pure, isomorphic, no canvas): the Utopia clamp() formula. fluidClamp /
resolveTypeSize / isFluidSize. rem-based (a11y: scales with browser font-zoom).
- config-types: TextMetric.size accepts `string | FluidSize` ({min,max,minVw?,maxVw?}).
Plain length strings still valid -> backward-compatible.
- render-css appendTypographyDeclarations: emits calc(resolveTypeSize(size) * --scaling)
-> a fixed rem or a fluid clamp; the --scaling axis composes on top.
- config.ts: validateSizeValue accepts a FluidSize (validates min/max/minVw/maxVw) so
the base config validates (was the cascade root — FluidSize objects failed the
string-only CSS-value check).
- typography.ts: sizes in rem; headings (lg/xl/xxl/xxxl) fluid (min @480px -> max
@1280px, max = previous fixed px so desktop is unchanged); body (md) fixed. hero/h1/h2
drop the manual { base, md } responsive sizes — the clamp covers the viewport.
- generated/base.css regenerated. type-scale.test.ts (6 tests). 2 config-test assertions
updated to the new rem/clamp values.
Verified in browser: --font-size-xxxl 40px @480 -> 80px @1280; xxl 32->48; lg 18->20;
md 16 fixed. check 0 errors; eidos suite green (3 pre-existing words failures only).
canvas-text/<SText> unaffected (reads getComputedStyle real font, measures the clamp).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
4 months ago
|
|
|
|
// font-size scales with the global `--scaling` zoom. A fluid size resolves to a
|
|
|
|
|
|
// rem-based `clamp(...)` (Utopia, viewport-fluid); a fixed size passes through.
|
|
|
|
|
|
// `--scaling` composes on top via calc(). line-height is a unitless ratio — it
|
|
|
|
|
|
// scales implicitly via font-size; scaling it here too would double-scale.
|
|
|
|
|
|
// letter-spacing stays as authored.
|
|
|
|
|
|
declarations.push(
|
|
|
|
|
|
cssVar(`font-size-${name}`, `calc(${resolveTypeSize(metric.size)} * var(--scaling))`)
|
|
|
|
|
|
)
|
|
|
|
|
|
declarations.push(cssVar(`font-line-height-${name}`, metric.lineHeight))
|
|
|
|
|
|
declarations.push(cssVar(`font-letter-spacing-${name}`, metric.letterSpacing))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
for (const [name, weight] of Object.entries(typography.weights)) {
|
|
|
|
|
|
declarations.push(cssVar(`font-weight-${name}`, weight))
|
|
|
|
|
|
}
|
feat(eidos foundation): emit --style-{name}-* tokens for named typography styles
`STATIC_TYPOGRAPHY.styles` already defined 11 named typography styles
(hero, h1..h6, body, prose, label, caption, code) but the renderer
only emitted families / sizes / weights — the style entries lived
as data with no CSS reflection.
Extend `appendTypographyDeclarations` so each named style emits:
- `--style-{name}-font-family` → `var(--font-family-{family})`
- `--style-{name}-font-size` → `var(--font-size-{size})`
- `--style-{name}-line-height` → literal or `var(--font-line-height-{size})`
- `--style-{name}-letter-spacing` → literal or `var(--font-letter-spacing-{size})`
- `--style-{name}-font-weight` → `var(--font-weight-{weight})` (or numeric)
- `--style-{name}-color` → `var(--color-{dot.path → dash-path})`
Responsive sizes (e.g. `hero.size = { base: 'xxl', md: 'xxxl' }`)
emit the base value into `:root` and per-breakpoint
`@media (min-width: …)` blocks that override the same variables.
Breakpoint thresholds match `$libs/dom/responsive` (sm: 480, md: 768,
lg: 1024, xl: 1280, xxl: 1536).
`renderStaticCss` now iterates `STYLE_BREAKPOINT_ORDER` after the
main `:root` block and appends one media-query block per breakpoint
that has at least one responsive override. The `indentBlock` helper
preserves indentation inside the wrapper.
`base.css` regenerated. svelte-check 0 errors, `npm run component:audit`
81 / 81 PASS unchanged. This is foundation-only; no components consume
the new tokens yet — that lands in the typography port (Text + Heading
+ Display + Code + CodeBlock + Kbd + Mark + Highlight + Link).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
5 months ago
|
|
|
|
|
feat(eidos): typography scales — tracking/leading/features/measure + optical tracking (Phase 3a)
TYPOGRAPHY_ENGINE_RFC Phase 3 (engine tokens). Additive scales, pure CSS, no binaries.
- config-types: TypographyPrimitiveSet += tracking / leading / features / measure
(optional Record<string,string>).
- render-css: emits --tracking-{k} / --leading-{k} / --font-feature-{k} / --measure-{k}.
- typography.ts defaults: tracking (tighter…wider), leading (none…loose), features
(tabular = "tnum"+"lnum" for data, oldstyle/smallcaps/ligatures), measure (54/66/78ch).
+ optical tracking baked into the size tokens: small text slightly looser
(xxs +0.01em), display tighter (xxxl -0.02em) — was all 0.
- config.ts: validates the 4 scales (CSS-value maps).
- generated/base.css regenerated. test: scales + optical tracking emission.
Additive tokens (recipes/components consume var(--tracking-tight) etc.) so the scales
are zero-risk; the only rendered change is the gentle optical tracking on headings/small
text. check 0 errors; eidos suite green (3 pre-existing words failures only).
Next (Phase 3b): wire the component props (wrap: balance/pretty, numeric: tabular,
tracking/leading/measure) on <Text>/<Heading>.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
4 months ago
|
|
|
|
// Phase 3 scales — tracking / leading / features / measure. Additive tokens that
|
|
|
|
|
|
// recipes + components consume (var(--tracking-tight), etc.).
|
|
|
|
|
|
for (const [name, value] of Object.entries(typography.tracking ?? {})) {
|
|
|
|
|
|
declarations.push(cssVar(`tracking-${name}`, value))
|
|
|
|
|
|
}
|
|
|
|
|
|
for (const [name, value] of Object.entries(typography.leading ?? {})) {
|
|
|
|
|
|
declarations.push(cssVar(`leading-${name}`, value))
|
|
|
|
|
|
}
|
|
|
|
|
|
for (const [name, value] of Object.entries(typography.features ?? {})) {
|
|
|
|
|
|
declarations.push(cssVar(`font-feature-${name}`, value))
|
|
|
|
|
|
}
|
|
|
|
|
|
for (const [name, value] of Object.entries(typography.measure ?? {})) {
|
|
|
|
|
|
declarations.push(cssVar(`measure-${name}`, value))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
feat(eidos foundation): emit --style-{name}-* tokens for named typography styles
`STATIC_TYPOGRAPHY.styles` already defined 11 named typography styles
(hero, h1..h6, body, prose, label, caption, code) but the renderer
only emitted families / sizes / weights — the style entries lived
as data with no CSS reflection.
Extend `appendTypographyDeclarations` so each named style emits:
- `--style-{name}-font-family` → `var(--font-family-{family})`
- `--style-{name}-font-size` → `var(--font-size-{size})`
- `--style-{name}-line-height` → literal or `var(--font-line-height-{size})`
- `--style-{name}-letter-spacing` → literal or `var(--font-letter-spacing-{size})`
- `--style-{name}-font-weight` → `var(--font-weight-{weight})` (or numeric)
- `--style-{name}-color` → `var(--color-{dot.path → dash-path})`
Responsive sizes (e.g. `hero.size = { base: 'xxl', md: 'xxxl' }`)
emit the base value into `:root` and per-breakpoint
`@media (min-width: …)` blocks that override the same variables.
Breakpoint thresholds match `$libs/dom/responsive` (sm: 480, md: 768,
lg: 1024, xl: 1280, xxl: 1536).
`renderStaticCss` now iterates `STYLE_BREAKPOINT_ORDER` after the
main `:root` block and appends one media-query block per breakpoint
that has at least one responsive override. The `indentBlock` helper
preserves indentation inside the wrapper.
`base.css` regenerated. svelte-check 0 errors, `npm run component:audit`
81 / 81 PASS unchanged. This is foundation-only; no components consume
the new tokens yet — that lands in the typography port (Text + Heading
+ Display + Code + CodeBlock + Kbd + Mark + Highlight + Link).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
5 months ago
|
|
|
|
if (typography.styles) {
|
|
|
|
|
|
appendTypographyStyleDeclarations(declarations, typography)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── Depth (DEPTH_ENGINE_RFC) ─────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
/** Emit `--depth-{plane}-{cue}` tokens — each plane's cue values composed into `:root`. */
|
|
|
|
|
|
function appendDepthDeclarations(declarations: string[], depth: DepthPrimitiveSet): void {
|
|
|
|
|
|
for (const [plane, cues] of Object.entries(depth.planes)) {
|
feat(eidos): depth Fase 2 (oklab rim halo) + reference-grade /temas/profundidad
Depth engine — Fase 2 (mode-adaptive mezcla):
- New `halo` cue per plane: a top-edge rim-light computed in oklab
(color-mix(in oklab, white N%, transparent); 5/7/8% on raised/overlay/modal).
The `[data-depth]` box-shadow now composes `shadow, halo`. Invisible on light
surfaces (the drop shadow leads), the lift cue on dark surfaces (where the drop
shadow barely shows) — the mode-adaptive answer to "shadow lies in dark",
scoped to the depth channel (global --shadow-* untouched).
- Wired through config-types (DepthPlane.halo) + render-css (declare + compose)
+ config validation + STATIC_DEPTH + regenerated generated/base.css.
Showcase — /temas/profundidad to reference depth (4 -> 9 sections): matches
Material elevation catalog breadth and adds the two axes it lacks (eventful +
open cage):
- Responde a cada estado — dynamic elevation, live interactive control
- La escalera de planos — z-stack of the 5 planes
- Catalogo de planos en reposo — the resting-elevation spec table, our vocabulary
- Luz vs sombra — light/dark side-by-side showing the halo mechanism
- Accesibilidad — never the only channel, reduced-motion, forced-colors, contrast
- Fix: mirror data-theme onto <html> so :root depth tokens stay mode-aware
Docs: DEPTH_ENGINE_RFC (Fase 2 + 5 done, token contract + halo), THEMING 29.
Verified: npm run check 0 errors; depth tests 50/50 (updated the box-shadow
assertion to the shadow,halo composition). Pre-existing `words` recipe-contract
failures unrelated.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
4 months ago
|
|
|
|
for (const cue of ['surface', 'shadow', 'halo', 'z', 'blur', 'scrim'] as const) {
|
|
|
|
|
|
const value = cues[cue]
|
|
|
|
|
|
if (value !== undefined) declarations.push(cssVar(`depth-${plane}-${cue}`, value))
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* `[data-depth='{plane}']` applies the *safe additive* depth cues — box-shadow + z-index —
|
feat(eidos): depth Fase 2 (oklab rim halo) + reference-grade /temas/profundidad
Depth engine — Fase 2 (mode-adaptive mezcla):
- New `halo` cue per plane: a top-edge rim-light computed in oklab
(color-mix(in oklab, white N%, transparent); 5/7/8% on raised/overlay/modal).
The `[data-depth]` box-shadow now composes `shadow, halo`. Invisible on light
surfaces (the drop shadow leads), the lift cue on dark surfaces (where the drop
shadow barely shows) — the mode-adaptive answer to "shadow lies in dark",
scoped to the depth channel (global --shadow-* untouched).
- Wired through config-types (DepthPlane.halo) + render-css (declare + compose)
+ config validation + STATIC_DEPTH + regenerated generated/base.css.
Showcase — /temas/profundidad to reference depth (4 -> 9 sections): matches
Material elevation catalog breadth and adds the two axes it lacks (eventful +
open cage):
- Responde a cada estado — dynamic elevation, live interactive control
- La escalera de planos — z-stack of the 5 planes
- Catalogo de planos en reposo — the resting-elevation spec table, our vocabulary
- Luz vs sombra — light/dark side-by-side showing the halo mechanism
- Accesibilidad — never the only channel, reduced-motion, forced-colors, contrast
- Fix: mirror data-theme onto <html> so :root depth tokens stay mode-aware
Docs: DEPTH_ENGINE_RFC (Fase 2 + 5 done, token contract + halo), THEMING 29.
Verified: npm run check 0 errors; depth tests 50/50 (updated the box-shadow
assertion to the shadow,halo composition). Pre-existing `words` recipe-contract
failures unrelated.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
4 months ago
|
|
|
|
* referencing the plane tokens (so `setCssVariables` / a theme can retune them). The
|
|
|
|
|
|
* box-shadow composes the drop `shadow` with the rim-light `halo` (comma-joined) when both
|
|
|
|
|
|
* are declared, so the lift cue survives in dark mode. `surface` (background) + `blur`/`scrim`
|
|
|
|
|
|
* (atmosphere) stay opt-in tokens, never forced here, so the rule never fights a component's
|
|
|
|
|
|
* own background. (DEPTH_ENGINE_RFC §5)
|
|
|
|
|
|
*/
|
|
|
|
|
|
function renderDepthBlocks(depth: DepthPrimitiveSet): string[] {
|
|
|
|
|
|
const blocks: string[] = []
|
|
|
|
|
|
for (const [plane, cues] of Object.entries(depth.planes)) {
|
|
|
|
|
|
const lines: string[] = []
|
feat(eidos): depth Fase 2 (oklab rim halo) + reference-grade /temas/profundidad
Depth engine — Fase 2 (mode-adaptive mezcla):
- New `halo` cue per plane: a top-edge rim-light computed in oklab
(color-mix(in oklab, white N%, transparent); 5/7/8% on raised/overlay/modal).
The `[data-depth]` box-shadow now composes `shadow, halo`. Invisible on light
surfaces (the drop shadow leads), the lift cue on dark surfaces (where the drop
shadow barely shows) — the mode-adaptive answer to "shadow lies in dark",
scoped to the depth channel (global --shadow-* untouched).
- Wired through config-types (DepthPlane.halo) + render-css (declare + compose)
+ config validation + STATIC_DEPTH + regenerated generated/base.css.
Showcase — /temas/profundidad to reference depth (4 -> 9 sections): matches
Material elevation catalog breadth and adds the two axes it lacks (eventful +
open cage):
- Responde a cada estado — dynamic elevation, live interactive control
- La escalera de planos — z-stack of the 5 planes
- Catalogo de planos en reposo — the resting-elevation spec table, our vocabulary
- Luz vs sombra — light/dark side-by-side showing the halo mechanism
- Accesibilidad — never the only channel, reduced-motion, forced-colors, contrast
- Fix: mirror data-theme onto <html> so :root depth tokens stay mode-aware
Docs: DEPTH_ENGINE_RFC (Fase 2 + 5 done, token contract + halo), THEMING 29.
Verified: npm run check 0 errors; depth tests 50/50 (updated the box-shadow
assertion to the shadow,halo composition). Pre-existing `words` recipe-contract
failures unrelated.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
4 months ago
|
|
|
|
const shadowParts: string[] = []
|
|
|
|
|
|
if (cues.shadow !== undefined) shadowParts.push(`var(--depth-${plane}-shadow)`)
|
|
|
|
|
|
if (cues.halo !== undefined) shadowParts.push(`var(--depth-${plane}-halo)`)
|
|
|
|
|
|
if (shadowParts.length) lines.push(`box-shadow: ${shadowParts.join(', ')};`)
|
|
|
|
|
|
if (cues.z !== undefined) lines.push(`z-index: var(--depth-${plane}-z);`)
|
|
|
|
|
|
if (lines.length) blocks.push(renderBlock(`[data-depth='${plane}']`, lines))
|
|
|
|
|
|
|
|
|
|
|
|
// Atmosphere (Fase 4) — frost is opt-in via `data-frost` so it never turns an opaque
|
|
|
|
|
|
// overlay translucent by default. Gated on the plane attribute → reliably overrides the
|
|
|
|
|
|
// component's own background (specificity 0,2,0). Needs a surface to dilute + a blur.
|
|
|
|
|
|
if (cues.blur !== undefined) {
|
|
|
|
|
|
const frost: string[] = []
|
|
|
|
|
|
if (cues.surface !== undefined)
|
|
|
|
|
|
frost.push(
|
|
|
|
|
|
`background-color: color-mix(in srgb, var(--depth-${plane}-surface) 80%, transparent);`
|
|
|
|
|
|
)
|
|
|
|
|
|
frost.push(`backdrop-filter: blur(var(--depth-${plane}-blur));`)
|
|
|
|
|
|
frost.push(`-webkit-backdrop-filter: blur(var(--depth-${plane}-blur));`)
|
|
|
|
|
|
blocks.push(renderBlock(`[data-depth='${plane}'][data-frost]`, frost))
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return blocks
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── Shape (SHAPE_ENGINE_RFC) ─────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
/** Emit the shape tokens — `--shape-smoothing` (superellipse exponent) + `--shape-nest-gap`. */
|
|
|
|
|
|
function appendShapeDeclarations(declarations: string[], shape: ShapePrimitiveSet): void {
|
|
|
|
|
|
declarations.push(cssVar('shape-smoothing', shape.smoothing))
|
|
|
|
|
|
if (shape.nestGap !== undefined) declarations.push(cssVar('shape-nest-gap', shape.nestGap))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* `[data-shape='{family}']` sets `corner-shape` (continuity / perceptual family) — opt-in, so the
|
|
|
|
|
|
* magnitude (`border-radius`) stays universal and the corners degrade to the plain arc where
|
|
|
|
|
|
* `corner-shape` is unsupported. `[data-shape-nest]` adds the concentric harmony: a child's
|
|
|
|
|
|
* radius = the parent's `--shape-outer-radius` minus the nest gap, so nested corners stay
|
|
|
|
|
|
* parallel. Magnitude is never touched on the family rules. (SHAPE_ENGINE_RFC)
|
|
|
|
|
|
*/
|
|
|
|
|
|
function renderShapeBlocks(shape: ShapePrimitiveSet): string[] {
|
|
|
|
|
|
const blocks: string[] = []
|
|
|
|
|
|
// Register --shape-smoothing as a typed number so the eventful corner morph (the press-squeeze
|
|
|
|
|
|
// firma, SHAPE_ENGINE_RFC Fase 3) interpolates smoothly instead of jumping. inherits:true keeps
|
|
|
|
|
|
// the :root default + any theme override cascading to every element.
|
|
|
|
|
|
blocks.push(
|
|
|
|
|
|
`@property --shape-smoothing {\n\tsyntax: '<number>';\n\tinherits: true;\n\tinitial-value: ${shape.smoothing};\n}`
|
|
|
|
|
|
)
|
|
|
|
|
|
for (const [family, value] of Object.entries(shape.families)) {
|
|
|
|
|
|
blocks.push(renderBlock(`[data-shape='${family}']`, [`corner-shape: ${value};`]))
|
|
|
|
|
|
}
|
|
|
|
|
|
// Concentric nesting (Fase 2): inner radius = outer − gap, clamped ≥ 0. The parent exposes
|
|
|
|
|
|
// its radius via `--shape-outer-radius` (inherited); the gap defaults to the nest token.
|
|
|
|
|
|
if (shape.nestGap !== undefined) {
|
|
|
|
|
|
blocks.push(
|
|
|
|
|
|
renderBlock(`[data-shape-nest]`, [
|
|
|
|
|
|
`border-radius: max(0px, calc(var(--shape-outer-radius, var(--radius-lg)) - var(--shape-nest-gap)));`
|
|
|
|
|
|
])
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
return blocks
|
|
|
|
|
|
}
|
|
|
|
|
|
|
feat(eidos foundation): emit --style-{name}-* tokens for named typography styles
`STATIC_TYPOGRAPHY.styles` already defined 11 named typography styles
(hero, h1..h6, body, prose, label, caption, code) but the renderer
only emitted families / sizes / weights — the style entries lived
as data with no CSS reflection.
Extend `appendTypographyDeclarations` so each named style emits:
- `--style-{name}-font-family` → `var(--font-family-{family})`
- `--style-{name}-font-size` → `var(--font-size-{size})`
- `--style-{name}-line-height` → literal or `var(--font-line-height-{size})`
- `--style-{name}-letter-spacing` → literal or `var(--font-letter-spacing-{size})`
- `--style-{name}-font-weight` → `var(--font-weight-{weight})` (or numeric)
- `--style-{name}-color` → `var(--color-{dot.path → dash-path})`
Responsive sizes (e.g. `hero.size = { base: 'xxl', md: 'xxxl' }`)
emit the base value into `:root` and per-breakpoint
`@media (min-width: …)` blocks that override the same variables.
Breakpoint thresholds match `$libs/dom/responsive` (sm: 480, md: 768,
lg: 1024, xl: 1280, xxl: 1536).
`renderStaticCss` now iterates `STYLE_BREAKPOINT_ORDER` after the
main `:root` block and appends one media-query block per breakpoint
that has at least one responsive override. The `indentBlock` helper
preserves indentation inside the wrapper.
`base.css` regenerated. svelte-check 0 errors, `npm run component:audit`
81 / 81 PASS unchanged. This is foundation-only; no components consume
the new tokens yet — that lands in the typography port (Text + Heading
+ Display + Code + CodeBlock + Kbd + Mark + Highlight + Link).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
5 months ago
|
|
|
|
/**
|
|
|
|
|
|
* 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)'))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Per-role semantic leading/tracking (config-driven) → --leading-{role} /
|
|
|
|
|
|
// --tracking-{role}. Distinct keys from the SCALES (tighter/tight/… · none/tight/…)
|
|
|
|
|
|
// emitted in appendTypographyDeclarations. `--leading-ui` anchors to the label named
|
|
|
|
|
|
// style through its config value (var(--style-label-line-height, …)), so the canonical
|
|
|
|
|
|
// UI leading still follows the foundation. A theme retunes any of these via
|
|
|
|
|
|
// `typography.semanticLeading` / `semanticTracking`.
|
|
|
|
|
|
for (const [name, value] of Object.entries(typography.semanticLeading ?? {})) {
|
|
|
|
|
|
declarations.push(cssVar(`leading-${name}`, value))
|
|
|
|
|
|
}
|
|
|
|
|
|
for (const [name, value] of Object.entries(typography.semanticTracking ?? {})) {
|
|
|
|
|
|
declarations.push(cssVar(`tracking-${name}`, value))
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function hasFocusColor(options: EidosConfig): boolean {
|
|
|
|
|
|
if (options.semantics.color.focus) return true
|
|
|
|
|
|
|
|
|
|
|
|
return Object.values(options.themes ?? {}).some((theme) => theme.color?.focus)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function appendThemeColorDeclarations(
|
|
|
|
|
|
declarations: string[],
|
|
|
|
|
|
color: ReturnType<typeof mergeThemeColor>
|
|
|
|
|
|
): void {
|
|
|
|
|
|
if (color.surface) {
|
|
|
|
|
|
declarations.push(cssVar('color-surface-default', color.surface.default))
|
|
|
|
|
|
declarations.push(cssVar('color-surface-raised', color.surface.raised))
|
|
|
|
|
|
declarations.push(cssVar('color-surface-overlay', color.surface.overlay))
|
eidos: theming fixes + size/variant parity batch + docs
- theme: add surface.muted + content.muted to color contract (neutral-3
+ neutral-10). Plugs 17 broken --color-content-muted and 3 broken
--color-surface-muted references in recipes/components.
- form.css: fix --color-neutral-element-hover typo → --color-neutral-hover.
- archetypes.css + events.css: replace raw hsl/rgba indigo with
color-mix(var(--color-primary-solid) …) — no raw colors left in eidos.
- combobox dark scrollbar: unscope ::-webkit-scrollbar rules in uix.css
and duplicate --uix-line on :root + :root[data-mode='dark'] so portaled
overlays (Combobox listbox, Popover, Dialog, Drawer) inherit the theme.
- sizes: 20 components expand from sm/md/lg to xs..xl (form controls,
text inputs, progress/meter, field/form) or xs..lg (nav controls:
breadcrumb, pagination, tag-group, toolbar). Composite panels keep
sm/md/lg deliberately.
- variants: field + toolbar drop arbitrary ControlVariant narrowings;
both expose all 3 (surface | outline | ghost) with new outline CSS.
- pagination demo: disambiguate siblingCount/boundaryCount as "per side"
in label + API table (Radix/MUI convention).
- docs: CHECKLIST §C-2.6 (contract token presence) + §D-7.4 (chip parity)
+ §D-7.5 (size category) added. DEMO_AUTHORING §12.7 (chip parity) +
§12.8 (size category cheatsheet) added. eidos/README +
active_architecture.md sync texts/migration nomenclature.
- PENDIENTES.md: normas N-1..N-5 implantadas en esta sesión.
Verification: 88/88 eidos tests, 0 type errors, 67/67 component:audit PASS.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
5 months ago
|
|
|
|
declarations.push(cssVar('color-surface-muted', color.surface.muted))
|
|
|
|
|
|
declarations.push(cssVar('color-overlay', color.surface.backdrop))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (color.content) {
|
|
|
|
|
|
declarations.push(cssVar('color-content-primary', color.content.primary))
|
|
|
|
|
|
declarations.push(cssVar('color-content-secondary', color.content.secondary))
|
eidos: theming fixes + size/variant parity batch + docs
- theme: add surface.muted + content.muted to color contract (neutral-3
+ neutral-10). Plugs 17 broken --color-content-muted and 3 broken
--color-surface-muted references in recipes/components.
- form.css: fix --color-neutral-element-hover typo → --color-neutral-hover.
- archetypes.css + events.css: replace raw hsl/rgba indigo with
color-mix(var(--color-primary-solid) …) — no raw colors left in eidos.
- combobox dark scrollbar: unscope ::-webkit-scrollbar rules in uix.css
and duplicate --uix-line on :root + :root[data-mode='dark'] so portaled
overlays (Combobox listbox, Popover, Dialog, Drawer) inherit the theme.
- sizes: 20 components expand from sm/md/lg to xs..xl (form controls,
text inputs, progress/meter, field/form) or xs..lg (nav controls:
breadcrumb, pagination, tag-group, toolbar). Composite panels keep
sm/md/lg deliberately.
- variants: field + toolbar drop arbitrary ControlVariant narrowings;
both expose all 3 (surface | outline | ghost) with new outline CSS.
- pagination demo: disambiguate siblingCount/boundaryCount as "per side"
in label + API table (Radix/MUI convention).
- docs: CHECKLIST §C-2.6 (contract token presence) + §D-7.4 (chip parity)
+ §D-7.5 (size category) added. DEMO_AUTHORING §12.7 (chip parity) +
§12.8 (size category cheatsheet) added. eidos/README +
active_architecture.md sync texts/migration nomenclature.
- PENDIENTES.md: normas N-1..N-5 implantadas en esta sesión.
Verification: 88/88 eidos tests, 0 type errors, 67/67 component:audit PASS.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
5 months ago
|
|
|
|
declarations.push(cssVar('color-content-muted', color.content.muted))
|
|
|
|
|
|
declarations.push(cssVar('color-content-disabled', color.content.disabled))
|
|
|
|
|
|
declarations.push(cssVar('color-content-on-solid', color.content.onSolid))
|
Theming: on-solid contrast by luminance (P2-2) + translucent role surfaces (P2-4)
Two audit P2 quality defects, fixed at engine level.
P2-2 - on-solid text illegible on light solids:
The `contrast` slot defaulted to `--color-content-on-solid` (white) for every
role. On light solids (amber/yellow, risk=orange ~2.3:1) white is sub-AA. The
engine now picks by WCAG contrast (gamma-linearized) of the role step-9: when
white fails (<3:1) it uses `--color-content-on-solid-contrast` (a dark, new
OPTIONAL `content.onSolidContrast` semantic, #1c1917 in base). Only risk flips
to dark in base (5.89:1); purple/red/teal/green keep white (convention, >=3:1).
An explicit `slots.contrast` override is still honored verbatim.
P2-4 - opaque tinted soft surfaces:
The soft variant tint (Button + Badge `{role}-soft-bg`) was opaque (step-1 track
+ opaque color-mix hover) so it did not composite over non-uniform backgrounds.
New derived tokens `--color-{role}-surface` (= a2) + `--color-{role}-surface-hover`
(= a3) are translucent by construction (compositing-inverse alpha). Button/Badge
soft consume them. Toast/Tabs untouched - they are cards, opacity is correct.
- config-types: optional onSolidContrast + CONTENT_COLOR_OPTIONAL_KEYS.
- config: content keySet allows the optional key; validator value-checks it.
- render-css: wcagContrastRatio/wcagRelativeLuminance; luminance pick; emit
on-solid-contrast + surface/surface-hover.
- contract: on-solid-contrast + surface tokens per role.
- themes/base: onSolidContrast #1c1917 (light + dark).
- recipes/base: Button + Badge soft-bg -> surface, soft-bg-hover -> surface-hover.
- docs: THEMING.md section 24 + section 22 item 2; audit P2-2/P2-4 resolved.
Verified: npm run check (0 new errors), vitest eidos (0 new regressions),
browser runtime (risk 5.89:1 dark text, surfaces translucent rgba).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
4 months ago
|
|
|
|
if (color.content.onSolidContrast) {
|
|
|
|
|
|
declarations.push(
|
|
|
|
|
|
cssVar('color-content-on-solid-contrast', color.content.onSolidContrast)
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (color.border) {
|
|
|
|
|
|
declarations.push(cssVar('color-border-subtle', color.border.subtle))
|
|
|
|
|
|
declarations.push(cssVar('color-border-default', color.border.default))
|
|
|
|
|
|
declarations.push(cssVar('color-border-strong', color.border.strong))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (color.focus) {
|
|
|
|
|
|
declarations.push(cssVar('focus-ring-color', color.focus.ring))
|
|
|
|
|
|
declarations.push(cssVar('focus-ring-color-error', color.focus.ringError))
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function appendColorScaleDeclarations(
|
|
|
|
|
|
declarations: string[],
|
|
|
|
|
|
scaleName: string,
|
|
|
|
|
|
scale: ColorScale
|
|
|
|
|
|
): void {
|
|
|
|
|
|
for (const step of COLOR_SCALE_STEPS) {
|
|
|
|
|
|
const value = scale[step]
|
|
|
|
|
|
// Wide-gamut output (RFC §7, strategy A): the hex stays as the universal
|
|
|
|
|
|
// fallback, then an `oklch()` sibling overrides it where supported — so the
|
|
|
|
|
|
// color uses the display's gamut (more saturated on P3) automatically, with
|
|
|
|
|
|
// NO `@media`, and renders identically in sRGB. Only opaque, parseable steps
|
|
|
|
|
|
// get the sibling; empty / non-color values keep just the fallback line.
|
|
|
|
|
|
declarations.push(cssVar(`scale-${scaleName}-${step}`, value))
|
|
|
|
|
|
const oklch = safeParseColor(value)
|
|
|
|
|
|
if (oklch) declarations.push(cssVar(`scale-${scaleName}-${step}`, oklchToCss(oklch)))
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function parseHexColor(value: string): { r: number; g: number; b: number } | null {
|
|
|
|
|
|
const text = value.trim()
|
|
|
|
|
|
if (!text.startsWith('#')) return null
|
|
|
|
|
|
const hex = text.slice(1)
|
|
|
|
|
|
let r: number, g: number, b: number
|
|
|
|
|
|
if (hex.length === 3) {
|
|
|
|
|
|
r = parseInt(hex[0] + hex[0], 16)
|
|
|
|
|
|
g = parseInt(hex[1] + hex[1], 16)
|
|
|
|
|
|
b = parseInt(hex[2] + hex[2], 16)
|
|
|
|
|
|
} else if (hex.length === 6 || hex.length === 8) {
|
|
|
|
|
|
r = parseInt(hex.slice(0, 2), 16)
|
|
|
|
|
|
g = parseInt(hex.slice(2, 4), 16)
|
|
|
|
|
|
b = parseInt(hex.slice(4, 6), 16)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
return null
|
|
|
|
|
|
}
|
|
|
|
|
|
if ([r, g, b].some(Number.isNaN)) return null
|
|
|
|
|
|
return { r, g, b }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** sRGB relative luminance (0..1) — enough to pick the alpha background polarity. */
|
|
|
|
|
|
function srgbLuminance({ r, g, b }: { r: number; g: number; b: number }): number {
|
|
|
|
|
|
return (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Compositing inverse: the translucent color that, painted over a solid
|
|
|
|
|
|
* background `bg` (255 = white, 0 = black), reproduces the opaque `solidHex`.
|
|
|
|
|
|
* Solves `solid = a·c + (1-a)·bg` for the minimum alpha `a` (max saturation),
|
|
|
|
|
|
* per channel. Returns `null` when the solid isn't a plain hex (caller keeps
|
|
|
|
|
|
* the synthetic ramp for non-hex values).
|
|
|
|
|
|
*/
|
|
|
|
|
|
function alphaColorOverBackground(solidHex: string, bg: 0 | 255): string | null {
|
|
|
|
|
|
const t = parseHexColor(solidHex)
|
|
|
|
|
|
if (!t) return null
|
|
|
|
|
|
let a = 0
|
|
|
|
|
|
for (const c of [t.r, t.g, t.b]) {
|
|
|
|
|
|
if (c === bg) continue
|
|
|
|
|
|
const channelAlpha = bg === 255 ? (255 - c) / 255 : c / 255
|
|
|
|
|
|
if (channelAlpha > a) a = channelAlpha
|
|
|
|
|
|
}
|
|
|
|
|
|
if (a <= 0) return `rgb(${t.r} ${t.g} ${t.b} / 0)`
|
|
|
|
|
|
const resolve = (c: number) =>
|
|
|
|
|
|
Math.min(255, Math.max(0, Math.round((c - bg * (1 - a)) / a)))
|
|
|
|
|
|
const alpha = Math.round(a * 10000) / 10000
|
|
|
|
|
|
return `rgb(${resolve(t.r)} ${resolve(t.g)} ${resolve(t.b)} / ${alpha})`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Alpha (translucent) variants of a color scale.
|
|
|
|
|
|
*
|
|
|
|
|
|
* If a theme provides explicit `alphaScales` for this scale they win (verbatim).
|
|
|
|
|
|
* Otherwise each `aN` is GENERATED as the compositing inverse of the solid step
|
|
|
|
|
|
* `N` over the scale's background polarity (white for light scales, black for
|
|
|
|
|
|
* dark — decided by step 1's luminance): `aN` painted over that background
|
|
|
|
|
|
* reproduces solid `N`. This keeps the alpha ramp consistent-by-construction
|
|
|
|
|
|
* with the solid scale (no drift, Radix-style fidelity) for every theme, not
|
|
|
|
|
|
* just the base. Non-hex solids (e.g. `var()`) fall back to the legacy
|
|
|
|
|
|
* step-9-at-opacity `color-mix` ramp. (audit P1-1)
|
|
|
|
|
|
*/
|
|
|
|
|
|
function appendColorAlphaScaleDeclarations(
|
|
|
|
|
|
declarations: string[],
|
|
|
|
|
|
scaleName: string,
|
|
|
|
|
|
solidScale: ColorScale | undefined,
|
|
|
|
|
|
alphaScale: ColorAlphaScale | undefined
|
|
|
|
|
|
): void {
|
|
|
|
|
|
const step1 = solidScale?.['1']
|
|
|
|
|
|
const parsedStep1 = step1 ? parseHexColor(step1) : null
|
|
|
|
|
|
const bg: 0 | 255 = parsedStep1 && srgbLuminance(parsedStep1) < 0.5 ? 0 : 255
|
|
|
|
|
|
|
|
|
|
|
|
for (const step of COLOR_ALPHA_STEPS) {
|
|
|
|
|
|
const authored = alphaScale?.[step]
|
|
|
|
|
|
const solid = solidScale?.[step]
|
|
|
|
|
|
const computed =
|
|
|
|
|
|
authored === undefined && solid ? alphaColorOverBackground(solid, bg) : null
|
|
|
|
|
|
const value =
|
|
|
|
|
|
authored ??
|
|
|
|
|
|
computed ??
|
|
|
|
|
|
`color-mix(in srgb, var(--scale-${scaleName}-9) ${
|
|
|
|
|
|
DEFAULT_COLOR_ALPHA_PERCENTAGES[step]
|
|
|
|
|
|
}, transparent)`
|
|
|
|
|
|
declarations.push(cssVar(`scale-${scaleName}-a${step}`, value))
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function appendRecordDeclarations(
|
|
|
|
|
|
declarations: string[],
|
|
|
|
|
|
prefix: string,
|
|
|
|
|
|
record: Record<string, string | number> | undefined
|
|
|
|
|
|
): void {
|
|
|
|
|
|
if (!record) return
|
|
|
|
|
|
|
|
|
|
|
|
for (const [name, value] of Object.entries(record)) {
|
|
|
|
|
|
declarations.push(cssVar(`${prefix}-${name}`, value))
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Like {@link appendRecordDeclarations} but multiplies each value by the
|
|
|
|
|
|
* active density scalar (`var(--{scaleVar})`) via `calc()`, so the density
|
|
|
|
|
|
* axis (compact / comfortable / spacious) actually rescales the token.
|
|
|
|
|
|
*
|
|
|
|
|
|
* The density block (`renderDensityBlocks`) redeclares `--density-*-scale`
|
|
|
|
|
|
* on `[data-density='…']` (the same element where these tokens are
|
|
|
|
|
|
* authored — `:root` / `<html>`), so the `var()` here resolves against the
|
|
|
|
|
|
* active density. Zero values are emitted verbatim (`calc(0 * x)` is
|
|
|
|
|
|
* pointless and `0px` must remain a valid length). At the `comfortable`
|
|
|
|
|
|
* default the scalar is `1`, so the emitted value is numerically identical
|
|
|
|
|
|
* to the raw token — no change for consumers that never switch density.
|
|
|
|
|
|
*/
|
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()
|
|
|
|
|
|
// Only multiply finite, non-zero numeric lengths. Zero stays verbatim (`0px`
|
|
|
|
|
|
// must remain a valid length, `calc(0 * x)` is pointless) and so does any
|
|
|
|
|
|
// non-numeric value (keyword like `auto`, a `var()` or `calc()`) — wrapping
|
|
|
|
|
|
// those in `calc(x * …)` would emit invalid CSS.
|
|
|
|
|
|
const num = parseFloat(raw)
|
|
|
|
|
|
const scalable = Number.isFinite(num) && num !== 0
|
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(
|
|
|
|
|
|
cssVar(`${prefix}-${name}`, scalable ? `calc(${raw}${density} * var(--scaling))` : raw)
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function mergeThemeColor(
|
|
|
|
|
|
primitiveScales: ColorScales | undefined,
|
|
|
|
|
|
semanticColor: EidosConfig['semantics']['color'],
|
|
|
|
|
|
themeColor: ThemeColorSet | undefined
|
|
|
|
|
|
) {
|
|
|
|
|
|
return {
|
|
|
|
|
|
scales: {
|
|
|
|
|
|
...primitiveScales,
|
|
|
|
|
|
...themeColor?.scales
|
|
|
|
|
|
},
|
|
|
|
|
|
alphaScales: {
|
|
|
|
|
|
...themeColor?.alphaScales
|
|
|
|
|
|
},
|
|
|
|
|
|
roles: {
|
|
|
|
|
|
...semanticColor.roles,
|
|
|
|
|
|
...themeColor?.roles
|
|
|
|
|
|
} as ColorRoleMap,
|
|
|
|
|
|
surface:
|
|
|
|
|
|
semanticColor.surface || themeColor?.surface
|
|
|
|
|
|
? ({
|
|
|
|
|
|
...semanticColor.surface,
|
|
|
|
|
|
...themeColor?.surface
|
|
|
|
|
|
} as SurfaceColorRoles)
|
|
|
|
|
|
: undefined,
|
|
|
|
|
|
content:
|
|
|
|
|
|
semanticColor.content || themeColor?.content
|
|
|
|
|
|
? ({
|
|
|
|
|
|
...semanticColor.content,
|
|
|
|
|
|
...themeColor?.content
|
|
|
|
|
|
} as ContentColorRoles)
|
|
|
|
|
|
: undefined,
|
|
|
|
|
|
border:
|
|
|
|
|
|
semanticColor.border || themeColor?.border
|
|
|
|
|
|
? ({
|
|
|
|
|
|
...semanticColor.border,
|
|
|
|
|
|
...themeColor?.border
|
|
|
|
|
|
} as BorderColorRoles)
|
|
|
|
|
|
: undefined,
|
|
|
|
|
|
focus:
|
|
|
|
|
|
semanticColor.focus || themeColor?.focus
|
|
|
|
|
|
? ({
|
|
|
|
|
|
...semanticColor.focus,
|
|
|
|
|
|
...themeColor?.focus
|
|
|
|
|
|
} as FocusColorRoles)
|
|
|
|
|
|
: undefined
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function getThemeSelector(themeId: string): string {
|
|
|
|
|
|
return `[data-theme='${themeId}']`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function getColorRoleScaleName(definition: string | ColorRoleDefinition): string {
|
|
|
|
|
|
if (typeof definition === 'string') return definition
|
|
|
|
|
|
|
|
|
|
|
|
return definition.scale
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function cssVar(name: string, value: string | number): string {
|
|
|
|
|
|
return `--${name}: ${value};`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function emptyCssVar(name: string): string {
|
|
|
|
|
|
return `--${name}: ;`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function renderBlock(selector: string, declarations: string[]): string {
|
|
|
|
|
|
return `${selector} {\n${declarations.map((declaration) => `\t${declaration}`).join('\n')}\n}`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function formatFontFamily(family: FontFamily): string {
|
feat(eidos): config-driven @font-face (Phase 2) — fonts as theme data (next/font model)
TYPOGRAPHY_ENGINE_RFC Phase 2. Fonts are theme data (each theme owns its families), so
@font-face becomes config-driven + generated — like the color palette — instead of a
separate hand-written CSS file. This matches next/font / Fontaine (config -> @font-face),
above the token-only frameworks (Radix/Tailwind/MUI) that leave loading to you.
- config-types: FontFamily += faces (FontFace[]) / axes (FontAxes) / fallback
(FontFallback, metric-override) / display / preload. Additive — the family stack still
works from `family`+`fallbacks`.
- render-css: renderFontFaceBlocks generates @font-face per face from the config, deduped
by the real font name (a font shared across slots — Lora as secondary+display — emits
once). Optional metric-override fallback @font-face (anti-CLS) injected into the stack
as `'{family} Fallback'` when declared. Emitted first in renderStaticCss.
- typography.ts: the BASE THEME's 14 @font-face migrated from fonts.css into the config
(faces). TTF today (the theme's choice); a theme swaps to woff2/variable + fallback
metrics by editing config only.
- index.css: drops `@import './themes/fonts.css'` — the @font-face now ships in
generated/base.css. (fonts.css superseded; left in place, no longer imported.)
- generated/base.css regenerated (14 @font-face, Lora deduped). test: @font-face
generation + dedup; merge-without-mutation assertion updated for the faces field.
Verified in browser: 3 families registered, files resolve (200), fonts load on demand
(swap). check 0 errors; eidos suite green (3 pre-existing words failures only).
Deferred (capability typed, theme adopts when it has the assets): variable woff2,
metric-override numbers (need fontkit/precompute), <link rel=preload> (head markup).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
4 months ago
|
|
|
|
const names = [family.family]
|
|
|
|
|
|
// Inject the metric-override fallback right after the real family so it renders
|
|
|
|
|
|
// (matched to the webfont's metrics) until the webfont loads — zero CLS.
|
|
|
|
|
|
if (family.fallback) names.push(`${family.family} Fallback`)
|
|
|
|
|
|
names.push(...(family.fallbacks ?? []))
|
|
|
|
|
|
return names.map(formatFontName).join(', ')
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Generate `@font-face` blocks from the theme's font families (config-driven loading,
|
|
|
|
|
|
* next/font model). Deduped by the real font name — a font shared across slots
|
|
|
|
|
|
* (e.g. Lora as both `secondary` and `display`) emits its faces once. Each family may
|
|
|
|
|
|
* also declare a metric-override `fallback` (anti-CLS). (TYPOGRAPHY_ENGINE_RFC §5)
|
|
|
|
|
|
*/
|
|
|
|
|
|
function renderFontFaceBlocks(typography: TypographyPrimitiveSet): string {
|
|
|
|
|
|
const seen = new Set<string>()
|
|
|
|
|
|
const blocks: string[] = []
|
|
|
|
|
|
for (const family of Object.values(typography.families)) {
|
|
|
|
|
|
if (!family.faces || family.faces.length === 0) continue
|
|
|
|
|
|
if (seen.has(family.family)) continue
|
|
|
|
|
|
seen.add(family.family)
|
|
|
|
|
|
for (const face of family.faces) {
|
|
|
|
|
|
blocks.push(renderFontFace(family.family, face, family.display, family.axes))
|
feat(eidos): config-driven @font-face (Phase 2) — fonts as theme data (next/font model)
TYPOGRAPHY_ENGINE_RFC Phase 2. Fonts are theme data (each theme owns its families), so
@font-face becomes config-driven + generated — like the color palette — instead of a
separate hand-written CSS file. This matches next/font / Fontaine (config -> @font-face),
above the token-only frameworks (Radix/Tailwind/MUI) that leave loading to you.
- config-types: FontFamily += faces (FontFace[]) / axes (FontAxes) / fallback
(FontFallback, metric-override) / display / preload. Additive — the family stack still
works from `family`+`fallbacks`.
- render-css: renderFontFaceBlocks generates @font-face per face from the config, deduped
by the real font name (a font shared across slots — Lora as secondary+display — emits
once). Optional metric-override fallback @font-face (anti-CLS) injected into the stack
as `'{family} Fallback'` when declared. Emitted first in renderStaticCss.
- typography.ts: the BASE THEME's 14 @font-face migrated from fonts.css into the config
(faces). TTF today (the theme's choice); a theme swaps to woff2/variable + fallback
metrics by editing config only.
- index.css: drops `@import './themes/fonts.css'` — the @font-face now ships in
generated/base.css. (fonts.css superseded; left in place, no longer imported.)
- generated/base.css regenerated (14 @font-face, Lora deduped). test: @font-face
generation + dedup; merge-without-mutation assertion updated for the faces field.
Verified in browser: 3 families registered, files resolve (200), fonts load on demand
(swap). check 0 errors; eidos suite green (3 pre-existing words failures only).
Deferred (capability typed, theme adopts when it has the assets): variable woff2,
metric-override numbers (need fontkit/precompute), <link rel=preload> (head markup).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
4 months ago
|
|
|
|
}
|
|
|
|
|
|
if (family.fallback) blocks.push(renderFallbackFontFace(family.family, family.fallback))
|
|
|
|
|
|
}
|
|
|
|
|
|
return blocks.join('\n\n')
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function renderFontFace(
|
|
|
|
|
|
family: string,
|
|
|
|
|
|
face: FontFace,
|
|
|
|
|
|
defaultDisplay: string | undefined,
|
|
|
|
|
|
axes?: FontFamily['axes']
|
|
|
|
|
|
): string {
|
feat(eidos): config-driven @font-face (Phase 2) — fonts as theme data (next/font model)
TYPOGRAPHY_ENGINE_RFC Phase 2. Fonts are theme data (each theme owns its families), so
@font-face becomes config-driven + generated — like the color palette — instead of a
separate hand-written CSS file. This matches next/font / Fontaine (config -> @font-face),
above the token-only frameworks (Radix/Tailwind/MUI) that leave loading to you.
- config-types: FontFamily += faces (FontFace[]) / axes (FontAxes) / fallback
(FontFallback, metric-override) / display / preload. Additive — the family stack still
works from `family`+`fallbacks`.
- render-css: renderFontFaceBlocks generates @font-face per face from the config, deduped
by the real font name (a font shared across slots — Lora as secondary+display — emits
once). Optional metric-override fallback @font-face (anti-CLS) injected into the stack
as `'{family} Fallback'` when declared. Emitted first in renderStaticCss.
- typography.ts: the BASE THEME's 14 @font-face migrated from fonts.css into the config
(faces). TTF today (the theme's choice); a theme swaps to woff2/variable + fallback
metrics by editing config only.
- index.css: drops `@import './themes/fonts.css'` — the @font-face now ships in
generated/base.css. (fonts.css superseded; left in place, no longer imported.)
- generated/base.css regenerated (14 @font-face, Lora deduped). test: @font-face
generation + dedup; merge-without-mutation assertion updated for the faces field.
Verified in browser: 3 families registered, files resolve (200), fonts load on demand
(swap). check 0 errors; eidos suite green (3 pre-existing words failures only).
Deferred (capability typed, theme adopts when it has the assets): variable woff2,
metric-override numbers (need fontkit/precompute), <link rel=preload> (head markup).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
4 months ago
|
|
|
|
const src = face.sources
|
|
|
|
|
|
.map((s) => `url('${s.url}')${s.format ? ` format('${s.format}')` : ''}`)
|
|
|
|
|
|
.join(', ')
|
|
|
|
|
|
// Variable fonts: when the face omits an explicit weight and the family declares a
|
|
|
|
|
|
// `wght` axis range, emit the range (`font-weight: 100 900`) so a single face spans the
|
|
|
|
|
|
// whole weight axis. (TYPOGRAPHY_ENGINE_RFC §5 — variable fonts)
|
|
|
|
|
|
const weight = face.weight ?? (axes?.wght ? `${axes.wght[0]} ${axes.wght[1]}` : 400)
|
feat(eidos): config-driven @font-face (Phase 2) — fonts as theme data (next/font model)
TYPOGRAPHY_ENGINE_RFC Phase 2. Fonts are theme data (each theme owns its families), so
@font-face becomes config-driven + generated — like the color palette — instead of a
separate hand-written CSS file. This matches next/font / Fontaine (config -> @font-face),
above the token-only frameworks (Radix/Tailwind/MUI) that leave loading to you.
- config-types: FontFamily += faces (FontFace[]) / axes (FontAxes) / fallback
(FontFallback, metric-override) / display / preload. Additive — the family stack still
works from `family`+`fallbacks`.
- render-css: renderFontFaceBlocks generates @font-face per face from the config, deduped
by the real font name (a font shared across slots — Lora as secondary+display — emits
once). Optional metric-override fallback @font-face (anti-CLS) injected into the stack
as `'{family} Fallback'` when declared. Emitted first in renderStaticCss.
- typography.ts: the BASE THEME's 14 @font-face migrated from fonts.css into the config
(faces). TTF today (the theme's choice); a theme swaps to woff2/variable + fallback
metrics by editing config only.
- index.css: drops `@import './themes/fonts.css'` — the @font-face now ships in
generated/base.css. (fonts.css superseded; left in place, no longer imported.)
- generated/base.css regenerated (14 @font-face, Lora deduped). test: @font-face
generation + dedup; merge-without-mutation assertion updated for the faces field.
Verified in browser: 3 families registered, files resolve (200), fonts load on demand
(swap). check 0 errors; eidos suite green (3 pre-existing words failures only).
Deferred (capability typed, theme adopts when it has the assets): variable woff2,
metric-override numbers (need fontkit/precompute), <link rel=preload> (head markup).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
4 months ago
|
|
|
|
const lines = [
|
|
|
|
|
|
'@font-face {',
|
|
|
|
|
|
`\tfont-family: '${family}';`,
|
|
|
|
|
|
`\tsrc: ${src};`,
|
|
|
|
|
|
`\tfont-weight: ${weight};`,
|
feat(eidos): config-driven @font-face (Phase 2) — fonts as theme data (next/font model)
TYPOGRAPHY_ENGINE_RFC Phase 2. Fonts are theme data (each theme owns its families), so
@font-face becomes config-driven + generated — like the color palette — instead of a
separate hand-written CSS file. This matches next/font / Fontaine (config -> @font-face),
above the token-only frameworks (Radix/Tailwind/MUI) that leave loading to you.
- config-types: FontFamily += faces (FontFace[]) / axes (FontAxes) / fallback
(FontFallback, metric-override) / display / preload. Additive — the family stack still
works from `family`+`fallbacks`.
- render-css: renderFontFaceBlocks generates @font-face per face from the config, deduped
by the real font name (a font shared across slots — Lora as secondary+display — emits
once). Optional metric-override fallback @font-face (anti-CLS) injected into the stack
as `'{family} Fallback'` when declared. Emitted first in renderStaticCss.
- typography.ts: the BASE THEME's 14 @font-face migrated from fonts.css into the config
(faces). TTF today (the theme's choice); a theme swaps to woff2/variable + fallback
metrics by editing config only.
- index.css: drops `@import './themes/fonts.css'` — the @font-face now ships in
generated/base.css. (fonts.css superseded; left in place, no longer imported.)
- generated/base.css regenerated (14 @font-face, Lora deduped). test: @font-face
generation + dedup; merge-without-mutation assertion updated for the faces field.
Verified in browser: 3 families registered, files resolve (200), fonts load on demand
(swap). check 0 errors; eidos suite green (3 pre-existing words failures only).
Deferred (capability typed, theme adopts when it has the assets): variable woff2,
metric-override numbers (need fontkit/precompute), <link rel=preload> (head markup).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
4 months ago
|
|
|
|
`\tfont-style: ${face.style ?? 'normal'};`,
|
|
|
|
|
|
`\tfont-display: ${face.display ?? defaultDisplay ?? 'swap'};`
|
|
|
|
|
|
]
|
|
|
|
|
|
if (face.unicodeRange) lines.push(`\tunicode-range: ${face.unicodeRange};`)
|
|
|
|
|
|
lines.push('}')
|
|
|
|
|
|
return lines.join('\n')
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function renderFallbackFontFace(family: string, fallback: FontFallback): string {
|
|
|
|
|
|
const lines = [
|
|
|
|
|
|
'@font-face {',
|
|
|
|
|
|
`\tfont-family: '${family} Fallback';`,
|
|
|
|
|
|
`\tsrc: local('${fallback.local}');`
|
|
|
|
|
|
]
|
|
|
|
|
|
if (fallback.sizeAdjust) lines.push(`\tsize-adjust: ${fallback.sizeAdjust};`)
|
|
|
|
|
|
if (fallback.ascentOverride) lines.push(`\tascent-override: ${fallback.ascentOverride};`)
|
|
|
|
|
|
if (fallback.descentOverride) lines.push(`\tdescent-override: ${fallback.descentOverride};`)
|
|
|
|
|
|
if (fallback.lineGapOverride) lines.push(`\tline-gap-override: ${fallback.lineGapOverride};`)
|
|
|
|
|
|
lines.push('}')
|
|
|
|
|
|
return lines.join('\n')
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function formatFontName(name: string): string {
|
|
|
|
|
|
if (GENERIC_FONT_FAMILIES.has(name)) return name
|
|
|
|
|
|
if (name.startsWith('"') || name.startsWith("'")) return name
|
|
|
|
|
|
if (!/\s/.test(name)) return name
|
|
|
|
|
|
|
|
|
|
|
|
return `'${name.replace(/'/g, "\\'")}'`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function getContractVariableNames(
|
|
|
|
|
|
contract: EidosCssContract | readonly EidosCssContractToken[]
|
|
|
|
|
|
): Set<string> {
|
|
|
|
|
|
const tokens = isEidosCssContract(contract) ? [...contract.static, ...contract.theme] : contract
|
|
|
|
|
|
return new Set(tokens.map((token) => token.name))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function isEidosCssContract(
|
|
|
|
|
|
contract: EidosCssContract | readonly EidosCssContractToken[]
|
|
|
|
|
|
): contract is EidosCssContract {
|
|
|
|
|
|
return !Array.isArray(contract)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function normalizeCssVariableName(name: string): string | undefined {
|
|
|
|
|
|
const normalized = name.startsWith('--') ? name.slice(2) : name
|
|
|
|
|
|
if (/^[a-zA-Z0-9_-]+$/.test(normalized)) return normalized
|
|
|
|
|
|
return undefined
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function assertCssVariableValue(name: string, value: EidosCssVariableValue): string | number {
|
|
|
|
|
|
if (typeof value === 'number') return value
|
|
|
|
|
|
if (!/[;{}]/.test(value)) return value
|
|
|
|
|
|
|
|
|
|
|
|
throw new EidosCssVariableError([name])
|
|
|
|
|
|
}
|