Las páginas de error, a pantalla completa y desnudas

Las láminas están dibujadas como carteles y venían armonizadas: el
dibujo ocupa el 62,6 % de la izquierda y el resto es una banda lisa, que
es el sitio del texto. Las recorté creyendo que sobraba y no: ahora va
la lámina entera de fondo, pegada a la izquierda con el alto exacto de
la pantalla, y lo que sobre a la derecha se pinta del color de la propia
banda, así que en un monitor ancho la banda se alarga y no se ve dónde
acaba la imagen. La columna del texto empieza a 0,88 × el alto, que es
donde empieza la banda sea cual sea el ancho.

Sin cabecera, sin pie y sin barra de reproducción. Un cartel a pantalla
completa con el menú del sitio encima deja de ser un cartel, y de un
error se sale por la puerta que pone la propia página.

En vertical no vale ese reparto: al alto de un móvil el cartel se
quedaría en una franja del centro con el número cortado por los lados.
Ahí se apila, y con la otra versión del archivo —solo el dibujo, sin la
banda—, con el panel del color del borde del dibujo, que es exactamente
el color con el que la imagen termina.

Un 403 no es ninguno de los dos casos: la página existe y el sitio
funciona, es que no te toca. Esos salen sin ilustración. 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`, desnudo a propósito, para cuando la aplicación no
puede ni pintar su propia página de error.

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

@ -106,17 +106,37 @@ test.describe('Navegación', () => {
test.describe('La página de error', () => {
test('lleva su lámina y sale del paso', async ({ page }) => {
const respuestas: Record<string, number> = {};
page.on('response', (r) => {
if (r.url().includes('/errores/')) respuestas[r.url().split('/').pop()!] = r.status();
});
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);
// La lámina es el fondo de la pantalla, no una imagen dentro del contenido.
const pantalla = page.locator('.pantalla');
await expect(pantalla).toHaveCSS('background-image', /errores\/404\.webp/);
// Y el archivo existe de verdad, no solo la regla que lo nombra.
expect(respuestas['404.webp']).toBe(200);
await page.getByRole('link', { name: 'Volver al inicio' }).click();
await expect(page).toHaveURL('/');
});
/*
* Va desnuda: un cartel a pantalla completa con la barra del sitio encima
* deja de ser un cartel. De un error se sale por la puerta que pone la
* propia página.
*/
test('no lleva cabecera, ni pie, ni reproductor', async ({ page }) => {
await page.goto('/no-existe-esto');
await expect(page.locator('header')).toHaveCount(0);
await expect(page.locator('footer')).toHaveCount(0);
await expect(page.getByRole('region', { name: 'Reproductor' })).toHaveCount(0);
await expect(page.getByRole('link', { name: 'Volver al inicio' })).toBeVisible();
});
/*
* 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"`.
@ -128,7 +148,7 @@ test.describe('La página de error', () => {
});
await page.goto('/no-existe-esto');
await expect(page.locator('.lamina img')).toBeVisible();
await expect(page.getByRole('button', { name: 'Poner música' })).toBeVisible();
expect(pedidos.some((u) => u.endsWith('.mp3'))).toBe(false);
await page.getByRole('button', { name: 'Poner música' }).click();

@ -28,13 +28,18 @@
* el píxel exacto.
*/
enlaces?: boolean;
/**
* Más pequeñas, para cuando van dentro de una fila de una lista y no en
* una ficha: ahí son un dato de apoyo y no deben pesar más que el título.
*/
menudas?: boolean;
}
let { etiquetas, titulo = 'Etiquetas', enlaces = true }: Props = $props();
let { etiquetas, titulo = 'Etiquetas', enlaces = true, menudas = false }: Props = $props();
</script>
{#if etiquetas.length > 0}
<ul class="etiquetas" aria-label={titulo}>
<ul class="etiquetas" class:menudas aria-label={titulo}>
{#each etiquetas as etiqueta (etiqueta)}
<li>
{#if enlaces}
@ -96,6 +101,30 @@
--velo: oklch(0.45 0.13 var(--tono) / 0.1);
}
/* En una fila: sin caja, solo el nombre en su color y un punto delante. */
.menudas {
gap: var(--sp-3);
}
.menudas .etiqueta {
padding: 0;
border: 0;
font-size: 0.6875rem;
letter-spacing: 0.02em;
}
.menudas li + li .etiqueta::before {
content: '·';
margin-right: var(--sp-3);
color: var(--c-text-muted);
}
.menudas a.etiqueta:hover,
.menudas a.etiqueta:focus-visible {
background: none;
text-decoration: underline;
}
a.etiqueta:hover,
a.etiqueta:focus-visible {
border-color: var(--claro);

@ -9,6 +9,8 @@
import { resolve } from '$app/paths';
import BotonCarrito from './BotonCarrito.svelte';
import BotonReproducir from './BotonReproducir.svelte';
import Etiquetas from './Etiquetas.svelte';
import SinPortada from './SinPortada.svelte';
import { aPistas } from '$lib/player/pista';
import { reproductor } from '$lib/player/player.svelte';
import type { Cancion } from '$lib/types';
@ -23,6 +25,49 @@
let { canciones, numerar = true }: Props = $props();
const pistas = $derived(aPistas(canciones));
/**
* La portada ampliada al pasar el ratón por encima de la miniatura.
*
* Va en `position: fixed` y no dentro de la fila porque una fila mide 64 px de
* alto: colgada de ahí, la tarjeta se saldría de la lista y quedaría a merced
* de cualquier `overflow` de los antepasados. Fija, solo depende de la
* ventana, y por eso se puede pegar al borde sin que nada la recorte.
*
* Solo con ratón. No se abre con el teclado a propósito: haría falta un punto
* de tabulación por fila —diez en un disco— para enseñar una imagen que ya
* está, más grande, en la ficha del tema a la que lleva el título.
*/
const LADO = 260;
const AIRE = 12;
/** El lado más el relleno y la línea del título: lo que mide de alto. */
const ALTO = LADO + 46;
let vista = $state<{ src: string; titulo: string; x: number; y: number } | null>(null);
function asomar(evento: MouseEvent, cancion: Cancion): void {
if (!cancion.portada) return;
const caja = (evento.currentTarget as HTMLElement).getBoundingClientRect();
const margen = 16;
/* A la derecha si cabe; si no, al otro lado. En una ventana estrecha no
cabe en ninguno de los dos, y entonces se pega al margen izquierdo. */
const derecha = caja.right + AIRE;
const izquierda = caja.left - AIRE - LADO;
const x =
derecha + LADO <= window.innerWidth - margen
? derecha
: izquierda >= margen
? izquierda
: margen;
/* Centrada en la fila, sin salirse por arriba ni por abajo. */
const centro = caja.top + caja.height / 2 - ALTO / 2;
const y = Math.min(Math.max(centro, margen), window.innerHeight - ALTO - margen);
vista = { src: cancion.portada, titulo: cancion.titulo, x, y };
}
</script>
{#if canciones.length === 0}
@ -38,20 +83,51 @@
<BotonReproducir {pistas} indice={i} />
</div>
<!--
La portada del tema, no la del disco: en un álbum donde cada canción
tiene la suya, la lista es lo único que las enseña juntas. Va muda
—`alt` vacío, `muda` en el hueco— porque el título está justo al lado
y repetirlo solo alarga la lectura con un lector de pantalla.
-->
<a
class="mini"
href={resolve('/canciones/[slug]', { slug: cancion.slug })}
tabindex="-1"
onmouseenter={(e) => asomar(e, cancion)}
onmouseleave={() => (vista = null)}
>
{#if cancion.portada}
<img src={cancion.portada} alt="" width="72" height="72" loading="lazy" />
{:else}
<SinPortada titulo={cancion.titulo} lado={72} muda />
{/if}
</a>
<div class="datos">
<a class="titulo" href={resolve('/canciones/[slug]', { slug: cancion.slug })}>
{cancion.titulo}
</a>
<p class="extracto muted">
<!--
Géneros y etiquetas, no el primer verso. La letra ya está en la
ficha, entera; aquí lo que sirve es saber de qué está hecho cada
tema para elegir cuál abrir.
-->
<div class="senas">
{#if cancion.instrumental}
<span class="marca">Instrumental</span>
{/if}
{#if cancion.extracto.length > 0}
{cancion.extracto.join(' / ')}
{:else}
{listaDeNombres(cancion.interpretes)}
{#each cancion.generos as g (g.slug)}
<a class="genero" href={resolve('/generos/[slug]', { slug: g.slug })}>{g.nombre}</a>
{/each}
<Etiquetas
etiquetas={cancion.etiquetas}
titulo="Etiquetas de {cancion.titulo}"
menudas
/>
{#if cancion.generos.length === 0 && cancion.etiquetas.length === 0}
<span class="muted sin">{listaDeNombres(cancion.interpretes)}</span>
{/if}
</p>
</div>
</div>
<div class="final">
@ -63,6 +139,22 @@
</li>
{/each}
</ol>
{#if vista}
<!--
`aria-hidden`: es la misma portada de la miniatura de al lado, en grande.
Para quien no la ve no añade nada, y anunciarla interrumpiría la lectura
de la lista cada vez que el ratón roza una fila.
-->
<div
class="vista-previa"
style="--x: {vista.x}px; --y: {vista.y}px; --lado: {LADO}px"
aria-hidden="true"
>
<img src={vista.src} alt="" width={LADO} height={LADO} />
<p>{vista.titulo}</p>
</div>
{/if}
{/if}
<style>
@ -75,13 +167,62 @@
.fila {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
grid-template-columns: auto auto minmax(0, 1fr) auto;
align-items: center;
gap: var(--sp-3);
padding: var(--sp-2) var(--sp-1);
border-bottom: 1px solid var(--c-border);
}
.mini {
display: block;
width: 72px;
line-height: 0;
}
.mini img {
width: 72px;
height: 72px;
object-fit: cover;
border-radius: var(--radius-tarjeta);
background: var(--c-bg-sunken);
}
/*
* `pointer-events: none` para que la tarjeta no se cruce con el ratón: si lo
* capturase, al salir de la miniatura hacia ella el `mouseleave` la cerraría
* y volvería a abrirse en bucle.
*/
.vista-previa {
position: fixed;
top: var(--y);
left: var(--x);
z-index: 40;
width: var(--lado);
padding: var(--sp-2);
border: 1px solid var(--c-border);
border-radius: var(--radius-tarjeta);
background: var(--c-bg-raised);
box-shadow: 0 12px 32px rgb(0 0 0 / 0.45);
pointer-events: none;
}
.vista-previa img {
display: block;
width: 100%;
height: auto;
aspect-ratio: 1;
object-fit: cover;
border-radius: calc(var(--radius-tarjeta) - 2px);
}
.vista-previa p {
margin: var(--sp-2) 0 0;
font-size: var(--fs-xs);
color: var(--c-text-muted);
text-align: center;
}
.fila:hover {
background: var(--c-bg-raised);
}
@ -115,11 +256,48 @@
text-decoration: underline;
}
.extracto {
margin: 0;
font-size: var(--fs-xs);
/*
* En una línea y sin envolver: en una lista de diez temas, una fila que crece
* porque su canción tiene una etiqueta más rompe el ritmo de todas. Lo que no
* cabe se recorta con el degradado del final.
*/
.senas {
display: flex;
align-items: center;
gap: var(--sp-2);
min-width: 0;
margin-top: var(--sp-1);
overflow: hidden;
text-overflow: ellipsis;
mask-image: linear-gradient(to right, #000 calc(100% - 2rem), transparent);
}
.senas :global(.etiquetas) {
flex-wrap: nowrap;
}
.senas > *,
.senas :global(li) {
flex: none;
}
/* El género es del glosario, no del catálogo: se distingue con el oro. */
.genero {
padding: 0.2em 0.7em;
border: 1px solid var(--c-accent);
border-radius: var(--radius-boton);
font-size: var(--fs-xs);
color: var(--c-accent);
text-decoration: none;
white-space: nowrap;
}
.genero:hover,
.genero:focus-visible {
background: color-mix(in oklab, var(--c-accent) 14%, transparent);
}
.sin {
font-size: var(--fs-xs);
white-space: nowrap;
}
@ -127,7 +305,9 @@
border: 1px solid var(--c-border);
border-radius: var(--radius-boton);
padding: 0 0.4em;
margin-right: var(--sp-1);
font-size: var(--fs-xs);
color: var(--c-text-muted);
white-space: nowrap;
}
.final {

@ -19,6 +19,7 @@ function cancion(parcial: Partial<Cancion> = {}): Cancion {
interpretes: ['Marta Cifuentes'],
destacada: false,
etiquetas: [],
generos: [],
enlaces: {},
instrumental: false,
idioma: 'es',
@ -48,21 +49,27 @@ describe('ListaCanciones', () => {
.toHaveAttribute('href', '/canciones/como-si-nada');
});
it('enseña los primeros versos en cada fila', async () => {
// Es lo que distingue un tema de otro dentro de un disco; los créditos se
// repetirían idénticos en todas las filas.
it('enseña de qué está hecho el tema, no su letra', async () => {
// El primer verso ya no va aquí: la letra está entera en la ficha, y en
// una lista lo que sirve para elegir es el género y las etiquetas.
render(ListaCanciones, {
canciones: [cancion({ extracto: ['Entras en la casa y no preguntas nada,'] })]
canciones: [
cancion({
extracto: ['Entras en la casa y no preguntas nada,'],
generos: [{ slug: 'bolero', nombre: 'Bolero' }],
etiquetas: ['Desamor']
})
]
});
await expect
.element(page.getByText(/Entras en la casa y no preguntas nada/))
.toBeInTheDocument();
await expect.element(page.getByRole('link', { name: 'Bolero' })).toBeInTheDocument();
await expect.element(page.getByRole('link', { name: 'Desamor' })).toBeInTheDocument();
expect(document.body.textContent).not.toContain('Entras en la casa');
});
it('cae en los intérpretes cuando no hay letra que enseñar', async () => {
it('cae en los intérpretes cuando no hay género ni etiqueta', async () => {
render(ListaCanciones, {
canciones: [cancion({ extracto: [], interpretes: ['Marta Cifuentes'] })]
canciones: [cancion({ generos: [], etiquetas: [], interpretes: ['Marta Cifuentes'] })]
});
await expect.element(page.getByText('Marta Cifuentes')).toBeInTheDocument();

@ -67,6 +67,7 @@ function cancion(parcial: Partial<Cancion> = {}): Cancion {
interpretes: ['Marta Cifuentes'],
destacada: true,
etiquetas: ['Desamor', 'Dueto'],
generos: [],
enlaces: {},
instrumental: false,
idioma: 'es',

@ -97,6 +97,12 @@
.pista::-webkit-slider-runnable-track {
height: 4px;
border-radius: 2px;
/*
* El degradado va escrito aquí y no en una variable de `app.css`: un
* `var()` dentro de otra variable se sustituye donde se **declara**, y
* allí `--avance` no existe, así que el token entero salía inválido y el
* carril no se pintaba. Donde se declara `--avance` es aquí.
*/
background: linear-gradient(
to right,
var(--c-accent) var(--avance),
@ -107,6 +113,12 @@
.pista::-moz-range-track {
height: 4px;
border-radius: 2px;
/*
* El degradado va escrito aquí y no en una variable de `app.css`: un
* `var()` dentro de otra variable se sustituye donde se **declara**, y
* allí `--avance` no existe, así que el token entero salía inválido y el
* carril no se pintaba. Donde se declara `--avance` es aquí.
*/
background: linear-gradient(
to right,
var(--c-accent) var(--avance),

@ -158,7 +158,7 @@
<button
type="button"
class="control principal"
class="control principal oro"
onclick={() => reproductor.alternar()}
aria-label={reproductor.sonando ? 'Pausar' : 'Reproducir'}
>
@ -324,12 +324,16 @@
height: 2.25rem;
border: none;
border-radius: 999px;
background: transparent;
color: var(--c-text-muted);
cursor: pointer;
}
.control:hover:not(:disabled) {
/* `:not(.oro)`, o estas dos le borrarían el degradado al botón principal. */
.control:not(.oro) {
background: transparent;
}
.control:hover:not(:disabled, .oro) {
background: var(--c-bg-sunken);
color: var(--c-text);
}
@ -343,17 +347,25 @@
color: var(--c-focus);
}
/*
* El mismo metal que los botones del sitio: la clase `.oro` de `app.css`
* pone el degradado, el filo de luz y el barrido al pasar por encima. Antes
* era `--c-accent` plano y, al lado de un «Escuchar la muestra» dorado,
* parecía otro color.
*/
.control.principal {
width: 2.75rem;
height: 2.75rem;
background: var(--c-accent);
/*
* El icono, del marino de la página. `.oro` ya lo pone, pero `.control`
* le gana por especificidad —lleva la clase de ámbito de Svelte— y lo
* dejaba del gris de los botones de al lado, encima del oro.
*/
color: var(--c-accent-text);
}
.control.principal:hover {
background: var(--c-accent);
color: var(--c-accent-text);
opacity: 0.85;
}
.marca {

@ -63,6 +63,7 @@
.deslizador::-webkit-slider-runnable-track {
height: 4px;
border-radius: 2px;
/* Literal, no en una variable: ver la nota de `BarraProgreso`. */
background: linear-gradient(
to right,
var(--c-accent) var(--avance),
@ -73,6 +74,7 @@
.deslizador::-moz-range-track {
height: 4px;
border-radius: 2px;
/* Literal, no en una variable: ver la nota de `BarraProgreso`. */
background: linear-gradient(
to right,
var(--c-accent) var(--avance),

@ -253,6 +253,9 @@ export function parsearCancion(datos: Datos, slug: string, archivo: string): Can
interpretes: listaDeTextos(datos, 'interpretes', archivo),
destacada: booleano(datos, 'destacada', archivo),
etiquetas: listaDeTextos(datos, 'etiquetas', archivo),
// Los generos viven en la base, no en el frontmatter: este lector es del
// material antiguo y nunca los tuvo.
generos: [],
enlaces: enlaces(datos, 'enlaces', archivo, PLATAFORMAS),
instrumental: booleano(datos, 'instrumental', archivo),
idioma: textoConDefecto(datos, 'idioma', archivo, 'es'),

@ -104,6 +104,31 @@ class Reproductor {
/** No es reactivo a proposito: es un recurso, no estado de interfaz. */
private audio: HTMLAudioElement | null = null;
/** El cuadro pedido para ir leyendo el reloj del audio, o 0 si no hay. */
private cuadro = 0;
private seguirElReloj(): void {
if (!browser || this.cuadro) return;
const paso = () => {
const el = this.audio;
if (!el || el.paused) {
this.cuadro = 0;
return;
}
this.tiempo = el.currentTime;
this.cuadro = requestAnimationFrame(paso);
};
this.cuadro = requestAnimationFrame(paso);
}
private soltarElReloj(): void {
if (!this.cuadro) return;
cancelAnimationFrame(this.cuadro);
this.cuadro = 0;
/* El último valor bueno: al parar, `currentTime` ya no se mueve más. */
if (this.audio) this.tiempo = this.audio.currentTime;
}
/* --------------------------------------------------------------------
* Elemento de audio
* ----------------------------------------------------------------- */
@ -117,9 +142,19 @@ class Reproductor {
el.volume = this.volumen;
el.muted = this.silenciado;
/*
* `timeupdate` salta unas cuatro veces por segundo, y con eso la barra de
* progreso avanza a tirones. Mientras suena se lee el reloj en cada cuadro
* —`requestAnimationFrame`—, que es lo que hace que la línea corra en vez
* de dar pasos; el evento se deja para lo que no genera cuadros: la pausa,
* el salto a otro punto y el final.
*/
el.addEventListener('timeupdate', () => {
this.tiempo = el.currentTime;
if (!this.sonando) this.tiempo = el.currentTime;
});
el.addEventListener('play', () => this.seguirElReloj());
el.addEventListener('pause', () => this.soltarElReloj());
el.addEventListener('ended', () => this.soltarElReloj());
el.addEventListener('durationchange', () => {
this.duracionAudio = Number.isFinite(el.duration) ? el.duration : 0;
});

@ -34,10 +34,12 @@ import {
cancion,
cancionEnlace,
cancionEtiqueta,
cancionGenero,
cancionPersona,
creditoAlbum,
estilo,
etiqueta,
genero,
medio,
pais,
persona,
@ -132,7 +134,8 @@ async function cargar(): Promise<Catalogo> {
traducciones,
registros,
partes,
creditos
creditos,
generosDeCancion
] = await Promise.all([
db.select().from(estilo).orderBy(asc(estilo.orden), asc(estilo.nombre)),
@ -231,7 +234,18 @@ async function cargar(): Promise<Catalogo> {
})
.from(creditoAlbum)
.leftJoin(persona, eq(persona.id, creditoAlbum.personaId))
.orderBy(asc(creditoAlbum.orden))
.orderBy(asc(creditoAlbum.orden)),
/*
* El género musical del tema —bolero, bachata—, que no es lo mismo que el
* estilo: el estilo es cómo se ordena la obra en esta web y es uno solo;
* los géneros son de qué está hecha la canción y pueden ser varios.
*/
db
.select({ cancionId: cancionGenero.cancionId, nombre: genero.nombre, slug: genero.slug })
.from(cancionGenero)
.innerJoin(genero, eq(genero.id, cancionGenero.generoId))
.orderBy(asc(genero.nombre))
]);
/* --- Índices para no recorrer listas dentro de bucles ------------------ */
@ -239,6 +253,7 @@ async function cargar(): Promise<Catalogo> {
const porCancion = {
personas: porClave(personas, (p) => p.cancionId),
etiquetas: porClave(etiquetas, (e) => e.cancionId),
generos: porClave(generosDeCancion, (g) => g.cancionId),
enlaces: porClave(enlacesCancion, (e) => e.cancionId),
videos: porClave(videos, (v) => v.cancionId),
partes: porClave(partes, (p) => p.cancionId)
@ -350,6 +365,10 @@ async function cargar(): Promise<Catalogo> {
interpretes: suyas.filter((p) => p.rol === 'interprete').map((p) => p.nombre),
destacada: fila.destacada,
etiquetas: (porCancion.etiquetas.get(fila.id) ?? []).map((e) => e.nombre),
generos: (porCancion.generos.get(fila.id) ?? []).map((g) => ({
slug: g.slug,
nombre: g.nombre
})),
enlaces: comoEnlaces(porCancion.enlaces.get(fila.id) ?? []),
instrumental: fila.instrumental,
idioma: fila.idioma,

@ -151,6 +151,12 @@ export interface Cancion {
* Cruzan el catalogo por donde no lo hacen el estilo ni el disco.
*/
etiquetas: string[];
/**
* El género musical del tema —bolero, bachata—, que no es el estilo. El
* estilo es como se ordena la obra en esta web y es uno solo; los generos son
* de que esta hecha la cancion y pueden ser varios. Llevan al glosario.
*/
generos: { slug: string; nombre: string }[];
enlaces: Enlaces;
/** El cuerpo del Markdown es la letra completa del tema. */
instrumental: boolean;

@ -2,23 +2,24 @@
/**
* 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.
* Van **a pantalla completa y desnudas**: sin cabecera, sin pie y sin barra de
* reproducción. La lámina es el fondo, y encima va lo que hay que leer y
* pulsar, centrado.
*
* El fondo se recorta al ancho (`cover`), así que el color de relleno de
* `FONDOS` casi nunca se ve; está para el instante antes de que cargue la
* imagen y para las proporciones raras, y es el del borde de cada lámina
* —medido sobre el archivo—, de modo que no hay salto de color.
*
* ## 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
* 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.
* Al entrar se para lo que hubiera sonando. Sin barra no hay con qué pararlo, y
* un tema que sigue sonando sin controles a la vista es peor que un silencio.
*
* 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.
@ -29,12 +30,17 @@
import Meta from '$lib/components/Meta.svelte';
import { reproductor } from '$lib/player/player.svelte';
/** El color del borde de cada lámina, medido sobre el archivo. */
const FONDOS: Record<string, string> = { '404': '#00122a', '500': '#000719' };
/** El de la banda lisa donde va el texto. Es el mismo en las dos. */
const BANDA = '#00213c';
/**
* 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.»
* nuestros vienen redactados: «Ese tema no existe.»
*/
const GENERICOS = new Set([
'Not Found',
@ -49,15 +55,13 @@
);
/**
* 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.
* Solo hay lámina para dos casos, que son los dos que tienen dibujo. Un 403 no
* es ninguno: la página existe y el sitio funciona, es que no te toca. Ese
* 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'
@ -66,11 +70,7 @@
: (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.
*/
/** No repite lo que la lámina lleva dibujado: lo dice con otras palabras. */
const encabezado = $derived(
caso === 'perdida'
? 'Esta dirección no lleva a ninguna parte'
@ -90,47 +90,40 @@
let audio = $state<HTMLAudioElement | null>(null);
let sonando = $state(false);
/* Al llegar aquí ya no hay barra con la que pararlo. */
$effect(() => {
if (reproductor.sonando) reproductor.pausar();
});
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();
if (sonando) audio.pause();
else 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" 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="pantalla"
class:con-lamina={Boolean(lamina)}
style={lamina
? `--fondo: ${FONDOS[lamina]}; --banda: ${BANDA};` +
` --lamina: url('/errores/${lamina}.webp');` +
` --lamina-alta: url('/errores/${lamina}-vertical.webp')`
: undefined}
>
<div class="dicho">
<p class="codigo muted">{page.status}</p>
<!--
El código solo cuando no hay lámina. Con ella, el «404» ya está escrito
en letras de un palmo a la izquierda, y repetirlo en pequeño al lado no
añade nada.
-->
{#if !lamina}
<p class="codigo">{page.status}</p>
{:else}
<span class="filete" aria-hidden="true"></span>
{/if}
<h1>{encabezado}</h1>
{#if detalle}
<p class="detalle">{detalle}</p>
@ -160,7 +153,7 @@
<Icono nombre={sonando ? 'pausa' : 'play'} tamano={18} />
{sonando ? 'Parar' : 'Poner música'}
</button>
<p class="mientras muted">
<p class="mientras">
{caso === 'perdida' ? 'Ya que has llegado hasta aquí.' : 'Mientras lo arreglamos.'}
</p>
</div>
@ -170,50 +163,101 @@
<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.
* `100dvh` y no `100vh`: en el móvil, con la barra del navegador desplegada,
* `vh` cuenta una pantalla que no se ve entera y el bloque queda descolocado.
*/
.error {
.pantalla {
position: relative;
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);
place-items: center;
min-height: 100dvh;
padding: var(--sp-6) var(--sp-5);
background: var(--c-bg);
}
/* Sin lámina —un 403, un 401— no hay dos columnas que repartir. */
.error.sola {
grid-template-columns: minmax(0, 1fr);
max-width: 44rem;
/*
* La lámina viene armonizada: el dibujo ocupa el 62,6 % de la izquierda y el
* resto es una banda lisa, que es el sitio del texto.
*
* No se recorta. Se pega a la izquierda con el alto exacto de la pantalla
* (`auto 100dvh`) y lo que sobre a la derecha se pinta del color de la propia
* banda, que es plano: en un monitor ancho la banda simplemente se alarga y
* no se ve dónde acaba la imagen. Recortar al ancho —`cover`— se comía la
* línea del dominio de arriba, y eso es parte del cartel.
*
* La columna del texto empieza donde empieza la banda. Como el dibujo mide
* 1,406 de ancho por cada 1 de alto y la banda arranca en su 62,6 %, ese
* punto está siempre a 0,88 × el alto de la pantalla, sea cual sea el ancho.
*/
.pantalla.con-lamina {
grid-template-columns: 88dvh 1fr;
place-items: center;
padding: 0;
background:
var(--lamina) left center / auto 100dvh no-repeat,
var(--banda);
}
.lamina {
margin: 0;
/*
* `justify-self: stretch` a propósito: en una rejilla, un elemento centrado se
* encoge a su contenido, y el titular se partía en cuatro renglones sueltos
* dentro de una banda que tenía sitio de sobra. Ocupa la banda y se centra por
* dentro con el margen automático.
*/
.pantalla.con-lamina .dicho {
grid-column: 2;
justify-self: stretch;
width: auto;
max-width: none;
padding: var(--sp-6) var(--sp-5);
}
.codigo {
margin-bottom: var(--sp-2);
font-size: var(--fs-sm);
font-variant-numeric: tabular-nums;
.dicho {
position: relative;
max-width: 46ch;
text-align: center;
}
.con-lamina .dicho > * {
max-width: 34rem;
margin-inline: auto;
}
.lamina img {
/*
* Un filete dorado, corto, encima del titular. Es el mismo gesto que el
* cartel hace arriba a la izquierda junto a «senzapaura.es»: sin él, la banda
* es texto suelto sobre un plano y no parece la otra mitad de la lámina.
*/
.filete {
display: block;
width: 100%;
height: auto;
border-radius: var(--radius-tarjeta);
width: 3.5rem;
height: 1px;
margin: 0 auto var(--sp-5);
background: var(--c-accent);
opacity: 0.7;
}
.dicho {
max-width: 44ch;
/*
* A la altura del cartel: al lado de un «404» de un palmo, un titular de
* cuerpo mediano parecía un pie de foto. Sube al mismo escalón que los de las
* páginas grandes del sitio.
*/
.con-lamina h1 {
font-size: var(--fs-3xl);
text-wrap: balance;
}
.con-lamina .detalle {
font-size: var(--fs-lg);
text-wrap: pretty;
}
.codigo {
margin-bottom: var(--sp-2);
font-size: var(--fs-sm);
font-variant-numeric: tabular-nums;
letter-spacing: 0.12em;
color: var(--c-text-muted);
}
h1 {
@ -228,15 +272,18 @@
.acciones {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: var(--sp-3);
margin-top: var(--sp-5);
}
/* Separada de los botones de salir: esto no es una forma de irse. */
/* En fila. Apilados parecían una lista de opciones, no dos salidas. */
.con-lamina .acciones {
max-width: none;
}
.musica {
margin-top: var(--sp-7);
padding-top: var(--sp-5);
border-top: 1px solid var(--c-border);
margin-top: var(--sp-6);
}
.poner {
@ -261,6 +308,21 @@
.mientras {
margin: var(--sp-2) 0 0;
font-size: var(--fs-xs);
color: var(--c-text-muted);
}
/*
* Sobre la lámina el gris apagado del sitio se pierde: el fondo de aquí es
* mucho más oscuro que el del sitio y el contraste se cae.
*/
.con-lamina .codigo,
.con-lamina .detalle,
.con-lamina .mientras {
color: #c2cbdb;
}
.con-lamina .poner {
border-color: #4a5a76;
}
@media (prefers-reduced-motion: reduce) {
@ -270,28 +332,43 @@
}
/*
* 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.
* En vertical el reparto de arriba no vale: el cartel es apaisado y, al alto
* de un móvil, se quedaría en una franja del centro con el número cortado por
* los dos lados.
*
* Aquí se apila, y con la otra versión del archivo: solo el dibujo, sin la
* banda. Poner el cartel entero dejaba su banda como un rectángulo más claro
* pegado a la derecha de la imagen, sin relación con el panel de abajo.
*
* El panel va del color del borde del dibujo —`--fondo`, no `--banda`—, y por
* eso no se ve la juntura: lo que hay debajo de la imagen es exactamente el
* color con el que la imagen termina.
*
* El texto se baja con un `padding-top` de la altura que ocupa el dibujo —el
* ancho partido por su proporción, 0,880— porque una fila de rejilla no puede
* medirse contra el ancho del elemento.
*/
@media (max-width: 52rem) {
.error {
@media (max-aspect-ratio: 1.41 / 1), (max-width: 52rem) {
.pantalla.con-lamina {
grid-template-columns: minmax(0, 1fr);
gap: var(--sp-5);
justify-items: center;
text-align: center;
align-content: start;
padding: 0;
background:
var(--lamina-alta) top center / 100% auto no-repeat,
var(--fondo);
}
.lamina {
max-width: 20rem;
.pantalla.con-lamina .dicho {
grid-column: 1;
padding: calc(100vw / 0.88 + var(--sp-6)) var(--sp-5) var(--sp-6);
}
.acciones {
justify-content: center;
.con-lamina .dicho > * {
max-width: 34rem;
}
.musica {
width: 100%;
.con-lamina h1 {
font-size: var(--fs-2xl);
}
}
</style>

@ -18,15 +18,21 @@
const hayReproduccion = $derived(reproductor.pista !== null);
/*
* La página de espera va desnuda: sin cabecera, sin pie y sin reproductor.
* Mientras la web no está abierta no hay a dónde ir, y un menú cuyos enlaces
* llevan todos al mismo cartel es peor que no tener menú.
* Dos páginas van desnudas: sin cabecera, sin pie y sin reproductor.
*
* La de espera, porque mientras la web no está abierta no hay a dónde ir, y un
* menú cuyos enlaces llevan todos al mismo cartel es peor que no tener menú.
*
* Y las de error, porque son carteles a pantalla completa —el número y el
* rótulo van dibujados dentro— y colgarles encima la barra del sitio las
* convierte en una miniatura de algo pensado para ocupar la pantalla. De un
* error se sale por una puerta, que la página pone, no por siete.
*
* Se resuelve aquí y no con un grupo de rutas propio porque eso obligaría a
* mover todas las demás páginas dentro de otra carpeta, y sería un cambio
* enorme para una excepción.
*/
const desnuda = $derived(page.url.pathname === RUTA_ESPERA);
const desnuda = $derived(page.url.pathname === RUTA_ESPERA || page.error !== null);
/*
* Avisa de que la página ya es interactiva.

@ -179,7 +179,6 @@
gap: var(--sp-6);
align-items: start;
padding-bottom: var(--sp-6);
border-bottom: 1px solid var(--c-border);
}
.portada {

@ -328,7 +328,6 @@
gap: var(--sp-6);
align-items: stretch;
padding-bottom: var(--sp-5);
border-bottom: 1px solid var(--c-border);
}
.portada {

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 73 KiB

Loading…
Cancel
Save

Powered by TurnKey Linux.