19 KiB
Morfo — propuesta de diseño
Documento de diseño para revisión externa. Describe la motivación, el shape propuesto y las decisiones abiertas de
morfo, un artefacto compartido entre las capas de un framework de componentes Svelte 5.Se busca crítica técnica: puntos ciegos, precedentes pasados por alto, consecuencias no previstas, y alternativas razonables al shape propuesto.
1. Contexto del framework
El framework en cuestión (llamémoslo Vicen) organiza componentes en capas independientes que colaboran:
| Capa | Rol | Estado |
|---|---|---|
| soma | Headless. Comportamiento, estado, ARIA, keyboard, A11Y. ~66 componentes hoy. | Estable |
| sema | Semántica de comportamiento (qué semántica aplica cada componente). | No definido todavía |
| eidos | Capa visual. Tokens, variants, recipes, estilos. | Planificado |
| air | Primitives visuales sin behavior (Separator, Link, Banner, layout). | Convive con eidos |
Regla del framework: soma nunca importa de eidos. eidos y sema consumen soma, no al revés.
Componentes como Dialog, Calendar, Table existirán en varias capas a la vez: un soma/Dialog (headless) y un eidos/Dialog (estilado), ambos refiriéndose a la misma idea de componente.
2. El problema
Hoy, la información formal de un componente vive repartida en varios sitios dentro de soma:
| Información | Dónde vive hoy |
|---|---|
Nombres de parts (root, trigger, …) |
createAttrs({ parts: [...] }) en el provider |
| Data-attrs + enums válidos | registerContract({ parts: { trigger: [{ attr: 'data-state', values: ['open','closed'] }]}}) |
| Qué ARIA emite cada part | hardcoded en props = $derived.by(...) del provider |
| Keyboard contract | en los handlers + en el README |
| Props | TypeScript en types.ts con JSDoc |
| Traducciones | langs.ts con idlangref constants |
| Prosa (summary, when-to-use) | README.md |
| Comparativa vs otras libs | README.md |
Problemas derivados:
-
Drift dentro de soma. Si renombro un part
content→panel, tengo que tocarcreateAttrs,registerContract, la emisión en el provider, los selectores del demo, las tablas del README, y los tests. Fácil olvidar alguno. -
Drift futuro entre capas. Cuando llegue
eidos/Dialog, su CSS estilará[data-dialog-content]. Sisoma/Dialogemite[data-dialog-panel]después de un refactor, los estilos silenciosamente no aplican. Esto es un clásico de los design systems con layering físico. -
Sin fuente única para tooling. Docs autogeneradas, low-code builders, AI assistants que generan código consumer, form designers — todos necesitan un descriptor máquina-legible del componente. Hoy lo único cercano es
registerContract, que solo cubre data-attrs. -
Versionado impreciso. Un cambio en nombres de parts debería ser breaking. Hoy se notifica con una entrada en un CHANGELOG manual; no hay chequeo automático.
3. Propuesta: morfo
Un artefacto declarativo estructural, cross-layer, máquina-legible, por componente. Define la forma que cualquier capa que implemente el componente debe respetar.
Se llama morfo (de μορφή, "forma") y no "ADN" ni "anatomy" porque:
- Anatomy (Zag.js tiene
@zag-js/anatomy) cubre solo la estructura de parts + selectores. Si ampliamos el alcance, el nombre queda estrecho. - ADN sugiere "blueprint completo por célula/capa". Implicaría un ADN por capa (soma ADN, eidos ADN). Lo que queremos es lo compartido entre capas, no lo exhaustivo de cada una.
- Morfo es la forma que convergentemente implementan las distintas capas. Análogo a cómo un pez y un delfín comparten morfología pero su ADN es distinto.
3.1 Alcance
Morfo contiene solo contrato máquina-legible:
- Nombres de parts (árbol recursivo con posible nesting)
- Elemento HTML que renderiza cada part
- Data-attrs que emite cada part (con valores válidos enumerables)
- ARIA contract (qué atributo emite, qué estado semántico expone, condición)
- Keyboard contract (teclas por part con acción semántica)
- URL del pattern WAI-ARIA APG si aplica (pointer, no prose)
- Qué capas implementan el componente (
scope: ('soma' | 'sema' | 'eidos')[])
Morfo NO contiene (y por qué):
| Fuera de morfo | Dónde vive | Por qué |
|---|---|---|
| Summary, overview | README.md | Prose editorial, no contrato |
| Comparativa vs Radix/BaseUI/Bits | README.md | Drift externo; no ensuciar el contrato |
| Ejemplos de uso | README.md | Narrativa |
whenToUse X vs Y |
README.md | Guía de decisión, prose |
| Props (nombre, tipo, default) | types.ts con JSDoc |
Ya es canónico. Docs parsean JSDoc |
| Event handlers | {name}-provider.svelte.ts |
Código, no datos |
| State machine | {name}-provider.svelte.ts |
Código, no datos |
| Traducciones | langs.ts (idlangref) |
Registry aparte, consumido por el provider |
| Recipes (variants visuales) | eidos (cuando exista) | Capa-específico, fuera del contrato compartido |
3.2 Shape propuesto
// src/uix/morfo/types.ts
export type Layer = 'soma' | 'sema' | 'eidos';
export interface Morfo {
/** Component display name. */
name: string; // "Dialog"
/** kebab-case name. Matches createAttrs({component}). */
kebab: string; // "dialog"
/** Layers that implement this component. */
scope: Layer[]; // ['soma'] | ['soma', 'eidos']
/** WAI-ARIA APG pattern URL, if the component implements a formal pattern. */
apg?: string;
/** Top-level parts. Recursive. */
parts: MorfoPart[];
}
export interface MorfoPart {
/** PascalCase name exposed as a component. */
name: string; // "Trigger"
/** kebab-case matching createAttrs({parts}). Used for data-attr suffix. */
kebab: string; // "trigger"
/** HTML element this part renders. "none" for context-only parts. */
element: string; // "<button>" | "<div>" | "none"
/** Whether the part is required in a valid composition. */
optional: boolean;
/** Data attributes emitted by this part. */
data: MorfoData[];
/** ARIA attributes emitted by this part, with semantic description. */
aria: MorfoAria[];
/** Keyboard contract relevant when focus is on this part. */
keyboard?: MorfoKeyboard[];
/** Nested parts (e.g. Accordion.Item contains Header, Trigger, Content). */
parts?: MorfoPart[];
}
export interface MorfoData {
/** Data attribute name, e.g. "data-state". */
attr: string;
/** If the attribute is enum-valued, the complete set of valid values. */
values?: string[];
/** If omitted + no values, it's a boolean presence flag (present or absent). */
}
export interface MorfoAria {
/** ARIA attribute name, e.g. "aria-expanded". */
attr: string;
/** Semantic ID of the state exposed, e.g. "open", "selected", "expanded".
* Not a literal value; provider decides how to format at runtime. */
valueRef: string;
/** When the attribute is emitted. "always" or a semantic condition. */
condition?: string; // "always" | "when hasChildren" | "role=dialog only"
}
export interface MorfoKeyboard {
/** Key name (matches KeyboardEvent.key). */
key: string;
/** Semantic action description, e.g. "close", "toggle", "next-item". */
action: string;
}
Ningún campo de prosa. Solo contrato.
3.3 Ejemplo: dialogMorfo
// src/uix/morfo/components/dialog.ts
import type { Morfo } from '../types';
export const dialogMorfo: Morfo = {
name: 'Dialog',
kebab: 'dialog',
scope: ['soma'],
apg: 'https://www.w3.org/WAI/ARIA/apg/patterns/dialog/',
parts: [
{
name: 'Provider',
kebab: 'root', // special: root emits `data-dialog` (no part suffix)
element: 'none',
optional: false,
data: [],
aria: []
},
{
name: 'Trigger',
kebab: 'trigger',
element: '<button>',
optional: false,
data: [
{ attr: 'data-state', values: ['open', 'closed'] }
],
aria: [
{ attr: 'aria-haspopup', valueRef: 'dialog', condition: 'always' },
{ attr: 'aria-expanded', valueRef: 'open', condition: 'always' },
{ attr: 'aria-controls', valueRef: 'contentId', condition: 'always' }
]
},
{
name: 'Content',
kebab: 'content',
element: '<div>',
optional: false,
data: [
{ attr: 'data-state', values: ['open', 'closed'] },
{ attr: 'data-nested' },
{ attr: 'data-starting-style' },
{ attr: 'data-ending-style' }
],
aria: [
{ attr: 'role', valueRef: 'dialog', condition: 'modal' },
{ attr: 'aria-modal', valueRef: 'true', condition: 'modal' },
{ attr: 'aria-labelledby', valueRef: 'titleId', condition: 'when Title present' },
{ attr: 'aria-describedby', valueRef: 'descriptionId', condition: 'when Description present' }
],
keyboard: [
{ key: 'Escape', action: 'close' },
{ key: 'Tab', action: 'trap-forward' },
{ key: 'Shift+Tab', action: 'trap-backward' }
]
},
{
name: 'Title',
kebab: 'title',
element: '<div>',
optional: true,
data: [],
aria: [
{ attr: 'role', valueRef: 'heading', condition: 'always' },
{ attr: 'aria-level', valueRef: '2', condition: 'default' }
]
},
{
name: 'Description',
kebab: 'description',
element: '<div>',
optional: true,
data: [],
aria: []
},
{
name: 'Close',
kebab: 'close',
element: '<button>',
optional: true,
data: [],
aria: [
{ attr: 'aria-label', valueRef: 'translated(close)', condition: 'always' }
]
},
{
name: 'Overlay',
kebab: 'overlay',
element: '<div>',
optional: true,
data: [
{ attr: 'data-state', values: ['open', 'closed'] }
],
aria: [
{ attr: 'aria-hidden', valueRef: 'true', condition: 'always' }
]
}
]
};
Queda aprox. 100 líneas por componente. Agradable de leer de cabo a rabo.
3.4 Consumo
Desde el provider (soma):
// dialog-provider.svelte.ts
import { dialogMorfo } from '$uix/morfo/components/dialog';
import { createAttrs, registerContract } from '../../attrs';
const attrs = createAttrs(dialogMorfo); // ← lee parts del morfo
registerContract(dialogMorfo); // ← lee data+values del morfo
export class DialogProvider extends Provider<DialogOpts> {
// ... comportamiento
readonly props = $derived.by(() =>
this.assertProps({
...this.baseProps,
'aria-haspopup': 'dialog',
'aria-expanded': this.opts.open.current,
// assertProps valida contra dialogMorfo.parts.trigger.aria: si el provider
// no emite uno declarado, dev-mode warning. Si emite uno no declarado, error.
})
);
}
Desde eidos (futuro):
// eidos/Dialog.css
// (hipotéticamente) una macro CSS genera los selectores desde dialogMorfo.parts
O más realista: un script npm run generate:eidos-selectors escribe dialog.selectors.ts leyendo dialogMorfo:
export const sel = {
content: '[data-dialog-content]',
trigger: '[data-dialog-trigger]',
title: '[data-dialog-title]',
// ...
};
Eidos CSS usa sel.content, no strings. Si renombro content → panel en morfo, el generador cambia sel, los estilos siguen aplicando.
Desde docs:
<script>
import { dialogMorfo } from '$uix/morfo/components/dialog';
import README from '$uix/soma/components/dialog/README.md';
</script>
<MorfoDocs morfo={dialogMorfo} /> <!-- tabla de parts, data, aria, keyboard -->
<PropsTable source="soma/dialog/types" /> <!-- genera de JSDoc -->
<Prose>{@html README}</Prose> <!-- intro, comparativa, when-to-use -->
Tres fuentes, cada una con dueño claro, cero duplicación.
3.5 Modo estricto
El provider no puede emitir data-attrs o ARIA que no estén declarados en morfo. assertProps lo chequea en dev:
// Dentro de assertProps:
// - Si el provider emite data-X que no está en morfo → error: "undeclared data attr"
// - Si el provider emite aria-Y que no está en morfo → warning: "undeclared aria"
// - Si morfo declara aria-Z y el provider no lo emite → warning: "missing declared aria"
Escape hatch para data-attrs privados (debug, internal state):
// Cualquier attr que empieza con `data-_` se considera privado y skipea la validación.
// Convención: nunca exponer data-_* como selector público.
'data-_cursor': someDebugState
Para ARIA no hay escape hatch; ARIA es siempre público (semántico).
4. Precedentes
| Framework | ¿Qué hace similar? | ¿Qué no? |
|---|---|---|
| Zag.js / @zag-js/anatomy | createAnatomy(name).parts(...) — fuente de verdad para nombres de parts + selectores + data-attrs |
Solo cubre parts/attrs. ARIA y keyboard los emite la state machine; no hay "contract declarativo" |
| Design Tokens / Style Dictionary | Tokens como SoT, build genera CSS/JS/Figma | Para tokens, no componentes. Mismo patrón filosófico |
| React Spectrum / React Aria | Especificaciones formales por componente (Markdown) | Prescriptivo, no runtime data object |
| OpenUI (W3C) | Propuesta de estandarizar semántica de componentes | Solo spec, sin implementación consumible |
| JSON Schema / OpenAPI | Contratos machine-readable para APIs | Mismo patrón; distinto dominio |
| Radix UI | Primitives TS con data-attrs ad-hoc por componente | Sin fuente única. Cada componente declara lo suyo |
| Base UI (MUI) | Hooks-first, sin SoT estructural | — |
| Ariakit | Composición con hooks | — |
| shadcn/ui | Plantillas de código, no framework | No aplica |
Nadie declara ARIA + keyboard contract como datos machine-readable al nivel que se propone aquí. Zag llega más lejos con parts, pero no incluye el resto.
Hipótesis de por qué nadie lo ha hecho del todo:
- ARIA es dinámica (
aria-expandedcambia con state). Declararla pierde matiz. - El contrato puede quedar desfasado frente al código emisor si no hay validación runtime/build.
- Añade una capa más al desarrollo del componente.
Contra-argumentos:
- Declarar el ARIA contract (qué attrs emite, con qué condición) no pierde matiz; el valor literal sigue siendo responsabilidad del emisor. Solo declaramos QUÉ se emite, no el valor concreto.
- La validación runtime/build existe (
assertContractya lo hace parcialmente). - La capa extra se paga una vez; se amortiza cada vez que un cambio de parts se propaga automáticamente.
5. Decisiones abiertas
5.1 Donde vive morfo físicamente
Dos opciones:
A. src/uix/morfo/components/{name}.ts — cross-layer desde día 1, aunque solo soma lo consuma por ahora.
B. src/uix/soma/components/{name}/morfo.ts junto al código consumidor; mover a src/uix/morfo/ cuando eidos arranque.
Opción A es arquitecturalmente honesta (morfo es cross-layer por intención). Opción B es colocal y más cómoda ahora. Impacto real en el refactor es el mismo; el import path cambia.
Inclinación: A, desde el principio.
5.2 Props en morfo — sí o no
- No: props se quedan en
types.tscon JSDoc. Docs parsean JSDoc. Morfo no los toca. Clean separation. - Sí: morfo incluye
props[]con{ name, type, default, bindable }sindescription. La description vive en JSDoc. Duplicación mínima pero duplicación al fin.
Inclinación: No. Evitamos duplicación.
Contra-inclinación: si más tarde un tool quiere programáticamente listar props (p. ej. un form-builder que auto-genera UI para props de configuración), le resulta más difícil. Pero ese tool puede parsear types.ts con TS AST o TypeDoc.
5.3 Traducciones en morfo — sí o no
Soma tiene langs.ts por componente con idlangref constants (#?components.dialog.trigger|Open dialog). Morfo podría declarar qué keys de traducción consume cada part, para que consumers sepan "este part espera una traducción para X".
Inclinación: no por ahora. langs.ts ya es canónico para ese mapping.
5.4 Versionado
Morfo es contrato. Breaking cambios deberían detectarse.
- Renombrar
content→paneles breaking. - Añadir un part opcional nuevo es minor.
- Cambiar
values: ['open','closed']avalues: ['open','closed','opening','closing']es minor (ensanchar enum) o breaking (si un consumer hacía exhaustive match).
Propuesta: morfo tiene un version: number (semver integer) y un CI check compara morfo del branch con morfo de main para clasificar el diff.
5.5 Composition constraints
¿Declara morfo la estructura requerida? Ej: "Dialog.Content debe contener Title si no hay aria-label del Provider".
- Sí:
parts[x].requires: ['title'] | 'if:not-aria-labelledby'— morfo expresa constraints de composición. Más poder, más complejidad. - No: los constraints los valida el provider runtime (como ahora —
A4exige ARIA relationships). Morfo solo dice "qué parts existen".
Inclinación: no. Menos poder, menos chance de que el lenguaje de morfo se vaya de madre. Los constraints siguen en el provider.
6. Riesgos
-
Morfo se queda desfasado respecto al provider. Mitigación: validación dev-time en
assertProps(estricto). CI opcional que monta cada componente y valida. -
Añadir un part nuevo es "tocar dos sitios" (morfo + provider). Mitigación: es lo que toca para que sea SoT real. Hoy ya son tres sitios (createAttrs + registerContract + provider emission). Morfo reduce a dos + un generador.
-
El esquema de morfo se queda estrecho y hay que ampliarlo. Mitigación: empezar minimal. Evolucionar con casos concretos, no adelantarse.
-
ARIA runtime-dinámica no se representa bien con
conditioncomo string. Mitigación: condition es documental, no ejecutable. El provider sigue siendo dueño de la lógica. Morfo solo dice "existe este ARIA". -
Low-code tools / codegen consumers tendrán que aprender el shape de morfo. Mitigación: el shape es pequeño y hay un único punto de consumo.
7. Preguntas concretas para revisión
- ¿Morfo debería incluir props, aunque sea sin description? (§5.2)
- ¿Los constraints de composición deberían expresarse en morfo, o quedarse en el provider? (§5.5)
- ¿El valor de
valueRefenMorfoAriadebería estar enumerado (comodata.values) o string libre? Ej:'open' | 'closed' | 'selected' | …vs cualquier string. - ¿Hay un precedente que se me escapa donde alguien haya hecho algo parecido? El parecido más cercano que encontré es Zag anatomy + design tokens, pero ninguno llega a ARIA + keyboard contract declarativo.
- ¿El modo estricto (§3.5) es demasiado restrictivo? ¿Habría componentes legítimos que necesiten emitir data-attrs no-declarados públicos (no privados con
data-_)? - ¿Qué patologías he pasado por alto? Especialmente sobre drift vs provider, sobre evolutions del schema, y sobre generación de código downstream.
Gracias por la revisión. Feedback concreto sobre cualquiera de los 6 puntos o cualquier parte del shape es bienvenido. Este doc es el boceto; todavía no hay líneas de código.