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

21 KiB

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

Kickoff: "Lee docs/process/CONTINUE-direction.md y empieza por §2.1." Fecha: 2026-08-02 · Rama alpha-0.1-dir-prefs (sale de alpha-0.1-sec-dom). §1 es lo que ya está cerrado — no lo rehagas. Lo abierto empieza en §2.

Hermano de este documento: CONTINUE-player-rtl.md, 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 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.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).

Powered by TurnKey Linux.