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)} +> +
+ {#each laminas as l, i (l.id)} +
+ {@render lamina(i)} +
+ {/each} +
+ + {#if total > 1} + +
+ + + + + + + +
+ {/if} +
+ + diff --git a/src/lib/components/LaminaCta.svelte b/src/lib/components/LaminaCta.svelte new file mode 100644 index 0000000..f6777c9 --- /dev/null +++ b/src/lib/components/LaminaCta.svelte @@ -0,0 +1,115 @@ + + +
+
+ {#if fondo} + + {/if} + +
+ +
+

{eyebrow}

+

{titulo}

+

{texto}

+
{@render acciones()}
+
+
+ + 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 @@