● 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=`
Step ${step} of 3
${step===1?'Pick the closest form':step===2?'Add useful context':'Safety check'}
${stepBody(step)}`;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('')}
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.
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.
`;
}
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();