uix(background): el contrato del fondo existe antes que su primer píxel

F0 de `docs/process/PLAN-background.md`: la infraestructura que las fases
siguientes consumen, sin una sola regla de pintura todavía.

- **morfo `background`** (`scope: ['eidos']`, 0 eventos): tres partes — la pila,
  la capa (`aria-hidden`) y la pausa. Ningún knob visual: no hay soma que cruzar,
  así que cada eje de pintura es attr de wrapper (la llamada que `image.ts` ya
  registró en su bloque comentado). La pausa es el BOTÓN compuesto, no un
  contenedor de colocación: el patrón de `fab.ts` — el mismo `<button>` lleva
  los dos markers — porque `[data-archetype='action']` paga cursor, anillo de
  foco y 44px de suelo táctil, y eso no se le cuelga a un div. Textos
  `pause`/`play` con las claves idénticas al catálogo (la trampa de chronos).

- **foundation**: `:where(:has(> [data-background]))` adopta a CUALQUIER padre
  como anfitrión — posicionado y aislado — sin tocarle la receta. Un componente
  no puede estilar a su padre, y la alternativa (que el consumidor recuerde
  `position: relative`) es el footgun de las referencias: las capas desaparecen
  y nada dice por qué. Especificidad 0 a propósito: el `position` propio de un
  `Dialog.Content` sigue ganando. El bloque D12 gana el gemelo `:has` para que
  el `on` de un fondo re-entinte al padre que lo hospeda.

- **`$adom`** gana los dos puertos que un fondo necesita y nadie tenía:
  `ScrollProgress` (el 0→1 que `view()` recorre, hasta ahora privado dentro de
  ScrollFrames) y `prefersReducedData` (la query estándar O el `saveData` de
  Chromium — el gemelo de PESO del reduced-motion, honrado no descargando, nunca
  escondiendo contenido).

Dos defectos propios, cazados por revisión adversaria y corregidos aquí: la
primera medición de `ScrollProgress` era síncrona dentro del `$effect` (la
regla 5 de timing — ahora se difiere al frame que el helper ya tenía), y el
stub de `matchMedia` ignoraba la query, así que la implementación podía pedir
`prefers-reduced-motion` con la suite entera en verde. Medido por mutación:
ahora mata 4 de 6.

Los tokens `--background-*` NO entran todavía: declararlos sin su receta rompe
tres guards de `recipe-css-contract` (medido con sonda, revertida). Viajan con
el CSS que los consume, en F1.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
alpha-0.1-background
dev 2 months ago
parent f3bfcf2dd9
commit 50a7f47147

@ -0,0 +1,671 @@
# PLAN — `Background`: el anfitrión de capas de fondo del canon (estudio + plan)
> **Estado: DECISIONES D-BG.1–11 y D-BG.13–15 FIRMADAS 2026-08-17 (D-BG.12
> superada por la 14) — NADA CONSTRUIDO.** Falta que el autor nombre la rama de
> trabajo (§10.1) y ordene el arranque del agente (F0). El estudio original,
> abajo, se conserva íntegro.
>
> ~~Estado: ESTUDIO ENTREGADO 2026-08-17 — NADA CONSTRUIDO, NADA FIRMADO.~~
> Este documento responde a la pregunta «¿conviene un componente `Background`
> rico, composable desde el resto del framework, a la altura o por encima de lo
> que hay hoy (parallax, capas, vídeo, patrones, scrim…)?». Contiene el
> veredicto razonado, la comparativa con las referencias, el inventario de lo
> que YA existe en el framework, la forma canónica propuesta, las decisiones
> que sólo el autor puede firmar (§6, D-BG) y el plan por fases con sus gates
> (§7). Es un documento de proceso: no es doctrina hasta que el autor firma.
>
> **Kickoff para sesión nueva**: _«Lee `docs/process/PLAN-background.md`; si
> las D-BG de §6 están firmadas, ejecuta la fase que toque; si no, PARA y
> preséntalas una a una.»_ Cada fase lista qué leer, qué producir y con qué
> guard se verifica.
>
> **Enmiendas de la sesión 2026-08-17 (conversación con el autor, tras el
> estudio)** — mandan sobre cualquier frase anterior de este documento:
>
> 1. **`Background` NO compone `Box` ni es una caja de flujo.** Es la PILA DE
> CAPAS que el padre renderiza como hijo (`position:absolute; inset:0;
> z-index:-1; border-radius: inherit; overflow: clip` en la propia pila). Un
> solo modo de colocación; el layout sigue siendo de `Box`/`Section`/`Card`
> («one axis, one primitive»). D-BG.12 queda superada por D-BG.14.
> 2. **El padre se convierte en anfitrión sin tocarlo**: una regla de foundation
> `:where(:has(> [data-background])) { position: relative; isolation: isolate }`
> (`:has()` ya se usa 71 veces en eidos; `:where` deja ganar al `position` de
> cualquier recipe). El bloque `data-on` gana el mismo gemelo `:has`.
> 3. **Cero props nuevas en `Box`** (Box es layout-only por doctrina; el
> tratamiento vive en primitivas aparte — Surface es el precedente).
> 4. **Composición, no struct**: el padre que quiera renderizar el fondo expone
> un snippet (`background`) donde el app compone `<Background>…</Background>`;
> un prop `background={struct}` contradice la regla compositional-not-data-driven
> del autor (OnionMenu 2026-06-21), B-5 y la regla 6 de eidos → D-BG.13.
> 5. Ejecución: **un agente Opus 5 construye; Fable supervisa** — brief en §10,
> protocolo de supervisión en §11.
---
## 0. La pregunta y la respuesta corta
**Pregunta** (usuario, 2026-08-17): estudiar el sistema ActiveUIX, su
documentación, filosofía y guías, y evaluar un componente `Background` rico,
composable en el resto de componentes, que permita las técnicas de fondo de hoy
(parallax, etc.), a la par o por encima de las referencias; evaluar idoneidad;
si conviene, plan de desarrollo.
**Respuesta corta — SÍ conviene, con una forma precisa**: no «un componente
más» sino **el anfitrión canónico de capas de fondo** que el tier `blocks` ya
está reclamando a gritos y que hoy se resuelve a mano. La evidencia es
mecánica, no de gusto:
- `src/uix/blocks/hero/hero.svelte` (layout `background`) monta **tres capas a
mano**: un `Box` absoluto para el media del app, un `Box` con
`style="background: var(--color-overlay); opacity: var(--opacity-scrim)"` de
scrim y un `<style>` scoped `/* justified: */` para el `object-fit: cover`
del `<img>/<video>` del app — la **excepción D-BLK.2** del contrato B, más el
`data-on='dark'` y las tintas explícitas puestos a mano. El README del hero
documenta dos huecos del canon que salieron de ahí. Es exactamente la regla
de admisión disparando: «si al construir un block hace falta un `data-attr`
que el CSS necesita seleccionar o una obligación a11y, esa pieza se construye
ANTES como componente canónico».
- `docs/process/PLAN-blocks-quality.md` §2 diagnosticó «no hay fondo decorativo
de sección» y resolvió `Backdrop` (glow/mesh/grid/dots) — **cuatro patrones
estáticos, una sola capa, sin README, sin demo, un consumidor** (hero). El
mismo plan deja «parallax suave» del hero como acabado que falta, y
`Surface` deja en Gaps «scrim de autoría para vívidos — componentizarlo sólo
si aparece el caso repetido». El caso ha aparecido.
- Ninguna referencia (Radix/Ark/Bits/React Aria/MUI/Chakra) tiene un
componente de fondo; Mantine (`BackgroundImage` + `Overlay`), Vuetify
(`v-parallax`) y `react-scroll-parallax` (`ParallaxBanner` por capas) son
los únicos con API de producto; el resto (Aceternity, Magic UI, shadcn.io,
React Bits) son colecciones de efectos copy-paste sin sistema. **El hueco de
«anfitrión de capas con contrato» está vacío en el sector** — y el framework
ya posee todas las piezas para llenarlo con ventaja (tokens+TSC, ActiveDom,
motion reducido, `data-on`, `Image`, `$scene`/`Ambient`, `Toggle`).
**Y con la misma precisión, lo que NO conviene**: meter en el canon los efectos
animados decorativos (aurora WebGL, beams, partículas…). Eso ya está decidido y
construido: son el **pack `Ambient`** (32 efectos sobre `$scene`) —
`docs/architecture/packs.md` + `decisions/design-text-effects.md` («backgrounds
went to the pack tier»). `Background` es el **anfitrión** (geometría, a11y,
tokens, parallax, scrim, media) donde el app monta esos efectos como una capa
más. Canon = la superficie de contrato; pack = la hoja decorativa. La doctrina
se cumple leyéndola entera: lo que fue al pack fue el EFECTO, no la ranura.
---
## 1. Lo que se ha leído (precondición cumplida)
Doctrina leída ENTERA para los ejes que toca esta pieza (arquitectura, canon,
morfo, soma §2 membresía, eidos, motion, theming, packs, blocks, guías):
`docs/README.md` · `architecture/overview.md` · `architecture/active-architecture.md`
· `CANON.md` · `architecture/morfo.md` · `architecture/soma.md` (membresía) ·
`architecture/eidos.md` · `src/uix/eidos/components/README.md` ·
`theming/motion.md` · `theming/motion-guide.md` · `src/arts/motion/README.md` ·
`theming/reference.md` (entera) · `canon/tsc.md` · `canon/recipe-contract.md` ·
`theming/gradient-finish.md` · `theming/channels.md` · `theming/notes.md` ·
`architecture/packs.md` · `architecture/blocks.md` · `building-a-component.md` ·
`guides/component-guide.md` (build contract + Before You Start §1–5) ·
`guides/completion-checklist.md` · `guides/demo-authoring.md` ·
`guides/component-audit.md` · `canon/vocabularies.md` ·
`decisions/design-text-effects.md` · `decisions.md` · `glossary.md` ·
`next-features.md` · `rfcs/rfc-depth.md` (alcance: scrim/frost/parallax) ·
`process/PLAN-blocks-quality.md` · `process/PLAN-blocks.md` (forma) ·
`process/CONTINUE-lectura-doctrina.md`.
Código leído: `eidos/components/{backdrop,surface,image,scroll-frames,motion}`
+ sus morfos · `blocks/hero/{README,hero.svelte}` · `packs/ambient/{README,
ambient.svelte}` · `arts/scene/{README,types}` · `arts/adom/README.md` ·
`eidos/lib/primitives/static.ts` (gradientes/scrim/opacity/blur) ·
`eidos/lib/themes/cristal.ts` · `web/routes/demos/heroscrolling` (semilla de
parallax) · `web/routes/demos/animations/background/*` (censo de semillas).
**No leído, y por qué no bloquea**: `architecture/sema.md` (1051 L) — la
pieza es pasiva (`scope: ['eidos']`, 0 eventos, patrón ScrollFrames/Backdrop);
si alguna decisión de §6 la hiciera emitir eventos, se lee ANTES de tocar el
morfo. `MOTION_SERVICE_RFC.md` — leído por sus síntesis en `motion.md` /
`motion-guide.md` y grepeado: **no contempla timelines de scroll ni parallax**
(sólo cita GSAP como lo que NO se reinventa) → el parallax es terreno no
cubierto por la doctrina de motion, y por eso es una decisión (D-BG.3), no una
presuposición.
---
## 2. Comparativa — qué hay hoy en el sector y dónde queda UIX
Leído en las fuentes (docs oficiales / repos), 2026-08-17.
| Capacidad | Mantine (`BackgroundImage`, `Overlay`) | Vuetify `v-parallax` | `react-scroll-parallax` (`ParallaxBanner`) | Motion/Framer (`useScroll`) | Aceternity · Magic UI · shadcn.io · React Bits | Tailwind Plus · Untitled · daisyUI | **UIX hoy** | **UIX con `Background`** |
| --- | --- | --- | --- | --- | --- | --- | --- | --- |
| Imagen de fondo cover con contenido encima | ✓ (`src`, `radius`) | ✓ (`src`, `height`) | ✓ (`image` por capa) | — | dentro de cada efecto | HTML + `bg-*` / `hero-overlay` | hero a mano (`<style>` justified) | ✓ `Background.Image` compone `Image` (fit/position/srcset/priority/fallback) |
| Vídeo de fondo (autoplay muted loop poster, pausa a11y) | ✗ | ✗ | ✗ | ✗ | ✗ | HTML crudo | ✗ | ✓ `Background.Video` + `Background.Pause` (WCAG 2.2.2), pausa fuera de vista/pestaña |
| Scrim / overlay (color, opacidad, gradiente, blur, fixed) | ✓ `Overlay` | ✗ | ✗ | — | ad hoc | `hero-overlay` (opaco fijo) | inline `style=` en hero | ✓ `Background.Scrim` sobre `--color-overlay` / `--opacity-*` / `--blur-*` (Cristal-compatible) |
| Capas apiladas con blend/opacidad/máscara | ✗ | ✗ | ✓ (orden = z; `expanded`) | manual | ✗ (un efecto = un wrapper) | ✗ | `Backdrop` = una capa `::before` | ✓ `Background.Layer` genérica (blend, opacity semántica, fade-mask, bleed) |
| Patrones estáticos (grid, dots, líneas, ruido, anillos, viñeta, glow, mesh, spotlight) | ✗ | ✗ | ✗ | — | ✓ (copy-paste, colores a mano) | Untitled `BackgroundPattern` (SVG), TW blobs `clip-path` | `Backdrop`: 4 sobre tokens | ✓ `Background.Pattern` (los 4 + lines/noise/rings/vignette/spotlight), **sobre tokens y modo** |
| Gradientes nombrados temables | ✗ | ✗ | ✗ | — | hex a mano | utilidades | `--gradient-{name}` (open cage) + `Surface gradient` | ✓ `Background.Gradient colors="aurora"\|[stops]` (patrón D-T3 de TextGradient) |
| Parallax por scroll (velocidad por capa) | ✗ | ✓ (una imagen) | ✓ (`speed`, easing, ranges) | ✓ (`useTransform`) | «Hero Parallax» (JS, GSAP-like) | ✗ | ✗ (semilla `heroscrolling`) | ✓ CSS scroll-driven (`animation-timeline: view()`) primero + fallback JS por ActiveDom |
| Parallax por puntero / spotlight | ✗ | ✗ | ✗ | ✗ | ✓ («Parallax Scroll mouse», Spotlight) | ✗ | sólo dentro de efectos WebGL del pack | ✓ `depth` por capa + `spotlight` (rAF-coalesced, `dom.writeProperty`) |
| Efectos animados (aurora, beams, partículas, mesh en deriva…) | ✗ | ✗ | ✗ | ✗ | ✓ decenas (canvas/CSS/WebGL) | ✗ | ✓ **pack `Ambient` (32)** + `$scene` | = pack; `Background.Layer` es la ranura; `Ambient` lee el contexto (pausa/reduce) |
| Reduced-motion / reduced-data / forced-colors por construcción | ✗ | ✗ | `disabled` manual | manual | rara vez | ✗ | Backdrop: `prefers-contrast` ✓; pack: P-1 reduce ✓ | ✓ política `reduce` en el host + poster-only bajo `saveData` + capas fuera en `forced-colors` |
| Contexto de tinta para el contenido (inversión) | ✗ | ✗ | ✗ | ✗ | ✗ | ✗ | `data-on` (D12, mínimo) | ✓ prop `on` en el host (D12) + scrim: la legibilidad se compone, no se improvisa |
| Disciplina de plataforma (iframe-safe, sin reflow forzado, teardown) | ✗ | ✗ | ✗ | parcial | ✗ | ✗ | ActiveDom (`listen/raf/measure/observe*`) | ✓ heredada — la misma que ScrollFrames |
| Contrato declarado (partes, `aria-hidden`, tokens con TSC, guard) | ✗ | ✗ | ✗ | ✗ | ✗ | ✗ | morfo pasivo (Backdrop/Surface/ScrollFrames) | ✓ morfo `scope:['eidos']` + `component:audit` + eidos-lint + recipe-contract |
**Lectura de la tabla.** Ninguna referencia ofrece la pieza entera; cada una
tiene un trozo. UIX iguala cada trozo con SU mecanismo (tokens, ActiveDom,
`Image`, `data-on`, pack) y supera en lo que ninguna tiene: contrato
declarado, a11y por construcción, theming vivo, y la ranura para el pack. Donde
las referencias seguirán ganando **a propósito**: amplitud de efectos exóticos
(shadcn.io lista 100) — la respuesta del framework es el tier de packs, no el
canon; y la orquestación de scroll tipo GSAP (pin + scrub + timelines) —
fuera de alcance de esta pieza (ScrollFrames ya cubre el scrub de media; el
storytelling pinned es otra iniciativa).
**Soporte de plataforma comprobado (2026-08)**: CSS scroll-driven animations
en Chrome/Edge 115+, Safari 26+ (threaded en 26.4), Firefox aún tras flag
(prioridad Interop 2026) — ~84 % global, no Baseline → **progressive
enhancement obligatoria** con `@supports (animation-timeline: view())` y
fallback JS (es lo que hace Motion/Framer: `ScrollTimeline` nativo donde hay,
JS donde no).
---
## 3. Inventario — lo que el framework YA tiene (y no se reinventa)
| Pieza | Qué aporta a `Background` | Cómo se usa |
| --- | --- | --- |
| `Backdrop` (canon, eidos-native; morfo `scope:['eidos']`, 1 parte, `data:[]`) | 4 patrones sobre tokens (glow/mesh/grid/dots), `fade` mask, `prefers-contrast: more` cae el bloom, `isolation:isolate` + `::before z:-1` | Se **absorbe** como `Background.Pattern` (D-BG.1); único consumidor: hero |
| `Surface` (Box + tratamiento; `on`, `gradient`, `rounded`) | El precedente de «lienzo temable» y de `data-on` (D12) | `Background` NO duplica: es la superficie de CAPAS; `Surface` sigue siendo el lienzo pintado por identidad. Se cierra su gap «scrim» |
| Gradientes nombrados `--gradient-{name}` (open cage; `aurora` es `background-image`-válido, los mesh con base final NO) | Capa de gradiente temable | `Background.Gradient` pinta con `background:` shorthand (válido en ambos casos) — la lección de `reference_mesh_gradient_not_background_image` |
| `Image` (fit/position responsive, srcset/sizes/fetchpriority/decoding, Fallback/Error, `data-status` de `ImageProvider`) | La imagen de fondo con ciclo de carga | `Background.Image` **compone** `<Image fit="cover">`; nunca un `<img>` crudo |
| `ScrollFrames` (progreso de scroll por `dom.listen` + `dom.raf` + `observeResize`, lecturas dentro del rAF) | El patrón JS de progreso de scroll disciplinado | Semilla del fallback de parallax; candidato a compartir un helper `$adom` (`ScrollProgress`, D-BG.7) |
| `Motion trigger="viewport"` + contexto `seen` · `Cascade` · `data-stagger` | La coreografía de entrada del contenido — no del fondo | El contenido encima sigue usando esto; `Background` publica su propio contexto (pausa/reduce) por el mismo patrón |
| `$scene` (engine con IO-pause, DPR cap, reduce obligatoria, context-loss, budget, `pause()/resume()` en el handle) + pack `Ambient` (32 efectos, P-1…P-6, re-tintado por tema) + `Aura` | Los fondos ANIMADOS ya resueltos | Se montan DENTRO de `Background.Layer` desde el app; el pack puede leer el contexto de `Background` (dirección pack→framework, legal por `packs.md` §Hard boundaries 1) |
| Tokens: `--color-overlay` (por modo), `--opacity-{scrim,overlay,muted,subtle,ghost}`, `--blur-*`, `--gradient-angle-*`, `--motion-distance-*`, `--radius-*`, `--z-index-*` | Todo lo que un scrim / frost / parallax necesita, ya temable | Consumidos por el recipe; el generador emite los `--background-*` (TSC) |
| Depth channel + `data-frost` + tema Cristal | El vidrio como cue de plano | `Background.Scrim blur` usa `--blur-*`; el fondo NO es elevación (`rfc-depth` excluye parallax 3D del canal a propósito) |
| `data-on` (foundation, D12) | Inversión mínima de tinta del subárbol | Prop `on` en el host; límites documentados (anidados/portales) — hero midió los dos huecos (`Display` ignora el contexto, `Link subtle` hereda) |
| `ActiveDom`: `listen`, `raf`, `measure`, `writeProperty`, `observeIntersection`, `observeResize`, `prefersReducedMotion`, `IsInViewport`, `IsDocumentVisible`, `ScrollState`, `ElementRect`, `getWindow` | Toda la ciudadanía DOM | Sin `window.*` crudos; `CSS.supports` vía `dom.getWindow(node)` |
| `Toggle` / `IconButton` (canon) | El control de pausa | `Background.Pause` compone `Toggle` (`pressed` = pausado); los eventos sema son de Toggle |
| `Box` (position/inset/overflow/minHeight…) | La caja anfitriona | `Background` compone `<Box>` como Backdrop/Surface (patrón Section) |
| Semillas: `web/routes/demos/heroscrolling` (parallax de columnas + giro 3D), `animations/background/{liquid-image,glass-window,…}` | Referencia comparativa (decisión D5 del plan scene: intactas, no se shippean) | Se citan en el README como baseline; nada se porta verbatim |
**Huecos reales del framework** (lo que `Background` construye o destapa):
anfitrión de capas composable · scrim/overlay canónico · imagen/vídeo cover
como capa con políticas (modo, reduce-motion, reduce-data, pausa) · parallax
por scroll y por puntero · patrones adicionales sobre tokens · política
`forced-colors` para decoración · puerto `prefersReducedData` en `$adom` (no
existe; `prefers-reduced-data` no es Baseline, `navigator.connection.saveData`
sólo Chromium) · helper `ScrollProgress` en `$adom` (no existe; ScrollFrames lo
lleva privado).
---
## 4. Idoneidad y tier — el veredicto razonado
**Regla de admisión** (`packs.md` / `blocks.md`): canon = lo que tiene
superficie de contrato que otros consumen. `Background` la tiene por tres vías:
1. **`data-*` que el CSS necesita seleccionar** — `data-background`,
`data-background-layer`, `data-background-pause`: un block NO puede escribir
CSS (B-3/B-11), así que la geometría de capas sólo puede vivir en el canon.
2. **Obligaciones a11y** — capas decorativas `aria-hidden` +
`pointer-events:none`; **pausa/parada** de movimiento automático > 5 s en
paralelo con contenido (WCAG 2.2.2) para vídeo y capas animadas;
**legibilidad del contenido sobre media** (1.4.3/1.4.11 → scrim + `on`);
reduced-motion (2.3.3) para parallax; `forced-colors`. «The moment an
artifact owns an accessibility obligation, it enters the canon»
(`design-text-effects.md`).
3. **Tokens que otros recipes/temas quieren** — `--background-scrim-*`,
`--background-parallax-travel`, `--background-pattern-*` (los de Backdrop),
temables por config (precedente state-layer §38/§40).
**Forma**: componente **eidos-native pasivo** — morfo `scope: ['eidos']`, 0
eventos, sin soma (membresía §2 de `soma.md`: no hay patrón APG ni máquina de
estados accesible; el fondo pinta y reacciona a scroll/puntero como
**modulación continua**, no como acto — el mismo criterio contract-level que
ScrollFrames («observación, no acto») y la regla «no emitir por frame»). El
único acto de usuario —pausar— pertenece al `Toggle` compuesto y a SU morfo
(`commit-toggle`). El estado `paused` es visual (`data-paused`, attr de
wrapper). Sema: `SemaPanel` en vacío justificado (D-4.1).
**Morfo mínimo (regla 2026-08-15, morfo `image.ts`)**: sólo partes + ARIA;
los knobs visuales (`data-kind`, `data-fit`, `data-blend`, `data-parallax`,
`data-attach`, `data-paused`, `data-pattern`, `data-color`, `data-strength`)
son attrs eidos-only de wrapper — ningún prop cruza la frontera de soma. Las
partes son lo que el consumidor COMPONE (`morfo.md` §renderAttrs: «if a
consumer can compose it or address it, it is a PART»):
| Parte | kebab | archetype | rol/aria | Nota |
| --- | --- | --- | --- | --- |
| Provider (host) | `provider` | `provider` | — | `Box` + `position:relative; isolation:isolate; overflow:clip` |
| Layer | `layer` | — (display puro; ver nota) | `aria-hidden="true"` (literal, como `scroll-frames.media`) | UNA parte para todas las capas; el `kind` (image/video/pattern/gradient/scrim/custom) es attr eidos-only |
| Pause | `pause` | `action` | ARIA del `Toggle` compuesto | Opcional; contenedor de colocación (patrón Fab-sobre-Button) |
> Nota archetype `layer`: `image` sólo cuando el elemento ES `<img>`; una capa
> genérica no encaja en ninguno de los 26 (`overlay` = «modal/dim backdrop» y
> arrastraría CSS de veil; `content` pisa `position`) → sin archetype
> («Omit it for a plain display part that pulls no shared styling»). Se
> confirma en F0 contra `ARCHETYPE_DESCRIPTIONS`.
**Textos** (`texts:` + `langs/components/background.ts`): `pause`
(`#?components.background.pause|Pause background`) y `play`
(`#?components.background.play|Play background`) para el `Toggle`; claves
IDÉNTICAS entre `texts` y catálogo (la trampa de chronos).
**Motion (doctrina aplicada)**: la deriva ambiental de un gradiente o el
travel de parallax NO son firmas de evento ni presets de estado → van como
`@keyframes` locales anotados `/* functional: … */` (R-4.5; precedente
TextGradient) con kill bajo reduce; el parallax por scroll con
`animation-timeline: view()` es la misma clase (maquinaria continua). La
alternativa —un eje `timeline` en el registro de presets— es una EXTENSIÓN de
contrato de `$motion`/eidos y sólo entra firmada (D-BG.3).
**Packs (doctrina aplicada)**: canon nunca importa `src/packs/`;
`Background.Layer` es una ranura; el app compone `<Ambient>` dentro; un block
NO puede (B-4) — recibe la escena del app por snippet (el hero ya tiene
`backdrop`). Un `Background.Scene effect=…` en canon importaría decoración al
canon → **rechazado** (queda registrado como alternativa desechada).
**Blocks (doctrina aplicada)**: al existir `Background`, el layout
`background` del hero deja de necesitar su `<style>` justificado y su scrim
inline; `Background.Pattern` releva a `Backdrop`; los blocks pendientes de
acabado (`cta`, `stats-band`, `testimonials`, `feature-split` «banda de fondo
por fila», `banner`, `site-footer`) componen la misma pieza. **Un block sólo
puede ser tan expresivo como el canon que compone** — ésta es la palanca.
---
## 5. Forma canónica propuesta (API, recipe, tokens, a11y, perf)
Todo lo de esta sección es PROPUESTA sujeta a §6. Convención: disciplined
option C (root + partes atadas), children siempre; sin flat-snippet API.
### 5.1 API
```svelte
<!-- El PADRE es cualquier componente o elemento que acepte hijos: Section, Card,
Dialog.Content, un Box-columna de Grid/Flex, un <li>… No se envuelve nada:
Background es un HIJO más, y el padre se vuelve anfitrión por la regla de
foundation (`:has`) sin tocar su recipe. -->
<Section>
<Background on="dark" parallax bind:paused>
<!-- capas: orden de fuente = apilamiento; todas absolute/inset 0 DENTRO de la pila, aria-hidden -->
<Background.Image src={hero} srcset sizes fetchpriority="high" fit="cover" position="center"
speed={-0.3} sources={{ dark: heroDark }} />
<Background.Video src="/loop.mp4" poster="/loop.jpg" sources={[{ src, type, media }]}
speed={-0.2} />
<Background.Gradient colors="aurora" | colors={['--color-primary-solid', '--color-tertiary-solid']}
angle="to-b" animate />
<Background.Pattern pattern="dots" color="primary" fade /> <!-- ex-Backdrop -->
<Background.Layer speed={0.15} blend="screen" opacity="muted" fade="to-b">
<Ambient effect="mesh" params={{…}} /> <!-- el pack, desde el APP -->
</Background.Layer>
<Background.Scrim strength="scrim" gradient="to-t" blur="md" />
<Background.Pause position="top-end" /> <!-- opcional: reubica el control -->
</Background>
<!-- el contenido, hermano de la pila, en flujo normal: pinta encima solo -->
<Container>…</Container>
</Section>
<!-- Un componente que quiera RENDERIZAR el fondo desde dentro (hero) expone un
snippet — composición, nunca un struct (D-BG.13): -->
<Hero>
{#snippet background()}<Background>…</Background>{/snippet}
{#snippet title()}…{/snippet}
</Hero>
```
| Superficie | Props (v1) | Notas |
| --- | --- | --- |
| `Background` (root = la PILA; NO compone `Box`) | `on?: 'light'\|'dark'` · `parallax?: boolean \| 'scroll' \| 'pointer' \| ('scroll'\|'pointer')[]` · `attach?: 'scroll'\|'fixed'` · `reduce?: 'static'\|'hide'` (política bajo reduced-motion, default `static`) · `paused?: boolean` (bindable) · `children` (las capas) + `HTMLAttributes<HTMLDivElement>` | La pila es `position:absolute; inset:0; z-index:-1; border-radius: inherit; overflow: clip` — bleed, recorte, radio y `attach='fixed'` (`clip-path: inset(0)` en la PILA, nunca en el padre) no tocan al anfitrión. El padre queda posicionado + aislado por la regla de foundation `:where(:has(> [data-background]))` (D-BG.14). `on` re-vincula la tinta del PADRE vía el gemelo `:where(:has(> [data-background][data-on='dark']))` del bloque D12 (mismos límites: anidados/portales). Publica contexto `getBackgroundContext()` → `{ paused, reduced, seen }`. Sin `rounded` (hereda del padre), sin props de layout (son del padre) |
| `Background.Layer` | `speed?: number` (parallax scroll; 0 = fijo; negativo = más lento/opuesto — semántica react-scroll-parallax) · `depth?: number` (parallax puntero) · `blend?: MixBlend` · `opacity?: 'full'\|'subtle'\|'muted'\|'ghost'` (tokens `--opacity-*`) · `fade?: 'radial'\|'to-t'\|'to-b'\|'edges'` (mask) · `bleed?: boolean` (default `true` cuando hay `speed`: margen negativo = `--background-parallax-travel` para que no asomen bordes; el `expanded` de react-scroll-parallax) · `pointer?: boolean` (opt-in `pointer-events:auto` para escenas interactivas, P-5) · `children` | La capa genérica; las demás son azúcar sobre ella con `data-kind` |
| `Background.Image` | `ImageProps` (compone `<Image fit="cover">`) + layer props + `sources?: { dark?: string; light?: string }` | Decorativa por definición: `alt=""` + `aria-hidden` (si la imagen significa algo, es contenido, no fondo). Modo resuelto por `eidos.getThemeContext()` (D-BG.5). Default `loading="eager"` + `fetchpriority="high"` sólo si el app lo pide: un fondo bajo el pliegue debe seguir lazy |
| `Background.Video` | `src` · `sources?` · `poster` · `loop=true` · `autoplay=true` · `preload='metadata'` · layer props | Siempre `muted playsinline` (política de autoplay); pausa fuera de vista (`observeIntersection`) y con pestaña oculta (`IsDocumentVisible`); reduced-motion → no autoplay (poster); reduced-data → sólo poster; `paused` global |
| `Background.Pattern` | `pattern: 'glow'\|'mesh'\|'grid'\|'dots'\|'lines'\|'noise'\|'rings'\|'vignette'\|'spotlight'` · `color?: ComponentColorProp` · `cell?` · `fade?` + layer props | Los 4 de Backdrop migran 1:1 (tokens `--backdrop-*` → `--background-pattern-*`); `noise` = `feTurbulence` en `url()` data-URI (sobrevive forced-colors → por eso D-BG.6 oculta las capas allí); `spotlight` sigue el puntero |
| `Background.Gradient` | `colors: string \| string[]` (nombre canónico `--gradient-{name}` \| lista de stops con tokens) · `angle?: GradientAngle` · `animate?: boolean` + layer props | Discriminación por FORMA (D-T3 de TextGradient); pinta con `background:` shorthand; `animate` = deriva `background-position` funcional, estática bajo reduce |
| `Background.Scrim` | `color?: ComponentColorProp` (default `--color-overlay`) · `strength?: 'scrim'\|'overlay'\|'muted'\|'subtle'\|'ghost'` (tokens) · `gradient?: 'to-t'\|'to-b'\|'radial'` · `blur?: BlurKey` (`--blur-*`, `backdrop-filter`) | Paridad Mantine `Overlay`; el frost es Cristal-compatible |
| `Background.Pause` | `position?: LogicalPosition` (default `top-end`) · props del `Toggle` compuesto | Compone `Toggle` (`pressed` ↔ `paused`, textos del morfo). Regla de render por defecto: D-BG.4. **Colocación**: NO puede vivir dentro de la pila (`z-index:-1` la dejaría bajo el contenido) → `Background` la renderiza como **segundo nodo raíz** (fragmento hermano de la pila, `position:absolute; z-index: 1` sobre el contenido del padre); si el app compone `<Background.Pause>` fuera de `<Background>` (junto al contenido), esa instancia se registra en el contexto y el default no se renderiza |
Contexto publicado (patrón `getMotionContext`): `{ paused, reduced, seen }` —
`seen` por `IsInViewport` para que capas caras arranquen al entrar (misma
razón que Motion→CountUp).
### 5.2 Recipe (`background.css`) — sólo selectores morfo-backed + attrs de wrapper
- **Foundation (generador, `render-css.ts`, junto a `renderOnContextBlocks`)** —
la adopción del anfitrión (D-BG.14):
`:where(:has(> [data-background])) { position: relative; isolation: isolate; }`
y el gemelo de tinta `:where(:has(> [data-background][data-on='dark']))` /
`…='light'` añadido como segundo selector del bloque D12 (una regla, dos
selectores — sin duplicar declaraciones). `:where` = especificidad 0: el
`position` propio de cualquier recipe (Dialog.Content `fixed`, Affix…) gana;
`isolation` no altera layout. Un padre `display: contents` no tiene caja → se
documenta como no-anfitrión.
- `[data-background]` (la pila): `position:absolute; inset:0; z-index:-1;
border-radius: inherit; overflow: clip; pointer-events: none;` —
`[data-attach='fixed']` → `clip-path: inset(0)` (la capa fija dentro queda
recortada por la pila; documentar que un `transform`/`filter`/`contain: paint`
en ancestros rompe `fixed`). Dentro de un padre con `[data-shape]` /
`[data-shape-nest]` el recorte sigue al `corner-shape` heredado (verificar en
F1: Card `rounded` × shape `continuous`, en Chrome real).
- `[data-background-pause]` (segundo nodo raíz): `position:absolute;
inset-block-start/inset-inline-end` según `LogicalPosition`; `z-index: 1`
(intra-componente, entero crudo permitido por el recipe-contract) — sobre el
contenido del padre, alcanzable por puntero y teclado.
- `[data-background-layer]`: `position:absolute; inset:0; z-index:-1;
pointer-events:none;` + `[data-bleed]` inset negativo por
`--background-parallax-travel`; `[data-blend=…]`; `[data-opacity=…]` →
`opacity: var(--opacity-{k})` (R-4.2 sin literales); `[data-fade=…]` → mask
(Backdrop hoy); `[data-pointer]` → `pointer-events:auto`.
- Parallax scroll: `[data-background][data-parallax~='scroll'] [data-background-layer][data-speed]`
→ `animation: background-parallax linear both; animation-timeline: view();
animation-range: entry 0% exit 100%;` con `@keyframes background-parallax
{ from { translate: 0 calc(var(--_background-speed) * var(--background-parallax-travel)) } to { translate: 0 calc(-1 * …) } }`
`/* functional: scroll-linked travel, not an event signature */`; bajo
`@supports not (animation-timeline: view())` el wrapper escribe
`--background-progress` (0..1) por `dom.writeProperty` desde `ScrollProgress`
y el recipe traduce con `calc()`. Reduce → `animation:none; translate:none`.
- Parallax puntero: host escribe `--background-pointer-x/-y` (−1..1) en
`pointermove` coalescido por `dom.raf`; capa → `translate:
calc(var(--background-pointer-x) * var(--_background-depth) * var(--motion-distance-xl))`.
Físico, no espeja en RTL (sigue al puntero). Reduce → 0. Sin `will-change`
global (memoria: jitter a DPR fraccional; sólo si se mide necesidad).
- Scrim: `[data-kind='scrim']` → `background: color-mix(in oklch,
var(--_background-scrim-color) calc(var(--_background-scrim-opacity) * 100%), transparent)`
o gradiente `to-t/to-b/radial`; `[data-blur]` → `backdrop-filter: blur(var(--blur-{k}))`.
- Patrones: migración literal de `backdrop.css` con prefijo `--background-pattern-*`.
- `@media (forced-colors: active) { [data-background-layer] { display:none } }`
(D-BG.6); `@media (prefers-contrast: more)` cae glow/mesh (hoy).
- Cero `@keyframes` no anotados, cero color crudo, cero `!important`, cero
`--eidos-*`. Recipe tokens en `lib/recipes/base.ts` (`background-*`, TSC:
`root` los estables; `host` los que leen `--palette-*`).
### 5.3 a11y — el contrato, no la esperanza
- Capas: `aria-hidden` (morfo) + `pointer-events:none` (recipe). Un fondo NO
es contenido; si el media significa, el app lo pone como `Image` con `alt`
en el flujo.
- **Pausa** (WCAG 2.2.2): cualquier capa que se mueva sola > 5 s (vídeo
autoplay, `Gradient animate`, escena del pack) exige control accesible;
`Background.Pause` compone `Toggle` (foco, `aria-pressed`, textos por langs).
Reduced-motion NO sustituye al control (es preferencia, no mecanismo).
- **Legibilidad**: `Scrim` + `on` son la pareja documentada; la demo mide
contraste con el método del hero (píxeles pintados; el probe rgb/canvas
miente con `oklch()`).
- Reduced-motion: parallax off, vídeo sin autoplay, `animate` estático;
`reduce='hide'` oculta las capas decorativas enteras.
- Reduced-data: vídeo → poster; imágenes: se respeta `srcset/sizes` del app.
- `forced-colors`: capas fuera; el contenido cae al `Canvas` del UA.
- Sin foco atrapado, sin roles falsos (lección TextFocus).
### 5.4 Rendimiento
Sólo `translate`/`opacity` en las capas; scroll-driven en el compositor donde
hay soporte; el fallback lee layout dentro de `dom.raf` (nunca sync tras
escritura; el detector `uix.perf` de reflow lo verifica); vídeo pausado fuera
de vista y con pestaña oculta; `preload="metadata"`; imágenes lazy por
defecto; el presupuesto de escenas lo pone `$scene`; `attach='fixed'` con
`clip-path` (no `background-attachment: fixed`, roto en iOS y caro).
---
## 6. Decisiones D-BG — para firmar UNA A UNA (nada ejecutado)
| # | Decisión | Opciones | Recomendación (arquitecto de framework de referencia) |
| --- | --- | --- | --- |
| **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.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 |
| **D-BG.7** | Infra `$adom` | (i) helper reactivo `ScrollProgress` (patrón `IsInViewport`; fallback de parallax; ScrollFrames candidato a migrar); (ii) puerto `prefersReducedData` (`prefers-reduced-data` + `navigator.connection.saveData`, `false` en SSR) | **FIRMADA 2026-08-17 — ambos**: `src/arts/adom/scroll-progress.svelte.ts` (+ test happy-dom; progreso 0→1 de un elemento por viewport o `root`, medido en `dom.raf` sobre `listen('scroll', passive)` + `observeResize`) y `ActiveDom.prefersReducedData` (`matchMedia('(prefers-reduced-data: reduce)')` OR `navigator.connection?.saveData`, `false` en SSR, sobre `targetWindow`; + test); export en el barrel `$adom`; entrada fechada en «Backlog / Evolution decisions» del README de adom (what/why/trigger). `SceneDom`/`MotionDom` no cambian; ScrollFrames no se migra ahora (F6) |
| **D-BG.8** | Integración con el pack | `Background` publica `getBackgroundContext()` `{ paused, reduced, seen }`; `<Ambient>` lo lee opcionalmente y llama `pause()/resume()` del handle (`$scene` ya lo expone) | **FIRMADA 2026-08-17** — el canon publica `{ paused, reduced, seen, registerAnimated() }` (patrón `getMotionContext`); `<Ambient>` (pack) lo lee opcionalmente: `handle.pause()/resume()` con `paused`, respeta `reduced`, se declara animado (dispara la pausa por defecto de D-BG.4); un prop `paused` explícito en `<Ambient>` puede coexistir como override. Tarea DEL PACK, separada, después de F2 (`src/packs/ambient/ambient.svelte` + README del pack); nada en `$scene`, nada en el canon más allá del contexto; encapsulación intacta |
| **D-BG.9** | `attach='fixed'` | técnica `clip-path: inset(0)` + capa `position:fixed`; incompatibilidades documentadas | **FIRMADA 2026-08-17** — en F3: `[data-background][data-attach='fixed'] { clip-path: inset(0) }` + `[data-background-layer][data-attach='fixed'] { position: fixed; inset: 0 }` (el `overflow: clip` de la pila NO recorta fijos; el `clip-path` sí). Nunca `background-attachment: fixed`. Incompatibilidades documentadas (`transform`/`filter`/`perspective`/`contain: paint`/`will-change: transform` en un ancestro → el fijo se comporta como absoluto, sin efecto pero sin fallo); `Dialog.Content` = no-anfitrión de este modo (documentado). Sin JS, sin listeners; verificar en Chrome y Safari reales |
| **D-BG.10** | Rollout en blocks | hero `background` → `Background` (borra la excepción D-BLK.2), Backdrop→Pattern en todos; luego cta/stats-band/testimonials/feature-split/banner/site-footer | **FIRMADA 2026-08-17** — F5, sólo tras F4 PASS, en este orden: (1) hero (layout `background` compone `Background`: fuera el `<style>` justificado, el scrim inline y el `data-on` manual → `on="dark"`; `decor` → `Background.Pattern`; snippet `backdrop` → `background`; su README borra la excepción D-BLK.2) — es la prueba del listón; (2) cta · stats-band · testimonials · feature-split (banda por fila) · banner · site-footer (columna B de PLAN-blocks-quality §3); (3) página compuesta. Una entrada de `AUDIT-blocks-ledger.md` por block con medición (contraste en píxeles). El «parallax suave del mockup» del hero queda FUERA (contenido, no fondo → candidato del eje motion/scroll); los huecos `Display`/`Link` bajo `data-on` siguen como candidatos de canon aparte |
| **D-BG.11** | Registro documental | entrada nueva en `docs/next-features.md` (§11); nota aclaratoria en `design-text-effects.md` («el HOST es canon; el EFECTO es pack»); `PLAN-blocks-quality.md` Q0.3 apunta aquí | **FIRMADA 2026-08-17** — en F4, tras el PASS (nunca antes: una nota que describe futuro es la deriva de `packs.md`/`glossary.md` con Aura): (1) `next-features.md` §11 (fecha, origen, alcance, deps, enlace a este plan); (2) frase en `design-text-effects.md`: el EFECTO fue al pack, el ANFITRIÓN de capas es canon por su contrato; (3) `PLAN-blocks-quality.md` Q0.3 → sucesor; (4) `surface/README.md` §Gaps «scrim» → cerrado por `Background.Scrim`; (5) `motion-guide.md` §8 una línea + nota en `MOTION_SERVICE_RFC` (D-BG.3); (6) `glossary.md`: entrada `Background` + corregir `Aura` («Not built yet» caduco, hallazgo nº 18 del registro de lectura). Regla `authoring.md` (leerlo entero antes): enlazar, no copiar; sin conteos a mano; `docs:check` verde |
| **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.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`**. `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» |
---
## 7. Plan por fases (cada una con lectura, producto y gate)
| Fase | Producto | Leer antes | Gate |
| --- | --- | --- | --- |
| **F0 · Firma + infra** | D-BG.1–14 firmadas · `$adom` `ScrollProgress` (+ test happy-dom, patrón `is-in-viewport.svelte.test.ts`) y `prefersReducedData` (+ README adom Backlog) · morfo `background.ts` (3 partes, `texts`, cabecera con la justificación pasiva y la regla de attrs de wrapper) + `langs/components/background.ts` · recipe tokens `background-*` en `lib/recipes/base.ts` · **regla de adopción del anfitrión + gemelo `data-on` en el generador** (`render-css.ts`, junto a `renderOnContextBlocks`; test en `generated-css`/`active-eidos-config`) · `generate:eidos-css` | `morfo.md` §Authoring · `canon/tsc.md` · `recipe-contract.md` §1 · `arts/adom/README.md` · `render-css.ts` (`renderOnContextBlocks`, `renderSharedPaletteLayer`) | `validateMorfo` · `npm run translations:check` · `recipe-css-contract` · `generated-css` · `npm run check` · suites adom verdes |
| **F1 · Núcleo** | `Background` root = la pila (hijo, sin Box — D-BG.14) · `Layer` · `Pattern` (4 migrados + lines/noise/rings/vignette) · `Gradient` · `Scrim` · `on` (gemelo `:has`) · contexto · hero: `Backdrop`→`Background.Pattern` (snippet `backdrop`→`background`, D-BG.13) · borrado de `backdrop/` (tras tu orden) · verificación previa del `Box flex/grow` (bug hero 2026-07-23; si persiste, arreglo en `Box` primero) | `eidos/components/README.md` (7 reglas + comparativa obligatoria) · `gradient-finish.md` §10 (fronteras) · `backdrop.css` (migración literal) · `box/README.md` + `card/README.md` + `dialog/dialog.css` (qué `position` propio traen los anfitriones habituales) | `component-api-contract` · `component-visual-attrs` · `eidos-lint` invalid 0 · `rtl:check` · `blocks:check` · verificación en Chrome REAL (el pane oculto suspende rAF) de CUATRO anfitriones: `Section`, **`Box`-columna de Grid/Flex**, **Card `rounded` × shape**, **`Dialog.Content`** (portal + `position: fixed` propio) |
| **F2 · Media** | `Image` (compone `<Image>`, `sources` por modo, prioridad) · `Video` (políticas: IO-pause, doc-hidden, reduce-motion, reduce-data, `paused`) · `Pause` (Toggle; regla D-BG.4) · hero layout `background` refactorizado (fuera `<style>` justified + scrim inline + `data-on` manual) | `image/README.md` + `image/types.ts` · `toggle/README.md` · WCAG 2.2.2 · `hero/README.md` (huecos medidos) | audit `--only background` · `blocks:check` · contraste medido en píxeles (método hero) · teclado: el control de pausa alcanzable y anunciado · reduce-motion probado en Chrome |
| **F3 · Parallax** | scroll (`animation-timeline: view()` + fallback `ScrollProgress`→`--background-progress`) · `speed`/`bleed`/`--background-parallax-travel` · puntero (`depth`, `--background-pointer-*`) · `spotlight` · `attach='fixed'` · reduce=static | `motion.md` §2/§10 · `motion-guide.md` §8 · `theming/reference.md` §14 (draggable lift: `translate` ≠ `transform`) · MDN scroll-driven · memoria `will-change` jitter | `@supports` en ambos caminos (Chrome con/sin flag) · `uix.perf` reflowDetector 0 violaciones · 60 fps medido · RTL: puntero físico, scroll vertical |
| **F4 · Demo + README + docs** | `web/routes/uix/components/background/+page.svelte` (v2 9 tabs LOCKED; Sema en vacío justificado; chips = uniones completas) · README (Baseline: Backdrop + hero background + semillas · Comparativa ≥3 · Decisiones · Gaps · Passive justification · Audit exceptions) · `next-features.md` §11 · nota en `design-text-effects.md` · `PLAN-blocks-quality.md` Q0.3 · Surface README (gap scrim → cerrado) | `demo-authoring.md` (entera) · `completion-checklist.md` | `component:audit --only background` **PASS 0 errores** · `morfo:check` · `SMOKE_SCOPE=/uix/components/background npm run smoke` · `docs:check` |
| **F5 · Rollout tier** | cta · stats-band · testimonials · feature-split (banda por fila) · banner · site-footer con `Background.*` (columna B de PLAN-blocks-quality §3) · página compuesta | `AUDIT-blocks-ledger.md` · cada README de block | `blocks:check` · ledger actualizado · comparación al lado de la referencia (la vara de `feedback_blocks_must_surpass`) |
| **F6 · Condicionado** | `Ambient` lee el contexto (pack) · ScrollFrames migra a `ScrollProgress` · `Gradient animate` variantes · patrones open-cage por config | — | sólo con consumidor real |
Orden fijo: **F0 → F1 → F2 → F3 → F4 → F5**; F4 puede solaparse con F2/F3
(la demo se construye con el componente). Nada de F1 empieza sin D-BG.1/2
firmadas; nada de F3 sin D-BG.3; F2 depende de D-BG.4/5.
### 7.bis Hallazgos de ejecución de F0 (2026-08-17) — medidos, no supuestos
Tres cosas que el plan no podía saber hasta tocar el árbol. Se registran aquí
porque son deuda de ESTE documento, no del código.
1. **F0.4 (tokens `background-*`) es IMPOSIBLE en F0 y se ejecuta en F1.** No
es un recorte: los dos guards que el propio F0 declara como gate lo
prohíben. Medido declarando un bloque `background: { 'parallax-travel': … }`
en `lib/recipes/base.ts` y corriendo `recipe-css-contract`:
- `does not declare recipes for missing component CSS files` →
`expected [ 'background' ] to deeply equal []` (el guard exige
`components/background/background.css`, producto de F1);
- `does not leave declared public recipe variables orphaned` → el token no
lo consume nadie hasta que exista la receta.
Y un tercero espera detrás: `loads every component CSS recipe exactly once`
exige que ese `.css` lo auto-importe `background.svelte` — el wrapper,
también de F1. **Los tokens viajan con la receta que los consume**; la
sonda se revirtió sin dejar rastro (`git diff` vacío en `base.ts`).
2. **La parte `pause` es el BOTÓN, no un contenedor de colocación.** La tabla
de §4 dice «contenedor de colocación (patrón Fab-sobre-Button)», pero el
patrón que cita hace lo contrario: `morfo/components/fab.ts:31-33` declara
*«Structurally a `<Button>`: the same `<button>` carries `data-button` +
`data-fab`»*. Y el archetype importa: `[data-archetype='action']` arrastra
`cursor: pointer` + `user-select: none` (`archetypes.css:169-172`), el
anillo de foco y un suelo de 44px bajo `pointer: coarse`
(`archetypes.css:293-301`) — sobre un `div` de colocación eso es la clase
de defecto que `reference_archetype_item_pulls_interactive_styling` fichó.
El morfo declara `defaultElement: 'button'` + `archetype: 'action'`: UN
nodo, el `Toggle` compuesto llevando ambos markers. La colocación absoluta
vivirá en el recipe sobre ese mismo nodo, como en `Fab`.
3. **«test happy-dom» (F0 / D-BG.7) no existe como proyecto.**
`vite.config.ts:73-107` define dos: `client` (Playwright chromium,
`*.svelte.{test,spec}.ts`) y `server` (node). Los helpers con runas de
`$adom` se prueban TODOS en `client` (`is-in-viewport.svelte.test.ts` y sus
once hermanos). Los dos tests nuevos siguen esa convención.
---
## 8. Riesgos y cómo se acotan
- **Sobre-alcance**: la pieza podría crecer hacia GSAP. Frontera escrita: el
fondo NO orquesta contenido (pin/scrub/timelines fuera; ScrollFrames sigue
siendo el scrub de media).
- **Deuda de contraste**: un fondo hace fácil poner texto ilegible. La demo y
el README ENSEÑAN scrim+`on` y miden; el hero ya midió los dos huecos de
`data-on` (Display/Link) — candidatos de canon aparte, no de esta pieza.
- **Firefox sin scroll-driven**: el fallback JS existe desde F3; nunca «no
hay parallax en Firefox».
- **`will-change`/compositing**: no se añade a ciegas (memoria de jitter);
sólo si una medición lo pide.
- **Pack ↔ canon**: la integración va por contexto opcional (pack lee); el
canon jamás importa el pack — `npm run check` verde al borrar `src/packs/`.
- **Naming**: renombrar/borrar `Backdrop` exige orden explícita del autor
(regla dura); hasta entonces `Background.Pattern` puede convivir con
`Backdrop` sin shim y el hero migra en F1.
---
## 9. Fuentes externas consultadas (2026-08-17)
Mantine `BackgroundImage` / `Overlay` (docs) · Vuetify `v-parallax` (**no verificado en sesión**: la página devolvió sólo el título y el JSON de la API dio 429; la columna de la tabla §2 va por conocimiento previo — `src` + altura, una sola imagen) · react-scroll-parallax
(`ParallaxBanner` layers, props) · Motion/Framer `useScroll`/`useTransform` (+ ScrollTimeline
nativo con fallback) · Aceternity UI (backgrounds, parallax, spotlight) · Magic UI (Backgrounds)
· shadcn.io/background (100 fondos canvas/CSS) · Chakra v3 `Bleed`/layout helpers · daisyUI
`hero`/`hero-overlay` · MDN «CSS scroll-driven animations» + soporte 2026 (Chrome/Edge 115+,
Safari 26+, Firefox flag/Interop 2026, ~84 %) · WCAG 2.2.2 / 2.3.3 / 1.4.3.
---
## 10. Brief de ejecución para el agente constructor (Opus 5)
> El agente lo ejecuta; **Fable supervisa** (§11). El brief es autosuficiente:
> el agente NO descubre la doctrina por arqueología — la lee en el orden de
> abajo. Todo lo que no esté aquí ni en los docs enlazados es una PREGUNTA al
> supervisor, nunca una decisión propia.
### 10.1 Precondiciones (no arranca sin ellas)
1. **D-BG.1–14 (§6) FIRMADAS por el autor** — una fila sin «FIRMADA» bloquea la
fase que dependa de ella (F1 ← D-BG.1/2/13/14 · F2 ← D-BG.4/5/6 · F3 ←
D-BG.3/7/9 · F5 ← D-BG.10). El agente empieza por la fase más baja cuyas
decisiones estén firmadas y PARA en la primera que no.
2. **Rama propia** para el trabajo (`alpha-0.1-background` o la que el autor
nombre): el árbol actual (`alpha-0.1-dir-prefs`) lleva cambios sin commitear
ajenos a esta pieza (`cta`, docs, `scripts/__scratch-*`, `web/routes/alpha/`)
— **no tocarlos, no incluirlos, no hacer `git stash`/`reset`/`checkout` de
nada**. Crear la rama la ordena el autor.
3. Dev server: el que ya corra en el árbol (`npm run dev`); no reiniciarlo
(`reference_dev_server_restart_orphans_browser`), no arrancar otro en un
worktree para `morfo:check` (403 por `fs.allow`).
### 10.2 Lectura obligatoria (el «paquete mínimo» de `component-audit.md` §0, más los ejes de esta pieza)
Orden y TODO entero, no «la parte relevante»:
1. `docs/building-a-component.md` → `docs/guides/component-guide.md` (Build
contract + Before You Start §1–5) → `docs/guides/completion-checklist.md` →
`docs/guides/demo-authoring.md` (LOCKED) → `docs/guides/component-audit.md`
→ `docs/CANON.md` → `docs/architecture/morfo.md` + `soma.md` (§2 membresía)
→ `docs/canon/vocabularies.md` → `src/uix/eidos/components/README.md`.
2. Ejes de la pieza: `docs/theming/reference.md` (§3, §6, §25, §39, §40) ·
`docs/canon/tsc.md` · `docs/canon/recipe-contract.md` ·
`docs/theming/gradient-finish.md` (§10 fronteras, D8, D12) ·
`docs/theming/motion.md` + `motion-guide.md` (§8 límites) ·
`docs/architecture/packs.md` · `docs/architecture/blocks.md` ·
`docs/decisions/design-text-effects.md` · `src/arts/adom/README.md`.
3. Código de referencia (los precedentes que se imitan, no se reinventan):
`eidos/components/backdrop/*` (se absorbe) · `surface/*` (Box+tratamiento,
`on`) · `image/*` (se compone) · `scroll-frames/*` (progreso de scroll por
ActiveDom) · `motion/*` (contexto `seen`) · `toggle/*` (se compone para la
pausa) · `blocks/hero/{README.md,hero.svelte}` (el consumidor que motiva
todo) · `packs/ambient/ambient.svelte` + `arts/scene/{README.md,types.ts}`
(el pack que se montará DENTRO) · `eidos/lib/render-css.ts`
(`renderOnContextBlocks`) · `eidos/lib/primitives/static.ts` (gradientes,
scrim/opacity/blur) · `morfo/components/{surface,scroll-frames,image}.ts`.
4. Este plan entero, incluidas las enmiendas de cabecera.
### 10.3 Reglas duras del agente (además de `CLAUDE.md`)
- **Nunca commitear ni pushear**; nunca borrar ficheros (incluido
`eidos/components/backdrop/`) sin orden escrita del autor transmitida por el
supervisor. Nunca `--no-verify`.
- **Morfo-first** y morfo MÍNIMO: partes `provider` (la pila) · `layer`
(`aria-hidden` literal) · `pause`; `texts` `pause`/`play` con claves
IDÉNTICAS al catálogo; **ningún knob visual en el morfo** (regla 2026-08-15,
`image.ts`); `as const satisfies Morfo`; `validateMorfo` en test.
- **Sin soma**, sin pack sema, `scope: ['eidos']`, 0 eventos + `## Passive
justification` en el README. Si en algún punto parece necesitar un evento →
PARAR y preguntar (antes se lee `architecture/sema.md`).
- **`Background` NO compone `Box`** ni acepta props de layout; es un hijo del
padre; el anfitrión se adopta por la regla de foundation `:where(:has(>
[data-background]))` emitida por el generador (no por el recipe). La pausa
es un segundo nodo raíz.
- **Cero props nuevas en `Box`**, cero cambios en recipes ajenos (Card,
Section, Dialog…) para «hacer sitio»: si un anfitrión no funciona, se
reporta con medición, no se parchea desde fuera.
- **Canon nunca importa `src/packs/`**; `Background.Layer` es una ranura. La
integración con `Ambient` (D-BG.8) es una tarea DEL PACK, separada, después.
- Recipe: tokens `background-*` en `lib/recipes/base.ts` (TSC: `root` /
`host`), `generate:eidos-css`; sin color crudo, sin `opacity` literal
(`--opacity-*`), sin `box-shadow` literal, sin `@keyframes` sin
`/* functional: … */`, sin `!important` sin `/* important: … */`, sin
`--eidos-*`, sin `will-change` (memoria: jitter a DPR fraccional). Migrar
`backdrop.css` LITERALMENTE con prefijo `--background-pattern-*`.
- DOM sólo por `ActiveEidos.require().dom` (`listen`, `raf`, `measure`,
`writeProperty`, `observeIntersection`, `observeResize`, `getWindow`,
`prefersReducedMotion`); lecturas de layout SIEMPRE dentro de `dom.raf`/
`dom.measure`, nunca síncronas tras una escritura. `CSS.supports` vía
`dom.getWindow(node).CSS`.
- Composición: `Background.Image` compone `<Image>`; `Background.Pause`
compone `<Toggle>`; nada de `<img>`/`<button>` crudos.
- Demo: plantilla v2 de 9 tabs, chips = uniones completas, `SemaPanel` en
vacío justificado, snippet con paridad; canario `button`.
- README con las secciones que el audit exige (Baseline · Comparativa ≥3 ·
Decisiones · Gaps con disposición · Passive justification · Audit
exceptions) — Baseline = `Backdrop` + layout `background` del hero + semillas
`web/routes/demos/{heroscrolling,animations/background}` (referencia, no se
portan).
- Comentarios en inglés; tabs; comillas simples; sin `console.log`; sin
español nuevo en código.
- **Ante cualquier contradicción entre docs, o entre docs y código, o ante
una decisión no cubierta por §6: PARAR, escribirla con cita
(`fichero:línea`) y devolverla al supervisor.** No inventar campos, tipos,
tokens ni mecanismos.
### 10.4 Entregable por fase (lo que el supervisor recibe)
Al cerrar cada fase el agente entrega, en su mensaje final: (a) lista de
ficheros creados/modificados; (b) salida LITERAL de cada gate de la fase
(comando + resultado); (c) qué verificó en navegador y cómo (ruta, qué midió,
valores) — para F1+ una captura por anfitrión (Section · Box-columna · Card
rounded×shape · Dialog.Content) y para F2 la medición de contraste con el
método del hero (píxeles pintados, no `rgb()` parseado a mano); (d) dudas y
contradicciones encontradas, con cita; (e) lo que quedó fuera y por qué. Sin
adjetivos: números y rutas.
### 10.5 Prompt de arranque (para el `Agent`, `model: opus`)
> «Lee ENTERO `docs/process/PLAN-background.md` (incluidas las enmiendas de
> cabecera y §10) y después, en ese orden, toda la lectura de §10.2. Comprueba
> en §6 qué D-BG están FIRMADAS. Ejecuta SOLO la fase más baja de §7 cuyas
> decisiones estén firmadas, respetando §10.3, y PARA al terminarla entregando
> §10.4. No commitees, no borres, no toques ficheros ajenos a la pieza. Si
> algo no está decidido o contradice la doctrina, para y devuélvelo con cita.»
---
## 11. Protocolo de supervisión (Fable)
Por cada fase entregada, en este orden y sin saltarse ninguno:
1. **Firma**: comprobar que la fase sólo usó decisiones FIRMADAS; si el agente
decidió algo por su cuenta → se retira antes de revisar nada más.
2. **Gates, re-ejecutados por el supervisor** (no se acepta el pegado del
agente): `npm run check` (filtrado a los paths de la pieza + hero) ·
`npx vitest run src/uix/eidos` (recipe-css-contract, component-api-contract,
component-visual-attrs, generated-css, gradient-finish-guard) ·
`npx vitest run src/arts/adom` (F0) · `node --import tsx/esm
scripts/component-audit.ts --only background` (F4: PASS 0 errores) ·
`node scripts/eidos-lint.ts background` (invalid 0) · `npm run rtl:check` ·
`npm run blocks:check` (F1+, hero) · `npm run translations:check` ·
`npm run docs:check` (F4) · `morfo:check` + `SMOKE_SCOPE=/uix/components/background npm run smoke` (F4, con dev server).
3. **Diff contra doctrina** (lectura del diff entero, no de resumen): morfo
sin knobs · sin Box · regla `:has` en el generador y no en el recipe ·
tokens con TSC · anotaciones R-4.x · ActiveDom sin globales · lecturas
post-layout · Image/Toggle compuestos · nada en `src/packs/` · nada en
recipes ajenos · nada borrado sin orden · sin shims.
4. **Navegador REAL** (Chrome; el pane oculto suspende rAF y miente): los
cuatro anfitriones de F1; en F2 el vídeo (autoplay muted, pausa por
teclado, `aria-pressed`, pausa fuera de vista y con pestaña oculta,
reduced-motion emulado → poster, reduced-data → poster) y el contraste en
píxeles; en F3 el parallax con y sin soporte de `animation-timeline`
(`@supports` forzado / flag de Chrome), el detector `uix.perf` de reflow a
0, RTL con `dir="rtl"` (puntero físico, scroll vertical) y reduced-motion →
estático.
5. **Encapsulación**: el pack sigue fuera del canon (grep de imports); borrar
`src/packs/` en un worktree de prueba deja `npm run check` verde (F1+).
6. **Informe al autor**: 3–6 líneas — qué entregó el agente, qué verifiqué,
qué falló (con `fichero:línea`), qué decide él. Si hay decisión nueva → las
5 preguntas, UNA por mensaje, y PARAR.
7. Sólo tras el «ok» del autor: siguiente fase al agente. Los commits los
ordena el autor; el supervisor los prepara (staged verificado, mensaje) y
no los ejecuta sin orden.

@ -105,6 +105,8 @@ export interface ActiveDom {
currentBreakpoint: Active<Breakpoint>;
/** Reactive `matchMedia('(prefers-reduced-motion: reduce)')`; `false` in SSR. */
prefersReducedMotion: { readonly matches: boolean };
/** Reactive `(prefers-reduced-data: reduce)` OR `navigator.connection.saveData`; `false` in SSR. */
prefersReducedData: { readonly matches: boolean };
resolve<T>(value: ResponsiveProp<T> | undefined): T | undefined;
isAtLeast(breakpoint: Breakpoint): boolean;
matches(breakpoint: Breakpoint): boolean;
@ -311,6 +313,7 @@ primitives (0-dep ports of `runed`, fixed to respect the `targetWindow` via
| `IsDocumentVisible` | is the tab visible? (`visibilitychange`) |
| `IsFocusWithin` | is focus inside an element? |
| `IsInViewport` | does a node intersect the viewport? (`IntersectionObserver`) |
| `ScrollProgress` | 0→1 progress of a node across its scrollport (the `view()` range) |
| `IsIdle` | user inactivity after N ms without interaction |
| `PressedKeys` | reactive set of currently-pressed keys |
| `ElementRect` | reactive `DOMRect` of an element |
@ -410,6 +413,42 @@ Record of surface changes after the foundational phase. Each entry documents
**what** was added and, above all, **why** — so the decision is not lost and
future consumers understand the canonical pattern.
### 2026-08-17 — `ScrollProgress` + `dom.prefersReducedData`: the two ports a background needs
**What.** Two additions, both from the `Background` initiative
(`docs/process/PLAN-background.md`, decision D-BG.7):
- **`ScrollProgress`** — a standalone reactive helper (the `IsInViewport`
family) reporting a node's 0→1 progress across its scrollport, coalescing
its reads into one animation frame per scroll burst. Block axis, full cover
range.
- **`ActiveDom.prefersReducedData`** — a second preference port beside
`prefersReducedMotion`, reading `(prefers-reduced-data: reduce)` OR
`navigator.connection.saveData` (Chromium's Data Saver).
**Why.** Both were living as private code or not at all:
- The scroll-progress maths existed **once, privately**, inside
`eidos/components/scroll-frames` (listener + rAF + `observeResize`). A
second consumer arrived — the CSS-scroll-driven parallax needs a JS fallback
for engines without `animation-timeline` (Firefox, still flagged in 2026-08)
— and a second private copy is how a repo grows two subtly different
answers to one question. The helper is the shared one; ScrollFrames keeps
its own until it migrates (a separate, consumer-driven step).
- There was **no port for the data preference at all**, so a component that
wanted to skip a heavyweight fetch had to reach for `matchMedia` /
`navigator.connection` itself — exactly the global-window access this
artifact exists to own. It is the WEIGHT counterpart of the MOTION
preference: honored by not fetching the expensive thing (a background video
stays on its poster), never by hiding content.
**Discipline.** `ScrollProgress` measures inside `requestAnimationFrame`
resolved from the node's own window (iframe / popup safe), never
synchronously after a write. `prefersReducedData` mirrors the reduced-motion
tracker byte for byte: per-instance by default, singleton under
`shareViewport`, `false` when neither source exists (SSR / Node), disposed
with the instance.
### 2026-06-29 — `dom.measure(read, node?)`: coalesced post-layout read
**What.** New method on `ActiveDom`:

@ -23,6 +23,11 @@ import {
getSharedReducedMotion,
type ReducedMotionTracker
} from './reduced-motion.svelte.js';
import {
createReducedDataTracker,
getSharedReducedData,
type ReducedDataTracker
} from './reduced-data.svelte.js';
export type ActiveDomProps = {
breakpoints?: Active<Partial<Breakpoints>>;
@ -90,6 +95,17 @@ export interface ActiveDom {
* `a11ySemantic.reducedMotionFallback` (book §9).
*/
prefersReducedMotion: { readonly matches: boolean };
/**
* Live read of the user's "spend less data" preference — the standard
* `(prefers-reduced-data: reduce)` query OR Chromium's
* `navigator.connection.saveData`. Reactive, and `false` in SSR / Node
* tests where neither exists.
*
* The counterpart of `prefersReducedMotion` for WEIGHT rather than
* movement: a consumer honors it by not fetching the expensive thing
* (a background video stays on its poster), never by hiding content.
*/
prefersReducedData: { readonly matches: boolean };
resolve<T>(value: ResponsiveProp<T> | undefined): T | undefined;
isAtLeast(breakpoint: Breakpoint): boolean;
matches(breakpoint: Breakpoint): boolean;
@ -248,6 +264,10 @@ export function createActiveDom(props: ActiveDomProps = {}): ActiveDom {
? getSharedReducedMotion()
: createReducedMotionTracker(props.targetWindow);
const data: ReducedDataTracker = props.shareViewport
? getSharedReducedData()
: createReducedDataTracker(props.targetWindow);
const breakpoints = readableActive(() => ({
...BREAKPOINTS_DEFAULT,
...props.breakpoints?.current
@ -269,6 +289,12 @@ export function createActiveDom(props: ActiveDomProps = {}): ActiveDom {
}
};
const reducedDataReadonly: { readonly matches: boolean } = {
get matches() {
return data.matches;
}
};
let disposed = false;
// Coalesced post-layout read queue (FastDOM-style, reads only). `measure`
@ -363,6 +389,7 @@ export function createActiveDom(props: ActiveDomProps = {}): ActiveDom {
viewport: viewportReadonly,
currentBreakpoint,
prefersReducedMotion: reducedMotionReadonly,
prefersReducedData: reducedDataReadonly,
resolve<T>(value: ResponsiveProp<T> | undefined): T | undefined {
return resolveResponsiveProp(value, tracker.width, breakpoints.current);
},
@ -607,6 +634,7 @@ export function createActiveDom(props: ActiveDomProps = {}): ActiveDom {
measureQueues.clear();
tracker.dispose();
motion.dispose();
data.dispose();
}
};
}

@ -81,6 +81,8 @@ export { TextareaAutosize } from './textarea-autosize.svelte.js';
export type { TextareaAutosizeOptions } from './textarea-autosize.svelte.js';
export { onClickOutside } from './on-click-outside.svelte.js';
export type { OnClickOutsideOptions } from './on-click-outside.svelte.js';
export { ScrollProgress } from './scroll-progress.svelte.js';
export type { ScrollProgressOptions } from './scroll-progress.svelte.js';
export { ScrollState } from './scroll-state.svelte.js';
export type { ScrollStateOptions } from './scroll-state.svelte.js';

@ -0,0 +1,95 @@
import { isBrowser } from '$libs/dom';
/**
* Tracks the user's "spend less data" preference as a reactive boolean, from
* the two sources a browser exposes today:
*
* - `(prefers-reduced-data: reduce)` — the CSS media query (the standard one;
* not Baseline yet, so it is absent on most engines);
* - `navigator.connection.saveData` — the Network Information API's Data Saver
* flag (Chromium-only, not in TypeScript's DOM lib, hence the structural
* type below).
*
* Either one turns it on: they say the same thing through different doors, and
* a consumer that honors only the standard query would ignore every Data Saver
* user on the browser that actually ships the flag.
*
* Same shape as {@link ReducedMotionTracker}: a per-instance tracker scoped to
* a target window plus a process-wide singleton, both reactive (`$state`), both
* SSR-safe — without `matchMedia` and without `navigator.connection` the value
* stays `false` forever and `dispose()` is a no-op.
*/
export interface ReducedDataTracker {
readonly matches: boolean;
dispose: () => void;
}
/**
* The slice of the Network Information API this tracker reads. Declared
* structurally because `navigator.connection` is not in TypeScript's DOM lib.
*/
interface SaveDataConnection {
readonly saveData?: boolean;
addEventListener?: (type: 'change', listener: () => void) => void;
removeEventListener?: (type: 'change', listener: () => void) => void;
}
// ── Shared singleton ────────────────────────────────────────────────────────
let shared: ReducedDataTracker | undefined;
export function getSharedReducedData(): ReducedDataTracker {
if (shared !== undefined) return shared;
shared = createReducedDataTracker();
// Override dispose — singleton is process-lifetime.
const baseDispose = shared.dispose;
shared.dispose = () => {
void baseDispose;
// no-op
};
return shared;
}
// ── Per-instance tracker ────────────────────────────────────────────────────
const QUERY = '(prefers-reduced-data: reduce)';
export function createReducedDataTracker(targetWindow?: Window): ReducedDataTracker {
const win = targetWindow ?? (isBrowser ? window : undefined);
const local = $state({ matches: false });
const connection = (win?.navigator as { connection?: SaveDataConnection } | undefined)
?.connection;
let mql: MediaQueryList | undefined;
if (win && typeof win.matchMedia === 'function') mql = win.matchMedia(QUERY);
const read = (): boolean => Boolean(mql?.matches) || Boolean(connection?.saveData);
const update = (): void => {
local.matches = read();
};
update();
const detachers: (() => void)[] = [];
if (mql) {
mql.addEventListener('change', update);
detachers.push(() => mql?.removeEventListener('change', update));
}
// The Data Saver flag can flip mid-session (the user toggles it, or the
// connection type changes); Chromium reports it on the connection object.
if (connection?.addEventListener && connection.removeEventListener) {
connection.addEventListener('change', update);
detachers.push(() => connection.removeEventListener?.('change', update));
}
return {
get matches() {
return local.matches;
},
dispose: () => {
for (const detach of detachers) detach();
detachers.length = 0;
}
};
}

@ -0,0 +1,94 @@
/** Deep tests for `ScrollProgress`. Browser project (real layout + scrolling). */
import { afterEach, describe, expect, it, vi } from 'vitest';
import { flushSync } from 'svelte';
import { ScrollProgress } from './scroll-progress.svelte';
let scene: HTMLElement | undefined;
function mountScene(): HTMLElement {
scene = document.createElement('div');
scene.innerHTML = [
'<div data-lead style="height: 300vh"></div>',
'<div data-target style="height: 100px"></div>',
'<div data-trail style="height: 300vh"></div>'
].join('');
document.body.appendChild(scene);
return scene.querySelector('[data-target]') as HTMLElement;
}
afterEach(() => {
scene?.remove();
scene = undefined;
window.scrollTo(0, 0);
});
describe('ScrollProgress', () => {
it('reads 0 while the node still sits below the scrollport', async () => {
const target = mountScene();
let progress = -1;
const stop = $effect.root(() => {
const scroll = new ScrollProgress(() => target);
$effect(() => {
progress = scroll.current;
});
flushSync();
});
await vi.waitFor(() => {
flushSync();
expect(progress).toBe(0);
});
stop();
});
it('reaches 1 once the node has fully crossed the scrollport', async () => {
const target = mountScene();
let progress = -1;
const stop = $effect.root(() => {
const scroll = new ScrollProgress(() => target);
$effect(() => {
progress = scroll.current;
});
flushSync();
});
window.scrollTo(0, document.documentElement.scrollHeight);
await vi.waitFor(() => {
flushSync();
expect(progress).toBe(1);
});
stop();
});
it('lands mid-range while the node is crossing, and stays clamped', async () => {
const target = mountScene();
let progress = -1;
const stop = $effect.root(() => {
const scroll = new ScrollProgress(() => target);
$effect(() => {
progress = scroll.current;
});
flushSync();
});
// Put the target's top exactly at the middle of the viewport: it has
// travelled half the scrollport out of a (scrollport + node) total.
const top = target.getBoundingClientRect().top + window.scrollY;
window.scrollTo(0, top - window.innerHeight / 2);
const expected =
window.innerHeight / 2 / (window.innerHeight + target.getBoundingClientRect().height);
await vi.waitFor(() => {
flushSync();
expect(progress).toBeCloseTo(expected, 2);
});
expect(progress).toBeGreaterThan(0);
expect(progress).toBeLessThan(1);
stop();
});
});

@ -0,0 +1,109 @@
import { getWindow } from '$libs/dom';
// Reactive scroll progress of a node across its scrollport — the JS twin of the
// CSS `animation-timeline: view()` range, for engines that do not support
// scroll-driven animations yet (Firefox is still behind a flag as of 2026-08).
//
// The travel it measures is the FULL cover range, the same one `view()` spans by
// default (`animation-range: entry 0% exit 100%`):
//
// 0 — the node's leading edge is at the scrollport's trailing edge (about to enter)
// 1 — the node's trailing edge is at the scrollport's leading edge (fully gone)
//
// so a consumer can feed `current` straight into the same `calc()` the CSS path
// uses, and the two agree frame by frame.
//
// Block axis only, deliberately: `view()` defaults to the block axis too, and the
// only consumer today (background parallax) travels vertically. An inline-axis
// mode is a second consumer's decision, not a speculative option here.
//
// Reads are coalesced into one animation frame per scroll burst (the
// ScrollFrames pattern): a scroll event already fires post-layout, and batching
// keeps a fast scroll from measuring N times per frame.
type MaybeElement = HTMLElement | null | undefined;
type ElementGetter = MaybeElement | (() => MaybeElement);
export interface ScrollProgressOptions {
/**
* The scroll container. Defaults to the node's own window (page scroll).
* Pass an element when the node scrolls inside a container instead.
*/
root?: MaybeElement | (() => MaybeElement);
}
export class ScrollProgress {
#progress = $state(0);
#node: ElementGetter;
#root: ScrollProgressOptions['root'];
constructor(node: ElementGetter, options: ScrollProgressOptions = {}) {
this.#node = node;
this.#root = options.root;
$effect(() => {
const el = this.#resolve(this.#node);
if (!el) return;
const root = this.#resolve(this.#root);
const win = getWindow(el);
const scroller: HTMLElement | Window = root ?? win;
let frame: number | null = null;
const compute = (): void => {
frame = null;
const rect = el.getBoundingClientRect();
// Scrollport box: the root's own box, or the viewport.
const portTop = root ? root.getBoundingClientRect().top : 0;
const portHeight = root ? root.clientHeight : win.innerHeight;
// Total travel = the scrollport plus the node itself: from "leading
// edge at the trailing edge of the port" to "trailing edge at the
// leading edge of the port".
const travel = portHeight + rect.height;
if (travel <= 0) {
this.#progress = 0;
return;
}
const advanced = portHeight - (rect.top - portTop);
this.#progress = Math.min(1, Math.max(0, advanced / travel));
};
const schedule = (): void => {
if (frame === null) frame = win.requestAnimationFrame(compute);
};
// The FIRST measurement is scheduled too, not taken here: this effect body
// runs inside the flush that just wrote the DOM, so a read at this point
// is the sync-after-write ordering the framework forbids (hard rule 5 —
// `active-architecture.md` §7). Deferring costs one frame and nothing else:
// the field starts at 0 and no consumer reads it before paint.
schedule();
scroller.addEventListener('scroll', schedule, { passive: true });
win.addEventListener('resize', schedule);
const ResizeObserverCtor = (win as unknown as { ResizeObserver?: typeof ResizeObserver })
.ResizeObserver;
const ro =
typeof ResizeObserverCtor === 'function' ? new ResizeObserverCtor(schedule) : undefined;
ro?.observe(el);
if (root) ro?.observe(root);
return () => {
if (frame !== null) win.cancelAnimationFrame(frame);
scroller.removeEventListener('scroll', schedule);
win.removeEventListener('resize', schedule);
ro?.disconnect();
};
});
}
#resolve(source: ElementGetter): MaybeElement {
return typeof source === 'function' ? source() : source;
}
/** Progress across the scrollport, clamped to 0–1. */
get current(): number {
return this.#progress;
}
}

@ -0,0 +1,146 @@
// @vitest-environment jsdom
import { describe, expect, it } from 'vitest';
import { createReducedDataTracker } from '../reduced-data.svelte';
type ChangeListener = () => void;
/**
* A window stub whose `matchMedia` is KEYED BY QUERY, like the sibling
* reduced-motion test and the prefs / eidos stubs. A stub that ignores the
* query cannot fail when the implementation asks for the wrong one — measured:
* with a query-blind stub, swapping `prefers-reduced-data` for
* `prefers-reduced-motion` in the tracker left the whole suite green.
*/
function stubWindow(options: {
queryMatches?: boolean;
saveData?: boolean;
withMatchMedia?: boolean;
withConnection?: boolean;
}): {
win: Window;
counts: { query: number; connection: number };
fireQuery: (matches: boolean) => void;
fireConnection: (saveData: boolean) => void;
} {
const counts = { query: 0, connection: 0 };
const queryListeners = new Set<(e: { matches: boolean }) => void>();
const connectionListeners = new Set<ChangeListener>();
const mql = {
matches: options.queryMatches ?? false,
addEventListener: (_type: 'change', listener: (e: { matches: boolean }) => void) => {
queryListeners.add(listener);
counts.query++;
},
removeEventListener: (_type: 'change', listener: (e: { matches: boolean }) => void) => {
queryListeners.delete(listener);
counts.query--;
}
};
const connection = {
saveData: options.saveData ?? false,
addEventListener: (_type: 'change', listener: ChangeListener) => {
connectionListeners.add(listener);
counts.connection++;
},
removeEventListener: (_type: 'change', listener: ChangeListener) => {
connectionListeners.delete(listener);
counts.connection--;
}
};
const win = {
matchMedia:
(options.withMatchMedia ?? true)
? (query: string) => {
// THE assertion that pins the query the tracker asks for.
expect(query).toBe('(prefers-reduced-data: reduce)');
return mql as unknown as MediaQueryList;
}
: undefined,
navigator: (options.withConnection ?? true) ? { connection } : {}
} as unknown as Window;
return {
win,
counts,
fireQuery: (matches: boolean) => {
mql.matches = matches;
for (const listener of queryListeners) listener({ matches });
},
fireConnection: (saveData: boolean) => {
connection.saveData = saveData;
for (const listener of connectionListeners) listener();
}
};
}
describe('ReducedDataTracker', () => {
it('is false when neither source asks for less data', () => {
const { win } = stubWindow({});
const tracker = createReducedDataTracker(win);
expect(tracker.matches).toBe(false);
tracker.dispose();
});
it('is true from the media query alone (and asks for the right query)', () => {
const { win } = stubWindow({ queryMatches: true });
const tracker = createReducedDataTracker(win);
expect(tracker.matches).toBe(true);
tracker.dispose();
});
it('is true from `navigator.connection.saveData` alone', () => {
// The Chromium door: the standard query is absent on most engines, so a
// tracker that read only the media query would ignore every Data Saver user.
const { win } = stubWindow({ withMatchMedia: false, saveData: true });
const tracker = createReducedDataTracker(win);
expect(tracker.matches).toBe(true);
tracker.dispose();
});
it('follows a mid-session change of EITHER source', () => {
const { win, fireQuery, fireConnection } = stubWindow({});
const tracker = createReducedDataTracker(win);
expect(tracker.matches).toBe(false);
fireQuery(true);
expect(tracker.matches).toBe(true);
fireQuery(false);
expect(tracker.matches).toBe(false);
fireConnection(true);
expect(tracker.matches).toBe(true);
fireConnection(false);
expect(tracker.matches).toBe(false);
tracker.dispose();
});
it('attaches one listener per source and detaches BOTH on dispose (idempotent)', () => {
const { win, counts, fireQuery, fireConnection } = stubWindow({});
const tracker = createReducedDataTracker(win);
expect(counts).toEqual({ query: 1, connection: 1 });
tracker.dispose();
expect(counts).toEqual({ query: 0, connection: 0 });
// Detached means deaf: a source that flips now must not move the value.
// The tracker sits at `false`, so the assertion discriminates.
fireQuery(true);
fireConnection(true);
expect(tracker.matches).toBe(false);
// Second dispose is a no-op.
expect(() => tracker.dispose()).not.toThrow();
});
it('falls back to false with neither matchMedia nor connection (SSR / Node)', () => {
const { win } = stubWindow({ withMatchMedia: false, withConnection: false });
const tracker = createReducedDataTracker(win);
expect(tracker.matches).toBe(false);
expect(() => tracker.dispose()).not.toThrow();
});
});

@ -348,6 +348,11 @@ function createDisabledActiveDom(): ActiveDom {
return false;
}
},
prefersReducedData: {
get matches() {
return false;
}
},
resolve<T>(value: ResponsiveProp<T> | undefined): T | undefined {
return resolveResponsiveProp(value, 0, BREAKPOINTS_DEFAULT);
},

@ -570,6 +570,43 @@ describe('ActiveEidos config', () => {
);
});
it('adopts any parent of a `<Background>` as its host (D-BG.14), at specificity 0', () => {
const css = createThemeBaseEidos().renderStaticCss();
// The stack is `position: absolute; z-index: -1` and lives as a CHILD of
// the surface it dresses, so the parent must be positioned + isolated.
// The foundation adopts it — no recipe of the parent is patched, and no
// consumer has to remember `position: relative`.
expect(css).toContain(':where(:has(> [data-background])) {');
expect(css).toContain('position: relative;');
expect(css).toContain('isolation: isolate;');
// `:where()` is what keeps a recipe's own `position` (Dialog.Content's
// `fixed`) winning: the guard is the WRAPPER, not the declarations.
expect(css).not.toContain(':has(> [data-background]) {\n\tposition: relative;');
});
it('carries the `on` ink context from a Background to the surface hosting it', () => {
const css = createThemeBaseEidos().renderStaticCss();
// The attr sits on the STACK (a child), so the context reaches the host
// through `:has` — one rule, two selectors, the same declarations.
expect(css).toContain(
"[data-on='dark'], :where(:has(> [data-background][data-on='dark'])) {"
);
expect(css).toContain(
"[data-on='light'], :where(:has(> [data-background][data-on='light'])) {"
);
// And it re-binds the same four roles + the `color` property the direct
// stamp does — the twin adds a selector, never a second doctrine.
const darkBlock = css.slice(
css.indexOf("[data-on='dark'],"),
css.indexOf("[data-on='light'],")
);
expect(darkBlock).toContain('--color-content-primary: var(--color-content-on-solid);');
// The `color` PROPERTY too, not only the variables — without it everything
// resolving to `color: inherit` keeps the page's ink over the new canvas.
// (Emitted as the block's last declaration, so it carries no `;`.)
expect(darkBlock).toContain('color: var(--color-content-on-solid)');
});
it('applyDepth retunes planes at runtime (jaula-abierta depth builder)', () => {
const eidos = createThemeBaseEidos();
const result = eidos.applyDepth({

@ -4094,7 +4094,7 @@
--palette-finish-angle: var(--gradient-angle-to-b);
}
[data-on='dark'] {
[data-on='dark'], :where(:has(> [data-background][data-on='dark'])) {
--color-content-primary: var(--color-content-on-solid);
--color-content-secondary: color-mix(in oklch, var(--color-content-on-solid) 82%, transparent);
--color-content-muted: color-mix(in oklch, var(--color-content-on-solid) 64%, transparent);
@ -4102,7 +4102,7 @@
color: var(--color-content-on-solid)
}
[data-on='light'] {
[data-on='light'], :where(:has(> [data-background][data-on='light'])) {
--color-content-primary: var(--color-content-on-solid-contrast);
--color-content-secondary: color-mix(in oklch, var(--color-content-on-solid-contrast) 82%, transparent);
--color-content-muted: color-mix(in oklch, var(--color-content-on-solid-contrast) 64%, transparent);
@ -4110,6 +4110,11 @@
color: var(--color-content-on-solid-contrast)
}
:where(:has(> [data-background])) {
position: relative;
isolation: isolate;
}
[data-avatar] {
--_avatar-palette-solid: var(--avatar-neutral-solid-bg);
--_avatar-palette-surface: var(--avatar-neutral-soft-bg);

@ -360,6 +360,7 @@ export function renderStaticCss(options: EidosConfig): string {
renderBlock(':root', declarations),
...renderSharedPaletteLayer(lightSolidScales),
...renderOnContextBlocks(),
renderBackgroundHostBlock(),
...scopedRecipeBlocks,
...depthBlocks,
...shapeBlocks
@ -495,12 +496,52 @@ function renderOnContextBlocks(): string[] {
// `data-on` is enough and consumers stop writing the declaration themselves.
`color: var(${ink})`
];
// The twin selector carries the context from a `<Background on='…'>` to the
// surface that HOSTS it (D-BG.14). The stack is a CHILD of that surface, so
// the attr sits one level below the element whose content must be re-inked;
// `:has` walks it back up. Wrapped in `:where()` for the same reason as the
// host-adoption rule below — at specificity 0 a recipe's own `color` still
// wins, which is the documented D12 limit (a nested component resolves its
// OWN tokens), not a new one.
return [
renderBlock(`[data-on='dark']`, context('--color-content-on-solid')),
renderBlock(`[data-on='light']`, context('--color-content-on-solid-contrast'))
renderBlock(
`[data-on='dark'], :where(:has(> [data-background][data-on='dark']))`,
context('--color-content-on-solid')
),
renderBlock(
`[data-on='light'], :where(:has(> [data-background][data-on='light']))`,
context('--color-content-on-solid-contrast')
)
];
}
/**
* Background host adoption (D-BG.14 — `docs/process/PLAN-background.md`).
*
* `<Background>` renders as a CHILD of the surface it dresses, and its stack is
* `position: absolute; inset: 0; z-index: -1`. For that stack to sit behind the
* host's own content — instead of escaping to whatever distant positioned
* ancestor happens to exist — the host has to be a positioned, isolated box.
* This rule adopts ANY parent of a Background into that role WITHOUT touching
* it: a component cannot style its own parent from its recipe, and the
* alternative (asking every consumer to remember `position: relative`) is the
* footgun the references ship — the layers simply vanish, with nothing naming
* the cause.
*
* `:where()` zeroes the specificity ON PURPOSE: a recipe's own `position`
* (`Dialog.Content`'s `fixed`, an `Affix`'s `fixed`) keeps winning, and
* `isolation` alters no layout — it only stops the negative z-index from
* slipping behind an ancestor's own background. A parent with
* `display: contents` generates no box and therefore cannot host; that is
* documented, not patched.
*/
function renderBackgroundHostBlock(): string {
return renderBlock(':where(:has(> [data-background]))', [
'position: relative;',
'isolation: isolate;'
]);
}
/**
* Native CSS Anchor Positioning — the PRIMARY path of the in-house `$ethereal`
* positioner, behind `@supports`. When a baseline-2026 engine supports anchor

@ -0,0 +1,19 @@
import type { LangNode } from '$libs/langs';
/**
* Default strings for the Background component. Merged under
* `components.background.*` by `ActiveUix` (via the `componentLangs` barrel).
*
* Both name the SAME control in its two states — the pause toggle a background
* with self-moving layers must offer (WCAG 2.2.2).
*/
export const backgroundLangs = {
pause: {
es: 'Pausar el fondo',
en: 'Pause background'
},
play: {
es: 'Reanudar el fondo',
en: 'Play background'
}
} satisfies LangNode;

@ -6,6 +6,7 @@ import { alertDialogLangs } from './alert-dialog';
import { anchorNavLangs } from './anchor-nav';
import { announceLangs } from './announce';
import { auraLangs } from './aura';
import { backgroundLangs } from './background';
import { badgeLangs } from './badge';
import { barcodeLangs } from './barcode';
import { breadcrumbLangs } from './breadcrumb';
@ -139,6 +140,7 @@ export const componentLangs = {
'anchor-nav': anchorNavLangs,
announce: announceLangs,
aura: auraLangs,
background: backgroundLangs,
badge: badgeLangs,
barcode: barcodeLangs,
breadcrumb: breadcrumbLangs,

@ -0,0 +1,70 @@
import { describe, expect, it } from 'vitest';
import { validateMorfo } from '../schema';
import { backgroundMorfo } from './background';
import { backgroundLangs } from '../../langs/components/background';
describe('backgroundMorfo', () => {
it('passes shape + invariant validation', () => {
expect(() => validateMorfo(backgroundMorfo)).not.toThrow();
});
it('declares the three parts the component renders', () => {
const kebabs = backgroundMorfo.parts.map((p) => p.kebab).sort();
expect(kebabs).toEqual(['layer', 'pause', 'provider']);
});
it('is a passive eidos-only surface: no soma, no events', () => {
expect(backgroundMorfo.scope).toEqual(['eidos']);
// D-BG.2: the only user act in reach (pausing) belongs to the composed
// Toggle and is declared in THAT morfo. Declaring an event here would
// manufacture perception this component never emits.
expect('events' in backgroundMorfo).toBe(false);
});
it('declares no visual knob — every paint axis is an eidos wrapper attr', () => {
// The knobs (`data-kind`, `data-parallax`, `data-attach`, `data-opacity`,
// `data-pattern`, `data-paused`, …) are read by ONE layer, this
// component's own CSS, and no prop of theirs crosses a soma boundary —
// there is no soma. Same call `image.ts` records (2026-08-15).
// Affirm the cardinality first: a loop over an empty part list would pass
// while inspecting nothing.
expect(backgroundMorfo.parts).toHaveLength(3);
for (const part of backgroundMorfo.parts) {
expect(part.data, `${part.kebab} declares a data attr`).toEqual([]);
}
});
it('hides the layer from assistive tech (decoration is never content)', () => {
const layer = backgroundMorfo.parts.find((p) => p.kebab === 'layer')!;
expect(layer.aria).toEqual([
{ attr: 'aria-hidden', value: { kind: 'literal', value: 'true' } }
]);
// A plain display part pulls no shared interactive styling.
expect('archetype' in layer).toBe(false);
});
it('puts the pause control ON the composed button, not on a wrapper', () => {
// The Fab pattern (`fab.ts`): the SAME element carries `data-toggle` and
// `data-background-pause`, so the `action` archetype — which the
// foundation pays with a pointer cursor, a focus ring and a 44px coarse
// touch floor — lands on a real button instead of a placement div.
const pause = backgroundMorfo.parts.find((p) => p.kebab === 'pause')!;
expect(pause.defaultElement).toBe('button');
expect(pause.archetype).toBe('action');
expect(pause.role).toBe('button');
// The name is state-dependent (pause ↔ play), so it travels as a `texts`
// slot handed to the Toggle — never as a morfo naming default.
expect(pause.aria).toEqual([]);
});
it('keeps the `texts` keys identical to the catalog keys', () => {
// `texts` is a DECLARATION, never a lookup table: a key that differs from
// the catalog's resolves to the English fallback in silence (the chronos
// class of defect, 2026-08-10).
expect(Object.keys(backgroundMorfo.texts).sort()).toEqual(Object.keys(backgroundLangs).sort());
for (const [key, ref] of Object.entries(backgroundMorfo.texts)) {
expect(ref.startsWith(`#?components.background.${key}|`)).toBe(true);
}
});
});

@ -0,0 +1,96 @@
import type { Morfo } from '../types';
import { v } from '../types';
/**
* Background — the host of a surface's background LAYERS: image, video,
* pattern, gradient, scrim, or an app-supplied scene mounted in a generic
* layer. Decorative by construction; the content it sits behind belongs to
* whoever renders it.
*
* Eidos-native. Structurally it is NOT a box in the flow: `<Background>` is
* rendered as a CHILD of the surface it dresses (a Section, a Card, a
* `Dialog.Content`, a Grid column, an `<li>`), and the stack itself is
* absolutely positioned behind that parent's content. The parent becomes the
* host without being touched: the foundation emits
* `:where(:has(> [data-background])) { position: relative; isolation: isolate }`,
* so no recipe of the parent is patched and no wrapper is introduced
* (decision D-BG.14 — `docs/process/PLAN-background.md`).
*
* VISUAL KNOBS ARE NOT DECLARED HERE. `data-kind`, `data-parallax`,
* `data-attach`, `data-blend`, `data-opacity`, `data-fade`, `data-pattern`,
* `data-color`, `data-paused` … are eidos WRAPPER attrs: this component has no
* soma layer, so none of their props crosses the soma boundary — the condition
* the thumb rule names (`theming/reference.md` §39; the same call `image.ts`
* records for its own knobs, 2026-08-15). Morfo is the CROSS-LAYER contract;
* a paint knob is read by one layer, this component's own CSS.
*
* Justification for the 0-event surface: a background paints and reacts to
* 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`).
*
* 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
* (pause ↔ play), which `architecture/morfo.md` §Step 4 lists as a naming
* default that cannot live in the morfo.
*/
export const backgroundMorfo = {
name: 'Background',
kebab: 'background',
scope: ['eidos'],
texts: {
pause: '#?components.background.pause|Pause background',
play: '#?components.background.play|Play background'
},
parts: [
{
// The stack: absolutely positioned behind the host's content, clipped
// to it, and inheriting its radius. Every layer lives inside.
name: 'Provider',
kebab: 'provider',
archetype: 'provider',
kind: 'public',
defaultElement: 'div',
optional: false,
data: [],
aria: []
},
{
// ONE part for every layer — image, video, pattern, gradient, scrim or
// a generic one holding an app-supplied scene. Which treatment it
// paints is an eidos wrapper attr, not a contract axis.
//
// No archetype: a layer is a plain display part and must pull none of
// the shared interactive styling (`overlay` is the modal veil and would
// drag its treatment; `content` overrides `position`).
name: 'Layer',
kebab: 'layer',
kind: 'public',
defaultElement: 'div',
optional: true,
data: [],
// Decoration is never content: the meaning lives in the page around it.
aria: [{ attr: 'aria-hidden', value: v.literal('true') }]
},
{
// 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
// morfo REFERENCES them, it does not re-stamp them.
name: 'Pause',
kebab: 'pause',
archetype: 'action',
kind: 'public',
defaultElement: 'button',
role: 'button',
optional: true,
data: [],
aria: []
}
]
} as const satisfies Morfo;
Loading…
Cancel
Save

Powered by TurnKey Linux.