Las fichas de género salen del taller y tienen su propia sección

Con lo que pediste: portada con mapa de Iberoamérica, buscador y un aside
que empieza por Inicio, Los principios, Glosario y Letristas antes de la
lista de géneros.

El mapa es interactivo de verdad: cada país se pinta según lo que tenga
—marcado si es cuna de algún género, tenue si solo lo adoptó, apagado si
no hay nada—, y al pulsarlo la lista de abajo se parte en «nació aquí» y
«también se canta ahí». Esa diferencia es lo que un mapa cuenta mejor que
una lista.

Los contornos son de `BlankMap-Americas.svg` de Wikimedia, en dominio
público, recortados a los veinte países de Iberoamérica en América. De
640 KB a 36 KB comprimidos, y solo en esta ruta: coordenadas a un decimal
y fuera los 134 islotes que a este tamaño miden menos de un píxel. Va por
script y no pegado a mano, para poder rehacerlo.

Cada país lleva además un punto de agarre calculado —el promedio de los
vértices de su costa, no el centro de su rectángulo, que en Cuba cae en
el mar—: sin él, Puerto Rico mide cuatro píxeles y una isla alargada no
hay quien la acierte.

«Letristas» es nueva: las mismas personas que salen repartidas en nueve
fichas, aquí juntas, con su papel en cada género y sus obras.

La página del capítulo pasa a `$lib/components/Capitulo.svelte` y la usan
las dos secciones: son setecientas líneas, y copiarlas habría sido dos
sitios donde arreglar cada cosa.

Las direcciones viejas redirigen con 301 —`/taller/bolero` a
`/generos/bolero`— resolviendo contra el catálogo de capítulos, no contra
una lista escrita a mano que alguien tenga que ampliar. Un enlace
compartido no puede empezar a dar 404 porque hayamos reordenado el menú.

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

@ -93,8 +93,8 @@ test.describe('Responsive', () => {
await page.goto('/musica/baladas-romanticas');
await expect(page.getByRole('navigation', { name: 'Estilos' })).toBeVisible();
await page.goto('/taller/bachata');
await expect(page.getByRole('navigation', { name: 'Contenido del taller' })).toBeVisible();
await page.goto('/generos/bachata');
await expect(page.getByRole('navigation', { name: 'Contenido de géneros' })).toBeVisible();
});
test('la tabla se desplaza dentro de su marco, no arrastra la página', async ({ page }) => {

@ -56,7 +56,7 @@ test.describe('Mapa del sitio', () => {
expect(xml).toContain('/canciones/dorina');
expect(xml).toContain('/musica/baladas-romanticas');
expect(xml).toContain('/albumes/singles-baladas-romanticas');
expect(xml).toContain('/taller/bachata');
expect(xml).toContain('/generos/bachata');
expect(xml).toContain('/blog/lo-que-no-cabe-en-una-cancion');
expect(xml).toContain('/etiquetas/desamor');
});

@ -1,15 +1,16 @@
import { expect, test } from '@playwright/test';
test.describe('Taller de letras', () => {
test('el índice separa los principios de las fichas', async ({ page }) => {
test('la portada lleva a los principios y a los géneros', async ({ page }) => {
await page.goto('/taller');
await expect(page.getByRole('heading', { level: 1 })).toHaveText('Taller de letras');
await expect(page.getByRole('heading', { level: 2, name: 'Los principios' })).toBeVisible();
await expect(page.getByRole('heading', { level: 2, name: 'Fichas de creación' })).toBeVisible();
// El índice lateral rotula sus bloques, pero no como encabezados: si no,
// el esquema de la página diría dos veces lo mismo.
await expect(page.getByRole('heading', { level: 2 })).toHaveCount(2);
// Las fichas se fueron a su propia sección; aquí queda el paso a ella.
await expect(page.getByRole('heading', { level: 2, name: 'Fichas de género' })).toBeVisible();
await page.getByRole('link', { name: 'Ver los géneros' }).click();
await expect(page).toHaveURL('/generos');
});
test('se llega a un principio y se encadena con el siguiente', async ({ page }) => {
@ -31,7 +32,7 @@ test.describe('Taller de letras', () => {
});
test('una ficha de género trae sus datos de creación', async ({ page }) => {
await page.goto('/taller/bolero');
await page.goto('/generos/bolero');
await expect(page.getByRole('heading', { level: 1 })).toHaveText('Bolero');
await expect(page.getByText('Santiago de Cuba, 1883')).toBeVisible();
@ -62,7 +63,7 @@ test.describe('Taller de letras', () => {
// Es la diferencia con la barra lateral de antes, y lo que hace que los
// datos se puedan comparar de un vistazo.
await page.setViewportSize({ width: 1440, height: 900 });
await page.goto('/taller/bolero');
await page.goto('/generos/bolero');
const datos = (await page.locator('.datos').boundingBox())!;
const texto = (await page.locator('.lectura .texto').boundingBox())!;
@ -80,7 +81,7 @@ test.describe('Taller de letras', () => {
* que cubre ese ancho es lo que va a su lado, no el texto estirado.
*/
await page.setViewportSize({ width: 1440, height: 900 });
await page.goto('/taller/bolero');
await page.goto('/generos/bolero');
const derecha = async (selector: string) => {
const caja = (await page.locator(selector).first().boundingBox())!;
@ -102,23 +103,23 @@ test.describe('Taller de letras', () => {
const indice = page.getByRole('navigation', { name: 'Contenido del taller' });
await expect(indice).toBeVisible();
await expect(indice.getByRole('link', { name: 'Bolero' })).toBeVisible();
await expect(indice.getByRole('link', { name: 'El estribillo' })).toBeVisible();
// En la portada no hay capítulo abierto que marcar.
await expect(indice.locator('a[aria-current="page"]')).toHaveCount(0);
});
test('cada capítulo lleva el índice del taller al lado', async ({ page }) => {
await page.goto('/taller/bolero');
await page.goto('/taller/el-estribillo');
const indice = page.getByRole('navigation', { name: 'Contenido del taller' });
await expect(indice).toBeVisible();
// Marca dónde estás dentro de la estructura.
await expect(indice.getByRole('link', { name: 'Bolero' })).toHaveAttribute(
await expect(indice.getByRole('link', { name: 'El estribillo' })).toHaveAttribute(
'aria-current',
'page'
);
// Y enseña los dos bloques enteros, no solo el actual.
await expect(indice.getByRole('link', { name: 'El estribillo' })).toBeVisible();
// Y enseña el temario entero, no solo el capítulo actual.
await expect(indice.getByRole('link', { name: 'La imagen concreta' })).toBeVisible();
});
test('un principio no enseña ficha de creación', async ({ page }) => {
@ -137,7 +138,7 @@ test.describe('Taller de letras', () => {
*/
test('la ficha dice qué país es la cuna y cuáles lo adoptaron', async ({ page }) => {
await page.goto('/taller/bolero');
await page.goto('/generos/bolero');
const paises = page.locator('.paises li');
await expect(paises).toHaveCount(6);
@ -148,7 +149,7 @@ test.describe('Taller de letras', () => {
});
test('la instrumentación distingue lo esencial de lo ocasional', async ({ page }) => {
await page.goto('/taller/bolero');
await page.goto('/generos/bolero');
const filas = page.locator('.instrumentos li');
await expect(filas.filter({ hasText: 'Guitarra' })).toContainText('esencial');
@ -158,7 +159,7 @@ test.describe('Taller de letras', () => {
});
test('los letristas traen país, años y papel', async ({ page }) => {
await page.goto('/taller/tango');
await page.goto('/generos/tango');
const gardel = page.locator('.personas li').filter({ hasText: 'Carlos Gardel' });
await expect(gardel).toContainText('Argentina · 1890–1935');
@ -167,7 +168,7 @@ test.describe('Taller de letras', () => {
});
test('las obras de referencia se fechan y se acreditan', async ({ page }) => {
await page.goto('/taller/bolero');
await page.goto('/generos/bolero');
const primera = page.locator('.obras li').first();
await expect(primera).toContainText('1883');
@ -184,7 +185,7 @@ test.describe('Taller de letras', () => {
* El bolero-son existe en la base porque el bolero y el son confluyen en
* él, pero nadie ha escrito su ficha. Enlazarlo mandaba a un 404.
*/
await page.goto('/taller/bolero');
await page.goto('/generos/bolero');
const linaje = page.locator('.rama');
await expect(linaje.getByRole('link', { name: 'Bachata' })).toBeVisible();
@ -198,7 +199,7 @@ test.describe('Taller de letras', () => {
* ver nunca uno. Un taller que describe la métrica y no la enseña está
* pidiendo que se la imaginen.
*/
await page.goto('/taller/bolero');
await page.goto('/generos/bolero');
const ejemplos = page.locator('.ejemplo');
await expect(ejemplos).toHaveCount(2);
@ -212,7 +213,7 @@ test.describe('Taller de letras', () => {
});
test('la escansión marca la sinalefa y el acento', async ({ page }) => {
await page.goto('/taller/bolero');
await page.goto('/generos/bolero');
// Dos palabras en una casilla, unidas por el arco: al cantarlas se dice
// una sola sílaba. Es lo que en un texto corrido no se ve.
@ -227,7 +228,7 @@ test.describe('Taller de letras', () => {
test('el octosílabo agudo mide ocho con siete casillas', async ({ page }) => {
// La ley del acento final, que es la primera regla que hay que tener
// automatizada para escribir sobre clave.
await page.goto('/taller/son-cubano');
await page.goto('/generos/son-cubano');
const segundo = page.locator('.estrofa li').nth(1);
await expect(segundo.locator('.verso')).toHaveText('yo respondo con el son;');
@ -238,7 +239,7 @@ test.describe('Taller de letras', () => {
test('los ejemplos van antes que la documentación del género', async ({ page }) => {
// Es un taller: quien abre esta página viene a escribir una letra, no a
// documentarse. Primero lo que se usa para escribir.
await page.goto('/taller/bolero');
await page.goto('/generos/bolero');
const titulos = await page.getByRole('heading', { level: 2 }).allTextContents();
expect(titulos.indexOf('Ejemplos de versos')).toBeLessThan(titulos.indexOf('Instrumentación'));
@ -248,7 +249,7 @@ test.describe('Taller de letras', () => {
});
test('cada obra de referencia dice dónde escucharla', async ({ page }) => {
await page.goto('/taller/tango');
await page.goto('/generos/tango');
const cambalache = page.locator('.obras li').filter({ hasText: 'Cambalache' });
const spotify = cambalache.getByRole('link', { name: 'Spotify' });
@ -263,7 +264,7 @@ test.describe('Taller de letras', () => {
test('se avisa de lo que aún no ha revisado nadie', async ({ page }) => {
// La musicología la reunió un modelo y la ficha se publica con el nombre
// del titular. Callarlo sería lo cómodo.
await page.goto('/taller/bolero');
await page.goto('/generos/bolero');
const aviso = page.locator('.sin-revisar');
await expect(aviso).toContainText('pendiente de revisión');
@ -276,7 +277,7 @@ test.describe('Taller de letras', () => {
test('los términos del glosario salen marcados en el texto', async ({ page }) => {
// Lo que se pidió: «cada vez que aparezca un término del glosario en el
// texto, estará marcado para visualizarse con un popup».
await page.goto('/taller/son-cubano');
await page.goto('/generos/son-cubano');
const marcados = page.locator('.texto .termino');
await expect(marcados.first()).toBeVisible();
@ -290,7 +291,7 @@ test.describe('Taller de letras', () => {
});
test('al pulsar un término se abre su definición', async ({ page }) => {
await page.goto('/taller/son-cubano');
await page.goto('/generos/son-cubano');
const montuno = page.locator('.texto .termino[data-termino="montuno"]').first();
await montuno.click();
@ -307,7 +308,7 @@ test.describe('Taller de letras', () => {
test('el globo no tapa el término que se acaba de pulsar', async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 900 });
await page.goto('/taller/son-cubano');
await page.goto('/generos/son-cubano');
const termino = page.locator('.texto .termino').first();
await termino.click();
@ -321,35 +322,48 @@ test.describe('Taller de letras', () => {
test('no se marca dentro de un encabezado', async ({ page }) => {
// Ensuciaría el índice del capítulo, que sale de los encabezados.
await page.goto('/taller/son-cubano');
await page.goto('/generos/son-cubano');
await expect(page.locator('.texto h2 .termino')).toHaveCount(0);
});
test('el glosario tiene su propia página, con las acepciones numeradas', async ({ page }) => {
await page.goto('/taller/glosario');
test('el glosario se busca y sus acepciones van numeradas', async ({ page }) => {
await page.goto('/generos/glosario');
await expect(page.getByRole('heading', { level: 1 })).toHaveText('Glosario');
await expect(page.getByRole('heading', { level: 2, name: 'sinalefa' })).toBeVisible();
// El buscador acota; con doscientas entradas es la única forma de llegar.
await page.getByLabel('Buscar un término').fill('clave');
await expect(page.locator('.palabra')).toHaveCount(1);
/*
* «Clave» tiene dos: patrón rítmico e instrumento. Con una sola
* definición por palabra, una de las dos sería falsa, y por eso las
* acepciones van numeradas.
* «Clave» tiene dos acepciones: patrón rítmico e instrumento. Con una sola
* definición por palabra, una de las dos sería falsa.
*/
const clave = page.locator('#clave');
await expect(clave.locator('.acepciones > li')).toHaveCount(2);
await expect(clave).toContainText('patrón rítmico');
await expect(clave).toContainText('percusión');
await page.locator('[data-termino="clave"]').click();
const globo = page.getByRole('dialog', { name: /Definición de clave/ });
await expect(globo.locator('.acepciones > li')).toHaveCount(2);
await expect(globo).toContainText('patrón rítmico');
await expect(globo).toContainText('percusión');
});
test('se llega al glosario desde el índice del taller', async ({ page }) => {
await page.goto('/taller/bolero');
test('se llega al glosario desde el índice de géneros', async ({ page }) => {
await page.goto('/generos/bolero');
await page
.getByRole('navigation', { name: 'Contenido del taller' })
.getByRole('navigation', { name: 'Contenido de géneros' })
.getByRole('link', { name: 'Glosario' })
.click();
await expect(page).toHaveURL('/taller/glosario');
await expect(page).toHaveURL('/generos/glosario');
});
test('las direcciones viejas de las fichas siguen llevando a alguna parte', async ({ page }) => {
// Un enlace compartido no puede empezar a dar 404 porque hayamos
// reordenado el menú.
await page.goto('/generos/bolero');
await expect(page).toHaveURL('/generos/bolero');
await page.goto('/taller/glosario');
await expect(page).toHaveURL('/generos/glosario');
});
test('el taller está en la navegación principal', async ({ page }) => {

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 623 KiB

@ -0,0 +1,190 @@
/**
* Genera `src/lib/mapa/iberoamerica.ts` a partir del mapa de Wikimedia.
*
* node scripts/mapa/generar.mjs [ruta al svg]
*
* El original es `BlankMap-Americas.svg`, de Lokal_Profil, en dominio público:
* https://commons.wikimedia.org/wiki/File:BlankMap-Americas.svg
*
* De sus cincuenta y cinco países se conservan los veinte de Iberoamérica en
* América, y de cada uno todos sus trazados unidos en uno. Dos recortes bajan
* el archivo de 640 KB a algo que puede viajar en una página:
*
* - **Las coordenadas, a un decimal.** El mapa mide dos mil setecientas
* unidades de ancho y se dibuja a setecientos píxeles: la décima de unidad no
* llega ni a la décima de píxel.
* - **Fuera los islotes.** Chile trae ciento cincuenta y cinco trazados, casi
* todos rocas del sur que a este tamaño ocupan menos de un píxel. Se
* descartan los que no llegan al umbral; las islas que sí se ven —Galápagos,
* Chiloé, las Malvinas— lo pasan de sobra.
*
* Se deja como script y no como un archivo pegado a mano para que se pueda
* rehacer: el día que haga falta añadir un país o cambiar el umbral, esto es
* una línea, y a mano sería volver a empezar.
*/
import fs from 'node:fs';
const ORIGEN = process.argv[2] ?? 'scripts/mapa/BlankMap-Americas.svg';
const DESTINO = 'src/lib/mapa/iberoamerica.ts';
/** Los veinte, en el orden en que se leen de norte a sur. */
const PAISES = [
'mx',
'gt',
'sv',
'hn',
'ni',
'cr',
'pa',
'cu',
'do',
'pr',
've',
'co',
'ec',
'pe',
'bo',
'py',
'cl',
'ar',
'uy',
'br'
];
/** Un trazado más pequeño que esto no se ve, y sí se nota en el peso. */
const MINIMO = 2;
const svg = fs.readFileSync(ORIGEN, 'utf8');
/*
* Se recorren las etiquetas con expresión regular y no con un analizador de
* XML: hace falta saber en qué grupo va cada trazado, y para eso basta con
* llevar la cuenta de los `<g>` abiertos.
*/
const trozos = Object.fromEntries(PAISES.map((c) => [c, []]));
const pila = [];
for (const [, cierre, etiqueta, atributos, suelta] of svg.matchAll(
/<(\/?)(g|path)\b([^>]*?)(\/?)>/g
)) {
if (etiqueta === 'g') {
if (cierre) pila.pop();
else pila.push(atributos.match(/id="([^"]+)"/)?.[1] ?? null);
continue;
}
if (cierre) continue;
const propio = atributos.match(/id="([^"]+)"/)?.[1] ?? null;
const codigo = [propio, ...[...pila].reverse()].find((c) => c && PAISES.includes(c));
const d = atributos.match(/d="([^"]+)"/)?.[1];
if (codigo && d) trozos[codigo].push(d);
void suelta;
}
/** El rectángulo que ocupa un trazado, para saber si merece la pena. */
function tamano(d) {
const numeros = [...d.matchAll(/-?\d+(?:\.\d+)?/g)].map((m) => Number(m[0]));
const xs = numeros.filter((_, i) => i % 2 === 0);
const ys = numeros.filter((_, i) => i % 2 === 1);
if (xs.length === 0) return { ancho: 0, alto: 0, xs, ys };
return {
ancho: Math.max(...xs) - Math.min(...xs),
alto: Math.max(...ys) - Math.min(...ys),
xs,
ys
};
}
const unaDecimal = (d) => d.replace(/-?\d+\.\d+/g, (n) => String(Math.round(Number(n) * 10) / 10));
const contorno = {};
const centro = {};
let descartados = 0;
for (const codigo of PAISES) {
const buenos = trozos[codigo].filter((d) => {
const { ancho, alto } = tamano(d);
const vale = ancho >= MINIMO || alto >= MINIMO;
if (!vale) descartados++;
return vale;
});
contorno[codigo] = unaDecimal(buenos.join(' '));
/*
* Un punto donde agarrar el país.
*
* Es el promedio de los vértices del trazado más grande, no el centro de su
* rectángulo: el de Cuba cae en el mar, porque la isla es alargada y curva.
* Con el promedio de la costa el punto queda dentro, y además da algo que
* pulsar en Puerto Rico o El Salvador, que a esta escala miden cuatro
* píxeles.
*/
const mayor = buenos.reduce(
(a, b) => (tamano(b).ancho * tamano(b).alto > tamano(a).ancho * tamano(a).alto ? b : a),
buenos[0] ?? ''
);
const { xs, ys } = tamano(mayor);
const medio = (ns) => ns.reduce((t, n) => t + n, 0) / ns.length;
centro[codigo] = [medio(xs), medio(ys)].map((n) => Math.round(n * 10) / 10);
}
/* El encuadre sale de lo que queda, no del mapa entero. */
const xs = [];
const ys = [];
for (const d of Object.values(contorno)) {
const t = tamano(d);
xs.push(...t.xs);
ys.push(...t.ys);
}
const MARGEN = 10;
const x0 = Math.min(...xs) - MARGEN;
const y0 = Math.min(...ys) - MARGEN;
const encuadre = [x0, y0, Math.max(...xs) - x0 + MARGEN, Math.max(...ys) - y0 + MARGEN]
.map((n) => n.toFixed(1))
.join(' ');
const cuerpo = PAISES.map(
(c) =>
`\t{ codigo: '${c.toUpperCase()}', centro: [${centro[c].join(', ')}], d: '${contorno[c]}' }`
).join(',\n');
fs.writeFileSync(
DESTINO,
`/**
* El mapa de Iberoamérica, país a país.
*
* **Generado. No se edita a mano:** \`node scripts/mapa/generar.mjs\`, que
* explica de dónde sale y qué se recorta.
*
* Los contornos son de \`BlankMap-Americas.svg\` de Wikimedia Commons, obra de
* Lokal_Profil, en dominio público.
*
* España y Portugal no están: el original es de las Américas. El día que hagan
* falta habrá que traerlos de otro mapa o enseñarlos aparte.
*/
export interface PaisDelMapa {
/** ISO 3166-1 alfa-2, como en la tabla \`pais\`. */
codigo: string;
/** Un punto dentro del país, para colgar de él lo que haya que pulsar. */
centro: [number, number];
/** El contorno, con todas sus islas en un solo trazado. */
d: string;
}
/** El encuadre justo de los veinte países, no el del mapa original. */
export const ENCUADRE = '${encuadre}';
export const PAISES: PaisDelMapa[] = [
${cuerpo}
];
`
);
const kb = (fs.statSync(DESTINO).size / 1024).toFixed(0);
console.log(` países ${PAISES.length}`);
console.log(` descartados ${descartados} trazados por debajo de ${MINIMO} unidades`);
console.log(` encuadre ${encuadre}`);
console.log(` tamaño ${kb} KB`);

@ -7,6 +7,7 @@ import {
establecerCookieSesion,
validarSesion
} from '$lib/server/auth';
import { indiceDelTaller } from '$lib/server/taller';
import {
esRutaLibre,
porton,
@ -84,4 +85,36 @@ const sesion: Handle = async ({ event, resolve }) => {
return resolve(event);
};
export const handle: Handle = sequence(esperar, sesion);
/**
* Las direcciones que cambiaron de sitio.
*
* Las fichas de género vivían en `/taller/<slug>` y ahora están en
* `/generos/<slug>`. Un enlace compartido no puede empezar a dar 404 porque
* hayamos reordenado el menú: **la dirección publicada es un compromiso**, y
* por eso el modelo tiene una tabla `redireccion`.
*
* Se resuelve consultando el catálogo de capítulos, que ya dice cuáles son
* fichas: así no hay una lista escrita a mano que alguien tenga que acordarse
* de ampliar cuando añada un género.
*
* 301 y no 302: el cambio es definitivo y conviene que los buscadores lo
* aprendan en vez de seguir pidiendo la vieja.
*/
const mudanzas: Handle = async ({ event, resolve }) => {
const camino = event.url.pathname;
if (camino.startsWith('/taller/') && camino !== '/taller/') {
const slug = camino.slice('/taller/'.length);
const { fichas } = await indiceDelTaller();
if (fichas.some((f) => f.slug === slug)) {
redirect(301, `/generos/${slug}${event.url.search}`);
}
// El glosario también se mudó, y no es un capítulo.
if (slug === 'glosario') redirect(301, '/generos/glosario');
}
return resolve(event);
};
export const handle: Handle = sequence(esperar, mudanzas, sesion);

File diff suppressed because it is too large Load Diff

@ -1,3 +1,20 @@
<script lang="ts" module>
/**
* La materia se guarda como código —sin tildes y en minúscula, que es lo que
* conviene a una columna— y se lee como palabra. Vive aquí y lo importa
* también la página del glosario: salía «MUSICA» en el globo y «Música» en la
* lista, que es la misma cosa escrita de dos maneras.
*/
export const MATERIAS: Record<string, string> = {
metrica: 'Métrica',
rima: 'Rima',
musica: 'Música',
estructura: 'Estructura',
interpretacion: 'Interpretación',
instrumento: 'Instrumento'
};
</script>
<script lang="ts">
/**
* Los globos del glosario.
@ -19,9 +36,14 @@
glosario: TerminoDeGlosario[];
/** Dónde escuchar los clics: el artículo que lleva el texto marcado. */
dentro?: HTMLElement | null;
/**
* Con la ampliación, que en el texto de un capítulo taparía media página
* y en la página del glosario es justo lo que se viene a leer.
*/
ampliada?: boolean;
}
let { glosario, dentro = null }: Props = $props();
let { glosario, dentro = null, ampliada = false }: Props = $props();
let abierto = $state<TerminoDeGlosario | null>(null);
let origen = $state<HTMLElement | null>(null);
@ -117,8 +139,11 @@
<ol class="acepciones" class:una={abierto.acepciones.length === 1}>
{#each abierto.acepciones as acepcion (acepcion.numero)}
<li>
<p class="categoria">{acepcion.categoria}</p>
<p class="categoria">{MATERIAS[acepcion.categoria] ?? acepcion.categoria}</p>
<p class="definicion">{acepcion.definicion}</p>
{#if ampliada && acepcion.ampliacion}
<p class="ampliacion">{acepcion.ampliacion}</p>
{/if}
{#if acepcion.ejemplo}
<p class="ejemplo">{acepcion.ejemplo}</p>
{/if}
@ -142,7 +167,7 @@
.globo {
position: fixed;
z-index: 41;
width: min(22rem, calc(100vw - 1.5rem));
width: min(26rem, calc(100vw - 1.5rem));
padding: var(--sp-4);
background: var(--c-bg-raised);
border: 1px solid var(--c-border);
@ -150,12 +175,31 @@
box-shadow: 0 12px 32px oklch(0 0 0 / 0.35);
}
/*
* Dos tamaños y no cuatro: la palabra en 24 y **todo el contenido en el
* tamaño de lectura del sitio**, 16, igual que cualquier otro texto. La
* etiqueta de materia es lo único que baja, porque es una etiqueta.
*
* Antes el globo tenía su propia escala —19 el título, 14 el cuerpo—, que
* no era ni la jerarquía del tema ni una jerarquía: cinco píxeles de
* diferencia y lo que se viene a leer, en tamaño de pie de foto.
*/
/*
* La mayúscula es de la presentación, no del dato: el término se guarda en
* minúscula porque así se busca y así se compara con el texto al marcarlo.
* `::first-letter` y no `capitalize`, que pondría «Rima Asonante».
*/
.palabra::first-letter {
text-transform: uppercase;
}
.palabra {
margin: 0 0 var(--sp-3);
padding-bottom: var(--sp-2);
padding-bottom: var(--sp-3);
border-bottom: 1px solid var(--c-border);
font-family: var(--font-display);
font-size: var(--fs-lg);
font-size: var(--fs-xl);
line-height: 1.15;
}
.acepciones {
@ -175,7 +219,7 @@
counter-increment: acepcion;
content: counter(acepcion) '.';
font-family: var(--font-mono);
font-size: var(--fs-xs);
font-size: var(--fs-base);
color: var(--c-accent);
}
@ -209,7 +253,7 @@
.definicion {
grid-column: 2;
margin: 0;
font-size: var(--fs-sm);
font-size: var(--fs-base);
line-height: 1.5;
}
@ -217,14 +261,26 @@
grid-column: 1;
}
.ampliacion {
grid-column: 2;
margin: var(--sp-3) 0 0;
font-size: var(--fs-base);
line-height: 1.6;
color: var(--c-text-muted);
}
.acepciones.una .ampliacion {
grid-column: 1;
}
.ejemplo {
grid-column: 2;
margin: var(--sp-2) 0 0;
margin: var(--sp-3) 0 0;
padding-left: var(--sp-3);
border-left: 2px solid var(--c-border);
font-family: var(--font-display);
font-style: italic;
font-size: var(--fs-sm);
font-size: var(--fs-base);
color: var(--c-text-muted);
}

@ -0,0 +1,110 @@
<script lang="ts">
/**
* El índice de la sección de géneros.
*
* Arriba las cuatro páginas que no son un género —el inicio, los principios
* del taller, el glosario y los letristas— y debajo la lista. Van separadas
* porque son cosas distintas: las de arriba se visitan una vez y la lista se
* recorre.
*
* «Los principios» apunta a `/taller`, que es otra sección: son el curso, y
* las fichas se leen con él al lado.
*/
import { resolve } from '$app/paths';
interface Entrada {
slug: string;
titulo: string;
}
interface Props {
generos: Entrada[];
/** Slug del género abierto, si estamos en uno. */
actual?: string;
}
let { generos, actual }: Props = $props();
const paginas = [
{ href: resolve('/generos'), texto: 'Inicio', clave: '' },
{ href: resolve('/taller'), texto: 'Los principios', clave: 'principios' },
{ href: resolve('/generos/glosario'), texto: 'Glosario', clave: 'glosario' },
{ href: resolve('/generos/letristas'), texto: 'Letristas', clave: 'letristas' }
];
</script>
<nav class="indice" aria-label="Contenido de géneros">
<section>
<ul>
{#each paginas as pagina (pagina.clave)}
<li>
<a href={pagina.href} aria-current={pagina.clave === actual ? 'page' : undefined}>
{pagina.texto}
</a>
</li>
{/each}
</ul>
</section>
<section>
<p class="rotulo" id="lista-generos">Géneros</p>
<ul aria-labelledby="lista-generos">
{#each generos as genero (genero.slug)}
<li>
<a
href={resolve('/generos/[slug]', { slug: genero.slug })}
aria-current={genero.slug === actual ? 'page' : undefined}
>
{genero.titulo}
</a>
</li>
{/each}
</ul>
</section>
</nav>
<style>
.indice {
display: grid;
gap: var(--sp-5);
font-size: var(--fs-sm);
}
.rotulo {
margin: 0 0 var(--sp-2);
font-size: var(--fs-xs);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.12em;
color: var(--c-text-muted);
}
ul {
list-style: none;
margin: 0;
padding: 0;
}
a {
display: block;
padding: var(--sp-1) 0;
line-height: 1.35;
color: var(--c-text-muted);
text-decoration: none;
}
a:hover {
color: var(--c-accent);
}
a[aria-current='page'] {
color: var(--c-text);
font-weight: 500;
}
/* El bloque de arriba se separa de la lista con una línea, no con aire. */
section + section {
padding-top: var(--sp-4);
border-top: 1px solid var(--c-border);
}
</style>

@ -2,9 +2,8 @@
/**
* Índice del taller, para la barra lateral de un capítulo.
*
* Enseña la estructura entera —los principios numerados y las fichas de
* estilo— con el capítulo abierto marcado. En un curso, saber dónde estás y
* qué queda es parte de poder seguirlo.
* Enseña los principios numerados con el capítulo abierto marcado. En un
* curso, saber dónde estás y qué queda es parte de poder seguirlo.
*
* En pantallas estrechas no se esconde: baja debajo del texto, antes de la
* navegación de anterior y siguiente. Ocultar la estructura en móvil sería
@ -25,12 +24,11 @@
interface Props {
principios: Entrada[];
fichas: Entrada[];
/** Slug del capítulo abierto, si lo hay. */
actual?: string;
}
let { principios, fichas, actual }: Props = $props();
let { principios, actual }: Props = $props();
</script>
<nav class="indice" aria-label="Contenido del taller">
@ -51,37 +49,14 @@
</ol>
</section>
<section>
<p class="rotulo" id="taller-fichas">Fichas de creación</p>
<ul aria-labelledby="taller-fichas">
{#each fichas as capitulo (capitulo.slug)}
<li>
<a
href={resolve('/taller/[slug]', { slug: capitulo.slug })}
aria-current={capitulo.slug === actual ? 'page' : undefined}
>
{capitulo.titulo}
</a>
</li>
{/each}
</ul>
</section>
<!--
El glosario, al final y aparte: no es un capítulo que se lea en orden,
es donde se mira una palabra cuando hace falta.
Las fichas de género se fueron a `/generos`, con su portada, su mapa y su
buscador. Aquí queda el enlace: el curso y las fichas se leen juntos.
-->
<section>
<p class="rotulo" id="taller-glosario">Consulta</p>
<ul aria-labelledby="taller-glosario">
<li>
<a
href={resolve('/taller/glosario')}
aria-current={actual === 'glosario' ? 'page' : undefined}
>
Glosario
</a>
</li>
<ul>
<li><a href={resolve('/generos')}>Fichas de género</a></li>
<li><a href={resolve('/generos/glosario')}>Glosario</a></li>
</ul>
</section>
</nav>

@ -0,0 +1,181 @@
<script lang="ts">
/**
* El mapa de Iberoamérica con los géneros de cada país.
*
* Un país se pinta según lo que tenga: apagado si no hay nada, marcado si es
* cuna de algún género, y tenue si solo lo adoptó. Es la información que el
* mapa aporta y que una lista no da: dónde nacieron las cosas y por dónde se
* extendieron.
*
* Se puede recorrer con el teclado. Cada país es un `<button>` dentro del
* SVG, no un `<path>` con un `onclick`: así entra en el orden de tabulación
* y se anuncia con su nombre.
*/
import { ENCUADRE, PAISES } from '$lib/mapa/iberoamerica';
import type { GeneroEnElMapa } from '$lib/server/taller';
interface Props {
generos: GeneroEnElMapa[];
/** El país elegido, en ISO alfa-2. Vacío si no hay ninguno. */
pais: string;
/** Se llama al pulsar un país; el mismo otra vez lo deselecciona. */
alElegir: (codigo: string) => void;
}
let { generos, pais, alElegir }: Props = $props();
/** Lo que tiene cada país, para saber cómo pintarlo y qué anunciar. */
const porPais = $derived.by(() => {
const mapa: Record<string, { nombre: string; cuna: string[]; adoptados: string[] }> = {};
for (const genero of generos) {
for (const p of genero.paises) {
const suyo = (mapa[p.codigo] ??= { nombre: p.nombre, cuna: [], adoptados: [] });
if (p.relacion === 'cuna') suyo.cuna.push(genero.nombre);
else suyo.adoptados.push(genero.nombre);
}
}
return mapa;
});
/** Lo que lee un lector de pantalla y lo que sale al pasar el ratón. */
function resumen(codigo: string): string {
const suyo = porPais[codigo];
if (!suyo) return '';
const partes = [];
if (suyo.cuna.length > 0) partes.push(`cuna de ${suyo.cuna.join(', ')}`);
if (suyo.adoptados.length > 0) partes.push(`también se canta ${suyo.adoptados.join(', ')}`);
return `${suyo.nombre}: ${partes.join('; ')}`;
}
</script>
<div class="mapa">
<svg viewBox={ENCUADRE} role="group" aria-label="Géneros por país">
{#each PAISES as p (p.codigo)}
{@const suyo = porPais[p.codigo]}
{#if suyo}
<!--
El contorno pinta; el punto de encima es lo que se pulsa y lo que
entra en el orden de tabulación. Hacen falta los dos: Puerto Rico
mide cuatro píxeles y Cuba es tan alargada que apuntar a su centro
es apuntar al mar.
-->
<g
class="pais"
class:cuna={suyo.cuna.length > 0}
class:elegido={pais === p.codigo}
role="button"
tabindex="0"
aria-pressed={pais === p.codigo}
aria-label={resumen(p.codigo)}
onclick={() => alElegir(p.codigo)}
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
alElegir(p.codigo);
}
}}
>
<title>{resumen(p.codigo)}</title>
<path class="tierra" d={p.d} />
<circle class="punto" cx={p.centro[0]} cy={p.centro[1]} r="9" />
</g>
{:else}
<!-- Sin géneros: se dibuja para que el mapa sea un mapa, y no se pulsa. -->
<path class="pais vacio" d={p.d} aria-hidden="true" />
{/if}
{/each}
</svg>
</div>
<style>
/*
* Iberoamérica es casi tan alta como ancha, así que el límite lo pone el
* alto: a todo lo ancho ocupaba la pantalla entera y había que bajar para
* ver que debajo hay un buscador.
*/
.mapa {
display: grid;
place-items: center;
}
svg {
display: block;
width: auto;
height: auto;
max-width: min(100%, 26rem);
max-height: 58vh;
}
path {
stroke: var(--c-bg);
stroke-width: 1.2;
transition:
fill 0.15s var(--suave),
stroke 0.15s var(--suave);
}
/* Un país sin géneros no se pulsa, así que tampoco se ilumina. */
.vacio {
fill: color-mix(in oklab, var(--c-bg-raised) 55%, transparent);
pointer-events: none;
}
.pais {
cursor: pointer;
}
/*
* Tres estados y no dos: donde nació algo pesa más que donde se adoptó, y
* esa diferencia es lo que el mapa cuenta mejor que una lista.
*/
.pais .tierra {
fill: color-mix(in oklab, var(--c-accent) 22%, var(--c-bg-raised));
}
.pais.cuna .tierra {
fill: color-mix(in oklab, var(--c-accent) 55%, var(--c-bg-raised));
}
.pais:hover .tierra,
.pais:focus-visible .tierra {
fill: var(--c-accent);
}
.pais.elegido .tierra {
fill: var(--c-accent);
stroke: var(--c-text);
stroke-width: 2;
}
.pais:focus {
outline: none;
}
/*
* El punto es el área de golpeo y solo se ve cuando hace falta: transparente
* en reposo, marcado al pasar por encima, al enfocar con el teclado y en el
* país elegido.
*/
.punto {
fill: transparent;
stroke: none;
transition: fill 0.15s var(--suave);
}
.pais:focus-visible .punto {
fill: color-mix(in oklab, var(--c-text) 70%, transparent);
}
.pais.elegido .punto {
fill: var(--c-text);
}
@media (prefers-reduced-motion: reduce) {
path,
.punto {
transition: none;
}
}
</style>

File diff suppressed because one or more lines are too long

@ -9,24 +9,41 @@
import { and, asc, desc, eq, inArray } from 'drizzle-orm';
import { db } from './db';
import { cancion, entrada, entradaCancion, entradaEtiqueta, etiqueta, pagina } from './db/schema';
import {
cancion,
entrada,
entradaCancion,
entradaEtiqueta,
etiqueta,
medio,
pagina
} from './db/schema';
import { componer } from './markdown';
import { leerCatalogo } from './catalogo';
import * as q from '$lib/catalogo/consultas';
import { PORTADA_POR_DEFECTO } from '$lib/content/schema';
import type { Cancion, EntradaDeBlog } from '$lib/types';
/** Una entrada, con lo que necesita el listado. */
/**
* Una entrada, con lo que necesita el listado.
*
* La portada tiene respaldo: una entrada sin imagen propia sale con la de
* reserva en lugar de con un hueco, porque el listado la enseña siempre y una
* tarjeta a medias descoloca la rejilla entera.
*/
function comoEntrada(fila: {
slug: string;
titulo: string;
fecha: string;
resumen: string;
}): Omit<EntradaDeBlog, 'canciones' | 'etiquetas' | 'imagen'> {
imagenClave?: string | null;
}): Omit<EntradaDeBlog, 'canciones' | 'etiquetas'> {
return {
slug: fila.slug,
titulo: fila.titulo,
fecha: fila.fecha,
resumen: fila.resumen,
imagen: fila.imagenClave ? `/${fila.imagenClave}` : PORTADA_POR_DEFECTO,
borrador: false
};
}
@ -39,9 +56,11 @@ export async function entradasPublicadas(): Promise<EntradaDeBlog[]> {
slug: entrada.slug,
titulo: entrada.titulo,
fecha: entrada.fecha,
resumen: entrada.resumen
resumen: entrada.resumen,
imagenClave: medio.clave
})
.from(entrada)
.leftJoin(medio, eq(medio.id, entrada.imagenId))
.where(eq(entrada.estado, 'publicado'))
.orderBy(desc(entrada.fecha));
@ -81,8 +100,9 @@ export interface EntradaCompleta extends EntradaDeBlog {
export async function obtenerEntrada(slug: string): Promise<EntradaCompleta | null> {
const [fila] = await db
.select()
.select({ entrada, imagenClave: medio.clave })
.from(entrada)
.leftJoin(medio, eq(medio.id, entrada.imagenId))
.where(and(eq(entrada.slug, slug), eq(entrada.estado, 'publicado')))
.limit(1);
@ -93,14 +113,14 @@ export async function obtenerEntrada(slug: string): Promise<EntradaCompleta | nu
.select({ nombre: etiqueta.nombre })
.from(entradaEtiqueta)
.innerJoin(etiqueta, eq(etiqueta.id, entradaEtiqueta.etiquetaId))
.where(eq(entradaEtiqueta.entradaId, fila.id))
.where(eq(entradaEtiqueta.entradaId, fila.entrada.id))
.orderBy(asc(etiqueta.nombre)),
db
.select({ slug: cancion.slug })
.from(entradaCancion)
.innerJoin(cancion, eq(cancion.id, entradaCancion.cancionId))
.where(eq(entradaCancion.entradaId, fila.id)),
.where(eq(entradaCancion.entradaId, fila.entrada.id)),
leerCatalogo(),
entradasPublicadas()
@ -113,10 +133,10 @@ export async function obtenerEntrada(slug: string): Promise<EntradaCompleta | nu
const i = todas.findIndex((e) => e.slug === slug);
return {
...comoEntrada(fila),
...comoEntrada({ ...fila.entrada, imagenClave: fila.imagenClave }),
etiquetas: etiquetas.map((e) => e.nombre),
canciones: temas.map((t) => t.slug),
html: componer(fila.cuerpo),
html: componer(fila.entrada.cuerpo),
temas: temas
.map((t) => q.obtenerCancion(catalogo, t.slug))
.filter((c): c is Cancion => c !== undefined),

@ -496,3 +496,159 @@ async function fichaDeGenero(generoId: string): Promise<FichaDeGenero | null> {
}))
};
}
/* -------------------------------------------------------------------------
* La portada de géneros
* ---------------------------------------------------------------------- */
/** Un género, con lo que hace falta para buscarlo y para pintarlo en el mapa. */
export interface GeneroEnElMapa {
slug: string;
nombre: string;
resumen: string;
origenLugar: string | null;
origenAno: number | null;
/** Su relación con cada país: dónde nació y quién lo adoptó. */
paises: { codigo: string; nombre: string; relacion: string }[];
alias: string[];
temas: string[];
}
/**
* Todos los géneros con sus países, alias y temática.
*
* Es una sola consulta por tabla y el cruce se hace aquí: son nueve géneros y
* unas decenas de filas en cada auxiliar. Con una consulta por género serían
* treinta viajes para pintar una página que se ve entera de un vistazo.
*/
export async function generosParaLaPortada(): Promise<GeneroEnElMapa[]> {
const [generos, paises, alias, temas] = await Promise.all([
db
.select({
id: genero.id,
slug: genero.slug,
nombre: genero.nombre,
resumen: genero.resumen,
origenLugar: genero.origenLugar,
origenAno: genero.origenAno
})
.from(genero)
.innerJoin(capitulo, eq(capitulo.generoId, genero.id))
// Solo los que tienen ficha publicada: el bolero-son existe como fila
// del linaje y no tiene página a la que llevar.
.where(eq(capitulo.estado, 'publicado'))
.orderBy(asc(genero.nombre)),
db
.select({
generoId: generoPais.generoId,
codigo: pais.codigo,
nombre: pais.nombre,
relacion: generoPais.relacion
})
.from(generoPais)
.innerJoin(pais, eq(pais.codigo, generoPais.paisCodigo)),
db.select({ generoId: generoAlias.generoId, nombre: generoAlias.nombre }).from(generoAlias),
db
.select({ generoId: generoTema.generoId, nombre: etiqueta.nombre })
.from(generoTema)
.innerJoin(etiqueta, eq(etiqueta.id, generoTema.etiquetaId))
.orderBy(asc(generoTema.orden))
]);
return generos.map((g) => ({
slug: g.slug,
nombre: g.nombre,
resumen: g.resumen,
origenLugar: g.origenLugar,
origenAno: g.origenAno,
paises: paises
.filter((p) => p.generoId === g.id)
// La cuna primero: es el dato que se lee, los demás acompañan.
.sort((a, b) => (a.relacion === 'cuna' ? -1 : 1) - (b.relacion === 'cuna' ? -1 : 1))
.map(({ codigo, nombre, relacion }) => ({ codigo, nombre, relacion })),
alias: alias.filter((a) => a.generoId === g.id).map((a) => a.nombre),
temas: temas.filter((t) => t.generoId === g.id).map((t) => t.nombre)
}));
}
/* -------------------------------------------------------------------------
* Letristas
* ---------------------------------------------------------------------- */
/** Quien escribió en un género, con lo que hace falta para situarlo. */
export interface Letrista {
slug: string;
nombre: string;
nacimiento: number | null;
defuncion: number | null;
pais: string | null;
semblanza: string | null;
/** En qué géneros aparece y con qué papel. */
generos: { slug: string; nombre: string; papel: string }[];
/** Las obras suyas que están en las fichas. */
obras: { titulo: string; ano: number | null }[];
}
/**
* Las personas que aparecen en las fichas de género.
*
* Salen de `genero_persona`, así que la lista es la de quien está acreditado en
* alguna ficha y no la tabla `persona` entera —que tiene también a quien firma
* las canciones del catálogo—.
*/
export async function letristas(): Promise<Letrista[]> {
const [gente, enGeneros, obras] = await Promise.all([
db
.selectDistinctOn([persona.id], {
id: persona.id,
slug: persona.slug,
nombre: persona.nombre,
nacimiento: persona.nacimiento,
defuncion: persona.defuncion,
semblanza: persona.semblanza,
pais: pais.nombre
})
.from(persona)
.innerJoin(generoPersona, eq(generoPersona.personaId, persona.id))
.leftJoin(pais, eq(pais.codigo, persona.paisCodigo)),
db
.select({
personaId: generoPersona.personaId,
papel: generoPersona.papel,
slug: genero.slug,
nombre: genero.nombre
})
.from(generoPersona)
.innerJoin(genero, eq(genero.id, generoPersona.generoId))
.orderBy(asc(generoPersona.orden)),
db
.select({
personaId: obraReferenciaPersona.personaId,
titulo: obraReferencia.titulo,
ano: obraReferencia.ano
})
.from(obraReferenciaPersona)
.innerJoin(obraReferencia, eq(obraReferencia.id, obraReferenciaPersona.obraId))
.orderBy(asc(obraReferencia.ano))
]);
return gente
.map((p) => ({
slug: p.slug,
nombre: p.nombre,
nacimiento: p.nacimiento,
defuncion: p.defuncion,
pais: p.pais,
semblanza: p.semblanza,
generos: enGeneros
.filter((g) => g.personaId === p.id)
.map(({ slug, nombre, papel }) => ({ slug, nombre, papel })),
obras: obras.filter((o) => o.personaId === p.id).map(({ titulo, ano }) => ({ titulo, ano }))
}))
.sort((a, b) => a.nombre.localeCompare(b.nombre, 'es'));
}

@ -1,11 +1,59 @@
<script lang="ts">
/**
* El listado del blog.
*
* Con dos formas de verlo, como en música: tarjetas para mirar y tabla para
* buscar algo concreto. Y con la misma barra de consulta que el glosario
* —buscar y acotar por etiqueta—, para que el sitio se maneje igual en todas
* partes.
*/
import { resolve } from '$app/paths';
import Meta from '$lib/components/Meta.svelte';
import { brillo } from '$lib/acciones/brillo';
import { revelar } from '$lib/acciones/revelar';
import { aSlug as slugDeEtiqueta } from '$lib/content/schema';
import { formatearFecha } from '$lib/utils/date';
let { data } = $props();
let consulta = $state('');
let etiquetaActiva = $state('todas');
let vista = $state<'tarjetas' | 'tabla'>('tarjetas');
/** Sin tildes y en minúsculas: quien busca «cancion» quiere «canción». */
const plano = (t: string) => t.toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '');
/** Las etiquetas que existen, con cuántas entradas tiene cada una. */
const etiquetas = $derived.by(() => {
// Objeto y no `Map`: es una cuenta local que se tira al salir de aquí, y
// un `Map` dentro de estado reactivo obliga a usar el de Svelte.
const cuenta: Record<string, number> = {};
for (const entrada of data.entradas) {
for (const nombre of entrada.etiquetas) {
cuenta[nombre] = (cuenta[nombre] ?? 0) + 1;
}
}
return Object.entries(cuenta)
.map(([nombre, cuantas]) => ({ nombre, cuantas }))
.sort((a, b) => b.cuantas - a.cuantas || a.nombre.localeCompare(b.nombre, 'es'));
});
/*
* Busca en el título y en el resumen. En el cuerpo no: no viaja con el
* listado, y traerlo entero para poder buscar en él sería mandar todas las
* entradas completas para pintar una lista.
*/
const encontradas = $derived.by(() => {
const q = plano(consulta.trim());
return data.entradas.filter((e) => {
if (etiquetaActiva !== 'todas' && !e.etiquetas.includes(etiquetaActiva)) return false;
if (!q) return true;
return plano(e.titulo).includes(q) || plano(e.resumen).includes(q);
});
});
const buscando = $derived(consulta.trim() !== '' || etiquetaActiva !== 'todas');
</script>
<Meta
@ -27,42 +75,151 @@
</p>
</header>
{#if data.entradas.length > 0}
<!--
Una sola pieza: buscar y elegir cómo se ve son el mismo gesto —acotar lo
que hay en pantalla—, igual que en la página de música.
-->
<div class="barra">
<label class="visually-hidden" for="buscar-entrada">Buscar una entrada</label>
<input
id="buscar-entrada"
type="search"
bind:value={consulta}
placeholder="Buscar por título o por lo que cuenta"
autocomplete="off"
/>
<div class="vistas" role="group" aria-label="Cómo ver el blog">
<button
type="button"
class="vista"
class:oro={vista === 'tarjetas'}
aria-pressed={vista === 'tarjetas'}
onclick={() => (vista = 'tarjetas')}
>
Tarjetas
</button>
<button
type="button"
class="vista"
class:oro={vista === 'tabla'}
aria-pressed={vista === 'tabla'}
onclick={() => (vista = 'tabla')}
>
Tabla
</button>
</div>
</div>
{#if etiquetas.length > 0}
<div class="etiquetas" role="group" aria-label="Filtrar por etiqueta">
<button
type="button"
class="filtro"
aria-pressed={etiquetaActiva === 'todas'}
onclick={() => (etiquetaActiva = 'todas')}
>
Todas
</button>
{#each etiquetas as etiqueta (etiqueta.nombre)}
<button
type="button"
class="filtro"
aria-pressed={etiquetaActiva === etiqueta.nombre}
onclick={() => (etiquetaActiva = etiqueta.nombre)}
>
{etiqueta.nombre}
<span class="cuantas">{etiqueta.cuantas}</span>
</button>
{/each}
</div>
{/if}
<p class="resultado muted" role="status">
{#if buscando}
{encontradas.length}
{encontradas.length === 1 ? 'entrada' : 'entradas'}
{:else}
{data.entradas.length}
{data.entradas.length === 1 ? 'entrada' : 'entradas'} en total
{/if}
</p>
{#if encontradas.length === 0}
<p class="vacio muted">
{#if data.entradas.length === 0}
Todavía no hay ninguna entrada.
{:else}
Nada con eso. Prueba con otra palabra o quita el filtro de etiqueta.
{/if}
</p>
{:else if vista === 'tabla'}
<div class="tabla-envoltura">
<table class="tabla">
<thead>
<tr>
<th scope="col">Fecha</th>
<th scope="col">Título</th>
<th scope="col">De qué va</th>
<th scope="col">Etiquetas</th>
</tr>
</thead>
<tbody>
{#each encontradas as entrada (entrada.slug)}
<tr>
<td class="celda-fecha">
<time datetime={entrada.fecha}>{formatearFecha(entrada.fecha)}</time>
</td>
<td>
<a href={resolve('/blog/[slug]', { slug: entrada.slug })}>{entrada.titulo}</a>
</td>
<td class="celda-resumen muted">{entrada.resumen}</td>
<td class="celda-etiquetas muted">{entrada.etiquetas.join(', ')}</td>
</tr>
{/each}
</tbody>
</table>
</div>
{:else}
<ul class="entradas">
{#each data.entradas as entrada, i (entrada.slug)}
<li use:revelar={Math.min(i, 8) * 45}>
{#each encontradas as entrada, i (entrada.slug)}
<li class="tarjeta" use:revelar={Math.min(i, 8) * 45} use:brillo>
<article>
<p class="fecha muted">
<time datetime={entrada.fecha}>{formatearFecha(entrada.fecha)}</time>
</p>
<h2>
<a href={resolve('/blog/[slug]', { slug: entrada.slug })}>{entrada.titulo}</a>
</h2>
{#if entrada.resumen}
<p class="resumen muted">{entrada.resumen}</p>
{/if}
{#if entrada.etiquetas.length > 0}
<ul class="chips">
{#each entrada.etiquetas as etiqueta (etiqueta)}
<li>
<a
class="chip"
href={resolve('/etiquetas/[etiqueta]', {
etiqueta: slugDeEtiqueta(etiqueta)
})}
>
{etiqueta}
</a>
</li>
{/each}
</ul>
{/if}
<a class="portada" href={resolve('/blog/[slug]', { slug: entrada.slug })} tabindex="-1">
<img src={entrada.imagen} alt="" loading="lazy" />
</a>
<div class="cuerpo">
<p class="fecha muted">
<time datetime={entrada.fecha}>{formatearFecha(entrada.fecha)}</time>
</p>
<h2>
<a href={resolve('/blog/[slug]', { slug: entrada.slug })}>{entrada.titulo}</a>
</h2>
{#if entrada.resumen}
<p class="resumen muted">{entrada.resumen}</p>
{/if}
{#if entrada.etiquetas.length > 0}
<ul class="chips">
{#each entrada.etiquetas as etiqueta (etiqueta)}
<li>
<a
class="chip"
href={resolve('/etiquetas/[etiqueta]', {
etiqueta: slugDeEtiqueta(etiqueta)
})}
>
{etiqueta}
</a>
</li>
{/each}
</ul>
{/if}
</div>
</article>
</li>
{/each}
</ul>
{:else}
<p class="muted">Todavía no hay ninguna entrada.</p>
{/if}
</div>
@ -86,24 +243,164 @@
font-size: var(--fs-sm);
}
.chips {
margin-top: var(--sp-3);
/* --- La barra de consulta ------------------------------------------------ */
.barra {
display: flex;
align-items: stretch;
border: 1px solid var(--c-border);
border-radius: var(--radius-boton);
background: var(--c-bg-sunken);
overflow: hidden;
}
.barra:focus-within {
border-color: var(--c-accent);
box-shadow: 0 0 0 2px color-mix(in oklab, var(--c-accent) 30%, transparent);
}
input[type='search'] {
flex: 1 1 12rem;
min-width: 0;
padding: var(--boton-relleno);
border: 0;
background: transparent;
color: inherit;
font: inherit;
font-size: var(--boton-texto);
}
input[type='search']:focus-visible {
outline: none;
}
.vistas {
display: flex;
border-left: 1px solid var(--c-border);
}
.vista {
padding: var(--boton-relleno);
border: 0;
background-color: transparent;
font: inherit;
font-size: var(--boton-texto);
white-space: nowrap;
cursor: pointer;
transition:
background-color 0.2s var(--suave),
color 0.2s var(--suave);
}
/* Ver el comentario de la misma barra en la página de música. */
.vista:not(.oro) {
color: var(--c-text-muted);
}
.vista + .vista {
border-left: 1px solid var(--c-border);
}
.vista:hover:not(.oro) {
background-color: var(--c-bg-raised);
color: var(--c-text);
}
.etiquetas {
display: flex;
flex-wrap: wrap;
gap: var(--sp-2);
padding-top: var(--sp-3);
}
.filtro {
display: inline-flex;
align-items: baseline;
gap: var(--sp-2);
padding: 0.25rem 0.7rem;
border: 1px solid var(--c-border);
border-radius: var(--radius-boton);
background: transparent;
color: var(--c-text-muted);
font: inherit;
font-size: var(--fs-xs);
cursor: pointer;
}
.filtro:hover {
color: var(--c-text);
border-color: var(--c-text-muted);
}
.filtro[aria-pressed='true'] {
color: var(--c-accent);
border-color: color-mix(in oklab, var(--c-accent) 45%, transparent);
background: color-mix(in oklab, var(--c-accent) 10%, transparent);
}
.cuantas {
font-family: var(--font-mono);
opacity: 0.7;
}
.resultado {
padding-block: var(--sp-3);
font-size: var(--fs-sm);
}
.vacio {
padding-block: var(--sp-6);
}
/* --- Tarjetas ------------------------------------------------------------ */
/*
* `auto-fit` y no `auto-fill`: con dos entradas, `auto-fill` crea las cuatro
* columnas que caben y deja dos vacías a la derecha. `auto-fit` colapsa las
* que sobran y lo que hay se reparte el ancho entero.
*/
.entradas {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(20rem, 100%), 1fr));
gap: var(--sp-4);
list-style: none;
margin: 0;
padding: 0 0 var(--sp-7);
border-top: 1px solid var(--c-border);
}
.entradas > li {
padding-block: var(--sp-5);
border-bottom: 1px solid var(--c-border);
.entradas article {
display: grid;
grid-template-rows: auto 1fr;
height: 100%;
}
/*
* La portada, con proporción fija. Sin ella, dos imágenes de distinto alto
* descuadran la fila entera y las tarjetas dejan de alinearse.
*/
.portada {
display: block;
aspect-ratio: 16 / 9;
overflow: hidden;
background: var(--c-bg-sunken);
}
.portada img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.cuerpo {
display: flex;
flex-direction: column;
gap: var(--sp-2);
padding: var(--sp-4);
}
.fecha {
margin: 0 0 var(--sp-2);
margin: 0;
font-family: var(--font-mono);
font-size: var(--fs-xs);
text-transform: uppercase;
@ -112,8 +409,7 @@
h2 {
margin: 0;
font-size: var(--fs-2xl);
max-width: 24ch;
font-size: var(--fs-xl);
}
h2 a {
@ -127,11 +423,89 @@
}
.resumen {
max-width: 62ch;
margin-top: var(--sp-2);
margin: 0;
font-size: var(--fs-sm);
}
.chips {
margin-top: auto;
padding-top: var(--sp-2);
}
/* --- Tabla --------------------------------------------------------------- */
/* Que la tabla se desplace dentro de su caja y no arrastre la página. */
.tabla-envoltura {
overflow-x: auto;
padding-bottom: var(--sp-7);
}
.tabla {
width: 100%;
border-collapse: collapse;
font-size: var(--fs-sm);
}
.tabla th {
text-align: left;
padding: var(--sp-2) var(--sp-3);
border-bottom: 1px solid var(--c-border);
font-size: var(--fs-xs);
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--c-text-muted);
white-space: nowrap;
}
.tabla td {
padding: var(--sp-3);
border-bottom: 1px solid var(--c-border);
vertical-align: baseline;
}
.tabla td a {
font-family: var(--font-display);
font-size: var(--fs-lg);
text-decoration: none;
}
.celda-fecha {
white-space: nowrap;
font-family: var(--font-mono);
font-size: var(--fs-xs);
color: var(--c-text-muted);
}
.celda-resumen {
min-width: 24ch;
}
.celda-etiquetas {
white-space: nowrap;
}
@media (max-width: 30rem) {
.barra {
flex-direction: column;
}
input[type='search'] {
flex: 0 0 auto;
}
.vistas {
border-left: 0;
border-top: 1px solid var(--c-border);
}
.vista {
flex: 1;
}
}
@media (prefers-reduced-motion: reduce) {
h2 a {
h2 a,
.vista {
transition: none;
}
}

@ -0,0 +1,10 @@
import { indiceDelTaller } from '$lib/server/taller';
/**
* El índice de géneros vive en el layout, así que se carga una vez y lo tienen
* la portada de la sección y cada ficha.
*/
export async function load() {
const { fichas } = await indiceDelTaller();
return { fichas };
}

@ -0,0 +1,69 @@
<script lang="ts">
/**
* Estructura de la sección de géneros.
*
* El mismo reparto que el taller —índice a la izquierda y contenido al
* lado—, porque son dos mitades de lo mismo: el curso explica cómo se
* escribe y las fichas, cómo suena cada estilo. Se separaron en dos
* secciones cuando el catálogo de géneros pasó a tener portada propia con
* mapa y buscador: como capítulo dentro del taller ya no cabía.
*/
import { page } from '$app/state';
import IndiceDeGeneros from '$lib/components/IndiceDeGeneros.svelte';
let { data, children } = $props();
/** La página abierta: el slug de un género, «glosario», «letristas» o nada. */
const actual = $derived(page.url.pathname.split('/')[2] ?? '');
</script>
<div class="container generos">
<aside class="indice">
<IndiceDeGeneros generos={data.fichas} {actual} />
</aside>
<div class="principal">
{@render children()}
</div>
</div>
<style>
.generos {
display: grid;
grid-template-columns: 13rem minmax(0, 1fr);
gap: var(--sp-6);
align-items: start;
}
/* Se separa de la cabecera pegajosa en vez de deslizarse bajo ella. */
.indice {
position: sticky;
top: calc(var(--altura-cabecera) + var(--sp-5));
max-height: calc(100vh - var(--altura-cabecera) - var(--sp-6));
overflow-y: auto;
padding-top: var(--sp-7);
}
.principal {
min-width: 0;
}
@media (max-width: 60rem) {
.generos {
grid-template-columns: minmax(0, 1fr);
}
/*
* Sin sitio para la columna, el índice pasa detrás del contenido: saber
* qué géneros hay importa igual en un móvil, así que se mueve, no se
* esconde.
*/
.indice {
position: static;
order: 1;
max-height: none;
padding-top: var(--sp-5);
border-top: 1px solid var(--c-border);
}
}
</style>

@ -0,0 +1,5 @@
import { generosParaLaPortada } from '$lib/server/taller';
export async function load() {
return { generos: await generosParaLaPortada() };
}

@ -0,0 +1,279 @@
<script lang="ts">
/**
* La portada de los géneros.
*
* Dos formas de llegar a una ficha, porque son dos preguntas distintas: el
* mapa responde a «qué se canta en Cuba» y el buscador a «cuál era el que
* iba en tres por cuatro». Las dos filtran la misma lista, así que lo que se
* ve debajo es siempre el resultado de lo que se ha pedido.
*/
import { resolve } from '$app/paths';
import Meta from '$lib/components/Meta.svelte';
import MapaDeGeneros from '$lib/components/MapaDeGeneros.svelte';
let { data } = $props();
let consulta = $state('');
let pais = $state('');
/** Sin tildes y en minúsculas: quien busca «bolero-son» quiere «bolero son». */
const plano = (t: string) => t.toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '');
const encontrados = $derived.by(() => {
const q = plano(consulta.trim());
return data.generos.filter((g) => {
if (pais && !g.paises.some((p) => p.codigo === pais)) return false;
if (!q) return true;
/*
* Busca por todo lo que alguien podría teclear: el nombre, cómo se
* llama en otro país, de qué habla, de dónde es y el resumen.
*/
const donde = [
g.nombre,
g.resumen,
g.origenLugar ?? '',
...g.alias,
...g.temas,
...g.paises.map((p) => p.nombre)
];
return donde.some((t) => plano(t).includes(q));
});
});
const paisElegido = $derived(
pais ? data.generos.flatMap((g) => g.paises).find((p) => p.codigo === pais) : undefined
);
const filtrando = $derived(consulta.trim() !== '' || pais !== '');
/*
* Con un país elegido, sus géneros se parten en dos: los que nacieron allí y
* los que llegaron después. Es la diferencia que el mapa ya distingue por
* color, y en una lista sin separar se perdería.
*/
const relacionCon = (g: (typeof data.generos)[number]) =>
g.paises.find((p) => p.codigo === pais)?.relacion;
const nacidos = $derived(pais ? encontrados.filter((g) => relacionCon(g) === 'cuna') : []);
const adoptados = $derived(pais ? encontrados.filter((g) => relacionCon(g) !== 'cuna') : []);
</script>
<Meta
titulo="Géneros"
descripcion="Las fichas de creación de los estilos hispanoamericanos: de dónde vienen, cómo se escriben y cómo suenan."
/>
<header class="portada">
<h1>Géneros</h1>
<p class="entradilla">
Una ficha por estilo, con lo que hace falta para escribir una letra en él y que suene a lo que
es. Pulsa un país para ver lo suyo, o busca por nombre, por tema o por cómo se llama en otro
sitio.
</p>
</header>
<MapaDeGeneros
generos={data.generos}
{pais}
alElegir={(codigo) => (pais = pais === codigo ? '' : codigo)}
/>
<div class="buscador">
<label class="visually-hidden" for="buscar-genero">Buscar un género</label>
<input
id="buscar-genero"
type="search"
bind:value={consulta}
placeholder="Buscar por nombre, tema, país o compás"
autocomplete="off"
/>
{#if pais}
<button type="button" class="quitar" onclick={() => (pais = '')}>
{paisElegido?.nombre ?? pais}
<span aria-hidden="true">×</span>
<span class="visually-hidden">Quitar el filtro de país</span>
</button>
{/if}
</div>
<p class="resultado muted" role="status">
{#if filtrando}
{encontrados.length}
{encontrados.length === 1 ? 'género' : 'géneros'}
{:else}
{data.generos.length} géneros
{/if}
</p>
{#if encontrados.length === 0}
<p class="vacio muted">
Nada con eso. Prueba con otra palabra, o quita el país si lo tienes puesto.
</p>
{:else if pais}
{#if nacidos.length > 0}
<section class="bloque">
<h2>Nació en {paisElegido?.nombre}</h2>
{@render lista(nacidos)}
</section>
{/if}
{#if adoptados.length > 0}
<section class="bloque">
<h2>También se canta ahí</h2>
{@render lista(adoptados)}
</section>
{/if}
{:else}
{@render lista(encontrados)}
{/if}
{#snippet lista(generos: typeof data.generos)}
<ul class="generos">
{#each generos as genero (genero.slug)}
{@const cuna = genero.paises.find((p) => p.relacion === 'cuna')}
<li>
<a href={resolve('/generos/[slug]', { slug: genero.slug })}>
<span class="nombre">{genero.nombre}</span>
<span class="origen muted">
{cuna?.nombre ?? genero.origenLugar ?? ''}{#if genero.origenAno}, {genero.origenAno}{/if}
</span>
<span class="resumen muted">{genero.resumen}</span>
{#if genero.temas.length > 0}
<span class="temas muted">{genero.temas.slice(0, 3).join(' · ')}</span>
{/if}
</a>
</li>
{/each}
</ul>
{/snippet}
<style>
.portada {
max-width: 62ch;
padding-block: var(--sp-6) var(--sp-5);
}
h1 {
margin: 0 0 var(--sp-2);
font-size: var(--fs-3xl);
}
.entradilla {
font-size: var(--fs-lg);
color: var(--c-text-muted);
}
.buscador {
display: flex;
align-items: center;
gap: var(--sp-3);
padding-top: var(--sp-6);
}
input[type='search'] {
flex: 1 1 auto;
min-width: 0;
padding: var(--boton-relleno);
border: 1px solid var(--c-border);
border-radius: var(--radius-boton);
background: var(--c-bg-sunken);
color: inherit;
font: inherit;
font-size: var(--boton-texto);
}
input[type='search']:focus-visible {
outline: 2px solid var(--c-accent);
outline-offset: 1px;
}
/* El país elegido en el mapa, como una pastilla que se puede quitar. */
.quitar {
display: inline-flex;
align-items: center;
gap: var(--sp-2);
padding: 0.35rem 0.75rem;
border: 1px solid color-mix(in oklab, var(--c-accent) 45%, transparent);
border-radius: var(--radius-boton);
background: color-mix(in oklab, var(--c-accent) 12%, transparent);
color: var(--c-accent);
font: inherit;
font-size: var(--fs-sm);
white-space: nowrap;
cursor: pointer;
}
.resultado {
padding-block: var(--sp-3);
font-size: var(--fs-sm);
}
.vacio {
padding-block: var(--sp-6);
}
.bloque + .bloque {
padding-top: var(--sp-5);
}
.bloque h2 {
margin: 0 0 var(--sp-3);
font-size: var(--fs-xl);
}
/*
* `auto-fit` y no `auto-fill`: al filtrar por un país quedan dos o tres
* géneros, y `auto-fill` dejaría las columnas que sobran vacías a la
* derecha.
*/
.generos {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(18rem, 100%), 1fr));
gap: 1px;
background: var(--c-border);
border: 1px solid var(--c-border);
border-radius: var(--radius-tarjeta);
overflow: hidden;
list-style: none;
margin: 0 0 var(--sp-5);
padding: 0;
}
.generos a {
display: grid;
gap: var(--sp-1);
height: 100%;
padding: var(--sp-4);
background: var(--c-bg-raised);
text-decoration: none;
}
.generos a:hover .nombre {
color: var(--c-accent);
}
.nombre {
font-family: var(--font-display);
font-size: var(--fs-xl);
}
.origen {
font-size: var(--fs-xs);
text-transform: uppercase;
letter-spacing: 0.08em;
}
.resumen {
font-size: var(--fs-sm);
line-height: 1.5;
}
.temas {
margin-top: auto;
padding-top: var(--sp-2);
font-size: var(--fs-xs);
}
</style>

@ -0,0 +1,12 @@
import { error } from '@sveltejs/kit';
import { obtenerCapitulo } from '$lib/server/taller';
export async function load({ params }) {
const capitulo = await obtenerCapitulo(params.slug);
if (!capitulo) error(404, 'Ese género no existe.');
// Los principios viven en `/taller`; aquí solo las fichas.
if (capitulo.tipo !== 'ficha') error(404, 'Ese género no existe.');
return { capitulo };
}

@ -0,0 +1,12 @@
<script lang="ts">
import { resolve } from '$app/paths';
import Capitulo from '$lib/components/Capitulo.svelte';
let { data } = $props();
</script>
<Capitulo
capitulo={data.capitulo}
seccion={{ texto: 'Géneros', href: resolve('/generos') }}
enlaceA={(slug) => resolve('/generos/[slug]', { slug })}
/>

@ -0,0 +1,414 @@
<script lang="ts">
/**
* El glosario.
*
* Pensado para doscientas entradas, no para las veintinueve de hoy: con una
* lista suelta, el día que crezca hay que bajar cinco pantallas para llegar
* a la eme. Por eso lleva las tres cosas con las que se consulta un
* diccionario —buscar, saltar a una letra, acotar por materia— y va agrupado
* por inicial.
*
* La definición sale en el mismo globo que dentro de los capítulos: se lee
* lo mismo en los dos sitios y no hay dos maneras de enseñarlo.
*/
import { resolve } from '$app/paths';
import Meta from '$lib/components/Meta.svelte';
import Migas from '$lib/components/Migas.svelte';
import GlobosDeGlosario, { MATERIAS } from '$lib/components/GlobosDeGlosario.svelte';
let { data } = $props();
let consulta = $state('');
let materia = $state('todas');
let lista = $state<HTMLElement | null>(null);
/** Sin tildes y en minúsculas: quien busca «compas» quiere «compás». */
const plano = (t: string) => t.toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '');
/** Las materias que existen de verdad, con cuántos términos tiene cada una. */
const materias = $derived.by(() => {
// Objeto y no `Map`: es una cuenta local que se tira al salir de aquí, y
// un `Map` dentro de estado reactivo obliga a usar el de Svelte.
const cuenta: Record<string, number> = {};
for (const t of data.glosario) {
for (const c of new Set(t.acepciones.map((a) => a.categoria))) {
cuenta[c] = (cuenta[c] ?? 0) + 1;
}
}
return Object.entries(cuenta)
.map(([clave, cuantos]) => ({ clave, nombre: MATERIAS[clave] ?? clave, cuantos }))
.sort((a, b) => a.nombre.localeCompare(b.nombre, 'es'));
});
/*
* Busca en la palabra y en la definición. Lo segundo importa: media consulta
* a un glosario no es «qué significa esto» sino «cómo se llamaba lo de las
* dos vocales que se juntan».
*/
const encontrados = $derived.by(() => {
const q = plano(consulta.trim());
return data.glosario.filter((t) => {
if (materia !== 'todas' && !t.acepciones.some((a) => a.categoria === materia)) return false;
if (!q) return true;
return (
plano(t.termino).includes(q) || t.acepciones.some((a) => plano(a.definicion).includes(q))
);
});
});
/** La inicial con la que se agrupa, sin tilde: «ó» va en la O. */
const inicialDe = (termino: string) => plano(termino).charAt(0).toUpperCase();
const grupos = $derived.by(() => {
const porLetra: Record<string, typeof data.glosario> = {};
for (const t of encontrados) {
const letra = inicialDe(t.termino);
porLetra[letra] = [...(porLetra[letra] ?? []), t];
}
return Object.entries(porLetra).sort(([a], [b]) => a.localeCompare(b, 'es'));
});
/** El abecedario entero: las letras sin términos se ven apagadas, no desaparecen. */
const ABECEDARIO = 'ABCDEFGHIJKLMNÑOPQRSTUVWXYZ'.split('');
const conTerminos = $derived(new Set(grupos.map(([letra]) => letra)));
const migas = [{ texto: 'Géneros', href: resolve('/generos') }, { texto: 'Glosario' }];
</script>
<Meta
titulo="Glosario del taller"
descripcion="Los términos que aparecen al escribir una letra: sinalefa, compás, rima asonante, montuno."
/>
<Migas {migas} />
<!-- La definición sale en el mismo globo que en los capítulos. -->
<GlobosDeGlosario glosario={data.glosario} dentro={lista} ampliada />
<header class="portada">
<p class="rotulo">Taller de letras</p>
<h1>Glosario</h1>
<p class="entradilla">
Las palabras que hacen falta para hablar de una letra. Pulsa una para leer su definición; las
mismas salen marcadas dentro de los capítulos.
</p>
</header>
<!--
La barra de consulta, pegada arriba: con la lista larga, tener que subir a
buscar es justo lo que hace que se abandone un glosario.
-->
<div class="consulta">
<div class="buscador">
<label class="visually-hidden" for="buscar-termino">Buscar un término</label>
<input
id="buscar-termino"
type="search"
bind:value={consulta}
placeholder="Buscar por palabra o por lo que significa"
autocomplete="off"
/>
<p class="cuenta muted" role="status">
{encontrados.length} de {data.glosario.length}
</p>
</div>
<div class="materias" role="group" aria-label="Filtrar por materia">
<button
type="button"
class="materia"
aria-pressed={materia === 'todas'}
onclick={() => (materia = 'todas')}
>
Todas
</button>
{#each materias as m (m.clave)}
<button
type="button"
class="materia"
aria-pressed={materia === m.clave}
onclick={() => (materia = m.clave)}
>
{m.nombre}
<span class="cuantos">{m.cuantos}</span>
</button>
{/each}
</div>
<nav class="abecedario" aria-label="Saltar a una letra">
{#each ABECEDARIO as letra (letra)}
{#if conTerminos.has(letra)}
<a href="#letra-{letra}">{letra}</a>
{:else}
<span aria-hidden="true">{letra}</span>
{/if}
{/each}
</nav>
</div>
{#if grupos.length === 0}
<p class="vacio muted">
Nada con «{consulta.trim()}». Prueba con una palabra suelta: «rima», «verso», «acento».
</p>
{:else}
<div class="grupos" bind:this={lista}>
{#each grupos as [letra, terminos] (letra)}
<section class="grupo" id="letra-{letra}">
<h2>{letra}</h2>
<ul class="terminos">
{#each terminos as termino (termino.slug)}
<li>
<button type="button" class="palabra" data-termino={termino.slug}>
{termino.termino}
<span class="materia-de">
{MATERIAS[termino.acepciones[0].categoria] ??
termino.acepciones[0].categoria}{#if termino.acepciones.length > 1}<span
class="acepciones">· {termino.acepciones.length} acepciones</span
>{/if}
</span>
</button>
</li>
{/each}
</ul>
</section>
{/each}
</div>
{/if}
<style>
/*
* El título y la entradilla se reparten la fila en vez de apilarse.
* Apilados y limitados a la medida de lectura dejaban media pantalla en
* blanco encima de la barra de búsqueda, que es lo primero que se ve al
* entrar.
*/
.portada {
max-width: 62ch;
padding-block: var(--sp-6) var(--sp-4);
}
h1 {
margin: 0;
font-size: var(--fs-3xl);
}
.rotulo {
margin: 0 0 var(--sp-2);
font-size: var(--fs-xs);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.14em;
color: var(--c-text-muted);
}
.entradilla {
font-size: var(--fs-lg);
color: var(--c-text-muted);
}
/* --- La barra de consulta ----------------------------------------------- */
.consulta {
position: sticky;
top: var(--altura-cabecera);
z-index: 5;
display: grid;
gap: var(--sp-3);
padding-block: var(--sp-4);
margin-bottom: var(--sp-5);
background: var(--c-bg);
}
.buscador {
display: flex;
align-items: center;
gap: var(--sp-4);
}
input[type='search'] {
flex: 1 1 auto;
min-width: 0;
padding: var(--boton-relleno);
border: 1px solid var(--c-border);
border-radius: var(--radius-boton);
background: var(--c-bg-sunken);
color: inherit;
font: inherit;
font-size: var(--boton-texto);
}
input[type='search']:focus-visible {
outline: 2px solid var(--c-accent);
outline-offset: 1px;
}
.cuenta {
flex: 0 0 auto;
margin: 0;
font-size: var(--fs-sm);
font-variant-numeric: tabular-nums;
}
.materias {
display: flex;
flex-wrap: wrap;
gap: var(--sp-2);
}
.materia {
display: inline-flex;
align-items: baseline;
gap: var(--sp-2);
padding: 0.25rem 0.7rem;
border: 1px solid var(--c-border);
border-radius: var(--radius-boton);
background: transparent;
color: var(--c-text-muted);
font: inherit;
font-size: var(--fs-xs);
cursor: pointer;
}
.materia:hover {
color: var(--c-text);
border-color: var(--c-text-muted);
}
.materia[aria-pressed='true'] {
color: var(--c-accent);
border-color: color-mix(in oklab, var(--c-accent) 45%, transparent);
background: color-mix(in oklab, var(--c-accent) 10%, transparent);
}
.cuantos {
font-family: var(--font-mono);
opacity: 0.7;
}
/*
* El abecedario entero, con las letras vacías apagadas. Si desaparecieran,
* la fila cambiaría de longitud en cada búsqueda y no habría dónde apuntar.
*/
.abecedario {
display: flex;
flex-wrap: wrap;
gap: 0.15rem;
font-family: var(--font-mono);
font-size: var(--fs-sm);
}
.abecedario a,
.abecedario span {
display: grid;
place-items: center;
min-width: 1.6rem;
padding: 0.1rem;
border-radius: 4px;
text-decoration: none;
}
.abecedario a {
color: var(--c-accent);
}
.abecedario a:hover {
background: color-mix(in oklab, var(--c-accent) 16%, transparent);
}
.abecedario span {
color: color-mix(in oklab, var(--c-text-muted) 45%, transparent);
}
/* --- Los grupos ---------------------------------------------------------- */
.grupos {
display: grid;
gap: var(--sp-6);
}
.grupo {
/* Para que al saltar por el ancla no quede bajo la barra pegajosa. */
scroll-margin-top: calc(var(--altura-cabecera) + 9rem);
}
.grupo h2 {
margin: 0 0 var(--sp-3);
padding-bottom: var(--sp-2);
border-bottom: 1px solid var(--c-border);
font-family: var(--font-mono);
font-size: var(--fs-lg);
color: var(--c-accent);
}
/*
* Columnas de texto, no una rejilla de celdas.
*
* Con rejilla, una letra de un solo término —la be tiene «bandoneón» y nada
* más— dejaba tres celdas vacías pintadas del color del borde: media fila de
* gris a la derecha de una palabra. Un índice de diccionario fluye: donde no
* hay palabras no hay nada que pintar.
*/
.terminos {
columns: 4 14rem;
column-gap: var(--sp-6);
list-style: none;
margin: 0;
padding: 0;
}
.terminos li {
/* Que una entrada no se parta entre el pie de una columna y la siguiente. */
break-inside: avoid;
margin-bottom: var(--sp-1);
}
/*
* La mayúscula es de la presentación, no del dato: el término se guarda en
* minúscula porque así se busca y así se compara con el texto al marcarlo.
* `::first-letter` y no `capitalize`, que pondría «Rima Asonante».
*/
.palabra::first-letter {
text-transform: uppercase;
}
.palabra {
display: block;
width: 100%;
padding: var(--sp-2) var(--sp-2) var(--sp-2) 0;
border: 0;
background: none;
color: inherit;
font: inherit;
font-family: var(--font-display);
font-size: var(--fs-lg);
line-height: 1.25;
text-align: left;
cursor: pointer;
}
.palabra:hover,
.palabra:focus-visible {
color: var(--c-accent);
}
/*
* La materia, debajo y en minúscula. En versalitas pesaba tanto como la
* palabra que acompaña, y aquí lo que se busca con la vista es la palabra.
*/
.materia-de {
display: block;
font-family: var(--font-sans);
font-size: var(--fs-sm);
color: var(--c-text-muted);
}
.acepciones {
margin-left: 0.35rem;
}
.vacio {
padding: var(--sp-6) 0;
}
</style>

@ -0,0 +1,5 @@
import { letristas } from '$lib/server/taller';
export async function load() {
return { letristas: await letristas() };
}

@ -0,0 +1,241 @@
<script lang="ts">
/**
* Quién escribió cada género.
*
* Las mismas personas que salen en las fichas, aquí juntas y ordenadas: es
* la vista que contesta a «quién hay detrás de todo esto», que en las fichas
* está repartida en nueve páginas.
*/
import { resolve } from '$app/paths';
import Meta from '$lib/components/Meta.svelte';
import Migas from '$lib/components/Migas.svelte';
let { data } = $props();
let consulta = $state('');
const plano = (t: string) => t.toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '');
const encontrados = $derived.by(() => {
const q = plano(consulta.trim());
if (!q) return data.letristas;
return data.letristas.filter((p) =>
[
p.nombre,
p.pais ?? '',
...p.generos.map((g) => g.nombre),
...p.obras.map((o) => o.titulo)
].some((t) => plano(t).includes(q))
);
});
const PAPEL: Record<string, string> = {
fundador: 'fundador',
letrista: 'letrista',
compositor: 'compositor',
interprete: 'intérprete'
};
/** «1897–1970», «n. 1957», o nada si no se sabe. */
function vida(nacimiento: number | null, defuncion: number | null): string {
if (nacimiento && defuncion) return `${nacimiento}–${defuncion}`;
if (nacimiento) return `n. ${nacimiento}`;
if (defuncion) return `m. ${defuncion}`;
return '';
}
const migas = [{ texto: 'Géneros', href: resolve('/generos') }, { texto: 'Letristas' }];
</script>
<Meta
titulo="Letristas"
descripcion="Quién escribió los géneros hispanoamericanos: los fundadores, los letristas y los intérpretes que fijaron cada estilo."
/>
<Migas {migas} />
<header class="portada">
<h1>Letristas</h1>
<p class="entradilla">
Quién fijó cada estilo. Salen de las fichas de género, donde cada uno está acreditado con su
papel: quien lo fundó, quien lo escribió y quien lo cantó.
</p>
</header>
<div class="buscador">
<label class="visually-hidden" for="buscar-letrista">Buscar una persona</label>
<input
id="buscar-letrista"
type="search"
bind:value={consulta}
placeholder="Buscar por nombre, país, género u obra"
autocomplete="off"
/>
<p class="cuenta muted" role="status">
{encontrados.length} de {data.letristas.length}
</p>
</div>
{#if encontrados.length === 0}
<p class="vacio muted">Nada con «{consulta.trim()}».</p>
{:else}
<ul class="personas">
{#each encontrados as persona (persona.slug)}
<li>
<div class="quien">
<p class="nombre">{persona.nombre}</p>
<p class="datos muted">
{persona.pais ?? ''}{#if persona.pais && vida(persona.nacimiento, persona.defuncion)}
·
{/if}{vida(persona.nacimiento, persona.defuncion)}
</p>
</div>
<div class="suyo">
{#if persona.semblanza}
<p class="semblanza">{persona.semblanza}</p>
{/if}
<p class="generos">
{#each persona.generos as genero, i (genero.slug + genero.papel)}
{#if i > 0}<span class="separa" aria-hidden="true">·</span>{/if}
<a href={resolve('/generos/[slug]', { slug: genero.slug })}>{genero.nombre}</a>
<span class="papel muted">{PAPEL[genero.papel] ?? genero.papel}</span>
{/each}
</p>
{#if persona.obras.length > 0}
<p class="obras muted">
{persona.obras.map((o) => (o.ano ? `${o.titulo} (${o.ano})` : o.titulo)).join(' · ')}
</p>
{/if}
</div>
</li>
{/each}
</ul>
{/if}
<style>
.portada {
max-width: 62ch;
padding-block: var(--sp-6) var(--sp-4);
}
h1 {
margin: 0 0 var(--sp-2);
font-size: var(--fs-3xl);
}
.entradilla {
font-size: var(--fs-lg);
color: var(--c-text-muted);
}
.buscador {
display: flex;
align-items: center;
gap: var(--sp-4);
padding-bottom: var(--sp-5);
}
input[type='search'] {
flex: 1 1 auto;
min-width: 0;
padding: var(--boton-relleno);
border: 1px solid var(--c-border);
border-radius: var(--radius-boton);
background: var(--c-bg-sunken);
color: inherit;
font: inherit;
font-size: var(--boton-texto);
}
input[type='search']:focus-visible {
outline: 2px solid var(--c-accent);
outline-offset: 1px;
}
.cuenta {
flex: 0 0 auto;
margin: 0;
font-size: var(--fs-sm);
font-variant-numeric: tabular-nums;
}
/*
* Dos columnas por persona: quién es a la izquierda y qué hizo a la derecha.
* La de la izquierda mide lo que mide un nombre; el resto de la fila lo
* ocupa lo que se lee.
*/
.personas {
list-style: none;
margin: 0;
padding: 0 0 var(--sp-7);
border-top: 1px solid var(--c-border);
}
.personas li {
display: grid;
grid-template-columns: minmax(0, 15rem) minmax(0, 1fr);
gap: var(--sp-4) var(--sp-6);
padding-block: var(--sp-4);
border-bottom: 1px solid var(--c-border);
}
.nombre {
margin: 0;
font-family: var(--font-display);
font-size: var(--fs-lg);
}
.datos {
margin: var(--sp-1) 0 0;
font-size: var(--fs-sm);
font-variant-numeric: tabular-nums;
}
.suyo {
display: grid;
gap: var(--sp-2);
}
.semblanza {
margin: 0;
line-height: 1.55;
}
.generos {
margin: 0;
font-size: var(--fs-sm);
}
.generos a {
text-decoration: none;
}
.papel {
margin-left: 0.35rem;
font-size: var(--fs-xs);
text-transform: uppercase;
letter-spacing: 0.08em;
}
.separa {
margin: 0 0.5rem;
color: var(--c-text-muted);
}
.obras {
margin: 0;
font-size: var(--fs-sm);
font-style: italic;
}
@media (max-width: 46rem) {
.personas li {
grid-template-columns: minmax(0, 1fr);
gap: var(--sp-2);
}
}
</style>

@ -68,7 +68,12 @@ export const GET: RequestHandler = async () => {
// Incluye los álbumes de singles, que son sintéticos pero tienen página.
...discos.map((slug) => url(`/albumes/${slug}`, PRIORIDAD.obra)),
...canciones.map((c) => url(`/canciones/${c.slug}`, PRIORIDAD.obra)),
...[...principios, ...fichas].map((c) => url(`/taller/${c.slug}`, PRIORIDAD.obra)),
url('/generos', PRIORIDAD.seccion),
url('/generos/glosario', PRIORIDAD.resto),
url('/generos/letristas', PRIORIDAD.resto),
...principios.map((c) => url(`/taller/${c.slug}`, PRIORIDAD.obra)),
// Las fichas viven en su sección desde que tienen portada propia.
...fichas.map((c) => url(`/generos/${c.slug}`, PRIORIDAD.obra)),
...entradas.map((e) => url(`/blog/${e.slug}`, PRIORIDAD.obra, e.fecha)),
...temas.map((e) => url(`/etiquetas/${e.slug}`, PRIORIDAD.resto)),
...paginas.map((p) => url(`/${p.slug}`, PRIORIDAD.resto)),

@ -22,7 +22,7 @@
<div class="container taller">
<aside class="indice">
<IndiceTaller principios={data.principios} fichas={data.fichas} {actual} />
<IndiceTaller principios={data.principios} {actual} />
</aside>
<div class="principal">

@ -1,7 +1,6 @@
<script lang="ts">
import { resolve } from '$app/paths';
import Meta from '$lib/components/Meta.svelte';
import { brillo } from '$lib/acciones/brillo';
import { revelar } from '$lib/acciones/revelar';
let { data } = $props();
@ -44,33 +43,20 @@
</ol>
</section>
<!--
Las fichas se fueron a su propia sección cuando dejaron de caber aquí: son
nueve, con mapa y buscador propios. Este bloque es el paso de una a otra.
-->
<section class="bloque">
<h2>Fichas de creación</h2>
<h2>Fichas de género</h2>
<p class="muted introduccion">
Una por estilo. No son historia de la música: son lo que hay que tener delante para escribir una
letra en ese estilo y que suene a ese estilo.
Una por estilo hispanoamericano. No son historia de la música: son lo que hay que tener delante
para escribir una letra en ese estilo y que suene a ese estilo.
</p>
<ul class="fichas">
{#each data.fichas as capitulo, i (capitulo.slug)}
<li class="tarjeta" use:revelar={Math.min(i, 8) * 45} use:brillo>
<div class="tarjeta__cuerpo">
<span class="titulo">
<a class="tarjeta__enlace" href={resolve('/taller/[slug]', { slug: capitulo.slug })}>
{capitulo.titulo}
</a>
</span>
{#if capitulo.origenLugar}
<span class="origen muted">{capitulo.origenLugar}</span>
{/if}
<span class="resumen muted">{capitulo.resumen}</span>
{#if capitulo.compasNota}
<span class="compas">{capitulo.compasNota.split(';')[0]}</span>
{/if}
</div>
</li>
{/each}
</ul>
<p class="pasar">
<a class="btn btn--primary" href={resolve('/generos')}>Ver los géneros</a>
</p>
</section>
<style>
@ -140,36 +126,8 @@
max-width: 62ch;
}
.fichas {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(16rem, 1fr));
gap: var(--sp-4);
list-style: none;
margin: var(--sp-5) 0 var(--sp-7);
padding: 0;
}
.origen {
font-size: var(--fs-xs);
text-transform: uppercase;
letter-spacing: 0.06em;
}
.fichas .titulo {
font-family: var(--font-display);
font-size: var(--fs-xl);
}
.fichas .titulo a {
text-decoration: none;
}
.compas {
margin-top: auto;
padding-top: var(--sp-3);
font-family: var(--font-mono);
font-size: var(--fs-xs);
color: var(--c-accent);
.pasar {
margin-top: var(--sp-4);
}
@media (prefers-reduced-motion: reduce) {

File diff suppressed because it is too large Load Diff

@ -1,189 +0,0 @@
<script lang="ts">
/**
* El glosario entero.
*
* Los globos del texto sirven para no perder el hilo mientras se lee; esta
* página es para lo otro: mirar un término a propósito, o recorrerlos para
* ver qué vocabulario hace falta. Por eso aquí sí va la ampliación, que en
* un globo no cabría sin tapar el texto.
*/
import { resolve } from '$app/paths';
import Meta from '$lib/components/Meta.svelte';
import Migas from '$lib/components/Migas.svelte';
let { data } = $props();
const CATEGORIAS: Record<string, string> = {
metrica: 'Métrica',
rima: 'Rima',
musica: 'Música',
estructura: 'Estructura',
interpretacion: 'Interpretación',
instrumento: 'Instrumento'
};
const migas = [{ texto: 'Taller de letras', href: resolve('/taller') }, { texto: 'Glosario' }];
</script>
<Meta
titulo="Glosario del taller"
descripcion="Los términos que aparecen al escribir una letra: sinalefa, compás, rima asonante, montuno."
/>
<Migas {migas} />
<header class="portada">
<p class="rotulo">Taller de letras</p>
<h1>Glosario</h1>
<p class="entradilla">
Las palabras que hacen falta para hablar de una letra. Salen marcadas en el texto de los
capítulos: al pulsarlas se abre su definición sin salir de donde estabas.
</p>
</header>
<ul class="terminos">
{#each data.glosario as termino (termino.slug)}
<li id={termino.slug}>
<h2>{termino.termino}</h2>
<ol class="acepciones" class:una={termino.acepciones.length === 1}>
{#each termino.acepciones as acepcion (acepcion.numero)}
<li>
<p class="categoria">{CATEGORIAS[acepcion.categoria] ?? acepcion.categoria}</p>
<p class="definicion">{acepcion.definicion}</p>
{#if acepcion.ampliacion}
<p class="ampliacion muted">{acepcion.ampliacion}</p>
{/if}
{#if acepcion.ejemplo}
<p class="ejemplo">{acepcion.ejemplo}</p>
{/if}
</li>
{/each}
</ol>
</li>
{/each}
</ul>
<style>
.portada {
max-width: 62ch;
margin-bottom: var(--sp-6);
}
h1 {
font-size: var(--fs-3xl);
}
.rotulo {
margin: 0 0 var(--sp-2);
font-size: var(--fs-xs);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.14em;
color: var(--c-text-muted);
}
.entradilla {
font-size: var(--fs-lg);
color: var(--c-text-muted);
}
/*
* Dos columnas cuando caben. Un glosario es una lista de piezas cortas e
* independientes: en una sola columna a lo alto obliga a bajar el doble para
* ver lo mismo.
*/
.terminos {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(24rem, 100%), 1fr));
gap: var(--sp-4);
list-style: none;
margin: 0;
padding: 0;
align-items: start;
}
.terminos > li {
padding: var(--sp-4);
background: var(--c-bg-raised);
border: 1px solid var(--c-border);
border-radius: var(--radius-tarjeta);
/* Para que al llegar por el ancla no quede bajo la cabecera pegajosa. */
scroll-margin-top: calc(var(--altura-cabecera) + var(--sp-4));
}
.terminos h2 {
margin: 0 0 var(--sp-3);
padding-bottom: var(--sp-2);
border-bottom: 1px solid var(--c-border);
font-size: var(--fs-lg);
}
.acepciones {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: var(--sp-4);
counter-reset: acepcion;
}
/* El número solo si hay más de una: con una sola, un «1» sugiere que falta algo. */
.acepciones:not(.una) > li::before {
counter-increment: acepcion;
content: counter(acepcion) '.';
font-family: var(--font-mono);
font-size: var(--fs-xs);
color: var(--c-accent);
}
.acepciones > li {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 0 var(--sp-2);
}
.acepciones.una > li {
grid-template-columns: minmax(0, 1fr);
}
.acepciones > li::before {
grid-row: 1 / -1;
}
.acepciones p {
grid-column: 2;
}
.acepciones.una p {
grid-column: 1;
}
.categoria {
margin: 0 0 var(--sp-1);
font-size: var(--fs-xs);
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--c-text-muted);
}
.definicion {
margin: 0;
line-height: 1.5;
}
.ampliacion {
margin: var(--sp-2) 0 0;
font-size: var(--fs-sm);
}
.ejemplo {
margin: var(--sp-2) 0 0;
padding-left: var(--sp-3);
border-left: 2px solid var(--c-border);
font-family: var(--font-display);
font-style: italic;
font-size: var(--fs-sm);
color: var(--c-text-muted);
}
</style>
Loading…
Cancel
Save

Powered by TurnKey Linux.