# CONTINUE — el eje de dirección (RTL/LTR) > **Kickoff**: _"Lee `docs/process/CONTINUE-direction.md` y empieza por §2.1."_ > **Fecha**: 2026-08-02 · Rama `alpha-0.1-dir-prefs` (sale de `alpha-0.1-sec-dom`). > **§1 es lo que ya está cerrado — no lo rehagas.** Lo abierto empieza en §2. > > Hermano de este documento: [`CONTINUE-player-rtl.md`](./CONTINUE-player-rtl.md), > cuyo §1 cerró este hilo. Sus §2–§5 (disposición de botones del player, iconos de > seek, escenario mixto, `Captions`, waveform-como-scrubber) siguen abiertos y son > de otro asunto. --- ## 1. Lo cerrado — 3 commits, verificados | Commit | Qué | | --- | --- | | `013ceac57` | La raíz: `getDir()` reactivo · estampado condicional · `activeDir()` único · 99 ficheros | | `5469e05df` | 49 demos + arnés `SystemAxes` a `auto·ltr·rtl` · prosa declarada inglesa · 51 ficheros | | `538f4932b` | El handoff anterior decía que esto seguía roto | | **sin commitear** | **§2.1 cerrada**: el guard RTL-1 + los 23 hallazgos + los 12 `[dir='rtl']` + el pin del `
` (§6) | Empezó como «el slider no responde al RTL» y eran **dos defectos independientes**: **El del slider** — `slider.css` emparejaba `inset-inline-start: 50%` (lógico) con `translateX(-50%)` (**físico, no se voltea**) en las tres reglas verticales. Medido en Chrome: raíz/pulgar/ticks en x=961, raíl y relleno en x=955. El `Tick` se libraba porque ya usaba `margin-inline-start` — ése era el idioma correcto del propio fichero. Mismo defecto en `accordion.css` (`text-align: left`), que era el **único** `text-align` físico de todo eidos. **La causa raíz, que no estaba en ninguna hipótesis** — `makeDimension().get()` (`arts/prefs/active-prefs.svelte.ts`) leía `engine.snapshot()` esquivando la celda `$state`. Así que `soma.prefs.getDir()` era una **lectura sin tracking** y los ~64 componentes que resuelven su dirección desde prefs la **congelaban al montarse**. La proyección DOM se salvaba porque usa `onChange`: misma dimensión, dos caminos de lectura, uno solo reactivo. **Y el estampado** — 33 de 37 componentes escribían `dir` con un valor SIEMPRE concreto, así que una app que ponga `` sin registrar la preferencia se encontraba 33 islas del revés. Ahora el atributo se omite cuando nadie afirmó nada. Es el modelo de **Zag** (`prop("dir")`), aplicado uniforme — Zag lo incumple en 12 de sus 51 máquinas. ### El contrato, ratificado por el usuario ``` prop dir → soma.prefs.getDir() → 'ltr' ``` **SIN paso del padre.** El `dir` del DOM es **proyección, no fuente** (`docs/architecture/active-architecture.md:407`). La herencia que se obtiene ahora la hace el navegador por **ausencia** de atributo, no por ninguna lectura del DOM. Fase posterior que el usuario dejó planteada, sin fecha: **qué propiedades podrían heredarse implícitamente del padre** y si beneficia al framework. ### La forma única, para no volver a divergir Había **diez** formas de resolver lo mismo en los wrappers y **cinco** de declarar el tipo, con un cuarto escalón semántico —heredar del menú padre— enterrado en dos expresiones sueltas. Ahora: ```ts // wrapper — idéntico en los 36 dir: activeDir(() => dir, soma) // el escalón extra se declara donde se ve dir: activeDir(() => dir ?? parentMenu?.opts.dir.current, soma) // provider — dos valores, nunca uno readonly resolvedDir = $derived.by(() => this.opts.dir.current ?? 'ltr'); // la matemática dir: this.opts.dir.current // el atributo, CRUDO ``` `src/uix/soma/direction.ts` documenta el porqué. **La regla que hay que defender: el atributo NUNCA tiene valor por defecto.** ### Guards que nacieron en rojo - `src/arts/prefs/test/active-prefs-reactivity.svelte.test.ts` — intent · derivación desde el idioma · **granularidad por clave** (el negativo: un commit de `accent` NO debe despertar al lector de `direction`; con una celda global se pondría rojo). - `src/uix/active-uix/test/prefs-view.svelte.test.ts` — `getDir()` distingue «nadie afirmó» de «ltr». ⚠️ **Los dos DEBEN seguir siendo `*.svelte.test.ts`.** El proyecto `server` de vitest compila el `$state` fuera (transform de servidor de Svelte), así que un `.test.ts` ahí pasaría hiciera lo que hiciera el código. --- ## 2. Lo que queda — por orden de valor ### 2.1 · El guard — CERRADA (ver §6) ### 2.2 · Geometría lógica en los cinco que calculan píxeles desde JS | componente | sitios | | --- | --- | | `slider` | 6 | | `carousel` | 2 (signo del `translate3d` + signo del swipe) | | `number-field` | 2 (signo del arrastre) | | `css-field` | 2 (signo del arrastre) | | `dropdown-menu` | colocación flotante | **Funciona hoy** — no es un bug abierto. Pero es lo que convierte un `dir` equivocado en catástrofe en vez de en detalle cosmético, y la fila RTL del contrato manda lógicas para el flujo (`left`/`right` físicos sólo como API de *placement* flotante, excepción EID-3). Migrar el PINTADO a `inset-inline-*` deja el JS con `dir` sólo para el signo del gesto y las flechas. **De uno en uno y con verificación en navegador.** Toca comportamiento ya probado. ⚠️ **Matiz que salió al cerrar §2.1**: el `sliding-indicator` era el mismo defecto y se resolvió al revés — soma mide `itemRect.left - rootRect.left`, una coordenada FÍSICA, así que el arreglo fue hacer FÍSICA el ancla (`left: 0`), no lógico el pintado. Antes de migrar cada sitio, mira de qué lado está la medida: si el JS mide en físico, lo coherente es el ancla física. Lo mismo vale para el arco polar del `menu-dial` y los handles de brújula del `cropper`. ### 2.3 · El `secondary-range` vertical en RTL no lo ha visto nadie Arreglado por simetría con el `range` (misma regla, mismo cambio), y el `range` sí se midió. Pero **la combinación vertical + RTL + `secondaryValue` no es observable**: la única demo que expone `secondaryValue` es la del **waveform**, y es horizontal. O se cablea `secondaryValue` como control en la demo del slider, o se declara como verificado-por-construcción y no por vista. ✅ **La mitad «no observable» ya no lo es**: el pin del `
` (§6.4) era lo que impedía ver RTL en CUALQUIER demo. Ahora el toggle del topbar y el árabe llegan al componente. Queda sólo cablear `secondaryValue` en la demo del slider. ### 2.4 · `parts[2]` en la demo del slider `web/routes/uix/components/slider/+page.svelte:409` indexa `SecondaryRange` en vez de `Thumb` — la tabla de teclado sale vacía y es el **único** error de tipos del fichero. Preexistente (venía de cuando `SecondaryRange` se insertó en el índice 2); señalado y no tocado por ser ajeno al eje. --- ## 3. Encontrado por el camino, NO de este hilo - **`html lang` no sigue al idioma.** El `dir` sí se proyecta; el `lang` se queda en `en` con árabe seleccionado. Afecta a selección de fuentes, corte de palabras y lectores de pantalla. - **`perm:check` sin ejecutar.** El topbar ya lleva `data-perm-step="90"`, lo que hace que **toda** ruta bajo el layout tenga paso (antes saltaba 158). La pasada se vuelve mucho más larga, y como el runner usa **un solo contexto de navegador**, la dirección persiste en `localStorage` y las rutas alternan ltr/rtl. - **91 ficheros de test falsifican `Soma.require()`** con `vi.spyOn(Soma, 'require') .mockReturnValue({ prefs: { getDir: () => dir } } as unknown as Soma)`. Incumple una Regla Crítica de CLAUDE.md y significa que esa parte de la batería **no ejercita el código real**. Es otro proyecto entero, pero es el hallazgo más grave de la lista. - **El `?` desplazado dentro de un componente RTL no es arreglable desde el framework.** Es el algoritmo bidi de Unicode sobre texto inglés en un párrafo RTL: el `?` es neutro y al final de la tirada adopta la dirección del párrafo. El componente hace bien en estar en `rtl`. Se cierra **traduciendo el contenido de las demos** o marcándolo — que es exactamente la parte que las cinco librerías de referencia dejan al consumidor. --- ## 4. Investigación de referencias — hecha, no la repitas 25 agentes, 13 hallazgos confirmados, **5 tumbados** por refutación con la fuente delante. | | ¿estampa `dir`? | cómo resuelve | | --- | --- | --- | | **MUI** | nunca | tema + contexto `useRtl`; voltea CSS físico con stylis | | **react-aria** | nunca (salvo portales) | del *locale* por contexto; te manda escribir `
` en tu raíz | | **bits-ui** | no verificado que escriba | prop con default duro `'ltr'` + `getComputedStyle().direction` en roving-focus | | **Radix** | **siempre** | prop → contexto → `'ltr'`; sin salida → su `discussions/1405` | | **Zag/Ark** | **46/51 máquinas** | prop → contexto, **valor `prop("dir")`: ausente si nadie lo pidió** | **Ninguna** de las cinco emite `dir="auto"`, `` ni `unicode-bidi`. Dos datos que costaron trabajo y conviene no volver a averiguar: - **`unicode-bidi: isolate` NO arregla el `?`.** Aísla la tirada respecto a sus hermanas, pero no cambia su dirección base, y el `?` está *dentro* de la tirada. - **`dir="ltr"` sí, y de paso aísla.** Verificado en la hoja de estilos de agente del WHATWG: `… bdi, output, [dir=ltr i], [dir=rtl i], [dir=auto i] { unicode-bidi: isolate; }`. Un atributo, dos efectos. - **`dir="auto"` es la herramienta equivocada** cuando SÍ se sabe la dirección: mira sólo el primer carácter fuerte, la spec llama a la heurística *"very crude"* y el W3C documenta que falla justo en esta clase de texto. Excepción correcta que se queda: `chat-message.css:156` usa `unicode-bidi: plaintext` porque el texto de un mensaje es de dirección **incognoscible al escribir**. Ése es el discriminador de la doctrina: **auto-detectar sólo donde no se puede saber; donde se sabe, declararlo en el sitio que lo sabe.** --- ## 5. Trampas de método — me costaron horas, léelas - **Ensancha el TIPO primero.** Al pasar `dir: Direction` → `Direction | undefined` en las opts, cada sitio de LÓGICA que asumía un valor concreto se volvió error de compilación y el compilador enumeró el trabajo. **Pero no cubre la otra mitad**: `dir: undefined` es un valor de atributo válido, así que los estampados compilan pasara lo que pasara. Esa mitad se verifica **contando** (`grep -c` de crudo vs resuelto por fichero), no confiando. - **Los barridos con regex mintieron cuatro veces de cuatro**: `resolvedDir` autorreferencial en 17 ficheros (el regex reescribió el cuerpo de la propia declaración), duplicados en 3, campo insertado en 3 clases sin `dir`, y `activeDir(valor)` en vez de `activeDir(() => valor)` en 10 wrappers. - **Anclaje de clase que falla**: `^export class \w+Provider \{$` exige que la línea TERMINE en `{`, así que se salta `class X implements Y {` y aterriza en la clase siguiente. Usa `^export class \w+Provider\b[^\n]*\{$`. - **Discriminante que sí generaliza** cuando un símbolo tiene dos roles: la forma sintáctica, no el identificador. Aquí `dir: …` (clave de objeto) = estampado; cualquier otro `.opts.dir.current` = lógica. Sobrevivió a las tres formas distintas del catálogo. - **NO lances `prettier --write` sobre un directorio entero.** Sobre las demos generó **165 ficheros y +31.000 líneas** de ruido y **rompió dos atributos** normalizando `data-perm-mode='type="X"'` a comillas dobles. Revertido y reaplicado sólo el cambio: 49 ficheros, +357/−161. Formatea únicamente los ficheros que tocas, y mira el tamaño de la diff después. - **El panel de navegador embebido no sirve** para nada visual (no compone frames). Usar el Chrome real (`mcp__claude-in-chrome__*`). - **`smoke` es inestable.** Con mis cambios falló en `/demos/motion`, `/temas`, `/uix/components/card`; con los cambios stasheados falló en **dos rutas distintas** (`/demos/cristal`, `/demos/heroscrolling`). Conjuntos disjuntos ⇒ no son regresiones. Compara siempre contra una pasada de referencia antes de culpar a tu diff. - ⚠️ **Rama compartida.** El usuario commiteó `42d58c394` mientras yo trabajaba. Verifica `HEAD` antes de dar por sabido el estado. --- ## 6. Sesión 2026-08-02 — §2.1 cerrada, y lo que arrastró Sin commitear. `check` = **74 = línea base exacta** en las tres pasadas. ### 6.1 · El guard RTL-1 `src/uix/eidos/rtl-lint.ts` (lógica) + `rtl-lint.test.ts` (**14/14**) + `scripts/rtl-check.ts` + `npm run rtl:check`. La fila RTL de `docs/guides/component-guide.md` deja de decir «rule (LIVE)». El test ancla el caso histórico: `slider.css` ANTES de `013ceac57` sale rojo, el arreglo que se commiteó sale verde, y el eje de bloque (`inset-block-start` + `translateY`) no se marca. **Dos desviaciones del enunciado de §2.1, deliberadas:** - **Amplía** a la propiedad independiente `translate:`, que se usa MÁS que `transform: translate*` (87 apariciones frente a 36) y tiene el mismo defecto. Ocho de los 24 hallazgos eran de esa forma. - **Recorta** `padding-inline*`: no mueve la caja contra su ancla, así que no puede pelear con un translate. Sólo añadía ruido. Vía de escape `/* rtl-physical: */` en la línea del translate o la de encima. Uso legítimo: el signo se invierte en otra regla bajo `:dir(rtl)` y el guard no puede ver esa compensación. ### 6.2 · La cosecha: 24 → 1 El único que queda es `palabras-chrome.css:446`, **auditado y no tocado** por la regla de no escribir en palabras. Los 23 restantes cayeron en tres familias: | Familia | Cuántos | Arreglo | | --- | --- | --- | | **Centrado** | 10 | `margin-inline-start: calc( / -2)` si el tamaño se conoce (idioma del slider); `inset-inline: 0` + `margin-inline: auto` si lo fija el contenido | | **Direccional** | 6 | El signo se invierte con `:dir(rtl)`; el par queda marcado `rtl-physical` | | **Físico** | 7 | `left`/`right` — geometría de brújula (`cropper`) y polar (`menu-dial` arco) | ⚠️ **NO añadas `inline-size: fit-content` a ciegas** en el centrado por márgenes automáticos: al trigger del `carousel` le comió el ancho de 36px a 17px porque su tamaño venía de fuera. Sólo hace falta cuando el elemento no tiene ancho determinable, y hay que medirlo después. **Verificado en Chrome**: switch (roto→arreglado, medido y visto), carousel, avatar, cropper, menu-dial, chat-log. **NO verificado, y conviene mirarlo**: `archetypes` y `proof-of-human` (sus reglas viven en `@media (pointer: coarse)`, invisible en escritorio) y `sliding-indicator` (la demo del `radio-group` segmented no llegó a medir — `--indicator-w` salía 0). ### 6.3 · `:dir()` es la doctrina; `[dir='rtl']` está PROHIBIDO Los 12 selectores `[dir='rtl']` que había en eidos estaban **todos rotos**, en los dos sentidos opuestos del mismo error. Medido en Chrome: | Forma | Cuántos | Fallo | Medición | | --- | --- | --- | --- | | `[dir='rtl'] ` | 5 | **Se aplica de más** — capta un ancestro RTL e ignora un `dir` más cercano que redeclare | `sidebar`: `direction: ltr` y aun así matchea | | `[data-x][dir='rtl']` | 7 | **No se aplica nunca** — el atributo ya no se estampa por defecto (`013ceac57`) | `tree-view`: `direction: rtl` de verdad y NO matchea | Migrados los 12 a `:dir()`, que acierta los tres casos (atributo propio, heredado, y redeclarado por un ancestro intermedio). Verificado en `sidebar` y `tree-view`. **No lo reintroduzcas.** Con el contrato «el atributo nunca tiene defecto», `[dir='rtl']` no puede expresar «la dirección efectiva de este elemento es RTL». Baseline desde 2023 (Chrome 120, Safari 16.4, Firefox 49). ### 6.4 · El pin del `
` anulaba la otra mitad de `5469e05df` `5469e05df` hizo dos cosas que **se cancelan**: puso las demos en `auto` (sin prop `dir`, resolviendo por prefs → **no estampan atributo → heredan del DOM**) y a la vez clavó `
` sobre ellas. Resultado: el toggle del topbar y el árabe llegaban a `` y **morían en el `
`**. Las 51 demos estaban congeladas en LTR. El comentario que lo justificaba afirmaba que «los componentes siguen las prefs, así que este contenedor no les afecta». Es cierto para la LÓGICA (teclado, cálculos JS con `resolvedDir`) y **falso para el CSS**, que depende del `direction` computado, y éste viene del DOM. Medido en el sidebar: `html dir="rtl"` con el panel todavía a la izquierda, y quitar ESE atributo y nada más lo movía a la derecha. Quitado el `dir` del `
` (el `lang="en"` se queda: es correcto y no toca la dirección). **Coste aceptado por el usuario**: la prosa inglesa vuelve a mostrar la puntuación desplazada en RTL. Es cosmético y sólo en un modo de inspección; demos que no pueden demostrar, no. Si se quiere recuperar, **el pin va alrededor de la PROSA**, nunca alrededor del canvas que también contiene los ejemplos vivos. ### 6.5 · Suelto, para quien siga - **Hay componentes estampando `dir=""`** (cadena vacía) en vez de omitir el atributo — visto en el wrapper del `sidebar` y en `tree-view`. No rompe nada (valor inválido ⇒ hereda), pero contradice la letra del contrato y hace que `[dir]` matchee. Sin investigar de dónde sale. - `src/uix/eidos/lint.test.ts` falla por `audio-player` — **preexistente**, verificado con stash. Es del hilo del sonido, no de este eje. - Los 9 CSS tocados ya fallaban `prettier --check` ANTES de tocarlos. No los formatees: son ficheros enteros de ruido (§5).