La queja era justa y va por la tercera vez, así que esta vez queda medida
y con prueba: la franja de datos, el índice del capítulo y los bloques de
abajo terminan los tres en el mismo borde.
Lo que no se puede hacer es ensanchar el texto. Para llenar novecientos
píxeles con prosa legible haría falta letra de veinticuatro puntos; a
menos de eso salen ciento veinte caracteres por línea y el ojo pierde el
renglón. Ya se midió al hacer las páginas legales: estirado daba 129.
Así que lo que cubre ese ancho es lo que va al lado, y no es la lista de
compás y tempo que se quitó —esa subió a la franja—: es el índice de
apartados del propio capítulo, el mismo recurso que llevan los textos
legales. Y las columnas miden lo suyo con el sobrante en el corredor,
porque con anchos fijos quedaban ciento veinte píxeles muertos al final de
la fila, que es exactamente el hueco que se ve como un fallo.
Además, la semilla de géneros: países, veintiún instrumentos, veintiuna
personas, obras de referencia y el linaje del bolero-son, que deriva de
dos. Entra marcada como importada y sin revisar, porque la escribió un
modelo y se publica con el nombre del titular.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Los datos del género —compás, metro, tempo, rima— pasan de una lista de
definiciones apretada en una columna estrecha a la derecha a una franja a
todo lo ancho encima del texto. El compás y la métrica de un género se
leen juntos o no se leen, y en la lateral quedaban en letra pequeña con el
texto escorado.
La estructura, la temática y los rasgos bajan debajo de la prosa como
bloques a lo ancho: la temática en rejilla —son etiquetas disfrazadas de
frase— y los rasgos en lista, que son frases de verdad. Es el sitio donde
entrarán los instrumentos, los letristas, las obras de referencia y el
linaje cuando tengan datos.
Y el volcado de medios: 55 archivos a la tabla `medio` con su huella, su
peso y su duración MEDIDA, no escrita. De ahí salen dos cosas que no se
veían: el mismo audio está copiado bajo doce nombres distintos, y las
duraciones del catálogo dicen 252 segundos donde el archivo dura 6.
La clave de un medio lleva su almacén delante, porque `singles/dorina.mp3`
existe en audio y en muestras y sin eso serían la misma fila. Es además la
ruta con la que se servirán desde m.senzapaura.es/archives.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Los 48 archivos entran en la base: 18 canciones con sus 5 versiones como
filas hermanas, 4 discos, 4 estilos, 8 géneros, 14 capítulos, 9 personas,
53 etiquetas, 2 entradas, 5 legales y una página. La web sigue leyendo de
los archivos; esto solo llena el sitio donde caben.
Reutiliza los analizadores del sitio en vez de leer el frontmatter otra
vez. Es la decisión que sostiene todo lo demás: si un valor por defecto o
una normalización cambian, cambian para los dos. Un segundo analizador
«parecido» es lo que garantiza que un día la base y la web cuenten cosas
distintas sin que nadie lo note.
Todo en una transacción, y al final comprueba lo escrito contra lo leído
—la letra de cada tema carácter a carácter— porque «no dio error» no es lo
mismo que «está bien». Si no cuadra, deshace y dice qué falló. Y es
idempotente: se puede lanzar las veces que haga falta sin duplicar nada,
sin tocar cuentas, pedidos ni compras.
Para poder reutilizar ese código desde Node hizo falta un gancho de
resolución —`$lib`, imports sin extensión y un sustituto de `$app/paths`—
y quitar una propiedad de parámetro de `ErrorDeContenido`: es sintaxis
exclusiva de TypeScript y ningún cargador que solo borre tipos puede con
ella.
Y una corrección: el cuerpo del Markdown de una canción NO era un
comentario, es la letra. Lo dije al revés en el documento del modelo y lo
repetí varias veces. El campo `letra:` del frontmatter —que aparece en un
solo archivo— son los créditos de quién la firma. Corregido en el
documento, en el esquema, y quitada la columna `nota` que sobraba.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
52 tablas nuevas, una vista y las guardas que Drizzle no sabe expresar. El
sitio sigue leyendo de los archivos: esto solo crea el sitio donde caben.
El esquema pasa de archivo a carpeta —con el catálogo dentro pasaría de
las dos mil líneas—, y el corte se hizo por rangos de línea para no
reescribir los comentarios que explican decisiones. Se comprobó que era un
no-op: «No schema changes, nothing to migrate».
Lo que Drizzle no genera va escrito a mano al final de la migración, y es
justo lo que impide que el modelo mienta: la clave foránea de las
versiones contra su propia tabla, dos columnas generadas y una clave
compuesta que impiden a la vez la versión de una versión y los ciclos, la
vista `cancion_principal`, el disparador que exige que el reparto de
autoría sume cien, el que impide ciclos en el linaje de géneros, y los
índices parciales del estilo principal y de la arista principal.
Y las guardas se comprueban intentando lo que deben rechazar, en
`scripts/db/comprobar-guardas.mjs`. Que una restricción exista en
`pg_constraint` no significa que impida nada.
Tres cosas que salieron de hacerlo y no de suponerlo:
- `creado_en` era obligatorio SIN valor por defecto en la base: lo ponía
JavaScript al insertar con Drizzle, así que cualquier INSERT escrito a
mano fallaba y la invariante la sostenía la aplicación, no la tabla.
Ahora es `defaultNow()`.
- `drizzle-kit migrate` se atasca con los cuerpos `$$` de plpgsql. La
migración se aplicó con `scripts/db/probar-migracion.mjs`, que además
dice en qué sentencia falla, y se anotó en su registro.
- Dos pruebas pasaban por el motivo equivocado: una por clave duplicada en
vez de por la suma, y otra porque un disparador aplazado no salta dentro
de un savepoint que nunca se cierra. Las dos corregidas; una guarda dada
por buena sin ejecutarse es peor que no tenerla.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Las dos ultimas decisiones se toman por defecto eligiendo la opcion
reversible en cada caso: valoracion y favoritos en la cancion principal
—si cada version se valorase aparte, la media de la tarjeta dejaria de
significar nada— y prompts privados, porque publicarlos manana es cambiar
una columna y despublicarlos cuando ya estan indexados no.
Queda apuntado lo que no es una tabla y hay que hacer igual: reescribir
la politica de privacidad antes de guardar escuchas, la ventanilla unica
del IVA, y el DNS y el certificado de m.senzapaura.es.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Glosario: `termino` se parte en termino y acepciones numeradas, y el
marcado apunta a una acepcion. Eso cierra la duda del automatico: sabe
encontrar la palabra pero no elegir el sentido —«clave» es patron ritmico
en un texto de bolero y armadura en uno de armonia—, asi que propone y
una persona confirma.
Facturas: el correlativo sale de un contador con bloqueo y no de
max(numero)+1, que con dos compras simultaneas daria el mismo numero. Y
hacen falta rectificativas: una venta anulada no borra su factura. El IVA
es el del pais del comprador, no el nuestro.
Escuchas: se conservan por persona, y eso obliga a reescribir la politica
de privacidad, que hoy afirma que no hay seguimiento.
Medios en m.senzapaura.es: cambia una decision anterior. Estaba escrito
que las descargas no usan URLs firmadas porque la sesion va delante; en
otro dominio ya no va, asi que para el audio completo la firma deja de
ser opcional.
Fichas por JSON: el contrato de importacion pasa a ser parte del modelo,
con esquema, procedencia y creacion marcada de lo que la IA se invente.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Los temas salen del array a `genero_tema` y comparten vocabulario con las
etiquetas de las canciones. No es por ahorrar una tabla: es lo que
permite cruzar «el desamor: nueve temas mios y cuatro generos que viven
de el». Con dos listas separadas, «desamor» y «desamor» serian dos cosas
distintas y nadie lo veria.
Los rasgos se quedan como array porque si son prosa de verdad.
Y la completitud de una ficha se deduce de sus secciones y relaciones, sin
columna: una marca de progreso que se pone a mano miente a la semana.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`padre_id` no aguantaba. Un genero no deriva de uno: el bolero-son es
bolero y son, y la bachata viene de tres. `genero_origen` guarda el grafo
con el tipo de vinculo —deriva, fusiona, influye— y una arista marcada
como principal da el arbol de navegacion, asi que no hay que elegir entre
la verdad y la usabilidad.
Ese tercer tipo ya estaba escrito en el contenido, dentro del campo
equivocado: son-cubano.md pone la herencia de la decima y la copla dentro
de `metrica` porque no habia donde ponerla.
Y lo comparable sale de la prosa a vocabularios: el octosilabo aparece en
tres de las cinco fichas y no se podia preguntar por el. `metro` enlaza
ademas con el glosario, que es donde ese termino ya vive.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
La forma del prompt la define un tercero y cambia sin avisarnos, asi que
modelarla en columnas es perseguir el esquema de otro. Es el caso
contrario al del contenido del sitio, donde el esquema es nuestro.
Abierto no es sin validar: cada `modelo_ia` trae su JSON Schema, se
valida al guardar y el panel puede pintar el formulario a partir de el.
Y lo que se consulta —genero, animo, modelo, valoracion, idioma— se
queda en columnas.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Tres tablas: modelo_ia, animo y prompt.
El prompt cuelga del MODELO y no de la IA: uno de Suno v3 no es uno de
v4, y con la clave apuntando a "Suno" a secas queda una biblioteca sin
saber para que servia cada entrada.
El animo se codifica con la GEMS, hecha para emocion musical y no para
emocion en general —nostalgia es una de sus nueve categorias—, mas los
dos ejes de Russell, que ademas son redactables. Se llama `animo` y no
`version` a proposito: `version` ya significa otra cosa en este modelo.
Y un prompt no es un solo texto: Suno separa estilo de letra y admite
exclusiones, y guardar si funciono es lo que distingue una biblioteca de
un monton de texto.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Versiones: una version es una fila de `cancion` con `version_de_id`, no
una tabla aparte. Se descarto partir en obra y grabacion porque en ese
reparto el estilo y las etiquetas colgarian de la obra y una version no
podria tener los suyos. Van los cuatro cabos que hay que atar, empezando
por la vista `cancion_principal`: hay once sitios que listan o cuentan
canciones y a los once se les puede olvidar el filtro.
Generos: el catalogo aspira a ser completo. La ficha se amplia con
instrumentos, letristas, obras de referencia y secciones de prosa. Sin
tabla `letrista` —un letrista es una persona— y sin letras completas de
terceros, que tienen dueno: se cita y se enlaza.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Fase 1 de la migracion del contenido a PostgreSQL: las 41 entidades con
sus campos, sus relaciones y el motivo de cada decision. Sin codigo.
De analizarlo salieron dos cosas que no se buscaban:
- Cinco tablas apuntan a una cancion por una cadena de texto y ninguna
tiene clave foranea, porque el destino esta en un archivo. Renombrar un
slug deja huerfana una compra en silencio. Por eso ese arreglo es la
fase 3 y no la ultima.
- `estilo` hace dos trabajos: las secciones del catalogo y los generos
del taller. El pais que hay que clasificar es del genero, y mientras
sean lo mismo esa clasificacion no se puede ni escribir.
Y el ISRC estaba en la cancion cuando identifica una grabacion, asi que
pasa a la version.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
El color forma parte de lo que cuenta el sitio, y seguir a
`prefers-color-scheme` hacía que media parte de las visitas viera una
versión que no es la de la marca —un portátil configurado en claro no
tiene nada que decir sobre el aspecto de un catálogo de canciones—. Al
revés que en una herramienta de trabajo, aquí nadie va a pasar ocho horas
seguidas leyendo.
El claro sigue entero y a un clic, y la elección se recuerda. Lo que se
deja de hacer es decidirlo por nadie.
La prueba de contraste emulaba el esquema del sistema, así que con este
cambio habría pasado a comprobar dos veces lo mismo sin que nadie lo
notara: ahora pone `data-tema` a mano. Y la medida de claridad convierte
el `oklch()` pintándolo en un lienzo, porque desmontar la cadena a mano
daba NaN.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
La regla de que la red local entra siempre no sirve para el porton, y es
lo que mas despista: una peticion al dominio desde la propia red sale al
router y vuelve reescrita, asi que al proxy le llega con la IP publica.
Sin esa direccion en la lista, uno ve su propia pagina de espera en su
propio ordenador y no hay forma de entender por que.
La IP es fija y contratada, asi que la lista no hay que revisarla.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Con `PROXIMAMENTE=1`, quien no esté en la lista acaba en una página de
espera con el mismo fondo animado que la portada. La red local entra
siempre sin configurar nada: encender el portón y quedarse uno mismo
fuera es la forma más rápida de no poder comprobar si funciona.
Tres decisiones que salieron de probarlo:
- **Redirige, no pinta la espera en la dirección pedida.** Se intentó lo
segundo, que conserva la dirección en la barra, y no vale: el HTML sale
compuesto para /proximamente y el navegador lo hidrata creyendo estar
en otra ruta, así que al terminar de cargar volvía a pintar la cabecera
y el pie que la espera no debe tener.
- **503, no 200.** La web existe pero todavía no atiende. Es lo que
impide que un buscador se lleve el cartel como si fuera el sitio. Y el
robots.txt pasa a cerrar el paso entero mientras dure: si no,
anunciaría un mapa lleno de direcciones que hoy contestan «muy pronto».
- **Rutas de recursos absolutas.** Con las relativas que SvelteKit usa por
defecto, la página servida en otra dirección pedía sus hojas de estilo
en un sitio donde no hay ninguna.
El reconocimiento de direcciones va aparte y con pruebas: una máscara mal
interpretada no da ningún error, solo deja pasar a quien no debía. Ahí
está el caso de `/0`, que en JavaScript se cuela porque el desplazamiento
de 32 bits es un no-op.
Y el portón está abierto mientras nadie diga lo contrario. Una web que se
esconde sola porque falta una variable es peor fallo que una que se ve
antes de tiempo: nadie mira una página que cree no publicada, así que
puede pasar semanas caída sin que se note.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
En este repositorio npm es el gestor de paquetes de Node, el de npm run
dev. Abreviar Nginx Proxy Manager igual en un documento de despliegue
confunde justo a quien lo lee para montar el servidor.
De paso, el documento explica que es antes de decir donde tocarlo.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
El proxy no lo hace el Nginx de la maquina de la web, sino un Nginx
Proxy Manager en otro sitio de la red. Con eso delante, el Nginx local
sobra: adapter-node ya sirve los estaticos con sus cabeceras de cache, y
un segundo salto solo complica el recuento de IPs.
Y ese recuento importa mas de lo que parece. Sin ADDRESS_HEADER y
XFF_DEPTH, getClientAddress() devuelve la IP del proxy: el limitador del
formulario de acceso cuenta por direccion, asi que con todas las
peticiones pareciendo venir del mismo sitio salta con una sola persona
insistiendo y nadie mas puede pedir su codigo.
El documento incluye que registros de DNS cambiar y, sobre todo, cuales
no tocar: los MX, el SPF y el DKIM son el correo del dominio, y borrar
cualquiera de ellos da un sintoma que aparece dias despues.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
El servicio de systemd nunca llegó a instalarse y el sitio de fábrica de
Nginx seguía puesto: es él quien contesta a quien entra por la IP, así
que se veía su bienvenida aunque nuestro sitio estuviera bien
configurado.
`instalar.sh` hace las tres cosas que el despliegue normal no puede hacer
por sí solo —poner el unit, poner el sitio, quitar el de fábrica—, es
idempotente, y comprueba al final que Node y Nginx contestan de verdad en
vez de dar por hecho que sí.
El script va dentro de `despliegue/`, que ahora se sube con el resto: la
vez anterior remití a copiar de una ruta del servidor donde ese archivo
no estaba.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Cambia las referencias al dominio de trabajo: la URL canónica, los correos
de los textos legales, las direcciones de retorno de Google y Facebook, y
lo que espera la suite.
El `robots.txt` pasa de archivo estático a ruta generada. Llevaba el
dominio escrito a mano y se quedó anunciando el anterior: un mapa del
sitio mal anunciado no da ningún error, simplemente no lo encuentra
nadie. Ahora sale de `site.url`, igual que el mapa y las etiquetas de
cada página. De paso deja fuera del rastreo lo que depende de quién mira
—cuenta, carrito, favoritos, entrar, descargas—, que no es contenido.
Y el Nginx queda listo para el dominio, con los tres pasos que hay que
dar fuera del servidor: los registros A a 89.46.247.16, la redirección de
los puertos 80 y 443 a 192.168.18.171, y Certbot.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Detrás de la cuenta hay compras y facturación, y un código por correo apoya
toda esa puerta en el buzón: quien entre ahí, entra aquí. Delegar la
identidad en un proveedor que ya tiene doble factor la sostiene mejor.
Es el flujo de código de autorización con PKCE, escrito a mano porque son
cien líneas y las partes que importan son tres, mejor a la vista que
detrás de una dependencia: comparar el `state`, mandar el `code_verifier`
y exigir que el correo venga verificado antes de enlazar nada.
Lo que NO hace, a propósito: verificar la firma del `id_token`. La
identidad no se saca de lo que trae el navegador, sino de una llamada
nuestra al proveedor por TLS, así que no hay firma ajena que comprobar.
Y no guarda ningún token: se usa una vez para preguntar quién es y se
tira.
El enlazado por correo verificado es lo delicado, y por eso está separado
en `usuarioParaPerfil` con su explicación: sin exigir la verificación,
cualquiera podría poner la dirección de otro en un perfil suyo y quedarse
con su cuenta y sus compras.
El código por correo se queda debajo, como alternativa. Sin credenciales
puestas, los botones no se pintan y la ruta contesta 503: un botón que
solo lleva a un error es peor que no tenerlo.
Y los textos legales al día, que ahora sí hay terceros: qué se manda a
Google y a Facebook, cuándo, qué se guarda y cómo revocarlo.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
El acceso deja de ser un salto de página: «Entrar» abre un diálogo con la
imagen de marca a la izquierda y el formulario a la derecha, y al entrar
se vuelve a donde se estaba en vez de aterrizar en /cuenta. `/entrar`
sigue existiendo con el mismo formulario y la misma estampa, para quien
llegue por un enlace directo o sin JavaScript.
Tres cosas que salieron de probarlo, no de suponerlo:
- `showModal()` enfocaba el campo trampa contra envíos automáticos: está
fuera de pantalla y fuera del recorrido de tabulación, pero se puede
enfocar. Ahora el campo se enfoca a mano.
- Una regla de `display` en el diálogo le gana a la del navegador, que es
hoja de usuario. El diálogo cerrado se pintaba igual: una pantalla de
vacío al final de todas las páginas del sitio.
- Los enlaces legales navegaban por debajo del modal. Van en pestaña
aparte: se consultan y se vuelve con lo escrito intacto.
Y aparte, el carrusel de la portada. Cada lámina lleva su fondo con
`z-index: -1`, pero con `opacity: 1` la lámina no crea contexto de
apilamiento, así que ese fondo se escapaba y se pintaba detrás del
carrusel entero. Al pasar de lámina asomaba la foto de la de al lado y el
fondo de la que entraba saltaba hacia atrás al terminar el fundido.
Parecía que el shader se desincronizaba; lo que se movía era el orden de
pintado.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
El ancho útil vuelve a 74rem (1184px). Se había subido a 88rem para
aprovechar los monitores anchos y salió al revés: la prosa tiene una
medida de lectura y no crece con el contenedor, así que lo único que
creció fue el hueco a su derecha.
Un vacío al lado de un texto no se tapa estirando el texto —probado: 129
caracteres por línea, ilegible— sino poniendo algo al lado:
- Los textos legales van a tres columnas: los otros documentos, el que se
lee y sus apartados. El índice de apartados sale del Markdown en bruto
y las anclas las pone un plugin al compilarlo, así que hay pruebas de
que las dos formas de calcularlas siguen coincidiendo.
- El índice legal pasa de lista de renglones a rejilla de tarjetas, como
el taller y los estilos; era el único listado que no se veía como los
demás.
- La bio lleva al lado lo que hay publicado —temas, discos y estilos, con
sus enlaces—, sacado del catálogo y no escrito a mano, para que no se
contradiga sola el día que se suba una canción.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Cinco páginas legales en /legal, enlazadas desde el pie de todas las páginas:
información legal, condiciones de uso, privacidad, cookies y condiciones de
venta. Son borradores con los datos del titular marcados entre corchetes y un
aviso de que hace falta revisión de un abogado antes de vender: cubren lo que
pide la ley española, no son un documento validado.
La política de cookies y la de privacidad están escritas mirando el código, no
copiadas de una plantilla: dos cookies, las dos necesarias, y dos terceros
—Stripe y Resend— que reciben lo imprescindible.
Y eran tres, porque las tipografías venían de Google: cada visita mandaba la IP
del visitante a un tercero sin pedirlo y sin que aportara nada. Ahora se sirven
desde el propio dominio. Cargar la web ya no contacta con ningún dominio ajeno,
y hay una prueba que lo comprueba.
Aparte: el símbolo de marca registrada en el aviso de derechos, los botones de
reproducir de las tarjetas pasan de 48 a 36 píxeles —eran casi una quinta parte
de la portada, y cambiaban de proporción según la tarjeta— y el de ver el
carrito solo aparece cuando hay algo dentro.
De paso se arregla un fallo de tipos que estaba latente: `resolve()` tiene una
sobrecarga por ruta y no acepta una unión, así que la navegación dejaba de
compilar en cuanto se añadían rutas. Ahora se resuelve en `site.ts`, una a una.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
El texto del botón dorado se aclaraba al pasar el puntero y quedaba en 1,67 de
contraste sobre el oro. La causa no era el color del botón: `.btn:hover` pone
`color: inherit` —para que la regla general de los enlaces no tiña de dorado los
secundarios— y le gana por especificidad. Ahora el dorado repite su color ahí.
De paso, el dorado se parte en dos tokens porque hacía dos trabajos
incompatibles: de relleno tiene que ser claro para que encima se lea el marino
—7,6 de contraste en el tema claro— y escrito tiene que ser hondo para leerse
sobre el fondo —5,3—. Con un solo valor, el tema claro se quedaba en 4,4, por
debajo del mínimo. Y encima del oro va marino, no blanco, en los dos temas.
Los botones tenían tres tamaños distintos: el dorado a 16px con relleno 8/16 y
el de al lado a 14px con 12/24. Dos botones juntos con distinta letra se ven
como un descuido. Ahora hay una sola escala, y los 15px son los del menú.
El botón de ver el carrito solo aparece cuando hay algo dentro, con la cuenta:
uno que lleva a una página vacía gasta el sitio de una llamada a la acción para
decir que no hay nada.
Tres pruebas nuevas lo fijan, incluida la del contraste con el puntero encima,
que es donde estaba el fallo y donde una captura en reposo no lo enseña.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Los botones dejan de ser cápsulas y las tarjetas pierden la mitad del canto. De
paso se ordena el sistema: había tres radios sueltos y una píldora de 999px
repetida en quince archivos, y quedan dos —uno para lo que se pulsa, otro para
las superficies—. Los botones de icono suelto siguen redondos, que ahí sí tiene
sentido.
Las fichas del taller no usaban la tarjeta compartida: tenían su propio borde,
su propio canto y su propio hover, así que se veían de otra familia que las de
música. Ahora usan `.tarjeta`, con el mismo reflejo bajo el puntero y el mismo
subrayado.
Y la portada arranca en el borde de la ventana, con la cabecera flotando encima.
Para eso la cabecera pasa a medir su propio alto y publicarlo: estaba escrito a
mano en 4,75rem cuando mide 68px, y todo lo que dependía de ese número —el salto
a un ancla, las barras laterales pegajosas— quedaba desplazado.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Lo escribí suponiendo, lo probé a mano contra la máquina y las suposiciones
fallaron en dos sitios:
- `rsync` no está en la máquina de desarrollo. Git Bash no lo trae. Se sube con
`tar` por SSH, que sí está en los dos lados.
- `npm ci` no funciona en el servidor. Tiene salida a internet, pero su DNS
resuelve unos dominios y otros no: `google.com` sí, `registry.npmjs.org` no.
Las dependencias se resuelven aquí y se suben instaladas. Los tres paquetes de
producción son JavaScript puro, así que viajan tal cual; el script comprueba
que sigan siéndolo y se para si aparece un binario de plataforma.
Comprobado en el servidor y no solo en el papel: la web arranca, las siete rutas
que probé devuelven 200, la muestra de audio sale entera y la ficha lee las
valoraciones de Postgres por el bucle local.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
El servidor del usuario lleva Nginx, así que el montaje es proxy inverso por
delante y Node por detrás. La web no son archivos estáticos —cada página se
genera sabiendo quién la pide—, de modo que hace falta un proceso vivo y un
servicio que lo mantenga.
Nginx sirve además los estáticos por su cuenta: lo de `_app/immutable` lleva el
hash del contenido en el nombre y se cachea para siempre, así que abrir la web
deja de despertar a Node para devolver un CSS. Lo que no puede tocar son las
descargas: comprueban la compra antes de entregar el archivo, y el audio
completo vive fuera de `build/` para que no haya forma de pedirlo por URL.
El script construye aquí y sube el resultado, en vez de compilar en el servidor:
allí no hacen falta ni Vite ni el compilador, solo Node y tres paquetes. Las
migraciones también se lanzan desde aquí, porque `drizzle-kit` es herramienta de
desarrollo y en producción se instala con `--omit=dev`.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
El usuario ha puesto un servidor con PostgreSQL 16, así que la base pasa allí.
El catálogo no se toca: sigue siendo Markdown del repositorio; lo que se muda es
lo que generan las personas, que es lo único que estaba en la base.
Dos diferencias entre motores que habrían pasado desapercibidas y se cazaron
comprobando con datos de verdad, no compilando:
- `integer` en Postgres son cuatro bytes y en SQLite ocho. El limitador de
envíos guarda su vencimiento en milisegundos desde 1970 —trece dígitos, del
orden del billón— y en `integer` desbordaría, dejando de limitar. Va en
`bigint`.
- `avg()` y `count()` los entrega el driver como cadena para no perder
precisión. Sin convertirlas, `cuantas === 0` nunca se cumpliría y «1 voto»
saldría siempre en plural. Se convierten en la consulta.
Verificado contra el servidor: once tablas creadas, las 113 pruebas de extremo a
extremo y las 224 unitarias en verde, el servidor de producción arrancando, el
limitador escribiendo sus trece dígitos y el borrado en cascada llevándose
valoraciones y favoritos al borrar una cuenta.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
La página pasaba de las setecientas líneas y había que recorrerla entera para
tocar cualquier cosa. La columna de la derecha —valoración, ficha, créditos y
registro— son cuatro bloques de información que no tienen nada que ver con lo
que hace la página, que es presentar la letra y dejarla sonar.
Quedan 437 líneas en la página y 276 en el componente. De paso se va un bloque
de CSS muerto: un `@media` vacío con el comentario de las etiquetas, que se
quedó atrás cuando las pastillas pasaron a la clase global `.chip`.
Comprobado en el navegador, no solo compilando: los cuatro bloques salen en el
mismo orden, el de registro sigue apareciendo solo cuando hay algo que enseñar,
y no hay ningún error en consola.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
El botón dorado se quedaba en un rectángulo oscuro con el borde de oro al pasar
el puntero. La causa: `.btn:hover` usaba el atajo `background`, que reinicia
también `background-image` y se llevaba el degradado por delante. Las reglas
base pasan a `background-color`, que es la longhand que no pisa lo que no le
toca.
De comprobarlo salió que no había ningún `a:hover` global: cualquier enlace sin
regla propia —el del feed, los del pie— no daba ninguna señal de ser pulsable.
Ahora todos responden con el dorado, que es el color de lo accionable aquí.
Tampoco la tenía el conmutador de vista de la página de música.
El botón secundario acusa el puntero con el fondo y el canto, no tiñendo el
texto de dorado: sería una señal doble y competiría con el botón dorado que
suele tener al lado.
Seis pruebas nuevas cubren la respuesta al puntero de cada tipo de control. Es
lo que faltaba: un estado de hover roto no se ve en una captura en reposo, y así
fue como se coló.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
El pack pasa de cinco a diez canciones por el mismo 1,50 €: diez canciones por
lo que cuesta un café. Es la propuesta, así que el número y el importe se leen
juntos en todas partes.
Las pruebas de la tienda estaban escritas con el cinco metido a mano y hubo que
reescribirlas enteras. Ahora dicen la regla en términos del propio tamaño del
pack, y el helper de las pruebas de extremo a extremo añade lo que haya a la
venta hasta llenarlo en vez de nombrar los temas uno a uno: así cambiar el
tamaño otra vez no obliga a tocar nada.
Las dos láminas de llamada estrenan foto propia con su velo, más cubriente que
el de la textura: sobre una imagen clara el texto blanco no se lee.
Y el dorado deja de ser plano en los botones. Un metal refleja, y lo que lo hace
parecer metal es la banda de luz que lo cruza; el fondo se pinta más alto que el
botón para que al acercar el puntero la luz recorra la pieza en vez de
limitarse a aclararla. El acento sube un punto de croma y baja uno de claridad,
que es lo que lo separa de un amarillo de rotulador.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Un shader de ondas en los azules de marca, sobre la imagen de la portada. Es
WebGL a pelo: un fondo así es un triángulo y un programa de fragmentos, y
traerse una biblioteca 3D para eso sería medio mega de dependencia en la primera
pantalla del sitio.
El dibujo es domain warping —ruido que desplaza el dominio de otro ruido—, con
la escala horizontal mayor que la vertical para que las formas se tumben en
bandas en vez de quedarse redondas.
La paleta va en RGB lineal, que es en lo que mezcla el shader. El primer intento
usó los cuatro fondos de `app.css` tal cual y salió una plancha lisa: caben en
un pelo de claridad, y sin recorrido de color el ruido no se ve por bien que
esté calculado.
Con `prefers-reduced-motion` no se monta; se para al salir de pantalla y al
cambiar de pestaña; y si no hay WebGL o el programa no compila, debajo queda la
imagen de siempre. Solo lo lleva la cabecera: en las tres láminas serían tres
shaders pintando lo mismo a la vez.
De paso resuelve el tema claro, que no tenía imagen propia: lo pinta en azules
pálidos, así que el clip de relleno se va.
Las pruebas arrancan Chromium con SwiftShader para poder comprobar el efecto y
no solo su ausencia.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Tres piezas estaban copiadas en varios sitios y ahora se escriben una vez: el
fondo a sangre de la portada, las migas de pan y la pastilla de una etiqueta.
El fondo ya había divergido entre la cabecera y las láminas de llamada: el
degradado era distinto por descuido y no por decisión, que es lo que pasa con el
marcado duplicado.
Las migas pasan a ser una lista ordenada, que es su marcado correcto: un lector
de pantalla anuncia cuántos escalones hay y en cuál se va, y el último deja de
ser un enlace a donde ya estás.
Tres cosas salieron de hacerlo, ninguna cosmética:
- Con el texto como clave del bucle, un disco y un tema que se llaman igual
—«Como si nada» es las dos cosas— daban claves duplicadas y tumbaban la ficha
al hidratar. El SSR salía bien, así que solo se veía en el navegador.
- Al compartir el fondo, las tres láminas montaban el clip de vídeo. En tema
claro eso eran tres copias del mismo vídeo en la portada. Ahora lo pide solo
la cabecera.
- La lista de temas de un disco no tenía nombre, así que no se distinguía de
ninguna otra lista de la página. Ahora se llama «Temas».
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
El campo `volverA` de los formularios se comprobaba en tres sitios con tres
copias del mismo filtro, y las tres dejaban pasar `/\otro-dominio.com`: el
navegador convierte la barra invertida en barra normal al resolver, así que eso
es `//otro-dominio.com` con otra ropa. En la pantalla de entrar eso es phishing
servido: enlace a nuestro propio acceso, y quien escribe su correo acaba fuera.
Ahora hay un solo `destinoSeguro`, que no compara prefijos sino que resuelve la
URL y solo acepta lo que sigue en el origen. Con pruebas de cada disfraz.
Fuera siete funciones exportadas que no llamaba nadie. Las escribí "por si
acaso" y una API que no se usa no es una API: es código que leer y mantener sin
que nada dependa de ella.
Del repaso responsive salen dos fallos reales. Los puntos del carrusel medían
8×8 píxeles, la tercera parte del mínimo para acertarles con el dedo; ahora el
punto se ve igual y el botón mide 24×24. Y el hueco reservado bajo el contenido
para la barra de reproducción estaba escrito a mano en 88 píxeles cuando la
barra mide 97 en escritorio y 145 apilada en un móvil: se comía el pie. Ahora la
barra se mide sola y publica su alto.
Todo ello fijado en `responsive.e2e.ts`, que recorre diez páginas a cuatro
anchos.
Sobre rendimiento, nada que hacer: parecía que las páginas tardaban 215 ms, pero
eran de la resolución de `localhost` por IPv6 en la medición. Por IPv4 se
renderizan en 5-9 ms, así que memorizar el catálogo habría sido optimizar un
problema inexistente.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
La portada abre con tres láminas: el disco en el tocadiscos, una invitación al
taller y otra a la tienda. Un carrusel mal hecho es de las piezas más hostiles
que hay —cambia mientras lees, no se para, y para un lector de pantalla es un
cajón que aparece y desaparece—, así que este se para al pasar el puntero o al
entrar el foco, tiene botón de pausa, no gira solo con `prefers-reduced-motion`
y deja las láminas ocultas con `inert`, fuera del orden de tabulación.
Las letras que no están en español se enfrentan con su traducción en dos
columnas, verso a verso. Cada columna declara su `lang`, que es lo que impide
que un lector de pantalla lea el inglés con voz española, y la traducción se
pinta con `pre-line`: es verso, y perder los cortes la deja inservible para
seguir la original. El nombre del idioma sale de `Intl.DisplayNames`.
Sobre el reproductor: ya sobrevivía a la navegación, porque vive en el layout
raíz fuera de `main`. Lo que faltaba era comprobarlo de verdad; el test decía
que la barra seguía visible, no que siguiera sonando. Ahora mira que la posición
avance.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
La palabra se va del proyecto entero, interfaz e identificadores. Venía del
encargo inicial pero no es la del oficio y se colaba en la interfaz: lo que hay
son estilos, discos y temas. Lo que agrupa un estilo con su obra pasa a ser
`EstiloConObra`, y el componente que lo pinta, `BloqueDeEstilo`.
La portada cuenta el catálogo en tres pasos: el estilo principal a fila
completa, sus discos debajo, y el resto en una rejilla donde cada tarjeta dice
cuánto hay y de qué se habla. Las etiquetas de un estilo no se declaran: salen
de sus temas, así que no hay dos sitios que mantener.
La página de un estilo estrena su propio `h1` —antes heredaba un `h2` del
listado, y se quedaba sin encabezado de nivel uno— y pierde el botón de
reproducir suelto en la cabecera, que no decía qué iba a sonar. Los demás
estilos la acompañan en una barra lateral que ahora vive en el layout de la
sección, así que la llevan también la portada de música y cualquier página que
se añada.
Las tarjetas se mueven de otra manera: reflejo que nace bajo el puntero, borde
dorado, portada que recupera el color, subrayado que crece y respuesta al
pulsar. Nada usa la curva por defecto.
Dos rarezas del minificador de CSS, ambas comprobadas en el build y ambas
dejaban efectos muertos: junta las funciones de un filtro sin el espacio que
las separa, y borra `backdrop-filter` sin prefijo si se declara también la
versión `-webkit-`.
Y la canción real estrena su vídeo de YouTube.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Se generan en la raíz al verificar un cambio en el navegador y no son parte del
proyecto. Se ignoran a partir de ahora para que no vuelva a pasar.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
La portada estrena imagen de fondo propia, detrás del tocadiscos y del texto.
Llega en PNG de 2,1 MB y se sirve en tres anchos WebP: 10 kB en un móvil, 57 kB
en un panel grande. El velo que va encima se afloja hasta lo justo para que el
texto se lea, porque taparlo entero sería no haberlo puesto. El tema claro
todavía no tiene su versión, así que ahí sigue el clip de reserva.
La cabecera deja ver lo que pasa por debajo. La transparencia va con desenfoque
y no sola: sobre una imagen a sangre, un fondo solo translúcido dejaría el
contraste del menú a merced de lo que quedara detrás.
Y fuera las líneas entre secciones: separa el aire. Se quedan las que dibujan
una pieza —el pie, el reproductor, las filas de un listado, la tabla—.
Aparte, scripts/construir.mjs comprobaba `build/server/index.js`, un archivo
que adapter-node no genera: el servidor sale troceado en chunks. Daba por
fallido un build correcto.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sitio del letrista y compositor, con SvelteKit 2 y Svelte 5.
El catálogo es contenido, no base de datos: estilos, álbumes, canciones y
capítulos son Markdown en `src/content/`, validados al compilar y compilados
dentro del bundle, así que recorrer la música no hace ni una consulta. La base
de datos guarda solo lo que generan las personas: cuentas, carritos, pedidos,
compras, valoraciones y favoritos.
Lo que hay:
- Música agrupada por estilos, con página propia para cada estilo, álbum y
tema, buscador y tabla ordenable.
- Reproductor persistente que sobrevive a la navegación. Suena un fragmento;
el archivo completo solo se obtiene comprando.
- Taller de letras: los principios del oficio y una ficha de creación por
estilo hispanoamericano.
- Blog con feed RSS, y etiquetas que cruzan blog y catálogo.
- Tienda con cuentas por código de correo, carrito, Stripe y descargas
protegidas.
- Panel de contenido en `/admin` que escribe los mismos Markdown.
- Tema oscuro de marca y su inversión clara, aplicado antes de pintar.
Lo que falta y no es código: las claves de Stripe y de correo, el tratamiento
del IVA, las páginas de condiciones y privacidad, y el material real. Las
canciones de ejemplo son inventadas y están para sustituirse; el audio son
marcadores salvo un tema.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>