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/README.md

47 KiB

Eidos

Eidos es la capa visual de UIX. Cubre lo que en la rama muerta air/ era el "runtime visual" más el sistema de tokens — sin heredar código. Lee del DOM lo que las capas anteriores han escrito (morfo runtime + sema visual channel) y aplica estilos, animaciones y wrappers ergonómicos.

Morfo declara la genética
  ↓
Soma transcribe el comportamiento → DOM (data-state, data-color, aria-*)
  ↓
Sema emite señales perceptivas → DOM (data-event-*) durante el hold
  ↓
Eidos aplica el visual: tokens, themes, recipes, archetypes, wrappers

Eidos nunca importa internals de soma ni de sema. Su fuente de verdad es lo que está escrito en el DOM (parts, data-attrs, ARIA, archetypes, event signals) y los tipos públicos de Soma que necesita para componer wrappers.

Handoff 2026-05-14

Eidos queda congelado a nivel de componentes hasta reauditar la arquitectura de UIX. No tocar src/uix/eidos/components/* salvo orden explicita.

Actualización 2026-05-17: la migración de componentes se reanudó por orden explícita. La regla vigente no cambia: cada componente nuevo debe seguir components/README.md, envolver partes públicas de Soma directamente y añadir sólo superficie visual de Eidos.

Antes de seguir con wrappers o recipes por componente hay que respetar estas decisiones:

  • que contrato minimo consume Eidos desde ActiveUix;
  • ActiveEidos asume authoring, validacion, generacion CSS, persistencia y contexto visual;
  • events es el servicio perceptivo runtime y morfo.translations es el catalogo declarativo de texto owned por el componente;
  • que parte se genera desde codigo y que parte puede venir solo por CSS;
  • como se mantiene la regla de escritura DOM unica en standalone dom:false.

La referencia de arranque esta en ../active_architecture.md, seccion Handoff 2026-05-14.

No es solo CSS

El primer mental model fue "eidos = CSS reactivo". Insuficiente: hay concerns visuales puros (variant, size, layout flags, icon slots) que no son parte del comportamiento headless pero sí son ortogonales al CSS. Eidos los aloja como wrappers Svelte sobre el Soma.

src/uix/eidos/
  active-eidos.svelte.ts      → runtime/contexto visual y generacion CSS
  index.css                  → entrypoint que importa todo el CSS
  archetypes.css             → reglas comunes a [data-archetype=*]
  events.css                 → reacciones a [data-event-*] (sema visual)
  generated/base.css         → salida estatica generada desde EidosConfig base
  themes/fonts.css           → font faces usados por el theme base generado
  lib/                       → soporte de config, recipes, contrato CSS y tipos compartidos
  components/{x}/            → recipe + wrapper + tipos por componente
    {x}.css                    recipe CSS (selectores [data-{x}], etc.)
    {x}.svelte                 wrapper Svelte sobre el componente Soma
    types.ts                   props del wrapper (extiende el contrato público Soma)
    index.ts                   namespace publico (default + partes attached)

lib/ contiene el soporte puro de configuracion visual: primitivas, semantica visual, themes, contrato CSS y render. generated/base.css es el primer artefacto estatico generado desde esa configuracion (npm run generate:eidos-css). Los antiguos contracts/ y themes/base/ CSS se retiraron del arbol activo: el contrato publico se obtiene con ActiveEidos.getCssContract() / renderContractCss() y el base visual sale de generated/base.css. Los antiguos tokens/components/* tambien se retiraron: los nombres de custom properties de recipe (--toast-*, --dialog-*, etc.) siguen siendo el contrato estable, pero sus valores base viven en EidosConfig.recipes y se generan en generated/base.css.

Runtime activo

Eidos tiene una clase activa:

  • ActiveEidos es el runtime activo y contexto visual de los wrappers Svelte. Gestiona configuracion visual pura, primitivas, roles semanticos, themes, validacion, render de CSS y persistencia. Conecta esa configuracion con los servicios de ActiveUix cuando hay contexto: prefs, dom, langs, format y helpers visuales como resolve(...), breakpoint(...) o isBelow(...). Si applyDom esta activo, inyecta/quita <style data-uix-eidos> usando uix.dom.

Regla de dependencia: un componente en src/uix/eidos/components/* no importa getActiveUix() directamente. Consume ActiveEidos.require() y solo conoce la superficie visual de Eidos.

Regla de ownership: ActiveEidos no crea servicios compartidos. En modo contexto, ActiveEidos.create(...) obtiene esos servicios de ActiveUix; si un wrapper requiere uno que no existe, lanza error. En modo explicito, createActiveEidos(...) recibe servicios ya construidos. Con applyDom:false queda como runtime sin escritura DOM: puede renderizar, serializar y validar CSS sin insertar estilos.

Matiz importante: prefs, langs y format son opcionales durante la construccion porque ActiveEidos tambien sirve para generar/serializar CSS. Los getters activeEidos.prefs y activeEidos.langs si fallan temprano si un wrapper visual los lee y no fueron inyectados. dom solo es obligatorio cuando applyDom esta activo.

La configuracion vive en EidosConfig:

interface EidosConfig {
	primitives: PrimitiveSet;
	semantics: SemanticSet;
	themes?: ThemeMap;
	recipes?: RecipeTokenSet;
}

Para authoring incremental sobre el theme base existe EidosConfigPatch. No sustituye al objeto completo: lo extiende de forma profunda, preservando lo no declarado y reemplazando arrays completos (por ejemplo fallbacks de una familia tipografica).

const config = createThemeBaseEidosConfig({
	primitives: {
		typography: {
			families: {
				primary: { family: 'Inter' }
			}
		}
	},
	semantics: {
		color: {
			roles: {
				primary: 'blue'
			}
		}
	}
});

Si la app quiere partir de cero, usa defineEidosConfig() con un EidosConfig completo y lo pasa a ActiveEidos.create({ config }). Si quiere partir del theme base, usa createThemeBaseEidosConfig(patch) o directamente ActiveEidos.create({ themeBase: patch }).

primitives contiene las bases no semanticas: escalas de color de 12 pasos, size map canonico, espacios, alturas de control, radios, borde, opacidad, z-index, focus ring, layout, tipografia, sombras, motion e iconos. semantics.color.roles mapea esas escalas a los roles canonicos:

primary · secondary · tertiary · neutral · affirm · fulfill · risk · threat · loss

No hay danger, success, warning ni info: esos nombres pertenecen a otros modelos. En Eidos los componentes hablan por intents y jerarquia visual.

recipes contiene los aliases publicos de cada recipe activa. No define selectores ni estados de componente: solo valores para custom properties que las recipes CSS consumen. Por ejemplo, recipes.tooltip['content-z'] genera --tooltip-content-z; recipes.dialog['overlay-bg'] genera --dialog-overlay-bg. Asi los componentes mantienen su CSS estable y el tema puede persistir/editar esos valores desde el mismo objeto de authoring.

El contrato recipe/CSS se valida en recipe-css-contract.test.ts: si una recipe CSS consume --{component}-*, ese alias debe existir en EidosConfig.recipes; y si el theme base declara un alias publico, debe estar consumido por el componente o por otro token compuesto. No hay reservas fantasma ni paletas que el componente no pueda activar desde su API real.

Para editores de theme, ActiveEidos.listRecipes() enumera los componentes con recipe tokens y ActiveEidos.getRecipeTokens(component) devuelve una copia defensiva del mapa de aliases. No muta el config interno.

La decision vigente es mantener recipes como mapa plano de aliases (RecipeTokenSet). Subir una recipe a tipo estructurado propio solo se justifica cuando exista un builder o consumer real que necesite semantica interna; mientras las recipes sigan siendo CSS plano, el contrato publico es el custom property generado.

ActiveEidos genera tambien la escala alpha de cada paleta fisica:

--scale-blue-a1 … --scale-blue-a12
--primitive-primary-a1 … --primitive-primary-a12

Si el theme declara color.alphaScales, esos valores se respetan. Si no, se derivan desde el paso sólido de la escala (--scale-{name}-9) con una progresion canonica de opacidad. Esto permite usar tintas translucidas para overlays, rings, hovers suaves o scrims sin que cada recipe invente su propia formula.

Size canonico

Size es discreto y estable:

xxs · xs · sm · md · lg · xl · xxl · full

full es semantica de layout y no genera primitiva fisica. La config mapea solo xxs..xxl a tokens coordinados:

--size-md-control-height
--size-md-font-size
--size-md-font-line-height
--size-md-font-letter-spacing
--size-md-icon-size
--size-md-padding-inline
--size-md-padding-block
--size-md-gap
--size-md-radius

La regla es: md no cambia de significado por viewport. Lo responsive decide que size activo se usa (ResponsiveProp<Size>), no redefine los tokens de md. Porcentajes, vw, dvh, clamp() o min() pertenecen a layouts como full, panels y containers; no al size canonico de controles, iconos o tipografia.

Primitivas transversales

El bloque static ya genera las primitivas compartidas que suelen aparecer en Radix Themes, Ark/Panda, Chakra, Tailwind o shadcn como foundation tokens:

  • layout: anchuras de container, padding inline de container, anchuras de contenido y ratios canonicos. Genera: --container-width-*, --container-padding-inline, --content-width-* y --aspect-ratio-*.
  • density: escalas compact · comfortable · spacious para que las recipes puedan ajustar espacio, altura de control o contenido sin redefinir los tokens canonicos. Genera --density-{key}-* y aliases activos como --density-space-scale.
  • border: anchuras none · hairline · thin · medium · thick, estilos solid · dashed · dotted y aliases --border-width, --border-style, --border.
  • opacity: 0 · muted · disabled · scrim · overlay · hover · press · full.
  • zIndex: base · raised · sticky · dropdown · popover · tooltip · modal · toast.
  • shadow: escala fisica 1..6 más aliases semanticos none · subtle · raised · overlay por theme.

La regla es la misma que en size: estos tokens no cambian de significado por breakpoint. Un componente o wrapper puede elegir otro token en un viewport concreto, pero --shadow-3, --opacity-disabled o --z-index-modal siguen siendo la misma coordenada del sistema.

El bridge responsive vive en ActiveDom/ActiveEidos: los wrappers usan ActiveEidos.resolve(...), breakpoint(...), isAtLeast(...) e isBelow(...) para decidir que token canonico aplica en cada viewport. --container-width-md o --content-width-lg no se recalculan por responsive; lo que cambia es la eleccion del token. La densidad sigue el mismo principio: ActiveEidos proyecta data-density y publica scalars activos:

:root {
	--density-compact-space-scale: 0.84;
	--density-comfortable-space-scale: 1;
	--density-spacious-space-scale: 1.16;
	--density-space-scale: var(--density-comfortable-space-scale);
}

[data-density='compact'] {
	--density-space-scale: var(--density-compact-space-scale);
}

Una recipe puede usar calc(var(--space-4) * var(--density-space-scale)). --space-4 no cambia de significado; cambia la policy visual activa.

themes permite sobrescribir escalas, roles, superficies, contenido, bordes, focus y sombras por theme. El theme base actual expone base-light y base-dark; defaultActiveEidosThemeResolver usa el theme activo si existe como id exacto, preserva ids externos ya cualificados (*-light, *-dark) y, si no, prueba ${theme}-${mode}.

ActiveEidos no persiste CSS en disco. Si applyDom esta activo, escribe en el DOM:

  • ${styleId}-static con primitivas estables (renderStaticCss()).
  • ${styleId}-theme con el theme resuelto cuando procede de la config.

themeSource decide de donde salen los valores de theme:

  • 'auto' (default): genera CSS si la config conoce el theme; si no lo conoce, asume que viene de CSS externo y deja solo los tokens static.
  • 'config': modo estricto; si el theme resuelto no existe en la config, renderThemeCss() lanza error.
  • 'css': nunca genera CSS de theme; el integrador aporta los valores con CSS externo que respeta el contrato de custom properties.

Para inspeccionar o publicar ese contrato, ActiveEidos expone dos superficies:

  • getCssContract() devuelve el contrato estructurado, typed, con cada token (name, cssVar, scope, category, path). Es la superficie pensada para editores de theme, validadores, tooling o persistencia de usuario.
  • renderContractCss() renderiza ese mismo contrato como CSS vacío para documentar o bootstrappear themes externos.
  • renderCssVariables() acepta un mapa de custom properties y lo convierte en CSS runtime validado contra el contrato de Eidos.
const tokens = eidos.getCssContract();
const recipes = eidos.listRecipes();
const tooltipTokens = eidos.getRecipeTokens('tooltip');
const contract = eidos.renderContractCss({
	staticSelector: ':root',
	themeSelector: "[data-theme='acme-light']"
});

El resultado no asigna valores reales; declara las custom properties que un theme CSS-only puede aportar o sobrescribir:

[data-theme='acme-light'] {
	--scale-blue-9: ;
	--scale-blue-a9: ;
	--primitive-primary-9: ;
	--primitive-primary-a9: ;
	--color-primary-solid: ;
	--size-md-control-height: ;
	--border-width-thin: ;
	--opacity-disabled: ;
	--z-index-modal: ;
	--shadow-3: ;
}

La persistencia usa un envelope versionado:

const document = eidos.toDocument();
const json = eidos.serialize();

const hydrated = createActiveEidos({
	config: JSON.parse(json),
	prefs,
	dom
});

El documento tiene forma { kind: 'uix.eidos-config', version: 1, options }. EidosConfig no lleva version dentro: sigue siendo configuracion visual pura. Si el shape cambia en el futuro, se sube EIDOS_CONFIG_DOCUMENT_VERSION; Eidos no intenta compatibilidad silenciosa con versiones no soportadas. La app decide dónde guardar ese documento (prefs, backend, archivo, etc.) y qué metadata de producto lo envuelve (name, owner, timestamps).

Al hidratar un documento como runtime (config: document, readEidosConfigFromDocument(...) o parseEidosConfigFromJson(...)), Eidos valida también las options contra el contrato completo de EidosConfig. parseEidosConfigDocument(...) queda como parser de envelope: confirma kind/version/options, pero no convierte ese envelope en configuracion visual usable por si solo.

Integracion normal dentro de un arbol con ActiveUix:

const uix = createActiveUix({
	langs: { schema, defaultLocale: 'es' }
});

setActiveUix(uix);

ActiveEidos.create({
	themeBase: {
		semantics: {
			color: {
				roles: { primary: 'blue' }
			}
		}
	},
	themeSource: 'auto',
	themeResolver,
	styleId: 'uix-eidos'
});

config acepta un EidosConfig completo o un EidosConfigDocument. themeBase acepta solo un patch sobre el theme base. Son mutuamente excluyentes para que no haya ambiguedad entre "config completa" y "override del base".

theme en Eidos nombra la familia/theme visual (base, acme, acme-light, etc.). mode nombra el esquema efectivo light | dark y density nombra la ergonomia visual activa. Cuando applyDom esta activo, ActiveEidos proyecta data-theme, data-mode y data-density en el documento a traves de ActiveDom.

mode y density se inyectan como fuentes visuales, no como dimensiones de prefs:

const eidos = ActiveEidos.create({
	theme: 'base',
	modeSource: {
		get: () => colorMode,
		onChange: (handler) => subscribeColorMode(handler)
	},
	densitySource,
	applyDom: true
});

Si no se pasa modeSource, ActiveEidos usa prefers-color-scheme con fallback light. Si no se pasa densitySource, usa comfortable. No leer ni escribir uix.prefs.theme para UIX: el modo visual pertenece a Eidos.

Un theme CSS-only puede vivir fuera de TypeScript:

[data-theme='acme-light'] {
	--scale-blue-9: #006adc;
	--scale-blue-a9: color-mix(in srgb, var(--scale-blue-9) 56%, transparent);
	--primitive-primary-9: var(--scale-blue-9);
	--primitive-primary-a9: var(--scale-blue-a9);
	--color-primary-solid: var(--primitive-primary-9);
	--font-family-primary: Inter, system-ui, sans-serif;
	--size-md-control-height: 38px;
	--shadow-3: 0 8px 24px rgb(15 23 42 / 0.12);
}

Para pasar valores desde runtime sin recompilar:

activeEidos.setCssVariables({
	'--color-primary-solid': 'rebeccapurple',
	'size-md-control-height': '40px',
	'shadow-3': '0 10px 28px rgb(20 20 20 / 0.16)'
});

ActiveEidos lo escribe en ${styleId}-variables como un <style> gestionado. Por defecto valida los nombres contra getCssContract(); si una app necesita variables locales fuera del contrato puede usar { strict: false }. La actualizacion es transaccional: primero renderiza y valida el siguiente bloque, y solo reemplaza el estado runtime si el mapa es usable. Un token desconocido no deja el style block anterior a medias.

No crear ActiveEidos desactiva la capa visual runtime de Eidos. Los componentes Soma siguen funcionando headless porque el comportamiento pertenece a Soma.

Qué consume

De morfo (declaración)

Pieza Eidos la usa para
parts[].kebab selectores [data-{component}-{kebab}]
parts[].archetype reglas transversales [data-archetype=trigger]
parts[].states + data[].values variantes [data-state=open]
parts[].data con data-starting-style / data-ending-style hooks de animación enter/exit
events[].name selectores [data-event=dismiss], [data-event^=commit]
events[].semantic.family + .intent tinta semántica de transiciones
events[].prewrite[] (e.g. data-last-action) tintar exit anim por causa
focus.trap hint de layout para overlays

De Soma

Los wrappers de components/* importan las partes públicas de Soma directamente ($soma/components/{x}) y sólo añaden superficie visual de Eidos: tokens, recipes, layout shells y data-attrs de presentación. No hay facade intermedio por componente. Ejemplo:

// eidos/components/toggle/types.ts
import type { ProviderProps } from '$soma/components/toggle';
export type ToggleProps = ProviderProps & { variant?: ToggleVariant; size?: ToggleSize; … };

El wrapper .svelte importa el namespace público de Soma y usa sus partes con ese mismo namespace:

import * as Select from '$soma/components/select';

<Select.Provider {...rest}>
	<Select.Trigger />
	<Select.Content />
</Select.Provider>

No usar aliases como Parts, SelectBase o SomaSelectProvider. Tampoco usar etiquetas sueltas <Provider> / <Trigger> dentro de Eidos. El wrapper añade los data-attrs de tokens visuales (data-variant, data-size, data-block, data-icon-only) y no reimplementa el estado.

De sema (DOM)

Sólo el DOM. El visual channel proyecta data-event-* durante el hold mediante SignalProjector y eidos reacciona vía events.css:

[data-event-family='commit'][data-event-phase='active'] {
	animation: eidos-commit-settle 260ms var(--ease-out);
}
[data-event-family='commit'][data-event-intent='threat'][data-event-phase='active'] {
	animation: eidos-announce-pulse-threat 400ms var(--ease-spring);
}

events.css documenta los hold defaults por familia y por qué se usa animation: @keyframes (no transition) para reacciones a señales.

Qué NO consume

  • Computed state lógico del provider (e.g. la composición de isDisabled propio del componente con el disabled heredado de un Field). Eidos sólo ve el resultado: [data-disabled] está o no está.
  • Internals de runtime. No sabe si un attr lo escribió dom.apply, Svelte render o el provider manualmente. Sólo le importa que esté.
  • Layers de soma (Presence, Dismissal, ScrollLock, FocusScope). Reacciona a sus efectos visibles, no a su existencia.

La regla de los --* tokens

Eidos posee el namespace --* en el visual layer. Razones:

  1. Authorship clarity en debug — inspeccionar un elemento y ver --toggle-bg informa que viene del visual layer de UIX.
  2. Override discipline — un consumer que sobreescribe --color-primary-element sabe que está tocando contrato visual, no nombrando-colisionando con una variable local.

Las capas superiores (sema, soma, morfo) NO consumen estos tokens y no usan el prefijo. Cada una carga sus propias concerns (perceptual durations, behavior, contract DNA) ortogonales al rendering visual.

Theming, tokens, bundle — ver THEMING.md

La doctrina completa del sistema de theming (token scope contract, token layers, naming conventions, bundle purge, motion via event:*, comparación con referencias, anti-patterns, FAQ) vive en src/uix/eidos/THEMING.md. Ese es el reference canónico.

Resumen rápido de lo que cubre, para no duplicar aquí:

Tema Sección en THEMING.md
Por qué el theming vive en Eidos y no en Morfo §1.bis
7 capas de tokens y override points §3
9 roles canónicos de color §4
6 sizes canónicos §5
Naming conventions §6
Token Scope Contract (TSC): tipo, álgebra de scope, cross-axis §7
TSC v2.2: multi-part scope (parts: [...]) + cross-recipe composition §7
Cómo añadir un componente nuevo §8
Cómo definir un theme §9
Cómo overridear tokens en runtime §10
Bundle strategy + eidos:purge §11
Integración con sema vía event:* §13
Anti-patterns y FAQ §16, §17
Cobertura universal de TSC (sin excepciones) §18
Variants son canon del eidos, NO del theme (con EIDOS_VARIANTS) §19

Lo que sigue en este README son las decisiones operativas de la capa visual como módulo (typography sourcing, picker patterns, API conventions, runtime activo), independientes del sistema de theming.

Vertebración tipográfica — single source of truth en foundation

Eidos tiene dos anclas tipográficas, en capas distintas, por diseño. Esta sección documenta por qué y cómo se relacionan.

Las dos capas

src/uix/eidos/lib/primitives/typography.ts
        │
        ├── families / sizes / weights        (escala numérica)
        │        ↓
        │   foundation tokens
        │   --font-family-{primary,secondary,display,mono}
        │   --font-size-{xxs..xxxl}
        │   --font-weight-{regular,medium,semibold,bold}
        │   --font-line-height-{xxs..xxxl}
        │
        └── styles                            (capa semántica)
                 ↓
            named-style tokens
            --style-{hero,h1..h6,body,prose,label,caption,code}-{font-family,
                font-size,line-height,letter-spacing,font-weight,color}
Capa Quién la consume Para qué
Foundation numerical (--font-size-*, --font-family-primary, …) recipe tokens en lib/recipes/base.ts + foundation aliases (--font-ui, --leading-ui, …) Internals de componentes (Field labels, Combobox triggers, Button text, …) — necesitan escalado t-shirt (xs/sm/md/lg/xl) que NO mapea limpio a una semántica fija.
Named styles (--style-label-*, --style-body-*, --style-caption-*, --style-h{1..6}-*, --style-{hero,prose,code}-*) typography primitives (<Text>, <Heading>, <Display>, <Code>, <Link>, …) API de usuario para componer contenido — el USUARIO eligió "label" o "body" y quiere que ese rol semántico se respete.

Las dos capas no son redundantes: sirven a contextos distintos. La numérica vertebra el interior del sistema; la semántica vertebra la superficie que el consumer compone.

Cómo se vertebran sin duplicarse — la chain de aliases

Donde un valor coincide entre las dos capas, el foundation alias lee del named style, no al revés. Single source of truth: el named style.

/* generated/base.css (vía render-css.ts) */
:root {
  /* Named style — fuente de verdad */
  --style-label-font-family: var(--font-family-primary);
  --style-label-line-height: 1.25;

  /* Foundation alias — vertebra los recipes */
  --font-ui: var(--style-label-font-family, var(--font-family-primary));
  --leading-ui: var(--style-label-line-height, 1.25);
}

/* recipes/base.ts → generated/base.css */
:root {
  --field-label-line-height: var(--leading-ui);
  --field-control-line-height: var(--leading-ui);
  /* … docenas de recipe tokens más */
}

/* components/field/field.css */
[data-field-label] {
  line-height: var(--field-label-line-height);
}

Cambiar STATIC_TYPOGRAPHY.styles.label.lineHeight = '1.3' (en primitives/typography.ts) propaga a --style-label-line-height → --leading-ui → todos los recipes → todos los componentes. Una sola edición llega a Field, Form, Combobox, Select, Toolbar, Toast y los typography primitives simultáneamente.

El fallback , 1.25 / , var(--font-family-primary) garantiza que el sistema sigue produciendo CSS válido si el consumer apaga los named styles en su foundation override.

Por qué los component recipes NO leen --style-{name}-* directamente

Tentación recurrente: "cada componente debería leer --style-label-font-size para que sea coherente". No es la forma.

  1. Las t-shirts no caben en cuatro buckets. Un Field con size="xs" tiene un label más pequeño que el "label canónico". Si su recipe leyera --style-label-font-size, perderías ese escalado o tendrías que crear --style-label-{xs,sm,md,lg,xl}-*, replicando lo que ya viven los recipe tokens.
  2. Los matices por componente son legítimos. El message-de-Field, el description-de-Tooltip y el subtitle-de-Card son todos "caption-ish" pero cada uno quiere su color/weight/letter-spacing propios. Forzarlos a un único --style-caption-* mata expresividad.
  3. Coupling lock-in. Día 100 el sistema quiere label-form, label-table, label-chart. Forzar acoplamiento día 1 te lleva a replicar la jerarquía recipe en la capa semántica.
  4. Cómo lo hacen las referencias. Radix Themes, Mantine, MUI todos tienen escala numérica que los componentes leen; la capa semántica existe sólo para los typography primitives (<Text variant="body2">). Chakra v3 ofrece textStyle acoplable pero la mayoría de sus componentes hardcodean igualmente. Acoplar todo a la capa semántica no es la práctica dominante y por buenas razones (1-3).

Auditoría: regla R-2.7

scripts/component-audit.ts detecta literales tipográficos en CSS de componentes: font-size: 12px, line-height: 1.4, font-weight: 500, letter-spacing: 0.02em que no estén envueltos en var(). Severidad warn, no error — el componente sigue pasando, pero queda visible en el report.

Escape valves (no cuentan como drift):

  • valores en var(...)
  • ceros e identidades: 0, 0px, 0em, 0rem, 1
  • keywords: inherit, initial, unset
  • comentario en línea: font-size: 13px; /* literal: tight icon affordance */

Si necesitas un literal con justificación, anótalo. Si no, tokenizalo.

La regla "2-de-3" (heredada de morfo)

Una extensión a morfo se justifica si al menos dos de las tres capas (soma, sema, eidos) la consumen. Las que entraron por el voto de eidos:

  • archetype — eidos + sema (+ soma como emisor)
  • events[].semantic.family/intent — sema + eidos
  • events[].prewrite[] — soma (ejecuta) + eidos (anima)
  • data-starting-style / data-ending-style — soma (Presence) + eidos (anima)

Convenciones del API — disciplined option C (vigente 2026-05-10)

Las convenciones doctrinales viven en src/docs/GUIA_IMPLEMENTACION_SEMAUIX.md sección 13 (operación instantánea = un evento; sistema unificado de 8 tokens; intent ↔ color resolution; subset por componente; iconOnly sr-only; sound prepare-time priming).

La convención del shape público del componente eidos vive en components/README.md. Resumen — 7 reglas duras:

  1. Un solo punto de entrada por componente — la default export es el componente root visual, llamado igual que el componente (<Drawer>, <Tabs>, <Checkbox>). NO <Drawer.Provider>, NO <Drawer.Root>.
  2. El root vive en {name}.svelte — NO en {name}-provider.svelte. Un solo fichero por root.
  3. NO exportar Provider públicamente. La separación "Provider compound vs flat" es invención retirada; hay UNA forma compound (root + hijos atados como propiedades).
  4. Hijos siguen el naming de air / headless: Trigger, Content, Overlay, Title, Description, Close, Portal, Header, Footer, Item, Indicator, HiddenInput, Group, Label. No inventar nombres.
  5. Portal se incluye donde air lo tenía (Dialog, Drawer, Popover, Tooltip — overlays portaled). Importado de $soma/components/internal.
  6. NO flat con snippet slots como API principal — <Drawer trigger={...} title={...}> está retirado. La compound explícita expone las decisiones de composición que el flat escondía.
  7. Hijos atados con asignación explícita, no Object.assign (Svelte 5 puede manejar mal la mutación bulk del component constructor durante hidratación):
    const Drawer = DrawerRoot as DrawerNamespace;
    Drawer.Trigger = Trigger;
    Drawer.Content = Content;
    

Plus reglas de implementación:

  1. Wrapper, no fork. El .svelte de eidos importa el namespace público de Soma (import * as Drawer from '$soma/components/drawer'), usa <Drawer.Provider> / <Drawer.Trigger> internamente y añade los data-attrs de tokens visuales. No reimplementa estado.
  2. Size desde lib/types.ts. Componentes que aceptan tamaños reusan el tipo compartido y narrowingan al subset que su recipe soporta (Extract<Size, 'sm' | 'md' | 'lg'>).
  3. Sin prefijo Eidos en los tipos. El path $uix/eidos/components/{x} ya identifica la capa.
  4. CSS recipe sin prefijo --eidos-. Los custom properties usan --{component}-… para los públicos y --_{component}-… para los internos.

Por qué disciplined option C

El patrón anterior (default flat con snippet slots + Provider compound duplicado) tenía dos problemas:

  • Doble verdad: dos APIs (flat con trigger/title/actions snippets, compound con children explícitos) llevaban a la misma funcionalidad por caminos divergentes. Cualquier tweak visual exigía actualizar ambas.
  • Inventaba sobre air: air era pure compound (<Drawer.Provider> con children). El flat con snippets fue una invención sin baseline ni firma del usuario.

Option C disciplinada:

  • Toma de air la convención de hijos (Trigger, Content, Portal, …)
  • Toma de bits-ui / shadcn-svelte la ergonomía del root con propiedades attached (<Drawer><Drawer.Trigger>)
  • Elimina la invención del flat con snippets (no era ni air ni soma)

Caso especial — Toast

Tiene dos roots independientes (no anidados):

  • <Toast> — manual compound (consumer itera toaster.toasts)
  • <Toaster /> — imperative auto-mount (template default)

Export separados; Toaster NO va attached como Toast.Toaster porque es un root competidor, no un hijo. Documentado en components/toast/index.ts.

Defensa contra drift de selectores

La cadena morfo → soma → eidos depende de que los selectores que eidos escribe ([data-{component}], [data-{component}-{part}], [data-state=...], [data-event-*=...]) se mantengan en sintonía con los attrs que el morfo declara y el runtime emite. Hay dos defensas distintas según el tipo de consumidor:

Compile-time (TS / Svelte) — typed builder

Cualquier consumidor TypeScript que construya selectores MUST usar semaSelector(morfo, partKebab, matchers?) desde $uix/morfo. Esto cubre las cascade rules de sema/components/*.ts y cualquier lógica TypeScript en eidos/components/{x}/ que apunte a attrs morfo-backed.

import { semaSelector } from '$uix/morfo';
import { toggleMorfo } from '$uix/morfo/components/toggle';

semaSelector(toggleMorfo, 'provider', { eventName: 'commit-toggle' });
// → '[data-toggle][data-event="commit-toggle"]'

Renombrar una part o un evento en el morfo rompe el typecheck. Es imposible que un selector TypeScript drifte silenciosamente. Ver morfo/README.md#typed-selector-builder--semaselector.

Run-time (CSS recipes) — eidos-lint como red de seguridad opt-in

Los recipes son CSS plano (components/{x}/{x}.css); no hay typed builder en el lado CSS. Para esa superficie:

node scripts/eidos-lint.ts toggle      # un componente
node scripts/eidos-lint-all.ts         # todos

Clasifica cada selector [data-*] como:

  • morfo-backed — declarado en el morfo; soma runtime lo emite; el valor (si hay enum) cae dentro de data[].values.
  • eidos-only — el marker está, pero al menos un data-* no está declarado en el morfo. Válido por convención (tokens visuales como data-variant, data-size vienen del wrapper).
  • invalid — referencia un attr declarado pero con un valor fuera del enum. Bug.

El lint es una red de seguridad, no el contrato. El contrato vive en el morfo y se defiende a nivel de tipos donde se puede. El lint existe sólo para la porción CSS-pura que aún no consume el morfo a través de TypeScript. Cuando los recipes migren a un builder, el lint podrá retirarse.

Cambios 2026-05-21 — pickers: kind + composition

  • kind: 'date' | 'month' | 'year' sobre DatePicker y DateRangePicker. Single source for input segments + popover view. Inputs filtran segments en el DateFieldProvider (soma) — los consumers nunca filtran en el snippet. Views como parts canónicas Eidos: <DatePicker.YearView>, <DatePicker.MonthView>, idem en range. Range views implementan state machine: empty → start, pending → end (con swap automático si reverse), complete → reset.
  • Footer = composición pura. Drop de clearButton/cancelButton/ closeButton root props. <Picker.Footer> contiene las parts que compongas; cada part renderiza siempre que se monta (sin checks internos). Modal mode no fuerza Close visible — el consumer es responsable de incluirlo (documentado en la part).
  • Modal mode + footer pattern documentado en PENDIENTES.md (norma N-6 y N-7).
  • Provider helpers: commit() / cancel() / clear() en DatePickerProvider y DateRangePickerProvider. cancel() revierte al snapshot capturado en el OPEN edge vía watch(open).
  • Composition wins: no hay MonthPicker / YearPicker / MonthRangePicker / YearRangePicker como componentes propios. Esas variantes son <DatePicker kind='month'> / <DateRangePicker kind='year'> etc.

Picker patterns (contrato reutilizable)

Los pickers (date-picker, date-range-picker, y los futuros time-picker, time-range-picker, color-picker) comparten un contrato común. Documentado aquí como referencia canónica — cualquier picker nuevo se construye sobre este esqueleto.

P-1 · Provider helpers: commit() / cancel() / clear()

Cada *PickerProvider expone tres métodos imperativos consumidos por las parts del Footer:

  • commit() — cierra el popover preservando value.current tal cual. Es la confirmación normal del valor seleccionado.

  • cancel() — revierte value.current al snapshot capturado en el OPEN edge y cierra el popover. El snapshot se toma vía watch(opts.open) cuando open transita de false → true:

    private valueOnOpen: TValue | undefined = undefined;
    
    constructor(...) {
      watch(() => this.opts.open.current, (isOpen) => {
        if (isOpen) this.valueOnOpen = $state.snapshot(this.opts.value.current);
      });
    }
    
    cancel() {
      this.opts.value.current = this.valueOnOpen;
      this.opts.open.current = false;
    }
    
  • clear() — pone value.current = undefined. NO cierra el popover (es una acción destructiva visible que el usuario puede querer seguir editando). Si el consumer quiere cierre tras clear, compone <Picker.Close/> en el mismo footer.

Estas tres operaciones son ortogonales: cada part Footer hace exactamente una. No mezcles (ej. clear no debe cerrar; cancel no debe limpiar).

P-2 · mode: 'inline' | 'modal' → popover.modal

mode es un opt root del provider que se propaga al Popover.modal underlying:

  • mode='inline' (default) — popover no-modal. Outside-click cierra. Escape cierra. Body scroll libre.
  • mode='modal' — popover modal. Outside-click NO cierra (el usuario debe usar <Picker.Close/> o <Picker.Cancel/>). Escape sigue cerrando. Focus trap dentro del popover. Body scroll lock.

El consumer NO setea popover.modal directamente — eso es decisión del provider:

<Popover.Provider modal={provider.opts.mode.current === 'modal'} ...>

Modal mode obliga al consumer a componer un Footer con <Cancel/> o <Close/> para escapar; sin ellos el popover sólo cierra por Escape. Sigue siendo el consumer responsable (no auto-injecta nada).

Layout canónico de un picker:

<X.Provider value={...} open={...} mode='modal' kind='date'>
  {#snippet trigger()}
    <X.Input /> {!-- field input con segments, button, etc. --}
  {/snippet}

  {#snippet content()}
    <X.Content>
      {#if kind === 'year'}      <X.YearView />
      {:else if kind === 'month'} <X.MonthView />
      {:else}                     <X.Calendar />
      {/if}

      <X.Footer>
        <X.Clear />
        <X.Cancel />
        <X.Close />
      </X.Footer>
    </X.Content>
  {/snippet}
</X.Provider>
  • Trigger snippet — entry surface (input + segments, button, swatch…).
  • Content — popover content; sólo <X.Content> puede ir aquí.
  • View — Calendar, YearView, MonthView, Clock, Picker (color), etc. El consumer hace branching estructural por kind o variante (P-4 abajo).
  • Footer — <X.Footer> es contenedor archetype='footer'. Las parts (Clear, Cancel, Close, etc.) se componen dentro. Cada part renderiza sin checks internos contra props del provider — presencia = visibilidad (N-7).

Si quieres omitir el footer entero, no compones <X.Footer>. Si quieres sólo Close, compones sólo <X.Close> dentro.

P-4 · kind o equivalente como single source of truth

Cuando un picker tiene variantes de granularidad (date: day/month/year; time: hour/minute/second), el opt kind (o equivalente) es el único punto de configuración. Drives:

  1. Segments del input — filtrado a nivel del FieldProvider (soma), no en el snippet del consumer. El provider expone una derivación tipo visiblePartsByKind: Set<PartName> y segmentContents filtra allSegmentContent.arr colapsando literal runs.
  2. View del popover — el consumer hace branching estructural sobre el opt ({#if kind === 'year'} <YearView/> etc).

No existen componentes separados por variante (MonthPicker, HourPicker, etc). Esas formas son <X kind='month'>, <X kind='hour'>.

P-5 · Range state machine (para *-range-picker)

Para selección de rangos, el provider mantiene un estado interno implícito (no expuesto como opt):

  • empty (value === undefined o {start: undefined, end: undefined}) — siguiente click setea start. Estado pasa a pending.
  • pending (start definido, end undefined) — siguiente click setea end. Si el nuevo punto < start, swap automático (start ↔ end). Estado pasa a complete.
  • complete (start y end definidos) — siguiente click reinicia: setea start = click, end = undefined. Estado pasa a pending.

Cada granularidad normaliza los endpoints:

  • Year range — start = Jan 1, end = Dec 31.
  • Month range — start = day 1, end = último día del mes.
  • Day range — start/end son la fecha clickada tal cual.
  • Hour range — start = :00, end = :59:59.

Implementado en YearView/MonthView/Calendar (range variant). El provider sólo expone setValue(start, end); la state machine vive en los views.

Cambios 2026-05-21

  • Tokens muted añadidos al contrato. SurfaceColorRoles y ContentColorRoles ahora incluyen muted (entre overlay+backdrop y entre secondary+disabled respectivamente). Mapeo base: --color-surface-muted: var(--primitive-neutral-3) (light + dark) y --color-content-muted: var(--primitive-neutral-10). Antes existían 17+3 referencias rotas en recipes/components que el browser caía a initial-value (texto invisible para placeholders, separadores, weekday del calendar, group-heading del select, etc.). Solucionado vía themes/base.ts + render-css.ts + regen.
  • Typo --color-neutral-element-hover corregido en form.css:76 → --color-neutral-hover (el token correcto existente).
  • Raw colors removidos. archetypes.css:122 (hsl indigo fijo para [aria-selected]) y events.css:eidos-commit-settle (rgba indigo fijo) sustituidos por color-mix(var(--color-primary-solid) …). Ya no quedan hex/rgb/hsl crudos en src/uix/eidos/**/*.css ni en recipes/base.ts.
  • Cobertura de tamaños expandida. 20 componentes pasan de sm·md·lg a xs·sm·md·lg·xl (form controls + text inputs + progress/meter + field/form) o a xs·sm·md·lg (nav controls: breadcrumb, pagination, tag-group, toolbar). Categorización documentada en web/routes/uix/lib/DEMO_AUTHORING_GUIDE.md §12.8. Paneles compuestos (calendar, date-picker, date-range-picker, file-upload, stepper, tooltip) mantienen sm·md·lg. La elección responde a uso real, no a artificio: barras y controles tactiles escalan limpio en 5 escalones; paneles compuestos no se benefician por debajo de sm.
  • Paridad de chips en demos. field.variant y toolbar.variant dejaron de narrowar ControlVariant a 2 valores; ahora exponen los 3 (surface | outline | ghost). El CSS añade selectores [data-variant='outline'] con bg transparente + border visible para ambos componentes. La norma queda fijada en CHECKLIST §D-7.4.
  • Scrollbar portaled. Las reglas ::-webkit-scrollbar* viven sin scope en web/routes/uix/uix.css (sólo se carga bajo /uix). --uix-line se duplica en :root con override :root[data-mode='dark'] (atributo escrito por ActiveEidos), permitiendo que portals (Combobox listbox, Popover, Dialog, Drawer) resuelvan el token aunque vivan fuera de [data-uix-docs].

Estado actual (2026-05-17)

  • ActiveEidos: implementado como runtime/contexto visual y superficie de configuracion. Gestiona primitivas, roles canonicos, themes, validacion, contrato CSS, persistencia y render CSS (renderStaticCss, renderThemeCss).
  • Authoring API: defineEidosConfig, extendEidosConfig y createThemeBaseEidosConfig permiten crear configuraciones completas o extender el theme base sin mutar las constantes del sistema. getCssContract() expone el contrato estructurado, renderContractCss() lo materializa como CSS para themes externos, renderCssVariables() permite escribir overrides runtime contract-aware y toDocument() / serialize() exponen el envelope versionado para persistencia.
  • Runtime CSS: ActiveEidos reacciona a su preferences compuesto o a fuentes explicitas modeSource / densitySource, soporta themes de config y CSS-only via themeSource, acepta variables runtime en un style block propio, y con applyDom:false no escribe en el DOM.
  • Wrappers por componente: la superficie migrada desde Soma ya incluye toggle, switch, collapsible, dialog, drawer, popover, toast, accordion, avatar, tooltip, tabs, checkbox, radio-group, meter, progress, slider, pagination, rating-group, search-field, number-field y breadcrumb. Todos usan root visual + partes attached, sin Provider público ni API flat.
  • Color: basado en roles canonicos de jerarquia e intents (primary, secondary, tertiary, neutral, affirm, fulfill, risk, threat, loss) y escalas de 12 pasos. Por cada escala genera alpha tokens a1..a12, derivadas automaticamente o sobrescribibles con color.alphaScales por theme. Los roles semanticos validos son solo los declarados por el contrato de Eidos.
  • Size: xxs..xxl se renderiza como map global coordinado (control-height, font, icon, padding, gap, radius). full queda como valor de layout, no como primitiva fisica. Cada componente declara el sub-rango que su recipe mapea — la categorización canónica está en DEMO_AUTHORING_GUIDE.md §12.8 (form controls + text inputs + progress/meter + field/form usan xs..xl; nav controls usan xs..lg; paneles compuestos mantienen sm..lg).
  • Border / opacity / z-index / shadow: ya forman parte del contrato generado. Border define escala de width/style y aliases globales; opacity cubre estados de UI y overlays; z-index cubre capas comunes; shadow combina escala fisica 1..6 con aliases semanticos por theme.
  • Layout: ya forma parte del contrato generado. Incluye containerWidth, containerPaddingInline, contentWidth y aspectRatio como tokens estables y authorables desde EidosConfig.
  • Density: ya forma parte del contrato generado. Incluye scale, spaceScale, controlScale y contentScale para los tres niveles canonicos compact, comfortable y spacious, conectados al data-density que proyecta ActiveEidos.
  • Tipografia: usa tamaños canonicos xxs a xxxl, familias libres por key y estilos tipograficos (h1, h2, body, etc.) como Record<string, TypographyStyle>.
  • Convencion del API de componentes: disciplined option C esta documentada en components/README.md. La migracion de componentes avanza por tandas pequenas y no debe arrastrar cambios de demos/rutas ni reimplementar comportamiento que pertenece a Soma.
  • CSS generado: generated/base.css ya se genera desde la config base de Eidos con npm run generate:eidos-css y se importa como foundation estatica. Los CSS historicos de contracts/ y themes/base/ ya no existen en el arbol activo; el contrato se publica desde ActiveEidos y los valores base desde generated/base.css. Los antiguos tokens/components/* tambien salen del entrypoint: EidosConfig.recipes genera los aliases de recipe estables.
  • Recipes: quedan deliberadamente como RecipeTokenSet plano. No se crea jerarquia estructurada hasta que una recipe tenga un builder/consumer real que necesite mas semantica que aliases CSS. La guardia recipe-css-contract.test.ts evita que el theme base declare aliases no consumidos o que un CSS de componente use variables fuera del contrato.
  • Check del repo: npm run check no reporta errores ni warnings en este punto.

Powered by TurnKey Linux.