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} - +