diff --git a/README.md b/README.md
index 3baca3c..1cd5087 100644
--- a/README.md
+++ b/README.md
@@ -3,7 +3,7 @@
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 cinco canciones**
+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 vive en Markdown dentro del repositorio: publicar una canción es
@@ -23,6 +23,15 @@ 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.
+**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
@@ -543,6 +552,17 @@ 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**
@@ -739,8 +759,10 @@ poder comparar arreglos seguidos.
### Precio
-Las canciones se venden en **packs cerrados de cinco, a 1,50 € el pack**. El
-carrito no deja pagar hasta completar un múltiplo de cinco, dice cuántas faltan
+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.
diff --git a/e2e/navegacion.e2e.ts b/e2e/navegacion.e2e.ts
index 3cac3c3..2a13362 100644
--- a/e2e/navegacion.e2e.ts
+++ b/e2e/navegacion.e2e.ts
@@ -115,7 +115,7 @@ test.describe('Carrusel de la portada', () => {
).toBeVisible();
await page.getByRole('button', { name: 'Ir a: Comprar canciones' }).click();
- await expect(page.getByRole('heading', { name: 'Llévate las canciones' })).toBeVisible();
+ await expect(page.getByRole('heading', { name: /canciones por un café/ })).toBeVisible();
});
test('el teclado no entra en las láminas que no se ven', async ({ page }) => {
diff --git a/e2e/tienda.e2e.ts b/e2e/tienda.e2e.ts
index aa82619..56aa710 100644
--- a/e2e/tienda.e2e.ts
+++ b/e2e/tienda.e2e.ts
@@ -1,5 +1,8 @@
import { expect, test, type Page } from '@playwright/test';
+/** El tamaño del pack, tal como lo dice `$lib/tienda`. */
+const CANCIONES_POR_PACK = 10;
+
/** Añade un tema desde el listado de un álbum, por su título. */
async function anadir(page: Page, titulo: string) {
// Dentro de la lista de temas, no en cualquier lista: las migas de pan
@@ -13,14 +16,33 @@ async function anadir(page: Page, titulo: string) {
await expect(fila.getByRole('button', { name: 'Quitar' })).toBeVisible();
}
-/** Deja el carrito con las cinco canciones de un pack completo. */
-async function llenarUnPack(page: Page) {
- await page.goto('/albumes/como-si-nada');
- for (const titulo of ['Como si nada', 'Media verdad', 'El último tren', 'Domingo largo']) {
- await anadir(page, titulo);
+/**
+ * Deja el carrito con un pack completo.
+ *
+ * Recorre discos añadiendo lo que haya a la venta hasta llegar al número, en
+ * vez de nombrar los temas uno a uno: así el tamaño del pack puede cambiar
+ * —pasó de cinco a diez— sin que haya que reescribir la lista, y añadir o
+ * quitar una canción del catálogo no rompe la prueba.
+ */
+async function llenarUnPack(page: Page, cuantas = CANCIONES_POR_PACK) {
+ let puestas = 0;
+
+ for (const album of ['como-si-nada', 'dos-orillas', 'ruido-blanco', 'preludios-para-piano']) {
+ if (puestas >= cuantas) break;
+ await page.goto(`/albumes/${album}`);
+
+ const botones = page
+ .getByRole('list', { name: 'Temas' })
+ .getByRole('button', { name: 'Añadir' });
+ while (puestas < cuantas && (await botones.count()) > 0) {
+ await botones.first().click();
+ // El botón cambia a «Quitar», así que la cuenta de «Añadir» baja sola.
+ await expect(botones).toHaveCount((await botones.count()) - 1 + 1 - 1);
+ puestas++;
+ }
}
- await page.goto('/albumes/dos-orillas');
- await anadir(page, 'Dos orillas');
+
+ expect(puestas, 'no hay bastantes temas a la venta para llenar un pack').toBe(cuantas);
}
test.describe('Carrito', () => {
@@ -39,7 +61,7 @@ test.describe('Carrito', () => {
await page.goto('/carrito');
await expect(
- page.getByText('Faltan 4 canciones para completar el pack.').first()
+ page.getByText(`Faltan ${CANCIONES_POR_PACK - 1} canciones para completar el pack.`).first()
).toBeVisible();
// Sin sesión el botón de pagar ni siquiera aparece: primero hay que entrar.
await expect(page.getByRole('link', { name: 'Entrar para comprar' })).toBeVisible();
@@ -54,11 +76,11 @@ test.describe('Carrito', () => {
await expect(sugerencias).toBeVisible();
});
- test('con cinco canciones cuenta un pack y su precio', async ({ page }) => {
+ test('un pack completo cuenta y se puede pagar', async ({ page }) => {
await llenarUnPack(page);
await page.goto('/carrito');
- await expect(page.getByText('1 pack de 5 canciones.')).toBeVisible();
+ await expect(page.getByText(`1 pack de ${CANCIONES_POR_PACK} canciones.`)).toBeVisible();
await expect(page.getByText('1,50 €').first()).toBeVisible();
// Al estar completo, ya no hay nada que sugerir.
await expect(page.getByRole('heading', { level: 2, name: 'Completa el pack' })).toHaveCount(0);
diff --git a/src/app.css b/src/app.css
index 1ddd252..26f9cca 100644
--- a/src/app.css
+++ b/src/app.css
@@ -33,8 +33,15 @@
--c-text: oklch(0.96 0.012 255);
--c-text-muted: oklch(0.74 0.022 258);
- --c-accent: oklch(0.82 0.13 88);
+ /*
+ * El oro. El plano de antes tiraba a amarillo; este baja de claridad y sube
+ * de croma, que es lo que separa un metal de un color de rotulador.
+ */
+ --c-accent: oklch(0.8 0.145 84);
--c-accent-text: oklch(0.16 0.042 264);
+ /* Los dos extremos del reflejo: la luz que lo cruza y la sombra del canto. */
+ --c-accent-brillo: oklch(0.94 0.095 96);
+ --c-accent-sombra: oklch(0.63 0.13 72);
--c-danger: oklch(0.72 0.16 22);
--c-focus: oklch(0.84 0.12 88);
@@ -68,6 +75,25 @@
--sp-7: 3rem;
--sp-8: 5rem;
+ /*
+ * El oro de una superficie no es un color plano: un metal refleja, y lo que
+ * lo hace parecer metal es la banda de luz que lo cruza. Cinco paradas —de
+ * la sombra del canto al brillo y otra vez a la sombra— bastan para
+ * sugerirlo sin que parezca un tubo de cromo.
+ *
+ * El fondo se pinta más alto que el elemento a propósito: así, al acercar el
+ * puntero, se desplaza y la luz recorre la pieza en vez de limitarse a
+ * aclararla. Es la diferencia entre algo que se ilumina y algo que gira.
+ */
+ --oro: linear-gradient(
+ 166deg,
+ var(--c-accent-sombra) 0%,
+ var(--c-accent) 24%,
+ var(--c-accent-brillo) 46%,
+ var(--c-accent) 64%,
+ var(--c-accent-sombra) 100%
+ );
+
--radius: 4px;
/* Radio propio: 4px en una tarjeta grande se lee como un recorte, no como una pieza. */
--radius-tarjeta: 14px;
@@ -119,8 +145,10 @@
--c-text: oklch(0.24 0.055 264);
--c-text-muted: oklch(0.48 0.038 264);
- --c-accent: oklch(0.58 0.115 75);
+ --c-accent: oklch(0.57 0.125 74);
--c-accent-text: oklch(0.99 0.004 264);
+ --c-accent-brillo: oklch(0.72 0.14 82);
+ --c-accent-sombra: oklch(0.44 0.1 66);
--c-danger: oklch(0.5 0.17 25);
--c-focus: oklch(0.5 0.13 264);
@@ -137,8 +165,10 @@
--c-text: oklch(0.24 0.055 264);
--c-text-muted: oklch(0.48 0.038 264);
- --c-accent: oklch(0.58 0.115 75);
+ --c-accent: oklch(0.57 0.125 74);
--c-accent-text: oklch(0.99 0.004 264);
+ --c-accent-brillo: oklch(0.72 0.14 82);
+ --c-accent-sombra: oklch(0.44 0.1 66);
--c-danger: oklch(0.5 0.17 25);
--c-focus: oklch(0.5 0.13 264);
@@ -286,14 +316,27 @@ button {
}
.btn--primary {
- background: var(--c-accent);
+ /* El color plano queda de reserva: si el degradado no se pintara, el botón
+ sigue siendo dorado y legible, no transparente. */
+ background-color: var(--c-accent);
+ background-image: var(--oro);
+ background-size: 100% 240%;
+ background-position: 0 22%;
+ border-color: var(--c-accent-sombra);
color: var(--c-accent-text);
- border-color: var(--c-accent);
+ /* El filo de luz de arriba. Sin él, el degradado se ve pintado, no biselado. */
+ box-shadow: inset 0 1px 0 color-mix(in oklab, white 45%, transparent);
+ transition: background-position 0.5s var(--sal);
}
.btn--primary:hover {
- opacity: 0.85;
- background: var(--c-accent);
+ background-position: 0 78%;
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .btn--primary {
+ transition: none;
+ }
}
/* --- etiquetas -----------------------------------------------------------
diff --git a/src/lib/components/BotonReproducir.svelte b/src/lib/components/BotonReproducir.svelte
index 91b90b5..00a84c5 100644
--- a/src/lib/components/BotonReproducir.svelte
+++ b/src/lib/components/BotonReproducir.svelte
@@ -109,14 +109,24 @@
.solido {
padding: var(--sp-2) var(--sp-4);
- background: var(--c-accent);
+ background-color: var(--c-accent);
+ background-image: var(--oro);
+ background-size: 100% 240%;
+ background-position: 0 22%;
+ border-color: var(--c-accent-sombra);
color: var(--c-accent-text);
- border-color: var(--c-accent);
+ box-shadow: inset 0 1px 0 color-mix(in oklab, white 45%, transparent);
+ transition: background-position 0.5s var(--sal);
}
.solido:hover:not(:disabled) {
- background: var(--c-accent);
- opacity: 0.85;
+ background-position: 0 78%;
+ }
+
+ @media (prefers-reduced-motion: reduce) {
+ .solido {
+ transition: none;
+ }
}
.solido.activo {
diff --git a/src/lib/components/FondoDePortada.svelte b/src/lib/components/FondoDePortada.svelte
index 94b4a7a..af85b10 100644
--- a/src/lib/components/FondoDePortada.svelte
+++ b/src/lib/components/FondoDePortada.svelte
@@ -26,8 +26,17 @@
* la derecha, que es donde se ve el tocadiscos.
* - `cerrado` — parejo y más cubriente. En una lámina sin nada que enseñar
* a la derecha, lo único que importa es que se lea lo que se propone.
+ * - `foto` — para una imagen real y clara. El texto del tema oscuro es
+ * blanco, y sobre un mármol iluminado no se lee: aquí el velo no atenúa,
+ * oscurece.
*/
- velo?: 'lateral' | 'cerrado';
+ velo?: 'lateral' | 'cerrado' | 'foto';
+ /**
+ * Imagen propia de la lámina. Sin esto se usa el fondo de marca, que
+ * cambia con el tema; una foto no cambia, así que quien la pasa se
+ * responsabiliza también de que el velo la deje legible.
+ */
+ imagen?: { src: string; srcset: string; ancho: number; alto: number };
/**
* Solo la primera lámina lo pide con prioridad alta: es lo que se ve al
* llegar. Pedir tres fondos con prioridad alta es no priorizar nada.
@@ -58,9 +67,15 @@
ondas?: boolean;
}
- let { velo = 'lateral', prioritario = false, clip = false, ondas = false }: Props = $props();
+ let {
+ velo = 'lateral',
+ prioritario = false,
+ clip = false,
+ ondas = false,
+ imagen
+ }: Props = $props();
- const fondo = $derived(heroe.fondo[tema.actual]);
+ const fondo = $derived(imagen ?? heroe.fondo[tema.actual]);
/*
* Donde hay imagen no se monta el clip, así que nunca conviven los dos en la
* página. Lo que sí ocurre es que el servidor pinta el tema oscuro —el de
@@ -126,7 +141,7 @@
{/if}
-
+