33 KiB
CONTINUE — el eje de dirección (RTL/LTR)
Kickoff: "Lee
docs/process/CONTINUE-direction.mdy empieza por §2.1." Fecha: 2026-08-02 · Ramaalpha-0.1-dir-prefs(sale dealpha-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, 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 <main> (§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 <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 deaccentNO debe despertar al lector dedirection; 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 langno sigue al idioma. Eldirsí se proyecta; ellangse queda enencon árabe seleccionado. Afecta a selección de fuentes, corte de palabras y lectores de pantalla.perm:checksin ejecutar. El topbar ya llevadata-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 enlocalStoragey las rutas alternan ltr/rtl.- 91 ficheros de test falsifican
Soma.require()convi.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 enrtl. 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: isolateNO 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 | undefineden 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: undefinedes un valor de atributo válido, así que los estampados compilan pasara lo que pasara. Esa mitad se verifica contando (grep -cde crudo vs resuelto por fichero), no confiando. - Los barridos con regex mintieron cuatro veces de cuatro:
resolvedDirautorreferencial en 17 ficheros (el regex reescribió el cuerpo de la propia declaración), duplicados en 3, campo insertado en 3 clases sindir, yactiveDir(valor)en vez deactiveDir(() => valor)en 10 wrappers. - Anclaje de clase que falla:
^export class \w+Provider \{$exige que la línea TERMINE en{, así que se saltaclass 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 --writesobre un directorio entero. Sobre las demos generó 165 ficheros y +31.000 líneas de ruido y rompió dos atributos normalizandodata-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__*). smokees 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ó
42d58c394mientras yo trabajaba. VerificaHEADantes 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 quetransform: 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: -22pxcon slop de44px— la mitad exacta — ytranslate: 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:-23pxsobre46px,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 = 0en LTR, en RTL y al volver.tabs: roto por partida doble, y MIGRADO aMeasuredIndicator. 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 coninset-inline-start: 0LÓGICO mientras el JS le aplicaba untranslate3d(x)FÍSICO. Ahora soma mide (con la dependenciadir) y el recipe posiciona desde--indicator-*conleft: 0. Los cuatro escenarios adx = 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:
drawerestá bien y es el ejemplo a imitar: traducestart/endaleft/rightUNA vez enresolveDirection()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 exponetop/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:
startyendse espejan (lo que el viejo no podía). - RTL con side horizontal: el align NO cambia — es el eje de bloque.
centersigue 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.
calcFromPointermedí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.- Y aun así seguía sin funcionar, porque su wrapper se quedó fuera de la
normalización de
013ceac57: teníadir = 'ltr'como default duro yreadableActive(() => dir)en vez deactiveDir(() => dir, soma). O sea nunca consultaba prefs yresolvedDirvalí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.
6.13 · El swipe del carousel SÍ estaba mal — el rail huía del dedo
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-areafunciona. Confirmado por el usuario y medido: con el toggle enauto, ES→ltr y AR→rtl, y el elemento pasa adirection: rtl. SuresolvedDirsigue siendo código muerto (una sola aparición) y el uso descrollLeftsigue sin verificarse en un eje horizontal real — pero el componente responde a la dirección.chartno necesita arreglo, y las referencias lo respaldan. Medido:chartDir,legendDiry el texto de los ejes pasan deltrartl; 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 delsidebary entree-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.tsfalla poraudio-player— preexistente, verificado con stash. Es del hilo del sonido, no de este eje.- Los 9 CSS tocados ya fallaban
prettier --checkANTES de tocarlos. No los formatees: son ficheros enteros de ruido (§5).