La portada pasa de 17rem a 22rem y la columna del título crece hasta su altura, así que los botones se descuelgan y su base queda a la misma línea que la de la portada: 494 y 494 medidos en el navegador. El aviso de venta y las plataformas suben por encima de ellos, porque lo que se viene a hacer va abajo del todo. La separación la pone un `gap` y no un margen: ninguna de las dos cosas está siempre, y un margen sobre un hueco vacío despegaba los botones justo en las fichas que no las tienen. Las etiquetas no salían porque no había ninguna: las diez de «Cupido sin flechas» entraron sin ellas. No están en el material —el prompt de Suno dice cómo suena el tema, no de qué va—, así que van escritas a mano en `sembrar-etiquetas-cupido.mjs`, donde se leen y se discuten, reusando el vocabulario que ya existía siempre que encajaba. Y se pintan en fantasma y en color. Sin relleno: son un dato, no una acción, y un botón lleno al lado del de escuchar competiría con él. El tono sale del nombre —nueve elegidos, saltando el marino del fondo y el oro del acento—, así que «Desamor» es del mismo color en la ficha, en el blog y en la portada sin tabla que mantener. Medidos los dieciocho casos: el peor da 6,14 sobre 4,5 que pide el texto pequeño. El botón de añadir al pack faltaba por lo mismo: el importador ponía `estado = publicado` pero dejaba `ala_venta` en el false de la columna, y un tema publicado y no a la venta sale sin botón. Ahora lo pone al crear la canción y no lo toca al reimportar, que esa decisión es del panel. Las diez ya están a la venta. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>master
parent
af40c25e1b
commit
fef07e6b8b
@ -0,0 +1,125 @@
|
|||||||
|
/**
|
||||||
|
* Pone las etiquetas de «Cupido sin flechas».
|
||||||
|
*
|
||||||
|
* npm run db:etiquetas-cupido
|
||||||
|
* npm run db:etiquetas-cupido -- --escribir
|
||||||
|
*
|
||||||
|
* **Por defecto no toca nada.** Dice lo que pondría y se para.
|
||||||
|
*
|
||||||
|
* El importador trae el texto y el colocador trae los archivos; las etiquetas no
|
||||||
|
* las trae nadie, porque no están en el material: los `.txt` de Suno llevan el
|
||||||
|
* prompt de estilo —instrumentos, tempo, timbre de la voz—, que describe cómo
|
||||||
|
* suena y no de qué va. La etiqueta es una decisión editorial, así que va
|
||||||
|
* escrita a mano y aquí, donde se puede leer y discutir, en vez de deducida.
|
||||||
|
*
|
||||||
|
* Se reutiliza el vocabulario que ya existe siempre que encaja —«Mentiras»,
|
||||||
|
* «Rabia», «Melancolía», «Despedidas»— para que la página de cada etiqueta junte
|
||||||
|
* estos temas con los de antes en lugar de abrir un vocabulario paralelo con
|
||||||
|
* sinónimos.
|
||||||
|
*
|
||||||
|
* Idempotente: vuelve a lanzarse y deja lo mismo, sin duplicar.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import pg from 'pg';
|
||||||
|
|
||||||
|
const ESCRIBIR = process.argv.includes('--escribir');
|
||||||
|
|
||||||
|
const DISCO = 'cupido-sin-flechas';
|
||||||
|
|
||||||
|
/** El orden dentro de cada canción es el que se pinta. De más a menos central. */
|
||||||
|
const POR_CANCION = {
|
||||||
|
'amame-esta-noche-nada-mas': ['Deseo', 'Una noche', 'Autoengaño'],
|
||||||
|
'cuando-tus-manos-me-tocan': ['Deseo', 'Entrega'],
|
||||||
|
'inventario-a-los-56': ['Paso del tiempo', 'Balance'],
|
||||||
|
'no-me-mientas': ['Mentiras', 'Celos', 'Ruptura'],
|
||||||
|
'pague-por-una-noche-de-amor': ['Una noche', 'Soledad', 'Compasión'],
|
||||||
|
'para-que-recordar': ['Melancolía', 'Recuerdos'],
|
||||||
|
prometiste: ['Promesas', 'Desamor'],
|
||||||
|
quedate: ['Deseo', 'Despedidas', 'Súplica'],
|
||||||
|
'ton-silence-me-libere': ['Silencio', 'Rabia', 'Ruptura'],
|
||||||
|
'tus-malas-maneras': ['Rabia', 'Despecho', 'Mentiras']
|
||||||
|
};
|
||||||
|
|
||||||
|
/** El mismo que `aSlug` en `$lib/content/schema`: la web resuelve por él. */
|
||||||
|
const aSlug = (texto) =>
|
||||||
|
texto
|
||||||
|
.normalize('NFD')
|
||||||
|
.replace(/\p{Diacritic}/gu, '')
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/^-+|-+$/g, '');
|
||||||
|
|
||||||
|
const c = new pg.Client({ connectionString: process.env.DATABASE_URL, query_timeout: 30_000 });
|
||||||
|
await c.connect();
|
||||||
|
|
||||||
|
const { rows: canciones } = await c.query(
|
||||||
|
`select c.id, c.slug, c.titulo, c.numero
|
||||||
|
from cancion c join album a on a.id = c.album_id
|
||||||
|
where a.slug = $1 order by c.numero`,
|
||||||
|
[DISCO]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (canciones.length === 0) {
|
||||||
|
console.error(`No hay ningún disco «${DISCO}» en la base.`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { rows: yaHay } = await c.query('select slug, nombre from etiqueta');
|
||||||
|
const conocidas = new Set(yaHay.map((e) => e.slug));
|
||||||
|
|
||||||
|
console.log(`\n${canciones.length} temas de «${DISCO}»\n`);
|
||||||
|
|
||||||
|
const sinConocer = new Set();
|
||||||
|
for (const cancion of canciones) {
|
||||||
|
const nombres = POR_CANCION[cancion.slug];
|
||||||
|
if (!nombres) {
|
||||||
|
console.log(` ${String(cancion.numero).padStart(2, '0')} ${cancion.titulo} — SIN ASIGNAR`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const pintadas = nombres.map((n) => (conocidas.has(aSlug(n)) ? n : `${n}*`));
|
||||||
|
for (const n of nombres) if (!conocidas.has(aSlug(n))) sinConocer.add(n);
|
||||||
|
console.log(
|
||||||
|
` ${String(cancion.numero).padStart(2, '0')} ${cancion.titulo.padEnd(30)} ${pintadas.join(' · ')}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sinConocer.size > 0) {
|
||||||
|
console.log(`\n* etiquetas nuevas (${sinConocer.size}): ${[...sinConocer].join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ESCRIBIR) {
|
||||||
|
console.log('\nEnsayo. No se ha escrito nada. Añade --escribir.');
|
||||||
|
await c.end();
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
let puestas = 0;
|
||||||
|
for (const cancion of canciones) {
|
||||||
|
const nombres = POR_CANCION[cancion.slug];
|
||||||
|
if (!nombres) continue;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Se borra primero lo que tenga: si aquí se le quita una etiqueta, tiene que
|
||||||
|
* desaparecer de la canción, no quedarse por no haberla nombrado.
|
||||||
|
*/
|
||||||
|
await c.query('delete from cancion_etiqueta where cancion_id = $1', [cancion.id]);
|
||||||
|
|
||||||
|
for (const [orden, nombre] of nombres.entries()) {
|
||||||
|
const { rows } = await c.query(
|
||||||
|
`insert into etiqueta (id, slug, nombre) values (gen_random_uuid(), $1, $2)
|
||||||
|
on conflict (slug) do update set nombre = excluded.nombre
|
||||||
|
returning id`,
|
||||||
|
[aSlug(nombre), nombre]
|
||||||
|
);
|
||||||
|
await c.query(
|
||||||
|
`insert into cancion_etiqueta (cancion_id, etiqueta_id, orden) values ($1, $2, $3)
|
||||||
|
on conflict (cancion_id, etiqueta_id) do update set orden = excluded.orden`,
|
||||||
|
[cancion.id, rows[0].id, orden]
|
||||||
|
);
|
||||||
|
puestas++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await c.end();
|
||||||
|
console.log(`\nHecho: ${puestas} etiquetas en ${canciones.length} temas.`);
|
||||||
|
console.log('El catálogo se cachea 60 s: si la web aún no las enseña, espera o reinicia.');
|
||||||
@ -0,0 +1,110 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
/**
|
||||||
|
* Las etiquetas de un tema o de una entrada, en fantasma y con color.
|
||||||
|
*
|
||||||
|
* Fantasma: sin relleno. Son un dato, no una acción, y un botón lleno al lado
|
||||||
|
* del botón de escuchar competiría con él. El relleno aparece solo al pasar
|
||||||
|
* por encima, que es cuando sí hay una acción.
|
||||||
|
*
|
||||||
|
* Con color: el tono sale del nombre —ver `$lib/utils/etiquetas`—, así que la
|
||||||
|
* misma etiqueta es del mismo color en toda la web. El color distingue; no
|
||||||
|
* significa nada, y por eso ninguna información va solo en él: el nombre
|
||||||
|
* siempre está escrito.
|
||||||
|
*
|
||||||
|
* La claridad y el croma se fijan aquí, iguales para los nueve tonos, para que
|
||||||
|
* ninguna etiqueta grite más que otra y todas se lean sobre el fondo.
|
||||||
|
*/
|
||||||
|
import { resolve } from '$app/paths';
|
||||||
|
import { aSlug } from '$lib/content/schema';
|
||||||
|
import { tonoDeEtiqueta } from '$lib/utils/etiquetas';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
etiquetas: string[];
|
||||||
|
/** Título accesible de la lista. */
|
||||||
|
titulo?: string;
|
||||||
|
/**
|
||||||
|
* Sin enlaces, para cuando ya van dentro de algo clicable: un enlace
|
||||||
|
* dentro de otro deja una zona donde el clic hace una cosa distinta según
|
||||||
|
* el píxel exacto.
|
||||||
|
*/
|
||||||
|
enlaces?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { etiquetas, titulo = 'Etiquetas', enlaces = true }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if etiquetas.length > 0}
|
||||||
|
<ul class="etiquetas" aria-label={titulo}>
|
||||||
|
{#each etiquetas as etiqueta (etiqueta)}
|
||||||
|
<li>
|
||||||
|
{#if enlaces}
|
||||||
|
<a
|
||||||
|
class="etiqueta"
|
||||||
|
style="--tono: {tonoDeEtiqueta(etiqueta)}"
|
||||||
|
href={resolve('/etiquetas/[etiqueta]', { etiqueta: aSlug(etiqueta) })}
|
||||||
|
>
|
||||||
|
{etiqueta}
|
||||||
|
</a>
|
||||||
|
{:else}
|
||||||
|
<span class="etiqueta" style="--tono: {tonoDeEtiqueta(etiqueta)}">{etiqueta}</span>
|
||||||
|
{/if}
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.etiquetas {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* En oscuro el color va claro y el borde apagado; el relleno del hover es el
|
||||||
|
* mismo tono a un 12 % de opacidad, que sobre el marino se nota sin encender
|
||||||
|
* la etiqueta.
|
||||||
|
*/
|
||||||
|
.etiqueta {
|
||||||
|
--claro: oklch(0.84 0.11 var(--tono));
|
||||||
|
--apagado: oklch(0.52 0.08 var(--tono));
|
||||||
|
--velo: oklch(0.84 0.11 var(--tono) / 0.14);
|
||||||
|
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.2em 0.7em;
|
||||||
|
border: 1px solid var(--apagado);
|
||||||
|
border-radius: var(--radius-boton);
|
||||||
|
background: transparent;
|
||||||
|
font-size: var(--fs-xs);
|
||||||
|
color: var(--claro);
|
||||||
|
text-decoration: none;
|
||||||
|
transition:
|
||||||
|
background-color 0.3s var(--suave),
|
||||||
|
border-color 0.3s var(--suave);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* En claro se invierte: el texto tiene que bajar de claridad para leerse sobre
|
||||||
|
* el fondo casi blanco, y el borde subir para no ser un trazo negro.
|
||||||
|
*/
|
||||||
|
:global(:root[data-tema='claro']) .etiqueta {
|
||||||
|
--claro: oklch(0.45 0.13 var(--tono));
|
||||||
|
--apagado: oklch(0.78 0.08 var(--tono));
|
||||||
|
--velo: oklch(0.45 0.13 var(--tono) / 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
a.etiqueta:hover,
|
||||||
|
a.etiqueta:focus-visible {
|
||||||
|
border-color: var(--claro);
|
||||||
|
background: var(--velo);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.etiqueta {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -0,0 +1,61 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { tonoDeEtiqueta } from './etiquetas';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lo que se prueba no es qué número sale —eso lo decide el revoltijo y da igual—
|
||||||
|
* sino las tres cosas de las que depende que el color sirva para algo.
|
||||||
|
*/
|
||||||
|
describe('tonoDeEtiqueta', () => {
|
||||||
|
it('da siempre lo mismo para el mismo nombre', () => {
|
||||||
|
// Si esto fallara, la etiqueta cambiaría de color al hidratar.
|
||||||
|
expect(tonoDeEtiqueta('Desamor')).toBe(tonoDeEtiqueta('Desamor'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('no distingue mayúsculas ni tildes de más', () => {
|
||||||
|
expect(tonoDeEtiqueta('Desamor')).toBe(tonoDeEtiqueta('desamor'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('se queda en los tonos elegidos, fuera del marino y del oro', () => {
|
||||||
|
const nombres = [
|
||||||
|
'Desamor',
|
||||||
|
'Mentiras',
|
||||||
|
'Rabia',
|
||||||
|
'Deseo',
|
||||||
|
'Una noche',
|
||||||
|
'Soledad',
|
||||||
|
'Silencio',
|
||||||
|
'Recuerdos',
|
||||||
|
'Promesas',
|
||||||
|
'Despecho',
|
||||||
|
'Celos',
|
||||||
|
'Ruptura'
|
||||||
|
];
|
||||||
|
for (const n of nombres) {
|
||||||
|
const tono = tonoDeEtiqueta(n);
|
||||||
|
expect(tono).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(tono).toBeLessThan(360);
|
||||||
|
// El marino del fondo ronda los 264 y el oro del acento los 84.
|
||||||
|
expect(Math.abs(tono - 264)).toBeGreaterThan(20);
|
||||||
|
expect(Math.abs(tono - 84)).toBeGreaterThan(20);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reparte: doce etiquetas no caen todas en el mismo color', () => {
|
||||||
|
const nombres = [
|
||||||
|
'Desamor',
|
||||||
|
'Mentiras',
|
||||||
|
'Rabia',
|
||||||
|
'Deseo',
|
||||||
|
'Una noche',
|
||||||
|
'Soledad',
|
||||||
|
'Silencio',
|
||||||
|
'Recuerdos',
|
||||||
|
'Promesas',
|
||||||
|
'Despecho',
|
||||||
|
'Celos',
|
||||||
|
'Ruptura'
|
||||||
|
];
|
||||||
|
const tonos = new Set(nombres.map(tonoDeEtiqueta));
|
||||||
|
expect(tonos.size).toBeGreaterThanOrEqual(5);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -0,0 +1,38 @@
|
|||||||
|
/**
|
||||||
|
* El color de una etiqueta.
|
||||||
|
*
|
||||||
|
* No hay tabla de colores porque no hay tabla de etiquetas: se crean escribiendo
|
||||||
|
* una. El tono sale del propio nombre, así que «Desamor» es del mismo color en
|
||||||
|
* la ficha, en el blog y en la página de la etiqueta sin que nadie lo mantenga,
|
||||||
|
* y una etiqueta nueva nace con el suyo.
|
||||||
|
*
|
||||||
|
* Los tonos no son los 360 del círculo sino nueve elegidos. Dos motivos: entre
|
||||||
|
* tonos vecinos no se distingue nada —un 210 y un 218 son el mismo color— y hay
|
||||||
|
* dos zonas prohibidas, el marino del fondo y el oro del acento, donde una
|
||||||
|
* etiqueta o se pierde o parece un botón.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Los nueve. Saltan el marino (~264) y el oro (~84), y están lo bastante
|
||||||
|
* separados como para no confundirse de dos en dos.
|
||||||
|
*/
|
||||||
|
const TONOS = [20, 45, 135, 165, 200, 230, 290, 320, 350];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FNV-1a de 32 bits. Vale cualquier función que reparta; esta se elige porque
|
||||||
|
* cabe en cinco líneas y da lo mismo en el servidor y en el navegador, que es
|
||||||
|
* lo único que importa aquí: el color no puede cambiar al hidratar.
|
||||||
|
*/
|
||||||
|
function revoltijo(texto: string): number {
|
||||||
|
let h = 0x811c9dc5;
|
||||||
|
for (let i = 0; i < texto.length; i++) {
|
||||||
|
h ^= texto.charCodeAt(i);
|
||||||
|
h = Math.imul(h, 0x01000193);
|
||||||
|
}
|
||||||
|
return h >>> 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** El tono en grados que le toca a una etiqueta por su nombre. */
|
||||||
|
export function tonoDeEtiqueta(nombre: string): number {
|
||||||
|
return TONOS[revoltijo(nombre.toLowerCase()) % TONOS.length];
|
||||||
|
}
|
||||||
Loading…
Reference in new issue