diff --git a/src/uix/soma/README.md b/src/uix/soma/README.md index cef9d5e6d..54aa79ced 100644 --- a/src/uix/soma/README.md +++ b/src/uix/soma/README.md @@ -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 }) -│ ├── contracts.ts ← registerContract, assertContract +├── attrs/ ← data-* system (consumes morfo — see §4b) +│ ├── create-attrs.ts ← createAttrs(morfo) — literal-typed attr map +│ ├── contracts.ts ← registerContract(morfo), assertContract │ ├── 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'; + +const attrs = createAttrs(dialogMorfo); // typed: { provider: 'data-dialog', trigger: 'data-dialog-trigger', ... } +registerContract(dialogMorfo); +``` + +`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`: + +```ts +// ✅ Obligatorio +export const dialogMorfo = { ... } as const satisfies Morfo; + +// ❌ Pierde literales, degrada `createAttrs` a `Record` +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 | +| Summary, comparativa, ejemplos | `{component}/README.md` | +| Traducciones | `{component}/langs.ts` (idlangref) | +| 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`, `State`) 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. diff --git a/src/uix/soma/components/slider/slider-provider.svelte.ts b/src/uix/soma/components/slider/slider-provider.svelte.ts index 445c5c808..3a6768fcb 100644 --- a/src/uix/soma/components/slider/slider-provider.svelte.ts +++ b/src/uix/soma/components/slider/slider-provider.svelte.ts @@ -331,8 +331,15 @@ export class SliderThumbProvider extends Provider { const isHorizontal = this.provider.opts.orientation.current === 'horizontal'; const isRtl = this.provider.opts.dir.current === 'rtl'; if (isHorizontal) { + // LTR anchors with `left: X%`, so the thumb must shift LEFT by half + // its own width to center-align its centre with the anchor + // (`translateX(-50%)`). In RTL the anchor is `right: X%`, and the + // thumb must shift RIGHT by half its width (`translateX(50%)`). + // The previous code used `+50%` for both, which offset every thumb + // by `thumbWidth` and made the rightmost thumb overshoot the track. const side = isRtl ? 'right' : 'left'; - return { [side]: `${this.percent}%`, top: '50%', transform: 'translate(50%, -50%)' }; + const tx = isRtl ? '50%' : '-50%'; + return { [side]: `${this.percent}%`, top: '50%', transform: `translate(${tx}, -50%)` }; } return { bottom: `${this.percent}%`, left: '50%', transform: 'translate(-50%, 50%)' }; });