Refine music and artist UI, preserve ratings and fix Jukebox

codex/estilos-web-publica
dev 3 weeks ago
parent 1e24c87043
commit 30c29b38c5

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

@ -0,0 +1,982 @@
[
{
"background": "rgba(0, 0, 0, 0)",
"clase": "marca svelte-w5uecg",
"color": "oklch(0.96 0.012 255)",
"font": "Newsreader, \"Iowan Old Style\", Georgia, serif",
"height": 108,
"line": "36px",
"padding": "24px 24px 16px",
"size": "24px",
"tag": "A",
"text": "Senza PauraAdministración",
"weight": "600",
"width": 264
},
{
"background": "rgba(0, 0, 0, 0)",
"clase": "svelte-w5uecg",
"color": "oklch(0.96 0.012 255)",
"font": "Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 24,
"line": "24px",
"padding": "0px",
"size": "16px",
"tag": "SMALL",
"text": "Administración",
"weight": "500",
"width": 127.109375
},
{
"background": "rgba(0, 0, 0, 0)",
"clase": "inicio svelte-w5uecg",
"color": "oklch(0.96 0.012 255)",
"font": "Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 34.59375,
"line": "26.6px",
"padding": "4px 12px",
"size": "19px",
"tag": "A",
"text": "⌂ Inicio",
"weight": "400",
"width": 232
},
{
"background": "oklch(0.26 0.05 264)",
"clase": "svelte-w5uecg activo",
"color": "oklch(0.8 0.145 84)",
"font": "Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 34.59375,
"line": "26.6px",
"padding": "4px 12px",
"size": "19px",
"tag": "A",
"text": "Canciones",
"weight": "600",
"width": 232
},
{
"background": "rgba(0, 0, 0, 0)",
"clase": "svelte-w5uecg",
"color": "oklch(0.96 0.012 255)",
"font": "Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 34.59375,
"line": "26.6px",
"padding": "4px 12px",
"size": "19px",
"tag": "A",
"text": "Lanzamientos",
"weight": "400",
"width": 232
},
{
"background": "rgba(0, 0, 0, 0)",
"clase": "svelte-w5uecg",
"color": "oklch(0.96 0.012 255)",
"font": "Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 34.59375,
"line": "26.6px",
"padding": "4px 12px",
"size": "19px",
"tag": "A",
"text": "Personas e identidades",
"weight": "400",
"width": 232
},
{
"background": "rgba(0, 0, 0, 0)",
"clase": "svelte-w5uecg",
"color": "oklch(0.96 0.012 255)",
"font": "Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 34.59375,
"line": "26.6px",
"padding": "4px 12px",
"size": "19px",
"tag": "A",
"text": "Estilos del catálogo",
"weight": "400",
"width": 232
},
{
"background": "rgba(0, 0, 0, 0)",
"clase": "svelte-w5uecg",
"color": "oklch(0.96 0.012 255)",
"font": "Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 34.59375,
"line": "26.6px",
"padding": "4px 12px",
"size": "19px",
"tag": "A",
"text": "Etiquetas",
"weight": "400",
"width": 232
},
{
"background": "rgba(0, 0, 0, 0)",
"clase": "svelte-w5uecg",
"color": "oklch(0.96 0.012 255)",
"font": "Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 34.59375,
"line": "26.6px",
"padding": "4px 12px",
"size": "19px",
"tag": "A",
"text": "Blog",
"weight": "400",
"width": 232
},
{
"background": "rgba(0, 0, 0, 0)",
"clase": "svelte-w5uecg",
"color": "oklch(0.96 0.012 255)",
"font": "Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 34.59375,
"line": "26.6px",
"padding": "4px 12px",
"size": "19px",
"tag": "A",
"text": "Páginas",
"weight": "400",
"width": 232
},
{
"background": "rgba(0, 0, 0, 0)",
"clase": "svelte-w5uecg",
"color": "oklch(0.96 0.012 255)",
"font": "Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 34.59375,
"line": "26.6px",
"padding": "4px 12px",
"size": "19px",
"tag": "A",
"text": "Textos legales",
"weight": "400",
"width": 232
},
{
"background": "rgba(0, 0, 0, 0)",
"clase": "svelte-w5uecg",
"color": "oklch(0.96 0.012 255)",
"font": "Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 34.59375,
"line": "26.6px",
"padding": "4px 12px",
"size": "19px",
"tag": "A",
"text": "Capítulos",
"weight": "400",
"width": 232
},
{
"background": "rgba(0, 0, 0, 0)",
"clase": "svelte-w5uecg",
"color": "oklch(0.96 0.012 255)",
"font": "Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 34.59375,
"line": "26.6px",
"padding": "4px 12px",
"size": "19px",
"tag": "A",
"text": "Géneros musicales",
"weight": "400",
"width": 232
},
{
"background": "rgba(0, 0, 0, 0)",
"clase": "svelte-w5uecg",
"color": "oklch(0.96 0.012 255)",
"font": "Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 34.59375,
"line": "26.6px",
"padding": "4px 12px",
"size": "19px",
"tag": "A",
"text": "Glosario",
"weight": "400",
"width": 232
},
{
"background": "rgba(0, 0, 0, 0)",
"clase": "svelte-w5uecg",
"color": "oklch(0.96 0.012 255)",
"font": "Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 34.59375,
"line": "26.6px",
"padding": "4px 12px",
"size": "19px",
"tag": "A",
"text": "Prompts",
"weight": "400",
"width": 232
},
{
"background": "rgba(0, 0, 0, 0)",
"clase": "svelte-w5uecg",
"color": "oklch(0.96 0.012 255)",
"font": "Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 34.59375,
"line": "26.6px",
"padding": "4px 12px",
"size": "19px",
"tag": "A",
"text": "Pedidos",
"weight": "400",
"width": 232
},
{
"background": "rgba(0, 0, 0, 0)",
"clase": "svelte-w5uecg",
"color": "oklch(0.96 0.012 255)",
"font": "Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 34.59375,
"line": "26.6px",
"padding": "4px 12px",
"size": "19px",
"tag": "A",
"text": "Cuentas",
"weight": "400",
"width": 232
},
{
"background": "rgba(0, 0, 0, 0)",
"clase": "svelte-w5uecg",
"color": "oklch(0.96 0.012 255)",
"font": "Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 34.59375,
"line": "26.6px",
"padding": "4px 12px",
"size": "19px",
"tag": "A",
"text": "Suscripciones",
"weight": "400",
"width": 232
},
{
"background": "rgba(0, 0, 0, 0)",
"clase": "svelte-w5uecg",
"color": "oklch(0.96 0.012 255)",
"font": "Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 34.59375,
"line": "26.6px",
"padding": "4px 12px",
"size": "19px",
"tag": "A",
"text": "Medios y procesamiento",
"weight": "400",
"width": 232
},
{
"background": "rgba(0, 0, 0, 0)",
"clase": "svelte-w5uecg",
"color": "oklch(0.96 0.012 255)",
"font": "Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 34.59375,
"line": "26.6px",
"padding": "4px 12px",
"size": "19px",
"tag": "A",
"text": "Modelos de IA",
"weight": "400",
"width": 232
},
{
"background": "rgba(0, 0, 0, 0)",
"clase": "svelte-w5uecg",
"color": "oklch(0.96 0.012 255)",
"font": "Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 34.59375,
"line": "26.6px",
"padding": "4px 12px",
"size": "19px",
"tag": "A",
"text": "Vocabularios",
"weight": "400",
"width": 232
},
{
"background": "rgba(0, 0, 0, 0)",
"clase": "svelte-w5uecg",
"color": "oklch(0.96 0.012 255)",
"font": "Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 34.59375,
"line": "26.6px",
"padding": "4px 12px",
"size": "19px",
"tag": "A",
"text": "Redirecciones",
"weight": "400",
"width": 232
},
{
"background": "rgba(0, 0, 0, 0)",
"clase": "svelte-w5uecg",
"color": "oklch(0.96 0.012 255)",
"font": "Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 34.59375,
"line": "26.6px",
"padding": "4px 12px",
"size": "19px",
"tag": "A",
"text": "Actividad",
"weight": "400",
"width": 232
},
{
"background": "rgba(0, 0, 0, 0)",
"clase": "oculto svelte-w5uecg",
"color": "oklch(0.96 0.012 255)",
"font": "Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 1,
"line": "24px",
"padding": "0px",
"size": "16px",
"tag": "LABEL",
"text": "Buscar en Estudio",
"weight": "600",
"width": 1
},
{
"background": "rgba(0, 0, 0, 0)",
"clase": "svelte-w5uecg",
"color": "oklch(0.96 0.012 255)",
"font": "Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 48,
"line": "24px",
"padding": "12px 16px",
"size": "16px",
"tag": "INPUT",
"text": "Buscar en Estudio…",
"weight": "400",
"width": 220
},
{
"background": "rgba(0, 0, 0, 0)",
"clase": "svelte-w5uecg",
"color": "oklch(0.74 0.022 258)",
"font": "Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 52,
"line": "36px",
"padding": "8px",
"size": "24px",
"tag": "BUTTON",
"text": "Buscar",
"weight": "400",
"width": 29.84375
},
{
"background": "rgba(0, 0, 0, 0)",
"clase": "ver-sitio svelte-w5uecg",
"color": "oklch(0.96 0.012 255)",
"font": "Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 24,
"line": "24px",
"padding": "0px",
"size": "16px",
"tag": "A",
"text": "Ver sitio ↗",
"weight": "400",
"width": 71.046875
},
{
"background": "rgba(0, 0, 0, 0)",
"clase": "svelte-w5uecg",
"color": "oklch(0.8 0.145 84)",
"font": "Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 48,
"line": "24px",
"padding": "12px",
"size": "16px",
"tag": "A",
"text": "Canción",
"weight": "400",
"width": 156
},
{
"background": "rgba(0, 0, 0, 0)",
"clase": "svelte-w5uecg",
"color": "oklch(0.8 0.145 84)",
"font": "Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 48,
"line": "24px",
"padding": "12px",
"size": "16px",
"tag": "A",
"text": "Lanzamiento",
"weight": "400",
"width": 156
},
{
"background": "rgba(0, 0, 0, 0)",
"clase": "svelte-w5uecg",
"color": "oklch(0.8 0.145 84)",
"font": "Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 72,
"line": "24px",
"padding": "12px",
"size": "16px",
"tag": "A",
"text": "Persona o identidad",
"weight": "400",
"width": 156
},
{
"background": "rgba(0, 0, 0, 0)",
"clase": "svelte-w5uecg",
"color": "oklch(0.8 0.145 84)",
"font": "Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 48,
"line": "24px",
"padding": "12px",
"size": "16px",
"tag": "A",
"text": "Entrada",
"weight": "400",
"width": 156
},
{
"background": "rgba(0, 0, 0, 0)",
"clase": "svelte-w5uecg",
"color": "oklch(0.74 0.022 258)",
"font": "Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 24,
"line": "24px",
"padding": "0px",
"size": "16px",
"tag": "SMALL",
"text": "estudio@example.test",
"weight": "400",
"width": 159.9375
},
{
"background": "oklch(0.21 0.046 264)",
"clase": "boton pequeno svelte-w5uecg",
"color": "oklch(0.96 0.012 255)",
"font": "Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 42,
"line": "24px",
"padding": "8px 12px",
"size": "16px",
"tag": "BUTTON",
"text": "Cerrar sesión",
"weight": "600",
"width": 124.75
},
{
"background": "rgba(0, 0, 0, 0)",
"clase": "svelte-53yoji",
"color": "oklch(0.8 0.145 84)",
"font": "Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 24,
"line": "24px",
"padding": "0px",
"size": "16px",
"tag": "A",
"text": "Canciones",
"weight": "400",
"width": 76.34375
},
{
"background": "rgba(0, 0, 0, 0)",
"clase": "svelte-53yoji",
"color": "oklch(0.96 0.012 255)",
"font": "Newsreader, \"Iowan Old Style\", Georgia, serif",
"height": 51,
"line": "51px",
"padding": "0px",
"size": "34px",
"tag": "H1",
"text": "Canción 2e6ccade",
"weight": "600",
"width": 240.84375
},
{
"background": "oklch(0.21 0.046 264)",
"clase": "boton svelte-53yoji",
"color": "oklch(0.96 0.012 255)",
"font": "Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 50,
"line": "24px",
"padding": "12px 16px",
"size": "16px",
"tag": "BUTTON",
"text": "Guardar borrador",
"weight": "600",
"width": 161.796875
},
{
"background": "oklch(0.21 0.046 264)",
"clase": "boton svelte-53yoji",
"color": "oklch(0.96 0.012 255)",
"font": "Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 50,
"line": "24px",
"padding": "12px 16px",
"size": "16px",
"tag": "A",
"text": "Vista previa ↗",
"weight": "600",
"width": 134.9375
},
{
"background": "oklch(0.8 0.145 84)",
"clase": "boton primario svelte-53yoji",
"color": "oklch(0.16 0.042 264)",
"font": "Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 50,
"line": "24px",
"padding": "12px 16px",
"size": "16px",
"tag": "BUTTON",
"text": "Revisar y publicar",
"weight": "600",
"width": 164.453125
},
{
"background": "rgba(0, 0, 0, 0)",
"clase": "svelte-53yoji",
"color": "oklch(0.8 0.145 84)",
"font": "Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 58,
"line": "24px",
"padding": "16px 4px",
"size": "16px",
"tag": "A",
"text": "Datos y contenido",
"weight": "600",
"width": 140.75
},
{
"background": "rgba(0, 0, 0, 0)",
"clase": "svelte-53yoji",
"color": "oklch(0.74 0.022 258)",
"font": "Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 58,
"line": "24px",
"padding": "16px 4px",
"size": "16px",
"tag": "A",
"text": "Grabaciones",
"weight": "400",
"width": 99.375
},
{
"background": "rgba(0, 0, 0, 0)",
"clase": "svelte-53yoji",
"color": "oklch(0.74 0.022 258)",
"font": "Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 58,
"line": "24px",
"padding": "16px 4px",
"size": "16px",
"tag": "A",
"text": "Visibilidad y acceso",
"weight": "400",
"width": 146.59375
},
{
"background": "rgba(0, 0, 0, 0)",
"clase": "svelte-53yoji",
"color": "oklch(0.74 0.022 258)",
"font": "Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 58,
"line": "24px",
"padding": "16px 4px",
"size": "16px",
"tag": "A",
"text": "Resumen y usos",
"weight": "400",
"width": 126.125
},
{
"background": "rgba(0, 0, 0, 0)",
"clase": "svelte-53yoji",
"color": "oklch(0.74 0.022 258)",
"font": "Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 58,
"line": "24px",
"padding": "16px 4px",
"size": "16px",
"tag": "A",
"text": "Actividad",
"weight": "400",
"width": 74.109375
},
{
"background": "rgba(0, 0, 0, 0)",
"clase": "svelte-53yoji",
"color": "oklch(0.96 0.012 255)",
"font": "Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 36,
"line": "36px",
"padding": "0px",
"size": "24px",
"tag": "H2",
"text": "Datos de la ficha",
"weight": "700",
"width": 551
},
{
"background": "rgba(0, 0, 0, 0)",
"clase": "ancho svelte-53yoji",
"color": "oklch(0.96 0.012 255)",
"font": "Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 82,
"line": "24px",
"padding": "0px",
"size": "16px",
"tag": "LABEL",
"text": "Título",
"weight": "600",
"width": 551
},
{
"background": "oklch(0.21 0.046 264)",
"clase": "svelte-53yoji",
"color": "oklch(0.96 0.012 255)",
"font": "Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 50,
"line": "24px",
"padding": "12px 16px",
"size": "16px",
"tag": "INPUT",
"text": "",
"weight": "600",
"width": 551
},
{
"background": "rgba(0, 0, 0, 0)",
"clase": "svelte-ti6721",
"color": "oklch(0.96 0.012 255)",
"font": "Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 189.953125,
"line": "24px",
"padding": "0px",
"size": "16px",
"tag": "LABEL",
"text": "Descripción breve",
"weight": "600",
"width": 551
},
{
"background": "rgba(0, 0, 0, 0)",
"clase": "svelte-ti6721",
"color": "oklch(0.96 0.012 255)",
"font": "Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 157,
"line": "24px",
"padding": "0px",
"size": "16px",
"tag": "LABEL",
"text": "Plantilla Detalle de canción Composición visual controlada para este tipo de con",
"weight": "600",
"width": 263.5
},
{
"background": "oklch(0.21 0.046 264)",
"clase": "svelte-ti6721",
"color": "oklch(0.96 0.012 255)",
"font": "Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 53,
"line": "normal",
"padding": "12px 16px",
"size": "16px",
"tag": "SELECT",
"text": "Detalle de canción",
"weight": "600",
"width": 263.5
},
{
"background": "rgba(0, 0, 0, 0)",
"clase": "svelte-ti6721",
"color": "oklch(0.74 0.022 258)",
"font": "Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 56,
"line": "24px",
"padding": "0px",
"size": "16px",
"tag": "SMALL",
"text": "Composición visual controlada para este tipo de contenido.",
"weight": "400",
"width": 263.5
},
{
"background": "rgba(0, 0, 0, 0)",
"clase": "svelte-ti6721",
"color": "oklch(0.96 0.012 255)",
"font": "Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 157,
"line": "24px",
"padding": "0px",
"size": "16px",
"tag": "LABEL",
"text": "Hero AutomáticoVisibleOculto Automático usa una cabecera amplia solo cuando hay ",
"weight": "600",
"width": 263.5
},
{
"background": "oklch(0.21 0.046 264)",
"clase": "svelte-ti6721",
"color": "oklch(0.96 0.012 255)",
"font": "Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 45,
"line": "normal",
"padding": "12px 16px",
"size": "16px",
"tag": "SELECT",
"text": "AutomáticoVisibleOculto",
"weight": "600",
"width": 263.5
},
{
"background": "rgba(0, 0, 0, 0)",
"clase": "svelte-ti6721",
"color": "oklch(0.74 0.022 258)",
"font": "Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 72,
"line": "24px",
"padding": "0px",
"size": "16px",
"tag": "SMALL",
"text": "Automático usa una cabecera amplia solo cuando hay contenido suficiente.",
"weight": "400",
"width": 263.5
},
{
"background": "oklch(0.21 0.046 264)",
"clase": "svelte-p8k2p9",
"color": "oklch(0.8 0.145 84)",
"font": "Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 44,
"line": "24px",
"padding": "8px 12px",
"size": "16px",
"tag": "BUTTON",
"text": "+ Texto",
"weight": "600",
"width": 86.0625
},
{
"background": "oklch(0.21 0.046 264)",
"clase": "svelte-p8k2p9",
"color": "oklch(0.8 0.145 84)",
"font": "Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 44,
"line": "24px",
"padding": "8px 12px",
"size": "16px",
"tag": "BUTTON",
"text": "+ Código",
"weight": "600",
"width": 98.84375
},
{
"background": "oklch(0.21 0.046 264)",
"clase": "svelte-p8k2p9",
"color": "oklch(0.8 0.145 84)",
"font": "Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 44,
"line": "24px",
"padding": "8px 12px",
"size": "16px",
"tag": "BUTTON",
"text": "+ Vídeo",
"weight": "600",
"width": 87.984375
},
{
"background": "oklch(0.21 0.046 264)",
"clase": "svelte-p8k2p9",
"color": "oklch(0.8 0.145 84)",
"font": "Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 44,
"line": "24px",
"padding": "8px 12px",
"size": "16px",
"tag": "BUTTON",
"text": "+ Imagen",
"weight": "600",
"width": 100.171875
},
{
"background": "oklch(0.21 0.046 264)",
"clase": "svelte-p8k2p9",
"color": "oklch(0.8 0.145 84)",
"font": "Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 44,
"line": "24px",
"padding": "8px 12px",
"size": "16px",
"tag": "BUTTON",
"text": "+ Sonido",
"weight": "600",
"width": 97.859375
},
{
"background": "rgba(0, 0, 0, 0)",
"clase": "svelte-ti6721",
"color": "oklch(0.96 0.012 255)",
"font": "Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 109,
"line": "24px",
"padding": "0px",
"size": "16px",
"tag": "LABEL",
"text": "Grabación principal Seleccionar…Canción 2e6ccadeMedios 06b1475cOtro audio 07be4b",
"weight": "600",
"width": 551
},
{
"background": "oklch(0.21 0.046 264)",
"clase": "svelte-ti6721",
"color": "oklch(0.96 0.012 255)",
"font": "Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 45,
"line": "normal",
"padding": "12px 16px",
"size": "16px",
"tag": "SELECT",
"text": "Seleccionar…Canción 2e6ccadeMedios 06b1475cOtro audio 07be4b04Versión f9cf6de8",
"weight": "600",
"width": 551
},
{
"background": "rgba(0, 0, 0, 0)",
"clase": "svelte-ti6721",
"color": "oklch(0.74 0.022 258)",
"font": "Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 24,
"line": "24px",
"padding": "0px",
"size": "16px",
"tag": "SMALL",
"text": "La interpretación que presenta esta canción en la web.",
"weight": "400",
"width": 551
},
{
"background": "rgba(0, 0, 0, 0)",
"clase": "ancho svelte-53yoji",
"color": "oklch(0.96 0.012 255)",
"font": "Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 138,
"line": "24px",
"padding": "0px",
"size": "16px",
"tag": "LABEL",
"text": "Dirección en la webSe conserva al cambiar el título. Modificarla requiere manten",
"weight": "600",
"width": 551
},
{
"background": "oklch(0.21 0.046 264)",
"clase": "svelte-53yoji",
"color": "oklch(0.96 0.012 255)",
"font": "Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 50,
"line": "24px",
"padding": "12px 16px",
"size": "16px",
"tag": "INPUT",
"text": "",
"weight": "600",
"width": 551
},
{
"background": "rgba(0, 0, 0, 0)",
"clase": "svelte-53yoji",
"color": "oklch(0.74 0.022 258)",
"font": "Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 48,
"line": "24px",
"padding": "0px",
"size": "16px",
"tag": "SMALL",
"text": "Se conserva al cambiar el título. Modificarla requiere mantener los enlaces\n\t\t\t\t",
"weight": "400",
"width": 551
},
{
"background": "rgba(0, 0, 0, 0)",
"clase": "svelte-53yoji",
"color": "oklch(0.96 0.012 255)",
"font": "Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 36,
"line": "36px",
"padding": "0px",
"size": "24px",
"tag": "H2",
"text": "Antes de publicar",
"weight": "700",
"width": 230
},
{
"background": "rgba(0, 0, 0, 0)",
"clase": "svelte-53yoji",
"color": "oklch(0.96 0.012 255)",
"font": "Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 72,
"line": "36px",
"padding": "0px",
"size": "24px",
"tag": "H2",
"text": "Guardado y publicación",
"weight": "700",
"width": 230
},
{
"background": "rgba(0, 0, 0, 0)",
"clase": "svelte-53yoji",
"color": "oklch(0.74 0.022 258)",
"font": "Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 41,
"line": "24px",
"padding": "0px",
"size": "16px",
"tag": "SMALL",
"text": "Último guardado: 12/9/2026, 18:06:47",
"weight": "400",
"width": 202.59375
},
{
"background": "rgba(0, 0, 0, 0)",
"clase": "check svelte-53yoji",
"color": "oklch(0.96 0.012 255)",
"font": "Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 24,
"line": "24px",
"padding": "0px",
"size": "16px",
"tag": "LABEL",
"text": "Confirmo la retirada",
"weight": "600",
"width": 230
},
{
"background": "rgba(0, 0, 0, 0)",
"clase": "svelte-53yoji",
"color": "rgb(255, 255, 255)",
"font": "Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 17,
"line": "24px",
"padding": "0px",
"size": "16px",
"tag": "INPUT",
"text": "",
"weight": "600",
"width": 17
},
{
"background": "oklch(0.21 0.046 264)",
"clase": "boton peligro separado svelte-53yoji",
"color": "oklch(0.72 0.16 22)",
"font": "Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 50,
"line": "24px",
"padding": "12px 16px",
"size": "16px",
"tag": "BUTTON",
"text": "Retirar publicación",
"weight": "600",
"width": 172.234375
}
]

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 190 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

@ -0,0 +1,576 @@
[
{
"background": "oklch(0.52 0.125 74)",
"clase": "saltar svelte-12qhfyh",
"color": "oklch(0.24 0.055 264)",
"font": "Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 42.390625,
"line": "26.4px",
"padding": "8px 16px",
"size": "16px",
"tag": "A",
"text": "Saltar al contenido",
"weight": "400",
"width": 163.3125
},
{
"background": "rgba(0, 0, 0, 0)",
"clase": "marca svelte-1ss0jhs",
"color": "oklch(0.24 0.055 264)",
"font": "Newsreader, \"Iowan Old Style\", Georgia, serif",
"height": 38,
"line": "39.1px",
"padding": "0px",
"size": "34px",
"tag": "A",
"text": "Senza Paura",
"weight": "600",
"width": 226.3125
},
{
"background": "rgba(0, 0, 0, 0)",
"clase": "svelte-1ss0jhs",
"color": "oklch(0.24 0.055 264)",
"font": "Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 26,
"line": "26.4px",
"padding": "4px 0px",
"size": "16px",
"tag": "A",
"text": "Música",
"weight": "400",
"width": 51.328125
},
{
"background": "rgba(0, 0, 0, 0)",
"clase": "svelte-1ss0jhs",
"color": "oklch(0.24 0.055 264)",
"font": "Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 26,
"line": "26.4px",
"padding": "4px 0px",
"size": "16px",
"tag": "A",
"text": "Músicos y grupos",
"weight": "400",
"width": 124.203125
},
{
"background": "rgba(0, 0, 0, 0)",
"clase": "svelte-1ss0jhs",
"color": "oklch(0.24 0.055 264)",
"font": "Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 26,
"line": "26.4px",
"padding": "4px 0px",
"size": "16px",
"tag": "A",
"text": "Taller de letra",
"weight": "400",
"width": 93.625
},
{
"background": "rgba(0, 0, 0, 0)",
"clase": "svelte-1ss0jhs",
"color": "oklch(0.52 0.125 74)",
"font": "Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 26,
"line": "26.4px",
"padding": "4px 0px",
"size": "16px",
"tag": "A",
"text": "Blog",
"weight": "400",
"width": 32.796875
},
{
"background": "rgba(0, 0, 0, 0)",
"clase": "accion-icono jukebox svelte-1ss0jhs",
"color": "oklch(0.24 0.055 264)",
"font": "Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 44,
"line": "26.4px",
"padding": "0px",
"size": "16px",
"tag": "A",
"text": "Juke-box",
"weight": "400",
"width": 44
},
{
"background": "rgba(0, 0, 0, 0)",
"clase": "svelte-1ss0jhs",
"color": "oklch(0.24 0.055 264)",
"font": "Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 35.390625,
"line": "26.4px",
"padding": "4px 0px",
"size": "16px",
"tag": "A",
"text": "Entrar",
"weight": "400",
"width": 43.78125
},
{
"background": "rgba(0, 0, 0, 0)",
"clase": "accion-icono buscar-contenido svelte-1ss0jhs",
"color": "oklch(0.24 0.055 264)",
"font": "Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 44,
"line": "26.4px",
"padding": "0px",
"size": "16px",
"tag": "A",
"text": "Buscar en Senza Paura",
"weight": "400",
"width": 44
},
{
"background": "rgba(0, 0, 0, 0)",
"clase": "accion-icono carrito svelte-1ss0jhs",
"color": "oklch(0.24 0.055 264)",
"font": "Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 44,
"line": "26.4px",
"padding": "0px",
"size": "16px",
"tag": "A",
"text": "Carrito",
"weight": "400",
"width": 44
},
{
"background": "rgba(0, 0, 0, 0)",
"clase": "boton svelte-10d17h5",
"color": "oklch(0.48 0.038 264)",
"font": "Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 44,
"line": "26.4px",
"padding": "1px 6px",
"size": "16px",
"tag": "BUTTON",
"text": "Cambiar al tema oscuro",
"weight": "400",
"width": 44
},
{
"background": "rgba(0, 0, 0, 0)",
"clase": "svelte-1971vt9",
"color": "oklch(0.24 0.055 264)",
"font": "Newsreader, \"Iowan Old Style\", Georgia, serif",
"height": 52.796875,
"line": "52.8px",
"padding": "0px",
"size": "48px",
"tag": "H1",
"text": "Blog",
"weight": "400",
"width": 568.40625
},
{
"background": "rgba(0, 0, 0, 0)",
"clase": "svelte-1971vt9",
"color": "oklch(0.48 0.038 264)",
"font": "Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 15,
"line": "23.1px",
"padding": "0px",
"size": "14px",
"tag": "A",
"text": "Seguirlo por RSS",
"weight": "400",
"width": 106.84375
},
{
"background": "rgba(0, 0, 0, 0)",
"clase": "visually-hidden svelte-egko5e",
"color": "oklch(0.24 0.055 264)",
"font": "Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 1,
"line": "26.4px",
"padding": "0px",
"size": "16px",
"tag": "LABEL",
"text": "Buscar una entrada",
"weight": "400",
"width": 1
},
{
"background": "rgba(0, 0, 0, 0)",
"clase": "svelte-egko5e",
"color": "oklch(0.24 0.055 264)",
"font": "Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 48.75,
"line": "24.75px",
"padding": "12px 24px",
"size": "15px",
"tag": "INPUT",
"text": "Buscar por título o por lo que cuenta",
"weight": "400",
"width": 962.984375
},
{
"background": "rgba(0, 0, 0, 0)",
"clase": "vista svelte-egko5e oro",
"color": "oklch(0.24 0.055 264)",
"font": "Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 48.75,
"line": "24.75px",
"padding": "12px 24px",
"size": "15px",
"tag": "BUTTON",
"text": "Tarjetas",
"weight": "400",
"width": 100.890625
},
{
"background": "rgba(0, 0, 0, 0)",
"clase": "vista svelte-egko5e",
"color": "oklch(0.48 0.038 264)",
"font": "Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 48.75,
"line": "24.75px",
"padding": "12px 24px",
"size": "15px",
"tag": "BUTTON",
"text": "Tabla",
"weight": "400",
"width": 85.125
},
{
"background": "rgba(0, 0, 0, 0)",
"clase": "etiqueta svelte-q0tptl",
"color": "oklch(0.45 0.13 290)",
"font": "Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 28,
"line": "16.2px",
"padding": "4px 12px",
"size": "12px",
"tag": "BUTTON",
"text": "Oficio 2",
"weight": "400",
"width": 72.328125
},
{
"background": "rgba(0, 0, 0, 0)",
"clase": "etiqueta svelte-q0tptl",
"color": "oklch(0.45 0.13 20)",
"font": "Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 28,
"line": "16.2px",
"padding": "4px 12px",
"size": "12px",
"tag": "BUTTON",
"text": "Desamor 1",
"weight": "400",
"width": 90.234375
},
{
"background": "rgba(0, 0, 0, 0)",
"clase": "etiqueta svelte-q0tptl",
"color": "oklch(0.45 0.13 20)",
"font": "Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 28,
"line": "16.2px",
"padding": "4px 12px",
"size": "12px",
"tag": "BUTTON",
"text": "Estilos 1",
"weight": "400",
"width": 77.203125
},
{
"background": "oklch(0.915 0.016 264)",
"clase": "portada svelte-1971vt9",
"color": "oklch(0.24 0.055 264)",
"font": "Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 318.375,
"line": "26.4px",
"padding": "0px",
"size": "16px",
"tag": "A",
"text": "",
"weight": "400",
"width": 566
},
{
"background": "rgba(0, 0, 0, 0)",
"clase": "svelte-1971vt9",
"color": "oklch(0.24 0.055 264)",
"font": "Newsreader, \"Iowan Old Style\", Georgia, serif",
"height": 26.390625,
"line": "26.4px",
"padding": "0px",
"size": "24px",
"tag": "H2",
"text": "Escribir en un estilo que no es el tuyo",
"weight": "400",
"width": 534
},
{
"background": "rgba(0, 0, 0, 0)",
"clase": "svelte-1971vt9",
"color": "oklch(0.24 0.055 264)",
"font": "Newsreader, \"Iowan Old Style\", Georgia, serif",
"height": 24,
"line": "26.4px",
"padding": "0px",
"size": "24px",
"tag": "A",
"text": "Escribir en un estilo que no es el tuyo",
"weight": "400",
"width": 349.09375
},
{
"background": "rgba(0, 0, 0, 0)",
"clase": "etiqueta svelte-q0tptl",
"color": "oklch(0.45 0.13 20)",
"font": "Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 28,
"line": "16.2px",
"padding": "4px 12px",
"size": "12px",
"tag": "A",
"text": "Estilos",
"weight": "400",
"width": 62.171875
},
{
"background": "rgba(0, 0, 0, 0)",
"clase": "etiqueta svelte-q0tptl",
"color": "oklch(0.45 0.13 290)",
"font": "Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 28,
"line": "16.2px",
"padding": "4px 12px",
"size": "12px",
"tag": "A",
"text": "Oficio",
"weight": "400",
"width": 57.296875
},
{
"background": "oklch(0.915 0.016 264)",
"clase": "portada svelte-1971vt9",
"color": "oklch(0.24 0.055 264)",
"font": "Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 318.375,
"line": "26.4px",
"padding": "0px",
"size": "16px",
"tag": "A",
"text": "",
"weight": "400",
"width": 566
},
{
"background": "rgba(0, 0, 0, 0)",
"clase": "svelte-1971vt9",
"color": "oklch(0.24 0.055 264)",
"font": "Newsreader, \"Iowan Old Style\", Georgia, serif",
"height": 26.390625,
"line": "26.4px",
"padding": "0px",
"size": "24px",
"tag": "H2",
"text": "Lo que no cabe en una canción",
"weight": "400",
"width": 534
},
{
"background": "rgba(0, 0, 0, 0)",
"clase": "svelte-1971vt9",
"color": "oklch(0.24 0.055 264)",
"font": "Newsreader, \"Iowan Old Style\", Georgia, serif",
"height": 24,
"line": "26.4px",
"padding": "0px",
"size": "24px",
"tag": "A",
"text": "Lo que no cabe en una canción",
"weight": "400",
"width": 289.71875
},
{
"background": "rgba(0, 0, 0, 0)",
"clase": "etiqueta svelte-q0tptl",
"color": "oklch(0.45 0.13 20)",
"font": "Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 28,
"line": "16.2px",
"padding": "4px 12px",
"size": "12px",
"tag": "A",
"text": "Desamor",
"weight": "400",
"width": 75.203125
},
{
"background": "rgba(0, 0, 0, 0)",
"clase": "etiqueta svelte-q0tptl",
"color": "oklch(0.45 0.13 290)",
"font": "Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 28,
"line": "16.2px",
"padding": "4px 12px",
"size": "12px",
"tag": "A",
"text": "Oficio",
"weight": "400",
"width": 57.296875
},
{
"background": "rgba(0, 0, 0, 0)",
"clase": "emblema svelte-34w38f",
"color": "oklch(0.24 0.055 264)",
"font": "Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 52,
"line": "23.1px",
"padding": "0px",
"size": "14px",
"tag": "A",
"text": "Senza Paura",
"weight": "400",
"width": 52
},
{
"background": "rgba(0, 0, 0, 0)",
"clase": "svelte-34w38f",
"color": "oklch(0.24 0.055 264)",
"font": "Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 15,
"line": "23.1px",
"padding": "0px",
"size": "14px",
"tag": "A",
"text": "Sobre mí",
"weight": "400",
"width": 55.640625
},
{
"background": "rgba(0, 0, 0, 0)",
"clase": "svelte-34w38f",
"color": "oklch(0.24 0.055 264)",
"font": "Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 15,
"line": "23.1px",
"padding": "0px",
"size": "14px",
"tag": "A",
"text": "hola@senzapaura.es",
"weight": "400",
"width": 132
},
{
"background": "rgba(0, 0, 0, 0)",
"clase": "svelte-34w38f",
"color": "oklch(0.24 0.055 264)",
"font": "Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 15,
"line": "23.1px",
"padding": "0px",
"size": "14px",
"tag": "A",
"text": "booking@senzapaura.es",
"weight": "400",
"width": 155.265625
},
{
"background": "rgba(0, 0, 0, 0)",
"clase": "svelte-34w38f",
"color": "oklch(0.24 0.055 264)",
"font": "Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 15,
"line": "23.1px",
"padding": "0px",
"size": "14px",
"tag": "A",
"text": "Formulario de contacto",
"weight": "400",
"width": 143.09375
},
{
"background": "rgba(0, 0, 0, 0)",
"clase": "svelte-34w38f",
"color": "oklch(0.24 0.055 264)",
"font": "Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 15,
"line": "23.1px",
"padding": "0px",
"size": "14px",
"tag": "A",
"text": "Suscripciones",
"weight": "400",
"width": 88.53125
},
{
"background": "rgba(0, 0, 0, 0)",
"clase": "svelte-34w38f",
"color": "oklch(0.48 0.038 264)",
"font": "Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 14,
"line": "19.8px",
"padding": "0px",
"size": "12px",
"tag": "A",
"text": "Información legal",
"weight": "400",
"width": 91.171875
},
{
"background": "rgba(0, 0, 0, 0)",
"clase": "svelte-34w38f",
"color": "oklch(0.48 0.038 264)",
"font": "Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 14,
"line": "19.8px",
"padding": "0px",
"size": "12px",
"tag": "A",
"text": "Condiciones de uso",
"weight": "400",
"width": 105.15625
},
{
"background": "rgba(0, 0, 0, 0)",
"clase": "svelte-34w38f",
"color": "oklch(0.48 0.038 264)",
"font": "Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 14,
"line": "19.8px",
"padding": "0px",
"size": "12px",
"tag": "A",
"text": "Política de privacidad",
"weight": "400",
"width": 112.578125
},
{
"background": "rgba(0, 0, 0, 0)",
"clase": "svelte-34w38f",
"color": "oklch(0.48 0.038 264)",
"font": "Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 14,
"line": "19.8px",
"padding": "0px",
"size": "12px",
"tag": "A",
"text": "Política de cookies",
"weight": "400",
"width": 99
},
{
"background": "rgba(0, 0, 0, 0)",
"clase": "svelte-34w38f",
"color": "oklch(0.48 0.038 264)",
"font": "Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 14,
"line": "19.8px",
"padding": "0px",
"size": "12px",
"tag": "A",
"text": "Condiciones de venta",
"weight": "400",
"width": 114.890625
}
]

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

@ -0,0 +1,744 @@
[
{
"background": "oklch(0.52 0.125 74)",
"clase": "saltar svelte-12qhfyh",
"color": "oklch(0.24 0.055 264)",
"font": "Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 42.390625,
"line": "26.4px",
"padding": "8px 16px",
"size": "16px",
"tag": "A",
"text": "Saltar al contenido",
"weight": "400",
"width": 163.3125
},
{
"background": "rgba(0, 0, 0, 0)",
"clase": "marca svelte-1ss0jhs",
"color": "oklch(0.24 0.055 264)",
"font": "Newsreader, \"Iowan Old Style\", Georgia, serif",
"height": 38,
"line": "39.1px",
"padding": "0px",
"size": "34px",
"tag": "A",
"text": "Senza Paura",
"weight": "600",
"width": 226.3125
},
{
"background": "rgba(0, 0, 0, 0)",
"clase": "svelte-1ss0jhs",
"color": "oklch(0.24 0.055 264)",
"font": "Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 26,
"line": "26.4px",
"padding": "4px 0px",
"size": "16px",
"tag": "A",
"text": "Música",
"weight": "400",
"width": 51.328125
},
{
"background": "rgba(0, 0, 0, 0)",
"clase": "svelte-1ss0jhs",
"color": "oklch(0.24 0.055 264)",
"font": "Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 26,
"line": "26.4px",
"padding": "4px 0px",
"size": "16px",
"tag": "A",
"text": "Músicos y grupos",
"weight": "400",
"width": 124.203125
},
{
"background": "rgba(0, 0, 0, 0)",
"clase": "svelte-1ss0jhs",
"color": "oklch(0.24 0.055 264)",
"font": "Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 26,
"line": "26.4px",
"padding": "4px 0px",
"size": "16px",
"tag": "A",
"text": "Taller de letra",
"weight": "400",
"width": 93.625
},
{
"background": "rgba(0, 0, 0, 0)",
"clase": "svelte-1ss0jhs",
"color": "oklch(0.24 0.055 264)",
"font": "Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 26,
"line": "26.4px",
"padding": "4px 0px",
"size": "16px",
"tag": "A",
"text": "Blog",
"weight": "400",
"width": 32.796875
},
{
"background": "rgba(0, 0, 0, 0)",
"clase": "accion-icono jukebox svelte-1ss0jhs",
"color": "oklch(0.24 0.055 264)",
"font": "Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 44,
"line": "26.4px",
"padding": "0px",
"size": "16px",
"tag": "A",
"text": "Juke-box",
"weight": "400",
"width": 44
},
{
"background": "rgba(0, 0, 0, 0)",
"clase": "svelte-1ss0jhs",
"color": "oklch(0.24 0.055 264)",
"font": "Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 35.390625,
"line": "26.4px",
"padding": "4px 0px",
"size": "16px",
"tag": "A",
"text": "Entrar",
"weight": "400",
"width": 43.78125
},
{
"background": "rgba(0, 0, 0, 0)",
"clase": "accion-icono buscar-contenido svelte-1ss0jhs",
"color": "oklch(0.24 0.055 264)",
"font": "Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 44,
"line": "26.4px",
"padding": "0px",
"size": "16px",
"tag": "A",
"text": "Buscar en Senza Paura",
"weight": "400",
"width": 44
},
{
"background": "rgba(0, 0, 0, 0)",
"clase": "accion-icono carrito svelte-1ss0jhs",
"color": "oklch(0.24 0.055 264)",
"font": "Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 44,
"line": "26.4px",
"padding": "0px",
"size": "16px",
"tag": "A",
"text": "Carrito",
"weight": "400",
"width": 44
},
{
"background": "rgba(0, 0, 0, 0)",
"clase": "boton svelte-10d17h5",
"color": "oklch(0.48 0.038 264)",
"font": "Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 44,
"line": "26.4px",
"padding": "1px 6px",
"size": "16px",
"tag": "BUTTON",
"text": "Cambiar al tema oscuro",
"weight": "400",
"width": 44
},
{
"background": "rgba(0, 0, 0, 0)",
"clase": "svelte-92u4vw",
"color": "oklch(0.48 0.038 264)",
"font": "Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 54.1875,
"line": "23.1px",
"padding": "4px 0px 4px 12px",
"size": "14px",
"tag": "A",
"text": "01 Lo que hace que una letra funcione",
"weight": "400",
"width": 220
},
{
"background": "rgba(0, 0, 0, 0)",
"clase": "svelte-92u4vw",
"color": "oklch(0.48 0.038 264)",
"font": "Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 31.09375,
"line": "23.1px",
"padding": "4px 0px 4px 12px",
"size": "14px",
"tag": "A",
"text": "02 La imagen concreta",
"weight": "400",
"width": 220
},
{
"background": "rgba(0, 0, 0, 0)",
"clase": "svelte-92u4vw",
"color": "oklch(0.48 0.038 264)",
"font": "Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 31.09375,
"line": "23.1px",
"padding": "4px 0px 4px 12px",
"size": "14px",
"tag": "A",
"text": "03 Métrica y acento",
"weight": "400",
"width": 220
},
{
"background": "rgba(0, 0, 0, 0)",
"clase": "svelte-92u4vw",
"color": "oklch(0.48 0.038 264)",
"font": "Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 31.09375,
"line": "23.1px",
"padding": "4px 0px 4px 12px",
"size": "14px",
"tag": "A",
"text": "04 La rima como consecuencia",
"weight": "400",
"width": 220
},
{
"background": "rgba(0, 0, 0, 0)",
"clase": "svelte-92u4vw",
"color": "oklch(0.48 0.038 264)",
"font": "Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 31.09375,
"line": "23.1px",
"padding": "4px 0px 4px 12px",
"size": "14px",
"tag": "A",
"text": "05 Estructura y giro",
"weight": "400",
"width": 220
},
{
"background": "rgba(0, 0, 0, 0)",
"clase": "svelte-92u4vw",
"color": "oklch(0.48 0.038 264)",
"font": "Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 31.09375,
"line": "23.1px",
"padding": "4px 0px 4px 12px",
"size": "14px",
"tag": "A",
"text": "06 El estribillo",
"weight": "400",
"width": 220
},
{
"background": "rgba(0, 0, 0, 0)",
"clase": "svelte-92u4vw",
"color": "oklch(0.48 0.038 264)",
"font": "Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 31.09375,
"line": "23.1px",
"padding": "4px 0px 4px 12px",
"size": "14px",
"tag": "A",
"text": "Fichas de género",
"weight": "400",
"width": 220
},
{
"background": "rgba(0, 0, 0, 0)",
"clase": "svelte-92u4vw",
"color": "oklch(0.48 0.038 264)",
"font": "Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 31.09375,
"line": "23.1px",
"padding": "4px 0px 4px 12px",
"size": "14px",
"tag": "A",
"text": "Glosario",
"weight": "400",
"width": 220
},
{
"background": "rgba(0, 0, 0, 0)",
"clase": "svelte-qodzbh",
"color": "oklch(0.24 0.055 264)",
"font": "Newsreader, \"Iowan Old Style\", Georgia, serif",
"height": 52.796875,
"line": "52.8px",
"padding": "0px",
"size": "48px",
"tag": "H1",
"text": "Taller de letras",
"weight": "400",
"width": 568.40625
},
{
"background": "rgba(0, 0, 0, 0)",
"clase": "svelte-qodzbh",
"color": "oklch(0.24 0.055 264)",
"font": "Newsreader, \"Iowan Old Style\", Georgia, serif",
"height": 37.390625,
"line": "37.4px",
"padding": "0px",
"size": "34px",
"tag": "H2",
"text": "Los principios",
"weight": "400",
"width": 912
},
{
"background": "rgba(0, 0, 0, 0)",
"clase": "svelte-qodzbh",
"color": "oklch(0.24 0.055 264)",
"font": "Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 99.6875,
"line": "26.4px",
"padding": "16px 0px",
"size": "16px",
"tag": "A",
"text": "01 Lo que hace que una letra funcione Una letra funciona cuando dice una sola co",
"weight": "400",
"width": 912
},
{
"background": "rgba(0, 0, 0, 0)",
"clase": "svelte-qodzbh",
"color": "oklch(0.24 0.055 264)",
"font": "Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 99.6875,
"line": "26.4px",
"padding": "16px 0px",
"size": "16px",
"tag": "A",
"text": "02 La imagen concreta Lo concreto emociona; lo abstracto solo informa de que hay",
"weight": "400",
"width": 912
},
{
"background": "rgba(0, 0, 0, 0)",
"clase": "svelte-qodzbh",
"color": "oklch(0.24 0.055 264)",
"font": "Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 122.78125,
"line": "26.4px",
"padding": "16px 0px",
"size": "16px",
"tag": "A",
"text": "03 Métrica y acento Escribes para una boca, no para una página: lo que no se pue",
"weight": "400",
"width": 912
},
{
"background": "rgba(0, 0, 0, 0)",
"clase": "svelte-qodzbh",
"color": "oklch(0.24 0.055 264)",
"font": "Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 99.6875,
"line": "26.4px",
"padding": "16px 0px",
"size": "16px",
"tag": "A",
"text": "04 La rima como consecuencia La rima confirma un verso que ya era bueno; no pued",
"weight": "400",
"width": 912
},
{
"background": "rgba(0, 0, 0, 0)",
"clase": "svelte-qodzbh",
"color": "oklch(0.24 0.055 264)",
"font": "Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 99.6875,
"line": "26.4px",
"padding": "16px 0px",
"size": "16px",
"tag": "A",
"text": "05 Estructura y giro Una canción es una promesa que se cumple de otra manera.",
"weight": "400",
"width": 912
},
{
"background": "rgba(0, 0, 0, 0)",
"clase": "svelte-qodzbh",
"color": "oklch(0.24 0.055 264)",
"font": "Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 99.6875,
"line": "26.4px",
"padding": "16px 0px",
"size": "16px",
"tag": "A",
"text": "06 El estribillo El estribillo es la frase que sobrevive cuando se olvida el res",
"weight": "400",
"width": 912
},
{
"background": "rgba(0, 0, 0, 0)",
"clase": "svelte-qodzbh",
"color": "oklch(0.24 0.055 264)",
"font": "Newsreader, \"Iowan Old Style\", Georgia, serif",
"height": 37.390625,
"line": "37.4px",
"padding": "0px",
"size": "34px",
"tag": "H2",
"text": "Fichas de género",
"weight": "400",
"width": 912
},
{
"background": "oklch(0.76 0.13 82)",
"clase": "btn btn--primary svelte-qodzbh",
"color": "oklch(0.24 0.055 264)",
"font": "Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 46.25,
"line": "20.25px",
"padding": "12px 24px",
"size": "15px",
"tag": "A",
"text": "Ver los géneros",
"weight": "400",
"width": 154.296875
},
{
"background": "rgba(0, 0, 0, 0)",
"clase": "emblema svelte-34w38f",
"color": "oklch(0.24 0.055 264)",
"font": "Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 52,
"line": "23.1px",
"padding": "0px",
"size": "14px",
"tag": "A",
"text": "Senza Paura",
"weight": "400",
"width": 52
},
{
"background": "rgba(0, 0, 0, 0)",
"clase": "svelte-34w38f",
"color": "oklch(0.24 0.055 264)",
"font": "Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 15,
"line": "23.1px",
"padding": "0px",
"size": "14px",
"tag": "A",
"text": "Sobre mí",
"weight": "400",
"width": 55.640625
},
{
"background": "rgba(0, 0, 0, 0)",
"clase": "svelte-34w38f",
"color": "oklch(0.24 0.055 264)",
"font": "Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 15,
"line": "23.1px",
"padding": "0px",
"size": "14px",
"tag": "A",
"text": "hola@senzapaura.es",
"weight": "400",
"width": 132
},
{
"background": "rgba(0, 0, 0, 0)",
"clase": "svelte-34w38f",
"color": "oklch(0.24 0.055 264)",
"font": "Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 15,
"line": "23.1px",
"padding": "0px",
"size": "14px",
"tag": "A",
"text": "booking@senzapaura.es",
"weight": "400",
"width": 155.265625
},
{
"background": "rgba(0, 0, 0, 0)",
"clase": "svelte-34w38f",
"color": "oklch(0.24 0.055 264)",
"font": "Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 15,
"line": "23.1px",
"padding": "0px",
"size": "14px",
"tag": "A",
"text": "Formulario de contacto",
"weight": "400",
"width": 143.09375
},
{
"background": "rgba(0, 0, 0, 0)",
"clase": "svelte-34w38f",
"color": "oklch(0.24 0.055 264)",
"font": "Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 15,
"line": "23.1px",
"padding": "0px",
"size": "14px",
"tag": "A",
"text": "Suscripciones",
"weight": "400",
"width": 88.53125
},
{
"background": "rgba(0, 0, 0, 0)",
"clase": "svelte-34w38f",
"color": "oklch(0.48 0.038 264)",
"font": "Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 14,
"line": "19.8px",
"padding": "0px",
"size": "12px",
"tag": "A",
"text": "Información legal",
"weight": "400",
"width": 91.171875
},
{
"background": "rgba(0, 0, 0, 0)",
"clase": "svelte-34w38f",
"color": "oklch(0.48 0.038 264)",
"font": "Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 14,
"line": "19.8px",
"padding": "0px",
"size": "12px",
"tag": "A",
"text": "Condiciones de uso",
"weight": "400",
"width": 105.15625
},
{
"background": "rgba(0, 0, 0, 0)",
"clase": "svelte-34w38f",
"color": "oklch(0.48 0.038 264)",
"font": "Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 14,
"line": "19.8px",
"padding": "0px",
"size": "12px",
"tag": "A",
"text": "Política de privacidad",
"weight": "400",
"width": 112.578125
},
{
"background": "rgba(0, 0, 0, 0)",
"clase": "svelte-34w38f",
"color": "oklch(0.48 0.038 264)",
"font": "Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 14,
"line": "19.8px",
"padding": "0px",
"size": "12px",
"tag": "A",
"text": "Política de cookies",
"weight": "400",
"width": 99
},
{
"background": "rgba(0, 0, 0, 0)",
"clase": "svelte-34w38f",
"color": "oklch(0.48 0.038 264)",
"font": "Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 14,
"line": "19.8px",
"padding": "0px",
"size": "12px",
"tag": "A",
"text": "Condiciones de venta",
"weight": "400",
"width": 114.890625
},
{
"background": "rgba(0, 0, 0, 0)",
"clase": "svelte-amof2w",
"color": "oklch(0.24 0.055 264)",
"font": "Newsreader, \"Iowan Old Style\", Georgia, serif",
"height": 26.390625,
"line": "26.4px",
"padding": "0px",
"size": "24px",
"tag": "H2",
"text": "Contacto",
"weight": "400",
"width": 416
},
{
"background": "rgba(0, 0, 0, 0)",
"clase": "svelte-amof2w",
"color": "oklch(0.48 0.038 264)",
"font": "Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 15,
"line": "21px",
"padding": "0px",
"size": "14px",
"tag": "A",
"text": "hola@senzapaura.es",
"weight": "400",
"width": 132
},
{
"background": "rgba(0, 0, 0, 0)",
"clase": "svelte-14m83vr",
"color": "oklch(0.24 0.055 264)",
"font": "Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 94.171875,
"line": "23.1px",
"padding": "0px",
"size": "14px",
"tag": "LABEL",
"text": "No rellenes este campo",
"weight": "600",
"width": 51.296875
},
{
"background": "oklch(0.98 0.008 264)",
"clase": "svelte-14m83vr",
"color": "oklch(0.24 0.055 264)",
"font": "Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 44.390625,
"line": "26.4px",
"padding": "8px 12px",
"size": "16px",
"tag": "INPUT",
"text": "",
"weight": "400",
"width": 26
},
{
"background": "rgba(0, 0, 0, 0)",
"clase": "svelte-14m83vr",
"color": "oklch(0.24 0.055 264)",
"font": "Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 23.09375,
"line": "23.1px",
"padding": "0px",
"size": "14px",
"tag": "LABEL",
"text": "Nombre",
"weight": "600",
"width": 416
},
{
"background": "oklch(0.98 0.008 264)",
"clase": "svelte-14m83vr",
"color": "oklch(0.24 0.055 264)",
"font": "Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 44.390625,
"line": "26.4px",
"padding": "8px 12px",
"size": "16px",
"tag": "INPUT",
"text": "",
"weight": "400",
"width": 416
},
{
"background": "rgba(0, 0, 0, 0)",
"clase": "svelte-14m83vr",
"color": "oklch(0.24 0.055 264)",
"font": "Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 23.09375,
"line": "23.1px",
"padding": "0px",
"size": "14px",
"tag": "LABEL",
"text": "Correo electrónico",
"weight": "600",
"width": 416
},
{
"background": "oklch(0.98 0.008 264)",
"clase": "svelte-14m83vr",
"color": "oklch(0.24 0.055 264)",
"font": "Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 44.390625,
"line": "26.4px",
"padding": "8px 12px",
"size": "16px",
"tag": "INPUT",
"text": "",
"weight": "400",
"width": 416
},
{
"background": "rgba(0, 0, 0, 0)",
"clase": "svelte-14m83vr",
"color": "oklch(0.24 0.055 264)",
"font": "Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 23.09375,
"line": "23.1px",
"padding": "0px",
"size": "14px",
"tag": "LABEL",
"text": "Motivo",
"weight": "600",
"width": 416
},
{
"background": "oklch(0.98 0.008 264)",
"clase": "svelte-14m83vr",
"color": "oklch(0.24 0.055 264)",
"font": "Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 44,
"line": "normal",
"padding": "8px 12px",
"size": "16px",
"tag": "SELECT",
"text": "Elige un motivoColaboración o encargoLicencia de una obraContratación de un dire",
"weight": "400",
"width": 416
},
{
"background": "rgba(0, 0, 0, 0)",
"clase": "svelte-14m83vr",
"color": "oklch(0.24 0.055 264)",
"font": "Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 23.09375,
"line": "23.1px",
"padding": "0px",
"size": "14px",
"tag": "LABEL",
"text": "Mensaje",
"weight": "600",
"width": 416
},
{
"background": "oklch(0.3 0.075 255)",
"clase": "btn btn--primary svelte-14m83vr",
"color": "oklch(0.98 0.005 255)",
"font": "Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 46.25,
"line": "20.25px",
"padding": "12px 24px",
"size": "15px",
"tag": "BUTTON",
"text": "Enviar mensaje",
"weight": "400",
"width": 154.59375
},
{
"background": "rgba(0, 0, 0, 0)",
"clase": "cerrar modal-close svelte-amof2w",
"color": "oklch(0.48 0.038 264)",
"font": "Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 44,
"line": "26.4px",
"padding": "0px",
"size": "16px",
"tag": "BUTTON",
"text": "Cerrar contacto",
"weight": "400",
"width": 44
}
]

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 206 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 220 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 205 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

@ -0,0 +1,336 @@
[
{
"buttons": [
{
"font": "500 14px / 20px Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 44,
"padding": "11px 16px",
"text": "Acción principal"
},
{
"font": "500 14px / 20px Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 44,
"padding": "11px 16px",
"text": "Acción secundaria"
},
{
"font": "500 14px / 20px Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 44,
"padding": "11px 16px",
"text": "No disponible"
},
{
"font": "500 14px / 20px Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 36,
"padding": "7px 12px",
"text": "Compacto"
},
{
"font": "500 14px / 20px Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 44,
"padding": "11px 16px",
"text": "Acción con una etiqueta extensa que debe adaptarse al espacio disponible"
}
],
"page": "/__diseno",
"w": 1280
},
{
"buttons": [
{
"font": "500 14px / 20px Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 44,
"text": "Acción principal"
},
{
"font": "500 14px / 20px Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 44,
"text": "Acción secundaria"
},
{
"font": "500 14px / 20px Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 44,
"text": "No disponible"
},
{
"font": "500 14px / 20px Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 36,
"text": "Compacto"
},
{
"font": "500 14px / 20px Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 64,
"text": "Acción con una etiqueta extensa que debe adaptarse al espacio disponible"
}
],
"overflow": false,
"page": "/__diseno",
"w": 390
},
{
"brokenImages": 0,
"fields": [
{
"height": 44,
"weight": "400"
},
{
"height": 44,
"weight": "400"
}
],
"overflow": false,
"page": "/medios",
"w": 390
},
{
"buttons": [
{
"font": "500 14px / 20px Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 44,
"text": "Acción principal"
},
{
"font": "500 14px / 20px Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 44,
"text": "Acción secundaria"
},
{
"font": "500 14px / 20px Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 44,
"text": "No disponible"
},
{
"font": "500 14px / 20px Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 36,
"text": "Compacto"
},
{
"font": "500 14px / 20px Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 44,
"text": "Acción con una etiqueta extensa que debe adaptarse al espacio disponible"
}
],
"headings": [
{
"font": "600 28px / 33.6px Archivo, ui-sans-serif, system-ui, sans-serif",
"t": "Controles y jerarquía"
},
{
"font": "600 20px / 26px Archivo, ui-sans-serif, system-ui, sans-serif",
"t": "Acciones"
},
{
"font": "600 20px / 26px Archivo, ui-sans-serif, system-ui, sans-serif",
"t": "Campos y mensajes"
},
{
"font": "600 20px / 26px Archivo, ui-sans-serif, system-ui, sans-serif",
"t": "Sección"
},
{
"font": "600 18px / 24.3px Archivo, ui-sans-serif, system-ui, sans-serif",
"t": "Panel secundario"
},
{
"font": "600 20px / 26px Archivo, ui-sans-serif, system-ui, sans-serif",
"t": "Comprobación en el navegador"
}
],
"page": "estudio/__diseno",
"w": 1280
},
{
"buttons": [
{
"font": "500 14px / 20px Archivo, ui-sans-serif, system-ui, sans-serif",
"h": 44,
"text": "Acción principal"
},
{
"font": "500 14px / 20px Archivo, ui-sans-serif, system-ui, sans-serif",
"h": 44,
"text": "Acción secundaria"
},
{
"font": "500 14px / 20px Archivo, ui-sans-serif, system-ui, sans-serif",
"h": 44,
"text": "No disponible"
},
{
"font": "500 14px / 20px Archivo, ui-sans-serif, system-ui, sans-serif",
"h": 36,
"text": "Compacto"
},
{
"font": "500 14px / 20px Archivo, ui-sans-serif, system-ui, sans-serif",
"h": 64,
"text": "Acción con una etiqueta extensa que debe adaptarse al espacio disponible"
}
],
"page": "estudio/__diseno",
"status": "Sin desviaciones de tipografía, altura ni desbordamiento en las muestras.",
"w": 390
},
{
"actions": [
{
"font": "500 14px / 20px Archivo, ui-sans-serif, system-ui, sans-serif",
"h": 44,
"text": "Guardar borrador"
},
{
"font": "500 14px / 20px Archivo, ui-sans-serif, system-ui, sans-serif",
"h": 44,
"text": "Vista previa"
},
{
"font": "500 14px / 20px Archivo, ui-sans-serif, system-ui, sans-serif",
"h": 44,
"text": "Revisar y publicar"
}
],
"fieldY": 547.140625,
"overflow": false,
"page": "estudio-editor",
"w": 768
},
{
"actions": [
{
"font": "500 14px / 20px Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 44,
"text": "Guardar borrador"
},
{
"font": "500 14px / 20px Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 44,
"text": "Vista previa"
},
{
"font": "500 14px / 20px Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 44,
"text": "Revisar y publicar"
}
],
"fieldY": 500.75,
"fields": [
{
"font": "16px / 24px Archivo, ui-sans-serif, system-ui, sans-serif"
},
{
"font": "16px Archivo, ui-sans-serif, system-ui, sans-serif"
},
{
"font": "16px Archivo, ui-sans-serif, system-ui, sans-serif"
},
{
"font": "16px Archivo, ui-sans-serif, system-ui, sans-serif"
}
],
"page": "estudio-editor",
"w": 1280
},
{
"covers": [
{
"h": 56,
"plays": 1,
"w": 56
},
{
"h": 56,
"plays": 1,
"w": 56
},
{
"h": 56,
"plays": 1,
"w": 56
}
],
"heading": "34px / 37.4px Newsreader, \"Iowan Old Style\", Georgia, serif",
"overflow": false,
"page": "catalogo-tablas",
"w": 390
},
{
"buttons": [
{
"font": "500 14px / 20px Archivo, ui-sans-serif, system-ui, sans-serif",
"h": 44,
"text": "Guardar borrador"
},
{
"font": "500 14px / 20px Archivo, ui-sans-serif, system-ui, sans-serif",
"h": 44,
"text": "Revisar y publicar"
},
{
"font": "500 14px / 20px Archivo, ui-sans-serif, system-ui, sans-serif",
"h": 36,
"text": "Texto"
},
{
"font": "500 14px / 20px Archivo, ui-sans-serif, system-ui, sans-serif",
"h": 36,
"text": "Código"
},
{
"font": "500 14px / 20px Archivo, ui-sans-serif, system-ui, sans-serif",
"h": 36,
"text": "Vídeo"
},
{
"font": "500 14px / 20px Archivo, ui-sans-serif, system-ui, sans-serif",
"h": 36,
"text": "Imagen"
},
{
"font": "500 14px / 20px Archivo, ui-sans-serif, system-ui, sans-serif",
"h": 36,
"text": "Sonido"
},
{
"font": "500 14px / 20px Archivo, ui-sans-serif, system-ui, sans-serif",
"h": 44,
"text": "Retirar publicación"
}
],
"fieldY": 605.34375,
"overflow": false,
"page": "estudio-editor-final",
"w": 390
},
{
"controls": [
{
"font": "16px / 24px Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 44,
"tag": "INPUT"
},
{
"font": "16px / 24px Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 44,
"tag": "INPUT"
},
{
"font": "16px / 24px Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 44,
"tag": "INPUT"
},
{
"font": "16px Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 44,
"tag": "SELECT"
},
{
"font": "500 14px / 20px Archivo, ui-sans-serif, system-ui, sans-serif",
"height": 44,
"tag": "BUTTON"
}
],
"page": "/contacto",
"w": 1280
}
]

@ -0,0 +1,170 @@
[
{
"ancho": 3801,
"controlesFuera": 0,
"desborde": 0,
"desvio": 0,
"estado": "Reproducción normal"
},
{
"ancho": 1920,
"controlesFuera": 0,
"desborde": 0,
"desvio": 0,
"estado": "Reproducción normal"
},
{
"ancho": 1440,
"controlesFuera": 0,
"desborde": 0,
"desvio": 0,
"estado": "Reproducción normal"
},
{
"ancho": 1280,
"controlesFuera": 0,
"desborde": 0,
"desvio": 0,
"estado": "Reproducción normal"
},
{
"ancho": 1024,
"controlesFuera": 0,
"desborde": 0,
"desvio": 0,
"estado": "Reproducción normal"
},
{
"ancho": 838,
"controlesFuera": 0,
"desborde": 0,
"desvio": 0,
"estado": "Reproducción normal"
},
{
"ancho": 768,
"controlesFuera": 0,
"desborde": 0,
"desvio": 0,
"estado": "Reproducción normal"
},
{
"ancho": 390,
"controlesFuera": 0,
"desborde": 0,
"desvio": 0,
"estado": "Reproducción normal"
},
{
"ancho": 3801,
"controlesFuera": 0,
"desborde": 0,
"desvio": 0,
"estado": "Iniciar programación de prueba"
},
{
"ancho": 1920,
"controlesFuera": 0,
"desborde": 0,
"desvio": 0,
"estado": "Iniciar programación de prueba"
},
{
"ancho": 1440,
"controlesFuera": 0,
"desborde": 0,
"desvio": 0,
"estado": "Iniciar programación de prueba"
},
{
"ancho": 1280,
"controlesFuera": 0,
"desborde": 0,
"desvio": 0,
"estado": "Iniciar programación de prueba"
},
{
"ancho": 1024,
"controlesFuera": 0,
"desborde": 0,
"desvio": 0,
"estado": "Iniciar programación de prueba"
},
{
"ancho": 838,
"controlesFuera": 0,
"desborde": 0,
"desvio": 0,
"estado": "Iniciar programación de prueba"
},
{
"ancho": 768,
"controlesFuera": 0,
"desborde": 0,
"desvio": 0,
"estado": "Iniciar programación de prueba"
},
{
"ancho": 390,
"controlesFuera": 0,
"desborde": 0,
"desvio": 0,
"estado": "Iniciar programación de prueba"
},
{
"ancho": 3801,
"controlesFuera": 0,
"desborde": 0,
"desvio": 0,
"estado": "Elegir canción manual"
},
{
"ancho": 1920,
"controlesFuera": 0,
"desborde": 0,
"desvio": 0,
"estado": "Elegir canción manual"
},
{
"ancho": 1440,
"controlesFuera": 0,
"desborde": 0,
"desvio": 0,
"estado": "Elegir canción manual"
},
{
"ancho": 1280,
"controlesFuera": 0,
"desborde": 0,
"desvio": 0,
"estado": "Elegir canción manual"
},
{
"ancho": 1024,
"controlesFuera": 0,
"desborde": 0,
"desvio": 0,
"estado": "Elegir canción manual"
},
{
"ancho": 838,
"controlesFuera": 0,
"desborde": 0,
"desvio": 0,
"estado": "Elegir canción manual"
},
{
"ancho": 768,
"controlesFuera": 0,
"desborde": 0,
"desvio": 0,
"estado": "Elegir canción manual"
},
{
"ancho": 390,
"controlesFuera": 0,
"desborde": 0,
"desvio": 0,
"estado": "Elegir canción manual"
}
]

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

@ -0,0 +1,86 @@
[
{
"alturasEtiquetas": [
44
],
"ancho": 1440,
"desborde": 0,
"origen": [
{
"alto": 36,
"texto": "Todo el catálogo"
},
{
"alto": 36,
"texto": "Emociones y etiquetas"
},
{
"alto": 36,
"texto": "Listas y canciones"
}
]
},
{
"alturasEtiquetas": [
44
],
"ancho": 1024,
"desborde": 0,
"origen": [
{
"alto": 36,
"texto": "Todo el catálogo"
},
{
"alto": 36,
"texto": "Emociones y etiquetas"
},
{
"alto": 36,
"texto": "Listas y canciones"
}
]
},
{
"alturasEtiquetas": [
44
],
"ancho": 768,
"desborde": 0,
"origen": [
{
"alto": 36,
"texto": "Todo el catálogo"
},
{
"alto": 36,
"texto": "Emociones y etiquetas"
},
{
"alto": 36,
"texto": "Listas y canciones"
}
]
},
{
"alturasEtiquetas": [
44
],
"ancho": 390,
"desborde": 0,
"origen": [
{
"alto": 48.5,
"texto": "Todo el catálogo"
},
{
"alto": 48.5,
"texto": "Emociones y etiquetas"
},
{
"alto": 48.5,
"texto": "Listas y canciones"
}
]
}
]

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

@ -2,6 +2,8 @@
Fecha de corte: 13 de septiembre de 2026. Fecha de corte: 13 de septiembre de 2026.
> Revisión del 14 de septiembre: la [auditoría visual en navegador](auditoria-visual-2026-09-14.md) confirma incoherencias de componentes, jerarquía y tamaños efectivos que este inventario estático no detecta. La centralización descrita aquí no acredita la aprobación del diseño. El sistema visual queda pendiente de corrección y validación por función.
## Objetivo y criterio de aceptación ## Objetivo y criterio de aceptación
La aplicación debe comportarse como un único sistema visual, no como una colección de páginas con CSS independiente. Un lote de normalización solo se considera terminado cuando: La aplicación debe comportarse como un único sistema visual, no como una colección de páginas con CSS independiente. Un lote de normalización solo se considera terminado cuando:

@ -0,0 +1,174 @@
# Auditoría visual de Senza Paura — 14 de septiembre de 2026
## Dictamen
**El sistema visual no está normalizado por función. El diseño no se considera cerrado.**
La centralización de variables ha reducido los valores dispersos, pero no ha creado un contrato efectivo entre los componentes. Hay diferencias de comportamiento entre tablas equivalentes, alturas incompatibles dentro de una misma fila de acciones, pesos heredados y una jerarquía administrativa demasiado plana. Estudio necesita una revisión de su estructura visual, además de la corrección de sus controles.
La valoración anterior de «cierre y pulido» era demasiado optimista para el estado del diseño. Las pruebas funcionales y el uso de tokens no acreditan coherencia visual.
Esta auditoría registra el estado del commit `1e24c87`, anterior a las correcciones. La implementación y su comprobación posterior se recogen en [Correcciones visuales](correcciones-visuales-2026-09-14.md). Las capturas de este documento se conservan como evidencia del estado inicial.
## Evidencia y cobertura
Inspección en navegador del código local, con la web conectada a su base configurada y Estudio al esquema de pruebas existente `estudio_pruebas_20260911_nuevo`. No se han creado contenidos, publicado fichas, enviado formularios ni alterado el catálogo. Se ha utilizado el acceso de prueba existente.
| Superficie | Revisión realizada |
| -------------- | ---------------------------------------------------------------------------------------------------------- |
| Portada | Vista de escritorio del carrusel, detenido en la diapositiva del taller |
| Música | Tarjetas y tablas; claro y oscuro en escritorio; tablas en móvil |
| Álbum | Cupido sin flechas: cabecera, acciones y tabla de temas |
| Canción | Ámame esta noche, nada más: cabecera, acciones, letra y ficha lateral |
| Blog | Índice, buscador, etiquetas y tarjetas |
| Taller | Índice, navegación lateral y jerarquía editorial |
| Intérpretes | Directorio y tarjetas |
| Suscripciones | Cabeceras y tarjetas de planes; no contratación |
| Juke-box | Hero público y sus dos acciones; no editor autenticado |
| Contacto | Diálogo abierto desde el pie, campos, foco y acción principal |
| Estudio | Acceso, inicio, listado de canciones, creación, ficha existente, revisión de publicación, pedidos y medios |
| Estudio móvil | Ficha existente a 390 × 844 |
| Panel anterior | Lectura de código del layout; no revisión autenticada en navegador |
Escritorio: 1280 × 720 y 1280 × 900 según la captura. Móvil: 390 × 844. Las dimensiones se comprobaron en el navegador. Estudio se revisó en su presentación oscura actual; no dispone del selector de tema de la web.
Las capturas y mediciones están en [la carpeta de evidencias](assets/auditoria-visual-2026-09-14/). Los JSON contienen estilos calculados y geometría; son inventarios, no informes de accesibilidad. También pueden incluir elementos fuera del viewport, ocultos mediante recorte o transformados por la animación de entrada. Las medidas citadas a continuación corresponden a los controles principales inspeccionados, no a esos elementos auxiliares.
No quedan certificados todos los estados de todas las rutas: faltan, entre otros, el panel anterior autenticado, el editor Jukebox, cuenta/biblioteca, el reproductor activo, errores de validación exhaustivos y la matriz completa de móvil/tableta/temas. Esto no reduce la validez de los fallos reproducidos; impide llamar a este informe una aprobación visual integral.
## Hallazgos prioritarios
P1 significa que debe resolverse antes de aprobar el sistema visual. P2 significa coherencia o composición que debe corregirse dentro del mismo trabajo de diseño. No son clasificaciones de seguridad.
### V01 · P1 · Álbumes y canciones tienen dos patrones de reproducción en tabla
Álbumes conserva una celda exclusiva para Play antes de la portada. Canciones superpone Play sobre la portada y lo muestra con interacción, foco o reproducción. Las portadas y los títulos empiezan en posiciones distintas y la misma acción se busca en dos lugares.
**Origen:** `src/lib/components/TablaAlbumes.svelte:29` y `:41`; `src/lib/components/TablaCanciones.svelte:255` y `:349`. `TablaCatalogo` comparte la envoltura, pero no la composición de la fila.
**Corrección:** compartir una celda de portada/reproducción. Mantener el comportamiento de canciones en ambos listados, con acción visible en táctil y accesible con teclado. La portada no debe cambiar de tamaño al pasar de Play a Pausa.
![Play separado en álbumes y superpuesto en canciones](assets/auditoria-visual-2026-09-14/comparacion-albumes-canciones.jpg)
### V02 · P1 · Las acciones de una misma canción tienen alturas diferentes
En la ficha, «Escuchar completa» mide **44 px** y «Añadir al carrito» **50,75 px**. Ambos tienen texto de 15 px y están juntos. El primero usa interlineado de 15 px; el segundo hereda 24,75 px. El relleno se suma de forma distinta.
**Origen:** `BotonReproducir.svelte:111` y `BotonCarrito.svelte:57`. Evidencia: `web-cancion-medidas.json` y `web-cancion-claro.jpg`.
**Corrección:** una base geométrica de botón compartida. Dos acciones del mismo tamaño deben tener la misma altura, interlineado, radio y alineación de icono, independientemente del componente que las renderice.
### V03 · P1 · El tamaño declarado no coincide con el tamaño final de Estudio
`.boton` declara mínimo de 44 px, pero 24 px de línea + 24 px de relleno vertical + 2 px de borde producen **50 px**. `.boton.pequeno` declara mínimo de 36 px, pero su línea y relleno producen **42 px**. El botón de búsqueda de la cabecera usa un glifo de 24 px y alcanza **52 px** de altura.
**Origen:** `src/estudio/estudio.css:94` y `:125`; barra superior en `src/estudio/rutas/(dentro)/+layout.svelte`. Evidencia: `estudio-canciones-medidas.json`.
**Corrección:** definir tamaños reales, con interlineado explícito y relleno compatible. Un token de `min-height` por sí solo no establece el tamaño.
### V04 · P1 · Estudio aplana funciones distintas a 16 px
Ayudas, `small`, antetítulos, estados, migas, encabezados de tabla y botones pequeños usan `--fs-base`. Las cabeceras de tabla añaden mayúsculas y peso 600; los valores, estados y explicaciones ocupan casi el mismo nivel. Se obliga a distinguir la información por posición en lugar de por jerarquía.
**Origen:** `estudio.css:84`, `:87`, `:160`, `:289` y `:354`.
**Corrección:** asignar roles completos a metadatos, etiquetas de campo, valores, títulos de registro, sección y página. La normalización debe conservar diferencias funcionales.
### V05 · P1 · Los campos heredan negrita de sus etiquetas
El título de una ficha y el buscador de Medios se presentan con peso 600. El buscador de Canciones usa 400. En Medios también se ve la diferencia en el placeholder. La apariencia depende de si el control está envuelto en un `label`.
**Origen:** `estudio.css:25` (`font: inherit`) y `:209` (`label` con peso 600); solo `textarea` restablece explícitamente el peso.
**Corrección:** separar el estilo de la etiqueta del estilo del valor. Inputs, selects, textareas y placeholders deben tener peso explícito; preferentemente 400 para la entrada.
### V06 · P1 · Hay pesos e interlineados efectivos fuera del contrato
El H1 de Estudio calcula 34/51 px y peso 600. Sus H2 calculan 24/36 px y **peso 700** heredado de las reglas del navegador. En la web los títulos editoriales sí reciben peso 400 e interlineado 1,1. El problema no es que una página editorial y una pantalla administrativa tengan títulos distintos: es que esa diferencia no está definida por un rol y parte del resultado queda en manos de la herencia.
**Origen:** `estudio.css:57–76`. Evidencia: `estudio-ficha-medidas.json`.
**Corrección:** declarar familia, tamaño, peso e interlineado de todos los roles de encabezado; no depender de los valores predeterminados de `h2`, `h3` o `strong`.
### V07 · P1 · Conviven varias bases de botón
La web utiliza `.btn`, botones musicales `.boton` y estilos locales. Estudio define otra `.boton`: texto de 16 px/600 y primario dorado plano, frente a 15 px/400 en acciones públicas. Los formularios públicos cambian el primario a navy/celeste según el tema mediante selectores dependientes de sus ancestros. El componente y su ubicación deciden la apariencia.
**Origen:** `src/app.css:453`, `:545`, `:665`; `estudio.css:94`; componentes `BotonCarrito`, `BotonReproducir`, favoritos y listas.
**Corrección:** una base compartida y variantes explícitas por importancia y contexto. La excepción del Play dorado puede conservarse, pero tiene que ser deliberada. Mover un botón dentro de un formulario no debe cambiar su diseño accidentalmente.
### V08 · P1 · El editor dedica casi toda la primera pantalla a su cabecera
En escritorio a 720 px de alto, el primer campo queda al borde inferior tras cabecera global, aviso de entorno, migas, antetítulo, título, estado, caja de publicación y pestañas. En móvil a 844 px apenas empieza «Datos de la ficha». Se repiten el estado y las explicaciones de publicación.
**Origen:** layout de Estudio y `src/estudio/rutas/(dentro)/[tipo]/[id]/+page.svelte`. Evidencia: `estudio-ficha-oscuro.jpg` y `estudio-ficha-movil.jpg`.
**Corrección:** cabecera de editor compacta con título, un estado y acciones; agrupar ayudas secundarias y hacerlas desplegables. El primer campo útil debe quedar visible en la primera pantalla normal de edición. Validar con títulos largos y avisos reales.
![Cabecera y acciones de Estudio antes de llegar a los campos](assets/auditoria-visual-2026-09-14/estudio-ficha-oscuro.jpg)
## Otros hallazgos de coherencia
| ID | Prioridad | Evidencia y efecto | Corrección verificable |
| --- | --------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| V09 | P2 | Inicio de Estudio: «Ver todas» queda junto a «Crear canción», pero debajo de las otras acciones; los títulos largos desplazan el contenido. `estudio-inicio-oscuro.jpg`; `src/estudio/rutas/(dentro)/+page.svelte:31`. | Tarjetas con las mismas filas internas y acciones ancladas abajo. Comprobar nombres de una y dos líneas. |
| V10 | P2 | Navegación de Estudio a 19 px, frente a texto principal de 16 px. Nombres partidos y menú con scroll propio; el menú compite con el trabajo. `+layout.svelte:205`. | Navegación utilitaria a 14–15 px, grupos secundarios y áreas táctiles suficientes, sin aumentar el texto para aumentar el área pulsable. |
| V11 | P2 | Los estados Publicado y Retirado comparten colores; Borrador y la selección de navegación usan el mismo dorado que las acciones. `estudio.css:178–189`. | Separar tokens de estado, selección y acción; conservar etiqueta e icono además del color. |
| V12 | P2 | La tabla de Canciones ofrece filtros sin etiquetas visibles y botón Buscar; Medios muestra etiquetas y filtrado con otro patrón. Sus inputs también difieren de peso. `estudio-canciones-oscuro.jpg`, `estudio-medios.jpg`. | Barra de filtros compartida, con la misma posición de etiquetas y una regla explícita de aplicación de filtros. |
| V13 | P2 | En Medios hay imágenes rotas que muestran icono del navegador y nombre de archivo; el audio usa el control nativo gris. Los archivos pertenecen a pruebas: no se atribuye su ausencia a producción. | Estado visual definido para archivo ausente/no disponible; contenedor y densidad comunes para vistas previas. Usar audio nativo puede ser válido si se integra deliberadamente. |
| V14 | P2 | En móvil, Música conserva la instrucción «de la izquierda» aunque los filtros estén en un botón superior. La primera portada aparece casi al final de la pantalla. `web-tablas-movil.jpg`. | Texto neutral respecto a la posición, introducción más breve y separación vertical adaptada a exploración móvil. |
| V15 | P2 | Estudio mezcla glifos de texto (`⌕`, `⌂`, `+`, `↗`, `♫`) con el sistema SVG de la web; cambian tamaño óptico y alineación. | Mismo componente de icono, cajas de 16/20 px por tamaño de control y una línea base común. |
| V16 | P2 | Los títulos de tarjetas musicales usan 24 px en álbumes y 19 px en canciones; los metadatos usan 14 frente a 12 px. La excepción no viene dada por un tipo compartido de tarjeta. `TarjetaAlbum.svelte:101`, `TarjetaCancion.svelte:123`. | Definir tarjeta normal/compacta por contexto. Un tipo de contenido no debe introducir su propia escala sin una razón de densidad documentada. |
| V17 | P1 | `node scripts/auditar-estilos.mjs --strict` termina con código 0 y «0 infracciones», mientras V01–V08 están presentes. El análisis no calcula cascada, herencia ni medidas reales y no compara funciones equivalentes. | Mantenerlo como control estático; añadir comprobación de estilos calculados y revisión visual de una galería de componentes. No usar su resultado como aprobación del diseño. |
## Qué conservar y qué no confundir con un fallo
Las familias Archivo y Newsreader y la base marino/dorado proporcionan una identidad reconocible. Las cabeceras de Música, Blog y Taller comparten ya 48 px/400; las tablas de canciones integran la reproducción en la portada y contemplan foco y táctil. Las tarjetas de intérpretes alinean sus acciones inferiores mejor que las del inicio de Estudio.
Un hero editorial puede tener una escala distinta de un editor. La marca puede aparecer en serif mientras la administración utiliza una jerarquía utilitaria. La normalización no exige igualar todos los encabezados HTML, sino dar el mismo tratamiento a las funciones equivalentes y documentar las excepciones.
## Contrato propuesto para la corrección
Propuesta de trabajo, todavía no aplicada. Usar los tokens primitivos existentes como base; añadir roles que agrupen familia, tamaño, peso e interlineado. Evitar que cada pantalla seleccione estas cuatro propiedades de forma independiente.
### Tipografía por función
| Función | Familia | Tamaño / interlineado | Peso |
| --------------------------------------------- | ------------------------ | ------------------------------ | --------------------------------- |
| Título de página editorial | Newsreader | 48/53 escritorio; 34/39 móvil | 400 |
| Hero de marca | Newsreader | Escala fluida propia y acotada | 400 |
| Título de página administrativa | Archivo | 28/34 escritorio; 24/30 móvil | 600 |
| Sección editorial | Newsreader | 34/38 | 400 |
| Sección administrativa / tarjeta de gestión | Archivo | 20/26 | 600 |
| Panel auxiliar / diálogo compacto | Archivo | 18/24 | 600 |
| Título de registro o tarjeta compacta | Archivo | 16/22 | 500 |
| Texto de lectura | Según contexto editorial | 16–19, línea 1,6–1,7 | 400 |
| Valor de formulario | Archivo | 16/24 | 400 |
| Etiqueta de campo, navegación y dato de tabla | Archivo | 14/20 | 500 para etiqueta; 400 para valor |
| Ayuda y metadatos | Archivo | 13/18 | 400 |
| Antetítulo y cabecera de tabla | Archivo | 12/16 | 500 |
La elección de Archivo para títulos administrativos es una propuesta de dirección, no un defecto objetivo de Newsreader. Debe validarse en el conjunto antes de extenderla.
### Botones y controles
| Tamaño | Altura final normal | Texto | Icono | Uso |
| --------- | ------------------- | --------------- | ----- | -------------------------------------------- |
| Compacto | 36 px | 14/20, peso 500 | 16 px | Tablas, herramientas y tarjetas densas |
| Normal | 44 px | 14/20, peso 500 | 20 px | Formularios y acciones de página |
| Destacado | 48 px | 16/22, peso 500 | 20 px | CTA de hero cuando lo justifique el contexto |
Radio compartido de 6 px; relleno horizontal según tamaño; borde incluido en el cálculo. En táctil, asegurar una zona de interacción de al menos 44 px sin agrandar necesariamente el símbolo. Con ampliación de texto, permitir crecimiento accesible en lugar de cortar las etiquetas para mantener una altura rígida.
Variantes explícitas: primaria, secundaria, discreta, destructiva y reproducción de marca. Todas deben definir reposo, hover, foco, pulsado, deshabilitado y carga. El estado de carga conserva el ancho y la alineación. Los segmentos seleccionados y las etiquetas informativas no heredan automáticamente el tratamiento del botón primario.
## Orden de implementación y aceptación
1. **Base y galería:** crear una página local de referencia con roles tipográficos, botones, campos, estados, tablas y tarjetas. Mostrar juntos los equivalentes de web y Estudio. Revisar el resultado renderizado antes de migrar pantallas.
2. **Estudio:** corregir herencia y encabezados, tamaños reales de controles, navegación, filtros, tarjetas de inicio y cabecera del editor. Resolver V03–V12 y V15 con una matriz de listado/edición/diálogo/vacío.
3. **Catálogo y fichas:** compartir celda de reproducción, base de botones y tarjetas. Resolver V01, V02 y V16 con Play/Pausa, foco, táctil y títulos largos.
4. **Resto público:** aplicar los roles a formulario, navegación, blog, taller, planes y Jukebox; conservar las excepciones editoriales justificadas. Revisar V14 y los estados no cubiertos.
5. **Cierre visual:** capturas comparables a 390, 768, 1024 y 1440 px, ambos temas donde existan; teclado, carga, error y vacío. Comparar estilos calculados además de imágenes. La ausencia de overflow o de errores de Svelte no sustituye esta revisión.
La aprobación exige que las acciones equivalentes compartan geometría, que no aparezcan pesos accidentales, que los valores de formulario no hereden el énfasis de sus etiquetas y que la tarea principal sea visible sin atravesar bloques repetidos de explicación. El diseño permanece **pendiente de corrección y validación visual** hasta entonces.

@ -0,0 +1,21 @@
# Correcciones del reproductor y Jukebox
El reparto desigual de columnas desplazaba el transporte al activar el Jukebox. Los laterales ahora tienen el mismo ancho; cuando falta espacio, el estado del Jukebox ocupa otra fila. En móvil el transporte ocupa una fila centrada. Los botones conservan su tamaño sin comprimirse.
El botón principal del hero reproduce el Jukebox elegido (por enlace, selección actual o único disponible), lo pausa y lo continúa. La selección se abre con un botón independiente, «Elegir Jukebox». Antes «Play JukeBox» únicamente desplegaba la selección. En espera, el botón informa «Jukebox programado».
Las acciones de la barra y del hero corresponden al estado: «Detener Jukebox» durante su reproducción, «Cancelar reanudación» durante una selección manual con retorno pendiente y «Desactivar Jukebox» en pausa o espera. Cancelar el retorno conserva la canción manual. El acceso anónimo al Jukebox también está disponible en el menú móvil.
## Comprobaciones
- 24 combinaciones: reproducción normal, programación activa y canción manual, cada una a 3801, 1920, 1440, 1280, 1024, 838, 768 y 390 píxeles. Desviación del transporte respecto al centro de la barra: 0 px. Sin desbordamientos ni controles fuera de la barra. [Medidas](assets/jukebox-corregido-2026-09-14/centrado.json).
- Navegador: audio público real, Play con una pulsación, pausa, continuación, interrupción manual, retorno a la pista y posición anteriores, cancelación sin cortar la canción manual y reinicio. La última carga completa no produjo errores de JavaScript.
- Galería de desarrollo `/__diseno/reproductor`: instancia del reproductor en memoria y los mismos componentes de la página, sin modificar cuentas ni programaciones guardadas. La ruta devuelve 404 fuera de desarrollo.
- 31 pruebas unitarias de cola, selección y programación superadas. Regresiones de interfaz añadidas en `e2e/jukebox-controles.e2e.ts`; sus escenarios se comprobaron mediante el navegador integrado. No se ejecutó el conjunto E2E autenticado con escrituras en la base de datos.
- Servidor local: `/juke-box` responde HTTP 200.
## Límites de la verificación
Se verificaron los componentes y el motor con canciones públicas y una programación efímera. No se comprobó la programación guardada de la cuenta del usuario. La revisión automática rechazó crear una cuenta y suscripción de prueba; esa acción no se ejecutó y no se modificaron cuentas ni suscripciones.
[Escritorio](assets/jukebox-corregido-2026-09-14/escritorio.jpg) · [Móvil](assets/jukebox-corregido-2026-09-14/movil.jpg)

@ -0,0 +1,52 @@
# Correcciones visuales — 14 de septiembre de 2026
Implementación local de los problemas encontrados en la [auditoría visual](auditoria-visual-2026-09-14.md). No se ha desplegado ni publicado contenido. Estudio se ha revisado con su esquema de pruebas existente.
## Cambios
| Hallazgos | Implementación |
| ------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| V01 | `PortadaReproducible` compartida por las tablas de canciones y álbumes. Una sola celda de portada/reproducción, con Play/Pausa, foco, estado activo y alternativa sin imagen. |
| V02, V03, V07 | `controles.css` define la geometría y tipografía de acciones públicas, musicales y administrativas. Las variantes de formulario se declaran explícitamente; su color no depende del ancestro. Incluye carrito, favoritos, cola, listas y herramientas del editor. |
| V04–V06 | Roles completos de título, sección, panel, etiqueta, metadato y valor. Estudio usa Archivo 28/33,6–600 para página, 20/26–600 para sección y 18/24,3–600 para panel. Los campos tienen peso 400 y las etiquetas 500. También se normalizan los campos públicos de acceso, búsqueda y contacto. |
| V08 | Cabecera más corta; un único estado de edición, con explicación desplegable. La ayuda de guardado y el aviso de entorno también se pueden desplegar. Se conservan mensajes de operación, errores y revisión previa a publicar. |
| V09, V10 | Acciones alineadas al pie de los tres accesos de inicio. Navegación a 14 px, con altura de interacción independiente de la letra. |
| V11 | Publicado verde, borrador azul y retirado neutro, conservando texto y marcador. Las acciones de administración usan azul; la reproducción musical conserva oro. |
| V12 | Etiquetas visibles y patrón de campos común en listados, búsqueda, operaciones y medios. Medios identifica expresamente su filtrado al escribir. Los otros filtros conservan su envío GET. |
| V13 | Vista previa de medios con estado «Archivo no disponible». El audio mantiene controles nativos en un contenedor definido. |
| V14 | Introducción de Música más corta y sin referencias a «la izquierda»; título móvil a 34 px. |
| V15 | SVG del catálogo de iconos en cabecera, listados, creación, relaciones y herramientas de secciones. |
| V16 | Los títulos de tarjetas de álbum y canción comparten Newsreader 19/20,9–400; metadatos de ambas a 13 px. |
| V17 | Galería local en `/__diseno` en la web y Estudio. Incluye variantes, etiquetas largas, foco, campos y estados; permite comprobar estilos calculados y desbordamientos. Responde 404 fuera de desarrollo. |
## Evidencia del resultado
| Comprobación | Antes | Después medido |
| ---------------------------------- | -------------------------------------- | ------------------------------------------------------------------- |
| Escuchar / carrito en ficha | 44 / 50,75 px; interlíneas diferentes | Ambos 44 px, Archivo 14/20–500 |
| Botón normal / compacto de Estudio | 50 / 42 px | 44 / 36 px |
| Buscar en cabecera de Estudio | 52 px y glifo tipográfico | 44 px y SVG de 20 px |
| Valor de campo dentro de etiqueta | Peso 600 heredado | Archivo 16 px, peso 400 |
| Secciones H2 de Estudio | 24/36–700 | 20/26–600 |
| Portadas de ambas tablas | Celda de Play independiente en álbumes | Una portada con un control; 80 × 80 en escritorio, 56 × 56 en móvil |
| Primer campo del editor móvil | Quedaba fuera de la primera pantalla | Empieza en y=605 a 390 × 844, sin desbordamiento de la página |
| Acciones de los accesos de inicio | Distinta colocación | Las tres filas de acciones comparten la misma coordenada vertical |
El compacto crece a 44 px para punteros táctiles mediante CSS. Las alturas son mínimas y las etiquetas multilínea pueden crecer; no se recorta el texto para conservar una medida rígida.
![Inicio de Estudio](assets/correcciones-visuales-2026-09-14/estudio-inicio-escritorio.jpg)
![Editor de Estudio](assets/correcciones-visuales-2026-09-14/estudio-editor-escritorio.jpg)
![Catálogo móvil](assets/correcciones-visuales-2026-09-14/catalogo-tablas-movil.jpg)
Más capturas y [mediciones JSON](assets/correcciones-visuales-2026-09-14/mediciones.json) en `docs/assets/correcciones-visuales-2026-09-14/`.
## Verificación y límites
- Web: tarjetas y tablas del catálogo en escritorio y móvil; reproducción y pausa desde portada de álbum; ficha de canción en temas claro y oscuro; foco de teclado; campos de contacto; galería con etiquetas largas y controles deshabilitados.
- Estudio: inicio, filtros del listado con resultado y vacío, editor a 390, 768 y 1280 px, diálogo de revisión de publicación, biblioteca y archivo dañado. Los formularios de guardado y publicación no se enviaron.
- Comprobador de la galería sin desviaciones en la web y Estudio; Estudio comprobado a 390, 768 y 1280 px. Las mediciones de viewport no equivalen a una prueba en dispositivo físico táctil.
- `npm run check`: web, panel anterior y Estudio, sin errores ni advertencias de Svelte. Repetidos los checks afectados después de las últimas modificaciones. ESLint de los archivos modificados, Prettier y `audit:styles -- --strict` correctos.
Esto acredita las superficies y estados indicados, no una aprobación estética global. No se ha repetido la matriz completa de todos los contenidos, todos los anchos y todos los estados autenticados de compras/Jukebox, ni una revisión visual autenticada del panel anterior. La auditoría estática sigue siendo complementaria: no sustituye las capturas ni los estilos calculados.

@ -0,0 +1,17 @@
# Jukebox por emociones y etiquetas
Cada franja permite elegir «Emociones y etiquetas» en Contenido. Usa las etiquetas editoriales del catálogo, incluidas las emociones, con búsqueda, selección múltiple, recuentos y vista previa. Coincidir con cualquiera de las etiquetas basta; una canción no se duplica por coincidir con varias.
La franja guarda las etiquetas, no una lista fija de canciones. La selección se resuelve con el catálogo disponible al iniciar el Jukebox, tras aplicar los permisos de escucha existentes. Las programaciones anteriores mantienen su funcionamiento. El campo opcional se guarda dentro del JSON de franjas existente; no requiere migración.
Una selección vacía desactiva el guardado y la prueba de reproducción. El servidor rechaza filtros malformados y filtros sin canciones coincidentes. Una selección sin coincidencias nunca se sustituye por el catálogo completo. El resumen de configuración muestra las etiquetas elegidas y el número de canciones.
## Verificación
- 39 pruebas automatizadas superadas: cola, programación, selección y endpoint de guardado. Cubren normalización de tildes y mayúsculas, unión sin duplicados, incorporación de canciones nuevas al resolver, compatibilidad con franjas anteriores, serialización y datos inválidos. Las pruebas del endpoint usan almacenamiento simulado; no escriben en cuentas reales.
- Comprobación manual en navegador con los componentes reales: «Deseo» y «Despedidas» producen tres canciones sin duplicar «Quédate»; «Melancolía» reproduce «Para qué recordar» y el audio avanza.
- Revisión visual a 1440, 1024, 768 y 390 píxeles, sin desbordamientos. Opciones de etiquetas de 44 px y alturas iguales entre los controles de origen. Temas claro y oscuro comprobados.
- `check:sitio`: sin errores ni avisos. ESLint en los archivos afectados y `git diff --check`: sin incidencias.
- Regresión de navegador añadida en `e2e/jukebox-etiquetas.e2e.ts`. La galería `/__diseno/jukebox` es exclusiva de desarrollo y permite probar el editor y audio sin modificar una cuenta. Sus escenarios se verificaron con el navegador integrado; no se ejecutó el conjunto E2E autenticado contra la base de datos.
[Escritorio](assets/jukebox-etiquetas-2026-09-14/escritorio.jpg) · [Móvil](assets/jukebox-etiquetas-2026-09-14/movil.jpg) · [Tema claro](assets/jukebox-etiquetas-2026-09-14/claro.jpg) · [Medidas](assets/jukebox-etiquetas-2026-09-14/medidas.json)

@ -0,0 +1,24 @@
# Likes públicos y valoraciones conservadas
Las fichas y tarjetas de canciones muestran un pulgar y el número entero de likes. Las estrellas y las medias ya no se muestran en la web pública. Favoritos conserva su función personal y su corazón.
La tabla existente `valoracion` reúne ambos datos, con una fila por `cancion_id` y `usuario_id`:
- `date`: fecha original, conservada.
- `like`: booleano independiente; solo los valores `true` suman al contador.
- `value`: puntuación anterior de 1 a 5, o NULL si la cuenta solo ha dado like.
- `actualizada_en`: fecha histórica adicional conservada.
Dar o quitar like nunca modifica `value` ni `date`, y quitarlo no borra la fila. Una fila creada para un like puede recibir su primera puntuación posteriormente mediante el servicio de valoraciones; una puntuación existente no se sobrescribe. No se ha habilitado una interfaz profesional nueva.
La migración 0041 incorpora los likes de la tabla provisional 0040 y retira esa tabla tras verificar la copia. En la misma transacción compara todas las valoraciones antiguas, incluyendo sus fechas, y revierte si falta o cambia alguna. No convierte puntuaciones en likes.
Validación:
- 23 pruebas de servidor superadas, incluidas pruebas contra tablas temporales de PostgreSQL, sin cuentas nuevas ni cambios persistentes en los datos de prueba.
- Svelte: cero errores y cero avisos. ESLint y `git diff --check` correctos.
- Navegador con sesión: 0 → 1 en ficha, persistencia al abrir catálogo y 1 → 0 desde tarjeta. Estado final de prueba restaurado a 0.
- Tarjeta móvil de 390 px y escritorio de 1440 px sin desbordamiento; botón de 44 px. Sin errores de consola en la pestaña de prueba.
- Los E2E existentes se adaptaron a los likes; no se ejecutó la suite E2E completa.
Evidencia: [tarjeta móvil](assets/likes-2026-09-14/tarjeta-movil.jpg).

File diff suppressed because it is too large Load Diff

@ -0,0 +1,9 @@
-- Likes independientes de las antiguas puntuaciones. Un like por cuenta y canción.
CREATE TABLE "me_gusta" (
"usuario_id" text NOT NULL REFERENCES "usuario"("id") ON DELETE CASCADE,
"cancion_id" text NOT NULL REFERENCES "cancion"("id") ON DELETE CASCADE,
"creado_en" timestamp with time zone DEFAULT now() NOT NULL,
PRIMARY KEY ("usuario_id", "cancion_id")
);
--> statement-breakpoint
CREATE INDEX "idx_me_gusta_cancion" ON "me_gusta" ("cancion_id");

@ -0,0 +1,32 @@
-- Conserva cada puntuación y su fecha. El like es independiente y empieza desmarcado.
CREATE TEMP TABLE valoraciones_antes_de_likes ON COMMIT DROP AS
SELECT usuario_id, cancion_id, puntuacion, creada_en, actualizada_en FROM valoracion;
--> statement-breakpoint
ALTER TABLE "valoracion" ADD COLUMN "like" boolean DEFAULT false NOT NULL;
--> statement-breakpoint
ALTER TABLE "valoracion" ALTER COLUMN "puntuacion" DROP NOT NULL;
--> statement-breakpoint
ALTER TABLE "valoracion" RENAME COLUMN "puntuacion" TO "value";
--> statement-breakpoint
ALTER TABLE "valoracion" RENAME COLUMN "creada_en" TO "date";
--> statement-breakpoint
-- Recoge cualquier like recibido durante la transición sin sobrescribir valoraciones.
INSERT INTO "valoracion" ("usuario_id", "cancion_id", "date", "like", "value")
SELECT "usuario_id", "cancion_id", "creado_en", true, NULL FROM "me_gusta"
ON CONFLICT ("usuario_id", "cancion_id") DO UPDATE SET "like" = true;
--> statement-breakpoint
-- Verifica la copia antes de retirar la tabla provisional. Toda la migración es transaccional.
DO $$ BEGIN
IF EXISTS (
SELECT usuario_id, cancion_id, puntuacion, creada_en, actualizada_en FROM valoraciones_antes_de_likes
EXCEPT
SELECT usuario_id, cancion_id, "value", "date", actualizada_en FROM valoracion
) THEN RAISE EXCEPTION 'Las valoraciones históricas deben permanecer intactas'; END IF;
IF EXISTS (
SELECT 1 FROM "me_gusta" m LEFT JOIN "valoracion" v
ON v.usuario_id = m.usuario_id AND v.cancion_id = m.cancion_id
WHERE v."like" IS DISTINCT FROM true
) THEN RAISE EXCEPTION 'Faltan likes por trasladar'; END IF;
END $$;
--> statement-breakpoint
DROP TABLE "me_gusta";

@ -0,0 +1,102 @@
-- Cuatro identidades ficticias para acreditar las voces de «Cupido sin flechas».
-- Los retratos son recursos editoriales originales guardados junto al sitio.
INSERT INTO medio (id, clase, clave, publico, mime, bytes, ancho, alto, sha256, alt)
VALUES
('d35863e8-7ae5-4835-b3f5-a2753a9a75ef', 'imagen', 'images/artistas/cupido-sin-flechas/lia-valdes.webp', true, 'image/webp', 84798, 1448, 1086, '2f695eab09337e1e65a8d8fc8b59808204bc5bdb64514491250a797d7ef5d422', 'Retrato editorial de Lía Valdés'),
('e2ab35f0-f965-47f2-afef-15eb63046ac1', 'imagen', 'images/artistas/cupido-sin-flechas/adrian-vega.webp', true, 'image/webp', 110522, 1448, 1086, '8dba9aa86a758d58b0515010bc41820d38524c8946c182c8543ccda4995f4e4d', 'Retrato editorial de Adrián Vega'),
('c73a6532-35bc-4bf7-9f37-57061fffb1ac', 'imagen', 'images/artistas/cupido-sin-flechas/mara-santel.webp', true, 'image/webp', 82632, 1448, 1086, '14bba4065581d72acfbf684235870cd0e6c24ef2b7ab50a8c28b43de29ba1776', 'Retrato editorial en blanco y negro de Mara Santel'),
('52cd559f-30f5-4b5f-8207-e70aab24f044', 'imagen', 'images/artistas/cupido-sin-flechas/bruno-leal.webp', true, 'image/webp', 104022, 1448, 1086, '51b6b1d24c964b085752a8418e582c60ade084519a04229194348584647e0a38', 'Retrato editorial de Bruno Leal')
ON CONFLICT (clave) DO UPDATE SET
publico = EXCLUDED.publico,
mime = EXCLUDED.mime,
bytes = EXCLUDED.bytes,
ancho = EXCLUDED.ancho,
alto = EXCLUDED.alto,
sha256 = EXCLUDED.sha256,
alt = EXCLUDED.alt;
--> statement-breakpoint
INSERT INTO artista (
id, codigo, slug, tipo, es_virtual, nombre, descripcion_corta, biografia,
imagen_id, pais_codigo, lugar_origen, inicio, estado, origen, modelo_origen, importado_en
)
VALUES
(
'53f546da-378b-47ac-be14-b0f3e13a9496', 'ART-CF-001', 'lia-valdes', 'persona', false,
'Lía Valdés',
'Una voz soul de pulso íntimo, capaz de convertir el deseo y la pérdida en grandes baladas contemporáneas.',
'Lía Valdés creció entre el piano familiar y los discos de soul que sonaban en casa. En Cupido sin flechas interpreta las canciones más abiertas y emocionales del álbum, con una voz cálida que pasa de la confidencia al desgarro sin perder elegancia.',
(SELECT id FROM medio WHERE clave = 'images/artistas/cupido-sin-flechas/lia-valdes.webp'),
CASE WHEN EXISTS (SELECT 1 FROM pais WHERE codigo = 'ES') THEN 'ES' END,
'Valencia', 2012, 'publicado', 'importado', 'gpt-image-2 / perfil ficticio', now()
),
(
'f50f8e16-c1ae-4326-a303-c8ba3b30ea58', 'ART-CF-002', 'adrian-vega', 'persona', false,
'Adrián Vega',
'Baladista de voz grave y vivida, con una interpretación serena marcada por la memoria y los años.',
'Adrián Vega llegó a la canción después de una larga etapa como músico de directo. Su timbre áspero, su fraseo lento y una presencia de otra época dan peso a las promesas, las ausencias y los regresos de Cupido sin flechas.',
(SELECT id FROM medio WHERE clave = 'images/artistas/cupido-sin-flechas/adrian-vega.webp'),
CASE WHEN EXISTS (SELECT 1 FROM pais WHERE codigo = 'ES') THEN 'ES' END,
'Madrid', 1996, 'publicado', 'importado', 'gpt-image-2 / perfil ficticio', now()
),
(
'87387a05-9058-492e-8263-24549fced35b', 'ART-CF-003', 'mara-santel', 'persona', false,
'Mara Santel',
'Una intérprete de pop nocturno y emoción contenida, reconocible por su dicción precisa y su tensión dramática.',
'Mara Santel construye cada canción desde el silencio y la palabra. Su lectura sobria, casi cinematográfica, sostiene los pasajes más frágiles de Cupido sin flechas y deja que cada ruptura respire antes del siguiente verso.',
(SELECT id FROM medio WHERE clave = 'images/artistas/cupido-sin-flechas/mara-santel.webp'),
CASE WHEN EXISTS (SELECT 1 FROM pais WHERE codigo = 'ES') THEN 'ES' END,
'Barcelona', 2008, 'publicado', 'importado', 'gpt-image-2 / perfil ficticio', now()
),
(
'e81d9231-b0d1-4bc0-8c4a-8e2229bef15d', 'ART-CF-004', 'bruno-leal', 'persona', false,
'Bruno Leal',
'Voz fronteriza entre la balada y el rock, directa, madura y sin miedo a mostrar las grietas.',
'Bruno Leal aporta el reverso más áspero del álbum. Su trayectoria entre pequeñas salas y bandas de carretera se oye en una voz sin adornos, elegida para las canciones donde el amor se mira con distancia, ironía y dignidad.',
(SELECT id FROM medio WHERE clave = 'images/artistas/cupido-sin-flechas/bruno-leal.webp'),
CASE WHEN EXISTS (SELECT 1 FROM pais WHERE codigo = 'ES') THEN 'ES' END,
'Sevilla', 2001, 'publicado', 'importado', 'gpt-image-2 / perfil ficticio', now()
)
ON CONFLICT (slug) DO UPDATE SET
codigo = EXCLUDED.codigo,
tipo = EXCLUDED.tipo,
es_virtual = EXCLUDED.es_virtual,
nombre = EXCLUDED.nombre,
descripcion_corta = EXCLUDED.descripcion_corta,
biografia = EXCLUDED.biografia,
imagen_id = EXCLUDED.imagen_id,
pais_codigo = EXCLUDED.pais_codigo,
lugar_origen = EXCLUDED.lugar_origen,
inicio = EXCLUDED.inicio,
estado = EXCLUDED.estado,
origen = EXCLUDED.origen,
modelo_origen = EXCLUDED.modelo_origen,
importado_en = EXCLUDED.importado_en,
actualizado_en = now();
--> statement-breakpoint
-- Sustituye cualquier crédito principal provisional del disco por el reparto editorial definitivo.
DELETE FROM cancion_interprete
WHERE rol = 'principal'
AND cancion_id IN (
SELECT c.id FROM cancion c
INNER JOIN album a ON a.id = c.album_id
WHERE a.slug = 'cupido-sin-flechas' AND c.version_de_id IS NULL
);
--> statement-breakpoint
INSERT INTO cancion_interprete (cancion_id, artista_id, rol, orden)
SELECT c.id, a.id, 'principal', 0
FROM (
VALUES
('amame-esta-noche-nada-mas', 'lia-valdes'),
('cuando-tus-manos-me-tocan', 'adrian-vega'),
('inventario-a-los-56', 'bruno-leal'),
('no-me-mientas', 'mara-santel'),
('pague-por-una-noche-de-amor', 'lia-valdes'),
('para-que-recordar', 'mara-santel'),
('prometiste', 'adrian-vega'),
('quedate', 'lia-valdes'),
('ton-silence-me-libere', 'mara-santel'),
('tus-malas-maneras', 'bruno-leal')
) AS reparto(cancion_slug, artista_slug)
INNER JOIN cancion c ON c.slug = reparto.cancion_slug
INNER JOIN artista a ON a.slug = reparto.artista_slug
ON CONFLICT (cancion_id, artista_id, rol) DO UPDATE SET orden = EXCLUDED.orden;

@ -0,0 +1,12 @@
CREATE TABLE "artista_seguido" (
"usuario_id" text NOT NULL,
"artista_id" text NOT NULL,
"seguido_en" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "artista_seguido_usuario_id_artista_id_pk" PRIMARY KEY("usuario_id", "artista_id"),
CONSTRAINT "artista_seguido_usuario_fk" FOREIGN KEY ("usuario_id") REFERENCES "usuario"("id") ON DELETE cascade,
CONSTRAINT "artista_seguido_artista_fk" FOREIGN KEY ("artista_id") REFERENCES "artista"("id") ON DELETE cascade
);
--> statement-breakpoint
CREATE INDEX "artista_seguido_usuario" ON "artista_seguido" USING btree ("usuario_id");
--> statement-breakpoint
CREATE INDEX "artista_seguido_artista" ON "artista_seguido" USING btree ("artista_id");

@ -0,0 +1,28 @@
-- Los retratos pasan a un encuadre panorámico preparado para la cabecera pública.
UPDATE medio SET
bytes = 83386,
ancho = 1672,
alto = 941,
sha256 = '8cb093e8dd2c775815ee7af232c9829598049d3f00753329d6064c95cde4eb08'
WHERE clave = 'images/artistas/cupido-sin-flechas/lia-valdes.webp';
--> statement-breakpoint
UPDATE medio SET
bytes = 118842,
ancho = 1672,
alto = 941,
sha256 = '7cbdc2b9489c586cf8b8f18b56cc0621c59cda9f75ad2d25d9a97a206e89d1a0'
WHERE clave = 'images/artistas/cupido-sin-flechas/adrian-vega.webp';
--> statement-breakpoint
UPDATE medio SET
bytes = 69182,
ancho = 1672,
alto = 941,
sha256 = '1eff6b1c802d397d3aeb310df610d6f1827305d44a8428112dce15d25eee2944'
WHERE clave = 'images/artistas/cupido-sin-flechas/mara-santel.webp';
--> statement-breakpoint
UPDATE medio SET
bytes = 102606,
ancho = 1672,
alto = 941,
sha256 = '74cb868aeffb3c1b7cac6dab79dc72afc775dfd3dfabeb1bd96cec10a98fc31f'
WHERE clave = 'images/artistas/cupido-sin-flechas/bruno-leal.webp';

@ -0,0 +1,29 @@
-- Sincroniza los metadatos con los cuatro retratos definitivos incluidos en static.
-- Actualiza solo los archivos conocidos; repetir estas sentencias conserva el resultado.
UPDATE medio SET
bytes = 113578,
ancho = 1672,
alto = 941,
sha256 = '88b20050c4d4257187ad9eaebc8fe4b4ea76446370698796186f3aff1dbcaa55'
WHERE clave = 'images/artistas/cupido-sin-flechas/lia-valdes.webp';
--> statement-breakpoint
UPDATE medio SET
bytes = 140926,
ancho = 1672,
alto = 941,
sha256 = '3fcea5a11ccf1471929786e343b48751f1a95bbbfb59d24810f4e638a1067b76'
WHERE clave = 'images/artistas/cupido-sin-flechas/adrian-vega.webp';
--> statement-breakpoint
UPDATE medio SET
bytes = 73984,
ancho = 1672,
alto = 941,
sha256 = '674d596109d9088eca36df8f6326104af621abc97037764bfe3a316f98b50919'
WHERE clave = 'images/artistas/cupido-sin-flechas/mara-santel.webp';
--> statement-breakpoint
UPDATE medio SET
bytes = 110558,
ancho = 1672,
alto = 941,
sha256 = '18a62844dc4348444520559b77e7b736acdc7f9a61477d6ca041aaa802e5d645'
WHERE clave = 'images/artistas/cupido-sin-flechas/bruno-leal.webp';

@ -229,66 +229,33 @@ test.describe('Blog', () => {
}); });
}); });
test.describe('Valoraciones', () => { test.describe('Me gusta', () => {
test('quien no ha entrado ve solo las estrellas y la puntuación', async ({ page }) => { test('muestra el pulgar y el contador, sin estrellas', async ({ page }) => {
await page.goto('/canciones/prometiste'); await page.goto('/canciones/prometiste');
const bloque = page.locator('header.ficha .me-gusta');
const bloque = page.getByRole('region', { name: 'Valoración de la canción' });
await expect(bloque).toBeVisible();
await expect( await expect(
bloque.getByRole('link', { name: 'Inicia sesión para valorar esta canción' }) bloque.getByRole('link', { name: /Me gusta: \d+ likes?\. Inicia sesión/ })
).toBeVisible(); ).toBeVisible();
await expect(bloque.locator('.estrellas span')).toHaveCount(5); await expect(bloque.locator('.contador')).toHaveText(/^\d+$/);
await expect(bloque.locator('.media')).toBeVisible(); await expect(page.locator('.estrellas')).toHaveCount(0);
await expect(bloque).not.toContainText(/valoración|de 5|votos?|quitar mi voto/i);
// Sin sesión no se puede votar: no hay estrellas que pulsar.
await expect(bloque.getByRole('radio')).toHaveCount(0);
}); });
test('conserva la canción como destino al entrar', async ({ page }) => {
test('las estrellas vuelven al tema tras entrar', async ({ page }) => {
await page.goto('/canciones/prometiste'); await page.goto('/canciones/prometiste');
await expect(page.locator('header.ficha .me-gusta a')).toHaveAttribute(
await expect( 'href',
page /volverA=%2Fcanciones%2Fprometiste/
.getByRole('region', { name: 'Valoración de la canción' }) );
.getByRole('link', { name: 'Inicia sesión para valorar esta canción' })
).toHaveAttribute('href', /volverA=%2Fcanciones%2Fprometiste/);
});
test('un POST sin origen lo para el CSRF, antes de llegar a la acción', async ({ request }) => {
const respuesta = await request.post('/canciones/prometiste?/valorar', {
form: { puntuacion: '5' }
});
expect(respuesta.status()).toBe(403);
}); });
test('rechaza un POST sin origen', async ({ request }) => {
test('sin sesión, la acción de valorar se rechaza', async ({ request, baseURL }) => { const r = await request.post('/canciones/prometiste?/meGusta', { form: { activo: '1' } });
// Con el origen correcto la petición llega a la acción, así que lo que se expect(r.status()).toBe(403);
// comprueba aquí es su guardia y no la del framework. La interfaz no
// enseña estrellas sin sesión, pero el formulario se puede enviar a mano.
//
// La respuesta es el resultado de la acción en JSON, no la página: el
// código de `fail` viaja dentro, y el de la respuesta HTTP es 200.
const respuesta = await request.post('/canciones/prometiste?/valorar', {
headers: { origin: baseURL ?? '' },
form: { puntuacion: '5' }
});
const resultado = JSON.parse(await respuesta.text());
expect(resultado.type).toBe('failure');
expect(resultado.status).toBe(401);
}); });
test('rechaza likes sin sesión', async ({ request, baseURL }) => {
test('una puntuación fuera de rango tampoco pasa', async ({ request, baseURL }) => { const r = await request.post('/canciones/prometiste?/meGusta', {
// Sin sesión gana la guardia de la cuenta; que el rango se comprueba está headers: { origin: baseURL ?? '', accept: 'application/json', 'x-sveltekit-action': 'true' },
// cubierto aparte, en las pruebas de `aPuntuacion`. form: { activo: '1' }
const respuesta = await request.post('/canciones/prometiste?/valorar', {
headers: { origin: baseURL ?? '' },
form: { puntuacion: '9' }
}); });
expect(JSON.parse(await r.text())).toMatchObject({ type: 'failure', status: 401 });
expect(JSON.parse(await respuesta.text()).type).toBe('failure');
}); });
}); });

@ -110,13 +110,15 @@ test.describe('Tarjetas de canción', () => {
await expect(page).toHaveURL(/\/musica\/baladas-romanticas$/); await expect(page).toHaveURL(/\/musica\/baladas-romanticas$/);
}); });
test('la tarjeta muestra la nota de valoración', async ({ page }) => { test('la tarjeta muestra el número de likes', async ({ page }) => {
await page.goto('/musica/baladas-romanticas'); await page.goto('/musica/baladas-romanticas');
const tarjeta = page const tarjeta = page
.locator('article') .locator('article')
.filter({ has: page.locator('a[href="/canciones/prometiste"]') }); .filter({ has: page.locator('a[href="/canciones/prometiste"]') });
await expect(tarjeta.getByLabel('Sin valoraciones')).toContainText('Sin valorar'); await expect(
tarjeta.getByRole('link', { name: /Me gusta: \d+ likes?\. Inicia sesión/ })
).toContainText(/^\d+$/);
}); });
}); });

@ -0,0 +1,57 @@
import { expect, test } from '@playwright/test';
test.describe('Controles del Jukebox sin cambios en la base de datos', () => {
test.skip(process.env.E2E_JUKEBOX !== '1', 'La galería solo existe en desarrollo.');
test('Play inicia, pausa y recupera la programación con acciones coherentes', async ({
page
}) => {
await page.goto('/__diseno/reproductor');
await page.locator('html[data-hidratado]').waitFor();
const barra = page.getByRole('region', { name: 'Reproductor', exact: true });
const posicion = barra.getByRole('slider', { name: 'Posición de reproducción' });
await page.getByRole('button', { name: 'Play JukeBox', exact: true }).click();
await expect.poll(() => posicion.inputValue().then(Number)).toBeGreaterThan(0);
const titulo = await barra.getByRole('group', { name: 'Sonando ahora' }).innerText();
await page.getByRole('button', { name: 'Pausar Jukebox', exact: true }).click();
await expect(barra).toContainText('En pausa');
await expect(page.getByRole('button', { name: 'Detener Jukebox', exact: true })).toHaveCount(0);
await page.getByRole('button', { name: 'Continuar Jukebox', exact: true }).click();
await page.getByRole('button', { name: 'Elegir canción manual', exact: true }).click();
await expect(barra).toContainText('Vuelve al terminar');
await expect(page.getByRole('button', { name: 'Detener Jukebox', exact: true })).toHaveCount(0);
await expect(barra.getByRole('button', { name: 'Cancelar reanudación' })).toBeVisible();
await page.getByRole('button', { name: 'Continuar Jukebox', exact: true }).click();
await expect(barra.getByRole('group', { name: 'Sonando ahora' })).toHaveText(titulo);
await page.getByRole('button', { name: 'Limpiar prueba', exact: true }).click();
});
test('mantiene el transporte centrado con y sin Jukebox, también en escucha manual', async ({
page
}) => {
await page.goto('/__diseno/reproductor');
await page.locator('html[data-hidratado]').waitFor();
for (const estado of [
'Reproducción normal',
'Iniciar programación de prueba',
'Elegir canción manual'
]) {
await page.getByRole('button', { name: estado, exact: true }).click();
for (const width of [3801, 1920, 1440, 1280, 1024, 838, 768, 390]) {
await page.setViewportSize({ width, height: 1000 });
const geometria = await page
.getByRole('region', { name: 'Reproductor' })
.evaluate((barra) => {
const r = barra.getBoundingClientRect();
const b = barra.querySelector('.principal')!.getBoundingClientRect();
return {
desvio: Math.abs(b.x + b.width / 2 - r.x - r.width / 2),
desborde: document.documentElement.scrollWidth - document.documentElement.clientWidth
};
});
expect(geometria.desvio, `${estado} a ${width}px`).toBeLessThanOrEqual(1);
expect(geometria.desborde).toBe(0);
}
}
await page.getByRole('button', { name: 'Limpiar prueba', exact: true }).click();
});
});

@ -0,0 +1,45 @@
import { expect, test } from '@playwright/test';
test.describe('Franjas por emociones y etiquetas', () => {
test.skip(process.env.E2E_JUKEBOX !== '1', 'La galería solo existe en desarrollo.');
test('selecciona etiquetas, muestra la cola filtrada y bloquea una selección vacía', async ({
page
}) => {
await page.goto('/__diseno/jukebox');
await page.locator('html[data-hidratado]').waitFor();
await page.getByRole('button', { name: 'Emociones y etiquetas', exact: true }).click();
await expect(page.getByRole('button', { name: 'Guardar jukebox', exact: true })).toBeDisabled();
await expect(page.getByRole('button', { name: 'Guardar franja', exact: true })).toBeDisabled();
await page.getByRole('checkbox', { name: 'Deseo', exact: true }).check();
await page.getByRole('checkbox', { name: 'Despedidas', exact: true }).check();
await page.getByText('Ver canciones de la selección', { exact: true }).click();
const selector = page.getByRole('region', { name: 'Selección de emociones y etiquetas' });
const titulos = await selector.locator('details li').allTextContents();
expect(titulos.length).toBeGreaterThan(0);
expect(new Set(titulos).size).toBe(titulos.length);
await expect(page.getByRole('button', { name: 'Guardar jukebox', exact: true })).toBeEnabled();
await page.getByRole('button', { name: 'Probar selección', exact: true }).click();
await page.getByRole('button', { name: 'Ver la cola de reproducción', exact: true }).click();
const cola = page.getByRole('complementary', { name: 'Cola de reproducción' });
for (const titulo of titulos) await expect(cola).toContainText(titulo);
await expect
.poll(() =>
page.getByRole('slider', { name: 'Posición de reproducción' }).inputValue().then(Number)
)
.toBeGreaterThan(0);
await page.getByRole('button', { name: 'Detener prueba', exact: true }).click();
for (const width of [1440, 1024, 768, 390]) {
await page.setViewportSize({ width, height: 1000 });
expect(
await page.evaluate(
() => document.documentElement.scrollWidth - document.documentElement.clientWidth
)
).toBe(0);
}
await page.getByRole('button', { name: 'Quitar selección', exact: true }).click();
await expect(
page.getByRole('button', { name: 'Probar selección', exact: true })
).toBeDisabled();
await expect(page.getByRole('button', { name: 'Guardar franja', exact: true })).toBeDisabled();
});
});

@ -117,7 +117,7 @@ test.describe('Programación autenticada del Juke-box', () => {
await expect(page.locator('.horarios')).toContainText('Todo el catálogo'); await expect(page.locator('.horarios')).toContainText('Todo el catálogo');
await expect(page.locator('.horarios')).toContainText('Aleatoria'); await expect(page.locator('.horarios')).toContainText('Aleatoria');
await page.getByRole('link', { name: 'Abrir reproductor de Mañanas', exact: true }).click(); 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: 'Elegir Jukebox', exact: true }).click();
await page.getByRole('button', { name: 'Reproducir Mañanas', exact: true }).click(); await page.getByRole('button', { name: 'Reproducir Mañanas', exact: true }).click();
await expect(page.locator('.estado-jukebox')).toContainText('Mañanas'); await expect(page.locator('.estado-jukebox')).toContainText('Mañanas');
const alturasAcciones = await page.locator('.acciones-heroe').evaluate((fila) => const alturasAcciones = await page.locator('.acciones-heroe').evaluate((fila) =>
@ -143,21 +143,27 @@ test.describe('Programación autenticada del Juke-box', () => {
canciones[0].titulo canciones[0].titulo
); );
await page.getByRole('link', { name: 'Abrir reproductor de Tardes', exact: true }).click(); 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: 'Elegir Jukebox', exact: true }).click();
await page.getByRole('button', { name: 'Reproducir Tardes', exact: true }).click(); await page.getByRole('button', { name: 'Reproducir Tardes', exact: true }).click();
await expect(page.locator('.estado-jukebox')).toContainText('Tardes'); await expect(page.locator('.estado-jukebox')).toContainText('Tardes');
await page.getByRole('button', { name: 'Detener Jukebox', exact: true }).click(); await page
.locator('.estado-jukebox')
.getByRole('button', { name: 'Detener Jukebox', exact: true })
.click();
await expect(page.locator('.estado-jukebox')).toContainText('Tardes'); await expect(page.locator('.estado-jukebox')).toContainText('Tardes');
await expect(page.locator('.estado-jukebox')).toContainText('Detenido'); await expect(page.locator('.estado-jukebox')).toContainText('Detenido');
expect((await page.request.get('/juke-box/' + segundo + '/programacion')).status()).toBe(200); expect((await page.request.get('/juke-box/' + segundo + '/programacion')).status()).toBe(200);
await page.goto('/juke-box'); await page.goto('/juke-box');
await page.locator('html[data-hidratado]').waitFor(); await page.locator('html[data-hidratado]').waitFor();
await page.getByRole('button', { name: 'Play JukeBox', exact: true }).click(); await page.getByRole('button', { name: 'Elegir Jukebox', exact: true }).click();
await expect(page.getByRole('list', { name: 'Tus jukebox' })).toBeVisible(); await expect(page.getByRole('list', { name: 'Tus jukebox' })).toBeVisible();
await page.getByRole('button', { name: 'Reproducir Mañanas', exact: true }).click(); await page.getByRole('button', { name: 'Reproducir Mañanas', exact: true }).click();
await expect(page.getByRole('list', { name: 'Tus jukebox' })).not.toBeVisible(); await expect(page.getByRole('list', { name: 'Tus jukebox' })).not.toBeVisible();
await expect(page.locator('.estado-jukebox')).toContainText('Mañanas'); await expect(page.locator('.estado-jukebox')).toContainText('Mañanas');
await page.getByRole('button', { name: 'Detener Jukebox', exact: true }).click(); await page
.locator('.estado-jukebox')
.getByRole('button', { name: 'Detener Jukebox', exact: true })
.click();
}); });
test('mantiene borradores con el historial y permite cancelar un jukebox nuevo sin crearlo', async ({ test('mantiene borradores con el historial y permite cancelar un jukebox nuevo sin crearlo', async ({
page page
@ -281,7 +287,7 @@ test.describe('Programación autenticada del Juke-box', () => {
.locator('.entradilla span') .locator('.entradilla span')
.evaluate((s) => parseFloat(getComputedStyle(s).fontSize)) .evaluate((s) => parseFloat(getComputedStyle(s).fontSize))
).toBeGreaterThan(24); ).toBeGreaterThan(24);
await page.getByRole('button', { name: 'Play JukeBox', exact: true }).click(); await page.getByRole('button', { name: 'Elegir Jukebox', exact: true }).click();
await expect(page.getByRole('list', { name: 'Tus jukebox' })).toContainText( await expect(page.getByRole('list', { name: 'Tus jukebox' })).toContainText(
'Música para empezar el día.' 'Música para empezar el día.'
); );
@ -304,7 +310,7 @@ test.describe('Programación autenticada del Juke-box', () => {
) )
); );
await page.screenshot({ path: 'test-results-publica/jukebox-hero-100vh-claro.png' }); await page.screenshot({ path: 'test-results-publica/jukebox-hero-100vh-claro.png' });
await page.getByRole('button', { name: 'Play JukeBox', exact: true }).click(); await page.getByRole('button', { name: 'Elegir Jukebox', exact: true }).click();
await page.screenshot({ path: 'test-results-publica/jukebox-desplegable-claro.png' }); await page.screenshot({ path: 'test-results-publica/jukebox-desplegable-claro.png' });
await page.keyboard.press('Escape'); await page.keyboard.press('Escape');
await page.setViewportSize({ width: 390, height: 844 }); await page.setViewportSize({ width: 390, height: 844 });
@ -398,7 +404,7 @@ test.describe('Programación autenticada del Juke-box', () => {
await page.clock.setFixedTime(new Date('2026-09-14T08:59:50Z')); await page.clock.setFixedTime(new Date('2026-09-14T08:59:50Z'));
await page.goto('/juke-box?jukebox=' + primero); await page.goto('/juke-box?jukebox=' + primero);
await page.locator('html[data-hidratado]').waitFor(); await page.locator('html[data-hidratado]').waitFor();
await page.getByRole('button', { name: 'Play JukeBox', exact: true }).click(); await page.getByRole('button', { name: 'Elegir Jukebox', exact: true }).click();
await page.getByRole('button', { name: 'Reproducir Mañanas', 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'); await expect(page.locator('.estado-jukebox')).toContainText('Esperando próxima franja');
const respuesta = page.waitForResponse( const respuesta = page.waitForResponse(
@ -425,7 +431,7 @@ test.describe('Programación autenticada del Juke-box', () => {
await expect(page.locator('.control-jukebox')).toContainText('Esperando próxima franja'); 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('region', { name: 'Reproductor', exact: true })).toBeVisible();
await expect(page.getByRole('group', { name: 'Sonando ahora', exact: true })).toHaveCount(0); await expect(page.getByRole('group', { name: 'Sonando ahora', exact: true })).toHaveCount(0);
await page.getByRole('button', { name: 'Detener', exact: true }).click(); await page.getByRole('button', { name: 'Desactivar Jukebox', exact: true }).click();
const control = page.getByRole('group', { name: 'Jukebox', exact: true }); const control = page.getByRole('group', { name: 'Jukebox', exact: true });
const barra = page.getByRole('region', { name: 'Reproductor', exact: true }); const barra = page.getByRole('region', { name: 'Reproductor', exact: true });
await expect(control).toContainText('Mañanas'); await expect(control).toContainText('Mañanas');
@ -443,7 +449,7 @@ test.describe('Programación autenticada del Juke-box', () => {
await expect await expect
.poll(() => audio.evaluate((a: HTMLAudioElement) => !a.paused && a.currentTime > 0.05)) .poll(() => audio.evaluate((a: HTMLAudioElement) => !a.paused && a.currentTime > 0.05))
.toBe(true); .toBe(true);
await control.getByRole('button', { name: 'Detener', exact: true }).click(); await control.getByRole('button', { name: 'Detener Jukebox', exact: true }).click();
await expect(control).toContainText('Detenido'); await expect(control).toContainText('Detenido');
await expect(barra.getByRole('group', { name: 'Sonando ahora', exact: true })).toHaveCount(0); await expect(barra.getByRole('group', { name: 'Sonando ahora', exact: true })).toHaveCount(0);
expect(await audio.evaluate((a: HTMLAudioElement) => a.paused)).toBe(true); expect(await audio.evaluate((a: HTMLAudioElement) => a.paused)).toBe(true);
@ -473,7 +479,7 @@ test.describe('Programación autenticada del Juke-box', () => {
}); });
await page.goto('/juke-box?jukebox=' + primero); await page.goto('/juke-box?jukebox=' + primero);
await page.locator('html[data-hidratado]').waitFor(); await page.locator('html[data-hidratado]').waitFor();
await page.getByRole('button', { name: 'Play JukeBox', exact: true }).click(); await page.getByRole('button', { name: 'Elegir Jukebox', exact: true }).click();
await page.getByRole('button', { name: 'Reproducir Mañanas', exact: true }).click(); await page.getByRole('button', { name: 'Reproducir Mañanas', exact: true }).click();
const barra = page.getByRole('region', { name: 'Reproductor', exact: true }); const barra = page.getByRole('region', { name: 'Reproductor', exact: true });
const control = barra.getByRole('group', { name: 'Jukebox', exact: true }); const control = barra.getByRole('group', { name: 'Jukebox', exact: true });
@ -567,7 +573,7 @@ test.describe('Programación autenticada del Juke-box', () => {
await barra.screenshot({ path: `test-results-publica/jukebox-continuar-foco-${tema}.png` }); await barra.screenshot({ path: `test-results-publica/jukebox-continuar-foco-${tema}.png` });
} }
if (tema === 'movil') { if (tema === 'movil') {
expect((await barra.boundingBox())!.height).toBeLessThanOrEqual(170); expect((await barra.boundingBox())!.height).toBeLessThanOrEqual(240);
await expect( await expect(
barra.getByRole('button', { name: 'Tema anterior', exact: true }) barra.getByRole('button', { name: 'Tema anterior', exact: true })
).toBeInViewport(); ).toBeInViewport();
@ -689,7 +695,7 @@ test.describe('Programación autenticada del Juke-box', () => {
await page.setViewportSize({ width: 1440, height: 1000 }); await page.setViewportSize({ width: 1440, height: 1000 });
await page.getByRole('link', { name: 'Volver al Juke-box', exact: true }).click(); await page.getByRole('link', { name: 'Volver al Juke-box', exact: true }).click();
await expect(page).toHaveURL('/juke-box?jukebox=' + primero); await expect(page).toHaveURL('/juke-box?jukebox=' + primero);
await control.getByRole('button', { name: 'Detener', exact: true }).click(); await control.getByRole('button', { name: 'Desactivar Jukebox', exact: true }).click();
await expect(barra).toBeVisible(); await expect(barra).toBeVisible();
await expect(control).toContainText('Detenido'); await expect(control).toContainText('Detenido');
}); });

@ -540,28 +540,18 @@ test.describe('Página de música', () => {
await expect(page.locator('.texto[lang="fr"]')).toBeVisible(); await expect(page.locator('.texto[lang="fr"]')).toBeVisible();
}); });
test('la valoración y las acciones personales están junto al título', async ({ page }) => { test('los likes y las acciones personales están junto al título', async ({ page }) => {
await page.goto('/canciones/ton-silence-me-libere'); await page.goto('/canciones/ton-silence-me-libere');
const cabecera = page.locator('header.ficha'); const cabecera = page.locator('header.ficha');
const valoracion = cabecera.getByRole('region', { name: 'Valoración de la canción' }); const likes = cabecera.locator('.me-gusta');
await expect(valoracion).toBeVisible(); await expect(likes.getByRole('link', { name: /Me gusta:/ })).toBeVisible();
await expect(valoracion.getByRole('heading')).toHaveCount(0); await expect(likes.locator('.contador')).toHaveText(/^\d+$/);
await expect(valoracion).not.toContainText(/valoración|de 5|votos?|quitar mi voto/i); await expect(cabecera.locator('.estrellas')).toHaveCount(0);
const familia = await likes
const numeroValoracion = valoracion.locator('.media'); .locator('.contador')
const estrellas = await valoracion.locator('.estrellas').boundingBox(); .evaluate((e) => getComputedStyle(e).fontFamily);
const puntuacion = await numeroValoracion.boundingBox(); expect(familia).toContain('Archivo');
expect(estrellas).not.toBeNull();
expect(puntuacion).not.toBeNull();
const centroEstrellas = (estrellas?.y ?? 0) + (estrellas?.height ?? 0) / 2;
const centroPuntuacion = (puntuacion?.y ?? 0) + (puntuacion?.height ?? 0) / 2;
expect(Math.abs(centroEstrellas - centroPuntuacion)).toBeLessThan(2);
const familiaNumero = await numeroValoracion.evaluate(
(elemento) => getComputedStyle(elemento).fontFamily
);
expect(familiaNumero).toContain('Archivo');
expect(familiaNumero).not.toContain('Newsreader');
const favorito = cabecera.getByRole('link', { name: /añadir a favoritos/i }); const favorito = cabecera.getByRole('link', { name: /añadir a favoritos/i });
const lista = cabecera.getByRole('link', { name: /añadir a una lista de reproducción/i }); const lista = cabecera.getByRole('link', { name: /añadir a una lista de reproducción/i });

@ -215,7 +215,7 @@ test('cada canción muestra sus versiones y recomendaciones en una fila horizont
).toBe(true); ).toBe(true);
}); });
test('favoritos, listas y valoración abren el acceso como popup sin abandonar la canción', async ({ test('favoritos, listas y likes abren el acceso como popup sin abandonar la canción', async ({
page page
}) => { }) => {
const ruta = '/canciones/cuando-tus-manos-me-tocan'; const ruta = '/canciones/cuando-tus-manos-me-tocan';
@ -234,9 +234,9 @@ test('favoritos, listas y valoración abren el acceso como popup sin abandonar l
'Necesitas entrar para añadir esta canción a una lista de reproducción o crear una nueva.' 'Necesitas entrar para añadir esta canción a una lista de reproducción o crear una nueva.'
}, },
{ {
nombre: /inicia sesión para valorar/i, nombre: /Me gusta: .*Inicia sesión/i,
motivo: motivo:
'Necesitas entrar para valorar esta canción. Así cada persona puede votar una sola vez.' 'Necesitas entrar para dar Me gusta a esta canción. Cada cuenta puede dar un like y quitarlo cuando quiera.'
} }
]) { ]) {
await ficha.getByRole('link', { name: nombre }).click(); await ficha.getByRole('link', { name: nombre }).click();

@ -122,7 +122,7 @@ test.describe('Modal de acceso', () => {
const dialogo = page.locator('dialog.acceso'); const dialogo = page.locator('dialog.acceso');
await expect(dialogo).toBeVisible(); await expect(dialogo).toBeVisible();
await expect( await expect(
dialogo.getByText('Entra para acceder a tus compras, favoritos, listas y valoraciones.') dialogo.getByText('Entra para acceder a tus compras, favoritos, listas y likes.')
).toBeVisible(); ).toBeVisible();
// Sigue siendo /musica: entrar no debería sacarte de donde estabas. // Sigue siendo /musica: entrar no debería sacarte de donde estabas.
await expect(page).toHaveURL(/\/musica$/); await expect(page).toHaveURL(/\/musica$/);

@ -389,7 +389,7 @@ await permite('y quien administra, sí', `update usuario set rol = 'admin' where
apuntar('\nLA VALORACIÓN'); apuntar('\nLA VALORACIÓN');
await rechaza( await rechaza(
'no se puede puntuar con un seis', 'no se puede puntuar con un seis',
`insert into valoracion (usuario_id, cancion_id, puntuacion) `insert into valoracion (usuario_id, cancion_id, "value")
values ('u9', 'c1', 6)` values ('u9', 'c1', 6)`
); );

@ -0,0 +1,417 @@
/** Genera el diccionario del modelo desde el esquema PostgreSQL realmente aplicado. */
import fs from 'node:fs/promises';
import pg from 'pg';
import { format } from 'prettier';
if (!process.env.DATABASE_URL) throw new Error('DATABASE_URL is not set');
const salida = process.argv[2] ?? 'docs/modelo-datos.md';
const cliente = new pg.Client({
connectionString: process.env.DATABASE_URL,
query_timeout: 120_000
});
const dominios = [
[
'Base y artistas',
['pais', 'medio', 'persona', 'artista', 'artista_persona', 'artista_identificador']
],
[
'Catálogo musical',
[
'estilo',
'album',
'cancion',
'cancion_interprete',
'cancion_audio',
'traduccion',
'cancion_persona',
'registro_obra',
'autoria',
'video',
'cancion_enlace',
'album_enlace',
'credito_album',
'etiqueta',
'cancion_etiqueta'
]
],
[
'Cuentas y suscripciones',
['usuario', 'plan_suscripcion', 'suscripcion', 'identidad', 'codigo_acceso', 'sesion', 'limite']
],
[
'Interacción y escucha',
[
'favorito',
'lista_reproduccion',
'lista_reproduccion_cancion',
'acceso_cancion_usuario',
'valoracion',
'reproduccion'
]
],
[
'Comercio',
[
'carrito',
'carrito_item',
'producto',
'carrito_producto',
'pedido',
'pedido_item',
'pedido_linea',
'compra',
'intencion_pago'
]
],
['Facturación', ['datos_facturacion', 'contador_factura', 'factura', 'descarga']],
[
'Editorial y legal',
[
'entrada',
'entrada_cancion',
'entrada_etiqueta',
'pagina',
'documento_legal',
'version_legal',
'redireccion'
]
],
[
'Taller de letras',
[
'genero',
'genero_origen',
'genero_pais',
'genero_seccion',
'genero_alias',
'genero_tema',
'cancion_genero',
'metro',
'genero_metro',
'compas',
'genero_compas',
'instrumento',
'genero_instrumento',
'genero_persona',
'obra_referencia',
'obra_referencia_persona',
'obra_referencia_enlace',
'verso',
'verso_linea',
'capitulo',
'termino',
'acepcion',
'termino_variante',
'capitulo_acepcion',
'modelo_ia',
'animo',
'prompt'
]
],
['Jukebox', ['programacion_jukebox', 'jukebox']]
];
const explicaciones = {
valoracion:
'Interacción unificada por cuenta y canción. `like` alimenta el contador público; `value` conserva la puntuación de 1 a 5 para un uso profesional posterior. Son independientes.',
cancion:
'Tema principal o versión musical. `version_de_id` apunta a otra canción y separa obra principal de arreglo o grabación.',
medio:
'Archivo administrado: imagen, audio, muestra o documento, con metadatos técnicos e integridad SHA-256.',
jukebox: 'Programación independiente y versionada de una cuenta.',
programacion_jukebox:
'Programación histórica por cuenta, conservada por compatibilidad con el modelo anterior de un único Jukebox.',
compra: 'Permiso materializado de descarga de una canción adquirida.',
factura: 'Documento fiscal inmutable; una rectificativa referencia otra factura.',
producto: 'Oferta comercial genérica; sus datos específicos se conservan en JSON.',
prompt:
'Prompt probado para un modelo de IA, género, ánimo y, opcionalmente, una canción resultante.'
};
const celda = (valor) =>
String(valor ?? '')
.replaceAll('|', '\\|')
.replaceAll('\n', ' ');
const codigo = (valor) => `\`${celda(valor).replaceAll('`', '\\`')}\``;
const nombreDominio = (tabla) =>
dominios.find(([, tablas]) => tablas.includes(tabla))?.[0] ?? 'Otras estructuras';
await cliente.connect();
await cliente.query('BEGIN READ ONLY');
try {
// Una transacción pertenece a una sola conexión: las consultas se ejecutan en
// orden para no solaparlas sobre el mismo cliente de PostgreSQL.
const { rows: objetos } =
await cliente.query(`select c.relname as nombre, c.relkind, obj_description(c.oid, 'pg_class') as comentario,
case when c.relkind in ('v','m') then pg_get_viewdef(c.oid,true) end as definicion
from pg_class c join pg_namespace n on n.oid=c.relnamespace
where n.nspname='public' and c.relkind in ('r','p','v','m') order by c.relkind, c.relname`);
const { rows: columnas } =
await cliente.query(`select c.relname as tabla, a.attnum as posicion, a.attname as nombre,
pg_catalog.format_type(a.atttypid,a.atttypmod) as tipo, not a.attnotnull as nullable,
pg_get_expr(ad.adbin,ad.adrelid) as defecto, col_description(c.oid,a.attnum) as comentario
from pg_attribute a join pg_class c on c.oid=a.attrelid join pg_namespace n on n.oid=c.relnamespace
left join pg_attrdef ad on ad.adrelid=a.attrelid and ad.adnum=a.attnum
where n.nspname='public' and c.relkind in ('r','p','v','m') and a.attnum>0 and not a.attisdropped
order by c.relname,a.attnum`);
const { rows: restricciones } =
await cliente.query(`select c.relname as tabla, con.conname as nombre, con.contype as tipo,
pg_get_constraintdef(con.oid,true) as definicion
from pg_constraint con join pg_class c on c.oid=con.conrelid join pg_namespace n on n.oid=c.relnamespace
where n.nspname='public' order by c.relname, con.contype, con.conname`);
const { rows: indices } =
await cliente.query(`select tablename as tabla,indexname as nombre,indexdef as definicion
from pg_indexes where schemaname='public' order by tablename,indexname`);
const { rows: disparadores } =
await cliente.query(`select c.relname as tabla,t.tgname as nombre,pg_get_triggerdef(t.oid,true) as definicion
from pg_trigger t join pg_class c on c.oid=t.tgrelid join pg_namespace n on n.oid=c.relnamespace
where n.nspname='public' and not t.tgisinternal order by c.relname,t.tgname`);
const { rows: secuencias } =
await cliente.query(`select sequencename as nombre,data_type,start_value,increment_by,min_value,max_value,cycle
from pg_sequences where schemaname='public' order by sequencename`);
const { rows: enumeraciones } =
await cliente.query(`select t.typname as tipo,e.enumlabel as valor,e.enumsortorder as orden
from pg_type t join pg_enum e on e.enumtypid=t.oid join pg_namespace n on n.oid=t.typnamespace
where n.nspname='public' order by t.typname,e.enumsortorder`);
const { rows: funciones } = await cliente.query(`select p.proname as nombre,
pg_get_function_identity_arguments(p.oid) as argumentos,
pg_get_function_result(p.oid) as resultado,l.lanname as lenguaje
from pg_proc p join pg_namespace n on n.oid=p.pronamespace join pg_language l on l.oid=p.prolang
where n.nspname='public' and p.prokind='f' order by p.proname,argumentos`);
const { rows: migraciones } = await cliente.query(
`select count(*)::int as total,max(created_at)::text as ultima from drizzle.__drizzle_migrations`
);
const tablas = objetos.filter((o) => ['r', 'p'].includes(o.relkind));
const vistas = objetos.filter((o) => ['v', 'm'].includes(o.relkind));
const lineas = [];
lineas.push('# Modelo de datos completo de Senza Paura', '');
lineas.push(
`Generado el ${new Date().toISOString()} desde el esquema PostgreSQL aplicado y contrastado con los contratos TypeScript de la aplicación.`,
''
);
lineas.push(
`**Alcance:** ${tablas.length} tablas, ${vistas.length} ${vistas.length === 1 ? 'vista' : 'vistas'}, ${secuencias.length} secuencias, ${funciones.length} funciones de base y ${migraciones[0]?.total ?? 0} migraciones aplicadas.`,
''
);
lineas.push(
'## Fuentes y convenciones',
'',
'- Esquema declarativo: `src/lib/server/db/schema/`.',
'- Historial físico: `drizzle/`.',
'- Campos TypeScript en `camelCase`; nombres PostgreSQL en `snake_case`.',
'- `text` es el identificador opaco habitual. `pais.codigo` es la excepción: usa ISO 3166-1 alfa-2.',
'- Los importes monetarios son enteros en céntimos y la moneda usa código ISO en minúsculas.',
'- Las marcas de tiempo son `timestamp with time zone`.',
'- Los estados editoriales comunes son `borrador`, `publicado` y `retirado`.',
''
);
lineas.push(
'## Vista conceptual',
'',
'```mermaid',
'flowchart LR',
' BASE[Base y artistas] --> CAT[Catálogo musical]',
' CAT --> INT[Interacción y escucha]',
' CTA[Cuentas y suscripciones] --> INT',
' CTA --> COM[Comercio]',
' CAT --> COM',
' COM --> FAC[Facturación]',
' CAT --> EDI[Editorial y legal]',
' BASE --> TALLER[Taller de letras]',
' CAT --> TALLER',
' CTA --> JUKE[Jukebox]',
' CAT --> JUKE',
'```',
''
);
lineas.push(
'La entidad central es `cancion`. Se relaciona con álbumes, estilos, intérpretes, audios, autoría, etiquetas, ventas, descargas, listas, reproducciones, likes/valoraciones, entradas editoriales, géneros del taller y prompts.',
''
);
lineas.push(
'## Reglas que definen el modelo',
'',
'- Una versión musical es otra fila de `cancion`; `version_de_id` apunta a la canción principal. Una versión no puede venderse por separado.',
'- `valoracion` tiene una sola fila por `(usuario_id, cancion_id)`: `like` y `value` no se pisan. Quitar un like conserva `value` y `date`.',
'- `artista` es la identidad pública; `persona` es la persona física o acreditada. `artista_persona` permite solistas, grupos y proyectos.',
'- `compra` materializa el permiso de descarga. El pedido conserva el hecho comercial; la factura conserva el hecho fiscal.',
'- Una factura emitida no se reescribe: las correcciones se modelan como facturas rectificativas.',
'- La programación del Jukebox vive como franjas JSON versionadas mediante `revision` para control de concurrencia.',
'- El taller usa vocabularios normalizados y tablas puente para géneros, países, metros, compases, instrumentos, figuras, obras, versos y glosario.',
''
);
lineas.push(
'## Relaciones principales',
'',
'```mermaid',
'erDiagram',
' USUARIO ||--o{ VALORACION : emite',
' CANCION ||--o{ VALORACION : recibe',
' USUARIO ||--o{ FAVORITO : guarda',
' USUARIO ||--o{ LISTA_REPRODUCCION : crea',
' LISTA_REPRODUCCION ||--o{ LISTA_REPRODUCCION_CANCION : contiene',
' CANCION ||--o{ LISTA_REPRODUCCION_CANCION : aparece',
' ESTILO ||--o{ ALBUM : agrupa',
' ESTILO ||--o{ CANCION : clasifica',
' ALBUM ||--o{ CANCION : publica',
' CANCION ||--o{ CANCION_AUDIO : dispone',
' MEDIO ||--o{ CANCION_AUDIO : almacena',
' ARTISTA ||--o{ CANCION_INTERPRETE : interpreta',
' CANCION ||--o{ CANCION_INTERPRETE : acredita',
' USUARIO ||--o{ PEDIDO : realiza',
' PEDIDO ||--o{ PEDIDO_LINEA : contiene',
' PEDIDO ||--o{ FACTURA : documenta',
' USUARIO ||--o{ JUKEBOX : configura',
'```',
''
);
lineas.push(
'## Contratos JSON de la aplicación',
'',
'### Contenido editorial por bloques',
'',
'`artista.secciones`, `album.secciones`, `cancion.inspiracion_secciones` y `entrada.secciones` contienen una lista ordenada de bloques:',
'',
'- Base: `{ id, tipo, titulo? }`.',
'- Texto: `{ tipo: "texto", texto }`.',
'- Código: `{ tipo: "codigo", codigo, lenguaje? }`.',
'- Medio: `{ tipo: "video" | "imagen" | "audio", url, pie?, subtitulos?, alt? }`.',
'',
'### Presentación editorial',
'',
'`artista.presentacion`, `album.presentacion`, `cancion.presentacion` y `entrada.presentacion` usan `{ plantilla, heroe }`, donde `heroe` es `automatico`, `visible` u `oculto`.',
'',
'### Jukebox',
'',
'`jukebox.franjas` y `programacion_jukebox.franjas` contienen objetos `{ id, inicio, fin, orden, catalogo, etiquetas?, contenidos }`. `orden` es `secuencial` o `aleatoria`; `contenidos` referencia listas o canciones mediante `{ tipo, id }`; varias etiquetas se combinan como unión.',
'',
'### Comercio abierto',
'',
'`producto.datos`, `pedido_linea.datos`, `intencion_pago.datos` y `factura.datos_congelados` conservan atributos variables o una instantánea del momento comercial. Los campos consultables y las cantidades monetarias permanecen normalizados en columnas.',
''
);
for (const [dominio] of dominios.concat([['Otras estructuras', []]])) {
const delDominio = tablas.filter((t) => nombreDominio(t.nombre) === dominio);
if (!delDominio.length) continue;
lineas.push(`## ${dominio}`, '');
for (const tabla of delDominio) {
lineas.push(`### ${codigo(tabla.nombre)}`, '');
if (explicaciones[tabla.nombre]) lineas.push(explicaciones[tabla.nombre], '');
lineas.push(
'| Columna | Tipo PostgreSQL | Nulo | Valor por defecto |',
'|---|---|:---:|---|'
);
for (const col of columnas.filter((c) => c.tabla === tabla.nombre)) {
lineas.push(
`| ${codigo(col.nombre)} | ${codigo(col.tipo)} | ${col.nullable ? 'sí' : 'no'} | ${col.defecto ? codigo(col.defecto) : '—'} |`
);
}
lineas.push('');
const cons = restricciones.filter((c) => c.tabla === tabla.nombre);
if (cons.length) {
lineas.push('Claves y restricciones:', '');
for (const con of cons) {
const clase =
{ p: 'PK', f: 'FK', u: 'UNIQUE', c: 'CHECK', x: 'EXCLUDE' }[con.tipo] ?? con.tipo;
lineas.push(`- **${clase} ${celda(con.nombre)}:** ${codigo(con.definicion)}`);
}
lineas.push('');
}
const idx = indices.filter(
(i) => i.tabla === tabla.nombre && !cons.some((c) => c.nombre === i.nombre)
);
if (idx.length)
lineas.push(`Índices adicionales: ${idx.map((i) => codigo(i.nombre)).join(', ')}.`, '');
const trg = disparadores.filter((t) => t.tabla === tabla.nombre);
if (trg.length) {
lineas.push('Disparadores:', '');
for (const t of trg) lineas.push(`- ${codigo(t.nombre)}: ${codigo(t.definicion)}`);
lineas.push('');
}
}
}
lineas.push('## Vistas', '');
if (!vistas.length) lineas.push('No hay vistas en el esquema público.', '');
for (const vista of vistas) {
lineas.push(
`### ${codigo(vista.nombre)}`,
'',
'| Columna | Tipo PostgreSQL | Nulo |',
'|---|---|:---:|'
);
for (const col of columnas.filter((c) => c.tabla === vista.nombre))
lineas.push(
`| ${codigo(col.nombre)} | ${codigo(col.tipo)} | ${col.nullable ? 'sí' : 'no'} |`
);
lineas.push('', 'Definición:', '', '```sql', vista.definicion, '```', '');
}
lineas.push('## Enumeraciones PostgreSQL', '');
if (!enumeraciones.length) lineas.push('No hay tipos enumerados en el esquema público.', '');
for (const tipo of [...new Set(enumeraciones.map((e) => e.tipo))]) {
lineas.push(
`- ${codigo(tipo)}: ${enumeraciones
.filter((e) => e.tipo === tipo)
.map((e) => codigo(e.valor))
.join(', ')}.`
);
}
lineas.push(
'',
'Los demás vocabularios cerrados se almacenan como `text` y aparecen en las restricciones `CHECK` de cada tabla.',
''
);
lineas.push(
'## Funciones de base de datos',
'',
'| Función | Argumentos | Resultado | Lenguaje |',
'|---|---|---|---|'
);
for (const f of funciones)
lineas.push(
`| ${codigo(f.nombre)} | ${f.argumentos ? codigo(f.argumentos) : '—'} | ${codigo(f.resultado)} | ${codigo(f.lenguaje)} |`
);
lineas.push(
'',
'Las funciones asociadas a disparadores se localizan además bajo la tabla afectada, en el apartado “Disparadores”.',
''
);
lineas.push(
'## Secuencias',
'',
'| Secuencia | Tipo | Inicio | Incremento | Ciclo |',
'|---|---|---:|---:|:---:|'
);
for (const s of secuencias)
lineas.push(
`| ${codigo(s.nombre)} | ${codigo(s.data_type)} | ${s.start_value} | ${s.increment_by} | ${s.cycle ? 'sí' : 'no'} |`
);
lineas.push(
'',
'## Mantenimiento',
'',
'Este documento se regenera desde la base aplicada:',
'',
'```powershell',
'node --env-file=.env scripts/db/documentar-modelo.mjs',
'```',
'',
'Después de una migración, regénéralo y revisa también los contratos JSON si cambió su estructura TypeScript.',
''
);
const markdown = await format(`${lineas.join('\n')}\n`, { parser: 'markdown' });
await fs.writeFile(salida, markdown, 'utf8');
console.log(
`${salida}: ${tablas.length} tablas, ${vistas.length} ${vistas.length === 1 ? 'vista' : 'vistas'}, ${columnas.length} columnas.`
);
} finally {
await cliente.query('ROLLBACK').catch(() => {});
await cliente.end();
}

@ -1,6 +1,7 @@
@import './fuentes.css'; @import './fuentes.css';
@import './marca.css'; @import './marca.css';
@import './tokens.css'; @import './tokens.css';
@import './controles.css';
/* /*
* --------------------------------------------------------------------------- * ---------------------------------------------------------------------------
* DIRECCIÓN VISUAL * DIRECCIÓN VISUAL
@ -59,6 +60,11 @@
/* Los dos extremos del reflejo: la luz que lo cruza y la sombra del canto. */ /* Los dos extremos del reflejo: la luz que lo cruza y la sombra del canto. */
--c-accent-brillo: var(--c-marca-oro-brillo); --c-accent-brillo: var(--c-marca-oro-brillo);
--c-accent-sombra: var(--c-marca-oro-sombra); --c-accent-sombra: var(--c-marca-oro-sombra);
--c-accent-borde: var(--c-accent-sombra);
/* Los controles finos necesitan contraste incluso en la zona de brillo. */
--c-control-oro: var(--c-marca-oro);
--c-control-oro-brillo: var(--c-marca-oro-brillo);
--c-control-oro-sombra: var(--c-marca-oro-sombra);
/* /*
* El verdemar: el tercer color de la paleta, para datos. * El verdemar: el tercer color de la paleta, para datos.
@ -116,23 +122,22 @@
/* /*
* El mismo metal, orientado de izquierda a derecha para controles lineales: * El mismo metal, orientado de izquierda a derecha para controles lineales:
* barras de progreso, volumen y barras de desplazamiento. Usar los tres * barras de progreso, volumen y barras de desplazamiento. Esta gama se
* tonos fijos de la marca evita que estos controles parezcan amarillos o * oscurece en claro para que incluso el reflejo se distinga del carril.
* grises del navegador.
*/ */
--oro-control: linear-gradient( --oro-control: linear-gradient(
90deg, 90deg,
var(--c-marca-oro-sombra) 0%, var(--c-control-oro-sombra) 0%,
var(--c-marca-oro) 32%, var(--c-control-oro) 32%,
var(--c-marca-oro-brillo) 52%, var(--c-control-oro-brillo) 52%,
var(--c-marca-oro) 72%, var(--c-control-oro) 72%,
var(--c-marca-oro-sombra) 100% var(--c-control-oro-sombra) 100%
); );
--oro-control-pulgar: radial-gradient( --oro-control-pulgar: radial-gradient(
circle at 34% 28%, circle at 34% 28%,
var(--c-marca-oro-brillo) 0 12%, var(--c-control-oro-brillo) 0 12%,
var(--c-marca-oro) 46%, var(--c-control-oro) 46%,
var(--c-marca-oro-sombra) 100% var(--c-control-oro-sombra) 100%
); );
/* /*
@ -208,11 +213,15 @@
--c-text: oklch(0.24 0.055 264); --c-text: oklch(0.24 0.055 264);
--c-text-muted: oklch(0.48 0.038 264); --c-text-muted: oklch(0.48 0.038 264);
--c-accent: oklch(0.52 0.125 74); --c-accent: oklch(0.42 0.135 70);
--c-accent-superficie: oklch(0.76 0.13 82); --c-accent-superficie: oklch(0.76 0.13 82);
--c-accent-text: oklch(0.24 0.055 264); --c-accent-text: oklch(0.24 0.055 264);
--c-accent-brillo: oklch(0.9 0.1 92); --c-accent-brillo: oklch(0.9 0.1 92);
--c-accent-sombra: oklch(0.6 0.12 74); --c-accent-sombra: oklch(0.63 0.12 74);
--c-accent-borde: var(--c-accent);
--c-control-oro: oklch(0.46 0.1 78);
--c-control-oro-brillo: oklch(0.58 0.11 78);
--c-control-oro-sombra: oklch(0.4 0.09 78);
/* En claro se invierten: el dato pintado tiene que ser oscuro sobre papel. */ /* En claro se invierten: el dato pintado tiene que ser oscuro sobre papel. */
--c-dato: oklch(0.5 0.085 200); --c-dato: oklch(0.5 0.085 200);
@ -253,13 +262,12 @@ body {
/* /*
* Los desplazamientos forman parte de la interfaz visible. Se pintan con el * Los desplazamientos forman parte de la interfaz visible. Se pintan con el
* oro fijo del logotipo en lugar del color nativo del sistema, que podía salir * oro adaptado al fondo. Las reglas WebKit aportan el reflejo metálico;
* azul o gris según el navegador. Las reglas WebKit aportan el reflejo metálico; * `scrollbar-color` conserva su contraste en Firefox.
* `scrollbar-color` mantiene el mismo oro en Firefox.
*/ */
* { * {
scrollbar-width: thin; scrollbar-width: thin;
scrollbar-color: var(--c-marca-oro) var(--c-bg-sunken); scrollbar-color: var(--c-control-oro) var(--c-bg-sunken);
} }
*::-webkit-scrollbar { *::-webkit-scrollbar {
@ -276,12 +284,12 @@ body {
min-width: 2.5rem; min-width: 2.5rem;
border: 2px solid var(--c-bg-sunken); border: 2px solid var(--c-bg-sunken);
border-radius: var(--radius-round); border-radius: var(--radius-round);
background-color: var(--c-marca-oro); background-color: var(--c-control-oro);
background-image: var(--oro-control); background-image: var(--oro-control);
} }
*::-webkit-scrollbar-thumb:hover { *::-webkit-scrollbar-thumb:hover {
background-color: var(--c-marca-oro-brillo); background-color: var(--c-control-oro-brillo);
} }
/* /*
@ -294,13 +302,13 @@ body {
-webkit-appearance: none; -webkit-appearance: none;
height: 1rem; height: 1rem;
background: transparent; background: transparent;
accent-color: var(--c-marca-oro); accent-color: var(--c-control-oro);
cursor: pointer; cursor: pointer;
} }
.rango-dorado::-webkit-slider-runnable-track { .rango-dorado::-webkit-slider-runnable-track {
height: 5px; height: 5px;
border: 1px solid color-mix(in oklab, var(--c-marca-oro-sombra) 55%, var(--c-border)); border: 1px solid var(--c-control-oro-sombra);
border-radius: var(--radius-round); border-radius: var(--radius-round);
background-color: var(--c-bg-sunken); background-color: var(--c-bg-sunken);
background-image: var(--oro-control); background-image: var(--oro-control);
@ -310,7 +318,7 @@ body {
.rango-dorado::-moz-range-track { .rango-dorado::-moz-range-track {
height: 5px; height: 5px;
border: 1px solid color-mix(in oklab, var(--c-marca-oro-sombra) 55%, var(--c-border)); border: 1px solid var(--c-control-oro-sombra);
border-radius: var(--radius-round); border-radius: var(--radius-round);
background: var(--c-bg-sunken); background: var(--c-bg-sunken);
} }
@ -318,7 +326,7 @@ body {
.rango-dorado::-moz-range-progress { .rango-dorado::-moz-range-progress {
height: 5px; height: 5px;
border-radius: var(--radius-round); border-radius: var(--radius-round);
background-color: var(--c-marca-oro); background-color: var(--c-control-oro);
background-image: var(--oro-control); background-image: var(--oro-control);
} }
@ -328,9 +336,9 @@ body {
width: 14px; width: 14px;
height: 14px; height: 14px;
margin-top: calc((var(--sp-1) + var(--gap-hairline)) * -1); margin-top: calc((var(--sp-1) + var(--gap-hairline)) * -1);
border: 1px solid var(--c-marca-oro-sombra); border: 1px solid var(--c-control-oro-sombra);
border-radius: var(--radius-round); border-radius: var(--radius-round);
background-color: var(--c-marca-oro); background-color: var(--c-control-oro);
background-image: var(--oro-control-pulgar); background-image: var(--oro-control-pulgar);
box-shadow: 0 0 0 2px var(--c-bg-raised); box-shadow: 0 0 0 2px var(--c-bg-raised);
} }
@ -338,9 +346,9 @@ body {
.rango-dorado::-moz-range-thumb { .rango-dorado::-moz-range-thumb {
width: 14px; width: 14px;
height: 14px; height: 14px;
border: 1px solid var(--c-marca-oro-sombra); border: 1px solid var(--c-control-oro-sombra);
border-radius: var(--radius-round); border-radius: var(--radius-round);
background-color: var(--c-marca-oro); background-color: var(--c-control-oro);
background-image: var(--oro-control-pulgar); background-image: var(--oro-control-pulgar);
box-shadow: 0 0 0 2px var(--c-bg-raised); box-shadow: 0 0 0 2px var(--c-bg-raised);
} }
@ -351,7 +359,7 @@ body {
.rango-dorado:focus-visible::-moz-range-thumb { .rango-dorado:focus-visible::-moz-range-thumb {
box-shadow: box-shadow:
0 0 0 2px var(--c-bg-raised), 0 0 0 2px var(--c-bg-raised),
0 0 0 4px color-mix(in oklab, var(--c-marca-oro) 25%, transparent); 0 0 0 4px color-mix(in oklab, var(--c-control-oro) 25%, transparent);
} }
h1, h1,
@ -450,8 +458,6 @@ a:hover {
} }
/* Los formularios usan azul; las acciones de reproducción conservan el oro. */ /* Los formularios usan azul; las acciones de reproducción conservan el oro. */
form .btn--primary,
.programador .btn--primary,
.btn.btn--form-primary { .btn.btn--form-primary {
background: var(--c-form-action); background: var(--c-form-action);
border-color: var(--c-form-action); border-color: var(--c-form-action);
@ -461,8 +467,6 @@ form .btn--primary,
background-color var(--duration-normal) var(--ease-standard), background-color var(--duration-normal) var(--ease-standard),
border-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 { .btn.btn--form-primary:hover {
background: var(--c-form-action-hover); background: var(--c-form-action-hover);
border-color: var(--c-form-action-hover); border-color: var(--c-form-action-hover);
@ -543,13 +547,6 @@ button {
} }
.btn { .btn {
display: inline-flex;
align-items: center;
gap: var(--sp-2);
min-height: var(--control-height);
padding: var(--boton-relleno);
border: 1px solid var(--c-border);
border-radius: var(--radius-boton);
/* /*
* `background-color` y no el atajo `background`: el atajo reinicia también * `background-color` y no el atajo `background`: el atajo reinicia también
* `background-image`, así que una variante con degradado —el botón dorado— * `background-image`, así que una variante con degradado —el botón dorado—
@ -557,11 +554,6 @@ button {
* en un rectángulo oscuro con el borde de oro. * en un rectángulo oscuro con el borde de oro.
*/ */
background-color: transparent; background-color: transparent;
font-size: var(--boton-texto);
letter-spacing: 0.01em;
cursor: pointer;
text-decoration: none;
line-height: var(--leading-ui);
transition: transition:
background-color 0.2s var(--ease-standard), background-color 0.2s var(--ease-standard),
border-color 0.2s var(--ease-standard); border-color 0.2s var(--ease-standard);
@ -680,7 +672,7 @@ button {
/* El canto sí es del botón: dentro de una barra no hay borde que teñir. */ /* El canto sí es del botón: dentro de una barra no hay borde que teñir. */
.btn--primary { .btn--primary {
border-color: var(--c-accent-sombra); border-color: var(--c-accent-borde);
} }
/* /*
@ -782,6 +774,25 @@ a.chip:focus-visible {
box-shadow 0.42s var(--ease-emphasized); box-shadow 0.42s var(--ease-emphasized);
} }
/*
* El selector de listas es un panel superpuesto que nace dentro de la tarjeta.
* Mientras está abierto debe poder rebasar sus bordes y quedar sobre las
* tarjetas vecinas; al cerrarlo se recupera el recorte de portada y efectos.
*/
.tarjeta:has(details[open]) {
z-index: 10;
overflow: visible;
}
.tarjeta:has(details[open])::before,
.tarjeta:has(details[open])::after {
border-radius: inherit;
}
.tarjeta:has(details[open]) .tarjeta__medio {
border-radius: calc(var(--radius-tarjeta) - 1px) calc(var(--radius-tarjeta) - 1px) 0 0;
}
/* /*
* El reflejo. Nace donde está el puntero —lo escribe la acción `brillo` en * El reflejo. Nace donde está el puntero —lo escribe la acción `brillo` en
* `--mx` y `--my`— y por eso no se lee como un efecto enlatado. Sin JavaScript * `--mx` y `--my`— y por eso no se lee como un efecto enlatado. Sin JavaScript
@ -1014,12 +1025,18 @@ a.chip:focus-visible {
* controles, proporcionada al título y al resto de sus acciones. * controles, proporcionada al título y al resto de sus acciones.
*/ */
.tarjeta .tarjeta__reproduccion button { .tarjeta .tarjeta__reproduccion button {
min-width: var(--control-height-compact); --lado-play: var(--control-height-compact);
min-height: var(--control-height-compact); min-width: var(--lado-play);
width: var(--control-height-compact); min-height: var(--lado-play);
height: var(--control-height-compact); width: var(--lado-play);
height: var(--lado-play);
padding: 0; padding: 0;
} }
@media (pointer: coarse) {
.tarjeta .tarjeta__reproduccion button {
--lado-play: var(--control-height);
}
}
/* Lo que se apoya sobre la tarjeta y tiene que quedar por encima del enlace. */ /* Lo que se apoya sobre la tarjeta y tiene que quedar por encima del enlace. */
.tarjeta__encima { .tarjeta__encima {

@ -0,0 +1,48 @@
/* Geometría compartida por enlaces de acción, formularios y controles musicales.
* La altura es mínima: el texto ampliado puede crecer sin quedar recortado. */
.btn,
.ui-button {
--ui-control-height: var(--control-height);
--ui-control-inset: var(--sp-4);
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--sp-2);
box-sizing: border-box;
min-height: var(--ui-control-height);
padding: calc((var(--ui-control-height) - var(--ui-control-line) - 2px) / 2)
var(--ui-control-inset);
border: 1px solid var(--c-border);
border-radius: var(--radius-boton);
font: var(--ui-control-font);
text-align: center;
text-decoration: none;
cursor: pointer;
}
.ui-button.pequeno,
.ui-button.compacto,
.btn--compact {
--ui-control-height: var(--control-height-compact);
--ui-control-inset: var(--sp-3);
}
.ui-button svg,
.btn svg {
flex: none;
}
.ui-button:disabled,
.btn:disabled {
cursor: not-allowed;
opacity: 0.5;
}
.ui-button:focus-visible,
.btn:focus-visible {
outline: 2px solid var(--c-focus);
outline-offset: 2px;
}
@media (pointer: coarse) {
.ui-button.pequeno,
.ui-button.compacto,
.btn--compact {
--ui-control-height: var(--control-height);
}
}

@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import Icono from '$lib/components/Icono.svelte';
import { DEFINICIONES, NOMBRES_TIPO_PERSONA, type Campo, type Tipo, type Valor } from '../modelo'; import { DEFINICIONES, NOMBRES_TIPO_PERSONA, type Campo, type Tipo, type Valor } from '../modelo';
import { NOMBRES_ACCESO_CANCION, NOMBRES_FORMACION_VOCAL } from '$lib/canciones'; import { NOMBRES_ACCESO_CANCION, NOMBRES_FORMACION_VOCAL } from '$lib/canciones';
import EditorSecciones from './EditorSecciones.svelte'; import EditorSecciones from './EditorSecciones.svelte';
@ -127,8 +128,13 @@
>{/if} >{/if}
{#if campo.tipo === 'referencia' && campo.referencia && campo.clave !== 'principalId' && campo.clave !== 'cancionId'}<div {#if campo.tipo === 'referencia' && campo.referencia && campo.clave !== 'principalId' && campo.clave !== 'cancionId'}<div
> >
<button type="button" class="crear" onclick={() => crear(campo.referencia!, campo.clave)} <button
>+ Crear {DEFINICIONES[campo.referencia].singular.toLowerCase()}</button type="button"
class="ui-button pequeno crear"
onclick={() => crear(campo.referencia!, campo.clave)}
><Icono nombre="mas" tamano={16} /> Crear {DEFINICIONES[
campo.referencia
].singular.toLowerCase()}</button
> >
</div>{/if} </div>{/if}
</div> </div>
@ -141,8 +147,7 @@
min-width: 0; min-width: 0;
} }
.tipos-persona legend { .tipos-persona legend {
font-size: var(--fs-base); font: var(--ui-label-font);
font-weight: 600;
margin-bottom: var(--sp-4); margin-bottom: var(--sp-4);
} }
.tipos-persona > div { .tipos-persona > div {
@ -164,9 +169,6 @@
border: 0; border: 0;
background: none; background: none;
color: var(--acento); color: var(--acento);
padding: var(--sp-2) 0; padding-inline: 0;
font-size: var(--fs-base);
font-weight: 600;
min-height: var(--control-height-compact);
} }
</style> </style>

@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import Icono from '$lib/components/Icono.svelte';
import { import {
TIPOS_SECCION, TIPOS_SECCION,
normalizarSecciones, normalizarSecciones,
@ -79,20 +80,22 @@
<strong>Sección {indice + 1}</strong> <strong>Sección {indice + 1}</strong>
<div class="orden" aria-label="Orden de la sección {indice + 1}"> <div class="orden" aria-label="Orden de la sección {indice + 1}">
<button <button
class="ui-button boton pequeno"
type="button" type="button"
disabled={indice === 0} disabled={indice === 0}
onclick={() => mover(indice, indice - 1)} onclick={() => mover(indice, indice - 1)}
aria-label="Subir sección {indice + 1}">↑</button aria-label="Subir sección {indice + 1}"><Icono nombre="subir" tamano={16} /></button
> >
<button <button
class="ui-button boton pequeno"
type="button" type="button"
disabled={indice === secciones.length - 1} disabled={indice === secciones.length - 1}
onclick={() => mover(indice, indice + 1)} onclick={() => mover(indice, indice + 1)}
aria-label="Bajar sección {indice + 1}">↓</button aria-label="Bajar sección {indice + 1}"><Icono nombre="bajar" tamano={16} /></button
> >
<button <button
type="button" type="button"
class="eliminar" class="ui-button boton pequeno eliminar"
onclick={() => eliminar(indice)} onclick={() => eliminar(indice)}
aria-label="Eliminar sección {indice + 1}">Eliminar</button aria-label="Eliminar sección {indice + 1}">Eliminar</button
> >
@ -212,7 +215,9 @@
<div class="anadir" aria-label="Añadir una sección"> <div class="anadir" aria-label="Añadir una sección">
<span>Añadir:</span> <span>Añadir:</span>
{#each TIPOS_SECCION as tipo (tipo)} {#each TIPOS_SECCION as tipo (tipo)}
<button type="button" onclick={() => anadir(tipo)}>+ {nombres[tipo]}</button> <button class="ui-button boton pequeno" type="button" onclick={() => anadir(tipo)}
><Icono nombre="mas" tamano={16} /> {nombres[tipo]}</button
>
{/each} {/each}
</div> </div>
</fieldset> </fieldset>
@ -235,10 +240,10 @@
border: 0; border: 0;
} }
.editor > legend { .editor > legend {
font-size: var(--fs-lg); font: var(--ui-panel-font);
font-weight: var(--font-weight-semibold);
} }
.introduccion { .introduccion {
font: var(--ui-meta-font);
margin: var(--sp-2) 0 var(--sp-4); margin: var(--sp-2) 0 var(--sp-4);
color: var(--muted); color: var(--muted);
} }
@ -271,17 +276,7 @@
align-items: center; align-items: center;
gap: var(--sp-2); gap: var(--sp-2);
} }
.orden button,
.anadir button {
min-height: var(--control-height);
padding: var(--sp-2) var(--sp-3);
border: 1px solid var(--borde);
border-radius: var(--radius-boton);
background: var(--papel);
color: inherit;
font-size: var(--fs-base);
cursor: pointer;
}
.orden button:disabled { .orden button:disabled {
opacity: 0.35; opacity: 0.35;
cursor: default; cursor: default;
@ -327,7 +322,6 @@
} }
.anadir button { .anadir button {
color: var(--acento); color: var(--acento);
font-weight: var(--font-weight-semibold);
} }
@media (max-width: 48rem) { @media (max-width: 48rem) {
.campos { .campos {

@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import Icono from '$lib/components/Icono.svelte';
import { resolve } from '$app/paths'; import { resolve } from '$app/paths';
import { import {
DEFINICIONES, DEFINICIONES,
@ -75,19 +76,19 @@
<div class="relacion-controles"> <div class="relacion-controles">
<button <button
type="button" type="button"
class="boton pequeno" class="ui-button boton pequeno"
aria-label={`Subir ${ref?.titulo}`} aria-label={`Subir ${ref?.titulo}`}
disabled={!vinculos.slice(0, i).some((x) => x.papel === v.papel)} disabled={!vinculos.slice(0, i).some((x) => x.papel === v.papel)}
onclick={() => mover(i, -1, v.papel)}>↑</button onclick={() => mover(i, -1, v.papel)}><Icono nombre="subir" tamano={16} /></button
><button ><button
type="button" type="button"
class="boton pequeno" class="ui-button boton pequeno"
aria-label={`Bajar ${ref?.titulo}`} aria-label={`Bajar ${ref?.titulo}`}
disabled={!vinculos.slice(i + 1).some((x) => x.papel === v.papel)} disabled={!vinculos.slice(i + 1).some((x) => x.papel === v.papel)}
onclick={() => mover(i, 1, v.papel)}>↓</button onclick={() => mover(i, 1, v.papel)}><Icono nombre="bajar" tamano={16} /></button
><button ><button
type="button" type="button"
class="boton pequeno peligro" class="ui-button boton pequeno peligro"
aria-label={`Quitar ${ref?.titulo}`} aria-label={`Quitar ${ref?.titulo}`}
onclick={() => (vinculos = vinculos.filter((_, j) => j !== i))}>×</button onclick={() => (vinculos = vinculos.filter((_, j) => j !== i))}>×</button
> >
@ -117,22 +118,24 @@
></label ></label
><button ><button
type="button" type="button"
class="boton" class="ui-button boton"
disabled={!elegidos[relacion.papel]} disabled={!elegidos[relacion.papel]}
onclick={() => añadir(relacion.papel)}>Añadir</button onclick={() => añadir(relacion.papel)}>Añadir</button
>{#if relacion.destino !== 'grabaciones'}<button >{#if relacion.destino !== 'grabaciones'}<button
type="button" type="button"
class="boton" class="ui-button boton"
onclick={() => crear(relacion.destino, `relacion:${relacion.papel}`)}>+ Crear</button onclick={() => crear(relacion.destino, `relacion:${relacion.papel}`)}
>{:else}<a class="boton" href={resolve('/(dentro)/[tipo]/nuevo', { tipo: 'canciones' })} ><Icono nombre="mas" tamano={16} /> Crear</button
>Nueva canción</a >{:else}<a
class="ui-button boton"
href={resolve('/(dentro)/[tipo]/nuevo', { tipo: 'canciones' })}>Nueva canción</a
>{/if} >{/if}
</div> </div>
</section>{/each} </section>{/each}
<style> <style>
.ayuda-persona { .ayuda-persona {
font-size: var(--fs-base); font: var(--ui-meta-font);
color: var(--muted); color: var(--muted);
margin: var(--sp-2) 0 var(--sp-4); margin: var(--sp-2) 0 var(--sp-4);
} }

@ -0,0 +1,39 @@
<script lang="ts">
import Icono from '$lib/components/Icono.svelte';
let { src, nombre, clase }: { src: string; nombre: string; clase: string } = $props();
let fallida = $state(false);
$effect(() => {
if (src) fallida = false;
});
</script>
{#if fallida}
<span class="no-disponible" title={nombre}
><Icono nombre="documento" tamano={20} /><small>Archivo no disponible</small></span
>
{:else if clase === 'imagen'}
<img {src} alt={nombre} width="48" height="48" loading="lazy" onerror={() => (fallida = true)} />
{:else}
<audio controls preload="none" {src} aria-label={nombre} onerror={() => (fallida = true)}></audio>
{/if}
<style>
img {
object-fit: cover;
border-radius: var(--radius-boton);
}
audio {
display: block;
width: 230px;
max-width: 100%;
}
.no-disponible {
display: inline-flex;
align-items: center;
gap: var(--sp-2);
padding: var(--sp-2);
border: 1px dashed var(--borde);
border-radius: var(--radius-boton);
color: var(--muted);
}
</style>

@ -1,6 +1,7 @@
@import '../fuentes.css'; @import '../fuentes.css';
@import '../marca.css'; @import '../marca.css';
@import '../tokens.css'; @import '../tokens.css';
@import '../controles.css';
:root { :root {
font-size: var(--fs-base); font-size: var(--fs-base);
line-height: 1.5; line-height: 1.5;
@ -14,7 +15,7 @@
--borde: var(--c-border); --borde: var(--c-border);
--texto: var(--c-text); --texto: var(--c-text);
--muted: var(--c-text-muted); --muted: var(--c-text-muted);
--acento: var(--c-marca-oro); --acento: var(--ui-action);
--suave: var(--c-bg-sunken); --suave: var(--c-bg-sunken);
} }
* { * {
@ -61,18 +62,18 @@ p {
margin-top: 0; margin-top: 0;
} }
h1 { h1 {
font-family: var(--font-display); font: var(--ui-page-font);
font-size: var(--fs-2xl); letter-spacing: -0.025em;
letter-spacing: -0.045em; margin-bottom: var(--sp-2);
font-weight: var(--font-weight-semibold);
margin-bottom: var(--sp-3);
} }
h2 { h2 {
font-size: var(--fs-xl); font: var(--ui-section-font);
letter-spacing: -0.02em; letter-spacing: -0.015em;
margin-bottom: var(--sp-3);
} }
h3 { h3 {
font-size: var(--fs-lg); font: var(--ui-panel-font);
margin-bottom: var(--sp-3);
} }
p { p {
line-height: 1.65; line-height: 1.65;
@ -82,30 +83,18 @@ small,
color: var(--muted); color: var(--muted);
} }
small { small {
font-size: var(--fs-base); font: var(--ui-meta-font);
} }
.eyebrow { .eyebrow {
font-size: var(--fs-base); font-size: var(--fs-xs);
letter-spacing: 0.06em; letter-spacing: 0.06em;
text-transform: uppercase; text-transform: uppercase;
font-weight: var(--font-weight-semibold); font-weight: var(--font-weight-medium);
color: var(--muted); color: var(--muted);
} }
.boton { .boton {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--sp-3);
min-height: var(--control-height);
border: 1px solid var(--borde);
border-radius: var(--radius-boton);
padding: var(--sp-3) var(--sp-4);
background: var(--papel); background: var(--papel);
color: var(--texto); color: var(--texto);
font-weight: 600;
font-size: var(--fs-base);
text-decoration: none;
white-space: nowrap;
} }
.boton:hover { .boton:hover {
background: var(--suave); background: var(--suave);
@ -113,31 +102,26 @@ small {
} }
.boton.primario { .boton.primario {
background: var(--acento); background: var(--acento);
color: var(--c-bg); color: var(--ui-action-text);
border-color: var(--acento); border-color: var(--acento);
} }
.boton.primario:hover { .boton.primario:hover {
background: var(--c-marca-oro-brillo); background: var(--ui-action-hover);
} }
.boton.peligro { .boton.peligro {
color: var(--c-danger); color: var(--c-danger);
} }
.boton.pequeno {
font-size: var(--fs-base);
min-height: var(--control-height-compact);
padding: var(--sp-2) var(--sp-3);
}
.cabecera-pagina { .cabecera-pagina {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: var(--sp-5); gap: var(--sp-5);
margin-bottom: var(--sp-6); margin-bottom: var(--sp-5);
} }
.cabecera-pagina p { .cabecera-pagina p {
margin: 0; margin: 0;
color: var(--muted); color: var(--muted);
font-size: var(--fs-base); font: var(--ui-label-font);
} }
.acciones { .acciones {
display: flex; display: flex;
@ -163,7 +147,7 @@ small {
gap: var(--sp-2); gap: var(--sp-2);
border-radius: var(--radius-detail); border-radius: var(--radius-detail);
padding: var(--sp-1) var(--sp-2); padding: var(--sp-1) var(--sp-2);
font-size: var(--fs-base); font: var(--ui-meta-font);
background: var(--papel); background: var(--papel);
color: var(--texto); color: var(--texto);
white-space: nowrap; white-space: nowrap;
@ -177,15 +161,15 @@ small {
} }
.estado.publicado { .estado.publicado {
background: var(--papel); background: var(--papel);
color: var(--texto); color: var(--ui-status-success);
} }
.estado.borrador { .estado.borrador {
background: var(--suave); background: var(--suave);
color: var(--acento); color: var(--ui-status-draft);
} }
.estado.retirado { .estado.retirado {
background: var(--papel); background: var(--papel);
color: var(--texto); color: var(--muted);
} }
.aviso { .aviso {
border: 1px solid var(--borde); border: 1px solid var(--borde);
@ -209,8 +193,7 @@ small {
label { label {
display: grid; display: grid;
gap: var(--sp-2); gap: var(--sp-2);
font-size: var(--fs-base); font: var(--ui-label-font);
font-weight: 600;
} }
input:not([type='checkbox']):not([type='file']), input:not([type='checkbox']):not([type='file']),
select, select,
@ -218,7 +201,8 @@ textarea {
width: 100%; width: 100%;
border: 1px solid var(--borde); border: 1px solid var(--borde);
border-radius: var(--radius-boton); border-radius: var(--radius-boton);
padding: var(--sp-3) var(--sp-4); padding: var(--ui-input-padding);
font: var(--ui-input-font);
background: var(--papel); background: var(--papel);
color: var(--texto); color: var(--texto);
min-height: var(--control-height); min-height: var(--control-height);
@ -267,7 +251,8 @@ input[type='checkbox'] {
padding: var(--sp-4) var(--sp-5); padding: var(--sp-4) var(--sp-5);
display: flex; display: flex;
gap: var(--sp-3); gap: var(--sp-3);
align-items: center; align-items: end;
flex-wrap: wrap;
border-bottom: 1px solid var(--borde); border-bottom: 1px solid var(--borde);
} }
.filtros input { .filtros input {
@ -289,10 +274,10 @@ table {
th { th {
text-align: left; text-align: left;
text-transform: uppercase; text-transform: uppercase;
font-size: var(--fs-base); font-size: var(--fs-xs);
letter-spacing: 0.03em; letter-spacing: 0.03em;
color: var(--texto); color: var(--muted);
font-weight: var(--font-weight-semibold); font-weight: var(--font-weight-medium);
background: var(--papel); background: var(--papel);
} }
th, th,
@ -336,7 +321,7 @@ td .titulo {
align-items: center; align-items: center;
padding: var(--sp-4) var(--sp-5); padding: var(--sp-4) var(--sp-5);
border-top: 1px solid var(--borde); border-top: 1px solid var(--borde);
font-size: var(--fs-base); font: var(--ui-meta-font);
} }
.vacio { .vacio {
padding: calc(var(--sp-6) * 2) var(--sp-6); padding: calc(var(--sp-6) * 2) var(--sp-6);
@ -355,9 +340,9 @@ td .titulo {
display: flex; display: flex;
gap: var(--sp-2); gap: var(--sp-2);
align-items: center; align-items: center;
font-size: var(--fs-base); font: var(--ui-meta-font);
color: var(--muted); color: var(--muted);
margin-bottom: var(--sp-5); margin-bottom: var(--sp-3);
flex-wrap: wrap; flex-wrap: wrap;
} }
.tabs { .tabs {
@ -365,12 +350,12 @@ td .titulo {
gap: var(--sp-5); gap: var(--sp-5);
overflow: auto; overflow: auto;
border-bottom: 1px solid var(--borde); border-bottom: 1px solid var(--borde);
margin-bottom: var(--sp-6); margin-bottom: var(--sp-5);
} }
.tabs a, .tabs a,
.tabs button { .tabs button {
padding: var(--sp-4) var(--sp-1); padding: var(--sp-4) var(--sp-1);
font-size: var(--fs-base); font: var(--ui-label-font);
border: 0; border: 0;
border-bottom: 2px solid transparent; border-bottom: 2px solid transparent;
background: transparent; background: transparent;
@ -393,12 +378,15 @@ td .titulo {
display: grid; display: grid;
gap: var(--sp-4); gap: var(--sp-4);
} }
.editor-lateral h2 {
font: var(--ui-panel-font);
}
.editor-lateral p { .editor-lateral p {
font-size: var(--fs-base); font-size: var(--fs-sm);
} }
.editor-lateral ul { .editor-lateral ul {
padding-left: var(--sp-4); padding-left: var(--sp-4);
font-size: var(--fs-base); font-size: var(--fs-sm);
line-height: 1.8; line-height: 1.8;
} }
.editor-barra { .editor-barra {
@ -487,7 +475,7 @@ dialog form {
scroll-margin-top: calc(var(--sp-7) * 2); scroll-margin-top: calc(var(--sp-7) * 2);
} }
h1 { h1 {
font-size: var(--fs-2xl); font-size: var(--fs-xl);
} }
.cabecera-pagina { .cabecera-pagina {
align-items: flex-start; align-items: flex-start;
@ -531,6 +519,15 @@ dialog form {
border-radius: var(--radius-boton); border-radius: var(--radius-boton);
background: var(--papel); background: var(--papel);
color: var(--texto); color: var(--texto);
font-size: var(--fs-base); font: var(--ui-meta-font);
white-space: nowrap; white-space: nowrap;
} }
.filtros .filtro {
flex: 1 1 12rem;
min-width: 0;
}
.filtros .filtro select {
width: 100%;
max-width: none;
}

@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import Icono from '$lib/components/Icono.svelte';
import { resolve } from '$app/paths'; import { resolve } from '$app/paths';
import { page } from '$app/state'; import { page } from '$app/state';
import { site } from '$lib/site'; import { site } from '$lib/site';
@ -27,7 +28,7 @@
class="inicio" class="inicio"
class:activo={page.url.pathname === '/'} class:activo={page.url.pathname === '/'}
href={resolve('/')} href={resolve('/')}
onclick={() => (menu = false)}><span aria-hidden="true">⌂</span> Inicio</a onclick={() => (menu = false)}><Icono nombre="inicio" tamano={18} /> Inicio</a
> >
{#each grupos as grupo (grupo)} {#each grupos as grupo (grupo)}
<div class="grupo"> <div class="grupo">
@ -74,11 +75,11 @@
<header class="barra-superior"> <header class="barra-superior">
<div class="izquierda"> <div class="izquierda">
<button <button
class="boton movil" class="ui-button boton movil"
disabled={!listo} disabled={!listo}
aria-expanded={menu} aria-expanded={menu}
aria-label="Abrir navegación" aria-label="Abrir navegación"
onclick={() => (menu = !menu)}>☰</button onclick={() => (menu = !menu)}><Icono nombre="menu" tamano={20} /></button
> >
<form class="busqueda-superior" method="GET" action={resolve('/(dentro)/buscar')}> <form class="busqueda-superior" method="GET" action={resolve('/(dentro)/buscar')}>
<label class="oculto" for="busqueda-global">Buscar en Estudio</label><input <label class="oculto" for="busqueda-global">Buscar en Estudio</label><input
@ -86,14 +87,20 @@
type="search" type="search"
name="q" name="q"
placeholder="Buscar en Estudio…" placeholder="Buscar en Estudio…"
/><button type="submit" aria-label="Buscar">⌕</button> /><button class="ui-button" type="submit" aria-label="Buscar"
><Icono nombre="buscar" tamano={20} /></button
>
</form> </form>
</div> </div>
<div class="acciones"> <div class="acciones">
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve --> <!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
<a class="ver-sitio" href={site.url} target="_blank" rel="noreferrer">Ver sitio ↗</a> <a class="ver-sitio" href={site.url} target="_blank" rel="noreferrer"
>Ver sitio <Icono nombre="externo" tamano={16} /></a
>
<details class="crear-menu"> <details class="crear-menu">
<summary class="boton primario">+ Crear</summary> <summary class="ui-button boton primario"
><Icono nombre="mas" tamano={20} /> Crear</summary
>
<div> <div>
{#each ['canciones', 'lanzamientos', 'personas', 'blog'] as tipo (tipo)}<a {#each ['canciones', 'lanzamientos', 'personas', 'blog'] as tipo (tipo)}<a
href={resolve('/(dentro)/[tipo]/nuevo', { tipo })} href={resolve('/(dentro)/[tipo]/nuevo', { tipo })}
@ -106,7 +113,7 @@
<div> <div>
<small>{data.admin.email}</small> <small>{data.admin.email}</small>
<form method="POST" action={resolve('/salir')}> <form method="POST" action={resolve('/salir')}>
<button class="boton pequeno">Cerrar sesión</button> <button class="ui-button boton pequeno">Cerrar sesión</button>
</form> </form>
</div> </div>
</details> </details>
@ -120,10 +127,10 @@
procedimiento de puesta en marcha para inicializarlo e importar el catálogo. procedimiento de puesta en marcha para inicializarlo e importar el catálogo.
</p> </p>
</div>{:else} </div>{:else}
{#if data.estudio.modo === 'preparacion'}<div class="entorno"> {#if data.estudio.modo === 'preparacion'}<details class="entorno">
<span></span> Entorno de preparación <summary>Entorno de preparación</summary>
<span class="muted">Las publicaciones de este entorno no cambian la web.</span> <p>Las publicaciones de este entorno no cambian la web.</p>
</div>{/if} </details>{/if}
{@render children()}{/if} {@render children()}{/if}
</main> </main>
<footer>SENZA PAURA <span>Estudio / Administración</span></footer> <footer>SENZA PAURA <span>Estudio / Administración</span></footer>
@ -140,14 +147,14 @@
border: 0; border: 0;
background: transparent; background: transparent;
min-width: 220px; min-width: 220px;
font-size: var(--fs-base); font-size: var(--fs-sm);
} }
.busqueda-superior button { .busqueda-superior button {
border: 0; border: 0;
background: transparent; background: transparent;
color: var(--muted); color: var(--muted);
font-size: var(--fs-xl); width: var(--control-height);
padding: var(--sp-2); padding-inline: 0;
} }
@media (max-width: 48rem) { @media (max-width: 48rem) {
.busqueda-superior { .busqueda-superior {
@ -188,7 +195,7 @@
display: block; display: block;
color: var(--texto); color: var(--texto);
letter-spacing: 0.08em; letter-spacing: 0.08em;
font-size: var(--fs-base); font-size: var(--fs-sm);
font-weight: var(--font-weight-medium); font-weight: var(--font-weight-medium);
font-family: var(--font-body); font-family: var(--font-body);
margin-top: var(--sp-2); margin-top: var(--sp-2);
@ -197,7 +204,7 @@
padding-top: var(--sp-3); padding-top: var(--sp-3);
} }
.grupo p { .grupo p {
font-size: var(--fs-base); font-size: var(--fs-sm);
font-weight: var(--font-weight-semibold); font-weight: var(--font-weight-semibold);
color: var(--muted); color: var(--muted);
margin: 0 0 var(--sp-1); margin: 0 0 var(--sp-1);
@ -211,9 +218,10 @@
gap: var(--sp-3); gap: var(--sp-3);
align-items: center; align-items: center;
color: var(--texto); color: var(--texto);
font-size: var(--fs-lg); font-size: var(--fs-sm);
line-height: 1.4; line-height: 1.4;
padding: var(--sp-1) var(--sp-3); padding: var(--sp-2) var(--sp-3);
min-height: var(--control-height-compact);
border-radius: var(--radius-boton); border-radius: var(--radius-boton);
margin-bottom: 0; margin-bottom: 0;
} }
@ -231,9 +239,6 @@
.inicio { .inicio {
margin-top: var(--sp-2); margin-top: var(--sp-2);
} }
.inicio span {
font-size: var(--fs-lg);
}
.superficie { .superficie {
min-width: 0; min-width: 0;
margin-left: var(--ancho-aside); margin-left: var(--ancho-aside);
@ -242,11 +247,11 @@
flex-direction: column; flex-direction: column;
} }
.barra-superior { .barra-superior {
height: 78px; min-height: 64px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
padding: 0 var(--sp-7); padding: 0 var(--sp-6);
background: var(--papel); background: var(--papel);
border-bottom: 1px solid var(--borde); border-bottom: 1px solid var(--borde);
position: sticky; position: sticky;
@ -254,7 +259,7 @@
z-index: 20; z-index: 20;
} }
.ver-sitio { .ver-sitio {
font-size: var(--fs-base); font-size: var(--fs-sm);
color: var(--texto); color: var(--texto);
margin-right: var(--sp-4); margin-right: var(--sp-4);
} }
@ -269,7 +274,7 @@
place-items: center; place-items: center;
background: var(--papel); background: var(--papel);
color: var(--texto); color: var(--texto);
font-size: var(--fs-base); font-size: var(--fs-sm);
cursor: pointer; cursor: pointer;
} }
.cuenta div, .cuenta div,
@ -299,10 +304,10 @@
.crear-menu a { .crear-menu a {
display: block; display: block;
padding: var(--sp-3); padding: var(--sp-3);
font-size: var(--fs-base); font-size: var(--fs-sm);
} }
main { main {
padding: var(--sp-7) var(--sp-7); padding: var(--sp-5) var(--sp-6);
flex: 1; flex: 1;
max-width: 1500px; max-width: 1500px;
width: 100%; width: 100%;
@ -311,7 +316,7 @@
} }
footer { footer {
padding: var(--sp-5) var(--sp-7); padding: var(--sp-5) var(--sp-7);
font-size: var(--fs-base); font-size: var(--fs-sm);
letter-spacing: 0.03em; letter-spacing: 0.03em;
color: var(--muted); color: var(--muted);
display: flex; display: flex;
@ -321,18 +326,20 @@
letter-spacing: 0; letter-spacing: 0;
} }
.entorno { .entorno {
display: flex; font: var(--ui-meta-font);
margin-bottom: var(--sp-4);
color: var(--muted);
}
.entorno summary {
cursor: pointer;
}
.entorno p {
margin: var(--sp-2) 0 0;
}
.ver-sitio {
display: inline-flex;
align-items: center; align-items: center;
gap: var(--sp-2); 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: var(--radius-round);
background: var(--papel);
} }
.movil { .movil {
display: none; display: none;
@ -356,7 +363,7 @@
@media (max-width: 48rem) { @media (max-width: 48rem) {
aside { aside {
transform: translateX(-100%); transform: translateX(-100%);
top: 70px; top: 64px;
width: min(280px, 85vw); width: min(280px, 85vw);
} }
aside.abierto { aside.abierto {
@ -374,7 +381,7 @@
display: inline-flex; display: inline-flex;
} }
.barra-superior { .barra-superior {
height: 70px; min-height: 64px;
padding: 0 var(--sp-4); padding: 0 var(--sp-4);
} }
.ver-sitio { .ver-sitio {
@ -383,12 +390,6 @@
main { main {
padding: var(--sp-5) var(--sp-4); padding: var(--sp-5) var(--sp-4);
} }
.entorno {
flex-wrap: wrap;
}
.entorno .muted {
flex-basis: 100%;
}
footer { footer {
padding: var(--sp-5) var(--sp-4); padding: var(--sp-5) var(--sp-4);
} }

@ -29,7 +29,7 @@
: 'Artistas, letristas, compositores y músicos.'} : 'Artistas, letristas, compositores y músicos.'}
</p> </p>
<div class="acciones"> <div class="acciones">
<a class="boton primario" href={resolve('/(dentro)/[tipo]/nuevo', { tipo })} <a class="ui-button boton primario" href={resolve('/(dentro)/[tipo]/nuevo', { tipo })}
>Crear {DEFINICIONES[tipo as Tipo].singular.toLowerCase()}</a >Crear {DEFINICIONES[tipo as Tipo].singular.toLowerCase()}</a
><a href={resolve('/(dentro)/[tipo]', { tipo })}>Ver todas →</a> ><a href={resolve('/(dentro)/[tipo]', { tipo })}>Ver todas →</a>
</div> </div>
@ -72,9 +72,22 @@
grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-columns: repeat(3, minmax(0, 1fr));
gap: var(--sp-4); gap: var(--sp-4);
} }
.atajos article {
display: flex;
flex-direction: column;
}
.atajos .acciones {
margin-top: auto;
display: grid;
justify-items: start;
font: var(--ui-label-font);
}
.atajos p {
font-size: var(--fs-sm);
}
.cabecera-atajo { .cabecera-atajo {
display: flex; display: flex;
align-items: center; align-items: flex-start;
justify-content: space-between; justify-content: space-between;
gap: var(--sp-4); gap: var(--sp-4);
} }

@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { resolve } from '$app/paths'; import { resolve } from '$app/paths';
import Icono from '$lib/components/Icono.svelte';
import type { ResolvedPathname } from '$app/types'; import type { ResolvedPathname } from '$app/types';
import { import {
DEFINICIONES, DEFINICIONES,
@ -29,40 +30,41 @@
<h1>{def.plural}</h1> <h1>{def.plural}</h1>
<p>{def.descripcion}</p> <p>{def.descripcion}</p>
</div> </div>
<a class="boton primario" href={resolve('/(dentro)/[tipo]/nuevo', { tipo: data.tipo })} <a class="ui-button boton primario" href={resolve('/(dentro)/[tipo]/nuevo', { tipo: data.tipo })}
>+ Crear {def.singular.toLowerCase()}</a ><Icono nombre="mas" tamano={20} /> Crear {def.singular.toLowerCase()}</a
> >
</header> </header>
<section class="tarjeta"> <section class="tarjeta">
<form method="GET" class="filtros"> <form method="GET" class="filtros">
<label class="oculto" for="buscar">Buscar {def.plural.toLowerCase()}</label><input <label class="filtro" for="buscar"
id="buscar" >Buscar {def.plural.toLowerCase()}<input
name="q" id="buscar"
type="search" name="q"
value={data.q} type="search"
placeholder={`Buscar ${def.plural.toLowerCase()}…`} value={data.q}
/><label class="oculto" for="estado">Estado</label><select placeholder={`Buscar ${def.plural.toLowerCase()}…`}
id="estado" /></label
name="estado" ><label class="filtro" for="estado"
value={data.estado} >Estado<select id="estado" name="estado" value={data.estado}
><option value="">Todos los estados</option><option value="borrador">Borrador</option><option ><option value="">Todos los estados</option><option value="borrador">Borrador</option
value="publicado">Publicado</option ><option value="publicado">Publicado</option><option value="retirado">Retirado</option
><option value="retirado">Retirado</option></select ></select
><label class="oculto" for="relacion">Relacionado con</label><select ></label
id="relacion" ><label class="filtro" for="relacion"
name="relacion" >Relacionado con<select id="relacion" name="relacion" value={data.relacion}
value={data.relacion} ><option value="">Cualquier relación</option
><option value="">Cualquier relación</option >{#each data.referencias.filter( (r) => ['personas', 'estilos', 'lanzamientos', 'canciones'].includes(r.tipo) ) as r (r.id)}<option
>{#each data.referencias.filter( (r) => ['personas', 'estilos', 'lanzamientos', 'canciones'].includes(r.tipo) ) as r (r.id)}<option value={r.id}>{r.titulo}</option
value={r.id}>{r.titulo}</option
>{/each}</select
>{#if data.tipo === 'personas'}<label class="oculto" for="tipo-persona">Tipo de persona</label
><select id="tipo-persona" name="tipoPersona" value={data.tipoPersona}
><option value="">Todos los tipos</option
>{#each Object.entries(NOMBRES_TIPO_PERSONA) as [valor, nombre] (valor)}<option
value={valor}>{nombre}</option
>{/each}</select >{/each}</select
>{/if}<button class="boton">Buscar</button> ></label
>{#if data.tipo === 'personas'}<label class="filtro" for="tipo-persona"
>Tipo de persona<select id="tipo-persona" name="tipoPersona" value={data.tipoPersona}
><option value="">Todos los tipos</option
>{#each Object.entries(NOMBRES_TIPO_PERSONA) as [valor, nombre] (valor)}<option
value={valor}>{nombre}</option
>{/each}</select
></label
>{/if}<button class="ui-button boton">Buscar</button>
</form> </form>
{#if data.documentos.length}<div class="tabla-scroll"> {#if data.documentos.length}<div class="tabla-scroll">
<table> <table>
@ -79,7 +81,10 @@
><td ><td
><div class="marca-fila"> ><div class="marca-fila">
<span class="miniatura" aria-hidden="true" <span class="miniatura" aria-hidden="true"
>{data.tipo === 'canciones' ? '♫' : '◇'}</span ><Icono
nombre={data.tipo === 'canciones' ? 'musica' : 'documento'}
tamano={20}
/></span
> >
<div> <div>
<a <a
@ -121,16 +126,19 @@
? 'Prueba con otro título o cambia los filtros.' ? 'Prueba con otro título o cambia los filtros.'
: def.descripcion + ' Empieza por un borrador y completa los detalles después.'} : def.descripcion + ' Empieza por un borrador y completa los detalles después.'}
</p> </p>
<a class="boton primario" href={resolve('/(dentro)/[tipo]/nuevo', { tipo: data.tipo })} <a
class="ui-button boton primario"
href={resolve('/(dentro)/[tipo]/nuevo', { tipo: data.tipo })}
>Crear {def.singular.toLowerCase()}</a >Crear {def.singular.toLowerCase()}</a
> >
</div>{/if} </div>{/if}
<div class="paginacion"> <div class="paginacion">
<span>{data.total} registros · Página {data.pagina}</span> <span>{data.total} registros · Página {data.pagina}</span>
<div class="acciones"> <div class="acciones">
{#if data.pagina > 1}<a class="boton pequeno" href={urlPagina(data.pagina - 1)}>Anterior</a {#if data.pagina > 1}<a class="ui-button boton pequeno" href={urlPagina(data.pagina - 1)}
>Anterior</a
>{/if}{#if data.pagina * 24 < data.total}<a >{/if}{#if data.pagina * 24 < data.total}<a
class="boton pequeno" class="ui-button boton pequeno"
href={urlPagina(data.pagina + 1)}>Siguiente</a href={urlPagina(data.pagina + 1)}>Siguiente</a
>{/if} >{/if}
</div> </div>

@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { untrack, onMount } from 'svelte'; import { untrack, onMount } from 'svelte';
import { resolve } from '$app/paths'; import { resolve } from '$app/paths';
import Icono from '$lib/components/Icono.svelte';
import type { ResolvedPathname } from '$app/types'; import type { ResolvedPathname } from '$app/types';
import { page } from '$app/state'; import { page } from '$app/state';
import { beforeNavigate } from '$app/navigation'; import { beforeNavigate } from '$app/navigation';
@ -10,7 +11,6 @@
NOMBRES_TIPO_PERSONA, NOMBRES_TIPO_PERSONA,
tiposDePersona, tiposDePersona,
tipoInicialCredito, tipoInicialCredito,
nombreEstado,
pendientes, pendientes,
type Contenido, type Contenido,
type Tipo, type Tipo,
@ -180,9 +180,7 @@
<p class="eyebrow">{def.singular}</p> <p class="eyebrow">{def.singular}</p>
<h1>{data.documento.borrador.titulo}</h1> <h1>{data.documento.borrador.titulo}</h1>
<div class="acciones"> <div class="acciones">
<span class="estado {data.documento.estado}">{nombreEstado[data.documento.estado]}</span {#if tipo === 'grabaciones'}{@const cancion = refs.find(
>{#if data.documento.cambios && data.documento.publicado}<small>Cambios sin publicar</small
>{/if}{#if tipo === 'grabaciones'}{@const cancion = refs.find(
(r) => r.id === contenido.campos.cancionId (r) => r.id === contenido.campos.cancionId
)}{#if cancion}<small )}{#if cancion}<small
>Versión de <a >Versión de <a
@ -194,26 +192,29 @@
</div> </div>
</header> </header>
<div class="flujo-editor"> <div class="flujo-editor">
<div class="estado-editor" aria-live="polite"> <details class="estado-editor">
<strong>{guardando ? 'Guardando borrador…' : publicando ? 'Publicando…' : estadoEdicion}</strong <summary aria-live="polite">
> <strong
>{guardando ? 'Guardando borrador…' : publicando ? 'Publicando…' : estadoEdicion}</strong
>
</summary>
<p>{mensajeEstado}</p> <p>{mensajeEstado}</p>
</div> </details>
<div class="acciones"> <div class="acciones">
<button <button
class="boton" class="ui-button boton"
type="submit" type="submit"
form="ficha" form="ficha"
disabled={!listo || !sucio || guardando || subiendo || publicando} disabled={!listo || !sucio || guardando || subiendo || publicando}
>{guardando ? 'Guardando…' : 'Guardar borrador'}</button >{guardando ? 'Guardando…' : 'Guardar borrador'}</button
> >
<a <a
class="boton" class="ui-button boton"
href={resolve('/(dentro)/[tipo]/[id]/vista-previa', { tipo, id: data.documento.id })} href={resolve('/(dentro)/[tipo]/[id]/vista-previa', { tipo, id: data.documento.id })}
target="_blank">Vista previa ↗</a target="_blank">Vista previa <Icono nombre="externo" tamano={16} /></a
> >
<button <button
class="boton primario" class="ui-button boton primario"
type="button" type="button"
disabled={!listo || guardando || subiendo || publicando} disabled={!listo || guardando || subiendo || publicando}
onclick={() => revisionPublicacion.showModal()}>Revisar y publicar</button onclick={() => revisionPublicacion.showModal()}>Revisar y publicar</button
@ -315,10 +316,10 @@
<p class="muted"> <p class="muted">
Cada grabación tiene sus propios intérpretes, letra, clasificación y archivos. Cada grabación tiene sus propios intérpretes, letra, clasificación y archivos.
</p> </p>
<a class="boton primario" href={nuevaVersion}>+ Añadir versión</a> <a class="ui-button boton primario" href={nuevaVersion}>+ Añadir versión</a>
</div> </div>
{#each grabaciones as g (g.id)}<div class="fila-grabacion"> {#each grabaciones as g (g.id)}<div class="fila-grabacion">
<span class="miniatura">♫</span> <span class="miniatura"><Icono nombre="musica" tamano={20} /></span>
<div> <div>
<a href={resolve('/(dentro)/[tipo]/[id]', { tipo: 'grabaciones', id: g.id })} <a href={resolve('/(dentro)/[tipo]/[id]', { tipo: 'grabaciones', id: g.id })}
>{g.titulo}</a >{g.titulo}</a
@ -329,7 +330,7 @@
> >
</div> </div>
<a <a
class="boton pequeno" class="ui-button boton pequeno"
href={resolve('/(dentro)/[tipo]/[id]', { tipo: 'grabaciones', id: g.id })} href={resolve('/(dentro)/[tipo]/[id]', { tipo: 'grabaciones', id: g.id })}
>Abrir →</a >Abrir →</a
> >
@ -364,7 +365,7 @@
'Empieza por los datos de la ficha y completa sus relaciones.' 'Empieza por los datos de la ficha y completa sus relaciones.'
)} )}
</p> </p>
<a class="boton" href={urlTab('datos')}>Editar datos →</a>{/if} <a class="ui-button boton" href={urlTab('datos')}>Editar datos →</a>{/if}
</section> </section>
{#if data.relacionados.length}<section class="tarjeta relleno separado"> {#if data.relacionados.length}<section class="tarjeta relleno separado">
<h2>Dónde se utiliza</h2> <h2>Dónde se utiliza</h2>
@ -448,7 +449,7 @@
cuadrado</label cuadrado</label
>{/if} >{/if}
<div class="acciones separado"> <div class="acciones separado">
<button class="boton" disabled={sucio || subiendo} <button class="ui-button boton" disabled={sucio || subiendo}
>{subiendo ? 'Procesando archivo…' : 'Subir y asignar'}</button >{subiendo ? 'Procesando archivo…' : 'Subir y asignar'}</button
>{#if sucio}<small>Guarda la ficha antes de subir archivos.</small>{/if} >{#if sucio}<small>Guarda la ficha antes de subir archivos.</small>{/if}
</div> </div>
@ -467,7 +468,7 @@
value={a.id}>{a.nombre}</option value={a.id}>{a.nombre}</option
>{/each}</select >{/each}</select
></label ></label
><button class="boton" disabled={sucio || subiendo}>Asignar</button> ><button class="ui-button boton" disabled={sucio || subiendo}>Asignar</button>
</form> </form>
</details> </details>
</section>{/each}{/if} </section>{/each}{/if}
@ -493,8 +494,8 @@
la publicación. la publicación.
</p>{/if} </p>{/if}
</section> </section>
<section class="tarjeta relleno"> <details class="tarjeta relleno">
<h2>Guardado y publicación</h2> <summary>Guardado y publicación</summary>
<ol class="explicacion-flujo"> <ol class="explicacion-flujo">
<li> <li>
<strong>Edita</strong><span <strong>Edita</strong><span
@ -513,7 +514,7 @@
</li> </li>
</ol> </ol>
<small>Último guardado: {new Date(data.documento.actualizado).toLocaleString('es-ES')}</small> <small>Último guardado: {new Date(data.documento.actualizado).toLocaleString('es-ES')}</small>
</section> </details>
{#if data.documento.estado === 'publicado'}<details class="tarjeta relleno"> {#if data.documento.estado === 'publicado'}<details class="tarjeta relleno">
<summary>Retirar publicación</summary> <summary>Retirar publicación</summary>
<p class="separado"> <p class="separado">
@ -523,8 +524,9 @@
<input type="hidden" name="revision" value={data.documento.revision} /><label <input type="hidden" name="revision" value={data.documento.revision} /><label
class="check" class="check"
><input type="checkbox" name="confirmar" value="si" required />Confirmo la retirada</label ><input type="checkbox" name="confirmar" value="si" required />Confirmo la retirada</label
><button class="boton peligro separado" disabled={sucio || publicando || guardando} ><button
>Retirar publicación</button class="ui-button boton peligro separado"
disabled={sucio || publicando || guardando}>Retirar publicación</button
> >
</form> </form>
{#if sucio}<p>Guarda tus cambios antes de retirar.</p>{/if} {#if sucio}<p>Guarda tus cambios antes de retirar.</p>{/if}
@ -591,9 +593,9 @@
</div> </div>
</fieldset>{/if} </fieldset>{/if}
<div class="acciones"> <div class="acciones">
<button type="button" class="boton" onclick={() => dialogo.close()}>Cancelar</button><button <button type="button" class="ui-button boton" onclick={() => dialogo.close()}>Cancelar</button
class="boton primario" ><button class="ui-button boton primario" disabled={creando}
disabled={creando}>{creando ? 'Creando…' : 'Crear y seleccionar'}</button >{creando ? 'Creando…' : 'Crear y seleccionar'}</button
> >
</div> </div>
</form> </form>
@ -656,11 +658,11 @@
<div class="acciones"> <div class="acciones">
<button <button
type="button" type="button"
class="boton" class="ui-button boton"
disabled={publicando} disabled={publicando}
onclick={() => revisionPublicacion.close()}>Volver a editar</button onclick={() => revisionPublicacion.close()}>Volver a editar</button
><button ><button
class="boton primario" class="ui-button boton primario"
disabled={publicando || !!faltan.length || !!referenciasPendientes.length} disabled={publicando || !!faltan.length || !!referenciasPendientes.length}
>{publicando >{publicando
? 'Publicando…' ? 'Publicando…'
@ -677,25 +679,30 @@
<style> <style>
.flujo-editor { .flujo-editor {
position: sticky; position: sticky;
top: 78px; top: 64px;
z-index: 15; z-index: 15;
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: var(--sp-4); gap: var(--sp-4);
padding: var(--sp-4) var(--sp-5); padding: var(--sp-3) var(--sp-4);
background: var(--papel); background: var(--papel);
border: 1px solid var(--borde); border: 1px solid var(--borde);
border-radius: var(--radius-tarjeta); border-radius: var(--radius-tarjeta);
margin-bottom: var(--sp-5); margin-bottom: var(--sp-5);
box-shadow: var(--shadow-popover); box-shadow: none;
} }
.estado-editor { .estado-editor {
flex: 1 1 22rem; flex: 1 1 12rem;
font: var(--ui-label-font);
} }
.estado-editor strong { .estado-editor strong {
color: var(--acento); color: var(--texto);
font-weight: var(--font-weight-medium);
}
.estado-editor summary {
cursor: pointer;
} }
.estado-editor p { .estado-editor p {
margin: var(--sp-2) 0 0; margin: var(--sp-2) 0 0;

@ -23,7 +23,7 @@
</div> </div>
<div class="acciones separado"> <div class="acciones separado">
<a <a
class="boton" class="ui-button boton"
href={resolve('/(dentro)/[tipo]/[id]', { tipo: data.documento.tipo, id: data.documento.id })} href={resolve('/(dentro)/[tipo]/[id]', { tipo: data.documento.tipo, id: data.documento.id })}
>← Volver a editar</a >← Volver a editar</a
> >

@ -70,7 +70,7 @@
{#if form?.error}<p class="aviso error" role="alert">{form.error}</p>{/if} {#if form?.error}<p class="aviso error" role="alert">{form.error}</p>{/if}
<div class="editor-barra"> <div class="editor-barra">
<a href={resolve('/(dentro)/[tipo]', { tipo: data.tipo })}>Cancelar</a><button <a href={resolve('/(dentro)/[tipo]', { tipo: data.tipo })}>Cancelar</a><button
class="boton primario" class="ui-button boton primario"
disabled={guardando}>{guardando ? 'Creando…' : 'Crear borrador →'}</button disabled={guardando}>{guardando ? 'Creando…' : 'Crear borrador →'}</button
> >
</div> </div>

@ -0,0 +1,6 @@
import { dev } from '$app/environment';
import { error } from '@sveltejs/kit';
export const load = () => {
if (!dev) error(404, 'No encontrado');
return {};
};

@ -0,0 +1,5 @@
<script lang="ts">
import GaleriaControles from '$lib/components/GaleriaControles.svelte';
</script>
<GaleriaControles administracion />

@ -14,13 +14,15 @@
</header> </header>
<section class="tarjeta"> <section class="tarjeta">
<form class="filtros" method="GET"> <form class="filtros" method="GET">
<label class="oculto" for="consulta">Buscar fichas</label><input <label class="filtro" for="consulta"
id="consulta" >Buscar fichas<input
name="q" id="consulta"
type="search" name="q"
value={data.q} type="search"
placeholder="Escribe un nombre o título…" value={data.q}
/><button class="boton primario">Buscar</button> placeholder="Escribe un nombre o título…"
/></label
><button class="ui-button boton primario">Buscar</button>
</form> </form>
<div class="tabla-scroll"> <div class="tabla-scroll">
<table> <table>

@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { resolve } from '$app/paths'; import { resolve } from '$app/paths';
import VistaPreviaMedio from '../../../components/VistaPreviaMedio.svelte';
let { data } = $props(); let { data } = $props();
let filtro = $state(''); let filtro = $state('');
let clase = $state(''); let clase = $state('');
@ -20,19 +21,20 @@
</header> </header>
<section class="tarjeta"> <section class="tarjeta">
<div class="filtros"> <div class="filtros">
<label style="flex:1" <label class="filtro"
>Buscar archivo<input >Buscar archivo<input
type="search" type="search"
bind:value={filtro} bind:value={filtro}
placeholder="Nombre del archivo…" placeholder="Nombre del archivo…"
/></label /></label
><label ><label class="filtro"
>Tipo<select bind:value={clase} >Tipo<select bind:value={clase}
><option value="">Todos</option><option value="imagen">Imágenes</option><option ><option value="">Todos</option><option value="imagen">Imágenes</option><option
value="audio">Audio</option value="audio">Audio</option
></select ></select
></label ></label
> >
<small class="filtro-ayuda">Los resultados se actualizan al escribir.</small>
</div> </div>
<div class="tabla-scroll"> <div class="tabla-scroll">
<table> <table>
@ -46,18 +48,11 @@
href={resolve('/(dentro)/[tipo]/[id]', { tipo: u.tipo, id: u.id })}>{u.titulo}</a href={resolve('/(dentro)/[tipo]/[id]', { tipo: u.tipo, id: u.id })}>{u.titulo}</a
>{:else}<small>Disponible para asignar</small>{/each}</td >{:else}<small>Disponible para asignar</small>{/each}</td
><td ><td
>{#if a.clase === 'imagen'}<img ><VistaPreviaMedio
src={resolve('/(dentro)/archivo/[id]', { id: a.id })} src={resolve('/(dentro)/archivo/[id]', { id: a.id })}
loading="lazy" nombre={a.nombre}
alt={a.nombre} clase={a.clase}
width="48" /></td
height="48"
style="object-fit:cover;border-radius:5px"
/>{:else}<audio
controls
preload="none"
src={resolve('/(dentro)/archivo/[id]', { id: a.id })}
></audio>{/if}</td
></tr ></tr
>{:else}<tr><td colspan="4">No hay archivos con estos filtros.</td></tr>{/each}</tbody >{:else}<tr><td colspan="4">No hay archivos con estos filtros.</td></tr>{/each}</tbody
> >
@ -94,8 +89,7 @@
font-size: var(--fs-base); font-size: var(--fs-base);
padding: var(--sp-2) 0; padding: var(--sp-2) 0;
} }
audio { .filtro-ayuda {
width: 230px; flex-basis: 100%;
max-width: 100%;
} }
</style> </style>

@ -23,21 +23,22 @@
</header> </header>
<section class="tarjeta"> <section class="tarjeta">
<form class="filtros" method="GET"> <form class="filtros" method="GET">
<label class="oculto" for="q">Buscar cuenta</label><input <label class="filtro" for="q"
id="q" >Buscar cuenta<input
name="q" id="q"
type="search" name="q"
value={data.q} type="search"
placeholder="Buscar por correo…" value={data.q}
/><label class="oculto" for="estado">Estado</label><select placeholder="Buscar por correo…"
id="estado" /></label
name="estado" ><label class="filtro" for="estado"
value={data.estado} >Estado<select id="estado" name="estado" value={data.estado}
><option value="">Todos los estados</option ><option value="">Todos los estados</option
>{#each data.seccion === 'pedidos' ? ['pendiente', 'pagado', 'cancelado'] : data.seccion === 'cuentas' ? ['persona', 'admin'] : ['activa', 'pendiente', 'cancelada', 'vencida', 'pausada'] as e (e)}<option >{#each data.seccion === 'pedidos' ? ['pendiente', 'pagado', 'cancelado'] : data.seccion === 'cuentas' ? ['persona', 'admin'] : ['activa', 'pendiente', 'cancelada', 'vencida', 'pausada'] as e (e)}<option
value={e}>{e[0].toUpperCase() + e.slice(1)}</option value={e}>{e[0].toUpperCase() + e.slice(1)}</option
>{/each}</select >{/each}</select
><button class="boton">Buscar</button> ></label
><button class="ui-button boton">Buscar</button>
</form> </form>
<div class="tabla-scroll"> <div class="tabla-scroll">
<table> <table>
@ -74,9 +75,10 @@
<div class="paginacion"> <div class="paginacion">
<span>{data.total} registros · Página {data.pagina}</span> <span>{data.total} registros · Página {data.pagina}</span>
<div class="acciones"> <div class="acciones">
{#if data.pagina > 1}<a class="boton pequeno" href={pagina(data.pagina - 1)}>Anterior</a {#if data.pagina > 1}<a class="ui-button boton pequeno" href={pagina(data.pagina - 1)}
>Anterior</a
>{/if}{#if data.pagina * 25 < data.total}<a >{/if}{#if data.pagina * 25 < data.total}<a
class="boton pequeno" class="ui-button boton pequeno"
href={pagina(data.pagina + 1)}>Siguiente</a href={pagina(data.pagina + 1)}>Siguiente</a
>{/if} >{/if}
</div> </div>

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

Loading…
Cancel
Save

Powered by TurnKey Linux.