You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
svelte-kit-vice/src/uix/eidos/THEMING.md

1966 lines
73 KiB

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
# Eidos Theming — Architecture Reference
> **Audiencia**: cualquier dev que abra el repo y necesite entender cómo
> se hace el theming en UIX. Cubre el modelo mental, los contratos,
> las herramientas y las trampas. Si después de leerlo todavía no sabes
> dónde poner un token nuevo, falló este doc — abre un issue.
**TL;DR**:
- **9 roles canónicos** de color (`primary`, `secondary`, `tertiary`,
`neutral`, `affirm`, `fulfill`, `risk`, `threat`, `loss`).
- **6 sizes canónicos** + `full` (`xxs..xxl`).
- **3 niveles de tokens** públicos: foundation (estable), per-component
recipe (overrideable), private (`--_*`, no contrato externo).
- **Token Scope Contract (TSC)** decide DÓNDE se emite cada token
(`:root` / `[data-{c}]` / `[data-{c}][data-color='X']` / etc.) y
valida transitividad al generar.
- **226 KB raw / 25 KB gzip** de CSS foundation por defecto. Usa
`npm run eidos:purge` para apps en producción → −46 a −55%.
- **Compatible con** persistencia versionada, themes CSS-only,
runtime overrides, dark/light, density (compact/comfortable/spacious),
reduced motion, multi-axis breakpoints.
---
## Tabla de contenidos
1. [Mental model](#1-mental-model)
1bis. [Theming vive en Eidos, no en Morfo (por diseño)](#1bis-theming-vive-en-eidos-no-en-morfo-por-diseño)
2. [Las 6 capas del CSS de Eidos](#2-las-6-capas-del-css-de-eidos)
3. [Las 7 capas de tokens](#3-las-7-capas-de-tokens)
4. [Los 9 roles canónicos de color](#4-los-9-roles-canónicos-de-color)
5. [El canon de sizes](#5-el-canon-de-sizes)
6. [Convenciones de naming](#6-convenciones-de-naming)
7. [Token Scope Contract (TSC)](#7-token-scope-contract-tsc)
8. [Cómo añadir un componente nuevo](#8-cómo-añadir-un-componente-nuevo)
9. [Cómo definir un theme](#9-cómo-definir-un-theme)
10. [Cómo overridear tokens en runtime](#10-cómo-overridear-tokens-en-runtime)
11. [Bundle strategy + `eidos:purge`](#11-bundle-strategy--eidospurge)
12. [Herramientas de validación](#12-herramientas-de-validación)
13. [Integración con Sema (`event:*` scope)](#13-integración-con-sema-event-scope)
14. [Motion (estado actual)](#14-motion-estado-actual)
15. [Comparación con librerías de referencia](#15-comparación-con-librerías-de-referencia)
16. [Anti-patterns que NO debes cometer](#16-anti-patterns-que-no-debes-cometer)
17. [FAQ — decisiones polémicas](#17-faq--decisiones-polémicas)
18. [Cobertura universal de TSC](#18-cobertura-universal-de-tsc)
feat(eidos): variants canon — EIDOS_VARIANTS + per-component lint Variants son canon del eidos, NO del theme. Decisión arquitectónica firmemente sostenida: el vocabulario de variants (solid/outline/ghost/ soft/surface/line/pills) está fijo a nivel del framework — paralelo a las 8 sema families del libro. Theme = retintar lo perceptualmente fijo; cambia QUÉ color es `affirm`, no QUÉ significa `outline`. Cambios: - lib/types.ts: nueva constante `EIDOS_VARIANTS` con los 5 archetypes canónicos (control / selection / chip / marker / tabs). Los 5 union types se derivan via `[number]` indexed access — valor y tipo no pueden desincronizarse. Nueva `EIDOS_VARIANT_VALUES` Set flat con todos los valores canónicos + utilidades cross-component (`plain`, `subtle`). - recipe-css-contract.test.ts: nuevo test "variant CSS selectors per component match the declared type union". Por cada componente: extrae el union type de `components/{c}/types.ts` (soporta literal unions + archetype aliases; cae a advisory mode en Extract<> y conditional types); compara con `[data-{c}][data-variant='X']` selectores en `{c}.css`; reporta typos y unauthorized extensions bidireccionalmente. - THEMING.md §19: nueva sección "Variants son canon del eidos, NO del theme" con argumentación (portabilidad, type safety, archetypes perceptuales paralelos a sema families), tabla de las 3 capas de la cebolla, referencia a `EIDOS_VARIANTS`, comparación con Radix Themes 3.x / Mantine 7 / Chakra v3 / Ark / shadcn. TOC actualizado. - eidos/README.md: tabla de referencia ampliada con §19. - CLAUDE.md: hand-off "2026-05-27 #6 (variants canon)". - CONTINUE.md: nota de la decisión arquitectónica. Variants component-specific permitidos (Banner inline/overlay/ persistent, Spinner bars/dots/ring, Button 'plain'): viven en cada `components/{c}/types.ts` y el lint los valida contra la CSS del componente. Tests: 101/101 pass en `src/uix/eidos`. `npm run check`: los mismos 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>
4 months ago
19. [Variants son canon del eidos, NO del theme](#19-variants-son-canon-del-eidos-no-del-theme)
20. [Correcciones del engine de theming (2026-06-01)](#20-correcciones-del-engine-de-theming-2026-06-01)
21. [Propuesta abierta — modelo de color de dos niveles (RFC, NO implementado)](#21-propuesta-abierta--modelo-de-color-de-dos-niveles-rfc-no-implementado)
22. [Mejoras pendientes del theming](#22-mejoras-pendientes-del-theming)
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
---
## 1. Mental model
Eidos es **la capa visual** de UIX. NO posee comportamiento ni estado.
Lee del DOM lo que las capas anteriores escribieron y aplica estilos.
```
Morfo declara la genética (qué attrs / events / partes existen)
↓
Soma transcribe behavior (data-state, data-color, aria-*, focus, …)
↓
Sema emite señales (data-event-* durante el hold perceptual)
↓
Eidos aplica visual (tokens, themes, recipes, archetypes, motion)
```
**Lo que Eidos posee**:
- El namespace `--*` de custom properties.
- 5 layers de CSS (archetypes, events, foundation generado, recipes,
themes).
- El runtime `ActiveEidos` que inyecta foundation + theme CSS.
- Tooling: generación, validación, purge, lint.
**Lo que Eidos NO posee**:
- Estado lógico de componentes (eso es soma).
- Definición de qué events existen (eso es morfo).
- Disparar señales perceptivas (eso es sema).
**La regla del 2-de-3**: una extensión al sistema (atributo, token,
convención) sólo se justifica si **al menos dos de las tres capas**
(soma, sema, eidos) la consumen. Las extensiones que entraron por voto
de eidos: `archetype`, `events[].semantic.{family,intent}`,
`events[].prewrite[]`, `data-starting-style` / `data-ending-style`.
---
## 1.bis Theming vive en Eidos, no en Morfo (por diseño)
> **Esta es la pregunta arquitectónica más frecuente — y la respuesta más
> importante para no romper el sistema.**
La intuición razonable de un dev nuevo es: *"si morfo es la fuente de
verdad cross-layer, los tokens visuales deberían vivir en morfo también"*.
**NO.** El diseño explícito de UIX dice lo contrario. Esta sección existe
para cerrar el caso con citas, antes de que la confusión arrastre a un
PR que viole la arquitectura.
### Las dos citas canónicas del repo
**`src/uix/active_architecture.md` §9 (Lo que NO es esta arquitectura)**:
> **No es un design system clásico.** Tokens, themes y recipes pertenecen
> a Eidos, no al núcleo.
**`src/uix/README.md` §2 (Eidos)**:
> Capa visual: **tokens, themes, recipes CSS por componente**, archetype
> rules, event reactions y wrappers Svelte sobre los providers headless de
> soma…
>
> Eidos lee del DOM lo que las otras capas escriben — **nunca importa
> internals de soma ni de sema**.
Estas dos frases, por sí solas, cierran cualquier debate sobre dónde
vive el theming. Si una propuesta futura las contradice, la propuesta
debe rechazarse o el doc canónico debe actualizarse antes — no después.
### La regla 2-de-3 lo deriva mecánicamente
**`active_architecture.md` §7 #12** y **README.md §5** dicen lo mismo:
> Una extensión a morfo solo se justifica si **al menos dos de las tres
> capas** (soma, sema, eidos) la consumen.
Aplicado al theming:
| ¿Quién consume los tokens visuales? | |
|---|---|
| Soma (behavior runtime) | ❌ no |
| Sema (perceptual signals) | ❌ no |
| Eidos (visual layer) | ✅ sí |
| **Cuenta** | **1-de-3** |
**1-de-3 ≠ 2-de-3 → tokens NO van en morfo, por regla**. La integración
visual cae automáticamente en eidos por la disciplina del 2-de-3, sin
que nadie tenga que decidirlo per-caso.
### ¿Cuál es entonces la relación entre morfo y theming?
Morfo es source-of-truth del **contrato cross-layer**:
- Parts (qué partes existen)
- Events (qué eventos puede disparar)
- Attrs y sus valores enumerados (qué attrs aparecen en DOM con qué values)
- Archetypes (clasificación transversal)
- States declarativos
**El theming se integra con morfo en UN SENTIDO PRECISO**: las recipes de
eidos targetean DOM attrs que morfo declara. Sin morfo, los attrs no
existirían en el DOM y los selectores eidos estarían muertos.
```
MORFO declara data-color.values = ['primary', 'affirm', 'threat', ...]
↓
SOMA emite <button data-color="affirm"> (al DOM)
↓
EIDOS recipe targetea scope: 'color:affirm' → [data-toggle][data-color='affirm']
↓
BROWSER cascade resuelve la regla CSS
```
El canal entre morfo y eidos es **el DOM**, no objetos TypeScript. Esto
es crítico y está blindado por la regla #6 de las reglas duras:
**`active_architecture.md` §7 #6**:
> **Eidos consume DOM y data-*, no internals de Soma ni Sema.** Si lo
> necesita, debe estar declarado en morfo o emitido en una señal de sema.
**README.md §5** lo repite literalmente.
### Lo que NO debe hacer eidos jamás
```ts
// ❌ VIOLACIÓN ARQUITECTÓNICA — Eidos importando morfo en runtime
import { toggleMorfo } from '$uix/morfo/components/toggle';
const validValues = toggleMorfo.parts
.find((p) => p.kebab === 'provider')!
.data.find((d) => d.attr === 'data-color')!.values;
// usar validValues para validar TSC scope:'color:X'
```
Aunque la intención sea buena (validar que `scope: 'color:affirm'` matchee
un value morfo-declarado), **este import viola la regla #6** y rompe el
boundary morfo↔eidos. Si quieres esa validación, la defensa correcta es
eidos-lint al nivel del DOM/CSS, no acoplamiento TS.
### La defensa correcta: eidos-lint al nivel del DOM/CSS
La validación de que las recipes eidos targetean valores que morfo declara
SE HACE, pero al nivel DOM/CSS:
```bash
node scripts/eidos-lint.ts toggle
```
Clasifica cada selector `[data-*]` como:
- **morfo-backed** — declarado en morfo, soma lo emite con value válido
- **eidos-only** — attr añadido por wrapper (data-variant, data-size)
- **invalid** — referencia un attr morfo-backed con value fuera del enum → bug
Esto cierra el loop arquitectónicamente sin requerir imports cross-layer.
### Recapitulación de la división canónica
| Concepto | Source-of-truth | Justificación |
|---|---|---|
| Parts (qué partes existen) | **Morfo** | Cross-layer: soma emite, eidos selecciona, sema referencia |
| Events + semantic | **Morfo** | Cross-layer: soma trigger, sema dispatch, eidos reaction |
| Archetypes | **Morfo** | Cross-layer: soma emite, sema cascade, eidos selectores |
| Attr values (`data-color.values`) | **Morfo** | Cross-layer: soma valida, eidos targetea, sema referencia |
| States declarativos | **Morfo** | Cross-layer: soma emite data-state, eidos selecciona |
| **9 roles canónicos sistémicos** | **Eidos** (`lib/themes/base.ts`) | Solo eidos los materializa |
| **6 sizes canónicos** | **Eidos** (`lib/config-types.ts`) | Solo eidos los coordina |
| **Variants visuales** (solid/outline/ghost) | **Eidos wrapper** | Solo eidos las renderiza |
| **Tokens** (`--toggle-solid-on-bg`) | **Eidos** (`lib/recipes/base.ts`) | Solo eidos los consume |
| **Themes** (light/dark/custom) | **Eidos** (`lib/themes/`) | Solo eidos los compone |
| **Persistencia del theme** | **Eidos** (`toDocument()`) | Solo eidos lo serializa |
| **TSC scope axes** (color, state, variant, size, event) | **Eidos** (refieren a attrs morfo-emitted) | Hardcoded en TSC porque son axes del DOM contract |
### La frase canónica
> **Morfo declara el contrato. Eidos declara el theming. El DOM los conecta.**
Esto NO es un compromiso. ES el diseño. La pureza de morfo (TS
declarativo, sin runtime, sin imports de capas visuales) DEPENDE de que
el theming viva fuera.
### Qué propuestas futuras DEBEN rechazarse citando esta sección
1. **"Vamos a poner los tokens visuales del Toggle en su morfo así
morfo es source of truth de todo"** — viola §9 y la regla 2-de-3.
2. **"Vamos a hacer que TSC valide `color:affirm` importando
`toggleMorfo.data['data-color'].values`"** — viola regla #6 (eidos
no importa internals de morfo en TS).
3. **"Vamos a meter `variant: 'solid' | 'outline'` en el morfo del
Toggle"** — viola 2-de-3 (variants solo las consume eidos).
4. **"Vamos a definir `size` en el morfo con sus 6 valores
canónicos"** — viola 2-de-3 (los 6 sizes son sistema visual, solo
eidos los materializa con tokens coordinados).
Si la propuesta tiene mérito, lo correcto es **actualizar el doc
canónico (`active_architecture.md` §9) ANTES** de aplicar el cambio.
No al revés.
### Lo que SÍ debería entrar en morfo respecto al theming
- Un componente que expone `color` como prop → debe declarar
`data-color.values: ['primary', 'affirm', ...]` en su morfo. Esos
values son cross-layer (eidos targetea, soma emite, sema podría
referenciar).
- Un componente que expone `state` (open/closed) → declara
`data-state.values: ['open', 'closed']`. Igual.
- Un componente que añade attrs visualmente puros (`data-variant`,
`data-size`) que NADIE más necesita → **NO van en morfo**, los añade
el wrapper eidos directamente.
### Consistencia con los docs canónicos
Esta sección **no introduce doctrina nueva**. Recoge y consolida lo que
ya estaba disperso en:
- [`src/uix/active_architecture.md`](../active_architecture.md) §3 (Morfo
= único punto de articulación cross-layer), §7 #6 (Eidos no importa
internals), §7 #12 (regla 2-de-3), §9 (tokens pertenecen a Eidos).
- [`src/uix/README.md`](../README.md) §2 (Eidos = capa visual con tokens),
§4 (no es design system clásico), §5 (Eidos consume DOM y data-\*).
- Esta misma `THEMING.md` §1 (Mental model) y §7 (TSC).
Si alguno de esos docs canónicos contradice esta sección, **el doc
canónico gana**. Esta sección consolida; no decide.
---
## 2. Las 6 capas del CSS de Eidos
`src/uix/eidos/index.css` es el entrypoint. Importa, en orden:
```
1. themes/fonts.css ← font faces
2. generated/base.css ← foundation + recipes tokens (226 KB)
3. archetypes.css ← reglas transversales por data-archetype
4. events.css ← reacciones a data-event-* (sema)
5. lib/menu-indicator.css ← partial compartido
6. components/{c}/{c}.css × ~95 ← recipes per-component
```
### Por qué este orden importa
- `generated/base.css` declara tokens (no estiliza). Si los recipes se
cargan antes, no tienen los tokens disponibles.
- `archetypes.css` setea baseline interactiva (cursor, hover, focus
ring). Recipes específicos sobrescriben.
- `events.css` reacciona a `data-event-*` con `animation: @keyframes`
(no `transition`) porque las señales son transitorias y la animación
debe completar independiente del lifetime de la señal.
- Recipes específicos vienen al final → mayor cascade priority en
conflictos de igual specificity.
### Qué hace cada layer concretamente
| Layer | Tipo | Cuántas reglas | Propósito |
|---|---|---|---|
| `themes/fonts.css` | `@font-face` | 4-12 | Cargar fuentes |
| `generated/base.css` | `:root` + algunos `[data-{c}]` blocks | 4400+ declaraciones | Foundation tokens (scale, primitive, color, size, density, typography, recipe tokens) |
| `archetypes.css` | `[data-archetype='X']` selectors | 11 archetypes | Estilo baseline transversal (trigger, overlay, content, indicator, thumb, track, close, action, item, option, focus) |
| `events.css` | `[data-event-*]` selectors + @keyframes | 9 keyframes + 15 rules | Reactions perceptivas (announce pulse, dismiss fade, commit settle, etc.) |
| `lib/menu-indicator.css` | Partial | 1 selector | Indicator alignment compartido entre menus |
| `components/{c}/{c}.css` | `[data-{c}-*]` selectors | varía | Recipe específico del componente |
### Reglas para tocar cada layer
- **`fonts.css`**: añade `@font-face`. No declara tokens, no estiliza.
- **`generated/base.css`**: **NUNCA editar a mano**. Es output del
generador. Para cambiarlo, edita `lib/recipes/base.ts` o
`lib/themes/base.ts` y corre `npm run generate:eidos-css`.
- **`archetypes.css`**: añade entradas sólo si el archetype está
declarado en algún morfo. Reglas de specificity baja (un atributo).
- **`events.css`**: añade reactions sólo para señales que sema emite.
Usa `animation: @keyframes`, NO `transition`. Lee `data-event-intent`
(signal-bound), NUNCA `data-intent` (state-bound).
- **`components/{c}/{c}.css`**: el dueño es la persona que mantiene
el componente. Sigue la convención de naming (sección 6).
---
## 3. Las 7 capas de tokens
Eidos compone el color final de un elemento atravesando 7 niveles de
indirección. Cada nivel sirve un propósito distinto:
```
┌─ Capa 1: --scale-{name}-{step} :root (estable)
│ Escalas físicas Radix (12 steps + alpha): --scale-teal-9 = #12a594
│
├─ Capa 2: --primitive-{role}-{step} :root (estable)
│ Mapeo role → scale: --primitive-affirm-9 = var(--scale-teal-9)
│
├─ Capa 3: --color-{role}-{slot} :root (estable)
│ Slot semántico: --color-affirm-solid = var(--primitive-affirm-9)
│
├─ Capa 4: --{component}-{role}-{slot} :root (estable)
│ Per-component alias: --button-affirm-solid = var(--color-affirm-solid)
│ (NOTA: drop del segmento "color-" en 2026-05-27)
│
├─ Capa 5: --{component}-palette-{slot} [data-{c}] (DINÁMICA)
│ Palette dinámica por instancia: cambia con data-color
│
├─ Capa 6: --{component}-{variant}-{slot} [data-{c}] (host) (DINÁMICA)
│ Combinación de variante × palette
│
└─ Capa 7: --_{component}-{slot} [data-{c}] (privado)
Token privado consumido por el recipe CSS directamente
```
**Reglas de scope**:
- Capas 1-4 son constantes → `:root`.
- Capa 5 cambia por instancia → `[data-{c}]` y `[data-{c}][data-color='X']`.
- Capa 6 depende de la 5 → DEBE estar en `[data-{c}]` (TSC lo enfuerza).
- Capa 7 es privada → siempre en `[data-{c}]`.
### Por qué tantas capas
**No es accidental**. Cada salto sirve un punto de extensión:
| Capa | Override permite | Ejemplo de uso |
|---|---|---|
| 1 | Cambiar la escala física Radix | Brand quiere su propio teal |
| 2 | Cambiar qué escala mapea un role | "Affirm" usa green en vez de teal |
| 3 | Cambiar slot mapping per role | "Solid" del affirm usa step 10 en vez de 9 |
| 4 | Cambiar token component-specific | Toggle quiere su affirm distinto del global |
| 5 | El runtime per-instancia | `<Toggle color="affirm" />` cambia el palette |
| 6 | Combinar variant × color | Solid variant del toggle con affirm color |
| 7 | Recipe-internal | El recipe decide qué token interno usa para qué |
En la práctica, **la mayoría de las apps SÓLO tocan las capas 1-3**
(brand customization). Las capas 4-7 son del catálogo de componentes.
### Cuándo crear un token nuevo en cada capa
- **Capa 1** (scale): jamás añades. Usa las 30 escalas Radix existentes.
- **Capa 2** (primitive): rara vez. Sólo si añades un role canónico
nuevo (lo cual cambiaría el book canon — no lo hagas).
- **Capa 3** (color): si añades un nuevo `{slot}` (raro). Hoy hay 9
slots (track, element, hover, active, border, solid, solid-hover,
text, contrast).
- **Capa 4** (component-color): añadiendo color support a un componente
nuevo. Se genera automáticamente por `lib/recipes/base.ts`.
- **Capa 5** (palette): cuando el componente acepta `data-color` prop
y necesita un palette dinámico. TSC `scope: 'host'` + overrides
`scope: 'color:X'`.
- **Capa 6** (variant): cuando una variant (`solid`, `outline`, etc.)
combina palette + algo específico. TSC `scope: 'host'`.
- **Capa 7** (private): el recipe lo consume. Convención: prefijo `_`.
---
## 4. Los 9 roles canónicos de color
Los roles vienen del libro *Diseñando lo que ocurre*. SON CANON. NO
inventes nuevos.
```
HIERARCHY (no evaluative) INTENT (evaluative)
───────────────────────────── ───────────────────────────
primary — brand main affirm — turning ON something positive
secondary — brand support fulfill — completion / success
tertiary — brand tertiary risk — moderate negative consequence
neutral — gray default threat — active negative consequence
loss — irreversible negative outcome
```
**Reglas estrictas**:
- Los 9 nombres son los únicos válidos. NO uses `success`, `warning`,
`danger`, `info` — esos pertenecen a otros modelos (Bootstrap, etc.).
- **`primary`/`secondary`/`tertiary`** son **jerárquicos**: usar cuando
la diferencia es "más vs menos prominente". Sin carga evaluativa.
- **`neutral`** es el default. Sin carga semántica.
- **`affirm`/`fulfill`/`risk`/`threat`/`loss`** son **evaluativos**:
comunican qué pasa con la acción.
- Si `intent === 'neutral'`, `color` (hierarchy override) puede aplicar.
Si `intent` es evaluativo, el `intent` GANA y `color` se ignora.
**Mapping a escalas físicas** (en theme base):
| Role | Escala Radix | Reasoning |
|---|---|---|
| primary | indigo | Brand default, neutral-positive |
| secondary | slate | Support hierarchy, slate-blue |
| tertiary | (lo elige el theme) | — |
| neutral | gray | Sin carga |
| affirm | teal | Light positive, mint-fresh |
| fulfill | green | Completion, classic success |
| risk | orange | Caution, warning |
| threat | red | Active danger |
| loss | purple | Posterior gravity, deep |
Cada escala genera 12 steps + 12 alpha steps = 24 tokens × 9 roles =
**216 color primitives**. Es la mayor parte del bloat del foundation.
### Por qué 9 roles y no 4 (como shadcn) o 14 (como Mantine)
Los 9 son el resultado del análisis perceptivo del libro:
- 3 hierarchy roles cubren la dimensión "prominencia visual".
- 1 neutral cubre el default sin carga.
- 5 intent roles cubren las cinco valencias evaluativas distintas.
Cualquier sistema con menos pierde resolución perceptiva. Cualquier
sistema con más cae en redundancia (success vs fulfill, danger vs
threat — no son lo mismo).
### Subset por componente
Cada componente expone su propio subset de los 9. Ejemplos:
| Componente | Subset | Excluye |
|---|---|---|
| Toggle | primary, secondary, neutral, affirm, risk, threat | fulfill, loss (no aplica) |
| Button | los 9 | — |
| Badge | primary, secondary, neutral, affirm, fulfill, risk, threat, loss | tertiary (no canónico) |
Por qué subsets: un toggle no es completion ni irreversible loss.
Exponer fulfill/loss en su API sería semánticamente incorrecto.
---
## 5. El canon de sizes
```
xxs · xs · sm · md · lg · xl · xxl | full
───────────────────────────────────── ───────
6 sizes canónicos (físicos) 1 size de layout
```
`md` es el default. `full` no es físico — es semántica de layout
(`100%` / `100vw` / `100dvh` según contexto). No genera tokens fijos.
Cada size canónico genera tokens coordinados:
```
--size-md-control-height: 36px
--size-md-font-size: 14px
--size-md-font-line-height: 1.5
--size-md-icon-size: 16px
--size-md-padding-inline: 12px
--size-md-padding-block: 8px
--size-md-gap: 8px
--size-md-radius: 6px
```
### Regla clave: `md` NO cambia por viewport
Lo responsive decide **qué size activo se usa**, NO redefine los
tokens. Si tu Toggle en mobile usa `sm` y en desktop `md`, ambos
tokens están disponibles y el wrapper elige cuál.
```svelte
<!-- Correcto -->
<Toggle size={{ base: 'sm', md: 'md' }} />
<!-- Incorrecto -->
@media (max-width: 768px) {
:root { --toggle-size-md-control-height: 32px; }
}
```
### Subset por componente
Como con color, cada componente expone su subset de sizes que su
recipe soporta. Categorías:
| Categoría | Subset | Ejemplos |
|---|---|---|
| Form controls + text inputs | `xs..xl` | input, select, switch, slider, checkbox |
| Nav controls | `xs..lg` | breadcrumb, pagination, tag-group, toolbar |
| Composed panels | `sm..lg` | calendar, date-picker, file-upload, stepper, tooltip |
La categorización vive en
[`web/routes/uix/lib/DEMO_AUTHORING_GUIDE.md §12.8`](../../../web/routes/uix/lib/DEMO_AUTHORING_GUIDE.md).
---
## 6. Convenciones de naming
### Tokens públicos (consumibles)
```
--{prefix}-{slot}
```
Donde `{prefix}` es uno de:
| Prefix | Significado | Ejemplo |
|---|---|---|
| `--scale-{name}-{step}` | Escala física Radix | `--scale-teal-9` |
| `--primitive-{role}-{step}` | Role → step | `--primitive-affirm-9` |
| `--color-{role}-{slot}` | Color role × slot | `--color-affirm-solid` |
| `--font-{kind}-{key}` | Tipografía | `--font-family-primary` |
| `--size-{key}-{slot}` | Size primitive | `--size-md-control-height` |
| `--space-{n}` | Spacing scale | `--space-3` |
| `--radius-{key}` | Radius scale | `--radius-md` |
| `--shadow-{n}` | Shadow scale | `--shadow-3` |
| `--z-index-{key}` | Z-index layer | `--z-index-modal` |
| `--opacity-{key}` | Opacity | `--opacity-disabled` |
| `--duration-{key}` | Motion duration | `--duration-fast` |
| `--ease-{key}` | Motion ease | `--ease-out` |
| `--style-{name}-*` | Typography named style | `--style-h1-font-size` |
| `--{c}-{slot}` | Component recipe token | `--toggle-radius-md` |
| `--{c}-{role}-{slot}` | Component color | `--toggle-affirm-solid` |
| `--{c}-palette-{slot}` | Component palette runtime | `--toggle-palette-solid` |
### Tokens privados (componente-internal)
```
--_{c}-{slot}
```
El prefijo `_` significa: NO consumes esto desde fuera del recipe del
componente. Es interno. Ejemplo:
```css
[data-toggle] {
--_toggle-bg: var(--toggle-solid-bg); /* privado */
--_toggle-on-bg: var(--toggle-palette-solid); /* privado */
}
```
### Reglas estrictas
1. **Todos los public tokens del Eidos llevan el prefijo `--`** sin
sub-prefijo de capa. Razón: clarity en debug. Ver `--toggle-bg` y
sabes que es Eidos. Ver `--bg` y no sabes de dónde viene.
2. **NUNCA usar `--eidos-`** como prefijo. La capa ya está implícita
en el path `$uix/eidos/components/{c}`.
3. **NUNCA usar `--soma-` ni `--air-` ni `--terra-`**. Esas capas son
muertas o no poseen tokens.
4. **Los component tokens siguen el patrón** `--{component-kebab}-...`.
El componente kebab es el nombre del directorio.
5. **No abreviar nombres de componente**. `dropdown-menu` no se vuelve
`ddmenu`. La authorship clarity vale 6 chars.
6. **NO incluir el segmento "color-"** intermedio en tokens de color.
`--toggle-affirm-solid` (correcto), `--toggle-color-affirm-solid`
(deprecado 2026-05-27).
7. **Slots siguen vocabulario fijo**: `track, element, hover, active,
border, solid, solid-hover, text, contrast` (de capas 3-5),
`bg, fg, border, on-bg, on-fg, on-border, hover-bg, on-hover-bg`
(de capas 6-7).
### Tokens generados vs autoría
Tokens en `generated/base.css` son **output**. Para añadir uno nuevo,
editas:
- `lib/themes/base.ts` para primitives, scales, theme variants.
- `lib/recipes/base.ts` para tokens de componente.
Y corres `npm run generate:eidos-css`.
---
## 7. Token Scope Contract (TSC)
> Eidos does not infer token scope from emitted CSS. Token scope is
> part of the source contract. The generator emits CSS from scoped
> declarations and validates that every token dependency is available
> in the consumer scope.
TSC es la pieza arquitectónica que distingue a Eidos de Tailwind /
Radix / Chakra / Mantine / shadcn. Resuelve un problema sutil pero
crítico que ningún otro sistema cierra estructuralmente.
### El problema que resuelve
CSS custom property substitution es **eager**, no lazy:
```css
:root {
--base: black;
--derived: var(--base);
}
.x { --base: red; }
.y { background: var(--derived); }
```
¿Qué color tiene `.x.y`? **NEGRO**, no rojo. `--derived` se computa
en `:root` con `--base=black` y se hereda como `black`. El override
de `.x` sobre `--base` no afecta a `--derived` ya congelado.
Aplicado al Toggle pre-TSC:
```css
:root {
--toggle-palette-solid: var(--toggle-color-neutral-solid);
--toggle-solid-on-bg: var(--toggle-palette-solid); /* CONGELADO */
}
[data-toggle][data-color='affirm'] {
--toggle-palette-solid: var(--toggle-color-affirm-solid); /* INÚTIL */
}
```
`--toggle-solid-on-bg` quedaba congelado al neutral. El toggle con
`data-color='affirm'` mostraba gris en vez de teal. **Bug
arquitectónico** que ningún linter detectaría.
### Cómo TSC lo cierra
El config del recipe declara **dónde** se emite cada token:
```ts
recipes.toggle = {
'palette-solid': {
declarations: [
{ value: 'var(--toggle-color-neutral-solid)', scope: 'host' },
{ value: 'var(--toggle-color-affirm-solid)', scope: 'color:affirm' },
{ value: 'var(--toggle-color-threat-solid)', scope: 'color:threat' }
]
},
'solid-on-bg': {
value: 'var(--toggle-palette-solid)',
scope: 'host' // ← obligatorio: dep está en 'host', no en 'root'
}
}
```
El generador:
1. **Infiere `depends`** parseando `var(--{c}-XXX)` del value.
2. **Valida transitivamente**: `solid-on-bg` (scope `host`) depende
de `palette-solid` (scope `host` o más específico) — OK.
3. **Emite cada declaración bajo su selector**: `host` → `[data-toggle]`,
`color:affirm` → `[data-toggle][data-color='affirm']`, etc.
4. **Falla el build** si el scope del consumer no cubre el del dep.
### Scopes disponibles
| Scope | Selector generado | Cuándo usar |
|---|---|---|
| `'root'` | `:root` | Token estable. Default para bare-string. |
| `'host'` | `[data-{c}]` | Token referencia `var(--{c}-palette-*)` u otro `host` token. |
| `color:${v}` | `[data-{c}][data-color='${v}']` | Override del palette por color value. |
| `variant:${v}` | `[data-{c}][data-variant='${v}']` | Cascada de variante. |
| `state:${v}` | `[data-{c}][data-state='${v}']` | Cascada de estado. |
| `size:${v}` | `[data-{c}][data-size='${v}']` | Cascada de tamaño. |
| `event:${v}` | `[data-{c}][data-event='${v}']` | Token de motion ligado a señal perceptual. |
| `[axis:v, …]` | `[data-{c}][data-X='v'][data-Y='w']` | **Composite** — múltiples condiciones ANDed. |
### Tres formas de declarar un token
```ts
recipes.toggle = {
// (1) Forma corta — scope 'root' implícito (token estable)
'height-md': '32px',
// (2) Forma simple — una declaración con scope explícito
// depends se infiere automáticamente de var() en el value
'solid-on-bg': {
value: 'var(--toggle-palette-solid)',
scope: 'host'
},
// (3) Forma multi-declaración — el MISMO token bajo distintos scopes
// (la realidad CSS de un custom property redeclarado por cascada)
'palette-solid': {
declarations: [
{ value: 'var(--toggle-color-neutral-solid)', scope: 'host' },
{ value: 'var(--toggle-color-affirm-solid)', scope: 'color:affirm' }
]
}
};
```
### Álgebra de scope (`scopeCovers`)
No es un orden total simple. La regla es:
> `consumer scopeCovers dep` ⇔ todo elemento que matchea el consumer's
> scope también matchea el dep's scope.
Equivalentemente: las constraints del dep deben ser un **subconjunto**
de las constraints del consumer.
| consumer | dep | covers? | Razón |
|---|---|---|---|
| `host` | `root` | ✓ | host es más específico, root siempre aplica |
| `host` | `host` | ✓ | mismo scope |
| `host` | `color:affirm` | ✗ | consumer no constraint el color |
| `color:affirm` | `host` | ✓ | host cubre todo el host scope |
| `color:affirm` | `color:affirm` | ✓ | mismo scope |
| `color:affirm` | `color:loss` | ✗ | scopes incompatibles (diferentes values del mismo axis) |
| `color:affirm` | `size:lg` | ✗ | consumer no constraint el size |
| `[color:affirm, size:lg]` | `color:affirm` | ✓ | composite cubre cada componente |
| `[color:affirm, size:lg]` | `size:lg` | ✓ | igual |
### Cross-axis collision detection
Si un token tiene declaraciones en axes incomparables (e.g.
`color:affirm` y `state:on`), un elemento con ambos atributos matchea
ambos bloques. El cascade winner depende de orden de declaración —
silent correctness bug.
El generador detecta esto y **exige una declaración composite** que
desambigüe:
```ts
'bg': {
declarations: [
{ value: 'red', scope: 'color:affirm' },
{ value: 'blue', scope: 'state:on' },
{ value: 'purple', scope: ['color:affirm', 'state:on'] } // ← obligatorio
]
}
```
Sin la composite, build falla:
```
Eidos recipe scope contract violations:
- synth.bg: declarations at scopes color:affirm and state:on can both
apply to the same element. Add an explicit composite declaration
[color:affirm, state:on] to disambiguate cascade order.
```
### Multi-part scope — `parts: [...]` (TSC v2.2)
Cuando `data-color` (u otro axis TSC) NO vive en el root del componente
sino en parts específicos, el generador emite una regla con selector
comma-separado:
```ts
// recipes.select._accent-track
{
parts: ['trigger', 'content'],
declarations: [
{ value: 'var(--select-primary-track)', scope: 'host' },
{ value: 'var(--select-affirm-track)', scope: 'color:affirm' }
]
}
```
Genera:
```css
[data-select-trigger], [data-select-content] {
--_select-accent-track: var(--select-primary-track);
}
[data-select-trigger][data-color='affirm'], [data-select-content][data-color='affirm'] {
--_select-accent-track: var(--select-affirm-track);
}
```
**Cuándo usarlo**: el componente porta `data-color` per-part (típicamente
porque un part viaja por portal y se renderiza fuera del árbol DOM del
otro). Single-part components siguen sin necesitar `parts` — el default
`[data-{c}]` es lo correcto.
**Quién lo usa hoy**: `select` (trigger + content) — único caso real
en el catálogo. Los demás componentes con `data-color` lo declaran en
el root.
### Cross-recipe composition — `composition: { ... }` (TSC v2.2)
Cuando un recipe necesita modificar tokens de OTRO recipe scoped a su
propio cascade, declara un bloque `composition` sibling de los tokens
regulares:
```ts
// recipes.toggle-group
{
gap: 'var(--space-1)',
composition: {
toggle: { // foreign recipe name
targetSelector: '[data-toggle-group-item]', // descendant selector
tokens: {
'palette-solid': {
declarations: [
{ value: 'var(--toggle-affirm-solid)', scope: 'color:affirm' },
{ value: 'var(--toggle-risk-solid)', scope: 'color:risk' }
]
}
}
}
}
}
```
Genera:
```css
[data-toggle-group][data-color='affirm'] [data-toggle-group-item] {
--toggle-palette-solid: var(--toggle-affirm-solid);
}
[data-toggle-group][data-color='risk'] [data-toggle-group-item] {
--toggle-palette-solid: var(--toggle-risk-solid);
}
```
Reglas:
- El CSS variable name se deriva del recipe FORÁNEO
(`--toggle-palette-solid`), no del host. Para tokens privados del
foreign use `_palette-solid` → `--_toggle-palette-solid`.
- El selector es `{host's scope-rule} {targetSelector}` — combinación
ancestor + descendant.
- Las composition declarations DEBEN tener scope ≠ `'root'`. Un
override no-scoped pertenece al foreign recipe, no al composition
block. El validador rechaza root-scoped composition entries.
- Composition NO se valida con el algebra de scope del host (las
composition entries modifican TOKENS del foreign, no del host), pero
sí pasa por el mismo pipeline de validación general
(`validateRecipeComposition`).
**Quién lo usa hoy**: `toggle-group` (modifica `--toggle-palette-*` en
sus items). Pattern reutilizable para futuros wrappers compositivos
(button-group, nav-menu).
### Pipeline de defensas (5 capas)
```
1. tsc --noEmit ← TS bien tipado
2. TSC scope algebra ← ningún token depende de scope más dinámico
3. TSC cross-axis check ← composites obligatorios donde hay collision
4. eidos-lint ← defensa secundaria del CSS generado
5. runtime probe ← confirma comportamiento real en browser
```
---
## 8. Cómo añadir un componente nuevo
Asumiendo que ya tienes el morfo, soma y el scaffolding del wrapper
eidos (`src/uix/eidos/components/{name}/`):
### Paso 1 — Decide qué tokens necesitas
Mira componentes similares (`button`, `toggle`, `switch`). Identifica
qué dimensiones tu componente expone:
- ¿Tiene `data-color`? → palette tokens.
- ¿Tiene `data-variant`? → variant tokens.
- ¿Tiene `data-size`? → size tokens.
- ¿Cuántas partes tiene? → tokens por part.
### Paso 2 — Añade el recipe en `lib/recipes/base.ts`
```ts
// Within THEME_BASE_RECIPE_TOKENS:
'my-component': {
// size tokens — scope 'root' (estables)
'height-md': '36px',
'padding-inline-md': 'var(--space-3)',
'gap': 'var(--space-2)',
'radius': 'var(--radius-md)',
// per-color literal definitions — scope 'root'
'primary-solid': 'var(--color-primary-solid)',
'affirm-solid': 'var(--color-affirm-solid)',
'threat-solid': 'var(--color-threat-solid)',
// palette dinámica — scope 'host' default + overrides por color
'palette-solid': {
declarations: [
{ value: 'var(--my-component-primary-solid)', scope: 'host' },
{ value: 'var(--my-component-affirm-solid)', scope: 'color:affirm' },
{ value: 'var(--my-component-threat-solid)', scope: 'color:threat' }
]
},
// tokens derivados — scope 'host' (deps inferidas)
'solid-bg': {
value: 'var(--my-component-palette-solid)',
scope: 'host'
}
}
```
### Paso 3 — Regenera
```bash
npm run generate:eidos-css
```
Si tu config viola TSC, te avisa al regenerar:
```
Eidos recipe scope contract violations:
- my-component.solid-bg (scope root): dependency 'palette-solid' is only
declared at scopes [host], none of which is reachable from the
consumer's scope.
```
### Paso 4 — Escribe el recipe CSS
`src/uix/eidos/components/my-component/my-component.css`:
```css
[data-my-component] {
/* Private tokens — sólo este recipe los lee */
--_my-component-bg: var(--my-component-solid-bg);
--_my-component-radius: var(--my-component-radius);
display: inline-flex;
align-items: center;
padding-inline: var(--my-component-padding-inline-md);
height: var(--my-component-height-md);
border-radius: var(--_my-component-radius);
background: var(--_my-component-bg);
gap: var(--my-component-gap);
}
[data-my-component][data-disabled] {
opacity: var(--opacity-disabled);
pointer-events: none;
}
```
### Paso 5 — Importa en `index.css`
```css
@import './components/my-component/my-component.css';
```
### Paso 6 — Verifica
```bash
npm run generate:eidos-css
npm test -- src/uix/eidos
npm run morfo:check
node --import tsx/esm scripts/eidos-lint.ts my-component
```
### Anti-pattern común: declarar tokens compuestos en `:root`
```ts
// ❌ INCORRECTO — TSC fallará
'my-component': {
'palette-solid': { value: 'var(--color-neutral-solid)', scope: 'host' },
'solid-bg': 'var(--my-component-palette-solid)' // ← scope 'root' implícito, deps en 'host'
}
// ✓ CORRECTO
'my-component': {
'palette-solid': { value: 'var(--color-neutral-solid)', scope: 'host' },
'solid-bg': {
value: 'var(--my-component-palette-solid)',
scope: 'host'
}
}
```
---
## 9. Cómo definir un theme
Eidos soporta **3 modos** de definir un theme:
### Modo 1 — Patch del theme base (recomendado)
Cambia sólo lo que necesitas; el resto sigue el base:
```ts
import { ActiveEidos } from '$uix/eidos';
ActiveEidos.create({
themeBase: {
semantics: {
color: {
roles: {
primary: 'blue', // primary usa la escala blue Radix
secondary: 'plum'
}
}
},
primitives: {
typography: {
families: {
primary: { family: 'Inter' }
}
}
}
},
applyDom: true
});
```
### Modo 2 — Config completa
Si quieres autoría desde cero:
```ts
import { ActiveEidos, defineEidosConfig } from '$uix/eidos';
const config = defineEidosConfig({
primitives: { /* … */ },
semantics: { color: { /* … */ } },
themes: { /* … */ }
});
ActiveEidos.create({ config, applyDom: true });
```
### Modo 3 — Theme CSS-only (sin TypeScript)
Eidos publica el contrato como CSS vacío para que externals lo
sobrescriban:
```ts
const contract = activeEidos.renderContractCss({
themeSelector: "[data-theme='acme-light']"
});
// Output:
// [data-theme='acme-light'] {
// --scale-blue-9: ;
// --color-primary-solid: ;
// --size-md-control-height: ;
// ...
// }
```
El consumer rellena los valores:
```css
[data-theme='acme-light'] {
--scale-blue-9: #006adc;
--color-primary-solid: var(--scale-blue-9);
--size-md-control-height: 38px;
}
```
Y carga ese CSS junto con el de Eidos. Con `themeSource: 'css'`,
`ActiveEidos` no genera theme propio.
### Persistencia versionada
```ts
const document = activeEidos.toDocument();
// → { kind: 'uix.eidos-config', version: 1, options: {...} }
const json = activeEidos.serialize();
localStorage.setItem('user-theme', json);
// Más tarde
const hydrated = createActiveEidos({
config: JSON.parse(localStorage.getItem('user-theme')!),
prefs, dom
});
```
El document envelope tiene `version` para migraciones futuras.
---
## 10. Cómo overridear tokens en runtime
`ActiveEidos.setCssVariables()` permite override runtime contract-aware:
```ts
activeEidos.setCssVariables({
'--color-primary-solid': 'rebeccapurple',
'size-md-control-height': '40px', // sin -- también vale
'shadow-3': '0 10px 28px rgb(20 20 20 / 0.16)'
});
```
Eidos:
1. **Valida** cada nombre contra `getCssContract()`. Tokens fuera del
contrato lanzan error en modo `strict` (default).
2. **Renderiza** transacionalmente: primero render + valida, luego
reemplaza el `<style>` block runtime.
3. **Aplica** los overrides bajo `:root` (o el selector que pases).
Para variables fuera del contrato (locales de la app):
```ts
activeEidos.setCssVariables(
{ '--my-app-custom': 'value' },
{ strict: false }
);
```
---
## 11. Bundle strategy + `eidos:purge`
`generated/base.css` contiene tokens de TODOS los componentes del
catálogo (~95 components). En producción una app típica usa 5-20.
### El tool
```bash
npm run eidos:purge -- \
--src 'src/**/*.svelte' \
--src 'src/**/*.ts' \
--src 'src/**/*.css' \
--output dist/eidos.purged.css \
--verbose
```
### Cómo decide qué mantener
1. **Foundation siempre kept**: scale, primitive, color, size, opacity,
z-index, shadow, border, radius, space, density, motion, icon,
typography, layout. ~1100 tokens (~95 KB raw / ~11 KB gzip).
2. **Source-scan tokens**: cada `var(--XXX)` y `--XXX:` declaración
encontrada en source → `XXX` pinned.
3. **Component-import detection**: cada `from '...components/{c}'` →
recipe completo de `{c}` pinned.
4. **Data-attr detection**: cada `data-{c}=` (filtrado contra registry
canonical de recipes) → recipe completo de `{c}` pinned.
5. **Cierre transitivo**: si X pinned y X→`var(--Y)`, Y pinned.
Iteración hasta fixed point.
### Resultados medidos
| Perfil | Components | Raw before | Raw after | Reducción | Gzip after |
|---|---|---|---|---|---|
| Minimal (toggle+button+badge) | 3 | 217.7 KB | 97.4 KB | **−55%** | 11.1 KB |
| SaaS típico (10 components) | 10 | 217.7 KB | 116.3 KB | **−46%** | 13.6 KB |
| 5 páginas demo UIX | 7 | 217.7 KB | 107.3 KB | −51% | 12.4 KB |
| Exhaustivo (todos) | 62 | 217.7 KB | ~217 KB | −0.4% | ~25 KB |
**Piso arquitectónico**: ~95 KB raw / ~11 KB gzip (foundation que
toda app necesita).
### Cuándo usarlo
- **En producción**: SIEMPRE. Integra en tu build pipeline.
- **En dev**: opcional. El raw 226 KB es aceptable para iteración local.
- **En SSR**: pre-purge una vez por build, no per-request.
### Limitaciones conocidas
- **Dynamic component selection**: si tu app importa componentes
dinámicamente (`await import(...)`), el scanner los puede perder.
Mitigación: pasa los nombres via `--keep my-component`.
- **`var()` en strings dinámicos**: si construyes `var(--${name})` en
runtime, el scanner no lo ve. Mitigación: declara los nombres
estáticamente en algún archivo escaneable.
---
## 12. Herramientas de validación
| Tool | Comando | Qué valida |
|---|---|---|
| **morfo:check** | `npm run morfo:check` | DOM contracts vs morfo declarations (Playwright walk de 107 demos) |
| **eidos-lint** | `node scripts/eidos-lint.ts {c}` | Recipe CSS selectors vs morfo enum values |
| **eidos-lint-all** | `node scripts/eidos-lint-all.ts` | Igual, todos los componentes |
| **TSC validation** | `npm run generate:eidos-css` (implícito) | Scope algebra + cross-axis collision detection |
| **recipe-css-contract** | `npm test -- recipe-css-contract` | Recipe tokens consumidos + TSC v2 scenarios (17 tests) |
| **component-api-contract** | `npm test -- component-api-contract` | Public API surface por componente |
| **component-visual-attrs** | `npm test -- component-visual-attrs` | Visual data-attrs que el wrapper emite |
| **generated-css** | `npm test -- generated-css` | Estructura del CSS generado |
### Pipeline de validación recomendado pre-commit
```bash
npm run generate:eidos-css # Si tocaste recipes/themes
npm test -- src/uix/eidos # 99/99 tests
npm run check # TS check
npm run morfo:check # DOM contracts (requiere dev server)
node scripts/eidos-lint-all.ts # CSS drift safety net
```
---
## 13. Integración con Sema (`event:*` scope)
Sema emite `data-event-*` durante hold windows perceptuales. Eidos
reacciona vía `events.css` (animations) o vía tokens scoped a `event:*`.
### Tokens scoped a `event:*`
Permite que un token cambie SU VALOR durante una señal:
```ts
recipes.toast = {
// Color base — scope 'host'
'bg': {
value: 'var(--color-surface-raised)',
scope: 'host'
},
// Override durante señal de announce — el toast cambia su bg
// mientras dura la señal perceptual
'bg-during-announce': {
value: 'var(--color-primary-element)',
scope: 'event:announce'
}
};
```
CSS generado:
```css
[data-toast] {
--toast-bg: var(--color-surface-raised);
}
[data-toast][data-event='announce'] {
--toast-bg-during-announce: var(--color-primary-element);
}
```
El recipe usa el token apropiado:
```css
[data-toast] {
background: var(--toast-bg);
}
[data-toast][data-event='announce'] {
background: var(--toast-bg-during-announce);
}
```
### Por qué NO usar `data-motion-ref`
`eidos-motion.md` propuso un atributo nuevo `data-motion-ref` y un
registry separado. **TSC absorbe esa necesidad** sin nueva superficie
DOM: el scope `event:*` se materializa contra `data-event='X'` que
sema ya emite.
### Reduced motion
Eidos lee `data-motion` (la pref global proyectada por `ActivePrefs`):
```css
[data-motion='reduce'] [data-event][data-event-phase='active'] {
animation-duration: 1ms;
transition-duration: 1ms;
}
```
Cobertura per-event vive en `events.css`. Cobertura per-token
(durante señal) puede vivir como composite scope `[event:X, motion:reduce]`
si necesitas afinar.
---
## 14. Motion (estado actual)
### Lo que YA tienes
| Locación | Cobertura |
|---|---|
| `archetypes.css` | 4 transitions baseline (trigger, indicator, thumb, close) |
| `events.css` | 9 keyframes + reactions a `data-event-*` con intent tinting |
| `components/{c}/{c}.css` | transitions/animations específicas del componente |
Total: ~30 keyframes únicos distribuidos en el árbol + transitions
inline en cada recipe.
### Lo que ESTÁ deferred (eidos-motion.md)
Documento de propuesta sin implementar. Define:
- Atributo `data-motion-ref` (NO existe)
- Registry tipado `EidosConfig.motion` (NO existe)
- Drivers `css / eidos-rect / waapi`
**Estado**: superseded por TSC scope `event:*` para el caso de tokens
ligados a señales. Los drivers `eidos-rect` (medición de rects) y
`waapi` (keyframes runtime) siguen diferidos hasta que aparezca un
consumer real (e.g. `presence.genie` fly-to-target).
### Cuándo añadir un keyframe nuevo
Añade a `events.css` si:
- Reacciona a una señal perceptual concreta (`data-event` o `data-event-family`).
- Es transversal (varios componentes pueden compartirlo).
Añade al recipe del componente si:
- Es específico (un slider drag, una calendar swap).
- No reacciona a una señal de sema, sino a un `data-state` transition.
---
## 15. Comparación con librerías de referencia
### Bundle size (app típica con 10 componentes)
| Sistema | Raw | Gzip | Strategy |
|---|---|---|---|
| Tailwind v4 | ~30 KB | ~10 KB | JIT atomic classes |
| **Eidos + purge** | **116 KB** | **13.6 KB** | **JIT custom-property purge** |
| Chakra Panda v3 | ~30 KB | ~12 KB | Build-time JIT |
| Mantine | ~60 KB | ~18 KB | Sin purge |
| Radix Themes | ~80 KB | ~22 KB | Sin purge |
| shadcn/ui | varía | varía | Copy-paste, no centralized |
| **Eidos sin purge** | **218 KB** | **25 KB** | Single CSS |
### Theming features
| Feature | Eidos | Radix Themes | Chakra Panda | Mantine | shadcn | Tailwind v4 |
|---|---|---|---|---|---|---|
| **Token scope as data** | ✅ TSC | ❌ implícito | ⚠️ build-time | ❌ runtime | ❌ N/A | ❌ N/A |
| **Auto-inferencia de deps** | ✅ `var()` parse | ❌ | ✅ types | ❌ | N/A | N/A |
| **Cross-axis collision** | ✅ explícito | ❌ | ⚠️ partial | ❌ | N/A | N/A |
| **Composite scopes** | ✅ `[axis:v, …]` | ❌ | ✅ conditional pairs | ❌ | N/A | N/A |
| **9 color roles canónicos** | ✅ libro | ⚠️ 6 accents | ❌ open | ❌ open | ⚠️ 4 roles | ❌ open |
| **6 size canon coordinated** | ✅ | ⚠️ 1-3 | ⚠️ 5 | ⚠️ 5 | ❌ | N/A |
| **Density runtime** | ✅ 3 levels | ❌ | ❌ | ⚠️ partial | ❌ | ❌ |
| **Contract introspection** | ✅ typed | ⚠️ docs | ✅ Panda | ⚠️ docs | ❌ | ❌ |
| **Runtime override** | ✅ contract-aware | ⚠️ via CSS vars | ❌ | ✅ CSSVarsProvider | ⚠️ via CSS | ❌ |
| **Persistence versioned** | ✅ envelope | ❌ | ❌ | ❌ | ❌ | N/A |
| **Perceptual layer (sema)** | ✅ unique | ❌ | ❌ | ❌ | ❌ | ❌ |
### Mental model
| Sistema | Token philosophy |
|---|---|
| Eidos | 7 layers de indirección, scope-as-contract, perceptual integration |
| Radix Themes | 3 layers, accent runtime swap, no scope contract |
| Chakra Panda | Conditional values build-time, recipe system |
| Mantine | Theme provider runtime, string interpolation |
| shadcn | Plano `--primary` + `.dark`, copy-paste components |
| Tailwind v4 | `@theme` directive, atomic utilities, no tokens compuestos |
**Lectura crítica**: Eidos NO es más simple que Tailwind ni más
ergonómico que shadcn. Es **más expresivo** en la dimensión "qué
puede comunicar un componente". Si tu app solo necesita un color
primario y un dark mode, shadcn es la respuesta. Si tu app necesita
diferenciar perceptualmente entre "guardar borrador" (affirm) y
"borrar permanentemente" (loss) con tokens y animaciones distintas,
Eidos es el sistema.
---
## 16. Anti-patterns que NO debes cometer
### A. Declarar tokens derivados en `:root`
```ts
// ❌ INCORRECTO — el bug del Toggle pre-TSC
'palette-solid': { value: '...', scope: 'host' },
'solid-on-bg': 'var(--my-component-palette-solid)' // scope 'root' implícito
```
TSC lanza error al regenerar. Fix: `scope: 'host'` en el consumer.
### B. Usar nombres con segmento "color-" redundante
```ts
// ❌ DEPRECATED (2026-05-27)
'color-affirm-solid': 'var(--color-affirm-solid)'
// ✅ CORRECTO
'affirm-solid': 'var(--color-affirm-solid)'
```
### C. Inventar roles fuera del canon
```ts
// ❌ NO — success/danger/warning/info son de otros modelos
'success': 'green',
'danger': 'red'
// ✅ Usa los 9 canónicos
'fulfill': 'green', // success → fulfill
'threat': 'red' // danger → threat
```
### D. Definir media-queries que cambien tokens canónicos
```css
/* ❌ NO — md cambia significado por viewport */
@media (max-width: 768px) {
:root { --size-md-control-height: 32px; }
}
/* ✅ Componente elige qué size aplica por viewport */
<Toggle size={{ base: 'sm', md: 'md' }} />
```
### E. Importar `$libs/dom` directamente en eidos
```ts
// ❌ NO
import { foo } from '$libs/dom';
// ✅ Eidos consume vía ActiveEidos.dom
const eidos = ActiveEidos.require();
eidos.dom.apply(...);
```
### F. Tocar `generated/base.css` a mano
Es output. Cualquier cambio se sobrescribe al regenerar. Si necesitas
cambiar algo, edita `lib/themes/base.ts` o `lib/recipes/base.ts`.
### G. Crear nuevas escalas físicas
Las 30 escalas Radix cubren todas las paletas razonables. Si crees
necesitar una nueva, replantéate el role mapping antes.
### H. Re-exportar entre layers
```ts
// ❌ NO — eidos no re-exporta soma
export * from '$soma/components/toggle';
// ✅ Cada layer expone su propio API
```
---
## 17. FAQ — decisiones polémicas
### ¿Por qué el theming no vive en Morfo? ¿No debería Morfo ser source of truth de todo?
Pregunta CRÍTICA — respuesta detallada en [sección 1.bis](#1bis-theming-vive-en-eidos-no-en-morfo-por-diseño).
Resumen: morfo es source-of-truth del **contrato cross-layer** (parts,
events, attrs, archetypes, attr values). Tokens/themes/recipes pertenecen
a Eidos por diseño explícito de la arquitectura (`active_architecture.md`
§9). La regla 2-de-3 lo deriva: los tokens visuales los consume solo
eidos → 1-de-3 → no entra en morfo. La integración eidos↔morfo se hace
**a través del DOM** (los recipes targetean attrs que morfo declara), NO
importando objetos morfo en TS (la regla #6 lo prohíbe explícitamente).
### ¿Por qué 7 capas de indirección? Parece excesivo
Cada capa sirve un override point real:
- Sin capa 1, no puedes traer una paleta Radix custom.
- Sin capa 2, no puedes remapear roles.
- Sin capa 3, no puedes ajustar slots per role.
- Sin capa 4, no puedes overridear un color SOLO para un componente.
- Sin capa 5, no puedes tener palette dinámico por instancia.
- Sin capa 6, no puedes combinar variant × palette.
- Sin capa 7, los recipes mezclan tokens externos con internos.
La capa 4 es la más sospechosa de redundancia. Es candidata a
deprecate si después de 6 meses ningún consumer la usa.
### ¿Por qué TSC y no simplemente convención?
**Convención falla en silencio**. El bug del Toggle (pre-TSC) habría
quedado escondido años. Con TSC, el build falla. Es la diferencia
entre "deberías hacerlo bien" y "no puedes hacerlo mal".
### ¿Por qué no usar Tailwind si es más pequeño?
Tailwind:
- No tiene roles semánticos canónicos (success/danger/warning sí pero
son del mundo Bootstrap).
- No tiene perceptual layer (sema).
- No tiene density runtime.
- No tiene contract introspection programática.
Pero si tu app es simple, **úsalo**. Eidos justifica su complejidad
sólo cuando la app necesita las dimensiones que Eidos cubre.
### ¿Por qué no atomic classes como Tailwind?
Custom properties permiten:
- **Cascada dinámica** (palette overrides en runtime).
- **Theme switching** sin recompile.
- **Persistencia** del user's theme.
- **Composability** con sema (`event:*` scope).
Atomic classes son más comprimibles pero son estáticas. Imposible
hacer `--palette-solid` cambie con `data-color='affirm'` desde
atomic classes sin generar ×N variantes en build.
### ¿Por qué inventar "TSC" y no usar @scope nativo de CSS?
`@scope` (CSS Cascading Modules L6) es bleeding-edge: Chrome 118+,
Firefox 128+, Safari aún no. No es production-ready 2026.
Cuando @scope sea universal, TSC podría re-implementarse encima de
él. La superficie del config (declarations[] + scope) seguiría igual;
sólo cambiaría el CSS emitido.
### ¿Por qué `event:` en TSC y no usar el `data-motion-ref` del doc motion?
Tres razones:
1. **TSC ya existe y funciona**. `data-motion-ref` requeriría un
atributo DOM nuevo, runtime para inyectarlo, registry separado.
2. **Sema ya emite `data-event`**. Reutilizar es 0 coste arquitectónico.
3. **Composable**: `scope: ['event:announce', 'color:affirm']` permite
diferenciar la animación según valencia. `data-motion-ref` perdería
eso o requeriría keys más complejas.
### ¿Cuál es el siguiente paso?
Pendientes deferred:
- Migrar los 5 palette consumers (button, checkbox, switch, radio-group,
toggle-group) a `declarations[]` para uniformidad.
- Implementar el primer caso real de `scope: 'event:*'` (e.g. toast
bg-during-announce).
- Decidir si colapsar la capa 4 (component-color) — defer hasta que
un consumer pida ese punto de extensión.
---
## Referencias
- [`src/uix/eidos/README.md`](./README.md) — el doc de arquitectura
vivo (esta info está duplicada parcialmente; este doc es la canónica).
- [`src/uix/eidos/THEMING_AUDIT_2026-05-27.md`](./THEMING_AUDIT_2026-05-27.md) —
el journal de cómo llegamos aquí.
- [`src/uix/eidos/eidos-motion.md`](./eidos-motion.md) — propuesta
motion (deferred, partially superseded by TSC).
- [`src/uix/eidos/lib/config-types.ts`](./lib/config-types.ts) — la
fuente de verdad del TSC type.
- [`src/uix/eidos/lib/render-css.ts`](./lib/render-css.ts) — el
generador (parsing, scope algebra, cross-axis detection).
- [`src/uix/eidos/lib/recipes/base.ts`](./lib/recipes/base.ts) — el
catálogo de tokens per-component.
- [`src/uix/eidos/lib/themes/base.ts`](./lib/themes/base.ts) — el
theme base (primitives + semantics + themes).
- [`scripts/eidos-purge.ts`](../../../scripts/eidos-purge.ts) — el
purge tool.
- [`src/uix/eidos/recipe-css-contract.test.ts`](./recipe-css-contract.test.ts) —
el test guard (17 tests).
- [`src/uix/active_architecture.md`](../active_architecture.md) —
el contexto UIX completo.
- [`src/docs/GUIA_IMPLEMENTACION_SEMAUIX.md`](../../docs/GUIA_IMPLEMENTACION_SEMAUIX.md) —
la doctrina sema/perceptual (fuente del canon de 9 roles).
---
## 18. Cobertura universal de TSC
**Los 15 componentes con `data-color` están en TSC**. No hay
excepciones arquitectónicas — TSC v2.2 cubre las 3 patrones que
antes vivían fuera del modelo:
| Patrón | Solución TSC v2.2 | Componentes |
|---|---|---|
| `data-color` per-parte (no en root) | `parts: ['x', 'y']` en `RecipeTokenMultiDeclaration` (multi-part scope) | `select` (trigger + content) |
| Composite (variant × color) | `scope: ['variant:X', 'color:Y']` (TSC v2 composite) | `avatar` (root + badge) |
| Cross-recipe override desde ancestor | `composition: { foreignRecipe: { targetSelector, tokens } }` | `toggle-group` (modifica Toggle's palette) |
El guard universal `forbids palette-derived tokens at :root scope`
(en `recipe-css-contract.test.ts`) sigue activo como defensa
secundaria en el CSS final, pero la fuente de verdad es el contrato
de tipos.
### 18.1 Cuándo se añadió cada extensión
- **Multi-part scope** (TSC v2.2): permite que un token cascadee sobre
más de un selector raíz. Necesario cuando `data-color` vive en parts
distintos por razones de portal/cascade (Select Content vive fuera
del árbol DOM del Trigger).
- **Composition** (TSC v2.2): permite que un recipe declare overrides
de los tokens de OTRO recipe, scoped a sus propias condiciones.
Necesario para wrappers compositivos (toggle-group, eventual
button-group, nav-menu, etc.).
Ambas extensiones se validan con el mismo pipeline TSC (scope
algebra + cross-axis collision detection + auto-inferred deps).
---
feat(eidos): variants canon — EIDOS_VARIANTS + per-component lint Variants son canon del eidos, NO del theme. Decisión arquitectónica firmemente sostenida: el vocabulario de variants (solid/outline/ghost/ soft/surface/line/pills) está fijo a nivel del framework — paralelo a las 8 sema families del libro. Theme = retintar lo perceptualmente fijo; cambia QUÉ color es `affirm`, no QUÉ significa `outline`. Cambios: - lib/types.ts: nueva constante `EIDOS_VARIANTS` con los 5 archetypes canónicos (control / selection / chip / marker / tabs). Los 5 union types se derivan via `[number]` indexed access — valor y tipo no pueden desincronizarse. Nueva `EIDOS_VARIANT_VALUES` Set flat con todos los valores canónicos + utilidades cross-component (`plain`, `subtle`). - recipe-css-contract.test.ts: nuevo test "variant CSS selectors per component match the declared type union". Por cada componente: extrae el union type de `components/{c}/types.ts` (soporta literal unions + archetype aliases; cae a advisory mode en Extract<> y conditional types); compara con `[data-{c}][data-variant='X']` selectores en `{c}.css`; reporta typos y unauthorized extensions bidireccionalmente. - THEMING.md §19: nueva sección "Variants son canon del eidos, NO del theme" con argumentación (portabilidad, type safety, archetypes perceptuales paralelos a sema families), tabla de las 3 capas de la cebolla, referencia a `EIDOS_VARIANTS`, comparación con Radix Themes 3.x / Mantine 7 / Chakra v3 / Ark / shadcn. TOC actualizado. - eidos/README.md: tabla de referencia ampliada con §19. - CLAUDE.md: hand-off "2026-05-27 #6 (variants canon)". - CONTINUE.md: nota de la decisión arquitectónica. Variants component-specific permitidos (Banner inline/overlay/ persistent, Spinner bars/dots/ring, Button 'plain'): viven en cada `components/{c}/types.ts` y el lint los valida contra la CSS del componente. Tests: 101/101 pass en `src/uix/eidos`. `npm run check`: los mismos 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>
4 months ago
## 19. Variants son canon del eidos, NO del theme
**Posición arquitectónica firmemente sostenida**: el vocabulario de
variants (`solid`, `outline`, `ghost`, `soft`, `surface`, `line`,
`pills`, etc.) está **fijo en el sistema**. Un theme NO puede:
- Añadir un variant nuevo (no hay un `branded`, `bubble`, `corporate`
que cada theme invente).
- Redefinir el cascade visual de un variant existente (`outline` significa
"bordered restraint" en todos los themes — solo cambia el COLOR del
border, no su geometría).
### Las 3 capas de la cebolla
| Capa | Qué es | Quién la cambia |
|---|---|---|
| **Sema families / intents** | vocabulario perceptual canon del libro (8 families × 6 intents) | NADIE — fijo |
| **Eidos variants** | archetypes visuales perceptuales (5 archetypes shared + variants component-specific) | NADIE — fijo |
| **Eidos color roles** | los 9 roles canon (`primary`/`affirm`/`risk`/…) | NADIE — fijo |
| **Eidos color palette** | qué hex es cada rol | **THEME** |
| **Tokens internos de recipe** | `--toggle-solid-bg` etc. | **App** (override puntual en `EidosConfig.recipes`) |
**Theming = retintar lo perceptualmente fijo**. El theme cambia QUÉ
color es `affirm`, no QUÉ significa `outline`.
### Por qué fijos
1. **Portabilidad de componentes.** `<Toggle variant="outline">` debe
renderizar coherentemente en cualquier theme. Theme-defined variants
romperían eso silenciosamente — un componente que asume `outline`
no funcionaría en un theme que no lo declara.
2. **Type safety = parte del contrato.** Los consumers necesitan
`SelectionVariant = 'solid' | 'outline' | 'ghost'` para autocomplete
y TS errors. Un `Record<string, …>` extensible perdería esa garantía.
Radix Themes 3.x, Chakra v3, Mantine — todas las referencias serias
mantienen variants fijos por componente.
3. **Variants son archetypes perceptuales, paralelos a sema families.**
`solid` = "filled emphasis", `outline` = "bordered restraint",
`ghost` = "ambient transparency", `soft` = "tinted background". Eso
es vocabulario perceptual del framework — no decisión de theme.
4. **Hay 5 archetypes, no infinitos.** El catálogo se cierra; el TS
los rechaza si no son canónicos. Si emerge un nuevo archetype
genuinamente perceptual, se añade a `EIDOS_VARIANTS` — pero al
nivel del framework, no al del theme.
### Single source of truth — `EIDOS_VARIANTS`
`src/uix/eidos/lib/types.ts` declara la constante:
```ts
export const EIDOS_VARIANTS = {
control: ['surface', 'outline', 'ghost'],
selection: ['solid', 'outline', 'ghost'],
chip: ['soft', 'solid', 'outline', 'ghost'],
marker: ['solid', 'soft', 'outline'],
tabs: ['line', 'surface', 'pills']
} as const satisfies Readonly<Record<string, readonly string[]>>;
export type ControlVariant = (typeof EIDOS_VARIANTS.control)[number];
export type SelectionVariant = (typeof EIDOS_VARIANTS.selection)[number];
// …
```
Los 5 unions se DERIVAN de la const — el valor y el tipo no pueden
desincronizarse. Cada componente narrow al archetype apropiado:
```ts
// components/toggle/types.ts
export type ToggleVariant = SelectionVariant;
// components/accordion/types.ts
export type AccordionVariant = ControlVariant;
```
Para narrowing más fino dentro de un archetype:
```ts
export type AlertDialogCancelVariant = Extract<ButtonVariant, ControlVariant>;
```
### Variants component-specific
Algunos componentes tienen vocabularios genuinamente únicos:
- `Banner`: `inline | overlay | persistent` (posicionamiento, no
treatment perceptual).
- `Spinner`: `bars | dots | ring` (geometría del indicador).
- `Button`: añade `'plain'` para inline/link-like — no merece archetype
propio porque solo aparece en Button + Code.
Estos viven en cada `components/{c}/types.ts`. El lint
`recipe-css-contract.test.ts > variant CSS selectors per component
match the declared type union` valida bidireccionalmente:
- CSS usa `[data-{c}][data-variant='X']` → X debe estar en el union.
- Type union declara `'X'` → CSS debería tener entries (advisory).
### Lo que un theme SÍ puede
- Cambiar paletas (`ThemeColorSet.scales`, `ThemeColorSet.roles`).
- Cambiar shadows (`ShadowScale`).
- Cambiar typography styles (`TypographyPrimitiveSet.styles`).
### Lo que un theme NO puede
- Añadir variants. (`ThemeDefinition` no expone `recipes`.)
- Redefinir cascades visuales. (Los recipes son `EidosConfig.recipes`,
parte del bootstrap del app, no del theme.)
- Cambiar roles de color. (Los 9 roles son canon.)
- Cambiar sizes canon. (`SIZE_PRIMITIVE_KEYS` es fijo.)
### Lo que el app SÍ puede (al boot, no per-theme)
- Override de tokens en `EidosConfig.recipes` — cambia el RESULTADO
del cascade visual, no añade un variant nuevo.
- Crear componentes wrappers propios que compongan los primitives de
eidos con className/style custom.
- Cambiar tokens en runtime via `ActiveEidos.setCssVariables()` /
`clearCssVariables()` (per-app variables CSS).
feat(eidos): variants canon — EIDOS_VARIANTS + per-component lint Variants son canon del eidos, NO del theme. Decisión arquitectónica firmemente sostenida: el vocabulario de variants (solid/outline/ghost/ soft/surface/line/pills) está fijo a nivel del framework — paralelo a las 8 sema families del libro. Theme = retintar lo perceptualmente fijo; cambia QUÉ color es `affirm`, no QUÉ significa `outline`. Cambios: - lib/types.ts: nueva constante `EIDOS_VARIANTS` con los 5 archetypes canónicos (control / selection / chip / marker / tabs). Los 5 union types se derivan via `[number]` indexed access — valor y tipo no pueden desincronizarse. Nueva `EIDOS_VARIANT_VALUES` Set flat con todos los valores canónicos + utilidades cross-component (`plain`, `subtle`). - recipe-css-contract.test.ts: nuevo test "variant CSS selectors per component match the declared type union". Por cada componente: extrae el union type de `components/{c}/types.ts` (soporta literal unions + archetype aliases; cae a advisory mode en Extract<> y conditional types); compara con `[data-{c}][data-variant='X']` selectores en `{c}.css`; reporta typos y unauthorized extensions bidireccionalmente. - THEMING.md §19: nueva sección "Variants son canon del eidos, NO del theme" con argumentación (portabilidad, type safety, archetypes perceptuales paralelos a sema families), tabla de las 3 capas de la cebolla, referencia a `EIDOS_VARIANTS`, comparación con Radix Themes 3.x / Mantine 7 / Chakra v3 / Ark / shadcn. TOC actualizado. - eidos/README.md: tabla de referencia ampliada con §19. - CLAUDE.md: hand-off "2026-05-27 #6 (variants canon)". - CONTINUE.md: nota de la decisión arquitectónica. Variants component-specific permitidos (Banner inline/overlay/ persistent, Spinner bars/dots/ring, Button 'plain'): viven en cada `components/{c}/types.ts` y el lint los valida contra la CSS del componente. Tests: 101/101 pass en `src/uix/eidos`. `npm run check`: los mismos 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>
4 months ago
### Cuándo añadir un archetype nuevo
Solo si EMERGE un patrón perceptual repetido en ≥3 componentes que no
encaja en los 5 archetypes existentes. Procedimiento:
1. Documentar el archetype con 1 párrafo describiendo el affordance
perceptual (paralelo a "solid = filled emphasis").
2. Añadirlo a `EIDOS_VARIANTS` en `lib/types.ts`.
3. Export el type derivado.
4. Migrar los componentes consumidores a referenciarlo.
5. Actualizar esta sección.
### Comparación con referentes
| Lib | Variants extensibles por theme | Variants extensibles por app |
|---|---|---|
| **Radix Themes 3.x** | ❌ | ❌ (fijos por componente) |
| **Mantine 7+** | ❌ | ❌ (defaultProps + styles override) |
| **Chakra UI v3 (Panda)** | ❌ | ⚠️ via recipes config (compound variants) |
| **Ark UI** | n/a (100% headless, sin opinión) | n/a |
| **shadcn/ui** | n/a (copy-paste, no framework) | ✓ (copia + edita) |
| **activeUIX** | ❌ | ⚠️ via `EidosConfig.recipes` override (cambia tokens, no añade variants) |
activeUIX se alinea con Radix Themes y Mantine: framework con contrato
fijo, theme con flexibilidad acotada al color/spacing. La extensibilidad
extrema (Tailwind, CSS-in-JS plain) es deliberadamente NO el goal —
porque la promesa del framework es portabilidad perceptual entre apps
y themes.
---
## 20. Correcciones del engine de theming (2026-06-01)
Dos bugs del engine de theming detectados al construir el tema
`untitled-ui` (`web/routes/temas/untitled-ui`) y corregidos **a nivel
engine** (no parcheados en el theme), de modo que aplican a todos los
themes y consumidores.
### 20.1 — Densidad inerte (`data-density` no hacía nada)
**Síntoma**: cambiar `data-density` entre `compact` / `comfortable` /
`spacious` no movía nada en pantalla. El sistema de densidad parecía
muerto.
**Causa**: el generador emitía los escalares de densidad
(`--density-scale`, `--density-space-scale`, `--density-control-scale`,
`--density-content-scale`) y los redeclaraba por `[data-density='…']`,
**pero las primitivas `--space-*` y `--control-height-*` eran px fijos
que nunca los consumían**. Los escalares existían y cambiaban, pero
ningún token los usaba → cero efecto visible.
**Fix** (`lib/render-css.ts`): nuevo helper
`appendDensityScaledDeclarations` que emite `--space-{n}` y
`--control-height-{k}` como `calc(<valor> * var(--density-{space|control}-scale))`.
El valor cero se emite tal cual (`0px`). A `comfortable` el escalar es
`1`, así que el resultado es idéntico al valor crudo — **cero regresión**
para quien nunca cambia de densidad. Las primitivas de tamaño
(`--size-{k}-*`) y el padding de los recipes heredan el escalado porque
referencian `var(--space-*)` / `var(--control-height-*)`.
Resultado (verificado): a `compact` el espaciado y las alturas se
reducen (×0.84 / ×0.90), a `spacious` crecen (×1.16 / ×1.12).
> **Nota**: solo se escalan `space` y `control-height` (los dos ejes
> con escalar dedicado y mapeo claro). La tipografía NO se escala con
> densidad — igual que Radix Themes / Untitled UI, la densidad afecta
> a ritmo y altura de controles, no al cuerpo de texto. **El zoom global
> que SÍ escala la tipografía es un eje aparte (`data-scaling`) — ver §23.**
>
> **Actualización (eje de scaling)**: los escalares `--density-scale` y
> `--density-content-scale` que el generador emitía originalmente fueron
> **eliminados** al introducir el eje `scaling` (§23). La densidad hoy
> emite solo `--density-space-scale` y `--density-control-scale`; el helper
> se generalizó a `appendScaledMetricDeclarations`, que compone
> `calc(<raw> * var(--density-…-scale) * var(--scaling))` — densidad y
> scaling se multiplican.
### 20.2 — `contrast` ilegible sobre sólidos
**Síntoma**: el texto de los botones / badges / banners / cards de
variante `solid` salía oscuro sobre un fondo saturado oscuro
(p. ej. botón primario del base: texto `purple-12` `#402060` sobre
`purple-9` `#8e4ec6` ≈ 2:1, ilegible).
**Causa**: el slot de color `contrast` mapeaba por defecto al **step 12**
("texto de alto contraste", pensado para fondos CLAROS), y los recipes
usan `--color-{role}-contrast` como **color de texto SOBRE el sólido**
(step 9). Step 12 sobre step 9 = oscuro-sobre-oscuro.
**Fix** (`lib/render-css.ts`, loop de slots en `renderThemeCss`): el slot
`contrast`, **cuando usa el valor por defecto**, ahora resuelve a
`var(--color-content-on-solid, var(--primitive-{role}-12))` — el color
on-solid del theme (blanco), con el step 12 como fallback. Un **override
explícito** del slot (`roles: { x: { scale, slots: { contrast: '1' } } }`)
se respeta verbatim, así que roles monocromos que invierten su texto
(p. ej. un primario carbón que apunta `contrast` al step 1) siguen
funcionando.
`--color-{role}-contrast` se consume **exclusivamente** como fg sobre
sólidos (button / badge / banner / card / calendar-range / color-picker
ring) — verificado por grep — así que el cambio es seguro y no afecta a
ningún uso de "texto oscuro sobre fondo claro" (ese es el slot `text`,
step 11).
### Verificación
- `npx vitest run src/uix/eidos`: sin regresión — las únicas fallas son
3 pre-existentes (`words` huérfanos + wrappers, track aparte),
confirmadas con baseline (`git stash` del cambio). El test
`active-eidos-config` se actualizó para asertar la nueva forma
density-aware de `--space-4` / `--control-height-xxs`.
- `npm run generate:eidos-css` regenerado (la densidad vive en el CSS
estático precompilado; el `contrast` vive en el bloque de tema runtime).
---
## 21. Propuesta abierta — modelo de color de dos niveles (RFC, NO implementado)
Tras el sprint de theming surgió una observación de fondo (comparando con
Radix Themes): hoy **cada rol de color exige una escala de 12 pasos**, incluidos
los 5 intents evaluativos (`affirm` / `fulfill` / `risk` / `threat` / `loss`).
Eso obliga a autorar ramps a mano para hues fuera de la librería base (12
escalas) y es propenso a error — un intent es conceptualmente **un color**, no
un ramp interactivo.
La propuesta (dos niveles: accents/neutral ricos + intents de **un solo color
ancla** con slots derivados por `color-mix()`, más ampliar la librería hacia
paridad Radix) está documentada como RFC en
[`COLOR_MODEL_RFC.md`](./COLOR_MODEL_RFC.md). **Estado: propuesta — no
implementada.** El modelo actual descrito en este documento (rol → escala de 12
pasos) sigue siendo el vigente.
---
## 22. Mejoras pendientes del theming
> **Auditoría completa 2026-06-01**: [`THEMING_AUDIT_2026-06-01.md`](./THEMING_AUDIT_2026-06-01.md)
> — informe priorizado (P0–P3) en 6 frentes. Incluye defectos reales verificados
> (tokens de foundation inexistentes, `neutral` ilegible en dark, alpha scales
> fabricadas, tokens de densidad muertos, huecos de tests) más todo lo de abajo.
Backlog vivo de mejoras al sistema. Ordenado por impacto, no por prioridad.
1. **Modelo de color de dos niveles** _(mayor)_ — accents/neutral ricos +
intents de **un solo color ancla** con slots derivados, y ampliar la librería
de paletas hacia paridad Radix (~24–30 escalas). Diseño completo en §21 /
[`COLOR_MODEL_RFC.md`](./COLOR_MODEL_RFC.md). Estado: propuesta.
2. **Variant `surface` vía alpha en vez de `color-mix` opaco** _(menor)_ — hoy
las superficies tintadas por rol se computan **opacas**:
`{role}-surface = color-mix(in srgb, var(--color-surface-overlay) 90%, var(--color-{role}-track))`
(ver `lib/recipes/base.ts`). Sobre fondo plano se ve idéntico a un surface
translúcido, pero **no** funciona al superponerse sobre fondos no uniformes
(filas de tabla a rayas, imágenes, secciones con gradiente). Eidos ya emite
alpha steps `--primitive-{role}-a{1..12}`; la mejora es que el variant
`surface` (y el token `{role}-surface` de los recipes) use esos alpha en lugar
del mix opaco, igualando el `--{color}-surface` translúcido de Radix. Ámbito:
recipes; sin cambio de API pública.
---
## 23. Eje de `scaling` (zoom global) — 2026-06-02
Eje **independiente** de la densidad, en paridad con el `scaling` de
Radix Themes. Diseño completo en [`SCALING_RFC.md`](./SCALING_RFC.md).
### 23.1 — Qué es y en qué se diferencia de la densidad
Son **dos ejes ortogonales** que se multiplican:
| Eje | Atributo | Qué mueve | Tipografía |
| --- | --- | --- | --- |
| **Densidad** | `data-density` (`compact` / `comfortable` / `spacious`) | ritmo de layout (`space`) + altura de controles (`control-height`) | **NO** — el cuerpo de texto queda fijo |
| **Scaling** | `data-scaling` (`90` / `95` / `100` / `105` / `110`) | **zoom global**: `space` + `control-height` + `font-size` + `icon-size` | **SÍ** — escala el cuerpo de texto |
Densidad = "más/menos aire entre cosas, controles más bajos, mismo
texto". Scaling = "agranda/encoge **todo** proporcionalmente", igual que
el zoom del navegador pero acotado al subárbol del tema. Concep­tualmente:
densidad es una decisión de **diseño** (compacto vs holgado); scaling es
una decisión de **accesibilidad / preferencia de tamaño** del usuario.
### 23.2 — Qué escala y qué NO
`--scaling` (default `var(--scaling-100)` = `1`) multiplica **solo
métricas en px** cuyo crecimiento proporcional es correcto:
- ✅ `--space-{n}`, `--control-height-{k}` (también llevan el escalar de densidad)
- ✅ `--font-size-{name}`, `--icon-size-{k}`
**NO** escala (a propósito):
- ❌ `line-height` — es un **ratio sin unidad**; escalar el `font-size`
ya escala el interlineado real.
- ❌ `--radius-*`, `--border-*`, sombras — un zoom de UI **no** engorda
bordes ni radios proporcionalmente (Radix tampoco lo hace); mantenerlos
fijos conserva la nitidez del chrome.
### 23.3 — Generación + proyección
`lib/render-css.ts`:
- `appendScalingDeclarations` emite las constantes `--scaling-{90..110}`
(`STATIC_SCALING` en `lib/primitives/static.ts`) + `--scaling: var(--scaling-100)`
en `:root`.
- `appendScaledMetricDeclarations(declarations, prefix, record, densityScaleVar?)`
envuelve cada métrica en `calc(<raw>[ * var(--density-…-scale)] * var(--scaling))`.
El valor cero se emite tal cual. `space` y `control-height` pasan el
`densityScaleVar`; `font-size` e `icon-size` no (no dependen de densidad).
- `renderScalingBlocks` emite `[data-scaling='90'] { --scaling: var(--scaling-90); }`
… para los niveles ≠ `100`. Como todas las métricas leen `var(--scaling)`,
reescribir esa única variable reproyecta el subárbol entero — **cero
redeclaración por token**.
A `100` el escalar es `1` → idéntico al valor crudo, **cero regresión**
para quien no toca scaling.
### 23.4 — API (`ActiveEidos`)
Simétrica a `density`:
```ts
createActiveEidos({
scaling: '110', // estático
// o reactivo:
scalingSource: { get: () => prefs.scaling, onChange: (fn) => prefs.subscribe(fn) }
})
```
`ActiveEidos` escribe `data-scaling` en el target junto a `data-theme` /
`data-mode` / `data-density`, y lo limpia en `dispose()`. La preferencia
viaja por `ActiveEidosPreferenceSource.getScaling()`; `DEFAULT_SCALING`
es `'100'`.
---
**Última revisión**: 2026-06-02. Si algo en este doc no coincide con
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
el código, el código gana — pero abre un issue para que actualicemos
el doc.

Powered by TurnKey Linux.