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-player-rtl.md

373 lines
24 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# CONTINUE — el media-player (nació del hilo RTL, que ya cerró)
> **Kickoff**: _"Lee `docs/process/CONTINUE-player-rtl.md` y sigue por la cola de §0."_
> **Fecha**: 2026-08-01 · actualizado 2026-08-04 · Rama `alpha-0.1-dir-prefs`
> (sale de `alpha-0.1-sec-dom`).
> **§1, §2, §3 y §4 están CERRADAS y verificadas** — no las rehagas. El
> documento arrancó siendo del eje RTL; hoy es el handoff del **componente**.
> Empieza por **§0**, que es la cola priorizada; §1–§4 son el registro de lo
> hecho, y se leen sólo si necesitas el porqué de una decisión.
---
## 0. La cola — por orden de valor (estado a 2026-08-04)
### Dónde está el componente
`component:audit --only media-player` = **PASS** (0 errores; los 2 avisos son
falsos positivos del script, que busca `MutationObserver` y `uix.events.emit`
como TEXTO en la página y no los ve porque la demo los factoriza en `DemoTrace`
y `SemaPanel` — comprobado en el código de ambos).
| Guard | Estado |
| ------------------------- | --------------------------------------------------------------------- |
| `check` | **0 errores propios** (la base global fluctúa por las otras sesiones) |
| vitest soma del player | **14/14** |
| `eidos-lint media-player` | **invalid 0 · class-hooks 0** (morfo-backed 29) |
| `morfo:check` | player sin fallos (7 preexistentes ajenos) |
| `smoke` de la ruta | PASS |
| `perm:check` | **SIN CORRER** — es el largo; el topbar dio paso a todas las rutas |
**Partes declaradas sin implementar: 1 de 23.** Con `Captions` cerrado sólo
queda **`settings-button`**.
### 0.1 · `settings-button` — el ítem grande restante
Menú de velocidad / calidad / pista. Exige componer `DropdownMenu` a nivel soma;
la mecánica ya existe (`setPlaybackRate`, `cycleRate`, y el `RateButton` los
usa). **Cerrarlo pone el contrato del morfo en 23/23**, que es lo único que
separa al componente de tenerlo completo.
### 0.2 · Waveform como scrubber — DESBLOQUEADO, faltan dos precisiones
Las decisiones del usuario (§5) resolvieron el nudo. Antes de implementar hay
que decidir —y **NO improvisar**— dos cosas:
1. Qué es exactamente «genérico»: ¿patrón fijo, pseudoaleatorio estable derivado
de la fuente, o barras planas?
2. ¿Basta la ausencia de `peaks` como señal de streaming, o hace falta una prop
aparte?
### 0.3 · Defectos vivos, medidos
- **La variante `bar` se estruja a 720px**: el `time-slider` colapsa a **ancho
0** y el mute a **19px** (los demás miden 36). Pasa **igual en LTR y RTL**, así
que no es del eje de dirección — la fila no tiene sitio para lo que mete.
- **`audio-player` rojo en `eidos/lint.test.ts`**: la capa de variantes no tiene
morfo propio y el guard no tiene categoría para «capa de variantes sobre morfo
hermano». Es un **guard compartido**; decisión pendiente (¿categoría nueva o
`KNOWN_MISSING_MORFO`?). No tocar en solitario.
### 0.4 · Sin verificar por ojos
La matriz **4 variantes × claro/oscuro × RTL**. Cubierto a 2026-08-04: vídeo
LTR/RTL, audio `card` en RTL, y los subtítulos en claro y oscuro. **Faltan
`row` / `bar` / `inline` y casi todo el modo oscuro.**
### 0.5 · Diferidos con disposición escrita (no son deuda oculta)
Doble-tap-seek · preview de miniatura en el scrubber —que es un **gap del
`Slider`**, no del player— · migrar los botones a `renderProps`, que es un
barrido global del catálogo.
### 0.6 · Ajeno al player, abierto por estas sesiones
- **El interruptor Sound del topbar no silencia los earcons** (§4.2). Es de la
app de demos, no del framework.
- `src/arts/adom/__scratch-verify.ts`: fichero de sonda que dejó un agente,
**sin trackear**, suma 1 error a `check`. Sobra; no se borró por la regla de
no eliminar sin instrucción explícita.
- Del hilo de dirección: `html lang` no sigue al idioma (el `dir` sí) y la deuda
ajena de `CONTINUE-direction.md` §8.4.
---
> El hilo de audio propiamente dicho sigue en
> [`CONTINUE-sound-engine.md`](./CONTINUE-sound-engine.md); este documento
> recoge lo que la **mirada del usuario** abrió el 2026-08-01 y quedó SIN cerrar.
## Lo que se cerró el 2026-08-01 (7 commits, verificados)
| Commit | Qué |
| ----------- | ------------------------------------------------------------------------------------------------------------------------------- |
| `df86a4e50` | El anillo del `commit` dejaba de enmarcar contenedores (slider + media-player) · saltar al final ya no anuncia compleción falsa |
| `e096f65e3` | MediaSession verificada por el usuario (carátula + título + artista en el hub de Chrome) |
| `905012507` | Waveform: el buffered vuelve a **banda recta** con acento lavado |
| `58acb778e` | Waveform F6 firmada — iniciativa COMPLETA |
| `77f8a4e46` | La demo del waveform carga solo la pista real; `sample` deja de fingir que es una prop |
| `4356020e3` | La carátula de `card` sale a token (`--audio-player-artwork-size-card`) |
| `3ceda5b59` | El botón de velocidad ya dice su velocidad (`emit: 'value'`) · el player reenvía `dir` a sus dos sliders |
## 1. ~~PRIORIDAD — el Slider está roto en RTL~~ · **CERRADO 2026-08-02**
Cerrado en `013ceac57` + `5469e05df` (rama `alpha-0.1-dir-prefs`). **La
hipótesis de este documento acertó las dos piezas visibles** — el Slider sí
estampaba `dir` y `slider.css` sí mezclaba lógicas con físicas — pero eran
síntomas, no la causa. Lo que resultó ser, medido:
| | |
| ------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **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`), el único físico que quedaba en todo eidos. |
| **La causa raíz, que no estaba en las hipótesis** | `makeDimension().get()` (`arts/prefs`) 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 → 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 (modelo de Zag, aplicado uniforme). |
**No hizo falta elegir «un solo dueño de la inversión»**: el JS sigue calculando
físico y el CSS sigue siendo lógico. No había doble volteo — había un transform
físico donde tocaba uno lógico.
✅ **La contradicción del pulgar del volumen queda explicada**: con `getDir()`
congelado, el mismo slider daba lecturas distintas según cuándo se midiera
respecto al montaje. No era una sonda mintiendo.
Guards que nacieron en rojo y ahora pasan:
`arts/prefs/test/active-prefs-reactivity.svelte.test.ts` (intent · derivación ·
granularidad por clave) y `uix/active-uix/test/prefs-view.svelte.test.ts`.
**De regalo**: elegir árabe voltea la dirección por derivación desde el idioma —
la cadena que `active-architecture.md:407` documenta y que en esta app era
código muerto, porque la shell fijaba intent desde el primer frame y no había
ningún idioma RTL en el catálogo.
⚠️ Sigue abierto de este eje: **`html lang` no sigue al idioma** (el `dir` sí), y
`perm:check` sin ejecutar — el topbar ya lleva `data-perm-step`, lo que hace que
**toda** ruta tenga paso y la pasada sea mucho más larga.
## 2 y 3. ~~Disposición de los botones + iconos de seek~~ · **CERRADAS 2026-08-04**
**Eran el mismo defecto visto desde dos lados.** Las sondas de aquel día
acertaban y §2 no era un defecto de disposición: re-medido con el contexto
verificado ANTES de cada número (`data-media` / `data-variant` / `data-dir`
leídos tras cada clic), la geometría mirroriza **exacta** en las cinco
disposiciones — vídeo con `DefaultControls` y audio en `card` · `row` · `bar` ·
`inline`. Cada `x` en RTL es el espejo del LTR ±1px, comprobado punto por punto.
Lo que el ojo rechazaba eran los **glifos**, que no se volteaban
(`transform: none`, `rotate: none`). En RTL el transporte se leía `▶▶ ▶ ◀◀`:
las dos flechas exteriores apuntando **HACIA DENTRO**. Es literalmente el
síntoma que `carousel.css:235` y §9.9 de [`CONTINUE-direction.md`](./CONTINUE-direction.md)
ya habían documentado y arreglado en los chevrons del carousel.
| | LTR (izq→der) | RTL antes | RTL ahora |
| ------------------ | ------------- | ------------------ | ----------------- |
| transporte (vídeo) | `▷ ◁◁ ▷▷` | `▷▷ ◁◁ ▷` ✗ dentro | `◁◁ ▷▷ ◁` ✓ fuera |
| transporte (audio) | `◁◁ ▷ ▷▷` | `▷▷ ▷ ◁◁` ✗ dentro | `◁◁ ◁ ▷▷` ✓ fuera |
**Alcance elegido por el usuario: todo lo direccional** — seek · play/pause ·
altavoz de volumen/mute · esquina del PiP. Fuera se quedan `fullscreen` (sus
cuatro esquinas son simétricas) y **`captions`, que es la única llamada que el
catálogo no ha hecho**: si el glifo de subtítulos cuenta como direccional está
sin decidir, y se dejó SIN voltear.
**El mecanismo**, heredado del precedente del carousel:
- `scale: -1 1` en el recipe, no `transform` — la propiedad independiente
COMPONE con lo que un icono escriba inline en vez de reemplazarlo.
- **`scale`, no `rotate: 180deg`**: sólo coinciden en glifos verticalmente
simétricos. Rotar el icono del PiP le llevaría el recuadro a la esquina
superior izquierda en vez de a la inferior izquierda.
- Anclado a `[data-media-player][data-dir='rtl']` — el atributo PROPIO del
componente, no el `dir` del DOM, así que no es el `[dir='rtl']` prohibido y
garantiza que el glifo coincida con la matemática del teclado, que resuelve de
esa misma fuente. `eidos-lint media-player`: **invalid 0 · class-hooks 0**.
De paso: el `--slider-range-bg` del scrubber era un `linear-gradient(90deg)`
FÍSICO, así que en RTL la rampa corría al revés respecto al sentido en el que
crece el rango. Par `:dir()` con `270deg`, como la rampa de saturación del
`color-picker`.
### 2.1 · El `dir` del player estaba PARTIDO POR LA MITAD — encontrado por el camino
`<MediaPlayer dir="rtl">` sin `dir` ambiental dejaba el cromo dispuesto en
**LTR** y sus dos Sliders en **RTL**. El wrapper pasaba la prop CRUDA
(`readableActive(() => dir)`) y el provider sólo la reenviaba a los sliders: no
resolvía la cadena ni estampaba nada. En la demo estaba **enmascarado** porque
el stage estampa `dir` además de pasar la prop — por eso ninguna medida anterior
lo vio.
⚠️ **El censo de canonización de `CONTINUE-direction.md` §9.15 no lo tenía**: el
media-player no es ni canónico, ni grupo A, ni grupo B. Era un **cuarto caso** —
expone `dir`, pero no lo resuelve NI lo estampa. Si alguien vuelve a auditar la
canonización, ese censo cuenta wrappers y providers, no componentes que
simplemente reenvían la prop a un hijo.
Canonizado con el patrón de §9.16: `activeDir(() => dir, soma)` en el wrapper ·
`resolvedDir` en el provider · `dir` crudo + `data-dir` resuelto en los props.
El tipo pasó de `dir?: Direction` a `dir: Direction | undefined` (la clave
siempre la pone el wrapper; sólo el VALOR puede faltar) — ensanchar el tipo
primero, que es lo que destapó los 11 puntos del test.
### 2.2 · El teclado seguía siendo físico
El morfo declaraba `ArrowLeft→seek-backward` / `ArrowRight→seek-forward`, ciego
a la dirección, mientras soma ya tiene `getDirectionalKeys(dir, orientation)`
justo para esto y el Slider embebido sí es direccional. **Decisión del usuario:
direccional.** Ahora resuelve por ese helper. `j` / `l` se quedan quietas: son
alias semánticos, no flechas.
⚠️ **CORRECCIÓN (2026-08-04, misma jornada)**: la tabla de abajo se leyó mal.
Que la raíz y el pulgar dieran el mismo ±10 **NO significaba que estuvieran de
acuerdo** — significaba que el `seekBy` del player **pisaba** el paso más
pequeño del Slider, porque ambos escriben `currentTime`. Las dos manos actuaban
sobre la misma tecla. Se destapó midiendo con magnitudes distintas (`ArrowUp`
sobre el pulgar del scrubber movía el tiempo **y** el volumen) y está arreglado:
el player exime ahora las teclas que nacen dentro de un `[data-slider]`.
**Lección**: dos manejadores que escriben la MISMA magnitud son
indistinguibles; para detectar doble manejo hay que cruzar ejes.
Medido, un solo `seekStep` por pulsación:
| | LTR | RTL |
| ----------------------------------------- | --- | ------- |
| `ArrowRight` (raíz / pulgar del scrubber) | +10 | **−10** |
| `ArrowLeft` (raíz / pulgar del scrubber) | −10 | **+10** |
### 2.3 · Verificación
`check` **74 = línea base** · `rtl:check` 1 (el de `palabras`, preexistente) ·
`morfo:check` 7 fallos, ninguno del player (preexistentes) ·
`eidos-lint media-player` invalid 0 · vitest del player + los dos guards de
dirección **16/16**. A ojo en el Chrome real del usuario, A/B completo en LTR y
RTL, vídeo y audio/card.
## 4. ~~Medición del escenario mixto~~ · **CERRADA 2026-08-04**
**La receta que este documento prescribía no hacía falta.** «Instrumentar antes
del boot + navegación client-side» era la respuesta a la pregunta equivocada. El
problema real es que se parcheaba el **CONSTRUCTOR**, que sólo puede responder
«¿se creó uno nuevo?». La solución es parchear el **PROTOTIPO**:
> Cualquier contexto —nacido cuando sea— tiene que llamar a `createGain` /
> `createOscillator` / `resume` para hacer algo. Recogiendo el `this` de esas
> llamadas en un `Set` se enumeran las INSTANCIAS, no las creaciones, y un
> contexto preexistente aparece igual. Sin inyección pre-boot y sin tocar
> `app.html`.
Con eso, medido en el Chrome del usuario sobre `/uix/components/media-player`,
audio `card`, pista real de 6:12 **sonando sin mutear a volumen 1**:
| | |
| ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
| Contextos distintos que trabajaron | **1** (`running`, 44,1 kHz) |
| Earcons disparados encima | **26** (52 osciladores + 78 gains + 26 biquads — el par de osciladores + biquad lowpass que describe `PLAN-sound-engine.md` §4) |
| `createMediaElementSource` | **0** — la media NUNCA entra en el grafo |
| Contextos cerrados / churn | 0 / 0 (contraste con el «antes» de §15.2: 10 creados, 9 cerrados) |
**La invariante de un solo `AudioContext` por documento se sostiene en el
escenario mixto.**
### 4.1 · Y el contexto preexistente, por fin fechado
Es real y la instrumentación vieja no podía verlo. Con la sonda instalada en
t=10,9s y el primer earcon a t≈30s, el reloj del contexto marcaba 25,86s → **el
contexto nació en t ≈ 4,1s, sin ningún gesto de usuario, y ya `running`**. Dos
señales independientes lo confirman: `constructed: 0` (el parche del constructor
no lo vio nacer) y **la primera aparición es `createOscillator`, no
`createGain`** — los tres `createGain` que `createContext()` hace de inmediato
para el master y los dos buses ya habían pasado.
⚠️ **Mi primera hipótesis —que lo creaba el registro de `sound.media(el)` del
player— es FALSA**, y el control la tumbó: en `/uix/components/button`, una
página **sin ningún elemento media**, el contexto nace en t ≈ 4,08s, lo mismo.
Es del arranque de la app, no del reproductor.
Auditoría de código en paralelo (29 agentes, cada afirmación refutada
adversarialmente) que respalda la lectura del navegador:
- Sitio de construcción **único**: `arts/sound/engine-sound.ts:452`, y el ctor se
busca en `globalThis` **en cada llamada, nunca cacheado a nivel de módulo** —
por eso `constructed: 0` significa de verdad «nació antes del parche» y no
«se coló por una referencia capturada».
- Embudo único `createContext()` con tres llamadores (`prime` ·
`getOrCreateContext` · `ensureContext`); el motor no toca Web Audio hasta uno
de ellos.
- El player **estructuralmente no puede** meter la media en el grafo: guarda el
handle tipado como `MediaProvider`, puerto que **no declara `attach()`**. Es
más fuerte que «es opt-in y nadie lo activa».
### 4.2 · Hallazgo NUEVO y ajeno: el interruptor de Sound del topbar no silencia
Salió de la auditoría y lo verifiqué midiendo. Con `data-sound="reduce"`
(interruptor OFF, `soundOn: false` persistido) un clic en un `Button` sigue
construyendo **2 osciladores + 3 gains**, y la envolvente es **idéntica**:
```
Sound ON [0, 0.25, 0.25, 0.0001]
Sound OFF [0, 0.25, 0.25, 0.0001] ← mismo pico, mismos nodos
```
El toggle escribe el intent en prefs y estampa `data-sound` en `<html>`, pero el
root de composición (`web/routes/uix/+layout@.svelte`) **no pasa `preferences`**
al canal de sonido de sema, que cae a nivel `'full'`. El framework sí lo
soporta (`sema/engine.ts` reenvía `soundOptions.preferences ?? opts.preferences`);
es la app de demos la que no lo cablea.
⚠️ **Alcance de lo que medí**: la envolvente del PROPIO earcon. No medí una
ganancia de bus aguas abajo, así que no puedo afirmar «se oye igual de fuerte»
— eso lo zanja el oído. Dos líneas independientes (medida + auditoría) apuntan a
que el interruptor es inerte. **Es de la app de demos, no del player**, y no se
tocó.
## 4.bis · Ajeno al eje, encontrado midiendo — la variante `bar` se estruja
A 720px de ancho y `size='md'`, la variante `bar` deja el `time-slider` en
**ancho 0** y el botón de mute en **19px** (los otros miden 36). Pasa **igual en
LTR y en RTL**, así que no es del eje de dirección: la fila no tiene sitio para
todo lo que mete. Sin tocar.
## 5. Iniciativas dormidas (cada una con su gate)
- ~~**`Captions`**~~ — **CERRADA 2026-08-04**. Se implementó tal como el
diagnóstico predecía: `mode='hidden'` + pintar `activeCues` en la parte
declarada. El espejo de una sola dirección de `captionsOn` también cerró: el
estado se **deriva** de los modos reales y una pista que el SO encienda se
adopta (degradada a `hidden`, para que no pinte encima). Detalle y las tres
trampas de especificación que impuso —borrado imperativo, siembra del primer
pintado, `activeCues` nulo en `disabled`— en el README de eidos, §Captions.
Decisión declarada: el marcado inline se conserva, la metadata de posición
**se descarta**.
- **Waveform como scrubber del player** — cero cables hoy entre ambos, y es
deliberado (D-AP2.4 + `PLAN-waveform.md` §0 lo aplazan a la v2 del player).
El nudo NO era cambiar el componente: era **de dónde salen los picos**. El
contrato del waveform (D-WF.2) dice que los provee la app, y un player
_streamea_, así que no puede decodificar su fuente sin bajársela entera.
Verificado además que `fetch` de `media.w3.org` y `soundhelix.com` **falla
por CORS** mientras el `<audio>` las reproduce sin problema.
✅ **NUDO RESUELTO — decisión del usuario, 2026-08-04.** Dos piezas:
1. **Los picos se pasan como PROP.** No se generan en el player ni se
regeneran solos: los provee quien los tenga (el backend que ya los calculó
al subir el fichero, un `.json` junto al audio, una decodificación previa).
Ratifica D-WF.2 y lo lleva a la API del player. Evita, además, el coste de
recalcularlos en cada montaje.
2. **Si la fuente se streamea, waveform GENÉRICO.** Sin picos que decodificar,
el player pinta una onda genérica y sigue sirviendo de scrubber. Se acabó
el bloqueo del CORS y el «bajarse la pista entera»: el caso sin datos tiene
ahora una respuesta declarada en vez de quedarse sin componente.
Queda por decidir al implementarlo (NO decidido): qué es exactamente
«genérico» — patrón fijo, pseudoaleatorio estable derivado de la fuente, o
barras planas — y si `peaks` ausente basta como señal de streaming o hace
falta una prop aparte.
## Lecciones de método del 2026-08-01 — leer antes de repetirlas
- **Medir el contrato NO es verificar la experiencia.** Se afirmó tres veces
que algo estaba verificado midiendo JS: la MediaSession (metadata poblada
≠ overlay del SO visible), el botón de velocidad (cambiaba `playbackRate`
perfectamente… y no lo mostraba) y el RTL (se comprobó LTR y se dio RTL por
bueno). Las tres las cazó el usuario.
- **El panel del navegador embebido NO SIRVE** para nada visual:
`innerWidth: 0`, `visibility: hidden`, el stage mide 2px. Ese día **inventó
tres «overflow» inexistentes** y un hueco de carátula que estaba bien.
Screenshot falla con «not compositing frames». Para cualquier juicio visual,
usar el **Chrome real del usuario** (`mcp__claude-in-chrome__*`).
- **Comprobar que el control se pulsó de verdad.** Dos medidas de esa sesión
midieron el modo vídeo creyendo que era audio porque el chip no cambió y
nadie lo verificó. Leer siempre `data-media` / `data-variant` después de
clicar.
- **La distancia RGB engaña**: avaló un gris que el ojo del usuario rechazó al
instante. Está dominada por la luminosidad y es ciega al croma.
- **La forma manda sobre el color**: el buffered del waveform se rechazó dos
veces; la segunda porque una copia de la onda **es** la onda y se camufla por
construcción, por muy distinto que sea su color.

Powered by TurnKey Linux.