Carrusel en la portada y letras a dos columnas

La portada abre con 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 —cambia mientras lees, no se para, y para un lector de pantalla es un
cajón que aparece y desaparece—, así que este se para al pasar el puntero o al
entrar el foco, tiene botón de pausa, no gira solo con `prefers-reduced-motion`
y deja las láminas ocultas con `inert`, fuera del orden de tabulación.

Las letras que no están en español se enfrentan con su traducción en dos
columnas, verso a verso. Cada columna declara 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
pinta con `pre-line`: es verso, y perder los cortes la deja inservible para
seguir la original. El nombre del idioma sale de `Intl.DisplayNames`.

Sobre el reproductor: ya sobrevivía a la navegación, porque vive en el layout
raíz fuera de `main`. Lo que faltaba era comprobarlo de verdad; el test decía
que la barra seguía visible, no que siguiera sonando. Ahora mira que la posición
avance.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
master
dev 1 month ago
parent 4c65a5b719
commit e1884864cf

@ -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.

@ -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);
});
});

@ -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();
});
});

@ -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 }) => {

@ -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.

@ -0,0 +1,262 @@
<script lang="ts" module>
/** Una lámina del carrusel. El `id` va en el ancla y en los controles. */
export interface Lamina {
id: string;
/** Cómo se nombra en los puntos y en el anuncio a un lector de pantalla. */
etiqueta: string;
}
</script>
<script lang="ts">
/**
* Carrusel de la portada.
*
* Un carrusel mal hecho es de las piezas más hostiles que hay: cambia solo
* mientras alguien lee, no se puede parar y para un lector de pantalla es un
* cajón de contenido que aparece y desaparece sin avisar. Este intenta no
* serlo:
*
* - **Se para solo** al pasar el puntero o al poner el foco dentro, y sigue
* al salir. Nadie pierde una lámina por leerla despacio.
* - **Hay un botón de pausa** de verdad, y su estado manda sobre todo lo
* demás: si alguien lo pulsa, no se vuelve a mover.
* - **Con `prefers-reduced-motion` no gira solo.** Quien pide menos
* movimiento no quiere que la página se mueva sin pedirlo.
* - Las láminas que no se ven quedan **fuera del orden de tabulación**
* (`inert`), así que el teclado no se pierde en botones invisibles.
* - Las flechas izquierda y derecha mueven el carrusel cuando el foco está
* en sus controles.
*
* Todas las láminas se pintan a la vez y se cruzan con opacidad; no se
* desmontan. Así el vídeo o el fondo de la primera no se recargan cada vez
* que se vuelve a ella.
*/
import Icono from './Icono.svelte';
import type { Snippet } from 'svelte';
interface Props {
laminas: Lamina[];
/** Qué se pinta en cada lámina, por su índice. */
lamina: Snippet<[number]>;
/** Cada cuánto pasa sola, en milisegundos. */
intervalo?: number;
}
let { laminas, lamina, intervalo = 7000 }: Props = $props();
let actual = $state(0);
/** Lo que ha decidido quien mira. Manda sobre la parada automática. */
let pausadoAMano = $state(false);
/** Parada temporal: el puntero encima o el foco dentro. */
let retenido = $state(false);
const total = $derived(laminas.length);
const enMovimiento = $derived(!pausadoAMano && !retenido);
function ir(indice: number) {
actual = (indice + total) % total;
}
$effect(() => {
if (!enMovimiento || total < 2) return;
if (matchMedia('(prefers-reduced-motion: reduce)').matches) return;
const reloj = setInterval(() => ir(actual + 1), intervalo);
return () => clearInterval(reloj);
});
function alTeclado(evento: KeyboardEvent) {
if (evento.key === 'ArrowLeft') ir(actual - 1);
else if (evento.key === 'ArrowRight') ir(actual + 1);
else return;
evento.preventDefault();
}
</script>
<section
class="carrusel"
aria-roledescription="carrusel"
aria-label="Portada"
onpointerenter={() => (retenido = true)}
onpointerleave={() => (retenido = false)}
onfocusin={() => (retenido = true)}
onfocusout={() => (retenido = false)}
>
<div class="laminas">
{#each laminas as l, i (l.id)}
<div
class="lamina"
class:visible={i === actual}
role="group"
aria-roledescription="lámina"
aria-label="{i + 1} de {total}: {l.etiqueta}"
aria-hidden={i === actual ? undefined : 'true'}
inert={i === actual ? undefined : true}
>
{@render lamina(i)}
</div>
{/each}
</div>
{#if total > 1}
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<div class="mandos" role="group" aria-label="Controles de la portada" onkeydown={alTeclado}>
<button type="button" class="flecha" onclick={() => ir(actual - 1)} aria-label="Anterior">
<Icono nombre="anterior" tamano={16} />
</button>
<ul class="puntos">
{#each laminas as l, i (l.id)}
<li>
<button
type="button"
class="punto"
class:activo={i === actual}
onclick={() => ir(i)}
aria-label="Ir a: {l.etiqueta}"
aria-current={i === actual ? 'true' : undefined}
></button>
</li>
{/each}
</ul>
<button type="button" class="flecha" onclick={() => ir(actual + 1)} aria-label="Siguiente">
<Icono nombre="siguiente" tamano={16} />
</button>
<button
type="button"
class="flecha"
onclick={() => (pausadoAMano = !pausadoAMano)}
aria-label={pausadoAMano ? 'Reanudar el paso automático' : 'Parar el paso automático'}
>
<Icono nombre={pausadoAMano ? 'play' : 'pausa'} tamano={14} />
</button>
</div>
{/if}
</section>
<style>
.carrusel {
position: relative;
}
/*
* Las láminas se apilan y se cruzan con opacidad. La rejilla de una sola
* celda las superpone dejando que la más alta marque la altura, así que el
* bloque no da saltos al cambiar de lámina.
*/
.laminas {
display: grid;
}
.lamina {
grid-area: 1 / 1;
opacity: 0;
visibility: hidden;
transition:
opacity 0.7s var(--suave),
visibility 0s linear 0.7s;
}
.lamina.visible {
opacity: 1;
visibility: visible;
transition:
opacity 0.7s var(--suave),
visibility 0s;
}
.mandos {
/* En variable: el minificador junta las funciones del filtro sin espacio. */
--desenfoque-mandos: blur(10px) saturate(1.3);
position: absolute;
right: max(var(--sp-4), calc((100vw - var(--container)) / 2));
bottom: var(--sp-5);
z-index: 3;
display: flex;
align-items: center;
gap: var(--sp-2);
padding: var(--sp-1);
border: 1px solid var(--c-border);
border-radius: 999px;
background: color-mix(in oklab, var(--c-bg) 70%, transparent);
backdrop-filter: var(--desenfoque-mandos);
}
.flecha {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
border: none;
border-radius: 999px;
background: transparent;
color: var(--c-text-muted);
cursor: pointer;
transition: color 0.2s var(--suave);
}
.flecha:hover,
.flecha:focus-visible {
color: var(--c-text);
}
.puntos {
display: flex;
align-items: center;
gap: var(--sp-2);
list-style: none;
margin: 0;
padding: 0 var(--sp-2);
}
/*
* El punto activo se alarga en vez de solo cambiar de color: así se
* distingue sin depender del color, que es lo que hace falta para quien no
* los separa bien.
*/
.punto {
display: block;
width: 0.5rem;
height: 0.5rem;
padding: 0;
border: none;
border-radius: 999px;
background: var(--c-text-muted);
opacity: 0.5;
cursor: pointer;
transition:
width 0.4s var(--sal),
opacity 0.3s var(--suave),
background 0.3s var(--suave);
}
.punto:hover {
opacity: 0.8;
}
.punto.activo {
width: 1.5rem;
background: var(--c-accent);
opacity: 1;
}
@media (max-width: 40rem) {
.mandos {
right: 50%;
transform: translateX(50%);
bottom: var(--sp-4);
}
}
@media (prefers-reduced-motion: reduce) {
.lamina,
.flecha,
.punto {
transition: none;
}
}
</style>

@ -0,0 +1,115 @@
<script lang="ts">
/**
* Una lámina del carrusel de la portada que propone algo.
*
* Comparte el alto y el fondo con la cabecera del disco para que el carrusel
* no dé saltos al pasar de una a otra, pero no repite el nombre del artista:
* el `h1` está en la primera lámina y aquí sobraría un segundo.
*
* El fondo es el mismo archivo que ya se descargó para la cabecera, así que
* estas dos láminas no añaden peso.
*/
import { heroe } from '$lib/site';
import { tema } from '$lib/tema.svelte';
import type { Snippet } from 'svelte';
interface Props {
/** Rótulo corto sobre el título. */
eyebrow: string;
titulo: string;
texto: string;
/** Los botones. Van como snippet para poder mezclar enlaces y acciones. */
acciones: Snippet;
}
let { eyebrow, titulo, texto, acciones }: Props = $props();
const fondo = $derived(heroe.fondo[tema.actual]);
</script>
<section class="lamina-cta">
<div class="fondo">
{#if fondo}
<img
src={fondo.src}
srcset={fondo.srcset}
sizes="100vw"
width={fondo.ancho}
height={fondo.alto}
alt=""
/>
{/if}
<span class="velo"></span>
</div>
<div class="container">
<p class="eyebrow">{eyebrow}</p>
<h2>{titulo}</h2>
<p class="entradilla">{texto}</p>
<div class="acciones">{@render acciones()}</div>
</div>
</section>
<style>
.lamina-cta {
position: relative;
display: flex;
align-items: center;
min-height: min(88vh, 46rem);
padding-block: var(--sp-8);
overflow: hidden;
isolation: isolate;
}
.fondo {
position: absolute;
inset: 0;
z-index: -1;
background: var(--c-bg-sunken);
}
.fondo img {
width: 100%;
height: 100%;
object-fit: cover;
}
/*
* Aquí el velo es más cerrado que en la cabecera del disco: no hay
* tocadiscos que enseñar a la derecha y lo único que importa es que se lea
* lo que se propone.
*/
.velo {
position: absolute;
inset: 0;
background:
linear-gradient(
to right,
color-mix(in oklab, var(--c-bg) 92%, transparent) 0%,
color-mix(in oklab, var(--c-bg) 70%, transparent) 55%,
color-mix(in oklab, var(--c-bg) 30%, transparent) 100%
),
linear-gradient(to top, var(--c-bg) 0%, transparent 35%);
}
h2 {
max-width: 18ch;
margin: var(--sp-2) 0 0;
font-size: var(--fs-3xl);
line-height: 1.05;
}
.entradilla {
max-width: 52ch;
margin-top: var(--sp-4);
font-size: var(--fs-lg);
color: var(--c-text-muted);
}
.acciones {
display: flex;
flex-wrap: wrap;
gap: var(--sp-3);
margin-top: var(--sp-5);
}
</style>

@ -21,6 +21,7 @@ function cancion(parcial: Partial<Cancion> = {}): Cancion {
etiquetas: [],
enlaces: {},
instrumental: false,
idioma: 'es',
inspiracion: [],
extracto: [],
versiones: [],

@ -22,6 +22,7 @@ function cancion(parcial: Partial<Cancion> = {}): 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: [],

@ -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

@ -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. */

@ -1,7 +1,9 @@
<script lang="ts">
import { resolve } from '$app/paths';
import BotonReproducir from '$lib/components/BotonReproducir.svelte';
import Carrusel, { type Lamina } from '$lib/components/Carrusel.svelte';
import Heroe from '$lib/components/Heroe.svelte';
import LaminaCta from '$lib/components/LaminaCta.svelte';
import Meta from '$lib/components/Meta.svelte';
import TarjetaAlbum from '$lib/components/TarjetaAlbum.svelte';
import TarjetaEstilo from '$lib/components/TarjetaEstilo.svelte';
@ -9,9 +11,17 @@
import { aPistas } from '$lib/player/pista';
import { slugDeEtiqueta } from '$lib/content';
import { site } from '$lib/site';
import { CANCIONES_POR_PACK, formatearPrecio, PRECIO_PACK_CENTIMOS } from '$lib/tienda';
let { data } = $props();
/** El orden es el de la portada: primero la obra, luego las dos propuestas. */
const laminas: Lamina[] = [
{ id: 'disco', etiqueta: 'El disco en el tocadiscos' },
{ id: 'taller', etiqueta: 'Aprender a escribir letras' },
{ id: 'packs', etiqueta: 'Comprar canciones' }
];
const principal = $derived(data.bloquePrincipal);
const pistasPrincipal = $derived(
principal ? aPistas(principal.albumes.flatMap((a) => a.canciones)) : []
@ -20,7 +30,39 @@
<Meta titulo={site.nombre} />
<Carrusel {laminas}>
{#snippet lamina(i)}
{#if i === 0}
<Heroe album={data.heroe} />
{:else if i === 1}
<LaminaCta
eyebrow="Taller de letras"
titulo="¿Quieres escribir tus propias letras?"
texto="Lo que he aprendido escribiendo, puesto en orden: los principios que hacen que una letra funcione y una ficha de creación por cada estilo hispanoamericano, con lo que hay que tener delante para escribir en él."
>
{#snippet acciones()}
<a class="btn btn--primary" href={resolve('/taller')}>Entrar al taller</a>
<a class="btn" href={resolve('/taller/lo-que-hace-que-una-letra-funcione')}>
Empezar por el principio
</a>
{/snippet}
</LaminaCta>
{:else}
<LaminaCta
eyebrow="La tienda"
titulo="Llévate las canciones"
texto="{CANCIONES_POR_PACK} canciones por {formatearPrecio(
PRECIO_PACK_CENTIMOS
)}, en MP3 y con todas sus versiones. En la web suena un fragmento; la descarga es el archivo completo."
>
{#snippet acciones()}
<a class="btn btn--primary" href={resolve('/musica')}>Elegir canciones</a>
<a class="btn" href={resolve('/carrito')}>Ver el carrito</a>
{/snippet}
</LaminaCta>
{/if}
{/snippet}
</Carrusel>
{#if principal}
<!--

@ -47,6 +47,30 @@
: [])
]);
/**
* La letra va a dos columnas cuando no está en español y hay traducción:
* original a la izquierda, castellano a la derecha. Con una sola de las dos
* cosas no hay nada que enfrentar, y una columna vacía sería peor que
* ninguna.
*/
const enEspanol = $derived(cancion.idioma.split('-')[0] === 'es');
const aDosColumnas = $derived(!enEspanol && Boolean(cancion.traduccion));
/**
* El nombre del idioma en español, sin tabla que mantener. Si el navegador o
* el servidor no saben traducirlo, se queda el código, que sigue diciendo
* algo.
*/
const nombreIdioma = $derived.by(() => {
try {
return (
new Intl.DisplayNames(['es'], { type: 'language' }).of(cancion.idioma) ?? cancion.idioma
);
} catch {
return cancion.idioma;
}
});
const registro = $derived(cancion.registro);
/** El rol se guarda sin tildes por ser clave de datos; se pinta con ellas. */
@ -151,9 +175,26 @@
{#snippet panel(id)}
{#if id === 'letra'}
{#if Cuerpo}
<div class="texto" class:versos={!cancion.instrumental}>
<div class="letra" class:pareada={aDosColumnas}>
<div class="texto" class:versos={!cancion.instrumental} lang={cancion.idioma}>
{#if aDosColumnas}
<p class="rotulo-columna muted">Letra original · {nombreIdioma}</p>
{/if}
<Cuerpo />
</div>
{#if aDosColumnas}
<!--
`pre-line` conserva el corte de los versos sin necesidad de
compilar Markdown: una traducción que pierde los cortes deja
de servir para seguir la original línea a línea.
-->
<div class="texto versos traduccion" lang="es">
<p class="rotulo-columna muted">Traducción al español</p>
<p class="verso-a-verso">{cancion.traduccion}</p>
</div>
{/if}
</div>
{:else}
<p class="muted">
{cancion.instrumental
@ -374,6 +415,39 @@
</div>
<style>
/*
* A dos columnas se leen en paralelo, que es para lo que sirve tener la
* traducción al lado. Al estrecharse caen una debajo de otra: partir un
* verso para que quepan dos columnas sería peor que apilarlas.
*/
.letra.pareada {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--sp-5) var(--sp-6);
align-items: start;
}
.rotulo-columna {
margin: 0 0 var(--sp-3);
padding-bottom: var(--sp-2);
border-bottom: 1px solid var(--c-border);
font-family: var(--font-body);
font-size: var(--fs-xs);
text-transform: uppercase;
letter-spacing: 0.08em;
}
.verso-a-verso {
margin: 0;
white-space: pre-line;
}
@media (max-width: 52rem) {
.letra.pareada {
grid-template-columns: minmax(0, 1fr);
}
}
/* Las etiquetas son enlaces, no datos sueltos: llevan a lo que agrupan. */
.etiquetas {
display: flex;

@ -202,6 +202,28 @@ collections:
required: false
hint: Sin letra. El cuerpo pasa a leerse como notas de la pieza.
- { name: alaVenta, label: A la venta, widget: boolean, required: false, hint: 'Por defecto se vende si tiene audio.' }
- name: idioma
label: Idioma de la letra
widget: select
default: es
options:
- { label: 'Español', value: es }
- { label: 'Inglés', value: en }
- { label: 'Portugués', value: pt }
- { label: 'Francés', value: fr }
- { label: 'Italiano', value: it }
- { label: 'Catalán', value: ca }
- { label: 'Gallego', value: gl }
- { label: 'Euskera', value: eu }
required: false
- name: traduccion
label: Traducción al español
widget: text
required: false
hint: >-
Solo si la letra no está en español. Se enseña en una segunda columna
al lado de la original, así que conviene respetar el corte de los
versos para que se puedan seguir en paralelo.
- name: inspiracion
label: De dónde sale
widget: text

Loading…
Cancel
Save

Powered by TurnKey Linux.