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');