F2 de PLAN-background.md. Entran las tres piezas que le faltaban al anfitrión: `Image` (compone el `<Image>` canónico, una fuente por modo de tema, `priority` para el caso LCP), `Video` (con las cinco políticas que deciden si suena una sola trama) y `Pause`, el control que WCAG 2.2.2 le debe al lector cuando algo se mueve solo. Ninguna de las cinco políticas del vídeo es del consumidor: fuera de vista, pestaña oculta, movimiento reducido, datos reducidos y la pausa. El elemento se gobierna con `play()`/`pause()` y no con `autoplay` porque cuatro de las cinco son estado que va y vuelve, y el atributo es un disparo único al parsear. El hero se queda SIN CSS. Su layout `background` era tres capas `Box` a mano, un scrim inline y una hoja con marcador `/* justified: */` para el cover-fit del media; las tres cosas son ya canon, así que el bloque pierde su única excepción D-BLK.2. También pierde el `data-on` manual: `<Background on="dark">` lo estampa en la pila y el gemelo de foundation se lo da al anfitrión — medido, el título y la bajada resuelven `rgb(255,255,255)` sin que el bloque lo recuerde. Tres decisiones que la ejecución obligó a tomar, todas firmadas: - **D-BG.17 — el control explícito viaja por un snippet.** La pila es `z-index: -1` con `pointer-events: none` y su propio contexto de apilamiento: cualquier control escrito DENTRO pinta detrás del contenido y deja de ser un control, pero registrarse en el contexto exige ser descendiente. El snippet `pause` rompe el nudo con el único mecanismo que ya tenía precedente (`Toggle.icon`, `Image.fallback`), y suministrarlo suprime el default igual que `Switch` elige entre su snippet y su propio thumb. - **D-BG.18 — la etiqueta CAMBIA y no hay `aria-pressed`.** El APG ofrece dos formas de nombrar un control de dos estados y hacer las dos anuncia el estado por partida doble, en dos lecturas que se contradicen. Es la forma que `MediaPlayer.PlayButton` ya usa para el mismo acto. - **D-BG.15 completa.** Un fondo no reporta su fallo: se aparta y la capa de debajo ES la composición. Pero si la capa todavía tiene algo que enseñar —un snippet `error` del app, o el póster de un vídeo— la capa SE QUEDA (`data-has-fallback`). Sin eso los snippets eran inalcanzables por construcción, y un `<video>` fallido se llevaba por delante el póster que la decisión promete conservar. Y tres defectos que sólo aparecieron al medirlos en Chrome: - **`effect_update_depth_exceeded`**: una capa que se registraba desde un `$effect` escribía el contador del padre en fase de efectos y el flush no cerraba. No era ruido — mataba el efecto raíz: el botón se pintaba y todo clic posterior en la superficie se ignoraba en silencio. La cura es la ley que el framework ya tenía escrita, A30 (`anchor-nav-provider.svelte.ts`): registrar desde el init, nunca desde un efecto reactivo. Bisecado: ni `untrack` solo ni mover la lectura a otro componente lo arreglaban. - **El control caía 18px fuera del anfitrión**: el `<Button>` compuesto declara `position: relative` en `[data-button]`, misma especificidad que un `[data-background-pause]` pelado, así que decidía el orden de hojas del bundler. Fijado a dos atributos. - **Un `<img>` pelado no se estiraba** dentro de una capa. La capa es ahora una rejilla de una celda —así cualquier hijo la llena sin que la receta escriba tamaños sobre contenido ajeno— y el media se dimensiona aparte, porque `stretch` no aplica a elementos reemplazados. Medido en Chrome real: el control por defecto y el de snippet alternan etiqueta, escriben `data-paused` y congelan de verdad la deriva; sin capa que se mueva no hay control; una imagen rota oculta su capa y el patrón de debajo sigue pintando; un vídeo roto conserva su póster; `Surface`, `<Image>` compuesto, `<img>` pelado y el backdrop real del hero llenan su capa. Queda por verificar con Chrome visible: el vídeo REPRODUCIÉNDOSE y las políticas de movimiento/datos reducidos. En un panel oculto el IntersectionObserver está suspendido y las media features no se pueden emular, así que las políticas 1 y 2 se comprobaron RETENIÉNDOLO y las otras no se dan por probadas. Dos decisiones del autor quedan abiertas en el README (§Gaps): la escala `strength` del scrim no está ordenada por peso (`subtle` 0.80 vela más que `overlay` 0.65, y `overlay` ≡ `muted`), y sobre una foto clara ningún peso llega a AA — 2.10:1 el default, 4.42:1 el más fuerte. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>alpha-0.1-background
parent
b7474e499d
commit
33031c2ad9
@ -0,0 +1,91 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Eidos `<Background.Image>` — a photograph as a layer of the stack.
|
||||
*
|
||||
* <Background on="dark">
|
||||
* <Background.Image src={photo} sources={{ dark: photoNight }} priority />
|
||||
* <Background.Scrim />
|
||||
* </Background>
|
||||
*
|
||||
* It composes the canonical `<Image>`: the load cycle, the `<img>`
|
||||
* passthrough and the Fallback / Error slots are ITS contract, already built
|
||||
* and already audited. What this layer changes are the DEFAULTS, because a
|
||||
* background fails differently from a portrait (D-BG.15):
|
||||
*
|
||||
* - `placeholder='none'` — a full-bleed skeleton shimmering behind a hero's
|
||||
* copy is worse than a plain surface. The layer is simply transparent
|
||||
* until the file lands, and whatever sits below it shows through.
|
||||
* - no error glyph — a decoration does not report its own failure. On error
|
||||
* the layer hides (`data-status='error'`, recipe) and the stack degrades
|
||||
* to the layer beneath: THE STACK IS THE FALLBACK. That is why the source
|
||||
* order reads "cheap first, media on top". An app that DOES want something
|
||||
* there passes `error` / `fallback`, and then the layer STAYS: it stamps
|
||||
* `data-has-fallback` so the hide rule stands down. Without that the two
|
||||
* snippets were unreachable by construction — the layer hid them along
|
||||
* with the broken image.
|
||||
* - no `alt`, on purpose — every layer is `aria-hidden` by the morfo, so an
|
||||
* accessible name here would be a promise the tree never keeps. A
|
||||
* photograph that MEANS something is content, and content is not a
|
||||
* background.
|
||||
*/
|
||||
import { Image } from '$uix/eidos/components/image';
|
||||
import { ActiveEidos } from '$uix/eidos';
|
||||
import Layer from './background-layer.svelte';
|
||||
import type { BackgroundImageProps, BackgroundImageStatus } from './types';
|
||||
|
||||
let {
|
||||
src,
|
||||
sources,
|
||||
srcset,
|
||||
sizes,
|
||||
fit = 'cover',
|
||||
position = 'center',
|
||||
priority = false,
|
||||
fallback,
|
||||
error,
|
||||
...restProps
|
||||
}: BackgroundImageProps = $props();
|
||||
|
||||
const eidos = ActiveEidos.require();
|
||||
|
||||
// D-BG.5 — the FRAMEWORK's mode, not the OS's. `mode` is a visual source of
|
||||
// ActiveEidos (an app toggle, a persisted pref, a local `data-mode`) and it
|
||||
// can diverge from `prefers-color-scheme`, which is all a `<picture media>`
|
||||
// can see. Reading the theme context subscribes to that source when it is
|
||||
// reactive, so a light/dark switch re-resolves without a remount.
|
||||
const mode = $derived(eidos.getThemeContext().mode);
|
||||
const resolvedSrc = $derived(sources?.[mode] ?? src);
|
||||
|
||||
// A hero's background IS the LCP element; a decoration three sections down is
|
||||
// not. One boolean picks the pair the browser needs, and no consumer has to
|
||||
// remember which two attributes go together.
|
||||
const loading = $derived<'eager' | 'lazy'>(priority ? 'eager' : 'lazy');
|
||||
const fetchpriority = $derived<'high' | 'auto'>(priority ? 'high' : 'auto');
|
||||
|
||||
let status = $state<BackgroundImageStatus>('idle');
|
||||
</script>
|
||||
|
||||
<Layer
|
||||
{...restProps}
|
||||
data-kind="image"
|
||||
data-status={status}
|
||||
data-has-fallback={error || fallback ? '' : undefined}
|
||||
>
|
||||
<!-- The SHORTHAND path deliberately: handing `<Image>` children replaces its
|
||||
whole body, inner `<img>` included, so the two slots travel as the
|
||||
`fallback` / `errorFallback` props Image already exposes for this. -->
|
||||
<Image
|
||||
src={resolvedSrc}
|
||||
alt=""
|
||||
{srcset}
|
||||
{sizes}
|
||||
{fit}
|
||||
{position}
|
||||
{loading}
|
||||
{fetchpriority}
|
||||
placeholder="none"
|
||||
{fallback}
|
||||
errorFallback={error}
|
||||
bind:imageStatus={status}
|
||||
/>
|
||||
</Layer>
|
||||
@ -0,0 +1,65 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Eidos `<Background.Pause>` — the control a background OWES the reader when
|
||||
* something in it moves on its own (WCAG 2.2.2: content that starts
|
||||
* automatically, lasts more than five seconds and sits alongside other
|
||||
* content must have a way to pause it).
|
||||
*
|
||||
* It is rendered for you: the stack counts its self-moving layers and emits
|
||||
* this as its SECOND ROOT NODE when the count is above zero. Compose it by
|
||||
* hand only to move or restyle it, through the `pause` snippet:
|
||||
*
|
||||
* <Background>
|
||||
* <Background.Video src={clip} />
|
||||
* {#snippet pause()}<Background.Pause placement="bottom-start" />{/snippet}
|
||||
* </Background>
|
||||
*
|
||||
* Shape — `IconButton`, a label that CHANGES, and no `aria-pressed`
|
||||
* (D-BG.18). The APG separates the two ways to name a two-state control: a
|
||||
* fixed label with `aria-pressed`, or a label that swaps without it. Doing
|
||||
* both makes a screen reader announce the state twice, and the two readings
|
||||
* contradict each other ("Pause, pressed" — pressed meaning what?). The
|
||||
* framework already picked the second form in `MediaPlayer.PlayButton`, and
|
||||
* the same act must behave the same way twice.
|
||||
*
|
||||
* It is persistent, never hover-only: a control that appears on hover does
|
||||
* not exist for a touch screen, and it is the keyboard user reaching it
|
||||
* first who needs it most.
|
||||
*/
|
||||
import { ActiveEidos } from '$uix/eidos';
|
||||
import { IconButton } from '$uix/eidos/components/icon-button';
|
||||
import { Pause, Play } from '$uix/eidos/components/icon';
|
||||
import { BACKGROUND_LANGS } from './langs';
|
||||
import { getBackgroundContext } from './context';
|
||||
import type { BackgroundPauseProps } from './types';
|
||||
|
||||
// `size` / `variant` are NOT re-defaulted here: they are the canon's
|
||||
// (`md`, `ghost`), the same pair `MediaPlayer.PlayButton` composes for the
|
||||
// same act. Inventing a smaller default would make the two controls of one
|
||||
// gesture look like two different things.
|
||||
let { placement = 'top-end', variant = 'ghost', ...restProps }: BackgroundPauseProps = $props();
|
||||
|
||||
const eidos = ActiveEidos.require();
|
||||
const stack = getBackgroundContext();
|
||||
|
||||
const paused = $derived(stack?.paused ?? false);
|
||||
// The label names what the press WILL DO, which is why it swaps: while the
|
||||
// background moves the button offers "pause", once stopped it offers "play".
|
||||
const label = $derived(eidos.langs.ts(paused ? BACKGROUND_LANGS.PLAY : BACKGROUND_LANGS.PAUSE));
|
||||
|
||||
// The glyph takes a resolved size: `size` may arrive responsive (Button's
|
||||
// scale is a subset of Icon's, so the resolved value always fits).
|
||||
const glyphSize = $derived(eidos.resolve(restProps.size, 'md'));
|
||||
</script>
|
||||
|
||||
<IconButton
|
||||
{...restProps}
|
||||
{variant}
|
||||
color="neutral"
|
||||
aria-label={label}
|
||||
onclick={() => stack?.toggle()}
|
||||
data-background-pause=""
|
||||
data-placement={placement}
|
||||
>
|
||||
{#if paused}<Play size={glyphSize} />{:else}<Pause size={glyphSize} />{/if}
|
||||
</IconButton>
|
||||
@ -0,0 +1,154 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Eidos `<Background.Video>` — a moving image as a layer of the stack.
|
||||
*
|
||||
* <Background on="dark">
|
||||
* <Background.Video src={clip} poster={still} sources={{ dark: clipNight }} />
|
||||
* <Background.Scrim />
|
||||
* <Background.Pause />
|
||||
* </Background>
|
||||
*
|
||||
* Five policies decide whether it plays, and NONE of them is the consumer's
|
||||
* to remember. A background video that ignores them is the single most
|
||||
* expensive decoration on the web: it burns battery behind a scrolled-past
|
||||
* section, keeps decoding in a hidden tab, moves under the eyes of someone
|
||||
* who asked for stillness, and arrives over a metered connection nobody
|
||||
* offered to pay for.
|
||||
*
|
||||
* 1. OUT OF VIEW — the stack's `seen` (one observer for all layers)
|
||||
* 2. HIDDEN DOCUMENT — `IsDocumentVisible`; a background tab decodes nothing
|
||||
* 3. REDUCED MOTION — never autoplays; the poster stands in (WCAG 2.3.3)
|
||||
* 4. REDUCED DATA — `prefers-reduced-data` / `saveData`: the file is not
|
||||
* even requested, the poster is the whole layer
|
||||
* 5. PAUSED — the user pressed the control the stack owes them
|
||||
*
|
||||
* Why the element is driven by hand instead of by `autoplay`: the attribute
|
||||
* is a one-shot at parse time, and four of the five policies are RUNTIME
|
||||
* state that flips both ways. `play()` / `pause()` from an effect is the only
|
||||
* shape that can follow them.
|
||||
*
|
||||
* There is no state contract for `<video>` in the framework (`ImageProvider`
|
||||
* covers `<img>` only, and ScrollFrames listens by hand). With TWO consumers
|
||||
* this is a candidate for a shared `soma/layers` port — flagged, not invented
|
||||
* here (D-BG.15): v1 listens locally, exactly as ScrollFrames does.
|
||||
*/
|
||||
import { untrack } from 'svelte';
|
||||
import { IsDocumentVisible } from '$adom';
|
||||
import { ActiveEidos } from '$uix/eidos';
|
||||
import { getBackgroundContext } from './context';
|
||||
import Layer from './background-layer.svelte';
|
||||
import type { BackgroundVideoProps, BackgroundMediaStatus } from './types';
|
||||
|
||||
let {
|
||||
src,
|
||||
sources,
|
||||
poster,
|
||||
loop = true,
|
||||
muted = true,
|
||||
playsinline = true,
|
||||
preload = 'metadata',
|
||||
...restProps
|
||||
}: BackgroundVideoProps = $props();
|
||||
|
||||
const eidos = ActiveEidos.require();
|
||||
const stack = getBackgroundContext();
|
||||
|
||||
let video = $state<HTMLVideoElement | null>(null);
|
||||
let status = $state<BackgroundMediaStatus>('idle');
|
||||
|
||||
const documentVisible = new IsDocumentVisible();
|
||||
|
||||
const mode = $derived(eidos.getThemeContext().mode);
|
||||
const resolvedSrc = $derived(sources?.[mode] ?? src);
|
||||
|
||||
// Policy 4 — the file is not requested at all. A poster is one image; a clip
|
||||
// is megabytes, and the person who turned this on said they are counting.
|
||||
const reducedData = $derived(eidos.dom.prefersReducedData.matches);
|
||||
// Policy 3 — reduced motion does not merely pause it, it never starts: the
|
||||
// first frames are the ones that would move under someone who asked for
|
||||
// stillness. The poster stays, so the composition does not collapse.
|
||||
const reducedMotion = $derived(stack?.reduced ?? eidos.dom.prefersReducedMotion.matches);
|
||||
|
||||
const wanted = $derived(
|
||||
!reducedData &&
|
||||
!reducedMotion &&
|
||||
!(stack?.paused ?? false) &&
|
||||
(stack?.seen ?? true) &&
|
||||
documentVisible.current
|
||||
);
|
||||
|
||||
// It MOVES: that is what makes the stack owe a pause control (WCAG 2.2.2).
|
||||
// Deliberately NOT gated on `paused` — a clip stopped by the control is still
|
||||
// a moving layer, and the button must not vanish the instant it works. It IS
|
||||
// gated on the two preferences that keep the clip from ever running, because
|
||||
// a button that pauses nothing is not accessibility. That is not
|
||||
// reduced-motion "substituting" the control (D-BG.4 forbids that): the
|
||||
// control is owed whenever something moves, and here nothing does.
|
||||
//
|
||||
// Registered from INIT, unregistered from a cleanup — rule A30, and the
|
||||
// preferences are therefore read at mount. They are session-stable in
|
||||
// practice, and the alternative (a reactive effect) is the reflush loop
|
||||
// documented on `registerAnimated` in `background.svelte`.
|
||||
const movesOnMount = untrack(
|
||||
() => !eidos.dom.prefersReducedData.matches && !eidos.dom.prefersReducedMotion.matches
|
||||
);
|
||||
const unregister = movesOnMount ? stack?.registerAnimated() : undefined;
|
||||
$effect(() => () => unregister?.());
|
||||
|
||||
$effect(() => {
|
||||
const el = video;
|
||||
if (!el) return;
|
||||
const dom = eidos.dom;
|
||||
const settle = (next: BackgroundMediaStatus) => () => (status = next);
|
||||
const stops = [
|
||||
// `loadeddata`, not `loadedmetadata`: the first frame has to be decoded
|
||||
// before the layer claims it has something to show.
|
||||
dom.listen(el, 'loadeddata', settle('loaded')),
|
||||
dom.listen(el, 'error', settle('error')),
|
||||
// A stall is not a failure — the poster carries the composition while
|
||||
// the network catches up, and `playing` takes it back.
|
||||
dom.listen(el, 'stalled', settle('stalled')),
|
||||
dom.listen(el, 'playing', settle('loaded'))
|
||||
];
|
||||
return () => {
|
||||
for (const stop of stops) stop();
|
||||
};
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const el = video;
|
||||
if (!el) return;
|
||||
// Set as a PROPERTY, not left to the attribute: an unmuted clip has its
|
||||
// autoplay refused by every browser, and the attribute alone is not a
|
||||
// reliable way to reach the property after hydration.
|
||||
el.muted = muted;
|
||||
if (wanted) {
|
||||
// A refused autoplay is the browser's policy, not a failure of the clip:
|
||||
// the poster stays and nothing is reported.
|
||||
void el.play().catch(() => {});
|
||||
} else {
|
||||
el.pause();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<Layer
|
||||
{...restProps}
|
||||
data-kind="video"
|
||||
data-status={status}
|
||||
data-has-fallback={poster ? '' : undefined}
|
||||
>
|
||||
<!-- The poster is not merely the `<video>`'s attribute: when the clip is not
|
||||
going to play — no data allowance, or it failed — the poster becomes the
|
||||
layer, as its own `<img>`. A failed `<video>` paints nothing, poster
|
||||
included, so relying on the attribute alone would have thrown away the
|
||||
still frame D-BG.15 promises stays. -->
|
||||
{#if !reducedData && status !== 'error'}
|
||||
<!-- svelte-ignore a11y_media_has_caption -- decoration inside an
|
||||
`aria-hidden` layer: a caption track would name what the tree hides. -->
|
||||
<video bind:this={video} src={resolvedSrc} {poster} {loop} {muted} {playsinline} {preload}
|
||||
></video>
|
||||
{:else if poster}
|
||||
<img src={poster} alt="" />
|
||||
{/if}
|
||||
</Layer>
|
||||
@ -0,0 +1,5 @@
|
||||
/** Idlangref constants for the Background component. */
|
||||
export const BACKGROUND_LANGS = {
|
||||
PAUSE: '#?components.background.pause|Pause background',
|
||||
PLAY: '#?components.background.play|Play background'
|
||||
} as const;
|
||||
Loading…
Reference in new issue