feat(a11y): accessibility and performance gate checks (#386)

Closes #330 - Playwright a11y gate spec: 320/360/390/414/768px viewport tests for overflow, tap targets, pre-content height - Lighthouse gate script: a11y>=95, best-practices>=95, CLS<=0.1 - Extended visual capture viewport matrix - Updated visual-verification docs Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Juan Manuel Servera committed Jun 11, 2026 at 23:53 UTC 05a10b9b772ff9741619f798b7dd575d42398b9b
4 files changed +324 -5
docs/design/visual-verification.md
+45 -1
@@ -149,13 +149,57 @@ git push
149
150 | File | Purpose |
151 |------|---------|
152 -| `scripts/design/verify-visual.mjs` | Standalone capture script — no test framework needed |
152 +| `scripts/design/verify-visual.mjs` | Standalone capture script — now includes the added 320/360/390/414 mobile widths |
153 +| `scripts/design/lighthouse-gates.mjs` | Lighthouse accessibility / best-practices / CLS gate runner |
154 | `tests/visual/playwright.config.mjs` | Playwright config for snapshot regression tests |
155 | `tests/visual/visual.spec.mjs` | Snapshot specs for each page |
156 +| `tests/visual/a11y-perf.spec.mjs` | Playwright viewport gate checks for overflow, tap targets, and pre-content height |
157 | `tests/visual/snapshots/` | Committed baseline screenshots |
158 | `screenshots/design-verification/` | Ad-hoc capture output (gitignored) |
159 | `.squad/skills/design-visual-verification/SKILL.md` | Full skill pattern for the team |
160
161 ---
162
163 +
164 +## Accessibility & Performance Gates
165 +
166 +The design review flow now includes a lightweight gate pass focused on mobile resilience and Lighthouse regressions.
167 +
168 +### Gate viewport matrix
169 +
170 +- `320×568`
171 +- `360×640`
172 +- `390×844`
173 +- `414×896`
174 +- `768×1024`
175 +
176 +### Checks performed
177 +
178 +- No horizontal overflow (`document.documentElement.scrollWidth <= document.documentElement.clientWidth`)
179 +- Tap targets for all visible `<a>` and `<button>` elements are at least `44×44px` unless explicitly marked with `data-small-ok`
180 +- Home-page pre-content height guard: the main content container (`.main`, `main`, or `#main-content`) must begin within `600px` of the top edge at `320–414px`
181 +- Lighthouse mobile gates on `/`, `/weekly/2026/w22/`, `/monthly/2026/05/`, and `/yearly/2026/`
182 +
183 +### Thresholds
184 +
185 +- Accessibility score ≥ `95`
186 +- Best Practices score ≥ `95`
187 +- CLS ≤ `0.1`
188 +- Tap targets ≥ `44×44`
189 +- No horizontal scrolling
190 +- Pre-content start ≤ `600px` on home at mobile widths
191 +
192 +### How to run
193 +
194 +```bash
195 +npx playwright test --config tests/visual/playwright.config.mjs tests/visual/a11y-perf.spec.mjs
196 +node scripts/design/lighthouse-gates.mjs
197 +```
198 +
199 +### PR review summary (Fry)
200 +
201 +Fry should summarize the gate pass as a page-by-page matrix, call out any tap-target or pre-content exceptions that need `data-small-ok`, and include the Lighthouse score table with any threshold failures highlighted for reviewers.
202 +
203 +---
204 +
205 *Established: 2026-05-25 — Calculon (Designer)*
scripts/design/lighthouse-gates.mjs new
+137
@@ -0,0 +1,137 @@
1 +#!/usr/bin/env node
2 +
3 +import { execSync } from 'node:child_process';
4 +import { mkdir, writeFile } from 'node:fs/promises';
5 +import { existsSync } from 'node:fs';
6 +import { join } from 'node:path';
7 +
8 +const args = process.argv.slice(2);
9 +
10 +function getArg(name, fallback) {
11 + const index = args.indexOf(name);
12 + return index !== -1 && args[index + 1] ? args[index + 1] : fallback;
13 +}
14 +
15 +const BASE_URL = getArg('--base', 'http://localhost:1313/SquadScope').replace(/\/$/, '');
16 +const OUTPUT_DIR = join('screenshots', 'lighthouse-results');
17 +const THRESHOLDS = {
18 + accessibility: 0.95,
19 + bestPractices: 0.95,
20 + cls: 0.1,
21 +};
22 +
23 +const PAGES = [
24 + { key: 'home', path: '/' },
25 + { key: 'weekly', path: '/weekly/2026/w22/' },
26 + { key: 'monthly', path: '/monthly/2026/05/' },
27 + { key: 'yearly', path: '/yearly/2026/' },
28 +];
29 +
30 +function ensureDir(path) {
31 + if (!existsSync(path)) {
32 + return mkdir(path, { recursive: true });
33 + }
34 +
35 + return Promise.resolve();
36 +}
37 +
38 +function runLighthouse(url) {
39 + const command = [
40 + 'npx -y lighthouse',
41 + JSON.stringify(url),
42 + '--quiet',
43 + '--output=json',
44 + '--output-path=stdout',
45 + '--only-categories=accessibility,best-practices,performance',
46 + '--chrome-flags="--headless --no-sandbox"',
47 + '--form-factor=mobile',
48 + ].join(' ');
49 +
50 + const output = execSync(command, {
51 + cwd: process.cwd(),
52 + encoding: 'utf8',
53 + maxBuffer: 20 * 1024 * 1024,
54 + stdio: ['ignore', 'pipe', 'pipe'],
55 + });
56 +
57 + return JSON.parse(output);
58 +}
59 +
60 +function getScores(report) {
61 + return {
62 + accessibility: report.categories.accessibility?.score ?? 0,
63 + bestPractices: report.categories['best-practices']?.score ?? 0,
64 + cls: report.audits['cumulative-layout-shift']?.numericValue ?? Number.POSITIVE_INFINITY,
65 + };
66 +}
67 +
68 +function getFailures(scores) {
69 + const failures = [];
70 +
71 + if (scores.accessibility < THRESHOLDS.accessibility) {
72 + failures.push(`a11y ${(scores.accessibility * 100).toFixed(0)} < 95`);
73 + }
74 +
75 + if (scores.bestPractices < THRESHOLDS.bestPractices) {
76 + failures.push(`best ${(scores.bestPractices * 100).toFixed(0)} < 95`);
77 + }
78 +
79 + if (scores.cls > THRESHOLDS.cls) {
80 + failures.push(`cls ${scores.cls.toFixed(3)} > 0.100`);
81 + }
82 +
83 + return failures;
84 +}
85 +
86 +function formatPercent(score) {
87 + return `${(score * 100).toFixed(0)}%`;
88 +}
89 +
90 +function formatCls(value) {
91 + return Number.isFinite(value) ? value.toFixed(3) : 'n/a';
92 +}
93 +
94 +async function main() {
95 + await ensureDir(OUTPUT_DIR);
96 +
97 + const results = [];
98 +
99 + for (const page of PAGES) {
100 + const url = `${BASE_URL}${page.path}`;
101 + const report = runLighthouse(url);
102 + const scores = getScores(report);
103 + const failures = getFailures(scores);
104 + const result = {
105 + page: page.key,
106 + url,
107 + accessibility: scores.accessibility,
108 + bestPractices: scores.bestPractices,
109 + cls: scores.cls,
110 + ok: failures.length === 0,
111 + failures,
112 + };
113 +
114 + results.push(result);
115 + await writeFile(join(OUTPUT_DIR, `${page.key}.json`), JSON.stringify(report, null, 2));
116 + }
117 +
118 + await writeFile(join(OUTPUT_DIR, 'summary.json'), JSON.stringify({ baseUrl: BASE_URL, thresholds: THRESHOLDS, results }, null, 2));
119 +
120 + console.log(`Lighthouse gates for ${BASE_URL}`);
121 + console.table(results.map(result => ({
122 + page: result.page,
123 + accessibility: formatPercent(result.accessibility),
124 + bestPractices: formatPercent(result.bestPractices),
125 + cls: formatCls(result.cls),
126 + status: result.ok ? 'PASS' : `FAIL (${result.failures.join(', ')})`,
127 + })));
128 +
129 + if (results.some(result => !result.ok)) {
130 + process.exit(1);
131 + }
132 +}
133 +
134 +main().catch(error => {
135 + console.error(error instanceof Error ? error.message : error);
136 + process.exit(1);
137 +});
scripts/design/verify-visual.mjs
+8 -4
@@ -36,10 +36,14 @@ const DATE_SLUG = getArg('--date', '2026-05-25');
36 const OUT_DIR = getArg('--out', join('screenshots', 'design-verification', DATE_SLUG));
37
38 const VIEWPORTS = [
39 - { name: 'mobile', width: 375, height: 667 },
40 - { name: 'tablet', width: 768, height: 1024 },
41 - { name: 'desktop', width: 1280, height: 800 },
42 - { name: 'wide', width: 1920, height: 1080 },
39 + { name: 'mobile-320', width: 320, height: 568 },
40 + { name: 'mobile-360', width: 360, height: 640 },
41 + { name: 'mobile', width: 375, height: 667 },
42 + { name: 'mobile-390', width: 390, height: 844 },
43 + { name: 'mobile-414', width: 414, height: 896 },
44 + { name: 'tablet', width: 768, height: 1024 },
45 + { name: 'desktop', width: 1280, height: 800 },
46 + { name: 'wide', width: 1920, height: 1080 },
47 ];
48
49 const THEMES = ['light', 'dark'];
tests/visual/a11y-perf.spec.mjs new
+134
@@ -0,0 +1,134 @@
1 +import { test, expect } from '@playwright/test';
2 +
3 +const NOISE_SUPPRESSION_CSS = `
4 + .cost-dashboard__date,
5 + [data-cost-date],
6 + .run-date,
7 + .last-updated { visibility: hidden !important; }
8 + .dynamic-counter, .repo-count, [data-live] { visibility: hidden !important; }
9 + *, *::before, *::after { animation-duration: 0s !important; transition-duration: 0s !important; }
10 +`;
11 +
12 +const VIEWPORTS = [
13 + { width: 320, height: 568 },
14 + { width: 360, height: 640 },
15 + { width: 390, height: 844 },
16 + { width: 414, height: 896 },
17 + { width: 768, height: 1024 },
18 +];
19 +
20 +const PAGES = [
21 + { key: 'home', label: 'home', path: '/' },
22 + { key: 'weekly', label: 'weekly', path: '/weekly/2026/w22/' },
23 + { key: 'monthly', label: 'monthly', path: '/monthly/2026/05/' },
24 + { key: 'yearly', label: 'yearly', path: '/yearly/2026/' },
25 +];
26 +
27 +async function settle(page) {
28 + await page.waitForLoadState('networkidle').catch(() => page.waitForTimeout(1000));
29 + await page.addStyleTag({ content: NOISE_SUPPRESSION_CSS });
30 + await page.waitForTimeout(300);
31 +}
32 +
33 +async function openPage(page, path, viewport) {
34 + await page.setViewportSize(viewport);
35 + await page.emulateMedia({ colorScheme: 'light' });
36 + await page.goto(path);
37 + await settle(page);
38 +}
39 +
40 +function formatViewport({ width }) {
41 + return `${width}px`;
42 +}
43 +
44 +function skipNonMatrixProject(testInfo) {
45 + test.skip(testInfo.project.name !== 'desktop-light', 'A11y matrix is defined in-spec.');
46 +}
47 +
48 +for (const pageConfig of PAGES) {
49 + test.describe(pageConfig.label, () => {
50 + for (const viewport of VIEWPORTS) {
51 + const viewportLabel = formatViewport(viewport);
52 +
53 + test(`${pageConfig.label} — no horizontal overflow at ${viewportLabel}`, async ({ page }, testInfo) => {
54 + skipNonMatrixProject(testInfo);
55 + await openPage(page, pageConfig.path, viewport);
56 +
57 + const hasNoOverflow = await page.evaluate(() => {
58 + const root = document.documentElement;
59 + return root.scrollWidth <= root.clientWidth;
60 + });
61 +
62 + expect(hasNoOverflow).toBe(true);
63 + });
64 +
65 + test(`${pageConfig.label} — tap targets ≥ 44x44 at ${viewportLabel}`, async ({ page }, testInfo) => {
66 + skipNonMatrixProject(testInfo);
67 + await openPage(page, pageConfig.path, viewport);
68 +
69 + const violations = await page.evaluate(() => {
70 + const minSize = 44;
71 + const round = value => Math.round(value * 10) / 10;
72 +
73 + return Array.from(document.querySelectorAll('a, button'))
74 + .filter(element => !element.hasAttribute('data-small-ok'))
75 + .map(element => {
76 + const rect = element.getBoundingClientRect();
77 + const style = window.getComputedStyle(element);
78 +
79 + if (
80 + style.display === 'none' ||
81 + style.visibility === 'hidden' ||
82 + rect.width <= 0 ||
83 + rect.height <= 0
84 + ) {
85 + return null;
86 + }
87 +
88 + if (rect.width >= minSize && rect.height >= minSize) {
89 + return null;
90 + }
91 +
92 + const label =
93 + element.getAttribute('aria-label') ||
94 + element.textContent?.replace(/\s+/g, ' ').trim() ||
95 + element.getAttribute('href') ||
96 + element.tagName.toLowerCase();
97 +
98 + return {
99 + tag: element.tagName.toLowerCase(),
100 + label,
101 + width: round(rect.width),
102 + height: round(rect.height),
103 + };
104 + })
105 + .filter(Boolean);
106 + });
107 +
108 + expect(violations, `Tap target violations: ${JSON.stringify(violations, null, 2)}`).toEqual([]);
109 + });
110 +
111 + if (pageConfig.key === 'home' && viewport.width <= 414) {
112 + test(`${pageConfig.label} — main content starts within 600px at ${viewportLabel}`, async ({ page }, testInfo) => {
113 + skipNonMatrixProject(testInfo);
114 + await openPage(page, pageConfig.path, viewport);
115 +
116 + const mainTop = await page.evaluate(() => {
117 + const main =
118 + document.querySelector('.main') ||
119 + document.querySelector('main') ||
120 + document.querySelector('#main-content');
121 +
122 + if (!main) {
123 + throw new Error('No main content element found');
124 + }
125 +
126 + return main.getBoundingClientRect().top;
127 + });
128 +
129 + expect(mainTop).toBeLessThanOrEqual(600);
130 + });
131 + }
132 + }
133 + });
134 +}