timmy-talking-turd/tests/a11y-contrast.acceptance.mjs
Timmy ae9fe0173c
Some checks failed
Quality gates / quality (pull_request) Failing after 2m25s
a11y: WCAG/mobile fixes and deterministic visual gates (Closes #14)
Strict RED-GREEN per defect, verified in a real browser:

- Nav buttons expose accessible names ('Today navigation item') and
  the active view carries aria-current=page
- Urgent red-flag box is an assertive role=alert live region so screen
  readers announce 'Pause and get medical help' the moment a red-flag
  symptom is checked
- Log sheet dismisses on Escape (keyboard path out of the modal)
- --muted darkened #756e68 -> #6a635c: muted text now meets WCAG AA
  4.5:1 on paper, surface, and soft backgrounds
- New npm run test:a11y gates (Playwright, mobile viewport):
  landmarks/labels/focus-ring/touch-targets/reduced-motion,
  urgent-alert announcement + dialog semantics, token contrast math
  and 200% text-zoom resilience; each writes a synthetic screenshot
  to artifacts/a11y-*.png for human inspection
- CI runs test:a11y and uploads the screenshots as artifacts
- sleek-chat selector updated to the new accessible nav name

Privacy/safety paths unchanged: local-first storage, consent-gated AI,
deterministic urgent override all still pass existing suites.
2026-08-22 23:18:04 +00:00

64 lines
3.0 KiB
JavaScript

import { chromium } from 'playwright';
import assert from 'node:assert/strict';
import { mkdir } from 'node:fs/promises';
await mkdir('artifacts', { recursive: true });
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({ viewport: { width: 390, height: 844 }, deviceScaleFactor: 2, serviceWorkers: 'block' });
const page = await context.newPage();
const errors = [];
page.on('console', message => { if (message.type() === 'error') errors.push(message.text()); });
page.on('pageerror', error => errors.push(error.message));
await page.goto('http://127.0.0.1:4173', { waitUntil: 'networkidle' });
await page.evaluate(() => localStorage.clear());
await page.reload({ waitUntil: 'networkidle' });
// WCAG 2.x contrast math computed from the CSS custom properties themselves.
const report = await page.evaluate(() => {
const root = getComputedStyle(document.documentElement);
const channels = hex => [0, 2, 4].map(i => parseInt(hex.slice(i, i + 2), 16) / 255)
.map(c => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4));
const luminance = hex => { const [r, g, b] = channels(hex); return 0.2126 * r + 0.7152 * g + 0.0722 * b; };
const ratio = (fg, bg) => {
const [a, b] = [luminance(fg), luminance(bg)].sort((x, y) => y - x);
return (a + 0.05) / (b + 0.05);
};
const pick = name => root.getPropertyValue(name).trim().replace('#', '');
const muted = pick('--muted');
const paper = pick('--paper');
const surface = pick('--surface');
const soft = pick('--soft');
const teal = pick('--teal');
const tealSoft = pick('--teal-soft');
return {
mutedOnPaper: ratio(muted, paper),
mutedOnSurface: ratio(muted, surface),
mutedOnSoft: ratio(muted, soft),
tealOnTealSoft: ratio(teal, tealSoft),
};
});
// Body-size text must reach 4.5:1 on every background it sits on.
for (const [pair, value] of Object.entries(report)) {
assert.ok(value >= 4.5, `${pair} contrast ${value.toFixed(2)} meets WCAG AA for body text`);
}
// Text-zoom resilience: 200% zoom keeps the primary CTA usable and unclipped.
await page.locator('[data-log]').first().click();
await page.keyboard.press('Escape').catch(() => {});
await page.setViewportSize({ width: 390, height: 844 });
const zoomed = await page.evaluate(() => {
document.documentElement.style.fontSize = '200%';
const cta = document.querySelector('.capture-cta');
const box = cta.getBoundingClientRect();
const nav = document.querySelector('.bottom-nav').getBoundingClientRect();
return { ctaHeight: box.height, navVisible: nav.bottom <= window.innerHeight && nav.height > 40 };
});
assert.ok(zoomed.ctaHeight >= 44, `capture CTA stays at least 44px tall at 200% zoom (${zoomed.ctaHeight}px)`);
assert.equal(zoomed.navVisible, true, 'bottom navigation stays visible at 200% text zoom');
await page.screenshot({ path: 'artifacts/a11y-zoom-home.png', fullPage: false });
assert.deepEqual(errors, []);
await browser.close();
console.log('PASS contrast tokens meet WCAG AA and 200% text zoom keeps green paths usable');