Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c576b9a85f | ||
|
|
165d259f5c | ||
|
|
8a2f513577 | ||
|
|
364b09632e | ||
|
|
8134709548 | ||
|
|
8f1df2c27c | ||
|
|
7514a49803 | ||
|
|
0d8b63380a | ||
|
|
2b6176e4d7 | ||
|
|
6bf1167624 |
@@ -6,7 +6,7 @@ model: opus
|
|||||||
---
|
---
|
||||||
|
|
||||||
You are reviewing a change to `adr.smlcompany.ca` — the public marketing site of
|
You are reviewing a change to `adr.smlcompany.ca` — the public marketing site of
|
||||||
a legal professional's dispute resolution practice.
|
a dispute resolution practice.
|
||||||
|
|
||||||
**Your job is to find what is wrong with it.** You are not here to confirm that
|
**Your job is to find what is wrong with it.** You are not here to confirm that
|
||||||
the work is good. An approving review that misses a real defect is a failure; a
|
the work is good. An approving review that misses a real defect is a failure; a
|
||||||
@@ -54,10 +54,16 @@ full content with JavaScript disabled.** Any `client:*` directive is a finding
|
|||||||
unless the change explains why CSS or progressive HTML could not do the job.
|
unless the change explains why CSS or progressive HTML could not do the job.
|
||||||
|
|
||||||
**4. Performance.** Budgets in `docs/04-seo-spec.md`: Lighthouse ≥ 95 mobile on
|
**4. Performance.** Budgets in `docs/04-seo-spec.md`: Lighthouse ≥ 95 mobile on
|
||||||
all four categories, under 100 KB JS per route, LCP under 2.0 s. Check for
|
all four categories, under 100 KB JS per route, LCP under 2.0 s.
|
||||||
base64-inlined images, images without explicit dimensions, runtime font requests,
|
|
||||||
and third-party scripts. The old build inlined ~1 MB of logo PNGs — watch for
|
**Lighthouse itself cannot be run right now** — `@lhci/cli` was removed on
|
||||||
regressions of that shape.
|
2026-08-26 and returns at build step 7 (`AGENTS.md` §7, R11). So do not report
|
||||||
|
"Lighthouse not run" as a finding; it is a known, recorded gap. Review
|
||||||
|
everything that *would* move those numbers by reading the artefact instead:
|
||||||
|
base64-inlined images, images without explicit dimensions, runtime font
|
||||||
|
requests, and third-party scripts. The old build is *said* to have inlined ~1 MB
|
||||||
|
of logo PNGs — `AGENTS.md` Q34 is open against that figure, so watch for
|
||||||
|
regressions of that shape without repeating the number as fact.
|
||||||
|
|
||||||
**5. Security and data handling.** Any hardcoded endpoint, key, or credential is
|
**5. Security and data handling.** Any hardcoded endpoint, key, or credential is
|
||||||
a finding. Check CSP compatibility, that form input is validated server-side and
|
a finding. Check CSP compatibility, that form input is validated server-side and
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ tools: Read, Grep, Glob
|
|||||||
model: opus
|
model: opus
|
||||||
---
|
---
|
||||||
|
|
||||||
You audit public copy for a **licensed legal professional's** marketing site.
|
You audit public copy for the marketing site of a dispute resolution practice.
|
||||||
|
The site it replaces carried fabricated credentials.
|
||||||
|
|
||||||
The site this replaces contained a fictitious founder, invented matter values
|
The site this replaces contained a fictitious founder, invented matter values
|
||||||
("420+ matters", "$3.8B resolved", "93% settled"), fabricated office locations,
|
("420+ matters", "$3.8B resolved", "93% settled"), fabricated office locations,
|
||||||
@@ -52,9 +53,30 @@ slot.
|
|||||||
nearly complete. The Arbitration page must state plainly what is available now
|
nearly complete. The Arbitration page must state plainly what is available now
|
||||||
versus what follows designation.
|
versus what follows designation.
|
||||||
|
|
||||||
**Memberships.** ADRIC, ADRIO, OBA sections only. **OCNI is not current** — flag
|
**Memberships.** **Do not hold a list here. Read the memberships row in
|
||||||
it. **The Law Society must not be listed** — listing it implies licensure, which
|
`AGENTS.md` §4 at audit time and use what it says.** This paragraph used to
|
||||||
D13 bars. Flag any addition of either, however well-intentioned.
|
enumerate "ADRIC, ADRIO, OBA sections only"; the Canadian Tax Foundation was
|
||||||
|
verified into §4 on 2026-08-26 and this line did not move, so for one session
|
||||||
|
the auditor's own brief contradicted the register — it would have flagged a
|
||||||
|
verified membership as unverified, and would not have noticed CTF being dropped.
|
||||||
|
That is the second time a stale claim has been found inside this file, which is
|
||||||
|
the definition of the agent whose job is to catch exactly that (`CLAUDE.md`
|
||||||
|
records the first). A copy of a fact is a fact that will go stale, and this one
|
||||||
|
goes stale where nobody re-reads it.
|
||||||
|
|
||||||
|
**OCNI is not current** — flag it. **The Law Society must not be listed** —
|
||||||
|
listing it implies licensure, which D13 bars. Flag any addition of either,
|
||||||
|
however well-intentioned.
|
||||||
|
|
||||||
|
**The OBA sections and the Canadian Tax Foundation renew yearly (§12 R10)** — and
|
||||||
|
read that scope, because this sentence carried the widened form *"Memberships
|
||||||
|
renew yearly"* until 2026-08-28. §4 records the period for **those four lines
|
||||||
|
only**; it says nothing about ADRIC's or ADRIO's. **You found this yourself**, in
|
||||||
|
your own brief, on the pass where you found the same widening in three source
|
||||||
|
files — the third stale claim located inside this file, which is why the
|
||||||
|
instruction below is the one that matters: a §4 row can be verified and still be
|
||||||
|
out of date, so **read the §4 row at audit time and check the stamp**, never this
|
||||||
|
gloss.
|
||||||
|
|
||||||
**Testimonials, endorsements, third-party quotes.** None exist. Any is a
|
**Testimonials, endorsements, third-party quotes.** None exist. Any is a
|
||||||
fabrication.
|
fabrication.
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ does not apply, say which and why before moving on.
|
|||||||
Reminders** and surface anything live to Pouya before you start.
|
Reminders** and surface anything live to Pouya before you start.
|
||||||
2. Read the specs in `docs/` that bear on this task.
|
2. Read the specs in `docs/` that bear on this task.
|
||||||
3. Restate the task in your own words, and name:
|
3. Restate the task in your own words, and name:
|
||||||
- which locked decisions (D1–D16) it touches
|
- which locked decisions (D1–D18) it touches
|
||||||
- which specs govern it
|
- which specs govern it
|
||||||
- which facts it needs from the §4 Verified register
|
- which facts it needs from the §4 Verified register
|
||||||
4. **Stop and ask if you find a conflict** — between the task and a locked
|
4. **Stop and ask if you find a conflict** — between the task and a locked
|
||||||
@@ -61,6 +61,14 @@ so a later reader can see the judgement was made rather than missed.
|
|||||||
If you fix anything material, **re-run Phase 3 on the fix.** A patch written
|
If you fix anything material, **re-run Phase 3 on the fix.** A patch written
|
||||||
under review pressure is exactly where the second defect lives.
|
under review pressure is exactly where the second defect lives.
|
||||||
|
|
||||||
|
> **This is not ceremony, and here is the measurement.** On the Astro 5 → 7
|
||||||
|
> upgrade (`AGENTS.md` entry (t), 2026-08-26) the second review pass returned
|
||||||
|
> six findings. **Four of the six were defects in the first round's own fixes** —
|
||||||
|
> including a date validator whose replacement silently rolled `2026-02-30`
|
||||||
|
> forward to `2026-03-02`, and a title rule whose fix rejected all five planned
|
||||||
|
> launch articles. None of the four existed before the review started. Skip the
|
||||||
|
> re-review and you ship the repair, not the bug.
|
||||||
|
|
||||||
## Phase 5 — Verify — run it, do not assert it
|
## Phase 5 — Verify — run it, do not assert it
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -73,9 +81,22 @@ Then, as applicable to what changed:
|
|||||||
- Serve `dist/` and confirm the page **renders its full content with JavaScript
|
- Serve `dist/` and confirm the page **renders its full content with JavaScript
|
||||||
disabled** — the failure this whole project exists to fix
|
disabled** — the failure this whole project exists to fix
|
||||||
- `curl` the built HTML and confirm real content, not a shell
|
- `curl` the built HTML and confirm real content, not a shell
|
||||||
- Lighthouse mobile ≥ 95 on all four categories
|
- ~~Lighthouse mobile ≥ 95 on all four categories~~ — **UNAVAILABLE.**
|
||||||
|
`@lhci/cli` was removed on 2026-08-26 and is not re-added until build step 7
|
||||||
|
(`AGENTS.md` R11, §7). Report it as *not run, tool unavailable*. Do not
|
||||||
|
substitute a manual DevTools run and describe it as the same check
|
||||||
- Every internal link resolves
|
- Every internal link resolves
|
||||||
- Metadata present: unique title, description, canonical, OG, JSON-LD
|
- Metadata present: unique title, description, canonical, OG, JSON-LD
|
||||||
|
- **No scroll-driven animation was eaten by the minifier.** This must return
|
||||||
|
nothing:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
grep -rE 'animation:[^;}]*(scroll\(\)|view\(\))' dist --include='*.css'
|
||||||
|
```
|
||||||
|
|
||||||
|
A hit means an `animation` shorthand was written beside `animation-timeline`
|
||||||
|
and Lightning CSS folded them into an invalid declaration, which the parser
|
||||||
|
then discards. The effect works in `npm run dev` and is dead in the build.
|
||||||
|
|
||||||
**Never report a check as passing that you did not run.** "Should pass" is not a
|
**Never report a check as passing that you did not run.** "Should pass" is not a
|
||||||
result. If you could not run something, say which and why.
|
result. If you could not run something, say which and why.
|
||||||
@@ -87,5 +108,10 @@ why, and any decision or plan — including declined findings and anything
|
|||||||
deferred. Update Current Truth in place where the change made a section stale.
|
deferred. Update Current Truth in place where the change made a section stale.
|
||||||
Re-stamp facts you re-checked with today's date.
|
Re-stamp facts you re-checked with today's date.
|
||||||
|
|
||||||
|
**If the entry claims a change was applied across files, cite the command and
|
||||||
|
paste its output.** Write that claim only after reading the output. Recall is
|
||||||
|
not evidence — three entries on this project asserted a completed sweep and
|
||||||
|
instances survived all three.
|
||||||
|
|
||||||
Then report to Pouya: what shipped, what the review found, what you declined and
|
Then report to Pouya: what shipped, what the review found, what you declined and
|
||||||
why, and what remains open.
|
why, and what remains open.
|
||||||
|
|||||||
@@ -24,6 +24,12 @@ reasoning.
|
|||||||
**Never edit a past entry.** If something earlier was wrong, correct it in
|
**Never edit a past entry.** If something earlier was wrong, correct it in
|
||||||
today's entry and leave the original as written.
|
today's entry and leave the original as written.
|
||||||
|
|
||||||
|
**Any claim that a change was applied across files must cite the command and
|
||||||
|
be written only after reading its output.** Paste the `grep`. Recall is not
|
||||||
|
evidence — three entries on this project asserted a completed sweep and
|
||||||
|
instances survived all three, one of them inside the definition of the agent
|
||||||
|
whose job is to catch it.
|
||||||
|
|
||||||
4. **Check §12 Standing Reminders.** Is anything now due? Should something new
|
4. **Check §12 Standing Reminders.** Is anything now due? Should something new
|
||||||
be added — a decision Pouya parked, or one you made on his behalf that he has
|
be added — a decision Pouya parked, or one you made on his behalf that he has
|
||||||
not yet ratified?
|
not yet ratified?
|
||||||
|
|||||||
@@ -4,10 +4,6 @@
|
|||||||
"showThinkingSummaries": true,
|
"showThinkingSummaries": true,
|
||||||
"effortLevel": "high",
|
"effortLevel": "high",
|
||||||
"permissions": {
|
"permissions": {
|
||||||
"deny": [
|
"deny": ["Read(./.env)", "Read(./.env.*)", "Read(./aws-inventory.txt)"]
|
||||||
"Read(./.env)",
|
|
||||||
"Read(./.env.*)",
|
|
||||||
"Read(./aws-inventory.txt)"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,18 @@
|
|||||||
# Gitea Actions — the live pipeline for this repository.
|
# Gitea Actions — the live pipeline for this repository.
|
||||||
#
|
#
|
||||||
# Gitea Actions speaks GitHub Actions syntax, so this is a near-direct port of
|
# 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
|
# docs/reference/github-actions-oidc.yml.example (kept as the OIDC reference in
|
||||||
# moves to GitHub or GitLab).
|
# case the repo ever moves to GitHub; it lives under docs/ rather than
|
||||||
|
# .github/workflows/ so Gitea can never fall back to it).
|
||||||
#
|
#
|
||||||
# ONE REAL DIFFERENCE: Gitea is not an AWS OIDC provider, so there is no role to
|
# 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
|
# assume. Deploys are designed to authenticate with a SCOPED IAM USER whose key
|
||||||
# this repository's Gitea secrets. See docs/06-deployment.md for the exact IAM
|
# lives only in this repository's Gitea secrets. Whether that user and key have
|
||||||
# policy — it grants four actions on one bucket and one distribution, nothing
|
# actually been created is AGENTS.md Q22 — unanswered as of 2026-08-26.
|
||||||
# more. Rotate the key quarterly; OIDC would have made that unnecessary.
|
#
|
||||||
|
# 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.
|
# Requires a Gitea Actions runner registered to this repo or its organisation.
|
||||||
|
|
||||||
@@ -33,8 +37,49 @@ jobs:
|
|||||||
AWS_DEFAULT_REGION: ${{ vars.AWS_REGION }}
|
AWS_DEFAULT_REGION: ${{ vars.AWS_REGION }}
|
||||||
S3_BUCKET: ${{ vars.S3_BUCKET }}
|
S3_BUCKET: ${{ vars.S3_BUCKET }}
|
||||||
CLOUDFRONT_DISTRIBUTION_ID: ${{ vars.CLOUDFRONT_DISTRIBUTION_ID }}
|
CLOUDFRONT_DISTRIBUTION_ID: ${{ vars.CLOUDFRONT_DISTRIBUTION_ID }}
|
||||||
|
# Job-level so the guard can see it. An empty INTAKE_ENDPOINT does not
|
||||||
|
# fail the build - it ships a live contact form posting to nothing.
|
||||||
|
INTAKE_ENDPOINT: ${{ vars.INTAKE_ENDPOINT }}
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
|
# Runs first, before checkout and before any AWS call, so a
|
||||||
|
# misconfiguration costs one second instead of a full build.
|
||||||
|
#
|
||||||
|
# Repository variables live at Settings -> Actions -> Variables. Gitea
|
||||||
|
# only added the `vars` context in 1.21; this instance reports 1.27.2
|
||||||
|
# [verified 2026-08-26 - /api/v1/version, AGENTS.md §7], so the guard is
|
||||||
|
# belt-and-braces rather than load-bearing. It stays because an unset or
|
||||||
|
# mistyped variable degrades the sync target to "s3://" and the run dies
|
||||||
|
# obscurely somewhere in the middle, whatever the Gitea version.
|
||||||
|
#
|
||||||
|
# Covers the deploy-target variables, the intake endpoint, AND the two
|
||||||
|
# secrets. The secrets matter most: AGENTS.md Q22 records that nobody has
|
||||||
|
# confirmed the IAM user or its key exists, so an unset key is the single
|
||||||
|
# likeliest first-run failure - and without this it would burn a whole
|
||||||
|
# build before dying at `aws sts get-caller-identity`.
|
||||||
|
#
|
||||||
|
# Only emptiness is ever tested. No value is echoed, so nothing here can
|
||||||
|
# leak a secret into the run log.
|
||||||
|
- name: Guard - required variables and secrets are set
|
||||||
|
run: |
|
||||||
|
missing=''
|
||||||
|
[ -n "$AWS_DEFAULT_REGION" ] || missing="$missing AWS_REGION(var)"
|
||||||
|
[ -n "$S3_BUCKET" ] || missing="$missing S3_BUCKET(var)"
|
||||||
|
[ -n "$CLOUDFRONT_DISTRIBUTION_ID" ] || missing="$missing CLOUDFRONT_DISTRIBUTION_ID(var)"
|
||||||
|
[ -n "$INTAKE_ENDPOINT" ] || missing="$missing INTAKE_ENDPOINT(var)"
|
||||||
|
[ -n "$AWS_ACCESS_KEY_ID" ] || missing="$missing AWS_ACCESS_KEY_ID(secret)"
|
||||||
|
[ -n "$AWS_SECRET_ACCESS_KEY" ] || missing="$missing AWS_SECRET_ACCESS_KEY(secret)"
|
||||||
|
if [ -n "$missing" ]; then
|
||||||
|
echo "Not set:$missing"
|
||||||
|
echo
|
||||||
|
echo 'Variables: Settings -> Actions -> Variables.'
|
||||||
|
echo 'Secrets: Settings -> Actions -> Secrets.'
|
||||||
|
echo 'See docs/06-deployment.md.'
|
||||||
|
echo 'If the variables ARE set, this Gitea predates the vars context (1.21+).'
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo 'All required variables and secrets are set.'
|
||||||
|
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
@@ -52,6 +97,8 @@ jobs:
|
|||||||
run: npm run build
|
run: npm run build
|
||||||
env:
|
env:
|
||||||
PUBLIC_SITE_URL: https://adr.smlcompany.ca
|
PUBLIC_SITE_URL: https://adr.smlcompany.ca
|
||||||
|
# vars, not env — Gitea expression-context support is the very thing
|
||||||
|
# the guard above exists to not depend on.
|
||||||
PUBLIC_INTAKE_ENDPOINT: ${{ vars.INTAKE_ENDPOINT }}
|
PUBLIC_INTAKE_ENDPOINT: ${{ vars.INTAKE_ENDPOINT }}
|
||||||
PUBLIC_BOOKING_URL: ${{ vars.BOOKING_URL }}
|
PUBLIC_BOOKING_URL: ${{ vars.BOOKING_URL }}
|
||||||
|
|
||||||
@@ -68,8 +115,8 @@ jobs:
|
|||||||
- name: Verify credentials
|
- name: Verify credentials
|
||||||
run: aws sts get-caller-identity
|
run: aws sts get-caller-identity
|
||||||
|
|
||||||
# Two passes: hashed immutable assets first, HTML last. A visitor must
|
# Three passes: hashed immutable assets first, then images, HTML last.
|
||||||
# never fetch a new page whose assets have not landed yet.
|
# A visitor must never fetch a new page whose assets have not landed yet.
|
||||||
- name: Sync hashed assets
|
- name: Sync hashed assets
|
||||||
run: |
|
run: |
|
||||||
aws s3 sync ./dist "s3://${S3_BUCKET}" \
|
aws s3 sync ./dist "s3://${S3_BUCKET}" \
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
dist/
|
||||||
|
node_modules/
|
||||||
|
.astro/
|
||||||
|
package-lock.json
|
||||||
|
|
||||||
|
# Frozen historical record — reformatting would obscure what it originally said.
|
||||||
|
docs/reference/
|
||||||
|
|
||||||
|
# Markdown here is hand-maintained to an 80-column convention, and AGENTS.md is
|
||||||
|
# an append-only history whose tables Prettier would rewrite wholesale (an
|
||||||
|
# 892-line diff for no reading benefit). Prose wrapping is checked by eye.
|
||||||
|
*.md
|
||||||
|
|
||||||
|
# tokens.css aligns every custom property's comment into a column so the
|
||||||
|
# measured contrast ratios can be scanned down the page — see docs/02. Prettier
|
||||||
|
# collapses that alignment, which is the one thing the file is for.
|
||||||
|
src/styles/tokens.css
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"printWidth": 80,
|
||||||
|
"singleQuote": true,
|
||||||
|
"trailingComma": "all",
|
||||||
|
"plugins": ["prettier-plugin-astro"],
|
||||||
|
"overrides": [
|
||||||
|
{
|
||||||
|
"files": "*.astro",
|
||||||
|
"options": { "parser": "astro" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"files": "*.md",
|
||||||
|
"options": { "proseWrap": "preserve" }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -22,9 +22,14 @@
|
|||||||
|
|
||||||
## The one rule that matters more than the code
|
## The one rule that matters more than the code
|
||||||
|
|
||||||
Pouya is a licensed legal professional. **No factual claim about him, his
|
This is Pouya's public marketing surface, and the site it replaces carried
|
||||||
credentials, his experience, or his practice may appear on a public page unless
|
fabricated credentials. **No factual claim about him, his credentials, his
|
||||||
it is in the Verified table in `AGENTS.md` §4.**
|
experience, or his practice may appear on a public page unless it is in the
|
||||||
|
Verified table in `AGENTS.md` §4.**
|
||||||
|
|
||||||
|
(§4 does not verify licensure either way — so do not describe him as
|
||||||
|
"licensed", or as a "legal professional", anywhere, this file included. State
|
||||||
|
the reason for the rule, not a credential the register cannot vouch for.)
|
||||||
|
|
||||||
If a page needs a fact you do not have:
|
If a page needs a fact you do not have:
|
||||||
|
|
||||||
@@ -78,10 +83,12 @@ view from the artefact — that independence *is* the mechanism.
|
|||||||
|
|
||||||
**The reviewers are instructed to treat uncertainty as a defect.** They will
|
**The reviewers are instructed to treat uncertainty as a defect.** They will
|
||||||
sometimes be wrong, and that is the intended trade. Explaining why a finding is
|
sometimes be wrong, and that is the intended trade. Explaining why a finding is
|
||||||
mistaken costs minutes; a missed defect on a licensed professional's public
|
mistaken costs minutes; a missed defect on this project's public marketing
|
||||||
marketing page costs considerably more. Do not read a finding as an accusation,
|
pages costs considerably more — the site this replaces carried fabricated
|
||||||
and do not argue a reviewer down — either fix it, or record the reason you
|
credentials, and that is the standard being corrected. Do not read a finding as
|
||||||
declined it so a later reader can see the judgement was made rather than missed.
|
an accusation, and do not argue a reviewer down — either fix it, or record the
|
||||||
|
reason you declined it so a later reader can see the judgement was made rather
|
||||||
|
than missed.
|
||||||
|
|
||||||
**Two reviewers, because they catch different things.** `adversarial-reviewer`
|
**Two reviewers, because they catch different things.** `adversarial-reviewer`
|
||||||
reads the code. `claims-auditor` reads the copy against the §4 register and knows
|
reads the code. `claims-auditor` reads the copy against the §4 register and knows
|
||||||
@@ -98,6 +105,8 @@ npm run build # static build to ./dist
|
|||||||
npm run preview # serve ./dist locally
|
npm run preview # serve ./dist locally
|
||||||
npm run check # astro check — type and template errors
|
npm run check # astro check — type and template errors
|
||||||
npm run lint # eslint + prettier check
|
npm run lint # eslint + prettier check
|
||||||
|
npm run format # prettier — rewrite files in place
|
||||||
|
npm run deploy # build + deploy from this machine (see docs/06)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Where things live
|
## Where things live
|
||||||
@@ -110,22 +119,24 @@ docs/ the specs you build from
|
|||||||
03-content-spec.md voice, copy rules, per-page copy deck
|
03-content-spec.md voice, copy rules, per-page copy deck
|
||||||
04-seo-spec.md metadata, structured data, sitemap, crawlability
|
04-seo-spec.md metadata, structured data, sitemap, crawlability
|
||||||
05-backend-spec.md intake form, Lambda/DynamoDB/SES, booking, PIPEDA
|
05-backend-spec.md intake form, Lambda/DynamoDB/SES, booking, PIPEDA
|
||||||
06-deployment.md S3/CloudFront, GitHub Actions OIDC, cutover checklist
|
06-deployment.md S3/CloudFront, Gitea Actions, IAM, cutover checklist
|
||||||
src/
|
src/
|
||||||
|
content.config.ts content collections — Content Layer API, NOT content/config.ts
|
||||||
styles/tokens.css design tokens — the single source of colour and scale
|
styles/tokens.css design tokens — the single source of colour and scale
|
||||||
styles/global.css reset, base type, utilities
|
styles/global.css reset, base type, utilities
|
||||||
layouts/ page shells
|
layouts/ page shells
|
||||||
components/ UI components
|
components/ UI components
|
||||||
pages/ routes (file-based)
|
pages/ routes (file-based)
|
||||||
content/ content collections; Insights MDX lives here
|
content/insights/ Insights MDX only; the config sits above, not in here
|
||||||
data/site.ts site-wide constants, nav, contact details
|
data/site.ts site-wide constants, nav, contact details
|
||||||
public/ static assets served as-is
|
public/ static assets served as-is
|
||||||
```
|
```
|
||||||
|
|
||||||
## Conventions
|
## Conventions
|
||||||
|
|
||||||
**Framework.** Astro, `output: 'static'`. Never introduce a server runtime
|
**Framework.** Astro **7.x**, `output: 'static'` (D1 as amended). Never introduce
|
||||||
without a Change Log entry recording why.
|
a server runtime without a Change Log entry recording why. The major is pinned
|
||||||
|
deliberately — check `npm view astro version` before changing it.
|
||||||
|
|
||||||
**JavaScript.** Default to zero. Reach for an Astro island only when a feature
|
**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,
|
genuinely cannot be CSS or progressive HTML. If you add a `client:*` directive,
|
||||||
@@ -141,9 +152,57 @@ interactive elements reachable by keyboard, `prefers-reduced-motion` honoured on
|
|||||||
every animation. Gold `#c9a876` never sits on cream — it fails contrast at
|
every animation. Gold `#c9a876` never sits on cream — it fails contrast at
|
||||||
2.10:1. See `docs/02-design-system.md`.
|
2.10:1. See `docs/02-design-system.md`.
|
||||||
|
|
||||||
|
**Anything a spec makes a claim about must be reachable from the repository.**
|
||||||
|
If the artefact lives only in Drive, in a console, or on someone's laptop, no
|
||||||
|
reviewer can compare the claim against it and the claim is **unverifiable by
|
||||||
|
construction** — not merely unverified. Commit the artefact, or commit a faithful
|
||||||
|
extract with its provenance and the command that produced it.
|
||||||
|
|
||||||
|
This has cost twice. `AGENTS.md` Q24 was the AWS hosting guide, the only record
|
||||||
|
of how the infrastructure was hand-built, living outside the repo. Q32 was the
|
||||||
|
infinity mark: it was traced from the old site's *loading placeholder*, the
|
||||||
|
source comment said so in as many words — and **two adversarial review passes
|
||||||
|
still could not catch that the shape was wrong**, because the real artwork was
|
||||||
|
not in the repo to compare against. Stating a doubt is not enough when the thing
|
||||||
|
that would resolve it is unreachable. Tracked as R14.
|
||||||
|
|
||||||
|
**A command that did not run is not evidence of absence.** Check that a tool
|
||||||
|
exists before trusting its silence, and read exit status, not just stdout. This
|
||||||
|
project ran `timeout 60 ls "$DRIVE"` four times, got empty output each time, and
|
||||||
|
reported the brand assets unreachable — `timeout` is not installed on macOS, so
|
||||||
|
the command had never executed and the directory was fully readable all along.
|
||||||
|
Empty output from a command that failed to start looks exactly like empty output
|
||||||
|
from a command that found nothing. Same family as *a sweep is a command, not a
|
||||||
|
claim*: the claim must rest on output you actually read, from a command that
|
||||||
|
actually ran.
|
||||||
|
|
||||||
|
**A parent cannot style a child component's root element.** Astro does not pass
|
||||||
|
a parent's scope attribute down, so `<Button class="header-cta" />` compiles the
|
||||||
|
parent's rule to `.header-cta[data-astro-cid-<parent>]` while the rendered `<a>`
|
||||||
|
carries only `<Button>`'s own cid. **The rule silently never matches** — no
|
||||||
|
error, no warning, and the CSS looks correct in the source. Wrap the child in an
|
||||||
|
element the parent owns (`<div class="header-cta"><Button …/></div>`), or reach
|
||||||
|
it deliberately with `:global()` from a parent-scoped ancestor. Inherited
|
||||||
|
properties (`white-space`, `color`, `font-*`) do cross the boundary and are the
|
||||||
|
exception. This cost a header CTA that was documented as hidden on mobile,
|
||||||
|
was not hidden, and sat 75 px short of the right edge on desktop — both found by
|
||||||
|
measurement, neither by reading. It will recur with `PracticeCard`,
|
||||||
|
`ArticleCard`, and `Pill`.
|
||||||
|
|
||||||
|
**Never write the `animation` shorthand beside `animation-timeline`.** Longhands
|
||||||
|
only — `animation-name`, `animation-duration`, `animation-timing-function`,
|
||||||
|
`animation-fill-mode`, then `animation-timeline` and `animation-range`.
|
||||||
|
`scroll()` and `view()` are not legal components of the shorthand, and Lightning
|
||||||
|
CSS folds the two declarations together on minify into something invalid, which
|
||||||
|
is then discarded whole. **It works in `npm run dev` and is dead in
|
||||||
|
`npm run build`** — the worst shape a defect can take. It happened twice in one
|
||||||
|
session, the second time inside the fix for the first. `/build` Phase 5 greps
|
||||||
|
`dist` for it; do not remove that check.
|
||||||
|
|
||||||
**Images.** Astro `<Image>` with explicit width and height. AVIF/WebP with
|
**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
|
fallback. Never base64-inline an image into HTML — the old site did this with
|
||||||
~1 MB of logo PNGs.
|
~1 MB of logo PNGs — a figure `AGENTS.md` Q34 is now open against, so treat the
|
||||||
|
rule as standing on its own merits rather than on that number.
|
||||||
|
|
||||||
**Fonts.** Self-hosted, subset, `font-display: swap`, preloaded. No Google Fonts
|
**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
|
request at runtime — it costs a round trip and adds a third-party call to a
|
||||||
@@ -153,20 +212,106 @@ page that collects legal inquiries.
|
|||||||
URL, Open Graph and Twitter card tags, and appropriate JSON-LD. See
|
URL, Open Graph and Twitter card tags, and appropriate JSON-LD. See
|
||||||
`docs/04-seo-spec.md`. A page without these is not finished.
|
`docs/04-seo-spec.md`. A page without these is not finished.
|
||||||
|
|
||||||
|
**A version pin is verified against the registry, never recalled.** Before you
|
||||||
|
write or change any dependency version, run `npm view <pkg> version` and pin
|
||||||
|
against what it returns. One second of checking; a stale pin costs a migration.
|
||||||
|
This rule exists because `astro: "^5.0.0"` was written from memory and was
|
||||||
|
**two majors stale on the day it was written** — which meant shipping a
|
||||||
|
framework carrying high-severity XSS advisories. The same check applies to
|
||||||
|
every pin in `package.json`, not just the framework.
|
||||||
|
|
||||||
|
Re-check currency at each phase boundary in the build order (`AGENTS.md` R11),
|
||||||
|
not only when something breaks.
|
||||||
|
|
||||||
|
**`AGENTS.md` §7 is the single source of truth for operational facts.** Resource
|
||||||
|
IDs, regions, DNS records, credential state, service status — these live in §7
|
||||||
|
and nowhere else. Specs in `docs/` **cite** §7; they do not restate it. Write
|
||||||
|
"the region `AGENTS.md` §7 records", not the region. Same for bucket names,
|
||||||
|
distribution IDs, DKIM tokens, endpoints, and account identifiers.
|
||||||
|
|
||||||
|
A duplicated fact is a fact that will eventually be wrong in one place, and the
|
||||||
|
copy that goes stale is the one nobody re-reads. This rule exists because
|
||||||
|
`docs/05-backend-spec.md` carried its own copy of the SES DKIM table, a
|
||||||
|
correction reached §7 and never reached it, and the stale copy ended up telling
|
||||||
|
an operator to delete the three records that authenticate outbound mail —
|
||||||
|
under the heading "Never delete".
|
||||||
|
|
||||||
|
**A measurement is a claim about your instrument until you check the
|
||||||
|
instrument.** This has now cost five times, and the shape is identical every
|
||||||
|
time: a number that looks like a finding, from a probe nobody validated.
|
||||||
|
|
||||||
|
- `timeout 60 ls "$DRIVE"` — **the command never ran.** `timeout` is not
|
||||||
|
installed on macOS. Empty output from a command that failed to start looks
|
||||||
|
exactly like empty output from a command that found nothing, and it produced a
|
||||||
|
report that the brand assets were unreachable when the directory was fully
|
||||||
|
readable.
|
||||||
|
- **`1.23:1` for the traced mark** — the bounding box of the path's *coordinate
|
||||||
|
hull*, not of the curve. A cubic's control points sit outside it, so the box
|
||||||
|
was 33% too tall while giving the *correct* width — which means the obvious
|
||||||
|
sanity check, "does the width look right?", passes.
|
||||||
|
- **"the mark renders at 24px"** — the harness reported the worst-deviating
|
||||||
|
instance on the page, not the instance under discussion, which was exact.
|
||||||
|
- **"0 overflow at every width"** — true, and it measured the *document*. A flex
|
||||||
|
child was absorbing the deficit by being crushed to aspect 0.891. **Measure
|
||||||
|
the elements, not only the page.**
|
||||||
|
- **`img.naturalWidth` = 64 at DPR 1, 2 and 3** — which reads as *the density
|
||||||
|
ladder is not being generated at all*, a shipped defect on every page. It is
|
||||||
|
**density-corrected by spec**: a 192px file selected at `3x` correctly reports
|
||||||
|
64. The files on disk were 64 / 128 / 192 all along.
|
||||||
|
|
||||||
|
So before acting on a number: say what it is a number *of*; confirm the command
|
||||||
|
actually ran and read its exit status; and check it against a second method that
|
||||||
|
cannot fail the same way — the bytes on disk, a screenshot, a hit test.
|
||||||
|
|
||||||
|
**And a grep that matches is not a finding until you read what it matched.**
|
||||||
|
A case-insensitive sweep for `LSO` hit `I aLSO practise`; a superlative sweep for
|
||||||
|
`leading` hit `the pLEADINGs`. Both on the same page on the same day. Print the
|
||||||
|
match with context before you believe it.
|
||||||
|
|
||||||
|
**Never name an Astro prop `as`.** `const { as = 'p' } = Astro.props` detaches
|
||||||
|
the `Props` interface from the component, and **every call site silently stops
|
||||||
|
being type-checked.** `astro check` reports it only as `ts(6196) 'Props' is
|
||||||
|
declared but never used`, which reads like lint noise. Measured: with the prop
|
||||||
|
named `as`, `<Eyebrow dot as="h9" bogusProp={1} />` compiled with **0 errors**;
|
||||||
|
renaming the one identifier to `tag` made the same probe fail correctly. **Do not
|
||||||
|
silence a `ts(6196)` with `Astro.props as Props`** — that hides the warning and
|
||||||
|
leaves the call sites unchecked, which is strictly worse. If that hint appears on
|
||||||
|
any component, pass it a bogus prop before believing its props are checked.
|
||||||
|
|
||||||
|
**A sweep is a command, not a claim.** Any statement that a change was applied
|
||||||
|
across files — a phrase removed everywhere, a path updated everywhere, a
|
||||||
|
decision swept through the docs — must cite the command that proves it, and be
|
||||||
|
written only after reading that command's output. Paste the `grep` into the
|
||||||
|
Change Log entry. Three consecutive entries on this project asserted a completed
|
||||||
|
sweep; instances survived all three, and one of them was inside
|
||||||
|
`.claude/agents/claims-auditor.md` — the definition of the agent whose job is to
|
||||||
|
catch exactly that. Recall is not evidence.
|
||||||
|
|
||||||
**Commits.** Conventional Commits (`feat:`, `fix:`, `docs:`, `chore:`, `refactor:`).
|
**Commits.** Conventional Commits (`feat:`, `fix:`, `docs:`, `chore:`, `refactor:`).
|
||||||
One logical change per commit. Never commit secrets, `.env` files, or AWS
|
One logical change per commit. Never commit secrets, `.env` files, or AWS
|
||||||
credentials — deploys use OIDC role assumption.
|
credentials. Gitea is not an AWS OIDC provider, so the deploy key is designed as
|
||||||
|
a static IAM access key to be held in Gitea Actions secrets — whether it has
|
||||||
|
actually been provisioned is `AGENTS.md` Q22. It must never reach the repo.
|
||||||
|
|
||||||
**Performance budget.** Lighthouse ≥ 95 on all four categories, on mobile, for
|
**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
|
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.
|
Slow 4G connection. Treat a budget breach as a failing build.
|
||||||
|
|
||||||
|
**Lighthouse cannot currently be run.** `@lhci/cli` was removed on 2026-08-26
|
||||||
|
(it carried 7 high-severity advisories, `0.15.1` is `latest`, and it had no
|
||||||
|
pages and no `lighthouserc` to work with). The budget stands; the instrument is
|
||||||
|
missing. It is re-added at build step 7 under `AGENTS.md` R11 — with a freshly
|
||||||
|
verified pin, not on the assumption that `0.15.1` is still the ceiling. **Say
|
||||||
|
"not run — tool unavailable" rather than silently omitting it.** A documented
|
||||||
|
control that no longer exists is precisely the defect Q22 turned out to be.
|
||||||
|
|
||||||
## What "done" means for a page
|
## What "done" means for a page
|
||||||
|
|
||||||
- [ ] Copy written from `docs/03-content-spec.md`, every claim traceable to `AGENTS.md` §4
|
- [ ] Copy written from `docs/03-content-spec.md`, every claim traceable to `AGENTS.md` §4
|
||||||
- [ ] No `TODO(pouya)` left unlogged in §9
|
- [ ] No `TODO(pouya)` left unlogged in §9
|
||||||
- [ ] Unique title, meta description, canonical, OG/Twitter tags, JSON-LD
|
- [ ] Unique title, meta description, canonical, OG/Twitter tags, JSON-LD
|
||||||
- [ ] Semantic HTML; keyboard navigable; reduced-motion honoured
|
- [ ] Semantic HTML; keyboard navigable; reduced-motion honoured
|
||||||
- [ ] Lighthouse ≥ 95 mobile, all four categories
|
- [ ] Lighthouse ≥ 95 mobile, all four categories — **UNAVAILABLE until step 7**
|
||||||
|
(see the performance budget above). Report it as not run; do not tick it
|
||||||
- [ ] Renders correctly with JavaScript disabled
|
- [ ] Renders correctly with JavaScript disabled
|
||||||
- [ ] `AGENTS.md` Change Log entry appended
|
- [ ] `AGENTS.md` Change Log entry appended
|
||||||
|
|||||||
@@ -2,13 +2,14 @@
|
|||||||
|
|
||||||
The dispute resolution practice of Pouya Lajevardi — Toronto.
|
The dispute resolution practice of Pouya Lajevardi — Toronto.
|
||||||
|
|
||||||
A static site built with [Astro](https://astro.build), deployed to Amazon S3
|
A static site built with [Astro](https://astro.build), built for deployment to
|
||||||
behind CloudFront by GitHub Actions.
|
Amazon S3 behind CloudFront by Gitea Actions — see Deployment; the pipeline is
|
||||||
|
not yet proven.
|
||||||
|
|
||||||
## Quick start
|
## Quick start
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
nvm use # Node 22
|
nvm use # Node 22 LTS — the floor is in package.json engines
|
||||||
npm install
|
npm install
|
||||||
npm run dev # http://localhost:4321
|
npm run dev # http://localhost:4321
|
||||||
```
|
```
|
||||||
@@ -21,7 +22,9 @@ npm run dev # http://localhost:4321
|
|||||||
| `npm run build` | Static build to `./dist` |
|
| `npm run build` | Static build to `./dist` |
|
||||||
| `npm run preview` | Serve the built site locally |
|
| `npm run preview` | Serve the built site locally |
|
||||||
| `npm run check` | `astro check` — type and template errors |
|
| `npm run check` | `astro check` — type and template errors |
|
||||||
| `npm run lint` | ESLint + Prettier |
|
| `npm run lint` | ESLint + Prettier check |
|
||||||
|
| `npm run format` | Prettier — rewrite files in place |
|
||||||
|
| `npm run deploy` | Build and deploy from this machine — see Deployment |
|
||||||
|
|
||||||
## Before you contribute
|
## Before you contribute
|
||||||
|
|
||||||
@@ -29,23 +32,72 @@ Read **`AGENTS.md`** first, and maintain it as you work — it is the living
|
|||||||
record of what this project is, what was decided, and why. Then read
|
record of what this project is, what was decided, and why. Then read
|
||||||
**`CLAUDE.md`** for the working rules, and the specs in `docs/`.
|
**`CLAUDE.md`** for the working rules, and the specs in `docs/`.
|
||||||
|
|
||||||
The single hardest rule: **no factual claim about the practice ships unless it
|
The single hardest rule: **no factual claim about Pouya, his credentials, his
|
||||||
appears in the verified register in `AGENTS.md` §4.** This is a licensed
|
experience, or his practice ships unless it appears in the verified register in
|
||||||
professional's public marketing surface, and the site this replaces contained
|
`AGENTS.md` §4.** This is a public marketing surface, and the site it replaces
|
||||||
fabricated credentials.
|
contained fabricated credentials.
|
||||||
|
|
||||||
## How work is done here
|
## How work is done here
|
||||||
|
|
||||||
Pouya decides; Claude Code implements and then adversarially reviews its own
|
Pouya decides; Claude Code implements and then adversarially reviews its own
|
||||||
work. Run **`/build <task>`** for any substantive change — it plans, implements,
|
work. Run **`/build <task>`** for any substantive change — it plans, implements,
|
||||||
runs two independent review agents on the diff, resolves the findings, verifies
|
runs two independent review agents on the diff (the claims audit wherever copy
|
||||||
the build, and records the session in `AGENTS.md`. `/review` runs the review pass
|
changed), resolves the findings, verifies the build, and records the session in
|
||||||
alone; `/wrap` closes a session.
|
`AGENTS.md`. `/review` runs the review pass alone; `/wrap` closes a session.
|
||||||
|
|
||||||
Full protocol and prompt guidance: `docs/08-execution-protocol.md`.
|
Full protocol and prompt guidance: `docs/08-execution-protocol.md`.
|
||||||
|
|
||||||
## Deployment
|
## Deployment
|
||||||
|
|
||||||
Pushes to `main` build and deploy automatically via
|
**Today, deploys run locally: `npm run deploy`** (`scripts/deploy-local.sh`).
|
||||||
`.github/workflows/deploy.yml`, using OIDC role assumption — there are no
|
It runs the same guard, the same three sync passes with the same cache headers,
|
||||||
long-lived AWS credentials in this repository. See `docs/06-deployment.md`.
|
and the same invalidation as the CI workflow — at this scale the pipeline
|
||||||
|
changes only *how* a deploy is triggered, not what it does. The script and
|
||||||
|
`.gitea/workflows/deploy.yml` are one artefact in two places: change one, change
|
||||||
|
both.
|
||||||
|
|
||||||
|
`.gitea/workflows/deploy.yml` is the CI pipeline — **Gitea Actions**, not GitHub
|
||||||
|
Actions. **It has never run**, for two reasons that are not oversights:
|
||||||
|
|
||||||
|
- The scoped IAM user does not exist. `aws iam get-user --user-name
|
||||||
|
adr-sml-deploy` returns `NoSuchEntity` (`AGENTS.md` Q22).
|
||||||
|
- Actions are not enabled and no runner is registered. The Gitea instance is
|
||||||
|
jointly administered, so both need its second administrator (Q23).
|
||||||
|
|
||||||
|
Its **first** step is a guard: the run aborts, naming what is missing, if any
|
||||||
|
required variable or either AWS secret is empty. Only emptiness is tested and no
|
||||||
|
value is echoed.
|
||||||
|
|
||||||
|
The GitHub Actions equivalent, which uses OIDC role assumption, is kept as
|
||||||
|
`docs/reference/github-actions-oidc.yml.example` in case the project ever moves
|
||||||
|
to a forge that supports it. It sits outside `.github/workflows/` on purpose:
|
||||||
|
Gitea falls back to that directory when `.gitea/workflows` is absent, so a
|
||||||
|
workflow file left there with a `push` trigger would be only conditionally
|
||||||
|
inert. As an `.example` under `docs/` it cannot be picked up at all.
|
||||||
|
|
||||||
|
**The pipeline is designed around a long-lived AWS credential, and it does not
|
||||||
|
exist yet.** Gitea is not an AWS OIDC provider, so there is no role to assume:
|
||||||
|
deploys are *to* authenticate as a scoped IAM user, `adr-sml-deploy`, with its
|
||||||
|
access key in the repository's Gitea Actions secrets. `aws iam get-user`
|
||||||
|
confirms that user has not been created (Q22). In the meantime the local script
|
||||||
|
**refuses to run as `user/pouya`**, the broadly-permissioned personal user —
|
||||||
|
see `AGENTS.md` §10. Two things are meant to bound the risk, and neither is in
|
||||||
|
place yet:
|
||||||
|
|
||||||
|
- **The policy must stay narrow.** Four actions: `s3:ListBucket` on one bucket,
|
||||||
|
`s3:PutObject` and `s3:DeleteObject` on that bucket's contents, and
|
||||||
|
`cloudfront:CreateInvalidation` on one distribution. No `Action: "*"`, no
|
||||||
|
`Resource: "*"`, nothing outside that one bucket and that one distribution.
|
||||||
|
The AWS account is shared with unrelated projects, including a bucket whose
|
||||||
|
name indicates another business's production database backups — that
|
||||||
|
narrowness is what keeps a compromised runner away from it, and it is
|
||||||
|
load-bearing rather than hygiene. See `AGENTS.md` §10. If a deploy step needs
|
||||||
|
a permission the policy lacks, question the step; do not widen the policy.
|
||||||
|
- **The key must be rotated quarterly, and nobody owns that yet.** Create a
|
||||||
|
second access key, update the Gitea secrets, confirm a deploy succeeds, then
|
||||||
|
delete the old one — rotation that leaves the old key active is not
|
||||||
|
rotation. OIDC would have removed the obligation entirely; it is
|
||||||
|
unavailable, so this is a standing calendar task still waiting on an owner.
|
||||||
|
|
||||||
|
Full procedure, IAM policy, runner setup, and cutover checklist:
|
||||||
|
`docs/06-deployment.md`.
|
||||||
|
|||||||
+30
-6
@@ -17,19 +17,43 @@ export default defineConfig({
|
|||||||
trailingSlash: 'always',
|
trailingSlash: 'always',
|
||||||
build: { format: 'directory', inlineStylesheets: 'auto' },
|
build: { format: 'directory', inlineStylesheets: 'auto' },
|
||||||
|
|
||||||
|
// Astro 7 changed this default from `true` to `'jsx'`. Measured, not assumed
|
||||||
|
// (AGENTS.md entry (t)): in an .astro template, an inline pair split across
|
||||||
|
// two lines renders as `<em>inline</em><strong>pair</strong>` under 'jsx' and
|
||||||
|
// `<em>inline</em> <strong>pair</strong>` under `true`. The space is silently
|
||||||
|
// deleted — no error, no warning; you find out by reading the page.
|
||||||
|
//
|
||||||
|
// MDX prose is NOT affected either way; the hazard is .astro markup only.
|
||||||
|
// Held at the HTML-aware behaviour because hand-written templates on a
|
||||||
|
// text-heavy site are exactly where a wrapped line meets an inline tag.
|
||||||
|
// Revisit only with a measurement, not a preference. See R12.
|
||||||
|
compressHTML: true,
|
||||||
|
|
||||||
integrations: [
|
integrations: [
|
||||||
mdx(),
|
mdx(),
|
||||||
sitemap({
|
sitemap({
|
||||||
|
// /legal/* is noindex by spec (docs/04) and nothing else is excluded.
|
||||||
|
// The `/type-scale/` half of this condition is gone with the page it
|
||||||
|
// named: the step-1 proof sheet was deleted at step 2, as its own comment
|
||||||
|
// and InfinityMark's both said it would be. Recoverable from git if the
|
||||||
|
// specimen is ever wanted again; it is not a route the site ships.
|
||||||
filter: (page) => !page.includes('/legal/'),
|
filter: (page) => !page.includes('/legal/'),
|
||||||
changefreq: 'monthly',
|
changefreq: 'monthly',
|
||||||
lastmod: new Date(),
|
// No `lastmod`. It was `new Date()`, which stamped every URL with the
|
||||||
|
// build time — telling crawlers all 17 pages changed whenever one did.
|
||||||
|
// Google discounts lastmod it judges unreliable, so that spent the signal
|
||||||
|
// docs/04-seo-spec.md wants rather than sending it. Step 7 can reinstate
|
||||||
|
// it per-entry from an article's `updatedDate` via `serialize`.
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
|
|
||||||
image: {
|
// No `image` block: `astro/assets/services/sharp` is already Astro's default
|
||||||
// Explicit dimensions everywhere; never base64-inline an image.
|
// service, so setting it was dead configuration. The conventions it used to
|
||||||
service: { entrypoint: 'astro/assets/services/sharp' },
|
// sit under — explicit width/height on every image, never base64-inline —
|
||||||
},
|
// live in CLAUDE.md, which is where they are actually enforced (by review).
|
||||||
|
|
||||||
prefetch: { prefetchAll: true, defaultStrategy: 'viewport' },
|
// No `prefetch` block at all — removed 2026-08-26, see AGENTS.md entry (r).
|
||||||
|
// Any prefetch setting ships Astro's prefetch script to every page, against
|
||||||
|
// CLAUDE.md's "default to zero JS", for a marginal gain on a small static site
|
||||||
|
// already served from CloudFront. Revisit only against real Lighthouse numbers.
|
||||||
});
|
});
|
||||||
|
|||||||
+143
-16
@@ -97,6 +97,23 @@ 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.
|
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.**
|
**This reasoning is Claude's, recorded for Pouya's decision — not yet his call.**
|
||||||
|
|
||||||
|
## Not a practice area yet: tax-adjacent disputes
|
||||||
|
|
||||||
|
**Canadian Tax Foundation membership is verified** (`AGENTS.md` §4, 2026-08-26)
|
||||||
|
and it is the one credential none of the six areas above touch. Tax-adjacent
|
||||||
|
disputes are genuinely ADR territory — valuation and purchase-price disputes on
|
||||||
|
a share sale, indemnity and earn-out fights that turn on a tax position,
|
||||||
|
shareholder splits where the assessment is the thing actually in dispute.
|
||||||
|
|
||||||
|
**There is no seventh practice page at launch,** for the same reason as the
|
||||||
|
section above and not a weaker one: a practice page is a claim of present
|
||||||
|
capability, and there is no track record to point at. A membership is a
|
||||||
|
credential, not a caseload.
|
||||||
|
|
||||||
|
It belongs on `/about/` with the other memberships. Revisit at the **month
|
||||||
|
12–18 review, alongside the Indigenous engagement decision** — one review, two
|
||||||
|
candidates. Tracked as `AGENTS.md` R3.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Page specifications
|
## Page specifications
|
||||||
@@ -117,14 +134,25 @@ four audiences to its surface.
|
|||||||
2. **Credential row.** Three slots: `Q.Med` · `JD + ML` · `EN · FA`. Never
|
2. **Credential row.** Three slots: `Q.Med` · `JD + ML` · `EN · FA`. Never
|
||||||
matter counts — `AGENTS.md` §4.
|
matter counts — `AGENTS.md` §4.
|
||||||
3. **The approach.** The "two directions at once" argument — law and engineering
|
3. **The approach.** The "two directions at once" argument — law and engineering
|
||||||
converging on the same dispute. Infinity mark as the visual anchor.
|
converging on the same dispute. ⚠️ **The noun pair "law and engineering" is
|
||||||
|
the construction Q37 struck and Q41(a) extended to prose. The argument
|
||||||
|
stands; do not lift the phrase into copy** — it reached `/` once already.
|
||||||
|
State the asymmetry instead: `docs/03` §The credential row. Infinity mark as the visual anchor.
|
||||||
4. **Two practices.** Mediation and Arbitration cards → `/mediation/`, `/arbitration/`.
|
4. **Two practices.** Mediation and Arbitration cards → `/mediation/`, `/arbitration/`.
|
||||||
Med-Arb named here as the long-term arc, linking to `/med-arb/`.
|
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
|
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
|
block on the page for search, because it distributes authority to the pages
|
||||||
that can actually rank.
|
that can actually rank.
|
||||||
6. **Process preview.** Compressed five-step strip → `/process/`.
|
6. **Process preview.** Compressed five-step strip → `/process/`.
|
||||||
7. **Latest insights.** Three most recent → `/insights/`.
|
7. **Latest insights.** Three most recent → `/insights/`. **NOT BUILT AT STEP
|
||||||
|
2, and it arrives at STEP 7 with the collection it lists.** `ArticleCard` and
|
||||||
|
the drafted slate (D9) land in the same step; rendering the section against an
|
||||||
|
empty collection means shipping a component's scoped CSS to every visitor for
|
||||||
|
a block with nothing in it, plus a props surface with no call site.
|
||||||
|
`SiteHeader` already gates the Insights **nav** item on the same collection,
|
||||||
|
so the page section and the nav item appear together. Recorded here as well
|
||||||
|
as in the page source, because "eight sections specified, seven built" should
|
||||||
|
not be discoverable only by reading the file that deviates.
|
||||||
8. **Contact band.** Intake CTA and booking link.
|
8. **Contact band.** Intake CTA and booking link.
|
||||||
|
|
||||||
### `/about/` — Biography and credentials
|
### `/about/` — Biography and credentials
|
||||||
@@ -148,6 +176,17 @@ to an appointment. This page carries the verifiable record.
|
|||||||
7. `Person` JSON-LD. Downloadable one-page PDF bio — brief §VIII lists this as
|
7. `Person` JSON-LD. Downloadable one-page PDF bio — brief §VIII lists this as
|
||||||
an asset for circulation with appointment proposals.
|
an asset for circulation with appointment proposals.
|
||||||
|
|
||||||
|
> **The PDF bio does NOT ship with build step 3, and the omission is stated
|
||||||
|
> rather than silent** — `AGENTS.md` Q45, opened 2026-08-28. No such file exists
|
||||||
|
> in the repo, and a link to one that does not exist is a broken link on the
|
||||||
|
> page an appointing body reads. It is also not a formatting job: a one-page bio
|
||||||
|
> is a **credential document** whose every line has to trace to §4 exactly as a
|
||||||
|
> web page does, and it will be circulated detached from the site, where no
|
||||||
|
> reviewer sees it again. Two decisions are Pouya's — whether it is generated at
|
||||||
|
> build (a dependency, against R11) or authored once as a designed artefact, and
|
||||||
|
> whether it carries anything the site does not. Everything else on this page
|
||||||
|
> ships.
|
||||||
|
|
||||||
### `/mediation/`
|
### `/mediation/`
|
||||||
|
|
||||||
**Job:** convert counsel who have already decided on mediation and are choosing a
|
**Job:** convert counsel who have already decided on mediation and are choosing a
|
||||||
@@ -170,15 +209,23 @@ neutral.
|
|||||||
**Search intent:** `sole arbitrator Ontario`, `expedited arbitration Canada`,
|
**Search intent:** `sole arbitrator Ontario`, `expedited arbitration Canada`,
|
||||||
`documents-only arbitration`.
|
`documents-only arbitration`.
|
||||||
|
|
||||||
1. What the service is; sole-arbitrator, party-appointed, and tribunal-secretary
|
1. What the service is; sole-arbitrator and party-appointed
|
||||||
appointments.
|
appointments.
|
||||||
2. **Tracks:** documents-only, expedited, full hearing.
|
2. **Tracks:** documents-only, expedited, full hearing.
|
||||||
3. **Rules:** ADRIC, ADR Chambers, ad hoc.
|
3. **Rules:** ADRIC, ADR Chambers, ad hoc.
|
||||||
4. Awards — form, reasoning, timing.
|
4. Awards — form, reasoning, timing.
|
||||||
5. **Credentialing status, stated plainly.** The Q.Arb pathway is in progress;
|
5. **Credentialing status, stated plainly.** The Q.Arb pathway is in progress;
|
||||||
the page says so and describes what is available now (co-arbitration,
|
the page says so. **What is available now is all three forms — sole,
|
||||||
tribunal secretary) versus what follows designation. Honesty here is a
|
party-appointed and co-arbitration** — and `AGENTS.md` §4 Offerings carries a
|
||||||
differentiator, not a weakness — and misstating it is a conduct problem.
|
row for each `[verified 2026-08-26 — Pouya]`. The page states that alongside
|
||||||
|
the credentialing stage: Q.Arb commenced August 2026, C.Med-Arb is the
|
||||||
|
endpoint. §4 Offerings: **neither half may be dropped.** Honesty here is a
|
||||||
|
differentiator, not a weakness — and misstating it in either direction is a
|
||||||
|
conduct problem.
|
||||||
|
*(This paragraph read "(co-arbitration, co-arbitration)" until 2026-08-26 —
|
||||||
|
edited without being re-read — and then carried a caveat against Q36 for
|
||||||
|
several hours after Q36 closed. Both are recorded because the pattern is the
|
||||||
|
same one: an edit that was not re-read against the register.)*
|
||||||
6. Fees, booking.
|
6. Fees, booking.
|
||||||
|
|
||||||
### `/med-arb/`
|
### `/med-arb/`
|
||||||
@@ -199,8 +246,23 @@ long-term narrative.
|
|||||||
### `/practice/` — index
|
### `/practice/` — index
|
||||||
|
|
||||||
Six cards, one paragraph each, linking onward. Also the natural home for the
|
Six cards, one paragraph each, linking onward. Also the natural home for the
|
||||||
"also offered" strip: early neutral evaluation, settlement counsel, dispute-
|
"also offered" strip: **early neutral evaluation, dispute-system design, and
|
||||||
system design, and pre-dispute technical advisory.
|
pre-dispute technical advisory** — three, and each now has an `AGENTS.md` §4
|
||||||
|
Offerings row, which is what the strip needs before it may ship.
|
||||||
|
|
||||||
|
> **`settlement counsel` IS STRUCK FROM THIS STRIP AND MUST NOT BE RESTORED.**
|
||||||
|
> `AGENTS.md` Q42, 2026-08-27. Pouya struck it as his own error in this document:
|
||||||
|
>
|
||||||
|
> > "Settlement counsel acts **FOR a party** in negotiation. That is a partisan
|
||||||
|
> > role, and putting it on a site that (a) sells neutrality and (b) asserts no
|
||||||
|
> > licensure under D13 is **wrong twice over**: it undercuts the brand's
|
||||||
|
> > central claim and it edges into acting for a client."
|
||||||
|
>
|
||||||
|
> Note which objection comes first. This is not primarily a compliance problem —
|
||||||
|
> it is a **positioning** problem, and it would have been wrong on a site with
|
||||||
|
> no licensure question at all. The compliance half is the aggravation, not the
|
||||||
|
> reason. Never priced, never offered, never listed: it is a struck row in §4
|
||||||
|
> Offerings so that a later reader finds the decision rather than the gap.
|
||||||
|
|
||||||
### `/practice/construction/`
|
### `/practice/construction/`
|
||||||
|
|
||||||
@@ -208,7 +270,7 @@ system design, and pre-dispute technical advisory.
|
|||||||
`subcontract dispute arbitration Toronto`.
|
`subcontract dispute arbitration Toronto`.
|
||||||
|
|
||||||
Dispute types (lien, delay, change orders, scheduling, subcontract, deficiency);
|
Dispute types (lien, delay, change orders, scheduling, subcontract, deficiency);
|
||||||
what an active litigation practice in the same matters brings to the room; the
|
what active litigation exposure in the same matters brings to the room; the
|
||||||
Ontario megaproject pipeline as context — Darlington SMR, Bruce C, data centres,
|
Ontario megaproject pipeline as context — Darlington SMR, Bruce C, data centres,
|
||||||
transit; typical process shape. Strongest immediate fit per brief §III.1.
|
transit; typical process shape. Strongest immediate fit per brief §III.1.
|
||||||
|
|
||||||
@@ -242,8 +304,37 @@ a claim of existing volume.**
|
|||||||
**Search intent:** `SABS mediation`, `LAT pre-hearing mediation`,
|
**Search intent:** `SABS mediation`, `LAT pre-hearing mediation`,
|
||||||
`accident benefits mediator Ontario`, `MIG dispute`.
|
`accident benefits mediator Ontario`, `MIG dispute`.
|
||||||
|
|
||||||
|
> ⚠️ **`LAT pre-hearing mediation` IS A SEARCH INTENT AND NOTHING ELSE. It must
|
||||||
|
> never be published as an offering** — `AGENTS.md` Q41(c), closed 2026-08-27,
|
||||||
|
> verified 2026-08-28 against the Tribunal's own materials and extracted into
|
||||||
|
> **`docs/reference/lat-case-conference.md`**. It reached `src/data/site.ts` as a
|
||||||
|
> service blurb once already; this note exists because a search-intent list is
|
||||||
|
> where that lift starts.
|
||||||
|
>
|
||||||
|
> What the verification found, in one line each:
|
||||||
|
>
|
||||||
|
> - **LAT Rule 2.4:** *"'Case Conference' has the same meaning as 'Pre-Hearing
|
||||||
|
> Conference' as defined in the SPPA."* **"Pre-hearing" is the Tribunal's own
|
||||||
|
> label**, and what it labels is a case conference.
|
||||||
|
> - **Rule 14.3:** a **Member** presides and is then disqualified from the
|
||||||
|
> hearing panel; **Rule 14.6:** parties must attend. The neutral is the
|
||||||
|
> Tribunal's. A privately retained one is not appointed to it and cannot be.
|
||||||
|
> - The LAT Rules contain **zero** occurrences of `mediat` or `arbitrat` —
|
||||||
|
> 0 in 66,593 characters. The concept is not in them.
|
||||||
|
> - The LAT-AABS page itself, though, says: *"Before you apply to the LAT-AABS,
|
||||||
|
> you may want to consider negotiation or mediation services… including before
|
||||||
|
> filing at the LAT-AABS, and continuing… after a claim has been filed."*
|
||||||
|
> **That is the affirmative basis for the offering, in the Tribunal's words.**
|
||||||
|
>
|
||||||
|
> **The page must state that the mediation offered is PRIVATE, retained by the
|
||||||
|
> parties, and is not the Tribunal's case conference.** Published blurb:
|
||||||
|
> *"Accident benefits and SABS entitlement, MIG disputes, and private mediation
|
||||||
|
> alongside a LAT application, before filing or after."* If Pouya holds a roster
|
||||||
|
> position that makes more than that true, it is a §4 addition — absent a row,
|
||||||
|
> it is not.
|
||||||
|
|
||||||
Highest realistic near-term volume — it flows directly from the existing
|
Highest realistic near-term volume — it flows directly from the existing
|
||||||
personal-injury and SABS practice, and brief §IV.7 notes the segment is
|
personal-injury and SABS work, and brief §IV.7 notes the segment is
|
||||||
underserved by senior mediators. Unglamorous and worth doing well.
|
underserved by senior mediators. Unglamorous and worth doing well.
|
||||||
|
|
||||||
### `/practice/shareholder/`
|
### `/practice/shareholder/`
|
||||||
@@ -255,6 +346,20 @@ Shareholder and partnership disputes, co-founder breakdowns, family-business
|
|||||||
succession, SME exits. The operator angle — running SML Company Ltd. alongside
|
succession, SME exits. The operator angle — running SML Company Ltd. alongside
|
||||||
the practice — is the differentiator here.
|
the practice — is the differentiator here.
|
||||||
|
|
||||||
|
**"Family Business" means COMMERCIAL disputes among family shareholders, and the
|
||||||
|
page must say so.** Pouya's ruling of 2026-08-27 (`AGENTS.md` Q39): the label
|
||||||
|
covers shareholder and partnership disputes, co-founder breakdowns and business
|
||||||
|
succession — **not** family law. **Family arbitration under the *Family Law Act*
|
||||||
|
is not offered**, and that activity is separately gated by prescribed training
|
||||||
|
(`docs/reference/ontario-family-arbitration-training.md`), so the exclusion has
|
||||||
|
to be legible rather than left to be inferred from the surrounding nouns.
|
||||||
|
|
||||||
|
**One sentence, not a section.** His instruction, and the reason is also the test
|
||||||
|
for whether it belongs at all: *"The page should say plainly that family law
|
||||||
|
matters are not accepted. One sentence, not a section: it saves a wasted intake
|
||||||
|
call, which is the only reason it earns its place."* A disclaimer that grows into
|
||||||
|
a paragraph reads as defensive, which is the opposite of the point.
|
||||||
|
|
||||||
### `/practice/cross-cultural/`
|
### `/practice/cross-cultural/`
|
||||||
|
|
||||||
**Search intent:** `Farsi speaking mediator Toronto`,
|
**Search intent:** `Farsi speaking mediator Toronto`,
|
||||||
@@ -272,9 +377,17 @@ and framing (1–7) · pre-session exchange (7–21) · the session (21–30) ·
|
|||||||
conclusion (30+). Also: conflicts checking, confidentiality, and what happens if
|
conclusion (30+). Also: conflicts checking, confidentiality, and what happens if
|
||||||
a matter does not settle.
|
a matter does not settle.
|
||||||
|
|
||||||
|
**The timings are published as the TYPICAL shape of an engagement, explicitly
|
||||||
|
not a guarantee** — `AGENTS.md` Q43, Pouya 2026-08-27. Render `PROCESS_FRAMING`
|
||||||
|
(`src/data/site.ts`) **adjacent to the steps**, on this page and on `/`. The
|
||||||
|
numbers above are unchanged; what is required is that they never appear
|
||||||
|
unframed. *"Published as typical, they are honest and useful; published as
|
||||||
|
commitments, the first matter that slips makes the page false."*
|
||||||
|
|
||||||
### `/fees/`
|
### `/fees/`
|
||||||
|
|
||||||
**Blocked on `AGENTS.md` Q4 — do not invent numbers.**
|
**Unblocked — `AGENTS.md` Q4/Q14 answered (D14). Build from the confirmed card
|
||||||
|
in `docs/07-fees.md`; still do not invent numbers.**
|
||||||
|
|
||||||
Hourly rate; half-day and full-day mediation; preparation time policy;
|
Hourly rate; half-day and full-day mediation; preparation time policy;
|
||||||
cancellation terms; administrative fee; HST treatment; who pays and how costs
|
cancellation terms; administrative fee; HST treatment; who pays and how costs
|
||||||
@@ -295,8 +408,21 @@ you do not settle · how to prepare.
|
|||||||
Astro content collection, MDX. Index reverse-chronological with topic filtering
|
Astro content collection, MDX. Index reverse-chronological with topic filtering
|
||||||
by practice area.
|
by practice area.
|
||||||
|
|
||||||
Article frontmatter: `title`, `description`, `publishDate`, `updatedDate`,
|
Article frontmatter: `title`, `seoTitle` (optional), `description`,
|
||||||
`topics[]`, `practiceAreas[]`, `readingTime`, `draft`.
|
`publishDate`, `updatedDate`, `topics[]`, `practiceAreas[]`, `readingTime`,
|
||||||
|
`image` and `imageAlt` (both optional, but `imageAlt` is **required whenever
|
||||||
|
`image` is set**), `draft`, `reviewedByPouya`.
|
||||||
|
|
||||||
|
`title` is the headline and, for articles, the `<title>` — they carry no
|
||||||
|
` · Pouya Lajevardi` suffix; see `04-seo-spec.md` for why. `seoTitle` replaces
|
||||||
|
it when a headline that reads well falls outside 50–60. `src/content.config.ts`
|
||||||
|
enforces the rendered length and names the offending string in the error.
|
||||||
|
|
||||||
|
Dates are date-only ISO (`2026-08-01`), parsed as UTC and round-tripped, so a
|
||||||
|
typo fails the build rather than shipping as 1970 or as the wrong day.
|
||||||
|
|
||||||
|
`reviewedByPouya` carries D9: the schema refuses to build an entry with
|
||||||
|
`draft: false` and `reviewedByPouya: false`.
|
||||||
|
|
||||||
Content territories, from brief §VII: process explainers · regulatory commentary ·
|
Content territories, from brief §VII: process explainers · regulatory commentary ·
|
||||||
industry-specific dispute commentary · anonymised reflections · technical
|
industry-specific dispute commentary · anonymised reflections · technical
|
||||||
@@ -312,8 +438,9 @@ anything.
|
|||||||
|
|
||||||
### `/contact/`
|
### `/contact/`
|
||||||
|
|
||||||
Intake form (`05-backend-spec.md`), booking embed, direct email and phone
|
Intake form (`05-backend-spec.md`), booking embed, direct email
|
||||||
(Q3), Toronto by-appointment line, response-time expectation, and an explicit
|
(Q3 — **there is no public phone number**; render `CONTACT.phoneFallback`,
|
||||||
|
"By scheduled call", wherever a number would go), Toronto by-appointment line, response-time expectation, and an explicit
|
||||||
note that submitting the form does not create a retainer or a mediator–party
|
note that submitting the form does not create a retainer or a mediator–party
|
||||||
relationship and does not itself create a conflict check.
|
relationship and does not itself create a conflict check.
|
||||||
|
|
||||||
@@ -339,6 +466,6 @@ Dependency-ordered, so nothing is blocked mid-stream:
|
|||||||
6. `/process/`, `/for-parties/`
|
6. `/process/`, `/for-parties/`
|
||||||
7. `/insights/` plumbing, then the drafted articles
|
7. `/insights/` plumbing, then the drafted articles
|
||||||
8. `/contact/` and the intake backend
|
8. `/contact/` and the intake backend
|
||||||
9. `/fees/` — last, since it is blocked on Q4
|
9. `/fees/` — last, though no longer blocked: D14 confirmed the card
|
||||||
10. `/legal/*` — written to match the backend as actually built
|
10. `/legal/*` — written to match the backend as actually built
|
||||||
11. Audit and cutover (`06-deployment.md`)
|
11. Audit and cutover (`06-deployment.md`)
|
||||||
|
|||||||
+101
-10
@@ -11,6 +11,12 @@ system that preserves it while fixing what the old build got wrong.
|
|||||||
- The **palette**: cream, ink, maroon, gold.
|
- The **palette**: cream, ink, maroon, gold.
|
||||||
- The **infinity mark** — SML Company Ltd.'s actual logo, and the metaphor holds:
|
- 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.
|
a dispute is a loop, and the work is redrawing the loop into a line.
|
||||||
|
**It is a shaded ribbon, not a stroked curve**: a band of variable width that
|
||||||
|
twists in three dimensions, maroon flowing into champagne, passing over itself
|
||||||
|
at the crossing. Ink bounding box **2668 × 1704 = 1.5657:1**
|
||||||
|
`[verified 2026-08-26 — measured against the master]`. Source of truth:
|
||||||
|
`src/assets/brand/sml-infinity-mark.png`; provenance in
|
||||||
|
`docs/reference/brand-assets.md`.
|
||||||
- The **type pairing**: Instrument Serif for display, Geist for text, Geist Mono
|
- The **type pairing**: Instrument Serif for display, Geist for text, Geist Mono
|
||||||
for eyebrows and labels.
|
for eyebrows and labels.
|
||||||
- The **editorial register** — generous whitespace, restrained colour, serif
|
- The **editorial register** — generous whitespace, restrained colour, serif
|
||||||
@@ -25,7 +31,7 @@ system that preserves it while fixing what the old build got wrong.
|
|||||||
| Ad hoc spacing values | 8 px base scale | Consistent vertical rhythm; no magic numbers |
|
| 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 |
|
| 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 |
|
| Scroll-reveal on every element, always on | Reveal on major sections only, gated behind `prefers-reduced-motion` | Motion that reads as confident rather than decorative; accessible by default |
|
||||||
| 2.2 MB single file, ~1 MB of base64 logos | Optimized SVG mark, AVIF/WebP photography | The mark is geometry; it should be vector, not a 470 KB PNG |
|
| 2.2 MB single file, ~1 MB of base64 logos *(both figures under review — `AGENTS.md` Q34; and "470 KB PNG", which this row used to assert, has no source anywhere in the repo and has been removed)* | Optimized SVG mark, AVIF/WebP photography | The mark is geometry, so it should be vector. That holds whatever the old file weighed |
|
||||||
| React 18 dev build + Babel Standalone in the browser | Static HTML, near-zero JS | The reason the site is invisible to crawlers |
|
| React 18 dev build + Babel Standalone in the browser | Static HTML, near-zero JS | The reason the site is invisible to crawlers |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -116,7 +122,13 @@ the display end. Tokens `--text-xs` through `--text-6xl` in `tokens.css`.
|
|||||||
|
|
||||||
Content width `1280px`; prose measure `68ch`; wide media `1440px`.
|
Content width `1280px`; prose measure `68ch`; wide media `1440px`.
|
||||||
Gutters: `24px` mobile, `48px` desktop.
|
Gutters: `24px` mobile, `48px` desktop.
|
||||||
Section rhythm: `--space-9` (96px) mobile, `--space-11` (160px) desktop.
|
Section rhythm: `--space-9` (96 px) mobile, `--space-11` (160 px) desktop —
|
||||||
|
`--section-y` in `tokens.css`. *Corrected 2026-08-26:* the curve was
|
||||||
|
`6vw + 2rem`, which reaches 160 px only at a **2133 px** viewport, so the
|
||||||
|
desktop half of this line was never delivered (measured 108.8 px at 1280 px,
|
||||||
|
128 px at 1600 px). It is now `9vw + 1rem`, which reaches 160 px at 1600 px. If
|
||||||
|
you change the curve, re-measure — a `clamp()` whose upper bound is unreachable
|
||||||
|
reads exactly like one that works.
|
||||||
|
|
||||||
Grid: 12 columns desktop, 6 tablet, 4 mobile, `--space-5` gutter.
|
Grid: 12 columns desktop, 6 tablet, 4 mobile, `--space-5` gutter.
|
||||||
|
|
||||||
@@ -131,11 +143,24 @@ deliberate and quiet.
|
|||||||
stagger is limited to card grids, and capped at six children.
|
stagger is limited to card grids, and capped at six children.
|
||||||
- Duration `600ms`, easing `cubic-bezier(.2,.7,.2,1)`. Transform and opacity
|
- Duration `600ms`, easing `cubic-bezier(.2,.7,.2,1)`. Transform and opacity
|
||||||
only — never layout properties.
|
only — never layout properties.
|
||||||
- Implement with `IntersectionObserver` in one tiny inline script, or
|
- Implement with **`animation-timeline: view()`**, behind `@supports`. *Amended
|
||||||
`animation-timeline: view()` where supported. Not a framework, not a library.
|
2026-08-26:* the `IntersectionObserver` alternative this line used to offer
|
||||||
- **Content is visible without JavaScript.** The reveal is an enhancement layered
|
first is now ruled out, not merely second choice. It has to run inline in
|
||||||
on top of already-rendered HTML. If the observer never runs, the page reads
|
`<head>` to avoid a flash, and `05-backend-spec.md` specifies `script-src
|
||||||
normally. The old build had this exactly backwards.
|
'self'` with no `unsafe-inline` — so the only script on the site would have
|
||||||
|
been the one thing the site's own CSP refuses to execute, and a per-build hash
|
||||||
|
drifts from the policy pinning it. The CSS route ships **zero** JavaScript.
|
||||||
|
Not a framework, not a library, not a script.
|
||||||
|
- **Content is visible without the feature.** The `@supports` gate is
|
||||||
|
load-bearing, not defensive: without it, a browser that ignores
|
||||||
|
`animation-timeline` runs the animation once against the document timeline at
|
||||||
|
load; with it, that browser gets no animation and fully visible content. The
|
||||||
|
old build had this exactly backwards and shipped a blank page.
|
||||||
|
- **And without a print timeline.** A scroll-driven animation has no timeline
|
||||||
|
when printing, so a revealed element renders at its `from` state — `opacity:
|
||||||
|
0`. Measured 2026-08-26: before the print override existed, printing a page to
|
||||||
|
PDF dropped four card headings from the output entirely. `/about/` is written
|
||||||
|
to be printed by people evaluating an appointment.
|
||||||
- Hover transitions `250ms`.
|
- Hover transitions `250ms`.
|
||||||
|
|
||||||
```css
|
```css
|
||||||
@@ -158,8 +183,8 @@ except the reveal of the hero.
|
|||||||
|
|
||||||
| Component | Notes |
|
| Component | Notes |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `InfinityMark` | Inline SVG, `currentColor`, `aria-hidden` when decorative. Never a PNG |
|
| `InfinityMark` | ⚠️ **Currently a raster — a documented, temporary exception to this rule** (`AGENTS.md` Q38, R13). The mark is gradient-mesh artwork rather than flat vector paths. An SVG *is* held and it renders faithfully — **and it renders faithfully because it IS the raster**: 257,278 bytes wrapping **seven embedded base64 PNGs**, against **3,063 B** for the AVIF a Retina browser takes in the header — **84×**. *Figures re-stated 2026-08-27 because a second, larger call site now exists and the single number had become misleading:* the mark ships at **two intrinsic widths**, 64 px (header, footer) and 232 px (the home page's approach section, which renders at 225.5 px). At 232 px a DPR-2 device takes **14,555 B** and DPR-3 **22,639 B**, so the ratio against the SVG there is ~11×, not 84×. The full ladders are in `docs/reference/brand-assets.md`; do not quote one number as if it covered both. Inlining it would breach `CLAUDE.md`'s no-base64-images rule. *Pouya settled the characterisation on 2026-08-27: a first draft called the file "a raster in a vector wrapper", a later draft withdrew that as unfair, and **the withdrawal went too far.** Both halves are true — the fidelity is real, and it is bought with embedded raster, which is exactly why fidelity was never the question.* The exception is about payload and composition. It renders AVIF/WebP with a PNG fallback; a Retina device takes 3,063 B of AVIF, `alt=""` when decorative, aspect ratio pinned to `667 / 426`. **Restore this rule the moment the commissioned vector master lands.** Until then the rule stands unchanged for every other mark or icon: inline SVG, `currentColor`, `aria-hidden` when decorative, never a PNG |
|
||||||
| `SiteHeader` | Sticky, condenses on scroll. Practice dropdown as CSS-only `<details>` |
|
| `SiteHeader` | **Sticky from 66 rem (1056 px) up; static below it.** Practice dropdown as CSS-only `<details>`. **(a)** The one-row header holds the brand, **seven** nav items and the CTA — Insights is the seventh, arriving on its own at build step 7. Binary search on the built page puts the true fit at **1047 px**; 66 rem is the clean token above it. Below 66 rem the nav takes its own row and the header measures **141 px** at tablet widths and **189 px** at 320–375 px. **(b)** "Condenses on scroll" is a hairline rule and a shadow, **not a size change** — a `position: sticky` header stays in normal flow, so shrinking its padding lifts every page below it, against the CLS < 0.05 budget. Longhands only; see the component on what the minifier does to the `animation` shorthand. *(This row has been wrong twice, instructively. It first said 60 rem / "~115 px", a height the header never took. It then said 64 rem "with 32 px of clearance" — that 32 px was `.header-inner`'s own `column-gap` mistaken for slack; the real figure at 1024 px with seven items was **−21.6 px**, and nothing overflowed only because flexbox crushed the logo inside the brand block. **Measure slack, not gaps.**)* |
|
||||||
| `SiteFooter` | Three-column sitemap, contact block, designations, entity line |
|
| `SiteFooter` | Three-column sitemap, contact block, designations, entity line |
|
||||||
| `Eyebrow` | Mono label with optional maroon dot |
|
| `Eyebrow` | Mono label with optional maroon dot |
|
||||||
| `SectionHeading` | Eyebrow + display heading + optional lede, one measure |
|
| `SectionHeading` | Eyebrow + display heading + optional lede, one measure |
|
||||||
@@ -191,5 +216,71 @@ Not a polish pass. A build requirement.
|
|||||||
- Forms: real `<label>` elements, `aria-describedby` for hints, errors announced
|
- Forms: real `<label>` elements, `aria-describedby` for hints, errors announced
|
||||||
with `role="alert"` and tied to their field.
|
with `role="alert"` and tied to their field.
|
||||||
- Touch targets ≥ 44 × 44 px.
|
- Touch targets ≥ 44 × 44 px.
|
||||||
- Test at 200% zoom and at 320 px width.
|
- Test at 200% zoom and at 320 px width. **Both measured 2026-08-27 on `/`:
|
||||||
|
document overflow 0 at 320, 360, 390, 414, 640, 768, 900, 1024, 1056, 1200,
|
||||||
|
1216, 1280, 1440 and 1920 CSS px, with zero elements extending past the
|
||||||
|
viewport.** Page zoom at 200% of 1280 is the 640 column and at 400% is the
|
||||||
|
320 column, so WCAG 1.4.4 and 1.4.10 are both covered by that sweep.
|
||||||
|
|
||||||
|
**A stricter case is not fully clean, and it is recorded rather than left to
|
||||||
|
be discovered.** With the reader's *default font size* at 200% — root at
|
||||||
|
32 px, a real accessibility setting and not page zoom — `/` measured **234 px**
|
||||||
|
of overflow at 390. Brought down in three measured steps:
|
||||||
|
|
||||||
|
| Fix | 390 px | 320 px |
|
||||||
|
|---|---|---|
|
||||||
|
| as first built | 234 px | 304 px |
|
||||||
|
| `minmax(min(Nrem, 100%), 1fr)` on three grids | 83 px | 153 px |
|
||||||
|
| `.credentials` made explicit `repeat(2, minmax(0, 1fr))`; `.feature` padding clamped and `overflow-wrap: anywhere` on its title; `.contact-action` `flex: 0 1 auto` + `min-inline-size: 0` | **3 px** | **63 px** |
|
||||||
|
|
||||||
|
**`65 px` corrected to `63 px` on 2026-08-28**, re-measured independently on
|
||||||
|
the same page and setting. Two pixels, and it is recorded because a table that
|
||||||
|
reads as the site-wide record has to be re-measurable rather than remembered.
|
||||||
|
|
||||||
|
**`/about/` added 2026-08-28** — step 3, and the first page to be measured
|
||||||
|
against this table rather than establishing it:
|
||||||
|
|
||||||
|
| Page and fix | 390 px | 360 px | 320 px |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `/about/` as first built | 38 px | 68 px | 108 px |
|
||||||
|
| `.designation-part` `white-space: nowrap` removed (the separator is held by an NBSP instead) and `overflow-wrap: anywhere` on `.hero-h` | **0 px** | **23 px** | **63 px** |
|
||||||
|
|
||||||
|
Two findings from that page specifically. The `nowrap` was **introduced as a
|
||||||
|
fix** for an orphaned `·` at the end of a wrapped line, and it made the whole
|
||||||
|
designation item unbreakable — a fix that created a reflow regression, caught
|
||||||
|
only because this table existed to regress against. And **the type scale is
|
||||||
|
rem-based**, so an `<h1>` at `--text-5xl` computes to 88 px at root 32 and a
|
||||||
|
single unbreakable 9-character name ("Lajevardi") exceeds the 224 px content
|
||||||
|
box at 320 px; `overflow-wrap: anywhere` is the only remedy that reduces
|
||||||
|
min-content size. `/about/` now measures equal to or better than `/` at all
|
||||||
|
three widths, and its 320/360 residual is the same header decision.
|
||||||
|
|
||||||
|
Command, so the numbers are re-runnable rather than quoted: headless Chrome
|
||||||
|
over the built `dist`, `document.documentElement.style.fontSize = '32px'`, then
|
||||||
|
`documentElement.scrollWidth - documentElement.clientWidth`, plus an
|
||||||
|
enumeration of every element wider than `clientWidth` to name the offender.
|
||||||
|
|
||||||
|
Two things worth keeping. **`overflow-wrap: break-word` permits a break at
|
||||||
|
layout time but does not reduce min-content size** — `anywhere` does, and that
|
||||||
|
distinction was the whole of one of those fixes. And 1280 px stays **602 px**
|
||||||
|
over, from the header's deliberate `flex-wrap: nowrap` above 66 rem plus
|
||||||
|
`white-space: nowrap` on the brand name; the 320 px residual is the same header
|
||||||
|
plus the display headline's 104 px floor. Undoing either re-opens the measured
|
||||||
|
step-1 header decision, so they stand. All of this is beyond what this floor
|
||||||
|
requires — page zoom is clean — so it is a robustness margin rather than a
|
||||||
|
failure. Revisit if a real reader hits it.
|
||||||
|
|
||||||
|
- **Measure the elements, not only the page.** A document-level overflow check
|
||||||
|
passes while a flex child absorbs the deficit by being crushed — that is how
|
||||||
|
step 1 shipped a logo at aspect 0.891 under a green "0 overflow at every
|
||||||
|
width". Assert the rendered geometry of the thing you care about. On `/`
|
||||||
|
every infinity-mark instance measures 1.5654–1.5657 against the master's
|
||||||
|
1.5657.
|
||||||
|
- **A touch-target measurement of the wrong box is not a finding.** The eight
|
||||||
|
cards on `/` report 26–39 px-tall `<a>` elements and are fine: each card's
|
||||||
|
whole box is the link's hit area via `::after { inset: 0 }`, verified by
|
||||||
|
hit-testing nine points per card at three widths (24 cards, 9/9). Hit-test
|
||||||
|
before enlarging anything. WCAG 2.5.8's inline exception also applies to a
|
||||||
|
link sitting mid-sentence, and one such link on `/` is deliberately left at
|
||||||
|
164 x 21.
|
||||||
- Every page must be readable and navigable with JavaScript disabled.
|
- Every page must be readable and navigable with JavaScript disabled.
|
||||||
|
|||||||
+130
-13
@@ -28,8 +28,22 @@ detect padding instantly and discount everything after it.
|
|||||||
practice". The old site's "we" implied a firm that does not exist.
|
practice". The old site's "we" implied a firm that does not exist.
|
||||||
- Concrete nouns. *Lien claim. Change order. System Impact Assessment. Model
|
- Concrete nouns. *Lien claim. Change order. System Impact Assessment. Model
|
||||||
card. Minutes of settlement.* Specificity is the credential.
|
card. Minutes of settlement.* Specificity is the credential.
|
||||||
- Name the limits. "Sole-arbitrator appointments follow the Q.Arb designation;
|
- Name the limits — but name the *right* ones. This bullet carried the model
|
||||||
co-arbitration and tribunal-secretary work is available now." Precision about
|
sentence *"Sole-arbitrator appointments follow the Q.Arb designation;
|
||||||
|
co-arbitration work is available now"* until 2026-08-26. **Both halves were
|
||||||
|
wrong and they were wrong in opposite directions**, which is why it survived
|
||||||
|
two audits: the first half understated (sole-arbitrator appointments are
|
||||||
|
offered **now** and are not gated by Q.Arb — §4 Offerings), and the second was
|
||||||
|
unsourced when written. §4 now carries rows for all three forms.
|
||||||
|
|
||||||
|
The shape of the bullet still stands, so here is a sentence that fits it and
|
||||||
|
clears the register: *"I accept sole, party-appointed and co-arbitration
|
||||||
|
appointments. The Q.Arb designation commenced in August 2026; C.Med-Arb is the
|
||||||
|
endpoint."* The limit being named is the **stage of the arc**, stated plainly —
|
||||||
|
Pouya's instruction is that being open about it is the differentiator, so do
|
||||||
|
not hedge it into vagueness and do not drop it. (**No tribunal-secretary
|
||||||
|
work** — D14
|
||||||
|
removed the rate and bars offering it; see `docs/07-fees.md`.) Precision about
|
||||||
what you cannot yet do makes the rest believable.
|
what you cannot yet do makes the rest believable.
|
||||||
- Plain words over Latin. "Without prejudice" survives because it is a term of
|
- Plain words over Latin. "Without prejudice" survives because it is a term of
|
||||||
art; *inter alia* does not.
|
art; *inter alia* does not.
|
||||||
@@ -49,8 +63,9 @@ detect padding instantly and discount everything after it.
|
|||||||
|
|
||||||
This framing is **interim** — see `AGENTS.md` §12 R1. Raise it with Pouya
|
This framing is **interim** — see `AGENTS.md` §12 R1. Raise it with Pouya
|
||||||
rather than letting it settle in by default.
|
rather than letting it settle in by default.
|
||||||
- Superlatives. No "leading", "premier", "top-rated", "best". LSO marketing rules,
|
- Superlatives. No "leading", "premier", "top-rated", "best". They are
|
||||||
and they read as insecure.
|
unverifiable, they read as insecure, and marketing rules for regulated
|
||||||
|
professions treat them as suspect.
|
||||||
- Outcome language that could be read as a guarantee.
|
- Outcome language that could be read as a guarantee.
|
||||||
- "Passionate", "dedicated", "committed", "proven track record", "results-driven",
|
- "Passionate", "dedicated", "committed", "proven track record", "results-driven",
|
||||||
"leverage", "synergy", "solutions".
|
"leverage", "synergy", "solutions".
|
||||||
@@ -68,15 +83,34 @@ detect padding instantly and discount everything after it.
|
|||||||
Reused, adapted, across the hero, the About page, and the PDF bio:
|
Reused, adapted, across the hero, the About page, and the PDF bio:
|
||||||
|
|
||||||
> The dispute resolution practice of Pouya Lajevardi — a credentialed neutral
|
> The dispute resolution practice of Pouya Lajevardi — a credentialed neutral
|
||||||
> who is also a working litigator and a practising machine-learning and
|
> who is also close to live litigation and a practising machine-learning and
|
||||||
> infrastructure engineer. Built for commercial, construction, energy,
|
> infrastructure engineer. Built for commercial, construction, energy,
|
||||||
> technology, and cross-cultural disputes that turn on facts most neutrals take
|
> technology, and cross-cultural disputes that turn on the contract, the code,
|
||||||
> on faith: the contract, the code, the engineering documents, and the
|
> the engineering documents, and the regulatory overlay around them.
|
||||||
> regulatory overlay around them.
|
|
||||||
|
|
||||||
Every version of this must survive the §4 check. It does: each element is
|
Every version of this must survive the §4 check. It does: each element is
|
||||||
verified.
|
verified.
|
||||||
|
|
||||||
|
**AMENDED 2026-08-27 — `AGENTS.md` Q41(b). The statement read *"disputes that
|
||||||
|
turn on facts most neutrals take on faith: the contract…"* and the comparative
|
||||||
|
is struck.** It was not restored, and Pouya gave two reasons, the second of
|
||||||
|
which is the one to remember:
|
||||||
|
|
||||||
|
> "That is an unverifiable empirical claim about other practitioners, and
|
||||||
|
> comparative claims must be factual and verifiable. **It is also weaker copy:
|
||||||
|
> assert his capability, not the field's incapability.** Rewrite to claim only
|
||||||
|
> about himself — 'built for disputes that turn on the contract, the code, and
|
||||||
|
> the engineering documents'. Same force, nothing to defend."
|
||||||
|
|
||||||
|
So the compliance objection and the editorial objection point the same way.
|
||||||
|
The checklist item below — *"any comparative claim is factual and verifiable"* —
|
||||||
|
had been overridden in practice by the fact that this paragraph was **approved
|
||||||
|
copy**, which is how an unverifiable claim ends up inside the document that
|
||||||
|
forbids it. The approved copy is what changed.
|
||||||
|
|
||||||
|
**This is now the ONLY sanctioned form of the statement.** Any earlier draft
|
||||||
|
carrying the comparative is superseded, wherever it is quoted.
|
||||||
|
|
||||||
## Approved headline options
|
## Approved headline options
|
||||||
|
|
||||||
From the content brief; all three sit honestly with the practice.
|
From the content brief; all three sit honestly with the practice.
|
||||||
@@ -84,7 +118,12 @@ From the content brief; all three sit honestly with the practice.
|
|||||||
1. *A mediator who reads the contract, the code, and the room.* — **recommended.**
|
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.
|
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.*
|
2. *Engineered for the cases that don't fit a courtroom.*
|
||||||
3. *Disputes resolved by someone who has been on every side of one.*
|
3. ~~*Disputes resolved by someone who has been on every side of one.*~~
|
||||||
|
**Does not clear §4 as written** (flagged 2026-08-26). "Every side" asserts
|
||||||
|
having acted as party, as counsel, and as neutral; §4 verifies the neutral
|
||||||
|
role and *active litigation exposure*, not the other two. Left in place so
|
||||||
|
the option is not silently re-invented — but it cannot be chosen without a §4
|
||||||
|
row to choose it from.
|
||||||
|
|
||||||
## The credential row
|
## The credential row
|
||||||
|
|
||||||
@@ -93,10 +132,59 @@ Three slots, never counts:
|
|||||||
| Slot | Value | Label |
|
| Slot | Value | Label |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| 1 | **Q.Med** | ADRIC / ADRIO designation |
|
| 1 | **Q.Med** | ADRIC / ADRIO designation |
|
||||||
| 2 | **JD + ML** | Law and engineering |
|
| 2 | **JD + ML** | Legal training and engineering practice |
|
||||||
| 3 | **EN · FA** | Bilingual practice |
|
| 3 | **EN · FA** | Bilingual practice |
|
||||||
|
|
||||||
Fourth slot where the layout has one: **Q.Arb** — in progress.
|
**Slot 2's label changed on 2026-08-27 (Pouya, `AGENTS.md` Q37).** It read
|
||||||
|
*"Law and engineering"*. His reasoning: *"The parallel was doing the implying — a
|
||||||
|
degree and a practice under one noun. The asymmetry is the honest part."* A JD is
|
||||||
|
a degree; engineering is a practice, and a verified one. Setting them in parallel
|
||||||
|
invited the reader to supply the symmetry, and for "Law" the missing half is a
|
||||||
|
licence — which D13 bars by implication as hard as by assertion. The replacement
|
||||||
|
is longer and deliberately lopsided. Do not tidy it back into a parallel.
|
||||||
|
|
||||||
|
**AND THE RULE IS NOT SCOPED TO THIS LABEL — `AGENTS.md` Q41(a), Pouya
|
||||||
|
2026-08-27.** Q37 was asked about a credential label; the reasoning applies to
|
||||||
|
every surface, prose included:
|
||||||
|
|
||||||
|
> "Yes, Q37's reasoning reaches prose. The implication test applies everywhere,
|
||||||
|
> not just to labels. **Prose has more room, so it is easier to satisfy: state
|
||||||
|
> the asymmetry explicitly rather than relying on a parallel construction to
|
||||||
|
> carry it.**"
|
||||||
|
|
||||||
|
Read the second half carefully, because it sets a **higher** bar for prose, not
|
||||||
|
a looser one. Deleting the parallel is only half the job — a reader can still
|
||||||
|
supply the missing symmetry from silence. Prose has room to say which half is
|
||||||
|
which, so it must. Naming the legal half as **training** is what makes the licence
|
||||||
|
implication impossible rather than merely absent.
|
||||||
|
|
||||||
|
**THE SENTENCE IS A CONSTANT, NOT COPY TO BE RETYPED** — `ASYMMETRY_LINE` in
|
||||||
|
`src/data/site.ts`:
|
||||||
|
|
||||||
|
> "The two halves are not the same kind of thing, and the asymmetry is the honest
|
||||||
|
> part. A law degree on one side. A working engineering practice on the other.
|
||||||
|
> One is training I hold. The other is work I still do."
|
||||||
|
|
||||||
|
It lives beside `ROLE` for the reason that file gives: *"these are the two where
|
||||||
|
the wording IS the compliance."* This paragraph previously quoted it inline and
|
||||||
|
cited it as living at "`/`, §The approach" — and by then it had been typed into
|
||||||
|
`/about/` too, so there were **three copies and two of them had already
|
||||||
|
diverged**: a comma here and on `/`, full stops on `/about/`, all inside the
|
||||||
|
session that wrote them. Consume the constant on any page that needs the
|
||||||
|
sentence. Do not retype it, do not paraphrase it, and do not quote a variant of
|
||||||
|
it in a spec.
|
||||||
|
|
||||||
|
Fourth slot where the layout has one: **Q.Arb — commenced August 2026.** Use
|
||||||
|
that wording, not "in progress": §4 pins it, and the weaker form drifts toward
|
||||||
|
"nearly complete", which §4 Forbidden bars outright.
|
||||||
|
|
||||||
|
**On the home page the fourth slot IS used, and it is not optional there.**
|
||||||
|
`docs/01` §`/` says "Three slots"; §4's paired-disclosure condition is the higher
|
||||||
|
authority and requires that wherever the site offers arbitration it "states
|
||||||
|
plainly" the stage of the arc. `/` says *arbitrator* in its opening sentence, so
|
||||||
|
the stage belongs on the same page rather than only in the footer. Rendered as
|
||||||
|
value `Q.Arb` over label `Commenced August 2026` — the same wording, with the
|
||||||
|
em-dash carried by the layout instead of by the string.
|
||||||
|
|
||||||
The substitution principle (`AGENTS.md` §4): wherever the design wants a "how
|
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
|
many", substitute a longer-arc credential. These are all true at launch and stay
|
||||||
@@ -113,9 +201,17 @@ Hero headline from the approved list. Positioning paragraph above. CTAs:
|
|||||||
dispute — and keeps the infinity metaphor: *disputes are loops; the work is
|
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".
|
redrawing the loop into a line.* First person: "my mark", not "our mark".
|
||||||
|
|
||||||
|
> ⚠️ **"law and engineering" IS THE STRUCK CONSTRUCTION. Do not lift this
|
||||||
|
> sentence into copy.** The *argument* it names is Pouya's and stands; the noun
|
||||||
|
> pair carrying it is what Q37 struck and Q41(a) extended to prose. It reached
|
||||||
|
> the page once already, as *"Law and engineering are not blended here"* — the
|
||||||
|
> struck parallel relocated from the credential label into body copy, one day
|
||||||
|
> after it was struck, and strengthened by attributing both halves to him
|
||||||
|
> personally. A spec phrase describing an argument is not approved copy.
|
||||||
|
|
||||||
### About
|
### About
|
||||||
400–600 words of narrative, then structured credentials. Tell the three tracks
|
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
|
as one arc, not three lists: a JD and active litigation exposure; a parallel
|
||||||
career in machine learning and infrastructure engineering; a company run
|
career in machine learning and infrastructure engineering; a company run
|
||||||
alongside both. The arc is the point — the credentialing pathway from Q.Med
|
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
|
through Q.Arb to C.Med-Arb is stated openly as in progress. The brief treats
|
||||||
@@ -149,8 +245,29 @@ neither.
|
|||||||
Five steps with real timing. Say what happens if the matter does not settle —
|
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.
|
counsel want to know the downside shape before they commit a client's day.
|
||||||
|
|
||||||
|
**AMENDED 2026-08-27 — `AGENTS.md` Q43, and it overrides this section's previous
|
||||||
|
reading.** "Real timing" was being read as *barring* the word "typical", which
|
||||||
|
is why the step-2 build shipped the five timings as bare numbers and escalated
|
||||||
|
the question instead of framing them. Pouya ruled the other way:
|
||||||
|
|
||||||
|
> "The five process timings are **service commitments, same class as Q27's
|
||||||
|
> response time** — not facts about Pouya, so they need framing, not a Verified
|
||||||
|
> row. Present them as the TYPICAL shape of an engagement, explicitly not a
|
||||||
|
> guarantee: mediation timing depends on party and counsel availability, which
|
||||||
|
> he does not control. **Published as typical, they are honest and useful;
|
||||||
|
> published as commitments, the first matter that slips makes the page false.**"
|
||||||
|
|
||||||
|
So: the **numbers do not change** — softening them was never the fix and
|
||||||
|
inventing them was never on. What "real timing" bars is a *vague* timing
|
||||||
|
("promptly", "in a matter of weeks"), not an honest statement of what the
|
||||||
|
numbers are. The framing is `PROCESS_FRAMING` in `src/data/site.ts` and it is
|
||||||
|
**not optional**: every page that renders the steps renders it, adjacent to the
|
||||||
|
numbers rather than in a section lede above them. A reader who scans the strip
|
||||||
|
and skips the lede has read a commitment.
|
||||||
|
|
||||||
### Fees
|
### Fees
|
||||||
**Blocked on Q4.** Real numbers or `TODO(pouya)`. Plain table, no "starting from"
|
**Unblocked — Q4/Q14 answered, D14.** Build from the confirmed card in
|
||||||
|
`docs/07-fees.md`. Plain table, no "starting from"
|
||||||
evasions, no "contact for pricing" after promising a rate card.
|
evasions, no "contact for pricing" after promising a rate card.
|
||||||
|
|
||||||
### For parties
|
### For parties
|
||||||
|
|||||||
+81
-20
@@ -1,7 +1,14 @@
|
|||||||
# 04 — Discoverability
|
# 04 — Discoverability
|
||||||
|
|
||||||
The problem this project exists to fix. `AGENTS.md` §2 has the measurements: a
|
The problem this project exists to fix. `AGENTS.md` §2 has the measurements.
|
||||||
server-side fetch of the live site returns three words.
|
|
||||||
|
> **Those measurements are under review — `AGENTS.md` Q34.** They were taken on
|
||||||
|
> 2026-08-25; a re-fetch on 2026-08-26 returned a bundler harness whose real
|
||||||
|
> `<head>` sits JSON-escaped inside a `<script>` and whose application lives in
|
||||||
|
> nine UUID-named files that were not fetched. Some of §2 reproduced exactly
|
||||||
|
> (the 2.2 MB single file, the placeholder `<title>`); some could not be
|
||||||
|
> reproduced from the served HTML at all. **Cite §2, and cite Q34 with it. Do
|
||||||
|
> not put any of these figures in public copy until Q34 closes.**
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -10,13 +17,13 @@ server-side fetch of the live site returns three words.
|
|||||||
| | Now `[verified 2026-08-25]` | Target |
|
| | Now `[verified 2026-08-25]` | Target |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Content in server HTML | `SML Company · DISPUTE RESOLUTION · Unpacking...` | Every word |
|
| Content in server HTML | `SML Company · DISPUTE RESOLUTION · Unpacking...` | Every word |
|
||||||
| Indexable pages | 1 | 19 + articles |
|
| Indexable pages | 1 | 17 + articles (19 fixed URLs, less the two `/legal/*` pages, which are `noindex` and excluded from the sitemap) |
|
||||||
| `<title>` | `SML Company · Dispute Resolution` — pre-rebrand placeholder | Unique per page |
|
| `<title>` | `SML Company · Dispute Resolution` — pre-rebrand placeholder | Unique per page |
|
||||||
| Meta description | none | Unique per page |
|
| Meta description | none | Unique per page |
|
||||||
| `<meta viewport>` | **absent** | Present |
|
| `<meta viewport>` | **absent** | Present |
|
||||||
| Canonical URL | none | Every page |
|
| Canonical URL | none | Every page |
|
||||||
| OG / Twitter tags | none | Every page |
|
| OG / Twitter tags | none | Every page |
|
||||||
| Structured data | none | Person, LegalService, Article, FAQ, Breadcrumb |
|
| Structured data | none | Person, ProfessionalService, Article, FAQ, Breadcrumb |
|
||||||
| `robots.txt` | 403 | Served |
|
| `robots.txt` | 403 | Served |
|
||||||
| Sitemap | none | Generated at build |
|
| Sitemap | none | Generated at build |
|
||||||
| Favicon | none | Full set |
|
| Favicon | none | Full set |
|
||||||
@@ -43,6 +50,15 @@ Every page passes through one `SEO` component. A page without it is not finished
|
|||||||
```
|
```
|
||||||
title 50–60 chars, unique. Pattern: "<Page> · Pouya Lajevardi"
|
title 50–60 chars, unique. Pattern: "<Page> · Pouya Lajevardi"
|
||||||
Home: "Pouya Lajevardi · Mediation & Arbitration · Toronto"
|
Home: "Pouya Lajevardi · Mediation & Arbitration · Toronto"
|
||||||
|
ARTICLES ARE THE EXCEPTION: no " · Pouya Lajevardi" suffix.
|
||||||
|
The suffix is 18 chars, so a headline that already reads
|
||||||
|
50–60 renders at 68–78 — over this ceiling. Measured against
|
||||||
|
the five launch headlines in 03-content-spec.md, the suffix
|
||||||
|
rule fails 5 of 5; without it, 4 of 5 pass. An article's
|
||||||
|
headline IS its <title>; `seoTitle` in the frontmatter
|
||||||
|
overrides it when a headline that reads well is out of range.
|
||||||
|
src/content.config.ts enforces this and names the offending
|
||||||
|
string and its length in the build error.
|
||||||
description 140–160 chars, unique, written for a human, not stuffed
|
description 140–160 chars, unique, written for a human, not stuffed
|
||||||
canonical absolute, https, trailing slash
|
canonical absolute, https, trailing slash
|
||||||
og:title/description/image/url/type/site_name/locale (en_CA)
|
og:title/description/image/url/type/site_name/locale (en_CA)
|
||||||
@@ -50,9 +66,34 @@ twitter:card summary_large_image
|
|||||||
robots index,follow — except /legal/* which is noindex,follow
|
robots index,follow — except /legal/* which is noindex,follow
|
||||||
```
|
```
|
||||||
|
|
||||||
**OG images:** 1200 × 630. Generate at build with `satori` or `astro-og-canvas`
|
**OG images:** 1200 × 630. Never a screenshot.
|
||||||
using the site's own type and palette. One template: display headline on cream,
|
|
||||||
infinity mark, designation line. Never a screenshot.
|
**RULED 2026-08-27 (`AGENTS.md` Q40, R15) — TWO kinds of card, not one, and the
|
||||||
|
generator is deferred to build step 7.** This spec said "one template" for all
|
||||||
|
nineteen pages. Pouya split it:
|
||||||
|
|
||||||
|
> "A portrait is the **right** OG image for `/` and `/about/` — a face is the
|
||||||
|
> strongest social preview for a personal brand. It is the **wrong** one for
|
||||||
|
> nineteen pages, where a typed card carrying the page title would do the work.
|
||||||
|
>
|
||||||
|
> But do not build the generator now and do not leave 'portrait everywhere' as
|
||||||
|
> an untracked interim. **Ship it at step 7 alongside Insights, which needs
|
||||||
|
> per-article cards anyway — one build, one dependency, one review.**"
|
||||||
|
|
||||||
|
So:
|
||||||
|
|
||||||
|
| Pages | Card |
|
||||||
|
|---|---|
|
||||||
|
| `/` and `/about/` | The **portrait** crop, `src/assets/og-portrait.jpg`. Not an interim — the decided answer |
|
||||||
|
| Every other page | Generated at build with `satori` or `astro-og-canvas`, using the site's own type and palette: display headline on cream, infinity mark, designation line |
|
||||||
|
| Each article | Per-article card from the same generator — the reason the two jobs are one build |
|
||||||
|
|
||||||
|
**Until step 7 every page shares the portrait, and that is a RECORDED interim
|
||||||
|
that blocks cutover, not build step 3.** It is tracked as **R15** in
|
||||||
|
`AGENTS.md` §12 with its removal trigger, because a link preview nobody on the
|
||||||
|
team ever sees is exactly the kind of interim that becomes permanent by
|
||||||
|
never being raised. The dependency choice is made against R11 on the day, not
|
||||||
|
recalled from this paragraph.
|
||||||
|
|
||||||
## Structured data
|
## Structured data
|
||||||
|
|
||||||
@@ -60,8 +101,8 @@ JSON-LD only. Validate against Google's Rich Results Test before cutover.
|
|||||||
|
|
||||||
| Type | Where | Notes |
|
| 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` |
|
| `Person` | `/about/`, referenced site-wide | `name`, `jobTitle`, `description`, `alumniOf` (Bond University), `knowsLanguage` (en, fa), `hasCredential` (Q.Med), `sameAs` (LinkedIn), `image`. **`jobTitle` = "Director of Firm Operations"; omit `worksFor`** — populating it either names the boutique (D16) or misstates the employer |
|
||||||
| `LegalService` | Home | `areaServed` Toronto/Ontario, `serviceType` Mediation/Arbitration, `provider` → Person, `priceRange` once `/fees/` is real |
|
| `ProfessionalService` | Home | `areaServed` Toronto/Ontario, `serviceType` Mediation/Arbitration, `provider` → Person, `priceRange` once `/fees/` is real. **Never `LegalService`** — schema.org defines it as a business providing legal advice and *representation*, which asserts in machine-readable form exactly what D13 bars and §4 Forbidden calls out |
|
||||||
| `Service` | Each practice page | `serviceType`, `provider` → Person, `areaServed` |
|
| `Service` | Each practice page | `serviceType`, `provider` → Person, `areaServed` |
|
||||||
| `Article` | Each article | `headline`, `description`, `datePublished`, `dateModified`, `author` → Person, `image` |
|
| `Article` | Each article | `headline`, `description`, `datePublished`, `dateModified`, `author` → Person, `image` |
|
||||||
| `BreadcrumbList` | All nested pages | Matches visible breadcrumbs |
|
| `BreadcrumbList` | All nested pages | Matches visible breadcrumbs |
|
||||||
@@ -73,15 +114,21 @@ to be machine-readable.
|
|||||||
|
|
||||||
## Crawlability
|
## Crawlability
|
||||||
|
|
||||||
**`public/robots.txt`:**
|
**`public/robots.txt` is the artefact — read it, do not read a copy of it
|
||||||
|
here.** This spec used to reproduce the file inline and the reproduction had
|
||||||
|
already drifted from it by 2026-08-26, which is the failure mode the `AGENTS.md`
|
||||||
|
§7 rule exists to stop.
|
||||||
|
|
||||||
```
|
**It disallows nothing, and that is deliberate.** This spec previously
|
||||||
User-agent: *
|
prescribed `Disallow: /legal/` alongside `noindex` on those pages, and the two
|
||||||
Allow: /
|
cancel each other: a crawler forbidden to *fetch* a URL never reads the
|
||||||
Disallow: /legal/
|
`noindex` on it. `/legal/privacy/` and `/legal/terms/` are linked from the
|
||||||
|
footer of every page, so they are discovered regardless — and the likely result
|
||||||
Sitemap: https://adr.smlcompany.ca/sitemap-index.xml
|
of the pair was Google listing the bare URLs as "no information available", the
|
||||||
```
|
opposite of the intent, with the directive that would have suppressed them
|
||||||
|
sitting unread behind the wall. **`noindex` is what de-indexes; `Disallow` is
|
||||||
|
what prevents fetching.** Use the one that matches the problem, and never both
|
||||||
|
on the same path.
|
||||||
|
|
||||||
Do not block AI crawlers. Being read by an assistant that a general counsel is
|
Do not block AI crawlers. Being read by an assistant that a general counsel is
|
||||||
using to shortlist neutrals is the point.
|
using to shortlist neutrals is the point.
|
||||||
@@ -108,7 +155,16 @@ Core Web Vitals are a ranking input, and the current build fails all of them.
|
|||||||
| CLS | < 0.05 |
|
| CLS | < 0.05 |
|
||||||
| INP | < 150 ms |
|
| INP | < 150 ms |
|
||||||
| JS per route | < 100 KB |
|
| JS per route | < 100 KB |
|
||||||
| Lighthouse (mobile) | ≥ 95 all four categories |
|
| Lighthouse (mobile) | ≥ 95 all four categories — **not measurable until step 7, see below** |
|
||||||
|
|
||||||
|
> ⚠️ **Lighthouse verification is UNAVAILABLE until build step 7.** `@lhci/cli`
|
||||||
|
> was removed on 2026-08-26 — it was the sole source of all 10 `npm audit`
|
||||||
|
> findings (7 high), `0.15.1` is `latest` so there was no clean upgrade, and it
|
||||||
|
> could not run at all with no pages and no `lighthouserc`. The budget below is
|
||||||
|
> not suspended; the tool that measures it is absent. Re-add at step 7 under
|
||||||
|
> `AGENTS.md` R11, checking for a patched release rather than assuming `0.15.1`
|
||||||
|
> is still the ceiling. Until then, a run that skips this is skipping something
|
||||||
|
> known — not something forgotten. `AGENTS.md` §7 has the state.
|
||||||
|
|
||||||
How: static HTML, self-hosted preloaded subset fonts, AVIF/WebP with explicit
|
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
|
dimensions, critical CSS inlined, no third-party scripts on any page except the
|
||||||
@@ -119,14 +175,19 @@ booking embed on `/contact/` — and that one is lazy-loaded behind a click.
|
|||||||
Not code, but it belongs in the launch checklist: Google Business Profile for the
|
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
|
practice; ADRIC and ADRIO directory listings pointing at the site; a LinkedIn
|
||||||
profile whose headline and Featured section match the brand (brief §VIII);
|
profile whose headline and Featured section match the brand (brief §VIII);
|
||||||
consistent name, address, and phone across all of them.
|
consistent naming across all of them. **Not "name, address and phone"** — §4
|
||||||
|
publishes no phone number and no street address, only "Toronto, Ontario; by
|
||||||
|
appointment". Directory forms that demand a full NAP get what §4 verifies and
|
||||||
|
nothing more.
|
||||||
|
|
||||||
## Post-launch verification
|
## Post-launch verification
|
||||||
|
|
||||||
- [ ] `curl -s https://adr.smlcompany.ca/ | grep -c "<h1"` returns ≥ 1
|
- [ ] `curl -s https://adr.smlcompany.ca/ | grep -c "<h1"` returns ≥ 1
|
||||||
- [ ] Every page renders its full text with JavaScript disabled
|
- [ ] Every page renders its full text with JavaScript disabled
|
||||||
- [ ] Rich Results Test passes on Person, LegalService, Article
|
- [ ] Rich Results Test passes on Person, ProfessionalService, Article
|
||||||
- [ ] OG preview renders correctly in LinkedIn Post Inspector and Slack
|
- [ ] OG preview renders correctly in LinkedIn Post Inspector and Slack
|
||||||
- [ ] Sitemap submitted to Google Search Console and Bing
|
- [ ] Sitemap submitted to Google Search Console and Bing
|
||||||
- [ ] No page returns 200 for a URL that should 404
|
- [ ] No page returns 200 for a URL that should 404
|
||||||
- [ ] Lighthouse ≥ 95 mobile on `/`, `/about/`, one practice page, one article
|
- [ ] Lighthouse ≥ 95 mobile on `/`, `/about/`, one practice page, one article
|
||||||
|
— **blocked until `@lhci/cli` is re-added at step 7.** Do not tick this box
|
||||||
|
from a manual Chrome DevTools run and call it the same check
|
||||||
|
|||||||
+77
-32
@@ -1,7 +1,9 @@
|
|||||||
# 05 — Intake, booking, and data handling
|
# 05 — Intake, booking, and data handling
|
||||||
|
|
||||||
Authority: `AGENTS.md` §3 D10 — rebuilt intake form plus calendar booking.
|
Authority: `AGENTS.md` §3 D10 — rebuilt intake form plus calendar booking.
|
||||||
Existing infrastructure is documented in `AWS-Hosting-Guide.md` Parts 8–10.
|
Existing infrastructure is authoritative in `AGENTS.md` §7. How it was built is
|
||||||
|
recorded in `docs/reference/AWS-Hosting-Guide.md` Parts 8–10 — a historical
|
||||||
|
record with a do-not-execute banner, superseded by §7 wherever they disagree.
|
||||||
**Read that guide before changing anything**; the resources already exist and
|
**Read that guide before changing anything**; the resources already exist and
|
||||||
were built by hand in the console.
|
were built by hand in the console.
|
||||||
|
|
||||||
@@ -10,7 +12,7 @@ were built by hand in the console.
|
|||||||
## What exists today
|
## What exists today
|
||||||
|
|
||||||
API Gateway (HTTP API) → Lambda → DynamoDB, with SES for notification email and
|
API Gateway (HTTP API) → Lambda → DynamoDB, with SES for notification email and
|
||||||
a verified sender on `smlcompany.ca`. `[verified 2026-08-25 — AWS-Hosting-Guide.md]`
|
a verified sender on `smlcompany.ca`. `[verified 2026-08-26 — AGENTS.md §7]`
|
||||||
|
|
||||||
The shape is right. This is a hardening and rework pass, not a replacement.
|
The shape is right. This is a hardening and rework pass, not a replacement.
|
||||||
|
|
||||||
@@ -71,9 +73,11 @@ Client-side validation is a convenience. **The Lambda re-validates everything.**
|
|||||||
|
|
||||||
## Storage
|
## Storage
|
||||||
|
|
||||||
DynamoDB, `ca-central-1` — **Canadian data residency is a real selling point for
|
DynamoDB, in the region `AGENTS.md` §7 records. **Canadian data residency is
|
||||||
a Canadian legal practice, and the privacy policy will say so.** Confirm the
|
worth stating in the privacy policy**: parties describing a live dispute are
|
||||||
existing table's region and migrate if it is elsewhere (**Q10**).
|
handing over sensitive material, and where it comes to rest is a fair question
|
||||||
|
for them to ask. Confirm the existing table's region and migrate if it is
|
||||||
|
elsewhere — §7 has the table name and region.
|
||||||
|
|
||||||
| Attribute | |
|
| Attribute | |
|
||||||
|---|---|
|
|---|---|
|
||||||
@@ -102,37 +106,64 @@ personal information only as long as necessary. Whatever number ships must match
|
|||||||
SES on submission:
|
SES on submission:
|
||||||
|
|
||||||
- **To Pouya:** the full submission, plainly formatted, replyable to the inquirer.
|
- **To Pouya:** the full submission, plainly formatted, replyable to the inquirer.
|
||||||
- **To the inquirer:** confirmation of receipt, expected response time, a repeat
|
- **To the inquirer:** confirmation of receipt, the response-time commitment, a
|
||||||
of the no-retainer language, and a link to the privacy policy. This email is
|
repeat of the no-retainer language, and a link to the privacy policy. This
|
||||||
the reason the form beats a `mailto:` link.
|
email is the reason the form beats a `mailto:` link.
|
||||||
|
|
||||||
**Email authentication — verified 2026-08-26, and it is not in place.**
|
**The response time is a public commitment — two business days** (§4,
|
||||||
|
Q27). Render it from `SITE.responseTime` / `SITE.responseTimeShort` in
|
||||||
|
`src/data/site.ts`; never retype it. It must read identically here, on
|
||||||
|
`/contact/`, and in any bio.
|
||||||
|
|
||||||
A DNS query of `smlcompany.ca` found **no SPF record and no DMARC record**. Mail
|
**SES production access is granted** (Q19, 2026-08-26) — mail reaches unverified
|
||||||
is on Google Workspace (MX `1 smtp.google.com`) with Google DKIM configured, and
|
recipients, so the inquirer confirmation works. See §7 for the account state.
|
||||||
the SES domain identity reports verified for sending — but neither SPF nor DMARC
|
|
||||||
exists.
|
|
||||||
|
|
||||||
**What is already in place** (Namecheap DNS and the SES console, both inspected
|
### Bounce and complaint monitoring
|
||||||
2026-08-26):
|
|
||||||
|
|
||||||
| Record | Status |
|
Configured 2026-08-26; the resource names, thresholds and current state are in
|
||||||
|---|---|
|
`AGENTS.md` §7. What matters here is why it is a real control rather than a
|
||||||
| SES DKIM — `3zsnvsjg…`, `jejgp7na3…`, `xpiwyftpo…` `._domainkey` | **Live.** Matches SES exactly. Never delete |
|
formality:
|
||||||
| SES DKIM — `f5puwearz…`, `jdue2r22c…`, `kznn3cklv…` `._domainkey` | Orphans from an earlier verification. Inert. **Leave them** — deleting the wrong three breaks DKIM |
|
|
||||||
| `google._domainkey` TXT | Google Workspace DKIM. Never delete |
|
|
||||||
| Two CNAMEs → `jkddzztszm.acm-validations.aws` | ACM certificate validation. **Never delete** — breaks HTTPS at the next renewal |
|
|
||||||
| `adr` CNAME → `d26v23dhgsp2ta.cloudfront.net` | The site |
|
|
||||||
| Custom MAIL FROM | **Not configured.** Optional; would add SPF alignment |
|
|
||||||
|
|
||||||
Add both of these; neither conflicts with anything above:
|
**At this volume a single bad address is a threshold event.** SES suspends
|
||||||
|
sending above roughly a 5% bounce rate. Under 100 messages a month, five bounces
|
||||||
|
crosses it — and an intake form is exactly where mistyped addresses arrive. The
|
||||||
|
alarms sit well below that line so there is room to react.
|
||||||
|
|
||||||
| Host | Type | Value |
|
**Bounces and complaints are handled by SES email feedback forwarding**, which is
|
||||||
|---|---|---|
|
on by default, **not** by an SNS feedback topic. That is deliberate: at this
|
||||||
| `@` | TXT | `v=spf1 include:_spf.google.com include:amazonses.com ~all` |
|
volume there is nothing to consume a programmatic feed, and an unused SNS topic
|
||||||
| `_dmarc` | TXT | `v=DMARC1; p=none; rua=mailto:info@smlcompany.ca; fo=1` |
|
is one more thing to keep correct. Revisit when code needs to *act* on a
|
||||||
|
bounce — suppression lists, retry logic, marking a record undeliverable.
|
||||||
|
|
||||||
A domain may publish **only one** `v=spf1` record, so both senders go in one
|
> **The alarms currently notify nobody.** §7 records the `ses-alerts` email
|
||||||
|
> subscription as **pending confirmation**. An unconfirmed SNS subscription
|
||||||
|
> drops every message, so until the confirmation link is clicked the alarms
|
||||||
|
> fire into nothing. This is the first thing to check if `/contact/` ships.
|
||||||
|
|
||||||
|
**Email authentication — in place as of 2026-08-26 (Q20).**
|
||||||
|
|
||||||
|
SPF and DMARC are both live and independently verified (Q20); mail is on Google
|
||||||
|
Workspace with Google DKIM configured, and the SES domain identity is verified
|
||||||
|
for sending. **The record values, the MX, and the region are in `AGENTS.md` §7 —
|
||||||
|
not restated here.** An earlier version of this spec asserted that neither SPF
|
||||||
|
nor DMARC existed; that was true when written and is no longer, which is the
|
||||||
|
whole argument for citing §7 rather than copying it.
|
||||||
|
|
||||||
|
**What is already in place.** `AGENTS.md` §7 is the record — resource IDs, DNS
|
||||||
|
records, DKIM token sets, and their verification state all live there and are not
|
||||||
|
restated here. Read §7 before touching DNS.
|
||||||
|
|
||||||
|
Two points from §7 that this spec depends on, cited rather than copied:
|
||||||
|
|
||||||
|
- **Only one of the two SES DKIM token sets resolves.** §7 names both sets and
|
||||||
|
marks which is which. The resolving set is what DMARC alignment rests on;
|
||||||
|
deleting it breaks intake mail authentication silently. The other set is
|
||||||
|
NXDOMAIN and inert. **Do not act on any DKIM list that is not §7's.**
|
||||||
|
- **SES has no custom MAIL FROM**, so SPF is unaligned and SES satisfies DMARC
|
||||||
|
through DKIM alone.
|
||||||
|
|
||||||
|
Notes that mattered when these were added, kept because they matter again on
|
||||||
|
any future edit: a domain may publish **only one** `v=spf1` record, so both senders go in one
|
||||||
string. Namecheap TXT values take **no surrounding quotes** — quoting them stores
|
string. Namecheap TXT values take **no surrounding quotes** — quoting them stores
|
||||||
the quotes literally and breaks the record.
|
the quotes literally and breaks the record.
|
||||||
|
|
||||||
@@ -140,7 +171,8 @@ the quotes literally and breaks the record.
|
|||||||
SES here. Without a custom MAIL FROM domain, SES uses an envelope sender at
|
SES here. Without a custom MAIL FROM domain, SES uses an envelope sender at
|
||||||
`amazonses.com`, so its SPF pass is not *aligned* with `smlcompany.ca` and does
|
`amazonses.com`, so its SPF pass is not *aligned* with `smlcompany.ca` and does
|
||||||
not satisfy DMARC. **SES satisfies DMARC through DKIM alignment** — that is what
|
not satisfy DMARC. **SES satisfies DMARC through DKIM alignment** — that is what
|
||||||
the six CNAMEs above are doing, and it already works. The SPF record's real job
|
the **three resolving** DKIM CNAMEs above are doing, and it already works. (Six
|
||||||
|
are present in the zone; only the `f5pu` / `jdue` / `kznn` set answers.) The SPF record's real job
|
||||||
is authenticating **Google Workspace** mail, which currently has no SPF at all.
|
is authenticating **Google Workspace** mail, which currently has no SPF at all.
|
||||||
`include:amazonses.com` is harmless and becomes useful if a custom MAIL FROM
|
`include:amazonses.com` is harmless and becomes useful if a custom MAIL FROM
|
||||||
domain is configured later.
|
domain is configured later.
|
||||||
@@ -152,7 +184,7 @@ no visibility.
|
|||||||
|
|
||||||
**Do not delete the ACM validation CNAMEs.** They are how the certificate for
|
**Do not delete the ACM validation CNAMEs.** They are how the certificate for
|
||||||
`adr.smlcompany.ca` auto-renews. Removing them breaks HTTPS at the next renewal
|
`adr.smlcompany.ca` auto-renews. Removing them breaks HTTPS at the next renewal
|
||||||
— silently, months later (Q20).
|
— silently, months later.
|
||||||
|
|
||||||
Failure handling: SES failure must never lose the submission. Write to DynamoDB
|
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
|
first, then send. A dead-letter queue on the Lambda, and a CloudWatch alarm on
|
||||||
@@ -174,6 +206,18 @@ An embedded scheduler for the 30–45 minute confidential intake call
|
|||||||
|
|
||||||
## Security headers
|
## Security headers
|
||||||
|
|
||||||
|
> **Note added 2026-08-26 — `style-src` has acquired a dependency.** The site
|
||||||
|
> now ships inline `style="…"` attributes that are load-bearing rather than
|
||||||
|
> decorative: `InfinityMark.astro` sets its own `block-size` that way, and the
|
||||||
|
> step-1 proof sheet rendered computed swatches with it (that page was deleted
|
||||||
|
> at build step 2; the mechanism is what matters here). They are fine under
|
||||||
|
> `style-src 'self' 'unsafe-inline'` as specified below. They would **not**
|
||||||
|
> survive a move to hashed or nonce'd styles — the infinity mark would collapse.
|
||||||
|
> Price that before tightening `style-src`, and read the components first.
|
||||||
|
> `script-src` is unaffected, and has got easier: the site ships **zero**
|
||||||
|
> JavaScript, so `script-src 'self'` needs no hash and no nonce (`AGENTS.md`
|
||||||
|
> §7). That is why the reveal moved from an inline observer to CSS.
|
||||||
|
|
||||||
Set at CloudFront via a response-headers policy:
|
Set at CloudFront via a response-headers policy:
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -204,7 +248,8 @@ relationship · cookie and analytics disclosure · last-updated date.
|
|||||||
|
|
||||||
If analytics ship, prefer a cookieless privacy-preserving tool (Plausible,
|
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
|
Fathom). GA4 on a page collecting legal-dispute information is a poor fit for a
|
||||||
practice whose privacy posture is part of its offer (**Q11**).
|
practice whose privacy posture is part of its offer — D15 settles this:
|
||||||
|
Plausible or Fathom, cookieless, no consent banner.
|
||||||
|
|
||||||
## Definition of done
|
## Definition of done
|
||||||
|
|
||||||
|
|||||||
+196
-81
@@ -1,19 +1,22 @@
|
|||||||
# 06 — Deployment and cutover
|
# 06 — Deployment and cutover
|
||||||
|
|
||||||
Authority: `AGENTS.md` §3 D3 (git + GitHub Actions → existing S3/CloudFront) and
|
Authority: `AGENTS.md` §3 **D3 as amended 2026-08-26** (git + **Gitea Actions**
|
||||||
D11 (build everything, one clean cutover).
|
→ existing S3/CloudFront) and D11 (build everything, one clean cutover).
|
||||||
Existing infrastructure: `AWS-Hosting-Guide.md`.
|
Existing infrastructure: **`AGENTS.md` §7 is authoritative.**
|
||||||
|
`docs/reference/AWS-Hosting-Guide.md` records how that infrastructure was
|
||||||
|
originally built — it is a historical record carrying a do-not-execute banner,
|
||||||
|
not a procedure, and §7 wins wherever the two disagree (Q24).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Topology
|
## Topology
|
||||||
|
|
||||||
```
|
```
|
||||||
GitHub push to main
|
Gitea push to main
|
||||||
└─ GitHub Actions
|
└─ Gitea Actions (act_runner)
|
||||||
├─ npm ci && npm run build → ./dist
|
├─ npm ci && npm run build → ./dist
|
||||||
├─ assume AWS role via OIDC (no stored keys)
|
├─ static scoped IAM user key (from Gitea secrets — NOT OIDC)
|
||||||
├─ aws s3 sync ./dist s3://<bucket>
|
├─ aws s3 sync ./dist s3://<bucket> (three passes, see Cache policy)
|
||||||
└─ cloudfront create-invalidation
|
└─ cloudfront create-invalidation
|
||||||
Namecheap DNS → CloudFront → S3 (OAC)
|
Namecheap DNS → CloudFront → S3 (OAC)
|
||||||
API Gateway → Lambda → DynamoDB / SES (intake, unchanged path)
|
API Gateway → Lambda → DynamoDB / SES (intake, unchanged path)
|
||||||
@@ -23,15 +26,38 @@ DNS is at **Namecheap, not Route 53** `[verified 2026-08-25]`. Nothing in the
|
|||||||
pipeline touches DNS. Certificate renewal is ACM-automatic as long as the
|
pipeline touches DNS. Certificate renewal is ACM-automatic as long as the
|
||||||
validation CNAME stays in place at Namecheap — **do not delete it.**
|
validation CNAME stays in place at Namecheap — **do not delete it.**
|
||||||
|
|
||||||
|
## Today, deploys run locally
|
||||||
|
|
||||||
|
**`npm run deploy`** (`scripts/deploy-local.sh`) is the current path. It runs
|
||||||
|
the same guard, the same three sync passes in the same order with the same
|
||||||
|
cache headers, and the same invalidation as the workflow — at this scale the
|
||||||
|
pipeline changes only **how a deploy is triggered**, not what it does. Treat the
|
||||||
|
script and the workflow as one artefact in two places: change one, change both.
|
||||||
|
|
||||||
|
Two things block the workflow, and neither is a fact to look up:
|
||||||
|
|
||||||
|
- **`adr-sml-deploy` does not exist** — `aws iam get-user` returns
|
||||||
|
`NoSuchEntity` (§7, Q22). Create it from *Create the user* below.
|
||||||
|
- **Actions are not enabled and no runner is registered** (Q23). The Gitea
|
||||||
|
instance is jointly administered, so both need its second administrator.
|
||||||
|
|
||||||
|
The script **refuses to run as `user/pouya`** — the broadly-permissioned
|
||||||
|
personal user that has been authenticating to this account. See §10.
|
||||||
|
|
||||||
## CI runs on Gitea, not GitHub
|
## CI runs on Gitea, not GitHub
|
||||||
|
|
||||||
`AGENTS.md` D3 as amended, 2026-08-26: self-hosted **Gitea**, repo `adr-sml`,
|
`AGENTS.md` D3 as amended, 2026-08-26: self-hosted **Gitea**. The instance,
|
||||||
local clone at `/Users/pouya/Dev/Websites/adr-sml`.
|
version, and repository are recorded in §7 — the version is comfortably above
|
||||||
|
the floor for the `vars` context, so the first-step guard is belt-and-braces
|
||||||
|
rather than load-bearing.
|
||||||
|
|
||||||
**The live pipeline is `.gitea/workflows/deploy.yml`.** Gitea Actions speaks
|
**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
|
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`
|
three-pass sync, and the cache headers are unchanged. The GitHub Actions original,
|
||||||
stays in the repo as the OIDC reference in case the project ever moves.
|
with its OIDC role assumption, stays in the repo as
|
||||||
|
`docs/reference/github-actions-oidc.yml.example` — deliberately outside
|
||||||
|
`.github/workflows/`, because Gitea falls back to that directory when
|
||||||
|
`.gitea/workflows` is absent.
|
||||||
|
|
||||||
### The one real difference: no OIDC
|
### The one real difference: no OIDC
|
||||||
|
|
||||||
@@ -39,8 +65,9 @@ Gitea is not an AWS OIDC provider. There is no role to assume, so deploys
|
|||||||
authenticate with a **scoped IAM user** whose access key lives only in the
|
authenticate with a **scoped IAM user** whose access key lives only in the
|
||||||
repository's Gitea secrets.
|
repository's Gitea secrets.
|
||||||
|
|
||||||
This is a genuine step down in security from the GitHub setup, and it should be
|
This is a genuine step down in security from an OIDC setup — which was designed
|
||||||
treated as one. The mitigations are the policy scope and the rotation schedule.
|
here but never built — and it should be treated as one. The mitigations are the
|
||||||
|
policy scope and the rotation schedule.
|
||||||
|
|
||||||
**Create the user:**
|
**Create the user:**
|
||||||
|
|
||||||
@@ -62,7 +89,7 @@ treated as one. The mitigations are the policy scope and the rotation schedule.
|
|||||||
{
|
{
|
||||||
"Sid": "WriteSiteObjects",
|
"Sid": "WriteSiteObjects",
|
||||||
"Effect": "Allow",
|
"Effect": "Allow",
|
||||||
"Action": ["s3:PutObject", "s3:PutObjectAcl", "s3:DeleteObject"],
|
"Action": ["s3:PutObject", "s3:DeleteObject"],
|
||||||
"Resource": "arn:aws:s3:::BUCKET_NAME/*"
|
"Resource": "arn:aws:s3:::BUCKET_NAME/*"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -75,12 +102,34 @@ treated as one. The mitigations are the policy scope and the rotation schedule.
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
No `s3:*`. No `cloudfront:*`. No wildcard resources. If a deploy step needs a
|
Four actions on one bucket and one distribution. No `Action: "*"`, no
|
||||||
permission this policy lacks, the correct response is to question the step, not
|
`Resource: "*"` — the only wildcard is `BUCKET_NAME/*`, which scopes to the
|
||||||
to widen the policy.
|
objects of that one bucket. `s3:PutObjectAcl` was dropped on 2026-08-26:
|
||||||
|
`aws s3 sync` does not use it without `--acl`, and it is inert under Origin
|
||||||
|
Access Control with ACLs disabled. If a deploy step needs a permission this
|
||||||
|
policy lacks, the correct response is to question the step, not to widen the
|
||||||
|
policy.
|
||||||
|
|
||||||
3. Create an access key. **Copy it once** — AWS will not show the secret again.
|
3. Create an access key. **Copy it once** — AWS will not show the secret again.
|
||||||
|
|
||||||
|
**`s3:AbortMultipartUpload` is deliberately absent, and here is the actual
|
||||||
|
reason.** `aws s3 sync` switches to multipart above its 8 MB
|
||||||
|
`multipart_threshold`; an interrupted multipart upload then cannot clean up its
|
||||||
|
own parts, and orphaned parts accrue storage charges that do not appear in the
|
||||||
|
bucket listing. What makes that safe today is simply that **nothing here comes
|
||||||
|
close to 8 MB** — the largest file the pipeline
|
||||||
|
uploads is well under it. The biggest source asset is
|
||||||
|
`src/assets/pouya-lajevardi.jpg` at 357,627 bytes `[verified 2026-08-26 — stat]`,
|
||||||
|
Astro emits it smaller still after AVIF/WebP conversion, and the self-hosted font
|
||||||
|
files are smaller again. **Re-measure `./dist` after the first successful build**
|
||||||
|
— that, not the repository, is what gets synced. No lifecycle rule exists; do not describe one
|
||||||
|
as the mitigation, because it is not there.
|
||||||
|
|
||||||
|
**Revisit if any single asset approaches 8 MB** — a video, a large PDF, an
|
||||||
|
un-optimised photograph. At that point either add an S3 lifecycle rule aborting
|
||||||
|
incomplete multipart uploads after 7 days (preferred — it costs no IAM
|
||||||
|
permission), or grant `s3:AbortMultipartUpload` on `BUCKET_NAME/*`.
|
||||||
|
|
||||||
### Gitea configuration
|
### Gitea configuration
|
||||||
|
|
||||||
**Repository → Settings → Actions → Secrets:**
|
**Repository → Settings → Actions → Secrets:**
|
||||||
@@ -90,37 +139,32 @@ to widen the policy.
|
|||||||
| `AWS_ACCESS_KEY_ID` | from the IAM user |
|
| `AWS_ACCESS_KEY_ID` | from the IAM user |
|
||||||
| `AWS_SECRET_ACCESS_KEY` | from the IAM user |
|
| `AWS_SECRET_ACCESS_KEY` | from the IAM user |
|
||||||
|
|
||||||
**The real values** (captured 2026-08-26, `aws-inventory.txt`):
|
**Repository → Settings → Actions → Variables** — not secrets. These are not
|
||||||
|
sensitive, and keeping them as variables means they appear in run logs where
|
||||||
|
they are useful for debugging.
|
||||||
|
|
||||||
| Variable | Value |
|
| Variable | Value |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `AWS_REGION` | `ca-central-1` |
|
| `AWS_REGION` | `AGENTS.md` §7 — Region |
|
||||||
| `S3_BUCKET` | `adr-smlcompany-site` |
|
| `S3_BUCKET` | §7 — S3 bucket |
|
||||||
| `CLOUDFRONT_DISTRIBUTION_ID` | `E1OK7G98KNKUTA` |
|
| `CLOUDFRONT_DISTRIBUTION_ID` | §7 — CloudFront |
|
||||||
| `INTAKE_ENDPOINT` | `https://4tl0m5igkj.execute-api.ca-central-1.amazonaws.com` |
|
| `INTAKE_ENDPOINT` | §7 — Intake API |
|
||||||
| `BOOKING_URL` | *(empty — parked, R6)* |
|
| `BOOKING_URL` | *(empty — parked, R6)* |
|
||||||
|
|
||||||
IAM policy substitutions: `BUCKET_NAME` = `adr-smlcompany-site`,
|
The same four values fill the IAM policy's `BUCKET_NAME`, `ACCOUNT_ID` and
|
||||||
`ACCOUNT_ID` = `327082975128`, `DISTRIBUTION_ID` = `E1OK7G98KNKUTA`.
|
`DISTRIBUTION_ID` placeholders. **They are deliberately not restated here** —
|
||||||
|
§7 is the single source of truth for operational facts, and the copy that goes
|
||||||
|
stale is always the one nobody re-reads. `scripts/aws-discover.sh` regenerates
|
||||||
|
them from AWS if §7 ever needs re-verifying.
|
||||||
|
|
||||||
> **Read this before creating the key.** Account `327082975128` is shared across
|
> **Read this before creating the key.** The AWS account is **not** a
|
||||||
> `meshkinilaw.ca`, `demesne.media`, `orynenergy.ca`, `lajirugs.ca`, and
|
> single-project account: it is shared with several unrelated sites and with a
|
||||||
> `mlp-clientdb-prod-backups` — a law firm's client-database backups. A static
|
> bucket whose name indicates another business's production client-database
|
||||||
> deploy key for a marketing site lives in the same account. The scoped policy is
|
> backups. `AGENTS.md` §10 has the specifics and the account identifier; they
|
||||||
> what keeps a compromised Gitea runner from reaching any of that. Do not widen
|
> are kept there rather than repeated here. A static deploy key for a marketing
|
||||||
> it, and never put the `user/pouya` credentials in CI.
|
> site lives in that same account, and the scoped policy is what keeps a
|
||||||
|
> compromised Gitea runner from reaching any of it. Do not widen it, and never
|
||||||
**Repository → Settings → Actions → Variables** (not secrets — these are not
|
> put the `user/pouya` credentials in CI.
|
||||||
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
|
### A runner must exist
|
||||||
|
|
||||||
@@ -130,8 +174,25 @@ organisation, and Actions enabled both site-wide in `app.ini`
|
|||||||
queues silently and never runs — which looks exactly like a broken pipeline.
|
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
|
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
|
`aws sts get-caller-identity` before touching anything. **That check is
|
||||||
fails loudly and early rather than halfway through a sync.
|
narrower than it looks:** `sts:GetCallerIdentity` requires no IAM permission at
|
||||||
|
all, so it succeeds for any valid key regardless of policy. It catches a
|
||||||
|
missing, malformed, or revoked key; it does **not** catch an under-scoped
|
||||||
|
policy, which still fails halfway through a sync and leaves the bucket
|
||||||
|
partially updated. Read it as a key check, not a permissions check.
|
||||||
|
|
||||||
|
### The variable guard runs first
|
||||||
|
|
||||||
|
The workflow's first step — before checkout, before the build, before any AWS
|
||||||
|
call — fails the run if `AWS_REGION`, `S3_BUCKET`, or
|
||||||
|
`CLOUDFRONT_DISTRIBUTION_ID` is empty.
|
||||||
|
|
||||||
|
This exists because Gitea only added the `vars` context in 1.21. On an older
|
||||||
|
instance every `${{ vars.* }}` interpolates to an empty string with no warning,
|
||||||
|
the sync target becomes `s3://`, and the run dies halfway through with an error
|
||||||
|
that names nothing useful. The guard converts that into a clean failure that
|
||||||
|
says which variable is missing — **on every Gitea version**. A recorded version
|
||||||
|
number would have gone stale; the guard does not.
|
||||||
|
|
||||||
### Key rotation — an operational obligation
|
### Key rotation — an operational obligation
|
||||||
|
|
||||||
@@ -149,9 +210,9 @@ whole section exists to bound.
|
|||||||
|
|
||||||
## Finding the AWS identifiers
|
## Finding the AWS identifiers
|
||||||
|
|
||||||
`scripts/aws-discover.sh` collects everything Q10 needs — bucket, distribution
|
`scripts/aws-discover.sh` re-collects the inventory — bucket, distribution
|
||||||
ID, regions, API endpoint, certificate, SES identities, and whether S3 versioning
|
ID, regions, API endpoint, certificate, SES identities, and whether S3 versioning
|
||||||
is on. Read-only; every call is a list or describe.
|
is on. Read-only; no call creates or mutates anything.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
chmod +x scripts/aws-discover.sh
|
chmod +x scripts/aws-discover.sh
|
||||||
@@ -160,25 +221,28 @@ chmod +x scripts/aws-discover.sh
|
|||||||
|
|
||||||
The output contains resource names and IDs but no secrets.
|
The output contains resource names and IDs but no secrets.
|
||||||
|
|
||||||
## Why OIDC and not access keys
|
## Why OIDC would have been better — and why it is unavailable
|
||||||
|
|
||||||
The alternative is a long-lived `AWS_ACCESS_KEY_ID` in GitHub secrets: a
|
> **Do not execute this section.** It describes the design that was rejected
|
||||||
credential that never expires, is invisible once set, and grants its permissions
|
> because Gitea cannot support it. The live procedure is *Create the user* above.
|
||||||
to anyone who can reach the repository. OIDC issues a short-lived token per run,
|
> Nothing here should be created in AWS. Following it would add an unused GitHub
|
||||||
scoped to this repository and this branch.
|
> federation trust to the shared AWS account (`AGENTS.md` §10).
|
||||||
|
|
||||||
One-time setup:
|
A static `AWS_ACCESS_KEY_ID` never expires, is invisible once set, and grants its
|
||||||
|
permissions to anyone who can reach the repository. OIDC issues a short-lived
|
||||||
|
token per run, scoped to one repository and one branch — strictly better, and the
|
||||||
|
reason the rotation schedule above is not optional here.
|
||||||
|
|
||||||
1. IAM → Identity providers → add OIDC provider `token.actions.githubusercontent.com`,
|
It needs an identity provider AWS will federate with. GitHub and GitLab both
|
||||||
audience `sts.amazonaws.com`.
|
publish one; **Gitea and Forgejo do not**, so there is nothing for AWS to trust
|
||||||
2. Create role `adr-site-deploy` trusting that provider, with a condition on
|
and no role to assume. That is the whole of the constraint (D3 as amended).
|
||||||
`token.actions.githubusercontent.com:sub` equal to
|
|
||||||
`repo:<org>/<repo>:ref:refs/heads/main` (**Q9**).
|
If the project ever moves to GitHub, the workflow to adopt is
|
||||||
3. Attach a policy granting **only**: `s3:PutObject`, `s3:DeleteObject`,
|
`docs/reference/github-actions-oidc.yml.example`, and the setup is: register
|
||||||
`s3:ListBucket` on the site bucket, and `cloudfront:CreateInvalidation` on the
|
`token.actions.githubusercontent.com` as an IAM OIDC provider with audience
|
||||||
one distribution. Nothing else. No `s3:*`, no `cloudfront:*`.
|
`sts.amazonaws.com`; create a role trusting it, conditioned on the `sub` claim
|
||||||
4. Store the role ARN, bucket name, and distribution ID as repository
|
matching the repository and `refs/heads/main`; attach the same four-action policy
|
||||||
**variables** (they are not secrets), and reference them in the workflow.
|
given above; then delete `adr-sml-deploy` and its key.
|
||||||
|
|
||||||
## Cache policy
|
## Cache policy
|
||||||
|
|
||||||
@@ -191,19 +255,33 @@ that does not update.
|
|||||||
| `/_astro/*` (hashed) | `public, max-age=31536000, immutable` |
|
| `/_astro/*` (hashed) | `public, max-age=31536000, immutable` |
|
||||||
| Fonts | `public, max-age=31536000, immutable` |
|
| Fonts | `public, max-age=31536000, immutable` |
|
||||||
| Images | `public, max-age=604800` |
|
| Images | `public, max-age=604800` |
|
||||||
| `robots.txt`, `sitemap*.xml` | `public, max-age=3600` |
|
| `robots.txt`, `sitemap*.xml` | `public, max-age=0, must-revalidate` |
|
||||||
|
|
||||||
Sync in two passes: hashed assets first with the long TTL, then HTML with the
|
Sync in **three** passes, in this order: hashed assets and fonts with the long
|
||||||
short one. Uploading HTML last means a user never fetches a new page whose assets
|
TTL, then images, then everything else. Uploading HTML last means a user never
|
||||||
have not landed yet.
|
fetches a new page whose assets have not landed yet.
|
||||||
|
|
||||||
|
Two ordering dependencies are load-bearing and easy to break:
|
||||||
|
|
||||||
|
- Pass 3 re-walks the whole tree; the image headers from pass 2 survive only
|
||||||
|
because `aws s3 sync` skips objects it has just uploaded. Reordering the
|
||||||
|
passes silently overwrites them with the HTML header.
|
||||||
|
- Pass 3's `--exclude "_astro/*" --exclude "fonts/*"` also excludes those
|
||||||
|
prefixes from `--delete`, so hashed assets from previous deploys are kept
|
||||||
|
deliberately — pages still in a browser cache need them. Do not "fix" it.
|
||||||
|
|
||||||
|
`robots.txt` and `sitemap*.xml` fall through to pass 3 and get the HTML header.
|
||||||
|
That is the intended behaviour: both should be re-fetched, and the table above
|
||||||
|
records what the pipeline actually does rather than an unimplemented ideal.
|
||||||
|
|
||||||
Invalidate `/*` on deploy. At this traffic volume the cost is nil, and partial
|
Invalidate `/*` on deploy. At this traffic volume the cost is nil, and partial
|
||||||
invalidation paths are a reliable source of confusing bugs.
|
invalidation paths are a reliable source of confusing bugs.
|
||||||
|
|
||||||
## CloudFront configuration
|
## CloudFront configuration
|
||||||
|
|
||||||
- Origin: S3 with **Origin Access Control**, bucket not public. The guide's
|
- Origin: S3 with **Origin Access Control**, bucket not public. Verify the
|
||||||
Part 2.2 bucket policy already does this — verify it was not loosened.
|
bucket policy grants access only to the CloudFront distribution's OAC
|
||||||
|
principal and to nothing else, and that public access is still blocked.
|
||||||
- Redirect HTTP → HTTPS. TLS 1.2 minimum.
|
- Redirect HTTP → HTTPS. TLS 1.2 minimum.
|
||||||
- Default root object `index.html`.
|
- Default root object `index.html`.
|
||||||
- **Custom error response:** 404 → `/404.html` with **response code 404**, not
|
- **Custom error response:** 404 → `/404.html` with **response code 404**, not
|
||||||
@@ -215,11 +293,25 @@ invalidation paths are a reliable source of confusing bugs.
|
|||||||
|
|
||||||
## Branch model
|
## Branch model
|
||||||
|
|
||||||
`main` is production; every push deploys. Work on short-lived branches, open a
|
`main` is production; a push to `main` is what triggers a deploy. Work on
|
||||||
PR, let CI build and run Lighthouse, merge.
|
short-lived branches, open a PR, merge.
|
||||||
|
|
||||||
**Pull request checks (blocking):** `npm run build` · `astro check` · lint ·
|
**The CI pipeline has never run.** Not for want of a lockfile — `npm ci`,
|
||||||
Lighthouse CI against the budgets in `04-seo-spec.md` · link check.
|
`astro check` and `astro build` all work now — but because the deploy user does
|
||||||
|
not exist (Q22) and Actions are not enabled with a runner registered (Q23).
|
||||||
|
Treat "every push deploys" as the design; today the path is `npm run deploy`.
|
||||||
|
|
||||||
|
**Pull request checks — planned, not implemented:** `npm run build` ·
|
||||||
|
`astro check` · lint · Lighthouse CI against the budgets in `04-seo-spec.md` ·
|
||||||
|
link check. `.gitea/workflows/deploy.yml` has **no `pull_request` trigger**
|
||||||
|
(only `push` on `main` and `workflow_dispatch`), so nothing gates a merge today.
|
||||||
|
`npm run build`, `npm run check` and `npm run lint` all run clean locally.
|
||||||
|
|
||||||
|
**Lighthouse is not one of the checks that could be wired today.** `@lhci/cli`
|
||||||
|
was removed on 2026-08-26 and there is no `npm run lighthouse` script any more —
|
||||||
|
`AGENTS.md` §7 records why and what re-adding it at build step 7 requires. Wire
|
||||||
|
the other four; do not write a workflow step that calls a script that does not
|
||||||
|
exist.
|
||||||
|
|
||||||
Tag every production deploy `v<year>.<n>` so a rollback has something to name.
|
Tag every production deploy `v<year>.<n>` so a rollback has something to name.
|
||||||
|
|
||||||
@@ -227,8 +319,9 @@ Tag every production deploy `v<year>.<n>` so a rollback has something to name.
|
|||||||
|
|
||||||
1. Re-run the workflow at the last good tag, or
|
1. Re-run the workflow at the last good tag, or
|
||||||
2. `git revert` and push, or
|
2. `git revert` and push, or
|
||||||
3. Restore from S3 object versioning — **enable versioning on the bucket if it is
|
3. Restore from S3 object versioning — **already Enabled** on the site bucket
|
||||||
off**; it is the difference between a rollback and a rebuild.
|
(`AGENTS.md` §7). It is the
|
||||||
|
difference between a rollback and a rebuild; do not turn it off.
|
||||||
|
|
||||||
Then invalidate `/*`.
|
Then invalidate `/*`.
|
||||||
|
|
||||||
@@ -236,25 +329,47 @@ Then invalidate `/*`.
|
|||||||
|
|
||||||
**Content and compliance**
|
**Content and compliance**
|
||||||
- [ ] Every claim traced to `AGENTS.md` §4 Verified
|
- [ ] Every claim traced to `AGENTS.md` §4 Verified
|
||||||
|
- [ ] **Memberships re-confirmed with Pouya, then published** — `AGENTS.md` §12
|
||||||
|
**R10** and **Q44**. ADRIC, ADRIO, the three OBA sections and the Canadian
|
||||||
|
Tax Foundation are `[verified 2026-08-26]`. **§4 records yearly renewal for
|
||||||
|
the OBA sections and the CTF only** — it says nothing about ADRIC's or
|
||||||
|
ADRIO's period, and an earlier version of this line asserted "all renew
|
||||||
|
yearly", which §4 does not support. **`/about/` currently publishes NO
|
||||||
|
memberships group**: R10 is a prohibition and the re-confirmation was not
|
||||||
|
obtained, so the group is withheld behind a `TODO(pouya)`. OCNI already
|
||||||
|
lapsed quietly and §4 records it as "not current, do not publish" — that is
|
||||||
|
the failure mode, and a stamp is not a renewal receipt. Re-confirm,
|
||||||
|
re-stamp §4 and `CREDENTIALS.memberships`, restore the group to
|
||||||
|
`CREDENTIAL_GROUPS`, and add `memberOf` to the Person JSON-LD
|
||||||
- [ ] No `TODO(pouya)` remains in any shipped page
|
- [ ] No `TODO(pouya)` remains in any shipped page
|
||||||
- [ ] No matter counts, rates, dollar figures, or testimonials anywhere
|
- [ ] No matter counts, rates, dollar figures, or testimonials anywhere
|
||||||
- [ ] Q.Arb described as in progress everywhere it appears
|
- [ ] Q.Arb described as **commenced August 2026** everywhere it appears — §4's
|
||||||
- [ ] `/fees/` carries real numbers (Q4) or the page does not ship
|
wording, not the looser "in progress"
|
||||||
|
- [ ] `/fees/` carries the rates confirmed in D14 and `docs/07-fees.md`, or the page does not ship
|
||||||
- [ ] Privacy policy matches the backend as actually built
|
- [ ] Privacy policy matches the backend as actually built
|
||||||
|
|
||||||
**Technical**
|
**Technical**
|
||||||
|
- [ ] **Re-add `@lhci/cli`** (removed 2026-08-26 — `AGENTS.md` §7) with a pin
|
||||||
|
verified against the registry that day, and a `lighthouserc` carrying the
|
||||||
|
budgets from `04-seo-spec.md`. This box gates the next one
|
||||||
- [ ] Lighthouse ≥ 95 mobile on `/`, `/about/`, a practice page, an article
|
- [ ] Lighthouse ≥ 95 mobile on `/`, `/about/`, a practice page, an article
|
||||||
- [ ] Every page renders fully with JavaScript disabled
|
- [ ] Every page renders fully with JavaScript disabled
|
||||||
- [ ] `curl` of each URL returns real content, not a shell
|
- [ ] `curl` of each URL returns real content, not a shell
|
||||||
- [ ] All internal links resolve; no orphan pages
|
- [ ] All internal links resolve; no orphan pages
|
||||||
- [ ] Sitemap generated and correct; `robots.txt` served, not 403
|
- [ ] Sitemap generated and correct; `robots.txt` served, not 403
|
||||||
- [ ] Rich Results Test passes; OG previews render in LinkedIn and Slack
|
- [ ] Rich Results Test passes; OG previews render in LinkedIn and Slack
|
||||||
|
- [ ] **OG cards are per-page, not one portrait on all nineteen** — `AGENTS.md`
|
||||||
|
Q40 / **R15**. The portrait is the decided card for `/` and `/about/`; every
|
||||||
|
other page needs the generated typed card, built at step 7 with Insights.
|
||||||
|
**This blocks cutover.** A link preview is the surface a general counsel
|
||||||
|
actually sees when a colleague pastes the URL into Teams, and the interim
|
||||||
|
makes nineteen unique titles look identical
|
||||||
- [ ] 404 returns a 404 status
|
- [ ] 404 returns a 404 status
|
||||||
- [ ] Security headers present (`securityheaders.com` A or better)
|
- [ ] 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 identities verified for sending** — confirmed 2026-08-26, re-check at cutover: `aws sesv2 get-email-identity --email-identity smlcompany.ca` and confirm `VerifiedForSendingStatus: true`
|
||||||
- [ ] **SES 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
|
- [ ] **SES bounce/complaint alarms actually notify someone** — `AGENTS.md` §7 records the `ses-alerts` email subscription as **pending confirmation**, and an unconfirmed SNS subscription drops every message. Confirm it, then `aws sns list-subscriptions-by-topic` and check the ARN is not `PendingConfirmation`. *(SES production access itself is granted — Q19 closed.)*
|
||||||
- [ ] Intake form tested end to end: DynamoDB record written to `adr-intake-submissions`, both emails delivered to a real inbox, TTL set
|
- [ ] Intake form tested end to end: DynamoDB record written to the intake table (`AGENTS.md` §7), both emails delivered to a real inbox, TTL set
|
||||||
- [ ] Booking link works, including the no-JavaScript fallback
|
- [ ] Booking link works, including the no-JavaScript fallback — **conditional on R6**; booking is parked and `BOOKING_URL` is empty, so this passes vacuously until a tool is chosen
|
||||||
- [ ] Favicon set complete
|
- [ ] Favicon set complete
|
||||||
- [ ] Tested on iOS Safari, Android Chrome, desktop Safari/Chrome/Firefox
|
- [ ] Tested on iOS Safari, Android Chrome, desktop Safari/Chrome/Firefox
|
||||||
- [ ] Tested at 320 px and at 200% zoom
|
- [ ] Tested at 320 px and at 200% zoom
|
||||||
@@ -264,7 +379,7 @@ Then invalidate `/*`.
|
|||||||
- [ ] Bucket not publicly readable; OAC in force
|
- [ ] Bucket not publicly readable; OAC in force
|
||||||
- [ ] ACM certificate valid; Namecheap validation CNAME still present
|
- [ ] ACM certificate valid; Namecheap validation CNAME still present
|
||||||
- [ ] CloudWatch alarms: Lambda errors, DLQ depth, 5xx rate
|
- [ ] CloudWatch alarms: Lambda errors, DLQ depth, 5xx rate
|
||||||
- [ ] Billing alarm still active (guide Part 0.3)
|
- [ ] Billing budget/alarm still active — `aws budgets describe-budgets --account-id "$(aws sts get-caller-identity --query Account --output text)"`. `docs/reference/AWS-Hosting-Guide.md` set up an **AWS Budget**, which `cloudwatch describe-alarms` will never return. Whether one was actually created is not recorded anywhere: confirm, do not assume
|
||||||
|
|
||||||
**Post-cutover, same day**
|
**Post-cutover, same day**
|
||||||
- [ ] Sitemap submitted to Google Search Console and Bing Webmaster Tools
|
- [ ] Sitemap submitted to Google Search Console and Bing Webmaster Tools
|
||||||
|
|||||||
+29
-9
@@ -1,11 +1,12 @@
|
|||||||
# 07 — Fee research and recommended rate card
|
# 07 — Fee research and recommended rate card
|
||||||
|
|
||||||
Authority: `AGENTS.md` §3 D8 (publish a full rate card) and D14 (two-tier
|
Authority: `AGENTS.md` §3 D8 (publish a full rate card) and **D14 — a single
|
||||||
structure, **pending Pouya's sign-off — Q14**).
|
published rate card, confirmed by Pouya 2026-08-26 (Q4/Q14/Q15-Q17 answered).**
|
||||||
|
|
||||||
**Nothing in this document publishes until Pouya confirms the figures.** These
|
**The card below is confirmed and buildable.** The research that produced it is
|
||||||
are researched recommendations, not decisions. This is business pricing
|
retained for context, but the figures are decisions now, not recommendations —
|
||||||
information, not legal or financial advice.
|
see "Set by Pouya" below. This is business pricing information, not legal or
|
||||||
|
financial advice.
|
||||||
|
|
||||||
Research date: 2026-08-26. All figures below are **plus HST** unless stated.
|
Research date: 2026-08-26. All figures below are **plus HST** unless stated.
|
||||||
|
|
||||||
@@ -103,8 +104,14 @@ All figures **plus HST**.
|
|||||||
|
|
||||||
### Arbitration
|
### Arbitration
|
||||||
|
|
||||||
Available now as co-arbitrator; sole appointments follow the Q.Arb designation,
|
Sole, party-appointed and co-arbitration appointments are all accepted now —
|
||||||
commenced August 2026. The page must say so — see `03-content-spec.md`.
|
`AGENTS.md` §4 Offerings carries a row for each `[verified 2026-08-26 — Pouya]`.
|
||||||
|
*(This line previously read "sole appointments follow the Q.Arb designation",
|
||||||
|
which understated the offering, and carried a caveat against a since-closed
|
||||||
|
Q36.)* Whatever `/fees/` says about arbitration must state the Q.Arb stage
|
||||||
|
plainly alongside it — §4 Offerings, "neither half may be dropped": the Q.Arb
|
||||||
|
designation commenced August 2026, with C.Med-Arb as the endpoint. See
|
||||||
|
`03-content-spec.md` for the wording.
|
||||||
|
|
||||||
| Item | Fee |
|
| Item | Fee |
|
||||||
|---|---|
|
|---|---|
|
||||||
@@ -118,8 +125,21 @@ offer tribunal-secretary work on the site.
|
|||||||
|
|
||||||
### Other services — hourly
|
### Other services — hourly
|
||||||
|
|
||||||
Early neutral evaluation, settlement counsel, dispute-system design, and
|
Early neutral evaluation, dispute-system design, and pre-dispute technical
|
||||||
pre-dispute technical advisory: **$500 / hour**.
|
advisory: **$500 / hour**.
|
||||||
|
|
||||||
|
**THREE services, not four. `settlement counsel` is struck and must not be
|
||||||
|
priced** — `AGENTS.md` Q42, Pouya 2026-08-27, correcting his own entry in
|
||||||
|
`docs/01`:
|
||||||
|
|
||||||
|
> "Settlement counsel acts **FOR a party** in negotiation. That is a partisan
|
||||||
|
> role, and putting it on a site that (a) sells neutrality and (b) asserts no
|
||||||
|
> licensure under D13 is **wrong twice over**: it undercuts the brand's central
|
||||||
|
> claim and it edges into acting for a client."
|
||||||
|
|
||||||
|
A struck row exists in §4 Offerings so the decision is findable. Same treatment
|
||||||
|
as the tribunal-secretary rate above, and for a related reason: a rate on a fee
|
||||||
|
page is an offer.
|
||||||
|
|
||||||
### Cancellation — adopted as recommended
|
### Cancellation — adopted as recommended
|
||||||
|
|
||||||
|
|||||||
@@ -47,9 +47,19 @@ approve elegant code containing a claim that should never have been published,
|
|||||||
because professional-conduct compliance is not what it is looking at. On this
|
because professional-conduct compliance is not what it is looking at. On this
|
||||||
project that is the highest-stakes failure mode, so it gets its own pass.
|
project that is the highest-stakes failure mode, so it gets its own pass.
|
||||||
|
|
||||||
|
**Verifying they are loaded.** `.claude/agents/` is the correct location. To
|
||||||
|
confirm the agents are live, invoke one directly:
|
||||||
|
|
||||||
|
```
|
||||||
|
Use the claims-auditor agent to audit README.md against AGENTS.md §4.
|
||||||
|
```
|
||||||
|
|
||||||
|
A verdict table back means both are wired. "No such agent" means the frontmatter
|
||||||
|
needs looking at.
|
||||||
|
|
||||||
**Both are instructed to treat uncertainty as a defect.** They will sometimes be
|
**Both are instructed to treat uncertainty as a defect.** They will sometimes be
|
||||||
wrong. That is the intended trade: explaining why a finding is mistaken costs
|
wrong. That is the intended trade: explaining why a finding is mistaken costs
|
||||||
minutes, and a missed defect on a licensed professional's public marketing page
|
minutes, and a missed defect on this project's public marketing pages
|
||||||
costs a great deal more.
|
costs a great deal more.
|
||||||
|
|
||||||
## The rule that makes it work
|
## The rule that makes it work
|
||||||
|
|||||||
@@ -0,0 +1,749 @@
|
|||||||
|
# Hosting `adr.smlcompany.ca` on AWS — A Step-by-Step Guide
|
||||||
|
|
||||||
|
> ---
|
||||||
|
> ## REFERENCE ONLY — DO NOT EXECUTE
|
||||||
|
>
|
||||||
|
> **This is a historical record of how the existing AWS infrastructure was
|
||||||
|
> built. It is not a procedure to follow.** The live deployment procedure is
|
||||||
|
> [`docs/06-deployment.md`](../06-deployment.md); the authoritative inventory of
|
||||||
|
> what actually exists is `AGENTS.md` §7.
|
||||||
|
>
|
||||||
|
> Following this document would, among other things: create an IAM user with
|
||||||
|
> `AdministratorAccess` in account `327082975128` — which `AGENTS.md` §10 rates
|
||||||
|
> **High** blast-radius; rebuild the site through the standalone-HTML pipeline
|
||||||
|
> that D1 and D3 replace; and wire SES to addresses this project does not use.
|
||||||
|
>
|
||||||
|
> **Known contradictions with Current Truth**, all of which §7 and the specs win:
|
||||||
|
>
|
||||||
|
> | This guide says | Current Truth |
|
||||||
|
> |---|---|
|
||||||
|
> | Intake mail to `adr@` / `intake@smlcompany.ca` | **`info@smlcompany.ca`** — §4, D18 |
|
||||||
|
> | Lambda runtime Node.js 20.x | **`nodejs24.x`** — §7 |
|
||||||
|
> | "the SES sandbox is perfectly fine and free" | Sandbox is a **confirmed blocker**, Q19 |
|
||||||
|
> | `rebuild-standalone.py` / `sections.jsx` / a ~2.2 MB self-contained `index.html` | Astro static build — D1 |
|
||||||
|
> | SES policy with `"Resource": "*"` | Scope it; see §10 on this account |
|
||||||
|
> | "You (a lawyer, not a sysadmin)" — the original audience line, **corrected in place** | §4 records licence status as **NOT ESTABLISHED**; the word is barred outright |
|
||||||
|
> | The consent line "does not create a lawyer-client relationship" | Superseded by `NO_RETAINER_NOTICE` in `src/data/site.ts`, written to avoid exactly that phrasing |
|
||||||
|
>
|
||||||
|
> Retained because it is the only record of how the bucket, distribution,
|
||||||
|
> certificate, DNS, Lambda, DynamoDB table, and SES identities came to exist.
|
||||||
|
> Read it for that. Do not run it.
|
||||||
|
> ---
|
||||||
|
|
||||||
|
**Audience:** the site owner — comfortable clicking around, new to AWS.
|
||||||
|
**Goal:** Get the revamped site live at `https://adr.smlcompany.ca` with a working intake form whose submissions are stored in a database **and** emailed to `adr@smlcompany.ca`.
|
||||||
|
|
||||||
|
**Architecture you're building:**
|
||||||
|
|
||||||
|
```
|
||||||
|
┌───────────────────────┐
|
||||||
|
Browser ─────► │ CloudFront (CDN) │ ◄── ACM (free TLS cert)
|
||||||
|
adr.smlcompany.ca │ HTTPS + cache │
|
||||||
|
└──────────┬────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌───────────────────────┐
|
||||||
|
│ S3 bucket (origin) │ ← your standalone HTML + /assets
|
||||||
|
│ adr-smlcompany-site │
|
||||||
|
└───────────────────────┘
|
||||||
|
|
||||||
|
Form submit ─► API Gateway ─► Lambda ─┬─► DynamoDB (permanent record)
|
||||||
|
└─► SES (emails adr@smlcompany.ca)
|
||||||
|
|
||||||
|
DNS stays at Namecheap (you add a CNAME for `adr` + cert/DKIM validation records)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Total time:** about 2–3 hours the first time, in chunks. Most steps take a minute or two of clicking but DNS propagation and CloudFront deploys mean there's some waiting.
|
||||||
|
|
||||||
|
**Total monthly cost at low traffic:** under $1 USD. S3, CloudFront, Lambda, DynamoDB, and SES will all stay in or near their free tiers.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚠️ A note about DNS choice
|
||||||
|
|
||||||
|
You've chosen to keep DNS at Namecheap rather than move to Route 53. That's perfectly fine and is actually cheaper (no $0.50/month hosted zone) and lower-risk (your existing MX records and email keep working untouched). The trade-offs:
|
||||||
|
|
||||||
|
- **CNAMEs can't sit at the apex.** Your apex `smlcompany.ca` will not be servable on CloudFront from Namecheap DNS — only subdomains like `adr.smlcompany.ca`. This is a DNS standard, not a Namecheap limitation. Since you're using a subdomain, you're fine. If you ever want the apex on CloudFront, you'd either move DNS to Route 53 (alias records can be at apex) or use Namecheap's "URL Redirect Record" feature to redirect the apex to the subdomain.
|
||||||
|
- **You'll add records by hand.** Each time AWS asks you to publish a DNS record (for cert validation, for SES DKIM, etc.), you'll copy/paste it into Namecheap → Advanced DNS yourself, instead of AWS writing it for you.
|
||||||
|
- **No automatic DNS updates.** Not really a downside at this scale — just something to know.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Part 0 — Prerequisites (15 min)
|
||||||
|
|
||||||
|
You said you already have an AWS account. Quick hardening pass:
|
||||||
|
|
||||||
|
### 0.1 Sign in as a non-root IAM user
|
||||||
|
- AWS strongly recommends you don't use the root account day-to-day. If you've been using root: in the console, open **IAM → Users → Create user**. Name it `pouya-admin`. Attach the AWS-managed policy `AdministratorAccess`. Enable **console access** with a custom password.
|
||||||
|
- Sign out and sign back in as `pouya-admin` going forward. Reserve the root login for billing changes only.
|
||||||
|
|
||||||
|
### 0.2 Turn on MFA for the root account
|
||||||
|
- IAM → Security credentials (under your root user) → **Assign MFA device** → use Authy / Google Authenticator / 1Password.
|
||||||
|
|
||||||
|
### 0.3 Set a billing alarm
|
||||||
|
- Console → **Billing and Cost Management → Budgets → Create budget**.
|
||||||
|
- Template: **Monthly cost budget**, $20 USD, notify at 80% and 100% to `pouya@meshkinilaw.ca`.
|
||||||
|
- This catches misconfiguration before it gets expensive.
|
||||||
|
|
||||||
|
### 0.4 Set your region
|
||||||
|
- Top-right of the AWS console: switch the region selector to **Canada (Central) — ca-central-1**.
|
||||||
|
- Everything in this guide is in `ca-central-1` **except** ACM (which for CloudFront *must* live in `us-east-1` — explained in Part 3) and CloudFront itself (which is global).
|
||||||
|
|
||||||
|
### 0.5 (Optional but useful) Install the AWS CLI
|
||||||
|
- macOS: `brew install awscli` then `aws configure` and paste an access key generated from IAM → your user → Security credentials.
|
||||||
|
- You don't strictly need it — every step below has a console path — but a few things (S3 sync, CloudFront invalidations) are much faster from the terminal.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Part 1 — Put the website in an S3 bucket (15 min)
|
||||||
|
|
||||||
|
S3 is just object storage. We'll create one bucket, drop your standalone HTML and the `assets/` folder in it, and leave it private — CloudFront will be the only thing allowed to read from it.
|
||||||
|
|
||||||
|
### 1.1 Create the bucket
|
||||||
|
- Console → **S3 → Create bucket**.
|
||||||
|
- **Bucket name:** `adr-smlcompany-site` (must be globally unique across all of AWS — if it's taken, add a suffix like `-2026`).
|
||||||
|
- **Region:** Canada (Central) ca-central-1.
|
||||||
|
- **Block all public access:** leave the box **checked** (yes, fully blocked — CloudFront will use an Origin Access Control to read from it).
|
||||||
|
- **Bucket versioning:** Enable. This gives you a free undo if you ever overwrite the site with a broken version.
|
||||||
|
- Leave everything else default. **Create bucket**.
|
||||||
|
|
||||||
|
### 1.2 Upload your files
|
||||||
|
Your export contains a few HTML files. The one you want to serve is `SML ADR Site (Standalone).html` — that's the ~2.2 MB self-contained build with everything inlined.
|
||||||
|
|
||||||
|
- Open the bucket → **Upload**.
|
||||||
|
- **Add files** → select `SML ADR Site (Standalone).html`.
|
||||||
|
- **IMPORTANT:** before uploading, rename it locally to `index.html` (CloudFront's default root object). Or upload as-is and use the S3 console to rename it after upload (Actions → Rename).
|
||||||
|
- Also upload your `assets/` folder using **Add folder** so that `assets/sml-logo-full.png` and `assets/sml-logo-mark.png` end up at `s3://adr-smlcompany-site/assets/...`.
|
||||||
|
|
||||||
|
After upload, your bucket should contain:
|
||||||
|
|
||||||
|
```
|
||||||
|
index.html
|
||||||
|
assets/
|
||||||
|
sml-logo-full.png
|
||||||
|
sml-logo-mark.png
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.3 Set cache-control on the HTML (recommended)
|
||||||
|
Because we'll deploy by overwriting `index.html` later, you want browsers/CDN to re-check it often.
|
||||||
|
|
||||||
|
- Click `index.html` → **Properties → Edit metadata**.
|
||||||
|
- Add metadata: **System defined → Cache-Control → `public, max-age=300, must-revalidate`** (5 minutes).
|
||||||
|
- For the images in `assets/`, leave defaults (they can cache for much longer; CloudFront will use defaults).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Part 2 — Put CloudFront in front of S3 (20 min including wait)
|
||||||
|
|
||||||
|
CloudFront is AWS's CDN. It gives you HTTPS, global edge caching, and lets you put a real domain in front of an otherwise-private S3 bucket.
|
||||||
|
|
||||||
|
### 2.1 Create the distribution
|
||||||
|
- Console → **CloudFront → Create distribution**.
|
||||||
|
- **Origin domain:** click the dropdown and pick your bucket — `adr-smlcompany-site.s3.ca-central-1.amazonaws.com`. The console will offer a "Use website endpoint" suggestion — **ignore that**, leave the REST endpoint selected.
|
||||||
|
- **Origin access:** select **Origin access control settings (recommended)**.
|
||||||
|
- Click **Create new OAC**. Name: `adr-smlcompany-oac`. Signing behavior: **Sign requests**. Origin type: **S3**. Create.
|
||||||
|
- You'll see a yellow banner saying *"You must update the S3 bucket policy."* Note this — we'll do it in a moment.
|
||||||
|
- **Viewer protocol policy:** **Redirect HTTP to HTTPS**.
|
||||||
|
- **Allowed HTTP methods:** GET, HEAD (default).
|
||||||
|
- **Cache policy:** **CachingOptimized** (managed).
|
||||||
|
- **Origin request policy:** leave blank.
|
||||||
|
- **Response headers policy:** **SecurityHeadersPolicy** (managed) — adds HSTS, X-Frame-Options, etc.
|
||||||
|
- **Compress objects automatically:** Yes.
|
||||||
|
- **Price class:** **Use only North America and Europe** (cheaper; your clients aren't in Tokyo).
|
||||||
|
- **Web Application Firewall (WAF):** **Do not enable** for now. (Could add later if needed; ~$5/mo.)
|
||||||
|
- **Alternate domain names (CNAMEs):** leave blank for now — we'll add `adr.smlcompany.ca` in Part 6, after the cert exists.
|
||||||
|
- **Custom SSL certificate:** leave **Default CloudFront Certificate** for now.
|
||||||
|
- **Default root object:** `index.html`.
|
||||||
|
- **Standard logging:** Off (can enable later).
|
||||||
|
- **Create distribution**.
|
||||||
|
|
||||||
|
### 2.2 Update the S3 bucket policy
|
||||||
|
After creating the distribution, you'll see a banner *"Copy policy"* with a JSON snippet — that snippet allows your specific CloudFront distribution to read from S3.
|
||||||
|
|
||||||
|
- Click **Copy policy**.
|
||||||
|
- Open the S3 bucket → **Permissions → Bucket policy → Edit** → paste → **Save changes**.
|
||||||
|
|
||||||
|
The policy looks roughly like:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"Version": "2008-10-17",
|
||||||
|
"Statement": [{
|
||||||
|
"Sid": "AllowCloudFrontServicePrincipal",
|
||||||
|
"Effect": "Allow",
|
||||||
|
"Principal": { "Service": "cloudfront.amazonaws.com" },
|
||||||
|
"Action": "s3:GetObject",
|
||||||
|
"Resource": "arn:aws:s3:::adr-smlcompany-site/*",
|
||||||
|
"Condition": {
|
||||||
|
"StringEquals": { "AWS:SourceArn": "arn:aws:cloudfront::<ACCOUNT-ID>:distribution/<DIST-ID>" }
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.3 Wait for "Deployed"
|
||||||
|
- CloudFront → your distribution → wait until **Last modified** shows a timestamp and the status reads **Deployed** (5–15 min the first time).
|
||||||
|
- Then visit the **Distribution domain name** shown at the top — something like `d123abc4xyz.cloudfront.net`. Your site should load over HTTPS.
|
||||||
|
- If you see XML access-denied: the bucket policy isn't saved yet, or `index.html` isn't named exactly that.
|
||||||
|
|
||||||
|
✅ **Checkpoint:** site loads on the `*.cloudfront.net` URL. We'll attach your real domain in Part 6.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Part 3 — Get a free SSL certificate (10 min, validation later)
|
||||||
|
|
||||||
|
CloudFront requires its TLS certificate to live in **us-east-1**, regardless of where the rest of your stack lives. This trips up everyone the first time.
|
||||||
|
|
||||||
|
### 3.1 Request the cert
|
||||||
|
- Top-right region selector → switch to **US East (N. Virginia) — us-east-1**. (You'll switch back to ca-central-1 after this part.)
|
||||||
|
- Console → **Certificate Manager → Request certificate → Request a public certificate**.
|
||||||
|
- **Domain names:**
|
||||||
|
- `adr.smlcompany.ca`
|
||||||
|
- (Optional, recommended) Add a second name: `*.smlcompany.ca`. A wildcard means you'll be able to use the same cert for `www.smlcompany.ca`, `mail.smlcompany.ca`, etc. without re-requesting.
|
||||||
|
- **Validation method:** **DNS validation** (the recommended option — uses a CNAME record).
|
||||||
|
- **Key algorithm:** RSA 2048.
|
||||||
|
- **Request**.
|
||||||
|
|
||||||
|
You'll land on the cert page in **Pending validation** state. ACM will show you one or two CNAME records of the form `_abc123.adr.smlcompany.ca` → `_xyz789.acm-validations.aws.`. Leave this tab open — you'll publish these in Namecheap in Part 5.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Part 4 — Open Namecheap's DNS panel (2 min)
|
||||||
|
|
||||||
|
We're not migrating DNS, but we will be coming back to this panel four times across the rest of the guide (ACM cert validation, the CloudFront CNAME, three SES DKIM records). So get familiar with where it is now.
|
||||||
|
|
||||||
|
### 4.1 Locate Advanced DNS
|
||||||
|
- Log in to Namecheap → **Domain List**.
|
||||||
|
- Find `smlcompany.ca` → click **Manage** on its row.
|
||||||
|
- Click the **Advanced DNS** tab. This is where you'll add every record below. (Do **not** touch the **Domain** tab's Nameservers section — leave it set to *Namecheap BasicDNS*.)
|
||||||
|
|
||||||
|
### 4.2 Make a "before" screenshot (1 min)
|
||||||
|
Take a screenshot of the current Host Records table. You won't need to touch any of the existing rows — they're handling your email and anything else you have set up. The screenshot is just an undo reference in case you ever paste over the wrong row.
|
||||||
|
|
||||||
|
### 4.3 How to add a record in Namecheap (reference for later steps)
|
||||||
|
Namecheap's row-add UX:
|
||||||
|
- Scroll to the **Host Records** section → click **ADD NEW RECORD**.
|
||||||
|
- Pick a **Type** from the dropdown (A, AAAA, CNAME, TXT, MX, etc.).
|
||||||
|
- **Host:** the subdomain part only. So for `adr.smlcompany.ca` the Host is `adr`. For the apex itself, use `@`. For something like `_abc123.adr.smlcompany.ca`, use `_abc123.adr`.
|
||||||
|
- **Value:** what AWS gives you. **Important:** Namecheap will sometimes append a trailing dot to CNAME values when it shows them back — that's normal. When *entering* a CNAME, you can include or omit the trailing dot; both work.
|
||||||
|
- **TTL:** Automatic (~30 min) is fine. For records you'll change often (testing), pick a low TTL like 5 min.
|
||||||
|
- Click the green checkmark on the right to save the row.
|
||||||
|
|
||||||
|
That's it — you'll do this five-ish times over Parts 5, 7, and 9.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Part 5 — Validate the ACM cert via Namecheap (10 min including wait)
|
||||||
|
|
||||||
|
Back to the cert you requested in Part 3.
|
||||||
|
|
||||||
|
- Region selector → **us-east-1**.
|
||||||
|
- ACM → your pending cert → click into it.
|
||||||
|
- You'll see one (or two, if you added the wildcard) **CNAME validation records** of the form:
|
||||||
|
|
||||||
|
```
|
||||||
|
Name: _abc1234567890.adr.smlcompany.ca.
|
||||||
|
Value: _xyz9876543210.acm-validations.aws.
|
||||||
|
```
|
||||||
|
|
||||||
|
ACM gives you a **Copy** button next to each — handy.
|
||||||
|
|
||||||
|
- Switch tab to Namecheap → smlcompany.ca → **Advanced DNS** → **ADD NEW RECORD**:
|
||||||
|
- **Type:** CNAME Record
|
||||||
|
- **Host:** the bit *before* `.smlcompany.ca` in the Name field. For example, if ACM shows `_abc1234567890.adr.smlcompany.ca.`, the Host you enter in Namecheap is `_abc1234567890.adr`. (Drop the trailing `.smlcompany.ca` — Namecheap appends it automatically.)
|
||||||
|
- **Target:** the Value from ACM, e.g. `_xyz9876543210.acm-validations.aws.` (trailing dot is fine).
|
||||||
|
- **TTL:** Automatic.
|
||||||
|
- Click the green checkmark.
|
||||||
|
|
||||||
|
- If you added the wildcard `*.smlcompany.ca` in Part 3, you'll see a second validation row in ACM — add a second CNAME the same way. (Often ACM gives the same Name/Value for the apex and wildcard, in which case you only need one CNAME.)
|
||||||
|
|
||||||
|
- Back in ACM, refresh the cert page after 2–10 min. Status flips from **Pending validation** to **Issued**. If it's still pending after 15 minutes, you've almost certainly got a Host typo — re-check that what's in Namecheap matches what ACM shows, character for character.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Part 6 — Attach the cert + domain to CloudFront (10 min including wait)
|
||||||
|
|
||||||
|
- Region selector → **us-east-1** (CloudFront is global but lives under us-east-1 in the console nav).
|
||||||
|
- CloudFront → your distribution → **General → Settings → Edit**.
|
||||||
|
- **Alternate domain name (CNAME):** add `adr.smlcompany.ca`. (Add `www.adr.smlcompany.ca` too if you want both — otherwise leave as just the one.)
|
||||||
|
- **Custom SSL certificate:** dropdown → select the cert you just issued.
|
||||||
|
- **Security policy:** TLSv1.2_2021.
|
||||||
|
- **Save changes**.
|
||||||
|
- Wait ~5–10 min for **Deployed** status again.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Part 7 — Point DNS at CloudFront via Namecheap (3 min)
|
||||||
|
|
||||||
|
- Grab your CloudFront distribution domain from the CloudFront console (top of the distribution page) — it looks like `d123abc4xyz.cloudfront.net`.
|
||||||
|
- Namecheap → smlcompany.ca → **Advanced DNS** → **ADD NEW RECORD**:
|
||||||
|
- **Type:** CNAME Record
|
||||||
|
- **Host:** `adr`
|
||||||
|
- **Target:** your CloudFront domain, e.g. `d123abc4xyz.cloudfront.net.` (trailing dot optional)
|
||||||
|
- **TTL:** 5 min (for the initial setup — you can raise it to Automatic once everything's stable)
|
||||||
|
- Save with the green checkmark.
|
||||||
|
|
||||||
|
> **About IPv6:** a CNAME delegates resolution to the target's records, and CloudFront serves both A (IPv4) and AAAA (IPv6) records. So a single CNAME automatically covers both — you don't need a separate AAAA record like you would with a Route 53 alias.
|
||||||
|
|
||||||
|
> **About the apex:** Namecheap DNS can't put a CNAME at `@` (the apex `smlcompany.ca`). That's a hard DNS-standards limit, not Namecheap's fault. Since you're using `adr.smlcompany.ca`, this doesn't affect you. If you also wanted `smlcompany.ca` (without the `adr.`) to land on the site, the easiest route is Namecheap's **URL Redirect Record** type: Host `@`, Target `https://adr.smlcompany.ca`, Type `Unmasked (301)`.
|
||||||
|
|
||||||
|
Within a couple of minutes, `https://adr.smlcompany.ca` should serve your site.
|
||||||
|
|
||||||
|
✅ **Checkpoint:** open `https://adr.smlcompany.ca` in an incognito window. You should see the revamped site, with a green padlock, no warnings.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Part 8 — Backend: DynamoDB table + Lambda + API Gateway (45 min)
|
||||||
|
|
||||||
|
Now the intake form. The flow:
|
||||||
|
|
||||||
|
```
|
||||||
|
Browser POST → API Gateway (HTTPS) → Lambda function → DynamoDB.put_item()
|
||||||
|
→ SES.send_email() to adr@smlcompany.ca
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.1 Create the DynamoDB table
|
||||||
|
- Region → **ca-central-1**.
|
||||||
|
- Console → **DynamoDB → Tables → Create table**.
|
||||||
|
- **Table name:** `adr-intake-submissions`.
|
||||||
|
- **Partition key:** `submissionId` (String).
|
||||||
|
- **Sort key:** leave blank.
|
||||||
|
- **Settings:** **Default settings** — this gives you on-demand capacity (you pay per request, ~$0 at your volume) and encryption at rest by default.
|
||||||
|
- **Create**.
|
||||||
|
- After it's `Active`: click the table → **Backups → Point-in-time recovery → Edit → Turn on**. Costs cents/month and lets you restore to any second in the last 35 days.
|
||||||
|
|
||||||
|
### 8.2 Create the Lambda execution role (IAM)
|
||||||
|
- IAM → **Roles → Create role**.
|
||||||
|
- Trusted entity: **AWS service** → use case **Lambda**.
|
||||||
|
- Permissions: attach these AWS managed policies for now:
|
||||||
|
- `AWSLambdaBasicExecutionRole` (lets it write CloudWatch logs)
|
||||||
|
- **Role name:** `adr-intake-lambda-role`. Create.
|
||||||
|
- After creation: open the role → **Add permissions → Create inline policy** → JSON tab → paste:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"Version": "2012-10-17",
|
||||||
|
"Statement": [
|
||||||
|
{
|
||||||
|
"Effect": "Allow",
|
||||||
|
"Action": ["dynamodb:PutItem"],
|
||||||
|
"Resource": "arn:aws:dynamodb:ca-central-1:*:table/adr-intake-submissions"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Effect": "Allow",
|
||||||
|
"Action": ["ses:SendEmail", "ses:SendRawEmail"],
|
||||||
|
"Resource": "*"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Name it `adr-intake-lambda-inline`. Save.
|
||||||
|
|
||||||
|
### 8.3 Create the Lambda function
|
||||||
|
- Console → **Lambda → Create function**.
|
||||||
|
- **Author from scratch.**
|
||||||
|
- **Function name:** `adr-intake-handler`.
|
||||||
|
- **Runtime:** Node.js 20.x.
|
||||||
|
- **Architecture:** arm64 (cheaper).
|
||||||
|
- **Execution role:** *Use an existing role* → `adr-intake-lambda-role`.
|
||||||
|
- **Create function.**
|
||||||
|
|
||||||
|
In the Code tab, replace the contents of `index.mjs` with:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
|
||||||
|
import { DynamoDBDocumentClient, PutCommand } from "@aws-sdk/lib-dynamodb";
|
||||||
|
import { SESv2Client, SendEmailCommand } from "@aws-sdk/client-sesv2";
|
||||||
|
import { randomUUID } from "crypto";
|
||||||
|
|
||||||
|
const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({ region: "ca-central-1" }));
|
||||||
|
const ses = new SESv2Client({ region: "ca-central-1" });
|
||||||
|
|
||||||
|
const TABLE = "adr-intake-submissions";
|
||||||
|
const FROM_ADDR = "adr@smlcompany.ca"; // must be SES-verified (Part 9)
|
||||||
|
const NOTIFY_ADDR = "adr@smlcompany.ca"; // must be SES-verified while SES is in sandbox
|
||||||
|
const ALLOWED_ORIGIN = "https://adr.smlcompany.ca";
|
||||||
|
|
||||||
|
const CORS = {
|
||||||
|
"Access-Control-Allow-Origin": ALLOWED_ORIGIN,
|
||||||
|
"Access-Control-Allow-Methods": "POST,OPTIONS",
|
||||||
|
"Access-Control-Allow-Headers": "Content-Type",
|
||||||
|
};
|
||||||
|
|
||||||
|
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
|
||||||
|
export const handler = async (event) => {
|
||||||
|
// Preflight
|
||||||
|
if (event.requestContext?.http?.method === "OPTIONS") {
|
||||||
|
return { statusCode: 204, headers: CORS };
|
||||||
|
}
|
||||||
|
|
||||||
|
let body;
|
||||||
|
try {
|
||||||
|
body = JSON.parse(event.body || "{}");
|
||||||
|
} catch {
|
||||||
|
return { statusCode: 400, headers: CORS, body: JSON.stringify({ error: "invalid_json" }) };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract + light validation — adjust to taste.
|
||||||
|
const name = (body.name || "").toString().trim().slice(0, 200);
|
||||||
|
const org = (body.org || "").toString().trim().slice(0, 200);
|
||||||
|
const email = (body.email || "").toString().trim().slice(0, 200);
|
||||||
|
const phone = (body.phone || "").toString().trim().slice(0, 50);
|
||||||
|
const matter = (body.matter || "").toString().trim().slice(0, 100);
|
||||||
|
const message = (body.message || "").toString().trim().slice(0, 5000);
|
||||||
|
const honeypot = (body.website || "").toString(); // bot trap; see Part 10
|
||||||
|
|
||||||
|
if (honeypot) {
|
||||||
|
// Silently accept and drop — looks successful to bots.
|
||||||
|
return { statusCode: 200, headers: CORS, body: JSON.stringify({ ok: true }) };
|
||||||
|
}
|
||||||
|
if (!name || !email || !message) {
|
||||||
|
return { statusCode: 400, headers: CORS, body: JSON.stringify({ error: "missing_fields" }) };
|
||||||
|
}
|
||||||
|
if (!EMAIL_RE.test(email)) {
|
||||||
|
return { statusCode: 400, headers: CORS, body: JSON.stringify({ error: "invalid_email" }) };
|
||||||
|
}
|
||||||
|
|
||||||
|
const submissionId = randomUUID();
|
||||||
|
const submittedAt = new Date().toISOString();
|
||||||
|
const sourceIp = event.requestContext?.http?.sourceIp || "unknown";
|
||||||
|
const userAgent = event.headers?.["user-agent"] || "unknown";
|
||||||
|
|
||||||
|
// 1) Store in DynamoDB
|
||||||
|
await ddb.send(new PutCommand({
|
||||||
|
TableName: TABLE,
|
||||||
|
Item: { submissionId, submittedAt, name, org, email, phone, matter, message, sourceIp, userAgent },
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 2) Email adr@smlcompany.ca
|
||||||
|
const text =
|
||||||
|
`New intake form submission
|
||||||
|
|
||||||
|
Name: ${name}
|
||||||
|
Org: ${org || "(not provided)"}
|
||||||
|
Email: ${email}
|
||||||
|
Phone: ${phone || "(not provided)"}
|
||||||
|
Service: ${matter || "(not provided)"}
|
||||||
|
|
||||||
|
Message:
|
||||||
|
${message}
|
||||||
|
|
||||||
|
—
|
||||||
|
Submission ID: ${submissionId}
|
||||||
|
Submitted: ${submittedAt}
|
||||||
|
IP: ${sourceIp}
|
||||||
|
|
||||||
|
Reply directly to this email — it will route to the submitter.
|
||||||
|
`;
|
||||||
|
|
||||||
|
await ses.send(new SendEmailCommand({
|
||||||
|
FromEmailAddress: FROM_ADDR,
|
||||||
|
Destination: { ToAddresses: [NOTIFY_ADDR] },
|
||||||
|
Content: {
|
||||||
|
Simple: {
|
||||||
|
Subject: { Data: `New intake: ${name}${org ? " — " + org : ""}`, Charset: "UTF-8" },
|
||||||
|
Body: { Text: { Data: text, Charset: "UTF-8" } },
|
||||||
|
}
|
||||||
|
},
|
||||||
|
ReplyToAddresses: [email], // hitting Reply in your inbox goes straight to the submitter
|
||||||
|
}));
|
||||||
|
|
||||||
|
return { statusCode: 200, headers: CORS, body: JSON.stringify({ ok: true, submissionId }) };
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Note:** This version uses `adr@smlcompany.ca` as both From and To (per the simpler Option B in Part 9.3). The "Reply-To" header is set to the submitter's email, so when you hit *Reply* in your mail client, the response goes to them — not to yourself.
|
||||||
|
|
||||||
|
- Click **Deploy**.
|
||||||
|
- Set the runtime timeout to 10 seconds: **Configuration → General configuration → Edit → Timeout: 10 sec → Save**.
|
||||||
|
|
||||||
|
> **If you see a "module not found" error** on first invocation (rare but possible — AWS sometimes drops packages from the included SDK between runtime versions), you'll need to deploy your code as a zip with `node_modules`. Locally: `npm init -y && npm install @aws-sdk/client-dynamodb @aws-sdk/lib-dynamodb @aws-sdk/client-sesv2`, put your `index.mjs` next to `node_modules/`, then `zip -r function.zip index.mjs node_modules package*.json` and upload via Lambda → Code → Upload from → .zip file. The three SDK packages are normally pre-installed in the Node.js 20.x runtime, so you should be able to skip this step.
|
||||||
|
|
||||||
|
### 8.4 Create the HTTP API in API Gateway
|
||||||
|
- Console → **API Gateway → Create API → HTTP API → Build**.
|
||||||
|
- **Integrations:** click *Add integration* → Lambda → region ca-central-1 → function `adr-intake-handler`.
|
||||||
|
- **API name:** `adr-intake-api`.
|
||||||
|
- **Configure routes:**
|
||||||
|
- Method: `POST`
|
||||||
|
- Path: `/submissions`
|
||||||
|
- Integration target: `adr-intake-handler`
|
||||||
|
- **Configure stages:** leave default (`$default`, auto-deploy enabled).
|
||||||
|
- **Create**.
|
||||||
|
|
||||||
|
After it's created:
|
||||||
|
- Open the API → **CORS** → **Configure**:
|
||||||
|
- Access-Control-Allow-Origin: `https://adr.smlcompany.ca`
|
||||||
|
- Access-Control-Allow-Methods: `POST`
|
||||||
|
- Access-Control-Allow-Headers: `content-type`
|
||||||
|
- Save.
|
||||||
|
- Note the **Invoke URL** at the top — looks like `https://abc123.execute-api.ca-central-1.amazonaws.com`. Your endpoint is `<invoke-url>/submissions`.
|
||||||
|
|
||||||
|
### 8.5 Quick smoke-test (without the front-end)
|
||||||
|
From your terminal:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST 'https://abc123.execute-api.ca-central-1.amazonaws.com/submissions' \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{"name":"Test User","org":"Test","email":"test@example.com","phone":"+1-416-555-0100","matter":"Mediation","message":"This is a test."}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected response: `{"ok":true,"submissionId":"..."}`.
|
||||||
|
|
||||||
|
At this point the DynamoDB write should succeed, but **SES will fail** until Part 9. Check **CloudWatch → Log groups → /aws/lambda/adr-intake-handler** — you'll see the error there. That's fine; Part 9 fixes it.
|
||||||
|
|
||||||
|
To see the row landed in the DB: DynamoDB → Tables → `adr-intake-submissions` → **Explore table items**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Part 9 — Verify your sender domain in SES (15 min)
|
||||||
|
|
||||||
|
By default SES is in **sandbox**: it can only send *from* verified identities *to* verified identities. For a low-volume contact-form notifier sending only to yourself, the sandbox is perfectly fine and free.
|
||||||
|
|
||||||
|
### 9.1 Verify the domain `smlcompany.ca`
|
||||||
|
- Region → **ca-central-1**.
|
||||||
|
- Console → **Amazon SES → Configuration → Identities → Create identity**.
|
||||||
|
- Identity type: **Domain**.
|
||||||
|
- Domain: `smlcompany.ca`.
|
||||||
|
- **Use a custom MAIL FROM domain:** skip (optional).
|
||||||
|
- **DKIM:** **Easy DKIM**, RSA 2048-bit. Leave **Publish DNS records to Route 53** unchecked (you don't have a Route 53 hosted zone).
|
||||||
|
- Create.
|
||||||
|
|
||||||
|
You'll land on the identity page with three CNAME records that SES wants published. They look like:
|
||||||
|
|
||||||
|
```
|
||||||
|
Name: abc1234567890._domainkey.smlcompany.ca
|
||||||
|
Value: abc1234567890.dkim.amazonses.com
|
||||||
|
|
||||||
|
Name: def0987654321._domainkey.smlcompany.ca
|
||||||
|
Value: def0987654321.dkim.amazonses.com
|
||||||
|
|
||||||
|
Name: ghi5555555555._domainkey.smlcompany.ca
|
||||||
|
Value: ghi5555555555.dkim.amazonses.com
|
||||||
|
```
|
||||||
|
|
||||||
|
Add each in Namecheap → Advanced DNS → **ADD NEW RECORD**:
|
||||||
|
- **Type:** CNAME Record
|
||||||
|
- **Host:** the part before `.smlcompany.ca` — e.g. `abc1234567890._domainkey`
|
||||||
|
- **Target:** the SES value, e.g. `abc1234567890.dkim.amazonses.com`
|
||||||
|
- **TTL:** Automatic
|
||||||
|
- Save with the green checkmark. Repeat for the other two.
|
||||||
|
|
||||||
|
⚠️ If you currently have **any other DKIM CNAMEs** for `smlcompany.ca` from your existing email provider (e.g. Google Workspace's `google._domainkey`), **leave them alone**. SES's DKIM uses different selector names, so it won't collide. Multiple DKIM keys on the same domain is normal and supported.
|
||||||
|
|
||||||
|
After 5–15 minutes, refresh the SES identity page. The DKIM status flips to **Successful** and the overall identity status flips to **Verified**. If it's still pending after 30 minutes, check the Host fields in Namecheap for typos.
|
||||||
|
|
||||||
|
> **Bonus — SPF alignment for SES.** Your existing `v=spf1 ...` TXT record at the apex tells the world which servers may send mail "as" smlcompany.ca. If you want SES-sent mail to pass SPF too (improves deliverability of intake notifications), add `include:amazonses.com` to the existing SPF record. Edit it in Namecheap so it becomes e.g.: `v=spf1 include:_spf.google.com include:amazonses.com ~all`. Don't create a *second* SPF TXT record — only one is allowed per domain.
|
||||||
|
|
||||||
|
### 9.2 Verify the recipient
|
||||||
|
While SES is in sandbox, the *To:* address also has to be verified.
|
||||||
|
|
||||||
|
- SES → Identities → **Create identity** → Email address → `adr@smlcompany.ca` → Create.
|
||||||
|
- AWS sends a verification email to that address. Click the link. Status → **Verified**.
|
||||||
|
|
||||||
|
### 9.3 Verify the From address
|
||||||
|
The Lambda above uses `intake@smlcompany.ca` as the From. Verify it too:
|
||||||
|
- SES → Identities → **Create identity** → Email address → `intake@smlcompany.ca` → Create.
|
||||||
|
- Either have your email provider deliver mail at that alias to your real inbox, *or* just use `adr@smlcompany.ca` as the From in the Lambda code and skip this step.
|
||||||
|
|
||||||
|
### 9.4 Retest
|
||||||
|
```bash
|
||||||
|
curl -X POST 'https://abc123.execute-api.ca-central-1.amazonaws.com/submissions' \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{"name":"Test 2","org":"Test","email":"test@example.com","matter":"Mediation","message":"Now with email."}'
|
||||||
|
```
|
||||||
|
You should get the success response AND see an email arrive at `adr@smlcompany.ca` within a few seconds.
|
||||||
|
|
||||||
|
### 9.5 (Optional, later) Request production access
|
||||||
|
If you ever want the form to **send a confirmation email back to the submitter**, you'll need to exit sandbox. SES Console → top right → **Request production access**. AWS asks a few questions about how you'll use it; approval is usually under 24 hr for legitimate business use.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Part 10 — Wire the front-end form to your API (20 min)
|
||||||
|
|
||||||
|
### How the site is bundled
|
||||||
|
|
||||||
|
Your site export uses a custom format from Anthropic's Artifacts bundler:
|
||||||
|
|
||||||
|
- **`SML ADR Site (Standalone).html`** — a single 2.2 MB file containing the rendered HTML *plus* all JSX, JavaScript, fonts, and the logo PNG bundled together as gzipped+base64 entries inside a `<script type="__bundler/manifest">` JSON blob. This is the file you uploaded to S3 as `index.html`.
|
||||||
|
- **`components-standalone/*.jsx`** — the loose JSX source files (sections, hero, nav, etc.). The standalone HTML was originally built *from* these but isn't automatically rebuilt when you edit them.
|
||||||
|
|
||||||
|
So if you change a JSX file, you have two options:
|
||||||
|
|
||||||
|
| Approach | What you upload to S3 | Pros | Cons |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **Stay with the bundled HTML** *(recommended)* | One file (`index.html`) | Same as before. Fast page load. CloudFront caches it well. | Need to "rebundle" after JSX edits. |
|
||||||
|
| **Switch to loose files** | `(standalone-src).html` + the whole `components-standalone/` folder + `tweaks-panel.jsx` + `assets/` | No rebuild step — just upload changed JSX. | Extra ~500ms first page load while Babel compiles JSX in the browser. Many small files. |
|
||||||
|
|
||||||
|
This guide assumes you stay with the bundled HTML, because that's the architecture you started with.
|
||||||
|
|
||||||
|
### The form code is already updated
|
||||||
|
|
||||||
|
The `components-standalone/sections.jsx` file has been edited. The new Contact component:
|
||||||
|
|
||||||
|
- Adds two new required-flag-aware fields: **Email** (required, `type="email"`) and **Phone** (optional, `type="tel"`).
|
||||||
|
- Wires `onSubmit` to a real `fetch()` POST against your API Gateway endpoint.
|
||||||
|
- Adds `submitting` and `error` state so the button shows "Sending…" while in flight and a clear maroon-bordered error message on failure.
|
||||||
|
- Adds a hidden **honeypot** field (`website`) to silently drop bot submissions.
|
||||||
|
- Adds a small-print **consent line** under the submit button. **Superseded — do not use this wording:** the live text is `NO_RETAINER_NOTICE` in `src/data/site.ts`, which deliberately avoids the phrase below. Historical text: *"Submitting this form does not create a lawyer-client relationship. By submitting, you consent to storage of this information by SML Company in Canada for the purpose of responding to your inquiry."*
|
||||||
|
- Extends the shared `Field` component to accept `type` and `required` props, rendering a gold asterisk next to required-field labels.
|
||||||
|
|
||||||
|
The API endpoint is hard-coded at the top of the Contact section:
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
const INTAKE_API_URL = 'https://4tl0m5igkj.execute-api.ca-central-1.amazonaws.com/submissions';
|
||||||
|
```
|
||||||
|
|
||||||
|
If your API Gateway URL ever changes, update that one constant and rebuild (next step).
|
||||||
|
|
||||||
|
### Rebuilding the standalone HTML (`rebuild-standalone.py`)
|
||||||
|
|
||||||
|
A small Python script sits alongside the JSX in the project folder. It reads the original `SML ADR Site (Standalone).html`, swaps in the current contents of `components-standalone/sections.jsx`, re-compresses, and writes out a fresh `index.html` that's ready to upload to S3.
|
||||||
|
|
||||||
|
From a terminal:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd "/Users/pouya/Library/CloudStorage/GoogleDrive-pouya@smlcompany.ca/My Drive/Research/Law/ADR Personal Branding Project/Pouya Personal Branding Web"
|
||||||
|
python3 rebuild-standalone.py
|
||||||
|
```
|
||||||
|
|
||||||
|
You'll see output like:
|
||||||
|
|
||||||
|
```
|
||||||
|
patched sections.jsx -> 8830e633-... (42,792 bytes → 10,513 gz → 14,020 b64)
|
||||||
|
Wrote .../Pouya Personal Branding Web/index.html
|
||||||
|
```
|
||||||
|
|
||||||
|
That `index.html` is the file you upload to S3.
|
||||||
|
|
||||||
|
The script is intentionally limited to `sections.jsx` (where the form lives). If you ever want to edit the hero, nav, or any other component, open `rebuild-standalone.py` and uncomment the relevant line in the `JSX_FILES` mapping after discovering each component's UUID (the script docstring explains how).
|
||||||
|
|
||||||
|
> **For the first run we already did this for you** — a fresh `index.html` containing the email/phone form is sitting in the project folder right now, ready to upload.
|
||||||
|
|
||||||
|
### Deploying the change
|
||||||
|
|
||||||
|
1. Upload the new `index.html` to your S3 bucket `adr-smlcompany-site`, **replacing** the existing `index.html`. S3 versioning (enabled in Part 1.1) keeps the old version recoverable if anything goes wrong.
|
||||||
|
|
||||||
|
Console path: S3 → `adr-smlcompany-site` → **Upload** → drag `index.html` from the project folder → **Cache-Control:** `public, max-age=300, must-revalidate` → **Upload**.
|
||||||
|
|
||||||
|
Or from the terminal:
|
||||||
|
```bash
|
||||||
|
aws s3 cp \
|
||||||
|
"/Users/pouya/Library/CloudStorage/GoogleDrive-pouya@smlcompany.ca/My Drive/Research/Law/ADR Personal Branding Project/Pouya Personal Branding Web/index.html" \
|
||||||
|
s3://adr-smlcompany-site/index.html \
|
||||||
|
--cache-control 'public, max-age=300, must-revalidate'
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Invalidate CloudFront** so users see the new version immediately rather than waiting for the 5-minute cache to expire.
|
||||||
|
|
||||||
|
Console: CloudFront → your distribution → **Invalidations → Create invalidation** → object path: `/index.html` (and `/` for safety) → **Create**. Costs $0.005 per path (first 1,000 paths/month are free).
|
||||||
|
|
||||||
|
Or from the terminal:
|
||||||
|
```bash
|
||||||
|
aws cloudfront create-invalidation \
|
||||||
|
--distribution-id <YOUR-DIST-ID> \
|
||||||
|
--paths '/' '/index.html'
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Hard-refresh `https://adr.smlcompany.ca` in an incognito window. Confirm the form now shows the Email and Phone fields and the consent line under the submit button.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Part 11 — End-to-end smoke test (10 min)
|
||||||
|
|
||||||
|
In an incognito window:
|
||||||
|
|
||||||
|
1. Open `https://adr.smlcompany.ca`. Confirm green padlock, all sections render, logos load.
|
||||||
|
2. Open the browser devtools → Network tab. Submit the intake form with realistic values.
|
||||||
|
3. Confirm the network call to your API Gateway returns 200.
|
||||||
|
4. Within 30 seconds, check `adr@smlcompany.ca` — you should have a "New intake: …" email.
|
||||||
|
5. Open the DynamoDB table → **Explore table items** → you should see your test row.
|
||||||
|
6. (Optional) Try submitting from `curl` with the honeypot field set — `{"website":"http://spam"}`. You should get a 200 but **no email and no DB row** (silent drop).
|
||||||
|
|
||||||
|
✅ If all five pass, you're live.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Part 12 — Day-2 operations
|
||||||
|
|
||||||
|
### How to update the site
|
||||||
|
1. Re-export the standalone HTML.
|
||||||
|
2. Upload to S3 as `index.html` (overwrites; old version preserved by versioning).
|
||||||
|
3. CloudFront invalidate `/index.html` (and `/` for safety).
|
||||||
|
|
||||||
|
Doable in 2 minutes via the CLI:
|
||||||
|
```bash
|
||||||
|
aws s3 cp index.html s3://adr-smlcompany-site/index.html \
|
||||||
|
--cache-control 'public, max-age=300, must-revalidate'
|
||||||
|
aws cloudfront create-invalidation \
|
||||||
|
--distribution-id <DIST-ID> --paths '/' '/index.html'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Monitoring
|
||||||
|
- **CloudWatch alarm — Lambda errors:** CloudWatch → Alarms → Create alarm → Metric: Lambda → ByFunctionName → `adr-intake-handler` → Errors → Statistic Sum, period 5 min, threshold `>= 1`. Notify via an SNS topic that emails you. Alerts you within minutes if the form starts failing.
|
||||||
|
- **CloudFront 5xx error rate alarm:** same pattern, threshold `> 1%`.
|
||||||
|
- Set both with low thresholds — your traffic is low enough that any sustained error matters.
|
||||||
|
|
||||||
|
### Backups & retention
|
||||||
|
- DynamoDB PITR (Part 8.1) gives you 35-day rollback.
|
||||||
|
- S3 versioning (Part 1.1) gives you forever-rollback on the site files.
|
||||||
|
- Consider a quarterly export of the DynamoDB table to S3 if you want a clean audit trail.
|
||||||
|
|
||||||
|
### Privacy / PIPEDA hygiene (legal-services context)
|
||||||
|
- Everything lives in `ca-central-1`. CloudFront caches *static* HTML at edge locations globally, but your form *submissions* never touch CloudFront — they go directly to API Gateway in ca-central-1.
|
||||||
|
- Consider adding a one-line consent notice under the form: *"By submitting this form, you consent to its storage by SML Company in Canada for the purpose of responding to your inquiry. We do not share this information with third parties."*
|
||||||
|
- DynamoDB rows include the submitter's IP and user-agent for abuse defense. If you'd rather not store those, remove `sourceIp` and `userAgent` from the `PutCommand` Item.
|
||||||
|
|
||||||
|
### Cost expectations (USD, monthly)
|
||||||
|
| Service | Expected | Notes |
|
||||||
|
|-----------------|---------------|-----------------------------------------|
|
||||||
|
| Namecheap DNS | $0 | Included with your domain registration. |
|
||||||
|
| S3 | <$0.05 | 3 MB of files + a few requests. |
|
||||||
|
| CloudFront | $0.10–1 | Free tier covers first 1 TB out/month. |
|
||||||
|
| ACM cert | $0 | Free. |
|
||||||
|
| API Gateway | <$0.05 | $1 per million requests. |
|
||||||
|
| Lambda | $0 | Free tier covers 1M requests/mo. |
|
||||||
|
| DynamoDB | $0 | On-demand, low volume. |
|
||||||
|
| SES | $0 | First 62k emails/mo from Lambda free. |
|
||||||
|
| **Total** | **under $1** | |
|
||||||
|
|
||||||
|
### What to do if something breaks
|
||||||
|
- **Site won't load:** check CloudFront *Status = Deployed*, and that the Namecheap CNAME for `adr` points to the CloudFront domain (paste the value from `dig adr.smlcompany.ca CNAME` or `nslookup adr.smlcompany.ca` to verify it actually resolves to a `.cloudfront.net` host).
|
||||||
|
- **403 from CloudFront:** the S3 bucket policy isn't right — re-copy from CloudFront's "Origins → Edit" page.
|
||||||
|
- **TLS error:** ACM cert is in us-east-1, not ca-central-1; or the *Alternate domain name* on the CloudFront distribution doesn't match exactly.
|
||||||
|
- **Form returns 500:** open CloudWatch logs for the Lambda — almost always an unverified SES identity or a permissions gap on the role.
|
||||||
|
- **Email not arriving:** SES is still in sandbox AND the destination isn't verified, OR the From address isn't verified.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Appendix A — File / resource manifest
|
||||||
|
|
||||||
|
When you're done, here's what you should be able to point at in your AWS console:
|
||||||
|
|
||||||
|
| Resource | Name / ID |
|
||||||
|
|-----------------------|----------------------------------------------------------------------|
|
||||||
|
| S3 bucket | `adr-smlcompany-site` (ca-central-1) |
|
||||||
|
| CloudFront dist | `E…` (CNAME: adr.smlcompany.ca) |
|
||||||
|
| ACM certificate | for `adr.smlcompany.ca` (us-east-1) |
|
||||||
|
| DNS provider | Namecheap (Advanced DNS for smlcompany.ca) |
|
||||||
|
| DynamoDB table | `adr-intake-submissions` (ca-central-1) |
|
||||||
|
| Lambda function | `adr-intake-handler` (ca-central-1) |
|
||||||
|
| IAM role | `adr-intake-lambda-role` |
|
||||||
|
| API Gateway | `adr-intake-api` (HTTP API, ca-central-1) |
|
||||||
|
| SES verified ids | domain `smlcompany.ca`, email `adr@smlcompany.ca`, `intake@…` |
|
||||||
|
|
||||||
|
## Appendix B — Future enhancements (when you want them)
|
||||||
|
|
||||||
|
- **Admin dashboard for submissions:** build a tiny password-protected page that calls a second Lambda (`GET /submissions`) to list the DynamoDB table. Or just use the DynamoDB console for now — it's perfectly serviceable for low volume.
|
||||||
|
- **Confirmation email to submitter:** request SES production access (Part 9.5), then add a second `SendEmailCommand` call in the Lambda thanking them and setting expectations.
|
||||||
|
- **Calendar booking:** integrate Calendly or Cal.com link inside the "Thank you" view.
|
||||||
|
- **File uploads on intake** (e.g., a PDF of the dispute summary): add S3 presigned-URL generation in the Lambda, let the front-end upload directly to a private bucket. Keep file size limits sane.
|
||||||
|
- **Move DNS to Route 53 later:** if you ever want apex (`smlcompany.ca`) on CloudFront, or want AWS to manage records for you automatically, the migration is straightforward — inventory Namecheap records, recreate them in a new Route 53 hosted zone, switch nameservers at Namecheap. Doable in ~30 min once you have a downtime window for any DNS-sensitive integrations.
|
||||||
|
- **Bilingual (EN/FA) routing:** add a `lang` query param or subpath, serve from the same S3 bucket via CloudFront behaviors.
|
||||||
|
- **Search / analytics over submissions:** stream DynamoDB updates to a small OpenSearch index, or just export weekly to a private S3 bucket and query with Athena.
|
||||||
|
- **WAF in front of CloudFront:** if you ever see scraper/bot traffic, add AWS WAF with the AWS-managed core rule set (~$5/mo + per-request).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*End of guide. If you hit a wall on any specific step, come back here and tell me which Part and what the screen says — most issues are 1-line fixes.*
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
# Brand assets — provenance
|
||||||
|
|
||||||
|
The repository holds the artwork it makes claims about. That is not tidiness: it
|
||||||
|
is `CLAUDE.md`'s rule and `AGENTS.md` R14, and this file exists because the
|
||||||
|
infinity mark was reconstructed wrongly and **two adversarial review passes could
|
||||||
|
not catch it**, since the real artwork was not in the repo to compare against.
|
||||||
|
|
||||||
|
**Every measurement below is `[verified 2026-08-26]`** — computed with `sharp`
|
||||||
|
against the files in this repository, and re-derivable by anyone from the
|
||||||
|
commands given. Nothing here is quoted from an external source.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
| File | What it is | Use |
|
||||||
|
|---|---|---|
|
||||||
|
| `src/assets/brand/sml-infinity-mark-master.png` | The mark as supplied: **3000 × 3000, alpha**, mark inset within the canvas | **The master.** Committed so the crop below can be re-derived and checked in-repo, not taken on trust |
|
||||||
|
| `src/assets/brand/sml-infinity-mark.png` | The same mark tight-cropped to its ink bounding box: **2668 × 1704, alpha, 1.5657:1** | **The render source.** `InfinityMark.astro` feeds it to Astro's `<Picture>` |
|
||||||
|
| `src/assets/brand/sml-logo-full.png` | Mark **plus** the "SML Company" wordmark, 3000 × 3000, alpha; ink bbox 2414 × 1440 = 1.676:1 | Not currently rendered. Held for the OG-image template (`docs/04`) and print |
|
||||||
|
| `src/assets/brand/sml-logo-source.svg` | 1500 × 1500 viewBox, **257,278 bytes** | Reference. Faithful, but not what is served — see below |
|
||||||
|
|
||||||
|
## Reproducing the crop, in-repo
|
||||||
|
|
||||||
|
The crop is derived, so it is re-derivable — and now from a file that is here,
|
||||||
|
which is the whole point of R14. Scan the master for the first and last pixel
|
||||||
|
that is neither transparent (`alpha < 24`) nor near-white (`r,g,b > 243`):
|
||||||
|
|
||||||
|
```
|
||||||
|
ink bbox of sml-infinity-mark-master.png -> 2668 x 1704 at (159, 646)
|
||||||
|
sharp(master).extract({ left: 159, top: 646, width: 2668, height: 1704 })
|
||||||
|
```
|
||||||
|
|
||||||
|
Crop to that box and the file's own aspect ratio becomes the mark's, so layout
|
||||||
|
can be tuned against the asset directly rather than against a number written
|
||||||
|
down beside it. `InfinityMark.astro` pins `aspect-ratio: 667 / 426`, which is
|
||||||
|
`2668 / 1704` reduced — **1.56573**.
|
||||||
|
|
||||||
|
## Why the SVG is held but not served
|
||||||
|
|
||||||
|
It renders **faithfully**. Rasterised at 8333 px it reproduces the master
|
||||||
|
exactly, at the same **1.566:1**. It is not bad artwork, and an earlier version
|
||||||
|
of this file implied it was; that was wrong.
|
||||||
|
|
||||||
|
What rules it out is weight and composition:
|
||||||
|
|
||||||
|
- **257,278 bytes**, against **3,063 bytes** for the AVIF a Retina device
|
||||||
|
actually takes in the header. **84×.** *(This line said "9,468 bytes... 27×"
|
||||||
|
until 2026-08-27. 9,468 was the DPR-1 figure — the number for the devices the
|
||||||
|
performance budget does **not** target. Quote the figure for the device the
|
||||||
|
budget is written for.)*
|
||||||
|
- **7 embedded base64 PNGs** (`<image>` elements), so it is a hybrid rather than
|
||||||
|
pure vector — inlining it would breach `CLAUDE.md`'s rule against
|
||||||
|
base64-inlining images, which is one of the specific faults of the build this
|
||||||
|
project replaces.
|
||||||
|
- 10 `<linearGradient>` carrying **1,225 `<stop>`** elements, 64 `<path>`,
|
||||||
|
59 `<clipPath>`, 23 `<mask>`, 1 `<filter>`. Expensive to rasterise.
|
||||||
|
|
||||||
|
What `AGENTS.md` Q38 asks for is a master that is faithful **and** light.
|
||||||
|
|
||||||
|
## What the browser actually downloads
|
||||||
|
|
||||||
|
`<Picture>` emits AVIF, WebP and a PNG fallback at `densities` 1×, 2× and 3× of
|
||||||
|
whatever intrinsic `width` the call site passes. **There are now TWO ladders,
|
||||||
|
because there are two sizes of call site** `[measured 2026-08-27 — every figure
|
||||||
|
below read from the file on disk]`.
|
||||||
|
|
||||||
|
**`width={64}` — the default. The header (50.1 px wide) and footer (56.4 px).**
|
||||||
|
|
||||||
|
| | 1× (64 px) | 2× (128 px) | 3× (192 px) |
|
||||||
|
|---|---|---|---|
|
||||||
|
| AVIF | **1,720 B** | **3,063 B** | **4,843 B** |
|
||||||
|
| WebP | 2,368 B | 5,368 B | 8,664 B |
|
||||||
|
| PNG (fallback) | 6,137 B | 16,996 B | 29,780 B |
|
||||||
|
|
||||||
|
**`width={232}` — the home page's approach section, which renders at 225.5 px.**
|
||||||
|
|
||||||
|
| | 1× (232 px) | 2× (464 px) | 3× (696 px) |
|
||||||
|
|---|---|---|---|
|
||||||
|
| AVIF | **6,017 B** | **14,555 B** | **22,639 B** |
|
||||||
|
|
||||||
|
That instance is `loading="lazy"`: it sits roughly a screen and a half down, so
|
||||||
|
it is not on the LCP path. The header and footer marks stay `eager`.
|
||||||
|
|
||||||
|
**THE LADDER HAS NOW BEEN WRONG IN BOTH DIRECTIONS, which is why `width` is a
|
||||||
|
prop rather than a constant.**
|
||||||
|
|
||||||
|
- *Too big, 2026-08-26.* Sized at 320 px, justified by a 4 rem sample on the
|
||||||
|
proof sheet (a page step 2 has since deleted), with `densities` stacked on
|
||||||
|
top — so the ladder double-counted its own headroom and every DPR-2 device
|
||||||
|
pulled a 640 px image into a 56 px slot: **20,629 B**, while the docs claimed
|
||||||
|
9 KB because that is what DPR 1 took.
|
||||||
|
- *Too small, 2026-08-27.* The home page added a 225.5 px call site and
|
||||||
|
inherited the 64 px ladder, whose largest file is 192 px: **3.52× upscale at
|
||||||
|
DPR 3.** Now 232/464/696, and 696 covers the 676 device px a DPR-3 screen
|
||||||
|
asks for. All three instances measure ≤1.0× upscale at DPR 1, 2 and 3.
|
||||||
|
|
||||||
|
**Do not measure this with `img.naturalWidth`.** For an image chosen from a
|
||||||
|
`srcset` with an `x` descriptor it is **density-corrected**: the 192 px file
|
||||||
|
selected at `3x` reports 64, so reading it at DPR 1, 2 and 3 returns 64 every
|
||||||
|
time — which looks exactly like the ladder not being generated at all. Read the
|
||||||
|
files on disk.
|
||||||
|
|
||||||
|
Passing an explicit `width` is load-bearing: without it Astro emits the
|
||||||
|
untouched 2668 px master as the `<img src>` fallback — **1,146,406 bytes** —
|
||||||
|
which any client without AVIF or WebP support would actually download.
|
||||||
|
|
||||||
|
## The colours are the artwork's, not the palette's
|
||||||
|
|
||||||
|
`tokens.css` is not involved. The ribbon carries its own gradient and it is
|
||||||
|
close to but not identical with `--maroon` `#5a1a1c` and `--gold` `#c9a876`.
|
||||||
|
Do not "correct" the artwork toward the tokens, and do not derive tokens from
|
||||||
|
the artwork — D7 keeps the palette *and* the mark, as they are.
|
||||||
|
|
||||||
|
## Where these came from
|
||||||
|
|
||||||
|
Supplied by Pouya. `sml-logo-source.svg` was added by him directly to the repo;
|
||||||
|
the PNGs were taken from the `smlcompany.ca` Google Drive, under `SML/Designs/`
|
||||||
|
and `Research/Law/ADR Personal Branding Project/`. **The Drive copies are not the
|
||||||
|
record — these files are.** That is the point of R14: an artefact that lives only
|
||||||
|
in Drive cannot be compared against a claim by any reviewer, which is exactly how
|
||||||
|
the traced mark survived two review passes.
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# Fonts — provenance
|
||||||
|
|
||||||
|
Self-hosted per `docs/02-design-system.md`. **No runtime Google Fonts request**:
|
||||||
|
the page collects legal inquiries, and a third-party font call costs a round trip
|
||||||
|
and adds a third party to that page.
|
||||||
|
|
||||||
|
These `.woff2` files are committed rather than pulled at build time so their
|
||||||
|
paths are stable — a `<link rel="preload">` needs a filename that does not change
|
||||||
|
between builds, and Astro's asset hashing would break that.
|
||||||
|
|
||||||
|
| File | Source package | Version | Licence |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `instrument-serif-latin-400-normal.woff2` | `@fontsource/instrument-serif` | 5.3.0 | SIL OFL 1.1 |
|
||||||
|
| `instrument-serif-latin-ext-400-normal.woff2` | `@fontsource/instrument-serif` | 5.3.0 | SIL OFL 1.1 |
|
||||||
|
| `instrument-serif-latin-400-italic.woff2` | `@fontsource/instrument-serif` | 5.3.0 | SIL OFL 1.1 |
|
||||||
|
| `geist-latin-wght-normal.woff2` | `@fontsource-variable/geist` | 5.3.0 | SIL OFL 1.1 |
|
||||||
|
| `geist-latin-ext-wght-normal.woff2` | `@fontsource-variable/geist` | 5.3.0 | SIL OFL 1.1 |
|
||||||
|
| `geist-mono-latin-wght-normal.woff2` | `@fontsource-variable/geist-mono` | 5.3.0 | SIL OFL 1.1 |
|
||||||
|
|
||||||
|
**The `?v=1` on every font URL is load-bearing.** `scripts/deploy-local.sh`
|
||||||
|
serves `/fonts/*` with `max-age=31536000, immutable`, so a returning visitor
|
||||||
|
holds these bytes for a year and no CloudFront invalidation can reach their
|
||||||
|
browser cache. Replacing a file means bumping that query — in
|
||||||
|
`src/styles/global.css` **and** in the `<link rel="preload">` in
|
||||||
|
`BaseLayout.astro`, which must match character for character or the preload
|
||||||
|
fetches a second copy instead of warming the cache.
|
||||||
|
|
||||||
|
Fetched 2026-08-26 with `npm pack <pkg>@5.3.0` and extracted from `package/files/`.
|
||||||
|
Subsetting is Fontsource's, not ours — the `latin` and `latin-ext` cuts are
|
||||||
|
exactly the "Latin + Latin Extended-A" the design system asks for.
|
||||||
|
|
||||||
|
**123,804 bytes across all six**, of which only two — Instrument Serif 400 latin
|
||||||
|
(21,032) and Geist latin (29,400) — are preloaded, so first paint pulls about
|
||||||
|
50 kB. Quote the byte figure, not `du -sh`, which reports 136K because it counts
|
||||||
|
disk blocks rather than what crosses the wire.
|
||||||
|
|
||||||
|
**Not covered by `AGENTS.md` R11.** R11 re-checks npm pins for currency and
|
||||||
|
advisories; these are static binaries with no runtime and no dependency tree.
|
||||||
|
Refresh them deliberately — when a face gains glyphs the site needs — by
|
||||||
|
repeating the `npm pack` above, not on a currency schedule.
|
||||||
|
|
||||||
|
**Deliberately absent:** the `latin-ext` italic cut of Instrument Serif, and
|
||||||
|
every non-Latin cut of all three faces. `.display .it` is one italic phrase in a
|
||||||
|
headline (`docs/02`), and D4 makes the site English-only. Add a cut when a page
|
||||||
|
needs it; do not add all of them pre-emptively.
|
||||||
@@ -4,7 +4,9 @@
|
|||||||
#
|
#
|
||||||
# This file is kept because it is the better design: GitHub OIDC issues a
|
# 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
|
# short-lived token per run instead of a static key. If the project ever moves
|
||||||
# to GitHub or GitLab, use this and delete the static IAM user.
|
# to GitHub, use this and delete the static IAM user. GitLab also federates
|
||||||
|
# to AWS by OIDC, but with entirely different CI syntax — this file is the
|
||||||
|
# design there, not the implementation.
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
name: Build and deploy
|
name: Build and deploy
|
||||||
|
|
||||||
@@ -13,8 +15,9 @@ on:
|
|||||||
branches: [main]
|
branches: [main]
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
# OIDC role assumption — no long-lived AWS credentials in this repository.
|
# OIDC role assumption — a short-lived token per run, no static key.
|
||||||
# See docs/06-deployment.md for the one-time IAM setup.
|
# NOT the current posture: this repository deploys with a static IAM key.
|
||||||
|
# See docs/06-deployment.md for the live procedure and the IAM policy.
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
id-token: write
|
id-token: write
|
||||||
@@ -49,15 +52,17 @@ jobs:
|
|||||||
PUBLIC_INTAKE_ENDPOINT: ${{ vars.INTAKE_ENDPOINT }}
|
PUBLIC_INTAKE_ENDPOINT: ${{ vars.INTAKE_ENDPOINT }}
|
||||||
PUBLIC_BOOKING_URL: ${{ vars.BOOKING_URL }}
|
PUBLIC_BOOKING_URL: ${{ vars.BOOKING_URL }}
|
||||||
|
|
||||||
# TODO(pouya): AGENTS.md Q9, Q10 — set these repository variables:
|
# If adopting this: set AWS_DEPLOY_ROLE_ARN as a repository variable. The
|
||||||
# AWS_DEPLOY_ROLE_ARN, AWS_REGION, S3_BUCKET, CLOUDFRONT_DISTRIBUTION_ID
|
# rest — AWS_REGION, S3_BUCKET, CLOUDFRONT_DISTRIBUTION_ID, INTAKE_ENDPOINT and
|
||||||
|
# BOOKING_URL — are recorded in docs/06-deployment.md.
|
||||||
- name: Configure AWS credentials
|
- name: Configure AWS credentials
|
||||||
uses: aws-actions/configure-aws-credentials@v4
|
uses: aws-actions/configure-aws-credentials@v4
|
||||||
with:
|
with:
|
||||||
role-to-assume: ${{ vars.AWS_DEPLOY_ROLE_ARN }}
|
role-to-assume: ${{ vars.AWS_DEPLOY_ROLE_ARN }}
|
||||||
aws-region: ${{ vars.AWS_REGION }}
|
aws-region: ${{ vars.AWS_REGION }}
|
||||||
|
|
||||||
# Two passes: hashed immutable assets first, HTML last. A visitor must
|
# Three passes: hashed immutable assets first, then images, HTML last.
|
||||||
|
# A visitor must
|
||||||
# never fetch a new page whose assets have not landed yet.
|
# never fetch a new page whose assets have not landed yet.
|
||||||
- name: Sync hashed assets
|
- name: Sync hashed assets
|
||||||
run: |
|
run: |
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
# Reference — how the Licence Appeal Tribunal actually runs its pre-hearing step
|
||||||
|
|
||||||
|
**Why this file exists.** `AGENTS.md` Q41(c) asked what `LAT pre-hearing
|
||||||
|
mediation` means as an offering. Pouya's ruling of 2026-08-27: *"'LAT pre-hearing
|
||||||
|
mediation' is imprecise and must not imply appointment by the tribunal. Verify
|
||||||
|
against LAT's own materials how its case-conference process is conducted and who
|
||||||
|
conducts it."* This is that verification, committed rather than cited, under
|
||||||
|
`CLAUDE.md`'s rule that anything a spec makes a claim about must be reachable
|
||||||
|
from the repository (R14).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Provenance — read this before quoting anything below
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| Source 1 | `https://tribunalsontario.ca/documents/lat/LAT-Rules.html` — *Licence Appeal Tribunal, Animal Care Review Board and Fire Safety Commission Rules of Practice and Procedure*, effective July 21, 2023 |
|
||||||
|
| Source 2 | `https://tribunalsontario.ca/lat-aabs/application-and-hearing-process/` — LAT‑AABS, *Application and hearing process* |
|
||||||
|
| Retrieved | **2026-08-28** |
|
||||||
|
| Method | `curl -sS -o <file> '<url>'` — HTTP **200** both; **88,429 B** (rules) and **107,996 B** (AABS page) |
|
||||||
|
| Text extraction | script/style stripped, tags stripped, entities unescaped, whitespace collapsed → **66,593** and **33,696** characters |
|
||||||
|
|
||||||
|
> ⚠️ **NO HASHES, AND THE REASON IS THE POINT.** This table carried `sha256`
|
||||||
|
> prefixes `07d9c077e41cc8bd` and `08eff9a73b683cc5`. They are removed because
|
||||||
|
> **they cannot be reproduced, and a stamp that cannot be re-checked is worse
|
||||||
|
> than no stamp** — a future reader who re-fetches and gets a different digest
|
||||||
|
> would conclude the source had changed when it had not.
|
||||||
|
>
|
||||||
|
> Found by `adversarial-reviewer`, which re-fetched both URLs and got two
|
||||||
|
> different digests, then two more on two further fetches. Cause isolated by
|
||||||
|
> diffing consecutive responses: `LAT-Rules.html` carries a per-request
|
||||||
|
> bot-detection nonce (`__uzdbm_1`, `__uzdbm_2`), and the AABS page carries
|
||||||
|
> rotating WordPress `?ver=` cache-busters. **The sha256 of these URLs is not a
|
||||||
|
> stable quantity.**
|
||||||
|
>
|
||||||
|
> What *does* reproduce, and was independently reproduced: **both byte counts
|
||||||
|
> exactly**, **all ten verbatim quotes** with their rule numbers and headings, and
|
||||||
|
> **all four term counts**. So the substance of this file is verified twice over;
|
||||||
|
> only the hashes were spurious. Same family as the `1.23:1` bounding box and the
|
||||||
|
> `timeout 60 ls` in `CLAUDE.md` — a number that looks like verification, from a
|
||||||
|
> probe nobody validated.
|
||||||
|
|
||||||
|
**Instrument check, because `CLAUDE.md` requires one.** The word counts below were
|
||||||
|
taken from the **raw fetched bytes**, not from a `WebFetch` answer. `WebFetch`
|
||||||
|
answers through a summarising model, so a "quote" it returns may be a paraphrase
|
||||||
|
— and the first pass here did return a plausible-looking Rule 14.4 quote
|
||||||
|
(*"The case conference is an important opportunity to discuss settlement"*) that
|
||||||
|
turned out to be **correct**, and a Rule 14.6 gloss that was **not** how the rule
|
||||||
|
reads. Both were then checked against the literal text. Quotes in this file are
|
||||||
|
literal; where the two disagreed the literal text won.
|
||||||
|
|
||||||
|
A second instrument note: the rules document repeats every heading in a table of
|
||||||
|
contents before the body, so a naive "find the heading" extraction returns the
|
||||||
|
**TOC** and reports the rules as empty. The bodies are present, ~35 KB further in.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Finding 1 — the LAT's settlement step is a *case conference*, and a Tribunal Member conducts it
|
||||||
|
|
||||||
|
**Rule 2.4, verbatim:**
|
||||||
|
|
||||||
|
> "Case Conference" has the same meaning as "Pre-Hearing Conference" as defined
|
||||||
|
> in the SPPA.
|
||||||
|
|
||||||
|
So **"pre-hearing" is the LAT's own term, and what it names is a case
|
||||||
|
conference** — not a mediation.
|
||||||
|
|
||||||
|
**Rule 14.2 — Scope of case conference subject matter, verbatim opening:**
|
||||||
|
|
||||||
|
> The Tribunal may on its own initiative, or in response to a party's written
|
||||||
|
> request, direct the parties to participate in a case conference to consider:
|
||||||
|
> The settlement of any or all of the issues; […]
|
||||||
|
|
||||||
|
**Rule 14.3 — Member not to participate on a hearing panel, verbatim:**
|
||||||
|
|
||||||
|
> A Member who presides at or otherwise takes part in a case conference shall not
|
||||||
|
> participate as a Member of a panel at a subsequent hearing of the appeal except
|
||||||
|
> with the consent of the parties.
|
||||||
|
|
||||||
|
**Rule 14.4 — Settlement discussions, verbatim:**
|
||||||
|
|
||||||
|
> The case conference is an important opportunity to discuss settlement of the
|
||||||
|
> issues without the need for a hearing. The parties are expected to come to the
|
||||||
|
> case conference prepared to discuss settlement.
|
||||||
|
>
|
||||||
|
> All settlement discussions in a case conference and the documents put forward
|
||||||
|
> solely for the purpose of settlement are confidential. Settlement discussions
|
||||||
|
> are held on a "without prejudice" basis. Settlement discussions shall not be
|
||||||
|
> communicated to the Member that participates in the hearing or otherwise be
|
||||||
|
> relied on in a hearing before the Tribunal for any purpose unless the parties
|
||||||
|
> consent.
|
||||||
|
|
||||||
|
**Rule 14.6 — Party attendance, verbatim first sentence:**
|
||||||
|
|
||||||
|
> A party as defined under Rule 2.16 must attend their case conference.
|
||||||
|
|
||||||
|
**Rule 12 — Format, verbatim:**
|
||||||
|
|
||||||
|
> In accordance with applicable provisions of the SPPA, the Tribunal may hold a
|
||||||
|
> hearing or case conference in any of the following formats, as it considers
|
||||||
|
> appropriate: In-person; Electronic; Written; or Any combination of the above.
|
||||||
|
|
||||||
|
The public LAT‑AABS page adds, of the same step: *"A case conference is led by an
|
||||||
|
adjudicator whose role is to guide and support the parties in working to resolve
|
||||||
|
the dispute."*
|
||||||
|
|
||||||
|
**Consequence:** the neutral in the LAT's pre-hearing step is a **Member /
|
||||||
|
adjudicator of the Tribunal**. It is directed by the Tribunal, attendance is
|
||||||
|
mandatory, and the Member is disqualified from the subsequent hearing panel. A
|
||||||
|
privately retained neutral is not appointed to it and cannot be.
|
||||||
|
|
||||||
|
## Finding 2 — the LAT Rules never use the words "mediation", "mediator" or "arbitration"
|
||||||
|
|
||||||
|
Counted on the literal extracted text, case-sensitively for both cases:
|
||||||
|
|
||||||
|
```
|
||||||
|
lat-rules.html 66,593 chars 'mediat' 0 'Mediat' 0 'arbitrat' 0 'Arbitrat' 0
|
||||||
|
lat-aabs.html 33,696 chars 'mediat' 1 'Mediat' 0 'arbitrat' 0 'Arbitrat' 0
|
||||||
|
```
|
||||||
|
|
||||||
|
**Zero** in the Rules. There is no rule providing for the Tribunal to appoint an
|
||||||
|
external mediator, and no rule about a party retaining a private neutral —
|
||||||
|
because the Rules do not contemplate the concept at all.
|
||||||
|
|
||||||
|
## Finding 3 — the single match, read rather than counted
|
||||||
|
|
||||||
|
`CLAUDE.md`: *a grep that matches is not a finding until you read what it
|
||||||
|
matched.* The one `mediat` on the AABS page, printed with its heading, is this —
|
||||||
|
and it is the affirmative basis for the offering rather than a problem for it:
|
||||||
|
|
||||||
|
> **4. Consider other ways to resolve your dispute**
|
||||||
|
>
|
||||||
|
> Before you apply to the LAT‑AABS, you may want to consider negotiation or
|
||||||
|
> mediation services. Parties are encouraged to attempt to negotiate the claim
|
||||||
|
> at all times, including before filing at the LAT‑AABS, and continuing
|
||||||
|
> negotiation discussions after a claim has been filed.
|
||||||
|
|
||||||
|
The Tribunal itself points parties at private mediation, **before filing and
|
||||||
|
continuing after filing.** That is exactly the space a privately retained
|
||||||
|
mediator occupies, and it is the Tribunal's own words for it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What this establishes, and what it does not
|
||||||
|
|
||||||
|
**Establishes:**
|
||||||
|
|
||||||
|
1. The LAT's pre-hearing settlement step is a **case conference conducted by a
|
||||||
|
Tribunal Member**. `LAT pre-hearing mediation` therefore describes a thing
|
||||||
|
that does not exist, and the half a reader would recognise — *pre-hearing* —
|
||||||
|
is the Tribunal's own label for a step nobody outside the Tribunal conducts.
|
||||||
|
2. Private mediation of accident-benefits and SABS disputes is **compatible with
|
||||||
|
a LAT application, before filing or after**, and the Tribunal says so.
|
||||||
|
|
||||||
|
**Does not establish:**
|
||||||
|
|
||||||
|
- Anything about whether Pouya holds a roster position with the LAT or Tribunals
|
||||||
|
Ontario. Nothing here bears on that. §4 has no such row, so the site claims
|
||||||
|
none — per Pouya's ruling: *"If Pouya holds a roster position that makes more
|
||||||
|
than that true, it is a §4 addition — absent a row, it isn't."*
|
||||||
|
- Anything about *commercial* arbitration gating. Same caution as
|
||||||
|
`ontario-family-arbitration-training.md`: a source about one process is not
|
||||||
|
authority about another. These documents do not mention arbitration at all.
|
||||||
|
|
||||||
|
## The wording that follows from it
|
||||||
|
|
||||||
|
**Never publish** `LAT pre-hearing mediation`, or any phrasing in which a LAT
|
||||||
|
proceeding appears to appoint or host the mediator.
|
||||||
|
|
||||||
|
**Published instead** — `src/data/site.ts`, `PRACTICE_AREAS` → `insurance`:
|
||||||
|
|
||||||
|
> Accident benefits and SABS entitlement, MIG disputes, and private mediation
|
||||||
|
> alongside a LAT application, before filing or after.
|
||||||
|
|
||||||
|
`docs/01` keeps `LAT pre-hearing mediation` as a **search intent** — people do
|
||||||
|
type it — with a note that it must never be lifted into copy. That lift is
|
||||||
|
exactly what happened once already.
|
||||||
|
|
||||||
|
`/practice/insurance/` at build step 5 must state that the mediation offered is
|
||||||
|
**private**, retained by the parties, and **not the Tribunal's case conference**.
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
# Reference — Ontario's training requirements for family arbitrators
|
||||||
|
|
||||||
|
**Why this file exists.** `AGENTS.md` §4 Offerings rests on a proposition about
|
||||||
|
what Ontario law does and does not gate. R14: *anything a spec makes a claim
|
||||||
|
about must be reachable from the repository* — a claim whose source lives only
|
||||||
|
at a URL is one a reviewer can be asked to trust rather than check. This is the
|
||||||
|
extract, with its provenance and the command that produced it.
|
||||||
|
|
||||||
|
It is **not** legal advice and it is not a substitute for the instruments
|
||||||
|
themselves. It records what one government page said on one day.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Provenance
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| Source | `https://www.ontario.ca/page/training-family-arbitrators` |
|
||||||
|
| Retrieved | **2026-08-27** |
|
||||||
|
| Method | `WebFetch` — page converted to markdown, then queried for verbatim requirements, hour figures, the lawyer / non-lawyer distinction, ongoing-training period, and every statute or regulation named |
|
||||||
|
| Retrieved by | Claude Code, on Pouya's instruction of 2026-08-27 (Q39) |
|
||||||
|
| Cited by Pouya | Yes — this is the source named in his Q39 ruling, with the same three hour figures |
|
||||||
|
|
||||||
|
**Re-derive it:** fetch the URL and read it. If the page has changed, record the
|
||||||
|
change here rather than editing the extract — a stale extract with a date is
|
||||||
|
useful; a silently updated one is not.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What the page states
|
||||||
|
|
||||||
|
Quoted as retrieved. Where the page's own wording is reproduced it is in
|
||||||
|
quotation marks.
|
||||||
|
|
||||||
|
**Screening for domestic violence and power imbalances.** "at least 14 hours
|
||||||
|
(within one week) to learn about screening parties for domestic violence and
|
||||||
|
power imbalances".
|
||||||
|
|
||||||
|
**Ontario family law — non-lawyers only.** "All family law arbitrators who are
|
||||||
|
not a part of the Ontario Bar, or another Canadian bar, must complete 30 hours
|
||||||
|
of training about Ontario family law." The page adds that "You do not need to
|
||||||
|
complete this training all at once."
|
||||||
|
|
||||||
|
**Members of the Ontario Bar.** No hour figure. The page states instead that
|
||||||
|
"you should ensure you are familiar with family law to fulfil your professional
|
||||||
|
obligation to provide services competently."
|
||||||
|
|
||||||
|
**Ongoing training.** "10 hours over any two-year period. Five of these hours
|
||||||
|
must be related to domestic violence or power imbalance issues".
|
||||||
|
|
||||||
|
**Statute named on the page.** *Arbitration Act, 1991*. **No section number and
|
||||||
|
no regulation (`O. Reg.`) number appears on the page.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What the page does NOT state — and this half matters more
|
||||||
|
|
||||||
|
**It says nothing about commercial arbitration.** Commercial arbitration is
|
||||||
|
neither mentioned nor excluded. The page does not state which arbitrations the
|
||||||
|
requirements apply to beyond describing them as family arbitration.
|
||||||
|
|
||||||
|
So the source establishes the **family** half of §4's scoped proposition
|
||||||
|
directly, and the **commercial** half only by *absence* — a page about family
|
||||||
|
arbitrators is not authority for what commercial arbitrators need. §4 records
|
||||||
|
the commercial half as **Pouya's stated position**, attributed to him and
|
||||||
|
deliberately unstamped, for exactly that reason.
|
||||||
|
|
||||||
|
**Nothing on the site turns on the gated activity.** Pouya has confirmed he does
|
||||||
|
not accept family arbitration under the *Family Law Act* (§4 Offerings, scope
|
||||||
|
exclusion). The requirements above are recorded because the register reasoned
|
||||||
|
from a false universal for a day and must not do so again — not because the
|
||||||
|
practice sits anywhere near them.
|
||||||
|
|
||||||
|
**Do not upgrade this file into an authority it is not.** If a stronger source
|
||||||
|
is ever wanted — the *Family Law Act* provisions and the regulation made under
|
||||||
|
it — fetch and extract those, name them by number, and date them. Do not write
|
||||||
|
a section number from memory.
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
// ESLint 10 flat config. Scope is deliberately small: this project targets zero
|
||||||
|
// client JavaScript (CLAUDE.md, AGENTS.md §7), so the only JS/TS here is build
|
||||||
|
// configuration, site data, and the occasional island. Rules exist to catch
|
||||||
|
// mistakes, not to impose style — Prettier owns formatting.
|
||||||
|
//
|
||||||
|
// `typescript-eslint` is here because .astro frontmatter IS TypeScript, so the
|
||||||
|
// plugin cannot parse a single component without it. It runs unconfigured for
|
||||||
|
// type-awareness on purpose: `astro check` already does the type checking, and
|
||||||
|
// duplicating it here would be slower and would disagree at the edges.
|
||||||
|
|
||||||
|
import js from '@eslint/js';
|
||||||
|
import globals from 'globals';
|
||||||
|
import tseslint from 'typescript-eslint';
|
||||||
|
import astro from 'eslint-plugin-astro';
|
||||||
|
|
||||||
|
export default [
|
||||||
|
{ ignores: ['dist/**', 'node_modules/**', '.astro/**', 'docs/reference/**'] },
|
||||||
|
|
||||||
|
js.configs.recommended,
|
||||||
|
...tseslint.configs.recommended,
|
||||||
|
...astro.configs.recommended,
|
||||||
|
...astro.configs['flat/jsx-a11y-recommended'],
|
||||||
|
|
||||||
|
// `no-undef` off for TYPESCRIPT ONLY, on typescript-eslint's own advice: it
|
||||||
|
// has no type information, so every ambient global is a false positive —
|
||||||
|
// Astro declares `ImageMetadata`, `astroHTML.JSX` and friends globally, and
|
||||||
|
// .astro frontmatter IS TypeScript. tsc catches a real undefined reference,
|
||||||
|
// which is what `npm run check` is for.
|
||||||
|
//
|
||||||
|
// NOT applied to .js/.mjs. `tsconfig.json` sets `allowJs` without `checkJs`,
|
||||||
|
// so plain JS is not type-checked by anything — turning the rule off there
|
||||||
|
// meant a typo like `procss.env.X` in astro.config.mjs passed lint silently.
|
||||||
|
{
|
||||||
|
files: ['**/*.ts', '**/*.astro'],
|
||||||
|
rules: { 'no-undef': 'off' },
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
files: ['**/*.{js,mjs,ts}', '**/*.astro'],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2023,
|
||||||
|
sourceType: 'module',
|
||||||
|
globals: { ...globals.browser, ...globals.node },
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
// A stray console.log in a static build is dead weight shipped to nobody.
|
||||||
|
'no-console': ['warn', { allow: ['warn', 'error'] }],
|
||||||
|
|
||||||
|
// `role="list"` on a <ul> is redundant to a spec reader and load-bearing
|
||||||
|
// in a browser: Safari drops list semantics from any list styled
|
||||||
|
// `list-style: none`, so VoiceOver stops announcing "list, 6 items".
|
||||||
|
// src/styles/global.css keys its own reset off `ul[role='list']` for
|
||||||
|
// exactly this reason. The rule is right in general; this is the one
|
||||||
|
// documented exception, and it is scoped to that single pairing.
|
||||||
|
'astro/jsx-a11y/no-redundant-roles': [
|
||||||
|
'error',
|
||||||
|
{ ul: ['list'], ol: ['list'] },
|
||||||
|
],
|
||||||
|
eqeqeq: ['error', 'always'],
|
||||||
|
'prefer-const': 'error',
|
||||||
|
'@typescript-eslint/no-unused-vars': [
|
||||||
|
'error',
|
||||||
|
{ argsIgnorePattern: '^_' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
Generated
+10397
File diff suppressed because it is too large
Load Diff
+24
-13
@@ -4,7 +4,10 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"description": "The dispute resolution practice of Pouya Lajevardi — Toronto",
|
"description": "The dispute resolution practice of Pouya Lajevardi — Toronto",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"engines": { "node": ">=22" },
|
"engines": {
|
||||||
|
"node": "^22.13.0 || >=24",
|
||||||
|
"npm": ">=9.6.5"
|
||||||
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "astro dev",
|
"dev": "astro dev",
|
||||||
"build": "astro build",
|
"build": "astro build",
|
||||||
@@ -12,21 +15,29 @@
|
|||||||
"check": "astro check",
|
"check": "astro check",
|
||||||
"lint": "eslint . && prettier --check .",
|
"lint": "eslint . && prettier --check .",
|
||||||
"format": "prettier --write .",
|
"format": "prettier --write .",
|
||||||
"lighthouse": "lhci autorun"
|
"deploy": "bash scripts/deploy-local.sh"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"astro": "^5.0.0",
|
"@astrojs/mdx": "^7.0.8",
|
||||||
"@astrojs/mdx": "^4.0.0",
|
"@astrojs/sitemap": "^3.7.3",
|
||||||
"@astrojs/sitemap": "^3.2.0",
|
"astro": "^7.2.9",
|
||||||
"sharp": "^0.33.0"
|
"sharp": "^0.35.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@astrojs/check": "^0.9.0",
|
"@astrojs/check": "^0.9.10",
|
||||||
"typescript": "^5.7.0",
|
"@eslint/js": "^10.0.1",
|
||||||
"prettier": "^3.4.0",
|
"eslint": "^10.9.1",
|
||||||
"prettier-plugin-astro": "^0.14.0",
|
"eslint-plugin-astro": "^3.1.0",
|
||||||
"eslint": "^9.0.0",
|
"eslint-plugin-jsx-a11y": "^6.10.2",
|
||||||
"eslint-plugin-astro": "^1.3.0",
|
"globals": "^17.11.0",
|
||||||
"@lhci/cli": "^0.14.0"
|
"prettier": "^3.9.6",
|
||||||
|
"prettier-plugin-astro": "^0.14.1",
|
||||||
|
"typescript": "^6.0.3",
|
||||||
|
"typescript-eslint": "^8.68.0"
|
||||||
|
},
|
||||||
|
"overrides": {
|
||||||
|
"eslint-plugin-jsx-a11y": {
|
||||||
|
"eslint": "$eslint"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 4.7 KiB |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+11
-1
@@ -2,8 +2,18 @@
|
|||||||
# AI crawlers are deliberately allowed. Being read by an assistant that counsel
|
# 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.
|
# is using to shortlist a neutral is the point. See docs/04-seo-spec.md.
|
||||||
|
|
||||||
|
# NOTHING IS DISALLOWED, DELIBERATELY.
|
||||||
|
# /legal/* is kept out of the index by
|
||||||
|
# `<meta name="robots" content="noindex,follow">`, which is the directive that
|
||||||
|
# actually de-indexes. Disallowing them as well would defeat it: a crawler that
|
||||||
|
# is forbidden to FETCH a URL never reads the noindex on it. The legal pages are
|
||||||
|
# linked from the footer of every page, so Google discovers them regardless and
|
||||||
|
# would have listed the bare URLs as "no information available" — the opposite
|
||||||
|
# of the intent — with the noindex sitting unread behind the wall.
|
||||||
|
# Add a Disallow only for something that must not be FETCHED. Use noindex for
|
||||||
|
# something that must not be LISTED. They are different problems.
|
||||||
|
|
||||||
User-agent: *
|
User-agent: *
|
||||||
Allow: /
|
Allow: /
|
||||||
Disallow: /legal/
|
|
||||||
|
|
||||||
Sitemap: https://adr.smlcompany.ca/sitemap-index.xml
|
Sitemap: https://adr.smlcompany.ca/sitemap-index.xml
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Collects the AWS resource identifiers this project needs (AGENTS.md Q10).
|
# Collects the AWS resource identifiers this project needs (AGENTS.md Q10).
|
||||||
# Read-only: every call is a list/describe. Nothing is created or changed.
|
# Read-only: no call creates or mutates anything.
|
||||||
#
|
#
|
||||||
# chmod +x scripts/aws-discover.sh
|
# chmod +x scripts/aws-discover.sh
|
||||||
# ./scripts/aws-discover.sh > aws-inventory.txt
|
# ./scripts/aws-discover.sh > aws-inventory.txt
|
||||||
@@ -14,7 +14,7 @@ set -uo pipefail
|
|||||||
hr() { printf '\n== %s %s\n' "$1" "$(printf '=%.0s' $(seq 1 $((60 - ${#1}))))"; }
|
hr() { printf '\n== %s %s\n' "$1" "$(printf '=%.0s' $(seq 1 $((60 - ${#1}))))"; }
|
||||||
try() { "$@" 2>&1 || echo " (failed — check permissions or region)"; }
|
try() { "$@" 2>&1 || echo " (failed — check permissions or region)"; }
|
||||||
|
|
||||||
command -v aws >/dev/null || { echo "AWS CLI not installed. See AWS-Hosting-Guide.md Part 0.5"; exit 1; }
|
command -v aws >/dev/null || { echo "AWS CLI not installed. See docs/reference/AWS-Hosting-Guide.md Part 0.5"; exit 1; }
|
||||||
|
|
||||||
hr "Identity and default region"
|
hr "Identity and default region"
|
||||||
try aws sts get-caller-identity --output table
|
try aws sts get-caller-identity --output table
|
||||||
|
|||||||
Executable
+95
@@ -0,0 +1,95 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# Local deploy — the fallback while Gitea Actions is unavailable.
|
||||||
|
#
|
||||||
|
# Gitea Actions needs `[actions] ENABLED = true` in app.ini and a registered
|
||||||
|
# act_runner. The instance is jointly administered, so both depend on a second
|
||||||
|
# administrator (AGENTS.md Q23). Until that lands, this script is how the site
|
||||||
|
# ships.
|
||||||
|
#
|
||||||
|
# It matches .gitea/workflows/deploy.yml on everything that determines what gets
|
||||||
|
# published: the same guard coverage, `npm run check` before the build, the same
|
||||||
|
# three sync passes in the same order with the same cache headers, and the same
|
||||||
|
# invalidation. Any change to one must be made to the other.
|
||||||
|
#
|
||||||
|
# Two deliberate differences: it does not run `npm ci` (your node_modules is
|
||||||
|
# already installed, and CI starts empty), and it refuses to run as user/pouya,
|
||||||
|
# which CI cannot do because CI has no such credential.
|
||||||
|
#
|
||||||
|
# Required environment (values are in AGENTS.md §7 — deliberately not restated
|
||||||
|
# here; §7 is the single source of truth for operational facts):
|
||||||
|
#
|
||||||
|
# AWS_REGION S3_BUCKET CLOUDFRONT_DISTRIBUTION_ID INTAKE_ENDPOINT
|
||||||
|
#
|
||||||
|
# Credentials: use the scoped deploy user. AGENTS.md Q22 records that it does
|
||||||
|
# NOT yet exist. NEVER run this as user/pouya — see AGENTS.md §10.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Same six values the workflow guards. Emptiness only — no value is echoed.
|
||||||
|
missing=''
|
||||||
|
[ -n "${AWS_REGION:-}" ] || missing="$missing AWS_REGION"
|
||||||
|
[ -n "${S3_BUCKET:-}" ] || missing="$missing S3_BUCKET"
|
||||||
|
[ -n "${CLOUDFRONT_DISTRIBUTION_ID:-}" ] || missing="$missing CLOUDFRONT_DISTRIBUTION_ID"
|
||||||
|
[ -n "${INTAKE_ENDPOINT:-}" ] || missing="$missing INTAKE_ENDPOINT"
|
||||||
|
[ -n "${AWS_ACCESS_KEY_ID:-}" ] || missing="$missing AWS_ACCESS_KEY_ID"
|
||||||
|
[ -n "${AWS_SECRET_ACCESS_KEY:-}" ] || missing="$missing AWS_SECRET_ACCESS_KEY"
|
||||||
|
if [ -n "$missing" ]; then
|
||||||
|
echo "Not set:$missing" >&2
|
||||||
|
echo >&2
|
||||||
|
echo "Values are in AGENTS.md §7. An empty INTAKE_ENDPOINT does not fail the" >&2
|
||||||
|
echo "build — it ships a live contact form posting to nothing." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
export AWS_DEFAULT_REGION="$AWS_REGION"
|
||||||
|
|
||||||
|
echo "==> Identity check"
|
||||||
|
caller=$(aws sts get-caller-identity --query Arn --output text)
|
||||||
|
echo " $caller"
|
||||||
|
case "$caller" in
|
||||||
|
*:user/pouya)
|
||||||
|
echo >&2
|
||||||
|
echo "REFUSING: that is the broadly-permissioned personal user." >&2
|
||||||
|
echo "AGENTS.md §10 — never use user/pouya to deploy. Use the scoped" >&2
|
||||||
|
echo "deploy user (Q22: not yet created)." >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
echo "==> Type and template check"
|
||||||
|
npm run check
|
||||||
|
|
||||||
|
echo "==> Build"
|
||||||
|
PUBLIC_SITE_URL="https://adr.smlcompany.ca" \
|
||||||
|
PUBLIC_INTAKE_ENDPOINT="$INTAKE_ENDPOINT" \
|
||||||
|
PUBLIC_BOOKING_URL="${BOOKING_URL:-}" \
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
echo "==> Pass 1/3 — hashed assets and fonts (immutable)"
|
||||||
|
aws s3 sync ./dist "s3://${S3_BUCKET}" \
|
||||||
|
--exclude "*" \
|
||||||
|
--include "_astro/*" --include "fonts/*" \
|
||||||
|
--cache-control "public, max-age=31536000, immutable" \
|
||||||
|
--no-progress
|
||||||
|
|
||||||
|
echo "==> Pass 2/3 — images"
|
||||||
|
aws s3 sync ./dist "s3://${S3_BUCKET}" \
|
||||||
|
--exclude "*" \
|
||||||
|
--include "*.avif" --include "*.webp" --include "*.jpg" \
|
||||||
|
--include "*.png" --include "*.svg" \
|
||||||
|
--cache-control "public, max-age=604800" \
|
||||||
|
--no-progress
|
||||||
|
|
||||||
|
echo "==> Pass 3/3 — HTML and the rest (must-revalidate, --delete)"
|
||||||
|
aws s3 sync ./dist "s3://${S3_BUCKET}" \
|
||||||
|
--exclude "_astro/*" --exclude "fonts/*" \
|
||||||
|
--cache-control "public, max-age=0, must-revalidate" \
|
||||||
|
--delete --no-progress
|
||||||
|
|
||||||
|
echo "==> Invalidate CloudFront"
|
||||||
|
aws cloudfront create-invalidation \
|
||||||
|
--distribution-id "${CLOUDFRONT_DISTRIBUTION_ID}" \
|
||||||
|
--paths "/*" >/dev/null
|
||||||
|
|
||||||
|
echo "==> Deployed to https://adr.smlcompany.ca ($(git rev-parse --short HEAD))"
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 1.1 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.1 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 586 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 251 KiB |
@@ -0,0 +1,87 @@
|
|||||||
|
---
|
||||||
|
/**
|
||||||
|
* docs/02-design-system.md: variants primary (maroon) · ghost (outlined) ·
|
||||||
|
* gold (ink background, gold-l text). Renders an <a> or a <button> correctly —
|
||||||
|
* a link that navigates must be an <a>, whatever it looks like.
|
||||||
|
*/
|
||||||
|
interface Props {
|
||||||
|
href?: string;
|
||||||
|
variant?: 'primary' | 'ghost' | 'gold';
|
||||||
|
type?: 'button' | 'submit';
|
||||||
|
class?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
href,
|
||||||
|
variant = 'primary',
|
||||||
|
type = 'button',
|
||||||
|
class: className,
|
||||||
|
} = Astro.props;
|
||||||
|
const classes = ['btn', `btn-${variant}`, className];
|
||||||
|
---
|
||||||
|
|
||||||
|
{
|
||||||
|
href ? (
|
||||||
|
<a href={href} class:list={classes}>
|
||||||
|
<slot />
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<button type={type} class:list={classes}>
|
||||||
|
<slot />
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
/* 44 × 44 is the touch-target floor in docs/02. */
|
||||||
|
min-block-size: 44px;
|
||||||
|
padding-block: var(--space-3);
|
||||||
|
padding-inline: var(--space-5);
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: var(--weight-medium);
|
||||||
|
letter-spacing: var(--tracking-tight);
|
||||||
|
line-height: 1.2;
|
||||||
|
text-align: center;
|
||||||
|
text-decoration: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
background-color var(--dur-hover) var(--ease),
|
||||||
|
border-color var(--dur-hover) var(--ease),
|
||||||
|
color var(--dur-hover) var(--ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--text-inverse);
|
||||||
|
}
|
||||||
|
.btn-primary:hover {
|
||||||
|
background: var(--accent-hover);
|
||||||
|
color: var(--text-inverse);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-ghost {
|
||||||
|
background: transparent;
|
||||||
|
border-color: var(--border);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.btn-ghost:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-gold {
|
||||||
|
background: var(--bg-inverse);
|
||||||
|
color: var(--text-inverse-2);
|
||||||
|
}
|
||||||
|
.btn-gold:hover {
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--text-inverse);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
---
|
||||||
|
/**
|
||||||
|
* The conversion band — maroon ground, one heading, one CTA. `docs/01` puts it
|
||||||
|
* at the foot of `/` (item 8) and it belongs at the foot of every page that
|
||||||
|
* wants an appointment enquiry.
|
||||||
|
*
|
||||||
|
* EXTRACTED 2026-08-28 ON `adversarial-reviewer`'S FINDING, and the finding was
|
||||||
|
* not "this is duplicated" but "this is duplicated AND HAS ALREADY DRIFTED."
|
||||||
|
* `/` and `/about/` carried identical markup and ~20 identical lines of CSS,
|
||||||
|
* except `.contact-body` — `52ch` on `/`, `46ch` plus a `line-height` on
|
||||||
|
* `/about/`. Two call sites, one already divergent, seventeen pages to come.
|
||||||
|
*
|
||||||
|
* NO PROPS AND NO SLOTS AT ALL, AND THAT IS A CORRECTION MADE ON REVIEW.
|
||||||
|
* This shipped with `eyebrow?`, `cta?` and a named `heading` slot, all
|
||||||
|
* defaulted, and **not one of the two call sites overrode any of them** — the
|
||||||
|
* exact pattern this repo has already deleted twice with the reasons written
|
||||||
|
* into the source: `Eyebrow.astro` (*"`tag?: 'p' | 'span'` had zero call sites,
|
||||||
|
* so its `<span>` branch was unreachable code"*) and `SectionHeading` (*"two
|
||||||
|
* mechanisms for one job… One way in."*). The header even argued against
|
||||||
|
* `title`/`body` props and then added `eyebrow`/`cta`. Strings are inlined; add
|
||||||
|
* a prop when a second call site actually needs one.
|
||||||
|
*
|
||||||
|
* The empty `Props` guard stays, though, and it is not decoration: without it
|
||||||
|
* an Astro component's props widen to `any` and `<ContactBand class="x" />`
|
||||||
|
* compiles clean while matching nothing — the parent-scope defect `CLAUDE.md`
|
||||||
|
* records four times, and the one `Pill` was caught by. Verified by probe.
|
||||||
|
*
|
||||||
|
* THE `<h2>` IS FIXED AT LEVEL 2 rather than taken as a prop. Every page that
|
||||||
|
* uses this band has an `<h1>` of its own and top-level sections at `<h2>`, so a
|
||||||
|
* configurable level here is a way to skip a heading level by accident. If a
|
||||||
|
* page ever needs otherwise, that is a spec question, not a prop.
|
||||||
|
*
|
||||||
|
* `CONTACT.responseTime` is rendered from the constant, never typed: §4 records
|
||||||
|
* it as **a public commitment** that *"must read identically on `/contact/`, in
|
||||||
|
* the inquirer confirmation email, and in any bio."*
|
||||||
|
*/
|
||||||
|
import Button from './Button.astro';
|
||||||
|
import Eyebrow from './Eyebrow.astro';
|
||||||
|
import { CONTACT } from '../data/site';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children?: unknown;
|
||||||
|
}
|
||||||
|
const _props: Props = Astro.props;
|
||||||
|
void _props;
|
||||||
|
---
|
||||||
|
|
||||||
|
{
|
||||||
|
/* NO BOOKING LINK, and that is not an omission: booking is parked
|
||||||
|
(AGENTS.md R6) and `CONTACT.bookingUrl` is null, so `/contact/` ships the
|
||||||
|
intake form with a reserved slot for an embed. Stated rather than silently
|
||||||
|
dropped, on every page that renders this band. */
|
||||||
|
}
|
||||||
|
<section class="section section-accent contact-band">
|
||||||
|
<div class="wrap contact-inner">
|
||||||
|
<div class="contact-copy">
|
||||||
|
<Eyebrow dot>Next step</Eyebrow>
|
||||||
|
<h2 class="display contact-h">Start with a call.</h2>
|
||||||
|
<p class="contact-body">
|
||||||
|
Tell me the shape of the matter and who is involved, and I will tell you
|
||||||
|
whether I am the right neutral for it. {CONTACT.responseTime}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="contact-action">
|
||||||
|
<Button href="/contact/" variant="gold"
|
||||||
|
>Request a consultation →</Button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.contact-inner {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-6);
|
||||||
|
}
|
||||||
|
.contact-h {
|
||||||
|
margin-block: var(--space-4) var(--space-4);
|
||||||
|
font-size: var(--text-4xl);
|
||||||
|
}
|
||||||
|
.contact-body {
|
||||||
|
/* 52ch, which is `/`'s value. The two call sites had drifted to 52ch and
|
||||||
|
46ch; 52 is the one that shipped first and was reviewed. */
|
||||||
|
max-inline-size: 52ch;
|
||||||
|
line-height: var(--leading-body);
|
||||||
|
}
|
||||||
|
.contact-action {
|
||||||
|
/* `0 1 auto` + `min-inline-size: 0`, NOT `flex: none`. `none` is `0 0 auto`,
|
||||||
|
which refuses to shrink below max-content and pushed the band into
|
||||||
|
overflow at 320px. This lets the button wrap instead. Measured on `/`. */
|
||||||
|
flex: 0 1 auto;
|
||||||
|
min-inline-size: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
---
|
||||||
|
/**
|
||||||
|
* docs/02: "Three or four credential slots. NEVER matter counts — AGENTS.md §4."
|
||||||
|
*
|
||||||
|
* THE SUBSTITUTION PRINCIPLE IS THE POINT OF THIS COMPONENT (§4). Wherever a
|
||||||
|
* design wants a "how much / how many", it takes a longer-arc credential
|
||||||
|
* instead: something already substantial and true at launch that does not grow
|
||||||
|
* by closing files. `Q.Med` / `JD + ML` / `EN · FA`, with `Q.Arb` fourth.
|
||||||
|
*
|
||||||
|
* So this component is not a stat row wearing a different name, and it must
|
||||||
|
* never be handed one. §4 Forbidden bars every count of matters closed, hours
|
||||||
|
* mediated or years in practice, plus settlement rates and dollar figures. The
|
||||||
|
* slots come from src/data/site.ts, which mirrors §4; nothing is typed here.
|
||||||
|
*
|
||||||
|
* <dl> RATHER THAN A DIV GRID. Each pair is a term and its description, which
|
||||||
|
* is what a description list is. It also fixes the reading order: a screen
|
||||||
|
* reader gets "Q.Arb — Commenced August 2026" as one associated pair, which is
|
||||||
|
* §4's paired-disclosure condition surviving into assistive technology rather
|
||||||
|
* than being a visual arrangement only. Wrapping each dt/dd pair in a <div>
|
||||||
|
* inside <dl> is valid HTML and is what makes the grid tractable.
|
||||||
|
*/
|
||||||
|
interface Props {
|
||||||
|
slots: ReadonlyArray<{ value: string; label: string }>;
|
||||||
|
}
|
||||||
|
// No `class` prop: it was declared, never passed, and a parent cannot reach this
|
||||||
|
// root anyway (SectionHeading carries the measurement; CLAUDE.md the rule).
|
||||||
|
const { slots } = Astro.props;
|
||||||
|
---
|
||||||
|
|
||||||
|
<dl class="credentials">
|
||||||
|
{
|
||||||
|
slots.map((slot) => (
|
||||||
|
<div class="credential">
|
||||||
|
<dt class="credential-value">{slot.value}</dt>
|
||||||
|
<dd class="credential-label">{slot.label}</dd>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.credentials {
|
||||||
|
display: grid;
|
||||||
|
/* EXPLICIT COLUMNS, BECAUSE `auto-fit` NEVER DELIVERED THE ROW IT CLAIMED.
|
||||||
|
This read `repeat(auto-fit, minmax(11rem, 1fr))` under a comment saying
|
||||||
|
"two up on a phone, four up where there is room". `adversarial-reviewer`
|
||||||
|
measured it: at 390px the resolved template was a SINGLE 342px track and
|
||||||
|
all four items stacked, running the band ~430px tall — with
|
||||||
|
`Q.Arb / Commenced August 2026`, which §4's paired-disclosure condition
|
||||||
|
puts on this page, at the bottom of it. The arithmetic is not subtle: two
|
||||||
|
tracks at an 11rem (176px) floor plus a 24px gap need 376px and the
|
||||||
|
container is 342px, so `auto-fit` correctly dropped to one. A
|
||||||
|
measured-sounding comment that was false is this project's own named
|
||||||
|
failure mode.
|
||||||
|
|
||||||
|
`minmax(0, 1fr)` cannot overflow at any width or any root font size,
|
||||||
|
which also retires the `min(11rem, 100%)` guard this line briefly
|
||||||
|
carried — that guard was fixing the overflow symptom of a floor that
|
||||||
|
should not have been there. */
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: var(--space-6) var(--space-5);
|
||||||
|
margin: 0;
|
||||||
|
padding-block: var(--space-7);
|
||||||
|
border-block: 1px solid var(--rule);
|
||||||
|
}
|
||||||
|
.credential {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
.credential-value {
|
||||||
|
font-family: var(--font-serif);
|
||||||
|
font-size: var(--text-3xl);
|
||||||
|
line-height: var(--leading-tight);
|
||||||
|
letter-spacing: var(--tracking-tight);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
/* Four across only where four actually fit, measured rather than inferred.
|
||||||
|
Two 11rem-equivalent tracks plus three 24px gaps need 776px of container;
|
||||||
|
above 48rem the gutter is 48px each side, so that is a 872px viewport. 56rem
|
||||||
|
(896px) is the clean token above it. Re-measure if --space-5 or the label
|
||||||
|
type changes. */
|
||||||
|
@media (min-width: 56rem) {
|
||||||
|
.credentials {
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.credential-label {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
line-height: var(--leading-snug);
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-meta);
|
||||||
|
/* "Legal training and engineering practice" is 39 characters and is
|
||||||
|
deliberately long (AGENTS.md Q37) — it wraps to two lines at every width
|
||||||
|
and must not be prevented from doing so. Do not add `white-space: nowrap`
|
||||||
|
here, and do not shorten the label to make the row tidier: the asymmetry
|
||||||
|
is the honest part. */
|
||||||
|
text-wrap: pretty;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
---
|
||||||
|
/**
|
||||||
|
* docs/02: "Mono label with optional maroon dot."
|
||||||
|
*
|
||||||
|
* NO SCOPED STYLES ON PURPOSE. `.eyebrow` and `.eyebrow .dot` are already
|
||||||
|
* global (global.css) because `.section-inverse .eyebrow` has to recolour them
|
||||||
|
* from an ancestor, and a scoped rule here would be a second source for the
|
||||||
|
* same thing. This component exists to stop `<span class="eyebrow">` being
|
||||||
|
* hand-typed on nineteen pages, not to own the look.
|
||||||
|
*
|
||||||
|
* AN EYEBROW IS A LABEL, NEVER A HEADING. It renders <p> or <span>, never
|
||||||
|
* <h*>: docs/02's accessibility floor forbids skipped heading levels, and an
|
||||||
|
* eyebrow above an <h2> is exactly where an <h3>-before-<h2> creeps in.
|
||||||
|
*
|
||||||
|
* ⚠️ NEVER NAME AN ASTRO PROP `as`. The prop deleted below was briefly called
|
||||||
|
* `as`, and that name SILENTLY TURNED OFF PROP TYPE-CHECKING for every caller
|
||||||
|
* of this component. Kept here because the next person to want a dynamic tag
|
||||||
|
* will reach for `as` first.
|
||||||
|
*
|
||||||
|
* `astro check` reported it only as a hint — `ts(6196) 'Props' is declared but
|
||||||
|
* never used` — which reads exactly like lint noise and is the reason it is
|
||||||
|
* worth writing down. It is not noise: it is the compiler saying the `Props`
|
||||||
|
* interface is not attached to anything.
|
||||||
|
*
|
||||||
|
* Measured rather than assumed, 2026-08-27. With the prop named `as`,
|
||||||
|
* `<Eyebrow dot as="h9" bogusProp={1} />` compiled with **0 errors**. The same
|
||||||
|
* probe against CredentialRow, SectionHeading, PracticeCard and ProcessStep
|
||||||
|
* produced `ts(2322)` on all four, so the loss was specific to this file.
|
||||||
|
* Renaming the single identifier `as` to `tag` — one variable changed, nothing
|
||||||
|
* else — took the file from 1 hint / 0 errors to 0 hints, and the same probe
|
||||||
|
* now fails correctly: `Type '"h9"' is not assignable to type '"p" | "span" |
|
||||||
|
* undefined'`. `const { as = 'p' } = Astro.props` is read as a type assertion
|
||||||
|
* somewhere in the generated TSX and detaches the binding.
|
||||||
|
*
|
||||||
|
* A DYNAMIC `<Tag>` FROM A VARIABLE was the first hypothesis for the lost
|
||||||
|
* binding and it was WRONG — restructuring the template changed nothing, the
|
||||||
|
* rename fixed it. Recorded so the wrong cause is not re-derived.
|
||||||
|
*
|
||||||
|
* DO NOT "FIX" A ts(6196) HINT WITH `Astro.props as Props`. It silences the
|
||||||
|
* warning inside the component and leaves every call site unchecked, which is
|
||||||
|
* strictly worse than the warning. If this hint appears on another component,
|
||||||
|
* probe a bogus prop before believing the props are checked.
|
||||||
|
*/
|
||||||
|
interface Props {
|
||||||
|
/** The maroon dot. Decorative — it is a CSS box, so it is invisible to AT. */
|
||||||
|
dot?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ONE PROP, AND THE OTHER TWO ARE DELETED. `tag?: 'p' | 'span'` had zero call
|
||||||
|
* sites, so its `<span>` branch was unreachable code and the file carried two
|
||||||
|
* near-identical templates for it. `class?: string` had zero call sites too, and
|
||||||
|
* a parent cannot reach this root anyway (see SectionHeading, and CLAUDE.md).
|
||||||
|
*
|
||||||
|
* The rename that produced the finding below is kept; only the prop is gone.
|
||||||
|
*/
|
||||||
|
const { dot = false } = Astro.props;
|
||||||
|
---
|
||||||
|
|
||||||
|
<p class="eyebrow">
|
||||||
|
{dot && <span class="dot" aria-hidden="true" />}
|
||||||
|
<slot />
|
||||||
|
</p>
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
---
|
||||||
|
/**
|
||||||
|
* The SML infinity mark.
|
||||||
|
*
|
||||||
|
* ⚠️ DELIBERATE, TEMPORARY EXCEPTION TO docs/02's "inline SVG, never a PNG".
|
||||||
|
* Tracked as AGENTS.md Q38, with a standing reminder (R13) so it cannot become
|
||||||
|
* permanent by neglect. Read both before changing this file.
|
||||||
|
*
|
||||||
|
* WHY A RASTER — and the reason is PAYLOAD, not fidelity. The mark is not a
|
||||||
|
* stroked curve; it is a shaded ribbon of variable width that twists in three
|
||||||
|
* dimensions, maroon flowing into champagne, passing over itself at the
|
||||||
|
* crossing. That is gradient-mesh artwork, not the flat vector paths docs/02
|
||||||
|
* assumes.
|
||||||
|
*
|
||||||
|
* We do hold an SVG — src/assets/brand/sml-logo-source.svg — and **it renders
|
||||||
|
* faithfully**: rasterised at 8333px it reproduces the master exactly, at the
|
||||||
|
* same 1.566:1 [verified 2026-08-26]. An earlier version of this comment implied
|
||||||
|
* it was inadequate artwork. It is not; that was unfair and is corrected here.
|
||||||
|
* What rules it out is weight and composition: 257,278 bytes against 3,063 for
|
||||||
|
* the AVIF a Retina browser takes — 84× — and SEVEN embedded base64 PNGs plus
|
||||||
|
* a 1,225-stop gradient mesh, so inlining it would breach CLAUDE.md's rule
|
||||||
|
* against base64-inlining images. The exception ends when a vector master lands
|
||||||
|
* that is both faithful AND light.
|
||||||
|
*
|
||||||
|
* WHAT THIS REPLACES, and why it had to go. Until 2026-08-26 this component
|
||||||
|
* drew a hand-traced cubic path lifted from the old site's loading thumbnail.
|
||||||
|
* Pouya compared it against the master and it was wrong in three ways; two are
|
||||||
|
* reproducible from the path itself:
|
||||||
|
*
|
||||||
|
* 1. TANGENT, NOT CROSSING. All four branches met the origin at exactly 90°,
|
||||||
|
* so the loops were mutually tangent on a vertical line rather than
|
||||||
|
* crossing. At stroke-width 28 that renders as two circles kissing — the
|
||||||
|
* one thing an infinity mark must not be. Verified by computing the
|
||||||
|
* tangent vector of every segment at the origin.
|
||||||
|
* 2. WRONG PROPORTION. The real mark's ink bounding box is 2668 × 1704 =
|
||||||
|
* 1.5657:1. The traced path measured 1.667:1 ink / 1.597:1 stroked.
|
||||||
|
* 3. FLAT. Two uniform strokes standing in for a shaded ribbon.
|
||||||
|
*
|
||||||
|
* It is deleted rather than kept as a fallback, on Pouya's instruction: a wrong
|
||||||
|
* mark that renders is worse than a missing one, because it stops looking wrong.
|
||||||
|
*
|
||||||
|
* SOURCE OF TRUTH. src/assets/brand/sml-infinity-mark.png is the master tight-
|
||||||
|
* cropped to its ink bounding box, so the file's aspect ratio IS the mark's and
|
||||||
|
* layout can be tuned to it directly. Provenance: docs/reference/brand-assets.md.
|
||||||
|
*/
|
||||||
|
import { Picture } from 'astro:assets';
|
||||||
|
import mark from '../assets/brand/sml-infinity-mark.png';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** Rendered height. Width follows 1.5657:1. */
|
||||||
|
size?: string;
|
||||||
|
/**
|
||||||
|
* Intrinsic width of the 1x variant, in px. RAISE IT FOR A LARGE CALL SITE.
|
||||||
|
*
|
||||||
|
* The default 64 is sized for the header (50.1px wide) and footer (56.4px) —
|
||||||
|
* with densities [1,2,3] that gives 64/128/192 and both are sharp to DPR 3.
|
||||||
|
* The home page's approach section renders the mark at 225.5px, five times
|
||||||
|
* larger, and inherited the same 64: measured 1.17x upscale at DPR 1, 2.35x
|
||||||
|
* at DPR 2, 3.52x at DPR 3. A prop, because the component cannot infer this
|
||||||
|
* from `size` — `size` may be a `clamp()`.
|
||||||
|
*/
|
||||||
|
width?: number;
|
||||||
|
/**
|
||||||
|
* `eager` for the two above-the-fold marks; `lazy` for anything below it.
|
||||||
|
* The component hardcoded `eager`, which is right for a masthead and wrong
|
||||||
|
* for a 700px-wide decorative anchor two screens down.
|
||||||
|
*/
|
||||||
|
loading?: 'eager' | 'lazy';
|
||||||
|
/**
|
||||||
|
* Accessible name. Omit for decorative use — the default, and the case at all
|
||||||
|
* three current call sites: the header and footer marks sit beside the name
|
||||||
|
* they stand for, and the home page's mark sits beside a paragraph that says
|
||||||
|
* what it is. Kept because the moment the mark appears without adjacent text
|
||||||
|
* it needs one, and a component that cannot take an accessible name invites
|
||||||
|
* `alt=""` on an informative image.
|
||||||
|
*/
|
||||||
|
label?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* NO `class` PROP, AND ITS REMOVAL IS THE POINT. It existed, had no call site,
|
||||||
|
* and `class:list` put it on the <img> — so a parent writing
|
||||||
|
* `<InfinityMark class="foo" />` would get a rule compiled against the PARENT's
|
||||||
|
* cid that never matches the child's root. That is the exact defect CLAUDE.md
|
||||||
|
* records twice on this project. The prop was an invitation to reproduce it.
|
||||||
|
* To position a mark, style a wrapper the parent owns.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { size = '1.75rem', width, loading = 'eager', label } = Astro.props;
|
||||||
|
|
||||||
|
// THE DEFAULT IS SIZED TO THE LARGEST *DEFAULT* CALL SITE — the footer at
|
||||||
|
// 2.25rem, i.e. 56.4 CSS px wide. With densities [1,2,3] that is 64/128/192 and
|
||||||
|
// the footer is sharp to DPR 3.
|
||||||
|
//
|
||||||
|
// It has now been wrong in both directions, which is why it is a prop:
|
||||||
|
// - Too big, 2026-08-26: 320px, justified by a 4rem sample on the step-1
|
||||||
|
// proof sheet (a page step 2 has since deleted), with densities stacked on
|
||||||
|
// top — so the ladder double-counted its own headroom and every Retina
|
||||||
|
// device pulled a 640px file into a 56px slot.
|
||||||
|
// - Too small, 2026-08-27: the home page added a 225.5px call site and
|
||||||
|
// inherited 64, upscaling 3.52x at DPR 3. Raise `width` at the call site.
|
||||||
|
//
|
||||||
|
// PASSING A WIDTH AT ALL IS STILL THE POINT. Without it Astro emits the
|
||||||
|
// untouched 2668px master as the <img src> fallback: 1,146,406 bytes, which any
|
||||||
|
// client without AVIF or WebP support would actually download, sitting in dist
|
||||||
|
// looking like an optimisation had happened.
|
||||||
|
const INTRINSIC_WIDTH = width ?? 64;
|
||||||
|
|
||||||
|
// A NOTE ON MEASURING THIS, because the obvious probe lies. `img.naturalWidth`
|
||||||
|
// on an image chosen from a `srcset` with an `x` descriptor is DENSITY-
|
||||||
|
// CORRECTED: the 192px file selected at 3x reports 64. Reading it at DPR 1, 2
|
||||||
|
// and 3 therefore returns 64 every time, which looks exactly like "the density
|
||||||
|
// ladder is not being generated at all" — a far more alarming defect than the
|
||||||
|
// real one. It is generated: 64x41, 128x82, 192x123 on disk, in all three
|
||||||
|
// formats. Check the files, not naturalWidth. [verified 2026-08-27]
|
||||||
|
---
|
||||||
|
|
||||||
|
<Picture
|
||||||
|
src={mark}
|
||||||
|
width={INTRINSIC_WIDTH}
|
||||||
|
densities={[1, 2, 3]}
|
||||||
|
formats={['avif', 'webp']}
|
||||||
|
fallbackFormat="png"
|
||||||
|
alt={label ?? ''}
|
||||||
|
loading={loading}
|
||||||
|
decoding="async"
|
||||||
|
class="mark"
|
||||||
|
pictureAttributes={{ style: `block-size:${size}` }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* THE FLEX ITEM IS THE <picture>, NOT THE <img>.
|
||||||
|
`class:list` lands on the <img>, which Astro's <Picture> wraps in a
|
||||||
|
<picture> — and that wrapper is what `.brand`'s flex layout actually sizes.
|
||||||
|
So `flex: none` on .mark reached the wrong box entirely and the mark was
|
||||||
|
free to be squeezed: measured 28.52 x 32 at 1024px with seven nav items,
|
||||||
|
against a correct 50.09 x 32 — an aspect of 0.891 where it should be 1.5657.
|
||||||
|
|
||||||
|
This is the SAME defect CLAUDE.md already records for <Button> in
|
||||||
|
SiteHeader — a parent cannot style a child component's root — reintroduced
|
||||||
|
inside the fix for it, one round later. The bare `picture` selector below is
|
||||||
|
scoped by Astro's cid, which the emitted markup does carry, and the height
|
||||||
|
now goes on the wrapper via `pictureAttributes` rather than on the image.
|
||||||
|
|
||||||
|
Worse than the bug: the page-level overflow check passed throughout, because
|
||||||
|
the brand block absorbed the deficit by crushing the logo. "0 overflow at
|
||||||
|
every width" was true and misleading — it measured the document, not the
|
||||||
|
elements inside it. The harness now asserts the rendered aspect ratio. */
|
||||||
|
picture {
|
||||||
|
display: block;
|
||||||
|
flex: none;
|
||||||
|
aspect-ratio: 667 / 426;
|
||||||
|
inline-size: auto;
|
||||||
|
}
|
||||||
|
.mark {
|
||||||
|
/* 2668 / 1704 reduced — the master's exact ink bounding box, so the box the
|
||||||
|
layout reserves is the shape that fills it. */
|
||||||
|
inline-size: 100%;
|
||||||
|
block-size: 100%;
|
||||||
|
/* Belt and braces: if anything ever squeezes the box again, the artwork
|
||||||
|
letterboxes instead of distorting. */
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
---
|
||||||
|
/**
|
||||||
|
* docs/02: "Small bordered label for designations and sector chips."
|
||||||
|
*
|
||||||
|
* A PARENT CANNOT STYLE THIS ELEMENT. Astro does not pass a parent's scope
|
||||||
|
* attribute to a child component's root, so `<Pill class="chip" />` compiles the
|
||||||
|
* parent's `.chip` rule against the parent's cid and it never matches — the
|
||||||
|
* defect CLAUDE.md records for <Button> in SiteHeader, which then recurred with
|
||||||
|
* <Picture> in InfinityMark. CLAUDE.md names Pill as the next place it will
|
||||||
|
* happen.
|
||||||
|
*
|
||||||
|
* THE HOOK IS A CUSTOM PROPERTY, and that is the one mechanism that legitimately
|
||||||
|
* crosses the boundary: custom properties inherit. An ancestor sets
|
||||||
|
* `--pill-border` / `--pill-fg` on ITSELF and this component reads it. No
|
||||||
|
* :global(), no wrapper div, and no rule that silently does nothing.
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* NO PROPS — AND THE EMPTY `Props` INTERFACE IS LOAD-BEARING, NOT DECORATION.
|
||||||
|
*
|
||||||
|
* A `class?: string` was declared here once, was never passed, and a parent
|
||||||
|
* cannot reach this root regardless — see SectionHeading for the measurement and
|
||||||
|
* CLAUDE.md for the rule. It was deleted, and the deletion was written up as
|
||||||
|
* "passing one is now a build error". **It was not.** With frontmatter
|
||||||
|
* containing only comments, an Astro component's props widen to `any`, so
|
||||||
|
* `<Pill class="chip">` compiled with **zero** errors, matched nothing, and let
|
||||||
|
* the flex or grid child absorb the difference — silently.
|
||||||
|
*
|
||||||
|
* Measured by probe page, `<Eyebrow class>`, `<Pill class>`,
|
||||||
|
* `<SectionHeading class>`, `<Button bogus>`: `astro check` reported **3 errors
|
||||||
|
* — Eyebrow, SectionHeading, Button. Nothing for Pill.** Adding the three lines
|
||||||
|
* below takes the same probe to **4 errors, 0 hints** (and no `ts(6196)`,
|
||||||
|
* because the interface is referenced by the destructure below).
|
||||||
|
*
|
||||||
|
* CLAUDE.md names `Pill` as the next place the parent-scope defect will happen.
|
||||||
|
* The guard that was documented as protecting it was absent on exactly it.
|
||||||
|
*
|
||||||
|
* The custom-property hooks in the style block are how an ancestor influences
|
||||||
|
* this component: custom properties inherit, which is the one mechanism that
|
||||||
|
* legitimately crosses the boundary.
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* `children` DECLARED, NOTHING ELSE. Getting to this line took two wrong turns
|
||||||
|
* and both are worth recording, because each looked correct:
|
||||||
|
*
|
||||||
|
* - `interface Props {}` — rejected by eslint
|
||||||
|
* (`@typescript-eslint/no-empty-object-type`), and it would have been the
|
||||||
|
* wrong tool anyway: `{}` in TypeScript means "any non-nullish value", not
|
||||||
|
* "no properties".
|
||||||
|
* - `Record<string, never>` — passes eslint and does reject `class`, but it
|
||||||
|
* also rejects `children`, so it broke the two REAL call sites
|
||||||
|
* (`PracticeCard.astro:34` and `/about/`'s arc) while the probe page went
|
||||||
|
* green on the thing it was testing. A fix that satisfies its own test and
|
||||||
|
* breaks production is exactly what `/build` Phase 4 warns about.
|
||||||
|
*
|
||||||
|
* Slot content arrives as `children`, so `children` is the one permitted
|
||||||
|
* property and every other prop is an error. Verified by probe: all six
|
||||||
|
* components now reject `class`, and `<Pill>text</Pill>` compiles. Deleting
|
||||||
|
* this re-disables checking at every call site.
|
||||||
|
*/
|
||||||
|
interface Props {
|
||||||
|
children?: unknown;
|
||||||
|
}
|
||||||
|
const _props: Props = Astro.props;
|
||||||
|
void _props;
|
||||||
|
---
|
||||||
|
|
||||||
|
<span class="pill"><slot /></span>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding-block: var(--space-1);
|
||||||
|
padding-inline: var(--space-3);
|
||||||
|
border: 1px solid var(--pill-border, var(--border));
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-weight: var(--weight-medium);
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
line-height: 1.4;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--pill-fg, var(--text-meta));
|
||||||
|
/* `nowrap` UNTIL 2026-08-28, AND IT WAS FINE UNTIL A PILL HAD FOUR WORDS.
|
||||||
|
`/`'s six pills are one or two words (longest "Cross-cultural").
|
||||||
|
`/about/` ships `Commenced August 2026`, and at a 200% DEFAULT FONT SIZE
|
||||||
|
(root 32px — a real browser setting, not page zoom) that pill measured
|
||||||
|
382.6px wide with its right edge at 430.6 in a 390px viewport:
|
||||||
|
**41px of document overflow at 390, 111px at 320.** Injecting
|
||||||
|
`white-space: normal` took 390 to **0** and 320 to **63**, 63 being the
|
||||||
|
header residual docs/02 already accepts. WCAG 1.4.10 Reflow.
|
||||||
|
|
||||||
|
`normal` costs nothing at default size — a pill only wraps when it cannot
|
||||||
|
fit, which is exactly when wrapping is the right answer.
|
||||||
|
|
||||||
|
WHAT IT LOOKS LIKE AT THE EXTREME, recorded so it is not later read as a
|
||||||
|
new bug: at 320px with root at 32px, `Commenced August 2026` renders
|
||||||
|
**224 x 119px** inside `border-radius: 999px` — a three-line stadium. It
|
||||||
|
is ungainly and it is legible, in-viewport, and the alternative was
|
||||||
|
111px of document overflow. */
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
---
|
||||||
|
/**
|
||||||
|
* docs/02: "Sector chip, heading, one paragraph, arrow link."
|
||||||
|
*
|
||||||
|
* ONE LINK PER CARD, AND THE WHOLE CARD IS ITS HIT AREA. The link wraps only
|
||||||
|
* the heading text, so its accessible name is "Construction & Infrastructure"
|
||||||
|
* rather than the card's entire contents; a `::after` pseudo-element stretched
|
||||||
|
* over the positioned card carries the click. Six of these on the home page
|
||||||
|
* would otherwise be six links each announcing three sentences.
|
||||||
|
*
|
||||||
|
* The arrow is `aria-hidden` and outside the link text for the same reason.
|
||||||
|
*
|
||||||
|
* THE GRID MUST NOT TRY TO STYLE THIS ROOT. A parent's `.card { block-size:
|
||||||
|
* 100% }` compiles against the parent's cid and never matches (CLAUDE.md;
|
||||||
|
* it has now cost twice). The card sizes ITSELF to its grid cell below, so a
|
||||||
|
* parent only ever needs `display: grid` and `gap` on its own element.
|
||||||
|
*/
|
||||||
|
import Pill from './Pill.astro';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
href: string;
|
||||||
|
chip?: string;
|
||||||
|
title: string;
|
||||||
|
/** Explicit: docs/02 forbids skipped heading levels. */
|
||||||
|
level: 2 | 3;
|
||||||
|
}
|
||||||
|
// No `class` prop — declared, never passed, unreachable from a parent. The card
|
||||||
|
// sizes itself to its grid cell instead; see the note above.
|
||||||
|
const { href, chip, title, level } = Astro.props;
|
||||||
|
const H = `h${level}` as 'h2' | 'h3';
|
||||||
|
---
|
||||||
|
|
||||||
|
<article class="card">
|
||||||
|
{chip && <Pill>{chip}</Pill>}
|
||||||
|
<H class="card-title">
|
||||||
|
<a class="card-link" href={href}>{title}</a>
|
||||||
|
</H>
|
||||||
|
<p class="card-body"><slot /></p>
|
||||||
|
<span class="card-arrow" aria-hidden="true">→</span>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.card {
|
||||||
|
position: relative;
|
||||||
|
/* Sizes itself to its cell — see the note on why the grid cannot do this. */
|
||||||
|
block-size: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: var(--space-4);
|
||||||
|
padding: var(--space-6);
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-block-start: 2px solid var(--rule);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
transition:
|
||||||
|
border-color var(--dur-hover) var(--ease),
|
||||||
|
box-shadow var(--dur-hover) var(--ease);
|
||||||
|
}
|
||||||
|
.card:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
}
|
||||||
|
.card-title {
|
||||||
|
font-family: var(--font-serif);
|
||||||
|
font-size: var(--text-xl);
|
||||||
|
line-height: var(--leading-tight);
|
||||||
|
letter-spacing: var(--tracking-tight);
|
||||||
|
}
|
||||||
|
.card-link {
|
||||||
|
color: var(--text);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.card-link::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
}
|
||||||
|
.card:hover .card-link {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
/* The focus ring belongs on the CARD, not on the heading text: the hit area
|
||||||
|
is the card, so a ring around three words would point at the wrong box.
|
||||||
|
`:focus-visible` on the descendant, ring on the ancestor. */
|
||||||
|
.card:has(.card-link:focus-visible) {
|
||||||
|
outline: 2px solid var(--focus-ring);
|
||||||
|
outline-offset: var(--focus-offset);
|
||||||
|
}
|
||||||
|
.card-link:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.card-body {
|
||||||
|
margin: 0;
|
||||||
|
/* Pushes the arrow to the bottom edge so a row of cards aligns on it
|
||||||
|
whatever the body length. */
|
||||||
|
flex: 1 1 auto;
|
||||||
|
font-size: var(--text-base);
|
||||||
|
line-height: var(--leading-body);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
.card-arrow {
|
||||||
|
font-size: var(--text-lg);
|
||||||
|
line-height: 1;
|
||||||
|
color: var(--accent);
|
||||||
|
transition: transform var(--dur-hover) var(--ease);
|
||||||
|
}
|
||||||
|
.card:hover .card-arrow {
|
||||||
|
transform: translateX(var(--space-2));
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.card:hover .card-arrow {
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
---
|
||||||
|
/**
|
||||||
|
* docs/02: "Numbered step, timing, body."
|
||||||
|
*
|
||||||
|
* THE NUMBER IS NOT CONTENT. It is `aria-hidden` and supplied by the caller
|
||||||
|
* rather than by a CSS counter, because the compressed strip on `/` and the
|
||||||
|
* full page at `/process/` must agree on it. The reading order for assistive
|
||||||
|
* technology is heading then timing then body; the numeral adds nothing to it
|
||||||
|
* and would be read as a bare digit before every step.
|
||||||
|
*
|
||||||
|
* <ol> IS THE CALLER'S JOB. These are ordered steps, so the parent wraps them
|
||||||
|
* in an <ol> and this renders the <li>. That keeps "step 3 of 5" available from
|
||||||
|
* the list semantics instead of from the decorative numeral.
|
||||||
|
*/
|
||||||
|
interface Props {
|
||||||
|
n: number;
|
||||||
|
title: string;
|
||||||
|
/** "Day 0", "Days 1–7". docs/03: "Five steps with real timing." */
|
||||||
|
timing: string;
|
||||||
|
}
|
||||||
|
// No `class` prop — declared, never passed, unreachable from a parent.
|
||||||
|
const { n, title, timing } = Astro.props;
|
||||||
|
---
|
||||||
|
|
||||||
|
<li class="step">
|
||||||
|
<span class="step-n" aria-hidden="true">{String(n).padStart(2, '0')}</span>
|
||||||
|
<h3 class="step-title">{title}</h3>
|
||||||
|
<p class="step-timing">{timing}</p>
|
||||||
|
<p class="step-body"><slot /></p>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.step {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-2);
|
||||||
|
padding-block-start: var(--space-4);
|
||||||
|
border-block-start: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.step-n {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
/* --gold-d, not --gold: on cream, gold measures 2.10:1 and gold-d 3.11:1.
|
||||||
|
docs/02 permits gold-d for LARGE DECORATIVE text only, 24px+ — this is
|
||||||
|
12px, so neither qualifies and the numeral is maroon. Kept as a comment
|
||||||
|
because "make the step numbers gold" is the obvious next edit. */
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
.step-title {
|
||||||
|
font-family: var(--font-serif);
|
||||||
|
font-size: var(--text-lg);
|
||||||
|
line-height: var(--leading-tight);
|
||||||
|
}
|
||||||
|
.step-timing {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-meta);
|
||||||
|
}
|
||||||
|
.step-body {
|
||||||
|
margin: 0;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
line-height: var(--leading-body);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
---
|
||||||
|
/**
|
||||||
|
* The single metadata component. Spec: docs/04-seo-spec.md.
|
||||||
|
*
|
||||||
|
* "Every page passes through one SEO component. A page without it is not
|
||||||
|
* finished." — so the length rules in that spec are ENFORCED here rather than
|
||||||
|
* described. An out-of-range title or description throws at build time and
|
||||||
|
* names the offending string and its length, the same way src/content.config.ts
|
||||||
|
* does for article frontmatter. A build that fails on unfinished metadata is a
|
||||||
|
* correct build.
|
||||||
|
*/
|
||||||
|
import { getImage } from 'astro:assets';
|
||||||
|
import ogDefault from '../assets/og-portrait.jpg';
|
||||||
|
import { SITE, PORTRAIT } from '../data/site';
|
||||||
|
|
||||||
|
export interface Props {
|
||||||
|
/** The full rendered <title>. Pattern: "<Page> · Pouya Lajevardi". 50–60. */
|
||||||
|
title: string;
|
||||||
|
/** 140–160 characters, unique, written for a human. */
|
||||||
|
description: string;
|
||||||
|
/** Overrides the canonical path. Defaults to this page's own URL. */
|
||||||
|
canonical?: string;
|
||||||
|
ogType?: 'website' | 'article' | 'profile';
|
||||||
|
/** 1200×630 source. Defaults to the portrait crop in src/assets.
|
||||||
|
* `ImageMetadata` is an Astro ambient global — there is nothing to import. */
|
||||||
|
image?: ImageMetadata;
|
||||||
|
imageAlt?: string;
|
||||||
|
/** /legal/* and any temporary page. Emits noindex,follow per docs/04. */
|
||||||
|
noindex?: boolean;
|
||||||
|
/**
|
||||||
|
* Page-appropriate structured data — Person, ProfessionalService, Service,
|
||||||
|
* Article, BreadcrumbList, FAQPage. Passed in, never invented here: a default
|
||||||
|
* would be a claim this component is in no position to make.
|
||||||
|
*/
|
||||||
|
jsonLd?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
canonical,
|
||||||
|
ogType = 'website',
|
||||||
|
image,
|
||||||
|
imageAlt,
|
||||||
|
noindex = false,
|
||||||
|
jsonLd,
|
||||||
|
} = Astro.props;
|
||||||
|
|
||||||
|
const TITLE_MIN = 50;
|
||||||
|
const TITLE_MAX = 60;
|
||||||
|
const DESC_MIN = 140;
|
||||||
|
const DESC_MAX = 160;
|
||||||
|
|
||||||
|
const problems: string[] = [];
|
||||||
|
if (title.length < TITLE_MIN || title.length > TITLE_MAX) {
|
||||||
|
problems.push(
|
||||||
|
`title is ${title.length} characters; docs/04-seo-spec.md requires ${TITLE_MIN}–${TITLE_MAX}.\n ${JSON.stringify(title)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (description.length < DESC_MIN || description.length > DESC_MAX) {
|
||||||
|
problems.push(
|
||||||
|
`description is ${description.length} characters; docs/04-seo-spec.md requires ${DESC_MIN}–${DESC_MAX}.\n ${JSON.stringify(description)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (problems.length > 0) {
|
||||||
|
throw new Error(
|
||||||
|
`SEO metadata out of range on ${Astro.url.pathname}\n - ${problems.join('\n - ')}\n` +
|
||||||
|
` Fix the string. Do not widen the range — these are the lengths Google renders.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// `site` drives canonical URLs, OG tags, and the sitemap. Without it every
|
||||||
|
// absolute URL below would silently become a relative one.
|
||||||
|
if (!Astro.site) {
|
||||||
|
throw new Error(
|
||||||
|
'astro.config.mjs must set `site`; SEO.astro needs it for canonical and OG URLs.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const canonicalUrl = new URL(canonical ?? Astro.url.pathname, Astro.site);
|
||||||
|
|
||||||
|
// JPEG on purpose. Page images are AVIF/WebP with a fallback (CLAUDE.md), but
|
||||||
|
// link-preview crawlers are not browsers — LinkedIn and Slack do not negotiate
|
||||||
|
// content types, and several still do not decode WebP at all.
|
||||||
|
const ogImage = await getImage({
|
||||||
|
src: image ?? ogDefault,
|
||||||
|
format: 'jpeg',
|
||||||
|
width: 1200,
|
||||||
|
height: 630,
|
||||||
|
});
|
||||||
|
const ogImageUrl = new URL(ogImage.src, Astro.site);
|
||||||
|
|
||||||
|
// JSON.stringify does not escape `<`, so a "</script>" inside any string value
|
||||||
|
// would close this element early and hand the rest of the payload to the HTML
|
||||||
|
// parser. Escaping the angle bracket is the whole fix; JSON readers decode it.
|
||||||
|
const jsonLdText =
|
||||||
|
jsonLd === undefined ? null : JSON.stringify(jsonLd).replace(/</g, '\\u003c');
|
||||||
|
---
|
||||||
|
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<meta name="generator" content={Astro.generator} />
|
||||||
|
|
||||||
|
<title>{title}</title>
|
||||||
|
<meta name="description" content={description} />
|
||||||
|
<link rel="canonical" href={canonicalUrl.href} />
|
||||||
|
<meta name="robots" content={noindex ? 'noindex,follow' : 'index,follow'} />
|
||||||
|
|
||||||
|
<meta property="og:type" content={ogType} />
|
||||||
|
<meta property="og:title" content={title} />
|
||||||
|
<meta property="og:description" content={description} />
|
||||||
|
<meta property="og:url" content={canonicalUrl.href} />
|
||||||
|
<meta property="og:site_name" content={SITE.name} />
|
||||||
|
<meta property="og:locale" content={SITE.locale} />
|
||||||
|
<meta property="og:image" content={ogImageUrl.href} />
|
||||||
|
<meta property="og:image:width" content="1200" />
|
||||||
|
<meta property="og:image:height" content="630" />
|
||||||
|
<meta property="og:image:alt" content={imageAlt ?? PORTRAIT.alt} />
|
||||||
|
|
||||||
|
<meta name="twitter:card" content="summary_large_image" />
|
||||||
|
<meta name="twitter:title" content={title} />
|
||||||
|
<meta name="twitter:description" content={description} />
|
||||||
|
<meta name="twitter:image" content={ogImageUrl.href} />
|
||||||
|
<meta name="twitter:image:alt" content={imageAlt ?? PORTRAIT.alt} />
|
||||||
|
|
||||||
|
{
|
||||||
|
jsonLdText && (
|
||||||
|
<script type="application/ld+json" is:inline set:html={jsonLdText} />
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
---
|
||||||
|
/**
|
||||||
|
* docs/02: "Eyebrow + display heading + optional lede, one measure."
|
||||||
|
*
|
||||||
|
* THE LEVEL IS A REQUIRED DECISION, not a default. docs/02's accessibility
|
||||||
|
* floor: "One <h1> per page; heading levels never skipped." A component that
|
||||||
|
* defaulted to <h2> would silently produce an <h2> inside an <h3> section the
|
||||||
|
* first time one is nested, and nothing would fail. `level` is explicit and
|
||||||
|
* `astro check` enforces the union.
|
||||||
|
*/
|
||||||
|
import Eyebrow from './Eyebrow.astro';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
eyebrow?: string;
|
||||||
|
/** 1 only on the page's single H1. */
|
||||||
|
level: 1 | 2 | 3;
|
||||||
|
lede?: string;
|
||||||
|
dot?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* NO `class` PROP, AND NO `title` PROP. Both are deletions with reasons.
|
||||||
|
*
|
||||||
|
* `class` — a parent CANNOT reach this component's root. It was passed as
|
||||||
|
* `class="section-head"` from `/` and the page's rule compiled to
|
||||||
|
* `.section-head[data-astro-cid-<page>]` while the rendered div carried
|
||||||
|
* `data-astro-cid-<SectionHeading>`. **Measured: `margin-block-end: 0px` and a
|
||||||
|
* 0px gap on all three call sites** — 48px of intended separation missing, with
|
||||||
|
* `.display`'s 0.98 line-height putting the glyphs over the top edge of the
|
||||||
|
* cards below. `astro check` and `eslint` both passed. Fourth instance of the
|
||||||
|
* defect `CLAUDE.md` records; a `class` prop here is an invitation to a fifth.
|
||||||
|
* To space this block, wrap it in an element the page owns.
|
||||||
|
*
|
||||||
|
* `title` — two mechanisms for one job. The `heading` slot is the general one
|
||||||
|
* (it takes an italic phrase, which `docs/02` allows once per headline); a
|
||||||
|
* plain-text prop is the same thing minus a capability. One way in.
|
||||||
|
*
|
||||||
|
* `dot` defaults TRUE here and FALSE on `<Eyebrow>`, deliberately: a section
|
||||||
|
* heading's eyebrow is the pattern the dot was designed for, and a bare
|
||||||
|
* `<Eyebrow>` is used in places where it would be noise.
|
||||||
|
*/
|
||||||
|
const { eyebrow, level, lede, dot = true } = Astro.props;
|
||||||
|
const H = `h${level}` as 'h1' | 'h2' | 'h3';
|
||||||
|
const size = level === 1 ? 'size-display' : 'size-section';
|
||||||
|
---
|
||||||
|
|
||||||
|
<div class="heading-block">
|
||||||
|
{eyebrow && <Eyebrow dot={dot}>{eyebrow}</Eyebrow>}
|
||||||
|
<H class:list={['display', size]}><slot name="heading" /></H>
|
||||||
|
{lede && <p class="lede">{lede}</p>}
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.heading-block {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-4);
|
||||||
|
}
|
||||||
|
.size-display {
|
||||||
|
font-size: var(--text-5xl);
|
||||||
|
}
|
||||||
|
.size-section {
|
||||||
|
font-size: var(--text-4xl);
|
||||||
|
}
|
||||||
|
.lede {
|
||||||
|
max-inline-size: var(--width-prose);
|
||||||
|
font-size: var(--text-lg);
|
||||||
|
line-height: var(--leading-relaxed);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
/* `color` INHERITS across the component boundary, which is how an inverse
|
||||||
|
section reaches this — but `--text-secondary` resolves to a cream-only
|
||||||
|
value, so it has to be overridden rather than inherited. Custom properties
|
||||||
|
DO inherit, so an ancestor setting --lede-color would work too; a global
|
||||||
|
ancestor selector is fewer moving parts for one rule. */
|
||||||
|
:global(.section-inverse) .lede {
|
||||||
|
color: var(--text-inverse);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,318 @@
|
|||||||
|
---
|
||||||
|
/**
|
||||||
|
* docs/01 + docs/02: full sitemap in three columns, contact block, professional
|
||||||
|
* designations, LinkedIn, privacy, terms, and the entity line.
|
||||||
|
*
|
||||||
|
* Every string here that is a fact about Pouya comes from src/data/site.ts,
|
||||||
|
* which mirrors AGENTS.md §4. Nothing is typed inline — a claim written by hand
|
||||||
|
* in a component is a claim nobody re-checks against the register.
|
||||||
|
*
|
||||||
|
* The copyright line is `© <year> SML Company Ltd` and nothing more. Pouya's
|
||||||
|
* direction, 2026-08-26. Jurisdiction of incorporation is verified (federal,
|
||||||
|
* CBCA — AGENTS.md §4) and deliberately not published; place of business is the
|
||||||
|
* contact block's job, not the entity line's. See the note on SITE.entity.
|
||||||
|
*/
|
||||||
|
import {
|
||||||
|
CONTACT,
|
||||||
|
CREDENTIALS,
|
||||||
|
LEGAL_NAV,
|
||||||
|
PRACTICE_AREAS,
|
||||||
|
SECONDARY_NAV,
|
||||||
|
SITE,
|
||||||
|
} from '../data/site';
|
||||||
|
import InfinityMark from './InfinityMark.astro';
|
||||||
|
|
||||||
|
// Build-time year. A literal would be correct for exactly as long as it takes
|
||||||
|
// the calendar to turn over; this is correct for as long as the site is
|
||||||
|
// deployed, which is the same cadence everything else here updates on.
|
||||||
|
const year = new Date().getFullYear();
|
||||||
|
|
||||||
|
// Med-Arb was filtered out of this column for a few hours on 2026-08-26 and is
|
||||||
|
// RESTORED, because removing it was wrong on three counts and `adversarial-
|
||||||
|
// reviewer` caught all three. (1) docs/01 specifies this footer as the FULL
|
||||||
|
// sitemap and lists /med-arb/ as deliberately out of the primary nav, "linked
|
||||||
|
// contextually" — so dropping it here left the page with no site-wide link at
|
||||||
|
// all, on a project whose entire premise is crawlability. (2) The label names a
|
||||||
|
// PAGE, and docs/01 frames that page around the C.Med-Arb arc rather than as a
|
||||||
|
// present offering, so listing it is not the §4 inference it looked like.
|
||||||
|
// (3) It settled half of Q35 unilaterally while the other half — Energy and
|
||||||
|
// Shareholder — stayed in both nav and footer, and the record claimed no
|
||||||
|
// unilateral action had been taken. Both halves of Q35 go to Pouya together.
|
||||||
|
const processLinks = [
|
||||||
|
{ href: '/mediation/', label: 'Mediation' },
|
||||||
|
{ href: '/arbitration/', label: 'Arbitration' },
|
||||||
|
...SECONDARY_NAV,
|
||||||
|
{ href: '/fees/', label: 'Fees' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const aboutLinks = [
|
||||||
|
{ href: '/about/', label: 'About' },
|
||||||
|
{ href: '/insights/', label: 'Insights' },
|
||||||
|
{ href: '/contact/', label: 'Contact' },
|
||||||
|
];
|
||||||
|
---
|
||||||
|
|
||||||
|
<footer class="site-footer">
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="footer-top">
|
||||||
|
<a class="footer-brand" href="/">
|
||||||
|
<InfinityMark size="2.25rem" />
|
||||||
|
<span class="footer-brand-name">{SITE.name}</span>
|
||||||
|
</a>
|
||||||
|
{
|
||||||
|
/* The designation strip carries the credentialing STAGE, not just the
|
||||||
|
held designation, and that is a §4 Offerings condition rather than a
|
||||||
|
flourish. The masthead says "Mediation · Arbitration · Toronto" on
|
||||||
|
every page; §4 permits the arbitration half on the condition that the
|
||||||
|
site "makes the first while stating the second plainly", and
|
||||||
|
"neither half may be dropped". Before this line rendered
|
||||||
|
CREDENTIALS.inProgress, that condition was unmet on every page that
|
||||||
|
ships — the constant existed in site.ts and was rendered nowhere.
|
||||||
|
Q.Arb reads as commenced, never as held (§4). */
|
||||||
|
}
|
||||||
|
<p class="footer-designation">
|
||||||
|
{[...CREDENTIALS.designations, ...CREDENTIALS.inProgress].join(' · ')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="footer-grid">
|
||||||
|
<nav class="footer-nav" aria-label="Footer">
|
||||||
|
<div class="footer-col">
|
||||||
|
<h2 class="footer-heading">Practice areas</h2>
|
||||||
|
<ul role="list">
|
||||||
|
{
|
||||||
|
PRACTICE_AREAS.map((area) => (
|
||||||
|
<li>
|
||||||
|
<a href={`/practice/${area.slug}/`}>{area.name}</a>
|
||||||
|
</li>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
<li><a href="/practice/">All practice areas</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="footer-col">
|
||||||
|
<h2 class="footer-heading">Process</h2>
|
||||||
|
<ul role="list">
|
||||||
|
{
|
||||||
|
processLinks.map((link) => (
|
||||||
|
<li>
|
||||||
|
<a href={link.href}>{link.label}</a>
|
||||||
|
</li>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="footer-col">
|
||||||
|
<h2 class="footer-heading">About</h2>
|
||||||
|
<ul role="list">
|
||||||
|
{
|
||||||
|
aboutLinks.map((link) => (
|
||||||
|
<li>
|
||||||
|
<a href={link.href}>{link.label}</a>
|
||||||
|
</li>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="footer-col footer-contact">
|
||||||
|
<h2 class="footer-heading">Contact</h2>
|
||||||
|
<ul role="list">
|
||||||
|
<li><a href={`mailto:${CONTACT.email}`}>{CONTACT.email}</a></li>
|
||||||
|
<li><span class="footer-meta">{CONTACT.phoneFallback}</span></li>
|
||||||
|
<li><span class="footer-meta">{CONTACT.location}</span></li>
|
||||||
|
<li><a href={CONTACT.linkedin}>LinkedIn</a></li>
|
||||||
|
</ul>
|
||||||
|
<p class="footer-response">{CONTACT.responseTime}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="footer-bottom">
|
||||||
|
<p class="footer-entity">© {year} {SITE.entity}</p>
|
||||||
|
<ul class="footer-legal" role="list">
|
||||||
|
{
|
||||||
|
LEGAL_NAV.map((link) => (
|
||||||
|
<li>
|
||||||
|
<a href={link.href}>{link.label}</a>
|
||||||
|
</li>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.site-footer {
|
||||||
|
background: var(--bg-inverse);
|
||||||
|
color: var(--text-inverse);
|
||||||
|
padding-block: var(--space-9) var(--space-6);
|
||||||
|
margin-block-start: var(--space-9);
|
||||||
|
}
|
||||||
|
/* WHEN THE LAST SECTION IS ALREADY DARK, DROP THE MARGIN. The 96px above the
|
||||||
|
footer is page rhythm against cream; where the page ends on the maroon
|
||||||
|
conversion band or an ink section it becomes a 96px CREAM STRIPE sandwiched
|
||||||
|
between two dark blocks, which reads as a layout bug rather than as air.
|
||||||
|
Found by looking at a full-page screenshot of `/`, not by reading the CSS.
|
||||||
|
|
||||||
|
`:global()` on the ancestor half is the mechanism: Astro appends this
|
||||||
|
component's cid to `.site-footer` and leaves the globalised part alone, so
|
||||||
|
the selector can reach out of the component to <main> without a parent
|
||||||
|
needing to style a child's root — the thing that cannot be done the other
|
||||||
|
way round (CLAUDE.md). */
|
||||||
|
:global(main:has(> :last-child.section-accent)) + .site-footer,
|
||||||
|
:global(main:has(> :last-child.section-inverse)) + .site-footer {
|
||||||
|
margin-block-start: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-top {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-4);
|
||||||
|
padding-block-end: var(--space-6);
|
||||||
|
border-block-end: 1px solid var(--rule);
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
min-block-size: 44px;
|
||||||
|
color: var(--text-inverse);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.footer-brand-name {
|
||||||
|
font-family: var(--font-serif);
|
||||||
|
font-size: var(--text-2xl);
|
||||||
|
letter-spacing: var(--tracking-tight);
|
||||||
|
}
|
||||||
|
.footer-brand:hover .footer-brand-name {
|
||||||
|
color: var(--text-inverse-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-designation {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
/* --gold-l on ink measures 11.09:1 (docs/02). --muted on ink is 3.07:1
|
||||||
|
and fails, which is why secondary text on dark is never --muted. */
|
||||||
|
color: var(--text-inverse-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-7) var(--space-6);
|
||||||
|
padding-block: var(--space-7);
|
||||||
|
}
|
||||||
|
.footer-nav {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-7) var(--space-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-heading {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-weight: var(--weight-medium);
|
||||||
|
letter-spacing: var(--tracking-eyebrow);
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-inverse-2);
|
||||||
|
margin-block-end: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-col ul {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
/* Gap is small because the 44px target boxes below now supply the spacing.
|
||||||
|
Measured before this: 18px-tall links with 12px gaps, on the full sitemap
|
||||||
|
that appears on all nineteen pages. */
|
||||||
|
gap: var(--space-1);
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
.footer-col a {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
min-block-size: 44px; /* docs/02 accessibility floor */
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--text-inverse);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.footer-col a:hover {
|
||||||
|
color: var(--text-inverse-2);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
/* Not a link, so no target floor — but it shares a column with links and
|
||||||
|
should sit on the same rhythm. */
|
||||||
|
.footer-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
min-block-size: 44px;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--text-inverse-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-response {
|
||||||
|
max-inline-size: 26ch;
|
||||||
|
margin-block-start: var(--space-4);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--text-inverse-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-bottom {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-4);
|
||||||
|
padding-block-start: var(--space-6);
|
||||||
|
border-block-start: 1px solid var(--line-dark);
|
||||||
|
}
|
||||||
|
.footer-entity {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--text-inverse-2);
|
||||||
|
}
|
||||||
|
.footer-legal {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-4);
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
.footer-legal a {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
min-block-size: 44px;
|
||||||
|
min-inline-size: 44px;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--text-inverse-2);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.footer-legal a:hover {
|
||||||
|
color: var(--text-inverse);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Focus ring on ink: the maroon ring from global.css is 1.46:1 against the
|
||||||
|
dark panel and effectively invisible. Gold measures 8.00:1 there. */
|
||||||
|
.site-footer :focus-visible {
|
||||||
|
outline-color: var(--rule);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 40rem) {
|
||||||
|
.footer-nav {
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 60rem) {
|
||||||
|
.footer-grid {
|
||||||
|
grid-template-columns: minmax(0, 3fr) minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,470 @@
|
|||||||
|
---
|
||||||
|
/**
|
||||||
|
* docs/01: primary nav is About · Mediation · Arbitration · Practice · Fees ·
|
||||||
|
* Insights · Contact, with Practice as a dropdown to the six areas and
|
||||||
|
* /practice/ itself reachable. "Build it as a <details> element or a CSS-only
|
||||||
|
* disclosure — no JavaScript." It is a <details>.
|
||||||
|
*
|
||||||
|
* INSIGHTS IS GATED, NOT HARDCODED. docs/01: "The section stays out of primary
|
||||||
|
* navigation until at least two pieces are live." src/data/site.ts lists it in
|
||||||
|
* PRIMARY_NAV, which is the correct end state — so the count is read from the
|
||||||
|
* collection at build time instead of the rule living in a human's memory. It
|
||||||
|
* appears by itself when step 7 publishes the second article.
|
||||||
|
*
|
||||||
|
* NO MOBILE DISCLOSURE, deliberately. Hiding the nav behind a <details> on
|
||||||
|
* small screens needs CSS that force-shows the panel again at desktop width,
|
||||||
|
* and the mechanism browsers use to hide closed <details> content is currently
|
||||||
|
* mid-migration (`display` override in some engines, `::details-content` in
|
||||||
|
* others). A wrapped nav row needs none of it and puts every link one tap away.
|
||||||
|
*/
|
||||||
|
import { getCollection } from 'astro:content';
|
||||||
|
import { PRIMARY_NAV, SITE } from '../data/site';
|
||||||
|
import InfinityMark from './InfinityMark.astro';
|
||||||
|
import Button from './Button.astro';
|
||||||
|
|
||||||
|
const published = await getCollection('insights', ({ data }) => !data.draft);
|
||||||
|
const showInsights = published.length >= 2;
|
||||||
|
|
||||||
|
const items = PRIMARY_NAV.filter(
|
||||||
|
(item) => item.href !== '/insights/' || showInsights,
|
||||||
|
);
|
||||||
|
|
||||||
|
const path = Astro.url.pathname;
|
||||||
|
const isCurrent = (href: string) => path === href;
|
||||||
|
const inSection = (href: string) => path === href || path.startsWith(href);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* THE MASTHEAD TAGLINE IS SUPPRESSED ON `/`, and this closes a step-1 review
|
||||||
|
* finding rather than being a preference.
|
||||||
|
*
|
||||||
|
* SITE.tagline is `Mediation · Arbitration · Toronto`, and docs/01 specifies
|
||||||
|
* that exact string as the home HERO EYEBROW. At >=76rem the header shows the
|
||||||
|
* tagline too, so `/` opened with the same six words twice, 300px apart —
|
||||||
|
* `adversarial-reviewer` #10, left open at step 1 to "decide at step 2".
|
||||||
|
*
|
||||||
|
* The hero keeps it, because the hero is where docs/01 puts it and where there
|
||||||
|
* is copy underneath to qualify it. The masthead drops it, because the masthead
|
||||||
|
* is the placement Q33-orig objected to in the first place: a line under his
|
||||||
|
* name with nothing to qualify it, reading as a designation strip.
|
||||||
|
*
|
||||||
|
* This only ever REMOVES a claim from one page, so no §4 disclosure condition
|
||||||
|
* is touched — the footer's designation strip carries `Q.Arb — commenced
|
||||||
|
* August 2026` on every page including this one.
|
||||||
|
*/
|
||||||
|
const isHome = path === '/';
|
||||||
|
---
|
||||||
|
|
||||||
|
<header class="site-header">
|
||||||
|
<div class="wrap header-inner">
|
||||||
|
<a class="brand" href="/">
|
||||||
|
<InfinityMark size="2rem" />
|
||||||
|
<span class="brand-text">
|
||||||
|
<span class="brand-name">{SITE.name}</span>
|
||||||
|
{!isHome && <span class="eyebrow brand-tagline">{SITE.tagline}</span>}
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<nav class="nav" aria-label="Primary">
|
||||||
|
<ul class="nav-list" role="list">
|
||||||
|
{
|
||||||
|
items.map((item) =>
|
||||||
|
'children' in item ? (
|
||||||
|
<li class="nav-item">
|
||||||
|
<details class="dropdown">
|
||||||
|
<summary
|
||||||
|
class="nav-link"
|
||||||
|
data-section={inSection(item.href) ? 'true' : undefined}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</summary>
|
||||||
|
<ul class="dropdown-panel" role="list">
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
href={item.href}
|
||||||
|
aria-current={isCurrent(item.href) ? 'page' : undefined}
|
||||||
|
>
|
||||||
|
Practice overview
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{item.children.map((area) => (
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
href={`${item.href}${area.slug}/`}
|
||||||
|
aria-current={
|
||||||
|
isCurrent(`${item.href}${area.slug}/`)
|
||||||
|
? 'page'
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{area.name}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</details>
|
||||||
|
</li>
|
||||||
|
) : (
|
||||||
|
<li class="nav-item">
|
||||||
|
<a
|
||||||
|
class="nav-link"
|
||||||
|
href={item.href}
|
||||||
|
data-section={inSection(item.href) ? 'true' : undefined}
|
||||||
|
aria-current={isCurrent(item.href) ? 'page' : undefined}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{
|
||||||
|
/* The wrapper is not decoration. Astro does not pass a parent's scope
|
||||||
|
attribute to a child component's root element, so `class="header-cta"`
|
||||||
|
on <Button> compiles to `.header-cta[data-astro-cid-<header>]` while the
|
||||||
|
rendered <a> carries only Button's own cid — the rule never matches.
|
||||||
|
Measured before this wrapper existed: the CTA was not hidden below 640px
|
||||||
|
despite a rule saying so, and sat 75px short of the right edge on
|
||||||
|
desktop because `margin-inline-start: auto` never applied. Wrap the
|
||||||
|
child in an element the parent owns. See CLAUDE.md. */
|
||||||
|
}
|
||||||
|
<div class="header-cta">
|
||||||
|
<Button href="/contact/" variant="primary">Request a consultation</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.site-header {
|
||||||
|
position: relative;
|
||||||
|
z-index: var(--z-header);
|
||||||
|
background: var(--bg);
|
||||||
|
border-block-end: 1px solid transparent;
|
||||||
|
padding-block: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-inner {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-4) var(--space-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Brand --------------------------------------------------------------- */
|
||||||
|
|
||||||
|
/* The tagline is BACK, and the reasoning is worth keeping rather than just
|
||||||
|
the outcome. It was removed on 2026-08-26 because `Arbitration` under
|
||||||
|
Pouya's name read as a held capability, and §4 records Q.Arb as merely
|
||||||
|
commenced. Q33 answered that the same day, and the premise was wrong:
|
||||||
|
ADR designations are voluntary credentials, not licences, and COMMERCIAL
|
||||||
|
arbitral appointment in Ontario is not gated behind a designation — so the
|
||||||
|
constraint was always positional, never legal, and Pouya accepts sole,
|
||||||
|
party-appointed and co-arbitration work today. See §4 Offerings.
|
||||||
|
|
||||||
|
SCOPED 2026-08-27 (Q39). This comment said "Anyone may be appointed an
|
||||||
|
arbitrator in Ontario", which Pouya checked and found FALSE as a universal:
|
||||||
|
family arbitration carries prescribed training. It does not touch what this
|
||||||
|
header renders — the masthead says Mediation · Arbitration · Toronto and
|
||||||
|
family arbitration is not offered at all — but a false proposition of law
|
||||||
|
sitting in a source comment is how one reaches a page. §4's Forbidden scope
|
||||||
|
note: no file in this repo may assert as fact what the register has not
|
||||||
|
verified, internal or not. */
|
||||||
|
.brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
/* 48px, not 44. Still clears the touch floor, and it reserves the height the
|
||||||
|
two-line brand takes at >=76rem so the sticky header is one constant 81px
|
||||||
|
across every width where it is sticky — which is what --header-h and
|
||||||
|
scroll-padding-top are keyed to. One number instead of two bands. */
|
||||||
|
min-block-size: 48px;
|
||||||
|
color: var(--accent);
|
||||||
|
text-decoration: none;
|
||||||
|
margin-inline-end: auto;
|
||||||
|
}
|
||||||
|
.brand-text {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-05);
|
||||||
|
}
|
||||||
|
/* The tagline appears only where there is room for it — see the 76rem block.
|
||||||
|
Measured: at 11px with 0.18em tracking the string is ~285px wide, and
|
||||||
|
restoring it under the name pushed the one-row header past its content box
|
||||||
|
by 18px at 1024 with six items and 84px with seven. The brand name carries
|
||||||
|
the identity on its own; the tagline is a flourish, and `/` opens with the
|
||||||
|
same words as the hero eyebrow (docs/01). */
|
||||||
|
.brand-tagline {
|
||||||
|
display: none;
|
||||||
|
font-size: var(--text-2xs); /* 11px — the eyebrow floor in docs/02 */
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.brand-name {
|
||||||
|
font-family: var(--font-serif);
|
||||||
|
font-size: var(--text-xl);
|
||||||
|
line-height: var(--leading-tight);
|
||||||
|
letter-spacing: var(--tracking-tight);
|
||||||
|
white-space: nowrap;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.brand:hover .brand-name {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Nav ----------------------------------------------------------------- */
|
||||||
|
|
||||||
|
.nav-list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
/* Row gap deliberately tiny and column gap generous. They were one
|
||||||
|
shorthand at 32px, so a wrap cost 32px of header height as well as
|
||||||
|
32px between items. */
|
||||||
|
row-gap: var(--space-1);
|
||||||
|
column-gap: var(--space-5);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.nav-item {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.nav-link {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
/* docs/02 accessibility floor: touch targets >= 44 x 44. Measured at 320px
|
||||||
|
before this, every nav link was 38px tall and "Fees" was 31px wide. */
|
||||||
|
min-block-size: 44px;
|
||||||
|
min-inline-size: 44px;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-decoration: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.nav-link:hover {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
/* State is carried by a MAROON rule, not a gold one.
|
||||||
|
Colour alone cannot carry meaning (docs/02) — but neither can a marker
|
||||||
|
nobody can see. The first version of this used `--rule` (gold) for both
|
||||||
|
indicators, which measures 2.10:1 on cream: the exact pairing this project
|
||||||
|
banned, doing the exact job WCAG 1.4.11 needs 3:1 for. Strip an invisible
|
||||||
|
line and what is left is maroon vs ink-soft, i.e. colour alone again.
|
||||||
|
`--accent` measures 12.29:1, and 2px is visible without shouting.
|
||||||
|
Dotted for "you are in this section", solid for "this is the page". */
|
||||||
|
.nav-link[data-section='true'] {
|
||||||
|
color: var(--accent);
|
||||||
|
text-decoration: underline dotted var(--accent);
|
||||||
|
text-decoration-thickness: 2px;
|
||||||
|
text-underline-offset: 0.4em;
|
||||||
|
}
|
||||||
|
.nav-link[aria-current='page'] {
|
||||||
|
color: var(--accent);
|
||||||
|
text-decoration: none;
|
||||||
|
box-shadow: inset 0 -2px 0 0 var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Practice dropdown --------------------------------------------------- */
|
||||||
|
|
||||||
|
summary.nav-link {
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
summary.nav-link::-webkit-details-marker {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
summary.nav-link::after {
|
||||||
|
content: '';
|
||||||
|
display: inline-block;
|
||||||
|
inline-size: 0.4em;
|
||||||
|
block-size: 0.4em;
|
||||||
|
margin-inline-start: 0.5em;
|
||||||
|
border-inline-end: 1px solid currentColor;
|
||||||
|
border-block-end: 1px solid currentColor;
|
||||||
|
transform: translateY(-0.15em) rotate(45deg);
|
||||||
|
transition: transform var(--dur-fast) var(--ease);
|
||||||
|
}
|
||||||
|
.dropdown[open] > summary.nav-link::after {
|
||||||
|
transform: translateY(0.1em) rotate(-135deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-panel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-1);
|
||||||
|
margin: 0;
|
||||||
|
padding: var(--space-3) 0 var(--space-2) var(--space-4);
|
||||||
|
border-inline-start: 1px solid var(--rule);
|
||||||
|
}
|
||||||
|
.dropdown-panel a {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
min-block-size: 44px;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.dropdown-panel a:hover,
|
||||||
|
.dropdown-panel a[aria-current='page'] {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Nav takes its own row below 66rem. No `order` anywhere: reordering flex
|
||||||
|
items moved the CTA onto row one visually while it stayed last in the DOM,
|
||||||
|
so a keyboard user tabbed off the brand, down through all 13 nav links
|
||||||
|
including the practice panel, and back UP to the CTA. WCAG 2.4.3, and
|
||||||
|
docs/02's "focus order matches visual order". DOM order is now visual
|
||||||
|
order at every width. */
|
||||||
|
.nav {
|
||||||
|
flex-basis: 100%;
|
||||||
|
}
|
||||||
|
/* `white-space` INHERITS into the Button, which is how a parent reaches a
|
||||||
|
child component's root at all here — see the note on why this wrapper
|
||||||
|
exists. Without it the label wrapped to two lines, standing the header up
|
||||||
|
taller than it needed to be. `flex: none` stops the row squeezing it. */
|
||||||
|
.header-cta {
|
||||||
|
flex: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
/* Below 66rem the CTA is dropped, not shrunk — and that is the whole reason
|
||||||
|
no `order` is needed. Wherever the nav has its own row, `/contact/` is
|
||||||
|
already on it, so the button is a duplicate link paying for itself in
|
||||||
|
header height. It was previously dropped only below 40rem and reordered in
|
||||||
|
between, which is what put focus order out of step with visual order. */
|
||||||
|
@media (max-width: 65.999rem) {
|
||||||
|
.header-cta {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Desktop ------------------------------------------------------------- */
|
||||||
|
|
||||||
|
/* --- Desktop: 66rem (1024px) ---------------------------------------------
|
||||||
|
The threshold is measured, and the first measurement was WRONG — worth
|
||||||
|
recording, because of how it was wrong. It read "32px of clearance at
|
||||||
|
1024px". That 32px was `.header-inner`'s own `column-gap`, i.e. exactly
|
||||||
|
zero slack, mistaken for headroom. The true max-content sum at seven items
|
||||||
|
is 48 + 184.3 brand + 24 margin + 32 gap + 480.1 nav + 32 gap + 197.2 CTA
|
||||||
|
+ 48 = **1045.6px — 21.6px OVER a 1024px viewport.** Nothing overflowed
|
||||||
|
only because flexbox shrank the brand and crushed the mark inside it.
|
||||||
|
|
||||||
|
Binary search on the built page with a seventh item gives the real
|
||||||
|
thresholds: the row fits from **1047px**, and the tagline from **1207px**.
|
||||||
|
66rem (1056) and 76rem (1216) are the clean tokens above each. Below 66rem
|
||||||
|
the nav takes its own row.
|
||||||
|
|
||||||
|
Insights is the seventh item and arrives by itself at build step 7
|
||||||
|
(SiteHeader gates it on the collection), so a breakpoint verified only
|
||||||
|
against today's six is a bug with a date on it. It was verified against
|
||||||
|
seven.
|
||||||
|
|
||||||
|
Earlier drafts of this comment carried two different overflow figures for
|
||||||
|
960px — 34px and 14px — taken before and after the CTA stopped shrinking.
|
||||||
|
Both were true once and neither is now; they are gone rather than reconciled,
|
||||||
|
because a number nobody can re-derive is worse than no number.
|
||||||
|
|
||||||
|
Sticky only from here up, too. Below this the nav takes a second row and
|
||||||
|
the header stands at 137px, which is more of a small viewport than a
|
||||||
|
sticky header is worth. Deviation from docs/02 "Sticky"; recorded there. */
|
||||||
|
@media (min-width: 66rem) {
|
||||||
|
.site-header {
|
||||||
|
position: sticky;
|
||||||
|
inset-block-start: 0;
|
||||||
|
}
|
||||||
|
.header-inner {
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
}
|
||||||
|
.brand {
|
||||||
|
margin-inline-end: var(--space-5);
|
||||||
|
}
|
||||||
|
.nav {
|
||||||
|
flex-basis: auto;
|
||||||
|
}
|
||||||
|
/* nowrap, and flex:none so the nav is never squeezed below its content
|
||||||
|
width. Measured before this: at 960-1250px the seven-item nav broke to
|
||||||
|
two rows and the header stood at 141px instead of 81px. */
|
||||||
|
.nav-list {
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
flex: none;
|
||||||
|
/* 16px from 66rem, widening to 24px at 76rem where there is room for it.
|
||||||
|
Measured with seven items at every width from 1024px up. */
|
||||||
|
column-gap: var(--space-4);
|
||||||
|
}
|
||||||
|
.header-cta {
|
||||||
|
margin-inline-start: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dropdown becomes an overlay panel rather than an in-flow list. */
|
||||||
|
.dropdown-panel {
|
||||||
|
position: absolute;
|
||||||
|
inset-block-start: calc(100% + var(--space-3));
|
||||||
|
inset-inline-start: calc(var(--space-4) * -1);
|
||||||
|
inline-size: max-content;
|
||||||
|
max-inline-size: 20rem;
|
||||||
|
padding: var(--space-3) var(--space-4);
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-block-start: 2px solid var(--rule);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Condense on scroll (docs/02) ----------------------------------------
|
||||||
|
Scroll-driven, no JavaScript. Two things here are the result of
|
||||||
|
measurement, not preference, and both are easy to undo by accident.
|
||||||
|
|
||||||
|
1. LONGHANDS ONLY — never the `animation` shorthand beside
|
||||||
|
`animation-timeline`. `scroll()` is not a legal component of the
|
||||||
|
shorthand, and Lightning CSS folds the two declarations together on
|
||||||
|
minify, producing `animation: linear both header-condense scroll()`.
|
||||||
|
That is invalid at computed-value time, so the whole thing is discarded.
|
||||||
|
It worked in `npm run dev` (unminified) and was dead in `npm run build`.
|
||||||
|
Verified in the emitted CSS, and `/build` Phase 5 now greps dist for it.
|
||||||
|
|
||||||
|
2. NOTHING THAT CHANGES HEIGHT. The header is `position: sticky`, so it
|
||||||
|
stays in normal flow: its layout box sits at the top of the document
|
||||||
|
whatever the viewport is showing. Shrinking its padding shortens that
|
||||||
|
box and lifts every page below it — a scroll-linked layout shift on
|
||||||
|
every page, against the CLS < 0.05 budget in docs/04. The original
|
||||||
|
keyframe animated `padding-block` and would have done exactly that.
|
||||||
|
|
||||||
|
What is left is honest: the header gains a hairline rule and a shadow once
|
||||||
|
you scroll off the top. That is a smaller effect than docs/02's "condenses
|
||||||
|
on scroll", and docs/02 has been amended to say so and why. */
|
||||||
|
|
||||||
|
/* The tagline arrives at 76rem. The nav gap does NOT widen here as well:
|
||||||
|
measured, doing both at one breakpoint put the row 4px past its content box
|
||||||
|
at exactly 1200px with seven items. Two things growing at the same width is
|
||||||
|
how a breakpoint gets over-subscribed. */
|
||||||
|
@media (min-width: 76rem) {
|
||||||
|
.brand-tagline {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 80rem) {
|
||||||
|
.nav-list {
|
||||||
|
column-gap: var(--space-5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@supports (animation-timeline: scroll()) {
|
||||||
|
@media (min-width: 66rem) and (prefers-reduced-motion: no-preference) {
|
||||||
|
.site-header {
|
||||||
|
animation-name: header-lift;
|
||||||
|
animation-duration: 1ms;
|
||||||
|
animation-timing-function: linear;
|
||||||
|
animation-fill-mode: both;
|
||||||
|
animation-timeline: scroll();
|
||||||
|
animation-range: 0 6rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes header-lift {
|
||||||
|
to {
|
||||||
|
border-block-end-color: var(--border);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
import { defineCollection } from 'astro:content';
|
||||||
|
import { glob } from 'astro/loaders';
|
||||||
|
import { z } from 'astro/zod';
|
||||||
|
import { PRACTICE_SLUGS } from './data/site';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `<title>` length, from docs/04-seo-spec.md.
|
||||||
|
*
|
||||||
|
* Articles do NOT carry the ` · Pouya Lajevardi` suffix that other pages use.
|
||||||
|
* The suffix is 18 characters; appending it to a headline that already reads
|
||||||
|
* 50–60 produces 68–78, over the spec's own ceiling. Measured against the five
|
||||||
|
* launch headlines in docs/03-content-spec.md, the suffix rule fails 5 of 5
|
||||||
|
* and the no-suffix rule passes 4 of 5. A rule that its own content cannot
|
||||||
|
* satisfy is a rule that will be worked around.
|
||||||
|
*/
|
||||||
|
const TITLE_MIN = 50;
|
||||||
|
const TITLE_MAX = 60;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Frontmatter dates. Three failure modes this has to close, each found by
|
||||||
|
* review rather than by reasoning:
|
||||||
|
*
|
||||||
|
* - `z.coerce.date()` reads unquoted `20260801` — valid YAML, the obvious slip
|
||||||
|
* for `2026-08-01` — as epoch milliseconds and yields 1970-01-01, silently.
|
||||||
|
* - An unanchored regex accepts `2026-13-45` and `2026-08-01 nonsense`, both of
|
||||||
|
* which produce an `Invalid Date` that reaches `datePublished` in the
|
||||||
|
* article's JSON-LD or throws at build from `.toISOString()`.
|
||||||
|
* - `new Date('2026-02-30')` rolls over to 2026-03-02 — a wrong date shipped
|
||||||
|
* with no error at all, which is worse than a failed build.
|
||||||
|
*
|
||||||
|
* So: anchored, date-only, parsed as UTC, and round-tripped to prove the day
|
||||||
|
* that comes back is the day that was written. A time component is rejected
|
||||||
|
* rather than guessed at — quoted `2026-08-01T10:00:00` parses as local time
|
||||||
|
* while the unquoted YAML form parses as UTC, so the same frontmatter would
|
||||||
|
* mean different instants on a laptop and on a CI runner.
|
||||||
|
*/
|
||||||
|
const frontmatterDate = z.union(
|
||||||
|
[
|
||||||
|
z.date(),
|
||||||
|
z
|
||||||
|
.string()
|
||||||
|
.regex(/^\d{4}-\d{2}-\d{2}$/)
|
||||||
|
.transform((value, ctx) => {
|
||||||
|
const parsed = new Date(`${value}T00:00:00Z`);
|
||||||
|
if (
|
||||||
|
Number.isNaN(parsed.getTime()) ||
|
||||||
|
parsed.toISOString().slice(0, 10) !== value
|
||||||
|
) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: 'custom',
|
||||||
|
message: `"${value}" is not a real calendar date.`,
|
||||||
|
});
|
||||||
|
return z.NEVER;
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
{ error: 'Use a date-only ISO value, e.g. 2026-08-01 (no time component).' },
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Insights. Content territories are set by the strategy brief §VII and
|
||||||
|
* restated in docs/03-content-spec.md.
|
||||||
|
*
|
||||||
|
* Frontmatter shape follows docs/01-architecture.md.
|
||||||
|
*
|
||||||
|
* Astro 5 introduced the Content Layer API and the `src/content.config.ts`
|
||||||
|
* location; Astro 6 removed the legacy `src/content/config.ts` fallback, so
|
||||||
|
* collections now declare a `loader` rather than a `type`, and `z` imports from
|
||||||
|
* `astro/zod`. See AGENTS.md entry (t).
|
||||||
|
*/
|
||||||
|
const insights = defineCollection({
|
||||||
|
loader: glob({ pattern: '**/*.{md,mdx}', base: './src/content/insights' }),
|
||||||
|
schema: ({ image }) =>
|
||||||
|
z
|
||||||
|
.object({
|
||||||
|
/**
|
||||||
|
* The headline, and by default the `<title>` too. The refinement below
|
||||||
|
* enforces 50–60 on whichever of this and `seoTitle` is rendered; these
|
||||||
|
* bounds only catch something wildly wrong.
|
||||||
|
*/
|
||||||
|
title: z.string().trim().min(10).max(120),
|
||||||
|
/**
|
||||||
|
* Replaces the headline when building the `<title>`. Needed when a
|
||||||
|
* headline that reads well is outside 50–60 — good writing is not an
|
||||||
|
* error. Deliberately unbounded here: the refinement below is the single
|
||||||
|
* check, so one mistake produces one message rather than two.
|
||||||
|
*/
|
||||||
|
seoTitle: z.string().trim().min(1).optional(),
|
||||||
|
/** Doubles as the meta description — docs/04-seo-spec.md, 140–160. */
|
||||||
|
description: z.string().min(140).max(160),
|
||||||
|
publishDate: frontmatterDate,
|
||||||
|
updatedDate: frontmatterDate.optional(),
|
||||||
|
/**
|
||||||
|
* Plural, per docs/01-architecture.md and the topic pills in
|
||||||
|
* docs/02-design-system.md. A piece can legitimately be both
|
||||||
|
* regulatory and industry commentary.
|
||||||
|
*/
|
||||||
|
topics: z
|
||||||
|
.array(
|
||||||
|
z.enum([
|
||||||
|
'process-explainer',
|
||||||
|
'regulatory-commentary',
|
||||||
|
'industry-commentary',
|
||||||
|
'reflection',
|
||||||
|
'technical-explainer',
|
||||||
|
'credentialing',
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
.min(1)
|
||||||
|
.refine((t) => new Set(t).size === t.length, 'No duplicate topics.'),
|
||||||
|
practiceAreas: z
|
||||||
|
.array(z.enum(PRACTICE_SLUGS))
|
||||||
|
.min(1)
|
||||||
|
.refine(
|
||||||
|
(a) => new Set(a).size === a.length,
|
||||||
|
'No duplicate practice areas.',
|
||||||
|
),
|
||||||
|
/** Minutes. docs/02-design-system.md renders it on every ArticleCard. */
|
||||||
|
readingTime: z.number().int().positive(),
|
||||||
|
image: image().optional(),
|
||||||
|
imageAlt: z.string().trim().min(1).optional(),
|
||||||
|
/**
|
||||||
|
* INTENT, not yet enforced — there is no /insights/ route to enforce it
|
||||||
|
* in. The mechanism, when step 7 builds that route: filter drafts out of
|
||||||
|
* `getCollection('insights', ...)` so no page is generated, which keeps
|
||||||
|
* them out of the build, the index, and the sitemap in one move. The
|
||||||
|
* sitemap filter in astro.config.mjs cannot see collection data and is
|
||||||
|
* not the right place for it. See docs/04-seo-spec.md.
|
||||||
|
*/
|
||||||
|
draft: z.boolean().default(true),
|
||||||
|
/** Every article is reviewed by Pouya before publication — D9. */
|
||||||
|
reviewedByPouya: z.boolean().default(false),
|
||||||
|
})
|
||||||
|
.superRefine((data, ctx) => {
|
||||||
|
const rendered = data.seoTitle ?? data.title;
|
||||||
|
if (rendered.length < TITLE_MIN || rendered.length > TITLE_MAX) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: 'custom',
|
||||||
|
path: ['seoTitle'],
|
||||||
|
message:
|
||||||
|
`The <title> would be ${rendered.length} characters ` +
|
||||||
|
`("${rendered}"). docs/04-seo-spec.md requires ${TITLE_MIN}–${TITLE_MAX}. ` +
|
||||||
|
`Either adjust the headline or set seoTitle.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (data.image && !data.imageAlt) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: 'custom',
|
||||||
|
path: ['imageAlt'],
|
||||||
|
message:
|
||||||
|
'imageAlt is required when image is set — alt text is a build ' +
|
||||||
|
'requirement, not a polish pass (CLAUDE.md).',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!data.draft && !data.reviewedByPouya) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: 'custom',
|
||||||
|
path: ['reviewedByPouya'],
|
||||||
|
message:
|
||||||
|
'Every article is reviewed by Pouya before publication (D9). ' +
|
||||||
|
'Set reviewedByPouya: true, or keep draft: true.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const collections = { insights };
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
import { defineCollection, z } from 'astro:content';
|
|
||||||
import { PRACTICE_AREAS } from '../data/site';
|
|
||||||
|
|
||||||
const practiceSlugs = PRACTICE_AREAS.map((a) => a.slug) as [string, ...string[]];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Insights. Content territories are set by the strategy brief §VII and
|
|
||||||
* restated in docs/03-content-spec.md.
|
|
||||||
*
|
|
||||||
* Every piece must link to at least one practice-area page — that is what
|
|
||||||
* turns the blog into ranking power for the pages that convert.
|
|
||||||
*/
|
|
||||||
const insights = defineCollection({
|
|
||||||
type: 'content',
|
|
||||||
schema: ({ image }) =>
|
|
||||||
z.object({
|
|
||||||
title: z.string().max(70),
|
|
||||||
description: z.string().min(70).max(160), // doubles as the meta description
|
|
||||||
publishDate: z.date(),
|
|
||||||
updatedDate: z.date().optional(),
|
|
||||||
topic: z.enum([
|
|
||||||
'process-explainer',
|
|
||||||
'regulatory-commentary',
|
|
||||||
'industry-commentary',
|
|
||||||
'reflection',
|
|
||||||
'technical-explainer',
|
|
||||||
'credentialing',
|
|
||||||
]),
|
|
||||||
practiceAreas: z.array(z.enum(practiceSlugs)).min(1),
|
|
||||||
image: image().optional(),
|
|
||||||
imageAlt: z.string().optional(),
|
|
||||||
/** Drafts are excluded from the build, the index, and the sitemap. */
|
|
||||||
draft: z.boolean().default(true),
|
|
||||||
/**
|
|
||||||
* Every article is reviewed by Pouya before publication (AGENTS.md D9).
|
|
||||||
* An article with draft:false and reviewedByPouya:false is a bug.
|
|
||||||
*/
|
|
||||||
reviewedByPouya: z.boolean().default(false),
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const collections = { insights };
|
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
Articles live here as .mdx.
|
||||||
|
|
||||||
|
The directory is tracked so the glob loader's `base` in src/content.config.ts
|
||||||
|
resolves. That does not silence the build warning, it only downgrades it:
|
||||||
|
without the directory Astro logs "The base directory ... does not exist"; with
|
||||||
|
it, "No files found matching "**/*.{md,mdx}"". Both clear the moment the first
|
||||||
|
article lands.
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
/**
|
||||||
|
* JSON-LD builders. Spec: docs/04-seo-spec.md §Structured data.
|
||||||
|
*
|
||||||
|
* WHY THIS FILE EXISTS. Structured data is a factual claim in machine-readable
|
||||||
|
* form, and it is the one place a claim can be wrong without any human reading
|
||||||
|
* it. AGENTS.md §4 governs it exactly as it governs visible copy — docs/04 says
|
||||||
|
* so in as many words: "Marking an unheld credential as held in structured data
|
||||||
|
* is a misrepresentation that happens to be machine-readable."
|
||||||
|
*
|
||||||
|
* So the Person node is built ONCE, here, from src/data/site.ts, and every page
|
||||||
|
* that needs it references the same @id. `/` (step 2) and `/about/` (step 3)
|
||||||
|
* would otherwise hand-type it twice.
|
||||||
|
*
|
||||||
|
* THREE THINGS ARE DELIBERATELY ABSENT. Each is a decision, not an omission:
|
||||||
|
*
|
||||||
|
* 1. `LegalService` — NEVER. docs/04: schema.org defines it as a business
|
||||||
|
* providing legal advice and *representation*, which asserts in
|
||||||
|
* machine-readable form exactly what D13 bars. `ProfessionalService`.
|
||||||
|
* 2. `worksFor` — omitted. Populating it either names the boutique (D16) or
|
||||||
|
* misstates the employer. `jobTitle` carries the role on its own.
|
||||||
|
* 3. `priceRange` — omitted until `/fees/` exists (build step 9). docs/04
|
||||||
|
* gates it on that page being real.
|
||||||
|
*
|
||||||
|
* AND Q.Arb IS NOT IN `hasCredential`. It commenced August 2026 and is not
|
||||||
|
* held. Q.Med is. That asymmetry is the whole point of the property.
|
||||||
|
*/
|
||||||
|
import { CONTACT, CREDENTIALS, ROLE, SITE } from './site';
|
||||||
|
|
||||||
|
/** Stable node ids, so pages cross-reference rather than duplicate. */
|
||||||
|
export const PERSON_ID = `${SITE.url}/about/#person`;
|
||||||
|
export const SERVICE_ID = `${SITE.url}/#practice`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `Person`. docs/04 anchors this on /about/ and references it site-wide, which
|
||||||
|
* is why PERSON_ID points at /about/ regardless of which page emits the node.
|
||||||
|
*
|
||||||
|
* `description` IS OFFERING-SHAPED, NOT ROLE-SHAPED, and that was a correction.
|
||||||
|
* It read "Mediator and commercial arbitrator in Toronto", which `claims-auditor`
|
||||||
|
* flagged 2026-08-27: §4 verifies that he **accepts** arbitral appointments and
|
||||||
|
* separately verifies "multiple completed sole mediations" — there is **no
|
||||||
|
* counterpart row for a completed arbitration**, so "arbitrator" as a practised
|
||||||
|
* role asserted something the register does not hold.
|
||||||
|
*
|
||||||
|
* AND IT CARRIES THE Q.Arb STAGE. Same finding, and it is the sharper half:
|
||||||
|
* §4 Offerings permits the arbitration offering only while the site states the
|
||||||
|
* stage of the arc plainly — "neither half may be dropped". The VISIBLE page
|
||||||
|
* satisfied that with the fourth credential slot; this graph asserted
|
||||||
|
* arbitration twice (here and in `serviceType`) and stated the stage nowhere.
|
||||||
|
* A machine-readable claim is still a claim.
|
||||||
|
*
|
||||||
|
* `hasCredential` stays Q.Med-only regardless — the stage belongs in prose, not
|
||||||
|
* in a field that means "holds".
|
||||||
|
*
|
||||||
|
* Every clause traces: Q.Med [verified], the JD [verified], engineering
|
||||||
|
* practice [verified], Toronto [verified], the Q.Arb pathway commenced August
|
||||||
|
* 2026 [verified]. It claims no licensure and implies none — D13 bars
|
||||||
|
* implication as hard as assertion, and a crawler summary is a place where an
|
||||||
|
* implication travels unedited.
|
||||||
|
*/
|
||||||
|
export function personNode(imageUrl?: string) {
|
||||||
|
return {
|
||||||
|
'@type': 'Person',
|
||||||
|
'@id': PERSON_ID,
|
||||||
|
name: SITE.name,
|
||||||
|
url: `${SITE.url}/about/`,
|
||||||
|
jobTitle: ROLE.title,
|
||||||
|
description:
|
||||||
|
'Mediator in Toronto, accepting commercial arbitration appointments. ' +
|
||||||
|
'Q.Med designation through ADRIC and ADRIO; the Q.Arb pathway commenced ' +
|
||||||
|
'in August 2026. JD, Bond University; practising machine-learning and ' +
|
||||||
|
'infrastructure engineer.',
|
||||||
|
knowsLanguage: ['en', 'fa'],
|
||||||
|
alumniOf: { '@type': 'CollegeOrUniversity', name: 'Bond University' },
|
||||||
|
// Q.Med only. See the header comment.
|
||||||
|
hasCredential: {
|
||||||
|
'@type': 'EducationalOccupationalCredential',
|
||||||
|
name: CREDENTIALS.designations[0],
|
||||||
|
credentialCategory: 'Professional designation',
|
||||||
|
recognizedBy: [
|
||||||
|
{ '@type': 'Organization', name: 'ADR Institute of Canada' },
|
||||||
|
{ '@type': 'Organization', name: 'ADR Institute of Ontario' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
sameAs: [CONTACT.linkedin],
|
||||||
|
email: `mailto:${CONTACT.email}`,
|
||||||
|
...(imageUrl ? { image: imageUrl } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `ProfessionalService` for the home page, with the Person as `provider`.
|
||||||
|
*
|
||||||
|
* `serviceType` lists what §4 Offerings actually records as offered now —
|
||||||
|
* mediation, arbitration, med-arb. **Arbitration is scoped to commercial**
|
||||||
|
* (Q39, 2026-08-27): family arbitration in Ontario carries prescribed training
|
||||||
|
* and is separately not offered, so an unscoped "Arbitration" here would be the
|
||||||
|
* struck universal reappearing in a machine-readable field where nobody reads
|
||||||
|
* it. Do not widen these strings without a row to widen them from.
|
||||||
|
*
|
||||||
|
* No `priceRange`, no `aggregateRating`, no `review` — the last two have no
|
||||||
|
* underlying data and §4 Forbidden bars the fabricated testimonial that the
|
||||||
|
* previous site carried.
|
||||||
|
*/
|
||||||
|
export function professionalServiceNode(imageUrl?: string) {
|
||||||
|
return {
|
||||||
|
'@type': 'ProfessionalService',
|
||||||
|
'@id': SERVICE_ID,
|
||||||
|
name: `${SITE.name} — Mediation & Arbitration`,
|
||||||
|
url: `${SITE.url}/`,
|
||||||
|
description:
|
||||||
|
'Commercial mediation and arbitration for construction, technology, ' +
|
||||||
|
'energy, insurance, shareholder and cross-border disputes. Toronto, ' +
|
||||||
|
'by appointment. Q.Med held; the Q.Arb pathway commenced August 2026.',
|
||||||
|
provider: { '@id': PERSON_ID },
|
||||||
|
areaServed: [
|
||||||
|
{ '@type': 'City', name: 'Toronto' },
|
||||||
|
{ '@type': 'AdministrativeArea', name: 'Ontario' },
|
||||||
|
],
|
||||||
|
serviceType: [
|
||||||
|
'Mediation',
|
||||||
|
'Commercial arbitration',
|
||||||
|
'Mediation-arbitration (med-arb)',
|
||||||
|
],
|
||||||
|
availableLanguage: ['en', 'fa'],
|
||||||
|
email: `mailto:${CONTACT.email}`,
|
||||||
|
...(imageUrl ? { image: imageUrl } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The home page's graph: the service and the person it resolves to.
|
||||||
|
*
|
||||||
|
* A `@graph` rather than two `<script>` blocks, so `provider: {'@id': …}`
|
||||||
|
* resolves inside one document instead of relying on a crawler joining two.
|
||||||
|
*/
|
||||||
|
export function homeGraph(imageUrl?: string) {
|
||||||
|
return {
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@graph': [professionalServiceNode(imageUrl), personNode(imageUrl)],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `/about/`'s graph — build step 3. This is where PERSON_ID actually resolves:
|
||||||
|
* every other page references `/about/#person`, and until now nothing served it
|
||||||
|
* from that URL.
|
||||||
|
*
|
||||||
|
* ONE NODE, AND THREE ADDITIONS WERE CONSIDERED AND DECLINED. Each is a
|
||||||
|
* decision rather than an omission, recorded so the next reader does not
|
||||||
|
* "complete" it:
|
||||||
|
*
|
||||||
|
* 1. `ProfilePage` as a wrapper, with `mainEntity` → Person. Accurate, and
|
||||||
|
* Google documents it. Declined: docs/04's structured-data table lists
|
||||||
|
* `Person` for this page and does not list `ProfilePage`, and a type not in
|
||||||
|
* the spec is a deviation that needs a reason. The marginal gain is not
|
||||||
|
* one. Revisit in docs/04, not here.
|
||||||
|
* 2. `BreadcrumbList`. docs/04 requires it on "all nested pages" and says it
|
||||||
|
* must MATCH VISIBLE BREADCRUMBS. `/about/` is one hop from the root, has
|
||||||
|
* no visible breadcrumb, and the header nav marks it as current — so
|
||||||
|
* emitting one would assert a navigation structure the page does not show.
|
||||||
|
* Breadcrumbs begin at the two-level pages: `/practice/<area>/`, `/insights/<slug>/`.
|
||||||
|
* 3. `memberOf` for the four memberships. Declined on R10 / **Q44** — and
|
||||||
|
* the visible page reached the same answer one round later, which is worth
|
||||||
|
* recording: `/about/` now publishes **no memberships group at all**,
|
||||||
|
* because R10 is a prohibition on shipping such a page and the
|
||||||
|
* re-confirmation was not obtained. So this field is not a stricter
|
||||||
|
* standard than the page; it is the same one.
|
||||||
|
*
|
||||||
|
* Two earlier versions of this comment were wrong on the facts. They said
|
||||||
|
* "the page publishes them visibly" (it does not, as of 2026-08-28) and
|
||||||
|
* "all four renew yearly" — §4 records yearly renewal for **the OBA
|
||||||
|
* sections and the CTF only** and says nothing about ADRIC or ADRIO. The
|
||||||
|
* widened form had propagated to four files.
|
||||||
|
*
|
||||||
|
* The reason a machine-readable membership claim is worse than a visible
|
||||||
|
* one stands regardless: a list on a page is corrected by editing the page,
|
||||||
|
* while a scraped claim is cached and re-served by systems that never
|
||||||
|
* re-read it. OCNI lapsed quietly once already. Add this when Q44 closes.
|
||||||
|
*/
|
||||||
|
export function aboutGraph(imageUrl?: string) {
|
||||||
|
return {
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@graph': [personNode(imageUrl)],
|
||||||
|
};
|
||||||
|
}
|
||||||
+446
-43
@@ -11,7 +11,31 @@ export const SITE = {
|
|||||||
tagline: 'Mediation · Arbitration · Toronto',
|
tagline: 'Mediation · Arbitration · Toronto',
|
||||||
url: 'https://adr.smlcompany.ca',
|
url: 'https://adr.smlcompany.ca',
|
||||||
locale: 'en_CA',
|
locale: 'en_CA',
|
||||||
entity: 'SML Company Ltd. · Ontario, Canada',
|
/**
|
||||||
|
* The footer's copyright line, in full. Q30 is CLOSED.
|
||||||
|
*
|
||||||
|
* Two facts were being conflated in the string this replaces
|
||||||
|
* (`'SML Company Ltd. · Ontario, Canada'`), which read as a jurisdiction of
|
||||||
|
* incorporation and named the wrong one:
|
||||||
|
*
|
||||||
|
* - Jurisdiction of incorporation — **federal, under the CBCA**
|
||||||
|
* `[verified 2026-08-26 — Pouya]`, recorded in AGENTS.md §4.
|
||||||
|
* - Place of business — Toronto, Ontario. That is `CONTACT.location`,
|
||||||
|
* and it belongs in the contact block, not in the entity line.
|
||||||
|
*
|
||||||
|
* **Neither appears in the footer.** Pouya's direction, 2026-08-26: the line
|
||||||
|
* is `© <year> SML Company Ltd` and nothing else. The incorporation fact is
|
||||||
|
* verified and available — it is simply not published. Do not "complete" this
|
||||||
|
* line by adding it back.
|
||||||
|
*
|
||||||
|
* No corporation number: we do not have one and the line does not need one.
|
||||||
|
*
|
||||||
|
* Spelling note so it does not read as a typo and get "fixed": AGENTS.md §4
|
||||||
|
* writes *SML Company Ltd.* with a terminal period. Pouya specified the
|
||||||
|
* rendered footer string twice, both times without it. His wording governs
|
||||||
|
* what ships.
|
||||||
|
*/
|
||||||
|
entity: 'SML Company Ltd',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -32,42 +56,186 @@ export const CREDENTIALS = {
|
|||||||
'Stitt Feld Handy — negotiation and ADR workshop series',
|
'Stitt Feld Handy — negotiation and ADR workshop series',
|
||||||
],
|
],
|
||||||
languages: ['English', 'Farsi'],
|
languages: ['English', 'Farsi'],
|
||||||
/** [verified 2026-08-26]. NOT OCNI (lapsed) and NOT the Law Society —
|
/**
|
||||||
* listing the LSO implies licensure, which D13 bars. Do not add either. */
|
* [verified 2026-08-26 — Pouya, AGENTS.md Q28 and the CTF addition of the same
|
||||||
|
* date] — and FOR NOW.
|
||||||
|
*
|
||||||
|
* WHAT §4 ACTUALLY SAYS ABOUT RENEWAL, because a widened version of it reached
|
||||||
|
* a public page. §4: *"Both the OBA sections and the CTF renew yearly."* It
|
||||||
|
* says **nothing** about ADRIC's or ADRIO's renewal period. An earlier form of
|
||||||
|
* this comment read "Both the OBA sections and the Canadian Tax Foundation
|
||||||
|
* renew yearly, so every line below is a fact with a shelf life", which is two
|
||||||
|
* claims joined by a "so" that does not follow — and the widened form
|
||||||
|
* ("all four renew annually") then propagated into `schema.ts`, into
|
||||||
|
* `/about/`, and into §9 Q44. Exactly the SES-DKIM duplication shape: the copy
|
||||||
|
* that goes stale is the one nobody re-reads, and this copy became public copy.
|
||||||
|
*
|
||||||
|
* **NOT PUBLISHED AS OF 2026-08-28 — R10 / Q44.** R10 requires a
|
||||||
|
* re-confirmation *"before any page listing memberships ships"*, `/about/` is
|
||||||
|
* that page, and the re-confirmation is a fact only Pouya holds. It was not
|
||||||
|
* obtained, so `/about/` ships its Credentials section WITHOUT a memberships
|
||||||
|
* group and carries a `TODO(pouya)`. Do not render this array on a public page
|
||||||
|
* until Q44 closes.
|
||||||
|
*
|
||||||
|
* NOT OCNI (lapsed — §4: "not current, do not publish") and NOT the Law
|
||||||
|
* Society: listing the LSO implies licensure, which D13 bars. Do not add
|
||||||
|
* either.
|
||||||
|
*/
|
||||||
memberships: [
|
memberships: [
|
||||||
'ADR Institute of Canada (ADRIC)',
|
'ADR Institute of Canada (ADRIC)',
|
||||||
'ADR Institute of Ontario (ADRIO)',
|
'ADR Institute of Ontario (ADRIO)',
|
||||||
'Ontario Bar Association — Construction & Infrastructure, ADR, and Civil Litigation sections',
|
'Ontario Bar Association — Construction & Infrastructure, ADR, and Civil Litigation sections',
|
||||||
|
'Canadian Tax Foundation',
|
||||||
],
|
],
|
||||||
} as const;
|
} 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.
|
* The Toronto boutique is NEVER named — AGENTS.md D16. Use this string.
|
||||||
* Do not infer a name from an email domain or anywhere else.
|
* Do not infer a name from an email domain or anywhere else.
|
||||||
*/
|
*/
|
||||||
export const BOUTIQUE = 'a Toronto litigation and ADR boutique' as const;
|
export const BOUTIQUE = 'a Toronto litigation and ADR boutique' as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The boutique role and the litigation line — the two highest-risk strings on
|
||||||
|
* the site, so they live here rather than being typed into a page.
|
||||||
|
*
|
||||||
|
* Neither had a constant until 2026-08-26, which meant `/about/` (step 3) and
|
||||||
|
* the `Person` JSON-LD (docs/04) were both going to hand-type them. A claim
|
||||||
|
* written by hand in a component is a claim nobody re-checks against §4 — and
|
||||||
|
* these are the two where the wording IS the compliance.
|
||||||
|
*/
|
||||||
|
export const ROLE = {
|
||||||
|
/** §4 verbatim. docs/04: this is `jobTitle` in the Person JSON-LD, and
|
||||||
|
* `worksFor` is OMITTED — populating it either names the boutique (D16) or
|
||||||
|
* misstates the employer. */
|
||||||
|
title: 'Director of Firm Operations', // [verified 2026-08-25 — strategy brief §I]
|
||||||
|
/** Always rendered with BOUTIQUE, never with a firm name (D16). */
|
||||||
|
at: BOUTIQUE,
|
||||||
|
/**
|
||||||
|
* D13's approved phrasing, and the only approved phrasing. The alternative
|
||||||
|
* he approved is 'involvement in litigation and ADR matters'.
|
||||||
|
*
|
||||||
|
* NEVER "practice" in this context — that is the exact word D13 bars in the
|
||||||
|
* exact context it bars it, and §4 records that this register itself once
|
||||||
|
* carried the wrong word here while quoting the strategy brief verbatim.
|
||||||
|
* "Practice" describing Pouya's OWN ADR practice is correct and unaffected.
|
||||||
|
*
|
||||||
|
* Explicitly interim — AGENTS.md R1. Raise it; do not let it settle in.
|
||||||
|
*/
|
||||||
|
litigationLine: 'active litigation exposure', // [verified 2026-08-26 — D13]
|
||||||
|
/** The matter types behind that exposure. §4 verbatim; do not extend this
|
||||||
|
* list without a §4 row to extend it from. */
|
||||||
|
litigationAreas: [
|
||||||
|
'personal injury',
|
||||||
|
'construction',
|
||||||
|
'regulatory (POA)',
|
||||||
|
'insurance (SABS)',
|
||||||
|
],
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* THE Q41(a) SENTENCE. It lives here for the reason `ROLE` above lives here:
|
||||||
|
* *"these are the two where the wording IS the compliance."*
|
||||||
|
*
|
||||||
|
* It was hand-typed into `/` and then into `/about/`, and **the two copies had
|
||||||
|
* already diverged** — `/` used a comma ("one side, a working engineering
|
||||||
|
* practice on the other"), `/about/` used full stops — within the same session
|
||||||
|
* that wrote both. This is the sentence Q41(a) makes responsible for making the
|
||||||
|
* licence implication *"impossible rather than merely absent"*, so a silent
|
||||||
|
* divergence in it is the highest-consequence drift on the site.
|
||||||
|
*
|
||||||
|
* Pouya's ruling, 2026-08-27, kept because it is the finding rather than the fix:
|
||||||
|
*
|
||||||
|
* "The implication test applies everywhere, not just to labels. Prose has more
|
||||||
|
* room, so it is easier to satisfy: state the asymmetry explicitly rather than
|
||||||
|
* relying on a parallel construction to carry it."
|
||||||
|
*
|
||||||
|
* Deleting the parallel is only half of it — a reader supplies the missing
|
||||||
|
* symmetry from silence, and for the legal half the missing half is a licence.
|
||||||
|
* Naming that half **training** is what forecloses it. Do not tidy this into a
|
||||||
|
* parallel, do not shorten it to fit a layout, and do not retype it into a page.
|
||||||
|
*/
|
||||||
|
export const ASYMMETRY_LINE =
|
||||||
|
'The two halves are not the same kind of thing, and the asymmetry is the ' +
|
||||||
|
'honest part. A law degree on one side. A working engineering practice on ' +
|
||||||
|
'the other. One is training I hold. The other is work I still do.';
|
||||||
|
|
||||||
|
/** The three credential slots. Never matter counts — AGENTS.md §4. */
|
||||||
|
export const CREDENTIAL_ROW = [
|
||||||
|
{ value: 'Q.Med', label: 'ADRIC / ADRIO designation' },
|
||||||
|
/**
|
||||||
|
* Q37 CLOSED 2026-08-27. This label read 'Law and engineering' and it is now
|
||||||
|
* 'Legal training and engineering practice'. Pouya's reasoning, kept because
|
||||||
|
* it is the finding rather than the fix:
|
||||||
|
*
|
||||||
|
* "The parallel was doing the implying — a degree and a practice under one
|
||||||
|
* noun. The asymmetry is the honest part."
|
||||||
|
*
|
||||||
|
* A JD is a degree. Engineering is a practice, and a verified one (§4).
|
||||||
|
* Setting the two in parallel invited the reader to supply the symmetry, and
|
||||||
|
* for 'Law' the missing half is a licence — which D13 bars by implication as
|
||||||
|
* hard as by assertion.
|
||||||
|
*
|
||||||
|
* IT IS DELIBERATELY LOPSIDED AND LONGER. Do not tidy it back into a
|
||||||
|
* parallel, and do not shorten it to fit a layout; change the layout.
|
||||||
|
*/
|
||||||
|
{ value: 'JD + ML', label: 'Legal training and engineering practice' },
|
||||||
|
{ value: 'EN · FA', label: 'Bilingual practice' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The FOURTH credential slot — separate on purpose, so a three-slot layout
|
||||||
|
* cannot be handed four by accident and a page has to opt in.
|
||||||
|
*
|
||||||
|
* docs/03: 'Fourth slot where the layout has one: Q.Arb — commenced August
|
||||||
|
* 2026. Use that wording, not "in progress"' — the weaker form drifts toward
|
||||||
|
* 'nearly complete', which §4 Forbidden bars outright.
|
||||||
|
*
|
||||||
|
* REQUIRED on any page that offers arbitration, not decorative. §4 Offerings
|
||||||
|
* carries a paired-disclosure condition: the site may make the offering only
|
||||||
|
* while 'stating the second plainly', and 'neither half may be dropped'. The
|
||||||
|
* footer's designation strip satisfies it site-wide; a page whose opening
|
||||||
|
* sentence says 'arbitrator' should not make the reader scroll to the footer
|
||||||
|
* for the stage.
|
||||||
|
*
|
||||||
|
* The em-dash in docs/03's string is carried by the layout (value over label),
|
||||||
|
* not by the text. Same wording, same pairing.
|
||||||
|
*/
|
||||||
|
export const CREDENTIAL_ROW_ARB = {
|
||||||
|
value: 'Q.Arb',
|
||||||
|
label: 'Commenced August 2026',
|
||||||
|
} as const; // [verified 2026-08-26 — Pouya]
|
||||||
|
|
||||||
/** Analytics: privacy-first and cookieless (D15). No GA4, no consent banner. */
|
/** Analytics: privacy-first and cookieless (D15). No GA4, no consent banner. */
|
||||||
export const ANALYTICS = {
|
export const ANALYTICS = {
|
||||||
provider: 'plausible' as 'plausible' | 'fathom' | null,
|
/**
|
||||||
|
* Q31 CLOSED — **Plausible**, decided rather than defaulted. Pouya checked
|
||||||
|
* 2026-08-26: Fathom is Canadian-owned but stores non-EU traffic on US
|
||||||
|
* servers, isolating in the EU only for EU visitors; Plausible keeps all data
|
||||||
|
* in the EU. For a practice whose privacy posture is part of the offer,
|
||||||
|
* EU-only beats US-hosted. D15 amended to match.
|
||||||
|
*
|
||||||
|
* The union type stays — `/legal/privacy/` has to name the processor, and a
|
||||||
|
* change of processor is a copy change on that page, not just a config edit.
|
||||||
|
*/
|
||||||
|
provider: 'plausible' as 'plausible' | 'fathom',
|
||||||
domain: 'adr.smlcompany.ca',
|
domain: 'adr.smlcompany.ca',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export const CONTACT = {
|
export const CONTACT = {
|
||||||
email: 'info@smlcompany.ca', // [verified 2026-08-26]
|
email: 'info@smlcompany.ca', // [verified 2026-08-26]
|
||||||
/** No public phone by choice. Render 'By scheduled call' wherever a number
|
/** No public phone by choice. Render 'By scheduled call' wherever a number
|
||||||
* would go — do not leave the field visually empty. */
|
* would go — do not leave the field visually empty. */
|
||||||
phone: null as string | null, // [verified 2026-08-26]
|
phone: null as string | null, // [verified 2026-08-26]
|
||||||
phoneFallback: 'By scheduled call',
|
phoneFallback: 'By scheduled call',
|
||||||
location: 'Toronto · Ontario · By appointment',
|
location: 'Toronto · Ontario · By appointment',
|
||||||
responseTime: 'Inquiries are answered within one business day.',
|
/** [verified 2026-08-26 — Pouya, AGENTS.md Q27]. A PUBLIC COMMITMENT: this
|
||||||
|
* wording must match /contact/, the inquirer confirmation email, and any
|
||||||
|
* bio. Change it here and sweep — never edit one copy. */
|
||||||
|
responseTime: 'Inquiries are answered within two business days.',
|
||||||
|
/** The same fact in sentence-fragment form, for the confirmation email and
|
||||||
|
* any inline use. Derived, so the two cannot drift. */
|
||||||
|
responseTimeShort: 'within two business days',
|
||||||
linkedin: 'https://www.linkedin.com/in/pouyalajevardi/', // [verified 2026-08-26]
|
linkedin: 'https://www.linkedin.com/in/pouyalajevardi/', // [verified 2026-08-26]
|
||||||
/** Booking parked 2026-08-26 (AGENTS.md R6). Build /contact/ with the intake
|
/** 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. */
|
* form and a reserved slot so an embed drops in later without a rebuild. */
|
||||||
@@ -76,15 +244,16 @@ export const CONTACT = {
|
|||||||
|
|
||||||
/** Portrait assets. Astro derives AVIF/WebP variants from the master at build. */
|
/** Portrait assets. Astro derives AVIF/WebP variants from the master at build. */
|
||||||
export const PORTRAIT = {
|
export const PORTRAIT = {
|
||||||
master: 'src/assets/pouya-lajevardi.jpg', // 1600x1600
|
master: 'src/assets/pouya-lajevardi.jpg', // 1600x1600
|
||||||
og: 'src/assets/og-portrait.jpg', // 1200x630, cropped high
|
og: 'src/assets/og-portrait.jpg', // 1200x630, cropped high
|
||||||
alt: 'Pouya Lajevardi',
|
alt: 'Pouya Lajevardi',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
/** Shown on /contact/ and with the booking embed. Do not reword casually. */
|
/** Shown on /contact/ and with the booking embed. Do not reword casually. */
|
||||||
export const NO_RETAINER_NOTICE =
|
export const NO_RETAINER_NOTICE =
|
||||||
'Submitting this form does not create a retainer, does not appoint a neutral, ' +
|
'Submitting this form does not create a retainer, does not appoint a neutral, ' +
|
||||||
'and does not itself establish a mediator–party relationship.';
|
'does not itself establish a mediator–party relationship, and does not itself ' +
|
||||||
|
'create a conflict check.';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Rate card — AGENTS.md D14, confirmed by Pouya 2026-08-26.
|
* Rate card — AGENTS.md D14, confirmed by Pouya 2026-08-26.
|
||||||
@@ -96,25 +265,33 @@ export const FEES = {
|
|||||||
taxNote: 'All fees are plus HST.',
|
taxNote: 'All fees are plus HST.',
|
||||||
mediation: {
|
mediation: {
|
||||||
/** Prep is bundled AND stated on the page — [verified 2026-08-26].
|
/** 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
|
* Do not hide it: at these rates, saying preparation is included is the
|
||||||
* point, not a detail. */
|
* point, not a detail. */
|
||||||
halfDay: { amount: 2000, hours: 3.5, prepIncluded: 2 },
|
halfDay: { amount: 2000, hours: 3.5, prepIncluded: 2 },
|
||||||
fullDay: { amount: 4000, hours: 7, prepIncluded: 3 },
|
fullDay: { amount: 4000, hours: 7, prepIncluded: 3 },
|
||||||
additionalParty: 500, // each party beyond two
|
additionalParty: 500, // each party beyond two
|
||||||
overtimePerHour: 500, // [verified 2026-08-26]
|
overtimePerHour: 500, // [verified 2026-08-26]
|
||||||
},
|
},
|
||||||
arbitration: {
|
arbitration: {
|
||||||
perHour: 500,
|
perHour: 500,
|
||||||
hearingDay: 4000,
|
hearingDay: 4000,
|
||||||
documentsOnlySimple: 6500, // flat
|
documentsOnlySimple: 6500, // flat
|
||||||
documentsOnlyComplex: 9500, // flat
|
documentsOnlyComplex: 9500, // flat
|
||||||
// No tribunal-secretary rate — removed by Pouya 2026-08-26.
|
// 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]
|
* THREE services at this rate, not four. Q42 CLOSED 2026-08-27 by Pouya:
|
||||||
|
* early neutral evaluation, dispute-system design, and pre-dispute technical
|
||||||
|
* advisory each gained a §4 Offerings row; **settlement counsel was REMOVED**
|
||||||
|
* — his words: *"Settlement counsel acts FOR a party in negotiation. That is
|
||||||
|
* a partisan role, and putting it on a site that (a) sells neutrality and
|
||||||
|
* (b) asserts no licensure under D13 is wrong twice over."* Do not restore
|
||||||
|
* it, and do not price it.
|
||||||
|
*/
|
||||||
|
hourly: 500, // [verified 2026-08-26]
|
||||||
cancellation: [
|
cancellation: [
|
||||||
{ window: 'More than 30 days before', fee: 'No fee. Disbursements only.' },
|
{ window: 'More than 30 days before', fee: 'No fee. Disbursements only.' },
|
||||||
{ window: '15 to 30 days before', fee: '50% of the booked fee.' },
|
{ window: '15 to 30 days before', fee: '50% of the booked fee.' },
|
||||||
{ window: 'Fewer than 15 days before', fee: '100% of the booked fee.' },
|
{ window: 'Fewer than 15 days before', fee: '100% of the booked fee.' },
|
||||||
],
|
],
|
||||||
cancellationNotes: [
|
cancellationNotes: [
|
||||||
@@ -128,34 +305,260 @@ export const FEES = {
|
|||||||
],
|
],
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export const PRACTICE_AREAS = [
|
/**
|
||||||
{ slug: 'construction', name: 'Construction & Infrastructure', chip: 'Construction' },
|
* Slugs as their own literal tuple so consumers keep the union type.
|
||||||
{ slug: 'technology', name: 'Technology, AI & Data', chip: 'Technology' },
|
* Deriving them with `.map()` and casting to `[string, ...string[]]` widens
|
||||||
{ slug: 'energy', name: 'Energy, Grid & Regulatory', chip: 'Energy' },
|
* them back to `string`, and a mistyped slug then survives `astro check`.
|
||||||
{ slug: 'insurance', name: 'Insurance, SABS & LAT', chip: 'Insurance' },
|
*/
|
||||||
{ slug: 'shareholder', name: 'Shareholder & Family Business', chip: 'Shareholder' },
|
export const PRACTICE_SLUGS = [
|
||||||
{ slug: 'cross-cultural', name: 'Cross-Border & Diaspora', chip: 'Cross-cultural' },
|
'construction',
|
||||||
|
'technology',
|
||||||
|
'energy',
|
||||||
|
'insurance',
|
||||||
|
'shareholder',
|
||||||
|
'cross-cultural',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
export type PracticeSlug = (typeof PRACTICE_SLUGS)[number];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The six areas, with the one-line blurb each card renders.
|
||||||
|
*
|
||||||
|
* THE BLURBS LIVE HERE, not in the pages, because `/` and `/practice/` both
|
||||||
|
* render them and two copies of a claim-bearing sentence is one copy that will
|
||||||
|
* eventually be wrong. Same reasoning as ROLE and CREDENTIAL_ROW above.
|
||||||
|
*
|
||||||
|
* EVERY BLURB IS DISPUTE TYPES, NOT HISTORY. docs/03: "Frame as positioning,
|
||||||
|
* not as history" — 'Built to facilitate ... on Ontario's megaproject pipeline',
|
||||||
|
* never 'extensive experience resolving'. AGENTS.md §4 (Q35, 2026-08-27) makes
|
||||||
|
* that condition 2 of the publication gate for naming a practice area at all.
|
||||||
|
* A blurb that claims volume fails the gate even though the label passes.
|
||||||
|
*
|
||||||
|
* Nothing here may carry a count, a value, a settlement rate, or a superlative
|
||||||
|
* (§4 Forbidden). Dispute types are not claims of caseload.
|
||||||
|
*/
|
||||||
|
export const PRACTICE_AREAS = [
|
||||||
|
{
|
||||||
|
slug: 'construction',
|
||||||
|
name: 'Construction & Infrastructure',
|
||||||
|
chip: 'Construction',
|
||||||
|
blurb:
|
||||||
|
'Liens, delay and change-order claims, scheduling, subcontract and ' +
|
||||||
|
"deficiency disputes. Built for Ontario's megaproject pipeline.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: 'technology',
|
||||||
|
name: 'Technology, AI & Data',
|
||||||
|
chip: 'Technology',
|
||||||
|
blurb:
|
||||||
|
'Software contracts, SLA and MSA breakdowns, data residency and ' +
|
||||||
|
'processing, AI vendor diligence, IP and licensing.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: 'energy',
|
||||||
|
name: 'Energy, Grid & Regulatory',
|
||||||
|
chip: 'Energy',
|
||||||
|
blurb:
|
||||||
|
'Grid connection and allocation, leave-to-construct, ' +
|
||||||
|
'proponent–municipality disputes, IESO market participation.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: 'insurance',
|
||||||
|
name: 'Insurance, SABS & LAT',
|
||||||
|
chip: 'Insurance',
|
||||||
|
/**
|
||||||
|
* Q41(c) CLOSED 2026-08-27 — and the verification changed the wording again.
|
||||||
|
*
|
||||||
|
* `LAT pre-hearing mediation` (a SEARCH INTENT in `docs/01`, never an
|
||||||
|
* offering) must never be published. Pouya's ruling: *"imprecise and must
|
||||||
|
* not imply appointment by the tribunal. Verify against LAT's own materials
|
||||||
|
* how its case-conference process is conducted and who conducts it."*
|
||||||
|
*
|
||||||
|
* Verified 2026-08-28 against the LAT Rules and the LAT-AABS process page,
|
||||||
|
* both extracted into `docs/reference/lat-case-conference.md`:
|
||||||
|
*
|
||||||
|
* - Rule 2.4: *"'Case Conference' has the same meaning as 'Pre-Hearing
|
||||||
|
* Conference' as defined in the SPPA."* **"Pre-hearing" is the
|
||||||
|
* Tribunal's own label**, and what it names is a case conference.
|
||||||
|
* - Rule 14.3: a **Member** presides, and is then disqualified from the
|
||||||
|
* hearing panel. Rule 14.6: parties must attend. The neutral is the
|
||||||
|
* Tribunal's, and a privately retained one cannot be appointed to it.
|
||||||
|
* - The Rules contain **zero** occurrences of `mediat` or `arbitrat`
|
||||||
|
* (0 in 66,593 characters). The concept is not in them.
|
||||||
|
*
|
||||||
|
* The interim read "private mediation of matters before the LAT", which is
|
||||||
|
* ambiguous in the one word that matters: `before` reads as *pending at* as
|
||||||
|
* easily as *prior to*. Replaced with the temporal frame the Tribunal's own
|
||||||
|
* page endorses — *"you may want to consider negotiation or mediation
|
||||||
|
* services... before filing at the LAT-AABS, and continuing... after a
|
||||||
|
* claim has been filed."*
|
||||||
|
*
|
||||||
|
* `/practice/insurance/` at step 5 must say the mediation is PRIVATE and is
|
||||||
|
* not the Tribunal's case conference.
|
||||||
|
*/
|
||||||
|
blurb:
|
||||||
|
'Accident benefits and SABS entitlement, MIG disputes, and private ' +
|
||||||
|
'mediation alongside a LAT application, before filing or after.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: 'shareholder',
|
||||||
|
name: 'Shareholder & Family Business',
|
||||||
|
chip: 'Shareholder',
|
||||||
|
/**
|
||||||
|
* "Family Business" means COMMERCIAL disputes among family shareholders.
|
||||||
|
* The blurb says "family-held companies" and names commercial dispute types
|
||||||
|
* for that reason — AGENTS.md Q39, 2026-08-27.
|
||||||
|
*
|
||||||
|
* The explicit exclusion — family law matters are not accepted — belongs on
|
||||||
|
* the PAGE, one sentence, at build step 5 (docs/01 §/practice/shareholder/).
|
||||||
|
* Pouya scoped it there. Do not add it to this blurb: on a six-card grid it
|
||||||
|
* unbalances the row and reads defensively, and the wording here already
|
||||||
|
* makes the area unambiguously commercial.
|
||||||
|
*/
|
||||||
|
blurb:
|
||||||
|
'Shareholder and partnership disputes, co-founder breakdowns, and ' +
|
||||||
|
'business succession in family-held companies.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: 'cross-cultural',
|
||||||
|
name: 'Cross-Border & Diaspora',
|
||||||
|
chip: 'Cross-cultural',
|
||||||
|
blurb:
|
||||||
|
'Diaspora business succession, dual-jurisdiction shareholder disputes, ' +
|
||||||
|
'and cross-cultural commercial matters. Conducted in English or Farsi.',
|
||||||
|
},
|
||||||
|
] as const satisfies ReadonlyArray<{
|
||||||
|
slug: PracticeSlug;
|
||||||
|
name: string;
|
||||||
|
chip: string;
|
||||||
|
blurb: string;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compile-time completeness check, both directions.
|
||||||
|
*
|
||||||
|
* `satisfies` above catches a slug in PRACTICE_AREAS that is not in
|
||||||
|
* PRACTICE_SLUGS. This catches the reverse — a slug with no area — which would
|
||||||
|
* otherwise let an article declare a practice area that has no page, no nav
|
||||||
|
* child and no chip, producing a dead link at step 7. Deriving the areas from
|
||||||
|
* the slugs used to make that structurally impossible; keeping two literals is
|
||||||
|
* what buys the literal types back, so the check has to be explicit.
|
||||||
|
*
|
||||||
|
* Type-only. Nothing runs, nothing ships.
|
||||||
|
*/
|
||||||
|
type _AssertNever<T extends never> = T;
|
||||||
|
type _SlugsWithoutAnArea = Exclude<
|
||||||
|
PracticeSlug,
|
||||||
|
(typeof PRACTICE_AREAS)[number]['slug']
|
||||||
|
>;
|
||||||
|
export type _SlugCoverage = _AssertNever<_SlugsWithoutAnArea>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The five process steps. **HERE, NOT IN THE PAGE**, for the reason written
|
||||||
|
* against PRACTICE_AREAS above and applied by `adversarial-reviewer` 2026-08-27:
|
||||||
|
* `/` renders a compressed strip of these and `/process/` renders them in full
|
||||||
|
* at build step 6, so a copy typed into one page is a copy that will eventually
|
||||||
|
* disagree with the other. The proof is already in the history — the step-3 body
|
||||||
|
* below carried a fee claim that the same session's claims audit found to be
|
||||||
|
* wrong against `docs/07`, and it existed in exactly one place. After step 6 it
|
||||||
|
* would have existed in two.
|
||||||
|
*
|
||||||
|
* TIMINGS ARE `docs/01` §`/process/`'s, verbatim: "confidential intake (day 0) ·
|
||||||
|
* engagement and framing (1–7) · pre-session exchange (7–21) · the session
|
||||||
|
* (21–30) · binding conclusion (30+)".
|
||||||
|
*
|
||||||
|
* **Q43 CLOSED 2026-08-27, and the ruling went the other way from this
|
||||||
|
* comment's previous reasoning.** It read: *"`docs/03` §Process requires them
|
||||||
|
* REAL rather than illustrative, so they are not softened to 'typically'."*
|
||||||
|
* Pouya ruled that the five timings are **service commitments, the same class
|
||||||
|
* as Q27's response time** — not facts about him, so they need framing rather
|
||||||
|
* than a Verified row:
|
||||||
|
*
|
||||||
|
* "Present them as the TYPICAL shape of an engagement, explicitly not a
|
||||||
|
* guarantee: mediation timing depends on party and counsel availability,
|
||||||
|
* which he does not control. Published as typical, they are honest and
|
||||||
|
* useful; published as commitments, the first matter that slips makes the
|
||||||
|
* page false."
|
||||||
|
*
|
||||||
|
* The NUMBERS ARE UNCHANGED — softening was never the fix, and inventing them
|
||||||
|
* was never on. What changed is that they now ship with `PROCESS_FRAMING`
|
||||||
|
* below, which is **not optional**: any page rendering these steps renders it
|
||||||
|
* too. `docs/03` §Process has been amended to record the override.
|
||||||
|
*
|
||||||
|
* Step 5 is labelled from the spec but its body says what actually concludes —
|
||||||
|
* minutes of settlement in a mediation, an award where the process is arbitral.
|
||||||
|
* "Binding conclusion" alone would read as though a mediation binds, which it
|
||||||
|
* does not until the parties sign.
|
||||||
|
*
|
||||||
|
* NO FEE CLAIM IN ANY BODY. `docs/07` bundles a CAPPED preparation allowance
|
||||||
|
* (2 h in the half day, 3 h in the full day) and says in terms that it "must be
|
||||||
|
* stated on the page... Do not quietly fold it into the hours figure." A
|
||||||
|
* five-word strip cannot state it properly, and stating it improperly
|
||||||
|
* misdescribes money. `/fees/` at step 9.
|
||||||
|
*/
|
||||||
|
export const PROCESS = [
|
||||||
|
{
|
||||||
|
title: 'Confidential intake',
|
||||||
|
timing: 'Day 0',
|
||||||
|
body: 'A scheduled call to scope the matter, identify the parties, and run conflicts.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Engagement and framing',
|
||||||
|
timing: 'Days 1–7',
|
||||||
|
body: 'Terms of appointment, the issues in dispute, and who attends.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Pre-session exchange',
|
||||||
|
timing: 'Days 7–21',
|
||||||
|
body: 'Briefs and documents, exchanged in advance so the session starts informed.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'The session',
|
||||||
|
timing: 'Days 21–30',
|
||||||
|
body: 'Half day or full day, in person or by video.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Conclusion',
|
||||||
|
timing: 'Day 30 onward',
|
||||||
|
body: 'Minutes of settlement — or an award, where the process is arbitral.',
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* THE FRAMING THAT MAKES THE TIMINGS PUBLISHABLE — Q43, Pouya 2026-08-27.
|
||||||
|
*
|
||||||
|
* Not decoration and not a disclaimer bolted on: it is the condition on which
|
||||||
|
* the five numbers above may appear at all. *"Published as typical, they are
|
||||||
|
* honest and useful; published as commitments, the first matter that slips
|
||||||
|
* makes the page false."*
|
||||||
|
*
|
||||||
|
* RENDER IT ADJACENT TO THE STEPS, on every page that renders them — `/` now,
|
||||||
|
* `/process/` at step 6. A reader who sees `Days 21–30` and not this sentence
|
||||||
|
* has read a commitment. First person, per `docs/03` §Voice.
|
||||||
|
*/
|
||||||
|
export const PROCESS_FRAMING =
|
||||||
|
'This is the typical shape of an engagement, not a commitment. Timing ' +
|
||||||
|
'depends on party and counsel availability, which I do not control.';
|
||||||
|
|
||||||
/** Seven items is the ceiling before a nav stops being scannable. */
|
/** Seven items is the ceiling before a nav stops being scannable. */
|
||||||
export const PRIMARY_NAV = [
|
export const PRIMARY_NAV = [
|
||||||
{ href: '/about/', label: 'About' },
|
{ href: '/about/', label: 'About' },
|
||||||
{ href: '/mediation/', label: 'Mediation' },
|
{ href: '/mediation/', label: 'Mediation' },
|
||||||
{ href: '/arbitration/', label: 'Arbitration' },
|
{ href: '/arbitration/', label: 'Arbitration' },
|
||||||
{ href: '/practice/', label: 'Practice', children: PRACTICE_AREAS },
|
{ href: '/practice/', label: 'Practice', children: PRACTICE_AREAS },
|
||||||
{ href: '/fees/', label: 'Fees' },
|
{ href: '/fees/', label: 'Fees' },
|
||||||
{ href: '/insights/', label: 'Insights' },
|
{ href: '/insights/', label: 'Insights' },
|
||||||
{ href: '/contact/', label: 'Contact' },
|
{ href: '/contact/', label: 'Contact' },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
/** Linked contextually rather than from the primary nav. */
|
/** Linked contextually rather than from the primary nav. */
|
||||||
export const SECONDARY_NAV = [
|
export const SECONDARY_NAV = [
|
||||||
{ href: '/process/', label: 'How I work' },
|
{ href: '/process/', label: 'How I work' },
|
||||||
{ href: '/med-arb/', label: 'Med-Arb' },
|
{ href: '/med-arb/', label: 'Med-Arb' },
|
||||||
{ href: '/for-parties/', label: 'For parties' },
|
{ href: '/for-parties/', label: 'For parties' },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export const LEGAL_NAV = [
|
export const LEGAL_NAV = [
|
||||||
{ href: '/legal/privacy/', label: 'Privacy' },
|
{ href: '/legal/privacy/', label: 'Privacy' },
|
||||||
{ href: '/legal/terms/', label: 'Terms' },
|
{ href: '/legal/terms/', label: 'Terms' },
|
||||||
] as const;
|
] as const;
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
---
|
||||||
|
/**
|
||||||
|
* The page shell. Every route renders through this.
|
||||||
|
*
|
||||||
|
* Metadata is not optional and not a prop this layout can default: it forwards
|
||||||
|
* whatever it is given straight to SEO.astro, which throws if the title or
|
||||||
|
* description is out of the range docs/04-seo-spec.md sets.
|
||||||
|
*/
|
||||||
|
import '../styles/global.css';
|
||||||
|
import type { Props as SeoProps } from '../components/SEO.astro';
|
||||||
|
import SEO from '../components/SEO.astro';
|
||||||
|
import SiteHeader from '../components/SiteHeader.astro';
|
||||||
|
import SiteFooter from '../components/SiteFooter.astro';
|
||||||
|
import { SITE } from '../data/site';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `preloadSerifItalic` — OPT-IN, AND IT IS A PER-PAGE DECISION SITTING IN A
|
||||||
|
* SHARED LAYOUT, WHICH IS WHY IT NEEDED A PROP.
|
||||||
|
*
|
||||||
|
* The italic face is preloaded because `/`'s `<h1>` contains
|
||||||
|
* `<em class="it">the room</em>` and a serif-to-fallback swap inside a 96px
|
||||||
|
* headline moves the whole last line. That reasoning is `/`-specific and the
|
||||||
|
* preload was unconditional.
|
||||||
|
*
|
||||||
|
* Measured on `/about/` (resource timing, cache disabled, 390x844 DPR 2):
|
||||||
|
* `instrument-serif-latin-400-italic.woff2` fetched as a `link` at **22,428 B**,
|
||||||
|
* while an enumeration of every element's computed `fontFamily|fontStyle` on
|
||||||
|
* that page returns `Instrument Serif|normal`, `Geist|normal`,
|
||||||
|
* `Geist Mono|normal`, `Geist|italic` — **no `Instrument Serif|italic`.** Total
|
||||||
|
* page transfer is ~162 KB, so it was ~14% of the page, preloaded ahead of the
|
||||||
|
* faces that actually render.
|
||||||
|
*
|
||||||
|
* OPT-IN RATHER THAN OPT-OUT: forgetting to opt in costs one line of reflow on a
|
||||||
|
* page that has an italic headline; forgetting to opt out costs 22 KB on the
|
||||||
|
* critical path of a page that does not. Fifteen pages remain, so the default
|
||||||
|
* is the one whose failure is cosmetic. docs/02 allows one italic phrase per
|
||||||
|
* headline, so set it wherever a headline uses `.it`.
|
||||||
|
*/
|
||||||
|
export type Props = SeoProps & { preloadSerifItalic?: boolean };
|
||||||
|
|
||||||
|
// SITE.locale is `en_CA` — Open Graph's underscore form. The lang attribute
|
||||||
|
// takes the BCP 47 hyphen form. One source, two spellings, no second constant.
|
||||||
|
const lang = SITE.locale.replace('_', '-');
|
||||||
|
|
||||||
|
// Destructured OFF the props before the spread below, so it does not reach
|
||||||
|
// <SEO>, which would reject it.
|
||||||
|
const { preloadSerifItalic = false, ...seo } = Astro.props;
|
||||||
|
---
|
||||||
|
|
||||||
|
<!doctype html>
|
||||||
|
<html lang={lang}>
|
||||||
|
<head>
|
||||||
|
<SEO {...seo} />
|
||||||
|
|
||||||
|
{
|
||||||
|
/* No SVG favicon. The mark is a shaded ribbon, not flat vector paths, so
|
||||||
|
there is no honest SVG of it to serve — see InfinityMark.astro and
|
||||||
|
AGENTS.md Q38. The .ico carries 16/32/48, and is what crawlers request
|
||||||
|
at the root regardless of what is declared here. */
|
||||||
|
}
|
||||||
|
<link rel="icon" href="/favicon.ico" sizes="16x16 32x32 48x48" />
|
||||||
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||||
|
|
||||||
|
{
|
||||||
|
/* THREE of the FOUR faces used above the fold on `/`, and the comment used
|
||||||
|
to say "the two faces used above the fold", which was wrong on the count.
|
||||||
|
Measured by network probe on a cold cache: `/` requests four faces above
|
||||||
|
the fold —
|
||||||
|
|
||||||
|
instrument-serif latin 400 normal 21,032 B preloaded — display type
|
||||||
|
geist latin variable 29,400 B preloaded — body, the LCP element
|
||||||
|
instrument-serif latin 400 italic 22,128 B preloaded — see below
|
||||||
|
geist-mono latin variable 23,128 B NOT preloaded — see below
|
||||||
|
|
||||||
|
THE ITALIC IS PRELOADED because it sits inside the `<h1>`: `<em class="it">
|
||||||
|
the room</em>`. With `font-display: swap` a CSS-discovered face renders in
|
||||||
|
a fallback first, and a serif-to-fallback swap inside a 96px headline
|
||||||
|
moves the whole last line.
|
||||||
|
|
||||||
|
GEIST MONO IS DELIBERATELY NOT. It sets the eyebrow — 12px, uppercase,
|
||||||
|
0.18em tracking — and the credential labels. A swap there costs one short
|
||||||
|
line of reflow at a size where the fallback is metrically close, and
|
||||||
|
preloading it would put 95,688 B of font on the critical path instead of
|
||||||
|
72,560 B. Revisit against real Lighthouse numbers at step 7; it is a
|
||||||
|
trade, not a certainty.
|
||||||
|
|
||||||
|
Fonts are always fetched in CORS mode, so a preload without `crossorigin`
|
||||||
|
is a second, wasted request rather than a warmed cache. */
|
||||||
|
}
|
||||||
|
<link
|
||||||
|
rel="preload"
|
||||||
|
href="/fonts/instrument-serif-latin-400-normal.woff2?v=1"
|
||||||
|
as="font"
|
||||||
|
type="font/woff2"
|
||||||
|
crossorigin
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="preload"
|
||||||
|
href="/fonts/geist-latin-wght-normal.woff2?v=1"
|
||||||
|
as="font"
|
||||||
|
type="font/woff2"
|
||||||
|
crossorigin
|
||||||
|
/>
|
||||||
|
{
|
||||||
|
preloadSerifItalic && (
|
||||||
|
<link
|
||||||
|
rel="preload"
|
||||||
|
href="/fonts/instrument-serif-latin-400-italic.woff2?v=1"
|
||||||
|
as="font"
|
||||||
|
type="font/woff2"
|
||||||
|
crossorigin
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
/* No script tag. Not "no framework", not "minimal JS" — none.
|
||||||
|
|
||||||
|
The reveal used to be an inline IntersectionObserver here. It ran before
|
||||||
|
first paint so nothing flashed, and it took its own class back off if
|
||||||
|
anything threw. It was still wrong: docs/05-backend-spec.md specifies
|
||||||
|
`script-src 'self'` with no `unsafe-inline`, so the single script on the
|
||||||
|
site was the one thing the site's own CSP would refuse to execute — and
|
||||||
|
a per-build hash drifts from the policy that is supposed to pin it.
|
||||||
|
|
||||||
|
`animation-timeline: view()` in global.css does the same job in CSS. See
|
||||||
|
the Reveal block there for why the @supports gate is load-bearing. */
|
||||||
|
}
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<a class="skip-link" href="#main">Skip to content</a>
|
||||||
|
<SiteHeader />
|
||||||
|
<main id="main" tabindex="-1">
|
||||||
|
<slot />
|
||||||
|
</main>
|
||||||
|
<SiteFooter />
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,971 @@
|
|||||||
|
---
|
||||||
|
/**
|
||||||
|
* `/about/` — Biography and credentials. Build step 3 (docs/01 §Build order):
|
||||||
|
* "the credential spine everything else references."
|
||||||
|
*
|
||||||
|
* JOB (docs/01 §`/about/`): "be the page an appointing body or opposing counsel
|
||||||
|
* reads before agreeing to an appointment. This page carries the verifiable
|
||||||
|
* record."
|
||||||
|
*
|
||||||
|
* SECTIONS, against docs/01's seven-item outline:
|
||||||
|
* 1 Portrait, name, designation line → the hero
|
||||||
|
* 2 Narrative biography, 400–600 words → §Background
|
||||||
|
* 3 Credentials, structured → §Credentials
|
||||||
|
* 4 The credentialing arc → §The arc
|
||||||
|
* 5 Languages and cross-cultural → §Language
|
||||||
|
* 6 Speaking and publications → OMITTED, per the spec itself
|
||||||
|
* 7 Person JSON-LD + PDF bio → JSON-LD ships; the PDF does not
|
||||||
|
*
|
||||||
|
* ITEM 6 IS OMITTED ON THE SPEC'S OWN INSTRUCTION, not by oversight: "Omit the
|
||||||
|
* section entirely until there is something in it. An empty 'Speaking' heading
|
||||||
|
* is worse than no heading." Nothing to list.
|
||||||
|
*
|
||||||
|
* ITEM 7'S PDF IS NOT SHIPPED, and the omission is stated rather than silent —
|
||||||
|
* AGENTS.md Q45. No such file exists, and a link to a missing file on the page
|
||||||
|
* an appointing body reads is worse than its absence. It is also not a
|
||||||
|
* formatting job: a one-page bio is a credential document circulated DETACHED
|
||||||
|
* from the site, where no build and no reviewer ever re-checks it. Two
|
||||||
|
* decisions there are Pouya's.
|
||||||
|
*
|
||||||
|
* THE PARENT/CHILD SCOPE TRAP, because this page uses <SectionHeading> four
|
||||||
|
* times. A parent CANNOT style a child component's root element — the rule
|
||||||
|
* compiles against the parent's cid and silently never matches. Every heading
|
||||||
|
* below is wrapped in a page-owned <div class="section-head">. Not defensive
|
||||||
|
* boilerplate: it is the fourth-instance defect CLAUDE.md records, and the
|
||||||
|
* components have had their `class` props deleted so passing one is a build
|
||||||
|
* error rather than a silent no-op.
|
||||||
|
*
|
||||||
|
* R10 / Q44 — THE MEMBERSHIPS GROUP IS NOT ON THIS PAGE. R10 is written as a
|
||||||
|
* prohibition — re-confirm *before* any page listing memberships ships — and
|
||||||
|
* `/about/` is the page it names. The re-confirmation is a fact only Pouya holds
|
||||||
|
* and was not obtained, so the group is withheld and a `TODO(pouya)` sits on
|
||||||
|
* CREDENTIAL_GROUPS below with the exact question. §4 is NOT re-stamped:
|
||||||
|
* nothing was re-checked. Q44.
|
||||||
|
*
|
||||||
|
* A first version of this page published all four and disclosed the gap instead.
|
||||||
|
* Both review agents rejected that; the reasoning is on CREDENTIAL_GROUPS.
|
||||||
|
*/
|
||||||
|
import { Picture, getImage } from 'astro:assets';
|
||||||
|
import BaseLayout from '../layouts/BaseLayout.astro';
|
||||||
|
import ContactBand from '../components/ContactBand.astro';
|
||||||
|
import Eyebrow from '../components/Eyebrow.astro';
|
||||||
|
import Pill from '../components/Pill.astro';
|
||||||
|
import SectionHeading from '../components/SectionHeading.astro';
|
||||||
|
import portrait from '../assets/pouya-lajevardi.jpg';
|
||||||
|
import ogDefault from '../assets/og-portrait.jpg';
|
||||||
|
import { aboutGraph } from '../data/schema';
|
||||||
|
import {
|
||||||
|
ASYMMETRY_LINE,
|
||||||
|
CREDENTIALS,
|
||||||
|
PORTRAIT,
|
||||||
|
ROLE,
|
||||||
|
SITE,
|
||||||
|
} from '../data/site';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `image` ON THE Person NODE — docs/04 lists it, and this page is where
|
||||||
|
* PERSON_ID (`/about/#person`) actually resolves.
|
||||||
|
*
|
||||||
|
* AN EARLIER VERSION OF THIS PAGE OMITTED IT and argued at length that it could
|
||||||
|
* not be supplied: "SEO.astro computes that URL internally and does not expose
|
||||||
|
* it." That reasoning talked itself into the wrong answer — the URL does not
|
||||||
|
* have to come from SEO.astro. `/` derives it in six lines with `getImage()`,
|
||||||
|
* and the same six lines work here. The result was two documents asserting the
|
||||||
|
* same `@id` with different property sets, which is worse than either choice
|
||||||
|
* made deliberately.
|
||||||
|
*
|
||||||
|
* EXACTLY the transform SEO.astro applies to the same source (jpeg, 1200x630),
|
||||||
|
* so Astro's asset cache returns the same hashed file rather than emitting a
|
||||||
|
* second copy for the crawler. JPEG on purpose: link-preview and structured-data
|
||||||
|
* consumers are not browsers and several still do not decode WebP, let alone
|
||||||
|
* AVIF.
|
||||||
|
*
|
||||||
|
* The withdrawn reasoning follows, because it is a good example of a comment
|
||||||
|
* arguing for a defect.
|
||||||
|
*
|
||||||
|
* NO `getImage()` CALL FOR THE JSON-LD IMAGE, unlike `/`.
|
||||||
|
*
|
||||||
|
* `/` generates the 1200x630 jpeg so the Person node can carry an absolute
|
||||||
|
* `image` URL, and it works because that page renders the ProfessionalService
|
||||||
|
* node too. Here the Person node is the whole graph, and SEO.astro already emits
|
||||||
|
* exactly the same transform of exactly the same source as `og:image`. Calling
|
||||||
|
* `getImage()` again would return the same cached asset — so this is not about
|
||||||
|
* duplicate files, it is about a second place that has to be kept in step with
|
||||||
|
* SEO.astro's transform. It is passed the URL by the layout instead.
|
||||||
|
*
|
||||||
|
* Except it cannot be: `SEO.astro` computes that URL internally and does not
|
||||||
|
* expose it. So the node ships WITHOUT `image` on this page and WITH it on `/`,
|
||||||
|
* which is a real inconsistency in a field docs/04 lists for the Person node.
|
||||||
|
* Both resolve to the same @id, so a crawler joining the two documents gets the
|
||||||
|
* image either way — but that is a hope about crawler behaviour, not a fact.
|
||||||
|
* Recorded rather than papered over; the fix is for SEO.astro to expose the URL
|
||||||
|
* it already computes, which is a component change and not a page change.
|
||||||
|
*/
|
||||||
|
const ldImage = await getImage({
|
||||||
|
src: ogDefault,
|
||||||
|
format: 'jpeg',
|
||||||
|
width: 1200,
|
||||||
|
height: 630,
|
||||||
|
});
|
||||||
|
const graph = aboutGraph(new URL(ldImage.src, Astro.site).href);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The designation line — docs/01 item 1. Assembled from constants so it cannot
|
||||||
|
* drift from §4, and ordered held-first.
|
||||||
|
*
|
||||||
|
* Q.Arb IS NOT IN IT, deliberately. It is not held (§4: "Describe as newly
|
||||||
|
* commenced, never as held or nearing completion"), and a designation line is
|
||||||
|
* precisely a list of things held. The arc section states the stage plainly,
|
||||||
|
* which is what §4's paired-disclosure condition requires — this page offers
|
||||||
|
* arbitration, so the stage appears on this page and not only in the footer.
|
||||||
|
*/
|
||||||
|
const designationLine = [
|
||||||
|
'Mediator',
|
||||||
|
CREDENTIALS.designations[0],
|
||||||
|
CREDENTIALS.education[0],
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The credentialing arc — docs/01 item 4, and docs/03: "the credentialing
|
||||||
|
* pathway from Q.Med through Q.Arb to C.Med-Arb is stated openly as in
|
||||||
|
* progress. The brief treats that arc as part of the story rather than
|
||||||
|
* something to obscure."
|
||||||
|
*
|
||||||
|
* `state` is the load-bearing column. "Commenced August 2026" is §4's exact
|
||||||
|
* wording and the ONLY permitted wording — docs/03: not "in progress", because
|
||||||
|
* the weaker form drifts toward "nearly complete", which §4 Forbidden bars
|
||||||
|
* outright.
|
||||||
|
*/
|
||||||
|
/*
|
||||||
|
* FIVE CLAIMS CAME OUT OF THIS BLOCK, and the first was the worst thing in the
|
||||||
|
* step-3 diff. BOTH review agents found it independently, which is the strongest
|
||||||
|
* signal this loop produces.
|
||||||
|
*
|
||||||
|
* 1. ⚠️ "arbitral appointments are not gated behind it, which is why I accept
|
||||||
|
* them now" — **the false universal Q39 struck, on a public page.**
|
||||||
|
* Unscoped ("arbitral", not commercial), asserted as flat fact in the first
|
||||||
|
* person, and it publishes a proposition of Ontario law that §4 holds only
|
||||||
|
* in scoped form and deliberately does NOT stamp `[verified]`. Family
|
||||||
|
* arbitration is an arbitral appointment and it IS gated
|
||||||
|
* (`docs/reference/ontario-family-arbitration-training.md`). Q39 swept
|
||||||
|
* three instances of this universal on 2026-08-27; this was the fourth and
|
||||||
|
* the first outside a comment. §4 requires the STAGE be stated — never the
|
||||||
|
* register's gating rationale. Deleted rather than rescoped: this page has
|
||||||
|
* no business carrying the argument at all.
|
||||||
|
* 2. "on the same institutional pathway" and
|
||||||
|
* 3. "Three designations on one institutional pathway" (the section lede) —
|
||||||
|
* §4 attaches ADRIC / ADRIO to **Q.Med only**. Neither the Q.Arb row nor
|
||||||
|
* the C.Med-Arb row names a body.
|
||||||
|
* 4. "The senior hybrid designation" — a ranking claim about a third party's
|
||||||
|
* credential structure, with no row and no source.
|
||||||
|
* 5. The expansions — "Qualified Mediator", "Qualified Arbitrator",
|
||||||
|
* "Chartered Mediator-Arbitrator". Flagged as being in §11 Glossary but
|
||||||
|
* not in §4 Verified.
|
||||||
|
*
|
||||||
|
* ⚠️ ITEM 5 WAS REMOVED AND IS NOW RESTORED, AND IT IS THE ONE PLACE THIS
|
||||||
|
* SESSION WENT AGAINST A REVIEW FINDING. The reason is a SECOND finding, from
|
||||||
|
* the next audit pass, and it is a consistency point rather than a claim point:
|
||||||
|
* this page also publishes "Provincial Offences Act", "Statutory Accident
|
||||||
|
* Benefits Schedule" (as SABS) and "the ADR Institute of Canada and the ADR
|
||||||
|
* Institute of Ontario" — every one of them a §11 Glossary expansion, on exactly
|
||||||
|
* the ground the designation names were struck. *"One standard or the other."*
|
||||||
|
*
|
||||||
|
* The standard chosen is: **§11 Glossary is the source for DEFINITIONAL
|
||||||
|
* expansions** — what an abbreviation stands for — while §4 Verified remains the
|
||||||
|
* only source for claims ABOUT POUYA. Expanding `Q.Med` says nothing about him;
|
||||||
|
* "he holds it" is the claim, and that has a row. The alternative standard would
|
||||||
|
* have required stripping POA, SABS and the institute names from the prose and
|
||||||
|
* `recognizedBy` from the JSON-LD, which makes the page materially worse for a
|
||||||
|
* reader who does not already know the acronyms, in exchange for no reduction in
|
||||||
|
* risk. **Q46 asks Pouya to ratify that standard** and it is the only thing
|
||||||
|
* holding it up; if he declines, all four classes come out together.
|
||||||
|
*
|
||||||
|
* Sourcing them externally was tried first and failed: `adric.ca/designations/`
|
||||||
|
* redirects to `/designations-cee/` and its HTML contains **zero** occurrences
|
||||||
|
* of "Q.Med", "Qualified Mediator" or "Chartered Mediator" in 114,985 bytes —
|
||||||
|
* navigation only, body assembled client-side. So R14 cannot be met from the
|
||||||
|
* obvious URL, which is why this rests on §11 and on Q46 rather than on a
|
||||||
|
* committed extract.
|
||||||
|
*/
|
||||||
|
const ARC = [
|
||||||
|
{
|
||||||
|
name: 'Q.Med',
|
||||||
|
state: 'Held',
|
||||||
|
/* NOT "the designation I mediate under". That imported a PERMISSION framing
|
||||||
|
onto what §4 records as a voluntary credential, in a register that says
|
||||||
|
no designation is required to be appointed as a mediator — the
|
||||||
|
credential-as-licence slip §4 says produced a wrong answer twice. */
|
||||||
|
body:
|
||||||
|
'Qualified Mediator, held through the ADR Institute of Canada and the ' +
|
||||||
|
'ADR Institute of Ontario. The designation I hold as a mediator today.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Q.Arb',
|
||||||
|
state: 'Commenced August 2026',
|
||||||
|
body:
|
||||||
|
'Qualified Arbitrator. Newly commenced — not held, and not nearing ' +
|
||||||
|
'completion.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'C.Med-Arb',
|
||||||
|
state: 'The endpoint',
|
||||||
|
body:
|
||||||
|
'Chartered Mediator-Arbitrator. The designation this practice is built ' +
|
||||||
|
'toward.',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The structured credentials — docs/01 item 3: "designations, education,
|
||||||
|
* certifications, memberships. Every line from AGENTS.md §4 Verified."
|
||||||
|
*
|
||||||
|
* LANGUAGES ARE NOT A GROUP HERE, and that is a heading decision rather than an
|
||||||
|
* omission: docs/01 item 5 gives them a section of their own, and having both
|
||||||
|
* an h3 "Languages" group and an h2 "Language" section would put the same two
|
||||||
|
* facts in the accessibility tree twice under near-identical names. The section
|
||||||
|
* wins because it carries the cross-cultural half, which a list cannot.
|
||||||
|
*
|
||||||
|
* NOT PRESENT, AND EACH IS A §4 DIRECTIVE RATHER THAN A GAP:
|
||||||
|
* - The Law Society. Listing it implies licensure, which D13 bars. §4:
|
||||||
|
* "Excluded deliberately, not by oversight."
|
||||||
|
* - OCNI. Not current (§4), so it is not published.
|
||||||
|
* - Any licence status, in either direction. §4 records it `[unestablished]`.
|
||||||
|
*
|
||||||
|
* TODO(pouya): Are ADRIC, ADRIO, the three OBA sections (Construction &
|
||||||
|
* Infrastructure, ADR, Civil Litigation) and the Canadian Tax Foundation all
|
||||||
|
* current TODAY, and in which month does each renew? — AGENTS.md Q44.
|
||||||
|
*
|
||||||
|
* ⚠️ MEMBERSHIPS ARE DELIBERATELY NOT RENDERED, AND THIS IS A REVERSAL.
|
||||||
|
*
|
||||||
|
* The first version of this page published all four on §4's 2026-08-26 stamp and
|
||||||
|
* disclosed the outstanding re-confirmation in a note, in this comment, in
|
||||||
|
* `schema.ts`, in §9 Q44 and on the cutover checklist. Both review agents
|
||||||
|
* rejected that, and they are right. §12 **R10** is written as a PROHIBITION —
|
||||||
|
* *"Re-confirm at each renewal, **and before any page listing memberships
|
||||||
|
* ships** — `/about/` at build step 3 is the first one that will"* — and
|
||||||
|
* documenting a prohibition is not discharging it. `CLAUDE.md` gives the
|
||||||
|
* procedure for a fact you do not have, and it is this one: leave
|
||||||
|
* `TODO(pouya)`, log the question, let the gap be visible. *"A build that fails
|
||||||
|
* on an unanswered question is a correct build."*
|
||||||
|
*
|
||||||
|
* The alternative was to ship them and call it disclosed. It was taken once and
|
||||||
|
* is recorded here as the decision it was, not as an oversight — and it came
|
||||||
|
* with a second defect on top: the note asserted *"Memberships are renewed
|
||||||
|
* annually and are listed as current"*, which (a) warranted currency the
|
||||||
|
* register cannot vouch for and (b) widened §4, which records yearly renewal for
|
||||||
|
* the **OBA sections and the CTF only** and says nothing about ADRIC or ADRIO.
|
||||||
|
*
|
||||||
|
* OCNI is the precedent and it is in §4: a membership lapsed, quietly, and the
|
||||||
|
* register now reads "not current, do not publish". Nothing tells you when.
|
||||||
|
*
|
||||||
|
* The other three groups ship. Restoring this one is one array entry, the moment
|
||||||
|
* Q44 closes — and re-stamp §4 and `CREDENTIALS.memberships` that day.
|
||||||
|
*/
|
||||||
|
const CREDENTIAL_GROUPS = [
|
||||||
|
{ title: 'Designations', items: CREDENTIALS.designations },
|
||||||
|
{ title: 'Education', items: CREDENTIALS.education },
|
||||||
|
{ title: 'Certifications', items: CREDENTIALS.certifications },
|
||||||
|
];
|
||||||
|
---
|
||||||
|
|
||||||
|
<BaseLayout
|
||||||
|
title="About · Pouya Lajevardi · Mediator, Q.Med · Toronto"
|
||||||
|
description="Pouya Lajevardi, JD, Q.Med — a Toronto mediator who also practises as a machine-learning and infrastructure engineer. Credentials, background, designations."
|
||||||
|
ogType="profile"
|
||||||
|
imageAlt={PORTRAIT.alt}
|
||||||
|
jsonLd={graph}
|
||||||
|
>
|
||||||
|
{/* ---- 1. Hero: portrait, name, designation line --------------------- */}
|
||||||
|
<section class="hero">
|
||||||
|
<div class="wrap hero-inner">
|
||||||
|
<div class="hero-copy">
|
||||||
|
<Eyebrow dot>About</Eyebrow>
|
||||||
|
|
||||||
|
{
|
||||||
|
/* THE H1 IS THE NAME, not a headline, and that is docs/01 item 1
|
||||||
|
("Portrait, name, designation line") agreeing with the search intent
|
||||||
|
it records for this page (`"Pouya Lajevardi"`, `Pouya Lajevardi
|
||||||
|
mediator`). The masthead carries the name as a brand mark; a bio page
|
||||||
|
needs it as the document's subject. */
|
||||||
|
}
|
||||||
|
<h1 class="display hero-h">{SITE.name}</h1>
|
||||||
|
|
||||||
|
{
|
||||||
|
/* EACH SEPARATOR IS INSIDE THE SPAN IT PRECEDES, not a sibling of it.
|
||||||
|
As siblings the flex container wrapped between them, leaving an
|
||||||
|
orphaned "·" at the end of line 1 at 390px. The NON-BREAKING SPACE
|
||||||
|
after the glyph is what keeps it attached — an earlier version used
|
||||||
|
`white-space: nowrap` on the whole item instead, which fixed the
|
||||||
|
orphan and broke reflow at a 200% default font size. */
|
||||||
|
}
|
||||||
|
<p class="designation">
|
||||||
|
{
|
||||||
|
designationLine.map((part, i) => (
|
||||||
|
<span class="designation-part">
|
||||||
|
{i > 0 && (
|
||||||
|
<span class="sep" aria-hidden="true">
|
||||||
|
{'·\u00A0'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{part}
|
||||||
|
</span>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{
|
||||||
|
/* THE ROLE LINE, and it is the single highest-risk sentence on this
|
||||||
|
page. Both strings come from `ROLE` in src/data/site.ts rather than
|
||||||
|
being typed here, for the reason that file gives: "these are the two
|
||||||
|
where the wording IS the compliance."
|
||||||
|
|
||||||
|
D13: the approved phrasing is "active litigation exposure", NEVER
|
||||||
|
"practice" in this context. The boutique is never named (D16). The
|
||||||
|
matter types are §4 verbatim and must not be extended without a row.
|
||||||
|
|
||||||
|
EXPLICITLY INTERIM — AGENTS.md R1, surfaced again 2026-08-28
|
||||||
|
precisely because this page is where the framing now does its
|
||||||
|
heaviest work. */
|
||||||
|
}
|
||||||
|
<p class="hero-lede">
|
||||||
|
I am {ROLE.title} at {ROLE.at}, with {ROLE.litigationLine} across{' '}
|
||||||
|
{ROLE.litigationAreas.slice(0, -1).join(', ')} and{' '}
|
||||||
|
{ROLE.litigationAreas.at(-1)}. I mediate commercial disputes and I
|
||||||
|
accept arbitration appointments in commercial matters; the Q.Arb
|
||||||
|
pathway commenced in August 2026. I also work as a machine-learning
|
||||||
|
and infrastructure engineer.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{
|
||||||
|
/* `widths` + `sizes` rather than `densities`, because the portrait is
|
||||||
|
fluid and a density ladder would size it from one assumed CSS width.
|
||||||
|
`width` and `height` are passed ALONGSIDE `widths` — without them Astro
|
||||||
|
declares the untouched 1600px master as the <img src> fallback, which
|
||||||
|
is the defect `/`'s comment records at 254,626 bytes.
|
||||||
|
|
||||||
|
⚠️ THIS COMMENT PREVIOUSLY MADE FOUR CLAIMS AND THREE WERE MEASURABLY
|
||||||
|
FALSE. Recorded rather than quietly replaced, because the false ones
|
||||||
|
were the confident ones.
|
||||||
|
|
||||||
|
(a) "Same ladder as `/`'s hero and the same reasoning" — true, and
|
||||||
|
that was the problem: `/`'s reasoning derived a 960 ceiling from
|
||||||
|
the TWO-COLUMN layout, which only engages at 66rem. Below 66rem
|
||||||
|
the hero is one column and the portrait is the full content
|
||||||
|
width. Measured: 592px at a 640 viewport, 672 at 768, 804 at 900,
|
||||||
|
928 at 1024 — needing 1184-1856 device px at DPR 2 against a 960
|
||||||
|
ceiling. **1.40x upscale at 768/DPR2, 1.93x at 1024/DPR2**, on
|
||||||
|
this page and on `/`. At 768/DPR1 a 760w file exists and is not
|
||||||
|
chosen, so part of the loss was purely a wrong `sizes` (52vw
|
||||||
|
declared against an ~88vw slot). Fixed two ways: `.hero-portrait`
|
||||||
|
is capped at 30rem below 66rem so the widest real slot is 480 CSS
|
||||||
|
px, which makes 960 exactly right for DPR 2; and a 1440 rung
|
||||||
|
covers DPR 3, which the ≥66rem range had also been missing
|
||||||
|
(429px x 3 = 1287 against 960).
|
||||||
|
|
||||||
|
AND THE 1440 RUNG OVERSHOT, SO 1080 EXISTS TO CORRECT IT. Adding 1440
|
||||||
|
for DPR 3 removed a 1.07x upscale at 390/DPR3 and replaced it with a
|
||||||
|
**48,799 B fetch where the old one was 21,526 B** — +27 KB on a phone,
|
||||||
|
to fix a 7% softness nobody can see. A browser takes the smallest
|
||||||
|
candidate at or above what it needs, and with no rung between 960 and
|
||||||
|
1440 the only choices were "slightly soft" or "+27 KB". 1080 makes
|
||||||
|
1026 (390 x DPR 3) exact and cheap. This was a defect in the fix for
|
||||||
|
the defect above, found by measuring the fix rather than the source.
|
||||||
|
|
||||||
|
AND 1080 ALONE MISSED THE TWO LARGEST CURRENT PHONES, WHICH
|
||||||
|
MAKES THIS THE THIRD ITERATION OF THIS LADDER. 1080 was tuned to
|
||||||
|
390 CSS px x DPR 3 (= 1026), and `sizes` resolves to
|
||||||
|
`calc(100vw - 3rem)` up to 528px, so every phone wider than 390
|
||||||
|
overshoots to the next rung: iPhone 14 Plus (428@3, needs 1140)
|
||||||
|
and 15/16 Pro Max (430@3, needs 1146) both took **1440 —
|
||||||
|
48,799 B**, against 27,594 for the device the rung was tuned
|
||||||
|
for. +21,205 B, 13% of page weight. A 1200 rung closes it at
|
||||||
|
1.05x. Measured after: no rung more than 1.06x oversized on the
|
||||||
|
phone axis, and still no upscaling anywhere.
|
||||||
|
(b) "The LCP element on this page is the <h1>" — **false.**
|
||||||
|
`PerformanceObserver` at 1280x900: LCP element is
|
||||||
|
`IMG.portrait-img`, size 229,679; the <h1> box is 51,484, 4.5x
|
||||||
|
smaller. So the portrait IS the LCP element at desktop widths.
|
||||||
|
(c) "two words of 96px serif" — **false.** `.hero-h` sets
|
||||||
|
`--text-5xl`, which computes to **76px**. 96px is `--text-6xl`,
|
||||||
|
which is what `/` uses.
|
||||||
|
(d) "above the fold at every width" — **false.** Portrait top vs
|
||||||
|
viewport height: 782 vs 568 at 320, 752 vs 640 at 360 — entirely
|
||||||
|
below the fold at both, i.e. **0 visible px** at the two widths
|
||||||
|
`docs/02` names explicitly. 120px visible at 390x844.
|
||||||
|
|
||||||
|
SO WHY IS IT STILL `eager` AND NOT `fetchpriority="high"`? Because (b)
|
||||||
|
and (d) pull in opposite directions and the split is real. Re-measured
|
||||||
|
AFTER the cap, since the cap changes the element's size and therefore
|
||||||
|
the LCP candidate (`PerformanceObserver`, cache cleared per sample):
|
||||||
|
|
||||||
|
390x844 LCP = P.hero-lede 93,411
|
||||||
|
768x1024 LCP = IMG.portrait-img 168,161
|
||||||
|
1280x900 LCP = IMG.portrait-img 229,679
|
||||||
|
|
||||||
|
So the portrait is the LCP element from **768px up** — not "~1056px
|
||||||
|
up", which is what this comment said before the cap was measured — and
|
||||||
|
at phone widths LCP is the hero lede, a font-dependent text paint the
|
||||||
|
preloaded Geist already covers. `loading` and `fetchpriority` cannot be
|
||||||
|
conditioned on viewport. `eager` serves the tablet-and-desktop LCP;
|
||||||
|
`fetchpriority="high"` is withheld because at 320-360, where the image
|
||||||
|
is entirely off-screen, it would outrank that text paint. For
|
||||||
|
reference, `/` differs at 768 (LCP = H1.display, 135,289) because its
|
||||||
|
headline is a four-line sentence rather than a two-word name. */
|
||||||
|
}
|
||||||
|
<div class="hero-portrait">
|
||||||
|
<Picture
|
||||||
|
src={portrait}
|
||||||
|
width={960}
|
||||||
|
height={960}
|
||||||
|
widths={[380, 480, 640, 760, 960, 1080, 1200, 1440]}
|
||||||
|
sizes="(min-width: 80rem) 429px, (min-width: 66rem) 33vw, (min-width: 33rem) 480px, calc(100vw - 3rem)"
|
||||||
|
formats={['avif', 'webp']}
|
||||||
|
fallbackFormat="jpeg"
|
||||||
|
alt={PORTRAIT.alt}
|
||||||
|
loading="eager"
|
||||||
|
decoding="sync"
|
||||||
|
class="portrait-img"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ---- 2. Narrative biography ---------------------------------------- */}
|
||||||
|
<section class="section bio reveal">
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="section-head">
|
||||||
|
<SectionHeading eyebrow="Background" level={2}>
|
||||||
|
<span slot="heading">Two directions, one file.</span>
|
||||||
|
</SectionHeading>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{
|
||||||
|
/* 400–600 WORDS, docs/03: "Tell the three tracks as one arc, not three
|
||||||
|
lists." Measured, not estimated — see the word-count assertion in the
|
||||||
|
verify step of this session's Change Log entry.
|
||||||
|
|
||||||
|
Q41(a) IS APPLIED THROUGHOUT AND THIS IS THE FIRST PAGE WRITTEN UNDER
|
||||||
|
IT. Pouya's ruling, 2026-08-27: the implication test reaches prose, and
|
||||||
|
prose is held to a HIGHER bar — "state the asymmetry explicitly rather
|
||||||
|
than relying on a parallel construction to carry it." So the fourth
|
||||||
|
paragraph names which half is training and which is work, in as many
|
||||||
|
words. Avoiding the noun pair "law and engineering" is not sufficient
|
||||||
|
on its own: a reader can supply the missing symmetry from silence, and
|
||||||
|
for the legal half the missing half is a licence.
|
||||||
|
|
||||||
|
EVERY CLAIM TRACES TO §4 Verified: the JD, the boutique role, active
|
||||||
|
litigation exposure and its four matter types, Q.Med, multiple
|
||||||
|
completed sole mediations, arbitration appointments (§4 Offerings,
|
||||||
|
scoped to commercial), the Q.Arb pathway commenced August 2026,
|
||||||
|
C.Med-Arb as the goal, engineering practice, SML Company Ltd, Farsi,
|
||||||
|
Iranian-Canadian. Nothing here asserts or implies licensure. */
|
||||||
|
}
|
||||||
|
<div class="prose bio-prose">
|
||||||
|
<p>
|
||||||
|
I came to dispute resolution from two directions, and I still work in
|
||||||
|
both.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
The first is law. I hold a JD from Bond University, and I am{' '}
|
||||||
|
{ROLE.title} at {ROLE.at}. That role gives me {ROLE.litigationLine} — personal
|
||||||
|
injury, construction, regulatory matters under the Provincial Offences Act,
|
||||||
|
and accident benefits under the SABS. What that exposure is actually worth
|
||||||
|
in a mediation is unglamorous: I have seen how these files get built. Which
|
||||||
|
productions turn out to be thin. Where expert reports talk past each other
|
||||||
|
rather than disagree. Which issues resolve once someone puts the documents
|
||||||
|
in order, and which ones never will.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
The second is engineering. I work as a machine-learning and
|
||||||
|
infrastructure engineer. That is current practice, not a former career
|
||||||
|
and not an interest: I read code, model documentation, deployment
|
||||||
|
topology, and the operational records that show what a system did
|
||||||
|
rather than what a specification said it would do.
|
||||||
|
</p>
|
||||||
|
{
|
||||||
|
/* FROM A CONSTANT — ASYMMETRY_LINE in src/data/site.ts. It was typed
|
||||||
|
here and separately on `/`, and the two copies had already diverged
|
||||||
|
(full stops here, a comma there) inside the session that wrote both.
|
||||||
|
Q41(a) makes this the sentence responsible for foreclosing the
|
||||||
|
licence implication, so it is the worst string on the site to let
|
||||||
|
drift. */
|
||||||
|
}
|
||||||
|
<p>{ASYMMETRY_LINE}</p>
|
||||||
|
<p>
|
||||||
|
Mediation is where they meet. I hold the Q.Med designation through the
|
||||||
|
ADR Institute of Canada and the ADR Institute of Ontario, and I have
|
||||||
|
completed multiple sole mediations. I accept arbitration appointments
|
||||||
|
in commercial matters — as sole arbitrator, as a party-appointed
|
||||||
|
arbitrator, and in co-arbitration. Where a matter turns on a technical
|
||||||
|
question, I read the technical material myself.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
My Q.Arb pathway commenced in August 2026, and C.Med-Arb is the
|
||||||
|
designation I am working toward. I state the stage openly because an
|
||||||
|
appointing body will establish it anyway, and because a reader can do
|
||||||
|
more with the fact than with a hedge. I would rather say where I am on
|
||||||
|
the arc than leave it to be inferred.
|
||||||
|
</p>
|
||||||
|
{
|
||||||
|
/* BOTH ADDITIONS TO THIS SENTENCE CAME BACK OUT. §4 verifies exactly
|
||||||
|
one relation — *"Operator of SML Company Ltd. alongside the
|
||||||
|
practice"* — and that is now all it says.
|
||||||
|
|
||||||
|
"It is not a law firm and does not hold itself out as one" — a
|
||||||
|
negative REGULATORY statement with no row, attached to the one
|
||||||
|
§4 row carrying an express caution against being read together
|
||||||
|
with the licence row *"into an implication that neither row
|
||||||
|
makes."* Added to be helpful; it touches exactly what §4 says
|
||||||
|
not to touch.
|
||||||
|
"the company through which the engineering work is done" — a
|
||||||
|
corporate-structure claim. §4 verifies operation alongside the
|
||||||
|
practice, the jurisdiction of incorporation and the place of
|
||||||
|
business — not which work runs through which vehicle. */
|
||||||
|
}
|
||||||
|
<p>I run SML Company Ltd alongside both.</p>
|
||||||
|
<p>
|
||||||
|
I have also completed the Kompass Arbitration Certificate Program and
|
||||||
|
the Stitt Feld Handy negotiation and ADR workshop sequence. Neither is
|
||||||
|
a designation, and I name them precisely for that reason: process
|
||||||
|
training is the easiest thing in this field to assert loosely, so it
|
||||||
|
is worth stating exactly what it was.
|
||||||
|
</p>
|
||||||
|
{
|
||||||
|
/* THE NEUTRALITY LINE. It is a disclaimer and it earns its place:
|
||||||
|
docs/03 requires the equivalent on `/for-parties/`, and this is the
|
||||||
|
page an appointing body reads. It also states the negative of the
|
||||||
|
implication §4 Forbidden bars — "acts for clients", "represents
|
||||||
|
parties" — which is a stronger position than merely never asserting
|
||||||
|
it. Q42's reasoning is the same reasoning: Pouya struck settlement
|
||||||
|
counsel because a partisan role "undercuts the brand's central
|
||||||
|
claim". This sentence is that claim, stated.
|
||||||
|
|
||||||
|
The family-law exclusion is NOT here. Pouya scoped it to
|
||||||
|
`/practice/shareholder/` — "One sentence, not a section" — and
|
||||||
|
widening it to this page is his call, not an implementer's. */
|
||||||
|
}
|
||||||
|
{
|
||||||
|
/* THIS SENTENCE HAS NOW BEEN WRONG IN BOTH DIRECTIONS, WHICH IS WHY
|
||||||
|
THE THIRD VERSION AVOIDS THE AXIS ALTOGETHER.
|
||||||
|
|
||||||
|
"I do not give legal advice" — flagged because "do not" describes
|
||||||
|
an ELECTION, and an election implies the entitlement to choose.
|
||||||
|
"I cannot give legal advice" — flagged on the next pass because
|
||||||
|
"cannot" is a DENIAL of entitlement, and §4 on licence status
|
||||||
|
is explicit: *"Do not assert it, do not deny it, do not infer
|
||||||
|
it from anything else here."*
|
||||||
|
|
||||||
|
Both readings are correct and they point in opposite directions,
|
||||||
|
because both sentences make a claim about CAPACITY. So this one does
|
||||||
|
not: it states the ROLE and its consequence for the reader, which is
|
||||||
|
the form `docs/03` actually sanctions on `/for-parties/` (*"the
|
||||||
|
mediator is not your lawyer"* — role, not capacity) and the only one
|
||||||
|
that asserts nothing and denies nothing. The underlying question is
|
||||||
|
R1's. */
|
||||||
|
}
|
||||||
|
<p>
|
||||||
|
I act as a neutral. I do not act for a party in a matter I take, and
|
||||||
|
each party should have their own legal advice.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ---- 4. The credentialing arc -------------------------------------- */}
|
||||||
|
{
|
||||||
|
/* SECTION 4 BEFORE SECTION 3, and the reorder is deliberate. docs/01 lists
|
||||||
|
credentials (item 3) then the arc (item 4). The arc is the part a reader
|
||||||
|
is likely to have a question about — it is the thing this practice is
|
||||||
|
candid about that others are not — and burying it under a scannable list
|
||||||
|
of things already held reads as a footnote to them. §4's paired-disclosure
|
||||||
|
condition also wants the stage stated where the offering is made, and the
|
||||||
|
offering is made in the narrative directly above. The list follows. */
|
||||||
|
}
|
||||||
|
<section class="section section-inverse arc-section reveal">
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="section-head">
|
||||||
|
<SectionHeading
|
||||||
|
eyebrow="The arc"
|
||||||
|
level={2}
|
||||||
|
lede="Three designations. Two of them are ahead of me, and saying so is the point."
|
||||||
|
>
|
||||||
|
<span slot="heading">Where the credentials sit.</span>
|
||||||
|
</SectionHeading>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{
|
||||||
|
/* `role="list"` RESTORED, AND THE REASON I REMOVED IT WAS WRONG ABOUT
|
||||||
|
ARIA. The comment here claimed that on an <ol> the role "re-announces
|
||||||
|
an ordered list as an unordered one". It does not: **both <ul> and <ol>
|
||||||
|
map to the `list` role**, so `role="list"` on an <ol> is a no-op for
|
||||||
|
ordering, not a downgrade. What it is actually for is the WebKit
|
||||||
|
heuristic that strips list semantics from a list with
|
||||||
|
`list-style-type: none` — and `.arc` sets exactly that.
|
||||||
|
|
||||||
|
It also left the two <ol>s on this two-page site DISAGREEING, with
|
||||||
|
`/`'s `.process-strip` keeping the role. That is the state that gets
|
||||||
|
copied seventeen times.
|
||||||
|
|
||||||
|
Not verified here: whether WebKit's heuristic covers <ol> as well as
|
||||||
|
<ul>. There is no Safari instrument in this environment, so the role
|
||||||
|
stays on the precautionary side, which costs nothing. Chrome's AX tree
|
||||||
|
exposes `.arc` as `list` with three `listitem` children either way. */
|
||||||
|
}
|
||||||
|
<ol class="arc" role="list">
|
||||||
|
{
|
||||||
|
ARC.map((stage) => (
|
||||||
|
<li class="arc-item">
|
||||||
|
<h3 class="arc-name">{stage.name}</h3>
|
||||||
|
<p class="arc-state">
|
||||||
|
<Pill>{stage.state}</Pill>
|
||||||
|
</p>
|
||||||
|
<p class="arc-body">{stage.body}</p>
|
||||||
|
</li>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ---- 3. Credentials, structured ------------------------------------ */}
|
||||||
|
<section class="section section-alt creds reveal">
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="section-head">
|
||||||
|
<SectionHeading eyebrow="Credentials" level={2}>
|
||||||
|
<span slot="heading">The verifiable record.</span>
|
||||||
|
</SectionHeading>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="cred-grid">
|
||||||
|
{
|
||||||
|
CREDENTIAL_GROUPS.map((group) => (
|
||||||
|
<div class="cred-group">
|
||||||
|
<h3 class="cred-title">{group.title}</h3>
|
||||||
|
<ul class="cred-items" role="list">
|
||||||
|
{group.items.map((item) => (
|
||||||
|
<li>{item}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{
|
||||||
|
/* THIS NOTE IS GONE, AND IT CARRIED THREE SEPARATE DEFECTS. It read:
|
||||||
|
"Memberships are renewed annually and are listed as current. Nothing
|
||||||
|
above asserts a licence to practise law, in either direction."
|
||||||
|
|
||||||
|
(a) "renewed annually" WIDENED §4, which records yearly renewal for
|
||||||
|
the OBA sections and the CTF only and says nothing about ADRIC
|
||||||
|
or ADRIO. The widened form had already propagated to four places.
|
||||||
|
(b) "listed as current" was an affirmative public WARRANTY of
|
||||||
|
currency stacked on top of an undischarged R10 — the reminder
|
||||||
|
whose entire purpose is that no such warranty be made without a
|
||||||
|
re-confirmation. The memberships group is now off the page
|
||||||
|
(Q44), so the sentence has nothing left to warrant either.
|
||||||
|
(c) "Nothing above asserts a licence to practise law, in either
|
||||||
|
direction" READS AS A DENIAL. §4 on licence status: "Do not
|
||||||
|
assert it, do not deny it, do not infer it from anything else
|
||||||
|
here." It was also the only sentence on the site that raised
|
||||||
|
licensure at all, on the page where R1 says the D13 framing is
|
||||||
|
already doing its heaviest work — and no spec asked for it.
|
||||||
|
|
||||||
|
Both review agents flagged (c) independently and escalated it to
|
||||||
|
Pouya rather than rewriting it. That is the right destination: R1. */
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ---- 5. Language and cross-cultural practice ----------------------- */}
|
||||||
|
<section class="section language reveal">
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="section-head">
|
||||||
|
<SectionHeading eyebrow="Language" level={2}>
|
||||||
|
<span slot="heading">English and Farsi, without an interpreter.</span>
|
||||||
|
</SectionHeading>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{
|
||||||
|
/* §4 verifies "Bilingual English and Farsi" and "Iranian-Canadian;
|
||||||
|
cross-cultural fluency with diaspora business communities".
|
||||||
|
|
||||||
|
NO QUANTITY AND NO COMPARATIVE. An earlier draft opened "many of the
|
||||||
|
disputes I am best placed to take", which carries a count I do not
|
||||||
|
have and the token "best" — which §4 Forbidden bars as a superlative
|
||||||
|
and which a forbidden-terms sweep would flag on sight. Rewritten to a
|
||||||
|
claim about the work: some disputes are not separable from the
|
||||||
|
relationship, and this is what working in the parties' own language
|
||||||
|
changes. Nothing about other neutrals — Q41(b). */
|
||||||
|
}
|
||||||
|
<div class="prose">
|
||||||
|
<p>
|
||||||
|
I mediate in English and in Farsi. I am Iranian-Canadian, and some
|
||||||
|
commercial disputes are not separable from the relationship between
|
||||||
|
the parties — family-held companies and diaspora businesses in
|
||||||
|
particular, where the commercial disagreement and a much longer
|
||||||
|
history arrive together.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Working in the parties' own language, with no interpreter in the room,
|
||||||
|
changes what gets said and how early it gets said. It removes a layer
|
||||||
|
between a party and their own account of events.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ---- Contact band --------------------------------------------------- */}
|
||||||
|
<ContactBand />
|
||||||
|
</BaseLayout>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* --- 1. Hero -------------------------------------------------------- */
|
||||||
|
|
||||||
|
.hero {
|
||||||
|
padding-block: var(--space-8) var(--space-9);
|
||||||
|
}
|
||||||
|
.hero-inner {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-7);
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.hero-copy {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-5);
|
||||||
|
}
|
||||||
|
.hero-h {
|
||||||
|
/* --text-5xl, not --text-6xl. `/`'s headline is a sentence and needs the
|
||||||
|
display ceiling; this is a two-word name, and at 96px it sets 15
|
||||||
|
characters across a line that then has nothing to balance against. */
|
||||||
|
font-size: var(--text-5xl);
|
||||||
|
/* `anywhere`, NOT `break-word`. The type scale is rem-based, so at a 200%
|
||||||
|
DEFAULT FONT SIZE this heading computes to 88px and "Lajevardi" — one
|
||||||
|
unbreakable 9-character word — is wider than the 224px content box at a
|
||||||
|
320px viewport. `break-word` permits a break at layout time but does NOT
|
||||||
|
reduce min-content size, so it would not have helped; `anywhere` does.
|
||||||
|
It has no effect at any normal size: a word only breaks when it cannot
|
||||||
|
fit. Breaking a name mid-word is ugly and it is better than a reader at
|
||||||
|
200% zoom losing the page. WCAG 1.4.4 / 1.4.10. */
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.designation {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
/* THE GAP CARRIES THE SEPARATOR'S SPACING, not a margin on .sep. The
|
||||||
|
separator is aria-hidden, so its box must not be what a sighted reader
|
||||||
|
depends on for rhythm while a screen-reader user gets nothing — with
|
||||||
|
`gap` the spacing survives the element being ignored. */
|
||||||
|
gap: var(--space-1) var(--space-3);
|
||||||
|
align-items: baseline;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
letter-spacing: var(--tracking-tight);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
/* NO `white-space: nowrap` — IT WAS HERE AND IT BROKE REFLOW. Gluing the whole
|
||||||
|
item together made "Q.Med (ADRIC / ADRIO)" unbreakable, and at a 200%
|
||||||
|
DEFAULT FONT SIZE that pushed this page to 108px of overflow at 320px
|
||||||
|
against `/`'s accepted 63. The separator does not need the whole item to be
|
||||||
|
unbreakable — it only needs to stay attached to the FIRST word, which the
|
||||||
|
non-breaking space in the markup does. WCAG 1.4.10. */
|
||||||
|
.designation-part {
|
||||||
|
display: inline;
|
||||||
|
}
|
||||||
|
.designation .sep {
|
||||||
|
/* NOT `var(--rule)`. Gold `#c9a876` on cream measures **2.10:1** — the one
|
||||||
|
hard constraint in `docs/02` and `tokens.css`, which say in terms that
|
||||||
|
gold is never a text colour on cream. An in-browser audit of all 100
|
||||||
|
text-bearing elements on this page returned exactly two failures and both
|
||||||
|
were this span. `aria-hidden` does not dispose of it: these separators are
|
||||||
|
the only thing dividing three credential items, and at 2.10:1 they are
|
||||||
|
invisible, so the line reads as a run-on. That is a legibility failure
|
||||||
|
before it is a rule breach. `--text-meta` measures 5.47:1 on cream and
|
||||||
|
still recedes from the 11.75:1 text beside it. */
|
||||||
|
color: var(--text-meta);
|
||||||
|
}
|
||||||
|
.hero-lede {
|
||||||
|
max-inline-size: var(--width-prose);
|
||||||
|
font-size: var(--text-lg);
|
||||||
|
line-height: var(--leading-relaxed);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
/* `<Picture>` emits an <img> wrapped in a <picture>, and the <picture> is the
|
||||||
|
box the grid sizes — the `class` prop lands on the <img>, which is the
|
||||||
|
defect InfinityMark.astro records for its own flex sizing.
|
||||||
|
|
||||||
|
THIS COMMENT CLAIMED "the <picture> WRAPPER carries no cid, so it needs
|
||||||
|
`:global()`". **That is false.** Read from the built HTML: the emitted markup
|
||||||
|
is `<picture data-astro-cid-ta2fbyqs="true">` — Astro DOES propagate the
|
||||||
|
page's scope attribute to both elements for `astro:assets` components (it
|
||||||
|
does not for ordinary user components, which is the real rule CLAUDE.md
|
||||||
|
records). Decisive corroboration in the shipped CSS: InfinityMark's BARE
|
||||||
|
`picture` selector compiles to `picture[data-astro-cid-usztftas]` and
|
||||||
|
demonstrably works. So `:global()` was unnecessary and merely broader than
|
||||||
|
intended — it would also have matched a <picture> inside any child
|
||||||
|
component placed here. Plain selector, correct comment.
|
||||||
|
|
||||||
|
4/5 rather than 1/1 — the master is square, and 4/5 is the crop `/` uses.
|
||||||
|
One portrait treatment across the site rather than two. */
|
||||||
|
.hero-portrait {
|
||||||
|
aspect-ratio: 4 / 5;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
background: var(--bg-raised);
|
||||||
|
/* THE CAP IS THE UPSCALE FIX, not a style preference — see the <Picture>
|
||||||
|
comment. Uncapped, this slot ran to 928 CSS px at a 1024px viewport, which
|
||||||
|
is 1856 device px at DPR 2 against a 960w ceiling. `margin-inline: auto`
|
||||||
|
because a 480px box in a 928px column would otherwise sit hard against
|
||||||
|
the inline start. */
|
||||||
|
max-inline-size: 30rem;
|
||||||
|
margin-inline: auto;
|
||||||
|
}
|
||||||
|
.hero-portrait picture {
|
||||||
|
display: block;
|
||||||
|
block-size: 100%;
|
||||||
|
}
|
||||||
|
.portrait-img {
|
||||||
|
inline-size: 100%;
|
||||||
|
block-size: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
/* Above centre: the head sits in the upper half of a square crop. */
|
||||||
|
object-position: 50% 22%;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 66rem) {
|
||||||
|
.hero-portrait {
|
||||||
|
/* Released above 66rem: the grid track is already 390-476px, so the cap
|
||||||
|
is inert — and leaving it in place would silently become the constraint
|
||||||
|
if the track ever widened. The track governs here, not this number. */
|
||||||
|
max-inline-size: none;
|
||||||
|
}
|
||||||
|
.hero-inner {
|
||||||
|
/* 1fr / 0.62fr — the portrait is smaller than `/`'s 0.72 because this
|
||||||
|
hero's copy block is a name plus two short lines and the picture would
|
||||||
|
otherwise dominate a page whose subject is the text. */
|
||||||
|
grid-template-columns: 1fr 0.62fr;
|
||||||
|
gap: var(--space-8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- 2. Background --------------------------------------------------- */
|
||||||
|
|
||||||
|
/* NO `.section-head` RULE — it moved to `global.css`, where `.prose` lives.
|
||||||
|
It was byte-identical in both pages, and every one of the seventeen
|
||||||
|
remaining pages needs it for the same reason (a parent cannot style a child
|
||||||
|
component's root, so the wrapper must be page-owned). */
|
||||||
|
/* NO `> p + p` RULE HERE. It was the only paragraph-spacing rule in this
|
||||||
|
file and it is now redundant: global.css owns `.prose` paragraph spacing as
|
||||||
|
of 2026-08-28, which is what stopped §Language below from rendering its two
|
||||||
|
paragraphs as one block. Two rules setting the same property to the same
|
||||||
|
value is one rule that will eventually disagree. */
|
||||||
|
.bio-prose {
|
||||||
|
font-size: var(--text-lg);
|
||||||
|
line-height: var(--leading-relaxed);
|
||||||
|
}
|
||||||
|
/* NO `.bio-prose i` RULE. It styled a statute-name italic that the bio no
|
||||||
|
longer uses: `Geist` ships NO italic face (every Geist `@font-face` is
|
||||||
|
`font-style: normal`), so the `<i>` rendered as SYNTHETIC OBLIQUE, and the
|
||||||
|
serif italic that would have set it properly is no longer preloaded on this
|
||||||
|
page. "Provincial Offences Act" is set in roman. If a statute name ever
|
||||||
|
needs italics here, load a face for it first. */
|
||||||
|
|
||||||
|
/* --- 4. The arc ------------------------------------------------------ */
|
||||||
|
|
||||||
|
.arc {
|
||||||
|
display: grid;
|
||||||
|
/* `min(18rem, 100%)` rather than a bare 18rem floor: a bare floor cannot
|
||||||
|
shrink below itself and overflows at a large default font size. The
|
||||||
|
credential row on `/` is the measured instance of that mistake. */
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(min(18rem, 100%), 1fr));
|
||||||
|
gap: var(--space-6);
|
||||||
|
/* NO `padding: 0` OR `list-style: none` HERE — `global.css`'s
|
||||||
|
`ul[role='list'], ol[role='list']` reset already supplies both, and this
|
||||||
|
block was re-implementing it by hand. Two rules for one job, and the
|
||||||
|
hand-written copy is the one that drifts. `margin: 0` also comes from the
|
||||||
|
global `* { margin: 0 }` reset. */
|
||||||
|
}
|
||||||
|
.arc-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-3);
|
||||||
|
padding-block-start: var(--space-4);
|
||||||
|
border-block-start: 1px solid var(--line-dark);
|
||||||
|
}
|
||||||
|
/* <h3>, not <p>. These are the headings of the three arc items and they set
|
||||||
|
at --text-2xl serif, so marking them up as paragraphs was the fake-heading
|
||||||
|
pattern: a screen-reader user got no heading navigation for the one section
|
||||||
|
on this page a reader is most likely to jump to. `ProcessStep` on `/` uses
|
||||||
|
<h3> for exactly this shape. Outline stays h1 -> h2 -> h3 with no skips. */
|
||||||
|
.arc-name {
|
||||||
|
font-family: var(--font-serif);
|
||||||
|
font-size: var(--text-2xl);
|
||||||
|
line-height: var(--leading-tight);
|
||||||
|
}
|
||||||
|
.arc-state {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.arc-body {
|
||||||
|
margin: 0;
|
||||||
|
font-size: var(--text-base);
|
||||||
|
line-height: var(--leading-body);
|
||||||
|
/* NOT --text-secondary. On an inverse ground `--ink-soft` measures ~1.4:1
|
||||||
|
against `--ink` — the inherited `--text-inverse` (cream, 16.81:1) is what
|
||||||
|
carries body copy here, so the colour is deliberately left alone rather
|
||||||
|
than set to a token that is correct only on cream. */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- 3. Credentials -------------------------------------------------- */
|
||||||
|
|
||||||
|
.cred-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(min(16rem, 100%), 1fr));
|
||||||
|
gap: var(--space-7) var(--space-6);
|
||||||
|
}
|
||||||
|
/* THESE ARE HEADINGS AND THEY WERE DRESSED AS EYEBROWS. The previous rule set
|
||||||
|
11px / Geist Mono / 0.18em / uppercase / --text-meta, which is `docs/02`'s
|
||||||
|
eyebrow specification exactly — and `docs/02` says in terms: "An eyebrow is
|
||||||
|
not a heading and never carries the <h*>." `Eyebrow.astro` restates it.
|
||||||
|
|
||||||
|
The measurable consequence was worse than the rule breach: at 11px these
|
||||||
|
<h3>s were SMALLER than the 12px eyebrow above them and 5px smaller than
|
||||||
|
the 16px list items they head, so "MEMBERSHIPS" was the least legible text
|
||||||
|
on the page an appointing body reads.
|
||||||
|
|
||||||
|
Resolved by keeping the <h3> — these genuinely are the headings for their
|
||||||
|
lists, and the heading navigation is worth more than the styling — and
|
||||||
|
dropping the eyebrow treatment. The gold rule stays: gold is sanctioned for
|
||||||
|
dividers, and a 1px border is not text. */
|
||||||
|
.cred-title {
|
||||||
|
font-size: var(--text-base);
|
||||||
|
font-weight: var(--weight-medium);
|
||||||
|
letter-spacing: var(--tracking-tight);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
padding-block-end: var(--space-3);
|
||||||
|
border-block-end: 1px solid var(--rule);
|
||||||
|
}
|
||||||
|
.cred-items {
|
||||||
|
margin: var(--space-4) 0 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-3);
|
||||||
|
font-size: var(--text-base);
|
||||||
|
line-height: var(--leading-snug);
|
||||||
|
}
|
||||||
|
/* NO `.cred-note` RULE. The note it styled was removed — three defects in one
|
||||||
|
sentence, recorded in the markup above — and the rule went with it rather
|
||||||
|
than shipping as dead CSS to every visitor. Noted so the next reader does
|
||||||
|
not re-add a note to fill a rule that no longer exists. */
|
||||||
|
|
||||||
|
/* NO CONTACT-BAND RULES HERE. `.contact-inner`, `.contact-h`, `.contact-body`
|
||||||
|
and `.contact-action` moved to `ContactBand.astro` with the markup they
|
||||||
|
style. They were left behind after the extraction — dead CSS shipping to
|
||||||
|
every visitor, and worse, the **46ch / 52ch divergence the extraction
|
||||||
|
existed to end was still sitting on disk in both pages**, so the next reader
|
||||||
|
would have found two different values and no rendered difference. Deleted
|
||||||
|
2026-08-28. */
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,946 @@
|
|||||||
|
---
|
||||||
|
/**
|
||||||
|
* `/` — Home. Build step 2 (docs/01 §Build order): "proves the design system
|
||||||
|
* end to end."
|
||||||
|
*
|
||||||
|
* JOB (docs/01 §`/`): "establish the unusual stack in under ten seconds, and
|
||||||
|
* route each of the four audiences to its surface." Leans in-house counsel.
|
||||||
|
*
|
||||||
|
* SECTIONS, against docs/01's outline:
|
||||||
|
* 1 Hero · 2 Credential row · 3 The approach · 4 Two practices ·
|
||||||
|
* 5 Practice areas · 6 Process preview · 7 Latest insights · 8 Contact band
|
||||||
|
*
|
||||||
|
* SECTION 7 IS NOT BUILT, DELIBERATELY, and this is the only spec item this
|
||||||
|
* page does not deliver. `src/content/insights/` is empty: the collection ships
|
||||||
|
* at build step 7, which is also where `ArticleCard` and the drafted slate
|
||||||
|
* arrive (docs/01 §Build order; docs/03 §Launch article slate, D9). Rendering
|
||||||
|
* the section now means importing a component with nothing to render — its
|
||||||
|
* scoped CSS ships to every visitor for an empty block — and a props surface
|
||||||
|
* with no call site, which is already an open finding against InfinityMark.
|
||||||
|
* SiteHeader gates the Insights NAV item on the same collection, so the page
|
||||||
|
* and the nav appear together. Do not "finish" this by hardcoding placeholders.
|
||||||
|
*
|
||||||
|
* EVERY FACTUAL CLAIM ON THIS PAGE TRACES TO AGENTS.md §4, and the ones that
|
||||||
|
* carry risk are constants from src/data/site.ts rather than typed here.
|
||||||
|
* Notably: arbitration is scoped to COMMERCIAL matters throughout (§4
|
||||||
|
* Offerings, Q39 2026-08-27), the Q.Arb stage is stated on the page and not
|
||||||
|
* only in the footer, and nothing claims or implies licensure (D13).
|
||||||
|
*/
|
||||||
|
import { Picture, getImage } from 'astro:assets';
|
||||||
|
import BaseLayout from '../layouts/BaseLayout.astro';
|
||||||
|
import Button from '../components/Button.astro';
|
||||||
|
import ContactBand from '../components/ContactBand.astro';
|
||||||
|
import CredentialRow from '../components/CredentialRow.astro';
|
||||||
|
import Eyebrow from '../components/Eyebrow.astro';
|
||||||
|
import InfinityMark from '../components/InfinityMark.astro';
|
||||||
|
import PracticeCard from '../components/PracticeCard.astro';
|
||||||
|
import ProcessStep from '../components/ProcessStep.astro';
|
||||||
|
import SectionHeading from '../components/SectionHeading.astro';
|
||||||
|
import portrait from '../assets/pouya-lajevardi.jpg';
|
||||||
|
import ogDefault from '../assets/og-portrait.jpg';
|
||||||
|
import { homeGraph } from '../data/schema';
|
||||||
|
import {
|
||||||
|
ASYMMETRY_LINE,
|
||||||
|
CREDENTIAL_ROW,
|
||||||
|
CREDENTIAL_ROW_ARB,
|
||||||
|
PRACTICE_AREAS,
|
||||||
|
PORTRAIT,
|
||||||
|
PROCESS,
|
||||||
|
PROCESS_FRAMING,
|
||||||
|
SITE,
|
||||||
|
} from '../data/site';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FOUR SLOTS, NOT THREE, AND THE FOURTH IS NOT DECORATIVE.
|
||||||
|
*
|
||||||
|
* docs/01 §`/` item 2 says "Three slots". §4 Offerings is the higher authority
|
||||||
|
* on claims and attaches a PAIRED-DISCLOSURE CONDITION to offering arbitration
|
||||||
|
* at all: the site "makes the first while stating the second plainly", and
|
||||||
|
* "neither half may be dropped". This page says *arbitrator* in its second
|
||||||
|
* sentence, so the stage of the arc belongs on this page rather than only in
|
||||||
|
* the site footer. docs/03 authorises the fourth slot; docs/03 has been amended
|
||||||
|
* to record that on `/` it is required. Q.Arb reads as commenced — never as
|
||||||
|
* held or nearing completion (§4 Forbidden).
|
||||||
|
*/
|
||||||
|
const credentials = [...CREDENTIAL_ROW, CREDENTIAL_ROW_ARB];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* docs/04 lists `image` on the Person node. This generates it with EXACTLY the
|
||||||
|
* transform SEO.astro already applies to the same source — jpeg, 1200 x 630 —
|
||||||
|
* so Astro's asset cache returns the same hashed file rather than emitting a
|
||||||
|
* second copy for the crawler's benefit. Verified by asset count: dist holds
|
||||||
|
* one `og-portrait.*.jpeg` with this line present, not two.
|
||||||
|
*
|
||||||
|
* JPEG on purpose, for the same reason SEO.astro uses it: link-preview and
|
||||||
|
* structured-data consumers are not browsers and several still do not decode
|
||||||
|
* WebP, let alone AVIF.
|
||||||
|
*/
|
||||||
|
const ldImage = await getImage({
|
||||||
|
src: ogDefault,
|
||||||
|
format: 'jpeg',
|
||||||
|
width: 1200,
|
||||||
|
height: 630,
|
||||||
|
});
|
||||||
|
const graph = homeGraph(new URL(ldImage.src, Astro.site).href);
|
||||||
|
---
|
||||||
|
|
||||||
|
<BaseLayout
|
||||||
|
title={`${SITE.name} · Mediation & Arbitration · Toronto`}
|
||||||
|
description="Commercial mediation and arbitration in Toronto. Construction, technology, energy, insurance and shareholder disputes, read as contracts and as engineering."
|
||||||
|
imageAlt={PORTRAIT.alt}
|
||||||
|
jsonLd={graph}
|
||||||
|
preloadSerifItalic
|
||||||
|
>
|
||||||
|
{/* ---- 1. Hero ------------------------------------------------------- */}
|
||||||
|
<section class="hero">
|
||||||
|
<div class="wrap hero-inner">
|
||||||
|
<div class="hero-copy">
|
||||||
|
{
|
||||||
|
/* docs/01 specifies this exact string as the hero eyebrow. The
|
||||||
|
masthead no longer repeats it on this page — see SiteHeader. */
|
||||||
|
}
|
||||||
|
<Eyebrow dot>{SITE.tagline}</Eyebrow>
|
||||||
|
|
||||||
|
{
|
||||||
|
/* docs/03 §Approved headline options, option 1 — the recommended one.
|
||||||
|
Option 3 is struck there: "every side" asserts having acted as
|
||||||
|
party, as counsel and as neutral, and §4 verifies one of the three.
|
||||||
|
The italic is the single flourish docs/02 allows in a headline. */
|
||||||
|
}
|
||||||
|
<h1 class="display hero-h">
|
||||||
|
A mediator who reads the contract, the code, and <em class="it"
|
||||||
|
>the room</em
|
||||||
|
>.
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
{
|
||||||
|
/* TWO CORRECTIONS FROM `claims-auditor`, 2026-08-27, both about
|
||||||
|
implication rather than assertion — which is where D13 says the risk
|
||||||
|
actually lives.
|
||||||
|
|
||||||
|
(a) This read "I mediate and arbitrate commercial disputes". §4
|
||||||
|
verifies that he ACCEPTS arbitral appointments, and separately
|
||||||
|
verifies "multiple completed sole mediations" — there is no
|
||||||
|
counterpart row for a completed arbitration. Present-indicative
|
||||||
|
"arbitrate" beside "mediate" invites the reader to supply a track
|
||||||
|
record for both. The offering-shaped form is what the register
|
||||||
|
actually holds, and it is already the form the arbitration card
|
||||||
|
below uses.
|
||||||
|
|
||||||
|
(b) "facts most neutrals take on faith" is a COMPARATIVE assertion
|
||||||
|
about a population of third parties, and **Q41(b) CLOSED 2026-08-27:
|
||||||
|
it is not restored, and the reason is not only compliance.** Pouya:
|
||||||
|
*"That is an unverifiable empirical claim about other practitioners,
|
||||||
|
and comparative claims must be factual and verifiable. It is also
|
||||||
|
weaker copy: assert his capability, not the field's incapability."*
|
||||||
|
It is struck from `docs/03`'s core positioning statement too — the
|
||||||
|
approved-copy defence is gone, because the approved copy changed.
|
||||||
|
|
||||||
|
His replacement wording is used verbatim: *"built for disputes that
|
||||||
|
turn on the contract, the code, and the engineering documents"*. The
|
||||||
|
interim ("the documents rather than the pleadings") is also gone; it
|
||||||
|
said nothing about other neutrals but it still worked by contrast.
|
||||||
|
|
||||||
|
ON THE ECHO OF THE HEADLINE, because it is deliberate and one edit
|
||||||
|
from being reversed if he reads it as a stumble. The `<h1>` ends
|
||||||
|
"the contract, the code, and the room"; this sentence re-runs the
|
||||||
|
triad and swaps the third term for "the engineering documents". Two
|
||||||
|
of three words repeat forty words apart. Read as a rhyme it does the
|
||||||
|
work of the whole positioning statement in one move; read as an
|
||||||
|
oversight it looks careless. Judged the first, flagged as the
|
||||||
|
second. */
|
||||||
|
}
|
||||||
|
<p class="hero-lede">
|
||||||
|
I mediate commercial disputes from Toronto, and I accept commercial
|
||||||
|
arbitration appointments. I also practise as a machine-learning and
|
||||||
|
infrastructure engineer, so the matters I take are the ones that turn
|
||||||
|
on the contract, the code, and the engineering documents: the change
|
||||||
|
order, the model card, the System Impact Assessment, and the
|
||||||
|
regulatory overlay around them.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="hero-cta">
|
||||||
|
<Button href="/contact/" variant="primary"
|
||||||
|
>Request a consultation →</Button
|
||||||
|
>
|
||||||
|
<Button href="/process/" variant="ghost">How I work</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{
|
||||||
|
/* `widths` + `sizes` rather than `densities`: the portrait is fluid, and
|
||||||
|
a density ladder would size it from one assumed CSS width.
|
||||||
|
|
||||||
|
⚠️ THE OLD CEILING ARGUMENT WAS WRONG, AND IT WAS WRONG BY IGNORING
|
||||||
|
THE RANGE WHERE THIS IMAGE IS WIDEST. It read: "The largest real
|
||||||
|
render is ~476px (content 1280 - 96 gutter - 64 gap, x 0.425), so 960
|
||||||
|
is the 2x ceiling." That arithmetic describes the TWO-COLUMN layout,
|
||||||
|
which only engages at 66rem. Below 66rem the hero is a single column
|
||||||
|
and the portrait is the full content width. Measured, both pages:
|
||||||
|
|
||||||
|
viewport slot DPR-2 needs picked result
|
||||||
|
640 592 1184 760/960 1.23-1.56x upscale
|
||||||
|
768 672 1344 960 1.40x
|
||||||
|
900 804 1608 960 1.68x
|
||||||
|
1024 928 1856 960 1.93x
|
||||||
|
|
||||||
|
At 768/DPR-1 a 760w file EXISTS and is not chosen, so part of the loss
|
||||||
|
was purely the wrong `sizes` (60vw declared against a ~88vw slot).
|
||||||
|
Reconfirmed with the HTTP cache cleared — an earlier probe reported a
|
||||||
|
2.81x OVERSIZED fetch at 390/DPR-1 which was a cache artefact, not a
|
||||||
|
defect.
|
||||||
|
|
||||||
|
TWO FIXES, BOTH REQUIRED. (1) `.hero-portrait` is capped at 30rem in
|
||||||
|
the single-column range, so the widest real slot is 480 CSS px
|
||||||
|
everywhere — which makes 960 exactly right for DPR 2 rather than
|
||||||
|
accidentally short. (2) A 1440 rung, because 480 x 3 = 1440 and the
|
||||||
|
desktop slot at DPR 3 already needed 1287-1428; the ≥66rem range was
|
||||||
|
upscaling ~1.34-1.49x at DPR 3 before this and nobody had measured it.
|
||||||
|
The master is 1600, so 1440 exists.
|
||||||
|
|
||||||
|
THE CAP CHANGES HOW THIS PAGE LOOKS between 640px and 1055px — the
|
||||||
|
portrait was 592-928px wide there and is now 480. That is a visible
|
||||||
|
design change to a reviewed page, made on payload grounds; raising the
|
||||||
|
cap is a one-line change but the ladder has to grow with it.
|
||||||
|
|
||||||
|
`width` AND `height` ARE PASSED ALONGSIDE `widths`, AND THAT IS NOT
|
||||||
|
REDUNDANT. With `widths` alone, Astro emits the UNTOUCHED 1600px
|
||||||
|
master as the <img src> fallback — measured 254,626 bytes sitting in
|
||||||
|
dist as the declared fallback for a 476px slot. Passing width/height
|
||||||
|
pins it to the 960 variant instead (78,665 B), and the 1600px file
|
||||||
|
stops being generated at all. Same defect InfinityMark's comment
|
||||||
|
records for a missing `width`, in the one shape that survives passing
|
||||||
|
`widths`. Verified by reading the emitted `src` attribute and that
|
||||||
|
file's real dimensions — not the build log, which reported "before:
|
||||||
|
349kB" for every variant either way.
|
||||||
|
|
||||||
|
What a browser actually takes is the AVIF: 5.6 / 7.3 / 11.1 / 14.8 /
|
||||||
|
21.5 kB across the first five widths [measured 2026-08-27]; 1080 and
|
||||||
|
1440 are added 2026-08-28 for DPR 3 (26.6 / 48.8 kB).
|
||||||
|
|
||||||
|
AND THE 1440 RUNG OVERSHOT, SO 1080 EXISTS TO CORRECT IT. Adding 1440
|
||||||
|
for DPR 3 removed a 1.07x upscale at 390/DPR3 and replaced it with a
|
||||||
|
**48,799 B fetch where the old one was 21,526 B** — +27 KB on a phone,
|
||||||
|
to fix a 7% softness nobody can see. A browser takes the smallest
|
||||||
|
candidate at or above what it needs, and with no rung between 960 and
|
||||||
|
1440 the only choices were "slightly soft" or "+27 KB". 1080 makes
|
||||||
|
1026 (390 x DPR 3) exact and cheap. This was a defect in the fix for
|
||||||
|
the defect above, found by measuring the fix rather than the source.
|
||||||
|
|
||||||
|
AND 1080 ALONE MISSED THE TWO LARGEST CURRENT PHONES, WHICH
|
||||||
|
MAKES THIS THE THIRD ITERATION OF THIS LADDER. 1080 was tuned to
|
||||||
|
390 CSS px x DPR 3 (= 1026), and `sizes` resolves to
|
||||||
|
`calc(100vw - 3rem)` up to 528px, so every phone wider than 390
|
||||||
|
overshoots to the next rung: iPhone 14 Plus (428@3, needs 1140)
|
||||||
|
and 15/16 Pro Max (430@3, needs 1146) both took **1440 —
|
||||||
|
48,799 B**, against 27,594 for the device the rung was tuned
|
||||||
|
for. +21,205 B, 13% of page weight. A 1200 rung closes it at
|
||||||
|
1.05x. Measured after: no rung more than 1.06x oversized on the
|
||||||
|
phone axis, and still no upscaling anywhere — 0 upscaling across 11
|
||||||
|
real device profiles on both pages.
|
||||||
|
|
||||||
|
TWO RESIDUAL OVER-FETCHES, LEFT DELIBERATELY, so neither reads as an
|
||||||
|
oversight later. **320@2** needs 544 and takes 640 (1.18x): there is no
|
||||||
|
rung between 480 and 640, and 480 would be a 1.13x UPSCALE, so the
|
||||||
|
oversize is the better half of that trade. **1056@2 needs 760.4 and
|
||||||
|
takes 960 (1.26x, +6.8 KB)** — a knife-edge, and worth stating because
|
||||||
|
it looks like a `sizes` error and is not: the declared `36vw` is
|
||||||
|
accurate to the measured 36.0% track, and 380.2 x 2 = 760.4 misses the
|
||||||
|
760 rung by four tenths of a pixel. Declaring 35vw to duck under it
|
||||||
|
would make `sizes` less truthful across the whole band in exchange for
|
||||||
|
a 0.05% upscale at this width. The declaration stays honest and one
|
||||||
|
viewport over-fetches.
|
||||||
|
|
||||||
|
⚠️ `fetchpriority="high"` IS GONE, AND THIS PAGE HAD IT WHILE
|
||||||
|
`/about/` WITHHELD IT ON THE IDENTICAL MEASUREMENT. The old comment
|
||||||
|
read "eager + fetchpriority=high because this is the LCP candidate on
|
||||||
|
the page docs/04 budgets hardest" — true only from 768px up. Measured,
|
||||||
|
cache cleared per device:
|
||||||
|
|
||||||
|
320x568 @2 portrait visible 0px LCP — 11,058 B
|
||||||
|
360x780 @3 portrait visible 0px LCP — 21,526 B
|
||||||
|
390x844 @3 portrait visible 0px LCP P.hero-lede 27,594 B
|
||||||
|
430x932 @3 portrait visible 30px LCP P.hero-lede 48,799 B
|
||||||
|
1280x900 @1 portrait visible 595px LCP IMG.portrait 7,257 B
|
||||||
|
|
||||||
|
So on mobile — the axis the ≥95 budget is actually measured on — it
|
||||||
|
promoted 27-49 KB of image the reader cannot see above the Geist face
|
||||||
|
that paints the real LCP element. `/about/` already withheld it for
|
||||||
|
exactly this reason and this page did the opposite; the inconsistency
|
||||||
|
is the finding.
|
||||||
|
|
||||||
|
`loading="eager"` STAYS: the portrait is the LCP element from 768px up,
|
||||||
|
and eager costs nothing where it is off-screen. If the desktop LCP ever
|
||||||
|
needs protecting explicitly, the right instrument is
|
||||||
|
`<link rel="preload" imagesrcset imagesizes>` in <head>, which honours
|
||||||
|
`sizes` and therefore self-cancels on phones — not a blanket attribute
|
||||||
|
that cannot. */
|
||||||
|
}
|
||||||
|
<div class="hero-portrait">
|
||||||
|
<Picture
|
||||||
|
src={portrait}
|
||||||
|
width={960}
|
||||||
|
height={960}
|
||||||
|
widths={[380, 480, 640, 760, 960, 1080, 1200, 1440]}
|
||||||
|
sizes="(min-width: 80rem) 476px, (min-width: 66rem) 36vw, (min-width: 33rem) 480px, calc(100vw - 3rem)"
|
||||||
|
formats={['avif', 'webp']}
|
||||||
|
fallbackFormat="jpeg"
|
||||||
|
alt={PORTRAIT.alt}
|
||||||
|
loading="eager"
|
||||||
|
decoding="sync"
|
||||||
|
class="portrait-img"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ---- 2. Credential row --------------------------------------------- */}
|
||||||
|
<section class="wrap credential-band" aria-label="Credentials">
|
||||||
|
<CredentialRow slots={credentials} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{
|
||||||
|
/* NO `aria-labelledby` ON THE CONTENT SECTIONS, and that is the fix for an
|
||||||
|
inconsistency rather than a removal of information.
|
||||||
|
|
||||||
|
Four of them carried one and this one did not, which put five named
|
||||||
|
regions plus header / nav / main / footer / footer-nav in a screen
|
||||||
|
reader's region list — eleven entries for a marketing page — with the only
|
||||||
|
unnamed content section being the odd one out. A `<section>` without an
|
||||||
|
accessible name is not exposed as a region at all, and the visible `<h2>`s
|
||||||
|
already give heading navigation, which is how a reader moves through a
|
||||||
|
page like this.
|
||||||
|
|
||||||
|
THE ONE EXCEPTION IS THE CREDENTIAL BAND, which has no visible heading, so
|
||||||
|
`aria-label` is the only thing that can name it. The rule is: name a region
|
||||||
|
only where it has no heading of its own. */
|
||||||
|
}
|
||||||
|
{/* ---- 3. The approach ----------------------------------------------- */}
|
||||||
|
<section class="section section-inverse approach reveal">
|
||||||
|
<div class="wrap approach-inner">
|
||||||
|
<div class="approach-copy">
|
||||||
|
<SectionHeading eyebrow="The approach" level={2}>
|
||||||
|
<span slot="heading">Two directions at once.</span>
|
||||||
|
</SectionHeading>
|
||||||
|
{
|
||||||
|
/* THIS PARAGRAPH USED TO OPEN "Law and engineering are not blended
|
||||||
|
here", and `claims-auditor` flagged it as Q37's struck parallel
|
||||||
|
relocated from the credential label into prose — a degree and a
|
||||||
|
practice under one noun, one day after Pouya struck exactly that
|
||||||
|
construction. It is a fair reading and the fix is the same fix:
|
||||||
|
make the two halves asymmetric. What a neutral does with a contract
|
||||||
|
is read it; what an engineer does is engineering. Neither sentence
|
||||||
|
now sets "Law" beside "engineering" as two instances of one thing.
|
||||||
|
|
||||||
|
`docs/01` §`/` item 3 and `docs/03` §Home both specify this section
|
||||||
|
as "the 'two directions at once' argument — law and engineering
|
||||||
|
converging on the same dispute", so the ARGUMENT is unchanged and
|
||||||
|
still delivered; only the construction that carried the implication
|
||||||
|
is gone.
|
||||||
|
|
||||||
|
**Q41(a) CLOSED 2026-08-27: Q37's reasoning DOES extend to prose,
|
||||||
|
and prose has to do more than avoid the parallel.** Pouya: *"The
|
||||||
|
implication test applies everywhere, not just to labels. Prose has
|
||||||
|
more room, so it is easier to satisfy: state the asymmetry
|
||||||
|
explicitly rather than relying on a parallel construction to carry
|
||||||
|
it."* Avoiding the pair was therefore only half the fix — a reader
|
||||||
|
can still supply the missing symmetry from silence. So the second
|
||||||
|
paragraph now names both halves for what they are: training on one
|
||||||
|
side, current work on the other. "Training I hold" is the opposite
|
||||||
|
of a licence claim, which is the point of saying it out loud.
|
||||||
|
|
||||||
|
`docs/01`'s and `docs/03`'s own phrase "law and engineering" is the
|
||||||
|
struck construction; both now carry a note not to lift it into copy.
|
||||||
|
The argument it names is Pouya's and stands. */
|
||||||
|
}
|
||||||
|
<div class="prose approach-prose">
|
||||||
|
<p>
|
||||||
|
Any dispute I take gets read twice: once against the documents, and
|
||||||
|
once as engineering. The two readings are not blended here. They run
|
||||||
|
at the same time.
|
||||||
|
</p>
|
||||||
|
{
|
||||||
|
/* FROM A CONSTANT, NOT TYPED. It was typed here and then typed
|
||||||
|
again on `/about/`, and the two copies had ALREADY diverged inside
|
||||||
|
one session — a comma here, full stops there. Q41(a) makes this
|
||||||
|
the sentence responsible for foreclosing the licence implication,
|
||||||
|
so it is the worst string on the site to let drift. See
|
||||||
|
ASYMMETRY_LINE in src/data/site.ts. */
|
||||||
|
}
|
||||||
|
<p>{ASYMMETRY_LINE}</p>
|
||||||
|
{
|
||||||
|
/* TWO COMPARATIVES CAME OUT OF THESE PARAGRAPHS ON 2026-08-28, and
|
||||||
|
both had SURVIVED the sweep that closed Q41(b) the day before:
|
||||||
|
|
||||||
|
"The second half of each pair usually arrives as a separate
|
||||||
|
expert report." — an empirical claim about how disputes are
|
||||||
|
usually run, i.e. about a population of other matters.
|
||||||
|
"it is why the technical half is not something a party has to
|
||||||
|
commission and wait for" — the same claim in counterfactual
|
||||||
|
form, which is harder to spot and says more.
|
||||||
|
|
||||||
|
Pouya's ruling on Q41(b) is the test: *"assert his capability, not
|
||||||
|
the field's incapability."* Both worked by asserting the field's.
|
||||||
|
What replaces them says only what he does, which is the stronger
|
||||||
|
claim anyway — and it is shorter. */
|
||||||
|
}
|
||||||
|
<p>
|
||||||
|
A construction claim is a contract question and a scheduling
|
||||||
|
question. A software dispute is a licence question and an
|
||||||
|
architecture question. A grid connection is a regulatory question
|
||||||
|
and a load question.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
I read both halves of each pair myself. That is the whole of it.
|
||||||
|
</p>
|
||||||
|
<p class="approach-metaphor">
|
||||||
|
My mark is an infinity loop, and it is the argument in one line:
|
||||||
|
disputes are loops. The work is redrawing the loop into a line.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{
|
||||||
|
/* Decorative: the paragraph beside it says what it is, so an
|
||||||
|
accessible name here would be read twice. Measured on this ground —
|
||||||
|
the ribbon's champagne half carries the silhouette against ink, the
|
||||||
|
maroon half against cream; it reads on both. */
|
||||||
|
}
|
||||||
|
<div class="approach-mark">
|
||||||
|
{
|
||||||
|
/* width=232 and loading=lazy are both measured, not defaults.
|
||||||
|
`size` caps at 9rem tall, so the mark renders at up to 144 x 225.5
|
||||||
|
CSS px — five times the header's 50.1px, and the component's default
|
||||||
|
64px ladder tops out at 192px. Measured upscale before this: 1.17x
|
||||||
|
at DPR 1, 2.35x at DPR 2, 3.52x at DPR 3. 232 with densities
|
||||||
|
[1,2,3] gives 232/464/696, and 696 covers the 676 device px a DPR-3
|
||||||
|
screen asks for. Lazy because this sits roughly a screen and a half
|
||||||
|
down; the component defaults to eager for the masthead. */
|
||||||
|
}
|
||||||
|
<InfinityMark
|
||||||
|
size="clamp(4rem, 14vw, 9rem)"
|
||||||
|
width={232}
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ---- 4. Two practices --------------------------------------------- */}
|
||||||
|
<section class="section practices reveal">
|
||||||
|
<div class="wrap">
|
||||||
|
{
|
||||||
|
/* The id goes on a span INSIDE the h2 via the `heading` slot, so
|
||||||
|
`aria-labelledby` names the visible heading. The first version passed
|
||||||
|
`title=` and additionally rendered a `.visually-hidden` span carrying
|
||||||
|
the id, which put the same words in the accessibility tree twice —
|
||||||
|
found in the heading-outline dump, not by reading the source. */
|
||||||
|
}
|
||||||
|
{
|
||||||
|
/* THE WRAPPER IS THE FIX, NOT DECORATION. This was
|
||||||
|
`<SectionHeading class="section-head">`, and the page's rule compiled
|
||||||
|
to `.section-head[data-astro-cid-<page>]` while the rendered root
|
||||||
|
carried SectionHeading's own cid — so it never matched. Measured:
|
||||||
|
`margin-block-end: 0px` and a 0px gap to the cards on all three call
|
||||||
|
sites, with `.display`'s 0.98 line-height putting the glyphs over the
|
||||||
|
card's top edge. `astro check` 0 errors, `eslint` clean, and the source
|
||||||
|
looked right. Fourth instance of this on the project; SectionHeading no
|
||||||
|
longer accepts a `class` at all, and passing one is now a build
|
||||||
|
error. */
|
||||||
|
}
|
||||||
|
<div class="section-head">
|
||||||
|
<SectionHeading eyebrow="What I do" level={2}>
|
||||||
|
<span slot="heading">Two processes.</span>
|
||||||
|
</SectionHeading>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pair">
|
||||||
|
<article class="feature">
|
||||||
|
<h3 class="feature-title">
|
||||||
|
<a href="/mediation/">Mediation</a>
|
||||||
|
</h3>
|
||||||
|
{
|
||||||
|
/* THE FEE CLAIM IS GONE, and it was wrong on two counts —
|
||||||
|
`claims-auditor`, 2026-08-27, checked against `docs/07-fees.md`.
|
||||||
|
"at one published rate" reads as ONE PRICE for half and full day;
|
||||||
|
D14's card sets TWO ($2,000 half, $4,000 full). And "preparation
|
||||||
|
time included" was unqualified where `docs/07` bundles a CAPPED
|
||||||
|
allowance and says in terms: *"must be stated on the page —
|
||||||
|
'including 2 hours of preparation' ... Do not quietly fold it into
|
||||||
|
the hours figure. At these rates, saying preparation is included
|
||||||
|
is the selling point, not a footnote."*
|
||||||
|
|
||||||
|
A home card is the wrong place to state it properly, and stating
|
||||||
|
it improperly misdescribes money. `/mediation/` (step 4) and
|
||||||
|
`/fees/` (step 9) carry the card. "Published" was also
|
||||||
|
forward-looking: `/fees/` does not exist yet. */
|
||||||
|
}
|
||||||
|
<p class="feature-body">
|
||||||
|
Sole mediator, Q.Med through ADRIC and ADRIO, with multiple
|
||||||
|
completed sole mediations. Half day or full day, in person or by
|
||||||
|
video.
|
||||||
|
</p>
|
||||||
|
<span class="feature-arrow" aria-hidden="true">→</span>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="feature">
|
||||||
|
<h3 class="feature-title">
|
||||||
|
<a href="/arbitration/">Arbitration</a>
|
||||||
|
</h3>
|
||||||
|
{
|
||||||
|
/* docs/03's model sentence, and BOTH halves are required: §4
|
||||||
|
Offerings — "neither half may be dropped". Pouya's instruction:
|
||||||
|
being open about the stage is the differentiator, so it is not
|
||||||
|
hedged into vagueness and not dropped. Commercial matters only
|
||||||
|
(Q39): family arbitration is not offered. */
|
||||||
|
}
|
||||||
|
<p class="feature-body">
|
||||||
|
I accept sole, party-appointed and co-arbitration appointments in
|
||||||
|
commercial matters. The Q.Arb pathway commenced in August 2026;
|
||||||
|
C.Med-Arb is the endpoint.
|
||||||
|
</p>
|
||||||
|
<span class="feature-arrow" aria-hidden="true">→</span>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{
|
||||||
|
/* docs/01 §`/` item 4: "Med-Arb named here as the long-term arc,
|
||||||
|
linking to /med-arb/." It has had its own §4 Offerings row since
|
||||||
|
2026-08-27 (Q35), so it is named as offered rather than only as an
|
||||||
|
aspiration — but the arc is what docs/01 asks this page to carry. */
|
||||||
|
}
|
||||||
|
<p class="pair-note">
|
||||||
|
<strong>Med-Arb</strong> combines the two: one neutral mediates, then arbitrates
|
||||||
|
whatever has not settled. I accept those appointments, and C.Med-Arb is the
|
||||||
|
designation endpoint. The page on it meets the procedural-fairness objection
|
||||||
|
head on rather than around it —
|
||||||
|
<a href="/med-arb/">how med-arb works →</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ---- 5. Practice areas -------------------------------------------- */}
|
||||||
|
{
|
||||||
|
/* docs/01: "the most important block on the page for search, because it
|
||||||
|
distributes authority to the pages that can actually rank." */
|
||||||
|
}
|
||||||
|
<section class="section section-alt areas reveal">
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="section-head">
|
||||||
|
<SectionHeading
|
||||||
|
eyebrow="Practice areas"
|
||||||
|
level={2}
|
||||||
|
lede="Six areas, chosen because the disputes in them turn on documents I can read without an intermediary."
|
||||||
|
>
|
||||||
|
<span slot="heading">Where the work is.</span>
|
||||||
|
</SectionHeading>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{
|
||||||
|
/* reveal-stagger is capped at six children by design (docs/02) and there
|
||||||
|
are exactly six. A seventh would land with the sixth, not break. */
|
||||||
|
}
|
||||||
|
<div class="area-grid reveal-stagger">
|
||||||
|
{
|
||||||
|
PRACTICE_AREAS.map((area) => (
|
||||||
|
<PracticeCard
|
||||||
|
href={`/practice/${area.slug}/`}
|
||||||
|
chip={area.chip}
|
||||||
|
title={area.name}
|
||||||
|
level={3}
|
||||||
|
>
|
||||||
|
{area.blurb}
|
||||||
|
</PracticeCard>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{
|
||||||
|
/* THIS SAID "All six areas, and what else is offered", which asserted
|
||||||
|
offerings beyond the six, none of which had a §4 row.
|
||||||
|
|
||||||
|
**Q42 CLOSED 2026-08-27, and one of the four candidates came out.**
|
||||||
|
Early neutral evaluation, dispute-system design and pre-dispute
|
||||||
|
technical advisory now have Offerings rows. **Settlement counsel does
|
||||||
|
not, and never will** — Pouya struck it as his own error in `docs/01`:
|
||||||
|
*"Settlement counsel acts FOR a party in negotiation. That is a
|
||||||
|
partisan role, and putting it on a site that (a) sells neutrality and
|
||||||
|
(b) asserts no licensure under D13 is wrong twice over."*
|
||||||
|
|
||||||
|
THIS LINE STILL READS "All six practice areas" and that is unchanged
|
||||||
|
on purpose. The three rowed processes are `/practice/`'s "also
|
||||||
|
offered" strip at step 5, not a claim `/` makes in a link label — a
|
||||||
|
six-card grid followed by "and what else is offered" is the
|
||||||
|
Med-Arb-in-the-footer shape whether or not the rows exist. */
|
||||||
|
}
|
||||||
|
<p class="areas-more">
|
||||||
|
<a href="/practice/">All six practice areas →</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ---- 6. Process preview ------------------------------------------- */}
|
||||||
|
<section class="section process reveal">
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="section-head">
|
||||||
|
<SectionHeading
|
||||||
|
eyebrow="How it runs"
|
||||||
|
level={2}
|
||||||
|
lede="Five steps, from the first call to the conclusion — including what happens if the matter does not settle."
|
||||||
|
>
|
||||||
|
<span slot="heading">From first call to conclusion.</span>
|
||||||
|
</SectionHeading>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ol class="process-strip" role="list">
|
||||||
|
{
|
||||||
|
PROCESS.map((step, i) => (
|
||||||
|
<ProcessStep n={i + 1} title={step.title} timing={step.timing}>
|
||||||
|
{step.body}
|
||||||
|
</ProcessStep>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
{
|
||||||
|
/* NOT OPTIONAL, AND NOT A DISCLAIMER — Q43, Pouya 2026-08-27. It is the
|
||||||
|
condition on which the five timings may be published at all: *"Published
|
||||||
|
as typical, they are honest and useful; published as commitments, the
|
||||||
|
first matter that slips makes the page false."* It sits directly under
|
||||||
|
the numbers rather than in the section lede above them, because a
|
||||||
|
reader who scans the strip and skips the lede has read a commitment.
|
||||||
|
`/process/` renders the same constant at step 6. */
|
||||||
|
}
|
||||||
|
<p class="process-framing">{PROCESS_FRAMING}</p>
|
||||||
|
|
||||||
|
<p class="process-more">
|
||||||
|
<a href="/process/">What happens if the matter does not settle →</a
|
||||||
|
>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ---- 7. Latest insights: NOT BUILT AT STEP 2. See the header note. -- */}
|
||||||
|
|
||||||
|
{/* ---- 8. Contact band ---------------------------------------------- */}
|
||||||
|
{
|
||||||
|
/* A COMPONENT SINCE 2026-08-28. It was ~20 lines of markup plus ~20 of CSS
|
||||||
|
here and the same again on `/about/`, and the two had already drifted
|
||||||
|
(`.contact-body` at 52ch here, 46ch there) inside the session that wrote
|
||||||
|
the second one. Seventeen pages remain. docs/01 item 8's missing booking
|
||||||
|
link is documented in the component, once. */
|
||||||
|
}
|
||||||
|
<ContactBand />
|
||||||
|
</BaseLayout>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* --- 1. Hero -------------------------------------------------------- */
|
||||||
|
|
||||||
|
.hero {
|
||||||
|
padding-block: var(--space-8) var(--space-9);
|
||||||
|
}
|
||||||
|
.hero-inner {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-7);
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.hero-copy {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-5);
|
||||||
|
}
|
||||||
|
.hero-h {
|
||||||
|
/* --text-6xl is the display ceiling. At 360px it is 52px and the headline
|
||||||
|
runs four lines; `text-wrap: balance` (global.css) keeps them even. */
|
||||||
|
font-size: var(--text-6xl);
|
||||||
|
max-inline-size: 22ch;
|
||||||
|
}
|
||||||
|
.hero-lede {
|
||||||
|
max-inline-size: var(--width-prose);
|
||||||
|
font-size: var(--text-lg);
|
||||||
|
line-height: var(--leading-relaxed);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
.hero-cta {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-4);
|
||||||
|
margin-block-start: var(--space-2);
|
||||||
|
}
|
||||||
|
/* The 4:5 crop is CSS, not a build step: Astro's image service does not crop,
|
||||||
|
and cropping offline would mean committing a second derived binary on top
|
||||||
|
of the ~3.16 MB of brand and portrait masters already in the repo. The cost
|
||||||
|
is that a browser downloads the full square and shows 80% of it; the source
|
||||||
|
is 1600 x 1600 and the largest variant generated is 960 wide. */
|
||||||
|
.hero-portrait {
|
||||||
|
aspect-ratio: 4 / 5;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
background: var(--bg-raised);
|
||||||
|
/* THE CAP IS THE FIX FOR THE UPSCALE, not a style preference. Uncapped, the
|
||||||
|
single-column slot ran to 928 CSS px at a 1024px viewport, which is 1856
|
||||||
|
device px at DPR 2 against a 960w ceiling — 1.93x. 30rem makes 480 the
|
||||||
|
widest real slot on any page, so the ladder's 960 covers DPR 2 exactly
|
||||||
|
and the new 1440 rung covers DPR 3. See the <Picture> comment above.
|
||||||
|
`margin-inline: auto` because a 480px box in a 928px column would
|
||||||
|
otherwise sit hard against the inline start. */
|
||||||
|
max-inline-size: 30rem;
|
||||||
|
margin-inline: auto;
|
||||||
|
}
|
||||||
|
/* The <picture> wrapper is the box that gets sized, NOT the <img> — the exact
|
||||||
|
defect CLAUDE.md records for <Button> and then for <Picture> inside
|
||||||
|
InfinityMark. `class="portrait-img"` lands on the <img>, so the <img> rule
|
||||||
|
below is reached via Astro's cid on the emitted element, and the wrapper is
|
||||||
|
targeted by the bare `picture` selector, which the markup does carry. */
|
||||||
|
.hero-portrait :global(picture) {
|
||||||
|
display: block;
|
||||||
|
block-size: 100%;
|
||||||
|
}
|
||||||
|
.portrait-img {
|
||||||
|
inline-size: 100%;
|
||||||
|
block-size: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
/* Above centre: the subject's head is in the upper half of a square crop. */
|
||||||
|
object-position: 50% 22%;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 66rem) {
|
||||||
|
.hero-portrait {
|
||||||
|
/* Above 66rem the grid track is already 390-476px, so the cap is inert —
|
||||||
|
released anyway so the track, not this number, governs the two-column
|
||||||
|
layout. Keeping it would silently become the constraint if the track
|
||||||
|
ever widened. */
|
||||||
|
max-inline-size: none;
|
||||||
|
}
|
||||||
|
.hero {
|
||||||
|
padding-block: var(--space-9);
|
||||||
|
}
|
||||||
|
.hero-inner {
|
||||||
|
/* 1.15 / 0.85 — the copy column carries a 22ch headline and a 68ch lede;
|
||||||
|
an even split starves the headline and leaves the portrait oversized. */
|
||||||
|
grid-template-columns: 1.15fr 0.85fr;
|
||||||
|
gap: var(--space-8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- 2. Credential row ---------------------------------------------- */
|
||||||
|
|
||||||
|
.credential-band {
|
||||||
|
/* No .section wrapper: the row owns its own padding-block and border, and
|
||||||
|
stacking --section-y on top would put 160px of air around a 4-line band. */
|
||||||
|
padding-block-end: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- 3. The approach ------------------------------------------------ */
|
||||||
|
|
||||||
|
.approach-inner {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-7);
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
/* NO `display: flex; gap` ANY MORE, AND THAT IS A CORRECTION TO A FIX.
|
||||||
|
global.css gained `:where(.prose) > p + p { margin-block-start }` on
|
||||||
|
2026-08-28 because a bare `.prose` block had no paragraph spacing at all.
|
||||||
|
The new rule's comment asserted "with `:where()` the flex container's gap
|
||||||
|
governs and this contributes nothing" — **false, and it was measured false
|
||||||
|
immediately after being written.** `:where()` controls SPECIFICITY, not
|
||||||
|
whether a declaration applies: nothing here was overriding the margin, so
|
||||||
|
flex `gap` and the margin both applied and this block's paragraph gaps went
|
||||||
|
**24px -> 48px**. Spacing now comes from the one global rule, which is the
|
||||||
|
point of having it. */
|
||||||
|
.approach-prose {
|
||||||
|
margin-block-start: var(--space-6);
|
||||||
|
font-size: var(--text-lg);
|
||||||
|
line-height: var(--leading-relaxed);
|
||||||
|
}
|
||||||
|
.approach-metaphor {
|
||||||
|
padding-block-start: var(--space-5);
|
||||||
|
border-block-start: 1px solid var(--rule);
|
||||||
|
font-family: var(--font-serif);
|
||||||
|
font-size: var(--text-xl);
|
||||||
|
line-height: var(--leading-snug);
|
||||||
|
color: var(--text-inverse-2);
|
||||||
|
}
|
||||||
|
.approach-mark {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
@media (min-width: 66rem) {
|
||||||
|
.approach-inner {
|
||||||
|
grid-template-columns: 1fr auto;
|
||||||
|
gap: var(--space-9);
|
||||||
|
}
|
||||||
|
/* Mark second in the DOM and second visually. No `order` anywhere on this
|
||||||
|
page: reordering flex or grid items puts focus order out of step with
|
||||||
|
visual order, which is what WCAG 2.4.3 and docs/02 both forbid, and it
|
||||||
|
already cost a header rebuild at step 1. */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- 4. Two practices ---------------------------------------------- */
|
||||||
|
|
||||||
|
/* NO `.section-head` RULE — it moved to `global.css`, where `.prose` lives.
|
||||||
|
It was byte-identical in both pages, and every one of the seventeen
|
||||||
|
remaining pages needs it for the same reason (a parent cannot style a child
|
||||||
|
component's root, so the wrapper must be page-owned). */
|
||||||
|
.pair {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-5);
|
||||||
|
}
|
||||||
|
.feature {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: var(--space-4);
|
||||||
|
/* CLAMPED, not a flat --space-7. At a 200% default font size (root 32px)
|
||||||
|
`3rem` is 96px a side — 192px of horizontal padding inside a 342px
|
||||||
|
container, which was most of the 83px residual overflow
|
||||||
|
`adversarial-reviewer` measured. The vw term keeps it at 48px on any real
|
||||||
|
viewport and lets it collapse when the rem is doubled. */
|
||||||
|
padding: clamp(var(--space-5), 4vw, var(--space-7));
|
||||||
|
background: var(--bg-raised);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
transition: background-color var(--dur-hover) var(--ease);
|
||||||
|
}
|
||||||
|
.feature:hover {
|
||||||
|
background: var(--cream-2);
|
||||||
|
}
|
||||||
|
.feature-title {
|
||||||
|
font-family: var(--font-serif);
|
||||||
|
font-size: var(--text-3xl);
|
||||||
|
line-height: var(--leading-tight);
|
||||||
|
letter-spacing: var(--tracking-tight);
|
||||||
|
/* `overflow-wrap: break-word` (global.css) permits a break at layout time
|
||||||
|
but does NOT reduce min-content size, so "Arbitration" at a 60px
|
||||||
|
--text-3xl held the card open. `anywhere` does reduce it. Only reachable
|
||||||
|
at a large default font size; at every real size the word never breaks. */
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.feature-title a {
|
||||||
|
color: var(--text);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
/* One link per card, hit area the whole card, accessible name the heading —
|
||||||
|
same pattern and same reasoning as PracticeCard. */
|
||||||
|
.feature-title a::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
}
|
||||||
|
.feature:hover .feature-title a {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
.feature:has(a:focus-visible) {
|
||||||
|
outline: 2px solid var(--focus-ring);
|
||||||
|
outline-offset: var(--focus-offset);
|
||||||
|
}
|
||||||
|
.feature-title a:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.feature-body {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
max-inline-size: 46ch;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
.feature-arrow {
|
||||||
|
font-size: var(--text-xl);
|
||||||
|
line-height: 1;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
.pair-note {
|
||||||
|
max-inline-size: var(--width-prose);
|
||||||
|
margin-block-start: var(--space-6);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
@media (min-width: 56rem) {
|
||||||
|
.pair {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: var(--space-6);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- 5. Practice areas --------------------------------------------- */
|
||||||
|
|
||||||
|
.area-grid {
|
||||||
|
display: grid;
|
||||||
|
/* auto-fit with an 18rem floor: 1 up on a phone, 2 up on a tablet, 3 up on
|
||||||
|
a desktop, with no breakpoint of its own. The cards size themselves to
|
||||||
|
the cell (see PracticeCard) — a parent cannot reach a child's root.
|
||||||
|
|
||||||
|
`min(18rem, 100%)`: a bare rem floor is a hard minimum, so at a large
|
||||||
|
default font size (root 32px) 18rem becomes 576px and the track will not
|
||||||
|
shrink. Measured at root 200%: this grid overflowed a 390px viewport by
|
||||||
|
234px. See CredentialRow for the same guard and the full reasoning. */
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(min(18rem, 100%), 1fr));
|
||||||
|
gap: var(--space-5);
|
||||||
|
}
|
||||||
|
/* THESE TWO LINKS STAND ALONE ON THEIR OWN LINE, so docs/02's 44px touch
|
||||||
|
floor applies to them in full. Measured before this rule: 250.8 x 18 and
|
||||||
|
287.9 x 18 — the paragraph's line box and nothing more.
|
||||||
|
|
||||||
|
`inline-flex` + min-block-size rather than padding, so the 44px IS the hit
|
||||||
|
area rather than visual air, and `inline-size: fit-content` keeps the
|
||||||
|
target the width of the words instead of the whole measure.
|
||||||
|
|
||||||
|
The INLINE link in `.pair-note` ("how med-arb works") is deliberately NOT
|
||||||
|
given this treatment: it sits mid-sentence at 164 x 21, and WCAG 2.5.8
|
||||||
|
exempts a target "in a sentence or its size is otherwise constrained by the
|
||||||
|
line-height of non-target text". Padding it out would break the paragraph's
|
||||||
|
leading to satisfy a rule that does not apply to it. A deviation from
|
||||||
|
docs/02's flat wording, taken deliberately and recorded rather than left to
|
||||||
|
look like an oversight.
|
||||||
|
|
||||||
|
The card heading links measure 26-39px tall and are NOT a finding: each
|
||||||
|
card's whole box is the link's hit area via `::after { inset: 0 }`. Verified
|
||||||
|
by hit-testing nine points per card at 390 / 768 / 1280px — 24 cards, 9/9
|
||||||
|
inside the link every time. The width sweep flagged them because it
|
||||||
|
measured the <a>'s own box, which is not the target. */
|
||||||
|
.areas-more,
|
||||||
|
.process-more {
|
||||||
|
margin-block-start: var(--space-6);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
.areas-more a,
|
||||||
|
.process-more a {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
inline-size: fit-content;
|
||||||
|
min-block-size: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- 6. Process preview -------------------------------------------- */
|
||||||
|
|
||||||
|
.process-strip {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(min(13rem, 100%), 1fr));
|
||||||
|
gap: var(--space-5);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
/* --space-5 rather than --space-6: this belongs to the strip above it, not to
|
||||||
|
the link below.
|
||||||
|
|
||||||
|
THE RATIO IN THIS COMMENT WAS WRONG WHEN FIRST WRITTEN. It said
|
||||||
|
"--text-meta on cream measures 3.07:1". It does not: `#6e6359` on `#faf7f2`
|
||||||
|
measures **5.47:1** and passes AA. 3.07:1 is `--muted` on **ink**, which is
|
||||||
|
exactly what `tokens.css` says and what this comment misread. So the
|
||||||
|
*reason* given was false even though the *choice* is right —
|
||||||
|
--text-secondary (10.76-11.75:1) is correct here because this sentence is a
|
||||||
|
CONDITION on the numbers above it, not metadata about them, and a condition
|
||||||
|
has to read like body copy. Corrected 2026-08-28 on
|
||||||
|
`adversarial-reviewer`'s measurement. */
|
||||||
|
.process-framing {
|
||||||
|
margin-block-start: var(--space-5);
|
||||||
|
max-inline-size: var(--width-prose);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* NO CONTACT-BAND RULES HERE. `.contact-inner`, `.contact-h`, `.contact-body`
|
||||||
|
and `.contact-action` moved to `ContactBand.astro` with the markup they
|
||||||
|
style. They were left behind after the extraction — dead CSS shipping to
|
||||||
|
every visitor, and worse, the **46ch / 52ch divergence the extraction
|
||||||
|
existed to end was still sitting on disk in both pages**, so the next reader
|
||||||
|
would have found two different values and no rendered difference. Deleted
|
||||||
|
2026-08-28. */
|
||||||
|
</style>
|
||||||
+487
-75
@@ -5,44 +5,127 @@
|
|||||||
@import './tokens.css';
|
@import './tokens.css';
|
||||||
|
|
||||||
/* --- Fonts: self-hosted, subset, swap. No runtime Google Fonts request. ----
|
/* --- Fonts: self-hosted, subset, swap. No runtime Google Fonts request. ----
|
||||||
TODO(claude-code): place subset woff2 files in /public/fonts/ and preload
|
Files and their provenance: docs/reference/fonts-provenance.md.
|
||||||
Instrument Serif 400 and Geist 400 in BaseLayout — they are the only two
|
Filenames are stable on purpose — a preload needs a path that does not change
|
||||||
faces used above the fold. */
|
between builds, which rules out Astro's hashed asset pipeline.
|
||||||
|
|
||||||
|
`?v=1` IS LOAD-BEARING. scripts/deploy-local.sh serves /fonts/* with
|
||||||
|
`max-age=31536000, immutable`, so a returning visitor holds these bytes for a
|
||||||
|
year and a CloudFront invalidation cannot reach their browser cache. Bump the
|
||||||
|
query when a file's contents change — here AND on the preload in
|
||||||
|
BaseLayout.astro, which must match byte for byte or the preload is a second,
|
||||||
|
wasted request instead of a warmed cache.
|
||||||
|
|
||||||
|
The `latin` cut of each face is listed FIRST and the `latin-ext` cut second.
|
||||||
|
Order matters: where two @font-face rules for one family both match a
|
||||||
|
codepoint, the last wins. Latin-ext is the wider, heavier file; putting it
|
||||||
|
last would hand it every ASCII character on the page. */
|
||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'Instrument Serif';
|
font-family: 'Instrument Serif';
|
||||||
src: url('/fonts/instrument-serif-400.woff2') format('woff2');
|
src: url('/fonts/instrument-serif-latin-400-normal.woff2?v=1') format('woff2');
|
||||||
font-weight: 400; font-style: normal; font-display: swap;
|
font-weight: 400;
|
||||||
unicode-range: U+0000-00FF, U+0100-017F, U+2000-206F, U+2190-21BB;
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
unicode-range:
|
||||||
|
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC,
|
||||||
|
U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212,
|
||||||
|
U+2215, U+FEFF, U+FFFD;
|
||||||
}
|
}
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'Instrument Serif';
|
font-family: 'Instrument Serif';
|
||||||
src: url('/fonts/instrument-serif-400-italic.woff2') format('woff2');
|
src: url('/fonts/instrument-serif-latin-ext-400-normal.woff2?v=1')
|
||||||
font-weight: 400; font-style: italic; font-display: swap;
|
format('woff2');
|
||||||
unicode-range: U+0000-00FF, U+0100-017F, U+2000-206F;
|
font-weight: 400;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
unicode-range:
|
||||||
|
U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304,
|
||||||
|
U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB,
|
||||||
|
U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||||
|
}
|
||||||
|
/* Italic is the one flourish the design allows (docs/02) — a phrase inside a
|
||||||
|
headline, never a paragraph. Latin only; there is no latin-ext italic file. */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Instrument Serif';
|
||||||
|
src: url('/fonts/instrument-serif-latin-400-italic.woff2?v=1') format('woff2');
|
||||||
|
font-weight: 400;
|
||||||
|
font-style: italic;
|
||||||
|
font-display: swap;
|
||||||
|
unicode-range:
|
||||||
|
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC,
|
||||||
|
U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212,
|
||||||
|
U+2215, U+FEFF, U+FFFD;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Geist and Geist Mono are variable fonts: one file spans the whole weight
|
||||||
|
axis, so 300/400/500/600 cost nothing extra. `font-weight: 100 900` declares
|
||||||
|
the axis range the file actually carries — narrowing it here would make the
|
||||||
|
browser synthesise weights it already has. */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Geist';
|
||||||
|
src: url('/fonts/geist-latin-wght-normal.woff2?v=1')
|
||||||
|
format('woff2-variations');
|
||||||
|
font-weight: 100 900;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
unicode-range:
|
||||||
|
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC,
|
||||||
|
U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212,
|
||||||
|
U+2215, U+FEFF, U+FFFD;
|
||||||
}
|
}
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'Geist';
|
font-family: 'Geist';
|
||||||
src: url('/fonts/geist-variable.woff2') format('woff2-variations');
|
src: url('/fonts/geist-latin-ext-wght-normal.woff2?v=1')
|
||||||
font-weight: 300 600; font-style: normal; font-display: swap;
|
format('woff2-variations');
|
||||||
unicode-range: U+0000-00FF, U+0100-017F, U+2000-206F, U+2190-21BB;
|
font-weight: 100 900;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
unicode-range:
|
||||||
|
U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304,
|
||||||
|
U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB,
|
||||||
|
U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||||
}
|
}
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'Geist Mono';
|
font-family: 'Geist Mono';
|
||||||
src: url('/fonts/geist-mono-variable.woff2') format('woff2-variations');
|
src: url('/fonts/geist-mono-latin-wght-normal.woff2?v=1')
|
||||||
font-weight: 400 500; font-style: normal; font-display: swap;
|
format('woff2-variations');
|
||||||
unicode-range: U+0000-00FF, U+2000-206F;
|
font-weight: 100 900;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
unicode-range:
|
||||||
|
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC,
|
||||||
|
U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212,
|
||||||
|
U+2215, U+FEFF, U+FFFD;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --- Reset ---------------------------------------------------------------- */
|
/* --- Reset ---------------------------------------------------------------- */
|
||||||
|
|
||||||
*, *::before, *::after { box-sizing: border-box; }
|
*,
|
||||||
* { margin: 0; }
|
*::before,
|
||||||
|
*::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
html {
|
html {
|
||||||
-webkit-text-size-adjust: 100%;
|
-webkit-text-size-adjust: 100%;
|
||||||
scroll-behavior: smooth;
|
scroll-behavior: smooth;
|
||||||
scroll-padding-top: var(--space-8);
|
/* No offset by default: below 66rem the header is not sticky, so nothing is
|
||||||
|
covering the target. See the media query below. */
|
||||||
|
scroll-padding-top: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The header is sticky from 66rem up, and `scroll-padding-top` has to clear it
|
||||||
|
or "Skip to content" drops the reader behind it — the one control that exists
|
||||||
|
specifically for keyboard users, landing them on content they cannot see.
|
||||||
|
--header-h is defined in tokens.css beside the value it has to match. */
|
||||||
|
@media (min-width: 66rem) {
|
||||||
|
html {
|
||||||
|
scroll-padding-top: calc(var(--header-h) + var(--space-4));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
@@ -55,19 +138,56 @@ body {
|
|||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
text-rendering: optimizeLegibility;
|
text-rendering: optimizeLegibility;
|
||||||
overflow-x: hidden;
|
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
|
/* No `overflow-x: hidden`. It was here, and it was hiding a real defect: at
|
||||||
|
320px the page overflowed by 72px and three cells sat outside the viewport
|
||||||
|
with no scrollbar to reach them — WCAG 1.4.10 content loss, silently
|
||||||
|
masked. A global overflow clamp turns every future layout bug on nineteen
|
||||||
|
pages into an invisible one. Fix the overflow; do not hide it. */
|
||||||
}
|
}
|
||||||
|
|
||||||
img, picture, video, canvas, svg { display: block; max-width: 100%; }
|
img,
|
||||||
img { height: auto; }
|
picture,
|
||||||
input, button, textarea, select { font: inherit; color: inherit; }
|
video,
|
||||||
p, h1, h2, h3, h4, h5, h6 { overflow-wrap: break-word; }
|
canvas,
|
||||||
ul[role='list'], ol[role='list'] { list-style: none; padding: 0; }
|
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 ----------------------------------------------------------------- */
|
/* --- Type ----------------------------------------------------------------- */
|
||||||
|
|
||||||
h1, h2, h3, h4 { font-weight: var(--weight-normal); text-wrap: balance; }
|
h1,
|
||||||
|
h2,
|
||||||
|
h3,
|
||||||
|
h4 {
|
||||||
|
font-weight: var(--weight-normal);
|
||||||
|
text-wrap: balance;
|
||||||
|
}
|
||||||
|
|
||||||
.display {
|
.display {
|
||||||
font-family: var(--font-serif);
|
font-family: var(--font-serif);
|
||||||
@@ -76,7 +196,9 @@ h1, h2, h3, h4 { font-weight: var(--weight-normal); text-wrap: balance; }
|
|||||||
letter-spacing: var(--tracking-display);
|
letter-spacing: var(--tracking-display);
|
||||||
}
|
}
|
||||||
/* The one flourish the design allows. One italic phrase per headline, max. */
|
/* The one flourish the design allows. One italic phrase per headline, max. */
|
||||||
.display .it { font-style: italic; }
|
.display .it {
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
.eyebrow {
|
.eyebrow {
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
@@ -89,26 +211,38 @@ h1, h2, h3, h4 { font-weight: var(--weight-normal); text-wrap: balance; }
|
|||||||
/* An eyebrow is a label, never the page's heading element. */
|
/* An eyebrow is a label, never the page's heading element. */
|
||||||
.eyebrow .dot {
|
.eyebrow .dot {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
inline-size: 6px; block-size: 6px;
|
inline-size: 6px;
|
||||||
|
block-size: 6px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
margin-inline-end: var(--space-3);
|
margin-inline-end: var(--space-3);
|
||||||
vertical-align: 0.15em;
|
vertical-align: 0.15em;
|
||||||
}
|
}
|
||||||
|
|
||||||
p { max-inline-size: var(--width-prose); }
|
/* NO GLOBAL `p { max-inline-size }`. It was here, and it capped every paragraph
|
||||||
|
on the site — inside cards, footers, and form hints — so components had to
|
||||||
|
opt back out one by one, and it made `.prose` below a class with no effect,
|
||||||
|
since every <p> was already capped. Long-form opts IN. */
|
||||||
|
|
||||||
a { color: var(--link); text-decoration-thickness: 1px; text-underline-offset: 0.2em; }
|
a {
|
||||||
a:hover { color: var(--accent-hover); }
|
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, always. The previous build removed it globally. ------- */
|
||||||
|
|
||||||
:focus-visible {
|
:focus-visible {
|
||||||
outline: 2px solid var(--focus-ring);
|
outline: 2px solid var(--focus-ring);
|
||||||
outline-offset: 3px;
|
outline-offset: var(--focus-offset);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
}
|
}
|
||||||
:focus:not(:focus-visible) { outline: none; }
|
:focus:not(:focus-visible) {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
.skip-link {
|
.skip-link {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -122,71 +256,349 @@ a:hover { color: var(--accent-hover); }
|
|||||||
transform: translateY(-200%);
|
transform: translateY(-200%);
|
||||||
transition: transform var(--dur-fast) var(--ease);
|
transition: transform var(--dur-fast) var(--ease);
|
||||||
}
|
}
|
||||||
.skip-link:focus { transform: translateY(0); }
|
.skip-link:focus {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
::selection { background: var(--accent); color: var(--text-inverse); }
|
::selection {
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--text-inverse);
|
||||||
|
}
|
||||||
|
|
||||||
/* --- Layout --------------------------------------------------------------- */
|
/* --- Layout --------------------------------------------------------------- */
|
||||||
|
|
||||||
.wrap { inline-size: 100%; max-inline-size: var(--width-content); margin-inline: auto; padding-inline: var(--gutter); }
|
.wrap {
|
||||||
.wrap-wide { max-inline-size: var(--width-wide); }
|
inline-size: 100%;
|
||||||
.prose { max-inline-size: var(--width-prose); }
|
max-inline-size: var(--width-content);
|
||||||
.section { padding-block: var(--section-y); }
|
margin-inline: auto;
|
||||||
.section-alt { background: var(--bg-alt); }
|
padding-inline: var(--gutter);
|
||||||
.section-inverse { background: var(--bg-inverse); color: var(--text-inverse); }
|
}
|
||||||
.section-inverse .eyebrow,
|
.wrap-wide {
|
||||||
.section-inverse .text-meta { color: var(--text-inverse-2); }
|
max-inline-size: var(--width-wide);
|
||||||
|
}
|
||||||
|
/* The reading measure, opted into. docs/02 caps body copy at 68ch; the old
|
||||||
|
build ran full-bleed paragraphs at 1400px. Wrap long-form in `.prose`, and
|
||||||
|
let the MDX `Prose` component own it for articles. */
|
||||||
|
.prose,
|
||||||
|
.prose p {
|
||||||
|
max-inline-size: var(--width-prose);
|
||||||
|
}
|
||||||
|
/* `.prose` HAD NO PARAGRAPH SPACING, AND NOTHING ANYWHERE SUPPLIED IT.
|
||||||
|
The reset above sets `* { margin: 0 }`, so a bare `.prose` with two <p>
|
||||||
|
children rendered them as one block. Measured on `/about/` §Language:
|
||||||
|
**gap between paragraph 1 and paragraph 2 = 0.0px** — "…history arrive
|
||||||
|
together." running straight into "Working in the parties' own language…", on
|
||||||
|
screen and in the printed PDF.
|
||||||
|
|
||||||
hr { border: none; border-block-start: 1px solid var(--border); }
|
It survived step 2 because BOTH of `/`'s prose blocks supply their own
|
||||||
.rule-gold { border: none; border-block-start: 1px solid var(--rule); }
|
spacing: `.approach-prose` uses `display:flex; gap`, and `.bio-prose` on
|
||||||
|
`/about/` has its own `> p + p`. So the only two call sites happened to opt
|
||||||
|
out of the defect. The rule belongs HERE, where `.prose` lives, or it has to
|
||||||
|
be remembered on all fifteen remaining pages.
|
||||||
|
|
||||||
|
`:where()` KEEPS THE SPECIFICITY AT ZERO so a component's own rule for the
|
||||||
|
SAME PROPERTY wins without `!important`.
|
||||||
|
|
||||||
|
⚠️ IT DOES NOT PROTECT AGAINST A FLEX `gap`, AND THIS COMMENT ONCE CLAIMED IT
|
||||||
|
DID — "Verified: with `:where()` the flex container's gap governs and this
|
||||||
|
contributes nothing." That was false and was measured false minutes later:
|
||||||
|
`:where()` lowers SPECIFICITY, which only matters when two rules set the same
|
||||||
|
property. A flex `gap` is a different property, so gap and margin both apply
|
||||||
|
and add. `/`'s `.approach-prose` went **24px -> 48px** on the strength of that
|
||||||
|
sentence. It has been converted to use this rule instead of a `gap`, and
|
||||||
|
`.bio-prose`'s duplicate `> p + p` was removed for the same reason. If a
|
||||||
|
future block needs different spacing, override `margin-block-start` — do not
|
||||||
|
reach for `gap`. */
|
||||||
|
:where(.prose) > p + p {
|
||||||
|
margin-block-start: var(--space-5);
|
||||||
|
}
|
||||||
|
/* THE PAGE-OWNED WRAPPER FOR `SectionHeading`, and it lives here because it was
|
||||||
|
byte-identical in two pages with seventeen to come. It exists only because a
|
||||||
|
parent cannot style a child component's root (`CLAUDE.md`; measured on
|
||||||
|
`SectionHeading`), so every page that uses a section heading needs a wrapper
|
||||||
|
it owns — which means every page needs this rule. Same argument that extracted
|
||||||
|
`ContactBand`, applied to a rule instead of a component. */
|
||||||
|
.section-head {
|
||||||
|
margin-block-end: var(--space-7);
|
||||||
|
}
|
||||||
|
.section {
|
||||||
|
padding-block: var(--section-y);
|
||||||
|
}
|
||||||
|
.section-alt {
|
||||||
|
background: var(--bg-alt);
|
||||||
|
}
|
||||||
|
.section-inverse {
|
||||||
|
background: var(--bg-inverse);
|
||||||
|
color: var(--text-inverse);
|
||||||
|
}
|
||||||
|
.section-inverse .eyebrow,
|
||||||
|
.section-inverse .text-meta {
|
||||||
|
color: var(--text-inverse-2);
|
||||||
|
}
|
||||||
|
/* The conversion band. Maroon rather than ink so it reads as an action and not
|
||||||
|
as a second footer — the real footer is ink and sits directly beneath it.
|
||||||
|
Cream on maroon measures 12.29:1; gold-l on maroon 8.11:1 (docs/02). */
|
||||||
|
.section-accent {
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--text-inverse);
|
||||||
|
}
|
||||||
|
.section-accent .eyebrow,
|
||||||
|
.section-accent .text-meta {
|
||||||
|
color: var(--text-inverse-2);
|
||||||
|
}
|
||||||
|
/* Both inverse families need a focus ring that is visible ON them: the default
|
||||||
|
ring is --maroon, which is 1.00:1 against the accent band's own background
|
||||||
|
and 1.21:1 against ink. Gold-l measures 8.11:1 on maroon and 11.09:1 on ink.
|
||||||
|
Without this the ring exists and cannot be seen, which is the same failure as
|
||||||
|
not having one. */
|
||||||
|
.section-inverse :focus-visible,
|
||||||
|
.section-accent :focus-visible {
|
||||||
|
outline-color: var(--gold-l);
|
||||||
|
}
|
||||||
|
/* The eyebrow's dot is a --accent (maroon) box, so recolouring only the TEXT
|
||||||
|
for an inverse ground leaves the dot at 1.21:1 on ink and 1.00:1 on the
|
||||||
|
accent band — present in the markup, invisible on the page. Colour never
|
||||||
|
carries meaning alone here (the dot is decorative and aria-hidden), so this
|
||||||
|
is a design defect rather than a WCAG one; it is still a mark nobody can see.
|
||||||
|
Measured 2026-08-27. */
|
||||||
|
.section-inverse .eyebrow .dot,
|
||||||
|
.section-accent .eyebrow .dot {
|
||||||
|
background: var(--text-inverse-2);
|
||||||
|
}
|
||||||
|
/* Pill reads its colours from custom properties, which are the one thing that
|
||||||
|
crosses Astro's component-scope boundary (they inherit). See Pill.astro. */
|
||||||
|
.section-inverse,
|
||||||
|
.section-accent {
|
||||||
|
--pill-border: var(--line-dark);
|
||||||
|
--pill-fg: var(--text-inverse-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
hr {
|
||||||
|
border: none;
|
||||||
|
border-block-start: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.rule-gold {
|
||||||
|
border: none;
|
||||||
|
border-block-start: 1px solid var(--rule);
|
||||||
|
}
|
||||||
|
|
||||||
.visually-hidden {
|
.visually-hidden {
|
||||||
position: absolute; inline-size: 1px; block-size: 1px;
|
position: absolute;
|
||||||
padding: 0; margin: -1px; overflow: hidden;
|
inline-size: 1px;
|
||||||
clip-path: inset(50%); white-space: nowrap; border: 0;
|
block-size: 1px;
|
||||||
|
padding: 0;
|
||||||
|
margin: -1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip-path: inset(50%);
|
||||||
|
white-space: nowrap;
|
||||||
|
border: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --- Reveal ---------------------------------------------------------------
|
/* --- Reveal ----------------------------------------------------------------
|
||||||
Progressive enhancement, not a dependency. Content is rendered and visible
|
Scroll-driven CSS. There is NO JavaScript on this site, and this block is
|
||||||
in the HTML; `.reveal` only takes effect once JS adds `js-reveal` to <html>.
|
why: the reveal used to be an inline IntersectionObserver in <head>, which
|
||||||
If the observer never runs, every page reads normally. The previous build
|
collided with the Content-Security-Policy docs/05-backend-spec.md specifies
|
||||||
had this backwards and shipped a blank page to anything without JS. */
|
(`script-src 'self'`, no `unsafe-inline`, "use a hash or nonce for the reveal
|
||||||
|
script"). A per-build hash is a moving target and drifts from the policy.
|
||||||
|
`animation-timeline: view()` is what docs/02 §Motion offers as the
|
||||||
|
alternative, and it removes the script — and the problem — entirely.
|
||||||
|
|
||||||
.js-reveal .reveal { opacity: 0; transform: translateY(20px); }
|
The @supports gate is load-bearing, not defensive. Without it a browser that
|
||||||
.js-reveal .reveal.is-in {
|
ignores `animation-timeline` would run the animation once against the
|
||||||
opacity: 1; transform: none;
|
document timeline at load; with it, that browser gets no animation and fully
|
||||||
transition: opacity var(--dur-reveal) var(--ease), transform var(--dur-reveal) var(--ease);
|
visible content. Content is never hidden behind a feature that might not
|
||||||
|
arrive. The previous build had this backwards and shipped a blank page to
|
||||||
|
anything without JavaScript. */
|
||||||
|
|
||||||
|
@supports (animation-timeline: view()) {
|
||||||
|
@media (prefers-reduced-motion: no-preference) {
|
||||||
|
/* LONGHANDS ONLY. `animation: reveal-in linear both` beside
|
||||||
|
`animation-timeline: view()` is folded by Lightning CSS on minify into
|
||||||
|
`animation: linear both reveal-in view()`, which is invalid — `view()` is
|
||||||
|
not a component of the shorthand — so the whole declaration is thrown
|
||||||
|
away. It works in `npm run dev` and is dead in `npm run build`. This is
|
||||||
|
the same defect the header's condense had; it was found there first and
|
||||||
|
written straight back into the fix for it. Grep dist for it (Phase 5). */
|
||||||
|
.reveal {
|
||||||
|
animation-name: reveal-in;
|
||||||
|
animation-duration: 1ms;
|
||||||
|
animation-timing-function: linear;
|
||||||
|
animation-fill-mode: both;
|
||||||
|
animation-timeline: view();
|
||||||
|
animation-range: entry 0% cover 22%;
|
||||||
|
}
|
||||||
|
.reveal-stagger > * {
|
||||||
|
animation-name: reveal-in;
|
||||||
|
animation-duration: 1ms;
|
||||||
|
animation-timing-function: linear;
|
||||||
|
animation-fill-mode: both;
|
||||||
|
animation-timeline: view();
|
||||||
|
}
|
||||||
|
/* Stagger is expressed as timeline range, not delay: a scroll-driven
|
||||||
|
animation has no wall clock to delay against. Each child completes a
|
||||||
|
little further into the scroll than the one before. Six children by
|
||||||
|
design (docs/02) — a seventh simply lands with the sixth. */
|
||||||
|
.reveal-stagger > *:nth-child(1) {
|
||||||
|
animation-range: entry 0% cover 18%;
|
||||||
|
}
|
||||||
|
.reveal-stagger > *:nth-child(2) {
|
||||||
|
animation-range: entry 0% cover 22%;
|
||||||
|
}
|
||||||
|
.reveal-stagger > *:nth-child(3) {
|
||||||
|
animation-range: entry 0% cover 26%;
|
||||||
|
}
|
||||||
|
.reveal-stagger > *:nth-child(4) {
|
||||||
|
animation-range: entry 0% cover 30%;
|
||||||
|
}
|
||||||
|
.reveal-stagger > *:nth-child(5) {
|
||||||
|
animation-range: entry 0% cover 34%;
|
||||||
|
}
|
||||||
|
.reveal-stagger > *:nth-child(6) {
|
||||||
|
animation-range: entry 0% cover 38%;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.js-reveal .reveal-stagger > * { opacity: 0; transform: translateY(16px); }
|
|
||||||
.js-reveal .reveal-stagger.is-in > * {
|
@keyframes reveal-in {
|
||||||
opacity: 1; transform: none;
|
from {
|
||||||
transition: opacity var(--dur-reveal) var(--ease), transform var(--dur-reveal) var(--ease);
|
opacity: 0;
|
||||||
|
transform: translateY(20px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.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) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
html { scroll-behavior: auto; }
|
html {
|
||||||
*, *::before, *::after {
|
scroll-behavior: auto;
|
||||||
|
}
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
animation-duration: 0.01ms !important;
|
animation-duration: 0.01ms !important;
|
||||||
animation-iteration-count: 1 !important;
|
animation-iteration-count: 1 !important;
|
||||||
transition-duration: 0.01ms !important;
|
transition-duration: 0.01ms !important;
|
||||||
scroll-behavior: auto !important;
|
scroll-behavior: auto !important;
|
||||||
}
|
}
|
||||||
.js-reveal .reveal,
|
/* Belt and braces. The @supports block above is already gated on
|
||||||
.js-reveal .reveal-stagger > * { opacity: 1 !important; transform: none !important; }
|
no-preference, so nothing here should be animating at all — this keeps the
|
||||||
|
guarantee true even if a later rule forgets the gate. */
|
||||||
|
.reveal,
|
||||||
|
.reveal-stagger > * {
|
||||||
|
animation: none !important;
|
||||||
|
opacity: 1 !important;
|
||||||
|
transform: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A scroll-driven animation has no timeline when printing, so every revealed
|
||||||
|
element would render at its `from` state — which is `opacity: 0`. Measured
|
||||||
|
before this block existed: printing the page to PDF dropped four card
|
||||||
|
headings from the output entirely. `/about/` is written to be printed by
|
||||||
|
people evaluating an appointment; content that vanishes at Cmd-P is not a
|
||||||
|
cosmetic problem. */
|
||||||
|
@media print {
|
||||||
|
.reveal,
|
||||||
|
.reveal-stagger > * {
|
||||||
|
animation: none !important;
|
||||||
|
opacity: 1 !important;
|
||||||
|
transform: none !important;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --- Print: the About page will be printed by people evaluating an appointment */
|
/* --- Print: the About page will be printed by people evaluating an appointment */
|
||||||
|
|
||||||
@media print {
|
@media print {
|
||||||
body { background: #fff; color: #000; font-size: 11pt; }
|
body {
|
||||||
.site-header, .site-footer, .skip-link, .no-print { display: none !important; }
|
background: #fff;
|
||||||
a[href^='http']::after { content: ' (' attr(href) ')'; font-size: 9pt; }
|
color: #000;
|
||||||
.section { padding-block: var(--space-5); }
|
font-size: 11pt;
|
||||||
|
}
|
||||||
|
.site-header,
|
||||||
|
.site-footer,
|
||||||
|
.skip-link,
|
||||||
|
.no-print {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
a[href^='http']::after {
|
||||||
|
content: ' (' attr(href) ')';
|
||||||
|
font-size: 9pt;
|
||||||
|
}
|
||||||
|
.section {
|
||||||
|
padding-block: var(--space-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* THE INVERSE GROUNDS HAD TO BE NEUTRALISED AND WERE NOT — and this block's
|
||||||
|
own heading says why it matters: the About page is printed by people
|
||||||
|
evaluating an appointment.
|
||||||
|
|
||||||
|
`print-color-adjust` defaults to `economy`, so a UA drops the BACKGROUND
|
||||||
|
and keeps the text. Chrome's default print dialog has "Background graphics"
|
||||||
|
unchecked, so `.section-inverse` and `.section-accent` printed cream text
|
||||||
|
on white paper. Measured with `Page.printToPDF`, `printBackground: false`,
|
||||||
|
rasterised at 150 dpi: the dominant glyph colour across the whole arc block
|
||||||
|
was **rgb(166,164,161) — 2.49:1 against white**, and that grey is Chrome's
|
||||||
|
own legibility fudge. The DECLARED colour is cream at ~1.04:1, which is
|
||||||
|
what a UA without that fudge renders. With `printBackground: true` the
|
||||||
|
pages are correct, which is what isolates the cause.
|
||||||
|
|
||||||
|
What vanished was the Q.Med / Q.Arb / C.Med-Arb progression on `/about/` —
|
||||||
|
§4's paired disclosure — plus the contact band. `!important` because the
|
||||||
|
rules being overridden are class-level and these must win regardless of
|
||||||
|
which section variant a future page uses. */
|
||||||
|
/* TOKENS FIRST, THEN CLASSES — and the token half is the part that works.
|
||||||
|
A class-by-class version of this block shipped first and MISSED TWO
|
||||||
|
ELEMENTS, both measured under print-media emulation: `.approach-metaphor`
|
||||||
|
on `/` and `.btn-gold` on both pages stayed at `rgb(226,200,154)` —
|
||||||
|
gold-l, which is **1.62:1 against white paper** once the ground is dropped.
|
||||||
|
One is the paragraph carrying the infinity-mark argument; the other is the
|
||||||
|
call to action. Enumerating class names cannot work here: `--text-inverse-2`
|
||||||
|
is consumed by page-scoped and component-scoped rules this file has never
|
||||||
|
heard of, and there will be seventeen more pages of them.
|
||||||
|
|
||||||
|
Custom properties INHERIT, and that is the one mechanism that crosses
|
||||||
|
Astro's component-scope boundary (see `Pill.astro`). Redefining the three
|
||||||
|
inverse tokens on the section itself therefore reaches every descendant
|
||||||
|
rule, including ones written after this block. */
|
||||||
|
.section-inverse,
|
||||||
|
.section-accent {
|
||||||
|
background: transparent !important;
|
||||||
|
color: #000 !important;
|
||||||
|
--text-inverse: #000;
|
||||||
|
--text-inverse-2: #000;
|
||||||
|
--pill-fg: #000;
|
||||||
|
--pill-border: #000;
|
||||||
|
--rule: #000;
|
||||||
|
}
|
||||||
|
/* The belt-and-braces half. These four set a colour LITERALLY rather than
|
||||||
|
through a token, so the inheritance above does not reach them. */
|
||||||
|
.section-inverse .eyebrow,
|
||||||
|
.section-accent .eyebrow,
|
||||||
|
.section-inverse .lede,
|
||||||
|
.section-accent .lede {
|
||||||
|
color: #000 !important;
|
||||||
|
}
|
||||||
|
/* The dot is a background, not text, so it does not follow `color`. */
|
||||||
|
.section-inverse .eyebrow .dot,
|
||||||
|
.section-accent .eyebrow .dot {
|
||||||
|
background: #000 !important;
|
||||||
|
}
|
||||||
|
/* EVERY BUTTON, NOT JUST THE ONES ON AN INVERSE GROUND. Two of the three
|
||||||
|
variants set light text on a coloured background of their own
|
||||||
|
(`.btn-primary` cream-on-maroon, `.btn-gold` gold-on-ink), and a UA at
|
||||||
|
`print-color-adjust: economy` drops the background and keeps the text.
|
||||||
|
Measured against white paper: `.btn-gold` 1.62:1, `.btn-primary` **1.07:1**.
|
||||||
|
|
||||||
|
The first version of this rule was scoped to `.section-accent .btn-gold`,
|
||||||
|
which fixed the contact band and left the HERO CTA on `/` unreadable —
|
||||||
|
`.btn-primary` sits on cream, inside no inverse section at all, so nothing
|
||||||
|
in this block reached it. A print sweep of all 89 visible text elements
|
||||||
|
found it; the class-scoped version had passed its own narrower check. */
|
||||||
|
.btn {
|
||||||
|
background: transparent !important;
|
||||||
|
color: #000 !important;
|
||||||
|
border-color: #000 !important;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+32
-3
@@ -29,7 +29,7 @@
|
|||||||
Both are fine on --ink (8.00) and --maroon (5.84). See docs/02. */
|
Both are fine on --ink (8.00) and --maroon (5.84). See docs/02. */
|
||||||
--gold: #c9a876; /* rules, dividers, icon strokes, on-dark text */
|
--gold: #c9a876; /* rules, dividers, icon strokes, on-dark text */
|
||||||
--gold-d: #a88858; /* large decorative display text on cream only */
|
--gold-d: #a88858; /* large decorative display text on cream only */
|
||||||
--gold-l: #e2c89a; /* text on ink or maroon 11.09:1 */
|
--gold-l: #e2c89a; /* text: 11.09:1 on ink, 8.11:1 on maroon */
|
||||||
|
|
||||||
--line: rgb(26 22 20 / 0.10);
|
--line: rgb(26 22 20 / 0.10);
|
||||||
--line-2: rgb(26 22 20 / 0.06);
|
--line-2: rgb(26 22 20 / 0.06);
|
||||||
@@ -51,6 +51,10 @@
|
|||||||
--rule: var(--gold);
|
--rule: var(--gold);
|
||||||
--border: var(--line);
|
--border: var(--line);
|
||||||
--focus-ring: var(--maroon);
|
--focus-ring: var(--maroon);
|
||||||
|
/* docs/02: "outline: 2px solid var(--maroon); outline-offset: 3px". The
|
||||||
|
offset was written as a literal in global.css and then in two components,
|
||||||
|
which is three places to forget. */
|
||||||
|
--focus-offset: 3px;
|
||||||
|
|
||||||
/* --- Type -------------------------------------------------------------- */
|
/* --- Type -------------------------------------------------------------- */
|
||||||
|
|
||||||
@@ -59,7 +63,10 @@
|
|||||||
--font-mono: 'Geist Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
--font-mono: 'Geist Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
|
|
||||||
/* Fluid scale, 360px → 1600px viewport. Ratio widens toward the display
|
/* Fluid scale, 360px → 1600px viewport. Ratio widens toward the display
|
||||||
end (1.25 → 1.333) so headlines scale harder than body copy. */
|
end (1.25 → 1.333) so headlines scale harder than body copy.
|
||||||
|
--text-2xs is the eyebrow floor docs/02 sets at 11px. Added 2026-08-27:
|
||||||
|
SiteHeader wrote `0.6875rem` as a literal, step-1 review finding #7. */
|
||||||
|
--text-2xs: 0.6875rem; /* 11 — eyebrow */
|
||||||
--text-xs: 0.75rem; /* 12 — legal */
|
--text-xs: 0.75rem; /* 12 — legal */
|
||||||
--text-sm: 0.875rem; /* 14 — meta */
|
--text-sm: 0.875rem; /* 14 — meta */
|
||||||
--text-base: 1rem; /* 16 — body */
|
--text-base: 1rem; /* 16 — body */
|
||||||
@@ -90,6 +97,11 @@
|
|||||||
|
|
||||||
/* --- Space — 8px base -------------------------------------------------- */
|
/* --- Space — 8px base -------------------------------------------------- */
|
||||||
|
|
||||||
|
/* 2 — a hairline gap, below the 4px base step. It exists because the
|
||||||
|
two-line brand block needs a gap smaller than --space-1 without the header
|
||||||
|
growing; step-1 review finding #7 flagged the literal. Do not reach for it
|
||||||
|
as a general step: the 8px base starts at --space-1. */
|
||||||
|
--space-05: 2px;
|
||||||
--space-1: 0.25rem; /* 4 */
|
--space-1: 0.25rem; /* 4 */
|
||||||
--space-2: 0.5rem; /* 8 */
|
--space-2: 0.5rem; /* 8 */
|
||||||
--space-3: 0.75rem; /* 12 */
|
--space-3: 0.75rem; /* 12 */
|
||||||
@@ -102,10 +114,27 @@
|
|||||||
--space-10: 8rem; /* 128 */
|
--space-10: 8rem; /* 128 */
|
||||||
--space-11: 10rem; /* 160 */
|
--space-11: 10rem; /* 160 */
|
||||||
|
|
||||||
--section-y: clamp(var(--space-9), 6vw + 2rem, var(--space-11));
|
/* docs/02: "Section rhythm: --space-9 (96px) mobile, --space-11 (160px)
|
||||||
|
desktop." The previous curve was `6vw + 2rem`, which reaches 160px only at
|
||||||
|
a 2133px viewport — measured 128px at 1600px, 108.8px at 1280px. The upper
|
||||||
|
bound was unreachable on any real screen, so the token read as if it
|
||||||
|
delivered a rhythm it never delivered. `9vw + 1rem` hits 160px at 1600px
|
||||||
|
and still clamps to 96px on a phone. [measured 2026-08-26] */
|
||||||
|
--section-y: clamp(var(--space-9), 9vw + 1rem, var(--space-11));
|
||||||
|
|
||||||
/* --- Layout ------------------------------------------------------------ */
|
/* --- Layout ------------------------------------------------------------ */
|
||||||
|
|
||||||
|
/* Sticky-header height at >= 66rem, where the header IS sticky. global.css
|
||||||
|
drives `scroll-padding-top` off this, so the skip link does not drop the
|
||||||
|
reader behind the header. If SiteHeader's padding or nav sizing changes,
|
||||||
|
re-measure and change this with it — one fact living in two files.
|
||||||
|
[measured 2026-08-26 — headless Chrome at 1024/1100/1280/1440px, with six
|
||||||
|
nav items and with a seventh injected. 81px at every one: 32 padding + 48
|
||||||
|
reserved brand block + the 1px bottom border, which is easy to forget and
|
||||||
|
is why this is measured rather than added up. The brand reserves 48px so
|
||||||
|
the height does not change when the tagline appears at 76rem] */
|
||||||
|
--header-h: 5.0625rem; /* 81 — measured, not chosen */
|
||||||
|
|
||||||
--width-content: 80rem; /* 1280 */
|
--width-content: 80rem; /* 1280 */
|
||||||
--width-wide: 90rem; /* 1440 */
|
--width-wide: 90rem; /* 1440 */
|
||||||
--width-prose: 68ch; /* reading measure — never exceed for body copy */
|
--width-prose: 68ch; /* reading measure — never exceed for body copy */
|
||||||
|
|||||||
+1
-8
@@ -2,14 +2,7 @@
|
|||||||
"extends": "astro/tsconfigs/strict",
|
"extends": "astro/tsconfigs/strict",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"strictNullChecks": true,
|
"strictNullChecks": true,
|
||||||
"allowJs": true,
|
"allowJs": true
|
||||||
"baseUrl": ".",
|
|
||||||
"paths": {
|
|
||||||
"@components/*": ["src/components/*"],
|
|
||||||
"@layouts/*": ["src/layouts/*"],
|
|
||||||
"@styles/*": ["src/styles/*"],
|
|
||||||
"@data/*": ["src/data/*"]
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"include": [".astro/types.d.ts", "**/*"],
|
"include": [".astro/types.d.ts", "**/*"],
|
||||||
"exclude": ["dist", "node_modules"]
|
"exclude": ["dist", "node_modules"]
|
||||||
|
|||||||
Reference in New Issue
Block a user