@@ -260,7 +278,8 @@
border-top: 1px solid var(--c-border);
}
.barra.con-jukebox {
- grid-template-columns: minmax(0, 1fr) minmax(16rem, 1.2fr) minmax(0, 1.8fr);
+ /* Los laterales iguales mantienen el transporte en el centro de la ventana. */
+ grid-template-columns: minmax(0, 1fr) minmax(16rem, 1.2fr) minmax(0, 1fr);
}
.barra.en-espera {
grid-template-columns: minmax(0, 1fr);
@@ -345,6 +364,7 @@
.control {
position: relative;
+ flex: none;
display: inline-flex;
align-items: center;
justify-content: center;
@@ -416,6 +436,28 @@
.herramientas-audio {
flex: none;
}
+ .acciones-cancion {
+ display: flex;
+ align-items: center;
+ gap: var(--sp-1);
+ }
+ .acciones-cancion :global(.boton.compacto),
+ .acciones-cancion :global(.entrar),
+ .acciones-cancion :global(summary) {
+ width: var(--control-height);
+ min-width: var(--control-height);
+ height: var(--control-height);
+ min-height: var(--control-height);
+ padding: 0;
+ }
+ .estado-programacion {
+ display: flex;
+ min-width: 0;
+ justify-content: center;
+ }
+ .en-espera .estado-programacion {
+ grid-row: auto;
+ }
.con-jukebox .herramientas-audio {
padding-left: var(--sp-2);
}
@@ -432,13 +474,26 @@
* En movil el transporte es lo unico imprescindible: la cola y el volumen
* se resuelven con los controles del propio sistema.
*/
- @media (max-width: 75rem) {
- .barra.con-jukebox {
- grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
+ @media (max-width: 90rem) {
+ .barra:not(.con-jukebox) .extras {
+ grid-column: 1 / -1;
+ grid-row: 2;
+ justify-self: center;
}
.con-jukebox .extras {
+ display: contents;
+ }
+ .estado-programacion {
+ grid-column: 1 / -1;
+ grid-row: 2;
+ justify-self: center;
+ width: min(100%, 36rem);
+ }
+ .con-jukebox .herramientas-audio {
grid-column: 1 / -1;
- justify-content: flex-start;
+ grid-row: 3;
+ justify-self: center;
+ padding-left: 0;
}
.en-espera .extras {
border: 0;
@@ -453,28 +508,26 @@
padding: var(--sp-2) var(--sp-3);
}
- .barra:not(.con-jukebox) .extras,
+ .extras,
.herramientas-audio {
- display: none;
+ justify-content: center;
}
- .barra.con-jukebox {
- grid-template-columns: minmax(0, 1fr) auto;
+ .herramientas-audio {
+ grid-column: 1;
+ grid-row: auto;
+ }
+ .cola-control,
+ .volumen-control {
+ display: none;
}
.con-jukebox .ahora {
- grid-column: 1;
- grid-row: 1;
gap: var(--sp-2);
}
.con-jukebox .portada {
width: 40px;
height: 40px;
}
- .con-jukebox .centro {
- display: contents;
- }
.con-jukebox .controles {
- grid-column: 2;
- grid-row: 1;
gap: var(--sp-1);
}
.con-jukebox .control {
@@ -485,9 +538,6 @@
width: var(--control-height);
height: var(--control-height);
}
- .con-jukebox .centro :global(.progreso) {
- grid-column: 1 / -1;
- }
.con-jukebox .contexto {
display: block;
}
@@ -501,5 +551,8 @@
.barra.en-espera {
grid-template-columns: minmax(0, 1fr);
}
+ .estado-programacion {
+ grid-row: auto;
+ }
}
diff --git a/src/lib/components/player/ControlJukebox.svelte b/src/lib/components/player/ControlJukebox.svelte
index f96c878..c47f7b7 100644
--- a/src/lib/components/player/ControlJukebox.svelte
+++ b/src/lib/components/player/ControlJukebox.svelte
@@ -1,7 +1,8 @@
{#if reproductor.programacion}
@@ -36,8 +37,8 @@
{#if !reproductor.programacionDetenida}
{/if}
diff --git a/src/lib/components/player/ControlVolumen.svelte b/src/lib/components/player/ControlVolumen.svelte
index 190bead..29564ca 100644
--- a/src/lib/components/player/ControlVolumen.svelte
+++ b/src/lib/components/player/ControlVolumen.svelte
@@ -1,7 +1,8 @@
+
+
diff --git a/src/routes/(sitio)/__diseno/jukebox/+page.server.ts b/src/routes/(sitio)/__diseno/jukebox/+page.server.ts
new file mode 100644
index 0000000..7a62296
--- /dev/null
+++ b/src/routes/(sitio)/__diseno/jukebox/+page.server.ts
@@ -0,0 +1 @@
+export { load } from '../reproductor/+page.server';
diff --git a/src/routes/(sitio)/__diseno/jukebox/+page.svelte b/src/routes/(sitio)/__diseno/jukebox/+page.svelte
new file mode 100644
index 0000000..5327d60
--- /dev/null
+++ b/src/routes/(sitio)/__diseno/jukebox/+page.svelte
@@ -0,0 +1,77 @@
+
+
+
Prueba del editor de Jukebox
+
+
+ Prueba aislada del editor con el catálogo público. Los controles de prueba no guardan cambios en
+ ninguna cuenta.
+
+
+
+
+
+
+
+
+
+
diff --git a/src/routes/(sitio)/__diseno/reproductor/+page.server.ts b/src/routes/(sitio)/__diseno/reproductor/+page.server.ts
new file mode 100644
index 0000000..fde1754
--- /dev/null
+++ b/src/routes/(sitio)/__diseno/reproductor/+page.server.ts
@@ -0,0 +1,8 @@
+import { dev } from '$app/environment';
+import { error } from '@sveltejs/kit';
+import { leerCatalogo } from '$lib/server/catalogo';
+
+export const load = async () => {
+ if (!dev) error(404, 'No encontrado');
+ return { catalogo: await leerCatalogo() };
+};
diff --git a/src/routes/(sitio)/__diseno/reproductor/+page.svelte b/src/routes/(sitio)/__diseno/reproductor/+page.svelte
new file mode 100644
index 0000000..1ad369b
--- /dev/null
+++ b/src/routes/(sitio)/__diseno/reproductor/+page.svelte
@@ -0,0 +1,104 @@
+
+
+
Comprobación del reproductor
+
+
Comprobación del reproductor
+
+ Instancia aislada en memoria con canciones públicas. No modifica cuentas, suscripciones ni
+ programaciones guardadas.
+
+
+
+
+
+
+
+
{reproductor.estadoJukebox || 'Sin programación'}
+
+
+
+
+
diff --git a/src/routes/(sitio)/canciones/[slug]/+page.server.ts b/src/routes/(sitio)/canciones/[slug]/+page.server.ts
index 7ecf885..f19277b 100644
--- a/src/routes/(sitio)/canciones/[slug]/+page.server.ts
+++ b/src/routes/(sitio)/canciones/[slug]/+page.server.ts
@@ -10,7 +10,9 @@ import {
import { leerCatalogo } from '$lib/server/catalogo';
import { recomendarCanciones } from '$lib/catalogo/recomendaciones';
import { componer } from '$lib/server/markdown';
-import { aPuntuacion, resumenDeCancion, valoracionDe, valorar } from '$lib/server/valoraciones';
+import { accionMeGusta } from '$lib/server/accion-me-gusta';
+import { obtenerArtistaPublico } from '$lib/server/artistas';
+import { alternarSeguimientoArtista, sigueArtista } from '$lib/server/seguimiento-artistas';
import {
alternarCancionEnLista,
crearListaConCancion,
@@ -23,10 +25,11 @@ import {
* universal, que leía el catálogo compilado; ese ya no existe.
*/
export async function load({ params, locals }) {
- const usuarioId = locals.usuario?.id;
-
const cancion = await obtenerCancion(params.slug);
if (!cancion) error(404, 'Ese tema no existe.');
+ const artistaSlug =
+ cancion.interpretes.find((interprete) => interprete.rol === 'principal')?.slug ??
+ cancion.interpretes[0]?.slug;
/*
* El álbum completo hace de contexto de reproducción: darle al play aquí
@@ -37,51 +40,45 @@ export async function load({ params, locals }) {
const hermanas = album?.canciones ?? [cancion];
const posicion = hermanas.findIndex((c) => c.slug === cancion.slug);
- const [valoracion, miValoracion, estilo, portada, letra, catalogo] = await Promise.all([
- resumenDeCancion(params.slug),
- usuarioId ? valoracionDe(usuarioId, params.slug) : undefined,
+ const [estilo, portada, letra, catalogo, artista, siguiendoArtista] = await Promise.all([
obtenerEstilo(cancion.estilo),
portadaDeCancion(cancion),
letraDeCancion(params.slug),
- leerCatalogo()
+ leerCatalogo(),
+ artistaSlug ? obtenerArtistaPublico(artistaSlug) : Promise.resolve(undefined),
+ artistaSlug && locals.usuario
+ ? sigueArtista(locals.usuario.id, artistaSlug)
+ : Promise.resolve(false)
]);
return {
cancion,
album,
estilo,
+ artista,
+ siguiendoArtista,
portada,
hermanas,
anterior: posicion > 0 ? hermanas[posicion - 1] : undefined,
siguiente: posicion < hermanas.length - 1 ? hermanas[posicion + 1] : undefined,
/** La letra, o las notas si el tema declara que no tiene letra. */
letra: componer(letra),
- valoracion,
- miValoracion,
recomendaciones: recomendarCanciones(cancion, catalogo.canciones)
};
}
export const actions = {
- valorar: async ({ params, request, locals }) => {
- // Sin cuenta no hay voto: un voto anonimo no se puede corregir ni contar
- // una sola vez.
- if (!locals.usuario) {
- return fail(401, { valoracion: { error: 'Entra con tu cuenta para valorar.' } });
- }
- const puntuacion = aPuntuacion((await request.formData()).get('puntuacion'));
- if (puntuacion === undefined) {
- return fail(400, { valoracion: { error: 'La valoración va de 1 a 5.' } });
- }
+ meGusta: accionMeGusta,
- const resultado = await valorar(locals.usuario.id, params.slug, puntuacion);
- if (resultado === 'cancion-inexistente') {
- return fail(404, { valoracion: { error: 'Ese tema no existe.' } });
- }
- if (resultado === 'ya-valorada') {
- return fail(409, { valoracion: { error: 'Ya has valorado este tema.' } });
- }
- return { valoracion: { puntuacion } };
+ seguirArtista: async ({ request, locals }) => {
+ if (!locals.usuario)
+ return fail(401, { seguimiento: { error: 'Entra para seguir artistas.' } });
+ const artistaSlug = String((await request.formData()).get('artista') ?? '');
+ if (!artistaSlug) return fail(400, { seguimiento: { error: 'Falta el artista.' } });
+ const siguiendo = await alternarSeguimientoArtista(locals.usuario.id, artistaSlug);
+ if (siguiendo === undefined)
+ return fail(404, { seguimiento: { error: 'Ese artista no existe.' } });
+ return { seguimiento: { siguiendo } };
},
alternarLista: async ({ params, request, locals }) => {
diff --git a/src/routes/(sitio)/canciones/[slug]/+page.svelte b/src/routes/(sitio)/canciones/[slug]/+page.svelte
index c7b657f..43ced53 100644
--- a/src/routes/(sitio)/canciones/[slug]/+page.svelte
+++ b/src/routes/(sitio)/canciones/[slug]/+page.svelte
@@ -1,6 +1,7 @@
-
-
-
- {#if data.artistas.length > 0}
-
-
-
-
Directorio artístico
-
- {artistas.length}
- {artistas.length === 1 ? 'intérprete' : 'intérpretes'}
-
-
+
+
+
+
+
+
+
+
+ {#if data.artistas.length > 0}
+
{#if data.artistas.length > 6}
{/if}
-
-
-
- {#each artistas as artista, i (artista.id)}
- -
-
-
-
-
-
+
+
diff --git a/src/routes/(sitio)/interpretes/[slug]/+page.svelte b/src/routes/(sitio)/interpretes/[slug]/+page.svelte
index f3d4353..9b9ff3a 100644
--- a/src/routes/(sitio)/interpretes/[slug]/+page.svelte
+++ b/src/routes/(sitio)/interpretes/[slug]/+page.svelte
@@ -201,7 +201,8 @@
width: 100%;
height: 100%;
object-fit: cover;
- object-position: center;
+ /* Conserva la zona segura generada sobre la cabeza bajo la cabecera superpuesta. */
+ object-position: center top;
}
.heroe.con-fondo {
@@ -211,8 +212,8 @@
.con-fondo .heroe__interior {
grid-template-columns: minmax(0, 54rem);
align-items: end;
- min-height: 70vh;
- min-height: 70dvh;
+ min-height: 90vh;
+ min-height: 90dvh;
}
.con-fondo .presentacion {
@@ -384,8 +385,9 @@
.albumes {
display: grid;
- grid-template-columns: repeat(2, minmax(0, 1fr));
+ grid-template-columns: repeat(auto-fill, minmax(min(100%, 15rem), 20rem));
gap: var(--sp-5);
+ align-items: start;
}
.ficha-tecnica {
@@ -427,10 +429,6 @@
grid-template-columns: minmax(10rem, 18rem) minmax(0, 1fr);
min-height: 0;
}
-
- .albumes {
- grid-template-columns: minmax(0, 1fr);
- }
}
@media (max-width: 42rem) {
diff --git a/src/routes/(sitio)/juke-box/+page.svelte b/src/routes/(sitio)/juke-box/+page.svelte
index 1b5c7a1..d66aeb1 100644
--- a/src/routes/(sitio)/juke-box/+page.svelte
+++ b/src/routes/(sitio)/juke-box/+page.svelte
@@ -1,247 +1,6 @@
-
{
- if (desplegado && e.target instanceof Node && !selector?.contains(e.target)) desplegado = false;
- }}
- onkeydown={(e) => {
- if (e.key === 'Escape' && desplegado) {
- desplegado = false;
- botonPlay?.focus();
- }
- }}
-/>
-
-
- {#snippet play()}
-
-
- {#if desplegado}
-
- {#if data.jukeboxes.length}
-
- {#each data.jukeboxes as j (j.id)}
- -
-
-
- {/each}
-
- {:else}
-
Crea tu primer jukebox para elegir qué quieres escuchar.
-
Configura tu Jukebox
- {/if}
-
- {/if}
-
- {/snippet}
- {#snippet estado()}
- {#if reproductor.programacion}
-
-
- {reproductor.programacion.nombre}
- {reproductor.estadoJukebox}
-
-
- {#if !reproductor.programacionDetenida}
-
- {/if}
-
- {/if}
- {/snippet}
-
-
-
+
diff --git a/src/routes/(sitio)/juke-box/[id]/programacion/+server.ts b/src/routes/(sitio)/juke-box/[id]/programacion/+server.ts
index a39e68a..e668542 100644
--- a/src/routes/(sitio)/juke-box/[id]/programacion/+server.ts
+++ b/src/routes/(sitio)/juke-box/[id]/programacion/+server.ts
@@ -3,7 +3,12 @@ import { usuarioTieneAccesoJukebox } from '$lib/server/suscripciones';
import { guardarJukebox, leerJukebox, eliminarJukebox } from '$lib/server/jukebox';
import { listasDeUsuario } from '$lib/server/listas-reproduccion';
import { leerCatalogo } from '$lib/server/catalogo';
-import { validarProgramacion, type ProgramacionJukebox } from '$lib/programacion-jukebox';
+import {
+ validarProgramacion,
+ usaEtiquetas,
+ cancionesDeFranja,
+ type ProgramacionJukebox
+} from '$lib/programacion-jukebox';
import type { RequestHandler } from './$types';
const gestionar: RequestHandler = async ({ locals, request, url, params }) => {
if (!locals.usuario) return json({ error: 'Entra para acceder a tus jukebox.' }, { status: 401 });
@@ -61,6 +66,11 @@ const gestionar: RequestHandler = async ({ locals, request, url, params }) => {
leerCatalogo()
]);
for (const [n, franja] of datos.programacion.franjas.entries()) {
+ if (usaEtiquetas(franja) && !cancionesDeFranja(franja, listas, catalogo.canciones).length)
+ return json(
+ { error: `Franja ${n + 1}: no hay canciones con las emociones o etiquetas elegidas.` },
+ { status: 400 }
+ );
for (const fuente of franja.contenidos) {
const existe =
fuente.tipo === 'lista'
diff --git a/src/routes/(sitio)/juke-box/[id]/programacion/jukebox-etiquetas.spec.ts b/src/routes/(sitio)/juke-box/[id]/programacion/jukebox-etiquetas.spec.ts
new file mode 100644
index 0000000..a3a7711
--- /dev/null
+++ b/src/routes/(sitio)/juke-box/[id]/programacion/jukebox-etiquetas.spec.ts
@@ -0,0 +1,81 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import type { ProgramacionJukebox } from '$lib/programacion-jukebox';
+
+const dobles = vi.hoisted(() => ({ guardar: vi.fn(), catalogo: vi.fn() }));
+vi.mock('$lib/server/suscripciones', () => ({
+ usuarioTieneAccesoJukebox: vi.fn(async () => true)
+}));
+vi.mock('$lib/server/jukebox', () => ({
+ guardarJukebox: dobles.guardar,
+ leerJukebox: vi.fn(),
+ eliminarJukebox: vi.fn()
+}));
+vi.mock('$lib/server/listas-reproduccion', () => ({ listasDeUsuario: vi.fn(async () => []) }));
+vi.mock('$lib/server/catalogo', () => ({ leerCatalogo: dobles.catalogo }));
+import { PUT } from './+server';
+
+const programa = (etiquetas: string[]): ProgramacionJukebox => ({
+ zonaHoraria: 'Europe/Madrid',
+ franjas: [
+ {
+ id: 'franja',
+ inicio: '00:00',
+ fin: '00:00',
+ orden: 'aleatoria',
+ catalogo: true,
+ etiquetas,
+ contenidos: []
+ }
+ ]
+});
+async function guardar(programacion: ProgramacionJukebox) {
+ const id = '00000000-0000-4000-8000-000000000001';
+ const url = new URL(`http://localhost/juke-box/${id}/programacion`);
+ return PUT({
+ locals: { usuario: { id: 'cuenta' } },
+ params: { id },
+ url,
+ request: new Request(url, {
+ method: 'PUT',
+ headers: { origin: url.origin, 'content-type': 'application/json' },
+ body: JSON.stringify({ nombre: 'Emociones', descripcion: '', revision: 1, programacion })
+ })
+ } as unknown as Parameters[0]);
+}
+describe('Guardado de franjas por emociones y etiquetas', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ dobles.catalogo.mockResolvedValue({ canciones: [{ slug: 'una', etiquetas: ['Melancolía'] }] });
+ dobles.guardar.mockImplementation(async (_usuario, _id, programacion) => ({
+ programacion,
+ revision: 2
+ }));
+ });
+ it('envía las reglas al almacenamiento y las devuelve, sin convertirlas en canciones fijas', async () => {
+ const p = programa(['MELANCOLIA']);
+ const r = await guardar(p);
+ expect(r.status).toBe(200);
+ expect(await r.json()).toEqual({ programacion: p, revision: 2 });
+ expect(dobles.guardar).toHaveBeenCalledWith(
+ 'cuenta',
+ expect.any(String),
+ p,
+ 1,
+ 'Emociones',
+ ''
+ );
+ });
+ it('rechaza etiquetas sin coincidencias y no guarda un catálogo completo como sustituto', async () => {
+ const r = await guardar(programa(['Deseo']));
+ expect(r.status).toBe(400);
+ expect((await r.json()).error).toContain('no hay canciones');
+ expect(dobles.guardar).not.toHaveBeenCalled();
+ });
+ it('rechaza la selección vacía antes de consultar o guardar el catálogo', async () => {
+ const r = await guardar(programa([]));
+ expect(r.status).toBe(400);
+ expect((await r.json()).error).toContain('Elige al menos');
+ expect(dobles.catalogo).not.toHaveBeenCalled();
+ expect(dobles.guardar).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/routes/(sitio)/juke-box/configurar/+page.svelte b/src/routes/(sitio)/juke-box/configurar/+page.svelte
index bfbb445..c8ceb05 100644
--- a/src/routes/(sitio)/juke-box/configurar/+page.svelte
+++ b/src/routes/(sitio)/juke-box/configurar/+page.svelte
@@ -7,6 +7,7 @@
import Meta from '$lib/components/Meta.svelte';
import NavegacionJukebox from '$lib/components/jukebox/NavegacionJukebox.svelte';
import Icono from '$lib/components/Icono.svelte';
+ import { usaEtiquetas } from '$lib/programacion-jukebox';
let { data } = $props();
const seleccionado = $derived(
@@ -101,7 +102,14 @@
>
- {f.catalogo ? 'Todo el catálogo' : f.pistas.length + ' canciones'}
+ {usaEtiquetas(f)
+ ? 'Emociones y etiquetas: ' + f.etiquetas?.join(', ')
+ : f.catalogo
+ ? 'Todo el catálogo'
+ : f.pistas.length + ' canciones'}
+ {#if usaEtiquetas(f)}{f.pistas.length} canciones{/if}
{f.orden === 'aleatoria' ? 'Aleatoria' : 'Secuencial'}
@@ -116,7 +124,8 @@
Tu primer jukebox
- Elige el catálogo completo o tus listas y canciones, y prepara sus franjas de reproducción.
+ Elige emociones y etiquetas, el catálogo completo o tus listas y canciones, y prepara sus
+ franjas de reproducción.
Crear jukebox
diff --git a/src/routes/(sitio)/musica/+layout.svelte b/src/routes/(sitio)/musica/+layout.svelte
index 9213973..59fbb73 100644
--- a/src/routes/(sitio)/musica/+layout.svelte
+++ b/src/routes/(sitio)/musica/+layout.svelte
@@ -179,7 +179,7 @@
padding: var(--sp-7) var(--sp-5) var(--sp-4) 0;
scrollbar-gutter: stable;
scrollbar-width: thin;
- scrollbar-color: color-mix(in oklab, var(--c-marca-oro) 82%, transparent) transparent;
+ scrollbar-color: var(--c-control-oro) transparent;
overscroll-behavior: contain;
font-size: var(--fs-sm);
}
@@ -204,9 +204,9 @@
border-radius: var(--radius-round);
background: linear-gradient(
180deg,
- var(--c-marca-oro-sombra),
- var(--c-marca-oro-brillo) 48%,
- var(--c-marca-oro)
+ var(--c-control-oro-sombra),
+ var(--c-control-oro-brillo) 48%,
+ var(--c-control-oro)
);
}
diff --git a/src/routes/(sitio)/musica/+page.svelte b/src/routes/(sitio)/musica/+page.svelte
index 8f93b46..4700879 100644
--- a/src/routes/(sitio)/musica/+page.svelte
+++ b/src/routes/(sitio)/musica/+page.svelte
@@ -60,7 +60,11 @@
album.tipo,
String(album.fecha ?? ''),
album.resumen,
- ...album.canciones.flatMap((cancion) => [cancion.titulo, ...cancion.etiquetas])
+ ...album.canciones.flatMap((cancion) => [
+ cancion.titulo,
+ ...cancion.etiquetas,
+ ...cancion.interpretes.map((interprete) => interprete.nombre)
+ ])
].join(' ')
)
}))
@@ -264,8 +268,7 @@
Música
- 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.
+ Álbumes y canciones. Busca por título o filtra por estilo, idioma y etiquetas.
Conocer a los intérpretes →
@@ -455,6 +458,16 @@
}
@media (max-width: 42rem) {
+ .intro {
+ padding-block: var(--sp-5) var(--sp-2);
+ }
+ .intro h1 {
+ font-size: var(--fs-2xl);
+ margin-bottom: var(--sp-3);
+ }
+ .intro .entradilla-pagina {
+ font-size: var(--fs-base);
+ }
.cabecera-seccion {
align-items: start;
flex-direction: column;
diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte
index 94b074c..e82da0c 100644
--- a/src/routes/+layout.svelte
+++ b/src/routes/+layout.svelte
@@ -125,7 +125,7 @@
top: -3rem;
z-index: 60;
padding: var(--sp-2) var(--sp-4);
- background: var(--c-accent);
+ background: var(--c-accent-superficie);
color: var(--c-accent-text);
border-radius: var(--radius-boton);
transition: top var(--duration-fast) var(--ease-standard);
diff --git a/src/tokens.css b/src/tokens.css
index 5173f4d..8ff4197 100644
--- a/src/tokens.css
+++ b/src/tokens.css
@@ -31,6 +31,9 @@
--fs-2xl: 2.125rem;
--fs-3xl: 3rem;
--fs-4xl: clamp(3.25rem, 9vw, 6rem);
+ /* Portada: escala con la columna de texto y tiene límites legibles en rem. */
+ --fs-hero-title: clamp(2.5rem, min(13cqi, calc(1.25rem + 5vw)), 6rem);
+ --fs-hero-lead: clamp(var(--fs-base), calc(0.875rem + 0.5cqi), var(--fs-lg));
--fs-editorial-index: clamp(6rem, 15vw, 11rem);
--fs-editorial-title: clamp(3.25rem, 6vw, 5.25rem);
--fs-editorial-title-compact: clamp(3rem, 14vw, 4rem);
@@ -78,6 +81,25 @@
--control-height-compact: 2.25rem;
--control-height: 2.75rem;
--control-height-touch: 3rem;
+ /* Roles utilitarios: incluyen línea y peso para no depender del ancestro. */
+ --fs-ui-page: 1.75rem;
+ --fs-ui-section: 1.25rem;
+ --fs-ui-panel: 1.125rem;
+ --fs-ui-meta: 0.8125rem;
+ --ui-page-font: var(--font-weight-semibold) var(--fs-ui-page) / 1.2 var(--font-body);
+ --ui-section-font: var(--font-weight-semibold) var(--fs-ui-section) / 1.3 var(--font-body);
+ --ui-panel-font: var(--font-weight-semibold) var(--fs-ui-panel) / 1.35 var(--font-body);
+ --ui-label-font: var(--font-weight-medium) var(--fs-sm) / 1.4 var(--font-body);
+ --ui-meta-font: var(--font-weight-regular) var(--fs-ui-meta) / 1.4 var(--font-body);
+ --ui-input-font: var(--font-weight-regular) var(--fs-base) / 1.5 var(--font-body);
+ --ui-control-font: var(--font-weight-medium) var(--fs-sm) / 1.25rem var(--font-body);
+ --ui-control-line: 1.25rem;
+ --ui-input-padding: calc((var(--control-height) - 1.5rem - 2px) / 2) var(--sp-3);
+ --ui-action: oklch(0.8 0.095 230);
+ --ui-action-hover: oklch(0.86 0.075 230);
+ --ui-action-text: oklch(0.22 0.05 255);
+ --ui-status-success: oklch(0.78 0.12 155);
+ --ui-status-draft: oklch(0.8 0.095 230);
--icon-size-sm: 1.25rem;
--icon-size-md: 1.5rem;
--icon-size-lg: 1.75rem;
diff --git a/static/images/artistas/cupido-sin-flechas/adrian-vega.webp b/static/images/artistas/cupido-sin-flechas/adrian-vega.webp
new file mode 100644
index 0000000..1ee1075
Binary files /dev/null and b/static/images/artistas/cupido-sin-flechas/adrian-vega.webp differ
diff --git a/static/images/artistas/cupido-sin-flechas/bruno-leal.webp b/static/images/artistas/cupido-sin-flechas/bruno-leal.webp
new file mode 100644
index 0000000..4f7f59e
Binary files /dev/null and b/static/images/artistas/cupido-sin-flechas/bruno-leal.webp differ
diff --git a/static/images/artistas/cupido-sin-flechas/lia-valdes.webp b/static/images/artistas/cupido-sin-flechas/lia-valdes.webp
new file mode 100644
index 0000000..b7b2588
Binary files /dev/null and b/static/images/artistas/cupido-sin-flechas/lia-valdes.webp differ
diff --git a/static/images/artistas/cupido-sin-flechas/mara-santel.webp b/static/images/artistas/cupido-sin-flechas/mara-santel.webp
new file mode 100644
index 0000000..b29bccc
Binary files /dev/null and b/static/images/artistas/cupido-sin-flechas/mara-santel.webp differ