Apply shared styles and scheduled Jukebox controls

codex/estilos-web-publica
dev 3 weeks ago
parent 65da577c05
commit 1e24c87043

@ -15,7 +15,7 @@ La aplicación debe comportarse como un único sistema visual, no como una colec
## 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.
La medición procede de `scripts/auditar-estilos.mjs` sobre los archivos CSS, HTML y Svelte de `src`. La auditoría queda disponible mediante `npm run audit:styles`; con `--strict` falla si encuentra variables CSS sin declarar, pesos tipográficos inexistentes o valores que incumplen el contrato visual.
| Indicador | Línea base |
| ------------------------------- | ---------------------------------: |
@ -47,7 +47,7 @@ La mayor deuda está en la fragmentación, no en un único selector: 26 breakpoi
| 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.
En la línea base, Estudio concentraba la mayor deuda: sus 70 tamaños, 27 radios y 161 espaciados eran literales. La migración posterior los asignó por función a la escala compartida, conservando por separado los anchos estructurales de su navegación.
## P0 corregidos
@ -159,35 +159,61 @@ 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 las barras de búsqueda/vista de Música y Blog: completado con
`BarraExploracion`, incluida su adaptación móvil y el estado de foco.
- 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.
Estado: completada para los patrones compartidos identificados.
- Unificar tablas de álbumes y canciones sin borrar sus diferencias semánticas:
completado con `TablaCatalogo`. En el ancho con índice lateral el relleno se
compacta antes de ocultar columnas o partir títulos; las dos tablas conservan
el mismo ancho y superan sus seis pruebas en escritorio, móvil, claro y oscuro.
- Unificar índices laterales, chips, entradillas y barras de filtros: completado.
La base usa `BarraExploracion`, `EtiquetaColor`, `DisposicionConIndice`,
`CampoBusquedaLocal` y `entradilla-pagina`; cada sección conserva únicamente
sus diferencias de contenido, medida o separación.
- Eliminar marcos exteriores de cabeceras, heroes y agrupaciones editoriales:
completado. La inspección computada conserva únicamente separadores,
controles, tarjetas interactivas, obras gráficas y grupos de datos.
- 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.
Estado: completada para la escala de viewport y las rutas cubiertas por la batería responsive.
- Los 26 breakpoints iniciales se han reducido a siete: 30, 34, 42, 48, 60, 64 y 75 rem.
- La escala de viewport está documentada en `src/tokens.css`; la cabecera usa `75rem`
para compactar el escritorio y `60rem` para cambiar al menú superpuesto.
- Las reglas equivalentes de la web, el panel y Estudio comparten ya esos mismos cortes.
- 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.
- La matriz de desbordamiento recorre 27 rutas públicas —incluidos artículo,
perfil, cuenta, listas, favoritos, compra cancelada y 404— en los cuatro anchos.
- La comprobación táctil recorre 13 rutas con controles y exige 44 × 44 px en
móvil; excluye únicamente trampas anti-bot, controles deshabilitados y nodos
retirados del orden de tabulación.
### 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.
Estado: migración de valores y prevención automática completadas; queda repetir la comprobación visual de Estudio con su base de datos disponible.
- La escala tipográfica, espacial y geométrica vive en `src/tokens.css` y la consumen la web, el panel y Estudio.
- Estudio ha pasado de 70 a 0 tamaños tipográficos literales, de 27 a 0 radios, de 161 a 0 espaciados y de 7 a 0 colores literales.
- Botones, campos, menú de cuenta y acciones de secciones de Estudio consumen
las alturas compartidas de 36, 44 y 48 px según su función.
- El panel ha pasado a 0 tamaños, radios, espaciados y colores literales.
- La web pública y sus componentes han pasado a 0 tamaños, espacios y colores literales; los únicos `border-radius: 0` son reseteos y la auditoría ya no los confunde con valores de diseño.
- 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.
- `audit:styles -- --strict` falla si reaparecen literales fuera de la paleta global,
duraciones, curvas, sombras, `!important` o breakpoints ajenos a la escala de siete cortes.
## Matriz QA obligatoria
@ -228,46 +254,85 @@ Después de cada fase se guarda el resultado de `npm run audit:styles -- --json`
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
## Punto de continuidad tras la normalización de escalas
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 auditoría estricta pasa también con 0 infracciones del contrato visual;
- el fallback autónomo de 404/500 en `src/error.html` forma parte ahora de la auditoría y usa el mismo contrato de escala, peso y color;
- 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;
- «Todos los álbumes» y «Todas las canciones» limpian estilo, idioma y etiquetas
tanto en el aside de escritorio como en el diálogo compacto;
- Música y Blog comparten `BarraExploracion` para búsqueda, Tarjetas/Tabla y los
controles adicionales de cada catálogo;
- Música, Blog, fichas y tarjetas comparten `EtiquetaColor`: el tono depende del
nombre y los estados de enlace, selección y dato conservan la misma geometría;
- los filtros y la presentación de Música y Blog, junto con las pestañas de las
canciones, viven en la URL y responden a Atrás/Adelante; las búsquedas locales,
el orden de la tabla, el país de Géneros, la materia del Glosario, el borrador
de Contacto y la configuración del Juke-box se restauran mediante snapshots al
regresar a su página;
- las navegaciones consecutivas de etiquetas se serializan para que una segunda
pulsación rápida parta del estado ya aplicado y no elimine la selección anterior;
- Taller y Géneros comparten `DisposicionConIndice` para la cuadrícula, el índice
fijo de escritorio y su traslado bajo el contenido en móvil; cada sección
conserva su índice semántico propio;
- Géneros, Letristas y Glosario comparten `CampoBusquedaLocal`, con una sola
implementación para etiqueta accesible, enlace reactivo, foco y aspecto;
- once rutas públicas comparten `entradilla-pagina` como jerarquía tipográfica
de introducción, manteniendo localmente solo sus márgenes y medidas especiales;
- 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 |
- `check:sitio`, `check:panel` y `check:estudio` terminan correctamente;
- las 448 pruebas unitarias activas y las 28 pruebas responsive terminan correctamente;
- las 112 pruebas funcionales de Música, Blog, fichas de contenido,
Taller, Juke-box y Contacto terminan correctamente contra el servidor de
desarrollo, incluida la retirada independiente de filtros, la apertura del
acceso como diálogo y la restauración mediante Atrás/Adelante de filtros,
presentación, etiquetas, pestañas, búsquedas, ordenación y borradores; la
protección CSRF se ha comprobado además contra el build de producción;
- la batería funcional de Estudio requiere PostgreSQL; el servidor de pruebas no
puede consultarlo actualmente y falla antes del render, no en una regla de
estilos ni en la interfaz.
Corte cuantitativo actual después de la normalización:
| Indicador | Valor actual |
| ----------------------------------- | -----------: |
| Archivos CSS/Svelte/HTML analizados | 139 |
| Declaraciones `font-size` | 510 |
| Tamaños tipográficos literales | 0 |
| Declaraciones de espaciado | 1447 |
| Espaciados literales relevantes | 0 |
| Declaraciones de borde | 529 |
| Declaraciones `border-radius` | 165 |
| Radios literales | 0 |
| Colores literales | 47 |
| Movimientos literales | 0 |
| Sombras literales | 0 |
| Media queries | 100 |
| Breakpoints distintos | 7 |
| Usos de `!important` | 0 |
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.
1. Repetir la matriz visual de Estudio cuando vuelva a estar disponible PostgreSQL.
2. Ejecutar la batería funcional completa si el siguiente lote modifica lógica,
datos o navegación; los cambios actuales de presentación quedan cubiertos por
las pruebas focalizadas y la matriz responsive.
3. Retirar el Panel heredado cuando Estudio cubra todas sus operaciones, en vez de
compartir componentes y volver a introducir su modelo anterior.
4. Guardar capturas de referencia definitivas antes de cerrar la rama de estilos.
El primer lote responsive supera las 28 pruebas de `e2e/responsive.e2e.ts`, incluida
la matriz de anchos, la navegación, los objetivos táctiles, los filtros, las tablas,
el contraste y los estados de puntero.
Restricciones que deben mantenerse durante la continuación:
@ -276,3 +341,19 @@ Restricciones que deben mantenerse durante la continuación:
- 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.
### Corrección del alcance del Juke-box
La selección automática por filtros se ha sustituido por programación diaria con
franjas horarias y listas/canciones asignadas en orden. La programación se guarda
por cuenta, detecta solapamientos, admite el cruce de medianoche y continúa al
navegar. La interfaz reutiliza los controles y etiquetas del sistema visual.
Ver [operativa y validación de la programación](jukebox-programacion-horaria.md).
Validación realizada: 13 pruebas de dominio/reproductor y 8 pruebas de navegador,
incluida reproducción de audio real a la hora programada, más compilación,
comprobación de tipos y auditoría estricta de estilos.
### Juke-box: separación de pantallas y controles comunes (14 de septiembre)
La reproducción queda en el hero, seguido del pie global; la colección y la edición viven en configuración. Nombre y descripción se guardan por jukebox. El editor permite guardar una franja o todo el jukebox. Play conserva el oro y despliega la colección; los formularios usan navy en claro y celeste en oscuro. GrupoToggle unifica las selecciones exclusivas en una barra con activo verde, con estilos y tokens globales. El subtítulo del hero adapta el tamaño al ancho del título con espaciado natural; los controles quedan más altos y el estado de reproducción comparte su fila.

@ -0,0 +1,75 @@
# Juke-box: reproducción y configuración
Actualizado el 14 de septiembre de 2026.
## Pantallas
- **/juke-box:** hero de 100vh, con el botón dorado «Play JukeBox» y «Configura tu Jukebox» debajo del título. El pie del sitio aparece inmediatamente después del hero. No se muestra la colección ni el editor debajo.
- **/juke-box/configurar:** colección «Mis jukebox», creación, edición y consulta de las programaciones. «Abrir reproductor» vuelve al hero con el jukebox elegido.
- **/juke-box/nuevo** y **/juke-box/[id]/editar:** comparten el editor. Guardar y cancelar vuelven a configuración; guardar no inicia la reproducción.
«Play JukeBox» despliega todos los jukebox con su nombre y descripción. Elegir uno inicia su reproducción. Su nombre, estado y botón Detener se muestran en la misma fila de acciones en escritorio. Si todavía no hay ninguno, el hero indica que se debe crear desde configuración. El acceso anónimo utiliza el popup de cuenta.
La frase «Tu música, a su hora.» queda debajo de Juke-box; su tamaño tipográfico se adapta al ancho del título conservando el espaciado natural entre palabras.
## Uso del editor
Escribir el nombre y la descripción del jukebox en la cabecera del editor.
1. Crear una franja. Se identifica automáticamente como Franja 1, Franja 2, etc.; no hay campo de nombre ni selector de zona horaria.
2. Elegir «Todo el día» o un horario de inicio y fin. Se rechazan solapamientos dentro del mismo jukebox.
3. Elegir «Todo el catálogo» o «Listas y canciones». La selección preparada se conserva al alternar entre ambas opciones.
4. Elegir reproducción secuencial o aleatoria para esa franja. Las canciones y listas seleccionadas se pueden ordenar y retirar.
5. «Guardar franja» persiste la franja seleccionada, el nombre y la descripción y mantiene abierto el editor. No guarda los cambios pendientes de otras franjas. «Guardar jukebox» persiste todos los cambios y vuelve a configuración. Cada registro conserva su revisión independiente.
Cancelar un jukebox nuevo no crea un registro vacío. El historial del navegador conserva el borrador. Se pueden deshacer cambios y recuperar una franja eliminada. La eliminación de un jukebox requiere confirmación dentro del editor.
## Reproducción
Solo un jukebox controla el reproductor de la pestaña a la vez. Editar o guardar otro no lo detiene. Una nueva pulsación de Play aplica la revisión guardada seleccionada.
Las franjas se repiten diariamente. Pueden cruzar medianoche; el inicio está incluido y el final excluido. El contenido se repite hasta terminar la franja, cambia al comenzar la siguiente y queda en espera durante los huecos. Estas reglas se explican en el editor.
En secuencial se respeta el orden visible y el orden interno de las listas. En aleatoria se barajan todas las canciones de la franja, incluida la primera; al comenzar una nueva vuelta se evita repetir inmediatamente la última canción.
La programación y el audio permanecen activos al navegar. Su icono, nombre, estado y acciones se agrupan en la barra del reproductor, además del estado del hero. Si no hay franja activa, queda visible una barra compacta de espera.
Elegir una canción suspende temporalmente el jukebox: conserva pista, segundo, posición y orden de la cola. Al terminar la canción o pulsar su pausa en la web vuelve automáticamente; «Continuar» permite volver inmediatamente. Al reproducir un álbum o lista completos, el retorno automático espera al final de esa selección, salvo que se pulse pausa o «Continuar». Varias selecciones consecutivas conservan el punto de retorno original. La pausa del propio jukebox se mantiene hasta pulsar play; las pausas técnicas, como abrir un vídeo o pausar desde el sistema, no arrancan otro audio.
La vuelta recupera el punto guardado si sigue vigente la misma franja; si ha cambiado, empieza la actual. Sin franja queda en espera. Una franja del día siguiente no recupera un punto antiguo, y las franjas nocturnas se identifican en su zona horaria. Pausar el jukebox impide que el reloj vuelva a arrancarlo: Play valida la franja vigente al reanudar. Si estaba pausado antes de elegir una canción manual, su final o pausa lo devuelve todavía pausado; «Continuar» lo reanuda expresamente. «Detener» cancela el retorno sin cortar la canción manual. Cerrar sesión o perder el acceso detiene la programación y el audio.
«Detener» conserva el jukebox seleccionado y sus controles, con estado «Detenido» y botón «Reproducir». Desarma el reloj y vacía la escucha del jukebox, sin borrar su nombre, descripción ni franjas guardadas. Si suena una selección manual, la deja continuar y cancela el retorno automático. «Reproducir» inicia una nueva sesión según la franja vigente; «Eliminar Jukebox» sigue siendo una acción distinta dentro de configuración.
Configuración, creación y edición comparten una navegación compacta con «Volver al Juke-box». Las migas no acumulan su separación con otro relleno superior de la página.
La web debe permanecer abierta y el dispositivo activo. Recargar requiere pulsar Play de nuevo. La zona horaria se obtiene del dispositivo al crear y queda guardada. Las listas y el catálogo se resuelven al iniciar la sesión de reproducción.
## Persistencia y permisos
La migración 0037_jukeboxes_independientes.sql crea la tabla jukebox y migra las programaciones anteriores no vacías a un primer jukebox por cuenta, conservando horarios y contenidos. No elimina la tabla anterior.
Cada registro tiene ID, propietario, número, nombre, descripción, programación y revisión. Las migraciones 0038 y 0039 añaden nombre y descripción a los registros existentes. El servidor comprueba sesión, prestación Juke-box, pertenencia de listas, canciones válidas, origen de las peticiones y revisión. Una revisión antigua no sobrescribe un guardado posterior. El endpoint anterior devuelve 410 para impedir escrituras desde el editor antiguo.
## Verificación
La suite de navegador usa cuentas suscritas temporales que elimina al terminar. Comprueba creación de dos jukebox, guardados independientes, edición durante reproducción, cancelación, historial, aislamiento de cuentas, selección desde el hero, horario con audio real, solapamientos y eliminación. También verifica Continuar, el retorno tras un final real del audio, el cambio de franja durante la interrupción y el regreso desde configuración y edición.
La revisión visual comprueba el hero a 100vh, el pie inmediatamente después, ausencia de gestión bajo el hero, botones originales y adaptación móvil. Las pruebas de dominio y reproductor cubren horarios, medianoche, modos y cambios de franja.
Para repetir en desarrollo, con el servidor en http://127.0.0.1:4180 y el acceso de pruebas habilitado:
- npx vitest run src/lib/programacion-jukebox.spec.ts src/lib/player/player.svelte.spec.ts
- npx playwright test --config playwright.jukebox.config.ts
- npm run check:sitio
La suite utiliza la conexión de desarrollo configurada en .env; no introduce ejemplos en la cuenta del usuario.
Los botones principales de los formularios utilizan navy en el tema claro y celeste en el oscuro, mediante tokens globales. El Play del hero conserva el dorado.
Los controles exclusivos de horario, modo, origen y tipo de contenido utilizan GrupoToggle: una barra común con el segmento activo verde en ambos temas. Sus tokens y estilos residen en app.css. El contenido del hero reserva un margen inferior de 12vh, acotado con el espaciado global, más la altura del reproductor cuando está visible.
La barra utiliza los tokens del sitio: títulos de canción y jukebox de 16 px e información secundaria de 14 px. «Continuar» conserva el dorado del reproductor, con letra de 13 px, peso normal y altura compacta de 36 px. El icono para detener permanece junto a esta acción y al nombre del jukebox. Se elimina la etiqueta fija «Calidad de escucha», que no describía el audio real. En móvil, la canción y los controles de transporte comparten fila; el progreso y el módulo del jukebox ocupan las siguientes.
El editor reúne título, estado de guardado y acciones en su cabecera. El nombre y la descripción preceden a un panel común de franjas y edición, con horario, reproducción y contenido agrupados. La colección muestra la programación junto al jukebox seleccionado en escritorio y debajo en móvil.
Las tablas de canciones integran el control de reproducción sobre la portada, sin una columna independiente. Aparece al pasar el ratón por la fila, recibir foco de teclado o reproducir la canción; en pantallas táctiles permanece visible. Las pruebas comprueban reproducción, pausa, teclado, tamaño estable de la portada y ausencia de desbordamiento, además de generar capturas de la barra, la colección y el editor en claro, oscuro y móvil.

@ -0,0 +1,54 @@
# Reanálisis del Juke-box
Fecha: 14 de septiembre de 2026. Actualizado tras implementar la separación de pantallas y las correcciones del hero.
## Problemas identificados
La implementación anterior guardaba una sola programación por cuenta, mezclaba reproducción y edición, exigía nombres de franja y no representaba el catálogo completo ni el modo de reproducción por franja. Las pruebas de la programación única no acreditaban los requisitos de varios jukebox.
La primera separación mantuvo «Mis jukebox» debajo del hero. El usuario indicó que esa gestión debía quedar fuera de la pantalla de reproducción. Se trasladó a /juke-box/configurar. El pie del sitio se conserva debajo del hero; no es parte del bloque de gestión retirado.
## Modelo implementado
Cuenta → varios jukebox independientes → franjas numeradas → horario, contenido y modo de reproducción.
Cada jukebox tiene ID, propietario, número, nombre editable, descripción y revisión. Las franjas muestran Franja 1, Franja 2… sin campo de nombre. La referencia horaria se resuelve internamente. Cada franja permite catálogo completo o listas/canciones seleccionadas, con reproducción secuencial o aleatoria.
## Separación de tareas
| Ruta | Función |
| --------------------------- | ------------------------------------------------------------------------------- |
| /juke-box | Hero de reproducción a 100vh, botones Play y Configura, seguido del pie global. |
| /juke-box/configurar | Colección de jukebox y acceso a sus programaciones, creación y edición. |
| /juke-box/nuevo | Borrador nuevo que solo se persiste al guardar. |
| /juke-box/[id]/editar | Editor del jukebox elegido. |
| /juke-box/[id]/programacion | API con autorización por propietario y control de revisión. |
Play mantiene su botón dorado original. Configura utiliza el botón secundario común. Ambos quedan a la izquierda debajo del título. Se conservan las imágenes del hero y se retira «Piloto automático».
Play despliega todos los jukebox con su nombre y descripción. El estado y Detener quedan en la misma fila de acciones. La frase debajo del título adapta su tamaño tipográfico al ancho del título, sin justificar las palabras. El reproductor global mantiene la escucha al navegar; editar otro jukebox no modifica ni detiene el activo. Guardar y cancelar regresan a configuración.
## Migración y comportamiento
La migración 0037 crea los registros independientes y traslada las programaciones previas no vacías sin eliminar la tabla de origen. Se descartan los nombres de franja de la representación nueva. Los clientes antiguos no pueden sobrescribir esta estructura mediante el endpoint anterior.
Cada jukebox valida sus solapamientos de forma independiente. El modo aleatorio baraja todas las canciones de cada franja, incluida la primera. La programación espera en los huecos y cambia de contenido al llegar a la siguiente franja. El editor explica la repetición, el cambio de franja y la espera.
Los borradores sobreviven al historial. Los conflictos de revisión impiden sobrescribir cambios recientes. Las cuentas no pueden leer, editar o eliminar jukebox ajenos ni incorporar listas de otra cuenta.
## Criterios de aceptación
- Crear dos jukebox y recuperarlos con contenidos independientes.
- Editar el segundo mientras suena el primero.
- Reproducir desde el hero y gestionar desde configuración.
- Conservar el pie inmediatamente después del hero, sin «Mis jukebox» intermedio.
- Cancelar una creación sin persistir registros vacíos.
- Usar catálogo completo y modos por franja.
- Conservar borradores al volver atrás y rechazar revisiones antiguas.
- Validar aislamiento de cuentas, solapamientos y eliminación individual.
- Comprobar audio real y cambios horarios mientras se navega.
- Verificar escritorio y móvil, hero, botones y etiquetas comunes.
La guía de uso y los comandos de verificación están en [jukebox-programacion-horaria.md](jukebox-programacion-horaria.md).
«Guardar franja» guarda la selección actual sin persistir cambios de otras franjas; «Guardar jukebox» guarda el conjunto. Nombre y descripción se incluyen en ambos guardados. Los formularios utilizan botones navy/celeste según el tema; el Play del hero conserva el dorado.

@ -0,0 +1,9 @@
CREATE TABLE "programacion_jukebox" (
"usuario_id" text PRIMARY KEY REFERENCES "usuario"("id") ON DELETE CASCADE,
"zona_horaria" text NOT NULL DEFAULT 'Europe/Madrid',
"franjas" jsonb NOT NULL DEFAULT '[]'::jsonb,
"revision" integer NOT NULL DEFAULT 1,
"actualizada_en" timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "programacion_jukebox_franjas_array" CHECK (jsonb_typeof("franjas") = 'array'),
CONSTRAINT "programacion_jukebox_revision_positiva" CHECK ("revision" > 0)
);

@ -0,0 +1,21 @@
CREATE TABLE jukebox (
id text PRIMARY KEY,
usuario_id text NOT NULL REFERENCES usuario(id) ON DELETE CASCADE,
numero integer NOT NULL,
zona_horaria text NOT NULL,
franjas jsonb NOT NULL,
revision integer NOT NULL DEFAULT 1,
actualizada_en timestamptz NOT NULL DEFAULT now(),
CONSTRAINT jukebox_franjas_array CHECK (jsonb_typeof(franjas) = 'array'),
CONSTRAINT jukebox_revision_positiva CHECK (revision > 0)
);
--> statement-breakpoint
CREATE UNIQUE INDEX jukebox_usuario_numero ON jukebox(usuario_id, numero);
--> statement-breakpoint
-- La programación antigua pasa a Jukebox 1; la tabla original se conserva.
INSERT INTO jukebox(id, usuario_id, numero, zona_horaria, franjas, revision, actualizada_en)
SELECT gen_random_uuid()::text, p.usuario_id, 1, p.zona_horaria,
COALESCE((SELECT jsonb_agg((f.valor - 'nombre') || jsonb_build_object('orden', 'secuencial', 'catalogo', false) ORDER BY f.posicion)
FROM jsonb_array_elements(p.franjas) WITH ORDINALITY AS f(valor, posicion)), '[]'::jsonb),
p.revision, p.actualizada_en
FROM programacion_jukebox p WHERE jsonb_array_length(p.franjas) > 0;

@ -0,0 +1,3 @@
ALTER TABLE jukebox ADD COLUMN nombre text NOT NULL DEFAULT '';
--> statement-breakpoint
UPDATE jukebox SET nombre = 'Jukebox ' || numero;

@ -0,0 +1 @@
ALTER TABLE jukebox ADD COLUMN descripcion text NOT NULL DEFAULT '';

@ -118,9 +118,8 @@ test('las tablas comparten ancho sin partir los títulos ni alterar la jerarquí
(enlace) => enlace.textContent?.trim() === 'Ámame esta noche, nada más'
)!;
const celdaTitulo = enlaceTitulo.closest<HTMLElement>('th')!;
const enlaceAlbum = celdaTitulo.parentElement!.querySelector<HTMLAnchorElement>(
'td.columna-album a'
)!;
const enlaceAlbum =
celdaTitulo.parentElement!.querySelector<HTMLAnchorElement>('td.columna-album a')!;
return {
anchos: [albumes.getBoundingClientRect().width, canciones.getBoundingClientRect().width],
album: {
@ -156,18 +155,10 @@ test('las tablas comparten ancho sin partir los títulos ni alterar la jerarquí
const catalogo = page
.getByRole('navigation', { name: 'Ver catálogo' })
.getByRole('link', { name: 'Todo el catálogo' });
const idiomas = page
.getByRole('navigation', { name: 'Idiomas' })
.getByRole('link', { name: 'Todos los idiomas' });
const estilosActivos = await Promise.all(
[catalogo, idiomas].map((enlace) =>
enlace.evaluate((elemento) => ({
color: getComputedStyle(elemento).color,
peso: getComputedStyle(elemento).fontWeight
}))
)
);
expect(estilosActivos[0]).toEqual(estilosActivos[1]);
const idiomas = page.getByRole('navigation', { name: 'Idiomas' });
await expect(catalogo).toHaveAttribute('aria-current', 'page');
await expect(idiomas.getByRole('link', { name: 'Todos los idiomas' })).toHaveCount(0);
await expect(idiomas.locator('[aria-current]')).toHaveCount(0);
await page.screenshot({ path: 'test-results/catalogo-tablas-1536.png', fullPage: true });
});

@ -22,6 +22,95 @@ test.describe('Blog', () => {
expect(fechas).toEqual([...fechas].sort().reverse());
});
test('buscar y cambiar de vista comparten el mismo control', async ({ page }) => {
await page.goto('/blog');
await page.locator('html[data-hidratado]').waitFor();
await page.getByRole('searchbox', { name: 'Buscar una entrada' }).fill('no cabe');
await expect(page.getByRole('status')).toContainText('1 entrada');
await expect(page.getByRole('link', { name: 'Lo que no cabe en una canción' })).toBeVisible();
const tabla = page.getByRole('button', { name: 'Tabla' });
await tabla.click();
await expect(tabla).toHaveAttribute('aria-pressed', 'true');
await expect(page.getByRole('table')).toBeVisible();
});
test('las etiquetas no tienen una opción ficticia y admiten selección múltiple', async ({
page
}) => {
await page.goto('/blog');
await page.locator('html[data-hidratado]').waitFor();
const grupo = page.getByRole('group', { name: 'Filtrar por etiqueta' });
await expect(grupo.getByRole('button', { name: 'Todas', exact: true })).toHaveCount(0);
const colores = await grupo
.getByRole('button')
.evaluateAll((botones) => botones.map((boton) => getComputedStyle(boton).color));
expect(new Set(colores).size).toBeGreaterThan(1);
const primera = grupo.getByRole('button').nth(0);
const segunda = grupo.getByRole('button').nth(1);
await primera.click();
await segunda.click();
await expect(primera).toHaveAttribute('aria-pressed', 'true');
await expect(segunda).toHaveAttribute('aria-pressed', 'true');
await primera.click();
await expect(primera).toHaveAttribute('aria-pressed', 'false');
await expect(segunda).toHaveAttribute('aria-pressed', 'true');
});
test('Atrás y Adelante restauran la presentación y las etiquetas', async ({ page }) => {
await page.goto('/blog');
await page.locator('html[data-hidratado]').waitFor();
const tabla = page.getByRole('button', { name: 'Tabla' });
const tarjetas = page.getByRole('button', { name: 'Tarjetas' });
const primeraEtiqueta = page
.getByRole('group', { name: 'Filtrar por etiqueta' })
.getByRole('button')
.first();
await tabla.click();
await expect(page).toHaveURL('/blog?presentacion=tabla');
await expect(page.getByRole('table')).toBeVisible();
await primeraEtiqueta.click();
await expect(page).toHaveURL(/\/blog\?presentacion=tabla&etiqueta=.+/);
await expect(primeraEtiqueta).toHaveAttribute('aria-pressed', 'true');
await page.goBack();
await expect(page).toHaveURL('/blog?presentacion=tabla');
await expect(primeraEtiqueta).toHaveAttribute('aria-pressed', 'false');
await expect(tabla).toHaveAttribute('aria-pressed', 'true');
await page.goBack();
await expect(page).toHaveURL('/blog');
await expect(tarjetas).toHaveAttribute('aria-pressed', 'true');
await expect(page.locator('ul.entradas')).toBeVisible();
await page.goForward();
await expect(page).toHaveURL('/blog?presentacion=tabla');
await expect(tabla).toHaveAttribute('aria-pressed', 'true');
await page.goForward();
await expect(page).toHaveURL(/\/blog\?presentacion=tabla&etiqueta=.+/);
await expect(primeraEtiqueta).toHaveAttribute('aria-pressed', 'true');
});
test('Atrás recupera la búsqueda después de abrir una entrada', async ({ page }) => {
await page.goto('/blog');
const buscador = page.getByRole('searchbox', { name: 'Buscar una entrada' });
await buscador.fill('no cabe');
await expect(page.getByRole('status')).toContainText('1 entrada');
await page.getByRole('link', { name: 'Lo que no cabe en una canción' }).click();
await expect(page).toHaveURL('/blog/lo-que-no-cabe-en-una-cancion');
await page.goBack();
await expect(page).toHaveURL('/blog');
await expect(buscador).toHaveValue('no cabe');
await expect(page.getByRole('status')).toContainText('1 entrada');
});
test('una entrada se lee entera y encadena con la anterior', async ({ page }) => {
await page.goto('/blog');
await page.getByRole('link', { name: 'Escribir en un estilo que no es el tuyo' }).click();
@ -206,6 +295,7 @@ test.describe('Valoraciones', () => {
test.describe('Favoritos', () => {
test('quien no ha entrado ve el corazón y abre el acceso en la ficha', async ({ page }) => {
await page.goto('/canciones/prometiste');
await expect(page.locator('html')).toHaveAttribute('data-hidratado', '');
// No se esconde: la interfaz no cambia de forma según quién mira.
const enlace = page.locator('header.ficha').getByRole('link', {

@ -0,0 +1,87 @@
import { expect, test } from '@playwright/test';
test('las tablas reproducen sobre la miniatura al pasar por la fila y con teclado', async ({
page
}) => {
await page.setViewportSize({ width: 1440, height: 1000 });
for (const ruta of ['/albumes/cupido-sin-flechas', '/musica?vista=canciones']) {
await page.goto(ruta);
await page.locator('html[data-hidratado]').waitFor();
if (ruta.startsWith('/musica'))
await page.getByRole('button', { name: 'Tabla', exact: true }).click();
const tabla = page.locator('table.tabla-canciones');
const fila = tabla
.locator('tbody tr')
.filter({ has: page.locator('.mini-control button:enabled') })
.first();
const mini = fila.locator('.mini');
const control = fila.locator('.mini-control');
const play = control.getByRole('button');
await mini.scrollIntoViewIfNeeded();
await page.mouse.move(0, 0);
await expect(control).toHaveCSS('opacity', '0');
const antes = await mini.boundingBox();
await fila.locator('.titulo').hover();
await expect(control).toHaveCSS('opacity', '1');
const medidas = await mini.evaluate((el) => {
const imagen = el.querySelector('img')!.getBoundingClientRect();
const boton = el.querySelector('button')!.getBoundingClientRect();
return {
dx: Math.abs(imagen.x - boton.x),
dy: Math.abs(imagen.y - boton.y),
ancho: Math.abs(imagen.width - boton.width),
alto: Math.abs(imagen.height - boton.height),
celdasPrevias: el.closest('td')!.cellIndex
};
});
expect(medidas).toEqual({ dx: 0, dy: 0, ancho: 0, alto: 0, celdasPrevias: 0 });
expect(await mini.boundingBox()).toEqual(antes);
await page.screenshot({
path: `test-results-publica/tabla-play-${ruta.startsWith('/musica') ? 'catalogo' : 'album'}.png`
});
await page.mouse.move(0, 0);
await play.focus();
await page.keyboard.press('Tab');
await page.keyboard.press('Shift+Tab');
await expect(play).toBeFocused();
await expect(control).toHaveCSS('opacity', '1');
await expect(play).toHaveAttribute('title', /Reproducir/);
await page.keyboard.press('Enter');
await expect(play).toHaveAttribute('aria-label', 'Pausar');
await play.evaluate((b: HTMLButtonElement) => b.blur());
await expect(control).toHaveCSS('opacity', '1');
await expect(page.getByRole('region', { name: 'Reproductor', exact: true })).toBeVisible();
await play.click();
await expect(play).toHaveAttribute('aria-label', /Reproducir/);
}
});
test('el control sobre la miniatura permanece visible en táctil sin necesitar hover', async ({
browser
}) => {
const context = await browser.newContext({
viewport: { width: 390, height: 844 },
isMobile: true,
hasTouch: true
});
try {
const page = await context.newPage();
await page.goto('http://127.0.0.1:4180/albumes/cupido-sin-flechas');
await page.locator('html[data-hidratado]').waitFor();
const tabla = page.locator('table.tabla-canciones');
const control = tabla
.locator('.mini-control')
.filter({ has: page.locator('button:enabled') })
.first();
await control.scrollIntoViewIfNeeded();
await expect(control).toHaveCSS('opacity', '1');
await control.getByRole('button').tap();
await expect(control.getByRole('button')).toHaveAttribute('aria-label', 'Pausar');
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(
true
);
await page.screenshot({ path: 'test-results-publica/tabla-play-tactil.png' });
} finally {
await context.close();
}
});

@ -92,6 +92,23 @@ test.describe('Formulario de contacto', () => {
await expect(page.getByLabel('Mensaje')).not.toBeEmpty();
});
test('Atrás recupera un borrador sin enviar', async ({ page }) => {
await abrirFormulario(page);
await rellenar(page);
await page.locator('header a.marca').click();
await expect(page).toHaveURL('/');
await page.goBack();
await expect(page).toHaveURL('/contacto');
await expect(page.getByLabel('Nombre')).toHaveValue('Ana Ruiz');
await expect(page.getByLabel('Correo electrónico')).toHaveValue('ana@example.com');
await expect(page.getByLabel('Motivo')).toHaveValue('colaboracion');
await expect(page.getByLabel('Mensaje')).toHaveValue(
'Me gustaría hablar contigo sobre una colaboración para un disco.'
);
});
test('el campo trampa descarta los envíos automáticos', async ({ page }) => {
await abrirFormulario(page);
await rellenar(page);

@ -23,6 +23,7 @@ test.describe('Fichas de contenido', () => {
test('la letra bloquea selección, copia y menú contextual', async ({ page }) => {
await page.goto('/canciones/cuando-tus-manos-me-tocan');
await page.locator('html[data-hidratado]').waitFor();
const letra = page.locator('.letra.protegida');
await expect(letra).toBeVisible();
@ -76,7 +77,7 @@ test.describe('Tarjetas de canción', () => {
transformacion: getComputedStyle(imagen).transform,
transiciones: getComputedStyle(imagen).transitionProperty
}))
).toEqual({ transformacion: 'none', transiciones: 'filter' });
).toEqual({ transformacion: 'none', transiciones: 'none' });
await tarjeta.hover();
expect(await portada.evaluate((imagen) => getComputedStyle(imagen).transform)).toBe('none');
@ -96,6 +97,7 @@ test.describe('Tarjetas de canción', () => {
test('la tarjeta permite añadir la canción a una lista de reproducción', async ({ page }) => {
await page.goto('/musica/baladas-romanticas');
await page.locator('html[data-hidratado]').waitFor();
const tarjeta = page
.locator('article')
@ -129,15 +131,39 @@ test.describe('Pestañas de la ficha', () => {
test('al pulsar una pestaña se cambia de panel', async ({ page }) => {
await page.goto('/canciones/dorina');
await page.locator('html[data-hidratado]').waitFor();
await page.getByRole('tab', { name: 'Vídeo' }).click();
await expect(page).toHaveURL('/canciones/dorina#panel-videos');
await expect(page.getByRole('tabpanel', { name: 'Vídeo' })).toBeVisible();
await expect(page.getByRole('tabpanel', { name: 'Letra' })).toBeHidden();
await expect(page.getByText(/El vídeo se carga solo al pulsarlo/)).toBeVisible();
});
test('Atrás y Adelante restauran la pestaña de la canción', async ({ page }) => {
await page.goto('/canciones/dorina');
await page.locator('html[data-hidratado]').waitFor();
await page.getByRole('tab', { name: 'Versiones' }).click();
await expect(page.getByRole('tabpanel', { name: 'Versiones' })).toBeVisible();
await page.getByRole('tab', { name: 'Vídeo' }).click();
await expect(page.getByRole('tabpanel', { name: 'Vídeo' })).toBeVisible();
await page.goBack();
await expect(page).toHaveURL('/canciones/dorina#panel-versiones');
await expect(page.getByRole('tabpanel', { name: 'Versiones' })).toBeVisible();
await page.goBack();
await expect(page).toHaveURL('/canciones/dorina');
await expect(page.getByRole('tabpanel', { name: 'Letra' })).toBeVisible();
await page.goForward();
await expect(page.getByRole('tabpanel', { name: 'Versiones' })).toBeVisible();
});
test('las flechas mueven entre pestañas', async ({ page }) => {
await page.goto('/canciones/dorina');
await page.locator('html[data-hidratado]').waitFor();
await page.getByRole('tab', { name: 'Letra' }).focus();
await page.keyboard.press('ArrowRight');

@ -0,0 +1,743 @@
import { expect, test } from '@playwright/test';
import pg from 'pg';
import { randomUUID } from 'node:crypto';
// Solo contra desarrollo con el código de prueba habilitado y una cuenta efímera.
test.describe('Programación autenticada del Juke-box', () => {
test.skip(process.env.E2E_JUKEBOX !== '1', 'Requiere la base de desarrollo y ACCESO_PRUEBAS.');
test.describe.configure({ mode: 'serial' });
const usuarioId = randomUUID();
const otroId = randomUUID();
const email = `jukebox-${usuarioId}@example.test`;
const listaId = randomUUID();
const listaAjena = randomUUID();
let canciones: { id: string; slug: string; titulo: string }[];
let db: pg.Client;
let conectada = false;
test.beforeAll(async () => {
db = new pg.Client({ connectionString: process.env.DATABASE_URL });
await db.connect();
conectada = true;
canciones = (
await db.query(
"select id, slug, titulo from cancion where estado='publicado' and slug in ('prometiste','cuando-tus-manos-me-tocan') order by slug"
)
).rows;
await db.query('insert into usuario (id,email) values ($1,$2), ($3,$4)', [
usuarioId,
email,
otroId,
`otro-${usuarioId}@example.test`
]);
await db.query(
"insert into suscripcion (id,usuario_id,plan_id,estado) select $1,$2,id,'activa' from plan_suscripcion where codigo='friend' and acceso_jukebox=true",
[randomUUID(), usuarioId]
);
await db.query(
"insert into lista_reproduccion (id,usuario_id,nombre) values ($1,$2,'Mi selección de mañana'),($3,$4,'Lista ajena')",
[listaId, usuarioId, listaAjena, otroId]
);
for (const [orden, c] of canciones.entries())
await db.query(
'insert into lista_reproduccion_cancion (lista_id,cancion_id,orden) values ($1,$2,$3)',
[listaId, c.id, orden]
);
});
test.afterAll(async () => {
if (db && conectada) {
await db.query('delete from usuario where id = any($1::text[])', [[usuarioId, otroId]]);
await db.end();
}
});
test.beforeEach(async ({ page }) => {
await page.goto(`/entrar?paso=codigo&email=${encodeURIComponent(email)}&volverA=%2Fjuke-box`);
await page.locator('html[data-hidratado]').waitFor();
await page.getByLabel('Código', { exact: true }).fill('123456');
await page.getByRole('button', { name: 'Entrar', exact: true }).click();
await expect(page).toHaveURL('/juke-box');
});
let primero = '';
let segundo = '';
test('separa reproducción y edición, crea dos jukebox y conserva catálogo, listas y modos', async ({
page
}) => {
await page.setViewportSize({ width: 1440, height: 1000 });
await expect(page.locator('.programador')).toHaveCount(0);
await page.getByRole('link', { name: 'Configura tu Jukebox', exact: true }).click();
await expect(page).toHaveURL('/juke-box/configurar');
await page.getByRole('link', { name: 'Crear jukebox', exact: true }).click();
await expect(page).toHaveURL('/juke-box/nuevo');
await page.getByLabel('Nombre del jukebox', { exact: true }).fill('Mañanas');
await page.getByLabel('Descripción', { exact: true }).fill('Música para empezar el día.');
await expect(page.getByLabel('Nombre de la franja')).toHaveCount(0);
await expect(page.getByLabel('Zona horaria')).toHaveCount(0);
await page.getByRole('button', { name: 'Crear primera franja' }).click();
await page.getByRole('button', { name: 'Listas y canciones', exact: true }).click();
await page.getByRole('button', { name: '+ Añadir contenido', exact: true }).click();
await page
.getByRole('button', { name: 'Añadir lista Mi selección de mañana', exact: true })
.click();
await page.getByRole('button', { name: 'Canciones', exact: true }).click();
await page
.getByRole('button', { name: 'Añadir canción ' + canciones[1].titulo, exact: true })
.click();
await page.getByRole('button', { name: 'Listo', exact: false }).click();
await page.getByRole('button', { name: 'Subir ' + canciones[1].titulo, exact: true }).click();
await expect(page.locator('.contenidos > .contenido').first()).toContainText(
canciones[1].titulo
);
await page.getByRole('button', { name: 'Bajar ' + canciones[1].titulo, exact: true }).click();
await page.getByRole('button', { name: 'Todo el catálogo', exact: true }).click();
await page.getByRole('button', { name: 'Listas y canciones', exact: true }).click();
await expect(page.locator('.contenidos > .contenido')).toHaveCount(2);
await page.getByRole('button', { name: 'Guardar jukebox', exact: true }).click();
await expect(page).toHaveURL(/juke-box\/configurar\?jukebox=.+/);
primero = new URL(page.url()).searchParams.get('jukebox')!;
await expect(page.locator('.programador')).toHaveCount(0);
await page.getByRole('link', { name: '+ Crear jukebox', exact: true }).click();
await page.getByLabel('Nombre del jukebox', { exact: true }).fill('Tardes');
await page
.getByLabel('Descripción', { exact: true })
.fill('Todo el catálogo al caer la tarde.');
await page.getByRole('button', { name: 'Crear primera franja' }).click();
await page.getByRole('button', { name: 'Aleatoria', exact: true }).click();
await expect(
page.getByRole('button', { name: 'Todo el catálogo', exact: true })
).toHaveAttribute('aria-pressed', 'true');
await page.getByRole('button', { name: 'Guardar jukebox', exact: true }).click();
await expect(page).toHaveURL(/juke-box\/configurar\?jukebox=.+/);
segundo = new URL(page.url()).searchParams.get('jukebox')!;
expect(segundo).not.toBe(primero);
await page.reload();
await page.locator('html[data-hidratado]').waitFor();
await expect(page.locator('.coleccion .jukebox')).toHaveCount(2);
await expect(page.locator('.coleccion')).toContainText('Música para empezar el día.');
await expect(page.locator('.coleccion')).toContainText('Todo el catálogo al caer la tarde.');
await expect(page.locator('.horarios')).toContainText('Todo el catálogo');
await expect(page.locator('.horarios')).toContainText('Aleatoria');
await page.getByRole('link', { name: 'Abrir reproductor de Mañanas', exact: true }).click();
await page.getByRole('button', { name: 'Play JukeBox', exact: true }).click();
await page.getByRole('button', { name: 'Reproducir Mañanas', exact: true }).click();
await expect(page.locator('.estado-jukebox')).toContainText('Mañanas');
const alturasAcciones = await page.locator('.acciones-heroe').evaluate((fila) =>
Array.from(fila.querySelectorAll('button'))
.filter((b) => b.offsetHeight)
.map((b) => {
const r = b.getBoundingClientRect();
return r.y + r.height / 2;
})
);
expect(Math.max(...alturasAcciones) - Math.min(...alturasAcciones)).toBeLessThanOrEqual(1);
await expect(page.getByRole('region', { name: 'Reproductor', exact: true })).toContainText(
canciones[0].titulo
);
await page.getByRole('link', { name: 'Configura tu Jukebox', exact: true }).click();
await page.getByRole('link', { name: 'Editar Tardes', exact: true }).click();
await expect(page).toHaveURL('/juke-box/' + segundo + '/editar');
await page.getByRole('button', { name: 'Secuencial', exact: true }).click();
await page.getByRole('button', { name: 'Guardar jukebox', exact: true }).click();
await expect(page).toHaveURL(/juke-box\/configurar\?jukebox=.+/);
await expect(page.locator('.control-jukebox')).toContainText('Mañanas');
await expect(page.getByRole('region', { name: 'Reproductor', exact: true })).toContainText(
canciones[0].titulo
);
await page.getByRole('link', { name: 'Abrir reproductor de Tardes', exact: true }).click();
await page.getByRole('button', { name: 'Play JukeBox', exact: true }).click();
await page.getByRole('button', { name: 'Reproducir Tardes', exact: true }).click();
await expect(page.locator('.estado-jukebox')).toContainText('Tardes');
await page.getByRole('button', { name: 'Detener Jukebox', exact: true }).click();
await expect(page.locator('.estado-jukebox')).toContainText('Tardes');
await expect(page.locator('.estado-jukebox')).toContainText('Detenido');
expect((await page.request.get('/juke-box/' + segundo + '/programacion')).status()).toBe(200);
await page.goto('/juke-box');
await page.locator('html[data-hidratado]').waitFor();
await page.getByRole('button', { name: 'Play JukeBox', exact: true }).click();
await expect(page.getByRole('list', { name: 'Tus jukebox' })).toBeVisible();
await page.getByRole('button', { name: 'Reproducir Mañanas', exact: true }).click();
await expect(page.getByRole('list', { name: 'Tus jukebox' })).not.toBeVisible();
await expect(page.locator('.estado-jukebox')).toContainText('Mañanas');
await page.getByRole('button', { name: 'Detener Jukebox', exact: true }).click();
});
test('mantiene borradores con el historial y permite cancelar un jukebox nuevo sin crearlo', async ({
page
}) => {
await page.getByRole('link', { name: 'Configura tu Jukebox', exact: true }).click();
await page.getByRole('link', { name: 'Editar Mañanas', exact: true }).click();
await page.getByRole('button', { name: 'Aleatoria', exact: true }).click();
await page.getByRole('banner').getByRole('link', { name: 'Música', exact: true }).click();
await expect(page).toHaveURL('/musica');
await page.goBack();
await expect(page.getByRole('button', { name: 'Aleatoria', exact: true })).toHaveAttribute(
'aria-pressed',
'true'
);
await page.getByRole('button', { name: 'Deshacer cambios', exact: true }).click();
await expect(page.getByRole('button', { name: 'Secuencial', exact: true })).toHaveAttribute(
'aria-pressed',
'true'
);
await page.getByRole('link', { name: 'Cancelar', exact: true }).click();
await page.getByRole('link', { name: '+ Crear jukebox', exact: true }).click();
await page.getByRole('button', { name: 'Crear primera franja' }).click();
await page.getByRole('link', { name: 'Cancelar', exact: true }).click();
await expect(page.locator('.coleccion .jukebox')).toHaveCount(2);
});
test('comprueba propietario, listas ajenas, revisión y la independencia de guardados', async ({
page,
request
}) => {
const endpoint = '/juke-box/' + primero + '/programacion';
expect((await request.get(endpoint)).status()).toBe(401);
const actual = await (await page.request.get(endpoint)).json();
const headers = { origin: new URL(page.url()).origin };
const ajena = structuredClone(actual);
ajena.programacion.franjas[0].contenidos.push({ tipo: 'lista', id: listaAjena });
expect((await page.request.put(endpoint, { headers, data: ajena })).status()).toBe(400);
const otroJukebox = randomUUID();
await db.query(
'insert into jukebox(id,usuario_id,numero,zona_horaria,franjas) values($1,$2,1,$3,$4)',
[otroJukebox, otroId, 'UTC', JSON.stringify(actual.programacion.franjas)]
);
expect((await page.request.get('/juke-box/' + otroJukebox + '/programacion')).status()).toBe(
404
);
expect(
(
await page.request.put('/juke-box/' + otroJukebox + '/programacion', {
headers,
data: actual
})
).status()
).toBe(409);
expect(
(
await page.request.delete('/juke-box/' + otroJukebox + '/programacion', {
headers,
data: { revision: 1 }
})
).status()
).toBe(409);
const revSegundo = await (
await page.request.get('/juke-box/' + segundo + '/programacion')
).json();
actual.programacion.usuarioId = otroId;
actual.programacion.franjas[0].nombre = 'Campo obsoleto';
expect((await page.request.put(endpoint, { headers, data: actual })).status()).toBe(200);
expect((await page.request.put(endpoint, { headers, data: actual })).status()).toBe(409);
const recargado = await (await page.request.get(endpoint)).json();
expect(recargado.programacion.usuarioId).toBeUndefined();
expect(recargado.programacion.franjas[0].nombre).toBeUndefined();
expect(
(await (await page.request.get('/juke-box/' + segundo + '/programacion')).json()).revision
).toBe(revSegundo.revision);
expect(
(
await page.request.put(endpoint, {
headers: { origin: 'https://ajena.example' },
data: recargado
})
).status()
).toBe(403);
});
test('el hero ocupa 100vh, conserva el play dorado y coloca las dos acciones debajo del título', async ({
page
}) => {
await page.setViewportSize({ width: 1440, height: 1000 });
await page.emulateMedia({ reducedMotion: 'reduce' });
await expect(page.getByRole('heading', { name: 'Mis jukebox', exact: true })).toHaveCount(0);
await expect(page.getByRole('contentinfo')).toHaveCount(1);
expect(
await page.getByRole('contentinfo').evaluate((pie) => pie.getBoundingClientRect().top)
).toBe(1000);
await page.locator('.heroe-jukebox img').evaluate((img: HTMLImageElement) => img.decode());
const geometria = await page.locator('.heroe-jukebox').evaluate((h) => {
const titulo = h.querySelector('h1')!.getBoundingClientRect();
const botones = h.querySelector('.acciones-heroe')!.getBoundingClientRect();
return {
alto: h.getBoundingClientRect().height,
ventana: innerHeight,
izquierda: botones.x - titulo.x,
debajo: botones.y >= titulo.bottom
};
});
expect(geometria.alto).toBe(geometria.ventana);
expect(geometria.izquierda).toBe(0);
expect(geometria.debajo).toBe(true);
await expect
.poll(() =>
page
.locator('.titulo-heroe')
.evaluate((h) =>
Math.abs(
h.querySelector('h1')!.getBoundingClientRect().width -
h.querySelector('.entradilla span')!.getBoundingClientRect().width
)
)
)
.toBeLessThan(1);
expect(
await page
.locator('.entradilla span')
.evaluate((s) => parseFloat(getComputedStyle(s).fontSize))
).toBeGreaterThan(24);
await page.getByRole('button', { name: 'Play JukeBox', exact: true }).click();
await expect(page.getByRole('list', { name: 'Tus jukebox' })).toContainText(
'Música para empezar el día.'
);
await page.screenshot({ path: 'test-results-publica/jukebox-desplegable-oscuro.png' });
await page.keyboard.press('Escape');
await expect(page.getByText('Piloto automático', { exact: true })).toHaveCount(0);
await expect(page.getByRole('button', { name: 'Play JukeBox', exact: true })).toHaveClass(
/btn--primary/
);
await expect(
page.getByRole('link', { name: 'Configura tu Jukebox', exact: true })
).not.toHaveClass(/btn--primary/);
await page.screenshot({ path: 'test-results-publica/jukebox-hero-100vh-oscuro.png' });
await page.getByRole('button', { name: 'Cambiar al tema claro' }).click();
await expect(page.locator('html')).toHaveAttribute('data-tema', 'claro');
await page.evaluate(
() =>
new Promise<void>((done) =>
requestAnimationFrame(() => requestAnimationFrame(() => done()))
)
);
await page.screenshot({ path: 'test-results-publica/jukebox-hero-100vh-claro.png' });
await page.getByRole('button', { name: 'Play JukeBox', exact: true }).click();
await page.screenshot({ path: 'test-results-publica/jukebox-desplegable-claro.png' });
await page.keyboard.press('Escape');
await page.setViewportSize({ width: 390, height: 844 });
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(
true
);
expect(
await page.locator('.heroe-jukebox').evaluate((h) => h.getBoundingClientRect().height)
).toBe(844);
await page.screenshot({ path: 'test-results-publica/jukebox-hero-100vh-movil.png' });
expect(
await page.getByRole('contentinfo').evaluate((pie) => pie.getBoundingClientRect().top)
).toBe(844);
await page.getByRole('link', { name: 'Configura tu Jukebox', exact: true }).click();
await page.getByRole('link', { name: 'Editar Mañanas', exact: true }).click();
await page.getByRole('button', { name: '+ Añadir contenido', exact: true }).click();
await page.getByRole('button', { name: 'Canciones', exact: true }).click();
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(
true
);
const alturas = await page
.locator('.selector .etiqueta')
.evaluateAll((ns) => ns.map((n) => n.getBoundingClientRect().height));
expect(Math.max(...alturas)).toBeLessThanOrEqual(26);
await page.evaluate(() => window.scrollTo({ top: 0, behavior: 'instant' }));
await page.screenshot({
path: 'test-results-publica/jukebox-editor-movil.png',
fullPage: true
});
const guardarFranja = page.getByRole('button', { name: 'Guardar franja', exact: true });
const claro = await guardarFranja.evaluate((b) => getComputedStyle(b).backgroundColor);
await expect(guardarFranja).toHaveCSS('background-image', 'none');
await expect(page.locator('.toggle-rail')).toHaveCount(4);
await page.setViewportSize({ width: 1440, height: 1000 });
await page.evaluate(() => window.scrollTo({ top: 0, behavior: 'instant' }));
await page.screenshot({
path: 'test-results-publica/jukebox-formulario-claro.png',
fullPage: true
});
await page.getByRole('button', { name: 'Cambiar al tema oscuro' }).click();
await expect
.poll(() => guardarFranja.evaluate((b) => getComputedStyle(b).backgroundColor))
.not.toBe(claro);
await page.screenshot({
path: 'test-results-publica/jukebox-formulario-oscuro.png',
fullPage: true
});
});
test('cambia de franja al navegar, aplica sus modos y reproduce audio real', async ({ page }) => {
const endpoint = '/juke-box/' + primero + '/programacion';
const actual = await (await page.request.get(endpoint)).json();
actual.programacion = {
zonaHoraria: 'UTC',
franjas: [
{
id: 'primera',
inicio: '09:00',
fin: '10:00',
orden: 'secuencial',
catalogo: false,
contenidos: [{ tipo: 'lista', id: listaId }]
},
{
id: 'segunda',
inicio: '10:00',
fin: '11:00',
orden: 'aleatoria',
catalogo: false,
contenidos: [{ tipo: 'cancion', id: canciones[1].slug }]
}
]
};
expect(
(
await page.request.put(endpoint, {
headers: { origin: new URL(page.url()).origin },
data: actual
})
).status()
).toBe(200);
await page.addInitScript(() => {
window.Audio = new Proxy(window.Audio, {
construct(Original, args) {
const audio = Reflect.construct(Original, args) as HTMLAudioElement;
audio.dataset.pruebaJukebox = '';
document.body.append(audio);
return audio;
}
});
});
await page.clock.setFixedTime(new Date('2026-09-14T08:59:50Z'));
await page.goto('/juke-box?jukebox=' + primero);
await page.locator('html[data-hidratado]').waitFor();
await page.getByRole('button', { name: 'Play JukeBox', exact: true }).click();
await page.getByRole('button', { name: 'Reproducir Mañanas', exact: true }).click();
await expect(page.locator('.estado-jukebox')).toContainText('Esperando próxima franja');
const respuesta = page.waitForResponse(
(r) => r.url().includes('/escucha/' + canciones[0].slug) && [200, 206].includes(r.status())
);
await page.clock.setFixedTime(new Date('2026-09-14T09:00:01Z'));
await respuesta;
const audio = page.locator('audio[data-prueba-jukebox]');
await expect
.poll(() =>
audio.evaluate(
(a: HTMLAudioElement) => !a.paused && a.readyState >= 2 && a.currentTime > 0.05
)
)
.toBe(true);
await page.getByRole('banner').getByRole('link', { name: 'Música', exact: true }).click();
await expect(page).toHaveURL('/musica');
await page.clock.setFixedTime(new Date('2026-09-14T10:00:01Z'));
await expect(page.locator('.control-jukebox')).toContainText('Franja 2');
await expect(page.getByRole('region', { name: 'Reproductor', exact: true })).toContainText(
canciones[1].titulo
);
await page.clock.setFixedTime(new Date('2026-09-14T11:00:01Z'));
await expect(page.locator('.control-jukebox')).toContainText('Esperando próxima franja');
await expect(page.getByRole('region', { name: 'Reproductor', exact: true })).toBeVisible();
await expect(page.getByRole('group', { name: 'Sonando ahora', exact: true })).toHaveCount(0);
await page.getByRole('button', { name: 'Detener', exact: true }).click();
const control = page.getByRole('group', { name: 'Jukebox', exact: true });
const barra = page.getByRole('region', { name: 'Reproductor', exact: true });
await expect(control).toContainText('Mañanas');
await expect(control).toContainText('Detenido');
await expect(control.getByRole('button', { name: 'Reproducir', exact: true })).toBeVisible();
const guardado = await (await page.request.get(endpoint)).json();
expect(guardado.nombre).toBe(actual.nombre);
expect(guardado.programacion).toEqual(actual.programacion);
await page.clock.setFixedTime(new Date('2026-09-15T09:15:00Z'));
await page.waitForTimeout(1200);
await expect(control).toContainText('Detenido');
expect(await audio.evaluate((a: HTMLAudioElement) => a.paused)).toBe(true);
await control.getByRole('button', { name: 'Reproducir', exact: true }).click();
await expect(control).toContainText('Franja 1');
await expect
.poll(() => audio.evaluate((a: HTMLAudioElement) => !a.paused && a.currentTime > 0.05))
.toBe(true);
await control.getByRole('button', { name: 'Detener', exact: true }).click();
await expect(control).toContainText('Detenido');
await expect(barra.getByRole('group', { name: 'Sonando ahora', exact: true })).toHaveCount(0);
expect(await audio.evaluate((a: HTMLAudioElement) => a.paused)).toBe(true);
await barra.screenshot({ path: 'test-results-publica/jukebox-detenido-oscuro.png' });
await page.getByRole('button', { name: 'Cambiar al tema claro', exact: true }).click();
await barra.screenshot({ path: 'test-results-publica/jukebox-detenido-claro.png' });
await page.setViewportSize({ width: 390, height: 844 });
await barra.screenshot({ path: 'test-results-publica/jukebox-detenido-movil.png' });
expect(
await page.evaluate(() => document.documentElement.scrollWidth - innerWidth)
).toBeLessThanOrEqual(0);
});
test('interrumpe y recupera audio real, agrupa los controles y permite volver desde la configuración', async ({
page
}) => {
await page.setViewportSize({ width: 1440, height: 1000 });
await page.clock.setFixedTime(new Date('2026-09-14T09:15:00Z'));
await page.addInitScript(() => {
window.Audio = new Proxy(window.Audio, {
construct(Original, args) {
const audio = Reflect.construct(Original, args) as HTMLAudioElement;
audio.dataset.pruebaJukebox = '';
document.body.append(audio);
return audio;
}
});
});
await page.goto('/juke-box?jukebox=' + primero);
await page.locator('html[data-hidratado]').waitFor();
await page.getByRole('button', { name: 'Play JukeBox', exact: true }).click();
await page.getByRole('button', { name: 'Reproducir Mañanas', exact: true }).click();
const barra = page.getByRole('region', { name: 'Reproductor', exact: true });
const control = barra.getByRole('group', { name: 'Jukebox', exact: true });
const audio = page.locator('audio[data-prueba-jukebox]');
const suena = async () => {
await expect
.poll(() =>
audio.evaluate(
(a: HTMLAudioElement) => !a.paused && a.readyState >= 2 && a.currentTime > 0
)
)
.toBe(true);
};
await suena();
await page.getByRole('banner').getByRole('link', { name: 'Música', exact: true }).click();
await page.locator(`main a[href="/canciones/${canciones[1].slug}"]`).first().click();
await expect(page).toHaveURL('/canciones/' + canciones[1].slug);
await audio.evaluate((a: HTMLAudioElement) => {
a.currentTime = 37;
});
await page.getByRole('button', { name: 'Escuchar completa', exact: true }).click();
await expect(control).toContainText('Vuelve al terminar');
await expect(barra.getByRole('group', { name: 'Sonando ahora', exact: true })).toContainText(
canciones[1].titulo
);
await suena();
// El texto y sus acciones permanecen juntos, incluso con mucho ancho disponible.
for (const [tema, width] of [
['oscuro', 1440],
['claro', 1440],
['movil', 390]
] as const) {
if (tema === 'claro')
await page.getByRole('button', { name: 'Cambiar al tema claro', exact: true }).click();
await page.setViewportSize({ width, height: tema === 'movil' ? 844 : 1000 });
await expect(
control.getByRole('button', { name: 'Continuar', exact: true })
).toBeInViewport();
const medidas = await control.evaluate((grupo) => {
const datos = grupo.querySelector('.datos')!.getBoundingClientRect();
const botones = Array.from(grupo.querySelectorAll('button')).map((b) =>
b.getBoundingClientRect()
);
return {
separacion: botones[0].left - datos.right,
entreBotones: botones[1].left - botones[0].right,
desborde: document.documentElement.scrollWidth - innerWidth
};
});
expect(medidas.separacion).toBeLessThanOrEqual(12);
expect(medidas.separacion).toBeGreaterThanOrEqual(0);
expect(medidas.entreBotones).toBeLessThanOrEqual(12);
expect(medidas.desborde).toBeLessThanOrEqual(0);
await barra.screenshot({ path: `test-results-publica/jukebox-retorno-${tema}.png` });
await expect(barra.getByText('Calidad de escucha', { exact: true })).toHaveCount(0);
const tipo = await control.evaluate((grupo) => ({
nombre: getComputedStyle(grupo.querySelector('a')!).fontSize,
boton: getComputedStyle(grupo.querySelector('button')!).fontSize,
alto: grupo.querySelector('button')!.getBoundingClientRect().height
}));
expect(tipo).toEqual({ nombre: '16px', boton: '13px', alto: 36 });
const estiloContinuar = await control
.getByRole('button', { name: 'Continuar', exact: true })
.evaluate((b) => ({
fondo: getComputedStyle(b).backgroundImage,
peso: getComputedStyle(b).fontWeight
}));
expect(estiloContinuar.fondo).toContain('linear-gradient');
expect(estiloContinuar.peso).toBe('400');
if (tema !== 'movil') {
const continuar = control.getByRole('button', { name: 'Continuar', exact: true });
const colorDorado = await continuar.evaluate((b) =>
getComputedStyle(b).getPropertyValue('--c-accent-text').trim()
);
await expect(continuar).toHaveCSS('color', colorDorado);
await continuar.hover();
await continuar.evaluate(async (b) => {
await Promise.all(b.getAnimations().map((a) => a.finished.catch(() => {})));
});
await expect(continuar).toHaveCSS('color', colorDorado);
await barra.screenshot({
path: `test-results-publica/jukebox-continuar-hover-${tema}.png`
});
await page.mouse.move(0, 0);
await continuar.focus();
await page.keyboard.press('Tab');
await page.keyboard.press('Shift+Tab');
await expect(continuar).toBeFocused();
await expect(continuar).toHaveCSS('color', colorDorado);
await barra.screenshot({ path: `test-results-publica/jukebox-continuar-foco-${tema}.png` });
}
if (tema === 'movil') {
expect((await barra.boundingBox())!.height).toBeLessThanOrEqual(170);
await expect(
barra.getByRole('button', { name: 'Tema anterior', exact: true })
).toBeInViewport();
await expect(
barra.getByRole('button', { name: 'Tema siguiente', exact: true })
).toBeInViewport();
}
}
await page.setViewportSize({ width: 1440, height: 1000 });
await control.getByRole('button', { name: 'Continuar', exact: true }).click();
await suena();
await expect(barra.getByRole('group', { name: 'Sonando ahora', exact: true })).toContainText(
canciones[0].titulo
);
await expect
.poll(() => audio.evaluate((a: HTMLAudioElement) => a.currentTime))
.toBeGreaterThanOrEqual(37);
expect(await audio.evaluate((a: HTMLAudioElement) => a.currentTime)).toBeLessThan(45);
await expect(control.getByRole('button', { name: 'Continuar', exact: true })).toHaveCount(0);
// La pausa desde la ficha y desde la barra devuelve inmediatamente el jukebox.
for (const origen of ['ficha', 'barra'] as const) {
await page.getByRole('button', { name: 'Escuchar completa', exact: true }).click();
await suena();
await expect(control).toContainText('Vuelve al terminar');
await (origen === 'ficha' ? page.locator('main') : barra)
.getByRole('button', { name: 'Pausar', exact: true })
.click();
await expect(control).toContainText('Franja 1');
await suena();
await expect(barra.getByRole('group', { name: 'Sonando ahora', exact: true })).toContainText(
canciones[0].titulo
);
await expect
.poll(() => audio.evaluate((a: HTMLAudioElement) => a.currentTime))
.toBeGreaterThanOrEqual(37);
await expect(control.getByRole('button', { name: 'Continuar', exact: true })).toHaveCount(0);
}
// Si el jukebox ya estaba pausado, una canción manual no levanta esa pausa.
await barra.getByRole('button', { name: 'Pausar', exact: true }).click();
await expect(control).toContainText('Franja 1 · En pausa');
await page.getByRole('button', { name: 'Escuchar completa', exact: true }).click();
await suena();
await expect(control).toContainText('Jukebox en pausa');
await page.locator('main').getByRole('button', { name: 'Pausar', exact: true }).click();
await expect(control).toContainText('Franja 1 · En pausa');
expect(await audio.evaluate((a: HTMLAudioElement) => a.paused)).toBe(true);
await expect(barra.getByRole('group', { name: 'Sonando ahora', exact: true })).toContainText(
canciones[0].titulo
);
await barra.getByRole('button', { name: 'Reproducir', exact: true }).click();
await suena();
// Un final real del audio elegido vuelve automáticamente al mismo punto.
await page.getByRole('button', { name: 'Escuchar completa', exact: true }).click();
await suena();
await audio.evaluate((a: HTMLAudioElement) => {
a.currentTime = a.duration - 0.15;
});
await expect(control).toContainText('Franja 1');
await suena();
await expect
.poll(() => audio.evaluate((a: HTMLAudioElement) => a.currentTime))
.toBeGreaterThanOrEqual(37);
// El reloj cambia de franja durante la elección manual sin cortarla.
await page.getByRole('button', { name: 'Escuchar completa', exact: true }).click();
await suena();
await page.clock.setFixedTime(new Date('2026-09-14T10:00:01Z'));
await expect(control).toContainText('Vuelve al terminar');
await audio.evaluate((a: HTMLAudioElement) => {
a.currentTime = a.duration - 0.15;
});
await expect(control).toContainText('Franja 2');
await suena();
expect(await audio.evaluate((a: HTMLAudioElement) => a.currentTime)).toBeLessThan(10);
await barra.getByRole('button', { name: 'Pausar', exact: true }).click();
await page.clock.setFixedTime(new Date('2026-09-14T11:00:01Z'));
// Dejamos pasar al menos una consulta real del reloj de programación.
await page.waitForTimeout(1200);
await expect(control).toContainText('Franja 2 · En pausa');
expect(await audio.evaluate((a: HTMLAudioElement) => a.paused)).toBe(true);
await barra.screenshot({ path: 'test-results-publica/jukebox-pausa-persistente.png' });
await barra.getByRole('button', { name: 'Reproducir', exact: true }).click();
await expect(control).toContainText('Esperando próxima franja');
await expect(barra.getByRole('group', { name: 'Sonando ahora', exact: true })).toHaveCount(0);
await barra.screenshot({ path: 'test-results-publica/jukebox-espera-compacta.png' });
await control.getByRole('link', { name: 'Mañanas', exact: true }).click();
await expect(page).toHaveURL('/juke-box?jukebox=' + primero);
await page.getByRole('link', { name: 'Configura tu Jukebox', exact: true }).click();
await expect(page).toHaveURL('/juke-box/configurar');
await expect(page.getByRole('link', { name: 'Volver al Juke-box', exact: true })).toBeVisible();
await page.screenshot({ path: 'test-results-publica/jukebox-coleccion-claro.png' });
await page.getByRole('link', { name: 'Editar Mañanas', exact: true }).click();
await expect(page).toHaveURL('/juke-box/' + primero + '/editar');
await expect(
page.getByRole('heading', { name: 'Editar Jukebox 1', exact: true })
).toBeVisible();
await page.evaluate(() => window.scrollTo(0, 0));
const separacion = await page.evaluate(() => {
const cabecera = document.querySelector('header')!.getBoundingClientRect();
const migas = document.querySelector('.migas')!.getBoundingClientRect();
return migas.top - cabecera.bottom;
});
expect(separacion).toBeGreaterThanOrEqual(0);
expect(separacion).toBeLessThanOrEqual(32);
expect((await page.locator('.agenda').boundingBox())!.y).toBeLessThanOrEqual(460);
await page.screenshot({ path: 'test-results-publica/jukebox-editor-cabecera.png' });
await page.getByRole('button', { name: 'Cambiar al tema oscuro', exact: true }).click();
await page.screenshot({ path: 'test-results-publica/jukebox-editor-oscuro.png' });
await page.setViewportSize({ width: 390, height: 844 });
await page.locator('.editor').scrollIntoViewIfNeeded();
await page.screenshot({ path: 'test-results-publica/jukebox-editor-movil.png' });
expect(
await page.evaluate(() => document.documentElement.scrollWidth - innerWidth)
).toBeLessThanOrEqual(0);
await page.setViewportSize({ width: 1440, height: 1000 });
await page.getByRole('link', { name: 'Volver al Juke-box', exact: true }).click();
await expect(page).toHaveURL('/juke-box?jukebox=' + primero);
await control.getByRole('button', { name: 'Detener', exact: true }).click();
await expect(barra).toBeVisible();
await expect(control).toContainText('Detenido');
});
test('guardar una franja persiste sus cambios sin guardar el borrador de otra', async ({
page
}) => {
await page.goto('/juke-box/' + primero + '/editar');
await page.locator('html[data-hidratado]').waitFor();
await expect(page.getByLabel('Nombre del jukebox')).toHaveValue('Mañanas');
await page.getByLabel('Descripción', { exact: true }).fill('Descripción editada y guardada.');
await page.getByRole('button', { name: 'Aleatoria', exact: true }).click();
await page.locator('.franjas .franja').nth(1).click();
await page.getByLabel('Hasta', { exact: true }).fill('12:00');
await page.getByRole('button', { name: 'Guardar franja', exact: true }).click();
await expect(page.getByRole('status')).toContainText('Franja 2 guardada');
const guardado = await (
await page.request.get('/juke-box/' + primero + '/programacion')
).json();
expect(guardado.nombre).toBe('Mañanas');
expect(guardado.descripcion).toBe('Descripción editada y guardada.');
expect(guardado.programacion.franjas[0].orden).toBe('secuencial');
expect(guardado.programacion.franjas[1].fin).toBe('12:00');
await expect(page.getByText('Cambios sin guardar', { exact: true })).toBeVisible();
// Otra pestaña comprueba lo persistido sin restaurar el borrador del historial.
const recargada = await page.context().newPage();
await recargada.goto('/juke-box/' + primero + '/editar');
await recargada.locator('html[data-hidratado]').waitFor();
await expect(recargada.getByLabel('Descripción', { exact: true })).toHaveValue(
'Descripción editada y guardada.'
);
await expect(
recargada.getByRole('button', { name: 'Secuencial', exact: true })
).toHaveAttribute('aria-pressed', 'true');
await recargada.close();
});
test('detecta solapamientos y elimina solo el jukebox elegido', async ({ page }) => {
await page.getByRole('link', { name: 'Configura tu Jukebox', exact: true }).click();
await page.getByRole('link', { name: 'Editar Mañanas', exact: true }).click();
await page.getByLabel('Hasta', { exact: true }).fill('10:30');
await expect(page.getByRole('alert')).toContainText('solapa');
await expect(page.getByRole('button', { name: 'Guardar jukebox' })).toBeDisabled();
await page.getByRole('button', { name: 'Deshacer cambios', exact: true }).click();
await page.getByRole('link', { name: 'Cancelar', exact: true }).click();
await page.getByRole('link', { name: 'Editar Tardes', exact: true }).click();
await page.getByRole('button', { name: 'Eliminar Jukebox', exact: true }).click();
await page.getByRole('button', { name: 'Sí, eliminar Jukebox', exact: true }).click();
await expect(page).toHaveURL('/juke-box/configurar');
await expect(page.locator('.coleccion .jukebox')).toHaveCount(1);
await expect(page.locator('.coleccion .jukebox')).toContainText('Mañanas');
});
});

@ -1,7 +1,7 @@
import { expect, test } from '@playwright/test';
test.describe('Juke-box', () => {
test('está junto al carrito y no dentro del menú editorial', async ({ page }) => {
test('está en las acciones de cabecera y no dentro del menú editorial', async ({ page }) => {
await page.goto('/juke-box');
await page.locator('html[data-hidratado]').waitFor();
@ -10,22 +10,24 @@ test.describe('Juke-box', () => {
const carrito = cabecera.getByRole('link', { name: /Carrito/ });
await expect(jukebox).toBeVisible();
await expect(carrito).toBeVisible();
await expect(page.locator('header .cuenta > .jukebox + .carrito')).toHaveCount(1);
const accionesCabecera = page.locator('header .cuenta');
await expect(accionesCabecera.locator('.jukebox')).toHaveCount(1);
await expect(accionesCabecera.locator('.carrito')).toHaveCount(1);
await expect(
cabecera.getByRole('navigation', { name: 'Principal' }).getByText('Juke-box')
).toHaveCount(0);
});
test('una cuenta sin prestación no puede utilizar los controles', async ({ page }) => {
test('el acceso anónimo abre el login desde el hero sin mostrar gestión debajo', async ({
page
}) => {
await page.goto('/juke-box');
await page.locator('html[data-hidratado]').waitFor();
await expect(page.getByRole('heading', { level: 1, name: 'Juke-box' })).toBeVisible();
await expect(page.getByText('Acceso reservado')).toBeVisible();
await expect(page.getByRole('button', { name: /Activar piloto automático/ })).toHaveCount(0);
await expect(page.getByRole('link', { name: 'Ver suscripciones' })).toBeVisible();
await page.getByRole('link', { name: 'Entrar', exact: true }).last().click();
await expect(page.getByRole('heading', { name: 'Mis jukebox' })).toHaveCount(0);
await expect(page.getByRole('contentinfo')).toHaveCount(1);
await page.getByRole('link', { name: 'Configura tu Jukebox', exact: true }).click();
await expect(page.getByRole('dialog', { name: 'Entrar o crear cuenta' })).toBeVisible();
await expect(page).toHaveURL('/juke-box');
});

@ -80,6 +80,91 @@ test.describe('Página de música', () => {
await expect(page.getByRole('heading', { name: 'Álbumes' })).toHaveCount(0);
});
test('Atrás y Adelante restauran la presentación y los filtros del catálogo', async ({
page
}) => {
await page.goto('/musica?vista=albumes');
await page.locator('html[data-hidratado]').waitFor();
const tabla = page.getByRole('button', { name: 'Tabla' });
const tarjetas = page.getByRole('button', { name: 'Tarjetas' });
await tabla.click();
await expect(page).toHaveURL('/musica?vista=albumes&presentacion=tabla');
await expect(tabla).toHaveAttribute('aria-pressed', 'true');
await expect(page.locator('section.albumes table')).toBeVisible();
await page
.getByRole('navigation', { name: 'Estilos' })
.getByRole('link', { name: /Baladas románticas/ })
.click();
await expect(page).toHaveURL(
'/musica?vista=albumes&presentacion=tabla&estilo=baladas-romanticas'
);
await expect(page.getByRole('status')).toContainText('estilo «Baladas románticas»');
await page.goBack();
await expect(page).toHaveURL('/musica?vista=albumes&presentacion=tabla');
await expect(page.getByRole('status')).not.toContainText('estilo «Baladas románticas»');
await expect(tabla).toHaveAttribute('aria-pressed', 'true');
await page.goBack();
await expect(page).toHaveURL('/musica?vista=albumes');
await expect(tarjetas).toHaveAttribute('aria-pressed', 'true');
await expect(page.locator('section.albumes .tarjeta')).not.toHaveCount(0);
await page.goForward();
await expect(page).toHaveURL('/musica?vista=albumes&presentacion=tabla');
await expect(tabla).toHaveAttribute('aria-pressed', 'true');
await page.goForward();
await expect(page).toHaveURL(
'/musica?vista=albumes&presentacion=tabla&estilo=baladas-romanticas'
);
await expect(page.getByRole('status')).toContainText('estilo «Baladas románticas»');
});
test('Atrás recupera la búsqueda después de abrir una canción', async ({ page }) => {
await page.goto('/musica');
const buscador = page.getByRole('searchbox', { name: 'Buscar en el catálogo' });
await buscador.fill('desamor');
await expect(page.getByRole('status')).toContainText('búsqueda «desamor»');
await page.getByRole('heading', { name: 'Prometiste' }).getByRole('link').click();
await expect(page).toHaveURL('/canciones/prometiste');
await page.goBack();
await expect(page).toHaveURL('/musica');
await expect(buscador).toHaveValue('desamor');
await expect(page.getByRole('status')).toContainText('búsqueda «desamor»');
});
test('las vistas de todos los álbumes y canciones limpian los filtros activos', async ({
page
}) => {
await page.goto('/musica?vista=albumes&estilo=clasica');
const catalogo = page.getByRole('navigation', { name: 'Ver catálogo' });
await catalogo.getByRole('link', { name: 'Todos los álbumes' }).click();
await expect(page).toHaveURL('/musica?vista=albumes');
expect(await page.locator('section.albumes .tarjeta').count()).toBeGreaterThan(0);
await page.goto('/musica?vista=canciones&idioma=ga&etiqueta=rabia');
await catalogo.getByRole('link', { name: 'Todas las canciones' }).click();
await expect(page).toHaveURL('/musica?vista=canciones');
expect(await page.locator('section.canciones .tarjeta').count()).toBeGreaterThan(0);
await page.setViewportSize({ width: 800, height: 900 });
await page.goto('/musica?vista=albumes&estilo=clasica');
await page.locator('html[data-hidratado]').waitFor();
await page.getByRole('button', { name: /Filtros/ }).click();
const dialogo = page.getByRole('dialog', { name: 'Filtrar música' });
await expect(dialogo).toBeVisible();
await dialogo.getByRole('link', { name: 'Todos los álbumes' }).click();
await expect(page).toHaveURL('/musica?vista=albumes');
await expect(dialogo).not.toBeVisible();
await expect(page.getByRole('button', { name: 'Filtros', exact: true })).toBeVisible();
await expect(page.locator('.contador-filtros')).toHaveCount(0);
expect(await page.locator('section.albumes .tarjeta').count()).toBeGreaterThan(0);
});
test('buscar y elegir vista son una sola pieza', async ({ page }) => {
/*
* Eran dos cajas con un hueco entre ellas y parecían dos controles sin
@ -178,6 +263,25 @@ test.describe('Página de música', () => {
await expect(columna).toHaveAttribute('aria-sort', 'ascending');
});
test('Atrás conserva el orden elegido en la tabla', async ({ page }) => {
await page.goto('/musica?vista=canciones&presentacion=tabla');
await page.locator('html[data-hidratado]').waitFor();
const columna = page.getByRole('columnheader', { name: 'Duración' });
await columna.getByRole('button').click();
await expect(columna).toHaveAttribute('aria-sort', 'descending');
await page.locator('section.canciones tbody th a').first().click();
await expect(page).toHaveURL(/\/canciones\//);
await page.goBack();
await expect(page).toHaveURL('/musica?vista=canciones&presentacion=tabla');
await expect(page.getByRole('columnheader', { name: 'Duración' })).toHaveAttribute(
'aria-sort',
'descending'
);
});
test('la búsqueda encuentra por etiqueta, no solo por título', async ({ page }) => {
await page.goto('/musica');
await page.getByLabel('Buscar en el catálogo').fill('desamor');
@ -212,9 +316,7 @@ test.describe('Página de música', () => {
await page.goto('/musica');
const albumes = page.locator('section.albumes');
await expect(
albumes.getByRole('heading', { level: 2, name: 'Álbumes' })
).toBeVisible();
await expect(albumes.getByRole('heading', { level: 2, name: 'Álbumes' })).toBeVisible();
await expect(albumes.getByText('Álbum', { exact: true })).toBeVisible();
await expect(albumes.getByText('Colección', { exact: true })).toBeVisible();
@ -340,9 +442,7 @@ test.describe('Página de música', () => {
await page.goto('/musica');
const catalogo = page.locator('section.albumes');
await expect(
catalogo.getByRole('heading', { level: 2, name: 'Álbumes' })
).toBeVisible();
await expect(catalogo.getByRole('heading', { level: 2, name: 'Álbumes' })).toBeVisible();
const visibles = (
await catalogo
.locator('a.tarjeta__enlace')
@ -369,6 +469,11 @@ test.describe('Página de música', () => {
await expect(page.getByRole('status')).toContainText('estilo «Baladas románticas»');
await expect(page.locator('section.albumes').getByText('Cupido sin flechas')).toBeVisible();
await expect(page.locator('section.canciones').getByText('Dorina')).toBeVisible();
await estilos.getByRole('link', { name: /Baladas románticas/ }).click();
await expect(page).toHaveURL('/musica');
await expect(estilos.locator('[aria-current]')).toHaveCount(0);
await expect(page.getByRole('status')).not.toContainText('estilo «Baladas románticas»');
});
test('el aside ofrece los idiomas del catálogo y filtra canciones y lanzamientos', async ({
@ -376,6 +481,8 @@ test.describe('Página de música', () => {
}) => {
await page.goto('/musica');
const idiomas = page.getByRole('navigation', { name: 'Idiomas' });
await expect(idiomas.getByRole('link', { name: 'Todos los idiomas' })).toHaveCount(0);
await expect(idiomas.locator('[aria-current]')).toHaveCount(0);
for (const idioma of [
'Español',
'Portugués',
@ -407,6 +514,12 @@ test.describe('Página de música', () => {
await expect(page.getByRole('heading', { name: 'Dorina' })).toHaveCount(0);
await expect(page.locator('section.albumes').getByText('Cupido sin flechas')).toBeVisible();
await frances.click();
await expect(page).toHaveURL('/musica');
await expect(idiomas.locator('[aria-current]')).toHaveCount(0);
await frances.click();
await expect(page).toHaveURL('/musica?idioma=fr');
await page
.getByRole('navigation', { name: 'Estilos' })
.getByRole('link', { name: /Baladas románticas/ })
@ -520,23 +633,48 @@ test.describe('Etiquetas', () => {
await page.getByRole('button', { name: 'Etiquetas', exact: true }).click();
await expect(nube).toBeVisible();
await expect(page.locator('aside.lateral').getByRole('link', { name: 'Rabia' })).toHaveCount(0);
await nube.getByRole('link', { name: 'Rabia', exact: true }).click();
await nube.getByRole('link', { name: /Rabia, pulsar para añadir/ }).click();
await expect(page).toHaveURL('/musica?etiqueta=rabia');
await expect(nube.getByRole('link', { name: 'Rabia', exact: true })).toHaveAttribute(
'aria-current',
'page'
);
await expect(nube.getByRole('link', { name: /Rabia, seleccionada/ })).toHaveClass(/activa/);
await expect(nube.getByRole('link', { name: 'Todas', exact: true })).toHaveCount(0);
await expect(page.getByRole('heading', { name: 'Ton silence me libère' })).toBeVisible();
await expect(page.getByRole('heading', { name: 'Tus malas maneras' })).toBeVisible();
await nube.getByRole('link', { name: 'Rabia', exact: true }).click();
await nube.getByRole('link', { name: /Rabia, seleccionada/ }).click();
await expect(page).toHaveURL('/musica');
await expect(nube).toBeVisible();
await expect(nube.getByRole('link', { name: 'Rabia', exact: true })).not.toHaveAttribute(
'aria-current',
'page'
await expect(nube.getByRole('link', { name: /Rabia, pulsar para añadir/ })).not.toHaveClass(
/activa/
);
});
test('las etiquetas admiten selección múltiple y se eliminan de forma independiente', async ({
page
}) => {
await page.goto('/musica');
await page.waitForSelector('html[data-hidratado]');
await page.getByRole('button', { name: 'Etiquetas', exact: true }).click();
const nube = page.getByRole('navigation', { name: 'Filtrar por etiqueta' });
await nube.getByRole('link', { name: /Rabia, pulsar para añadir/ }).click();
await nube.getByRole('link', { name: /Desamor, pulsar para añadir/ }).click();
await expect(page).toHaveURL('/musica?etiqueta=rabia&etiqueta=desamor');
await expect(page.getByRole('button', { name: 'Etiquetas (2)' })).toHaveAttribute(
'aria-pressed',
'true'
);
await expect(nube.getByRole('link', { name: /Rabia, seleccionada/ })).toHaveClass(/activa/);
await expect(nube.getByRole('link', { name: /Desamor, seleccionada/ })).toHaveClass(/activa/);
await expect(page.getByRole('status')).toContainText('etiquetas «Rabia» o «Desamor»');
await expect(page.getByRole('heading', { name: 'Ton silence me libère' })).toBeVisible();
await nube.getByRole('link', { name: /Rabia, seleccionada/ }).click();
await expect(page).toHaveURL('/musica?etiqueta=desamor');
await expect(nube.getByRole('link', { name: /Rabia, pulsar para añadir/ })).not.toHaveClass(
/activa/
);
await expect(nube.getByRole('link', { name: /Desamor, seleccionada/ })).toHaveClass(/activa/);
});
test('las etiquetas del desplegable se ordenan de más usada a menos', async ({ page }) => {
@ -545,7 +683,7 @@ test.describe('Etiquetas', () => {
const titulos = await page
.getByRole('navigation', { name: 'Filtrar por etiqueta' })
.locator('a:not(.todas)')
.locator('a')
.evaluateAll((enlaces) => enlaces.map((enlace) => enlace.getAttribute('title') ?? ''));
const cuentas = titulos.map((titulo) => Number(titulo.split(' ')[0]));
expect(cuentas).toEqual([...cuentas].sort((a, b) => b - a));

@ -15,12 +15,80 @@ const RUTAS = [
'/generos/glosario',
'/generos/letristas',
'/blog',
'/blog/lo-que-no-cabe-en-una-cancion',
'/interpretes',
'/interpretes/senza-paura',
'/bio',
'/contacto',
'/legal',
'/suscripciones',
'/juke-box',
'/buscar?q=amor',
'/cuenta',
'/favoritos',
'/listas',
'/carrito',
'/entrar'
'/entrar',
'/compra/cancelada',
'/esta-ruta-no-existe'
];
test.describe('Responsive', () => {
test('los índices de Taller y Géneros comparten el mismo comportamiento adaptable', async ({
page
}) => {
for (const ruta of ['/taller/el-estribillo', '/generos/bachata']) {
await page.setViewportSize({ width: 1440, height: 900 });
await page.goto(ruta);
const columnas = await page
.locator('.disposicion-con-indice')
.evaluate((elemento) => getComputedStyle(elemento).gridTemplateColumns.split(' ').length);
await expect(page.locator('aside.indice')).toHaveCSS('position', 'sticky');
expect(columnas, `${ruta} en escritorio`).toBe(2);
await page.setViewportSize({ width: 390, height: 800 });
const cajas = await Promise.all([
page.locator('.disposicion-con-indice > .contenido').boundingBox(),
page.locator('aside.indice').boundingBox()
]);
expect(cajas[0], `contenido de ${ruta}`).not.toBeNull();
expect(cajas[1], `índice de ${ruta}`).not.toBeNull();
expect(cajas[1]!.y, `orden móvil de ${ruta}`).toBeGreaterThan(cajas[0]!.y);
await expect(page.locator('aside.indice')).toHaveCSS('position', 'static');
}
});
test('las entradillas de sección conservan una jerarquía tipográfica común', async ({ page }) => {
let referencia: { color: string; fontSize: string; fontWeight: string } | undefined;
for (const ruta of [
'/musica',
'/blog',
'/generos',
'/generos/glosario',
'/generos/letristas',
'/taller',
'/interpretes',
'/bio',
'/contacto',
'/legal',
'/buscar'
]) {
await page.goto(ruta);
const estilo = await page
.locator('.entradilla-pagina')
.first()
.evaluate((elemento) => {
const css = getComputedStyle(elemento);
return { color: css.color, fontSize: css.fontSize, fontWeight: css.fontWeight };
});
referencia ??= estilo;
expect(estilo, ruta).toEqual(referencia);
}
});
for (const ancho of ANCHOS) {
test(`ninguna página desborda a lo ancho a ${ancho}px`, async ({ page }) => {
// Es el fallo clásico: un elemento se pasa de ancho, aparece una barra
@ -41,13 +109,31 @@ test.describe('Responsive', () => {
// 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']) {
const problemas: { ruta: string; controles: string[] }[] = [];
for (const ruta of [
'/',
'/musica',
'/carrito',
'/canciones/dorina',
'/blog',
'/interpretes',
'/generos',
'/generos/glosario',
'/juke-box',
'/suscripciones',
'/contacto',
'/buscar?q=amor',
'/entrar'
]) {
await page.goto(ruta);
const pequenos = await page.evaluate(() => {
const fuera: string[] = [];
for (const e of document.querySelectorAll('button, input, select, [role="button"]')) {
if (!(e instanceof HTMLElement)) continue;
if (e.matches(':disabled, [tabindex="-1"]') || e.closest('[aria-hidden="true"]')) {
continue;
}
const ancho = e.offsetWidth;
const alto = e.offsetHeight;
if (!ancho || !alto) continue;
@ -57,8 +143,10 @@ test.describe('Responsive', () => {
}
return [...new Set(fuera)];
});
expect(pequenos, `controles pequeños en ${ruta}`).toEqual([]);
if (pequenos.length) problemas.push({ ruta, controles: pequenos });
}
expect(problemas).toEqual([]);
});
test('la barra de reproducción no tapa el final de la página', async ({ page }) => {
@ -67,6 +155,7 @@ test.describe('Responsive', () => {
// fijo, en móvil se comía el pie.
await page.setViewportSize({ width: 375, height: 800 });
await page.goto('/albumes/cupido-sin-flechas');
await expect(page.locator('html')).toHaveAttribute('data-hidratado', '');
await page.getByRole('button', { name: 'Reproducir', exact: true }).first().click();
await expect(page.getByRole('region', { name: 'Reproductor' })).toBeVisible();
@ -128,6 +217,17 @@ test.describe('Responsive', () => {
await expect(nav.getByRole('link', { name: 'Contacto' })).toHaveCount(0);
});
test('la cabecera cambia de navegación en el corte responsive común', async ({ page }) => {
await page.setViewportSize({ width: 1024, height: 800 });
await page.goto('/');
await expect(page.getByRole('navigation', { name: 'Principal' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Menú' })).toBeHidden();
await page.setViewportSize({ width: 768, height: 800 });
await expect(page.getByRole('navigation', { name: 'Principal' })).toBeHidden();
await expect(page.getByRole('button', { name: 'Menú' })).toBeVisible();
});
test('el burger separa el acceso, las utilidades y el tema', async ({ page }) => {
await page.setViewportSize({ width: 375, height: 800 });
await page.goto('/');
@ -187,11 +287,21 @@ test.describe('Responsive', () => {
}) => {
await page.setViewportSize({ width: 375, height: 800 });
await page.goto('/musica/baladas-romanticas');
await page.goto('/musica?estilo=baladas-romanticas&etiqueta=autoengano');
await expect(page.locator('html')).toHaveAttribute('data-hidratado', '');
const botonFiltros = page.getByRole('button', { name: /Filtros/ });
await expect(botonFiltros).toBeVisible();
await expect(botonFiltros.getByText('1')).toHaveAttribute('aria-label', '1 filtro activo');
await expect(page.getByRole('heading', { level: 1 })).toBeVisible();
await expect(page.getByRole('navigation', { name: 'Estilos' })).toBeHidden();
await botonFiltros.focus();
const marcoFoco = await botonFiltros.evaluate((boton) => ({
borde: getComputedStyle(boton).borderTopColor,
foco: getComputedStyle(boton).outlineColor,
desfase: getComputedStyle(boton).outlineOffset
}));
expect(marcoFoco.borde).toBe(marcoFoco.foco);
expect(marcoFoco.desfase).toBe('0px');
await botonFiltros.click();
const dialogo = page.getByRole('dialog', { name: 'Filtrar música' });
@ -213,16 +323,32 @@ test.describe('Responsive', () => {
await expect(page.getByRole('navigation', { name: 'Contenido de géneros' })).toBeVisible();
});
test('la tabla se desplaza dentro de su marco, no arrastra la página', async ({ page }) => {
test('la tabla cabe o se desplaza en su marco sin arrastrar la página', async ({ page }) => {
await page.setViewportSize({ width: 375, height: 800 });
await page.goto('/musica');
await expect(page.locator('html')).toHaveAttribute('data-hidratado', '');
await page.getByRole('button', { name: 'Tabla' }).click();
const tablas = page.locator('.tabla-catalogo');
await expect(tablas).toHaveCount(2);
await expect(tablas.first()).toBeVisible();
const dentro = await page.evaluate(() => {
const marco = document.querySelector('.marco')!;
return marco.scrollWidth > marco.clientWidth;
const medidas = await page.evaluate(() => {
return {
desbordePagina: document.documentElement.scrollWidth - window.innerWidth,
marcos: [...document.querySelectorAll<HTMLElement>('.tabla-catalogo-marco')].map(
(marco) => ({
desborde: marco.scrollWidth - marco.clientWidth,
overflow: getComputedStyle(marco).overflowX
})
)
};
});
expect(dentro).toBe(true);
expect(medidas.desbordePagina).toBeLessThanOrEqual(1);
expect(medidas.marcos).toHaveLength(2);
for (const marco of medidas.marcos) {
expect(marco.desborde).toBeGreaterThanOrEqual(0);
expect(marco.overflow).toBe('auto');
}
});
test('el encabezado de la tabla no tapa la primera canción en anchura intermedia', async ({

@ -8,9 +8,7 @@ test.describe('Suscripciones', () => {
await expect(page.getByRole('heading', { level: 1, name: 'Suscripciones' })).toBeVisible();
await expect(page.getByRole('heading', { level: 3, name: 'Seguidor' })).toBeVisible();
await expect(
page.getByRole('heading', { level: 3, name: 'Amigo', exact: true })
).toBeVisible();
await expect(page.getByRole('heading', { level: 3, name: 'Amigo', exact: true })).toBeVisible();
await expect(page.getByRole('heading', { level: 3, name: 'Buen amigo' })).toBeVisible();
await expect(page.getByRole('heading', { level: 3, name: 'Taller de letras' })).toBeVisible();
await expect(page.getByText('Escucha con publicidad')).toBeVisible();

@ -292,6 +292,7 @@ test.describe('Taller de letras', () => {
test('al pulsar un término se abre su definición', async ({ page }) => {
await page.goto('/generos/son-cubano');
await page.locator('html[data-hidratado]').waitFor();
const montuno = page.locator('.texto .termino[data-termino="montuno"]').first();
await montuno.click();
@ -309,6 +310,7 @@ test.describe('Taller de letras', () => {
test('el globo no tapa el término que se acaba de pulsar', async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 900 });
await page.goto('/generos/son-cubano');
await page.locator('html[data-hidratado]').waitFor();
const termino = page.locator('.texto .termino').first();
await termino.click();
@ -390,6 +392,43 @@ test.describe('Taller de letras', () => {
await expect(page).toHaveURL('/generos');
});
test('los buscadores locales comparten foco y filtrado', async ({ page }) => {
for (const caso of [
{ ruta: '/generos', etiqueta: 'Buscar un género', vacio: 'p.vacio' },
{ ruta: '/generos/letristas', etiqueta: 'Buscar una persona', vacio: 'p.vacio' },
{ ruta: '/generos/glosario', etiqueta: 'Buscar un término', vacio: 'p.vacio' }
]) {
await page.goto(caso.ruta);
await expect(page.locator('html')).toHaveAttribute('data-hidratado', '');
const campo = page.getByLabel(caso.etiqueta);
await campo.fill('zzzz-sin-resultados');
await expect(campo).toBeFocused();
await expect(campo).toHaveCSS('outline-style', 'solid');
await expect(page.locator(caso.vacio)).toBeVisible();
}
});
test('Atrás restaura las búsquedas locales del taller', async ({ page }) => {
for (const caso of [
{ ruta: '/generos', etiqueta: 'Buscar un género' },
{ ruta: '/generos/letristas', etiqueta: 'Buscar una persona' },
{ ruta: '/generos/glosario', etiqueta: 'Buscar un término' }
]) {
await page.goto(caso.ruta);
await page.locator('html[data-hidratado]').waitFor();
const campo = page.getByLabel(caso.etiqueta);
await campo.fill('consulta que debe conservarse');
await page.locator('header a.marca').click();
await expect(page).toHaveURL('/');
await page.goBack();
await expect(page).toHaveURL(caso.ruta);
await expect(page.getByLabel(caso.etiqueta)).toHaveValue('consulta que debe conservarse');
}
});
});
test.describe('Tema claro y oscuro', () => {

@ -0,0 +1,16 @@
import { defineConfig } from '@playwright/test';
import { loadEnvFile } from 'node:process';
// Prueba local explícita: crea y elimina su propia cuenta suscrita.
loadEnvFile('.env');
process.env.E2E_JUKEBOX = '1';
export default defineConfig({
testDir: 'e2e',
testMatch: ['jukebox*.e2e.ts', 'canciones-play-tabla.e2e.ts', 'albumes-tabla.e2e.ts'],
workers: 1,
timeout: 60_000,
use: { baseURL: 'http://127.0.0.1:4180', headless: true, timezoneId: 'UTC' },
reporter: 'list',
outputDir: 'test-results-publica/jukebox-results'
});

@ -7,8 +7,19 @@ 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 extensiones = new Set(['.css', '.html', '.svelte']);
const pesosPermitidos = new Set(['400', '500', '600']);
const breakpointsPermitidos = new Set([
'30rem',
'34rem',
'42rem',
'48rem',
'60rem',
'64rem',
'75rem'
]);
const propiedadEspaciado =
'(?<![\\w-])(?:(?:padding|margin|scroll-margin)(?:-[a-z]+)?|(?:row|column)-gap|gap)';
async function recorrer(directorio) {
const encontrados = [];
@ -52,6 +63,17 @@ function contarLiterales(css, expresion) {
return valores(css, expresion).filter((valor) => !valor.includes('var(')).length;
}
function contarColoresLiterales(css) {
return [...css.matchAll(/(?:^|[;{])\s*(?:--[\w-]+|[\w-]+)\s*:\s*([^;{}]+)/gm)].reduce(
(total, coincidencia) => {
const valor = coincidencia[1];
if (valor.includes('var(')) return total;
return total + contar(valor, /#[\da-f]{3,8}\b|rgba?\(|hsla?\(|oklch\(/gi);
},
0
);
}
const rutas = await recorrer(SRC);
const documentos = await Promise.all(
rutas.map(async (ruta) => {
@ -113,14 +135,24 @@ const resumen = ambitos.map((ambito) => {
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),
radiosLiterales: valores(css, /border-radius\s*:\s*([^;\r\n]+)/g).filter(
(valor) => !valor.includes('var(') && !/^(?:0|none|inherit)$/.test(valor)
).length,
espaciados: contar(css, new RegExp(`${propiedadEspaciado}\\s*:`, 'g')),
espaciadosLiterales: valores(
css,
/(?:padding|margin|gap)(?:-[a-z]+)?\s*:\s*([^;\r\n]+)/g
new RegExp(`${propiedadEspaciado}\\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),
coloresLiterales: contarColoresLiterales(css),
movimientosLiterales: valores(
css,
/(?<![\w-])(?:transition|animation)(?:-[a-z]+)?\s*:\s*([^;{}]+)/g
).filter((valor) => !valor.includes('var(') && !/^(?:none|initial|inherit)$/.test(valor))
.length,
sombrasLiterales: valores(css, /(?<![\w-])(?:box-shadow|text-shadow)\s*:\s*([^;{}]+)/g).filter(
(valor) => !valor.includes('var(') && !/^(?:none|initial|inherit)$/.test(valor)
).length,
mediaQueries: contar(css, /@media\s*\(/g),
important: contar(css, /!important/g)
};
@ -146,11 +178,33 @@ for (const nombre of nombresFuentes) {
fuentes.push({ nombre, sha256: createHash('sha256').update(contenido).digest('hex') });
}
const infraccionesContrato = [];
for (const fila of resumen) {
for (const [metrica, etiqueta] of [
['tamanosTipograficosLiterales', 'tamaños tipográficos literales'],
['radiosLiterales', 'radios literales'],
['espaciadosLiterales', 'espaciados literales'],
['movimientosLiterales', 'movimientos literales'],
['sombrasLiterales', 'sombras literales'],
['important', 'usos de !important']
]) {
if (fila[metrica] > 0)
infraccionesContrato.push(`${fila.ambito}: ${fila[metrica]} ${etiqueta}`);
}
if (fila.ambito !== 'global' && fila.coloresLiterales > 0)
infraccionesContrato.push(`${fila.ambito}: ${fila.coloresLiterales} colores literales`);
}
for (const [valor] of breakpoints) {
if (!breakpointsPermitidos.has(valor))
infraccionesContrato.push(`breakpoint fuera de la escala compartida: ${valor}`);
}
const informe = {
archivos: documentos.length,
resumen,
variablesSinDeclarar,
pesosNoDisponibles,
infraccionesContrato,
breakpoints: [...breakpoints.entries()]
.map(([valor, veces]) => ({ valor, veces }))
.sort((a, b) => b.veces - a.veces || a.valor.localeCompare(b.valor)),
@ -170,6 +224,8 @@ if (JSON_SALIDA) {
for (const peso of pesosNoDisponibles) {
console.log(` ${peso.valor} — ${peso.archivo}:${peso.linea}`);
}
console.log(`\nInfracciones del contrato visual: ${infraccionesContrato.length}`);
for (const infraccion of infraccionesContrato) console.log(` ${infraccion}`);
console.log(`\nBreakpoints distintos: ${breakpoints.size}`);
console.log(
[...breakpoints.entries()]
@ -183,6 +239,11 @@ if (JSON_SALIDA) {
);
}
if (ESTRICTO && (variablesSinDeclarar.length > 0 || pesosNoDisponibles.length > 0)) {
if (
ESTRICTO &&
(variablesSinDeclarar.length > 0 ||
pesosNoDisponibles.length > 0 ||
infraccionesContrato.length > 0)
) {
process.exitCode = 1;
}

@ -41,6 +41,12 @@
*/
--c-accent: var(--c-marca-oro);
--c-form-action: oklch(0.8 0.095 230);
--c-form-action-hover: oklch(0.86 0.075 230);
--c-form-action-text: oklch(0.22 0.05 255);
--c-toggle-active: oklch(0.76 0.13 155);
--c-toggle-text: oklch(0.2 0.045 155);
--c-toggle-rail: var(--c-bg-raised);
--c-accent-text: oklch(0.16 0.042 264);
/*
* El dorado como superficie va aparte del dorado como texto, porque no
@ -75,25 +81,6 @@
*/
--font-versos: var(--font-display);
/* Escala amplia: el salto entre texto y titular es parte del carácter. */
--fs-xs: 0.75rem;
--fs-sm: 0.875rem;
--fs-base: 1rem;
--fs-lg: 1.1875rem;
--fs-xl: 1.5rem;
--fs-2xl: 2.125rem;
--fs-3xl: 3rem;
--fs-4xl: clamp(3.25rem, 9vw, 6rem);
--sp-1: 0.25rem;
--sp-2: 0.5rem;
--sp-3: 0.75rem;
--sp-4: 1rem;
--sp-5: 1.5rem;
--sp-6: 2rem;
--sp-7: 3rem;
--sp-8: 5rem;
/*
* El oro de una superficie no es un color plano: un metal refleja, y lo que
* lo hace parecer metal es la banda de luz que lo cruza. Cinco paradas —de
@ -148,18 +135,6 @@
var(--c-marca-oro-sombra) 100%
);
/*
* Dos radios y no más, para que nada se vea de otra familia:
*
* - `--radius-boton` para lo que se pulsa o se escribe: botones, campos,
* pastillas.
* - `--radius-tarjeta` para las superficies: tarjetas, paneles, portadas.
*
* Un canto muy redondo tira a aplicación de móvil, y esto es una web de
* letras: la esquina corta acompaña mejor a una serif.
*/
--radius-boton: 6px;
/*
* Los botones. Un solo tamaño y un solo relleno, porque antes convivían tres
* —el dorado a 16px con relleno 8/16, el de al lado a 14px con 12/24— y dos
@ -173,8 +148,6 @@
/* La versión reducida, para las esquinas de una tarjeta o una fila. */
--boton-texto-corto: 0.8125rem;
--boton-relleno-corto: var(--sp-2) var(--sp-3);
--radius-tarjeta: 8px;
/*
* 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
@ -221,6 +194,11 @@
* bronce, que mantiene el contraste como texto.
*/
:root[data-tema='claro'] {
--c-toggle-active: oklch(0.43 0.09 155);
--c-toggle-text: oklch(0.99 0.005 155);
--c-form-action: oklch(0.3 0.075 255);
--c-form-action-hover: oklch(0.37 0.085 255);
--c-form-action-text: oklch(0.98 0.005 255);
color-scheme: light;
--c-bg: oklch(0.98 0.008 264);
--c-bg-raised: oklch(0.955 0.011 264);
@ -291,13 +269,13 @@ body {
*::-webkit-scrollbar-track {
background: var(--c-bg-sunken);
border-radius: 999px;
border-radius: var(--radius-round);
}
*::-webkit-scrollbar-thumb {
min-width: 2.5rem;
border: 2px solid var(--c-bg-sunken);
border-radius: 999px;
border-radius: var(--radius-round);
background-color: var(--c-marca-oro);
background-image: var(--oro-control);
}
@ -323,7 +301,7 @@ body {
.rango-dorado::-webkit-slider-runnable-track {
height: 5px;
border: 1px solid color-mix(in oklab, var(--c-marca-oro-sombra) 55%, var(--c-border));
border-radius: 999px;
border-radius: var(--radius-round);
background-color: var(--c-bg-sunken);
background-image: var(--oro-control);
background-repeat: no-repeat;
@ -333,13 +311,13 @@ body {
.rango-dorado::-moz-range-track {
height: 5px;
border: 1px solid color-mix(in oklab, var(--c-marca-oro-sombra) 55%, var(--c-border));
border-radius: 999px;
border-radius: var(--radius-round);
background: var(--c-bg-sunken);
}
.rango-dorado::-moz-range-progress {
height: 5px;
border-radius: 999px;
border-radius: var(--radius-round);
background-color: var(--c-marca-oro);
background-image: var(--oro-control);
}
@ -349,9 +327,9 @@ body {
-webkit-appearance: none;
width: 14px;
height: 14px;
margin-top: -5px;
margin-top: calc((var(--sp-1) + var(--gap-hairline)) * -1);
border: 1px solid var(--c-marca-oro-sombra);
border-radius: 50%;
border-radius: var(--radius-round);
background-color: var(--c-marca-oro);
background-image: var(--oro-control-pulgar);
box-shadow: 0 0 0 2px var(--c-bg-raised);
@ -361,7 +339,7 @@ body {
width: 14px;
height: 14px;
border: 1px solid var(--c-marca-oro-sombra);
border-radius: 50%;
border-radius: var(--radius-round);
background-color: var(--c-marca-oro);
background-image: var(--oro-control-pulgar);
box-shadow: 0 0 0 2px var(--c-bg-raised);
@ -406,7 +384,7 @@ p {
a {
color: inherit;
text-underline-offset: 0.2em;
transition: color 0.2s var(--ease-standard);
transition: color var(--duration-normal) var(--ease-standard);
}
/*
@ -422,6 +400,75 @@ a:hover {
color: var(--c-accent);
}
/* Selección exclusiva: una barra común y un segmento verde activo. */
.toggle-rail {
display: inline-flex;
max-width: 100%;
padding: calc(var(--sp-1) - var(--gap-hairline));
gap: var(--sp-1);
border: 1px solid var(--c-border);
border-radius: var(--radius-overlay);
background: var(--c-toggle-rail);
}
.toggle-rail__option {
display: inline-flex;
flex: 1 1 auto;
align-items: center;
justify-content: center;
gap: var(--sp-2);
min-width: 0;
min-height: var(--control-height-compact);
padding: var(--sp-1) var(--sp-3);
border: 0;
border-radius: var(--radius-tarjeta);
background: transparent;
color: var(--c-text-muted);
font: inherit;
font-size: var(--boton-texto);
font-weight: var(--font-weight-regular);
letter-spacing: 0.01em;
line-height: var(--leading-ui);
cursor: pointer;
transition:
background-color var(--duration-normal) var(--ease-standard),
color var(--duration-normal) var(--ease-standard);
}
.toggle-rail__option:hover {
color: var(--c-text);
}
.toggle-rail__option[aria-pressed='true'] {
background: var(--c-toggle-active);
color: var(--c-toggle-text);
}
.toggle-rail__option:focus-visible {
outline: 2px solid var(--c-toggle-active);
outline-offset: 2px;
}
.toggle-rail__option:disabled {
cursor: not-allowed;
opacity: 0.5;
}
/* Los formularios usan azul; las acciones de reproducción conservan el oro. */
form .btn--primary,
.programador .btn--primary,
.btn.btn--form-primary {
background: var(--c-form-action);
border-color: var(--c-form-action);
color: var(--c-form-action-text);
box-shadow: none;
transition:
background-color var(--duration-normal) var(--ease-standard),
border-color var(--duration-normal) var(--ease-standard);
}
form .btn--primary:hover,
.programador .btn--primary:hover,
.btn.btn--form-primary:hover {
background: var(--c-form-action-hover);
border-color: var(--c-form-action-hover);
color: var(--c-form-action-text);
}
@media (prefers-reduced-motion: reduce) {
a {
transition: none;
@ -448,7 +495,7 @@ button {
:where(a, button, input, textarea, select, [tabindex]):focus-visible {
outline: 2px solid var(--c-focus);
outline-offset: 2px;
border-radius: 2px;
border-radius: var(--radius-detail);
}
/* --- primitivas de layout ------------------------------------------------ */
@ -476,12 +523,19 @@ button {
margin: 0 0 var(--sp-2);
}
.entradilla-pagina {
max-width: 56ch;
color: var(--c-text-muted);
font-size: var(--fs-lg);
font-weight: var(--font-weight-regular);
}
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
margin: calc(var(--gap-hairline) * -1);
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
@ -519,13 +573,20 @@ button {
* competiría con el botón dorado que suele tener al lado. El color se declara
* aquí para ganarle a la regla general de los enlaces, ya que muchos de estos
* botones lo son.
* La condición de disponibilidad no suma especificidad: las variantes de
* color deben conservar su texto, fondo y borde propios también en hover.
*/
.btn:hover {
.btn:hover:where(:not(:disabled)) {
background-color: var(--c-bg-raised);
border-color: var(--c-text-muted);
color: inherit;
}
.btn:disabled {
opacity: 0.45;
cursor: default;
}
/* --- diálogos ------------------------------------------------------------
*
* Una sola carcasa para acceso, contacto y búsqueda. El borde no forma parte
@ -611,7 +672,7 @@ button {
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);
box-shadow: var(--shadow-control-highlight);
transition:
background-position 0.6s var(--ease-emphasized),
border-color 0.2s var(--ease-standard);
@ -634,9 +695,9 @@ button {
border-color: var(--c-accent);
/*
* El color se repite aquí porque `.btn:hover` lo pone en `inherit` —para
* que la regla general de los enlaces no tiña de dorado los secundarios— y
* le gana por especificidad. Sin esta línea, el texto del botón dorado se
* aclaraba al pasar el puntero y quedaba en 1,67 de contraste sobre el oro.
* que la regla general de los enlaces no tiña de dorado los secundarios—.
* Ambas reglas tienen la misma especificidad y esta variante, declarada
* después, conserva el texto marino sobre el oro.
*/
color: var(--c-accent-text);
}
@ -669,7 +730,7 @@ button {
.chip {
display: inline-block;
padding: 0.2em 0.7em;
padding: var(--sp-1) var(--sp-3);
border: 1px solid var(--c-border);
border-radius: var(--radius-boton);
font-size: var(--fs-xs);
@ -739,7 +800,7 @@ a.chip:focus-visible {
color-mix(in oklab, var(--c-accent) 11%, transparent),
transparent 72%
);
transition: opacity 0.35s var(--ease-standard);
transition: opacity var(--duration-slow) var(--ease-standard);
}
.tarjeta:hover::before,
@ -772,7 +833,7 @@ a.chip:focus-visible {
.tarjeta:hover::after,
.tarjeta:focus-within::after {
animation: tarjeta-destello 0.82s var(--ease-emphasized) both;
animation: tarjeta-destello var(--duration-reveal) var(--ease-emphasized) both;
}
@keyframes tarjeta-destello {
@ -810,7 +871,8 @@ 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(--ease-emphasized) var(--retraso-revelado, 0ms) backwards;
animation: revelarse var(--duration-reveal) var(--ease-emphasized) var(--retraso-revelado, 0ms)
backwards;
}
@keyframes revelarse {
@ -846,7 +908,7 @@ a.chip:focus-visible {
0 1px 4px -3px rgb(0 0 0 / 0.5),
0 10px 22px -20px rgb(0 0 0 / 0.75),
inset 0 0 0 1px color-mix(in oklab, var(--c-accent) 18%, transparent);
transition-duration: 0.12s;
transition-duration: var(--duration-fast);
}
.tarjeta__medio {
@ -864,6 +926,8 @@ a.chip:focus-visible {
width: 100%;
height: 100%;
object-fit: cover;
transform: none;
transition: none;
}
/* Sombra al pie de la portada, para que el cuerpo no arranque de golpe. */
@ -946,14 +1010,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 el mínimo táctil común en
* las dos clases de tarjeta.
* menos peso no se aplicarían. Las tarjetas comparten la escala compacta de
* controles, proporcionada al título y al resto de sus acciones.
*/
.tarjeta .tarjeta__reproduccion button {
min-width: var(--control-height);
min-height: var(--control-height);
width: var(--control-height);
height: var(--control-height);
min-width: var(--control-height-compact);
min-height: var(--control-height-compact);
width: var(--control-height-compact);
height: var(--control-height-compact);
padding: 0;
}

@ -15,6 +15,26 @@
<style>
:root {
color-scheme: dark;
--c-bg: #0e1424;
--c-text: #eef1f7;
--c-text-muted: #aab3c5;
--c-border: #4a5a76;
--c-accent: #e2b567;
--c-accent-text: #00122a;
--fs-sm: 0.875rem;
--fs-base: 1rem;
--fs-lg: 1.1875rem;
--fs-error-title: clamp(2.4rem, 5vw, 4.5rem);
--sp-2: 0.5rem;
--sp-3: 0.75rem;
--sp-5: 1.5rem;
--sp-6: 2rem;
--sp-7: 3rem;
--space-error-grid: clamp(2rem, 5vw, 4rem);
--space-error-page: clamp(2rem, 7vh, 5rem);
--control-height: 2.75rem;
--radius-detail: 0.25rem;
--shadow-error-image: 0 1.5rem 5rem rgb(0 0 0 / 32%);
font-family:
system-ui,
-apple-system,
@ -31,20 +51,20 @@
margin: 0;
min-height: 100vh;
min-height: 100dvh;
background: #0e1424;
color: #eef1f7;
background: var(--c-bg);
color: var(--c-text);
line-height: 1.6;
}
main {
display: grid;
grid-template-columns: minmax(0, 30rem) minmax(0, 1fr);
gap: clamp(2rem, 5vw, 4rem);
gap: var(--space-error-grid);
align-items: center;
width: min(68rem, calc(100% - 3rem));
min-height: 100dvh;
margin-inline: auto;
padding-block: clamp(2rem, 7vh, 5rem);
padding-block: var(--space-error-page);
}
.lamina {
@ -55,8 +75,8 @@
display: block;
width: 100%;
height: auto;
border-radius: 0.25rem;
box-shadow: 0 1.5rem 5rem rgba(0, 0, 0, 0.32);
border-radius: var(--radius-detail);
box-shadow: var(--shadow-error-image);
}
.dicho {
@ -64,34 +84,34 @@
}
.codigo {
margin: 0 0 0.5rem;
font-size: 0.82rem;
margin: 0 0 var(--sp-2);
font-size: var(--fs-sm);
font-variant-numeric: tabular-nums;
letter-spacing: 0.14em;
text-transform: uppercase;
color: #e2b567;
color: var(--c-accent);
}
h1 {
margin: 0;
font-family: Georgia, 'Times New Roman', serif;
font-size: clamp(2.4rem, 5vw, 4.5rem);
font-size: var(--fs-error-title);
font-weight: 400;
line-height: 1.04;
text-wrap: balance;
}
.detalle {
margin: 1.25rem 0 0;
font-size: 1.08rem;
color: #aab3c5;
margin: var(--sp-5) 0 0;
font-size: var(--fs-lg);
color: var(--c-text-muted);
}
.acciones {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
margin-top: 2rem;
gap: var(--sp-3);
margin-top: var(--sp-6);
}
.acciones a,
@ -99,65 +119,65 @@
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 2.8rem;
padding: 0.7rem 1.15rem;
border: 1px solid #4a5a76;
border-radius: 0.25rem;
min-height: var(--control-height);
padding: var(--sp-3) var(--sp-5);
border: 1px solid var(--c-border);
border-radius: var(--radius-detail);
background: transparent;
color: #eef1f7;
color: var(--c-text);
font: inherit;
font-size: 0.94rem;
font-weight: 650;
font-size: var(--fs-base);
font-weight: 600;
text-decoration: none;
cursor: pointer;
}
.acciones a:first-child {
border-color: #e2b567;
background: #e2b567;
color: #00122a;
border-color: var(--c-accent);
background: var(--c-accent);
color: var(--c-accent-text);
}
.acciones a:hover,
.poner:hover {
border-color: #e2b567;
border-color: var(--c-accent);
}
.acciones a:focus-visible,
.poner:focus-visible {
outline: 3px solid #e2b567;
outline: 3px solid var(--c-accent);
outline-offset: 3px;
}
.musica {
margin-top: 3rem;
padding-top: 1.5rem;
border-top: 1px solid rgba(170, 179, 197, 0.25);
margin-top: var(--sp-7);
padding-top: var(--sp-5);
border-top: 1px solid color-mix(in oklab, var(--c-text-muted) 25%, transparent);
}
.poner {
gap: 0.6rem;
gap: var(--sp-3);
}
.icono {
width: 1rem;
font-size: 0.82rem;
font-size: var(--fs-sm);
line-height: 1;
text-align: center;
}
.mientras {
margin: 0.55rem 0 0;
font-size: 0.82rem;
color: #aab3c5;
margin: var(--sp-3) 0 0;
font-size: var(--fs-sm);
color: var(--c-text-muted);
}
@media (max-width: 52rem) {
@media (max-width: 60rem) {
main {
grid-template-columns: minmax(0, 1fr);
gap: 2rem;
gap: var(--sp-6);
justify-items: center;
padding-block: 2rem 3rem;
padding-block: var(--sp-6) var(--sp-7);
}
.lamina {

@ -141,20 +141,20 @@
min-width: 0;
}
.tipos-persona legend {
font-size: 1rem;
font-size: var(--fs-base);
font-weight: 600;
margin-bottom: 0.8rem;
margin-bottom: var(--sp-4);
}
.tipos-persona > div {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(155px, 1fr));
gap: 0.7rem;
margin-bottom: 0.7rem;
gap: var(--sp-3);
margin-bottom: var(--sp-3);
}
.tipos-persona label {
padding: 0.65rem;
padding: var(--sp-3);
border: 1px solid var(--borde);
border-radius: 6px;
border-radius: var(--radius-boton);
}
.tipos-persona small {
display: block;
@ -164,9 +164,9 @@
border: 0;
background: none;
color: var(--acento);
padding: 0.4rem 0;
font-size: 1rem;
padding: var(--sp-2) 0;
font-size: var(--fs-base);
font-weight: 600;
min-height: 36px;
min-height: var(--control-height-compact);
}
</style>

@ -235,33 +235,33 @@
border: 0;
}
.editor > legend {
font-size: 1.15rem;
font-size: var(--fs-lg);
font-weight: var(--font-weight-semibold);
}
.introduccion {
margin: 0.45rem 0 1rem;
margin: var(--sp-2) 0 var(--sp-4);
color: var(--muted);
}
.lista {
display: grid;
gap: 1rem;
gap: var(--sp-4);
margin: 0;
padding: 0;
list-style: none;
}
.seccion {
padding: 1rem;
padding: var(--sp-4);
border: 1px solid var(--borde);
border-radius: 8px;
border-radius: var(--radius-tarjeta);
background: color-mix(in srgb, var(--papel) 82%, var(--fondo));
}
.seccion > header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding-bottom: 0.8rem;
margin-bottom: 1rem;
gap: var(--sp-4);
padding-bottom: var(--sp-4);
margin-bottom: var(--sp-4);
border-bottom: 1px solid var(--borde);
}
.orden,
@ -269,17 +269,17 @@
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
gap: var(--sp-2);
}
.orden button,
.anadir button {
min-height: 2.4rem;
padding: 0.45rem 0.7rem;
min-height: var(--control-height);
padding: var(--sp-2) var(--sp-3);
border: 1px solid var(--borde);
border-radius: 5px;
border-radius: var(--radius-boton);
background: var(--papel);
color: inherit;
font-size: 1rem;
font-size: var(--fs-base);
cursor: pointer;
}
.orden button:disabled {
@ -287,16 +287,16 @@
cursor: default;
}
.orden .eliminar {
color: var(--peligro, #c86868);
color: var(--peligro, var(--c-danger));
}
.campos {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1rem;
gap: var(--sp-4);
}
.campos label {
display: grid;
gap: 0.4rem;
gap: var(--sp-2);
}
.campos label > span {
color: var(--muted);
@ -311,15 +311,15 @@
}
.vacio {
display: grid;
gap: 0.35rem;
padding: 1.2rem;
gap: var(--sp-2);
padding: var(--sp-5);
border: 1px dashed var(--borde);
border-radius: 8px;
border-radius: var(--radius-tarjeta);
color: var(--muted);
}
.anadir {
margin-top: 1rem;
padding-top: 1rem;
margin-top: var(--sp-4);
padding-top: var(--sp-4);
border-top: 1px solid var(--borde);
}
.anadir > span {
@ -329,7 +329,7 @@
color: var(--acento);
font-weight: var(--font-weight-semibold);
}
@media (max-width: 700px) {
@media (max-width: 48rem) {
.campos {
grid-template-columns: minmax(0, 1fr);
}

@ -132,22 +132,22 @@
<style>
.ayuda-persona {
font-size: 1rem;
font-size: var(--fs-base);
color: var(--muted);
margin: 0.4rem 0 0.8rem;
margin: var(--sp-2) 0 var(--sp-4);
}
.bloque + .bloque {
margin-top: 2rem;
margin-top: var(--sp-6);
}
.bloque h3 {
margin-bottom: 0.5rem;
margin-bottom: var(--sp-2);
}
.selector {
display: flex;
align-items: center;
gap: 0.5rem;
gap: var(--sp-2);
flex-wrap: wrap;
margin-top: 0.9rem;
margin-top: var(--sp-4);
}
.selector label:first-child {
flex: 1;
@ -159,10 +159,10 @@
}
.nombre small {
display: block;
margin-top: 0.3rem;
margin-top: var(--sp-2);
}
.nombre a {
font-weight: 600;
font-size: 1rem;
font-size: var(--fs-base);
}
</style>

@ -2,7 +2,7 @@
@import '../marca.css';
@import '../tokens.css';
:root {
font-size: 100%;
font-size: var(--fs-base);
line-height: 1.5;
font-family: var(--font-body);
color-scheme: dark;
@ -62,17 +62,17 @@ p {
}
h1 {
font-family: var(--font-display);
font-size: 2rem;
font-size: var(--fs-2xl);
letter-spacing: -0.045em;
font-weight: var(--font-weight-semibold);
margin-bottom: 0.6rem;
margin-bottom: var(--sp-3);
}
h2 {
font-size: 1.375rem;
font-size: var(--fs-xl);
letter-spacing: -0.02em;
}
h3 {
font-size: 1.125rem;
font-size: var(--fs-lg);
}
p {
line-height: 1.65;
@ -82,10 +82,10 @@ small,
color: var(--muted);
}
small {
font-size: 1rem;
font-size: var(--fs-base);
}
.eyebrow {
font-size: 1rem;
font-size: var(--fs-base);
letter-spacing: 0.06em;
text-transform: uppercase;
font-weight: var(--font-weight-semibold);
@ -95,15 +95,15 @@ small {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.6rem;
min-height: 42px;
gap: var(--sp-3);
min-height: var(--control-height);
border: 1px solid var(--borde);
border-radius: 7px;
padding: 0.65rem 1rem;
border-radius: var(--radius-boton);
padding: var(--sp-3) var(--sp-4);
background: var(--papel);
color: var(--texto);
font-weight: 600;
font-size: 1rem;
font-size: var(--fs-base);
text-decoration: none;
white-space: nowrap;
}
@ -123,47 +123,47 @@ small {
color: var(--c-danger);
}
.boton.pequeno {
font-size: 1rem;
min-height: 34px;
padding: 0.4rem 0.65rem;
font-size: var(--fs-base);
min-height: var(--control-height-compact);
padding: var(--sp-2) var(--sp-3);
}
.cabecera-pagina {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1.5rem;
margin-bottom: 2rem;
gap: var(--sp-5);
margin-bottom: var(--sp-6);
}
.cabecera-pagina p {
margin: 0;
color: var(--muted);
font-size: 1rem;
font-size: var(--fs-base);
}
.acciones {
display: flex;
align-items: center;
gap: 0.6rem;
gap: var(--sp-3);
flex-wrap: wrap;
}
.tarjeta {
background: var(--papel);
border: 1px solid var(--borde);
border-radius: 10px;
border-radius: var(--radius-tarjeta);
overflow: hidden;
}
.relleno {
padding: 1.5rem;
padding: var(--sp-5);
}
.separado {
margin-top: 1.5rem;
margin-top: var(--sp-5);
}
.estado {
display: inline-flex;
align-items: center;
gap: 0.4rem;
border-radius: 4px;
padding: 0.26rem 0.5rem;
font-size: 1rem;
gap: var(--sp-2);
border-radius: var(--radius-detail);
padding: var(--sp-1) var(--sp-2);
font-size: var(--fs-base);
background: var(--papel);
color: var(--texto);
white-space: nowrap;
@ -172,7 +172,7 @@ small {
content: '';
width: 5px;
height: 5px;
border-radius: 50%;
border-radius: var(--radius-round);
background: currentColor;
}
.estado.publicado {
@ -189,11 +189,11 @@ small {
}
.aviso {
border: 1px solid var(--borde);
border-radius: 7px;
border-radius: var(--radius-boton);
background: var(--papel);
color: var(--texto);
padding: 0.9rem 1rem;
font-size: 1rem;
padding: var(--sp-4) var(--sp-4);
font-size: var(--fs-base);
line-height: 1.55;
}
.error {
@ -208,8 +208,8 @@ small {
}
label {
display: grid;
gap: 0.45rem;
font-size: 1rem;
gap: var(--sp-2);
font-size: var(--fs-base);
font-weight: 600;
}
input:not([type='checkbox']):not([type='file']),
@ -217,11 +217,11 @@ select,
textarea {
width: 100%;
border: 1px solid var(--borde);
border-radius: 6px;
padding: 0.7rem 0.8rem;
border-radius: var(--radius-boton);
padding: var(--sp-3) var(--sp-4);
background: var(--papel);
color: var(--texto);
min-height: 42px;
min-height: var(--control-height);
}
textarea {
resize: vertical;
@ -241,32 +241,32 @@ input[type='checkbox'] {
.check {
display: flex;
align-items: center;
gap: 0.7rem;
gap: var(--sp-3);
}
.campos {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1.3rem;
gap: var(--sp-5);
}
.ancho {
grid-column: 1/-1;
}
.campo {
display: grid;
gap: 0.4rem;
scroll-margin-top: 20rem;
gap: var(--sp-2);
scroll-margin-top: calc(var(--sp-8) * 4);
}
#campo-titulo,
#campo-slug {
scroll-margin-top: 20rem;
scroll-margin-top: calc(var(--sp-8) * 4);
}
.buscar {
max-width: 27rem;
}
.filtros {
padding: 1rem 1.25rem;
padding: var(--sp-4) var(--sp-5);
display: flex;
gap: 0.75rem;
gap: var(--sp-3);
align-items: center;
border-bottom: 1px solid var(--borde);
}
@ -284,12 +284,12 @@ input[type='checkbox'] {
table {
width: 100%;
border-collapse: collapse;
font-size: 1rem;
font-size: var(--fs-base);
}
th {
text-align: left;
text-transform: uppercase;
font-size: 1rem;
font-size: var(--fs-base);
letter-spacing: 0.03em;
color: var(--texto);
font-weight: var(--font-weight-semibold);
@ -297,7 +297,7 @@ th {
}
th,
td {
padding: 1rem 1.25rem;
padding: var(--sp-4) var(--sp-5);
border-bottom: 1px solid var(--borde);
vertical-align: middle;
}
@ -311,11 +311,11 @@ td .titulo {
display: block;
font-weight: 600;
color: var(--texto);
margin-bottom: 0.25rem;
margin-bottom: var(--sp-1);
}
.marca-fila {
display: flex;
gap: 0.9rem;
gap: var(--sp-4);
align-items: center;
}
.miniatura {
@ -324,53 +324,53 @@ td .titulo {
display: grid;
place-items: center;
border: 1px solid var(--borde);
border-radius: 7px;
border-radius: var(--radius-boton);
background: var(--papel);
color: var(--texto);
font-size: 1.05rem;
font-size: var(--fs-lg);
flex: none;
}
.paginacion {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 1.25rem;
padding: var(--sp-4) var(--sp-5);
border-top: 1px solid var(--borde);
font-size: 1rem;
font-size: var(--fs-base);
}
.vacio {
padding: 4rem 2rem;
padding: calc(var(--sp-6) * 2) var(--sp-6);
text-align: center;
}
.vacio h2 {
font-size: 1.25rem;
font-size: var(--fs-xl);
}
.vacio p {
max-width: 40ch;
margin: 0 auto 1.5rem;
margin: 0 auto var(--sp-5);
color: var(--muted);
font-size: 1rem;
font-size: var(--fs-base);
}
.migas {
display: flex;
gap: 0.5rem;
gap: var(--sp-2);
align-items: center;
font-size: 1rem;
font-size: var(--fs-base);
color: var(--muted);
margin-bottom: 1.5rem;
margin-bottom: var(--sp-5);
flex-wrap: wrap;
}
.tabs {
display: flex;
gap: 1.4rem;
gap: var(--sp-5);
overflow: auto;
border-bottom: 1px solid var(--borde);
margin-bottom: 1.7rem;
margin-bottom: var(--sp-6);
}
.tabs a,
.tabs button {
padding: 1rem 0.15rem;
font-size: 1rem;
padding: var(--sp-4) var(--sp-1);
font-size: var(--fs-base);
border: 0;
border-bottom: 2px solid transparent;
background: transparent;
@ -386,52 +386,52 @@ td .titulo {
.editor-rejilla {
display: grid;
grid-template-columns: minmax(0, 1fr) 280px;
gap: 1.5rem;
gap: var(--sp-5);
align-items: start;
}
.editor-lateral {
display: grid;
gap: 1rem;
gap: var(--sp-4);
}
.editor-lateral p {
font-size: 1rem;
font-size: var(--fs-base);
}
.editor-lateral ul {
padding-left: 1rem;
font-size: 1rem;
padding-left: var(--sp-4);
font-size: var(--fs-base);
line-height: 1.8;
}
.editor-barra {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
margin-top: 1.5rem;
padding-top: 1.3rem;
gap: var(--sp-4);
margin-top: var(--sp-5);
padding-top: var(--sp-5);
border-top: 1px solid var(--borde);
}
dialog {
background: var(--papel);
color: var(--texto);
border: 1px solid var(--borde);
border-radius: 12px;
padding: 2rem;
border-radius: var(--radius-overlay);
padding: var(--sp-6);
max-width: 480px;
width: calc(100% - 2rem);
box-shadow: 0 24px 80px #0006;
box-shadow: var(--shadow-modal);
}
dialog::backdrop {
background: #020615b3;
background: var(--c-overlay);
}
dialog form {
display: grid;
gap: 1.2rem;
gap: var(--sp-5);
}
.relacion {
display: flex;
align-items: center;
gap: 0.7rem;
padding: 0.85rem 0;
gap: var(--sp-3);
padding: var(--sp-4) 0;
border-bottom: 1px solid var(--borde);
flex-wrap: wrap;
}
@ -444,7 +444,7 @@ dialog form {
}
.relacion-controles {
display: flex;
gap: 0.3rem;
gap: var(--sp-2);
}
.contenido-previo {
max-width: 72ch;
@ -455,7 +455,7 @@ dialog form {
}
.codigo {
font-family: ui-monospace, monospace;
font-size: 1rem;
font-size: var(--fs-base);
}
.oculto {
position: absolute;
@ -465,14 +465,14 @@ dialog form {
clip-path: inset(50%);
}
.carga {
animation: pulso 1s infinite alternate;
animation: pulso var(--duration-pulse) infinite alternate;
}
@keyframes pulso {
to {
opacity: 0.5;
}
}
@media (max-width: 1000px) {
@media (max-width: 64rem) {
.editor-rejilla {
grid-template-columns: 1fr;
}
@ -480,19 +480,19 @@ dialog form {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 650px) {
@media (max-width: 42rem) {
.campo,
#campo-titulo,
#campo-slug {
scroll-margin-top: 6rem;
scroll-margin-top: calc(var(--sp-7) * 2);
}
h1 {
font-size: 1.65rem;
font-size: var(--fs-2xl);
}
.cabecera-pagina {
align-items: flex-start;
flex-direction: column;
margin-bottom: 1.5rem;
margin-bottom: var(--sp-5);
}
.campos {
grid-template-columns: 1fr;
@ -508,32 +508,29 @@ dialog form {
flex: 1;
}
.relleno {
padding: 1rem;
padding: var(--sp-4);
}
th,
td {
padding: 0.9rem;
padding: var(--sp-4);
}
.editor-barra {
align-items: flex-start;
flex-direction: column;
}
.boton {
min-height: 44px;
}
}
.tipos-ficha {
display: flex;
gap: 0.35rem;
gap: var(--sp-2);
flex-wrap: wrap;
}
.tipo-persona {
display: inline-flex;
padding: 0.3rem 0.5rem;
border-radius: 5px;
padding: var(--sp-2) var(--sp-2);
border-radius: var(--radius-boton);
background: var(--papel);
color: var(--texto);
font-size: 1rem;
font-size: var(--fs-base);
white-space: nowrap;
}

@ -134,32 +134,33 @@
.busqueda-superior {
display: flex;
align-items: center;
gap: 0.5rem;
gap: var(--sp-2);
}
.busqueda-superior input {
border: 0;
background: transparent;
min-width: 220px;
font-size: 1rem;
font-size: var(--fs-base);
}
.busqueda-superior button {
border: 0;
background: transparent;
color: var(--muted);
font-size: 1.4rem;
padding: 0.4rem;
font-size: var(--fs-xl);
padding: var(--sp-2);
}
@media (max-width: 760px) {
@media (max-width: 48rem) {
.busqueda-superior {
display: none;
}
}
.estudio-app {
--ancho-aside: 264px;
display: flex;
min-height: 100vh;
}
aside {
width: 264px;
width: var(--ancho-aside);
position: fixed;
inset: 0 auto 0 0;
background: var(--papel);
@ -172,10 +173,10 @@
.marca {
display: flex;
align-items: center;
gap: 0.85rem;
gap: var(--sp-4);
color: var(--texto);
padding: 1.25rem 1.25rem 1rem;
font-size: 1.35rem;
padding: var(--sp-5) var(--sp-5) var(--sp-4);
font-size: var(--fs-xl);
font-family: var(--font-display);
letter-spacing: -0.035em;
font-weight: var(--font-weight-semibold);
@ -187,33 +188,33 @@
display: block;
color: var(--texto);
letter-spacing: 0.08em;
font-size: 1rem;
font-size: var(--fs-base);
font-weight: var(--font-weight-medium);
font-family: var(--font-body);
margin-top: 0.3rem;
margin-top: var(--sp-2);
}
.grupo {
padding-top: 0.75rem;
padding-top: var(--sp-3);
}
.grupo p {
font-size: 1rem;
font-size: var(--fs-base);
font-weight: var(--font-weight-semibold);
color: var(--muted);
margin: 0 0 0.2rem;
padding: 0 0.75rem;
margin: 0 0 var(--sp-1);
padding: 0 var(--sp-3);
}
nav {
padding: 0 0.8rem;
padding: 0 var(--sp-4);
}
nav a {
display: flex;
gap: 0.7rem;
gap: var(--sp-3);
align-items: center;
color: var(--texto);
font-size: 1.0625rem;
font-size: var(--fs-lg);
line-height: 1.4;
padding: 0.25rem 0.75rem;
border-radius: 5px;
padding: var(--sp-1) var(--sp-3);
border-radius: var(--radius-boton);
margin-bottom: 0;
}
nav a:hover {
@ -228,15 +229,15 @@
box-shadow: inset 2px 0 var(--acento);
}
.inicio {
margin-top: 0.4rem;
margin-top: var(--sp-2);
}
.inicio span {
font-size: 1.15rem;
font-size: var(--fs-lg);
}
.superficie {
min-width: 0;
margin-left: 264px;
width: calc(100% - 264px);
margin-left: var(--ancho-aside);
width: calc(100% - var(--ancho-aside));
display: flex;
flex-direction: column;
}
@ -245,7 +246,7 @@
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 2.8rem;
padding: 0 var(--sp-7);
background: var(--papel);
border-bottom: 1px solid var(--borde);
position: sticky;
@ -253,22 +254,22 @@
z-index: 20;
}
.ver-sitio {
font-size: 1rem;
font-size: var(--fs-base);
color: var(--texto);
margin-right: 0.8rem;
margin-right: var(--sp-4);
}
.cuenta {
margin-left: 0.7rem;
margin-left: var(--sp-3);
}
.cuenta summary {
width: 34px;
height: 34px;
border-radius: 50%;
width: var(--control-height);
height: var(--control-height);
border-radius: var(--radius-round);
display: grid;
place-items: center;
background: var(--papel);
color: var(--texto);
font-size: 1rem;
font-size: var(--fs-base);
cursor: pointer;
}
.cuenta div,
@ -276,16 +277,16 @@
position: absolute;
right: 0;
top: calc(100% + 0.7rem);
padding: 0.8rem;
padding: var(--sp-4);
border: 1px solid var(--borde);
background: var(--papel);
border-radius: 8px;
box-shadow: 0 8px 25px #25352b15;
border-radius: var(--radius-tarjeta);
box-shadow: var(--shadow-popover);
min-width: 190px;
}
.cuenta small {
display: block;
margin-bottom: 0.8rem;
margin-bottom: var(--sp-4);
}
.crear-menu,
.cuenta {
@ -297,19 +298,20 @@
}
.crear-menu a {
display: block;
padding: 0.7rem;
font-size: 1rem;
padding: var(--sp-3);
font-size: var(--fs-base);
}
main {
padding: 2.5rem 2.8rem;
padding: var(--sp-7) var(--sp-7);
flex: 1;
max-width: 1500px;
width: 100%;
margin: 0 auto;
margin-block: 0;
margin-inline: auto;
}
footer {
padding: 1.5rem 2.8rem;
font-size: 1rem;
padding: var(--sp-5) var(--sp-7);
font-size: var(--fs-base);
letter-spacing: 0.03em;
color: var(--muted);
display: flex;
@ -321,15 +323,15 @@
.entorno {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 1rem;
margin-bottom: 1.6rem;
gap: var(--sp-2);
font-size: var(--fs-base);
margin-bottom: var(--sp-6);
color: var(--texto);
}
.entorno > span:first-child {
width: 6px;
height: 6px;
border-radius: 50%;
border-radius: var(--radius-round);
background: var(--papel);
}
.movil {
@ -338,24 +340,20 @@
.izquierda {
display: flex;
align-items: center;
gap: 1rem;
gap: var(--sp-4);
}
@media (max-width: 1000px) {
aside {
width: 248px;
}
.superficie {
margin-left: 248px;
width: calc(100% - 248px);
@media (max-width: 64rem) {
.estudio-app {
--ancho-aside: 248px;
}
main {
padding: 2rem 1.5rem;
padding: var(--sp-6) var(--sp-5);
}
.barra-superior {
padding: 0 1.5rem;
padding: 0 var(--sp-5);
}
}
@media (max-width: 760px) {
@media (max-width: 48rem) {
aside {
transform: translateX(-100%);
top: 70px;
@ -363,10 +361,10 @@
}
aside.abierto {
transform: none;
box-shadow: 12px 0 40px #173b3033;
box-shadow: var(--shadow-popover);
}
nav a {
min-height: 44px;
min-height: var(--control-height);
}
.superficie {
margin-left: 0;
@ -377,13 +375,13 @@
}
.barra-superior {
height: 70px;
padding: 0 1rem;
padding: 0 var(--sp-4);
}
.ver-sitio {
display: none;
}
main {
padding: 1.5rem 1rem;
padding: var(--sp-5) var(--sp-4);
}
.entorno {
flex-wrap: wrap;
@ -392,7 +390,7 @@
flex-basis: 100%;
}
footer {
padding: 1.5rem 1rem;
padding: var(--sp-5) var(--sp-4);
}
}
</style>

@ -70,31 +70,31 @@
.atajos {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 1rem;
gap: var(--sp-4);
}
.cabecera-atajo {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
gap: var(--sp-4);
}
.cabecera-atajo h2 {
margin: 0;
}
.cabecera-atajo span {
font-size: 1.5rem;
font-size: var(--fs-xl);
}
.atajos p {
margin: 1rem 0;
margin: var(--sp-4) 0;
}
.tipo {
display: block;
margin-top: 0.3rem;
margin-top: var(--sp-2);
}
header.relleno p {
margin-bottom: 0;
}
@media (max-width: 1100px) {
@media (max-width: 75rem) {
.atajos {
grid-template-columns: 1fr;
}

@ -683,13 +683,13 @@
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 1rem 1.25rem;
gap: var(--sp-4);
padding: var(--sp-4) var(--sp-5);
background: var(--papel);
border: 1px solid var(--borde);
border-radius: 8px;
margin-bottom: 1.5rem;
box-shadow: 0 5px 18px #0003;
border-radius: var(--radius-tarjeta);
margin-bottom: var(--sp-5);
box-shadow: var(--shadow-popover);
}
.estado-editor {
flex: 1 1 22rem;
@ -698,7 +698,7 @@
color: var(--acento);
}
.estado-editor p {
margin: 0.35rem 0 0;
margin: var(--sp-2) 0 0;
color: var(--muted);
}
.resultado {
@ -709,32 +709,32 @@
flex-basis: 100%;
}
.lista-pendientes {
padding-left: 1.2rem;
padding-left: var(--sp-5);
}
.lista-pendientes li {
margin-bottom: 0.75rem;
margin-bottom: var(--sp-3);
line-height: 1.5;
}
.explicacion-flujo {
padding-left: 1.2rem;
padding-left: var(--sp-5);
}
.explicacion-flujo li {
margin-bottom: 1rem;
margin-bottom: var(--sp-4);
}
.explicacion-flujo span {
display: block;
color: var(--muted);
margin-top: 0.25rem;
margin-top: var(--sp-1);
}
.revision-publicacion {
max-width: 680px;
max-height: calc(100dvh - 2rem);
overflow: auto;
}
@media (max-width: 760px) {
@media (max-width: 48rem) {
.flujo-editor {
position: static;
padding: 1rem;
padding: var(--sp-4);
}
.flujo-editor .acciones {
width: 100%;
@ -749,8 +749,8 @@
margin: 0;
}
.tipos-contexto legend {
font-size: 1rem;
margin-bottom: 0.8rem;
font-size: var(--fs-base);
margin-bottom: var(--sp-4);
font-weight: var(--font-weight-semibold);
}
.campos-listos {
@ -763,8 +763,8 @@
.fila-grabacion {
display: flex;
align-items: center;
gap: 1rem;
padding: 1.2rem 1.5rem;
gap: var(--sp-4);
padding: var(--sp-5) var(--sp-5);
border-top: 1px solid var(--borde);
}
.fila-grabacion > div {
@ -772,51 +772,51 @@
}
.fila-grabacion small {
display: block;
margin-top: 0.3rem;
margin-top: var(--sp-2);
}
.fila-grabacion a {
font-size: 1rem;
font-size: var(--fs-base);
font-weight: 600;
}
.pasos {
display: grid;
gap: 0.8rem;
margin-top: 1.5rem;
gap: var(--sp-4);
margin-top: var(--sp-5);
}
.pasos a {
display: grid;
grid-template-columns: 30px 1fr;
gap: 0.3rem 1rem;
padding: 1rem;
gap: var(--sp-2) var(--sp-4);
padding: var(--sp-4);
border: 1px solid var(--borde);
border-radius: 8px;
border-radius: var(--radius-tarjeta);
}
.pasos a span {
grid-row: span 2;
font-size: 1rem;
font-size: var(--fs-base);
color: var(--texto);
padding-top: 0.1rem;
padding-top: var(--sp-1);
}
.pasos a strong {
font-size: 1rem;
font-size: var(--fs-base);
}
.pasos a small {
font-size: 1rem;
font-size: var(--fs-base);
line-height: 1.6;
}
.enlace-relacion {
display: flex;
justify-content: space-between;
gap: 1rem;
padding: 0.8rem 0;
gap: var(--sp-4);
padding: var(--sp-4) 0;
border-bottom: 1px solid var(--borde);
font-size: 1rem;
font-size: var(--fs-base);
}
.preview-imagen {
width: 180px;
max-height: 200px;
object-fit: contain;
border-radius: 8px;
border-radius: var(--radius-tarjeta);
}
audio {
max-width: 100%;
@ -824,13 +824,13 @@
}
summary {
cursor: pointer;
font-size: 1rem;
font-size: var(--fs-base);
}
input[type='file'] {
padding: 0.8rem;
padding: var(--sp-4);
border: 1px dashed var(--borde);
border-radius: 7px;
border-radius: var(--radius-boton);
max-width: 100%;
font-size: 1rem;
font-size: var(--fs-base);
}
</style>

@ -99,7 +99,7 @@
.configuracion {
color: var(--c-marca-oro);
font-family: var(--font-body);
font-size: 0.875rem;
font-size: var(--fs-sm);
}
.nombre-logotipo {

@ -79,16 +79,16 @@
<style>
.tipos-nueva {
margin: 1.5rem 0;
margin: var(--sp-5) 0;
border: 1px solid var(--borde);
border-radius: 8px;
padding: 1rem;
border-radius: var(--radius-tarjeta);
padding: var(--sp-4);
}
.tipos-nueva legend {
font-weight: var(--font-weight-semibold);
font-size: 1rem;
font-size: var(--fs-base);
}
.tipos-nueva p {
font-size: 1rem;
font-size: var(--fs-base);
}
</style>

@ -91,8 +91,8 @@
<style>
.uso {
display: block;
font-size: 1rem;
padding: 0.3rem 0;
font-size: var(--fs-base);
padding: var(--sp-2) 0;
}
audio {
width: 230px;

@ -136,7 +136,7 @@
<style>
.acceso {
min-height: 100dvh;
padding: 3rem 1rem;
padding: var(--sp-7) var(--sp-4);
background: var(--fondo);
display: flex;
flex-direction: column;
@ -146,10 +146,10 @@
.marca {
display: flex;
align-items: center;
gap: 0.8rem;
margin-bottom: 1.8rem;
gap: var(--sp-4);
margin-bottom: var(--sp-6);
color: var(--texto);
font-size: 2rem;
font-size: var(--fs-2xl);
font-family: var(--font-display);
font-weight: var(--font-weight-semibold);
}
@ -158,39 +158,39 @@
max-width: 470px;
background: var(--papel);
border: 1px solid var(--borde);
border-radius: 14px;
padding: 2.3rem;
box-shadow: 0 14px 48px #253e2910;
border-radius: var(--radius-overlay);
padding: var(--sp-7);
box-shadow: var(--shadow-modal);
}
h1 {
font-size: 1.7rem;
font-size: var(--fs-2xl);
line-height: 1.2;
letter-spacing: -0.04em;
margin: 1.5rem 0 0.7rem;
margin: var(--sp-5) 0 var(--sp-3);
}
.descripcion {
font-size: 1rem;
font-size: var(--fs-base);
color: var(--muted);
margin-bottom: 1.6rem;
margin-bottom: var(--sp-6);
}
.pasos {
display: flex;
gap: 1.5rem;
gap: var(--sp-5);
list-style: none;
padding: 0;
margin: 1.3rem 0;
margin: var(--sp-5) 0;
color: var(--texto);
font-size: 1rem;
font-size: var(--fs-base);
}
.pasos li {
display: flex;
align-items: center;
gap: 0.5rem;
gap: var(--sp-2);
}
.pasos span {
width: 24px;
height: 24px;
border-radius: 50%;
border-radius: var(--radius-round);
display: grid;
place-items: center;
background: var(--papel);
@ -206,33 +206,33 @@
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.8rem;
gap: var(--sp-4);
background: var(--papel);
border-radius: 7px;
padding: 1rem;
margin: 1.2rem 0 1.5rem;
border-radius: var(--radius-boton);
padding: var(--sp-4);
margin: var(--sp-5) 0 var(--sp-5);
}
.destinatario strong {
display: block;
font-size: 1rem;
font-size: var(--fs-base);
overflow-wrap: anywhere;
}
.destinatario a {
font-size: 1rem;
font-size: var(--fs-base);
flex: none;
}
.destinatario small {
display: block;
margin-bottom: 0.3rem;
margin-bottom: var(--sp-2);
}
form {
display: grid;
gap: 0.7rem;
gap: var(--sp-3);
}
.principal {
width: 100%;
margin-top: 0.6rem;
min-height: 46px;
margin-top: var(--sp-3);
min-height: var(--control-height-touch);
}
.secundario {
background: transparent;
@ -241,69 +241,69 @@
}
.entrada-codigo {
letter-spacing: 0.35em;
font-size: 1.5rem;
font-size: var(--fs-xl);
text-align: center;
min-height: 56px;
}
.reenviar {
margin-top: 1.5rem;
padding-top: 1.3rem;
margin-top: var(--sp-5);
padding-top: var(--sp-5);
border-top: 1px solid var(--borde);
text-align: center;
}
.reenviar p {
font-size: 1rem;
margin-bottom: 0.2rem;
font-size: var(--fs-base);
margin-bottom: var(--sp-1);
}
.reenviar small {
font-size: 1rem;
font-size: var(--fs-base);
}
.nota-prueba {
margin: 1.5rem 0 0;
padding: 1rem;
border-radius: 6px;
margin: var(--sp-5) 0 0;
padding: var(--sp-4);
border-radius: var(--radius-boton);
background: var(--papel);
color: var(--texto);
font-size: 1rem;
font-size: var(--fs-base);
}
.codigo-prueba {
display: grid;
text-align: center;
gap: 0.4rem;
gap: var(--sp-2);
background: var(--papel);
padding: 1rem;
margin-bottom: 1.3rem;
border-radius: 8px;
padding: var(--sp-4);
margin-bottom: var(--sp-5);
border-radius: var(--radius-tarjeta);
}
.codigo-prueba span {
font-size: 1rem;
font-size: var(--fs-base);
}
.codigo-prueba strong {
font-size: 1.8rem;
font-size: var(--fs-2xl);
letter-spacing: 0.2em;
}
.pie-acceso {
font-size: 1rem;
font-size: var(--fs-base);
color: var(--texto);
margin-top: 1.5rem;
margin-top: var(--sp-5);
}
@media (max-width: 500px) {
@media (max-width: 34rem) {
.acceso {
padding: 1.5rem 1rem;
padding: var(--sp-5) var(--sp-4);
justify-content: flex-start;
}
.acceso-tarjeta {
padding: 1.4rem;
padding: var(--sp-5);
}
.destinatario {
align-items: flex-start;
flex-direction: column;
}
.marca {
margin-bottom: 1rem;
margin-bottom: var(--sp-4);
}
h1 {
font-size: 1.55rem;
font-size: var(--fs-2xl);
}
}
</style>

@ -0,0 +1,143 @@
<script lang="ts">
type AccionExploracion = {
clave: string;
texto: string;
activa: boolean;
alPulsar: () => void;
controla?: string;
expandida?: boolean;
};
interface Props {
id: string;
etiqueta: string;
placeholder: string;
valor?: string;
acciones: AccionExploracion[];
etiquetaAcciones: string;
desplegada?: boolean;
}
let {
id,
etiqueta,
placeholder,
valor = $bindable(''),
acciones,
etiquetaAcciones,
desplegada = false
}: Props = $props();
</script>
<div class="barra" class:desplegada>
<label class="visually-hidden" for={id}>{etiqueta}</label>
<input {id} type="search" bind:value={valor} {placeholder} autocomplete="off" />
<div class="vistas" role="group" aria-label={etiquetaAcciones}>
{#each acciones as accion (accion.clave)}
<button
type="button"
class="vista"
class:oro={accion.activa}
aria-pressed={accion.activa}
aria-expanded={accion.expandida}
aria-controls={accion.controla}
onclick={accion.alPulsar}
>
{accion.texto}
</button>
{/each}
</div>
</div>
<style>
.barra {
display: flex;
align-items: stretch;
border: 1px solid var(--c-border);
border-radius: var(--radius-boton);
background: var(--c-bg-sunken);
overflow: hidden;
}
.barra.desplegada {
border-radius: var(--radius-boton) var(--radius-boton) 0 0;
}
.barra:focus-within {
border-color: var(--c-accent);
box-shadow: 0 0 0 2px color-mix(in oklab, var(--c-accent) 30%, transparent);
}
input[type='search'] {
flex: 1 1 12rem;
min-width: 0;
scroll-margin-top: calc(var(--altura-cabecera) + var(--sp-3));
padding: var(--boton-relleno);
border: 0;
background: transparent;
color: inherit;
font: inherit;
font-size: var(--boton-texto);
}
input[type='search']:focus-visible {
outline: none;
}
.vistas {
display: flex;
border-left: 1px solid var(--c-border);
}
.vista {
padding: var(--boton-relleno);
border: 0;
background-color: transparent;
font: inherit;
font-size: var(--boton-texto);
white-space: nowrap;
cursor: pointer;
transition:
background-color var(--duration-fast) var(--ease-standard),
color var(--duration-fast) var(--ease-standard);
}
.vista:not(.oro) {
color: var(--c-text-muted);
}
.vista + .vista {
border-left: 1px solid var(--c-border);
}
.vista:hover:not(.oro) {
background-color: var(--c-bg-raised);
color: var(--c-text);
}
@media (max-width: 30rem) {
.barra {
flex-direction: column;
}
input[type='search'] {
flex: 0 0 auto;
}
.vistas {
border-top: 1px solid var(--c-border);
border-left: 0;
}
.vista {
flex: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.vista {
transition: none;
}
}
</style>

@ -180,7 +180,7 @@
}
}
@media (max-width: 27rem) {
@media (max-width: 30rem) {
.rejilla {
grid-template-columns: minmax(0, 1fr);
}

@ -71,7 +71,7 @@
<style>
.documento {
display: grid;
gap: clamp(2rem, 5vw, 3.5rem);
gap: var(--space-section);
}
.bloque {
min-width: 0;
@ -83,21 +83,21 @@
}
h2 {
margin: 0 0 var(--sp-4);
font-size: clamp(1.75rem, 3.5vw, 2.2rem);
font-size: var(--fs-content-title);
line-height: 1.08;
}
.prosa {
font-size: clamp(1.125rem, 1.7vw, 1.25rem);
font-size: var(--fs-content-intro);
line-height: 1.78;
}
.prosa :global(p) {
margin-block: 0 1.35em;
margin-block: 0 var(--space-prose-paragraph);
}
.editorial .prosa :global(> p:first-child::first-letter) {
float: left;
margin: 0.08em 0.12em 0 0;
margin: var(--space-drop-cap-top) var(--space-drop-cap-side) 0 0;
font-family: var(--font-display);
font-size: 4.4em;
font-size: var(--fs-drop-cap);
line-height: 0.72;
color: var(--c-accent);
}
@ -106,8 +106,8 @@
grid-template-columns: auto 1fr;
align-items: center;
gap: var(--sp-4);
margin: clamp(2.75rem, 7vw, 4.5rem) 0 var(--sp-5);
font-size: clamp(2rem, 4vw, 2.75rem);
margin: var(--space-editorial-heading) 0 var(--sp-5);
font-size: var(--fs-content-section);
line-height: 1;
letter-spacing: -0.025em;
}
@ -152,7 +152,7 @@
transparent
);
font-family: var(--font-versos);
font-size: 1.15em;
font-size: var(--fs-quote-emphasis);
color: var(--c-text-muted);
}
.prosa :global(a) {
@ -210,7 +210,7 @@
}
.editorial .prosa :global(> p:first-child::first-letter) {
font-size: 3.8em;
font-size: var(--fs-drop-cap-compact);
}
.editorial .prosa :global(> h2) {

@ -148,7 +148,7 @@
border: 1px solid var(--c-border);
border-radius: var(--radius-tarjeta);
background: var(--c-bg-raised);
box-shadow: 0 0.75rem 2.5rem color-mix(in oklab, black 40%, transparent);
box-shadow: var(--shadow-popover);
}
.titulo {
@ -168,7 +168,7 @@
height: var(--control-height);
padding: 0;
border: 0;
border-radius: 50%;
border-radius: var(--radius-round);
background: transparent;
color: var(--c-text-muted);
cursor: pointer;
@ -254,7 +254,7 @@
.nueva input {
min-width: 0;
width: 100%;
min-height: 2.75rem;
min-height: var(--control-height);
padding: var(--sp-2);
border: 0;
border-radius: 0;
@ -265,7 +265,7 @@
}
.nueva button {
min-height: 2.75rem;
min-height: var(--control-height);
padding-inline: var(--sp-3);
border: 0;
border-left: 1px solid var(--c-accent);

@ -75,7 +75,11 @@
}
const indiceAutorizado =
modo === 'pista' ? autorizadas.findIndex((p) => p.id === objetivoOriginal?.id) : 0;
reproductor.reproducirLista(autorizadas, Math.max(0, indiceAutorizado));
reproductor.reproducirLista(
autorizadas,
Math.max(0, indiceAutorizado),
modo === 'pista' ? 'cancion' : 'lista'
);
}
const indisponible = $derived(
@ -144,8 +148,8 @@
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(--ease-emphasized);
box-shadow: var(--shadow-control-highlight);
transition: background-position var(--duration-slow) var(--ease-emphasized);
}
.solido:hover:not(:disabled) {

@ -451,7 +451,7 @@
font-weight: 600;
line-height: 1.15;
letter-spacing: 0.01em;
color: #fff;
color: var(--c-text-on-media);
text-decoration: none;
}
@ -558,11 +558,11 @@
z-index: 2;
display: grid;
min-width: 14rem;
gap: 0.125rem;
gap: calc(var(--sp-1) / 2);
padding: var(--sp-2);
border-radius: var(--radius-tarjeta);
background: var(--c-bg-raised);
box-shadow: 0 1rem 2.5rem color-mix(in oklab, black 30%, transparent);
box-shadow: var(--shadow-popover);
}
.menu-usuario a {
@ -604,7 +604,7 @@
justify-content: center;
min-width: 1.4em;
height: 1.4em;
padding-inline: 0.35em;
padding-inline: var(--sp-2);
border-radius: var(--radius-boton);
background: var(--c-accent);
color: var(--c-accent-text);
@ -637,7 +637,7 @@
* reduce el aire entre elementos; convertir aquí toda la cabecera en un
* botón de menú desperdicia una anchura en la que todavía cabe cómodamente.
*/
@media (max-width: 72rem) {
@media (max-width: 75rem) {
.fila {
gap: var(--sp-2);
}
@ -670,7 +670,7 @@
}
}
@media (max-width: 56rem) {
@media (max-width: 60rem) {
/*
* El desplegable sale del flujo y vive en su propia capa debajo de la
* barra. Abrirlo no cambia la altura de la cabecera ni desplaza la página.

@ -0,0 +1,33 @@
<script lang="ts">
interface Props {
id: string;
etiqueta: string;
placeholder: string;
valor?: string;
}
let { id, etiqueta, placeholder, valor = $bindable('') }: Props = $props();
</script>
<label class="visually-hidden" for={id}>{etiqueta}</label>
<input {id} type="search" bind:value={valor} {placeholder} autocomplete="off" />
<style>
input {
flex: 1 1 auto;
width: 100%;
min-width: 0;
padding: var(--boton-relleno);
border: 1px solid var(--c-border);
border-radius: var(--radius-boton);
background: var(--c-bg-sunken);
color: inherit;
font: inherit;
font-size: var(--boton-texto);
}
input:focus-visible {
outline: 2px solid var(--c-accent);
outline-offset: 1px;
}
</style>

@ -0,0 +1,60 @@
<script lang="ts">
let {
id,
etiqueta,
valor = $bindable(''),
tipo = 'text',
opciones,
maxlength
}: {
id: string;
etiqueta: string;
valor?: string;
tipo?: 'text' | 'time' | 'textarea';
opciones?: { valor: string; texto: string }[];
maxlength?: number;
} = $props();
</script>
<div class="campo">
<label for={id}>{etiqueta}</label>
{#if opciones}
<select {id} bind:value={valor}>
{#each opciones as opcion (opcion.valor)}<option value={opcion.valor}>{opcion.texto}</option
>{/each}
</select>
{:else if tipo === 'textarea'}
<textarea {id} bind:value={valor} {maxlength} rows="3"></textarea>
{:else}
<input {id} type={tipo} bind:value={valor} {maxlength} required />
{/if}
</div>
<style>
.campo {
display: grid;
gap: var(--sp-2);
min-width: 0;
}
label {
font-size: var(--fs-sm);
font-weight: var(--font-weight-medium);
}
input,
select,
textarea {
width: 100%;
min-width: 0;
min-height: var(--control-height);
padding: var(--sp-2) var(--sp-3);
border: 1px solid var(--c-border);
border-radius: var(--radius-boton);
background: var(--c-bg);
color: var(--c-text);
font: inherit;
font-size: var(--fs-base);
}
textarea {
resize: vertical;
}
</style>

@ -172,7 +172,7 @@
{/if}
{#if capitulo.resumen}
<p class="entradilla">{capitulo.resumen}</p>
<p class="entradilla-pagina">{capitulo.resumen}</p>
{/if}
{#if ficha?.origenLugar}
@ -348,9 +348,7 @@
<ul class="temas">
{#each temas as tema (tema.slug)}
<li>
<a href={resolve(`/musica?etiqueta=${encodeURIComponent(tema.slug)}`)}
>{tema.nombre}</a
>
<a href={resolve(`/musica?etiqueta=${encodeURIComponent(tema.slug)}`)}>{tema.nombre}</a>
</li>
{/each}
</ul>
@ -552,11 +550,6 @@
color: var(--c-text-muted);
}
.entradilla {
font-size: var(--fs-lg);
color: var(--c-text-muted);
}
.rotulo {
margin: 0 0 var(--sp-2);
font-size: var(--fs-xs);
@ -587,7 +580,7 @@
align-items: center;
gap: var(--sp-2);
font-size: var(--fs-xs);
padding: 0.2rem 0.6rem;
padding: var(--sp-1) var(--sp-3);
border: 1px solid var(--c-border);
border-radius: var(--radius-boton);
color: var(--c-text-muted);
@ -615,7 +608,7 @@
.datos {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(11rem, 1fr));
gap: 1px;
gap: var(--gap-hairline);
margin-bottom: var(--sp-7);
background: var(--c-border);
border: 1px solid var(--c-border);
@ -689,7 +682,7 @@
color: var(--c-accent);
}
@media (max-width: 68rem) {
@media (max-width: 64rem) {
.lectura {
grid-template-columns: minmax(0, 1fr);
}
@ -740,7 +733,7 @@
.personas,
.obras {
display: grid;
gap: 1px;
gap: var(--gap-hairline);
background: var(--c-border);
border: 1px solid var(--c-border);
border-radius: var(--radius-tarjeta);
@ -789,7 +782,7 @@
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
padding: 0.15rem 0.5rem;
padding: var(--sp-1) var(--sp-2);
border-radius: var(--radius-boton);
white-space: nowrap;
color: var(--c-text-muted);
@ -888,7 +881,7 @@
.rama li {
display: grid;
gap: 0.1rem;
gap: var(--sp-1);
padding-left: var(--sp-3);
border-left: 2px solid var(--c-border);
}
@ -995,7 +988,7 @@
grid-column: 1;
display: flex;
flex-wrap: wrap;
gap: 0.15rem;
gap: var(--sp-1);
margin: var(--sp-2) 0 0;
}
@ -1007,8 +1000,8 @@
.silaba {
font-family: var(--font-mono);
font-size: var(--fs-xs);
padding: 0.05rem 0.3rem;
border-radius: 3px;
padding: var(--sp-1) var(--sp-2);
border-radius: var(--radius-detail);
background: color-mix(in oklab, var(--c-text) 7%, transparent);
color: var(--c-text-muted);
}
@ -1047,7 +1040,7 @@
height: 1.25rem;
display: grid;
place-items: center;
border-radius: 50%;
border-radius: var(--radius-round);
font-size: var(--fs-xs);
font-weight: 600;
color: var(--c-accent);
@ -1115,7 +1108,7 @@
.escuchar a {
display: block;
font-size: var(--fs-xs);
padding: 0.2rem 0.6rem;
padding: var(--sp-1) var(--sp-3);
border: 1px solid var(--c-border);
border-radius: var(--radius-boton);
color: var(--c-text-muted);
@ -1203,7 +1196,7 @@
text-align: right;
}
@media (max-width: 52rem) {
@media (max-width: 60rem) {
.instrumentos li,
.personas li {
grid-template-columns: minmax(0, 1fr);

@ -194,7 +194,7 @@
gap: var(--sp-2);
padding: var(--sp-1);
border: 1px solid var(--c-border);
border-radius: 999px;
border-radius: var(--radius-round);
background: color-mix(in oklab, var(--c-bg) 70%, transparent);
backdrop-filter: var(--desenfoque-mandos);
}
@ -206,11 +206,11 @@
width: var(--control-height);
height: 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 var(--ease-standard);
transition: color var(--duration-normal) var(--ease-standard);
}
.flecha:hover,
@ -257,7 +257,7 @@
width: 0.5rem;
height: 0.5rem;
transform: translate(-50%, -50%);
border-radius: 999px;
border-radius: var(--radius-round);
background: var(--c-text-muted);
opacity: 0.5;
transition:
@ -276,7 +276,7 @@
opacity: 1;
}
@media (max-width: 40rem) {
@media (max-width: 42rem) {
.mandos {
right: 50%;
transform: translateX(50%);

@ -88,7 +88,7 @@
width: 2.75rem;
height: 2.75rem;
border: 1px solid var(--c-border);
border-radius: 50%;
border-radius: var(--radius-round);
background: var(--c-bg-raised);
color: var(--c-text);
font: inherit;
@ -136,7 +136,7 @@
height: 100%;
}
@media (max-width: 40rem) {
@media (max-width: 42rem) {
.carril {
grid-auto-columns: min(82vw, 18rem);
}

@ -75,8 +75,8 @@
.clasificacion {
display: flex;
align-items: center;
gap: 0.45em;
margin-top: 0.15rem;
gap: var(--sp-2);
margin-top: var(--sp-1);
font-size: var(--fs-xs);
line-height: 1.25;
color: var(--c-text-muted);
@ -97,7 +97,7 @@
display: flex;
align-items: center;
gap: var(--sp-2);
margin-top: 0.3rem;
margin-top: var(--sp-2);
}
.mas {
@ -119,7 +119,7 @@
}
.marca {
padding: 0.1em 0.6em;
padding: var(--sp-1) var(--sp-3);
border: 1px solid var(--c-border);
border-radius: var(--radius-boton);
color: var(--c-text-muted);

@ -0,0 +1,50 @@
<script lang="ts">
import type { Snippet } from 'svelte';
let { indice, contenido }: { indice: Snippet; contenido: Snippet } = $props();
</script>
<div class="container disposicion-con-indice">
<aside class="indice">
{@render indice()}
</aside>
<div class="contenido">
{@render contenido()}
</div>
</div>
<style>
.disposicion-con-indice {
display: grid;
grid-template-columns: 13rem minmax(0, 1fr);
gap: var(--sp-6);
align-items: start;
}
.indice {
position: sticky;
top: calc(var(--altura-cabecera) + var(--sp-5));
max-height: calc(100vh - var(--altura-cabecera) - var(--sp-6));
overflow-y: auto;
padding-top: var(--sp-7);
}
.contenido {
min-width: 0;
}
@media (max-width: 60rem) {
.disposicion-con-indice {
grid-template-columns: minmax(0, 1fr);
}
.indice {
position: static;
order: 1;
max-height: none;
padding-top: var(--sp-5);
border-top: 1px solid var(--c-border);
}
}
</style>

@ -75,7 +75,7 @@
}
input:not([type='checkbox']) {
max-width: 100%;
padding: 0.5rem;
padding: var(--sp-2);
background: var(--c-bg-sunken);
color: var(--c-text);
border: 1px solid var(--c-border);

@ -0,0 +1,135 @@
<script lang="ts">
import { tonoDeEtiqueta } from '$lib/utils/etiquetas';
interface Props {
texto: string;
href?: string;
activa?: boolean;
etiquetaAccesible?: string;
title?: string;
contador?: number;
/** Variante compacta para metadatos dentro de filas y tarjetas. */
menuda?: boolean;
alPulsar?: (evento: MouseEvent) => void;
}
let {
texto,
href,
activa = false,
etiquetaAccesible,
title,
contador,
menuda = false,
alPulsar
}: Props = $props();
</script>
{#snippet contenido()}
<span>{texto}</span>
{#if contador !== undefined}<span class="contador">{contador}</span>{/if}
{/snippet}
{#if href}
<a
class="etiqueta"
class:activa
class:menuda
style="--tono: {tonoDeEtiqueta(texto)}"
{href}
aria-label={etiquetaAccesible}
{title}
onclick={alPulsar}
>
{@render contenido()}
</a>
{:else if alPulsar}
<button
type="button"
class="etiqueta"
class:activa
class:menuda
style="--tono: {tonoDeEtiqueta(texto)}"
aria-label={etiquetaAccesible}
aria-pressed={activa}
{title}
onclick={alPulsar}
>
{@render contenido()}
</button>
{:else}
<span class="etiqueta" class:menuda style="--tono: {tonoDeEtiqueta(texto)}" {title}>
{@render contenido()}
</span>
{/if}
<style>
.etiqueta {
--claro: oklch(0.84 0.11 var(--tono));
--apagado: oklch(0.52 0.08 var(--tono));
--velo: oklch(0.84 0.11 var(--tono) / 0.14);
display: inline-flex;
align-items: center;
gap: var(--sp-2);
min-height: 1.75rem;
padding: var(--sp-1) var(--sp-3);
border: 1px solid var(--apagado);
border-radius: var(--radius-boton);
background: transparent;
color: var(--claro);
font: inherit;
font-size: var(--fs-xs);
line-height: 1.35;
text-decoration: none;
transition:
background-color var(--duration-normal) var(--ease-standard),
border-color var(--duration-normal) var(--ease-standard);
}
button.etiqueta {
cursor: pointer;
}
/*
* En tablas es información secundaria. La cápsula conserva el color y el
* borde del sistema, pero ocupa una sola línea compacta y no fuerza la altura
* táctil de los controles principales.
*/
.etiqueta.menuda {
min-height: 1.5rem;
padding: 0.0625rem var(--sp-2);
line-height: 1.2;
}
.etiqueta:is(a, button):hover,
.etiqueta:is(a, button):focus-visible,
.etiqueta.activa {
border-color: var(--claro);
background: var(--velo);
color: var(--claro);
}
.contador {
font-family: var(--font-mono);
font-size: var(--fs-xs);
}
:global(:root[data-tema='claro']) .etiqueta {
--claro: oklch(0.45 0.13 var(--tono));
--apagado: oklch(0.78 0.08 var(--tono));
--velo: oklch(0.45 0.13 var(--tono) / 0.1);
}
@media (max-width: 42rem) {
.etiqueta:is(a, button):not(.menuda) {
min-height: var(--control-height);
}
}
@media (prefers-reduced-motion: reduce) {
.etiqueta {
transition: none;
}
}
</style>

@ -16,7 +16,7 @@
*/
import { resolve } from '$app/paths';
import { aSlug } from '$lib/content/schema';
import { tonoDeEtiqueta } from '$lib/utils/etiquetas';
import EtiquetaColor from './EtiquetaColor.svelte';
interface Props {
etiquetas: string[];
@ -42,17 +42,13 @@
<ul class="etiquetas" class:menudas aria-label={titulo}>
{#each etiquetas as etiqueta (etiqueta)}
<li>
{#if enlaces}
<a
class="etiqueta"
style="--tono: {tonoDeEtiqueta(etiqueta)}"
href={resolve(`/musica?etiqueta=${encodeURIComponent(aSlug(etiqueta))}`)}
>
{etiqueta}
</a>
{:else}
<span class="etiqueta" style="--tono: {tonoDeEtiqueta(etiqueta)}">{etiqueta}</span>
{/if}
<EtiquetaColor
texto={etiqueta}
menuda={menudas}
href={enlaces
? resolve(`/musica?etiqueta=${encodeURIComponent(aSlug(etiqueta))}`)
: undefined}
/>
</li>
{/each}
</ul>
@ -68,58 +64,7 @@
padding: 0;
}
/*
* En oscuro el color va claro y el borde apagado; el relleno del hover es el
* mismo tono a un 12 % de opacidad, que sobre el marino se nota sin encender
* la etiqueta.
*/
.etiqueta {
--claro: oklch(0.84 0.11 var(--tono));
--apagado: oklch(0.52 0.08 var(--tono));
--velo: oklch(0.84 0.11 var(--tono) / 0.14);
display: inline-block;
padding: 0.2em 0.7em;
border: 1px solid var(--apagado);
border-radius: var(--radius-boton);
background: transparent;
font-size: var(--fs-xs);
color: var(--claro);
text-decoration: none;
transition:
background-color 0.3s var(--ease-standard),
border-color 0.3s var(--ease-standard);
}
/*
* En claro se invierte: el texto tiene que bajar de claridad para leerse sobre
* el fondo casi blanco, y el borde subir para no ser un trazo negro.
*/
:global(:root[data-tema='claro']) .etiqueta {
--claro: oklch(0.45 0.13 var(--tono));
--apagado: oklch(0.78 0.08 var(--tono));
--velo: oklch(0.45 0.13 var(--tono) / 0.1);
}
/* La misma cápsula, más pequeña: en una fila no debe pesar más que el título. */
.menudas {
gap: var(--sp-2);
}
.menudas .etiqueta {
padding: 0.1em 0.55em;
font-size: var(--fs-xs);
}
a.etiqueta:hover,
a.etiqueta:focus-visible {
border-color: var(--claro);
background: var(--velo);
}
@media (prefers-reduced-motion: reduce) {
.etiqueta {
transition: none;
}
}
</style>

@ -212,6 +212,9 @@
}
h2 {
display: flex;
align-items: center;
min-height: var(--control-height);
margin: 0;
padding: var(--sp-2) var(--sp-3);
border-bottom: 1px solid var(--c-border);

@ -4,8 +4,9 @@
type OpcionesCatalogo = {
vista?: string;
presentacion?: string;
estilo?: string;
etiqueta?: string;
etiquetas?: string[];
idioma?: string;
};
@ -20,9 +21,10 @@
bloques: EstiloConObra[];
idiomas: IdiomaConTotal[];
estiloActual: string;
etiquetaActual: string;
etiquetasActuales: string[];
idiomaActual: string;
vistaActual: string;
presentacionActual: string;
rutaDeCatalogo: (opciones?: OpcionesCatalogo) => '/musica' | `/musica?${string}`;
movil?: boolean;
}
@ -32,9 +34,10 @@
bloques,
idiomas,
estiloActual,
etiquetaActual,
etiquetasActuales,
idiomaActual,
vistaActual,
presentacionActual,
rutaDeCatalogo,
movil = false
}: Props = $props();
@ -50,8 +53,11 @@
<ul aria-labelledby={idCatalogo}>
<li>
<a
href={resolve(rutaDeCatalogo())}
aria-current={!vistaActual && !estiloActual && !etiquetaActual && !idiomaActual
href={resolve(rutaDeCatalogo({ presentacion: presentacionActual }))}
aria-current={!vistaActual &&
!estiloActual &&
etiquetasActuales.length === 0 &&
!idiomaActual
? 'page'
: undefined}
>
@ -60,30 +66,26 @@
</li>
<li>
<a
href={resolve(
rutaDeCatalogo({
vista: 'albumes',
estilo: estiloActual,
etiqueta: etiquetaActual,
idioma: idiomaActual
})
)}
aria-current={vistaActual === 'albumes' ? 'page' : undefined}
href={resolve(rutaDeCatalogo({ vista: 'albumes', presentacion: presentacionActual }))}
aria-current={vistaActual === 'albumes' &&
!estiloActual &&
etiquetasActuales.length === 0 &&
!idiomaActual
? '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}
href={resolve(rutaDeCatalogo({ vista: 'canciones', presentacion: presentacionActual }))}
aria-current={vistaActual === 'canciones' &&
!estiloActual &&
etiquetasActuales.length === 0 &&
!idiomaActual
? 'page'
: undefined}
>
<span>Todas las canciones</span>
</a>
@ -100,16 +102,16 @@
href={resolve(
rutaDeCatalogo({
vista: vistaActual,
estilo: bloque.estilo.slug,
etiqueta: etiquetaActual,
presentacion: presentacionActual,
estilo: bloque.estilo.slug === estiloActual ? '' : bloque.estilo.slug,
etiquetas: etiquetasActuales,
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'}"
title={bloque.estilo.slug === estiloActual
? `Quitar filtro de estilo ${bloque.estilo.nombre}`
: `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>
@ -122,33 +124,22 @@
<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,
presentacion: presentacionActual,
estilo: estiloActual,
etiqueta: etiquetaActual,
idioma: idioma.codigo
etiquetas: etiquetasActuales,
idioma: idioma.codigo === idiomaActual ? '' : idioma.codigo
})
)}
aria-current={idioma.codigo === idiomaActual ? 'page' : undefined}
title="{idioma.total} {idioma.total === 1 ? 'canción' : 'canciones'} en {idioma.nombre}"
title={idioma.codigo === idiomaActual
? `Quitar filtro de idioma ${idioma.nombre}`
: `${idioma.total} ${idioma.total === 1 ? 'canción' : 'canciones'} en ${idioma.nombre}`}
>
<span>{idioma.nombre}</span>
<span class="cuenta muted">{idioma.total}</span>
@ -190,7 +181,6 @@
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);
@ -202,7 +192,6 @@
}
a[aria-current='page'] {
border-left-color: var(--c-accent);
color: var(--c-accent);
font-weight: var(--font-weight-semibold);
}
@ -218,10 +207,6 @@
column-gap: var(--sp-3);
}
.todos-idiomas {
grid-column: 1 / -1;
}
.movil {
gap: var(--sp-7);
font-size: var(--fs-base);

@ -186,7 +186,7 @@
* deja de tener izquierda y derecha: pasa a cerrar de abajo hacia arriba,
* que es como quedan repartidos.
*/
@media (max-width: 62rem) {
@media (max-width: 64rem) {
.velo {
background: linear-gradient(
to top,
@ -240,7 +240,7 @@
linear-gradient(to top, var(--c-bg) 0%, transparent 22%);
}
@media (max-width: 62rem) {
@media (max-width: 64rem) {
:global(:root[data-tema='claro']) .velo:not(.foto):not(.cerrado) {
background: linear-gradient(
to top,

@ -68,7 +68,7 @@
input {
width: 100%;
min-width: 0;
min-height: 3rem;
min-height: var(--control-height-touch);
padding: var(--sp-3);
border: 0;
border-radius: var(--radius-boton) 0 0 var(--radius-boton);
@ -85,7 +85,7 @@
justify-content: center;
gap: var(--sp-2);
flex: none;
margin: -1px;
margin: calc(var(--gap-hairline) * -1);
border-radius: 0 var(--radius-boton) var(--radius-boton) 0;
}
.filtros {
@ -97,7 +97,7 @@
select {
width: 100%;
min-width: 0;
min-height: 2.75rem;
min-height: var(--control-height);
padding: var(--sp-2);
border: 1px solid var(--c-border);
border-radius: var(--radius-boton);

@ -16,22 +16,25 @@
/** Evita identificadores repetidos si el diálogo se abre sobre /contacto. */
prefijoId?: string;
compacto?: boolean;
/** Valores controlables para conservar un borrador al navegar. */
campos?: ValoresContacto;
}
let { resultadoInicial = null, prefijoId = '', compacto = false }: Props = $props();
let enviando = $state(false);
let errorConexion = $state('');
let respuesta = $state<RespuestaContacto | null>(untrack(() => resultadoInicial));
let campos = $state(
untrack(() => ({
let {
resultadoInicial = null,
prefijoId = '',
compacto = false,
campos = $bindable<ValoresContacto>({
nombre: resultadoInicial?.valores?.nombre ?? '',
email: resultadoInicial?.valores?.email ?? '',
motivo: resultadoInicial?.valores?.motivo ?? '',
mensaje: resultadoInicial?.valores?.mensaje ?? ''
}))
);
})
}: Props = $props();
let enviando = $state(false);
let errorConexion = $state('');
let respuesta = $state<RespuestaContacto | null>(untrack(() => resultadoInicial));
const errores = $derived(respuesta?.errores);
const exito = $derived(Boolean(respuesta?.exito));
const id = (campo: string) => (prefijoId ? `${prefijoId}-${campo}` : campo);
@ -191,6 +194,7 @@
select,
textarea {
width: 100%;
min-height: var(--control-height);
padding: var(--sp-2) var(--sp-3);
border: 1px solid var(--c-border);
border-radius: var(--radius-boton);

@ -172,7 +172,7 @@
background: var(--c-bg-raised);
border: 1px solid var(--c-border);
border-radius: var(--radius-tarjeta);
box-shadow: 0 12px 32px oklch(0 0 0 / 0.35);
box-shadow: var(--shadow-popover);
}
/*

@ -0,0 +1,33 @@
<script lang="ts">
import Icono from './Icono.svelte';
import type { ComponentProps } from 'svelte';
let {
etiqueta,
valor,
opciones,
cambiar
}: {
etiqueta: string;
valor: string;
opciones: readonly {
valor: string;
texto: string;
icono?: ComponentProps<typeof Icono>['nombre'];
}[];
cambiar: (valor: string) => void;
} = $props();
</script>
<div class="toggle-rail" role="group" aria-label={etiqueta}>
{#each opciones as opcion (opcion.valor)}
<button
type="button"
class="toggle-rail__option"
aria-pressed={valor === opcion.valor}
onclick={() => cambiar(opcion.valor)}
>
{#if opcion.icono}<Icono nombre={opcion.icono} tamano={18} />{/if}
{opcion.texto}
</button>
{/each}
</div>

@ -154,7 +154,7 @@
.ficha {
display: flex;
flex-wrap: wrap;
gap: 0.4em;
gap: var(--sp-2);
font-size: var(--fs-sm);
margin-bottom: var(--sp-5);
}
@ -177,7 +177,7 @@
justify-content: center;
}
@media (max-width: 62rem) {
@media (max-width: 64rem) {
.heroe {
padding-block: calc(var(--altura-cabecera) + var(--sp-5)) var(--sp-5);
min-height: 0;

@ -10,6 +10,7 @@
const ICONOS = {
play: { tipo: 'relleno', d: ['M8 5v14l11-7z'] },
pausa: { tipo: 'relleno', d: ['M7 4h3.5v16H7z', 'M13.5 4H17v16h-3.5z'] },
detener: { tipo: 'relleno', d: ['M6 6h12v12H6z'] },
anterior: { tipo: 'relleno', d: ['M19 5v14L8 12z', 'M6 5h2v14H6z'] },
siguiente: { tipo: 'relleno', d: ['M5 5v14l11-7z', 'M18 5h2v14h-2z'] },
aleatorio: {
@ -31,6 +32,17 @@
},
silencio: { tipo: 'linea', d: ['M11 5 6 9H2v6h4l5 4z', 'M22 9l-6 6', 'M16 9l6 6'] },
cola: { tipo: 'linea', d: ['M4 6h11', 'M4 12h11', 'M4 18h7', 'M17 11l4 3-4 3z'] },
jukebox: {
tipo: 'linea',
d: [
'M4 21V10a8 8 0 0 1 16 0v11Z',
'M7 11v-1a5 5 0 0 1 10 0v1Z',
'M8 15h8',
'M8 18h8',
'M4 11h3v10',
'M20 11h-3v10'
]
},
/* Las mismas tres rayas de la cola, con un «+» en vez de la flecha. */
'anadir-cola': {
tipo: 'linea',

@ -99,7 +99,7 @@
border-left: 2px solid transparent;
padding-left: var(--sp-3);
margin-left: calc(var(--sp-3) * -1);
transition: color 0.2s ease;
transition: color var(--duration-normal) var(--ease-standard);
}
a:hover {

@ -32,7 +32,7 @@
<div class="container">
<p class="eyebrow">{eyebrow}</p>
<h2>{titulo}</h2>
<p class="entradilla">{texto}</p>
<p class="entradilla entradilla-pagina">{texto}</p>
<div class="acciones">{@render acciones()}</div>
</div>
</section>
@ -58,8 +58,6 @@
.entradilla {
max-width: 52ch;
margin-top: var(--sp-4);
font-size: var(--fs-lg);
color: var(--c-text-muted);
}
.acciones {

@ -54,7 +54,7 @@
height: var(--lado);
object-fit: contain;
transform-origin: center;
animation: giro-logo 8s linear infinite;
animation: giro-logo var(--duration-logo) linear infinite;
animation-play-state: paused;
user-select: none;
}

@ -327,7 +327,7 @@
justify-content: space-between;
gap: var(--sp-3);
width: 100%;
padding: 0.15rem 0.15rem 0.15rem 0;
padding: var(--sp-1) var(--sp-1) var(--sp-1) 0;
border: 0;
background: none;
color: inherit;
@ -349,7 +349,7 @@
.cuantos {
display: inline-flex;
gap: 0.35rem;
gap: var(--sp-2);
font-family: var(--font-mono);
font-size: var(--fs-xs);
}
@ -363,11 +363,22 @@
color: var(--c-dato-tenue);
}
@media (max-width: 52rem) {
@media (max-width: 60rem) {
.conjunto {
grid-template-columns: minmax(0, 1fr);
}
.listado {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
column-gap: var(--sp-4);
}
.fila {
align-items: center;
min-height: var(--control-height);
}
/*
* Aquí la Península ocupa unos noventa píxeles de ancho: cuarenta a la
* izquierda la meterían encima de América.
@ -479,7 +490,7 @@
.punto {
fill: transparent;
stroke: none;
transition: opacity 0.15s var(--ease-standard);
transition: opacity var(--duration-fast) var(--ease-standard);
opacity: 0;
}

@ -27,12 +27,13 @@
*/
interface Props {
migas: Miga[];
compacta?: boolean;
}
let { migas }: Props = $props();
let { migas, compacta = false }: Props = $props();
</script>
<nav class="migas" aria-label="Ruta">
<nav class="migas" class:compacta aria-label="Ruta">
<ol>
<!--
La clave es la posición y no el texto: un disco y un tema pueden llamarse
@ -58,6 +59,9 @@
font-size: var(--fs-sm);
color: var(--c-text-muted);
}
.migas.compacta {
padding-block: 0;
}
ol {
display: flex;

@ -240,7 +240,7 @@
* En pantallas estrechas la imagen pasa a ser una franja arriba: dos
* columnas de diez centímetros no son dos columnas, son dos rendijas.
*/
@media (max-width: 44rem) {
@media (max-width: 48rem) {
.marco {
grid-template-columns: minmax(0, 1fr);
max-width: 26rem;

@ -140,7 +140,7 @@
line-height: 1.5;
}
@media (max-width: 50rem) {
@media (max-width: 48rem) {
.marco {
grid-template-columns: minmax(0, 1fr);
max-width: 30rem;

@ -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(--ease-standard);
transition: opacity var(--duration-reveal) var(--ease-standard);
}
canvas.activo {

@ -9,6 +9,7 @@
* pestañas: la letra de una canción es el contenido principal del sitio y no
* puede depender de que el navegador ejecute nada.
*/
import { goto } from '$app/navigation';
import type { Snippet } from 'svelte';
import { page } from '$app/state';
@ -29,21 +30,23 @@
let { pestanas, panel, inicial, etiqueta }: Props = $props();
/** Lo que se ha pulsado; `undefined` mientras no se haya tocado nada. */
let elegida = $state<string | undefined>(undefined);
/**
* La pestaña abierta se deriva en vez de sincronizarse con un efecto: si la
* lista cambia y la elegida deja de existir, cae sola en la de partida.
* La pestaña abierta vive en el fragmento de la URL. Así se puede enlazar y
* Atrás/Adelante recuperan el panel anterior. Un fragmento desconocido cae
* solo en la pestaña inicial.
*/
const activa = $derived.by(() => {
const existe = (id?: string) => id !== undefined && pestanas.some((p) => p.id === id);
if (existe(elegida)) return elegida;
const desdeUrl = page.url.hash.startsWith('#panel-')
? page.url.hash.slice('#panel-'.length)
: undefined;
if (existe(desdeUrl)) return desdeUrl;
return existe(inicial) ? inicial : pestanas[0]?.id;
});
let botones: HTMLButtonElement[] = $state([]);
let paneles: HTMLElement[] = $state([]);
let hashSinDesplazar = '';
/*
* Un enlace a `#panel-<id>` abre esa pestaña y baja hasta ella.
@ -59,13 +62,24 @@
const id = page.url.hash.slice(prefijo.length);
const indice = pestanas.findIndex((p) => p.id === id);
if (indice === -1) return;
if (page.url.hash === hashSinDesplazar) {
hashSinDesplazar = '';
return;
}
elegida = id;
// Tras abrirla: el panel acaba de dejar de estar oculto, así que hasta
// ahora no tenía posición a la que desplazarse.
paneles[indice]?.scrollIntoView({ block: 'start', behavior: 'smooth' });
});
function elegir(id: string): void {
if (id === activa) return;
const destino = new URL(page.url);
destino.hash = `panel-${id}`;
hashSinDesplazar = destino.hash;
void goto(destino, { keepFocus: true, noScroll: true });
}
function alPulsarTecla(evento: KeyboardEvent, indice: number) {
const ultimo = pestanas.length - 1;
let destino: number | null = null;
@ -88,8 +102,8 @@
if (destino === null) return;
evento.preventDefault();
elegida = pestanas[destino].id;
botones[destino]?.focus();
elegir(pestanas[destino].id);
}
</script>
@ -105,7 +119,7 @@
aria-selected={activa === pestana.id}
aria-controls="panel-{pestana.id}"
tabindex={activa === pestana.id ? 0 : -1}
onclick={() => (elegida = pestana.id)}
onclick={() => elegir(pestana.id)}
onkeydown={(e) => alPulsarTecla(e, i)}
>
{pestana.etiqueta}
@ -220,7 +234,7 @@
/* Al dejar de estar oculto, el panel entra con una transición corta. */
.panel:not([hidden]) {
animation: entrar-panel 0.25s var(--ease-emphasized) both;
animation: entrar-panel var(--duration-normal) var(--ease-emphasized) both;
}
@keyframes entrar-panel {

@ -5,6 +5,7 @@
import { pedirContacto } from '$lib/contacto-modal.svelte';
import { site } from '$lib/site';
import LogoSenzaPaura from './LogoSenzaPaura.svelte';
let { continuo = false }: { continuo?: boolean } = $props();
const ano = new Date().getFullYear();
@ -19,7 +20,7 @@
};
</script>
<footer class="pie">
<footer class="pie" class:continuo>
<div class="container contenido">
<div class="identidad">
<a class="emblema" href={resolve('/')} aria-label={site.nombre}>
@ -98,6 +99,9 @@
padding-block: var(--sp-6) var(--sp-5);
font-size: var(--fs-sm);
}
.pie.continuo {
margin-top: 0;
}
.contenido {
display: grid;

@ -50,9 +50,9 @@
span {
font-family: var(--font-display);
font-size: clamp(4rem, 10vw, 8rem);
font-size: var(--fs-avatar-monogram);
line-height: 1;
color: var(--c-accent);
text-shadow: 0 1rem 3rem rgb(0 0 0 / 0.4);
text-shadow: var(--shadow-text-avatar);
}
</style>

@ -45,6 +45,9 @@
contexto?: Contexto[];
/** Ordenar pulsando los encabezados. En un disco no: manda el orden del disco. */
ordenable?: boolean;
/** Columna y dirección controlables para conservar el orden al navegar. */
campo?: Campo;
ascendente?: boolean;
vacio?: string;
}
@ -53,6 +56,8 @@
numerar = false,
contexto = ['estilo', 'album', 'ano'],
ordenable = false,
campo = $bindable<Campo>('titulo'),
ascendente = $bindable(true),
vacio = 'Ningún tema coincide.'
}: Props = $props();
@ -87,9 +92,6 @@
const visibles = $derived(COLUMNAS.filter((c) => contexto.includes(c.campo as Contexto)));
let campo = $state<Campo>('titulo');
let ascendente = $state(true);
/**
* Lo que se compara en cada columna. Un valor ausente devuelve `undefined` y
* se va al final, mande la dirección que mande: una fila sin año no es «el
@ -147,56 +149,6 @@
// arriba es lo que se suele querer ver primero.
ascendente = !NUMERICAS.has(cual);
}
/* ---------------------------------------------------------------------
* La portada ampliada al pasar por encima de la miniatura
*
* Va en `position: fixed` porque una fila mide setenta y pico píxeles:
* colgada de ahí, la tarjeta se saldría de la tabla y quedaría a merced de
* cualquier `overflow` de los antepasados —y esta tabla tiene uno—. Fija,
* solo depende de la ventana.
*
* Solo con ratón. No se abre con el teclado a propósito: haría falta un
* punto de tabulación por fila para enseñar una imagen que ya está, más
* grande, en la ficha del tema a la que lleva el título.
* ------------------------------------------------------------------ */
/*
* Cuántas etiquetas caben antes de estorbar. Se cortan por número y no con un
* degradado al final: recortar «Autoengaño» a «Autoeng» no parece una
* elección, parece que algo se ha roto. El resto se cuenta con un «+2», que
* dice lo mismo sin partir ninguna palabra.
*/
const LADO = 260;
const ALTO = LADO + 46;
const AIRE = 12;
let vista = $state<{ src: string; titulo: string; x: number; y: number } | null>(null);
function asomar(evento: MouseEvent, cancion: Cancion): void {
if (!cancion.portada) return;
const caja = (evento.currentTarget as HTMLElement).getBoundingClientRect();
const margen = 16;
/* A la derecha si cabe; si no, al otro lado. En una ventana estrecha no
cabe en ninguno de los dos, y entonces se pega al margen izquierdo. */
const derecha = caja.right + AIRE;
const izquierda = caja.left - AIRE - LADO;
const x =
derecha + LADO <= window.innerWidth - margen
? derecha
: izquierda >= margen
? izquierda
: margen;
/* Centrada en la fila, sin salirse por arriba ni por abajo. */
const centro = caja.top + caja.height / 2 - ALTO / 2;
const y = Math.min(Math.max(centro, margen), window.innerHeight - ALTO - margen);
vista = { src: cancion.portada, titulo: cancion.titulo, x, y };
}
</script>
{#if ordenadas.length === 0}
@ -211,7 +163,6 @@
<thead>
<tr>
<th scope="col" class="control"><span class="visually-hidden">Reproducir</span></th>
<th scope="col" class="control"><span class="visually-hidden">Portada</span></th>
{#if numerar}
<th scope="col" class="control numerica">
<span class="visually-hidden">Pista</span>
@ -301,23 +252,16 @@
{@const c = fila.cancion}
<tr class:sonando={reproductor.esActual(pistas[i].id)}>
<td class="control">
<BotonReproducir {pistas} indice={i} tamano={16} />
</td>
<td class="control">
<a
class="mini"
href={resolve('/(sitio)/canciones/[slug]', { slug: c.slug })}
tabindex="-1"
onmouseenter={(e) => asomar(e, c)}
onmouseleave={() => (vista = null)}
>
<div class="mini">
{#if c.portada}
<img src={c.portada} alt="" width="80" height="80" loading="lazy" />
{:else}
<SinPortada titulo={c.titulo} lado={80} muda />
{/if}
</a>
<div class="mini-control">
<BotonReproducir {pistas} indice={i} tamano={24} />
</div>
</div>
</td>
{#if numerar}
@ -373,22 +317,6 @@
{/each}
</tbody>
</TablaCatalogo>
{#if vista}
<!--
`aria-hidden`: es la misma portada de la miniatura de al lado, en grande.
Para quien no la ve no añade nada, y anunciarla interrumpiría la lectura
de la tabla cada vez que el ratón roza una fila.
-->
<div
class="vista-previa"
style="--x: {vista.x}px; --y: {vista.y}px; --lado: {LADO}px"
aria-hidden="true"
>
<img src={vista.src} alt="" width={LADO} height={LADO} />
<p>{vista.titulo}</p>
</div>
{/if}
{/if}
<style>
@ -418,38 +346,45 @@
font-weight: 400;
}
/*
* `pointer-events: none` para que la tarjeta no se cruce con el ratón: si lo
* capturase, al salir de la miniatura hacia ella el `mouseleave` la cerraría
* y volvería a abrirse en bucle.
*/
.vista-previa {
position: fixed;
top: var(--y);
left: var(--x);
z-index: 40;
width: var(--lado);
padding: var(--sp-2);
border: 1px solid var(--c-border);
.mini {
position: relative;
aspect-ratio: 1;
overflow: hidden;
border-radius: var(--radius-tarjeta);
background: var(--c-bg-raised);
box-shadow: 0 12px 32px rgb(0 0 0 / 0.45);
pointer-events: none;
}
.vista-previa img {
display: block;
.mini-control {
position: absolute;
inset: 0;
opacity: 0;
transition: opacity var(--duration-fast) var(--ease-standard);
}
.mini-control :global(button.boton) {
width: 100%;
height: auto;
aspect-ratio: 1;
object-fit: cover;
border-radius: calc(var(--radius-tarjeta) - 2px);
height: 100%;
border-radius: 0;
}
.vista-previa p {
margin: var(--sp-2) 0 0;
font-size: var(--fs-xs);
color: var(--c-text-muted);
text-align: center;
.mini-control :global(button.boton),
.mini-control :global(button.boton.activo),
.mini-control :global(button.boton:hover:not(:disabled)) {
color: var(--c-text-on-media);
background-color: color-mix(in oklab, var(--c-media-veil) 45%, transparent);
}
.mini-control :global(button.boton:focus-visible) {
outline-offset: calc(var(--sp-1) * -1);
}
tr:hover .mini-control,
tr:focus-within .mini-control,
tr.sonando .mini-control {
opacity: 1;
}
@media (hover: none), (pointer: coarse) {
.mini-control {
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.mini-control {
transition: none;
}
}
</style>

@ -126,6 +126,23 @@
table :global(tr.sonando .titulo) {
font-weight: var(--font-weight-semibold);
}
/*
* Cuando la tabla comparte la columna principal con un índice lateral, el
* relleno amplio de diez columnas era suficiente para crear un pequeño
* desplazamiento horizontal. Se compacta el aire antes de ocultar datos o
* partir títulos; la jerarquía y las acciones permanecen intactas.
*/
@container tabla-catalogo (max-width: 62rem) {
table :global(tbody th),
table :global(tbody td) {
padding-inline: var(--sp-1);
}
table :global(thead button),
table :global(thead .rotulo) {
padding-inline: var(--sp-2);
}
}
@container tabla-catalogo (max-width: 42rem) {
table :global(.mini) {
width: 56px;

@ -136,7 +136,8 @@
display: flex;
align-items: center;
gap: var(--sp-1);
margin: auto 0 0;
margin-top: auto;
margin-inline: 0;
padding-top: var(--sp-1);
font-family: var(--font-body);
font-size: var(--fs-sm);

@ -74,10 +74,10 @@
.plato {
position: absolute;
inset: 0;
border-radius: 50%;
border-radius: var(--radius-round);
background: radial-gradient(circle at 50% 50%, var(--c-bg-sunken) 0%, var(--c-bg-raised) 70%);
box-shadow:
0 40px 80px -40px rgb(0 0 0 / 0.9),
0 40px 80px -40px color-mix(in oklab, var(--c-scrim-base) 90%, transparent),
inset 0 0 0 1px var(--c-border);
display: grid;
place-items: center;
@ -89,12 +89,12 @@
width: 88%;
height: 88%;
border: none;
border-radius: 50%;
border-radius: var(--radius-round);
padding: 0;
background: radial-gradient(
circle at 50% 50%,
oklch(0.14 0.03 264) 0%,
oklch(0.09 0.02 264) 70%
color-mix(in oklab, var(--c-scrim-base) 88%, white) 0%,
color-mix(in oklab, var(--c-scrim-base) 88%, black) 70%
);
cursor: pointer;
display: grid;
@ -104,7 +104,7 @@
* solo se pausa, para que al reanudar continúe donde estaba en vez de
* saltar al principio.
*/
animation: girar 1.8s linear infinite;
animation: girar var(--duration-turntable) linear infinite;
animation-play-state: paused;
}
@ -125,10 +125,10 @@
.surcos {
position: absolute;
inset: 6%;
border-radius: 50%;
border-radius: var(--radius-round);
background: repeating-radial-gradient(
circle at 50% 50%,
rgb(255 255 255 / 0.045) 0 1px,
color-mix(in oklab, var(--c-text-on-media) 4.5%, transparent) 0 1px,
transparent 1px 5px
);
}
@ -138,14 +138,14 @@
content: '';
position: absolute;
inset: 0;
border-radius: 50%;
border-radius: var(--radius-round);
background: conic-gradient(
from 210deg,
transparent 0deg,
rgb(255 255 255 / 0.07) 24deg,
color-mix(in oklab, var(--c-text-on-media) 7%, transparent) 24deg,
transparent 60deg,
transparent 180deg,
rgb(255 255 255 / 0.05) 208deg,
color-mix(in oklab, var(--c-text-on-media) 5%, transparent) 208deg,
transparent 244deg
);
}
@ -155,7 +155,7 @@
z-index: 1;
width: 40%;
height: 40%;
border-radius: 50%;
border-radius: var(--radius-round);
object-fit: cover;
}
@ -164,9 +164,9 @@
z-index: 2;
width: 3.2%;
height: 3.2%;
border-radius: 50%;
border-radius: var(--radius-round);
background: var(--c-bg);
box-shadow: 0 0 0 1px rgb(255 255 255 / 0.15);
box-shadow: 0 0 0 1px color-mix(in oklab, var(--c-text-on-media) 15%, transparent);
}
.brazo {
@ -177,7 +177,7 @@
height: 46%;
transform-origin: 88% 12%;
transform: rotate(-24deg);
transition: transform 0.9s cubic-bezier(0.33, 1, 0.68, 1);
transition: transform var(--duration-tonearm) var(--ease-emphasized);
pointer-events: none;
}
@ -192,9 +192,9 @@
right: 0;
width: 22%;
height: 22%;
border-radius: 50%;
border-radius: var(--radius-round);
background: var(--c-border);
box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.12);
box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--c-text-on-media) 12%, transparent);
}
.varilla {
@ -203,7 +203,7 @@
right: 10%;
width: 4px;
height: 86%;
border-radius: 2px;
border-radius: var(--radius-detail);
background: linear-gradient(to bottom, var(--c-text-muted), var(--c-border));
transform-origin: top center;
transform: rotate(28deg);
@ -215,7 +215,7 @@
left: 34%;
width: 12%;
height: 8%;
border-radius: 2px;
border-radius: var(--radius-detail);
background: var(--c-accent);
transform: rotate(28deg);
}

@ -111,6 +111,8 @@
<style>
.valoracion {
--tamano-estrella: var(--icon-size-lg);
display: flex;
align-items: center;
flex-wrap: wrap;
@ -145,7 +147,7 @@
position: relative;
display: inline-flex;
cursor: pointer;
font-size: 1.25rem;
font-size: var(--tamano-estrella);
line-height: 1;
color: var(--c-border);
transition: color var(--duration-fast) var(--ease-standard);
@ -157,7 +159,7 @@
.estrellas-lectura {
color: var(--c-border);
font-size: 1.25rem;
font-size: var(--tamano-estrella);
line-height: 1;
text-decoration: none;
}

@ -117,7 +117,11 @@
.velo {
position: absolute;
inset: 0;
background: linear-gradient(to top, rgb(0 0 0 / 0.6), rgb(0 0 0 / 0.05) 55%);
background: linear-gradient(
to top,
color-mix(in oklab, var(--c-scrim-base) 60%, transparent),
color-mix(in oklab, var(--c-scrim-base) 5%, transparent) 55%
);
}
.play {
@ -126,8 +130,8 @@
display: flex;
align-items: center;
justify-content: center;
color: #fff;
transition: transform 0.25s cubic-bezier(0.22, 1, 0.36, 1);
color: var(--c-text-on-media);
transition: transform var(--duration-normal) var(--ease-emphasized);
}
.portada:hover .play,
@ -142,7 +146,7 @@
bottom: var(--sp-3);
display: flex;
flex-direction: column;
color: #fff;
color: var(--c-text-on-media);
}
.titulo {

@ -0,0 +1,14 @@
<script lang="ts">
import { page } from '$app/state';
import { reproductor } from '$lib/player/player.svelte';
$effect(() => {
const usuarioId = page.data.usuario?.id;
if (
reproductor.programacion &&
(reproductor.programacion.usuarioId !== usuarioId || !page.data.accesoJukebox)
)
reproductor.detenerProgramacion(true);
});
</script>
<svelte:document onvisibilitychange={() => reproductor.comprobarProgramacion()} />

@ -0,0 +1,215 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import { onMount } from 'svelte';
let titulo: HTMLHeadingElement;
let subtitulo: HTMLSpanElement;
onMount(() => {
let activo = true;
const ajustar = () => {
if (!activo || !titulo || !subtitulo) return;
const ancho = titulo.getBoundingClientRect().width;
const actual = subtitulo.getBoundingClientRect().width;
if (actual > 0)
subtitulo.style.fontSize =
(parseFloat(getComputedStyle(subtitulo).fontSize) * ancho) / actual + 'px';
};
const observador = new ResizeObserver(ajustar);
observador.observe(titulo);
void document.fonts.ready.then(ajustar);
return () => {
activo = false;
observador.disconnect();
};
});
let {
play,
configurarHref,
configurar,
mensaje = '',
estado,
reproduciendo = false
}: {
play: Snippet;
configurarHref: string;
configurar?: (evento: MouseEvent) => void;
mensaje?: string;
estado?: Snippet;
reproduciendo?: boolean;
} = $props();
</script>
<header class="heroe-jukebox" class:reproduciendo>
<picture class="imagen-heroe" aria-hidden="true">
<source
srcset="/images/jukebox/hero-768.webp 768w, /images/jukebox/hero-1280.webp 1280w, /images/jukebox/hero-1944.webp 1944w"
sizes="100vw"
type="image/webp"
/>
<img
src="/images/jukebox/hero-1944.webp"
alt=""
width="1944"
height="809"
fetchpriority="high"
/>
</picture>
<div class="container contenido-heroe">
<div class="texto-heroe">
<div class="titulo-heroe">
<h1 bind:this={titulo}>Juke-box</h1>
<p class="entradilla"><span bind:this={subtitulo}>Tu música, a su hora.</span></p>
</div>
<div class="acciones-heroe">
{@render play()}
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -- La página resuelve esta ruta según el jukebox seleccionado. -->
<a class="btn" href={configurarHref} onclick={configurar}>Configura tu Jukebox</a>
{@render estado?.()}
</div>
{#if mensaje}<p class="mensaje-heroe" role="status">{mensaje}</p>{/if}
</div>
</div>
</header>
<style>
.heroe-jukebox {
--espacio-inferior-heroe: clamp(var(--sp-8), 12vh, calc(var(--sp-8) * 2));
position: relative;
display: grid;
min-height: 100vh;
margin-top: calc(var(--altura-cabecera) * -1);
align-items: end;
overflow: hidden;
isolation: isolate;
background: var(--c-media-bg-deep);
}
.imagen-heroe,
.imagen-heroe::after {
position: absolute;
inset: 0;
}
.imagen-heroe {
z-index: -2;
background: var(--c-media-bg);
}
.imagen-heroe::after {
background:
linear-gradient(
90deg,
color-mix(in srgb, var(--c-media-veil) 93%, transparent) 0%,
color-mix(in srgb, var(--c-media-veil) 72%, transparent) 40%,
transparent 72%
),
linear-gradient(
270deg,
color-mix(in srgb, var(--c-media-veil) 98%, transparent) 0%,
color-mix(in srgb, var(--c-media-veil) 98%, transparent) 20%,
transparent 32%
),
linear-gradient(
0deg,
color-mix(in srgb, var(--c-media-veil) 78%, transparent),
transparent 48%
);
content: '';
}
.imagen-heroe img {
position: absolute;
top: 0;
right: clamp(8rem, 20vw, 48rem);
width: auto;
max-width: none;
height: 100%;
object-fit: contain;
object-position: right center;
transform: translateY(calc(var(--sp-6) * -1));
}
.contenido-heroe {
width: 100%;
padding-top: calc(var(--altura-cabecera) + var(--sp-7));
padding-bottom: var(--espacio-inferior-heroe);
color: white;
}
.reproduciendo .contenido-heroe {
padding-bottom: calc(var(--espacio-inferior-heroe) + var(--player-height));
}
.texto-heroe {
max-width: none;
}
.titulo-heroe {
width: max-content;
max-width: 100%;
}
.texto-heroe h1,
.texto-heroe p {
margin: 0;
}
.texto-heroe h1 {
width: max-content;
font-size: var(--fs-4xl);
line-height: 0.95;
}
.acciones-heroe {
display: flex;
flex-wrap: wrap;
justify-content: flex-start;
align-items: center;
gap: var(--sp-3);
margin-top: var(--sp-5);
}
.acciones-heroe > .btn {
--c-text: var(--c-text-on-media);
--c-border: var(--c-border-on-media);
}
.texto-heroe .mensaje-heroe {
margin-top: var(--sp-4);
color: var(--c-marca-oro-brillo);
font-size: var(--fs-base);
}
.texto-heroe .entradilla {
width: max-content;
margin-top: var(--sp-4);
color: var(--c-text-on-media-muted);
font-size: var(--fs-lg);
font-weight: var(--font-weight-regular);
}
.entradilla span {
display: inline-block;
white-space: nowrap;
}
@media (max-width: 60rem) {
.imagen-heroe img {
position: static;
transform: none;
width: 100%;
max-width: 100%;
object-fit: cover;
object-position: 63% center;
}
}
@media (max-width: 34rem) {
.imagen-heroe::after {
background:
linear-gradient(
90deg,
color-mix(in srgb, var(--c-media-veil) 90%, transparent),
color-mix(in srgb, var(--c-media-veil) 46%, transparent)
),
linear-gradient(
0deg,
color-mix(in srgb, var(--c-media-veil) 82%, transparent),
transparent 55%
);
}
}
</style>

@ -0,0 +1,36 @@
<script lang="ts">
import { resolve } from '$app/paths';
import Migas, { type Miga } from '../Migas.svelte';
let { migas, jukeboxId }: { migas: Miga[]; jukeboxId?: string } = $props();
</script>
<div class="navegacion-jukebox">
<Migas {migas} compacta />
<a class="volver" href={resolve(jukeboxId ? `/juke-box?jukebox=${jukeboxId}` : '/juke-box')}>
<span aria-hidden="true">←</span> Volver al Juke-box
</a>
</div>
<style>
.navegacion-jukebox {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
column-gap: var(--sp-4);
row-gap: var(--sp-1);
padding-block: var(--sp-3);
}
.volver {
display: inline-flex;
align-items: center;
gap: var(--sp-2);
min-height: var(--control-height);
font-size: var(--boton-texto);
color: var(--c-text);
text-decoration: none;
}
.volver:hover {
text-decoration: underline;
}
</style>

@ -0,0 +1,880 @@
<script module lang="ts">
import type { ProgramacionJukebox } from '$lib/programacion-jukebox';
export interface EstadoEditorJukebox {
id: string;
nombre: string;
descripcion: string;
nombreGuardado: string;
descripcionGuardada: string;
numero: number;
programacion: ProgramacionJukebox;
guardado: string;
revision: number;
seleccionada: string;
}
</script>
<script lang="ts">
import { resolve } from '$app/paths';
import { goto } from '$app/navigation';
import {
cancionesDeFranja,
validarProgramacion,
type FranjaJukebox,
type FuenteJukebox
} from '$lib/programacion-jukebox';
import { reproductor } from '$lib/player/player.svelte';
import type { Cancion } from '$lib/types';
import type { ListaDeReproduccion } from '$lib/server/listas-reproduccion';
import CampoFormulario from '../CampoFormulario.svelte';
import GrupoToggle from '../GrupoToggle.svelte';
import Icono from '../Icono.svelte';
import SelectorContenido from './SelectorContenido.svelte';
let {
estado = $bindable(),
listas,
canciones
}: {
estado: EstadoEditorJukebox;
listas: ListaDeReproduccion[];
canciones: Cancion[];
} = $props();
let anadiendo = $state(false);
let guardando = $state(false);
let mensaje = $state('');
let errorServidor = $state('');
let eliminada = $state<FranjaJukebox | null>(null);
const modificada = $derived(
JSON.stringify(estado.programacion) !== estado.guardado ||
estado.nombre !== estado.nombreGuardado ||
estado.descripcion !== estado.descripcionGuardada
);
const errorHorario = $derived(validarProgramacion(estado.programacion));
const franja = $derived(estado.programacion.franjas.find((f) => f.id === estado.seleccionada));
const ordenadas = $derived(
[...estado.programacion.franjas].sort((a, b) => a.inicio.localeCompare(b.inicio))
);
function temas(f: FranjaJukebox) {
return cancionesDeFranja(
f,
listas,
canciones.map((c) => c.slug)
).flatMap((slug) => canciones.find((c) => c.slug === slug) ?? []);
}
const vacias = $derived(estado.programacion.franjas.filter((f) => temas(f).length === 0));
function nuevaFranja() {
const ultima = ordenadas.at(-1);
const inicio = ultima?.fin ?? '00:00';
const horas = (Number(inicio.slice(0, 2)) + 2) % 24;
const nueva: FranjaJukebox = {
id: crypto.randomUUID(),
orden: 'secuencial',
catalogo: true,
inicio,
fin: ultima ? `${String(horas).padStart(2, '0')}:${inicio.slice(3)}` : '00:00',
contenidos: []
};
estado.programacion.franjas.push(nueva);
estado.seleccionada = nueva.id;
anadiendo = false;
mensaje = '';
}
function seleccionar(id: string) {
estado.seleccionada = id;
anadiendo = false;
}
function agregar(fuente: FuenteJukebox) {
if (franja && franja.contenidos.length < 100) franja.contenidos.push(fuente);
}
function nombre(fuente: FuenteJukebox) {
return fuente.tipo === 'lista'
? (listas.find((l) => l.id === fuente.id)?.nombre ?? 'Lista no disponible')
: (canciones.find((c) => c.slug === fuente.id)?.titulo ?? 'Canción no disponible');
}
function mover(indice: number, direccion: -1 | 1) {
if (!franja) return;
const destino = indice + direccion;
if (destino < 0 || destino >= franja.contenidos.length) return;
[franja.contenidos[indice], franja.contenidos[destino]] = [
franja.contenidos[destino],
franja.contenidos[indice]
];
}
function eliminar() {
if (!franja) return;
eliminada = structuredClone($state.snapshot(franja));
estado.programacion.franjas = estado.programacion.franjas.filter((f) => f.id !== franja.id);
estado.seleccionada = estado.programacion.franjas[0]?.id ?? '';
anadiendo = false;
mensaje = 'Franja eliminada. Guarda para aplicar el cambio.';
}
function deshacer() {
estado.nombre = estado.nombreGuardado;
estado.descripcion = estado.descripcionGuardada;
estado.programacion = JSON.parse(estado.guardado);
estado.seleccionada = estado.programacion.franjas[0]?.id ?? '';
anadiendo = false;
errorServidor = '';
mensaje = '';
eliminada = null;
}
async function guardar(soloFranja = false) {
if (guardando) return;
const programacion = soloFranja
? JSON.parse(estado.guardado)
: $state.snapshot(estado.programacion);
if (soloFranja && franja) {
const indice = programacion.franjas.findIndex((f: FranjaJukebox) => f.id === franja.id);
if (indice < 0) programacion.franjas.push($state.snapshot(franja));
else programacion.franjas[indice] = $state.snapshot(franja);
}
const falloValidacion = !estado.nombre.trim()
? 'Escribe el nombre del jukebox antes de guardar.'
: validarProgramacion(programacion);
if (falloValidacion) {
errorServidor = falloValidacion;
return;
}
if (programacion.franjas.some((f: FranjaJukebox) => !temas(f).length)) {
errorServidor = 'Añade canciones a las franjas vacías antes de guardar.';
return;
}
guardando = true;
errorServidor = '';
mensaje = '';
const enviado = JSON.stringify(programacion);
const nombreEnviado = estado.nombre.trim();
const descripcionEnviada = estado.descripcion.trim();
try {
const respuesta = await fetch(
resolve('/(sitio)/juke-box/[id]/programacion', { id: estado.id }),
{
method: 'PUT',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
nombre: nombreEnviado,
descripcion: descripcionEnviada,
programacion: JSON.parse(enviado),
revision: estado.revision
})
}
);
const resultado = await respuesta.json();
if (!respuesta.ok)
throw new Error(resultado.error ?? 'No se pudo guardar. Tus cambios siguen aquí.');
estado.revision = resultado.revision;
estado.numero = resultado.numero;
estado.nombreGuardado = nombreEnviado;
estado.descripcionGuardada = descripcionEnviada;
if (estado.nombre.trim() === nombreEnviado) estado.nombre = nombreEnviado;
if (estado.descripcion.trim() === descripcionEnviada) estado.descripcion = descripcionEnviada;
estado.guardado = enviado;
mensaje =
soloFranja && franja
? `Franja ${numero(franja)} guardada en «${nombreEnviado}».`
: 'Jukebox guardado en tu cuenta.';
eliminada = null;
if (soloFranja) return;
await goto(resolve(`/juke-box/configurar?jukebox=${estado.id}`), {
invalidateAll: true
});
} catch (fallo) {
errorServidor =
fallo instanceof Error ? fallo.message : 'No se pudo guardar. Tus cambios siguen aquí.';
} finally {
guardando = false;
}
}
function numero(f: FranjaJukebox) {
return estado.programacion.franjas.findIndex((x) => x.id === f.id) + 1;
}
let confirmarEliminacion = $state(false);
async function borrarJukebox() {
if (guardando) return;
guardando = true;
errorServidor = '';
try {
const r = await fetch(resolve('/(sitio)/juke-box/[id]/programacion', { id: estado.id }), {
method: 'DELETE',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ revision: estado.revision })
});
const resultado = await r.json();
if (!r.ok) throw new Error(resultado.error);
if (reproductor.programacion?.jukeboxId === estado.id) reproductor.detenerProgramacion();
await goto(resolve('/juke-box/configurar'), { invalidateAll: true });
} catch (error) {
errorServidor = error instanceof Error ? error.message : 'No se pudo eliminar.';
} finally {
guardando = false;
}
}
</script>
<section
class="programador"
aria-label={estado.numero ? `Editar Jukebox ${estado.numero}` : 'Crear jukebox'}
>
<div class="barra-programacion">
<div class="titulo-programacion">
<h1>{estado.numero ? 'Editar Jukebox ' + estado.numero : 'Crear jukebox'}</h1>
<div class="estado-edicion">
<span class="muted"
>{modificada
? 'Cambios sin guardar'
: estado.revision
? 'Guardado en tu cuenta'
: 'Aún sin programación'}</span
>
{#if modificada}<button class="enlace" onclick={deshacer} disabled={guardando}
>Deshacer cambios</button
>{/if}
{#if eliminada}<button
class="enlace"
onclick={() => {
if (eliminada) {
estado.programacion.franjas.push(eliminada);
seleccionar(eliminada.id);
eliminada = null;
mensaje = '';
}
}}>Recuperar franja eliminada</button
>{/if}
</div>
</div>
<div class="acciones">
<button
class="btn btn--form-primary"
disabled={(!modificada && estado.revision > 0) ||
!!errorHorario ||
guardando ||
vacias.length > 0}
onclick={() => guardar()}>{guardando ? 'Guardando…' : 'Guardar jukebox'}</button
>
<a class="btn" href={resolve('/juke-box/configurar')}>Cancelar</a>
</div>
</div>
<div class="identidad-jukebox">
<CampoFormulario
id="nombre-jukebox"
etiqueta="Nombre del jukebox"
bind:valor={estado.nombre}
maxlength={100}
/>
<CampoFormulario
id="descripcion-jukebox"
etiqueta="Descripción"
tipo="textarea"
bind:valor={estado.descripcion}
maxlength={1000}
/>
</div>
{#if errorHorario || errorServidor}<p class="aviso error" role="alert">
{errorHorario ?? errorServidor}
</p>{/if}
{#if mensaje && !franja}<p class="aviso" role="status">{mensaje}</p>{/if}
<div class="agenda">
<aside aria-label="Franjas horarias">
<div class="titulo-franjas">
<h3>Franjas</h3>
<span class="numero-franjas muted">{ordenadas.length}</span>
</div>
<ol class="franjas">
{#each ordenadas as f (f.id)}
<li>
<button
class="franja"
class:elegida={f.id === estado.seleccionada}
aria-pressed={f.id === estado.seleccionada}
onclick={() => seleccionar(f.id)}
>
<strong>Franja {numero(f)}</strong>
<span class="horario"
>{f.inicio === f.fin ? 'Todo el día' : `${f.inicio} – ${f.fin}`}</span
>
<span class="muted"
>{temas(f).length} canciones{#if reproductor.programacion?.jukeboxId === estado.id && reproductor.franjaProgramada?.id === f.id && !reproductor.jukeboxInterrumpido && reproductor.sonando}
· En reproducción{/if}</span
>
</button>
</li>
{/each}
</ol>
<button class="btn nueva" onclick={nuevaFranja} disabled={ordenadas.length >= 48}
>+ Añadir franja</button
>
</aside>
<div class="editor">
{#if franja}
<header class="cabecera-franja">
<h3>Franja {numero(franja)}</h3>
</header>
<fieldset class="ajuste ajuste-horario">
<legend>Horario</legend>
<div class="fila-horario">
<GrupoToggle
etiqueta="Horario de la franja"
valor={franja.inicio === franja.fin ? 'dia' : 'horario'}
opciones={[
{ valor: 'dia', texto: 'Todo el día' },
{ valor: 'horario', texto: 'Horario' }
]}
cambiar={(valor) => {
if (valor === 'dia') {
franja.inicio = '00:00';
franja.fin = '00:00';
} else if (franja.inicio === franja.fin) {
franja.inicio = '08:00';
franja.fin = '10:00';
}
}}
/>
{#if franja.inicio !== franja.fin}<div class="campos-horario">
<CampoFormulario
id="inicio-franja"
etiqueta="Desde"
tipo="time"
bind:valor={franja.inicio}
/>
<CampoFormulario
id="fin-franja"
etiqueta="Hasta"
tipo="time"
bind:valor={franja.fin}
/>
</div>
{/if}
</div>
{#if franja.fin < franja.inicio}<p class="muted nota-horaria">
Termina al día siguiente.
</p>{/if}
</fieldset>
<div class="ajustes-reproduccion">
<fieldset class="ajuste">
<legend>Reproducción</legend>
<GrupoToggle
etiqueta="Modo de reproducción"
valor={franja.orden}
opciones={[
{ valor: 'secuencial', texto: 'Secuencial' },
{ valor: 'aleatoria', texto: 'Aleatoria', icono: 'aleatorio' }
]}
cambiar={(valor) =>
(franja.orden = valor === 'aleatoria' ? 'aleatoria' : 'secuencial')}
/>
</fieldset>
<fieldset class="ajuste">
<legend>Contenido</legend>
<GrupoToggle
etiqueta="Origen de las canciones"
valor={franja.catalogo ? 'catalogo' : 'seleccion'}
opciones={[
{ valor: 'catalogo', texto: 'Todo el catálogo' },
{ valor: 'seleccion', texto: 'Listas y canciones' }
]}
cambiar={(valor) => {
franja.catalogo = valor === 'catalogo';
if (franja.catalogo) anadiendo = false;
}}
/>
</fieldset>
</div>
{#if franja.catalogo}<div class="catalogo">
<div class="resumen-catalogo">
<Icono nombre="lista" tamano={24} />
<p>
<strong>Catálogo completo</strong><span class="muted"
>{canciones.length} canciones disponibles</span
>
</p>
</div>
<details>
<summary>Ver canciones del catálogo</summary>
<ol>
{#each canciones as c (c.slug)}<li>{c.titulo}</li>{/each}
</ol>
</details>
</div>{:else}
<div class="cabecera-contenido">
<div>
<h4>Listas y canciones</h4>
</div>
<button
class="btn"
disabled={franja.contenidos.length >= 100}
onclick={() => (anadiendo = !anadiendo)}
aria-expanded={anadiendo}>+ Añadir contenido</button
>
</div>
<ol class="contenidos" aria-label="Orden de reproducción de la franja">
{#each franja.contenidos as fuente, indice (fuente.tipo + fuente.id + indice)}
<li class="contenido">
<span class="posicion muted">{indice + 1}</span><Icono
nombre={fuente.tipo === 'lista' ? 'lista' : 'nota'}
tamano={24}
/>
<div class="datos">
<strong>{nombre(fuente)}</strong><span class="muted"
>{fuente.tipo === 'lista' ? 'Lista de reproducción' : 'Canción'}</span
>
{#if fuente.tipo === 'lista'}
<details>
<summary>Ver canciones de esta lista</summary>
<ol>
{#each listas.find((l) => l.id === fuente.id)?.canciones ?? [] as slug, n (slug + n)}<li
>
{n + 1}. {canciones.find((c) => c.slug === slug)?.titulo ??
'Canción no disponible'}
</li>{/each}
</ol>
</details>
{/if}
</div>
<div class="orden">
<button
class="btn"
aria-label="Subir {nombre(fuente)}"
disabled={indice === 0}
onclick={() => mover(indice, -1)}>↑</button
><button
class="btn"
aria-label="Bajar {nombre(fuente)}"
disabled={indice === franja.contenidos.length - 1}
onclick={() => mover(indice, 1)}>↓</button
><button
class="btn"
aria-label="Quitar {nombre(fuente)} de la franja"
onclick={() => franja?.contenidos.splice(indice, 1)}
><Icono nombre="cerrar" tamano={18} /></button
>
</div>
</li>
{:else}<li class="vacio-contenido">
<Icono nombre="lista" tamano={28} />
<p>Añade una lista o elige canciones para esta franja.</p>
</li>{/each}
</ol>
{#if anadiendo}<SelectorContenido
{listas}
{canciones}
anadir={agregar}
cerrar={() => (anadiendo = false)}
/>{/if}
{/if}
{#if !franja.catalogo && franja.contenidos.length > 0}<p class="muted total">
{temas(franja).length} canciones disponibles en esta franja.
</p>{/if}
<div class="acciones guardar-franja">
<button class="btn btn--form-primary" disabled={guardando} onclick={() => guardar(true)}
>Guardar franja</button
>
<button class="enlace enlace--peligro" disabled={guardando} onclick={eliminar}
>Eliminar franja</button
>
</div>
{#if mensaje}<p class="aviso" role="status">{mensaje}</p>{/if}
<p class="muted nota-horaria">
Guarda solo esta franja. «Guardar jukebox» guarda todos los cambios.
</p>
{:else}
<div class="vacio">
<Icono nombre="lista" tamano={32} />
<h3>Tu día, con tu música</h3>
<p class="muted">Crea una franja horaria y añade lo que quieres que suene en ella.</p>
<button class="btn btn--primary" onclick={nuevaFranja}>Crear primera franja</button>
</div>
{/if}
</div>
</div>
{#if vacias.length && !modificada}<p class="aviso muted">
Añade canciones a {vacias.map((f) => `Franja ${numero(f)}`).join(', ')} para poder guardar el jukebox.
</p>{/if}
<p class="muted ayuda-final">
La selección se repite hasta el final de la franja. Al comenzar otra se cambia a su contenido;
entre franjas la música se detiene. Los cambios se aplican al volver a pulsar Play JukeBox. <a
href={resolve('/listas')}>Gestionar mis listas</a
>
</p>
{#if estado.revision > 0}<div class="eliminar-jukebox">
{#if confirmarEliminacion}<p>¿Eliminar este jukebox y sus franjas?</p>
<div class="acciones">
<button class="btn btn--peligro" disabled={guardando} onclick={borrarJukebox}
>Sí, eliminar Jukebox</button
><button class="btn" onclick={() => (confirmarEliminacion = false)}
>Conservar Jukebox</button
>
</div>{:else}<button
class="enlace enlace--peligro"
onclick={() => (confirmarEliminacion = true)}>Eliminar Jukebox</button
>{/if}
</div>{/if}
</section>
<style>
.identidad-jukebox {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1.5fr);
align-items: start;
gap: var(--sp-5);
margin-block: var(--sp-4) var(--sp-5);
}
.identidad-jukebox :global(textarea) {
block-size: calc(var(--control-height) + var(--sp-5));
}
.guardar-franja {
margin-top: var(--sp-5);
padding-top: var(--sp-4);
border-top: 1px solid var(--c-border);
}
.ajuste {
min-width: 0;
border: 0;
padding: 0;
margin: 0;
}
.ajuste legend {
font-weight: var(--font-weight-medium);
margin-bottom: var(--sp-3);
padding: 0;
}
.ajustes-reproduccion {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1.4fr);
gap: var(--sp-5);
margin-block: var(--sp-5);
padding-top: var(--sp-5);
border-top: 1px solid var(--c-border);
}
.catalogo {
padding: var(--sp-4);
border: 1px solid var(--c-border);
border-radius: var(--radius-tarjeta);
background: var(--c-bg-raised);
}
.catalogo p {
display: grid;
gap: var(--sp-1);
margin: 0;
}
.catalogo p strong {
font-weight: var(--font-weight-medium);
}
.resumen-catalogo {
display: flex;
align-items: center;
gap: var(--sp-3);
margin-bottom: var(--sp-3);
}
.resumen-catalogo p span {
font-size: var(--fs-sm);
}
.eliminar-jukebox {
margin-top: var(--sp-4);
}
.programador {
padding-bottom: var(--sp-7);
}
.barra-programacion,
.acciones,
.estado-edicion,
.titulo-franjas,
.cabecera-franja,
.cabecera-contenido {
display: flex;
align-items: center;
gap: var(--sp-3);
}
.barra-programacion,
.cabecera-franja,
.cabecera-contenido,
.titulo-franjas {
justify-content: space-between;
}
.barra-programacion {
position: sticky;
top: var(--altura-cabecera);
z-index: 10;
background: var(--c-bg);
padding-top: var(--sp-2);
flex-wrap: wrap;
padding-bottom: var(--sp-2);
}
.titulo-programacion {
min-width: 0;
}
.titulo-programacion h1 {
font-size: var(--fs-3xl);
margin: 0 0 var(--sp-2);
}
.acciones,
.estado-edicion {
flex-wrap: wrap;
}
.estado-edicion {
font-size: var(--fs-sm);
}
.enlace {
padding: var(--sp-2) 0;
border: 0;
background: none;
color: var(--c-text);
font: inherit;
cursor: pointer;
text-decoration: underline;
text-underline-offset: 0.2em;
}
.enlace--peligro,
.btn--peligro {
color: var(--c-danger);
}
.aviso {
margin: var(--sp-3) 0;
padding: var(--sp-3) var(--sp-4);
border-left: 2px solid var(--c-form-action);
background: var(--c-bg-raised);
}
.error {
color: var(--c-danger);
border-color: var(--c-danger);
}
.agenda {
display: grid;
grid-template-columns: 14rem minmax(0, 1fr);
border: 1px solid var(--c-border);
border-radius: var(--radius-overlay);
}
aside {
min-width: 0;
padding: var(--sp-4);
border-right: 1px solid var(--c-border);
border-radius: var(--radius-overlay) 0 0 var(--radius-overlay);
background: var(--c-bg-raised);
}
h3 {
margin: 0;
font-size: var(--fs-xl);
}
.titulo-franjas {
margin-bottom: var(--sp-4);
}
.titulo-franjas h3 {
font: inherit;
font-weight: var(--font-weight-medium);
}
.numero-franjas {
min-width: var(--sp-5);
text-align: center;
font-variant-numeric: tabular-nums;
}
.franjas {
list-style: none;
margin: 0 0 var(--sp-4);
padding: 0;
display: grid;
gap: var(--sp-2);
}
.franja {
display: grid;
gap: var(--sp-2);
width: 100%;
padding: var(--sp-3);
border: 1px solid transparent;
border-radius: var(--radius-boton);
background: transparent;
color: var(--c-text);
text-align: left;
font: inherit;
cursor: pointer;
}
.franja:hover {
background: var(--c-bg);
}
.franja.elegida {
border-color: var(--c-form-action);
background: var(--c-bg);
}
.horario {
font-size: var(--fs-sm);
color: var(--c-text-muted);
font-variant-numeric: tabular-nums;
}
.franja strong {
font-weight: var(--font-weight-medium);
overflow-wrap: anywhere;
}
.franja .muted {
font-size: var(--fs-sm);
}
.nueva {
width: 100%;
justify-content: center;
}
.editor {
min-width: 0;
padding: var(--sp-5);
}
.cabecera-franja {
margin-bottom: var(--sp-4);
flex-wrap: wrap;
}
.fila-horario {
display: flex;
align-items: flex-end;
flex-wrap: wrap;
gap: var(--sp-4) var(--sp-5);
}
.campos-horario {
display: grid;
flex: 0 1 18rem;
min-width: min(100%, 16rem);
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--sp-3);
}
.nota-horaria {
margin: var(--sp-2) 0;
font-size: var(--fs-sm);
}
.cabecera-contenido {
margin: var(--sp-5) 0 var(--sp-3);
flex-wrap: wrap;
}
h4 {
margin: 0;
font-family: var(--font-body);
font-size: var(--fs-base);
font-weight: var(--font-weight-medium);
}
.contenidos {
list-style: none;
padding: 0;
margin: 0 0 var(--sp-4);
}
.contenido {
display: grid;
grid-template-columns: 1.5rem 1.5rem minmax(0, 1fr) auto;
align-items: center;
gap: var(--sp-3);
padding: var(--sp-4) 0;
border-bottom: 1px solid var(--c-border);
}
.posicion {
font-variant-numeric: tabular-nums;
}
.datos {
min-width: 0;
display: grid;
gap: var(--sp-1);
}
.datos strong {
font-weight: var(--font-weight-medium);
overflow-wrap: anywhere;
}
.datos > span,
details {
font-size: var(--fs-sm);
}
details ol {
list-style: none;
margin: var(--sp-2) 0 0;
padding: 0;
color: var(--c-text-muted);
}
details li {
padding-block: var(--sp-1);
}
summary {
cursor: pointer;
color: var(--c-text);
text-decoration: underline;
text-underline-offset: 0.2em;
}
.orden {
display: flex;
gap: var(--sp-1);
}
.orden .btn {
justify-content: center;
padding: var(--sp-2);
min-width: var(--control-height-compact);
}
.vacio,
.vacio-contenido {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
gap: var(--sp-4);
padding: var(--sp-7) var(--sp-5);
}
.vacio p,
.vacio-contenido p {
max-width: 44ch;
margin: 0;
}
.vacio {
min-height: 20rem;
justify-content: center;
}
.vacio-contenido {
background: var(--c-bg-raised);
border-radius: var(--radius-tarjeta);
}
.ayuda-final {
font-size: var(--fs-sm);
margin: var(--sp-4) 0 0;
max-width: 90ch;
}
.total {
font-size: var(--fs-sm);
}
@media (max-width: 60rem) {
.agenda {
grid-template-columns: 12rem minmax(0, 1fr);
}
.ajustes-reproduccion {
grid-template-columns: minmax(0, 1fr);
}
.contenido {
grid-template-columns: 1.5rem minmax(0, 1fr);
}
.contenido > :global(.icono) {
display: none;
}
.orden {
grid-column: 2;
}
}
@media (max-width: 42rem) {
.barra-programacion {
position: static;
}
.identidad-jukebox {
grid-template-columns: minmax(0, 1fr);
gap: var(--sp-4);
}
.agenda {
grid-template-columns: minmax(0, 1fr);
}
aside {
padding: var(--sp-4);
border-right: 0;
border-bottom: 1px solid var(--c-border);
border-radius: var(--radius-overlay) var(--radius-overlay) 0 0;
}
.franjas {
grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr));
}
.editor {
padding: var(--sp-4);
}
.acciones .btn {
flex: 1;
justify-content: center;
}
.acciones {
width: 100%;
}
}
</style>

@ -0,0 +1,196 @@
<script lang="ts">
import { resolve } from '$app/paths';
import CampoBusquedaLocal from '../CampoBusquedaLocal.svelte';
import Etiquetas from '../Etiquetas.svelte';
import Icono from '../Icono.svelte';
import GrupoToggle from '../GrupoToggle.svelte';
import type { Cancion } from '$lib/types';
import type { ListaDeReproduccion } from '$lib/server/listas-reproduccion';
import type { FuenteJukebox } from '$lib/programacion-jukebox';
let {
listas,
canciones,
anadir,
cerrar
}: {
listas: ListaDeReproduccion[];
canciones: Cancion[];
anadir: (fuente: FuenteJukebox) => void;
cerrar: () => void;
} = $props();
let vista = $state<'lista' | 'cancion'>('lista');
let busqueda = $state('');
let aviso = $state('');
const normalizar = (texto: string) =>
texto
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLocaleLowerCase('es');
const consulta = $derived(normalizar(busqueda.trim()));
const listasVisibles = $derived(listas.filter((l) => normalizar(l.nombre).includes(consulta)));
const temasVisibles = $derived(
canciones.filter((c) => normalizar([c.titulo, ...c.etiquetas].join(' ')).includes(consulta))
);
function agregar(tipo: 'lista' | 'cancion', id: string, nombre: string) {
anadir({ tipo, id });
aviso = `«${nombre}» añadido a la franja.`;
}
</script>
<section class="selector" aria-label="Añadir contenido a la franja">
<header>
<h3>Añadir a esta franja</h3>
<button class="btn" type="button" onclick={cerrar}
>Listo <Icono nombre="cerrar" tamano={18} /></button
>
</header>
<div class="tipos">
<GrupoToggle
etiqueta="Tipo de contenido"
valor={vista}
opciones={[
{ valor: 'lista', texto: 'Mis listas' },
{ valor: 'cancion', texto: 'Canciones' }
]}
cambiar={(valor) => (vista = valor === 'lista' ? 'lista' : 'cancion')}
/>
</div>
<CampoBusquedaLocal
id="buscar-contenido-jukebox"
etiqueta="Buscar contenido para la franja"
placeholder={vista === 'lista' ? 'Buscar en tus listas' : 'Buscar canciones o etiquetas'}
bind:valor={busqueda}
/>
<p class="aviso muted" role="status">{aviso}</p>
<div class="resultados" role="region" aria-label="Contenido disponible">
{#if vista === 'lista'}
{#each listasVisibles as lista (lista.id)}
{@const disponibles = lista.canciones.filter((slug) =>
canciones.some((c) => c.slug === slug)
).length}
<div class="resultado">
<Icono nombre="lista" tamano={24} />
<div class="datos">
<strong>{lista.nombre}</strong><span class="muted"
>Lista · {disponibles} canciones disponibles</span
>
</div>
<button
class="btn"
disabled={disponibles === 0}
onclick={() => agregar('lista', lista.id, lista.nombre)}
aria-label="Añadir lista {lista.nombre}">Añadir</button
>
</div>
{:else}
<p class="muted">
{listas.length
? 'No hay listas con ese nombre.'
: 'Aún no tienes listas de reproducción.'}
<a href={resolve('/listas')}>Ir a mis listas</a>
</p>
{/each}
{:else}
{#each temasVisibles as cancion (cancion.slug)}
<div class="resultado">
{#if cancion.portada}<img
src={cancion.portada}
alt=""
width="48"
height="48"
loading="lazy"
/>{:else}<Icono nombre="nota" tamano={24} />{/if}
<div class="datos">
<strong>{cancion.titulo}</strong><Etiquetas
etiquetas={cancion.etiquetas.slice(0, 3)}
menudas
enlaces={false}
/>
</div>
<button
class="btn"
onclick={() => agregar('cancion', cancion.slug, cancion.titulo)}
aria-label="Añadir canción {cancion.titulo}">Añadir</button
>
</div>
{:else}<p class="muted">No hay canciones disponibles con esa búsqueda.</p>{/each}
{/if}
</div>
</section>
<style>
.selector {
padding: var(--sp-5);
border: 1px solid var(--c-border);
border-radius: var(--radius-tarjeta);
background: var(--c-bg);
}
header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--sp-3);
margin-bottom: var(--sp-4);
}
h3 {
margin: 0;
font-size: var(--fs-xl);
}
.tipos {
display: flex;
gap: var(--sp-2);
margin-bottom: var(--sp-4);
}
.aviso {
min-height: 1.5em;
margin: var(--sp-2) 0;
font-size: var(--fs-sm);
}
.resultados {
max-height: 24rem;
overflow: auto;
padding-right: var(--sp-3);
scrollbar-width: thin;
}
.resultado {
display: grid;
grid-template-columns: 3rem minmax(0, 1fr) auto;
align-items: center;
gap: var(--sp-3);
padding-block: var(--sp-3);
border-bottom: 1px solid var(--c-border);
}
.resultado:last-child {
border-bottom: 0;
}
.resultado img {
width: 3rem;
height: 3rem;
object-fit: cover;
border-radius: var(--radius-detail);
}
.datos {
display: grid;
gap: var(--sp-2);
min-width: 0;
}
.datos strong {
font-weight: var(--font-weight-medium);
overflow-wrap: anywhere;
}
.datos > span {
font-size: var(--fs-sm);
}
@media (max-width: 34rem) {
.selector {
padding: var(--sp-3);
}
.resultado {
grid-template-columns: minmax(0, 1fr) auto;
}
.resultado > img,
.resultado > :global(.icono) {
display: none;
}
}
</style>

@ -84,5 +84,4 @@
cursor: default;
opacity: 0.5;
}
</style>

@ -1,4 +1,5 @@
<script lang="ts">
import ControlJukebox from './ControlJukebox.svelte';
/**
* Barra de reproduccion fija.
*
@ -107,121 +108,135 @@
<svelte:window onkeydown={atajos} />
{#if pista}
{#if reproductor.colaVisible}
{#if pista || reproductor.programacion}
{#if pista && reproductor.colaVisible}
<PanelCola />
{/if}
<div class="barra" role="region" aria-label="Reproductor" bind:this={barra}>
<!--
<div
class="barra"
class:con-jukebox={!!reproductor.programacion}
class:en-espera={!pista}
role="region"
aria-label="Reproductor"
bind:this={barra}
>
{#if pista}
<!--
Agrupado y etiquetado porque el titulo del tema y el del album pueden
coincidir: sin el grupo, un lector de pantalla lee dos enlaces con el
mismo nombre y ningun contexto que los separe.
-->
<div class="ahora" role="group" aria-label="Sonando ahora">
{#if pista.portada}
<img class="portada" src={pista.portada} alt="" width="48" height="48" />
{:else}
<SinPortada titulo={pista.titulo} lado={48} muda />
{/if}
<div class="datos">
<p class="linea">
<a
class="titulo"
href={resolve('/(sitio)/canciones/[slug]', { slug: pista.cancionSlug })}
>
{pista.titulo}
</a>
<span class="calidad">Calidad de escucha</span>
</p>
<span class="contexto muted">
<a href={resolve('/(sitio)/albumes/[slug]', { slug: pista.albumSlug })}
>{pista.albumTitulo}</a
>
{#if pista.estiloNombre}
<span aria-hidden="true">&middot;</span>
<a href={resolve('/(sitio)/musica/[estilo]', { estilo: pista.estiloSlug })}
>{pista.estiloNombre}</a
<div class="ahora" role="group" aria-label="Sonando ahora">
{#if pista.portada}
<img class="portada" src={pista.portada} alt="" width="48" height="48" />
{:else}
<SinPortada titulo={pista.titulo} lado={48} muda />
{/if}
<div class="datos">
<p class="linea">
<a
class="titulo"
href={resolve('/(sitio)/canciones/[slug]', { slug: pista.cancionSlug })}
>
{pista.titulo}
</a>
</p>
<span class="contexto muted">
<a href={resolve('/(sitio)/albumes/[slug]', { slug: pista.albumSlug })}
>{pista.albumTitulo}</a
>
{/if}
</span>
{#if pista.estiloNombre}
<span aria-hidden="true">&middot;</span>
<a href={resolve('/(sitio)/musica/[estilo]', { estilo: pista.estiloSlug })}
>{pista.estiloNombre}</a
>
{/if}
</span>
</div>
</div>
</div>
<!-- Transporte -->
<div class="centro">
<div class="controles">
<button
type="button"
class="control"
class:encendido={reproductor.aleatorio}
onclick={() => reproductor.alternarAleatorio()}
aria-pressed={reproductor.aleatorio}
aria-label="Reproducción aleatoria"
>
<Icono nombre="aleatorio" tamano={18} />
</button>
<button
type="button"
class="control"
onclick={() => reproductor.anterior()}
aria-label="Tema anterior"
>
<Icono nombre="anterior" tamano={20} />
</button>
<button
type="button"
class="control principal oro"
onclick={() => reproductor.alternar()}
aria-label={reproductor.sonando ? 'Pausar' : 'Reproducir'}
>
<Icono nombre={reproductor.sonando ? 'pausa' : 'play'} tamano={22} />
</button>
<button
type="button"
class="control"
onclick={() => reproductor.siguiente()}
disabled={!reproductor.haySiguiente}
aria-label="Tema siguiente"
>
<Icono nombre="siguiente" tamano={20} />
</button>
<button
type="button"
class="control"
class:encendido={reproductor.repetir !== 'no'}
onclick={() => reproductor.ciclarRepeticion()}
aria-label={etiquetaRepetir}
>
<Icono nombre="repetir" tamano={18} />
{#if reproductor.repetir === 'una'}<span class="marca" aria-hidden="true">1</span>{/if}
</button>
<!-- Transporte -->
<div class="centro">
<div class="controles">
<button
type="button"
class="control"
class:encendido={reproductor.aleatorio}
onclick={() => reproductor.alternarAleatorio()}
aria-pressed={reproductor.aleatorio}
aria-label="Reproducción aleatoria"
>
<Icono nombre="aleatorio" tamano={18} />
</button>
<button
type="button"
class="control"
onclick={() => reproductor.anterior()}
aria-label="Tema anterior"
>
<Icono nombre="anterior" tamano={20} />
</button>
<button
type="button"
class="control principal oro"
onclick={() => reproductor.alternar()}
aria-label={reproductor.sonando ? 'Pausar' : 'Reproducir'}
>
<Icono nombre={reproductor.sonando ? 'pausa' : 'play'} tamano={22} />
</button>
<button
type="button"
class="control"
onclick={() => reproductor.siguiente()}
disabled={!reproductor.haySiguiente}
aria-label="Tema siguiente"
>
<Icono nombre="siguiente" tamano={20} />
</button>
<button
type="button"
class="control"
class:encendido={reproductor.repetir !== 'no'}
disabled={reproductor.jukeboxInterrumpido}
onclick={() => reproductor.ciclarRepeticion()}
aria-label={etiquetaRepetir}
>
<Icono nombre="repetir" tamano={18} />
{#if reproductor.repetir === 'una'}<span class="marca" aria-hidden="true">1</span>{/if}
</button>
</div>
<BarraProgreso
tiempo={reproductor.tiempo}
duracion={reproductor.duracion}
onbuscar={(s) => reproductor.buscar(s)}
/>
</div>
<BarraProgreso
tiempo={reproductor.tiempo}
duracion={reproductor.duracion}
onbuscar={(s) => reproductor.buscar(s)}
/>
</div>
{/if}
<!-- Cola y volumen -->
<div class="extras">
<button
type="button"
class="control"
class:encendido={reproductor.colaVisible}
onclick={() => reproductor.alternarPanelCola()}
aria-expanded={reproductor.colaVisible}
aria-label="Ver la cola de reproducción"
>
<Icono nombre="cola" tamano={18} />
</button>
<ControlVolumen />
<ControlJukebox />
{#if pista}
<div class="herramientas-audio">
<button
type="button"
class="control"
class:encendido={reproductor.colaVisible}
onclick={() => reproductor.alternarPanelCola()}
aria-expanded={reproductor.colaVisible}
aria-label="Ver la cola de reproducción"
>
<Icono nombre="cola" tamano={18} />
</button>
<ControlVolumen />
</div>
{/if}
</div>
{#if reproductor.error}
@ -240,11 +255,20 @@
grid-template-columns: minmax(0, 1fr) minmax(0, 2fr) minmax(0, 1fr);
align-items: center;
gap: var(--sp-4);
min-height: var(--player-height);
padding: var(--sp-3) var(--sp-4);
background: var(--c-bg-raised);
border-top: 1px solid var(--c-border);
}
.barra.con-jukebox {
grid-template-columns: minmax(0, 1fr) minmax(16rem, 1.2fr) minmax(0, 1.8fr);
}
.barra.en-espera {
grid-template-columns: minmax(0, 1fr);
}
.en-espera .extras {
width: min(100%, 36rem);
margin-inline: auto;
}
.ahora {
display: flex;
@ -257,7 +281,7 @@
width: 48px;
height: 48px;
object-fit: cover;
border-radius: 3px;
border-radius: var(--radius-detail);
}
.datos {
@ -274,20 +298,9 @@
min-width: 0;
}
.calidad {
flex: none;
padding: 0 0.4em;
border: 1px solid var(--c-border);
border-radius: var(--radius-boton);
font-size: var(--fs-xs);
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--c-text-muted);
}
.titulo {
font-size: var(--fs-sm);
font-weight: 600;
font-size: var(--fs-base);
font-weight: var(--font-weight-semibold);
text-decoration: none;
}
@ -296,9 +309,9 @@
}
.contexto {
font-size: var(--fs-xs);
font-size: var(--fs-sm);
display: flex;
gap: 0.35em;
gap: var(--sp-2);
}
.contexto a {
@ -392,11 +405,19 @@
line-height: 1;
}
.extras {
.extras,
.herramientas-audio {
display: flex;
align-items: center;
justify-content: flex-end;
gap: var(--sp-2);
min-width: 0;
}
.herramientas-audio {
flex: none;
}
.con-jukebox .herramientas-audio {
padding-left: var(--sp-2);
}
.error {
@ -411,15 +432,74 @@
* 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);
}
.con-jukebox .extras {
grid-column: 1 / -1;
justify-content: flex-start;
}
.en-espera .extras {
border: 0;
padding-top: 0;
}
}
@media (max-width: 48rem) {
.barra {
.barra,
.barra.con-jukebox {
grid-template-columns: minmax(0, 1fr);
gap: var(--sp-2);
padding: var(--sp-2) var(--sp-3);
}
.extras {
.barra:not(.con-jukebox) .extras,
.herramientas-audio {
display: none;
}
.barra.con-jukebox {
grid-template-columns: minmax(0, 1fr) auto;
}
.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 {
width: var(--control-height-compact);
height: var(--control-height-compact);
}
.con-jukebox .control.principal {
width: var(--control-height);
height: var(--control-height);
}
.con-jukebox .centro :global(.progreso) {
grid-column: 1 / -1;
}
.con-jukebox .contexto {
display: block;
}
.con-jukebox .contexto a {
display: block;
}
.con-jukebox .contexto > span,
.con-jukebox .contexto a:not(:first-child) {
display: none;
}
.barra.en-espera {
grid-template-columns: minmax(0, 1fr);
}
}
</style>

@ -0,0 +1,125 @@
<script lang="ts">
import { resolve } from '$app/paths';
import Icono from '../Icono.svelte';
import { reproductor } from '$lib/player/player.svelte';
</script>
{#if reproductor.programacion}
<div
class="control-jukebox"
class:interrumpido={reproductor.jukeboxInterrumpido}
role="group"
aria-label="Jukebox"
>
<a
class="emblema"
href={resolve(`/juke-box?jukebox=${reproductor.programacion.jukeboxId}`)}
aria-label="Abrir Juke-box"
>
<Icono nombre="jukebox" tamano={24} />
</a>
<div class="datos">
<a
class="nombre"
href={resolve(`/juke-box?jukebox=${reproductor.programacion.jukeboxId}`)}
title={reproductor.programacion.nombre}>{reproductor.programacion.nombre}</a
>
<span role="status">{reproductor.estadoJukebox}</span>
</div>
{#if reproductor.jukeboxInterrumpido || reproductor.programacionDetenida}
<button class="btn btn--primary continuar" onclick={() => reproductor.reanudarProgramacion()}
><Icono nombre="play" tamano={14} />{reproductor.programacionDetenida
? 'Reproducir'
: 'Continuar'}</button
>
{/if}
{#if !reproductor.programacionDetenida}
<button
class="detener"
aria-label="Detener"
title="Detener jukebox"
onclick={() => reproductor.detenerJukebox()}><Icono nombre="detener" tamano={18} /></button
>
{/if}
</div>
{/if}
<style>
.control-jukebox {
display: flex;
align-items: center;
gap: var(--sp-3);
min-width: 0;
max-width: 100%;
flex: 0 1 auto;
padding: var(--sp-2) var(--sp-3);
border: 1px solid var(--c-border);
border-radius: var(--radius-overlay);
background: var(--c-bg);
font-size: var(--fs-base);
}
.emblema {
display: grid;
place-items: center;
flex: none;
color: var(--c-text);
text-decoration: none;
}
.datos {
display: flex;
flex-direction: column;
min-width: 0;
flex: 0 1 auto;
max-width: 16rem;
line-height: var(--leading-ui);
gap: var(--sp-1);
}
.nombre {
color: var(--c-text);
font-weight: var(--font-weight-semibold);
text-decoration: none;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.nombre:hover {
text-decoration: underline;
}
span {
color: var(--c-text-muted);
font-size: var(--fs-sm);
}
.btn {
flex: none;
min-height: var(--control-height-compact);
padding: var(--sp-1) var(--sp-2);
}
.btn.continuar {
font-size: var(--boton-texto-corto);
font-weight: var(--font-weight-regular);
}
.detener {
display: grid;
place-items: center;
flex: none;
width: var(--control-height-compact);
height: var(--control-height-compact);
padding: 0;
border: 0;
border-radius: var(--radius-round);
background: transparent;
color: var(--c-text-muted);
cursor: pointer;
}
.detener:hover {
background: var(--c-bg-raised);
color: var(--c-text);
}
@media (max-width: 48rem) {
.control-jukebox {
width: 100%;
gap: var(--sp-2);
padding: var(--sp-2);
}
}
</style>

@ -133,7 +133,7 @@
width: 36px;
height: 36px;
object-fit: cover;
border-radius: 3px;
border-radius: var(--radius-detail);
}
.datos {

@ -0,0 +1,30 @@
import type { Cancion } from './types';
import { aPista, aPistas } from './player/pista';
import { puedeEscucharCancion } from './acceso-canciones';
import { cancionesDeFranja, type ProgramacionJukebox } from './programacion-jukebox';
export function catalogoJukebox(
canciones: Cancion[],
acceso: { suscripcionActiva: boolean; accesosPrivados: string[] }
) {
return canciones.filter((c) => {
const p = aPista(c);
return Boolean(p.audio) && puedeEscucharCancion(p.acceso, p.recursoSlug, acceso);
});
}
export function prepararFranjas(
programacion: ProgramacionJukebox,
listas: { id: string; canciones: string[] }[],
canciones: Cancion[]
) {
return programacion.franjas.map((f, n) => ({
...f,
numero: n + 1,
pistas: aPistas(
cancionesDeFranja(
f,
listas,
canciones.map((c) => c.slug)
).flatMap((slug) => canciones.find((c) => c.slug === slug) ?? [])
)
}));
}

@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest';
import type { Cancion } from '$lib/types';
import {
alternarFiltroDeLetra,
crearMezclaJukebox,
PREFERENCIAS_JUKEBOX_INICIALES,
type PreferenciasJukebox
@ -41,6 +42,12 @@ const catalogo = [
];
describe('Juke-box', () => {
it('representa el estado sin filtro sin una opción ficticia', () => {
expect(alternarFiltroDeLetra('todas', 'con-letra')).toBe('con-letra');
expect(alternarFiltroDeLetra('con-letra', 'con-letra')).toBe('todas');
expect(alternarFiltroDeLetra('instrumentales', 'con-letra')).toBe('con-letra');
});
it('cruza los grupos de selección y acepta cualquiera de las etiquetas elegidas', () => {
const preferencias: PreferenciasJukebox = {
...PREFERENCIAS_JUKEBOX_INICIALES,

@ -21,6 +21,14 @@ export const PREFERENCIAS_JUKEBOX_INICIALES: PreferenciasJukebox = {
letra: 'todas'
};
/** Activa un filtro de letra o lo retira al pulsarlo de nuevo. */
export function alternarFiltroDeLetra(
actual: FiltroDeLetra,
siguiente: Exclude<FiltroDeLetra, 'todas'>
): FiltroDeLetra {
return actual === siguiente ? 'todas' : siguiente;
}
function incluye<T>(seleccion: readonly T[], valor: T | undefined): boolean {
return seleccion.length === 0 || (valor !== undefined && seleccion.includes(valor));
}

@ -12,7 +12,9 @@ class AudioDePrueba extends EventTarget {
currentTime = 0;
duration = 120;
pendientes: { resolver: () => void; rechazar: (error: Error) => void }[] = [];
load = vi.fn();
load = vi.fn(() => {
this.currentTime = 0;
});
removeAttribute() {
this.src = '';
}
@ -58,7 +60,9 @@ describe('Reproductor con solicitudes de audio solapadas', () => {
});
afterEach(() => {
reproductor.limpiar();
vi.useRealTimers();
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
it('un play antiguo rechazado no pone en pausa el tema nuevo ni termina su carga', async () => {
@ -108,4 +112,499 @@ describe('Reproductor con solicitudes de audio solapadas', () => {
expect(reproductor.pilotoAutomatico).toBe(false);
expect(reproductor.pista?.id).toBe('tres');
});
it('cambia de franja al llegar la hora, respeta el orden y se detiene en los huecos', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-09-14T07:59:59Z'));
reproductor.activarProgramacion('usuario', 'UTC', [
{
id: 'uno',
numero: 1,
orden: 'secuencial' as const,
inicio: '08:00',
fin: '09:00',
pistas: [pista('a'), pista('b')]
},
{
id: 'dos',
numero: 2,
orden: 'secuencial' as const,
inicio: '09:00',
fin: '10:00',
pistas: [pista('c')]
}
]);
expect(reproductor.franjaProgramada).toBeNull();
vi.advanceTimersByTime(1000);
expect(reproductor.pista?.id).toBe('a');
reproductor.siguiente();
expect(reproductor.pista?.id).toBe('b');
vi.setSystemTime(new Date('2026-09-14T09:00:00Z'));
reproductor.comprobarProgramacion();
expect(reproductor.pista?.id).toBe('c');
vi.setSystemTime(new Date('2026-09-14T10:00:00Z'));
reproductor.comprobarProgramacion();
expect(reproductor.pista).toBeNull();
expect(reproductor.sonando).toBe(false);
expect(reproductor.programacion).not.toBeNull();
});
it('detener cancela el reloj; elegir otra canción conserva la programación', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-09-14T08:30:00Z'));
const franjas = [
{
id: 'uno',
numero: 1,
orden: 'secuencial' as const,
inicio: '08:00',
fin: '10:00',
pistas: [pista('a')]
}
];
reproductor.activarProgramacion('u', 'UTC', franjas);
reproductor.detenerProgramacion();
expect(reproductor.sonando).toBe(false);
expect(reproductor.programacion).toBeNull();
reproductor.activarProgramacion('u', 'UTC', franjas);
reproductor.reproducirLista([pista('manual')]);
vi.advanceTimersByTime(2000);
expect(reproductor.programacion).not.toBeNull();
expect(reproductor.jukeboxInterrumpido).toBe(true);
expect(reproductor.pista?.id).toBe('manual');
expect(reproductor.repetir).toBe('no');
});
it('aplica aleatoria a toda una franja, recorre todas las pistas y vuelve a secuencial en la siguiente', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-09-14T08:30:00Z'));
vi.spyOn(Math, 'random').mockReturnValue(0);
reproductor.activarProgramacion(
'u',
'UTC',
[
{
id: 'a',
numero: 1,
orden: 'aleatoria',
inicio: '08:00',
fin: '09:00',
pistas: [pista('a'), pista('b'), pista('c')]
},
{
id: 'b',
numero: 2,
orden: 'secuencial',
inicio: '09:00',
fin: '10:00',
pistas: [pista('uno'), pista('dos')]
}
],
'j1',
1
);
expect(reproductor.aleatorio).toBe(true);
expect(reproductor.pista?.id).not.toBe('a');
const escuchadas = [reproductor.pista?.id];
audio.dispatchEvent(new Event('ended'));
escuchadas.push(reproductor.pista?.id);
audio.dispatchEvent(new Event('ended'));
escuchadas.push(reproductor.pista?.id);
expect([...escuchadas].sort()).toEqual(['a', 'b', 'c']);
const ultima = reproductor.pista?.id;
audio.dispatchEvent(new Event('ended'));
expect(reproductor.pista?.id).not.toBe(ultima);
expect(reproductor.colaOrdenada.map((p) => p.id).sort()).toEqual(['a', 'b', 'c']);
vi.setSystemTime(new Date('2026-09-14T09:00:00Z'));
reproductor.comprobarProgramacion();
expect(reproductor.aleatorio).toBe(false);
expect(reproductor.pista?.id).toBe('uno');
audio.dispatchEvent(new Event('ended'));
expect(reproductor.pista?.id).toBe('dos');
});
describe('interrupción temporal del jukebox', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-09-14T08:30:00Z'));
reproductor.activarProgramacion(
'u',
'UTC',
[
{
id: 'f1',
numero: 1,
orden: 'aleatoria',
inicio: '08:00',
fin: '09:00',
pistas: [pista('a'), pista('b'), pista('c')]
},
{
id: 'f2',
numero: 2,
orden: 'secuencial',
inicio: '09:00',
fin: '10:00',
pistas: [pista('d')]
}
],
'jukebox',
1,
0,
'Mi jukebox'
);
});
it('al terminar una canción recupera pista, segundo, posición y orden aleatorio', () => {
reproductor.siguiente();
const antes = {
id: reproductor.pista?.id,
orden: [...reproductor.orden],
posicion: reproductor.posicion
};
audio.currentTime = 37.25;
reproductor.reproducirLista([pista('manual'), pista('otra')], 0, 'cancion');
expect(reproductor.estadoJukebox).toBe('Vuelve al terminar');
expect(reproductor.pista?.id).toBe('manual');
audio.dispatchEvent(new Event('ended'));
audio.dispatchEvent(new Event('loadedmetadata'));
expect(reproductor.pista?.id).toBe(antes.id);
expect(reproductor.orden).toEqual(antes.orden);
expect(reproductor.posicion).toBe(antes.posicion);
expect(audio.currentTime).toBe(37.25);
expect(reproductor.tiempo).toBe(37.25);
expect(reproductor.aleatorio).toBe(true);
expect(reproductor.repetir).toBe('todo');
expect(reproductor.sonando).toBe(true);
expect(reproductor.jukeboxInterrumpido).toBe(false);
});
it('varias elecciones manuales conservan el primer punto de retorno y Continuar lo recupera', () => {
const original = reproductor.pista?.id;
audio.currentTime = 26;
reproductor.reproducirLista([pista('manual')]);
audio.currentTime = 12;
reproductor.reproducirLista([pista('otra')]);
reproductor.reanudarProgramacion();
audio.dispatchEvent(new Event('loadedmetadata'));
expect(reproductor.pista?.id).toBe(original);
expect(audio.currentTime).toBe(26);
});
it('pausar la canción manual desde su botón devuelve el jukebox al punto guardado', () => {
const original = reproductor.pista?.id;
const orden = [...reproductor.orden];
audio.currentTime = 37.25;
reproductor.alternarPista(pista('manual'));
expect(reproductor.jukeboxInterrumpido).toBe(true);
reproductor.alternarPista(pista('manual'));
audio.dispatchEvent(new Event('loadedmetadata'));
expect(reproductor.pista?.id).toBe(original);
expect(reproductor.orden).toEqual(orden);
expect(audio.currentTime).toBe(37.25);
expect(reproductor.sonando).toBe(true);
expect(reproductor.jukeboxInterrumpido).toBe(false);
});
it('pausar la selección manual desde la barra aplica la franja vigente', () => {
reproductor.reproducirLista([pista('manual')], 0, 'cancion');
vi.setSystemTime(new Date('2026-09-14T09:01:00Z'));
reproductor.alternar();
expect(reproductor.pista?.id).toBe('d');
expect(reproductor.franjaProgramada?.numero).toBe(2);
expect(reproductor.sonando).toBe(true);
expect(reproductor.jukeboxInterrumpido).toBe(false);
});
it('pausar la selección manual sin franja activa deja el jukebox en espera', () => {
reproductor.reproducirLista([pista('manual')], 0, 'cancion');
vi.setSystemTime(new Date('2026-09-14T10:30:00Z'));
reproductor.alternar();
expect(reproductor.pista).toBeNull();
expect(reproductor.estadoJukebox).toBe('Esperando próxima franja');
expect(reproductor.sonando).toBe(false);
});
it('la pausa del propio jukebox se mantiene hasta volver a pulsar play', () => {
const original = reproductor.pista?.id;
reproductor.alternar();
vi.advanceTimersByTime(2000);
expect(reproductor.pista?.id).toBe(original);
expect(reproductor.sonando).toBe(false);
expect(reproductor.estadoJukebox).toBe('Franja 1 · En pausa');
reproductor.alternar();
expect(reproductor.sonando).toBe(true);
});
it('la pausa explícita sigue vigente cuando el reloj entra en otra franja', () => {
const original = reproductor.pista?.id;
audio.currentTime = 31;
reproductor.alternar();
const cargas = audio.load.mock.calls.length;
vi.setSystemTime(new Date('2026-09-14T09:01:00Z'));
vi.advanceTimersByTime(2000);
expect(reproductor.programacionEnPausa).toBe(true);
expect(reproductor.pista?.id).toBe(original);
expect(audio.currentTime).toBe(31);
expect(audio.load).toHaveBeenCalledTimes(cargas);
expect(audio.paused).toBe(true);
expect(reproductor.sonando).toBe(false);
});
it('Play tras una pausa cambia a la franja vigente en vez de arrancar la cola anterior', () => {
audio.currentTime = 31;
reproductor.alternar();
vi.setSystemTime(new Date('2026-09-14T09:01:00Z'));
reproductor.alternar();
expect(reproductor.programacionEnPausa).toBe(false);
expect(reproductor.franjaProgramada?.id).toBe('f2');
expect(reproductor.pista?.id).toBe('d');
expect(audio.currentTime).toBe(0);
expect(reproductor.sonando).toBe(true);
});
it.each(['terminar', 'pausar'] as const)(
'%s la canción manual conserva la pausa que tenía previamente el jukebox',
(accion) => {
reproductor.siguiente();
const original = {
id: reproductor.pista?.id,
orden: [...reproductor.orden],
posicion: reproductor.posicion
};
audio.currentTime = 28.5;
reproductor.alternar();
reproductor.alternarPista(pista('manual'));
expect(reproductor.sonando).toBe(true);
expect(reproductor.jukeboxInterrumpido).toBe(true);
if (accion === 'terminar') audio.dispatchEvent(new Event('ended'));
else reproductor.alternar();
audio.dispatchEvent(new Event('loadedmetadata'));
vi.advanceTimersByTime(2000);
expect(reproductor.jukeboxInterrumpido).toBe(false);
expect(reproductor.programacionEnPausa).toBe(true);
expect(reproductor.pista?.id).toBe(original.id);
expect(reproductor.orden).toEqual(original.orden);
expect(reproductor.posicion).toBe(original.posicion);
expect(audio.currentTime).toBe(28.5);
expect(audio.paused).toBe(true);
expect(reproductor.sonando).toBe(false);
}
);
it('Continuar explícito levanta la pausa previa y recupera el jukebox durante una canción manual', () => {
const original = reproductor.pista?.id;
audio.currentTime = 22;
reproductor.alternar();
reproductor.alternarPista(pista('manual'));
reproductor.reanudarProgramacion();
audio.dispatchEvent(new Event('loadedmetadata'));
expect(reproductor.programacionEnPausa).toBe(false);
expect(reproductor.jukeboxInterrumpido).toBe(false);
expect(reproductor.pista?.id).toBe(original);
expect(audio.currentTime).toBe(22);
expect(reproductor.sonando).toBe(true);
});
it('Play al día siguiente reinicia la franja aunque conserve el mismo identificador', () => {
reproductor.siguiente();
audio.currentTime = 48;
reproductor.alternar();
vi.setSystemTime(new Date('2026-09-15T08:30:00Z'));
reproductor.alternar();
audio.dispatchEvent(new Event('loadedmetadata'));
expect(reproductor.franjaProgramada?.id).toBe('f1');
expect(reproductor.posicion).toBe(0);
expect(audio.currentTime).toBe(0);
expect(reproductor.programacionEnPausa).toBe(false);
expect(reproductor.sonando).toBe(true);
});
it('una canción manual tras una pausa de un día no convierte la cola antigua en la sesión de hoy', () => {
reproductor.siguiente();
audio.currentTime = 48;
reproductor.alternar();
vi.setSystemTime(new Date('2026-09-15T08:30:00Z'));
reproductor.alternarPista(pista('manual'));
reproductor.reanudarProgramacion();
audio.dispatchEvent(new Event('loadedmetadata'));
expect(reproductor.franjaProgramada?.id).toBe('f1');
expect(reproductor.posicion).toBe(0);
expect(audio.currentTime).toBe(0);
expect(reproductor.programacionEnPausa).toBe(false);
expect(reproductor.sonando).toBe(true);
});
it('una pausa técnica, por ejemplo al abrir un vídeo, no arranca el jukebox', () => {
reproductor.reproducirLista([pista('manual')], 0, 'cancion');
reproductor.pausar();
vi.advanceTimersByTime(2000);
expect(reproductor.pista?.id).toBe('manual');
expect(reproductor.sonando).toBe(false);
expect(reproductor.jukeboxInterrumpido).toBe(true);
});
it('un cambio de franja no corta la canción manual y se aplica al terminarla', () => {
reproductor.reproducirLista([pista('manual')]);
vi.setSystemTime(new Date('2026-09-14T09:01:00Z'));
vi.advanceTimersByTime(2000);
expect(reproductor.pista?.id).toBe('manual');
audio.dispatchEvent(new Event('ended'));
expect(reproductor.pista?.id).toBe('d');
expect(reproductor.franjaProgramada?.numero).toBe(2);
expect(reproductor.aleatorio).toBe(false);
expect(audio.currentTime).toBe(0);
});
it('queda esperando si ya no hay franja y arranca la siguiente cuando llega', () => {
reproductor.reproducirLista([pista('manual')]);
vi.setSystemTime(new Date('2026-09-14T10:30:00Z'));
audio.dispatchEvent(new Event('ended'));
expect(reproductor.pista).toBeNull();
expect(reproductor.estadoJukebox).toBe('Esperando próxima franja');
expect(reproductor.sonando).toBe(false);
vi.setSystemTime(new Date('2026-09-15T08:00:00Z'));
vi.advanceTimersByTime(1000);
expect(reproductor.franjaProgramada?.numero).toBe(1);
expect(reproductor.sonando).toBe(true);
});
it('no recupera un segundo antiguo si ya es la sesión del día siguiente', () => {
audio.currentTime = 50;
reproductor.reproducirLista([pista('manual')]);
vi.setSystemTime(new Date('2026-09-15T08:30:00Z'));
reproductor.reanudarProgramacion();
audio.dispatchEvent(new Event('loadedmetadata'));
expect(audio.currentTime).toBe(0);
expect(reproductor.posicion).toBe(0);
});
it('Detener cancela el retorno sin cortar la canción elegida', () => {
reproductor.reproducirLista([pista('manual')]);
reproductor.detenerJukebox();
expect(reproductor.programacion?.jukeboxId).toBe('jukebox');
expect(reproductor.estadoJukebox).toBe('Detenido');
expect(reproductor.jukeboxInterrumpido).toBe(false);
expect(reproductor.sonando).toBe(true);
audio.dispatchEvent(new Event('ended'));
vi.advanceTimersByTime(1000);
expect(reproductor.pista?.id).toBe('manual');
expect(reproductor.sonando).toBe(false);
});
it('detener conserva el nombre y las franjas, vacía la escucha y no arranca con el reloj', () => {
const franjas = reproductor.programacion?.franjas;
audio.currentTime = 42;
reproductor.detenerJukebox();
expect(reproductor.programacion?.nombre).toBe('Mi jukebox');
expect(reproductor.programacion?.franjas).toEqual(franjas);
expect(reproductor.estadoJukebox).toBe('Detenido');
expect(reproductor.pista).toBeNull();
expect(audio.paused).toBe(true);
expect(audio.src).toBe('');
vi.setSystemTime(new Date('2026-09-14T09:01:00Z'));
vi.advanceTimersByTime(2000);
reproductor.comprobarProgramacion();
expect(reproductor.sonando).toBe(false);
expect(reproductor.estadoJukebox).toBe('Detenido');
reproductor.reanudarProgramacion();
expect(reproductor.pista?.id).toBe('d');
expect(reproductor.programacionDetenida).toBe(false);
expect(reproductor.sonando).toBe(true);
expect(audio.currentTime).toBe(0);
});
it('una canción manual después de detener no activa el jukebox ni retira sus controles', () => {
reproductor.detenerJukebox();
reproductor.reproducirLista([pista('manual')], 0, 'cancion');
expect(reproductor.jukeboxInterrumpido).toBe(false);
expect(reproductor.estadoJukebox).toBe('Detenido');
reproductor.alternar();
expect(reproductor.programacionEnPausa).toBe(false);
expect(reproductor.pista?.id).toBe('manual');
expect(reproductor.sonando).toBe(false);
reproductor.ciclarRepeticion();
expect(reproductor.programacion?.jukeboxId).toBe('jukebox');
reproductor.alternar();
expect(reproductor.pista?.id).toBe('manual');
expect(reproductor.sonando).toBe(true);
expect(reproductor.estadoJukebox).toBe('Detenido');
});
it('cerrar sesión también descarta un jukebox detenido', () => {
reproductor.detenerJukebox();
reproductor.detenerProgramacion(true);
expect(reproductor.programacion).toBeNull();
expect(reproductor.programacionDetenida).toBe(false);
});
it('elegir un álbum completo espera al final del álbum', () => {
reproductor.reproducirLista([pista('manual'), pista('otra')]);
audio.dispatchEvent(new Event('ended'));
expect(reproductor.pista?.id).toBe('otra');
expect(reproductor.jukeboxInterrumpido).toBe(true);
audio.dispatchEvent(new Event('ended'));
expect(reproductor.jukeboxInterrumpido).toBe(false);
});
it('no interrumpe el jukebox cuando la selección no tiene audio', () => {
const original = reproductor.pista?.id;
reproductor.reproducirLista([{ ...pista('invalida'), audio: '' }]);
expect(reproductor.pista?.id).toBe(original);
expect(reproductor.jukeboxInterrumpido).toBe(false);
});
it('descarta una restauración pendiente al cambiar de pista antes de los metadatos', () => {
audio.currentTime = 42;
reproductor.reproducirLista([pista('manual')]);
reproductor.reanudarProgramacion();
reproductor.siguiente();
audio.dispatchEvent(new Event('loadedmetadata'));
expect(audio.currentTime).toBe(0);
});
it('cambiar de jukebox descarta el retorno anterior', () => {
reproductor.reproducirLista([pista('manual')]);
reproductor.activarProgramacion(
'u',
'UTC',
[
{
id: 'nueva',
numero: 1,
orden: 'secuencial',
inicio: '00:00',
fin: '00:00',
pistas: [pista('nuevo')]
}
],
'j2'
);
audio.dispatchEvent(new Event('ended'));
expect(reproductor.programacion?.jukeboxId).toBe('j2');
expect(reproductor.pista?.id).toBe('nuevo');
expect(reproductor.jukeboxInterrumpido).toBe(false);
});
it('la revocación del acceso también detiene la selección manual', () => {
reproductor.reproducirLista([pista('manual')]);
reproductor.detenerProgramacion(true);
expect(reproductor.programacion).toBeNull();
expect(reproductor.sonando).toBe(false);
});
});
it('el final de una pista al cambiar el día inicia la nueva sesión sin saltar su primera canción', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-09-14T23:59:59Z'));
reproductor.activarProgramacion('u', 'UTC', [
{
id: 'todo-el-dia',
numero: 1,
orden: 'secuencial',
inicio: '00:00',
fin: '00:00',
pistas: [pista('primera'), pista('segunda')]
}
]);
reproductor.siguiente();
expect(reproductor.pista?.id).toBe('segunda');
const cargas = audio.load.mock.calls.length;
// Cambia la fecha sin ejecutar el reloj: ended detecta primero la nueva sesión.
vi.setSystemTime(new Date('2026-09-15T00:00:00Z'));
audio.dispatchEvent(new Event('ended'));
expect(reproductor.franjaProgramada?.id).toBe('todo-el-dia');
expect(reproductor.pista?.id).toBe('primera');
expect(reproductor.posicion).toBe(0);
expect(audio.currentTime).toBe(0);
expect(audio.load).toHaveBeenCalledTimes(cargas + 1);
expect(reproductor.sonando).toBe(true);
});
it('cambiar de jukebox sustituye el reloj y la cola anterior', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-09-14T08:30:00Z'));
const f = {
id: 'f',
numero: 1,
orden: 'secuencial' as const,
inicio: '00:00',
fin: '00:00',
pistas: [pista('uno')]
};
reproductor.activarProgramacion('u', 'UTC', [f], 'j1', 1);
reproductor.activarProgramacion('u', 'UTC', [{ ...f, pistas: [pista('dos')] }], 'j2', 2);
vi.advanceTimersByTime(2000);
expect(reproductor.programacion?.jukeboxId).toBe('j2');
expect(reproductor.pista?.id).toBe('dos');
});
});

@ -21,6 +21,12 @@ import {
type ModoRepeticion
} from './cola';
import type { AccesoCancion } from '$lib/canciones';
import { franjaEnFecha, ocurrenciaDeFranja, type FranjaJukebox } from '$lib/programacion-jukebox';
export interface FranjaReproductor extends Pick<FranjaJukebox, 'id' | 'inicio' | 'fin' | 'orden'> {
numero: number;
pistas: PistaReproductor[];
}
/** Un tema tal y como lo necesita el reproductor, ya desnormalizado. */
export interface PistaReproductor {
@ -88,6 +94,48 @@ export class Reproductor {
colaVisible = $state(false);
/** La cola se ha generado desde el Juke-box y vuelve a empezar al terminar. */
pilotoAutomatico = $state(false);
programacion = $state<{
usuarioId: string;
jukeboxId: string;
nombre: string;
numero: number;
revision: number;
zonaHoraria: string;
franjas: FranjaReproductor[];
} | null>(null);
franjaProgramada = $state<FranjaReproductor | null>(null);
/** Intención explícita del usuario; el reloj no puede levantar esta pausa. */
programacionEnPausa = $state(false);
/** Detenido conserva el jukebox elegido, pero desarma el reloj y su retorno. */
programacionDetenida = $state(false);
private ocurrenciaProgramada = '';
private puntoDeRetorno = $state<{
franja: FranjaReproductor | null;
ocurrencia: string;
cola: PistaReproductor[];
orden: number[];
posicion: number;
tiempo: number;
aleatorio: boolean;
repetir: ModoRepeticion;
enPausa: boolean;
} | null>(null);
private retornoAlTerminar: 'cancion' | 'lista' = 'cancion';
jukeboxInterrumpido = $derived(this.puntoDeRetorno !== null);
estadoJukebox = $derived(
!this.programacion
? ''
: this.programacionDetenida
? 'Detenido'
: this.jukeboxInterrumpido
? this.programacionEnPausa
? 'Jukebox en pausa'
: 'Vuelve al terminar'
: this.franjaProgramada
? `Franja ${this.franjaProgramada.numero} · ${this.sonando ? 'Reproduciendo' : 'En pausa'}`
: 'Esperando próxima franja'
);
private relojProgramacion: ReturnType<typeof setInterval> | undefined;
/** Pista sonando ahora mismo, o `null`. */
pista = $derived.by(() => {
@ -110,6 +158,7 @@ export class Reproductor {
/** No es reactivo a proposito: es un recurso, no estado de interfaz. */
private audio: HTMLAudioElement | null = null;
private intento = 0;
private cancelarRestauracion: (() => void) | undefined;
/** El cuadro pedido para ir leyendo el reloj del audio, o 0 si no hay. */
private cuadro = 0;
@ -190,11 +239,12 @@ export class Reproductor {
}
/** Carga la pista de la posicion actual y, si se pide, la arranca. */
private cargar(reproducir: boolean): void {
private cargar(reproducir: boolean, desde = 0): void {
const el = this.asegurarAudio();
const pista = this.pista;
if (!el || !pista) return;
this.cancelarRestauracion?.();
this.intento++;
this.sonando = false;
this.cargando = false;
@ -205,12 +255,26 @@ export class Reproductor {
el.src = pista.audio;
el.load();
this.publicarEnSistema(pista);
if (desde > 0) {
// La duración y la búsqueda solo son fiables tras cargar los metadatos.
const restaurar = () => {
el.currentTime = Number.isFinite(el.duration) ? Math.min(desde, el.duration) : desde;
this.tiempo = el.currentTime;
this.cancelarRestauracion = undefined;
};
el.addEventListener('loadedmetadata', restaurar, { once: true });
this.cancelarRestauracion = () => {
el.removeEventListener('loadedmetadata', restaurar);
this.cancelarRestauracion = undefined;
};
}
if (reproducir) void this.arrancar();
}
private async arrancar(): Promise<void> {
if (!this.pista) return;
if (this.programacionEnPausa && !this.jukeboxInterrumpido) return;
const el = this.asegurarAudio();
if (!el) return;
const intento = ++this.intento;
@ -240,13 +304,38 @@ export class Reproductor {
artwork: [{ src: pista.portada, sizes: '512x512' }]
});
navigator.mediaSession.setActionHandler('play', () => void this.arrancar());
navigator.mediaSession.setActionHandler('pause', () => this.pausar());
navigator.mediaSession.setActionHandler('play', () => {
if (this.programacionEnPausa && !this.jukeboxInterrumpido) this.reanudarProgramacion();
else void this.arrancar();
});
navigator.mediaSession.setActionHandler('pause', () => {
if (this.programacion && !this.programacionDetenida && !this.jukeboxInterrumpido)
this.programacionEnPausa = true;
this.pausar();
});
navigator.mediaSession.setActionHandler('previoustrack', () => this.anterior());
navigator.mediaSession.setActionHandler('nexttrack', () => this.siguiente());
}
private alTerminar(): void {
if (this.programacionEnPausa && !this.jukeboxInterrumpido) return;
if (
this.jukeboxInterrumpido &&
(this.retornoAlTerminar === 'cancion' || this.posicion >= this.orden.length - 1)
) {
this.volverTrasSeleccion();
return;
}
if (this.programacion) {
const anterior = this.franjaProgramada?.id;
const ocurrenciaAnterior = this.ocurrenciaProgramada;
this.comprobarProgramacion();
if (
anterior !== this.franjaProgramada?.id ||
ocurrenciaAnterior !== this.ocurrenciaProgramada
)
return;
}
const decision = alTerminarPista(this.posicion, this.orden.length, this.repetir);
if (decision.accion === 'repetir') {
@ -255,6 +344,7 @@ export class Reproductor {
return;
}
if (decision.accion === 'ir') {
this.prepararVuelta(decision.posicion);
this.posicion = decision.posicion;
this.cargar(true);
return;
@ -272,9 +362,101 @@ export class Reproductor {
* Sustituye la cola por `pistas` y empieza a sonar desde `indice`.
* Es lo que hace "Reproducir album" y el clic sobre un tema de un listado.
*/
reproducirLista(pistas: PistaReproductor[], indice = 0): void {
// Una reproducción elegida fuera del Juke-box toma el control de la cola.
reproducirLista(
pistas: PistaReproductor[],
indice = 0,
seleccion: 'cancion' | 'lista' = 'lista'
): void {
if (!pistas.some((p) => p.audio)) {
this.error = 'Todavía no hay audio disponible para estos temas.';
return;
}
this.suspenderProgramacion();
this.retornoAlTerminar = seleccion;
this.pilotoAutomatico = false;
this.cargarLista(pistas, indice);
}
private suspenderProgramacion(): void {
if (!this.programacion || this.programacionDetenida || this.puntoDeRetorno) return;
this.puntoDeRetorno = {
franja: this.franjaProgramada,
ocurrencia: this.ocurrenciaProgramada,
cola: [...this.cola],
orden: [...this.orden],
posicion: this.posicion,
tiempo: this.audio?.currentTime ?? this.tiempo,
aleatorio: this.aleatorio,
repetir: this.repetir,
enPausa: this.programacionEnPausa
};
this.pausar();
this.aleatorio = false;
this.repetir = 'no';
}
/** Play o Continuar explícito: levanta la pausa y valida la franja actual. */
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- Consulta puntual del reloj al volver.
reanudarProgramacion(ahora = new Date()): void {
if (!this.programacion) return;
if (this.programacionDetenida) {
const p = this.programacion;
this.activarProgramacion(
p.usuarioId,
p.zonaHoraria,
p.franjas,
p.jukeboxId,
p.numero,
p.revision,
p.nombre
);
return;
}
if (this.puntoDeRetorno) {
this.recuperarProgramacion(ahora, true);
return;
}
this.programacionEnPausa = false;
this.comprobarProgramacion(ahora);
if (this.pista && !this.sonando && !this.cargando) void this.arrancar();
}
/** Terminar o pausar la selección manual respeta una pausa previa del jukebox. */
private volverTrasSeleccion(): void {
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- Consulta puntual al devolver el control.
this.recuperarProgramacion(new Date(), !this.puntoDeRetorno?.enPausa);
}
private recuperarProgramacion(ahora: Date, reproducir: boolean): void {
const guardado = this.puntoDeRetorno;
const programa = this.programacion;
if (!guardado || !programa) return;
this.pausar();
this.puntoDeRetorno = null;
this.programacionEnPausa = !reproducir;
const franja = franjaEnFecha(programa.franjas, programa.zonaHoraria, ahora);
if (
!reproducir ||
(franja &&
franja.id === guardado.franja?.id &&
ocurrenciaDeFranja(franja, programa.zonaHoraria, ahora) === guardado.ocurrencia)
) {
this.franjaProgramada = guardado.franja;
this.ocurrenciaProgramada = guardado.ocurrencia;
this.cola = guardado.cola;
this.orden = guardado.orden;
this.posicion = guardado.posicion;
this.aleatorio = guardado.aleatorio;
this.repetir = guardado.repetir;
this.pilotoAutomatico = Boolean(guardado.franja);
this.cargar(reproducir, guardado.tiempo);
} else {
this.franjaProgramada = null;
this.comprobarProgramacion(ahora);
}
}
private cargarLista(pistas: PistaReproductor[], indice = 0, barajarTodas = false): void {
const reproducibles = pistas.filter((p) => p.audio);
if (reproducibles.length === 0) {
this.error = 'Todavía no hay audio disponible para estos temas.';
@ -291,7 +473,7 @@ export class Reproductor {
this.cola = reproducibles;
this.orden = this.aleatorio
? ordenAleatorio(reproducibles.length, inicio)
? ordenAleatorio(reproducibles.length, barajarTodas ? -1 : inicio)
: ordenNatural(reproducibles.length);
this.posicion = this.aleatorio ? 0 : inicio;
this.cargar(true);
@ -307,6 +489,7 @@ export class Reproductor {
this.error = 'No hay canciones disponibles para esta selección.';
return;
}
this.detenerProgramacion(false);
this.repetir = 'todo';
this.reproducirLista(pistas, 0);
this.pilotoAutomatico = true;
@ -314,10 +497,109 @@ export class Reproductor {
/** Conserva la cola actual, pero deja de reiniciarla cuando llegue al final. */
desactivarPilotoAutomatico(): void {
this.detenerProgramacion(false);
this.pilotoAutomatico = false;
if (this.repetir === 'todo') this.repetir = 'no';
}
/** Se arma mediante un gesto del usuario; el reloj sobrevive a la navegación. */
activarProgramacion(
usuarioId: string,
zonaHoraria: string,
franjas: FranjaReproductor[],
jukeboxId = 'principal',
numero = 1,
revision = 0,
nombre = `Jukebox ${numero}`
): void {
this.detenerProgramacion();
this.programacion = { usuarioId, zonaHoraria, franjas, jukeboxId, numero, revision, nombre };
this.aleatorio = false;
this.comprobarProgramacion();
if (browser) {
// Desbloquea este mismo elemento con silencio si la primera franja es futura.
if (!this.franjaProgramada) {
const el = this.asegurarAudio();
if (el) {
el.src =
'data:audio/wav;base64,UklGRiYAAABXQVZFZm10IBAAAAABAAEAQB8AAEAfAAABAAgAZGF0YQIAAACAgA==';
void el.play().catch(() => {
this.error =
'El navegador ha bloqueado el audio. Vuelve a activar la programación cuando empiece la franja.';
});
}
}
this.relojProgramacion = setInterval(() => this.comprobarProgramacion(), 1000);
}
}
/** Al llegar al límite cambia de lista; en los huecos no reproduce nada. */
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- Instante de consulta; el reloj invoca explícitamente esta función.
comprobarProgramacion(ahora = new Date()): void {
const programa = this.programacion;
if (!programa) return;
// Un cambio horario nunca corta la selección manual. Se resuelve al volver.
if (this.jukeboxInterrumpido || this.programacionEnPausa || this.programacionDetenida) return;
const franja = franjaEnFecha(programa.franjas, programa.zonaHoraria, ahora);
const ocurrencia = franja ? ocurrenciaDeFranja(franja, programa.zonaHoraria, ahora) : '';
if (
franja &&
franja.id === this.franjaProgramada?.id &&
ocurrencia === this.ocurrenciaProgramada
)
return;
if (!franja && !this.franjaProgramada && this.pista === null) return;
this.pausar();
this.franjaProgramada = franja ?? null;
this.ocurrenciaProgramada = ocurrencia;
this.pilotoAutomatico = Boolean(franja);
this.repetir = franja ? 'todo' : 'no';
if (franja) {
this.aleatorio = franja.orden === 'aleatoria';
this.cargarLista(franja.pistas, 0, true);
} else {
this.cancelarRestauracion?.();
this.cola = [];
this.orden = [];
this.posicion = -1;
this.tiempo = 0;
this.duracionAudio = 0;
this.colaVisible = false;
if (this.audio) {
this.audio.removeAttribute('src');
this.audio.load();
}
if (browser && 'mediaSession' in navigator) navigator.mediaSession.metadata = null;
}
}
/** Detiene la sesión de escucha sin retirar el jukebox de sus controles. */
detenerJukebox(): void {
const programa = this.programacion;
if (!programa || this.programacionDetenida) return;
if (this.jukeboxInterrumpido) this.detenerProgramacion(false);
else this.limpiar();
this.programacion = programa;
this.programacionDetenida = true;
}
/** Descarta la sesión al cerrar, cambiar de jukebox, eliminarlo o perder el acceso. */
detenerProgramacion(detenerAudio = !this.jukeboxInterrumpido): void {
if (this.relojProgramacion !== undefined) clearInterval(this.relojProgramacion);
this.relojProgramacion = undefined;
if (!this.programacion) return;
this.puntoDeRetorno = null;
this.cancelarRestauracion?.();
this.programacion = null;
this.franjaProgramada = null;
this.programacionEnPausa = false;
this.programacionDetenida = false;
this.ocurrenciaProgramada = '';
this.pilotoAutomatico = false;
this.repetir = 'no';
if (detenerAudio) this.pausar();
}
/**
* Anade pistas al final de la cola, sin tocar lo que suena.
*
@ -372,16 +654,21 @@ export class Reproductor {
0,
contexto.findIndex((p) => p.id === pista.id)
);
this.reproducirLista(contexto, indice);
this.reproducirLista(contexto, indice, 'cancion');
}
/** Play/pausa del tema actual. */
/** La pausa de una selección manual devuelve el control al jukebox suspendido. */
alternar(): void {
if (!this.pista) return;
if (this.sonando) this.pausar();
if (this.sonando && this.jukeboxInterrumpido) this.volverTrasSeleccion();
else if (this.sonando) {
if (this.programacion && !this.programacionDetenida) this.programacionEnPausa = true;
this.pausar();
} else if (this.programacionEnPausa && !this.jukeboxInterrumpido) this.reanudarProgramacion();
else void this.arrancar();
}
/** Pausa técnica: no inicia otra fuente de audio (vídeo, sistema o cambio de pista). */
pausar(): void {
this.intento++;
this.audio?.pause();
@ -389,9 +676,24 @@ export class Reproductor {
this.cargando = false;
}
private prepararVuelta(destino: number): void {
if (
destino !== 0 ||
!this.programacion ||
this.jukeboxInterrumpido ||
!this.aleatorio ||
this.cola.length < 2
)
return;
const anterior = this.orden[this.posicion];
const orden = ordenAleatorio(this.cola.length, -1);
if (orden[0] === anterior) [orden[0], orden[1]] = [orden[1], orden[0]];
this.orden = orden;
}
siguiente(): void {
const destino = siguientePosicion(this.posicion, this.orden.length, this.repetir === 'todo');
if (destino === null) return;
this.prepararVuelta(destino);
this.posicion = destino;
this.cargar(true);
}
@ -475,6 +777,9 @@ export class Reproductor {
}
ciclarRepeticion(): void {
// La selección temporal termina una vez: repetirla impediría volver al jukebox.
if (this.jukeboxInterrumpido) return;
if (!this.programacionDetenida) this.detenerProgramacion(false);
this.repetir = siguienteModoRepeticion(this.repetir);
if (this.repetir !== 'todo') this.pilotoAutomatico = false;
}
@ -485,6 +790,8 @@ export class Reproductor {
/** Vacia la cola y detiene la reproduccion. */
limpiar(): void {
this.cancelarRestauracion?.();
this.detenerProgramacion(false);
this.pausar();
if (this.audio) {
this.audio.removeAttribute('src');
@ -526,8 +833,14 @@ const reproductorAnterior = browser
? (import.meta.hot?.data.reproductor as Reproductor | undefined)
: undefined;
export const reproductor = reproductorAnterior ?? new Reproductor();
// Una instancia de desarrollo antigua no debe conservar métodos anteriores a estos cambios.
const versionReproductor = 8;
const compatible = import.meta.hot?.data.versionReproductor === versionReproductor;
if (reproductorAnterior && !compatible) reproductorAnterior.limpiar();
export const reproductor =
compatible && reproductorAnterior ? reproductorAnterior : new Reproductor();
if (browser && import.meta.hot) {
import.meta.hot.data.reproductor = reproductor;
import.meta.hot.data.versionReproductor = versionReproductor;
}

@ -0,0 +1,116 @@
import { describe, expect, it } from 'vitest';
import {
cancionesDeFranja,
contieneMinuto,
franjaEnFecha,
ocurrenciaDeFranja,
validarProgramacion,
type FranjaJukebox
} from './programacion-jukebox';
const franja = (id: string, inicio = '08:00', fin = '10:00'): FranjaJukebox => ({
id,
orden: 'secuencial',
catalogo: false,
inicio,
fin,
contenidos: []
});
const programa = (franjas: FranjaJukebox[]) => ({ zonaHoraria: 'Europe/Madrid', franjas });
describe('Programación diaria del Juke-box', () => {
it('identifica una franja nocturna a ambos lados de medianoche en su zona horaria', () => {
const nocturna = franja('noche', '22:00', '06:00');
expect(ocurrenciaDeFranja(nocturna, 'Europe/Madrid', new Date('2026-09-14T21:00:00Z'))).toBe(
'2026-09-14'
);
expect(ocurrenciaDeFranja(nocturna, 'Europe/Madrid', new Date('2026-09-15T03:00:00Z'))).toBe(
'2026-09-14'
);
expect(ocurrenciaDeFranja(nocturna, 'Europe/Madrid', new Date('2026-09-15T21:00:00Z'))).toBe(
'2026-09-15'
);
});
it('incluye el inicio y excluye el final sin solapar franjas contiguas', () => {
expect(contieneMinuto(franja('mañana'), 480)).toBe(true);
expect(contieneMinuto(franja('mañana'), 600)).toBe(false);
expect(
validarProgramacion(programa([franja('uno'), franja('dos', '10:00', '12:00')]))
).toBeNull();
});
it('resuelve franjas nocturnas y detecta su solapamiento a ambos lados de medianoche', () => {
const noche = franja('noche', '22:00', '02:00');
expect(contieneMinuto(noche, 60)).toBe(true);
expect(contieneMinuto(noche, 720)).toBe(false);
expect(validarProgramacion(programa([noche, franja('madrugada', '01:00', '03:00')]))).toContain(
'solapa'
);
expect(validarProgramacion(programa([noche, franja('tarde', '21:00', '23:00')]))).toContain(
'solapa'
);
});
it('un horario de 24 horas ocupa el día completo', () => {
expect(contieneMinuto(franja('día', '00:00', '00:00'), 1439)).toBe(true);
expect(
validarProgramacion(programa([franja('día', '00:00', '00:00'), franja('otra')]))
).toContain('solapa');
});
it('usa la referencia horaria guardada y sus cambios de horario de verano', () => {
expect(
franjaEnFecha([franja('mañana')], 'Europe/Madrid', new Date('2026-07-01T06:30:00Z'))?.id
).toBe('mañana');
expect(
franjaEnFecha([franja('mañana')], 'Europe/Madrid', new Date('2026-01-01T06:30:00Z'))
).toBeUndefined();
expect(
franjaEnFecha(
[franja('noche', '22:00', '02:00')],
'Europe/Madrid',
new Date('2026-07-01T22:30:00Z')
)?.id
).toBe('noche');
});
it('rechaza datos malformados, horas inválidas e identificadores duplicados', () => {
for (const valor of [
null,
{},
{ zonaHoraria: null, franjas: [] },
{ zonaHoraria: 'No/existe', franjas: [] },
programa([franja('x', '24:30')]),
programa([franja('x'), franja('x')])
])
expect(validarProgramacion(valor)).not.toBeNull();
});
it('expande las listas en el orden elegido, conserva repeticiones y admite una lista eliminada', () => {
const f = {
...franja('x'),
contenidos: [
{ tipo: 'cancion' as const, id: 'tres' },
{ tipo: 'lista' as const, id: 'lista' },
{ tipo: 'lista' as const, id: 'retirada' }
]
};
expect(cancionesDeFranja(f, [{ id: 'lista', canciones: ['uno', 'tres', 'dos'] }])).toEqual([
'tres',
'uno',
'tres',
'dos'
]);
});
it('el catálogo completo se resuelve al activar sin duplicar la selección guardada', () => {
const f = {
...franja('x'),
catalogo: true,
contenidos: [{ tipo: 'cancion' as const, id: 'uno' }]
};
expect(cancionesDeFranja(f, [], ['uno', 'dos', 'tres'])).toEqual(['uno', 'dos', 'tres']);
expect(cancionesDeFranja({ ...f, catalogo: false }, [], ['uno', 'dos', 'tres'])).toEqual([
'uno'
]);
});
it('admite franjas sin nombre y exige un modo de reproducción conocido', () => {
expect(validarProgramacion(programa([franja('x')]))).toBeNull();
expect(validarProgramacion(programa([{ ...franja('x'), orden: 'otro' as never }]))).toContain(
'modo'
);
});
});

@ -0,0 +1,172 @@
/** Programación diaria. Las horas se interpretan en la zona elegida por la cuenta. */
export type FuenteJukebox = { tipo: 'lista' | 'cancion'; id: string };
export interface FranjaJukebox {
id: string;
orden: 'secuencial' | 'aleatoria';
catalogo: boolean;
inicio: string;
fin: string;
contenidos: FuenteJukebox[];
}
export interface ProgramacionJukebox {
zonaHoraria: string;
franjas: FranjaJukebox[];
}
export interface JukeboxGuardado {
id: string;
nombre: string;
descripcion: string;
numero: number;
programacion: ProgramacionJukebox;
revision: number;
}
export const PROGRAMACION_VACIA: ProgramacionJukebox = {
zonaHoraria: 'Europe/Madrid',
franjas: []
};
export function minutosDeHora(hora: string): number {
const [h, m] = hora.split(':').map(Number);
return h * 60 + m;
}
/** Inicio incluido, fin excluido. Horas iguales significan un día completo. */
export function contieneMinuto(
franja: Pick<FranjaJukebox, 'inicio' | 'fin'>,
minuto: number
): boolean {
const inicio = minutosDeHora(franja.inicio);
const fin = minutosDeHora(franja.fin);
return (
inicio === fin ||
(inicio < fin ? minuto >= inicio && minuto < fin : minuto >= inicio || minuto < fin)
);
}
export function franjaEnFecha<T extends Pick<FranjaJukebox, 'inicio' | 'fin'>>(
franjas: T[],
zonaHoraria: string,
ahora = new Date()
): T | undefined {
const partes = new Intl.DateTimeFormat('en-GB', {
timeZone: zonaHoraria,
hour: '2-digit',
minute: '2-digit',
hourCycle: 'h23'
}).formatToParts(ahora);
const minuto =
Number(partes.find((p) => p.type === 'hour')?.value) * 60 +
Number(partes.find((p) => p.type === 'minute')?.value);
return franjas.find((franja) => contieneMinuto(franja, minuto));
}
/** Distingue la sesión de hoy de la de mañana, incluyendo franjas nocturnas. */
export function ocurrenciaDeFranja(
franja: Pick<FranjaJukebox, 'inicio' | 'fin'>,
zonaHoraria: string,
ahora = new Date()
): string {
if (franja.inicio === franja.fin) return 'continua';
const partes = new Intl.DateTimeFormat('en-GB', {
timeZone: zonaHoraria,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hourCycle: 'h23'
}).formatToParts(ahora);
const valor = (tipo: Intl.DateTimeFormatPartTypes) =>
Number(partes.find((p) => p.type === tipo)?.value);
const minuto = valor('hour') * 60 + valor('minute');
const diaAnterior = franja.inicio > franja.fin && minuto < minutosDeHora(franja.fin);
return new Date(Date.UTC(valor('year'), valor('month') - 1, valor('day') - Number(diaAnterior)))
.toISOString()
.slice(0, 10);
}
export function validarProgramacion(valor: unknown): string | null {
if (!valor || typeof valor !== 'object') return 'La programación no es válida.';
const { zonaHoraria, franjas } = valor as ProgramacionJukebox;
if (typeof zonaHoraria !== 'string' || zonaHoraria.length > 80)
return 'Elige una zona horaria válida.';
try {
new Intl.DateTimeFormat('es', { timeZone: zonaHoraria }).format();
} catch {
return 'Elige una zona horaria válida.';
}
if (!Array.isArray(franjas) || franjas.length > 48) return 'Puedes guardar hasta 48 franjas.';
const ids = new Set<string>();
const horas = /^([01]\d|2[0-3]):[0-5]\d$/;
const ocupados = new Array<string | undefined>(1440);
for (const [indice, franja] of franjas.entries()) {
if (
!franja ||
typeof franja.id !== 'string' ||
!franja.id ||
franja.id.length > 120 ||
ids.has(franja.id)
)
return 'Cada franja debe tener un identificador único.';
ids.add(franja.id);
const etiqueta = `Franja ${indice + 1}`;
if (!['secuencial', 'aleatoria'].includes(franja.orden) || typeof franja.catalogo !== 'boolean')
return `Revisa el modo de reproducción y el contenido de ${etiqueta}.`;
if (
typeof franja.inicio !== 'string' ||
typeof franja.fin !== 'string' ||
!horas.test(franja.inicio) ||
!horas.test(franja.fin)
)
return `Revisa las horas de «${etiqueta}».`;
if (
!Array.isArray(franja.contenidos) ||
franja.contenidos.length > 100 ||
franja.contenidos.some(
(f) =>
!f ||
!['lista', 'cancion'].includes(f.tipo) ||
typeof f.id !== 'string' ||
!f.id ||
f.id.length > 160
)
)
return `Revisa las listas y canciones de «${etiqueta}».`;
for (let minuto = 0; minuto < 1440; minuto++) {
if (!contieneMinuto(franja, minuto)) continue;
if (ocupados[minuto])
return `«${etiqueta}» se solapa con «${ocupados[minuto]}». Ajusta sus horas.`;
ocupados[minuto] = etiqueta;
}
}
return null;
}
/** Expande cada lista en su posición. Las repeticiones expresamente elegidas se conservan. */
export function cancionesDeFranja(
franja: FranjaJukebox,
listas: { id: string; canciones: string[] }[],
catalogo: string[] = []
): string[] {
if (franja.catalogo) return [...catalogo];
return franja.contenidos.flatMap((fuente) =>
fuente.tipo === 'cancion'
? [fuente.id]
: (listas.find((lista) => lista.id === fuente.id)?.canciones ?? [])
);
}
/** Descarta campos ajenos al modelo antes de persistir. */
export function sanearProgramacion(p: ProgramacionJukebox): ProgramacionJukebox {
return {
zonaHoraria: p.zonaHoraria,
franjas: p.franjas.map((f) => ({
id: f.id,
inicio: f.inicio,
fin: f.fin,
orden: f.orden,
catalogo: f.catalogo,
contenidos: f.contenidos.map((c) => ({ tipo: c.tipo, id: c.id }))
}))
};
}

@ -0,0 +1,8 @@
import { error, redirect } from '@sveltejs/kit';
import { usuarioTieneAccesoJukebox } from './suscripciones';
export async function exigirJukebox(locals: App.Locals, volverA: string) {
if (!locals.usuario) redirect(303, '/entrar?volverA=' + encodeURIComponent(volverA));
if (!(await usuarioTieneAccesoJukebox(locals.usuario.id)))
error(403, 'Tu suscripción no incluye el Juke-box.');
return locals.usuario.id;
}

@ -17,4 +17,5 @@ export * from './facturacion';
export * from './cuentas';
export * from './comercio';
export * from './interaccion';
export * from './jukebox';
export * from './relaciones';

@ -0,0 +1,44 @@
import { integer, jsonb, pgTable, text, check, uniqueIndex } from 'drizzle-orm/pg-core';
import { sql } from 'drizzle-orm';
import { usuario } from './cuentas';
import { ahora } from './comun';
import type { FranjaJukebox } from '$lib/programacion-jukebox';
export const programacionJukebox = pgTable(
'programacion_jukebox',
{
usuarioId: text('usuario_id')
.primaryKey()
.references(() => usuario.id, { onDelete: 'cascade' }),
zonaHoraria: text('zona_horaria').notNull().default('Europe/Madrid'),
franjas: jsonb('franjas').notNull().default([]).$type<FranjaJukebox[]>(),
revision: integer('revision').notNull().default(1),
actualizadaEn: ahora('actualizada_en')
},
(t) => [
check('programacion_jukebox_franjas_array', sql`jsonb_typeof(${t.franjas}) = 'array'`),
check('programacion_jukebox_revision_positiva', sql`${t.revision} > 0`)
]
);
export const jukebox = pgTable(
'jukebox',
{
id: text('id').primaryKey(),
usuarioId: text('usuario_id')
.notNull()
.references(() => usuario.id, { onDelete: 'cascade' }),
numero: integer('numero').notNull(),
nombre: text('nombre').notNull().default(''),
descripcion: text('descripcion').notNull().default(''),
zonaHoraria: text('zona_horaria').notNull(),
franjas: jsonb('franjas').notNull().$type<FranjaJukebox[]>(),
revision: integer('revision').notNull().default(1),
actualizadaEn: ahora('actualizada_en')
},
(t) => [
uniqueIndex('jukebox_usuario_numero').on(t.usuarioId, t.numero),
check('jukebox_franjas_array', sql`jsonb_typeof(${t.franjas}) = 'array'`),
check('jukebox_revision_positiva', sql`${t.revision} > 0`)
]
);

@ -0,0 +1,79 @@
import { and, asc, eq, sql } from 'drizzle-orm';
import { db } from './db';
import { jukebox } from './db/schema';
import {
sanearProgramacion,
type JukeboxGuardado,
type ProgramacionJukebox
} from '$lib/programacion-jukebox';
function presentar(f: typeof jukebox.$inferSelect): JukeboxGuardado {
return {
id: f.id,
nombre: f.nombre || `Jukebox ${f.numero}`,
descripcion: f.descripcion,
numero: f.numero,
revision: f.revision,
programacion: { zonaHoraria: f.zonaHoraria, franjas: f.franjas }
};
}
export async function jukeboxesDeUsuario(usuarioId: string) {
return (
await db
.select()
.from(jukebox)
.where(eq(jukebox.usuarioId, usuarioId))
.orderBy(asc(jukebox.numero))
).map(presentar);
}
export async function leerJukebox(usuarioId: string, id: string) {
const [f] = await db
.select()
.from(jukebox)
.where(and(eq(jukebox.usuarioId, usuarioId), eq(jukebox.id, id)));
return f ? presentar(f) : null;
}
export async function guardarJukebox(
usuarioId: string,
id: string,
programacion: ProgramacionJukebox,
revision: number,
nombre: string,
descripcion: string
) {
const datos = sanearProgramacion(programacion);
if (revision === 0)
return db.transaction(async (tx) => {
// Numeración por cuenta, también si se crean desde dos pestañas a la vez.
await tx.execute(sql`select pg_advisory_xact_lock(hashtext(${usuarioId}))`);
const [ultimo] = await tx
.select({ numero: sql<number>`coalesce(max(${jukebox.numero}),0)` })
.from(jukebox)
.where(eq(jukebox.usuarioId, usuarioId));
const [creado] = await tx
.insert(jukebox)
.values({ id, usuarioId, nombre, descripcion, numero: Number(ultimo.numero) + 1, ...datos })
.onConflictDoNothing()
.returning();
return creado ? presentar(creado) : null;
});
const [guardado] = await db
.update(jukebox)
.set({ ...datos, nombre, descripcion, revision: revision + 1, actualizadaEn: new Date() })
.where(
and(eq(jukebox.usuarioId, usuarioId), eq(jukebox.id, id), eq(jukebox.revision, revision))
)
.returning();
return guardado ? presentar(guardado) : null;
}
export async function eliminarJukebox(usuarioId: string, id: string, revision: number) {
return (
(
await db
.delete(jukebox)
.where(
and(eq(jukebox.usuarioId, usuarioId), eq(jukebox.id, id), eq(jukebox.revision, revision))
)
.returning({ id: jukebox.id })
).length > 0
);
}

@ -0,0 +1,43 @@
import { eq, sql } from 'drizzle-orm';
import { db } from './db';
import { programacionJukebox } from './db/schema';
import { PROGRAMACION_VACIA, type ProgramacionJukebox } from '$lib/programacion-jukebox';
export async function leerProgramacion(usuarioId: string) {
const [guardada] = await db
.select()
.from(programacionJukebox)
.where(eq(programacionJukebox.usuarioId, usuarioId));
return {
programacion: guardada
? { zonaHoraria: guardada.zonaHoraria, franjas: guardada.franjas }
: structuredClone(PROGRAMACION_VACIA),
revision: guardada?.revision ?? 0
};
}
/** Evita que otra pestaña sobrescriba silenciosamente una edición más reciente. */
export async function guardarProgramacion(
usuarioId: string,
programa: ProgramacionJukebox,
revision: number
): Promise<number | undefined> {
// Solo los campos editables: el propietario procede siempre de la sesión.
const datos = { zonaHoraria: programa.zonaHoraria, franjas: programa.franjas };
if (revision === 0) {
const [creada] = await db
.insert(programacionJukebox)
.values({ usuarioId, ...datos })
.onConflictDoNothing()
.returning({ revision: programacionJukebox.revision });
return creada?.revision;
}
const [guardada] = await db
.update(programacionJukebox)
.set({ ...datos, revision: revision + 1, actualizadaEn: new Date() })
.where(
sql`${programacionJukebox.usuarioId} = ${usuarioId} and ${programacionJukebox.revision} = ${revision}`
)
.returning({ revision: programacionJukebox.revision });
return guardada?.revision;
}

@ -168,7 +168,8 @@
list-style: none;
width: 100%;
max-width: 72rem;
margin: 0 auto;
margin-block: 0;
margin-inline: auto;
padding: 0 var(--sp-6);
overflow-x: auto;
}
@ -197,11 +198,12 @@
flex: 1;
width: 100%;
max-width: 72rem;
margin: 0 auto;
margin-block: 0;
margin-inline: auto;
padding: var(--sp-6);
}
@media (max-width: 40rem) {
@media (max-width: 42rem) {
.barra {
padding: var(--sp-4);
}

@ -146,7 +146,7 @@
/* El desglose, detrás del nombre: es lo que explica el número de al lado. */
.detalle {
display: block;
margin-top: 0.15rem;
margin-top: var(--sp-1);
font-size: var(--fs-xs);
font-weight: 400;
}
@ -163,7 +163,7 @@
}
.unidad {
margin-left: 0.4rem;
margin-left: var(--sp-2);
font-size: var(--fs-xs);
color: var(--c-text-muted);
}
@ -176,11 +176,11 @@
}
.cuales {
margin-left: 0.6rem;
margin-left: var(--sp-3);
font-size: var(--fs-sm);
}
@media (max-width: 44rem) {
@media (max-width: 48rem) {
tbody th {
width: auto;
}

@ -82,7 +82,7 @@
.marca {
display: flex;
align-items: center;
min-height: 2.75rem;
min-height: var(--control-height);
}
.marca input {
width: auto;

@ -190,7 +190,7 @@
.guardado {
color: var(--c-dato);
}
@media (max-width: 40rem) {
@media (max-width: 42rem) {
.rejilla {
grid-template-columns: 1fr;
}

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save

Powered by TurnKey Linux.