Textos legales, fuentes propias y ajustes de botones

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>
master
dev 1 month ago
parent e929e8c5a9
commit 5c3d3371f0

@ -202,11 +202,16 @@ test.describe('Contraste del dorado', () => {
// estaban: el dorado a 16px y el de al lado a 14. // estaban: el dorado a 16px y el de al lado a 14.
await page.goto('/'); await page.goto('/');
// Los botones de página. El de la esquina de una tarjeta queda fuera a
// propósito: es un adorno accionable sobre una portada y tiene su propio
// tamaño, más pequeño y fijo.
const tamanos = await page const tamanos = await page
.locator('a.btn, button.solido') .locator('a.btn, button.solido')
.evaluateAll((es) => [ .evaluateAll((es) =>
...new Set(es.map((e) => `${getComputedStyle(e).fontSize} ${getComputedStyle(e).padding}`)) es
]); .filter((e) => !e.closest('.tarjeta'))
expect(tamanos).toHaveLength(1); .map((e) => `${getComputedStyle(e).fontSize} ${getComputedStyle(e).padding}`)
);
expect([...new Set(tamanos)]).toHaveLength(1);
}); });
}); });

@ -98,3 +98,59 @@ test.describe('Imagen al compartir', () => {
expect(respuesta.headers()['content-type']).toContain('image/jpeg'); expect(respuesta.headers()['content-type']).toContain('image/jpeg');
}); });
}); });
test.describe('Legal', () => {
test('las cinco páginas están y se enlazan desde el pie', async ({ page }) => {
await page.goto('/');
const pie = page.getByRole('navigation', { name: 'Legal' });
for (const titulo of [
'Información legal',
'Condiciones de uso',
'Política de privacidad',
'Política de cookies',
'Condiciones de venta'
]) {
await expect(pie.getByRole('link', { name: titulo })).toBeVisible();
}
});
test('cada texto legal dice cuándo se revisó', async ({ page }) => {
// Un aviso legal sin fecha no sirve: quien lo lee necesita saber a qué
// versión se está acogiendo.
await page.goto('/legal/privacidad');
await expect(page.getByRole('heading', { level: 1 })).toHaveText('Política de privacidad');
await expect(page.getByText(/Última revisión/)).toBeVisible();
});
test('desde un texto legal se llega a los demás', async ({ page }) => {
await page.goto('/legal/cookies');
const lateral = page.getByRole('navigation', { name: 'Textos legales' });
await expect(lateral.getByRole('link', { name: 'Política de cookies' })).toHaveAttribute(
'aria-current',
'page'
);
await lateral.getByRole('link', { name: 'Condiciones de venta' }).click();
await expect(page).toHaveURL('/legal/condiciones-de-venta');
});
});
test.describe('Sin terceros', () => {
test('cargar la web no contacta con ningún dominio ajeno', async ({ page }) => {
// Las tipografías se sirven desde aquí. Cargarlas de Google mandaba la IP
// de cada visitante a un tercero en cada página, sin pedirlo.
const ajenos = new Set<string>();
page.on('request', (r) => {
const host = new URL(r.url()).host;
if (!host.startsWith('localhost') && !host.startsWith('127.0.0.1')) ajenos.add(host);
});
for (const ruta of ['/', '/musica', '/canciones/dorina', '/entrar']) {
await page.goto(ruta);
}
expect([...ajenos]).toEqual([]);
});
});

@ -1,3 +1,4 @@
@import './fuentes.css';
/* /*
* --------------------------------------------------------------------------- * ---------------------------------------------------------------------------
* DIRECCIÓN VISUAL * DIRECCIÓN VISUAL
@ -705,6 +706,27 @@ a.chip:focus-visible {
transform: none; transform: none;
} }
/*
* El botón de una tarjeta va a su propio tamaño, y no al de los botones de
* página. Medía 48px sobre portadas de 280 —casi una quinta parte de la
* imagen— y encima cambiaba de proporción según la tarjeta fuera cuadrada o
* apaisada. Aquí es siempre el mismo, y sigue por encima del mínimo para
* acertarle con el dedo.
*/
/*
* El selector lleva `.tarjeta` delante a propósito: los estilos del botón
* vienen de un componente y Svelte les añade su clase de ámbito, así que con
* menos peso no se aplicarían. Y nada de `:global()`, que aquí no es sintaxis
* válida —esto es CSS normal— y hace que se descarte la regla entera sin avisar.
*/
.tarjeta .tarjeta__flotante button {
min-width: 2.25rem;
min-height: 2.25rem;
width: 2.25rem;
height: 2.25rem;
padding: 0;
}
/* /*
* En pantallas táctiles no hay puntero que lo revele, y un botón invisible * En pantallas táctiles no hay puntero que lo revele, y un botón invisible
* pero pulsable es peor que ninguno: ahí se queda siempre a la vista. Sin * pero pulsable es peor que ninguno: ahí se queda siempre a la vista. Sin

@ -30,12 +30,6 @@
la hoja de estilos y las fuentes viven en dominios distintos, y sin él la hoja de estilos y las fuentes viven en dominios distintos, y sin él
la letra tarda una vuelta de más en aparecer. la letra tarda una vuelta de más en aparecer.
--> -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Archivo:wght@400;500;600&family=Newsreader:opsz,wght@6..72,300;6..72,400;6..72,500&display=swap"
/>
<link <link
rel="alternate" rel="alternate"
type="application/rss+xml" type="application/rss+xml"

@ -0,0 +1,67 @@
---
titulo: 'Información legal'
resumen: 'Quién está detrás de esta web y bajo qué condiciones se publica.'
actualizada: 2026-09-07
orden: 1
---
> **Pendiente de completar.** Los datos entre corchetes los tiene que rellenar
> el titular, y un abogado debería revisar este texto antes de vender nada. Lo
> que sigue es un borrador que cubre lo que exige la ley española, no un
> documento validado.
## Titular del sitio
En cumplimiento del artículo 10 de la Ley 34/2002 de servicios de la sociedad
de la información y de comercio electrónico:
- **Titular:** [nombre y apellidos o razón social]
- **NIF / CIF:** [documento]
- **Domicilio:** [dirección completa]
- **Correo electrónico:** hola@senzapaura.com
- **Actividad:** creación musical y venta de fonogramas en formato digital
- **Registro mercantil:** [si es sociedad: registro, tomo, folio, hoja]
## Marca
**Senza Paura®** es una marca registrada. [Número de registro y oficina —OEPM,
EUIPO— que lo concede.]
El nombre, el logotipo y los elementos gráficos de este sitio están protegidos.
No se pueden usar sin autorización escrita del titular, ni siquiera citando la
fuente.
## Propiedad intelectual
Las letras, las composiciones, las grabaciones y los textos del taller son obra
del titular y están protegidos por el Real Decreto Legislativo 1/1996, de
Propiedad Intelectual.
**Comprar una canción da derecho a escucharla y conservarla, no a explotarla.**
Concretamente, la compra no autoriza a reproducirla en público, a incorporarla
a otra obra, a distribuirla, ni a usarla en ninguna producción audiovisual o
publicitaria. Para cualquiera de esas cosas hay que pedir licencia: los
identificadores de registro (ISWC, ISRC, entidad de gestión) están publicados
en la ficha de cada tema precisamente para poder localizarla y negociarla.
Citar un fragmento de una letra con fines de crítica, reseña o docencia está
amparado por el derecho de cita del artículo 32, siempre que se indique el
autor y la fuente.
## Responsabilidad
El titular procura que la información publicada sea exacta y esté al día, pero
no garantiza que el sitio esté disponible sin interrupciones ni libre de
errores.
Los enlaces a sitios ajenos —plataformas de escucha, redes— se ofrecen por
comodidad. Lo que haya al otro lado no depende del titular ni es
responsabilidad suya.
## Ley aplicable
Esta relación se rige por la ley española. Para cualquier controversia, las
partes se someten a los juzgados y tribunales del domicilio del titular, salvo
cuando la persona usuaria tenga la condición de consumidora, en cuyo caso será
competente el tribunal de su propio domicilio, según lo dispuesto en la
normativa de consumo.

@ -0,0 +1,67 @@
---
titulo: 'Condiciones de uso'
resumen: 'Qué puedes hacer en esta web y qué no.'
actualizada: 2026-09-07
orden: 2
---
> **Pendiente de revisión legal.** Es un borrador razonable, no un documento
> validado por un abogado.
Usar esta web supone aceptar estas condiciones. Si no estás de acuerdo con
alguna, no la uses.
## La cuenta
Para comprar hace falta una cuenta, y para crearla basta un correo electrónico
al que llega un código. **No hay contraseña**: cada vez que entras se envía un
código nuevo. Eso significa que quien tenga acceso a tu correo tiene acceso a
tu cuenta, así que protégelo.
Al crear la cuenta te comprometes a usar una dirección **tuya**. Escribir la de
otra persona para que le lleguen los códigos es un uso indebido, y el sistema
lo limita activamente.
Puedes pedir que se cierre tu cuenta cuando quieras escribiendo a
hola@senzapaura.com. Las compras y los datos de facturación se conservan el
tiempo que obliga la ley, según se explica en la
[política de privacidad](/legal/privacidad).
## Lo que suena y lo que se compra
En la web **suena un fragmento** de cada tema, no la canción entera. Es una
muestra para que decidas.
Al comprar recibes el archivo completo en MP3, con todas sus versiones
alternativas, y queda vinculado a tu cuenta: puedes volver a descargarlo desde
ella siempre que quieras.
## Lo que no se puede hacer
Con las canciones compradas:
- Redistribuirlas, subirlas a ninguna plataforma ni compartirlas públicamente.
- Usarlas en vídeos, anuncios, espectáculos o cualquier producción.
- Incorporarlas a otra obra o hacer versiones para explotarlas.
Todo eso requiere una licencia aparte. Los identificadores de registro están en
la ficha de cada tema para poder pedirla.
Con la web:
- Intentar acceder a partes que no te corresponden, o a los archivos completos
sin haberlos comprado.
- Automatizar peticiones de forma que degraden el servicio.
- Usar el formulario de acceso o el de contacto para molestar a terceros.
## El taller de letras
Los textos del taller se publican para leerlos y aprender. Puedes usarlos para
escribir tus propias canciones —para eso están— pero no republicarlos como
propios ni venderlos.
## Cambios
Estas condiciones pueden cambiar. La fecha de arriba dice cuándo se revisaron
por última vez, y lo que se aplica a una compra son las condiciones vigentes en
el momento de hacerla.

@ -0,0 +1,75 @@
---
titulo: 'Condiciones de venta'
resumen: 'Qué se vende, a qué precio, cómo se entrega y qué pasa si te arrepientes.'
actualizada: 2026-09-07
orden: 5
---
> **Pendiente de completar y revisar.** El tratamiento del IVA y el derecho de
> desistimiento son las dos partes que más conviene que mire un abogado antes
> de vender.
## Qué se vende
Fonogramas en formato digital: canciones en MP3, con todas sus versiones
alternativas, para descargar y conservar.
No se vende un servicio de suscripción ni un acceso temporal: lo que compras es
un archivo, y sigue siendo tuyo.
## Precio
Las canciones se venden en **packs cerrados de 10 por 1,50 €**. No hay precio
por tema suelto, y el pago no se puede completar hasta cerrar un pack.
[**Pendiente:** indicar si el precio incluye IVA. Vender contenido digital a
particulares en la Unión Europea obliga a repercutir el IVA del país del
comprador. Stripe Tax lo resuelve, pero hay que activarlo y decidirlo.]
## Pago
Se paga con tarjeta a través de **Stripe**. Los datos de la tarjeta se escriben
en la pasarela de Stripe y **no pasan por esta web en ningún momento**.
El pedido se confirma cuando Stripe confirma el cobro.
## Entrega
La entrega es **inmediata y digital**: en cuanto se confirma el pago, las
canciones quedan disponibles para descargar desde tu cuenta, sin límite de
descargas ni caducidad mientras la cuenta exista.
No hay envío físico ni gastos de envío.
## Derecho de desistimiento
Aquí hay una particularidad que conviene entender antes de comprar.
En una compra a distancia tienes catorce días naturales para desistir sin dar
explicaciones. Pero el artículo 103.m del texto refundido de la Ley General
para la Defensa de los Consumidores establece una excepción para el **contenido
digital sin soporte material**: el derecho se pierde cuando la ejecución
comienza con tu consentimiento previo y expreso, sabiendo que al hacerlo lo
pierdes.
En la práctica: **descargar una canción consume el derecho de desistimiento
sobre esa canción**. Mientras no la descargues, sigues teniendo los catorce
días.
[**Pendiente:** el proceso de compra debe recoger ese consentimiento de forma
explícita antes de la primera descarga. Está sin implementar.]
Para desistir dentro de plazo, escribe a hola@senzapaura.com indicando el
pedido. La devolución se hace por el mismo medio de pago.
## Si algo va mal
Si un archivo no se descarga, está corrupto o no corresponde a lo comprado,
escribe a hola@senzapaura.com y se resuelve. Si no quedas conforme, puedes
acudir a la plataforma de resolución de litigios en línea de la Comisión
Europea.
## Facturación
Puedes indicar tus datos de facturación en tu cuenta antes de comprar, y
descargar el justificante de cada pedido desde ahí.

@ -0,0 +1,38 @@
---
titulo: 'Política de cookies'
resumen: 'Dos cookies, las dos necesarias, ninguna de seguimiento.'
actualizada: 2026-09-07
orden: 4
---
Esta web usa **dos cookies, y las dos son necesarias para que funcione**. No
hay cookies de analítica, ni de publicidad, ni de terceros.
Por eso no verás un aviso pidiéndote permiso: la normativa solo exige
consentimiento para las que no son imprescindibles, y aquí no hay ninguna.
## Las que hay
| Nombre | Para qué | Cuánto dura |
| --------- | ----------------------------------------------------------------------------------- | --------------------------------- |
| `sesion` | Mantener la sesión abierta cuando entras con tu cuenta | Hasta que cierras sesión o caduca |
| `carrito` | Recordar el carrito antes de que entres, para que no se pierda al cambiar de página | Mientras dure el carrito |
Las dos van marcadas como `HttpOnly` y `SameSite`, lo que significa que ningún
script puede leerlas y que no viajan a otros sitios.
## Lo que se guarda en tu navegador, que no son cookies
Hay dos preferencias que se quedan **solo en tu equipo** y no se envían a
ninguna parte:
- El **tema** que elijas, claro u oscuro.
- El **volumen** del reproductor.
Se borran vaciando los datos del sitio en tu navegador.
## Cómo quitarlas
Puedes borrar y bloquear las cookies desde tu navegador. Ten en cuenta que si
bloqueas las dos de arriba **no podrás entrar en tu cuenta ni comprar**, porque
son justamente las que sostienen esas dos cosas.

@ -0,0 +1,72 @@
---
titulo: 'Política de privacidad'
resumen: 'Qué datos se recogen, para qué, cuánto se guardan y cómo pedir que se borren.'
actualizada: 2026-09-07
orden: 3
---
> **Pendiente de completar.** Los datos entre corchetes los tiene que rellenar
> el titular, y un abogado debería revisarlo antes de vender. Lo que sí es
> exacto es la descripción técnica: está escrita mirando lo que hace el código,
> no copiada de una plantilla.
## Quién trata tus datos
- **Responsable:** [nombre y apellidos o razón social]
- **NIF:** [documento]
- **Domicilio:** [dirección completa]
- **Contacto:** hola@senzapaura.com
## Qué se recoge y para qué
Esta web pide **lo mínimo para funcionar**. No hay analítica, ni píxeles de
seguimiento, ni perfilado, ni publicidad.
| Qué | Para qué | Base jurídica | Cuánto se guarda |
| ------------------------------- | ---------------------------------------------- | ---------------------- | ----------------------------------------------------------------------------------------- |
| Tu correo electrónico | Crear la cuenta y enviarte el código de acceso | Ejecución del contrato | Mientras tengas cuenta |
| Códigos de acceso | Verificar que el correo es tuyo | Ejecución del contrato | Caducan a los pocos minutos |
| Carrito y compras | Venderte las canciones y darte acceso a ellas | Ejecución del contrato | Las compras, mientras tengas cuenta; los datos de facturación, 6 años (obligación fiscal) |
| Favoritos y valoraciones | Funciones de la cuenta | Ejecución del contrato | Mientras tengas cuenta |
| Dirección IP y correo, contados | Limitar los envíos de códigos | Interés legítimo | Entre un minuto y un día, según el contador |
Ese último merece explicación. El formulario de acceso manda un correo a la
dirección que se escriba, **sea o no de quien lo escribe**. Sin un límite,
cualquiera podría usarlo para llenar el buzón de otra persona. Por eso se
cuentan los intentos por dirección y por correo durante ventanas cortas. No se
guarda un historial: solo un contador que caduca.
## Con quién se comparten
Solo con dos empresas, y solo lo imprescindible:
- **Stripe Payments Europe, Ltd.** (Irlanda) procesa los pagos. Los datos de la
tarjeta **no pasan por esta web en ningún momento**: se escriben directamente
en la pasarela de Stripe.
- **Resend** envía los correos con los códigos de acceso, y para eso recibe la
dirección de destino.
**Nada más sale de aquí.** Las tipografías se sirven desde este mismo dominio y
no desde Google, precisamente para que tu dirección IP no llegue a un tercero
solo por abrir una página. Los vídeos de YouTube no cargan nada hasta que
pulsas el botón de reproducir: hasta ese momento lo que se ve es una imagen
servida desde aquí.
## Cuánto se guardan
Lo dicho en la tabla. Los datos de facturación se conservan seis años aunque
cierres la cuenta, porque lo exige el Código de Comercio.
## Tus derechos
Puedes pedir acceso a tus datos, su rectificación, su supresión, la limitación
del tratamiento, la portabilidad y oponerte al tratamiento. Escribiendo a
hola@senzapaura.com desde la dirección de tu cuenta, con [plazo de respuesta].
Si crees que no se han atendido bien, puedes reclamar ante la **Agencia
Española de Protección de Datos** (aepd.es).
## Menores
Este servicio no está dirigido a menores de 14 años y no se recogen sus datos a
sabiendas.

@ -0,0 +1,150 @@
/*
* Las fuentes se sirven desde aquí, no desde Google.
*
* Cargarlas de fonts.googleapis.com manda la IP de cada visitante a un tercero
* en cada carga de página, sin haberlo pedido y sin que aporte nada. Servidas
* desde el propio dominio no sale nada fuera, la política de privacidad se
* queda en una línea y encima llegan antes: se ahorran dos conexiones nuevas
* —DNS, TLS— antes de poder empezar a descargarlas.
*
* Generado a partir del CSS de Google, conservando solo los subconjuntos
* latinos. Si se cambian los pesos, hay que volver a generarlo.
*/
@font-face {
font-family: 'Archivo';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fuentes/archivo-400-latin-ext.woff2') format('woff2');
unicode-range:
U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329,
U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F,
U+A720-A7FF;
}
@font-face {
font-family: 'Archivo';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fuentes/archivo-400-latin.woff2') format('woff2');
unicode-range:
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329,
U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: 'Archivo';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url('/fuentes/archivo-500-latin-ext.woff2') format('woff2');
unicode-range:
U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329,
U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F,
U+A720-A7FF;
}
@font-face {
font-family: 'Archivo';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url('/fuentes/archivo-500-latin.woff2') format('woff2');
unicode-range:
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329,
U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: 'Archivo';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url('/fuentes/archivo-600-latin-ext.woff2') format('woff2');
unicode-range:
U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329,
U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F,
U+A720-A7FF;
}
@font-face {
font-family: 'Archivo';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url('/fuentes/archivo-600-latin.woff2') format('woff2');
unicode-range:
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329,
U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: 'Newsreader';
font-style: normal;
font-weight: 300;
font-display: swap;
src: url('/fuentes/newsreader-300-latin-ext.woff2') format('woff2');
unicode-range:
U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329,
U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F,
U+A720-A7FF;
}
@font-face {
font-family: 'Newsreader';
font-style: normal;
font-weight: 300;
font-display: swap;
src: url('/fuentes/newsreader-300-latin.woff2') format('woff2');
unicode-range:
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329,
U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: 'Newsreader';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fuentes/newsreader-400-latin-ext.woff2') format('woff2');
unicode-range:
U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329,
U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F,
U+A720-A7FF;
}
@font-face {
font-family: 'Newsreader';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fuentes/newsreader-400-latin.woff2') format('woff2');
unicode-range:
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329,
U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: 'Newsreader';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url('/fuentes/newsreader-500-latin-ext.woff2') format('woff2');
unicode-range:
U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329,
U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F,
U+A720-A7FF;
}
@font-face {
font-family: 'Newsreader';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url('/fuentes/newsreader-500-latin.woff2') format('woff2');
unicode-range:
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329,
U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}

@ -101,7 +101,7 @@
<ul> <ul>
{#each navegacion as enlace (enlace.href)} {#each navegacion as enlace (enlace.href)}
<li> <li>
<a href={resolve(enlace.href)} aria-current={activa(enlace.href) ? 'page' : undefined}> <a href={enlace.href} aria-current={activa(enlace.href) ? 'page' : undefined}>
{enlace.texto} {enlace.texto}
</a> </a>
</li> </li>

@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
/** Pie del sitio: contacto directo, redes y aviso de autoria. */ /** Pie del sitio: contacto directo, redes y aviso de autoria. */
import { resolve } from '$app/paths'; import { resolve } from '$app/paths';
import { legales } from '$lib/content';
import { site } from '$lib/site'; import { site } from '$lib/site';
const ano = new Date().getFullYear(); const ano = new Date().getFullYear();
@ -49,7 +50,22 @@
</div> </div>
<div class="container legal muted"> <div class="container legal muted">
<p>&copy; {ano} {site.nombre}. Todos los derechos reservados.</p> <!--
Los textos legales van en el pie y en todas las páginas porque tienen
que estar accesibles desde cualquier punto de la compra, no solo desde
una página escondida.
-->
<nav aria-label="Legal">
<ul>
{#each legales as pagina (pagina.slug)}
<li>
<a href={resolve('/legal/[slug]', { slug: pagina.slug })}>{pagina.titulo}</a>
</li>
{/each}
</ul>
</nav>
<p>&copy; {ano} {site.nombre}&reg;. Todos los derechos reservados.</p>
</div> </div>
</footer> </footer>
@ -84,6 +100,23 @@
gap: var(--sp-1); gap: var(--sp-1);
} }
.legal nav ul {
display: flex;
flex-wrap: wrap;
gap: var(--sp-2) var(--sp-4);
list-style: none;
margin: 0 0 var(--sp-3);
padding: 0;
}
.legal a {
text-decoration: none;
}
.legal a:hover {
text-decoration: underline;
}
.legal { .legal {
margin-top: var(--sp-5); margin-top: var(--sp-5);
padding-top: var(--sp-4); padding-top: var(--sp-4);

@ -42,7 +42,7 @@
loading="lazy" loading="lazy"
/> />
<div class="tarjeta__flotante"> <div class="tarjeta__flotante">
<BotonReproducir {pistas} modo="album" variante="solido" tamano={20} /> <BotonReproducir {pistas} modo="album" variante="solido" tamano={16} />
</div> </div>
</div> </div>

@ -47,7 +47,7 @@
loading="lazy" loading="lazy"
/> />
<div class="tarjeta__flotante"> <div class="tarjeta__flotante">
<BotonReproducir {pistas} {indice} variante="solido" tamano={20} /> <BotonReproducir {pistas} {indice} variante="solido" tamano={16} />
</div> </div>
</div> </div>

@ -38,7 +38,7 @@
<img src={imagen} alt="" width="480" height="300" loading="lazy" /> <img src={imagen} alt="" width="480" height="300" loading="lazy" />
{#if pistas.length > 0} {#if pistas.length > 0}
<div class="tarjeta__flotante"> <div class="tarjeta__flotante">
<BotonReproducir {pistas} modo="album" variante="solido" tamano={20} /> <BotonReproducir {pistas} modo="album" variante="solido" tamano={16} />
</div> </div>
{/if} {/if}
</div> </div>

@ -28,6 +28,7 @@ import type {
EntradaDeBlog, EntradaDeBlog,
Estilo, Estilo,
Etiqueta, Etiqueta,
PaginaLegal,
PaginaMeta, PaginaMeta,
EstiloConObra EstiloConObra
} from '$lib/types'; } from '$lib/types';
@ -38,6 +39,7 @@ import {
parsearCancion, parsearCancion,
parsearCapitulo, parsearCapitulo,
parsearEntrada, parsearEntrada,
parsearLegal,
parsearEstilo, parsearEstilo,
parsearPagina, parsearPagina,
PORTADA_SINGLES, PORTADA_SINGLES,
@ -59,6 +61,7 @@ const modCanciones = import.meta.glob<ModuloMarkdown>('/src/content/canciones/*.
const modPaginas = import.meta.glob<ModuloMarkdown>('/src/content/paginas/*.md', { eager: true }); const modPaginas = import.meta.glob<ModuloMarkdown>('/src/content/paginas/*.md', { eager: true });
const modTaller = import.meta.glob<ModuloMarkdown>('/src/content/taller/*.md', { eager: true }); const modTaller = import.meta.glob<ModuloMarkdown>('/src/content/taller/*.md', { eager: true });
const modBlog = import.meta.glob<ModuloMarkdown>('/src/content/blog/*.md', { eager: true }); const modBlog = import.meta.glob<ModuloMarkdown>('/src/content/blog/*.md', { eager: true });
const modLegal = import.meta.glob<ModuloMarkdown>('/src/content/legal/*.md', { eager: true });
/** Aplica un parser al frontmatter de cada archivo del glob. */ /** Aplica un parser al frontmatter de cada archivo del glob. */
function mapear<T>( function mapear<T>(
@ -108,6 +111,20 @@ export const entradas: EntradaDeBlog[] = mapear(modBlog, parsearEntrada)
.filter((e) => !e.borrador) .filter((e) => !e.borrador)
.sort(porFechaDesc); .sort(porFechaDesc);
/** Los textos legales, en el orden en que se enumeran en el pie. */
export const legales: PaginaLegal[] = mapear(modLegal, parsearLegal).sort(
(a, b) => a.orden - b.orden
);
export function obtenerLegal(slug: string): PaginaLegal | undefined {
return legales.find((p) => p.slug === slug);
}
/** Componente con el texto de una pagina legal. */
export function cuerpoLegal(slug: string): Component | undefined {
return cuerpo(modLegal, slug);
}
export function obtenerEntrada(slug: string): EntradaDeBlog | undefined { export function obtenerEntrada(slug: string): EntradaDeBlog | undefined {
return entradas.find((e) => e.slug === slug); return entradas.find((e) => e.slug === slug);
} }

@ -14,6 +14,7 @@ import type {
EdicionDeAlbum, EdicionDeAlbum,
EntradaDeBlog, EntradaDeBlog,
Estilo, Estilo,
PaginaLegal,
PaginaMeta, PaginaMeta,
ParteDeAutoria, ParteDeAutoria,
RegistroDeObra, RegistroDeObra,
@ -313,6 +314,16 @@ export function parsearEntrada(datos: Datos, slug: string, archivo: string): Ent
}; };
} }
export function parsearLegal(datos: Datos, slug: string, archivo: string): PaginaLegal {
return {
slug,
titulo: texto(datos, 'titulo', archivo),
resumen: textoConDefecto(datos, 'resumen', archivo, ''),
actualizada: fecha(datos, 'actualizada', archivo),
orden: entero(datos, 'orden', archivo, { min: 0 }) ?? 99
};
}
export function parsearPagina(datos: Datos, slug: string, archivo: string): PaginaMeta { export function parsearPagina(datos: Datos, slug: string, archivo: string): PaginaMeta {
return { return {
slug, slug,

@ -1,4 +1,5 @@
import type { Pathname } from '$app/types'; import { resolve } from '$app/paths';
import type { ResolvedPathname } from '$app/types';
/** /**
* Datos globales del sitio. * Datos globales del sitio.
@ -82,13 +83,19 @@ export const heroe = {
poster: { oscuro: '/video/heroe.jpg', claro: '/video/heroe-claro.jpg' } poster: { oscuro: '/video/heroe.jpg', claro: '/video/heroe-claro.jpg' }
} as const; } as const;
/** Navegacion principal. El orden es el que se pinta en la cabecera. */ /**
export const navegacion: { href: Pathname; texto: string }[] = [ * Navegacion principal. El orden es el que se pinta en la cabecera.
{ href: '/musica', texto: 'Música' }, *
{ href: '/taller', texto: 'Taller de letras' }, * Las rutas se resuelven aqui, una a una, y no en la cabecera: `resolve()`
{ href: '/blog', texto: 'Blog' }, * tiene una sobrecarga por ruta y no acepta una union de todas, asi que
{ href: '/bio', texto: 'Bio' }, * pasarle `enlace.href` deja de compilar en cuanto el sitio crece.
{ href: '/contacto', texto: 'Contacto' } */
export const navegacion: { href: ResolvedPathname; texto: string }[] = [
{ href: resolve('/musica'), texto: 'Música' },
{ href: resolve('/taller'), texto: 'Taller de letras' },
{ href: resolve('/blog'), texto: 'Blog' },
{ href: resolve('/bio'), texto: 'Bio' },
{ href: resolve('/contacto'), texto: 'Contacto' }
]; ];
/** Etiquetas visibles de cada plataforma de escucha. */ /** Etiquetas visibles de cada plataforma de escucha. */

@ -313,6 +313,23 @@ export interface EntradaDeBlog {
borrador: boolean; borrador: boolean;
} }
/**
* Un texto legal. El cuerpo del Markdown es el texto.
*
* Lleva fecha de actualizacion porque en un aviso legal importa: quien lo lee
* necesita saber a que version se esta acogiendo, y si cambia hay que poder
* demostrar desde cuando.
*/
export interface PaginaLegal {
slug: string;
titulo: string;
resumen: string;
/** ISO `YYYY-MM-DD` de la ultima revision. */
actualizada: string;
/** Orden en el indice y en el pie. */
orden: number;
}
/** Una etiqueta con lo que agrupa. El slug es lo que viaja en la URL. */ /** Una etiqueta con lo que agrupa. El slug es lo que viaja en la URL. */
export interface Etiqueta { export interface Etiqueta {
slug: string; slug: string;

@ -0,0 +1,80 @@
<script lang="ts">
import { resolve } from '$app/paths';
import Meta from '$lib/components/Meta.svelte';
import { formatearFecha } from '$lib/utils/date';
let { data } = $props();
</script>
<Meta titulo="Legal" descripcion="Información legal, condiciones, privacidad y cookies." />
<div class="container">
<header class="intro">
<h1>Legal</h1>
<p class="entradilla">
Quién está detrás de esta web, bajo qué condiciones se usa y qué se hace con tus datos.
</p>
</header>
<ul class="paginas">
{#each data.legales as pagina (pagina.slug)}
<li>
<a href={resolve('/legal/[slug]', { slug: pagina.slug })}>
<span class="titulo">{pagina.titulo}</span>
<span class="resumen muted">{pagina.resumen}</span>
<span class="fecha muted">
Revisada el <time datetime={pagina.actualizada}
>{formatearFecha(pagina.actualizada)}</time
>
</span>
</a>
</li>
{/each}
</ul>
</div>
<style>
.intro {
max-width: 62ch;
padding-block: var(--sp-7) var(--sp-5);
}
h1 {
font-size: var(--fs-3xl);
}
.entradilla {
font-size: var(--fs-lg);
color: var(--c-text-muted);
}
.paginas {
list-style: none;
margin: 0;
padding: 0 0 var(--sp-7);
border-top: 1px solid var(--c-border);
max-width: 62ch;
}
.paginas a {
display: flex;
flex-direction: column;
gap: var(--sp-1);
padding-block: var(--sp-4);
border-bottom: 1px solid var(--c-border);
text-decoration: none;
}
.titulo {
font-family: var(--font-display);
font-size: var(--fs-lg);
}
.resumen {
font-size: var(--fs-sm);
}
.fecha {
font-size: var(--fs-xs);
}
</style>

@ -0,0 +1,5 @@
import { legales } from '$lib/content';
export function load() {
return { legales };
}

@ -0,0 +1,170 @@
<script lang="ts">
import { resolve } from '$app/paths';
import Meta from '$lib/components/Meta.svelte';
import Migas from '$lib/components/Migas.svelte';
import { formatearFecha } from '$lib/utils/date';
let { data } = $props();
const pagina = $derived(data.pagina);
const Cuerpo = $derived(data.Cuerpo);
const migas = $derived([{ texto: 'Legal', href: resolve('/legal') }, { texto: pagina.titulo }]);
</script>
<Meta titulo={pagina.titulo} descripcion={pagina.resumen} />
<div class="container legal">
<!--
El índice al lado, como en el taller: quien abre un texto legal suele
querer mirar otro, y volver atrás para eso es una molestia.
-->
<aside class="lateral">
<nav aria-label="Textos legales">
<p class="rotulo" id="lista-legal">Legal</p>
<ul aria-labelledby="lista-legal">
{#each data.legales as otra (otra.slug)}
<li>
<a
href={resolve('/legal/[slug]', { slug: otra.slug })}
aria-current={otra.slug === pagina.slug ? 'page' : undefined}
>
{otra.titulo}
</a>
</li>
{/each}
</ul>
</nav>
</aside>
<div class="principal">
<Migas {migas} />
<header class="intro">
<h1>{pagina.titulo}</h1>
<p class="fecha muted">
Última revisión:
<time datetime={pagina.actualizada}>{formatearFecha(pagina.actualizada)}</time>
</p>
</header>
<article class="texto">
{#if Cuerpo}
<Cuerpo />
{/if}
</article>
</div>
</div>
<style>
.legal {
display: grid;
grid-template-columns: 14rem minmax(0, 1fr);
gap: var(--sp-6);
align-items: start;
}
.lateral {
position: sticky;
top: calc(var(--altura-cabecera) + var(--sp-5));
padding-top: var(--sp-7);
font-size: var(--fs-sm);
}
.rotulo {
margin: 0 0 var(--sp-2);
padding-bottom: var(--sp-2);
border-bottom: 1px solid var(--c-border);
font-size: var(--fs-xs);
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--c-text-muted);
}
.lateral ul {
list-style: none;
margin: 0;
padding: 0;
}
.lateral a {
display: block;
padding: var(--sp-1) 0 var(--sp-1) var(--sp-3);
margin-left: calc(var(--sp-3) * -1);
border-left: 2px solid transparent;
color: var(--c-text-muted);
text-decoration: none;
}
.lateral a[aria-current='page'] {
color: var(--c-text);
border-left-color: var(--c-accent);
}
.intro {
padding-bottom: var(--sp-5);
}
h1 {
font-size: var(--fs-2xl);
}
.fecha {
font-size: var(--fs-sm);
margin: var(--sp-1) 0 0;
}
/* Prosa legal: medida corta, y aire para que no parezca letra pequeña. */
.texto {
max-width: 66ch;
padding-bottom: var(--sp-7);
}
.texto :global(h2) {
font-size: var(--fs-xl);
margin-top: var(--sp-6);
}
.texto :global(blockquote) {
margin: var(--sp-5) 0;
padding: var(--sp-3) var(--sp-4);
border-left: 2px solid var(--c-accent);
background: var(--c-bg-raised);
font-size: var(--fs-sm);
}
.texto :global(table) {
width: 100%;
border-collapse: separate;
border-spacing: 0;
margin: var(--sp-4) 0;
font-size: var(--fs-sm);
}
.texto :global(th),
.texto :global(td) {
padding: var(--sp-2) var(--sp-3);
text-align: left;
border-bottom: 1px solid var(--c-border);
vertical-align: top;
}
.texto :global(th) {
font-size: var(--fs-xs);
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--c-text-muted);
}
@media (max-width: 60rem) {
.legal {
grid-template-columns: minmax(0, 1fr);
}
.lateral {
position: static;
order: 1;
padding-top: var(--sp-5);
}
}
</style>

@ -0,0 +1,9 @@
import { error } from '@sveltejs/kit';
import { cuerpoLegal, legales, obtenerLegal } from '$lib/content';
export function load({ params }) {
const pagina = obtenerLegal(params.slug);
if (!pagina) error(404, 'Esa página no existe.');
return { pagina, legales, Cuerpo: cuerpoLegal(params.slug) };
}

@ -4,6 +4,7 @@ import {
entradas, entradas,
estilos, estilos,
etiquetas, etiquetas,
legales,
paginas, paginas,
slugsDeAlbum slugsDeAlbum
} from '$lib/content'; } from '$lib/content';
@ -44,6 +45,7 @@ export const GET: RequestHandler = () => {
url('/blog', PRIORIDAD.seccion), url('/blog', PRIORIDAD.seccion),
url('/etiquetas', PRIORIDAD.resto), url('/etiquetas', PRIORIDAD.resto),
url('/contacto', PRIORIDAD.resto), url('/contacto', PRIORIDAD.resto),
url('/legal', PRIORIDAD.resto),
...estilos.map((e) => url(`/musica/${e.slug}`, PRIORIDAD.seccion)), ...estilos.map((e) => url(`/musica/${e.slug}`, PRIORIDAD.seccion)),
// Incluye los álbumes de singles, que son sintéticos pero tienen página. // Incluye los álbumes de singles, que son sintéticos pero tienen página.
@ -52,7 +54,8 @@ export const GET: RequestHandler = () => {
...capitulos.map((c) => url(`/taller/${c.slug}`, PRIORIDAD.obra)), ...capitulos.map((c) => url(`/taller/${c.slug}`, PRIORIDAD.obra)),
...entradas.map((e) => url(`/blog/${e.slug}`, PRIORIDAD.obra, e.fecha)), ...entradas.map((e) => url(`/blog/${e.slug}`, PRIORIDAD.obra, e.fecha)),
...etiquetas().map((e) => url(`/etiquetas/${e.slug}`, PRIORIDAD.resto)), ...etiquetas().map((e) => url(`/etiquetas/${e.slug}`, PRIORIDAD.resto)),
...paginas.map((p) => url(`/${p.slug}`, PRIORIDAD.resto)) ...paginas.map((p) => url(`/${p.slug}`, PRIORIDAD.resto)),
...legales.map((p) => url(`/legal/${p.slug}`, PRIORIDAD.resto, p.actualizada))
]; ];
const xml = `<?xml version="1.0" encoding="UTF-8"?> const xml = `<?xml version="1.0" encoding="UTF-8"?>

Loading…
Cancel
Save

Powered by TurnKey Linux.