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
+308 -16
View File
@@ -52,19 +52,43 @@ is built to be added to continuously, not shipped once. `[verified 2026-08-25]`
The site live at `adr.smlcompany.ca` as of this entry: `[verified 2026-08-25]`
> ⚠️ **Several of these measurements are contested — see Q34 (opened
> 2026-08-26).** A re-fetch of the deployed URL that day reproduced some of the
> list exactly and could not reproduce others *from the served HTML*, because
> the served document turns out to be a bundler harness whose application lives
> in nine UUID-named script files that were not fetched. **Do not cite any
> figure below as established, and do not put one in public copy, until Q34
> closes.** The items marked ⚠️ are the specific ones in doubt. This caveat sits
> here rather than only in §9 because a reader who arrives at a `[verified]`
> stamp will otherwise never learn it is under review.
- It is a **single 2.2 MB HTML file** containing an entire React application,
compiled **in the browser at runtime** by Babel Standalone loaded from unpkg.
- React and ReactDOM are loaded as **development** builds from a public CDN.
*Confirmed on re-fetch 2026-08-26: the response is **2,206,032 bytes**, of
which **2,210,921** are inline `<script>`.*
- ⚠️ React and ReactDOM are loaded as **development** builds from a public CDN.
*Not reproducible 2026-08-26: the served file contains no reference to
`unpkg` or to a React development build. They may be inside the unfetched
bundles. Q34.*
- **Crawlers see almost nothing.** A server-side fetch of the URL returns only
`SML Company`, `DISPUTE RESOLUTION`, and the loading string `Unpacking...`.
Every word of real content is assembled client-side after three CDN round
trips. `[verified 2026-08-25 — direct fetch]`
- The deployed `<head>` has **no `<meta name="viewport">`**, no meta
description, no Open Graph or Twitter card tags, no canonical URL, and no
favicon. `[verified 2026-08-25]`
favicon. `[verified 2026-08-25]` — *still true of the head a crawler parses.
Note for accuracy: the harness carries a second, JSON-escaped `<head>` inside
a `<script type="__bundler/template">` which does contain a viewport tag. It
is assembled by JavaScript, so it reaches a browser and not a crawler, which
is the distinction that matters here.*
- `robots.txt` returns **403**. There is no sitemap. `[verified 2026-08-25]`
- The two logo PNGs are ~1 MB combined and are **base64-inlined** into the HTML,
so they cannot be cached separately from the page. `[verified 2026-08-25]`
- ⚠️ The two logo PNGs are ~1 MB combined and are **base64-inlined** into the
HTML, so they cannot be cached separately from the page. `[verified
2026-08-25]` — **but a 2026-08-26 fetch found zero `base64,` occurrences
anywhere in the served document.** Q34. This figure is cited as the *reason*
for a standing convention in `CLAUDE.md` and `docs/02`, both of which now
carry the caveat. A related number, "470 KB PNG", appeared in `docs/02` with
no source anywhere in this repo and has been removed.
- `<title>` is still `SML Company · Dispute Resolution` — the pre-rebrand
placeholder title. `[verified 2026-08-25]`
- There is **no version control, no build step, and no test suite**. The
@@ -102,7 +126,7 @@ without an explicit instruction and a Change Log entry. `[verified 2026-08-25]`
| D12 | Agent working file | **This file**, maintained under the constitution above | Ad hoc notes |
| D13 | Licensure claims | **The site asserts the JD only.** No claim of licensure, call to the bar, or entitlement to practise law. The approved phrasing for the boutique role is **"active litigation exposure"** or **"involvement in litigation and ADR matters"** — never "practice" **in that context**. "Practice" describing Pouya's own ADR practice — "the dispute resolution practice of Pouya Lajevardi" — is correct and stays. Pouya's direction, 2026-08-26. **Explicitly interim — see Standing Reminders §12** | Stating LSO licence status; the word "practice" |
| D14 | Fee structure | **Single published rate card, all mediation matters at one rate.** Confirmed by Pouya 2026-08-26; figures in `docs/07-fees.md`. No tribunal-secretary line | Two-tier card (recommended by Claude, declined); ranges; fees on request |
| D15 | Analytics | **Privacy-first and cookieless** (Plausible or Fathom). No cookie banner, nothing to consent to, one line in the privacy policy | GA4; no analytics at all |
| D15 | Analytics | **Plausible.** Privacy-first and cookieless: no cookie banner, nothing to consent to, one line in the privacy policy. *Amended 2026-08-26 (Q31):* the original read "Plausible **or** Fathom", i.e. undecided, and a guessed `'plausible'` had already leaked into `src/data/site.ts` from it. **Decided, not defaulted** — Pouya checked rather than assumed and found his own assumption wrong: Fathom is Canadian-owned, which is the intuitive answer here, but it stores non-EU traffic on **US servers** and isolates 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, and ownership is not the axis that matters | GA4; no analytics at all; **Fathom** — rejected on data residency, not on quality |
| D18 | Intake email | **Two emails per submission** — notification to `info@smlcompany.ca`, and a **confirmation receipt to the inquirer**. Pouya, 2026-08-26. The receipt is what requires SES production access (Q19) | Notification only; no email; no form at all |
| D17 | Working method | **Pouya architects; Claude Code implements and then adversarially reviews its own work.** Six-phase loop encoded in `.claude/commands/build.md`; two independent review agents in `.claude/agents/`; extended thinking on by default. Standing agreement — not restated per prompt | Implement-and-report; single generic reviewer; review only on request |
| D16 | Naming the boutique | **Never named.** Referred to throughout as *a Toronto litigation and ADR boutique*. Pouya's decision, 2026-08-26 | Naming the firm |
@@ -155,7 +179,8 @@ since May.
| Bilingual English and Farsi | `[verified 2026-08-25 — strategy brief §I]` |
| Iranian-Canadian; cross-cultural fluency with diaspora business communities | `[verified 2026-08-25 — strategy brief §I]` |
| Operator of SML Company Ltd. alongside the practice | `[verified 2026-08-25 — strategy brief §I]` |
| Memberships: **ADRIC**, **ADRIO**, and **OBA — Construction & Infrastructure, ADR, and Civil Litigation sections** | `[verified 2026-08-26 — Pouya]`, **for now**. Section membership renews yearly; this is a fact with a shelf life, tracked as R10 |
| **SML Company Ltd — incorporated FEDERALLY, under the CBCA** | `[verified 2026-08-26 — Pouya, Q30]`. Two facts were being conflated and one of them was wrong: **jurisdiction of incorporation is federal (Canada)**; **place of business is Toronto, Ontario**. `src/data/site.ts` carried `'SML Company Ltd. · Ontario, Canada'`, which reads as a jurisdiction of incorporation and named the wrong one. **No corporation number** — none is held and the line does not need one. **Caution, and it is the point of this row:** "federally incorporated" says nothing about professional licensure, and nothing about where the practice may operate. It must not be read together with the **Licence status — NOT ESTABLISHED** row into an implication that neither row makes. **Not published:** on Pouya's direction the footer reads `© <year> SML Company Ltd` and nothing further — the fact is verified and available, it is simply not on a page |
| Memberships: **ADRIC**, **ADRIO**, **OBA — Construction & Infrastructure, ADR, and Civil Litigation sections**, and the **Canadian Tax Foundation** | `[verified 2026-08-26 — Pouya]`, **for now**. *CTF added 2026-08-26.* Both the OBA sections and the CTF renew yearly; every line here is a fact with a shelf life, tracked as R10. Re-confirm at renewal and before any page listing memberships ships. **CTF is a membership, not a practice area** — it is the one credential none of the six areas touch, and `docs/01-architecture.md` records why there is no seventh page at launch and when to revisit (R3) |
| ~~OCNI~~ | **Not current. Do not publish** `[verified 2026-08-26 — Pouya]` |
| ~~LSO~~ | **Do not publish.** Listing the Law Society among memberships implies licensure, which D13 bars. Excluded deliberately, not by oversight `[verified 2026-08-26]` |
| Toronto, Ontario; by appointment | `[verified 2026-08-26]` |
@@ -237,12 +262,12 @@ the audience it targets. Revisit at month 1218. `[verified 2026-08-25 — dec
|---|---|
| Framework | **Astro 7.2.7**, `output: 'static'` `[verified 2026-08-26 — npm view astro version, D1 as amended]`. `@astrojs/mdx` 7.0.8, `@astrojs/sitemap` 3.7.3, `sharp` 0.35.4 |
| Lint toolchain | ESLint **10.9.1**, `@eslint/js` 10.0.1, `globals` 17.11.0, `eslint-plugin-astro` **3.1.0**, `eslint-plugin-jsx-a11y` 6.10.2, `typescript-eslint` 8.68.0, `typescript` 5.9.3 — **every pin at the registry's `latest` except `typescript`** `[verified 2026-08-26 — npm view, all 14 pins]`. `@eslint/js` and `globals` are declared explicitly; before 2026-08-26 `eslint.config.js` imported them and they resolved only by npm hoisting accident. **Accessibility linting is on**`flat/jsx-a11y-recommended`, 36 rules `[verified 2026-08-26 — 7 rules fired on a deliberately inaccessible .astro file]`. `eslint-plugin-jsx-a11y@6.10.2` declares a stale `eslint ^3..^9` peer range; a one-line `overrides` entry in `package.json` resolves it. ⚠️ **`eslint-plugin-astro@3.1.0` declares `node ^22.22.3 \|\| ^24.16.0 \|\| >=26.3.0`**, which excludes Pouya's Node 25.6.0 — `npm install` prints EBADENGINE there. Dev-time only, and `nvm use` (Node 22 LTS, per `.nvmrc`) clears it. `typescript` is held at 5.x: `typescript-eslint` peers `<6.1.0` and `@astrojs/check` peers `^5 \|\| ^6`, so TypeScript 7.0.2 is unusable here `[verified 2026-08-26 — npm view]` |
| Lighthouse CI | **Not installed.** `@lhci/cli` was the sole source of all 10 `npm audit` findings (7 high, via `lighthouse → puppeteer-core → extract-zip`), 0.15.1 is `latest` so there was no clean upgrade, and it cannot run at all — no pages, no `lighthouserc`. Removed 2026-08-26; **`npm audit` now reports 0 vulnerabilities**. Re-add at build step 7 with a freshly verified pin (R11) `[verified 2026-08-26 — npm audit]` |
| Node | **22 LTS line**, `.nvmrc` holds `22` so it tracks the latest 22.x rather than freezing at a floor. `package.json` declares **`engines.node >=22.13.0`** and **`engines.npm >=9.6.5`** — the intersection of every dependency's own floor: Astro 7 and `@astrojs/mdx` 7 need `>=22.12.0`, ESLint 10 needs `^20.19.0 \|\| ^22.13.0 \|\| >=24` `[verified 2026-08-26 — npm view astro@7.2.7 engines, npm view eslint@10.9.1 engines]`. **Pouya's machine reports Node v25.6.0** `[verified 2026-08-26 — node -v]`, an odd-numbered non-LTS line; it satisfies the floor, but `nvm use` keeps local and CI on the same runtime |
| Lighthouse CI | **Not installed, and that is now stated wherever the check is claimed.** `@lhci/cli` was the sole source of all 10 `npm audit` findings (7 high, via `lighthouse → puppeteer-core → extract-zip`), 0.15.1 is `latest` so there was no clean upgrade, and it cannot run at all — no pages, no `lighthouserc`. Removed 2026-08-26; **`npm audit` now reports 0 vulnerabilities** `[verified 2026-08-26 — npm audit]`. Removing it left a **documented control that no longer exists**`docs/04`, `CLAUDE.md` and `/build` Phase 5 all required a run that had silently become impossible, which is Q22's defect in a different costume. Fixed 2026-08-26: the unavailability is stated in `docs/04-seo-spec.md` (budget table, the callout under Performance, and the post-launch checklist), `CLAUDE.md` (performance budget and the definition of done), `.claude/commands/build.md` Phase 5, `docs/06-deployment.md` (PR checks and the cutover checklist), and `.claude/agents/adversarial-reviewer.md` §4 — which is also told **not** to raise the absence as a finding. **Re-add at build step 7** with a pin verified that day and a check for a patched release; do not assume 0.15.1 is still the ceiling (R11) |
| Node | **22 LTS line**, `.nvmrc` holds `22` so it tracks the latest 22.x rather than freezing at a floor. `package.json` declares **`engines.node` = `^22.13.0 || >=24`** (verbatim — it excludes Node 23, an EOL odd line, which a flat `>=22.13.0` would have admitted) and **`engines.npm >=9.6.5`** — the intersection of every dependency's own floor: Astro 7 and `@astrojs/mdx` 7 need `>=22.12.0`, ESLint 10 needs `^20.19.0 \|\| ^22.13.0 \|\| >=24` `[verified 2026-08-26 — npm view astro@7.2.7 engines, npm view eslint@10.9.1 engines]`. **Pouya's machine reports Node v25.6.0** `[verified 2026-08-26 — node -v]`, an odd-numbered non-LTS line; it satisfies the floor, but `nvm use` keeps local and CI on the same runtime |
| Styling | Plain CSS with custom properties. No Tailwind, no CSS-in-JS `[verified 2026-08-25]` |
| Client JS | Astro islands only, where genuinely needed. Target: most pages ship zero JS `[verified 2026-08-25]` |
| Client JS | **Zero. Not "minimal" — none.** `dist/type-scale/index.html` contains **0 `<script>` tags** and `dist/_astro/` contains no JS bundle `[verified 2026-08-26 — built, counted, and confirmed in headless Chrome]`. The reveal was an inline `IntersectionObserver` in `<head>` until 2026-08-26; it was removed because `docs/05-backend-spec.md` 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 run. `animation-timeline: view()` in `global.css` replaces it. **Both scroll-driven animations must use `animation-*` LONGHANDS** — Lightning CSS folds the `animation` shorthand together with `animation-timeline` into an invalid declaration and discards it, which works in `npm run dev` and is dead in `npm run build`. It happened twice in one session: once in the header condense, then again in the fix written for it. `/build` Phase 5 now greps `dist` for it. Islands remain available where a feature genuinely cannot be CSS or progressive HTML — none has been needed |
| Content | Astro **Content Layer API** collections, MDX for Insights. Config lives at **`src/content.config.ts`**; collections declare a `loader`, not a `type`; `z` imports from `astro/zod`. **Astro 5 introduced** the Content Layer API and this location; **Astro 6 removed** the legacy `src/content/config.ts` fallback — moving the file back now raises `LegacyContentConfigError` `[verified 2026-08-26 — reproduced by moving it back]` |
| Fonts | Instrument Serif + Geist + Geist Mono, **self-hosted**, `font-display: swap` `[verified 2026-08-25]` |
| Fonts | Instrument Serif + Geist + Geist Mono, **self-hosted**, `font-display: swap`. **Files present as of 2026-08-26** — six `.woff2` cuts, **123,804 bytes** total, in `public/fonts/` (`du -sh` says 136K; that is disk blocks, not transfer, and this row is read in a performance context); provenance, licences (all SIL OFL 1.1) and the reason they are committed rather than pulled at build are in `docs/reference/fonts-provenance.md`. Before this they were *declared* in `global.css` and **absent from disk**, so every face silently fell back to Georgia and the system sans `[verified 2026-08-26 — built and listed]`. Instrument Serif 400 latin (21,032 B) and Geist latin (29,400 B) are preloaded in `BaseLayout.astro` — about 50 kB at first paint; nothing else is. Every font URL carries **`?v=1`**, because the deploy script serves `/fonts/*` `immutable` for a year: replacing a cut means bumping that query in `global.css` and in the preload together `[verified 2026-08-26 — byte counts]` |
| AWS account | `327082975128` `[verified 2026-08-26 — inventory]` |
| Region | **`ca-central-1`** throughout — hosting, Lambda, DynamoDB `[verified 2026-08-26]` |
| S3 bucket | **`adr-smlcompany-site`** — versioning **Enabled**, so rollback works `[verified 2026-08-26]` |
@@ -278,7 +303,7 @@ the audience it targets. Revisit at month 1218. `[verified 2026-08-25 — dec
| **Deploy credential — NOT PROVISIONED** | `aws iam get-user --user-name adr-sml-deploy` returns **NoSuchEntity** `[verified 2026-08-26 — Q22]`. The scoped IAM user does not exist, no access key has been issued, and the Gitea secrets are unset. **No file may describe this credential as existing.** Creation commands are with Pouya; this is a to-do, not an open question |
| **How deploys actually happen today** | **Locally, via `npm run deploy`** (`scripts/deploy-local.sh`) — same guard, same three sync passes, same cache headers, same invalidation as the workflow. Gitea Actions needs `[actions] ENABLED` in `app.ini` and a registered `act_runner`, both of which need the instance's second administrator (Q23). At this scale the pipeline changes only **how a deploy is triggered**, not what it does `[verified 2026-08-26]` |
| **Interim auth — do not repeat in CI** | Pouya has been authenticating as **`user/pouya`**, the broadly-permissioned personal user the Q10 inventory captured. Acceptable for interactive work at a keyboard; **never** as a CI credential — see §10 `[verified 2026-08-26]` |
| Analytics | **Plausible or Fathom** — cookieless, no personal data, no consent banner, EU-hosted `[verified 2026-08-26 — D15]` |
| Analytics | **Plausible** — cookieless, no personal data, no consent banner, **all data held in the EU** `[verified 2026-08-26 — D15 as amended, Q31]`. `src/data/site.ts` sets `ANALYTICS.provider`. Not yet installed: no script is on any page, and `/legal/privacy/` (step 10) is where the processor gets named |
## 8. Design system
@@ -326,8 +351,14 @@ Nothing below can be invented. Each needs an answer from Pouya.
| ~~Q27~~ | **ANSWERED 2026-08-26 — two business days.** A public commitment, so it must read identically everywhere it appears: `src/data/site.ts`, `/contact/`, the inquirer confirmation email, and any bio | — |
| ~~Q28~~ | **ANSWERED 2026-08-26 — OBA Construction & Infrastructure, ADR, and Civil Litigation** `[verified 2026-08-26 — Pouya]`, **for now**: section membership renews yearly, so this is a fact with a shelf life. Tracked as R10 | — |
| ~~Q29~~ | **ANSWERED 2026-08-26 by implementation.** Guard widened on both counts: the two secrets are checked (emptiness only, never echoed), and `INTAKE_ENDPOINT` is promoted to job-level `env:` and guarded. Every `vars.*` and `secrets.*` the workflow consumes is now covered except `BOOKING_URL`, empty by decision (R6) | — |
| **Q30** | **What is SML Company Ltd.'s jurisdiction of incorporation, and should it appear in the footer?** `src/data/site.ts` carried `entity: 'SML Company Ltd. · Ontario, Canada'`. §4 verifies "Operator of SML Company Ltd." and "Toronto, Ontario" — the latter about **Pouya**, not the company's incorporation. Jurisdiction dropped pending an answer; the operator fact stays. Raised by `claims-auditor`, 2026-08-26 | The site-wide footer |
| **Q31** | **Plausible or Fathom?** D15 records the choice as "Plausible **or** Fathom" — undecided. `src/data/site.ts` had `provider: 'plausible'`, a guessed value, in a file whose own header says not to guess one to make the build pass. Set to `null` with a `TODO(pouya)`. Raised by `claims-auditor`, 2026-08-26 | Analytics; the privacy-policy line that names the processor |
| ~~Q30~~ | **ANSWERED 2026-08-26 — federally, under the CBCA**, and it does **not** appear in the footer. Recorded in §4 with the caution that it implies nothing about licensure or about where the practice may operate. The footer reads `© <year> SML Company Ltd` and stops there (Pouya's direction, given twice). `src/data/site.ts` un-nulled, `TODO(pouya)` removed. Note the spelling: §4 writes *SML Company Ltd.* with a terminal period; the rendered string has none, because that is how Pouya specified it | — |
| ~~Q31~~ | **ANSWERED 2026-08-26 — Plausible**, and decided rather than defaulted. Pouya checked and found his own assumption wrong: Fathom is Canadian-owned but stores non-EU traffic on US servers, isolating in the EU only for EU visitors; Plausible holds everything in the EU. D15 amended with that reasoning. The value that was guessed and nulled is now the value that was chosen — which is not the same thing, and the difference is why it was nulled | — |
| **Q35** | **May the header nav and footer sitemap name a service or practice area the register does not establish as a present capability, before the page that qualifies it exists?** This is the architecture-versus-register question that `claims-auditor` has now raised twice, and it is not one an implementer should settle. The nav and footer ship on all nineteen pages and today carry **`Arbitration`**, **`Med-Arb`**, **`Energy, Grid & Regulatory`** and **`Shareholder & Family Business`** — none of which trace to a §4 row. Every one of them is also **locked architecture**: D5 and §6 fix the sitemap, and `docs/01` specifies each page. The specs already anticipate the tension and answer it at page level — `docs/03`: *"Frame as positioning, not as history"*; `docs/01` on energy: *"Write it as a genuine position, not a claim of existing volume"* — so the question is precisely whether a **one-word nav label**, which has no room to frame anything, inherits that licence. Three ways out: accept the labels as taxonomy and rely on the pages to qualify (status quo), rename toward capability-neutral wording, or hold items out of the nav until their page ships. **Claude Code declined to act unilaterally on either audit pass**; recorded so the judgement is visible rather than missed | The header and footer on every page; sharpest at step 5 |
| **Q36** | **Is co-arbitration work available now?** Asserted in three specs — `docs/01` §`/arbitration/`, `docs/03` §Voice as model copy, and `docs/07-fees.md`, which instructs *"The page must say so"* — and **it is in no §4 row.** §4 records the Q.Arb pathway as commenced August 2026 and nothing about what is available in the meantime. The sentence is offered under the heading *"Name the limits"*, so the unverified half travels inside advice about honesty, which is how it survived. All three files now cite this question; none has been deleted, because the *shape* of the sentence is right and only its second clause is unsourced. Raised by `claims-auditor`, 2026-08-26 | `/arbitration/` (step 4) and `/fees/` (step 9) |
| **Q37** | **Is "Law and engineering" the right label under `JD + ML`?** §4's substitution principle supplies the three credential-row **values** (`Q.Med` / `JD + ML` / `EN · FA`); the labels come from `docs/03` and are not themselves registered. `claims-auditor` reads this one as a D13 implication risk: "engineering" is shorthand for a **verified practising** claim, so the parallel invites "Law" to be read the same way, and D13 bars implication as hard as assertion. Left as written — it is approved copy and changing approved copy is not an implementer's call — with a `TODO(pouya)` on it in `src/data/site.ts`. Nothing renders it yet. Raised by `claims-auditor`, 2026-08-26 | The credential row on `/` (step 2) and `/about/` (step 3) |
| **Q33** | **How is arbitration described in site-wide copy before Q.Arb is conferred?** Not a wording question — a positioning one, and it recurs on every page. §4 records the Q.Arb pathway as **commenced August 2026**, "never as held or nearing completion". `SITE.tagline` reads `Mediation · Arbitration · Toronto`, and `docs/01` specifies that exact string as the **Home hero eyebrow** — where the hero copy beneath it can qualify it. Build step 1 promoted it to a masthead line under Pouya's name on every page, where nothing qualifies it and it reads as a designation strip; `claims-auditor` flagged it and **it has been removed from the header**. But the question it exposed is still open and lands at build step 2: does the hero eyebrow keep `Arbitration` as specified, qualify it, or hold it until designation? `/arbitration/` (step 4) is separately specced to state the position plainly, which is the honest model — the issue is the one-line forms that have no room to. **Second pass, same day:** the removal from the header did not sweep the proof sheet, where the same string was **hand-typed** as a type specimen and rendered ten times at up to 96px. Both defects fixed — the specimen is now a neutral pangram — and the lesson is the sweep rule again: a removal is a `grep`, not a memory. The nav item `Arbitration` also survives, deliberately; that is Q35. Raised by `claims-auditor`, 2026-08-26 | The Home hero (step 2); `docs/04`'s specified home `<title>`; any bio or one-line descriptor |
| **Q34** | **Do §2's measurements of the previous build still hold, and were they measured against the deployed file?** Two of `claims-auditor`'s findings were unverified old-site assertions published in copy, which sent Claude Code to measure the live URL on 2026-08-26. What came back does not straightforwardly match §2. **Reproduced:** the response is a single **2,206,032-byte** file, and `<title>` is still `SML Company · Dispute Resolution`. **Could not be reproduced from the served HTML:** there are **zero** `base64,` occurrences anywhere in it — §2 records "two logo PNGs ~1 MB, base64-inlined" — and no reference to `unpkg` or a React development build. **Why this is a question and not a correction:** the served document is a *bundler harness*. 2,210,921 bytes of it are inline `<script>`, the real `<head>` sits JSON-escaped inside a `<script type="__bundler/template">`, and the application is loaded from **nine UUID-named script files that were not fetched**. The logos and the CDN references may well be in those bundles. So §2 may be right, may be stale, or may have been measured against the local source rather than the deployment — this record cannot tell which. **Why it matters beyond tidiness:** `docs/02` and `CLAUDE.md` both cite the base64-logo figure as the *reason* for a standing convention, and §12 R8 exists because unverified premises propagate. Nobody should quote these numbers in public copy until the question closes. Raised by Claude Code, 2026-08-26 | Nothing structural. It governs whether §2's figures may be cited as fact |
| **Q32** | **Is there a canonical vector file for the SML infinity mark?** `src/components/InfinityMark.astro` is built from geometry lifted verbatim from the deployed site's own loading-thumbnail SVG — the element it labels `smlMark`, fetched from `https://adr.smlcompany.ca/` on 2026-08-26. Path, both stroke widths (28 / 6) and the 0.7 inner opacity are the source's; the only change is folding a `translate(60 0)` into the coordinates. So it is SML's own artwork rather than a redrawing — but a loading placeholder is not necessarily the canonical file, and D7 says the mark carries over *unchanged*. If a master SVG or AI/EPS exists, supply it and the component gets replaced. Raised by Claude Code, 2026-08-26 | Nothing — the mark renders. This is about fidelity, not a blocker |
| **Q23** | **Is a Gitea `act_runner` registered, and are Actions enabled in `app.ini`?** *The version half is closed:* the instance reports **1.27.2** `[verified 2026-08-26 — /api/v1/version]`, so the `vars` context is supported and the first-step guard is belt-and-braces rather than load-bearing. **What remains is not a fact to look up but a dependency on a person:** git.larsnolden.com is **jointly administered**, and both enabling Actions and registering a runner need the second administrator. Until then `npm run deploy` is the path, and it is documented rather than treated as a workaround | How deploys are triggered — not what they do |
## 10. Risks
@@ -379,13 +410,13 @@ never being raised again.
|---|---|---|---|
| R1 | **Licensure.** No site exists yet — `src/pages/` does not exist. **As specified**, the copy will assert the JD only and describe the boutique role as *active litigation exposure*, never *practice*. Pouya flagged that as an interim position and asked to be reminded to change it. §4 now records licence status as `[unestablished]`, which is the honest state, not a resolution | 2026-08-26 | If he is licensed and in good standing, it is the first credential appointing bodies and opposing counsel look for, and its absence from a detailed credentials page is conspicuous. If licensure is in progress, the copy should be rewritten the moment it completes. Either way this is a **temporary** framing that will otherwise ossify |
| R2 | **Matter counts stay off the site until they are independently credible.** Revisit once there is a number a sophisticated GC would find persuasive on its own | 2026-08-26 | §4 forbids them now. That rule has an expiry date nobody has set |
| R3 | **Indigenous engagement practice page.** Deliberately omitted at launch (§6). Revisit at month 1218 | 2026-08-26 | The strategy brief rates it the most valuable single niche. Omission is a timing call, not a permanent one |
| R3 | **The month 1218 practice-area review — now two candidates, not one.** *(a)* **Indigenous engagement**, deliberately omitted at launch (§6). *(b)* **Tax-adjacent disputes**, added 2026-08-26 on the Canadian Tax Foundation membership: it is the one credential none of the six areas touch, and valuation, earn-out, indemnity and shareholder disputes that turn on a tax position are ADR territory. Reasoning for both is in `docs/01-architecture.md`. One review, two candidates | 2026-08-26 | The brief rates the Indigenous niche the most valuable single one, and omission there is a timing call rather than a permanent one. The tax case is the same shape and fails for the same reason today: a practice page is a claim of present capability, and a membership is a credential, not a caseload. Both stop being true at some point, and nothing will tell you when |
| R4 | **Insights cadence.** D9 commits to monthly. A blog that stops is worse than one that never started | 2026-08-26 | The section's whole value is compounding |
| R5 | **Fee review at 12 months.** Published rates are sticky; the right moment to move them is deliberate, not reactive | 2026-08-26 | D14 is priced for where the practice is going, not where it is |
| R6 | **Booking tool.** Parked by Pouya on 2026-08-26; `/contact/` ships with the intake form and a reserved slot for an embed | 2026-08-26 | He asked to be reminded. D10 committed to booking because it removes the back-and-forth that loses appointments — the form alone is a partial answer |
| R9 | **The SES alarms notify nobody until the `ses-alerts` email subscription is confirmed.** `SES-BounceRate-High` and `SES-ComplaintRate-High` are configured and live; the SNS email subscription to `info@smlcompany.ca` is **pending confirmation**, and an unconfirmed subscription drops every message | 2026-08-26 | A monitoring control that exists but does not deliver is worse than none, because it reads as covered. At this volume five bounces can cross the ~5% suspension threshold. Tracked in §7 and on the cutover checklist, but a one-click task nobody owns is exactly what §12 is for |
| R10 | **OBA section membership renews yearly.** §4 verifies Construction & Infrastructure, ADR, and Civil Litigation as of 2026-08-26 — *for now* | 2026-08-26 | A credential that lapses quietly is the failure mode §4 exists to prevent, and OCNI already did exactly this (§4 records it as "not current, do not publish"). Re-confirm at renewal, and before any page listing memberships ships |
| R11 | **Re-check dependency currency at every phase boundary in the build order** (`docs/01-architecture.md` §Build order, 11 steps). Run `npm view <pkg> version` across **every** pin in `package.json` and compare; do not wait for something to break. Verified does not mean latest — record the reason for any deliberate hold in §7 | 2026-08-26 | `astro: "^5.0.0"` was recalled rather than checked and was two majors stale the day it was written, which meant a framework carrying high-severity XSS advisories. Between phases is cheap; after a phase of pages is written is not. The build order has ten more boundaries |
| R10 | **Annually-renewing memberships.** §4 verifies the **OBA sections** (Construction & Infrastructure, ADR, Civil Litigation) and the **Canadian Tax Foundation** as of 2026-08-26 — *for now*. Both renew yearly. *Widened 2026-08-26 when CTF was added; it was OBA-only before* | 2026-08-26 | A credential that lapses quietly is the failure mode §4 exists to prevent, and OCNI already did exactly this (§4 records it as "not current, do not publish"). Re-confirm at each renewal, and before any page listing memberships ships `/about/` at build step 3 is the first one that will |
| R11 | **Re-check dependency currency at every phase boundary in the build order** (`docs/01-architecture.md` §Build order, 11 steps). Run `npm view <pkg> version` across **every** pin in `package.json` and compare; do not wait for something to break. Verified does not mean latest — record the reason for any deliberate hold in §7. **Carries one specific re-add trigger: at step 7, put `@lhci/cli` back.** Check for a patched release — do not assume `0.15.1` is still the ceiling just because it was on 2026-08-26 — pin it against `npm view` that day, write a `lighthouserc` with the budgets from `docs/04-seo-spec.md`, and delete the UNAVAILABLE notices this repo now carries in six places (§7 lists them). If the advisories are still unfixed, that is a decision to take deliberately, not a reason to leave the gap unstated | 2026-08-26 | `astro: "^5.0.0"` was recalled rather than checked and was two majors stale the day it was written, which meant a framework carrying high-severity XSS advisories. Between phases is cheap; after a phase of pages is written is not. The build order has ten more boundaries |
| R12 | **`compressHTML: true` is a deliberate deviation from the Astro 7 default (`'jsx'`).** Measured 2026-08-26: in an `.astro` template an inline pair split across two lines renders as `<em>a</em><strong>b</strong>` under the default — the space is silently deleted. MDX prose is unaffected | 2026-08-26 | It is a deviation, and undocumented deviations become folklore. Revisit **with a measurement**, not a preference — and re-measure after any Astro major, since the behaviour could change again |
| ~~R7~~ | **RATIFIED / SUPERSEDED 2026-08-26.** (a) Cache-policy table matching the pipeline — **accepted**; documenting what the pipeline does beats documenting an intention. (b) `s3:AbortMultipartUpload` omitted — **accepted, reasoning corrected**: the lifecycle rule does not exist and is therefore not the cover; the actual cover is that `aws s3 sync` only goes multipart above 8 MB and the largest asset is a 357 KB portrait. Recorded in `docs/06-deployment.md` with a revisit trigger. (c) The `aws s3 ls` pre-flight — **superseded** by the variable guard now running as the workflow's first step | 2026-08-26 | — |
| ~~R8~~ | **PROMOTED TO A RULE 2026-08-26.** A reminder was too weak for a pattern that survived three entries. *A sweep is a command, not a claim* now sits in `CLAUDE.md` under Conventions, in `/build` Phase 6, and in `/wrap` step 3: any claim that a change was applied across files must cite the command and be written only after reading its output | 2026-08-26 | — |
@@ -394,6 +425,267 @@ never being raised again.
# Change Log
## 2026-08-26 (u) — Build step 1. Zero JavaScript, and both review passes found defects in the first pass's fixes
**Who:** Pouya ruled on CTF, Q30, Q31 and the Lighthouse gap, and set the
direction for step 1. Claude Code implemented under `/build`;
`adversarial-reviewer` and `claims-auditor` each ran **twice**, and the second
pass is where most of this entry comes from.
### What Pouya decided
- **Canadian Tax Foundation membership** added to §4 and to
`CREDENTIALS.memberships`, `[verified 2026-08-26 — Pouya]`, **for now** — it
renews yearly, so **R10 was widened** from "OBA section membership" to
"annually-renewing memberships" covering both.
- **Not a seventh practice area at launch.** CTF is a credential none of the six
areas touch and tax-adjacent disputes are ADR territory, but there is no track
record to point at. Recorded in `docs/01` beside the Indigenous-engagement
omission, and **R3 widened from one candidate to two** — one month 1218
review, two questions.
- **Q31 CLOSED — Plausible**, decided rather than defaulted. Pouya checked and
found his own assumption wrong: Fathom is Canadian-owned but stores non-EU
traffic on US servers, isolating in the EU only for EU visitors; Plausible
holds everything in the EU. **D15 amended** from "Plausible **or** Fathom" to
Plausible, with that reasoning and with Fathom recorded as rejected on data
residency rather than on quality.
- **Q30 CLOSED — SML Company Ltd is incorporated FEDERALLY, under the CBCA.**
Two facts were being conflated and one was wrong: `site.ts` carried
`'SML Company Ltd. · Ontario, Canada'`, which reads as a jurisdiction of
incorporation and named the wrong one. Jurisdiction of incorporation is
federal; place of business is Toronto, Ontario; they are different facts.
**Neither is published** — the footer reads `© <year> SML Company Ltd` and
stops, on Pouya's direction given twice. No corporation number: none is held
and none is needed. §4 carries the caution that "federally incorporated" says
nothing about licensure and nothing about where the practice may operate, and
must not be read together with the **Licence status — NOT ESTABLISHED** row
into an implication neither makes.
- **The Lighthouse gap, ratified and closed as a documented-control problem.**
Removing `@lhci/cli` was right — seven high-severity advisories for a tool
that could not run — but it left `docs/04`, `CLAUDE.md` and `/build` Phase 5
all requiring a check that had silently become impossible. That is Q22's
defect in a different costume. The unavailability is now stated in **six
places**, listed in §7, and `adversarial-reviewer` is told **not** to raise the
absence as a finding. **R11 carries the re-add trigger** for step 7, including
"check for a patched release; do not assume `0.15.1` is still the ceiling".
### What shipped
`src/layouts/BaseLayout.astro`, `src/components/{SEO,SiteHeader,SiteFooter,Button,InfinityMark}.astro`,
`public/fonts/` (six cuts), `public/favicon.{svg,ico}`, `public/apple-touch-icon.png`,
and a temporary proof sheet at `/type-scale/` 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. The design system had
never once rendered as specified. Six cuts committed, **123,804 bytes** (not the
136 K `du -sh` reports — that is disk blocks, and §7 is read in a performance
context), provenance and SIL OFL licences in `docs/reference/fonts-provenance.md`.
Every URL carries `?v=1` because the deploy script serves `/fonts/*` `immutable`
for a year and no invalidation reaches a browser cache.
**The infinity mark was recoverable, and is not a redrawing.** The deployed site
serves its own loading-thumbnail SVG labelled `smlMark`; `InfinityMark.astro` is
that path verbatim, with the source's `translate(60 0)` folded into the
coordinates. Stroke widths (28/6) and the 0.7 inner opacity are the source's.
Geometry verified to fit its viewBox with 6 units of margin, so it cannot clip at
any size. **Q32 opened** — a loading placeholder is not necessarily the canonical
file, and D7 says the mark carries over *unchanged*.
**The proof sheet computes rather than restates.** It reads `tokens.css` at build
time and runs the WCAG luminance formula over it. All eleven ratios reproduce
`docs/02`'s measured table exactly — 16.81, 11.75, 12.29, 8.95, 5.47, 3.11, 2.10
on cream; 16.81, 11.09, 8.00, 3.07 on ink. It cannot drift from the tokens
because it is derived from them.
**Insights is gated, not hardcoded.** `docs/01` keeps the section out of primary
nav until two pieces are live, so `SiteHeader` reads the collection count at
build time. It does not render today and appears by itself at step 7.
### Decisions taken during implementation
- **ZERO JavaScript, not "minimal".** 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, and a per-build hash drifts from the
policy pinning it. Replaced with `animation-timeline: view()` behind
`@supports`. **0 `<script>` tags and 0 `.js` files in `dist/`.** `docs/02`
§Motion amended: the `IntersectionObserver` route is now ruled out, not merely
second choice. `docs/05` gained the converse note — inline `style=` attributes
are now load-bearing, so hashing `style-src` would collapse the infinity mark.
- **Header sticky from 64rem, not `docs/02`'s "sticky".** Measured: the one-row
header must hold brand + **seven** nav items + CTA, which it does at 1024px
with 32px clearance and not below. Below 64rem the nav takes its own row and
the header is 137px (tablet) / 185px (phone) — more of a small viewport than a
sticky header is worth. `docs/02` amended with the measured numbers.
- **"Condenses on scroll" is now a rule and a shadow, not a size change.** A
`position: sticky` header stays in normal flow, so shrinking its padding
shortens its layout box at the top of the document and lifts every page below
it — a scroll-linked layout shift on every page, against the CLS < 0.05 budget.
- **`--section-y` corrected.** `6vw + 2rem` reaches its own upper bound only at a
2133px viewport, so the "160px desktop" half of `docs/02` was never delivered
(measured 108.8px at 1280, 128px at 1600). Now `9vw + 1rem` — 160px at 1600px.
- **The measure is opt-in.** A global `p { max-inline-size: 68ch }` capped every
paragraph in every card and footer, forced components to opt back out, and made
`.prose` a class with no effect. Moved onto `.prose`.
- **`robots.txt` disallows nothing.** `Disallow` + `noindex` on the same path
cancel out: a crawler forbidden to fetch a URL never reads the `noindex`. The
legal pages are footer-linked from every page, so the pair would likely have
produced bare-URL listings with the suppressing directive unread. `docs/04`
amended.
### What the reviews found — including in each other's fixes
**Round 1 — code (5 blocking, 7 should-fix).** All fixed and re-measured:
the `animation` shorthand silently collapsed by Lightning CSS (dead in every
production build, alive in dev); a seven-item nav that fit at no width; parent
styles that never reach a child component's root in Astro; gold text shipped at
**1.76:1**; 27 touch targets under 44px; revealed content printing blank; a skip
link landing behind the sticky header; 72px of overflow at 320px masked by
`body { overflow-x: hidden }`.
**Round 1 — claims (FAIL, 8).** The masthead tagline `Mediation · Arbitration ·
Toronto` — never specified for the header, and unqualified `Arbitration` beneath
a name reads as a held capability against §4's Q.Arb row. Removed; **Q33 opened**
for the form the question takes at step 2. A four-stage service timeline and two
unverified assertions about the previous build, both removed from a page that
exists to demonstrate CSS.
**Round 2 found defects in round 1's fixes — four of them.**
1. **The minifier bug was written back into its own fix.** Having just diagnosed
that `animation:` beside `animation-timeline` gets folded into an invalid
declaration, the replacement reveal used the same shorthand. Caught only
because the print test still failed. Both are longhand-only;
**`/build` Phase 5 now greps `dist` for it** and `CLAUDE.md` carries the rule.
2. **The claims fixes were silently lost.** The patch applying all four aborted
on a later assertion *before* writing the file, having already reported four
successes. The service timeline was still in the source and still in the
build.
3. **The colour-alone fix used the banned pairing.** Nav state was given a gold
underline so colour would not carry meaning alone — gold on cream is 2.10:1,
below WCAG 1.4.11's 3:1 for a state indicator, so it carried nothing and the
state reverted to colour alone. Now maroon at 12.29:1, 2px, dotted for section
and solid for current page.
4. **`--header-h` was stamped `[measured]` before anything was measured.** It
said 4.75rem. Actual is **77px** at every sticky width, six items and seven.
5. Plus: a retracted "470 KB PNG" figure surviving in a file written the same
day; two "measured" comments giving 34px and 14px for the same measurement;
`docs/02` amended to a 60rem threshold the code no longer used; flex `order`
putting focus order out of step with visual order between 640 and 1023px.
**Round 2 — claims (FAIL, 10).** The most serious was inside the review
apparatus: **`.claude/agents/claims-auditor.md` said "Memberships. ADRIC, ADRIO,
OBA sections **only**"** — contradicting a §4 that had gained CTF that morning.
It would have flagged a verified membership as unverified and would not have
noticed CTF being dropped. That file has now hosted a stale claim **twice**, so
the list was not updated — it was **removed**, replaced by an instruction to read
the §4 row at audit time. Also fixed: two specs instructing a public phone onto
pages against a §4 row that records none (Q3, answered four entries ago); a
`robots.txt` reproduced inline in `docs/04` that had already drifted; Q.Arb
described as "in progress" where §4 pins "commenced August 2026"; and headline
option 3 struck as asserting party, counsel *and* neutral.
**`ROLE` added to `site.ts`** — there was no constant for *"Director of Firm
Operations"* or *"active litigation exposure"*, the two highest-risk strings on
the site, and both were headed for hand-typing at step 3 and in the `Person`
JSON-LD. `npm run check` then caught a temporal-dead-zone error in that fix.
### Declined, with reasons
- **The nav and footer naming `Arbitration`, `Med-Arb`, `Energy` and
`Shareholder`** — raised on both claims passes. Every one is locked
architecture (D5, §6, `docs/01`), and §4 registers claims about Pouya rather
than enumerating service lines. Removing them is not an implementer's call.
**Opened as Q35** so the judgement is visible rather than missed.
- **Inlining critical CSS.** `docs/04` names it, but the bundle is 22.5 KB
against a 4 KB `inlineStylesheets: 'auto'` threshold; inlining it into all
nineteen pages costs more than the one round trip it saves and destroys
cross-page caching. Revisit at step 11 against real Lighthouse numbers.
- **Simplifying `PRACTICE_SLUGS` / `PRACTICE_AREAS`.** A fair simplicity finding,
but it touches the content schema a prior review round hardened, and rewriting
it as a rider on step 1 is exactly how defects 1 and 2 above happened.
Deferred to its own change before step 7.
- **The sitemap 404.** `<link rel="sitemap">` removed as inert (it is not a
registered link relation and no crawler consumes it); the `robots.txt`
`Sitemap:` line resolves at step 2. **Step 1 is not deployed** — no deploy
credential exists (Q22) and deploys are manual.
- **Second-person copy on the proof sheet.** `docs/03` bars it on counsel-facing
pages; an internal proof sheet is not one, and the page is deleted at step 2.
### Verification — run, not asserted
`npm run check` 0/0/0 · `npm run lint` clean · `npm run build` 0 errors ·
`npm audit` **0 vulnerabilities** · **R11 dependency sweep: 13 of 14 pins at
registry `latest`**, the only drift being the deliberate `typescript` hold.
Measured in headless Chrome across 320/375/640/768/900/1000/1023/1024/1100/1200/1280/1440/1600,
**with a seventh nav item injected** to test the step-7 state that does not exist yet:
- Header **77px, one row, sticky** at every width ≥1024 with six items and seven;
137px static below; **0 overflow at every width including 320px**
- **0 tap targets under 44×44** at any width
- **0 focus-order inversions** (`checkVisibility`-filtered); closed `<details>`
panels contribute **0** hidden tab stops
- State indicators **12.29:1**; `--section-y` reaches exactly **160px at 1600px**
- `scroll-padding-top` computes to **93px** = 77 + 16, matching `--header-h`
- **0 `<script>` tags, 0 `.js` files**; both scroll-driven animations attach live
(`header-lift`/`scroll()`, `reveal-in`/`view()`)
- Printed to PDF: all four revealed cards present (they were absent before the
print override)
- **755 words** of body text from `curl` with no JavaScript — the old site
returns three
### Sweeps — the commands, not the claims (R8)
```
$ grep -rE 'animation:[^;}]*(scroll\(\)|view\(\))' dist --include='*.css'
(no output)
$ grep -rn "OBA sections only" . --exclude-dir={node_modules,.git,dist,.astro}
.claude/agents/claims-auditor.md:58:enumerate "ADRIC, ADRIO, OBA sections only"; the Canadian Tax Foundation was
(the sole hit is the note recording its removal)
$ grep -c '<script' dist/type-scale/index.html -> 0
$ find dist -name '*.js' | wc -l -> 0
$ grep -c "Mediation · Arbitration · Toronto" dist/type-scale/index.html -> 0
```
The `470 KB` sweep is the one worth recording as a near-miss: after retracting
the figure from `docs/02`, `grep -rn "470 KB"` found it **still asserted in
`src/components/InfinityMark.astro`, a file written the same day**. Without the
grep this entry would have claimed a completed retraction. Three previous entries
made exactly that mistake.
### Questions
**Closed:** Q30 (federal, CBCA), Q31 (Plausible).
**Opened:** **Q32** (canonical vector for the infinity mark) · **Q33** (how
arbitration is described in one-line site-wide copy before Q.Arb) · **Q34**
(whether §2's measurements of the previous build still hold — a re-fetch found
zero `base64,` occurrences, but the real application is in nine unfetched
bundles, so this is a question and not a correction; §2 now carries the caveat
inline, and `CLAUDE.md`, `docs/02` and `adversarial-reviewer.md` all cite it
where they quote the figure) · **Q35** (may the nav name a service the register
does not establish) · **Q36** (is co-arbitration work available now — asserted in
three specs, in no §4 row) · **Q37** (is "Law and engineering" the right label
under `JD + ML`).
**§12:** R3 widened to two candidates, R10 widened to all annually-renewing
memberships, R11 given the `@lhci/cli` re-add trigger. **R1 remains live and was
surfaced** — nothing in this change describes Pouya as licensed or as a legal
professional, and the header tagline that triggered Q33 is verified absent from
`dist`.
### Commit shape
Split in two on `adversarial-reviewer`'s own recommendation: instructions that
narrow a reviewer's scope should not travel in the same commit as the work that
reviewer is checking. The `.claude/` changes go in their own `chore:` commit so
the narrowing reads as a decision rather than as a line in a feature diff.
---
## 2026-08-26 (t) — Astro 5 → 7. The review found a defect in the fix, twice
**Who:** Pouya approved the upgrade and set the version-pin rule. Claude Code
+35 -2
View File
@@ -152,9 +152,33 @@ interactive elements reachable by keyboard, `prefers-reduced-motion` honoured on
every animation. Gold `#c9a876` never sits on cream — it fails contrast at
2.10:1. See `docs/02-design-system.md`.
**A parent cannot style a child component's root element.** Astro does not pass
a parent's scope attribute down, so `<Button class="header-cta" />` compiles the
parent's rule to `.header-cta[data-astro-cid-<parent>]` while the rendered `<a>`
carries only `<Button>`'s own cid. **The rule silently never matches** — no
error, no warning, and the CSS looks correct in the source. Wrap the child in an
element the parent owns (`<div class="header-cta"><Button …/></div>`), or reach
it deliberately with `:global()` from a parent-scoped ancestor. Inherited
properties (`white-space`, `color`, `font-*`) do cross the boundary and are the
exception. This cost a header CTA that was documented as hidden on mobile,
was not hidden, and sat 75 px short of the right edge on desktop — both found by
measurement, neither by reading. It will recur with `PracticeCard`,
`ArticleCard`, and `Pill`.
**Never write the `animation` shorthand beside `animation-timeline`.** Longhands
only — `animation-name`, `animation-duration`, `animation-timing-function`,
`animation-fill-mode`, then `animation-timeline` and `animation-range`.
`scroll()` and `view()` are not legal components of the shorthand, and Lightning
CSS folds the two declarations together on minify into something invalid, which
is then discarded whole. **It works in `npm run dev` and is dead in
`npm run build`** — the worst shape a defect can take. It happened twice in one
session, the second time inside the fix for the first. `/build` Phase 5 greps
`dist` for it; do not remove that check.
**Images.** Astro `<Image>` with explicit width and height. AVIF/WebP with
fallback. Never base64-inline an image into HTML — the old site did this with
~1 MB of logo PNGs.
~1 MB of logo PNGs — a figure `AGENTS.md` Q34 is now open against, so treat the
rule as standing on its own merits rather than on that number.
**Fonts.** Self-hosted, subset, `font-display: swap`, preloaded. No Google Fonts
request at runtime — it costs a round trip and adds a third-party call to a
@@ -207,12 +231,21 @@ actually been provisioned is `AGENTS.md` Q22. It must never reach the repo.
every page. Under 100 KB of JS on any route. LCP under 2.0 s on a simulated
Slow 4G connection. Treat a budget breach as a failing build.
**Lighthouse cannot currently be run.** `@lhci/cli` was removed on 2026-08-26
(it carried 7 high-severity advisories, `0.15.1` is `latest`, and it had no
pages and no `lighthouserc` to work with). The budget stands; the instrument is
missing. It is re-added at build step 7 under `AGENTS.md` R11 — with a freshly
verified pin, not on the assumption that `0.15.1` is still the ceiling. **Say
"not run — tool unavailable" rather than silently omitting it.** A documented
control that no longer exists is precisely the defect Q22 turned out to be.
## What "done" means for a page
- [ ] Copy written from `docs/03-content-spec.md`, every claim traceable to `AGENTS.md` §4
- [ ] No `TODO(pouya)` left unlogged in §9
- [ ] Unique title, meta description, canonical, OG/Twitter tags, JSON-LD
- [ ] Semantic HTML; keyboard navigable; reduced-motion honoured
- [ ] Lighthouse ≥ 95 mobile, all four categories
- [ ] Lighthouse ≥ 95 mobile, all four categories**UNAVAILABLE until step 7**
(see the performance budget above). Report it as not run; do not tick it
- [ ] Renders correctly with JavaScript disabled
- [ ] `AGENTS.md` Change Log entry appended
+4 -1
View File
@@ -32,7 +32,10 @@ export default defineConfig({
integrations: [
mdx(),
sitemap({
filter: (page) => !page.includes('/legal/'),
// /legal/* is noindex by spec (docs/04). /type-scale/ is the temporary
// step-1 proof sheet — delete this half of the condition with the page.
filter: (page) =>
!page.includes('/legal/') && !page.includes('/type-scale/'),
changefreq: 'monthly',
// No `lastmod`. It was `new Date()`, which stamped every URL with the
// build time — telling crawlers all 17 pages changed whenever one did.
+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.
+25
View File
@@ -21,6 +21,20 @@ export default [
...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: {
@@ -31,6 +45,17 @@ export default [
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': [
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

+17
View File
@@ -0,0 +1,17 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-232 -232 464 464">
<!-- The SML infinity mark. Geometry and colours match
src/components/InfinityMark.astro — see the provenance note there.
Kept as its own file rather than imported: the browser requests a favicon
at a fixed path before any stylesheet resolves.
SQUARE viewBox, deliberately. The mark is 11:7; browsers render an SVG
favicon into a square slot with xMidYMid meet, so an 11:7 viewBox
letterboxes — the cream ground becomes a floating stripe with transparent
bars above and below, which on a dark tab strip reads as a graphical
artefact rather than a logo. The square ground fills the slot. -->
<rect x="-232" y="-232" width="464" height="464" fill="#faf7f2"/>
<g fill="none" stroke-linecap="round" stroke-linejoin="round">
<path d="M -200 0 C -200 -160, 0 -160, 0 0 C 0 160, 200 160, 200 0 C 200 -160, 0 -160, 0 0 C 0 160, -200 160, -200 0 Z" stroke="#5a1a1c" stroke-width="34"/>
<path d="M -200 0 C -200 -160, 0 -160, 0 0 C 0 160, 200 160, 200 0 C 200 -160, 0 -160, 0 0 C 0 160, -200 160, -200 0 Z" stroke="#c9a876" stroke-width="7" opacity="0.7"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+11 -1
View File
@@ -2,8 +2,18 @@
# AI crawlers are deliberately allowed. Being read by an assistant that counsel
# is using to shortlist a neutral is the point. See docs/04-seo-spec.md.
# NOTHING IS DISALLOWED, DELIBERATELY.
# /legal/* and the temporary /type-scale/ are kept out of the index by
# `<meta name="robots" content="noindex,follow">`, which is the directive that
# actually de-indexes. Disallowing them as well would defeat it: a crawler that
# is forbidden to FETCH a URL never reads the noindex on it. The legal pages are
# linked from the footer of every page, so Google discovers them regardless and
# would have listed the bare URLs as "no information available" — the opposite
# of the intent — with the noindex sitting unread behind the wall.
# Add a Disallow only for something that must not be FETCHED. Use noindex for
# something that must not be LISTED. They are different problems.
User-agent: *
Allow: /
Disallow: /legal/
Sitemap: https://adr.smlcompany.ca/sitemap-index.xml
+87
View File
@@ -0,0 +1,87 @@
---
/**
* docs/02-design-system.md: variants primary (maroon) · ghost (outlined) ·
* gold (ink background, gold-l text). Renders an <a> or a <button> correctly —
* a link that navigates must be an <a>, whatever it looks like.
*/
interface Props {
href?: string;
variant?: 'primary' | 'ghost' | 'gold';
type?: 'button' | 'submit';
class?: string;
}
const {
href,
variant = 'primary',
type = 'button',
class: className,
} = Astro.props;
const classes = ['btn', `btn-${variant}`, className];
---
{
href ? (
<a href={href} class:list={classes}>
<slot />
</a>
) : (
<button type={type} class:list={classes}>
<slot />
</button>
)
}
<style>
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
/* 44 × 44 is the touch-target floor in docs/02. */
min-block-size: 44px;
padding-block: var(--space-3);
padding-inline: var(--space-5);
border: 1px solid transparent;
border-radius: var(--radius-full);
font-size: var(--text-sm);
font-weight: var(--weight-medium);
letter-spacing: var(--tracking-tight);
line-height: 1.2;
text-align: center;
text-decoration: none;
cursor: pointer;
transition:
background-color var(--dur-hover) var(--ease),
border-color var(--dur-hover) var(--ease),
color var(--dur-hover) var(--ease);
}
.btn-primary {
background: var(--accent);
color: var(--text-inverse);
}
.btn-primary:hover {
background: var(--accent-hover);
color: var(--text-inverse);
}
.btn-ghost {
background: transparent;
border-color: var(--border);
color: var(--text);
}
.btn-ghost:hover {
border-color: var(--accent);
color: var(--accent);
}
.btn-gold {
background: var(--bg-inverse);
color: var(--text-inverse-2);
}
.btn-gold:hover {
background: var(--accent);
color: var(--text-inverse);
}
</style>
+66
View File
@@ -0,0 +1,66 @@
---
/**
* The SML infinity mark. AGENTS.md D7 keeps it unchanged; docs/02 requires it
* be inline SVG rather than a raster. (The "~470 KB PNG" this comment used to
* cite had no source anywhere in the repo — see AGENTS.md Q34. The mark is
* geometry, which is reason enough without a number.)
*
* PROVENANCE — read before editing the path. This geometry is lifted verbatim
* from the deployed site's own loading-thumbnail SVG (the element it labels
* `smlMark`), fetched from https://adr.smlcompany.ca/ on 2026-08-26. The only
* change is arithmetic: the source drew the path offset by `translate(60 0)`
* inside a 1200×800 frame, and that offset is folded into the coordinates here
* so the curve is symmetric about the origin. Stroke widths (28 outer, 6 inner)
* and the 0.7 inner opacity are the source's.
*
* It is therefore SML's own artwork, not a redrawing — but it came from a
* loading placeholder, which is not necessarily the canonical file. See
* AGENTS.md Q32.
*
* Two-tone by design: the outer stroke takes `currentColor` so the mark sits
* correctly on cream, ink, and maroon; the inner hairline is gold. Gold as an
* icon stroke is explicitly allowed — the rule it must never break is gold as
* TEXT on cream, which measures 2.10:1 (docs/02).
*/
interface Props {
/** Rendered height. Width follows the 11:7 aspect ratio. */
size?: string;
/** Accessible name. Omit for decorative use — the default. */
label?: string;
class?: string;
}
const { size = '1.75rem', label, class: className } = Astro.props;
const decorative = label === undefined;
const PATH = `M -200 0
C -200 -160, 0 -160, 0 0
C 0 160, 200 160, 200 0
C 200 -160, 0 -160, 0 0
C 0 160, -200 160, -200 0 Z`;
---
<svg
class:list={['mark', className]}
viewBox="-220 -140 440 280"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
role={decorative ? undefined : 'img'}
aria-hidden={decorative ? 'true' : undefined}
aria-label={label}
focusable="false"
style={`block-size:${size}`}
>
<path d={PATH} stroke="currentColor" stroke-width="28"></path>
<path d={PATH} stroke="var(--rule)" stroke-width="6" opacity="0.7"></path>
</svg>
<style>
.mark {
/* 440 : 280 — the viewBox. Height is set inline; width follows. */
aspect-ratio: 11 / 7;
inline-size: auto;
flex: none;
}
</style>
+129
View File
@@ -0,0 +1,129 @@
---
/**
* The single metadata component. Spec: docs/04-seo-spec.md.
*
* "Every page passes through one SEO component. A page without it is not
* finished." — so the length rules in that spec are ENFORCED here rather than
* described. An out-of-range title or description throws at build time and
* names the offending string and its length, the same way src/content.config.ts
* does for article frontmatter. A build that fails on unfinished metadata is a
* correct build.
*/
import { getImage } from 'astro:assets';
import ogDefault from '../assets/og-portrait.jpg';
import { SITE, PORTRAIT } from '../data/site';
export interface Props {
/** The full rendered <title>. Pattern: "<Page> · Pouya Lajevardi". 5060. */
title: string;
/** 140160 characters, unique, written for a human. */
description: string;
/** Overrides the canonical path. Defaults to this page's own URL. */
canonical?: string;
ogType?: 'website' | 'article' | 'profile';
/** 1200×630 source. Defaults to the portrait crop in src/assets.
* `ImageMetadata` is an Astro ambient global — there is nothing to import. */
image?: ImageMetadata;
imageAlt?: string;
/** /legal/* and any temporary page. Emits noindex,follow per docs/04. */
noindex?: boolean;
/**
* Page-appropriate structured data — Person, ProfessionalService, Service,
* Article, BreadcrumbList, FAQPage. Passed in, never invented here: a default
* would be a claim this component is in no position to make.
*/
jsonLd?: unknown;
}
const {
title,
description,
canonical,
ogType = 'website',
image,
imageAlt,
noindex = false,
jsonLd,
} = Astro.props;
const TITLE_MIN = 50;
const TITLE_MAX = 60;
const DESC_MIN = 140;
const DESC_MAX = 160;
const problems: string[] = [];
if (title.length < TITLE_MIN || title.length > TITLE_MAX) {
problems.push(
`title is ${title.length} characters; docs/04-seo-spec.md requires ${TITLE_MIN}${TITLE_MAX}.\n ${JSON.stringify(title)}`,
);
}
if (description.length < DESC_MIN || description.length > DESC_MAX) {
problems.push(
`description is ${description.length} characters; docs/04-seo-spec.md requires ${DESC_MIN}${DESC_MAX}.\n ${JSON.stringify(description)}`,
);
}
if (problems.length > 0) {
throw new Error(
`SEO metadata out of range on ${Astro.url.pathname}\n - ${problems.join('\n - ')}\n` +
` Fix the string. Do not widen the range — these are the lengths Google renders.`,
);
}
// `site` drives canonical URLs, OG tags, and the sitemap. Without it every
// absolute URL below would silently become a relative one.
if (!Astro.site) {
throw new Error(
'astro.config.mjs must set `site`; SEO.astro needs it for canonical and OG URLs.',
);
}
const canonicalUrl = new URL(canonical ?? Astro.url.pathname, Astro.site);
// JPEG on purpose. Page images are AVIF/WebP with a fallback (CLAUDE.md), but
// link-preview crawlers are not browsers — LinkedIn and Slack do not negotiate
// content types, and several still do not decode WebP at all.
const ogImage = await getImage({
src: image ?? ogDefault,
format: 'jpeg',
width: 1200,
height: 630,
});
const ogImageUrl = new URL(ogImage.src, Astro.site);
// JSON.stringify does not escape `<`, so a "</script>" inside any string value
// would close this element early and hand the rest of the payload to the HTML
// parser. Escaping the angle bracket is the whole fix; JSON readers decode it.
const jsonLdText =
jsonLd === undefined ? null : JSON.stringify(jsonLd).replace(/</g, '\\u003c');
---
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="generator" content={Astro.generator} />
<title>{title}</title>
<meta name="description" content={description} />
<link rel="canonical" href={canonicalUrl.href} />
<meta name="robots" content={noindex ? 'noindex,follow' : 'index,follow'} />
<meta property="og:type" content={ogType} />
<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
<meta property="og:url" content={canonicalUrl.href} />
<meta property="og:site_name" content={SITE.name} />
<meta property="og:locale" content={SITE.locale} />
<meta property="og:image" content={ogImageUrl.href} />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta property="og:image:alt" content={imageAlt ?? PORTRAIT.alt} />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={title} />
<meta name="twitter:description" content={description} />
<meta name="twitter:image" content={ogImageUrl.href} />
<meta name="twitter:image:alt" content={imageAlt ?? PORTRAIT.alt} />
{
jsonLdText && (
<script type="application/ld+json" is:inline set:html={jsonLdText} />
)
}
+281
View File
@@ -0,0 +1,281 @@
---
/**
* 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();
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>
<p class="footer-designation">
{CREDENTIALS.designations.join(' · ')}
</p>
</div>
<div class="footer-grid">
<nav class="footer-nav" aria-label="Footer">
<div class="footer-col">
<h2 class="footer-heading">Practice areas</h2>
<ul role="list">
{
PRACTICE_AREAS.map((area) => (
<li>
<a href={`/practice/${area.slug}/`}>{area.name}</a>
</li>
))
}
<li><a href="/practice/">All practice areas</a></li>
</ul>
</div>
<div class="footer-col">
<h2 class="footer-heading">Process</h2>
<ul role="list">
{
processLinks.map((link) => (
<li>
<a href={link.href}>{link.label}</a>
</li>
))
}
</ul>
</div>
<div class="footer-col">
<h2 class="footer-heading">About</h2>
<ul role="list">
{
aboutLinks.map((link) => (
<li>
<a href={link.href}>{link.label}</a>
</li>
))
}
</ul>
</div>
</nav>
<div class="footer-col footer-contact">
<h2 class="footer-heading">Contact</h2>
<ul role="list">
<li><a href={`mailto:${CONTACT.email}`}>{CONTACT.email}</a></li>
<li><span class="footer-meta">{CONTACT.phoneFallback}</span></li>
<li><span class="footer-meta">{CONTACT.location}</span></li>
<li><a href={CONTACT.linkedin}>LinkedIn</a></li>
</ul>
<p class="footer-response">{CONTACT.responseTime}</p>
</div>
</div>
<div class="footer-bottom">
<p class="footer-entity">&copy; {year} {SITE.entity}</p>
<ul class="footer-legal" role="list">
{
LEGAL_NAV.map((link) => (
<li>
<a href={link.href}>{link.label}</a>
</li>
))
}
</ul>
</div>
</div>
</footer>
<style>
.site-footer {
background: var(--bg-inverse);
color: var(--text-inverse);
padding-block: var(--space-9) var(--space-6);
margin-block-start: var(--space-9);
}
.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>
+400
View File
@@ -0,0 +1,400 @@
---
/**
* 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);
---
<header class="site-header">
<div class="wrap header-inner">
<a class="brand" href="/">
<InfinityMark size="2rem" />
<span class="brand-name">{SITE.name}</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 --------------------------------------------------------------- */
/* No tagline under the name. `SITE.tagline` reads "Mediation · Arbitration ·
Toronto", and docs/01 specifies that string as the HOME HERO EYEBROW —
nothing asks for a header tagline. Set under the name on every page it
stops being page copy and becomes a designation strip, with `Arbitration`
unqualified beside a name. AGENTS.md §4 records Q.Arb as commenced August
2026 and requires it never read as held. See AGENTS.md Q33. */
.brand {
display: flex;
align-items: center;
gap: var(--space-3);
/* 44px floor — this is the tap target that goes home. */
min-block-size: 44px;
color: var(--accent);
text-decoration: none;
margin-inline-end: auto;
}
.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 64rem. 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
at 93px instead of 77px. `flex: none` stops the row squeezing it. */
.header-cta {
flex: none;
white-space: nowrap;
}
/* Below 64rem 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: 63.999rem) {
.header-cta {
display: none;
}
}
/* --- Desktop ------------------------------------------------------------- */
/* --- Desktop: 64rem (1024px) ---------------------------------------------
The threshold is measured, not conventional. The one-row header has to hold
the brand, seven nav items and the CTA side by side, and **at 1024px it
does, with 32px of clearance between the nav and the CTA — measured in
headless Chrome with a seventh nav item injected.** Below that it does not,
which is why the nav takes its own row there.
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: 64rem) {
.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 142px instead of 80px. */
.nav-list {
flex-wrap: nowrap;
flex: none;
/* 16px from 64rem, widening to 24px at 75rem 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. */
@media (min-width: 75rem) {
.nav-list {
column-gap: var(--space-5);
}
}
@supports (animation-timeline: scroll()) {
@media (min-width: 64rem) 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>
+97 -20
View File
@@ -12,12 +12,30 @@ export const SITE = {
url: 'https://adr.smlcompany.ca',
locale: 'en_CA',
/**
* TODO(pouya): AGENTS.md Q30 — §4 verifies "Operator of SML Company Ltd."
* but nothing verifies the company's jurisdiction of incorporation, and this
* string is destined for the public footer. Jurisdiction dropped until
* confirmed; the operator fact itself is verified and stays.
* 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.',
entity: 'SML Company Ltd',
} as const;
/**
@@ -38,38 +56,97 @@ export const CREDENTIALS = {
'Stitt Feld Handy — negotiation and ADR workshop series',
],
languages: ['English', 'Farsi'],
/** [verified 2026-08-26 — Pouya, AGENTS.md Q28] — and FOR NOW: OBA section
* membership renews yearly, so this is a fact with a shelf life (AGENTS.md
* R10). Re-confirm at renewal. 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. Both the OBA sections and the Canadian Tax
* Foundation renew yearly, so every line below is a fact with a shelf life
* (AGENTS.md R10). Re-confirm at renewal, and before any page that lists
* memberships ships. NOT OCNI (lapsed) and NOT the Law Society — listing the
* LSO implies licensure, which D13 bars. Do not add either. */
memberships: [
'ADR Institute of Canada (ADRIC)',
'ADR Institute of Ontario (ADRIO)',
'Ontario Bar Association — Construction & Infrastructure, ADR, and Civil Litigation sections',
'Canadian Tax Foundation',
],
} as const;
/** The three credential slots. Never matter counts — AGENTS.md §4. */
export const CREDENTIAL_ROW = [
{ value: 'Q.Med', label: 'ADRIC / ADRIO designation' },
{ value: 'JD + ML', label: 'Law and engineering' },
{ value: 'EN · FA', label: 'Bilingual practice' },
] as const;
/**
* The Toronto boutique is NEVER named — AGENTS.md D16. Use this string.
* Do not infer a name from an email domain or anywhere else.
*/
export const BOUTIQUE = 'a Toronto litigation and ADR boutique' as const;
/**
* The boutique role and the litigation line — the two highest-risk strings on
* the site, so they live here rather than being typed into a page.
*
* Neither had a constant until 2026-08-26, which meant `/about/` (step 3) and
* the `Person` JSON-LD (docs/04) were both going to hand-type them. A claim
* written by hand in a component is a claim nobody re-checks against §4 — and
* these are the two where the wording IS the compliance.
*/
export const ROLE = {
/** §4 verbatim. docs/04: this is `jobTitle` in the Person JSON-LD, and
* `worksFor` is OMITTED — populating it either names the boutique (D16) or
* misstates the employer. */
title: 'Director of Firm Operations', // [verified 2026-08-25 — strategy brief §I]
/** Always rendered with BOUTIQUE, never with a firm name (D16). */
at: BOUTIQUE,
/**
* D13's approved phrasing, and the only approved phrasing. The alternative
* he approved is 'involvement in litigation and ADR matters'.
*
* NEVER "practice" in this context — that is the exact word D13 bars in the
* exact context it bars it, and §4 records that this register itself once
* carried the wrong word here while quoting the strategy brief verbatim.
* "Practice" describing Pouya's OWN ADR practice is correct and unaffected.
*
* Explicitly interim — AGENTS.md R1. Raise it; do not let it settle in.
*/
litigationLine: 'active litigation exposure', // [verified 2026-08-26 — D13]
/** The matter types behind that exposure. §4 verbatim; do not extend this
* list without a §4 row to extend it from. */
litigationAreas: [
'personal injury',
'construction',
'regulatory (POA)',
'insurance (SABS)',
],
} as const;
/** The three credential slots. Never matter counts — AGENTS.md §4. */
export const CREDENTIAL_ROW = [
{ value: 'Q.Med', label: 'ADRIC / ADRIO designation' },
/**
* TODO(pouya): AGENTS.md Q37 — is "Law and engineering" the right label?
*
* §4's substitution principle supplies the three VALUES (`Q.Med / JD + ML /
* EN · FA`); the labels come from docs/03 and are not themselves registered.
* `claims-auditor` reads this one as a D13 implication risk: "engineering" is
* shorthand for a verified *practising* claim, so the parallel invites "Law"
* to be read the same way — and D13 bars implication as hard as assertion.
*
* Left as written, because it is docs/03's approved copy and changing
* approved copy is not an implementer's call. Nothing renders it yet. Answer
* before step 2 builds the credential row.
*/
{ value: 'JD + ML', label: 'Law and engineering' },
{ value: 'EN · FA', label: 'Bilingual practice' },
] as const;
/** Analytics: privacy-first and cookieless (D15). No GA4, no consent banner. */
export const ANALYTICS = {
/**
* TODO(pouya): AGENTS.md Q31 — D15 records the choice as "Plausible **or**
* Fathom", i.e. undecided. `'plausible'` was a guessed value, which is what
* the header of this file tells you not to do. Null until you pick one.
* 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: null as 'plausible' | 'fathom' | null,
provider: 'plausible' as 'plausible' | 'fathom',
domain: 'adr.smlcompany.ca',
} as const;
+79
View File
@@ -0,0 +1,79 @@
---
/**
* 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';
export type Props = SeoProps;
// 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('_', '-');
---
<!doctype html>
<html lang={lang}>
<head>
<SEO {...Astro.props} />
{
/* .ico first for clients that ignore the SVG, and because some crawlers
request /favicon.ico at the root regardless of what is declared here. */
}
<link rel="icon" href="/favicon.ico" sizes="48x48" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
{
/* Only the two faces used above the fold, per docs/02. 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
/>
{
/* 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>
+652
View File
@@ -0,0 +1,652 @@
---
/**
* TEMPORARY — build step 1 only. Delete when step 2 ships `/`.
*
* A proof sheet for the design system. It exists so the type scale, the
* palette, the spacing rhythm, the focus states, and the header and footer can
* be looked at in a browser before nineteen real pages are built on top of them.
*
* It carries `noindex` and is excluded from the sitemap in astro.config.mjs.
* Remove that filter entry when you remove this page.
*
* WHAT THIS PAGE MAY CONTAIN: body copy about CSS, and nothing else. The page
* is published — `noindex` suppresses indexing, not access — so AGENTS.md §4
* governs every string on it exactly as it governs `/about/`.
*
* An earlier version of this comment read "NOTHING HERE ASSERTS A FACT ABOUT
* POUYA." It was false when written, and it is recorded here as the caution:
* the page was at that moment carrying a four-stage service timeline with day
* counts and two unverified assertions about the previous build, and the claims
* audit found them *because* a comment had certified the file clean and nobody
* re-read it. A certification is not a check — CLAUDE.md, "a sweep is a
* command, not a claim". Audit the page; do not trust this paragraph.
*
* The contrast numbers are COMPUTED, not transcribed. The frontmatter reads
* src/styles/tokens.css, pulls the hex values out of it, and runs the WCAG 2.x
* relative-luminance formula over them. Restating docs/02's measured table here
* would have created a second copy of a fact — and the copy that goes stale is
* the one nobody re-reads. This one cannot drift from the tokens, because it is
* derived from them.
*/
import BaseLayout from '../../layouts/BaseLayout.astro';
import Button from '../../components/Button.astro';
import InfinityMark from '../../components/InfinityMark.astro';
// `?raw` rather than fs.readFileSync: `import.meta.url` is rewritten to the
// emitted chunk's own location during the build, so a relative path resolves
// against dist/.prerender/chunks/ and throws ENOENT. Vite resolves this at
// bundle time, in dev and in build alike.
import tokensCss from '../../styles/tokens.css?raw';
const hex: Record<string, string> = {};
for (const match of tokensCss.matchAll(
/--([a-z0-9-]+):\s*(#[0-9a-f]{6})\b/gi,
)) {
hex[match[1]] = match[2].toLowerCase();
}
// The parser above only understands 6-digit hex. Move a palette token to
// `oklch()`, `color-mix()` or 3-digit hex and the lookup silently yields
// undefined; the luminance maths then throws an unnamed TypeError with no clue
// which token did it. Fail here instead, naming them — the posture SEO.astro
// takes with an out-of-range title.
const REQUIRED_TOKENS = [
'cream',
'ink',
'ink-soft',
'muted',
'maroon',
'maroon-l',
'gold',
'gold-d',
'gold-l',
];
const unparsed = REQUIRED_TOKENS.filter((token) => !hex[token]);
if (unparsed.length > 0) {
throw new Error(
'/type-scale/ could not read these colour tokens as 6-digit hex from ' +
`src/styles/tokens.css: ${unparsed.map((t) => `--${t}`).join(', ')}. ` +
'The contrast table on this page is computed from them. If the palette ' +
'moved to another colour syntax, teach this parser that syntax — do not ' +
'hardcode the values back in.',
);
}
const typeScale: Array<{ token: string; value: string }> = [];
for (const match of tokensCss.matchAll(
/--(text-(?:xs|sm|base|lg|xl|\dxl)):\s*([^;]+);/g,
)) {
typeScale.push({ token: `--${match[1]}`, value: match[2].trim() });
}
/** WCAG 2.x relative luminance and contrast ratio. */
const channel = (v: number) =>
v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4;
const luminance = (value: string) => {
const n = Number.parseInt(value.slice(1), 16);
return (
0.2126 * channel(((n >> 16) & 255) / 255) +
0.7152 * channel(((n >> 8) & 255) / 255) +
0.0722 * channel((n & 255) / 255)
);
};
const contrast = (a: string, b: string) => {
const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x);
return (hi + 0.05) / (lo + 0.05);
};
const verdict = (ratio: number) =>
ratio >= 4.5 ? 'AA body' : ratio >= 3 ? 'AA large only' : 'fails both';
const foregrounds = [
'ink',
'ink-soft',
'maroon',
'maroon-l',
'muted',
'gold-d',
'gold',
];
const onCream = foregrounds.map((token) => ({
token,
value: hex[token],
ratio: contrast(hex[token], hex.cream),
}));
const onInk = ['cream', 'gold-l', 'gold', 'muted'].map((token) => ({
token,
value: hex[token],
ratio: contrast(hex[token], hex.ink),
}));
const spacing: Array<{ token: string; value: string }> = [];
for (const match of tokensCss.matchAll(/--(space-\d+):\s*([^;]+);/g)) {
spacing.push({ token: `--${match[1]}`, value: match[2].trim() });
}
const title = 'Type scale and colour proof sheet · Pouya Lajevardi';
const description =
'A temporary reference page rendering the design system end to end: the fluid type scale, the palette with computed contrast ratios, spacing, and motion.';
---
<BaseLayout title={title} description={description} noindex>
<div class="wrap">
<section class="section">
<p class="eyebrow">
<span class="dot"></span>Build step 1 · temporary page
</p>
<h1 class="display size-5xl">
The system, <span class="it">rendered</span>.
</h1>
<p class="lede">
Everything below is read out of <code>src/styles/tokens.css</code> at build
time. The contrast ratios are computed from the token values, not copied from
the spec — if a token changes, this page changes with it.
</p>
<p class="note">
This page is <strong>temporary</strong>. It is <code>noindex</code>, it
is excluded from the sitemap, and build step 2 deletes it.
</p>
</section>
<hr class="rule-gold" />
<section class="section">
<p class="eyebrow"><span class="dot"></span>Display type</p>
<h2 class="display size-3xl">
Instrument Serif, <span class="it">4xl and up</span>
</h2>
<p class="note">
docs/02: display type is used at <code>--text-4xl</code> and above only. It
has almost no hinting at small sizes and looks weak below 32&nbsp;px.
</p>
<div class="stack">
<p class="display size-6xl">Redraw the loop</p>
<p class="display size-5xl">Redraw the loop into a line</p>
<p class="display size-4xl">Redraw the loop into a line</p>
</div>
</section>
<hr />
<section class="section">
<p class="eyebrow"><span class="dot"></span>Type scale</p>
<h2 class="display size-3xl">Fluid, 360&nbsp;px to 1600&nbsp;px</h2>
<p class="note">
Resize the window. Every step interpolates; there are no breakpoint
jumps. The declaration beside each line is the token itself.
</p>
<dl class="scale-list">
{
typeScale.map((step) => (
<div class="scale-row">
<dt>
{/* A neutral specimen, deliberately. This was
"Mediation · Arbitration · Toronto" — hand-typed rather than
read from SITE.tagline, and rendered ten times at up to 96px
on a published page. Two defects in one string: a claim
typed inline is a claim nobody re-checks against §4 (the
rule SiteFooter states), and `Arbitration` unqualified at
display size is the open question AGENTS.md Q33 names. A
type specimen needs letterforms, not assertions. */}
<span class="sample" style={`font-size: var(${step.token})`}>
Handgloves &amp; quartz — 1234567890
</span>
</dt>
<dd>
<code>{step.token}</code>
<span class="scale-value">{step.value}</span>
</dd>
</div>
))
}
</dl>
</section>
<hr />
<section class="section">
<p class="eyebrow"><span class="dot"></span>Colour on cream</p>
<h2 class="display size-3xl">Measured, not assumed</h2>
<p class="note">
Ratios computed at build time against <code>--cream</code>
{' '}{hex.cream}. AA is 4.5:1 for body text and 3:1 for large text.
</p>
<ul class="swatches" role="list">
{
onCream.map((entry) => (
<li class="swatch">
<span class="chip" style={`background: ${entry.value}`} />
<span class="swatch-meta">
<code>--{entry.token}</code>
<span class="swatch-value">{entry.value}</span>
<span class="swatch-ratio">
{entry.ratio.toFixed(2)}:1 · {verdict(entry.ratio)}
</span>
</span>
</li>
))
}
</ul>
<div class="gold-demo">
<p class="note">
<strong>The hard rule, shown without breaking it.</strong> Gold is a rule,
a border, and an icon stroke. The identical hue fails at
{contrast(hex.gold, hex.cream).toFixed(2)}:1 as text on cream and
passes at {contrast(hex.gold, hex.ink).toFixed(2)}:1 on ink — the
colour is not the problem, the pairing is. Both blocks below are the
same <code>--gold</code>.
</p>
{
/* Shown as blocks, not as sentences. An earlier version set a real
paragraph in --gold on --bg-raised, which measures 1.76:1 — worse
than the 2.10:1 the rule exists to prevent. A reader with low vision
got an unreadable paragraph and no alternative, and the one artefact
documenting the rule was the one artefact breaking it. */
}
<div class="gold-pair">
<div class="gold-block on-cream">
<span class="gold-band"></span>
<span class="gold-caption">
<code>--gold</code> on <code>--cream</code>
<span class="swatch-ratio">
{contrast(hex.gold, hex.cream).toFixed(2)}:1 · never as text
</span>
</span>
</div>
<div class="gold-block on-ink">
<span class="gold-band"></span>
<span class="gold-caption">
<code>--gold</code> on <code>--ink</code>
<span class="swatch-ratio">
{contrast(hex.gold, hex.ink).toFixed(2)}:1 · text is fine here
</span>
</span>
</div>
</div>
</div>
</section>
<hr />
<section class="section">
<p class="eyebrow"><span class="dot"></span>Components</p>
<h2 class="display size-3xl">Buttons, mark, focus</h2>
<p class="note">
Tab through this section. Every interactive element takes a visible
focus ring — 2&nbsp;px maroon, 3&nbsp;px offset, on
<code>:focus-visible</code>. Never <code>outline: none</code> without a replacement.
</p>
<div class="row">
<Button href="/contact/" variant="primary"
>Request a consultation</Button
>
<Button href="/process/" variant="ghost">How I work</Button>
<Button href="/about/" variant="gold">Read the record</Button>
<Button type="button">A real &lt;button&gt;</Button>
</div>
<div class="row marks">
<InfinityMark size="4rem" />
<InfinityMark size="2.5rem" />
<InfinityMark size="1.5rem" />
</div>
</section>
<hr />
<section class="section">
<p class="eyebrow"><span class="dot"></span>Measure</p>
<h2 class="display size-3xl">Sixty-eight characters</h2>
{
/* `.prose` is opted into, not inherited. global.css no longer caps every
<p> on the site — long-form asks for the measure, and this block is
demonstrating the very thing it uses. */
}
<div class="prose">
<p>
Body copy is capped at <code>--width-prose</code>, 68&nbsp;ch. Past
roughly 75 characters the eye starts losing the beginning of the next
line on the return sweep, and long-form copy stops being read. This
paragraph sits at the cap. It is the single largest lever on whether a
page of prose is actually finished, and it costs nothing to set.
</p>
<p>
Line height is 1.6 for body and 0.98 for display. Headings balance
with
<code>text-wrap: balance</code>, so a two-line headline breaks evenly
rather than leaving one word stranded.
</p>
</div>
</section>
<hr />
<section class="section">
<p class="eyebrow"><span class="dot"></span>Spacing</p>
<h2 class="display size-3xl">Eight-pixel base</h2>
<ul class="spacing-list" role="list">
{
spacing.map((step) => (
<li class="spacing-row">
<code>{step.token}</code>
<span
class="spacing-bar"
style={`inline-size: var(${step.token})`}
/>
<span class="spacing-value">{step.value}</span>
</li>
))
}
</ul>
</section>
</div>
<section class="section section-inverse">
<div class="wrap">
<p class="eyebrow"><span class="dot"></span>Colour on ink</p>
<h2 class="display size-3xl">The dark panel</h2>
<p class="note-inverse">
<code>--muted</code> is the trap: it passes on cream at 5.47:1 and fails on
ink. Secondary text on dark is <code>--gold-l</code> or cream.
</p>
<ul class="swatches" role="list">
{
onInk.map((entry) => (
<li class="swatch">
<span class="chip" style={`background: ${entry.value}`} />
<span class="swatch-meta">
<code>--{entry.token}</code>
<span class="swatch-value">{entry.value}</span>
<span class="swatch-ratio">
{entry.ratio.toFixed(2)}:1 · {verdict(entry.ratio)}
</span>
</span>
</li>
))
}
</ul>
</div>
</section>
<div class="wrap">
<section class="section">
<p class="eyebrow"><span class="dot"></span>Motion</p>
<h2 class="display size-3xl">Reveal on section entry</h2>
<p class="note">
The four cards below carry <code>.reveal-stagger</code>. Scroll them
into view. With JavaScript disabled, or with reduced motion requested,
they are simply present — the animation is layered onto a page that
already reads.
</p>
{
/* Deliberately contentless. These four cards previously carried the
engagement timeline — which IS specified, at docs/01-architecture.md
§/process/, for the `/process/` page. It does not belong on a proof
sheet for a CSS stagger, where nothing qualifies it and no reader is
looking for it. The stagger demonstrates just as well with nothing to
say. */
}
<ul class="cards reveal-stagger" role="list">
<li class="card"><h3>First card</h3><p>Stagger step 1</p></li>
<li class="card"><h3>Second card</h3><p>Stagger step 2</p></li>
<li class="card"><h3>Third card</h3><p>Stagger step 3</p></li>
<li class="card"><h3>Fourth card</h3><p>Stagger step 4</p></li>
</ul>
</section>
</div>
</BaseLayout>
<style>
.size-6xl {
font-size: var(--text-6xl);
}
.size-5xl {
font-size: var(--text-5xl);
}
.size-4xl {
font-size: var(--text-4xl);
}
.size-3xl {
font-size: var(--text-3xl);
}
.lede {
max-inline-size: var(--width-prose);
margin-block-start: var(--space-5);
font-size: var(--text-lg);
color: var(--text-secondary);
}
.note,
.note-inverse {
max-inline-size: var(--width-prose);
margin-block-start: var(--space-4);
font-size: var(--text-sm);
}
.note {
color: var(--text-meta);
}
.note-inverse {
color: var(--text-inverse-2);
}
h2.display {
margin-block-start: var(--space-3);
}
code {
font-family: var(--font-mono);
font-size: 0.9em;
color: var(--text-secondary);
}
.stack {
display: flex;
flex-direction: column;
gap: var(--space-5);
margin-block-start: var(--space-6);
}
/* No `max-inline-size: none` escape hatch any more — there is nothing left to
escape from now that the measure is opt-in. */
/* --- Type scale ---------------------------------------------------------- */
.scale-list {
margin-block-start: var(--space-6);
}
.scale-row {
display: flex;
flex-wrap: wrap;
align-items: baseline;
justify-content: space-between;
gap: var(--space-4);
padding-block: var(--space-4);
border-block-start: 1px solid var(--border);
}
.scale-row dt,
.scale-row dd {
/* A flex item's default `min-width: auto` is its min-content width, which
for a 96px specimen is one whole word. */
min-inline-size: 0;
margin: 0;
}
.scale-row dd {
display: flex;
flex-direction: column;
align-items: flex-end;
text-align: end;
}
.scale-value {
font-family: var(--font-mono);
font-size: var(--text-xs);
color: var(--text-meta);
}
.sample {
line-height: var(--leading-tight);
/* At --text-6xl a single word is ~500px wide, so at 320px it has to break
mid-word or push the page out. Measured: 8px of overflow before this,
which now has no `body { overflow-x: hidden }` to hide it — which is
exactly why that clamp was removed. */
overflow-wrap: anywhere;
}
/* --- Colour -------------------------------------------------------------- */
.swatches {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr));
gap: var(--space-5);
margin-block-start: var(--space-6);
padding: 0;
list-style: none;
}
.swatch {
display: flex;
align-items: center;
gap: var(--space-4);
}
.chip {
inline-size: 3.5rem;
block-size: 3.5rem;
flex: none;
border-radius: var(--radius-md);
border: 1px solid var(--border);
}
.swatch-meta {
display: flex;
flex-direction: column;
gap: 2px;
}
.swatch-value,
.swatch-ratio {
font-family: var(--font-mono);
font-size: var(--text-xs);
color: var(--text-meta);
}
.section-inverse .swatch-value,
.section-inverse .swatch-ratio,
.section-inverse code {
color: var(--text-inverse-2);
}
.section-inverse .chip {
border-color: var(--line-dark);
}
.gold-demo {
margin-block-start: var(--space-7);
padding: var(--space-5);
background: var(--bg-raised);
border-radius: var(--radius-lg);
}
.gold-pair {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 14rem), 1fr));
gap: var(--space-4);
margin-block-start: var(--space-5);
}
.gold-block {
padding: var(--space-4);
border-radius: var(--radius-md);
}
.gold-block.on-cream {
background: var(--cream);
border: 1px solid var(--border);
}
.gold-block.on-ink {
background: var(--ink);
}
/* The colour itself, carried by a block rather than by glyphs. */
.gold-band {
display: block;
block-size: var(--space-6);
background: var(--gold);
border-radius: var(--radius-sm);
}
.gold-caption {
display: flex;
flex-direction: column;
gap: 2px;
margin-block-start: var(--space-3);
font-size: var(--text-sm);
}
.gold-block.on-cream .gold-caption {
color: var(--text);
}
.gold-block.on-ink .gold-caption,
.gold-block.on-ink code,
.gold-block.on-ink .swatch-ratio {
color: var(--text-inverse);
}
/* --- Rows ---------------------------------------------------------------- */
.row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-4);
margin-block-start: var(--space-6);
}
.marks {
color: var(--accent);
gap: var(--space-6);
}
/* --- Spacing ------------------------------------------------------------- */
.spacing-list {
margin-block-start: var(--space-6);
padding: 0;
list-style: none;
}
.spacing-row {
display: grid;
/* minmax(0, …) on every track. A grid item's default `min-width: auto` is
its min-content size, so the 160px bar below forced the row wider than
the viewport — 72px of overflow at 320px, with three cells pushed off
screen and unreachable now that body{overflow-x:hidden} is gone from
global.css. WCAG 1.4.10 Reflow. */
grid-template-columns: minmax(0, 6rem) minmax(0, 1fr) minmax(0, 3.5rem);
align-items: center;
gap: var(--space-3);
padding-block: var(--space-2);
}
.spacing-bar {
block-size: 0.75rem;
max-inline-size: 100%;
background: var(--rule);
border-radius: var(--radius-sm);
}
.spacing-value {
font-family: var(--font-mono);
font-size: var(--text-xs);
color: var(--text-meta);
text-align: end;
}
/* --- Cards --------------------------------------------------------------- */
.cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(13rem, 1fr));
gap: var(--space-4);
margin-block-start: var(--space-6);
padding: 0;
list-style: none;
}
.card {
padding: var(--space-5);
background: var(--bg-raised);
border-radius: var(--radius-lg);
}
.card h3 {
font-size: var(--text-lg);
letter-spacing: var(--tracking-tight);
}
.card p {
margin-block-start: var(--space-2);
font-family: var(--font-mono);
font-size: var(--text-xs);
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
color: var(--text-meta);
}
</style>
+185 -60
View File
@@ -5,41 +5,98 @@
@import './tokens.css';
/* --- Fonts: self-hosted, subset, swap. No runtime Google Fonts request. ----
TODO(claude-code): place subset woff2 files in /public/fonts/ and preload
Instrument Serif 400 and Geist 400 in BaseLayout — they are the only two
faces used above the fold. */
Files and their provenance: docs/reference/fonts-provenance.md.
Filenames are stable on purpose — a preload needs a path that does not change
between builds, which rules out Astro's hashed asset pipeline.
`?v=1` IS LOAD-BEARING. scripts/deploy-local.sh serves /fonts/* with
`max-age=31536000, immutable`, so a returning visitor holds these bytes for a
year and a CloudFront invalidation cannot reach their browser cache. Bump the
query when a file's contents change — here AND on the preload in
BaseLayout.astro, which must match byte for byte or the preload is a second,
wasted request instead of a warmed cache.
The `latin` cut of each face is listed FIRST and the `latin-ext` cut second.
Order matters: where two @font-face rules for one family both match a
codepoint, the last wins. Latin-ext is the wider, heavier file; putting it
last would hand it every ASCII character on the page. */
@font-face {
font-family: 'Instrument Serif';
src: url('/fonts/instrument-serif-400.woff2') format('woff2');
src: url('/fonts/instrument-serif-latin-400-normal.woff2?v=1') format('woff2');
font-weight: 400;
font-style: normal;
font-display: swap;
unicode-range: U+0000-00FF, U+0100-017F, U+2000-206F, U+2190-21BB;
unicode-range:
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC,
U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212,
U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: 'Instrument Serif';
src: url('/fonts/instrument-serif-400-italic.woff2') format('woff2');
src: url('/fonts/instrument-serif-latin-ext-400-normal.woff2?v=1')
format('woff2');
font-weight: 400;
font-style: normal;
font-display: swap;
unicode-range:
U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304,
U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB,
U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* Italic is the one flourish the design allows (docs/02) — a phrase inside a
headline, never a paragraph. Latin only; there is no latin-ext italic file. */
@font-face {
font-family: 'Instrument Serif';
src: url('/fonts/instrument-serif-latin-400-italic.woff2?v=1') format('woff2');
font-weight: 400;
font-style: italic;
font-display: swap;
unicode-range: U+0000-00FF, U+0100-017F, U+2000-206F;
unicode-range:
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC,
U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212,
U+2215, U+FEFF, U+FFFD;
}
/* Geist and Geist Mono are variable fonts: one file spans the whole weight
axis, so 300/400/500/600 cost nothing extra. `font-weight: 100 900` declares
the axis range the file actually carries — narrowing it here would make the
browser synthesise weights it already has. */
@font-face {
font-family: 'Geist';
src: url('/fonts/geist-latin-wght-normal.woff2?v=1')
format('woff2-variations');
font-weight: 100 900;
font-style: normal;
font-display: swap;
unicode-range:
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC,
U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212,
U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: 'Geist';
src: url('/fonts/geist-variable.woff2') format('woff2-variations');
font-weight: 300 600;
src: url('/fonts/geist-latin-ext-wght-normal.woff2?v=1')
format('woff2-variations');
font-weight: 100 900;
font-style: normal;
font-display: swap;
unicode-range: U+0000-00FF, U+0100-017F, U+2000-206F, U+2190-21BB;
unicode-range:
U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304,
U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB,
U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
@font-face {
font-family: 'Geist Mono';
src: url('/fonts/geist-mono-variable.woff2') format('woff2-variations');
font-weight: 400 500;
src: url('/fonts/geist-mono-latin-wght-normal.woff2?v=1')
format('woff2-variations');
font-weight: 100 900;
font-style: normal;
font-display: swap;
unicode-range: U+0000-00FF, U+2000-206F;
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 ---------------------------------------------------------------- */
@@ -56,7 +113,19 @@
html {
-webkit-text-size-adjust: 100%;
scroll-behavior: smooth;
scroll-padding-top: var(--space-8);
/* No offset by default: below 64rem 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 64rem 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: 64rem) {
html {
scroll-padding-top: calc(var(--header-h) + var(--space-4));
}
}
body {
@@ -69,8 +138,12 @@ body {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
overflow-x: hidden;
min-height: 100vh;
/* No `overflow-x: hidden`. It was here, and it was hiding a real defect: at
320px the page overflowed by 72px and three cells sat outside the viewport
with no scrollbar to reach them — WCAG 1.4.10 content loss, silently
masked. A global overflow clamp turns every future layout bug on nineteen
pages into an invisible one. Fix the overflow; do not hide it. */
}
img,
@@ -146,9 +219,10 @@ h4 {
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);
@@ -202,7 +276,11 @@ a:hover {
.wrap-wide {
max-inline-size: var(--width-wide);
}
.prose {
/* 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);
}
.section {
@@ -241,53 +319,81 @@ hr {
border: 0;
}
/* --- Reveal ---------------------------------------------------------------
Progressive enhancement, not a dependency. Content is rendered and visible
in the HTML; `.reveal` only takes effect once JS adds `js-reveal` to <html>.
If the observer never runs, every page reads normally. The previous build
had this backwards and shipped a blank page to anything without JS. */
/* --- Reveal ----------------------------------------------------------------
Scroll-driven CSS. There is NO JavaScript on this site, and this block is
why: the reveal used to be an inline IntersectionObserver in <head>, which
collided with the Content-Security-Policy docs/05-backend-spec.md specifies
(`script-src 'self'`, no `unsafe-inline`, "use a hash or nonce for the reveal
script"). A per-build hash is a moving target and drifts from the policy.
`animation-timeline: view()` is what docs/02 §Motion offers as the
alternative, and it removes the script — and the problem — entirely.
.js-reveal .reveal {
The @supports gate is load-bearing, not defensive. Without it a browser that
ignores `animation-timeline` would run the animation once against the
document timeline at load; with it, that browser gets no animation and fully
visible content. Content is never hidden behind a feature that might not
arrive. The previous build had this backwards and shipped a blank page to
anything without JavaScript. */
@supports (animation-timeline: view()) {
@media (prefers-reduced-motion: no-preference) {
/* LONGHANDS ONLY. `animation: reveal-in linear both` beside
`animation-timeline: view()` is folded by Lightning CSS on minify into
`animation: linear both reveal-in view()`, which is invalid — `view()` is
not a component of the shorthand — so the whole declaration is thrown
away. It works in `npm run dev` and is dead in `npm run build`. This is
the same defect the header's condense had; it was found there first and
written straight back into the fix for it. Grep dist for it (Phase 5). */
.reveal {
animation-name: reveal-in;
animation-duration: 1ms;
animation-timing-function: linear;
animation-fill-mode: both;
animation-timeline: view();
animation-range: entry 0% cover 22%;
}
.reveal-stagger > * {
animation-name: reveal-in;
animation-duration: 1ms;
animation-timing-function: linear;
animation-fill-mode: both;
animation-timeline: view();
}
/* Stagger is expressed as timeline range, not delay: a scroll-driven
animation has no wall clock to delay against. Each child completes a
little further into the scroll than the one before. Six children by
design (docs/02) — a seventh simply lands with the sixth. */
.reveal-stagger > *:nth-child(1) {
animation-range: entry 0% cover 18%;
}
.reveal-stagger > *:nth-child(2) {
animation-range: entry 0% cover 22%;
}
.reveal-stagger > *:nth-child(3) {
animation-range: entry 0% cover 26%;
}
.reveal-stagger > *:nth-child(4) {
animation-range: entry 0% cover 30%;
}
.reveal-stagger > *:nth-child(5) {
animation-range: entry 0% cover 34%;
}
.reveal-stagger > *:nth-child(6) {
animation-range: entry 0% cover 38%;
}
}
}
@keyframes reveal-in {
from {
opacity: 0;
transform: translateY(20px);
}
.js-reveal .reveal.is-in {
to {
opacity: 1;
transform: none;
transition:
opacity var(--dur-reveal) var(--ease),
transform var(--dur-reveal) var(--ease);
}
.js-reveal .reveal-stagger > * {
opacity: 0;
transform: translateY(16px);
}
.js-reveal .reveal-stagger.is-in > * {
opacity: 1;
transform: none;
transition:
opacity var(--dur-reveal) var(--ease),
transform var(--dur-reveal) var(--ease);
}
.js-reveal .reveal-stagger.is-in > *:nth-child(1) {
transition-delay: 0ms;
}
.js-reveal .reveal-stagger.is-in > *:nth-child(2) {
transition-delay: 70ms;
}
.js-reveal .reveal-stagger.is-in > *:nth-child(3) {
transition-delay: 140ms;
}
.js-reveal .reveal-stagger.is-in > *:nth-child(4) {
transition-delay: 210ms;
}
.js-reveal .reveal-stagger.is-in > *:nth-child(5) {
transition-delay: 280ms;
}
.js-reveal .reveal-stagger.is-in > *:nth-child(6) {
transition-delay: 350ms;
}
/* Stagger caps at six children by design. */
@media (prefers-reduced-motion: reduce) {
html {
@@ -301,8 +407,27 @@ hr {
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
.js-reveal .reveal,
.js-reveal .reveal-stagger > * {
/* Belt and braces. The @supports block above is already gated on
no-preference, so nothing here should be animating at all — this keeps the
guarantee true even if a later rule forgets the gate. */
.reveal,
.reveal-stagger > * {
animation: none !important;
opacity: 1 !important;
transform: none !important;
}
}
/* A scroll-driven animation has no timeline when printing, so every revealed
element would render at its `from` state — which is `opacity: 0`. Measured
before this block existed: printing the page to PDF dropped four card
headings from the output entirely. `/about/` is written to be printed by
people evaluating an appointment; content that vanishes at Cmd-P is not a
cosmetic problem. */
@media print {
.reveal,
.reveal-stagger > * {
animation: none !important;
opacity: 1 !important;
transform: none !important;
}
+15 -1
View File
@@ -102,10 +102,24 @@
--space-10: 8rem; /* 128 */
--space-11: 10rem; /* 160 */
--section-y: clamp(var(--space-9), 6vw + 2rem, var(--space-11));
/* docs/02: "Section rhythm: --space-9 (96px) mobile, --space-11 (160px)
desktop." The previous curve was `6vw + 2rem`, which reaches 160px only at
a 2133px viewport — measured 128px at 1600px, 108.8px at 1280px. The upper
bound was unreachable on any real screen, so the token read as if it
delivered a rhythm it never delivered. `9vw + 1rem` hits 160px at 1600px
and still clamps to 96px on a phone. [measured 2026-08-26] */
--section-y: clamp(var(--space-9), 9vw + 1rem, var(--space-11));
/* --- Layout ------------------------------------------------------------ */
/* Sticky-header height at >= 64rem, 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: 77px at every one] */
--header-h: 4.8125rem; /* 77 — measured, not chosen */
--width-content: 80rem; /* 1280 */
--width-wide: 90rem; /* 1440 */
--width-prose: 68ch; /* reading measure — never exceed for body copy */
+1 -8
View File
@@ -2,14 +2,7 @@
"extends": "astro/tsconfigs/strict",
"compilerOptions": {
"strictNullChecks": true,
"allowJs": true,
"baseUrl": ".",
"paths": {
"@components/*": ["src/components/*"],
"@layouts/*": ["src/layouts/*"],
"@styles/*": ["src/styles/*"],
"@data/*": ["src/data/*"]
}
"allowJs": true
},
"include": [".astro/types.d.ts", "**/*"],
"exclude": ["dist", "node_modules"]