commit 19f72266610b806b566278072694b81b4201cc3d Author: Pouya Lajevardi Date: Wed Aug 26 08:51:16 2026 -0400 chore: project scaffold, specs, and working record diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..acc41e0 --- /dev/null +++ b/.env.example @@ -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= diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml new file mode 100644 index 0000000..c83f111 --- /dev/null +++ b/.gitea/workflows/deploy.yml @@ -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}" diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..b167d10 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -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" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..677275b --- /dev/null +++ b/.gitignore @@ -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 diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..2bd5a0a --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +22 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..3e86f6a --- /dev/null +++ b/AGENTS.md @@ -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 `` has **no ``**, 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]` +- `` 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 3–5 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 12–18. `[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 3–5 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 12–18 | 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 Q15–Q17: 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 (R1–R5) 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, D1–D12, 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; 3–5 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. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..92594ee --- /dev/null +++ b/CLAUDE.md @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..2289fa2 --- /dev/null +++ b/README.md @@ -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`. diff --git a/astro.config.mjs b/astro.config.mjs new file mode 100644 index 0000000..fe60d7d --- /dev/null +++ b/astro.config.mjs @@ -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' }, +}); diff --git a/docs/01-architecture.md b/docs/01-architecture.md new file mode 100644 index 0000000..b117b16 --- /dev/null +++ b/docs/01-architecture.md @@ -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 12–18, 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**, 400–600 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, proponent–municipality +disputes, IESO market participation, data-centre connection allocation. Brief +§III.2 frames this as a 24–36 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 (1–7) · pre-session exchange (7–21) · the session (21–30) · 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 mediator–party +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`) diff --git a/docs/02-design-system.md b/docs/02-design-system.md new file mode 100644 index 0000000..983f1ae --- /dev/null +++ b/docs/02-design-system.md @@ -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, 11–12 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. diff --git a/docs/03-content-spec.md b/docs/03-content-spec.md new file mode 100644 index 0000000..2daea5c --- /dev/null +++ b/docs/03-content-spec.md @@ -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 +400–600 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 §III–IV — 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,200–1,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 + mediator–party relationship +- [ ] Any comparative claim is factual and verifiable diff --git a/docs/04-seo-spec.md b/docs/04-seo-spec.md new file mode 100644 index 0000000..6dc8b78 --- /dev/null +++ b/docs/04-seo-spec.md @@ -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 50–60 chars, unique. Pattern: "<Page> · Pouya Lajevardi" + Home: "Pouya Lajevardi · Mediation & Arbitration · Toronto" +description 140–160 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 diff --git a/docs/05-backend-spec.md b/docs/05-backend-spec.md new file mode 100644 index 0000000..a141b4d --- /dev/null +++ b/docs/05-backend-spec.md @@ -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 8–10. +**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 mediator–party 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 30–45 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 mediator–party +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 diff --git a/docs/06-deployment.md b/docs/06-deployment.md new file mode 100644 index 0000000..8fb9e6b --- /dev/null +++ b/docs/06-deployment.md @@ -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 diff --git a/docs/07-fees.md b/docs/07-fees.md new file mode 100644 index 0000000..b0141fb --- /dev/null +++ b/docs/07-fees.md @@ -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) diff --git a/package.json b/package.json new file mode 100644 index 0000000..c93e1fd --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/public/robots.txt b/public/robots.txt new file mode 100644 index 0000000..a1e8421 --- /dev/null +++ b/public/robots.txt @@ -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 diff --git a/scripts/aws-discover.sh b/scripts/aws-discover.sh new file mode 100755 index 0000000..f8cf65c --- /dev/null +++ b/scripts/aws-discover.sh @@ -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 diff --git a/src/assets/og-portrait.jpg b/src/assets/og-portrait.jpg new file mode 100644 index 0000000..3edef4d Binary files /dev/null and b/src/assets/og-portrait.jpg differ diff --git a/src/assets/pouya-lajevardi.jpg b/src/assets/pouya-lajevardi.jpg new file mode 100644 index 0000000..470a7a2 Binary files /dev/null and b/src/assets/pouya-lajevardi.jpg differ diff --git a/src/content/config.ts b/src/content/config.ts new file mode 100644 index 0000000..eb4629a --- /dev/null +++ b/src/content/config.ts @@ -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 }; diff --git a/src/data/site.ts b/src/data/site.ts new file mode 100644 index 0000000..7bd9330 --- /dev/null +++ b/src/data/site.ts @@ -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 mediator–party 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; diff --git a/src/styles/global.css b/src/styles/global.css new file mode 100644 index 0000000..15a9b2c --- /dev/null +++ b/src/styles/global.css @@ -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); } +} diff --git a/src/styles/tokens.css b/src/styles/tokens.css new file mode 100644 index 0000000..f712da9 --- /dev/null +++ b/src/styles/tokens.css @@ -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. */ diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..06fceba --- /dev/null +++ b/tsconfig.json @@ -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"] +}