uix(background): un fondo nunca suena, y no estaba resuelto sino evitado

D-BG.19, planteada por el autor: qué pasa con un `Background.Video` cuyo clip
trae pista de audio, y cómo se relaciona con el interruptor de sonido del
framework o con un «no sound» de la app. La respuesta que había en el código era
que no había respuesta. `muted` era una prop con default `true` y la razón
escrita al lado no era doctrinal sino mecánica —un clip sin mutear tiene el
autoplay rechazado en todos los navegadores—, de modo que el silencio era un
efecto secundario de la política del navegador y no una decisión de nadie.

Lo que eso significaba en cuanto alguien escribiera `muted={false}`: el clip
sonaba FUERA del grafo de audio. `sound.buses.content.setMuted(true)` no lo
silenciaba; no participaba del audio focus, así que hablaba por encima de un
`MediaPlayer` en vez de cederle el paso; no atenuaba el bus `ui` mientras sonaba;
no se proyectaba a MediaSession; y el slot `sound` de `$prefs` —el interruptor
«sin sonido» de la app, el que se proyecta al DOM como `data-sound`— no lo
alcanzaba nunca. Un `AudioContext` por documento es la razón de ser de ese art, y
el componente no tenía ni una referencia a él. De WCAG 1.4.2 se salvaba de
rebote: el control de pausa que el stack ya debía lo para, por carambola.

Así que `muted` deja de ser prop y se escribe `true` incondicionalmente: la API
ya no puede expresar un fondo audible. Tres razones, de más a menos vinculante.
La doctrina propia del componente —toda capa es `aria-hidden` y la decoración no
es contenido, así que un clip de aquí no puede portar significado que el audio
entregue; una capa audible sería contenido vestido de fondo—. WCAG 1.4.2: una
decoración no puede pedir un consentimiento que nadie le dio. Y la fuga de
`arts/sound`, que es la parte que hace de esto un asunto de framework y no de
gusto. Un clip que DEBE oírse es contenido: `MediaPlayer`, que ya es ciudadano de
`sound.media()` y obtiene focus, ducking y MediaSession gratis, o montarlo por
`Background.Layer` y registrarlo uno mismo.

La alternativa —hacerlo ciudadano con `sound.media(el, { focus: 'duck' })` cuando
el consumidor desmutea— queda rechazada por lo mismo que la hacía atractiva: abre
la puerta a que un fondo compita con el contenido real, y ese territorio es de
`Ambient` o de un reproductor.

Verificado en Chrome con la capa en `video`: `el.muted === true`, `paused: false`,
`currentTime` avanzando, `readyState: 4` —sigue reproduciéndose— y el control de
pausa presente. Queda anotado en el código un detalle que invita a un arreglo
equivocado: `hasAttribute('muted')` lee false mientras la propiedad es true,
porque Svelte fija `muted` como PROPIEDAD sin reflejarlo al atributo. No es un
agujero: la propiedad se escribe en el mismo efecto y dos líneas antes del
`play()`, y no hay nada que pueda colarse entre las dos.

Ningún consumidor pasaba `muted` —el `HeroSite` ya lo decía en prosa,
«DECORATIVE — muted»—, así que el cambio de API no rompe a nadie.

Gates: audit PASS 0/0 · vitest eidos 434/435 (el rojo es `skin-media-player`, el
de siempre) · `check` con los mismos 72 errores preexistentes y ninguno propio ·
prettier limpio.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
alpha-0.1-background
dev 2 months ago
parent 73f06a7882
commit c691a88c1a

@ -78,6 +78,12 @@ por la aritmética de ahora), el puntero entero, `attach='fixed'`, y RTL.
- **Paseo A–H registrado**: añadidas al README la excepción `A2.3` y la sección
`## Subset`; corregida una deriva de TRES sitios que llamaba `Toggle` al control
de pausa (compone `IconButton`, evento `contact-activate`).
- **D-BG.19 · un fondo NUNCA suena** (§7.undecies, firmada por el autor): `muted`
deja de ser prop y se escribe `true` siempre, así que la API no puede expresar un
fondo audible. No estaba resuelto, estaba evitado: con `muted={false}` el clip
sonaba FUERA de `arts/sound` — ni bus `content`, ni audio focus, ni duck del bus
`ui`, ni MediaSession, ni el slot `sound` de `$prefs`. Un clip que debe oírse es
contenido: `MediaPlayer`, que ya es ciudadano de `sound.media()`.
### 2. Lo que queda de tus firmas: DOS

@ -987,6 +987,49 @@ un screenshot (era el `write(0,0)` de un `pointerleave`), dar por muerto el
la precondición que necesitan (scrim encendido, capa que se mueva). **El
instrumento miente antes que el código**, y aquí mintió tres veces.
### 7.undecies D-BG.19 — un fondo NUNCA suena (2026-08-18)
**Planteada por el autor**: qué pasa con un `Background.Video` cuyo clip trae
pista de audio, y cómo se relaciona con el interruptor de sonido del framework o
con un «no sound» de la app. La respuesta, medida en el código, era que **no
estaba resuelto: estaba evitado**. `muted` era una prop con default `true`, y la
razón escrita no era doctrinal sino mecánica («un clip sin mutear tiene el
autoplay rechazado en todos los navegadores»). Nada conectaba el vídeo con
`arts/sound`: cero referencias en todo el componente.
**Lo que eso significaba con `muted={false}`**: el clip sonaba FUERA del grafo.
`sound.buses.content.setMuted(true)` no lo silenciaba; no participaba del audio
focus, así que hablaba por encima de un `MediaPlayer` en vez de cederle el paso;
no atenuaba el bus `ui`; no se proyectaba a MediaSession; y el slot `sound` de
`` —el interruptor «sin sonido» de la app, proyectado como `data-sound`—
no lo alcanzaba. Un `AudioContext` por documento es la razón de ser de ese art, y
un fondo cantando por fuera es exactamente la fuga que existe para impedir.
De WCAG 1.4.2 se salvaba de rebote: el control de pausa lo para, por carambola.
**FIRMADA (a): el fondo nunca suena.** `muted` deja de ser prop y se escribe
`true` incondicionalmente, así que **la API no puede expresar un fondo audible**.
Tres razones, de más a menos vinculante: la doctrina propia del componente (toda
capa es `aria-hidden`, la decoración no es contenido, así que un clip de aquí no
puede portar significado que el audio entregue); WCAG 1.4.2 (una decoración no
puede pedir un consentimiento que nadie le dio); y la fuga de `arts/sound` de
arriba, que es la parte de framework. Un clip que DEBE oírse es contenido:
`MediaPlayer`, que ya es ciudadano de `sound.media()`, o montarlo por
`Background.Layer` y registrarlo uno mismo.
(b) rechazada —hacerlo ciudadano con `sound.media(el, { focus: 'duck' })` cuando
`muted === false`— por más potente y por eso mismo: abre la puerta a que un fondo
compita con el contenido real, y ese territorio es de `Ambient` o de un reproductor.
**Verificado en Chrome**: con la capa en `video`, `el.muted === true`,
`paused: false`, `currentTime` avanzando y `readyState: 4` — sigue reproduciéndose
—, y el control de pausa presente. ⚠️ Anotado en el código: `hasAttribute('muted')`
lee **false** mientras la propiedad es true, porque Svelte lo fija como PROPIEDAD
sin reflejarlo; no es un agujero, porque la propiedad se escribe en el mismo
efecto y dos líneas antes del `play()`. Ningún consumidor pasaba `muted` (el
`HeroSite` ya lo decía en prosa: «DECORATIVE — muted»), así que el cambio de API
no rompe a nadie. Gates: audit PASS 0/0 · vitest eidos 434/435 (el de siempre) ·
`check` sin errores propios · prettier limpio.
---
## 8. Riesgos y cómo se acotan

@ -139,6 +139,30 @@ The element is driven by `play()` / `pause()` rather than the `autoplay`
attribute, because four of the five are runtime state that flips both ways and
the attribute is a one-shot at parse time.
**And a sixth rule that is a law, not a policy: a background never sounds.**
`muted` is not a prop — it is written `true` unconditionally, so the API cannot
express an audible background. Three reasons, in order of how much they bind:
- **The component's own doctrine.** Every layer is `aria-hidden` and decoration is
never content, so a clip here cannot be carrying meaning that audio delivers. An
audible layer would be content wearing a background's clothes.
- **WCAG 1.4.2.** Audio that starts on its own and lasts more than three seconds
owes the reader a way to stop it. The pause control happens to provide one, but
a decoration should not be asking for consent it was never given in the first
place.
- **It would leak past `arts/sound`, and that is the framework part.** An audible
`<video>` here plays OUTSIDE the audio graph: `sound.buses.content.setMuted(true)`
would not silence it, it would not take part in audio focus (so it would talk
over a `MediaPlayer` instead of ducking for it), it would not attenuate the `ui`
bus while it plays, and the `sound` slot of `$prefs` — the app's own "no sound"
switch, projected as `data-sound` — would never reach it. One `AudioContext` per
document is the whole point of that art; a background singing past it is the
exact leak it exists to prevent.
A clip that MUST be heard is content: compose `MediaPlayer`, which is a citizen of
`sound.media()` and gets focus, ducking and MediaSession for free — or mount your
own element through `Background.Layer` and register it yourself.
## The pause control (WCAG 2.2.2)
Content that starts on its own, lasts more than five seconds and sits beside

@ -27,6 +27,27 @@
* state that flips both ways. `play()` / `pause()` from an effect is the only
* shape that can follow them.
*
* And a sixth rule that is not a policy but a LAW: **a background never
* sounds**. `muted` is not a prop — it is written `true` unconditionally, so
* the API cannot express an audible background. Three reasons, and the first
* is the component's own doctrine: every layer is `aria-hidden` and
* decoration is never content, so a clip here cannot be carrying meaning that
* audio would deliver. Second, WCAG 1.4.2: audio that starts by itself and
* lasts more than three seconds owes the reader a way to stop it, and a
* decoration cannot ask for consent it was never given. Third, and the one
* that makes it a framework matter rather than a taste: an audible element
* here would play OUTSIDE `arts/sound` — it would not pass through the
* `content` bus, so `sound.buses.content.setMuted(true)` would not silence
* it; it would not take part in audio focus, so it would talk over a
* `MediaPlayer` instead of ducking; it would not attenuate the `ui` bus, and
* `$prefs`'s `sound` slot would not reach it. One `AudioContext` per document
* is the whole point of the art, and a background singing past it is exactly
* the leak the art exists to prevent.
*
* A clip that MUST be heard is content, not background: compose
* `MediaPlayer` (which is a citizen of `sound.media()`), or mount it through
* `Background.Layer` and register it yourself.
*
* There is no state contract for `<video>` in the framework (`ImageProvider`
* covers `<img>` only, and ScrollFrames listens by hand). With TWO consumers
* this is a candidate for a shared `soma/layers` port — flagged, not invented
@ -44,7 +65,6 @@
sources,
poster,
loop = true,
muted = true,
playsinline = true,
preload = 'metadata',
...restProps
@ -116,10 +136,17 @@
$effect(() => {
const el = video;
if (!el) return;
// Set as a PROPERTY, not left to the attribute: an unmuted clip has its
// autoplay refused by every browser, and the attribute alone is not a
// reliable way to reach the property after hydration.
el.muted = muted;
// Set as a PROPERTY and not left to the attribute: the attribute alone is
// not a reliable way to reach the property after hydration, and this one
// is a law rather than a default (see the header) — a background never
// sounds. It is also what keeps autoplay from being refused.
//
// Measured in Chrome: `hasAttribute('muted')` reads FALSE while `el.muted`
// is true, because Svelte sets `muted` as a property rather than reflecting
// it. That is not a hole to patch — the property is what the browser plays
// by, and it is written HERE, before the `play()` two lines below, in the
// same effect. Nothing can slip between them.
el.muted = true;
if (wanted) {
// A refused autoplay is the browser's policy, not a failure of the clip:
// the poster stays and nothing is reported.
@ -144,7 +171,7 @@
{#if !reducedData && status !== 'error'}
<!-- svelte-ignore a11y_media_has_caption -- decoration inside an
`aria-hidden` layer: a caption track would name what the tree hides. -->
<video bind:this={video} src={resolvedSrc} {poster} {loop} {muted} {playsinline} {preload}
<video bind:this={video} src={resolvedSrc} {poster} {loop} muted {playsinline} {preload}
></video>
{:else if poster}
<img src={poster} alt="" />

@ -256,8 +256,6 @@ export type BackgroundVideoProps = Omit<HTMLAttributes<HTMLDivElement>, 'childre
poster?: string;
/** @default true */
loop?: boolean;
/** @default true — an unmuted clip has its autoplay refused everywhere. */
muted?: boolean;
/** @default true — iOS plays inline instead of taking over the screen. */
playsinline?: boolean;
/** @default 'metadata' */

Loading…
Cancel
Save

Powered by TurnKey Linux.