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:
co-authored by
Claude Opus 5
parent
8f1df2c27c
commit
8134709548
@@ -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 12–18. `[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 12–18. `[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 12–18 | 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 12–18 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 12–18
|
||||
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
|
||||
|
||||
@@ -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
@@ -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
@@ -97,6 +97,23 @@ seat. The cost of getting this wrong is much higher than the cost of waiting.
|
||||
Revisit at month 12–18, once there is relationship history to point to.
|
||||
**This reasoning is Claude's, recorded for Pouya's decision — not yet his call.**
|
||||
|
||||
## Not a practice area yet: tax-adjacent disputes
|
||||
|
||||
**Canadian Tax Foundation membership is verified** (`AGENTS.md` §4, 2026-08-26)
|
||||
and it is the one credential none of the six areas above touch. Tax-adjacent
|
||||
disputes are genuinely ADR territory — valuation and purchase-price disputes on
|
||||
a share sale, indemnity and earn-out fights that turn on a tax position,
|
||||
shareholder splits where the assessment is the thing actually in dispute.
|
||||
|
||||
**There is no seventh practice page at launch,** for the same reason as the
|
||||
section above and not a weaker one: a practice page is a claim of present
|
||||
capability, and there is no track record to point at. A membership is a
|
||||
credential, not a caseload.
|
||||
|
||||
It belongs on `/about/` with the other memberships. Revisit at the **month
|
||||
12–18 review, alongside the Indigenous engagement decision** — one review, two
|
||||
candidates. Tracked as `AGENTS.md` R3.
|
||||
|
||||
---
|
||||
|
||||
## Page specifications
|
||||
@@ -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 mediator–party
|
||||
relationship and does not itself create a conflict check.
|
||||
|
||||
|
||||
@@ -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 320–375 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
@@ -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
@@ -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
|
||||
|
||||
@@ -206,6 +206,17 @@ An embedded scheduler for the 30–45 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
@@ -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
@@ -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 |
|
||||
|
||||
@@ -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.
|
||||
@@ -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 |
@@ -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.
Binary file not shown.
+11
-1
@@ -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
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
---
|
||||
/**
|
||||
* docs/02-design-system.md: variants primary (maroon) · ghost (outlined) ·
|
||||
* gold (ink background, gold-l text). Renders an <a> or a <button> correctly —
|
||||
* a link that navigates must be an <a>, whatever it looks like.
|
||||
*/
|
||||
interface Props {
|
||||
href?: string;
|
||||
variant?: 'primary' | 'ghost' | 'gold';
|
||||
type?: 'button' | 'submit';
|
||||
class?: string;
|
||||
}
|
||||
|
||||
const {
|
||||
href,
|
||||
variant = 'primary',
|
||||
type = 'button',
|
||||
class: className,
|
||||
} = Astro.props;
|
||||
const classes = ['btn', `btn-${variant}`, className];
|
||||
---
|
||||
|
||||
{
|
||||
href ? (
|
||||
<a href={href} class:list={classes}>
|
||||
<slot />
|
||||
</a>
|
||||
) : (
|
||||
<button type={type} class:list={classes}>
|
||||
<slot />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
<style>
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-2);
|
||||
/* 44 × 44 is the touch-target floor in docs/02. */
|
||||
min-block-size: 44px;
|
||||
padding-block: var(--space-3);
|
||||
padding-inline: var(--space-5);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-full);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--weight-medium);
|
||||
letter-spacing: var(--tracking-tight);
|
||||
line-height: 1.2;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background-color var(--dur-hover) var(--ease),
|
||||
border-color var(--dur-hover) var(--ease),
|
||||
color var(--dur-hover) var(--ease);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--accent);
|
||||
color: var(--text-inverse);
|
||||
}
|
||||
.btn-primary:hover {
|
||||
background: var(--accent-hover);
|
||||
color: var(--text-inverse);
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
background: transparent;
|
||||
border-color: var(--border);
|
||||
color: var(--text);
|
||||
}
|
||||
.btn-ghost:hover {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.btn-gold {
|
||||
background: var(--bg-inverse);
|
||||
color: var(--text-inverse-2);
|
||||
}
|
||||
.btn-gold:hover {
|
||||
background: var(--accent);
|
||||
color: var(--text-inverse);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,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>
|
||||
@@ -0,0 +1,129 @@
|
||||
---
|
||||
/**
|
||||
* The single metadata component. Spec: docs/04-seo-spec.md.
|
||||
*
|
||||
* "Every page passes through one SEO component. A page without it is not
|
||||
* finished." — so the length rules in that spec are ENFORCED here rather than
|
||||
* described. An out-of-range title or description throws at build time and
|
||||
* names the offending string and its length, the same way src/content.config.ts
|
||||
* does for article frontmatter. A build that fails on unfinished metadata is a
|
||||
* correct build.
|
||||
*/
|
||||
import { getImage } from 'astro:assets';
|
||||
import ogDefault from '../assets/og-portrait.jpg';
|
||||
import { SITE, PORTRAIT } from '../data/site';
|
||||
|
||||
export interface Props {
|
||||
/** The full rendered <title>. Pattern: "<Page> · Pouya Lajevardi". 50–60. */
|
||||
title: string;
|
||||
/** 140–160 characters, unique, written for a human. */
|
||||
description: string;
|
||||
/** Overrides the canonical path. Defaults to this page's own URL. */
|
||||
canonical?: string;
|
||||
ogType?: 'website' | 'article' | 'profile';
|
||||
/** 1200×630 source. Defaults to the portrait crop in src/assets.
|
||||
* `ImageMetadata` is an Astro ambient global — there is nothing to import. */
|
||||
image?: ImageMetadata;
|
||||
imageAlt?: string;
|
||||
/** /legal/* and any temporary page. Emits noindex,follow per docs/04. */
|
||||
noindex?: boolean;
|
||||
/**
|
||||
* Page-appropriate structured data — Person, ProfessionalService, Service,
|
||||
* Article, BreadcrumbList, FAQPage. Passed in, never invented here: a default
|
||||
* would be a claim this component is in no position to make.
|
||||
*/
|
||||
jsonLd?: unknown;
|
||||
}
|
||||
|
||||
const {
|
||||
title,
|
||||
description,
|
||||
canonical,
|
||||
ogType = 'website',
|
||||
image,
|
||||
imageAlt,
|
||||
noindex = false,
|
||||
jsonLd,
|
||||
} = Astro.props;
|
||||
|
||||
const TITLE_MIN = 50;
|
||||
const TITLE_MAX = 60;
|
||||
const DESC_MIN = 140;
|
||||
const DESC_MAX = 160;
|
||||
|
||||
const problems: string[] = [];
|
||||
if (title.length < TITLE_MIN || title.length > TITLE_MAX) {
|
||||
problems.push(
|
||||
`title is ${title.length} characters; docs/04-seo-spec.md requires ${TITLE_MIN}–${TITLE_MAX}.\n ${JSON.stringify(title)}`,
|
||||
);
|
||||
}
|
||||
if (description.length < DESC_MIN || description.length > DESC_MAX) {
|
||||
problems.push(
|
||||
`description is ${description.length} characters; docs/04-seo-spec.md requires ${DESC_MIN}–${DESC_MAX}.\n ${JSON.stringify(description)}`,
|
||||
);
|
||||
}
|
||||
if (problems.length > 0) {
|
||||
throw new Error(
|
||||
`SEO metadata out of range on ${Astro.url.pathname}\n - ${problems.join('\n - ')}\n` +
|
||||
` Fix the string. Do not widen the range — these are the lengths Google renders.`,
|
||||
);
|
||||
}
|
||||
|
||||
// `site` drives canonical URLs, OG tags, and the sitemap. Without it every
|
||||
// absolute URL below would silently become a relative one.
|
||||
if (!Astro.site) {
|
||||
throw new Error(
|
||||
'astro.config.mjs must set `site`; SEO.astro needs it for canonical and OG URLs.',
|
||||
);
|
||||
}
|
||||
const canonicalUrl = new URL(canonical ?? Astro.url.pathname, Astro.site);
|
||||
|
||||
// JPEG on purpose. Page images are AVIF/WebP with a fallback (CLAUDE.md), but
|
||||
// link-preview crawlers are not browsers — LinkedIn and Slack do not negotiate
|
||||
// content types, and several still do not decode WebP at all.
|
||||
const ogImage = await getImage({
|
||||
src: image ?? ogDefault,
|
||||
format: 'jpeg',
|
||||
width: 1200,
|
||||
height: 630,
|
||||
});
|
||||
const ogImageUrl = new URL(ogImage.src, Astro.site);
|
||||
|
||||
// JSON.stringify does not escape `<`, so a "</script>" inside any string value
|
||||
// would close this element early and hand the rest of the payload to the HTML
|
||||
// parser. Escaping the angle bracket is the whole fix; JSON readers decode it.
|
||||
const jsonLdText =
|
||||
jsonLd === undefined ? null : JSON.stringify(jsonLd).replace(/</g, '\\u003c');
|
||||
---
|
||||
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="generator" content={Astro.generator} />
|
||||
|
||||
<title>{title}</title>
|
||||
<meta name="description" content={description} />
|
||||
<link rel="canonical" href={canonicalUrl.href} />
|
||||
<meta name="robots" content={noindex ? 'noindex,follow' : 'index,follow'} />
|
||||
|
||||
<meta property="og:type" content={ogType} />
|
||||
<meta property="og:title" content={title} />
|
||||
<meta property="og:description" content={description} />
|
||||
<meta property="og:url" content={canonicalUrl.href} />
|
||||
<meta property="og:site_name" content={SITE.name} />
|
||||
<meta property="og:locale" content={SITE.locale} />
|
||||
<meta property="og:image" content={ogImageUrl.href} />
|
||||
<meta property="og:image:width" content="1200" />
|
||||
<meta property="og:image:height" content="630" />
|
||||
<meta property="og:image:alt" content={imageAlt ?? PORTRAIT.alt} />
|
||||
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content={title} />
|
||||
<meta name="twitter:description" content={description} />
|
||||
<meta name="twitter:image" content={ogImageUrl.href} />
|
||||
<meta name="twitter:image:alt" content={imageAlt ?? PORTRAIT.alt} />
|
||||
|
||||
{
|
||||
jsonLdText && (
|
||||
<script type="application/ld+json" is:inline set:html={jsonLdText} />
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,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">© {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>
|
||||
@@ -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
@@ -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;
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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 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 px to 1600 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 & 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 px maroon, 3 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 <button></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 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>
|
||||
+190
-65
@@ -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 {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
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%;
|
||||
}
|
||||
}
|
||||
}
|
||||
.js-reveal .reveal.is-in {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
transition:
|
||||
opacity var(--dur-reveal) var(--ease),
|
||||
transform var(--dur-reveal) var(--ease);
|
||||
|
||||
@keyframes reveal-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
.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
@@ -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
@@ -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"]
|
||||
|
||||
Reference in New Issue
Block a user