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:
co-authored by
Claude Opus 5
parent
6cfe69033f
commit
210bc25a26
+77
-19
@@ -321,11 +321,13 @@ link check. `.gitea/workflows/deploy.yml` has **no `pull_request` trigger**
|
||||
(only `push` on `main` and `workflow_dispatch`), so nothing gates a merge today.
|
||||
`npm run build`, `npm run check` and `npm run lint` all run clean locally.
|
||||
|
||||
**Lighthouse is not one of the checks that could be wired today.** `@lhci/cli`
|
||||
was removed on 2026-08-26 and there is no `npm run lighthouse` script any more —
|
||||
`AGENTS.md` §7 records why and what re-adding it at build step 7 requires. Wire
|
||||
the other four; do not write a workflow step that calls a script that does not
|
||||
exist.
|
||||
**Lighthouse is still not one of the checks that can be wired — and the reason
|
||||
changed on 2026-08-31.** `npm run lighthouse` exists again (step 7a, `AGENTS.md`
|
||||
§7), so the blocker is no longer a missing script: it is that standalone
|
||||
Lighthouse drives an **installed browser** and the runner has none. Wiring it
|
||||
would need Chrome on the `act_runner` host (Q23). Wire the other four; do not
|
||||
write a workflow step that shells out to a browser that is not there. The gate
|
||||
lives locally and on the cutover checklist below.
|
||||
|
||||
Tag every production deploy `v<year>.<n>` so a rollback has something to name.
|
||||
|
||||
@@ -399,31 +401,87 @@ Then invalidate `/*`.
|
||||
expressed without naming the designation; that gap is `claims-auditor`'s to
|
||||
close, and it is stated in the pattern itself
|
||||
- [ ] **C.Med-Arb appears nowhere in `dist/`** — struck entirely 2026-08-29
|
||||
- [ ] `/fees/` carries the rates confirmed in D14 and `docs/07-fees.md`, or the page does not ship
|
||||
- [ ] Privacy policy matches the backend as actually built
|
||||
- [ ] `/fees/` carries the rates confirmed in D14 and `docs/07-fees.md`, or the page does not ship. **And the reservation sentence sits adjacent to the overtime row** — Q59, ruled 2026-08-31: a reader who takes the $500 and skips *"a full day reserves the day; half-day overtime is subject to availability"* has read a different offer, and the arithmetic then makes the full-day rate look strictly worse
|
||||
- [ ] Privacy policy matches the backend as actually built. **Three of its statements are derived and cannot drift** — the collected-data list from `INTAKE_FIELDS`, the retention period from the handler's own figure, the analytics paragraph from `ANALYTICS.installed`. **If analytics are installed, that flag flips and the policy's last-updated date moves on the same build**; a policy naming a processor that processes nothing is a false disclosure
|
||||
- [ ] **`/insights/` — check which state it is in.** While no article is published the page emits `noindex` (derived from the collection) and `SiteHeader` keeps Insights out of the primary nav until **two** are live. Both clear themselves on publication. D9 means the flip is Pouya's: `src/content.config.ts` refuses `draft: false` without `reviewedByPouya: true`
|
||||
- [ ] **The five drafted articles are `draft: true` and therefore invisible to `check:claims`**, which reads `dist/`. They were checked once, on 2026-08-31, by temporarily publishing all five and running it — clean on 27 pages. **Re-run it that way before any of them publishes**, because a draft that changes in between is unchecked
|
||||
|
||||
**Technical**
|
||||
- [ ] **Re-add `@lhci/cli`** (removed 2026-08-26 — `AGENTS.md` §7) with a pin
|
||||
verified against the registry that day, and a `lighthouserc` carrying the
|
||||
budgets from `04-seo-spec.md`. This box gates the next one
|
||||
- [ ] Lighthouse ≥ 95 mobile on `/`, `/about/`, a practice page, an article
|
||||
- [ ] **`npm run lighthouse` on the full built site**, from a machine with
|
||||
Chrome — it is not in CI and cannot be (see above). Read the exit status.
|
||||
Every category ≥ 95 on every page; the accessibility figure is measured
|
||||
with `prefers-reduced-motion` forced and that qualification is reported
|
||||
with it (`AGENTS.md` §7)
|
||||
- [ ] Every page renders fully with JavaScript disabled
|
||||
- [ ] `curl` of each URL returns real content, not a shell
|
||||
- [ ] All internal links resolve; no orphan pages
|
||||
- [ ] Sitemap generated and correct; `robots.txt` served, not 403
|
||||
- [ ] Rich Results Test passes; OG previews render in LinkedIn and Slack
|
||||
- [ ] **OG cards are per-page, not one portrait on all nineteen** — `AGENTS.md`
|
||||
Q40 / **R15**. The portrait is the decided card for `/` and `/about/`; every
|
||||
other page needs the generated typed card, built at step 7 with Insights.
|
||||
**This blocks cutover.** A link preview is the surface a general counsel
|
||||
actually sees when a colleague pastes the URL into Teams, and the interim
|
||||
makes nineteen unique titles look identical
|
||||
- [ ] **`npm run og:proof`** — every page's `og:image` resolves to a file in
|
||||
`dist/`, and every generated card's headline is its page's own `<h1>`.
|
||||
R15 is discharged (step 7b) so this is a regression check rather than a
|
||||
blocker on the interim; it stays on the list because a broken preview image
|
||||
is invisible from inside the repo, and because card text cannot be reached
|
||||
by `check:claims`. Run `npm run og:proof -- --sheet` and **look at the
|
||||
contact sheet** as well — the check compares strings, not rendering
|
||||
- [ ] 404 returns a 404 status
|
||||
- [ ] Security headers present (`securityheaders.com` A or better)
|
||||
- [ ] **SES identities verified for sending** — confirmed 2026-08-26, re-check at cutover: `aws sesv2 get-email-identity --email-identity smlcompany.ca` and confirm `VerifiedForSendingStatus: true`
|
||||
- [ ] **SES bounce/complaint alarms actually notify someone** — `AGENTS.md` §7 records the `ses-alerts` email subscription as **pending confirmation**, and an unconfirmed SNS subscription drops every message. Confirm it, then `aws sns list-subscriptions-by-topic` and check the ARN is not `PendingConfirmation`. *(SES production access itself is granted — Q19 closed.)*
|
||||
- [ ] Intake form tested end to end: DynamoDB record written to the intake table (`AGENTS.md` §7), both emails delivered to a real inbox, TTL set
|
||||
- [ ] Booking link works, including the no-JavaScript fallback — **conditional on R6**; booking is parked and `BOOKING_URL` is empty, so this passes vacuously until a tool is chosen
|
||||
- [ ] **THE INTAKE FORM DOES NOT WORK YET, AND THREE THINGS HAVE TO HAPPEN BEFORE
|
||||
IT DOES — build step 8 shipped the page and not the pipe.**
|
||||
1. **A CloudFront behaviour routing `/api/*`** to the HTTP API origin
|
||||
`AGENTS.md` §7 records. The form posts to the same-origin path
|
||||
`/api/intake` — deliberately, so the CSP can be `form-action 'self'`,
|
||||
so there is no cross-origin POST, so the endpoint id stays out of the
|
||||
HTML, and so submitting the form from a laptop does nothing. Without
|
||||
this behaviour the form 404s.
|
||||
2. **Deploy `backend/intake/handler.mjs` + `backend/intake/fields.mjs`**,
|
||||
replacing the hand-built `adr-intake-handler` §7 records. It needs
|
||||
`INTAKE_TABLE`, `SITE_ORIGIN`, `NOTIFY_TO`, `MAIL_FROM` and
|
||||
`RESPONSE_TIME` set; it throws at cold start on any missing one, on
|
||||
purpose. `RESPONSE_TIME` must be `CONTACT.responseTime` verbatim — it is
|
||||
a public commitment (§4, Q27) and must read identically on `/contact/`,
|
||||
in the confirmation email, and in the bio.
|
||||
3. **API Gateway throttling, 5 requests / 5 minutes per source IP**
|
||||
(`docs/05`). Not expressible in handler code, and it is carrying load
|
||||
the unimplementable 3-second timing check was specified to carry — see
|
||||
`docs/05` §Three deviations.
|
||||
- [ ] **DYNAMODB TTL ENABLED ON THE TABLE, AND VERIFIED BY A TEST RECORD. THIS
|
||||
ONE BACKS A PUBLISHED PROMISE AND MUST NOT BE TICKED FROM THE HANDLER
|
||||
CODE.** `/legal/privacy/` states that a record is *"deleted automatically
|
||||
by the database rather than by someone remembering to do it"* after 24
|
||||
months. The handler writes the `ttl` attribute; **TTL must also be enabled
|
||||
on the table**, and §7 does not record that it is. Until this is verified,
|
||||
that page promises a mechanism that may not run — which on a privacy policy
|
||||
is a false statement to the public. `aws dynamodb describe-time-to-live
|
||||
--table-name <the table §7 records>` and confirm
|
||||
`TimeToLiveStatus: ENABLED`, then write a record with a near-future `ttl`
|
||||
and confirm it disappears.
|
||||
- [ ] **`npm run check:intake`** — the form's field table and the Lambda's agree
|
||||
on names, requiredness, caps and option sets. They are two independent
|
||||
tables on purpose (`docs/05`); this is what stops them drifting
|
||||
- [ ] **Both POST-redirect-GET landing pages reachable and `noindex`** —
|
||||
`/contact/received/` and `/contact/could-not-send/`. Both are excluded from
|
||||
the sitemap in `astro.config.mjs`
|
||||
- [ ] **Regenerate and re-commit the bio PDF** — `npm run build && npm run bio:pdf`.
|
||||
Nothing in the build does this, so a change to `/bio/`, to §4, to the rate
|
||||
card or to the print styles leaves the committed PDF stale. It asserts one
|
||||
page and writes nothing if the count is wrong (R16). ⚠️ **It is not
|
||||
byte-reproducible** — Chrome stamps a `/CreationDate`, so two runs of
|
||||
identical content differ in digest and every re-render is a binary diff.
|
||||
Re-commit it when something actually changed, and say what in the message
|
||||
- [ ] **`X-Robots-Tag: noindex` on `*.pdf`**, via a CloudFront response-headers
|
||||
policy. **This is the PDF half of a decision already taken for the page.**
|
||||
`/bio/` is `noindex` and excluded from the sitemap because it is a condensed
|
||||
duplicate of `/about/` and `/fees/`, and *"two URLs competing on the same
|
||||
content is the one thing `docs/04` is most concerned with."* The committed
|
||||
PDF is a rendering of that same page, it is linked from `/about/` which is
|
||||
`index,follow`, `robots.txt` allows everything, and **Google indexes PDFs** —
|
||||
so the duplicate the page-level `noindex` prevents arrives as a file
|
||||
instead. A `Disallow` will not do it: a blocked URL can still be listed.
|
||||
Found by `adversarial-reviewer`, 2026-08-31
|
||||
- [ ] Booking link works, including the no-JavaScript fallback — **conditional on R6**; booking is parked and `CONTACT.bookingUrl` is `null`, so nothing renders and this passes vacuously until a tool is chosen. **Nothing on `/contact/` mentions booking**, deliberately
|
||||
- [ ] Favicon set complete
|
||||
- [ ] Tested on iOS Safari, Android Chrome, desktop Safari/Chrome/Firefox
|
||||
- [ ] Tested at 320 px and at 200% zoom
|
||||
|
||||
Reference in New Issue
Block a user