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/morfo/DESIGN.md

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:

  1. Drift dentro de soma. Si renombro un part content → panel, tengo que tocar createAttrs, registerContract, la emisión en el provider, los selectores del demo, las tablas del README, y los tests. Fácil olvidar alguno.

  2. Drift futuro entre capas. Cuando llegue eidos/Dialog, su CSS estilará [data-dialog-content]. Si soma/Dialog emite [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.

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

  4. 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-expanded cambia 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 (assertContract ya 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.ts con JSDoc. Docs parsean JSDoc. Morfo no los toca. Clean separation.
  • Sí: morfo incluye props[] con { name, type, default, bindable } sin description. 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 → panel es breaking.
  • Añadir un part opcional nuevo es minor.
  • Cambiar values: ['open','closed'] a values: ['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 — A4 exige 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

  1. Morfo se queda desfasado respecto al provider. Mitigación: validación dev-time en assertProps (estricto). CI opcional que monta cada componente y valida.

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

  3. El esquema de morfo se queda estrecho y hay que ampliarlo. Mitigación: empezar minimal. Evolucionar con casos concretos, no adelantarse.

  4. ARIA runtime-dinámica no se representa bien con condition como string. Mitigación: condition es documental, no ejecutable. El provider sigue siendo dueño de la lógica. Morfo solo dice "existe este ARIA".

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

  1. ¿Morfo debería incluir props, aunque sea sin description? (§5.2)
  2. ¿Los constraints de composición deberían expresarse en morfo, o quedarse en el provider? (§5.5)
  3. ¿El valor de valueRef en MorfoAria debería estar enumerado (como data.values) o string libre? Ej: 'open' | 'closed' | 'selected' | … vs cualquier string.
  4. ¿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.
  5. ¿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-_)?
  6. ¿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.

Powered by TurnKey Linux.