diff --git a/README.md b/README.md
index 09c1f7e..10f1da1 100644
--- a/README.md
+++ b/README.md
@@ -296,6 +296,29 @@ quien quiera licenciar una obra necesita localizarla y saber con quién negociar
Si prefieres no enseñar el reparto de autoría, no lo rellenes: lo que falta no
se pinta.
+## Letras en otro idioma
+
+Una canción declara su `idioma` (ISO 639, español por defecto). Cuando no está
+en español y trae `traduccion`, la pestaña de la letra se parte en **dos
+columnas**: la original a la izquierda y el castellano a la derecha, verso a
+verso. Con una sola de las dos cosas se queda en una columna, porque una columna
+vacía sería peor que ninguna.
+
+```yaml
+idioma: en
+traduccion: |
+ Dejé las luces encendidas al salir,
+ no porque fuera a volver
+```
+
+Dos detalles que importan. Cada columna lleva su `lang`, que es lo que impide
+que un lector de pantalla lea el inglés con voz española. Y la traducción se
+guarda tal cual y se pinta con `white-space: pre-line`: es verso, no prosa, y
+una traducción que pierde los cortes deja de servir para seguir la original.
+
+El nombre del idioma se saca de `Intl.DisplayNames`, no de una tabla que
+mantener.
+
## Etiquetas
Cada tema y cada entrada del blog pueden llevar **etiquetas** libres: de qué
@@ -439,10 +462,26 @@ muestra, con una nota distinta por pista.
## La portada
-Tres pasos, en este orden: el **estilo principal a fila completa**, sus discos
-debajo, y el resto de estilos en una rejilla donde cada tarjeta dice cuánto hay
-—discos y temas— y de qué se habla, con las etiquetas más usadas de sus
-canciones.
+Un carrusel de tres láminas: el disco en el tocadiscos, una invitación al taller
+y otra a la tienda. Un carrusel mal hecho es de las piezas más hostiles que hay,
+así que este:
+
+- **se para solo** al pasar el puntero o al entrar el foco, y sigue al salir;
+- tiene un **botón de pausa** de verdad, cuyo estado manda sobre lo demás;
+- **no gira solo** con `prefers-reduced-motion`;
+- deja las láminas ocultas **fuera del orden de tabulación** (`inert`), así que
+ el teclado no cae en botones invisibles;
+- se mueve con las flechas del teclado desde sus controles.
+
+Las tres se pintan a la vez y se cruzan con opacidad, no se desmontan: así el
+fondo de la primera no se recarga al volver a ella. Las dos láminas de llamada
+reutilizan el mismo fondo, de modo que no añaden peso.
+
+Debajo del carrusel, el catálogo en tres pasos:
+
+el **estilo principal a fila completa**, sus discos debajo, y el resto de
+estilos en una rejilla donde cada tarjeta dice cuánto hay —discos y temas— y de
+qué se habla, con las etiquetas más usadas de sus canciones.
El estilo principal no compite en la rejilla a propósito: es el grueso de la
obra, y ponerlo del mismo tamaño que los demás diría lo contrario de lo que es.
diff --git a/e2e/contenido.e2e.ts b/e2e/contenido.e2e.ts
index 0ae70f8..60483d1 100644
--- a/e2e/contenido.e2e.ts
+++ b/e2e/contenido.e2e.ts
@@ -141,3 +141,35 @@ test.describe('Pestañas de la ficha', () => {
await expect(page.getByText('Versión orquestal')).toBeAttached();
});
});
+
+test.describe('Letra en otro idioma', () => {
+ test('se enfrenta con su traducción en dos columnas', async ({ page }) => {
+ await page.goto('/canciones/the-long-way-home');
+
+ await expect(page.getByText('Letra original · inglés')).toBeVisible();
+ await expect(page.getByText('Traducción al español')).toBeVisible();
+
+ // Cada columna declara su idioma: es lo que hace que un lector de pantalla
+ // no lea el inglés con voz española.
+ const columnas = page.locator('.letra.pareada > .texto');
+ await expect(columnas).toHaveCount(2);
+ await expect(columnas.first()).toHaveAttribute('lang', 'en');
+ await expect(columnas.last()).toHaveAttribute('lang', 'es');
+ });
+
+ test('la traducción conserva el corte de los versos', async ({ page }) => {
+ await page.goto('/canciones/the-long-way-home');
+
+ // Verso a verso: si se perdieran los saltos, no se podría seguir la
+ // original en paralelo, que es para lo que está al lado.
+ const traduccion = await page.locator('.verso-a-verso').innerText();
+ expect(traduccion.split('\n').filter((l) => l.trim()).length).toBeGreaterThan(4);
+ });
+
+ test('un tema en español se queda en una sola columna', async ({ page }) => {
+ await page.goto('/canciones/como-si-nada');
+
+ await expect(page.locator('.letra.pareada')).toHaveCount(0);
+ await expect(page.getByText('Traducción al español')).toHaveCount(0);
+ });
+});
diff --git a/e2e/navegacion.e2e.ts b/e2e/navegacion.e2e.ts
index abfab97..80c56e5 100644
--- a/e2e/navegacion.e2e.ts
+++ b/e2e/navegacion.e2e.ts
@@ -97,3 +97,45 @@ test.describe('Navegación', () => {
await expect(page.getByRole('heading', { level: 1 })).toHaveText('Página no encontrada');
});
});
+
+test.describe('Carrusel de la portada', () => {
+ test('tiene tres láminas y solo se ve una', async ({ page }) => {
+ await page.goto('/');
+
+ await expect(page.locator('.lamina')).toHaveCount(3);
+ await expect(page.locator('.lamina.visible')).toHaveCount(1);
+ });
+
+ test('los puntos llevan a cada lámina', async ({ page }) => {
+ await page.goto('/');
+
+ await page.getByRole('button', { name: 'Ir a: Aprender a escribir letras' }).click();
+ await expect(
+ page.getByRole('heading', { name: /Quieres escribir tus propias letras/ })
+ ).toBeVisible();
+
+ await page.getByRole('button', { name: 'Ir a: Comprar canciones' }).click();
+ await expect(page.getByRole('heading', { name: 'Llévate las canciones' })).toBeVisible();
+ });
+
+ test('el teclado no entra en las láminas que no se ven', async ({ page }) => {
+ // Van con `inert`: sin eso, tabular desde la cabecera cae en botones
+ // invisibles y el foco se pierde.
+ await page.goto('/');
+
+ const ocultas = page.locator('.lamina:not(.visible)');
+ await expect(ocultas).toHaveCount(2);
+ for (const lamina of await ocultas.all()) {
+ await expect(lamina).toHaveJSProperty('inert', true);
+ }
+ });
+
+ test('se puede parar el paso automático', async ({ page }) => {
+ await page.goto('/');
+
+ const parar = page.getByRole('button', { name: 'Parar el paso automático' });
+ await expect(parar).toBeVisible();
+ await parar.click();
+ await expect(page.getByRole('button', { name: 'Reanudar el paso automático' })).toBeVisible();
+ });
+});
diff --git a/e2e/reproductor.e2e.ts b/e2e/reproductor.e2e.ts
index ca18eb5..a4ef941 100644
--- a/e2e/reproductor.e2e.ts
+++ b/e2e/reproductor.e2e.ts
@@ -15,6 +15,13 @@ test.describe('Reproductor', () => {
await expect(barra).toBeVisible();
await expect(temaEnBarra(page, 'como-si-nada')).toBeVisible();
+
+ // Y sigue sonando de verdad, no solo pintada: la posición avanza. Sin esto
+ // el test pasaría igual con una barra congelada.
+ const posicion = page.getByLabel('Posición de reproducción');
+ const antes = Number(await posicion.inputValue());
+ await page.waitForTimeout(1500);
+ expect(Number(await posicion.inputValue())).toBeGreaterThan(antes);
});
test('el paso al tema siguiente carga el orden del disco', async ({ page }) => {
diff --git a/src/content/canciones/the-long-way-home.md b/src/content/canciones/the-long-way-home.md
new file mode 100644
index 0000000..2e879cc
--- /dev/null
+++ b/src/content/canciones/the-long-way-home.md
@@ -0,0 +1,29 @@
+---
+titulo: 'The long way home'
+estilo: pop-melodico
+ano: 2025
+idioma: en
+duracion: '3:24'
+interpretes:
+ - 'Senza Paura'
+etiquetas:
+ - 'Viajes'
+ - 'Distancia'
+alaVenta: false
+traduccion: |
+ Dejé las luces encendidas al salir,
+ no porque fuera a volver
+ sino porque la casa vacía
+ no tenía por qué parecerlo.
+
+ Cojo el camino largo a casa,
+ el que pasa por donde ya no vives.
+---
+
+I left the lights on when I walked out,
+not because I planned on coming back
+but because an empty house
+had no business looking like one.
+
+I take the long way home,
+the one that passes where you don't live anymore.
diff --git a/src/lib/components/Carrusel.svelte b/src/lib/components/Carrusel.svelte
new file mode 100644
index 0000000..fbdcbea
--- /dev/null
+++ b/src/lib/components/Carrusel.svelte
@@ -0,0 +1,262 @@
+
+
+
+
+ (retenido = true)}
+ onpointerleave={() => (retenido = false)}
+ onfocusin={() => (retenido = true)}
+ onfocusout={() => (retenido = false)}
+>
+
+
+
+
diff --git a/src/lib/components/ListaCanciones.svelte.spec.ts b/src/lib/components/ListaCanciones.svelte.spec.ts
index 4776aaa..dbe98ab 100644
--- a/src/lib/components/ListaCanciones.svelte.spec.ts
+++ b/src/lib/components/ListaCanciones.svelte.spec.ts
@@ -21,6 +21,7 @@ function cancion(parcial: Partial = {}): Cancion {
etiquetas: [],
enlaces: {},
instrumental: false,
+ idioma: 'es',
inspiracion: [],
extracto: [],
versiones: [],
diff --git a/src/lib/components/TarjetaCancion.svelte.spec.ts b/src/lib/components/TarjetaCancion.svelte.spec.ts
index 905579a..dea0468 100644
--- a/src/lib/components/TarjetaCancion.svelte.spec.ts
+++ b/src/lib/components/TarjetaCancion.svelte.spec.ts
@@ -22,6 +22,7 @@ function cancion(parcial: Partial = {}): Cancion {
etiquetas: ['Desamor', 'Dueto'],
enlaces: {},
instrumental: false,
+ idioma: 'es',
inspiracion: [],
extracto: ['Entras en la casa y no preguntas nada,', 'dejas las llaves donde siempre,'],
versiones: [],
diff --git a/src/lib/content/schema.ts b/src/lib/content/schema.ts
index 4847441..2869aa3 100644
--- a/src/lib/content/schema.ts
+++ b/src/lib/content/schema.ts
@@ -254,6 +254,8 @@ export function parsearCancion(datos: Datos, slug: string, archivo: string): Can
etiquetas: listaDeTextos(datos, 'etiquetas', archivo),
enlaces: enlaces(datos, 'enlaces', archivo, PLATAFORMAS),
instrumental: booleano(datos, 'instrumental', archivo),
+ idioma: textoConDefecto(datos, 'idioma', archivo, 'es'),
+ traduccion: textoOpcional(datos, 'traduccion', archivo),
portada: rutaPublica(datos, 'portada', archivo),
inspiracion: parrafos(datos, 'inspiracion', archivo),
// Lo pone el preprocesador `extracto-de-cancion`, no la persona que
diff --git a/src/lib/types.ts b/src/lib/types.ts
index 5842285..aedbe7c 100644
--- a/src/lib/types.ts
+++ b/src/lib/types.ts
@@ -154,6 +154,17 @@ export interface Cancion {
enlaces: Enlaces;
/** El cuerpo del Markdown es la letra completa del tema. */
instrumental: boolean;
+ /**
+ * Codigo del idioma en que esta escrita la letra, ISO 639. Por defecto
+ * espanol. Es lo que decide si la ficha ensena una columna o dos.
+ */
+ idioma: string;
+ /**
+ * Traduccion al espanol, cuando la letra no lo esta. Se guarda tal cual, con
+ * sus saltos de linea: es verso, no prosa, y una traduccion que pierde el
+ * corte de los versos deja de servir para seguir la original.
+ */
+ traduccion?: string;
/** Portada propia del tema (ruta bajo /static). */
portada?: string;
/** De donde sale el tema, en parrafos ya separados. */
diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte
index 8023299..2ac0bb3 100644
--- a/src/routes/+page.svelte
+++ b/src/routes/+page.svelte
@@ -1,7 +1,9 @@