El marino es el modo por defecto, no solo si el sistema lo pide

El color forma parte de lo que cuenta el sitio, y seguir a
`prefers-color-scheme` hacía que media parte de las visitas viera una
versión que no es la de la marca —un portátil configurado en claro no
tiene nada que decir sobre el aspecto de un catálogo de canciones—. Al
revés que en una herramienta de trabajo, aquí nadie va a pasar ocho horas
seguidas leyendo.

El claro sigue entero y a un clic, y la elección se recuerda. Lo que se
deja de hacer es decidirlo por nadie.

La prueba de contraste emulaba el esquema del sistema, así que con este
cambio habría pasado a comprobar dos veces lo mismo sin que nadie lo
notara: ahora pone `data-tema` a mano. Y la medida de claridad convierte
el `oklch()` pintándolo en un lienzo, porque desmontar la cadena a mano
daba NaN.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
master
dev 4 weeks ago
parent d4302527dd
commit 3f1a651006

@ -183,10 +183,18 @@ test.describe('Contraste del dorado', () => {
}); });
} }
for (const esquema of ['dark', 'light'] as const) { /*
test(`el botón dorado se lee en tema ${esquema}`, async ({ page }) => { * Se pone `data-tema` a mano en vez de emular `prefers-color-scheme`: el
await page.emulateMedia({ colorScheme: esquema }); * 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 }) => {
await page.goto('/'); await page.goto('/');
await page.evaluate((m) => {
document.documentElement.dataset.tema = m;
}, modo);
expect(await contraste(page, '.btn--primary')).toBeGreaterThan(MINIMO); expect(await contraste(page, '.btn--primary')).toBeGreaterThan(MINIMO);
@ -197,6 +205,43 @@ test.describe('Contraste del dorado', () => {
}); });
} }
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');
});
test('todos los botones llevan la misma letra', async ({ page }) => { test('todos los botones llevan la misma letra', async ({ page }) => {
// Dos botones juntos con distinto cuerpo se ven como un descuido, y así // 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. // estaban: el dorado a 16px y el de al lado a 14.

@ -87,15 +87,15 @@ test.describe('Taller de letras', () => {
}); });
test.describe('Tema claro y oscuro', () => { test.describe('Tema claro y oscuro', () => {
test('sigue al sistema cuando no se ha elegido nada', async ({ browser }) => { test('sin elegir nada no hay atributo, y manda el marino', async ({ browser }) => {
// Aunque el sistema pida el claro. El color es de la marca y no lo decide
// una preferencia que se configuró pensando en otra cosa.
const claro = await browser.newContext({ colorScheme: 'light' }); const claro = await browser.newContext({ colorScheme: 'light' });
const pagina = await claro.newPage(); const pagina = await claro.newPage();
await pagina.goto('/taller'); await pagina.goto('/taller');
// Sin elección manual no hay atributo: manda `prefers-color-scheme`.
await expect(pagina.locator('html')).not.toHaveAttribute('data-tema', /.+/); await expect(pagina.locator('html')).not.toHaveAttribute('data-tema', /.+/);
const fondo = await pagina.evaluate(() => getComputedStyle(document.body).backgroundColor); await expect(pagina.getByRole('button', { name: 'Cambiar al tema claro' })).toBeVisible();
expect(fondo).not.toBe('rgb(0, 0, 0)');
await claro.close(); await claro.close();
}); });
@ -105,12 +105,16 @@ test.describe('Tema claro y oscuro', () => {
const pagina = await claro.newPage(); const pagina = await claro.newPage();
await pagina.goto('/taller'); await pagina.goto('/taller');
await pagina.getByRole('button', { name: 'Cambiar al tema oscuro' }).click(); await pagina.getByRole('button', { name: 'Cambiar al tema claro' }).click();
await expect(pagina.locator('html')).toHaveAttribute('data-tema', 'oscuro'); await expect(pagina.locator('html')).toHaveAttribute('data-tema', 'claro');
// Sobrevive a la recarga y, sobre todo, se aplica antes de pintar: lo hace // Sobrevive a la recarga y, sobre todo, se aplica antes de pintar: lo hace
// un script en línea, no la hidratación. // un script en línea, no la hidratación.
await pagina.reload(); await pagina.reload();
await expect(pagina.locator('html')).toHaveAttribute('data-tema', 'claro');
// Y se puede volver.
await pagina.getByRole('button', { name: 'Cambiar al tema oscuro' }).click();
await expect(pagina.locator('html')).toHaveAttribute('data-tema', 'oscuro'); await expect(pagina.locator('html')).toHaveAttribute('data-tema', 'oscuro');
await claro.close(); await claro.close();

@ -162,51 +162,27 @@
} }
/* /*
* La inversión clara. * La inversión clara, y SOLO cuando se pide.
*
* El marino es el modo por defecto se pida lo que se pida al sistema, y no es
* un descuido: es la marca. Un sitio cuyo color forma parte de lo que cuenta no
* puede salir en blanco porque el portátil de quien entra venga configurado
* así; y al revés que en una herramienta de trabajo, aquí nadie va a pasar ocho
* horas seguidas leyendo. Antes esto seguía a `prefers-color-scheme` y llevaba
* a que media parte de las visitas viera una versión que no es la del sitio.
*
* El claro sigue entero y a un clic, en el botón de la cabecera, y la elección
* se recuerda. Lo que se deja de hacer es decidirlo por nadie a partir de una
* preferencia que se configuró pensando en otra cosa.
* *
* Es una inversión de verdad, no un fondo blanco con lo mismo encima: el texto * Es una inversión de verdad, no un fondo blanco con lo mismo encima: el texto
* pasa a ser el propio marino y los fondos, blancos apenas teñidos de él. Así * pasa a ser el propio marino y los fondos, blancos apenas teñidos de él. Así
* los dos modos son la misma marca vista de dos maneras. * los dos modos son la misma marca vista de dos maneras.
* *
* El dorado tiene que bajar de claridad para el modo claro. El de 0,82 se lee * El dorado tiene que bajar de claridad aquí. El de 0,82 se lee bien sobre
* bien sobre marino y desaparece sobre blanco, así que aquí es un dorado más * marino y desaparece sobre blanco, así que este es un dorado más hondo, casi
* hondo, casi bronce, que mantiene el contraste como texto. * bronce, que mantiene el contraste como texto.
*
* Tres estados: sin elegir nada se sigue al sistema; una elección manual escribe
* `data-tema` en la raíz y manda sobre él.
*/ */
@media (prefers-color-scheme: light) {
:root:not([data-tema='oscuro']) {
color-scheme: light;
--c-bg: oklch(0.98 0.008 264);
--c-bg-raised: oklch(0.955 0.011 264);
--c-bg-sunken: oklch(0.915 0.016 264);
--c-border: oklch(0.86 0.02 264);
--c-text: oklch(0.24 0.055 264);
--c-text-muted: oklch(0.48 0.038 264);
/*
* Escrito, el dorado tiene que ser hondo para leerse sobre el blanco:
* 5,3 de contraste. De relleno tiene que ser claro para que encima se lea
* el marino: 7,6. Son dos valores porque son dos trabajos.
*/
--c-accent: oklch(0.52 0.125 74);
--c-accent-superficie: oklch(0.76 0.13 82);
/*
* Marino y no blanco encima del dorado. El blanco daba 4,4 de contraste
* —por debajo del mínimo— y el degradado tiene una banda de luz que lo
* empeora justo donde pasa.
*/
--c-accent-text: oklch(0.24 0.055 264);
--c-accent-brillo: oklch(0.9 0.1 92);
--c-accent-sombra: oklch(0.6 0.12 74);
--c-danger: oklch(0.5 0.17 25);
--c-focus: oklch(0.5 0.13 264);
}
}
:root[data-tema='claro'] { :root[data-tema='claro'] {
color-scheme: light; color-scheme: light;
--c-bg: oklch(0.98 0.008 264); --c-bg: oklch(0.98 0.008 264);

@ -1,9 +1,12 @@
/** /**
* El tema visible: oscuro (el de marca) o su inversión clara. * El tema visible: oscuro (el de marca) o su inversión clara.
* *
* Tres estados en la práctica: mientras no se elija nada se sigue al sistema * Dos estados: el marino de la marca, que es el de partida, y su inversión
* —eso lo resuelve el CSS con `prefers-color-scheme`, sin JavaScript—, y una * clara. Una elección manual escribe `data-tema` en la raíz y se recuerda.
* elección manual escribe `data-tema` en la raíz y manda sobre él. *
* Ya no se sigue a `prefers-color-scheme`: el color es parte de lo que cuenta
* el sitio, y no tiene por qué salir en blanco porque el sistema de quien entra
* venga configurado así.
* *
* Quien aplica el tema al cargar NO es este módulo, sino un script en línea de * Quien aplica el tema al cargar NO es este módulo, sino un script en línea de
* `app.html`: tiene que correr antes de pintar o se ve un destello del modo * `app.html`: tiene que correr antes de pintar o se ve un destello del modo
@ -19,8 +22,15 @@ const COLOR_DE_BARRA: Record<Tema, string> = {
oscuro: '#0e1424' oscuro: '#0e1424'
}; };
function delSistema(): Tema { /**
return matchMedia('(prefers-color-scheme: light)').matches ? 'claro' : 'oscuro'; * El de partida cuando nadie ha elegido.
*
* Antes miraba `prefers-color-scheme`. Ya no: el marino es el modo de la marca
* y se aplica siempre salvo que alguien pida el claro. Aquí solo se refleja lo
* que hace el CSS, para que el botón enseñe el estado de verdad.
*/
function porDefecto(): Tema {
return 'oscuro';
} }
class ControlDeTema { class ControlDeTema {
@ -32,11 +42,11 @@ class ControlDeTema {
/** /**
* Lee el tema que ya está puesto. Se llama al montar, porque hasta que no * Lee el tema que ya está puesto. Se llama al montar, porque hasta que no
* hay navegador no se puede saber ni lo guardado ni lo que pide el sistema. * hay navegador no se puede saber qué hay guardado.
*/ */
sincronizar(): void { sincronizar(): void {
const elegido = document.documentElement.dataset.tema; const elegido = document.documentElement.dataset.tema;
this.actual = elegido === 'claro' || elegido === 'oscuro' ? elegido : delSistema(); this.actual = elegido === 'claro' || elegido === 'oscuro' ? elegido : porDefecto();
this.resuelto = true; this.resuelto = true;
} }

Loading…
Cancel
Save

Powered by TurnKey Linux.