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
+225
View File
@@ -0,0 +1,225 @@
/**
* The intake form's fields. Spec: docs/05-backend-spec.md §Form fields.
*
* ⚠️ **THE LAMBDA HAS ITS OWN COPY OF THIS TABLE, AND THAT DUPLICATION IS
* DELIBERATE — IT IS NOT THE SES-DKIM SHAPE.** `docs/05` is explicit: *"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 attacker what the rules are. So `backend/intake/handler.mjs`
* carries an independent table and trusts nothing from here.
*
* What stops the two drifting is a check rather than a shared import:
* **`npm run check:intake`** asserts that the two tables agree on every field
* name, on which are required, and on every length cap — and fails the build
* script if they do not. Independent validation, mechanically cross-checked. If
* you add a field here, add it there, and the check will tell you if you didn't.
*
* WHAT THIS DATA IS, because it changes how the form is built (`docs/05`): in a
* live legal dispute this collects the inquirer's identity, **the names of
* opposing parties and their counsel**, and the nature of the dispute. That is
* personal information about identifiable third parties who have not consented
* and do not know the submission happened. Hence: no dollar amounts, no
* uploads, an explicit unchecked consent box, and a matter summary whose hint
* tells the writer not to put privileged detail in it.
*/
export type IntakeField = {
name: string;
label: string;
/** `select` and `radio` carry `options`; everything else does not. */
type: 'text' | 'email' | 'tel' | 'select' | 'radio' | 'textarea' | 'checkbox';
required: boolean;
/** Maximum characters. The Lambda REJECTS over this rather than truncating —
* a silently truncated matter summary is a misread file. */
max?: number;
options?: readonly string[];
/** Rendered under the field. */
hint?: string;
/** `autocomplete` token, where one genuinely applies. Omitted rather than
* guessed: a wrong token makes a browser fill the wrong value. */
autocomplete?: string;
};
/**
* ⚠️ **DO NOT ADD A DOLLAR-AMOUNT FIELD.** `docs/05`: *"Do not collect dollar
* amounts, document uploads, or anything the inquirer might reasonably treat as
* privileged. The intake call is for that."* The old site invented matter values;
* this form is the one place a real one could arrive and then need storing.
*/
export const INTAKE_FIELDS: readonly IntakeField[] = [
{
name: 'name',
label: 'Your name',
type: 'text',
required: true,
max: 120,
autocomplete: 'name',
},
{
name: 'email',
label: 'Email',
type: 'email',
required: true,
max: 254, // RFC 5321 maximum path length; not a round number by choice.
autocomplete: 'email',
},
{
name: 'phone',
label: 'Phone',
type: 'tel',
required: false,
max: 40,
autocomplete: 'tel',
hint: 'Optional.',
},
{
name: 'role',
label: 'Your role',
type: 'select',
required: true,
options: ['Counsel', 'In-house', 'Party', 'Institution', 'Other'],
},
{
name: 'organisation',
label: 'Firm or organisation',
type: 'text',
required: false,
max: 160,
autocomplete: 'organization',
},
{
name: 'process',
label: 'Process sought',
type: 'select',
required: true,
/* The five from docs/05. "ENE" is expanded here because this is a form label
read by a party as well as by counsel, and §11's glossary authority is
about site copy rather than about abbreviating in a select. */
options: [
'Mediation',
'Arbitration',
'Med-Arb',
'Early neutral evaluation',
'Not sure',
],
},
{
name: 'practiceArea',
label: 'Subject matter',
type: 'select',
required: true,
/* THE SIX AREAS PLUS OTHER. Deliberately the short display names rather
than `PRACTICE_AREAS[].name`: those carry the full "Construction &
Infrastructure" form for a card heading, and a select is not a card. The
cross-check in `scripts/check-intake.mjs` compares these against the
handler's list, and `PRACTICE_SLUGS` remains the site's own source for
which areas exist. */
options: [
'Construction',
'Technology',
'Energy',
'Insurance',
'Shareholder',
'Cross-border',
'Other',
],
},
{
name: 'otherParties',
label: 'Other parties',
type: 'text',
required: false,
max: 300,
hint: 'Needed to run a conflicts check. Names only.',
},
{
name: 'opposingCounsel',
label: 'Opposing counsel',
type: 'text',
required: false,
max: 300,
hint: 'Also for the conflicts check.',
},
{
name: 'summary',
label: 'What the dispute is about',
type: 'textarea',
required: true,
max: 2000,
hint: 'A few sentences is enough. Please do not include privileged or confidential detail — that is what the intake call is for.',
},
{
name: 'timing',
label: 'Timing',
type: 'select',
required: false,
options: ['Urgent', 'Within 30 days', 'Within 90 days', 'Exploring'],
},
{
name: 'preferredContact',
label: 'Preferred reply',
type: 'radio',
required: false,
options: ['Email', 'Phone'],
},
];
/**
* THE CONSENT TEXT, VERBATIM FROM `docs/05` §Consent text. It is a legal notice
* the inquirer agrees to, so it is rendered from here and never retyped or
* reworded to fit a layout. Note that it says the same three things
* `NO_RETAINER_NOTICE` says — that constant is the site-wide statement and this
* is the one the inquirer ticks; both ship on `/contact/`, which is deliberate:
* `docs/01` requires the page to carry the notice, and `docs/05` requires the
* checkbox to carry it too.
*/
export const CONSENT_TEXT =
'I consent to Pouya Lajevardi storing and using the information in this form ' +
'to respond to my inquiry and to run a conflicts check. I understand that ' +
'submitting this form does not create a retainer, does not appoint a neutral, ' +
'and does not itself establish a mediatorparty relationship.';
/**
* The honeypot. `docs/05`: *"hidden from sighted and screen-reader users, must
* be empty"*.
*
* ⚠️ **`display: none` PLUS `tabindex="-1"` PLUS `aria-hidden`, AND THE NAME
* MATTERS.** A honeypot named `honeypot` is skipped by any bot worth stopping;
* one named like a real field is filled. `company_website` is a plausible field
* on a professional intake form and is not one this form has. It must never be
* reachable by keyboard or announced by a screen reader — a honeypot that traps
* a screen-reader user is an accessibility defect that also loses a real inquiry.
*/
export const HONEYPOT_FIELD = 'company_website';
/**
* WHERE THE FORM POSTS — AND IT IS A SAME-ORIGIN PATH, NOT THE API GATEWAY
* HOSTNAME. This is a design decision with four consequences, taken at step 8
* and recorded because the obvious implementation is the other one.
*
* The obvious version posts to the execute-api hostname `AGENTS.md` §7 records.
* Posting to `/api/intake` instead, with a CloudFront behaviour routing `/api/*`
* to that origin:
*
* 1. **`Content-Security-Policy: form-action 'self'`** — `docs/05` specifies
* `form-action 'self' <api-endpoint>`; with a same-origin post the second
* term is unnecessary, so the policy is strictly tighter.
* 2. **No cross-origin POST at all**, so no CORS question for the form. (CORS
* never governed it anyway — a form POST is a top-level navigation, not an
* XHR, so it is exempt from preflight. `docs/05`'s CORS line protects the
* endpoint against scripted calls from other origins, which is a different
* control, and the handler's `Origin` check is what covers the form.)
* 3. **The endpoint id stays out of the HTML**, so it is not restated in the
* repo either — §7 remains the only place it lives.
* 4. **Submitting locally does nothing.** `astro dev` has no `/api/` route, so
* a POST 404s. Under the alternative, clicking Submit on a laptop would
* write a real DynamoDB record and send two real emails.
*
* ⚠️ **THE COST, STATED RATHER THAN LEFT TO BE DISCOVERED: THE FORM DOES NOT
* WORK UNTIL THAT CLOUDFRONT BEHAVIOUR EXISTS AND THE HANDLER IS DEPLOYED.**
* Neither has been done — nothing on this project deploys before cutover (D11),
* and both are checklist items in `docs/06`. Until then the page is complete and
* the pipe behind it is not, which is why `/contact/` also publishes the email
* address rather than treating the form as the only way in.
*/
export const INTAKE_ACTION = '/api/intake';