From e51753b6d04e61c99983d4f32f8b9e2d096ec95f Mon Sep 17 00:00:00 2001 From: Ty Balascio Date: Sun, 20 Sep 2026 01:10:07 -0700 Subject: [PATCH] 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. --- frontend/src/create/settings/Settings.svelte | 18 ++- frontend/tests/e2e/settings-contrast.spec.ts | 133 +++++++++++++++++++ 2 files changed, 147 insertions(+), 4 deletions(-) create mode 100644 frontend/tests/e2e/settings-contrast.spec.ts diff --git a/frontend/src/create/settings/Settings.svelte b/frontend/src/create/settings/Settings.svelte index e63d443..97be974 100644 --- a/frontend/src/create/settings/Settings.svelte +++ b/frontend/src/create/settings/Settings.svelte @@ -181,8 +181,18 @@ diff --git a/frontend/tests/e2e/settings-contrast.spec.ts b/frontend/tests/e2e/settings-contrast.spec.ts new file mode 100644 index 0000000..373e6ce --- /dev/null +++ b/frontend/tests/e2e/settings-contrast.spec.ts @@ -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 { + 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: