feat: build step 1 — scaffold, layout, header, footer, SEO; zero JavaScript

Build order step 1 (docs/01): scaffold, tokens, base layout, header,
footer, SEO component, plus a temporary /type-scale/ proof sheet that
step 2 deletes.

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012XquaEq4BgWMCwUqLEyNkF
This commit is contained in:
Pouya Lajevardi
2026-08-26 15:57:02 -04:00
co-authored by Claude Opus 5
parent 8f1df2c27c
commit 8134709548
33 changed files with 2575 additions and 147 deletions
+28 -5
View File
@@ -97,6 +97,23 @@ seat. The cost of getting this wrong is much higher than the cost of waiting.
Revisit at month 1218, once there is relationship history to point to.
**This reasoning is Claude's, recorded for Pouya's decision — not yet his call.**
## Not a practice area yet: tax-adjacent disputes
**Canadian Tax Foundation membership is verified** (`AGENTS.md` §4, 2026-08-26)
and it is the one credential none of the six areas above touch. Tax-adjacent
disputes are genuinely ADR territory — valuation and purchase-price disputes on
a share sale, indemnity and earn-out fights that turn on a tax position,
shareholder splits where the assessment is the thing actually in dispute.
**There is no seventh practice page at launch,** for the same reason as the
section above and not a weaker one: a practice page is a claim of present
capability, and there is no track record to point at. A membership is a
credential, not a caseload.
It belongs on `/about/` with the other memberships. Revisit at the **month
1218 review, alongside the Indigenous engagement decision** — one review, two
candidates. Tracked as `AGENTS.md` R3.
---
## Page specifications
@@ -176,9 +193,14 @@ neutral.
3. **Rules:** ADRIC, ADR Chambers, ad hoc.
4. Awards — form, reasoning, timing.
5. **Credentialing status, stated plainly.** The Q.Arb pathway is in progress;
the page says so and describes what is available now (co-arbitration,
co-arbitration) versus what follows designation. Honesty here is a
differentiator, not a weakness — and misstating it is a conduct problem.
the page says so and describes what is available now versus what follows
designation. Honesty here is a differentiator, not a weakness — and
misstating it is a conduct problem. **What "available now" consists of is
`AGENTS.md` Q36 and is NOT yet in §4** — this line, `03-content-spec.md`, and
`07-fees.md` all assert co-arbitration availability off the same unverified
premise. Do not write it onto the page until Q36 closes.
*(This paragraph read "(co-arbitration, co-arbitration)" until 2026-08-26 —
edited without being re-read, which is how the unverified half travelled.)*
6. Fees, booking.
### `/med-arb/`
@@ -326,8 +348,9 @@ anything.
### `/contact/`
Intake form (`05-backend-spec.md`), booking embed, direct email and phone
(Q3), Toronto by-appointment line, response-time expectation, and an explicit
Intake form (`05-backend-spec.md`), booking embed, direct email
(Q3**there is no public phone number**; render `CONTACT.phoneFallback`,
"By scheduled call", wherever a number would go), Toronto by-appointment line, response-time expectation, and an explicit
note that submitting the form does not create a retainer or a mediatorparty
relationship and does not itself create a conflict check.
+27 -8
View File
@@ -25,7 +25,7 @@ system that preserves it while fixing what the old build got wrong.
| Ad hoc spacing values | 8 px base scale | Consistent vertical rhythm; no magic numbers |
| Gold used as a text colour on cream | Gold restricted to decorative and on-dark | **It fails WCAG AA at 2.10:1.** Measured, not assumed |
| Scroll-reveal on every element, always on | Reveal on major sections only, gated behind `prefers-reduced-motion` | Motion that reads as confident rather than decorative; accessible by default |
| 2.2 MB single file, ~1 MB of base64 logos | Optimized SVG mark, AVIF/WebP photography | The mark is geometry; it should be vector, not a 470 KB PNG |
| 2.2 MB single file, ~1 MB of base64 logos *(both figures under review — `AGENTS.md` Q34; and "470 KB PNG", which this row used to assert, has no source anywhere in the repo and has been removed)* | Optimized SVG mark, AVIF/WebP photography | The mark is geometry, so it should be vector. That holds whatever the old file weighed |
| React 18 dev build + Babel Standalone in the browser | Static HTML, near-zero JS | The reason the site is invisible to crawlers |
---
@@ -116,7 +116,13 @@ the display end. Tokens `--text-xs` through `--text-6xl` in `tokens.css`.
Content width `1280px`; prose measure `68ch`; wide media `1440px`.
Gutters: `24px` mobile, `48px` desktop.
Section rhythm: `--space-9` (96px) mobile, `--space-11` (160px) desktop.
Section rhythm: `--space-9` (96 px) mobile, `--space-11` (160 px) desktop
`--section-y` in `tokens.css`. *Corrected 2026-08-26:* the curve was
`6vw + 2rem`, which reaches 160 px only at a **2133 px** viewport, so the
desktop half of this line was never delivered (measured 108.8 px at 1280 px,
128 px at 1600 px). It is now `9vw + 1rem`, which reaches 160 px at 1600 px. If
you change the curve, re-measure — a `clamp()` whose upper bound is unreachable
reads exactly like one that works.
Grid: 12 columns desktop, 6 tablet, 4 mobile, `--space-5` gutter.
@@ -131,11 +137,24 @@ deliberate and quiet.
stagger is limited to card grids, and capped at six children.
- Duration `600ms`, easing `cubic-bezier(.2,.7,.2,1)`. Transform and opacity
only — never layout properties.
- Implement with `IntersectionObserver` in one tiny inline script, or
`animation-timeline: view()` where supported. Not a framework, not a library.
- **Content is visible without JavaScript.** The reveal is an enhancement layered
on top of already-rendered HTML. If the observer never runs, the page reads
normally. The old build had this exactly backwards.
- Implement with **`animation-timeline: view()`**, behind `@supports`. *Amended
2026-08-26:* the `IntersectionObserver` alternative this line used to offer
first is now ruled out, not merely second choice. It has to run inline in
`<head>` to avoid a flash, and `05-backend-spec.md` specifies `script-src
'self'` with no `unsafe-inline` — so the only script on the site would have
been the one thing the site's own CSP refuses to execute, and a per-build hash
drifts from the policy pinning it. The CSS route ships **zero** JavaScript.
Not a framework, not a library, not a script.
- **Content is visible without the feature.** The `@supports` gate is
load-bearing, not defensive: without it, a browser that ignores
`animation-timeline` runs the animation once against the document timeline at
load; with it, that browser gets no animation and fully visible content. The
old build had this exactly backwards and shipped a blank page.
- **And without a print timeline.** A scroll-driven animation has no timeline
when printing, so a revealed element renders at its `from` state — `opacity:
0`. Measured 2026-08-26: before the print override existed, printing a page to
PDF dropped four card headings from the output entirely. `/about/` is written
to be printed by people evaluating an appointment.
- Hover transitions `250ms`.
```css
@@ -159,7 +178,7 @@ except the reveal of the hero.
| Component | Notes |
|---|---|
| `InfinityMark` | Inline SVG, `currentColor`, `aria-hidden` when decorative. Never a PNG |
| `SiteHeader` | Sticky, condenses on scroll. Practice dropdown as CSS-only `<details>` |
| `SiteHeader` | **Sticky from 64rem (1024 px) up; static below it.** Practice dropdown as CSS-only `<details>`. *Amended 2026-08-26, both halves by measurement:* **(a)** the one-row header has to hold the brand, **seven** nav items and the CTA — Insights is the seventh and arrives on its own at build step 7 — and it does that at 1024 px with 32 px of clearance, not below. Under 64 rem the nav takes its own row and the header measures **137 px** at tablet widths and **185 px** at 320375 px, which is more of a small viewport than a sticky header is worth, so it scrolls away there. *(This row first said 60 rem and "~115 px". Both were wrong — the threshold moved when the seven-item case was measured, and 115 px was never a height the header took at any width. Corrected against headless-Chrome measurements at thirteen widths.)* **(b)** "Condenses on scroll" is now **a hairline rule and a shadow, not a size change.** A `position: sticky` header stays in normal flow, so its layout box sits at the top of the document whatever the viewport shows; 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 `04-seo-spec.md`. Implemented with `animation-timeline: scroll()`, **longhands only** — see the note in the component about what the minifier does to the `animation` shorthand |
| `SiteFooter` | Three-column sitemap, contact block, designations, entity line |
| `Eyebrow` | Mono label with optional maroon dot |
| `SectionHeading` | Eyebrow + display heading + optional lede, one measure |
+13 -3
View File
@@ -29,7 +29,10 @@ detect padding instantly and discount everything after it.
- Concrete nouns. *Lien claim. Change order. System Impact Assessment. Model
card. Minutes of settlement.* Specificity is the credential.
- Name the limits. "Sole-arbitrator appointments follow the Q.Arb designation;
co-arbitration work is available now." (**No tribunal-secretary work** — D14
co-arbitration work is available now." **The first half traces to §4; the
second does not — `AGENTS.md` Q36.** Keep the shape of the sentence, which is
the point being made here, and do not publish the second clause until Q36
closes. (**No tribunal-secretary work** — D14
removed the rate and bars offering it; see `docs/07-fees.md`.) Precision about
what you cannot yet do makes the rest believable.
- Plain words over Latin. "Without prejudice" survives because it is a term of
@@ -86,7 +89,12 @@ From the content brief; all three sit honestly with the practice.
1. *A mediator who reads the contract, the code, and the room.***recommended.**
The cleanest one-sentence statement of the moat, and rare because it is rare.
2. *Engineered for the cases that don't fit a courtroom.*
3. *Disputes resolved by someone who has been on every side of one.*
3. ~~*Disputes resolved by someone who has been on every side of one.*~~
**Does not clear §4 as written** (flagged 2026-08-26). "Every side" asserts
having acted as party, as counsel, and as neutral; §4 verifies the neutral
role and *active litigation exposure*, not the other two. Left in place so
the option is not silently re-invented — but it cannot be chosen without a §4
row to choose it from.
## The credential row
@@ -98,7 +106,9 @@ Three slots, never counts:
| 2 | **JD + ML** | Law and engineering |
| 3 | **EN · FA** | Bilingual practice |
Fourth slot where the layout has one: **Q.Arb** — in progress.
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.
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
+39 -12
View File
@@ -1,7 +1,14 @@
# 04 — Discoverability
The problem this project exists to fix. `AGENTS.md` §2 has the measurements: a
server-side fetch of the live site returns three words.
The problem this project exists to fix. `AGENTS.md` §2 has the measurements.
> **Those measurements are under review — `AGENTS.md` Q34.** They were taken on
> 2026-08-25; a re-fetch on 2026-08-26 returned a bundler harness whose real
> `<head>` sits JSON-escaped inside a `<script>` and whose application lives in
> nine UUID-named files that were not fetched. Some of §2 reproduced exactly
> (the 2.2 MB single file, the placeholder `<title>`); some could not be
> reproduced from the served HTML at all. **Cite §2, and cite Q34 with it. Do
> not put any of these figures in public copy until Q34 closes.**
---
@@ -82,15 +89,21 @@ to be machine-readable.
## Crawlability
**`public/robots.txt`:**
**`public/robots.txt` is the artefact — read it, do not read a copy of it
here.** This spec used to reproduce the file inline and the reproduction had
already drifted from it by 2026-08-26, which is the failure mode the `AGENTS.md`
§7 rule exists to stop.
```
User-agent: *
Allow: /
Disallow: /legal/
Sitemap: https://adr.smlcompany.ca/sitemap-index.xml
```
**It disallows nothing, and that is deliberate.** This spec previously
prescribed `Disallow: /legal/` alongside `noindex` on those pages, and the two
cancel each other: a crawler forbidden to *fetch* a URL never reads the
`noindex` on it. `/legal/privacy/` and `/legal/terms/` are linked from the
footer of every page, so they are discovered regardless — and the likely result
of the pair was Google listing the bare URLs as "no information available", the
opposite of the intent, with the directive that would have suppressed them
sitting unread behind the wall. **`noindex` is what de-indexes; `Disallow` is
what prevents fetching.** Use the one that matches the problem, and never both
on the same path.
Do not block AI crawlers. Being read by an assistant that a general counsel is
using to shortlist neutrals is the point.
@@ -117,7 +130,16 @@ Core Web Vitals are a ranking input, and the current build fails all of them.
| CLS | < 0.05 |
| INP | < 150 ms |
| JS per route | < 100 KB |
| Lighthouse (mobile) | ≥ 95 all four categories |
| Lighthouse (mobile) | ≥ 95 all four categories**not measurable until step 7, see below** |
> ⚠️ **Lighthouse verification is UNAVAILABLE until build step 7.** `@lhci/cli`
> was removed on 2026-08-26 — it was the sole source of all 10 `npm audit`
> findings (7 high), `0.15.1` is `latest` so there was no clean upgrade, and it
> could not run at all with no pages and no `lighthouserc`. The budget below is
> not suspended; the tool that measures it is absent. Re-add at step 7 under
> `AGENTS.md` R11, checking for a patched release rather than assuming `0.15.1`
> is still the ceiling. Until then, a run that skips this is skipping something
> known — not something forgotten. `AGENTS.md` §7 has the state.
How: static HTML, self-hosted preloaded subset fonts, AVIF/WebP with explicit
dimensions, critical CSS inlined, no third-party scripts on any page except the
@@ -128,7 +150,10 @@ booking embed on `/contact/` — and that one is lazy-loaded behind a click.
Not code, but it belongs in the launch checklist: Google Business Profile for the
practice; ADRIC and ADRIO directory listings pointing at the site; a LinkedIn
profile whose headline and Featured section match the brand (brief §VIII);
consistent name, address, and phone across all of them.
consistent naming across all of them. **Not "name, address and phone"** — §4
publishes no phone number and no street address, only "Toronto, Ontario; by
appointment". Directory forms that demand a full NAP get what §4 verifies and
nothing more.
## Post-launch verification
@@ -139,3 +164,5 @@ consistent name, address, and phone across all of them.
- [ ] Sitemap submitted to Google Search Console and Bing
- [ ] No page returns 200 for a URL that should 404
- [ ] Lighthouse ≥ 95 mobile on `/`, `/about/`, one practice page, one article
**blocked until `@lhci/cli` is re-added at step 7.** Do not tick this box
from a manual Chrome DevTools run and call it the same check
+11
View File
@@ -206,6 +206,17 @@ An embedded scheduler for the 3045 minute confidential intake call
## Security headers
> **Note added 2026-08-26 — `style-src` has acquired a dependency.** The site
> now ships inline `style="…"` attributes that are load-bearing rather than
> decorative: `InfinityMark.astro` sets its own `block-size` that way, and the
> proof sheet renders computed swatches with it. 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:
```
+12 -4
View File
@@ -305,9 +305,13 @@ Treat "every push deploys" as the design; today the path is `npm run deploy`.
`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;
`npm run lighthouse` still has no `lighthouserc` and nothing to measure until
pages exist.
`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.
@@ -327,11 +331,15 @@ Then invalidate `/*`.
- [ ] Every claim traced to `AGENTS.md` §4 Verified
- [ ] No `TODO(pouya)` remains in any shipped page
- [ ] No matter counts, rates, dollar figures, or testimonials anywhere
- [ ] Q.Arb described as in progress everywhere it appears
- [ ] Q.Arb described as **commenced August 2026** everywhere it appears — §4's
wording, not the looser "in progress"
- [ ] `/fees/` carries the rates confirmed in D14 and `docs/07-fees.md`, or the page does not ship
- [ ] Privacy policy matches the backend as actually built
**Technical**
- [ ] **Re-add `@lhci/cli`** (removed 2026-08-26 — `AGENTS.md` §7) with a pin
verified against the registry that day, and a `lighthouserc` carrying the
budgets from `04-seo-spec.md`. This box gates the next one
- [ ] Lighthouse ≥ 95 mobile on `/`, `/about/`, a practice page, an article
- [ ] Every page renders fully with JavaScript disabled
- [ ] `curl` of each URL returns real content, not a shell
+3 -1
View File
@@ -104,7 +104,9 @@ All figures **plus HST**.
### Arbitration
Available now as co-arbitrator; sole appointments follow the Q.Arb designation,
Available now as co-arbitrator; sole appointments follow the Q.Arb designation
**the co-arbitrator half is `AGENTS.md` Q36 and is not in §4.** Do not publish
it on `/fees/` until Q36 closes,
commenced August 2026. The page must say so — see `03-content-spec.md`.
| Item | Fee |
+45
View File
@@ -0,0 +1,45 @@
# Fonts — provenance
Self-hosted per `docs/02-design-system.md`. **No runtime Google Fonts request**:
the page collects legal inquiries, and a third-party font call costs a round trip
and adds a third party to that page.
These `.woff2` files are committed rather than pulled at build time so their
paths are stable — a `<link rel="preload">` needs a filename that does not change
between builds, and Astro's asset hashing would break that.
| File | Source package | Version | Licence |
|---|---|---|---|
| `instrument-serif-latin-400-normal.woff2` | `@fontsource/instrument-serif` | 5.3.0 | SIL OFL 1.1 |
| `instrument-serif-latin-ext-400-normal.woff2` | `@fontsource/instrument-serif` | 5.3.0 | SIL OFL 1.1 |
| `instrument-serif-latin-400-italic.woff2` | `@fontsource/instrument-serif` | 5.3.0 | SIL OFL 1.1 |
| `geist-latin-wght-normal.woff2` | `@fontsource-variable/geist` | 5.3.0 | SIL OFL 1.1 |
| `geist-latin-ext-wght-normal.woff2` | `@fontsource-variable/geist` | 5.3.0 | SIL OFL 1.1 |
| `geist-mono-latin-wght-normal.woff2` | `@fontsource-variable/geist-mono` | 5.3.0 | SIL OFL 1.1 |
**The `?v=1` on every font URL is load-bearing.** `scripts/deploy-local.sh`
serves `/fonts/*` with `max-age=31536000, immutable`, so a returning visitor
holds these bytes for a year and no CloudFront invalidation can reach their
browser cache. Replacing a file means bumping that query — in
`src/styles/global.css` **and** in the `<link rel="preload">` in
`BaseLayout.astro`, which must match character for character or the preload
fetches a second copy instead of warming the cache.
Fetched 2026-08-26 with `npm pack <pkg>@5.3.0` and extracted from `package/files/`.
Subsetting is Fontsource's, not ours — the `latin` and `latin-ext` cuts are
exactly the "Latin + Latin Extended-A" the design system asks for.
**123,804 bytes across all six**, of which only two — Instrument Serif 400 latin
(21,032) and Geist latin (29,400) — are preloaded, so first paint pulls about
50 kB. Quote the byte figure, not `du -sh`, which reports 136K because it counts
disk blocks rather than what crosses the wire.
**Not covered by `AGENTS.md` R11.** R11 re-checks npm pins for currency and
advisories; these are static binaries with no runtime and no dependency tree.
Refresh them deliberately — when a face gains glyphs the site needs — by
repeating the `npm pack` above, not on a currency schedule.
**Deliberately absent:** the `latin-ext` italic cut of Instrument Serif, and
every non-Latin cut of all three faces. `.display .it` is one italic phrase in a
headline (`docs/02`), and D4 makes the site English-only. Add a cut when a page
needs it; do not add all of them pre-emptively.