diff --git a/e2e/navegacion.e2e.ts b/e2e/navegacion.e2e.ts index d945eec..aeb2723 100644 --- a/e2e/navegacion.e2e.ts +++ b/e2e/navegacion.e2e.ts @@ -313,6 +313,52 @@ test.describe('Fondo animado de la portada', () => { await expect(page.locator('canvas.activo')).toHaveCount(1); }); + test('cambia de paleta con el tema sin perder el contexto WebGL', async ({ page }) => { + await page.addInitScript(() => { + type Uniforme = (this: WebGL2RenderingContext, ...argumentos: unknown[]) => void; + type VentanaConPaletas = Window & { paletasDeFondo: number[][] }; + const ventana = window as VentanaConPaletas; + const prototipo = WebGL2RenderingContext.prototype as unknown as { uniform3fv: Uniforme }; + const original = prototipo.uniform3fv; + ventana.paletasDeFondo = []; + + prototipo.uniform3fv = function (...argumentos) { + const valores = argumentos[1] as ArrayLike; + const paleta = Array.from(valores); + if (paleta.length === 12) ventana.paletasDeFondo.push(paleta); + original.apply(this, argumentos); + }; + }); + + await page.goto('/'); + const lienzo = page.locator('canvas'); + await expect(lienzo).toHaveClass(/activo/); + const primera = await page.evaluate(() => + (window as unknown as { paletasDeFondo: number[][] }).paletasDeFondo.at(-1)! + ); + + await page.getByRole('button', { name: 'Cambiar al tema claro' }).click(); + await expect(page.locator('html')).toHaveAttribute('data-tema', 'claro'); + await expect(lienzo).toHaveClass(/activo/); + await expect + .poll(() => + page.evaluate( + () => (window as unknown as { paletasDeFondo: number[][] }).paletasDeFondo.length + ) + ) + .toBeGreaterThan(1); + + const ultima = await page.evaluate(() => + (window as unknown as { paletasDeFondo: number[][] }).paletasDeFondo.at(-1)! + ); + expect(ultima).not.toEqual(primera); + expect( + await lienzo.evaluate((canvas) => + (canvas as HTMLCanvasElement).getContext('webgl2')?.isContextLost() + ) + ).toBe(false); + }); + 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 diff --git a/src/lib/components/FondoDePortada.svelte b/src/lib/components/FondoDePortada.svelte index af85b10..fa1dd81 100644 --- a/src/lib/components/FondoDePortada.svelte +++ b/src/lib/components/FondoDePortada.svelte @@ -222,4 +222,32 @@ ), linear-gradient(to top, var(--c-bg) 0%, transparent 35%); } + + /* + * El texto oscuro del tema claro necesita una base limpia a la izquierda, + * pero el velo anterior tapaba casi por completo las ondas: un 80 % de papel + * encima de una paleta ya pálida. Se conserva el contraste bajo el texto y se + * abre antes hacia la derecha, donde el fondo es el protagonista. + */ + :global(:root[data-tema='claro']) .velo:not(.foto):not(.cerrado) { + background: + linear-gradient( + to right, + color-mix(in oklab, var(--c-bg) 62%, transparent) 0%, + color-mix(in oklab, var(--c-bg) 18%, transparent) 48%, + transparent 82% + ), + linear-gradient(to top, var(--c-bg) 0%, transparent 22%); + } + + @media (max-width: 62rem) { + :global(:root[data-tema='claro']) .velo:not(.foto):not(.cerrado) { + background: linear-gradient( + to top, + var(--c-bg) 4%, + color-mix(in oklab, var(--c-bg) 38%, transparent) 72%, + transparent 100% + ); + } + } diff --git a/src/lib/components/OndasWebgl.svelte b/src/lib/components/OndasWebgl.svelte index 9d6ada9..6a01729 100644 --- a/src/lib/components/OndasWebgl.svelte +++ b/src/lib/components/OndasWebgl.svelte @@ -25,13 +25,22 @@ * programa que no compila, el componente no pinta y debajo sigue la imagen * de siempre. Un fondo decorativo no puede tumbar una portada. */ - import { tema } from '$lib/tema.svelte'; + import { tema, type Tema } from '$lib/tema.svelte'; /** Densidad máxima. En una pantalla 4K, pintar cada píxel real no compensa. */ const DPR_MAXIMO = 1.5; let lienzo = $state(null); let activo = $state(false); + /** + * Cambia solo los colores del programa que ya está dibujando. + * + * Va separado de la inicialización porque leer `tema.actual` dentro de aquel + * efecto hacía que Svelte desmontara WebGL al alternar el tema. Su limpieza + * pierde el contexto deliberadamente y un contexto perdido no se puede volver + * a montar de inmediato sobre el mismo canvas: por eso las ondas desaparecían. + */ + let cambiarPaleta = $state<((actual: Tema) => void) | null>(null); /* * La paleta sale de los colores de marca, convertida a RGB lineal: el shader @@ -51,10 +60,10 @@ [0.0636, 0.1453, 0.3199] ], claro: [ - [0.8849, 0.9147, 0.9744], - [0.6679, 0.7334, 0.8648], - [0.4187, 0.52, 0.7112], - [0.1813, 0.2974, 0.5092] + [0.35, 0.48, 0.72], + [0.18, 0.32, 0.58], + [0.07, 0.19, 0.42], + [0.02, 0.08, 0.24] ] } as const; @@ -188,8 +197,11 @@ void main() { const uTiempo = gl.getUniformLocation(programa, 'u_tiempo'); const uPaleta = gl.getUniformLocation(programa, 'u_paleta'); - const paleta = PALETAS[tema.actual] ?? PALETAS.oscuro; - gl.uniform3fv(uPaleta, new Float32Array(paleta.flat())); + const aplicarPaleta = (actual: Tema) => { + const paleta = PALETAS[actual] ?? PALETAS.oscuro; + gl.uniform3fv(uPaleta, new Float32Array(paleta.flat())); + }; + cambiarPaleta = aplicarPaleta; function medir() { if (!lienzo || !gl) return; @@ -253,6 +265,7 @@ void main() { arrancar(); return () => { + if (cambiarPaleta === aplicarPaleta) cambiarPaleta = null; parar(); observador.disconnect(); document.removeEventListener('visibilitychange', alCambiarDePestana); @@ -266,6 +279,15 @@ void main() { gl.getExtension('WEBGL_lose_context')?.loseContext(); }; }); + + /* + * Este efecto sí sigue el tema, pero no toca el ciclo de vida del contexto: + * doce números nuevos en un uniforme bastan para recolorear el siguiente + * fotograma sin recompilar shaders, perder animación ni dejar un parpadeo. + */ + $effect(() => { + cambiarPaleta?.(tema.actual); + });