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/docs/process/CONTINUE-direction.md

95 KiB

CONTINUE — el eje de dirección (RTL/LTR)

Kickoff: "Lee docs/process/CONTINUE-direction.md y sigue por §8." Fecha: 2026-08-03 · Rama alpha-0.1-dir-prefs (sale de alpha-0.1-sec-dom). §1, §6 y §7 son lo cerrado — no lo rehagas. Lo abierto está en §2 (lo que sobrevive), §3 (ajeno al eje) y §8, que es la cola priorizada para hoy.

Hermano de este documento: 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
ec533845d…714a1ddb5 §2.1 + §2.2 cerradas (§6): guard RTL-1 · 23 hallazgos · los 12 [dir='rtl'] → :dir() · el pin del <main> · tabs · virtualizadores · float-panel · rating-group · splitter · carousel
8086f21d6…01b891b64 Las gráficas (§7): 6 defectos de RTL en el chart + funnel, calendar-heatmap, heatmap matriz, radar, y los bloques de código

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 <html dir="rtl"> 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:

// 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 <main> (§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 <div lang dir> 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", <bdi> 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: <razón> */ 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(<size> / -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.

Los tres que faltaban, verificados después con un probe de Playwright y Emulation.setEmulatedMedia (pointer: coarse), porque el navegador embebido no llega a esa media query:

  • archetypes (checkbox y switch): margin-inline-start: -22px con slop de 44px — la mitad exacta — y translate: 0 -50% sin componente X. Barrido de hit-test: alcanza 22/21 y 22/22 px, simétrico en LTR y en RTL.
  • proof-of-human: -23px sobre 46px, translateY(-22) sin X, hit-test 23/23.
  • sliding-indicator: el CSS es correcto — con medida fresca cae exacto en RTL. Pero destapó §6.6.

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'] <desc> 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 <main> 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ó <main data-uix-canvas dir="ltr"> sobre ellas. Resultado: el toggle del topbar y el árabe llegaban a <html> y morían en el <main>. 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 <main> (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.6 · El indicador deslizante no se re-medía al voltear

Verificar §6.2 destapó la otra mitad del sliding-indicator. MeasuredIndicator (src/uix/soma/layers/measured-indicator.svelte.ts) re-mide cuando cambian root o active, cuando alguno redimensiona, en resize de ventana y en visibilitychange. Voltear la dirección no redimensiona nada —los ítems sólo cambian de POSICIÓN— y active conserva su identidad, así que --indicator-x se quedaba con la coordenada de la disposición anterior.

Arreglado con una opción dir (un getter) que el $effect lee antes del return temprano, para que la dependencia se registre en todas las pasadas. El consumidor (radio-group) le pasa resolvedDir. Getter y no lectura del DOM: la dirección viene de la cadena prop/prefs, y el atributo es proyección, no fuente.

⚠️ TRAMPA DE MÉTODO, me costó una pasada entera: el primer probe volteaba con el.setAttribute('dir', 'rtl') y daba «no arreglado» — pero eso no cambia resolvedDir, que sale de la prop/prefs, así que la dependencia no podía dispararse. Para probar cualquier cosa del eje hay que mover la dirección por el CAMINO REAL (el toggle del topbar o el control de la demo), nunca tocando el DOM.

A/B con git stash sobre los dos ficheros, por la cadena real: sin el arreglo, translate se queda en 177.156px y el indicador cae 173px fuera del seleccionado; con él, pasa a 4px y dx = 0. check 74 = línea base, 73/73 en soma/layers + radio-group.

Único consumidor hoy: radio-group. Si aparece otro, tiene que pasar dir.

6.7 · Los otros dos indicadores — uno bien, otro roto

Buscados los que comparten mecanismo con §6.6. Medidos, no supuestos:

  • navigation-menu: correcto, no se toca. Su indicador sólo existe mientras un menú está ABIERTO, así que abrirlo ES el disparador de la medida y nunca queda rancia; voltear cierra el menú, con lo que el escenario ni se alcanza; y ancla y medida son ambas físicas. Medido: dx = 0 en LTR, en RTL y al volver.
  • tabs: roto por partida doble, y MIGRADO a MeasuredIndicator. Medía en el wrapper de eidos con su propio rAF y observadores. (a) No re-medía al voltear —dx = 518— y (b) ni con medida fresca acertaba en RTL —dx = 400— porque [data-tabs-indicator] anclaba con inset-inline-start: 0 LÓGICO mientras el JS le aplicaba un translate3d(x) FÍSICO. Ahora soma mide (con la dependencia dir) y el recipe posiciona desde --indicator-* con left: 0. Los cuatro escenarios a dx = 0, verificado en las tres variantes con indicador y en RTL a ojo.

⚠️ PUNTO CIEGO DE RTL-1, y es importante: el defecto (b) llevaba ahí desde siempre y el guard no podía verlo, porque el ancla estaba en el CSS y el transform lo escribía el JS INLINE. RTL-1 cruza declaraciones dentro de una regla CSS. Así que «24 → 1» quiere decir 24 en el CSS estático, no que el catálogo esté limpio: lo que pinta desde JS (§2.2) sigue necesitando el ojo.

Efecto lateral bueno: la variante line vuelve a mandar en su grosor (2px). El height inline del wrapper viejo lo pisaba, cosa que un comentario del propio tabs.css daba por hecha que no pasaba.

Un guardián se activó y es correcto que lo hiciera: component-visual-attrs.test.ts exigía data-ready en el wrapper de eidos. El atributo cambió de dueño a soma, así que la entrada se retira — igual que el indicador del radio-group, que tampoco lo lista.

6.8 · §2.2 verificada — los cinco están sanos, pero la LISTA no lo estaba

Pasados por navegador, con la dirección movida por el camino real (toggle del topbar → prefs), nunca por setAttribute:

Resultado
slider ✓ Click al 25% del ancho FÍSICO da 75 en RTL; arrastrar +120px a la derecha sube en LTR y baja en RTL; ArrowRight igual. Los seis sitios responden
number-field ✓ El mismo arrastre físico sube en LTR y baja en RTL
css-field ✓ Idéntico (16px → 40px en LTR, 40px → 16px en RTL)
dropdown-menu ✓ El panel raíz se alinea al inline-start (izquierda en LTR, derecha en RTL) y el submenú abre a la derecha en LTR, a la izquierda en RTL
carousel Track ✓ (Next mueve −622px en LTR y +622px en RTL). Swipe SIN MEDIR

⚠️ El swipe del carousel se me resistió a cinco intentos de simulación de puntero: la capa de gesto tiene umbral de distancia y de velocidad, y con page.mouse no conseguí dispararlo de forma reproducible ni en LTR. El signo está bien POR LECTURA (isRtlHorizontal ? -offset : offset, con los tres casos comentados). Son diez segundos con un dedo o un ratón de verdad: probar que arrastrar hacia la izquierda avanza en LTR y hacia la derecha avanza en RTL.

6.9 · Barrido del punto ciego — quién más pinta desde JS

Un primer cruce «matemática horizontal» × «menciona la dirección» dio trece sospechosos. Revisado uno a uno, esa lista estaba inflada Y le faltaban piezas. El cruce por sí solo no vale: .left capta cosas que no son direccionales, y un componente puede manejar la dirección sin nombrar rtl.

Un motor cubre nueve de golpe. src/uix/soma/layers/floating lo consumen combobox, context-menu, dropdown-menu, link-preview, menubar, popover, select, sidebar y tooltip. Verificado el motor a través de popover (align=end: derecha en LTR, izquierda en RTL ✓) y menubar (align=start: izquierda en LTR, derecha en RTL ✓), más dropdown-menu en §6.8. Los nueve quedan cubiertos. ⚠️ float-panel NO usa este motor — tiene colocación, arrastre, resize y teclado propios, y cero dirección.

Falsos positivos, descartados por lectura del uso real: container (es una prop de márgenes), text-focus / text-scramble / path-trace (miden dónde está un elemento o el puntero para un efecto: físico correcto), drag-drop (sueltas donde ves), cropper (paneas una imagen que no se voltea, ya decidido en §6.2), gradient-builder (arrastras la parada donde la ves).

Falsos positivos de MI MÉTODO, que conviene no repetir: medir «a qué borde se pega el panel» falla cuando el panel tiene el ancho exacto del disparador — el select daba Δleft=0 Δright=0 y mi desempate lo marcaba en rojo sin que pasara nada.

Lo que el primer cruce NO vio (escriben geometría inline desde JS sin usar clientX): drawer, color-picker, rotate-align, spinner, text-circular. De ellos:

  • drawer está bien y es el ejemplo a imitar: traduce start/end a left/right UNA vez en resolveDirection() y a partir de ahí toda su matemática es física y coherente. Verificado que con un valor físico (right) NO voltea, que es lo correcto. ⚠️ Pero su demo sólo expone top/right/bottom/left, así que la mitad lógica —la única que ejercita RTL— no es observable. Mismo agujero que §2.3.
  • color-picker: el thumb del área 2D se queda en el mismo sitio al voltear. Probablemente correcto por diseño (una rueda de color es física, como el cropper), pero es una DECISIÓN, no un hecho verificado.
  • rotate-align, spinner, text-circular: efectos rotatorios, sin eje.

6.10 · float-panel — era código redundante, y por eso estaba mal

float-panel no usa el motor compartido porque el suyo es otro problema: useFloating mantiene el panel PEGADO a su ancla, y un float-panel se arrastra y redimensiona libremente en cuanto se abre. Hasta ahí, correcto.

Pero su semilla anclada sí era redundante. computeInitialPosition() re-derivaba side + align contra el rect del ancla a mano, y esa copia resolvía align: 'start' a r.left SIEMPRE — una alineación LÓGICA clavada a un borde FÍSICO, que nunca voltea. $ethereal ya exporta la matemática buena:

computeCoordsFromPlacement(rects, placement, rtl); // pura, síncrona, sin ciclo de vida

Es la misma que usa el motor compartido, y toma la dirección como argumento. Migrado: se comparte la SEMILLA, no useFloating.

Verificado con anchored-seed.test.ts (4 casos), porque la demo no ancla ningún panel y el defecto no era observable ahí:

  • LTR: idéntico al algoritmo anterior en las 12 combinaciones side × align — cero regresión.
  • RTL con side vertical: start y end se espejan (lo que el viejo no podía).
  • RTL con side horizontal: el align NO cambia — es el eje de bloque.
  • center sigue centrado en ambas.

La demo, medida antes y después, no se mueve (left 329 en LTR, 81 en RTL).

No tocado, y es una decisión pendiente: Home/End en el eje X llevan el panel a bounds.left / bounds.right. Para un panel que se arrastra libremente es defendible que sean extremos físicos y no principio/final de lectura. No es un defecto claro; hace falta criterio.

toast: descartado, es correcto. Todo su vocabulario es FÍSICO y coherente consigo mismo — position es top-left…bottom-right y swipeDirection es left/right/up/down (por defecto 'right'). No hay un solo start/end en su superficie, así que no promete seguir la dirección y no la incumple; misma excepción legítima que el side físico del drawer. Radix hace igual. Aquí basta la lectura porque lo que se comprueba es una AUSENCIA (no existe vocabulario lógico), que es propiedad del código; no es el caso de tabs, donde había una promesa lógica que el runtime incumplía.

6.11 · Los virtualizadores — el contenido DESAPARECÍA en RTL

Reportado por el usuario y reproducido: en virtual-grid, voltear la dirección dejaba la rejilla en blanco. Medido en Chrome:

LTR : 108 celdas, 70 visibles, primera en x=381   (borde del viewport)
RTL : 108 celdas,  0 visibles, primera en x=-5101 (fuera de la pantalla)

No era scrollLeft —está en 0 en ambos—, era el anclaje de la celda:

position: absolute; top: 0; left: 0;
transform: translate3d(${columnStart}px, …)

El sizer interno es inline-size: 6000px, o sea lógico, así que en RTL desborda hacia la izquierda y ocupa [-5101, 899]. Su borde físico izquierdo es donde el contenido TERMINA, y left: 0 mandaba todas las celdas allí.

Arreglado con ancla lógica (inset-inline-start) y el signo en el offset, para que el recorrido siga en translate3d — un virtualizador recoloca miles de celdas y conviene que siga en el compositor. Tras el arreglo: 70 visibles en RTL, primera en x=779 = borde derecho del viewport − ancho de celda.

virtual-list tenía el mismo patrón (left: 0 + translateX) y por tanto el mismo defecto en modo horizontal. Mismo arreglo; el modo vertical no se ve afectado (inset-inline-start: 0 con width: 100% es lo que left: 0 era). Verificado: 12 items visibles en RTL, primero en x=755.

El otro síntoma reportado —«el cambio de idioma no le afecta»— NO es un defecto. Con el toggle en auto el árabe voltea la lista correctamente (medido: htmlDir y direction del componente pasan a rtl). Si el toggle está en ltr/rtl EXPLÍCITO, el idioma no manda: el intent gana sobre la derivación, que es lo ratificado en 013ceac57. Despista en la práctica, pero es el contrato.

6.12 · Los tres que «tenían código de dirección» — dos estaban rotos

rating-group: roto, y por DOS motivos encadenados.

  1. calcFromPointer medía (clientX - rect.left) / rect.width, una fracción desde el borde físico izquierdo. En RTL los items van de derecha a izquierda, así que la mitad que EMPIEZA una estrella es la derecha: pinchar la primera mitad visual daba la estrella entera. Volteado, igual que el slider.
  2. Y aun así seguía sin funcionar, porque su wrapper se quedó fuera de la normalización de 013ceac57: tenía dir = 'ltr' como default duro y readableActive(() => dir) en vez de activeDir(() => dir, soma). O sea nunca consultaba prefs y resolvedDir valía 'ltr' siempre.

⚠️ natural-time-picker tenía el mismo agujero del wrapper (dir ?? 'ltr'). Son los DOS únicos que quedaron fuera; los otros 36 sí usan activeDir. Medido tras arreglar ambos: en RTL la mitad derecha da 2.5 y la izquierda la entera — espejo correcto.

LECCIÓN DE MÉTODO: al ver resolvedDir = opts.dir.current ?? 'ltr' en ~30 providers pensé que a todos les faltaba el eslabón de prefs. Falso: la cadena vive en el WRAPPER (activeDir(getter, soma)), y el provider recibe la prop ya resuelta. Antes de "arreglar" 30 componentes, mira el wrapper.

splitter: el arrastre estaba roto, el teclado no. resizePanels toma un delta LÓGICO —positivo agranda el panel de ANTES del handle— y recibía el delta físico del puntero sin voltear. Medido: en RTL arrastrar a la derecha agrandaba el panel que debía encoger, y el arrastre contradecía a las flechas, que sí pasan por getDirectionalKeys. Tras el arreglo: LTR arrastre +118 / ArrowRight +6; RTL arrastre −118 / ArrowRight −6 — espejo exacto y ambos de acuerdo.

scroll-area: SIN VERIFICAR, y con dos señales. Su resolvedDir aparece UNA sola vez —la declaración—, o sea es código muerto. Y usa scrollLeft en cuatro sitios (isAtLeft = scrollLeft <= 0, el ratio del thumb, el salto por click y el arrastre) sin tratar que en RTL los navegadores lo devuelven negativo: isAtLeft sería siempre cierto y el ratio saldría negativo. No está medido porque el scroll-area de la demo no tiene eje horizontal (maxScroll: 0) — hace falta un ejemplo que sí lo tenga.

Lo que §6.8 no consiguió disparar por simulación, el usuario lo vio a mano. El defecto no estaba en la decisión de finishDrag (que sí voltea con isRtlHorizontal) sino en el pintado durante el arrastre:

translate = base + dragOffset; // base = índice, dragOffset = dedo
itemGroupTransform = translate * flip; // el flip caía sobre LOS DOS

El recorrido por índice es LÓGICO y debe voltear; dragOffset es el desplazamiento FÍSICO del puntero y no. Al voltear ambos, en RTL el rail se movía en sentido contrario al dedo. Separados: sólo baseTranslate lleva el signo.

A/B con git stash, midiendo el transform A MITAD del arrastre (que no exige superar el umbral de gesto, y por eso ahora sí es medible): sin el arreglo, RTL da Δ−100 con el dedo a +100 —huye—; con él, Δ+100 —lo sigue—. LTR intacto en ambos casos.

MÉTODO: para un gesto con umbral de distancia/velocidad, no intentes completar el swipe; mide el estado INTERMEDIO con el puntero aún abajo.

6.14 · Descartados con medida — no tocar

  • scroll-area funciona. Confirmado por el usuario y medido: con el toggle en auto, ES→ltr y AR→rtl, y el elemento pasa a direction: rtl. Su resolvedDir sigue siendo código muerto (una sola aparición) y el uso de scrollLeft sigue sin verificarse en un eje horizontal real — pero el componente responde a la dirección.
  • chart no necesita arreglo, y las referencias lo respaldan. Medido: chartDir, legendDir y el texto de los ejes pasan de ltr a rtl; el eje numérico no se voltea. Es exactamente el consenso: Chart.js implementó RTL SÓLO para leyendas y tooltips y mantiene abierto el issue de ejes/etiquetas (chartjs/Chart.js#11937, PR #6460); el patrón documentado es eje numérico LTR con textos RTL. amCharts es de los pocos con RTL integral.

6.15 · El síntoma «la demo no reacciona al idioma» — es el TOGGLE

Reportado tres veces (virtual-list, carousel, scroll-area) y medido en las tres: con el toggle en auto, las tres reaccionan al idioma. Si el toggle está en ltr/rtl EXPLÍCITO, el idioma no manda — el intent gana sobre la derivación, que es lo ratificado en 013ceac57.

No es un defecto, pero despista de forma sistemática: el control parece un interruptor de dos posiciones y en realidad es un ciclo de tres donde sólo uno cede el mando al idioma, y el estado sólo se lee en el title. Si se quiere cerrar, es trabajo de la shell (hacer visible el estado), no del eje.

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

7. Las gráficas — barrido completo del catálogo (2026-08-03)

El usuario abrió este frente con «los chart calculan mal» y una captura. Me costó cuatro avisos suyos dar con lo que señalaba, y la causa de las tres primeras equivocaciones fue siempre la misma: verificar en LTR, con números, y sobre un recorte que yo elegía. La regla que sale de aquí está en la fila RTL · SVG del contrato y desarrollada en src/uix/eidos/components/chart/README.md §Direction.

7.1 · Las dos decisiones, que NO son la misma

  1. ¿Espeja la composición? Sólo si el gráfico tiene EJE DE LECTURA. Lo tienen chart (escala x), calendar-heatmap, la matriz de heatmap, el canalón del funnel y bar-list. NO lo tienen los radiales (gauge, pie-chart, polar-area, radar-chart, smith-chart): sus posiciones son ángulos. Se espeja invirtiendo el RANGO de píxeles de la escala, o reflejando la x (mx()). Los valores de Y no se espejan nunca.
  2. text-anchor es LÓGICO. Si la composición espeja, se deja lógico — voltearlo además lo cancela. Si NO espeja (canalón del eje Y, radar), se fuerza el físico con rtl.anchor().

Confundirlas es silencioso y se ve como las etiquetas caminando sobre el gráfico.

7.2 · Estado del catálogo — TODAS revisadas

Estado
chart + presets (line/area/bar/bubble/scatter) Arreglado: eje X espeja · tooltip hereda formatY · canalón adaptativo · etiquetas a 9.4px del tick · anchor físico
funnel Arreglado: composición espejada (embudo derecha, etiquetas izquierda)
calendar-heatmap Arreglado: semanas y canalón de días espejados
heatmap (matriz) Arreglado: columnas y canalón de filas espejados
radar-chart Arreglado: anchor físico, sin espejar (es radial)
bar-list Correcto sin tocar — CSS lógico
sparkline, bubble-chart Correctos sin tocar — heredan el frame de chart
gauge, pie-chart, polar-area, smith-chart Correctos sin tocar — radiales

Y los bloques de código de las demos (pre/code/kbd/samp) se declaran ltr: el bidi los volvía ilegibles (<script lang='ts'> → <'script lang='ts>). Es la versión ESTRECHA del pin que llevaba <main data-uix-canvas>.

7.3 · Trampas de medición — me engañaron tres veces

  • El bbox de un path espejado es idéntico al del original. Mi «fingerprint» por getBoundingClientRect declaró sparkline sin espejar cuando sí lo hacía. Para un espejado hay que mirar la PRIMERA coordenada (M0,… vs M186,…), no la caja.
  • Un desborde hacia DENTRO no sale en un test de «se sale del SVG». El barrido que escribí buscaba texto fuera del svg y dio 0 en todo mientras las etiquetas invadían el área de trazado.
  • Medir contra el elemento equivocado. El hueco del eje Y lo medí contra la LÍNEA del eje (7px, aceptable) cuando lo que toca es medir contra el TICK, que sale 4px hacia la etiqueta: quedaban 3.4px y se leía pegado.
  • Verificar en LTR un defecto de RTL. Tres veces. El text-anchor lógico no se manifiesta en LTR en absoluto.
  • La página completa, no el primer SVG. La ruta heatmap tiene DOS modos (matrix / calendar) tras un control; capturando el primer svg sólo veía uno.

7.4 · QUEDA en las gráficas

  • radar-chart: «Economy» empieza en x=-6, o sea se sale unos píxeles del SVG también en LTR. Preexistente, no tocado.
  • No hay NINGÚN test de chart en el repo. Todo lo de esta sesión está verificado a ojo y con medidas puntuales, no fijado por tests.
  • Sin revisar en RTL: metrics, bar-segment, chart-legend. Revisadas en §9.6 — y no eran inocentes.

8. La cola para mañana — por orden de valor

8.1 · scroll-area — CERRADA (ver §9)

Las dos señales eran la misma cosa y ambas estaban rotas. Lo de abajo es el enunciado original, conservado; el cierre está en §9.

Dos señales, ninguna verificada:

  • Su resolvedDir aparece una sola vez en todo el componente — la declaración. Es código muerto.
  • Usa scrollLeft en cuatro sitios (isAtLeft = scrollLeft <= 0, el ratio del thumb, el salto por click, el arrastre) sin tratar que en RTL los navegadores lo devuelven NEGATIVO: isAtLeft sería siempre cierto y el ratio del thumb saldría negativo.

⚠️ No está medido porque el scroll-area de la demo no tiene eje horizontal (maxScroll: 0). Primero hace falta un ejemplo con scroll horizontal; sin eso no es observable. El usuario ya confirmó que el componente «funciona» en lo demás, y medido: con el toggle en auto, ES→ltr y AR→rtl llegan bien.

8.2 · Agujeros de OBSERVABILIDAD — CERRADA (ver §9.5)

  • drawer: su demo sólo expone top/right/bottom/left. La mitad LÓGICA (start/end), que es la única que ejercita RTL, no se puede ver. El componente está bien por lectura (resolveDirection() es el ejemplo a imitar) y verificado sólo en su mitad física.
  • float-panel: su demo no ancla ningún panel, así que la semilla anclada —lo que se arregló en caf3cfd88— no es observable. Cubierto por anchored-seed.test.ts.
  • §2.3: cablear secondaryValue en la demo del slider.

8.3 · Verificaciones que quedaron sin hacer

  • archetypes y proof-of-human: el resto de esa media query. CERRADO (§9.9): hay 6 media queries pointer: coarse en el catálogo, no 2. Las 4 restantes (list-surface, chat-message, radio-group, rotate-align) no tienen NINGUNA geometría de eje inline — --list-item-height, opacity, min-block-size e inset: 0 + min-* lógicos. Las dos que sí la tenían son justo las que §6.2 midió con hit-test.
  • El swipe del carousel con un ratón de verdad. CERRADO (§9.9) — con el ratón real del navegador, las 4 combinaciones dan espejo exacto.
  • metrics, bar-segment, chart-legend como piezas sueltas en RTL. CERRADO — ver §9.6. Destapó un defecto de verdad en el preset grow-x.

8.4 · Deuda ajena al eje, por gravedad

  1. 91 ficheros de test falsifican Soma.require() con vi.spyOn(Soma, 'require').mockReturnValue({ prefs: { getDir: () => dir } }). Incumple una Regla Crítica de CLAUDE.md: esa parte de la batería no ejercita el código real. Es un proyecto entero y es lo más grave del documento.
  2. No hay NINGÚN test de chart en el repo. Todo lo de §7 está verificado a ojo, no fijado.
  3. html lang no sigue al idioma · componentes estampando dir="" vacío (sidebar, tree-view).
  4. smoke y perm:check sin ejecutar en toda la sesión.

8.5 · Lo que NO hay que rehacer

  • Las 5 de §2.2 (slider, number-field, css-field, dropdown-menu, carousel) están medidas y sanas; el único defecto era el pintado del swipe, ya arreglado.
  • El motor soma/layers/floating está verificado y cubre sus 9 consumidores.
  • toast es correcto: su vocabulario es físico y coherente consigo mismo.
  • Las 11 gráficas del catálogo están revisadas (§7.2).

9. Sesión 2026-08-03 (tarde) — §8.1 cerrada

Sin commitear. check = 74 = línea base exacta · 4/4 en el componente · rtl:check = 1, el mismo palabras-chrome.css:446 de siempre.

9.1 · El bloqueo no existía

«La demo no tiene eje horizontal (maxScroll: 0)» era cierto sólo con el control en su valor por defecto. El chip scrollbars tiene horizontal y both, y cualquiera de los dos le mete inline-size: 48rem al contenido (+page.svelte:168). Con both sale maxScroll: 258 y todo es observable. No hizo falta construir nada. Antes de dar una demo por insuficiente, hay que recorrer sus controles.

9.2 · Las dos señales eran una sola, y las dos estaban rotas

resolvedDir era código muerto porque nadie había traducido scrollLeft. Medido en Chrome con scrollbars="both" y la dirección movida por el toggle:

antes después
aria-valuenow en RTL -50, -99 (con aria-valuemin="0") 0 · 50 · 100
thumb en RTL left: -167px — fuera del track dentro, espejado (gapR 0·84·168)
data-at-left siempre puesto sólo en el borde físico izquierdo
data-at-right nunca en reposo, que es donde el contenido se pega
click en el canalón al 10% desde la izquierda → 0 (clampado) → -232 = el 90% lógico
arrastre del thumb (tapado por el clamp) dedo +60 → thumb +60, exacto

LTR quedó idéntico salvo una mejora: el extremo derecho ahora sí enciende data-at-right (ver 9.4).

9.3 · La forma: dos idiomas, uno por consumidor

En RTL el navegador descansa scrollLeft en 0 con el contenido pegado a la derecha y lo lleva negativo hacia el final de lectura, hasta -(scrollWidth - clientWidth) (modelo negativo del CSSOM: Chrome 85+, Firefox, Safari 14.1+). Dos traducciones privadas en el provider, y cada consumidor toma la suya:

Sigue el eje de lectura Se queda físico
offset del thumb — ancla inset-inline-start, así descansa a la derecha en RTL, como un scrollbar nativo data-at-left / data-at-right
aria-valuenow
click en el canalón

Por qué el thumb va al revés que tabs y el sliding-indicator (§6.7, §6.6): allí el JS medía una coordenada FÍSICA y por eso el ancla tenía que ser física. Aquí el JS no mide nada — calcula un PROGRESO de lectura, así que el ancla lógica es la que le corresponde. La regla de §2.2 sigue siendo la misma: mira de qué lado está la medida.

Y por qué data-at-* se queda físico: sus nombres lo son (misma familia que at-top / at-bottom) y su uso documentado —sombras de scroll— también: la sombra va en el borde que aún tiene contenido detrás, se lea como se lea. Es el criterio con el que §6.10 dejó toast intacto. Se arregló el CÁLCULO, no el nombre; ninguna CSS del repo los consume, así que no rompe a nadie.

El arrastre no necesitó nada, y no por suerte: subir scrollLeft mueve el viewport a la derecha en los dos marcos, así que un delta físico de dedo se mapea igual. Verificado, no supuesto.

9.4 · El borde de 1px cambió de lado

isAtRight ya llevaba - 1 de holgura y isAtLeft usaba <= 0 pelado. Es correcto en LTR porque el extremo de reposo es un 0 exacto y el lejano es el máximo fraccionario del navegador — pero en RTL el lejano es el IZQUIERDO. Sin holgura, data-at-left no se emitía nunca en RTL y el arreglo quedaba a medias. Ahora los dos extremos llevan la misma tolerancia. Efecto lateral en LTR: el extremo derecho, que tampoco se encendía, ahora sí.

9.5 · §8.2 cerrada — los tres agujeros de observabilidad

Los tres eran de DEMO, no de componente. check sigue en 74 = línea base.

slider (§2.3) — cableado secondaryValue como un solo mando (porcentaje del recorrido, para que sobreviva a editar min/max) y montada <Slider.SecondaryRange /> siempre: sin valor pinta a tamaño cero, así que el mando basta y la parte no miente. Medido:

resultado
horizontal LTR left: 0%; width: 60% — 288/480 desde la izquierda
horizontal RTL right: 0%; width: 60% — espejo exacto
vertical LTR vs RTL idénticos (bottom: 0%; height: 60%)

Lo vertical debe ser idéntico: rangeStyle sólo consulta isRtl en la rama horizontal, porque el eje de bloque no se voltea. La combinación que §2.3 daba por no observable ya está VISTA.

drawer — la demo listaba ['top','right','bottom','left'] y le faltaban los dos lógicos. Añadidos. Medido, con la dirección movida por el toggle:

direction LTR RTL
start data-side=left, x 12–452 data-side=right, x 1681–2121
end right left
left (físico) left left — no voltea, que es lo correcto

Confirma por vista lo que §6.9 sólo tenía por lectura: resolveDirection() es el ejemplo a imitar.

float-panel — el handoff acertaba, pero NO por lo que decía. «Su demo no ancla ningún panel» sugiere que falta el control, y el control existía (useAnchor, con side y align). La causa real: posA arranca en {x:24,y:24} y seedRect() sólo siembra si position está vacía, así que una posición ligada anula la semilla para siempre. El align era inerte.

Arreglado con releasePosition() (suelta posA) llamado desde el toggle useAnchor y desde los chips side / align — anclar y fijar posición son excluyentes por diseño, y sin esto el hint «(re-open with useAnchor)» era mentira.

⚠️ Y aun así seguía sin verse, por un CLAMP: el trigger empezaba exactamente en el borde del stage (triggerLeftInStage: 0), así que align=end pedía x=−173 y clampPosition devolvía 0 — los tres aligns colapsaban en el mismo sitio. Centrada la fila de disparadores sobre el escenario. Entonces sí:

align LTR RTL
start dLeft 0 dRight 0
end dRight 0 dLeft 0
center −86 / +87 igual — no se espeja

El arreglo de caf3cfd88 pasa de «cubierto por anchored-seed.test.ts» a verificado en navegador.

MÉTODO, dos veces en la misma sesión: una demo puede ser inobservable por tres motivos distintos —falta el control (drawer), el control existe pero otro estado lo anula (float-panel), o el control existe y basta con usarlo (scroll-area, §9.1)—. Antes de declarar el bloqueo, recorre los controles y mira qué gana.

📌 float-panel no expone prop dir: lee soma.prefs.getDir() directo (float-panel-provider.svelte.ts:590). Es la cadena documentada con el eslabón de prop ausente, no un salto. Anotado, no tocado.

📌 §2.4 sigue viva: parts[2] de la demo del slider indexa SecondaryRange en vez de Thumb (ahora línea 430, era 409). Es el único error de tipos del fichero y uno de los 74. Preexistente y ajeno al eje — señalado, no tocado.

9.6 · Las tres piezas sueltas de las gráficas — y el defecto que escondían

Commits 3c3d8e1ac (motion) + b0f995a3b (metrics). check = 74 = línea base · 32/32 en motion + metrics · rtl:check = 1, el de siempre.

pieza veredicto
chart-legend Correcto. No renderiza nada — es un config child; el marcado lo pinta el frame con flex lógico, y §6.14 ya midió que legendDir voltea
metrics CSS correcto: icon-start va al borde derecho en RTL y la sparkline espeja (M0,31 → M304,31). Problema de VOCABULARIO
bar-segment Layout, leyenda y tooltip correctos. Defecto real en la animación de entrada

El defecto no era de bar-segment, era del preset grow-x, que clavaba transform-origin: 0% 50% — el borde FÍSICO izquierdo. En RTL la barra se dispone contra el borde derecho, así que crecía desde su punta de vuelta a su base, despegándose de su ancla. Medido en los dos consumidores:

bar-segment   borde izq. clavado en 296, el derecho avanza 108 → 0
bar-list      borde izq. clavado en 239, y el de ANCLAJE se aleja 65 → 3

transform-origin no tiene forma lógica en CSS, así que el preset lee ahora --motion-origin-inline-start, que render-css.ts emite por :dir(). Es el patrón que el propio sistema ya usaba en scale-fade con --floating-transform-origin. La regla va acotada a [data-animation-style]: el nodo que la lee es siempre el animado, así que un :dir() pelado declararía una custom property heredada en CADA elemento del documento para nada. :dir() se resuelve contra ese nodo, así que un subárbol que redeclare su dirección sigue acertando — y se emiten las DOS direcciones justamente para ese caso, que es por lo que [dir='rtl'] está prohibido (§6.3).

⚠️ PUNTO CIEGO DE RTL-1, el tercero (tras el transform inline del §6.7): esto vive en un preset de TypeScript, ni siquiera en CSS estático. El guard lee CSS; los presets y lo que pinta el JS siguen necesitando el ojo.

metrics: renombrado placement: 'right' → 'end' (BREAKING, sin shim). El CSS siempre fue lógico, así que right ya ponía el chart a la izquierda en RTL: el comportamiento era el bueno, mentía el nombre. Y era una isla — layout: 'icon-start', align: 'start' y el resto del vocabulario del componente son lógicos. NO es el caso legítimo del toast (§6.10), que es físico y coherente consigo mismo; aquí la incoherencia estaba dentro.

MÉTODO: los tres se dieron por sanos en §7.2 mirando el CSS, y el CSS era correcto en los tres. Lo que fallaba era la ANIMACIÓN, que no está en el recipe sino en un preset compartido. Revisar una gráfica en RTL no es sólo leer su recipe: hay que disparar su entrada y mirar de dónde crece.

📌 No tocado y señalado: el token --metrics-chart-right-width conserva el nombre físico. Describe un ANCHO, no un lado, y es superficie pública de theming — su renombrado es otra decisión.

9.7 · Auditoría de la propia sesión — 2 desviaciones mías, corregidas

Repaso crítico de todo lo de §9 a petición del usuario. Dos cosas estaban mal y eran mías:

  1. La regla :dir() del §9.6 nacía sin acotar (:dir(ltr) { … }), o sea matcheaba cada elemento del documento para declarar una custom property que sólo lee el nodo animado. Acotada a [data-animation-style]:dir(…). Mismo comportamiento medido (origin 65.05px en RTL / 0px en LTR, ancla clavada), coste acotado.
  2. releasePosition() en la demo del float-panel soltaba la posición siempre, también con useAnchor apagado — así que cambiar side en modo libre re-centraba un panel colocado a mano. Condicionado a useAnchor. Medido: con ancla off el panel se queda en (24,24) al cambiar side; con ancla on sigue re-sembrando (start x=234 · end x=61).

Y un hueco de método: toqué render-css.ts, que genera el CSS de TODO eidos, y sólo había corrido los tests del ámbito. La pasada COMPLETA da 8 fallos en 4 ficheros — contracts.test.ts (5), soma-attr-audit, eidos/lint, orca. Verificado que ninguno es mío, cruzando los ficheros que señalan contra git diff --name-only 40db0b981..HEAD:

guard señala dueño
eidos/lint audio-player: no morfo hilo del sonido (ya en §6.5)
contracts × 5 aura/langs.ts, morfo/aura.ts, menubar-provider eje agéntico
contracts radio-group + tabs: data-ready ⚠️ probablemente de §6.7
soma-attr-audit — pasa al correrlo solo: flaky bajo carga —
orca hook timeout a 10s ajeno

⚠️ Lo que merece mirarse: §6.7 dice que retiró la entrada de data-ready de component-visual-attrs.test.ts al migrar tabs a MeasuredIndicator, pero contracts.test.ts sigue marcando data-ready en radio-group y tabs. O el guard llevaba rojo de antes, o se retiró la entrada de UN guard y hay OTRO que mira lo mismo. No es de este hilo, pero está sin cerrar.

Revisado y correcto, sin cambios: nadie lee style.left del thumb; el renombrado de placement no dejó ningún consumidor (grep global); la tolerancia de 1px es simétrica y no altera el caso sin overflow.

9.8 · Suelto

  • -0: negar un scrollLeft en reposo da -0. Es inocuo en el DOM (String(-0) es "0", medido), pero un matcher estricto lo distingue — de ahí el expect.closeTo(0) en el test.
  • El test nuevo ancla el caso histórico: con el provider en HEAD sale rojo (A/B con git stash), con el arreglo verde.
  • ⚠️ Se añadió al fichero de test existente, que es uno de los 91 de §8.4.1 (falsifica Soma.require()). Migrarlo es ese proyecto, no éste.
  • Los 3 ficheros tocados ya fallaban prettier --check en HEAD; no se formatearon (§5).

Commit del arreglo: ver git log. check = 74 = línea base · rtl:check = 1 (el de palabras) · eidos-lint carousel: invalid 0.

Las flechas del carousel apuntaban HACIA ADENTRO en RTL. Lo vio el usuario sobre la marcha. Medido, con el A/B completo:

LTR RTL (antes)
prev lado izq, apunta izq ✓ lado der, apunta izq ✗
next lado der, apunta der ✓ lado izq, apunta der ✗

La POSICIÓN sí se espejaba —los insets del recipe son lógicos— pero el GLIFO no: chevronDir en los triggers de eidos sólo mira orientation, nunca la dirección (carousel-prev-trigger.svelte:32). Resultado: ambas flechas apuntando al centro.

Por qué no bastaba una regla :dir() normal: SvgChevron escribe transform: rotate() inline, así que ninguna regla lo re-apunta sin !important. Se voltea con la propiedad independiente rotate, que COMPONE con ese transform en vez de reemplazarlo — exactamente el mismo motivo por el que el centrado de los triggers usa translate y no transform, y que el propio recipe ya documentaba dos reglas más arriba.

Y por qué [data-dir='rtl'] y no :dir(rtl) aquí: data-dir es el atributo PROPIO del componente, que soma estampa desde resolvedDir — no el dir del DOM, así que no es el [dir='rtl'] prohibido por §6.3. Leerlo garantiza que el glifo coincida con la matemática del arrastre y del teclado, que resuelven de esa misma fuente; con :dir() podrían discrepar si el DOM y las prefs divergen. eidos-lint lo clasifica morfo-backed.

Verificado en las dos direcciones y a ojo. Es lo mismo que hace shadcn (rtl:rotate-180); nuxt/ui tuvo este bug exacto (#1354) — allí fallaban además las posiciones y el signo de la navegación, que aquí ya estaban bien (§6.13).

El swipe con ratón REAL (el del navegador, no eventos sintéticos) — las 4 combinaciones, espejo exacto. Cierra lo que §6.8 no consiguió disparar:

LTR  arrastre izquierda  1 → 2  avanza      LTR  derecha    2 → 1  retrocede
RTL  arrastre derecha    1 → 2  avanza      RTL  izquierda  2 → 1  retrocede

⚠️ MÉTODO: el ratón real avanza 2 posiciones de golpe (momentum), y el left_click_drag no deja fijar la velocidad. Lo que se comprueba es el SIGNO, no la magnitud. Y verifica la dirección ANTES de cada gesto: mi primera pasada midió creyendo estar en LTR cuando el toggle estaba en RTL, y el resultado parecía un defecto que no existía.

9.10 · El dir="" vacío — CERRADO, y era el shorthand de Svelte

Commit b906dd407. §6.5 lo había visto en sidebar y tree-view «sin investigar de dónde sale». Sale del shorthand {dir}: para dir Svelte emite una asignación de PROPIEDAD además del atributo, y con undefined el resultado es la cadena vacía en vez de la omisión.

Cazado con un trap sobre setAttribute + el descriptor de la propiedad: dos escrituras desde el mismo componente compilado, la segunda por propiedad. Y no estaba en el SSR — dir="" no aparece ni una vez en el HTML servido, lo escribía el cliente al hidratar.

Un valor inválido HEREDA, así que nada pintaba mal; pero contradice la letra del contrato y hace que [dir] matchee.

{dir}   →   {...dir ? { dir } : {}}
  • tree-view (eidos): el wrapper LO NECESITA cuando el valor es concreto — el recipe selecciona con [data-tree-view-root]:dir(rtl) y ese div es el ANCESTRO del provider que estampa el attr. Ausente, hereda: correcto.
  • 26 demos: el arnés estándar (data-uix-stage-area / -canvas-inner).

Verificado: en auto sólo quedan <html> (la proyección) y el provider con su valor resuelto — cero dir=""; en rtl los tres lo reciben concreto; al volver a auto desaparece. Las 26 rutas sirven 200 y ninguna trae dir="".

⚠️ MÉTODO: mi primer regex también matcheaba dentro de dir={dir} y dejó dir={...} — dos ficheros con parse error, que check cazó (76 vs 74). Contar ficheros modificados NO basta; hay que mirar el diff.

9.11 · html lang — diagnóstico completo, decisión PENDIENTE

Causa raíz: src/app.html:2 tiene <html lang="en"> hardcodeado y nadie lo actualiza nunca. El dir sí se actualiza porque ActivePrefsDomProjection lo proyecta (PREFS_DOM_ATTRS.DIR); lang no está en esa lista.

Medido con árabe seleccionado: <html dir="rtl"> ✓ · <html lang="en"> ✗.

📌 El <main lang="en"> NO es el defecto y debe quedarse — es el residuo deliberado de §6.4 y es correcto: la prosa de las demos ESTÁ en inglés. Lo que falla es el lang del DOCUMENTO, que debería seguir al idioma de la shell.

El arreglo sería simétrico y corto —la shell ya registra language en prefs (es·en·fr·de·ar), que es justo de donde prefs deriva la dirección, así que slots.language ya está disponible en la proyección— pero cambia un contrato declarado: contracts.ts fija prefsDomProjection.ownsAttrs = ['dir','data-motion','data-sound','data-haptic'] con su guard en contracts.test.ts, más los tests de dom-projection y el README de prefs.

Y hay un argumento real EN CONTRA: en apps con i18n por routing el lang lo fija el servidor, y una proyección de cliente lo pisaría. Es decisión de arquitectura, no una corrección obvia — por eso §3 lo clasificó «NO de este hilo».

EJECUTADO tras firmarlo el usuario (41e1f2830). lang viaja con dir porque responden a la misma pregunta sobre el documento y el navegador lee LOS DOS del DOM. El slot ya estaba disponible y su valor efectivo es un tag BCP-47, justo lo que el atributo toma. Un esquema SIN la dimensión language no produce slot y no proyecta nada, así que una app con i18n por routing queda intacta — que era el argumento en contra, ahora cubierto por construcción. ownsAttrs pasa a ['dir','lang','data-motion','data-sound','data-haptic']. Medido por el camino real: ar→ar/rtl · en→en/ltr · es→es/ltr; un solo language.set mueve los dos, y eso queda fijado en el test.

9.12 · color-picker — el criterio lo zanjaron las referencias

Commit 46ffe1c74. §6.9 lo había dejado como «probablemente correcto por diseño (una rueda de color es física, como el cropper), pero es una DECISIÓN». Las referencias la zanjan, y en contra de esa suposición.

react-aria / react-spectrum, sobre ColorArea:

"In right-to-left languages, color areas should be mirrored. Orientation of the gradient background, positioning of the thumb, and dragging behavior is automatically mirrored by ColorArea."

La analogía con el cropper era mala y queda retirada: allí paneas una IMAGEN, que no se voltea; aquí el eje X reparte un canal por la dimensión inline — un eje de lectura. Y react-aria distingue ColorArea (rectangular, espeja) de ColorWheel (radial, no), que es el mismo corte que la regla RTL·SVG de §7.1.

Medido antes: pinchar el MISMO punto físico daba el mismo valor en las dos direcciones, y el degradado no seguía al thumb.

Pieza Qué necesitó
área · puntero la fracción física se espeja sobre el canal
área · thumb ancla FÍSICA (left) alimentada ya espejada
área · flechas ArrowRight mueve a la derecha ⇒ BAJA el canal
área · degradados saturación + arcoíris de hue voltean con :dir(rtl)
sliders de canal sólo el degradado

⚠️ El thumb conserva left FÍSICO a propósito: el recipe lo centra con translate(-50%,-50%), y ancla lógica + translate físico es justo la trampa que el contrato prohíbe. Soma le pasa la fracción ya espejada (physicalXProgress) — la misma resolución que el sliding-indicator (§6.2).

Los sliders de canal casi no necesitaban nada, y eso es mérito del refactor de 2026-05-21: componen el SliderProvider genérico, que ya espeja thumb, puntero y teclado. Pero su rampa estaba clavada a to right, así que el color BAJO el thumb dejaba de ser el valor — medido con hue 210: thumb a 123px del borde derecho y el degradado contando aún desde la izquierda.

Verificado en las dos direcciones: al 10% del borde izquierdo físico, LTR da saturation 10 y RTL 90; el blanco del área y el rojo del hue quedan a la derecha en RTL; ArrowRight mueve el thumb a la derecha en ambas. Dos tests nuevos, ROJOS con el provider anterior.

Home/End se quedan en min/max del CANAL (un valor, no un borde físico), igual que en un slider.

9.13 · float-panel (Home/End) — sigue PENDIENTE, y ahora se sabe por qué

Buscadas las referencias: no existe patrón APG para paneles flotantes movibles. El análogo más cercano es el Window Splitter, y ahí el APG define las teclas por VALOR, no por geometría:

Home: "Moves splitter to the position that gives the primary pane its smallest allowed size." · End: "…its largest allowed size."

Y no menciona RTL en ningún sitio. Pero la analogía es imperfecta: un splitter tiene un valor semántico unidimensional, así que «mínimo/máximo» significa algo; un float-panel que se arrastra libre por un plano no tiene valor, sólo posición. Lo único transferible es que react-aria sí voltea el movimiento por teclado en RTL (useMove), lo que apunta a que las FLECHAS deberían seguir la dirección.

Conclusión: extremos físicos son defendibles porque no hay semántica que respetar, pero no hay precedente que lo respalde ni que lo contradiga.

DECIDIDO (el usuario dejó la elección): el movimiento por teclado del panel —flechas y Home/End— se queda FÍSICO. Sin valor semántico que respetar, el vocabulario físico es coherente CONSIGO MISMO (bounds.left / bounds.right ya lo son), que es la misma excepción legítima con la que §6.10 dejó intacto al toast. No se toca nada.

9.14 · El resize del float-panel — el usuario lo vio, yo no lo había mirado

Commit 552f4f2c7. Reportado con una captura del grip en la esquina inferior IZQUIERDA. Ahí es donde debe estar en RTL (el recipe lo coloca con inset-inline-end), pero la matemática no acompañaba:

if (edge.includes('e')) width = start.width + dx; // dx es FÍSICO

Los handles se colocan con insets LÓGICOS, así que el marcado e es el borde inline-END: físicamente derecho en LTR y físicamente IZQUIERDO en RTL. Medido: arrastrar el grip hacia FUERA encogía el panel — 436 → 327 donde debía crecer a 545. El eje de bloque sí estaba bien (292 → 346).

Resuelto convirtiendo el edge lógico a su lado FÍSICO una sola vez (physicalResizeEdge), de modo que toda la geometría de abajo sigue en píxeles físicos — la forma de drawer.resolveDirection(), que §6.9 señaló como el ejemplo a imitar.

⚠️ Y destapó un SEGUNDO defecto, latente: crecer desde w/n mantiene el lado opuesto fijo, así que el origen del panel viaja hacia fuera, y ese caso no se acotaba contra los bounds — el panel se salía del contenedor (medido POS -85 con el stage empezando en 0). En LTR la rama era inalcanzable: sólo montan e, s y el grip se. Ahora se acota por el hueco disponible.

El grip además mentía en RTL: cursor: nwse-resize (↘↖) y los chevrones a -45deg / bottom right son físicos y no tienen forma lógica → par :dir(rtl).

resultado
RTL · arrastre hacia fuera 300 → 409 de ancho, borde DERECHO fijo en 803
RTL · arrastre largo se detiene exacto en el borde del stage (left 0)
LTR sin cambios: 300 → 436, borde izquierdo fijo en 24

⚠️ LECCIÓN, y es la tercera vez que aparece en esta sesión: §6.10 y §9.5 dieron el float-panel por revisado midiendo sólo la POSICIÓN del panel. Nadie tocó el resize. Verificar un componente con gestos no es medir su geometría en reposo — hay que ejercitar cada gesto que ofrece.

9.15 · Censo de canonización — NO está todo normalizado

Pregunta del usuario: «¿todos los componentes han canonizado la forma de obtener el dir?». No. §6.12 dijo que rating-group y natural-time-picker eran «los DOS únicos que quedaron fuera; los otros 36 sí usan activeDir». Ese censo estaba incompleto: contó los wrappers con default duro, no los que resuelven en OTRO sitio.

Medido: 106 componentes en soma · 38 con el wrapper canónico (dir: activeDir(() => dir, soma)) · 11 fuera de la norma, en dos grupos de naturaleza distinta.

Grupo A — exponen dir pero resuelven en el PROVIDER (4) css-field · listbox · navigation-menu · number-field. El wrapper pasa la prop CRUDA (readableActive(() => dir)) y el provider hace opts.dir.current ?? prefs.getDir() ?? 'ltr' — la cadena duplicada, al revés del reparto que fijó 013ceac57.

⚠️ Verificado que hoy son equivalentes: con el toggle en auto + árabe, number-field estampa dir="rtl" y el canónico scroll-area también. La sospecha de incumplimiento observable era FALSA y se retira.

Pero hay una divergencia latente: listbox y navigation-menu estampan assertedDir (distinguen «afirmado» de «resuelto») mientras css-field y number-field estampan el RESUELTO. En una app cuyo esquema de prefs no declare dirección, getDir() devuelve undefined y ahí los primeros omiten el atributo y los segundos estampan 'ltr' — el defecto que 013ceac57 arregló en 33 componentes, superviviente en 2.

Grupo B — no exponen dir en absoluto (7) drawer · float-panel · grid-list · tag-group · tree-grid · virtual-grid · virtual-list. Leen soma.prefs.getDir() directamente. Es coherente con el contrato con el eslabón de prop ausente, y por eso ninguno está roto. Pero es una asimetría de API: 38 componentes dejan fijar la dirección de su subárbol con una prop y estos 7 no.

Ninguno de los 11 es un defecto de comportamiento hoy. Son dos decisiones pendientes: (a) mover la cadena al wrapper en los 4 del grupo A —refactor mecánico, y de paso cierra la divergencia latente de 2—, y (b) si los 7 del grupo B deben exponer dir, que es superficie pública.

⚠️ Este censo se dejó fuera un GRUPO C, y ése sí estaba roto (hallado el 2026-08-04, ver CONTINUE-player-rtl.md §2.1). media-player expone dir y ni lo resuelve ni lo estampa: pasaba la prop cruda y sólo la reenviaba a los Sliders compuestos. Con dir="rtl" por prop y sin dir ambiental el cromo se disponía en LTR y sus dos sliders en RTL — partido por la mitad. La búsqueda que hizo el censo mira wrappers y providers; un componente que delega la prop a un hijo no cae en ninguno de los dos patrones y pasa desapercibido. Al re-auditar, buscar también quién ACEPTA dir sin escribirlo en ningún sitio.

9.16 · Grupo A canonizado — la cadena baja al wrapper (4/11)

Commit b73d6baf8. css-field · listbox · navigation-menu · number-field pasaban la prop CRUDA y duplicaban la cadena en el provider. Dos de ellos lo documentaban como desviación consciente («Unlike the other components…»), lo que confirma que fue deliberado y se quedó sin reconciliar.

Funcionalmente eran equivalentes hoy —verificado que number-field y el canónico scroll-area estampan lo mismo con el toggle en auto— pero había una divergencia latente y real en dos: css-field y number-field estampaban el valor RESUELTO. Con un esquema de prefs sin dimensión de dirección, getDir() devuelve undefined: los canónicos omiten el atributo y esos dos estampaban 'ltr'. Es el defecto que 013ceac57 corrigió en 33 componentes, superviviente en 2.

Tres tests se apoyaban en el mecanismo viejo (el harness devolvía dirección por prefs.getDir() y esperaban verla en el provider). Actualizados al contrato: sin afirmación el atributo se OMITE, con ella se estampa.

Verificado en Chrome en las tres posiciones del toggle. check 74, 25/25.

9.17 · Grupo B — NO es otro mecanismo, es otra decisión de API

drawer · float-panel · grid-list · tag-group · tree-grid · virtual-grid · virtual-list leen soma.prefs.getDir() directamente porque no exponen prop dir. Conviene separarlo del grupo A: no usan un mecanismo distinto, usan el mismo con el primer eslabón ausente, que es exactamente lo que el contrato prescribe cuando no hay prop.

Así que la pregunta no es «¿cómo resuelven?» sino «¿deberían aceptar dir?» — superficie pública, no implementación. A favor: 42 componentes ya la aceptan y un consumidor no puede fijar la dirección de estos 7 subárboles. En contra: añadir una prop a un virtual-list o un tag-group sólo tiene sentido si alguien va a fijar la dirección de ESE subárbol contra la de la página, que es un caso raro.

Pendiente de criterio. Si se hace, el patrón es mecánico y ya está probado en §9.16: dir?: Direction en types + activeDir(() => dir, soma) en el wrapper

  • ?? 'ltr' en el provider.

9.18 · Auditoría de la canonización — 2 defectos propios, con el criterio corregido

Commit 3b4340d2a. Repaso crítico de §9.16–§9.17 aplicando el criterio que la otra sesión demostró que faltaba: buscar quién ACEPTA dir y cruzarlo con quién lo resuelve, no sólo quién usa activeDir o consulta prefs.

Defecto 1 — añadí la prop pero NO el estampado. tree-grid y float-panel usan :dir() en su recipe y no estampaban el atributo: un consumidor que afirmase dir por prop movía la MATEMÁTICA y dejaba la PINTURA atrás, porque :dir() lee la dirección EFECTIVA y sin atributo eso es la heredada. Es el mismo partido-por-la-mitad que la otra sesión halló en media-player — o sea el grupo C tenía dos miembros más de los que yo creé sin darme cuenta. Verificado: tree-grid estampa ltr/rtl/auto y el grip del float-panel pasa de nwse-resize a nesw-resize.

Los otros 5 del grupo B no llevan :dir() en su recipe, así que no estampar no rompe nada; se dejan como están para no ampliar el DOM sin motivo.

⚠️ La regla que sale de aquí: añadir la prop dir a un componente no basta. Hay que preguntarse quién LEE la dirección — si el recipe usa :dir(), el atributo es parte del contrato y hay que estamparlo; si sólo hay matemática JS, la prop por opts es suficiente.

Defecto 2 — artefacto de regex. Cinco ficheros quedaron con un espacio antes de la coma (OnChangeFn , Direction). Prettier no lo corrigió porque ya estaban sin formatear en HEAD, así que nunca pasaron por su escritura — la regla de «formatea sólo lo que ensuciaste» tiene ese punto ciego.

Falso positivo, revisado y NO tocado: waveform acepta dir y no lo resuelve, pero no está roto — no tiene matemática direccional propia. El recipe espeja con :dir(rtl) (medido: la onda pasa a scaleX(-1)) y el Slider que compone corre la cadena por su cuenta. Corregido sólo su COMENTARIO, que afirmaba apoyarse en [dir='rtl'], prohibido desde §6.3.

📌 El censo final: 45 componentes aceptan dir; de ellos sólo waveform no lo resuelve, y con razón. media-player lo arregló la otra sesión.

9.19 · El alias, y los cuatro que pintaban con :dir() sin dejar afirmarla

Commits 188450072 y d490f53e9. Cierra las dos deudas que quedaban abiertas al final de §9.18.

El alias. Ocho componentes escribían dir?: 'ltr' | 'rtl' a mano en vez de Direction: calendar, command, emoji-picker, field-langs (props Y el spec de lengua del proveedor), month-grid, range-calendar y year-grid. Compila igual, pero deja el contrato fuera del alias — ensanchar o renombrar Direction no los tocaría, y la deriva no rompería en tipos, que es justo lo que el alias está para garantizar. Cambio de tipo puro.

Los cuatro de sólo-CSS. feed, nav-tree, sidebar y switch tienen reglas :dir(rtl) en su receta y no aceptaban dir. Funcionaban —:dir() lee la dirección HEREDADA y la proyección de prefs pone dir en el <html>— pero sólo para la página entera: no había forma de voltear UNO. Ahora corren la cadena canónica y estampan el crudo, por la regla de §9.18: si el recipe usa :dir(), el atributo es parte del contrato.

switch no pudo ir por OptsFromProps: ese helper quita el undefined del prop público y da Active<'ltr' | 'rtl'>, cuando «nadie afirmó nada» es exactamente el valor que el estampado crudo necesita distinguir. Va aparte, con ActiveProps<{ dir: Direction | undefined }>, y en el envoltorio fuera del bindProps (que toma getters, no Actives).

sidebar tenía además un resolutor a pelo en el motor flotante (soma.prefs?.getDir() ?? 'ltr', línea 662) que se saltaba el prop: ahora consume resolvedDir. Ése sí necesita valor concreto —el motor espeja la colocación del flyout—, a diferencia de la receta, que lee :dir().

📌 Censo final del eje (ya cerrado):

  • 55 componentes aceptan la prop dir; 61 ficheros de envoltorio corren activeDir (la diferencia son compuestos con varios proveedores). ⚠️ El grep de dir?: Direction devuelve 56 ficheros: el 56.º es field-langs, y ahí el dir es un campo de LangSpec —la dirección de cada IDIOMA del contenido—, no una prop del componente. Su proveedor no declara dir. Contar el grep sin abrir el fichero da un censo falso.
  • 0 componentes usan :dir() en su receta sin aceptar dir.
  • 0 resolutores a pelo vivos — sólo quedan menciones a getDir() en comentarios de documentación.
  • Acepta dir sin correr la cadena: sólo waveform (delega en el Slider, §9.18).
  • ⚠️ El union escrito a mano sobrevive en palabras (types.ts:130 + su test), que está excluido de escritura — la afirmación «0 inline unions» vale para el catálogo, no para el árbol entero.

Verificación: check sin errores nuevos (77 = la base con los ficheros de media-player, de otra sesión). Tests de feed y switch en verde. En SSR los cuatro estampan dir="ltr"; en Chrome el switch pasa a dir="rtl" al girar la preferencia y la regla dispara (--_switch-thumb-offset: 16px → calc(-1 * 16px)). ⚠️ La transición del pulgar no se pudo medir: el panel del navegador estaba oculto y ahí las transiciones CSS quedan congeladas — getComputedStyle devuelve la matriz identidad y parece un defecto que no lo es.

Queda fuera (deuda ajena, §8.4): 91 ficheros de test que falsean Soma.require(), cero tests de charts, smoke y perm:check sin ejecutar.

9.20 · El corpus, y los dos defectos que el propio barrido destapó

El eje estaba cerrado en CÓDIGO pero no en DOCUMENTACIÓN: el contrato vivía sólo aquí, en un handoff que docs/README.md declara «never a source of truth».

El contrato ahora es canon: docs/canon/direction-contract.md (E2, con enforcement: como recipe-contract.md). Cubre la cadena, las DOS atributos (dir crudo vs data-dir resuelto), cuándo el estampado es obligatorio, la doctrina de selector, las trampas que el guard no ve, qué espeja y qué no, y la mitad global de prefs. Registrado en docs/README.md (tabla E2 + «I want to…»), en docs/decisions.md y enlazado desde los 55 READMEs.

Dos defectos de CÓDIGO, encontrados al documentar — ambos el mismo patrón nuevo, y ambos en el eje que yo había dado por cerrado:

El doble volteo: una regla :dir(rtl) cuyo cuerpo sólo reasigna propiedades LÓGICAS. La propiedad ya se había espejado cuando la regla matchea, así que moverla de inline-start a inline-end la devuelve al punto de partida — y, como las declaraciones hermanas se quedan quietas, aterriza en el borde OPUESTO al de la cosa a la que pertenece.

  • feed.css — el raíl del hilo: sangría a un lado, raíl al otro.
  • tree-view.css — la guía de indentación: aparcada al otro lado de su subárbol.

Medido en Chrome tras el arreglo: feed da padding-left:20px+border-left:2px en LTR y padding-right:20px+border-right:2px en RTL; la guía de tree-view resuelve a left:16px en LTR y right:16px en RTL. ⚠️ No pude ver los píxeles: el panel del navegador no estaba visible.

⚠️⚠️ Cómo se colaron: la migración §6.3 ([dir='rtl'] → :dir(rtl), commit ec533845d) cambió el SELECTOR de las 12 reglas sin preguntarse si el CUERPO era correcto. Una migración mecánica hereda los defectos que traduce. RTL-1 no ve esta forma — busca un desplazamiento FÍSICO. Extender el guard para cazar «bloque :dir() cuyas declaraciones son todas lógicas» queda pendiente.

Correcciones al propio barrido (tres verificadores adversariales):

  • La ley de autoría prohíbe copiar el canon, y el primer barrido escribió la cadena literal en 54 READMEs. Retirada: cada README conserva SU efecto y enlaza el contrato una vez.
  • RTL-1 estaba anunciado como guard de todo el capítulo. Sólo cubre §4.
  • «nunca lee al padre» era absoluto en CLAUDE.md y en la arquitectura, y borraba la composición sancionada en el punto de llamada (submenús).
  • component-guide.md se contradecía a sí mismo: el estampado es CONDICIONAL.
  • Las cuatro filas nuevas del checklist usaban una aplicabilidad (directional) que ni la leyenda ni component-audit.ts conocen.
  • soma-architecture.md §3.4 enseñaba el resolutor a pelo que el eje retiró.
  • html.ts enseñaba en su JSDoc el union a mano que E-2.5 ahora prohíbe.

Divergencia DECLARADA (§7 del contrato): la familia chart no tiene prop ni proveedor, y resuelve leyendo getComputedStyle(node).direction. Es el único sitio del catálogo que hace lo que §1 prohíbe. Se registra en vez de esconderse; el arreglo conocido es que los charts acepten dir.

Guards: docs:check 0/0 sobre 564 docs · rtl:check 1 error, palabras (preexistente, excluido) · eidos-lint feed/tree-view invalid 0 · ningún test afirma sobre las dos reglas retiradas.


§10 · Cola para mañana

10.1 · RTL-2 — el guard del doble volteo · CERRADA (ver §11)

Era la única deuda del eje. §9.20 dejó los dos defectos arreglados pero la forma sigue sin guard, y es la forma que se me escapó DOS veces: en la migración §6.3 y en el cierre §9.19.

Ya está especificada y probada. No hay que investigar, hay que implementarla.

La firma — más estrecha que «bloque :dir() con puras lógicas», que daría falsos positivos sobre reglas legítimas que cambian un valor bajo RTL:

Dentro de un bloque cuyo prelude contiene :dir(), la MISMA familia lógica aparece como -start y como -end, una de las dos con valor neutro (0, auto, none, initial, unset) y la otra cargando el valor.

Familias: border-inline-*, padding-inline-*, margin-inline-*, inset-inline-*. Ése es exactamente el gesto «apago un lado y lo repinto en el otro», que es cancelar un espejo, nunca crearlo.

Recall y ruido, ya medidos (prototipo en Python, contra el árbol real):

resultado
Contra dee2c9e3a^ (antes del arreglo) 2/2 cazados — feed.css:145, tree-view.css:209
Contra HEAD 0 hallazgos sobre 18 bloques :dir()

O sea: la regla nace VERDE y con recall demostrado sobre defectos reales. Si al implementarla sale algo distinto de 0, es un hallazgo nuevo, no un bug de la regla — míralo antes de relajar el matcher.

Dónde va: src/uix/eidos/rtl-lint.ts. ⚠️ NO copies mi prototipo: usa una regex ingenua que no maneja bloques anidados ni comentarios. El fichero ya tiene parseBlocks() (línea ~175), que resuelve anidamiento, comillas, paréntesis y comentarios, y ya te da { prelude, decls[] } — que es justo lo que la regla necesita. Reutilízalo.

Piezas a tocar:

  1. rtl-lint.ts — nueva familia de constantes + la comprobación. RtlFinding hoy tiene forma de RTL-1 (translate + anchors); necesitarás un campo discriminante (rule: 'RTL-1' | 'RTL-2') o un segundo tipo.
  2. El docblock del módulo — la lista «Deliberately NOT flagged» y el enunciado de las reglas. Es doctrina, no adorno.
  3. scripts/rtl-check.ts (~línea 44) formatea el mensaje de RTL-1 a mano; necesita rama.
  4. src/uix/eidos/rtl-lint.test.ts — casos POSITIVOS: las dos formas reales (cópialas de dee2c9e3a^). Casos NEGATIVOS, que son los que evitan que la regla se vuelva ruido: un :dir() que cambia un valor sin relocalizarlo · un bloque con sólo -start · uno con las dos caras y NINGUNA neutra · un bloque sin :dir() en el prelude.

Tres decisiones que son tuyas, no las dejo tomadas:

  • ¿:dir(ltr) también? Mi prototipo escanea ambos. Un :dir(ltr) con esta firma es igual de sospechoso, pero es un gesto más raro.
  • ¿Escape hatch propio o reutilizar rtl-physical:? A favor de reutilizar: un solo vocabulario. En contra: la razón que se declara es otra.
  • ¿RTL-2 aparte o ensanchar RTL-1? Yo lo haría aparte — otra firma, otro mensaje, y la fila X-1.6 del checklist ya nombra RTL-1.

⚠️ Cuando aterrice, DOS textos quedan obsoletos el mismo día (los escribí diciendo que el guard no ve esta forma, y dejarán de ser verdad):

  • docs/canon/direction-contract.md §4, subsección «The double flip» — última frase: «RTL-1 does not see this shape».
  • docs/process/CONTINUE-direction.md §9.20 y la memoria reference_dir_rule_double_flip.

Verificación: npm run rtl:check debe seguir dando 1 error — el de palabras-chrome.css:446, preexistente y excluido de escritura. Cualquier otro número hay que explicarlo. Más npx vitest run src/uix/eidos/rtl-lint.test.ts.

10.1-bis · El refactor — handoff APARTE

El estampado y la propagación siguen siendo convenciones manuales: 54 líneas escritas a mano, 7 enlaces compuestos a mano, y 20 de 55 componentes que las incumplían hasta 21055bd3c. Eso no se arregla con otro barrido.

📄 docs/process/CONTINUE-direction-runtime.md — handoff propio, para abrir en sesión nueva. Lleva las dos comprobaciones de viabilidad YA HECHAS (el runtime ya omite un atributo undefined; ya inyecta atributos sin declaración en morfo, con precedente literal), los dos movimientos, el orden y la línea base de verificación.

10.2 · El resto de la cola, por valor

  • Los charts deberían aceptar dir. Es la divergencia DECLARADA en §7 del contrato: la familia no tiene prop ni provider y resuelve leyendo getComputedStyle(node).direction (chart/rtl.svelte.ts + duplicado inline en chart.svelte:175-198). Mientras siga así, un chart se espeja con la página y no se puede voltear para un subárbol. Consumidores: calendar-heatmap, funnel, heatmap, radar-chart.
  • number-field/README.md no tiene tabla de props (ninguna, no es que le falte la fila de dir). Su dir vive en ### Direction resolution. Fabricar una tabla de una fila diría «dir es su única prop» — hace falta la tabla entera o nada. Decisión pendiente.
  • Numeración duplicada en el checklist de construcción de component-guide.md: 38 y 39 aparecen dos veces, y el ítem 41 nuevo quedó entre el 13 y el 14. Es deuda PREEXISTENTE; renumerar rompería las citas («los ítems 34–35 son el mecanismo», A32). Requiere decisión, no barrido.
  • palabras conserva el union a mano (types.ts:130 + su test). Excluido de escritura por decisión del usuario.
  • Deuda ajena (§8.4), intacta: 91 ficheros de test que falsean Soma.require() · cero tests de gráficas · smoke y perm:check sin correr.

10.3 · Estado del árbol

Commiteado hasta 961c1bf8b, sin pushear — el remoto es gita, no hay origin. Sin tocar en el árbol: .claude/settings.local.json, src/arts/adom/__scratch-verify.ts y web/routes/alpha/ (este último no se commitea nunca). check = 77, que es la línea base con los ficheros de media-player de la otra sesión.


§11 · RTL-2 implementada — el guard del doble volteo

§10.1 cerrada. La regla existe, corre en npm run rtl:check junto a RTL-1, y nace verde con recall demostrado.

Las tres decisiones que §10.1 dejaba abiertas, tomadas:

  1. :dir(ltr) también entra. La firma es igual de sospechosa y no cuesta nada: 0 falsos positivos sobre el árbol.
  2. Marcador propio, rtl-mirror: <reason>. Reutilizar rtl-physical: mentiría — aquí no hay nada físico, y un marcador que miente es peor que ninguno. Mismo mecanismo (exemptLines pasó a tomar el patrón por parámetro), dos vocabularios precisos. Hay un test que comprueba que el marcador de RTL-1 no exime a RTL-2.
  3. Regla aparte: lintRtlMirror() con su propio RtlMirrorFinding. Los 14 tests de RTL-1 no se tocaron, y el tipo lleva los campos que importan (family · neutral · payload) en vez de forzar los de RTL-1.

La firma implementada — dentro de un bloque cuyo prelude tiene :dir(), la misma familia lógica (border/padding/margin/inset-inline) declarada en las dos caras, exactamente una con valor neutro (0, auto, none, initial, unset, revert). El desequilibrio ES el espejo cancelado.

Verificación, en este orden:

resultado
rtl-lint.test.ts 27/27 (14 de RTL-1 intactos + 13 nuevos)
Guard real contra el árbol pre-arreglo (dee2c9e3a^) 2/2 cazados — feed.css:147, tree-view.css:211
npm run rtl:check sobre HEAD 1 error, el de palabras, preexistente
docs:check 0/0 sobre 564 docs
check 77 = línea base

La prueba de recall se hizo restaurando los dos ficheros del commit anterior SOBRE el árbol, corriendo el guard completo y devolviéndolos con git checkout en el mismo bloque — probar el runner entero, no sólo la función.

Un defecto de presentación que salió al hacerlo: el calc() multilínea de tree-view partía el mensaje de error por el primer salto de línea. Los campos que RTL-2 emite colapsan el whitespace; hay test.

Textos actualizados (los que §10.1 avisaba que quedarían obsoletos): el enforcement: y §4 del contrato · la fila RTL del build contract en component-guide.md · la fila X-1.6 del checklist · los docblocks de rtl-lint.ts y rtl-check.ts, que son doctrina.

⚠️ Sigue siendo cierto lo que RTL-1 y RTL-2 NO ven: leen texto CSS. Un transform inline escrito por JS, un preset de motion compartido y la geometría SVG siguen necesitando el ojo en RTL. El docblock lo dice; no lo borres al tocarlo.

Queda: §10.2 sin cambios — los charts deberían aceptar dir (divergencia declarada §7 del contrato) · number-field sin tabla de props · numeración duplicada preexistente en component-guide.md · deuda ajena §8.4.

Powered by TurnKey Linux.