29 KiB
CONTINUE — la dirección deja de ser una convención
Handoff para una sesión nueva. Leer entero antes de tocar nada: el refactor es pequeño en líneas y grande en alcance — toca un mecanismo del que cuelga todo el catálogo.
Contexto previo: docs/process/CONTINUE-direction.md (el eje entero) y
docs/canon/direction-contract.md (el contrato, que es lo que este refactor
automatiza). No hay que releerlos para empezar; lo que hace falta está aquí.
1. Por qué — los números
El contrato es correcto. El mecanismo para cumplirlo es teclear la misma línea en cada componente, y por eso falla:
| veces | |
|---|---|
Estampado del dir escrito a mano en un provider |
54 |
| Enlace al padre compuesto a mano en un envoltorio | 7 |
Content portalizados que re-arrancan la cadena por su cuenta |
9 |
Componentes que aceptaban dir y no lo estampaban |
20 de 55 |
Los 20 se arreglaron uno a uno (21055bd3c), leyendo receta y envoltorio de
cada uno. Encontrarlos costó una auditoría. El siguiente componente que
alguien escriba volverá a tenerlo, porque nada lo impide: la regla vive en la
cabeza del que lo escribe.
Una convención que hay que repetir 54 veces y que 20 de 55 incumplían no es una convención — es una abstracción que falta.
2. Las dos comprobaciones, HECHAS
Las dos condiciones de viabilidad están verificadas. No hay que repetirlas.
2.1 · ¿Puede el runtime emitir un atributo que a veces debe estar AUSENTE?
Sí, ya lo hace. src/uix/soma/runtime.svelte.ts:519-522, en renderProps():
for (const plan of compiledPart.dynamicAttrs) {
const value = evalAttrPlan(plan, bindings);
if (value !== undefined) props[plan.attr] = value;
}
undefined ⇒ el atributo no se escribe. Es exactamente la semántica del
estampado crudo: nadie afirmó ⇒ no hay atributo ⇒ el elemento hereda.
resolveProps() (:496-503) hace lo mismo. No hay que construir nada para
esto.
2.2 · ¿Y sin declararlo en el morfo?
También, y hay precedente literal. partPropsForRegistration()
(runtime.svelte.ts:538-551) ya inyecta id, el marcador del componente,
data-archetype y reg.attachment sin ninguna declaración en el morfo, y
el comentario de data-archetype justifica por qué:
data-archetypeis part of static identity (cross-component classification, never mutates), so it ships through partProps — not through dom.apply.
Así que el movimiento 1 NO exige tocar 55 ficheros de morfo. Hay dos vehículos posibles y la elección es de quien lo haga (§4.1).
2.3 · ¿Tiene la capa flotante acceso al dueño?
Sí. Los sub-proveedores llevan this.provider (verificado en select y
popover).
⚠️ El reparto que decía este apartado era FALSO, y la fuga no estaba donde decía. Censo real (§9.1): de los 11
FloatingContent.create, 10 pasandir: opts.diry 1 (sidebar) pasathis.provider.opts.dir— no 8 y 3. Y eseopts.dirno era una fuga: los 7 envoltoriosContentque lo alimentan ya componían el enlace al padre a mano, así que la afirmación de la raíz SÍ cruzaba. Lo que no cruzaba era otra cosa (§9.1).
3. Qué está mal hoy, en una frase
El estampado y la propagación son dos convenciones manuales, y el eje entero —cuatro auditorías, tres barridos y unos veinte defectos— es lo que cuesta mantenerlas a mano.
4. Los dos movimientos
4.1 · El estampado pertenece al runtime
Objetivo: que las 54 líneas escritas a mano se vuelvan cero, y que un
componente que acepte dir no pueda dejar de estamparlo.
Vehículo A — inyección en partPropsForRegistration. El provider registra
su parte pasando la dirección cruda; el runtime la emite junto a id y
data-archetype. Precedente exacto (§2.2). Mínimo código. Coste: el runtime
pasa a conocer un concepto que hoy no conoce.
Vehículo B — un dynamicAttr universal en el compilador de morfo. El
compilador añade a la parte provider un AttrPlan para dir con fuente
fromProp: 'dir'. Encaja con «morfo declara, soma ejecuta», y renderProps()
ya lo omitiría solo cuando resuelve undefined (§2.1). Coste: toca el
compilador y su caché; hay que mirar contracts.cssSelectors, porque el
conjunto cerrado de selectores que eidos puede usar se calcula ahí.
⚠️ Decidir A o B es la primera tarea, y no está decidida. Mi inclinación es A por tamaño y por precedente, pero B es más fiel a la doctrina. Léete los dos sitios antes de elegir.
⚠️ Lo que NO debe pasar: que el atributo aparezca en componentes que hoy
NO deben llevarlo. Son cuatro y están verificados: popover, tooltip,
link-preview y context-menu — su raíz no renderiza elemento o no pinta nada
direccional, y su pintura vive en el panel portalizado. Si el runtime lo emite
universalmente, esos cuatro ganan un atributo inútil. Es la parte delicada del
movimiento 1: la emisión tiene que colgar de que el componente ACEPTE dir,
no de que exista una parte provider.
⚠️ Y la excepción del §2 del contrato: avatar e image NO aceptan dir a
propósito (su :dir() sólo traduce a físico una prop lógica que ya eligió el
consumidor). Si acaban con la prop por efecto colateral, el refactor se ha
llevado por delante una decisión firmada por el usuario.
4.2 · La propagación pertenece a la capa flotante
Objetivo: que la afirmación de la raíz cruce el portal sin que nadie escriba el enlace.
Hoy cada Content portalizado vuelve a arrancar activeDir desde cero, y por
eso la afirmación no cruza. La forma: FloatingContent toma la dirección del
proveedor DUEÑO, y el dir propio del Content la pisa si existe. Una
composición dentro de la capa en vez de once fuera.
Los 7 enlaces compuestos a mano (dropdown-menu ×2, menubar, select,
combobox, context-menu ×2) y el de color-picker se borran.
Y cierra sin trabajo extra el pendiente que quedó reportado: ningún picker
de eidos reenvía dir a su PopoverContent, así que el cromo del picker-shell
dentro del portal (picker-shell.css:78, el split del pie) está sin cubrir en
los siete. Con 4.2 deja de ser una tarea; sin 4.2 son seis parches idénticos.
✅ 4.2 EJECUTADA — ver §9. Lo de abajo es el enunciado original, conservado porque el diagnóstico cambió al verificarlo.
5. Orden, y por qué
4.2 primero. Es más pequeña, está más acotada (una capa, once consumidores)
y su verificación es directa: abrir un Select y comprobar que trigger y panel
coinciden. Además borra código, así que 4.1 llega a un árbol más limpio.
4.1 después, y sólo cuando 4.2 esté verde y commiteada.
6. Verificación — la línea base exacta
Antes de empezar, tomar estas medidas y compararlas al final. Cualquier desviación hay que explicarla, no absorberla:
| valor esperado | |
|---|---|
npx svelte-check --threshold error |
77 (la base; sube si otra sesión toca media-player) |
npm run rtl:check |
1 error — palabras-chrome.css:446, preexistente y excluido |
npm run docs:check |
0 errores sobre 564 docs |
| Tests de los componentes tocados | verdes; eidos/lint.test.ts falla por audio-player sin morfo, ajeno |
⚠️ En navegador, y no es opcional: el censo del estampado NO se puede hacer
por grep. Dos componentes (listbox, navigation-menu) estampan a través de un
alias assertedDir y un grep del patrón literal los da como huecos. La
comprobación buena es leer el DOM:
// con la demo abierta, por cada componente
document.querySelector('[data-select]').getAttribute('dir');
⚠️ El panel del navegador tiene que estar VISIBLE. Oculto no compone frames:
no hay capturas y las transiciones CSS se congelan, así que
getComputedStyle(...).transform devuelve la matriz identidad y parece un
defecto que no lo es. Me pasó dos veces.
7. Trampas ya pagadas — no volver a pisarlas
- El estampado es el CRUDO (
opts.dir.current), nuncaresolvedDir. Un valor resuelto escribedir="ltr"cuando nadie afirmó nada, forzando el subárbol a LTR dentro de una página RTL. Pasó ensidebary lo escribí yo. diren un<svg>no hace nada — la hoja UA de HTML no alcanza a los elementos SVG. Va en el envoltorio HTML. Medido en Chrome.OptsFromPropsquita elundefineddel prop público y destruye la distinción entre «nadie afirmó» y'ltr'. Quien lo use declaradiraparte.- Clasificar esto por grep se equivoca. Yo di
avatarpor defectuoso leyendo el patrón en vez del componente, y el usuario lo cazó. Abre la receta, abre el provider, mira dónde renderiza cada parte. - Una migración mecánica hereda los defectos que traduce. El barrido de §6.3 cambió doce selectores sin mirar los cuerpos y arrastró dos dobles volteos hasta que los cazó el guard RTL-2.
8. Estado del árbol al escribir esto
Rama alpha-0.1-dir-prefs, con remoto gita (no hay origin). Último commit
del eje: 21055bd3c. Sin tocar y no commitear: .claude/settings.local.json,
src/arts/adom/__scratch-verify.ts, web/routes/alpha/. media-player lo
edita otra sesión — excluirlo de todo staging.
9. Sesión 2026-08-04 — §4.2 ejecutada
Sin commitear. Rama alpha-0.1-dir-prefs, HEAD se movió a 4755d97d1
mientras trabajaba (otra sesión, media-player + audio-player): esos
ficheros están fuera de este cambio y fuera de cualquier staging.
9.1 · El censo del §2.3 estaba mal, y la fuga era otra
Rehecho leyendo los ficheros, no el patrón:
FloatingContent.create |
11 sitios en 9 ficheros |
…pasando dir: opts.dir |
10 |
…pasando this.provider.opts.dir |
1 (sidebar) |
Envoltorios Content que componían el enlace al padre A MANO |
7 |
Envoltorios Content SIN enlace |
3 — popover, tooltip, link-preview |
Y esos 3 no eran un defecto: sus raíces no aceptaban dir en absoluto, así
que no había nada de la raíz que heredar. O sea entre los 11 consumidores del
motor flotante no había ninguna fuga.
La fuga estaba un piso más arriba: en quien COMPONE un popover. Nueve
componentes crean un PopoverProvider internamente
(color-picker, date-picker, date-range-picker, emoji-picker,
gradient-picker, natural-time-picker, picker, time-picker,
time-range-picker) y siete de ellos aceptan dir — pero PopoverProvider
no tenía dónde recibirlo, así que la afirmación moría en el borde del portal.
Medido en Chrome ANTES, con <DatePicker dir="rtl"> y la página en LTR:
[data-floating-wrapper] dir="ltr" ← el panel entero forzado a LTR
[data-picker-shell] direction ltr
[data-picker-footer] direction ltr Clear x=15 · Cancel x=102 · Close x=209
[data-calendar] direction RTL ← lo ÚNICO que espejaba
El calendario mirroreaba porque es un componente soma propio que recibe el dir
por el árbol de proveedores; el cromo que lo rodea —el picker-shell, cuyo pie
reparte con margin-inline-end: auto (picker-shell.css:78)— no. Panel
partido por la mitad, que es exactamente el fallo del §2 del contrato.
9.2 · La forma implementada
el dir propio de la superficie → el dir afirmado por el proveedor DUEÑO → omitir
FloatingProviderOpts.dir— el dueño baja su afirmación (ya pasada poractiveDir, o seaprop → prefs, así que la cola de prefs queda intacta).FloatingContent.assertedDir—opts.dir.current ?? provider.opts.dir.current. Los dos estampados del wrapper (rama nativa y rama JS) leen de ahí.createFloatingShellRootlo reenvía; es la puerta de 8 de los 10FloatingProvider. Los otros 2 (submenús dedropdown-menuycontext-menu) pasan el del menú padre.
⚠️ El punto que decide si esto funciona: FloatingContentOpts.dir pasa a ser
la prop cruda del Content, no el resultado de activeDir. Si el envoltorio
resolviera ahí, el valor sería casi siempre concreto (prefs) y el dueño nunca
ganaría el fallback. Los 10 envoltorios pasan ahora readableActive(() => dir).
Consecuencias:
- Los 7 enlaces compuestos a mano se borran, y con ellos el
soma, elactiveDiry elparentRoot/parentMenude cada envoltorio. popover,tooltipylink-previewaceptandiren su raíz (prop pública nueva + opt). No estampan nada —no renderizan elemento—; el valor existe para cruzar el portal, y es lo que permite que un picker empuje su dirección al panel. Sin esa prop habría que escribiractiveDir(() => undefined, soma)en la raíz, que es peor y no da nada a cambio.- Los 9 composers pasan su dir al
PopoverProvider.gradient-pickerypickerno aceptandir(§9.17 del eje), así que corren la cadena con el primer eslabón ausente:activeDir(() => undefined, soma).
Queda UN activeDir con enlace al padre, y a propósito:
dropdown-menu-sub-content.svelte lo necesita para effectiveSide (el lado por
defecto del submenú es un left/right concreto, o sea MATEMÁTICA, que sí
quiere valor resuelto). El ATRIBUTO ya no sale de ahí.
9.3 · Verificación
Medido en Chrome real, con la página en LTR y el dir movido por el control de
la demo (el camino real, nunca setAttribute):
| antes | después | |
|---|---|---|
date-picker · wrapper |
ltr |
rtl |
date-picker · shell / footer |
ltr |
rtl |
date-picker · Clear/Cancel/Close x |
15 / 102 / 209 | 200 / 92 / 15 |
date-range-picker · Clear/Cancel/Close x |
— | 448 / 217 / 17 |
Y a ojo: el panel entero espeja —cabecera, rejilla de días y pie—, no sólo el
calendario. Sin regresión en los que ya funcionaban, comprobados uno a uno:
dropdown-menu (raíz y submenú, con data-side=left en RTL), context-menu,
menubar, select (por prefs, las dos direcciones), y tooltip en auto, que
sigue estampando ltr — la cola de prefs no se perdió.
| guard | resultado |
|---|---|
npx svelte-check --threshold error |
78 = línea base (A/B con stash: 78 sin mis cambios) |
| Tests de los 20 ámbitos tocados | 158/158 |
npm run rtl:check |
1 — palabras-chrome.css:446, preexistente |
npm run docs:check |
0 sobre 565 |
| Pasada completa de vitest | 9 fallos, ninguno mío (A/B con el árbol limpio: los 6 deterministas de contracts/eidos-lint salen igual; soma-attr-audit, orca y cookie-adapter pasan aislados — flaky bajo carga) |
⚠️ La línea base de check es 78, no 77 como decía §6 — con el
__scratch-verify.ts sin commitear en el árbol.
9.4 · Trampas de esta sesión
- Lancé
prettier --writesobre los 43 ficheros tocados y fue un error. Reformateó líneas PREEXISTENTES (imports colapsados, prosa de dos READMEs reflowada:popover/README.mdpasó de +7/−4 a +49/−46). Deshecho a mano hasta devolver la diff a su forma quirúrgica. La regla del §5 del eje sigue viva: formatea sólo lo que ensuciaste, y mira el tamaño de la diff después. Lo único que se conserva del formateo son dos líneas MÍAS que superaban las 100 columnas. - Un click JS justo después de navegar no engancha: hasta que Svelte hidrata,
.click()no dispara el handler. Y leer el atributo en la MISMA llamada que el click devuelve el valor viejo — hay que separar en dos llamadas. - El control de dirección de algunas demos vive bajo la pestaña «eidos System», no en la vista Live. Sin cambiar de pestaña no hay chips que pulsar.
9.5 · Lo que queda apuntado, sin tocar
- 9 campos
resolvedDirMUERTOS en los proveedoresContent(select495,combobox577,dropdown-menu314 y 1230,context-menu252 y 995,menubar563,tooltip409,link-preview268). Verificado que nadie los lee — toda la matemática direccional consumethis.provider.resolvedDir, o sea el de la RAÍZ. Ya eran código muerto antes; ahora además su docblock («Direction for this part's OWN math») describe algo queopts.dirya no es. Borrarlos requiere tu palabra. - Agujero de observabilidad en 5 demos de picker:
time-picker,time-range-picker,color-picker,emoji-pickerygradient-pickertienen el controldirdel arnés pero no se lo pasan al componente, así que la mitad que ejercita el portal no se puede ver. Sólodate-pickerydate-range-pickerlo pasan — y por eso son los dos que pude medir. Mismo agujero que §8.2 del eje, misma clase de arreglo. context-menu-sub-contentclavaside = 'right'(físico) mientrasdropdown-menu-sub-contentlo deriva de la dirección. Incoherencia entre dos componentes hermanos, preexistente y ajena a este cambio.- §4.1 sigue entera y sin empezar — el estampado del
diren el runtime, y la elección Vehículo A vs B sigue sin tomar.
10. Auditoría de la sesión — lo que salió al re-verificarlo todo
10.1 · Código muerto: BORRADO
Los 9 resolvedDir de §9.5.1 están eliminados. Antes de borrar, verificado
que ninguno se lee: las 5 lecturas vivas de resolvedDir en esos ficheros son
todas this.provider.resolvedDir —o sea el de la RAÍZ— desde
SelectTriggerProvider, MenuContentProvider, ContextMenuContentProvider,
MenubarMenuProvider y MenubarTriggerProvider. Cero lecturas desde eidos,
blocks, packs, web/routes o tests. Quedan 5 declaraciones, las cinco de
raíces vivas.
10.2 · Las demos: las 5 cableadas
time-picker, time-range-picker y color-picker no tenían control de
dirección en absoluto; emoji-picker y natural-time-picker lo tenían en el
arnés pero no se lo pasaban al componente. Ahora las siete demos de picker
que aceptan dir lo pasan a la instancia, así que el camino de la PROP —el
único que ejercita el portal— es observable en todas.
⚠️ Corrección a §9.5.2: dije «5 demos con el control pero sin pasarlo». Falso
en dos sentidos: tres no tenían control, y gradient-picker/picker no aceptan
dir (§9.17 del eje), así que no entran.
10.3 · Censo de ISLAS — un defecto nuevo, PREEXISTENTE y de otra familia
Con las demos cableadas se puede al fin escanear cada panel en RTL buscando elementos que estampen una dirección DISTINTA a la del envoltorio flotante:
| panel | islas |
|---|---|
date-picker · date-range-picker · time-picker · time-range-picker |
0 |
natural-time-picker |
1 — [data-slider] |
emoji-picker |
3 — [data-command] + [data-toggle-group] ×2 |
color-picker |
1 — [data-color-field-format-select] (un Select) |
La forma es la misma que arregló §4.2, pero para composición EN SITIO en vez
de por portal: un componente eidos monta otro componente del canon
(natural-time-picker-panel.svelte → Slider.Provider,
emoji-picker-content.svelte → Command + ToggleGroup,
color-field-format-select.svelte → Select) sin reenviarle el dir. El
hijo corre su propia cadena, cae en prefs, estampa ltr — y ese atributo CORTA
la herencia del panel, que sí es rtl.
Es preexistente: mientras el panel entero era LTR la isla no se veía. Lo que hizo §4.2 fue volverla visible.
⚠️ No lo he arreglado, y es deliberado: no hay UN solo precedente en eidos de
reenviar dir a un componente compuesto (grep "dir={" src/uix/eidos da un
único resultado, y es data-dir de otra cosa). Arreglarlo es inventar el patrón
—doctrina nueva, no un parche— y toca 3 ficheros que este movimiento no toca.
Es un movimiento propio.
La misma clase de defecto vivía en la DEMO del color-picker, que monta <Tabs>
y un <ColorField> a mano dentro del panel: ahí sí lo arreglé, porque es demo y
porque si no el control de dirección que acabo de añadir enseñaría un panel medio
volteado y parecería defecto del framework.
10.4 · Verificación tras la auditoría
npx svelte-check --threshold error |
78 = línea base (A/B con stash: 78 sin mis cambios) |
| Tests de los 20 ámbitos tocados | 158/158 |
npm run rtl:check |
1, el de palabras |
npm run docs:check |
0 sobre 565 |
| Islas en los 7 paneles | 4 paneles a 0; las 5 restantes documentadas arriba |
⚠️ Trampa medida dos veces: svelte-check dio 79 en una pasada y 78 en
la siguiente sin tocar nada. Era una CARRERA con la otra sesión guardando
ficheros de media-player. En una rama compartida, un número que no reproduce
no es un hallazgo — repite la medida antes de perseguirlo.
⚠️ Las 5 demos tocadas ya fallaban prettier --check en HEAD, así que no se
formatearon (§9.4).
11. ENDGAME ejecutado — 2026-08-05 (P1–P5, decisiones D1–D4 firmadas)
El plan completo vive en la sesión que lo ejecutó; lo durable está en el canon
(direction-contract.md §1/§2/§6) y en docs/decisions.md. Resumen operativo:
| fase | commit | qué |
|---|---|---|
| P1 | 9158d0796 |
DirectionContext dentro de activeDir — la cadena gana el eslabón del ancestro; los 3 reenvíos de 4.3, el enlace manual del submenú y los 3 canarios de media-player se BORRAN. El repro de la otra sesión pasa sin ellos. |
| P2 | 973d2c886 |
Proyección automática + semilla — createActiveUix proyecta por defecto (projectPrefs:false opt-out; attach opt-in); readPrefsEnvironmentFromDom siembra <html dir> puesto a mano (intent > env > derive > default). Los 3 cableados manuales borrados en el MISMO commit. |
| P3 | 01cc13d41 |
El flip — activeDir(dir) devuelve la AFIRMACIÓN (prop ?? ctx), sin prefs y sin parámetro soma; la matemática vive en resolveDir(dir, soma). En auto la página entera lleva UN dir (el <html>). SSR: cero dir en el payload. |
| P4 | fc84305c2 |
El morfo declara — direction: { parts } en 51 morfos; soma.runtime<M> computa el requisito del tipo (declarado ⇒ requerido; no ⇒ prohibido); compileMorfo valida parts fail-closed; censo prop↔morfo como guard (direction-census.test.ts) con las excepciones firmadas (field-langs, waveform, popover/tooltip/link-preview). La ceremonia de 4.1 (67+47 dir: null, 49 getters) MUERE. |
| P5 | (este commit) | activeEidosDir gana el contexto (compartido con soma) y se parte igual (resolveEidosDir); charts al día; corpus + decisiones + memoria. |
El estado final del eje: nadie escribe dir= a mano; ausente hereda de
verdad; olvidar el cable no compila; el ambiente llega al DOM una vez. Los tres
dir= de media-player eran los canarios y CANTARON (panel rtl sin ellos).
Verificación final medida
auto: 1[dir]en toda la página (el<html>proyectado) — antes ~cada raíz.- prefs rtl: select y su panel portalizado SIN atributo, computan rtl por herencia.
- prop rtl: trigger del dropdown estampa, el portal cruza, submenú
data-side=left, 0 islas. - matemática: slider sin atributo con página rtl — click 25% físico → 75,
ArrowRightbaja. - SSR (curl): cero
dir=en select y accordion. check77 = línea base en todas las fases ·rtl:check1 (palabras) ·docs:check0/566 · suites 1401/1402 (el 1:soma-attr-audit, flaky bajo carga, pasa aislado).
La cola que QUEDA (pospuesta por decisión D4 o ajena)
Censo de APICERRADO 2026-08-05 (tarde) — los tres que quedaban:pickerygradient-pickeraceptandir?: Direction(prop →activeDir→ morfodirection: {}→ runtime; el genérico arrastra la opt a natural-time-picker y gradient-picker por tipo), ychat-logacepta y reenvía a losFeed/VirtualListque compone — sus 2 errores de línea base MUEREN: la base pasa de 77 a 75. El censo-guard los cuenta sin excepción nueva. Verificado en Chrome: gradient-picker root+panelrtlpor prop con 0 islas; chat-log AUSENTE en auto yrtlpor prop.- Opts canónicas transversales (bindProps v2) — eje propio; el censo completo está en el reporte del agente de diseño de esta sesión.
- Deuda ajena intacta: 91 tests que falsean
Soma.require()· cero tests de charts ·smoke/perm:checksin correr.
Trampas nuevas de esta pasada, para no repetir
git commitSIN pathspec en rama compartida se llevó por delante el índice de la otra sesión una vez (b41669c43). Desde entonces: SIEMPREgit commit -- <rutas>.- El detector de huérfanos por identificador se tropezó con la RUTA del import
(
core/soma.sveltecontiene "soma") — excluir líneas de import antes de buscar usos. readableActive/vista de prefs construida DENTRO de una función pura llamada por$derived= una alocación por pasada — cachear por instancia (WeakMap enresolveEidosDir).- El gate de P4 cazó a
command(estampado inline en el assert que el barrido 4.1 no vio) y a 5 runtimes secundarios del mismo morfo — un tipo condicional bien puesto encuentra lo que los barridos no.