feat: build steps 7a-10 — the site is complete and reviewable at 22 pages

Steps 7a through 10 as one authorised run. Nothing deployed (D11).

7a  Lighthouse returns as `lighthouse@13.4.1` + `chrome-launcher`, NOT
    `@lhci/cli`. AGENTS.md §7's advisory attribution was wrong: the carriers
    were @lhci/cli's own `tmp` and @puppeteer/browsers' `extract-zip`, not
    Lighthouse, which audits clean. A deliberate deviation from R11's literal
    trigger, recorded with what it costs. Local gate; CI has no Chrome.

7b  OG card generator (satori + sharp) discharges R15 — 20 typed cards plus
    per-article cards; the portrait stays on / and /about/ by Q40. Insights
    plumbing: ArticleCard, Prose, the index, the article route, articleGraph,
    and /'s section 7. Card copy is constrained structurally because text in a
    JPEG cannot be grepped by check:claims: every headline IS its page's <h1>,
    enforced by `npm run og:proof`.

7c  Five drafted launch articles, draft: true / reviewedByPouya: false. An
    independent compliance audit returned 76 findings and 57 unsourced
    assertions; all blocking and should-fix applied.

8   /contact/, the intake form, and backend/intake/ (undeployed). Plain HTML
    POST to a same-origin /api/intake with a 303 redirect, so the form works
    with zero JavaScript. docs/05 records three deliberate deviations.

9   /fees/ on Q59's ruling — overtime runs from the session cap, and the
    reservation point ships adjacent to the rate. One-page PDF bio discharges
    R16; /bio/ is its source, so the circulated artefact stays inside the
    review apparatus.

10  /legal/privacy/ and /legal/terms/, written to the backend as built. Three
    of the policy's statements are derived and cannot drift.

Also: /about/'s inverse credentials band (approved at step 6); Q59 closed;
R15 and R16 discharged; and a fix to shipped copy — /practice/energy/ asserted
the absence of a regulation the source extract says must not be asserted.

Review: adversarial-reviewer, two rounds (D20/D19). Round 1 returned 16
findings including two blocking — an invisible ghost button on /fees/ at
1.00:1 that Lighthouse scored 100, and a privacy policy that named one data
processor when there are two. All 16 acted on.

Lighthouse, 22 pages, mobile: performance 99-100, accessibility 100,
best practices 100, SEO 100 on every indexable page, CLS 0.000.

AGENTS.md entry (ah) has the detail, including four of my own verification
commands that were wrong and what each of them nearly caused.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Md3GndFqWPzK78xAoebsg5
This commit is contained in:
Pouya Lajevardi
2026-08-31 10:56:54 -04:00
co-authored by Claude Opus 5
parent 6cfe69033f
commit 210bc25a26
53 changed files with 8589 additions and 177 deletions
+205
View File
@@ -0,0 +1,205 @@
#!/usr/bin/env node
/**
* Renders `/bio/` to `public/pouya-lajevardi-bio.pdf`. `npm run bio:pdf`,
* after `npm run build`. Discharges `AGENTS.md` R16 / Q45.
*
* WHY A LOCAL SCRIPT AND NOT A BUILD STEP. It drives Chrome, and the Gitea
* runner has none (`AGENTS.md` §7, Q23) — the same reason `npm run lighthouse`
* is a local gate. A build step that cannot run in CI is a control that exists
* on paper, which is the shape Q22 turned out to be. So the PDF is **committed**:
* the artefact is in the repository, which is also what R14 asks for.
*
* ⚠️ IT IS NOT BYTE-REPRODUCIBLE, AND AN EARLIER VERSION OF THIS COMMENT SAID
* "deterministically". Two consecutive runs produced 89,496 bytes both times and
* DIFFERENT SHA-256 digests — Chrome stamps a `/CreationDate` into the document.
* Measured by `adversarial-reviewer`, 2026-08-31.
*
* The consequence is not cosmetic: the "regenerate and re-commit the PDF" item on
* `docs/06`'s cutover checklist therefore always produces a binary diff, so a
* reviewer cannot tell a real content change from a no-op re-render. Do not
* re-commit it out of habit — re-commit it when `/bio/`, §4, the rate card or the
* print styles actually changed, and say which in the commit message.
*
* WHY THE PDF IS A RENDERING OF A PAGE RATHER THAN A DESIGNED DOCUMENT. R16's
* worry was never tooling: *"a PDF circulated with an appointment proposal is
* read once, by the reader who matters most, and never seen by a reviewer
* again."* Rendering it from `/bio/` puts it back inside this project's review
* apparatus — `astro check`, `check:claims` on the built HTML, the adversarial
* review and the cutover claims pass all see every word of it, because every
* word of it is on a page. (That is what caught `/bio/` opening with a clause
* that scoped mediation commercial, which Q56 forbids.)
*
* ⚠️ IT ASSERTS ONE PAGE. A one-page bio that silently becomes two is the defect
* this script exists to catch, and it is invisible from the source: it depends on
* the print stylesheet, the paper size, and how much §4 has grown since anyone
* looked. `printBackground: false` matches Chrome's own default print dialog,
* where "Background graphics" is unchecked — `global.css` records what that did
* to `/about/`'s inverse band when nobody checked.
*/
import { createServer } from 'node:http';
import { createReadStream } from 'node:fs';
import { writeFile, stat } from 'node:fs/promises';
import { join, extname } from 'node:path';
import * as chromeLauncher from 'chrome-launcher';
const ROOT = process.cwd();
const DIST = join(ROOT, 'dist');
const OUT = join(ROOT, 'public', 'pouya-lajevardi-bio.pdf');
const MIME = {
'.html': 'text/html; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.png': 'image/png',
'.avif': 'image/avif',
'.webp': 'image/webp',
'.woff2': 'font/woff2',
'.ico': 'image/x-icon',
'.svg': 'image/svg+xml',
};
try {
await stat(join(DIST, 'bio', 'index.html'));
} catch {
console.error(
'dist/bio/index.html is missing. Run `npm run build` first — this renders ' +
'the BUILT page, not the dev server, so what ships is what is measured.',
);
process.exit(2);
}
const server = createServer((req, res) => {
const pathname = decodeURIComponent(new URL(req.url, 'http://x').pathname);
const file = pathname.endsWith('/')
? join(DIST, pathname, 'index.html')
: join(DIST, pathname);
const stream = createReadStream(file);
stream.on('error', () => {
res.writeHead(404);
res.end('404');
});
stream.once('open', () => {
res.writeHead(200, {
'content-type': MIME[extname(file)] ?? 'application/octet-stream',
});
stream.pipe(res);
});
});
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
const port = server.address().port;
const chrome = await chromeLauncher.launch({
chromeFlags: ['--headless', '--no-sandbox', '--disable-gpu'],
});
/** Minimal CDP client over the DevTools WebSocket. `chrome-launcher` starts the
* browser and does not speak the protocol; adding a client library for four
* calls would be a dependency for nothing. */
async function cdp(port, fn) {
const list = await fetch(`http://127.0.0.1:${port}/json/list`).then((r) =>
r.json(),
);
const target = list.find((t) => t.type === 'page');
if (!target) throw new Error('no page target in Chrome');
const ws = new WebSocket(target.webSocketDebuggerUrl);
await new Promise((resolve, reject) => {
ws.addEventListener('open', resolve, { once: true });
ws.addEventListener('error', reject, { once: true });
});
let id = 0;
const pending = new Map();
const events = new Map();
ws.addEventListener('message', (event) => {
const message = JSON.parse(event.data);
if (message.id && pending.has(message.id)) {
const { resolve, reject } = pending.get(message.id);
pending.delete(message.id);
if (message.error) reject(new Error(JSON.stringify(message.error)));
else resolve(message.result);
} else if (message.method && events.has(message.method)) {
events.get(message.method)();
}
});
const send = (method, params = {}) =>
new Promise((resolve, reject) => {
id += 1;
pending.set(id, { resolve, reject });
ws.send(JSON.stringify({ id, method, params }));
});
const once = (method) =>
new Promise((resolve) => events.set(method, resolve));
try {
return await fn({ send, once });
} finally {
ws.close();
}
}
let pdfBase64;
try {
pdfBase64 = await cdp(chrome.port, async ({ send, once }) => {
await send('Page.enable');
const loaded = once('Page.loadEventFired');
await send('Page.navigate', { url: `http://127.0.0.1:${port}/bio/` });
await loaded;
// The page self-hosts its fonts and `document.fonts.ready` is the only
// reliable signal that they are laid out — a PDF printed before the serif
// arrives is set in the fallback and looks nothing like the site.
await send('Runtime.evaluate', {
expression: 'document.fonts.ready',
awaitPromise: true,
});
const result = await send('Page.printToPDF', {
// Letter, because this circulates in Canada with Canadian counsel.
paperWidth: 8.5,
paperHeight: 11,
marginTop: 0.55,
marginBottom: 0.55,
marginLeft: 0.6,
marginRight: 0.6,
printBackground: false,
preferCSSPageSize: false,
});
return result.data;
});
} finally {
chrome.kill();
server.close();
}
const pdf = Buffer.from(pdfBase64, 'base64');
/**
* PAGE COUNT, ASSERTED. Counted from the PDF's own page objects rather than
* trusting the layout — this is the whole reason the script exists rather than a
* note telling someone to check. A one-page bio that quietly becomes two pages
* is exactly the class of defect nobody looks for again.
*/
const text = pdf.toString('latin1');
const pageCount =
(text.match(/\/Type\s*\/Page[^s]/g) ?? []).length ||
Number((/\/Count\s+(\d+)/.exec(text) ?? [])[1] ?? 0);
console.log(
`bio:pdf — ${pdf.length.toLocaleString()} bytes, ${pageCount} page(s), Letter.`,
);
if (pageCount !== 1) {
console.error(
`\nTHE BIO IS ${pageCount} PAGES AND MUST BE ONE.\n` +
' It is specified as a one-page bio (docs/01 §/about/ item 7, R16), and a\n' +
' second sheet carrying three lines is worse than a denser first one.\n' +
' Tighten the @media print block in src/pages/bio.astro — do not widen\n' +
' the margins here, which changes the document rather than the layout.\n' +
' Nothing was written.',
);
process.exit(1);
}
await writeFile(OUT, pdf);
console.log(`wrote public/pouya-lajevardi-bio.pdf`);
console.log(
'It is COMMITTED. Regenerate and re-commit it whenever /bio/, §4, the rate ' +
'card or the print styles change — nothing in the build does this for you.',
);
+142
View File
@@ -0,0 +1,142 @@
#!/usr/bin/env node
/**
* Cross-checks the intake form's two field tables. `npm run check:intake`.
*
* WHY THERE ARE TWO TABLES AT ALL, because the obvious reaction to this script
* is to delete one of them and share an import. `docs/05-backend-spec.md`:
* *"Client-side validation is a convenience. **The Lambda re-validates
* everything.**"* A server that validates against a list the client shipped it
* is not validating — it is asking the caller what the rules are. And the Lambda
* is a separately deployed zip that cannot import from `src/` anyway.
*
* So the duplication is architectural, and what makes it safe is this check
* rather than a shared module: the two tables must agree on every field NAME, on
* which fields are REQUIRED, on every length CAP, and on every closed OPTION
* SET. If they disagree, the form offers something the handler rejects, or the
* handler accepts something the form never shows — and the first is a lost
* inquiry that looks like a bug in the browser.
*
* This is the one place in the repo where a duplicated fact is deliberate, and
* `AGENTS.md`'s standing rule about duplicated facts is why it needs a mechanism
* on top of a comment.
*
* Both files are read directly — Node strips the types out of the `.ts` — so
* this script holds no third copy of the list.
*/
import { INTAKE_FIELDS, HONEYPOT_FIELD } from '../src/data/intake.ts';
import {
FIELDS as SERVER_FIELDS,
HONEYPOT,
} from '../backend/intake/fields.mjs';
/**
* BOTH TABLES ARE IMPORTED, NOT PARSED. The first version of this script read
* `handler.mjs` as text, sliced out the `const FIELDS = [ … ]` literal, munged
* quotes and commas into JSON, and guarded the result with a regex meant to
* refuse anything executable.
*
* **That guard rejected the table on the word `process`, which is a FIELD NAME.**
* A guard that fires on the data it exists to protect is worse than no guard,
* and the munging underneath it would have broken on the first apostrophe or
* URL in a label. The fix was not a better regex: the server's table moved into
* `backend/intake/fields.mjs`, which has no module-scope side effects and can
* simply be imported. The independence that matters is that the SERVER's table
* lives with the server and the handler trusts nothing from `src/` — not that a
* check script refuses to load it.
*/
const problems = [];
const server = SERVER_FIELDS;
const clientNames = INTAKE_FIELDS.map((f) => f.name);
const serverNames = server.map((f) => f.name);
for (const name of clientNames) {
if (!serverNames.includes(name)) {
problems.push(
`"${name}" is on the form but the handler does not accept it — the ` +
'inquirer would fill it and it would be silently dropped.',
);
}
}
for (const name of serverNames) {
if (!clientNames.includes(name)) {
problems.push(
`"${name}" is validated by the handler but is not on the form.`,
);
}
}
if (HONEYPOT !== HONEYPOT_FIELD) {
problems.push(
`honeypot name differs: form "${HONEYPOT_FIELD}", handler "${HONEYPOT}". ` +
'A bot fills the field the form renders; the handler checks the one it ' +
'knows about, so a mismatch disables the honeypot silently.',
);
}
if (serverNames.includes(HONEYPOT_FIELD)) {
problems.push(
`the honeypot "${HONEYPOT_FIELD}" is in the handler's FIELDS table; it must ` +
'be checked separately, or a bot filling it would just fail validation ' +
'instead of being sent to the success page.',
);
}
for (const clientField of INTAKE_FIELDS) {
const serverField = server.find((f) => f.name === clientField.name);
if (!serverField) continue;
if (Boolean(clientField.required) !== Boolean(serverField.required)) {
problems.push(
`"${clientField.name}": form required=${Boolean(clientField.required)}, ` +
`handler required=${Boolean(serverField.required)}. A field the form ` +
'marks optional and the handler requires is a rejection the inquirer ' +
'cannot see the reason for.',
);
}
/* LABELS TOO, since 2026-08-31. The handler now renders `f.label` into the
confirmation email the inquirer keeps, so a label that drifts from the
form's own wording means the receipt describes fields by names the form
never showed. One more comparison; the duplication stays mechanical. */
if (clientField.label !== serverField.label) {
problems.push(
`"${clientField.name}": labels differ.\n` +
` form: ${JSON.stringify(clientField.label)}\n` +
` handler: ${JSON.stringify(serverField.label ?? null)}\n` +
' The handler renders its label into the confirmation email.',
);
}
if ((clientField.max ?? null) !== (serverField.max ?? null)) {
problems.push(
`"${clientField.name}": form max=${clientField.max ?? 'none'}, ` +
`handler max=${serverField.max ?? 'none'}. The form's maxlength stops ` +
'typing; a lower cap in the handler rejects a submission that looked fine.',
);
}
const clientOptions = clientField.options ? [...clientField.options] : null;
const serverOptions = serverField.options ? [...serverField.options] : null;
if (JSON.stringify(clientOptions) !== JSON.stringify(serverOptions)) {
problems.push(
`"${clientField.name}": option sets differ.\n` +
` form: ${JSON.stringify(clientOptions)}\n` +
` handler: ${JSON.stringify(serverOptions)}`,
);
}
}
console.log(
`check:intake — ${clientNames.length} form fields, ${serverNames.length} ` +
'handler fields, compared on name, label, requiredness, cap and option set.',
);
if (problems.length > 0) {
console.error(`\nINTAKE TABLE MISMATCH — ${problems.length}:`);
for (const p of problems) console.error(` - ${p}`);
console.error(
'\ndocs/05: the handler re-validates everything. The two tables are ' +
'independent on purpose; they still have to agree.',
);
process.exit(1);
}
console.log('OK — the form and the handler agree.');
+52 -7
View File
@@ -20,26 +20,38 @@
# Required environment (values are in AGENTS.md §7 — deliberately not restated
# here; §7 is the single source of truth for operational facts):
#
# AWS_REGION S3_BUCKET CLOUDFRONT_DISTRIBUTION_ID INTAKE_ENDPOINT
# AWS_REGION S3_BUCKET CLOUDFRONT_DISTRIBUTION_ID
#
# ⚠️ INTAKE_ENDPOINT IS NO LONGER ONE OF THEM, AND THE GUARD THAT DEMANDED IT
# WAS BLOCKING A DEPLOY ON A VALUE NOTHING READ. Build step 8 moved the intake
# form to the same-origin path /api/intake (see src/data/intake.ts for the four
# reasons). After that, `git grep PUBLIC_INTAKE_ENDPOINT -- src/` returned
# nothing — the value exported into the build below was consumed by no page —
# and the guard's own message was false in both directions: the form posts to
# /api/intake whatever that variable holds, and the thing that actually decides
# whether it works, the CloudFront /api/* behaviour, was guarded nowhere.
#
# So the guard now checks the thing that matters, after the deploy, at the
# bottom of this script. Found by `adversarial-reviewer`, 2026-08-31.
# PUBLIC_BOOKING_URL went with it: `CONTACT.bookingUrl` is `null` in source while
# R6 keeps booking parked, and nothing read that variable either.
#
# Credentials: use the scoped deploy user. AGENTS.md Q22 records that it does
# NOT yet exist. NEVER run this as user/pouya — see AGENTS.md §10.
set -euo pipefail
# Same six values the workflow guards. Emptiness only — no value is echoed.
# Same five values the workflow guards. Emptiness only — no value is echoed.
missing=''
[ -n "${AWS_REGION:-}" ] || missing="$missing AWS_REGION"
[ -n "${S3_BUCKET:-}" ] || missing="$missing S3_BUCKET"
[ -n "${CLOUDFRONT_DISTRIBUTION_ID:-}" ] || missing="$missing CLOUDFRONT_DISTRIBUTION_ID"
[ -n "${INTAKE_ENDPOINT:-}" ] || missing="$missing INTAKE_ENDPOINT"
[ -n "${AWS_ACCESS_KEY_ID:-}" ] || missing="$missing AWS_ACCESS_KEY_ID"
[ -n "${AWS_SECRET_ACCESS_KEY:-}" ] || missing="$missing AWS_SECRET_ACCESS_KEY"
if [ -n "$missing" ]; then
echo "Not set:$missing" >&2
echo >&2
echo "Values are in AGENTS.md §7. An empty INTAKE_ENDPOINT does not fail the" >&2
echo "build — it ships a live contact form posting to nothing." >&2
echo "Values are in AGENTS.md §7." >&2
exit 1
fi
@@ -62,9 +74,10 @@ echo "==> Type and template check"
npm run check
echo "==> Build"
# Only PUBLIC_SITE_URL, because it is the only one astro.config.mjs reads.
# PUBLIC_INTAKE_ENDPOINT and PUBLIC_BOOKING_URL were exported here and consumed
# by nothing — see the header.
PUBLIC_SITE_URL="https://adr.smlcompany.ca" \
PUBLIC_INTAKE_ENDPOINT="$INTAKE_ENDPOINT" \
PUBLIC_BOOKING_URL="${BOOKING_URL:-}" \
npm run build
# AFTER the build and BEFORE anything is uploaded. AGENTS.md §4 Forbidden,
@@ -101,4 +114,36 @@ aws cloudfront create-invalidation \
--distribution-id "${CLOUDFRONT_DISTRIBUTION_ID}" \
--paths "/*" >/dev/null
# THE CHECK THAT REPLACES THE INTAKE_ENDPOINT GUARD, and it runs AFTER the
# deploy because it tests the deployed thing rather than a variable.
#
# The intake form posts to the same-origin path /api/intake, which only works if
# a CloudFront behaviour routes /api/* to the HTTP API origin AGENTS.md §7
# records. Nothing in the build can know whether that behaviour exists, and a
# deploy that succeeds while the form posts into a 404 is the failure the old
# guard was reaching for and could not see.
#
# 404 means not routed. 403 means routed and REFUSED, which is the correct answer
# to this request: the handler checks the Origin header and this curl sends none,
# so it is rejected before any DynamoDB write or any email. That makes 403 a pass
# and is why this probe is safe to run against production.
echo "==> Intake route check"
code=$(curl -sS -o /dev/null -w '%{http_code}' -X POST \
--max-time 15 \
-H 'Content-Type: application/x-www-form-urlencoded' \
--data 'deploy-route-probe=1' \
"https://adr.smlcompany.ca/api/intake" || echo 000)
case "$code" in
404|000)
echo >&2
echo "WARNING: POST /api/intake returned $code." >&2
echo "The contact form posts there. 404 means the CloudFront /api/* behaviour" >&2
echo "is missing; 000 means the request did not complete. The site is" >&2
echo "deployed and the form is not wired — see docs/06's cutover checklist." >&2
;;
*)
echo " POST /api/intake -> $code (routed; 403 is the Origin check refusing a probe)"
;;
esac
echo "==> Deployed to https://adr.smlcompany.ca ($(git rev-parse --short HEAD))"
+377
View File
@@ -0,0 +1,377 @@
#!/usr/bin/env node
/**
* The performance gate. Budget: docs/04-seo-spec.md §Performance —
* Lighthouse >= 95 on all four categories, on mobile, for every page.
*
* WHY THIS IS `lighthouse` AND NOT `@lhci/cli`, WHICH IS WHAT R11 SAID TO PUT
* BACK. Measured 2026-08-31 from two probe lockfiles, not recalled:
*
* @lhci/cli@0.15.1 10 vulnerabilities (7 high) pins lighthouse 12.6.1
* high: tmp@0.1.0 <- a DIRECT dependency of @lhci/cli itself
* high: extract-zip@2.0.1 <- via @puppeteer/browsers
* lighthouse@13.4.1 0 vulnerabilities 109 packages
* tmp ABSENT, extract-zip ABSENT
*
* So the carrier was never Lighthouse. AGENTS.md §7 recorded the advisories as
* arriving "via lighthouse -> puppeteer-core -> extract-zip", and on that
* attribution the tool looked unusable for as long as the advisories stood.
* Standalone `lighthouse` measures the same budget with nothing outstanding.
* What is given up is real and is recorded in §7: `lhci autorun`'s assertion
* config, its server, and its CI upload.
*
* THIS IS A LOCAL GATE, NOT A CI CHECK, and the reason is Chrome. Standalone
* Lighthouse drives an installed browser; the Gitea runner has none (§7 — the
* runner is not registered at all yet, Q23). So this runs from a keyboard and
* as a blocking item on docs/06's cutover checklist. It is not wired into
* `npm run build` or either deploy path, and saying so is the point: a check
* described as running where it cannot is the defect Q22 turned out to be.
*
* PAGES ARE ENUMERATED FROM `dist/`, NEVER LISTED HERE. A hand-written list
* silently stops covering the site the first time a page is added — which is
* this project's most expensive recurring shape. Every `index.html` under
* `dist/` is a page, so the set cannot go stale.
*
* Usage: npm run build && npm run lighthouse
* npm run lighthouse -- /fees/ /insights/ # a subset, by pathname
*/
import { createServer } from 'node:http';
import { createReadStream } from 'node:fs';
import { readdir, readFile, stat } from 'node:fs/promises';
import { join, extname, relative, sep } from 'node:path';
import lighthouse from 'lighthouse';
import * as chromeLauncher from 'chrome-launcher';
const DIST = new URL('../dist/', import.meta.url).pathname;
const THRESHOLD = 95;
const CATEGORIES = ['performance', 'accessibility', 'best-practices', 'seo'];
/** docs/04's own budgets, reported alongside the scores rather than asserted
* separately — LCP is the one the spec states in seconds. */
const LCP_BUDGET_MS = 2000;
const CLS_BUDGET = 0.05;
const MIME = {
'.html': 'text/html; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.js': 'text/javascript; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.xml': 'application/xml; charset=utf-8',
'.txt': 'text/plain; charset=utf-8',
'.svg': 'image/svg+xml',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.png': 'image/png',
'.avif': 'image/avif',
'.webp': 'image/webp',
'.ico': 'image/x-icon',
'.woff2': 'font/woff2',
'.pdf': 'application/pdf',
};
/**
* `trailingSlash: 'always'` + `build.format: 'directory'` (astro.config.mjs),
* so `/mediation/` is `dist/mediation/index.html` and an extensionless path
* without the slash is a 404 here exactly as it is on CloudFront. Serving it
* anyway would measure a URL the site does not have.
*/
function resolveFile(pathname) {
if (pathname.endsWith('/')) return join(DIST, pathname, 'index.html');
if (extname(pathname)) return join(DIST, pathname);
return null;
}
async function collectPages(dir = DIST) {
const out = [];
for (const entry of await readdir(dir, { withFileTypes: true })) {
const full = join(dir, entry.name);
if (entry.isDirectory()) out.push(...(await collectPages(full)));
else if (entry.name === 'index.html') {
const rel = relative(DIST, dir).split(sep).filter(Boolean).join('/');
out.push(rel ? `/${rel}/` : '/');
}
}
return out.sort();
}
function serveDist() {
const server = createServer((req, res) => {
const pathname = decodeURIComponent(new URL(req.url, 'http://x').pathname);
const file = resolveFile(pathname);
if (!file) {
res.writeHead(404, { 'content-type': 'text/plain' });
res.end('404');
return;
}
const stream = createReadStream(file);
stream.on('error', () => {
res.writeHead(404, { 'content-type': 'text/plain' });
res.end('404');
});
stream.once('open', () => {
// NO `cache-control` HEADER, AND THAT IS DELIBERATE — measured
// 2026-08-31. `cache-control: no-store` was set here to force a cold
// cache, which it did not need to do (Lighthouse resets storage between
// runs by default) and which cost the `bf-cache` audit outright:
// "Pages whose main resource has cache-control:no-store cannot enter
// back/forward cache." The audit failed on every page, in a report whose
// whole job is to find defects on the site. Verified by toggling the one
// header: bf-cache 0 with it, 1 without, twice each.
res.writeHead(200, {
'content-type': MIME[extname(file)] ?? 'application/octet-stream',
});
stream.pipe(res);
});
});
return new Promise((resolve, reject) => {
server.once('error', reject);
server.listen(0, '127.0.0.1', () =>
resolve({ server, port: server.address().port }),
);
});
}
const pad = (s, n) => String(s).padEnd(n);
const scoreOf = (lhr, id) => Math.round((lhr.categories[id]?.score ?? 0) * 100);
/**
* ⚠️ AN INTENTIONALLY `noindex` PAGE CANNOT SCORE 95 ON LIGHTHOUSE'S SEO
* CATEGORY, AND THE BUDGET AS WRITTEN DID NOT KNOW THAT.
*
* Measured 2026-08-31, first full run over 22 pages: five pages scored SEO
* **69**, and on every one the ONLY failing audit was `is-crawlable` — *"Page is
* blocked from indexing"* — firing on `<meta name="robots" content="noindex,
* follow">`. That meta tag is what `docs/04` REQUIRES on `/legal/*`, and it is
* deliberate on `/bio/`, `/contact/received/` and `/contact/could-not-send/`.
* So the category is measuring the page doing exactly what it was built to do.
*
* The wrong fix is to drop the SEO threshold, or to except these pages, or to
* stop measuring them: each of those hides every OTHER SEO defect on the pages
* where a defect is hardest to notice. What is asserted instead is stricter than
* a number:
*
* indexable page -> SEO category >= 95, as before
* noindex page -> EVERY SEO audit must pass EXCEPT `is-crawlable`
*
* A missing canonical, a missing title, an unreadable font size or a bad link on
* a noindex page still fails the gate. Only the one audit that is measuring the
* intent is set aside, and the page is marked in the table so the number is
* never read as unqualified.
*
* `noindex` is read from the BUILT HTML rather than from a list of paths here —
* a list would stop covering the site the first time a page is added.
*/
const EXPECTED_NOINDEX_FAILURE = 'is-crawlable';
async function isNoindex(page) {
const file = resolveFile(page);
const html = await readFile(file, 'utf8');
return /<meta[^>]+name="robots"[^>]+content="[^"]*noindex/i.test(html);
}
function failingAudits(lhr, category) {
return (lhr.categories[category]?.auditRefs ?? [])
.map((ref) => lhr.audits[ref.id])
.filter((audit) => audit && audit.score !== null && audit.score < 1)
.map((audit) => audit.id);
}
async function main() {
try {
await stat(join(DIST, 'index.html'));
} catch {
console.error(
'dist/index.html is missing. Run `npm run build` first — this gate ' +
'measures the bytes that would ship, not the dev server.',
);
process.exit(2);
}
const requested = process.argv.slice(2).filter((a) => a.startsWith('/'));
const all = await collectPages();
const pages = requested.length ? requested : all;
const unknown = requested.filter((p) => !all.includes(p));
if (unknown.length) {
console.error(`Not built: ${unknown.join(', ')}`);
process.exit(2);
}
const { server, port } = await serveDist();
const baseFlags = ['--headless', '--no-sandbox', '--disable-gpu'];
const chrome = await chromeLauncher.launch({ chromeFlags: baseFlags });
/**
* ⚠️ A SECOND BROWSER, AND THE ACCESSIBILITY CATEGORY IS MEASURED IN IT.
*
* `--force-prefers-reduced-motion`. This is a deliberate deviation from a
* single default run and it must be stated wherever the number is, which is
* why the table below labels the column. Measured 2026-08-31, twice per
* condition, on `/process/`:
*
* motion on a11y = 96 color-contrast FAILED, 24 nodes
* motion off a11y = 100 color-contrast passed, 0 nodes
*
* The 24 nodes were the scroll-driven reveal (`animation-timeline: view()`,
* global.css) caught mid-flight: axe reported foregrounds like `#d0cbc4` on
* `#f8f4ed`, and NEITHER is in this site's palette — they are the real colours
* blended toward the background by an in-progress `opacity` keyframe. So the
* audit was measuring animation state, not contrast.
*
* WHY THIS IS THE HONEST RUN RATHER THAN THE CONVENIENT ONE. A category that
* reports 24 known-false nodes on ten of fourteen pages cannot surface the
* twenty-fifth, real one — it is a control that has stopped controlling, which
* is the shape `AGENTS.md` Q22 and the Lighthouse removal both took. The
* reduced-motion rendering is not a synthetic one: it is the branch
* `global.css` ships for `prefers-reduced-motion: reduce`, a real user setting,
* and it is the branch in which every element sits at its FINAL colour, which
* is what a contrast audit is asking about. Contrast ratios for the palette
* itself are computed and recorded in `docs/02-design-system.md`.
*
* Performance is NOT measured here — reduced motion would suppress work the
* site really does on a default profile.
*/
const chromeA11y = await chromeLauncher.launch({
chromeFlags: [...baseFlags, '--force-prefers-reduced-motion'],
});
const PERF_CATEGORIES = CATEGORIES.filter((id) => id !== 'accessibility');
const rows = [];
const breaches = [];
try {
for (const page of pages) {
const url = `http://127.0.0.1:${port}${page}`;
// Default config otherwise: Lighthouse's mobile preset — mobile form
// factor, mobile screen emulation, simulated Slow 4G. That is the
// budget's own wording in docs/04, so none of it is overridden.
const run = async (chromeInstance, onlyCategories) => {
const result = await lighthouse(url, {
logLevel: 'error',
output: 'json',
port: chromeInstance.port,
onlyCategories,
});
if (!result?.lhr) {
throw new Error(`Lighthouse returned nothing for ${page}`);
}
if (result.lhr.runtimeError?.code) {
throw new Error(`${page}: ${result.lhr.runtimeError.message}`);
}
return result.lhr;
};
const lhr = await run(chrome, PERF_CATEGORIES);
const lhrA11y = await run(chromeA11y, ['accessibility']);
const scores = Object.fromEntries([
...PERF_CATEGORIES.map((id) => [id, scoreOf(lhr, id)]),
['accessibility', scoreOf(lhrA11y, 'accessibility')],
]);
const lcp = lhr.audits['largest-contentful-paint']?.numericValue ?? NaN;
const cls = lhr.audits['cumulative-layout-shift']?.numericValue ?? NaN;
const noindex = await isNoindex(page);
rows.push({ page, scores, lcp, cls, noindex });
for (const id of CATEGORIES) {
// The SEO category on a noindex page is asserted audit by audit
// instead — see the comment on EXPECTED_NOINDEX_FAILURE.
if (id === 'seo' && noindex) continue;
if (scores[id] < THRESHOLD) {
breaches.push(`${page} ${id} = ${scores[id]} (< ${THRESHOLD})`);
}
}
if (noindex) {
const unexpected = failingAudits(lhr, 'seo').filter(
(id) => id !== EXPECTED_NOINDEX_FAILURE,
);
if (unexpected.length) {
breaches.push(
`${page} seo — noindex page, so only \`${EXPECTED_NOINDEX_FAILURE}\` ` +
`may fail; these also failed: ${unexpected.join(', ')}`,
);
}
}
}
} finally {
// `kill()` is synchronous in chrome-launcher 1.x — `await` on it draws
// ts(80007) from `astro check`, which this repo keeps at zero.
chrome.kill();
chromeA11y.kill();
server.close();
}
const w = Math.max(28, ...rows.map((r) => r.page.length + 2));
console.log(`\n${pad('page', w)} perf a11y* bestp seo LCP CLS`);
console.log('-'.repeat(w + 44));
for (const r of rows) {
const cells = CATEGORIES.map((id) =>
pad(id === 'seo' && r.noindex ? `${r.scores[id]}n` : r.scores[id], 6),
).join(' ');
const lcpCell = pad(`${(r.lcp / 1000).toFixed(2)}s`, 8);
console.log(`${pad(r.page, w)} ${cells} ${lcpCell} ${r.cls.toFixed(3)}`);
}
// The worst-of row excludes noindex pages from the SEO column, because
// including them would report 69 as the site's worst SEO score forever and
// train a reader to ignore the column — which is how a real regression there
// would go unnoticed.
const worst = (id) => {
const relevant = id === 'seo' ? rows.filter((r) => !r.noindex) : rows;
return relevant.length
? Math.min(...relevant.map((r) => r.scores[id]))
: 100;
};
console.log('-'.repeat(w + 44));
console.log(
`${pad(`worst of ${rows.length}`, w)} ` +
CATEGORIES.map((id) => pad(worst(id), 6)).join(' ') +
` ${pad(`${(Math.max(...rows.map((r) => r.lcp)) / 1000).toFixed(2)}s`, 8)} ` +
Math.max(...rows.map((r) => r.cls)).toFixed(3),
);
console.log(
`\nbudgets: all four categories >= ${THRESHOLD} (mobile) · ` +
`LCP < ${LCP_BUDGET_MS / 1000}s · CLS < ${CLS_BUDGET} — docs/04-seo-spec.md`,
);
const noindexCount = rows.filter((r) => r.noindex).length;
if (noindexCount) {
console.log(
`n = deliberately noindex (${noindexCount} page(s)). Lighthouse's SEO\n` +
' category cannot exceed ~69 on such a page: `is-crawlable` fails on the\n' +
' `noindex` the page is supposed to carry. Those pages are asserted audit\n' +
' by audit instead — every SEO audit must pass except that one — and are\n' +
' excluded from the SEO worst-of above.',
);
}
console.log(
'* a11y is measured with prefers-reduced-motion forced. The scroll-driven\n' +
" reveal otherwise puts axe's colour-contrast audit on mid-animation\n" +
' opacity rather than on the palette — 24 false nodes, measured. See the\n' +
' comment on chromeA11y in this script.',
);
// Reported, not asserted. docs/04 states LCP and CLS as budgets; Lighthouse's
// simulated throttling on a loopback server is not the Slow 4G field
// measurement they describe, so a hard failure here would be a claim about
// the instrument. The category scores ARE the gate.
const lcpOver = rows.filter((r) => r.lcp >= LCP_BUDGET_MS);
const clsOver = rows.filter((r) => r.cls >= CLS_BUDGET);
if (lcpOver.length) {
console.log(
`note: LCP at or over budget on ${lcpOver.length} page(s): ` +
lcpOver.map((r) => r.page).join(', '),
);
}
if (clsOver.length) {
console.log(
`note: CLS at or over budget on ${clsOver.length} page(s): ` +
clsOver.map((r) => r.page).join(', '),
);
}
if (breaches.length) {
console.error(`\nBUDGET BREACH — ${breaches.length}:`);
for (const b of breaches) console.error(` - ${b}`);
console.error('\nCLAUDE.md: treat a budget breach as a failing build.');
process.exit(1);
}
console.log(`\nOK — ${rows.length} page(s), no category below ${THRESHOLD}.`);
}
await main();
+325
View File
@@ -0,0 +1,325 @@
#!/usr/bin/env node
/**
* Proves the Open Graph cards, two ways. `npm run og:proof`, after a build.
*
* WHY THIS EXISTS AT ALL. `AGENTS.md` R15: *"Nobody on this project will ever
* see the defect. A link preview is rendered by LinkedIn, Slack and Teams for a
* reader who is not us."* Generating the cards does not fix that — it moves the
* invisible thing from "wrong image" to "wrong image, generated". So the two
* failures that would stay invisible are checked mechanically:
*
* 1. **Every page's `og:image` resolves to a file that exists in `dist/`.** A
* 404 preview image renders as a blank card, and nothing else in this repo
* would notice. Checked by reading the built HTML, not the source.
*
* 2. **Every card's headline and eyebrow are its page's own `<h1>` and first
* `.eyebrow`, character for character.** This is the compliance half. Text
* baked into a JPEG is text `npm run check:claims` cannot grep, and under D20
* that script is the only per-step claims control there is — so a card must
* never carry a claim its page does not already make in auditable HTML. The
* check enforces that structurally rather than trusting an author to
* remember it, and it fails in both directions: editing the page without the
* registry, or the registry without the page.
*
* It also catches the quiet one: a straight apostrophe in the registry
* against the typographic apostrophe the page renders. Found exactly that on
* the first run, on `/practice/insurance/`.
*
* It reads `src/data/og-cards.ts` DIRECTLY — Node strips the types — so there is
* no second list of cards to keep in step with the first.
*
* Optional: `npm run og:proof -- --sheet` writes a contact sheet of every card
* to `dist/og-proof.jpg` so the set can be looked at in one go. Not part of the
* check; a human still has to look.
*/
import { readdir, readFile, stat, writeFile } from 'node:fs/promises';
import { join, relative, sep } from 'node:path';
import sharp from 'sharp';
import {
OG_CARDS,
PORTRAIT_PAGES,
articleCard,
ogCardPath,
} from '../src/data/og-cards.ts';
const ROOT = process.cwd();
const DIST = join(ROOT, 'dist');
const SITE = 'https://adr.smlcompany.ca';
const strip = (html) =>
html
.replace(/<[^>]+>/g, '')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&nbsp;/g, ' ')
.replace(/\s+/g, ' ')
.trim();
async function pages(dir = DIST) {
const out = [];
for (const entry of await readdir(dir, { withFileTypes: true })) {
const full = join(dir, entry.name);
if (entry.isDirectory()) out.push(...(await pages(full)));
else if (entry.name === 'index.html') {
const rel = relative(DIST, dir).split(sep).filter(Boolean).join('/');
out.push({ path: rel ? `/${rel}/` : '/', file: full });
}
}
return out.sort((a, b) => a.path.localeCompare(b.path));
}
const problems = [];
const fail = (msg) => problems.push(msg);
try {
await stat(join(DIST, 'index.html'));
} catch {
console.error('dist/ is missing or empty. Run `npm run build` first.');
process.exit(2);
}
const built = await pages();
const seenCards = new Set();
let checkedHeadlines = 0;
let checkedArticles = 0;
/**
* An article's expected card, from the SAME `articleCard()` the endpoint calls,
* given the title in that article's own frontmatter. Read from the `.mdx` rather
* than from the built page, so the comparison has two independent sides: what
* the article says its title is, and what the route rendered as the `<h1>`.
*/
async function expectedArticleCard(path) {
const slug = path.replace(/^\/insights\/|\/$/g, '');
for (const ext of ['mdx', 'md']) {
try {
const src = await readFile(
join(ROOT, 'src', 'content', 'insights', `${slug}.${ext}`),
'utf8',
);
const m = /^title:\s*(.*)$/m.exec(src);
if (!m) break;
let title = m[1].trim();
// YAML scalar: strip one layer of quoting and unescape a doubled single
// quote, which is how YAML writes a literal apostrophe inside '…'.
if (
(title.startsWith("'") && title.endsWith("'")) ||
(title.startsWith('"') && title.endsWith('"'))
) {
title = title.slice(1, -1);
}
title = title.replace(/''/g, "'");
return articleCard(title);
} catch {
/* try the next extension */
}
}
return null;
}
for (const { path, file } of built) {
const html = await readFile(file, 'utf8');
// ---- 1. og:image exists -------------------------------------------------
const og = /<meta property="og:image" content="([^"]+)"/.exec(html);
if (!og) {
fail(`${path}: no og:image meta tag at all`);
continue;
}
const url = og[1];
if (!url.startsWith(SITE + '/')) {
fail(`${path}: og:image is not an absolute URL on ${SITE}${url}`);
continue;
}
const assetPath = url.slice(SITE.length);
try {
await stat(join(DIST, assetPath));
} catch {
fail(`${path}: og:image points at ${assetPath}, which is not in dist/`);
continue;
}
seenCards.add(assetPath);
// ---- 2. card copy is the page's own copy --------------------------------
const isPortrait = PORTRAIT_PAGES.includes(path);
const card = OG_CARDS[path];
if (isPortrait) {
if (card) fail(`${path}: in PORTRAIT_PAGES and in OG_CARDS — pick one`);
if (assetPath.startsWith('/og/')) {
fail(`${path}: is a portrait page but its og:image is a generated card`);
}
continue;
}
const expected = ogCardPath(path);
if (assetPath !== expected) {
fail(`${path}: og:image is ${assetPath}, expected ${expected}`);
}
/**
* ⚠️ AN ARTICLE IS CHECKED THE SAME WAY AS A REGISTRY PAGE, AND UNTIL
* 2026-08-31 IT WAS NOT CHECKED AT ALL.
*
* The first version of this script matched an article's card FILENAME and then
* `continue`d — skipping the headline and eyebrow comparisons entirely. So the
* one surface `check:claims` cannot reach was also the one surface this script
* did not compare, which is the opposite of what its own header claims and what
* `docs/04` says it enforces.
*
* `adversarial-reviewer` proved it rather than arguing it: with
* `headline: 'DELIBERATELY WRONG CARD TEXT — probe'` set in the endpoint and one
* article published, the card rendered that sentence in 68px Instrument Serif
* and this script printed `OK — every og:image resolves, and no card asserts
* anything its page does not`, exit 0. **`checkedHeadlines` stayed pinned at the
* registry size** no matter how many articles published — a coverage number
* that reads like completeness and falls further behind as the site grows,
* which is exactly the uniform-pass shape `CLAUDE.md` warns is the dangerous
* half.
*
* An article has no registry entry by design — its card comes from the
* collection — so the expectation comes from the page instead: the endpoint
* sets an article card's headline to `entry.data.title`, which is also the
* page's `<h1>`. Comparing the card's source of truth against the rendered
* `<h1>` is therefore the same check, and the eyebrow is the literal the
* endpoint sets.
*/
const isArticle = /^\/insights\/[^/]+\/$/.test(path);
if (!card && !isArticle) {
fail(`${path}: built, not a portrait page, and has no OG_CARDS entry`);
continue;
}
let expected_card = card;
if (!expected_card) {
expected_card = await expectedArticleCard(path);
if (!expected_card) {
fail(
`${path}: could not read a \`title:\` from this article's own .mdx, so ` +
'its card cannot be compared against anything. That is a failure, not ' +
'a skip — an unchecked card is the one surface check:claims cannot see.',
);
continue;
}
}
const expectedEyebrow = expected_card.eyebrow;
const h1 = /<h1[^>]*>([\s\S]*?)<\/h1>/.exec(html);
if (!h1) {
fail(`${path}: no <h1> to compare the card headline against`);
} else {
const text = strip(h1[1]);
const expectedHeadline = expected_card.headline;
if (text !== expectedHeadline) {
fail(
`${path}: card headline is not the page's <h1>.\n` +
` <h1>: ${JSON.stringify(text)}\n` +
` card: ${JSON.stringify(expectedHeadline)}`,
);
} else {
checkedHeadlines += 1;
if (isArticle) checkedArticles += 1;
}
}
const eyebrow = /<p class="eyebrow"[^>]*>([\s\S]*?)<\/p>/.exec(html);
if (!eyebrow) {
fail(`${path}: no .eyebrow to compare the card eyebrow against`);
} else {
const text = strip(eyebrow[1]);
if (text !== expectedEyebrow) {
fail(
`${path}: card eyebrow is not the page's first .eyebrow.\n` +
` page: ${JSON.stringify(text)}\n` +
` card: ${JSON.stringify(expectedEyebrow)}`,
);
}
}
}
/**
* ⚠️ AND THE COVERAGE IS ASSERTED, NOT REPORTED. Printing "20 headlines matched"
* beside a growing site is how the gap above stayed invisible: the number went up
* and never went up ENOUGH, and nothing said so. Every built page except the
* portrait pages carries a generated card, so the count must equal that or a page
* was silently skipped.
*/
const shouldCheck = built.filter(
({ path }) => !PORTRAIT_PAGES.includes(path),
).length;
if (checkedHeadlines !== shouldCheck) {
fail(
`only ${checkedHeadlines} of ${shouldCheck} non-portrait pages had their ` +
'card headline compared against their <h1>. A page was skipped, which is ' +
'the failure this assertion exists to make loud.',
);
}
// ---- 3. no card generated for a page that does not exist -------------------
// A stray card is not a shipped defect, but it is the signature of a page that
// was renamed or removed and a registry entry that was not — which the next
// person reads as "the card exists, so the page must".
let strays = [];
try {
const files = await readdir(join(DIST, 'og'));
strays = files
.filter((f) => f.endsWith('.jpg'))
.map((f) => `/og/${f}`)
.filter((p) => !seenCards.has(p));
} catch {
fail('dist/og/ does not exist — no cards were generated');
}
for (const s of strays) {
fail(`${s}: generated, but no built page references it`);
}
// ---- optional contact sheet ----------------------------------------------
if (process.argv.includes('--sheet')) {
const files = (await readdir(join(DIST, 'og')))
.filter((f) => f.endsWith('.jpg'))
.sort();
const COLS = 3;
const W = 400;
const H = 210;
const rows = Math.ceil(files.length / COLS);
const tiles = await Promise.all(
files.map(async (f, i) => ({
input: await sharp(join(DIST, 'og', f))
.resize(W, H)
.toBuffer(),
left: (i % COLS) * W,
top: Math.floor(i / COLS) * H,
})),
);
const sheet = await sharp({
create: {
width: COLS * W,
height: rows * H,
channels: 3,
background: '#ffffff',
},
})
.composite(tiles)
.jpeg({ quality: 82 })
.toBuffer();
await writeFile(join(DIST, 'og-proof.jpg'), sheet);
console.log(
`contact sheet: dist/og-proof.jpg — ${files.length} cards, ${COLS}x${rows}`,
);
}
console.log(
`og:proof — ${built.length} built pages, ${seenCards.size} distinct og:image ` +
`targets, ${checkedHeadlines} card headlines matched their page <h1> ` +
`(${checkedArticles} of them articles).`,
);
if (problems.length) {
console.error(`\nOG CARD PROBLEMS — ${problems.length}:`);
for (const p of problems) console.error(` - ${p}`);
process.exit(1);
}
console.log(
'OK — every og:image resolves, and no card asserts anything its page does not.',
);