a11y: WCAG/mobile fixes and deterministic visual gates (Closes #14)
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.
This commit is contained in:
Timmy 2026-08-22 23:18:04 +00:00
parent 47294a98aa
commit ae9fe0173c
12 changed files with 217 additions and 4 deletions

View File

@ -49,8 +49,20 @@ jobs:
sleep 1
done
npm run test:ui
npm run test:a11y
npm run test:photo
npm run test:sleek
- name: Upload accessibility screenshots
uses: actions/upload-artifact@v4
if: always()
with:
name: a11y-screenshots
path: |
artifacts/a11y-journal-mobile.png
artifacts/a11y-privacy-mobile.png
artifacts/a11y-redflag-alert.png
artifacts/a11y-zoom-home.png
if-no-files-found: ignore
- name: Dependency audit
run: npm audit --audit-level=high
- name: Syntax checks

5
app.js
View File

@ -45,7 +45,7 @@ function shell(content) {
app.innerHTML = `<header class="topbar"><div class="brand"><img src="${appPath('assets/timmy.svg')}" alt="Timmy mascot"><div class="brand-copy"><strong>Timmy</strong><span>private bowel journal</span></div></div><span class="local-mark" title="Saved locally">● Ledger local</span></header>${content}${label}${nav()}`;
bindGlobal();
}
function nav(){return `<nav class="bottom-nav" aria-label="Primary"><button class="nav-btn ${view==='home'?'active':''}" data-view="home"><b aria-hidden="true">⌂</b><span>Today</span></button><button class="nav-btn ${view==='calendar'||view==='privacy'?'active':''}" data-view="calendar"><b aria-hidden="true">▤</b><span>Journal</span></button><button class="nav-btn ${view==='timmy'?'active':''}" data-view="timmy"><b aria-hidden="true">✦</b><span>Timmy</span></button></nav>`}
function nav(){return `<nav class="bottom-nav" aria-label="Primary"><button class="nav-btn ${view==='home'?'active':''}" data-view="home" aria-label="Today navigation item" ${view==='home'?'aria-current="page"':''}><b aria-hidden="true">⌂</b><span>Today</span></button><button class="nav-btn ${view==='calendar'||view==='privacy'?'active':''}" data-view="calendar" aria-label="Journal navigation item" ${view==='calendar'||view==='privacy'?'aria-current="page"':''}><b aria-hidden="true">▤</b><span>Journal</span></button><button class="nav-btn ${view==='timmy'?'active':''}" data-view="timmy" aria-label="Timmy navigation item" ${view==='timmy'?'aria-current="page"':''}><b aria-hidden="true">✦</b><span>Timmy</span></button></nav>`}
function bindGlobal(){
document.querySelectorAll('[data-view]').forEach(btn=>btn.onclick=()=>{view=btn.dataset.view;render()});
document.querySelectorAll('[data-log]').forEach(btn=>btn.onclick=openLogger);
@ -158,12 +158,13 @@ function openLogger(){form=draft();photoDataUrl='';photoHint='';aiSuggestion=nul
function showLogStep(step){
document.querySelector('.sheet-backdrop')?.remove();
const wrap=document.createElement('div');wrap.className='sheet-backdrop';wrap.innerHTML=`<section class="sheet" role="dialog" aria-modal="true" aria-labelledby="log-title"><div class="sheet-handle"></div><div class="sheet-header"><div><span class="eyebrow">Step ${step} of 3</span><h2 id="log-title">${step===1?'Pick the closest form':step===2?'Add useful context':'Safety check'}</h2></div><button class="icon-btn" id="close-sheet" aria-label="Close">×</button></div><div class="progress"><i class="progress-step-${step}"></i></div>${stepBody(step)}</section>`;document.body.append(wrap);
wrap.addEventListener('keydown',e=>{if(e.key==='Escape'){e.stopPropagation();wrap.remove()}});
document.querySelector('#close-sheet').onclick=()=>wrap.remove();wrap.onclick=e=>{if(e.target===wrap)wrap.remove()};bindStep(step);
}
function stepBody(step){
if(step===1)return `${aiSuggestion?.status==='suggestion'?`<div class="ai-prefill"><span class="ai-badge">AI PREFILLED</span><strong>Type ${aiSuggestion.bristolType} · ${esc(aiSuggestion.color)}</strong><small>Youre in charge—tap any type to correct it.</small></div>`:'<p class="fine">Choose the closest match yourself. Timmy never treats a suggestion as fact.</p>'}<div class="choice-grid">${[[1,'Hard separate lumps'],[2,'Lumpy sausage'],[3,'Cracked sausage'],[4,'Smooth and soft'],[5,'Soft blobs'],[6,'Mushy pieces'],[7,'Entirely liquid']].map(([n,d])=>`<button class="bristol ${form.bristolType===n?'selected':''}" data-type="${n}"><strong>Type ${n}</strong><span>${d}</span></button>`).join('')}</div><button class="btn btn-primary btn-wide section" id="next">Confirm + add details →</button>`;
if(step===2)return `<label class="field"><span class="field-label">Color</span><select class="input" id="color"><option>brown</option><option>green</option><option>yellow</option><option>pale</option><option>red</option><option>black</option></select></label><label class="field"><span class="field-label">Urgency</span><div class="range-row"><input id="urgency" type="range" min="0" max="4" value="${form.urgency}"><output class="range-val">${form.urgency}</output></div></label><label class="field"><span class="field-label">Discomfort</span><div class="range-row"><input id="discomfort" type="range" min="0" max="4" value="${form.discomfort}"><output class="range-val">${form.discomfort}</output></div></label><label class="field"><span class="field-label">Note (optional)</span><textarea class="input" id="note" maxlength="500" placeholder="Meal, medicine, travel, stress…">${esc(form.note)}</textarea></label><label class="photo-drop btn" for="photo">📷 Add a private photo (optional)<input id="photo" type="file" accept="image/*" capture="environment"></label>${photoDataUrl?`<img class="photo-preview" src="${photoDataUrl}" alt="Private entry preview">`:''}${photoHint?`<p class="fine">${esc(photoHint)}</p>`:''}${aiSuggestion?.status==='suggestion'?'<p class="fine">AI suggested only form and visible color. Urgency, discomfort, notes, and symptoms must come from you.</p>':'<p class="fine">Manual-mode photos stay in this browser and receive quality checks only.</p>'}<div class="row section"><button class="btn btn-ghost" id="back">← Back</button><button class="btn btn-primary" id="next">Safety check →</button></div>`;
const urgent=detectUrgentFlags(form.symptoms);return `<p class="fine">Select anything you have now. This is where Timmy stops joking.</p><div class="symptoms">${[['blood','Blood in stool or rectal bleeding'],['blackOrDarkRed','Black or dark-red stool'],['severePain','Severe or constant abdominal pain'],['vomiting','Vomiting'],['fever','Fever'],['cannotPassGas','Unable to pass gas']].map(([k,l])=>`<label class="check"><input type="checkbox" data-symptom="${k}" ${form.symptoms[k]?'checked':''}><span>${l}</span></label>`).join('')}</div><div id="urgent-box">${urgent.urgent?alertHtml(urgent.message):''}</div><div class="row section"><button class="btn btn-ghost" id="back">← Back</button><button class="btn btn-primary" id="save">Save private log</button></div><p class="fine">Not medical advice. Heavy or nonstop bleeding, fainting, or severe worsening symptoms can be an emergency—call local emergency services.</p>`;
const urgent=detectUrgentFlags(form.symptoms);return `<p class="fine">Select anything you have now. This is where Timmy stops joking.</p><div class="symptoms">${[['blood','Blood in stool or rectal bleeding'],['blackOrDarkRed','Black or dark-red stool'],['severePain','Severe or constant abdominal pain'],['vomiting','Vomiting'],['fever','Fever'],['cannotPassGas','Unable to pass gas']].map(([k,l])=>`<label class="check"><input type="checkbox" data-symptom="${k}" ${form.symptoms[k]?'checked':''}><span>${l}</span></label>`).join('')}</div><div id="urgent-box" role="alert" aria-live="assertive">${urgent.urgent?alertHtml(urgent.message):''}</div><div class="row section"><button class="btn btn-ghost" id="back">← Back</button><button class="btn btn-primary" id="save">Save private log</button></div><p class="fine">Not medical advice. Heavy or nonstop bleeding, fainting, or severe worsening symptoms can be an emergency—call local emergency services.</p>`;
}
function alertHtml(msg){return `<div class="alert"><strong>Pause and get medical help.</strong><p>${esc(msg)}</p></div>`}
function bindStep(step){

Binary file not shown.

After

Width:  |  Height:  |  Size: 211 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 222 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 182 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 187 KiB

View File

@ -6,6 +6,7 @@
"scripts": {
"test": "node --test tests/domain.test.js tests/analysis.test.js tests/vision-service.test.js tests/vision-config.test.js tests/hermes-agent-service.test.js tests/agent-gateway.acceptance.test.js tests/staging-health.test.js tests/service-worker-runtime.test.js tests/training-ingest.test.js tests/ci-workflow.test.js tests/product-decisions.test.js tests/release-demo.test.js tests/selfhost-bootstrap.test.js tests/staging-config.test.js",
"test:ui": "node tests/ui.acceptance.mjs",
"test:a11y": "node tests/a11y.acceptance.mjs && node tests/a11y-urgent.acceptance.mjs && node tests/a11y-contrast.acceptance.mjs",
"test:photo": "node tests/photo-first.acceptance.mjs",
"test:sleek": "node tests/sleek-chat.acceptance.mjs",
"test:staging-smoke": "node tests/staging.acceptance.mjs",

View File

@ -1,5 +1,5 @@
:root{
--ink:#211d1a;--muted:#756e68;--paper:#f6f2ea;--surface:#fffdfa;--soft:#eee9df;
--ink:#211d1a;--muted:#6a635c;--paper:#f6f2ea;--surface:#fffdfa;--soft:#eee9df;
--teal:#0e7771;--teal-soft:#dcefeb;--gold:#efc75e;--brown:#75452f;--red:#ae3a33;
--red-soft:#fff0ed;--line:rgba(52,39,31,.1);--shadow:0 18px 45px rgba(58,43,32,.1);
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:var(--ink);background:var(--paper);font-synthesis:none

View File

@ -0,0 +1,63 @@
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');

View File

@ -0,0 +1,65 @@
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' });
// Open the manual logger, walk to the safety-check step.
await page.locator('[data-log]').first().click();
await page.locator('[data-type="6"]').click();
await page.locator('#next').click();
await page.locator('#next').click();
// The urgent alert container must be an assertive live region so a screen
// reader announces it the moment a red-flag symptom is checked.
assert.equal(
await page.locator('#urgent-box[role="alert"]').count(), 1,
'#urgent-box is an assertive role="alert" live region',
);
assert.equal(
await page.locator('#urgent-box[aria-live="assertive"]').count(), 1,
'#urgent-box carries aria-live="alert"',
);
// Check blood → the alert appears and is announced.
await page.locator('[data-symptom="blood"]').check();
const alertBox = page.locator('#urgent-box');
assert.equal(await alertBox.getAttribute('role'), 'alert');
assert.match(await alertBox.innerText(), /Pause and get medical help/i);
const liveValue = await alertBox.getAttribute('aria-live');
assert.equal(liveValue, 'assertive');
// Dialog semantics: sheet traps description and close button has a name.
const sheet = page.locator('.sheet').last();
assert.equal(await sheet.getAttribute('role'), 'dialog');
assert.equal(await sheet.getAttribute('aria-modal'), 'true');
assert.ok(await sheet.getAttribute('aria-labelledby'), 'sheet names itself via aria-labelledby');
assert.equal(await page.locator('.icon-btn[aria-label="Close"]').last().isVisible(), true);
// Escape closes the dialog (keyboard path out of the modal).
await page.locator('.sheet').last().click(); // ensure focus is inside the dialog
await page.locator('.icon-btn[aria-label="Close"]').last().focus();
await page.keyboard.press('Escape');
assert.equal(await page.locator('.sheet-backdrop').count(), 0, 'Escape dismisses the log sheet');
// Re-open and capture the red-flag alert visual for review.
await page.locator('[data-log]').first().click();
await page.locator('[data-type="6"]').click();
await page.locator('#next').click();
await page.locator('#next').click();
await page.locator('[data-symptom="blood"]').check();
await page.waitForTimeout(150);
await page.screenshot({ path: 'artifacts/a11y-redflag-alert.png', fullPage: true });
assert.equal(await page.locator('#urgent-box .alert').isVisible(), true);
assert.deepEqual(errors, []);
await browser.close();
console.log('PASS urgent-alert screen-reader announcement + dialog keyboard semantics');

71
tests/a11y.acceptance.mjs Normal file
View File

@ -0,0 +1,71 @@
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');

View File

@ -105,7 +105,7 @@ if (expectedBasePath === '/') {
assert.equal(await migrationPage.evaluate(() => localStorage.getItem('timmy-ledger-v1')), null);
await migrationPage.evaluate(() => localStorage.setItem('timmy-ledger-v1', '[{"id":"resurrection-risk"}]'));
await migrationPage.getByRole('button', { name: 'Journal', exact: true }).click();
await migrationPage.getByRole('button', { name: 'Journal navigation item', exact: true }).click();
await migrationPage.locator('[data-view="privacy"]').click();
migrationPage.once('dialog', dialog => dialog.accept());
await migrationPage.locator('#delete-all').click();