# Senza Paura Web de un letrista y compositor, con tienda. La obra se agrupa en **estilos por estilo** —las baladas románticas como estilos principal, y junto a ellas clásica, rock y pop melódico—, se escucha desde un **reproductor persistente** que no se corta al navegar, y se puede **comprar en packs de diez canciones** que se entregan como descarga en MP3. El catálogo y el contenido editorial viven en PostgreSQL. Los Markdown de `src/content` se conservan como fuente de importación y respaldo, pero el sitio público y el panel leen el modelo relacional. ## Estado La estructura, la funcionalidad y la **dirección visual** están hechas. Todo el color, la tipografía y el ritmo salen de las variables de `:root` en [`src/app.css`](src/app.css): cambiar la estética sigue siendo cambiar esos tokens, no recorrer los componentes. Los colores son los de la marca: **azul marino oscuro aterciopelado y dorado**. El marino lleva croma de verdad (0,04 en oklch) en lugar de ser un gris azulado, que es lo que le da el aspecto de terciopelo en vez del de pizarra; todos los fondos comparten tono y solo cambian de claridad. El dorado es el único acento y lo llevan las cosas accionables, nada más. **Cuidado con el atajo `background` en cualquier regla que pueda alcanzar a un botón dorado.** El atajo reinicia también `background-image`, así que `.btn:hover { background: … }` dejaba el botón en un rectángulo oscuro con el borde de oro. Por eso las reglas base usan `background-color`. Y todo control que responda al puntero está cubierto por una prueba: eso no se ve en una captura en reposo, que es justo como se coló. **En los botones el dorado no es plano.** Un metal refleja, y lo que lo hace parecer metal es la banda de luz que lo cruza: el degradado `--oro` va de la sombra del canto al brillo y vuelve, con un filo de luz arriba que lo bisela. El fondo se pinta más alto que el botón a propósito, así que al acercar el puntero la luz **recorre** la pieza en vez de limitarse a aclararla; es la diferencia entre algo que se ilumina y algo que gira. El color plano se queda debajo como reserva: si el degradado no llegara a pintarse, el botón sigue siendo dorado y legible. Hay **dos modos, y el claro es una inversión de verdad**: el texto pasa a ser el propio marino y los fondos, blancos apenas teñidos de él. El dorado baja de claridad en el modo claro —el que se lee bien sobre marino desaparece sobre blanco—, así que ahí es un dorado más hondo, casi bronce. Funciona en tres estados: mientras no se elija nada se sigue al sistema, sin JavaScript, y el botón de la cabecera escribe `data-tema` en la raíz y manda sobre él. Lo aplica un script en línea de `app.html` **antes de pintar**: si se hiciera al hidratar, quien tenga el claro guardado vería un destello oscuro en cada carga. Dos familias: **Newsreader** (serif) para titulares y para las letras, que son texto para leer despacio, y **Archivo** (sans) para interfaz, datos y navegación. Lo que sigue siendo provisional son los **materiales**: las portadas son SVG generados y el clip de la cabecera es un degradado con grano, no metraje real. Las claves de Stripe se configuran por entorno. Los textos legales se sirven desde la base de datos bajo `/legal`; los archivos de `src/content/legal` son fuentes de importación. El esquema incluye facturación, pero configurar una política fiscal o activar cobros recurrentes requiere las decisiones comerciales correspondientes. ## Empezar ```bash npm install cp .env.example .env # configura DATABASE_URL de una base PostgreSQL npm run db:migrate # aplica las migraciones a esa base PostgreSQL npm run dev ``` La web queda en `http://localhost:5173`. Sin claves de Stripe la tienda funciona salvo el cobro: el carrito calcula, pero el botón de pagar avisa de que no hay pasarela. Sin clave de correo, en desarrollo **el código de acceso se imprime por consola**, así que se puede entrar en una cuenta sin configurar nada. | Comando | Qué hace | | ---------------------------------------- | ------------------------------------------------- | | `npm run dev` | Web pública de desarrollo en `:5173` | | `npm run build` | Compila a `build/` (adapter-node) | | `npm run preview` | Sirve la compilación de producción en `:4173` | | `npm run check` | Comprobación de tipos | | `npm run lint` | Prettier + ESLint | | `npm run format` | Formatea el proyecto | | `npm run test:unit` | Tests unitarios y de componente (Vitest) | | `npm run test:e2e` | Compila y ejecuta los end-to-end (Playwright) | | `npm test` | Todo lo anterior | | `npm run db:generate` | Genera una migración a partir del esquema | | `npm run db:migrate` | Aplica las migraciones pendientes | | `npm run db:migrate:check -- ` | Prueba migraciones y revierte la transacción | | `npm run db:auditar-musica` | Comprueba códigos, créditos y streams tras migrar | | `npm run db:studio` | Abre el visor de la base de datos | ## El panel La administración es **otra aplicación**, en `admin.senzapaura.es`. Este repositorio compila dos: ```sh npm run dev # la web, en 5173 npm run dev:panel # el panel, en 5175 npm run build # → build/ npm run build:panel # → build-panel/ npm run check # comprueba las dos ``` Lo que las separa está en `vite.config.ts` y son tres líneas: con `APP=panel`, las rutas salen de `src/panel/rutas`, los hooks de `src/panel/hooks.server.ts` y el resultado va a `build-panel/`. **El servidor público no lleva dentro ni una línea del panel**: en `senzapaura.es` no es que `/admin` esté protegido, es que no existe. Comparten `src/lib`, que es donde están el esquema, las consultas y el acceso. Con dos repositorios habría que mantener las migraciones por duplicado, y el día que se desincronizaran nadie se enteraría hasta que el panel guardara en una columna que ya no está. La portada del panel dice qué hay y qué falta —capítulos sin publicar, géneros sin ficha, artistas incompletos y canciones sin audio de escucha—. Desde ahí se editan capítulos, géneros, artistas, créditos, versiones musicales y prompts. ### Los prompts Un prompt es de un **modelo concreto**: Suno v4 no es Suno v3.5, porque cambian la sintaxis y lo que el modelo ignora. Por eso `modelo_ia` lleva una fila por versión y no por marca, y los modelos se dan de alta a mano —una lista escrita en un script envejecería en un mes—. De cada combinación de modelo, género y ánimo hay **varios prompts con uno marcado**, porque esto se itera y no se acierta: la lista ordena por preferido y luego por valoración, que es como se busca «el que funcionó». Los nueve ánimos salen de la escala **GEMS**, que está hecha para emoción musical y no para emoción en general, y cada uno lleva valencia y energía para poder redactarlo: «alta energía, valencia baja» es una instrucción que una IA entiende; «tensión» a secas, no. ```bash npm run db:animos # siembra los nueve ánimos; idempotente ``` Antes había un `/admin` dentro de la web con [Sveltia CMS](https://sveltiacms.app), un editor sobre git que escribía los Markdown de `src/content/`. Se quitó al terminar la migración a PostgreSQL: **el sitio ya no lee esos archivos**, así que guardaba cambios que no salían en pantalla, y eso es peor que no tener panel. ### Quién entra El rol vive en la base, en `usuario.rol`, y se da con un script: ```bash npm run db:admin # quién administra ahora npm run db:admin -- hola@senzapaura.es # se lo da npm run db:admin -- hola@… -- --quitar # se lo quita ``` La cuenta tiene que existir: se da el rol a alguien que ya ha entrado alguna vez, para que no se quede esperando en una dirección mal escrita. El rol se lee de la base en cada petición, así que darlo o quitarlo surte efecto en la página siguiente, sin echar a nadie fuera. Es un script y no una pantalla del panel por dos razones: el primer administrador no puede nombrarse desde un panel al que todavía no entra nadie, y mientras administre una sola persona, una pantalla de permisos sería mantener un sistema de permisos para un usuario. ### Qué pasa si no eres tú El panel tiene su propia página de entrar, porque una sesión de `senzapaura.es` no vale en `admin.senzapaura.es`: la cookie se emite para el host y no para el dominio padre, que es justo lo que hace que la sesión del panel no viaje en ninguna petición de la web pública. Usa el mismo código por correo de `$lib/server/auth`, con una diferencia: **solo manda código a una cuenta que ya administre**, y contesta lo mismo exista o no. Si dijera «esa cuenta no administra», el formulario sería una forma cómoda de averiguar cuál sí. Sin sesión, a `/entrar` con la dirección de vuelta puesta. Con sesión pero sin el rol, **403 y con el motivo escrito**: ahí solo llega alguien que administraba y ha dejado de administrar. La guarda está en el `+layout.server.ts` del grupo que envuelve las pantallas, así que una pantalla nueva queda protegida por el hecho de estar dentro. ## Cómo se organiza el contenido ``` src/content/ estilos/ un archivo por estilo → un estilos de /musica albumes/ discos, EPs, singles y colecciones canciones/ un archivo por tema; el cuerpo del Markdown es la letra taller/ los capítulos del taller de letras paginas/ textos sueltos (bio) ``` El **slug** de cualquier pieza de contenido es el nombre de su archivo sin extensión, y es lo que aparece en la URL. `src/content/albumes/como-si-nada.md` se publica en `/albumes/como-si-nada`. Las relaciones se declaran por slug. **Si una referencia no existe, la compilación falla** con un mensaje que dice el archivo y el campo exactos; no hay contenido que desaparezca en silencio de los listados. Cada estilo, cada álbum y cada canción tienen su propia página. ### Canciones sueltas: el álbum «Singles» Una canción sin campo `album` es un single suelto. No se queda fuera de la web: los singles de cada estilo se agrupan en un álbum **«Singles»** que se genera solo, con su propia página en `/albumes/singles-`, y que aparece el último dentro de su estilos. No existe como archivo y no hay que mantenerlo. ### Añadir un estilo (un estilos nuevo) `src/content/estilos/bolero.md` ```markdown --- nombre: 'Bolero' resumen: 'Frase corta que acompaña al título del estilos.' orden: 4 # menor = más arriba en /musica --- Texto introductorio del estilos. Es opcional y aparece en /musica/bolero. ``` Solo un estilo puede llevar `principal: true`: es el que abre la portada y encabeza los estilos. ### Añadir un álbum `src/content/albumes/nombre-del-disco.md` ```markdown --- titulo: 'Nombre del disco' estilo: baladas-romanticas # slug de un archivo de estilos/ fecha: '2026-03-15' tipo: album # album | ep | single | coleccion portada: /images/albumes/nombre-del-disco.jpg sello: 'Autoeditado' # opcional resumen: 'Una frase sobre el disco.' destacado: true # opcional edicion: # opcional: lo que iría en la contraportada numeroCatalogo: 'SP-004' formatos: [digital, cd] # digital | cd | vinilo | casete pais: España creditos: - rol: 'Producción' nombre: 'Senza Paura' - rol: 'Diseño de portada' nombre: 'Estudio Bermejo' enlaces: # opcional spotify: https://open.spotify.com/... --- Notas del disco. Aparecen bajo el tracklist. ``` ### Añadir una canción `src/content/canciones/nombre-del-tema.md` ```markdown --- titulo: 'Nombre del tema' estilo: baladas-romanticas album: nombre-del-disco # quítalo y el tema pasa a ser un single suelto numero: 1 # obligatorio si hay álbum; se ignora en los singles duracion: '4:12' ano: 2026 idioma: es # etiqueta BCP 47: es, fr, en, pt-BR… formacionVocal: mujer # hombre | mujer | duetos y formaciones múltiples audio: nombre-del-disco/01-nombre-del-tema.mp3 # clave privada, sin barra inicial portada: /images/canciones/nombre-del-tema.jpg # opcional: si falta, se usa la del álbum interpretes: - 'Quien lo canta' destacada: true # opcional: aparece en portada y en su estilos versiones: # opcional: otros arreglos del mismo tema - nombre: 'Versión orquestal' audio: nombre-del-disco/01-nombre-del-tema-orquestal.mp3 duracion: '4:48' nota: 'Con cuerda y arpa, para el concierto de presentación.' registro: # opcional: la obra como propiedad registrada iswc: 'T-034.524.680-1' # identifica la OBRA, igual en todas sus versiones isrc: 'ES-A2B-24-00001' # identifica ESTA grabación entidad: SGAE numero: '9400685' editorial: 'Ediciones Senza Paura' # distinta del sello del disco reparto: # tiene que sumar 100; si no, el build falla - nombre: 'Senza Paura' rol: letra y musica # letra | musica | letra y musica porcentaje: 50 - nombre: 'Coral Vidal' rol: letra y musica porcentaje: 50 inspiracion: | De dónde sale el tema. Se escribe como bloque y se separa en párrafos por líneas en blanco. Este es el segundo párrafo. --- Aquí va la letra completa. Los saltos de línea se respetan, porque en una canción el verso es contenido. ``` La ficha del tema abre con la portada y el reproductor, y reparte el contenido en **pestañas**: «Letra» (abierta por defecto, porque es lo que se viene a leer), «De dónde sale» y «Alternativas musicales». Las dos últimas solo aparecen si el tema las tiene. Los créditos van al lado, siempre visibles. Las pestañas siguen el patrón de WAI-ARIA —flechas, `Inicio` y `Fin` mueven entre ellas— y **el contenido de todas está siempre en el HTML**, aunque su pestaña esté cerrada: es lo que indexan los buscadores. Sin JavaScript la fila de pestañas desaparece y se ven los tres bloques seguidos, cada uno con su encabezado; la letra de una canción no puede depender de que el navegador ejecute nada. Detalles que ahorran trabajo: - **La autoría se presupone.** Sin `letra:` ni `musica:`, ambas se atribuyen al artista. Solo hay que escribirlas cuando firma alguien más. - **El modelo guarda `tiene_letra` directamente.** El lector del contenido Markdown antiguo sigue aceptando `instrumental: true` y lo convierte a `tiene_letra = false`; así no se conservan dos booleanos que puedan contradecirse. - **La formación vocal no sustituye a los intérpretes.** `formacionVocal` describe si canta un hombre, una mujer, un dueto o una formación múltiple; los nombres acreditados siguen siendo entidades propias. - **Las versiones no se venden aparte.** Quien compra un tema se lleva todos sus arreglos. - **`audio` es opcional.** Sin él, la ficha y la letra se publican igual, el botón de reproducción sale desactivado y el tema no está a la venta. Para retirar de la venta un tema que sí tiene audio, `alaVenta: false`. - **El extracto para las tarjetas no se escribe.** Los primeros versos salen solos del cuerpo del Markdown, así que nunca se desincronizan de la letra. Si esos versos concretos no son los que mejor presentan el tema, un campo `extracto:` con una lista de líneas manda sobre el automático. ### La obra como propiedad registrada El bloque `registro` es lo que separa un catálogo de una web. Los códigos se validan y se normalizan al compilar —da igual si escribes `T0345246801` o `T-034.524.680-1`—, y **un reparto que no suma 100 rompe el build**: un reparto descuadrado solo se descubre cuando la entidad de gestión rechaza el registro, y para entonces ya nadie recuerda de dónde salió el número. La distinción que más se confunde: el **ISWC identifica la obra** y es el mismo en todas sus versiones; el **ISRC identifica una grabación**, así que la versión orquestal de un tema comparte ISWC con la original y tiene ISRC propio. Estos datos se publican en la ficha del tema, bajo «Registro». Es deliberado: el ISRC ya aparece en cada plataforma, el ISWC está en los repertorios públicos, y quien quiera licenciar una obra necesita localizarla y saber con quién negociar. Si prefieres no enseñar el reparto de autoría, no lo rellenes: lo que falta no se pinta. ## Letras en otro idioma Una canción declara su `idioma` como etiqueta BCP 47 (`es`, `fr`, `pt-BR` o `zh-Hant`). Cuando no está en español y trae `traduccion`, la pestaña de la letra se parte en **dos columnas**: la original a la izquierda y el castellano a la derecha, verso a verso. Con una sola de las dos cosas se queda en una columna, porque una columna vacía sería peor que ninguna. ```yaml idioma: en traduccion: | Dejé las luces encendidas al salir, no porque fuera a volver ``` Dos detalles que importan. Cada columna lleva 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 guarda tal cual y se pinta con `white-space: pre-line`: es verso, no prosa, y una traducción que pierde los cortes deja de servir para seguir la original. El nombre del idioma se saca de `Intl.DisplayNames`, no de una tabla que mantener. ## Etiquetas Cada tema y cada entrada del blog pueden llevar **etiquetas** libres: de qué habla, cómo suena, con quién se canta. Existen porque el estilo y el disco no cruzan el catálogo por donde a veces hace falta: un desamor puede ser una balada o una ranchera, y el año no ayuda a encontrarlo. Se escriben tal cual en el frontmatter y **agrupan por slug**, así que «Desamor» y «desamor» son la misma; el nombre que se lee es el primero que apareció. En `/etiquetas` están todas, de más usada a menos, y cada una tiene su página. También son lo que hace útil el buscador de `/musica`: quien recuerda «el dueto» o «desamor» llega igual que quien recuerda el título. ## Blog Apuntes fechados, en `src/content/blog/`. Es lo único del sitio que se fecha: el catálogo no caduca, pero un apunte sobre cómo salió un tema pertenece al día en que se escribió. ```markdown --- titulo: 'Lo que no cabe en una canción' fecha: 2026-02-14 resumen: 'Una frase; es lo que se lee en el listado y en los buscadores.' etiquetas: - 'Oficio' canciones: - como-si-nada borrador: false --- El cuerpo del Markdown es el artículo. ``` Dos cosas que conviene saber. El campo `canciones` enlaza temas del catálogo, y la entrada los enseña al final con su reproductor, así que se puede escuchar lo que se está leyendo. Y `borrador: true` deja la entrada fuera **en el catálogo**, no en cada página: no hay forma de escribir un listado nuevo y olvidarse de filtrarlos. ## Taller de letras Un apartado propio (`/taller`) con el oficio puesto por escrito, en `src/content/taller/`. Son dos bloques y se distinguen por el campo `tipo`: - **`principio`** — el curso: qué hace que una letra funcione, la imagen concreta, métrica y acento, la rima, la estructura y el estribillo. Se leen en orden y cada capítulo enlaza con el siguiente. - **`ficha`** — una por estilo hispanoamericano (bolero, son cubano, ranchera, tango, vals criollo, bachata, vallenato y cumbia). No son historia de la música: son lo que hay que tener delante para escribir en ese estilo, con compás, tempo, métrica, estructura, rima, de qué se habla y qué rasgos de escritura lo distinguen. Añadir un estilo es un archivo: ```markdown --- titulo: 'Milonga' tipo: ficha orden: 9 resumen: 'Una frase sobre qué aporta el estilo a quien escribe.' ficha: origen: 'Río de la Plata, siglo XIX' compas: '2/4' tempo: 'Vivo' metrica: 'Octosílabo' estructura: 'Estrofas encadenadas' rima: 'Consonante en los pares' temas: - 'El desafío' rasgos: - 'Se canta casi hablando' --- El cuerpo del Markdown: cómo se escribe una, el error más común y un ejercicio. ``` **Todas** las páginas del taller llevan el índice en una barra lateral a la izquierda —la portada y cada capítulo—, con el capítulo abierto marcado: en un curso, saber dónde estás y qué queda es parte de poder seguirlo. Vive en [`+layout.svelte`](src/routes/taller/+layout.svelte) de la sección, no en cada página, así que no se vuelve a pintar al cambiar de capítulo —no parpadea ni pierde el desplazamiento— y no hay forma de añadir una página y olvidarse de ponerlo. En pantallas estrechas no se oculta: baja debajo del texto. Los rótulos de sus dos bloques **no son encabezados**. En la portada repetirían palabra por palabra los títulos de la página y el esquema diría dos veces «Los principios»; nombran su lista con `aria-labelledby`, que es lo que hacía falta. Los capítulos anterior y siguiente se calculan **dentro de su bloque**: pasar de los principios a las fichas es cambiar de cosa, no seguir leyendo. ## Dónde va el audio Cada grabación tiene representaciones técnicas distintas. La escucha autorizada contiene la canción completa a calidad reducida; el máster y la descarga de compra siguen siendo privados. Todo va agrupado **por disco**, con el mismo nombre de carpeta en los tres árboles: ``` media/audio//NN-.wav máster o descarga — privado media/escucha//NN-.mp3 canción completa a 96 kbps — protegida static/ondas//NN-.json resumen de amplitudes — público, sin audio static/images//portada.jpg portada del disco static/images//.jpg portada propia de un tema static/images/sin-portada.svg reserva cuando no hay ninguna ``` Los temas sueltos usan `singles/` como carpeta, y ahí vive también la portada del álbum «Singles» que se genera solo. La `` del audio se guarda sin barra inicial, por ejemplo `cupido-sin-flechas/01-amame-esta-noche-nada-mas.mp3`. De ahí salen la ruta privada y la copia de escucha, así que no hay que escribirlas por duplicado. Los directorios privados se configuran con `MEDIA_DIR` y `ESCUCHA_DIR`. Los detalles de formato, la convención de nombres y cómo sustituir los archivos de marcador de posición están en [`media/LEEME.md`](media/LEEME.md). ### Subir un tema sin tocar el disco El campo **Archivo de audio** del panel sube el máster a `media/audio/subidas/`. Lo que el navegador no puede hacer —transcodificar la escucha y medir el audio— lo hace [`scripts/medios.mjs`](scripts/medios.mjs), que corre solo antes de `npm run dev` y de `npm run build`, y también a mano con `npm run medios`. Coloca las subidas reclamadas por una ficha y, además, recorre todos los másteres de `media/audio`, incluidos los que entraron por el importador de álbumes. Para cada uno genera la canción completa a 96 kbps si falta o está desactualizada. Necesita **ffmpeg** en el equipo de quien edita. Si falta, avisa de lo que no ha podido generar y sigue; no rompe el arranque ni el build. El servidor de despliegue no lo necesita: recibe `media/escucha` junto a los másteres privados; solo las ondas se versionan en el repositorio. El perfil se define una sola vez en `scripts/config/audio.mjs`. Un archivo que se quede en `subidas/` sin ficha que lo reclame se avisa en cada arranque, para que no se pierda ahí. ## La portada Un carrusel de 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, así que este: - **se para solo** al pasar el puntero o al entrar el foco, y sigue al salir; - tiene un **botón de pausa** de verdad, cuyo estado manda sobre lo demás; - **no gira solo** con `prefers-reduced-motion`; - deja las láminas ocultas **fuera del orden de tabulación** (`inert`), así que el teclado no cae en botones invisibles; - se mueve con las flechas del teclado desde sus controles. Las tres se pintan a la vez y se cruzan con opacidad, no se desmontan: así el fondo de la primera no se recarga al volver a ella. Las dos láminas de llamada reutilizan el mismo fondo, de modo que no añaden peso. Debajo del carrusel, el catálogo en tres pasos: el **estilo principal a fila completa**, sus discos debajo, y el resto de estilos en una rejilla donde cada tarjeta dice cuánto hay —discos y temas— y de qué se habla, con las etiquetas más usadas de sus canciones. El estilo principal no compite en la rejilla a propósito: es el grueso de la obra, y ponerlo del mismo tamaño que los demás diría lo contrario de lo que es. Cuál es lo marca `principal: true` en su archivo. Las etiquetas de un estilo **no se declaran**: salen de sus temas (`etiquetasDeEstilo`). Así no hay dos sitios que mantener ni forma de que la etiqueta de un estilo contradiga a las de sus canciones. ## La página de música Tres formas de recorrer lo mismo, porque no todo el mundo llega buscando igual: - **Los estilos, siempre a la izquierda.** Están en el layout de la sección, así que los llevan igual la portada de música y la página de cada estilo, con el abierto marcado. Son la navegación de la sección; en pantalla estrecha suben arriba en horizontal, no se esconden. - **Un buscador** que mira el título, el estilo, el disco, el año, las etiquetas y los intérpretes, sin tildes y sin mayúsculas. Con una búsqueda en marcha los estilos no dicen nada, así que se enseñan las coincidencias sueltas. - **Dos vistas, «Tarjetas» y «Tabla».** Las tarjetas sirven para descubrir; la tabla, ordenable por cualquier columna, para comparar: cuál dura menos, qué salió en 2022. Ordenar es cosa del cliente porque el catálogo va compilado en el bundle: no hay consulta que hacer ni espera que enseñar. En la página de **un estilo** se listan **todos** sus temas, no solo los destacados. En la de música, donde conviven todos los estilos, solo caben los destacados; ahí no, porque si no un tema recién añadido no se ve por ninguna parte. ## Las tarjetas de canción La tarjeta **identifica** un tema, no lo cuenta: portada, título, disco, año y estilo. Nada más. La letra se lee en la ficha, que es donde tiene sitio para respirar; en una rejilla, un fragmento de estrofa por tarjeta convierte el listado en un muro de texto y estorba justo lo que el listado sirve para hacer, que es recorrer y elegir. Sigue existiendo el **extracto** —los primeros versos, que saca del cuerpo del Markdown el preprocesador [`extracto.ts`](src/lib/content/extracto.ts) antes de que mdsvex lo compile, cortando en la primera línea en blanco—. Ya no se usa en las tarjetas, pero sí en la lista numerada de un disco, donde cada tema es una fila y una línea de letra distingue mejor que unos créditos idénticos en todo el álbum. La animación es doble: aparición escalonada al entrar en pantalla, y respuesta al puntero (la tarjeta se eleva, la portada se acerca y el botón de reproducir sube desde la esquina). Tres cosas que conviene no romper al rediseñar: - **Sin JavaScript se ve todo.** El estado oculto lo pone la acción [`revelar`](src/lib/acciones/revelar.ts), nunca el CSS: si no hay JS no hay atributo, y la tarjeta se pinta directamente. - **`prefers-reduced-motion: reduce` desactiva el movimiento**, y entonces el botón de reproducir se queda siempre visible. - **En pantallas táctiles** el botón también se queda visible: sin hover que lo revele, un botón invisible pero pulsable sería peor que ninguno. Dónde se usa cada cosa: **tarjetas** donde se descubre música (portada, temas destacados de cada estilos, colecciones de singles) y **lista numerada** en la página de un disco, donde el orden de las pistas es la información. ## Las láminas de llamada Las dos que no son la cabecera llevan **foto propia**, no la textura de marca: un café con un cuaderno para la tienda, y alguien escribiendo en la calle para el taller. En las dos, lo que se ve está a la derecha y el texto va a la izquierda, que es como está calibrado el velo de tipo `foto`: sobre una imagen clara el texto blanco no se lee, así que ahí el velo no atenúa, **oscurece**. Llegan en JPEG de más de medio mega y se sirven en tres anchos WebP, como el fondo de marca. ## El fondo de la portada Encima de todo, en la cabecera, van **ondas animadas** ([`OndasWebgl`](src/lib/components/OndasWebgl.svelte)): WebGL a pelo, sin biblioteca. Un shader de fondo es un triángulo y un programa de fragmentos; traerse three.js para eso sería medio mega de dependencia en la primera pantalla para dibujar dos triángulos. El dibujo es _domain warping_: se calcula ruido, se usa para desplazar las coordenadas y se vuelve a calcular. Dos vueltas bastan para que las formas dejen de parecer nubes y empiecen a parecer agua. La escala horizontal es mayor que la vertical, que es lo que las tumba en bandas. La paleta sale de los colores de marca convertidos a **RGB lineal**, que es en lo que mezcla el shader. El primer intento usó los cuatro fondos de `app.css`, que caben en un pelo de claridad, y salió una plancha lisa: por bien que calcule el ruido, sin recorrido de color no se ve nada. Cuatro cosas que no son negociables, y por las que no vale con soltar un canvas: - **Con `prefers-reduced-motion` no se monta.** - **Se para cuando no se ve**, al salir de pantalla y al cambiar de pestaña. Un shader a pantalla completa detrás de una página por la que ya has bajado es batería tirada. - **Si falla, no pasa nada**: sin WebGL o sin compilar, debajo queda la imagen. Un fondo decorativo no puede tumbar una portada. - **Solo lo lleva la cabecera.** En las tres láminas serían tres shaders peleándose por la tarjeta gráfica para pintar lo mismo. Las ondas resuelven además el **tema claro**, que no tiene imagen propia: las pinta en azules pálidos, y el clip de reserva dejó de hacer falta. Donde no pueden correr, el tema claro se queda con el color de fondo a secas hasta que exista una imagen suya. Las pruebas arrancan Chromium con SwiftShader —su rasterizador por software—, porque si no solo se podría comprobar la ausencia del efecto. ## La imagen del fondo Una imagen a sangre detrás del tocadiscos y del texto, en `static/background/`. Se sirve en **tres anchos WebP** y el navegador coge el que le toca: 10 kB en un móvil, 57 kB en un panel grande. El PNG original de 2,1 MB se queda en el repositorio como fuente, pero no lo pide nadie. El velo que va encima existe solo para que el texto de la izquierda se lea, y va lo justo: el fondo es material de marca y taparlo entero sería no haberlo puesto. **El tema claro todavía no tiene imagen** —esta es marino oscuro, y sobre blanco no valdría—, así que ahí sigue el clip de reserva. Mientras sea así, quien tenga el claro se descarga la imagen oscura una vez: el servidor pinta el tema de marca, que es el que no puede adivinar. Se acaba en cuanto exista la versión clara. ## Vídeo Dos sitios llevan vídeo, y ninguno carga nada de terceros por su cuenta: - **La cabecera de la portada** ([`Heroe.svelte`](src/lib/components/Heroe.svelte)) pone un disco en un [tocadiscos](src/lib/components/Tocadiscos.svelte) sobre un clip propio, mudo y en bucle desde `static/video/` —hay uno por tema, sobre marino y sobre blanco—. Es un archivo nuestro y no un incrustado porque en la primera pantalla un iframe de YouTube pesa más que todo lo demás junto. No se arranca con el atributo `autoplay` sino desde código, para poder respetar `prefers-reduced-motion`. El disco que se pone se elige en `heroe` (`src/lib/site.ts`). El tocadiscos no es un adorno: está atado al reproductor de verdad, gira mientras suena algo de ese disco y se para al pausar, y el propio disco es un botón enfocable con teclado. El giro es una animación CSS que se pausa con `animation-play-state`, no un bucle en JavaScript. - **Los vídeos de una canción** ([`VideoYoutube.svelte`](src/lib/components/VideoYoutube.svelte)) se declaran en su frontmatter y aparecen en una pestaña más de la ficha. Hasta que alguien pulsa, la página **no pide nada a YouTube**: se ve la portada propia del tema con un botón encima, y solo al pulsar se inserta el reproductor, contra `youtube-nocookie.com`. Evita también la miniatura oficial, que se serviría desde los servidores de Google. Hay un test end-to-end que comprueba que no sale ninguna petición antes de pulsar. En el frontmatter se acepta el enlace tal cual se copia del navegador o el id suelto; el esquema extrae el id y lo valida: ```yaml videos: - youtube: https://www.youtube.com/watch?v=XXXXXXXXXXX titulo: 'Videoclip oficial' - youtube: XXXXXXXXXXX titulo: 'En directo en el Teatro Circo' ``` ## La cabecera y las anclas La portada **empieza en el borde de la ventana** y la cabecera flota encima: por eso es translúcida con desenfoque, para apoyarse sobre la imagen en vez de tapar una franja. Se consigue tirando de la portada hacia arriba con un margen negativo del alto de la cabecera, que cada lámina devuelve en relleno para que su contenido no se mueva. Ese alto **lo mide la cabecera** y lo publica en `--altura-cabecera`, igual que hace la barra del reproductor con el suyo. Estaba escrito a mano en 4,75rem cuando la cabecera mide 68px, así que todo lo que dependía de él —el salto a un ancla, las barras laterales pegajosas— quedaba desplazado. La cabecera **acompaña al desplazamiento** (`position: sticky`) y deja ver lo que pasa por debajo. La transparencia va **con desenfoque, no sola**: sobre la portada, un fondo solo translúcido dejaría el contraste del menú a merced de la zona que quedara detrás; el desenfoque la promedia y la vuelve previsible. Donde no hay `backdrop-filter` la cabecera se vuelve casi opaca, porque es preferible perder el efecto a perder la legibilidad. El botón de claro/oscuro es el **último elemento del menú**, después del carrito y de la cuenta. Una cabecera fija tapa aquello a lo que se salta. Lo evita `--altura-cabecera` en [`app.css`](src/app.css), que alimenta el `scroll-padding-top` de la raíz y el `top` de las barras laterales que también se quedan fijas —el índice y la ficha del taller—. Si cambia el espaciado de la cabecera, ese número la sigue; nadie la mide en JavaScript. Las **pestañas se pueden enlazar**: `#panel-videos` en la ficha de una canción abre esa pestaña y baja hasta ella. Sin esto el enlace no llevaría a ninguna parte, porque el panel cerrado está oculto. Lo hace [`Pestanas.svelte`](src/lib/components/Pestanas.svelte) leyendo el hash de la URL; un ancla que no corresponde a ninguna pestaña se ignora y se queda la de siempre. Entre secciones **no hay líneas**: la separación la hace el aire. Los bordes que quedan son de pieza, no de sección —el pie, la barra del reproductor, las filas de un listado, la tabla—, y ahí sí dibujan algo. ## Piezas compartidas Tres cosas que estaban copiadas en varios sitios y ahora se escriben una vez: - **El fondo de la portada** ([`FondoDePortada`](src/lib/components/FondoDePortada.svelte)), que usan la cabecera del disco y las dos láminas de llamada. Estaba duplicado y **ya había divergido**: el degradado era distinto por descuido, no por decisión. El clip de vídeo solo lo monta la cabecera; con él compartido, el tema claro llegó a reproducir tres copias del mismo vídeo en la portada. - **Las migas de pan** ([`Migas`](src/lib/components/Migas.svelte)), en seis páginas. Son una lista ordenada, no una fila de enlaces: un lector de pantalla anuncia cuántos escalones hay y en cuál va. El último no es enlace, porque no se enlaza a donde ya estás. - **La pastilla de una etiqueta** (`.chip` en `app.css`), en cinco. A veces es enlace y a veces solo texto —dentro de una tarjeta que ya es clicable, meter un enlace dentro de otro deja una zona donde el clic hace una cosa distinta según el píxel—, así que es una clase y no un componente. ## Cantos y radios Dos valores y no más, para que nada se vea de otra familia: `--radius-boton` (6px) para lo que se pulsa o se escribe —botones, campos, pastillas—, y `--radius-tarjeta` (8px) para las superficies —tarjetas, paneles, portadas—. Antes había tres radios sueltos y una píldora de 999px repetida en quince archivos. Los botones de **icono suelto** siguen siendo redondos: el de reproducir sobre una portada, el del tema, los puntos del carrusel. Un icono en un círculo tiene sentido; un botón con texto en forma de cápsula es otra cosa. ## Tarjetas Las tres clases —estilo, álbum y canción— comparten `.tarjeta` en `app.css` y se mueven igual. Al acercarse: la tarjeta sube, se le enciende el borde en dorado, la portada recupera el color que tenía apagado en reposo y un reflejo **nace donde está el puntero** —lo escribe la acción [`brillo`](src/lib/acciones/brillo.ts) en `--mx` y `--my`—. El subrayado del título crece desde la izquierda y al pulsar la tarjeta cede. Nada usa la curva `ease`, que es la de por defecto y hace que todo se parezca: se sale rápido y se frena largo (`--sal`), que es como se mueve algo que pesa. **Cuidado con los filtros de varias funciones.** El minificador de CSS los junta sin el espacio que las separa —deja `saturate(.85)brightness(.92)`, que no es válido— y el navegador descarta la declaración entera. Por eso esos valores van dentro de una variable, donde se copian tal cual. Y no se declara nunca `-webkit-backdrop-filter` a mano: si está, el minificador borra la versión sin prefijo y deja la prefijada, que no llega a aplicarse. El prefijo lo pone él solo. Hay tres clases —estilo, álbum y canción— y se comportan igual porque comparten `.tarjeta` en `app.css`: aparecen escalonadas al entrar en pantalla, se elevan al acercar el puntero y descubren el botón de reproducir sobre la imagen. Tres cosas que conviene no romper: sin JavaScript se ven todas (el estado oculto lo pone la acción [`revelar`](src/lib/acciones/revelar.ts), nunca el CSS), `prefers-reduced-motion` desactiva el movimiento, y en pantallas táctiles el botón de reproducir se queda siempre visible, porque sin puntero no hay nada que lo revele. ## El reproductor Vive en el layout raíz, fuera de la parte que cambia al navegar, así que la música sigue sonando mientras se recorre la web. Su estado es una única instancia de módulo ([`src/lib/player/player.svelte.ts`](src/lib/player/player.svelte.ts)), no un contexto de Svelte, precisamente por eso. **Lo que suena es la canción completa en calidad de escucha.** La compra entrega la representación privada de mayor calidad e incluye las versiones musicales. Qué hace: cola con orden propio, aleatorio (respetando el tema en curso), repetición de cola o de tema, búsqueda dentro del tema, volumen recordado entre visitas, panel de cola e integración con la Media Session del sistema operativo. Atajos, mientras no se esté escribiendo en un campo: | Tecla | Acción | | ------------------- | ------------------------- | | `Espacio` | Reproducir / pausar | | `←` / `→` | Retroceder / avanzar 5 s | | `Mayús` + `←` / `→` | Tema anterior / siguiente | | `M` | Silenciar | Reproducir un tema desde cualquier listado carga **todo el listado** en la cola. En la ficha de un tema, las alternativas musicales forman su propia cola, para poder comparar arreglos seguidos. ## La tienda ### Precio Las canciones se venden en **packs cerrados de diez, a 1,50 € el pack**: diez canciones por lo que cuesta un café, que es la propuesta y por eso el número y el importe se leen siempre juntos. El carrito no deja pagar hasta completar un múltiplo de diez, dice cuántas faltan y propone temas para cerrar el pack. Toda esa aritmética está en [`src/lib/tienda.ts`](src/lib/tienda.ts), en funciones puras que usan por igual la página y el servidor: no hay dos versiones del cálculo. Para cambiar el precio o el tamaño del pack, esas dos constantes. ### Cuentas Alta y entrada con un **código de seis dígitos enviado por correo**. No hay contraseñas que guardar ni recuperar, y la verificación del correo va incluida en el propio registro. Si es la primera vez, la cuenta se crea sola. Ni el código ni el token de sesión se guardan en claro: en la base de datos hay hashes. El código caduca a los quince minutos, solo sirve una vez, y pedir uno nuevo invalida el anterior. Los envíos están limitados por dirección IP. `/entrar?paso=codigo&email=…` vuelve al paso del código sin gastar otro, para quien recarga la página o vuelve desde el correo. ### Carrito Se puede llenar **sin haber entrado**: mientras no hay cuenta se identifica con una cookie, y al iniciar sesión se fusiona con el carrito de la cuenta. Nadie pierde lo que estaba eligiendo por tener que registrarse a mitad. Los botones son formularios de verdad, así que **funcionan sin JavaScript**: la acción redirige de vuelta a la página de origen. Con JavaScript, `enhance` sigue esa misma redirección sin recargar. ### Pago Se usa **Stripe Checkout alojado**: la web no ve nunca los datos de la tarjeta. El importe no viaja como total, sino como precio del pack por cantidad, para que la factura de Stripe diga lo mismo que el carrito. El pedido se guarda como pendiente antes de mandar a la pasarela y se cierra cuando Stripe lo confirma. La confirmación llega por dos caminos —el webhook y la vuelta a `/compra/completada`, que consulta el estado real—, así que la compra queda registrada aunque se cierre la pestaña o el webhook tarde. Cerrar un pedido es idempotente, de modo que llegar por los dos no molesta. En local, para recibir el webhook: ```bash stripe listen --forward-to localhost:5173/api/stripe/webhook ``` y el secreto que imprime va en `STRIPE_WEBHOOK_SECRET`. ### Descargas `/descargas/` y `/descargas//` comprueban sesión y compra en cada petición y transmiten el archivo desde el directorio privado. No hay URLs firmadas a propósito: el servidor ya tiene la sesión delante, y una URL firmada solo añadiría una copia del permiso que puede quedarse obsoleta. Si algún día el catálogo se mueve a almacenamiento de objetos (S3, R2), lo que cambia es [`src/lib/server/medios.ts`](src/lib/server/medios.ts): en vez de leer del disco, firmaría una URL temporal. El resto de la aplicación no se entera. ## Favoritos Quien tiene cuenta puede guardar temas en una lista personal, con el corazón que hay en la ficha, en las tarjetas y en la tabla. La lista está en `/favoritos`. Se puede marcar **cualquier** tema, esté a la venta o no, y marcarlo no dice nada público sobre él: no hay contadores a la vista. Es lo que lo distingue de una compra —que da derecho a la descarga— y de una valoración, que es un dato agregado y visible. Los slugs viajan enteros en `page.data.favoritos`, igual que el carrito: son unas decenas y cualquier tarjeta necesita saber si el suyo está dentro, así que resolverlo una vez en el layout raíz sale más barato que consultarlo por componente. Quien no ha entrado **ve el corazón igualmente**, pero lleva a entrar. Esconderlo dejaría la interfaz cambiando de forma según quién mira, sin explicar por qué falta algo. ## Valoraciones Quien tiene cuenta puede puntuar un tema de 1 a 5 desde su ficha. La media y el número de votos se ven siempre, también sin entrar: saber cómo está valorado un tema no exige cuenta; lo que la exige es votar, para que un voto sea de alguien. **No hace falta haber comprado el tema.** La web permite escucharlo completo en calidad reducida, y opinar sobre lo que se ha escuchado es el uso normal; exigir la compra convertiría la valoración en una reseña de producto. Un voto por cuenta y tema, y volver a votar reemplaza el anterior. Eso lo impone la **clave primaria compuesta** de la tabla `valoracion`, no el código de la acción: si mañana hubiera otra vía de escritura, seguiría sin poder duplicar. El control son cinco radios dentro de un formulario, no cinco botones con `onclick`. De ahí salen gratis tres cosas: funciona sin JavaScript, las flechas del teclado se mueven entre las estrellas sin programar nada, y un lector de pantalla anuncia «3 de 5, seleccionado» en vez de cinco botones sueltos. ## Sindicación y buscadores Tres cosas generadas del catálogo, no escritas a mano: añadir un tema o un capítulo no puede exigir acordarse de tocar un archivo aparte. - **`/blog/rss.xml`** — el feed. Un blog sin feed obliga a volver a mirar si hay algo nuevo, y eso no lo hace nadie. Es lo único que se sindica: el catálogo no es una corriente de novedades, es una obra. Va anunciado con `` en toda la web, que es como lo encuentran los lectores. - **`/sitemap.xml`** — todas las páginas de contenido, con `robots.txt` apuntando a él. Quedan fuera las que dependen de quién mira —cuenta, carrito, favoritos, entrar— y las de descarga: no son contenido y no hay nada que indexar. Un test lo comprueba, porque colar ahí una ruta privada es fácil. - **`/og.jpg`** — la imagen de reserva al compartir. Sin ella, una página sin portada propia se pega en un chat como un enlace pelado. Es **provisional**, como el resto del material visual: sale del fotograma de la portada. ## Volver a donde estabas Los formularios llevan un campo `volverA` para devolver a quien pulsa exactamente a donde estaba. Ese campo lo escribe cualquiera, así que pasa por [`destinoSeguro`](src/lib/server/destino.ts) antes de convertirse en una redirección. No compara prefijos a mano. Se intentó, y se le escapaba `/otro-dominio.com`: el navegador convierte la barra invertida en barra normal al resolver, así que eso es `//otro-dominio.com` disfrazado, y comprobar `//` no basta. Ahora decide la propia resolución de URL —la misma que hará el navegador— y solo se acepta lo que sigue en el origen del sitio. Importa más de lo que parece en la página de entrar: sin filtrar, basta un enlace a nuestra propia pantalla de acceso con `?volverA=` apuntando fuera para que alguien escriba su correo aquí y acabe en una página ajena creyendo que sigue en esta. ## La base de datos **PostgreSQL 16**, con Drizzle por delante. Aquí no vive el catálogo —estilos, discos, temas y capítulos siguen siendo Markdown del repositorio—: la base guarda solo lo que generan las personas que visitan la web, que es lo que no puede estar en el código. Cuentas, códigos de acceso, sesiones, límites de envío, carritos, pedidos, compras, favoritos y valoraciones. Dos diferencias con SQLite que costaron un fallo cada una, por si aparecen otra vez: - **`integer` en Postgres son cuatro bytes**, no ocho. El limitador guarda su vencimiento en milisegundos desde 1970 —trece dígitos— y en `integer` no cabe: va en `bigint`. - **`avg()` y `count()` llegan como cadena.** El driver no las convierte para no perder precisión, así que `cuantas === 0` nunca se cumpliría y «1 voto» saldría siempre en plural. Se convierten en la propia consulta con `::float8` y `::int`. ## Variables de entorno Copia `.env.example` a `.env`. Todas son opcionales en desarrollo: | Variable | Para qué | | ----------------------- | -------------------------------------------------------- | | `DATABASE_URL` | PostgreSQL (`postgresql://usuario:clave@host:5432/base`) | | `MEDIA_DIR` | Directorio privado del audio comprado | | `ESCUCHA_DIR` | Directorio privado del audio de escucha reducida | | `STRIPE_SECRET_KEY` | Cobros | | `STRIPE_WEBHOOK_SECRET` | Firma del webhook de Stripe | | `RESEND_API_KEY` | Códigos de acceso y formulario de contacto | | `CONTACTO_DESTINO` | Buzón que recibe los mensajes | | `CONTACTO_REMITENTE` | Remitente verificado en el dominio | | `ORIGIN` | Origen público, que adapter-node necesita conocer | ## Responsive Se comprueba en pruebas, no de vista: [`responsive.e2e.ts`](e2e/responsive.e2e.ts) recorre diez páginas a 320, 375, 768 y 1440 px y exige que **ninguna desborde a lo ancho**, que ningún control suelto baje de 24×24 —el mínimo para acertarle con el dedo; los enlaces dentro de un texto quedan fuera, los limita la línea—, que la navegación móvil se despliegue entera y que las barras laterales **bajen en vez de desaparecer**: son la navegación de su sección. Dos cosas que salieron de ahí y no se veían a simple vista. Los puntos del carrusel medían 8×8; ahora el punto se ve igual pero el botón que lo rodea mide 24×24. Y el hueco que se reserva bajo el contenido para la barra de reproducción estaba escrito a mano —88 px— cuando la barra mide 97 en escritorio y **145 apilada en un móvil**: se comía el pie de página. Ahora la barra mide su propio alto con un `ResizeObserver` y lo publica en `--player-height`; el valor de `app.css` se queda solo como reserva para el primer pintado. ## Tests - **Unitarios** (Vitest, Node): validación del frontmatter, esquemas de contenido, reglas de la tienda, fechas y duraciones, lógica de cola del reproductor, validación del formulario y limitador de envíos. - **De componente** (Vitest en Chromium): renderizado de listados de temas. - **End-to-end** (Playwright): navegación entre estilos, reproducción y su persistencia al cambiar de página, cola, letras, pestañas de la ficha, taller de letras, cambio de tema, formulario de contacto, carrito anónimo, reglas del pack, alta por código y control de acceso a las descargas. ```bash npm run test:unit -- --run npm run test:e2e ``` ## El contenido en PostgreSQL Lo que hay hoy en `src/content/**` son registros disfrazados de documento, y eso tiene un coste concreto: cinco tablas apuntan a una canción por una cadena de texto sin clave foránea, así que **renombrar el slug de un tema deja huérfana una compra sin que salte ningún error**. El modelo completo, con el porqué de cada decisión, está en [`docs/modelo-de-datos.md`](docs/modelo-de-datos.md). Las migraciones, el panel y las lecturas públicas ya están implementados; los volcamientos totales quedan reservados para bases sin actividad comercial. ## Despliegue El proyecto usa `@sveltejs/adapter-node`: `npm run build` deja un servidor en `build/`, que se arranca con `node build`. **No son archivos estáticos.** Cada página se genera sabiendo quién la pide —si ha entrado, qué lleva en el carrito, qué ha comprado—, así que hace falta un proceso vivo. Un servidor web por sí solo no puede servir esto. El montaje es el de siempre: **Nginx delante, Node detrás**. Nginx recibe en el 443, habla HTTPS y pasa al 3000; y de paso sirve él los estáticos, que es lo que hace que abrir la web no despierte al proceso de Node para devolver un CSS. En [`despliegue/`](despliegue/) están los tres archivos, comentados: | Archivo | Qué es | | ----------------------------------------------------------- | ------------------------------------------------------------------- | | [`senzapaura.service`](despliegue/senzapaura.service) | Servicio de systemd: mantiene Node en pie y lo levanta al reiniciar | | [`senzapaura.nginx.conf`](despliegue/senzapaura.nginx.conf) | El proxy inverso, con los estáticos servidos aparte | | [`desplegar.sh`](despliegue/desplegar.sh) | Construye aquí y sube el resultado por SSH | | [`instalar.sh`](despliegue/instalar.sh) | Lo que en el servidor necesita root, una sola vez | | [`con-proxy-manager.md`](despliegue/con-proxy-manager.md) | El montaje real: DNS, router, Nginx Proxy Manager y sus variables | Lo que hace falta en el servidor: **Node 22**, Nginx y PostgreSQL. Nada más; ni Vite ni el compilador, porque el build se hace en la máquina de desarrollo. El script sube con `tar` por SSH y no con `rsync` —la máquina de desarrollo es Windows y Git Bash no lo trae—, y **sube las dependencias ya instaladas** en vez de resolverlas allí: el servidor tiene salida a internet pero su DNS no resuelve `registry.npmjs.org`, así que `npm ci` falla. Los tres paquetes de producción son JavaScript puro y viajan tal cual; si algún día entra uno con binarios de plataforma, el script se para y avisa en vez de dejar el servicio caído. Cinco cosas que muerden si se olvidan: 1. **`ORIGIN` con el dominio público.** Sin él, SvelteKit rechaza todos los formularios por CSRF con un 403 y parece que la web está rota. Es el error más fácil de cometer y el más desconcertante. 2. **`ADDRESS_HEADER` y `XFF_DEPTH`, si hay un proxy delante.** Sin ellas, `getClientAddress()` devuelve la IP del proxy, y el limitador del formulario de acceso —que cuenta por dirección— salta con una sola persona insistiendo. 3. **Copiar `media/audio/`.** No va en git ni lo genera el build, y sin él no hay descargas. Lo sube `desplegar.sh`. 4. **HTTPS de verdad.** Aquí se escriben correos y se paga. 5. **Las descargas no las puede servir Nginx.** `/descargas/…` comprueba la compra antes de entregar el archivo; el audio completo vive fuera de `build/` justamente para que no haya forma de pedirlo por URL. Al desplegar por primera vez: 1. `npm run db:migrate` con la `DATABASE_URL` de producción. 2. Crear `/var/www/senzapaura/.env` con las claves, en modo 600. 3. `ORIGIN` con el dominio público. 4. Configurar el webhook de Stripe apuntando a `/api/stripe/webhook`. Todas las páginas se renderizan en el servidor. El sitio dejó de prerenderizarse al añadir las cuentas: la cabecera y los listados dependen de quién pide la página. ## Un fallo conocido del entorno En Windows, el empaquetador nativo de Vite 8 (rolldown) termina con SIGSEGV **al salir del proceso** de forma intermitente —unas dos de cada tres veces en la máquina donde se escribió esto—. El build se completa y los artefactos son correctos; lo que falla es el cierre. `npm run build` no lo disimula: devuelve el código de error tal cual, que es lo que debe llegar a una plataforma de despliegue. Lo que sí lo tolera es [`scripts/construir.mjs`](scripts/construir.mjs), que usa solo `npm run test:e2e`: comprueba que los artefactos existan y se hayan escrito en esa ejecución, y si es así continúa avisando por consola. Un build que falla de verdad no deja artefactos nuevos y sigue fallando ahí. Si el fallo no aparece en el servidor de despliegue (es habitual: en Linux no se ha visto), no hay nada más que hacer. ## Decisiones que conviene conocer - **El catálogo no está en la base de datos.** Los pedidos referencian las canciones por slug, y guardan el título del momento de la compra: una factura no debe cambiar porque se renombre un tema. - **Markdown con validación estricta.** El frontmatter se convierte a datos tipados en un único punto ([`src/lib/content/fields.ts`](src/lib/content/fields.ts)), y los errores dicen archivo y campo. - **Fechas siempre en UTC.** Las del contenido son días sueltos. Formateadas en la zona local del navegador, un disco publicado el 1 de marzo se mostraría como 28 de febrero al oeste de Greenwich. - **`remark-breaks` está activo.** Sin él, Markdown juntaría los versos de una letra en un único párrafo. - **El formulario de acceso está limitado en varias capas** ([`src/lib/server/limite.ts`](src/lib/server/limite.ts)), y no por costumbre: manda un correo a la dirección que le escriban, sea de quien sea, así que sin frenos es un botón para bombardear el buzón de cualquiera desde nuestro dominio. Hay enfriamiento por dirección, tope por hora y por día, tope por IP y un techo global que salva la cuota de envío. Los contadores viven en la base de datos, no en memoria: uno que se borra al reiniciar no protege a nadie. Y el tope por dirección usa la forma canónica del correo ([`correo-clave.ts`](src/lib/server/correo-clave.ts)), porque si contara la cadena literal bastaría con ir cambiando `+1`, `+2` para saltárselo. - **Una cuenta solo existe tras verificar el código.** Pedir un código para una dirección ajena no crea nada ni da acceso a nada. - **El contenido va entero en el bundle.** Los listados necesitan el frontmatter de forma síncrona, lo que obliga a importar los `.md` de forma estática, y eso impide separar el cuerpo (las letras) en trozos aparte. Con un catálogo pequeño el coste es despreciable. Si crece hasta cientos de temas, la salida es un plugin de Vite que exponga el frontmatter como módulo virtual; está anotado en [`src/lib/content/index.ts`](src/lib/content/index.ts). ## Revisión e implementación de septiembre de 2026 El [informe de revisión](docs/analisis-aplicacion-2026-09-11.md) conserva los hallazgos de la auditoría. El estado de las correcciones se recoge en [implementación](docs/implementacion-2026-09-11.md). - El hook del panel autoriza cargas y acciones antes de resolverlas. - El catálogo se memoriza por petición; los cambios se ven en la petición siguiente. - Las compras se leen desde la biblioteca del usuario, aunque se retire un tema. - Checkout persiste una intención y su clave idempotente antes de contactar con Stripe. - El panel permite créditos detallados, audio de versiones, imágenes, suscripciones manuales y conciliación. `/medios` muestra incidencias del catálogo. - `npm run db:recuperar-audios` enumera trabajos interrumpidos. Añadir `-- --limpiar` retira solo huérfanos de más de 24 horas; los finales registrados se conservan. El despliegue construye ambas aplicaciones en `releases/` y cambia `current` tras preparar las dependencias. Los medios quedan fuera de las releases. Consulta [el procedimiento](despliegue/LEEME.md) antes de migrar una instalación anterior.