14 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.
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 = DrawerRoot 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 SomaXxxProviderProps
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 { Provider as SomaDrawerProvider } from '$soma/components/drawer';
import type { DrawerProps } from './types';
let {
open = $bindable(false),
activeSnapPoint = $bindable(null),
isDragging = $bindable(false),
children,
...rest
}: DrawerProps = $props();
</script>
<SomaDrawerProvider {...rest} bind:open bind:activeSnapPoint bind:isDragging>
{@render children?.()}
</SomaDrawerProvider>
Patrón de import:
import { Provider as SomaXxxProvider }desde$soma/components/{x}. Eidos no crea una fachada headless propia por componente; envuelve las partes públicas de Soma directamente.
{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 { Trigger } from '$soma/components/drawer';
import type { DrawerTriggerProps } from './types';
let { children, ...rest }: DrawerTriggerProps = $props();
</script>
<Trigger {...rest}>
{@render children?.()}
</Trigger>
index.ts — compone el namespace
Asignación explícita per-property sobre el root component. NO usar
Object.assign(DrawerRoot, { 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 DrawerRoot 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 DrawerRoot & {
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 = DrawerRoot 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 as SomaDrawerProviderProps,
TriggerProps as SomaDrawerTriggerProps,
ContentProps as SomaDrawerContentProps
// …
} from '$soma/components/drawer';
export type DrawerProps = SomaDrawerProviderProps;
export type DrawerTriggerProps = SomaDrawerTriggerProps;
// Eidos añade props visuales que el recipe consume vía data-attrs
export type DrawerContentProps = SomaDrawerContentProps & {
size?: ResponsiveProp<DrawerSize>;
};
Sin
XxxFlatProps, sinXxxProviderProps as ProviderPropsaliases. Hay un soloXxxProps(el del root) y losXxxPartPropsper hijo.
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, switch, icon, avatar)
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>
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.
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-17)
| Componente | Forma canónica | Notas |
|---|---|---|
| toggle | ✅ single-part | piloto |
| switch | ✅ single-part | |
| collapsible | ✅ multi-part | Trigger, Content |
| dialog | ✅ multi-part | Trigger, Portal, Overlay, Content, Title, Description, Close, Header, Footer |
| drawer | ✅ multi-part | + Handle |
| popover | ✅ multi-part | + Arrow, Anchor |
| toast | ✅ multi-part | + Toaster separate |
| accordion | ✅ multi-part | Item, Header, Trigger, Content |
| avatar | ✅ multi-part | Image, Fallback (eidos-native) |
| icon | ✅ single-part | + 1697 lucide glyphs |
| meter | ✅ multi-part | Indicator |
| number-field | ✅ multi-part | Input, IncrementTrigger, DecrementTrigger, Scrubber |
| pagination | ✅ multi-part | PrevTrigger, NextTrigger, Item, Ellipsis |
| progress | ✅ multi-part | Indicator |
| rating-group | ✅ multi-part | Item |
| search-field | ✅ multi-part | Input, ClearTrigger |
| 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 |