La ficha de género pierde la barra lateral y gana su franja de datos

Los datos del género —compás, metro, tempo, rima— pasan de una lista de
definiciones apretada en una columna estrecha a la derecha a una franja a
todo lo ancho encima del texto. El compás y la métrica de un género se
leen juntos o no se leen, y en la lateral quedaban en letra pequeña con el
texto escorado.

La estructura, la temática y los rasgos bajan debajo de la prosa como
bloques a lo ancho: la temática en rejilla —son etiquetas disfrazadas de
frase— y los rasgos en lista, que son frases de verdad. Es el sitio donde
entrarán los instrumentos, los letristas, las obras de referencia y el
linaje cuando tengan datos.

Y el volcado de medios: 55 archivos a la tabla `medio` con su huella, su
peso y su duración MEDIDA, no escrita. De ahí salen dos cosas que no se
veían: el mismo audio está copiado bajo doce nombres distintos, y las
duraciones del catálogo dicen 252 segundos donde el archivo dura 6.

La clave de un medio lleva su almacén delante, porque `singles/dorina.mp3`
existe en audio y en muestras y sin eso serían la misma fila. Es además la
ruta con la que se servirán desde m.senzapaura.es/archives.

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

@ -30,16 +30,45 @@ test.describe('Taller de letras', () => {
await expect(page.getByRole('heading', { level: 1 })).toHaveText('La imagen concreta');
});
test('una ficha de estilo trae sus datos de creación', async ({ page }) => {
test('una ficha de género trae sus datos de creación', async ({ page }) => {
await page.goto('/taller/bolero');
await expect(page.getByRole('heading', { level: 1 })).toHaveText('Bolero');
await expect(page.getByRole('heading', { level: 2, name: 'De un vistazo' })).toBeVisible();
await expect(page.getByText('Santiago de Cuba, 1883')).toBeVisible();
/*
* Los datos comparables van en una franja a lo ancho, no en una barra
* lateral: el compás y la métrica de un género se leen juntos o no se leen.
* Antes era una lista de definiciones en una columna estrecha a la derecha,
* con los datos en letra pequeña y el texto escorado.
*/
const franja = page.locator('.datos .dato');
await expect(franja).toHaveCount(4);
await expect(franja.filter({ hasText: 'Compás' })).toContainText('2/4');
await expect(franja.filter({ hasText: 'Metro' })).toContainText('Octosílabo');
await expect(page.locator('aside.lateral')).toHaveCount(0);
await expect(page.getByRole('heading', { level: 2, name: 'De qué se habla' })).toBeVisible();
await expect(
page.getByRole('heading', { level: 2, name: 'Rasgos de escritura' })
).toBeVisible();
await expect(
page.getByRole('heading', { level: 2, name: 'Estructura de las canciones' })
).toBeVisible();
});
test('la franja de datos ocupa el ancho, y el texto su medida de lectura', async ({ page }) => {
// 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: 1280, height: 900 });
await page.goto('/taller/bolero');
const datos = (await page.locator('.datos').boundingBox())!;
const texto = (await page.locator('.texto').boundingBox())!;
expect(datos.width).toBeGreaterThan(texto.width * 1.4);
expect(Math.abs(datos.x - texto.x)).toBeLessThan(2);
});
test('la portada del taller también lleva el índice al lado', async ({ page }) => {

@ -24,7 +24,8 @@
"prebuild": "node scripts/medios.mjs",
"db:guardas": "node --env-file=.env scripts/db/comprobar-guardas.mjs",
"db:probar": "node --env-file=.env scripts/db/probar-migracion.mjs",
"db:volcar": "node --experimental-strip-types --import ./scripts/db/resolver-lib.mjs --env-file=.env scripts/db/volcar.mjs"
"db:volcar": "node --experimental-strip-types --import ./scripts/db/resolver-lib.mjs --env-file=.env scripts/db/volcar.mjs",
"db:medios": "node --env-file=.env scripts/db/volcar-medios.mjs"
},
"devDependencies": {
"@eslint/js": "^10.0.1",

@ -0,0 +1,308 @@
/**
* Vuelca los archivos —audio, muestras y portadas— a la tabla `medio` y los
* enlaza con quien los usa.
*
* npm run db:medios
* npm run db:medios -- --seco
*
* Va aparte del volcado del catálogo porque no depende de él en un sentido
* importante: **estos datos no salen de los archivos de contenido, salen de los
* archivos de verdad**. El tamaño, la huella y la duración se miden; nadie los
* escribe. Hasta ahora una canción decía durar «4:12» porque alguien lo tecleó,
* y si el mp3 duraba otra cosa nadie se enteraba.
*
* Lo que no encuentra, lo dice. Un audio referenciado que no existe es
* exactamente lo que hoy no se ve hasta que alguien le da al play.
*/
import { createHash } from 'node:crypto';
import { execFileSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import pg from 'pg';
const SECO = process.argv.includes('--seco');
/** Dónde vive cada clase de archivo y si se puede pedir por URL. */
const ALMACENES = [
{ clase: 'audio', raiz: 'media/audio', publico: false, carpeta: 'audio', prefijo: '' },
{
clase: 'muestra',
raiz: 'static/muestras',
publico: true,
carpeta: 'muestras',
prefijo: '/muestras/'
},
{ clase: 'imagen', raiz: 'static/images', publico: true, carpeta: 'images', prefijo: '/images/' }
];
const MIMES = {
'.wav': 'audio/wav',
'.mp3': 'audio/mpeg',
'.m4a': 'audio/mp4',
'.flac': 'audio/flac',
'.svg': 'image/svg+xml',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.webp': 'image/webp',
'.avif': 'image/avif'
};
function recorrer(dir) {
if (!fs.existsSync(dir)) return [];
return fs.readdirSync(dir, { withFileTypes: true }).flatMap((e) => {
const completa = path.join(dir, e.name);
return e.isDirectory() ? recorrer(completa) : [completa];
});
}
/**
* Duración en segundos, medida con ffprobe.
*
* Medida y no escrita: es el dato que más fácil se queda viejo. Si ffprobe no
* está, se deja en nulo y se avisa; no se inventa.
*/
function duracionDe(ruta) {
try {
const salida = execFileSync(
'ffprobe',
['-v', 'error', '-show_entries', 'format=duration', '-of', 'csv=p=0', ruta],
{ encoding: 'utf8', timeout: 20_000 }
);
const segundos = Number.parseFloat(salida.trim());
return Number.isFinite(segundos) ? Math.round(segundos) : null;
} catch {
return null;
}
}
/**
* Medidas de una imagen.
*
* En los SVG salen del `viewBox` o de los atributos, que es donde están; para
* el resto haría falta descodificar, y hoy todas las portadas son SVG.
*/
function medidasDe(ruta) {
if (path.extname(ruta).toLowerCase() !== '.svg') return { ancho: null, alto: null };
const texto = fs.readFileSync(ruta, 'utf8').slice(0, 2000);
const caja = /viewBox\s*=\s*["']\s*[\d.-]+\s+[\d.-]+\s+([\d.]+)\s+([\d.]+)/.exec(texto);
if (caja) return { ancho: Math.round(+caja[1]), alto: Math.round(+caja[2]) };
const ancho = /\bwidth\s*=\s*["'](\d+)/.exec(texto);
const alto = /\bheight\s*=\s*["'](\d+)/.exec(texto);
return { ancho: ancho ? +ancho[1] : null, alto: alto ? +alto[1] : null };
}
/* -------------------------------------------------------------------------
* Recoger
* ---------------------------------------------------------------------- */
const encontrados = [];
const avisos = [];
for (const almacen of ALMACENES) {
for (const ruta of recorrer(almacen.raiz)) {
const extension = path.extname(ruta).toLowerCase();
const mime = MIMES[extension];
if (!mime) {
avisos.push(`no sé qué es ${ruta}, lo dejo fuera`);
continue;
}
const contenido = fs.readFileSync(ruta);
const relativa = path.relative(almacen.raiz, ruta).split(path.sep).join('/');
const esImagen = mime.startsWith('image/');
encontrados.push({
clase: almacen.clase,
// La clave lleva el almacen delante: `singles/dorina.mp3` existe en audio
// y en muestras, y sin el prefijo serian la misma fila. Ademas es la ruta
// con la que se serviran desde m.senzapaura.es/archives.
clave: almacen.carpeta + '/' + relativa,
url: almacen.prefijo + relativa,
publico: almacen.publico,
mime,
bytes: contenido.length,
sha256: createHash('sha256').update(contenido).digest('hex'),
duracionS: esImagen ? null : duracionDe(ruta),
...(esImagen ? medidasDe(ruta) : { ancho: null, alto: null })
});
}
}
console.log(`Encontrados ${encontrados.length} archivos\n`);
for (const almacen of ALMACENES) {
const suyos = encontrados.filter((m) => m.clase === almacen.clase);
const pesan = suyos.reduce((n, m) => n + m.bytes, 0);
console.log(
` ${almacen.clase.padEnd(9)} ${String(suyos.length).padStart(3)} archivos · ${(pesan / 1048576).toFixed(1)} MB`
);
}
const sinDuracion = encontrados.filter((m) => m.clase !== 'imagen' && m.duracionS === null);
if (sinDuracion.length) {
console.log(`\n ${sinDuracion.length} sin duración medida (¿falta ffprobe?)`);
}
/* Archivos repetidos: la huella los delata aunque tengan otro nombre. */
const porHuella = new Map();
for (const m of encontrados) {
porHuella.set(m.sha256, [...(porHuella.get(m.sha256) ?? []), m.clave]);
}
for (const [, claves] of porHuella) {
if (claves.length > 1) avisos.push(`mismo contenido en: ${claves.join(', ')}`);
}
if (SECO) {
for (const aviso of avisos) console.log(` aviso: ${aviso}`);
console.log('\n(en seco: no se ha tocado la base)');
process.exit(0);
}
/* -------------------------------------------------------------------------
* Escribir y enlazar
* ---------------------------------------------------------------------- */
const c = new pg.Client({ connectionString: process.env.DATABASE_URL, query_timeout: 60_000 });
await c.connect();
await c.query('BEGIN');
try {
/*
* Se sueltan los enlaces antes de vaciar la tabla. Las columnas son
* `ON DELETE set null`, así que Postgres lo haría solo, pero decirlo aquí
* deja claro que el volcado no pierde nada: lo que se rehace es la fila del
* archivo, no la canción.
*/
await c.query('UPDATE cancion SET audio_id = NULL, muestra_id = NULL, portada_id = NULL');
await c.query('UPDATE album SET portada_id = NULL');
await c.query('DELETE FROM medio');
const idPorClave = new Map();
for (const m of encontrados) {
const id = crypto.randomUUID();
await c.query(
`INSERT INTO medio (id, clase, clave, publico, mime, bytes, duracion_s, ancho, alto, sha256)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)`,
[id, m.clase, m.clave, m.publico, m.mime, m.bytes, m.duracionS, m.ancho, m.alto, m.sha256]
);
idPorClave.set(`${m.clase}:${m.clave}`, id);
if (m.clase === 'imagen') idPorClave.set(`url:${m.url}`, id);
}
console.log(`\n ${encontrados.length} filas en medio`);
/* --- Enlazar con lo que los usa ---------------------------------------
*
* Se releen los archivos de contenido para saber qué apunta a qué. El
* volcado del catálogo no guardó esas rutas: guardarlas como texto habría
* sido repetir el problema que esta tabla viene a resolver.
*/
const enlazados = { audio: 0, muestra: 0, portadaCancion: 0, portadaAlbum: 0 };
const huerfanos = [];
const canciones = fs
.readdirSync('src/content/canciones')
.filter((n) => n.endsWith('.md'))
.map((n) => ({
slug: n.replace(/\.md$/, ''),
texto: fs.readFileSync(path.join('src/content/canciones', n), 'utf8')
}));
for (const { slug, texto } of canciones) {
const audio = /^audio:\s*(.+)$/m
.exec(texto)?.[1]
.trim()
.replace(/^['"]|['"]$/g, '');
const portada = /^portada:\s*(.+)$/m
.exec(texto)?.[1]
.trim()
.replace(/^['"]|['"]$/g, '');
if (audio) {
const id = idPorClave.get(`audio:audio/${audio}`);
if (id) {
await c.query('UPDATE cancion SET audio_id = $1 WHERE slug = $2', [id, slug]);
enlazados.audio++;
} else {
huerfanos.push(`${slug}: el audio ${audio} no existe`);
}
/*
* La muestra se deduce del nombre del audio: es la convención que ya usa
* `scripts/medios.mjs` al generarlas. Si algún día deja de serlo, esto
* se queda sin encontrar nada y lo dirá, que es lo que se quiere.
*/
const muestra = audio.replace(/\.[^.]+$/, '.mp3');
const idMuestra = idPorClave.get(`muestra:muestras/${muestra}`);
if (idMuestra) {
await c.query('UPDATE cancion SET muestra_id = $1 WHERE slug = $2', [idMuestra, slug]);
enlazados.muestra++;
}
}
if (portada) {
const id = idPorClave.get(`url:${portada}`);
if (id) {
await c.query('UPDATE cancion SET portada_id = $1 WHERE slug = $2', [id, slug]);
enlazados.portadaCancion++;
} else {
huerfanos.push(`${slug}: la portada ${portada} no existe`);
}
}
}
for (const nombre of fs.readdirSync('src/content/albumes').filter((n) => n.endsWith('.md'))) {
const slug = nombre.replace(/\.md$/, '');
const texto = fs.readFileSync(path.join('src/content/albumes', nombre), 'utf8');
const portada = /^portada:\s*(.+)$/m
.exec(texto)?.[1]
.trim()
.replace(/^['"]|['"]$/g, '');
if (!portada) continue;
const id = idPorClave.get(`url:${portada}`);
if (id) {
await c.query('UPDATE album SET portada_id = $1 WHERE slug = $2', [id, slug]);
enlazados.portadaAlbum++;
} else {
huerfanos.push(`${slug}: la portada ${portada} no existe`);
}
}
console.log(
` enlazados: ${enlazados.audio} audios, ${enlazados.muestra} muestras, ` +
`${enlazados.portadaCancion} portadas de tema, ${enlazados.portadaAlbum} de disco`
);
/* --- Y la duración medida contra la escrita a mano --------------------- */
const { rows: desajustes } = await c.query(`
SELECT c.slug, c.duracion_s AS escrita, m.duracion_s AS medida
FROM cancion c JOIN medio m ON m.id = c.audio_id
WHERE m.duracion_s IS NOT NULL AND abs(c.duracion_s - m.duracion_s) > 2
ORDER BY abs(c.duracion_s - m.duracion_s) DESC`);
await c.query('COMMIT');
for (const aviso of avisos) console.log(` aviso: ${aviso}`);
for (const h of huerfanos) console.log(` falta: ${h}`);
if (desajustes.length) {
console.log(`\n La duración escrita a mano no coincide con la del archivo:`);
for (const d of desajustes) {
console.log(` ${d.slug.padEnd(24)} dice ${d.escrita} s, dura ${d.medida} s`);
}
} else {
console.log('\n Las duraciones escritas coinciden con las de los archivos.');
}
console.log('\nMedios volcados.');
} catch (fallo) {
await c.query('ROLLBACK').catch(() => {});
console.error('\nFalló, no se ha escrito nada:\n ' + fallo.message);
await c.end();
process.exit(1);
}
await c.end();

@ -1,4 +1,17 @@
<script lang="ts">
/**
* Un capítulo del taller: un principio del curso o la ficha de un género.
*
* La ficha ya no lleva barra lateral. Los datos del género —compás, metro,
* tempo, rima— van arriba y a todo lo ancho, en una franja comparable de un
* vistazo, y la prosa debajo con su medida de lectura. Antes eran una lista
* de definiciones apretada en una columna estrecha a la derecha: los datos
* quedaban en letra pequeña y el texto, escorado.
*
* Cuando el género tenga sus propias tablas —instrumentos, letristas, obras
* de referencia, linaje—, cada uno entra como bloque a lo ancho debajo de la
* prosa, en el mismo sitio donde hoy están la temática y los rasgos.
*/
import { resolve } from '$app/paths';
import Meta from '$lib/components/Meta.svelte';
import Migas from '$lib/components/Migas.svelte';
@ -8,22 +21,27 @@
const capitulo = $derived(data.capitulo);
const ficha = $derived(capitulo.ficha);
const Cuerpo = $derived(data.Cuerpo);
/** Las filas de la ficha que tienen algo que decir. */
const datos = $derived(
const esFicha = $derived(capitulo.tipo === 'ficha');
/**
* La franja de datos: solo lo corto y comparable.
*
* `estructura` se queda fuera a propósito —es una frase entera, «A · B ·
* interludio · A», y en una celda de dato queda apretada—; va debajo, con su
* sitio.
*/
const franja = $derived(
[
['Origen', ficha.origen],
['Compás', ficha.compas],
['Metro', ficha.metrica],
['Tempo', ficha.tempo],
['Métrica', ficha.metrica],
['Estructura', ficha.estructura],
['Rima', ficha.rima]
].filter((fila): fila is [string, string] => Boolean(fila[1]))
);
const migas = $derived([
{ texto: 'Taller de letras', href: resolve('/taller') },
{ texto: capitulo.tipo === 'ficha' ? 'Fichas de creación' : 'Los principios' }
{ texto: esFicha ? 'Fichas de creación' : 'Los principios' }
]);
</script>
@ -31,58 +49,69 @@
<Migas {migas} />
<header class="intro">
<header class="portada" class:de-genero={esFicha}>
{#if esFicha}
<p class="rotulo">Género</p>
{/if}
<h1>{capitulo.titulo}</h1>
{#if capitulo.resumen}
<p class="entradilla">{capitulo.resumen}</p>
{/if}
{#if esFicha && ficha.origen}
<p class="origen muted">{ficha.origen}</p>
{/if}
</header>
<div class="cuerpo">
<article class="texto">
{#if Cuerpo}
<Cuerpo />
{/if}
</article>
{#if capitulo.tipo === 'ficha'}
<aside class="lateral">
{#if datos.length > 0}
<section>
<h2>De un vistazo</h2>
<dl>
{#each datos as [etiqueta, valor] (etiqueta)}
<dt>{etiqueta}</dt>
<dd>{valor}</dd>
{/each}
</dl>
</section>
{/if}
{#if ficha.temas.length > 0}
<section>
<h2>De qué se habla</h2>
<ul>
{#each ficha.temas as tema (tema)}
<li>{tema}</li>
{/each}
</ul>
</section>
{/if}
{#if ficha.rasgos.length > 0}
<section>
<h2>Rasgos de escritura</h2>
<ul>
{#each ficha.rasgos as rasgo (rasgo)}
<li>{rasgo}</li>
{/each}
</ul>
</section>
{/if}
</aside>
{#if esFicha && franja.length > 0}
<div class="datos">
{#each franja as [etiqueta, valor] (etiqueta)}
<div class="dato">
<p class="rotulo">{etiqueta}</p>
<p class="valor">{valor}</p>
</div>
{/each}
</div>
{/if}
<article class="texto">
{#if Cuerpo}
<Cuerpo />
{/if}
</article>
{#if esFicha}
{#if ficha.estructura}
<section class="bloque">
<h2>Estructura de las canciones</h2>
<p class="estructura">{ficha.estructura}</p>
</section>
{/if}
</div>
{#if ficha.temas.length > 0}
<section class="bloque">
<h2>De qué se habla</h2>
<ul class="temas">
{#each ficha.temas as tema (tema)}
<li>{tema}</li>
{/each}
</ul>
</section>
{/if}
{#if ficha.rasgos.length > 0}
<section class="bloque">
<h2>Rasgos de escritura</h2>
<ul class="rasgos">
{#each ficha.rasgos as rasgo (rasgo)}
<li>{rasgo}</li>
{/each}
</ul>
</section>
{/if}
{/if}
{#if data.anterior || data.siguiente}
<nav class="vecinos" aria-label="Otros capítulos">
@ -105,11 +134,18 @@
{/if}
<style>
.intro {
/* --- Cabecera ------------------------------------------------------- */
.portada {
max-width: 62ch;
margin-bottom: var(--sp-5);
}
.portada.de-genero {
padding-bottom: var(--sp-5);
border-bottom: 1px solid var(--c-border);
}
h1 {
font-size: var(--fs-3xl);
}
@ -119,22 +155,60 @@
color: var(--c-text-muted);
}
.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);
}
.origen {
margin-top: var(--sp-3);
font-size: var(--fs-sm);
text-transform: uppercase;
letter-spacing: 0.1em;
}
/* --- La franja de datos ---------------------------------------------- */
/*
* El texto y —solo en las fichas— los datos del estilo. El índice del taller
* es la tercera columna, y la pone el layout de la sección.
* Una celda por dato, todas del mismo alto y con la misma sangría. Es lo que
* permite compararlos de un vistazo, que es para lo que están: el compás y
* la métrica de un género se leen juntos o no se leen.
*/
.cuerpo {
.datos {
display: grid;
grid-template-columns: minmax(0, 1fr) 16rem;
gap: var(--sp-6);
align-items: start;
padding-bottom: var(--sp-6);
grid-template-columns: repeat(auto-fit, minmax(11rem, 1fr));
gap: 1px;
margin-bottom: var(--sp-7);
background: var(--c-border);
border: 1px solid var(--c-border);
border-radius: var(--radius-tarjeta);
overflow: hidden;
}
/* Prosa para leer: medida corta y aire entre párrafos. */
.texto {
max-width: 64ch;
.dato {
background: var(--c-bg-raised);
padding: var(--sp-4) var(--sp-4) var(--sp-5);
}
.dato .rotulo {
margin-bottom: var(--sp-1);
}
.valor {
margin: 0;
font-family: var(--font-display);
font-size: var(--fs-lg);
line-height: 1.3;
}
/* --- La prosa --------------------------------------------------------- */
.texto {
max-width: 62ch;
}
.texto :global(h2) {
@ -144,10 +218,10 @@
.texto :global(blockquote) {
margin: var(--sp-5) 0;
padding-left: var(--sp-4);
padding: var(--sp-3) var(--sp-4);
border-left: 2px solid var(--c-accent);
font-family: var(--font-versos);
color: var(--c-text-muted);
background: var(--c-bg-raised);
font-size: var(--fs-sm);
}
.texto :global(ul) {
@ -158,82 +232,85 @@
margin-bottom: var(--sp-2);
}
.lateral {
display: flex;
flex-direction: column;
gap: var(--sp-5);
position: sticky;
top: calc(var(--altura-cabecera) + var(--sp-4));
font-size: var(--fs-sm);
/* --- Los bloques de debajo -------------------------------------------- */
.bloque {
margin-top: var(--sp-7);
}
.lateral h2 {
font-size: var(--fs-lg);
margin-bottom: var(--sp-2);
.bloque h2 {
font-size: var(--fs-xl);
padding-bottom: var(--sp-2);
margin-bottom: var(--sp-4);
border-bottom: 1px solid var(--c-border);
}
.lateral dl {
margin: 0;
.estructura {
font-family: var(--font-display);
font-size: var(--fs-lg);
max-width: 62ch;
}
.lateral dt {
font-size: var(--fs-xs);
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--c-text-muted);
margin-top: var(--sp-3);
/*
* La temática son etiquetas disfrazadas de frase —«El amor no
* correspondido»— y se leen en rejilla; los rasgos son frases de verdad y se
* leen en lista. Por eso no comparten forma.
*/
.temas {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(16rem, 1fr));
gap: var(--sp-2);
list-style: none;
margin: 0;
padding: 0;
}
.lateral dt:first-of-type {
margin-top: 0;
.temas li {
padding: var(--sp-3) var(--sp-4);
background: var(--c-bg-raised);
border: 1px solid var(--c-border);
border-radius: var(--radius-tarjeta);
font-family: var(--font-display);
}
.lateral dd {
.rasgos {
display: grid;
gap: var(--sp-3);
list-style: none;
margin: 0;
padding: 0;
max-width: 68ch;
}
.lateral ul {
margin: 0;
.rasgos li {
padding-left: var(--sp-4);
border-left: 2px solid var(--c-border);
color: var(--c-text-muted);
}
.lateral li {
margin-bottom: var(--sp-2);
}
/* --- Vecinos ----------------------------------------------------------- */
.vecinos {
display: grid;
grid-template-columns: 1fr 1fr;
display: flex;
justify-content: space-between;
gap: var(--sp-4);
padding-block: var(--sp-5) var(--sp-7);
margin-top: var(--sp-8);
padding-top: var(--sp-5);
border-top: 1px solid var(--c-border);
}
.vecino {
display: flex;
flex-direction: column;
display: grid;
gap: var(--sp-1);
font-size: var(--fs-sm);
text-decoration: none;
}
.vecino:hover span:last-child {
text-decoration: underline;
color: var(--c-accent);
}
.vecino.derecha {
text-align: right;
}
/* Sin sitio para las dos columnas, la ficha del estilo baja bajo el texto. */
@media (max-width: 72rem) {
.cuerpo {
grid-template-columns: minmax(0, 1fr);
}
.lateral {
position: static;
}
}
</style>

Loading…
Cancel
Save

Powered by TurnKey Linux.