create-website-build-kit 0.1.1 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,14 +1,22 @@
1
- # create-website-build-kit
1
+ <p align="center">
2
+ <img src="https://raw.githubusercontent.com/nurkamol/website-build-kit/master/site/brand/mark-light.png" width="72" height="72" alt="">
3
+ </p>
2
4
 
3
- Scaffold a production marketing site: **Astro, static, on Cloudflare Workers** — with the
4
- gates, the migration playbook and the accessibility work already wired.
5
+ <h1 align="center">create-website-build-kit</h1>
6
+
7
+ <p align="center"><em>Your build was green. The site was wrong.</em></p>
5
8
 
6
9
  ```bash
7
10
  npm create website-build-kit@latest my-site
8
11
  ```
9
12
 
13
+ Scaffold a production marketing site: **Astro, static, on Cloudflare Workers** — with the
14
+ gates, the migration playbook and the accessibility work already wired.
15
+
10
16
  Node 22.12+. It builds green immediately, with no content, no images and no secrets.
11
17
 
18
+ **[The method, the traps and the docs →](https://nurkamol.github.io/website-build-kit/)**
19
+
12
20
  ## What you get
13
21
 
14
22
  A skeleton, not a theme. It ships **no palette, no typeface and no home page** — deliberately,
@@ -60,6 +68,7 @@ off Elementor."*
60
68
  Behind it is the written method, including a file of failures that were **green in a build**:
61
69
  clean build, clean types, clean deploy, wrong result.
62
70
 
63
- **https://github.com/nurkamol/website-build-kit**
71
+ - **Site:** <https://nurkamol.github.io/website-build-kit/>
72
+ - **Source:** <https://github.com/nurkamol/website-build-kit>
64
73
 
65
74
  MIT.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-website-build-kit",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "Scaffold a production marketing site \u2014 Astro on Cloudflare Workers, with the gates, the migration playbook and the accessibility work already wired.",
5
5
  "keywords": [
6
6
  "astro",
@@ -15,7 +15,7 @@
15
15
  "wcag",
16
16
  "seo"
17
17
  ],
18
- "homepage": "https://github.com/nurkamol/website-build-kit#readme",
18
+ "homepage": "https://nurkamol.github.io/website-build-kit/",
19
19
  "bugs": "https://github.com/nurkamol/website-build-kit/issues",
20
20
  "repository": {
21
21
  "type": "git",
@@ -1 +1 @@
1
- 24.2.0
1
+ 24.19.0
@@ -1,19 +1,32 @@
1
1
  {
2
2
  "defaults": {
3
3
  "standard": "WCAG2AA",
4
- "runners": ["axe", "htmlcs"],
4
+ "runners": [
5
+ "axe",
6
+ "htmlcs"
7
+ ],
5
8
  "timeout": 30000,
6
- "concurrency": 4,
9
+ "concurrency": 1,
7
10
  "hideElements": "iframe[src*='google.com/maps'], iframe[src*='youtube.com']",
8
- "chromeLaunchConfig": { "args": ["--no-sandbox"] }
11
+ "chromeLaunchConfig": {
12
+ "args": [
13
+ "--no-sandbox"
14
+ ]
15
+ }
9
16
  },
10
17
  "//": [
11
- "One URL per template family, not every page a homepage-only pass misses",
18
+ "One URL per template family, not every page \u2014 a homepage-only pass misses",
12
19
  "everything the blog does differently. Point these at `wrangler dev`",
13
20
  "(localhost:8788) while building, or at the deployed staging host.",
14
21
  "Staging emits no sitemap on purpose, which is why these are explicit.",
15
22
  "Against production you can instead run:",
16
- " npx pa11y-ci --sitemap https://example.com/sitemap-index.xml --standard WCAG2AA"
23
+ " npx pa11y-ci --sitemap https://example.com/sitemap-index.xml --standard WCAG2AA",
24
+ "",
25
+ "concurrency is 1 DELIBERATELY. Above 1, pa11y intermittently reports",
26
+ "'Failed to run \u2014 Protocol error (Target.closeTarget)' on a random URL, which",
27
+ "reads as an accessibility failure and is Chrome tearing down targets.",
28
+ "Measured on this template: 4/5 runs failed at concurrency 4, 5/5 at 2, 0/5 at 1.",
29
+ "A gate that is wrong most of the time is a gate people learn to ignore."
17
30
  ],
18
31
  "urls": [
19
32
  "http://localhost:8788/",
@@ -182,7 +182,7 @@ perfectly, on time, to an empty room.
182
182
  revealing everything only if the observer never fired at all — which is the
183
183
  failure the guard actually exists for.
184
184
 
185
- ### A the booking vendor plugin that renders nothing because of a missing attribute
185
+ ### The booking vendor's plugin renders nothing, because of a missing attribute
186
186
 
187
187
  `/schedule/` was blank. The embed had been carried over from the WordPress site
188
188
  before the booking vendor's snippet gained `locale="en"`. Without it the plugin loads,
@@ -192,13 +192,13 @@ container.
192
192
  **Symptom:** no console error, no failed request, a populated network tab and
193
193
  an empty div.
194
194
 
195
- **Fix:** diff the embed against a freshly generated snippet from the the booking vendor
195
+ **Fix:** diff the embed against a freshly generated snippet from the booking vendor
196
196
  dashboard rather than debugging the plugin. Any third-party embed carried
197
197
  across a migration deserves the same check.
198
198
 
199
199
  ### A third-party widget that changes your own CSS
200
200
 
201
- Enabling the the booking vendor webchat took three pages from 0 accessibility errors to
201
+ Enabling the booking vendor's webchat took three pages from 0 accessibility errors to
202
202
  failing. It injects a stylesheet into the page. Separately, its fixed overlay
203
203
  makes axe report colour-contrast violations on elements it covers, which look
204
204
  identical to real failures — `htmlcs` reported nothing and the computed colours
@@ -418,7 +418,7 @@ site, which is what `docs/runbook.md` asks for.
418
418
 
419
419
  ### "It's inside a cross-origin iframe" is not the same as "it cannot be changed"
420
420
 
421
- The client asked twice for the the booking vendor booking panel's white ground to match
421
+ The client asked twice for the booking vendor's panel to match
422
422
  the cream page. The white comes from `html, body, #root { background-color:
423
423
  #ffffff }` inside vendor.com's own document — cross-origin, unreachable, and
424
424
  that was reported back as "the only lever is the booking vendor's branding settings."
@@ -901,3 +901,71 @@ grep -rnE '[a-z](https?://|[A-Z][a-z])' src/content/ | grep -vE 'iPhone|YouTube|
901
901
  ```
902
902
 
903
903
  Expect a few false positives from camelCase and brand names; read them.
904
+
905
+ ### The accessibility gate fails on a random URL, and it is not accessibility
906
+
907
+ **Symptom:** `npm run a11y` reports `Failed to run` against one URL, a different
908
+ one each time, with `Error: Protocol error (Target.closeTarget): No target with
909
+ given id found`. It reads as an accessibility failure on that page. The page is
910
+ fine, and a re-run usually blames a different page — or passes.
911
+
912
+ It is Chrome tearing down browser targets while another is still closing, and it
913
+ is driven entirely by pa11y's `concurrency`. Measured on this template, five runs
914
+ at each setting:
915
+
916
+ | `concurrency` | Runs that failed |
917
+ | --- | --- |
918
+ | 4 | **4 / 5** |
919
+ | 2 | **5 / 5** |
920
+ | 1 | **0 / 5** |
921
+
922
+ Note that 2 was no better than 4 — this is a race in target teardown, not a
923
+ resource limit, so lowering the number without going to 1 buys nothing.
924
+
925
+ **Fix:** `"concurrency": 1` in `.pa11yci.json`. Four URLs sequentially is a few
926
+ seconds; a gate that is wrong four times in five is a gate the team learns to
927
+ ignore, and then it is worse than no gate because its silence means nothing.
928
+
929
+ Worth knowing before you go hunting: this got *more* visible when the run started
930
+ covering both colour schemes, because that doubles the number of Chrome sessions
931
+ and so doubles the chances of hitting the race. The change that surfaced it was
932
+ not the change that caused it.
933
+
934
+ ### A script throws `ReferenceError` for something nothing ever imported
935
+
936
+ **Symptom:** `npm run recon` runs the whole crawl, prints its sitemap and URL
937
+ sections, and then dies at the last one:
938
+
939
+ ```
940
+ const PRESERVE = PRESERVED;
941
+ ^
942
+ ReferenceError: PRESERVED is not defined
943
+ ```
944
+
945
+ The file imports two things and uses a third. It shipped in a published package
946
+ and a user hit it on a real migration, on Windows, on the first command the
947
+ documentation tells you to run.
948
+
949
+ **Why nothing caught it.** This is the important part, because the instinct is
950
+ that surely *something* would have:
951
+
952
+ | | |
953
+ | --- | --- |
954
+ | `node --check` | Parses. An undefined identifier is **valid syntax** |
955
+ | `astro check` | Types `.astro` and `.ts`. The scripts are standalone `.mjs` |
956
+ | CI | Runs the build. `recon` needs a live site, so CI never runs it |
957
+ | Smoke-running it | The throw is on line 302, reached only after the crawl — tested, and a `--help` load-check passes the broken file |
958
+
959
+ **Fix:** import it. The real fix is the gate — `npm run check:refs` cross-checks
960
+ every name `scripts/lib/*.mjs` exports against every script that uses one, and
961
+ fails when a use has no import.
962
+
963
+ **The first version of that gate was worse than none.** It flagged every
964
+ SCREAMING_CASE identifier that was never bound, and produced seven false
965
+ positives on a clean tree: `WCAG` and `CAA` in prose, `ERR_ABORTED` inside a
966
+ regex literal, `AND` in a comment. Stripping comments and strings with regexes
967
+ is a losing game without a parser. Narrowing it to names the libs actually
968
+ export removed the guesswork — prose never collides with a real export.
969
+
970
+ **A checker with false positives gets switched off, and then its silence means
971
+ "nobody looked" rather than "nothing wrong".**
@@ -10,7 +10,7 @@
10
10
  "scripts": {
11
11
  "dev": "astro dev",
12
12
  "check": "astro check",
13
- "a11y": "pa11y-ci --config .pa11yci.json",
13
+ "a11y": "node scripts/check-a11y.mjs",
14
14
  "a11y:evidence": "node scripts/a11y-evidence.mjs",
15
15
  "reflow": "node scripts/check-reflow.mjs",
16
16
  "console": "node scripts/check-console.mjs",
@@ -28,9 +28,13 @@
28
28
  */
29
29
 
30
30
  import { execFileSync } from 'node:child_process';
31
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
31
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
32
+ import { tmpdir } from 'node:os';
33
+ import { join } from 'node:path';
32
34
  import { createRequire } from 'node:module';
33
35
 
36
+ import { SCHEMES, configForScheme } from './lib/schemes.mjs';
37
+
34
38
  const require = createRequire(import.meta.url);
35
39
 
36
40
  const RESET = '';
@@ -72,46 +76,86 @@ const config = JSON.parse(readFileSync('.pa11yci.json', 'utf8'));
72
76
  const standard = config.defaults?.standard ?? 'WCAG2AA';
73
77
  const runners = config.defaults?.runners ?? ['htmlcs'];
74
78
 
75
- const args = host
76
- ? ['pa11y-ci', '--sitemap', `${host}/sitemap-index.xml`, '--standard', standard, '--json']
77
- : ['pa11y-ci', '--config', '.pa11yci.json', '--json'];
79
+ /*
80
+ * EVERY SCHEME, because the pack is a COMPLIANCE ARTEFACT.
81
+ *
82
+ * pa11y drives Chrome, and Chrome picks a colour scheme from the machine it
83
+ * runs on. A site with a light and a dark palette has two sets of contrast
84
+ * pairs, so a single run measures one and says nothing about the other —
85
+ * measured on the kit's own landing page at 4.83:1 dark and **3.91:1 light**,
86
+ * a real AA failure in the half nobody tested.
87
+ *
88
+ * A red CI step from that is a nuisance. A dated evidence pack claiming a site
89
+ * was tested when half of it was not is the thing somebody hands to a lawyer.
90
+ * See scripts/lib/schemes.mjs.
91
+ */
92
+ /*
93
+ * ⚠ `shell: true` ON WINDOWS, and it is not optional there. `npx` is
94
+ * `npx.cmd`, and execFileSync does not resolve .cmd without a shell — it
95
+ * fails ENOENT, which reads as "npx is not installed" on a machine where it
96
+ * plainly is. Left off on POSIX, where a shell buys nothing and costs quoting.
97
+ */
98
+ const WIN = process.platform === 'win32';
99
+
100
+ const tmp = mkdtempSync(join(tmpdir(), 'a11y-evidence-'));
101
+
102
+ const runScheme = (scheme) => {
103
+ const file = join(tmp, `${scheme}.json`);
104
+ writeFileSync(file, JSON.stringify(configForScheme(config, scheme), null, 2));
105
+
106
+ /* --config supplies the forced-scheme defaults either way; --sitemap only
107
+ replaces where the URL list comes from. */
108
+ const args = host
109
+ ? ['pa11y-ci', '--config', file, '--sitemap', `${host}/sitemap-index.xml`, '--standard', standard, '--json']
110
+ : ['pa11y-ci', '--config', file, '--json'];
78
111
 
79
- let report;
80
- try {
81
- /* pa11y-ci exits non-zero when it finds errors, and still prints the JSON.
82
- A non-zero exit here is a RESULT, not a failure to run. */
83
- const out = execFileSync('npx', args, { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
84
- report = JSON.parse(out);
85
- } catch (error) {
86
- const out = error.stdout?.toString() ?? '';
87
112
  try {
88
- report = JSON.parse(out);
89
- } catch {
90
- console.error(
91
- `${RED}✗${RESET} pa11y-ci could not run.\n` +
92
- ` ${DIM}Start the site first: npm run build:staging && npx wrangler dev${RESET}\n` +
93
- ` ${(error.stderr?.toString() ?? '').slice(0, 400)}`,
113
+ /* pa11y-ci exits non-zero when it finds errors, and still prints the JSON.
114
+ A non-zero exit here is a RESULT, not a failure to run. */
115
+ return JSON.parse(
116
+ execFileSync('npx', args, { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, shell: WIN }),
94
117
  );
95
- process.exit(1);
118
+ } catch (error) {
119
+ const out = error.stdout?.toString() ?? '';
120
+ try {
121
+ return JSON.parse(out);
122
+ } catch {
123
+ console.error(
124
+ `${RED}✗${RESET} pa11y-ci could not run.\n` +
125
+ ` ${DIM}Start the site first: npm run build:staging && npx wrangler dev${RESET}\n` +
126
+ ` ${(error.stderr?.toString() ?? '').slice(0, 400)}`,
127
+ );
128
+ process.exit(1);
129
+ }
96
130
  }
97
- }
131
+ };
98
132
 
99
- const rows = Object.entries(report.results).map(([url, issues]) => ({
100
- url,
101
- errors: issues.filter((i) => i.type === 'error'),
102
- warnings: issues.filter((i) => i.type === 'warning'),
103
- notices: issues.filter((i) => i.type === 'notice'),
104
- }));
105
-
106
- for (const r of rows) {
107
- const mark = r.errors.length ? `${RED}✗${RESET}` : `${GREEN}✓${RESET}`;
108
- const path = new URL(r.url).pathname;
109
- console.log(
110
- ` ${mark} ${path.padEnd(28)} ${String(r.errors.length).padStart(2)} error(s) ` +
111
- `${DIM}${r.warnings.length} warning(s)${RESET}`,
112
- );
133
+ const passes = SCHEMES.map((scheme) => {
134
+ const report = runScheme(scheme);
135
+ const rows = Object.entries(report.results).map(([url, issues]) => ({
136
+ url,
137
+ errors: issues.filter((i) => i.type === 'error'),
138
+ warnings: issues.filter((i) => i.type === 'warning'),
139
+ notices: issues.filter((i) => i.type === 'notice'),
140
+ }));
141
+ return { scheme, rows };
142
+ });
143
+
144
+ for (const { scheme, rows: schemeRows } of passes) {
145
+ console.log(` ${BOLD}${scheme}${RESET}`);
146
+ for (const r of schemeRows) {
147
+ const mark = r.errors.length ? `${RED}✗${RESET}` : `${GREEN}✓${RESET}`;
148
+ const path = new URL(r.url).pathname;
149
+ console.log(
150
+ ` ${mark} ${path.padEnd(26)} ${String(r.errors.length).padStart(2)} error(s) ` +
151
+ `${DIM}${r.warnings.length} warning(s)${RESET}`,
152
+ );
153
+ }
113
154
  }
114
155
 
156
+ /* Kept for the sections below that report per-URL rather than per-scheme. */
157
+ const rows = passes[0].rows;
158
+
115
159
  /* ── 2. Reflow, which pa11y does not cover ───────────────────────────────── */
116
160
 
117
161
  console.log(`\n${BOLD}── reflow · 320px + 200% ${'─'.repeat(35)}${RESET}`);
@@ -152,14 +196,17 @@ if (reviewed) {
152
196
 
153
197
  /* ── 4. The pack ─────────────────────────────────────────────────────────── */
154
198
 
155
- const totalErrors = rows.reduce((n, r) => n + r.errors.length, 0);
199
+ const totalErrors = passes.reduce(
200
+ (n, pass) => n + pass.rows.reduce((m, r) => m + r.errors.length, 0),
201
+ 0,
202
+ );
156
203
  const target = host ?? 'http://localhost:8788 (wrangler dev)';
157
204
 
158
- const issueLines = rows
159
- .filter((r) => r.errors.length)
205
+ const issueLines = passes
206
+ .flatMap((pass) => pass.rows.filter((r) => r.errors.length).map((r) => ({ ...r, scheme: pass.scheme })))
160
207
  .map(
161
208
  (r) =>
162
- `### ${new URL(r.url).pathname}\n\n` +
209
+ `### ${new URL(r.url).pathname} — ${r.scheme}\n\n` +
163
210
  r.errors
164
211
  .map(
165
212
  (e) =>
@@ -189,18 +236,23 @@ Standard: **${standard}** · runners: ${runners.join(', ')}
189
236
 
190
237
  ---
191
238
 
192
- ## 1. Automated — one URL per template family
239
+ ## 1. Automated — one URL per template family, in both colour schemes
193
240
 
194
- | Page | Errors | Warnings | Notices |
195
- | --- | --- | --- | --- |
196
- ${rows
197
- .map(
198
- (r) =>
199
- `| \`${new URL(r.url).pathname}\` | ${r.errors.length} | ${r.warnings.length} | ${r.notices.length} |`,
241
+ Each page is measured twice, with \`prefers-color-scheme\` forced. A light and a dark
242
+ palette are two sets of contrast pairs, and a single run measures one of them.
243
+
244
+ | Page | Scheme | Errors | Warnings | Notices |
245
+ | --- | --- | --- | --- | --- |
246
+ ${passes
247
+ .flatMap((pass) =>
248
+ pass.rows.map(
249
+ (r) =>
250
+ `| \`${new URL(r.url).pathname}\` | ${pass.scheme} | ${r.errors.length} | ${r.warnings.length} | ${r.notices.length} |`,
251
+ ),
200
252
  )
201
253
  .join('\n')}
202
254
 
203
- **${totalErrors} error(s) across ${rows.length} page(s).**
255
+ **${totalErrors} error(s) across ${rows.length} page(s) × ${passes.length} scheme(s).**
204
256
 
205
257
  ${issueLines || '_No errors at this standard._'}
206
258
 
@@ -0,0 +1,86 @@
1
+ /**
2
+ * pa11y-ci at WCAG 2.2 AA, in BOTH colour schemes.
3
+ *
4
+ * npm run a11y # every URL in .pa11yci.json
5
+ * npm run a11y -- --only=light # one scheme
6
+ *
7
+ * ── WHY NOT JUST `pa11y-ci --config .pa11yci.json` ─────────────────────────
8
+ * Because that measures whichever scheme the machine happens to be in. See
9
+ * scripts/lib/schemes.mjs — the kit's own landing page passed a local run in
10
+ * dark mode and failed CI in light on a genuine 3.91:1 contrast pair.
11
+ *
12
+ * ── ONE SOURCE FOR THE URLS ────────────────────────────────────────────────
13
+ * `.pa11yci.json` stays the only place the URL list lives. The scheme is
14
+ * injected into a copy at run time rather than kept as a second config file,
15
+ * because two config files is two URL lists and one of them goes stale.
16
+ */
17
+
18
+ import { execFileSync } from 'node:child_process';
19
+ import { existsSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
20
+ import { tmpdir } from 'node:os';
21
+ import { join } from 'node:path';
22
+
23
+ import { SCHEMES, configForScheme } from './lib/schemes.mjs';
24
+
25
+ const RESET = '\x1b[0m';
26
+ const RED = '\x1b[31m';
27
+ const GREEN = '\x1b[32m';
28
+ const DIM = '\x1b[2m';
29
+ const BOLD = '\x1b[1m';
30
+
31
+ /*
32
+ * ⚠ `shell: true` ON WINDOWS, and it is not optional there. `npx` is
33
+ * `npx.cmd`, and execFileSync does not resolve .cmd without a shell — it
34
+ * fails ENOENT, which reads as "npx is not installed" on a machine where it
35
+ * plainly is. Left off on POSIX, where a shell buys nothing and costs quoting.
36
+ */
37
+ const WIN = process.platform === 'win32';
38
+
39
+ const CONFIG = '.pa11yci.json';
40
+ if (!existsSync(CONFIG)) {
41
+ console.error(`${RED}✗${RESET} ${CONFIG} not found — run this from the site root.`);
42
+ process.exit(1);
43
+ }
44
+
45
+ const args = process.argv.slice(2);
46
+ const only = (args.find((a) => a.startsWith('--only=')) ?? '').replace('--only=', '');
47
+ const schemes = only ? SCHEMES.filter((s) => s === only) : SCHEMES;
48
+
49
+ if (!schemes.length) {
50
+ console.error(`${RED}✗${RESET} --only=${only} is not a scheme. Try: ${SCHEMES.join(', ')}`);
51
+ process.exit(1);
52
+ }
53
+
54
+ const base = JSON.parse(readFileSync(CONFIG, 'utf8'));
55
+ const urlCount = (base.urls ?? []).length;
56
+ const dir = mkdtempSync(join(tmpdir(), 'a11y-'));
57
+
58
+ console.log(`${BOLD}── Accessibility · WCAG2AA ${'─'.repeat(33)}${RESET}`);
59
+ console.log(` ${DIM}${urlCount} URL(s) × ${schemes.length} scheme(s)${RESET}\n`);
60
+
61
+ let failed = 0;
62
+
63
+ for (const scheme of schemes) {
64
+ const file = join(dir, `${scheme}.json`);
65
+ writeFileSync(file, JSON.stringify(configForScheme(base, scheme), null, 2));
66
+
67
+ console.log(`${BOLD}${scheme}${RESET}`);
68
+ try {
69
+ execFileSync('npx', ['pa11y-ci', '--config', file], { stdio: 'inherit', shell: WIN });
70
+ console.log(` ${GREEN}✓${RESET} ${scheme} clean\n`);
71
+ } catch {
72
+ failed++;
73
+ console.log(` ${RED}✗${RESET} ${scheme} has errors\n`);
74
+ }
75
+ }
76
+
77
+ if (failed) {
78
+ console.error(`${RED}✗ ${failed} of ${schemes.length} scheme(s) failed${RESET}\n`);
79
+ process.exit(1);
80
+ }
81
+
82
+ console.log(
83
+ `${GREEN}✓ clean in ${schemes.join(' and ')}${RESET}` +
84
+ (schemes.length === 1 ? `\n ${DIM}only one scheme measured — the other is untested${RESET}` : '') +
85
+ '\n',
86
+ );
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Colour schemes an accessibility run has to cover.
3
+ *
4
+ * ── WHY THIS EXISTS ────────────────────────────────────────────────────────
5
+ * A site with a light and a dark palette has TWO sets of contrast pairs, and
6
+ * testing one proves nothing about the other. pa11y drives Chrome, and Chrome
7
+ * picks a scheme from the machine it runs on — so the same page passes on a
8
+ * developer's laptop in dark mode and fails in CI in light, from one commit,
9
+ * with nothing to say which half was measured.
10
+ *
11
+ * That is not hypothetical. The kit's own landing page passed locally and
12
+ * failed the moment it ran on a light-mode runner:
13
+ *
14
+ * --ink-3 on the page background 4.83:1 dark · 3.91:1 light
15
+ *
16
+ * A real AA failure, in the half nobody happened to test. Forcing the scheme
17
+ * removes the luck.
18
+ *
19
+ * ── IT RUNS BOTH EVEN WITH NO DARK MODE ────────────────────────────────────
20
+ * Deliberately. A fresh template ships no `prefers-color-scheme` rule at all,
21
+ * so the two runs are identical and cost a few seconds. The day somebody adds
22
+ * a dark palette is exactly the day the blind spot would otherwise open, and
23
+ * a check you have to remember to switch on is a check that is off.
24
+ */
25
+
26
+ export const SCHEMES = ['light', 'dark'];
27
+
28
+ /**
29
+ * A pa11y-ci config with the colour scheme pinned.
30
+ *
31
+ * `--force-prefers-color-scheme` makes Chrome answer the media query the same
32
+ * way on every machine, which is the only reason a contrast result is
33
+ * comparable between a laptop and a runner.
34
+ */
35
+ export function configForScheme(base, scheme) {
36
+ const defaults = { ...(base.defaults ?? {}) };
37
+ const launch = { ...(defaults.chromeLaunchConfig ?? {}) };
38
+ const args = (launch.args ?? []).filter((a) => !a.startsWith('--force-prefers-color-scheme'));
39
+
40
+ return {
41
+ ...base,
42
+ defaults: {
43
+ ...defaults,
44
+ chromeLaunchConfig: { ...launch, args: [...args, `--force-prefers-color-scheme=${scheme}`] },
45
+ },
46
+ };
47
+ }
@@ -64,6 +64,14 @@ function have(bin, args = ['--version']) {
64
64
  }
65
65
  }
66
66
 
67
+ /** The install line for the platform this is actually running on. */
68
+ const hint = (brew, winget, apt) =>
69
+ process.platform === 'win32'
70
+ ? `winget install ${winget}`
71
+ : process.platform === 'linux'
72
+ ? `sudo apt install ${apt}`
73
+ : `brew install ${brew}`;
74
+
67
75
  function preflight() {
68
76
  const missing = [];
69
77
 
@@ -83,9 +91,12 @@ function preflight() {
83
91
  }
84
92
 
85
93
  const tools = [
86
- ['magick', ['-version'], 'ImageMagick', 'brew install imagemagick'],
87
- ['rsvg-convert', ['--version'], 'rsvg-convert', 'brew install librsvg'],
88
- ['python3', ['--version'], 'Python 3', 'brew install python'],
94
+ /* Per-platform. "brew install" on Windows is not a hint, it is a dead
95
+ end and this preflight exists precisely so a missing tool names its
96
+ own fix. */
97
+ ['magick', ['-version'], 'ImageMagick', hint('imagemagick', 'ImageMagick.ImageMagick', 'imagemagick')],
98
+ ['rsvg-convert', ['--version'], 'rsvg-convert', hint('librsvg', 'GNOME.Librsvg', 'librsvg2-bin')],
99
+ ['python3', ['--version'], 'Python 3', hint('python', 'Python.Python.3.12', 'python3')],
89
100
  ];
90
101
  for (const [bin, args, label, install] of tools) {
91
102
  if (!have(bin, args)) {
@@ -28,6 +28,7 @@
28
28
  import { mkdirSync, writeFileSync } from 'node:fs';
29
29
 
30
30
  import { GONE_TAG } from './lib/inventory.mjs';
31
+ import { PRESERVED } from './lib/preserved.mjs';
31
32
 
32
33
  const RESET = '';
33
34
  const RED = '';
@@ -299,11 +300,9 @@ if (goneList.length) {
299
300
  /* ── 3. Paths other systems point at ──────────────────────────────────── */
300
301
  section('Preserved paths');
301
302
 
302
- const PRESERVE = PRESERVED;
303
-
304
303
  /* Manual redirects again: "serves a feed" and "301s to a feed" are different
305
304
  facts, and only the first means the path must be reproduced. */
306
- const preserved = await pool(PRESERVE, async ([path, why]) => {
305
+ const preserved = await pool(PRESERVED, async ([path, why]) => {
307
306
  const r = await req(`${ORIGIN}${path}`, { method: 'HEAD', redirect: 'manual' });
308
307
  return { path, why, status: r?.status ?? 0, location: r?.headers.get('location') ?? '' };
309
308
  });
@@ -1 +1,5 @@
1
- {}
1
+ {
2
+ "/": "2026-08-26",
3
+ "/accessibility/": "2026-08-26",
4
+ "/contact/": "2026-08-26"
5
+ }