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 @@
- +
diff --git a/src/lib/components/OndasWebgl.svelte b/src/lib/components/OndasWebgl.svelte new file mode 100644 index 0000000..9d6ada9 --- /dev/null +++ b/src/lib/components/OndasWebgl.svelte @@ -0,0 +1,298 @@ + + + + + +