El glosario del taller, con su globo en el texto

Lo que se pidió: «cada vez que aparezca un término del glosario en el
texto, estará marcado para visualizarse con un popup con la explicación».
Veintinueve términos con acepciones numeradas —«clave» es el patrón
rítmico y el instrumento de percusión, y con una sola definición una de
las dos sería falsa—, más las formas con las que cada palabra aparece de
verdad: plurales, femeninos, con tilde y sin ella.

El marcado va sobre el HTML ya compuesto y no sobre el Markdown: antes
habría que esquivar la sintaxis, y un término dentro de un enlace o de un
bloque de código es justo donde un reemplazo de texto se equivoca en
silencio. Se marca una vez por término y por apartado, no en cada
aparición: nueve «compás» subrayados no hay quien los lea.

La pieza tiene más casos raros que cualquier otra del sitio y ninguno se
ve mirando la página —el fallo es que un globo no salga, y nadie echa en
falta lo que no sabe que existía—, así que va separada de la base y con
doce pruebas. Dos las escribí creyendo que ya pasaban:

- Marcaba «sinalefas» del final del párrafo en vez de la «sinalefa» de la
  primera línea, porque probaba forma por forma en orden de longitud.
- Con las formas sin ordenar marcaba «rima» y no «rima asonante». Salía
  bien solo porque la consulta las devolvía ordenadas; ahora el desempate
  está en la función, que no puede depender de cómo se la llame.

El globo es un botón y no un `title`: un tooltip nativo no se abre con
teclado y en un móvil no existe. Se cierra con Escape devolviendo el
foco, y se coloca debajo del término o encima si no cabe, porque tapar lo
que se acaba de pulsar es lo peor que podría hacer.

El glosario entero tiene además su página, donde sí cabe la ampliación
que en un globo taparía el texto.

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

@ -271,6 +271,87 @@ test.describe('Taller de letras', () => {
await expect(aviso).toContainText('versos');
});
/* --- El glosario ------------------------------------------------------- */
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');
const marcados = page.locator('.texto .termino');
await expect(marcados.first()).toBeVisible();
// Cada término una sola vez por apartado, no en cada aparición: un texto
// con nueve «compás» subrayados no hay quien lo lea.
const slugs = await marcados.evaluateAll((bs) =>
bs.map((b) => (b as HTMLElement).dataset.termino)
);
expect(new Set(slugs).size).toBeGreaterThan(1);
});
test('al pulsar un término se abre su definición', async ({ page }) => {
await page.goto('/taller/son-cubano');
const montuno = page.locator('.texto .termino[data-termino="montuno"]').first();
await montuno.click();
const globo = page.getByRole('dialog', { name: /Definición de montuno/ });
await expect(globo).toBeVisible();
await expect(globo).toContainText('llamada y respuesta');
// Y se cierra con Escape, devolviendo el foco a donde estaba.
await page.keyboard.press('Escape');
await expect(globo).toBeHidden();
await expect(montuno).toBeFocused();
});
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');
const termino = page.locator('.texto .termino').first();
await termino.click();
const suyo = (await termino.boundingBox())!;
const globo = (await page.locator('.globo').boundingBox())!;
const seSolapan = globo.y < suyo.y + suyo.height && suyo.y < globo.y + globo.height;
expect(seSolapan).toBe(false);
});
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 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');
await expect(page.getByRole('heading', { level: 1 })).toHaveText('Glosario');
await expect(page.getByRole('heading', { level: 2, name: 'sinalefa' })).toBeVisible();
/*
* «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.
*/
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');
});
test('se llega al glosario desde el índice del taller', async ({ page }) => {
await page.goto('/taller/bolero');
await page
.getByRole('navigation', { name: 'Contenido del taller' })
.getByRole('link', { name: 'Glosario' })
.click();
await expect(page).toHaveURL('/taller/glosario');
});
test('el taller está en la navegación principal', async ({ page }) => {
await page.goto('/');
await page

@ -27,6 +27,7 @@
"db:medios": "node --env-file=.env scripts/db/volcar-medios.mjs",
"db:generos": "node --env-file=.env scripts/db/sembrar-generos.mjs",
"db:versos": "node --env-file=.env scripts/db/sembrar-versos.mjs",
"db:glosario": "node --env-file=.env scripts/db/sembrar-glosario.mjs",
"db:completar": "node --env-file=.env scripts/db/completar-catalogo.mjs",
"db:comparar": "node --experimental-strip-types --import ./scripts/db/resolver-lib.mjs --env-file=.env scripts/db/comparar-catalogo.mjs"
},

@ -0,0 +1,102 @@
/**
* Carga el glosario del taller.
*
* npm run db:glosario
*
* Veintiocho términos con sus acepciones numeradas y las formas con las que
* aparecen de verdad en el texto. Es lo que hace posible el marcado: sin las
* variantes, «sinalefas» en plural no se reconocería y nadie echaría en falta
* el globo que no salió.
*
* Idempotente por slug: reimportar una definición corregida actualiza. Las
* acepciones se rehacen enteras —se borran y se vuelven a poner— porque su
* número es su identidad y renumerar a mano dejaría huecos.
*/
import fs from 'node:fs';
import pg from 'pg';
const SEMILLA = JSON.parse(fs.readFileSync('scripts/db/semilla-glosario.json', 'utf8'));
const slugificar = (t) =>
t
.toLowerCase()
.normalize('NFD')
.replace(/[̀-ͯ]/g, '')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
const c = new pg.Client({ connectionString: process.env.DATABASE_URL, query_timeout: 60_000 });
await c.connect();
await c.query('BEGIN');
const contado = { termino: 0, acepcion: 0, variante: 0 };
const avisos = [];
try {
const vistos = new Set();
for (const t of SEMILLA.terminos) {
const slug = slugificar(t.termino);
if (vistos.has(slug)) {
avisos.push(`«${t.termino}» sale dos veces en la semilla`);
continue;
}
vistos.add(slug);
const { rows } = await c.query(
`INSERT INTO termino (id, slug, termino) VALUES ($1,$2,$3)
ON CONFLICT (slug) DO UPDATE SET termino = EXCLUDED.termino
RETURNING id`,
[crypto.randomUUID(), slug, t.termino]
);
const terminoId = rows[0].id;
contado.termino++;
await c.query('DELETE FROM acepcion WHERE termino_id = $1', [terminoId]);
for (const [i, a] of t.acepciones.entries()) {
await c.query(
`INSERT INTO acepcion (id, termino_id, numero, categoria, definicion, ampliacion, ejemplo)
VALUES ($1,$2,$3,$4,$5,$6,$7)`,
[
crypto.randomUUID(),
terminoId,
i + 1,
a.categoria,
a.definicion,
a.ampliacion ?? null,
a.ejemplo ?? null
]
);
contado.acepcion++;
}
/*
* El propio término también es una forma buscable, así no hay que
* repetirlo en la lista de variantes de los veintiocho.
*/
await c.query('DELETE FROM termino_variante WHERE termino_id = $1', [terminoId]);
for (const forma of new Set([t.termino, ...(t.variantes ?? [])])) {
await c.query(
'INSERT INTO termino_variante (termino_id, forma) VALUES ($1,$2) ON CONFLICT DO NOTHING',
[terminoId, forma.toLowerCase()]
);
contado.variante++;
}
}
await c.query('COMMIT');
} catch (fallo) {
await c.query('ROLLBACK').catch(() => {});
console.error('Falló, no se ha escrito nada:');
console.error(' ' + fallo.message);
await c.end();
process.exit(1);
}
await c.end();
for (const [que, n] of Object.entries(contado)) console.log(` ${que.padEnd(10)} ${n}`);
for (const aviso of avisos) console.log(` aviso: ${aviso}`);
console.log('');
console.log('Glosario cargado.');

@ -0,0 +1,376 @@
{
"_": [
"El glosario del taller.",
"",
"Las 'variantes' son las formas con las que la palabra aparece de verdad en",
"el texto: plurales, femeninos, la forma con tilde y sin ella. El marcado",
"busca por ellas, así que una variante que falte es un término que no se",
"marca y nadie echa de menos.",
"",
"Las acepciones van numeradas porque la misma palabra significa cosas",
"distintas: 'clave' es el instrumento de percusión, el patrón rítmico y la",
"armadura de tonalidad. Con una sola definición, dos de las tres serían",
"falsas.",
"",
"'definicion' es lo que cabe en un globo sin tapar el texto. 'ampliacion' es",
"lo que se lee en la página del glosario, y 'ejemplo' un verso donde se vea:",
"un término de métrica sin ejemplo no se entiende."
],
"terminos": [
{
"termino": "sinalefa",
"variantes": ["sinalefas"],
"acepciones": [
{
"categoria": "metrica",
"definicion": "Dos vocales que se juntan al cantar y cuentan como una sola sílaba: la última de una palabra y la primera de la siguiente.",
"ampliacion": "Es la regla que más cuesta interiorizar y la que más versos arregla. No es opcional: al cantar se produce sola, y contar sin ella da un verso más largo de lo que suena. Cuando molesta —porque junta dos vocales que quieren oírse aparte— se rompe metiendo una consonante o cambiando la palabra.",
"ejemplo": "«Dejaste la taza a medias» son ocho sílabas, no nueve: «za·a» va en una."
}
]
},
{
"termino": "hiato",
"variantes": ["hiatos"],
"acepciones": [
{
"categoria": "metrica",
"definicion": "Lo contrario de la sinalefa: dos vocales seguidas que se pronuncian en sílabas separadas.",
"ampliacion": "Se usa a propósito cuando hace falta una sílaba más o cuando la sinalefa atropella una palabra que importa. Forzarlo demasiado suena a verso mal medido, no a licencia.",
"ejemplo": "«que reías con el otro»: «re·í·as», tres sílabas, porque la tilde rompe el diptongo."
}
]
},
{
"termino": "octosílabo",
"variantes": ["octosílabos", "octosilabo", "octosilabos"],
"acepciones": [
{
"categoria": "metrica",
"definicion": "Verso de ocho sílabas. Es la medida de la copla popular en español y la más usada en la canción.",
"ampliacion": "Suena a lengua hablada porque el castellano cae solo en grupos de ocho. Por eso lo usan la ranchera, el son, la cumbia y el bolero clásico: no hay que forzar nada para que quepa una frase entera.",
"ejemplo": "«Si me llaman por el nombre»"
}
]
},
{
"termino": "endecasílabo",
"variantes": ["endecasílabos", "endecasilabo", "endecasilabos"],
"acepciones": [
{
"categoria": "metrica",
"definicion": "Verso de once sílabas, con acento obligatorio en la décima. Es el verso culto del español.",
"ampliacion": "Da sitio a subordinadas, que es lo que permite tener sintaxis de prosa sin dejar de cantarse. El tango vive de él; el bolero lo alterna con el octosílabo.",
"ejemplo": "«Volví a la esquina donde me esperabas»"
}
]
},
{
"termino": "heptasílabo",
"variantes": ["heptasílabos", "heptasilabo", "heptasilabos"],
"acepciones": [
{
"categoria": "metrica",
"definicion": "Verso de siete sílabas. Suele alternar con el endecasílabo para dar respiro.",
"ampliacion": "Es medida corta y por eso corta la frase: obliga a decir una cosa por verso. Alternado con versos largos, marca el pulso de una estrofa sin que se note.",
"ejemplo": "«y el humo por la casa»"
}
]
},
{
"termino": "métrica",
"variantes": ["metrica"],
"acepciones": [
{
"categoria": "metrica",
"definicion": "La medida de los versos: cuántas sílabas tiene cada uno y cómo se cuentan.",
"ampliacion": "No es un adorno académico: la métrica es lo que hace que una letra se pueda cantar sobre una melodía sin atropellar palabras. Un verso de más sobra, y se nota antes en el oído que en el papel.",
"ejemplo": null
}
]
},
{
"termino": "escansión",
"variantes": ["escansion", "escandir", "escandida", "escandido"],
"acepciones": [
{
"categoria": "metrica",
"definicion": "Contar las sílabas de un verso aplicando las reglas: sinalefas, hiatos y la ley del acento final.",
"ampliacion": "Escandir es lo que separa medir de contar letras. Se hace con la voz, no con los ojos: si al decirlo en alto la cuenta no sale, la escansión está mal.",
"ejemplo": null
}
]
},
{
"termino": "verso agudo",
"variantes": ["versos agudos", "aguda", "agudas"],
"acepciones": [
{
"categoria": "metrica",
"definicion": "Verso que acaba en palabra aguda. Suma una sílaba a la cuenta.",
"ampliacion": "Es la mitad de la ley del acento final, y la que más despista: «yo respondo con el son» tiene siete casillas y es un octosílabo.",
"ejemplo": "«yo respondo con el son» — siete sílabas, mide ocho."
}
]
},
{
"termino": "rima consonante",
"variantes": ["consonante", "rima perfecta"],
"acepciones": [
{
"categoria": "rima",
"definicion": "Riman todos los sonidos desde la última sílaba tónica: vocales y consonantes.",
"ampliacion": "Es la rima cerrada, la que suena a rima. Pide mucho —hay pocas palabras que rimen así— y por eso empuja a decir lo que rima en vez de lo que se quería decir. Úsala cuando el estilo la exija.",
"ejemplo": "techo / lecho, encendido / ruido"
}
]
},
{
"termino": "rima asonante",
"variantes": ["asonante", "asonantes"],
"acepciones": [
{
"categoria": "rima",
"definicion": "Riman solo las vocales desde la última tónica; las consonantes dan igual.",
"ampliacion": "Es la rima de la copla popular. Pide mucho menos que la consonante y por eso deja decir lo que hay que decir: en una letra cantada casi nadie nota la diferencia, y el margen que da se agradece en cada verso.",
"ejemplo": "Jalisco / dicho — las dos en i-o."
}
]
},
{
"termino": "estrofa",
"variantes": ["estrofas"],
"acepciones": [
{
"categoria": "estructura",
"definicion": "Grupo de versos que funciona como unidad: la parte que cuenta algo antes de volver al estribillo.",
"ampliacion": "En una canción la estrofa hace avanzar; el estribillo se queda. Una letra en la que las estrofas no avanzan es una letra que repite el estribillo tres veces con otras palabras.",
"ejemplo": null
}
]
},
{
"termino": "estribillo",
"variantes": ["estribillos"],
"acepciones": [
{
"categoria": "estructura",
"definicion": "La parte que vuelve, igual, cada vez. Es lo que se recuerda y lo que da nombre a la canción.",
"ampliacion": "Tiene que poder cantarse sin haber entendido la estrofa. Si necesita contexto para significar algo, no es estribillo: es otra estrofa con la misma melodía.",
"ejemplo": null
}
]
},
{
"termino": "puente",
"variantes": ["puentes"],
"acepciones": [
{
"categoria": "estructura",
"definicion": "La parte que rompe: distinta de la estrofa y del estribillo, y suele aparecer una sola vez.",
"ampliacion": "Sirve para que el último estribillo signifique algo distinto del primero. Sin puente, una canción de tres minutos dice lo mismo tres veces.",
"ejemplo": null
}
]
},
{
"termino": "colchón",
"variantes": ["colchon", "colchones"],
"acepciones": [
{
"categoria": "musica",
"definicion": "El fondo sostenido —cuerdas, teclado, coros— sobre el que se apoya la voz sin competir con ella.",
"ampliacion": "Al escribir importa porque un colchón admite versos largos y frases alargadas: no hay que dejar hueco. Sobre un acompañamiento marcado, en cambio, cada sílaba cae en un sitio.",
"ejemplo": null
}
]
},
{
"termino": "compás",
"variantes": ["compas", "compases"],
"acepciones": [
{
"categoria": "musica",
"definicion": "Cómo se agrupan los pulsos: de dos en dos, de tres en tres, de cuatro en cuatro.",
"ampliacion": "Es lo primero que hay que saber para escribir en un estilo. Un 3/4 pide frases que respiren de tres en tres; un 4/4 marcado admite versos más apretados. Escribir contra el compás se oye enseguida.",
"ejemplo": "El vals criollo va en 3/4; el son cubano, en 4/4."
}
]
},
{
"termino": "clave",
"variantes": ["claves"],
"acepciones": [
{
"categoria": "musica",
"definicion": "El patrón rítmico de cinco golpes sobre el que se organiza la música cubana. Va en 3-2 o en 2-3.",
"ampliacion": "No es un adorno de la percusión: es la rejilla a la que se ajusta todo lo demás, la letra incluida. Una frase que cae «al revés de la clave» suena mal aunque las sílabas cuadren.",
"ejemplo": null
},
{
"categoria": "instrumento",
"definicion": "El instrumento de percusión: dos palos de madera que se golpean uno contra otro.",
"ampliacion": "Es lo que marca el patrón que lleva su nombre. Suena por encima de todo sin necesitar volumen.",
"ejemplo": null
}
]
},
{
"termino": "cinquillo",
"variantes": ["cinquillos"],
"acepciones": [
{
"categoria": "musica",
"definicion": "Célula rítmica cubana de cinco golpes en un compás. Es el pulso del bolero y del danzón.",
"ampliacion": "Condiciona dónde caen los acentos de la letra: los versos se apoyan en él, y por eso un bolero escrito sin tenerlo en cuenta se canta a trompicones.",
"ejemplo": null
}
]
},
{
"termino": "montuno",
"variantes": ["montunos"],
"acepciones": [
{
"categoria": "estructura",
"definicion": "La segunda parte del son, con llamada y respuesta entre el solista y el coro.",
"ampliacion": "Es donde la letra deja de contar y empieza a insistir. Las frases se acortan, se repiten y admiten improvisación encima: escribir un montuno es escribir algo que aguante veinte vueltas.",
"ejemplo": null
}
]
},
{
"termino": "lunfardo",
"variantes": [],
"acepciones": [
{
"categoria": "interpretacion",
"definicion": "El habla popular de Buenos Aires y Montevideo. En el tango no es color local: identifica de dónde viene quien habla.",
"ampliacion": "Usado de fuera suena a disfraz. Si no es tu lengua, escribe el tango en la tuya: la estructura y la mirada son lo que hacen tango a un tango, no el diccionario.",
"ejemplo": "«Dejá el escabio, piba, y escuchá»"
}
]
},
{
"termino": "melisma",
"variantes": ["melismas", "melismático", "melismatico"],
"acepciones": [
{
"categoria": "interpretacion",
"definicion": "Cantar varias notas sobre una sola sílaba, alargándola.",
"ampliacion": "Por eso en un bolero cabe menos texto del que parece: cada sílaba se estira. Un verso apretado obliga a atropellar, y el melisma deja de poder hacerse.",
"ejemplo": null
}
]
},
{
"termino": "requinto",
"variantes": ["requintos"],
"acepciones": [
{
"categoria": "instrumento",
"definicion": "Guitarra pequeña afinada más aguda, que hace la introducción y los adornos entre versos del bolero.",
"ampliacion": "Es lo que deja huecos entre frase y frase, y por eso una letra de bolero no debe llenarlos: los versos van con aire porque el requinto va a hablar en medio.",
"ejemplo": null
}
]
},
{
"termino": "bandoneón",
"variantes": ["bandoneon", "bandoneones"],
"acepciones": [
{
"categoria": "instrumento",
"definicion": "El fuelle del tango. Su respiración marca el rubato, esa forma de adelantar y retrasar el tiempo.",
"ampliacion": "La letra del tango se canta con esa elasticidad, así que admite versos largos sin sonar apretados: el bandoneón espera.",
"ejemplo": null
}
]
},
{
"termino": "rubato",
"variantes": [],
"acepciones": [
{
"categoria": "interpretacion",
"definicion": "Estirar y encoger el tiempo al cantar, sin ceñirse al pulso exacto.",
"ampliacion": "Es lo que permite decir un verso como quien habla. También es lo que hace que la métrica importe menos de lo que parece en algunos estilos, y muchísimo en los que no lo admiten.",
"ejemplo": null
}
]
},
{
"termino": "copla",
"variantes": ["coplas"],
"acepciones": [
{
"categoria": "estructura",
"definicion": "Estrofa de cuatro versos octosílabos con rima en los pares. Es la forma popular por excelencia en español.",
"ampliacion": "Los impares van libres, y ese es su truco: dejan sitio para decir algo concreto sin pelearse con la rima. Media canción hispanoamericana está escrita en coplas.",
"ejemplo": null
}
]
},
{
"termino": "tónica",
"variantes": ["tonica", "tónicas", "tonicas", "sílaba tónica", "silaba tonica"],
"acepciones": [
{
"categoria": "metrica",
"definicion": "La sílaba en la que cae el acento de una palabra o de un verso.",
"ampliacion": "El octosílabo lleva siempre acento en la séptima; el endecasílabo, en la décima. Eso es lo que hace que un verso suene a lo que es, aunque cambien todas las palabras.",
"ejemplo": null
}
]
},
{
"termino": "ley del acento final",
"variantes": ["acento final"],
"acepciones": [
{
"categoria": "metrica",
"definicion": "Un verso se mide hasta su última sílaba tónica más una: el agudo suma una sílaba, el esdrújulo resta una.",
"ampliacion": "Es la primera regla que hay que tener automatizada. Sin ella, media escansión sale mal y no se entiende por qué.",
"ejemplo": "«yo respondo con el son»: siete casillas, ocho sílabas."
}
]
},
{
"termino": "encabalgamiento",
"variantes": ["encabalgamientos", "encabalgado", "encabalgada"],
"acepciones": [
{
"categoria": "metrica",
"definicion": "Cuando la frase no acaba con el verso y continúa en el siguiente.",
"ampliacion": "En una letra cantada es arriesgado: la melodía suele cerrar donde cierra el verso, y una frase que sigue queda partida. Usado a propósito, es lo que da sensación de habla.",
"ejemplo": null
}
]
},
{
"termino": "hemistiquio",
"variantes": ["hemistiquios"],
"acepciones": [
{
"categoria": "metrica",
"definicion": "Cada una de las dos mitades en que una pausa parte un verso largo.",
"ampliacion": "En el alejandrino la pausa cae en el centro y las dos mitades se miden aparte, como si fueran dos versos.",
"ejemplo": null
}
]
},
{
"termino": "voseo",
"variantes": [],
"acepciones": [
{
"categoria": "interpretacion",
"definicion": "Tratar de «vos» en vez de «tú», con su acento al final del verbo: dejá, escuchá, mirá.",
"ampliacion": "Cambia la cuenta del verso, porque convierte en agudas palabras que en el tuteo son llanas. En el tango no es un detalle regional: es la voz.",
"ejemplo": "«y escuchá» hace agudo el verso y le suma una sílaba."
}
]
}
]
}

@ -777,3 +777,31 @@ a.chip:focus-visible {
transform: none;
}
}
/* --- términos del glosario ------------------------------------------------
*
* El subrayado punteado es la convención de «esto tiene definición» desde
* mucho antes que la web, y se distingue de un enlace de verdad: un enlace
* lleva a otra página, esto abre un globo y te deja donde estabas.
*
* Va aquí y no en un componente porque el botón lo escribe el servidor dentro
* del HTML del capítulo, donde el ámbito de Svelte no llega.
*/
.termino {
border: 0;
padding: 0;
background: none;
font: inherit;
color: inherit;
cursor: help;
text-decoration: underline dotted;
text-decoration-color: color-mix(in oklab, var(--c-accent) 60%, transparent);
text-underline-offset: 0.2em;
text-decoration-thickness: 1px;
}
.termino:hover,
.termino:focus-visible {
color: var(--c-accent);
text-decoration-color: var(--c-accent);
}

@ -0,0 +1,234 @@
<script lang="ts">
/**
* Los globos del glosario.
*
* El texto llega del servidor como HTML, con los términos ya envueltos en un
* botón: dentro de `{@html}` no se pueden montar componentes, así que este
* escucha los clics desde fuera y mira qué botón los produjo. Es la razón de
* que el marcado ponga `data-termino` y no la definición entera: el texto de
* cada acepción viaja una vez con la página, no una por aparición.
*
* Es un botón y no un `title` ni un `abbr` porque tiene que poder abrirse con
* el teclado y leerse con lector de pantalla. Un tooltip nativo no se abre
* sin ratón, y en un móvil directamente no existe.
*/
import type { TerminoDeGlosario } from '$lib/server/glosario';
interface Props {
/** Los términos que salen en este texto. */
glosario: TerminoDeGlosario[];
/** Dónde escuchar los clics: el artículo que lleva el texto marcado. */
dentro?: HTMLElement | null;
}
let { glosario, dentro = null }: Props = $props();
let abierto = $state<TerminoDeGlosario | null>(null);
let origen = $state<HTMLElement | null>(null);
let globo = $state<HTMLElement | null>(null);
let posicion = $state({ x: 0, y: 0 });
const porSlug = $derived(new Map(glosario.map((t) => [t.slug, t])));
function cerrar() {
origen?.focus();
abierto = null;
origen = null;
}
/**
* Coloca el globo debajo del término, sin salirse por los lados.
*
* A mano y no con `anchor-positioning`, que todavía no está en todos los
* navegadores: un globo que en Firefox aparece en la esquina superior
* izquierda no es un globo.
*/
function colocar() {
if (!origen || !globo) return;
const boton = origen.getBoundingClientRect();
const suyo = globo.getBoundingClientRect();
const margen = 12;
let x = boton.left + boton.width / 2 - suyo.width / 2;
x = Math.max(margen, Math.min(x, window.innerWidth - suyo.width - margen));
// Debajo si cabe; si no, encima. Tapar el término que se acaba de pulsar
// es lo peor que puede hacer.
const debajo = boton.bottom + 8;
const y =
debajo + suyo.height + margen < window.innerHeight ? debajo : boton.top - suyo.height - 8;
posicion = { x, y: Math.max(margen, y) };
}
$effect(() => {
if (!abierto || !globo) return;
colocar();
});
function alPulsar(evento: MouseEvent) {
const boton = (evento.target as HTMLElement | null)?.closest<HTMLElement>('[data-termino]');
if (!boton) return;
const termino = porSlug.get(boton.dataset.termino ?? '');
if (!termino) return;
evento.preventDefault();
// Pulsar el mismo término otra vez lo cierra.
if (origen === boton) {
cerrar();
return;
}
origen = boton;
abierto = termino;
}
$effect(() => {
const raiz = dentro ?? document;
raiz.addEventListener('click', alPulsar as EventListener);
return () => raiz.removeEventListener('click', alPulsar as EventListener);
});
</script>
<svelte:window
onkeydown={(e) => e.key === 'Escape' && abierto && cerrar()}
onresize={() => abierto && colocar()}
onscroll={() => abierto && colocar()}
/>
{#if abierto}
<!--
El fondo cierra al pulsar fuera. No lleva color: no es un diálogo modal
—el texto de detrás se sigue leyendo, que es de lo que va esto— sino solo
la superficie que recoge el clic.
-->
<button type="button" class="fuera" aria-label="Cerrar la definición" onclick={cerrar}></button>
<div
class="globo"
role="dialog"
aria-label="Definición de {abierto.termino}"
bind:this={globo}
style="left: {posicion.x}px; top: {posicion.y}px"
>
<p class="palabra">{abierto.termino}</p>
<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="definicion">{acepcion.definicion}</p>
{#if acepcion.ejemplo}
<p class="ejemplo">{acepcion.ejemplo}</p>
{/if}
</li>
{/each}
</ol>
</div>
{/if}
<style>
.fuera {
position: fixed;
inset: 0;
z-index: 40;
border: 0;
padding: 0;
background: transparent;
cursor: default;
}
.globo {
position: fixed;
z-index: 41;
width: min(22rem, calc(100vw - 1.5rem));
padding: var(--sp-4);
background: var(--c-bg-raised);
border: 1px solid var(--c-border);
border-radius: var(--radius-tarjeta);
box-shadow: 0 12px 32px oklch(0 0 0 / 0.35);
}
.palabra {
margin: 0 0 var(--sp-3);
padding-bottom: var(--sp-2);
border-bottom: 1px solid var(--c-border);
font-family: var(--font-display);
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 cuando hay más de una. Con una sola acepción, un «1»
* delante 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 / 3;
}
.categoria {
grid-column: 2;
margin: 0 0 var(--sp-1);
font-size: var(--fs-xs);
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--c-text-muted);
}
.acepciones.una .categoria {
grid-column: 1;
}
.definicion {
grid-column: 2;
margin: 0;
font-size: var(--fs-sm);
line-height: 1.5;
}
.acepciones.una .definicion {
grid-column: 1;
}
.ejemplo {
grid-column: 2;
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);
}
.acepciones.una .ejemplo {
grid-column: 1;
}
</style>

@ -66,6 +66,24 @@
{/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.
-->
<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>
</section>
</nav>
<style>

@ -0,0 +1,108 @@
import { describe, expect, it } from 'vitest';
import { marcarConFormas, type Forma } from './glosario';
/**
* El marcado del glosario es la pieza con más casos raros del sitio: plurales,
* tildes, palabras dentro de otras palabras, etiquetas anidadas. Ninguno se ve
* mirando una página —el fallo es que un globo no salga, y nadie echa en falta
* lo que no sabe que existía—, así que se prueban aquí uno a uno.
*/
const FORMAS: Forma[] = [
{ forma: 'sinalefa', slug: 'sinalefa' },
{ forma: 'sinalefas', slug: 'sinalefa' },
{ forma: 'compás', slug: 'compas' },
{ forma: 'compas', slug: 'compas' },
{ forma: 'rima', slug: 'rima' },
{ forma: 'rima asonante', slug: 'rima-asonante' },
{ forma: 'octosílabo', slug: 'octosilabo' },
{ forma: 'octosílabos', slug: 'octosilabo' }
// Sin ordenar a propósito: el orden lo pone la función, no quien la llama.
];
/** Deja el marcado legible: `[texto→slug]`. */
function marcar(html: string): string {
return marcarConFormas(html, FORMAS).replace(
/<button[^>]*data-termino="([^"]+)"[^>]*>([^<]*)<\/button>/g,
'[$2→$1]'
);
}
describe('marcarConFormas', () => {
it('marca el término y lo deja envuelto en un botón', () => {
expect(marcarConFormas('<p>La sinalefa manda.</p>', FORMAS)).toBe(
'<p>La <button type="button" class="termino" data-termino="sinalefa">sinalefa</button> manda.</p>'
);
});
it('marca la primera aparición del texto, no la de la forma más larga', () => {
// Probando forma por forma en orden de longitud, «sinalefas» ganaba y la
// palabra de la primera línea —la que se lee— quedaba sin marcar.
expect(marcar('<p>La sinalefa junta vocales, y las sinalefas se cuentan.</p>')).toBe(
'<p>La [sinalefa→sinalefa] junta vocales, y las sinalefas se cuentan.</p>'
);
});
it('marca una sola vez por término', () => {
expect(marcar('<p>El compás y otro compás.</p>')).toBe(
'<p>El [compás→compas] y otro compás.</p>'
);
});
it('vuelve a marcar en el apartado siguiente', () => {
// Quien llega por un enlace a mitad del texto también encuentra su globo.
expect(marcar('<p>El compás.</p><h2 id="x">Otro</h2><p>Aquí el compás.</p>')).toBe(
'<p>El [compás→compas].</p><h2 id="x">Otro</h2><p>Aquí el [compás→compas].</p>'
);
});
it('no marca dentro de un enlace', () => {
// Un enlace dentro de otro no es HTML válido.
expect(marcar('<p>Un <a href="/x">compás</a> enlazado.</p>')).toBe(
'<p>Un <a href="/x">compás</a> enlazado.</p>'
);
});
it('no marca dentro de un encabezado', () => {
// Ensuciaría el índice del capítulo, que sale de los encabezados.
expect(marcar('<h2 id="y">Un compás</h2><p>y el cuerpo</p>')).toBe(
'<h2 id="y">Un compás</h2><p>y el cuerpo</p>'
);
});
it('no marca dentro de código', () => {
expect(marcar('<p>Escribe <code>compás</code> ahí.</p>')).toBe(
'<p>Escribe <code>compás</code> ahí.</p>'
);
});
it('prefiere el término de dos palabras al de una', () => {
// «rima asonante» y no «rima»: si ganara la corta, el término largo no se
// marcaría nunca.
expect(marcar('<p>La rima asonante.</p>')).toBe('<p>La [rima asonante→rima-asonante].</p>');
});
it('no marca una palabra dentro de otra', () => {
expect(marcar('<p>compasillo, descompasado y compás</p>')).toBe(
'<p>compasillo, descompasado y [compás→compas]</p>'
);
});
it('encuentra la palabra aunque la tilde no coincida', () => {
// En el texto se escribe «compás» y en la semilla puede estar «compas».
expect(marcarConFormas('<p>Un compás.</p>', [{ forma: 'compas', slug: 'compas' }])).toContain(
'>compás</button>'
);
});
it('conserva el texto tal cual lo encontró', () => {
// Mayúsculas incluidas: se marca lo que hay, no la forma del glosario.
expect(marcar('<p>Sinalefa al principio.</p>')).toBe(
'<p>[Sinalefa→sinalefa] al principio.</p>'
);
});
it('sin glosario, devuelve el html intacto', () => {
expect(marcarConFormas('<p>La sinalefa.</p>', [])).toBe('<p>La sinalefa.</p>');
});
});

@ -0,0 +1,220 @@
/**
* El glosario, y el marcado de los términos en el texto.
*
* Lo que se pidió: «cada vez que aparezca un término del glosario en el texto,
* estará marcado para visualizarse con un popup con la explicación».
*
* ## Dónde se marca
*
* Sobre el HTML ya compuesto y no sobre el Markdown. Marcar antes obligaría a
* esquivar la sintaxis —un término dentro de un enlace o de un bloque de
* código— y esas son justo las trampas en las que un reemplazo de texto se
* equivoca en silencio. Sobre el HTML basta con no entrar en las etiquetas ni
* en lo que ya es enlace, botón o código.
*
* ## Una vez por término y por apartado
*
* Marcar las nueve apariciones de «compás» de un capítulo dejaría el texto
* lleno de subrayados y sería ilegible. Se marca la primera de cada apartado:
* quien llega por un enlace a mitad del texto también encuentra la suya.
*
* ## Qué acepción
*
* La primera, salvo que alguien haya confirmado otra en `capitulo_acepcion`.
* El marcado automático propone y una persona confirma; mientras no exista la
* administración no hay quien confirme, así que hoy manda la primera acepción
* —que es la razón de que estén numeradas por orden de importancia—.
*/
import { asc, eq } from 'drizzle-orm';
import { db } from './db';
import { acepcion, termino, terminoVariante } from './db/schema';
export interface AcepcionDeGlosario {
numero: number;
categoria: string;
definicion: string;
ampliacion: string | null;
ejemplo: string | null;
}
export interface TerminoDeGlosario {
slug: string;
termino: string;
acepciones: AcepcionDeGlosario[];
}
/** El glosario entero, ordenado alfabéticamente. */
export async function leerGlosario(): Promise<TerminoDeGlosario[]> {
const [terminos, acepciones] = await Promise.all([
db.select().from(termino).orderBy(asc(termino.termino)),
db.select().from(acepcion).orderBy(asc(acepcion.numero))
]);
return terminos
.map((t) => ({
slug: t.slug,
termino: t.termino,
acepciones: acepciones
.filter((a) => a.terminoId === t.id)
.map((a) => ({
numero: a.numero,
categoria: a.categoria,
definicion: a.definicion,
ampliacion: a.ampliacion,
ejemplo: a.ejemplo
}))
}))
.filter((t) => t.acepciones.length > 0)
.sort((a, b) => a.termino.localeCompare(b.termino, 'es'));
}
/** Una forma buscable y a qué término lleva. */
export interface Forma {
forma: string;
slug: string;
}
let formasEnCache: { formas: Forma[]; hasta: number } | null = null;
const VIDA_MS = 60_000;
async function formas(): Promise<Forma[]> {
if (formasEnCache && Date.now() < formasEnCache.hasta) return formasEnCache.formas;
const filas = await db
.select({ forma: terminoVariante.forma, slug: termino.slug })
.from(terminoVariante)
.innerJoin(termino, eq(termino.id, terminoVariante.terminoId));
// Sin ordenar: el desempate entre «rima» y «rima asonante» lo resuelve el
// marcado, no el orden en que le lleguen.
formasEnCache = { formas: filas, hasta: Date.now() + VIDA_MS };
return filas;
}
/** Escapa lo que en una expresión regular significaría otra cosa. */
function literal(texto: string): string {
return texto.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
/**
* Las tildes no cuentan para buscar.
*
* En el texto se escribe «compás» y en la semilla puede estar «compas»: son la
* misma palabra y una tilde de diferencia no puede decidir si sale el globo.
*/
function sinTildes(texto: string): string {
return texto.normalize('NFD').replace(/[\u0300-\u036f]/g, '');
}
/**
* Dónde aparece una forma dentro de un texto, o `null`.
*
* El límite de palabra de las expresiones regulares no sirve con acentos: en
* JavaScript la «á» no cuenta como carácter de palabra, así que un patrón que
* lo use casaría dentro de otra palabra. Se comprueba a mano que alrededor no
* haya letra ni cifra, y así «compasillo» y «descompasado» no cuentan.
*
* Se prueba con tildes y sin ellas: en el texto se escribe «compás» y en la
* semilla puede estar «compas». Quitar los diacríticos no cambia el número de
* caracteres, así que el índice vale para las dos versiones.
*/
function buscar(texto: string, forma: string): { desde: number; hasta: number } | null {
const patron = (f: string) =>
new RegExp(`(^|[^\\p{L}\\p{N}-])(${literal(f)})(?![\\p{L}\\p{N}-])`, 'iu');
const encaje = patron(forma).exec(texto) ?? patron(sinTildes(forma)).exec(sinTildes(texto));
if (!encaje) return null;
const desde = encaje.index + encaje[1].length;
return { desde, hasta: desde + encaje[2].length };
}
/**
* Marca en el HTML la primera aparición de cada término.
*
* Devuelve el HTML con los términos envueltos en un botón. El botón lleva el
* slug; la definición no viaja aquí dentro —viaja una vez con la página— para
* no repetir el mismo texto en cada aparición.
*/
export async function marcarTerminos(html: string): Promise<string> {
if (!html) return html;
return marcarConFormas(html, await formas());
}
/**
* El marcado en sí, sin base de datos delante.
*
* Separado para poder probarlo: es la pieza con más casos raros de todo esto
—plurales, tildes, palabras dentro de otras, etiquetas anidadas— y son justo
* los que no se ven mirando una página.
*/
export function marcarConFormas(html: string, lista: Forma[]): string {
if (!html || lista.length === 0) return html;
const yaMarcados = new Set<string>();
/*
* Se trocea por etiquetas y solo se toca el texto de en medio. Dentro de un
* `<a>`, un `<code>` o un encabezado no se marca: un enlace dentro de otro
* no es HTML válido, y un encabezado marcado ensucia el índice.
*/
const partes = html.split(/(<[^>]+>)/);
let prohibido = 0;
for (let i = 0; i < partes.length; i++) {
const parte = partes[i];
if (parte.startsWith('<')) {
// Cada apartado vuelve a empezar: quien llega por un enlace a mitad del
// texto también encuentra marcados sus términos.
if (/^<h2/i.test(parte)) yaMarcados.clear();
if (/^<(a|code|pre|h[1-6]|button)\b/i.test(parte)) prohibido++;
else if (/^<\/(a|code|pre|h[1-6]|button)\s*>/i.test(parte))
prohibido = Math.max(0, prohibido - 1);
continue;
}
if (prohibido > 0 || !parte.trim()) continue;
/*
* Primero se recogen todas las coincidencias y después se escribe. Marcando
* sobre la marcha, el botón recién insertado pasaba a formar parte del texto
* y la vuelta siguiente encontraba «rima» dentro de «rima asonante», que
* acababa de marcarse.
*/
const encajes: { desde: number; hasta: number; slug: string }[] = [];
for (const { forma, slug } of lista) {
if (yaMarcados.has(slug)) continue;
const encaje = buscar(parte, forma);
if (encaje) encajes.push({ ...encaje, slug });
}
// El que empieza antes; a igualdad, el más largo. «rima asonante» y no
// «rima», y sin depender del orden en que lleguen las formas.
encajes.sort((a, b) => a.desde - b.desde || b.hasta - a.hasta);
const elegidos: typeof encajes = [];
for (const encaje of encajes) {
if (yaMarcados.has(encaje.slug)) continue;
// Nada de solapes: dentro de un término ya marcado no se marca otro.
if (elegidos.some((e) => encaje.desde < e.hasta && e.desde < encaje.hasta)) continue;
elegidos.push(encaje);
yaMarcados.add(encaje.slug);
}
// De derecha a izquierda, para que los índices sigan valiendo.
let texto = parte;
for (const { desde, hasta, slug } of [...elegidos].reverse()) {
texto =
texto.slice(0, desde) +
`<button type="button" class="termino" data-termino="${slug}">${texto.slice(desde, hasta)}</button>` +
texto.slice(hasta);
}
partes[i] = texto;
}
return partes.join('');
}

@ -30,6 +30,7 @@ import {
versoLinea
} from './db/schema';
import { componer } from './markdown';
import { leerGlosario, marcarTerminos, type TerminoDeGlosario } from './glosario';
import { encabezadosDe, type Encabezado } from '$lib/content/anclas';
/**
@ -168,6 +169,14 @@ export interface CapituloCompleto {
ficha: FichaDeGenero | null;
anterior: EnElIndice | null;
siguiente: EnElIndice | null;
/**
* Solo los términos que salen en este capítulo.
*
* El glosario entero son veintiocho definiciones y en un capítulo se marcan
* cuatro o cinco: mandarlo completo sería repartir veintitrés textos que
* nadie va a abrir.
*/
glosario: TerminoDeGlosario[];
}
export async function obtenerCapitulo(slug: string): Promise<CapituloCompleto | null> {
@ -183,12 +192,23 @@ export async function obtenerCapitulo(slug: string): Promise<CapituloCompleto |
const hermanos = fila.tipo === 'principio' ? principios : fichas;
const i = hermanos.findIndex((h) => h.slug === slug);
const html = await marcarTerminos(componer(fila.cuerpo));
/*
* Solo lo que se ha marcado de verdad. El marcado va antes que esto a
* propósito: así la lista sale de lo que hay en la página y no de una
* suposición sobre lo que debería haber.
*/
const marcados = new Set([...html.matchAll(/data-termino="([^"]+)"/g)].map((m) => m[1]));
const glosario = marcados.size ? (await leerGlosario()).filter((t) => marcados.has(t.slug)) : [];
return {
slug: fila.slug,
titulo: fila.titulo,
tipo: fila.tipo,
resumen: fila.resumen,
html: componer(fila.cuerpo),
html,
glosario,
/*
* Los apartados salen del Markdown en bruto y no del HTML compuesto: es la
* misma función que usa el plugin de anclas, así que el índice y los

@ -16,6 +16,7 @@
import { resolve } from '$app/paths';
import Meta from '$lib/components/Meta.svelte';
import Migas from '$lib/components/Migas.svelte';
import GlobosDeGlosario from '$lib/components/GlobosDeGlosario.svelte';
let { data } = $props();
@ -23,6 +24,9 @@
const ficha = $derived(capitulo.ficha);
const esFicha = $derived(capitulo.tipo === 'ficha');
/** El artículo con el texto marcado, para escuchar ahí los clics. */
let texto = $state<HTMLElement | null>(null);
/*
* Cada lista sale del script y no del template. Con `{#if ficha}` alrededor
* de veinte bloques, cualquier cambio dentro obliga a razonar otra vez si el
@ -138,6 +142,12 @@
<Migas {migas} />
<!--
Los términos del glosario que salen en este capítulo. El texto llega marcado
del servidor; esto solo pone el globo cuando se pulsa uno.
-->
<GlobosDeGlosario glosario={capitulo.glosario} dentro={texto} />
<header class="portada" class:de-genero={esFicha}>
{#if esFicha}
<p class="rotulo">Género</p>
@ -192,7 +202,7 @@
autor; cuando exista la administración habrá que sanearlo, y el sitio para
hacerlo es `$lib/server/markdown`, en un punto y no en cada plantilla.
-->
<article class="texto">
<article class="texto" bind:this={texto}>
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
{@html capitulo.html}
</article>

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

@ -0,0 +1,189 @@
<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.