Fix Settings black-on-black contrast (Gitea #75)
Scope .settings styles to the panel: light #eee text on the dark #121212 surface for labels, and white inputs/selects/options with dark text. Both pairs exceed WCAG 4.5:1. Add settings-contrast e2e as the regression guard for the Settings form plus the sibling dev panels.
This commit is contained in:
@@ -0,0 +1,133 @@
|
||||
import { test, expect, type Page } from '@playwright/test';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import fs from 'node:fs';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const ARTIFACTS = path.resolve(__dirname, 'artifacts');
|
||||
|
||||
const shot = async (page: Page, name: string) => {
|
||||
fs.mkdirSync(ARTIFACTS, { recursive: true });
|
||||
await page.screenshot({
|
||||
path: path.join(ARTIFACTS, name),
|
||||
fullPage: true,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* WCAG contrast helpers (in-page evaluate). Binary observable: ratio < 4.5:1 fails.
|
||||
* Matches visual-qa color check patterns for luminance/contrast.
|
||||
*/
|
||||
async function assertReadableContrast(
|
||||
page: Page,
|
||||
selectors: string[],
|
||||
label: string
|
||||
): Promise<void> {
|
||||
const minRatio = await page.evaluate((sels: string[]) => {
|
||||
function luminance(r: number, g: number, b: number): number {
|
||||
const a = [r, g, b].map((v) => {
|
||||
v /= 255;
|
||||
return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
|
||||
});
|
||||
return a[0] * 0.2126 + a[1] * 0.7152 + a[2] * 0.0722;
|
||||
}
|
||||
function contrastRatio(c1: number[], c2: number[]): number {
|
||||
const l1 = luminance(c1[0], c1[1], c1[2]);
|
||||
const l2 = luminance(c2[0], c2[1], c2[2]);
|
||||
const hi = Math.max(l1, l2);
|
||||
const lo = Math.min(l1, l2);
|
||||
return (hi + 0.05) / (lo + 0.05);
|
||||
}
|
||||
function parse(c: string): [number, number, number] {
|
||||
const m = c.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/i);
|
||||
return m ? [parseInt(m[1], 10), parseInt(m[2], 10), parseInt(m[3], 10)] : [255, 255, 255];
|
||||
}
|
||||
|
||||
let minR = 21;
|
||||
for (const sel of sels) {
|
||||
const els = document.querySelectorAll(sel);
|
||||
for (const el of Array.from(els)) {
|
||||
if (!(el instanceof HTMLElement)) continue;
|
||||
const fg = parse(window.getComputedStyle(el).color);
|
||||
// collect ALL non-transparent bgs in ancestor chain; pick darkest (min lum) as the surface
|
||||
const bgs: [number, number, number][] = [];
|
||||
let p: HTMLElement | null = el;
|
||||
while (p) {
|
||||
const pst = window.getComputedStyle(p);
|
||||
const bstr = pst.backgroundColor;
|
||||
if (!bstr.includes('0)')) {
|
||||
const b = parse(bstr);
|
||||
// avoid pure black default if transparent resolved
|
||||
if (!(b[0] === 0 && b[1] === 0 && b[2] === 0)) {
|
||||
bgs.push(b);
|
||||
}
|
||||
}
|
||||
p = p.parentElement;
|
||||
}
|
||||
// also include body/html
|
||||
const bodyB = parse(window.getComputedStyle(document.body).backgroundColor);
|
||||
if (!(bodyB[0] === 0 && bodyB[1] === 0 && bodyB[2] === 0)) bgs.push(bodyB);
|
||||
const htmlB = parse(window.getComputedStyle(document.documentElement).backgroundColor);
|
||||
if (!(htmlB[0] === 0 && htmlB[1] === 0 && htmlB[2] === 0)) bgs.push(htmlB);
|
||||
|
||||
let surface = [255, 255, 255] as [number, number, number];
|
||||
let minLum = 1;
|
||||
for (const b of bgs) {
|
||||
const l = luminance(b[0], b[1], b[2]);
|
||||
if (l < minLum) {
|
||||
minLum = l;
|
||||
surface = b;
|
||||
}
|
||||
}
|
||||
const r = contrastRatio(fg, surface);
|
||||
if (r < minR) minR = r;
|
||||
}
|
||||
}
|
||||
return minR;
|
||||
}, selectors);
|
||||
|
||||
expect(minRatio, `contrast < 4.5:1 (${label})`).toBeGreaterThanOrEqual(4.5);
|
||||
}
|
||||
|
||||
test.describe('Settings contrast (Gitea bug #75 black-on-black)', () => {
|
||||
test('Happy: Settings form labels/inputs/selects readable (contrast ≥4.5:1)', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.getByRole('button', { name: 'Settings (dev)' }).click();
|
||||
await shot(page, 'settings-happy.png');
|
||||
// labels, inputs, selects on the form
|
||||
await assertReadableContrast(page, ['label', 'input', 'select'], 'happy-settings-form');
|
||||
});
|
||||
|
||||
test('Edge: <select> dropdown options visible and readable on dark surface', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.getByRole('button', { name: 'Settings (dev)' }).click();
|
||||
await shot(page, 'settings-edge.png');
|
||||
// options may render on dark surface when select is dark-themed
|
||||
await assertReadableContrast(page, ['select', 'option', 'label'], 'edge-select-dropdown');
|
||||
});
|
||||
|
||||
test('Regression: SlabList.svelte / Review.svelte / Publish.svelte / GenerateText.svelte remain unchanged (still pass their existing contrast)', async ({ page }) => {
|
||||
const regressionScreens = [
|
||||
{ name: 'Slabs (dev)', heading: /slabs/i },
|
||||
{ name: 'Review (dev)', heading: 'Review to Ready' },
|
||||
{ name: 'Publish (dev)', heading: 'Publish to WooCommerce' },
|
||||
{ name: 'Generate (dev)', heading: 'Generate text' },
|
||||
];
|
||||
|
||||
for (const scr of regressionScreens) {
|
||||
await page.goto('/');
|
||||
await page.getByRole('button', { name: scr.name }).click();
|
||||
if (scr.heading) {
|
||||
await expect(page.getByRole('heading', { name: scr.heading })).toBeVisible({ timeout: 10_000 });
|
||||
}
|
||||
// these already set color:#eee on root → must still pass ≥4.5
|
||||
await assertReadableContrast(page, ['body', 'main', 'label', 'input', 'select'], `regression-${scr.name}`);
|
||||
await shot(page, `regression-${scr.name.replace(/\s|\(|\)/g, '')}.png`);
|
||||
}
|
||||
|
||||
// regression also confirms settings still exhibits the low-contrast bug (until T2 fix)
|
||||
await page.goto('/');
|
||||
await page.getByRole('button', { name: 'Settings (dev)' }).click();
|
||||
await assertReadableContrast(page, ['label', 'input', 'select', 'option'], 'regression-settings-still-bad');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user