Skip to content

Commit 7519536

Browse files
authored
fix(a11y): audit every page the site builds, and state the wire's axis once (#56)
The Lighthouse gate derives its route list from site.config.json instead of keeping its own, so a page added to the inventory cannot ship unaudited. The diagram wire states its axis once on .diag and inherits it, replacing six copies of the same gradient. Four tests guard both, each verified to fail without its fix.
1 parent 4ee9ef7 commit 7519536

7 files changed

Lines changed: 115 additions & 55 deletions

File tree

‎scripts/a11y-routes.mjs‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import site from '../site.config.json' with { type: 'json' };
2+
3+
/**
4+
* Which routes the Lighthouse gate audits.
5+
*
6+
* Derived from site.config.json rather than typed out, because a hand-kept copy
7+
* of the route inventory drifts: /code-signing-policy had to be added in a
8+
* follow-up commit, and /helper shipped with no audit at all. Adding a page to
9+
* the inventory now adds it to the audit, which is the only behaviour that
10+
* cannot fall behind.
11+
*/
12+
13+
/** In the inventory, but not an HTML page a Lighthouse run means anything for. */
14+
const NOT_A_PAGE = new Set(['/rss.xml']);
15+
16+
/**
17+
* Routes built from a template, where auditing every instance would audit the
18+
* same markup over and over. One stands for the family: the accessibility of
19+
* the 104th post is the accessibility of the first.
20+
*/
21+
const TEMPLATE_REPRESENTATIVES = ['/blog/the-tool-goes-to-the-data', '/blog/engine/postgresql'];
22+
23+
export const a11yRoutes = [...site.routes.filter((route) => !NOT_A_PAGE.has(route)), ...TEMPLATE_REPRESENTATIVES];

‎scripts/lighthouse-gate.mjs‎

Lines changed: 2 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import { existsSync, statSync } from 'node:fs';
2323
import { extname, join, resolve as resolvePath, sep } from 'node:path';
2424
import { launch } from 'chrome-launcher';
2525
import lighthouse from 'lighthouse';
26+
import { a11yRoutes } from './a11y-routes.mjs';
2627

2728
const args = process.argv.slice(2);
2829
const flag = (name, fallback) => {
@@ -32,37 +33,7 @@ const flag = (name, fallback) => {
3233

3334
const MIN = Number(flag('min', 95));
3435
const DIST = 'dist';
35-
const ROUTES = args.includes('--route')
36-
? args.filter((a, i) => args[i - 1] === '--route')
37-
: [
38-
'/',
39-
'/blog',
40-
'/blog/the-tool-goes-to-the-data',
41-
// The engine archives are one template over seventeen routes, so one
42-
// stands for all of them the way the post above stands for 104.
43-
'/blog/engine/postgresql',
44-
'/faq',
45-
'/get-started',
46-
// Every standalone content page is audited. /playground earns its slot
47-
// twice over: it is the only route with an interactive surface built by
48-
// script at runtime, so it is the one axe-core findings can appear on
49-
// without anyone touching a template.
50-
'/playground',
51-
'/databases',
52-
'/features',
53-
'/open-source',
54-
'/supporters',
55-
'/deploy',
56-
'/docker-compose',
57-
'/libredb-database',
58-
'/security',
59-
'/code-signing-policy',
60-
'/helper',
61-
'/support',
62-
'/platform',
63-
'/compare',
64-
'/privacy-policy',
65-
];
36+
const ROUTES = args.includes('--route') ? args.filter((a, i) => args[i - 1] === '--route') : a11yRoutes;
6637

6738
if (!existsSync(join(DIST, 'index.html'))) {
6839
console.error(`No ${DIST}/index.html — run \`bun run build\` first.`);

‎src/components/home/FlowBand.astro‎

Lines changed: 9 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -164,9 +164,15 @@ const modes = ['out', 'in'] as const;
164164
}
165165

166166
/* "Tool → data" is the working direction: green wire, Studio lands, specs light */
167-
.flow__card[data-switch-active='in'] .diag__line {
168-
background-image: repeating-linear-gradient(90deg, var(--success-text) 0 5px, transparent 5px 10px);
169-
animation-name: lbDashXR;
167+
.flow__card[data-switch-active='in'] .diag {
168+
--diag-dash-color: var(--success-text);
169+
}
170+
/* Running the dashes backwards is a horizontal idea. Below 560px the wire is
171+
drawn top-to-bottom, so it keeps the shared vertical animation instead. */
172+
@media (min-width: 561px) {
173+
.flow__card[data-switch-active='in'] .diag {
174+
--diag-dash-anim: lbDashXR;
175+
}
170176
}
171177
.flow__card[data-switch-active='in'] .diag__chip {
172178
color: var(--success-text);
@@ -180,15 +186,6 @@ const modes = ['out', 'in'] as const;
180186
color: var(--success-text);
181187
}
182188

183-
/* The stacked layout turns the wire vertical, but the rule above is what sets
184-
this state's animation and it outranks the one in diagram.css, so the axis
185-
has to be restated here or the green wire stops moving. */
186-
@media (max-width: 560px) {
187-
.flow__card[data-switch-active='in'] .diag__line {
188-
background-image: repeating-linear-gradient(180deg, var(--success-text) 0 5px, transparent 5px 10px);
189-
animation-name: lbDashY;
190-
}
191-
}
192189
.flow__card[data-switch-active='in'] .flow__spec {
193190
color: var(--text-brand);
194191
border-color: var(--primary-600);

‎src/components/home/WhySection.astro‎

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -163,8 +163,8 @@ import { why } from '../../data/home';
163163
}
164164

165165
/* step 04 is the one that works: the wire turns green and Studio lands */
166-
.why__card[data-switch-active='3'] .diag__line {
167-
background-image: repeating-linear-gradient(90deg, var(--success-text) 0 5px, transparent 5px 10px);
166+
.why__card[data-switch-active='3'] .diag {
167+
--diag-dash-color: var(--success-text);
168168
}
169169
.why__card[data-switch-active='3'] .diag__chip {
170170
color: var(--success-text);
@@ -178,12 +178,6 @@ import { why } from '../../data/home';
178178
color: var(--success-text);
179179
}
180180

181-
@media (max-width: 560px) {
182-
.why__card[data-switch-active='3'] .diag__line {
183-
background-image: repeating-linear-gradient(180deg, var(--success-text) 0 5px, transparent 5px 10px);
184-
}
185-
}
186-
187181
.why__status {
188182
font: var(--weight-regular) 11.5px / 1.5 var(--font-mono);
189183
color: var(--text-tertiary);

‎src/styles/diagram.css‎

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,14 @@
44
arrangement, so the base rules live here; each section keeps only its own
55
state overrides in its scoped <style>.
66
-------------------------------------------------------------------------- */
7+
/* The wire's axis is one decision, not two: the gradient angle and the keyframes
8+
that scroll it have to agree, and when they disagreed the wire sat still while
9+
looking alive. It is decided here, on the container, and inherited. A state
10+
that only recolours the wire sets --diag-dash-color and touches nothing else. */
711
.diag {
12+
--diag-dash-angle: 90deg;
13+
--diag-dash-anim: lbDashX;
14+
--diag-dash-color: var(--error-text);
815
display: flex;
916
align-items: stretch;
1017
}
@@ -64,8 +71,12 @@
6471
top: 50%;
6572
height: 2px;
6673
margin-top: -1px;
67-
background-image: repeating-linear-gradient(90deg, var(--error-text) 0 5px, transparent 5px 10px);
68-
animation: lbDashX 1s linear infinite;
74+
background-image: repeating-linear-gradient(
75+
var(--diag-dash-angle),
76+
var(--diag-dash-color) 0 5px,
77+
transparent 5px 10px
78+
);
79+
animation: var(--diag-dash-anim) 1s linear infinite;
6980
}
7081
.diag__chip {
7182
position: absolute;
@@ -126,6 +137,8 @@
126137

127138
@media (max-width: 560px) {
128139
.diag {
140+
--diag-dash-angle: 180deg;
141+
--diag-dash-anim: lbDashY;
129142
flex-direction: column;
130143
gap: var(--space-04);
131144
}
@@ -144,7 +157,5 @@
144157
width: 2px;
145158
height: auto;
146159
margin: 0 0 0 -1px;
147-
background-image: repeating-linear-gradient(180deg, var(--error-text) 0 5px, transparent 5px 10px);
148-
animation-name: lbDashY;
149160
}
150161
}

‎tests/dist-smoke.test.ts‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -265,3 +265,25 @@ describe('hero product tour', () => {
265265
expect(button?.getAttribute('aria-label')).toBeTruthy();
266266
});
267267
});
268+
269+
/**
270+
* The diagram wire is drawn by a dashed gradient and scrolled by keyframes, and
271+
* the two have to agree on an axis. They were stated together in six places —
272+
* base, mobile, and a green override per section per breakpoint — and one of the
273+
* six was missed: the working state animated along X while drawn along Y, so the
274+
* wire looked alive and sat still. The axis is now set once on .diag and
275+
* inherited; a state that recolours the wire must not restate the gradient.
276+
*/
277+
describe('the diagram wire states its axis once', () => {
278+
it('defines the dashed gradient in exactly one rule', () => {
279+
const gradients = styles().match(/repeating-linear-gradient\(var\(--diag-dash-angle\)/g) ?? [];
280+
expect(gradients.length, 'the wire gradient is defined more than once').toBe(1);
281+
});
282+
283+
it('leaves no state override spelling the gradient out again', () => {
284+
// A literal angle next to a dash colour is the old shape coming back.
285+
const literals =
286+
styles().match(/repeating-linear-gradient\((?:90|180)deg,\s*var\(--(?:error|success)-text\)/g) ?? [];
287+
expect(literals, 'a state restates the gradient instead of setting --diag-dash-color').toEqual([]);
288+
});
289+
});

‎tests/domain.test.ts‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,3 +112,45 @@ describe('the test and production deploys stay separate', () => {
112112
expect(netlify).toMatch(/NETLIFY_AUTH_TOKEN:\s*\$\{\{\s*secrets\.NETLIFY_AUTH_TOKEN\s*\}\}/);
113113
});
114114
});
115+
116+
/**
117+
* The Lighthouse gate audits a list of routes, and that list used to be typed
118+
* out by hand. That is the same second-copy-of-a-list this file exists to
119+
* forbid, and it failed the same way: /code-signing-policy had to be added in a
120+
* follow-up commit, and /helper plus seventeen engine archives shipped in
121+
* v1.3.0 with no audit at all, because adding a page to site.config.json does
122+
* not add it to a list that lives somewhere else.
123+
*
124+
* The list is now derived, so these assert the derivation against dist rather
125+
* than against another literal.
126+
*/
127+
describe('the accessibility gate audits what the site actually builds', () => {
128+
const built: string[] = [];
129+
const walk = (dir: string, prefix = '') => {
130+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
131+
if (entry.isDirectory()) walk(`${dir}/${entry.name}`, `${prefix}/${entry.name}`);
132+
else if (entry.name === 'index.html') built.push(prefix || '/');
133+
else if (entry.name === '404.html') built.push('/404');
134+
}
135+
};
136+
walk('dist');
137+
138+
it('covers every standalone page, so a new one cannot ship unaudited', async () => {
139+
const { a11yRoutes } = await import('../scripts/a11y-routes.mjs');
140+
// Blog posts and engine archives are templates over many routes; they are
141+
// covered by a representative below, not one entry per post.
142+
const standalone = built.filter((r) => !r.startsWith('/blog/') && !redirectPaths.includes(r));
143+
const missing = standalone.filter((r) => !a11yRoutes.includes(r));
144+
expect(missing, 'these pages build but are never audited').toEqual([]);
145+
});
146+
147+
it('audits one representative of each templated family', async () => {
148+
const { a11yRoutes } = await import('../scripts/a11y-routes.mjs');
149+
for (const family of ['/blog/', '/blog/engine/']) {
150+
const audited = a11yRoutes.filter((r: string) => r.startsWith(family) && r !== family.replace(/\/$/, ''));
151+
expect(audited.length, `no ${family} page is audited`).toBeGreaterThan(0);
152+
// A representative that no longer builds audits nothing.
153+
for (const route of audited) expect(built, `${route} is audited but not built`).toContain(route);
154+
}
155+
});
156+
});

0 commit comments

Comments
 (0)