Las páginas de error, con su lámina y su tema

Cada una lleva la suya: la del 404 y la del 500, recortadas del original
—traían una banda plana pegada a la derecha, de generarlas— y pasadas a
webp, 95 y 70 KB. El encabezado no repite lo que la lámina ya lleva
dibujado: dice lo mismo con otras palabras, para que no se lea dos veces
seguidas y para que la página siga teniendo titular si la imagen no
carga.

La música suena con un botón, no sola. Los navegadores cortan el audio
que empieza sin que el usuario haya tocado nada, y quien llega a un 404
desde un enlace roto no ha tocado nada: arrancarla al cargar no fallaría,
es que no sonaría, y en silencio. Con `preload="none"`, además, el mp3 no
se descarga hasta que se pide; sin eso, 686 KB a todo el que aterrice.

Y suena una cosa cada vez. Al 404 no se puede llegar sin recarga entera
—SvelteKit no enruta lo que no existe—, así que ahí nunca hay barra; al
500 sí, y entonces darle aquí pausa el tema, y darle al play de la barra
para esto.

Un 403 no es ninguna de las dos cosas: la página existe y el sitio
funciona, es que no te toca. Esos salen sin ilustración, con su mensaje,
en vez de con una lámina que miente. Y se descartan los mensajes que pone
SvelteKit cuando no hay uno nuestro: «Internal Error» en un sitio en
español es peor que no poner nada.

Con `src/error.html`, que es lo que sale cuando la aplicación no puede ni
pintar su propia página de error. Va desnudo a propósito: sin hoja de
estilos, sin fuentes, sin imagen y sin JavaScript, porque todo lo que
pidiera fuera podría estar caído también.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
master
dev 4 weeks ago
parent fef07e6b8b
commit 623eaa9bea

6
.gitignore vendored

@ -41,6 +41,12 @@ test-results
# despliegue no lleva ffmpeg y no puede regenerarlas.
media/audio/
# Los originales de las láminas de error, por lo mismo. Lo que se sirve —el
# webp recortado y el mp3— vive en static/errores y ese sí va en git; aquí solo
# queda el material del que salió, que es grande y no lo pide el navegador.
media/errors/
psp/
# La base de datos local
local.db*

@ -94,7 +94,62 @@ test.describe('Navegación', () => {
const respuesta = await page.goto('/canciones/no-existe');
expect(respuesta?.status()).toBe(404);
await expect(page.getByRole('heading', { level: 1 })).toHaveText('Página no encontrada');
// El titular no repite lo que la lámina ya lleva dibujado.
await expect(page.getByRole('heading', { level: 1 })).toHaveText(
'Esta dirección no lleva a ninguna parte'
);
// El mensaje que lanzó el `load`, que dice más que la frase de reserva.
await expect(page.getByText('Ese tema no existe.')).toBeVisible();
await expect(page).toHaveTitle(/Página no encontrada/);
});
});
test.describe('La página de error', () => {
test('lleva su lámina y sale del paso', async ({ page }) => {
await page.goto('/no-existe-esto');
const lamina = page.locator('.lamina img');
await expect(lamina).toHaveAttribute('src', '/errores/404.webp');
// Que exista el archivo, no solo la etiqueta.
expect(await lamina.evaluate((i: HTMLImageElement) => i.naturalWidth)).toBeGreaterThan(0);
await page.getByRole('link', { name: 'Volver al inicio' }).click();
await expect(page).toHaveURL('/');
});
/*
* Sin esto, todo el que aterrice en un 404 se traería 686 KB de mp3 que a lo
* mejor no va a escuchar. Es la razón de ser del `preload="none"`.
*/
test('no se trae el mp3 hasta que se pide', async ({ page }) => {
const pedidos: string[] = [];
page.on('request', (p) => {
if (p.url().includes('/errores/')) pedidos.push(p.url());
});
await page.goto('/no-existe-esto');
await expect(page.locator('.lamina img')).toBeVisible();
expect(pedidos.some((u) => u.endsWith('.mp3'))).toBe(false);
await page.getByRole('button', { name: 'Poner música' }).click();
await expect(page.getByRole('button', { name: 'Parar' })).toBeVisible();
expect(pedidos.some((u) => u.endsWith('/errores/404.mp3'))).toBe(true);
expect(await page.locator('.musica audio').evaluate((a: HTMLAudioElement) => a.paused)).toBe(
false
);
});
test('el botón vuelve a su sitio al parar', async ({ page }) => {
await page.goto('/no-existe-esto');
await page.getByRole('button', { name: 'Poner música' }).click();
await page.getByRole('button', { name: 'Parar' }).click();
await expect(page.getByRole('button', { name: 'Poner música' })).toBeVisible();
expect(await page.locator('.musica audio').evaluate((a: HTMLAudioElement) => a.paused)).toBe(
true
);
});
});

@ -0,0 +1,74 @@
<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#0e1424" />
<meta name="robots" content="noindex" />
<title>%sveltekit.status% · Senza Paura</title>
<!--
Esto sale cuando la aplicación no ha podido ni pintar su propia página de
error: un fallo antes de que arranque nada. Por eso va desnuda —sin hoja
de estilos, sin fuentes, sin imagen y sin JavaScript—: todo lo que se
pidiera fuera podría estar caído también, y entonces no se vería ni esto.
Los colores son los mismos del sitio, escritos a mano.
-->
<style>
body {
margin: 0;
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
padding: 2rem;
background: #0e1424;
color: #eef1f7;
font-family:
system-ui,
-apple-system,
'Segoe UI',
Roboto,
sans-serif;
line-height: 1.6;
}
main {
max-width: 44ch;
}
p.codigo {
margin: 0 0 0.5rem;
font-size: 0.875rem;
letter-spacing: 0.08em;
color: #e2b567;
}
h1 {
margin: 0 0 1rem;
font-size: 1.75rem;
font-weight: 500;
}
p {
margin: 0 0 1.5rem;
color: #aab3c5;
}
a {
color: #e2b567;
}
</style>
</head>
<body>
<main>
<p class="codigo">%sveltekit.status%</p>
<h1>%sveltekit.error.message%</h1>
<p>
El sitio no ha podido cargarse. Vuelve a intentarlo en un momento; si sigue igual,
escríbenos a <a href="mailto:hola@senzapaura.es">hola@senzapaura.es</a>.
</p>
<p><a href="/">Volver al inicio</a></p>
</main>
</body>
</html>

@ -1,48 +1,297 @@
<script lang="ts">
/**
* Las páginas de error, 404 y 500.
*
* Cada una lleva su lámina y su tema. La lámina trae el número y el rótulo
* dibujados dentro, así que la página **no** los repite: escribe lo que la
* imagen no puede decir —qué ha pasado y por dónde se sale— y deja el titular
* a la ilustración. Aun así hay un `h1` de verdad, porque una página cuyo
* único título viva dentro de un `.webp` no la lee un lector de pantalla ni
* sobrevive a que la imagen no cargue.
*
* ## La música
*
* Suena con un botón, no sola. Los navegadores cortan el audio que empieza
* sin que el usuario haya tocado nada, y quien llega a un 404 desde un enlace
* roto no ha tocado nada todavía: arrancarla al cargar no fallaría, es que no
* sonaría, y en silencio.
*
* Y suena una cosa cada vez. Si había un tema en la barra de reproducción, se
* pausa al dar aquí; si desde la barra se vuelve a dar al play, esto se para.
* Manda el último que haya pulsado, que es lo que uno espera.
*
* El `preload="none"` importa: sin él, el navegador se traería los 686 KB del
* mp3 a todo el que aterrice en un 404, lo vaya a escuchar o no.
*/
import { resolve } from '$app/paths';
import { page } from '$app/state';
import Icono from '$lib/components/Icono.svelte';
import Meta from '$lib/components/Meta.svelte';
import { reproductor } from '$lib/player/player.svelte';
const titulo = $derived(page.status === 404 ? 'Página no encontrada' : 'Ha ocurrido un error');
/**
* Lo que pone SvelteKit cuando no hay mensaje nuestro. Están en inglés y no
* dicen nada —«Internal Error» en un sitio en español es peor que no poner
* nada—, así que se descartan y se escribe la frase a mano. Los que sí son
* nuestros vienen redactados: «Ese tema no existe.», «Esta cuenta ya no
* administra el sitio.»
*/
const GENERICOS = new Set([
'Not Found',
'Internal Error',
'Forbidden',
'Unauthorized',
'Bad Request'
]);
const mensaje = $derived(
page.error?.message && !GENERICOS.has(page.error.message) ? page.error.message : null
);
/**
* Solo hay lámina para dos casos, y son los dos que tienen dibujo: la que dice
* «página no encontrada» y la que dice «algo ha salido mal». Un 403 no es
* ninguna de las dos cosas —la página existe y el sitio funciona, es que no
* te toca—, así que sale sin ilustración en vez de con una que miente.
*/
const caso = $derived(page.status === 404 ? 'perdida' : page.status >= 500 ? 'rota' : 'otra');
const lamina = $derived(caso === 'perdida' ? '404' : caso === 'rota' ? '500' : null);
/** El de la pestaña y el buscador; el de la página va aparte, más abajo. */
const titulo = $derived(
caso === 'perdida'
? 'Página no encontrada'
: caso === 'rota'
? 'Algo ha salido mal'
: (mensaje ?? `Error ${page.status}`)
);
/**
* El encabezado no repite lo que la lámina ya lleva dibujado —«404 · PÁGINA
* NO ENCONTRADA»—, dice lo mismo con otras palabras. Así no se lee dos veces
* seguidas, y si la imagen no carga la página sigue teniendo su titular.
*/
const encabezado = $derived(
caso === 'perdida'
? 'Esta dirección no lleva a ninguna parte'
: caso === 'rota'
? 'Se ha roto por nuestra parte'
: (mensaje ?? `Error ${page.status}`)
);
const detalle = $derived(
caso === 'perdida'
? (mensaje ?? 'O nunca existió, o el contenido se ha movido de sitio.')
: caso === 'rota'
? 'No es cosa tuya. Vuelve a intentarlo en un momento y, si sigue igual, avísanos.'
: null
);
let audio = $state<HTMLAudioElement | null>(null);
let sonando = $state(false);
function alternar(): void {
if (!audio) return;
if (sonando) {
audio.pause();
return;
}
// Una cosa cada vez: lo que hubiera en la barra se calla.
if (reproductor.sonando) reproductor.pausar();
void audio.play();
}
/*
* El otro lado del trato: si desde la barra vuelven a dar al play, esto se
* calla. `untrack` no hace falta —solo se lee `reproductor.sonando`—, pero sí
* la guarda de `sonando`, o el efecto pausaría un audio ya parado en cada
* cambio del reproductor.
*/
$effect(() => {
if (reproductor.sonando && sonando) audio?.pause();
});
</script>
<Meta {titulo} sinIndexar />
<div class="container error">
<div class="container error" class:sola={!lamina}>
{#if lamina}
<figure class="lamina">
<img
src="/errores/{lamina}.webp"
alt={caso === 'perdida'
? 'Una chica sentada junto a la funda de una guitarra, mirando hacia arriba, bajo un 404 dorado.'
: 'Un 500 dorado sobre el fondo marino, entre líneas y notas sueltas.'}
width="1200"
height="1364"
fetchpriority="high"
/>
</figure>
{/if}
<div class="dicho">
<p class="codigo muted">{page.status}</p>
<h1>{titulo}</h1>
<p class="detalle">
{#if page.status === 404}
La dirección a la que has llegado no existe o el contenido se ha movido.
{:else}
{page.error?.message ?? 'Algo ha fallado por nuestra parte.'}
<h1>{encabezado}</h1>
{#if detalle}
<p class="detalle">{detalle}</p>
{/if}
</p>
<p class="acciones">
<div class="acciones">
<a class="btn btn--primary" href={resolve('/')}>Volver al inicio</a>
{#if caso === 'perdida'}
<a class="btn" href={resolve('/musica')}>Ir a la música</a>
{:else if caso === 'rota'}
<a class="btn" href={resolve('/contacto')}>Avisarnos</a>
{/if}
</div>
{#if lamina}
<div class="musica">
<audio
bind:this={audio}
preload="none"
src="/errores/{lamina}.mp3"
onplay={() => (sonando = true)}
onpause={() => (sonando = false)}
onended={() => (sonando = false)}
></audio>
<button type="button" class="poner" onclick={alternar}>
<Icono nombre={sonando ? 'pausa' : 'play'} tamano={18} />
{sonando ? 'Parar' : 'Poner música'}
</button>
<p class="mientras muted">
{caso === 'perdida' ? 'Ya que has llegado hasta aquí.' : 'Mientras lo arreglamos.'}
</p>
</div>
{/if}
</div>
</div>
<style>
/*
* La lámina es vertical (0,88:1) y manda el alto; el texto se centra a su
* lado en vez de colgar de arriba, que con una imagen tan alta dejaría el
* bloque flotando en la esquina.
*
* El bloque se limita a 64rem y se centra en vez de estirarse a lo ancho del
* contenedor: las dos columnas suman justo eso —30rem de lámina y las 44ch
* del texto—, y dejarlas sueltas en un monitor ancho abriría un hueco a la
* derecha del texto que no hay con qué llenar.
*/
.error {
padding-block: var(--sp-8);
max-width: 44ch;
display: grid;
grid-template-columns: minmax(0, 30rem) minmax(0, 1fr);
gap: var(--sp-7);
align-items: center;
max-width: 64rem;
margin-inline: auto;
padding-block: var(--sp-7);
}
/* Sin lámina —un 403, un 401— no hay dos columnas que repartir. */
.error.sola {
grid-template-columns: minmax(0, 1fr);
max-width: 44rem;
}
.lamina {
margin: 0;
}
.codigo {
margin-bottom: var(--sp-2);
font-size: var(--fs-sm);
font-variant-numeric: tabular-nums;
margin-bottom: var(--sp-2);
}
.lamina img {
display: block;
width: 100%;
height: auto;
border-radius: var(--radius-tarjeta);
}
.dicho {
max-width: 44ch;
}
h1 {
margin-top: 0;
}
.detalle {
font-size: var(--fs-lg);
color: var(--c-text-muted);
}
.acciones {
display: flex;
flex-wrap: wrap;
gap: var(--sp-3);
margin-top: var(--sp-5);
}
/* Separada de los botones de salir: esto no es una forma de irse. */
.musica {
margin-top: var(--sp-7);
padding-top: var(--sp-5);
border-top: 1px solid var(--c-border);
}
.poner {
display: inline-flex;
align-items: center;
gap: var(--sp-2);
padding: var(--boton-relleno);
border: 1px solid var(--c-border);
border-radius: var(--radius-boton);
background: transparent;
color: inherit;
font-size: var(--boton-texto);
cursor: pointer;
transition: border-color 0.3s var(--suave);
}
.poner:hover,
.poner:focus-visible {
border-color: var(--c-accent);
}
.mientras {
margin: var(--sp-2) 0 0;
font-size: var(--fs-xs);
}
@media (prefers-reduced-motion: reduce) {
.poner {
transition: none;
}
}
/*
* Apiladas, la lámina se limita: a ancho completo en un móvil ocuparía la
* pantalla entera y el texto quedaría por debajo del pliegue, que es justo lo
* que hace falta leer.
*/
@media (max-width: 52rem) {
.error {
grid-template-columns: minmax(0, 1fr);
gap: var(--sp-5);
justify-items: center;
text-align: center;
}
.lamina {
max-width: 20rem;
}
.acciones {
justify-content: center;
}
.musica {
width: 100%;
}
}
</style>

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Loading…
Cancel
Save

Powered by TurnKey Linux.