Pack de diez por un café, fotos en las láminas y oro con reflejo

El pack pasa de cinco a diez canciones por el mismo 1,50 €: diez canciones por
lo que cuesta un café. Es la propuesta, así que el número y el importe se leen
juntos en todas partes.

Las pruebas de la tienda estaban escritas con el cinco metido a mano y hubo que
reescribirlas enteras. Ahora dicen la regla en términos del propio tamaño del
pack, y el helper de las pruebas de extremo a extremo añade lo que haya a la
venta hasta llenarlo en vez de nombrar los temas uno a uno: así cambiar el
tamaño otra vez no obliga a tocar nada.

Las dos láminas de llamada estrenan foto propia con su velo, más cubriente que
el de la textura: sobre una imagen clara el texto blanco no se lee.

Y el dorado deja de ser plano en los botones. Un metal refleja, y lo que lo hace
parecer metal es la banda de luz que lo cruza; el fondo se pinta más alto que el
botón para que al acercar el puntero la luz recorra la pieza en vez de
limitarse a aclararla. El acento sube un punto de croma y baja uno de claridad,
que es lo que lo separa de un amarillo de rotulador.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
master
dev 1 month ago
parent ba72469710
commit da127119c6

@ -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.

@ -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 }) => {

@ -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);

@ -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 -----------------------------------------------------------

@ -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 {

@ -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 @@
<OndasWebgl />
{/if}
<span class="velo" class:cerrado={velo === 'cerrado'}></span>
<span class="velo" class:cerrado={velo === 'cerrado'} class:foto={velo === 'foto'}></span>
</div>
<style>
@ -181,6 +196,22 @@
}
}
/*
* Sobre una foto clara hace falta oscurecer de verdad, no atenuar: el texto
* es blanco. Se carga la izquierda, donde va, y se deja respirar la derecha
* para que la imagen siga contándose.
*/
.velo.foto {
background:
linear-gradient(
to right,
color-mix(in oklab, var(--c-bg) 94%, transparent) 0%,
color-mix(in oklab, var(--c-bg) 80%, transparent) 45%,
color-mix(in oklab, var(--c-bg) 35%, transparent) 100%
),
linear-gradient(to top, var(--c-bg) 0%, transparent 45%);
}
.velo.cerrado {
background:
linear-gradient(

@ -13,6 +13,8 @@
import type { Snippet } from 'svelte';
interface Props {
/** Fondo propio de la lámina. Sin esto, el de marca. */
imagen?: { src: string; srcset: string; ancho: number; alto: number };
/** Rótulo corto sobre el título. */
eyebrow: string;
titulo: string;
@ -21,11 +23,11 @@
acciones: Snippet;
}
let { eyebrow, titulo, texto, acciones }: Props = $props();
let { imagen, eyebrow, titulo, texto, acciones }: Props = $props();
</script>
<section class="lamina-cta">
<FondoDePortada velo="cerrado" />
<FondoDePortada {imagen} velo={imagen ? 'foto' : 'cerrado'} />
<div class="container">
<p class="eyebrow">{eyebrow}</p>

@ -54,6 +54,28 @@ export const heroe = {
},
claro: null
},
/**
* Fondos de las dos laminas de llamada. Son fotos y no texturas, asi que
* llevan su propio velo: sobre una imagen clara, el texto blanco del tema
* oscuro no se leeria.
*
* En las dos, lo que se ve esta a la derecha y el texto va a la izquierda,
* que es como esta calibrado el velo de tipo `foto`.
*/
taller: {
src: '/background/taller-1408.webp',
ancho: 1408,
alto: 768,
srcset:
'/background/taller-700.webp 700w, /background/taller-1000.webp 1000w, /background/taller-1408.webp 1408w'
},
cafe: {
src: '/background/cafe-1376.webp',
ancho: 1376,
alto: 768,
srcset:
'/background/cafe-700.webp 700w, /background/cafe-1000.webp 1000w, /background/cafe-1376.webp 1376w'
},
/** Un clip por tema: el oscuro sobre marino, el claro sobre blanco. */
video: { oscuro: '/video/heroe.mp4', claro: '/video/heroe-claro.mp4' },
/** Primer fotograma; es lo que se ve sin video o con movimiento reducido. */

@ -18,8 +18,8 @@ describe('resumirCarrito', () => {
});
});
it('cinco canciones son un pack completo', () => {
expect(resumirCarrito(5)).toMatchObject({
it('un pack justo está completo', () => {
expect(resumirCarrito(CANCIONES_POR_PACK)).toMatchObject({
packs: 1,
faltan: 0,
completo: true,
@ -27,8 +27,8 @@ describe('resumirCarrito', () => {
});
});
it('diez canciones son dos packs', () => {
expect(resumirCarrito(10)).toMatchObject({
it('el doble son dos packs', () => {
expect(resumirCarrito(2 * CANCIONES_POR_PACK)).toMatchObject({
packs: 2,
completo: true,
totalCentimos: 2 * PRECIO_PACK_CENTIMOS
@ -37,22 +37,23 @@ describe('resumirCarrito', () => {
it('un pack a medias no se puede pagar', () => {
// Es la regla del negocio: solo packs cerrados.
for (const n of [1, 2, 3, 4]) {
for (let n = 1; n < CANCIONES_POR_PACK; n++) {
expect(resumirCarrito(n).completo).toBe(false);
}
});
it('dice cuántas faltan para cerrar el pack', () => {
expect(resumirCarrito(1).faltan).toBe(4);
expect(resumirCarrito(4).faltan).toBe(1);
expect(resumirCarrito(6).faltan).toBe(4);
expect(resumirCarrito(9).faltan).toBe(1);
expect(resumirCarrito(1).faltan).toBe(CANCIONES_POR_PACK - 1);
expect(resumirCarrito(CANCIONES_POR_PACK - 1).faltan).toBe(1);
// Y vuelve a contar desde cero en el pack siguiente.
expect(resumirCarrito(CANCIONES_POR_PACK + 1).faltan).toBe(CANCIONES_POR_PACK - 1);
expect(resumirCarrito(2 * CANCIONES_POR_PACK - 1).faltan).toBe(1);
});
it('solo cobra los packs cerrados', () => {
// Con siete canciones hay un pack completo y dos sueltas: no se cobran
// las sueltas, y el pago sigue bloqueado hasta completarlas.
const resumen = resumirCarrito(7);
// Un pack completo y dos sueltas: las sueltas no se cobran, y el pago
// sigue bloqueado hasta completarlas.
const resumen = resumirCarrito(CANCIONES_POR_PACK + 2);
expect(resumen.packs).toBe(1);
expect(resumen.totalCentimos).toBe(PRECIO_PACK_CENTIMOS);
expect(resumen.completo).toBe(false);
@ -84,14 +85,20 @@ describe('mensajeDeCarrito', () => {
});
it('cuenta los packs cuando está completo', () => {
expect(mensajeDeCarrito(resumirCarrito(5))).toBe(`1 pack de ${CANCIONES_POR_PACK} canciones.`);
expect(mensajeDeCarrito(resumirCarrito(10))).toBe(
expect(mensajeDeCarrito(resumirCarrito(CANCIONES_POR_PACK))).toBe(
`1 pack de ${CANCIONES_POR_PACK} canciones.`
);
expect(mensajeDeCarrito(resumirCarrito(2 * CANCIONES_POR_PACK))).toBe(
`2 packs de ${CANCIONES_POR_PACK} canciones.`
);
});
it('concuerda el singular al faltar una sola', () => {
expect(mensajeDeCarrito(resumirCarrito(4))).toBe('Falta 1 canción para completar el pack.');
expect(mensajeDeCarrito(resumirCarrito(3))).toBe('Faltan 2 canciones para completar el pack.');
expect(mensajeDeCarrito(resumirCarrito(CANCIONES_POR_PACK - 1))).toBe(
'Falta 1 canción para completar el pack.'
);
expect(mensajeDeCarrito(resumirCarrito(CANCIONES_POR_PACK - 2))).toBe(
'Faltan 2 canciones para completar el pack.'
);
});
});

@ -1,15 +1,18 @@
/**
* Reglas de la tienda.
*
* Las canciones se venden en packs cerrados de cinco: no hay precio por tema
* suelto, y el pago se bloquea hasta que el carrito tiene un multiplo de
* cinco. Toda esa aritmetica vive aqui, en funciones puras, porque la usan por
* igual la pagina del carrito y la accion de pago del servidor, y no puede
* haber dos versiones del calculo.
* Las canciones se venden en packs cerrados de diez: no hay precio por tema
* suelto, y el pago se bloquea hasta que el carrito tiene un multiplo de diez.
* Toda esa aritmetica vive aqui, en funciones puras, porque la usan por igual
* la pagina del carrito y la accion de pago del servidor, y no puede haber dos
* versiones del calculo.
*
* Diez canciones por el precio de un cafe. Es la propuesta, y por eso el numero
* y el importe se leen juntos en todas partes: sueltos no dicen nada.
*/
/** Canciones que componen un pack. */
export const CANCIONES_POR_PACK = 5;
export const CANCIONES_POR_PACK = 10;
/** Precio de un pack, en centimos. */
export const PRECIO_PACK_CENTIMOS = 150;

@ -10,7 +10,7 @@
import { revelar } from '$lib/acciones/revelar';
import { aPistas } from '$lib/player/pista';
import { slugDeEtiqueta } from '$lib/content';
import { site } from '$lib/site';
import { heroe, site } from '$lib/site';
import { CANCIONES_POR_PACK, formatearPrecio, PRECIO_PACK_CENTIMOS } from '$lib/tienda';
let { data } = $props();
@ -36,6 +36,7 @@
<Heroe album={data.heroe} />
{:else if i === 1}
<LaminaCta
imagen={heroe.taller}
eyebrow="Taller de letras"
titulo="¿Quieres escribir tus propias letras?"
texto="Lo que he aprendido escribiendo, puesto en orden: los principios que hacen que una letra funcione y una ficha de creación por cada estilo hispanoamericano, con lo que hay que tener delante para escribir en él."
@ -49,11 +50,12 @@
</LaminaCta>
{:else}
<LaminaCta
imagen={heroe.cafe}
eyebrow="La tienda"
titulo="Llévate las canciones"
texto="{CANCIONES_POR_PACK} canciones por {formatearPrecio(
titulo="{CANCIONES_POR_PACK} canciones por un café"
texto="{formatearPrecio(
PRECIO_PACK_CENTIMOS
)}, en MP3 y con todas sus versiones. En la web suena un fragmento; la descarga es el archivo completo."
)} el pack, en MP3 y con todas sus versiones. En la web suena un fragmento; la descarga es el archivo completo, para llevártelo donde quieras."
>
{#snippet acciones()}
<a class="btn btn--primary" href={resolve('/musica')}>Elegir canciones</a>

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 625 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 920 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Loading…
Cancel
Save

Powered by TurnKey Linux.