## Slider thumb misalignment
`SliderThumbProvider.thumbStyle` used `translate(50%, -50%)` for both LTR
and RTL. For LTR that pushed the thumb RIGHT by half its width from the
`left: X%` anchor — visible symptoms in the time-picker / time-range-picker
demos:
- thumb never reached `left: 0` (centre at value=0 was offset by
thumbWidth/2)
- thumb overshot the track on the right edge at value=max
- drag felt off by the same amount throughout
Fix: use `translate(-50%, -50%)` for LTR (thumb centre aligns with anchor)
and keep `translate(50%, -50%)` for RTL (anchor is `right: X%`, thumb must
shift right by half its width to centre-align). Matches the logic already
used by `SliderTickProvider` which chose `tx = isRtl ? '50%' : '-50%'`.
Browser probe confirms: value=0 now centres at 0%, value=30/59 at ~50.8%,
style attribute reports `translate(-50%, -50%)` in LTR.
## soma README documents morfo
Added §4b "Morfo — contrato declarativo cross-layer" covering: why morfo
exists (6-place drift without it), how providers consume it
(`createAttrs(morfo)` + `registerContract(morfo)`), the mandatory
`as const satisfies Morfo` authoring pattern for literal-typed inference,
the 3-layer validation pipeline (schema / assertContract / morfo-check),
and the boundary (what does NOT go in morfo — props, prose, langs, state
machines, visual recipes). Updated the §4 structure listing so
`attrs/create-attrs.ts` shows the new signature.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@ -96,6 +96,8 @@ import * as Drawer from '$soma/components/drawer';
soma no importa de `$lib` — cualquier utilidad que necesite (e.g., funciones triviales) debe vivir dentro de soma.
soma **sí importa** del paquete hermano **morfo** (`$uix/morfo`), que es el contrato declarativo de la superficie DOM de cada componente (partes, data-attrs, ARIA, keyboard, focus). Ver §4b.
---
## 4. Estructura
@ -119,9 +121,9 @@ src/uix/soma/
│ ├── compose-handlers.ts
│ └── index.ts
│
├── attrs/ ← data-* system
│ ├── create-attrs.ts ← createAttrs({ component, parts })
│ ├── helpers.ts ← boolToStr, boolToEmptyStrOrUndef, etc.
│ └── index.ts
│
@ -211,6 +213,81 @@ src/uix/soma/
---
## 4b. Morfo — contrato declarativo cross-layer
Cada componente tiene un archivo en `src/uix/morfo/components/{kebab}.ts` que declara, en un único objeto tipado, la **superficie DOM pública** del componente:
- **parts** — el árbol de partes (name, kebab, kind, defaultElement, role, states, supportsNesting).
- **data** — qué data-attrs emite cada parte, con valores enum cuando aplica y severity (`required` / `recommended` / `optional`).
- **aria** — qué atributos ARIA emite cada parte, con la fuente del valor tipada vía tagged union (`v.literal`, `v.stateRef`, `v.partRef`, `v.propRef`, `v.translationRef`) y condición de emisión opcional.
- **keyboard** — los atajos de teclado relevantes por parte.
- **focus** — política de foco para overlays (`initial`, `trap`, `return`, `restore`).
- **apg** — URL al patrón WAI-ARIA APG cuando aplica.
- **scope** — las capas que implementan el componente: `['soma']`, `['soma', 'eidos']`, etc.
El morfo es la **única fuente de verdad** del contrato público. soma, air, eidos, sema y la docs auto-generada lo consumen todos.
### ¿Por qué morfo existe?
Antes de morfo, la información estructural de un componente vivía en seis sitios:
1. `createAttrs({ parts: [...] })` — nombres de partes dentro del provider.
2. `registerContract({ parts: {...} })` — enums de data-attrs en el provider.
3. ARIA hard-coded en cada `$derived.by(...)` de props.
4. Keyboard handlers distribuidos por el provider.
5. Prose en el README.
6. Selectores en CSS de air/eidos, `.csem` de sema y tablas de docs.
Renombrar una parte (`content` → `panel`) tocaba 6+ sitios sin verificación automática. Drift cross-layer (soma emite `data-dialog-content`, eidos estiliza `data-dialog-panel`) era silencioso.
Con morfo, **todo se declara una sola vez**. `createAttrs` y `registerContract` consumen el morfo directamente. El script `npm run morfo:check` valida el DOM real contra la declaración en CI.
### Cómo soma consume un morfo
Cada provider raíz del componente empieza así:
```ts
import { createAttrs, registerContract } from '../../attrs';
import { dialogMorfo } from '../../../morfo/components/dialog';
`createAttrs` es **genérica con `const` type parameter** (TS 5.0+): infiere el tipo del objeto retornado a partir de la forma literal del morfo. Si un wrapper o provider escribe `attrs.trigerr`, es error de compilación. El soporte de autocomplete lista las partes válidas.
**Requisito de autoría**: cada morfo se declara como `as const satisfies Morfo`:
// ❌ Pierde literales, degrada `createAttrs` a `Record<string, string>`
export const dialogMorfo: Morfo = { ... };
```
Ver el dev guide completo en [`src/uix/morfo/README.md`](../morfo/README.md).
### Validación
Tres capas atrapan tres clases de drift:
- **Schema (build/dev)**: `validateMorfo(morfo)` en `src/uix/morfo/schema.ts` valida shape (sium) + invariantes cruzados (kebabs únicos, `partRef.target` resuelve, `stateRef.state` está en `states[]`, etc.).
- **Strict mode en `assertContract` (dev runtime)**: valida que los data-attrs emitidos por el provider tienen valores declarados en el morfo. Logs warnings en consola cuando algo se desvía.
- **CI**: `npm run morfo:check` navega a cada demo y valida el DOM real contra el morfo; `npm run morfo:vocabulary` detecta divergencias de vocabulario canónico (`open|closed`, `active|inactive`, etc.).
### Qué NO va en morfo
| No | Va en |
|----|-------|
| Props del componente | `{component}/types.ts` con JSDoc |
| Event handlers, state machines | `{component}-provider.svelte.ts` |
| Recetas visuales | `src/uix/eidos/` (futuro) |
---
## 5. Sistema reactivo
Los runes de Svelte 5 (`$state`, `$derived`) son compiler magic — solo funcionan en archivos `.svelte` y `.svelte.ts`, y no se pueden pasar como valores entre clases o funciones en TypeScript puro. soma necesita exactamente eso: pasar estado reactivo como argumento de constructor para componer Providers, layers y gestures. La capa `reactive/` resuelve esto con contenedores (`Active<T>`, `State<T>`) que envuelven runes y exponen `.current` — el mismo patrón que React refs y Solid signals. Cuando Svelte ofrezca signals exportables de primera clase, esta capa se convierte en un alias thin migrable sin tocar los consumidores.