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
+}