|
|
# 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%.
|
|
|
- **Modelo de color**: paleta de 31 escalas (diseñable) → roles de
|
|
|
jerarquía (alias explícito) → intents (auto-derivados por convención
|
|
|
del libro, identidad = step 9). Ver §25.
|
|
|
- **Compatible con** persistencia versionada, themes CSS-only,
|
|
|
runtime overrides, dark/light, density (compact/comfortable/spacious),
|
|
|
scaling (zoom 90–110, eje aparte), 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)
|
|
|
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. [Modelo de color de dos niveles (RFC — RESUELTO en §25)](#21-modelo-de-color-de-dos-niveles-rfc--resuelto-en-25)
|
|
|
22. [Mejoras pendientes del theming](#22-mejoras-pendientes-del-theming)
|
|
|
23. [Eje de `scaling` (zoom global)](#23-eje-de-scaling-zoom-global--2026-06-02)
|
|
|
24. [Correcciones P2 del engine (2026-06-02)](#24-correcciones-p2-del-engine-2026-06-02)
|
|
|
25. [Modelo de color — paleta + roles/intents derivados](#25-modelo-de-color--paleta--rolesintents-derivados-2026-06-02)
|
|
|
26. [Theme builder en runtime — `eidos.applyColorScheme`](#26-theme-builder-en-runtime--eidosapplycolorscheme-2026-06-04)
|
|
|
27. [Salida wide-gamut OKLCH (default-on)](#27-salida-wide-gamut-oklch-default-on-2026-06-04)
|
|
|
28. [Accesibilidad forced-colors + ramp de bordes](#28-accesibilidad-forced-colors--ramp-de-bordes-2026-06-05)
|
|
|
|
|
|
---
|
|
|
|
|
|
## 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): una app rara vez; un **tema de marca** SÍ trae o
|
|
|
amplía su propia paleta (§25.7). Las **31 escalas** por defecto cubren
|
|
|
el caso general.
|
|
|
- **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 | amber | Caution, warning |
|
|
|
| threat | red | Active danger |
|
|
|
| loss | plum | Posterior gravity, deep |
|
|
|
|
|
|
> **Los intents auto-derivan de la paleta** (`CANONICAL_INTENT_SCALES`,
|
|
|
> identidad = step 9): `neutral→gray · affirm→teal · fulfill→green ·
|
|
|
> risk→amber · threat→red · loss→plum`. La jerarquía (`primary` /
|
|
|
> `secondary` / `tertiary`) la elige el tema. Modelo completo en **§25**.
|
|
|
|
|
|
La **paleta** son **31 escalas** de 12 steps + 12 alpha = 24 tokens c/u
|
|
|
(**744 tokens `--scale-*`** — el grueso del bloat del foundation). Sobre
|
|
|
ella, los 9 roles aliasan vía `--primitive-{role}-{step}` (9 × 24 =
|
|
|
**216 primitives**).
|
|
|
|
|
|
### 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 }
|
|
|
);
|
|
|
```
|
|
|
|
|
|
### Builders de sistema completo
|
|
|
|
|
|
Por encima de `setCssVariables` hay dos builders que derivan un sistema entero desde una
|
|
|
semilla y lo escriben como bloque gestionado (siguen el tema activo light/dark):
|
|
|
|
|
|
- **`eidos.applyColorScheme(seed, opts)`** — deriva las 31 escalas + 9 roles desde un color
|
|
|
de marca (`buildScheme`). `clearColorScheme()` revierte.
|
|
|
- **`eidos.applyTypeScale(seed, opts)`** — deriva los 8 `--font-size-*` desde un ratio
|
|
|
modular + base (`buildTypeScale`), opcionalmente fluido (`ratioMax`). `clearTypeScale()`
|
|
|
revierte.
|
|
|
|
|
|
Ambos son puros en `eidos/lib` (`build-scheme` / `build-type-scale`) + un método de
|
|
|
aplicación en `ActiveEidos`. Demos en vivo: `/temas/color` y `/temas/tipografia`.
|
|
|
|
|
|
---
|
|
|
|
|
|
## 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)
|
|
|
|
|
|
> ⚠️ **Superseded (§13 + §14).** El modelo de motion vigente es el de **dos
|
|
|
> momentos** documentado en [`eidos-motion.md`](./eidos-motion.md) (F1–F7): el
|
|
|
> momento `--event` (la firma perceptiva) se declara en `motion.signatures` y se
|
|
|
> genera como CSS contra `data-event-*` directamente — sin el scope TSC `event:*`
|
|
|
> ni el `data-motion-ref` que estas secciones discuten. El motor (`EngineMotion`)
|
|
|
> es un servicio en `arts/motion` (`uix.motion`). Se conservan como contexto
|
|
|
> histórico de la decisión; no son la API actual.
|
|
|
|
|
|
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 escalas físicas sueltas dentro de una app
|
|
|
|
|
|
Las **31 escalas** por defecto cubren las paletas razonables. Un **tema
|
|
|
de marca** SÍ trae su propia paleta como escalas (§25.7) — eso es
|
|
|
legítimo. Lo que NO debes hacer es añadir una escala one-off dentro de
|
|
|
una app cuando remapear un role a una escala existente ya resuelve el caso.
|
|
|
|
|
|
### 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).
|
|
|
|
|
|
---
|
|
|
|
|
|
## 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).
|
|
|
|
|
|
### 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. Modelo de color de dos niveles (RFC — RESUELTO en §25)
|
|
|
|
|
|
> **Resuelto (2026-06-02).** El modelo de color quedó decidido — ver **§25**.
|
|
|
> Se adoptó "paleta rica + capa semántica de alias / auto-derivación" y se
|
|
|
> **descartó** "intent = ancla de un solo color" (Radix no lo hace, y con una
|
|
|
> paleta rica el problema que motivaba el ancla desaparece). Lo de abajo se
|
|
|
> conserva como registro histórico de la propuesta original.
|
|
|
|
|
|
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 original: propuesta.
|
|
|
**Resuelto en §25** — se adoptó paleta rica + alias / auto-derivación y se
|
|
|
descartó el ancla de un solo color.)_
|
|
|
|
|
|
---
|
|
|
|
|
|
## 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 — paleta + roles/intents derivados** _(mayor · resuelto 2026-06-02)_
|
|
|
— adoptado el modelo Radix-style: **paleta** de 31 escalas (diseñable por el
|
|
|
tema) + **roles de jerarquía** como alias explícito + **intents auto-derivados**
|
|
|
por convención del libro (identidad = step 9). Se **descartó** el "intent =
|
|
|
ancla de un solo color". Modelo completo en §25 /
|
|
|
[`COLOR_MODEL_RFC.md`](./COLOR_MODEL_RFC.md).
|
|
|
|
|
|
2. ✅ **Variant `surface`/`soft` vía alpha en vez de tinte opaco** _(resuelto 2026-06-02)_
|
|
|
— el tinte `soft` por rol (Button + Badge `{role}-soft-bg`) se computaba
|
|
|
**opaco** (step-1 `track` + `color-mix` opaco en hover) → no componía sobre
|
|
|
fondos no uniformes. **Resuelto** con tokens derivados `--color-{role}-surface`
|
|
|
(= `--primitive-{role}-a2`) + `--color-{role}-surface-hover` (= `a3`),
|
|
|
translúcidos por construcción. Ver §24.2.
|
|
|
|
|
|
---
|
|
|
|
|
|
## 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. Conceptualmente:
|
|
|
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'`.
|
|
|
|
|
|
---
|
|
|
|
|
|
## 24. Correcciones P2 del engine (2026-06-02)
|
|
|
|
|
|
Dos defectos de calidad de la auditoría
|
|
|
([`THEMING_AUDIT_2026-06-01.md`](./THEMING_AUDIT_2026-06-01.md) P2-2, P2-4),
|
|
|
corregidos **a nivel engine** para que apliquen a todos los temas.
|
|
|
|
|
|
### 24.1 — Texto on-solid ilegible sobre sólidos claros (P2-2)
|
|
|
|
|
|
**Síntoma**: el texto de los botones / badges `solid` de roles con sólido
|
|
|
**claro** (amarillo, ámbar, `risk`=naranja) salía **blanco sobre claro** —
|
|
|
naranja-9 con blanco ≈ 2.3:1, sub-AA.
|
|
|
|
|
|
**Causa**: el slot `contrast` (color del texto SOBRE el sólido) resolvía por
|
|
|
defecto a `--color-content-on-solid` (blanco) para **todos** los roles. Correcto
|
|
|
para sólidos oscuros (purple, red), ilegible para sólidos claros.
|
|
|
|
|
|
**Fix** (`render-css.ts`): pick por **luminancia**. En generación, el engine
|
|
|
calcula la ratio de contraste WCAG (gamma-linealizada, `wcagContrastRatio`)
|
|
|
entre `onSolid` y el **step-9** del rol. Si `onSolid` falla (< 3:1), el slot
|
|
|
resuelve a `--color-content-on-solid-contrast` (un oscuro, nuevo semantic
|
|
|
**opcional** `content.onSolidContrast`, `#1c1917` en base) en vez de blanco.
|
|
|
|
|
|
```
|
|
|
risk (orange #f76b15) → texto #1c1917 = 5.89:1 ✓ (era ~2.3:1 con blanco)
|
|
|
primary (purple) → texto #fff = 5.18:1 ✓ (se mantiene)
|
|
|
threat (red) → texto #fff = 3.91:1 ✓ (convención, ≥3:1)
|
|
|
```
|
|
|
|
|
|
Solo `risk` volcó a oscuro en el tema base; el resto mantiene blanco. Un
|
|
|
override explícito `slots.contrast` se respeta verbatim (p. ej. `neutral`
|
|
|
sigue en step-12). El umbral 3:1 es el mínimo AA para UI / texto grande —
|
|
|
ancla principista, no número mágico.
|
|
|
|
|
|
### 24.2 — Superficies tintadas opacas → translúcidas vía alpha (P2-4)
|
|
|
|
|
|
**Síntoma**: el fondo de la variante `soft` por rol (Button + Badge) era
|
|
|
**opaco** → al superponerse sobre fondos no uniformes (filas a rayas, imágenes,
|
|
|
gradientes) tapaba el fondo en vez de teñirlo.
|
|
|
|
|
|
**Causa**: `{role}-soft-bg` = `var(--color-{role}-track)` (step-1, opaco) y el
|
|
|
hover un `color-mix` opaco.
|
|
|
|
|
|
**Fix**: nuevos tokens de rol derivados, translúcidos por construcción (usan el
|
|
|
alpha compositing-inverse §P1-1, consistente con el sólido):
|
|
|
|
|
|
```
|
|
|
--color-{role}-surface = var(--primitive-{role}-a2) /* soft bg */
|
|
|
--color-{role}-surface-hover = var(--primitive-{role}-a3) /* soft bg hover */
|
|
|
```
|
|
|
|
|
|
Button y Badge `soft` consumen esos tokens. Sobre la superficie por defecto se
|
|
|
ven casi idénticos (a2 ≈ el step-1 anterior); sobre fondos no uniformes ahora
|
|
|
**componen** correctamente.
|
|
|
|
|
|
> **Toast y Tabs NO se tocaron** — aunque la auditoría los listó, son
|
|
|
> **tarjetas**: el toast tiene fondo neutral opaco y la tab-list un
|
|
|
> `surface-default` ya translúcido. La opacidad ahí es correcta por diseño (no
|
|
|
> quieres ver el contenido de la página a través de un toast). La fórmula opaca
|
|
|
> que §22 documentaba mal era la de `soft-bg-hover` de Button, ya migrada.
|
|
|
|
|
|
---
|
|
|
|
|
|
## 25. Modelo de color — paleta + roles/intents derivados (2026-06-02)
|
|
|
|
|
|
Decisiones **cerradas** sobre el modelo de color. Resuelve el RFC §21. Es, 1:1,
|
|
|
el modelo de **Radix Themes**: una **paleta** de escalas + una **capa semántica**
|
|
|
de alias + **override por componente**. Lo único propio es que los **intents**
|
|
|
(capa del libro) **auto-derivan** de la paleta por convención.
|
|
|
|
|
|
### 25.1 — Las tres capas
|
|
|
|
|
|
| Capa | Qué es | Cómo se define |
|
|
|
| --- | --- | --- |
|
|
|
| **Paleta** | librería de escalas de 12 pasos | `--scale-{name}-{step}` (+ alpha `--scale-{name}-a{step}`) · directamente usable · **diseñable por el tema** |
|
|
|
| **Roles** (jerarquía) | `primary` · `secondary` · `tertiary` | **alias explícito** a una escala (decisión de marca · obligatorio) |
|
|
|
| **Intents** | `neutral` + `affirm`/`fulfill`/`risk`/`threat`/`loss` | **auto-derivados** de la paleta por convención del libro · identidad = step 9 · slots derivan normal · override opcional |
|
|
|
|
|
|
Los componentes consumen la capa semántica (`--color-{role}-{slot}`) y pueden
|
|
|
**override** su color a cualquier escala vía la prop `color` / `data-color`.
|
|
|
|
|
|
### 25.2 — Paleta (la fuente, diseñable)
|
|
|
|
|
|
- Escalas **funcionales** de 12 pasos: `1-2` fondos · `3-5` componente · `6-8`
|
|
|
bordes · **`9` sólido** · `10` hover · `11-12` texto. El **representativo** de
|
|
|
una escala es el **step 9** (el sólido), NO el medio geométrico (step 6, que es
|
|
|
un tono de borde lavado).
|
|
|
- **Directamente usable**: cualquier paso es `var(--scale-{name}-{step})`
|
|
|
(p. ej. `var(--scale-green-10)`). **No** existe alias corto `--{name}-{step}`:
|
|
|
dos formas para el mismo valor crearían ambigüedad sobre cuál es la canónica.
|
|
|
- **Diseñable**: la paleta la trae el tema (dominio del diseñador). El framework
|
|
|
envía una paleta por defecto de **31 escalas** (valores exactos de Radix
|
|
|
Colors, en `lib/themes/radix-scales.ts` + `base.ts`) — pero es "la paleta", no
|
|
|
"la de Radix": un tema la reemplaza/amplía. Un color de marca se añade como
|
|
|
**una escala** (autorada o generada), nunca como un valor inline suelto.
|
|
|
|
|
|
### 25.3 — Roles de jerarquía (alias explícito)
|
|
|
|
|
|
`primary` / `secondary` / `tertiary` son decisiones de marca sin color canónico:
|
|
|
el tema **DEBE** mapearlos a una escala de la paleta. Pueden llevar override de
|
|
|
slots (p. ej. un primario monocromo con `slots: { contrast: '1' }`).
|
|
|
|
|
|
### 25.4 — Intents auto-derivados (convención del libro)
|
|
|
|
|
|
- Los 6 intents tienen color canónico definido en el libro *Diseñando lo que
|
|
|
ocurre*. La convención `INTENT → escala` vive en `CANONICAL_INTENT_SCALES`
|
|
|
(`lib/config-types.ts`):
|
|
|
`neutral→gray · affirm→teal · fulfill→green · risk→amber · threat→red · loss→plum`.
|
|
|
- Un intent **omitido** del mapa de roles **auto-deriva** de la paleta por esa
|
|
|
convención (`completeColorRoleMap`, consumido por `render-css` y la validación).
|
|
|
Su **identidad es el sólido (step 9)**; los 9 slots derivan normal. La paleta
|
|
|
debe proveer esas escalas (o el tema overridea el intent mapeándolo explícito).
|
|
|
- **Tipos**: en `ColorRoleMap` la jerarquía es **obligatoria** y los intents
|
|
|
**opcionales** — `Record<HierarchyColorRole, V> & Partial<Record<Intent, V>>`.
|
|
|
- **`neutral`** es el 6º intent pero **sin valencia**: funciona como gris de
|
|
|
superficies/bordes/texto, por eso auto-deriva a una escala gris (no es una
|
|
|
señal valenced). Las 5 valenced llevan la carga.
|
|
|
- Doctrina: **el color EXPRESA el intent, no lo define** — la valencia/activación
|
|
|
la lleva la capa **sema** (sonido/haptic/motion); el color solo aporta la
|
|
|
identidad de hue.
|
|
|
|
|
|
### 25.5 — Override por componente
|
|
|
|
|
|
Cualquier componente acepta `color="..."` (cualquier escala de la paleta) → la
|
|
|
cascada `_accent-*` del recipe remapea sus tokens a esa escala para esa
|
|
|
instancia. Equivalente a `<Button color="grass">` de Radix.
|
|
|
|
|
|
### 25.6 — Por qué se DESCARTÓ el "ancla por rol"
|
|
|
|
|
|
El RFC §21 proponía declarar un intent como un solo hex (`{ anchor }`) y derivar
|
|
|
los slots inline con `color-mix()`. Se **descartó**: Radix no lo hace (genera una
|
|
|
*escala* desde un hex y la aliasa), y con una **paleta rica** el problema que lo
|
|
|
motivaba (autorar 12 pasos a mano para `loss` → el bug de loss=azul) **desaparece
|
|
|
solo**: `loss` simplemente aliasa la escala `plum`, que ya existe en la paleta.
|
|
|
El modelo final es **paleta rica + alias / auto-derivación**, no ancla.
|
|
|
|
|
|
### 25.7 — Framework vs tema
|
|
|
|
|
|
- **Framework**: envía la paleta por defecto (31 escalas Radix) — para el tema
|
|
|
base y para quien no traiga la suya.
|
|
|
- **Tema de marca** (p. ej. Grafito): trae **su propia paleta** + mapea la
|
|
|
jerarquía; los intents auto-derivan. (= Radix Themes: Radix trae su paleta, tú
|
|
|
puedes traer la tuya.)
|
|
|
|
|
|
---
|
|
|
|
|
|
## 26. Theme builder en runtime — `eidos.applyColorScheme` (2026-06-04)
|
|
|
|
|
|
El RFC §6.2 (un seed → todo el sistema) está **implementado** como API de primera
|
|
|
clase. Un app re-tematiza desde UN color de marca con una llamada, sin tocar el CSS:
|
|
|
|
|
|
```ts
|
|
|
const result = eidos.applyColorScheme('#8e4ec6', {
|
|
|
variant: 'tonal', // 'tonal' | 'vibrant' | 'monochrome'
|
|
|
temper: 0.12, // cohesión de intents (mantiene hue)
|
|
|
overrides: { tertiary: '#3e63dd' } // fija un rol; el resto deriva del seed
|
|
|
})
|
|
|
eidos.clearColorScheme() // revierte a los primitives del tema
|
|
|
```
|
|
|
|
|
|
**Qué hace**: compone el motor `uix.color` — `deriveScheme` (Material 3 → jerarquía
|
|
|
+ neutral) → `generateScale` (12 pasos por rol) → APCA on-solid → alpha
|
|
|
compositing-inverse — en un override de la **capa de binding** `--primitive-{role}-*`
|
|
|
(+ `--color-{role}-contrast`). Override del binding **reproyecta** cada
|
|
|
`--color-{role}-{slot}` y el chrome neutral (surface/content/border) aguas abajo. La
|
|
|
**paleta de 31 escalas** y los slots NO se tocan.
|
|
|
|
|
|
**Capas** (matemática pura → composición pura → aplicación DOM):
|
|
|
|
|
|
| Pieza | Dónde | Qué |
|
|
|
| --- | --- | --- |
|
|
|
| matemática | `arts/color` (`$color`) | `deriveScheme` / `generateScale` / `temper` / APCA / alpha — pura, isomórfica |
|
|
|
| composición | `eidos/lib/build-scheme.ts` | `buildScheme(seed, opts)` → `{ variables, roles }` — pura, testeable |
|
|
|
| runtime | `ActiveEidos.applyColorScheme` | resuelve donantes + background del tema activo, escribe el bloque de estilo, **sigue light/dark** |
|
|
|
|
|
|
**Sigue el modo**: las curvas-donantes + el background salen del tema activo, así que
|
|
|
el esquema se **re-deriva en cada `apply()`** (cambio de modo → ramp light vs dark). El
|
|
|
bloque `uix-eidos-scheme` se escribe **después** del de tema para ganar en orden de
|
|
|
cascada.
|
|
|
|
|
|
**Override por rol** + **temper** = doctrina de §25.4 / RFC §6.2: la jerarquía deriva
|
|
|
(override per-rol opcional), los intents **mantienen su hue** y solo afinan
|
|
|
temperatura. `applyColorScheme` devuelve `BuildSchemeResult` (steps hex + `stepsOklch`
|
|
|
+ solid / on-solid / pinned por rol) para introspección de UI.
|
|
|
|
|
|
**Wide-gamut**: el bloque apila **hex fallback + `oklch()`** por paso (vía
|
|
|
`schemeDeclarations`), y `generateScale` retiene el OKLCH raw sin clamp — un seed
|
|
|
vívido (croma > sRGB) sale wide-gamut en P3. Ver §27.
|
|
|
|
|
|
Demo en vivo: `/temas/color` (el builder usa el mismo `buildScheme`). Tests:
|
|
|
`build-scheme.test.ts` + `active-eidos.test.ts`.
|
|
|
|
|
|
---
|
|
|
|
|
|
## 27. Salida wide-gamut OKLCH (default-on) (2026-06-04)
|
|
|
|
|
|
RFC §7 estrategia A, **implementada por defecto**. Cada paso de paleta se emite dos
|
|
|
veces: el **hex como fallback universal** + un hermano **`oklch()`** que gana donde el
|
|
|
navegador lo soporta (Chrome 111+ / Safari 15.4+ / Firefox 113+).
|
|
|
|
|
|
```css
|
|
|
:root {
|
|
|
--scale-purple-9: #8e4ec6; /* fallback sRGB */
|
|
|
--scale-purple-9: oklch(0.5556 0.1829 305.86); /* gana -> gamut del display */
|
|
|
}
|
|
|
```
|
|
|
|
|
|
- **Solo las hojas opacas** `--scale-{name}-{step}` ganan el hermano; las capas
|
|
|
`--primitive-*` / `--color-*` son `var()` (heredan) y las alpha siguen como
|
|
|
`color-mix` / rgba. Valores vacíos / no-color no reciben hermano.
|
|
|
- **sRGB idéntico**: el hex y el `oklch()` derivado de un sRGB pintan el mismo color
|
|
|
(verificado: `--scale-purple-9` → `oklch(...)` pinta `#8e4ec6`). El wide-gamut REAL
|
|
|
aparece cuando el origen excede sRGB (tema OKLCH / esquema generado vívido). La
|
|
|
paleta Radix shipped es sRGB → idéntica hoy; wide-gamut **visible** de la paleta = Fase 3.
|
|
|
- **Default-on, sin flag**: es el comportamiento del framework.
|
|
|
`render-css.ts > appendColorScaleDeclarations`.
|
|
|
- **El generador SÍ produce wide-gamut REAL**: `buildScheme` / `applyColorScheme`
|
|
|
(§26) retienen el OKLCH raw de `generateScale` (sin clamp), así que un seed cuyo
|
|
|
croma excede sRGB renderiza más saturado en P3 que su hex fallback — el bloque apila
|
|
|
**hex + `oklch()`** por paso vía `schemeDeclarations(result, { fallback })`. El demo
|
|
|
`/temas/color` lo demuestra con el slider **vivacidad P3** (badge «fuera de sRGB → P3»
|
|
|
al cruzar el gamut; verificado: croma 0.18 → 0.31).
|
|
|
|
|
|
---
|
|
|
|
|
|
## 28. Accesibilidad forced-colors + ramp de bordes (2026-06-05)
|
|
|
|
|
|
**Forced-colors (Windows High Contrast)** — bajo `@media (forced-colors: active)` el
|
|
|
navegador auto-mapea bordes / texto / fondos a system colors (`forced-color-adjust:
|
|
|
auto`), PERO **elimina `box-shadow`** — y el focus ring de eidos (`--focus-ring`) es un
|
|
|
box-shadow, así que el foco **desaparecía**. Fix: la foundation emite siempre
|
|
|
|
|
|
```css
|
|
|
@media (forced-colors: active) {
|
|
|
:focus-visible { outline: 2px solid Highlight; outline-offset: 2px; }
|
|
|
}
|
|
|
```
|
|
|
|
|
|
Los componentes que ya enfocan con `outline` (p. ej. Button) conservan el suyo por
|
|
|
especificidad; este es el fallback para los de box-shadow. `renderForcedColorsBlock`
|
|
|
en `render-css.ts`.
|
|
|
|
|
|
**`prefers-contrast: more`** (macOS "Aumentar contraste", etc.) — bloque aparte que
|
|
|
**refuerza el chrome neutral** para quien pide más contraste: bordes a pasos más
|
|
|
fuertes (`subtle/default/strong` → neutral 7/8/9) + texto de-enfatizado más legible
|
|
|
(`secondary` → 12, `muted` → 11). Sólidos + texto primario ya son alto-contraste. Usa
|
|
|
`:root:root` (especificidad 0,2,0) para ganar al `:root` del tema sin depender del orden;
|
|
|
referencia `--primitive-neutral-*` (resuelven del cascade; si un tema los omite, la
|
|
|
declaración se ignora — degrada con gracia). Estrictamente aditivo (gated por el media
|
|
|
query) y estrictamente MÁS fuerte, así que no puede regresar el look por defecto.
|
|
|
`renderPrefersContrastBlock` en `render-css.ts`.
|
|
|
|
|
|
**Ramp de bordes** — el slot de rol `border` pasó de **step 6 → step 7**. En la escala
|
|
|
funcional de Radix el 6 es un *separador sutil* y el 7 es el *UI element border*; el 6
|
|
|
se leía lavado en bordes reales (outline / surface / controles). `element` / `hover` /
|
|
|
`active` (3 / 4 / 5) se mantienen (canónicos de Radix para component-bg).
|
|
|
`DEFAULT_COLOR_ROLE_SLOT_STEPS`. Verificado en navegador (checkbox + token
|
|
|
`--color-{role}-border` → step 7).
|
|
|
|
|
|
## 29. Profundidad (depth) — canal unificado + eventful (2026-06-05)
|
|
|
|
|
|
La profundidad es un **canal unificado y eventful**, no tres sistemas sueltos (sombra +
|
|
|
superficie + z). Guía canónica: `DEPTH_ENGINE_RFC.md`. **Dos momentos**:
|
|
|
|
|
|
- **Estado** — `data-depth='{plane}'` aplica un **plano en reposo** (`flush · raised ·
|
|
|
overlay · modal · recessed`) que cohere superficie + sombra + z. Los tokens
|
|
|
`--depth-{plane}-{cue}` **componen los primitivos existentes** (`--color-surface-*`,
|
|
|
`--shadow-*`, `--z-index-*`), así que la mezcla es mode-aware gratis. La regla
|
|
|
`[data-depth]` aplica las señales aditivas seguras (`box-shadow` = gota `shadow` + rim-light
|
|
|
`halo`, más `z-index`); `surface` queda opt-in (no pisa fondos de componente). El **`halo`**
|
|
|
es un rim de borde superior computado en oklab (`color-mix(in oklab, white N%, transparent)`):
|
|
|
invisible sobre superficies claras (manda la gota), señal de elevación sobre oscuras — la
|
|
|
respuesta mode-adaptive a "la sombra miente en dark".
|
|
|
- **Evento** — al **emerger** la sombra crece desde plano → la de reposo (sube); al
|
|
|
**presionar** se aplana (recede). Vive en la *firma* (`present-rise` / `press-squeeze`
|
|
|
sobre `data-event-*`), coordinado con motion + sound + haptic desde **un solo evento**.
|
|
|
Generic: un elemento `flush` (sin sombra) = no-op. Degrada con `prefers-reduced-motion`.
|
|
|
|
|
|
**Jaula abierta**: el set de planos es config-driven (`EidosConfig.depth.planes` —
|
|
|
añade/renombra/retunea); los primitivos siguen accesibles (`box-shadow`/`z-index` crudo a un
|
|
|
paso); la capa eventful es aditiva y anulable (sobreescribe keyframes/signatures). Demo en
|
|
|
vivo: `/temas/profundidad`.
|
|
|
|
|
|
**Adopción** (hecha, 2026-06-05): los componentes elevados consumen el canal — sus tokens de
|
|
|
sombra (`--{c}-…-shadow` en `recipes/base.ts`, o el `box-shadow` directo) componen
|
|
|
`var(--depth-{plane}-shadow), var(--depth-{plane}-halo)`, así que **el halo llega a
|
|
|
popover · dialog · drawer · dropdown/context/navigation-menu · menubar · select · tooltip ·
|
|
|
card · combobox · command · link-preview · words**. La `z-index` la sigue gestionando cada
|
|
|
componente (las bandas z son más finas que los 5 planos) — la adopción es solo de la señal
|
|
|
sombra+halo, **cero riesgo de stacking**. La adopción plena vía atributo `data-depth` (que
|
|
|
unificaría también la z) queda como opción futura.
|
|
|
|
|
|
**Atmósfera** (frost, hecha 2026-06-05): cue `blur` por plano + regla **opt-in**
|
|
|
`[data-depth='{plane}'][data-frost]` → superficie translúcida (`color-mix` 80%) +
|
|
|
`backdrop-filter: blur(var(--depth-{plane}-blur))`. Gated, nunca por defecto (un overlay opaco
|
|
|
sigue opaco salvo que pida `data-frost`). El builder runtime `ActiveEidos.applyDepth(planes)` /
|
|
|
`clearDepth()` (+ `buildDepth` puro, exportado de `$uix/eidos`) retune cualquier cue de plano
|
|
|
en vivo — hermano de `applyColorScheme` / `applyTypeScale`. Demo: `/temas/profundidad`
|
|
|
§Materiales.
|
|
|
|
|
|
**Pendiente** (menor): el cue `scrim` está disponible como token (`--depth-{plane}-scrim`) pero
|
|
|
sin regla cableada — el backdrop dim de los modales lo gestiona hoy cada componente.
|
|
|
|
|
|
## 30. Forma (shape) — continuidad + familias + anidado + eventful (2026-06-05)
|
|
|
|
|
|
La forma es un **canal**, no un número de `border-radius`. Guía canónica:
|
|
|
`SHAPE_ENGINE_RFC.md`. La **magnitud** sigue en `--radius-*` (intacta); shape añade los ejes
|
|
|
que todos dejan planos. **El primer squircle-como-token de la web** (el campo entero es
|
|
|
arco + estático; la continuidad solo existía en Apple, atada a plataforma).
|
|
|
|
|
|
- **Continuidad** — `--shape-smoothing` (exponente superelipse: 1 = arco, 2 = squircle) +
|
|
|
familias vía `data-shape='{family}'` → `corner-shape`: `rounded` (round) · `continuous`
|
|
|
(`superellipse(var(--shape-smoothing))`) · `cut` (bevel) · `scoop`. **Opt-in** (no pisa
|
|
|
círculos/píldoras) y **progresivo**: degrada al arco de `border-radius` donde no hay
|
|
|
`corner-shape` (Chromium 2025+).
|
|
|
- **Armonía anidada** — `[data-shape-nest]` deriva `border-radius: max(0px,
|
|
|
var(--shape-outer-radius) − var(--shape-nest-gap))`: el hijo queda concéntrico al padre (que
|
|
|
expone su radio en `--shape-outer-radius`). **El concéntrico de 4 esquinas requiere radios
|
|
|
finitos**: a `full` (9999px) el radio se recorta a ½ de la dimensión menor *de cada elemento*, así
|
|
|
que un hijo de proporción distinta no puede serlo en las 4. Pero **sí en las superiores**
|
|
|
(`radio_card − gap`) si las inferiores quedan rectas — la geometría del reproductor iOS. El demo
|
|
|
`/temas/forma` lo mide (`ResizeObserver`, porque el cap es valor *usado* no legible en CSS) y lo
|
|
|
aplica al top de la carátula.
|
|
|
- **Eventful (dos momentos)** — la forma en reposo (`data-shape`) + el **morph** al pulsar: la
|
|
|
firma `press-squeeze` cuadra la esquina un instante (`--shape-smoothing` 2→3→2, registrado con
|
|
|
`@property` para que interpole). Cross-modal: un evento mueve escala + sombra + esquina.
|
|
|
Degrada con `prefers-reduced-motion`. No-op en familias no-`continuous`.
|
|
|
- **Jaula abierta** — escala + familias config-driven (`EidosConfig.primitives.shape`); el
|
|
|
`border-radius` crudo siempre a un paso; builder runtime `ActiveEidos.applyShape(seed)` /
|
|
|
`clearShape()` (+ `buildShape` puro, exportado de `$uix/eidos`) para dialar continuidad /
|
|
|
nestGap / familias en vivo. Demo: `/temas/forma`.
|
|
|
|
|
|
**Pendiente** (futuro): adopción por componentes (hoy las recipes usan `--radius-*` con arco;
|
|
|
optar a `data-shape='continuous'` en las superficies rectangulares es una pasada separada, con
|
|
|
cuidado de no squircle-izar avatares/píldoras).
|
|
|
|
|
|
## 31. Estructura (espacio · densidad · escala) — el espacio como ritmo (2026-06-05)
|
|
|
|
|
|
Los sistemas **estructurales** (a diferencia de los expresivos) son **solo-estado** — el
|
|
|
escenario, no el suceso. Guía canónica: `STRUCTURE_ENGINE_RFC.md`. Tres ejes ortogonales:
|
|
|
|
|
|
- **Densidad** — `[data-density='compact'|'comfortable'|'spacious']` reescala el layout (`space`
|
|
|
+ `control-height`) sin tocar la legibilidad del texto. 3 niveles × 2 ejes (config-driven).
|
|
|
- **Scaling** — `[data-scaling='90'..'110']` es el zoom global (incluye tipografía; paridad
|
|
|
Radix), compone con densidad.
|
|
|
- **Espacio (el ritmo)** — `--space-{key}` se emite como
|
|
|
`calc(value · var(--density-space-scale) · var(--scaling))`. El **value** ya no es solo px
|
|
|
plano: `buildSpaceScale(seed)` (puro) + `ActiveEidos.applySpacing(seed)` / `clearSpacing()`
|
|
|
lo regeneran desde **una unidad base** (`base × N`, modular) y opcionalmente **fluido**
|
|
|
(`growth > 1` → cada paso `clamp()` que respira entre 480 y 1280px, reusando el `fluidClamp`
|
|
|
del type scale). **Preserva** la composición density × scaling. Hermano de
|
|
|
`applyTypeScale` — opt-in sobre la escala authored (`STATIC_SPACE` intacta). Exportado de
|
|
|
`$uix/eidos`. Demo: `/temas/estructura`.
|
|
|
|
|
|
**Doctrina**: el espacio es **ritmo, no una tabla de píxeles**. Modular + fluido + compuesto con
|
|
|
densidad × zoom desde una semilla. El campo entero shippea una escala plana estática; el espacio
|
|
|
fluido (que casi nadie hace para el espacio, solo para el tipo) + los tres ejes integrados son el
|
|
|
diferencial. Estructural = solo-estado (sin dos momentos — el modelo eventful es de los canales
|
|
|
expresivos).
|
|
|
|
|
|
**Pendiente** (futuro): `applyTheme(seed)` — una semilla que componga tipo + espacio (ritmo
|
|
|
compartido), capstone del cuarteto→quinteto de builders.
|
|
|
|
|
|
---
|
|
|
|
|
|
**Última revisión**: 2026-06-05. Si algo en este doc no coincide con
|
|
|
el código, el código gana — pero abre un issue para que actualicemos
|
|
|
el doc.
|