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