chore: project scaffold, specs, and working record
Build and deploy / build-and-deploy (push) Failing after 5s

This commit is contained in:
Pouya Lajevardi
2026-08-26 08:51:16 -04:00
commit 19f7226661
26 changed files with 3206 additions and 0 deletions
+18
View File
@@ -0,0 +1,18 @@
# Copy to .env.local for development. Never commit .env files.
# Canonical site URL — drives canonical tags, OG tags, and the sitemap.
PUBLIC_SITE_URL=https://adr.smlcompany.ca
# Intake form endpoint — API Gateway HTTP API 'adr-intake-api', ca-central-1.
# [verified 2026-08-26]
PUBLIC_INTAKE_ENDPOINT=https://4tl0m5igkj.execute-api.ca-central-1.amazonaws.com
# Analytics — privacy-first and cookieless (AGENTS.md D15).
# Plausible or Fathom. No GA4, no consent banner. Leave blank in development.
PUBLIC_ANALYTICS_PROVIDER=plausible
PUBLIC_ANALYTICS_DOMAIN=adr.smlcompany.ca
# Booking embed — PARKED by Pouya, 2026-08-26 (AGENTS.md R6).
# /contact/ ships with the intake form and a reserved slot; leave this empty
# and the booking section does not render. Adding a URL later is a drop-in.
PUBLIC_BOOKING_URL=
+104
View File
@@ -0,0 +1,104 @@
# 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).
#
# 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.
#
# Requires a Gitea Actions runner registered to this repo or its organisation.
name: Build and deploy
on:
push:
branches: [main]
workflow_dispatch:
concurrency:
group: deploy-production
cancel-in-progress: false
jobs:
build-and-deploy:
runs-on: ubuntu-latest
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: ${{ vars.AWS_REGION }}
S3_BUCKET: ${{ vars.S3_BUCKET }}
CLOUDFRONT_DISTRIBUTION_ID: ${{ vars.CLOUDFRONT_DISTRIBUTION_ID }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
cache: npm
- name: Install
run: npm ci
- name: Type and template check
run: npm run check
- name: Build
run: npm run build
env:
PUBLIC_SITE_URL: https://adr.smlcompany.ca
PUBLIC_INTAKE_ENDPOINT: ${{ vars.INTAKE_ENDPOINT }}
PUBLIC_BOOKING_URL: ${{ vars.BOOKING_URL }}
# Some Gitea runner images ship without the AWS CLI. Install if missing.
- name: Ensure AWS CLI
run: |
if ! command -v aws >/dev/null 2>&1; then
curl -fsSL "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o /tmp/awscliv2.zip
unzip -q /tmp/awscliv2.zip -d /tmp
sudo /tmp/aws/install --update
fi
aws --version
- 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.
- name: Sync hashed assets
run: |
aws s3 sync ./dist "s3://${S3_BUCKET}" \
--exclude "*" \
--include "_astro/*" --include "fonts/*" \
--cache-control "public, max-age=31536000, immutable" \
--no-progress
- name: Sync images
run: |
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
- name: Sync HTML and the rest
run: |
aws s3 sync ./dist "s3://${S3_BUCKET}" \
--exclude "_astro/*" --exclude "fonts/*" \
--cache-control "public, max-age=0, must-revalidate" \
--delete --no-progress
- name: Invalidate CloudFront
run: |
aws cloudfront create-invalidation \
--distribution-id "${CLOUDFRONT_DISTRIBUTION_ID}" \
--paths "/*"
- name: Summary
run: echo "Deployed to https://adr.smlcompany.ca — commit ${GITHUB_SHA:0:7}"
+93
View File
@@ -0,0 +1,93 @@
# ---------------------------------------------------------------------------
# REFERENCE ONLY. This repository lives on self-hosted Gitea (AGENTS.md D3).
# The live pipeline is .gitea/workflows/deploy.yml.
#
# 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.
# ---------------------------------------------------------------------------
name: Build and deploy
on:
push:
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.
permissions:
contents: read
id-token: write
concurrency:
group: deploy-production
cancel-in-progress: false
jobs:
build-and-deploy:
runs-on: ubuntu-latest
environment: production
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
cache: npm
- name: Install
run: npm ci
- name: Type and template check
run: npm run check
- name: Build
run: npm run build
env:
PUBLIC_SITE_URL: https://adr.smlcompany.ca
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
- 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
# never fetch a new page whose assets have not landed yet.
- name: Sync hashed assets
run: |
aws s3 sync ./dist "s3://${{ vars.S3_BUCKET }}" \
--exclude "*" \
--include "_astro/*" --include "fonts/*" \
--cache-control "public, max-age=31536000, immutable" \
--no-progress
- name: Sync images
run: |
aws s3 sync ./dist "s3://${{ vars.S3_BUCKET }}" \
--exclude "*" \
--include "*.avif" --include "*.webp" --include "*.jpg" \
--include "*.png" --include "*.svg" \
--cache-control "public, max-age=604800" \
--no-progress
- name: Sync HTML and the rest
run: |
aws s3 sync ./dist "s3://${{ vars.S3_BUCKET }}" \
--exclude "_astro/*" --exclude "fonts/*" \
--cache-control "public, max-age=0, must-revalidate" \
--delete --no-progress
- name: Invalidate CloudFront
run: |
aws cloudfront create-invalidation \
--distribution-id "${{ vars.CLOUDFRONT_DISTRIBUTION_ID }}" \
--paths "/*"
- name: Summary
run: echo "Deployed to https://adr.smlcompany.ca — commit ${GITHUB_SHA::7}" >> "$GITHUB_STEP_SUMMARY"
+48
View File
@@ -0,0 +1,48 @@
# dependencies
node_modules/
.pnp
.pnp.js
# build output
dist/
.output/
.vercel/
.netlify/
# astro
.astro/
# environment — never commit
.env
.env.*
!.env.example
# credentials — never commit
*.pem
*.key
aws-credentials*
.aws/
# logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# editor / os
.vscode/*
!.vscode/extensions.json
.idea/
.DS_Store
Thumbs.db
# test / tooling caches
coverage/
.eslintcache
.cache/
playwright-report/
test-results/
# generated inventory — safe to share, but not tracked
aws-inventory.txt
+1
View File
@@ -0,0 +1 @@
22
+566
View File
@@ -0,0 +1,566 @@
# Agent Working File
This file is a **living document** and a **full history tree** for this project —
not a snapshot. Any agent or person working here (Claude in chat, Claude Code,
Claude Cowork, or a human) maintains it by the rules below. Everything else in
this file varies by project; these rules do not.
## How to maintain this file
1. **Document everything.** On every change, record what was discussed, decided,
changed, or planned — decisions and plans included, not just executed work.
2. **Never overwrite or delete history.** When something changes, update the
relevant Current Truth section in place **and** append a dated Change Log
entry capturing old → new and why.
3. **Two parts, kept separate:** *Current Truth* is updated in place and always
reflects the present state; the *Change Log* is append-only, newest entry
first, and is never edited retroactively.
4. **Stamp facts** as `[verified YYYY-MM-DD]` or `[assumed]`. When you re-check a
fact, re-stamp it with today's date; a stale date means it needs re-verifying.
5. **Never prune the Change Log.** If the file gets unwieldy, ask before
archiving old entries to `AGENTS-history.md`. History is not destroyed.
---
# Current Truth
## 1. What this project is
A ground-up rebuild of **adr.smlcompany.ca**, the personal-brand website of the
alternative dispute resolution practice of **Pouya Lajevardi**. `[verified 2026-08-25]`
The brand's centre of gravity is the **practitioner**, not the firm and not
SML Company Ltd. Legal background, firm affiliation, and the operating company
are credibility signals referenced in support; they are not the subject.
`[verified 2026-08-25 — source: PL_ADR_Personal_Branding_Strategy_Brief.docx]`
**Positioning in one sentence:** a credentialed neutral who is also a working
litigator and a practising machine-learning / infrastructure engineer — a
combination that is close to absent from Canadian ADR rosters, and which is the
practice's economic moat. `[verified 2026-08-25 — source: strategy brief §I, §V]`
**Time horizon:** the strategy brief plans a 24-month compounding arc. The site
is built to be added to continuously, not shipped once. `[verified 2026-08-25]`
## 2. State of the thing being replaced
The site live at `adr.smlcompany.ca` as of this entry: `[verified 2026-08-25]`
- It is a **single 2.2 MB HTML file** containing an entire React application,
compiled **in the browser at runtime** by Babel Standalone loaded from unpkg.
- React and ReactDOM are loaded as **development** builds from a public CDN.
- **Crawlers see almost nothing.** A server-side fetch of the URL returns only
`SML Company`, `DISPUTE RESOLUTION`, and the loading string `Unpacking...`.
Every word of real content is assembled client-side after three CDN round
trips. `[verified 2026-08-25 — direct fetch]`
- The deployed `<head>` has **no `<meta name="viewport">`**, no meta
description, no Open Graph or Twitter card tags, no canonical URL, and no
favicon. `[verified 2026-08-25]`
- `robots.txt` returns **403**. There is no sitemap. `[verified 2026-08-25]`
- The two logo PNGs are ~1 MB combined and are **base64-inlined** into the HTML,
so they cannot be cached separately from the page. `[verified 2026-08-25]`
- `<title>` is still `SML Company · Dispute Resolution` — the pre-rebrand
placeholder title. `[verified 2026-08-25]`
- There is **no version control, no build step, and no test suite**. The
publishing mechanism is a Python script (`rebuild-standalone.py`) that inlines
the JSX components into the single file. `[verified 2026-08-25]`
- The May 2026 content brief was **partially applied** — the JSX components
under `components-standalone/` contain the corrected Pouya-branded copy, but
the document `<head>` and some structural defaults still carry placeholder
content. `[verified 2026-08-25]`
**Consequence, stated plainly:** for a site whose entire purpose is to be found
by counsel, general counsel, and appointing bodies searching for a neutral, the
current architecture is close to a worst case. This rebuild is justified on
architecture alone, independent of any content or design opinion.
`[verified 2026-08-25]`
## 3. Locked decisions
Decided with Pouya on 2026-08-25. Each of these is settled; do not re-open one
without an explicit instruction and a Change Log entry. `[verified 2026-08-25]`
| # | Decision | Chosen | Rejected alternatives |
|---|---|---|---|
| D1 | Framework | **Astro**, static output | Next.js; patching the single-file build; hand-written HTML |
| D2 | Content scope | **Full re-architecture** — new IA, new pages, all copy rewritten | Copy rewrite on existing structure; audit only; technical layer only |
| D3 | Hosting & CI/CD | **Git repo + Gitea Actions → existing S3 + CloudFront.** *Amended 2026-08-26:* self-hosted **Gitea**, repo `adr-sml`. Gitea Actions uses GitHub Actions syntax, so the workflow ports nearly as is — but Gitea is not an AWS OIDC provider, so deploys authenticate with a **scoped IAM user**, rotated quarterly | GitHub Actions + OIDC; Terraform/CDK IaC; Amplify; manual deploys |
| D4 | Languages | **English only** | Full EN/FA bilingual; EN + one Farsi page; EN with FA scaffolding |
| D5 | Page structure | **Full multi-page**, ~20 URLs | Lean six-page; rich home + a few deep pages |
| D6 | Primary audience | **All four tiers**, served by different surfaces (see §5) | Single-audience focus |
| D7 | Visual design | **Keep palette and infinity mark; modernize the execution** | Faithful port; strip motion only; fresh design direction |
| D8 | Fees | **Full rate card published on a `/fees/` page** | Ranges only; on request; gated PDF |
| D9 | Insights | **Build it and launch with 35 drafted pieces**, every word reviewed by Pouya before publication | Launch empty; no Insights section |
| D10 | Intake | **Rebuilt intake form + calendar booking** for the confidential intake call | Structured questionnaire; form only; email/phone only |
| D11 | Rollout | **Build everything, one clean cutover.** The current site stays live untouched until replaced | Patch live site first; staging subdomain; take site down |
| D12 | Agent working file | **This file**, maintained under the constitution above | Ad hoc notes |
| D13 | Licensure claims | **The site asserts the JD only.** No claim of licensure, call to the bar, or entitlement to practise law. The approved phrasing for the boutique role is **"active litigation exposure"** or **"involvement in litigation and ADR matters"** — never "practice". Pouya's direction, 2026-08-26. **Explicitly interim — see Standing Reminders §12** | Stating LSO licence status; the word "practice" |
| D14 | Fee structure | **Single published rate card, all mediation matters at one rate.** Confirmed by Pouya 2026-08-26; figures in `docs/07-fees.md`. No tribunal-secretary line | Two-tier card (recommended by Claude, declined); ranges; fees on request |
| D15 | Analytics | **Privacy-first and cookieless** (Plausible or Fathom). No cookie banner, nothing to consent to, one line in the privacy policy | GA4; no analytics at all |
| D16 | Naming the boutique | **Never named.** Referred to throughout as *a Toronto litigation and ADR boutique*. Pouya's decision, 2026-08-26 | Naming the firm |
## 4. Credential and claim register
**This is the most important section in this file.** Pouya is a licensed legal
professional. Every public claim on this site is subject to Law Society of
Ontario marketing rules, and the previous version of the site carried fabricated
credentials, invented matter values, and a fictitious testimonial. That must
never recur.
### Governing rule
> **No claim reaches a public page unless it appears in the Verified column
> below.** If a page needs a fact that is not here, stop and ask Pouya. Do not
> infer it, do not soften it into something defensible, and do not carry it over
> from the old site.
### Verified — may be published
Sourced from Pouya's own strategy brief of 2026-05-26. Self-reported by the
subject, which makes them reliable as to substance, but **currency is not
confirmed** — several are on a moving credentialing track and may have advanced
since May.
| Claim | Status |
|---|---|
| Pouya Lajevardi, JD, Bond University | `[verified 2026-08-25 — strategy brief §I]` |
| ~~Lawyer; Law Society of Ontario member~~ | **DO NOT PUBLISH.** Pouya directed on 2026-08-26 that licensure is left out of the site entirely; the JD is asserted, nothing further. See D13 and the Forbidden table below `[verified 2026-08-26]` |
| Director of Firm Operations, Toronto litigation and ADR boutique | `[verified 2026-08-25 — strategy brief §I]` |
| Active litigation practice: personal injury, construction, regulatory (POA), insurance (SABS) | `[verified 2026-08-25 — strategy brief §I]` |
| Q.Med designation through ADRIC / ADRIO | `[verified 2026-08-25 — strategy brief §I]` |
| Has completed multiple sole mediations | `[verified 2026-08-25 — strategy brief §I]` — count deliberately not published, see below |
| Q.Arb credentialing pathway — **commenced August 2026** | `[verified 2026-08-26 — Pouya]`. Describe as newly commenced, never as held or nearing completion |
| C.Med-Arb stated as long-term designation goal | `[verified 2026-08-25 — strategy brief §I, §IV]` |
| Kompass Arbitration Certificate Program — completed | `[verified 2026-08-25 — strategy brief §I]` |
| Stitt Feld Handy negotiation and ADR workshop sequence — completed | `[verified 2026-08-25 — strategy brief §I]` |
| Practising machine-learning and DevOps / infrastructure engineer | `[verified 2026-08-25 — strategy brief §I, §V]` |
| Bilingual English and Farsi | `[verified 2026-08-25 — strategy brief §I]` |
| Iranian-Canadian; cross-cultural fluency with diaspora business communities | `[verified 2026-08-25 — strategy brief §I]` |
| Operator of SML Company Ltd. alongside the practice | `[verified 2026-08-25 — strategy brief §I]` |
| Memberships: **ADRIC**, **ADRIO**, **OBA sections** | `[verified 2026-08-26 — Pouya]`. Which OBA sections is still `[assumed]` — the brief names Construction & Infrastructure, ADR, and Civil Litigation |
| ~~OCNI~~ | **Not current. Do not publish** `[verified 2026-08-26 — Pouya]` |
| ~~LSO~~ | **Do not publish.** Listing the Law Society among memberships implies licensure, which D13 bars. Excluded deliberately, not by oversight `[verified 2026-08-26]` |
| Toronto, Ontario; by appointment | `[verified 2026-08-26]` |
| Contact: `info@smlcompany.ca`; no public phone number; consultations by scheduled call | `[verified 2026-08-26 — Pouya]` |
| LinkedIn: `https://www.linkedin.com/in/pouyalajevardi/` | `[verified 2026-08-26 — Pouya]` |
| The Toronto litigation and ADR boutique **may be named on the site** | `[verified 2026-08-26 — Pouya]`; the name itself is pending, see Q7 |
### Forbidden — must not appear anywhere
| Never publish | Why |
|---|---|
| Any count of matters closed, hours mediated, or years in ADR practice | Practice is new. Small true numbers do not persuade a sophisticated GC and invite scrutiny. Save for one-to-one appointment proposals where context travels with the figure. `[verified 2026-08-25 — content brief, "Rule of thumb on numbers"]` |
| Settlement rates, resolution percentages, median time to award | No underlying data exists |
| Aggregate value resolved; any dollar figure attached to past matters | No underlying data exists |
| Named or describable past matters | Confidentiality, and none are publishable |
| Testimonials, endorsements, quotes from counterparties | None exist. The prior site's testimonial was fabricated |
| "Since 2009", "sixteen years", London / New York offices, Co. № 07452218 | Artefacts of the placeholder template. All false |
| The name "S. M. Lawrence" | Fictitious founder from the template |
| Guarantees of outcome, or superlatives ("best", "leading", "top-rated") | LSO marketing rules |
| The word **"lawyer"** used of Pouya; "called to the bar"; "licensed"; "my law practice"; "my litigation practice"; any post-nominal implying a licence | D13. The site asserts the JD and nothing further |
| Any phrasing that *implies* entitlement to practise law without saying so — "acts for clients", "represents parties", "my clients", "legal advice" | Same rule. Implication is the risk, not just the word. Describe the role factually instead: *Director of Firm Operations at a Toronto litigation and ADR boutique*, and the matter types worked on |
| Q.Arb described as held, imminent, or "nearly complete" | It commenced August 2026 |
### The substitution principle
Wherever the design wants a "how much / how many" statistic, substitute a
**longer-arc credential** — something already substantial and true at launch that
does not grow by closing files. The approved stat set is `Q.Med` /
`JD + ML` / `EN · FA`, with `Q.Arb` as a fourth slot where one exists.
`[verified 2026-08-25 — content brief]`
## 5. Audience model
All four tiers matter, but they are served by different surfaces rather than by
diluting every page into a compromise. `[verified 2026-08-25 — decision D6]`
| Tier | Reads | Wants | Primary surface |
|---|---|---|---|
| In-house / general counsel | Home, practice pages | Credentials, subject-matter fluency, procedure, predictable cost | Home + `/practice/*` |
| Referring lawyers and litigation boutiques | Practice pages, fees, process | Availability, rules familiarity, rate card, confidence you won't embarrass them | `/fees/`, `/process/`, `/mediation/` |
| Appointing bodies and ADR institutions | About, credentials | A verifiable credential record and evidence of depth | `/about/` |
| Self-represented parties and SMEs | Plain-language layer | What mediation actually is, what it costs, what happens | `/for-parties/` |
Realistically, **referring counsel are the largest source of early
appointments**. Where two audiences conflict on a page, resolve toward counsel.
`[assumed]`
## 6. Architecture
Full sitemap, URL map, and per-page content outline: **`docs/01-architecture.md`**.
Summary: ~20 static pages. Home; About; three process pages (Mediation,
Arbitration, Med-Arb); six practice-area pages; Process; Fees; For Parties;
Insights index and articles; Contact; Privacy; Terms.
**Deliberate omission:** there is no Indigenous engagement / IBA practice page at
launch, though the strategy brief rates that niche as strategically the most
valuable. Publishing a practice page for it before the multi-year relationship
work described in brief §III.4 has been done would read as overreach to exactly
the audience it targets. Revisit at month 1218. `[verified 2026-08-25 — decision recorded here, not yet discussed with Pouya]`
## 7. Environment and stack
| Thing | Value |
|---|---|
| Framework | Astro, `output: 'static'` `[verified 2026-08-25 — D1]` |
| Node | 22 LTS, pinned in `.nvmrc` `[assumed]` |
| Styling | Plain CSS with custom properties. No Tailwind, no CSS-in-JS `[verified 2026-08-25]` |
| Client JS | Astro islands only, where genuinely needed. Target: most pages ship zero JS `[verified 2026-08-25]` |
| Content | Astro content collections, MDX for Insights `[verified 2026-08-25]` |
| Fonts | Instrument Serif + Geist + Geist Mono, **self-hosted**, `font-display: swap` `[verified 2026-08-25]` |
| AWS account | `327082975128` `[verified 2026-08-26 — inventory]` |
| Region | **`ca-central-1`** throughout — hosting, Lambda, DynamoDB `[verified 2026-08-26]` |
| S3 bucket | **`adr-smlcompany-site`** — versioning **Enabled**, so rollback works `[verified 2026-08-26]` |
| CloudFront | **`E1OK7G98KNKUTA`**, alias `adr.smlcompany.ca`, origin `adr-smlcompany-site.s3.ca-central-1.amazonaws.com`, Deployed `[verified 2026-08-26]` |
| ACM certificate | `arn:aws:acm:us-east-1:327082975128:certificate/2b6d5bdf-6790-430c-9b82-c00ab66e6d87` — ISSUED `[verified 2026-08-26]` |
| Intake API | `adr-intake-api`, HTTP API `4tl0m5igkj`, endpoint `https://4tl0m5igkj.execute-api.ca-central-1.amazonaws.com` `[verified 2026-08-26]` |
| Intake Lambda | `adr-intake-handler`, `nodejs24.x` `[verified 2026-08-26]` |
| Intake table | `adr-intake-submissions` (DynamoDB, ca-central-1) `[verified 2026-08-26]` |
| SES identities | Domain `smlcompany.ca`; addresses `info@`, `intake@`, `adr@`. **Sending status unconfirmed — Q18/Q19** `[verified 2026-08-26]` |
| TLS | ACM certificate `[verified 2026-08-25 — AWS-Hosting-Guide.md]` |
| DNS | **Namecheap**, not Route 53 `[verified 2026-08-25 — AWS-Hosting-Guide.md]` |
| Intake backend | API Gateway (HTTP API) → Lambda → DynamoDB, notifications via SES `[verified 2026-08-25 — AWS-Hosting-Guide.md]` |
| Repository | **`adr-sml`**, self-hosted **Gitea**. Local clone at `/Users/pouya/Dev/Websites/adr-sml` `[verified 2026-08-26]` |
| CI/CD | **Gitea Actions**, `.gitea/workflows/deploy.yml`. `.github/workflows/deploy.yml` kept as the OIDC reference in case the repo ever moves. Credentials: scoped IAM user in Gitea secrets — **no OIDC available** `[verified 2026-08-26 — D3 as amended]` |
| Analytics | **Plausible or Fathom** — cookieless, no personal data, no consent banner, EU-hosted `[verified 2026-08-26 — D15]` |
## 8. Design system
Full token set, type scale, motion rules, and contrast results:
**`docs/02-design-system.md`**.
Palette and infinity mark carry over unchanged. Execution is modernized:
fluid type scale, self-hosted fonts, an 8 px spacing scale, motion gated behind
`prefers-reduced-motion`, and one hard accessibility constraint discovered by
measurement:
> **Gold `#c9a876` on cream `#faf7f2` measures 2.10:1 contrast.** That fails WCAG
> AA for body text (4.5:1) and for large text (3:1). Gold is a decorative and
> on-dark colour only. On maroon it measures 5.84:1 and on ink 8.00:1, both of
> which pass. `[verified 2026-08-25 — computed]`
## 9. Open questions — blocking
Nothing below can be invented. Each needs an answer from Pouya.
| # | Question | Blocks |
|---|---|---|
| ~~Q1~~ | **ANSWERED 2026-08-26.** Licensure is left out entirely; the site asserts the JD only. See D13 | — |
| ~~Q2~~ | **ANSWERED 2026-08-26.** Q.Arb commenced August 2026 | — |
| ~~Q3~~ | **ANSWERED 2026-08-26.** Email `info@smlcompany.ca`. No public phone — "By scheduled call". Location: Toronto · Ontario · By appointment | — |
| ~~Q4 / Q14~~ | **ANSWERED 2026-08-26.** Rate card confirmed by Pouya — see D14 and `docs/07-fees.md` | — |
| ~~Q13~~ | **ANSWERED 2026-08-26.** Self-hosted Gitea with Gitea Actions | — |
| ~~Q15 / Q16 / Q17~~ | **ANSWERED 2026-08-26.** Non-mediation hourly $500. Prep bundled: 2 h in the half day, 3 h in the full day, **stated on the page**. Overtime $500/h | — |
| Q5 | Booking tool — **parked 2026-08-26 at Pouya's request.** Build `/contact/` with the form only and a clean slot for the embed. Now tracked as standing reminder R6 | `/contact/` — non-blocking |
| ~~Q6~~ | **ANSWERED 2026-08-26.** Supplied and committed: `src/assets/pouya-lajevardi.jpg` (1600×1600 master) and `src/assets/og-portrait.jpg` (1200×630 link-preview crop) | — |
| ~~Q7~~ | **ANSWERED 2026-08-26.** Pouya reverted to generic. The boutique is **never named**; refer to it as *a Toronto litigation and ADR boutique* throughout | — |
| ~~Q8~~ | **ANSWERED 2026-08-26.** ADRIC, ADRIO, OBA sections. Not OCNI. Not LSO (see §4) | — |
| ~~Q9~~ | **ANSWERED 2026-08-26.** Gitea, repo `adr-sml`, clone at `/Users/pouya/Dev/Websites/adr-sml` | — |
| ~~Q10~~ | **ANSWERED 2026-08-26.** Full inventory captured; values in §7 | — |
| Q18 | **Are the SES identities actually verified for sending?** `list-email-identities` returned `VerifiedForSendingStatus: None` for all six. If they are unverified, no intake email sends | Intake confirmation and notification email |
| Q19 | **Is the SES account still in the sandbox?** In sandbox, mail only reaches pre-verified addresses — the confirmation email to an inquirer would silently fail | `/contact/` going live |
| ~~Q11~~ | **ANSWERED 2026-08-26.** Privacy-first, cookieless — Plausible or Fathom. No GA4, no consent banner | — |
| ~~Q12~~ | **ANSWERED 2026-08-26.** `https://www.linkedin.com/in/pouyalajevardi/` | — |
## 10. Risks
| Risk | Severity | Mitigation |
|---|---|---|
| A fabricated or unverifiable claim reaches a public page | **High** — professional-conduct exposure for a licensed practitioner | §4 register; every claim traced to a source before it ships |
| Copy silently carried over from the placeholder template | High | Nothing is ported verbatim. All copy written fresh against `docs/03-content-spec.md` |
| Insights section launches and then goes stale | Medium | D9 commits to 35 pieces at launch and a monthly cadence. A dead blog is worse than no blog |
| Personal data in the intake pipeline without a retention policy | Medium — PIPEDA | `docs/05-backend-spec.md` sets retention, and `/legal/privacy/` states it |
| Cutover breaks the live site (D11 is a single-shot deploy) | Medium | Full pre-cutover checklist in `docs/06-deployment.md`; CloudFront can be rolled back to the prior origin path |
| Twenty pages of thin copy rank worse than six good ones | Medium | Each page must justify itself with substantive content. Ship fewer pages rather than padded ones |
| **Deploy-credential blast radius.** AWS account `327082975128` is not a single-project account. It also holds `meshkinilaw.ca` and its preview site, `demesne.media`, `orynenergy.ca`, `lajirugs.ca`, and **`mlp-clientdb-prod-backups`** — which by its name is a law firm's production client-database backups | **High** | A static deploy key for a personal website must never be able to reach a client database. The scoped IAM policy in `docs/06-deployment.md` grants four actions on one bucket and one distribution and nothing else — that narrowness is now load-bearing, not hygiene. Never widen it. Never reuse the `user/pouya` credentials in CI `[verified 2026-08-26 — inventory]` |
| SES not verified or still sandboxed | Medium | Q18/Q19. A silently undelivered confirmation email looks to the inquirer like being ignored |
## 11. Glossary
| Term | Meaning |
|---|---|
| ADR | Alternative dispute resolution |
| ADRIC | ADR Institute of Canada — national credentialing body |
| ADRIO | ADR Institute of Ontario — provincial affiliate |
| Q.Med | Qualified Mediator — ADRIC/ADRIO designation |
| Q.Arb | Qualified Arbitrator — ADRIC/ADRIO designation |
| C.Med-Arb | Chartered Mediator-Arbitrator — senior hybrid designation; the long-term goal |
| Med-Arb | Hybrid process: mediation that converts to binding arbitration if unresolved |
| SABS | Statutory Accident Benefits Schedule — Ontario auto insurance benefits |
| LAT | Licence Appeal Tribunal — hears Ontario SABS disputes |
| POA | Provincial Offences Act |
| ENE | Early neutral evaluation |
| LSO | Law Society of Ontario |
| OBA | Ontario Bar Association |
| IESO | Independent Electricity System Operator |
| OEB | Ontario Energy Board |
| SMR | Small modular reactor |
| PIPEDA | Personal Information Protection and Electronic Documents Act |
## 12. Standing reminders
**Surface these to Pouya at the start of any substantial session.** They are
open questions he has deliberately parked, not settled matters. The point of
this section is that a parked decision does not quietly become permanent by
never being raised again.
| # | Reminder | Raised | Why it must keep coming back |
|---|---|---|---|
| R1 | **Licensure.** The site currently asserts the JD only and describes the boutique role as *active litigation exposure*, never *practice*. Pouya flagged this as an interim position and asked to be reminded to change it | 2026-08-26 | If he is licensed and in good standing, it is the first credential appointing bodies and opposing counsel look for, and its absence from a detailed credentials page is conspicuous. If licensure is in progress, the copy should be rewritten the moment it completes. Either way this is a **temporary** framing that will otherwise ossify |
| R2 | **Matter counts stay off the site until they are independently credible.** Revisit once there is a number a sophisticated GC would find persuasive on its own | 2026-08-26 | §4 forbids them now. That rule has an expiry date nobody has set |
| R3 | **Indigenous engagement practice page.** Deliberately omitted at launch (§6). Revisit at month 1218 | 2026-08-26 | The strategy brief rates it the most valuable single niche. Omission is a timing call, not a permanent one |
| R4 | **Insights cadence.** D9 commits to monthly. A blog that stops is worse than one that never started | 2026-08-26 | The section's whole value is compounding |
| R5 | **Fee review at 12 months.** Published rates are sticky; the right moment to move them is deliberate, not reactive | 2026-08-26 | D14 is priced for where the practice is going, not where it is |
| R6 | **Booking tool.** Parked by Pouya on 2026-08-26; `/contact/` ships with the intake form and a reserved slot for an embed | 2026-08-26 | He asked to be reminded. D10 committed to booking because it removes the back-and-forth that loses appointments — the form alone is a partial answer |
---
# Change Log
## 2026-08-26 (e) — AWS inventory captured; Q10 closed; blast-radius risk logged
**Who:** Pouya ran `scripts/aws-discover.sh`. Claude recording.
**Q10 closed.** All identifiers in §7. Bucket `adr-smlcompany-site`,
distribution `E1OK7G98KNKUTA`, region `ca-central-1`, intake API
`4tl0m5igkj`. Certificate ISSUED. **S3 versioning is already Enabled**, so the
rollback path in `docs/06-deployment.md` works as written — no change needed.
**Data residency confirmed.** Hosting, Lambda, and DynamoDB are all in
`ca-central-1`. `docs/05-backend-spec.md` treated Canadian residency as a
selling point conditional on the table's region; it is now a verified fact and
`/legal/privacy/` can state it plainly.
**New risk logged — deploy-credential blast radius.** The inventory shows this is
a shared account across at least five unrelated properties, including
`mlp-clientdb-prod-backups-327082975128`. A Gitea runner holding a static AWS key
for this site sits in the same account as a law firm's client-database backups.
Nothing about the plan changes — the scoped IAM policy was already correct — but
its narrowness is now load-bearing rather than good practice, and that is
recorded so nobody widens it later for convenience.
**Q18 and Q19 opened — SES.** `list-email-identities` returned
`VerifiedForSendingStatus: None` for all six identities, and sandbox status was
not checked. Either condition means the intake confirmation email silently fails.
This is the failure mode where the site appears to work and inquirers think they
have been ignored, so it is on the cutover checklist, not the nice-to-have list.
**Closed:** Q10. **Opened:** Q18, Q19.
---
## 2026-08-26 (d) — Memberships, analytics, prep time, firm naming; one blocker left
**Who:** Pouya. Claude recording.
**Q16 closed — preparation time is bundled and stated.** 2 h in the half day,
3 h in the full day, printed on `/fees/`. The carried-forward assumption is now a
decision. At $4,000 a day counsel will assume preparation happened; saying so
converts the assumption into a selling point and forecloses an argument about
what the fee covered.
**Q15 and Q17 closed.** Non-mediation hourly (ENE, settlement counsel,
dispute-system design, technical advisory) and mediation overtime both **$500/h**,
matching the arbitration rate.
**Q8 closed — memberships.** Old → new: five `[assumed]` bodies from the strategy
brief → **ADRIC, ADRIO, and OBA sections, verified.** OCNI is **not current** and
must not be published; the brief listed it as aspirational positioning for the
nuclear niche, which is a different thing from a membership.
- **LSO was deliberately not offered as an option and is not published.** Listing
the Law Society in a credentials block implies licensure, which D13 bars. Worth
recording explicitly so a later reader does not "helpfully" add it back.
- Which OBA sections remains `[assumed]` — the brief names Construction &
Infrastructure, ADR, and Civil Litigation. Low stakes; confirm in passing.
**D15 — analytics (new decision).** Old → new: unchosen → **privacy-first and
cookieless**, Plausible or Fathom. No GA4, no consent banner, no personal data
leaving the site, one honest line in the privacy policy. Consistent with a
practice whose privacy posture is part of its offer, on a site where visitors are
often describing live disputes.
**D16 — the boutique is never named (new decision).** Old → new: "may be named,
name pending" → **generic throughout**: *a Toronto litigation and ADR boutique*.
Pouya reversed his earlier answer. Trade-off, recorded for the record: naming a
recognisable firm is a meaningful credibility signal to appointing bodies, and
the generic phrasing gives that up in exchange for zero coordination cost and no
dependency on anyone else's sign-off. Do not infer the name from his email domain
or from anything else.
**Closed:** Q7, Q8, Q11, Q15, Q16, Q17. **Opened:** none.
**Q10 is now the only blocker.** Everything else needed to build is answered.
R6 (booking) stays parked by choice and does not block `/contact/`.
---
## 2026-08-26 (c) — Contact, headshot, LinkedIn; booking parked
**Who:** Pouya. Claude recording.
**Closed.** Q3 — contact is `info@smlcompany.ca`, no public phone, consultations
by scheduled call. Q6 — headshot supplied; committed as
`src/assets/pouya-lajevardi.jpg` (1600×1600 master, for Astro to derive AVIF and
WebP from at build) and `src/assets/og-portrait.jpg` (1200×630, cropped high so
the face is not centred under the fold of a link preview). Q12 — LinkedIn URL,
which now feeds `sameAs` in the `Person` JSON-LD.
**Q5 parked, not closed.** Old → new: "choose a booking tool" → **deferred at
Pouya's request**; `/contact/` ships with the intake form and a reserved slot for
an embed, so adding one later is a drop-in rather than a rebuild. He asked to be
reminded, so it is now **R6** in §12 rather than a question that quietly expires.
Worth restating when it comes back up: D10 chose booking because it removes the
scheduling back-and-forth that loses appointments, and the form alone only
half-solves that.
**Q7 partially answered.** The boutique may be named. The name itself was not
given. His email domain (`meshkinilaw.ca`) points to **Meshkini Law**, but an
inferred firm name is exactly the kind of thing §4 exists to stop — asked for
confirmation rather than published.
---
## 2026-08-26 (b) — Fees confirmed; Gitea; licensure wording; standing reminders
**Who:** Pouya, answering the second round. Claude recording.
**D14 closed — rate card confirmed, and it is not what was recommended.**
- Old → new: two-tier card (Tier A insurance/SABS at $1,800/day, Tier B
commercial at $3,200/day) → **one rate for all mediation matters: $2,000 half
day, $4,000 full day, $500 per party beyond two.** Arbitration $500/h, hearing
day $4,000, documents-only flat $6,500 simple / $9,500 complex. Tribunal-
secretary line removed. Cancellation terms adopted as recommended.
- Why: Pouya's decision.
- **Recorded dissent, for the 12-month review (R5).** A single premium rate puts
the insurance / SABS / LAT segment out of reach — the published market there
runs roughly $800$1,200 a day, and $4,000 is three to five times it. That is
the segment the strategy brief (§IV.7) identifies as the highest realistic
near-term volume, flowing directly from the firm's existing accident-benefits
work. The trade is deliberate if it is deliberate: a premium specialist
position that forgoes volume. It is a mistake if the volume was being counted
on. Flagged once, implemented as directed.
- Three parameters were not specified and are carried forward as assumptions,
logged as Q15Q17: non-mediation hourly ($500), bundled prep hours (2 h half
day / 3 h full day), mediation overtime ($500/h).
**D13 refined — approved wording for the boutique role.**
- Old → new: "describe the role factually" → the specific approved phrases are
**"active litigation exposure"** and **"involvement in litigation and ADR
matters"**. The word **"practice"** is barred in that context.
- Pouya asked to be **continuously reminded** that this is interim. That request
is now structural rather than a note — see the new §12.
**New §12, Standing Reminders.** Five items (R1R5) that any agent must surface
at the start of a substantial session. Added because a parked decision otherwise
becomes permanent by never being raised again — and R1 is Pouya's explicit
instruction to keep raising it.
**D3 amended again — Gitea.**
- Old → new: "private git server, software unknown" → **self-hosted Gitea with
Gitea Actions**, clone at `/Users/pouya/Dev/Websites/adr-sml`.
- Consequence: Gitea Actions uses GitHub Actions syntax, so the workflow ports
almost unchanged into `.gitea/workflows/deploy.yml`. But **Gitea is not an AWS
OIDC provider**, so the OIDC role assumption is replaced by a scoped IAM user
whose key lives only in Gitea's secret store. The IAM policy stays exactly as
narrow. Quarterly rotation is now an operational obligation that OIDC would
have made unnecessary — noted in `docs/06-deployment.md`.
**Closed:** Q4, Q9, Q13, Q14. **Opened:** Q15, Q16, Q17.
**Still open:** Q3, Q5, Q6, Q7, Q8, Q10, Q11, Q12, Q15, Q16, Q17.
---
## 2026-08-26 (a) — Licensure decision; Q.Arb dated; git host changed; fees researched
**Who:** Pouya, answering the blocking questions from §9. Claude recording.
**Date correction.** The entry below is stamped 2026-08-25. The correct date of
that work was **2026-08-26** — the session clock was a day behind. Per rule 3 the
entry is left as written rather than edited; this note is the correction. Facts
touched today are re-stamped `[verified 2026-08-26]`.
**D13 — licensure left out of the site (new decision).**
- Old → new: "confirm LSO licence status before launch" → **the site asserts the
JD and makes no licensure claim at all.**
- Why: Pouya's direction.
- **Consequence, and it is not small.** The strategy brief describes an *"active
litigation practice in personal injury, construction, regulatory (POA), and
insurance (SABS) matters."* Copy written that way would imply entitlement to
practise law without stating it — which is the risk the decision is meant to
avoid, restated in different words. So the Forbidden table now bars implication
as well as assertion, and the About page describes the role factually:
*Director of Firm Operations at a Toronto litigation and ADR boutique*, plus
the matter types worked on. **Flagged back to Pouya**: if he is in fact
licensed and in good standing, that is a material credential for exactly the
audience this site targets, and omitting it costs more than it protects.
**Q.Arb dated.** Old → new: `[assumed]`, stage unknown → **commenced August
2026** `[verified 2026-08-26]`. Describe as newly commenced. Not as held, not as
nearing completion.
**D3 amended — the repository is not on GitHub.**
- Old → new: "git repo + GitHub Actions" → **repo `adr-sml` on a private git
server**; the GitHub Actions workflow in `.github/workflows/` is retained as a
working reference implementation, to be translated once the server software is
known (**Q13**).
- Why: Pouya is running his own git server.
- Knock-on: GitHub's OIDC provider is unavailable. GitLab CE can federate to AWS
by OIDC; Gitea/Forgejo and bare git cannot, so those need a scoped IAM user
with rotated keys, or deploys run from Pouya's machine. The choice is real and
waits on Q13.
**D14 — fee structure recommended (new decision, pending sign-off).** Market
research completed against the Ontario mandatory-mediation tariff, ADR Chambers
published ranges, and four published Ontario practitioner rate cards. A two-tier
structure is recommended so the price-sensitive SABS/LAT volume work does not
anchor the commercial rate. Figures are in the response to Pouya and are **not
written into any page until he signs off (Q14).**
**Still open:** Q3, Q5, Q6, Q7, Q8, Q10, Q11, Q12, Q13, Q14.
---
## 2026-08-25 — Project initiated; architecture decided; repo bundle authored
**Who:** Pouya Lajevardi with Claude (Cowork session), acting as architects. Claude Code to implement.
**What was discussed.** Pouya asked for a modernization of `adr.smlcompany.ca`
going beyond appearance — content and codebase included — with Claude Code doing
the implementation and deploying to AWS.
**What was found.** An audit of the live site and the local sources established
the facts in Current Truth §2. The headline finding: the site is a browser-
compiled single-file React bundle that serves crawlers three words of content.
For a personal-brand site whose function is discoverability, this is the
dominant problem, ahead of any question of looks or copy.
**What was decided.** Twelve decisions, D1D12, recorded in §3. In summary: an
Astro static rebuild, full content re-architecture, ~20 pages, English only,
git + GitHub Actions onto the existing S3/CloudFront, palette and infinity mark
retained with a modernized execution, a published rate card, an Insights section
launching with drafted content, a rebuilt intake form plus booking, and a single
clean cutover.
- Old → new (framework): browser-compiled React single file → Astro static site
- Old → new (publishing): manual `rebuild-standalone.py` + console upload → git + GitHub Actions with OIDC
- Old → new (structure): one scrolling page → ~20 pre-rendered pages
- Old → new (content): partially-corrected placeholder template → written fresh against a claim register
- Old → new (record-keeping): none → this file, under the AGENTS constitution
**Why.** Discoverability is the practice's growth constraint and the current
architecture forecloses it. Every other decision follows from fixing that, with
the content decisions following from the strategy brief of 2026-05-26.
**Also decided, not yet discussed with Pouya:** no Indigenous engagement practice
page at launch (§6), on the reasoning recorded there. Flag this to him.
**Planned, not yet done.** Astro implementation; all page copy; 35 Insights
drafts; intake backend rework; CI/CD wiring; pre-cutover audit. Twelve blocking
questions are open in §9 — several pages cannot be written until they are
answered.
+122
View File
@@ -0,0 +1,122 @@
# CLAUDE.md — operating instructions for Claude Code
## Read this first
1. **`AGENTS.md` is the source of truth for this project.** Read it in full
before your first edit in any session. It carries the locked decisions, the
credential register, the open questions, and the full history.
2. **You are required to maintain `AGENTS.md`** under the constitution written at
the top of it. Update *Current Truth* in place; append to the *Change Log*,
newest first; never edit a past entry; never delete history. Record decisions
and plans, not only executed work. Stamp facts `[verified YYYY-MM-DD]` or
`[assumed]`.
3. Update it **at the end of every working session**, not only when something
ships. A session that produced a decision and no code still produces a Change
Log entry.
4. **Read `AGENTS.md` §12 Standing Reminders at the start of every substantial
session and surface anything live to Pouya.** These are decisions he parked
deliberately, not settled matters — R1 in particular is his explicit
instruction to keep raising the licensure wording. A parked decision that
stops being raised has quietly become permanent, which is the failure mode
§12 exists to prevent.
## 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.**
If a page needs a fact you do not have:
- Do not infer it from context.
- Do not soften it into something defensible ("extensive experience", "years of").
- Do not carry it over from the old site — the old site contained a fictitious
founder, invented matter values, and a fabricated testimonial.
- **Leave `TODO(pouya): <the exact question>` in the source, and add the question
to `AGENTS.md` §9.** A build that fails on an unanswered question is a correct
build.
Read the Forbidden table in §4 before writing any statistic, number, or
superlative.
## Commands
```bash
npm install
npm run dev # local dev server
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
```
## Where things live
```
AGENTS.md living project record — read first, maintain always
docs/ the specs you build from
01-architecture.md sitemap, URL map, per-page content outline
02-design-system.md tokens, type scale, motion, contrast constraints
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
src/
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
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.
**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,
say why in the Change Log. A `<details>` element beats a JS accordion.
**Styling.** Plain CSS with custom properties. No Tailwind, no CSS-in-JS, no
utility framework. Every colour, space, and font size comes from a token in
`tokens.css` — no raw hex values and no magic numbers in component styles.
**Accessibility is a build requirement, not a polish pass.** Semantic landmarks,
one `<h1>` per page, heading levels never skipped, visible focus states, all
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`.
**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.
**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
page that collects legal inquiries.
**Every page ships with:** a unique `<title>` and meta description, a canonical
URL, Open Graph and Twitter card tags, and appropriate JSON-LD. See
`docs/04-seo-spec.md`. A page without these is not finished.
**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.
**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.
## 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
- [ ] Renders correctly with JavaScript disabled
- [ ] `AGENTS.md` Change Log entry appended
+41
View File
@@ -0,0 +1,41 @@
# adr.smlcompany.ca
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.
## Quick start
```bash
nvm use # Node 22
npm install
npm run dev # http://localhost:4321
```
## Scripts
| Command | Does |
|---|---|
| `npm run dev` | Development server with hot reload |
| `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 |
## Before you contribute
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.
## 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`.
+35
View File
@@ -0,0 +1,35 @@
import { defineConfig } from 'astro/config';
import mdx from '@astrojs/mdx';
import sitemap from '@astrojs/sitemap';
// Canonical origin. Drives canonical URLs, OG tags, and the sitemap.
const SITE = process.env.PUBLIC_SITE_URL ?? 'https://adr.smlcompany.ca';
export default defineConfig({
site: SITE,
// Static output is the point of this rebuild — see docs/04-seo-spec.md.
// Do not switch to a server adapter without an AGENTS.md Change Log entry.
output: 'static',
// Directory-style URLs with a trailing slash, matched by the CloudFront
// trailing-slash function. See docs/06-deployment.md.
trailingSlash: 'always',
build: { format: 'directory', inlineStylesheets: 'auto' },
integrations: [
mdx(),
sitemap({
filter: (page) => !page.includes('/legal/'),
changefreq: 'monthly',
lastmod: new Date(),
}),
],
image: {
// Explicit dimensions everywhere; never base64-inline an image.
service: { entrypoint: 'astro/assets/services/sharp' },
},
prefetch: { prefetchAll: true, defaultStrategy: 'viewport' },
});
+344
View File
@@ -0,0 +1,344 @@
# 01 — Information architecture
Authority: `AGENTS.md` §3 D5 (full multi-page), §6, §5 (audience model).
Every claim in the copy outlines below must clear `AGENTS.md` §4.
---
## Why multi-page at all
The site being replaced is one scrolling page. One page can hold one title, one
meta description, one canonical URL, and one primary topic. It therefore gets
one shot at a search result.
The practice's target searches are not "Toronto mediator" — that term is owned by
retired judges with twenty years of name recognition, and the strategy brief is
explicit that competing there is the wrong game (§II). The winnable searches are
specific: *construction lien mediation Ontario*, *SaaS contract arbitration
Canada*, *SABS mediation Toronto*, *technology dispute neutral*, *Farsi-speaking
mediator*. Each of those wants its own page, its own title, its own copy, and its
own structured data.
That is the entire argument for the structure below. It is a discoverability
decision, not an aesthetic one.
---
## Sitemap
```
/ Home
/about/ Biography, credentials, the professional record
/mediation/ Mediation — the process, formats, rules
/arbitration/ Arbitration — the process, tracks, rules
/med-arb/ Med-Arb and hybrid processes
/practice/ Practice areas index
/practice/construction/ Construction and infrastructure disputes
/practice/technology/ Technology, AI, and data disputes
/practice/energy/ Energy, grid, and regulatory disputes
/practice/insurance/ Insurance, SABS, and accident benefits
/practice/shareholder/ Shareholder, partnership, and family business
/practice/cross-cultural/ Cross-border and diaspora disputes
/process/ What an engagement looks like, step by step
/fees/ Fee schedule and engagement terms
/for-parties/ Plain language: what mediation actually is
/insights/ Article index
/insights/[slug]/ Individual articles
/contact/ Intake form and booking
/legal/privacy/ Privacy policy — PIPEDA
/legal/terms/ Terms of use
```
Nineteen fixed URLs plus one per article.
### URL rules
- Lowercase, hyphenated, trailing slash, no file extensions.
- `/practice/<area>/` is a stable namespace — new practice areas slot in without
touching anything else.
- `/insights/<slug>/` — no dates in the path. A dated URL makes a piece look
stale at 18 months, and this content is mostly evergreen.
- Never change a published URL. If one must move, ship a CloudFront Function
301 and record it in the Change Log.
### Navigation
**Primary (header).** About · Mediation · Arbitration · Practice · Fees ·
Insights · Contact
"Practice" is a dropdown to the six areas, with `/practice/` itself reachable.
Build it as a `<details>` element or a CSS-only disclosure — no JavaScript.
**Footer.** Full sitemap in three columns, plus contact block, professional
designations, LinkedIn, privacy, terms, and the SML Company Ltd. entity line.
**Deliberately not in primary nav:** `/process/`, `/for-parties/`, `/med-arb/`.
These are linked contextually from the pages that lead to them. Seven items is
the ceiling before a nav stops being scannable.
---
## Deliberate omission: Indigenous engagement
The strategy brief (§III.4) rates Indigenous engagement, IBA, and consultation-
breakdown mediation as *"strategically the most valuable single niche"* for a
Q.Med on the C.Med-Arb pathway.
There is no page for it at launch, on the following reasoning:
The brief itself says the niche *"requires deliberate relationship work with
First Nations advisors, federal and provincial engagement staff, and corporate
proponents over a multi-year horizon."* A practice page is a claim of present
capability. Publishing one before that relationship work exists would be read as
exactly what it is by the audience best positioned to notice — and that audience
is small, well-connected, and unforgiving of practitioners who arrive claiming a
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.**
---
## Page specifications
Each page below gives its job, its primary audience, its target search intent,
and its section outline. Copy itself is in `03-content-spec.md`.
### `/` — Home
**Job:** establish the unusual stack in under ten seconds, and route each of the
four audiences to its surface.
**Audience:** all four; leans in-house counsel.
**Search intent:** brand and name searches; "Toronto ADR practice".
1. **Hero.** Eyebrow (`Mediation · Arbitration · Toronto`), display headline,
two-sentence positioning paragraph, two CTAs (*Request a consultation* /
*How I work*), portrait.
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.
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/`.
8. **Contact band.** Intake CTA and booking link.
### `/about/` — Biography and credentials
**Job:** be the page an appointing body or opposing counsel reads before agreeing
to an appointment. This page carries the verifiable record.
**Audience:** appointing bodies, ADR institutions, opposing counsel.
**Search intent:** `"Pouya Lajevardi"`, `Pouya Lajevardi mediator`.
1. Portrait, name, designation line.
2. **Narrative biography**, 400600 words. The three-track story — law,
engineering, operating a company — told as one arc rather than three lists.
3. **Credentials**, structured and scannable: designations, education,
certifications, memberships. Every line from `AGENTS.md` §4 Verified.
4. **The credentialing arc.** Q.Med held → Q.Arb in progress → C.Med-Arb as the
endpoint. The brief (§V) treats the arc itself as part of the story; say so
openly rather than implying a finished state.
5. **Languages and cross-cultural practice.**
6. **Speaking and publications.** Omit the section entirely until there is
something in it. An empty "Speaking" heading is worse than no heading.
7. `Person` JSON-LD. Downloadable one-page PDF bio — brief §VIII lists this as
an asset for circulation with appointment proposals.
### `/mediation/`
**Job:** convert counsel who have already decided on mediation and are choosing a
neutral.
**Search intent:** `commercial mediator Toronto`, `ADRIC mediation rules`,
`what happens at mediation Ontario`.
1. What the service is; the neutral's role stated plainly.
2. **Formats:** full-day, half-day, shuttle, remote, hybrid.
3. **Rules:** ADRIC Model Mediation Rules, or a bespoke protocol agreed by the
parties.
4. **What parties should bring** — briefs, documents, authority to settle.
5. **Confidentiality and without-prejudice framing.**
6. Practice areas → `/practice/*`.
7. Fees → `/fees/`. Booking → `/contact/`.
### `/arbitration/`
**Job:** the same, for arbitration — and to state the Q.Arb position honestly.
**Search intent:** `sole arbitrator Ontario`, `expedited arbitration Canada`,
`documents-only arbitration`.
1. What the service is; sole-arbitrator, party-appointed, and tribunal-secretary
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.
6. Fees, booking.
### `/med-arb/`
**Job:** own a term few Canadian neutrals explain well, and frame the C.Med-Arb
endpoint.
**Search intent:** `med-arb Canada`, `what is med-arb`, `arb-med`.
1. What Med-Arb is; how it differs from Arb-Med.
2. The procedural fairness objection, addressed head-on rather than elided.
3. When it fits and when it does not.
4. The C.Med-Arb designation and why it is the practice's stated endpoint.
This page is a strong candidate for the best-performing page on the site.
Search demand exists, competition is thin, and it maps exactly to the brand's
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.
### `/practice/construction/`
**Search intent:** `construction lien mediation Ontario`, `delay claim mediation`,
`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
Ontario megaproject pipeline as context — Darlington SMR, Bruce C, data centres,
transit; typical process shape. Strongest immediate fit per brief §III.1.
### `/practice/technology/`
**Search intent:** `SaaS dispute arbitration Canada`, `AI vendor dispute`,
`data residency dispute resolution`, `software contract mediator`.
The differentiator page. Dispute types: software contracts, SLA and MSA
breakdowns, data residency and processing, AI vendor diligence, cloud
sub-processor disputes, IP and licensing.
**Write this page in the register the brief demands:** a neutral who can read an
API trace, a model card, or a System Impact Assessment on the same page as the
contract. The brief warns explicitly against softening this to "technologically
literate" — the claim is engineering practice, so the copy says engineering
practice.
### `/practice/energy/`
**Search intent:** `Bill 40 dispute`, `IESO dispute resolution`,
`OEB leave to construct dispute`, `grid connection dispute Ontario`.
Grid connection and allocation, leave-to-construct, proponentmunicipality
disputes, IESO market participation, data-centre connection allocation. Brief
§III.2 frames this as a 2436 month build. **Write it as a genuine position, not
a claim of existing volume.**
### `/practice/insurance/`
**Search intent:** `SABS mediation`, `LAT pre-hearing mediation`,
`accident benefits mediator Ontario`, `MIG dispute`.
Highest realistic near-term volume — it flows directly from the existing
personal-injury and SABS practice, and brief §IV.7 notes the segment is
underserved by senior mediators. Unglamorous and worth doing well.
### `/practice/shareholder/`
**Search intent:** `shareholder dispute mediation Ontario`,
`partnership dissolution mediator`, `family business succession dispute`.
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.
### `/practice/cross-cultural/`
**Search intent:** `Farsi speaking mediator Toronto`,
`Iranian Canadian business dispute`, `diaspora shareholder dispute`.
Note that D4 makes the site English-only. This page describes Farsi-language
capability in English; it is not a Farsi page. Diaspora family-business
succession, dual-jurisdiction shareholder disputes, partnership disputes among
diaspora entrepreneurs, cross-cultural commercial matters.
### `/process/`
Five steps, from intake to conclusion: confidential intake (day 0) · engagement
and framing (17) · pre-session exchange (721) · the session (2130) · binding
conclusion (30+). Also: conflicts checking, confidentiality, and what happens if
a matter does not settle.
### `/fees/`
**Blocked on `AGENTS.md` Q4 — 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
are shared between parties; payment terms. A real page with real numbers, or a
`TODO(pouya)` — nothing in between.
### `/for-parties/`
**Job:** serve the self-represented tier without diluting the counsel-facing
pages. Plain language, short sentences, no jargon.
What mediation is · what it is not · who the mediator is and is not (not your
lawyer, not a judge) · what happens on the day · what it costs · what happens if
you do not settle · how to prepare.
### `/insights/` and `/insights/[slug]/`
Astro content collection, MDX. Index reverse-chronological with topic filtering
by practice area.
Article frontmatter: `title`, `description`, `publishDate`, `updatedDate`,
`topics[]`, `practiceAreas[]`, `readingTime`, `draft`.
Content territories, from brief §VII: process explainers · regulatory commentary ·
industry-specific dispute commentary · anonymised reflections · technical
explainers for lawyers · credentialing and career-arc content.
`Article` JSON-LD with `author` pointing at the `Person` entity. Each article
links to the relevant practice-area page — this is what turns the blog into
ranking power for the pages that convert.
**The section stays out of primary navigation until at least two pieces are
live.** An empty blog signals abandonment more loudly than no blog signals
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
note that submitting the form does not create a retainer or a mediatorparty
relationship and does not itself create a conflict check.
### `/legal/privacy/` and `/legal/terms/`
Required, not optional — the intake form collects personal information about
identifiable third parties in live legal disputes. What is collected, why, where
it is stored (DynamoDB, region), retention period, who can access it, how to
request deletion, and the contact for privacy inquiries. Must match what the
backend actually does.
---
## Build order
Dependency-ordered, so nothing is blocked mid-stream:
1. Scaffold, tokens, base layout, header, footer, SEO component
2. `/` — proves the design system end to end
3. `/about/` — the credential spine everything else references
4. `/mediation/`, `/arbitration/`, `/med-arb/`
5. `/practice/` and the six area pages
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
10. `/legal/*` — written to match the backend as actually built
11. Audit and cutover (`06-deployment.md`)
+195
View File
@@ -0,0 +1,195 @@
# 02 — Design system
Authority: `AGENTS.md` §3 D7 — *keep the palette and the infinity mark;
modernize the execution.* The look is not up for redesign. What follows is the
system that preserves it while fixing what the old build got wrong.
---
## What carries over unchanged
- 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.
- The **type pairing**: Instrument Serif for display, Geist for text, Geist Mono
for eyebrows and labels.
- The **editorial register** — generous whitespace, restrained colour, serif
display type at large sizes.
## What changes
| Was | Is | Why |
|---|---|---|
| Google Fonts at runtime | Self-hosted, subset, preloaded | Removes a render-blocking third-party round trip from a page that collects legal inquiries |
| Fixed px type sizes | Fluid `clamp()` scale | One scale from 360 px to 1600 px with no breakpoint jumps |
| 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 |
| React 18 dev build + Babel Standalone in the browser | Static HTML, near-zero JS | The reason the site is invisible to crawlers |
---
## Colour
Tokens live in `src/styles/tokens.css`. Never write a raw hex value in a
component.
| Token | Value | Use |
|---|---|---|
| `--cream` | `#faf7f2` | Page background |
| `--cream-2` | `#f3ede0` | Alternating section background |
| `--cream-3` | `#ebe3d1` | Cards and insets on cream |
| `--ink` | `#1a1614` | Body text; dark section backgrounds |
| `--ink-soft` | `#3a322c` | Secondary text |
| `--muted` | `#6e6359` | Metadata, captions — **on cream only** |
| `--maroon` | `#5a1a1c` | Primary action, accents, dark panels |
| `--maroon-d` | `#3d1112` | Hover on maroon |
| `--maroon-l` | `#7a2a2c` | Links on cream |
| `--gold` | `#c9a876` | Rules, dividers, on-dark accent — **never text on cream** |
| `--gold-d` | `#a88858` | Large decorative text on cream only |
| `--gold-l` | `#e2c89a` | Text on ink or maroon |
### Contrast — measured, 2026-08-25
Against `--cream` `#faf7f2`:
| Colour | Ratio | AA body (4.5) | AA large (3.0) |
|---|---|---|---|
| `--ink` `#1a1614` | **16.81** | pass | pass |
| `--ink-soft` `#3a322c` | **11.75** | pass | pass |
| `--maroon` `#5a1a1c` | **12.29** | pass | pass |
| `--maroon-l` `#7a2a2c` | **8.95** | pass | pass |
| `--muted` `#6e6359` | **5.47** | pass | pass |
| `--gold-d` `#a88858` | **3.11** | **FAIL** | pass |
| `--gold` `#c9a876` | **2.10** | **FAIL** | **FAIL** |
Against `--maroon` `#5a1a1c`: cream 12.29 · gold-l 8.11 · gold 5.84 — all pass.
Against `--ink` `#1a1614`: cream 16.81 · gold-l 11.09 · gold 8.00 — all pass.
`--muted` on ink measures **3.07 and fails**; use `--gold-l` or cream at reduced
opacity for secondary text on dark.
**Hard rules.**
1. `--gold` is never a text colour on cream. Rules, borders, dividers, icon
strokes, and on-dark text only.
2. `--gold-d` on cream only at 24 px+ / 19 px bold, and only for decorative
display text — never for anything a reader must parse.
3. Body text on cream is `--ink` or `--ink-soft`. Metadata may use `--muted`.
4. Secondary text on dark is `--gold-l` or `--cream` at ≥ 70% opacity.
5. **No dark mode.** A single committed light identity is the right call for a
legal practice, and halving the surface area halves the ways contrast breaks.
---
## Typography
**Faces.** Instrument Serif (display) · Geist 300/400/500/600 (text) ·
Geist Mono 400/500 (eyebrows, labels, data).
Self-host all three. Subset to Latin + Latin Extended-A. `font-display: swap`.
Preload only the two faces used above the fold — Instrument Serif regular and
Geist 400.
**Scale.** Fluid, `clamp()`, `1.25` ratio at the small end widening to `1.333` at
the display end. Tokens `--text-xs` through `--text-6xl` in `tokens.css`.
**Rules.**
- Display type (`--font-serif`) at `--text-4xl` and above only. It has almost no
hinting at small sizes and looks weak below 32 px.
- Display line-height `0.95``1.05`; letter-spacing `-0.02em`.
- Body line-height `1.6`. Measure capped at `68ch` — the old site ran full-bleed
paragraphs at 1400 px, which is unreadable.
- Eyebrows: mono, 1112 px, `0.18em` tracking, uppercase, always paired with a
real heading. An eyebrow is not a heading and never carries the `<h*>`.
- Italic display (`.it`) is the one flourish the design allows. One italic phrase
per headline, at most.
- Never skip a heading level. `<h1>` once per page.
---
## Spacing and layout
8 px base: `--space-1` `4px` · `-2` `8` · `-3` `12` · `-4` `16` · `-5` `24` ·
`-6` `32` · `-7` `48` · `-8` `64` · `-9` `96` · `-10` `128` · `-11` `160`.
Content width `1280px`; prose measure `68ch`; wide media `1440px`.
Gutters: `24px` mobile, `48px` desktop.
Section rhythm: `--space-9` (96px) mobile, `--space-11` (160px) desktop.
Grid: 12 columns desktop, 6 tablet, 4 mobile, `--space-5` gutter.
---
## Motion
The old build animated nearly everything on scroll. The replacement is
deliberate and quiet.
- **Reveal on section entry only** — not on every child element. Sub-element
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.
- Hover transitions `250ms`.
```css
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
```
Nothing animates infinitely. Nothing autoplays. Nothing moves on page load
except the reveal of the hero.
---
## Components
| Component | Notes |
|---|---|
| `InfinityMark` | Inline SVG, `currentColor`, `aria-hidden` when decorative. Never a PNG |
| `SiteHeader` | Sticky, condenses on scroll. Practice dropdown as CSS-only `<details>` |
| `SiteFooter` | Three-column sitemap, contact block, designations, entity line |
| `Eyebrow` | Mono label with optional maroon dot |
| `SectionHeading` | Eyebrow + display heading + optional lede, one measure |
| `Button` | Variants `primary` (maroon) · `ghost` (outlined) · `gold` (ink bg, gold-l text). Renders `<a>` or `<button>` correctly |
| `Pill` | Small bordered label for designations and sector chips |
| `CredentialRow` | Three or four credential slots. **Never matter counts**`AGENTS.md` §4 |
| `PracticeCard` | Sector chip, heading, one paragraph, arrow link |
| `ProcessStep` | Numbered step, timing, body |
| `ArticleCard` | Title, description, date, topic pills, reading time |
| `Prose` | Long-form wrapper. Owns all typographic defaults for MDX |
| `SEO` | Title, description, canonical, OG, Twitter, JSON-LD — see `04-seo-spec.md` |
**Focus states.** Every interactive element gets a visible focus ring:
`outline: 2px solid var(--maroon); outline-offset: 3px`. Use `:focus-visible`.
Never `outline: none` without a replacement — the old build removed it globally.
---
## Accessibility floor
Not a polish pass. A build requirement.
- One `<h1>` per page; heading levels never skipped.
- Landmarks: `<header>`, `<nav>`, `<main>`, `<footer>`. Skip-to-content link first
in tab order.
- Every image has `alt`. Decorative images get `alt=""`.
- Colour never carries meaning alone.
- All functionality reachable by keyboard; focus order matches visual order.
- 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.
- Every page must be readable and navigable with JavaScript disabled.
+198
View File
@@ -0,0 +1,198 @@
# 03 — Content and voice
Authority: `AGENTS.md` §4 (claim register) and §5 (audience model).
Source material: `PL_ADR_Personal_Branding_Strategy_Brief.docx` (2026-05-26) and
`ADR_Site_Content_Brief_for_Claude_Design.md` (2026-05-26).
---
## The one rule
**Every factual claim traces to `AGENTS.md` §4 Verified.** Read the Forbidden
table before writing any number, statistic, or superlative. If you need a fact
you do not have, write `TODO(pouya): <exact question>` and log it in §9. Do not
infer, do not soften, do not carry anything over from the old site.
---
## Voice
**Restrained, precise, and unhedged.** The reader is usually a lawyer. They
detect padding instantly and discount everything after it.
**Do:**
- Short declaratives. "I read the contract and the code." Not "clients benefit
from a uniquely multidisciplinary perspective."
- First person singular. This is a practitioner brand — "I", not "we", not "the
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
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.
**Do not:**
- **Any claim or implication of legal licensure.** D13: the site asserts the JD
and nothing more. Never "lawyer", "called to the bar", "licensed", "my law
practice", "my litigation practice", "my clients", "acts for", "represents".
Implication counts as much as assertion.
**The approved phrasing is "active litigation exposure" or "involvement in
litigation and ADR matters" — never "practice" in that context.** Pouya's
wording, 2026-08-26. So: *Director of Firm Operations at a Toronto litigation
and ADR boutique, with active exposure to construction, personal injury, POA,
and SABS matters.* Accurate, specific, and it claims nothing it should not.
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.
- Outcome language that could be read as a guarantee.
- "Passionate", "dedicated", "committed", "proven track record", "results-driven",
"leverage", "synergy", "solutions".
- Hedges that erase the claim. The strategy brief warns specifically against
softening the technical claim to "technologically literate" — **the claim is
engineering practice, so the copy says engineering practice.**
- Em-dash-heavy rhythm and tricolon padding. One idea per sentence.
- Second-person sales copy on counsel-facing pages. `/for-parties/` is the one
page written to "you".
---
## The core positioning statement
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
> 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.
Every version of this must survive the §4 check. It does: each element is
verified.
## Approved headline options
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.*
## The credential row
Three slots, never counts:
| Slot | Value | Label |
|---|---|---|
| 1 | **Q.Med** | ADRIC / ADRIO designation |
| 2 | **JD + ML** | Law and engineering |
| 3 | **EN · FA** | Bilingual practice |
Fourth slot where the layout has one: **Q.Arb** — in progress.
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
true; none grows by closing files.
---
## Per-page copy notes
### Home
Hero headline from the approved list. Positioning paragraph above. CTAs:
*Request a consultation →* and *How I work*. The approach section makes the
"two directions at once" argument — law and engineering converging on the same
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".
### 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
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
that arc as part of the story rather than something to obscure.
Omit any section that would be empty. No "Speaking" heading until there is a
talk to list.
### Mediation / Arbitration / Med-Arb
Procedural, specific, unembellished. Name the rules. Describe the formats. State
what a party should expect to do and when. On `/arbitration/`, state the Q.Arb
position in plain terms — what is available now versus what follows designation.
`/med-arb/` addresses the procedural-fairness objection directly: the same
neutral who heard a party's confidential caucus later decides the matter. Do not
elide it. Explain the consent mechanics and when the process is inappropriate.
Meeting the strongest objection is what makes the page worth reading.
### Practice areas
Each page: dispute types, why this practice fits, what the process looks like,
and the market context that makes the area live. Context comes from the strategy
brief §IIIIV — Ontario's megaproject pipeline, Bill 40 and grid connection, the
2026 privacy statute, LAT volumes.
**Frame as positioning, not as history.** "Built to facilitate procurement and
subcontract disputes on Ontario's megaproject pipeline" — not "extensive
experience resolving". The first is true and forward-looking. The second is
neither.
### Process
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.
### Fees
**Blocked on Q4.** Real numbers or `TODO(pouya)`. Plain table, no "starting from"
evasions, no "contact for pricing" after promising a rate card.
### For parties
The one page in second person. Grade-9 reading level. Short sentences. Says
explicitly: the mediator is not your lawyer and cannot give you legal advice; the
mediator does not decide who is right. Answers what it costs and what happens if
you do not settle.
### Insights
1,2001,800 words, monthly cadence (brief §VIII). Territories from §VII:
process explainers · regulatory commentary · industry dispute commentary ·
anonymised reflections · technical explainers for lawyers · credentialing content.
Every piece links to at least one practice-area page. Anonymised reflections must
be genuinely unidentifiable — not merely name-stripped. If a matter could be
recognised by the parties to it, it does not run.
### Launch article slate (D9)
Drafted by Claude, **every word reviewed by Pouya before publication**:
1. *What the Ontario data-centre build-out means for dispute resolution*
technology + construction; the strongest single differentiator piece.
2. *When Med-Arb is the right answer, and when it is not* — process explainer;
feeds `/med-arb/`; high search intent, thin competition.
3. *Bill 40 and grid connection: a dispute-resolution read* — regulatory
commentary; establishes the energy niche.
4. *What a System Impact Assessment actually evaluates* — technical explainer for
lawyers; the clearest demonstration of the claim the whole brand rests on.
5. *Choosing a neutral: what counsel should actually ask* — evergreen, useful,
and it makes the case for this practice without arguing for it.
---
## Compliance checklist — before any page ships
- [ ] Every factual claim appears in `AGENTS.md` §4 Verified
- [ ] No matter counts, settlement rates, dollar figures, or time-to-award stats
- [ ] No testimonials, endorsements, or third-party quotes
- [ ] No superlatives and no guarantee language
- [ ] No claim or implication of legal licensure anywhere (D13)
- [ ] Q.Arb described as commenced August 2026, never as held or nearly complete
- [ ] Nothing implies a firm, a team, or offices that do not exist
- [ ] Contact page states that an inquiry creates no retainer and no
mediatorparty relationship
- [ ] Any comparative claim is factual and verifiable
+132
View File
@@ -0,0 +1,132 @@
# 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 baseline being replaced
| | Now `[verified 2026-08-25]` | Target |
|---|---|---|
| Content in server HTML | `SML Company · DISPUTE RESOLUTION · Unpacking...` | Every word |
| Indexable pages | 1 | 19 + articles |
| `<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 |
| `robots.txt` | 403 | Served |
| Sitemap | none | Generated at build |
| Favicon | none | Full set |
Astro's static output solves most of this by existing. The rest is the spec below.
## Why static output matters more than usual here
Google can sometimes render client-side JavaScript. Bing largely does not.
LinkedIn's preview crawler does not. Slack's unfurler does not. **And the
crawlers behind AI assistants — increasingly how counsel and in-house teams
find a neutral — generally do not.**
A site that requires three CDN round trips and an in-browser Babel compile before
producing a sentence is invisible to all of them. That is the whole argument for
D1.
---
## Metadata
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"
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)
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.
## Structured data
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 |
| `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 |
| `FAQPage` | `/for-parties/`, `/med-arb/` | Only where the visible page genuinely is Q&A. Never fabricate questions to farm a rich result |
**`hasCredential` must reflect reality.** Q.Med is held. Q.Arb is not. Marking an
unheld credential as held in structured data is a misrepresentation that happens
to be machine-readable.
## Crawlability
**`public/robots.txt`:**
```
User-agent: *
Allow: /
Disallow: /legal/
Sitemap: https://adr.smlcompany.ca/sitemap-index.xml
```
Do not block AI crawlers. Being read by an assistant that a general counsel is
using to shortlist neutrals is the point.
**Sitemap:** `@astrojs/sitemap`, excluding `/legal/*` and any `draft: true`
article. Submit to Google Search Console and Bing Webmaster Tools at cutover.
**Internal linking.** Every practice page links to `/mediation/` and
`/arbitration/`; those link back to the practice areas; every article links to
at least one practice page. This is what turns Insights into ranking power for
the pages that convert. Breadcrumbs on every nested page.
**404 page.** Real, styled, with search-intent links out. CloudFront must return
it with a genuine 404 status — not a 200, which the S3 website-endpoint pattern
gets wrong by default.
## Performance
Core Web Vitals are a ranking input, and the current build fails all of them.
| Metric | Budget |
|---|---|
| LCP | < 2.0 s, Slow 4G |
| CLS | < 0.05 |
| INP | < 150 ms |
| JS per route | < 100 KB |
| Lighthouse (mobile) | ≥ 95 all four categories |
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
booking embed on `/contact/` — and that one is lazy-loaded behind a click.
## Local and professional presence
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.
## 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
- [ ] 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
+175
View File
@@ -0,0 +1,175 @@
# 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.
**Read that guide before changing anything**; the resources already exist and
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]`
The shape is right. This is a hardening and rework pass, not a replacement.
## What this data actually is
The form collects, in a live legal dispute: the inquirer's identity and contact
details, the names of opposing parties and their counsel, the nature of the
dispute, and often the amounts at issue.
That is **personal information about identifiable third parties who have not
consented and do not know the submission happened.** It is more sensitive than a
typical contact form by a wide margin, and it is potentially conflict-relevant.
Design accordingly. Nothing in this section is optional.
---
## Form fields
| Field | Type | Required | Notes |
|---|---|---|---|
| Name | text | yes | |
| Email | email | yes | Validated server-side, not only in the browser |
| Phone | tel | no | |
| Role | select | yes | Counsel · In-house · Party · Institution · Other |
| Firm / organisation | text | no | |
| Process sought | select | yes | Mediation · Arbitration · Med-Arb · ENE · Not sure |
| Practice area | select | yes | The six areas plus Other |
| Other parties | text | no | Surfaced for conflicts screening |
| Opposing counsel | text | no | Same |
| Matter summary | textarea | yes | 2000 char cap. Hint: *no privileged or confidential detail* |
| Timing | select | no | Urgent · 30 days · 90 days · Exploring |
| Preferred contact | radio | no | Email · Phone |
| Consent | checkbox | **yes** | Explicit, unchecked by default, links to `/legal/privacy/` |
**Do not collect** dollar amounts, document uploads, or anything the inquirer
might reasonably treat as privileged. The intake call is for that.
### Consent text
> I consent to Pouya Lajevardi storing and using the information in this form to
> respond to my inquiry and to run a conflicts check. I understand that
> submitting this form does not create a retainer, does not appoint a neutral,
> and does not itself establish a mediatorparty relationship.
## Validation and abuse control
Client-side validation is a convenience. **The Lambda re-validates everything.**
- Required fields present; email well-formed; lengths within bounds
- Reject any field over its cap rather than truncating silently
- **Honeypot** field, hidden from sighted and screen-reader users, must be empty
- **Timestamp check** — reject submissions completed in under 3 seconds
- **Rate limit** by source IP at API Gateway: 5 requests / 5 minutes
- No CAPTCHA. It is a third-party script on a page collecting legal information,
and the two controls above stop the traffic that matters
- CORS restricted to `https://adr.smlcompany.ca` — no wildcard
- Strip HTML from every field before storage and before it enters an email body
## 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**).
| Attribute | |
|---|---|
| `pk` | `INTAKE#<uuid>` |
| `sk` | `<ISO-8601 timestamp>` |
| fields | as above |
| `sourceIp`, `userAgent` | abuse investigation only |
| `ttl` | epoch seconds — **automatic deletion** |
**Encryption at rest** with a customer-managed KMS key. **Point-in-time recovery
on.** Table access limited to the Lambda role and one named administrative
principal.
### Retention
**24 months, enforced by DynamoDB TTL.** Not a policy someone remembers — a
mechanism that runs whether anyone remembers or not.
Rationale: long enough to serve conflicts screening across a normal matter
lifecycle; short enough to be defensible under PIPEDA's requirement to retain
personal information only as long as necessary. Whatever number ships must match
`/legal/privacy/` exactly.
## Notification
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.
SES must have SPF, DKIM, and DMARC aligned on `smlcompany.ca` or these land in
spam. The guide covers domain verification; **DMARC needs confirming (Q3)**.
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
DLQ depth ≥ 1.
## Booking
An embedded scheduler for the 3045 minute confidential intake call
(**Q5** — tool not yet chosen).
- Prefer a provider with Canadian or EU data residency and no advertising
business. Cal.com self-hosted is the strongest privacy posture; Cal.com cloud
or Calendly are acceptable.
- **Lazy-load behind a click.** No third-party iframe on first paint, and no
third-party script on any other page.
- Provide a plain link fallback that works with JavaScript disabled.
- The booking page must carry the same no-retainer language.
- Disclose the provider by name in `/legal/privacy/`.
## Security headers
Set at CloudFront via a response-headers policy:
```
Strict-Transport-Security: max-age=63072000; includeSubDomains; preload
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: geolocation=(), microphone=(), camera=(), interest-cohort=()
Content-Security-Policy: default-src 'self'; img-src 'self' data:;
style-src 'self' 'unsafe-inline'; script-src 'self';
frame-src <booking-provider>; form-action 'self' <api-endpoint>;
base-uri 'self'; frame-ancestors 'none'
```
Tighten CSP once the booking provider is chosen. `unsafe-inline` on styles is
tolerable for critical CSS; `unsafe-inline` on scripts is not — use a hash or
nonce for the reveal script.
## Privacy policy must state
Written to match what is actually built, not what is typical:
What is collected · why · lawful basis (consent) · where it is stored (DynamoDB,
region, encrypted at rest) · **the retention period and that deletion is
automatic** · who can access it · third parties involved (AWS, SES, the booking
provider, analytics if any) · how to request access or deletion and the address
to use · that submitting the form creates no retainer and no mediatorparty
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**).
## Definition of done
- [ ] Server-side validation independent of the client
- [ ] Honeypot and timing checks live; rate limit configured
- [ ] CORS restricted to the production origin
- [ ] TTL set and verified by test record
- [ ] KMS encryption and PITR enabled
- [ ] Both emails send; SPF/DKIM/DMARC aligned; inbox-tested, not spam-tested
- [ ] DLQ and CloudWatch alarm configured
- [ ] Form usable by keyboard only; errors announced with `role="alert"`
- [ ] Form degrades to a `mailto:` fallback with JavaScript disabled
- [ ] Privacy policy matches the implementation line for line
+274
View File
@@ -0,0 +1,274 @@
# 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`.
---
## Topology
```
GitHub push to main
└─ GitHub Actions
├─ npm ci && npm run build → ./dist
├─ assume AWS role via OIDC (no stored keys)
├─ aws s3 sync ./dist s3://<bucket>
└─ cloudfront create-invalidation
Namecheap DNS → CloudFront → S3 (OAC)
API Gateway → Lambda → DynamoDB / SES (intake, unchanged path)
```
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.**
## 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`.
**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.
### The one real difference: no OIDC
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.
**Create the user:**
1. IAM → Users → `adr-sml-deploy`. **Programmatic access only** — no console
password, no MFA device, no group membership.
2. Attach this inline policy and nothing else. Substitute the real bucket name,
account ID, and distribution ID from `scripts/aws-discover.sh`:
```json
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "ListSiteBucket",
"Effect": "Allow",
"Action": "s3:ListBucket",
"Resource": "arn:aws:s3:::BUCKET_NAME"
},
{
"Sid": "WriteSiteObjects",
"Effect": "Allow",
"Action": ["s3:PutObject", "s3:PutObjectAcl", "s3:DeleteObject"],
"Resource": "arn:aws:s3:::BUCKET_NAME/*"
},
{
"Sid": "InvalidateOneDistribution",
"Effect": "Allow",
"Action": "cloudfront:CreateInvalidation",
"Resource": "arn:aws:cloudfront::ACCOUNT_ID:distribution/DISTRIBUTION_ID"
}
]
}
```
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.
3. Create an access key. **Copy it once** — AWS will not show the secret again.
### Gitea configuration
**Repository → Settings → Actions → Secrets:**
| Name | Value |
|---|---|
| `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`):
| 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` |
| `BOOKING_URL` | *(empty — parked, R6)* |
IAM policy substitutions: `BUCKET_NAME` = `adr-smlcompany-site`,
`ACCOUNT_ID` = `327082975128`, `DISTRIBUTION_ID` = `E1OK7G98KNKUTA`.
> **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) |
### A runner must exist
Gitea Actions needs `act_runner` registered to this repository or its
organisation, and Actions enabled both site-wide in `app.ini`
(`[actions] ENABLED = true`) and per-repository. Without a runner the workflow
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.
### Key rotation — an operational obligation
**Rotate `adr-sml-deploy` quarterly.** OIDC would have made this unnecessary;
with a static key it is a standing task:
1. Create a second access key on the same user.
2. Update the Gitea secrets.
3. Run the workflow and confirm it succeeds.
4. **Delete the old key.** Rotation that leaves the old key active is not
rotation.
Set a calendar reminder. A key that is never rotated is the failure mode this
whole section exists to bound.
## Finding the AWS identifiers
`scripts/aws-discover.sh` collects everything Q10 needs — bucket, distribution
ID, regions, API endpoint, certificate, SES identities, and whether S3 versioning
is on. Read-only; every call is a list or describe.
```bash
chmod +x scripts/aws-discover.sh
./scripts/aws-discover.sh > aws-inventory.txt
```
The output contains resource names and IDs but no secrets.
## Why OIDC and not access keys
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.
One-time setup:
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.
## Cache policy
The mistake to avoid is caching HTML aggressively — a stale index page is a site
that does not update.
| Pattern | `Cache-Control` |
|---|---|
| `*.html` | `public, max-age=0, must-revalidate` |
| `/_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` |
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.
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.
- Redirect HTTP → HTTPS. TLS 1.2 minimum.
- Default root object `index.html`.
- **Custom error response:** 404 → `/404.html` with **response code 404**, not
200. Returning 200 for a missing page tells crawlers every bad URL is real
content, and it is the single most common misconfiguration in this stack.
- Compression on. Response-headers policy from `05-backend-spec.md`.
- A CloudFront Function for trailing-slash normalisation, so `/about` and
`/about/` do not both resolve as separate indexable URLs.
## Branch model
`main` is production; every push deploys. Work on short-lived branches, open a
PR, let CI build and run Lighthouse, merge.
**Pull request checks (blocking):** `npm run build` · `astro check` · lint ·
Lighthouse CI against the budgets in `04-seo-spec.md` · link check.
Tag every production deploy `v<year>.<n>` so a rollback has something to name.
## Rollback
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.
Then invalidate `/*`.
## Cutover checklist — D11 is a single shot, so run all of it
**Content and compliance**
- [ ] Every claim traced to `AGENTS.md` §4 Verified
- [ ] 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
- [ ] Privacy policy matches the backend as actually built
**Technical**
- [ ] 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
- [ ] 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
- [ ] Favicon set complete
- [ ] Tested on iOS Safari, Android Chrome, desktop Safari/Chrome/Firefox
- [ ] Tested at 320 px and at 200% zoom
**Infrastructure**
- [ ] S3 versioning enabled
- [ ] 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)
**Post-cutover, same day**
- [ ] Sitemap submitted to Google Search Console and Bing Webmaster Tools
- [ ] Live site fetched as an anonymous crawler to confirm indexable content
- [ ] LinkedIn profile and ADRIC/ADRIO listings updated to point here
- [ ] Archive the old single-file build to `_archive/` — do not delete it
- [ ] `AGENTS.md` Change Log entry recording the cutover
+202
View File
@@ -0,0 +1,202 @@
# 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**).
**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.
Research date: 2026-08-26. All figures below are **plus HST** unless stated.
---
## The Ontario market, measured
### The regulated floor
Ontario's mandatory mediation tariff (Rule 24.1) sets the roster rate. ADR
Chambers publishes it as **$600 to $825 depending on the number of parties**,
covering *"one half hour of preparation time per party and up to three hours of
mediation."*
This is the floor of the market, and it is a floor with a signal attached:
pricing at or near it reads as roster-filler work.
### Published hourly bands
ADR Chambers, across its full roster:
| | Range |
|---|---|
| Mediators | **$150 $750 / hour** *"depending on the experience of the mediator"* |
| Arbitrators | **$250 $800 / hour** |
Plus, on the arbitration side: an **$800 filing fee**, a **$800 venue fee** for a
hearing room and one breakout room, and **$400** per additional room.
### Published practitioner rate cards
Four Ontario practitioners publishing real numbers:
| Practice | Half-day | Full day | Overtime | Notes |
|---|---|---|---|---|
| **Patey** — Tier 1, PI / insurance | $800 (3 h) | $1,200 (6 h) | $250 / h | Multi-party 3 h $1,200; multi-party full day $2,400; pro forma to 1.5 h $500 |
| **Patey** — Tier 2, estate / employment / civil | $1,200 (3 h) | $2,400 (6 h) | $375 / h | Pre-mediation caucus $175 flat |
| **Zuber** — video | $1,800 (3 h) | $2,800 (6 h) | $500 / h | +$500 per additional party |
| **Zuber** — in person, GTA | — | $4,000 (6 h) | $500 / h | Eastern Ontario $3,500. Prep and travel included |
| **Carroll** — Ottawa | $1,750 (incl. 1.5 h prep) | $3,000 (incl. 2 h prep) | $400 / h | Arbitration day rate $3,000 |
### What the shape of that data says
Three observations that drive the recommendation.
1. **The market is already segmented by matter type, not only by seniority.**
Patey runs two published tiers off the same neutral. Insurance and PI work
clears around $800$1,200 a day; estate, employment, and civil work clears
$2,400 for the same hours. This is the single most useful structural fact in
the research.
2. **Prep time is a pricing lever, disclosed differently by everyone.** Carroll
bundles named hours (1.5 h and 2 h). Zuber bundles prep *and* travel. Patey
bundles neither and sells a caucus separately. Bundling explicitly reads as
more confident and removes an argument later.
3. **Additional parties are always priced, never absorbed.** $300$500 per party
beyond two is the norm, and a four-party construction mediation is materially
more work than a two-party one.
---
## Where this practice should sit
**Not at the floor.** Pouya's stack — JD, an operating role inside a litigation
and ADR boutique, Q.Med held, Q.Arb commenced, and a working engineering career —
is not a junior generalist profile. Entering at roster rates would anchor him
into SABS volume work and make the commercial rate very hard to raise later.
Published rates are close to unrecoverable once set: raising them looks
opportunistic, discounting privately never becomes public knowledge.
**Not at the top either.** $4,000-a-day in-person GTA rates belong to neutrals
with twenty years of name recognition. Asking that without an independent track
record invites a comparison he loses.
**The position is the upper-middle: at or just above Patey Tier 2, just below
Zuber and Carroll.** That reads as *credentialed and serious, priced to be taken
seriously, not yet a marquee name* — which is exactly true.
---
## The confirmed rate card
**Set by Pouya on 2026-08-26 (D14). This is the card. Build `/fees/` from it.**
He declined the two-tier structure and set one rate for all mediation matters.
All figures **plus HST**.
### Mediation — all matters, one rate
| Item | Fee |
|---|---|
| Half day — up to 3.5 h, including 2 h preparation | **$2,000** |
| Full day — up to 7 h, including 3 h preparation | **$4,000** |
| Each party beyond two | **$500** |
| Overtime, per hour | **$500** |
### 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`.
| Item | Fee |
|---|---|
| Hourly | **$500** |
| Hearing day | **$4,000** |
| Documents-only / expedited, flat — simple | **$6,500** |
| Documents-only / expedited, flat — complex | **$9,500** |
**No tribunal-secretary rate.** Removed by Pouya. Do not reinstate it, and do not
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**.
### Cancellation — adopted as recommended
| When | Fee |
|---|---|
| More than 30 days before | No fee. Disbursements only |
| 15 30 days before | 50% of the booked fee |
| Fewer than 15 days before | 100% of the booked fee |
| Rescheduled with a new date fixed at the same time | No charge |
| Reserved time filled by another matter of equal or greater value | Waived |
### Terms to state on the page
- All fees plus HST.
- Shared equally between the parties unless they agree otherwise in writing.
- Payable on rendering; interest on overdue accounts at 5% per annum.
- **Video and in-person at the same rate.** Do not discount remote sessions —
the preparation is identical, and discounting teaches the market that the
session is the product.
- Travel outside the GTA billed separately or bundled at a stated day rate.
### All parameters confirmed
Q15, Q16, and Q17 were closed on 2026-08-26. **Preparation time is bundled and
must be stated on the page** — "including 2 hours of preparation", "including
3 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.
---
## Recorded dissent — for the 12-month review (R5)
Claude recommended a two-tier card; Pouya set a single rate. The reasoning is
recorded here so the 12-month review has something to test against, not to
re-open a settled decision.
**Where the single rate lands relative to the measured market:**
| Segment | Published market, full day | This card |
|---|---|---|
| Insurance / SABS / LAT | ~$1,200 $2,400 | **$4,000** |
| Commercial / civil / estate | ~$2,400 $3,000 | **$4,000** |
| Established GTA in person | ~$3,500 $4,000 | **$4,000** |
$4,000 is at the ceiling of the published Ontario market — level with Zuber's
in-person GTA rate, and roughly **three times** the going rate for the insurance
and SABS segment.
**The consequence worth watching.** The strategy brief (§IV.7) identifies
accident-benefits and LAT mediation as the highest realistic near-term volume,
flowing directly from the firm's existing practice. At $4,000 a day that segment
is priced out. This is a coherent choice — a premium specialist position that
forgoes volume — **provided the volume was not being counted on.** If early
appointment flow is slower than expected, the SABS tier is the first place to
look, and reintroducing a second tier is a cleaner fix than cutting the headline
rate.
**What makes the rate defensible.** $4,000 for a neutral who reads the contract,
the code, and the System Impact Assessment is a fair price. $4,000 for a
generalist is not. The rate and `/practice/technology/` are load-bearing for each
other, which is an argument for shipping them in the same release — and for the
Insights section carrying real technical depth rather than process explainers
alone.
**One thing the single rate gets right.** Published rates are close to
unrecoverable, and it is far easier to add a lower tier later than to raise a
headline rate. Setting the ceiling first and discounting privately preserves
more optionality than the reverse.
---
## Sources
- [ADR Chambers — Mediation Fees](https://adrchambers.com/mediation/fees/)
- [ADR Chambers — Roster Rate / Mandatory Mediations](https://adrchambers.com/roster-rate-mediation/)
- [ADR Chambers — Arbitration Fees](https://adrchambers.com/arbitration/fees/)
- [Patey Mediations — Rates & Cancellation](https://pateymediations.com/rates/)
- [Zuber Mediation — Fees](https://www.zubermediation.com/fees.html)
- [Carroll Mediation — Rates & Cancellation](https://www.carrollmediation.ca/?page_id=16)
- [O. Reg. 451/98 — Mediators' Fees (Rule 24.1)](https://www.canlii.org/en/on/laws/regu/o-reg-451-98/latest/o-reg-451-98.html)
+32
View File
@@ -0,0 +1,32 @@
{
"name": "adr-smlcompany-ca",
"version": "0.1.0",
"private": true,
"description": "The dispute resolution practice of Pouya Lajevardi — Toronto",
"type": "module",
"engines": { "node": ">=22" },
"scripts": {
"dev": "astro dev",
"build": "astro build",
"preview": "astro preview",
"check": "astro check",
"lint": "eslint . && prettier --check .",
"format": "prettier --write .",
"lighthouse": "lhci autorun"
},
"dependencies": {
"astro": "^5.0.0",
"@astrojs/mdx": "^4.0.0",
"@astrojs/sitemap": "^3.2.0",
"sharp": "^0.33.0"
},
"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"
}
}
+9
View File
@@ -0,0 +1,9 @@
# adr.smlcompany.ca
# 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.
User-agent: *
Allow: /
Disallow: /legal/
Sitemap: https://adr.smlcompany.ca/sitemap-index.xml
+62
View File
@@ -0,0 +1,62 @@
#!/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.
#
# chmod +x scripts/aws-discover.sh
# ./scripts/aws-discover.sh > aws-inventory.txt
#
# aws-inventory.txt contains NO secrets — only resource names and IDs — so it
# is safe to share. It is gitignored anyway.
# ---------------------------------------------------------------------------
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; }
hr "Identity and default region"
try aws sts get-caller-identity --output table
echo "default region: $(aws configure get region || echo '(unset)')"
hr "S3 buckets -> which one holds the site?"
try aws s3 ls
hr "CloudFront distributions -> Id, Aliases, Origin"
try aws cloudfront list-distributions \
--query "DistributionList.Items[].{Id:Id,Status:Status,Domain:DomainName,Aliases:join(',',Aliases.Items||[\`none\`]),Origin:Origins.Items[0].DomainName}" \
--output table
hr "ACM certificates (us-east-1 — CloudFront certs live there)"
try aws acm list-certificates --region us-east-1 \
--query "CertificateSummaryList[].{Domain:DomainName,Status:Status,Arn:CertificateArn}" --output table
hr "API Gateway HTTP APIs -> the intake endpoint"
try aws apigatewayv2 get-apis \
--query "Items[].{Name:Name,ApiId:ApiId,Endpoint:ApiEndpoint,Protocol:ProtocolType}" --output table
hr "Lambda functions"
try aws lambda list-functions \
--query "Functions[].{Name:FunctionName,Runtime:Runtime,Modified:LastModified}" --output table
hr "DynamoDB tables"
try aws dynamodb list-tables --output table
hr "SES verified identities"
try aws sesv2 list-email-identities \
--query "EmailIdentities[].{Identity:IdentityName,Type:IdentityType,Verified:VerifiedForSendingStatus}" --output table
hr "S3 versioning on each bucket (rollback depends on this)"
for b in $(aws s3api list-buckets --query 'Buckets[].Name' --output text 2>/dev/null); do
printf ' %-45s %s\n' "$b" "$(aws s3api get-bucket-versioning --bucket "$b" --query 'Status' --output text 2>/dev/null || echo '?')"
done
hr "Done"
cat <<'NOTE'
Send back:
* the S3 bucket that holds the site
* the CloudFront distribution Id whose Aliases include adr.smlcompany.ca
* the default region, and the region of the DynamoDB table
* the API Gateway Endpoint for the intake API
NOTE
Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 349 KiB

+42
View File
@@ -0,0 +1,42 @@
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 };
+161
View File
@@ -0,0 +1,161 @@
/**
* Site-wide constants.
*
* Anything marked TODO(pouya) is an unanswered question in AGENTS.md §9.
* Do not guess a value to make the build pass — an unanswered question is
* supposed to be visible. See CLAUDE.md.
*/
export const SITE = {
name: 'Pouya Lajevardi',
tagline: 'Mediation · Arbitration · Toronto',
url: 'https://adr.smlcompany.ca',
locale: 'en_CA',
entity: 'SML Company Ltd. · Ontario, Canada',
} as const;
/**
* Verified credentials only — mirrors AGENTS.md §4.
* Adding a line here without adding it there is a bug.
*
* D13: the site asserts the JD and makes NO licensure claim. Do not add
* 'lawyer', 'called to the bar', 'licensed', or any post-nominal implying a
* licence — and do not imply it either. See AGENTS.md §4 Forbidden.
*/
export const CREDENTIALS = {
designations: ['Q.Med (ADRIC / ADRIO)'],
inProgress: ['Q.Arb — commenced August 2026'], // [verified 2026-08-26]
goal: 'C.Med-Arb (Chartered Mediator-Arbitrator)',
education: ['JD, Bond University'],
certifications: [
'Kompass Arbitration Certificate Program',
'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. */
memberships: [
'ADR Institute of Canada (ADRIC)',
'ADR Institute of Ontario (ADRIO)',
'Ontario Bar Association — Construction & Infrastructure, ADR, and Civil Litigation sections',
],
} 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;
/** Analytics: privacy-first and cookieless (D15). No GA4, no consent banner. */
export const ANALYTICS = {
provider: 'plausible' as 'plausible' | 'fathom' | null,
domain: 'adr.smlcompany.ca',
} as const;
export const CONTACT = {
email: 'info@smlcompany.ca', // [verified 2026-08-26]
/** No public phone by choice. Render 'By scheduled call' wherever a number
* would go — do not leave the field visually empty. */
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.',
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. */
bookingUrl: null as string | null,
} as const;
/** Portrait assets. Astro derives AVIF/WebP variants from the master at build. */
export const PORTRAIT = {
master: 'src/assets/pouya-lajevardi.jpg', // 1600x1600
og: 'src/assets/og-portrait.jpg', // 1200x630, cropped high
alt: 'Pouya Lajevardi',
} as const;
/** 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.';
/**
* Rate card — AGENTS.md D14, confirmed by Pouya 2026-08-26.
* One rate for all mediation matters. All figures PLUS HST.
* Full research and reasoning: docs/07-fees.md
*/
export const FEES = {
currency: 'CAD',
taxNote: 'All fees are plus HST.',
mediation: {
/** Prep is bundled AND stated on the page — [verified 2026-08-26].
* Do not hide it: at these rates, saying preparation is included is the
* point, not a detail. */
halfDay: { amount: 2000, hours: 3.5, prepIncluded: 2 },
fullDay: { amount: 4000, hours: 7, prepIncluded: 3 },
additionalParty: 500, // each party beyond two
overtimePerHour: 500, // [verified 2026-08-26]
},
arbitration: {
perHour: 500,
hearingDay: 4000,
documentsOnlySimple: 6500, // flat
documentsOnlyComplex: 9500, // flat
// No tribunal-secretary rate — removed by Pouya 2026-08-26.
},
// ENE, settlement counsel, dispute-system design, pre-dispute technical advisory
hourly: 500, // [verified 2026-08-26]
cancellation: [
{ window: 'More than 30 days before', fee: 'No fee. Disbursements only.' },
{ window: '15 to 30 days before', fee: '50% of the booked fee.' },
{ window: 'Fewer than 15 days before', fee: '100% of the booked fee.' },
],
cancellationNotes: [
'Rescheduling at the time of cancellation, with a new date fixed, is not charged.',
'The cancellation fee is waived if the reserved time is filled by another matter of equal or greater value.',
],
terms: [
'Fees are shared equally between the parties unless they agree otherwise in writing.',
'Accounts are payable on rendering. Interest accrues on overdue accounts at 5% per annum.',
'Video and in-person sessions are charged at the same rate.',
],
} 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' },
] as const;
/** Seven items is the ceiling before a nav stops being scannable. */
export const PRIMARY_NAV = [
{ href: '/about/', label: 'About' },
{ href: '/mediation/', label: 'Mediation' },
{ href: '/arbitration/', label: 'Arbitration' },
{ href: '/practice/', label: 'Practice', children: PRACTICE_AREAS },
{ href: '/fees/', label: 'Fees' },
{ href: '/insights/', label: 'Insights' },
{ href: '/contact/', label: 'Contact' },
] as const;
/** Linked contextually rather than from the primary nav. */
export const SECONDARY_NAV = [
{ href: '/process/', label: 'How I work' },
{ href: '/med-arb/', label: 'Med-Arb' },
{ href: '/for-parties/', label: 'For parties' },
] as const;
export const LEGAL_NAV = [
{ href: '/legal/privacy/', label: 'Privacy' },
{ href: '/legal/terms/', label: 'Terms' },
] as const;
+192
View File
@@ -0,0 +1,192 @@
/* ============================================================================
Global base. Spec: docs/02-design-system.md
========================================================================= */
@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. */
@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;
}
@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;
}
@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;
}
@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;
}
/* --- Reset ---------------------------------------------------------------- */
*, *::before, *::after { box-sizing: border-box; }
* { margin: 0; }
html {
-webkit-text-size-adjust: 100%;
scroll-behavior: smooth;
scroll-padding-top: var(--space-8);
}
body {
background: var(--bg);
color: var(--text);
font-family: var(--font-sans);
font-size: var(--text-base);
font-weight: var(--weight-normal);
line-height: var(--leading-body);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
overflow-x: hidden;
min-height: 100vh;
}
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; }
.display {
font-family: var(--font-serif);
font-weight: var(--weight-normal);
line-height: var(--leading-display);
letter-spacing: var(--tracking-display);
}
/* The one flourish the design allows. One italic phrase per headline, max. */
.display .it { font-style: italic; }
.eyebrow {
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-meta);
}
/* An eyebrow is a label, never the page's heading element. */
.eyebrow .dot {
display: inline-block;
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); }
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;
border-radius: var(--radius-sm);
}
:focus:not(:focus-visible) { outline: none; }
.skip-link {
position: absolute;
inset-block-start: var(--space-2);
inset-inline-start: var(--space-2);
z-index: var(--z-skip);
padding: var(--space-3) var(--space-5);
background: var(--accent);
color: var(--text-inverse);
border-radius: var(--radius-md);
transform: translateY(-200%);
transition: transform var(--dur-fast) var(--ease);
}
.skip-link:focus { transform: translateY(0); }
::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); }
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;
}
/* --- 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. */
.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);
}
.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 {
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; }
}
/* --- 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); }
}
+144
View File
@@ -0,0 +1,144 @@
/* ============================================================================
Design tokens — the single source of colour, type, space, and motion.
Spec: docs/02-design-system.md
Never write a raw hex value or a magic number in a component. If a value is
missing here, add it here.
========================================================================= */
:root {
/* --- Colour ------------------------------------------------------------
Palette carried over unchanged from the previous build (AGENTS.md D7).
Contrast ratios measured 2026-08-25 and recorded in docs/02. */
--cream: #faf7f2; /* page background */
--cream-2: #f3ede0; /* alternating section background */
--cream-3: #ebe3d1; /* cards and insets on cream */
--ink: #1a1614; /* body text; dark section backgrounds 16.81:1 */
--ink-soft: #3a322c; /* secondary text 11.75:1 */
--muted: #6e6359; /* metadata — ON CREAM ONLY (3.07:1 on ink) */
--maroon: #5a1a1c; /* primary action, dark panels 12.29:1 */
--maroon-d: #3d1112; /* hover on maroon */
--maroon-l: #7a2a2c; /* links on cream 8.95:1 */
/* GOLD IS NOT A TEXT COLOUR ON CREAM.
--gold on cream measures 2.10:1 and fails AA for body AND large text.
--gold-d measures 3.11:1 — large decorative text only, 24px+.
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 */
--line: rgb(26 22 20 / 0.10);
--line-2: rgb(26 22 20 / 0.06);
--line-dark: rgb(250 247 242 / 0.14);
/* Semantic aliases — prefer these in components over raw palette names. */
--bg: var(--cream);
--bg-alt: var(--cream-2);
--bg-raised: var(--cream-3);
--bg-inverse: var(--ink);
--text: var(--ink);
--text-secondary: var(--ink-soft);
--text-meta: var(--muted);
--text-inverse: var(--cream);
--text-inverse-2: var(--gold-l);
--accent: var(--maroon);
--accent-hover: var(--maroon-d);
--link: var(--maroon-l);
--rule: var(--gold);
--border: var(--line);
--focus-ring: var(--maroon);
/* --- Type -------------------------------------------------------------- */
--font-serif: 'Instrument Serif', 'Cormorant Garamond', Georgia, 'Times New Roman', serif;
--font-sans: 'Geist', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
--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. */
--text-xs: 0.75rem; /* 12 — legal */
--text-sm: 0.875rem; /* 14 — meta */
--text-base: 1rem; /* 16 — body */
--text-lg: clamp(1.0625rem, 0.99rem + 0.32vw, 1.1875rem); /* 17→19 */
--text-xl: clamp(1.25rem, 1.14rem + 0.48vw, 1.5rem); /* 20→24 */
--text-2xl: clamp(1.5rem, 1.31rem + 0.81vw, 1.875rem); /* 24→30 */
--text-3xl: clamp(1.875rem, 1.55rem + 1.37vw, 2.5rem); /* 30→40 */
--text-4xl: clamp(2.25rem, 1.66rem + 2.50vw, 3.5rem); /* 36→56 */
--text-5xl: clamp(2.75rem, 1.75rem + 4.25vw, 4.75rem); /* 44→76 */
--text-6xl: clamp(3.25rem, 1.75rem + 6.35vw, 6rem); /* 52→96 */
--leading-display: 0.98;
--leading-tight: 1.15;
--leading-snug: 1.35;
--leading-body: 1.6;
--leading-relaxed: 1.75;
--tracking-display: -0.02em;
--tracking-tight: -0.01em;
--tracking-normal: 0;
--tracking-wide: 0.06em;
--tracking-eyebrow: 0.18em;
--weight-light: 300;
--weight-normal: 400;
--weight-medium: 500;
--weight-semi: 600;
/* --- Space — 8px base -------------------------------------------------- */
--space-1: 0.25rem; /* 4 */
--space-2: 0.5rem; /* 8 */
--space-3: 0.75rem; /* 12 */
--space-4: 1rem; /* 16 */
--space-5: 1.5rem; /* 24 */
--space-6: 2rem; /* 32 */
--space-7: 3rem; /* 48 */
--space-8: 4rem; /* 64 */
--space-9: 6rem; /* 96 */
--space-10: 8rem; /* 128 */
--space-11: 10rem; /* 160 */
--section-y: clamp(var(--space-9), 6vw + 2rem, var(--space-11));
/* --- Layout ------------------------------------------------------------ */
--width-content: 80rem; /* 1280 */
--width-wide: 90rem; /* 1440 */
--width-prose: 68ch; /* reading measure — never exceed for body copy */
--gutter: var(--space-5);
--gutter-lg: var(--space-7);
/* --- Radius, elevation, motion ----------------------------------------- */
--radius-sm: 4px;
--radius-md: 8px;
--radius-lg: 16px;
--radius-full: 999px;
--shadow-sm: 0 1px 2px rgb(26 22 20 / 0.04);
--shadow-md: 0 4px 16px rgb(26 22 20 / 0.06);
--shadow-lg: 0 12px 40px rgb(26 22 20 / 0.10);
--ease: cubic-bezier(0.2, 0.7, 0.2, 1);
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
--dur-fast: 150ms;
--dur-hover: 250ms;
--dur-reveal: 600ms;
--z-base: 0;
--z-sticky: 100;
--z-header: 200;
--z-overlay: 300;
--z-skip: 400;
}
@media (min-width: 48rem) {
:root { --gutter: var(--gutter-lg); }
}
/* No dark mode by design (docs/02). A single committed light identity is the
right call for a legal practice and halves the ways contrast can break. */
+16
View File
@@ -0,0 +1,16 @@
{
"extends": "astro/tsconfigs/strict",
"compilerOptions": {
"strictNullChecks": true,
"allowJs": true,
"baseUrl": ".",
"paths": {
"@components/*": ["src/components/*"],
"@layouts/*": ["src/layouts/*"],
"@styles/*": ["src/styles/*"],
"@data/*": ["src/data/*"]
}
},
"include": [".astro/types.d.ts", "**/*"],
"exclude": ["dist", "node_modules"]
}