Some checks failed
Quality gates / quality (pull_request) Failing after 2m25s
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.
64 lines
3.0 KiB
JavaScript
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');
|