Audit and normalize visual system

codex/redisenio-dominio-musical
dev 4 weeks ago
parent a9524fe411
commit 65da577c05

@ -0,0 +1,278 @@
# Auditoría y normalización de estilos
Fecha de corte: 13 de septiembre de 2026.
## Objetivo y criterio de aceptación
La aplicación debe comportarse como un único sistema visual, no como una colección de páginas con CSS independiente. Un lote de normalización solo se considera terminado cuando:
- reutiliza los tokens y patrones compartidos;
- no introduce marcos decorativos ni nuevas excepciones locales;
- conserva la jerarquía tipográfica y el significado de cada control;
- funciona en claro y oscuro;
- se comprueba a 390, 768, 1024 y 1440 px;
- supera `npm run audit:styles -- --strict` y las comprobaciones de Svelte correspondientes.
## Línea base medida
La medición procede de `scripts/auditar-estilos.mjs` sobre los archivos CSS y Svelte de `src`. La auditoría queda disponible mediante `npm run audit:styles`; con `--strict` falla si encuentra variables CSS sin declarar o pesos tipográficos que no existen en las fuentes cargadas.
| Indicador | Línea base |
| ------------------------------- | ---------------------------------: |
| Archivos CSS/Svelte analizados | 132 |
| Archivos Svelte con estilos | 118 |
| Bloques `<style>` en Svelte | 120 |
| Declaraciones `font-size` | 518 |
| Tamaños tipográficos literales | 102 |
| Declaraciones de espaciado | 1430 |
| Espaciados literales relevantes | 263 |
| Declaraciones de borde | 552 |
| Declaraciones `border-radius` | 169 |
| Radios literales | 68 |
| Colores literales | 108 |
| Media queries | 97 |
| Breakpoints distintos | 26 |
| Usos de `!important` | 3 |
| Archivos de fuente | 12 archivos / 4 binarios distintos |
La mayor deuda está en la fragmentación, no en un único selector: 26 breakpoints, reglas repetidas entre rutas y componentes, y dos vocabularios visuales parcialmente incompatibles entre la web/panel y Estudio. En la comprobación visual inicial de `/musica` se detectaron nueve tamaños computados y 24 controles por debajo de 44 px. A 390 y 768 px, los filtros ocupaban demasiado espacio vertical antes del contenido.
### Distribución por ámbito
| Ámbito | Archivos | Tamaños | Tamaños literales | Bordes | Media queries |
| ------------- | -------: | ------: | ----------------: | -----: | ------------: |
| Sitio público | 33 | 162 | 14 | 137 | 34 |
| Componentes | 56 | 158 | 15 | 206 | 38 |
| Panel | 19 | 113 | 2 | 105 | 10 |
| Estudio | 17 | 70 | 70 | 70 | 9 |
| Global | 7 | 15 | 1 | 34 | 6 |
Estudio requiere una migración propia: sus 70 tamaños y 27 radios son todavía literales. No se deben sustituir mecánicamente porque algunos nombres históricos tienen un significado distinto al de la web pública.
## P0 corregidos
La primera pasada elimina errores reales antes de abordar el rediseño:
- 5 referencias a variables CSS inexistentes corregidas; la auditoría estricta devuelve ahora 0 variables sin declarar.
- 25 usos de pesos 450, 650 y 700 sustituidos por pesos disponibles; quedan 0 pesos numéricos no soportados.
- Pesos compartidos definidos como 400, 500 y 600.
- Tokens comunes de interacción, geometría, movimiento y elevación centralizados en `src/tokens.css` y cargados por la web y Estudio.
- Colisiones de significado retiradas: las curvas de movimiento usan `--ease-standard` y `--ease-emphasized`; el degradado dorado usa `--gradiente-oro` y ya no comparte nombre con un color plano de Estudio.
- Eliminado el marco exterior añadido a la cabecera del álbum: la portada y la información vuelven a agruparse por alineación y espacio, sin caja, borde, radio ni fondo decorativo.
El resultado actual de `npm run audit:styles -- --strict` es correcto: 0 variables no declaradas y 0 pesos no disponibles. Esto es un umbral de integridad, no el final de la normalización.
## Política de bordes
Los bordes se usan para explicar una relación funcional. No se usan para “decorar” o para compensar una jerarquía débil.
### Permitidos
- Límite de un control: botones, inputs, selects, textareas y controles segmentados.
- Estado de foco visible y accesible. Se prioriza `outline` para no alterar la geometría.
- Separador único entre filas contiguas de tablas o listas de datos.
- Delimitación necesaria para leer datos, estados o columnas cuando el espacio por sí solo no sea suficiente.
### Prohibidos por defecto
- Marcos exteriores alrededor de cabeceras de álbum, canción, artista o página.
- Marcos alrededor de heroes.
- Cajas para agrupar títulos, introducciones, explicaciones o secciones completas.
- Bordes añadidos solo porque un bloque se llama “card”.
- Doble separación: borde de contenedor más bordes de todas sus filas.
- Combinaciones de borde, radio y fondo elevado sin una función interactiva o de datos demostrable.
La agrupación de contenido se resuelve primero con alineación, proximidad, espacio, escala tipográfica y, solo cuando sea imprescindible, un cambio de superficie sin borde. Un nuevo borde requiere justificar cuál de las cuatro categorías permitidas resuelve.
## Contrato tipográfico
Solo se contemplan tres familias y cada una tiene una función inequívoca:
| Rol | Familia | Uso |
| ------------------ | ------------------------- | ----------------------------------------------------------------------- |
| Display/editorial | Newsreader | H1, H2 y grandes titulares con carácter editorial |
| Interfaz y lectura | Archivo | Navegación, controles, tablas, metadatos, cuerpo y cifras de valoración |
| Técnica | Monoespaciada del sistema | Código, valores técnicos o datos donde el ancho fijo sea útil |
Reglas:
- Una cifra o puntuación pertenece a la interfaz y usa Archivo, no Newsreader.
- Los nombres de canciones y álbumes usan Archivo dentro de tablas y listados; su jerarquía se expresa con tamaño y peso, no cambiando de familia.
- Los pesos válidos son 400, 500 y 600. No se introducen 450, 650 ni 700.
- El texto de interfaz no baja de 12 px. El texto táctil o accionable parte de 16 px salvo una excepción accesible y documentada.
- Los encabezados usan interlineado 1.1, la interfaz 1.35 y la lectura extensa 1.65.
- No se declara `font-family`, `font-size` o `font-weight` localmente si ya existe un rol compartido que expresa la misma intención.
## Escalas y tokens vigentes
### Tipografía
| Token | Valor |
| ------------------------ | --------------------: |
| `--fs-xs` | 12 px |
| `--fs-sm` | 14 px |
| `--fs-base` | 16 px |
| `--fs-lg` | 19 px |
| `--fs-xl` | 24 px |
| `--fs-2xl` | 34 px |
| `--fs-3xl` | 48 px |
| `--fs-4xl` | fluido, de 52 a 96 px |
| `--font-weight-regular` | 400 |
| `--font-weight-medium` | 500 |
| `--font-weight-semibold` | 600 |
| `--leading-heading` | 1.1 |
| `--leading-ui` | 1.35 |
| `--leading-body` | 1.65 |
### Espacio, controles y geometría
- Espaciado: `--sp-1` a `--sp-8` = 4, 8, 12, 16, 24, 32, 48 y 80 px.
- Alturas de control: compacta 36 px, normal 44 px y táctil 48 px.
- Iconos: 20, 24 y 28 px.
- Radios: detalle 4 px, botón 6 px, tarjeta/superficie 8 px, overlay 12 px y circular 999 px.
- Una altura compacta solo es válida en escritorio y para una acción secundaria no táctil. La altura normal de 44 px es el mínimo general.
### Movimiento y elevación
- Duraciones: 150 ms, 200 ms y 400 ms.
- Curva estándar: `--ease-standard`.
- Curva con salida marcada: `--ease-emphasized`.
- Sombras: `--shadow-popover` y `--shadow-modal`.
- Overlay y texto sobre imagen: `--c-overlay` y `--c-text-on-media`.
No se añaden tiempos, curvas, radios ni sombras literales si uno de estos tokens cubre el mismo papel.
## Fases de migración
### Fase 0 — Integridad del contrato
Estado: completada en su alcance inicial.
- Mantener en cero variables no declaradas y pesos inexistentes.
- Retirar colisiones de nombres entre web, panel y Estudio.
- Documentar y hacer cumplir la política de bordes.
- Incorporar la auditoría estricta a la verificación habitual.
### Fase 1 — Primitivas compartidas
Estado: núcleo compartido aplicado; quedan consolidaciones secundarias.
- Normalizar botones, iconos accionables, inputs, chips y estados de foco.
- Llevar todos los objetivos táctiles principales a un mínimo de 44 × 44 px.
- Unificar las barras de búsqueda/vista de Música y Blog.
- Unificar el armazón de modales de acceso, contacto y búsqueda: completado.
- Sustituir tamaños de texto inferiores a 12 px y transiciones `ease` aisladas.
### Fase 2 — Patrones de contenido
Estado: iniciada. El filtro móvil de Música y el armazón común de modales ya están migrados.
- Unificar tablas de álbumes y canciones sin borrar sus diferencias semánticas.
- Unificar índices laterales, chips, entradillas y barras de filtros.
- Eliminar marcos exteriores de cabeceras, heroes y agrupaciones editoriales.
- Consolidar las reglas repetidas de tarjetas solo donde exista una entidad interactiva real.
- Corregir el filtro móvil de Música para que no desplace cientos de píxeles el contenido principal: completado.
### Fase 3 — Responsive y rutas críticas
- Reducir los 26 breakpoints actuales a un conjunto funcional pequeño y documentado.
- Revisar navegación principal, menú burger y popups sin saltos de layout.
- Validar Música, álbum, canción, artista, Juke-box, suscripciones, favoritos, búsqueda, carrito y páginas legales.
- Añadir estado intermedio de dos columnas donde una cuadrícula pase actualmente de tres a una de forma brusca.
- Proteger tablas y contenido legal frente a desbordamiento horizontal.
### Fase 4 — Estudio y prevención de regresiones
- Mapear los tokens históricos de Estudio a nombres semánticos propios, sin sustituciones ambiguas.
- Migrar sus tamaños, radios y espacios literales por familias de uso.
- Retirar duplicación restante de rutas y componentes.
- Guardar capturas de referencia y ejecutar la matriz QA después de cada lote significativo.
- Hacer que los nuevos literales, breakpoints y `!important` requieran una justificación explícita en revisión.
## Matriz QA obligatoria
Esta matriz se ejecuta en claro y oscuro. Cada celda debe comprobarse visualmente, además de las pruebas automáticas. “Pendiente” significa que la combinación debe repetirse después de cada lote; no equivale a un fallo conocido.
| Ancho | Tema | Comprobaciones principales | Estado de cierre |
| ------: | ------ | ------------------------------------------------------------------------------------------------------------ | ------------------ |
| 390 px | Claro | Menú burger en overlay, controles ≥44 px, filtros compactos, sin scroll horizontal, contraste de superficies | Pendiente por lote |
| 390 px | Oscuro | Lo anterior más legibilidad de bordes permitidos, iconos y overlays | Pendiente por lote |
| 768 px | Claro | Transición móvil/tablet, navegación sin salto, tablas adaptadas, jerarquía y separación coherentes | Pendiente por lote |
| 768 px | Oscuro | Lo anterior más contraste de filas, estados activos y modales | Pendiente por lote |
| 1024 px | Claro | Navegación completa si cabe, filtros y contenido equilibrados, cuadrículas sin cambio brusco | Pendiente por lote |
| 1024 px | Oscuro | Lo anterior más transparencia de cabecera y lectura sobre heroes | Pendiente por lote |
| 1440 px | Claro | Ancho útil controlado, cabeceras sin cajas, tablas a todo su contenedor, imágenes bien encuadradas | Pendiente por lote |
| 1440 px | Oscuro | Lo anterior más fondos, sombras y contraste sin bordes decorativos | Pendiente por lote |
En las ocho combinaciones se verifica también:
- claro/oscuro sin cambio de estructura;
- foco por teclado visible;
- zoom al 200 % sin pérdida de contenido o acción;
- ausencia de solapamientos, cortes y saltos al abrir menús o modales;
- estados hover, activo, reproducción y selección coherentes;
- ninguna caja exterior nueva fuera de la política de bordes;
- tipografía limitada a Newsreader, Archivo y monoespaciada técnica;
- cabeceras, filas y columnas alineadas con el mismo sistema de espaciado.
## Seguimiento cuantitativo
Después de cada fase se guarda el resultado de `npm run audit:styles -- --json` y se compara con esta línea base. Los objetivos son monotónicos:
- variables sin declarar: 0;
- pesos no disponibles: 0;
- `!important`: tender a 0;
- breakpoints distintos: descenso hasta el conjunto acordado;
- literales de tipografía, radio, espacio, color, movimiento y sombra: descenso sin reemplazarlos por tokens duplicados;
- bordes: descenso allí donde sean marcos decorativos, conservando controles, foco y datos.
La reducción de números no sustituye la comprobación visual: una regla compartida incorrecta puede producir más daño que varias reglas locales correctas. Por eso cada consolidación se valida primero en las rutas que comparten el patrón y después en la matriz completa.
## Punto de continuidad para el 14 de septiembre de 2026
Estado guardado al cerrar la sesión:
- la auditoría estricta pasa con 0 variables sin declarar y 0 pesos tipográficos no disponibles;
- la cabecera del álbum no tiene marco, fondo ni radio decorativo;
- `src/tokens.css` contiene el contrato común de pesos, interlineados, controles, iconos, radios, movimiento, overlays y sombras;
- acceso, contacto y búsqueda comparten el mismo armazón de modal;
- Música usa un único componente de filtros y, en móvil/tablet, los muestra en un diálogo superpuesto que no desplaza el contenido;
- los controles principales normalizados respetan el mínimo táctil de 44 px;
- la comprobación visual se realizó en claro y oscuro a 390, 768, 1024 y 1440 px sin desbordamiento horizontal ni errores de consola en las rutas revisadas;
- `check:sitio`, `check:panel`, `check:estudio`, las pruebas unitarias y la compilación terminaron correctamente.
Corte cuantitativo actual después de la primera pasada:
| Indicador | Valor actual |
| ------------------------------- | -----------: |
| Archivos CSS/Svelte analizados | 133 |
| Declaraciones `font-size` | 524 |
| Tamaños tipográficos literales | 96 |
| Declaraciones de espaciado | 1434 |
| Espaciados literales relevantes | 263 |
| Declaraciones de borde | 546 |
| Declaraciones `border-radius` | 169 |
| Radios literales | 65 |
| Colores literales | 98 |
| Media queries | 98 |
| Breakpoints distintos | 26 |
| Usos de `!important` | 3 |
La línea base anterior se conserva como referencia histórica. Este corte es el punto de comparación para el siguiente lote.
Orden de continuación:
1. Reducir los 26 breakpoints a una escala responsive común, empezando por cabecera, navegación y tablas.
2. Unificar tablas de álbumes y canciones conservando sus columnas y jerarquías específicas.
3. Consolidar buscadores, controles segmentados, chips, entradillas e índices laterales.
4. Revisar y retirar marcos decorativos restantes en rutas públicas, sin eliminar separadores funcionales de filas.
5. Migrar Estudio a su propio vocabulario semántico de tokens; no hacer sustituciones mecánicas de sus valores históricos.
6. Repetir la matriz visual completa y guardar el nuevo corte cuantitativo antes de cerrar el siguiente lote.
Restricciones que deben mantenerse durante la continuación:
- no añadir cajas exteriores a cabeceras, heroes, introducciones ni agrupaciones editoriales;
- no modificar simultáneamente componentes no relacionados para resolver un ajuste local;
- no mezclar las escalas tipográficas de Newsreader y Archivo;
- no dar por válido un cambio responsive sin comprobarlo visualmente en ambos temas;
- mantener en cero las variables CSS inexistentes y los pesos no cargados.

@ -1,7 +1,7 @@
import { expect, test } from '@playwright/test';
/** Anchos que representan un móvil pequeño, uno normal, una tableta y un panel. */
const ANCHOS = [320, 375, 768, 1440];
/** Matriz común: móvil, tableta, escritorio compacto y escritorio amplio. */
const ANCHOS = [390, 768, 1024, 1440];
const RUTAS = [
'/',
@ -38,18 +38,20 @@ test.describe('Responsive', () => {
}
test('los controles se pueden pulsar con el dedo', async ({ page }) => {
// 24x24 es el mínimo de la norma. Los enlaces dentro de un texto quedan
// fuera —los limita la línea—, pero un botón suelto no tiene excusa.
await page.setViewportSize({ width: 375, height: 800 });
// El contrato del sitio fija 44x44 como mínimo para controles accionables.
// Los enlaces dentro de texto no forman parte de esta comprobación.
await page.setViewportSize({ width: 390, height: 800 });
for (const ruta of ['/', '/musica', '/carrito', '/canciones/dorina']) {
await page.goto(ruta);
const pequenos = await page.evaluate(() => {
const fuera: string[] = [];
for (const e of document.querySelectorAll('button, input, select, [role="button"]')) {
const c = e.getBoundingClientRect();
if (!c.width || !c.height) continue;
if (c.width < 24 || c.height < 24) {
if (!(e instanceof HTMLElement)) continue;
const ancho = e.offsetWidth;
const alto = e.offsetHeight;
if (!ancho || !alto) continue;
if (ancho < 44 || alto < 44) {
fuera.push(`${e.getAttribute('aria-label') ?? e.textContent?.trim().slice(0, 20)}`);
}
}
@ -180,13 +182,33 @@ test.describe('Responsive', () => {
await expect(page.getByRole('navigation', { name: 'Principal' })).toBeVisible();
});
test('las barras laterales bajan en vez de desaparecer', async ({ page }) => {
// Son la navegación de su sección: esconderlas deja la página sin salidas.
test('los filtros de música pasan a un panel táctil sin empujar el contenido', async ({
page
}) => {
await page.setViewportSize({ width: 375, height: 800 });
await page.goto('/musica/baladas-romanticas');
await expect(page.getByRole('navigation', { name: 'Estilos' })).toBeVisible();
const botonFiltros = page.getByRole('button', { name: /Filtros/ });
await expect(botonFiltros).toBeVisible();
await expect(page.getByRole('heading', { level: 1 })).toBeVisible();
await expect(page.getByRole('navigation', { name: 'Estilos' })).toBeHidden();
await botonFiltros.click();
const dialogo = page.getByRole('dialog', { name: 'Filtrar música' });
await expect(dialogo).toBeVisible();
await expect(dialogo.getByRole('navigation', { name: 'Estilos' })).toBeVisible();
await expect(dialogo.getByRole('link', { name: /Baladas románticas/ })).toHaveAttribute(
'aria-current',
'page'
);
const tactil = await dialogo
.getByRole('link', { name: /Baladas románticas/ })
.evaluate((enlace) => enlace.getBoundingClientRect().height);
expect(tactil).toBeGreaterThanOrEqual(48);
await page.keyboard.press('Escape');
// El índice de géneros aún cabe como parte de su propia plantilla.
await page.goto('/generos/bachata');
await expect(page.getByRole('navigation', { name: 'Contenido de géneros' })).toBeVisible();
});

@ -16,6 +16,7 @@
"check:panel": "node scripts/como-panel.mjs npx svelte-kit sync && node scripts/como-panel.mjs npx svelte-check --tsconfig ./tsconfig.panel.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "prettier --check . && eslint .",
"audit:styles": "node scripts/auditar-estilos.mjs",
"format": "prettier --write .",
"test:unit": "vitest",
"test": "npm run test:unit -- --run && npm run test:e2e",

@ -0,0 +1,188 @@
import { createHash } from 'node:crypto';
import { readdir, readFile } from 'node:fs/promises';
import { extname, relative, resolve } from 'node:path';
const RAIZ = resolve(import.meta.dirname, '..');
const SRC = resolve(RAIZ, 'src');
const JSON_SALIDA = process.argv.includes('--json');
const ESTRICTO = process.argv.includes('--strict');
const extensiones = new Set(['.css', '.svelte']);
const pesosPermitidos = new Set(['400', '500', '600']);
async function recorrer(directorio) {
const encontrados = [];
for (const entrada of await readdir(directorio, { withFileTypes: true })) {
const ruta = resolve(directorio, entrada.name);
if (entrada.isDirectory()) encontrados.push(...(await recorrer(ruta)));
else if (extensiones.has(extname(entrada.name))) encontrados.push(ruta);
}
return encontrados;
}
function cssDe(ruta, fuente) {
if (ruta.endsWith('.css')) return fuente;
return [...fuente.matchAll(/<style[^>]*>([\s\S]*?)<\/style>/g)]
.map((coincidencia) => coincidencia[1])
.join('\n');
}
function lineaDe(texto, indice) {
return texto.slice(0, indice).split('\n').length;
}
function ambitoDe(ruta) {
const normalizada = ruta.replaceAll('\\', '/');
if (normalizada.includes('/routes/(sitio)/')) return 'sitio';
if (normalizada.includes('/lib/components/')) return 'componentes';
if (normalizada.includes('/panel/')) return 'panel';
if (normalizada.includes('/estudio/')) return 'estudio';
return 'global';
}
function contar(css, expresion) {
return [...css.matchAll(expresion)].length;
}
function valores(css, expresion) {
return [...css.matchAll(expresion)].map((coincidencia) => coincidencia[1].trim());
}
function contarLiterales(css, expresion) {
return valores(css, expresion).filter((valor) => !valor.includes('var(')).length;
}
const rutas = await recorrer(SRC);
const documentos = await Promise.all(
rutas.map(async (ruta) => {
const fuente = await readFile(ruta, 'utf8');
return {
ruta,
relativa: relative(RAIZ, ruta).replaceAll('\\', '/'),
fuente,
css: cssDe(ruta, fuente),
ambito: ambitoDe(ruta)
};
})
);
const declaraciones = new Map();
const usos = [];
const pesosNoDisponibles = [];
for (const documento of documentos) {
/* También se declaran custom properties desde atributos `style` de Svelte. */
for (const coincidencia of documento.fuente.matchAll(/(?<![\w-])(--[\w-]+)\s*:/g)) {
declaraciones.set(coincidencia[1], documento.relativa);
}
for (const coincidencia of documento.fuente.matchAll(/var\(\s*(--[\w-]+)(\s*,)?/g)) {
usos.push({
nombre: coincidencia[1],
conReserva: Boolean(coincidencia[2]),
archivo: documento.relativa,
linea: lineaDe(documento.fuente, coincidencia.index)
});
}
/* Las declaraciones de @font-face describen archivos; el contrato se aplica a su uso. */
const fuenteSinDeclaraciones = documento.fuente.replace(/@font-face\s*\{[\s\S]*?\}/g, '');
for (const coincidencia of fuenteSinDeclaraciones.matchAll(/font-weight\s*:\s*(\d+)/g)) {
if (!pesosPermitidos.has(coincidencia[1])) {
pesosNoDisponibles.push({
valor: coincidencia[1],
archivo: documento.relativa,
linea: lineaDe(fuenteSinDeclaraciones, coincidencia.index)
});
}
}
}
const variablesSinDeclarar = usos.filter(
(uso) => !uso.conReserva && !declaraciones.has(uso.nombre)
);
const ambitos = ['sitio', 'componentes', 'panel', 'estudio', 'global'];
const resumen = ambitos.map((ambito) => {
const delAmbito = documentos.filter((documento) => documento.ambito === ambito);
const css = delAmbito.map((documento) => documento.css).join('\n');
return {
ambito,
archivos: delAmbito.length,
bloques: contar(css, /(?:^|\n)\s*[^@/][^{]*\{/g),
tamanosTipograficos: contar(css, /font-size\s*:/g),
tamanosTipograficosLiterales: contarLiterales(css, /font-size\s*:\s*([^;\r\n]+)/g),
radios: contar(css, /border-radius\s*:/g),
radiosLiterales: contarLiterales(css, /border-radius\s*:\s*([^;\r\n]+)/g),
espaciados: contar(css, /(?:padding|margin|gap)(?:-[a-z]+)?\s*:/g),
espaciadosLiterales: valores(
css,
/(?:padding|margin|gap)(?:-[a-z]+)?\s*:\s*([^;\r\n]+)/g
).filter((valor) => !valor.includes('var(') && !/^(?:0|auto|none)$/.test(valor)).length,
bordes: contar(css, /border(?:-[a-z]+)?\s*:/g),
coloresLiterales: contar(css, /#[\da-f]{3,8}\b|rgba?\(|hsla?\(|oklch\(/gi),
mediaQueries: contar(css, /@media\s*\(/g),
important: contar(css, /!important/g)
};
});
const breakpoints = new Map();
for (const documento of documentos) {
for (const coincidencia of documento.css.matchAll(
/@media\s*\([^)]*(?:min|max)-width\s*:\s*([^\s)]+)/g
)) {
breakpoints.set(coincidencia[1], (breakpoints.get(coincidencia[1]) ?? 0) + 1);
}
}
const fuentes = [];
const directorioFuentes = resolve(RAIZ, 'static', 'fuentes');
const nombresFuentes = (await readdir(directorioFuentes))
.filter((nombre) => nombre.endsWith('.woff2'))
.sort();
for (const nombre of nombresFuentes) {
const ruta = resolve(directorioFuentes, nombre);
const contenido = await readFile(ruta);
fuentes.push({ nombre, sha256: createHash('sha256').update(contenido).digest('hex') });
}
const informe = {
archivos: documentos.length,
resumen,
variablesSinDeclarar,
pesosNoDisponibles,
breakpoints: [...breakpoints.entries()]
.map(([valor, veces]) => ({ valor, veces }))
.sort((a, b) => b.veces - a.veces || a.valor.localeCompare(b.valor)),
fuentes
};
if (JSON_SALIDA) {
console.log(JSON.stringify(informe, null, 2));
} else {
console.log('Auditoría de estilos de Senza Paura\n');
console.table(resumen);
console.log(`Variables CSS sin declarar y sin reserva: ${variablesSinDeclarar.length}`);
for (const uso of variablesSinDeclarar) {
console.log(` ${uso.nombre} — ${uso.archivo}:${uso.linea}`);
}
console.log(`\nPesos tipográficos no disponibles: ${pesosNoDisponibles.length}`);
for (const peso of pesosNoDisponibles) {
console.log(` ${peso.valor} — ${peso.archivo}:${peso.linea}`);
}
console.log(`\nBreakpoints distintos: ${breakpoints.size}`);
console.log(
[...breakpoints.entries()]
.sort((a, b) => b[1] - a[1])
.map(([valor, veces]) => `${valor} (${veces})`)
.join(' · ')
);
const hashes = new Set(fuentes.map((fuente) => fuente.sha256));
console.log(
`\nFuentes auditadas: ${fuentes.length} archivos, ${hashes.size} binarios distintos.`
);
}
if (ESTRICTO && (variablesSinDeclarar.length > 0 || pesosNoDisponibles.length > 0)) {
process.exitCode = 1;
}

@ -1,5 +1,6 @@
@import './fuentes.css';
@import './marca.css';
@import './tokens.css';
/*
* ---------------------------------------------------------------------------
* DIRECCIÓN VISUAL
@ -103,7 +104,7 @@
* puntero, se desplaza y la luz recorre la pieza en vez de limitarse a
* aclararla. Es la diferencia entre algo que se ilumina y algo que gira.
*/
--oro: linear-gradient(
--gradiente-oro: linear-gradient(
166deg,
var(--c-accent-sombra) 0%,
var(--c-accent-superficie) 24%,
@ -174,14 +175,6 @@
--boton-relleno-corto: var(--sp-2) var(--sp-3);
--radius-tarjeta: 8px;
/*
* Curvas del movimiento. `--sal` sale rápido y frena largo, que es como se
* mueve algo que pesa; `--suave` es para lo que solo cambia de color o de
* opacidad y no tiene inercia que fingir. Nada usa `ease`, que es la curva
* por defecto y hace que todo se parezca.
*/
--sal: cubic-bezier(0.16, 1, 0.3, 1);
--suave: cubic-bezier(0.4, 0, 0.2, 1);
/*
* El ancho útil: 1184px. Se probó a subirlo a 88rem (1408px) para aprovechar
* los monitores anchos y salió al revés: la prosa tiene una medida de
@ -275,7 +268,7 @@ body {
color: var(--c-text);
font-family: var(--font-body);
font-size: var(--fs-base);
line-height: 1.65;
line-height: var(--leading-body);
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
@ -388,9 +381,9 @@ h2,
h3,
h4 {
font-family: var(--font-display);
line-height: 1.1;
line-height: var(--leading-heading);
margin: 0 0 var(--sp-3);
font-weight: 400;
font-weight: var(--font-weight-regular);
letter-spacing: -0.015em;
text-wrap: balance;
}
@ -413,7 +406,7 @@ p {
a {
color: inherit;
text-underline-offset: 0.2em;
transition: color 0.2s var(--suave);
transition: color 0.2s var(--ease-standard);
}
/*
@ -499,6 +492,7 @@ button {
display: inline-flex;
align-items: center;
gap: var(--sp-2);
min-height: var(--control-height);
padding: var(--boton-relleno);
border: 1px solid var(--c-border);
border-radius: var(--radius-boton);
@ -513,10 +507,10 @@ button {
letter-spacing: 0.01em;
cursor: pointer;
text-decoration: none;
line-height: 1.2;
line-height: var(--leading-ui);
transition:
background-color 0.2s var(--suave),
border-color 0.2s var(--suave);
background-color 0.2s var(--ease-standard),
border-color 0.2s var(--ease-standard);
}
/*
@ -532,6 +526,73 @@ button {
color: inherit;
}
/* --- diálogos ------------------------------------------------------------
*
* Una sola carcasa para acceso, contacto y búsqueda. El borde no forma parte
* del diálogo: la separación del plano inferior la dan el fondo y la sombra.
*/
.modal-screen {
width: 100%;
max-width: 100%;
height: 100%;
max-height: 100%;
margin: 0;
padding: var(--sp-4);
border: 0;
background: transparent;
color: var(--c-text);
overflow: auto;
place-items: center;
}
.modal-screen[open] {
display: grid;
}
.modal-screen::backdrop {
background: var(--c-overlay);
backdrop-filter: blur(4px);
}
.modal-surface {
position: relative;
width: 100%;
border-radius: var(--radius-overlay);
background: var(--c-bg-raised);
box-shadow: var(--shadow-modal);
}
.modal-close {
position: absolute;
top: var(--sp-3);
right: var(--sp-3);
display: inline-grid;
place-items: center;
width: var(--control-height);
height: var(--control-height);
padding: 0;
border: 0;
border-radius: var(--radius-round);
background: transparent;
color: var(--c-text-muted);
cursor: pointer;
transition:
background-color var(--duration-normal) var(--ease-standard),
color var(--duration-normal) var(--ease-standard);
}
.modal-close:hover,
.modal-close:focus-visible {
background: var(--c-bg-sunken);
color: var(--c-text);
}
@media (max-width: 34rem) {
.modal-screen {
padding: var(--sp-3);
}
}
/*
* El aspecto dorado, aparte del botón que lo estrenó.
*
@ -545,15 +606,15 @@ button {
/* El color plano queda de reserva: si el degradado no se pintara, el botón
sigue siendo dorado y legible, no transparente. */
background-color: var(--c-accent-superficie);
background-image: var(--oro);
background-image: var(--gradiente-oro);
background-size: 100% 240%;
background-position: 0 22%;
color: var(--c-accent-text);
/* El filo de luz de arriba. Sin él, el degradado se ve pintado, no biselado. */
box-shadow: inset 0 1px 0 color-mix(in oklab, white 45%, transparent);
transition:
background-position 0.6s var(--sal),
border-color 0.2s var(--suave);
background-position 0.6s var(--ease-emphasized),
border-color 0.2s var(--ease-standard);
}
/* El canto sí es del botón: dentro de una barra no hay borde que teñir. */
@ -615,8 +676,8 @@ button {
color: var(--c-text-muted);
text-decoration: none;
transition:
border-color 0.3s var(--suave),
color 0.3s var(--suave);
border-color 0.3s var(--ease-standard),
color 0.3s var(--ease-standard);
}
a.chip:hover,
@ -655,9 +716,9 @@ a.chip:focus-visible {
overflow: hidden;
isolation: isolate;
transition:
background-color 0.38s var(--suave),
border-color 0.32s var(--suave),
box-shadow 0.42s var(--sal);
background-color 0.38s var(--ease-standard),
border-color 0.32s var(--ease-standard),
box-shadow 0.42s var(--ease-emphasized);
}
/*
@ -678,7 +739,7 @@ a.chip:focus-visible {
color-mix(in oklab, var(--c-accent) 11%, transparent),
transparent 72%
);
transition: opacity 0.35s var(--suave);
transition: opacity 0.35s var(--ease-standard);
}
.tarjeta:hover::before,
@ -711,7 +772,7 @@ a.chip:focus-visible {
.tarjeta:hover::after,
.tarjeta:focus-within::after {
animation: tarjeta-destello 0.82s var(--sal) both;
animation: tarjeta-destello 0.82s var(--ease-emphasized) both;
}
@keyframes tarjeta-destello {
@ -749,7 +810,7 @@ a.chip:focus-visible {
* transform al terminar. Con both, el último fotograma seguía mandando para
* siempre y anulaba la respuesta de la tarjeta al puntero.
*/
animation: revelarse 0.8s var(--sal) var(--retraso-revelado, 0ms) backwards;
animation: revelarse 0.8s var(--ease-emphasized) var(--retraso-revelado, 0ms) backwards;
}
@keyframes revelarse {
@ -863,8 +924,8 @@ a.chip:focus-visible {
background-position: 0 100%;
background-size: 0 1px;
transition:
background-size 0.45s var(--sal),
color 0.3s var(--suave);
background-size 0.45s var(--ease-emphasized),
color 0.3s var(--ease-standard);
}
.tarjeta:hover .tarjeta__enlace,
@ -885,14 +946,14 @@ a.chip:focus-visible {
/*
* El selector lleva `.tarjeta` delante a propósito: los estilos del botón
* vienen de un componente y Svelte les añade su clase de ámbito, así que con
* menos peso no se aplicarían. El control conserva 36 px en las dos clases de
* tarjeta.
* menos peso no se aplicarían. El control conserva el mínimo táctil común en
* las dos clases de tarjeta.
*/
.tarjeta .tarjeta__reproduccion button {
min-width: 2.25rem;
min-height: 2.25rem;
width: 2.25rem;
height: 2.25rem;
min-width: var(--control-height);
min-height: var(--control-height);
width: var(--control-height);
height: var(--control-height);
padding: 0;
}

@ -236,7 +236,7 @@
}
.editor > legend {
font-size: 1.15rem;
font-weight: 700;
font-weight: var(--font-weight-semibold);
}
.introduccion {
margin: 0.45rem 0 1rem;
@ -323,11 +323,11 @@
border-top: 1px solid var(--borde);
}
.anadir > span {
font-weight: 700;
font-weight: var(--font-weight-semibold);
}
.anadir button {
color: var(--acento);
font-weight: 650;
font-weight: var(--font-weight-semibold);
}
@media (max-width: 700px) {
.campos {

@ -1,5 +1,6 @@
@import '../fuentes.css';
@import '../marca.css';
@import '../tokens.css';
:root {
font-size: 100%;
line-height: 1.5;
@ -15,7 +16,6 @@
--muted: var(--c-text-muted);
--acento: var(--c-marca-oro);
--suave: var(--c-bg-sunken);
--oro: var(--c-marca-oro);
}
* {
box-sizing: border-box;
@ -64,7 +64,7 @@ h1 {
font-family: var(--font-display);
font-size: 2rem;
letter-spacing: -0.045em;
font-weight: 650;
font-weight: var(--font-weight-semibold);
margin-bottom: 0.6rem;
}
h2 {
@ -88,7 +88,7 @@ small {
font-size: 1rem;
letter-spacing: 0.06em;
text-transform: uppercase;
font-weight: 700;
font-weight: var(--font-weight-semibold);
color: var(--muted);
}
.boton {
@ -292,7 +292,7 @@ th {
font-size: 1rem;
letter-spacing: 0.03em;
color: var(--texto);
font-weight: 650;
font-weight: var(--font-weight-semibold);
background: var(--papel);
}
th,
@ -381,7 +381,7 @@ td .titulo {
.tabs [aria-selected='true'] {
color: var(--acento);
border-bottom-color: var(--acento);
font-weight: 650;
font-weight: var(--font-weight-semibold);
}
.editor-rejilla {
display: grid;

@ -178,7 +178,7 @@
font-size: 1.35rem;
font-family: var(--font-display);
letter-spacing: -0.035em;
font-weight: 650;
font-weight: var(--font-weight-semibold);
}
.marca:hover {
text-decoration: none;
@ -188,7 +188,7 @@
color: var(--texto);
letter-spacing: 0.08em;
font-size: 1rem;
font-weight: 450;
font-weight: var(--font-weight-medium);
font-family: var(--font-body);
margin-top: 0.3rem;
}
@ -197,7 +197,7 @@
}
.grupo p {
font-size: 1rem;
font-weight: 650;
font-weight: var(--font-weight-semibold);
color: var(--muted);
margin: 0 0 0.2rem;
padding: 0 0.75rem;

@ -751,7 +751,7 @@
.tipos-contexto legend {
font-size: 1rem;
margin-bottom: 0.8rem;
font-weight: 650;
font-weight: var(--font-weight-semibold);
}
.campos-listos {
display: contents;

@ -98,7 +98,7 @@
.configuracion {
color: var(--c-marca-oro);
font-family: var(--font-sans);
font-family: var(--font-body);
font-size: 0.875rem;
}

@ -85,7 +85,7 @@
padding: 1rem;
}
.tipos-nueva legend {
font-weight: 650;
font-weight: var(--font-weight-semibold);
font-size: 1rem;
}
.tipos-nueva p {

@ -151,7 +151,7 @@
color: var(--texto);
font-size: 2rem;
font-family: var(--font-display);
font-weight: 650;
font-weight: var(--font-weight-semibold);
}
.acceso-tarjeta {
width: 100%;
@ -197,7 +197,7 @@
}
.pasos .actual {
color: var(--acento);
font-weight: 650;
font-weight: var(--font-weight-semibold);
}
.actual span {
background: var(--papel);

@ -55,6 +55,7 @@
<style>
.boton {
min-height: var(--control-height);
padding: var(--boton-relleno);
border: 1px solid var(--c-border);
border-radius: var(--radius-boton);

@ -100,6 +100,7 @@
display: inline-flex;
align-items: center;
gap: var(--sp-2);
min-height: var(--control-height);
padding: var(--boton-relleno);
border: 1px solid var(--c-border);
border-radius: var(--radius-boton);
@ -109,8 +110,8 @@
font-size: var(--boton-texto);
cursor: pointer;
transition:
color 0.2s ease,
border-color 0.2s ease;
color var(--duration-normal) var(--ease-standard),
border-color var(--duration-normal) var(--ease-standard);
}
.boton:hover:not(:disabled),

@ -81,6 +81,7 @@
display: inline-flex;
align-items: center;
gap: var(--sp-2);
min-height: var(--control-height);
padding: var(--boton-relleno);
border: 1px solid var(--c-border);
border-radius: var(--radius-boton);
@ -91,8 +92,8 @@
text-decoration: none;
cursor: pointer;
transition:
color 0.2s ease,
border-color 0.2s ease;
color var(--duration-normal) var(--ease-standard),
border-color var(--duration-normal) var(--ease-standard);
}
.boton:hover,

@ -154,7 +154,7 @@
.titulo {
margin: 0 var(--sp-6) var(--sp-2) 0;
font-size: var(--fs-sm);
font-weight: 700;
font-weight: var(--font-weight-semibold);
}
.cerrar {
@ -164,8 +164,8 @@
display: inline-flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
width: var(--control-height);
height: var(--control-height);
padding: 0;
border: 0;
border-radius: 50%;
@ -208,7 +208,7 @@
.marca-lista {
width: 1.25rem;
color: var(--c-accent);
font-weight: 700;
font-weight: var(--font-weight-semibold);
text-align: center;
}

@ -110,10 +110,10 @@
justify-content: center;
gap: var(--sp-2);
padding: var(--sp-2);
min-width: 2.25rem;
min-height: 2.25rem;
min-width: var(--control-height);
min-height: var(--control-height);
border: 1px solid transparent;
border-radius: 999px;
border-radius: var(--radius-round);
/* Longhand, no el atajo: el atajo borraría el degradado del dorado. */
background-color: transparent;
cursor: pointer;
@ -139,13 +139,13 @@
padding: var(--boton-relleno);
font-size: var(--boton-texto);
background-color: var(--c-accent-superficie);
background-image: var(--oro);
background-image: var(--gradiente-oro);
background-size: 100% 240%;
background-position: 0 22%;
border-color: var(--c-accent-sombra);
color: var(--c-accent-text);
box-shadow: inset 0 1px 0 color-mix(in oklab, white 45%, transparent);
transition: background-position 0.5s var(--sal);
transition: background-position 0.5s var(--ease-emphasized);
}
.solido:hover:not(:disabled) {

@ -25,7 +25,7 @@
lado?: number;
}
let { tamano = 18, lado = 36 }: Props = $props();
let { tamano = 20, lado = 44 }: Props = $props();
const aOscuro = $derived(tema.actual === 'claro');
</script>
@ -48,16 +48,16 @@
display: inline-flex;
align-items: center;
justify-content: center;
width: var(--lado-boton-tema, 2.25rem);
height: var(--lado-boton-tema, 2.25rem);
width: var(--lado-boton-tema, var(--control-height));
height: var(--lado-boton-tema, var(--control-height));
border: none;
border-radius: 999px;
border-radius: var(--radius-round);
background: transparent;
color: var(--c-text-muted);
cursor: pointer;
transition:
color 0.2s ease,
background 0.2s ease;
color var(--duration-normal) var(--ease-standard),
background var(--duration-normal) var(--ease-standard);
}
.boton:hover {

@ -392,8 +392,8 @@
);
background-color: transparent;
transition:
background-color 0.35s var(--suave),
backdrop-filter 0.35s var(--suave);
background-color 0.35s var(--ease-standard),
backdrop-filter 0.35s var(--ease-standard);
}
/* Al bajar conserva la transparencia y desenfoca lo que pasa por detrás. */
@ -534,8 +534,8 @@
.cuenta .accion-icono {
display: inline-grid;
width: 2rem;
height: 2rem;
width: var(--control-height);
height: var(--control-height);
padding: 0;
place-items: center;
}

@ -169,7 +169,7 @@
opacity: 0;
visibility: hidden;
transition:
opacity 0.7s var(--suave),
opacity 0.7s var(--ease-standard),
visibility 0s linear 0.7s;
}
@ -177,7 +177,7 @@
opacity: 1;
visibility: visible;
transition:
opacity 0.7s var(--suave),
opacity 0.7s var(--ease-standard),
visibility 0s;
}
@ -203,14 +203,14 @@
display: inline-flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
width: var(--control-height);
height: var(--control-height);
border: none;
border-radius: 999px;
background: transparent;
color: var(--c-text-muted);
cursor: pointer;
transition: color 0.2s var(--suave);
transition: color 0.2s var(--ease-standard);
}
.flecha:hover,
@ -229,15 +229,15 @@
/*
* El punto que se ve mide ocho píxeles, pero el botón que se pulsa mide
* veinticuatro: es el mínimo que hace falta para acertarle con el dedo, y un
* cuarenta y cuatro: es el mínimo táctil común, y un
* punto de ocho no se acierta. La marca visible va en un pseudoelemento
* centrado, así que el área de toque crece sin que el punto engorde.
*/
.punto {
position: relative;
display: block;
width: 1.5rem;
height: 1.5rem;
width: var(--control-height);
height: var(--control-height);
padding: 0;
border: none;
background: none;
@ -261,9 +261,9 @@
background: var(--c-text-muted);
opacity: 0.5;
transition:
width 0.4s var(--sal),
opacity 0.3s var(--suave),
background 0.3s var(--suave);
width 0.4s var(--ease-emphasized),
opacity 0.3s var(--ease-standard),
background 0.3s var(--ease-standard);
}
.punto:hover::before {

@ -101,7 +101,7 @@
}
.mas {
font-size: 0.6875rem;
font-size: var(--fs-xs);
}
.senas :global(.etiquetas) {
@ -114,7 +114,7 @@
}
.marca {
font-size: 0.6875rem;
font-size: var(--fs-xs);
white-space: nowrap;
}

@ -87,8 +87,8 @@
color: var(--claro);
text-decoration: none;
transition:
background-color 0.3s var(--suave),
border-color 0.3s var(--suave);
background-color 0.3s var(--ease-standard),
border-color 0.3s var(--ease-standard);
}
/*
@ -108,7 +108,7 @@
.menudas .etiqueta {
padding: 0.1em 0.55em;
font-size: 0.6875rem;
font-size: var(--fs-xs);
}
a.etiqueta:hover,

@ -0,0 +1,250 @@
<script lang="ts">
import { resolve } from '$app/paths';
import type { EstiloConObra } from '$lib/types';
type OpcionesCatalogo = {
vista?: string;
estilo?: string;
etiqueta?: string;
idioma?: string;
};
type IdiomaConTotal = {
codigo: string;
nombre: string;
total: number;
};
interface Props {
prefijo: string;
bloques: EstiloConObra[];
idiomas: IdiomaConTotal[];
estiloActual: string;
etiquetaActual: string;
idiomaActual: string;
vistaActual: string;
rutaDeCatalogo: (opciones?: OpcionesCatalogo) => '/musica' | `/musica?${string}`;
movil?: boolean;
}
let {
prefijo,
bloques,
idiomas,
estiloActual,
etiquetaActual,
idiomaActual,
vistaActual,
rutaDeCatalogo,
movil = false
}: Props = $props();
const idCatalogo = $derived(`${prefijo}-catalogo`);
const idEstilos = $derived(`${prefijo}-estilos`);
const idIdiomas = $derived(`${prefijo}-idiomas`);
</script>
<div class="filtros" class:movil>
<nav aria-label="Ver catálogo">
<p class="rotulo" id={idCatalogo}>Catálogo</p>
<ul aria-labelledby={idCatalogo}>
<li>
<a
href={resolve(rutaDeCatalogo())}
aria-current={!vistaActual && !estiloActual && !etiquetaActual && !idiomaActual
? 'page'
: undefined}
>
<span>Todo el catálogo</span>
</a>
</li>
<li>
<a
href={resolve(
rutaDeCatalogo({
vista: 'albumes',
estilo: estiloActual,
etiqueta: etiquetaActual,
idioma: idiomaActual
})
)}
aria-current={vistaActual === 'albumes' ? 'page' : undefined}
>
<span>Todos los álbumes</span>
</a>
</li>
<li>
<a
href={resolve(
rutaDeCatalogo({
vista: 'canciones',
estilo: estiloActual,
etiqueta: etiquetaActual,
idioma: idiomaActual
})
)}
aria-current={vistaActual === 'canciones' ? 'page' : undefined}
>
<span>Todas las canciones</span>
</a>
</li>
</ul>
</nav>
<nav aria-label="Estilos">
<p class="rotulo" id={idEstilos}>Estilos</p>
<ul aria-labelledby={idEstilos}>
{#each bloques as bloque (bloque.estilo.slug)}
<li>
<a
href={resolve(
rutaDeCatalogo({
vista: vistaActual,
estilo: bloque.estilo.slug,
etiqueta: etiquetaActual,
idioma: idiomaActual
})
)}
aria-current={bloque.estilo.slug === estiloActual ? 'page' : undefined}
title="Filtrar por {bloque.estilo.nombre}: {bloque.albumes.length} {bloque.albumes
.length === 1
? 'lanzamiento'
: 'lanzamientos'}"
>
<span>{bloque.estilo.nombre}</span>
<span class="cuenta muted">{bloque.albumes.length}</span>
</a>
</li>
{/each}
</ul>
</nav>
<nav aria-label="Idiomas">
<p class="rotulo" id={idIdiomas}>Idiomas</p>
<ul class="lista-idiomas" aria-labelledby={idIdiomas}>
<li class="todos-idiomas">
<a
href={resolve(
rutaDeCatalogo({
vista: vistaActual,
estilo: estiloActual,
etiqueta: etiquetaActual
})
)}
aria-current={!idiomaActual ? 'page' : undefined}
>
<span>Todos los idiomas</span>
</a>
</li>
{#each idiomas as idioma (idioma.codigo)}
<li>
<a
href={resolve(
rutaDeCatalogo({
vista: vistaActual,
estilo: estiloActual,
etiqueta: etiquetaActual,
idioma: idioma.codigo
})
)}
aria-current={idioma.codigo === idiomaActual ? 'page' : undefined}
title="{idioma.total} {idioma.total === 1 ? 'canción' : 'canciones'} en {idioma.nombre}"
>
<span>{idioma.nombre}</span>
<span class="cuenta muted">{idioma.total}</span>
</a>
</li>
{/each}
</ul>
</nav>
</div>
<style>
.filtros {
display: flex;
flex-direction: column;
gap: var(--sp-5);
font-size: var(--fs-sm);
}
.rotulo {
margin: 0 0 var(--sp-2);
font-size: var(--fs-xs);
font-weight: var(--font-weight-medium);
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--c-text-muted);
}
ul {
list-style: none;
margin: 0;
padding: 0;
}
a {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--sp-2);
min-height: var(--control-height-compact);
margin-left: calc(var(--sp-3) * -1);
padding: var(--sp-1) 0 var(--sp-1) var(--sp-3);
border-left: 2px solid transparent;
color: var(--c-text-muted);
text-decoration: none;
transition: color var(--duration-normal) var(--ease-standard);
}
a:hover,
a:focus-visible {
color: var(--c-text);
}
a[aria-current='page'] {
border-left-color: var(--c-accent);
color: var(--c-accent);
font-weight: var(--font-weight-semibold);
}
.cuenta {
font-family: var(--font-mono);
font-size: var(--fs-xs);
}
.lista-idiomas {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
column-gap: var(--sp-3);
}
.todos-idiomas {
grid-column: 1 / -1;
}
.movil {
gap: var(--sp-7);
font-size: var(--fs-base);
}
.movil .rotulo {
margin-bottom: var(--sp-3);
font-size: var(--fs-sm);
}
.movil a {
min-height: var(--control-height-touch);
margin-left: 0;
padding: var(--sp-2) var(--sp-3);
}
.movil .lista-idiomas {
grid-template-columns: minmax(0, 1fr);
}
@media (prefers-reduced-motion: reduce) {
a {
transition: none;
}
}
</style>

@ -272,7 +272,7 @@
width: fit-content;
font-family: var(--font-mono);
font-size: var(--fs-base);
font-weight: 700;
font-weight: var(--font-weight-semibold);
letter-spacing: 0.08em;
}

@ -186,7 +186,7 @@
margin-bottom: var(--sp-2);
}
.entradilla {
font-size: var(--fs-md);
font-size: var(--fs-base);
}
.disposicion {
grid-template-columns: minmax(0, 1fr);

@ -66,6 +66,10 @@
cerrar: { tipo: 'linea', d: ['M6 6l12 12', 'M18 6 6 18'] },
menu: { tipo: 'linea', d: ['M4 7h16', 'M4 12h16', 'M4 17h16'] },
buscar: { tipo: 'linea', d: ['M21 21l-4.35-4.35', 'M19 11a8 8 0 1 1-16 0 8 8 0 0 1 16 0Z'] },
filtros: {
tipo: 'linea',
d: ['M4 6h16', 'M4 12h16', 'M4 18h16', 'M8 3v6', 'M16 9v6', 'M10 15v6']
},
externo: {
tipo: 'linea',
d: ['M14 3h7v7', 'M10 14 21 3', 'M19 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2h6']

@ -94,7 +94,7 @@
}
.nombre {
font-weight: 650;
font-weight: var(--font-weight-semibold);
}
.metadatos,

@ -408,8 +408,8 @@
*/
vector-effect: non-scaling-stroke;
transition:
fill 0.15s var(--suave),
stroke 0.15s var(--suave);
fill 0.15s var(--ease-standard),
stroke 0.15s var(--ease-standard);
}
/* Un país sin géneros no se pulsa, así que tampoco se ilumina. */
@ -479,7 +479,7 @@
.punto {
fill: transparent;
stroke: none;
transition: opacity 0.15s var(--suave);
transition: opacity 0.15s var(--ease-standard);
opacity: 0;
}

@ -62,7 +62,7 @@
-->
<dialog
bind:this={dialogo}
class="acceso"
class="acceso modal-screen"
aria-labelledby="titulo-acceso"
aria-describedby="motivo-acceso"
onclose={() => acceso.cerrar()}
@ -73,7 +73,7 @@
if (evento.target === dialogo) acceso.cerrar();
}}
>
<div class="marco">
<div class="marco modal-surface">
<!--
La mitad de la imagen. `aria-hidden` porque no cuenta nada que no esté
escrito al lado; para quien no la ve, el diálogo empieza en el título.
@ -123,7 +123,12 @@
directamente en el correo y el cierre queda al terminar, que es
cuando hace falta.
-->
<button type="button" class="cerrar" onclick={() => acceso.cerrar()} aria-label="Cerrar">
<button
type="button"
class="cerrar modal-close"
onclick={() => acceso.cerrar()}
aria-label="Cerrar"
>
<Icono nombre="cerrar" tamano={16} />
</button>
</div>
@ -136,19 +141,6 @@
* `.marco`, centrado. Así el clic fuera se detecta sin poner un div extra
* que tape el fondo nativo.
*/
.acceso {
width: 100%;
max-width: 100%;
height: 100%;
max-height: 100%;
margin: 0;
padding: var(--sp-4);
border: none;
background: transparent;
overflow: auto;
place-items: center;
}
/*
* El `display` va SOLO en el estado abierto.
*
@ -159,26 +151,11 @@
* páginas del sitio, invisibles pero con su scroll y con su encabezado
* contando en el esquema de la página.
*/
.acceso[open] {
display: grid;
}
.acceso::backdrop {
/* En variable: el minificador junta las funciones del filtro sin espacio. */
background: rgb(3 8 20 / 72%);
backdrop-filter: blur(4px);
}
.marco {
display: grid;
grid-template-columns: 20rem minmax(0, 24rem);
width: 100%;
max-width: 44rem;
overflow: hidden;
border: 1px solid var(--c-border);
border-radius: var(--radius-tarjeta);
background: var(--c-bg-raised);
box-shadow: 0 24px 64px rgb(0 0 0 / 45%);
}
/* --- La mitad de la imagen ------------------------------------------- */
@ -213,8 +190,8 @@
padding: var(--sp-5);
background: linear-gradient(
to top,
color-mix(in oklab, #030814 88%, transparent) 0%,
color-mix(in oklab, #030814 40%, transparent) 45%,
color-mix(in oklab, var(--c-scrim-base) 88%, transparent) 0%,
color-mix(in oklab, var(--c-scrim-base) 40%, transparent) 45%,
transparent 75%
);
}
@ -223,7 +200,7 @@
margin: 0;
font-family: var(--font-display);
font-size: var(--fs-xl);
background: var(--oro);
background: var(--gradiente-oro);
background-clip: text;
-webkit-background-clip: text;
color: transparent;
@ -234,7 +211,7 @@
font-size: var(--fs-sm);
line-height: 1.5;
/* Sobre la imagen el color va fijo: no lo cambia el tema. */
color: rgb(255 255 255 / 78%);
color: var(--c-text-on-media-muted);
}
/* --- La mitad del formulario ----------------------------------------- */
@ -259,30 +236,6 @@
line-height: 1.5;
}
.cerrar {
position: absolute;
top: var(--sp-3);
right: var(--sp-3);
display: inline-flex;
align-items: center;
justify-content: center;
/* 2.25rem: lo mínimo para acertarle con el dedo. */
width: 2.25rem;
height: 2.25rem;
padding: 0;
border: none;
border-radius: var(--radius-boton);
background: transparent;
color: var(--c-text-muted);
cursor: pointer;
transition: color 0.2s var(--suave);
}
.cerrar:hover,
.cerrar:focus-visible {
color: var(--c-text);
}
/*
* En pantallas estrechas la imagen pasa a ser una franja arriba: dos
* columnas de diez centímetros no son dos columnas, son dos rendijas.

@ -26,6 +26,7 @@
<dialog
bind:this={dialogo}
class="modal-screen"
aria-labelledby="titulo-busqueda-modal"
aria-describedby="descripcion-busqueda-modal"
onclose={() => busquedaModal.cerrar()}
@ -33,7 +34,7 @@
if (evento.target === dialogo) busquedaModal.cerrar();
}}
>
<div class="panel">
<div class="panel modal-surface">
<h2 id="titulo-busqueda-modal">Buscar en Senza Paura</h2>
<p id="descripcion-busqueda-modal">
Música, historias y recursos. Encuentra lo que buscas en todo el sitio.
@ -43,7 +44,7 @@
{/if}
<button
type="button"
class="cerrar"
class="cerrar modal-close"
aria-label="Cerrar búsqueda"
onclick={() => busquedaModal.cerrar()}
>
@ -53,35 +54,10 @@
</dialog>
<style>
dialog {
width: 100%;
max-width: 100%;
height: 100%;
max-height: 100%;
margin: 0;
padding: var(--sp-4);
border: 0;
background: transparent;
color: var(--c-text);
overflow: auto;
place-items: center;
}
dialog[open] {
display: grid;
}
dialog::backdrop {
background: rgb(3 8 20 / 72%);
backdrop-filter: blur(4px);
}
.panel {
position: relative;
width: 100%;
max-width: 44rem;
min-width: 0;
padding: var(--sp-6);
border-radius: var(--radius-tarjeta);
background: var(--c-bg-raised);
box-shadow: 0 24px 64px rgb(0 0 0 / 35%);
}
h2 {
margin: 0;
@ -93,23 +69,6 @@
max-width: 52ch;
color: var(--c-text-muted);
}
.cerrar {
position: absolute;
top: var(--sp-2);
right: var(--sp-2);
display: grid;
place-items: center;
width: 2.75rem;
height: 2.75rem;
padding: 0;
border: 0;
background: transparent;
color: var(--c-text-muted);
cursor: pointer;
}
.cerrar:hover {
color: var(--c-text);
}
@media (max-width: 34rem) {
dialog {
padding: var(--sp-3);

@ -21,7 +21,7 @@
<dialog
bind:this={dialogo}
class="contacto-modal"
class="contacto-modal modal-screen"
aria-labelledby="titulo-contacto-modal"
aria-describedby="descripcion-contacto-modal"
onclose={() => contactoModal.cerrar()}
@ -29,7 +29,7 @@
if (evento.target === dialogo) contactoModal.cerrar();
}}
>
<div class="marco">
<div class="marco modal-surface">
<div class="ilustracion" aria-hidden="true">
<img
src={imagen.src}
@ -59,7 +59,7 @@
<button
type="button"
class="cerrar"
class="cerrar modal-close"
onclick={() => contactoModal.cerrar()}
aria-label="Cerrar contacto"
>
@ -70,38 +70,11 @@
</dialog>
<style>
.contacto-modal {
width: 100%;
max-width: 100%;
height: 100%;
max-height: 100%;
margin: 0;
padding: var(--sp-4);
border: none;
background: transparent;
overflow: auto;
place-items: center;
}
.contacto-modal[open] {
display: grid;
}
.contacto-modal::backdrop {
background: rgb(3 8 20 / 72%);
backdrop-filter: blur(4px);
}
.marco {
display: grid;
grid-template-columns: 20rem minmax(0, 30rem);
width: 100%;
max-width: 50rem;
overflow: hidden;
border: 1px solid var(--c-border);
border-radius: var(--radius-tarjeta);
background: var(--c-bg-raised);
box-shadow: 0 24px 64px rgb(0 0 0 / 45%);
}
.ilustracion {
@ -126,8 +99,8 @@
padding: var(--sp-5);
background: linear-gradient(
to top,
color-mix(in oklab, #030814 88%, transparent) 0%,
color-mix(in oklab, #030814 40%, transparent) 45%,
color-mix(in oklab, var(--c-scrim-base) 88%, transparent) 0%,
color-mix(in oklab, var(--c-scrim-base) 40%, transparent) 45%,
transparent 75%
);
}
@ -136,7 +109,7 @@
margin: 0;
font-family: var(--font-display);
font-size: var(--fs-xl);
background: var(--oro);
background: var(--gradiente-oro);
background-clip: text;
-webkit-background-clip: text;
color: transparent;
@ -144,7 +117,7 @@
.lema {
margin: 0;
color: rgb(255 255 255 / 78%);
color: var(--c-text-on-media-muted);
font-size: var(--fs-sm);
line-height: 1.5;
}
@ -167,28 +140,6 @@
line-height: 1.5;
}
.cerrar {
position: absolute;
top: var(--sp-3);
right: var(--sp-3);
display: inline-flex;
align-items: center;
justify-content: center;
width: 2.25rem;
height: 2.25rem;
padding: 0;
border: none;
border-radius: var(--radius-boton);
background: transparent;
color: var(--c-text-muted);
cursor: pointer;
}
.cerrar:hover,
.cerrar:focus-visible {
color: var(--c-text);
}
@media (max-width: 50rem) {
.marco {
grid-template-columns: minmax(0, 1fr);

@ -305,7 +305,7 @@ void main() {
height: 100%;
opacity: 0;
/* Entra suave: aparecer de golpe al hidratar se ve como un parpadeo. */
transition: opacity 0.8s var(--suave);
transition: opacity 0.8s var(--ease-standard);
}
canvas.activo {

@ -173,6 +173,7 @@
.pestana {
position: relative;
min-height: var(--control-height);
padding: var(--sp-2) var(--sp-3);
border: none;
background: none;
@ -205,7 +206,7 @@
height: 2px;
background: var(--c-accent);
transform: scaleX(0);
transition: transform 0.2s ease;
transition: transform var(--duration-normal) var(--ease-emphasized);
}
.pestana[aria-selected='true']::after {
@ -219,7 +220,7 @@
/* Al dejar de estar oculto, el panel entra con una transición corta. */
.panel:not([hidden]) {
animation: entrar-panel 0.25s ease both;
animation: entrar-panel 0.25s var(--ease-emphasized) both;
}
@keyframes entrar-panel {

@ -37,7 +37,7 @@
table :global(thead th) {
padding: 0;
background: var(--c-bg);
font-weight: 500;
font-weight: var(--font-weight-medium);
border-bottom: 1px solid var(--c-border);
}
/* Separadores dentro de las tablas, nunca un cierre bajo la última fila. */
@ -45,7 +45,7 @@
border-bottom: 1px solid var(--c-border);
}
table :global(tbody th) {
font-weight: 400;
font-weight: var(--font-weight-regular);
}
table :global(tbody th),
table :global(tbody td) {
@ -70,7 +70,7 @@
color: var(--c-text-muted);
font: inherit;
font-size: var(--fs-xs);
font-weight: 500;
font-weight: var(--font-weight-medium);
text-transform: uppercase;
letter-spacing: 0.08em;
}
@ -119,12 +119,12 @@
text-decoration: underline;
}
table :global(.titulo) {
font-size: 1rem;
font-weight: 400;
font-size: var(--fs-base);
font-weight: var(--font-weight-regular);
color: var(--c-text);
}
table :global(tr.sonando .titulo) {
font-weight: 600;
font-weight: var(--font-weight-semibold);
}
@container tabla-catalogo (max-width: 42rem) {
table :global(.mini) {

@ -171,14 +171,15 @@
* Los botones compactos reservan espacio alrededor del icono. Dentro de la
* tarjeta ese aire empujaba el dibujo hacia el centro aunque el grupo ya
* estuviera a la izquierda. Conservan un blanco pulsable de 36px, pero el
* SVG empieza en el mismo eje que el título y la valoración.
* SVG empieza en el mismo eje que el título y la valoración. El blanco
* pulsable conserva el mínimo táctil común.
*/
.acciones-personales :global(.boton.compacto),
.acciones-personales :global(.entrar),
.acciones-personales :global(summary) {
justify-content: flex-start;
width: 2.25rem;
min-height: 2.25rem;
width: var(--control-height);
min-height: var(--control-height);
padding: 0;
}

@ -148,7 +148,7 @@
font-size: 1.25rem;
line-height: 1;
color: var(--c-border);
transition: color 0.15s ease;
transition: color var(--duration-fast) var(--ease-standard);
}
.estrellas label.llena {

@ -92,7 +92,7 @@
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.4s ease;
transition: transform var(--duration-slow) var(--ease-emphasized);
}
.fondo {

@ -279,7 +279,7 @@
padding: 0 0.4em;
border: 1px solid var(--c-border);
border-radius: var(--radius-boton);
font-size: 0.65rem;
font-size: var(--fs-xs);
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--c-text-muted);
@ -335,10 +335,10 @@
display: inline-flex;
align-items: center;
justify-content: center;
width: 2.25rem;
height: 2.25rem;
width: var(--control-height);
height: var(--control-height);
border: none;
border-radius: 999px;
border-radius: var(--radius-round);
color: var(--c-text-muted);
cursor: pointer;
}
@ -387,8 +387,8 @@
position: absolute;
right: 0.15rem;
bottom: 0.1rem;
font-size: 0.6rem;
font-weight: 700;
font-size: var(--fs-xs);
font-weight: var(--font-weight-semibold);
line-height: 1;
}

@ -189,20 +189,6 @@
padding-bottom: var(--sp-6);
}
.ficha.heroe {
margin-bottom: var(--sp-4);
padding: clamp(var(--sp-4), 4vw, var(--sp-6));
border: 1px solid var(--c-border);
border-radius: var(--radius-tarjeta);
background:
radial-gradient(
circle at 12% 30%,
color-mix(in oklab, var(--c-accent) 12%, transparent),
transparent 28rem
),
var(--c-bg-raised);
}
.ficha.compacta {
grid-template-columns: minmax(7rem, 11rem) minmax(0, 1fr);
align-items: center;

@ -285,8 +285,8 @@
white-space: nowrap;
cursor: pointer;
transition:
background-color 0.2s var(--suave),
color 0.2s var(--suave);
background-color 0.2s var(--ease-standard),
color 0.2s var(--ease-standard);
}
/* Ver el comentario de la misma barra en la página de música. */

@ -418,7 +418,7 @@
font-family: var(--font-display);
font-size: 1.05rem;
line-height: 1.2;
transition: color 0.2s var(--suave);
transition: color 0.2s var(--ease-standard);
}
.ver-blog {
@ -462,12 +462,12 @@
line-height: 1.3;
text-decoration: none;
transition:
border-color 0.2s var(--suave),
transform 0.25s var(--sal);
border-color 0.2s var(--ease-standard),
transform 0.25s var(--ease-emphasized);
}
.vecina .muted {
font-family: var(--font-ui);
font-family: var(--font-body);
font-size: var(--fs-sm);
}

@ -448,7 +448,7 @@
height: 48px;
border: 1px solid var(--c-border);
border-radius: 3px;
font-weight: 700;
font-weight: var(--font-weight-semibold);
color: var(--c-text-muted);
}

@ -123,7 +123,7 @@
margin: 0;
font-family: var(--font-display);
font-size: var(--fs-xl);
background: var(--oro);
background: var(--gradiente-oro);
background-clip: text;
-webkit-background-clip: text;
color: transparent;

@ -399,7 +399,7 @@
*/
.materia-de {
display: block;
font-family: var(--font-sans);
font-family: var(--font-body);
font-size: var(--fs-sm);
color: var(--c-text-muted);
}

@ -257,7 +257,7 @@
margin: 0;
color: var(--c-accent);
font-size: var(--fs-xs);
font-weight: 650;
font-weight: var(--font-weight-semibold);
letter-spacing: 0.12em;
text-transform: uppercase;
}
@ -335,7 +335,7 @@
}
.totales strong {
font-family: var(--font-sans);
font-family: var(--font-body);
font-size: var(--fs-2xl);
line-height: 1;
}

@ -436,7 +436,7 @@
margin-top: var(--sp-5) !important;
color: rgb(255 255 255 / 0.82);
font-size: var(--fs-lg);
font-weight: 300;
font-weight: var(--font-weight-regular);
}
.acceso {
@ -513,7 +513,7 @@
.grupo legend {
padding: 0;
font-weight: 650;
font-weight: var(--font-weight-semibold);
}
.ayuda {
@ -529,7 +529,7 @@
}
.opciones button {
min-height: 2.25rem;
min-height: var(--control-height);
padding: 0.35rem 0.8rem;
border: 1px solid var(--c-border);
border-radius: var(--radius-boton);
@ -539,9 +539,9 @@
font-size: var(--fs-sm);
cursor: pointer;
transition:
border-color 0.2s var(--suave),
background-color 0.2s var(--suave),
color 0.2s var(--suave);
border-color 0.2s var(--ease-standard),
background-color 0.2s var(--ease-standard),
color 0.2s var(--ease-standard);
}
.opciones button:hover,

@ -11,13 +11,17 @@
* En pantalla estrecha no se esconde: sube arriba en horizontal. Es la
* navegación de la sección, y sin ella la página se queda sin salidas.
*/
import { resolve } from '$app/paths';
import { afterNavigate } from '$app/navigation';
import { page } from '$app/state';
import { grabacionesUnicas } from '$lib/catalogo/consultas';
import FiltrosMusica from '$lib/components/FiltrosMusica.svelte';
import Icono from '$lib/components/Icono.svelte';
import { IDIOMAS_CATALOGO, idiomaBase } from '$lib/idiomas';
import { SvelteURLSearchParams } from 'svelte/reactivity';
let { data, children } = $props();
let dialogoFiltros = $state<HTMLDialogElement | null>(null);
let filtrosAbiertos = $state(false);
/** El estilo abierto o elegido como filtro; en el catálogo completo, ninguno. */
const actual = $derived(page.url.pathname.split('/')[2]);
@ -25,6 +29,9 @@
const etiquetaActual = $derived(page.url.searchParams.get('etiqueta') ?? '');
const idiomaActual = $derived(page.url.searchParams.get('idioma') ?? '');
const vistaActual = $derived(page.url.searchParams.get('vista') ?? '');
const totalFiltrosActivos = $derived(
Number(Boolean(estiloActual)) + Number(Boolean(etiquetaActual)) + Number(Boolean(idiomaActual))
);
const canciones = $derived(
grabacionesUnicas(
data.bloques.flatMap((bloque) => bloque.albumes.flatMap((album) => album.canciones))
@ -48,135 +55,92 @@
const consulta = parametros.toString();
return consulta ? `/musica?${consulta}` : '/musica';
}
function abrirFiltros() {
if (!dialogoFiltros || dialogoFiltros.open) return;
filtrosAbiertos = true;
dialogoFiltros.showModal();
}
function cerrarFiltros() {
filtrosAbiertos = false;
if (dialogoFiltros?.open) dialogoFiltros.close();
}
afterNavigate(cerrarFiltros);
</script>
<div class="container musica">
<aside class="lateral">
<nav aria-label="Ver catálogo">
<p class="rotulo" id="vistas-catalogo">Catálogo</p>
<ul aria-labelledby="vistas-catalogo">
<li>
<a
href={resolve(rutaDeCatalogo())}
aria-current={!actual &&
!vistaActual &&
!estiloActual &&
!etiquetaActual &&
!idiomaActual
? 'page'
: undefined}
>
<span>Todo el catálogo</span>
</a>
</li>
<li>
<a
href={resolve(
rutaDeCatalogo({
vista: 'albumes',
estilo: estiloActual,
etiqueta: etiquetaActual,
idioma: idiomaActual
})
)}
aria-current={vistaActual === 'albumes' ? 'page' : undefined}
>
<span>Todos los álbumes</span>
</a>
</li>
<li>
<a
href={resolve(
rutaDeCatalogo({
vista: 'canciones',
estilo: estiloActual,
etiqueta: etiquetaActual,
idioma: idiomaActual
})
)}
aria-current={vistaActual === 'canciones' ? 'page' : undefined}
>
<span>Todas las canciones</span>
</a>
</li>
</ul>
</nav>
<nav aria-label="Estilos">
<p class="rotulo" id="lista-estilos">Estilos</p>
<ul aria-labelledby="lista-estilos">
{#each data.bloques as bloque (bloque.estilo.slug)}
<li>
<a
href={resolve(
rutaDeCatalogo({
vista: vistaActual,
estilo: bloque.estilo.slug,
etiqueta: etiquetaActual,
idioma: idiomaActual
})
)}
aria-current={bloque.estilo.slug === estiloActual ? 'page' : undefined}
title="Filtrar por {bloque.estilo.nombre}: {bloque.albumes.length} {bloque.albumes
.length === 1
? 'lanzamiento'
: 'lanzamientos'}"
>
<span>{bloque.estilo.nombre}</span>
<span class="cuenta muted">{bloque.albumes.length}</span>
</a>
</li>
{/each}
</ul>
</nav>
<nav aria-label="Idiomas">
<p class="rotulo" id="lista-idiomas">Idiomas</p>
<ul class="lista-idiomas" aria-labelledby="lista-idiomas">
<li class="todos-idiomas">
<a
href={resolve(
rutaDeCatalogo({
vista: vistaActual,
estilo: estiloActual,
etiqueta: etiquetaActual
})
)}
aria-current={!idiomaActual ? 'page' : undefined}
>
<span>Todos los idiomas</span>
</a>
</li>
{#each idiomas as idioma (idioma.codigo)}
<li>
<a
href={resolve(
rutaDeCatalogo({
vista: vistaActual,
estilo: estiloActual,
etiqueta: etiquetaActual,
idioma: idioma.codigo
})
)}
aria-current={idioma.codigo === idiomaActual ? 'page' : undefined}
title="{idioma.total} {idioma.total === 1
? 'canción'
: 'canciones'} en {idioma.nombre}"
>
<span>{idioma.nombre}</span>
<span class="cuenta muted">{idioma.total}</span>
</a>
</li>
{/each}
</ul>
</nav>
<FiltrosMusica
prefijo="escritorio"
bloques={data.bloques}
{idiomas}
{estiloActual}
{etiquetaActual}
{idiomaActual}
{vistaActual}
{rutaDeCatalogo}
/>
</aside>
<div class="principal">
<button
type="button"
class="abrir-filtros"
aria-haspopup="dialog"
aria-controls="filtros-musica"
aria-expanded={filtrosAbiertos}
onclick={abrirFiltros}
>
<Icono nombre="filtros" tamano={24} />
<span>Filtros</span>
{#if totalFiltrosActivos > 0}
<span class="contador-filtros" aria-label={`${totalFiltrosActivos} filtros activos`}>
{totalFiltrosActivos}
</span>
{/if}
</button>
{@render children()}
</div>
</div>
<dialog
id="filtros-musica"
class="dialogo-filtros"
bind:this={dialogoFiltros}
aria-labelledby="titulo-filtros-musica"
onclose={cerrarFiltros}
onclick={(evento) => {
if (evento.target === dialogoFiltros) cerrarFiltros();
}}
>
<div class="panel-filtros">
<header class="cabecera-filtros">
<h2 id="titulo-filtros-musica">Filtrar música</h2>
<button
type="button"
class="cerrar-filtros"
onclick={cerrarFiltros}
aria-label="Cerrar filtros"
>
<Icono nombre="cerrar" tamano={24} />
</button>
</header>
<FiltrosMusica
prefijo="movil"
bloques={data.bloques}
{idiomas}
{estiloActual}
{etiquetaActual}
{idiomaActual}
{vistaActual}
{rutaDeCatalogo}
movil
/>
</div>
</dialog>
<style>
.musica {
display: grid;
@ -233,123 +197,117 @@
min-width: 0;
}
/*
* Los rótulos no son encabezados a propósito: repetirían los títulos de la
* página y el esquema diría dos veces lo mismo. Nombran su lista con
* `aria-labelledby`, que es lo que hacía falta.
*/
.rotulo {
margin: 0 0 var(--sp-3);
padding-bottom: var(--sp-3);
border-bottom: 1px solid var(--c-border);
font-size: var(--fs-xs);
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--c-text-muted);
.abrir-filtros {
display: none;
align-items: center;
justify-content: center;
gap: var(--sp-2);
width: 100%;
min-height: var(--control-height-touch);
margin-top: var(--sp-5);
padding: var(--sp-2) var(--sp-4);
border: 1px solid var(--c-border);
border-radius: var(--radius-boton);
background: transparent;
font-size: var(--fs-base);
font-weight: var(--font-weight-medium);
cursor: pointer;
}
.lateral ul {
list-style: none;
margin: 0;
padding: 0;
.abrir-filtros:hover,
.abrir-filtros:focus-visible {
border-color: var(--c-accent);
color: var(--c-accent);
}
.lateral a {
display: flex;
justify-content: space-between;
gap: var(--sp-2);
padding: var(--sp-1) 0;
color: var(--c-text-muted);
text-decoration: none;
/*
* El borde de la izquierda está siempre, transparente: si apareciera solo
* en el estilo abierto, el texto se desplazaría al cambiar de página.
*/
border-left: 2px solid transparent;
padding-left: var(--sp-3);
padding-block: 0.375rem;
margin-left: calc(var(--sp-3) * -1);
transition: color 0.2s ease;
.contador-filtros {
display: inline-grid;
place-items: center;
min-width: 1.5rem;
height: 1.5rem;
padding-inline: var(--sp-1);
border-radius: var(--radius-round);
background: var(--c-accent-superficie);
color: var(--c-accent-text);
font-family: var(--font-mono);
font-size: var(--fs-xs);
}
.lateral a:hover {
.dialogo-filtros {
width: min(100%, 28rem);
max-width: 28rem;
height: 100vh;
height: 100dvh;
max-height: 100vh;
max-height: 100dvh;
margin: 0 0 0 auto;
padding: 0;
border: 0;
background: var(--c-bg);
color: var(--c-text);
overflow-y: auto;
box-shadow: var(--shadow-modal);
}
.lateral a[aria-current='page'] {
color: var(--c-accent);
font-weight: 600;
border-left-color: var(--c-accent);
.dialogo-filtros::backdrop {
background: var(--c-overlay);
backdrop-filter: blur(4px);
}
.cuenta {
font-family: var(--font-mono);
font-size: var(--fs-xs);
.panel-filtros {
min-height: 100%;
padding: 0 var(--sp-5) var(--sp-7);
}
.lateral .lista-idiomas {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
column-gap: var(--sp-3);
.cabecera-filtros {
position: sticky;
top: 0;
z-index: 1;
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--sp-4);
margin-bottom: var(--sp-5);
padding: var(--sp-4) 0;
background: var(--c-bg);
}
/*
* En dos columnas, el filete lateral del elemento activo parecía una línea
* divisoria suelta en mitad de la lista. El idioma elegido se distingue por
* color y peso sobre su propio texto, sin introducir otra geometría.
*/
.lista-idiomas a {
margin-left: 0;
padding-left: 0;
border-left: 0;
.cabecera-filtros h2 {
margin: 0;
font-size: var(--fs-xl);
}
.lista-idiomas .todos-idiomas {
grid-column: 1 / -1;
.cerrar-filtros {
display: inline-grid;
place-items: center;
width: var(--control-height-touch);
height: var(--control-height-touch);
padding: 0;
border: 0;
border-radius: var(--radius-round);
background: transparent;
color: var(--c-text-muted);
cursor: pointer;
}
.cerrar-filtros:hover,
.cerrar-filtros:focus-visible {
background: var(--c-bg-raised);
color: var(--c-text);
}
@media (max-width: 60rem) {
.musica {
grid-template-columns: minmax(0, 1fr);
gap: 0;
}
.lateral {
position: static;
max-height: none;
overflow: visible;
padding: var(--sp-5) 0 0;
scrollbar-gutter: auto;
}
.lateral ul {
display: flex;
flex-wrap: wrap;
gap: var(--sp-2) var(--sp-4);
}
.lateral a {
border-left: none;
padding-left: 0;
margin-left: 0;
border-bottom: 2px solid transparent;
display: none;
}
.lateral a[aria-current='page'] {
border-bottom-color: var(--c-accent);
}
.lateral .lista-idiomas {
.abrir-filtros {
display: flex;
}
.lateral .lista-idiomas a[aria-current='page'] {
border-bottom-color: transparent;
}
}
@media (prefers-reduced-motion: reduce) {
.lateral a {
transition: none;
}
}
</style>

@ -160,8 +160,8 @@
<header class="intro">
<h1>Música</h1>
<p class="entradilla">
Todos los álbumes y canciones del catálogo. Usa los estilos o idiomas de la
izquierda y el desplegable de etiquetas del buscador para acotar la colección.
Todos los álbumes y canciones del catálogo. Usa los estilos o idiomas de la izquierda y el
desplegable de etiquetas del buscador para acotar la colección.
</p>
<p><a href={resolve('/interpretes')}>Conocer a los intérpretes →</a></p>
</header>
@ -321,7 +321,7 @@
.entradilla {
max-width: 56ch;
font-size: var(--fs-lg);
font-weight: 300;
font-weight: var(--font-weight-regular);
}
/*
@ -393,8 +393,8 @@
white-space: nowrap;
cursor: pointer;
transition:
background-color 0.2s var(--suave),
color 0.2s var(--suave);
background-color 0.2s var(--ease-standard),
color 0.2s var(--ease-standard);
}
/*

@ -178,7 +178,7 @@
margin-bottom: 0;
color: var(--c-text-muted);
font-size: var(--fs-lg);
font-weight: 300;
font-weight: var(--font-weight-regular);
}
.planes,
@ -274,9 +274,9 @@
0 1.5rem 3rem -2.25rem color-mix(in oklab, var(--c-text) 45%, transparent),
inset 0 1px 0 color-mix(in oklab, white 10%, transparent);
transition:
transform 0.35s var(--sal),
border-color 0.25s var(--suave),
box-shadow 0.35s var(--sal);
transform 0.35s var(--ease-emphasized),
border-color 0.25s var(--ease-standard),
box-shadow 0.35s var(--ease-emphasized);
}
.plan::before {
@ -323,7 +323,7 @@
margin: 0;
color: var(--tono-plan);
font-size: var(--fs-xs);
font-weight: 700;
font-weight: var(--font-weight-semibold);
letter-spacing: 0.12em;
text-transform: uppercase;
}
@ -378,7 +378,7 @@
.precio strong {
font-family: var(--font-body);
font-size: clamp(2.25rem, 4vw, 3rem);
font-weight: 650;
font-weight: var(--font-weight-semibold);
font-variant-numeric: tabular-nums;
letter-spacing: -0.045em;
line-height: 1;
@ -415,7 +415,7 @@
.prestaciones li::before {
color: var(--tono-plan);
content: '✓';
font-weight: 700;
font-weight: var(--font-weight-semibold);
}
.plan > .btn {

@ -250,7 +250,7 @@
color: inherit;
font-size: var(--boton-texto);
cursor: pointer;
transition: border-color 0.3s var(--suave);
transition: border-color 0.3s var(--ease-standard);
}
.poner:hover,

@ -65,7 +65,7 @@
margin: 0;
font-size: clamp(2.5rem, 9vw, 4.5rem);
line-height: 1.05;
background: var(--oro);
background: var(--gradiente-oro);
background-clip: text;
-webkit-background-clip: text;
color: transparent;

@ -0,0 +1,41 @@
/*
* Contrato visual compartido por la web pública, el panel y Estudio.
*
* Estos tokens describen funciones, no páginas concretas. Un componente puede
* elegir entre ellos, pero no debe inventar otra escala para resolver el mismo
* papel. Los colores base y las familias pertenecen a `marca.css`; aquí viven
* tipografía, interacción, geometría, movimiento y elevación.
*/
:root {
--font-weight-regular: 400;
--font-weight-medium: 500;
--font-weight-semibold: 600;
--leading-heading: 1.1;
--leading-ui: 1.35;
--leading-body: 1.65;
--control-height-compact: 2.25rem;
--control-height: 2.75rem;
--control-height-touch: 3rem;
--icon-size-sm: 1.25rem;
--icon-size-md: 1.5rem;
--icon-size-lg: 1.75rem;
--radius-detail: 4px;
--radius-overlay: 12px;
--radius-round: 999px;
--duration-fast: 150ms;
--duration-normal: 200ms;
--duration-slow: 400ms;
--ease-standard: cubic-bezier(0.4, 0, 0.2, 1);
--ease-emphasized: cubic-bezier(0.16, 1, 0.3, 1);
--c-overlay: rgb(3 8 20 / 72%);
--c-scrim-base: rgb(3 8 20);
--c-text-on-media: oklch(0.97 0.01 255);
--c-text-on-media-muted: rgb(255 255 255 / 78%);
--shadow-popover: 0 0.75rem 2rem rgb(0 0 0 / 35%);
--shadow-modal: 0 1.5rem 4rem rgb(0 0 0 / 45%);
}
Loading…
Cancel
Save

Powered by TurnKey Linux.