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
+516
View File
@@ -0,0 +1,516 @@
---
/**
* `/contact/` — build step 8. Spec: docs/01 §`/contact/`, docs/05-backend-spec.md.
*
* ⚠️ **THE FORM USES NO JAVASCRIPT, AND THAT IS NOT A CONSTRAINT WORKED AROUND —
* IT IS THE DESIGN.** A plain `<form method="post">` to a same-origin path; the
* handler answers `303 See Other` to `/contact/received/`. So it works with
* script disabled, cannot double-submit on refresh, and never shows the visitor a
* raw JSON response. `backend/intake/handler.mjs` carries the reasoning in full.
*
* Consequences that shape the markup:
* - **Validation errors land on `/contact/could-not-send/`**, because a static
* page cannot read a query string without script. In practice the browser's
* own `required` / `type="email"` / `maxlength` handling catches the real
* cases and announces them natively, which is what `docs/05`'s
* "errors announced with `role="alert"`" asks for; a server rejection is
* almost always a bot, and a bot gets the success page (see the handler).
* - **No booking embed — R6.** Parked by Pouya 2026-08-26. `docs/01` asks for a
* "reserved slot for an embed", so the slot is `CONTACT.bookingUrl` being
* `null`: nothing renders, and when a URL exists the block appears without a
* rebuild of this page. **Nothing on this page mentions booking**, because a
* page that says "book a call" with no way to book it is worse than one that
* says to email.
*
* ⚠️ **THE RESPONSE-TIME SENTENCE IS A PUBLIC COMMITMENT (§4, Q27) AND MUST READ
* IDENTICALLY HERE, IN THE CONFIRMATION EMAIL, AND IN ANY BIO.** It is rendered
* from `CONTACT.responseTime`; the handler takes the same string from its
* environment. Never retype it, and never soften it to "usually".
*
* ⚠️ **`NO_RETAINER_NOTICE` AND `CONSENT_TEXT` BOTH SHIP, AND THAT IS NOT
* DUPLICATION.** `docs/01` requires the page to carry the notice; `docs/05`
* requires the consent the inquirer TICKS to carry it too. One is a statement the
* page makes, the other is a thing the inquirer agrees to. Neither is retyped.
*
* ⚠️ **NO PHONE NUMBER — Q3. §4 verifies "no public phone number".** Render
* `CONTACT.phoneFallback` wherever a number would go rather than leaving the slot
* visually empty.
*/
import BaseLayout from '../layouts/BaseLayout.astro';
import Button from '../components/Button.astro';
import ContactBand from '../components/ContactBand.astro';
import Eyebrow from '../components/Eyebrow.astro';
import SectionHeading from '../components/SectionHeading.astro';
import { getImage } from 'astro:assets';
import ogDefault from '../assets/og-portrait.jpg';
import { pageGraph } from '../data/schema';
import { CONTACT, NO_RETAINER_NOTICE } from '../data/site';
import {
CONSENT_TEXT,
HONEYPOT_FIELD,
INTAKE_ACTION,
INTAKE_FIELDS,
} from '../data/intake';
const ldImage = await getImage({
src: ogDefault,
format: 'jpeg',
width: 1200,
height: 630,
});
/* No `Service` node. `/contact/` offers nothing — it is the way in to what the
other pages offer, and a `Service` here would duplicate an `@id` that already
resolves on `/mediation/`. Person alone, the `/practice/` and `/process/`
shape. No `BreadcrumbList`: one hop from the root, no visible trail. */
const graph = pageGraph(new URL(ldImage.src, Astro.site).href);
const hintId = (name: string) => `${name}-hint`;
---
<BaseLayout
title="Contact · Request a Consultation · Pouya Lajevardi"
description="Request a confidential intake call about a mediation, arbitration or med-arb appointment in Ontario. Inquiries are answered within two business days."
jsonLd={graph}
>
{/* ---- 1. Hero -------------------------------------------------------- */}
<section class="section hero">
<div class="wrap">
<Eyebrow dot>Contact</Eyebrow>
<h1 class="display hero-h">Start with a confidential call.</h1>
<p class="hero-lede">
The first step is a scheduled call to scope the matter, identify the
parties, and run conflicts. Send the form below, or email me directly.
</p>
<dl class="direct">
<div>
<dt>Email</dt>
<dd><a href={`mailto:${CONTACT.email}`}>{CONTACT.email}</a></dd>
</div>
<div>
<dt>Phone</dt>
{
/* Q3: no public number. The fallback fills the slot rather than
leaving a labelled row visually empty. */
}
<dd>{CONTACT.phoneFallback}</dd>
</div>
<div>
<dt>Location</dt>
<dd>{CONTACT.location}</dd>
</div>
<div>
<dt>Response</dt>
<dd>{CONTACT.responseTime}</dd>
</div>
</dl>
</div>
</section>
{/* ---- 2. What an inquiry does and does not do ------------------------ */}
<section class="section section-inverse reveal">
<div class="wrap">
<div class="section-head">
<SectionHeading eyebrow="Before you write" level={2}>
<span slot="heading">What an inquiry is, and what it is not.</span>
</SectionHeading>
</div>
<div class="prose">
<p class="statement">{NO_RETAINER_NOTICE}</p>
<p>
I ask for the other parties and their counsel because I cannot accept
an appointment before conflicts are checked, and that check needs
names. Please keep the summary short and leave privileged or
confidential detail out of it — the call is for that.
</p>
<p>
What is collected, where it is stored, how long it is kept, and how to
have it deleted are set out in the <a href="/legal/privacy/"
>privacy policy</a
>.
</p>
</div>
</div>
</section>
{/* ---- 3. The intake form -------------------------------------------- */}
<section class="section reveal">
<div class="wrap">
<div class="section-head">
<SectionHeading
eyebrow="Intake"
level={2}
lede="Required fields are marked. Nothing here is a retainer or an appointment."
>
<span slot="heading">Tell me about the matter.</span>
</SectionHeading>
</div>
{
/* `novalidate` IS DELIBERATELY ABSENT. The browser's own validation is
the only client-side validation on this page, and with no script it is
also the only thing that can announce an error inline — which is what
`docs/05`'s `role="alert"` item is really asking for. The Lambda
re-validates everything regardless; see `src/data/intake.ts`. */
}
<form class="intake" method="post" action={INTAKE_ACTION}>
{
INTAKE_FIELDS.map((field) => (
<div class={`field field-${field.type}`}>
{field.type === 'radio' ? (
<fieldset>
<legend>{field.label}</legend>
<div class="radios">
{field.options?.map((option) => (
<label class="radio">
{/* No default selection. `preferredContact` is
optional, and pre-checking "Email" would submit a
preference the inquirer never expressed. */}
<input type="radio" name={field.name} value={option} />
<span>{option}</span>
</label>
))}
</div>
</fieldset>
) : (
<>
<label for={field.name}>
{field.label}
{field.required && (
<>
{' '}
<span class="req" aria-hidden="true">
*
</span>
<span class="visually-hidden">(required)</span>
</>
)}
</label>
{field.type === 'select' ? (
<select
id={field.name}
name={field.name}
required={field.required || undefined}
aria-describedby={
field.hint ? hintId(field.name) : undefined
}
>
{/* An empty first option, so a required select cannot be
satisfied by whichever value happened to be first. */}
<option value="">Choose one</option>
{field.options?.map((option) => (
<option value={option}>{option}</option>
))}
</select>
) : field.type === 'textarea' ? (
<textarea
id={field.name}
name={field.name}
rows="6"
maxlength={field.max}
required={field.required || undefined}
aria-describedby={
field.hint ? hintId(field.name) : undefined
}
/>
) : (
<input
type={field.type}
id={field.name}
name={field.name}
maxlength={field.max}
autocomplete={field.autocomplete}
required={field.required || undefined}
aria-describedby={
field.hint ? hintId(field.name) : undefined
}
/>
)}
{field.hint && (
<p class="hint" id={hintId(field.name)}>
{field.hint}
</p>
)}
</>
)}
</div>
))
}
{
/* THE HONEYPOT. Hidden from sighted users by `display: none` on the
wrapper, from assistive technology by `aria-hidden`, and from the
keyboard by `tabindex="-1"` — all three, because any one alone leaves
a real visitor able to reach a field that silently discards their
inquiry. `autocomplete="off"` matters more here than anywhere else on
the form: a browser that helpfully fills a plausible-looking field
would make a human look like a bot. */
}
<div class="honeypot" aria-hidden="true">
<label for={HONEYPOT_FIELD}>Company website</label>
<input
type="text"
id={HONEYPOT_FIELD}
name={HONEYPOT_FIELD}
tabindex="-1"
autocomplete="off"
/>
</div>
{
/* ⚠️ THE LINK CAME OUT OF THE LABEL, AND THE LABEL IS NOW THE CONSENT
SENTENCE ALONE. Two defects in one element, found by
`adversarial-reviewer` 2026-08-31 and measured at 390px (the label
was 342 × 205 px and the nested anchor hit-tested as `<a>`,
100 × 21):
1. **A focusable interactive element inside a `<label>` for another
control.** Clicking it navigated rather than toggling, which is
the behaviour a reader wants — but label/link nesting is not
consistent across engines, so which of the two wins was left to
the browser.
2. **The checkbox's accessible name was a 250-character paragraph
ending "Privacy policy."** This is the one REQUIRED control on the
form, so it is also the one whose name is re-announced on every
validation failure.
The consent wording still has to be what the inquirer agrees to, so
it stays in the label verbatim from `CONSENT_TEXT`. The link moves to
a sibling that `aria-describedby` points at — described, not named.
The privacy policy is also linked twice above this form, so nothing
is lost. */
}
<div class="field field-consent">
<label class="consent">
<input
type="checkbox"
name="consent"
value="on"
required
aria-describedby="consent-privacy"
/>
<span>{CONSENT_TEXT}</span>
</label>
<p class="consent-note" id="consent-privacy">
How that information is handled, and how to have it deleted: <a
href="/legal/privacy/">privacy policy</a
>.
</p>
</div>
{
/* ⚠️ `<Button type="submit">`, NOT a hand-written `<button class="btn">`.
`.btn` and `.btn-primary` are SCOPED TO `Button.astro`, so a raw
button carrying those class names compiles against this page's cid,
matches nothing, and renders as an unstyled default button — the
parent-scope trap `CLAUDE.md` records, arrived at from the other
direction. The first version of this file did exactly that.
AND IT IS WRAPPED IN A DIV THIS PAGE OWNS, for the same rule read
forwards: `.submit` on `<Button>` itself would compile to
`.submit[cid-of-this-page]` and never match the rendered element. */
}
<div class="submit">
<Button type="submit">Send the inquiry</Button>
</div>
</form>
</div>
</section>
<ContactBand />
</BaseLayout>
<style>
.hero {
padding-block-start: var(--space-9);
}
.hero-h {
margin-block: var(--space-4) var(--space-5);
font-size: var(--text-6xl);
}
.hero-lede {
max-inline-size: 58ch;
font-size: var(--text-lg);
line-height: var(--leading-body);
color: var(--text-secondary);
}
/* The direct-contact block. A `<dl>` because each row is genuinely a
term and its value, which is also what lets the labels stay legible at
small sizes without a heading level. */
.direct {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(14rem, 100%), 1fr));
gap: var(--space-5);
margin-block-start: var(--space-8);
}
.direct dt {
font-family: var(--font-mono);
font-size: var(--text-2xs);
letter-spacing: var(--tracking-eyebrow);
text-transform: uppercase;
color: var(--text-meta);
}
.direct dd {
margin-block-start: var(--space-2);
font-size: var(--text-base);
line-height: var(--leading-snug);
}
/* The no-retainer sentence, set larger than the paragraphs under it. On an
inverse ground it inherits cream (16.81:1) from `.section-inverse`. */
.statement {
font-size: var(--text-lg);
line-height: var(--leading-body);
}
/* --- The form -------------------------------------------------------- */
.intake {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(20rem, 100%), 1fr));
gap: var(--space-5) var(--space-6);
max-inline-size: 56rem;
}
/* The two long fields span the whole form rather than sitting in a column
20rem wide. `1 / -1` works at every column count the auto-fit produces. */
.field-textarea,
.field-consent,
.submit {
grid-column: 1 / -1;
}
.field {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
label,
legend {
font-family: var(--font-mono);
font-size: var(--text-2xs);
letter-spacing: var(--tracking-eyebrow);
text-transform: uppercase;
color: var(--text-secondary);
}
/* Maroon on cream is 12.29:1, so the asterisk is legible — but it is
`aria-hidden` and paired with a visually-hidden "(required)", because
colour and a glyph must never be the only carrier of meaning (docs/02). */
.req {
color: var(--accent);
}
input,
select,
textarea {
inline-size: 100%;
padding: var(--space-3) var(--space-4);
font-family: var(--font-sans);
/* 1rem, not smaller. iOS Safari zooms the viewport on focus for any font
size under 16px, which on a form this long throws the layout sideways. */
font-size: var(--text-base);
line-height: var(--leading-snug);
color: var(--text);
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
/* 44px minimum target (WCAG 2.5.8) comes from the padding plus this
line-height; measured rather than set with a fixed height, so a longer
label or a zoomed root does not crush it. */
}
textarea {
resize: vertical;
line-height: var(--leading-body);
}
input:focus-visible,
select:focus-visible,
textarea:focus-visible {
outline: 2px solid var(--focus-ring);
outline-offset: var(--focus-offset);
}
.hint {
font-size: var(--text-sm);
line-height: var(--leading-snug);
color: var(--text-meta);
}
fieldset {
padding: 0;
border: none;
}
.radios {
display: flex;
flex-wrap: wrap;
gap: var(--space-4);
margin-block-start: var(--space-2);
}
.radio,
.consent {
display: flex;
gap: var(--space-3);
/* The label text next to a control is sentence case and normal size — the
mono uppercase treatment above is for the field's own name, and applying
it to a paragraph of consent text would be unreadable. */
font-family: var(--font-sans);
font-size: var(--text-base);
letter-spacing: var(--tracking-normal);
text-transform: none;
line-height: var(--leading-body);
color: var(--text-secondary);
}
/* ⚠️ 44px MINIMUM, AND IT WAS 25.6px. `docs/02` §Accessibility floor sets
44 × 44 for a touch target and `CLAUDE.md` calls that floor a build
requirement, not a polish pass. Hit-tested at 390px by
`adversarial-reviewer`: the label rect measured 70.6 × **25.6** and the hit
height 25px — an 18.4px control plus one line of body text, with no
`::after { inset: 0 }` overlay to enlarge it the way the cards on `/` have.
WCAG 2.2 SC 2.5.8's 24px was met; this project's own floor was not, and
`docs/02` grants no exception for a form control.
`min-block-size` rather than padding, so the label grows to the floor and no
further — padding would push the two radios apart at every width. */
.radio {
align-items: center;
min-block-size: 44px;
}
.consent {
align-items: flex-start;
max-inline-size: var(--width-prose);
}
/* Indented to the label's text column so it reads as belonging to the
checkbox — 1.15rem control plus the flex gap. */
.consent-note {
margin-block-start: var(--space-3);
margin-inline-start: calc(1.15rem + var(--space-3));
max-inline-size: var(--width-prose);
font-size: var(--text-sm);
line-height: var(--leading-body);
color: var(--text-meta);
}
.radio input,
.consent input {
inline-size: 1.15rem;
block-size: 1.15rem;
flex: none;
padding: 0;
/* The checkbox sits on the first line of its own label text rather than at
the top of the box, which is where `flex-start` alone would put it. */
margin-block-start: 0.25em;
accent-color: var(--accent);
}
/* THE HONEYPOT. `display: none` is what keeps it out of the layout AND out of
the accessibility tree; `aria-hidden` on the wrapper and `tabindex="-1"` on
the input are belt and braces for the case where a future stylesheet
un-hides it. Do not swap this for `visibility` or an off-screen position:
an off-screen input is still focusable and still announced. */
.honeypot {
display: none;
}
.submit {
justify-self: start;
}
</style>