Cierra una redirección abierta, quita código muerto y afina el responsive
El campo `volverA` de los formularios se comprobaba en tres sitios con tres
copias del mismo filtro, y las tres dejaban pasar `/\otro-dominio.com`: el
navegador convierte la barra invertida en barra normal al resolver, así que eso
es `//otro-dominio.com` con otra ropa. En la pantalla de entrar eso es phishing
servido: enlace a nuestro propio acceso, y quien escribe su correo acaba fuera.
Ahora hay un solo `destinoSeguro`, que no compara prefijos sino que resuelve la
URL y solo acepta lo que sigue en el origen. Con pruebas de cada disfraz.
Fuera siete funciones exportadas que no llamaba nadie. Las escribí "por si
acaso" y una API que no se usa no es una API: es código que leer y mantener sin
que nada dependa de ella.
Del repaso responsive salen dos fallos reales. Los puntos del carrusel medían
8×8 píxeles, la tercera parte del mínimo para acertarles con el dedo; ahora el
punto se ve igual y el botón mide 24×24. Y el hueco reservado bajo el contenido
para la barra de reproducción estaba escrito a mano en 88 píxeles cuando la
barra mide 97 en escritorio y 145 apilada en un móvil: se comía el pie. Ahora la
barra se mide sola y publica su alto.
Todo ello fijado en `responsive.e2e.ts`, que recorre diez páginas a cuatro
anchos.
Sobre rendimiento, nada que hacer: parecía que las páginas tardaban 215 ms, pero
eran de la resolución de `localhost` por IPv6 en la medición. Por IPv4 se
renderizan en 5-9 ms, así que memorizar el catálogo habría sido optimizar un
problema inexistente.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 month ago
|
|
|
import { expect, test } from '@playwright/test';
|
|
|
|
|
|
|
|
|
|
/** Anchos que representan un móvil pequeño, uno normal, una tableta y un panel. */
|
|
|
|
|
const ANCHOS = [320, 375, 768, 1440];
|
|
|
|
|
|
|
|
|
|
const RUTAS = [
|
|
|
|
|
'/',
|
|
|
|
|
'/musica',
|
|
|
|
|
'/musica/baladas-romanticas',
|
|
|
|
|
'/albumes/como-si-nada',
|
|
|
|
|
'/canciones/dorina',
|
|
|
|
|
'/taller/el-estribillo',
|
|
|
|
|
'/generos',
|
|
|
|
|
'/generos/bachata',
|
|
|
|
|
'/generos/glosario',
|
|
|
|
|
'/generos/letristas',
|
Cierra una redirección abierta, quita código muerto y afina el responsive
El campo `volverA` de los formularios se comprobaba en tres sitios con tres
copias del mismo filtro, y las tres dejaban pasar `/\otro-dominio.com`: el
navegador convierte la barra invertida en barra normal al resolver, así que eso
es `//otro-dominio.com` con otra ropa. En la pantalla de entrar eso es phishing
servido: enlace a nuestro propio acceso, y quien escribe su correo acaba fuera.
Ahora hay un solo `destinoSeguro`, que no compara prefijos sino que resuelve la
URL y solo acepta lo que sigue en el origen. Con pruebas de cada disfraz.
Fuera siete funciones exportadas que no llamaba nadie. Las escribí "por si
acaso" y una API que no se usa no es una API: es código que leer y mantener sin
que nada dependa de ella.
Del repaso responsive salen dos fallos reales. Los puntos del carrusel medían
8×8 píxeles, la tercera parte del mínimo para acertarles con el dedo; ahora el
punto se ve igual y el botón mide 24×24. Y el hueco reservado bajo el contenido
para la barra de reproducción estaba escrito a mano en 88 píxeles cuando la
barra mide 97 en escritorio y 145 apilada en un móvil: se comía el pie. Ahora la
barra se mide sola y publica su alto.
Todo ello fijado en `responsive.e2e.ts`, que recorre diez páginas a cuatro
anchos.
Sobre rendimiento, nada que hacer: parecía que las páginas tardaban 215 ms, pero
eran de la resolución de `localhost` por IPv6 en la medición. Por IPv4 se
renderizan en 5-9 ms, así que memorizar el catálogo habría sido optimizar un
problema inexistente.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 month ago
|
|
|
'/blog',
|
|
|
|
|
'/etiquetas',
|
|
|
|
|
'/carrito',
|
|
|
|
|
'/entrar'
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
test.describe('Responsive', () => {
|
|
|
|
|
for (const ancho of ANCHOS) {
|
|
|
|
|
test(`ninguna página desborda a lo ancho a ${ancho}px`, async ({ page }) => {
|
|
|
|
|
// Es el fallo clásico: un elemento se pasa de ancho, aparece una barra
|
|
|
|
|
// horizontal y toda la página se puede arrastrar de lado.
|
|
|
|
|
await page.setViewportSize({ width: ancho, height: 800 });
|
|
|
|
|
|
|
|
|
|
for (const ruta of RUTAS) {
|
|
|
|
|
await page.goto(ruta);
|
|
|
|
|
const desborde = await page.evaluate(
|
|
|
|
|
() => document.documentElement.scrollWidth - window.innerWidth
|
|
|
|
|
);
|
|
|
|
|
expect(desborde, `${ruta} a ${ancho}px`).toBeLessThanOrEqual(1);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
test('los controles se pueden pulsar con el dedo', async ({ page }) => {
|
|
|
|
|
// 24x24 es el mínimo de la norma. Los enlaces dentro de un texto quedan
|
|
|
|
|
// fuera —los limita la línea—, pero un botón suelto no tiene excusa.
|
|
|
|
|
await page.setViewportSize({ width: 375, height: 800 });
|
|
|
|
|
|
|
|
|
|
for (const ruta of ['/', '/musica', '/carrito', '/canciones/dorina']) {
|
|
|
|
|
await page.goto(ruta);
|
|
|
|
|
const pequenos = await page.evaluate(() => {
|
|
|
|
|
const fuera: string[] = [];
|
|
|
|
|
for (const e of document.querySelectorAll('button, input, select, [role="button"]')) {
|
|
|
|
|
const c = e.getBoundingClientRect();
|
|
|
|
|
if (!c.width || !c.height) continue;
|
|
|
|
|
if (c.width < 24 || c.height < 24) {
|
|
|
|
|
fuera.push(`${e.getAttribute('aria-label') ?? e.textContent?.trim().slice(0, 20)}`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return [...new Set(fuera)];
|
|
|
|
|
});
|
|
|
|
|
expect(pequenos, `controles pequeños en ${ruta}`).toEqual([]);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('la barra de reproducción no tapa el final de la página', async ({ page }) => {
|
|
|
|
|
// La barra se apila en un móvil y crece de 97 a 145 píxeles. El hueco que
|
|
|
|
|
// se le reserva sale de medirla, no de un número escrito a mano: con uno
|
|
|
|
|
// fijo, en móvil se comía el pie.
|
|
|
|
|
await page.setViewportSize({ width: 375, height: 800 });
|
|
|
|
|
await page.goto('/albumes/como-si-nada');
|
|
|
|
|
await page.getByRole('button', { name: 'Reproducir', exact: true }).first().click();
|
|
|
|
|
await expect(page.getByRole('region', { name: 'Reproductor' })).toBeVisible();
|
|
|
|
|
|
|
|
|
|
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
|
|
|
|
|
|
|
|
|
|
const solapa = await page.evaluate(() => {
|
|
|
|
|
const barra = document.querySelector('[aria-label="Reproductor"]')!.getBoundingClientRect();
|
|
|
|
|
const pie = document.querySelector('footer')!.getBoundingClientRect();
|
|
|
|
|
return Math.round(pie.bottom - barra.top);
|
|
|
|
|
});
|
|
|
|
|
expect(solapa).toBeLessThanOrEqual(1);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('en móvil la navegación se despliega y sigue completa', async ({ page }) => {
|
|
|
|
|
await page.setViewportSize({ width: 375, height: 800 });
|
|
|
|
|
await page.goto('/');
|
|
|
|
|
|
|
|
|
|
const nav = page.getByRole('navigation', { name: 'Principal' });
|
|
|
|
|
await expect(nav).toBeHidden();
|
|
|
|
|
|
|
|
|
|
await page.getByRole('button', { name: 'Menú' }).click();
|
|
|
|
|
await expect(nav).toBeVisible();
|
|
|
|
|
|
|
|
|
|
// Por nombre y no por cuenta: en móvil no se recorta el menú, y así la
|
|
|
|
|
// prueba dice cuál falta en vez de que el número no cuadra.
|
|
|
|
|
for (const seccion of ['Música', 'Taller de letras', 'Géneros', 'Blog', 'Bio', 'Contacto']) {
|
|
|
|
|
await expect(nav.getByRole('link', { name: seccion })).toBeVisible();
|
|
|
|
|
}
|
Cierra una redirección abierta, quita código muerto y afina el responsive
El campo `volverA` de los formularios se comprobaba en tres sitios con tres
copias del mismo filtro, y las tres dejaban pasar `/\otro-dominio.com`: el
navegador convierte la barra invertida en barra normal al resolver, así que eso
es `//otro-dominio.com` con otra ropa. En la pantalla de entrar eso es phishing
servido: enlace a nuestro propio acceso, y quien escribe su correo acaba fuera.
Ahora hay un solo `destinoSeguro`, que no compara prefijos sino que resuelve la
URL y solo acepta lo que sigue en el origen. Con pruebas de cada disfraz.
Fuera siete funciones exportadas que no llamaba nadie. Las escribí "por si
acaso" y una API que no se usa no es una API: es código que leer y mantener sin
que nada dependa de ella.
Del repaso responsive salen dos fallos reales. Los puntos del carrusel medían
8×8 píxeles, la tercera parte del mínimo para acertarles con el dedo; ahora el
punto se ve igual y el botón mide 24×24. Y el hueco reservado bajo el contenido
para la barra de reproducción estaba escrito a mano en 88 píxeles cuando la
barra mide 97 en escritorio y 145 apilada en un móvil: se comía el pie. Ahora la
barra se mide sola y publica su alto.
Todo ello fijado en `responsive.e2e.ts`, que recorre diez páginas a cuatro
anchos.
Sobre rendimiento, nada que hacer: parecía que las páginas tardaban 215 ms, pero
eran de la resolución de `localhost` por IPv6 en la medición. Por IPv4 se
renderizan en 5-9 ms, así que memorizar el catálogo habría sido optimizar un
problema inexistente.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 month ago
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('las barras laterales bajan en vez de desaparecer', async ({ page }) => {
|
|
|
|
|
// Son la navegación de su sección: esconderlas deja la página sin salidas.
|
|
|
|
|
await page.setViewportSize({ width: 375, height: 800 });
|
|
|
|
|
|
|
|
|
|
await page.goto('/musica/baladas-romanticas');
|
|
|
|
|
await expect(page.getByRole('navigation', { name: 'Estilos' })).toBeVisible();
|
|
|
|
|
|
Las fichas de género salen del taller y tienen su propia sección
Con lo que pediste: portada con mapa de Iberoamérica, buscador y un aside
que empieza por Inicio, Los principios, Glosario y Letristas antes de la
lista de géneros.
El mapa es interactivo de verdad: cada país se pinta según lo que tenga
—marcado si es cuna de algún género, tenue si solo lo adoptó, apagado si
no hay nada—, y al pulsarlo la lista de abajo se parte en «nació aquí» y
«también se canta ahí». Esa diferencia es lo que un mapa cuenta mejor que
una lista.
Los contornos son de `BlankMap-Americas.svg` de Wikimedia, en dominio
público, recortados a los veinte países de Iberoamérica en América. De
640 KB a 36 KB comprimidos, y solo en esta ruta: coordenadas a un decimal
y fuera los 134 islotes que a este tamaño miden menos de un píxel. Va por
script y no pegado a mano, para poder rehacerlo.
Cada país lleva además un punto de agarre calculado —el promedio de los
vértices de su costa, no el centro de su rectángulo, que en Cuba cae en
el mar—: sin él, Puerto Rico mide cuatro píxeles y una isla alargada no
hay quien la acierte.
«Letristas» es nueva: las mismas personas que salen repartidas en nueve
fichas, aquí juntas, con su papel en cada género y sus obras.
La página del capítulo pasa a `$lib/components/Capitulo.svelte` y la usan
las dos secciones: son setecientas líneas, y copiarlas habría sido dos
sitios donde arreglar cada cosa.
Las direcciones viejas redirigen con 301 —`/taller/bolero` a
`/generos/bolero`— resolviendo contra el catálogo de capítulos, no contra
una lista escrita a mano que alguien tenga que ampliar. Un enlace
compartido no puede empezar a dar 404 porque hayamos reordenado el menú.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
4 weeks ago
|
|
|
await page.goto('/generos/bachata');
|
|
|
|
|
await expect(page.getByRole('navigation', { name: 'Contenido de géneros' })).toBeVisible();
|
Cierra una redirección abierta, quita código muerto y afina el responsive
El campo `volverA` de los formularios se comprobaba en tres sitios con tres
copias del mismo filtro, y las tres dejaban pasar `/\otro-dominio.com`: el
navegador convierte la barra invertida en barra normal al resolver, así que eso
es `//otro-dominio.com` con otra ropa. En la pantalla de entrar eso es phishing
servido: enlace a nuestro propio acceso, y quien escribe su correo acaba fuera.
Ahora hay un solo `destinoSeguro`, que no compara prefijos sino que resuelve la
URL y solo acepta lo que sigue en el origen. Con pruebas de cada disfraz.
Fuera siete funciones exportadas que no llamaba nadie. Las escribí "por si
acaso" y una API que no se usa no es una API: es código que leer y mantener sin
que nada dependa de ella.
Del repaso responsive salen dos fallos reales. Los puntos del carrusel medían
8×8 píxeles, la tercera parte del mínimo para acertarles con el dedo; ahora el
punto se ve igual y el botón mide 24×24. Y el hueco reservado bajo el contenido
para la barra de reproducción estaba escrito a mano en 88 píxeles cuando la
barra mide 97 en escritorio y 145 apilada en un móvil: se comía el pie. Ahora la
barra se mide sola y publica su alto.
Todo ello fijado en `responsive.e2e.ts`, que recorre diez páginas a cuatro
anchos.
Sobre rendimiento, nada que hacer: parecía que las páginas tardaban 215 ms, pero
eran de la resolución de `localhost` por IPv6 en la medición. Por IPv4 se
renderizan en 5-9 ms, así que memorizar el catálogo habría sido optimizar un
problema inexistente.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 month ago
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('la tabla se desplaza dentro de su marco, no arrastra la página', async ({ page }) => {
|
|
|
|
|
await page.setViewportSize({ width: 375, height: 800 });
|
|
|
|
|
await page.goto('/musica');
|
|
|
|
|
await page.getByRole('button', { name: 'Tabla' }).click();
|
|
|
|
|
|
|
|
|
|
const dentro = await page.evaluate(() => {
|
|
|
|
|
const marco = document.querySelector('.marco')!;
|
|
|
|
|
return marco.scrollWidth > marco.clientWidth;
|
|
|
|
|
});
|
|
|
|
|
expect(dentro).toBe(true);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test.describe('Respuesta al puntero', () => {
|
|
|
|
|
// Un control que no acusa el puntero parece deshabilitado. Se comprueba en
|
|
|
|
|
// pruebas porque no se ve en una captura en reposo, que es justo como se
|
|
|
|
|
// coló el botón dorado quedándose transparente al pasar por encima.
|
|
|
|
|
const CASOS: [string, 'link' | 'button', string][] = [
|
|
|
|
|
['/', 'link', 'Ver el disco'],
|
|
|
|
|
['/musica', 'button', 'Tabla'],
|
|
|
|
|
['/blog', 'link', 'Seguirlo por RSS'],
|
|
|
|
|
['/canciones/como-si-nada', 'link', 'Desamor'],
|
|
|
|
|
['/albumes/como-si-nada', 'button', 'Añadir']
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
for (const [ruta, rol, nombre] of CASOS) {
|
|
|
|
|
test(`«${nombre}» acusa el puntero`, async ({ page }) => {
|
|
|
|
|
await page.goto(ruta);
|
|
|
|
|
const el = page.getByRole(rol, { name: nombre }).first();
|
|
|
|
|
|
|
|
|
|
const estilo = () =>
|
|
|
|
|
el.evaluate((e) => {
|
|
|
|
|
const s = getComputedStyle(e);
|
|
|
|
|
return [s.backgroundColor, s.color, s.borderColor, s.textDecorationLine].join('|');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const antes = await estilo();
|
|
|
|
|
await el.hover();
|
|
|
|
|
await expect.poll(estilo).not.toBe(antes);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
test('el botón dorado sigue siendo dorado al pasar por encima', async ({ page }) => {
|
|
|
|
|
// Pasó: una regla anterior usaba el atajo `background`, que borra
|
|
|
|
|
// `background-image`, y el botón se quedaba en un rectángulo oscuro con el
|
|
|
|
|
// borde de oro.
|
|
|
|
|
await page.goto('/');
|
|
|
|
|
const boton = page.getByRole('button', { name: 'Poner el disco' });
|
|
|
|
|
|
|
|
|
|
await boton.hover();
|
|
|
|
|
const fondo = await boton.evaluate((e) => getComputedStyle(e).backgroundImage);
|
|
|
|
|
expect(fondo).toContain('linear-gradient');
|
|
|
|
|
});
|
|
|
|
|
});
|
Arregla el contraste del botón dorado y unifica la letra de los botones
El texto del botón dorado se aclaraba al pasar el puntero y quedaba en 1,67 de
contraste sobre el oro. La causa no era el color del botón: `.btn:hover` pone
`color: inherit` —para que la regla general de los enlaces no tiña de dorado los
secundarios— y le gana por especificidad. Ahora el dorado repite su color ahí.
De paso, el dorado se parte en dos tokens porque hacía dos trabajos
incompatibles: de relleno tiene que ser claro para que encima se lea el marino
—7,6 de contraste en el tema claro— y escrito tiene que ser hondo para leerse
sobre el fondo —5,3—. Con un solo valor, el tema claro se quedaba en 4,4, por
debajo del mínimo. Y encima del oro va marino, no blanco, en los dos temas.
Los botones tenían tres tamaños distintos: el dorado a 16px con relleno 8/16 y
el de al lado a 14px con 12/24. Dos botones juntos con distinta letra se ven
como un descuido. Ahora hay una sola escala, y los 15px son los del menú.
El botón de ver el carrito solo aparece cuando hay algo dentro, con la cuenta:
uno que lleva a una página vacía gasta el sitio de una llamada a la acción para
decir que no hay nada.
Tres pruebas nuevas lo fijan, incluida la del contraste con el puntero encima,
que es donde estaba el fallo y donde una captura en reposo no lo enseña.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 month ago
|
|
|
|
|
|
|
|
test.describe('Contraste del dorado', () => {
|
|
|
|
|
// El dorado hace dos trabajos y no puede ser el mismo valor en los dos: de
|
|
|
|
|
// relleno tiene que ser claro para que encima se lea el marino, y escrito
|
|
|
|
|
// tiene que ser hondo para leerse sobre el fondo. Esto lo fija.
|
|
|
|
|
const MINIMO = 4.5;
|
|
|
|
|
|
|
|
|
|
async function contraste(page: import('@playwright/test').Page, selector: string) {
|
|
|
|
|
return page
|
|
|
|
|
.locator(selector)
|
|
|
|
|
.first()
|
|
|
|
|
.evaluate((e) => {
|
|
|
|
|
const aRgb = (c: string) => {
|
|
|
|
|
const x = document.createElement('canvas').getContext('2d')!;
|
|
|
|
|
x.fillStyle = c;
|
|
|
|
|
x.fillRect(0, 0, 1, 1);
|
|
|
|
|
const d = x.getImageData(0, 0, 1, 1).data;
|
|
|
|
|
return [d[0], d[1], d[2]];
|
|
|
|
|
};
|
|
|
|
|
const lum = ([r, g, b]: number[]) => {
|
|
|
|
|
const f = (v: number) => {
|
|
|
|
|
v /= 255;
|
|
|
|
|
return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4;
|
|
|
|
|
};
|
|
|
|
|
return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b);
|
|
|
|
|
};
|
|
|
|
|
const s = getComputedStyle(e);
|
|
|
|
|
const [a, b] = [lum(aRgb(s.color)), lum(aRgb(s.backgroundColor))].sort((m, n) => n - m);
|
|
|
|
|
return (a + 0.05) / (b + 0.05);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/*
|
|
|
|
|
* Se pone `data-tema` a mano en vez de emular `prefers-color-scheme`: el
|
|
|
|
|
* sitio ya no sigue al sistema —el marino es el de la marca y se aplica
|
|
|
|
|
* siempre—, así que emular el esquema claro dejaba esta prueba comprobando
|
|
|
|
|
* dos veces lo mismo sin que nadie lo notara.
|
|
|
|
|
*/
|
|
|
|
|
for (const modo of ['oscuro', 'claro'] as const) {
|
|
|
|
|
test(`el botón dorado se lee en tema ${modo}`, async ({ page }) => {
|
Arregla el contraste del botón dorado y unifica la letra de los botones
El texto del botón dorado se aclaraba al pasar el puntero y quedaba en 1,67 de
contraste sobre el oro. La causa no era el color del botón: `.btn:hover` pone
`color: inherit` —para que la regla general de los enlaces no tiña de dorado los
secundarios— y le gana por especificidad. Ahora el dorado repite su color ahí.
De paso, el dorado se parte en dos tokens porque hacía dos trabajos
incompatibles: de relleno tiene que ser claro para que encima se lea el marino
—7,6 de contraste en el tema claro— y escrito tiene que ser hondo para leerse
sobre el fondo —5,3—. Con un solo valor, el tema claro se quedaba en 4,4, por
debajo del mínimo. Y encima del oro va marino, no blanco, en los dos temas.
Los botones tenían tres tamaños distintos: el dorado a 16px con relleno 8/16 y
el de al lado a 14px con 12/24. Dos botones juntos con distinta letra se ven
como un descuido. Ahora hay una sola escala, y los 15px son los del menú.
El botón de ver el carrito solo aparece cuando hay algo dentro, con la cuenta:
uno que lleva a una página vacía gasta el sitio de una llamada a la acción para
decir que no hay nada.
Tres pruebas nuevas lo fijan, incluida la del contraste con el puntero encima,
que es donde estaba el fallo y donde una captura en reposo no lo enseña.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 month ago
|
|
|
await page.goto('/');
|
|
|
|
|
await page.evaluate((m) => {
|
|
|
|
|
document.documentElement.dataset.tema = m;
|
|
|
|
|
}, modo);
|
Arregla el contraste del botón dorado y unifica la letra de los botones
El texto del botón dorado se aclaraba al pasar el puntero y quedaba en 1,67 de
contraste sobre el oro. La causa no era el color del botón: `.btn:hover` pone
`color: inherit` —para que la regla general de los enlaces no tiña de dorado los
secundarios— y le gana por especificidad. Ahora el dorado repite su color ahí.
De paso, el dorado se parte en dos tokens porque hacía dos trabajos
incompatibles: de relleno tiene que ser claro para que encima se lea el marino
—7,6 de contraste en el tema claro— y escrito tiene que ser hondo para leerse
sobre el fondo —5,3—. Con un solo valor, el tema claro se quedaba en 4,4, por
debajo del mínimo. Y encima del oro va marino, no blanco, en los dos temas.
Los botones tenían tres tamaños distintos: el dorado a 16px con relleno 8/16 y
el de al lado a 14px con 12/24. Dos botones juntos con distinta letra se ven
como un descuido. Ahora hay una sola escala, y los 15px son los del menú.
El botón de ver el carrito solo aparece cuando hay algo dentro, con la cuenta:
uno que lleva a una página vacía gasta el sitio de una llamada a la acción para
decir que no hay nada.
Tres pruebas nuevas lo fijan, incluida la del contraste con el puntero encima,
que es donde estaba el fallo y donde una captura en reposo no lo enseña.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 month ago
|
|
|
|
|
|
|
|
expect(await contraste(page, '.btn--primary')).toBeGreaterThan(MINIMO);
|
|
|
|
|
|
|
|
|
|
// Y también con el puntero encima, que es cuando la banda de luz se
|
|
|
|
|
// mueve por debajo del texto.
|
|
|
|
|
await page.locator('.btn--primary').first().hover();
|
|
|
|
|
expect(await contraste(page, '.btn--primary')).toBeGreaterThan(MINIMO);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
test('sin elegir nada, el sitio sale en marino aunque el sistema pida claro', async ({
|
|
|
|
|
page
|
|
|
|
|
}) => {
|
|
|
|
|
// Es una decisión, no un descuido: el color forma parte de la marca. Si
|
|
|
|
|
// algún día vuelve a seguir al sistema, esta prueba lo dice.
|
|
|
|
|
await page.emulateMedia({ colorScheme: 'light' });
|
|
|
|
|
await page.goto('/');
|
|
|
|
|
|
|
|
|
|
const { fondo, claridad } = await page.evaluate(() => {
|
|
|
|
|
const color = getComputedStyle(document.body).backgroundColor;
|
|
|
|
|
/*
|
|
|
|
|
* El color viene en `oklch()`, que no se puede desmontar a mano. Se
|
|
|
|
|
* pinta y se lee el píxel: es la única conversión que no se inventa
|
|
|
|
|
* nada, y en su día unas cuentas sobre la cadena dieron números que no
|
|
|
|
|
* significaban nada.
|
|
|
|
|
*/
|
|
|
|
|
const lienzo = document.createElement('canvas');
|
|
|
|
|
lienzo.width = lienzo.height = 1;
|
|
|
|
|
const d = lienzo.getContext('2d')!;
|
|
|
|
|
d.fillStyle = color;
|
|
|
|
|
d.fillRect(0, 0, 1, 1);
|
|
|
|
|
const [r, g, b] = d.getImageData(0, 0, 1, 1).data;
|
|
|
|
|
return { fondo: color, claridad: (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255 };
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(claridad, `el fondo era ${fondo}`).toBeLessThan(0.2);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('el botón de la cabecera sigue llevando al claro', async ({ page }) => {
|
|
|
|
|
// El marino manda por defecto, pero el claro no desaparece: está a un clic
|
|
|
|
|
// y la elección se recuerda.
|
|
|
|
|
await page.goto('/');
|
|
|
|
|
|
|
|
|
|
await page.getByRole('button', { name: /tema|claro|oscuro/i }).click();
|
|
|
|
|
await expect(page.locator('html')).toHaveAttribute('data-tema', 'claro');
|
|
|
|
|
});
|
|
|
|
|
|
El nombre de la cabecera, en oro de verdad y más grande
Las mismas cinco paradas y el mismo ángulo que el botón dorado, recortadas
sobre las letras, y la banda de luz recorre el nombre al pasar el puntero igual
que recorre el botón. De 24 a 34 píxeles, que es el siguiente peldaño de la
escala y no un número a ojo.
El recorte va dentro de un `@supports` y el color plano se queda fuera: sin el
recorte, `color: transparent` dejaría la marca invisible, y eso es peor que
dejarla dorada y lisa. Es el mismo cuidado que ya tenía el botón, que guarda un
color de reserva por si el degradado no llega a pintar.
Y hace falta un segundo degradado, `--oro-escrito`. Sobre marino es
literalmente el mismo que el del botón —la peor de sus paradas da 5,4 de
contraste—, pero sobre papel no vale: medido contra el fondo claro, la parada
de brillo da **1,26**, la de superficie 2,05 y la de sombra 3,79, las tres por
debajo del mínimo de 4,5. Con el degradado del botón, el centro de las letras
desaparecía. En claro la banda va al revés: el punto más claro es el propio
`--c-accent` —5,29— y los extremos se oscurecen. Sigue pareciendo metal porque
lo que hace el efecto es la banda que cruza, no que llegue a brillar.
Es la regla que el proyecto ya tenía escrita y que yo no había aplicado: el
dorado hace dos trabajos, y de relleno tiene que ser claro mientras que escrito
tiene que ser hondo.
La prueba comprueba **cada parada** del degradado contra el fondo, en los dos
temas. A ojo, en la captura, el fallo se veía como «algo más pálido»; en
números, como ilegible.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
4 weeks ago
|
|
|
/*
|
|
|
|
|
* El logo lleva el oro recortado sobre las letras, y ahí el degradado no
|
|
|
|
|
* puede usar las mismas paradas que un botón: en un botón el oro es el
|
|
|
|
|
* fondo y encima va marino, y en el logo el oro *es* el texto.
|
|
|
|
|
*
|
|
|
|
|
* Se comprueba parada por parada porque el fallo fue exactamente ese: con el
|
|
|
|
|
* degradado del botón, la parada de brillo daba 1,26 sobre el fondo claro y
|
|
|
|
|
* el centro de las letras desaparecía. A ojo, en la captura, se veía «algo
|
|
|
|
|
* más pálido»; en números, ilegible.
|
|
|
|
|
*/
|
|
|
|
|
for (const modo of ['oscuro', 'claro'] as const) {
|
|
|
|
|
test(`cada parada del oro del logo se lee en tema ${modo}`, async ({ page }) => {
|
|
|
|
|
await page.goto('/');
|
|
|
|
|
await page.evaluate((m) => {
|
|
|
|
|
document.documentElement.dataset.tema = m;
|
|
|
|
|
}, modo);
|
|
|
|
|
|
|
|
|
|
const peor = await page.evaluate(() => {
|
|
|
|
|
const marca = document.querySelector('header .marca')!;
|
|
|
|
|
const fondo = getComputedStyle(document.body).backgroundColor;
|
|
|
|
|
|
|
|
|
|
/*
|
|
|
|
|
* Las paradas se sacan recorriendo la cadena y contando paréntesis:
|
|
|
|
|
* `oklch(…)` y `color-mix(in oklab, …)` llevan comas dentro, así que
|
|
|
|
|
* partir por comas devuelve trozos que no son colores.
|
|
|
|
|
*/
|
|
|
|
|
const colores: string[] = [];
|
|
|
|
|
const gradiente = getComputedStyle(marca).backgroundImage;
|
|
|
|
|
|
|
|
|
|
/*
|
|
|
|
|
* Los nombres se buscan con un límite de palabra porque el navegador
|
|
|
|
|
* no devuelve lo que está escrito: en el tema claro resuelve los
|
|
|
|
|
* `color-mix()` a `oklab()`, y buscando «lab» sin límite se contaría
|
|
|
|
|
* dos veces cada uno.
|
|
|
|
|
*/
|
|
|
|
|
const empiezan = /\b(oklch|oklab|color-mix|rgba?|hsla?|lab|lch|color)\(/g;
|
|
|
|
|
for (let m = empiezan.exec(gradiente); m; m = empiezan.exec(gradiente)) {
|
|
|
|
|
let nivel = 0;
|
|
|
|
|
let i = m.index + m[1].length;
|
|
|
|
|
for (; i < gradiente.length; i++) {
|
|
|
|
|
if (gradiente[i] === '(') nivel++;
|
|
|
|
|
else if (gradiente[i] === ')' && --nivel === 0) break;
|
|
|
|
|
}
|
|
|
|
|
colores.push(gradiente.slice(m.index, i + 1));
|
|
|
|
|
empiezan.lastIndex = i + 1;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const pincel = document.createElement('canvas').getContext('2d')!;
|
|
|
|
|
const aRgb = (c: string) => {
|
|
|
|
|
pincel.fillStyle = c;
|
|
|
|
|
pincel.fillRect(0, 0, 1, 1);
|
|
|
|
|
const d = pincel.getImageData(0, 0, 1, 1).data;
|
|
|
|
|
return [d[0], d[1], d[2]];
|
|
|
|
|
};
|
|
|
|
|
const lum = (rgb: number[]) => {
|
|
|
|
|
const f = (v: number) => {
|
|
|
|
|
v /= 255;
|
|
|
|
|
return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4;
|
|
|
|
|
};
|
|
|
|
|
return 0.2126 * f(rgb[0]) + 0.7152 * f(rgb[1]) + 0.0722 * f(rgb[2]);
|
|
|
|
|
};
|
|
|
|
|
const contraste = (c: string) => {
|
|
|
|
|
const [a, b] = [lum(aRgb(c)), lum(aRgb(fondo))].sort((m, n) => n - m);
|
|
|
|
|
return (a + 0.05) / (b + 0.05);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
cuantas: colores.length,
|
|
|
|
|
minimo: Math.min(...colores.map(contraste))
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Cinco paradas: si el degradado dejara de pintarse, esto lo dice.
|
|
|
|
|
expect(peor.cuantas).toBeGreaterThanOrEqual(3);
|
|
|
|
|
expect(peor.minimo).toBeGreaterThan(MINIMO);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
Arregla el contraste del botón dorado y unifica la letra de los botones
El texto del botón dorado se aclaraba al pasar el puntero y quedaba en 1,67 de
contraste sobre el oro. La causa no era el color del botón: `.btn:hover` pone
`color: inherit` —para que la regla general de los enlaces no tiña de dorado los
secundarios— y le gana por especificidad. Ahora el dorado repite su color ahí.
De paso, el dorado se parte en dos tokens porque hacía dos trabajos
incompatibles: de relleno tiene que ser claro para que encima se lea el marino
—7,6 de contraste en el tema claro— y escrito tiene que ser hondo para leerse
sobre el fondo —5,3—. Con un solo valor, el tema claro se quedaba en 4,4, por
debajo del mínimo. Y encima del oro va marino, no blanco, en los dos temas.
Los botones tenían tres tamaños distintos: el dorado a 16px con relleno 8/16 y
el de al lado a 14px con 12/24. Dos botones juntos con distinta letra se ven
como un descuido. Ahora hay una sola escala, y los 15px son los del menú.
El botón de ver el carrito solo aparece cuando hay algo dentro, con la cuenta:
uno que lleva a una página vacía gasta el sitio de una llamada a la acción para
decir que no hay nada.
Tres pruebas nuevas lo fijan, incluida la del contraste con el puntero encima,
que es donde estaba el fallo y donde una captura en reposo no lo enseña.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 month ago
|
|
|
test('todos los botones llevan la misma letra', async ({ page }) => {
|
|
|
|
|
// Dos botones juntos con distinto cuerpo se ven como un descuido, y así
|
|
|
|
|
// estaban: el dorado a 16px y el de al lado a 14.
|
|
|
|
|
await page.goto('/');
|
|
|
|
|
|
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>
1 month ago
|
|
|
// 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.
|
Arregla el contraste del botón dorado y unifica la letra de los botones
El texto del botón dorado se aclaraba al pasar el puntero y quedaba en 1,67 de
contraste sobre el oro. La causa no era el color del botón: `.btn:hover` pone
`color: inherit` —para que la regla general de los enlaces no tiña de dorado los
secundarios— y le gana por especificidad. Ahora el dorado repite su color ahí.
De paso, el dorado se parte en dos tokens porque hacía dos trabajos
incompatibles: de relleno tiene que ser claro para que encima se lea el marino
—7,6 de contraste en el tema claro— y escrito tiene que ser hondo para leerse
sobre el fondo —5,3—. Con un solo valor, el tema claro se quedaba en 4,4, por
debajo del mínimo. Y encima del oro va marino, no blanco, en los dos temas.
Los botones tenían tres tamaños distintos: el dorado a 16px con relleno 8/16 y
el de al lado a 14px con 12/24. Dos botones juntos con distinta letra se ven
como un descuido. Ahora hay una sola escala, y los 15px son los del menú.
El botón de ver el carrito solo aparece cuando hay algo dentro, con la cuenta:
uno que lleva a una página vacía gasta el sitio de una llamada a la acción para
decir que no hay nada.
Tres pruebas nuevas lo fijan, incluida la del contraste con el puntero encima,
que es donde estaba el fallo y donde una captura en reposo no lo enseña.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 month ago
|
|
|
const tamanos = await page
|
|
|
|
|
.locator('a.btn, button.solido')
|
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>
1 month ago
|
|
|
.evaluateAll((es) =>
|
|
|
|
|
es
|
|
|
|
|
.filter((e) => !e.closest('.tarjeta'))
|
|
|
|
|
.map((e) => `${getComputedStyle(e).fontSize} ${getComputedStyle(e).padding}`)
|
|
|
|
|
);
|
|
|
|
|
expect([...new Set(tamanos)]).toHaveLength(1);
|
Arregla el contraste del botón dorado y unifica la letra de los botones
El texto del botón dorado se aclaraba al pasar el puntero y quedaba en 1,67 de
contraste sobre el oro. La causa no era el color del botón: `.btn:hover` pone
`color: inherit` —para que la regla general de los enlaces no tiña de dorado los
secundarios— y le gana por especificidad. Ahora el dorado repite su color ahí.
De paso, el dorado se parte en dos tokens porque hacía dos trabajos
incompatibles: de relleno tiene que ser claro para que encima se lea el marino
—7,6 de contraste en el tema claro— y escrito tiene que ser hondo para leerse
sobre el fondo —5,3—. Con un solo valor, el tema claro se quedaba en 4,4, por
debajo del mínimo. Y encima del oro va marino, no blanco, en los dos temas.
Los botones tenían tres tamaños distintos: el dorado a 16px con relleno 8/16 y
el de al lado a 14px con 12/24. Dos botones juntos con distinta letra se ven
como un descuido. Ahora hay una sola escala, y los 15px son los del menú.
El botón de ver el carrito solo aparece cuando hay algo dentro, con la cuenta:
uno que lleva a una página vacía gasta el sitio de una llamada a la acción para
decir que no hay nada.
Tres pruebas nuevas lo fijan, incluida la del contraste con el puntero encima,
que es donde estaba el fallo y donde una captura en reposo no lo enseña.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 month ago
|
|
|
});
|
|
|
|
|
});
|