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.describe('La página de error', () => {
test('lleva su lámina y sale del paso', async ({ page }) => { 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'); await page.goto('/no-existe-esto');
const lamina = page.locator('.lamina img'); // La lámina es el fondo de la pantalla, no una imagen dentro del contenido.
await expect(lamina).toHaveAttribute('src', '/errores/404.webp'); const pantalla = page.locator('.pantalla');
// Que exista el archivo, no solo la etiqueta. await expect(pantalla).toHaveCSS('background-image', /errores\/404\.webp/);
expect(await lamina.evaluate((i: HTMLImageElement) => i.naturalWidth)).toBeGreaterThan(0); // 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 page.getByRole('link', { name: 'Volver al inicio' }).click();
await expect(page).toHaveURL('/'); 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 * 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"`. * 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 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); expect(pedidos.some((u) => u.endsWith('.mp3'))).toBe(false);
await page.getByRole('button', { name: 'Poner música' }).click(); await page.getByRole('button', { name: 'Poner música' }).click();

@ -28,13 +28,18 @@
* el píxel exacto. * el píxel exacto.
*/ */
enlaces?: boolean; 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> </script>
{#if etiquetas.length > 0} {#if etiquetas.length > 0}
<ul class="etiquetas" aria-label={titulo}> <ul class="etiquetas" class:menudas aria-label={titulo}>
{#each etiquetas as etiqueta (etiqueta)} {#each etiquetas as etiqueta (etiqueta)}
<li> <li>
{#if enlaces} {#if enlaces}
@ -96,6 +101,30 @@
--velo: oklch(0.45 0.13 var(--tono) / 0.1); --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:hover,
a.etiqueta:focus-visible { a.etiqueta:focus-visible {
border-color: var(--claro); border-color: var(--claro);

@ -9,6 +9,8 @@
import { resolve } from '$app/paths'; import { resolve } from '$app/paths';
import BotonCarrito from './BotonCarrito.svelte'; import BotonCarrito from './BotonCarrito.svelte';
import BotonReproducir from './BotonReproducir.svelte'; import BotonReproducir from './BotonReproducir.svelte';
import Etiquetas from './Etiquetas.svelte';
import SinPortada from './SinPortada.svelte';
import { aPistas } from '$lib/player/pista'; import { aPistas } from '$lib/player/pista';
import { reproductor } from '$lib/player/player.svelte'; import { reproductor } from '$lib/player/player.svelte';
import type { Cancion } from '$lib/types'; import type { Cancion } from '$lib/types';
@ -23,6 +25,49 @@
let { canciones, numerar = true }: Props = $props(); let { canciones, numerar = true }: Props = $props();
const pistas = $derived(aPistas(canciones)); 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> </script>
{#if canciones.length === 0} {#if canciones.length === 0}
@ -38,20 +83,51 @@
<BotonReproducir {pistas} indice={i} /> <BotonReproducir {pistas} indice={i} />
</div> </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"> <div class="datos">
<a class="titulo" href={resolve('/canciones/[slug]', { slug: cancion.slug })}> <a class="titulo" href={resolve('/canciones/[slug]', { slug: cancion.slug })}>
{cancion.titulo} {cancion.titulo}
</a> </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} {#if cancion.instrumental}
<span class="marca">Instrumental</span> <span class="marca">Instrumental</span>
{/if} {/if}
{#if cancion.extracto.length > 0} {#each cancion.generos as g (g.slug)}
{cancion.extracto.join(' / ')} <a class="genero" href={resolve('/generos/[slug]', { slug: g.slug })}>{g.nombre}</a>
{:else} {/each}
{listaDeNombres(cancion.interpretes)} <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} {/if}
</p> </div>
</div> </div>
<div class="final"> <div class="final">
@ -63,6 +139,22 @@
</li> </li>
{/each} {/each}
</ol> </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} {/if}
<style> <style>
@ -75,13 +167,62 @@
.fila { .fila {
display: grid; display: grid;
grid-template-columns: auto minmax(0, 1fr) auto; grid-template-columns: auto auto minmax(0, 1fr) auto;
align-items: center; align-items: center;
gap: var(--sp-3); gap: var(--sp-3);
padding: var(--sp-2) var(--sp-1); padding: var(--sp-2) var(--sp-1);
border-bottom: 1px solid var(--c-border); 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 { .fila:hover {
background: var(--c-bg-raised); background: var(--c-bg-raised);
} }
@ -115,11 +256,48 @@
text-decoration: underline; text-decoration: underline;
} }
.extracto { /*
margin: 0; * En una línea y sin envolver: en una lista de diez temas, una fila que crece
font-size: var(--fs-xs); * 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; 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; white-space: nowrap;
} }
@ -127,7 +305,9 @@
border: 1px solid var(--c-border); border: 1px solid var(--c-border);
border-radius: var(--radius-boton); border-radius: var(--radius-boton);
padding: 0 0.4em; padding: 0 0.4em;
margin-right: var(--sp-1); font-size: var(--fs-xs);
color: var(--c-text-muted);
white-space: nowrap;
} }
.final { .final {

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

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

@ -97,6 +97,12 @@
.pista::-webkit-slider-runnable-track { .pista::-webkit-slider-runnable-track {
height: 4px; height: 4px;
border-radius: 2px; 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( background: linear-gradient(
to right, to right,
var(--c-accent) var(--avance), var(--c-accent) var(--avance),
@ -107,6 +113,12 @@
.pista::-moz-range-track { .pista::-moz-range-track {
height: 4px; height: 4px;
border-radius: 2px; 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( background: linear-gradient(
to right, to right,
var(--c-accent) var(--avance), var(--c-accent) var(--avance),

@ -158,7 +158,7 @@
<button <button
type="button" type="button"
class="control principal" class="control principal oro"
onclick={() => reproductor.alternar()} onclick={() => reproductor.alternar()}
aria-label={reproductor.sonando ? 'Pausar' : 'Reproducir'} aria-label={reproductor.sonando ? 'Pausar' : 'Reproducir'}
> >
@ -324,12 +324,16 @@
height: 2.25rem; height: 2.25rem;
border: none; border: none;
border-radius: 999px; border-radius: 999px;
background: transparent;
color: var(--c-text-muted); color: var(--c-text-muted);
cursor: pointer; 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); background: var(--c-bg-sunken);
color: var(--c-text); color: var(--c-text);
} }
@ -343,17 +347,25 @@
color: var(--c-focus); 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 { .control.principal {
width: 2.75rem; width: 2.75rem;
height: 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); color: var(--c-accent-text);
} }
.control.principal:hover { .control.principal:hover {
background: var(--c-accent);
color: var(--c-accent-text); color: var(--c-accent-text);
opacity: 0.85;
} }
.marca { .marca {

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

@ -253,6 +253,9 @@ export function parsearCancion(datos: Datos, slug: string, archivo: string): Can
interpretes: listaDeTextos(datos, 'interpretes', archivo), interpretes: listaDeTextos(datos, 'interpretes', archivo),
destacada: booleano(datos, 'destacada', archivo), destacada: booleano(datos, 'destacada', archivo),
etiquetas: listaDeTextos(datos, 'etiquetas', 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), enlaces: enlaces(datos, 'enlaces', archivo, PLATAFORMAS),
instrumental: booleano(datos, 'instrumental', archivo), instrumental: booleano(datos, 'instrumental', archivo),
idioma: textoConDefecto(datos, 'idioma', archivo, 'es'), idioma: textoConDefecto(datos, 'idioma', archivo, 'es'),

@ -104,6 +104,31 @@ class Reproductor {
/** No es reactivo a proposito: es un recurso, no estado de interfaz. */ /** No es reactivo a proposito: es un recurso, no estado de interfaz. */
private audio: HTMLAudioElement | null = null; 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 * Elemento de audio
* ----------------------------------------------------------------- */ * ----------------------------------------------------------------- */
@ -117,9 +142,19 @@ class Reproductor {
el.volume = this.volumen; el.volume = this.volumen;
el.muted = this.silenciado; 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', () => { 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', () => { el.addEventListener('durationchange', () => {
this.duracionAudio = Number.isFinite(el.duration) ? el.duration : 0; this.duracionAudio = Number.isFinite(el.duration) ? el.duration : 0;
}); });

@ -34,10 +34,12 @@ import {
cancion, cancion,
cancionEnlace, cancionEnlace,
cancionEtiqueta, cancionEtiqueta,
cancionGenero,
cancionPersona, cancionPersona,
creditoAlbum, creditoAlbum,
estilo, estilo,
etiqueta, etiqueta,
genero,
medio, medio,
pais, pais,
persona, persona,
@ -132,7 +134,8 @@ async function cargar(): Promise<Catalogo> {
traducciones, traducciones,
registros, registros,
partes, partes,
creditos creditos,
generosDeCancion
] = await Promise.all([ ] = await Promise.all([
db.select().from(estilo).orderBy(asc(estilo.orden), asc(estilo.nombre)), db.select().from(estilo).orderBy(asc(estilo.orden), asc(estilo.nombre)),
@ -231,7 +234,18 @@ async function cargar(): Promise<Catalogo> {
}) })
.from(creditoAlbum) .from(creditoAlbum)
.leftJoin(persona, eq(persona.id, creditoAlbum.personaId)) .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 ------------------ */ /* --- Índices para no recorrer listas dentro de bucles ------------------ */
@ -239,6 +253,7 @@ async function cargar(): Promise<Catalogo> {
const porCancion = { const porCancion = {
personas: porClave(personas, (p) => p.cancionId), personas: porClave(personas, (p) => p.cancionId),
etiquetas: porClave(etiquetas, (e) => e.cancionId), etiquetas: porClave(etiquetas, (e) => e.cancionId),
generos: porClave(generosDeCancion, (g) => g.cancionId),
enlaces: porClave(enlacesCancion, (e) => e.cancionId), enlaces: porClave(enlacesCancion, (e) => e.cancionId),
videos: porClave(videos, (v) => v.cancionId), videos: porClave(videos, (v) => v.cancionId),
partes: porClave(partes, (p) => p.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), interpretes: suyas.filter((p) => p.rol === 'interprete').map((p) => p.nombre),
destacada: fila.destacada, destacada: fila.destacada,
etiquetas: (porCancion.etiquetas.get(fila.id) ?? []).map((e) => e.nombre), 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) ?? []), enlaces: comoEnlaces(porCancion.enlaces.get(fila.id) ?? []),
instrumental: fila.instrumental, instrumental: fila.instrumental,
idioma: fila.idioma, idioma: fila.idioma,

@ -151,6 +151,12 @@ export interface Cancion {
* Cruzan el catalogo por donde no lo hacen el estilo ni el disco. * Cruzan el catalogo por donde no lo hacen el estilo ni el disco.
*/ */
etiquetas: string[]; 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; enlaces: Enlaces;
/** El cuerpo del Markdown es la letra completa del tema. */ /** El cuerpo del Markdown es la letra completa del tema. */
instrumental: boolean; instrumental: boolean;

@ -2,23 +2,24 @@
/** /**
* Las páginas de error, 404 y 500. * 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 * Van **a pantalla completa y desnudas**: sin cabecera, sin pie y sin barra de
* dibujados dentro, así que la página **no** los repite: escribe lo que la * reproducción. La lámina es el fondo, y encima va lo que hay que leer y
* imagen no puede decir —qué ha pasado y por dónde se sale— y deja el titular * pulsar, centrado.
* 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 * El fondo se recorta al ancho (`cover`), así que el color de relleno de
* sobrevive a que la imagen no cargue. * `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 * ## La música
* *
* Suena con un botón, no sola. Los navegadores cortan el audio que empieza * Suena con un botón, no sola. Los navegadores cortan el audio que empieza sin
* sin que el usuario haya tocado nada, y quien llega a un 404 desde un enlace * que el usuario haya tocado nada, y quien llega a un 404 desde un enlace roto
* roto no ha tocado nada todavía: arrancarla al cargar no fallaría, es que no * no ha tocado nada todavía: arrancarla al cargar no fallaría, es que no
* sonaría, y en silencio. * sonaría, y en silencio.
* *
* Y suena una cosa cada vez. Si había un tema en la barra de reproducción, se * Al entrar se para lo que hubiera sonando. Sin barra no hay con qué pararlo, y
* pausa al dar aquí; si desde la barra se vuelve a dar al play, esto se para. * un tema que sigue sonando sin controles a la vista es peor que un silencio.
* 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 * 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. * 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 Meta from '$lib/components/Meta.svelte';
import { reproductor } from '$lib/player/player.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 * 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 * 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 * 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 * nuestros vienen redactados: «Ese tema no existe.»
* administra el sitio.»
*/ */
const GENERICOS = new Set([ const GENERICOS = new Set([
'Not Found', 'Not Found',
@ -49,15 +55,13 @@
); );
/** /**
* Solo hay lámina para dos casos, y son los dos que tienen dibujo: la que dice * Solo hay lámina para dos casos, que son los dos que tienen dibujo. Un 403 no
* «página no encontrada» y la que dice «algo ha salido mal». Un 403 no es * es ninguno: la página existe y el sitio funciona, es que no te toca. Ese
* ninguna de las dos cosas —la página existe y el sitio funciona, es que no * sale sin ilustración en vez de con una que miente.
* 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 caso = $derived(page.status === 404 ? 'perdida' : page.status >= 500 ? 'rota' : 'otra');
const lamina = $derived(caso === 'perdida' ? '404' : caso === 'rota' ? '500' : null); 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( const titulo = $derived(
caso === 'perdida' caso === 'perdida'
? 'Página no encontrada' ? 'Página no encontrada'
@ -66,11 +70,7 @@
: (mensaje ?? `Error ${page.status}`) : (mensaje ?? `Error ${page.status}`)
); );
/** /** No repite lo que la lámina lleva dibujado: lo dice con otras palabras. */
* 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( const encabezado = $derived(
caso === 'perdida' caso === 'perdida'
? 'Esta dirección no lleva a ninguna parte' ? 'Esta dirección no lleva a ninguna parte'
@ -90,47 +90,40 @@
let audio = $state<HTMLAudioElement | null>(null); let audio = $state<HTMLAudioElement | null>(null);
let sonando = $state(false); let sonando = $state(false);
/* Al llegar aquí ya no hay barra con la que pararlo. */
$effect(() => {
if (reproductor.sonando) reproductor.pausar();
});
function alternar(): void { function alternar(): void {
if (!audio) return; if (!audio) return;
if (sonando) { if (sonando) audio.pause();
audio.pause(); else void audio.play();
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> </script>
<Meta {titulo} sinIndexar /> <Meta {titulo} sinIndexar />
<div class="container error" class:sola={!lamina}> <div
{#if lamina} class="pantalla"
<figure class="lamina"> class:con-lamina={Boolean(lamina)}
<img style={lamina
src="/errores/{lamina}.webp" ? `--fondo: ${FONDOS[lamina]}; --banda: ${BANDA};` +
alt={caso === 'perdida' ` --lamina: url('/errores/${lamina}.webp');` +
? 'Una chica sentada junto a la funda de una guitarra, mirando hacia arriba, bajo un 404 dorado.' ` --lamina-alta: url('/errores/${lamina}-vertical.webp')`
: 'Un 500 dorado sobre el fondo marino, entre líneas y notas sueltas.'} : undefined}
width="1200" >
height="1364"
fetchpriority="high"
/>
</figure>
{/if}
<div class="dicho"> <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> <h1>{encabezado}</h1>
{#if detalle} {#if detalle}
<p class="detalle">{detalle}</p> <p class="detalle">{detalle}</p>
@ -160,7 +153,7 @@
<Icono nombre={sonando ? 'pausa' : 'play'} tamano={18} /> <Icono nombre={sonando ? 'pausa' : 'play'} tamano={18} />
{sonando ? 'Parar' : 'Poner música'} {sonando ? 'Parar' : 'Poner música'}
</button> </button>
<p class="mientras muted"> <p class="mientras">
{caso === 'perdida' ? 'Ya que has llegado hasta aquí.' : 'Mientras lo arreglamos.'} {caso === 'perdida' ? 'Ya que has llegado hasta aquí.' : 'Mientras lo arreglamos.'}
</p> </p>
</div> </div>
@ -170,50 +163,101 @@
<style> <style>
/* /*
* La lámina es vertical (0,88:1) y manda el alto; el texto se centra a su * `100dvh` y no `100vh`: en el móvil, con la barra del navegador desplegada,
* lado en vez de colgar de arriba, que con una imagen tan alta dejaría el * `vh` cuenta una pantalla que no se ve entera y el bloque queda descolocado.
* 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 { .pantalla {
position: relative;
display: grid; display: grid;
grid-template-columns: minmax(0, 30rem) minmax(0, 1fr); place-items: center;
gap: var(--sp-7); min-height: 100dvh;
align-items: center; padding: var(--sp-6) var(--sp-5);
max-width: 64rem; background: var(--c-bg);
margin-inline: auto;
padding-block: var(--sp-7);
} }
/* Sin lámina —un 403, un 401— no hay dos columnas que repartir. */ /*
.error.sola { * La lámina viene armonizada: el dibujo ocupa el 62,6 % de la izquierda y el
grid-template-columns: minmax(0, 1fr); * resto es una banda lisa, que es el sitio del texto.
max-width: 44rem; *
* 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 { .dicho {
margin-bottom: var(--sp-2); position: relative;
font-size: var(--fs-sm); max-width: 46ch;
font-variant-numeric: tabular-nums; 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; display: block;
width: 100%; width: 3.5rem;
height: auto; height: 1px;
border-radius: var(--radius-tarjeta); 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 { h1 {
@ -228,15 +272,18 @@
.acciones { .acciones {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
justify-content: center;
gap: var(--sp-3); gap: var(--sp-3);
margin-top: var(--sp-5); 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 { .musica {
margin-top: var(--sp-7); margin-top: var(--sp-6);
padding-top: var(--sp-5);
border-top: 1px solid var(--c-border);
} }
.poner { .poner {
@ -261,6 +308,21 @@
.mientras { .mientras {
margin: var(--sp-2) 0 0; margin: var(--sp-2) 0 0;
font-size: var(--fs-xs); 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) { @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 * En vertical el reparto de arriba no vale: el cartel es apaisado y, al alto
* pantalla entera y el texto quedaría por debajo del pliegue, que es justo lo * de un móvil, se quedaría en una franja del centro con el número cortado por
* que hace falta leer. * 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) { @media (max-aspect-ratio: 1.41 / 1), (max-width: 52rem) {
.error { .pantalla.con-lamina {
grid-template-columns: minmax(0, 1fr); grid-template-columns: minmax(0, 1fr);
gap: var(--sp-5); align-content: start;
justify-items: center; padding: 0;
text-align: center; background:
var(--lamina-alta) top center / 100% auto no-repeat,
var(--fondo);
} }
.lamina { .pantalla.con-lamina .dicho {
max-width: 20rem; grid-column: 1;
padding: calc(100vw / 0.88 + var(--sp-6)) var(--sp-5) var(--sp-6);
} }
.acciones { .con-lamina .dicho > * {
justify-content: center; max-width: 34rem;
} }
.musica { .con-lamina h1 {
width: 100%; font-size: var(--fs-2xl);
} }
} }
</style> </style>

@ -18,15 +18,21 @@
const hayReproduccion = $derived(reproductor.pista !== null); const hayReproduccion = $derived(reproductor.pista !== null);
/* /*
* La página de espera va desnuda: sin cabecera, sin pie y sin reproductor. * Dos páginas van desnudas: 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ú. * 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 * 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 * mover todas las demás páginas dentro de otra carpeta, y sería un cambio
* enorme para una excepción. * 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. * Avisa de que la página ya es interactiva.

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

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