Files
adr-sml/src/pages/contact.astro
T
Pouya LajevardiandClaude Opus 5 3c3ba5dc6e
Build and deploy / build-and-deploy (push) Failing after 4s
feat: price med-arb by phase, attest the conflicts undertaking, and answer the first real spam
Pouya's rulings of 2026-09-03 (the last two D20 findings) and 2026-09-04 (the
spam observation and four mitigations), in one change set.

D20 finding 10 — med-arb is billed BY PHASE, each phase at the rates already
published, so /fees/'s "Every figure is on this page" is true as written rather
than narrowed. FEES.medArb is the single source; docs/07 §Med-arb carries the
rule INTERIM against R5, and R5 now carries it back, because a derived price
moves silently when a rate moves.

D20 finding 13 — conduct undertaking (g), attested 2026-09-03, published as his
wording verbatim on /legal/privacy/ and /contact/. The clause that raised the
finding promised to DISCLOSE a conflicts check's outcome, which the attestation
does not cover; it is struck. D20 now partitions 17 fixed / 2 refuted / 1 owed.

Spam, 2026-09-04 — recorded in docs/05 §Observed abuse with the date and
signature. A second honeypot (a decoy checkbox, own class, `hidden`, a label
that tells a human not to tick it) and scoring that LABELS and never rejects:
nothing is dropped, nothing new is stored, and only the operator notification
changes. Q65 opens the WAF cost call.

The timing floor could not be built: there is no timing check and never has
been. docs/05 carries it struck, and every mechanism that would give a real
per-visitor clock breaks zero-JS, handler-and-form-only, or D1. Q66.

configure.mjs gains section 5 — a custom origin request policy forwarding
CloudFront-Viewer-Address on /api/*. Written, dry-run against the live
distribution, NOT applied. It reads the handler's own header reads and refuses
to run if the whitelist omits one.

And reading the live account to do it found four AGENTS.md §7 rows saying the
intake backend was undeployed, two days after it went live — corrected against
get-function-configuration, get-routes, get-stage, get-policy and the deployed
zip, which was downloaded and read.

Review: adversarial-reviewer only (claims-auditor is D20's cutover pass and has
run). Round 1 five lenses, 56 findings, 7 blocking, 4 refuted by an independent
refuter; round 2 four lenses, 36 findings, 33 of them defects in round 1's own
repairs. Stopped at two per D19.

Gates, exit status read for each: check 0 · build 0 (23 pages) · check:claims 0
· check:intake 0 · og:proof 0 · lint 0 · spam-score.test 39/39 with 6/6 mutations
killed · router.test 30/30 · minifier grep 1 (clean) · lighthouse 0, no category
below 95 · configure.mjs dry run 0, nothing written.

Nothing deployed and nothing applied.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Md3GndFqWPzK78xAoebsg5
2026-09-04 10:06:37 -04:00

573 lines
22 KiB
Plaintext

---
/**
* `/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 Undertaking from '../components/Undertaking.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 {
CONDUCT_UNDERTAKINGS,
CONTACT,
NO_RETAINER_NOTICE,
} from '../data/site';
import {
CONSENT_TEXT,
DECOY_CHECKBOX_FIELD,
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 for one reason.</p>
{
/* RENDERED FROM `CONDUCT_UNDERTAKINGS`, NEVER TYPED — undertaking (g).
⚠️ THIS PAGE HAND-TYPED THE SAME PROPOSITION AS *"I cannot accept an
appointment before conflicts are checked"* UNTIL 2026-09-04, and it
survived the change set that struck the identical sentence from
`/legal/privacy/` — one file swept, its sibling missed, which is the
shape R8 exists for. §4 row (g) lists BOTH surfaces. */
}
<Undertaking>{CONDUCT_UNDERTAKINGS.conflictsCheck}</Undertaking>
<p>
That check needs names, and the call above is where it happens. 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. */
}
{
/* `hidden` ADDED 2026-09-04, for the reason spelled out on the decoy
below: a class-only rule leaves this field on screen wherever author
styles do not apply, and a visitor who fills it loses their inquiry
behind a success page. */
}
<div class="honeypot" hidden 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 PRIVACY LINK MUST STAY OUT OF THIS LABEL. Two reasons, both
about the one REQUIRED control on the form: a focusable element
inside a `<label>` for another control behaves inconsistently across
engines, and the checkbox's accessible name becomes the whole
paragraph plus "Privacy policy link" — re-announced on every
validation failure. The consent wording itself must be verbatim from
`CONSENT_TEXT`, so it stays in the label; the link is DESCRIBED
instead, via `aria-describedby`. */
}
<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>
{
/* THE SECOND HONEYPOT — a decoy CHECKBOX. The mechanism, and the
limits of what the observed spam supports, are in `src/data/intake.ts`
and are not restated here. Four properties of the MARKUP, each of
which is what stops this field costing a real inquiry:
· its own CLASS NAME, not `.honeypot` — one selector must not
match both traps. They share a declaration block below, which is
presentation; what matters is that `.honeypot` does not select
this one;
· placed after the consent block, not beside the other honeypot;
· `hidden` as well as the CSS rule, so it stays hidden where
author styles do not apply;
· a label that tells a human not to tick it. With `hidden` in
place a human essentially cannot see it, so this is the last
line rather than the first — and it costs almost nothing,
because the PLAUSIBLE NAME is what a bot matches on and the name
is unchanged.
⚠️ NO `required`, AND NO `checked`. An unchecked box sends nothing,
so absence is the pass — and the handler tests for a NON-EMPTY value,
so an empty one passes too. */
}
<div class="optin-decoy" hidden aria-hidden="true">
<label for={DECOY_CHECKBOX_FIELD}>Leave this box unticked.</label>
<input
type="checkbox"
id={DECOY_CHECKBOX_FIELD}
name={DECOY_CHECKBOX_FIELD}
value="on"
tabindex="-1"
autocomplete="off"
/>
</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 what gives the labels their semantics without spending a
heading level on them. */
.direct {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(14rem, 100%), 1fr));
gap: var(--space-5);
margin-block-start: var(--space-8);
}
/* `--text-eyebrow`, not `--text-2xs`: these `<dt>`s are label text on the page
that collects inquiries, so they move with the `<label>`s below rather than
sitting a step behind them. Pouya's ruling, 2026-08-31. */
.direct dt {
font-family: var(--font-mono);
font-size: var(--text-eyebrow);
font-weight: var(--weight-medium);
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 email address has no break opportunity and overflowed at a 200% default
font size. It must stay selectable and correct, so it breaks rather than
being truncated. `docs/02` §Reflow carries the measurement. */
overflow-wrap: anywhere;
}
/* 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);
}
/* NOT the `.eyebrow` class, deliberately: `--text-secondary` (11.75:1) rather
than `.eyebrow`'s `--text-meta` (5.47:1), because a form label is operative
text. Everything else matches it, `font-weight` included — without that these
rendered at 400 under a `p.eyebrow` of the same size and colour.
`overflow-wrap` because at a 200% default font size "Firm or organisation"
ran 38px outside its own box at 320px: `docs/02` §Reflow, instrument
finding 2. */
label,
legend {
font-family: var(--font-mono);
font-size: var(--text-eyebrow);
font-weight: var(--weight-medium);
letter-spacing: var(--tracking-eyebrow);
text-transform: uppercase;
color: var(--text-secondary);
overflow-wrap: anywhere;
}
/* 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 IS THE FLOOR (`docs/02` §Accessibility floor) and this row was 25.6px:
an 18.4px control plus one line of body text, with no `::after { inset: 0 }`
overlay to enlarge it. `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,
.optin-decoy {
display: none;
}
.submit {
justify-self: start;
}
</style>