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: