Compare commits

...
10 Commits
Author SHA1 Message Date
Pouya LajevardiandClaude Opus 5 c576b9a85f feat: build step 3 — /about/, minus its memberships; close Q40–Q43
Build and deploy / build-and-deploy (push) Failing after 6s
Applies Pouya's rulings on Q42, Q41(a)(b)(c), Q43 and Q40, then builds
`/about/` — six of docs/01's seven items.

`/about/` ships WITHOUT a memberships group. R10 is a prohibition on
shipping a page that lists memberships before they are re-confirmed; the
re-confirmation is a fact only Pouya holds and was not obtained. The
first version published all four and disclosed the gap in five places
instead; both review agents rejected that. Q44 carries the question.

Rulings:
- Q42 — ENE, dispute-system design and pre-dispute technical advisory
  rowed; settlement counsel struck as a partisan role. The strike
  exposed a hole in the offering test, which now states the prior
  question it was missing.
- Q41(a) — Q37 reaches prose, and prose is held to a higher bar. The
  sentence is now one constant, ASYMMETRY_LINE, because two hand-typed
  copies had already diverged inside one session.
- Q41(b) — not restored; the implication turned out to be in three
  places, two of which survived the sweep that closed it.
- Q41(c) — verified against the LAT's own Rules and extracted into
  docs/reference/lat-case-conference.md. Rule 2.4 makes "Pre-Hearing
  Conference" the Tribunal's own term for a case conference; the Rules
  contain zero occurrences of `mediat` in 66,593 characters.
- Q43 — the timings are service commitments; PROCESS_FRAMING renders
  adjacent to them, not in a lede above.
- Q40 — bundled to step 7 as R15, blocking cutover.

Four review passes, 43 findings, nine of them defects in their own
predecessors' fixes. The worst was mine: the false universal Q39 struck
reached a public page. Also fixed a portrait ladder that upscaled 1.93x
at 1024/DPR2 on BOTH pages — the shipped home page included — because
its 960 ceiling was derived from the layout range where the image is
narrowest.

Verified: check/lint/build/audit clean; 0 upscaling across 11 device
profiles; 0 overflow and 0 over-wide elements at 13 widths; 0 contrast
failures across 127 and 88 painted pairs; 0 print failures against white
paper; reveal 0 hidden under reduced-motion and print; zero JavaScript.
Lighthouse NOT RUN — tool unavailable until step 7 (R11). HTML validator
NOT RUN.

Opens Q44 (memberships), Q45 (PDF bio), Q46 (offering-test gating; the
glossary standard), Q47 (jobTitle without worksFor). Adds R15.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0148NztQskLKKApP5SzAA78e
2026-08-28 12:10:41 -04:00
Pouya LajevardiandClaude Opus 5 165d259f5c feat: build step 2 — the home page; close Q35, Q37, Q39; Q39's answer corrected the register
Pouya's rulings and the page are one commit, not two, because they are not
separable: Q37 changed the credential label the page renders, Q39 scoped the
arbitration copy, and Q35 gave Med-Arb the row that lets the footer link stand.
Splitting them would produce a commit where the register says one thing and the
page says another.

RULINGS

Q39 — ANSWERED, and my assertion was FALSE as a universal. Pouya checked
rather than defended. Family arbitration in Ontario is gated: prescribed
training, 14h on screening for domestic violence and power imbalances, 30h of
Ontario family law for arbitrators outside the bar, 10h ongoing per two years.
claims-auditor produced that counter-example from inside the repo on 2026-08-26
and it was right. The source is now IN the repo per R14 —
docs/reference/ontario-family-arbitration-training.md, retrieved with its
provenance — and §4 states which half it establishes: the family gate directly,
the commercial half only by absence. So "commercial arbitration in Ontario
requires no licence and no designation" is recorded as Pouya's stated position,
unstamped. What disposes of the question is the scope exclusion: he does not do
family arbitration, so it is NOT OFFERED and the gate never bears on the
practice.

The false universal survived in three more live places, found by grep, not
recall: §4's NOT-NEGOTIABLE boundary bullet (the boundary paragraph breaching
its own boundary), §9's Q33 closure row, and a comment in SiteHeader.astro.

Q35 — ANSWERED, all four items, and the answer supplied a gate that was
missing. Med-Arb gets an Offerings row and stays in the footer sitemap. The six
subject-matter labels pass test 1. §4 gains "Subject-matter areas — the
publication gate": competence to accept an appointment, AND a page that frames
it as positioning without claiming history. Nothing in the nav or footer is
unrowed any more.

Q37 — ANSWERED. "Law and engineering" becomes "Legal training and engineering
practice". A degree is not a practice; the parallel was doing the implying.

Q38/R13 — the committed SVG does not close it; the walk-back went too far. It
renders faithfully BECAUSE it is the raster. R13 stays open.

R1 — surfaced and acknowledged; the interim licensure framing is now carried by
a shipped page rather than a spec, which raises the stakes.

BUILD STEP 2

Seven of docs/01's eight home sections, six new components, zero JavaScript.
Section 7 (Latest insights) ships at step 7 with the collection it lists —
recorded in docs/01 and in the page, not just here. Four credential slots, not
three: §4's paired-disclosure condition requires the Q.Arb stage on any page
that offers arbitration. No booking link (R6). The masthead tagline is
suppressed on / (it duplicated the hero eyebrow). The step-1 proof sheet is
deleted, and five live references to it were found by grep.

WHAT THE REVIEWS FOUND — 24 findings across the two passes

claims-auditor FAILED it on ten, every one implication or scope rather than
fabrication, which is where D13 says the risk lives. The four that mattered:
"I mediate and arbitrate" asserted a track record §4 does not hold for
arbitration; the JSON-LD asserted arbitration twice and stated the stage
nowhere (a crawler-only claim is still a claim); "at one published rate with
preparation time included" misdescribed money against docs/07's two day rates
and capped prep allowance; and "Law and engineering are not blended here" was
Q37's struck parallel relocated into prose one day after Pouya struck it.
It also found the Canadian Tax Foundation missing from §9 Q8 — a stale second
copy of a fact that would have bitten at step 3.

adversarial-reviewer found fourteen, three blocking. The first was
class="section-head" on <SectionHeading> never matching — the parent-cannot-
style-a-child defect for the FOURTH time, written into a diff where I had just
added fresh warnings about it to two other components. Measured: 0px margin,
0px gap, headings over the card edges, with astro check and eslint both clean.
I had looked at a screenshot of that section and passed over it. Fixed with a
page-owned wrapper (48px, measured) and the prop is deleted from six components
so it is now a build error. Also: the credential row was never "two up on a
phone" and its comment said it was; PROCESS was hardcoded in the page against
the reason written in site.ts; 83px of residual overflow at a 200% default font
size, now 3px.

Seven more I found myself first, including <Picture widths> declaring the
untouched 1600px master as the <img src> fallback (254,626 B for a 476px slot,
and the build log said "before: 349kB" either way), and a prop named `as`
silently turning off type-checking for a whole component.

VERIFICATION — run, not asserted. Full figures in AGENTS.md entry (w).

npx tsc 6.0.3 · check 0/0/0 · lint clean · build clean · audit 0
1 <script> and it is JSON-LD; non-JSON-LD scripts 0; no JS bundle; identical
page with script execution disabled (444 nodes, 6,578 chars)
Phase 5 minifier check: no `animation` shorthand beside animation-timeline
overflow 0 at 14 widths, AND every mark measured at 1.5654-1.5657 vs 1.5657
one h1, no heading skips, focus order == DOM order across 44 focusables
31 painted contrast pairs at 3 widths, 0 failures
72/72 hit-test points across 8 cards resolve to the card's link
print 0 hidden, reduced-motion 0 hidden
Lighthouse NOT RUN — tool unavailable until step 7 (R11)
HTML validator NOT RUN, and 4 of 12 srcsets carry a duplicate 1x descriptor

Opened for Pouya: Q40 (one OG image for nineteen pages), Q41 (does Q37 reach
prose; may the comparative be restored; what LAT pre-hearing mediation means),
Q42 (the four "also offered" processes have no row), Q43 (the process timings
are published commitments with no row).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0148NztQskLKKApP5SzAA78e
2026-08-27 16:31:56 -04:00
Pouya LajevardiandClaude Opus 5 8a2f513577 chore: R11 phase-boundary currency check — astro 7.2.9, TypeScript 6.0.3
Run at the step 1 → step 2 boundary, which is what R11 asks for rather than
waiting for something to break. `npm view <pkg> version` across all fourteen
pins; two were stale.

- astro ^7.2.7 → ^7.2.9. Patch. 7.2.8 published 2026-08-26, 7.2.9 2026-08-27.
- typescript ^5.9.3 → ^6.0.3. A full major behind, and installable.

TypeScript 7.0.2 is `latest` and is NOT taken — a deliberate hold with a
checkable reason, per R11. Both peer ranges exclude it:

  typescript-eslint@8.68.0  peer typescript >=4.8.4 <6.1.0
  @astrojs/check@0.9.10     peer typescript ^5.0.0 || ^6.0.0

6.0.3 is the newest stable both accept. Recorded in AGENTS.md §7 so the hold
is visible rather than looking like an oversight.

@lhci/cli is still 0.15.1 — unchanged, and it is not re-added here. R11's
re-add trigger is step 7.

Verified, not asserted: `npx tsc --version` 6.0.3 · `npm run check` 0 errors /
0 warnings / 0 hints · `npm run lint` clean (ESLint + Prettier) · `npm run
build` complete · `npm audit` 0 vulnerabilities.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0148NztQskLKKApP5SzAA78e
2026-08-27 15:39:10 -04:00
Pouya LajevardiandClaude Opus 5 364b09632e fix: replace the traced infinity mark with the real artwork; add §4 Offerings
Q32 — the traced mark was a WRONG SHAPE and had shipped. Pouya compared
it against the master and rejected it. Two grounds reproduce from the
path and are verified here: all four cubic branches meet the origin at
exactly 90 degrees, so the loops are tangent rather than crossing and at
stroke-width 28 render as two kissing circles (signed crossing number 0;
the strokes fuse across 61% of the mark's height at 2rem); and the
master's ink bbox is 2668x1704 = 1.5657:1. The path is deleted, not kept
as a fallback.

Pouya's 1.23:1 figure is reconciled rather than left dangling: it is the
bounding box of the path's COORDINATES, not the curve. Control points sit
at y +/-160 where the curve reaches +/-120, so the hull is 400x320 and
with stroke 428x348 = 1.2299. A trap rather than a slip — x is monotone,
so the control points give the right width and a 33% inflated height, and
the "does the width look right" check passes.

The real artwork is now in the repo: master, tight crop (the render
source, so the file's aspect ratio IS the mark's), full lockup, and the
SVG. InfinityMark renders AVIF/WebP; a Retina device takes 3,063 B.
Favicons regenerated; favicon.svg deleted.

Q33/Q36 — Pouya accepts arbitration appointments now. §4 gains an
Offerings category: competence for an offering, permission for a
credential, with an explicit boundary so it cannot become a route around
D13. The masthead tagline is restored, and the footer designation strip
now carries "Q.Arb — commenced August 2026" so §4's paired-disclosure
condition is actually met on every page rather than only asserted.

Two conventions added to CLAUDE.md, both earned this session: anything a
spec makes a claim about must be reachable from the repo (R14 — the
traced mark survived two review passes because the artwork was not here
to compare against); and a command that did not run is not evidence of
absence (`timeout` is not installed on macOS, so four Drive reads never
executed and were reported as an empty directory).

Reviews: claims-auditor FAIL/13 and adversarial-reviewer 2 blocking, all
resolved. The severe one was self-inflicted — `flex: none` landed on the
<img> while <Picture>'s <picture> wrapper is the flex item, so the logo
compressed to 28.5x32 at 1024px with seven nav items. The page-level
overflow check passed throughout because the brand block absorbed the
deficit by crushing the mark. Harness now asserts rendered aspect ratio.

Opened: Q38, Q39. Closed: Q32, Q33, Q36. Narrowed: Q35. Added: R13, R14.
AGENTS.md entry (v) carries a RESUME HERE section.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012XquaEq4BgWMCwUqLEyNkF
2026-08-26 17:30:19 -04:00
Pouya LajevardiandClaude Opus 5 8134709548 feat: build step 1 — scaffold, layout, header, footer, SEO; zero JavaScript
Build order step 1 (docs/01): scaffold, tokens, base layout, header,
footer, SEO component, plus a temporary /type-scale/ proof sheet that
step 2 deletes.

THE FONTS WERE NEVER ON DISK. global.css declared six @font-face rules
pointing at /fonts/*.woff2 and public/fonts/ did not exist, so every
face had been silently falling back to Georgia and the system sans.
Six cuts committed, 123,804 bytes, SIL OFL 1.1, provenance in
docs/reference/fonts-provenance.md. ?v=1 on every URL because the
deploy script serves them immutable for a year.

ZERO JAVASCRIPT. The reveal was an inline IntersectionObserver in
<head>; docs/05 specifies script-src 'self' with no unsafe-inline, so
the only script on the site was the one thing the site's own CSP would
refuse to execute. Replaced with animation-timeline: view() behind
@supports. 0 script tags and 0 .js files in dist.

The infinity mark is lifted verbatim from the deployed site's own
smlMark loading thumbnail, not redrawn (Q32 asks whether a canonical
vector exists). The proof sheet computes its contrast table from
tokens.css rather than restating docs/02 — all eleven ratios reproduce
the measured table exactly.

Register: Canadian Tax Foundation added (§4, R10 widened); Q30 closed
— SML Company Ltd is federally incorporated under the CBCA, and the
footer publishes neither that nor the place of business; Q31 closed —
Plausible, on EU-only data residency (D15 amended). ROLE constants
added for "Director of Firm Operations" and "active litigation
exposure" so step 3 does not hand-type them.

Lighthouse unavailability now stated in six places rather than left as
a control that had silently stopped existing (§7, R11).

Both review agents ran twice. The second pass found four defects in
the first pass's fixes, including the minifier bug written back into
its own fix and a colour-alone repair that used the banned gold-on-
cream pairing at 2.10:1. Measured in headless Chrome at thirteen
widths with a seventh nav item injected: 0 overflow, 0 tap targets
under 44x44, 0 focus-order inversions, state indicators at 12.29:1,
755 words of body text with no JavaScript.

Opened: Q32-Q37. Closed: Q30, Q31.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012XquaEq4BgWMCwUqLEyNkF
2026-08-26 15:57:02 -04:00
Pouya LajevardiandClaude Opus 5 8f1df2c27c chore: correct the review agents' briefs and add a Phase 5 minifier check
Separated from the step 1 feature commit on adversarial-reviewer's own
recommendation: instructions that narrow a reviewer's scope should not
travel in the same commit as the work that reviewer is checking.

claims-auditor.md — REMOVE the enumerated membership list. It read
"ADRIC, ADRIO, OBA sections only" while AGENTS.md §4 had gained the
Canadian Tax Foundation that morning, so the auditor's own brief
contradicted the register: it would have flagged a verified membership
as unverified and would not have noticed CTF being dropped. This file
has now hosted a stale claim twice. Replaced with an instruction to
read the §4 row at audit time — a copy of a fact goes stale where
nobody re-reads it.

adversarial-reviewer.md — state that Lighthouse cannot be run until
step 7 and that its absence is not a finding (AGENTS.md §7, R11).
Repair a sentence left truncated mid-list. Caveat the "~1 MB of logo
PNGs" figure against Q34, which is open on it.

build.md — Phase 4 now carries the measurement that justifies the
re-review requirement: on the Astro 5→7 upgrade four of six
second-round findings were defects in the first round's own fixes.
Phase 5 gains a grep asserting no `animation` shorthand beside
`animation-timeline` survives into dist — Lightning CSS folds them
into an invalid declaration that works in dev and is dead in the
build. That happened twice in one session, the second time inside the
fix for the first.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012XquaEq4BgWMCwUqLEyNkF
2026-08-26 15:56:43 -04:00
Pouya LajevardiandClaude Opus 5 7514a49803 feat: upgrade to Astro 7; harden the content schema; wire a11y linting
Amends D1 to pin the major explicitly (v7.x) rather than inherit it. The
^5.0.0 pin was recalled rather than checked and was two majors stale the day
it was written, which meant shipping a framework carrying high-severity XSS
advisories. CLAUDE.md now requires every version pin to be verified against
the registry, and R11 requires re-checking at each build-order boundary.

npm audit now reports 0 vulnerabilities, down from 16. Every Astro advisory
is cleared; the residual 10 all traced to @lhci/cli, which is removed — it
was the sole source of 7 high-severity findings, 0.15.1 is latest so there
was no clean upgrade, and it cannot run without pages or a lighthouserc.
Re-added at build step 7 with a freshly verified pin.

Content collections migrated to the Content Layer API: src/content.config.ts,
loader: glob(), z from astro/zod.

Two review passes found seven defects in the fix itself, all now closed:

- z.coerce.date() read an unquoted 20260801 as epoch milliseconds and
  yielded 1970-01-01 silently; the first replacement then accepted
  2026-13-45 as an Invalid Date and rolled 2026-02-30 over to 2026-03-02.
  Dates are now anchored, date-only, parsed as UTC and round-tripped.
- The title bound applied the SEO spec's 50-60 to the headline rather than
  the rendered <title>, which guaranteed 68-78 on every article and rejected
  all five planned launch headlines. Articles are now the documented
  exception: the headline is the <title>, no suffix.
- An article could ship an image with no alt text, or whitespace-only alt.
- Two schema comments asserted controls nothing enforced; both are now real
  refinements, each tested with a failing and a passing case.
- PRACTICE_SLUGS and PRACTICE_AREAS could drift silently; a compile-time
  check now catches both directions.
- eslint.config.js imported globals and @eslint/js undeclared, resolving by
  hoisting accident.
- scripts/deploy-local.sh claimed parity with CI while skipping npm run
  check and two credential guards — on the only path this site can ship
  today.

Accessibility linting is on (36 jsx-a11y rules) before step 1 writes the
layout. An earlier claim in §7 that none was possible was wrong twice, and
is corrected in AGENTS.md entry (t) along with the reasoning.

Opens Q30 and Q31 for two unregistered claims in src/data/site.ts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012XquaEq4BgWMCwUqLEyNkF
2026-08-26 14:10:09 -04:00
Pouya LajevardiandClaude Opus 5 0d8b63380a chore: install toolchain, wire lint, add local deploy path
Answers four questions and starts build step 1.

Q22 — the scoped deploy user does not exist: aws iam get-user returns
NoSuchEntity. Recorded in §7 as NOT PROVISIONED and swept so that no file
describes it as existing. §10 records that user/pouya, the broadly-
permissioned personal user that has been authenticating to this account,
must never be used in CI; scripts/deploy-local.sh refuses to run as it.

Q23 — the Gitea instance reports 1.27.2, well above the vars-context floor,
so the first-step guard is belt-and-braces rather than load-bearing. What
remains is not a fact but a dependency: the instance is jointly administered,
so enabling Actions and registering a runner both need a second admin. Hence
npm run deploy (scripts/deploy-local.sh), which performs exactly what the
workflow performs — same guard, same three passes, same headers, same
invalidation. Documented as the current path, not as a workaround.

§10 gains the risk that follows: the deploy secret will live on jointly
administered infrastructure, where an instance admin can reach repo secrets.
That does not change the plan, but it makes the scoped IAM policy the actual
control between a shared Gitea instance and an AWS account holding another
business's client-database backups. Never widen it.

Q27 — response time is two business days, in site.ts with a derived short
form so the confirmation email cannot drift from the page.
Q28 — OBA sections confirmed, stamped "for now"; membership renews yearly,
tracked as R10.

Build step 1: dependencies installed and package-lock.json created, closing
the npm ci blocker. ESLint flat config and Prettier config added; npm run
lint, check and build all pass. Prettier deliberately excludes *.md and
tokens.css — reasons recorded in .prettierignore.

npm audit reports 7 high-severity advisories, all requiring an Astro major
upgrade. Not applied; escalated in AGENTS.md entry (s) as a decision.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012XquaEq4BgWMCwUqLEyNkF
2026-08-26 11:54:04 -04:00
Pouya LajevardiandClaude Opus 5 2b6176e4d7 feat: SES production access and monitoring; §7 as single source of operational truth
Q19 is closed — SES production access granted in ca-central-1, confirmed in
writing. Nothing now blocks /contact/.

The structural change is the important one. Specs in docs/ carried their own
copies of resource IDs, regions, DNS records and service state. AGENTS.md §7
is now the single source of truth for operational facts and docs/ cite it
rather than restating it, with the rule recorded in CLAUDE.md under
Conventions.

The reason is the previous commit's DKIM inversion, generalised: the same
fact lived in §7 and docs/05, a correction reached one of them, and the stale
copy told an operator to delete the records that authenticate outbound mail.
A duplicated fact is one that will eventually be wrong in one place, and the
copy that goes stale is the one nobody re-reads. Verified by grep over
docs/*.md — no operational identifier remains.

Also in this change:

- §7 records the SES monitoring: SNS topic ses-alerts, alarms
  SES-BounceRate-High (>= 0.03) and SES-ComplaintRate-High (>= 0.001), and
  the deliberate choice of email feedback forwarding over an SNS feedback
  topic at this volume. The ses-alerts email subscription is stamped PENDING
  CONFIRMATION — the alarms currently notify nobody, now tracked as R9 and on
  the cutover checklist.
- docs/05 records why those alarms are a real control: SES suspends above
  roughly a 5% bounce rate, and under 100 messages a month five bounces
  crosses it.
- Q29: the deploy guard now covers AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY
  (emptiness only, never echoed) and INTAKE_ENDPOINT, promoted to job-level
  env. An empty intake endpoint ships a live form posting to nothing, which
  is worse than a failed build. Executed under sh -e across four input
  states; fails closed, leaks nothing.
- docs/06: account ID removed from the backup-bucket callout, pointing at §10
  instead, as README already does.
- astro.config.mjs: prefetch removed entirely. Any setting ships Astro's
  prefetch script to every page against the zero-JS convention. Recorded as a
  decision; revisit against real Lighthouse numbers.

AGENTS.md entry (r) records the full reasoning.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012XquaEq4BgWMCwUqLEyNkF
2026-08-26 11:47:17 -04:00
Pouya LajevardiandClaude Opus 5 6bf1167624 fix: sweep D3 amendment through the specs; correct inverted DKIM table
The re-audit of the deploy-guard change surfaced defects well outside the
diff, including one that would have broken production mail.

docs/05-backend-spec.md had the two SES DKIM sets exactly inverted, labelling
the three records that resolve as "orphans" and the three NXDOMAIN records as
"Live. Never delete". Entry (j) corrected this in AGENTS.md §7 and the
correction never reached docs/05. Since SES has no custom MAIL FROM, DKIM is
the only thing satisfying DMARC, so acting on that table would have silently
broken intake mail authentication.

Also in this change:

- .gitea/workflows/deploy.yml gains a guard as steps[0] that fails the run,
  naming the variable, if AWS_REGION, S3_BUCKET or CLOUDFRONT_DISTRIBUTION_ID
  is empty — how a Gitea too old for the vars context manifests. Verified
  fail-closed under bash -e, sh -e and bash -euo pipefail.
- AGENTS.md Current Truth: SPF and DMARC recorded as present (Q20), the
  matching §10 High risk row retired, three duplicate Q rows removed.
- docs/reference/AWS-Hosting-Guide.md tracked and given a do-not-execute
  banner; it was an executable procedure for the architecture D1/D3 replace.
- Copy decks: "a working litigator" and "an active litigation practice"
  replaced with the register's own wording; LegalService JSON-LD replaced with
  ProfessionalService; tribunal-secretary offers removed per D14; nine stale
  question blockers swept.
- astro.config.mjs: prefetchAll disabled — it injected JS into every page
  against the zero-JS convention with no decision recorded.
- src/data/site.ts: unregistered response-time commitment nulled (Q27);
  OBA section names downgraded to [assumed] (Q28).
- s3:AbortMultipartUpload reasoning corrected to measure ./dist, not the repo.

Opens Q27, Q28, Q29. AGENTS.md entry (q) records the full resolution,
including the findings declined and why.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012XquaEq4BgWMCwUqLEyNkF
2026-08-26 11:28:42 -04:00
67 changed files with 21326 additions and 471 deletions
+11 -5
View File
@@ -6,7 +6,7 @@ model: opus
---
You are reviewing a change to `adr.smlcompany.ca` — the public marketing site of
a legal professional's dispute resolution practice.
a dispute resolution practice.
**Your job is to find what is wrong with it.** You are not here to confirm that
the work is good. An approving review that misses a real defect is a failure; a
@@ -54,10 +54,16 @@ full content with JavaScript disabled.** Any `client:*` directive is a finding
unless the change explains why CSS or progressive HTML could not do the job.
**4. Performance.** Budgets in `docs/04-seo-spec.md`: Lighthouse ≥ 95 mobile on
all four categories, under 100 KB JS per route, LCP under 2.0 s. Check for
base64-inlined images, images without explicit dimensions, runtime font requests,
and third-party scripts. The old build inlined ~1 MB of logo PNGs — watch for
regressions of that shape.
all four categories, under 100 KB JS per route, LCP under 2.0 s.
**Lighthouse itself cannot be run right now**`@lhci/cli` was removed on
2026-08-26 and returns at build step 7 (`AGENTS.md` §7, R11). So do not report
"Lighthouse not run" as a finding; it is a known, recorded gap. Review
everything that *would* move those numbers by reading the artefact instead:
base64-inlined images, images without explicit dimensions, runtime font
requests, and third-party scripts. The old build is *said* to have inlined ~1 MB
of logo PNGs — `AGENTS.md` Q34 is open against that figure, so watch for
regressions of that shape without repeating the number as fact.
**5. Security and data handling.** Any hardcoded endpoint, key, or credential is
a finding. Check CSP compatibility, that form input is validated server-side and
+26 -4
View File
@@ -5,7 +5,8 @@ tools: Read, Grep, Glob
model: opus
---
You audit public copy for a **licensed legal professional's** marketing site.
You audit public copy for the marketing site of a dispute resolution practice.
The site it replaces carried fabricated credentials.
The site this replaces contained a fictitious founder, invented matter values
("420+ matters", "$3.8B resolved", "93% settled"), fabricated office locations,
@@ -52,9 +53,30 @@ slot.
nearly complete. The Arbitration page must state plainly what is available now
versus what follows designation.
**Memberships.** ADRIC, ADRIO, OBA sections only. **OCNI is not current** — flag
it. **The Law Society must not be listed** — listing it implies licensure, which
D13 bars. Flag any addition of either, however well-intentioned.
**Memberships.** **Do not hold a list here. Read the memberships row in
`AGENTS.md` §4 at audit time and use what it says.** This paragraph used to
enumerate "ADRIC, ADRIO, OBA sections only"; the Canadian Tax Foundation was
verified into §4 on 2026-08-26 and this line did not move, so for one session
the auditor's own brief contradicted the register — it would have flagged a
verified membership as unverified, and would not have noticed CTF being dropped.
That is the second time a stale claim has been found inside this file, which is
the definition of the agent whose job is to catch exactly that (`CLAUDE.md`
records the first). A copy of a fact is a fact that will go stale, and this one
goes stale where nobody re-reads it.
**OCNI is not current** — flag it. **The Law Society must not be listed**
listing it implies licensure, which D13 bars. Flag any addition of either,
however well-intentioned.
**The OBA sections and the Canadian Tax Foundation renew yearly (§12 R10)** — and
read that scope, because this sentence carried the widened form *"Memberships
renew yearly"* until 2026-08-28. §4 records the period for **those four lines
only**; it says nothing about ADRIC's or ADRIO's. **You found this yourself**, in
your own brief, on the pass where you found the same widening in three source
files — the third stale claim located inside this file, which is why the
instruction below is the one that matters: a §4 row can be verified and still be
out of date, so **read the §4 row at audit time and check the stamp**, never this
gloss.
**Testimonials, endorsements, third-party quotes.** None exist. Any is a
fabrication.
+28 -2
View File
@@ -19,7 +19,7 @@ does not apply, say which and why before moving on.
Reminders** and surface anything live to Pouya before you start.
2. Read the specs in `docs/` that bear on this task.
3. Restate the task in your own words, and name:
- which locked decisions (D1D16) it touches
- which locked decisions (D1D18) it touches
- which specs govern it
- which facts it needs from the §4 Verified register
4. **Stop and ask if you find a conflict** — between the task and a locked
@@ -61,6 +61,14 @@ so a later reader can see the judgement was made rather than missed.
If you fix anything material, **re-run Phase 3 on the fix.** A patch written
under review pressure is exactly where the second defect lives.
> **This is not ceremony, and here is the measurement.** On the Astro 5 → 7
> upgrade (`AGENTS.md` entry (t), 2026-08-26) the second review pass returned
> six findings. **Four of the six were defects in the first round's own fixes** —
> including a date validator whose replacement silently rolled `2026-02-30`
> forward to `2026-03-02`, and a title rule whose fix rejected all five planned
> launch articles. None of the four existed before the review started. Skip the
> re-review and you ship the repair, not the bug.
## Phase 5 — Verify — run it, do not assert it
```bash
@@ -73,9 +81,22 @@ Then, as applicable to what changed:
- Serve `dist/` and confirm the page **renders its full content with JavaScript
disabled** — the failure this whole project exists to fix
- `curl` the built HTML and confirm real content, not a shell
- Lighthouse mobile ≥ 95 on all four categories
- ~~Lighthouse mobile ≥ 95 on all four categories~~ — **UNAVAILABLE.**
`@lhci/cli` was removed on 2026-08-26 and is not re-added until build step 7
(`AGENTS.md` R11, §7). Report it as *not run, tool unavailable*. Do not
substitute a manual DevTools run and describe it as the same check
- Every internal link resolves
- Metadata present: unique title, description, canonical, OG, JSON-LD
- **No scroll-driven animation was eaten by the minifier.** This must return
nothing:
```bash
grep -rE 'animation:[^;}]*(scroll\(\)|view\(\))' dist --include='*.css'
```
A hit means an `animation` shorthand was written beside `animation-timeline`
and Lightning CSS folded them into an invalid declaration, which the parser
then discards. The effect works in `npm run dev` and is dead in the build.
**Never report a check as passing that you did not run.** "Should pass" is not a
result. If you could not run something, say which and why.
@@ -87,5 +108,10 @@ why, and any decision or plan — including declined findings and anything
deferred. Update Current Truth in place where the change made a section stale.
Re-stamp facts you re-checked with today's date.
**If the entry claims a change was applied across files, cite the command and
paste its output.** Write that claim only after reading the output. Recall is
not evidence — three entries on this project asserted a completed sweep and
instances survived all three.
Then report to Pouya: what shipped, what the review found, what you declined and
why, and what remains open.
+6
View File
@@ -24,6 +24,12 @@ reasoning.
**Never edit a past entry.** If something earlier was wrong, correct it in
today's entry and leave the original as written.
**Any claim that a change was applied across files must cite the command and
be written only after reading its output.** Paste the `grep`. Recall is not
evidence — three entries on this project asserted a completed sweep and
instances survived all three, one of them inside the definition of the agent
whose job is to catch it.
4. **Check §12 Standing Reminders.** Is anything now due? Should something new
be added — a decision Pouya parked, or one you made on his behalf that he has
not yet ratified?
+1 -5
View File
@@ -4,10 +4,6 @@
"showThinkingSummaries": true,
"effortLevel": "high",
"permissions": {
"deny": [
"Read(./.env)",
"Read(./.env.*)",
"Read(./aws-inventory.txt)"
]
"deny": ["Read(./.env)", "Read(./.env.*)", "Read(./aws-inventory.txt)"]
}
}
+55 -8
View File
@@ -1,14 +1,18 @@
# Gitea Actions — the live pipeline for this repository.
#
# Gitea Actions speaks GitHub Actions syntax, so this is a near-direct port of
# .github/workflows/deploy.yml (kept as the OIDC reference in case the repo ever
# moves to GitHub or GitLab).
# docs/reference/github-actions-oidc.yml.example (kept as the OIDC reference in
# case the repo ever moves to GitHub; it lives under docs/ rather than
# .github/workflows/ so Gitea can never fall back to it).
#
# ONE REAL DIFFERENCE: Gitea is not an AWS OIDC provider, so there is no role to
# assume. Deploys authenticate with a SCOPED IAM USER whose key lives only in
# this repository's Gitea secrets. See docs/06-deployment.md for the exact IAM
# policy — it grants four actions on one bucket and one distribution, nothing
# more. Rotate the key quarterly; OIDC would have made that unnecessary.
# assume. Deploys are designed to authenticate with a SCOPED IAM USER whose key
# lives only in this repository's Gitea secrets. Whether that user and key have
# actually been created is AGENTS.md Q22 — unanswered as of 2026-08-26.
#
# See docs/06-deployment.md for the exact IAM policy — it grants four actions on
# one bucket and one distribution, nothing more. Rotate the key quarterly; OIDC
# would have made that unnecessary.
#
# Requires a Gitea Actions runner registered to this repo or its organisation.
@@ -33,8 +37,49 @@ jobs:
AWS_DEFAULT_REGION: ${{ vars.AWS_REGION }}
S3_BUCKET: ${{ vars.S3_BUCKET }}
CLOUDFRONT_DISTRIBUTION_ID: ${{ vars.CLOUDFRONT_DISTRIBUTION_ID }}
# Job-level so the guard can see it. An empty INTAKE_ENDPOINT does not
# fail the build - it ships a live contact form posting to nothing.
INTAKE_ENDPOINT: ${{ vars.INTAKE_ENDPOINT }}
steps:
# Runs first, before checkout and before any AWS call, so a
# misconfiguration costs one second instead of a full build.
#
# Repository variables live at Settings -> Actions -> Variables. Gitea
# only added the `vars` context in 1.21; this instance reports 1.27.2
# [verified 2026-08-26 - /api/v1/version, AGENTS.md §7], so the guard is
# belt-and-braces rather than load-bearing. It stays because an unset or
# mistyped variable degrades the sync target to "s3://" and the run dies
# obscurely somewhere in the middle, whatever the Gitea version.
#
# Covers the deploy-target variables, the intake endpoint, AND the two
# secrets. The secrets matter most: AGENTS.md Q22 records that nobody has
# confirmed the IAM user or its key exists, so an unset key is the single
# likeliest first-run failure - and without this it would burn a whole
# build before dying at `aws sts get-caller-identity`.
#
# Only emptiness is ever tested. No value is echoed, so nothing here can
# leak a secret into the run log.
- name: Guard - required variables and secrets are set
run: |
missing=''
[ -n "$AWS_DEFAULT_REGION" ] || missing="$missing AWS_REGION(var)"
[ -n "$S3_BUCKET" ] || missing="$missing S3_BUCKET(var)"
[ -n "$CLOUDFRONT_DISTRIBUTION_ID" ] || missing="$missing CLOUDFRONT_DISTRIBUTION_ID(var)"
[ -n "$INTAKE_ENDPOINT" ] || missing="$missing INTAKE_ENDPOINT(var)"
[ -n "$AWS_ACCESS_KEY_ID" ] || missing="$missing AWS_ACCESS_KEY_ID(secret)"
[ -n "$AWS_SECRET_ACCESS_KEY" ] || missing="$missing AWS_SECRET_ACCESS_KEY(secret)"
if [ -n "$missing" ]; then
echo "Not set:$missing"
echo
echo 'Variables: Settings -> Actions -> Variables.'
echo 'Secrets: Settings -> Actions -> Secrets.'
echo 'See docs/06-deployment.md.'
echo 'If the variables ARE set, this Gitea predates the vars context (1.21+).'
exit 1
fi
echo 'All required variables and secrets are set.'
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
@@ -52,6 +97,8 @@ jobs:
run: npm run build
env:
PUBLIC_SITE_URL: https://adr.smlcompany.ca
# vars, not env — Gitea expression-context support is the very thing
# the guard above exists to not depend on.
PUBLIC_INTAKE_ENDPOINT: ${{ vars.INTAKE_ENDPOINT }}
PUBLIC_BOOKING_URL: ${{ vars.BOOKING_URL }}
@@ -68,8 +115,8 @@ jobs:
- name: Verify credentials
run: aws sts get-caller-identity
# Two passes: hashed immutable assets first, HTML last. A visitor must
# never fetch a new page whose assets have not landed yet.
# Three passes: hashed immutable assets first, then images, HTML last.
# A visitor must never fetch a new page whose assets have not landed yet.
- name: Sync hashed assets
run: |
aws s3 sync ./dist "s3://${S3_BUCKET}" \
+17
View File
@@ -0,0 +1,17 @@
dist/
node_modules/
.astro/
package-lock.json
# Frozen historical record — reformatting would obscure what it originally said.
docs/reference/
# Markdown here is hand-maintained to an 80-column convention, and AGENTS.md is
# an append-only history whose tables Prettier would rewrite wholesale (an
# 892-line diff for no reading benefit). Prose wrapping is checked by eye.
*.md
# tokens.css aligns every custom property's comment into a column so the
# measured contrast ratios can be scanned down the page — see docs/02. Prettier
# collapses that alignment, which is the one thing the file is for.
src/styles/tokens.css
+16
View File
@@ -0,0 +1,16 @@
{
"printWidth": 80,
"singleQuote": true,
"trailingComma": "all",
"plugins": ["prettier-plugin-astro"],
"overrides": [
{
"files": "*.astro",
"options": { "parser": "astro" }
},
{
"files": "*.md",
"options": { "proseWrap": "preserve" }
}
]
}
+3182 -38
View File
File diff suppressed because it is too large Load Diff
+159 -14
View File
@@ -22,9 +22,14 @@
## The one rule that matters more than the code
Pouya is a licensed legal professional. **No factual claim about him, his
credentials, his experience, or his practice may appear on a public page unless
it is in the Verified table in `AGENTS.md` §4.**
This is Pouya's public marketing surface, and the site it replaces carried
fabricated credentials. **No factual claim about him, his credentials, his
experience, or his practice may appear on a public page unless it is in the
Verified table in `AGENTS.md` §4.**
(§4 does not verify licensure either way — so do not describe him as
"licensed", or as a "legal professional", anywhere, this file included. State
the reason for the rule, not a credential the register cannot vouch for.)
If a page needs a fact you do not have:
@@ -78,10 +83,12 @@ view from the artefact — that independence *is* the mechanism.
**The reviewers are instructed to treat uncertainty as a defect.** They will
sometimes be wrong, and that is the intended trade. Explaining why a finding is
mistaken costs minutes; a missed defect on a licensed professional's public
marketing page costs considerably more. Do not read a finding as an accusation,
and do not argue a reviewer down — either fix it, or record the reason you
declined it so a later reader can see the judgement was made rather than missed.
mistaken costs minutes; a missed defect on this project's public marketing
pages costs considerably more — the site this replaces carried fabricated
credentials, and that is the standard being corrected. Do not read a finding as
an accusation, and do not argue a reviewer down — either fix it, or record the
reason you declined it so a later reader can see the judgement was made rather
than missed.
**Two reviewers, because they catch different things.** `adversarial-reviewer`
reads the code. `claims-auditor` reads the copy against the §4 register and knows
@@ -98,6 +105,8 @@ npm run build # static build to ./dist
npm run preview # serve ./dist locally
npm run check # astro check — type and template errors
npm run lint # eslint + prettier check
npm run format # prettier — rewrite files in place
npm run deploy # build + deploy from this machine (see docs/06)
```
## Where things live
@@ -110,22 +119,24 @@ docs/ the specs you build from
03-content-spec.md voice, copy rules, per-page copy deck
04-seo-spec.md metadata, structured data, sitemap, crawlability
05-backend-spec.md intake form, Lambda/DynamoDB/SES, booking, PIPEDA
06-deployment.md S3/CloudFront, GitHub Actions OIDC, cutover checklist
06-deployment.md S3/CloudFront, Gitea Actions, IAM, cutover checklist
src/
content.config.ts content collections — Content Layer API, NOT content/config.ts
styles/tokens.css design tokens — the single source of colour and scale
styles/global.css reset, base type, utilities
layouts/ page shells
components/ UI components
pages/ routes (file-based)
content/ content collections; Insights MDX lives here
content/insights/ Insights MDX only; the config sits above, not in here
data/site.ts site-wide constants, nav, contact details
public/ static assets served as-is
```
## Conventions
**Framework.** Astro, `output: 'static'`. Never introduce a server runtime
without a Change Log entry recording why.
**Framework.** Astro **7.x**, `output: 'static'` (D1 as amended). Never introduce
a server runtime without a Change Log entry recording why. The major is pinned
deliberately — check `npm view astro version` before changing it.
**JavaScript.** Default to zero. Reach for an Astro island only when a feature
genuinely cannot be CSS or progressive HTML. If you add a `client:*` directive,
@@ -141,9 +152,57 @@ interactive elements reachable by keyboard, `prefers-reduced-motion` honoured on
every animation. Gold `#c9a876` never sits on cream — it fails contrast at
2.10:1. See `docs/02-design-system.md`.
**Anything a spec makes a claim about must be reachable from the repository.**
If the artefact lives only in Drive, in a console, or on someone's laptop, no
reviewer can compare the claim against it and the claim is **unverifiable by
construction** — not merely unverified. Commit the artefact, or commit a faithful
extract with its provenance and the command that produced it.
This has cost twice. `AGENTS.md` Q24 was the AWS hosting guide, the only record
of how the infrastructure was hand-built, living outside the repo. Q32 was the
infinity mark: it was traced from the old site's *loading placeholder*, the
source comment said so in as many words — and **two adversarial review passes
still could not catch that the shape was wrong**, because the real artwork was
not in the repo to compare against. Stating a doubt is not enough when the thing
that would resolve it is unreachable. Tracked as R14.
**A command that did not run is not evidence of absence.** Check that a tool
exists before trusting its silence, and read exit status, not just stdout. This
project ran `timeout 60 ls "$DRIVE"` four times, got empty output each time, and
reported the brand assets unreachable — `timeout` is not installed on macOS, so
the command had never executed and the directory was fully readable all along.
Empty output from a command that failed to start looks exactly like empty output
from a command that found nothing. Same family as *a sweep is a command, not a
claim*: the claim must rest on output you actually read, from a command that
actually ran.
**A parent cannot style a child component's root element.** Astro does not pass
a parent's scope attribute down, so `<Button class="header-cta" />` compiles the
parent's rule to `.header-cta[data-astro-cid-<parent>]` while the rendered `<a>`
carries only `<Button>`'s own cid. **The rule silently never matches** — no
error, no warning, and the CSS looks correct in the source. Wrap the child in an
element the parent owns (`<div class="header-cta"><Button …/></div>`), or reach
it deliberately with `:global()` from a parent-scoped ancestor. Inherited
properties (`white-space`, `color`, `font-*`) do cross the boundary and are the
exception. This cost a header CTA that was documented as hidden on mobile,
was not hidden, and sat 75 px short of the right edge on desktop — both found by
measurement, neither by reading. It will recur with `PracticeCard`,
`ArticleCard`, and `Pill`.
**Never write the `animation` shorthand beside `animation-timeline`.** Longhands
only — `animation-name`, `animation-duration`, `animation-timing-function`,
`animation-fill-mode`, then `animation-timeline` and `animation-range`.
`scroll()` and `view()` are not legal components of the shorthand, and Lightning
CSS folds the two declarations together on minify into something invalid, which
is then discarded whole. **It works in `npm run dev` and is dead in
`npm run build`** — the worst shape a defect can take. It happened twice in one
session, the second time inside the fix for the first. `/build` Phase 5 greps
`dist` for it; do not remove that check.
**Images.** Astro `<Image>` with explicit width and height. AVIF/WebP with
fallback. Never base64-inline an image into HTML — the old site did this with
~1 MB of logo PNGs.
~1 MB of logo PNGs — a figure `AGENTS.md` Q34 is now open against, so treat the
rule as standing on its own merits rather than on that number.
**Fonts.** Self-hosted, subset, `font-display: swap`, preloaded. No Google Fonts
request at runtime — it costs a round trip and adds a third-party call to a
@@ -153,20 +212,106 @@ page that collects legal inquiries.
URL, Open Graph and Twitter card tags, and appropriate JSON-LD. See
`docs/04-seo-spec.md`. A page without these is not finished.
**A version pin is verified against the registry, never recalled.** Before you
write or change any dependency version, run `npm view <pkg> version` and pin
against what it returns. One second of checking; a stale pin costs a migration.
This rule exists because `astro: "^5.0.0"` was written from memory and was
**two majors stale on the day it was written** — which meant shipping a
framework carrying high-severity XSS advisories. The same check applies to
every pin in `package.json`, not just the framework.
Re-check currency at each phase boundary in the build order (`AGENTS.md` R11),
not only when something breaks.
**`AGENTS.md` §7 is the single source of truth for operational facts.** Resource
IDs, regions, DNS records, credential state, service status — these live in §7
and nowhere else. Specs in `docs/` **cite** §7; they do not restate it. Write
"the region `AGENTS.md` §7 records", not the region. Same for bucket names,
distribution IDs, DKIM tokens, endpoints, and account identifiers.
A duplicated fact is a fact that will eventually be wrong in one place, and the
copy that goes stale is the one nobody re-reads. This rule exists because
`docs/05-backend-spec.md` carried its own copy of the SES DKIM table, a
correction reached §7 and never reached it, and the stale copy ended up telling
an operator to delete the three records that authenticate outbound mail —
under the heading "Never delete".
**A measurement is a claim about your instrument until you check the
instrument.** This has now cost five times, and the shape is identical every
time: a number that looks like a finding, from a probe nobody validated.
- `timeout 60 ls "$DRIVE"`**the command never ran.** `timeout` is not
installed on macOS. Empty output from a command that failed to start looks
exactly like empty output from a command that found nothing, and it produced a
report that the brand assets were unreachable when the directory was fully
readable.
- **`1.23:1` for the traced mark** — the bounding box of the path's *coordinate
hull*, not of the curve. A cubic's control points sit outside it, so the box
was 33% too tall while giving the *correct* width — which means the obvious
sanity check, "does the width look right?", passes.
- **"the mark renders at 24px"** — the harness reported the worst-deviating
instance on the page, not the instance under discussion, which was exact.
- **"0 overflow at every width"** — true, and it measured the *document*. A flex
child was absorbing the deficit by being crushed to aspect 0.891. **Measure
the elements, not only the page.**
- **`img.naturalWidth` = 64 at DPR 1, 2 and 3** — which reads as *the density
ladder is not being generated at all*, a shipped defect on every page. It is
**density-corrected by spec**: a 192px file selected at `3x` correctly reports
64. The files on disk were 64 / 128 / 192 all along.
So before acting on a number: say what it is a number *of*; confirm the command
actually ran and read its exit status; and check it against a second method that
cannot fail the same way — the bytes on disk, a screenshot, a hit test.
**And a grep that matches is not a finding until you read what it matched.**
A case-insensitive sweep for `LSO` hit `I aLSO practise`; a superlative sweep for
`leading` hit `the pLEADINGs`. Both on the same page on the same day. Print the
match with context before you believe it.
**Never name an Astro prop `as`.** `const { as = 'p' } = Astro.props` detaches
the `Props` interface from the component, and **every call site silently stops
being type-checked.** `astro check` reports it only as `ts(6196) 'Props' is
declared but never used`, which reads like lint noise. Measured: with the prop
named `as`, `<Eyebrow dot as="h9" bogusProp={1} />` compiled with **0 errors**;
renaming the one identifier to `tag` made the same probe fail correctly. **Do not
silence a `ts(6196)` with `Astro.props as Props`** — that hides the warning and
leaves the call sites unchecked, which is strictly worse. If that hint appears on
any component, pass it a bogus prop before believing its props are checked.
**A sweep is a command, not a claim.** Any statement that a change was applied
across files — a phrase removed everywhere, a path updated everywhere, a
decision swept through the docs — must cite the command that proves it, and be
written only after reading that command's output. Paste the `grep` into the
Change Log entry. Three consecutive entries on this project asserted a completed
sweep; instances survived all three, and one of them was inside
`.claude/agents/claims-auditor.md` — the definition of the agent whose job is to
catch exactly that. Recall is not evidence.
**Commits.** Conventional Commits (`feat:`, `fix:`, `docs:`, `chore:`, `refactor:`).
One logical change per commit. Never commit secrets, `.env` files, or AWS
credentials — deploys use OIDC role assumption.
credentials. Gitea is not an AWS OIDC provider, so the deploy key is designed as
a static IAM access key to be held in Gitea Actions secrets — whether it has
actually been provisioned is `AGENTS.md` Q22. It must never reach the repo.
**Performance budget.** Lighthouse ≥ 95 on all four categories, on mobile, for
every page. Under 100 KB of JS on any route. LCP under 2.0 s on a simulated
Slow 4G connection. Treat a budget breach as a failing build.
**Lighthouse cannot currently be run.** `@lhci/cli` was removed on 2026-08-26
(it carried 7 high-severity advisories, `0.15.1` is `latest`, and it had no
pages and no `lighthouserc` to work with). The budget stands; the instrument is
missing. It is re-added at build step 7 under `AGENTS.md` R11 — with a freshly
verified pin, not on the assumption that `0.15.1` is still the ceiling. **Say
"not run — tool unavailable" rather than silently omitting it.** A documented
control that no longer exists is precisely the defect Q22 turned out to be.
## What "done" means for a page
- [ ] Copy written from `docs/03-content-spec.md`, every claim traceable to `AGENTS.md` §4
- [ ] No `TODO(pouya)` left unlogged in §9
- [ ] Unique title, meta description, canonical, OG/Twitter tags, JSON-LD
- [ ] Semantic HTML; keyboard navigable; reduced-motion honoured
- [ ] Lighthouse ≥ 95 mobile, all four categories
- [ ] Lighthouse ≥ 95 mobile, all four categories**UNAVAILABLE until step 7**
(see the performance budget above). Report it as not run; do not tick it
- [ ] Renders correctly with JavaScript disabled
- [ ] `AGENTS.md` Change Log entry appended
+66 -14
View File
@@ -2,13 +2,14 @@
The dispute resolution practice of Pouya Lajevardi — Toronto.
A static site built with [Astro](https://astro.build), deployed to Amazon S3
behind CloudFront by GitHub Actions.
A static site built with [Astro](https://astro.build), built for deployment to
Amazon S3 behind CloudFront by Gitea Actions — see Deployment; the pipeline is
not yet proven.
## Quick start
```bash
nvm use # Node 22
nvm use # Node 22 LTS — the floor is in package.json engines
npm install
npm run dev # http://localhost:4321
```
@@ -21,7 +22,9 @@ npm run dev # http://localhost:4321
| `npm run build` | Static build to `./dist` |
| `npm run preview` | Serve the built site locally |
| `npm run check` | `astro check` — type and template errors |
| `npm run lint` | ESLint + Prettier |
| `npm run lint` | ESLint + Prettier check |
| `npm run format` | Prettier — rewrite files in place |
| `npm run deploy` | Build and deploy from this machine — see Deployment |
## Before you contribute
@@ -29,23 +32,72 @@ Read **`AGENTS.md`** first, and maintain it as you work — it is the living
record of what this project is, what was decided, and why. Then read
**`CLAUDE.md`** for the working rules, and the specs in `docs/`.
The single hardest rule: **no factual claim about the practice ships unless it
appears in the verified register in `AGENTS.md` §4.** This is a licensed
professional's public marketing surface, and the site this replaces contained
fabricated credentials.
The single hardest rule: **no factual claim about Pouya, his credentials, his
experience, or his practice ships unless it appears in the verified register in
`AGENTS.md` §4.** This is a public marketing surface, and the site it replaces
contained fabricated credentials.
## How work is done here
Pouya decides; Claude Code implements and then adversarially reviews its own
work. Run **`/build <task>`** for any substantive change — it plans, implements,
runs two independent review agents on the diff, resolves the findings, verifies
the build, and records the session in `AGENTS.md`. `/review` runs the review pass
alone; `/wrap` closes a session.
runs two independent review agents on the diff (the claims audit wherever copy
changed), resolves the findings, verifies the build, and records the session in
`AGENTS.md`. `/review` runs the review pass alone; `/wrap` closes a session.
Full protocol and prompt guidance: `docs/08-execution-protocol.md`.
## Deployment
Pushes to `main` build and deploy automatically via
`.github/workflows/deploy.yml`, using OIDC role assumption — there are no
long-lived AWS credentials in this repository. See `docs/06-deployment.md`.
**Today, deploys run locally: `npm run deploy`** (`scripts/deploy-local.sh`).
It runs the same guard, the same three sync passes with the same cache headers,
and the same invalidation as the CI workflow — at this scale the pipeline
changes only *how* a deploy is triggered, not what it does. The script and
`.gitea/workflows/deploy.yml` are one artefact in two places: change one, change
both.
`.gitea/workflows/deploy.yml` is the CI pipeline — **Gitea Actions**, not GitHub
Actions. **It has never run**, for two reasons that are not oversights:
- The scoped IAM user does not exist. `aws iam get-user --user-name
adr-sml-deploy` returns `NoSuchEntity` (`AGENTS.md` Q22).
- Actions are not enabled and no runner is registered. The Gitea instance is
jointly administered, so both need its second administrator (Q23).
Its **first** step is a guard: the run aborts, naming what is missing, if any
required variable or either AWS secret is empty. Only emptiness is tested and no
value is echoed.
The GitHub Actions equivalent, which uses OIDC role assumption, is kept as
`docs/reference/github-actions-oidc.yml.example` in case the project ever moves
to a forge that supports it. It sits outside `.github/workflows/` on purpose:
Gitea falls back to that directory when `.gitea/workflows` is absent, so a
workflow file left there with a `push` trigger would be only conditionally
inert. As an `.example` under `docs/` it cannot be picked up at all.
**The pipeline is designed around a long-lived AWS credential, and it does not
exist yet.** Gitea is not an AWS OIDC provider, so there is no role to assume:
deploys are *to* authenticate as a scoped IAM user, `adr-sml-deploy`, with its
access key in the repository's Gitea Actions secrets. `aws iam get-user`
confirms that user has not been created (Q22). In the meantime the local script
**refuses to run as `user/pouya`**, the broadly-permissioned personal user —
see `AGENTS.md` §10. Two things are meant to bound the risk, and neither is in
place yet:
- **The policy must stay narrow.** Four actions: `s3:ListBucket` on one bucket,
`s3:PutObject` and `s3:DeleteObject` on that bucket's contents, and
`cloudfront:CreateInvalidation` on one distribution. No `Action: "*"`, no
`Resource: "*"`, nothing outside that one bucket and that one distribution.
The AWS account is shared with unrelated projects, including a bucket whose
name indicates another business's production database backups — that
narrowness is what keeps a compromised runner away from it, and it is
load-bearing rather than hygiene. See `AGENTS.md` §10. If a deploy step needs
a permission the policy lacks, question the step; do not widen the policy.
- **The key must be rotated quarterly, and nobody owns that yet.** Create a
second access key, update the Gitea secrets, confirm a deploy succeeds, then
delete the old one — rotation that leaves the old key active is not
rotation. OIDC would have removed the obligation entirely; it is
unavailable, so this is a standing calendar task still waiting on an owner.
Full procedure, IAM policy, runner setup, and cutover checklist:
`docs/06-deployment.md`.
+30 -6
View File
@@ -17,19 +17,43 @@ export default defineConfig({
trailingSlash: 'always',
build: { format: 'directory', inlineStylesheets: 'auto' },
// Astro 7 changed this default from `true` to `'jsx'`. Measured, not assumed
// (AGENTS.md entry (t)): in an .astro template, an inline pair split across
// two lines renders as `<em>inline</em><strong>pair</strong>` under 'jsx' and
// `<em>inline</em> <strong>pair</strong>` under `true`. The space is silently
// deleted — no error, no warning; you find out by reading the page.
//
// MDX prose is NOT affected either way; the hazard is .astro markup only.
// Held at the HTML-aware behaviour because hand-written templates on a
// text-heavy site are exactly where a wrapped line meets an inline tag.
// Revisit only with a measurement, not a preference. See R12.
compressHTML: true,
integrations: [
mdx(),
sitemap({
// /legal/* is noindex by spec (docs/04) and nothing else is excluded.
// The `/type-scale/` half of this condition is gone with the page it
// named: the step-1 proof sheet was deleted at step 2, as its own comment
// and InfinityMark's both said it would be. Recoverable from git if the
// specimen is ever wanted again; it is not a route the site ships.
filter: (page) => !page.includes('/legal/'),
changefreq: 'monthly',
lastmod: new Date(),
// No `lastmod`. It was `new Date()`, which stamped every URL with the
// build time — telling crawlers all 17 pages changed whenever one did.
// Google discounts lastmod it judges unreliable, so that spent the signal
// docs/04-seo-spec.md wants rather than sending it. Step 7 can reinstate
// it per-entry from an article's `updatedDate` via `serialize`.
}),
],
image: {
// Explicit dimensions everywhere; never base64-inline an image.
service: { entrypoint: 'astro/assets/services/sharp' },
},
// No `image` block: `astro/assets/services/sharp` is already Astro's default
// service, so setting it was dead configuration. The conventions it used to
// sit under — explicit width/height on every image, never base64-inline —
// live in CLAUDE.md, which is where they are actually enforced (by review).
prefetch: { prefetchAll: true, defaultStrategy: 'viewport' },
// No `prefetch` block at all — removed 2026-08-26, see AGENTS.md entry (r).
// Any prefetch setting ships Astro's prefetch script to every page, against
// CLAUDE.md's "default to zero JS", for a marginal gain on a small static site
// already served from CloudFront. Revisit only against real Lighthouse numbers.
});
+143 -16
View File
@@ -97,6 +97,23 @@ seat. The cost of getting this wrong is much higher than the cost of waiting.
Revisit at month 1218, once there is relationship history to point to.
**This reasoning is Claude's, recorded for Pouya's decision — not yet his call.**
## Not a practice area yet: tax-adjacent disputes
**Canadian Tax Foundation membership is verified** (`AGENTS.md` §4, 2026-08-26)
and it is the one credential none of the six areas above touch. Tax-adjacent
disputes are genuinely ADR territory — valuation and purchase-price disputes on
a share sale, indemnity and earn-out fights that turn on a tax position,
shareholder splits where the assessment is the thing actually in dispute.
**There is no seventh practice page at launch,** for the same reason as the
section above and not a weaker one: a practice page is a claim of present
capability, and there is no track record to point at. A membership is a
credential, not a caseload.
It belongs on `/about/` with the other memberships. Revisit at the **month
1218 review, alongside the Indigenous engagement decision** — one review, two
candidates. Tracked as `AGENTS.md` R3.
---
## Page specifications
@@ -117,14 +134,25 @@ four audiences to its surface.
2. **Credential row.** Three slots: `Q.Med` · `JD + ML` · `EN · FA`. Never
matter counts — `AGENTS.md` §4.
3. **The approach.** The "two directions at once" argument — law and engineering
converging on the same dispute. Infinity mark as the visual anchor.
converging on the same dispute. ⚠️ **The noun pair "law and engineering" is
the construction Q37 struck and Q41(a) extended to prose. The argument
stands; do not lift the phrase into copy** — it reached `/` once already.
State the asymmetry instead: `docs/03` §The credential row. Infinity mark as the visual anchor.
4. **Two practices.** Mediation and Arbitration cards → `/mediation/`, `/arbitration/`.
Med-Arb named here as the long-term arc, linking to `/med-arb/`.
5. **Practice areas.** Six-card grid → `/practice/*`. This is the most important
block on the page for search, because it distributes authority to the pages
that can actually rank.
6. **Process preview.** Compressed five-step strip → `/process/`.
7. **Latest insights.** Three most recent → `/insights/`.
7. **Latest insights.** Three most recent → `/insights/`. **NOT BUILT AT STEP
2, and it arrives at STEP 7 with the collection it lists.** `ArticleCard` and
the drafted slate (D9) land in the same step; rendering the section against an
empty collection means shipping a component's scoped CSS to every visitor for
a block with nothing in it, plus a props surface with no call site.
`SiteHeader` already gates the Insights **nav** item on the same collection,
so the page section and the nav item appear together. Recorded here as well
as in the page source, because "eight sections specified, seven built" should
not be discoverable only by reading the file that deviates.
8. **Contact band.** Intake CTA and booking link.
### `/about/` — Biography and credentials
@@ -148,6 +176,17 @@ to an appointment. This page carries the verifiable record.
7. `Person` JSON-LD. Downloadable one-page PDF bio — brief §VIII lists this as
an asset for circulation with appointment proposals.
> **The PDF bio does NOT ship with build step 3, and the omission is stated
> rather than silent** — `AGENTS.md` Q45, opened 2026-08-28. No such file exists
> in the repo, and a link to one that does not exist is a broken link on the
> page an appointing body reads. It is also not a formatting job: a one-page bio
> is a **credential document** whose every line has to trace to §4 exactly as a
> web page does, and it will be circulated detached from the site, where no
> reviewer sees it again. Two decisions are Pouya's — whether it is generated at
> build (a dependency, against R11) or authored once as a designed artefact, and
> whether it carries anything the site does not. Everything else on this page
> ships.
### `/mediation/`
**Job:** convert counsel who have already decided on mediation and are choosing a
@@ -170,15 +209,23 @@ neutral.
**Search intent:** `sole arbitrator Ontario`, `expedited arbitration Canada`,
`documents-only arbitration`.
1. What the service is; sole-arbitrator, party-appointed, and tribunal-secretary
1. What the service is; sole-arbitrator and party-appointed
appointments.
2. **Tracks:** documents-only, expedited, full hearing.
3. **Rules:** ADRIC, ADR Chambers, ad hoc.
4. Awards — form, reasoning, timing.
5. **Credentialing status, stated plainly.** The Q.Arb pathway is in progress;
the page says so and describes what is available now (co-arbitration,
tribunal secretary) versus what follows designation. Honesty here is a
differentiator, not a weakness — and misstating it is a conduct problem.
the page says so. **What is available now is all three forms — sole,
party-appointed and co-arbitration** — and `AGENTS.md` §4 Offerings carries a
row for each `[verified 2026-08-26 — Pouya]`. The page states that alongside
the credentialing stage: Q.Arb commenced August 2026, C.Med-Arb is the
endpoint. §4 Offerings: **neither half may be dropped.** Honesty here is a
differentiator, not a weakness — and misstating it in either direction is a
conduct problem.
*(This paragraph read "(co-arbitration, co-arbitration)" until 2026-08-26 —
edited without being re-read — and then carried a caveat against Q36 for
several hours after Q36 closed. Both are recorded because the pattern is the
same one: an edit that was not re-read against the register.)*
6. Fees, booking.
### `/med-arb/`
@@ -199,8 +246,23 @@ long-term narrative.
### `/practice/` — index
Six cards, one paragraph each, linking onward. Also the natural home for the
"also offered" strip: early neutral evaluation, settlement counsel, dispute-
system design, and pre-dispute technical advisory.
"also offered" strip: **early neutral evaluation, dispute-system design, and
pre-dispute technical advisory** — three, and each now has an `AGENTS.md` §4
Offerings row, which is what the strip needs before it may ship.
> **`settlement counsel` IS STRUCK FROM THIS STRIP AND MUST NOT BE RESTORED.**
> `AGENTS.md` Q42, 2026-08-27. Pouya struck it as his own error in this document:
>
> > "Settlement counsel acts **FOR a party** in negotiation. That is a partisan
> > role, and putting it on a site that (a) sells neutrality and (b) asserts no
> > licensure under D13 is **wrong twice over**: it undercuts the brand's
> > central claim and it edges into acting for a client."
>
> Note which objection comes first. This is not primarily a compliance problem —
> it is a **positioning** problem, and it would have been wrong on a site with
> no licensure question at all. The compliance half is the aggravation, not the
> reason. Never priced, never offered, never listed: it is a struck row in §4
> Offerings so that a later reader finds the decision rather than the gap.
### `/practice/construction/`
@@ -208,7 +270,7 @@ system design, and pre-dispute technical advisory.
`subcontract dispute arbitration Toronto`.
Dispute types (lien, delay, change orders, scheduling, subcontract, deficiency);
what an active litigation practice in the same matters brings to the room; the
what active litigation exposure in the same matters brings to the room; the
Ontario megaproject pipeline as context — Darlington SMR, Bruce C, data centres,
transit; typical process shape. Strongest immediate fit per brief §III.1.
@@ -242,8 +304,37 @@ a claim of existing volume.**
**Search intent:** `SABS mediation`, `LAT pre-hearing mediation`,
`accident benefits mediator Ontario`, `MIG dispute`.
> ⚠️ **`LAT pre-hearing mediation` IS A SEARCH INTENT AND NOTHING ELSE. It must
> never be published as an offering** — `AGENTS.md` Q41(c), closed 2026-08-27,
> verified 2026-08-28 against the Tribunal's own materials and extracted into
> **`docs/reference/lat-case-conference.md`**. It reached `src/data/site.ts` as a
> service blurb once already; this note exists because a search-intent list is
> where that lift starts.
>
> What the verification found, in one line each:
>
> - **LAT Rule 2.4:** *"'Case Conference' has the same meaning as 'Pre-Hearing
> Conference' as defined in the SPPA."* **"Pre-hearing" is the Tribunal's own
> label**, and what it labels is a case conference.
> - **Rule 14.3:** a **Member** presides and is then disqualified from the
> hearing panel; **Rule 14.6:** parties must attend. The neutral is the
> Tribunal's. A privately retained one is not appointed to it and cannot be.
> - The LAT Rules contain **zero** occurrences of `mediat` or `arbitrat` —
> 0 in 66,593 characters. The concept is not in them.
> - The LAT-AABS page itself, though, says: *"Before you apply to the LAT-AABS,
> you may want to consider negotiation or mediation services… including before
> filing at the LAT-AABS, and continuing… after a claim has been filed."*
> **That is the affirmative basis for the offering, in the Tribunal's words.**
>
> **The page must state that the mediation offered is PRIVATE, retained by the
> parties, and is not the Tribunal's case conference.** Published blurb:
> *"Accident benefits and SABS entitlement, MIG disputes, and private mediation
> alongside a LAT application, before filing or after."* If Pouya holds a roster
> position that makes more than that true, it is a §4 addition — absent a row,
> it is not.
Highest realistic near-term volume — it flows directly from the existing
personal-injury and SABS practice, and brief §IV.7 notes the segment is
personal-injury and SABS work, and brief §IV.7 notes the segment is
underserved by senior mediators. Unglamorous and worth doing well.
### `/practice/shareholder/`
@@ -255,6 +346,20 @@ Shareholder and partnership disputes, co-founder breakdowns, family-business
succession, SME exits. The operator angle — running SML Company Ltd. alongside
the practice — is the differentiator here.
**"Family Business" means COMMERCIAL disputes among family shareholders, and the
page must say so.** Pouya's ruling of 2026-08-27 (`AGENTS.md` Q39): the label
covers shareholder and partnership disputes, co-founder breakdowns and business
succession — **not** family law. **Family arbitration under the *Family Law Act*
is not offered**, and that activity is separately gated by prescribed training
(`docs/reference/ontario-family-arbitration-training.md`), so the exclusion has
to be legible rather than left to be inferred from the surrounding nouns.
**One sentence, not a section.** His instruction, and the reason is also the test
for whether it belongs at all: *"The page should say plainly that family law
matters are not accepted. One sentence, not a section: it saves a wasted intake
call, which is the only reason it earns its place."* A disclaimer that grows into
a paragraph reads as defensive, which is the opposite of the point.
### `/practice/cross-cultural/`
**Search intent:** `Farsi speaking mediator Toronto`,
@@ -272,9 +377,17 @@ and framing (17) · pre-session exchange (721) · the session (2130) ·
conclusion (30+). Also: conflicts checking, confidentiality, and what happens if
a matter does not settle.
**The timings are published as the TYPICAL shape of an engagement, explicitly
not a guarantee** — `AGENTS.md` Q43, Pouya 2026-08-27. Render `PROCESS_FRAMING`
(`src/data/site.ts`) **adjacent to the steps**, on this page and on `/`. The
numbers above are unchanged; what is required is that they never appear
unframed. *"Published as typical, they are honest and useful; published as
commitments, the first matter that slips makes the page false."*
### `/fees/`
**Blocked on `AGENTS.md` Q4 — do not invent numbers.**
**Unblocked `AGENTS.md` Q4/Q14 answered (D14). Build from the confirmed card
in `docs/07-fees.md`; still do not invent numbers.**
Hourly rate; half-day and full-day mediation; preparation time policy;
cancellation terms; administrative fee; HST treatment; who pays and how costs
@@ -295,8 +408,21 @@ you do not settle · how to prepare.
Astro content collection, MDX. Index reverse-chronological with topic filtering
by practice area.
Article frontmatter: `title`, `description`, `publishDate`, `updatedDate`,
`topics[]`, `practiceAreas[]`, `readingTime`, `draft`.
Article frontmatter: `title`, `seoTitle` (optional), `description`,
`publishDate`, `updatedDate`, `topics[]`, `practiceAreas[]`, `readingTime`,
`image` and `imageAlt` (both optional, but `imageAlt` is **required whenever
`image` is set**), `draft`, `reviewedByPouya`.
`title` is the headline and, for articles, the `<title>` — they carry no
` · Pouya Lajevardi` suffix; see `04-seo-spec.md` for why. `seoTitle` replaces
it when a headline that reads well falls outside 5060. `src/content.config.ts`
enforces the rendered length and names the offending string in the error.
Dates are date-only ISO (`2026-08-01`), parsed as UTC and round-tripped, so a
typo fails the build rather than shipping as 1970 or as the wrong day.
`reviewedByPouya` carries D9: the schema refuses to build an entry with
`draft: false` and `reviewedByPouya: false`.
Content territories, from brief §VII: process explainers · regulatory commentary ·
industry-specific dispute commentary · anonymised reflections · technical
@@ -312,8 +438,9 @@ anything.
### `/contact/`
Intake form (`05-backend-spec.md`), booking embed, direct email and phone
(Q3), Toronto by-appointment line, response-time expectation, and an explicit
Intake form (`05-backend-spec.md`), booking embed, direct email
(Q3**there is no public phone number**; render `CONTACT.phoneFallback`,
"By scheduled call", wherever a number would go), Toronto by-appointment line, response-time expectation, and an explicit
note that submitting the form does not create a retainer or a mediatorparty
relationship and does not itself create a conflict check.
@@ -339,6 +466,6 @@ Dependency-ordered, so nothing is blocked mid-stream:
6. `/process/`, `/for-parties/`
7. `/insights/` plumbing, then the drafted articles
8. `/contact/` and the intake backend
9. `/fees/` — last, since it is blocked on Q4
9. `/fees/` — last, though no longer blocked: D14 confirmed the card
10. `/legal/*` — written to match the backend as actually built
11. Audit and cutover (`06-deployment.md`)
+101 -10
View File
@@ -11,6 +11,12 @@ system that preserves it while fixing what the old build got wrong.
- The **palette**: cream, ink, maroon, gold.
- The **infinity mark** — SML Company Ltd.'s actual logo, and the metaphor holds:
a dispute is a loop, and the work is redrawing the loop into a line.
**It is a shaded ribbon, not a stroked curve**: a band of variable width that
twists in three dimensions, maroon flowing into champagne, passing over itself
at the crossing. Ink bounding box **2668 × 1704 = 1.5657:1**
`[verified 2026-08-26 — measured against the master]`. Source of truth:
`src/assets/brand/sml-infinity-mark.png`; provenance in
`docs/reference/brand-assets.md`.
- The **type pairing**: Instrument Serif for display, Geist for text, Geist Mono
for eyebrows and labels.
- The **editorial register** — generous whitespace, restrained colour, serif
@@ -25,7 +31,7 @@ system that preserves it while fixing what the old build got wrong.
| Ad hoc spacing values | 8 px base scale | Consistent vertical rhythm; no magic numbers |
| Gold used as a text colour on cream | Gold restricted to decorative and on-dark | **It fails WCAG AA at 2.10:1.** Measured, not assumed |
| Scroll-reveal on every element, always on | Reveal on major sections only, gated behind `prefers-reduced-motion` | Motion that reads as confident rather than decorative; accessible by default |
| 2.2 MB single file, ~1 MB of base64 logos | Optimized SVG mark, AVIF/WebP photography | The mark is geometry; it should be vector, not a 470 KB PNG |
| 2.2 MB single file, ~1 MB of base64 logos *(both figures under review — `AGENTS.md` Q34; and "470 KB PNG", which this row used to assert, has no source anywhere in the repo and has been removed)* | Optimized SVG mark, AVIF/WebP photography | The mark is geometry, so it should be vector. That holds whatever the old file weighed |
| React 18 dev build + Babel Standalone in the browser | Static HTML, near-zero JS | The reason the site is invisible to crawlers |
---
@@ -116,7 +122,13 @@ the display end. Tokens `--text-xs` through `--text-6xl` in `tokens.css`.
Content width `1280px`; prose measure `68ch`; wide media `1440px`.
Gutters: `24px` mobile, `48px` desktop.
Section rhythm: `--space-9` (96px) mobile, `--space-11` (160px) desktop.
Section rhythm: `--space-9` (96 px) mobile, `--space-11` (160 px) desktop
`--section-y` in `tokens.css`. *Corrected 2026-08-26:* the curve was
`6vw + 2rem`, which reaches 160 px only at a **2133 px** viewport, so the
desktop half of this line was never delivered (measured 108.8 px at 1280 px,
128 px at 1600 px). It is now `9vw + 1rem`, which reaches 160 px at 1600 px. If
you change the curve, re-measure — a `clamp()` whose upper bound is unreachable
reads exactly like one that works.
Grid: 12 columns desktop, 6 tablet, 4 mobile, `--space-5` gutter.
@@ -131,11 +143,24 @@ deliberate and quiet.
stagger is limited to card grids, and capped at six children.
- Duration `600ms`, easing `cubic-bezier(.2,.7,.2,1)`. Transform and opacity
only — never layout properties.
- Implement with `IntersectionObserver` in one tiny inline script, or
`animation-timeline: view()` where supported. Not a framework, not a library.
- **Content is visible without JavaScript.** The reveal is an enhancement layered
on top of already-rendered HTML. If the observer never runs, the page reads
normally. The old build had this exactly backwards.
- Implement with **`animation-timeline: view()`**, behind `@supports`. *Amended
2026-08-26:* the `IntersectionObserver` alternative this line used to offer
first is now ruled out, not merely second choice. It has to run inline in
`<head>` to avoid a flash, and `05-backend-spec.md` specifies `script-src
'self'` with no `unsafe-inline` — so the only script on the site would have
been the one thing the site's own CSP refuses to execute, and a per-build hash
drifts from the policy pinning it. The CSS route ships **zero** JavaScript.
Not a framework, not a library, not a script.
- **Content is visible without the feature.** The `@supports` gate is
load-bearing, not defensive: without it, a browser that ignores
`animation-timeline` runs the animation once against the document timeline at
load; with it, that browser gets no animation and fully visible content. The
old build had this exactly backwards and shipped a blank page.
- **And without a print timeline.** A scroll-driven animation has no timeline
when printing, so a revealed element renders at its `from` state — `opacity:
0`. Measured 2026-08-26: before the print override existed, printing a page to
PDF dropped four card headings from the output entirely. `/about/` is written
to be printed by people evaluating an appointment.
- Hover transitions `250ms`.
```css
@@ -158,8 +183,8 @@ except the reveal of the hero.
| Component | Notes |
|---|---|
| `InfinityMark` | Inline SVG, `currentColor`, `aria-hidden` when decorative. Never a PNG |
| `SiteHeader` | Sticky, condenses on scroll. Practice dropdown as CSS-only `<details>` |
| `InfinityMark` | ⚠️ **Currently a raster — a documented, temporary exception to this rule** (`AGENTS.md` Q38, R13). The mark is gradient-mesh artwork rather than flat vector paths. An SVG *is* held and it renders faithfully — **and it renders faithfully because it IS the raster**: 257,278 bytes wrapping **seven embedded base64 PNGs**, against **3,063 B** for the AVIF a Retina browser takes in the header — **84×**. *Figures re-stated 2026-08-27 because a second, larger call site now exists and the single number had become misleading:* the mark ships at **two intrinsic widths**, 64 px (header, footer) and 232 px (the home page's approach section, which renders at 225.5 px). At 232 px a DPR-2 device takes **14,555 B** and DPR-3 **22,639 B**, so the ratio against the SVG there is ~11×, not 84×. The full ladders are in `docs/reference/brand-assets.md`; do not quote one number as if it covered both. Inlining it would breach `CLAUDE.md`'s no-base64-images rule. *Pouya settled the characterisation on 2026-08-27: a first draft called the file "a raster in a vector wrapper", a later draft withdrew that as unfair, and **the withdrawal went too far.** Both halves are true — the fidelity is real, and it is bought with embedded raster, which is exactly why fidelity was never the question.* The exception is about payload and composition. It renders AVIF/WebP with a PNG fallback; a Retina device takes 3,063 B of AVIF, `alt=""` when decorative, aspect ratio pinned to `667 / 426`. **Restore this rule the moment the commissioned vector master lands.** Until then the rule stands unchanged for every other mark or icon: inline SVG, `currentColor`, `aria-hidden` when decorative, never a PNG |
| `SiteHeader` | **Sticky from 66 rem (1056 px) up; static below it.** Practice dropdown as CSS-only `<details>`. **(a)** The one-row header holds the brand, **seven** nav items and the CTA — Insights is the seventh, arriving on its own at build step 7. Binary search on the built page puts the true fit at **1047 px**; 66 rem is the clean token above it. Below 66 rem the nav takes its own row and the header measures **141 px** at tablet widths and **189 px** at 320375 px. **(b)** "Condenses on scroll" is a hairline rule and a shadow, **not a size change** — a `position: sticky` header stays in normal flow, so shrinking its padding lifts every page below it, against the CLS < 0.05 budget. Longhands only; see the component on what the minifier does to the `animation` shorthand. *(This row has been wrong twice, instructively. It first said 60 rem / "~115 px", a height the header never took. It then said 64 rem "with 32 px of clearance" — that 32 px was `.header-inner`'s own `column-gap` mistaken for slack; the real figure at 1024 px with seven items was **21.6 px**, and nothing overflowed only because flexbox crushed the logo inside the brand block. **Measure slack, not gaps.**)* |
| `SiteFooter` | Three-column sitemap, contact block, designations, entity line |
| `Eyebrow` | Mono label with optional maroon dot |
| `SectionHeading` | Eyebrow + display heading + optional lede, one measure |
@@ -191,5 +216,71 @@ Not a polish pass. A build requirement.
- Forms: real `<label>` elements, `aria-describedby` for hints, errors announced
with `role="alert"` and tied to their field.
- Touch targets ≥ 44 × 44 px.
- Test at 200% zoom and at 320 px width.
- Test at 200% zoom and at 320 px width. **Both measured 2026-08-27 on `/`:
document overflow 0 at 320, 360, 390, 414, 640, 768, 900, 1024, 1056, 1200,
1216, 1280, 1440 and 1920 CSS px, with zero elements extending past the
viewport.** Page zoom at 200% of 1280 is the 640 column and at 400% is the
320 column, so WCAG 1.4.4 and 1.4.10 are both covered by that sweep.
**A stricter case is not fully clean, and it is recorded rather than left to
be discovered.** With the reader's *default font size* at 200% — root at
32 px, a real accessibility setting and not page zoom — `/` measured **234 px**
of overflow at 390. Brought down in three measured steps:
| Fix | 390 px | 320 px |
|---|---|---|
| as first built | 234 px | 304 px |
| `minmax(min(Nrem, 100%), 1fr)` on three grids | 83 px | 153 px |
| `.credentials` made explicit `repeat(2, minmax(0, 1fr))`; `.feature` padding clamped and `overflow-wrap: anywhere` on its title; `.contact-action` `flex: 0 1 auto` + `min-inline-size: 0` | **3 px** | **63 px** |
**`65 px` corrected to `63 px` on 2026-08-28**, re-measured independently on
the same page and setting. Two pixels, and it is recorded because a table that
reads as the site-wide record has to be re-measurable rather than remembered.
**`/about/` added 2026-08-28** — step 3, and the first page to be measured
against this table rather than establishing it:
| Page and fix | 390 px | 360 px | 320 px |
|---|---|---|---|
| `/about/` as first built | 38 px | 68 px | 108 px |
| `.designation-part` `white-space: nowrap` removed (the separator is held by an NBSP instead) and `overflow-wrap: anywhere` on `.hero-h` | **0 px** | **23 px** | **63 px** |
Two findings from that page specifically. The `nowrap` was **introduced as a
fix** for an orphaned `·` at the end of a wrapped line, and it made the whole
designation item unbreakable — a fix that created a reflow regression, caught
only because this table existed to regress against. And **the type scale is
rem-based**, so an `<h1>` at `--text-5xl` computes to 88 px at root 32 and a
single unbreakable 9-character name ("Lajevardi") exceeds the 224 px content
box at 320 px; `overflow-wrap: anywhere` is the only remedy that reduces
min-content size. `/about/` now measures equal to or better than `/` at all
three widths, and its 320/360 residual is the same header decision.
Command, so the numbers are re-runnable rather than quoted: headless Chrome
over the built `dist`, `document.documentElement.style.fontSize = '32px'`, then
`documentElement.scrollWidth - documentElement.clientWidth`, plus an
enumeration of every element wider than `clientWidth` to name the offender.
Two things worth keeping. **`overflow-wrap: break-word` permits a break at
layout time but does not reduce min-content size** — `anywhere` does, and that
distinction was the whole of one of those fixes. And 1280 px stays **602 px**
over, from the header's deliberate `flex-wrap: nowrap` above 66 rem plus
`white-space: nowrap` on the brand name; the 320 px residual is the same header
plus the display headline's 104 px floor. Undoing either re-opens the measured
step-1 header decision, so they stand. All of this is beyond what this floor
requires — page zoom is clean — so it is a robustness margin rather than a
failure. Revisit if a real reader hits it.
- **Measure the elements, not only the page.** A document-level overflow check
passes while a flex child absorbs the deficit by being crushed — that is how
step 1 shipped a logo at aspect 0.891 under a green "0 overflow at every
width". Assert the rendered geometry of the thing you care about. On `/`
every infinity-mark instance measures 1.56541.5657 against the master's
1.5657.
- **A touch-target measurement of the wrong box is not a finding.** The eight
cards on `/` report 2639 px-tall `<a>` elements and are fine: each card's
whole box is the link's hit area via `::after { inset: 0 }`, verified by
hit-testing nine points per card at three widths (24 cards, 9/9). Hit-test
before enlarging anything. WCAG 2.5.8's inline exception also applies to a
link sitting mid-sentence, and one such link on `/` is deliberately left at
164 x 21.
- Every page must be readable and navigable with JavaScript disabled.
+130 -13
View File
@@ -28,8 +28,22 @@ detect padding instantly and discount everything after it.
practice". The old site's "we" implied a firm that does not exist.
- Concrete nouns. *Lien claim. Change order. System Impact Assessment. Model
card. Minutes of settlement.* Specificity is the credential.
- Name the limits. "Sole-arbitrator appointments follow the Q.Arb designation;
co-arbitration and tribunal-secretary work is available now." Precision about
- Name the limits — but name the *right* ones. This bullet carried the model
sentence *"Sole-arbitrator appointments follow the Q.Arb designation;
co-arbitration work is available now"* until 2026-08-26. **Both halves were
wrong and they were wrong in opposite directions**, which is why it survived
two audits: the first half understated (sole-arbitrator appointments are
offered **now** and are not gated by Q.Arb — §4 Offerings), and the second was
unsourced when written. §4 now carries rows for all three forms.
The shape of the bullet still stands, so here is a sentence that fits it and
clears the register: *"I accept sole, party-appointed and co-arbitration
appointments. The Q.Arb designation commenced in August 2026; C.Med-Arb is the
endpoint."* The limit being named is the **stage of the arc**, stated plainly —
Pouya's instruction is that being open about it is the differentiator, so do
not hedge it into vagueness and do not drop it. (**No tribunal-secretary
work** — D14
removed the rate and bars offering it; see `docs/07-fees.md`.) Precision about
what you cannot yet do makes the rest believable.
- Plain words over Latin. "Without prejudice" survives because it is a term of
art; *inter alia* does not.
@@ -49,8 +63,9 @@ detect padding instantly and discount everything after it.
This framing is **interim** — see `AGENTS.md` §12 R1. Raise it with Pouya
rather than letting it settle in by default.
- Superlatives. No "leading", "premier", "top-rated", "best". LSO marketing rules,
and they read as insecure.
- Superlatives. No "leading", "premier", "top-rated", "best". They are
unverifiable, they read as insecure, and marketing rules for regulated
professions treat them as suspect.
- Outcome language that could be read as a guarantee.
- "Passionate", "dedicated", "committed", "proven track record", "results-driven",
"leverage", "synergy", "solutions".
@@ -68,15 +83,34 @@ detect padding instantly and discount everything after it.
Reused, adapted, across the hero, the About page, and the PDF bio:
> The dispute resolution practice of Pouya Lajevardi — a credentialed neutral
> who is also a working litigator and a practising machine-learning and
> who is also close to live litigation and a practising machine-learning and
> infrastructure engineer. Built for commercial, construction, energy,
> technology, and cross-cultural disputes that turn on facts most neutrals take
> on faith: the contract, the code, the engineering documents, and the
> regulatory overlay around them.
> technology, and cross-cultural disputes that turn on the contract, the code,
> the engineering documents, and the regulatory overlay around them.
Every version of this must survive the §4 check. It does: each element is
verified.
**AMENDED 2026-08-27 — `AGENTS.md` Q41(b). The statement read *"disputes that
turn on facts most neutrals take on faith: the contract…"* and the comparative
is struck.** It was not restored, and Pouya gave two reasons, the second of
which is the one to remember:
> "That is an unverifiable empirical claim about other practitioners, and
> comparative claims must be factual and verifiable. **It is also weaker copy:
> assert his capability, not the field's incapability.** Rewrite to claim only
> about himself — 'built for disputes that turn on the contract, the code, and
> the engineering documents'. Same force, nothing to defend."
So the compliance objection and the editorial objection point the same way.
The checklist item below — *"any comparative claim is factual and verifiable"*
had been overridden in practice by the fact that this paragraph was **approved
copy**, which is how an unverifiable claim ends up inside the document that
forbids it. The approved copy is what changed.
**This is now the ONLY sanctioned form of the statement.** Any earlier draft
carrying the comparative is superseded, wherever it is quoted.
## Approved headline options
From the content brief; all three sit honestly with the practice.
@@ -84,7 +118,12 @@ From the content brief; all three sit honestly with the practice.
1. *A mediator who reads the contract, the code, and the room.***recommended.**
The cleanest one-sentence statement of the moat, and rare because it is rare.
2. *Engineered for the cases that don't fit a courtroom.*
3. *Disputes resolved by someone who has been on every side of one.*
3. ~~*Disputes resolved by someone who has been on every side of one.*~~
**Does not clear §4 as written** (flagged 2026-08-26). "Every side" asserts
having acted as party, as counsel, and as neutral; §4 verifies the neutral
role and *active litigation exposure*, not the other two. Left in place so
the option is not silently re-invented — but it cannot be chosen without a §4
row to choose it from.
## The credential row
@@ -93,10 +132,59 @@ Three slots, never counts:
| Slot | Value | Label |
|---|---|---|
| 1 | **Q.Med** | ADRIC / ADRIO designation |
| 2 | **JD + ML** | Law and engineering |
| 2 | **JD + ML** | Legal training and engineering practice |
| 3 | **EN · FA** | Bilingual practice |
Fourth slot where the layout has one: **Q.Arb** — in progress.
**Slot 2's label changed on 2026-08-27 (Pouya, `AGENTS.md` Q37).** It read
*"Law and engineering"*. His reasoning: *"The parallel was doing the implying — a
degree and a practice under one noun. The asymmetry is the honest part."* A JD is
a degree; engineering is a practice, and a verified one. Setting them in parallel
invited the reader to supply the symmetry, and for "Law" the missing half is a
licence — which D13 bars by implication as hard as by assertion. The replacement
is longer and deliberately lopsided. Do not tidy it back into a parallel.
**AND THE RULE IS NOT SCOPED TO THIS LABEL — `AGENTS.md` Q41(a), Pouya
2026-08-27.** Q37 was asked about a credential label; the reasoning applies to
every surface, prose included:
> "Yes, Q37's reasoning reaches prose. The implication test applies everywhere,
> not just to labels. **Prose has more room, so it is easier to satisfy: state
> the asymmetry explicitly rather than relying on a parallel construction to
> carry it.**"
Read the second half carefully, because it sets a **higher** bar for prose, not
a looser one. Deleting the parallel is only half the job — a reader can still
supply the missing symmetry from silence. Prose has room to say which half is
which, so it must. Naming the legal half as **training** is what makes the licence
implication impossible rather than merely absent.
**THE SENTENCE IS A CONSTANT, NOT COPY TO BE RETYPED**`ASYMMETRY_LINE` in
`src/data/site.ts`:
> "The two halves are not the same kind of thing, and the asymmetry is the honest
> part. A law degree on one side. A working engineering practice on the other.
> One is training I hold. The other is work I still do."
It lives beside `ROLE` for the reason that file gives: *"these are the two where
the wording IS the compliance."* This paragraph previously quoted it inline and
cited it as living at "`/`, §The approach" — and by then it had been typed into
`/about/` too, so there were **three copies and two of them had already
diverged**: a comma here and on `/`, full stops on `/about/`, all inside the
session that wrote them. Consume the constant on any page that needs the
sentence. Do not retype it, do not paraphrase it, and do not quote a variant of
it in a spec.
Fourth slot where the layout has one: **Q.Arb — commenced August 2026.** Use
that wording, not "in progress": §4 pins it, and the weaker form drifts toward
"nearly complete", which §4 Forbidden bars outright.
**On the home page the fourth slot IS used, and it is not optional there.**
`docs/01` §`/` says "Three slots"; §4's paired-disclosure condition is the higher
authority and requires that wherever the site offers arbitration it "states
plainly" the stage of the arc. `/` says *arbitrator* in its opening sentence, so
the stage belongs on the same page rather than only in the footer. Rendered as
value `Q.Arb` over label `Commenced August 2026` — the same wording, with the
em-dash carried by the layout instead of by the string.
The substitution principle (`AGENTS.md` §4): wherever the design wants a "how
many", substitute a longer-arc credential. These are all true at launch and stay
@@ -113,9 +201,17 @@ Hero headline from the approved list. Positioning paragraph above. CTAs:
dispute — and keeps the infinity metaphor: *disputes are loops; the work is
redrawing the loop into a line.* First person: "my mark", not "our mark".
> ⚠️ **"law and engineering" IS THE STRUCK CONSTRUCTION. Do not lift this
> sentence into copy.** The *argument* it names is Pouya's and stands; the noun
> pair carrying it is what Q37 struck and Q41(a) extended to prose. It reached
> the page once already, as *"Law and engineering are not blended here"* — the
> struck parallel relocated from the credential label into body copy, one day
> after it was struck, and strengthened by attributing both halves to him
> personally. A spec phrase describing an argument is not approved copy.
### About
400600 words of narrative, then structured credentials. Tell the three tracks
as one arc, not three lists: a JD and an active litigation practice; a parallel
as one arc, not three lists: a JD and active litigation exposure; a parallel
career in machine learning and infrastructure engineering; a company run
alongside both. The arc is the point — the credentialing pathway from Q.Med
through Q.Arb to C.Med-Arb is stated openly as in progress. The brief treats
@@ -149,8 +245,29 @@ neither.
Five steps with real timing. Say what happens if the matter does not settle —
counsel want to know the downside shape before they commit a client's day.
**AMENDED 2026-08-27 — `AGENTS.md` Q43, and it overrides this section's previous
reading.** "Real timing" was being read as *barring* the word "typical", which
is why the step-2 build shipped the five timings as bare numbers and escalated
the question instead of framing them. Pouya ruled the other way:
> "The five process timings are **service commitments, same class as Q27's
> response time** — not facts about Pouya, so they need framing, not a Verified
> row. Present them as the TYPICAL shape of an engagement, explicitly not a
> guarantee: mediation timing depends on party and counsel availability, which
> he does not control. **Published as typical, they are honest and useful;
> published as commitments, the first matter that slips makes the page false.**"
So: the **numbers do not change** — softening them was never the fix and
inventing them was never on. What "real timing" bars is a *vague* timing
("promptly", "in a matter of weeks"), not an honest statement of what the
numbers are. The framing is `PROCESS_FRAMING` in `src/data/site.ts` and it is
**not optional**: every page that renders the steps renders it, adjacent to the
numbers rather than in a section lede above them. A reader who scans the strip
and skips the lede has read a commitment.
### Fees
**Blocked on Q4.** Real numbers or `TODO(pouya)`. Plain table, no "starting from"
**Unblocked Q4/Q14 answered, D14.** Build from the confirmed card in
`docs/07-fees.md`. Plain table, no "starting from"
evasions, no "contact for pricing" after promising a rate card.
### For parties
+81 -20
View File
@@ -1,7 +1,14 @@
# 04 — Discoverability
The problem this project exists to fix. `AGENTS.md` §2 has the measurements: a
server-side fetch of the live site returns three words.
The problem this project exists to fix. `AGENTS.md` §2 has the measurements.
> **Those measurements are under review — `AGENTS.md` Q34.** They were taken on
> 2026-08-25; a re-fetch on 2026-08-26 returned a bundler harness whose real
> `<head>` sits JSON-escaped inside a `<script>` and whose application lives in
> nine UUID-named files that were not fetched. Some of §2 reproduced exactly
> (the 2.2 MB single file, the placeholder `<title>`); some could not be
> reproduced from the served HTML at all. **Cite §2, and cite Q34 with it. Do
> not put any of these figures in public copy until Q34 closes.**
---
@@ -10,13 +17,13 @@ server-side fetch of the live site returns three words.
| | Now `[verified 2026-08-25]` | Target |
|---|---|---|
| Content in server HTML | `SML Company · DISPUTE RESOLUTION · Unpacking...` | Every word |
| Indexable pages | 1 | 19 + articles |
| Indexable pages | 1 | 17 + articles (19 fixed URLs, less the two `/legal/*` pages, which are `noindex` and excluded from the sitemap) |
| `<title>` | `SML Company · Dispute Resolution` — pre-rebrand placeholder | Unique per page |
| Meta description | none | Unique per page |
| `<meta viewport>` | **absent** | Present |
| Canonical URL | none | Every page |
| OG / Twitter tags | none | Every page |
| Structured data | none | Person, LegalService, Article, FAQ, Breadcrumb |
| Structured data | none | Person, ProfessionalService, Article, FAQ, Breadcrumb |
| `robots.txt` | 403 | Served |
| Sitemap | none | Generated at build |
| Favicon | none | Full set |
@@ -43,6 +50,15 @@ Every page passes through one `SEO` component. A page without it is not finished
```
title 5060 chars, unique. Pattern: "<Page> · Pouya Lajevardi"
Home: "Pouya Lajevardi · Mediation & Arbitration · Toronto"
ARTICLES ARE THE EXCEPTION: no " · Pouya Lajevardi" suffix.
The suffix is 18 chars, so a headline that already reads
5060 renders at 6878 — over this ceiling. Measured against
the five launch headlines in 03-content-spec.md, the suffix
rule fails 5 of 5; without it, 4 of 5 pass. An article's
headline IS its <title>; `seoTitle` in the frontmatter
overrides it when a headline that reads well is out of range.
src/content.config.ts enforces this and names the offending
string and its length in the build error.
description 140160 chars, unique, written for a human, not stuffed
canonical absolute, https, trailing slash
og:title/description/image/url/type/site_name/locale (en_CA)
@@ -50,9 +66,34 @@ twitter:card summary_large_image
robots index,follow — except /legal/* which is noindex,follow
```
**OG images:** 1200 × 630. Generate at build with `satori` or `astro-og-canvas`
using the site's own type and palette. One template: display headline on cream,
infinity mark, designation line. Never a screenshot.
**OG images:** 1200 × 630. Never a screenshot.
**RULED 2026-08-27 (`AGENTS.md` Q40, R15) — TWO kinds of card, not one, and the
generator is deferred to build step 7.** This spec said "one template" for all
nineteen pages. Pouya split it:
> "A portrait is the **right** OG image for `/` and `/about/` — a face is the
> strongest social preview for a personal brand. It is the **wrong** one for
> nineteen pages, where a typed card carrying the page title would do the work.
>
> But do not build the generator now and do not leave 'portrait everywhere' as
> an untracked interim. **Ship it at step 7 alongside Insights, which needs
> per-article cards anyway — one build, one dependency, one review.**"
So:
| Pages | Card |
|---|---|
| `/` and `/about/` | The **portrait** crop, `src/assets/og-portrait.jpg`. Not an interim — the decided answer |
| Every other page | Generated at build with `satori` or `astro-og-canvas`, using the site's own type and palette: display headline on cream, infinity mark, designation line |
| Each article | Per-article card from the same generator — the reason the two jobs are one build |
**Until step 7 every page shares the portrait, and that is a RECORDED interim
that blocks cutover, not build step 3.** It is tracked as **R15** in
`AGENTS.md` §12 with its removal trigger, because a link preview nobody on the
team ever sees is exactly the kind of interim that becomes permanent by
never being raised. The dependency choice is made against R11 on the day, not
recalled from this paragraph.
## Structured data
@@ -60,8 +101,8 @@ JSON-LD only. Validate against Google's Rich Results Test before cutover.
| Type | Where | Notes |
|---|---|---|
| `Person` | `/about/`, referenced site-wide | `name`, `jobTitle`, `description`, `alumniOf` (Bond University), `knowsLanguage` (en, fa), `hasCredential` (Q.Med), `sameAs` (LinkedIn**Q12**), `image`, `worksFor` |
| `LegalService` | Home | `areaServed` Toronto/Ontario, `serviceType` Mediation/Arbitration, `provider` → Person, `priceRange` once `/fees/` is real |
| `Person` | `/about/`, referenced site-wide | `name`, `jobTitle`, `description`, `alumniOf` (Bond University), `knowsLanguage` (en, fa), `hasCredential` (Q.Med), `sameAs` (LinkedIn), `image`. **`jobTitle` = "Director of Firm Operations"; omit `worksFor`** — populating it either names the boutique (D16) or misstates the employer |
| `ProfessionalService` | Home | `areaServed` Toronto/Ontario, `serviceType` Mediation/Arbitration, `provider` → Person, `priceRange` once `/fees/` is real. **Never `LegalService`** — schema.org defines it as a business providing legal advice and *representation*, which asserts in machine-readable form exactly what D13 bars and §4 Forbidden calls out |
| `Service` | Each practice page | `serviceType`, `provider` → Person, `areaServed` |
| `Article` | Each article | `headline`, `description`, `datePublished`, `dateModified`, `author` → Person, `image` |
| `BreadcrumbList` | All nested pages | Matches visible breadcrumbs |
@@ -73,15 +114,21 @@ to be machine-readable.
## Crawlability
**`public/robots.txt`:**
**`public/robots.txt` is the artefact — read it, do not read a copy of it
here.** This spec used to reproduce the file inline and the reproduction had
already drifted from it by 2026-08-26, which is the failure mode the `AGENTS.md`
§7 rule exists to stop.
```
User-agent: *
Allow: /
Disallow: /legal/
Sitemap: https://adr.smlcompany.ca/sitemap-index.xml
```
**It disallows nothing, and that is deliberate.** This spec previously
prescribed `Disallow: /legal/` alongside `noindex` on those pages, and the two
cancel each other: a crawler forbidden to *fetch* a URL never reads the
`noindex` on it. `/legal/privacy/` and `/legal/terms/` are linked from the
footer of every page, so they are discovered regardless — and the likely result
of the pair was Google listing the bare URLs as "no information available", the
opposite of the intent, with the directive that would have suppressed them
sitting unread behind the wall. **`noindex` is what de-indexes; `Disallow` is
what prevents fetching.** Use the one that matches the problem, and never both
on the same path.
Do not block AI crawlers. Being read by an assistant that a general counsel is
using to shortlist neutrals is the point.
@@ -108,7 +155,16 @@ Core Web Vitals are a ranking input, and the current build fails all of them.
| CLS | < 0.05 |
| INP | < 150 ms |
| JS per route | < 100 KB |
| Lighthouse (mobile) | ≥ 95 all four categories |
| Lighthouse (mobile) | ≥ 95 all four categories**not measurable until step 7, see below** |
> ⚠️ **Lighthouse verification is UNAVAILABLE until build step 7.** `@lhci/cli`
> was removed on 2026-08-26 — it was the sole source of all 10 `npm audit`
> findings (7 high), `0.15.1` is `latest` so there was no clean upgrade, and it
> could not run at all with no pages and no `lighthouserc`. The budget below is
> not suspended; the tool that measures it is absent. Re-add at step 7 under
> `AGENTS.md` R11, checking for a patched release rather than assuming `0.15.1`
> is still the ceiling. Until then, a run that skips this is skipping something
> known — not something forgotten. `AGENTS.md` §7 has the state.
How: static HTML, self-hosted preloaded subset fonts, AVIF/WebP with explicit
dimensions, critical CSS inlined, no third-party scripts on any page except the
@@ -119,14 +175,19 @@ booking embed on `/contact/` — and that one is lazy-loaded behind a click.
Not code, but it belongs in the launch checklist: Google Business Profile for the
practice; ADRIC and ADRIO directory listings pointing at the site; a LinkedIn
profile whose headline and Featured section match the brand (brief §VIII);
consistent name, address, and phone across all of them.
consistent naming across all of them. **Not "name, address and phone"** — §4
publishes no phone number and no street address, only "Toronto, Ontario; by
appointment". Directory forms that demand a full NAP get what §4 verifies and
nothing more.
## Post-launch verification
- [ ] `curl -s https://adr.smlcompany.ca/ | grep -c "<h1"` returns ≥ 1
- [ ] Every page renders its full text with JavaScript disabled
- [ ] Rich Results Test passes on Person, LegalService, Article
- [ ] Rich Results Test passes on Person, ProfessionalService, Article
- [ ] OG preview renders correctly in LinkedIn Post Inspector and Slack
- [ ] Sitemap submitted to Google Search Console and Bing
- [ ] No page returns 200 for a URL that should 404
- [ ] Lighthouse ≥ 95 mobile on `/`, `/about/`, one practice page, one article
**blocked until `@lhci/cli` is re-added at step 7.** Do not tick this box
from a manual Chrome DevTools run and call it the same check
+77 -32
View File
@@ -1,7 +1,9 @@
# 05 — Intake, booking, and data handling
Authority: `AGENTS.md` §3 D10 — rebuilt intake form plus calendar booking.
Existing infrastructure is documented in `AWS-Hosting-Guide.md` Parts 810.
Existing infrastructure is authoritative in `AGENTS.md` §7. How it was built is
recorded in `docs/reference/AWS-Hosting-Guide.md` Parts 810 — a historical
record with a do-not-execute banner, superseded by §7 wherever they disagree.
**Read that guide before changing anything**; the resources already exist and
were built by hand in the console.
@@ -10,7 +12,7 @@ were built by hand in the console.
## What exists today
API Gateway (HTTP API) → Lambda → DynamoDB, with SES for notification email and
a verified sender on `smlcompany.ca`. `[verified 2026-08-25 — AWS-Hosting-Guide.md]`
a verified sender on `smlcompany.ca`. `[verified 2026-08-26 — AGENTS.md §7]`
The shape is right. This is a hardening and rework pass, not a replacement.
@@ -71,9 +73,11 @@ Client-side validation is a convenience. **The Lambda re-validates everything.**
## Storage
DynamoDB, `ca-central-1` **Canadian data residency is a real selling point for
a Canadian legal practice, and the privacy policy will say so.** Confirm the
existing table's region and migrate if it is elsewhere (**Q10**).
DynamoDB, in the region `AGENTS.md` §7 records. **Canadian data residency is
worth stating in the privacy policy**: parties describing a live dispute are
handing over sensitive material, and where it comes to rest is a fair question
for them to ask. Confirm the existing table's region and migrate if it is
elsewhere — §7 has the table name and region.
| Attribute | |
|---|---|
@@ -102,37 +106,64 @@ personal information only as long as necessary. Whatever number ships must match
SES on submission:
- **To Pouya:** the full submission, plainly formatted, replyable to the inquirer.
- **To the inquirer:** confirmation of receipt, expected response time, a repeat
of the no-retainer language, and a link to the privacy policy. This email is
the reason the form beats a `mailto:` link.
- **To the inquirer:** confirmation of receipt, the response-time commitment, a
repeat of the no-retainer language, and a link to the privacy policy. This
email is the reason the form beats a `mailto:` link.
**Email authentication — verified 2026-08-26, and it is not in place.**
**The response time is a public commitment — two business days** (§4,
Q27). Render it from `SITE.responseTime` / `SITE.responseTimeShort` in
`src/data/site.ts`; never retype it. It must read identically here, on
`/contact/`, and in any bio.
A DNS query of `smlcompany.ca` found **no SPF record and no DMARC record**. Mail
is on Google Workspace (MX `1 smtp.google.com`) with Google DKIM configured, and
the SES domain identity reports verified for sending — but neither SPF nor DMARC
exists.
**SES production access is granted** (Q19, 2026-08-26) — mail reaches unverified
recipients, so the inquirer confirmation works. See §7 for the account state.
**What is already in place** (Namecheap DNS and the SES console, both inspected
2026-08-26):
### Bounce and complaint monitoring
| Record | Status |
|---|---|
| SES DKIM — `3zsnvsjg…`, `jejgp7na3…`, `xpiwyftpo…` `._domainkey` | **Live.** Matches SES exactly. Never delete |
| SES DKIM — `f5puwearz…`, `jdue2r22c…`, `kznn3cklv…` `._domainkey` | Orphans from an earlier verification. Inert. **Leave them** — deleting the wrong three breaks DKIM |
| `google._domainkey` TXT | Google Workspace DKIM. Never delete |
| Two CNAMEs → `jkddzztszm.acm-validations.aws` | ACM certificate validation. **Never delete** — breaks HTTPS at the next renewal |
| `adr` CNAME → `d26v23dhgsp2ta.cloudfront.net` | The site |
| Custom MAIL FROM | **Not configured.** Optional; would add SPF alignment |
Configured 2026-08-26; the resource names, thresholds and current state are in
`AGENTS.md` §7. What matters here is why it is a real control rather than a
formality:
Add both of these; neither conflicts with anything above:
**At this volume a single bad address is a threshold event.** SES suspends
sending above roughly a 5% bounce rate. Under 100 messages a month, five bounces
crosses it — and an intake form is exactly where mistyped addresses arrive. The
alarms sit well below that line so there is room to react.
| Host | Type | Value |
|---|---|---|
| `@` | TXT | `v=spf1 include:_spf.google.com include:amazonses.com ~all` |
| `_dmarc` | TXT | `v=DMARC1; p=none; rua=mailto:info@smlcompany.ca; fo=1` |
**Bounces and complaints are handled by SES email feedback forwarding**, which is
on by default, **not** by an SNS feedback topic. That is deliberate: at this
volume there is nothing to consume a programmatic feed, and an unused SNS topic
is one more thing to keep correct. Revisit when code needs to *act* on a
bounce — suppression lists, retry logic, marking a record undeliverable.
A domain may publish **only one** `v=spf1` record, so both senders go in one
> **The alarms currently notify nobody.** §7 records the `ses-alerts` email
> subscription as **pending confirmation**. An unconfirmed SNS subscription
> drops every message, so until the confirmation link is clicked the alarms
> fire into nothing. This is the first thing to check if `/contact/` ships.
**Email authentication — in place as of 2026-08-26 (Q20).**
SPF and DMARC are both live and independently verified (Q20); mail is on Google
Workspace with Google DKIM configured, and the SES domain identity is verified
for sending. **The record values, the MX, and the region are in `AGENTS.md` §7 —
not restated here.** An earlier version of this spec asserted that neither SPF
nor DMARC existed; that was true when written and is no longer, which is the
whole argument for citing §7 rather than copying it.
**What is already in place.** `AGENTS.md` §7 is the record — resource IDs, DNS
records, DKIM token sets, and their verification state all live there and are not
restated here. Read §7 before touching DNS.
Two points from §7 that this spec depends on, cited rather than copied:
- **Only one of the two SES DKIM token sets resolves.** §7 names both sets and
marks which is which. The resolving set is what DMARC alignment rests on;
deleting it breaks intake mail authentication silently. The other set is
NXDOMAIN and inert. **Do not act on any DKIM list that is not §7's.**
- **SES has no custom MAIL FROM**, so SPF is unaligned and SES satisfies DMARC
through DKIM alone.
Notes that mattered when these were added, kept because they matter again on
any future edit: a domain may publish **only one** `v=spf1` record, so both senders go in one
string. Namecheap TXT values take **no surrounding quotes** — quoting them stores
the quotes literally and breaks the record.
@@ -140,7 +171,8 @@ the quotes literally and breaks the record.
SES here. Without a custom MAIL FROM domain, SES uses an envelope sender at
`amazonses.com`, so its SPF pass is not *aligned* with `smlcompany.ca` and does
not satisfy DMARC. **SES satisfies DMARC through DKIM alignment** — that is what
the six CNAMEs above are doing, and it already works. The SPF record's real job
the **three resolving** DKIM CNAMEs above are doing, and it already works. (Six
are present in the zone; only the `f5pu` / `jdue` / `kznn` set answers.) The SPF record's real job
is authenticating **Google Workspace** mail, which currently has no SPF at all.
`include:amazonses.com` is harmless and becomes useful if a custom MAIL FROM
domain is configured later.
@@ -152,7 +184,7 @@ no visibility.
**Do not delete the ACM validation CNAMEs.** They are how the certificate for
`adr.smlcompany.ca` auto-renews. Removing them breaks HTTPS at the next renewal
— silently, months later (Q20).
— silently, months later.
Failure handling: SES failure must never lose the submission. Write to DynamoDB
first, then send. A dead-letter queue on the Lambda, and a CloudWatch alarm on
@@ -174,6 +206,18 @@ An embedded scheduler for the 3045 minute confidential intake call
## Security headers
> **Note added 2026-08-26 — `style-src` has acquired a dependency.** The site
> now ships inline `style="…"` attributes that are load-bearing rather than
> decorative: `InfinityMark.astro` sets its own `block-size` that way, and the
> step-1 proof sheet rendered computed swatches with it (that page was deleted
> at build step 2; the mechanism is what matters here). They are fine under
> `style-src 'self' 'unsafe-inline'` as specified below. They would **not**
> survive a move to hashed or nonce'd styles — the infinity mark would collapse.
> Price that before tightening `style-src`, and read the components first.
> `script-src` is unaffected, and has got easier: the site ships **zero**
> JavaScript, so `script-src 'self'` needs no hash and no nonce (`AGENTS.md`
> §7). That is why the reveal moved from an inline observer to CSS.
Set at CloudFront via a response-headers policy:
```
@@ -204,7 +248,8 @@ relationship · cookie and analytics disclosure · last-updated date.
If analytics ship, prefer a cookieless privacy-preserving tool (Plausible,
Fathom). GA4 on a page collecting legal-dispute information is a poor fit for a
practice whose privacy posture is part of its offer (**Q11**).
practice whose privacy posture is part of its offer — D15 settles this:
Plausible or Fathom, cookieless, no consent banner.
## Definition of done
+196 -81
View File
@@ -1,19 +1,22 @@
# 06 — Deployment and cutover
Authority: `AGENTS.md` §3 D3 (git + GitHub Actions → existing S3/CloudFront) and
D11 (build everything, one clean cutover).
Existing infrastructure: `AWS-Hosting-Guide.md`.
Authority: `AGENTS.md` §3 **D3 as amended 2026-08-26** (git + **Gitea Actions**
→ existing S3/CloudFront) and D11 (build everything, one clean cutover).
Existing infrastructure: **`AGENTS.md` §7 is authoritative.**
`docs/reference/AWS-Hosting-Guide.md` records how that infrastructure was
originally built — it is a historical record carrying a do-not-execute banner,
not a procedure, and §7 wins wherever the two disagree (Q24).
---
## Topology
```
GitHub push to main
└─ GitHub Actions
Gitea push to main
└─ Gitea Actions (act_runner)
├─ npm ci && npm run build → ./dist
├─ assume AWS role via OIDC (no stored keys)
├─ aws s3 sync ./dist s3://<bucket>
├─ static scoped IAM user key (from Gitea secrets — NOT OIDC)
├─ aws s3 sync ./dist s3://<bucket> (three passes, see Cache policy)
└─ cloudfront create-invalidation
Namecheap DNS → CloudFront → S3 (OAC)
API Gateway → Lambda → DynamoDB / SES (intake, unchanged path)
@@ -23,15 +26,38 @@ DNS is at **Namecheap, not Route 53** `[verified 2026-08-25]`. Nothing in the
pipeline touches DNS. Certificate renewal is ACM-automatic as long as the
validation CNAME stays in place at Namecheap — **do not delete it.**
## Today, deploys run locally
**`npm run deploy`** (`scripts/deploy-local.sh`) is the current path. It runs
the same guard, the same three sync passes in the same order with the same
cache headers, and the same invalidation as the workflow — at this scale the
pipeline changes only **how a deploy is triggered**, not what it does. Treat the
script and the workflow as one artefact in two places: change one, change both.
Two things block the workflow, and neither is a fact to look up:
- **`adr-sml-deploy` does not exist** — `aws iam get-user` returns
`NoSuchEntity` (§7, Q22). Create it from *Create the user* below.
- **Actions are not enabled and no runner is registered** (Q23). The Gitea
instance is jointly administered, so both need its second administrator.
The script **refuses to run as `user/pouya`** — the broadly-permissioned
personal user that has been authenticating to this account. See §10.
## CI runs on Gitea, not GitHub
`AGENTS.md` D3 as amended, 2026-08-26: self-hosted **Gitea**, repo `adr-sml`,
local clone at `/Users/pouya/Dev/Websites/adr-sml`.
`AGENTS.md` D3 as amended, 2026-08-26: self-hosted **Gitea**. The instance,
version, and repository are recorded in §7 — the version is comfortably above
the floor for the `vars` context, so the first-step guard is belt-and-braces
rather than load-bearing.
**The live pipeline is `.gitea/workflows/deploy.yml`.** Gitea Actions speaks
GitHub Actions syntax, so it is a near-direct port — the build steps, the
two-pass sync, and the cache headers are unchanged. `.github/workflows/deploy.yml`
stays in the repo as the OIDC reference in case the project ever moves.
three-pass sync, and the cache headers are unchanged. The GitHub Actions original,
with its OIDC role assumption, stays in the repo as
`docs/reference/github-actions-oidc.yml.example` — deliberately outside
`.github/workflows/`, because Gitea falls back to that directory when
`.gitea/workflows` is absent.
### The one real difference: no OIDC
@@ -39,8 +65,9 @@ Gitea is not an AWS OIDC provider. There is no role to assume, so deploys
authenticate with a **scoped IAM user** whose access key lives only in the
repository's Gitea secrets.
This is a genuine step down in security from the GitHub setup, and it should be
treated as one. The mitigations are the policy scope and the rotation schedule.
This is a genuine step down in security from an OIDC setup — which was designed
here but never built — and it should be treated as one. The mitigations are the
policy scope and the rotation schedule.
**Create the user:**
@@ -62,7 +89,7 @@ treated as one. The mitigations are the policy scope and the rotation schedule.
{
"Sid": "WriteSiteObjects",
"Effect": "Allow",
"Action": ["s3:PutObject", "s3:PutObjectAcl", "s3:DeleteObject"],
"Action": ["s3:PutObject", "s3:DeleteObject"],
"Resource": "arn:aws:s3:::BUCKET_NAME/*"
},
{
@@ -75,12 +102,34 @@ treated as one. The mitigations are the policy scope and the rotation schedule.
}
```
No `s3:*`. No `cloudfront:*`. No wildcard resources. If a deploy step needs a
permission this policy lacks, the correct response is to question the step, not
to widen the policy.
Four actions on one bucket and one distribution. No `Action: "*"`, no
`Resource: "*"` — the only wildcard is `BUCKET_NAME/*`, which scopes to the
objects of that one bucket. `s3:PutObjectAcl` was dropped on 2026-08-26:
`aws s3 sync` does not use it without `--acl`, and it is inert under Origin
Access Control with ACLs disabled. If a deploy step needs a permission this
policy lacks, the correct response is to question the step, not to widen the
policy.
3. Create an access key. **Copy it once** — AWS will not show the secret again.
**`s3:AbortMultipartUpload` is deliberately absent, and here is the actual
reason.** `aws s3 sync` switches to multipart above its 8 MB
`multipart_threshold`; an interrupted multipart upload then cannot clean up its
own parts, and orphaned parts accrue storage charges that do not appear in the
bucket listing. What makes that safe today is simply that **nothing here comes
close to 8 MB** — the largest file the pipeline
uploads is well under it. The biggest source asset is
`src/assets/pouya-lajevardi.jpg` at 357,627 bytes `[verified 2026-08-26 — stat]`,
Astro emits it smaller still after AVIF/WebP conversion, and the self-hosted font
files are smaller again. **Re-measure `./dist` after the first successful build**
— that, not the repository, is what gets synced. No lifecycle rule exists; do not describe one
as the mitigation, because it is not there.
**Revisit if any single asset approaches 8 MB** — a video, a large PDF, an
un-optimised photograph. At that point either add an S3 lifecycle rule aborting
incomplete multipart uploads after 7 days (preferred — it costs no IAM
permission), or grant `s3:AbortMultipartUpload` on `BUCKET_NAME/*`.
### Gitea configuration
**Repository → Settings → Actions → Secrets:**
@@ -90,37 +139,32 @@ to widen the policy.
| `AWS_ACCESS_KEY_ID` | from the IAM user |
| `AWS_SECRET_ACCESS_KEY` | from the IAM user |
**The real values** (captured 2026-08-26, `aws-inventory.txt`):
**Repository → Settings → Actions → Variables** — not secrets. These are not
sensitive, and keeping them as variables means they appear in run logs where
they are useful for debugging.
| Variable | Value |
|---|---|
| `AWS_REGION` | `ca-central-1` |
| `S3_BUCKET` | `adr-smlcompany-site` |
| `CLOUDFRONT_DISTRIBUTION_ID` | `E1OK7G98KNKUTA` |
| `INTAKE_ENDPOINT` | `https://4tl0m5igkj.execute-api.ca-central-1.amazonaws.com` |
| `AWS_REGION` | `AGENTS.md` §7 — Region |
| `S3_BUCKET` | §7 — S3 bucket |
| `CLOUDFRONT_DISTRIBUTION_ID` | §7 — CloudFront |
| `INTAKE_ENDPOINT` | §7 — Intake API |
| `BOOKING_URL` | *(empty — parked, R6)* |
IAM policy substitutions: `BUCKET_NAME` = `adr-smlcompany-site`,
`ACCOUNT_ID` = `327082975128`, `DISTRIBUTION_ID` = `E1OK7G98KNKUTA`.
The same four values fill the IAM policy's `BUCKET_NAME`, `ACCOUNT_ID` and
`DISTRIBUTION_ID` placeholders. **They are deliberately not restated here**
§7 is the single source of truth for operational facts, and the copy that goes
stale is always the one nobody re-reads. `scripts/aws-discover.sh` regenerates
them from AWS if §7 ever needs re-verifying.
> **Read this before creating the key.** Account `327082975128` is shared across
> `meshkinilaw.ca`, `demesne.media`, `orynenergy.ca`, `lajirugs.ca`, and
> `mlp-clientdb-prod-backups` — a law firm's client-database backups. A static
> deploy key for a marketing site lives in the same account. The scoped policy is
> what keeps a compromised Gitea runner from reaching any of that. Do not widen
> it, and never put the `user/pouya` credentials in CI.
**Repository → Settings → Actions → Variables** (not secrets — these are not
sensitive, and keeping them as variables means they appear in run logs where they
are useful for debugging):
| Name | Value |
|---|---|
| `AWS_REGION` | e.g. `ca-central-1` |
| `S3_BUCKET` | the site bucket |
| `CLOUDFRONT_DISTRIBUTION_ID` | the `E...` ID |
| `INTAKE_ENDPOINT` | API Gateway invoke URL |
| `BOOKING_URL` | once chosen (Q5) |
> **Read this before creating the key.** The AWS account is **not** a
> single-project account: it is shared with several unrelated sites and with a
> bucket whose name indicates another business's production client-database
> backups. `AGENTS.md` §10 has the specifics and the account identifier; they
> are kept there rather than repeated here. A static deploy key for a marketing
> site lives in that same account, and the scoped policy is what keeps a
> compromised Gitea runner from reaching any of it. Do not widen it, and never
> put the `user/pouya` credentials in CI.
### A runner must exist
@@ -130,8 +174,25 @@ organisation, and Actions enabled both site-wide in `app.ini`
queues silently and never runs — which looks exactly like a broken pipeline.
The workflow installs the AWS CLI if the runner image lacks it, and runs
`aws sts get-caller-identity` before touching anything, so a credential problem
fails loudly and early rather than halfway through a sync.
`aws sts get-caller-identity` before touching anything. **That check is
narrower than it looks:** `sts:GetCallerIdentity` requires no IAM permission at
all, so it succeeds for any valid key regardless of policy. It catches a
missing, malformed, or revoked key; it does **not** catch an under-scoped
policy, which still fails halfway through a sync and leaves the bucket
partially updated. Read it as a key check, not a permissions check.
### The variable guard runs first
The workflow's first step — before checkout, before the build, before any AWS
call — fails the run if `AWS_REGION`, `S3_BUCKET`, or
`CLOUDFRONT_DISTRIBUTION_ID` is empty.
This exists because Gitea only added the `vars` context in 1.21. On an older
instance every `${{ vars.* }}` interpolates to an empty string with no warning,
the sync target becomes `s3://`, and the run dies halfway through with an error
that names nothing useful. The guard converts that into a clean failure that
says which variable is missing — **on every Gitea version**. A recorded version
number would have gone stale; the guard does not.
### Key rotation — an operational obligation
@@ -149,9 +210,9 @@ whole section exists to bound.
## Finding the AWS identifiers
`scripts/aws-discover.sh` collects everything Q10 needs — bucket, distribution
`scripts/aws-discover.sh` re-collects the inventory — bucket, distribution
ID, regions, API endpoint, certificate, SES identities, and whether S3 versioning
is on. Read-only; every call is a list or describe.
is on. Read-only; no call creates or mutates anything.
```bash
chmod +x scripts/aws-discover.sh
@@ -160,25 +221,28 @@ chmod +x scripts/aws-discover.sh
The output contains resource names and IDs but no secrets.
## Why OIDC and not access keys
## Why OIDC would have been better — and why it is unavailable
The alternative is a long-lived `AWS_ACCESS_KEY_ID` in GitHub secrets: a
credential that never expires, is invisible once set, and grants its permissions
to anyone who can reach the repository. OIDC issues a short-lived token per run,
scoped to this repository and this branch.
> **Do not execute this section.** It describes the design that was rejected
> because Gitea cannot support it. The live procedure is *Create the user* above.
> Nothing here should be created in AWS. Following it would add an unused GitHub
> federation trust to the shared AWS account (`AGENTS.md` §10).
One-time setup:
A static `AWS_ACCESS_KEY_ID` never expires, is invisible once set, and grants its
permissions to anyone who can reach the repository. OIDC issues a short-lived
token per run, scoped to one repository and one branch — strictly better, and the
reason the rotation schedule above is not optional here.
1. IAM → Identity providers → add OIDC provider `token.actions.githubusercontent.com`,
audience `sts.amazonaws.com`.
2. Create role `adr-site-deploy` trusting that provider, with a condition on
`token.actions.githubusercontent.com:sub` equal to
`repo:<org>/<repo>:ref:refs/heads/main` (**Q9**).
3. Attach a policy granting **only**: `s3:PutObject`, `s3:DeleteObject`,
`s3:ListBucket` on the site bucket, and `cloudfront:CreateInvalidation` on the
one distribution. Nothing else. No `s3:*`, no `cloudfront:*`.
4. Store the role ARN, bucket name, and distribution ID as repository
**variables** (they are not secrets), and reference them in the workflow.
It needs an identity provider AWS will federate with. GitHub and GitLab both
publish one; **Gitea and Forgejo do not**, so there is nothing for AWS to trust
and no role to assume. That is the whole of the constraint (D3 as amended).
If the project ever moves to GitHub, the workflow to adopt is
`docs/reference/github-actions-oidc.yml.example`, and the setup is: register
`token.actions.githubusercontent.com` as an IAM OIDC provider with audience
`sts.amazonaws.com`; create a role trusting it, conditioned on the `sub` claim
matching the repository and `refs/heads/main`; attach the same four-action policy
given above; then delete `adr-sml-deploy` and its key.
## Cache policy
@@ -191,19 +255,33 @@ that does not update.
| `/_astro/*` (hashed) | `public, max-age=31536000, immutable` |
| Fonts | `public, max-age=31536000, immutable` |
| Images | `public, max-age=604800` |
| `robots.txt`, `sitemap*.xml` | `public, max-age=3600` |
| `robots.txt`, `sitemap*.xml` | `public, max-age=0, must-revalidate` |
Sync in two passes: hashed assets first with the long TTL, then HTML with the
short one. Uploading HTML last means a user never fetches a new page whose assets
have not landed yet.
Sync in **three** passes, in this order: hashed assets and fonts with the long
TTL, then images, then everything else. Uploading HTML last means a user never
fetches a new page whose assets have not landed yet.
Two ordering dependencies are load-bearing and easy to break:
- Pass 3 re-walks the whole tree; the image headers from pass 2 survive only
because `aws s3 sync` skips objects it has just uploaded. Reordering the
passes silently overwrites them with the HTML header.
- Pass 3's `--exclude "_astro/*" --exclude "fonts/*"` also excludes those
prefixes from `--delete`, so hashed assets from previous deploys are kept
deliberately — pages still in a browser cache need them. Do not "fix" it.
`robots.txt` and `sitemap*.xml` fall through to pass 3 and get the HTML header.
That is the intended behaviour: both should be re-fetched, and the table above
records what the pipeline actually does rather than an unimplemented ideal.
Invalidate `/*` on deploy. At this traffic volume the cost is nil, and partial
invalidation paths are a reliable source of confusing bugs.
## CloudFront configuration
- Origin: S3 with **Origin Access Control**, bucket not public. The guide's
Part 2.2 bucket policy already does this — verify it was not loosened.
- Origin: S3 with **Origin Access Control**, bucket not public. Verify the
bucket policy grants access only to the CloudFront distribution's OAC
principal and to nothing else, and that public access is still blocked.
- Redirect HTTP → HTTPS. TLS 1.2 minimum.
- Default root object `index.html`.
- **Custom error response:** 404 → `/404.html` with **response code 404**, not
@@ -215,11 +293,25 @@ invalidation paths are a reliable source of confusing bugs.
## Branch model
`main` is production; every push deploys. Work on short-lived branches, open a
PR, let CI build and run Lighthouse, merge.
`main` is production; a push to `main` is what triggers a deploy. Work on
short-lived branches, open a PR, merge.
**Pull request checks (blocking):** `npm run build` · `astro check` · lint ·
Lighthouse CI against the budgets in `04-seo-spec.md` · link check.
**The CI pipeline has never run.** Not for want of a lockfile — `npm ci`,
`astro check` and `astro build` all work now — but because the deploy user does
not exist (Q22) and Actions are not enabled with a runner registered (Q23).
Treat "every push deploys" as the design; today the path is `npm run deploy`.
**Pull request checks — planned, not implemented:** `npm run build` ·
`astro check` · lint · Lighthouse CI against the budgets in `04-seo-spec.md` ·
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.
Tag every production deploy `v<year>.<n>` so a rollback has something to name.
@@ -227,8 +319,9 @@ Tag every production deploy `v<year>.<n>` so a rollback has something to name.
1. Re-run the workflow at the last good tag, or
2. `git revert` and push, or
3. Restore from S3 object versioning — **enable versioning on the bucket if it is
off**; it is the difference between a rollback and a rebuild.
3. Restore from S3 object versioning — **already Enabled** on the site bucket
(`AGENTS.md` §7). It is the
difference between a rollback and a rebuild; do not turn it off.
Then invalidate `/*`.
@@ -236,25 +329,47 @@ Then invalidate `/*`.
**Content and compliance**
- [ ] Every claim traced to `AGENTS.md` §4 Verified
- [ ] **Memberships re-confirmed with Pouya, then published**`AGENTS.md` §12
**R10** and **Q44**. ADRIC, ADRIO, the three OBA sections and the Canadian
Tax Foundation are `[verified 2026-08-26]`. **§4 records yearly renewal for
the OBA sections and the CTF only** — it says nothing about ADRIC's or
ADRIO's period, and an earlier version of this line asserted "all renew
yearly", which §4 does not support. **`/about/` currently publishes NO
memberships group**: R10 is a prohibition and the re-confirmation was not
obtained, so the group is withheld behind a `TODO(pouya)`. OCNI already
lapsed quietly and §4 records it as "not current, do not publish" — that is
the failure mode, and a stamp is not a renewal receipt. Re-confirm,
re-stamp §4 and `CREDENTIALS.memberships`, restore the group to
`CREDENTIAL_GROUPS`, and add `memberOf` to the Person JSON-LD
- [ ] No `TODO(pouya)` remains in any shipped page
- [ ] No matter counts, rates, dollar figures, or testimonials anywhere
- [ ] Q.Arb described as in progress everywhere it appears
- [ ] `/fees/` carries real numbers (Q4) or the page does not ship
- [ ] Q.Arb described as **commenced August 2026** everywhere it appears — §4's
wording, not the looser "in progress"
- [ ] `/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
**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
- [ ] 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
- [ ] 404 returns a 404 status
- [ ] Security headers present (`securityheaders.com` A or better)
- [ ] **SES identities verified for sending** (Q18) — `aws sesv2 get-email-identity --email-identity smlcompany.ca` and confirm `VerifiedForSendingStatus: true`
- [ ] **SES out of the sandbox** (Q19) — `aws sesv2 get-account --query 'ProductionAccessEnabled'`. In sandbox, mail reaches only pre-verified addresses and the inquirer's confirmation silently fails
- [ ] Intake form tested end to end: DynamoDB record written to `adr-intake-submissions`, both emails delivered to a real inbox, TTL set
- [ ] Booking link works, including the no-JavaScript fallback
- [ ] **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
- [ ] Favicon set complete
- [ ] Tested on iOS Safari, Android Chrome, desktop Safari/Chrome/Firefox
- [ ] Tested at 320 px and at 200% zoom
@@ -264,7 +379,7 @@ Then invalidate `/*`.
- [ ] Bucket not publicly readable; OAC in force
- [ ] ACM certificate valid; Namecheap validation CNAME still present
- [ ] CloudWatch alarms: Lambda errors, DLQ depth, 5xx rate
- [ ] Billing alarm still active (guide Part 0.3)
- [ ] Billing budget/alarm still active `aws budgets describe-budgets --account-id "$(aws sts get-caller-identity --query Account --output text)"`. `docs/reference/AWS-Hosting-Guide.md` set up an **AWS Budget**, which `cloudwatch describe-alarms` will never return. Whether one was actually created is not recorded anywhere: confirm, do not assume
**Post-cutover, same day**
- [ ] Sitemap submitted to Google Search Console and Bing Webmaster Tools
+29 -9
View File
@@ -1,11 +1,12 @@
# 07 — Fee research and recommended rate card
Authority: `AGENTS.md` §3 D8 (publish a full rate card) and D14 (two-tier
structure, **pending Pouya's sign-off — Q14**).
Authority: `AGENTS.md` §3 D8 (publish a full rate card) and **D14 — a single
published rate card, confirmed by Pouya 2026-08-26 (Q4/Q14/Q15-Q17 answered).**
**Nothing in this document publishes until Pouya confirms the figures.** These
are researched recommendations, not decisions. This is business pricing
information, not legal or financial advice.
**The card below is confirmed and buildable.** The research that produced it is
retained for context, but the figures are decisions now, not recommendations —
see "Set by Pouya" below. This is business pricing information, not legal or
financial advice.
Research date: 2026-08-26. All figures below are **plus HST** unless stated.
@@ -103,8 +104,14 @@ All figures **plus HST**.
### Arbitration
Available now as co-arbitrator; sole appointments follow the Q.Arb designation,
commenced August 2026. The page must say so — see `03-content-spec.md`.
Sole, party-appointed and co-arbitration appointments are all accepted now —
`AGENTS.md` §4 Offerings carries a row for each `[verified 2026-08-26 — Pouya]`.
*(This line previously read "sole appointments follow the Q.Arb designation",
which understated the offering, and carried a caveat against a since-closed
Q36.)* Whatever `/fees/` says about arbitration must state the Q.Arb stage
plainly alongside it — §4 Offerings, "neither half may be dropped": the Q.Arb
designation commenced August 2026, with C.Med-Arb as the endpoint. See
`03-content-spec.md` for the wording.
| Item | Fee |
|---|---|
@@ -118,8 +125,21 @@ offer tribunal-secretary work on the site.
### Other services — hourly
Early neutral evaluation, settlement counsel, dispute-system design, and
pre-dispute technical advisory: **$500 / hour**.
Early neutral evaluation, dispute-system design, and pre-dispute technical
advisory: **$500 / hour**.
**THREE services, not four. `settlement counsel` is struck and must not be
priced** — `AGENTS.md` Q42, Pouya 2026-08-27, correcting his own entry in
`docs/01`:
> "Settlement counsel acts **FOR a party** in negotiation. That is a partisan
> role, and putting it on a site that (a) sells neutrality and (b) asserts no
> licensure under D13 is **wrong twice over**: it undercuts the brand's central
> claim and it edges into acting for a client."
A struck row exists in §4 Offerings so the decision is findable. Same treatment
as the tribunal-secretary rate above, and for a related reason: a rate on a fee
page is an offer.
### Cancellation — adopted as recommended
+11 -1
View File
@@ -47,9 +47,19 @@ approve elegant code containing a claim that should never have been published,
because professional-conduct compliance is not what it is looking at. On this
project that is the highest-stakes failure mode, so it gets its own pass.
**Verifying they are loaded.** `.claude/agents/` is the correct location. To
confirm the agents are live, invoke one directly:
```
Use the claims-auditor agent to audit README.md against AGENTS.md §4.
```
A verdict table back means both are wired. "No such agent" means the frontmatter
needs looking at.
**Both are instructed to treat uncertainty as a defect.** They will sometimes be
wrong. That is the intended trade: explaining why a finding is mistaken costs
minutes, and a missed defect on a licensed professional's public marketing page
minutes, and a missed defect on this project's public marketing pages
costs a great deal more.
## The rule that makes it work
+749
View File
@@ -0,0 +1,749 @@
# Hosting `adr.smlcompany.ca` on AWS — A Step-by-Step Guide
> ---
> ## REFERENCE ONLY — DO NOT EXECUTE
>
> **This is a historical record of how the existing AWS infrastructure was
> built. It is not a procedure to follow.** The live deployment procedure is
> [`docs/06-deployment.md`](../06-deployment.md); the authoritative inventory of
> what actually exists is `AGENTS.md` §7.
>
> Following this document would, among other things: create an IAM user with
> `AdministratorAccess` in account `327082975128` — which `AGENTS.md` §10 rates
> **High** blast-radius; rebuild the site through the standalone-HTML pipeline
> that D1 and D3 replace; and wire SES to addresses this project does not use.
>
> **Known contradictions with Current Truth**, all of which §7 and the specs win:
>
> | This guide says | Current Truth |
> |---|---|
> | Intake mail to `adr@` / `intake@smlcompany.ca` | **`info@smlcompany.ca`** — §4, D18 |
> | Lambda runtime Node.js 20.x | **`nodejs24.x`** — §7 |
> | "the SES sandbox is perfectly fine and free" | Sandbox is a **confirmed blocker**, Q19 |
> | `rebuild-standalone.py` / `sections.jsx` / a ~2.2 MB self-contained `index.html` | Astro static build — D1 |
> | SES policy with `"Resource": "*"` | Scope it; see §10 on this account |
> | "You (a lawyer, not a sysadmin)" — the original audience line, **corrected in place** | §4 records licence status as **NOT ESTABLISHED**; the word is barred outright |
> | The consent line "does not create a lawyer-client relationship" | Superseded by `NO_RETAINER_NOTICE` in `src/data/site.ts`, written to avoid exactly that phrasing |
>
> Retained because it is the only record of how the bucket, distribution,
> certificate, DNS, Lambda, DynamoDB table, and SES identities came to exist.
> Read it for that. Do not run it.
> ---
**Audience:** the site owner — comfortable clicking around, new to AWS.
**Goal:** Get the revamped site live at `https://adr.smlcompany.ca` with a working intake form whose submissions are stored in a database **and** emailed to `adr@smlcompany.ca`.
**Architecture you're building:**
```
┌───────────────────────┐
Browser ─────► │ CloudFront (CDN) │ ◄── ACM (free TLS cert)
adr.smlcompany.ca │ HTTPS + cache │
└──────────┬────────────┘
┌───────────────────────┐
│ S3 bucket (origin) │ ← your standalone HTML + /assets
│ adr-smlcompany-site │
└───────────────────────┘
Form submit ─► API Gateway ─► Lambda ─┬─► DynamoDB (permanent record)
└─► SES (emails adr@smlcompany.ca)
DNS stays at Namecheap (you add a CNAME for `adr` + cert/DKIM validation records)
```
**Total time:** about 23 hours the first time, in chunks. Most steps take a minute or two of clicking but DNS propagation and CloudFront deploys mean there's some waiting.
**Total monthly cost at low traffic:** under $1 USD. S3, CloudFront, Lambda, DynamoDB, and SES will all stay in or near their free tiers.
---
## ⚠️ A note about DNS choice
You've chosen to keep DNS at Namecheap rather than move to Route 53. That's perfectly fine and is actually cheaper (no $0.50/month hosted zone) and lower-risk (your existing MX records and email keep working untouched). The trade-offs:
- **CNAMEs can't sit at the apex.** Your apex `smlcompany.ca` will not be servable on CloudFront from Namecheap DNS — only subdomains like `adr.smlcompany.ca`. This is a DNS standard, not a Namecheap limitation. Since you're using a subdomain, you're fine. If you ever want the apex on CloudFront, you'd either move DNS to Route 53 (alias records can be at apex) or use Namecheap's "URL Redirect Record" feature to redirect the apex to the subdomain.
- **You'll add records by hand.** Each time AWS asks you to publish a DNS record (for cert validation, for SES DKIM, etc.), you'll copy/paste it into Namecheap → Advanced DNS yourself, instead of AWS writing it for you.
- **No automatic DNS updates.** Not really a downside at this scale — just something to know.
---
# Part 0 — Prerequisites (15 min)
You said you already have an AWS account. Quick hardening pass:
### 0.1 Sign in as a non-root IAM user
- AWS strongly recommends you don't use the root account day-to-day. If you've been using root: in the console, open **IAM → Users → Create user**. Name it `pouya-admin`. Attach the AWS-managed policy `AdministratorAccess`. Enable **console access** with a custom password.
- Sign out and sign back in as `pouya-admin` going forward. Reserve the root login for billing changes only.
### 0.2 Turn on MFA for the root account
- IAM → Security credentials (under your root user) → **Assign MFA device** → use Authy / Google Authenticator / 1Password.
### 0.3 Set a billing alarm
- Console → **Billing and Cost Management → Budgets → Create budget**.
- Template: **Monthly cost budget**, $20 USD, notify at 80% and 100% to `pouya@meshkinilaw.ca`.
- This catches misconfiguration before it gets expensive.
### 0.4 Set your region
- Top-right of the AWS console: switch the region selector to **Canada (Central) — ca-central-1**.
- Everything in this guide is in `ca-central-1` **except** ACM (which for CloudFront *must* live in `us-east-1` — explained in Part 3) and CloudFront itself (which is global).
### 0.5 (Optional but useful) Install the AWS CLI
- macOS: `brew install awscli` then `aws configure` and paste an access key generated from IAM → your user → Security credentials.
- You don't strictly need it — every step below has a console path — but a few things (S3 sync, CloudFront invalidations) are much faster from the terminal.
---
# Part 1 — Put the website in an S3 bucket (15 min)
S3 is just object storage. We'll create one bucket, drop your standalone HTML and the `assets/` folder in it, and leave it private — CloudFront will be the only thing allowed to read from it.
### 1.1 Create the bucket
- Console → **S3 → Create bucket**.
- **Bucket name:** `adr-smlcompany-site` (must be globally unique across all of AWS — if it's taken, add a suffix like `-2026`).
- **Region:** Canada (Central) ca-central-1.
- **Block all public access:** leave the box **checked** (yes, fully blocked — CloudFront will use an Origin Access Control to read from it).
- **Bucket versioning:** Enable. This gives you a free undo if you ever overwrite the site with a broken version.
- Leave everything else default. **Create bucket**.
### 1.2 Upload your files
Your export contains a few HTML files. The one you want to serve is `SML ADR Site (Standalone).html` — that's the ~2.2 MB self-contained build with everything inlined.
- Open the bucket → **Upload**.
- **Add files** → select `SML ADR Site (Standalone).html`.
- **IMPORTANT:** before uploading, rename it locally to `index.html` (CloudFront's default root object). Or upload as-is and use the S3 console to rename it after upload (Actions → Rename).
- Also upload your `assets/` folder using **Add folder** so that `assets/sml-logo-full.png` and `assets/sml-logo-mark.png` end up at `s3://adr-smlcompany-site/assets/...`.
After upload, your bucket should contain:
```
index.html
assets/
sml-logo-full.png
sml-logo-mark.png
```
### 1.3 Set cache-control on the HTML (recommended)
Because we'll deploy by overwriting `index.html` later, you want browsers/CDN to re-check it often.
- Click `index.html`**Properties → Edit metadata**.
- Add metadata: **System defined → Cache-Control → `public, max-age=300, must-revalidate`** (5 minutes).
- For the images in `assets/`, leave defaults (they can cache for much longer; CloudFront will use defaults).
---
# Part 2 — Put CloudFront in front of S3 (20 min including wait)
CloudFront is AWS's CDN. It gives you HTTPS, global edge caching, and lets you put a real domain in front of an otherwise-private S3 bucket.
### 2.1 Create the distribution
- Console → **CloudFront → Create distribution**.
- **Origin domain:** click the dropdown and pick your bucket — `adr-smlcompany-site.s3.ca-central-1.amazonaws.com`. The console will offer a "Use website endpoint" suggestion — **ignore that**, leave the REST endpoint selected.
- **Origin access:** select **Origin access control settings (recommended)**.
- Click **Create new OAC**. Name: `adr-smlcompany-oac`. Signing behavior: **Sign requests**. Origin type: **S3**. Create.
- You'll see a yellow banner saying *"You must update the S3 bucket policy."* Note this — we'll do it in a moment.
- **Viewer protocol policy:** **Redirect HTTP to HTTPS**.
- **Allowed HTTP methods:** GET, HEAD (default).
- **Cache policy:** **CachingOptimized** (managed).
- **Origin request policy:** leave blank.
- **Response headers policy:** **SecurityHeadersPolicy** (managed) — adds HSTS, X-Frame-Options, etc.
- **Compress objects automatically:** Yes.
- **Price class:** **Use only North America and Europe** (cheaper; your clients aren't in Tokyo).
- **Web Application Firewall (WAF):** **Do not enable** for now. (Could add later if needed; ~$5/mo.)
- **Alternate domain names (CNAMEs):** leave blank for now — we'll add `adr.smlcompany.ca` in Part 6, after the cert exists.
- **Custom SSL certificate:** leave **Default CloudFront Certificate** for now.
- **Default root object:** `index.html`.
- **Standard logging:** Off (can enable later).
- **Create distribution**.
### 2.2 Update the S3 bucket policy
After creating the distribution, you'll see a banner *"Copy policy"* with a JSON snippet — that snippet allows your specific CloudFront distribution to read from S3.
- Click **Copy policy**.
- Open the S3 bucket → **Permissions → Bucket policy → Edit** → paste → **Save changes**.
The policy looks roughly like:
```json
{
"Version": "2008-10-17",
"Statement": [{
"Sid": "AllowCloudFrontServicePrincipal",
"Effect": "Allow",
"Principal": { "Service": "cloudfront.amazonaws.com" },
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::adr-smlcompany-site/*",
"Condition": {
"StringEquals": { "AWS:SourceArn": "arn:aws:cloudfront::<ACCOUNT-ID>:distribution/<DIST-ID>" }
}
}]
}
```
### 2.3 Wait for "Deployed"
- CloudFront → your distribution → wait until **Last modified** shows a timestamp and the status reads **Deployed** (515 min the first time).
- Then visit the **Distribution domain name** shown at the top — something like `d123abc4xyz.cloudfront.net`. Your site should load over HTTPS.
- If you see XML access-denied: the bucket policy isn't saved yet, or `index.html` isn't named exactly that.
**Checkpoint:** site loads on the `*.cloudfront.net` URL. We'll attach your real domain in Part 6.
---
# Part 3 — Get a free SSL certificate (10 min, validation later)
CloudFront requires its TLS certificate to live in **us-east-1**, regardless of where the rest of your stack lives. This trips up everyone the first time.
### 3.1 Request the cert
- Top-right region selector → switch to **US East (N. Virginia) — us-east-1**. (You'll switch back to ca-central-1 after this part.)
- Console → **Certificate Manager → Request certificate → Request a public certificate**.
- **Domain names:**
- `adr.smlcompany.ca`
- (Optional, recommended) Add a second name: `*.smlcompany.ca`. A wildcard means you'll be able to use the same cert for `www.smlcompany.ca`, `mail.smlcompany.ca`, etc. without re-requesting.
- **Validation method:** **DNS validation** (the recommended option — uses a CNAME record).
- **Key algorithm:** RSA 2048.
- **Request**.
You'll land on the cert page in **Pending validation** state. ACM will show you one or two CNAME records of the form `_abc123.adr.smlcompany.ca``_xyz789.acm-validations.aws.`. Leave this tab open — you'll publish these in Namecheap in Part 5.
---
# Part 4 — Open Namecheap's DNS panel (2 min)
We're not migrating DNS, but we will be coming back to this panel four times across the rest of the guide (ACM cert validation, the CloudFront CNAME, three SES DKIM records). So get familiar with where it is now.
### 4.1 Locate Advanced DNS
- Log in to Namecheap → **Domain List**.
- Find `smlcompany.ca` → click **Manage** on its row.
- Click the **Advanced DNS** tab. This is where you'll add every record below. (Do **not** touch the **Domain** tab's Nameservers section — leave it set to *Namecheap BasicDNS*.)
### 4.2 Make a "before" screenshot (1 min)
Take a screenshot of the current Host Records table. You won't need to touch any of the existing rows — they're handling your email and anything else you have set up. The screenshot is just an undo reference in case you ever paste over the wrong row.
### 4.3 How to add a record in Namecheap (reference for later steps)
Namecheap's row-add UX:
- Scroll to the **Host Records** section → click **ADD NEW RECORD**.
- Pick a **Type** from the dropdown (A, AAAA, CNAME, TXT, MX, etc.).
- **Host:** the subdomain part only. So for `adr.smlcompany.ca` the Host is `adr`. For the apex itself, use `@`. For something like `_abc123.adr.smlcompany.ca`, use `_abc123.adr`.
- **Value:** what AWS gives you. **Important:** Namecheap will sometimes append a trailing dot to CNAME values when it shows them back — that's normal. When *entering* a CNAME, you can include or omit the trailing dot; both work.
- **TTL:** Automatic (~30 min) is fine. For records you'll change often (testing), pick a low TTL like 5 min.
- Click the green checkmark on the right to save the row.
That's it — you'll do this five-ish times over Parts 5, 7, and 9.
---
# Part 5 — Validate the ACM cert via Namecheap (10 min including wait)
Back to the cert you requested in Part 3.
- Region selector → **us-east-1**.
- ACM → your pending cert → click into it.
- You'll see one (or two, if you added the wildcard) **CNAME validation records** of the form:
```
Name: _abc1234567890.adr.smlcompany.ca.
Value: _xyz9876543210.acm-validations.aws.
```
ACM gives you a **Copy** button next to each — handy.
- Switch tab to Namecheap → smlcompany.ca → **Advanced DNS** → **ADD NEW RECORD**:
- **Type:** CNAME Record
- **Host:** the bit *before* `.smlcompany.ca` in the Name field. For example, if ACM shows `_abc1234567890.adr.smlcompany.ca.`, the Host you enter in Namecheap is `_abc1234567890.adr`. (Drop the trailing `.smlcompany.ca` — Namecheap appends it automatically.)
- **Target:** the Value from ACM, e.g. `_xyz9876543210.acm-validations.aws.` (trailing dot is fine).
- **TTL:** Automatic.
- Click the green checkmark.
- If you added the wildcard `*.smlcompany.ca` in Part 3, you'll see a second validation row in ACM — add a second CNAME the same way. (Often ACM gives the same Name/Value for the apex and wildcard, in which case you only need one CNAME.)
- Back in ACM, refresh the cert page after 210 min. Status flips from **Pending validation** to **Issued**. If it's still pending after 15 minutes, you've almost certainly got a Host typo — re-check that what's in Namecheap matches what ACM shows, character for character.
---
# Part 6 — Attach the cert + domain to CloudFront (10 min including wait)
- Region selector → **us-east-1** (CloudFront is global but lives under us-east-1 in the console nav).
- CloudFront → your distribution → **General → Settings → Edit**.
- **Alternate domain name (CNAME):** add `adr.smlcompany.ca`. (Add `www.adr.smlcompany.ca` too if you want both — otherwise leave as just the one.)
- **Custom SSL certificate:** dropdown → select the cert you just issued.
- **Security policy:** TLSv1.2_2021.
- **Save changes**.
- Wait ~510 min for **Deployed** status again.
---
# Part 7 — Point DNS at CloudFront via Namecheap (3 min)
- Grab your CloudFront distribution domain from the CloudFront console (top of the distribution page) — it looks like `d123abc4xyz.cloudfront.net`.
- Namecheap → smlcompany.ca → **Advanced DNS** → **ADD NEW RECORD**:
- **Type:** CNAME Record
- **Host:** `adr`
- **Target:** your CloudFront domain, e.g. `d123abc4xyz.cloudfront.net.` (trailing dot optional)
- **TTL:** 5 min (for the initial setup — you can raise it to Automatic once everything's stable)
- Save with the green checkmark.
> **About IPv6:** a CNAME delegates resolution to the target's records, and CloudFront serves both A (IPv4) and AAAA (IPv6) records. So a single CNAME automatically covers both — you don't need a separate AAAA record like you would with a Route 53 alias.
> **About the apex:** Namecheap DNS can't put a CNAME at `@` (the apex `smlcompany.ca`). That's a hard DNS-standards limit, not Namecheap's fault. Since you're using `adr.smlcompany.ca`, this doesn't affect you. If you also wanted `smlcompany.ca` (without the `adr.`) to land on the site, the easiest route is Namecheap's **URL Redirect Record** type: Host `@`, Target `https://adr.smlcompany.ca`, Type `Unmasked (301)`.
Within a couple of minutes, `https://adr.smlcompany.ca` should serve your site.
✅ **Checkpoint:** open `https://adr.smlcompany.ca` in an incognito window. You should see the revamped site, with a green padlock, no warnings.
---
# Part 8 — Backend: DynamoDB table + Lambda + API Gateway (45 min)
Now the intake form. The flow:
```
Browser POST → API Gateway (HTTPS) → Lambda function → DynamoDB.put_item()
→ SES.send_email() to adr@smlcompany.ca
```
### 8.1 Create the DynamoDB table
- Region → **ca-central-1**.
- Console → **DynamoDB → Tables → Create table**.
- **Table name:** `adr-intake-submissions`.
- **Partition key:** `submissionId` (String).
- **Sort key:** leave blank.
- **Settings:** **Default settings** — this gives you on-demand capacity (you pay per request, ~$0 at your volume) and encryption at rest by default.
- **Create**.
- After it's `Active`: click the table → **Backups → Point-in-time recovery → Edit → Turn on**. Costs cents/month and lets you restore to any second in the last 35 days.
### 8.2 Create the Lambda execution role (IAM)
- IAM → **Roles → Create role**.
- Trusted entity: **AWS service** → use case **Lambda**.
- Permissions: attach these AWS managed policies for now:
- `AWSLambdaBasicExecutionRole` (lets it write CloudWatch logs)
- **Role name:** `adr-intake-lambda-role`. Create.
- After creation: open the role → **Add permissions → Create inline policy** → JSON tab → paste:
```json
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["dynamodb:PutItem"],
"Resource": "arn:aws:dynamodb:ca-central-1:*:table/adr-intake-submissions"
},
{
"Effect": "Allow",
"Action": ["ses:SendEmail", "ses:SendRawEmail"],
"Resource": "*"
}
]
}
```
Name it `adr-intake-lambda-inline`. Save.
### 8.3 Create the Lambda function
- Console → **Lambda → Create function**.
- **Author from scratch.**
- **Function name:** `adr-intake-handler`.
- **Runtime:** Node.js 20.x.
- **Architecture:** arm64 (cheaper).
- **Execution role:** *Use an existing role* → `adr-intake-lambda-role`.
- **Create function.**
In the Code tab, replace the contents of `index.mjs` with:
```javascript
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { DynamoDBDocumentClient, PutCommand } from "@aws-sdk/lib-dynamodb";
import { SESv2Client, SendEmailCommand } from "@aws-sdk/client-sesv2";
import { randomUUID } from "crypto";
const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({ region: "ca-central-1" }));
const ses = new SESv2Client({ region: "ca-central-1" });
const TABLE = "adr-intake-submissions";
const FROM_ADDR = "adr@smlcompany.ca"; // must be SES-verified (Part 9)
const NOTIFY_ADDR = "adr@smlcompany.ca"; // must be SES-verified while SES is in sandbox
const ALLOWED_ORIGIN = "https://adr.smlcompany.ca";
const CORS = {
"Access-Control-Allow-Origin": ALLOWED_ORIGIN,
"Access-Control-Allow-Methods": "POST,OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
};
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
export const handler = async (event) => {
// Preflight
if (event.requestContext?.http?.method === "OPTIONS") {
return { statusCode: 204, headers: CORS };
}
let body;
try {
body = JSON.parse(event.body || "{}");
} catch {
return { statusCode: 400, headers: CORS, body: JSON.stringify({ error: "invalid_json" }) };
}
// Extract + light validation — adjust to taste.
const name = (body.name || "").toString().trim().slice(0, 200);
const org = (body.org || "").toString().trim().slice(0, 200);
const email = (body.email || "").toString().trim().slice(0, 200);
const phone = (body.phone || "").toString().trim().slice(0, 50);
const matter = (body.matter || "").toString().trim().slice(0, 100);
const message = (body.message || "").toString().trim().slice(0, 5000);
const honeypot = (body.website || "").toString(); // bot trap; see Part 10
if (honeypot) {
// Silently accept and drop — looks successful to bots.
return { statusCode: 200, headers: CORS, body: JSON.stringify({ ok: true }) };
}
if (!name || !email || !message) {
return { statusCode: 400, headers: CORS, body: JSON.stringify({ error: "missing_fields" }) };
}
if (!EMAIL_RE.test(email)) {
return { statusCode: 400, headers: CORS, body: JSON.stringify({ error: "invalid_email" }) };
}
const submissionId = randomUUID();
const submittedAt = new Date().toISOString();
const sourceIp = event.requestContext?.http?.sourceIp || "unknown";
const userAgent = event.headers?.["user-agent"] || "unknown";
// 1) Store in DynamoDB
await ddb.send(new PutCommand({
TableName: TABLE,
Item: { submissionId, submittedAt, name, org, email, phone, matter, message, sourceIp, userAgent },
}));
// 2) Email adr@smlcompany.ca
const text =
`New intake form submission
Name: ${name}
Org: ${org || "(not provided)"}
Email: ${email}
Phone: ${phone || "(not provided)"}
Service: ${matter || "(not provided)"}
Message:
${message}
Submission ID: ${submissionId}
Submitted: ${submittedAt}
IP: ${sourceIp}
Reply directly to this email — it will route to the submitter.
`;
await ses.send(new SendEmailCommand({
FromEmailAddress: FROM_ADDR,
Destination: { ToAddresses: [NOTIFY_ADDR] },
Content: {
Simple: {
Subject: { Data: `New intake: ${name}${org ? " — " + org : ""}`, Charset: "UTF-8" },
Body: { Text: { Data: text, Charset: "UTF-8" } },
}
},
ReplyToAddresses: [email], // hitting Reply in your inbox goes straight to the submitter
}));
return { statusCode: 200, headers: CORS, body: JSON.stringify({ ok: true, submissionId }) };
};
```
> **Note:** This version uses `adr@smlcompany.ca` as both From and To (per the simpler Option B in Part 9.3). The "Reply-To" header is set to the submitter's email, so when you hit *Reply* in your mail client, the response goes to them — not to yourself.
- Click **Deploy**.
- Set the runtime timeout to 10 seconds: **Configuration → General configuration → Edit → Timeout: 10 sec → Save**.
> **If you see a "module not found" error** on first invocation (rare but possible — AWS sometimes drops packages from the included SDK between runtime versions), you'll need to deploy your code as a zip with `node_modules`. Locally: `npm init -y && npm install @aws-sdk/client-dynamodb @aws-sdk/lib-dynamodb @aws-sdk/client-sesv2`, put your `index.mjs` next to `node_modules/`, then `zip -r function.zip index.mjs node_modules package*.json` and upload via Lambda → Code → Upload from → .zip file. The three SDK packages are normally pre-installed in the Node.js 20.x runtime, so you should be able to skip this step.
### 8.4 Create the HTTP API in API Gateway
- Console → **API Gateway → Create API → HTTP API → Build**.
- **Integrations:** click *Add integration* → Lambda → region ca-central-1 → function `adr-intake-handler`.
- **API name:** `adr-intake-api`.
- **Configure routes:**
- Method: `POST`
- Path: `/submissions`
- Integration target: `adr-intake-handler`
- **Configure stages:** leave default (`$default`, auto-deploy enabled).
- **Create**.
After it's created:
- Open the API → **CORS** → **Configure**:
- Access-Control-Allow-Origin: `https://adr.smlcompany.ca`
- Access-Control-Allow-Methods: `POST`
- Access-Control-Allow-Headers: `content-type`
- Save.
- Note the **Invoke URL** at the top — looks like `https://abc123.execute-api.ca-central-1.amazonaws.com`. Your endpoint is `<invoke-url>/submissions`.
### 8.5 Quick smoke-test (without the front-end)
From your terminal:
```bash
curl -X POST 'https://abc123.execute-api.ca-central-1.amazonaws.com/submissions' \
-H 'Content-Type: application/json' \
-d '{"name":"Test User","org":"Test","email":"test@example.com","phone":"+1-416-555-0100","matter":"Mediation","message":"This is a test."}'
```
Expected response: `{"ok":true,"submissionId":"..."}`.
At this point the DynamoDB write should succeed, but **SES will fail** until Part 9. Check **CloudWatch → Log groups → /aws/lambda/adr-intake-handler** — you'll see the error there. That's fine; Part 9 fixes it.
To see the row landed in the DB: DynamoDB → Tables → `adr-intake-submissions` → **Explore table items**.
---
# Part 9 — Verify your sender domain in SES (15 min)
By default SES is in **sandbox**: it can only send *from* verified identities *to* verified identities. For a low-volume contact-form notifier sending only to yourself, the sandbox is perfectly fine and free.
### 9.1 Verify the domain `smlcompany.ca`
- Region → **ca-central-1**.
- Console → **Amazon SES → Configuration → Identities → Create identity**.
- Identity type: **Domain**.
- Domain: `smlcompany.ca`.
- **Use a custom MAIL FROM domain:** skip (optional).
- **DKIM:** **Easy DKIM**, RSA 2048-bit. Leave **Publish DNS records to Route 53** unchecked (you don't have a Route 53 hosted zone).
- Create.
You'll land on the identity page with three CNAME records that SES wants published. They look like:
```
Name: abc1234567890._domainkey.smlcompany.ca
Value: abc1234567890.dkim.amazonses.com
Name: def0987654321._domainkey.smlcompany.ca
Value: def0987654321.dkim.amazonses.com
Name: ghi5555555555._domainkey.smlcompany.ca
Value: ghi5555555555.dkim.amazonses.com
```
Add each in Namecheap → Advanced DNS → **ADD NEW RECORD**:
- **Type:** CNAME Record
- **Host:** the part before `.smlcompany.ca` — e.g. `abc1234567890._domainkey`
- **Target:** the SES value, e.g. `abc1234567890.dkim.amazonses.com`
- **TTL:** Automatic
- Save with the green checkmark. Repeat for the other two.
⚠️ If you currently have **any other DKIM CNAMEs** for `smlcompany.ca` from your existing email provider (e.g. Google Workspace's `google._domainkey`), **leave them alone**. SES's DKIM uses different selector names, so it won't collide. Multiple DKIM keys on the same domain is normal and supported.
After 515 minutes, refresh the SES identity page. The DKIM status flips to **Successful** and the overall identity status flips to **Verified**. If it's still pending after 30 minutes, check the Host fields in Namecheap for typos.
> **Bonus — SPF alignment for SES.** Your existing `v=spf1 ...` TXT record at the apex tells the world which servers may send mail "as" smlcompany.ca. If you want SES-sent mail to pass SPF too (improves deliverability of intake notifications), add `include:amazonses.com` to the existing SPF record. Edit it in Namecheap so it becomes e.g.: `v=spf1 include:_spf.google.com include:amazonses.com ~all`. Don't create a *second* SPF TXT record — only one is allowed per domain.
### 9.2 Verify the recipient
While SES is in sandbox, the *To:* address also has to be verified.
- SES → Identities → **Create identity** → Email address → `adr@smlcompany.ca` → Create.
- AWS sends a verification email to that address. Click the link. Status → **Verified**.
### 9.3 Verify the From address
The Lambda above uses `intake@smlcompany.ca` as the From. Verify it too:
- SES → Identities → **Create identity** → Email address → `intake@smlcompany.ca` → Create.
- Either have your email provider deliver mail at that alias to your real inbox, *or* just use `adr@smlcompany.ca` as the From in the Lambda code and skip this step.
### 9.4 Retest
```bash
curl -X POST 'https://abc123.execute-api.ca-central-1.amazonaws.com/submissions' \
-H 'Content-Type: application/json' \
-d '{"name":"Test 2","org":"Test","email":"test@example.com","matter":"Mediation","message":"Now with email."}'
```
You should get the success response AND see an email arrive at `adr@smlcompany.ca` within a few seconds.
### 9.5 (Optional, later) Request production access
If you ever want the form to **send a confirmation email back to the submitter**, you'll need to exit sandbox. SES Console → top right → **Request production access**. AWS asks a few questions about how you'll use it; approval is usually under 24 hr for legitimate business use.
---
# Part 10 — Wire the front-end form to your API (20 min)
### How the site is bundled
Your site export uses a custom format from Anthropic's Artifacts bundler:
- **`SML ADR Site (Standalone).html`** — a single 2.2 MB file containing the rendered HTML *plus* all JSX, JavaScript, fonts, and the logo PNG bundled together as gzipped+base64 entries inside a `<script type="__bundler/manifest">` JSON blob. This is the file you uploaded to S3 as `index.html`.
- **`components-standalone/*.jsx`** — the loose JSX source files (sections, hero, nav, etc.). The standalone HTML was originally built *from* these but isn't automatically rebuilt when you edit them.
So if you change a JSX file, you have two options:
| Approach | What you upload to S3 | Pros | Cons |
|---|---|---|---|
| **Stay with the bundled HTML** *(recommended)* | One file (`index.html`) | Same as before. Fast page load. CloudFront caches it well. | Need to "rebundle" after JSX edits. |
| **Switch to loose files** | `(standalone-src).html` + the whole `components-standalone/` folder + `tweaks-panel.jsx` + `assets/` | No rebuild step — just upload changed JSX. | Extra ~500ms first page load while Babel compiles JSX in the browser. Many small files. |
This guide assumes you stay with the bundled HTML, because that's the architecture you started with.
### The form code is already updated
The `components-standalone/sections.jsx` file has been edited. The new Contact component:
- Adds two new required-flag-aware fields: **Email** (required, `type="email"`) and **Phone** (optional, `type="tel"`).
- Wires `onSubmit` to a real `fetch()` POST against your API Gateway endpoint.
- Adds `submitting` and `error` state so the button shows "Sending…" while in flight and a clear maroon-bordered error message on failure.
- Adds a hidden **honeypot** field (`website`) to silently drop bot submissions.
- Adds a small-print **consent line** under the submit button. **Superseded — do not use this wording:** the live text is `NO_RETAINER_NOTICE` in `src/data/site.ts`, which deliberately avoids the phrase below. Historical text: *"Submitting this form does not create a lawyer-client relationship. By submitting, you consent to storage of this information by SML Company in Canada for the purpose of responding to your inquiry."*
- Extends the shared `Field` component to accept `type` and `required` props, rendering a gold asterisk next to required-field labels.
The API endpoint is hard-coded at the top of the Contact section:
```jsx
const INTAKE_API_URL = 'https://4tl0m5igkj.execute-api.ca-central-1.amazonaws.com/submissions';
```
If your API Gateway URL ever changes, update that one constant and rebuild (next step).
### Rebuilding the standalone HTML (`rebuild-standalone.py`)
A small Python script sits alongside the JSX in the project folder. It reads the original `SML ADR Site (Standalone).html`, swaps in the current contents of `components-standalone/sections.jsx`, re-compresses, and writes out a fresh `index.html` that's ready to upload to S3.
From a terminal:
```bash
cd "/Users/pouya/Library/CloudStorage/GoogleDrive-pouya@smlcompany.ca/My Drive/Research/Law/ADR Personal Branding Project/Pouya Personal Branding Web"
python3 rebuild-standalone.py
```
You'll see output like:
```
patched sections.jsx -> 8830e633-... (42,792 bytes → 10,513 gz → 14,020 b64)
Wrote .../Pouya Personal Branding Web/index.html
```
That `index.html` is the file you upload to S3.
The script is intentionally limited to `sections.jsx` (where the form lives). If you ever want to edit the hero, nav, or any other component, open `rebuild-standalone.py` and uncomment the relevant line in the `JSX_FILES` mapping after discovering each component's UUID (the script docstring explains how).
> **For the first run we already did this for you** — a fresh `index.html` containing the email/phone form is sitting in the project folder right now, ready to upload.
### Deploying the change
1. Upload the new `index.html` to your S3 bucket `adr-smlcompany-site`, **replacing** the existing `index.html`. S3 versioning (enabled in Part 1.1) keeps the old version recoverable if anything goes wrong.
Console path: S3 → `adr-smlcompany-site` → **Upload** → drag `index.html` from the project folder → **Cache-Control:** `public, max-age=300, must-revalidate` → **Upload**.
Or from the terminal:
```bash
aws s3 cp \
"/Users/pouya/Library/CloudStorage/GoogleDrive-pouya@smlcompany.ca/My Drive/Research/Law/ADR Personal Branding Project/Pouya Personal Branding Web/index.html" \
s3://adr-smlcompany-site/index.html \
--cache-control 'public, max-age=300, must-revalidate'
```
2. **Invalidate CloudFront** so users see the new version immediately rather than waiting for the 5-minute cache to expire.
Console: CloudFront → your distribution → **Invalidations → Create invalidation** → object path: `/index.html` (and `/` for safety) → **Create**. Costs $0.005 per path (first 1,000 paths/month are free).
Or from the terminal:
```bash
aws cloudfront create-invalidation \
--distribution-id <YOUR-DIST-ID> \
--paths '/' '/index.html'
```
3. Hard-refresh `https://adr.smlcompany.ca` in an incognito window. Confirm the form now shows the Email and Phone fields and the consent line under the submit button.
---
# Part 11 — End-to-end smoke test (10 min)
In an incognito window:
1. Open `https://adr.smlcompany.ca`. Confirm green padlock, all sections render, logos load.
2. Open the browser devtools → Network tab. Submit the intake form with realistic values.
3. Confirm the network call to your API Gateway returns 200.
4. Within 30 seconds, check `adr@smlcompany.ca` — you should have a "New intake: …" email.
5. Open the DynamoDB table → **Explore table items** → you should see your test row.
6. (Optional) Try submitting from `curl` with the honeypot field set — `{"website":"http://spam"}`. You should get a 200 but **no email and no DB row** (silent drop).
✅ If all five pass, you're live.
---
# Part 12 — Day-2 operations
### How to update the site
1. Re-export the standalone HTML.
2. Upload to S3 as `index.html` (overwrites; old version preserved by versioning).
3. CloudFront invalidate `/index.html` (and `/` for safety).
Doable in 2 minutes via the CLI:
```bash
aws s3 cp index.html s3://adr-smlcompany-site/index.html \
--cache-control 'public, max-age=300, must-revalidate'
aws cloudfront create-invalidation \
--distribution-id <DIST-ID> --paths '/' '/index.html'
```
### Monitoring
- **CloudWatch alarm — Lambda errors:** CloudWatch → Alarms → Create alarm → Metric: Lambda → ByFunctionName → `adr-intake-handler` → Errors → Statistic Sum, period 5 min, threshold `>= 1`. Notify via an SNS topic that emails you. Alerts you within minutes if the form starts failing.
- **CloudFront 5xx error rate alarm:** same pattern, threshold `> 1%`.
- Set both with low thresholds — your traffic is low enough that any sustained error matters.
### Backups & retention
- DynamoDB PITR (Part 8.1) gives you 35-day rollback.
- S3 versioning (Part 1.1) gives you forever-rollback on the site files.
- Consider a quarterly export of the DynamoDB table to S3 if you want a clean audit trail.
### Privacy / PIPEDA hygiene (legal-services context)
- Everything lives in `ca-central-1`. CloudFront caches *static* HTML at edge locations globally, but your form *submissions* never touch CloudFront — they go directly to API Gateway in ca-central-1.
- Consider adding a one-line consent notice under the form: *"By submitting this form, you consent to its storage by SML Company in Canada for the purpose of responding to your inquiry. We do not share this information with third parties."*
- DynamoDB rows include the submitter's IP and user-agent for abuse defense. If you'd rather not store those, remove `sourceIp` and `userAgent` from the `PutCommand` Item.
### Cost expectations (USD, monthly)
| Service | Expected | Notes |
|-----------------|---------------|-----------------------------------------|
| Namecheap DNS | $0 | Included with your domain registration. |
| S3 | <$0.05 | 3 MB of files + a few requests. |
| CloudFront | $0.101 | Free tier covers first 1 TB out/month. |
| ACM cert | $0 | Free. |
| API Gateway | <$0.05 | $1 per million requests. |
| Lambda | $0 | Free tier covers 1M requests/mo. |
| DynamoDB | $0 | On-demand, low volume. |
| SES | $0 | First 62k emails/mo from Lambda free. |
| **Total** | **under $1** | |
### What to do if something breaks
- **Site won't load:** check CloudFront *Status = Deployed*, and that the Namecheap CNAME for `adr` points to the CloudFront domain (paste the value from `dig adr.smlcompany.ca CNAME` or `nslookup adr.smlcompany.ca` to verify it actually resolves to a `.cloudfront.net` host).
- **403 from CloudFront:** the S3 bucket policy isn't right — re-copy from CloudFront's "Origins → Edit" page.
- **TLS error:** ACM cert is in us-east-1, not ca-central-1; or the *Alternate domain name* on the CloudFront distribution doesn't match exactly.
- **Form returns 500:** open CloudWatch logs for the Lambda — almost always an unverified SES identity or a permissions gap on the role.
- **Email not arriving:** SES is still in sandbox AND the destination isn't verified, OR the From address isn't verified.
---
## Appendix A — File / resource manifest
When you're done, here's what you should be able to point at in your AWS console:
| Resource | Name / ID |
|-----------------------|----------------------------------------------------------------------|
| S3 bucket | `adr-smlcompany-site` (ca-central-1) |
| CloudFront dist | `E…` (CNAME: adr.smlcompany.ca) |
| ACM certificate | for `adr.smlcompany.ca` (us-east-1) |
| DNS provider | Namecheap (Advanced DNS for smlcompany.ca) |
| DynamoDB table | `adr-intake-submissions` (ca-central-1) |
| Lambda function | `adr-intake-handler` (ca-central-1) |
| IAM role | `adr-intake-lambda-role` |
| API Gateway | `adr-intake-api` (HTTP API, ca-central-1) |
| SES verified ids | domain `smlcompany.ca`, email `adr@smlcompany.ca`, `intake@…` |
## Appendix B — Future enhancements (when you want them)
- **Admin dashboard for submissions:** build a tiny password-protected page that calls a second Lambda (`GET /submissions`) to list the DynamoDB table. Or just use the DynamoDB console for now — it's perfectly serviceable for low volume.
- **Confirmation email to submitter:** request SES production access (Part 9.5), then add a second `SendEmailCommand` call in the Lambda thanking them and setting expectations.
- **Calendar booking:** integrate Calendly or Cal.com link inside the "Thank you" view.
- **File uploads on intake** (e.g., a PDF of the dispute summary): add S3 presigned-URL generation in the Lambda, let the front-end upload directly to a private bucket. Keep file size limits sane.
- **Move DNS to Route 53 later:** if you ever want apex (`smlcompany.ca`) on CloudFront, or want AWS to manage records for you automatically, the migration is straightforward — inventory Namecheap records, recreate them in a new Route 53 hosted zone, switch nameservers at Namecheap. Doable in ~30 min once you have a downtime window for any DNS-sensitive integrations.
- **Bilingual (EN/FA) routing:** add a `lang` query param or subpath, serve from the same S3 bucket via CloudFront behaviors.
- **Search / analytics over submissions:** stream DynamoDB updates to a small OpenSearch index, or just export weekly to a private S3 bucket and query with Athena.
- **WAF in front of CloudFront:** if you ever see scraper/bot traffic, add AWS WAF with the AWS-managed core rule set (~$5/mo + per-request).
---
*End of guide. If you hit a wall on any specific step, come back here and tell me which Part and what the screen says — most issues are 1-line fixes.*
+120
View File
@@ -0,0 +1,120 @@
# Brand assets — provenance
The repository holds the artwork it makes claims about. That is not tidiness: it
is `CLAUDE.md`'s rule and `AGENTS.md` R14, and this file exists because the
infinity mark was reconstructed wrongly and **two adversarial review passes could
not catch it**, since the real artwork was not in the repo to compare against.
**Every measurement below is `[verified 2026-08-26]`** — computed with `sharp`
against the files in this repository, and re-derivable by anyone from the
commands given. Nothing here is quoted from an external source.
## Files
| File | What it is | Use |
|---|---|---|
| `src/assets/brand/sml-infinity-mark-master.png` | The mark as supplied: **3000 × 3000, alpha**, mark inset within the canvas | **The master.** Committed so the crop below can be re-derived and checked in-repo, not taken on trust |
| `src/assets/brand/sml-infinity-mark.png` | The same mark tight-cropped to its ink bounding box: **2668 × 1704, alpha, 1.5657:1** | **The render source.** `InfinityMark.astro` feeds it to Astro's `<Picture>` |
| `src/assets/brand/sml-logo-full.png` | Mark **plus** the "SML Company" wordmark, 3000 × 3000, alpha; ink bbox 2414 × 1440 = 1.676:1 | Not currently rendered. Held for the OG-image template (`docs/04`) and print |
| `src/assets/brand/sml-logo-source.svg` | 1500 × 1500 viewBox, **257,278 bytes** | Reference. Faithful, but not what is served — see below |
## Reproducing the crop, in-repo
The crop is derived, so it is re-derivable — and now from a file that is here,
which is the whole point of R14. Scan the master for the first and last pixel
that is neither transparent (`alpha < 24`) nor near-white (`r,g,b > 243`):
```
ink bbox of sml-infinity-mark-master.png -> 2668 x 1704 at (159, 646)
sharp(master).extract({ left: 159, top: 646, width: 2668, height: 1704 })
```
Crop to that box and the file's own aspect ratio becomes the mark's, so layout
can be tuned against the asset directly rather than against a number written
down beside it. `InfinityMark.astro` pins `aspect-ratio: 667 / 426`, which is
`2668 / 1704` reduced — **1.56573**.
## Why the SVG is held but not served
It renders **faithfully**. Rasterised at 8333 px it reproduces the master
exactly, at the same **1.566:1**. It is not bad artwork, and an earlier version
of this file implied it was; that was wrong.
What rules it out is weight and composition:
- **257,278 bytes**, against **3,063 bytes** for the AVIF a Retina device
actually takes in the header. **84×.** *(This line said "9,468 bytes... 27×"
until 2026-08-27. 9,468 was the DPR-1 figure — the number for the devices the
performance budget does **not** target. Quote the figure for the device the
budget is written for.)*
- **7 embedded base64 PNGs** (`<image>` elements), so it is a hybrid rather than
pure vector — inlining it would breach `CLAUDE.md`'s rule against
base64-inlining images, which is one of the specific faults of the build this
project replaces.
- 10 `<linearGradient>` carrying **1,225 `<stop>`** elements, 64 `<path>`,
59 `<clipPath>`, 23 `<mask>`, 1 `<filter>`. Expensive to rasterise.
What `AGENTS.md` Q38 asks for is a master that is faithful **and** light.
## What the browser actually downloads
`<Picture>` emits AVIF, WebP and a PNG fallback at `densities` 1×, 2× and 3× of
whatever intrinsic `width` the call site passes. **There are now TWO ladders,
because there are two sizes of call site** `[measured 2026-08-27 — every figure
below read from the file on disk]`.
**`width={64}` — the default. The header (50.1 px wide) and footer (56.4 px).**
| | 1× (64 px) | 2× (128 px) | 3× (192 px) |
|---|---|---|---|
| AVIF | **1,720 B** | **3,063 B** | **4,843 B** |
| WebP | 2,368 B | 5,368 B | 8,664 B |
| PNG (fallback) | 6,137 B | 16,996 B | 29,780 B |
**`width={232}` — the home page's approach section, which renders at 225.5 px.**
| | 1× (232 px) | 2× (464 px) | 3× (696 px) |
|---|---|---|---|
| AVIF | **6,017 B** | **14,555 B** | **22,639 B** |
That instance is `loading="lazy"`: it sits roughly a screen and a half down, so
it is not on the LCP path. The header and footer marks stay `eager`.
**THE LADDER HAS NOW BEEN WRONG IN BOTH DIRECTIONS, which is why `width` is a
prop rather than a constant.**
- *Too big, 2026-08-26.* Sized at 320 px, justified by a 4 rem sample on the
proof sheet (a page step 2 has since deleted), with `densities` stacked on
top — so the ladder double-counted its own headroom and every DPR-2 device
pulled a 640 px image into a 56 px slot: **20,629 B**, while the docs claimed
9 KB because that is what DPR 1 took.
- *Too small, 2026-08-27.* The home page added a 225.5 px call site and
inherited the 64 px ladder, whose largest file is 192 px: **3.52× upscale at
DPR 3.** Now 232/464/696, and 696 covers the 676 device px a DPR-3 screen
asks for. All three instances measure ≤1.0× upscale at DPR 1, 2 and 3.
**Do not measure this with `img.naturalWidth`.** For an image chosen from a
`srcset` with an `x` descriptor it is **density-corrected**: the 192 px file
selected at `3x` reports 64, so reading it at DPR 1, 2 and 3 returns 64 every
time — which looks exactly like the ladder not being generated at all. Read the
files on disk.
Passing an explicit `width` is load-bearing: without it Astro emits the
untouched 2668 px master as the `<img src>` fallback — **1,146,406 bytes**
which any client without AVIF or WebP support would actually download.
## The colours are the artwork's, not the palette's
`tokens.css` is not involved. The ribbon carries its own gradient and it is
close to but not identical with `--maroon` `#5a1a1c` and `--gold` `#c9a876`.
Do not "correct" the artwork toward the tokens, and do not derive tokens from
the artwork — D7 keeps the palette *and* the mark, as they are.
## Where these came from
Supplied by Pouya. `sml-logo-source.svg` was added by him directly to the repo;
the PNGs were taken from the `smlcompany.ca` Google Drive, under `SML/Designs/`
and `Research/Law/ADR Personal Branding Project/`. **The Drive copies are not the
record — these files are.** That is the point of R14: an artefact that lives only
in Drive cannot be compared against a claim by any reviewer, which is exactly how
the traced mark survived two review passes.
+45
View File
@@ -0,0 +1,45 @@
# Fonts — provenance
Self-hosted per `docs/02-design-system.md`. **No runtime Google Fonts request**:
the page collects legal inquiries, and a third-party font call costs a round trip
and adds a third party to that page.
These `.woff2` files are committed rather than pulled at build time so their
paths are stable — a `<link rel="preload">` needs a filename that does not change
between builds, and Astro's asset hashing would break that.
| File | Source package | Version | Licence |
|---|---|---|---|
| `instrument-serif-latin-400-normal.woff2` | `@fontsource/instrument-serif` | 5.3.0 | SIL OFL 1.1 |
| `instrument-serif-latin-ext-400-normal.woff2` | `@fontsource/instrument-serif` | 5.3.0 | SIL OFL 1.1 |
| `instrument-serif-latin-400-italic.woff2` | `@fontsource/instrument-serif` | 5.3.0 | SIL OFL 1.1 |
| `geist-latin-wght-normal.woff2` | `@fontsource-variable/geist` | 5.3.0 | SIL OFL 1.1 |
| `geist-latin-ext-wght-normal.woff2` | `@fontsource-variable/geist` | 5.3.0 | SIL OFL 1.1 |
| `geist-mono-latin-wght-normal.woff2` | `@fontsource-variable/geist-mono` | 5.3.0 | SIL OFL 1.1 |
**The `?v=1` on every font URL is load-bearing.** `scripts/deploy-local.sh`
serves `/fonts/*` with `max-age=31536000, immutable`, so a returning visitor
holds these bytes for a year and no CloudFront invalidation can reach their
browser cache. Replacing a file means bumping that query — in
`src/styles/global.css` **and** in the `<link rel="preload">` in
`BaseLayout.astro`, which must match character for character or the preload
fetches a second copy instead of warming the cache.
Fetched 2026-08-26 with `npm pack <pkg>@5.3.0` and extracted from `package/files/`.
Subsetting is Fontsource's, not ours — the `latin` and `latin-ext` cuts are
exactly the "Latin + Latin Extended-A" the design system asks for.
**123,804 bytes across all six**, of which only two — Instrument Serif 400 latin
(21,032) and Geist latin (29,400) — are preloaded, so first paint pulls about
50 kB. Quote the byte figure, not `du -sh`, which reports 136K because it counts
disk blocks rather than what crosses the wire.
**Not covered by `AGENTS.md` R11.** R11 re-checks npm pins for currency and
advisories; these are static binaries with no runtime and no dependency tree.
Refresh them deliberately — when a face gains glyphs the site needs — by
repeating the `npm pack` above, not on a currency schedule.
**Deliberately absent:** the `latin-ext` italic cut of Instrument Serif, and
every non-Latin cut of all three faces. `.display .it` is one italic phrase in a
headline (`docs/02`), and D4 makes the site English-only. Add a cut when a page
needs it; do not add all of them pre-emptively.
@@ -4,7 +4,9 @@
#
# This file is kept because it is the better design: GitHub OIDC issues a
# short-lived token per run instead of a static key. If the project ever moves
# to GitHub or GitLab, use this and delete the static IAM user.
# to GitHub, use this and delete the static IAM user. GitLab also federates
# to AWS by OIDC, but with entirely different CI syntax — this file is the
# design there, not the implementation.
# ---------------------------------------------------------------------------
name: Build and deploy
@@ -13,8 +15,9 @@ on:
branches: [main]
workflow_dispatch:
# OIDC role assumption — no long-lived AWS credentials in this repository.
# See docs/06-deployment.md for the one-time IAM setup.
# OIDC role assumption — a short-lived token per run, no static key.
# NOT the current posture: this repository deploys with a static IAM key.
# See docs/06-deployment.md for the live procedure and the IAM policy.
permissions:
contents: read
id-token: write
@@ -49,15 +52,17 @@ jobs:
PUBLIC_INTAKE_ENDPOINT: ${{ vars.INTAKE_ENDPOINT }}
PUBLIC_BOOKING_URL: ${{ vars.BOOKING_URL }}
# TODO(pouya): AGENTS.md Q9, Q10 — set these repository variables:
# AWS_DEPLOY_ROLE_ARN, AWS_REGION, S3_BUCKET, CLOUDFRONT_DISTRIBUTION_ID
# If adopting this: set AWS_DEPLOY_ROLE_ARN as a repository variable. The
# rest — AWS_REGION, S3_BUCKET, CLOUDFRONT_DISTRIBUTION_ID, INTAKE_ENDPOINT and
# BOOKING_URL — are recorded in docs/06-deployment.md.
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ vars.AWS_DEPLOY_ROLE_ARN }}
aws-region: ${{ vars.AWS_REGION }}
# Two passes: hashed immutable assets first, HTML last. A visitor must
# Three passes: hashed immutable assets first, then images, HTML last.
# A visitor must
# never fetch a new page whose assets have not landed yet.
- name: Sync hashed assets
run: |
+180
View File
@@ -0,0 +1,180 @@
# Reference — how the Licence Appeal Tribunal actually runs its pre-hearing step
**Why this file exists.** `AGENTS.md` Q41(c) asked what `LAT pre-hearing
mediation` means as an offering. Pouya's ruling of 2026-08-27: *"'LAT pre-hearing
mediation' is imprecise and must not imply appointment by the tribunal. Verify
against LAT's own materials how its case-conference process is conducted and who
conducts it."* This is that verification, committed rather than cited, under
`CLAUDE.md`'s rule that anything a spec makes a claim about must be reachable
from the repository (R14).
---
## Provenance — read this before quoting anything below
| | |
|---|---|
| Source 1 | `https://tribunalsontario.ca/documents/lat/LAT-Rules.html`*Licence Appeal Tribunal, Animal Care Review Board and Fire Safety Commission Rules of Practice and Procedure*, effective July 21, 2023 |
| Source 2 | `https://tribunalsontario.ca/lat-aabs/application-and-hearing-process/` — LATAABS, *Application and hearing process* |
| Retrieved | **2026-08-28** |
| Method | `curl -sS -o <file> '<url>'` — HTTP **200** both; **88,429 B** (rules) and **107,996 B** (AABS page) |
| Text extraction | script/style stripped, tags stripped, entities unescaped, whitespace collapsed → **66,593** and **33,696** characters |
> ⚠️ **NO HASHES, AND THE REASON IS THE POINT.** This table carried `sha256`
> prefixes `07d9c077e41cc8bd` and `08eff9a73b683cc5`. They are removed because
> **they cannot be reproduced, and a stamp that cannot be re-checked is worse
> than no stamp** — a future reader who re-fetches and gets a different digest
> would conclude the source had changed when it had not.
>
> Found by `adversarial-reviewer`, which re-fetched both URLs and got two
> different digests, then two more on two further fetches. Cause isolated by
> diffing consecutive responses: `LAT-Rules.html` carries a per-request
> bot-detection nonce (`__uzdbm_1`, `__uzdbm_2`), and the AABS page carries
> rotating WordPress `?ver=` cache-busters. **The sha256 of these URLs is not a
> stable quantity.**
>
> What *does* reproduce, and was independently reproduced: **both byte counts
> exactly**, **all ten verbatim quotes** with their rule numbers and headings, and
> **all four term counts**. So the substance of this file is verified twice over;
> only the hashes were spurious. Same family as the `1.23:1` bounding box and the
> `timeout 60 ls` in `CLAUDE.md` — a number that looks like verification, from a
> probe nobody validated.
**Instrument check, because `CLAUDE.md` requires one.** The word counts below were
taken from the **raw fetched bytes**, not from a `WebFetch` answer. `WebFetch`
answers through a summarising model, so a "quote" it returns may be a paraphrase
— and the first pass here did return a plausible-looking Rule 14.4 quote
(*"The case conference is an important opportunity to discuss settlement"*) that
turned out to be **correct**, and a Rule 14.6 gloss that was **not** how the rule
reads. Both were then checked against the literal text. Quotes in this file are
literal; where the two disagreed the literal text won.
A second instrument note: the rules document repeats every heading in a table of
contents before the body, so a naive "find the heading" extraction returns the
**TOC** and reports the rules as empty. The bodies are present, ~35 KB further in.
---
## Finding 1 — the LAT's settlement step is a *case conference*, and a Tribunal Member conducts it
**Rule 2.4, verbatim:**
> "Case Conference" has the same meaning as "Pre-Hearing Conference" as defined
> in the SPPA.
So **"pre-hearing" is the LAT's own term, and what it names is a case
conference** — not a mediation.
**Rule 14.2 — Scope of case conference subject matter, verbatim opening:**
> The Tribunal may on its own initiative, or in response to a party's written
> request, direct the parties to participate in a case conference to consider:
> The settlement of any or all of the issues; […]
**Rule 14.3 — Member not to participate on a hearing panel, verbatim:**
> A Member who presides at or otherwise takes part in a case conference shall not
> participate as a Member of a panel at a subsequent hearing of the appeal except
> with the consent of the parties.
**Rule 14.4 — Settlement discussions, verbatim:**
> The case conference is an important opportunity to discuss settlement of the
> issues without the need for a hearing. The parties are expected to come to the
> case conference prepared to discuss settlement.
>
> All settlement discussions in a case conference and the documents put forward
> solely for the purpose of settlement are confidential. Settlement discussions
> are held on a "without prejudice" basis. Settlement discussions shall not be
> communicated to the Member that participates in the hearing or otherwise be
> relied on in a hearing before the Tribunal for any purpose unless the parties
> consent.
**Rule 14.6 — Party attendance, verbatim first sentence:**
> A party as defined under Rule 2.16 must attend their case conference.
**Rule 12 — Format, verbatim:**
> In accordance with applicable provisions of the SPPA, the Tribunal may hold a
> hearing or case conference in any of the following formats, as it considers
> appropriate: In-person; Electronic; Written; or Any combination of the above.
The public LATAABS page adds, of the same step: *"A case conference is led by an
adjudicator whose role is to guide and support the parties in working to resolve
the dispute."*
**Consequence:** the neutral in the LAT's pre-hearing step is a **Member /
adjudicator of the Tribunal**. It is directed by the Tribunal, attendance is
mandatory, and the Member is disqualified from the subsequent hearing panel. A
privately retained neutral is not appointed to it and cannot be.
## Finding 2 — the LAT Rules never use the words "mediation", "mediator" or "arbitration"
Counted on the literal extracted text, case-sensitively for both cases:
```
lat-rules.html 66,593 chars 'mediat' 0 'Mediat' 0 'arbitrat' 0 'Arbitrat' 0
lat-aabs.html 33,696 chars 'mediat' 1 'Mediat' 0 'arbitrat' 0 'Arbitrat' 0
```
**Zero** in the Rules. There is no rule providing for the Tribunal to appoint an
external mediator, and no rule about a party retaining a private neutral —
because the Rules do not contemplate the concept at all.
## Finding 3 — the single match, read rather than counted
`CLAUDE.md`: *a grep that matches is not a finding until you read what it
matched.* The one `mediat` on the AABS page, printed with its heading, is this —
and it is the affirmative basis for the offering rather than a problem for it:
> **4. Consider other ways to resolve your dispute**
>
> Before you apply to the LATAABS, you may want to consider negotiation or
> mediation services. Parties are encouraged to attempt to negotiate the claim
> at all times, including before filing at the LATAABS, and continuing
> negotiation discussions after a claim has been filed.
The Tribunal itself points parties at private mediation, **before filing and
continuing after filing.** That is exactly the space a privately retained
mediator occupies, and it is the Tribunal's own words for it.
---
## What this establishes, and what it does not
**Establishes:**
1. The LAT's pre-hearing settlement step is a **case conference conducted by a
Tribunal Member**. `LAT pre-hearing mediation` therefore describes a thing
that does not exist, and the half a reader would recognise — *pre-hearing*
is the Tribunal's own label for a step nobody outside the Tribunal conducts.
2. Private mediation of accident-benefits and SABS disputes is **compatible with
a LAT application, before filing or after**, and the Tribunal says so.
**Does not establish:**
- Anything about whether Pouya holds a roster position with the LAT or Tribunals
Ontario. Nothing here bears on that. §4 has no such row, so the site claims
none — per Pouya's ruling: *"If Pouya holds a roster position that makes more
than that true, it is a §4 addition — absent a row, it isn't."*
- Anything about *commercial* arbitration gating. Same caution as
`ontario-family-arbitration-training.md`: a source about one process is not
authority about another. These documents do not mention arbitration at all.
## The wording that follows from it
**Never publish** `LAT pre-hearing mediation`, or any phrasing in which a LAT
proceeding appears to appoint or host the mediator.
**Published instead**`src/data/site.ts`, `PRACTICE_AREAS``insurance`:
> Accident benefits and SABS entitlement, MIG disputes, and private mediation
> alongside a LAT application, before filing or after.
`docs/01` keeps `LAT pre-hearing mediation` as a **search intent** — people do
type it — with a note that it must never be lifted into copy. That lift is
exactly what happened once already.
`/practice/insurance/` at build step 5 must state that the mediation offered is
**private**, retained by the parties, and **not the Tribunal's case conference**.
@@ -0,0 +1,77 @@
# Reference — Ontario's training requirements for family arbitrators
**Why this file exists.** `AGENTS.md` §4 Offerings rests on a proposition about
what Ontario law does and does not gate. R14: *anything a spec makes a claim
about must be reachable from the repository* — a claim whose source lives only
at a URL is one a reviewer can be asked to trust rather than check. This is the
extract, with its provenance and the command that produced it.
It is **not** legal advice and it is not a substitute for the instruments
themselves. It records what one government page said on one day.
---
## Provenance
| | |
|---|---|
| Source | `https://www.ontario.ca/page/training-family-arbitrators` |
| Retrieved | **2026-08-27** |
| Method | `WebFetch` — page converted to markdown, then queried for verbatim requirements, hour figures, the lawyer / non-lawyer distinction, ongoing-training period, and every statute or regulation named |
| Retrieved by | Claude Code, on Pouya's instruction of 2026-08-27 (Q39) |
| Cited by Pouya | Yes — this is the source named in his Q39 ruling, with the same three hour figures |
**Re-derive it:** fetch the URL and read it. If the page has changed, record the
change here rather than editing the extract — a stale extract with a date is
useful; a silently updated one is not.
---
## What the page states
Quoted as retrieved. Where the page's own wording is reproduced it is in
quotation marks.
**Screening for domestic violence and power imbalances.** "at least 14 hours
(within one week) to learn about screening parties for domestic violence and
power imbalances".
**Ontario family law — non-lawyers only.** "All family law arbitrators who are
not a part of the Ontario Bar, or another Canadian bar, must complete 30 hours
of training about Ontario family law." The page adds that "You do not need to
complete this training all at once."
**Members of the Ontario Bar.** No hour figure. The page states instead that
"you should ensure you are familiar with family law to fulfil your professional
obligation to provide services competently."
**Ongoing training.** "10 hours over any two-year period. Five of these hours
must be related to domestic violence or power imbalance issues".
**Statute named on the page.** *Arbitration Act, 1991*. **No section number and
no regulation (`O. Reg.`) number appears on the page.**
---
## What the page does NOT state — and this half matters more
**It says nothing about commercial arbitration.** Commercial arbitration is
neither mentioned nor excluded. The page does not state which arbitrations the
requirements apply to beyond describing them as family arbitration.
So the source establishes the **family** half of §4's scoped proposition
directly, and the **commercial** half only by *absence* — a page about family
arbitrators is not authority for what commercial arbitrators need. §4 records
the commercial half as **Pouya's stated position**, attributed to him and
deliberately unstamped, for exactly that reason.
**Nothing on the site turns on the gated activity.** Pouya has confirmed he does
not accept family arbitration under the *Family Law Act* (§4 Offerings, scope
exclusion). The requirements above are recorded because the register reasoned
from a false universal for a day and must not do so again — not because the
practice sits anywhere near them.
**Do not upgrade this file into an authority it is not.** If a stronger source
is ever wanted — the *Family Law Act* provisions and the regulation made under
it — fetch and extract those, name them by number, and date them. Do not write
a section number from memory.
+67
View File
@@ -0,0 +1,67 @@
// ESLint 10 flat config. Scope is deliberately small: this project targets zero
// client JavaScript (CLAUDE.md, AGENTS.md §7), so the only JS/TS here is build
// configuration, site data, and the occasional island. Rules exist to catch
// mistakes, not to impose style — Prettier owns formatting.
//
// `typescript-eslint` is here because .astro frontmatter IS TypeScript, so the
// plugin cannot parse a single component without it. It runs unconfigured for
// type-awareness on purpose: `astro check` already does the type checking, and
// duplicating it here would be slower and would disagree at the edges.
import js from '@eslint/js';
import globals from 'globals';
import tseslint from 'typescript-eslint';
import astro from 'eslint-plugin-astro';
export default [
{ ignores: ['dist/**', 'node_modules/**', '.astro/**', 'docs/reference/**'] },
js.configs.recommended,
...tseslint.configs.recommended,
...astro.configs.recommended,
...astro.configs['flat/jsx-a11y-recommended'],
// `no-undef` off for TYPESCRIPT ONLY, on typescript-eslint's own advice: it
// has no type information, so every ambient global is a false positive —
// Astro declares `ImageMetadata`, `astroHTML.JSX` and friends globally, and
// .astro frontmatter IS TypeScript. tsc catches a real undefined reference,
// which is what `npm run check` is for.
//
// NOT applied to .js/.mjs. `tsconfig.json` sets `allowJs` without `checkJs`,
// so plain JS is not type-checked by anything — turning the rule off there
// meant a typo like `procss.env.X` in astro.config.mjs passed lint silently.
{
files: ['**/*.ts', '**/*.astro'],
rules: { 'no-undef': 'off' },
},
{
files: ['**/*.{js,mjs,ts}', '**/*.astro'],
languageOptions: {
ecmaVersion: 2023,
sourceType: 'module',
globals: { ...globals.browser, ...globals.node },
},
rules: {
// A stray console.log in a static build is dead weight shipped to nobody.
'no-console': ['warn', { allow: ['warn', 'error'] }],
// `role="list"` on a <ul> is redundant to a spec reader and load-bearing
// in a browser: Safari drops list semantics from any list styled
// `list-style: none`, so VoiceOver stops announcing "list, 6 items".
// src/styles/global.css keys its own reset off `ul[role='list']` for
// exactly this reason. The rule is right in general; this is the one
// documented exception, and it is scoped to that single pairing.
'astro/jsx-a11y/no-redundant-roles': [
'error',
{ ul: ['list'], ol: ['list'] },
],
eqeqeq: ['error', 'always'],
'prefer-const': 'error',
'@typescript-eslint/no-unused-vars': [
'error',
{ argsIgnorePattern: '^_' },
],
},
},
];
+10397
View File
File diff suppressed because it is too large Load Diff
+24 -13
View File
@@ -4,7 +4,10 @@
"private": true,
"description": "The dispute resolution practice of Pouya Lajevardi — Toronto",
"type": "module",
"engines": { "node": ">=22" },
"engines": {
"node": "^22.13.0 || >=24",
"npm": ">=9.6.5"
},
"scripts": {
"dev": "astro dev",
"build": "astro build",
@@ -12,21 +15,29 @@
"check": "astro check",
"lint": "eslint . && prettier --check .",
"format": "prettier --write .",
"lighthouse": "lhci autorun"
"deploy": "bash scripts/deploy-local.sh"
},
"dependencies": {
"astro": "^5.0.0",
"@astrojs/mdx": "^4.0.0",
"@astrojs/sitemap": "^3.2.0",
"sharp": "^0.33.0"
"@astrojs/mdx": "^7.0.8",
"@astrojs/sitemap": "^3.7.3",
"astro": "^7.2.9",
"sharp": "^0.35.4"
},
"devDependencies": {
"@astrojs/check": "^0.9.0",
"typescript": "^5.7.0",
"prettier": "^3.4.0",
"prettier-plugin-astro": "^0.14.0",
"eslint": "^9.0.0",
"eslint-plugin-astro": "^1.3.0",
"@lhci/cli": "^0.14.0"
"@astrojs/check": "^0.9.10",
"@eslint/js": "^10.0.1",
"eslint": "^10.9.1",
"eslint-plugin-astro": "^3.1.0",
"eslint-plugin-jsx-a11y": "^6.10.2",
"globals": "^17.11.0",
"prettier": "^3.9.6",
"prettier-plugin-astro": "^0.14.1",
"typescript": "^6.0.3",
"typescript-eslint": "^8.68.0"
},
"overrides": {
"eslint-plugin-jsx-a11y": {
"eslint": "$eslint"
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+11 -1
View File
@@ -2,8 +2,18 @@
# AI crawlers are deliberately allowed. Being read by an assistant that counsel
# is using to shortlist a neutral is the point. See docs/04-seo-spec.md.
# NOTHING IS DISALLOWED, DELIBERATELY.
# /legal/* is kept out of the index by
# `<meta name="robots" content="noindex,follow">`, which is the directive that
# actually de-indexes. Disallowing them as well would defeat it: a crawler that
# is forbidden to FETCH a URL never reads the noindex on it. The legal pages are
# linked from the footer of every page, so Google discovers them regardless and
# would have listed the bare URLs as "no information available" — the opposite
# of the intent — with the noindex sitting unread behind the wall.
# Add a Disallow only for something that must not be FETCHED. Use noindex for
# something that must not be LISTED. They are different problems.
User-agent: *
Allow: /
Disallow: /legal/
Sitemap: https://adr.smlcompany.ca/sitemap-index.xml
+2 -2
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env bash
# ---------------------------------------------------------------------------
# Collects the AWS resource identifiers this project needs (AGENTS.md Q10).
# Read-only: every call is a list/describe. Nothing is created or changed.
# Read-only: no call creates or mutates anything.
#
# chmod +x scripts/aws-discover.sh
# ./scripts/aws-discover.sh > aws-inventory.txt
@@ -14,7 +14,7 @@ set -uo pipefail
hr() { printf '\n== %s %s\n' "$1" "$(printf '=%.0s' $(seq 1 $((60 - ${#1}))))"; }
try() { "$@" 2>&1 || echo " (failed — check permissions or region)"; }
command -v aws >/dev/null || { echo "AWS CLI not installed. See AWS-Hosting-Guide.md Part 0.5"; exit 1; }
command -v aws >/dev/null || { echo "AWS CLI not installed. See docs/reference/AWS-Hosting-Guide.md Part 0.5"; exit 1; }
hr "Identity and default region"
try aws sts get-caller-identity --output table
+95
View File
@@ -0,0 +1,95 @@
#!/usr/bin/env bash
#
# Local deploy — the fallback while Gitea Actions is unavailable.
#
# Gitea Actions needs `[actions] ENABLED = true` in app.ini and a registered
# act_runner. The instance is jointly administered, so both depend on a second
# administrator (AGENTS.md Q23). Until that lands, this script is how the site
# ships.
#
# It matches .gitea/workflows/deploy.yml on everything that determines what gets
# published: the same guard coverage, `npm run check` before the build, the same
# three sync passes in the same order with the same cache headers, and the same
# invalidation. Any change to one must be made to the other.
#
# Two deliberate differences: it does not run `npm ci` (your node_modules is
# already installed, and CI starts empty), and it refuses to run as user/pouya,
# which CI cannot do because CI has no such credential.
#
# 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
#
# 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.
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
exit 1
fi
export AWS_DEFAULT_REGION="$AWS_REGION"
echo "==> Identity check"
caller=$(aws sts get-caller-identity --query Arn --output text)
echo " $caller"
case "$caller" in
*:user/pouya)
echo >&2
echo "REFUSING: that is the broadly-permissioned personal user." >&2
echo "AGENTS.md §10 — never use user/pouya to deploy. Use the scoped" >&2
echo "deploy user (Q22: not yet created)." >&2
exit 1
;;
esac
echo "==> Type and template check"
npm run check
echo "==> Build"
PUBLIC_SITE_URL="https://adr.smlcompany.ca" \
PUBLIC_INTAKE_ENDPOINT="$INTAKE_ENDPOINT" \
PUBLIC_BOOKING_URL="${BOOKING_URL:-}" \
npm run build
echo "==> Pass 1/3 — hashed assets and fonts (immutable)"
aws s3 sync ./dist "s3://${S3_BUCKET}" \
--exclude "*" \
--include "_astro/*" --include "fonts/*" \
--cache-control "public, max-age=31536000, immutable" \
--no-progress
echo "==> Pass 2/3 — images"
aws s3 sync ./dist "s3://${S3_BUCKET}" \
--exclude "*" \
--include "*.avif" --include "*.webp" --include "*.jpg" \
--include "*.png" --include "*.svg" \
--cache-control "public, max-age=604800" \
--no-progress
echo "==> Pass 3/3 — HTML and the rest (must-revalidate, --delete)"
aws s3 sync ./dist "s3://${S3_BUCKET}" \
--exclude "_astro/*" --exclude "fonts/*" \
--cache-control "public, max-age=0, must-revalidate" \
--delete --no-progress
echo "==> Invalidate CloudFront"
aws cloudfront create-invalidation \
--distribution-id "${CLOUDFRONT_DISTRIBUTION_ID}" \
--paths "/*" >/dev/null
echo "==> Deployed to https://adr.smlcompany.ca ($(git rev-parse --short HEAD))"
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 586 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 251 KiB

+87
View File
@@ -0,0 +1,87 @@
---
/**
* docs/02-design-system.md: variants primary (maroon) · ghost (outlined) ·
* gold (ink background, gold-l text). Renders an <a> or a <button> correctly —
* a link that navigates must be an <a>, whatever it looks like.
*/
interface Props {
href?: string;
variant?: 'primary' | 'ghost' | 'gold';
type?: 'button' | 'submit';
class?: string;
}
const {
href,
variant = 'primary',
type = 'button',
class: className,
} = Astro.props;
const classes = ['btn', `btn-${variant}`, className];
---
{
href ? (
<a href={href} class:list={classes}>
<slot />
</a>
) : (
<button type={type} class:list={classes}>
<slot />
</button>
)
}
<style>
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
/* 44 × 44 is the touch-target floor in docs/02. */
min-block-size: 44px;
padding-block: var(--space-3);
padding-inline: var(--space-5);
border: 1px solid transparent;
border-radius: var(--radius-full);
font-size: var(--text-sm);
font-weight: var(--weight-medium);
letter-spacing: var(--tracking-tight);
line-height: 1.2;
text-align: center;
text-decoration: none;
cursor: pointer;
transition:
background-color var(--dur-hover) var(--ease),
border-color var(--dur-hover) var(--ease),
color var(--dur-hover) var(--ease);
}
.btn-primary {
background: var(--accent);
color: var(--text-inverse);
}
.btn-primary:hover {
background: var(--accent-hover);
color: var(--text-inverse);
}
.btn-ghost {
background: transparent;
border-color: var(--border);
color: var(--text);
}
.btn-ghost:hover {
border-color: var(--accent);
color: var(--accent);
}
.btn-gold {
background: var(--bg-inverse);
color: var(--text-inverse-2);
}
.btn-gold:hover {
background: var(--accent);
color: var(--text-inverse);
}
</style>
+97
View File
@@ -0,0 +1,97 @@
---
/**
* The conversion band — maroon ground, one heading, one CTA. `docs/01` puts it
* at the foot of `/` (item 8) and it belongs at the foot of every page that
* wants an appointment enquiry.
*
* EXTRACTED 2026-08-28 ON `adversarial-reviewer`'S FINDING, and the finding was
* not "this is duplicated" but "this is duplicated AND HAS ALREADY DRIFTED."
* `/` and `/about/` carried identical markup and ~20 identical lines of CSS,
* except `.contact-body` — `52ch` on `/`, `46ch` plus a `line-height` on
* `/about/`. Two call sites, one already divergent, seventeen pages to come.
*
* NO PROPS AND NO SLOTS AT ALL, AND THAT IS A CORRECTION MADE ON REVIEW.
* This shipped with `eyebrow?`, `cta?` and a named `heading` slot, all
* defaulted, and **not one of the two call sites overrode any of them** — the
* exact pattern this repo has already deleted twice with the reasons written
* into the source: `Eyebrow.astro` (*"`tag?: 'p' | 'span'` had zero call sites,
* so its `<span>` branch was unreachable code"*) and `SectionHeading` (*"two
* mechanisms for one job… One way in."*). The header even argued against
* `title`/`body` props and then added `eyebrow`/`cta`. Strings are inlined; add
* a prop when a second call site actually needs one.
*
* The empty `Props` guard stays, though, and it is not decoration: without it
* an Astro component's props widen to `any` and `<ContactBand class="x" />`
* compiles clean while matching nothing — the parent-scope defect `CLAUDE.md`
* records four times, and the one `Pill` was caught by. Verified by probe.
*
* THE `<h2>` IS FIXED AT LEVEL 2 rather than taken as a prop. Every page that
* uses this band has an `<h1>` of its own and top-level sections at `<h2>`, so a
* configurable level here is a way to skip a heading level by accident. If a
* page ever needs otherwise, that is a spec question, not a prop.
*
* `CONTACT.responseTime` is rendered from the constant, never typed: §4 records
* it as **a public commitment** that *"must read identically on `/contact/`, in
* the inquirer confirmation email, and in any bio."*
*/
import Button from './Button.astro';
import Eyebrow from './Eyebrow.astro';
import { CONTACT } from '../data/site';
interface Props {
children?: unknown;
}
const _props: Props = Astro.props;
void _props;
---
{
/* NO BOOKING LINK, and that is not an omission: booking is parked
(AGENTS.md R6) and `CONTACT.bookingUrl` is null, so `/contact/` ships the
intake form with a reserved slot for an embed. Stated rather than silently
dropped, on every page that renders this band. */
}
<section class="section section-accent contact-band">
<div class="wrap contact-inner">
<div class="contact-copy">
<Eyebrow dot>Next step</Eyebrow>
<h2 class="display contact-h">Start with a call.</h2>
<p class="contact-body">
Tell me the shape of the matter and who is involved, and I will tell you
whether I am the right neutral for it. {CONTACT.responseTime}
</p>
</div>
<div class="contact-action">
<Button href="/contact/" variant="gold"
>Request a consultation &rarr;</Button
>
</div>
</div>
</section>
<style>
.contact-inner {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: var(--space-6);
}
.contact-h {
margin-block: var(--space-4) var(--space-4);
font-size: var(--text-4xl);
}
.contact-body {
/* 52ch, which is `/`'s value. The two call sites had drifted to 52ch and
46ch; 52 is the one that shipped first and was reviewed. */
max-inline-size: 52ch;
line-height: var(--leading-body);
}
.contact-action {
/* `0 1 auto` + `min-inline-size: 0`, NOT `flex: none`. `none` is `0 0 auto`,
which refuses to shrink below max-content and pushed the band into
overflow at 320px. This lets the button wrap instead. Measured on `/`. */
flex: 0 1 auto;
min-inline-size: 0;
}
</style>
+104
View File
@@ -0,0 +1,104 @@
---
/**
* docs/02: "Three or four credential slots. NEVER matter counts — AGENTS.md §4."
*
* THE SUBSTITUTION PRINCIPLE IS THE POINT OF THIS COMPONENT (§4). Wherever a
* design wants a "how much / how many", it takes a longer-arc credential
* instead: something already substantial and true at launch that does not grow
* by closing files. `Q.Med` / `JD + ML` / `EN · FA`, with `Q.Arb` fourth.
*
* So this component is not a stat row wearing a different name, and it must
* never be handed one. §4 Forbidden bars every count of matters closed, hours
* mediated or years in practice, plus settlement rates and dollar figures. The
* slots come from src/data/site.ts, which mirrors §4; nothing is typed here.
*
* <dl> RATHER THAN A DIV GRID. Each pair is a term and its description, which
* is what a description list is. It also fixes the reading order: a screen
* reader gets "Q.Arb — Commenced August 2026" as one associated pair, which is
* §4's paired-disclosure condition surviving into assistive technology rather
* than being a visual arrangement only. Wrapping each dt/dd pair in a <div>
* inside <dl> is valid HTML and is what makes the grid tractable.
*/
interface Props {
slots: ReadonlyArray<{ value: string; label: string }>;
}
// No `class` prop: it was declared, never passed, and a parent cannot reach this
// root anyway (SectionHeading carries the measurement; CLAUDE.md the rule).
const { slots } = Astro.props;
---
<dl class="credentials">
{
slots.map((slot) => (
<div class="credential">
<dt class="credential-value">{slot.value}</dt>
<dd class="credential-label">{slot.label}</dd>
</div>
))
}
</dl>
<style>
.credentials {
display: grid;
/* EXPLICIT COLUMNS, BECAUSE `auto-fit` NEVER DELIVERED THE ROW IT CLAIMED.
This read `repeat(auto-fit, minmax(11rem, 1fr))` under a comment saying
"two up on a phone, four up where there is room". `adversarial-reviewer`
measured it: at 390px the resolved template was a SINGLE 342px track and
all four items stacked, running the band ~430px tall — with
`Q.Arb / Commenced August 2026`, which §4's paired-disclosure condition
puts on this page, at the bottom of it. The arithmetic is not subtle: two
tracks at an 11rem (176px) floor plus a 24px gap need 376px and the
container is 342px, so `auto-fit` correctly dropped to one. A
measured-sounding comment that was false is this project's own named
failure mode.
`minmax(0, 1fr)` cannot overflow at any width or any root font size,
which also retires the `min(11rem, 100%)` guard this line briefly
carried — that guard was fixing the overflow symptom of a floor that
should not have been there. */
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-6) var(--space-5);
margin: 0;
padding-block: var(--space-7);
border-block: 1px solid var(--rule);
}
.credential {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.credential-value {
font-family: var(--font-serif);
font-size: var(--text-3xl);
line-height: var(--leading-tight);
letter-spacing: var(--tracking-tight);
color: var(--accent);
}
/* Four across only where four actually fit, measured rather than inferred.
Two 11rem-equivalent tracks plus three 24px gaps need 776px of container;
above 48rem the gutter is 48px each side, so that is a 872px viewport. 56rem
(896px) is the clean token above it. Re-measure if --space-5 or the label
type changes. */
@media (min-width: 56rem) {
.credentials {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
}
.credential-label {
margin: 0;
font-family: var(--font-mono);
font-size: var(--text-xs);
letter-spacing: var(--tracking-wide);
line-height: var(--leading-snug);
text-transform: uppercase;
color: var(--text-meta);
/* "Legal training and engineering practice" is 39 characters and is
deliberately long (AGENTS.md Q37) — it wraps to two lines at every width
and must not be prevented from doing so. Do not add `white-space: nowrap`
here, and do not shorten the label to make the row tidier: the asymmetry
is the honest part. */
text-wrap: pretty;
}
</style>
+63
View File
@@ -0,0 +1,63 @@
---
/**
* docs/02: "Mono label with optional maroon dot."
*
* NO SCOPED STYLES ON PURPOSE. `.eyebrow` and `.eyebrow .dot` are already
* global (global.css) because `.section-inverse .eyebrow` has to recolour them
* from an ancestor, and a scoped rule here would be a second source for the
* same thing. This component exists to stop `<span class="eyebrow">` being
* hand-typed on nineteen pages, not to own the look.
*
* AN EYEBROW IS A LABEL, NEVER A HEADING. It renders <p> or <span>, never
* <h*>: docs/02's accessibility floor forbids skipped heading levels, and an
* eyebrow above an <h2> is exactly where an <h3>-before-<h2> creeps in.
*
* ⚠️ NEVER NAME AN ASTRO PROP `as`. The prop deleted below was briefly called
* `as`, and that name SILENTLY TURNED OFF PROP TYPE-CHECKING for every caller
* of this component. Kept here because the next person to want a dynamic tag
* will reach for `as` first.
*
* `astro check` reported it only as a hint — `ts(6196) 'Props' is declared but
* never used` — which reads exactly like lint noise and is the reason it is
* worth writing down. It is not noise: it is the compiler saying the `Props`
* interface is not attached to anything.
*
* Measured rather than assumed, 2026-08-27. With the prop named `as`,
* `<Eyebrow dot as="h9" bogusProp={1} />` compiled with **0 errors**. The same
* probe against CredentialRow, SectionHeading, PracticeCard and ProcessStep
* produced `ts(2322)` on all four, so the loss was specific to this file.
* Renaming the single identifier `as` to `tag` — one variable changed, nothing
* else — took the file from 1 hint / 0 errors to 0 hints, and the same probe
* now fails correctly: `Type '"h9"' is not assignable to type '"p" | "span" |
* undefined'`. `const { as = 'p' } = Astro.props` is read as a type assertion
* somewhere in the generated TSX and detaches the binding.
*
* A DYNAMIC `<Tag>` FROM A VARIABLE was the first hypothesis for the lost
* binding and it was WRONG — restructuring the template changed nothing, the
* rename fixed it. Recorded so the wrong cause is not re-derived.
*
* DO NOT "FIX" A ts(6196) HINT WITH `Astro.props as Props`. It silences the
* warning inside the component and leaves every call site unchecked, which is
* strictly worse than the warning. If this hint appears on another component,
* probe a bogus prop before believing the props are checked.
*/
interface Props {
/** The maroon dot. Decorative — it is a CSS box, so it is invisible to AT. */
dot?: boolean;
}
/**
* ONE PROP, AND THE OTHER TWO ARE DELETED. `tag?: 'p' | 'span'` had zero call
* sites, so its `<span>` branch was unreachable code and the file carried two
* near-identical templates for it. `class?: string` had zero call sites too, and
* a parent cannot reach this root anyway (see SectionHeading, and CLAUDE.md).
*
* The rename that produced the finding below is kept; only the prop is gone.
*/
const { dot = false } = Astro.props;
---
<p class="eyebrow">
{dot && <span class="dot" aria-hidden="true" />}
<slot />
</p>
+164
View File
@@ -0,0 +1,164 @@
---
/**
* The SML infinity mark.
*
* ⚠️ DELIBERATE, TEMPORARY EXCEPTION TO docs/02's "inline SVG, never a PNG".
* Tracked as AGENTS.md Q38, with a standing reminder (R13) so it cannot become
* permanent by neglect. Read both before changing this file.
*
* WHY A RASTER — and the reason is PAYLOAD, not fidelity. The mark is not a
* stroked curve; it is a shaded ribbon of variable width that twists in three
* dimensions, maroon flowing into champagne, passing over itself at the
* crossing. That is gradient-mesh artwork, not the flat vector paths docs/02
* assumes.
*
* We do hold an SVG — src/assets/brand/sml-logo-source.svg — and **it renders
* faithfully**: rasterised at 8333px it reproduces the master exactly, at the
* same 1.566:1 [verified 2026-08-26]. An earlier version of this comment implied
* it was inadequate artwork. It is not; that was unfair and is corrected here.
* What rules it out is weight and composition: 257,278 bytes against 3,063 for
* the AVIF a Retina browser takes — 84× — and SEVEN embedded base64 PNGs plus
* a 1,225-stop gradient mesh, so inlining it would breach CLAUDE.md's rule
* against base64-inlining images. The exception ends when a vector master lands
* that is both faithful AND light.
*
* WHAT THIS REPLACES, and why it had to go. Until 2026-08-26 this component
* drew a hand-traced cubic path lifted from the old site's loading thumbnail.
* Pouya compared it against the master and it was wrong in three ways; two are
* reproducible from the path itself:
*
* 1. TANGENT, NOT CROSSING. All four branches met the origin at exactly 90°,
* so the loops were mutually tangent on a vertical line rather than
* crossing. At stroke-width 28 that renders as two circles kissing — the
* one thing an infinity mark must not be. Verified by computing the
* tangent vector of every segment at the origin.
* 2. WRONG PROPORTION. The real mark's ink bounding box is 2668 × 1704 =
* 1.5657:1. The traced path measured 1.667:1 ink / 1.597:1 stroked.
* 3. FLAT. Two uniform strokes standing in for a shaded ribbon.
*
* It is deleted rather than kept as a fallback, on Pouya's instruction: a wrong
* mark that renders is worse than a missing one, because it stops looking wrong.
*
* SOURCE OF TRUTH. src/assets/brand/sml-infinity-mark.png is the master tight-
* cropped to its ink bounding box, so the file's aspect ratio IS the mark's and
* layout can be tuned to it directly. Provenance: docs/reference/brand-assets.md.
*/
import { Picture } from 'astro:assets';
import mark from '../assets/brand/sml-infinity-mark.png';
interface Props {
/** Rendered height. Width follows 1.5657:1. */
size?: string;
/**
* Intrinsic width of the 1x variant, in px. RAISE IT FOR A LARGE CALL SITE.
*
* The default 64 is sized for the header (50.1px wide) and footer (56.4px) —
* with densities [1,2,3] that gives 64/128/192 and both are sharp to DPR 3.
* The home page's approach section renders the mark at 225.5px, five times
* larger, and inherited the same 64: measured 1.17x upscale at DPR 1, 2.35x
* at DPR 2, 3.52x at DPR 3. A prop, because the component cannot infer this
* from `size` — `size` may be a `clamp()`.
*/
width?: number;
/**
* `eager` for the two above-the-fold marks; `lazy` for anything below it.
* The component hardcoded `eager`, which is right for a masthead and wrong
* for a 700px-wide decorative anchor two screens down.
*/
loading?: 'eager' | 'lazy';
/**
* Accessible name. Omit for decorative use — the default, and the case at all
* three current call sites: the header and footer marks sit beside the name
* they stand for, and the home page's mark sits beside a paragraph that says
* what it is. Kept because the moment the mark appears without adjacent text
* it needs one, and a component that cannot take an accessible name invites
* `alt=""` on an informative image.
*/
label?: string;
}
/**
* NO `class` PROP, AND ITS REMOVAL IS THE POINT. It existed, had no call site,
* and `class:list` put it on the <img> — so a parent writing
* `<InfinityMark class="foo" />` would get a rule compiled against the PARENT's
* cid that never matches the child's root. That is the exact defect CLAUDE.md
* records twice on this project. The prop was an invitation to reproduce it.
* To position a mark, style a wrapper the parent owns.
*/
const { size = '1.75rem', width, loading = 'eager', label } = Astro.props;
// THE DEFAULT IS SIZED TO THE LARGEST *DEFAULT* CALL SITE — the footer at
// 2.25rem, i.e. 56.4 CSS px wide. With densities [1,2,3] that is 64/128/192 and
// the footer is sharp to DPR 3.
//
// It has now been wrong in both directions, which is why it is a prop:
// - Too big, 2026-08-26: 320px, justified by a 4rem sample on the step-1
// proof sheet (a page step 2 has since deleted), with densities stacked on
// top — so the ladder double-counted its own headroom and every Retina
// device pulled a 640px file into a 56px slot.
// - Too small, 2026-08-27: the home page added a 225.5px call site and
// inherited 64, upscaling 3.52x at DPR 3. Raise `width` at the call site.
//
// PASSING A WIDTH AT ALL IS STILL THE POINT. Without it Astro emits the
// untouched 2668px master as the <img src> fallback: 1,146,406 bytes, which any
// client without AVIF or WebP support would actually download, sitting in dist
// looking like an optimisation had happened.
const INTRINSIC_WIDTH = width ?? 64;
// A NOTE ON MEASURING THIS, because the obvious probe lies. `img.naturalWidth`
// on an image chosen from a `srcset` with an `x` descriptor is DENSITY-
// CORRECTED: the 192px file selected at 3x reports 64. Reading it at DPR 1, 2
// and 3 therefore returns 64 every time, which looks exactly like "the density
// ladder is not being generated at all" — a far more alarming defect than the
// real one. It is generated: 64x41, 128x82, 192x123 on disk, in all three
// formats. Check the files, not naturalWidth. [verified 2026-08-27]
---
<Picture
src={mark}
width={INTRINSIC_WIDTH}
densities={[1, 2, 3]}
formats={['avif', 'webp']}
fallbackFormat="png"
alt={label ?? ''}
loading={loading}
decoding="async"
class="mark"
pictureAttributes={{ style: `block-size:${size}` }}
/>
<style>
/* THE FLEX ITEM IS THE <picture>, NOT THE <img>.
`class:list` lands on the <img>, which Astro's <Picture> wraps in a
<picture> — and that wrapper is what `.brand`'s flex layout actually sizes.
So `flex: none` on .mark reached the wrong box entirely and the mark was
free to be squeezed: measured 28.52 x 32 at 1024px with seven nav items,
against a correct 50.09 x 32 — an aspect of 0.891 where it should be 1.5657.
This is the SAME defect CLAUDE.md already records for <Button> in
SiteHeader — a parent cannot style a child component's root — reintroduced
inside the fix for it, one round later. The bare `picture` selector below is
scoped by Astro's cid, which the emitted markup does carry, and the height
now goes on the wrapper via `pictureAttributes` rather than on the image.
Worse than the bug: the page-level overflow check passed throughout, because
the brand block absorbed the deficit by crushing the logo. "0 overflow at
every width" was true and misleading — it measured the document, not the
elements inside it. The harness now asserts the rendered aspect ratio. */
picture {
display: block;
flex: none;
aspect-ratio: 667 / 426;
inline-size: auto;
}
.mark {
/* 2668 / 1704 reduced — the master's exact ink bounding box, so the box the
layout reserves is the shape that fills it. */
inline-size: 100%;
block-size: 100%;
/* Belt and braces: if anything ever squeezes the box again, the artwork
letterboxes instead of distorting. */
object-fit: contain;
}
</style>
+103
View File
@@ -0,0 +1,103 @@
---
/**
* docs/02: "Small bordered label for designations and sector chips."
*
* A PARENT CANNOT STYLE THIS ELEMENT. Astro does not pass a parent's scope
* attribute to a child component's root, so `<Pill class="chip" />` compiles the
* parent's `.chip` rule against the parent's cid and it never matches — the
* defect CLAUDE.md records for <Button> in SiteHeader, which then recurred with
* <Picture> in InfinityMark. CLAUDE.md names Pill as the next place it will
* happen.
*
* THE HOOK IS A CUSTOM PROPERTY, and that is the one mechanism that legitimately
* crosses the boundary: custom properties inherit. An ancestor sets
* `--pill-border` / `--pill-fg` on ITSELF and this component reads it. No
* :global(), no wrapper div, and no rule that silently does nothing.
*/
/**
* NO PROPS — AND THE EMPTY `Props` INTERFACE IS LOAD-BEARING, NOT DECORATION.
*
* A `class?: string` was declared here once, was never passed, and a parent
* cannot reach this root regardless — see SectionHeading for the measurement and
* CLAUDE.md for the rule. It was deleted, and the deletion was written up as
* "passing one is now a build error". **It was not.** With frontmatter
* containing only comments, an Astro component's props widen to `any`, so
* `<Pill class="chip">` compiled with **zero** errors, matched nothing, and let
* the flex or grid child absorb the difference — silently.
*
* Measured by probe page, `<Eyebrow class>`, `<Pill class>`,
* `<SectionHeading class>`, `<Button bogus>`: `astro check` reported **3 errors
* — Eyebrow, SectionHeading, Button. Nothing for Pill.** Adding the three lines
* below takes the same probe to **4 errors, 0 hints** (and no `ts(6196)`,
* because the interface is referenced by the destructure below).
*
* CLAUDE.md names `Pill` as the next place the parent-scope defect will happen.
* The guard that was documented as protecting it was absent on exactly it.
*
* The custom-property hooks in the style block are how an ancestor influences
* this component: custom properties inherit, which is the one mechanism that
* legitimately crosses the boundary.
*/
/**
* `children` DECLARED, NOTHING ELSE. Getting to this line took two wrong turns
* and both are worth recording, because each looked correct:
*
* - `interface Props {}` — rejected by eslint
* (`@typescript-eslint/no-empty-object-type`), and it would have been the
* wrong tool anyway: `{}` in TypeScript means "any non-nullish value", not
* "no properties".
* - `Record<string, never>` — passes eslint and does reject `class`, but it
* also rejects `children`, so it broke the two REAL call sites
* (`PracticeCard.astro:34` and `/about/`'s arc) while the probe page went
* green on the thing it was testing. A fix that satisfies its own test and
* breaks production is exactly what `/build` Phase 4 warns about.
*
* Slot content arrives as `children`, so `children` is the one permitted
* property and every other prop is an error. Verified by probe: all six
* components now reject `class`, and `<Pill>text</Pill>` compiles. Deleting
* this re-disables checking at every call site.
*/
interface Props {
children?: unknown;
}
const _props: Props = Astro.props;
void _props;
---
<span class="pill"><slot /></span>
<style>
.pill {
display: inline-flex;
align-items: center;
padding-block: var(--space-1);
padding-inline: var(--space-3);
border: 1px solid var(--pill-border, var(--border));
border-radius: var(--radius-full);
font-family: var(--font-mono);
font-size: var(--text-xs);
font-weight: var(--weight-medium);
letter-spacing: var(--tracking-wide);
line-height: 1.4;
text-transform: uppercase;
color: var(--pill-fg, var(--text-meta));
/* `nowrap` UNTIL 2026-08-28, AND IT WAS FINE UNTIL A PILL HAD FOUR WORDS.
`/`'s six pills are one or two words (longest "Cross-cultural").
`/about/` ships `Commenced August 2026`, and at a 200% DEFAULT FONT SIZE
(root 32px — a real browser setting, not page zoom) that pill measured
382.6px wide with its right edge at 430.6 in a 390px viewport:
**41px of document overflow at 390, 111px at 320.** Injecting
`white-space: normal` took 390 to **0** and 320 to **63**, 63 being the
header residual docs/02 already accepts. WCAG 1.4.10 Reflow.
`normal` costs nothing at default size — a pill only wraps when it cannot
fit, which is exactly when wrapping is the right answer.
WHAT IT LOOKS LIKE AT THE EXTREME, recorded so it is not later read as a
new bug: at 320px with root at 32px, `Commenced August 2026` renders
**224 x 119px** inside `border-radius: 999px` — a three-line stadium. It
is ungainly and it is legible, in-viewport, and the alternative was
111px of document overflow. */
white-space: normal;
}
</style>
+116
View File
@@ -0,0 +1,116 @@
---
/**
* docs/02: "Sector chip, heading, one paragraph, arrow link."
*
* ONE LINK PER CARD, AND THE WHOLE CARD IS ITS HIT AREA. The link wraps only
* the heading text, so its accessible name is "Construction & Infrastructure"
* rather than the card's entire contents; a `::after` pseudo-element stretched
* over the positioned card carries the click. Six of these on the home page
* would otherwise be six links each announcing three sentences.
*
* The arrow is `aria-hidden` and outside the link text for the same reason.
*
* THE GRID MUST NOT TRY TO STYLE THIS ROOT. A parent's `.card { block-size:
* 100% }` compiles against the parent's cid and never matches (CLAUDE.md;
* it has now cost twice). The card sizes ITSELF to its grid cell below, so a
* parent only ever needs `display: grid` and `gap` on its own element.
*/
import Pill from './Pill.astro';
interface Props {
href: string;
chip?: string;
title: string;
/** Explicit: docs/02 forbids skipped heading levels. */
level: 2 | 3;
}
// No `class` prop — declared, never passed, unreachable from a parent. The card
// sizes itself to its grid cell instead; see the note above.
const { href, chip, title, level } = Astro.props;
const H = `h${level}` as 'h2' | 'h3';
---
<article class="card">
{chip && <Pill>{chip}</Pill>}
<H class="card-title">
<a class="card-link" href={href}>{title}</a>
</H>
<p class="card-body"><slot /></p>
<span class="card-arrow" aria-hidden="true">&rarr;</span>
</article>
<style>
.card {
position: relative;
/* Sizes itself to its cell — see the note on why the grid cannot do this. */
block-size: 100%;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--space-4);
padding: var(--space-6);
background: var(--bg);
border: 1px solid var(--border);
border-block-start: 2px solid var(--rule);
border-radius: var(--radius-md);
transition:
border-color var(--dur-hover) var(--ease),
box-shadow var(--dur-hover) var(--ease);
}
.card:hover {
border-color: var(--accent);
box-shadow: var(--shadow-md);
}
.card-title {
font-family: var(--font-serif);
font-size: var(--text-xl);
line-height: var(--leading-tight);
letter-spacing: var(--tracking-tight);
}
.card-link {
color: var(--text);
text-decoration: none;
}
.card-link::after {
content: '';
position: absolute;
inset: 0;
border-radius: var(--radius-md);
}
.card:hover .card-link {
color: var(--accent);
}
/* The focus ring belongs on the CARD, not on the heading text: the hit area
is the card, so a ring around three words would point at the wrong box.
`:focus-visible` on the descendant, ring on the ancestor. */
.card:has(.card-link:focus-visible) {
outline: 2px solid var(--focus-ring);
outline-offset: var(--focus-offset);
}
.card-link:focus-visible {
outline: none;
}
.card-body {
margin: 0;
/* Pushes the arrow to the bottom edge so a row of cards aligns on it
whatever the body length. */
flex: 1 1 auto;
font-size: var(--text-base);
line-height: var(--leading-body);
color: var(--text-secondary);
}
.card-arrow {
font-size: var(--text-lg);
line-height: 1;
color: var(--accent);
transition: transform var(--dur-hover) var(--ease);
}
.card:hover .card-arrow {
transform: translateX(var(--space-2));
}
@media (prefers-reduced-motion: reduce) {
.card:hover .card-arrow {
transform: none;
}
}
</style>
+69
View File
@@ -0,0 +1,69 @@
---
/**
* docs/02: "Numbered step, timing, body."
*
* THE NUMBER IS NOT CONTENT. It is `aria-hidden` and supplied by the caller
* rather than by a CSS counter, because the compressed strip on `/` and the
* full page at `/process/` must agree on it. The reading order for assistive
* technology is heading then timing then body; the numeral adds nothing to it
* and would be read as a bare digit before every step.
*
* <ol> IS THE CALLER'S JOB. These are ordered steps, so the parent wraps them
* in an <ol> and this renders the <li>. That keeps "step 3 of 5" available from
* the list semantics instead of from the decorative numeral.
*/
interface Props {
n: number;
title: string;
/** "Day 0", "Days 17". docs/03: "Five steps with real timing." */
timing: string;
}
// No `class` prop — declared, never passed, unreachable from a parent.
const { n, title, timing } = Astro.props;
---
<li class="step">
<span class="step-n" aria-hidden="true">{String(n).padStart(2, '0')}</span>
<h3 class="step-title">{title}</h3>
<p class="step-timing">{timing}</p>
<p class="step-body"><slot /></p>
</li>
<style>
.step {
display: flex;
flex-direction: column;
gap: var(--space-2);
padding-block-start: var(--space-4);
border-block-start: 1px solid var(--border);
}
.step-n {
font-family: var(--font-mono);
font-size: var(--text-xs);
letter-spacing: var(--tracking-wide);
/* --gold-d, not --gold: on cream, gold measures 2.10:1 and gold-d 3.11:1.
docs/02 permits gold-d for LARGE DECORATIVE text only, 24px+ — this is
12px, so neither qualifies and the numeral is maroon. Kept as a comment
because "make the step numbers gold" is the obvious next edit. */
color: var(--accent);
}
.step-title {
font-family: var(--font-serif);
font-size: var(--text-lg);
line-height: var(--leading-tight);
}
.step-timing {
margin: 0;
font-family: var(--font-mono);
font-size: var(--text-xs);
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
color: var(--text-meta);
}
.step-body {
margin: 0;
font-size: var(--text-sm);
line-height: var(--leading-body);
color: var(--text-secondary);
}
</style>
+129
View File
@@ -0,0 +1,129 @@
---
/**
* The single metadata component. Spec: docs/04-seo-spec.md.
*
* "Every page passes through one SEO component. A page without it is not
* finished." — so the length rules in that spec are ENFORCED here rather than
* described. An out-of-range title or description throws at build time and
* names the offending string and its length, the same way src/content.config.ts
* does for article frontmatter. A build that fails on unfinished metadata is a
* correct build.
*/
import { getImage } from 'astro:assets';
import ogDefault from '../assets/og-portrait.jpg';
import { SITE, PORTRAIT } from '../data/site';
export interface Props {
/** The full rendered <title>. Pattern: "<Page> · Pouya Lajevardi". 5060. */
title: string;
/** 140160 characters, unique, written for a human. */
description: string;
/** Overrides the canonical path. Defaults to this page's own URL. */
canonical?: string;
ogType?: 'website' | 'article' | 'profile';
/** 1200×630 source. Defaults to the portrait crop in src/assets.
* `ImageMetadata` is an Astro ambient global — there is nothing to import. */
image?: ImageMetadata;
imageAlt?: string;
/** /legal/* and any temporary page. Emits noindex,follow per docs/04. */
noindex?: boolean;
/**
* Page-appropriate structured data — Person, ProfessionalService, Service,
* Article, BreadcrumbList, FAQPage. Passed in, never invented here: a default
* would be a claim this component is in no position to make.
*/
jsonLd?: unknown;
}
const {
title,
description,
canonical,
ogType = 'website',
image,
imageAlt,
noindex = false,
jsonLd,
} = Astro.props;
const TITLE_MIN = 50;
const TITLE_MAX = 60;
const DESC_MIN = 140;
const DESC_MAX = 160;
const problems: string[] = [];
if (title.length < TITLE_MIN || title.length > TITLE_MAX) {
problems.push(
`title is ${title.length} characters; docs/04-seo-spec.md requires ${TITLE_MIN}${TITLE_MAX}.\n ${JSON.stringify(title)}`,
);
}
if (description.length < DESC_MIN || description.length > DESC_MAX) {
problems.push(
`description is ${description.length} characters; docs/04-seo-spec.md requires ${DESC_MIN}${DESC_MAX}.\n ${JSON.stringify(description)}`,
);
}
if (problems.length > 0) {
throw new Error(
`SEO metadata out of range on ${Astro.url.pathname}\n - ${problems.join('\n - ')}\n` +
` Fix the string. Do not widen the range — these are the lengths Google renders.`,
);
}
// `site` drives canonical URLs, OG tags, and the sitemap. Without it every
// absolute URL below would silently become a relative one.
if (!Astro.site) {
throw new Error(
'astro.config.mjs must set `site`; SEO.astro needs it for canonical and OG URLs.',
);
}
const canonicalUrl = new URL(canonical ?? Astro.url.pathname, Astro.site);
// JPEG on purpose. Page images are AVIF/WebP with a fallback (CLAUDE.md), but
// link-preview crawlers are not browsers — LinkedIn and Slack do not negotiate
// content types, and several still do not decode WebP at all.
const ogImage = await getImage({
src: image ?? ogDefault,
format: 'jpeg',
width: 1200,
height: 630,
});
const ogImageUrl = new URL(ogImage.src, Astro.site);
// JSON.stringify does not escape `<`, so a "</script>" inside any string value
// would close this element early and hand the rest of the payload to the HTML
// parser. Escaping the angle bracket is the whole fix; JSON readers decode it.
const jsonLdText =
jsonLd === undefined ? null : JSON.stringify(jsonLd).replace(/</g, '\\u003c');
---
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="generator" content={Astro.generator} />
<title>{title}</title>
<meta name="description" content={description} />
<link rel="canonical" href={canonicalUrl.href} />
<meta name="robots" content={noindex ? 'noindex,follow' : 'index,follow'} />
<meta property="og:type" content={ogType} />
<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
<meta property="og:url" content={canonicalUrl.href} />
<meta property="og:site_name" content={SITE.name} />
<meta property="og:locale" content={SITE.locale} />
<meta property="og:image" content={ogImageUrl.href} />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta property="og:image:alt" content={imageAlt ?? PORTRAIT.alt} />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={title} />
<meta name="twitter:description" content={description} />
<meta name="twitter:image" content={ogImageUrl.href} />
<meta name="twitter:image:alt" content={imageAlt ?? PORTRAIT.alt} />
{
jsonLdText && (
<script type="application/ld+json" is:inline set:html={jsonLdText} />
)
}
+80
View File
@@ -0,0 +1,80 @@
---
/**
* docs/02: "Eyebrow + display heading + optional lede, one measure."
*
* THE LEVEL IS A REQUIRED DECISION, not a default. docs/02's accessibility
* floor: "One <h1> per page; heading levels never skipped." A component that
* defaulted to <h2> would silently produce an <h2> inside an <h3> section the
* first time one is nested, and nothing would fail. `level` is explicit and
* `astro check` enforces the union.
*/
import Eyebrow from './Eyebrow.astro';
interface Props {
eyebrow?: string;
/** 1 only on the page's single H1. */
level: 1 | 2 | 3;
lede?: string;
dot?: boolean;
}
/**
* NO `class` PROP, AND NO `title` PROP. Both are deletions with reasons.
*
* `class` — a parent CANNOT reach this component's root. It was passed as
* `class="section-head"` from `/` and the page's rule compiled to
* `.section-head[data-astro-cid-<page>]` while the rendered div carried
* `data-astro-cid-<SectionHeading>`. **Measured: `margin-block-end: 0px` and a
* 0px gap on all three call sites** — 48px of intended separation missing, with
* `.display`'s 0.98 line-height putting the glyphs over the top edge of the
* cards below. `astro check` and `eslint` both passed. Fourth instance of the
* defect `CLAUDE.md` records; a `class` prop here is an invitation to a fifth.
* To space this block, wrap it in an element the page owns.
*
* `title` — two mechanisms for one job. The `heading` slot is the general one
* (it takes an italic phrase, which `docs/02` allows once per headline); a
* plain-text prop is the same thing minus a capability. One way in.
*
* `dot` defaults TRUE here and FALSE on `<Eyebrow>`, deliberately: a section
* heading's eyebrow is the pattern the dot was designed for, and a bare
* `<Eyebrow>` is used in places where it would be noise.
*/
const { eyebrow, level, lede, dot = true } = Astro.props;
const H = `h${level}` as 'h1' | 'h2' | 'h3';
const size = level === 1 ? 'size-display' : 'size-section';
---
<div class="heading-block">
{eyebrow && <Eyebrow dot={dot}>{eyebrow}</Eyebrow>}
<H class:list={['display', size]}><slot name="heading" /></H>
{lede && <p class="lede">{lede}</p>}
<slot />
</div>
<style>
.heading-block {
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.size-display {
font-size: var(--text-5xl);
}
.size-section {
font-size: var(--text-4xl);
}
.lede {
max-inline-size: var(--width-prose);
font-size: var(--text-lg);
line-height: var(--leading-relaxed);
color: var(--text-secondary);
}
/* `color` INHERITS across the component boundary, which is how an inverse
section reaches this — but `--text-secondary` resolves to a cream-only
value, so it has to be overridden rather than inherited. Custom properties
DO inherit, so an ancestor setting --lede-color would work too; a global
ancestor selector is fewer moving parts for one rule. */
:global(.section-inverse) .lede {
color: var(--text-inverse);
}
</style>
+318
View File
@@ -0,0 +1,318 @@
---
/**
* docs/01 + docs/02: full sitemap in three columns, contact block, professional
* designations, LinkedIn, privacy, terms, and the entity line.
*
* Every string here that is a fact about Pouya comes from src/data/site.ts,
* which mirrors AGENTS.md §4. Nothing is typed inline — a claim written by hand
* in a component is a claim nobody re-checks against the register.
*
* The copyright line is `© <year> SML Company Ltd` and nothing more. Pouya's
* direction, 2026-08-26. Jurisdiction of incorporation is verified (federal,
* CBCA — AGENTS.md §4) and deliberately not published; place of business is the
* contact block's job, not the entity line's. See the note on SITE.entity.
*/
import {
CONTACT,
CREDENTIALS,
LEGAL_NAV,
PRACTICE_AREAS,
SECONDARY_NAV,
SITE,
} from '../data/site';
import InfinityMark from './InfinityMark.astro';
// Build-time year. A literal would be correct for exactly as long as it takes
// the calendar to turn over; this is correct for as long as the site is
// deployed, which is the same cadence everything else here updates on.
const year = new Date().getFullYear();
// Med-Arb was filtered out of this column for a few hours on 2026-08-26 and is
// RESTORED, because removing it was wrong on three counts and `adversarial-
// reviewer` caught all three. (1) docs/01 specifies this footer as the FULL
// sitemap and lists /med-arb/ as deliberately out of the primary nav, "linked
// contextually" — so dropping it here left the page with no site-wide link at
// all, on a project whose entire premise is crawlability. (2) The label names a
// PAGE, and docs/01 frames that page around the C.Med-Arb arc rather than as a
// present offering, so listing it is not the §4 inference it looked like.
// (3) It settled half of Q35 unilaterally while the other half — Energy and
// Shareholder — stayed in both nav and footer, and the record claimed no
// unilateral action had been taken. Both halves of Q35 go to Pouya together.
const processLinks = [
{ href: '/mediation/', label: 'Mediation' },
{ href: '/arbitration/', label: 'Arbitration' },
...SECONDARY_NAV,
{ href: '/fees/', label: 'Fees' },
];
const aboutLinks = [
{ href: '/about/', label: 'About' },
{ href: '/insights/', label: 'Insights' },
{ href: '/contact/', label: 'Contact' },
];
---
<footer class="site-footer">
<div class="wrap">
<div class="footer-top">
<a class="footer-brand" href="/">
<InfinityMark size="2.25rem" />
<span class="footer-brand-name">{SITE.name}</span>
</a>
{
/* The designation strip carries the credentialing STAGE, not just the
held designation, and that is a §4 Offerings condition rather than a
flourish. The masthead says "Mediation · Arbitration · Toronto" on
every page; §4 permits the arbitration half on the condition that the
site "makes the first while stating the second plainly", and
"neither half may be dropped". Before this line rendered
CREDENTIALS.inProgress, that condition was unmet on every page that
ships — the constant existed in site.ts and was rendered nowhere.
Q.Arb reads as commenced, never as held (§4). */
}
<p class="footer-designation">
{[...CREDENTIALS.designations, ...CREDENTIALS.inProgress].join(' · ')}
</p>
</div>
<div class="footer-grid">
<nav class="footer-nav" aria-label="Footer">
<div class="footer-col">
<h2 class="footer-heading">Practice areas</h2>
<ul role="list">
{
PRACTICE_AREAS.map((area) => (
<li>
<a href={`/practice/${area.slug}/`}>{area.name}</a>
</li>
))
}
<li><a href="/practice/">All practice areas</a></li>
</ul>
</div>
<div class="footer-col">
<h2 class="footer-heading">Process</h2>
<ul role="list">
{
processLinks.map((link) => (
<li>
<a href={link.href}>{link.label}</a>
</li>
))
}
</ul>
</div>
<div class="footer-col">
<h2 class="footer-heading">About</h2>
<ul role="list">
{
aboutLinks.map((link) => (
<li>
<a href={link.href}>{link.label}</a>
</li>
))
}
</ul>
</div>
</nav>
<div class="footer-col footer-contact">
<h2 class="footer-heading">Contact</h2>
<ul role="list">
<li><a href={`mailto:${CONTACT.email}`}>{CONTACT.email}</a></li>
<li><span class="footer-meta">{CONTACT.phoneFallback}</span></li>
<li><span class="footer-meta">{CONTACT.location}</span></li>
<li><a href={CONTACT.linkedin}>LinkedIn</a></li>
</ul>
<p class="footer-response">{CONTACT.responseTime}</p>
</div>
</div>
<div class="footer-bottom">
<p class="footer-entity">&copy; {year} {SITE.entity}</p>
<ul class="footer-legal" role="list">
{
LEGAL_NAV.map((link) => (
<li>
<a href={link.href}>{link.label}</a>
</li>
))
}
</ul>
</div>
</div>
</footer>
<style>
.site-footer {
background: var(--bg-inverse);
color: var(--text-inverse);
padding-block: var(--space-9) var(--space-6);
margin-block-start: var(--space-9);
}
/* WHEN THE LAST SECTION IS ALREADY DARK, DROP THE MARGIN. The 96px above the
footer is page rhythm against cream; where the page ends on the maroon
conversion band or an ink section it becomes a 96px CREAM STRIPE sandwiched
between two dark blocks, which reads as a layout bug rather than as air.
Found by looking at a full-page screenshot of `/`, not by reading the CSS.
`:global()` on the ancestor half is the mechanism: Astro appends this
component's cid to `.site-footer` and leaves the globalised part alone, so
the selector can reach out of the component to <main> without a parent
needing to style a child's root — the thing that cannot be done the other
way round (CLAUDE.md). */
:global(main:has(> :last-child.section-accent)) + .site-footer,
:global(main:has(> :last-child.section-inverse)) + .site-footer {
margin-block-start: 0;
}
.footer-top {
display: flex;
flex-wrap: wrap;
align-items: baseline;
justify-content: space-between;
gap: var(--space-4);
padding-block-end: var(--space-6);
border-block-end: 1px solid var(--rule);
}
.footer-brand {
display: flex;
align-items: center;
gap: var(--space-3);
min-block-size: 44px;
color: var(--text-inverse);
text-decoration: none;
}
.footer-brand-name {
font-family: var(--font-serif);
font-size: var(--text-2xl);
letter-spacing: var(--tracking-tight);
}
.footer-brand:hover .footer-brand-name {
color: var(--text-inverse-2);
}
.footer-designation {
font-family: var(--font-mono);
font-size: var(--text-sm);
letter-spacing: var(--tracking-wide);
/* --gold-l on ink measures 11.09:1 (docs/02). --muted on ink is 3.07:1
and fails, which is why secondary text on dark is never --muted. */
color: var(--text-inverse-2);
}
.footer-grid {
display: grid;
gap: var(--space-7) var(--space-6);
padding-block: var(--space-7);
}
.footer-nav {
display: grid;
gap: var(--space-7) var(--space-6);
}
.footer-heading {
font-family: var(--font-mono);
font-size: var(--text-xs);
font-weight: var(--weight-medium);
letter-spacing: var(--tracking-eyebrow);
text-transform: uppercase;
color: var(--text-inverse-2);
margin-block-end: var(--space-4);
}
.footer-col ul {
display: flex;
flex-direction: column;
/* Gap is small because the 44px target boxes below now supply the spacing.
Measured before this: 18px-tall links with 12px gaps, on the full sitemap
that appears on all nineteen pages. */
gap: var(--space-1);
margin: 0;
padding: 0;
list-style: none;
}
.footer-col a {
display: flex;
align-items: center;
min-block-size: 44px; /* docs/02 accessibility floor */
font-size: var(--text-sm);
color: var(--text-inverse);
text-decoration: none;
}
.footer-col a:hover {
color: var(--text-inverse-2);
text-decoration: underline;
}
/* Not a link, so no target floor — but it shares a column with links and
should sit on the same rhythm. */
.footer-meta {
display: flex;
align-items: center;
min-block-size: 44px;
font-size: var(--text-sm);
color: var(--text-inverse-2);
}
.footer-response {
max-inline-size: 26ch;
margin-block-start: var(--space-4);
font-size: var(--text-sm);
color: var(--text-inverse-2);
}
.footer-bottom {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: var(--space-4);
padding-block-start: var(--space-6);
border-block-start: 1px solid var(--line-dark);
}
.footer-entity {
font-size: var(--text-sm);
color: var(--text-inverse-2);
}
.footer-legal {
display: flex;
gap: var(--space-4);
margin: 0;
padding: 0;
list-style: none;
}
.footer-legal a {
display: flex;
align-items: center;
min-block-size: 44px;
min-inline-size: 44px;
font-size: var(--text-sm);
color: var(--text-inverse-2);
text-decoration: none;
}
.footer-legal a:hover {
color: var(--text-inverse);
text-decoration: underline;
}
/* Focus ring on ink: the maroon ring from global.css is 1.46:1 against the
dark panel and effectively invisible. Gold measures 8.00:1 there. */
.site-footer :focus-visible {
outline-color: var(--rule);
}
@media (min-width: 40rem) {
.footer-nav {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@media (min-width: 60rem) {
.footer-grid {
grid-template-columns: minmax(0, 3fr) minmax(0, 1fr);
}
}
</style>
+470
View File
@@ -0,0 +1,470 @@
---
/**
* docs/01: primary nav is About · Mediation · Arbitration · Practice · Fees ·
* Insights · Contact, with Practice as a dropdown to the six areas and
* /practice/ itself reachable. "Build it as a <details> element or a CSS-only
* disclosure — no JavaScript." It is a <details>.
*
* INSIGHTS IS GATED, NOT HARDCODED. docs/01: "The section stays out of primary
* navigation until at least two pieces are live." src/data/site.ts lists it in
* PRIMARY_NAV, which is the correct end state — so the count is read from the
* collection at build time instead of the rule living in a human's memory. It
* appears by itself when step 7 publishes the second article.
*
* NO MOBILE DISCLOSURE, deliberately. Hiding the nav behind a <details> on
* small screens needs CSS that force-shows the panel again at desktop width,
* and the mechanism browsers use to hide closed <details> content is currently
* mid-migration (`display` override in some engines, `::details-content` in
* others). A wrapped nav row needs none of it and puts every link one tap away.
*/
import { getCollection } from 'astro:content';
import { PRIMARY_NAV, SITE } from '../data/site';
import InfinityMark from './InfinityMark.astro';
import Button from './Button.astro';
const published = await getCollection('insights', ({ data }) => !data.draft);
const showInsights = published.length >= 2;
const items = PRIMARY_NAV.filter(
(item) => item.href !== '/insights/' || showInsights,
);
const path = Astro.url.pathname;
const isCurrent = (href: string) => path === href;
const inSection = (href: string) => path === href || path.startsWith(href);
/**
* THE MASTHEAD TAGLINE IS SUPPRESSED ON `/`, and this closes a step-1 review
* finding rather than being a preference.
*
* SITE.tagline is `Mediation · Arbitration · Toronto`, and docs/01 specifies
* that exact string as the home HERO EYEBROW. At >=76rem the header shows the
* tagline too, so `/` opened with the same six words twice, 300px apart —
* `adversarial-reviewer` #10, left open at step 1 to "decide at step 2".
*
* The hero keeps it, because the hero is where docs/01 puts it and where there
* is copy underneath to qualify it. The masthead drops it, because the masthead
* is the placement Q33-orig objected to in the first place: a line under his
* name with nothing to qualify it, reading as a designation strip.
*
* This only ever REMOVES a claim from one page, so no §4 disclosure condition
* is touched — the footer's designation strip carries `Q.Arb — commenced
* August 2026` on every page including this one.
*/
const isHome = path === '/';
---
<header class="site-header">
<div class="wrap header-inner">
<a class="brand" href="/">
<InfinityMark size="2rem" />
<span class="brand-text">
<span class="brand-name">{SITE.name}</span>
{!isHome && <span class="eyebrow brand-tagline">{SITE.tagline}</span>}
</span>
</a>
<nav class="nav" aria-label="Primary">
<ul class="nav-list" role="list">
{
items.map((item) =>
'children' in item ? (
<li class="nav-item">
<details class="dropdown">
<summary
class="nav-link"
data-section={inSection(item.href) ? 'true' : undefined}
>
{item.label}
</summary>
<ul class="dropdown-panel" role="list">
<li>
<a
href={item.href}
aria-current={isCurrent(item.href) ? 'page' : undefined}
>
Practice overview
</a>
</li>
{item.children.map((area) => (
<li>
<a
href={`${item.href}${area.slug}/`}
aria-current={
isCurrent(`${item.href}${area.slug}/`)
? 'page'
: undefined
}
>
{area.name}
</a>
</li>
))}
</ul>
</details>
</li>
) : (
<li class="nav-item">
<a
class="nav-link"
href={item.href}
data-section={inSection(item.href) ? 'true' : undefined}
aria-current={isCurrent(item.href) ? 'page' : undefined}
>
{item.label}
</a>
</li>
),
)
}
</ul>
</nav>
{
/* The wrapper is not decoration. Astro does not pass a parent's scope
attribute to a child component's root element, so `class="header-cta"`
on <Button> compiles to `.header-cta[data-astro-cid-<header>]` while the
rendered <a> carries only Button's own cid — the rule never matches.
Measured before this wrapper existed: the CTA was not hidden below 640px
despite a rule saying so, and sat 75px short of the right edge on
desktop because `margin-inline-start: auto` never applied. Wrap the
child in an element the parent owns. See CLAUDE.md. */
}
<div class="header-cta">
<Button href="/contact/" variant="primary">Request a consultation</Button>
</div>
</div>
</header>
<style>
.site-header {
position: relative;
z-index: var(--z-header);
background: var(--bg);
border-block-end: 1px solid transparent;
padding-block: var(--space-4);
}
.header-inner {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-4) var(--space-6);
}
/* --- Brand --------------------------------------------------------------- */
/* The tagline is BACK, and the reasoning is worth keeping rather than just
the outcome. It was removed on 2026-08-26 because `Arbitration` under
Pouya's name read as a held capability, and §4 records Q.Arb as merely
commenced. Q33 answered that the same day, and the premise was wrong:
ADR designations are voluntary credentials, not licences, and COMMERCIAL
arbitral appointment in Ontario is not gated behind a designation — so the
constraint was always positional, never legal, and Pouya accepts sole,
party-appointed and co-arbitration work today. See §4 Offerings.
SCOPED 2026-08-27 (Q39). This comment said "Anyone may be appointed an
arbitrator in Ontario", which Pouya checked and found FALSE as a universal:
family arbitration carries prescribed training. It does not touch what this
header renders — the masthead says Mediation · Arbitration · Toronto and
family arbitration is not offered at all — but a false proposition of law
sitting in a source comment is how one reaches a page. §4's Forbidden scope
note: no file in this repo may assert as fact what the register has not
verified, internal or not. */
.brand {
display: flex;
align-items: center;
gap: var(--space-3);
/* 48px, not 44. Still clears the touch floor, and it reserves the height the
two-line brand takes at >=76rem so the sticky header is one constant 81px
across every width where it is sticky — which is what --header-h and
scroll-padding-top are keyed to. One number instead of two bands. */
min-block-size: 48px;
color: var(--accent);
text-decoration: none;
margin-inline-end: auto;
}
.brand-text {
display: flex;
flex-direction: column;
gap: var(--space-05);
}
/* The tagline appears only where there is room for it — see the 76rem block.
Measured: at 11px with 0.18em tracking the string is ~285px wide, and
restoring it under the name pushed the one-row header past its content box
by 18px at 1024 with six items and 84px with seven. The brand name carries
the identity on its own; the tagline is a flourish, and `/` opens with the
same words as the hero eyebrow (docs/01). */
.brand-tagline {
display: none;
font-size: var(--text-2xs); /* 11px — the eyebrow floor in docs/02 */
white-space: nowrap;
}
.brand-name {
font-family: var(--font-serif);
font-size: var(--text-xl);
line-height: var(--leading-tight);
letter-spacing: var(--tracking-tight);
white-space: nowrap;
color: var(--text);
}
.brand:hover .brand-name {
color: var(--accent);
}
/* --- Nav ----------------------------------------------------------------- */
.nav-list {
display: flex;
flex-wrap: wrap;
align-items: center;
/* Row gap deliberately tiny and column gap generous. They were one
shorthand at 32px, so a wrap cost 32px of header height as well as
32px between items. */
row-gap: var(--space-1);
column-gap: var(--space-5);
margin: 0;
}
.nav-item {
position: relative;
}
.nav-link {
display: inline-flex;
align-items: center;
justify-content: center;
/* docs/02 accessibility floor: touch targets >= 44 x 44. Measured at 320px
before this, every nav link was 38px tall and "Fees" was 31px wide. */
min-block-size: 44px;
min-inline-size: 44px;
font-size: var(--text-sm);
color: var(--text-secondary);
text-decoration: none;
cursor: pointer;
}
.nav-link:hover {
color: var(--accent);
}
/* State is carried by a MAROON rule, not a gold one.
Colour alone cannot carry meaning (docs/02) — but neither can a marker
nobody can see. The first version of this used `--rule` (gold) for both
indicators, which measures 2.10:1 on cream: the exact pairing this project
banned, doing the exact job WCAG 1.4.11 needs 3:1 for. Strip an invisible
line and what is left is maroon vs ink-soft, i.e. colour alone again.
`--accent` measures 12.29:1, and 2px is visible without shouting.
Dotted for "you are in this section", solid for "this is the page". */
.nav-link[data-section='true'] {
color: var(--accent);
text-decoration: underline dotted var(--accent);
text-decoration-thickness: 2px;
text-underline-offset: 0.4em;
}
.nav-link[aria-current='page'] {
color: var(--accent);
text-decoration: none;
box-shadow: inset 0 -2px 0 0 var(--accent);
}
/* --- Practice dropdown --------------------------------------------------- */
summary.nav-link {
list-style: none;
}
summary.nav-link::-webkit-details-marker {
display: none;
}
summary.nav-link::after {
content: '';
display: inline-block;
inline-size: 0.4em;
block-size: 0.4em;
margin-inline-start: 0.5em;
border-inline-end: 1px solid currentColor;
border-block-end: 1px solid currentColor;
transform: translateY(-0.15em) rotate(45deg);
transition: transform var(--dur-fast) var(--ease);
}
.dropdown[open] > summary.nav-link::after {
transform: translateY(0.1em) rotate(-135deg);
}
.dropdown-panel {
display: flex;
flex-direction: column;
gap: var(--space-1);
margin: 0;
padding: var(--space-3) 0 var(--space-2) var(--space-4);
border-inline-start: 1px solid var(--rule);
}
.dropdown-panel a {
display: flex;
align-items: center;
min-block-size: 44px;
font-size: var(--text-sm);
color: var(--text-secondary);
text-decoration: none;
}
.dropdown-panel a:hover,
.dropdown-panel a[aria-current='page'] {
color: var(--accent);
}
/* Nav takes its own row below 66rem. No `order` anywhere: reordering flex
items moved the CTA onto row one visually while it stayed last in the DOM,
so a keyboard user tabbed off the brand, down through all 13 nav links
including the practice panel, and back UP to the CTA. WCAG 2.4.3, and
docs/02's "focus order matches visual order". DOM order is now visual
order at every width. */
.nav {
flex-basis: 100%;
}
/* `white-space` INHERITS into the Button, which is how a parent reaches a
child component's root at all here — see the note on why this wrapper
exists. Without it the label wrapped to two lines, standing the header up
taller than it needed to be. `flex: none` stops the row squeezing it. */
.header-cta {
flex: none;
white-space: nowrap;
}
/* Below 66rem the CTA is dropped, not shrunk — and that is the whole reason
no `order` is needed. Wherever the nav has its own row, `/contact/` is
already on it, so the button is a duplicate link paying for itself in
header height. It was previously dropped only below 40rem and reordered in
between, which is what put focus order out of step with visual order. */
@media (max-width: 65.999rem) {
.header-cta {
display: none;
}
}
/* --- Desktop ------------------------------------------------------------- */
/* --- Desktop: 66rem (1024px) ---------------------------------------------
The threshold is measured, and the first measurement was WRONG — worth
recording, because of how it was wrong. It read "32px of clearance at
1024px". That 32px was `.header-inner`'s own `column-gap`, i.e. exactly
zero slack, mistaken for headroom. The true max-content sum at seven items
is 48 + 184.3 brand + 24 margin + 32 gap + 480.1 nav + 32 gap + 197.2 CTA
+ 48 = **1045.6px — 21.6px OVER a 1024px viewport.** Nothing overflowed
only because flexbox shrank the brand and crushed the mark inside it.
Binary search on the built page with a seventh item gives the real
thresholds: the row fits from **1047px**, and the tagline from **1207px**.
66rem (1056) and 76rem (1216) are the clean tokens above each. Below 66rem
the nav takes its own row.
Insights is the seventh item and arrives by itself at build step 7
(SiteHeader gates it on the collection), so a breakpoint verified only
against today's six is a bug with a date on it. It was verified against
seven.
Earlier drafts of this comment carried two different overflow figures for
960px — 34px and 14px — taken before and after the CTA stopped shrinking.
Both were true once and neither is now; they are gone rather than reconciled,
because a number nobody can re-derive is worse than no number.
Sticky only from here up, too. Below this the nav takes a second row and
the header stands at 137px, which is more of a small viewport than a
sticky header is worth. Deviation from docs/02 "Sticky"; recorded there. */
@media (min-width: 66rem) {
.site-header {
position: sticky;
inset-block-start: 0;
}
.header-inner {
flex-wrap: nowrap;
}
.brand {
margin-inline-end: var(--space-5);
}
.nav {
flex-basis: auto;
}
/* nowrap, and flex:none so the nav is never squeezed below its content
width. Measured before this: at 960-1250px the seven-item nav broke to
two rows and the header stood at 141px instead of 81px. */
.nav-list {
flex-wrap: nowrap;
flex: none;
/* 16px from 66rem, widening to 24px at 76rem where there is room for it.
Measured with seven items at every width from 1024px up. */
column-gap: var(--space-4);
}
.header-cta {
margin-inline-start: auto;
}
/* Dropdown becomes an overlay panel rather than an in-flow list. */
.dropdown-panel {
position: absolute;
inset-block-start: calc(100% + var(--space-3));
inset-inline-start: calc(var(--space-4) * -1);
inline-size: max-content;
max-inline-size: 20rem;
padding: var(--space-3) var(--space-4);
background: var(--bg);
border: 1px solid var(--border);
border-block-start: 2px solid var(--rule);
border-radius: var(--radius-md);
box-shadow: var(--shadow-lg);
}
}
/* --- Condense on scroll (docs/02) ----------------------------------------
Scroll-driven, no JavaScript. Two things here are the result of
measurement, not preference, and both are easy to undo by accident.
1. LONGHANDS ONLY — never the `animation` shorthand beside
`animation-timeline`. `scroll()` is not a legal component of the
shorthand, and Lightning CSS folds the two declarations together on
minify, producing `animation: linear both header-condense scroll()`.
That is invalid at computed-value time, so the whole thing is discarded.
It worked in `npm run dev` (unminified) and was dead in `npm run build`.
Verified in the emitted CSS, and `/build` Phase 5 now greps dist for it.
2. NOTHING THAT CHANGES HEIGHT. The header is `position: sticky`, so it
stays in normal flow: its layout box sits at the top of the document
whatever the viewport is showing. Shrinking its padding shortens that
box and lifts every page below it — a scroll-linked layout shift on
every page, against the CLS < 0.05 budget in docs/04. The original
keyframe animated `padding-block` and would have done exactly that.
What is left is honest: the header gains a hairline rule and a shadow once
you scroll off the top. That is a smaller effect than docs/02's "condenses
on scroll", and docs/02 has been amended to say so and why. */
/* The tagline arrives at 76rem. The nav gap does NOT widen here as well:
measured, doing both at one breakpoint put the row 4px past its content box
at exactly 1200px with seven items. Two things growing at the same width is
how a breakpoint gets over-subscribed. */
@media (min-width: 76rem) {
.brand-tagline {
display: block;
}
}
@media (min-width: 80rem) {
.nav-list {
column-gap: var(--space-5);
}
}
@supports (animation-timeline: scroll()) {
@media (min-width: 66rem) and (prefers-reduced-motion: no-preference) {
.site-header {
animation-name: header-lift;
animation-duration: 1ms;
animation-timing-function: linear;
animation-fill-mode: both;
animation-timeline: scroll();
animation-range: 0 6rem;
}
}
}
@keyframes header-lift {
to {
border-block-end-color: var(--border);
box-shadow: var(--shadow-sm);
}
}
</style>
+168
View File
@@ -0,0 +1,168 @@
import { defineCollection } from 'astro:content';
import { glob } from 'astro/loaders';
import { z } from 'astro/zod';
import { PRACTICE_SLUGS } from './data/site';
/**
* `<title>` length, from docs/04-seo-spec.md.
*
* Articles do NOT carry the ` · Pouya Lajevardi` suffix that other pages use.
* The suffix is 18 characters; appending it to a headline that already reads
* 5060 produces 6878, over the spec's own ceiling. Measured against the five
* launch headlines in docs/03-content-spec.md, the suffix rule fails 5 of 5
* and the no-suffix rule passes 4 of 5. A rule that its own content cannot
* satisfy is a rule that will be worked around.
*/
const TITLE_MIN = 50;
const TITLE_MAX = 60;
/**
* Frontmatter dates. Three failure modes this has to close, each found by
* review rather than by reasoning:
*
* - `z.coerce.date()` reads unquoted `20260801` — valid YAML, the obvious slip
* for `2026-08-01` — as epoch milliseconds and yields 1970-01-01, silently.
* - An unanchored regex accepts `2026-13-45` and `2026-08-01 nonsense`, both of
* which produce an `Invalid Date` that reaches `datePublished` in the
* article's JSON-LD or throws at build from `.toISOString()`.
* - `new Date('2026-02-30')` rolls over to 2026-03-02 — a wrong date shipped
* with no error at all, which is worse than a failed build.
*
* So: anchored, date-only, parsed as UTC, and round-tripped to prove the day
* that comes back is the day that was written. A time component is rejected
* rather than guessed at — quoted `2026-08-01T10:00:00` parses as local time
* while the unquoted YAML form parses as UTC, so the same frontmatter would
* mean different instants on a laptop and on a CI runner.
*/
const frontmatterDate = z.union(
[
z.date(),
z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/)
.transform((value, ctx) => {
const parsed = new Date(`${value}T00:00:00Z`);
if (
Number.isNaN(parsed.getTime()) ||
parsed.toISOString().slice(0, 10) !== value
) {
ctx.addIssue({
code: 'custom',
message: `"${value}" is not a real calendar date.`,
});
return z.NEVER;
}
return parsed;
}),
],
{ error: 'Use a date-only ISO value, e.g. 2026-08-01 (no time component).' },
);
/**
* Insights. Content territories are set by the strategy brief §VII and
* restated in docs/03-content-spec.md.
*
* Frontmatter shape follows docs/01-architecture.md.
*
* Astro 5 introduced the Content Layer API and the `src/content.config.ts`
* location; Astro 6 removed the legacy `src/content/config.ts` fallback, so
* collections now declare a `loader` rather than a `type`, and `z` imports from
* `astro/zod`. See AGENTS.md entry (t).
*/
const insights = defineCollection({
loader: glob({ pattern: '**/*.{md,mdx}', base: './src/content/insights' }),
schema: ({ image }) =>
z
.object({
/**
* The headline, and by default the `<title>` too. The refinement below
* enforces 5060 on whichever of this and `seoTitle` is rendered; these
* bounds only catch something wildly wrong.
*/
title: z.string().trim().min(10).max(120),
/**
* Replaces the headline when building the `<title>`. Needed when a
* headline that reads well is outside 5060 — good writing is not an
* error. Deliberately unbounded here: the refinement below is the single
* check, so one mistake produces one message rather than two.
*/
seoTitle: z.string().trim().min(1).optional(),
/** Doubles as the meta description — docs/04-seo-spec.md, 140160. */
description: z.string().min(140).max(160),
publishDate: frontmatterDate,
updatedDate: frontmatterDate.optional(),
/**
* Plural, per docs/01-architecture.md and the topic pills in
* docs/02-design-system.md. A piece can legitimately be both
* regulatory and industry commentary.
*/
topics: z
.array(
z.enum([
'process-explainer',
'regulatory-commentary',
'industry-commentary',
'reflection',
'technical-explainer',
'credentialing',
]),
)
.min(1)
.refine((t) => new Set(t).size === t.length, 'No duplicate topics.'),
practiceAreas: z
.array(z.enum(PRACTICE_SLUGS))
.min(1)
.refine(
(a) => new Set(a).size === a.length,
'No duplicate practice areas.',
),
/** Minutes. docs/02-design-system.md renders it on every ArticleCard. */
readingTime: z.number().int().positive(),
image: image().optional(),
imageAlt: z.string().trim().min(1).optional(),
/**
* INTENT, not yet enforced — there is no /insights/ route to enforce it
* in. The mechanism, when step 7 builds that route: filter drafts out of
* `getCollection('insights', ...)` so no page is generated, which keeps
* them out of the build, the index, and the sitemap in one move. The
* sitemap filter in astro.config.mjs cannot see collection data and is
* not the right place for it. See docs/04-seo-spec.md.
*/
draft: z.boolean().default(true),
/** Every article is reviewed by Pouya before publication — D9. */
reviewedByPouya: z.boolean().default(false),
})
.superRefine((data, ctx) => {
const rendered = data.seoTitle ?? data.title;
if (rendered.length < TITLE_MIN || rendered.length > TITLE_MAX) {
ctx.addIssue({
code: 'custom',
path: ['seoTitle'],
message:
`The <title> would be ${rendered.length} characters ` +
`("${rendered}"). docs/04-seo-spec.md requires ${TITLE_MIN}${TITLE_MAX}. ` +
`Either adjust the headline or set seoTitle.`,
});
}
if (data.image && !data.imageAlt) {
ctx.addIssue({
code: 'custom',
path: ['imageAlt'],
message:
'imageAlt is required when image is set — alt text is a build ' +
'requirement, not a polish pass (CLAUDE.md).',
});
}
if (!data.draft && !data.reviewedByPouya) {
ctx.addIssue({
code: 'custom',
path: ['reviewedByPouya'],
message:
'Every article is reviewed by Pouya before publication (D9). ' +
'Set reviewedByPouya: true, or keep draft: true.',
});
}
}),
});
export const collections = { insights };
-42
View File
@@ -1,42 +0,0 @@
import { defineCollection, z } from 'astro:content';
import { PRACTICE_AREAS } from '../data/site';
const practiceSlugs = PRACTICE_AREAS.map((a) => a.slug) as [string, ...string[]];
/**
* Insights. Content territories are set by the strategy brief §VII and
* restated in docs/03-content-spec.md.
*
* Every piece must link to at least one practice-area page — that is what
* turns the blog into ranking power for the pages that convert.
*/
const insights = defineCollection({
type: 'content',
schema: ({ image }) =>
z.object({
title: z.string().max(70),
description: z.string().min(70).max(160), // doubles as the meta description
publishDate: z.date(),
updatedDate: z.date().optional(),
topic: z.enum([
'process-explainer',
'regulatory-commentary',
'industry-commentary',
'reflection',
'technical-explainer',
'credentialing',
]),
practiceAreas: z.array(z.enum(practiceSlugs)).min(1),
image: image().optional(),
imageAlt: z.string().optional(),
/** Drafts are excluded from the build, the index, and the sitemap. */
draft: z.boolean().default(true),
/**
* Every article is reviewed by Pouya before publication (AGENTS.md D9).
* An article with draft:false and reviewedByPouya:false is a bug.
*/
reviewedByPouya: z.boolean().default(false),
}),
});
export const collections = { insights };
+7
View File
@@ -0,0 +1,7 @@
Articles live here as .mdx.
The directory is tracked so the glob loader's `base` in src/content.config.ts
resolves. That does not silence the build warning, it only downgrades it:
without the directory Astro logs "The base directory ... does not exist"; with
it, "No files found matching "**/*.{md,mdx}"". Both clear the moment the first
article lands.
+185
View File
@@ -0,0 +1,185 @@
/**
* JSON-LD builders. Spec: docs/04-seo-spec.md §Structured data.
*
* WHY THIS FILE EXISTS. Structured data is a factual claim in machine-readable
* form, and it is the one place a claim can be wrong without any human reading
* it. AGENTS.md §4 governs it exactly as it governs visible copy — docs/04 says
* so in as many words: "Marking an unheld credential as held in structured data
* is a misrepresentation that happens to be machine-readable."
*
* So the Person node is built ONCE, here, from src/data/site.ts, and every page
* that needs it references the same @id. `/` (step 2) and `/about/` (step 3)
* would otherwise hand-type it twice.
*
* THREE THINGS ARE DELIBERATELY ABSENT. Each is a decision, not an omission:
*
* 1. `LegalService` — NEVER. docs/04: schema.org defines it as a business
* providing legal advice and *representation*, which asserts in
* machine-readable form exactly what D13 bars. `ProfessionalService`.
* 2. `worksFor` — omitted. Populating it either names the boutique (D16) or
* misstates the employer. `jobTitle` carries the role on its own.
* 3. `priceRange` — omitted until `/fees/` exists (build step 9). docs/04
* gates it on that page being real.
*
* AND Q.Arb IS NOT IN `hasCredential`. It commenced August 2026 and is not
* held. Q.Med is. That asymmetry is the whole point of the property.
*/
import { CONTACT, CREDENTIALS, ROLE, SITE } from './site';
/** Stable node ids, so pages cross-reference rather than duplicate. */
export const PERSON_ID = `${SITE.url}/about/#person`;
export const SERVICE_ID = `${SITE.url}/#practice`;
/**
* `Person`. docs/04 anchors this on /about/ and references it site-wide, which
* is why PERSON_ID points at /about/ regardless of which page emits the node.
*
* `description` IS OFFERING-SHAPED, NOT ROLE-SHAPED, and that was a correction.
* It read "Mediator and commercial arbitrator in Toronto", which `claims-auditor`
* flagged 2026-08-27: §4 verifies that he **accepts** arbitral appointments and
* separately verifies "multiple completed sole mediations" — there is **no
* counterpart row for a completed arbitration**, so "arbitrator" as a practised
* role asserted something the register does not hold.
*
* AND IT CARRIES THE Q.Arb STAGE. Same finding, and it is the sharper half:
* §4 Offerings permits the arbitration offering only while the site states the
* stage of the arc plainly — "neither half may be dropped". The VISIBLE page
* satisfied that with the fourth credential slot; this graph asserted
* arbitration twice (here and in `serviceType`) and stated the stage nowhere.
* A machine-readable claim is still a claim.
*
* `hasCredential` stays Q.Med-only regardless — the stage belongs in prose, not
* in a field that means "holds".
*
* Every clause traces: Q.Med [verified], the JD [verified], engineering
* practice [verified], Toronto [verified], the Q.Arb pathway commenced August
* 2026 [verified]. It claims no licensure and implies none — D13 bars
* implication as hard as assertion, and a crawler summary is a place where an
* implication travels unedited.
*/
export function personNode(imageUrl?: string) {
return {
'@type': 'Person',
'@id': PERSON_ID,
name: SITE.name,
url: `${SITE.url}/about/`,
jobTitle: ROLE.title,
description:
'Mediator in Toronto, accepting commercial arbitration appointments. ' +
'Q.Med designation through ADRIC and ADRIO; the Q.Arb pathway commenced ' +
'in August 2026. JD, Bond University; practising machine-learning and ' +
'infrastructure engineer.',
knowsLanguage: ['en', 'fa'],
alumniOf: { '@type': 'CollegeOrUniversity', name: 'Bond University' },
// Q.Med only. See the header comment.
hasCredential: {
'@type': 'EducationalOccupationalCredential',
name: CREDENTIALS.designations[0],
credentialCategory: 'Professional designation',
recognizedBy: [
{ '@type': 'Organization', name: 'ADR Institute of Canada' },
{ '@type': 'Organization', name: 'ADR Institute of Ontario' },
],
},
sameAs: [CONTACT.linkedin],
email: `mailto:${CONTACT.email}`,
...(imageUrl ? { image: imageUrl } : {}),
};
}
/**
* `ProfessionalService` for the home page, with the Person as `provider`.
*
* `serviceType` lists what §4 Offerings actually records as offered now —
* mediation, arbitration, med-arb. **Arbitration is scoped to commercial**
* (Q39, 2026-08-27): family arbitration in Ontario carries prescribed training
* and is separately not offered, so an unscoped "Arbitration" here would be the
* struck universal reappearing in a machine-readable field where nobody reads
* it. Do not widen these strings without a row to widen them from.
*
* No `priceRange`, no `aggregateRating`, no `review` — the last two have no
* underlying data and §4 Forbidden bars the fabricated testimonial that the
* previous site carried.
*/
export function professionalServiceNode(imageUrl?: string) {
return {
'@type': 'ProfessionalService',
'@id': SERVICE_ID,
name: `${SITE.name} — Mediation & Arbitration`,
url: `${SITE.url}/`,
description:
'Commercial mediation and arbitration for construction, technology, ' +
'energy, insurance, shareholder and cross-border disputes. Toronto, ' +
'by appointment. Q.Med held; the Q.Arb pathway commenced August 2026.',
provider: { '@id': PERSON_ID },
areaServed: [
{ '@type': 'City', name: 'Toronto' },
{ '@type': 'AdministrativeArea', name: 'Ontario' },
],
serviceType: [
'Mediation',
'Commercial arbitration',
'Mediation-arbitration (med-arb)',
],
availableLanguage: ['en', 'fa'],
email: `mailto:${CONTACT.email}`,
...(imageUrl ? { image: imageUrl } : {}),
};
}
/**
* The home page's graph: the service and the person it resolves to.
*
* A `@graph` rather than two `<script>` blocks, so `provider: {'@id': …}`
* resolves inside one document instead of relying on a crawler joining two.
*/
export function homeGraph(imageUrl?: string) {
return {
'@context': 'https://schema.org',
'@graph': [professionalServiceNode(imageUrl), personNode(imageUrl)],
};
}
/**
* `/about/`'s graph — build step 3. This is where PERSON_ID actually resolves:
* every other page references `/about/#person`, and until now nothing served it
* from that URL.
*
* ONE NODE, AND THREE ADDITIONS WERE CONSIDERED AND DECLINED. Each is a
* decision rather than an omission, recorded so the next reader does not
* "complete" it:
*
* 1. `ProfilePage` as a wrapper, with `mainEntity` → Person. Accurate, and
* Google documents it. Declined: docs/04's structured-data table lists
* `Person` for this page and does not list `ProfilePage`, and a type not in
* the spec is a deviation that needs a reason. The marginal gain is not
* one. Revisit in docs/04, not here.
* 2. `BreadcrumbList`. docs/04 requires it on "all nested pages" and says it
* must MATCH VISIBLE BREADCRUMBS. `/about/` is one hop from the root, has
* no visible breadcrumb, and the header nav marks it as current — so
* emitting one would assert a navigation structure the page does not show.
* Breadcrumbs begin at the two-level pages: `/practice/<area>/`, `/insights/<slug>/`.
* 3. `memberOf` for the four memberships. Declined on R10 / **Q44** — and
* the visible page reached the same answer one round later, which is worth
* recording: `/about/` now publishes **no memberships group at all**,
* because R10 is a prohibition on shipping such a page and the
* re-confirmation was not obtained. So this field is not a stricter
* standard than the page; it is the same one.
*
* Two earlier versions of this comment were wrong on the facts. They said
* "the page publishes them visibly" (it does not, as of 2026-08-28) and
* "all four renew yearly" — §4 records yearly renewal for **the OBA
* sections and the CTF only** and says nothing about ADRIC or ADRIO. The
* widened form had propagated to four files.
*
* The reason a machine-readable membership claim is worse than a visible
* one stands regardless: a list on a page is corrected by editing the page,
* while a scraped claim is cached and re-served by systems that never
* re-read it. OCNI lapsed quietly once already. Add this when Q44 closes.
*/
export function aboutGraph(imageUrl?: string) {
return {
'@context': 'https://schema.org',
'@graph': [personNode(imageUrl)],
};
}
+424 -21
View File
@@ -11,7 +11,31 @@ export const SITE = {
tagline: 'Mediation · Arbitration · Toronto',
url: 'https://adr.smlcompany.ca',
locale: 'en_CA',
entity: 'SML Company Ltd. · Ontario, Canada',
/**
* The footer's copyright line, in full. Q30 is CLOSED.
*
* Two facts were being conflated in the string this replaces
* (`'SML Company Ltd. · Ontario, Canada'`), which read as a jurisdiction of
* incorporation and named the wrong one:
*
* - Jurisdiction of incorporation — **federal, under the CBCA**
* `[verified 2026-08-26 — Pouya]`, recorded in AGENTS.md §4.
* - Place of business — Toronto, Ontario. That is `CONTACT.location`,
* and it belongs in the contact block, not in the entity line.
*
* **Neither appears in the footer.** Pouya's direction, 2026-08-26: the line
* is `© <year> SML Company Ltd` and nothing else. The incorporation fact is
* verified and available — it is simply not published. Do not "complete" this
* line by adding it back.
*
* No corporation number: we do not have one and the line does not need one.
*
* Spelling note so it does not read as a typo and get "fixed": AGENTS.md §4
* writes *SML Company Ltd.* with a terminal period. Pouya specified the
* rendered footer string twice, both times without it. His wording governs
* what ships.
*/
entity: 'SML Company Ltd',
} as const;
/**
@@ -32,31 +56,169 @@ export const CREDENTIALS = {
'Stitt Feld Handy — negotiation and ADR workshop series',
],
languages: ['English', 'Farsi'],
/** [verified 2026-08-26]. NOT OCNI (lapsed) and NOT the Law Society —
* listing the LSO implies licensure, which D13 bars. Do not add either. */
/**
* [verified 2026-08-26 — Pouya, AGENTS.md Q28 and the CTF addition of the same
* date] — and FOR NOW.
*
* WHAT §4 ACTUALLY SAYS ABOUT RENEWAL, because a widened version of it reached
* a public page. §4: *"Both the OBA sections and the CTF renew yearly."* It
* says **nothing** about ADRIC's or ADRIO's renewal period. An earlier form of
* this comment read "Both the OBA sections and the Canadian Tax Foundation
* renew yearly, so every line below is a fact with a shelf life", which is two
* claims joined by a "so" that does not follow — and the widened form
* ("all four renew annually") then propagated into `schema.ts`, into
* `/about/`, and into §9 Q44. Exactly the SES-DKIM duplication shape: the copy
* that goes stale is the one nobody re-reads, and this copy became public copy.
*
* **NOT PUBLISHED AS OF 2026-08-28 — R10 / Q44.** R10 requires a
* re-confirmation *"before any page listing memberships ships"*, `/about/` is
* that page, and the re-confirmation is a fact only Pouya holds. It was not
* obtained, so `/about/` ships its Credentials section WITHOUT a memberships
* group and carries a `TODO(pouya)`. Do not render this array on a public page
* until Q44 closes.
*
* NOT OCNI (lapsed — §4: "not current, do not publish") and NOT the Law
* Society: listing the LSO implies licensure, which D13 bars. Do not add
* either.
*/
memberships: [
'ADR Institute of Canada (ADRIC)',
'ADR Institute of Ontario (ADRIO)',
'Ontario Bar Association — Construction & Infrastructure, ADR, and Civil Litigation sections',
'Canadian Tax Foundation',
],
} as const;
/** The three credential slots. Never matter counts — AGENTS.md §4. */
export const CREDENTIAL_ROW = [
{ value: 'Q.Med', label: 'ADRIC / ADRIO designation' },
{ value: 'JD + ML', label: 'Law and engineering' },
{ value: 'EN · FA', label: 'Bilingual practice' },
] as const;
/**
* The Toronto boutique is NEVER named — AGENTS.md D16. Use this string.
* Do not infer a name from an email domain or anywhere else.
*/
export const BOUTIQUE = 'a Toronto litigation and ADR boutique' as const;
/**
* The boutique role and the litigation line — the two highest-risk strings on
* the site, so they live here rather than being typed into a page.
*
* Neither had a constant until 2026-08-26, which meant `/about/` (step 3) and
* the `Person` JSON-LD (docs/04) were both going to hand-type them. A claim
* written by hand in a component is a claim nobody re-checks against §4 — and
* these are the two where the wording IS the compliance.
*/
export const ROLE = {
/** §4 verbatim. docs/04: this is `jobTitle` in the Person JSON-LD, and
* `worksFor` is OMITTED — populating it either names the boutique (D16) or
* misstates the employer. */
title: 'Director of Firm Operations', // [verified 2026-08-25 — strategy brief §I]
/** Always rendered with BOUTIQUE, never with a firm name (D16). */
at: BOUTIQUE,
/**
* D13's approved phrasing, and the only approved phrasing. The alternative
* he approved is 'involvement in litigation and ADR matters'.
*
* NEVER "practice" in this context — that is the exact word D13 bars in the
* exact context it bars it, and §4 records that this register itself once
* carried the wrong word here while quoting the strategy brief verbatim.
* "Practice" describing Pouya's OWN ADR practice is correct and unaffected.
*
* Explicitly interim — AGENTS.md R1. Raise it; do not let it settle in.
*/
litigationLine: 'active litigation exposure', // [verified 2026-08-26 — D13]
/** The matter types behind that exposure. §4 verbatim; do not extend this
* list without a §4 row to extend it from. */
litigationAreas: [
'personal injury',
'construction',
'regulatory (POA)',
'insurance (SABS)',
],
} as const;
/**
* THE Q41(a) SENTENCE. It lives here for the reason `ROLE` above lives here:
* *"these are the two where the wording IS the compliance."*
*
* It was hand-typed into `/` and then into `/about/`, and **the two copies had
* already diverged** — `/` used a comma ("one side, a working engineering
* practice on the other"), `/about/` used full stops — within the same session
* that wrote both. This is the sentence Q41(a) makes responsible for making the
* licence implication *"impossible rather than merely absent"*, so a silent
* divergence in it is the highest-consequence drift on the site.
*
* Pouya's ruling, 2026-08-27, kept because it is the finding rather than the fix:
*
* "The implication test applies everywhere, not just to labels. Prose has more
* room, so it is easier to satisfy: state the asymmetry explicitly rather than
* relying on a parallel construction to carry it."
*
* Deleting the parallel is only half of it — a reader supplies the missing
* symmetry from silence, and for the legal half the missing half is a licence.
* Naming that half **training** is what forecloses it. Do not tidy this into a
* parallel, do not shorten it to fit a layout, and do not retype it into a page.
*/
export const ASYMMETRY_LINE =
'The two halves are not the same kind of thing, and the asymmetry is the ' +
'honest part. A law degree on one side. A working engineering practice on ' +
'the other. One is training I hold. The other is work I still do.';
/** The three credential slots. Never matter counts — AGENTS.md §4. */
export const CREDENTIAL_ROW = [
{ value: 'Q.Med', label: 'ADRIC / ADRIO designation' },
/**
* Q37 CLOSED 2026-08-27. This label read 'Law and engineering' and it is now
* 'Legal training and engineering practice'. Pouya's reasoning, kept because
* it is the finding rather than the fix:
*
* "The parallel was doing the implying — a degree and a practice under one
* noun. The asymmetry is the honest part."
*
* A JD is a degree. Engineering is a practice, and a verified one (§4).
* Setting the two in parallel invited the reader to supply the symmetry, and
* for 'Law' the missing half is a licence — which D13 bars by implication as
* hard as by assertion.
*
* IT IS DELIBERATELY LOPSIDED AND LONGER. Do not tidy it back into a
* parallel, and do not shorten it to fit a layout; change the layout.
*/
{ value: 'JD + ML', label: 'Legal training and engineering practice' },
{ value: 'EN · FA', label: 'Bilingual practice' },
] as const;
/**
* The FOURTH credential slot — separate on purpose, so a three-slot layout
* cannot be handed four by accident and a page has to opt in.
*
* docs/03: 'Fourth slot where the layout has one: Q.Arb — commenced August
* 2026. Use that wording, not "in progress"' — the weaker form drifts toward
* 'nearly complete', which §4 Forbidden bars outright.
*
* REQUIRED on any page that offers arbitration, not decorative. §4 Offerings
* carries a paired-disclosure condition: the site may make the offering only
* while 'stating the second plainly', and 'neither half may be dropped'. The
* footer's designation strip satisfies it site-wide; a page whose opening
* sentence says 'arbitrator' should not make the reader scroll to the footer
* for the stage.
*
* The em-dash in docs/03's string is carried by the layout (value over label),
* not by the text. Same wording, same pairing.
*/
export const CREDENTIAL_ROW_ARB = {
value: 'Q.Arb',
label: 'Commenced August 2026',
} as const; // [verified 2026-08-26 — Pouya]
/** Analytics: privacy-first and cookieless (D15). No GA4, no consent banner. */
export const ANALYTICS = {
provider: 'plausible' as 'plausible' | 'fathom' | null,
/**
* Q31 CLOSED — **Plausible**, decided rather than defaulted. Pouya checked
* 2026-08-26: Fathom is Canadian-owned but stores non-EU traffic on US
* servers, isolating in the EU only for EU visitors; Plausible keeps all data
* in the EU. For a practice whose privacy posture is part of the offer,
* EU-only beats US-hosted. D15 amended to match.
*
* The union type stays — `/legal/privacy/` has to name the processor, and a
* change of processor is a copy change on that page, not just a config edit.
*/
provider: 'plausible' as 'plausible' | 'fathom',
domain: 'adr.smlcompany.ca',
} as const;
@@ -67,7 +229,13 @@ export const CONTACT = {
phone: null as string | null, // [verified 2026-08-26]
phoneFallback: 'By scheduled call',
location: 'Toronto · Ontario · By appointment',
responseTime: 'Inquiries are answered within one business day.',
/** [verified 2026-08-26 — Pouya, AGENTS.md Q27]. A PUBLIC COMMITMENT: this
* wording must match /contact/, the inquirer confirmation email, and any
* bio. Change it here and sweep — never edit one copy. */
responseTime: 'Inquiries are answered within two business days.',
/** The same fact in sentence-fragment form, for the confirmation email and
* any inline use. Derived, so the two cannot drift. */
responseTimeShort: 'within two business days',
linkedin: 'https://www.linkedin.com/in/pouyalajevardi/', // [verified 2026-08-26]
/** Booking parked 2026-08-26 (AGENTS.md R6). Build /contact/ with the intake
* form and a reserved slot so an embed drops in later without a rebuild. */
@@ -84,7 +252,8 @@ export const PORTRAIT = {
/** Shown on /contact/ and with the booking embed. Do not reword casually. */
export const NO_RETAINER_NOTICE =
'Submitting this form does not create a retainer, does not appoint a neutral, ' +
'and does not itself establish a mediatorparty relationship.';
'does not itself establish a mediatorparty relationship, and does not itself ' +
'create a conflict check.';
/**
* Rate card — AGENTS.md D14, confirmed by Pouya 2026-08-26.
@@ -110,7 +279,15 @@ export const FEES = {
documentsOnlyComplex: 9500, // flat
// No tribunal-secretary rate — removed by Pouya 2026-08-26.
},
// ENE, settlement counsel, dispute-system design, pre-dispute technical advisory
/**
* THREE services at this rate, not four. Q42 CLOSED 2026-08-27 by Pouya:
* early neutral evaluation, dispute-system design, and pre-dispute technical
* advisory each gained a §4 Offerings row; **settlement counsel was REMOVED**
* — his words: *"Settlement counsel acts FOR a party in negotiation. That is
* a partisan role, and putting it on a site that (a) sells neutrality and
* (b) asserts no licensure under D13 is wrong twice over."* Do not restore
* it, and do not price it.
*/
hourly: 500, // [verified 2026-08-26]
cancellation: [
{ window: 'More than 30 days before', fee: 'No fee. Disbursements only.' },
@@ -128,15 +305,241 @@ export const FEES = {
],
} as const;
export const PRACTICE_AREAS = [
{ slug: 'construction', name: 'Construction & Infrastructure', chip: 'Construction' },
{ slug: 'technology', name: 'Technology, AI & Data', chip: 'Technology' },
{ slug: 'energy', name: 'Energy, Grid & Regulatory', chip: 'Energy' },
{ slug: 'insurance', name: 'Insurance, SABS & LAT', chip: 'Insurance' },
{ slug: 'shareholder', name: 'Shareholder & Family Business', chip: 'Shareholder' },
{ slug: 'cross-cultural', name: 'Cross-Border & Diaspora', chip: 'Cross-cultural' },
/**
* Slugs as their own literal tuple so consumers keep the union type.
* Deriving them with `.map()` and casting to `[string, ...string[]]` widens
* them back to `string`, and a mistyped slug then survives `astro check`.
*/
export const PRACTICE_SLUGS = [
'construction',
'technology',
'energy',
'insurance',
'shareholder',
'cross-cultural',
] as const;
export type PracticeSlug = (typeof PRACTICE_SLUGS)[number];
/**
* The six areas, with the one-line blurb each card renders.
*
* THE BLURBS LIVE HERE, not in the pages, because `/` and `/practice/` both
* render them and two copies of a claim-bearing sentence is one copy that will
* eventually be wrong. Same reasoning as ROLE and CREDENTIAL_ROW above.
*
* EVERY BLURB IS DISPUTE TYPES, NOT HISTORY. docs/03: "Frame as positioning,
* not as history" — 'Built to facilitate ... on Ontario's megaproject pipeline',
* never 'extensive experience resolving'. AGENTS.md §4 (Q35, 2026-08-27) makes
* that condition 2 of the publication gate for naming a practice area at all.
* A blurb that claims volume fails the gate even though the label passes.
*
* Nothing here may carry a count, a value, a settlement rate, or a superlative
* (§4 Forbidden). Dispute types are not claims of caseload.
*/
export const PRACTICE_AREAS = [
{
slug: 'construction',
name: 'Construction & Infrastructure',
chip: 'Construction',
blurb:
'Liens, delay and change-order claims, scheduling, subcontract and ' +
"deficiency disputes. Built for Ontario's megaproject pipeline.",
},
{
slug: 'technology',
name: 'Technology, AI & Data',
chip: 'Technology',
blurb:
'Software contracts, SLA and MSA breakdowns, data residency and ' +
'processing, AI vendor diligence, IP and licensing.',
},
{
slug: 'energy',
name: 'Energy, Grid & Regulatory',
chip: 'Energy',
blurb:
'Grid connection and allocation, leave-to-construct, ' +
'proponentmunicipality disputes, IESO market participation.',
},
{
slug: 'insurance',
name: 'Insurance, SABS & LAT',
chip: 'Insurance',
/**
* Q41(c) CLOSED 2026-08-27 — and the verification changed the wording again.
*
* `LAT pre-hearing mediation` (a SEARCH INTENT in `docs/01`, never an
* offering) must never be published. Pouya's ruling: *"imprecise and must
* not imply appointment by the tribunal. Verify against LAT's own materials
* how its case-conference process is conducted and who conducts it."*
*
* Verified 2026-08-28 against the LAT Rules and the LAT-AABS process page,
* both extracted into `docs/reference/lat-case-conference.md`:
*
* - Rule 2.4: *"'Case Conference' has the same meaning as 'Pre-Hearing
* Conference' as defined in the SPPA."* **"Pre-hearing" is the
* Tribunal's own label**, and what it names is a case conference.
* - Rule 14.3: a **Member** presides, and is then disqualified from the
* hearing panel. Rule 14.6: parties must attend. The neutral is the
* Tribunal's, and a privately retained one cannot be appointed to it.
* - The Rules contain **zero** occurrences of `mediat` or `arbitrat`
* (0 in 66,593 characters). The concept is not in them.
*
* The interim read "private mediation of matters before the LAT", which is
* ambiguous in the one word that matters: `before` reads as *pending at* as
* easily as *prior to*. Replaced with the temporal frame the Tribunal's own
* page endorses — *"you may want to consider negotiation or mediation
* services... before filing at the LAT-AABS, and continuing... after a
* claim has been filed."*
*
* `/practice/insurance/` at step 5 must say the mediation is PRIVATE and is
* not the Tribunal's case conference.
*/
blurb:
'Accident benefits and SABS entitlement, MIG disputes, and private ' +
'mediation alongside a LAT application, before filing or after.',
},
{
slug: 'shareholder',
name: 'Shareholder & Family Business',
chip: 'Shareholder',
/**
* "Family Business" means COMMERCIAL disputes among family shareholders.
* The blurb says "family-held companies" and names commercial dispute types
* for that reason — AGENTS.md Q39, 2026-08-27.
*
* The explicit exclusion — family law matters are not accepted — belongs on
* the PAGE, one sentence, at build step 5 (docs/01 §/practice/shareholder/).
* Pouya scoped it there. Do not add it to this blurb: on a six-card grid it
* unbalances the row and reads defensively, and the wording here already
* makes the area unambiguously commercial.
*/
blurb:
'Shareholder and partnership disputes, co-founder breakdowns, and ' +
'business succession in family-held companies.',
},
{
slug: 'cross-cultural',
name: 'Cross-Border & Diaspora',
chip: 'Cross-cultural',
blurb:
'Diaspora business succession, dual-jurisdiction shareholder disputes, ' +
'and cross-cultural commercial matters. Conducted in English or Farsi.',
},
] as const satisfies ReadonlyArray<{
slug: PracticeSlug;
name: string;
chip: string;
blurb: string;
}>;
/**
* Compile-time completeness check, both directions.
*
* `satisfies` above catches a slug in PRACTICE_AREAS that is not in
* PRACTICE_SLUGS. This catches the reverse — a slug with no area — which would
* otherwise let an article declare a practice area that has no page, no nav
* child and no chip, producing a dead link at step 7. Deriving the areas from
* the slugs used to make that structurally impossible; keeping two literals is
* what buys the literal types back, so the check has to be explicit.
*
* Type-only. Nothing runs, nothing ships.
*/
type _AssertNever<T extends never> = T;
type _SlugsWithoutAnArea = Exclude<
PracticeSlug,
(typeof PRACTICE_AREAS)[number]['slug']
>;
export type _SlugCoverage = _AssertNever<_SlugsWithoutAnArea>;
/**
* The five process steps. **HERE, NOT IN THE PAGE**, for the reason written
* against PRACTICE_AREAS above and applied by `adversarial-reviewer` 2026-08-27:
* `/` renders a compressed strip of these and `/process/` renders them in full
* at build step 6, so a copy typed into one page is a copy that will eventually
* disagree with the other. The proof is already in the history — the step-3 body
* below carried a fee claim that the same session's claims audit found to be
* wrong against `docs/07`, and it existed in exactly one place. After step 6 it
* would have existed in two.
*
* TIMINGS ARE `docs/01` §`/process/`'s, verbatim: "confidential intake (day 0) ·
* engagement and framing (17) · pre-session exchange (721) · the session
* (2130) · binding conclusion (30+)".
*
* **Q43 CLOSED 2026-08-27, and the ruling went the other way from this
* comment's previous reasoning.** It read: *"`docs/03` §Process requires them
* REAL rather than illustrative, so they are not softened to 'typically'."*
* Pouya ruled that the five timings are **service commitments, the same class
* as Q27's response time** — not facts about him, so they need framing rather
* than a Verified row:
*
* "Present them as the TYPICAL shape of an engagement, explicitly not a
* guarantee: mediation timing depends on party and counsel availability,
* which he does not control. Published as typical, they are honest and
* useful; published as commitments, the first matter that slips makes the
* page false."
*
* The NUMBERS ARE UNCHANGED — softening was never the fix, and inventing them
* was never on. What changed is that they now ship with `PROCESS_FRAMING`
* below, which is **not optional**: any page rendering these steps renders it
* too. `docs/03` §Process has been amended to record the override.
*
* Step 5 is labelled from the spec but its body says what actually concludes —
* minutes of settlement in a mediation, an award where the process is arbitral.
* "Binding conclusion" alone would read as though a mediation binds, which it
* does not until the parties sign.
*
* NO FEE CLAIM IN ANY BODY. `docs/07` bundles a CAPPED preparation allowance
* (2 h in the half day, 3 h in the full day) and says in terms that it "must be
* stated on the page... Do not quietly fold it into the hours figure." A
* five-word strip cannot state it properly, and stating it improperly
* misdescribes money. `/fees/` at step 9.
*/
export const PROCESS = [
{
title: 'Confidential intake',
timing: 'Day 0',
body: 'A scheduled call to scope the matter, identify the parties, and run conflicts.',
},
{
title: 'Engagement and framing',
timing: 'Days 17',
body: 'Terms of appointment, the issues in dispute, and who attends.',
},
{
title: 'Pre-session exchange',
timing: 'Days 721',
body: 'Briefs and documents, exchanged in advance so the session starts informed.',
},
{
title: 'The session',
timing: 'Days 2130',
body: 'Half day or full day, in person or by video.',
},
{
title: 'Conclusion',
timing: 'Day 30 onward',
body: 'Minutes of settlement — or an award, where the process is arbitral.',
},
] as const;
/**
* THE FRAMING THAT MAKES THE TIMINGS PUBLISHABLE — Q43, Pouya 2026-08-27.
*
* Not decoration and not a disclaimer bolted on: it is the condition on which
* the five numbers above may appear at all. *"Published as typical, they are
* honest and useful; published as commitments, the first matter that slips
* makes the page false."*
*
* RENDER IT ADJACENT TO THE STEPS, on every page that renders them — `/` now,
* `/process/` at step 6. A reader who sees `Days 2130` and not this sentence
* has read a commitment. First person, per `docs/03` §Voice.
*/
export const PROCESS_FRAMING =
'This is the typical shape of an engagement, not a commitment. Timing ' +
'depends on party and counsel availability, which I do not control.';
/** Seven items is the ceiling before a nav stops being scannable. */
export const PRIMARY_NAV = [
{ href: '/about/', label: 'About' },
+139
View File
@@ -0,0 +1,139 @@
---
/**
* The page shell. Every route renders through this.
*
* Metadata is not optional and not a prop this layout can default: it forwards
* whatever it is given straight to SEO.astro, which throws if the title or
* description is out of the range docs/04-seo-spec.md sets.
*/
import '../styles/global.css';
import type { Props as SeoProps } from '../components/SEO.astro';
import SEO from '../components/SEO.astro';
import SiteHeader from '../components/SiteHeader.astro';
import SiteFooter from '../components/SiteFooter.astro';
import { SITE } from '../data/site';
/**
* `preloadSerifItalic` — OPT-IN, AND IT IS A PER-PAGE DECISION SITTING IN A
* SHARED LAYOUT, WHICH IS WHY IT NEEDED A PROP.
*
* The italic face is preloaded because `/`'s `<h1>` contains
* `<em class="it">the room</em>` and a serif-to-fallback swap inside a 96px
* headline moves the whole last line. That reasoning is `/`-specific and the
* preload was unconditional.
*
* Measured on `/about/` (resource timing, cache disabled, 390x844 DPR 2):
* `instrument-serif-latin-400-italic.woff2` fetched as a `link` at **22,428 B**,
* while an enumeration of every element's computed `fontFamily|fontStyle` on
* that page returns `Instrument Serif|normal`, `Geist|normal`,
* `Geist Mono|normal`, `Geist|italic` — **no `Instrument Serif|italic`.** Total
* page transfer is ~162 KB, so it was ~14% of the page, preloaded ahead of the
* faces that actually render.
*
* OPT-IN RATHER THAN OPT-OUT: forgetting to opt in costs one line of reflow on a
* page that has an italic headline; forgetting to opt out costs 22 KB on the
* critical path of a page that does not. Fifteen pages remain, so the default
* is the one whose failure is cosmetic. docs/02 allows one italic phrase per
* headline, so set it wherever a headline uses `.it`.
*/
export type Props = SeoProps & { preloadSerifItalic?: boolean };
// SITE.locale is `en_CA` — Open Graph's underscore form. The lang attribute
// takes the BCP 47 hyphen form. One source, two spellings, no second constant.
const lang = SITE.locale.replace('_', '-');
// Destructured OFF the props before the spread below, so it does not reach
// <SEO>, which would reject it.
const { preloadSerifItalic = false, ...seo } = Astro.props;
---
<!doctype html>
<html lang={lang}>
<head>
<SEO {...seo} />
{
/* No SVG favicon. The mark is a shaded ribbon, not flat vector paths, so
there is no honest SVG of it to serve — see InfinityMark.astro and
AGENTS.md Q38. The .ico carries 16/32/48, and is what crawlers request
at the root regardless of what is declared here. */
}
<link rel="icon" href="/favicon.ico" sizes="16x16 32x32 48x48" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
{
/* THREE of the FOUR faces used above the fold on `/`, and the comment used
to say "the two faces used above the fold", which was wrong on the count.
Measured by network probe on a cold cache: `/` requests four faces above
the fold —
instrument-serif latin 400 normal 21,032 B preloaded — display type
geist latin variable 29,400 B preloaded — body, the LCP element
instrument-serif latin 400 italic 22,128 B preloaded — see below
geist-mono latin variable 23,128 B NOT preloaded — see below
THE ITALIC IS PRELOADED because it sits inside the `<h1>`: `<em class="it">
the room</em>`. With `font-display: swap` a CSS-discovered face renders in
a fallback first, and a serif-to-fallback swap inside a 96px headline
moves the whole last line.
GEIST MONO IS DELIBERATELY NOT. It sets the eyebrow — 12px, uppercase,
0.18em tracking — and the credential labels. A swap there costs one short
line of reflow at a size where the fallback is metrically close, and
preloading it would put 95,688 B of font on the critical path instead of
72,560 B. Revisit against real Lighthouse numbers at step 7; it is a
trade, not a certainty.
Fonts are always fetched in CORS mode, so a preload without `crossorigin`
is a second, wasted request rather than a warmed cache. */
}
<link
rel="preload"
href="/fonts/instrument-serif-latin-400-normal.woff2?v=1"
as="font"
type="font/woff2"
crossorigin
/>
<link
rel="preload"
href="/fonts/geist-latin-wght-normal.woff2?v=1"
as="font"
type="font/woff2"
crossorigin
/>
{
preloadSerifItalic && (
<link
rel="preload"
href="/fonts/instrument-serif-latin-400-italic.woff2?v=1"
as="font"
type="font/woff2"
crossorigin
/>
)
}
{
/* No script tag. Not "no framework", not "minimal JS" — none.
The reveal used to be an inline IntersectionObserver here. It ran before
first paint so nothing flashed, and it took its own class back off if
anything threw. It was still wrong: docs/05-backend-spec.md specifies
`script-src 'self'` with no `unsafe-inline`, so the single script on the
site was the one thing the site's own CSP would refuse to execute — and
a per-build hash drifts from the policy that is supposed to pin it.
`animation-timeline: view()` in global.css does the same job in CSS. See
the Reveal block there for why the @supports gate is load-bearing. */
}
</head>
<body>
<a class="skip-link" href="#main">Skip to content</a>
<SiteHeader />
<main id="main" tabindex="-1">
<slot />
</main>
<SiteFooter />
</body>
</html>
+971
View File
@@ -0,0 +1,971 @@
---
/**
* `/about/` — Biography and credentials. Build step 3 (docs/01 §Build order):
* "the credential spine everything else references."
*
* JOB (docs/01 §`/about/`): "be the page an appointing body or opposing counsel
* reads before agreeing to an appointment. This page carries the verifiable
* record."
*
* SECTIONS, against docs/01's seven-item outline:
* 1 Portrait, name, designation line → the hero
* 2 Narrative biography, 400600 words → §Background
* 3 Credentials, structured → §Credentials
* 4 The credentialing arc → §The arc
* 5 Languages and cross-cultural → §Language
* 6 Speaking and publications → OMITTED, per the spec itself
* 7 Person JSON-LD + PDF bio → JSON-LD ships; the PDF does not
*
* ITEM 6 IS OMITTED ON THE SPEC'S OWN INSTRUCTION, not by oversight: "Omit the
* section entirely until there is something in it. An empty 'Speaking' heading
* is worse than no heading." Nothing to list.
*
* ITEM 7'S PDF IS NOT SHIPPED, and the omission is stated rather than silent —
* AGENTS.md Q45. No such file exists, and a link to a missing file on the page
* an appointing body reads is worse than its absence. It is also not a
* formatting job: a one-page bio is a credential document circulated DETACHED
* from the site, where no build and no reviewer ever re-checks it. Two
* decisions there are Pouya's.
*
* THE PARENT/CHILD SCOPE TRAP, because this page uses <SectionHeading> four
* times. A parent CANNOT style a child component's root element — the rule
* compiles against the parent's cid and silently never matches. Every heading
* below is wrapped in a page-owned <div class="section-head">. Not defensive
* boilerplate: it is the fourth-instance defect CLAUDE.md records, and the
* components have had their `class` props deleted so passing one is a build
* error rather than a silent no-op.
*
* R10 / Q44 — THE MEMBERSHIPS GROUP IS NOT ON THIS PAGE. R10 is written as a
* prohibition — re-confirm *before* any page listing memberships ships — and
* `/about/` is the page it names. The re-confirmation is a fact only Pouya holds
* and was not obtained, so the group is withheld and a `TODO(pouya)` sits on
* CREDENTIAL_GROUPS below with the exact question. §4 is NOT re-stamped:
* nothing was re-checked. Q44.
*
* A first version of this page published all four and disclosed the gap instead.
* Both review agents rejected that; the reasoning is on CREDENTIAL_GROUPS.
*/
import { Picture, getImage } from 'astro:assets';
import BaseLayout from '../layouts/BaseLayout.astro';
import ContactBand from '../components/ContactBand.astro';
import Eyebrow from '../components/Eyebrow.astro';
import Pill from '../components/Pill.astro';
import SectionHeading from '../components/SectionHeading.astro';
import portrait from '../assets/pouya-lajevardi.jpg';
import ogDefault from '../assets/og-portrait.jpg';
import { aboutGraph } from '../data/schema';
import {
ASYMMETRY_LINE,
CREDENTIALS,
PORTRAIT,
ROLE,
SITE,
} from '../data/site';
/**
* `image` ON THE Person NODE — docs/04 lists it, and this page is where
* PERSON_ID (`/about/#person`) actually resolves.
*
* AN EARLIER VERSION OF THIS PAGE OMITTED IT and argued at length that it could
* not be supplied: "SEO.astro computes that URL internally and does not expose
* it." That reasoning talked itself into the wrong answer — the URL does not
* have to come from SEO.astro. `/` derives it in six lines with `getImage()`,
* and the same six lines work here. The result was two documents asserting the
* same `@id` with different property sets, which is worse than either choice
* made deliberately.
*
* EXACTLY the transform SEO.astro applies to the same source (jpeg, 1200x630),
* so Astro's asset cache returns the same hashed file rather than emitting a
* second copy for the crawler. JPEG on purpose: link-preview and structured-data
* consumers are not browsers and several still do not decode WebP, let alone
* AVIF.
*
* The withdrawn reasoning follows, because it is a good example of a comment
* arguing for a defect.
*
* NO `getImage()` CALL FOR THE JSON-LD IMAGE, unlike `/`.
*
* `/` generates the 1200x630 jpeg so the Person node can carry an absolute
* `image` URL, and it works because that page renders the ProfessionalService
* node too. Here the Person node is the whole graph, and SEO.astro already emits
* exactly the same transform of exactly the same source as `og:image`. Calling
* `getImage()` again would return the same cached asset — so this is not about
* duplicate files, it is about a second place that has to be kept in step with
* SEO.astro's transform. It is passed the URL by the layout instead.
*
* Except it cannot be: `SEO.astro` computes that URL internally and does not
* expose it. So the node ships WITHOUT `image` on this page and WITH it on `/`,
* which is a real inconsistency in a field docs/04 lists for the Person node.
* Both resolve to the same @id, so a crawler joining the two documents gets the
* image either way — but that is a hope about crawler behaviour, not a fact.
* Recorded rather than papered over; the fix is for SEO.astro to expose the URL
* it already computes, which is a component change and not a page change.
*/
const ldImage = await getImage({
src: ogDefault,
format: 'jpeg',
width: 1200,
height: 630,
});
const graph = aboutGraph(new URL(ldImage.src, Astro.site).href);
/**
* The designation line — docs/01 item 1. Assembled from constants so it cannot
* drift from §4, and ordered held-first.
*
* Q.Arb IS NOT IN IT, deliberately. It is not held (§4: "Describe as newly
* commenced, never as held or nearing completion"), and a designation line is
* precisely a list of things held. The arc section states the stage plainly,
* which is what §4's paired-disclosure condition requires — this page offers
* arbitration, so the stage appears on this page and not only in the footer.
*/
const designationLine = [
'Mediator',
CREDENTIALS.designations[0],
CREDENTIALS.education[0],
];
/**
* The credentialing arc — docs/01 item 4, and docs/03: "the credentialing
* pathway from Q.Med through Q.Arb to C.Med-Arb is stated openly as in
* progress. The brief treats that arc as part of the story rather than
* something to obscure."
*
* `state` is the load-bearing column. "Commenced August 2026" is §4's exact
* wording and the ONLY permitted wording — docs/03: not "in progress", because
* the weaker form drifts toward "nearly complete", which §4 Forbidden bars
* outright.
*/
/*
* FIVE CLAIMS CAME OUT OF THIS BLOCK, and the first was the worst thing in the
* step-3 diff. BOTH review agents found it independently, which is the strongest
* signal this loop produces.
*
* 1. ⚠️ "arbitral appointments are not gated behind it, which is why I accept
* them now" — **the false universal Q39 struck, on a public page.**
* Unscoped ("arbitral", not commercial), asserted as flat fact in the first
* person, and it publishes a proposition of Ontario law that §4 holds only
* in scoped form and deliberately does NOT stamp `[verified]`. Family
* arbitration is an arbitral appointment and it IS gated
* (`docs/reference/ontario-family-arbitration-training.md`). Q39 swept
* three instances of this universal on 2026-08-27; this was the fourth and
* the first outside a comment. §4 requires the STAGE be stated — never the
* register's gating rationale. Deleted rather than rescoped: this page has
* no business carrying the argument at all.
* 2. "on the same institutional pathway" and
* 3. "Three designations on one institutional pathway" (the section lede) —
* §4 attaches ADRIC / ADRIO to **Q.Med only**. Neither the Q.Arb row nor
* the C.Med-Arb row names a body.
* 4. "The senior hybrid designation" — a ranking claim about a third party's
* credential structure, with no row and no source.
* 5. The expansions — "Qualified Mediator", "Qualified Arbitrator",
* "Chartered Mediator-Arbitrator". Flagged as being in §11 Glossary but
* not in §4 Verified.
*
* ⚠️ ITEM 5 WAS REMOVED AND IS NOW RESTORED, AND IT IS THE ONE PLACE THIS
* SESSION WENT AGAINST A REVIEW FINDING. The reason is a SECOND finding, from
* the next audit pass, and it is a consistency point rather than a claim point:
* this page also publishes "Provincial Offences Act", "Statutory Accident
* Benefits Schedule" (as SABS) and "the ADR Institute of Canada and the ADR
* Institute of Ontario" — every one of them a §11 Glossary expansion, on exactly
* the ground the designation names were struck. *"One standard or the other."*
*
* The standard chosen is: **§11 Glossary is the source for DEFINITIONAL
* expansions** — what an abbreviation stands for — while §4 Verified remains the
* only source for claims ABOUT POUYA. Expanding `Q.Med` says nothing about him;
* "he holds it" is the claim, and that has a row. The alternative standard would
* have required stripping POA, SABS and the institute names from the prose and
* `recognizedBy` from the JSON-LD, which makes the page materially worse for a
* reader who does not already know the acronyms, in exchange for no reduction in
* risk. **Q46 asks Pouya to ratify that standard** and it is the only thing
* holding it up; if he declines, all four classes come out together.
*
* Sourcing them externally was tried first and failed: `adric.ca/designations/`
* redirects to `/designations-cee/` and its HTML contains **zero** occurrences
* of "Q.Med", "Qualified Mediator" or "Chartered Mediator" in 114,985 bytes —
* navigation only, body assembled client-side. So R14 cannot be met from the
* obvious URL, which is why this rests on §11 and on Q46 rather than on a
* committed extract.
*/
const ARC = [
{
name: 'Q.Med',
state: 'Held',
/* NOT "the designation I mediate under". That imported a PERMISSION framing
onto what §4 records as a voluntary credential, in a register that says
no designation is required to be appointed as a mediator — the
credential-as-licence slip §4 says produced a wrong answer twice. */
body:
'Qualified Mediator, held through the ADR Institute of Canada and the ' +
'ADR Institute of Ontario. The designation I hold as a mediator today.',
},
{
name: 'Q.Arb',
state: 'Commenced August 2026',
body:
'Qualified Arbitrator. Newly commenced — not held, and not nearing ' +
'completion.',
},
{
name: 'C.Med-Arb',
state: 'The endpoint',
body:
'Chartered Mediator-Arbitrator. The designation this practice is built ' +
'toward.',
},
];
/**
* The structured credentials — docs/01 item 3: "designations, education,
* certifications, memberships. Every line from AGENTS.md §4 Verified."
*
* LANGUAGES ARE NOT A GROUP HERE, and that is a heading decision rather than an
* omission: docs/01 item 5 gives them a section of their own, and having both
* an h3 "Languages" group and an h2 "Language" section would put the same two
* facts in the accessibility tree twice under near-identical names. The section
* wins because it carries the cross-cultural half, which a list cannot.
*
* NOT PRESENT, AND EACH IS A §4 DIRECTIVE RATHER THAN A GAP:
* - The Law Society. Listing it implies licensure, which D13 bars. §4:
* "Excluded deliberately, not by oversight."
* - OCNI. Not current (§4), so it is not published.
* - Any licence status, in either direction. §4 records it `[unestablished]`.
*
* TODO(pouya): Are ADRIC, ADRIO, the three OBA sections (Construction &
* Infrastructure, ADR, Civil Litigation) and the Canadian Tax Foundation all
* current TODAY, and in which month does each renew? — AGENTS.md Q44.
*
* ⚠️ MEMBERSHIPS ARE DELIBERATELY NOT RENDERED, AND THIS IS A REVERSAL.
*
* The first version of this page published all four on §4's 2026-08-26 stamp and
* disclosed the outstanding re-confirmation in a note, in this comment, in
* `schema.ts`, in §9 Q44 and on the cutover checklist. Both review agents
* rejected that, and they are right. §12 **R10** is written as a PROHIBITION —
* *"Re-confirm at each renewal, **and before any page listing memberships
* ships** — `/about/` at build step 3 is the first one that will"* — and
* documenting a prohibition is not discharging it. `CLAUDE.md` gives the
* procedure for a fact you do not have, and it is this one: leave
* `TODO(pouya)`, log the question, let the gap be visible. *"A build that fails
* on an unanswered question is a correct build."*
*
* The alternative was to ship them and call it disclosed. It was taken once and
* is recorded here as the decision it was, not as an oversight — and it came
* with a second defect on top: the note asserted *"Memberships are renewed
* annually and are listed as current"*, which (a) warranted currency the
* register cannot vouch for and (b) widened §4, which records yearly renewal for
* the **OBA sections and the CTF only** and says nothing about ADRIC or ADRIO.
*
* OCNI is the precedent and it is in §4: a membership lapsed, quietly, and the
* register now reads "not current, do not publish". Nothing tells you when.
*
* The other three groups ship. Restoring this one is one array entry, the moment
* Q44 closes — and re-stamp §4 and `CREDENTIALS.memberships` that day.
*/
const CREDENTIAL_GROUPS = [
{ title: 'Designations', items: CREDENTIALS.designations },
{ title: 'Education', items: CREDENTIALS.education },
{ title: 'Certifications', items: CREDENTIALS.certifications },
];
---
<BaseLayout
title="About · Pouya Lajevardi · Mediator, Q.Med · Toronto"
description="Pouya Lajevardi, JD, Q.Med — a Toronto mediator who also practises as a machine-learning and infrastructure engineer. Credentials, background, designations."
ogType="profile"
imageAlt={PORTRAIT.alt}
jsonLd={graph}
>
{/* ---- 1. Hero: portrait, name, designation line --------------------- */}
<section class="hero">
<div class="wrap hero-inner">
<div class="hero-copy">
<Eyebrow dot>About</Eyebrow>
{
/* THE H1 IS THE NAME, not a headline, and that is docs/01 item 1
("Portrait, name, designation line") agreeing with the search intent
it records for this page (`"Pouya Lajevardi"`, `Pouya Lajevardi
mediator`). The masthead carries the name as a brand mark; a bio page
needs it as the document's subject. */
}
<h1 class="display hero-h">{SITE.name}</h1>
{
/* EACH SEPARATOR IS INSIDE THE SPAN IT PRECEDES, not a sibling of it.
As siblings the flex container wrapped between them, leaving an
orphaned "·" at the end of line 1 at 390px. The NON-BREAKING SPACE
after the glyph is what keeps it attached — an earlier version used
`white-space: nowrap` on the whole item instead, which fixed the
orphan and broke reflow at a 200% default font size. */
}
<p class="designation">
{
designationLine.map((part, i) => (
<span class="designation-part">
{i > 0 && (
<span class="sep" aria-hidden="true">
{'·\u00A0'}
</span>
)}
{part}
</span>
))
}
</p>
{
/* THE ROLE LINE, and it is the single highest-risk sentence on this
page. Both strings come from `ROLE` in src/data/site.ts rather than
being typed here, for the reason that file gives: "these are the two
where the wording IS the compliance."
D13: the approved phrasing is "active litigation exposure", NEVER
"practice" in this context. The boutique is never named (D16). The
matter types are §4 verbatim and must not be extended without a row.
EXPLICITLY INTERIM — AGENTS.md R1, surfaced again 2026-08-28
precisely because this page is where the framing now does its
heaviest work. */
}
<p class="hero-lede">
I am {ROLE.title} at {ROLE.at}, with {ROLE.litigationLine} across{' '}
{ROLE.litigationAreas.slice(0, -1).join(', ')} and{' '}
{ROLE.litigationAreas.at(-1)}. I mediate commercial disputes and I
accept arbitration appointments in commercial matters; the Q.Arb
pathway commenced in August 2026. I also work as a machine-learning
and infrastructure engineer.
</p>
</div>
{
/* `widths` + `sizes` rather than `densities`, because the portrait is
fluid and a density ladder would size it from one assumed CSS width.
`width` and `height` are passed ALONGSIDE `widths` — without them Astro
declares the untouched 1600px master as the <img src> fallback, which
is the defect `/`'s comment records at 254,626 bytes.
⚠️ THIS COMMENT PREVIOUSLY MADE FOUR CLAIMS AND THREE WERE MEASURABLY
FALSE. Recorded rather than quietly replaced, because the false ones
were the confident ones.
(a) "Same ladder as `/`'s hero and the same reasoning" — true, and
that was the problem: `/`'s reasoning derived a 960 ceiling from
the TWO-COLUMN layout, which only engages at 66rem. Below 66rem
the hero is one column and the portrait is the full content
width. Measured: 592px at a 640 viewport, 672 at 768, 804 at 900,
928 at 1024 — needing 1184-1856 device px at DPR 2 against a 960
ceiling. **1.40x upscale at 768/DPR2, 1.93x at 1024/DPR2**, on
this page and on `/`. At 768/DPR1 a 760w file exists and is not
chosen, so part of the loss was purely a wrong `sizes` (52vw
declared against an ~88vw slot). Fixed two ways: `.hero-portrait`
is capped at 30rem below 66rem so the widest real slot is 480 CSS
px, which makes 960 exactly right for DPR 2; and a 1440 rung
covers DPR 3, which the ≥66rem range had also been missing
(429px x 3 = 1287 against 960).
AND THE 1440 RUNG OVERSHOT, SO 1080 EXISTS TO CORRECT IT. Adding 1440
for DPR 3 removed a 1.07x upscale at 390/DPR3 and replaced it with a
**48,799 B fetch where the old one was 21,526 B** — +27 KB on a phone,
to fix a 7% softness nobody can see. A browser takes the smallest
candidate at or above what it needs, and with no rung between 960 and
1440 the only choices were "slightly soft" or "+27 KB". 1080 makes
1026 (390 x DPR 3) exact and cheap. This was a defect in the fix for
the defect above, found by measuring the fix rather than the source.
AND 1080 ALONE MISSED THE TWO LARGEST CURRENT PHONES, WHICH
MAKES THIS THE THIRD ITERATION OF THIS LADDER. 1080 was tuned to
390 CSS px x DPR 3 (= 1026), and `sizes` resolves to
`calc(100vw - 3rem)` up to 528px, so every phone wider than 390
overshoots to the next rung: iPhone 14 Plus (428@3, needs 1140)
and 15/16 Pro Max (430@3, needs 1146) both took **1440 —
48,799 B**, against 27,594 for the device the rung was tuned
for. +21,205 B, 13% of page weight. A 1200 rung closes it at
1.05x. Measured after: no rung more than 1.06x oversized on the
phone axis, and still no upscaling anywhere.
(b) "The LCP element on this page is the <h1>" — **false.**
`PerformanceObserver` at 1280x900: LCP element is
`IMG.portrait-img`, size 229,679; the <h1> box is 51,484, 4.5x
smaller. So the portrait IS the LCP element at desktop widths.
(c) "two words of 96px serif" — **false.** `.hero-h` sets
`--text-5xl`, which computes to **76px**. 96px is `--text-6xl`,
which is what `/` uses.
(d) "above the fold at every width" — **false.** Portrait top vs
viewport height: 782 vs 568 at 320, 752 vs 640 at 360 — entirely
below the fold at both, i.e. **0 visible px** at the two widths
`docs/02` names explicitly. 120px visible at 390x844.
SO WHY IS IT STILL `eager` AND NOT `fetchpriority="high"`? Because (b)
and (d) pull in opposite directions and the split is real. Re-measured
AFTER the cap, since the cap changes the element's size and therefore
the LCP candidate (`PerformanceObserver`, cache cleared per sample):
390x844 LCP = P.hero-lede 93,411
768x1024 LCP = IMG.portrait-img 168,161
1280x900 LCP = IMG.portrait-img 229,679
So the portrait is the LCP element from **768px up** — not "~1056px
up", which is what this comment said before the cap was measured — and
at phone widths LCP is the hero lede, a font-dependent text paint the
preloaded Geist already covers. `loading` and `fetchpriority` cannot be
conditioned on viewport. `eager` serves the tablet-and-desktop LCP;
`fetchpriority="high"` is withheld because at 320-360, where the image
is entirely off-screen, it would outrank that text paint. For
reference, `/` differs at 768 (LCP = H1.display, 135,289) because its
headline is a four-line sentence rather than a two-word name. */
}
<div class="hero-portrait">
<Picture
src={portrait}
width={960}
height={960}
widths={[380, 480, 640, 760, 960, 1080, 1200, 1440]}
sizes="(min-width: 80rem) 429px, (min-width: 66rem) 33vw, (min-width: 33rem) 480px, calc(100vw - 3rem)"
formats={['avif', 'webp']}
fallbackFormat="jpeg"
alt={PORTRAIT.alt}
loading="eager"
decoding="sync"
class="portrait-img"
/>
</div>
</div>
</section>
{/* ---- 2. Narrative biography ---------------------------------------- */}
<section class="section bio reveal">
<div class="wrap">
<div class="section-head">
<SectionHeading eyebrow="Background" level={2}>
<span slot="heading">Two directions, one file.</span>
</SectionHeading>
</div>
{
/* 400600 WORDS, docs/03: "Tell the three tracks as one arc, not three
lists." Measured, not estimated — see the word-count assertion in the
verify step of this session's Change Log entry.
Q41(a) IS APPLIED THROUGHOUT AND THIS IS THE FIRST PAGE WRITTEN UNDER
IT. Pouya's ruling, 2026-08-27: the implication test reaches prose, and
prose is held to a HIGHER bar — "state the asymmetry explicitly rather
than relying on a parallel construction to carry it." So the fourth
paragraph names which half is training and which is work, in as many
words. Avoiding the noun pair "law and engineering" is not sufficient
on its own: a reader can supply the missing symmetry from silence, and
for the legal half the missing half is a licence.
EVERY CLAIM TRACES TO §4 Verified: the JD, the boutique role, active
litigation exposure and its four matter types, Q.Med, multiple
completed sole mediations, arbitration appointments (§4 Offerings,
scoped to commercial), the Q.Arb pathway commenced August 2026,
C.Med-Arb as the goal, engineering practice, SML Company Ltd, Farsi,
Iranian-Canadian. Nothing here asserts or implies licensure. */
}
<div class="prose bio-prose">
<p>
I came to dispute resolution from two directions, and I still work in
both.
</p>
<p>
The first is law. I hold a JD from Bond University, and I am{' '}
{ROLE.title} at {ROLE.at}. That role gives me {ROLE.litigationLine} — personal
injury, construction, regulatory matters under the Provincial Offences Act,
and accident benefits under the SABS. What that exposure is actually worth
in a mediation is unglamorous: I have seen how these files get built. Which
productions turn out to be thin. Where expert reports talk past each other
rather than disagree. Which issues resolve once someone puts the documents
in order, and which ones never will.
</p>
<p>
The second is engineering. I work as a machine-learning and
infrastructure engineer. That is current practice, not a former career
and not an interest: I read code, model documentation, deployment
topology, and the operational records that show what a system did
rather than what a specification said it would do.
</p>
{
/* FROM A CONSTANT — ASYMMETRY_LINE in src/data/site.ts. It was typed
here and separately on `/`, and the two copies had already diverged
(full stops here, a comma there) inside the session that wrote both.
Q41(a) makes this the sentence responsible for foreclosing the
licence implication, so it is the worst string on the site to let
drift. */
}
<p>{ASYMMETRY_LINE}</p>
<p>
Mediation is where they meet. I hold the Q.Med designation through the
ADR Institute of Canada and the ADR Institute of Ontario, and I have
completed multiple sole mediations. I accept arbitration appointments
in commercial matters — as sole arbitrator, as a party-appointed
arbitrator, and in co-arbitration. Where a matter turns on a technical
question, I read the technical material myself.
</p>
<p>
My Q.Arb pathway commenced in August 2026, and C.Med-Arb is the
designation I am working toward. I state the stage openly because an
appointing body will establish it anyway, and because a reader can do
more with the fact than with a hedge. I would rather say where I am on
the arc than leave it to be inferred.
</p>
{
/* BOTH ADDITIONS TO THIS SENTENCE CAME BACK OUT. §4 verifies exactly
one relation — *"Operator of SML Company Ltd. alongside the
practice"* — and that is now all it says.
"It is not a law firm and does not hold itself out as one" — a
negative REGULATORY statement with no row, attached to the one
§4 row carrying an express caution against being read together
with the licence row *"into an implication that neither row
makes."* Added to be helpful; it touches exactly what §4 says
not to touch.
"the company through which the engineering work is done" — a
corporate-structure claim. §4 verifies operation alongside the
practice, the jurisdiction of incorporation and the place of
business — not which work runs through which vehicle. */
}
<p>I run SML Company Ltd alongside both.</p>
<p>
I have also completed the Kompass Arbitration Certificate Program and
the Stitt Feld Handy negotiation and ADR workshop sequence. Neither is
a designation, and I name them precisely for that reason: process
training is the easiest thing in this field to assert loosely, so it
is worth stating exactly what it was.
</p>
{
/* THE NEUTRALITY LINE. It is a disclaimer and it earns its place:
docs/03 requires the equivalent on `/for-parties/`, and this is the
page an appointing body reads. It also states the negative of the
implication §4 Forbidden bars — "acts for clients", "represents
parties" — which is a stronger position than merely never asserting
it. Q42's reasoning is the same reasoning: Pouya struck settlement
counsel because a partisan role "undercuts the brand's central
claim". This sentence is that claim, stated.
The family-law exclusion is NOT here. Pouya scoped it to
`/practice/shareholder/` — "One sentence, not a section" — and
widening it to this page is his call, not an implementer's. */
}
{
/* THIS SENTENCE HAS NOW BEEN WRONG IN BOTH DIRECTIONS, WHICH IS WHY
THE THIRD VERSION AVOIDS THE AXIS ALTOGETHER.
"I do not give legal advice" — flagged because "do not" describes
an ELECTION, and an election implies the entitlement to choose.
"I cannot give legal advice" — flagged on the next pass because
"cannot" is a DENIAL of entitlement, and §4 on licence status
is explicit: *"Do not assert it, do not deny it, do not infer
it from anything else here."*
Both readings are correct and they point in opposite directions,
because both sentences make a claim about CAPACITY. So this one does
not: it states the ROLE and its consequence for the reader, which is
the form `docs/03` actually sanctions on `/for-parties/` (*"the
mediator is not your lawyer"* — role, not capacity) and the only one
that asserts nothing and denies nothing. The underlying question is
R1's. */
}
<p>
I act as a neutral. I do not act for a party in a matter I take, and
each party should have their own legal advice.
</p>
</div>
</div>
</section>
{/* ---- 4. The credentialing arc -------------------------------------- */}
{
/* SECTION 4 BEFORE SECTION 3, and the reorder is deliberate. docs/01 lists
credentials (item 3) then the arc (item 4). The arc is the part a reader
is likely to have a question about — it is the thing this practice is
candid about that others are not — and burying it under a scannable list
of things already held reads as a footnote to them. §4's paired-disclosure
condition also wants the stage stated where the offering is made, and the
offering is made in the narrative directly above. The list follows. */
}
<section class="section section-inverse arc-section reveal">
<div class="wrap">
<div class="section-head">
<SectionHeading
eyebrow="The arc"
level={2}
lede="Three designations. Two of them are ahead of me, and saying so is the point."
>
<span slot="heading">Where the credentials sit.</span>
</SectionHeading>
</div>
{
/* `role="list"` RESTORED, AND THE REASON I REMOVED IT WAS WRONG ABOUT
ARIA. The comment here claimed that on an <ol> the role "re-announces
an ordered list as an unordered one". It does not: **both <ul> and <ol>
map to the `list` role**, so `role="list"` on an <ol> is a no-op for
ordering, not a downgrade. What it is actually for is the WebKit
heuristic that strips list semantics from a list with
`list-style-type: none` — and `.arc` sets exactly that.
It also left the two <ol>s on this two-page site DISAGREEING, with
`/`'s `.process-strip` keeping the role. That is the state that gets
copied seventeen times.
Not verified here: whether WebKit's heuristic covers <ol> as well as
<ul>. There is no Safari instrument in this environment, so the role
stays on the precautionary side, which costs nothing. Chrome's AX tree
exposes `.arc` as `list` with three `listitem` children either way. */
}
<ol class="arc" role="list">
{
ARC.map((stage) => (
<li class="arc-item">
<h3 class="arc-name">{stage.name}</h3>
<p class="arc-state">
<Pill>{stage.state}</Pill>
</p>
<p class="arc-body">{stage.body}</p>
</li>
))
}
</ol>
</div>
</section>
{/* ---- 3. Credentials, structured ------------------------------------ */}
<section class="section section-alt creds reveal">
<div class="wrap">
<div class="section-head">
<SectionHeading eyebrow="Credentials" level={2}>
<span slot="heading">The verifiable record.</span>
</SectionHeading>
</div>
<div class="cred-grid">
{
CREDENTIAL_GROUPS.map((group) => (
<div class="cred-group">
<h3 class="cred-title">{group.title}</h3>
<ul class="cred-items" role="list">
{group.items.map((item) => (
<li>{item}</li>
))}
</ul>
</div>
))
}
</div>
{
/* THIS NOTE IS GONE, AND IT CARRIED THREE SEPARATE DEFECTS. It read:
"Memberships are renewed annually and are listed as current. Nothing
above asserts a licence to practise law, in either direction."
(a) "renewed annually" WIDENED §4, which records yearly renewal for
the OBA sections and the CTF only and says nothing about ADRIC
or ADRIO. The widened form had already propagated to four places.
(b) "listed as current" was an affirmative public WARRANTY of
currency stacked on top of an undischarged R10 — the reminder
whose entire purpose is that no such warranty be made without a
re-confirmation. The memberships group is now off the page
(Q44), so the sentence has nothing left to warrant either.
(c) "Nothing above asserts a licence to practise law, in either
direction" READS AS A DENIAL. §4 on licence status: "Do not
assert it, do not deny it, do not infer it from anything else
here." It was also the only sentence on the site that raised
licensure at all, on the page where R1 says the D13 framing is
already doing its heaviest work — and no spec asked for it.
Both review agents flagged (c) independently and escalated it to
Pouya rather than rewriting it. That is the right destination: R1. */
}
</div>
</section>
{/* ---- 5. Language and cross-cultural practice ----------------------- */}
<section class="section language reveal">
<div class="wrap">
<div class="section-head">
<SectionHeading eyebrow="Language" level={2}>
<span slot="heading">English and Farsi, without an interpreter.</span>
</SectionHeading>
</div>
{
/* §4 verifies "Bilingual English and Farsi" and "Iranian-Canadian;
cross-cultural fluency with diaspora business communities".
NO QUANTITY AND NO COMPARATIVE. An earlier draft opened "many of the
disputes I am best placed to take", which carries a count I do not
have and the token "best" — which §4 Forbidden bars as a superlative
and which a forbidden-terms sweep would flag on sight. Rewritten to a
claim about the work: some disputes are not separable from the
relationship, and this is what working in the parties' own language
changes. Nothing about other neutrals — Q41(b). */
}
<div class="prose">
<p>
I mediate in English and in Farsi. I am Iranian-Canadian, and some
commercial disputes are not separable from the relationship between
the parties — family-held companies and diaspora businesses in
particular, where the commercial disagreement and a much longer
history arrive together.
</p>
<p>
Working in the parties' own language, with no interpreter in the room,
changes what gets said and how early it gets said. It removes a layer
between a party and their own account of events.
</p>
</div>
</div>
</section>
{/* ---- Contact band --------------------------------------------------- */}
<ContactBand />
</BaseLayout>
<style>
/* --- 1. Hero -------------------------------------------------------- */
.hero {
padding-block: var(--space-8) var(--space-9);
}
.hero-inner {
display: grid;
gap: var(--space-7);
align-items: center;
}
.hero-copy {
display: flex;
flex-direction: column;
gap: var(--space-5);
}
.hero-h {
/* --text-5xl, not --text-6xl. `/`'s headline is a sentence and needs the
display ceiling; this is a two-word name, and at 96px it sets 15
characters across a line that then has nothing to balance against. */
font-size: var(--text-5xl);
/* `anywhere`, NOT `break-word`. The type scale is rem-based, so at a 200%
DEFAULT FONT SIZE this heading computes to 88px and "Lajevardi" — one
unbreakable 9-character word — is wider than the 224px content box at a
320px viewport. `break-word` permits a break at layout time but does NOT
reduce min-content size, so it would not have helped; `anywhere` does.
It has no effect at any normal size: a word only breaks when it cannot
fit. Breaking a name mid-word is ugly and it is better than a reader at
200% zoom losing the page. WCAG 1.4.4 / 1.4.10. */
overflow-wrap: anywhere;
}
.designation {
display: flex;
flex-wrap: wrap;
/* THE GAP CARRIES THE SEPARATOR'S SPACING, not a margin on .sep. The
separator is aria-hidden, so its box must not be what a sighted reader
depends on for rhythm while a screen-reader user gets nothing — with
`gap` the spacing survives the element being ignored. */
gap: var(--space-1) var(--space-3);
align-items: baseline;
font-family: var(--font-mono);
font-size: var(--text-sm);
letter-spacing: var(--tracking-tight);
color: var(--text-secondary);
}
/* NO `white-space: nowrap` — IT WAS HERE AND IT BROKE REFLOW. Gluing the whole
item together made "Q.Med (ADRIC / ADRIO)" unbreakable, and at a 200%
DEFAULT FONT SIZE that pushed this page to 108px of overflow at 320px
against `/`'s accepted 63. The separator does not need the whole item to be
unbreakable — it only needs to stay attached to the FIRST word, which the
non-breaking space in the markup does. WCAG 1.4.10. */
.designation-part {
display: inline;
}
.designation .sep {
/* NOT `var(--rule)`. Gold `#c9a876` on cream measures **2.10:1** — the one
hard constraint in `docs/02` and `tokens.css`, which say in terms that
gold is never a text colour on cream. An in-browser audit of all 100
text-bearing elements on this page returned exactly two failures and both
were this span. `aria-hidden` does not dispose of it: these separators are
the only thing dividing three credential items, and at 2.10:1 they are
invisible, so the line reads as a run-on. That is a legibility failure
before it is a rule breach. `--text-meta` measures 5.47:1 on cream and
still recedes from the 11.75:1 text beside it. */
color: var(--text-meta);
}
.hero-lede {
max-inline-size: var(--width-prose);
font-size: var(--text-lg);
line-height: var(--leading-relaxed);
color: var(--text-secondary);
}
/* `<Picture>` emits an <img> wrapped in a <picture>, and the <picture> is the
box the grid sizes — the `class` prop lands on the <img>, which is the
defect InfinityMark.astro records for its own flex sizing.
THIS COMMENT CLAIMED "the <picture> WRAPPER carries no cid, so it needs
`:global()`". **That is false.** Read from the built HTML: the emitted markup
is `<picture data-astro-cid-ta2fbyqs="true">` — Astro DOES propagate the
page's scope attribute to both elements for `astro:assets` components (it
does not for ordinary user components, which is the real rule CLAUDE.md
records). Decisive corroboration in the shipped CSS: InfinityMark's BARE
`picture` selector compiles to `picture[data-astro-cid-usztftas]` and
demonstrably works. So `:global()` was unnecessary and merely broader than
intended — it would also have matched a <picture> inside any child
component placed here. Plain selector, correct comment.
4/5 rather than 1/1 — the master is square, and 4/5 is the crop `/` uses.
One portrait treatment across the site rather than two. */
.hero-portrait {
aspect-ratio: 4 / 5;
overflow: hidden;
border-radius: var(--radius-lg);
background: var(--bg-raised);
/* THE CAP IS THE UPSCALE FIX, not a style preference — see the <Picture>
comment. Uncapped, this slot ran to 928 CSS px at a 1024px viewport, which
is 1856 device px at DPR 2 against a 960w ceiling. `margin-inline: auto`
because a 480px box in a 928px column would otherwise sit hard against
the inline start. */
max-inline-size: 30rem;
margin-inline: auto;
}
.hero-portrait picture {
display: block;
block-size: 100%;
}
.portrait-img {
inline-size: 100%;
block-size: 100%;
object-fit: cover;
/* Above centre: the head sits in the upper half of a square crop. */
object-position: 50% 22%;
}
@media (min-width: 66rem) {
.hero-portrait {
/* Released above 66rem: the grid track is already 390-476px, so the cap
is inert — and leaving it in place would silently become the constraint
if the track ever widened. The track governs here, not this number. */
max-inline-size: none;
}
.hero-inner {
/* 1fr / 0.62fr — the portrait is smaller than `/`'s 0.72 because this
hero's copy block is a name plus two short lines and the picture would
otherwise dominate a page whose subject is the text. */
grid-template-columns: 1fr 0.62fr;
gap: var(--space-8);
}
}
/* --- 2. Background --------------------------------------------------- */
/* NO `.section-head` RULE — it moved to `global.css`, where `.prose` lives.
It was byte-identical in both pages, and every one of the seventeen
remaining pages needs it for the same reason (a parent cannot style a child
component's root, so the wrapper must be page-owned). */
/* NO `> p + p` RULE HERE. It was the only paragraph-spacing rule in this
file and it is now redundant: global.css owns `.prose` paragraph spacing as
of 2026-08-28, which is what stopped §Language below from rendering its two
paragraphs as one block. Two rules setting the same property to the same
value is one rule that will eventually disagree. */
.bio-prose {
font-size: var(--text-lg);
line-height: var(--leading-relaxed);
}
/* NO `.bio-prose i` RULE. It styled a statute-name italic that the bio no
longer uses: `Geist` ships NO italic face (every Geist `@font-face` is
`font-style: normal`), so the `<i>` rendered as SYNTHETIC OBLIQUE, and the
serif italic that would have set it properly is no longer preloaded on this
page. "Provincial Offences Act" is set in roman. If a statute name ever
needs italics here, load a face for it first. */
/* --- 4. The arc ------------------------------------------------------ */
.arc {
display: grid;
/* `min(18rem, 100%)` rather than a bare 18rem floor: a bare floor cannot
shrink below itself and overflows at a large default font size. The
credential row on `/` is the measured instance of that mistake. */
grid-template-columns: repeat(auto-fit, minmax(min(18rem, 100%), 1fr));
gap: var(--space-6);
/* NO `padding: 0` OR `list-style: none` HERE — `global.css`'s
`ul[role='list'], ol[role='list']` reset already supplies both, and this
block was re-implementing it by hand. Two rules for one job, and the
hand-written copy is the one that drifts. `margin: 0` also comes from the
global `* { margin: 0 }` reset. */
}
.arc-item {
display: flex;
flex-direction: column;
gap: var(--space-3);
padding-block-start: var(--space-4);
border-block-start: 1px solid var(--line-dark);
}
/* <h3>, not <p>. These are the headings of the three arc items and they set
at --text-2xl serif, so marking them up as paragraphs was the fake-heading
pattern: a screen-reader user got no heading navigation for the one section
on this page a reader is most likely to jump to. `ProcessStep` on `/` uses
<h3> for exactly this shape. Outline stays h1 -> h2 -> h3 with no skips. */
.arc-name {
font-family: var(--font-serif);
font-size: var(--text-2xl);
line-height: var(--leading-tight);
}
.arc-state {
margin: 0;
}
.arc-body {
margin: 0;
font-size: var(--text-base);
line-height: var(--leading-body);
/* NOT --text-secondary. On an inverse ground `--ink-soft` measures ~1.4:1
against `--ink` — the inherited `--text-inverse` (cream, 16.81:1) is what
carries body copy here, so the colour is deliberately left alone rather
than set to a token that is correct only on cream. */
}
/* --- 3. Credentials -------------------------------------------------- */
.cred-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(16rem, 100%), 1fr));
gap: var(--space-7) var(--space-6);
}
/* THESE ARE HEADINGS AND THEY WERE DRESSED AS EYEBROWS. The previous rule set
11px / Geist Mono / 0.18em / uppercase / --text-meta, which is `docs/02`'s
eyebrow specification exactly — and `docs/02` says in terms: "An eyebrow is
not a heading and never carries the <h*>." `Eyebrow.astro` restates it.
The measurable consequence was worse than the rule breach: at 11px these
<h3>s were SMALLER than the 12px eyebrow above them and 5px smaller than
the 16px list items they head, so "MEMBERSHIPS" was the least legible text
on the page an appointing body reads.
Resolved by keeping the <h3> — these genuinely are the headings for their
lists, and the heading navigation is worth more than the styling — and
dropping the eyebrow treatment. The gold rule stays: gold is sanctioned for
dividers, and a 1px border is not text. */
.cred-title {
font-size: var(--text-base);
font-weight: var(--weight-medium);
letter-spacing: var(--tracking-tight);
color: var(--text-secondary);
padding-block-end: var(--space-3);
border-block-end: 1px solid var(--rule);
}
.cred-items {
margin: var(--space-4) 0 0;
padding: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: var(--space-3);
font-size: var(--text-base);
line-height: var(--leading-snug);
}
/* NO `.cred-note` RULE. The note it styled was removed — three defects in one
sentence, recorded in the markup above — and the rule went with it rather
than shipping as dead CSS to every visitor. Noted so the next reader does
not re-add a note to fill a rule that no longer exists. */
/* NO CONTACT-BAND RULES HERE. `.contact-inner`, `.contact-h`, `.contact-body`
and `.contact-action` moved to `ContactBand.astro` with the markup they
style. They were left behind after the extraction — dead CSS shipping to
every visitor, and worse, the **46ch / 52ch divergence the extraction
existed to end was still sitting on disk in both pages**, so the next reader
would have found two different values and no rendered difference. Deleted
2026-08-28. */
</style>
+946
View File
@@ -0,0 +1,946 @@
---
/**
* `/` — Home. Build step 2 (docs/01 §Build order): "proves the design system
* end to end."
*
* JOB (docs/01 §`/`): "establish the unusual stack in under ten seconds, and
* route each of the four audiences to its surface." Leans in-house counsel.
*
* SECTIONS, against docs/01's outline:
* 1 Hero · 2 Credential row · 3 The approach · 4 Two practices ·
* 5 Practice areas · 6 Process preview · 7 Latest insights · 8 Contact band
*
* SECTION 7 IS NOT BUILT, DELIBERATELY, and this is the only spec item this
* page does not deliver. `src/content/insights/` is empty: the collection ships
* at build step 7, which is also where `ArticleCard` and the drafted slate
* arrive (docs/01 §Build order; docs/03 §Launch article slate, D9). Rendering
* the section now means importing a component with nothing to render — its
* scoped CSS ships to every visitor for an empty block — and a props surface
* with no call site, which is already an open finding against InfinityMark.
* SiteHeader gates the Insights NAV item on the same collection, so the page
* and the nav appear together. Do not "finish" this by hardcoding placeholders.
*
* EVERY FACTUAL CLAIM ON THIS PAGE TRACES TO AGENTS.md §4, and the ones that
* carry risk are constants from src/data/site.ts rather than typed here.
* Notably: arbitration is scoped to COMMERCIAL matters throughout (§4
* Offerings, Q39 2026-08-27), the Q.Arb stage is stated on the page and not
* only in the footer, and nothing claims or implies licensure (D13).
*/
import { Picture, getImage } from 'astro:assets';
import BaseLayout from '../layouts/BaseLayout.astro';
import Button from '../components/Button.astro';
import ContactBand from '../components/ContactBand.astro';
import CredentialRow from '../components/CredentialRow.astro';
import Eyebrow from '../components/Eyebrow.astro';
import InfinityMark from '../components/InfinityMark.astro';
import PracticeCard from '../components/PracticeCard.astro';
import ProcessStep from '../components/ProcessStep.astro';
import SectionHeading from '../components/SectionHeading.astro';
import portrait from '../assets/pouya-lajevardi.jpg';
import ogDefault from '../assets/og-portrait.jpg';
import { homeGraph } from '../data/schema';
import {
ASYMMETRY_LINE,
CREDENTIAL_ROW,
CREDENTIAL_ROW_ARB,
PRACTICE_AREAS,
PORTRAIT,
PROCESS,
PROCESS_FRAMING,
SITE,
} from '../data/site';
/**
* FOUR SLOTS, NOT THREE, AND THE FOURTH IS NOT DECORATIVE.
*
* docs/01 §`/` item 2 says "Three slots". §4 Offerings is the higher authority
* on claims and attaches a PAIRED-DISCLOSURE CONDITION to offering arbitration
* at all: the site "makes the first while stating the second plainly", and
* "neither half may be dropped". This page says *arbitrator* in its second
* sentence, so the stage of the arc belongs on this page rather than only in
* the site footer. docs/03 authorises the fourth slot; docs/03 has been amended
* to record that on `/` it is required. Q.Arb reads as commenced — never as
* held or nearing completion (§4 Forbidden).
*/
const credentials = [...CREDENTIAL_ROW, CREDENTIAL_ROW_ARB];
/**
* docs/04 lists `image` on the Person node. This generates it with EXACTLY the
* transform SEO.astro already applies to the same source — jpeg, 1200 x 630 —
* so Astro's asset cache returns the same hashed file rather than emitting a
* second copy for the crawler's benefit. Verified by asset count: dist holds
* one `og-portrait.*.jpeg` with this line present, not two.
*
* JPEG on purpose, for the same reason SEO.astro uses it: link-preview and
* structured-data consumers are not browsers and several still do not decode
* WebP, let alone AVIF.
*/
const ldImage = await getImage({
src: ogDefault,
format: 'jpeg',
width: 1200,
height: 630,
});
const graph = homeGraph(new URL(ldImage.src, Astro.site).href);
---
<BaseLayout
title={`${SITE.name} · Mediation & Arbitration · Toronto`}
description="Commercial mediation and arbitration in Toronto. Construction, technology, energy, insurance and shareholder disputes, read as contracts and as engineering."
imageAlt={PORTRAIT.alt}
jsonLd={graph}
preloadSerifItalic
>
{/* ---- 1. Hero ------------------------------------------------------- */}
<section class="hero">
<div class="wrap hero-inner">
<div class="hero-copy">
{
/* docs/01 specifies this exact string as the hero eyebrow. The
masthead no longer repeats it on this page — see SiteHeader. */
}
<Eyebrow dot>{SITE.tagline}</Eyebrow>
{
/* docs/03 §Approved headline options, option 1 — the recommended one.
Option 3 is struck there: "every side" asserts having acted as
party, as counsel and as neutral, and §4 verifies one of the three.
The italic is the single flourish docs/02 allows in a headline. */
}
<h1 class="display hero-h">
A mediator who reads the contract, the code, and <em class="it"
>the room</em
>.
</h1>
{
/* TWO CORRECTIONS FROM `claims-auditor`, 2026-08-27, both about
implication rather than assertion — which is where D13 says the risk
actually lives.
(a) This read "I mediate and arbitrate commercial disputes". §4
verifies that he ACCEPTS arbitral appointments, and separately
verifies "multiple completed sole mediations" — there is no
counterpart row for a completed arbitration. Present-indicative
"arbitrate" beside "mediate" invites the reader to supply a track
record for both. The offering-shaped form is what the register
actually holds, and it is already the form the arbitration card
below uses.
(b) "facts most neutrals take on faith" is a COMPARATIVE assertion
about a population of third parties, and **Q41(b) CLOSED 2026-08-27:
it is not restored, and the reason is not only compliance.** Pouya:
*"That is an unverifiable empirical claim about other practitioners,
and comparative claims must be factual and verifiable. It is also
weaker copy: assert his capability, not the field's incapability."*
It is struck from `docs/03`'s core positioning statement too — the
approved-copy defence is gone, because the approved copy changed.
His replacement wording is used verbatim: *"built for disputes that
turn on the contract, the code, and the engineering documents"*. The
interim ("the documents rather than the pleadings") is also gone; it
said nothing about other neutrals but it still worked by contrast.
ON THE ECHO OF THE HEADLINE, because it is deliberate and one edit
from being reversed if he reads it as a stumble. The `<h1>` ends
"the contract, the code, and the room"; this sentence re-runs the
triad and swaps the third term for "the engineering documents". Two
of three words repeat forty words apart. Read as a rhyme it does the
work of the whole positioning statement in one move; read as an
oversight it looks careless. Judged the first, flagged as the
second. */
}
<p class="hero-lede">
I mediate commercial disputes from Toronto, and I accept commercial
arbitration appointments. I also practise as a machine-learning and
infrastructure engineer, so the matters I take are the ones that turn
on the contract, the code, and the engineering documents: the change
order, the model card, the System Impact Assessment, and the
regulatory overlay around them.
</p>
<div class="hero-cta">
<Button href="/contact/" variant="primary"
>Request a consultation &rarr;</Button
>
<Button href="/process/" variant="ghost">How I work</Button>
</div>
</div>
{
/* `widths` + `sizes` rather than `densities`: the portrait is fluid, and
a density ladder would size it from one assumed CSS width.
⚠️ THE OLD CEILING ARGUMENT WAS WRONG, AND IT WAS WRONG BY IGNORING
THE RANGE WHERE THIS IMAGE IS WIDEST. It read: "The largest real
render is ~476px (content 1280 - 96 gutter - 64 gap, x 0.425), so 960
is the 2x ceiling." That arithmetic describes the TWO-COLUMN layout,
which only engages at 66rem. Below 66rem the hero is a single column
and the portrait is the full content width. Measured, both pages:
viewport slot DPR-2 needs picked result
640 592 1184 760/960 1.23-1.56x upscale
768 672 1344 960 1.40x
900 804 1608 960 1.68x
1024 928 1856 960 1.93x
At 768/DPR-1 a 760w file EXISTS and is not chosen, so part of the loss
was purely the wrong `sizes` (60vw declared against a ~88vw slot).
Reconfirmed with the HTTP cache cleared — an earlier probe reported a
2.81x OVERSIZED fetch at 390/DPR-1 which was a cache artefact, not a
defect.
TWO FIXES, BOTH REQUIRED. (1) `.hero-portrait` is capped at 30rem in
the single-column range, so the widest real slot is 480 CSS px
everywhere — which makes 960 exactly right for DPR 2 rather than
accidentally short. (2) A 1440 rung, because 480 x 3 = 1440 and the
desktop slot at DPR 3 already needed 1287-1428; the ≥66rem range was
upscaling ~1.34-1.49x at DPR 3 before this and nobody had measured it.
The master is 1600, so 1440 exists.
THE CAP CHANGES HOW THIS PAGE LOOKS between 640px and 1055px — the
portrait was 592-928px wide there and is now 480. That is a visible
design change to a reviewed page, made on payload grounds; raising the
cap is a one-line change but the ladder has to grow with it.
`width` AND `height` ARE PASSED ALONGSIDE `widths`, AND THAT IS NOT
REDUNDANT. With `widths` alone, Astro emits the UNTOUCHED 1600px
master as the <img src> fallback — measured 254,626 bytes sitting in
dist as the declared fallback for a 476px slot. Passing width/height
pins it to the 960 variant instead (78,665 B), and the 1600px file
stops being generated at all. Same defect InfinityMark's comment
records for a missing `width`, in the one shape that survives passing
`widths`. Verified by reading the emitted `src` attribute and that
file's real dimensions — not the build log, which reported "before:
349kB" for every variant either way.
What a browser actually takes is the AVIF: 5.6 / 7.3 / 11.1 / 14.8 /
21.5 kB across the first five widths [measured 2026-08-27]; 1080 and
1440 are added 2026-08-28 for DPR 3 (26.6 / 48.8 kB).
AND THE 1440 RUNG OVERSHOT, SO 1080 EXISTS TO CORRECT IT. Adding 1440
for DPR 3 removed a 1.07x upscale at 390/DPR3 and replaced it with a
**48,799 B fetch where the old one was 21,526 B** — +27 KB on a phone,
to fix a 7% softness nobody can see. A browser takes the smallest
candidate at or above what it needs, and with no rung between 960 and
1440 the only choices were "slightly soft" or "+27 KB". 1080 makes
1026 (390 x DPR 3) exact and cheap. This was a defect in the fix for
the defect above, found by measuring the fix rather than the source.
AND 1080 ALONE MISSED THE TWO LARGEST CURRENT PHONES, WHICH
MAKES THIS THE THIRD ITERATION OF THIS LADDER. 1080 was tuned to
390 CSS px x DPR 3 (= 1026), and `sizes` resolves to
`calc(100vw - 3rem)` up to 528px, so every phone wider than 390
overshoots to the next rung: iPhone 14 Plus (428@3, needs 1140)
and 15/16 Pro Max (430@3, needs 1146) both took **1440 —
48,799 B**, against 27,594 for the device the rung was tuned
for. +21,205 B, 13% of page weight. A 1200 rung closes it at
1.05x. Measured after: no rung more than 1.06x oversized on the
phone axis, and still no upscaling anywhere — 0 upscaling across 11
real device profiles on both pages.
TWO RESIDUAL OVER-FETCHES, LEFT DELIBERATELY, so neither reads as an
oversight later. **320@2** needs 544 and takes 640 (1.18x): there is no
rung between 480 and 640, and 480 would be a 1.13x UPSCALE, so the
oversize is the better half of that trade. **1056@2 needs 760.4 and
takes 960 (1.26x, +6.8 KB)** — a knife-edge, and worth stating because
it looks like a `sizes` error and is not: the declared `36vw` is
accurate to the measured 36.0% track, and 380.2 x 2 = 760.4 misses the
760 rung by four tenths of a pixel. Declaring 35vw to duck under it
would make `sizes` less truthful across the whole band in exchange for
a 0.05% upscale at this width. The declaration stays honest and one
viewport over-fetches.
⚠️ `fetchpriority="high"` IS GONE, AND THIS PAGE HAD IT WHILE
`/about/` WITHHELD IT ON THE IDENTICAL MEASUREMENT. The old comment
read "eager + fetchpriority=high because this is the LCP candidate on
the page docs/04 budgets hardest" — true only from 768px up. Measured,
cache cleared per device:
320x568 @2 portrait visible 0px LCP — 11,058 B
360x780 @3 portrait visible 0px LCP — 21,526 B
390x844 @3 portrait visible 0px LCP P.hero-lede 27,594 B
430x932 @3 portrait visible 30px LCP P.hero-lede 48,799 B
1280x900 @1 portrait visible 595px LCP IMG.portrait 7,257 B
So on mobile — the axis the ≥95 budget is actually measured on — it
promoted 27-49 KB of image the reader cannot see above the Geist face
that paints the real LCP element. `/about/` already withheld it for
exactly this reason and this page did the opposite; the inconsistency
is the finding.
`loading="eager"` STAYS: the portrait is the LCP element from 768px up,
and eager costs nothing where it is off-screen. If the desktop LCP ever
needs protecting explicitly, the right instrument is
`<link rel="preload" imagesrcset imagesizes>` in <head>, which honours
`sizes` and therefore self-cancels on phones — not a blanket attribute
that cannot. */
}
<div class="hero-portrait">
<Picture
src={portrait}
width={960}
height={960}
widths={[380, 480, 640, 760, 960, 1080, 1200, 1440]}
sizes="(min-width: 80rem) 476px, (min-width: 66rem) 36vw, (min-width: 33rem) 480px, calc(100vw - 3rem)"
formats={['avif', 'webp']}
fallbackFormat="jpeg"
alt={PORTRAIT.alt}
loading="eager"
decoding="sync"
class="portrait-img"
/>
</div>
</div>
</section>
{/* ---- 2. Credential row --------------------------------------------- */}
<section class="wrap credential-band" aria-label="Credentials">
<CredentialRow slots={credentials} />
</section>
{
/* NO `aria-labelledby` ON THE CONTENT SECTIONS, and that is the fix for an
inconsistency rather than a removal of information.
Four of them carried one and this one did not, which put five named
regions plus header / nav / main / footer / footer-nav in a screen
reader's region list — eleven entries for a marketing page — with the only
unnamed content section being the odd one out. A `<section>` without an
accessible name is not exposed as a region at all, and the visible `<h2>`s
already give heading navigation, which is how a reader moves through a
page like this.
THE ONE EXCEPTION IS THE CREDENTIAL BAND, which has no visible heading, so
`aria-label` is the only thing that can name it. The rule is: name a region
only where it has no heading of its own. */
}
{/* ---- 3. The approach ----------------------------------------------- */}
<section class="section section-inverse approach reveal">
<div class="wrap approach-inner">
<div class="approach-copy">
<SectionHeading eyebrow="The approach" level={2}>
<span slot="heading">Two directions at once.</span>
</SectionHeading>
{
/* THIS PARAGRAPH USED TO OPEN "Law and engineering are not blended
here", and `claims-auditor` flagged it as Q37's struck parallel
relocated from the credential label into prose — a degree and a
practice under one noun, one day after Pouya struck exactly that
construction. It is a fair reading and the fix is the same fix:
make the two halves asymmetric. What a neutral does with a contract
is read it; what an engineer does is engineering. Neither sentence
now sets "Law" beside "engineering" as two instances of one thing.
`docs/01` §`/` item 3 and `docs/03` §Home both specify this section
as "the 'two directions at once' argument — law and engineering
converging on the same dispute", so the ARGUMENT is unchanged and
still delivered; only the construction that carried the implication
is gone.
**Q41(a) CLOSED 2026-08-27: Q37's reasoning DOES extend to prose,
and prose has to do more than avoid the parallel.** Pouya: *"The
implication test applies everywhere, not just to labels. Prose has
more room, so it is easier to satisfy: state the asymmetry
explicitly rather than relying on a parallel construction to carry
it."* Avoiding the pair was therefore only half the fix — a reader
can still supply the missing symmetry from silence. So the second
paragraph now names both halves for what they are: training on one
side, current work on the other. "Training I hold" is the opposite
of a licence claim, which is the point of saying it out loud.
`docs/01`'s and `docs/03`'s own phrase "law and engineering" is the
struck construction; both now carry a note not to lift it into copy.
The argument it names is Pouya's and stands. */
}
<div class="prose approach-prose">
<p>
Any dispute I take gets read twice: once against the documents, and
once as engineering. The two readings are not blended here. They run
at the same time.
</p>
{
/* FROM A CONSTANT, NOT TYPED. It was typed here and then typed
again on `/about/`, and the two copies had ALREADY diverged inside
one session — a comma here, full stops there. Q41(a) makes this
the sentence responsible for foreclosing the licence implication,
so it is the worst string on the site to let drift. See
ASYMMETRY_LINE in src/data/site.ts. */
}
<p>{ASYMMETRY_LINE}</p>
{
/* TWO COMPARATIVES CAME OUT OF THESE PARAGRAPHS ON 2026-08-28, and
both had SURVIVED the sweep that closed Q41(b) the day before:
"The second half of each pair usually arrives as a separate
expert report." — an empirical claim about how disputes are
usually run, i.e. about a population of other matters.
"it is why the technical half is not something a party has to
commission and wait for" — the same claim in counterfactual
form, which is harder to spot and says more.
Pouya's ruling on Q41(b) is the test: *"assert his capability, not
the field's incapability."* Both worked by asserting the field's.
What replaces them says only what he does, which is the stronger
claim anyway — and it is shorter. */
}
<p>
A construction claim is a contract question and a scheduling
question. A software dispute is a licence question and an
architecture question. A grid connection is a regulatory question
and a load question.
</p>
<p>
I read both halves of each pair myself. That is the whole of it.
</p>
<p class="approach-metaphor">
My mark is an infinity loop, and it is the argument in one line:
disputes are loops. The work is redrawing the loop into a line.
</p>
</div>
</div>
{
/* Decorative: the paragraph beside it says what it is, so an
accessible name here would be read twice. Measured on this ground —
the ribbon's champagne half carries the silhouette against ink, the
maroon half against cream; it reads on both. */
}
<div class="approach-mark">
{
/* width=232 and loading=lazy are both measured, not defaults.
`size` caps at 9rem tall, so the mark renders at up to 144 x 225.5
CSS px — five times the header's 50.1px, and the component's default
64px ladder tops out at 192px. Measured upscale before this: 1.17x
at DPR 1, 2.35x at DPR 2, 3.52x at DPR 3. 232 with densities
[1,2,3] gives 232/464/696, and 696 covers the 676 device px a DPR-3
screen asks for. Lazy because this sits roughly a screen and a half
down; the component defaults to eager for the masthead. */
}
<InfinityMark
size="clamp(4rem, 14vw, 9rem)"
width={232}
loading="lazy"
/>
</div>
</div>
</section>
{/* ---- 4. Two practices --------------------------------------------- */}
<section class="section practices reveal">
<div class="wrap">
{
/* The id goes on a span INSIDE the h2 via the `heading` slot, so
`aria-labelledby` names the visible heading. The first version passed
`title=` and additionally rendered a `.visually-hidden` span carrying
the id, which put the same words in the accessibility tree twice —
found in the heading-outline dump, not by reading the source. */
}
{
/* THE WRAPPER IS THE FIX, NOT DECORATION. This was
`<SectionHeading class="section-head">`, and the page's rule compiled
to `.section-head[data-astro-cid-<page>]` while the rendered root
carried SectionHeading's own cid — so it never matched. Measured:
`margin-block-end: 0px` and a 0px gap to the cards on all three call
sites, with `.display`'s 0.98 line-height putting the glyphs over the
card's top edge. `astro check` 0 errors, `eslint` clean, and the source
looked right. Fourth instance of this on the project; SectionHeading no
longer accepts a `class` at all, and passing one is now a build
error. */
}
<div class="section-head">
<SectionHeading eyebrow="What I do" level={2}>
<span slot="heading">Two processes.</span>
</SectionHeading>
</div>
<div class="pair">
<article class="feature">
<h3 class="feature-title">
<a href="/mediation/">Mediation</a>
</h3>
{
/* THE FEE CLAIM IS GONE, and it was wrong on two counts —
`claims-auditor`, 2026-08-27, checked against `docs/07-fees.md`.
"at one published rate" reads as ONE PRICE for half and full day;
D14's card sets TWO ($2,000 half, $4,000 full). And "preparation
time included" was unqualified where `docs/07` bundles a CAPPED
allowance and says in terms: *"must be stated on the page —
'including 2 hours of preparation' ... Do not quietly fold it into
the hours figure. At these rates, saying preparation is included
is the selling point, not a footnote."*
A home card is the wrong place to state it properly, and stating
it improperly misdescribes money. `/mediation/` (step 4) and
`/fees/` (step 9) carry the card. "Published" was also
forward-looking: `/fees/` does not exist yet. */
}
<p class="feature-body">
Sole mediator, Q.Med through ADRIC and ADRIO, with multiple
completed sole mediations. Half day or full day, in person or by
video.
</p>
<span class="feature-arrow" aria-hidden="true">&rarr;</span>
</article>
<article class="feature">
<h3 class="feature-title">
<a href="/arbitration/">Arbitration</a>
</h3>
{
/* docs/03's model sentence, and BOTH halves are required: §4
Offerings — "neither half may be dropped". Pouya's instruction:
being open about the stage is the differentiator, so it is not
hedged into vagueness and not dropped. Commercial matters only
(Q39): family arbitration is not offered. */
}
<p class="feature-body">
I accept sole, party-appointed and co-arbitration appointments in
commercial matters. The Q.Arb pathway commenced in August 2026;
C.Med-Arb is the endpoint.
</p>
<span class="feature-arrow" aria-hidden="true">&rarr;</span>
</article>
</div>
{
/* docs/01 §`/` item 4: "Med-Arb named here as the long-term arc,
linking to /med-arb/." It has had its own §4 Offerings row since
2026-08-27 (Q35), so it is named as offered rather than only as an
aspiration — but the arc is what docs/01 asks this page to carry. */
}
<p class="pair-note">
<strong>Med-Arb</strong> combines the two: one neutral mediates, then arbitrates
whatever has not settled. I accept those appointments, and C.Med-Arb is the
designation endpoint. The page on it meets the procedural-fairness objection
head on rather than around it &mdash;
<a href="/med-arb/">how med-arb works &rarr;</a>
</p>
</div>
</section>
{/* ---- 5. Practice areas -------------------------------------------- */}
{
/* docs/01: "the most important block on the page for search, because it
distributes authority to the pages that can actually rank." */
}
<section class="section section-alt areas reveal">
<div class="wrap">
<div class="section-head">
<SectionHeading
eyebrow="Practice areas"
level={2}
lede="Six areas, chosen because the disputes in them turn on documents I can read without an intermediary."
>
<span slot="heading">Where the work is.</span>
</SectionHeading>
</div>
{
/* reveal-stagger is capped at six children by design (docs/02) and there
are exactly six. A seventh would land with the sixth, not break. */
}
<div class="area-grid reveal-stagger">
{
PRACTICE_AREAS.map((area) => (
<PracticeCard
href={`/practice/${area.slug}/`}
chip={area.chip}
title={area.name}
level={3}
>
{area.blurb}
</PracticeCard>
))
}
</div>
{
/* THIS SAID "All six areas, and what else is offered", which asserted
offerings beyond the six, none of which had a §4 row.
**Q42 CLOSED 2026-08-27, and one of the four candidates came out.**
Early neutral evaluation, dispute-system design and pre-dispute
technical advisory now have Offerings rows. **Settlement counsel does
not, and never will** — Pouya struck it as his own error in `docs/01`:
*"Settlement counsel acts FOR a party in negotiation. That is a
partisan role, and putting it on a site that (a) sells neutrality and
(b) asserts no licensure under D13 is wrong twice over."*
THIS LINE STILL READS "All six practice areas" and that is unchanged
on purpose. The three rowed processes are `/practice/`'s "also
offered" strip at step 5, not a claim `/` makes in a link label — a
six-card grid followed by "and what else is offered" is the
Med-Arb-in-the-footer shape whether or not the rows exist. */
}
<p class="areas-more">
<a href="/practice/">All six practice areas &rarr;</a>
</p>
</div>
</section>
{/* ---- 6. Process preview ------------------------------------------- */}
<section class="section process reveal">
<div class="wrap">
<div class="section-head">
<SectionHeading
eyebrow="How it runs"
level={2}
lede="Five steps, from the first call to the conclusion — including what happens if the matter does not settle."
>
<span slot="heading">From first call to conclusion.</span>
</SectionHeading>
</div>
<ol class="process-strip" role="list">
{
PROCESS.map((step, i) => (
<ProcessStep n={i + 1} title={step.title} timing={step.timing}>
{step.body}
</ProcessStep>
))
}
</ol>
{
/* NOT OPTIONAL, AND NOT A DISCLAIMER — Q43, Pouya 2026-08-27. It is the
condition on which the five timings may be published at all: *"Published
as typical, they are honest and useful; published as commitments, the
first matter that slips makes the page false."* It sits directly under
the numbers rather than in the section lede above them, because a
reader who scans the strip and skips the lede has read a commitment.
`/process/` renders the same constant at step 6. */
}
<p class="process-framing">{PROCESS_FRAMING}</p>
<p class="process-more">
<a href="/process/">What happens if the matter does not settle &rarr;</a
>
</p>
</div>
</section>
{/* ---- 7. Latest insights: NOT BUILT AT STEP 2. See the header note. -- */}
{/* ---- 8. Contact band ---------------------------------------------- */}
{
/* A COMPONENT SINCE 2026-08-28. It was ~20 lines of markup plus ~20 of CSS
here and the same again on `/about/`, and the two had already drifted
(`.contact-body` at 52ch here, 46ch there) inside the session that wrote
the second one. Seventeen pages remain. docs/01 item 8's missing booking
link is documented in the component, once. */
}
<ContactBand />
</BaseLayout>
<style>
/* --- 1. Hero -------------------------------------------------------- */
.hero {
padding-block: var(--space-8) var(--space-9);
}
.hero-inner {
display: grid;
gap: var(--space-7);
align-items: center;
}
.hero-copy {
display: flex;
flex-direction: column;
gap: var(--space-5);
}
.hero-h {
/* --text-6xl is the display ceiling. At 360px it is 52px and the headline
runs four lines; `text-wrap: balance` (global.css) keeps them even. */
font-size: var(--text-6xl);
max-inline-size: 22ch;
}
.hero-lede {
max-inline-size: var(--width-prose);
font-size: var(--text-lg);
line-height: var(--leading-relaxed);
color: var(--text-secondary);
}
.hero-cta {
display: flex;
flex-wrap: wrap;
gap: var(--space-4);
margin-block-start: var(--space-2);
}
/* The 4:5 crop is CSS, not a build step: Astro's image service does not crop,
and cropping offline would mean committing a second derived binary on top
of the ~3.16 MB of brand and portrait masters already in the repo. The cost
is that a browser downloads the full square and shows 80% of it; the source
is 1600 x 1600 and the largest variant generated is 960 wide. */
.hero-portrait {
aspect-ratio: 4 / 5;
overflow: hidden;
border-radius: var(--radius-lg);
background: var(--bg-raised);
/* THE CAP IS THE FIX FOR THE UPSCALE, not a style preference. Uncapped, the
single-column slot ran to 928 CSS px at a 1024px viewport, which is 1856
device px at DPR 2 against a 960w ceiling — 1.93x. 30rem makes 480 the
widest real slot on any page, so the ladder's 960 covers DPR 2 exactly
and the new 1440 rung covers DPR 3. See the <Picture> comment above.
`margin-inline: auto` because a 480px box in a 928px column would
otherwise sit hard against the inline start. */
max-inline-size: 30rem;
margin-inline: auto;
}
/* The <picture> wrapper is the box that gets sized, NOT the <img> — the exact
defect CLAUDE.md records for <Button> and then for <Picture> inside
InfinityMark. `class="portrait-img"` lands on the <img>, so the <img> rule
below is reached via Astro's cid on the emitted element, and the wrapper is
targeted by the bare `picture` selector, which the markup does carry. */
.hero-portrait :global(picture) {
display: block;
block-size: 100%;
}
.portrait-img {
inline-size: 100%;
block-size: 100%;
object-fit: cover;
/* Above centre: the subject's head is in the upper half of a square crop. */
object-position: 50% 22%;
}
@media (min-width: 66rem) {
.hero-portrait {
/* Above 66rem the grid track is already 390-476px, so the cap is inert —
released anyway so the track, not this number, governs the two-column
layout. Keeping it would silently become the constraint if the track
ever widened. */
max-inline-size: none;
}
.hero {
padding-block: var(--space-9);
}
.hero-inner {
/* 1.15 / 0.85 — the copy column carries a 22ch headline and a 68ch lede;
an even split starves the headline and leaves the portrait oversized. */
grid-template-columns: 1.15fr 0.85fr;
gap: var(--space-8);
}
}
/* --- 2. Credential row ---------------------------------------------- */
.credential-band {
/* No .section wrapper: the row owns its own padding-block and border, and
stacking --section-y on top would put 160px of air around a 4-line band. */
padding-block-end: var(--space-4);
}
/* --- 3. The approach ------------------------------------------------ */
.approach-inner {
display: grid;
gap: var(--space-7);
align-items: center;
}
/* NO `display: flex; gap` ANY MORE, AND THAT IS A CORRECTION TO A FIX.
global.css gained `:where(.prose) > p + p { margin-block-start }` on
2026-08-28 because a bare `.prose` block had no paragraph spacing at all.
The new rule's comment asserted "with `:where()` the flex container's gap
governs and this contributes nothing" — **false, and it was measured false
immediately after being written.** `:where()` controls SPECIFICITY, not
whether a declaration applies: nothing here was overriding the margin, so
flex `gap` and the margin both applied and this block's paragraph gaps went
**24px -> 48px**. Spacing now comes from the one global rule, which is the
point of having it. */
.approach-prose {
margin-block-start: var(--space-6);
font-size: var(--text-lg);
line-height: var(--leading-relaxed);
}
.approach-metaphor {
padding-block-start: var(--space-5);
border-block-start: 1px solid var(--rule);
font-family: var(--font-serif);
font-size: var(--text-xl);
line-height: var(--leading-snug);
color: var(--text-inverse-2);
}
.approach-mark {
display: flex;
justify-content: center;
}
@media (min-width: 66rem) {
.approach-inner {
grid-template-columns: 1fr auto;
gap: var(--space-9);
}
/* Mark second in the DOM and second visually. No `order` anywhere on this
page: reordering flex or grid items puts focus order out of step with
visual order, which is what WCAG 2.4.3 and docs/02 both forbid, and it
already cost a header rebuild at step 1. */
}
/* --- 4. Two practices ---------------------------------------------- */
/* NO `.section-head` RULE — it moved to `global.css`, where `.prose` lives.
It was byte-identical in both pages, and every one of the seventeen
remaining pages needs it for the same reason (a parent cannot style a child
component's root, so the wrapper must be page-owned). */
.pair {
display: grid;
gap: var(--space-5);
}
.feature {
position: relative;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--space-4);
/* CLAMPED, not a flat --space-7. At a 200% default font size (root 32px)
`3rem` is 96px a side — 192px of horizontal padding inside a 342px
container, which was most of the 83px residual overflow
`adversarial-reviewer` measured. The vw term keeps it at 48px on any real
viewport and lets it collapse when the rem is doubled. */
padding: clamp(var(--space-5), 4vw, var(--space-7));
background: var(--bg-raised);
border-radius: var(--radius-lg);
transition: background-color var(--dur-hover) var(--ease);
}
.feature:hover {
background: var(--cream-2);
}
.feature-title {
font-family: var(--font-serif);
font-size: var(--text-3xl);
line-height: var(--leading-tight);
letter-spacing: var(--tracking-tight);
/* `overflow-wrap: break-word` (global.css) permits a break at layout time
but does NOT reduce min-content size, so "Arbitration" at a 60px
--text-3xl held the card open. `anywhere` does reduce it. Only reachable
at a large default font size; at every real size the word never breaks. */
overflow-wrap: anywhere;
}
.feature-title a {
color: var(--text);
text-decoration: none;
}
/* One link per card, hit area the whole card, accessible name the heading —
same pattern and same reasoning as PracticeCard. */
.feature-title a::after {
content: '';
position: absolute;
inset: 0;
border-radius: var(--radius-lg);
}
.feature:hover .feature-title a {
color: var(--accent);
}
.feature:has(a:focus-visible) {
outline: 2px solid var(--focus-ring);
outline-offset: var(--focus-offset);
}
.feature-title a:focus-visible {
outline: none;
}
.feature-body {
flex: 1 1 auto;
max-inline-size: 46ch;
color: var(--text-secondary);
}
.feature-arrow {
font-size: var(--text-xl);
line-height: 1;
color: var(--accent);
}
.pair-note {
max-inline-size: var(--width-prose);
margin-block-start: var(--space-6);
color: var(--text-secondary);
}
@media (min-width: 56rem) {
.pair {
grid-template-columns: 1fr 1fr;
gap: var(--space-6);
}
}
/* --- 5. Practice areas --------------------------------------------- */
.area-grid {
display: grid;
/* auto-fit with an 18rem floor: 1 up on a phone, 2 up on a tablet, 3 up on
a desktop, with no breakpoint of its own. The cards size themselves to
the cell (see PracticeCard) — a parent cannot reach a child's root.
`min(18rem, 100%)`: a bare rem floor is a hard minimum, so at a large
default font size (root 32px) 18rem becomes 576px and the track will not
shrink. Measured at root 200%: this grid overflowed a 390px viewport by
234px. See CredentialRow for the same guard and the full reasoning. */
grid-template-columns: repeat(auto-fit, minmax(min(18rem, 100%), 1fr));
gap: var(--space-5);
}
/* THESE TWO LINKS STAND ALONE ON THEIR OWN LINE, so docs/02's 44px touch
floor applies to them in full. Measured before this rule: 250.8 x 18 and
287.9 x 18 — the paragraph's line box and nothing more.
`inline-flex` + min-block-size rather than padding, so the 44px IS the hit
area rather than visual air, and `inline-size: fit-content` keeps the
target the width of the words instead of the whole measure.
The INLINE link in `.pair-note` ("how med-arb works") is deliberately NOT
given this treatment: it sits mid-sentence at 164 x 21, and WCAG 2.5.8
exempts a target "in a sentence or its size is otherwise constrained by the
line-height of non-target text". Padding it out would break the paragraph's
leading to satisfy a rule that does not apply to it. A deviation from
docs/02's flat wording, taken deliberately and recorded rather than left to
look like an oversight.
The card heading links measure 26-39px tall and are NOT a finding: each
card's whole box is the link's hit area via `::after { inset: 0 }`. Verified
by hit-testing nine points per card at 390 / 768 / 1280px — 24 cards, 9/9
inside the link every time. The width sweep flagged them because it
measured the <a>'s own box, which is not the target. */
.areas-more,
.process-more {
margin-block-start: var(--space-6);
font-size: var(--text-sm);
}
.areas-more a,
.process-more a {
display: inline-flex;
align-items: center;
inline-size: fit-content;
min-block-size: 44px;
}
/* --- 6. Process preview -------------------------------------------- */
.process-strip {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(13rem, 100%), 1fr));
gap: var(--space-5);
margin: 0;
}
/* --space-5 rather than --space-6: this belongs to the strip above it, not to
the link below.
THE RATIO IN THIS COMMENT WAS WRONG WHEN FIRST WRITTEN. It said
"--text-meta on cream measures 3.07:1". It does not: `#6e6359` on `#faf7f2`
measures **5.47:1** and passes AA. 3.07:1 is `--muted` on **ink**, which is
exactly what `tokens.css` says and what this comment misread. So the
*reason* given was false even though the *choice* is right —
--text-secondary (10.76-11.75:1) is correct here because this sentence is a
CONDITION on the numbers above it, not metadata about them, and a condition
has to read like body copy. Corrected 2026-08-28 on
`adversarial-reviewer`'s measurement. */
.process-framing {
margin-block-start: var(--space-5);
max-inline-size: var(--width-prose);
font-size: var(--text-sm);
color: var(--text-secondary);
}
/* NO CONTACT-BAND RULES HERE. `.contact-inner`, `.contact-h`, `.contact-body`
and `.contact-action` moved to `ContactBand.astro` with the markup they
style. They were left behind after the extraction — dead CSS shipping to
every visitor, and worse, the **46ch / 52ch divergence the extraction
existed to end was still sitting on disk in both pages**, so the next reader
would have found two different values and no rendered difference. Deleted
2026-08-28. */
</style>
+487 -75
View File
@@ -5,44 +5,127 @@
@import './tokens.css';
/* --- Fonts: self-hosted, subset, swap. No runtime Google Fonts request. ----
TODO(claude-code): place subset woff2 files in /public/fonts/ and preload
Instrument Serif 400 and Geist 400 in BaseLayout — they are the only two
faces used above the fold. */
Files and their provenance: docs/reference/fonts-provenance.md.
Filenames are stable on purpose — a preload needs a path that does not change
between builds, which rules out Astro's hashed asset pipeline.
`?v=1` IS LOAD-BEARING. scripts/deploy-local.sh serves /fonts/* with
`max-age=31536000, immutable`, so a returning visitor holds these bytes for a
year and a CloudFront invalidation cannot reach their browser cache. Bump the
query when a file's contents change — here AND on the preload in
BaseLayout.astro, which must match byte for byte or the preload is a second,
wasted request instead of a warmed cache.
The `latin` cut of each face is listed FIRST and the `latin-ext` cut second.
Order matters: where two @font-face rules for one family both match a
codepoint, the last wins. Latin-ext is the wider, heavier file; putting it
last would hand it every ASCII character on the page. */
@font-face {
font-family: 'Instrument Serif';
src: url('/fonts/instrument-serif-400.woff2') format('woff2');
font-weight: 400; font-style: normal; font-display: swap;
unicode-range: U+0000-00FF, U+0100-017F, U+2000-206F, U+2190-21BB;
src: url('/fonts/instrument-serif-latin-400-normal.woff2?v=1') format('woff2');
font-weight: 400;
font-style: normal;
font-display: swap;
unicode-range:
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC,
U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212,
U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: 'Instrument Serif';
src: url('/fonts/instrument-serif-400-italic.woff2') format('woff2');
font-weight: 400; font-style: italic; font-display: swap;
unicode-range: U+0000-00FF, U+0100-017F, U+2000-206F;
src: url('/fonts/instrument-serif-latin-ext-400-normal.woff2?v=1')
format('woff2');
font-weight: 400;
font-style: normal;
font-display: swap;
unicode-range:
U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304,
U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB,
U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* Italic is the one flourish the design allows (docs/02) — a phrase inside a
headline, never a paragraph. Latin only; there is no latin-ext italic file. */
@font-face {
font-family: 'Instrument Serif';
src: url('/fonts/instrument-serif-latin-400-italic.woff2?v=1') format('woff2');
font-weight: 400;
font-style: italic;
font-display: swap;
unicode-range:
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC,
U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212,
U+2215, U+FEFF, U+FFFD;
}
/* Geist and Geist Mono are variable fonts: one file spans the whole weight
axis, so 300/400/500/600 cost nothing extra. `font-weight: 100 900` declares
the axis range the file actually carries — narrowing it here would make the
browser synthesise weights it already has. */
@font-face {
font-family: 'Geist';
src: url('/fonts/geist-latin-wght-normal.woff2?v=1')
format('woff2-variations');
font-weight: 100 900;
font-style: normal;
font-display: swap;
unicode-range:
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC,
U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212,
U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: 'Geist';
src: url('/fonts/geist-variable.woff2') format('woff2-variations');
font-weight: 300 600; font-style: normal; font-display: swap;
unicode-range: U+0000-00FF, U+0100-017F, U+2000-206F, U+2190-21BB;
src: url('/fonts/geist-latin-ext-wght-normal.woff2?v=1')
format('woff2-variations');
font-weight: 100 900;
font-style: normal;
font-display: swap;
unicode-range:
U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304,
U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB,
U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
@font-face {
font-family: 'Geist Mono';
src: url('/fonts/geist-mono-variable.woff2') format('woff2-variations');
font-weight: 400 500; font-style: normal; font-display: swap;
unicode-range: U+0000-00FF, U+2000-206F;
src: url('/fonts/geist-mono-latin-wght-normal.woff2?v=1')
format('woff2-variations');
font-weight: 100 900;
font-style: normal;
font-display: swap;
unicode-range:
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC,
U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212,
U+2215, U+FEFF, U+FFFD;
}
/* --- Reset ---------------------------------------------------------------- */
*, *::before, *::after { box-sizing: border-box; }
* { margin: 0; }
*,
*::before,
*::after {
box-sizing: border-box;
}
* {
margin: 0;
}
html {
-webkit-text-size-adjust: 100%;
scroll-behavior: smooth;
scroll-padding-top: var(--space-8);
/* No offset by default: below 66rem the header is not sticky, so nothing is
covering the target. See the media query below. */
scroll-padding-top: var(--space-4);
}
/* The header is sticky from 66rem up, and `scroll-padding-top` has to clear it
or "Skip to content" drops the reader behind it — the one control that exists
specifically for keyboard users, landing them on content they cannot see.
--header-h is defined in tokens.css beside the value it has to match. */
@media (min-width: 66rem) {
html {
scroll-padding-top: calc(var(--header-h) + var(--space-4));
}
}
body {
@@ -55,19 +138,56 @@ body {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
overflow-x: hidden;
min-height: 100vh;
/* No `overflow-x: hidden`. It was here, and it was hiding a real defect: at
320px the page overflowed by 72px and three cells sat outside the viewport
with no scrollbar to reach them — WCAG 1.4.10 content loss, silently
masked. A global overflow clamp turns every future layout bug on nineteen
pages into an invisible one. Fix the overflow; do not hide it. */
}
img, picture, video, canvas, svg { display: block; max-width: 100%; }
img { height: auto; }
input, button, textarea, select { font: inherit; color: inherit; }
p, h1, h2, h3, h4, h5, h6 { overflow-wrap: break-word; }
ul[role='list'], ol[role='list'] { list-style: none; padding: 0; }
img,
picture,
video,
canvas,
svg {
display: block;
max-width: 100%;
}
img {
height: auto;
}
input,
button,
textarea,
select {
font: inherit;
color: inherit;
}
p,
h1,
h2,
h3,
h4,
h5,
h6 {
overflow-wrap: break-word;
}
ul[role='list'],
ol[role='list'] {
list-style: none;
padding: 0;
}
/* --- Type ----------------------------------------------------------------- */
h1, h2, h3, h4 { font-weight: var(--weight-normal); text-wrap: balance; }
h1,
h2,
h3,
h4 {
font-weight: var(--weight-normal);
text-wrap: balance;
}
.display {
font-family: var(--font-serif);
@@ -76,7 +196,9 @@ h1, h2, h3, h4 { font-weight: var(--weight-normal); text-wrap: balance; }
letter-spacing: var(--tracking-display);
}
/* The one flourish the design allows. One italic phrase per headline, max. */
.display .it { font-style: italic; }
.display .it {
font-style: italic;
}
.eyebrow {
font-family: var(--font-mono);
@@ -89,26 +211,38 @@ h1, h2, h3, h4 { font-weight: var(--weight-normal); text-wrap: balance; }
/* An eyebrow is a label, never the page's heading element. */
.eyebrow .dot {
display: inline-block;
inline-size: 6px; block-size: 6px;
inline-size: 6px;
block-size: 6px;
border-radius: 50%;
background: var(--accent);
margin-inline-end: var(--space-3);
vertical-align: 0.15em;
}
p { max-inline-size: var(--width-prose); }
/* NO GLOBAL `p { max-inline-size }`. It was here, and it capped every paragraph
on the site — inside cards, footers, and form hints — so components had to
opt back out one by one, and it made `.prose` below a class with no effect,
since every <p> was already capped. Long-form opts IN. */
a { color: var(--link); text-decoration-thickness: 1px; text-underline-offset: 0.2em; }
a:hover { color: var(--accent-hover); }
a {
color: var(--link);
text-decoration-thickness: 1px;
text-underline-offset: 0.2em;
}
a:hover {
color: var(--accent-hover);
}
/* --- Focus: visible, always. The previous build removed it globally. ------- */
:focus-visible {
outline: 2px solid var(--focus-ring);
outline-offset: 3px;
outline-offset: var(--focus-offset);
border-radius: var(--radius-sm);
}
:focus:not(:focus-visible) { outline: none; }
:focus:not(:focus-visible) {
outline: none;
}
.skip-link {
position: absolute;
@@ -122,71 +256,349 @@ a:hover { color: var(--accent-hover); }
transform: translateY(-200%);
transition: transform var(--dur-fast) var(--ease);
}
.skip-link:focus { transform: translateY(0); }
.skip-link:focus {
transform: translateY(0);
}
::selection { background: var(--accent); color: var(--text-inverse); }
::selection {
background: var(--accent);
color: var(--text-inverse);
}
/* --- Layout --------------------------------------------------------------- */
.wrap { inline-size: 100%; max-inline-size: var(--width-content); margin-inline: auto; padding-inline: var(--gutter); }
.wrap-wide { max-inline-size: var(--width-wide); }
.prose { max-inline-size: var(--width-prose); }
.section { padding-block: var(--section-y); }
.section-alt { background: var(--bg-alt); }
.section-inverse { background: var(--bg-inverse); color: var(--text-inverse); }
.section-inverse .eyebrow,
.section-inverse .text-meta { color: var(--text-inverse-2); }
.wrap {
inline-size: 100%;
max-inline-size: var(--width-content);
margin-inline: auto;
padding-inline: var(--gutter);
}
.wrap-wide {
max-inline-size: var(--width-wide);
}
/* The reading measure, opted into. docs/02 caps body copy at 68ch; the old
build ran full-bleed paragraphs at 1400px. Wrap long-form in `.prose`, and
let the MDX `Prose` component own it for articles. */
.prose,
.prose p {
max-inline-size: var(--width-prose);
}
/* `.prose` HAD NO PARAGRAPH SPACING, AND NOTHING ANYWHERE SUPPLIED IT.
The reset above sets `* { margin: 0 }`, so a bare `.prose` with two <p>
children rendered them as one block. Measured on `/about/` §Language:
**gap between paragraph 1 and paragraph 2 = 0.0px** — "…history arrive
together." running straight into "Working in the parties' own language…", on
screen and in the printed PDF.
hr { border: none; border-block-start: 1px solid var(--border); }
.rule-gold { border: none; border-block-start: 1px solid var(--rule); }
It survived step 2 because BOTH of `/`'s prose blocks supply their own
spacing: `.approach-prose` uses `display:flex; gap`, and `.bio-prose` on
`/about/` has its own `> p + p`. So the only two call sites happened to opt
out of the defect. The rule belongs HERE, where `.prose` lives, or it has to
be remembered on all fifteen remaining pages.
`:where()` KEEPS THE SPECIFICITY AT ZERO so a component's own rule for the
SAME PROPERTY wins without `!important`.
⚠️ IT DOES NOT PROTECT AGAINST A FLEX `gap`, AND THIS COMMENT ONCE CLAIMED IT
DID — "Verified: with `:where()` the flex container's gap governs and this
contributes nothing." That was false and was measured false minutes later:
`:where()` lowers SPECIFICITY, which only matters when two rules set the same
property. A flex `gap` is a different property, so gap and margin both apply
and add. `/`'s `.approach-prose` went **24px -> 48px** on the strength of that
sentence. It has been converted to use this rule instead of a `gap`, and
`.bio-prose`'s duplicate `> p + p` was removed for the same reason. If a
future block needs different spacing, override `margin-block-start` — do not
reach for `gap`. */
:where(.prose) > p + p {
margin-block-start: var(--space-5);
}
/* THE PAGE-OWNED WRAPPER FOR `SectionHeading`, and it lives here because it was
byte-identical in two pages with seventeen to come. It exists only because a
parent cannot style a child component's root (`CLAUDE.md`; measured on
`SectionHeading`), so every page that uses a section heading needs a wrapper
it owns — which means every page needs this rule. Same argument that extracted
`ContactBand`, applied to a rule instead of a component. */
.section-head {
margin-block-end: var(--space-7);
}
.section {
padding-block: var(--section-y);
}
.section-alt {
background: var(--bg-alt);
}
.section-inverse {
background: var(--bg-inverse);
color: var(--text-inverse);
}
.section-inverse .eyebrow,
.section-inverse .text-meta {
color: var(--text-inverse-2);
}
/* The conversion band. Maroon rather than ink so it reads as an action and not
as a second footer — the real footer is ink and sits directly beneath it.
Cream on maroon measures 12.29:1; gold-l on maroon 8.11:1 (docs/02). */
.section-accent {
background: var(--accent);
color: var(--text-inverse);
}
.section-accent .eyebrow,
.section-accent .text-meta {
color: var(--text-inverse-2);
}
/* Both inverse families need a focus ring that is visible ON them: the default
ring is --maroon, which is 1.00:1 against the accent band's own background
and 1.21:1 against ink. Gold-l measures 8.11:1 on maroon and 11.09:1 on ink.
Without this the ring exists and cannot be seen, which is the same failure as
not having one. */
.section-inverse :focus-visible,
.section-accent :focus-visible {
outline-color: var(--gold-l);
}
/* The eyebrow's dot is a --accent (maroon) box, so recolouring only the TEXT
for an inverse ground leaves the dot at 1.21:1 on ink and 1.00:1 on the
accent band — present in the markup, invisible on the page. Colour never
carries meaning alone here (the dot is decorative and aria-hidden), so this
is a design defect rather than a WCAG one; it is still a mark nobody can see.
Measured 2026-08-27. */
.section-inverse .eyebrow .dot,
.section-accent .eyebrow .dot {
background: var(--text-inverse-2);
}
/* Pill reads its colours from custom properties, which are the one thing that
crosses Astro's component-scope boundary (they inherit). See Pill.astro. */
.section-inverse,
.section-accent {
--pill-border: var(--line-dark);
--pill-fg: var(--text-inverse-2);
}
hr {
border: none;
border-block-start: 1px solid var(--border);
}
.rule-gold {
border: none;
border-block-start: 1px solid var(--rule);
}
.visually-hidden {
position: absolute; inline-size: 1px; block-size: 1px;
padding: 0; margin: -1px; overflow: hidden;
clip-path: inset(50%); white-space: nowrap; border: 0;
position: absolute;
inline-size: 1px;
block-size: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
/* --- Reveal ---------------------------------------------------------------
Progressive enhancement, not a dependency. Content is rendered and visible
in the HTML; `.reveal` only takes effect once JS adds `js-reveal` to <html>.
If the observer never runs, every page reads normally. The previous build
had this backwards and shipped a blank page to anything without JS. */
/* --- Reveal ----------------------------------------------------------------
Scroll-driven CSS. There is NO JavaScript on this site, and this block is
why: the reveal used to be an inline IntersectionObserver in <head>, which
collided with the Content-Security-Policy docs/05-backend-spec.md specifies
(`script-src 'self'`, no `unsafe-inline`, "use a hash or nonce for the reveal
script"). A per-build hash is a moving target and drifts from the policy.
`animation-timeline: view()` is what docs/02 §Motion offers as the
alternative, and it removes the script — and the problem — entirely.
.js-reveal .reveal { opacity: 0; transform: translateY(20px); }
.js-reveal .reveal.is-in {
opacity: 1; transform: none;
transition: opacity var(--dur-reveal) var(--ease), transform var(--dur-reveal) var(--ease);
The @supports gate is load-bearing, not defensive. Without it a browser that
ignores `animation-timeline` would run the animation once against the
document timeline at load; with it, that browser gets no animation and fully
visible content. Content is never hidden behind a feature that might not
arrive. The previous build had this backwards and shipped a blank page to
anything without JavaScript. */
@supports (animation-timeline: view()) {
@media (prefers-reduced-motion: no-preference) {
/* LONGHANDS ONLY. `animation: reveal-in linear both` beside
`animation-timeline: view()` is folded by Lightning CSS on minify into
`animation: linear both reveal-in view()`, which is invalid — `view()` is
not a component of the shorthand — so the whole declaration is thrown
away. It works in `npm run dev` and is dead in `npm run build`. This is
the same defect the header's condense had; it was found there first and
written straight back into the fix for it. Grep dist for it (Phase 5). */
.reveal {
animation-name: reveal-in;
animation-duration: 1ms;
animation-timing-function: linear;
animation-fill-mode: both;
animation-timeline: view();
animation-range: entry 0% cover 22%;
}
.reveal-stagger > * {
animation-name: reveal-in;
animation-duration: 1ms;
animation-timing-function: linear;
animation-fill-mode: both;
animation-timeline: view();
}
/* Stagger is expressed as timeline range, not delay: a scroll-driven
animation has no wall clock to delay against. Each child completes a
little further into the scroll than the one before. Six children by
design (docs/02) — a seventh simply lands with the sixth. */
.reveal-stagger > *:nth-child(1) {
animation-range: entry 0% cover 18%;
}
.reveal-stagger > *:nth-child(2) {
animation-range: entry 0% cover 22%;
}
.reveal-stagger > *:nth-child(3) {
animation-range: entry 0% cover 26%;
}
.reveal-stagger > *:nth-child(4) {
animation-range: entry 0% cover 30%;
}
.reveal-stagger > *:nth-child(5) {
animation-range: entry 0% cover 34%;
}
.reveal-stagger > *:nth-child(6) {
animation-range: entry 0% cover 38%;
}
}
}
@keyframes reveal-in {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: none;
}
.js-reveal .reveal-stagger > * { opacity: 0; transform: translateY(16px); }
.js-reveal .reveal-stagger.is-in > * {
opacity: 1; transform: none;
transition: opacity var(--dur-reveal) var(--ease), transform var(--dur-reveal) var(--ease);
}
.js-reveal .reveal-stagger.is-in > *:nth-child(1) { transition-delay: 0ms; }
.js-reveal .reveal-stagger.is-in > *:nth-child(2) { transition-delay: 70ms; }
.js-reveal .reveal-stagger.is-in > *:nth-child(3) { transition-delay: 140ms; }
.js-reveal .reveal-stagger.is-in > *:nth-child(4) { transition-delay: 210ms; }
.js-reveal .reveal-stagger.is-in > *:nth-child(5) { transition-delay: 280ms; }
.js-reveal .reveal-stagger.is-in > *:nth-child(6) { transition-delay: 350ms; }
/* Stagger caps at six children by design. */
@media (prefers-reduced-motion: reduce) {
html { scroll-behavior: auto; }
*, *::before, *::after {
html {
scroll-behavior: auto;
}
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
.js-reveal .reveal,
.js-reveal .reveal-stagger > * { opacity: 1 !important; transform: none !important; }
/* Belt and braces. The @supports block above is already gated on
no-preference, so nothing here should be animating at all — this keeps the
guarantee true even if a later rule forgets the gate. */
.reveal,
.reveal-stagger > * {
animation: none !important;
opacity: 1 !important;
transform: none !important;
}
}
/* A scroll-driven animation has no timeline when printing, so every revealed
element would render at its `from` state — which is `opacity: 0`. Measured
before this block existed: printing the page to PDF dropped four card
headings from the output entirely. `/about/` is written to be printed by
people evaluating an appointment; content that vanishes at Cmd-P is not a
cosmetic problem. */
@media print {
.reveal,
.reveal-stagger > * {
animation: none !important;
opacity: 1 !important;
transform: none !important;
}
}
/* --- Print: the About page will be printed by people evaluating an appointment */
@media print {
body { background: #fff; color: #000; font-size: 11pt; }
.site-header, .site-footer, .skip-link, .no-print { display: none !important; }
a[href^='http']::after { content: ' (' attr(href) ')'; font-size: 9pt; }
.section { padding-block: var(--space-5); }
body {
background: #fff;
color: #000;
font-size: 11pt;
}
.site-header,
.site-footer,
.skip-link,
.no-print {
display: none !important;
}
a[href^='http']::after {
content: ' (' attr(href) ')';
font-size: 9pt;
}
.section {
padding-block: var(--space-5);
}
/* THE INVERSE GROUNDS HAD TO BE NEUTRALISED AND WERE NOT — and this block's
own heading says why it matters: the About page is printed by people
evaluating an appointment.
`print-color-adjust` defaults to `economy`, so a UA drops the BACKGROUND
and keeps the text. Chrome's default print dialog has "Background graphics"
unchecked, so `.section-inverse` and `.section-accent` printed cream text
on white paper. Measured with `Page.printToPDF`, `printBackground: false`,
rasterised at 150 dpi: the dominant glyph colour across the whole arc block
was **rgb(166,164,161) — 2.49:1 against white**, and that grey is Chrome's
own legibility fudge. The DECLARED colour is cream at ~1.04:1, which is
what a UA without that fudge renders. With `printBackground: true` the
pages are correct, which is what isolates the cause.
What vanished was the Q.Med / Q.Arb / C.Med-Arb progression on `/about/` —
§4's paired disclosure — plus the contact band. `!important` because the
rules being overridden are class-level and these must win regardless of
which section variant a future page uses. */
/* TOKENS FIRST, THEN CLASSES — and the token half is the part that works.
A class-by-class version of this block shipped first and MISSED TWO
ELEMENTS, both measured under print-media emulation: `.approach-metaphor`
on `/` and `.btn-gold` on both pages stayed at `rgb(226,200,154)` —
gold-l, which is **1.62:1 against white paper** once the ground is dropped.
One is the paragraph carrying the infinity-mark argument; the other is the
call to action. Enumerating class names cannot work here: `--text-inverse-2`
is consumed by page-scoped and component-scoped rules this file has never
heard of, and there will be seventeen more pages of them.
Custom properties INHERIT, and that is the one mechanism that crosses
Astro's component-scope boundary (see `Pill.astro`). Redefining the three
inverse tokens on the section itself therefore reaches every descendant
rule, including ones written after this block. */
.section-inverse,
.section-accent {
background: transparent !important;
color: #000 !important;
--text-inverse: #000;
--text-inverse-2: #000;
--pill-fg: #000;
--pill-border: #000;
--rule: #000;
}
/* The belt-and-braces half. These four set a colour LITERALLY rather than
through a token, so the inheritance above does not reach them. */
.section-inverse .eyebrow,
.section-accent .eyebrow,
.section-inverse .lede,
.section-accent .lede {
color: #000 !important;
}
/* The dot is a background, not text, so it does not follow `color`. */
.section-inverse .eyebrow .dot,
.section-accent .eyebrow .dot {
background: #000 !important;
}
/* EVERY BUTTON, NOT JUST THE ONES ON AN INVERSE GROUND. Two of the three
variants set light text on a coloured background of their own
(`.btn-primary` cream-on-maroon, `.btn-gold` gold-on-ink), and a UA at
`print-color-adjust: economy` drops the background and keeps the text.
Measured against white paper: `.btn-gold` 1.62:1, `.btn-primary` **1.07:1**.
The first version of this rule was scoped to `.section-accent .btn-gold`,
which fixed the contact band and left the HERO CTA on `/` unreadable —
`.btn-primary` sits on cream, inside no inverse section at all, so nothing
in this block reached it. A print sweep of all 89 visible text elements
found it; the class-scoped version had passed its own narrower check. */
.btn {
background: transparent !important;
color: #000 !important;
border-color: #000 !important;
}
}
+32 -3
View File
@@ -29,7 +29,7 @@
Both are fine on --ink (8.00) and --maroon (5.84). See docs/02. */
--gold: #c9a876; /* rules, dividers, icon strokes, on-dark text */
--gold-d: #a88858; /* large decorative display text on cream only */
--gold-l: #e2c89a; /* text on ink or maroon 11.09:1 */
--gold-l: #e2c89a; /* text: 11.09:1 on ink, 8.11:1 on maroon */
--line: rgb(26 22 20 / 0.10);
--line-2: rgb(26 22 20 / 0.06);
@@ -51,6 +51,10 @@
--rule: var(--gold);
--border: var(--line);
--focus-ring: var(--maroon);
/* docs/02: "outline: 2px solid var(--maroon); outline-offset: 3px". The
offset was written as a literal in global.css and then in two components,
which is three places to forget. */
--focus-offset: 3px;
/* --- Type -------------------------------------------------------------- */
@@ -59,7 +63,10 @@
--font-mono: 'Geist Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
/* Fluid scale, 360px → 1600px viewport. Ratio widens toward the display
end (1.25 → 1.333) so headlines scale harder than body copy. */
end (1.25 → 1.333) so headlines scale harder than body copy.
--text-2xs is the eyebrow floor docs/02 sets at 11px. Added 2026-08-27:
SiteHeader wrote `0.6875rem` as a literal, step-1 review finding #7. */
--text-2xs: 0.6875rem; /* 11 — eyebrow */
--text-xs: 0.75rem; /* 12 — legal */
--text-sm: 0.875rem; /* 14 — meta */
--text-base: 1rem; /* 16 — body */
@@ -90,6 +97,11 @@
/* --- Space — 8px base -------------------------------------------------- */
/* 2 — a hairline gap, below the 4px base step. It exists because the
two-line brand block needs a gap smaller than --space-1 without the header
growing; step-1 review finding #7 flagged the literal. Do not reach for it
as a general step: the 8px base starts at --space-1. */
--space-05: 2px;
--space-1: 0.25rem; /* 4 */
--space-2: 0.5rem; /* 8 */
--space-3: 0.75rem; /* 12 */
@@ -102,10 +114,27 @@
--space-10: 8rem; /* 128 */
--space-11: 10rem; /* 160 */
--section-y: clamp(var(--space-9), 6vw + 2rem, var(--space-11));
/* docs/02: "Section rhythm: --space-9 (96px) mobile, --space-11 (160px)
desktop." The previous curve was `6vw + 2rem`, which reaches 160px only at
a 2133px viewport — measured 128px at 1600px, 108.8px at 1280px. The upper
bound was unreachable on any real screen, so the token read as if it
delivered a rhythm it never delivered. `9vw + 1rem` hits 160px at 1600px
and still clamps to 96px on a phone. [measured 2026-08-26] */
--section-y: clamp(var(--space-9), 9vw + 1rem, var(--space-11));
/* --- Layout ------------------------------------------------------------ */
/* Sticky-header height at >= 66rem, where the header IS sticky. global.css
drives `scroll-padding-top` off this, so the skip link does not drop the
reader behind the header. If SiteHeader's padding or nav sizing changes,
re-measure and change this with it — one fact living in two files.
[measured 2026-08-26 — headless Chrome at 1024/1100/1280/1440px, with six
nav items and with a seventh injected. 81px at every one: 32 padding + 48
reserved brand block + the 1px bottom border, which is easy to forget and
is why this is measured rather than added up. The brand reserves 48px so
the height does not change when the tagline appears at 76rem] */
--header-h: 5.0625rem; /* 81 — measured, not chosen */
--width-content: 80rem; /* 1280 */
--width-wide: 90rem; /* 1440 */
--width-prose: 68ch; /* reading measure — never exceed for body copy */
+1 -8
View File
@@ -2,14 +2,7 @@
"extends": "astro/tsconfigs/strict",
"compilerOptions": {
"strictNullChecks": true,
"allowJs": true,
"baseUrl": ".",
"paths": {
"@components/*": ["src/components/*"],
"@layouts/*": ["src/layouts/*"],
"@styles/*": ["src/styles/*"],
"@data/*": ["src/data/*"]
}
"allowJs": true
},
"include": [".astro/types.d.ts", "**/*"],
"exclude": ["dist", "node_modules"]