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

108 KiB

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 1bis. Theming vive en Eidos, no en Morfo (por diseño)
  2. Las 6 capas del CSS de Eidos
  3. Las 7 capas de tokens
  4. Los 9 roles canónicos de color
  5. El canon de sizes
  6. Convenciones de naming
  7. Token Scope Contract (TSC)
  8. Cómo añadir un componente nuevo
  9. Cómo definir un theme
  10. Cómo overridear tokens en runtime
  11. Bundle strategy + eidos:purge
  12. Herramientas de validación
  13. Integración con Sema (event:* scope)
  14. Motion
  15. Comparación con librerías de referencia
  16. Anti-patterns que NO debes cometer
  17. FAQ — decisiones polémicas
  18. Cobertura universal de TSC
  19. Variants son canon del eidos, NO del theme
  20. Correcciones del engine de theming (2026-06-01)
  21. Modelo de color de dos niveles (RFC — RESUELTO en §25)
  22. Mejoras pendientes del theming
  23. Eje de scaling (zoom global)
  24. Correcciones P2 del engine (2026-06-02)
  25. Modelo de color — paleta + roles/intents derivados
  26. Theme builder en runtime — eidos.applyColorScheme
  27. Salida wide-gamut OKLCH (default-on)
  28. Accesibilidad forced-colors + ramp de bordes
  29. Profundidad (depth) — canal unificado + eventful
  30. Forma (shape) — continuidad + familias + anidado
  31. Estructura (espacio · densidad · escala)
  32. Focus ring — modelo de dos anillos parametrizado
  33. Glifos de stepper themeables (spin-field)
  34. spin-field — visual compartido del stepper-field
  35. Canon de escalas — auditoría de theming (2026-06-15)

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

// ❌ 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:

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 §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 §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     /* bundle HUÉRFANO — recipes ya NO lo consumen; canon vivo = 1:1 (md=16) */
--size-md-font-line-height: 1.45
--size-md-icon-size: 18px     /* = --icon-size-md */
--size-md-padding-inline: 12px
--size-md-padding-block: 8px
--size-md-gap: 8px
--size-md-radius: 6px

Dos md distintos (la confusión que la auditoría señaló): el bundle de control --size-md-font-size = 14px (fuente sm, porque un control de 36px lleva texto compacto — paridad Radix/Material). La escala tipográfica --font-size-md = 16px (body). Comparten el nombre md pero son ejes distintos: control-size vs tipo. Ver los arquetipos size→fuente abajo.

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.

<!-- Correcto -->
<Toggle size={{ base: 'sm', md: 'md' }} />

<!-- Incorrecto -->
@media (max-width: 768px) {
  :root { --toggle-height-md: 32px; }   /* NO redefinas el token del size por viewport */
}

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.

Derivación contenedor→parte: cap en md (norma 2026-06-19)

Cuando una parte deriva su size de su contenedor (p. ej. Dialog.Close hereda el size del dialog), la parte sigue el size del contenedor solo en los pasos por debajo de md; en md y por encima (lg / xl / full) cap a la densidad normal md. Un contenedor más ancho — o full — NO engorda sus controles: full es layout (llena el viewport), no un tamaño de control mayor.

size del contenedor size de la parte
xs / sm xs / sm (sigue el paso)
md / lg / xl / full md (normal)

Coincide con las referencias: Radix separa size (densidad) de width (full); Mantine fullScreen ignora size; Material 3 full-screen es un tipo de layout (top app bar) con controles estándar. Ninguno agranda los controles por ser el dialog full.

Primer consumidor: Dialog.Close — components/dialog/context.ts publica el size del dialog y el Close lo deriva. Una prop size explícita en la parte siempre gana.

Mapeo size→fuente: 1:1 universal (override 2026-06-17)

Supersede los arquetipos control/compact/dense de la Fase 7. El usuario decidió que un framework de grado empresarial necesita una sola fuente de verdad: el texto de control sigue la escala tipográfica 1:1 — font-size-{size} = var(--font-size-{size}) — en TODOS los componentes. Así, cambiar --font-size-md a 15px re-adapta el tema entero sin tocar un solo recipe. El md-control es 16px (ya NO 14: la doctrina "control compacto md=14" queda revocada).

Escala (xs/sm/md/lg/xl/xxl): 12 · 14 · 16 · 18→20 · 24→28 · 32→48 (lib/primitives/typography.ts; lg/xl/xxl fluidas clamp()).

Recipes llevados a 1:1 (2026-06-17): button, badge, breadcrumb, calendar, pagination, radio-group, toolbar, file-upload, tag-group, stepper, toggle, tooltip — más toda la familia de campos (field, spin/date/time/color-field, search/password-field, select, editable, tags-input), ya 1:1 desde el sprint de campos.

Excepciones legítimas (NO son texto-de-control → no aplican 1:1):

  • avatar/marker — la fuente es la inicial dentro del círculo, escalada al diámetro (24→96px): glifo proporcional, no control.
  • accordion — el trigger es un encabezado de sección: usa la escala de prosa --text-N-size (xs→text-2 … full→text-6), progresión propia coherente.
  • words / palabras / chronos — tracks WIP excluidos.

El bundle --size-* (arriba) sigue huérfano (0 consumidores); su --size-md-font-size: 14px ya no refleja la regla — el canon vivo es el 1:1 de los recipes.

Regla dura (guard de coherencia) — recipe-css-contract.test.ts:

Ningún token font-size-* / icon-size-* de recipe puede ser un literal px/rem — DEBE referenciar --font-size-* / --icon-size-* (si no, el texto deja de seguir la escala tipográfica, --scaling y applyTypeScale()).

Cierra el hueco que el guard anterior (solo-CSS) dejaba — los valores de recipe-token se vuelven custom properties, no la propiedad font-size:. (words/palabras/chronos excluidos — track activo.) El refactor a consumir el bundle del arquetipo (en vez de re-declarar el mapeo) queda como follow-up; el guard es lo que impide la deriva.

Campos: input 1:1 + label un paso por debajo (override 2026-06-17)

La familia de campos aplica el 1:1 universal (arriba) al input, y añade una regla propia para el label:

  • Input/control → 1:1 (como todo control): 12 · 14 · 16 · 18→20 · 24→28.
  • Label → un paso tipográfico por debajo del input: 10 · 12 · 14 · 16 · 18. El label es el único elemento del sistema que baja un escalón a propósito — jerarquía label↔input, no la deriva colapsada que se rechazó.
Recipe input label
field (genérico) control-font-size-* 1:1 label-font-size-* un paso abajo → cubre todo lo que envuelve <Field>
spin-field (number/css), date/time/color-field font-size-* 1:1 date/time/color: label propio vía CSS calc; spin: label del Field
search-field, password-field, select, editable, tags-input font-size-* 1:1 label del Field

Label de campos segmentados (solo date/time/color tienen [data-X-field-label]): font-size: calc(1em - (var(--font-size-md) - var(--font-size-sm))) — el input (1em heredado) menos un paso de escala (md−sm = 2px, tokenizado). El Field genérico usa tokens label-font-size-* por-size.

No tocados (ya coherentes): pin-input (cell-font-size-* ya escala), combobox (sin fuente propia). Excluidos: words, chronos. Los no-campos (button/calendar/ badge…) conservan su arquetipo.

Pendiente: en lg el label segmentado (calc → 18) y el del Field (token → 16) divergen 2px porque la lg del input es fluida (18→20). En xs/sm/md (fijos) coinciden. Resolver dando tokens por-size a los segmentados, o quitando la lg fluida del input de campo.

Escala tipográfica ↔ escala de iconos

Son dos escalas paralelas — --font-size-{name} (lib/primitives/typography.ts) y --icon-size-{name} (lib/primitives/static.ts → STATIC_ICON.size) — ambas escaladas por densidad (× --scaling). No son independientes: el icono acompaña al texto.

La regla óptica se validó por ojo, no por fórmula (banco de pruebas /uix/icon-scale-study): el icono pesa un punto por encima del texto — ≈ font + 2 en los tamaños de cuerpo, creciendo hacia ≈ line-height en los grandes (un icono atado a font-size abajo, a la line-height arriba). No es una constante:

size --font-size (px) line-height (px) --icon-size (px) icon − font
xxs 10 15 12 +2
xs 12 18 14 +2
sm 14 20 16 +2
md 16 23 18 +2
lg 20 27 20 0
xl 24→28 34 32 +4
xxl 32→48 50 52 +4

(Los headings lg–xxl son fluidos clamp(); la columna font-size muestra el max desktop. El salto de iconos lg 20 → xl 32 es fiel al salto del texto 20 → 28 — no hay tamaño intermedio porque la tipografía tampoco lo tiene.)

Por eso un componente nunca inventa tamaños de icono en px: declara var(--icon-size-{name}) y hereda esta correlación. El size canónico ya empareja ambos ejes (--size-md-font-size + --size-md-icon-size). Matices por arquetipo:

  • Iconos de control siguen el font 1:1 (override 2026-06-17): el icon-size-{size} de cada recipe de control referencia var(--icon-size-{size}), paralelo al font 1:1. La escala de iconos preserva icon ≈ font+2 en cuerpo (md: font 16 → icono 18). Aplicado a button + search-field. Excepciones: password-field — su icon-size-* NO es un glifo sino la caja del botón visibility-trigger (el glifo es el 65%), control-coupled a propósito; radio-cards — el icono sigue el font del título de la card.
  • Densidad ⊥ tipografía (compact/comfortable/spacious): la densidad escala SOLO layout — space (× --density-space-scale) + control-height (× --density-control-scale). --font-size-* y --icon-size-* NO llevan densidad — solo el zoom global --scaling (Radix-parity) los toca. Consecuencia: a 1:1 el icono queda acoplado al texto en toda densidad (font 16 / icono 18 constantes; solo la caja del control se aprieta: 32.4 / 36 / 40.3). Por eso un icono dimensionado desde --control-height-* (density-coupled) se desacopla del texto — es un anti-patrón salvo que el elemento sea un control (p. ej. el trigger del password-field).
  • Cards / títulos (radio-cards, empty-states): el icono acompaña el font del título, nunca un tamaño inflado a mano — p. ej. radio-cards = 16/16/18/18/20 (sigue su título). Para destacar más se sube el font del título (el icono lo sigue), no se infla el icono.

Cambiar la escala = editar STATIC_ICON + components/icon/create-icon.ts + regenerar (npm run generate:eidos-css). Nada más la consume en crudo.


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
--border-width-{key} Border-width scale (lineal none·thin·medium·thick·heavy = 0/1/2/3/4) — §35 --border-width-thick
--ring-inset-width Default ancho del inset-ring (anillo interior box-shadow) — §35 --ring-inset-width
--shadow-{n} Shadow scale (gota) --shadow-3
--shadow-inset-{key} Inner-shadow / recessed (subtle·deep, mode-aware) — §35 --shadow-inset-subtle
--blur-{key} Blur scale (backdrop/frost, none·sm·md·lg·xl·xxl) — §35 --blur-lg
--gradient-{name} Gradiente nombrado (themeable, role-composed) — §35 --gradient-shimmer
--gradient-angle-{dir} Dirección de gradiente (8 brújulas) — §35 --gradient-angle-to-r
--breakpoint-{key} Breakpoint responsive (fuente = ActiveDom) — §35 --breakpoint-md
--z-index-{key} Z-index layer --z-index-modal
--opacity-{key} Opacidad — escala dual numérica (0..100) + semántica (disabled·muted·…) — §35 --opacity-disabled
--tracking-{key} Letter-spacing scale (incl. caps para MAYÚSCULAS) — §35 --tracking-caps
--leading-{key} Line-height scale --leading-ui
--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:

[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)

Movido a TSC.md (canon visual, E2). El Token Scope Contract —scopes disponibles, las tres formas de declarar un token, el álgebra de scopeCovers, la detección de colisión cross-axis, multi-part scope y cross-recipe composition (v2.2), y el pipeline de 5 defensas— vive ahí como su propio capítulo. Resumen: el TSC decide DÓNDE se emite cada token (raíz, por componente, por color, por evento) y valida al generar que toda dependencia esté disponible en el scope del consumidor.


8. Cómo añadir un componente nuevo

Movido a THEMING_GUIDE.md (guía E4). Los pasos para añadir un componente nuevo —decidir qué tokens necesita, recipe, scope y validación— viven ahí junto a la guía de definir un theme.


9. Cómo definir un theme

Movido a THEMING_GUIDE.md (guía E4).


10. Cómo overridear tokens en runtime

ActiveEidos.setCssVariables() permite override runtime contract-aware:

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):

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

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

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 (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:

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:

[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:

[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):

[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

El sistema de motion de Eidos —el modelo de dos momentos (--event perceptivo durante el hold de una señal + --state para transiciones persistentes), el motor EngineMotion (reubicado a arts/motion, expuesto como uix.motion y consumido por soma y eidos) y cómo se autora un preset— es su propio sistema y vive en eidos-motion.md. Roadmap F1–F7 implementado (2026-06-04). Esta sección cubre sólo lo que toca al theming.

Nota de estado. Versiones previas de este doc describían motion como "deferred" y daban el data-motion-ref / el "TSC event:* scope" (§13) como su futuro. Eso quedó obsoleto: la firma perceptiva migró al registro de signatures (no a events.css) y el motor es hoy un servicio en arts/motion. eidos-motion.md es la referencia canónica y actual.

Superficies arrastrables: el lift de "pickup"

Cuando el usuario agarra y arrastra una superficie, ésta debe subir hacia él (profundidad: "lo he cogido"). Es un cue transversal — NO específico de un componente — así que el factor de escala es un token global de motion, no un literal por recipe:

Token Valor Uso
--motion-scale-lift 1.02 escala de pickup al arrastrar (el único >1 de la familia --motion-scale-*)

Patrón (cualquier draggable lo aplica igual) — gateado por el data-attr de arrastre que su morfo declara (data-dragging, data-grabbed, …), emparejado con una elevación de sombra, y suprimido bajo reduced-motion:

/* float-panel, un thumb de slider arrastrándose, un item sortable, un drawer… */
[data-x][data-dragging] {
  scale: var(--motion-scale-lift);   /* sube hacia el usuario */
  box-shadow: var(--…-shadow-active); /* + eleva la sombra */
}
@media (prefers-reduced-motion: reduce) {
  [data-x][data-dragging] { scale: 1; transition: none; }
}

Reglas:

  • scale (no transform) para componer con la posición, que viaja por translate (propiedades distintas) → el lift no pelea con el arrastre 1:1.
  • El cambio de scale se transiciona (lift al agarrar / settle al soltar); durante el move queda estático (compuesto, sin coste por frame).
  • will-change: translate, scale mientras dura el gesto.
  • Un theme retematiza el lift en STATIC_MOTION.scale.lift (primitives/static.ts) — todos los draggables lo heredan. No redefinir el 1.02 por componente.

Hoy lo consume float-panel ([data-float-panel-content][data-dragging]); un slider/sortable que añada arrastre debe leer el MISMO token, no inventar el suyo.


15. Comparación con librerías de referencia

Movido a THEMING_NOTES.md (E3). La comparación de eidos con Radix, Ark, Mantine y otras vive ahí, junto al FAQ de decisiones.


16. Anti-patterns que NO debes cometer

A. Declarar tokens derivados en :root

// ❌ 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

// ❌ DEPRECATED (2026-05-27)
'color-affirm-solid': 'var(--color-affirm-solid)'

// ✅ CORRECTO
'affirm-solid': 'var(--color-affirm-solid)'

C. Inventar roles fuera del canon

// ❌ 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

/* ❌ 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

// ❌ 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

// ❌ NO — eidos no re-exporta soma
export * from '$soma/components/toggle';

// ✅ Cada layer expone su propio API

17. FAQ — decisiones polémicas

Movido a THEMING_NOTES.md (E3).


Referencias


18. Cobertura universal de TSC

Movido a TSC.md. Las extensiones v2.2 (multi-part scope y cross-recipe composition) que llevan el TSC a cobertura universal viven con el resto del contrato en TSC.md.


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:

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:

// 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:

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. (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 — 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.

  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.

23.1 — Qué es y en qué se diferencia de la densidad

Son dos ejes ortogonales que se multiplican:

Eje Atributo Qué mueve Tipografía
Densidad data-density (compact / comfortable / spacious) ritmo de layout (space) + altura de controles (control-height) NO — el cuerpo de texto queda fijo
Scaling data-scaling (90 / 95 / 100 / 105 / 110) zoom global: space + control-height + font-size + icon-size SÍ — escala el cuerpo de texto

Densidad = "más/menos aire entre cosas, controles más bajos, mismo texto". Scaling = "agranda/encoge todo proporcionalmente", igual que el zoom del navegador pero acotado al subárbol del tema. Concep­tualmente: densidad es una decisión de diseño (compacto vs holgado); scaling es una decisión de accesibilidad / preferencia de tamaño del usuario.

23.2 — Qué escala y qué NO

--scaling (default var(--scaling-100) = 1) multiplica solo métricas en px cuyo crecimiento proporcional es correcto:

  • ✅ --space-{n}, --control-height-{k} (también llevan el escalar de densidad)
  • ✅ --font-size-{name}, --icon-size-{k}

NO escala (a propósito):

  • ❌ line-height — es un ratio sin unidad; escalar el font-size ya escala el interlineado real.
  • ❌ --radius-*, --border-*, sombras — un zoom de UI no engorda bordes ni radios proporcionalmente (Radix tampoco lo hace); mantenerlos fijos conserva la nitidez del chrome.

23.3 — Generación + proyección

lib/render-css.ts:

  • appendScalingDeclarations emite las constantes --scaling-{90..110} (STATIC_SCALING en lib/primitives/static.ts) + --scaling: var(--scaling-100) en :root.
  • appendScaledMetricDeclarations(declarations, prefix, record, densityScaleVar?) envuelve cada métrica en calc(<raw>[ * var(--density-…-scale)] * var(--scaling)). El valor cero se emite tal cual. space y control-height pasan el densityScaleVar; font-size e icon-size no (no dependen de densidad).
  • renderScalingBlocks emite [data-scaling='90'] { --scaling: var(--scaling-90); } … para los niveles ≠ 100. Como todas las métricas leen var(--scaling), reescribir esa única variable reproyecta el subárbol entero — cero redeclaración por token.

A 100 el escalar es 1 → idéntico al valor crudo, cero regresión para quien no toca scaling.

23.4 — API (ActiveEidos)

Simétrica a density:

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 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:

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+).

: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

@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.

Tier de sombra interior (--shadow-inset-*, 2026-06-15): la escala de sombra gana un tier inset mode-aware, distinto de las sombras de gota (exteriores) y de los inset-rings (anillo nítido inset 0 0 0 Npx, otro eje):

Token Light Dark
--shadow-inset-subtle inset 0 1px 2px rgb(15 23 42 / 0.08) inset 0 1px 2px rgb(0 0 0 / 0.30)
--shadow-inset-deep inset 0 2px 4px rgb(15 23 42 / 0.12) inset 0 2px 4px rgb(0 0 0 / 0.45)

El plano recessed lo consume (--depth-recessed-shadow: var(--shadow-inset-subtle)), sustituyendo el color-mix(neutral-contrast …) inline previo — que en dark daba un borde claro (embossado) en vez de un hundido; ahora es mode-correcto (inset oscuro en ambos modos). Referencias: Tailwind inset-shadow-{2xs,xs,sm}, Bootstrap shadow-inset, Chakra inner. Disponible además para estados pressed / wells.

Inset-ring (--ring-inset-width, 2026-06-15): eje hermano pero distinto — un anillo interior nítido (no difuminado), como el inset-ring de Tailwind (inset 0 0 0 Npx <color>). El ancho sale de la escala --border-width-* (--ring-inset-width por defecto thick=2px, retunable por tema / override por uso); el color va por el hook --ring-inset-color. No se puede hacer un token único --ring-inset pre-resuelto: CSS hornea los var() anidados en el scope donde se declara (:root), así que el color/ancho por-elemento no propagaría — la expresión vive en el punto de uso: box-shadow: inset 0 0 0 var(--ring-inset-width) var(--ring-inset-color, currentColor). Consumidores: date-field (focus de segmento), drag-drop (accepting 1px / dragover 2px), float-panel (focus + grabbed + resize-grip), select (item checked+highlighted). Las marcas laterales de un solo lado (range-calendar inset ±2px 0 0 0) no son anillos → se quedan. De paso, este eje da el primer uso real a los pasos thin/thick de --border-width-*.

Escala de blur canónica (--blur-*, 2026-06-15): el desenfoque es un primitivo (STATIC_BLUR → lib/primitives/static.ts), no un px disperso. Valores alineados a Tailwind, escalados por --scaling como --icon-size-*:

Token px = Tailwind
--blur-none 0 —
--blur-sm 4 xs
--blur-md 8 sm
--blur-lg 12 md
--blur-xl 16 lg
--blur-xxl 24 xl

Dos modelos de referencia: Tailwind (escala numérica cruda) y Apple (materiales semánticos ultraThin…thick que acoplan blur+translucidez). Eidos toma el de Tailwind como eje crudo y lo compone en la capa semántica de profundidad: los planos consumen --blur-* (--depth-overlay-blur: var(--blur-lg), --depth-modal-blur: var(--blur-xl)), igual que color separa --scale-* (crudo) de los roles. Un futuro tema "cristal" acopla blur+alpha por plano (el modelo Apple) sobre esta escala. Consumidores ya migrados: planos overlay/modal, tooltip (frost), dialog/drawer (overlay-blur). Nada inventa px de blur a mano.

Gradientes themeables (--gradient-angle-* + gradients, 2026-06-15): eje de dos capas, espejo de Tailwind (que declara 0 gradientes nombrados — solo maquinaria):

  • Direcciones (--gradient-angle-*): las 8 brújulas de Tailwind como ángulos CSS (to-t 0deg · to-tr 45 · to-r 90 · to-br 135 · to-b 180 · to-bl 225 · to-l 270 · to-tl 315).
  • Nombrados (gradients config → --gradient-*): extensibles (jaula abierta: extendEidosConfig({ primitives: { gradients: {…} } })), role/surface-composed → mode-aware vía los tokens que referencian. Default fuerte mínimo: un solo nombrado, --gradient-shimmer (barrido de carga; lo consume image). Un tema añade sus gradientes de marca aquí.

Los gradientes funcionales (color-picker HSV/checkerboard, conic de progress/meter, líneas 1px de cropper/tree-grid, máscara de scroll de tabs, split bicolor de range-calendar, grip de float-panel, barra de carga de command) no son de tema y siguen crudos — no son decorativos. skeleton tinta su shimmer por variante de color (data-driven), así que conserva su gradiente local pero dogfoodea var(--gradient-angle-to-r).

Breakpoints — fuente única + container queries (EidosConfig.breakpoints, --breakpoint-*, 2026-06-15): la fuente de verdad de los breakpoints es el servicio runtime ActiveDom (el dev los setea vía createActiveUix({ dom: { breakpoints } }); BREAKPOINTS_DEFAULT es solo el seed). ActiveEidos threadea dom.breakpoints.current a renderStaticCss, que los emite como tokens --breakpoint-{sm..xxl} y los usa en los @media de tipografía responsive — así el CSS generado deja de congelarse en un const duplicado y sigue los breakpoints configurados. Container queries: una recipe declara overrides por breakpoint en la key reservada container (hermana de composition):

recipes: { card: { container: { md: { 'pad': 'var(--space-6)' } } } }
// → @container (min-width: 768px) { [data-card] { --card-pad: var(--space-6) } }

El generador (emitContainerQueries) usa los mismos breakpoints configurados (px literal — CSS prohíbe var() en condiciones @container/@media, así que la sincronía solo es posible generándolo). Opt-in: un ancestro con data-container activa container-type: inline-size. Eje themeable, 0 consumidores hoy (jaula abierta).

Opacidad — escala coordinada de dos capas (--opacity-*, 2026-06-15): mismo patrón dual que la sombra (numérico + semántico).

  • Numérico (--opacity-{0,5,…,100}, Tailwind step-5): granularidad fina para interfaces etéreas / cristal (capas translúcidas en el tramo bajo).
  • Semántico (los roles que consumen los recipes): ghost 0.3 · disabled 0.4 · scrim 0.45 · muted 0.65 · overlay 0.65 · subtle 0.8 · press 0.85 · hover 0.9 · full 1. disabled = 0.4 (estándar moderno ≈ Material 38%).

Unificación: el estado disabled se renderizaba con ~10 valores distintos (0.45–0.72) en recipes (disabled-opacity) + CSS ([data-disabled]/:disabled). Ahora TODOS consumen var(--opacity-disabled). La deriva ad-hoc de CSS (muted/ghost/subtle) migrada a sus roles. Quedan crudos solo los de animación (spinner keyframe) y scroll-frames (rol no semántico). Retunable por tema, como size/sombra/superficie (decisión del usuario).

Border-width — escala lineal (--border-width-*, 2026-06-15): adoptada la lineal de Bootstrap (none 0 · thin 1 · medium 2 · thick 3 · heavy 4) — la única escala de referencia que tiene el 3px que los componentes usan (Tailwind salta 1/2/4/8). Podados los pasos muertos hairline(0.5) y el viejo medium(1.5) (0 consumidores); medium retuneado a 2, thick a 3, heavy(4) nuevo. Los 3 consumidores de thick(2px) — focus-ring de select, quote-border, separator — + el default de --ring-inset-width movidos a medium(2px, sin cambio visual). Todos los anchos crudos tokenizados (consumo completo de la escala — la tesis de la auditoría): 3px→thick, 2px→medium, 1px→var(--border-width), 1.5px→medium (chevron de navigation-menu) en ~35 ficheros. Así un tema retunea el ancho de borde de una vez (p. ej. --border-width denso) y todos los bordes lo siguen.

Tracking — caps para mayúsculas (--tracking-*, 2026-06-15): añadidos caps 0.04em (micro-tracking canónico de etiquetas en MAYÚSCULAS — el patrón dominante en menús/headings) y widest 0.1em. Los 11 letter-spacing crudos de CSS migrados a sus roles (0.04→caps · 0.05→wider · 0.02→wide · 0.1→widest). El letterSpacing óptico por-tamaño de la escala tipográfica (xxs/xs…) NO se migra: es la corrección óptica intrínseca de cada paso.

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.


32. Focus ring — modelo de dos anillos parametrizado (2026-06-11)

El foco de los inputs estaba implementado distinto en cada componente (el anillo del [data-archetype]:focus-visible del foundation sobre el <input>, anillos ad-hoc [data-x-input]:focus-visible, el color-mix propio del textarea…). Resultado: un doble marco al editar (anillo interior + exterior), inconsistente entre componentes.

Solución — un único modelo de dos anillos, parametrizado a nivel de tema:

  • Token nuevo: --focus-ring-inner-width (= 0 por defecto). Definido en primitives/static.ts > STATIC_FOCUS_RING.innerWidth, tipado en FocusRingPrimitiveSet (config-types.ts), emitido en render-css.ts.

  • El anillo canónico (--focus-ring del foundation y todos los *-focus-shadow de los campos en recipes/base.ts) es ahora dos anillos:

    inset 0 0 0 var(--focus-ring-inner-width) var(--focus-ring-color),                       /* interior */
    0 0 0 var(--focus-ring-offset) var(--color-surface-default),                             /* hueco */
    0 0 0 calc(var(--focus-ring-offset) + var(--focus-ring-width)) var(--focus-ring-color)   /* exterior */
    

    Con inner-width: 0 el anillo interior es invisible → un solo marco exterior.

  • El anillo del foundation [data-archetype]:focus-visible excluye los elementos internos de campo (:not(input):not(textarea):not(select):not([data-archetype='segment'])): su foco lo muestra el control que los envuelve (archetypes.css).

  • Quitados los anillos interiores ad-hoc: [data-css-field-input]:focus-visible, [data-number-field-input]:focus-visible; el textarea pasa a box-shadow: var(--focus-ring).

Para encender el anillo interior en un tema: subir --focus-ring-inner-width > 0 → aparece la segunda línea en todos los inputs a la vez, sin tocar componentes.

Doctrina: el foco es un concepto de tema, no de componente. Dos anillos definidos una sola vez y parametrizados; los componentes no reinventan su anillo.

Backlog — tokens retirados en la unificación

Al unificar, la cascada per-data-color --_{css-field,number-field}-accent-* quedó sin uso (solo la consumía el anillo interior ad-hoc) y se retiró. Quedan registrados aquí por si se quiere reintroducir que css-field / number-field tiñan su foco por data-color (como hacen date/time/color-field con sus segmentos):

Componente Tokens retirados Cascada
css-field --_css-field-accent-border · --_css-field-accent-track · --_css-field-accent-text [data-css-field][data-color='…'] × 8 (primary/secondary/neutral/affirm/fulfill/risk/threat/loss)
number-field --_number-field-accent-border · --_number-field-accent-track · --_number-field-accent-text [data-number-field][data-color='…'] × 8

Para reinstaurarlos: re-declarar el trío en el bloque base + la cascada data-color, y consumir accent-border en el anillo del campo. Hoy ambos usan el --focus-ring-color genérico (consistente con el resto), así que data-color no tiñe su foco — decisión deliberada de la unificación.

33. Glifos de stepper themeables (spin-field) — 2026-06-11

Los botones increment/decrement pintan su glifo desde un token, no desde markup obligatorio. Un trigger sin children renderiza el glifo por defecto vía :empty::before; pasar children lo overridea por instancia. El glifo es decorativo — el botón se etiqueta con su aria-label (morfo), así que content en un pseudo-elemento es seguro (mismo patrón que --date-range-field-separator-glyph).

Cuatro tokens, dos por layout (viven en el recipe compartido spin-field — ver §34):

Token Default Layout
--spin-field-control-increment-glyph '+' split
--spin-field-control-decrement-glyph '−' (\2212) split
--spin-field-control-increment-glyph-stacked '▲' (\25B2) stacked
--spin-field-control-decrement-glyph-stacked '▼' (\25BC) stacked

El CSS resuelve una variable interna --_spin-field-increment-glyph que apunta al token split por defecto y se re-apunta al hermano -stacked bajo [data-steppers='stacked'], de modo que una sola regla content sirve ambos layouts. Un tema retinta/reforma overrideando cualquiera de los cuatro (globalmente o scoped por componente con [data-number-field] { --spin-field-control-… }); el color del glifo ya viaja por --spin-field-control-color* (no se duplica aquí).

Por qué cuatro y no dos: split usa el par horizontal +/−; la columna stacked usa flechas verticales ▲/▼. Un único par no puede tener ambos defaults a la vez, y forzar ▲/▼ en split (o +/− en stacked) rompe la convención. Cada par es independiente.

34. spin-field — visual compartido del stepper-field (number-field / css-field) — 2026-06-11

number-field y css-field son el mismo visual (campo con borde + input + botones increment/decrement + scrubber, layouts split/stacked, sizes/variants/colors, glifos); solo difieren en el modelo de valor (soma: número vs valor CSS). Tener dos recipes + dos CSS clonados causaba drift: refinar uno (botones cuadrados, flush, divisor) dejaba el otro con el look viejo. La respuesta canónica no es duplicar — es compartir estructuralmente, el mismo patrón que toggle-group reusa toggle.

Cómo:

  • Recipe único spin-field en recipes/base.ts → tokens --spin-field-* (geometría, superficie, control, glifos). NO hay --number-field-* / --css-field-*.
  • CSS único components/spin-field/spin-field.css con todas las reglas del stepper-field, seleccionando [data-spin-field*]. Cargado por el @import de foundation en index.css (no tiene .svelte propio que lo auto-importe).
  • Identidad estructural en los morfos de number-field y css-field: cada part declara data-spin-field / -input / -increment-trigger / -decrement-trigger / -scrubber (presence attrs). El Provider los emite vía syncAttrs; los sub-parts (cuyo soma hardcodea sus attrs) los emiten en su getter props. number-field.css y css-field.css quedan como stubs.

Theming por componente: aunque los tokens son compartidos, un tema puede tintar solo uno scopeando el token al data- del componente — [data-number-field] { --spin-field-bg: … } lo hereda el stepper porque vive dentro de ese elemento. El default es compartido.

Resultado: una sola fuente del visual del stepper-field. Un fix se aplica a los dos (y a cualquier futuro spin-field) sin posibilidad de drift. date/time/color-field son segmentados (sin steppers) — comparten solo la superficie del campo, lo que sería un refactor aparte.


35. Canon de escalas — auditoría de theming (2026-06-15)

Sprint de auditoría que llevó las escalas del theming a paridad con las referencias (Tailwind · Material 3 · Apple HIG · Bootstrap · Radix) y, sobre todo, forzó su consumo: la tesis de la auditoría es que una escala canónica que los componentes no consumen (la bypassan con literales) deriva en N variantes del mismo valor. Cada eje es ahora retunable por tema (igual que size/sombra/superficie) y los recipes consumen el token, nunca un literal. Detalle por-eje en el addendum de §29; tokens en la tabla de §6.

Eje Token(s) Canon Decisión clave
Blur --blur-{none,sm,md,lg,xl,xxl} 0/4/8/12/16/24 (Tailwind) numérico crudo; los planos de depth lo consumen (--depth-*-blur)
Inner-shadow --shadow-inset-{subtle,deep} mode-aware (light slate / dark negro) lo usa el plano recessed; ≠ inset-ring
Inset-ring --ring-inset-width + --ring-inset-color inset 0 0 0 var(width) var(color) en el punto de uso un token único pre-resuelto es imposible (CSS hornea el var() anidado en :root)
Gradientes --gradient-angle-* + gradients→--gradient-* 8 direcciones + nombrados role-composed Tailwind ship 0 nombrados → solo shimmer; los funcionales (HSV, conic, líneas) NO son de tema
Breakpoints --breakpoint-{sm..xxl}, EidosConfig.breakpoints fuente = ActiveDom (runtime, dev-settable) ActiveEidos threadea dom.breakpoints al generador; los @media dejan de congelarse
Container queries recipe key container → @container + [data-container] px literal generado (CSS prohíbe var() en @container) jaula abierta, 0 consumidores hoy
Opacidad --opacity-{0..100} + semánticos dual numérico + semántico; disabled 0.4 (≈ Material 38%) unificado (~10 valores de disabled → 1); numérico fino = glass-friendly
Border-width --border-width-{none,thin,medium,thick,heavy} lineal Bootstrap 0/1/2/3/4 (única ref con el 3px real) todos los anchos crudos tokenizados (~35 ficheros)
Tracking --tracking-{…,caps,widest} + caps 0.04em (MAYÚSCULAS) + widest 0.1em 11 letter-spacing crudos migrados; el óptico por-tamaño NO

Incidente registrado: una reescritura masiva por PowerShell (WriteAllText) corrompió 11 ficheros (o→p); recuperados con git checkout + rehechos con la herramienta Edit. Regla: modificar ficheros del repo SOLO con Edit/Write, nunca PowerShell en bloque.

Fase 7 (size→fuente — parcial): documentados los 3 arquetipos canónicos (control · compact · dense, §5) + --size-* como referencia del control. Guard de coherencia activo: ningún font-size-*/icon-size-* de recipe puede ser literal px/rem (cierra el hueco del guard solo-CSS). Arreglados los últimos hardcodes (toggle, avatar → --font-size-*, valores preservados). El icon-size de password-field desde --control-height-* es correcto (es el tamaño del botón reveal, no del glifo) — falso positivo de la auditoría. Deferido: el refactor a consumir el bundle del arquetipo (en vez de re-declarar el mapeo) — grande, con edge-cases (fuentes semánticas por-parte, sistema --text-N de accordion) + edición en paralelo; el guard es lo que impide la deriva mientras tanto.

Bloque C — números mágicos sueltos (z-index · duración · border/ring)

Cierre de los literales que bypasseaban una escala ya existente. Regla: un literal que iguala un paso de escala DEBE consumir el token; nada de "intencionales".

  • z-index de overlays flotantes — combobox (era 80), navigation-menu (era 50) y drag-drop preview (era 99) hardcodeaban su z. Ahora declaran un token de recipe (content-z / preview-z) como ya hacían sus 5 hermanos (popover/select/tooltip/link-preview/dropdown-menu). Estos NO usan la escala global --z-index-* a propósito: son una micro-banda baja (75-80) que la capa flotante de soma espeja leyendo el z computado del wrapper; subirlos a 300/400 rompería el espejo. El fallback redundante , 80 de dropdown-menu se eliminó (el valor vive una vez, en el recipe). Los z-index: 0..5 de apilado local (avatar, tabs, sticky cells) son ordenación relativa, NO mágicos — se quedan.
  • Duración — los que igualaban un paso de la escala la consumen: dialog enter 120ms→var(--duration-fast), exit 280ms→var(--duration-slow) (asimetría rápida-entra/lenta-sale preservada, ya 100% en escala); card emerge 320ms→var(--duration-slow); banner/code-block/link 120ms→fast. Los fallbacks muertos , 220ms/, 720ms (checkbox/button, cuyo recipe ya declaraba el token) se quitaron. Excepción razonada: press-duration 80ms, spinner-duration 720ms, loading-indicator-duration 900ms se quedan como token de recipe — son periodos de animación continua (giro / shimmer) o un press deliberadamente sub-fast, NO transiciones de interacción; la escala de 5 pasos (instant..slow) es para interacciones, no tiene sitio para ellos.
  • Border / ring width — los anchos únicos de borde/ring que igualaban un paso (2px=medium, 3px=thick, 1px=thin) se tokenizaron a var(--border-width-*) (avatar border + badge + carve, drawer drag-ring, slider thumb, grid-list focus-ring, toast accent-stripe → thick, table/menu cell/content border → var(--border-width)). Valor-preservante, cero cambio visual. Lo que se queda como escala dimensional propia (NO es el concepto border-width re-derivado): el ring del avatar sm/md/lg = 1.5/2/3px (el 1.5 quedó fuera de la escala global al podar el 0.5/1.5), ring-thickness 3..10px, track-width, content-width, offsets — escalas locales coherentes, no literales sueltos.

Última revisión: 2026-06-16. Si algo en este doc no coincide con el código, el código gana — pero abre un issue para que actualicemos el doc.

Powered by TurnKey Linux.