diff --git a/README.md b/README.md
index 5795d8b..3baca3c 100644
--- a/README.md
+++ b/README.md
@@ -545,6 +545,43 @@ página de un disco, donde el orden de las pistas es la información.
## El fondo de la portada
+Encima de todo, en la cabecera, van **ondas animadas**
+([`OndasWebgl`](src/lib/components/OndasWebgl.svelte)): WebGL a pelo, sin
+biblioteca. Un shader de fondo es un triángulo y un programa de fragmentos;
+traerse three.js para eso sería medio mega de dependencia en la primera pantalla
+para dibujar dos triángulos.
+
+El dibujo es _domain warping_: se calcula ruido, se usa para desplazar las
+coordenadas y se vuelve a calcular. Dos vueltas bastan para que las formas dejen
+de parecer nubes y empiecen a parecer agua. La escala horizontal es mayor que la
+vertical, que es lo que las tumba en bandas.
+
+La paleta sale de los colores de marca convertidos a **RGB lineal**, que es en
+lo que mezcla el shader. El primer intento usó los cuatro fondos de `app.css`,
+que caben en un pelo de claridad, y salió una plancha lisa: por bien que calcule
+el ruido, sin recorrido de color no se ve nada.
+
+Cuatro cosas que no son negociables, y por las que no vale con soltar un canvas:
+
+- **Con `prefers-reduced-motion` no se monta.**
+- **Se para cuando no se ve**, al salir de pantalla y al cambiar de pestaña. Un
+ shader a pantalla completa detrás de una página por la que ya has bajado es
+ batería tirada.
+- **Si falla, no pasa nada**: sin WebGL o sin compilar, debajo queda la imagen.
+ Un fondo decorativo no puede tumbar una portada.
+- **Solo lo lleva la cabecera.** En las tres láminas serían tres shaders
+ peleándose por la tarjeta gráfica para pintar lo mismo.
+
+Las ondas resuelven además el **tema claro**, que no tiene imagen propia: las
+pinta en azules pálidos, y el clip de reserva dejó de hacer falta. Donde no
+pueden correr, el tema claro se queda con el color de fondo a secas hasta que
+exista una imagen suya.
+
+Las pruebas arrancan Chromium con SwiftShader —su rasterizador por software—,
+porque si no solo se podría comprobar la ausencia del efecto.
+
+## La imagen del fondo
+
Una imagen a sangre detrás del tocadiscos y del texto, en
`static/background/`. Se sirve en **tres anchos WebP** y el navegador coge el
que le toca: 10 kB en un móvil, 57 kB en un panel grande. El PNG original de
diff --git a/e2e/navegacion.e2e.ts b/e2e/navegacion.e2e.ts
index 80c56e5..3cac3c3 100644
--- a/e2e/navegacion.e2e.ts
+++ b/e2e/navegacion.e2e.ts
@@ -12,15 +12,15 @@ test.describe('Navegación', () => {
await expect(page).toHaveURL('/musica');
});
- test('la cabecera lleva un vídeo de fondo propio, no un incrustado', async ({ page }) => {
- // Un iframe de terceros en la primera pantalla pesaría más que el resto
- // de la página y dejaría cookies antes de que nadie pida ver nada.
+ test('el fondo de la cabecera es propio, no un incrustado', async ({ page }) => {
+ // Lo que se comprueba es la decisión, no la técnica con que esté hecha:
+ // un iframe de terceros en la primera pantalla pesaría más que el resto de
+ // la página y dejaría cookies antes de que nadie pida ver nada.
await page.goto('/');
- const video = page.locator('section video');
- await expect(video).toHaveCount(1);
- await expect(video).toHaveJSProperty('muted', true);
- await expect(video).toHaveJSProperty('loop', true);
+ await expect(page.locator('iframe')).toHaveCount(0);
+ // Y el fondo existe: hoy es un shader propio sobre una imagen propia.
+ await expect(page.locator('.fondo')).not.toHaveCount(0);
});
test('la cabecera acompaña al desplazamiento', async ({ page }) => {
@@ -139,3 +139,58 @@ test.describe('Carrusel de la portada', () => {
await expect(page.getByRole('button', { name: 'Reanudar el paso automático' })).toBeVisible();
});
});
+
+test.describe('Fondo animado de la portada', () => {
+ test('se pinta y arranca', async ({ page }) => {
+ await page.goto('/');
+
+ // La clase solo se pone cuando el programa ha compilado y está dibujando:
+ // que exista el canvas no significa que haya salido nada.
+ await expect(page.locator('canvas.activo')).toHaveCount(1);
+ });
+
+ test('con movimiento reducido no se monta', async ({ page }) => {
+ // Quien pide menos movimiento no quiere una primera pantalla que ondula.
+ // En tema oscuro, porque es donde hay imagen debajo que comprobar: en
+ // claro el fondo lo pinta el propio shader.
+ await page.emulateMedia({ reducedMotion: 'reduce', colorScheme: 'dark' });
+ await page.goto('/');
+
+ await expect(page.locator('canvas.activo')).toHaveCount(0);
+ // Y debajo sigue el fondo de siempre, no un hueco.
+ await expect(page.locator('.fondo img').first()).toBeAttached();
+ });
+
+ test('deja de dibujar cuando ya no se ve', async ({ page }) => {
+ // Un shader a pantalla completa detrás de una página por la que ya has
+ // bajado es batería tirada.
+ await page.goto('/');
+ await expect(page.locator('canvas.activo')).toHaveCount(1);
+
+ await page.evaluate(() => {
+ (window as unknown as { cuadros: number }).cuadros = 0;
+ const original = window.requestAnimationFrame;
+ window.requestAnimationFrame = (cb) =>
+ original(() => {
+ (window as unknown as { cuadros: number }).cuadros++;
+ cb(performance.now());
+ });
+ });
+
+ await page.evaluate(() => window.scrollTo(0, 5000));
+ await page.waitForTimeout(600);
+ const antes = await page.evaluate(() => (window as unknown as { cuadros: number }).cuadros);
+ await page.waitForTimeout(800);
+ const despues = await page.evaluate(() => (window as unknown as { cuadros: number }).cuadros);
+
+ expect(despues - antes).toBe(0);
+ });
+
+ test('solo lo lleva la cabecera, no las tres láminas', async ({ page }) => {
+ await page.goto('/');
+
+ // Tres shaders a pantalla completa pintando lo mismo a la vez no aportan
+ // nada y cuestan el triple.
+ await expect(page.locator('canvas')).toHaveCount(1);
+ });
+});
diff --git a/playwright.config.ts b/playwright.config.ts
index d10a3da..209eff6 100644
--- a/playwright.config.ts
+++ b/playwright.config.ts
@@ -2,6 +2,16 @@ import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: 'e2e',
+ /*
+ * WebGL por software. El Chromium de las pruebas no trae aceleración, y sin
+ * esto el fondo animado de la portada no se podría comprobar: solo se vería
+ * su reserva, que es justo lo contrario de lo que hay que probar.
+ */
+ use: {
+ launchOptions: {
+ args: ['--use-gl=swiftshader', '--enable-unsafe-swiftshader']
+ }
+ },
testMatch: '**/*.e2e.{ts,js}',
webServer: {
// El build va aparte, en `npm run test:e2e`: en Windows el empaquetador
diff --git a/src/lib/components/FondoDePortada.svelte b/src/lib/components/FondoDePortada.svelte
index 1ae4e6e..94b4a7a 100644
--- a/src/lib/components/FondoDePortada.svelte
+++ b/src/lib/components/FondoDePortada.svelte
@@ -14,6 +14,7 @@
* nadie haya pedido ver nada. Y no se arranca con `autoplay` sino desde
* código, para poder respetar `prefers-reduced-motion`.
*/
+ import OndasWebgl from './OndasWebgl.svelte';
import { heroe } from '$lib/site';
import { tema } from '$lib/tema.svelte';
@@ -41,9 +42,23 @@
* color de fondo, que para una llamada a la acción sobra.
*/
clip?: boolean;
+ /**
+ * Si encima del fondo van las ondas animadas.
+ *
+ * Solo lo pide la cabecera: es la primera pantalla y donde ese movimiento
+ * dice algo. Repetirlo en las tres láminas serían tres shaders a pantalla
+ * completa peleándose por la tarjeta gráfica para pintar lo mismo.
+ *
+ * Las ondas también resuelven el tema claro, que no tiene imagen propia:
+ * el shader las pinta en azules pálidos y el clip de reserva dejó de hacer
+ * falta. Donde no pueden correr —sin WebGL o con movimiento reducido— el
+ * tema claro se queda con el color de fondo a secas, hasta que exista una
+ * imagen suya.
+ */
+ ondas?: boolean;
}
- let { velo = 'lateral', prioritario = false, clip = false }: Props = $props();
+ let { velo = 'lateral', prioritario = false, clip = false, ondas = false }: Props = $props();
const fondo = $derived(heroe.fondo[tema.actual]);
/*
@@ -101,6 +116,16 @@
>
{/if}
+
+ {#if ondas}
+
+ {/if}
+
@@ -134,11 +159,11 @@
background:
linear-gradient(
to right,
- color-mix(in oklab, var(--c-bg) 86%, transparent) 0%,
- color-mix(in oklab, var(--c-bg) 52%, transparent) 45%,
- color-mix(in oklab, var(--c-bg) 8%, transparent) 100%
+ color-mix(in oklab, var(--c-bg) 80%, transparent) 0%,
+ color-mix(in oklab, var(--c-bg) 40%, transparent) 45%,
+ transparent 100%
),
- linear-gradient(to top, var(--c-bg) 0%, transparent 30%);
+ linear-gradient(to top, var(--c-bg) 0%, transparent 28%);
}
/*
diff --git a/src/lib/components/Heroe.svelte b/src/lib/components/Heroe.svelte
index b895692..f60415e 100644
--- a/src/lib/components/Heroe.svelte
+++ b/src/lib/components/Heroe.svelte
@@ -56,7 +56,7 @@
-
+