diff --git a/.gitea/workflows/quality.yml b/.gitea/workflows/quality.yml index c77cc35..4fb8cfb 100644 --- a/.gitea/workflows/quality.yml +++ b/.gitea/workflows/quality.yml @@ -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@v3 + 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 diff --git a/app.js b/app.js index 1d9c082..a48f864 100644 --- a/app.js +++ b/app.js @@ -45,7 +45,7 @@ function shell(content) { app.innerHTML = `
Timmy mascot
Timmyprivate bowel journal
● Ledger local
${content}${label}${nav()}`; bindGlobal(); } -function nav(){return ``} +function nav(){return ``} 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=``;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'?`
AI PREFILLEDType ${aiSuggestion.bristolType} · ${esc(aiSuggestion.color)}You’re in charge—tap any type to correct it.
`:'

Choose the closest match yourself. Timmy never treats a suggestion as fact.

'}
${[[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])=>``).join('')}
`; if(step===2)return `${photoDataUrl?`Private entry preview`:''}${photoHint?`

${esc(photoHint)}

`:''}${aiSuggestion?.status==='suggestion'?'

AI suggested only form and visible color. Urgency, discomfort, notes, and symptoms must come from you.

':'

Manual-mode photos stay in this browser and receive quality checks only.

'}
`; - const urgent=detectUrgentFlags(form.symptoms);return `

Select anything you have now. This is where Timmy stops joking.

${[['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])=>``).join('')}
${urgent.urgent?alertHtml(urgent.message):''}

Not medical advice. Heavy or nonstop bleeding, fainting, or severe worsening symptoms can be an emergency—call local emergency services.

`; + const urgent=detectUrgentFlags(form.symptoms);return `

Select anything you have now. This is where Timmy stops joking.

${[['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])=>``).join('')}

Not medical advice. Heavy or nonstop bleeding, fainting, or severe worsening symptoms can be an emergency—call local emergency services.

`; } function alertHtml(msg){return `
Pause and get medical help.

${esc(msg)}

`} function bindStep(step){ diff --git a/artifacts/a11y-journal-mobile.png b/artifacts/a11y-journal-mobile.png new file mode 100644 index 0000000..bed81fe Binary files /dev/null and b/artifacts/a11y-journal-mobile.png differ diff --git a/artifacts/a11y-privacy-mobile.png b/artifacts/a11y-privacy-mobile.png new file mode 100644 index 0000000..ad7dd62 Binary files /dev/null and b/artifacts/a11y-privacy-mobile.png differ diff --git a/artifacts/a11y-redflag-alert.png b/artifacts/a11y-redflag-alert.png new file mode 100644 index 0000000..c26cccf Binary files /dev/null and b/artifacts/a11y-redflag-alert.png differ diff --git a/artifacts/a11y-zoom-home.png b/artifacts/a11y-zoom-home.png new file mode 100644 index 0000000..15075ab Binary files /dev/null and b/artifacts/a11y-zoom-home.png differ diff --git a/package.json b/package.json index 31839e0..04b5a76 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/styles.css b/styles.css index c9cb992..e023fa4 100644 --- a/styles.css +++ b/styles.css @@ -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 diff --git a/tests/a11y-contrast.acceptance.mjs b/tests/a11y-contrast.acceptance.mjs new file mode 100644 index 0000000..1d04ffb --- /dev/null +++ b/tests/a11y-contrast.acceptance.mjs @@ -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'); diff --git a/tests/a11y-urgent.acceptance.mjs b/tests/a11y-urgent.acceptance.mjs new file mode 100644 index 0000000..b622af7 --- /dev/null +++ b/tests/a11y-urgent.acceptance.mjs @@ -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'); diff --git a/tests/a11y.acceptance.mjs b/tests/a11y.acceptance.mjs new file mode 100644 index 0000000..d9da854 --- /dev/null +++ b/tests/a11y.acceptance.mjs @@ -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'); diff --git a/tests/ci-workflow.test.js b/tests/ci-workflow.test.js index 07fdb83..83059ea 100644 --- a/tests/ci-workflow.test.js +++ b/tests/ci-workflow.test.js @@ -21,6 +21,8 @@ test('Gitea CI gates pull requests and main with the reproducible quality suite' assert.match(workflow, /npm run test:ui/); assert.match(workflow, /npm run test:photo/); assert.match(workflow, /npm run test:sleek/); + assert.match(workflow, /uses: actions\/upload-artifact@v3/); + assert.doesNotMatch(workflow, /uses: actions\/upload-artifact@v4/); assert.match(workflow, /npm audit --audit-level=high/); assert.match(workflow, /npm run check:syntax/); assert.match(workflow, /npm run check:diff/); diff --git a/tests/sleek-chat.acceptance.mjs b/tests/sleek-chat.acceptance.mjs index fcf07d5..555c354 100644 --- a/tests/sleek-chat.acceptance.mjs +++ b/tests/sleek-chat.acceptance.mjs @@ -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();