23 KiB
eidos/components/
Recipes + wrappers visuales por componente. Cada componente vive en su
subdirectorio con la forma canónica documentada abajo. Los .css
sueltos al nivel raíz son legacy de la fase "eidos = solo CSS" y se
migran progresivamente.
⚠️ Canon de arquetipos (2026-06-19) — leer antes de escribir un recipe/wrapper. La fuente de verdad de qué tokens/arquetipos consume un componente visual es el Contrato de construcción (§13) de
../ARCHETYPE_COHERENCE_AUDIT_2026-06-19.md: elevación víadata-depth(bundle completo, NOsurface-raiseda dedo) · estado vía--state-*(NOcolor-mixad-hoc) · radio vía factor global +[data-shape-nest](NOcalc(--radius-md − space)) · label de campo canónico · bundle--size-*(NO re-derivar size→fuente). Cada fila del §13 marca su estado (LIVE/ pendiente Etapa A): no referencies un token aún pendiente. LoLIVEya es obligatorio: color por token (cero hex), densidad--space-*/--control-height-*, RTL lógico, i18nlangs.ts, portal-safe.
Forma canónica (disciplined option C — vigente desde 2026-05-10)
Convención unificada para TODOS los componentes multi-part. Sigue la
estructura de air y la ergonomía moderna de bits-ui /
shadcn-svelte: una sola entidad mental — <Drawer> — con hijos
accesibles como propiedades — <Drawer.Trigger>, <Drawer.Content>, etc.
Reglas duras
- Un solo punto de entrada por componente: la default export es el
componente root visual. Se llama igual que el componente
(
<Drawer>,<Tabs>,<Checkbox>, …) — no<Drawer.Provider>, no<Drawer.Root>. - El root vive en
{name}.svelte— NO en{name}-provider.svelte. El nombre del fichero coincide con el nombre del componente. - No exportar
Providerpúblicamente. La separación "Provider compound vs flat" es una invención previa que se ha retirado: hay UNA forma compound — el root + sus hijos atados. - Los hijos siguen el naming de air / headless:
Trigger,Content,Overlay,Title,Description,Close,Portal,Header,Footer,Item,Indicator,HiddenInput,Group,Label, etc. No inventar nombres nuevos. Portalse incluye donde air lo tenía (Dialog, Drawer, Popover, Tooltip — overlays portaled). Importado de$soma/components/internal.- No flat con snippet slots como API principal. La invención
<Drawer trigger={...} title={...} actions={...}>está retirada — esconde decisiones de composición que deberían ser explícitas en componentes con portal/overlay/content/close. - Los hijos se atan al root con asignación explícita, no
Object.assign(que en Svelte 5 puede causar issues sutiles de hidratación):const Drawer = DrawerComponent as DrawerNamespace; Drawer.Trigger = Trigger; Drawer.Content = Content; // ...
Estructura de directorio
drawer/
drawer.svelte ← root (lo que era air's Provider)
drawer-trigger.svelte
drawer-overlay.svelte
drawer-content.svelte
drawer-handle.svelte
drawer-title.svelte
drawer-description.svelte
drawer-close.svelte
drawer-header.svelte ← eidos-only layout shell (cuando aplique)
drawer-footer.svelte ← idem
drawer.css ← recipe
types.ts ← extiende las props públicas de Soma
index.ts ← compone Drawer + hijos
{name}.svelte — el root
Wrapper directo sobre el provider público de Soma. Setea el
contexto headless, acepta los bindables del estado (open, value,
pressed, etc.), añade los data-attrs visuales propios de eidos
(data-size, data-variant, data-color, …), y renderiza
{@render children?.()} para que los hijos se compongan dentro.
<script lang="ts">
/**
* Eidos `<Drawer>` — root component. Wraps the Soma
* to set up the component context.
*/
import * as Drawer from '$soma/components/drawer';
import type { DrawerProps } from './types';
let {
open = $bindable(false),
activeSnapPoint = $bindable(null),
isDragging = $bindable(false),
children,
...rest
}: DrawerProps = $props();
</script>
<Drawer.Provider {...rest} bind:open bind:activeSnapPoint bind:isDragging>
{@render children?.()}
</Drawer.Provider>
Patrón de import interno:
import * as Drawer from '$soma/components/drawer'. Eidos no crea una fachada headless propia por componente; envuelve las partes públicas de Soma directamente como<Drawer.Provider>,<Drawer.Trigger>, etc.
Prohibiciones de naming interno
El namespace interno que apunta a Soma se llama igual que el componente. No se prefija ni se renombra:
<!-- Correcto -->
import * as Collapsible from '$soma/components/collapsible';
<Collapsible.Provider>
<Collapsible.Trigger />
</Collapsible.Provider>
Formas prohibidas:
import * as Parts from '$soma/components/collapsible'import * as CollapsibleBase from '$soma/components/collapsible'import { Provider as SomaCollapsibleProvider } from '$soma/components/collapsible'- etiquetas sueltas
<Provider>,<Trigger>,<Content>dentro de Eidos - tipos o aliases internos
SomaXxxProvider,XxxBase,XxxRoot
Motivo: Eidos ya declara la capa en el path. El import interno debe expresar la
entidad headless concreta de Soma, no una abstraccion inventada. Si un wrapper
Eidos necesita el provider de Soma, escribe <Collapsible.Provider>; si
necesita una parte, escribe <Collapsible.Trigger>, <Collapsible.Content>,
etc.
Guardia de componentes compuestos de fecha
Incidencia 2026-05-20: DateField, DatePicker y DateRangePicker no pueden cerrarse con wrappers Eidos si Morfo/Soma y la demo no estan cerrados primero. Para componentes compuestos de fecha:
- Comparar antes con
morfo-runtime(air/terra) y referencias React Aria, Ark UI, Bits UI y shadcn-svelte. - Completar Morfo con partes,
data-*, ARIA, estados, keyboard y eventos observables de la superficie compuesta. - La demo no puede inventar
data-calendar-*odata-range-calendar-*; si la receta los necesita, pertenecen a Morfo/Soma. - Cada control visible (
granularity,hourCycle, segmentos, min/max,pagedNavigation, modal/no-modal, clear) debe cambiar algo visible en el stage y estar reflejado en los snippets. - Verificar visualmente popup, uno/dos meses, seleccion, deseleccion parcial, limites y accion de borrado antes de documentarlo como listo.
{name}-{part}.svelte — hijos passthrough
Cada hijo es un wrapper delgado sobre la part del Soma. Si
añade visual-only data-attrs, los stamp aquí (ej. data-size en
Content). Si es passthrough puro (Trigger, Close), trivial.
<!-- drawer-trigger.svelte -->
<script lang="ts">
import * as Drawer from '$soma/components/drawer';
import type { DrawerTriggerProps } from './types';
let { children, ...rest }: DrawerTriggerProps = $props();
</script>
<Drawer.Trigger {...rest}>
{@render children?.()}
</Drawer.Trigger>
index.ts — compone el namespace
Asignación explícita per-property sobre el root component. NO usar
Object.assign(DrawerComponent, { Trigger, ... }) — Svelte 5 puede manejar
mal la mutación bulk del component constructor durante hidratación,
causando que los hijos aparezcan y desaparezcan después de mount.
import DrawerComponent from './drawer.svelte';
import Trigger from './drawer-trigger.svelte';
import Overlay from './drawer-overlay.svelte';
import Content from './drawer-content.svelte';
import Title from './drawer-title.svelte';
import Description from './drawer-description.svelte';
import Close from './drawer-close.svelte';
import Header from './drawer-header.svelte';
import Footer from './drawer-footer.svelte';
import Handle from './drawer-handle.svelte';
import { Portal } from '$soma/components/internal';
type DrawerNamespace = typeof DrawerComponent & {
Trigger: typeof Trigger;
Portal: typeof Portal;
Overlay: typeof Overlay;
Content: typeof Content;
Handle: typeof Handle;
Title: typeof Title;
Description: typeof Description;
Close: typeof Close;
Header: typeof Header;
Footer: typeof Footer;
};
const Drawer = DrawerComponent as DrawerNamespace;
Drawer.Trigger = Trigger;
Drawer.Portal = Portal;
Drawer.Overlay = Overlay;
Drawer.Content = Content;
Drawer.Handle = Handle;
Drawer.Title = Title;
Drawer.Description = Description;
Drawer.Close = Close;
Drawer.Header = Header;
Drawer.Footer = Footer;
export { Drawer };
export default Drawer;
export type {
DrawerProps,
DrawerTriggerProps as TriggerProps,
DrawerOverlayProps as OverlayProps,
DrawerContentProps as ContentProps
// … etc
} from './types';
types.ts — passthrough + adiciones eidos
import type {
ProviderProps,
TriggerProps,
ContentProps
// …
} from '$soma/components/drawer';
export type DrawerProps = ProviderProps;
export type DrawerTriggerProps = TriggerProps;
// Eidos añade props visuales que el recipe consume vía data-attrs
export type DrawerContentProps = ContentProps & {
size?: ResponsiveProp<DrawerSize>;
};
Sin
XxxFlatProps, sinXxxProviderProps as ProviderPropsaliases. Hay un soloXxxProps(el del root) y losXxxPartPropsper hijo.
Cuando el tipo root de Soma se importa como ProviderProps, se usa solo como
tipo local dentro de types.ts. No se exporta al consumidor con nombre
ProviderProps desde Eidos ni se crea un alias SomaXxxProviderProps.
Tipos visuales canonicos
No se redeclaran variantes, colores ni tamanos a mano en cada componente.
La unica fuente para las props visuales compartidas es
src/uix/eidos/lib/types.ts:
SizeyResponsiveProp<T>para escalas responsivas.ControlVariant,SelectionVariant,ChipVariant,MarkerVariant,SurfaceVariant, etc. para variantes canonicas por familia visual.ColorRolepara la paleta de intents UIX (primary,secondary,neutral,affirm,fulfill,risk,threat,loss).
Cuando un componente necesita restringir una familia compartida, usa
Extract<> sobre el tipo canonico. No se crean unions locales con literales
duplicados:
import type { ColorRole, ControlVariant, Size } from '$uix/eidos/lib/types';
export type DateFieldSize = Extract<Size, 'sm' | 'md' | 'lg'>;
export type DateFieldVariant = ControlVariant;
export type DateFieldColor = ColorRole;
Partes Eidos-only
Algunas partes existen solo para componer la superficie visual: Header,
Footer, Status, Main, Handle, filas/labels planas, indicadores
decorativos o primitives svg. No necesitan morfo propio mientras cumplan
las cuatro reglas:
- No crean comportamiento ni estado.
- No son target de eventos perceptivos.
- No poseen ARIA obligatoria ni relaciones accesibles propias.
- Solo emiten estructura, clase/estilo passthrough o
data-*visuales que el recipe consume.
Si cualquiera de esas reglas deja de cumplirse, la parte deja de ser Eidos-only y debe subir al contrato correspondiente: primero morfo, luego Soma si necesita runtime.
El agregador scripts/eidos-lint-all.ts mantiene una allowlist explícita de
estos attrs/parts visuales. El lint base sigue siendo estricto con valores
inválidos de enums morfo; la allowlist solo evita que el reporte de drift se
llene de partes visuales intencionales.
src/uix/eidos/component-api-contract.test.ts protege la forma pública del
barrel: sin Object.assign, sin Provider público, root en
{component}.svelte y miembros del XxxNamespace sincronizados con sus
asignaciones explícitas (Drawer.Trigger = Trigger, etc.).
La guardia src/uix/eidos/component-visual-attrs.test.ts fija el cableado
mínimo entre wrapper y recipe: si un wrapper visual declara una prop que se
consume como data-* (size, variant, color, position, columns,
etc.), el fichero debe seguir estampando ese atributo. Esto no añade
comportamiento a Eidos; solo evita que la capa visual trague props en silencio.
Uso del consumidor
<script lang="ts">
import { Drawer } from '$uix/eidos/components/drawer';
let open = $state(false);
</script>
<Drawer bind:open variant="overlay" direction="right">
<Drawer.Trigger>Open</Drawer.Trigger>
<Drawer.Portal>
<Drawer.Overlay />
<Drawer.Content>
<Drawer.Header>
<Drawer.Title>Title</Drawer.Title>
<Drawer.Description>Subtitle</Drawer.Description>
</Drawer.Header>
<p>body</p>
<Drawer.Footer>
<Drawer.Close>Close</Drawer.Close>
</Drawer.Footer>
</Drawer.Content>
</Drawer.Portal>
</Drawer>
Componentes single-part (toggle, icon)
Sin hijos compound. El default export ES el componente entero. Sin
namespace, sin asignación de propiedades, sin Provider alias.
// toggle/index.ts
export { default } from './toggle.svelte';
export type { ToggleProps, ToggleVariant, ToggleSize } from './types';
import Toggle from '$uix/eidos/components/toggle';
<Toggle bind:pressed variant="solid">Bold</Toggle>
Partes visuales por defecto
Algunos componentes multi-part necesitan una parte visual minima para no
renderizar una superficie rota. Switch es el caso canonico: el root sigue
exponiendo Switch.Thumb, pero si el consumidor no aporta children, <Switch />
monta un thumb visual por defecto. Esto no crea una API flat ni esconde el
contrato de Soma; solo evita que el uso minimo renderice un track sin indicador.
Toast — caso especial (dos roots independientes)
Toast tiene dos roots públicos que NO se anidan:
<Toast>— manual compound (Provider+Viewport+Item iteración del consumer). Children attached: Viewport, Item, Status, Main, Title, Description, Action, Close.<Toaster />— imperative auto-mount. Renderiza Provider+Viewport+ Item-loop con un template default. PasastoasterdecreateToaster(). Export separado, noToast.Toasterporque es un root competidor, no un hijo.
import { Toast, Toaster, createToaster } from '$uix/eidos/components/toast';
const t = createToaster();
// Imperative
<Toaster toaster={t} />
// Manual
<Toast toaster={t}>
<Toast.Viewport>
{#each t.toasts as toast}
<Toast.Item {toast}>
<Toast.Title>{toast.title}</Toast.Title>
</Toast.Item>
{/each}
</Toast.Viewport>
</Toast>
Convenciones de naming de tokens CSS
Los tokens CSS llevan el nombre del componente, no de la capa.
Sin prefijos de capa — ni --eidos-, ni --air-, ni --terra-,
ni --soma-.
| Forma | Uso | Ejemplo |
|---|---|---|
--{component}-… |
tokens públicos (sobreescribibles por el consumer) | --dialog-content-bg, --toggle-radius-md |
--_{component}-… |
tokens internos del recipe (no parte del API público) | --_tabs-trigger-height, --_toggle-bg |
Los [data-{component}] y [data-{component}-{part}] selectors son la
única vía pública para que el recipe se acople al runtime — toda
información cross-layer pasa por data-attrs declarados en el morfo.
Cada --{component}-* consumido por CSS debe salir de EidosConfig.recipes;
si un alias publico queda sin consumidor real, el test
src/uix/eidos/recipe-css-contract.test.ts falla.
Comparativa obligatoria por componente
Ningun componente Eidos se declara cerrado solo por envolver Soma. Antes de implementar o revisar un componente:
- Leer Air en la rama anterior (
morfo-runtime:src/uix/air/components/{name}) cuando exista. Air es la primera baseline visual. - Leer Soma/Morfo actuales para separar comportamiento, ARIA, estado, traducciones y data-attrs de la superficie visual Eidos.
- Auditar Morfo/Sema. El morfo no se considera correcto solo porque compile:
- Clasificar el componente como pasivo, interactivo o mixto.
- Justificar cualquier
0 eventsde forma explicita. - Para cada accion real de usuario revisar
family,verb,sequence,intent,target,prewriteycommit. - Verificar que Soma dispara esas ocurrencias con
runtime.trigger(...). - Evitar eventos de alta frecuencia para cambios continuos; normalmente se modelan inicio/drag confirmado/commit, no cada frame.
- Comparar contra referentes externos relevantes: Radix/Radix Themes, Ark UI, Bits UI, shadcn-svelte y React Aria cuando aplique.
- Crear/actualizar
components/{name}/README.mdcon tabla de funcionalidades, tabla Morfo/Sema, gaps y decisiones. Cada⚠️/❌debe acabar en una decision explicita: implementar ahora, diferir a Morfo/Soma, diferir a v2 o descartar por no pertenecer a Eidos. - Si hay demo en
web/routes/uix/components/{name}, sus snippets forman parte de la arquitectura del componente: deben reproducir el mismo contrato visible que el preview. En componentes schema-driven (form,auto-fields, date/time con formatos, etc.) no se permite un schema reducido que omita campos visibles, validators o defaults reales. - Solo despues tocar wrapper, recipe o tokens.
El objetivo no es copiar APIs, sino que Eidos no quede por debajo de Air ni de los referentes en funcionalidades reales. Si una capacidad pertenece a Soma, la tabla debe decirlo; si es visual, Eidos debe cubrirla o justificar el gap.
Reglas especificas para demos de formularios
- La validacion en tiempo real se modela como
validationBehaviour: 'onChange'. - Si una demo arranca en
onChange, los defaults iniciales deben ser validos salvo que el README y la UI digan explicitamente que se esta mostrando un formulario inicialmente invalido. - El snippet de Soma y el snippet de Eidos deben declarar los mismos campos
visibles que el preview: imports SIUM, schema, defaults,
Form + FieldyForm.AutoFields. No se admiten snippets que omitenage,role, campos de fecha, arrays u otros datos que el preview valida. Form.AutoFieldses renderer reflectivo de SIUM. Si necesita comportamiento nuevo, se cambia Soma /$libs/forms/ Morfo antes que Eidos.
Forma legacy (CSS-only, retired)
Hubo una fase anterior cuando eidos sólo emitía CSS (accordion.css,
dialog.css, etc., al nivel raíz de components/). Todos los
componentes han sido migrados a la forma canónica del subdirectorio.
Si encuentras un .css suelto, es un descuido — debe vivir dentro de
su subdirectorio.
Estado actual de la migración (2026-05-20)
La tanda 2026-05-17 reanuda la migración Soma -> Eidos por orden explícita.
Los wrappers nuevos siguen el mismo criterio: envolver partes públicas de Soma,
añadir sólo props visuales (size en esta tanda) y dejar comportamiento,
estado, ARIA, traducciones y escritura headless en Soma/Morfo.
| Componente | Forma canónica | Notas |
|---|---|---|
| toggle | ✅ single-part | piloto |
| switch | ✅ multi-part | Thumb |
| collapsible | ✅ multi-part | Trigger, Content |
| dialog | ✅ multi-part | Trigger, Portal, Overlay, Content, Title, Description, Close, Header, Footer |
| drawer | ✅ multi-part | + Handle |
| field | ✅ multi-part | Label, RequiredIndicator, Control, Input, HelperText, ErrorText, Prefix, Suffix |
| form | ✅ multi-part | Submit, Reset, ErrorSummary, AutoFields |
| popover | ✅ multi-part | Arrow, Anchor, Title, Description |
| toast | ✅ multi-part | + Toaster separate |
| accordion | ✅ multi-part | Item, Header, Trigger, Content |
| avatar | ✅ multi-part | Image, Fallback (eidos-native) |
| breadcrumb | ✅ multi-part | List, Item, Link, Separator, Ellipsis |
| calendar | ✅ multi-part | Header, Heading, Prev/Next, Month/YearSelect, Grid, Cell, Day |
| date-picker | ✅ multi-part | DateField + Popover + Calendar composition |
| icon | ✅ single-part | + 1697 lucide glyphs |
| meter | ✅ multi-part | Indicator |
| number-field | ✅ multi-part | Input, IncrementTrigger, DecrementTrigger, Scrubber |
| pagination | ✅ multi-part | FirstTrigger, PrevTrigger, NextTrigger, LastTrigger, Item, Ellipsis |
| progress | ✅ multi-part | Label, ValueText, Indicator |
| rating-group | ✅ multi-part | Item |
| search-field | ✅ multi-part | Input, ClearTrigger |
| select | ✅ multi-part | Trigger, Value, Indicator, Portal, Content, Viewport, Item, ItemIndicator, Group |
| combobox | ✅ multi-part | Control, Input, Trigger, Indicator, Portal, Content, Viewport, Item, ItemIndicator |
| slider | ✅ multi-part | Range, Thumb, Tick |
| tooltip | ✅ multi-part | + Group |
| tabs | ✅ multi-part | List, Trigger, Content, Indicator |
| checkbox | ✅ multi-part | Indicator, HiddenInput, Group, GroupLabel |
| radio-group | ✅ multi-part | Item, Indicator, HiddenInput, Label |
| toolbar | ✅ multi-part | Button, Link, Group, GroupItem, Separator |
| tag-group | ✅ multi-part | Label, Item, Link, RemoveButton |
| tags-input | ✅ multi-part | Control, Input, Item, ItemText, ItemDeleteTrigger, ClearTrigger |
| file-upload | ✅ multi-part | Label, Dropzone, Trigger, HiddenInput, FileList, Item, preview/progress/actions |
| editable | ✅ multi-part | Area, Control, Preview, Input, EditTrigger, SubmitTrigger, CancelTrigger |
| stepper | ✅ multi-part | List, Item, Trigger, Indicator, Separator, Content, CompletedContent, Prev/Next |
Orden recomendado para continuar
- Componentes grandes sólo con tabla previa de migración:
date-range-picker,time-field,time-picker.