From ba72469710d0d9c89671a05b8a910dbe916963b2 Mon Sep 17 00:00:00 2001 From: dev Date: Mon, 7 Sep 2026 04:06:58 +0200 Subject: [PATCH] Ondas animadas de fondo en la cabecera MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Un shader de ondas en los azules de marca, sobre la imagen de la portada. Es WebGL a pelo: un fondo así es un triángulo y un programa de fragmentos, y traerse una biblioteca 3D para eso sería medio mega de dependencia en la primera pantalla del sitio. El dibujo es domain warping —ruido que desplaza el dominio de otro ruido—, con la escala horizontal mayor que la vertical para que las formas se tumben en bandas en vez de quedarse redondas. La paleta va en RGB lineal, que es en lo que mezcla el shader. El primer intento usó los cuatro fondos de `app.css` tal cual y salió una plancha lisa: caben en un pelo de claridad, y sin recorrido de color el ruido no se ve por bien que esté calculado. Con `prefers-reduced-motion` no se monta; se para al salir de pantalla y al cambiar de pestaña; y si no hay WebGL o el programa no compila, debajo queda la imagen de siempre. Solo lo lleva la cabecera: en las tres láminas serían tres shaders pintando lo mismo a la vez. De paso resuelve el tema claro, que no tenía imagen propia: lo pinta en azules pálidos, así que el clip de relleno se va. Las pruebas arrancan Chromium con SwiftShader para poder comprobar el efecto y no solo su ausencia. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 37 +++ e2e/navegacion.e2e.ts | 69 +++++- playwright.config.ts | 10 + src/lib/components/FondoDePortada.svelte | 35 ++- src/lib/components/Heroe.svelte | 2 +- src/lib/components/OndasWebgl.svelte | 298 +++++++++++++++++++++++ 6 files changed, 438 insertions(+), 13 deletions(-) create mode 100644 src/lib/components/OndasWebgl.svelte 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 @@ + + + + + +