timmy-talking-turd/tests/a11y.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

72 lines
4.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' });
// --- Landmarks and semantics ---
assert.equal(await page.locator('nav[aria-label="Primary"]').count(), 1, 'primary nav landmark');
assert.equal(await page.locator('main').count(), 1, 'exactly one main landmark');
const navButtons = page.locator('.bottom-nav .nav-btn');
for (let i = 0; i < await navButtons.count(); i++) {
const text = (await navButtons.nth(i).locator('span').innerText()).trim();
assert.equal(await navButtons.nth(i).getAttribute('aria-label'), `${text} navigation item`, `nav button ${text} exposes its accessible name`);
}
// Active view is marked for screen readers.
await navButtons.first().click(); // Today already active; click Journal
await page.locator('[data-view="calendar"]').click();
const activeBtn = page.locator('.bottom-nav .nav-btn.active');
assert.equal(await activeBtn.getAttribute('aria-current'), 'page', 'active nav button has aria-current="page"');
await page.screenshot({ path: 'artifacts/a11y-journal-mobile.png', fullPage: false });
// --- Focus visibility: keyboard through the journal surface lands on a visible ring ---
await page.keyboard.press('Tab'); // first stop should be inside the document with a visible focus style
const focused = page.evaluate(() => {
const el = document.activeElement;
const style = getComputedStyle(el);
return { tag: el.tagName, outlineStyle: style.outlineStyle, outlineWidth: style.outlineWidth, outlineColor: style.outlineColor };
});
assert.notEqual((await focused).outlineStyle, 'none', 'focused element shows an outline');
assert.notEqual((await focused).outlineWidth, '0px', 'focused element outline is visible');
assert.notEqual((await focused).outlineColor, 'transparent', 'focused element outline is not transparent');
// --- Privacy path: import control is keyboard reachable and labeled ---
await page.locator('.settings-row').click();
const importControl = page.locator('#import');
const importLabel = page.locator('label[for="import"]');
assert.equal(await importLabel.count(), 1, 'Import JSON control has a programmatic label');
const exportBtn = page.locator('#export');
const exportBox = await exportBtn.boundingBox();
assert.ok(exportBox.height >= 44 && exportBox.width >= 88, `export button meets touch target size (${exportBox.width}x${exportBox.height})`);
const deleteBox = await page.locator('#delete-all').boundingBox();
assert.ok(deleteBox.height >= 44, 'delete-all button meets 44px touch target height');
await page.screenshot({ path: 'artifacts/a11y-privacy-mobile.png', fullPage: false });
// --- Reduced motion removes animation ---
const reduced = await context.browser().newContext({
viewport: { width: 390, height: 844 }, serviceWorkers: 'block',
reducedMotion: 'reduce',
});
const rpage = await reduced.newPage();
await rpage.goto('http://127.0.0.1:4173', { waitUntil: 'networkidle' });
const animDurationMs = await rpage.evaluate(() => {
const value = getComputedStyle(document.querySelector('.nav-btn') || document.body).animationDuration;
const parsed = parseFloat(value);
return Number.isFinite(parsed) ? parsed * 1000 : 0;
});
assert.ok(animDurationMs < 5, `animations collapse under prefers-reduced-motion (got ${animDurationMs}ms)`);
await reduced.close();
assert.deepEqual(errors, []);
await browser.close();
console.log('PASS accessibility gates: landmarks, labels, focus ring, touch targets, reduced motion, synthetic screenshots');