uix(background): el media entra en la pila, y el fondo aprende a pararse

F2 de PLAN-background.md. Entran las tres piezas que le faltaban al anfitrión:
`Image` (compone el `<Image>` canónico, una fuente por modo de tema, `priority`
para el caso LCP), `Video` (con las cinco políticas que deciden si suena una
sola trama) y `Pause`, el control que WCAG 2.2.2 le debe al lector cuando algo
se mueve solo.

Ninguna de las cinco políticas del vídeo es del consumidor: fuera de vista,
pestaña oculta, movimiento reducido, datos reducidos y la pausa. El elemento se
gobierna con `play()`/`pause()` y no con `autoplay` porque cuatro de las cinco
son estado que va y vuelve, y el atributo es un disparo único al parsear.

El hero se queda SIN CSS. Su layout `background` era tres capas `Box` a mano, un
scrim inline y una hoja con marcador `/* justified: */` para el cover-fit del
media; las tres cosas son ya canon, así que el bloque pierde su única excepción
D-BLK.2. También pierde el `data-on` manual: `<Background on="dark">` lo estampa
en la pila y el gemelo de foundation se lo da al anfitrión — medido, el título y
la bajada resuelven `rgb(255,255,255)` sin que el bloque lo recuerde.

Tres decisiones que la ejecución obligó a tomar, todas firmadas:

- **D-BG.17 — el control explícito viaja por un snippet.** La pila es
  `z-index: -1` con `pointer-events: none` y su propio contexto de apilamiento:
  cualquier control escrito DENTRO pinta detrás del contenido y deja de ser un
  control, pero registrarse en el contexto exige ser descendiente. El snippet
  `pause` rompe el nudo con el único mecanismo que ya tenía precedente
  (`Toggle.icon`, `Image.fallback`), y suministrarlo suprime el default igual
  que `Switch` elige entre su snippet y su propio thumb.
- **D-BG.18 — la etiqueta CAMBIA y no hay `aria-pressed`.** El APG ofrece dos
  formas de nombrar un control de dos estados y hacer las dos anuncia el estado
  por partida doble, en dos lecturas que se contradicen. Es la forma que
  `MediaPlayer.PlayButton` ya usa para el mismo acto.
- **D-BG.15 completa.** Un fondo no reporta su fallo: se aparta y la capa de
  debajo ES la composición. Pero si la capa todavía tiene algo que enseñar —un
  snippet `error` del app, o el póster de un vídeo— la capa SE QUEDA
  (`data-has-fallback`). Sin eso los snippets eran inalcanzables por
  construcción, y un `<video>` fallido se llevaba por delante el póster que la
  decisión promete conservar.

Y tres defectos que sólo aparecieron al medirlos en Chrome:

- **`effect_update_depth_exceeded`**: una capa que se registraba desde un
  `$effect` escribía el contador del padre en fase de efectos y el flush no
  cerraba. No era ruido — mataba el efecto raíz: el botón se pintaba y todo clic
  posterior en la superficie se ignoraba en silencio. La cura es la ley que el
  framework ya tenía escrita, A30 (`anchor-nav-provider.svelte.ts`): registrar
  desde el init, nunca desde un efecto reactivo. Bisecado: ni `untrack` solo ni
  mover la lectura a otro componente lo arreglaban.
- **El control caía 18px fuera del anfitrión**: el `<Button>` compuesto declara
  `position: relative` en `[data-button]`, misma especificidad que un
  `[data-background-pause]` pelado, así que decidía el orden de hojas del
  bundler. Fijado a dos atributos.
- **Un `<img>` pelado no se estiraba** dentro de una capa. La capa es ahora una
  rejilla de una celda —así cualquier hijo la llena sin que la receta escriba
  tamaños sobre contenido ajeno— y el media se dimensiona aparte, porque
  `stretch` no aplica a elementos reemplazados.

Medido en Chrome real: el control por defecto y el de snippet alternan etiqueta,
escriben `data-paused` y congelan de verdad la deriva; sin capa que se mueva no
hay control; una imagen rota oculta su capa y el patrón de debajo sigue
pintando; un vídeo roto conserva su póster; `Surface`, `<Image>` compuesto,
`<img>` pelado y el backdrop real del hero llenan su capa.

Queda por verificar con Chrome visible: el vídeo REPRODUCIÉNDOSE y las
políticas de movimiento/datos reducidos. En un panel oculto el IntersectionObserver
está suspendido y las media features no se pueden emular, así que las políticas
1 y 2 se comprobaron RETENIÉNDOLO y las otras no se dan por probadas.

Dos decisiones del autor quedan abiertas en el README (§Gaps): la escala
`strength` del scrim no está ordenada por peso (`subtle` 0.80 vela más que
`overlay` 0.65, y `overlay` ≡ `muted`), y sobre una foto clara ningún peso llega
a AA — 2.10:1 el default, 4.42:1 el más fuerte.

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

@ -415,7 +415,7 @@ defecto; el presupuesto de escenas lo pone `$scene`; `attach='fixed'` con
| **D-BG.1** | Nombre y destino de `Backdrop` | (a) nuevo `Background` compound que ABSORBE Backdrop → `Background.Pattern`, hero migra, `backdrop/` se BORRA (sin shim); (b) crecer `Backdrop` como compound; (c) dos componentes | **FIRMADA 2026-08-17 — (a), nombre `Background`** (no `RichBackground`: los nombres del catálogo dicen QUÉ es la pieza, nunca cómo de buena es; ergonomía de `Background.Video` / `data-background` / `--background-*`; las referencias usan el sustantivo). Motivo: «Backdrop» colisiona con el veil modal (MUI/Vuetify y el archetype `overlay` = «modal/dim backdrop»); Backdrop tiene 1 consumidor, sin README ni demo (C8 de PLAN-blocks-quality). El borrado de `backdrop/` + su morfo sigue exigiendo tu orden explícita en F1 |
| **D-BG.2** | Tier y alcance | canon eidos-native pasivo (host + capas + a11y + tokens); efectos animados siguen en el pack; `Background.Layer` = ranura; SIN `Background.Scene` en canon | **FIRMADA 2026-08-17** — canon eidos-native pasivo (`scope: ['eidos']`, partes `provider`/`layer`/`pause`, 0 eventos, sin soma ni pack sema; la pausa la emite el `Toggle` compuesto); efectos animados = pack `Ambient`; `Background.Layer` = ranura; `Background.Scene` en canon RECHAZADO. Si una capa necesitara un evento propio algún día: leer `architecture/sema.md` y replantear, no improvisar |
| **D-BG.3** | Mecanismo de parallax | (A) local al recipe: `animation-timeline: view()` + keyframes `/* functional */` + fallback JS por ActiveDom; (B) eje `timeline` en el registro de presets de `$motion`/eidos (extensión de contrato) | **FIRMADA 2026-08-17 — (A)**: CSS scroll-driven primero (`animation-timeline: view()` + `animation-range`, keyframes locales anotados `/* functional: scroll-linked travel, not an event signature */`), fallback bajo `@supports not (animation-timeline: view())` con `ScrollProgress` (D-BG.7) escribiendo `--background-progress` vía `dom.writeProperty` dentro de `dom.raf`; reduce → sin travel; token `--background-parallax-travel` (config data). Sin cambios en `$motion` ni en el registro de presets; nota en `MOTION_SERVICE_RFC` (candidato «dominio scroll», ≥2 consumidores) y en `motion-guide.md` §8 |
| **D-BG.4** | Control de pausa | (a) `Background.Pause` se renderiza POR DEFECTO cuando hay capa autoplay/animada; componerlo explícito lo reubica y suprime el default (precedente: thumb por defecto de `Switch`); (b) sólo por composición (N-7 estricto) + warning dev si autoplay sin control | **FIRMADA 2026-08-17 — (a)**: default cuando hay capa que se mueve sola > 5 s (vídeo autoplay, `Gradient animate`, escena del pack que lo declare por contexto); `Toggle` compuesto (`pressed` ↔ `paused`, textos `pause`/`play` por langs, `aria-pressed`, teclado) como segundo nodo raíz sobre el contenido, `top-end` (`LogicalPosition`); una instancia compuesta explícitamente por el app se registra en el contexto y suprime el default; reduced-motion NO sustituye al control. Sin `*Button` boolean props |
| **D-BG.4** | Control de pausa | (a) `Background.Pause` se renderiza POR DEFECTO cuando hay capa autoplay/animada; componerlo explícito lo reubica y suprime el default (precedente: thumb por defecto de `Switch`); (b) sólo por composición (N-7 estricto) + warning dev si autoplay sin control | **FIRMADA 2026-08-17 — (a)**: default cuando hay capa que se mueve sola > 5 s (vídeo autoplay, `Gradient animate`, escena del pack que lo declare por contexto); `Toggle` compuesto (`pressed` ↔ `paused`, textos `pause`/`play` por langs, `aria-pressed`, teclado) como segundo nodo raíz sobre el contenido, `top-end` (`LogicalPosition`); una instancia compuesta explícitamente por el app se registra en el contexto y suprime el default; reduced-motion NO sustituye al control. Sin `*Button` boolean props. **Enmendada por D-BG.18** (la forma es `IconButton` + `aria-label` que cambia, sin `aria-pressed`) y **por D-BG.17** (la reubicación explícita viaja por el snippet `pause`, no por un hijo dentro de la pila) |
| **D-BG.5** | Media según modo | `sources={{ dark }}` resuelto por `eidos.getThemeContext()` (reactivo, como el re-tintado de Ambient), NO `prefers-color-scheme` (el modo del framework no es el del SO) | **FIRMADA 2026-08-17** — `sources={{ light?, dark? }}` en `Background.Image`/`Background.Video`, `src` derivado en el wrapper por `getThemeContext().mode` (reactivo, sin remount; cae a `src` si falta la clave del modo activo). Motivo: `mode` es un source visual de `ActiveEidos` (toggle del app / pref persistida / `data-mode` local) que puede divergir del SO; `<picture media="(prefers-color-scheme)">` sólo ve el SO. Alternativa CSS (dos `<img>` + `[data-mode]`) descartada: doble descarga y DOM |
| **D-BG.6** | `forced-colors` | ocultar todas las capas decorativas (contenido sobre `Canvas`) | **FIRMADA 2026-08-17** — `@media (forced-colors: active) { [data-background-layer] { display: none } }`; el `Toggle` de pausa sigue visible (control, no decoración); se mantiene el `prefers-contrast: more` heredado de Backdrop (glow/mesh fuera, patrones estructurales se quedan); nunca `forced-color-adjust: none`. Motivo: los `url()` (imagen, vídeo, ruido data-URI) sobreviven al UA y el scrim (`background-color`) lo pinta el UA como `Canvas`, así que no protege |
| **D-BG.15** | **Fallo de carga de imagen / vídeo** — planteada por el autor 2026-08-17 («¿lo resolvía Image con su fallback?») | (a) **la pila ES el fallback**: las capas de media son transparentes hasta cargar y se ocultan al fallar; lo que haya debajo (padre, `Pattern`, `Gradient`, `Scrim`) se ve; `Background.Image` compone `<Image>` y hereda su ciclo `idle/loading/loaded/error` (`data-status`, `Fallback`/`Error` como snippets passthrough) con defaults de FONDO: `placeholder='none'` (nada de skeleton a sangre), sin icono de error (decorativo → capa transparente); `Background.Video`: `poster` nativo + estados propios (`loadeddata`/`error`/`stalled`/fuente no soportada → `data-status='error'` en la capa → el vídeo se oculta y queda el poster o la capa de debajo); (b) API de fallback propia (`fallback` snippet por capa) | **FIRMADA 2026-08-17 — (a)**: un fondo no muestra errores: degrada a lo que tiene debajo, y el orden de capas ya expresa «primero lo barato, encima el media». `Background.Image` = `<Image>` con `placeholder='none'`, sin icono de error, `Fallback`/`Error` passthrough. `Background.Video` = `poster` + `data-status` propio (`loadeddata`/`error`/`stalled`/no soportado → capa oculta). Para el vídeo NO existe contrato de estado en el framework (`ImageProvider` sólo cubre `<img>`; ScrollFrames escucha `loadedmetadata` a mano): con DOS consumidores (`ScrollFrames` + `Background.Video`) es candidato a capa compartida `soma/layers` («compose existing; flag gaps») — se evalúa en F2 y, si se extrae, entra por su propia decisión; en v1 `Background.Video` lo hace localmente vía `dom.listen` como ScrollFrames. Demo: una imagen rota y un vídeo roto |
@ -427,6 +427,8 @@ defecto; el presupuesto de escenas lo pone `$scene`; `attach='fixed'` con
| **D-BG.12** | ~~Uso dentro de CUALQUIER componente — dos modos (envoltorio + `fill`)~~ | — | **SUPERADA el mismo día por D-BG.14** (un solo modo: hijo). Sobreviven sus tres consecuencias como alcance/gate de F1: (1) herencia de radio y **shape** dentro de superficies redondeadas/anidadas (verificar Card `rounded` × shape `continuous` en Chrome real); (2) el **bug medido** en el hero (`Box flex/grow` no crece un hijo flex, README hero 2026-07-23) lo destapa el caso columna → se verifica y, si sigue, se arregla en `Box` ANTES (canon, no en Background); (3) **coste en partes repetidas** (celdas/items/cards de una rejilla): patrones/gradientes/scrim sí; vídeo/escenas NO (una por sección; el presupuesto de `$scene` avisa) — README y demo lo dicen |
| **D-BG.13** | **Cómo llega el fondo a un componente que lo renderiza desde dentro** (hero, y cualquier otro) — planteada por el autor 2026-08-17 (`<Hero background={struct}>`) | (a) **snippet** `background` en el componente; el app compone `<Background>…</Background>` dentro y el componente lo coloca (el hero ya tiene `backdrop` → se renombra); (b) prop `background={struct}` (árbol de datos que describe capas) | **FIRMADA 2026-08-17 — (a)**: snippet `background` (hero: `backdrop` → `background` en F5); ninguna API nueva. (b) rechazada: contradice la regla compositional-not-data-driven fijada por el autor (OnionMenu 2026-06-21: «los hijos son componentes reales, nunca `root={tree}`/`items={[...]}`»), B-5 de blocks y la regla 6 de eidos; además un struct no puede nombrar un efecto del pack sin canon→pack, y cada componente tendría que poseer su mapeo struct→capas |
| **D-BG.16** | **Enmienda medida de la regla de anfitrión (D-BG.14)** — destapada en F1, 2026-08-17 | (a) la regla escribe también `--box-position: relative` (resuelve dentro del mecanismo de Box sin subir especificidad; un `position` por prop, inline, sigue ganando; `Dialog.Content` conserva su `fixed` — medido); (b) subir la especificidad de la regla (rompe `Dialog.Content`/`Affix`); (c) que Box deje de usar `revert-layer` para `position` (cambio de Box con radio catálogo) | **FIRMADA 2026-08-17 — (a)**: la regla queda `:where(:has(> [data-background])) { --box-position: relative; position: relative; isolation: isolate }`. Medido antes/después en Chrome: sin la var, Section y Box-columna computaban `static` y la pila se escapaba; con ella, 5/5 anfitriones cubiertos y `Dialog.Content` conserva su `fixed`. El acoplamiento foundation → token PÚBLICO de Box es el precio declarado (precedente estructural: la foundation ya escribe `--motion-stagger-index` sobre `[data-stagger] > *`); `:where` se mantiene, así que nada sube de especificidad |
| **D-BG.17** | **Dónde vive el control de pausa que el app compone** — destapada en F2, 2026-08-17 | (a) hijo registrador: `<Background.Pause>` dentro de la pila no pinta ahí, registra sus props y `<Background>` lo coloca; (b) **snippet** `pause` en `<Background>`, pintado como segundo nodo raíz; (c) hermano de `<Background>` con `bind:pressed` | **FIRMADA 2026-08-17 — (b)**. La pila es `z-index: -1` + `pointer-events: none` y forma su propio contexto de apilamiento: cualquier control escrito DENTRO pinta detrás del contenido del anfitrión y deja de ser un control — pero registrarse en el contexto exige ser descendiente. El snippet rompe el nudo con el único mecanismo que ya tiene precedente canónico (`Toggle.icon`, `Image.fallback`, y el propio D-BG.13): `<Background pause={…}>` recibe `{ paused, toggle }` y `<Background>` lo pinta donde la geometría funciona. Suministrar el snippet ES la composición explícita, así que suprime el default por la misma forma que `Switch` elige entre `bodyContent` y su `<SwitchThumb>` — sin prop booleana, que D-BG.4 prohíbe. (a) rechazada: inventa un mecanismo nuevo (un hijo que se re-parentiza) para un problema que un snippet resuelve; (c) rechazada: obliga al app a cablear el booleano y a una prop booleana para suprimir |
| **D-BG.18** | **Enmienda de D-BG.4: la FORMA del control de pausa** — destapada al comparar con el estado del arte, 2026-08-17 | (a) `IconButton` con `aria-label` que cambia play↔pause y SIN `aria-pressed` (el precedente propio: `MediaPlayer.PlayButton`); (b) `Toggle` con `aria-pressed` y etiqueta fija; (c) lo que decía D-BG.4: `Toggle` + `aria-pressed` + etiquetas que cambian | **FIRMADA 2026-08-17 — (a)**. (c) mezcla los dos patrones que el APG separa: etiqueta FIJA con `aria-pressed`, **o** etiqueta que CAMBIA sin él — nunca ambos, porque el lector anuncia dos veces el estado y se contradicen. El framework ya eligió en `MediaPlayer.PlayButton` (`IconButton` ghost, `aria-label` derivado del estado, sin `aria-pressed`), y coherencia C0–C10 manda que dos controles del mismo acto se comporten igual. Es además lo que hacen las dos referencias con equipo de accesibilidad (Apple y Microsoft ponen un botón persistente de pausa/reproducir en la esquina de cada hero animado; ninguna librería de componentes trae fondo de vídeo, y las colecciones copy-paste que sí — shadcn.io, `next-video` — lo sirven `autoplay` SIN control). Consecuencias: el morfo describe la parte como botón compuesto, no como Toggle; el control es persistente (jamás sólo-hover) y va ANTES del contenido en orden de DOM, que es el orden que el APG fija para el control de rotación de un carrusel |
| **D-BG.14** | **Colocación: hijo, sin `Box`** — decidido en conversación 2026-08-17 (autor: «componer una capa Box trastoca todo»; «¿no puede renderizar desde el padre?») | (a) `Background` = la PILA como hijo del padre; el padre se vuelve anfitrión por la regla de foundation `:where(:has(> [data-background])) { position: relative; isolation: isolate }` (+ gemelo `data-on`); pausa como segundo nodo raíz; cero props en `Box`; (b) igual pero SIN regla `:has` (el consumidor posiciona/aísla el padre a mano, estilo Mantine `Overlay`); (c) `Background` compone `Box` (el plan original) | **FIRMADA 2026-08-17 — (a)**: la pila como hijo (`absolute; inset:0; z:-1; border-radius: inherit; overflow: clip; pointer-events: none`); adopción del anfitrión por regla de FOUNDATION en el generador (`:where(:has(> [data-background])) { position: relative; isolation: isolate }`) + gemelo `data-on` como segundo selector del bloque D12 (test en `generated-css`); pausa = segundo nodo raíz; `Background` sin `BoxProps` ni `rounded`; **cero props nuevas en `Box`**. **Enmendada por D-BG.16** (la regla escribe además `--box-position: relative`; medido). `display: contents` = no-anfitrión (documentado). Verificación F1 en Chrome real: Section · Box-columna · Card rounded×shape · Dialog.Content. (b) rechazada: footgun «las capas desaparecen»; (c) rechazada: duplica la API de Box y contradice «one axis, one primitive» |
---
@ -537,6 +539,58 @@ Fuera de desviación, anotado: `[data-background-layer] > :where(img, video)`
(cover-fit del media del app) entró en F1 sin estar en su fila — es el receptor
del `<style>` justificado del hero y F2 lo consume; se acepta y se registra.
### 7.quater F2 ejecutada (2026-08-17) — medido en Chrome real
Entregado: `Background.Image` (compone `<Image>`, `sources` por modo, `priority`,
sin `alt` porque la capa es `aria-hidden`), `Background.Video` (las cinco
políticas), `Background.Pause` (D-BG.18), el snippet `pause` (D-BG.17), la
receta y sus dos tokens, y el layout `background` del hero refactorizado — el
bloque **se queda sin `<style>`**: su única excepción D-BLK.2 (el cover-fit) es
ya canon, y el `data-on` manual sobra porque el gemelo de foundation se lo da al
anfitrión (medido: título y bajada resuelven `rgb(255,255,255)` sin él).
**Tres defectos destapados por la verificación, los tres arreglados:**
1. **`effect_update_depth_exceeded`** — una capa que se registraba desde un
`$effect` escribía el contador del padre en fase de efectos, invalidaba al
hermano que lo lee y el flush no cerraba. No sólo hacía ruido: **mataba el
efecto raíz**, así que el botón se pintaba y todo clic posterior en la
superficie se ignoraba en silencio. Arreglado aplicando la ley que el propio
framework ya tenía escrita, **A30** (`anchor-nav-provider.svelte.ts:204`:
«register with the parent from the constructor, not a reactive effect»), más
`untrack` en la mutación y la decisión leída desde un componente aparte.
2. **El control de pausa computaba `position: relative`** y caía 18px fuera del
anfitrión: el `<Button>` compuesto declara `position: relative` en
`[data-button]`, misma (0,1,0), así que decidía el orden de hojas del
bundler. Clavado con `[data-background-pause][data-placement]` — (0,2,0), sin
`!important` ni envoltorio. Medido después: `absolute`, 12px/12px, dentro.
3. **Mi `size='sm'` inventado** en el control: retirado, se queda el del canon
(`md`, 36×36), que es el par que compone `MediaPlayer.PlayButton`.
**Comprobado en navegador**: control por defecto y de snippet alternan etiqueta
ES pausa↔reanudar, escriben `data-paused` y **congelan de verdad** la deriva
(`animation-play-state: paused`) · sin capa que se mueva no hay control · una
imagen rota oculta su capa (`data-status='error'`) y el patrón de debajo sigue
pintando · la buena carga con `object-fit: cover` a 192px · el control es
`<button type="button">`, enfocable, fuera de todo `aria-hidden` · el anfitrión
del hero es `relative` y la tinta sale a `rgb(255,255,255)`.
**DOS decisiones tuyas, medidas, sin tocar** (README §Gaps):
- **La escala de `strength` del scrim no está ordenada por peso**: `ghost` 0.30 ·
`scrim` 0.45 · `overlay` 0.65 · `muted` **0.65 (duplicado)** · `subtle`
**0.80 — el más pesado de todos, y su nombre dice lo contrario**. La causa es
que esos tokens nombran cuán opaco es un ELEMENTO, no cuánto vela un scrim.
Renombrar o renumerar es cambio de API, no arreglo de pasada.
- **Sobre una foto CLARA ningún peso llega a AA**: 2.10:1 el default, 4.42:1 el
más fuerte, con texto blanco. O entra un peso por encima de `--opacity-subtle`,
o se doctrina que la respuesta es el scrim graduado.
Gates: audit `--only background` **PASS 0/0** · eidos-lint invalid 0 ·
`blocks:check` 0/15 · `rtl:check` 0/180 · 441/442 en eidos+morfo (el fallo es el
`skin-media-player` de siempre) · `check` 0 errores propios · prettier limpio.
Sin commitear: falta tu orden.
### F1.5 — correcciones de supervisión (para el agente, ANTES del commit de F1)
| # | Qué | Cómo (exacto) | Gate |

@ -24,7 +24,7 @@ renders) while the app owns every word.
| `form` | `Container` (`sm`) + `Motion` | the sign-up: the app composes `Form` + `Field` + `Form.Submit` whole. The block gives it the CTA's place and a narrower measure — nothing else |
| `actions` | `Group` | the app drops `Button`s / `Link`s |
| `media` | — (app: `Image` / `AspectRatio` / `Surface`) | second column (split) or below (center) |
| `backdrop` | positioned `Box` + scrim + cover `<style>` | `background` layout only: full-bleed behind the copy, `--color-overlay` scrim at `--opacity-scrim`, media cover-fit |
| `backdrop` | `Background.Layer` + `Background.Scrim` | `background` layout only: full-bleed behind the copy. The veil and the media's cover-fit are canon since 2026-08-17 — the block owns no CSS at all now |
**Landmark + heading**: one `<section>` named by its title. The title is an
`<h{level}>` (default `h1`) — a single `h1` per page is the app's call: it drops
@ -49,11 +49,11 @@ Flowbite «Hero 18» · Untitled UI «Hero 44»):
`background` (cover) layout: the app's `<img>`/`<video>` full-bleed behind the
copy, with the contrast scrim and on-solid ink the block owns. Each layout
earns its place by discriminating the arrangement, not for variety.
- **The scrim and cover-fit are the only styling the block owns**: the scrim is
`--color-overlay` at `--opacity-scrim` (the same tokens the modal overlay
uses); the `object-fit: cover` on the app's slotted media lives in a scoped
`<style>` (a `/* justified: */` marker, D-BLK.2) because no component prop can
set it on content the block does not own. No hand-picked colour.
- **The scrim and cover-fit were the only styling the block owned** — and since
2026-08-17 it owns neither: both are `Background`'s (`Background.Scrim` paints
the same `--color-overlay` × `--opacity-scrim`, and the layer cover-fits the
app's slotted media). The scoped stylesheet and its `/* justified: */` marker
are gone, so **the block no longer needs the D-BLK.2 exception**.
- **Discarded**: the references' static HTML dumps of every arrangement. A
different arrangement is the app's markup inside the snippets, not a variant
prop.

@ -64,26 +64,22 @@
hand-written delays and no numbers in this file.
-->
<!--
Under `background` the ink is set ONCE, on the cluster, not piece by piece:
`data-on='dark'` is the foundation's inversion context (it redefines
`--color-content-*` to the on-solid scale), and the `color` declaration is
what anything inheriting picks up — a `Link variant='subtle'` resolves to
`color: inherit`, so without it the app's secondary action keeps the page's
dark ink over the dark canvas (measured at 1.61:1, now 6.61:1). `Surface`
pairs the two the same way.
Under `background` the ink is NOT set here any more. `<Background on="dark">`
stamps the context on the STACK, and the foundation's twin selector
(`:where(:has(> [data-background][data-on='dark']))`) hands it to the host —
the Section — which is the ancestor of this cluster. That block redefines
the `--color-content-*` scale AND declares `color`, which is what anything
inheriting picks up: a `Link variant='subtle'` resolves to `color: inherit`
and would otherwise keep the page's dark ink over the dark canvas
(measured 1.61:1 before the pair existed). One stamp, at the source of the
darkness, instead of a second one the block had to remember.
The `Display` below still takes its ink by prop: measured inside this
cluster, `--color-content-primary` already resolves to the on-solid white and
`Text` follows it through `--_text-color`, but `Display` computes
`oklch(0.2435 0 0)` anyway — its recipe does not read that token, so the
context alone leaves the title at 2.46:1. Both gaps are in the README.
The `Display` below still takes its ink by prop: `--color-content-primary`
resolves to the on-solid white here and `Text` follows it through
`--_text-color`, but `Display`'s recipe does not read that token, so the
context alone would leave the title at 2.46:1. That gap is in the README.
-->
<Stack
gap={5}
align={centered ? 'center' : 'start'}
data-stagger
data-on={onDark ? 'dark' : undefined}
>
<Stack gap={5} align={centered ? 'center' : 'start'} data-stagger>
{#if eyebrow}
<Motion trigger="viewport">{@render eyebrow()}</Motion>
{/if}
@ -156,28 +152,22 @@
<section aria-labelledby={title ? titleId : undefined} {...rest}>
{#if layout === 'background'}
<!-- Backdrop layout: media full-bleed behind, a contrast scrim over it, and
the copy on top. The three layers stack by source order (the relative
copy paints above the two absolute layers), so no z-index is needed. -->
<Box position="relative" overflow="hidden" width="100%" minHeight="34rem">
<Box position="absolute" inset={0}>
<div data-hero-bg>
{@render backdrop?.()}
</div>
</Box>
<Box
position="absolute"
inset={0}
style="background: var(--color-overlay); opacity: var(--opacity-scrim);"
></Box>
<Box position="relative">
<Section size={sectionSize}>
<Container size={containerSize}>
{@render copy()}
</Container>
</Section>
</Box>
</Box>
<!-- Background layout: the app's media full-bleed behind, a contrast scrim
over it, the copy on top. It used to be three hand-built `Box` layers,
an inline scrim and a scoped stylesheet for the media's cover-fit —
the measured case that motivated the `Background` component. Now the
Section hosts the stack (foundation `:has`, D-BG.14/16) and every one
of those three concerns is canon: the layer geometry, the veil's
tokens, and the `object-fit` on content the block does not own. -->
<Section size={sectionSize} minHeight="34rem">
<Background on="dark">
<Background.Layer>{@render backdrop?.()}</Background.Layer>
<Background.Scrim />
</Background>
<Container size={containerSize}>
{@render copy()}
</Container>
</Section>
{:else}
<Section size={sectionSize}>
<!-- The decoration is a CHILD of the section, not a wrapper around it:
@ -221,19 +211,7 @@
{/if}
</section>
<style>
/* justified: the backdrop media is the app's slotted <img>/<video>; a
background layout must cover-fit it to the section, which no component prop
can set on content the block does not own. */
[data-hero-bg] {
inline-size: 100%;
block-size: 100%;
}
[data-hero-bg] :global(img),
[data-hero-bg] :global(video) {
inline-size: 100%;
block-size: 100%;
object-fit: cover;
display: block;
}
</style>
<!-- No scoped stylesheet here any more, and that is the point of the whole
refactor: the block's one justified escape hatch (D-BLK.2) was the media's
cover-fit, and it is now `background.css`'s, where the layer IS the box. A
block that owns no CSS is the B contract holding without an exception. -->

@ -59,6 +59,9 @@ inside Box's mechanism instead of out-specifying it, so an explicit
| `.Pattern` | `glow · mesh · grid · dots · lines · noise · rings · vignette`, all from tokens |
| `.Gradient` | a canonical named gradient, or an explicit stop list |
| `.Scrim` | the veil that buys legibility — flat or graded, optionally frosted |
| `.Image` | a photograph: composes `<Image>`, one source per theme mode, `priority` for the LCP case |
| `.Video` | a clip, with the five playback policies below |
| `.Pause` | the WCAG 2.2.2 control. Rendered FOR you when a layer moves; compose it only to move or restyle it |
Source order IS the stacking order: the first child sits at the bottom. Every
layer is `aria-hidden` and pointer-transparent; a scene opts back in with
@ -74,6 +77,76 @@ above resolves to the on-solid ink. The documented limits of that context are
D12's, unchanged: nested components resolve their OWN tokens, and portaled
content escapes.
**And the default scrim is not enough for a bright photograph** — measured in
Chrome, white text over a black veil, per scrim weight:
| `strength` | effective α | over mid-grey | over a WHITE photo |
| ----------------- | ----------- | ------------- | ------------------ |
| `ghost` | 0.20 | — | — |
| (default) `scrim` | 0.297 | 6.90:1 | **2.10:1** ✗ |
| `overlay` | 0.429 | 9.00:1 | 3.11:1 ✗ |
| `subtle` | 0.528 | 11.03:1 | 4.42:1 ~ |
The weights are relative to an ink that is already translucent
(`--color-overlay` carries 0.66), which is why the default lands at 0.297 — the
hero's shipped value to the digit. Over dark or mid artwork it clears AA
comfortably; over a bright one, no weight in the current scale reaches 4.5:1 for
20px copy. Reach for a graded scrim so the copy sits on the calm end, or darken
the artwork itself. Stated because a framework that hides this ships a hero that
is illegible on somebody else's photograph.
## Media, and how it fails
A background does not report its own failure. It gets out of the way, and the
layer below becomes the composition — which is why the source order reads
"cheap first, media on top". Measured in Chrome: a broken `Background.Image`
computes `display: none` on its layer (`data-status='error'`) and the
`Background.Pattern` beneath it keeps painting. THE STACK IS THE FALLBACK.
So `Background.Image` composes the canonical `<Image>` with a background's
defaults: `placeholder='none'` (a full-bleed skeleton behind a hero's copy is
worse than a plain surface), no error glyph, and no `alt` — every layer is
`aria-hidden`, so a name here would be a promise the tree never keeps. A
photograph that MEANS something is content, and content is not a background.
`Background.Video` answers five questions before it plays a frame, and none of
them is the consumer's to remember:
| Policy | What stops it |
| ---------------- | -------------------------------------------------------- |
| out of view | the stack's `seen` — one observer for every layer |
| hidden document | `IsDocumentVisible`; a background tab decodes nothing |
| `reduced-motion` | it never autoplays; the poster stands in (WCAG 2.3.3) |
| `reduced-data` | the file is not even requested — the poster IS the layer |
| paused | the control below |
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.
## The pause control (WCAG 2.2.2)
Content that starts on its own, lasts more than five seconds and sits beside
other content must offer a way to stop it. The stack counts the layers that
declared themselves moving and renders the control as its SECOND ROOT NODE —
never inside the stack, which paints behind the host's content and eats no
pointer events.
- **The label CHANGES and there is no `aria-pressed`** (D-BG.18). The APG offers
two ways to name a two-state control and doing both announces the state twice
in two contradicting readings. It is the shape `MediaPlayer.PlayButton`
already uses for the same act.
- **It comes FIRST in the DOM**, the order the APG fixes for a carousel's
rotation control: the keyboard reaches the way to stop the movement before
wading through what moves. Measured: reachable, `<button type="button">`,
outside any `aria-hidden` subtree.
- **Persistent, never hover-revealed** — a hover control does not exist on a
touch screen, and the keyboard user meets it first.
- **To move or restyle it**, pass the `pause` snippet; supplying it suppresses
the default, the way `Switch` chooses between a body snippet and its own
thumb. Measured in Chrome: the default control and a snippet-supplied one both
drive `data-paused` and freeze the drift (`animation-play-state: paused`).
## Preferences
| Preference | What happens |
@ -186,15 +259,38 @@ var(--color-overlay); opacity: var(--opacity-scrim)`), which is why the axis is
- **The media fit lives in the recipe**, not in each consumer's scoped style —
the layer IS the box, so `object-fit: cover` on a slotted `<img>`/`<video>`
belongs to it.
- **A layer is a one-cell grid, so whatever the app drops in fills it** — a
`Surface`, a composed `<Image>`, an app wrapper. Grid's `stretch` does that
without this recipe writing sizes onto content it does not own. Media is the
exception and is sized explicitly: `<img>` / `<video>` are REPLACED elements,
which `stretch` does not apply to (measured: a bare `<img>` stayed 1×1 while
every other child filled).
- **A layer registers as "moving" from INIT, never from a `$effect`** — rule A30
(`anchor-nav-provider.svelte.ts` §registerLink). Registering from a reactive
effect writes the stack's count during the effect phase, invalidates the
sibling that reads it, and the flush re-enters:
`effect_update_depth_exceeded`, measured in Chrome. It does not merely log —
it KILLS the root effect, so the pause button renders and then every click on
the whole surface is silently ignored. The consequence is deliberate: whether
a layer drifts is read at MOUNT, an authoring fact, not a live one.
Bisected, not guessed: a `PauseSlot` component that moved the read into its
own render effect did NOT stop the loop, and neither did `untrack` alone.
Moving the REGISTRATION to init did, and removing the extra component
afterwards kept it fixed.
- **The pause control is keyed on `[data-placement]`, not on its bare marker.**
The composed `<Button>` declares `position: relative` at `[data-button]` — the
same (0,1,0) — so a bare rule would be decided by which stylesheet the bundler
emitted last. Measured: the control computed `relative` and sat 18px outside
its host. Two attributes make the outcome a fact instead of a build detail.
## Gaps
| Gap | Disposition |
| ------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------- |
| `Background.Image` / `.Video` (load states, `sources` per mode, poster, autoplay policies) | **implementar** — F2 of `docs/process/PLAN-background.md` |
| `Background.Pause` — the WCAG 2.2.2 control the morfo already declares as a part | **implementar** — F2. The stack already counts self-moving layers (`registerAnimated`); the control is what is missing |
| Parallax (`speed`, `depth`, `attach="fixed"`, the `--background-progress` fallback) | **implementar** — F3, over `animation-timeline: view()` with the `ScrollProgress` fallback |
| Demo page + the 9-tab harness | **implementar** — F4 |
| The hero's `background` layout still hand-builds its layers | **implementar** — F2, once `Image`/`Video` exist |
| `Ambient` reading the context to pause its scene | **diferir** — a task of the PACK (D-BG.8), after F2 |
| Per-layer scroll velocity in a nested scroller | **descartar** — the fondo does not orchestrate content; `ScrollFrames` owns scrub, and pinned storytelling is another initiative |
| Gap | Disposition |
| ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| **The `strength` scale is not ordered by veil weight** — `subtle` (0.80) is HEAVIER than `overlay` (0.65), and `overlay` and `muted` are the same number. Measured 2026-08-17 | **decisión del autor** — the tokens name how opaque an ELEMENT is, so borrowing them as veil names imports an ordering that means something else. Renaming or renumbering is an API change, not a fix to make in passing |
| A bright photograph is illegible at every scrim weight (4.42:1 at the heaviest) | **decisión del autor** — either a weight above `--opacity-subtle`, or the doctrine that a graded scrim is the answer |
| Parallax (`speed`, `depth`, `attach="fixed"`, the `--background-progress` fallback) | **implementar** — F3, over `animation-timeline: view()` with the `ScrollProgress` fallback |
| Demo page + the 9-tab harness | **implementar** — F4 |
| The hero's `background` layout still hand-builds its layers | **implementar** — F2, once `Image`/`Video` exist |
| `Ambient` reading the context to pause its scene | **diferir** — a task of the PACK (D-BG.8), after F2 |
| Per-layer scroll velocity in a nested scroller | **descartar** — the fondo does not orchestrate content; `ScrollFrames` owns scrub, and pinned storytelling is another initiative |

@ -43,11 +43,20 @@
// A drifting gradient is movement that nobody asked for and that never ends:
// it declares itself so the stack knows it owes a pause control (WCAG 2.2.2).
//
// Registered from INIT and unregistered from an effect's cleanup — rule A30
// (`anchor-nav-provider.svelte.ts` §registerLink, "register with the parent
// from the constructor, not a reactive effect"). Registering from a reactive
// effect is what the stack's own count cannot survive: the write lands in the
// effect phase, invalidates the sibling that reads the count, and the flush
// re-enters — `effect_update_depth_exceeded`, measured in Chrome, which kills
// the root effect and leaves the whole surface inert. Whether a layer drifts
// is an authoring fact, so mount time is the honest moment to read it.
const background = getBackgroundContext();
$effect(() => {
if (!animate || !background) return;
return background.registerAnimated();
});
// svelte-ignore state_referenced_locally -- reading `animate` once IS the
// contract: A30 registers at init, so the count is a mount-time fact.
const unregister = animate ? background?.registerAnimated() : undefined;
$effect(() => () => unregister?.());
</script>
<Layer

@ -0,0 +1,91 @@
<script lang="ts">
/**
* Eidos `<Background.Image>` — a photograph as a layer of the stack.
*
* <Background on="dark">
* <Background.Image src={photo} sources={{ dark: photoNight }} priority />
* <Background.Scrim />
* </Background>
*
* It composes the canonical `<Image>`: the load cycle, the `<img>`
* passthrough and the Fallback / Error slots are ITS contract, already built
* and already audited. What this layer changes are the DEFAULTS, because a
* background fails differently from a portrait (D-BG.15):
*
* - `placeholder='none'` — a full-bleed skeleton shimmering behind a hero's
* copy is worse than a plain surface. The layer is simply transparent
* until the file lands, and whatever sits below it shows through.
* - no error glyph — a decoration does not report its own failure. On error
* the layer hides (`data-status='error'`, recipe) and the stack degrades
* to the layer beneath: THE STACK IS THE FALLBACK. That is why the source
* order reads "cheap first, media on top". An app that DOES want something
* there passes `error` / `fallback`, and then the layer STAYS: it stamps
* `data-has-fallback` so the hide rule stands down. Without that the two
* snippets were unreachable by construction — the layer hid them along
* with the broken image.
* - no `alt`, on purpose — every layer is `aria-hidden` by the morfo, so an
* accessible name here would be a promise the tree never keeps. A
* photograph that MEANS something is content, and content is not a
* background.
*/
import { Image } from '$uix/eidos/components/image';
import { ActiveEidos } from '$uix/eidos';
import Layer from './background-layer.svelte';
import type { BackgroundImageProps, BackgroundImageStatus } from './types';
let {
src,
sources,
srcset,
sizes,
fit = 'cover',
position = 'center',
priority = false,
fallback,
error,
...restProps
}: BackgroundImageProps = $props();
const eidos = ActiveEidos.require();
// D-BG.5 — the FRAMEWORK's mode, not the OS's. `mode` is a visual source of
// ActiveEidos (an app toggle, a persisted pref, a local `data-mode`) and it
// can diverge from `prefers-color-scheme`, which is all a `<picture media>`
// can see. Reading the theme context subscribes to that source when it is
// reactive, so a light/dark switch re-resolves without a remount.
const mode = $derived(eidos.getThemeContext().mode);
const resolvedSrc = $derived(sources?.[mode] ?? src);
// A hero's background IS the LCP element; a decoration three sections down is
// not. One boolean picks the pair the browser needs, and no consumer has to
// remember which two attributes go together.
const loading = $derived<'eager' | 'lazy'>(priority ? 'eager' : 'lazy');
const fetchpriority = $derived<'high' | 'auto'>(priority ? 'high' : 'auto');
let status = $state<BackgroundImageStatus>('idle');
</script>
<Layer
{...restProps}
data-kind="image"
data-status={status}
data-has-fallback={error || fallback ? '' : undefined}
>
<!-- The SHORTHAND path deliberately: handing `<Image>` children replaces its
whole body, inner `<img>` included, so the two slots travel as the
`fallback` / `errorFallback` props Image already exposes for this. -->
<Image
src={resolvedSrc}
alt=""
{srcset}
{sizes}
{fit}
{position}
{loading}
{fetchpriority}
placeholder="none"
{fallback}
errorFallback={error}
bind:imageStatus={status}
/>
</Layer>

@ -0,0 +1,65 @@
<script lang="ts">
/**
* Eidos `<Background.Pause>` — the control a background OWES the reader when
* something in it moves on its own (WCAG 2.2.2: content that starts
* automatically, lasts more than five seconds and sits alongside other
* content must have a way to pause it).
*
* It is rendered for you: the stack counts its self-moving layers and emits
* this as its SECOND ROOT NODE when the count is above zero. Compose it by
* hand only to move or restyle it, through the `pause` snippet:
*
* <Background>
* <Background.Video src={clip} />
* {#snippet pause()}<Background.Pause placement="bottom-start" />{/snippet}
* </Background>
*
* Shape — `IconButton`, a label that CHANGES, and no `aria-pressed`
* (D-BG.18). The APG separates the two ways to name a two-state control: a
* fixed label with `aria-pressed`, or a label that swaps without it. Doing
* both makes a screen reader announce the state twice, and the two readings
* contradict each other ("Pause, pressed" — pressed meaning what?). The
* framework already picked the second form in `MediaPlayer.PlayButton`, and
* the same act must behave the same way twice.
*
* It is persistent, never hover-only: a control that appears on hover does
* not exist for a touch screen, and it is the keyboard user reaching it
* first who needs it most.
*/
import { ActiveEidos } from '$uix/eidos';
import { IconButton } from '$uix/eidos/components/icon-button';
import { Pause, Play } from '$uix/eidos/components/icon';
import { BACKGROUND_LANGS } from './langs';
import { getBackgroundContext } from './context';
import type { BackgroundPauseProps } from './types';
// `size` / `variant` are NOT re-defaulted here: they are the canon's
// (`md`, `ghost`), the same pair `MediaPlayer.PlayButton` composes for the
// same act. Inventing a smaller default would make the two controls of one
// gesture look like two different things.
let { placement = 'top-end', variant = 'ghost', ...restProps }: BackgroundPauseProps = $props();
const eidos = ActiveEidos.require();
const stack = getBackgroundContext();
const paused = $derived(stack?.paused ?? false);
// The label names what the press WILL DO, which is why it swaps: while the
// background moves the button offers "pause", once stopped it offers "play".
const label = $derived(eidos.langs.ts(paused ? BACKGROUND_LANGS.PLAY : BACKGROUND_LANGS.PAUSE));
// The glyph takes a resolved size: `size` may arrive responsive (Button's
// scale is a subset of Icon's, so the resolved value always fits).
const glyphSize = $derived(eidos.resolve(restProps.size, 'md'));
</script>
<IconButton
{...restProps}
{variant}
color="neutral"
aria-label={label}
onclick={() => stack?.toggle()}
data-background-pause=""
data-placement={placement}
>
{#if paused}<Play size={glyphSize} />{:else}<Pause size={glyphSize} />{/if}
</IconButton>

@ -0,0 +1,154 @@
<script lang="ts">
/**
* Eidos `<Background.Video>` — a moving image as a layer of the stack.
*
* <Background on="dark">
* <Background.Video src={clip} poster={still} sources={{ dark: clipNight }} />
* <Background.Scrim />
* <Background.Pause />
* </Background>
*
* Five policies decide whether it plays, and NONE of them is the consumer's
* to remember. A background video that ignores them is the single most
* expensive decoration on the web: it burns battery behind a scrolled-past
* section, keeps decoding in a hidden tab, moves under the eyes of someone
* who asked for stillness, and arrives over a metered connection nobody
* offered to pay for.
*
* 1. OUT OF VIEW — the stack's `seen` (one observer for all layers)
* 2. HIDDEN DOCUMENT — `IsDocumentVisible`; a background tab decodes nothing
* 3. REDUCED MOTION — never autoplays; the poster stands in (WCAG 2.3.3)
* 4. REDUCED DATA — `prefers-reduced-data` / `saveData`: the file is not
* even requested, the poster is the whole layer
* 5. PAUSED — the user pressed the control the stack owes them
*
* Why the element is driven by hand instead of by `autoplay`: the attribute
* is a one-shot at parse time, and four of the five policies are RUNTIME
* state that flips both ways. `play()` / `pause()` from an effect is the only
* shape that can follow them.
*
* 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
* here (D-BG.15): v1 listens locally, exactly as ScrollFrames does.
*/
import { untrack } from 'svelte';
import { IsDocumentVisible } from '$adom';
import { ActiveEidos } from '$uix/eidos';
import { getBackgroundContext } from './context';
import Layer from './background-layer.svelte';
import type { BackgroundVideoProps, BackgroundMediaStatus } from './types';
let {
src,
sources,
poster,
loop = true,
muted = true,
playsinline = true,
preload = 'metadata',
...restProps
}: BackgroundVideoProps = $props();
const eidos = ActiveEidos.require();
const stack = getBackgroundContext();
let video = $state<HTMLVideoElement | null>(null);
let status = $state<BackgroundMediaStatus>('idle');
const documentVisible = new IsDocumentVisible();
const mode = $derived(eidos.getThemeContext().mode);
const resolvedSrc = $derived(sources?.[mode] ?? src);
// Policy 4 — the file is not requested at all. A poster is one image; a clip
// is megabytes, and the person who turned this on said they are counting.
const reducedData = $derived(eidos.dom.prefersReducedData.matches);
// Policy 3 — reduced motion does not merely pause it, it never starts: the
// first frames are the ones that would move under someone who asked for
// stillness. The poster stays, so the composition does not collapse.
const reducedMotion = $derived(stack?.reduced ?? eidos.dom.prefersReducedMotion.matches);
const wanted = $derived(
!reducedData &&
!reducedMotion &&
!(stack?.paused ?? false) &&
(stack?.seen ?? true) &&
documentVisible.current
);
// It MOVES: that is what makes the stack owe a pause control (WCAG 2.2.2).
// Deliberately NOT gated on `paused` — a clip stopped by the control is still
// a moving layer, and the button must not vanish the instant it works. It IS
// gated on the two preferences that keep the clip from ever running, because
// a button that pauses nothing is not accessibility. That is not
// reduced-motion "substituting" the control (D-BG.4 forbids that): the
// control is owed whenever something moves, and here nothing does.
//
// Registered from INIT, unregistered from a cleanup — rule A30, and the
// preferences are therefore read at mount. They are session-stable in
// practice, and the alternative (a reactive effect) is the reflush loop
// documented on `registerAnimated` in `background.svelte`.
const movesOnMount = untrack(
() => !eidos.dom.prefersReducedData.matches && !eidos.dom.prefersReducedMotion.matches
);
const unregister = movesOnMount ? stack?.registerAnimated() : undefined;
$effect(() => () => unregister?.());
$effect(() => {
const el = video;
if (!el) return;
const dom = eidos.dom;
const settle = (next: BackgroundMediaStatus) => () => (status = next);
const stops = [
// `loadeddata`, not `loadedmetadata`: the first frame has to be decoded
// before the layer claims it has something to show.
dom.listen(el, 'loadeddata', settle('loaded')),
dom.listen(el, 'error', settle('error')),
// A stall is not a failure — the poster carries the composition while
// the network catches up, and `playing` takes it back.
dom.listen(el, 'stalled', settle('stalled')),
dom.listen(el, 'playing', settle('loaded'))
];
return () => {
for (const stop of stops) stop();
};
});
$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;
if (wanted) {
// A refused autoplay is the browser's policy, not a failure of the clip:
// the poster stays and nothing is reported.
void el.play().catch(() => {});
} else {
el.pause();
}
});
</script>
<Layer
{...restProps}
data-kind="video"
data-status={status}
data-has-fallback={poster ? '' : undefined}
>
<!-- The poster is not merely the `<video>`'s attribute: when the clip is not
going to play — no data allowance, or it failed — the poster becomes the
layer, as its own `<img>`. A failed `<video>` paints nothing, poster
included, so relying on the attribute alone would have thrown away the
still frame D-BG.15 promises stays. -->
{#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>
{:else if poster}
<img src={poster} alt="" />
{/if}
</Layer>

@ -39,6 +39,19 @@
position: absolute;
inset: 0;
background-repeat: repeat;
/* A single full-bleed cell, so WHATEVER the app drops in fills the layer: a
`<Surface>`, an `<Image>` (a `<span>` around its `<img>`), a bare `<img>`,
a wrapper of its own. Grid's default `stretch` does it without this recipe
writing `inline-size` / `block-size` onto content it does not own — so an
app that sizes its own child still wins, and no rule here has to name
another component's identity.
It replaces the hero's deleted scoped stylesheet, which reached
DESCENDANTS (`[data-hero-bg] :global(img)`). A child-only rule was a
regression: the block's own demo slots a `<Surface>`, and an `<Image>`
would have stopped stretching. */
display: grid;
grid-template: 1fr / 1fr;
}
[data-background-layer][data-pointer] {
@ -50,14 +63,38 @@
* wanted a photograph behind its content wrote the same four declarations into
* a scoped, justified `<style>` — the hero block is the measured case.
* `:where()` keeps it at specificity 0, so an app that wants `contain` says so
* and wins. */
[data-background-layer] > :where(img, video) {
* and wins.
*
* Media is sized EXPLICITLY and not left to the grid above: `<img>` / `<video>`
* are replaced elements, and `stretch` does not apply to them — they keep their
* intrinsic size. Measured: a bare `<img>` in a layer stayed 1×1 while every
* non-replaced child filled. It is a DESCENDANT selector for the same reason
* the hero's deleted stylesheet was one: the media can arrive inside a wrapper
* of the app's own. */
[data-background-layer] :where(img, video) {
display: block;
inline-size: 100%;
block-size: 100%;
object-fit: cover;
}
/* ── Media layers ────────────────────────────────────────────────────────────
* A background does not report its own failure (D-BG.15). What it does is get
* out of the way, so the layer below — a pattern, a gradient, the host's own
* surface — becomes the composition. THE STACK IS THE FALLBACK, which is why
* the source order reads "cheap first, media on top".
*
* UNLESS the layer still has something to show: an `error` / `fallback` snippet
* the app supplied, or a video's poster standing in for the clip. D-BG.15 says
* the failed VIDEO is hidden "and the poster, or the layer below, remains" —
* hiding the whole layer would have thrown the poster away with it, and it made
* the `error` snippet unreachable by construction.
*/
[data-background-layer][data-status='error']:not([data-has-fallback]) {
display: none;
}
/* Weight — the semantic scale, never a hand-picked number. */
[data-background-layer][data-opacity='subtle'] {
opacity: var(--opacity-subtle);
@ -334,6 +371,50 @@
backdrop-filter: blur(var(--blur-xxl));
}
/* ── The pause control ───────────────────────────────────────────────────────
* It is NOT part of the stack: it is the stack's second root node, so it sits
* in the host's own stacking context, above the content, where a control can be
* clicked. Everything else it needs — the fill, the focus ring, the touch
* target, the disabled treatment — is `IconButton`'s, already themed.
*
* Persistent, never hover-revealed: a control that appears on hover does not
* exist on a touch screen, and the keyboard user reaching it first is the one
* who needs it most (D-BG.18).
*/
/* Keyed on `[data-placement]` — which is always present — and NOT on the bare
marker, on purpose. The composed `<Button>` declares `position: relative` at
`[data-button]`, the same (0,1,0) as a bare `[data-background-pause]`, so a
bare rule would be decided by which stylesheet the bundler happened to emit
last: measured in Chrome, the control computed `relative` and sat in the flow,
18px outside its host. Two attributes make it (0,2,0) and the outcome a fact
instead of a build detail — no `!important`, no wrapper element.
The corners are LOGICAL: `start` / `end` follow the reader, so in RTL the
control crosses to the other side with the text (the canon 3×3 logical grid,
narrowed to four corners — never re-declared). */
[data-background-pause][data-placement] {
position: absolute;
z-index: var(--background-pause-z);
}
[data-background-pause][data-placement='top-start'] {
inset-block-start: var(--background-pause-offset);
inset-inline-start: var(--background-pause-offset);
}
[data-background-pause][data-placement='top-end'] {
inset-block-start: var(--background-pause-offset);
inset-inline-end: var(--background-pause-offset);
}
[data-background-pause][data-placement='bottom-start'] {
inset-block-end: var(--background-pause-offset);
inset-inline-start: var(--background-pause-offset);
}
[data-background-pause][data-placement='bottom-end'] {
inset-block-end: var(--background-pause-offset);
inset-inline-end: var(--background-pause-offset);
}
/* ── Less motion, less contrast, forced colours ──────────────────────────────
* Three preferences, three answers, none of them "hide the content".
*/
@ -372,4 +453,6 @@
[data-background-layer] {
display: none;
}
/* The pause control stays: it is a control, not decoration, and it is the one
thing on this surface the user may still need to press. */
}

@ -21,8 +21,10 @@
// Self-imported so Vite emits a per-component chunk: the recipe ships only
// where a Background mounts (the code-split contract, not `index.css`).
import './background.css';
import { untrack } from 'svelte';
import { IsInViewport } from '$adom';
import { ActiveEidos } from '$uix/eidos';
import Pause from './background-pause.svelte';
import { setBackgroundContext } from './context';
import type { BackgroundProps } from './types';
@ -30,6 +32,7 @@
on,
reduce = 'static',
paused = $bindable(false),
pause: pauseSnippet,
children,
...restProps
}: BackgroundProps = $props();
@ -55,15 +58,45 @@
get seen() {
return viewport.current;
},
toggle() {
paused = !paused;
},
registerAnimated() {
animated += 1;
return () => {
animated -= 1;
};
// The COUNT is read by the `{#if}` above; the write happens while a layer
// is initialising. That only settles because every caller registers from
// INIT and not from a reactive effect — rule A30 (`anchor-nav-provider`
// §registerLink). Bisected in Chrome: with a caller registering from an
// `$effect`, the flush re-enters until `effect_update_depth_exceeded`,
// which does not merely log — it KILLS the root effect, so the button
// renders and every later click on the surface is silently ignored.
// `untrack` mirrors A30's own shape (the write must not make the writer
// a reader); measured on its own it was NOT what fixed the loop.
untrack(() => (animated += 1));
return () => untrack(() => (animated -= 1));
}
});
</script>
<!--
The control comes FIRST in the DOM, before the stack: it is the order the APG
fixes for a carousel's rotation control, so the keyboard reaches the way to
stop the movement before wading through what moves. It cannot live INSIDE the
stack — that div paints behind the host's content and eats no pointer events —
which is why it is a second root node and why an app relocating it does so
through the `pause` snippet, not by dropping a child in (D-BG.17).
It appears only when something actually moves on its own: `animated` counts
the layers that declared it. A background of still patterns owes nothing, and
a dead button is not accessibility.
-->
{#if animated > 0}
{#if pauseSnippet}
{@render pauseSnippet({ paused, toggle: () => (paused = !paused) })}
{:else}
<Pause />
{/if}
{/if}
<!-- `restProps` FIRST so the stamps below win: the identity of the stack is not
a consumer's to override (the Backdrop / Surface order). -->
<div

@ -13,7 +13,17 @@ const KEY = Symbol('background');
* - `seen` — the stack is inside the viewport. Expensive layers (a scene, a
* video) start on it; the precedent is `<Motion>` publishing the same moment
* so a counter does not run behind `opacity: 0`.
* - `registerAnimated()` — a layer that moves on its own DECLARES it, and the
* - `toggle()` — flip the pause. It lives here so the control can be rendered
* OUTSIDE the stack (it has to be: the stack paints behind the host's content
* and eats no pointer events) while still driving the same state — and so an
* app's own button, written into the `pause` snippet, reaches it too.
* - `registerAnimated()` — a layer that moves on its own DECLARES it AT INIT,
* never from a reactive effect (rule A30). The stack counts, and the count is
* what decides whether a pause control is owed. The rule is not a style
* preference: registering from an effect writes this state during the effect
* phase, the template that reads the count re-enters, and the flush never
* settles — `effect_update_depth_exceeded`, measured, which kills the root
* effect and leaves the whole surface inert. And the
* stack counts. That count is what decides whether the pause control has to
* exist at all (WCAG 2.2.2): a background of still patterns owes no control,
* one with a drifting gradient or an autoplaying video does. Returns its own
@ -23,6 +33,7 @@ export type BackgroundContext = {
readonly paused: boolean;
readonly reduced: boolean;
readonly seen: boolean;
toggle(): void;
registerAnimated(): () => void;
};

@ -19,12 +19,18 @@ import Layer from './background-layer.svelte';
import Pattern from './background-pattern.svelte';
import Gradient from './background-gradient.svelte';
import Scrim from './background-scrim.svelte';
import Image from './background-image.svelte';
import Video from './background-video.svelte';
import Pause from './background-pause.svelte';
type BackgroundNamespace = typeof BackgroundComponent & {
Layer: typeof Layer;
Pattern: typeof Pattern;
Gradient: typeof Gradient;
Scrim: typeof Scrim;
Image: typeof Image;
Video: typeof Video;
Pause: typeof Pause;
};
const Background = BackgroundComponent as BackgroundNamespace;
@ -32,6 +38,9 @@ Background.Layer = Layer;
Background.Pattern = Pattern;
Background.Gradient = Gradient;
Background.Scrim = Scrim;
Background.Image = Image;
Background.Video = Video;
Background.Pause = Pause;
export { Background };
export default Background;
@ -45,6 +54,13 @@ export type {
BackgroundPatternProps,
BackgroundGradientProps,
BackgroundScrimProps,
BackgroundImageProps,
BackgroundVideoProps,
BackgroundPauseProps,
BackgroundImageStatus,
BackgroundMediaStatus,
BackgroundPausePlacement,
BackgroundSources,
BackgroundBlend,
BackgroundBlur,
BackgroundFade,

@ -0,0 +1,5 @@
/** Idlangref constants for the Background component. */
export const BACKGROUND_LANGS = {
PAUSE: '#?components.background.pause|Pause background',
PLAY: '#?components.background.play|Play background'
} as const;

@ -1,6 +1,8 @@
import type { Snippet } from 'svelte';
import type { HTMLAttributes } from 'svelte/elements';
import type { ComponentColorProp } from '$uix/eidos/lib/types';
import type { IconButtonProps } from '$uix/eidos/components/icon-button';
import type { ImageFit, ImagePosition, ImageStatusValue } from '$uix/eidos/components/image';
import type { ComponentColorProp, LogicalPosition } from '$uix/eidos/lib/types';
/**
* What the stack does when the user asks for less motion.
@ -88,6 +90,17 @@ export type BackgroundProps = Omit<HTMLAttributes<HTMLDivElement>, 'children'> &
reduce?: BackgroundReduce;
/** Pause self-moving layers. Bindable; the control itself arrives with `Pause`. */
paused?: boolean;
/**
* Replace the pause control the stack renders for itself — to move it, to
* restyle it, or to use the app's own button (D-BG.17). Supplying it IS the
* explicit composition, so it suppresses the default, exactly as `Switch`
* chooses between a body snippet and its own `<Switch.Thumb>`.
*
* It is rendered as the stack's SECOND ROOT NODE, never inside the stack:
* that div paints behind the host's content and eats no pointer events, so a
* control written into it would not be a control.
*/
pause?: Snippet<[{ paused: boolean; toggle: () => void }]>;
children?: Snippet;
};
@ -119,6 +132,90 @@ export type BackgroundGradientProps = Omit<HTMLAttributes<HTMLDivElement>, 'chil
animate?: boolean;
};
/**
* A source per THEME MODE (D-BG.5). Resolved through
* `eidos.getThemeContext().mode` — the framework's mode, which an app toggle or
* a persisted preference can move independently of the OS. A
* `<picture media="(prefers-color-scheme)">` only ever sees the OS, and the two
* CSS `<img>`s alternative costs a double download.
*
* The active mode's key missing falls back to `src`, so `{ dark }` alone is a
* complete answer: one artwork, plus a darker one where the theme is dark.
*/
export type BackgroundSources = {
light?: string;
dark?: string;
};
/** Load status of an image layer — the `<Image>` cycle, unchanged. */
export type BackgroundImageStatus = ImageStatusValue;
/**
* Load status of a video layer. `stalled` is NOT an error: the poster carries
* the composition while the network catches up, and `playing` takes it back.
*/
export type BackgroundMediaStatus = 'idle' | 'loaded' | 'stalled' | 'error';
/** The four logical corners a pause control may hug. Narrowed from the canon
* 3×3 grid, never re-declared — `start` / `end` follow the reader. */
export type BackgroundPausePlacement = Extract<
LogicalPosition,
'top-start' | 'top-end' | 'bottom-start' | 'bottom-end'
>;
export type BackgroundPauseProps = Omit<IconButtonProps, 'aria-label' | 'children'> & {
/** Which corner of the host it hugs. @default 'top-end' */
placement?: BackgroundPausePlacement;
};
export type BackgroundImageProps = Omit<HTMLAttributes<HTMLDivElement>, 'children'> &
Omit<BackgroundLayerBaseProps, 'children'> & {
/** The artwork. */
src?: string | null;
/** A source per theme mode; the missing key falls back to `src`. */
sources?: BackgroundSources;
/** Responsive source set, forwarded to the `<img>`. */
srcset?: string;
/** Sizes hint paired with `srcset`. */
sizes?: string;
/** Object-fit. @default 'cover' */
fit?: ImageFit;
/** Object-position. @default 'center' */
position?: ImagePosition;
/**
* This background is above the fold and IS the LCP element: load it eagerly
* and at high priority. One boolean, because the two attributes only ever
* make sense together. @default false
*/
priority?: boolean;
/** Shown while loading. Empty by default — the layer below IS the fallback. */
fallback?: Snippet;
/** Shown on failure. Empty by default, for the same reason. */
error?: Snippet;
};
export type BackgroundVideoProps = Omit<HTMLAttributes<HTMLDivElement>, 'children'> &
Omit<BackgroundLayerBaseProps, 'children'> & {
/** The clip. */
src?: string;
/** A clip per theme mode; the missing key falls back to `src`. */
sources?: BackgroundSources;
/**
* The still frame. It is not optional in spirit: it is what the layer shows
* under reduced data, before the first decoded frame, and while a stall
* lasts.
*/
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' */
preload?: 'none' | 'metadata' | 'auto';
};
export type BackgroundScrimProps = Omit<HTMLAttributes<HTMLDivElement>, 'children'> &
BackgroundLayerBaseProps & {
/** Scrim ink — any role / scale / raw value. @default `--color-overlay` */

@ -3817,7 +3817,15 @@ export const THEME_BASE_RECIPE_TOKENS = defineRecipes({
'scrim-color': 'var(--color-overlay)',
'scrim-strength': 'var(--opacity-scrim)',
// ── Gradient drift (the `animate` layer) ────────────────────────────
'gradient-drift-duration': '24s'
'gradient-drift-duration': '24s',
// ── The pause control (WCAG 2.2.2) ──────────────────────────────────
// Composed from primitives that are already themeable: the space scale
// for the inset, and the `--z-index-*` ladder for the plane. The control
// only has to clear the host's own content, not the app's overlays — a
// dialog or a popover opening over this surface must still cover it,
// which is why this sits far below `--z-index-affix` (150).
'pause-offset': 'var(--space-3)',
'pause-z': 'var(--z-index-raised)'
},
surface: {
// Surface — the themeable canvas (Box + treatment): the palette-tint

@ -28,11 +28,11 @@ import { v } from '../types';
* scroll / pointer as CONTINUOUS MODULATION, never as an act — the same
* contract-level criterion `scroll-frames.ts` records ("observation, not an
* act"), plus the standing rule against emitting per frame. The one user act
* in reach — pausing an animated layer — belongs to the `Toggle` this
* component composes, and is declared in THAT morfo (`commit-toggle`).
* in reach — pausing an animated layer — belongs to the `Button` this
* component composes, and is declared in THAT morfo (`contact-activate`).
*
* The accessible name of the pause control is a `texts` slot handed to the
* composed `Toggle`, not an `aria` entry here: the label is STATE-DEPENDENT
* composed button, not an `aria` entry here: the label is STATE-DEPENDENT
* (pause ↔ play), which `architecture/morfo.md` §Step 4 lists as a naming
* default that cannot live in the morfo.
*/
@ -80,12 +80,19 @@ export const backgroundMorfo = {
},
{
// The pause control for layers that move on their own (WCAG 2.2.2).
// Structurally a `<Toggle>`: the SAME element carries `data-toggle` and
// `data-background-pause` — the Fab pattern (`fab.ts`), not a wrapper,
// so the `action` archetype lands on a real button and its touch target
// and focus ring are the ones the foundation already guarantees.
// `role` / `aria-pressed` / the disabled state come from Toggle; this
// Structurally an `<IconButton>`: the SAME element carries `data-button`
// and `data-background-pause` — the Fab pattern (`fab.ts`), not a
// wrapper, so the `action` archetype lands on a real button and its
// touch target and focus ring are the ones the foundation already
// guarantees. `role` and the disabled state come from Button; this
// morfo REFERENCES them, it does not re-stamp them.
//
// NOT a Toggle, and NOT `aria-pressed` (D-BG.18): the APG offers two
// ways to name a two-state control — a fixed label with `aria-pressed`,
// or a label that CHANGES without it — and doing both makes the state
// be announced twice, in two readings that contradict each other. The
// framework already chose the second form for the same act in
// `MediaPlayer.PlayButton`, which is why the `texts` above are a pair.
name: 'Pause',
kebab: 'pause',
archetype: 'action',

Loading…
Cancel
Save

Powered by TurnKey Linux.