feat: build steps 7a-10 — the site is complete and reviewable at 22 pages
Steps 7a through 10 as one authorised run. Nothing deployed (D11).
7a Lighthouse returns as `lighthouse@13.4.1` + `chrome-launcher`, NOT
`@lhci/cli`. AGENTS.md §7's advisory attribution was wrong: the carriers
were @lhci/cli's own `tmp` and @puppeteer/browsers' `extract-zip`, not
Lighthouse, which audits clean. A deliberate deviation from R11's literal
trigger, recorded with what it costs. Local gate; CI has no Chrome.
7b OG card generator (satori + sharp) discharges R15 — 20 typed cards plus
per-article cards; the portrait stays on / and /about/ by Q40. Insights
plumbing: ArticleCard, Prose, the index, the article route, articleGraph,
and /'s section 7. Card copy is constrained structurally because text in a
JPEG cannot be grepped by check:claims: every headline IS its page's <h1>,
enforced by `npm run og:proof`.
7c Five drafted launch articles, draft: true / reviewedByPouya: false. An
independent compliance audit returned 76 findings and 57 unsourced
assertions; all blocking and should-fix applied.
8 /contact/, the intake form, and backend/intake/ (undeployed). Plain HTML
POST to a same-origin /api/intake with a 303 redirect, so the form works
with zero JavaScript. docs/05 records three deliberate deviations.
9 /fees/ on Q59's ruling — overtime runs from the session cap, and the
reservation point ships adjacent to the rate. One-page PDF bio discharges
R16; /bio/ is its source, so the circulated artefact stays inside the
review apparatus.
10 /legal/privacy/ and /legal/terms/, written to the backend as built. Three
of the policy's statements are derived and cannot drift.
Also: /about/'s inverse credentials band (approved at step 6); Q59 closed;
R15 and R16 discharged; and a fix to shipped copy — /practice/energy/ asserted
the absence of a regulation the source extract says must not be asserted.
Review: adversarial-reviewer, two rounds (D20/D19). Round 1 returned 16
findings including two blocking — an invisible ghost button on /fees/ at
1.00:1 that Lighthouse scored 100, and a privacy policy that named one data
processor when there are two. All 16 acted on.
Lighthouse, 22 pages, mobile: performance 99-100, accessibility 100,
best practices 100, SEO 100 on every indexable page, CLS 0.000.
AGENTS.md entry (ah) has the detail, including four of my own verification
commands that were wrong and what each of them nearly caused.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Md3GndFqWPzK78xAoebsg5
This commit is contained in:
co-authored by
Claude Opus 5
parent
6cfe69033f
commit
210bc25a26
@@ -0,0 +1,148 @@
|
||||
---
|
||||
/**
|
||||
* docs/02: "Title, description, date, topic pills, reading time."
|
||||
*
|
||||
* ONE LINK, AND THE WHOLE CARD IS ITS HIT AREA — the `PracticeCard` pattern,
|
||||
* for the same measured reason: the link wraps only the headline, so its
|
||||
* accessible name is the headline rather than the card's four elements, and a
|
||||
* `::after` stretched over the positioned card carries the click. Three of these
|
||||
* on `/` would otherwise be three links each announcing a date, two pills, a
|
||||
* reading time and a 150-character description.
|
||||
*
|
||||
* THE PARENT MUST NOT TRY TO STYLE THIS ROOT. Astro does not pass a parent's
|
||||
* scope attribute to a child's root element, so a grid's `.card { block-size:
|
||||
* 100% }` compiles against the parent's cid and never matches — `CLAUDE.md`
|
||||
* records this costing twice, and names `ArticleCard` as one of the next places
|
||||
* it would happen. The card sizes itself below; a parent supplies only
|
||||
* `display: grid` and `gap` on its own element.
|
||||
*
|
||||
* `readingTime` IS RENDERED WITH ITS UNIT AND IS NOT A CLAIM ABOUT THE PRACTICE.
|
||||
* §4 Forbidden bars counts of matters, hours mediated and years in practice —
|
||||
* a number describing how long an article takes to read is not in that family,
|
||||
* and `check:claims`'s `counts-and-tenure` pattern is scoped to the practice.
|
||||
* Do not reach for a matter count, a settlement rate, or a case figure here.
|
||||
*/
|
||||
import Pill from './Pill.astro';
|
||||
import { TOPIC_LABELS, formatArticleDate, isoDate } from '../data/insights';
|
||||
import type { InsightTopic } from '../data/insights';
|
||||
|
||||
interface Props {
|
||||
href: string;
|
||||
title: string;
|
||||
description: string;
|
||||
date: Date;
|
||||
topics: readonly InsightTopic[];
|
||||
/** Minutes. */
|
||||
readingTime: number;
|
||||
/** Explicit: docs/02 forbids skipped heading levels. */
|
||||
level: 2 | 3;
|
||||
}
|
||||
const { href, title, description, date, topics, readingTime, level } =
|
||||
Astro.props;
|
||||
const H = `h${level}` as 'h2' | 'h3';
|
||||
---
|
||||
|
||||
<article class="acard">
|
||||
<div class="acard-meta">
|
||||
<time datetime={isoDate(date)}>{formatArticleDate(date)}</time>
|
||||
<span aria-hidden="true">·</span>
|
||||
<span>{readingTime} min read</span>
|
||||
</div>
|
||||
|
||||
<H class="acard-title">
|
||||
<a class="acard-link" href={href}>{title}</a>
|
||||
</H>
|
||||
|
||||
<p class="acard-desc">{description}</p>
|
||||
|
||||
{
|
||||
topics.length > 0 && (
|
||||
<ul class="acard-topics" role="list">
|
||||
{topics.map((topic) => (
|
||||
<li>
|
||||
<Pill>{TOPIC_LABELS[topic]}</Pill>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
</article>
|
||||
|
||||
<style>
|
||||
.acard {
|
||||
position: relative;
|
||||
/* Sizes itself to its cell — see the note on why the grid cannot. */
|
||||
block-size: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-6);
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-block-start: 2px solid var(--rule);
|
||||
border-radius: var(--radius-md);
|
||||
transition:
|
||||
border-color var(--dur-hover) var(--ease),
|
||||
box-shadow var(--dur-hover) var(--ease);
|
||||
}
|
||||
.acard:hover {
|
||||
border-color: var(--rule);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.acard-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 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);
|
||||
}
|
||||
|
||||
.acard-title {
|
||||
font-family: var(--font-serif);
|
||||
font-size: var(--text-2xl);
|
||||
line-height: var(--leading-tight);
|
||||
letter-spacing: var(--tracking-tight);
|
||||
}
|
||||
.acard-link {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
/* The card-wide hit area. `inset: 0` on the positioned card, so the click
|
||||
target is the card and the accessible name stays the headline. */
|
||||
.acard-link::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: inherit;
|
||||
}
|
||||
/* The ring has to be on the CARD, not on the inline text, or focus draws a
|
||||
box around two words in the middle of a clickable panel. */
|
||||
.acard-link:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
.acard-link:focus-visible::after {
|
||||
outline: 2px solid var(--focus-ring);
|
||||
outline-offset: var(--focus-offset);
|
||||
}
|
||||
|
||||
.acard-desc {
|
||||
font-size: var(--text-base);
|
||||
line-height: var(--leading-body);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.acard-topics {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
/* `margin-block-start: auto` pins the pills to the bottom of the card so a
|
||||
row of cards with different description lengths still aligns on them. */
|
||||
margin-block-start: auto;
|
||||
padding-block-start: var(--space-3);
|
||||
}
|
||||
</style>
|
||||
@@ -66,18 +66,47 @@ const classes = ['btn', `btn-${variant}`, className];
|
||||
color: var(--text-inverse);
|
||||
}
|
||||
|
||||
/* ⚠️ THE THREE HOOKS BELOW EXIST BECAUSE THIS BUTTON SHIPPED INVISIBLE.
|
||||
Found 2026-08-31 by `adversarial-reviewer` on `/fees/`, measured in headless
|
||||
Chrome against `dist/`: `{"t":"How an engagement runs →","color":"rgb(26, 22,
|
||||
20)","bg":"rgb(26, 22, 20)","ratio":1}`. `.btn-ghost` sets `color:
|
||||
var(--text)` — ink — and a border of `--border`, which is ink at 10% alpha.
|
||||
On a `.section-inverse` ground both are the background colour. **Ratio
|
||||
1.00:1: a navigation link the same colour as the panel it sits on**, worse
|
||||
than the gold-on-cream 2.10:1 this project treats as unshippable.
|
||||
|
||||
⚠️ AND LIGHTHOUSE SCORED THAT PAGE ACCESSIBILITY 100. axe's
|
||||
`color-contrast` rule SKIPS a foreground that exactly equals its background
|
||||
as "unable to determine" — so the a11y category cannot be the only contrast
|
||||
control here, and a computed-contrast sweep is not redundant with it.
|
||||
|
||||
THE HOOKS ARE CUSTOM PROPERTIES, NOT A GLOBAL DESCENDANT RULE, and that is
|
||||
the load-bearing part. A parent cannot style a child component's root
|
||||
(CLAUDE.md), and `global.css`'s `.section-inverse .btn-ghost` would compile
|
||||
at specificity (0,2,0) — identical to `.btn-ghost[data-astro-cid]` here — so
|
||||
which one won would depend on injection order. `AGENTS.md` records that
|
||||
exact trap being hit once already, on `.btn-gold`. Custom properties
|
||||
INHERIT, which is the one mechanism that legitimately crosses the boundary;
|
||||
it is what `Pill` and `DefinitionGrid` already use. The fallbacks keep the
|
||||
on-cream appearance byte-identical. */
|
||||
.btn-ghost {
|
||||
background: transparent;
|
||||
border-color: var(--border);
|
||||
color: var(--text);
|
||||
border-color: var(--btn-ghost-border, var(--border));
|
||||
color: var(--btn-ghost-fg, var(--text));
|
||||
}
|
||||
.btn-ghost:hover {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
border-color: var(--btn-ghost-border-hover, var(--accent));
|
||||
color: var(--btn-ghost-fg-hover, var(--accent));
|
||||
}
|
||||
|
||||
/* `background: var(--bg-inverse)` is ink, so on an inverse ground the pill has
|
||||
no boundary and reads as bare text — the milder half of the same finding.
|
||||
The label is gold-l at 11.09:1 on ink and stays legible, so this needs an
|
||||
EDGE rather than a new colour scheme: giving it a different ground would be
|
||||
redesigning a button shipped at step 5 rather than fixing a defect. */
|
||||
.btn-gold {
|
||||
background: var(--bg-inverse);
|
||||
border-color: var(--btn-gold-border, transparent);
|
||||
color: var(--text-inverse-2);
|
||||
}
|
||||
.btn-gold:hover {
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
---
|
||||
/**
|
||||
* docs/02: "Long-form wrapper. Owns all typographic defaults for MDX."
|
||||
*
|
||||
* WHY IT HAS TO OWN THEM. `global.css`'s reset sets `* { margin: 0 }` and the
|
||||
* base type rules deliberately do not style `<h2>`, `<ul>`, `<blockquote>` or
|
||||
* `<code>` in body flow — every page so far has written its own section markup,
|
||||
* so nothing on the site has ever needed defaults for a document. An MDX article
|
||||
* is the first content this repo does not hand-mark up, and without a wrapper it
|
||||
* would render as one undifferentiated block. `global.css` already records that
|
||||
* exact failure for `.prose` itself: two `<p>` children with a 0.0 px gap,
|
||||
* shipped, because nothing supplied paragraph spacing.
|
||||
*
|
||||
* `:where()` ON EVERY SELECTOR, so specificity stays at zero and a page or a
|
||||
* component can override any of it without `!important` — the same device
|
||||
* `global.css` uses for `:where(.prose) > p + p`, and for the same reason.
|
||||
*
|
||||
* SCOPED STYLES NEED `:global()` HERE, and this is the one component where that
|
||||
* is correct rather than a smell: the elements being styled come from MDX at
|
||||
* build time and carry no `data-astro-cid` of this component's, so a scoped
|
||||
* descendant selector would match nothing. Astro's own `is:global` guidance.
|
||||
* The wrapper element itself is ours, so everything stays inside `.prose-body`.
|
||||
*
|
||||
* NO `max-inline-size` OF ITS OWN — it composes with `global.css`'s `.prose`,
|
||||
* which caps the reading measure at `--width-prose`. A second cap here would be
|
||||
* a second number to keep true.
|
||||
*/
|
||||
---
|
||||
|
||||
<div class="prose prose-body">
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* --- Rhythm ---------------------------------------------------------- */
|
||||
|
||||
.prose-body :global(:where(p, ul, ol, blockquote, figure, hr, table)) {
|
||||
margin-block-start: var(--space-5);
|
||||
line-height: var(--leading-body);
|
||||
}
|
||||
.prose-body :global(:where(p, li)) {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* --- Headings -------------------------------------------------------- */
|
||||
|
||||
/* An article's own `<h1>` is the page's, rendered by the route. MDX bodies
|
||||
start at `##`, so these are h2/h3/h4. A skipped level is a docs/02 breach
|
||||
and is caught by review, not by CSS. */
|
||||
.prose-body :global(:where(h2)) {
|
||||
margin-block-start: var(--space-8);
|
||||
font-family: var(--font-serif);
|
||||
font-size: var(--text-3xl);
|
||||
line-height: var(--leading-tight);
|
||||
letter-spacing: var(--tracking-tight);
|
||||
color: var(--text);
|
||||
}
|
||||
.prose-body :global(:where(h3)) {
|
||||
margin-block-start: var(--space-7);
|
||||
font-family: var(--font-serif);
|
||||
font-size: var(--text-xl);
|
||||
line-height: var(--leading-tight);
|
||||
color: var(--text);
|
||||
}
|
||||
.prose-body :global(:where(h4)) {
|
||||
margin-block-start: var(--space-6);
|
||||
font-size: var(--text-base);
|
||||
font-weight: var(--weight-semi);
|
||||
color: var(--text);
|
||||
}
|
||||
/* Nothing may collapse against the top of the article. */
|
||||
.prose-body :global(:where(:first-child)) {
|
||||
margin-block-start: 0;
|
||||
}
|
||||
|
||||
/* --- Lists ------------------------------------------------------------ */
|
||||
|
||||
/* `global.css` strips list styling from `ul[role='list']` only, so an MDX
|
||||
list keeps the UA marker and needs indenting rather than resetting. */
|
||||
.prose-body :global(:where(ul, ol)) {
|
||||
padding-inline-start: var(--space-6);
|
||||
}
|
||||
.prose-body :global(:where(li + li)) {
|
||||
margin-block-start: var(--space-3);
|
||||
}
|
||||
.prose-body :global(:where(li)) {
|
||||
padding-inline-start: var(--space-1);
|
||||
}
|
||||
.prose-body :global(:where(li::marker)) {
|
||||
color: var(--text-meta);
|
||||
}
|
||||
|
||||
/* --- Emphasis, links, quotes ----------------------------------------- */
|
||||
|
||||
.prose-body :global(:where(strong)) {
|
||||
font-weight: var(--weight-semi);
|
||||
color: var(--text);
|
||||
}
|
||||
.prose-body :global(:where(em)) {
|
||||
font-style: italic;
|
||||
}
|
||||
/* Links keep `global.css`'s colour and underline; only the offset is set, so
|
||||
a descender does not sit on the rule at body size. */
|
||||
.prose-body :global(:where(a)) {
|
||||
text-underline-offset: 0.15em;
|
||||
}
|
||||
|
||||
.prose-body :global(:where(blockquote)) {
|
||||
padding-inline-start: var(--space-5);
|
||||
border-inline-start: 2px solid var(--rule);
|
||||
font-family: var(--font-serif);
|
||||
font-size: var(--text-lg);
|
||||
color: var(--text);
|
||||
}
|
||||
.prose-body :global(:where(blockquote p)) {
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.prose-body :global(:where(hr)) {
|
||||
margin-block: var(--space-8);
|
||||
border: none;
|
||||
border-block-start: 1px solid var(--rule);
|
||||
}
|
||||
|
||||
/* --- Code ------------------------------------------------------------- */
|
||||
|
||||
/* Inline code only. A statute reference or a header name, not a code block:
|
||||
nothing in docs/03's content territories calls for one, and `<pre>` would
|
||||
need horizontal overflow handling this component has no call site for. Add
|
||||
it with the first article that needs it, and give it `overflow-x: auto`. */
|
||||
.prose-body :global(:where(code)) {
|
||||
padding: 0.1em 0.35em;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.9em;
|
||||
background: var(--bg-raised);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
/* --- Figures and tables ---------------------------------------------- */
|
||||
|
||||
.prose-body :global(:where(img)) {
|
||||
max-inline-size: 100%;
|
||||
block-size: auto;
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
.prose-body :global(:where(figcaption)) {
|
||||
margin-block-start: var(--space-3);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-meta);
|
||||
}
|
||||
/* ⚠️ NO TABLE RULES, AND THE THREE THAT WERE HERE ARE DELETED RATHER THAN
|
||||
FIXED. They set `display: block; overflow-x: auto` on the `<table>` itself,
|
||||
which has two defects: `display: block` **removes the table role** in
|
||||
Chromium and WebKit, so rows and cells lose their semantics for assistive
|
||||
technology; and an `overflow-x: auto` box with no `tabindex="0"` cannot be
|
||||
scrolled by keyboard (WCAG 2.1.1). The comment said *"the wrapper carries
|
||||
it"* — there was no wrapper; the properties were on the table.
|
||||
|
||||
Doing it properly means a real wrapper with `tabindex="0"`, `role="region"`
|
||||
and an accessible name, which in MDX means a rehype plugin or a `<Table>`
|
||||
component. **None of the five drafted articles contains a table**, and with
|
||||
all five `draft: true` no article page builds, so this CSS shipped nowhere:
|
||||
deleting it now and adding it with the first article that needs one is the
|
||||
same decision the `code` note above already takes for `<pre>`.
|
||||
Found by `adversarial-reviewer`, 2026-08-31, and correctly filed as latent. */
|
||||
</style>
|
||||
+70
-14
@@ -12,6 +12,7 @@
|
||||
import { getImage } from 'astro:assets';
|
||||
import ogDefault from '../assets/og-portrait.jpg';
|
||||
import { SITE, PORTRAIT } from '../data/site';
|
||||
import { OG_CARDS, PORTRAIT_PAGES, ogCardPath } from '../data/og-cards';
|
||||
|
||||
export interface Props {
|
||||
/** The full rendered <title>. Pattern: "<Page> · Pouya Lajevardi". 50–60. */
|
||||
@@ -21,8 +22,15 @@ export interface Props {
|
||||
/** 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. */
|
||||
/**
|
||||
* AN EXPLICIT PER-PAGE OVERRIDE, AND ALMOST NOTHING SHOULD PASS IT. Which
|
||||
* pages take the portrait is decided by `PORTRAIT_PAGES` and everything else
|
||||
* takes its generated card — both resolved below from the pathname, so the
|
||||
* decision lives in `src/data/og-cards.ts` rather than in nineteen call sites.
|
||||
* This exists for an article that sets its own `image` in frontmatter. Passing
|
||||
* it to get the portrait onto a third page would reinstate the interim R15
|
||||
* exists to end. `ImageMetadata` is an Astro ambient global — nothing to import.
|
||||
*/
|
||||
image?: ImageMetadata;
|
||||
imageAlt?: string;
|
||||
/** /legal/* and any temporary page. Emits noindex,follow per docs/04. */
|
||||
@@ -78,16 +86,64 @@ if (!Astro.site) {
|
||||
}
|
||||
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);
|
||||
/**
|
||||
* THE OG IMAGE, AND THIS IS WHERE R15 IS DISCHARGED — build step 7b.
|
||||
*
|
||||
* Two kinds of card, per Q40 and docs/04, both resolved from the pathname: the
|
||||
* pages in `PORTRAIT_PAGES` get the portrait crop, and every other page gets the
|
||||
* card generated for it by `src/pages/og/[...slug].jpg.ts`.
|
||||
*
|
||||
* ⚠️ A MISSING REGISTRY ENTRY THROWS RATHER THAN FALLING BACK TO THE PORTRAIT.
|
||||
* That is the whole mechanism. R15's failure mode is not that the wrong image
|
||||
* ships — it is that the wrong image ships *invisibly*, because no one on this
|
||||
* project ever sees a link preview. A silent fallback reproduces exactly that,
|
||||
* and reads as intentional. Both sides derive the path from `ogCardPath()`, so a
|
||||
* page with an entry cannot point at a card the endpoint did not generate.
|
||||
*
|
||||
* Articles are exempt from the registry check: their cards come from the same
|
||||
* `getCollection('insights', not draft)` the article route pages come from, so
|
||||
* a built article always has one and a draft has neither.
|
||||
*/
|
||||
const path = Astro.url.pathname;
|
||||
const isArticle = /^\/insights\/[^/]+\/$/.test(path);
|
||||
const usesPortrait = (PORTRAIT_PAGES as readonly string[]).includes(path);
|
||||
const hasCard = isArticle || path in OG_CARDS;
|
||||
|
||||
if (!image && !usesPortrait && !hasCard) {
|
||||
throw new Error(
|
||||
`No Open Graph card for ${path}.\n` +
|
||||
' Add an entry to OG_CARDS in src/data/og-cards.ts whose `headline` is ' +
|
||||
"this page's own <h1>, verbatim — `npm run og:proof` compares the two.\n" +
|
||||
' Only the pages in PORTRAIT_PAGES use the portrait (AGENTS.md Q40, R15).',
|
||||
);
|
||||
}
|
||||
|
||||
// JPEG on purpose, for both kinds. 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.
|
||||
// The generated card is already a 1200×630 JPEG, so it takes no `getImage` pass;
|
||||
// running one would re-encode a finished image for nothing.
|
||||
const portraitSource = image ?? ogDefault;
|
||||
const ogImageUrl =
|
||||
image || usesPortrait
|
||||
? new URL(
|
||||
(
|
||||
await getImage({
|
||||
src: portraitSource,
|
||||
format: 'jpeg',
|
||||
width: 1200,
|
||||
height: 630,
|
||||
})
|
||||
).src,
|
||||
Astro.site,
|
||||
)
|
||||
: new URL(ogCardPath(path), Astro.site);
|
||||
|
||||
// A typographic card's alt is its headline, which for every card in the
|
||||
// registry is the page's own <h1> — and `title` is the string already required
|
||||
// to be unique per page. The portrait keeps the person's name.
|
||||
const resolvedImageAlt =
|
||||
imageAlt ?? (image || usesPortrait ? PORTRAIT.alt : title);
|
||||
|
||||
// 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
|
||||
@@ -114,13 +170,13 @@ const jsonLdText =
|
||||
<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 property="og:image:alt" content={resolvedImageAlt} />
|
||||
|
||||
<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} />
|
||||
<meta name="twitter:image:alt" content={resolvedImageAlt} />
|
||||
|
||||
{
|
||||
jsonLdText && (
|
||||
|
||||
+16
-16
@@ -2,6 +2,7 @@ import { defineCollection } from 'astro:content';
|
||||
import { glob } from 'astro/loaders';
|
||||
import { z } from 'astro/zod';
|
||||
import { PRACTICE_SLUGS } from './data/site';
|
||||
import { INSIGHT_TOPICS } from './data/insights';
|
||||
|
||||
/**
|
||||
* `<title>` length, from docs/04-seo-spec.md.
|
||||
@@ -97,16 +98,12 @@ const insights = defineCollection({
|
||||
* regulatory and industry commentary.
|
||||
*/
|
||||
topics: z
|
||||
.array(
|
||||
z.enum([
|
||||
'process-explainer',
|
||||
'regulatory-commentary',
|
||||
'industry-commentary',
|
||||
'reflection',
|
||||
'technical-explainer',
|
||||
'credentialing',
|
||||
]),
|
||||
)
|
||||
/* The tuple lives in `src/data/insights.ts`, imported rather than
|
||||
written out here — build step 7b. It was inline until then, which
|
||||
made the display labels a second copy of the same list, and the
|
||||
copy that drifts is the one nobody re-reads. `TOPIC_LABELS` is
|
||||
keyed off it, so an unlabelled topic is a type error. */
|
||||
.array(z.enum(INSIGHT_TOPICS))
|
||||
.min(1)
|
||||
.refine((t) => new Set(t).size === t.length, 'No duplicate topics.'),
|
||||
practiceAreas: z
|
||||
@@ -121,12 +118,15 @@ const insights = defineCollection({
|
||||
image: image().optional(),
|
||||
imageAlt: z.string().trim().min(1).optional(),
|
||||
/**
|
||||
* INTENT, not yet enforced — there is no /insights/ route to enforce it
|
||||
* in. The mechanism, when step 7 builds that route: filter drafts out of
|
||||
* `getCollection('insights', ...)` so no page is generated, which keeps
|
||||
* them out of the build, the index, and the sitemap in one move. The
|
||||
* sitemap filter in astro.config.mjs cannot see collection data and is
|
||||
* not the right place for it. See docs/04-seo-spec.md.
|
||||
* ENFORCED SINCE BUILD STEP 7b, and by ONE predicate rather than four.
|
||||
* `!data.draft` is the filter passed to every `getCollection('insights')`
|
||||
* call on the site — the article route, the index, the home page's latest
|
||||
* strip, the `SiteHeader` nav gate, and the OG card endpoint. A draft
|
||||
* therefore produces no page, so it is absent from the build, the index,
|
||||
* the sitemap and the card set as a consequence of not existing, not
|
||||
* because four places each remembered to exclude it. The sitemap filter
|
||||
* in astro.config.mjs cannot see collection data and is deliberately not
|
||||
* where this lives. See docs/04-seo-spec.md.
|
||||
*/
|
||||
draft: z.boolean().default(true),
|
||||
/** Every article is reviewed by Pouya before publication — D9. */
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
---
|
||||
title: 'Bill 40 and grid connection: a dispute-resolution read'
|
||||
description: 'Ontario Bill 40 of the 44th Parliament, 1st Session widened what the OEB may weigh on leave to construct and gated grid connection for large loads.'
|
||||
# publishDate is the drafting date. Set it on approval (D9).
|
||||
publishDate: 2026-08-31
|
||||
topics: ['regulatory-commentary']
|
||||
practiceAreas: ['energy']
|
||||
readingTime: 8
|
||||
draft: true
|
||||
reviewedByPouya: false
|
||||
---
|
||||
|
||||
import { NEUTRAL_ROLE_LINE } from '../../data/site';
|
||||
|
||||
## Which Bill 40
|
||||
|
||||
Bill numbers are reused every parliament. Bill 40 of the 43rd Parliament, 1st Session is the Moving Ontarians Safely Act, 2023, amending the Highway Traffic Act. Bill 40 of the 42nd Parliament, 2nd Session is the Support for Adults in Need of Assistance Act, 2021. Neither touches electricity.
|
||||
|
||||
The energy one is Bill 40 of the 44th Parliament, 1st Session: the Protect Ontario by Securing Affordable Energy for Generations Act, 2025, sponsored by the Minister of Energy and Mines. The Legislative Assembly's status page for the Bill records First Reading on 3 June 2025 and Royal Assent on 11 December 2025. It is now chapter 22 of the Statutes of Ontario, 2025. Both dates do work below.
|
||||
|
||||
Cite it with the parliament and the session, because a reference carrying neither points at three unrelated statutes across three different parliaments.
|
||||
|
||||
Its long title is accurate about the method: "An Act to amend various statutes with respect to energy, the electrical sector and public utilities". Its three schedules amend the Electricity Act, 1998, the Municipal Franchises Act, and the Ontario Energy Board Act, 1998. What interests me is not what it created. It is which existing negotiations it moved.
|
||||
|
||||
## Section 96 grew a second branch
|
||||
|
||||
Leave to construct is section 92 of the Ontario Energy Board Act, 1998. No person may construct, expand or reinforce an electricity transmission or distribution line, or make an interconnection, without an order of the Board granting leave. Section 89 draws the line at voltage. Above 50 kilovolts is transmission; 50 kilovolts or less is distribution.
|
||||
|
||||
The thresholds people actually argue about are not in the section. They are exemptions in O. Reg. 161/99, which carves distribution lines out of section 92 outright and exempts a transmission line of two kilometres or less.
|
||||
|
||||
Section 96(1) supplies the test. If the Board is of the opinion that the work is in the public interest, it shall make an order granting leave. On a section 92 application, section 96(2) confines what the public interest may mean: the Board "shall only consider" the matters enumerated there. Bill 40's Schedule 3 lengthened that list. It now runs to the interests of consumers with respect to prices and the reliability and quality of electricity service, and to supporting economic growth consistent with Government of Ontario policy. A new section 96(3) requires the Board, on such an application, to consider such reports, documents or other information as may be prescribed by regulation. Both came into force on 11 December 2025.
|
||||
|
||||
That changes the shape of the record rather than the outcome of any application. A proponent's economic case now sits inside the statutory test instead of behind it, and part of the evidentiary burden can be set by regulation after the project's commercial arrangements are signed. Two allocations follow, and section 96(2) excludes both from the Board's public-interest inquiry: who pays to produce that material, and who carries the delay if it comes back thin. Both belong in the parties' commercial agreements, not the Board's record.
|
||||
|
||||
## A priority project settles need, not route
|
||||
|
||||
Section 96.1(1) lets the Lieutenant Governor in Council declare the construction, expansion or reinforcement of a specified transmission line to be needed as a priority project. The OEB's own page on leave-to-construct applications for priority transmission projects is direct about the consequence: approval under section 92 is still required, but "in these cases the OEB must accept that the project is needed when forming its opinion under section 96 of the Act."
|
||||
|
||||
A declaration removes one argument and leaves the others standing. Need is settled. Route, land, conditions and cost responsibility are not. Section 94 shows where the friction lives. The applicant files a map showing the municipalities, highways, railways, utility lines and navigable waters the proposed work passes through, under, over, upon or across. Each of those is a counterparty, an approval, or both.
|
||||
|
||||
As at the end of August 2026 that same OEB page recorded that no leave-to-construct application for a declared priority project was before the Board. That will change, and the first one will run alongside private disputes about access and cost, neither of which is among the two matters section 96(2) lets the Board weigh.
|
||||
|
||||
## The gate for large loads, and the date that sorts the pipeline
|
||||
|
||||
Schedule 1 added section 28.1 to the Electricity Act, 1998, in force 11 December 2025. It is a hard gate. Unless a transmitter or distributor is satisfied that the specified connection requirements have been complied with, it shall not connect a specified load facility to its system, or reconnect one that was disconnected for breach of those requirements.
|
||||
|
||||
"Specified load facility" is defined two ways. A facility that is a data centre and meets any criteria set out in the regulations. Or a facility that withdraws electricity from the IESO-controlled grid or from a distributor's system, whose connection demand exceeds a prescribed amount, and which meets any other prescribed criteria. Both limbs point outward. Bill 40 amended the regulation-making authority in section 114 to match, adding the power to define "data centre" for the purposes of section 28.1 and to prescribe criteria by geographic area, volume withdrawn, or connection demand.
|
||||
|
||||
The enabling section has been in force since 11 December 2025. The Ministry of Energy and Mines' Environmental Registry of Ontario posting of 13 August 2026, ERO 026-0853, described the connection-approval regulation as something "the province is considering drafting". The same posting proposes a Data Centre Playbook assessed against economic development, data security and digital sovereignty, and community trust, and separately proposes a new rate class under O. Reg. 429/04 for data centres above a demand threshold that would not be eligible for the Industrial Conservation Initiative. That comment period closes on 12 September 2026, so anything in it may move.
|
||||
|
||||
The provision that does not move is the transition rule. Section 28.1(6) says the section does not apply to a specified load facility whose connection request was submitted to a transmitter or distributor, in accordance with the Transmission System Code or the Distribution System Code, before 3 June 2025. That is the day Bill 40 had First Reading.
|
||||
|
||||
So one date sorts a pipeline into two regimes, and the requirements the later one turns on were still described by the Ministry in August 2026 as something the province is considering drafting. I expect arguments about which side a given project falls on: what was submitted, to whom, on what date, and whether it was a connection request in accordance with the applicable code at all. Those are questions about documents, which a neutral can work through with the parties rather than around them. The [energy disputes](/practice/energy/) I am built for start there rather than at the Board.
|
||||
|
||||
## The connection process is where the schedule lives
|
||||
|
||||
The mechanics of getting connected sit outside Bill 40, and they are what a supply agreement or a construction programme is quietly dated against.
|
||||
|
||||
The IESO's own description of the connection process sets out up to six stages, beginning with preparing the application and ending after the equipment is registered and tested. A transmitter's connections are generally subject to all six; a distributor's may be subject only to the first three. The umbrella name is connection assessment and approval. The IESO decides whether an application qualifies for a system impact assessment or an expedited one, and the transmitter generally runs its own customer impact assessment after the IESO's draft report, under a separate agreement. The final report goes out with either a notification of conditional approval or a notification of disapproval with reasons.
|
||||
|
||||
There is no queue. The IESO states in terms that it is not using an interconnection queue, and works instead from the concept of committed projects defined in its Market Manual 1.4. An argument built on a project's place in line is an argument about nothing.
|
||||
|
||||
The published timings are long. The IESO puts conditional approval at typically one year, and the process as a whole at anywhere from a few months for a small modification to more than three years for a new facility. A supply agreement, a site lease or a construction programme written against an earlier assumption is the dispute, and a dispute in that shape is a [construction claim](/practice/construction/) as much as an energy one.
|
||||
|
||||
## Schedule 2 takes the electors out of a municipal by-law
|
||||
|
||||
Schedule 2 amended the Municipal Franchises Act. The Bill's own summary of that schedule records that section 3 was re-enacted to remove the requirement for the municipal electors to assent to the by-law, and instead to require that a municipality pass a by-law setting out the terms and conditions.
|
||||
|
||||
That is a small amendment and it moves a step. Where that amendment is in force, the pressure moves to the drafting: assent to a by-law is one yes-or-no step, and terms and conditions in a by-law are not. Terms that have to be settled between a municipality and the party a by-law concerns are the kind of thing a facilitated process can move.
|
||||
|
||||
## What this changes about choosing a process
|
||||
|
||||
Each of these amendments puts a commercial argument next to a regulatory process that keeps its own timetable. That timetable is not something the parties can agree to move. Almost everything around it is: cost responsibility, schedule risk, the consequences of a condition attached to a conditional approval, and the allocation of a delay that nobody caused.
|
||||
|
||||
Two points follow for counsel. The first is timing. A session booked before the final system impact assessment report exists is one at which the conditional approval, and the conditions attached to it, do not exist yet, so the regulatory sequence belongs in the scheduling conversation. [The shape of an engagement](/process/) sets out where I put it. The second is the record. Where the parties want a decision instead of a settlement, a [commercial arbitration](/arbitration/) keeps the study, the conditions and the technical argument in front of one decision-maker rather than split across a hearing and a negotiation.
|
||||
|
||||
The disputes Bill 40 will generate have mostly not been had yet, because the regulation the large-load gate depends on was still under consideration as at August 2026. That is the honest description of this area, and it is why I describe energy as a position I am building into rather than a volume of work I have already done. Everything above is as at the end of August 2026 and should be checked before it is relied on. Nothing here is applied to a particular matter. {NEUTRAL_ROLE_LINE}
|
||||
@@ -0,0 +1,79 @@
|
||||
---
|
||||
title: 'Choosing a neutral: what counsel should actually ask'
|
||||
description: 'What counsel should ask before appointing a mediator or a commercial arbitrator: what a designation records, whose rules apply, and who reads the record.'
|
||||
# publishDate is the drafting date. Set it on approval (D9).
|
||||
publishDate: 2026-08-31
|
||||
topics: ['process-explainer', 'credentialing']
|
||||
practiceAreas: ['construction', 'technology']
|
||||
readingTime: 7
|
||||
draft: true
|
||||
reviewedByPouya: false
|
||||
---
|
||||
|
||||
import { CONDUCT_UNDERTAKINGS } from '../../data/site';
|
||||
|
||||
## What a designation records, and what it does not
|
||||
|
||||
Counsel choosing a neutral usually has a short list of names, a rate for each, and a signature block full of abbreviations. The abbreviations are the part most often skipped. They are also the part that can be checked in a few minutes.
|
||||
|
||||
The ADR Institute of Ontario publishes its own expansions on its professional designations page. Q.Med is Qualified Mediator. Q.Arb is Qualified Arbitrator. C.Med is Chartered Mediator. C.Arb is Chartered Arbitrator. The long forms are worth taking from the conferring body's own page rather than from recall, because the abbreviations sit close together and a wrong expansion is easy to write.
|
||||
|
||||
What those designations record is training. ADRIO's page for the Qualified designations describes them as recognising practitioners who have completed sufficient mediation or arbitration training, and related dispute resolution training. The same page notes that Q.Med criteria vary across affiliates, and points an applicant to the checklist on the application form for the criteria specific to Ontario.
|
||||
|
||||
What the page does not describe is what any activity requires. ADRIO sets out what its own designations recognise, and it says nothing about permission. A designation should not be read as though it did. So the question a designation answers is narrow: which body conferred it, against which criteria, and is it current.
|
||||
|
||||
Currency is the half that gets assumed. ADRIO's pages for C.Med and C.Arb each state that there is an annual fee to maintain the designation, payable to the ADR Institute of Canada, Inc., and that the holder must remain a member in good standing with the ADR Institute of Ontario to retain it. The Qualified page addresses neither fees nor retention. That is a fact about the page rather than an answer, particularly since ADRIO records on the same page that the criteria vary across affiliates. A signature block cannot settle currency. That is a question for the neutral, or for the conferring body.
|
||||
|
||||
## Whose rules the process will run under
|
||||
|
||||
The second question is whose rules the process runs under, and it is cheaper to ask before an appointment than to discover at the first call.
|
||||
|
||||
For mediation, the ADR Institute of Canada publishes the ADRIC National Mediation Rules. ADRIC's own description is that the rules provide for initiating mediations, including the appointment of a mediator should the parties be unable to come to an agreement. The document carries more than the rules themselves: a code of conduct, a standard form agreement to mediate at Schedule B, ADRIC's administration fees at Schedule A, and a model dispute resolution clause for contracts.
|
||||
|
||||
One currency note on the same rules. ADRIC's page states, as of 2025, that its Mediation Committee is reviewing the Mediation Rules, and that the existing rules remain in effect and should continue to be used until any updates are formally adopted. The sensible course is to check it at the point of appointment rather than to date the rules in a submission.
|
||||
|
||||
For arbitration, ADRIC adopted new Arbitration Rules and a new Arbitrator Appointment Protocol effective 1 March 2025, published as the ADRIC Arbitration Rules – Effective 2025. It publishes named forms alongside them: Notice to Arbitrate, Request to Administer the Arbitration, Request for the appointment of an arbitrator, Application for Urgent Interim Measures, Application to Challenge an Arbitrator, and Notice of Appeal.
|
||||
|
||||
None of that is a statement of what the rules require. The rules are published documents, and where an appointment will run under them the document is the thing to read rather than a summary of it, this one included. What can be settled in advance is which rule set applies, what governs where the contract is silent, and what the tribunal is left to decide. Where it is silent, the protocol is settled in writing before the session. I set out the rule sets I work under on [mediation](/mediation/) and [commercial arbitration](/arbitration/).
|
||||
|
||||
## What the neutral does with what is said in caucus
|
||||
|
||||
Third, and this is the question that discriminates most: what happens to caucus material.
|
||||
|
||||
In mediation the answer should be stated rather than assumed. Mine is published, and it is this. "{CONDUCT_UNDERTAKINGS.mediationCaucus}"
|
||||
|
||||
In med-arb the question is harder, because the neutral who hears the caucus may later decide the matter. ADRIC publishes ADRIC Med-Arb Rules, presented to its membership as a discussion draft at its 2019 annual conference and designed, in ADRIC's words, to "work in tandem with ADRIC's existing Mediation Rules and Arbitration Rules." Nothing here is a claim about what that draft provides, or about its status. Where a med-arb appointment names a rule set, the document is the thing to read.
|
||||
|
||||
Two things are worth asking of any med-arb appointment, and both are answerable in writing before it starts. The first is when and how the switch from mediation to arbitration happens, and what has to be agreed for it to happen at all. The second is what becomes of something said in confidence that turns out to matter to the decision. I accept med-arb appointments in commercial matters, and both answers are set out on [med-arb](/med-arb/). The second is the harder one. "{CONDUCT_UNDERTAKINGS.medArbStepOut}"
|
||||
|
||||
## Dates, and whether they are real
|
||||
|
||||
Fourth: availability. Three questions get at it. Which dates are actually held. How long a date is held without a signed agreement to mediate. Whether a second day is booked at the outset or looked for after the first one runs out.
|
||||
|
||||
Where the parties cannot agree on a name, ADRIC's National Mediation Rules cover the appointment of a mediator. That is a route rather than a date.
|
||||
|
||||
In a commercial arbitration the date that matters most is the award. Mine is published, and it is this. "{CONDUCT_UNDERTAKINGS.arbitrationAwardDate}"
|
||||
|
||||
No turnaround figure is published here. A time to award quoted before anyone has seen the record is a guess, whoever quotes it. A date in the first procedural order is a different thing: it is fixed once the shape of the record is known, and both parties can see it.
|
||||
|
||||
## Fees, and what happens when the day runs long
|
||||
|
||||
Fifth. The rate is the easy part of the fee question. What a day means and when it ends, whether preparation is charged separately and how it is estimated, the cancellation schedule and the notice period it turns on, who is billed and in what shares — those are the terms that decide what a process actually costs.
|
||||
|
||||
The overrun question is specific enough to be worth its own sentence. A session is booked to five o'clock, and at seven the parties are close. The possible answers are all defensible: the day converts to hourly, a day is a day whatever it runs to, the neutral stops. What is not defensible is finding out which one applies at half past six.
|
||||
|
||||
ADRIC's mediation rules publish the institute's administration fees at Schedule A of the same document. Whether they apply to a given appointment is a question for the institute, and it is not the neutral's own fee.
|
||||
|
||||
## Whether the neutral can read the record the dispute turns on
|
||||
|
||||
Sixth, and last. Some disputes turn on a document rather than on a submission — a critical-path analysis is one, a model card is another.
|
||||
|
||||
I work as a machine-learning and infrastructure engineer. On a [construction](/practice/construction/) file that means the baseline programme, the as-built, the change-order log and the delay analysis are documents I read, rather than take on trust from whichever expert explains them most confidently. On a [technology](/practice/technology/) file it means an API trace, a set of monitoring dashboards, a model card, an evaluation harness and a data-processing addendum.
|
||||
|
||||
The question that gets at this with any neutral is which primary documents will be read before the session, and which will be taken on an expert's account of them. An answer that names documents can be checked against the productions. An answer that names an industry cannot.
|
||||
|
||||
## Asked before the appointment, and answered in writing
|
||||
|
||||
None of this requires a long call. All of it is easier to raise before an appointment than after, because before the appointment an answer is a term and after it is a complaint.
|
||||
|
||||
[The shape of an engagement](/process/) sets out when conflicts are run: on the intake call, before anything is agreed. Where a party has no counsel, [what happens at a mediation](/for-parties/) is the more useful page. Everything else above is a question, and the answers are what counsel is actually choosing between.
|
||||
@@ -0,0 +1,68 @@
|
||||
---
|
||||
title: 'What the Ontario data-centre build-out means for dispute resolution'
|
||||
seoTitle: 'Ontario''s data-centre build-out and dispute resolution'
|
||||
description: 'A large-load grid connection, a construction contract and a technology contract meet on one date. Why litigating first and mediating late fits that badly.'
|
||||
# publishDate is the drafting date. Set it on approval (D9).
|
||||
publishDate: 2026-08-31
|
||||
topics: ['industry-commentary', 'technical-explainer']
|
||||
practiceAreas: ['technology', 'construction', 'energy']
|
||||
readingTime: 8
|
||||
draft: true
|
||||
reviewedByPouya: false
|
||||
---
|
||||
|
||||
## Three sets of rules over one connection date
|
||||
|
||||
A large data centre in Ontario is three projects wearing one name.
|
||||
|
||||
There is a connection: an assessment run by the Independent Electricity System Operator and by the transmitter, ending in an approval that arrives on the date the financial model assumed, or does not. There is a build: a construction contract, subcontracts under it, and the Construction Act's prompt payment and interim adjudication machinery standing behind every invoice. And there is a load: the computing the building exists to house, under a technology contract with its own service levels, capacity terms and data terms.
|
||||
|
||||
Each has a different decision-maker, a different vocabulary, and a different idea of what a deadline is. They converge on the date the facility can energise. That convergence is the shape of the dispute, and a dispute clause drafted for one of the three contracts alone will not hold it.
|
||||
|
||||
## What the connection assessment and approval process is
|
||||
|
||||
The terminology is precise and the wrong word travels badly, so it is worth taking from the IESO's own description of the connection process. The umbrella is connection assessment and approval, or CAA. Within it the IESO performs a System Impact Assessment (SIA), or an expedited SIA where the application qualifies, and assigns a unique CAA ID. The transmitter performs a Customer Impact Assessment (CIA), which the IESO says the transmitter generally initiates after the draft SIA report. The SIA agreement is prepared in accordance with section 6.1.15.3 of chapter 0.4 of the Market Rules. The IESO issues a draft SIA report to the applicant and the transmitter for comment, then a final report, and with it either a Notification of Conditional Approval or a Notification of Disapproval with Reasons.
|
||||
|
||||
The published process runs to as many as six stages. Connections to a transmitter's system are generally subject to all six; connections to a distributor's system may be subject only to the first three. On the IESO's own figures, obtaining conditional approval "typically takes one year", registering equipment "takes at least three months", and the whole process can run "anywhere from a few months for small modifications to existing facilities, to more than three years for major modifications or to connect new facilities".
|
||||
|
||||
Two features matter to anyone drafting a dispute clause. The SIA assesses the proposed connection's impact on the reliability of the integrated power system; what comes out of it is a report and a notification, not a ruling between parties. And there is no ordered line to be moved up. The IESO says so in terms in its connection-process FAQ: it works from "committed projects", a concept defined in section 3.3 of Market Manual 1.4, Connection Assessment and Approval, each assessment following section 5.8 of the same manual. The four IESO connection-process pages read for this piece describe only the six-stage process; no large-load or data-centre variant appears. This is the process I write about under [energy, grid and regulatory disputes](/practice/energy/).
|
||||
|
||||
## The statutory gate is in force; the regulation behind it is still a consultation
|
||||
|
||||
There is now a second gate, aimed squarely at this sector. Section 28.1 of the Electricity Act, 1998 came into force on 11 December 2025, through Bill 40 of the 44th Parliament, first session — the Protect Ontario by Securing Affordable Energy for Generations Act, 2025, chapter 22 of the Statutes of Ontario, 2025.
|
||||
|
||||
Section 28.1 provides that unless a transmitter or distributor is satisfied that the "specified connection requirements" have been complied with, it "shall not" connect a "specified load facility" — or reconnect one that was disconnected for breaching those requirements. A specified load facility is a data centre meeting whatever criteria the regulations may set, or a facility drawing from the grid with demand at the point of connection above a regulation-prescribed threshold, meeting any other prescribed criteria. Subsection (6) carries a transition: the section does not apply to a facility whose connection request under the Transmission System Code or the Distribution System Code was submitted before 3 June 2025.
|
||||
|
||||
The regulation is the part to watch, because as this is written at the end of August 2026 the ministry consulting on it still describes it as prospective. The Ministry of Energy and Mines' consultation notice on an Economic and Strategic Assessment Framework for New Data Centres, ERO 026-0853, open for comment from 13 August to 12 September 2026, says that "the province is considering drafting a proposed regulation" that would require new large data centres to obtain the approval of the government to connect or reconnect. The same notice proposes a Data Centre Playbook, one of whose three pillars is protecting data security and digital sovereignty. It records the ministry's estimate that data-centre connection proposals could cumulatively total more than 10,000 MW.
|
||||
|
||||
The notice puts the Playbook forward to attract investments that, among other things, "ensure Canadians' data remains in Canada". That phrase lives in a technology contract long before it reaches a grid application: it is a question about where workloads run, which subprocessors touch them, and what the operator has promised its own customers. An SIA report will not answer it, which is why the [technology](/practice/technology/) side of a data-centre project cannot be quarantined from the energy side.
|
||||
|
||||
## The construction contract runs on a different clock
|
||||
|
||||
Underneath the connection sits an ordinary Ontario construction project, on the statutory payment timetable. Proper invoices go to the owner monthly unless the contract says otherwise. The owner pays within 28 days, or gives a notice of non-payment within 14 days detailing the reasons. A contractor paid in full passes payment down within seven days; a contractor the owner has not paid must pay its subcontractors within 35 days of giving the invoice unless it serves a notice of non-payment, and one route through that notice requires an undertaking to refer the matter to adjudication within 21 days.
|
||||
|
||||
Interim adjudication under Part II.1 of the Construction Act has been available since October 2019, administered by Ontario Dispute Adjudication for Construction Contracts as the Authorized Nominating Authority, with amendments in force from 1 January 2026. What may be adjudicated without the other side's agreement is a prescribed list, now in section 19(1) of O. Reg. 264/25: the valuation of services or materials; payment under the contract, including a change order, approved or not, or a proposed change order; a notice-of-non-payment dispute; amounts retained by way of set-off; payment of a holdback; and, only where reasonably necessary to resolve another adjudicable matter, the scope of work, a change-in-price request, and an extension-of-time request.
|
||||
|
||||
Then the pace. The adjudicator must determine the matter no later than 30 days after receiving the claimant's documents, which are due within five days of the appointment. That deadline can be extended by up to 14 days at the adjudicator's request with written consent, or for a period the parties agree in writing, subject to the adjudicator's consent. A determination made late is "of no force or effect". A party ordered to pay must pay within 15 days. The determination binds until a court or an Arbitration Act, 1991 arbitration determines the matter, or the parties agree otherwise in writing; judicial review needs leave of the Divisional Court. An adjudication addresses a single dispute unless the parties and the adjudicator agree otherwise.
|
||||
|
||||
Put the two clocks beside each other. Conditional approval to connect typically takes a year. An adjudication is designed to be finished, with written reasons and a payment obligation, about five weeks after the adjudicator is appointed — seven if the deadline is extended. The prescribed list is a payment list. It does not reach the question the project turns on — when the facility will connect. That question reaches adjudication only if both parties agree to send it there, which nobody negotiates once the date has slipped. A slipped connection date arrives as a bundle: valuation, delay, scope, and a change order nobody approved, split between what the list reaches and what it does not. That is the [construction](/practice/construction/) half of the problem.
|
||||
|
||||
## Why litigating first and mediating late fits this badly
|
||||
|
||||
The Construction Act fixes no mediation step, so on a file where the contract is silent, the timing of any mediation is set by the litigation timetable rather than by the connection timetable. That assumes the amount in dispute is fixed and the commercial relationship has finished. On a live connection neither is true. The assessment is still running, the transmitter is still a counterparty rather than a witness, and every month of argument moves the energisation date all three contracts are priced against.
|
||||
|
||||
The statutory design points the other way. An interim determination is expressly provisional: it binds until a court, an arbitrator or the parties' own written agreement replaces it, and both a court and an arbitrator may consider the merits afresh. The lien timetable is short at the front and long at the back: 60 days to preserve, a further 90 to perfect, and a perfected lien expires immediately after the second anniversary of the action that perfected it unless that action has been set down or ordered to trial. A mediation two years into that arc is a mediation of a project whose connection window has closed.
|
||||
|
||||
The Construction Act contains no mediation provision at all: no mediation part, no step, no mediator's role. Whatever mediated step happens on a construction file comes from somewhere other than that statute.
|
||||
|
||||
## What I would fix before the first proper invoice
|
||||
|
||||
Most of the work here is sequencing rather than drafting.
|
||||
|
||||
The process and the neutral are worth naming before the draft SIA report lands, not after it. The point at which the connection date first moves is the point at which positions harden, and a poor moment to negotiate who resolves what. [Mediation](/mediation/) is available at that stage without characterising anything.
|
||||
|
||||
The same words are worth carrying across the three contracts. Milestones defined one way in the construction contract, another way in the technology contract, and a third way against a Notification of Conditional Approval produce disputes about which document governs before anyone reaches the merits.
|
||||
|
||||
Which questions go to adjudication and which go to [commercial arbitration](/arbitration/) is worth deciding in advance. A determination is provisional by design and the merits stay open, so an adjudication treated as final is a dispute deferred rather than resolved.
|
||||
|
||||
Where the parties want one neutral to mediate and then arbitrate, that switch is worth settling at the outset rather than in the room. What I undertake about the switch, and about caucus material afterwards, is set out on [med-arb](/med-arb/); [how I run a file](/process/) sets out the rest.
|
||||
@@ -0,0 +1,201 @@
|
||||
---
|
||||
title: 'What a System Impact Assessment actually evaluates'
|
||||
description: 'What an IESO System Impact Assessment evaluates, who performs it, where the transmitter customer impact assessment sits, and what to look for in one.'
|
||||
# publishDate is the drafting date. Set it on approval (D9).
|
||||
publishDate: 2026-08-31
|
||||
topics: ['technical-explainer']
|
||||
practiceAreas: ['energy', 'technology']
|
||||
readingTime: 8
|
||||
draft: true
|
||||
reviewedByPouya: false
|
||||
---
|
||||
|
||||
## An SIA is not an assessment of the project
|
||||
|
||||
A connection date is a common term in Ontario energy contracts: EPC schedules,
|
||||
equipment supply terms, the covenants around a commercial operation date. When
|
||||
it moves, the System Impact Assessment is the document the argument turns to,
|
||||
and it invites one specific misreading. An SIA does not assess the project; it
|
||||
assesses what happens to the grid if the project connects to it.
|
||||
|
||||
The term is the Independent Electricity System Operator's own, and so is its
|
||||
companion. In the IESO's description of the connection process, "New connections
|
||||
or modifications to facilities connected to a transmitter's system are subject
|
||||
to the IESO's system impact assessment (SIA) and the transmitter's customer
|
||||
impact assessment (CIA)." Two documents, two authors. The IESO conducts the SIA.
|
||||
The transmitter conducts the CIA. Treating the pair as one exhibit loses the
|
||||
distinction most of these disputes turn on. The umbrella name is the connection
|
||||
assessment and approval process, CAA in the IESO's usage, and each application
|
||||
is given a unique CAA ID.
|
||||
|
||||
## What the assessment is actually of
|
||||
|
||||
The IESO describes its study step as assessing "the impact of [the] proposed new
|
||||
or modified connection on the reliability of the integrated power system". Stage
|
||||
one of the same process puts it more broadly: planned connections and
|
||||
modifications "must be assessed to identify and mitigate any potential adverse
|
||||
effect on the reliability of the electricity grid and its existing customers".
|
||||
|
||||
The subject of the assessment is the system, not the applicant. The IESO
|
||||
describes its own function as coordinator and integrator of Ontario's
|
||||
electricity system, balancing supply against provincial demand in real time and
|
||||
directing the flow across the transmission lines, and it names five pillars of
|
||||
reliability it is responsible for meeting: capacity, energy, transmission,
|
||||
operability and ancillary services. An SIA asks whether a new connection
|
||||
disturbs those.
|
||||
|
||||
That is also how to read a condition: the assessment's subject is the system,
|
||||
so a condition speaks to how the system behaves with the facility on it. The
|
||||
published process does not describe what conditions a report may carry — that
|
||||
question is answered in the report. A pleading that reads a condition as an
|
||||
admission of defective work is reading the document as though the other side
|
||||
had commissioned it.
|
||||
|
||||
The IESO's connection-process FAQ names the tools: "The IESO uses DSA and PSSE
|
||||
tools to conduct SIA studies." Naming the tools is not describing the study, and
|
||||
the published process description does not say what a given study assumed,
|
||||
modelled or tested. Where the argument is about the study itself, the report and
|
||||
the record behind it are what answer it — not this outline of the process that
|
||||
produced it.
|
||||
|
||||
## Where it sits, and how long it takes
|
||||
|
||||
The IESO runs connection in up to six stages: prepare application; obtain
|
||||
conditional approval to connect; design and build; authorize market and program
|
||||
participation; register equipment; commission equipment and validate
|
||||
performance.
|
||||
|
||||
The SIA and the CIA both live in stage two, which "typically takes one year" on
|
||||
the IESO's figure. Stage four typically takes about a month, stage five at least
|
||||
three months, and the whole process "can take anywhere from a few months for
|
||||
small modifications to existing facilities, to more than three years for major
|
||||
modifications or to connect new facilities". All applicable stages have to be
|
||||
completed before final approval to connect and the start of commercial
|
||||
operation.
|
||||
|
||||
Which stages apply depends on what the facility connects to: "New or modified
|
||||
connections to a transmitter's system are generally subject to all six stages,
|
||||
while new or modified connections to a distributor's system may only be subject
|
||||
to the first three."
|
||||
|
||||
That last point is about parties as much as engineering. Distribution
|
||||
connections run through the distributor's own assessment process, and the IESO
|
||||
records that a distributor may itself need to participate in the IESO's and the
|
||||
transmitter's processes on the applicant's behalf. The entity handling the
|
||||
assessment correspondence is not always the entity whose contract is in dispute.
|
||||
|
||||
## Two documents, two authors, two agreements
|
||||
|
||||
The sequence is where the SIA and the CIA come apart.
|
||||
|
||||
On the IESO's account of stage two, a pre-application meeting comes first. The
|
||||
IESO then determines whether the application qualifies for a system impact
|
||||
assessment or an expedited system impact assessment (ESIA). Once the application
|
||||
and its deposit are in, it prepares an SIA agreement, "in accordance with
|
||||
section 6.1.15.3 of chapter 0.4 of the Market Rules", for execution by the
|
||||
applicant's authorized representative. Once all required information has been
|
||||
provided, it carries out the studies and issues a draft SIA report to the
|
||||
applicant and the transmitter for review and comments. After addressing the
|
||||
comments on the draft or on a revised draft, it sends the final report to both,
|
||||
with either a "Notification of conditional approval (NoCA)" or a "Notification
|
||||
of disapproval with reasons (NoDR)".
|
||||
|
||||
The CIA runs on a different clock. The transmitter "generally initiates the
|
||||
customer impact assessment (CIA) after the draft SIA report from the IESO", and
|
||||
the CIA has its own agreement, between the applicant and the transmitter.
|
||||
|
||||
Three consequences follow. The assessments are generally sequenced rather than
|
||||
parallel, so a slipped draft SIA ordinarily pushes the CIA start behind it.
|
||||
There are two contracts before there are two reports, and the obligations
|
||||
parties argue about, which information was owed and by when, live in those two
|
||||
agreements. And the draft-and-comment step is a record: what a party said about
|
||||
a study assumption at draft stage, and what it declined to say, sits in that
|
||||
record alongside the final report.
|
||||
|
||||
## What to ask for, and what the record will not support
|
||||
|
||||
Where a dispute turns on an SIA, the productive order is the order in which the
|
||||
record was made, not the order of the pleadings. The application first, and the
|
||||
IESO's FAQ names the instrument: Form 128 initiates the SIA process. Then the
|
||||
two agreements. Then the information the applicant supplied, with dates, because
|
||||
the study step begins once all required information has been provided:
|
||||
completeness is the hinge on which a year-long stage moves. Then the draft SIA
|
||||
report and each set of comments on it. Then any revised draft. Then the final
|
||||
report with the NoCA or the NoDR. Then the CIA.
|
||||
|
||||
The final report may already be public: the IESO states that it "will be
|
||||
published on the IESO website in the Application Status table at the end of the
|
||||
month in which it was finalized". Upstream of all this sits an optional
|
||||
technical feasibility study, a "confidential service" provided "on a
|
||||
cost-recovery basis to identify and mitigate potential issues with various
|
||||
connection options"; whether one was run often explains why a particular option
|
||||
was chosen.
|
||||
|
||||
Two arguments the published process will not carry. First, the queue. Ontario
|
||||
has no interconnection queue. The IESO is explicit: it "is not using an
|
||||
'interconnection queue'", adopting instead "the concept of 'committed projects'
|
||||
that is defined in Section 3.3 of Market Manual 1.4: Connection Assessment and
|
||||
Approval", and there is "no option to 'skip the interconnection queue'". Each
|
||||
assessment follows the timelines in section 5.8 of that manual. A head of loss
|
||||
framed as a lost place in a queue rests on a mechanism the system operator says
|
||||
it does not operate.
|
||||
|
||||
Second, differential treatment. Renewable generation is not assessed
|
||||
differently: "The treatment of new renewable generation facilities is no
|
||||
different than any other new facility, the normal System Impact Assessment (SIA)
|
||||
process applies to the connection of all generation facilities, renewable or
|
||||
non-renewable, equally." A delay theory resting on technology-specific handling
|
||||
has nothing in the published process to stand on.
|
||||
|
||||
## Why more contracts are about to depend on this
|
||||
|
||||
As this is written in August 2026, the gate in front of large loads is being
|
||||
rebuilt around the assessment, not in place of it.
|
||||
|
||||
Section 28.1 of the Electricity Act, 1998 came into force on 11 December 2025.
|
||||
Unless a transmitter or distributor is satisfied that the "specified connection
|
||||
requirements" have been complied with, it "shall not" connect or reconnect a
|
||||
"specified load facility". That category is defined to include a data centre
|
||||
meeting criteria that may be set out in the regulations, and a facility whose
|
||||
demand at the point of connection exceeds a prescribed amount. The section
|
||||
arrived through Bill 40 of the 44th Parliament, 1st Session — the Protect
|
||||
Ontario by Securing Affordable Energy for Generations Act, 2025 — which
|
||||
received Royal Assent on 11 December 2025 as chapter 22 of the Statutes of
|
||||
Ontario, 2025. Its transition rule turns on a date and a form: the section does
|
||||
not apply where a connection request made in accordance with the Transmission
|
||||
System Code or the Distribution System Code was submitted to the transmitter or
|
||||
distributor before 3 June 2025, the day Bill 40 had First Reading.
|
||||
|
||||
The regulation that would fill in those criteria is the part to watch. The
|
||||
Ministry of Energy and Mines' August 2026 consultation on an economic and
|
||||
strategic assessment framework for new data centres describes the province as
|
||||
"considering drafting" a regulation that would require new large data centres to
|
||||
obtain government approval to connect or reconnect. Its comment period runs to
|
||||
12 September 2026, and the same notice carries the Ministry's estimate that
|
||||
data-centre connection proposals could total more than 10,000 MW cumulatively.
|
||||
|
||||
None of that displaces the SIA; it sits on top of it. A large load will still be
|
||||
assessed for its effect on the reliability of the integrated power system, in
|
||||
stage two, and its transmitter will still run a CIA. What changes is the number
|
||||
of contracts written against a connection date whose gating conditions were
|
||||
still under consideration as at August 2026.
|
||||
|
||||
## Reading the study and the contract on the same page
|
||||
|
||||
Grid connection disputes are argued through technical studies. I work as a
|
||||
machine-learning and DevOps infrastructure engineer. The study assumptions, the
|
||||
modelling inputs and the constraint that produced a condition are documents I
|
||||
read directly and work through with the parties.
|
||||
|
||||
In a [mediation](/mediation/) that means a technical disagreement can be tested
|
||||
in the room rather than deferred to an expert exchange. In a
|
||||
[commercial arbitration](/arbitration/) it means the first procedural order can
|
||||
be built around the documents that decide the matter.
|
||||
[The shape of an engagement](/process/) sets out where each one starts.
|
||||
|
||||
Connection is one of the areas I take appointments in, set out at
|
||||
[energy and grid disputes](/practice/energy/); its large-load half overlaps
|
||||
with [technology and data disputes](/practice/technology/). Every date above is
|
||||
as at August 2026, and the instruments move. Nothing here is applied to a
|
||||
particular matter, and each party to a dispute should have their own legal
|
||||
advice.
|
||||
@@ -0,0 +1,90 @@
|
||||
---
|
||||
title: 'When Med-Arb is the right answer, and when it is not'
|
||||
description: 'Med-arb is mediation that converts to binding arbitration if it does not resolve. What it is, the fairness objection, and when it does not fit.'
|
||||
# publishDate is the drafting date. Set it on approval (D9).
|
||||
publishDate: 2026-08-31
|
||||
topics: ['process-explainer']
|
||||
practiceAreas: ['construction', 'shareholder']
|
||||
readingTime: 8
|
||||
draft: true
|
||||
reviewedByPouya: false
|
||||
---
|
||||
|
||||
import { CONDUCT_UNDERTAKINGS } from '../../data/site';
|
||||
|
||||
## What med-arb is, and what one appointment buys
|
||||
|
||||
Med-arb is mediation that converts to binding arbitration if the mediation does not resolve the dispute. One neutral is appointed for both phases. The matter is mediated. Whatever settles is recorded and is finished. Whatever does not settle moves to arbitration in front of the same neutral, on the terms the parties agreed before any of it began, and ends in an award. The two phases are the same two processes I offer on their own: [mediation](/mediation/) and [commercial arbitration](/arbitration/).
|
||||
|
||||
The commercial case for it is the gap it closes. A mediation that does not settle ordinarily means starting over. A new neutral, a second round of briefs, a fresh procedural timetable, and the same argument re-run in front of someone who did not watch the first attempt. Whatever narrowing the mediation achieved is re-argued, because nobody in the second room is bound by a concession made in the first. Med-arb keeps that work inside one appointment and one agreement.
|
||||
|
||||
## The objection is the right one
|
||||
|
||||
A mediator learns things a decision-maker is not supposed to know. What a party would actually take. What it is afraid of. What its own counsel thinks of the weak limb of its case. In med-arb the person holding that knowledge may go on to decide the matter.
|
||||
|
||||
Counsel who refuse med-arb on that ground are not being obstructive. The problem is structural rather than hypothetical, and no amount of drafting makes it disappear. What drafting decides is who carries it, and on what terms.
|
||||
|
||||
Two things carry it. The first is consent that is real: informed, in writing, and settled before the mediation phase starts, with the trigger for the switch and the treatment of caucus material dealt with in terms rather than left to good faith. Vagueness about either is what turns a procedural objection into a live one.
|
||||
|
||||
The second is what the neutral will actually do. That is a different question, and it is the one a party weighs when choosing between candidates. It is answered below rather than left to be inferred from the drafting.
|
||||
|
||||
## What I undertake
|
||||
|
||||
{/* ⚠️ RENDERED FROM `CONDUCT_UNDERTAKINGS`, NEVER TYPED — §4's third class says
|
||||
so in terms: "The six strings live in `CONDUCT_UNDERTAKINGS` in
|
||||
`src/data/site.ts` and the pages render them, so the diff that would soften
|
||||
one is visible on one constant rather than distributed through three
|
||||
templates." They were hand-typed here in the first draft, which put a fourth
|
||||
hand-copy of a published commitment outside that mechanism — and the
|
||||
characteristic failure mode of this class is SILENT: nothing in a build fails
|
||||
when a promise gets a little smaller, and the diff reads like tightening.
|
||||
⚠️ AND IF THIS ARTICLE IS APPROVED FOR PUBLICATION, §4's rows (a), (b) and
|
||||
(c) EACH GAIN A SURFACE and their "where it ships" column has to say so. */}
|
||||
|
||||
> {CONDUCT_UNDERTAKINGS.medArbSwitch}
|
||||
|
||||
> {CONDUCT_UNDERTAKINGS.medArbCaucus}
|
||||
|
||||
> {CONDUCT_UNDERTAKINGS.medArbStepOut}
|
||||
|
||||
The third is the expensive one, and its cost falls on the neutral rather than on the parties. It is also less costly in practice than it sounds. The arbitral phase runs on the evidentiary record, not on the caucus. Where the switch sits inside a whole engagement is set out under [the shape of an engagement](/process/).
|
||||
|
||||
## The rule set ADRIC publishes, and what a summary of it is worth
|
||||
|
||||
The ADR Institute of Canada's rules page carries ADRIC Med-Arb Rules. A discussion draft was presented to the membership at ADRIC's annual conference in November 2019. ADRIC's own framing of the process is worth quoting rather than paraphrasing:
|
||||
|
||||
> "Med-Arb is not merely the merging of separate mediation and arbitration processes, but a unique process designed to meet the needs of particular disputants. It involves nuances and complexities that can be fine-tuned to the needs of the parties as a customized dispute resolution process…"
|
||||
|
||||
ADRIC states that the rules are "designed to work in tandem with ADRIC's existing Mediation Rules and Arbitration Rules, integrating seamlessly", and on scope: "Although the Med-Arb Rules were drafted to assist in resolving domestic commercial disputes, parties may want to apply them to international or non-commercial disputes."
|
||||
|
||||
The two rule sets they sit alongside are published in their own right, as The ADRIC National Mediation Rules and as ADRIC Arbitration Rules – Effective 2025. The mediation document also carries a Model Dispute Resolution Clause, whose wording refers a dispute to mediation "pursuant to the National Mediation Rules of the ADR Institute of Canada, Inc."
|
||||
|
||||
Two cautions, and both apply to any account of a rule set, this one included. The first is currency. ADRIC records that "As of 2025, the ADRIC Mediation Committee is currently reviewing the Mediation Rules", and that "the existing rules remain in effect and should continue to be used until any updates are formally adopted". ADRIC's rules page carried that note when this piece was written, in August 2026; the current state of the review is on ADRIC's own page.
|
||||
|
||||
The second is that a description is not the rule set. What the rules require of the parties, of the neutral, and of caucus material sits in the documents themselves, not in anything quoted here. Nothing above states what any of them provides. Where this piece says what happens to caucus material, that is my own undertaking and not a rule.
|
||||
|
||||
## Where med-arb fits
|
||||
|
||||
**A deadlock that has to end by a date.** A closing, a fiscal year end, a lender's deadline, a milestone with liquidated damages behind it. Mediation on its own cannot promise an end. Arbitration on its own reaches one, and spends the interval as a contest. Med-arb reaches the date either way, and the parties know at the outset which way it will be reached if the room does not settle.
|
||||
|
||||
**A relationship that has to survive the dispute.** Shareholders in a closely held company. A general contractor and a trade it will meet again on the next tender. A distributor in the middle of a term. A unanimous shareholder agreement can specify how a dispute under it is resolved: section 108(6)(b) of Ontario's Business Corporations Act contemplates that where shareholders who are parties to such an agreement cannot agree on or resolve a matter pertaining to it, the matter may be referred to arbitration "under such procedures and conditions as are specified in the unanimous shareholder agreement". What that means for a particular company is a question for each party's own counsel. The dispute types are on [shareholder and family business](/practice/shareholder/).
|
||||
|
||||
**A narrow set of remaining issues.** Med-arb earns its keep when the mediation has done most of the work and two or three points are left, each capable of being decided on the documents. The parties take their settlement on everything they agreed and a decision on the residue, from one appointment, without a second procedural runway.
|
||||
|
||||
## Where it does not fit
|
||||
|
||||
**Where a statutory route already gives what med-arb is being asked to give.** Construction payment is the Ontario example. Part II.1 of the Construction Act, headed Construction Dispute Interim Adjudication, has been in force since 1 October 2019, and further amendments to the Act came into force on 1 January 2026. O. Reg. 264/25 prescribes the matters that may be adjudicated, among them the valuation of services or materials provided under the contract and payment in respect of a change order, whether approved or not. Ontario Dispute Adjudication for Construction Contracts, which states on its own site that it is the Authorized Nominating Authority under the Act, describes adjudication as "available as a right" and says a party "can commence an adjudication without the other Party's consent". An adjudicator must determine the referred matter no later than 30 days after receiving the referring party's documents, unless that date is extended in the way the Act allows, and the Act treats that determination as binding on the parties until the matter is determined by a court, by arbitration under the Arbitration Act, 1991, or by written agreement. A party that wants money moving on a change order does not need the other side's agreement to a process, and med-arb needs exactly that. Where the dispute is not a prescribed matter, or where the parties want the whole of it finally decided, the calculation changes. The dispute types are on [construction and infrastructure](/practice/construction/).
|
||||
|
||||
**Where the parties are not equally advised.** The caucus asymmetry compounds. One side with counsel and one without, or one a repeat player in this kind of dispute and the other in it once, is the configuration where a single neutral holding both roles is hardest to justify.
|
||||
|
||||
**Where one side needs a finding more than a settlement.** A party facing the same argument from a row of counterparties may want a reasoned determination on the record more than it wants this dispute closed quietly. Med-arb is built to settle first.
|
||||
|
||||
**Where consent is grudging.** A party that agrees to med-arb reluctantly has not agreed to it in the sense that matters, and the reluctance tends to come back in the arbitral phase as a complaint about the process. That is a reason not to take the appointment rather than a drafting problem.
|
||||
|
||||
## The name in the contract is worth reading twice
|
||||
|
||||
It is not arb-med. The two names are one syllable apart and the processes are not interchangeable. Where a contract names one of them, the thing to check is which one, and to check it against the rule set the contract adopts rather than against a page like this one.
|
||||
|
||||
Ontario's statute book names a version of the process, in a place written for family arbitration. O. Reg. 134/07 under the Arbitration Act, 1991 defines a "mediation-arbitration agreement" as a family arbitration agreement providing that "a mediation between the parties is to be conducted before any arbitration is conducted" and that "if the mediation fails, the mediator shall arbitrate the dispute and make a binding resolution of it". The same regulation requires that every arbitrator who conducts a family arbitration "shall have received the training approved by the Attorney General". I do not accept family law matters. The regulation is worth knowing about anyway: a search for the term surfaces it, and a definition written for family arbitration is easy to mistake for a general one.
|
||||
|
||||
I accept med-arb appointments in commercial matters. [Med-arb](/med-arb/) sets out the process and the objection at greater length. The part that cannot be fixed later is the switch, and it is settled in writing before the mediation starts or it is not settled at all.
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Insights vocabulary. Build step 7b. The six territories are the strategy
|
||||
* brief's (§VII), restated in `docs/03-content-spec.md` §Insights.
|
||||
*
|
||||
* `TOPIC_LABELS` is annotated `Record<InsightTopic, string>`, so adding a topic
|
||||
* without labelling it does not compile. `src/content.config.ts` imports the
|
||||
* tuple rather than repeating it.
|
||||
*
|
||||
* ⚠️ These are EDITORIAL categories, not claims. `credentialing` in particular
|
||||
* labels writing *about* credentialing in the field — never a credential of his.
|
||||
*/
|
||||
export const INSIGHT_TOPICS = [
|
||||
'process-explainer',
|
||||
'regulatory-commentary',
|
||||
'industry-commentary',
|
||||
'reflection',
|
||||
'technical-explainer',
|
||||
'credentialing',
|
||||
] as const;
|
||||
|
||||
export type InsightTopic = (typeof INSIGHT_TOPICS)[number];
|
||||
|
||||
/** Pill text. Sentence case, because these sit beside a serif headline rather
|
||||
* than in the mono eyebrow style — `Pill` sets its own type. */
|
||||
export const TOPIC_LABELS: Record<InsightTopic, string> = {
|
||||
'process-explainer': 'Process',
|
||||
'regulatory-commentary': 'Regulatory',
|
||||
'industry-commentary': 'Industry',
|
||||
reflection: 'Reflection',
|
||||
'technical-explainer': 'Technical',
|
||||
credentialing: 'Credentialing',
|
||||
};
|
||||
|
||||
/**
|
||||
* Date display. `en-CA` with an explicit UTC time zone, and the time zone is the
|
||||
* load-bearing part: `src/content.config.ts` parses frontmatter dates as
|
||||
* midnight UTC, so formatting them in a local zone west of Greenwich renders
|
||||
* the day before — a published date one day early, on every article, silently.
|
||||
* Same failure the schema's round-trip check exists to stop, one layer down.
|
||||
*/
|
||||
export function formatArticleDate(date: Date): string {
|
||||
return new Intl.DateTimeFormat('en-CA', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
timeZone: 'UTC',
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
/** `<time datetime>` wants the date-only ISO form, in the same zone. */
|
||||
export function isoDate(date: Date): string {
|
||||
return date.toISOString().slice(0, 10);
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
/**
|
||||
* The intake form's fields. Spec: docs/05-backend-spec.md §Form fields.
|
||||
*
|
||||
* ⚠️ **THE LAMBDA HAS ITS OWN COPY OF THIS TABLE, AND THAT DUPLICATION IS
|
||||
* DELIBERATE — IT IS NOT THE SES-DKIM SHAPE.** `docs/05` is explicit: *"Client
|
||||
* side validation is a convenience. The Lambda re-validates everything."* A
|
||||
* server that validates against a list the client shipped it is not validating;
|
||||
* it is asking the attacker what the rules are. So `backend/intake/handler.mjs`
|
||||
* carries an independent table and trusts nothing from here.
|
||||
*
|
||||
* What stops the two drifting is a check rather than a shared import:
|
||||
* **`npm run check:intake`** asserts that the two tables agree on every field
|
||||
* name, on which are required, and on every length cap — and fails the build
|
||||
* script if they do not. Independent validation, mechanically cross-checked. If
|
||||
* you add a field here, add it there, and the check will tell you if you didn't.
|
||||
*
|
||||
* WHAT THIS DATA IS, because it changes how the form is built (`docs/05`): in a
|
||||
* live legal dispute this collects the inquirer's identity, **the names of
|
||||
* opposing parties and their counsel**, and the nature of the dispute. That is
|
||||
* personal information about identifiable third parties who have not consented
|
||||
* and do not know the submission happened. Hence: no dollar amounts, no
|
||||
* uploads, an explicit unchecked consent box, and a matter summary whose hint
|
||||
* tells the writer not to put privileged detail in it.
|
||||
*/
|
||||
|
||||
export type IntakeField = {
|
||||
name: string;
|
||||
label: string;
|
||||
/** `select` and `radio` carry `options`; everything else does not. */
|
||||
type: 'text' | 'email' | 'tel' | 'select' | 'radio' | 'textarea' | 'checkbox';
|
||||
required: boolean;
|
||||
/** Maximum characters. The Lambda REJECTS over this rather than truncating —
|
||||
* a silently truncated matter summary is a misread file. */
|
||||
max?: number;
|
||||
options?: readonly string[];
|
||||
/** Rendered under the field. */
|
||||
hint?: string;
|
||||
/** `autocomplete` token, where one genuinely applies. Omitted rather than
|
||||
* guessed: a wrong token makes a browser fill the wrong value. */
|
||||
autocomplete?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* ⚠️ **DO NOT ADD A DOLLAR-AMOUNT FIELD.** `docs/05`: *"Do not collect dollar
|
||||
* amounts, document uploads, or anything the inquirer might reasonably treat as
|
||||
* privileged. The intake call is for that."* The old site invented matter values;
|
||||
* this form is the one place a real one could arrive and then need storing.
|
||||
*/
|
||||
export const INTAKE_FIELDS: readonly IntakeField[] = [
|
||||
{
|
||||
name: 'name',
|
||||
label: 'Your name',
|
||||
type: 'text',
|
||||
required: true,
|
||||
max: 120,
|
||||
autocomplete: 'name',
|
||||
},
|
||||
{
|
||||
name: 'email',
|
||||
label: 'Email',
|
||||
type: 'email',
|
||||
required: true,
|
||||
max: 254, // RFC 5321 maximum path length; not a round number by choice.
|
||||
autocomplete: 'email',
|
||||
},
|
||||
{
|
||||
name: 'phone',
|
||||
label: 'Phone',
|
||||
type: 'tel',
|
||||
required: false,
|
||||
max: 40,
|
||||
autocomplete: 'tel',
|
||||
hint: 'Optional.',
|
||||
},
|
||||
{
|
||||
name: 'role',
|
||||
label: 'Your role',
|
||||
type: 'select',
|
||||
required: true,
|
||||
options: ['Counsel', 'In-house', 'Party', 'Institution', 'Other'],
|
||||
},
|
||||
{
|
||||
name: 'organisation',
|
||||
label: 'Firm or organisation',
|
||||
type: 'text',
|
||||
required: false,
|
||||
max: 160,
|
||||
autocomplete: 'organization',
|
||||
},
|
||||
{
|
||||
name: 'process',
|
||||
label: 'Process sought',
|
||||
type: 'select',
|
||||
required: true,
|
||||
/* The five from docs/05. "ENE" is expanded here because this is a form label
|
||||
read by a party as well as by counsel, and §11's glossary authority is
|
||||
about site copy rather than about abbreviating in a select. */
|
||||
options: [
|
||||
'Mediation',
|
||||
'Arbitration',
|
||||
'Med-Arb',
|
||||
'Early neutral evaluation',
|
||||
'Not sure',
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'practiceArea',
|
||||
label: 'Subject matter',
|
||||
type: 'select',
|
||||
required: true,
|
||||
/* THE SIX AREAS PLUS OTHER. Deliberately the short display names rather
|
||||
than `PRACTICE_AREAS[].name`: those carry the full "Construction &
|
||||
Infrastructure" form for a card heading, and a select is not a card. The
|
||||
cross-check in `scripts/check-intake.mjs` compares these against the
|
||||
handler's list, and `PRACTICE_SLUGS` remains the site's own source for
|
||||
which areas exist. */
|
||||
options: [
|
||||
'Construction',
|
||||
'Technology',
|
||||
'Energy',
|
||||
'Insurance',
|
||||
'Shareholder',
|
||||
'Cross-border',
|
||||
'Other',
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'otherParties',
|
||||
label: 'Other parties',
|
||||
type: 'text',
|
||||
required: false,
|
||||
max: 300,
|
||||
hint: 'Needed to run a conflicts check. Names only.',
|
||||
},
|
||||
{
|
||||
name: 'opposingCounsel',
|
||||
label: 'Opposing counsel',
|
||||
type: 'text',
|
||||
required: false,
|
||||
max: 300,
|
||||
hint: 'Also for the conflicts check.',
|
||||
},
|
||||
{
|
||||
name: 'summary',
|
||||
label: 'What the dispute is about',
|
||||
type: 'textarea',
|
||||
required: true,
|
||||
max: 2000,
|
||||
hint: 'A few sentences is enough. Please do not include privileged or confidential detail — that is what the intake call is for.',
|
||||
},
|
||||
{
|
||||
name: 'timing',
|
||||
label: 'Timing',
|
||||
type: 'select',
|
||||
required: false,
|
||||
options: ['Urgent', 'Within 30 days', 'Within 90 days', 'Exploring'],
|
||||
},
|
||||
{
|
||||
name: 'preferredContact',
|
||||
label: 'Preferred reply',
|
||||
type: 'radio',
|
||||
required: false,
|
||||
options: ['Email', 'Phone'],
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* THE CONSENT TEXT, VERBATIM FROM `docs/05` §Consent text. It is a legal notice
|
||||
* the inquirer agrees to, so it is rendered from here and never retyped or
|
||||
* reworded to fit a layout. Note that it says the same three things
|
||||
* `NO_RETAINER_NOTICE` says — that constant is the site-wide statement and this
|
||||
* is the one the inquirer ticks; both ship on `/contact/`, which is deliberate:
|
||||
* `docs/01` requires the page to carry the notice, and `docs/05` requires the
|
||||
* checkbox to carry it too.
|
||||
*/
|
||||
export const CONSENT_TEXT =
|
||||
'I consent to Pouya Lajevardi storing and using the information in this form ' +
|
||||
'to respond to my inquiry and to run a conflicts check. I understand that ' +
|
||||
'submitting this form does not create a retainer, does not appoint a neutral, ' +
|
||||
'and does not itself establish a mediator–party relationship.';
|
||||
|
||||
/**
|
||||
* The honeypot. `docs/05`: *"hidden from sighted and screen-reader users, must
|
||||
* be empty"*.
|
||||
*
|
||||
* ⚠️ **`display: none` PLUS `tabindex="-1"` PLUS `aria-hidden`, AND THE NAME
|
||||
* MATTERS.** A honeypot named `honeypot` is skipped by any bot worth stopping;
|
||||
* one named like a real field is filled. `company_website` is a plausible field
|
||||
* on a professional intake form and is not one this form has. It must never be
|
||||
* reachable by keyboard or announced by a screen reader — a honeypot that traps
|
||||
* a screen-reader user is an accessibility defect that also loses a real inquiry.
|
||||
*/
|
||||
export const HONEYPOT_FIELD = 'company_website';
|
||||
|
||||
/**
|
||||
* WHERE THE FORM POSTS — AND IT IS A SAME-ORIGIN PATH, NOT THE API GATEWAY
|
||||
* HOSTNAME. This is a design decision with four consequences, taken at step 8
|
||||
* and recorded because the obvious implementation is the other one.
|
||||
*
|
||||
* The obvious version posts to the execute-api hostname `AGENTS.md` §7 records.
|
||||
* Posting to `/api/intake` instead, with a CloudFront behaviour routing `/api/*`
|
||||
* to that origin:
|
||||
*
|
||||
* 1. **`Content-Security-Policy: form-action 'self'`** — `docs/05` specifies
|
||||
* `form-action 'self' <api-endpoint>`; with a same-origin post the second
|
||||
* term is unnecessary, so the policy is strictly tighter.
|
||||
* 2. **No cross-origin POST at all**, so no CORS question for the form. (CORS
|
||||
* never governed it anyway — a form POST is a top-level navigation, not an
|
||||
* XHR, so it is exempt from preflight. `docs/05`'s CORS line protects the
|
||||
* endpoint against scripted calls from other origins, which is a different
|
||||
* control, and the handler's `Origin` check is what covers the form.)
|
||||
* 3. **The endpoint id stays out of the HTML**, so it is not restated in the
|
||||
* repo either — §7 remains the only place it lives.
|
||||
* 4. **Submitting locally does nothing.** `astro dev` has no `/api/` route, so
|
||||
* a POST 404s. Under the alternative, clicking Submit on a laptop would
|
||||
* write a real DynamoDB record and send two real emails.
|
||||
*
|
||||
* ⚠️ **THE COST, STATED RATHER THAN LEFT TO BE DISCOVERED: THE FORM DOES NOT
|
||||
* WORK UNTIL THAT CLOUDFRONT BEHAVIOUR EXISTS AND THE HANDLER IS DEPLOYED.**
|
||||
* Neither has been done — nothing on this project deploys before cutover (D11),
|
||||
* and both are checklist items in `docs/06`. Until then the page is complete and
|
||||
* the pipe behind it is not, which is why `/contact/` also publishes the email
|
||||
* address rather than treating the form as the only way in.
|
||||
*/
|
||||
export const INTAKE_ACTION = '/api/intake';
|
||||
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* The Open Graph card registry — one entry per page that does NOT use the
|
||||
* portrait. Spec: docs/04-seo-spec.md §Metadata; discharges `AGENTS.md` R15.
|
||||
*
|
||||
* ⚠️ **EVERY HEADLINE HERE IS ITS PAGE'S OWN `<h1>`, VERBATIM, AND THAT IS A
|
||||
* COMPLIANCE MECHANISM RATHER THAN A CONVENIENCE.** Text baked into a JPEG is
|
||||
* text `npm run check:claims` cannot see, and under D20 that script is the only
|
||||
* per-step claims control there is. New prose on a card would therefore be the
|
||||
* one kind of copy on this site with no mechanical check over it at all.
|
||||
*
|
||||
* So a card asserts nothing its page does not already assert in auditable HTML —
|
||||
* and `npm run og:proof` **verifies it**, by pulling the `<h1>` out of each
|
||||
* built page and comparing. A headline edited here without editing the page
|
||||
* fails that check; so does the reverse. `scripts/og-proof.mjs` is where the
|
||||
* comparison lives.
|
||||
*
|
||||
* THE EYEBROWS ARE EACH PAGE'S FIRST `.eyebrow`, VERBATIM, on the same
|
||||
* reasoning. `/` and `/about/` are absent by design — Q40 decided the portrait
|
||||
* for those two and called it "not an interim".
|
||||
*
|
||||
* ONE ENTRY PER PAGE, AND A PAGE WITHOUT ONE IS A BUILD ERROR (`SEO.astro`).
|
||||
* The alternative — falling back to the portrait when no entry exists — is how
|
||||
* "portrait everywhere" became an eighteen-page interim in the first place: it
|
||||
* fails silently and looks intentional.
|
||||
*/
|
||||
|
||||
/** Articles are not listed here. Their cards come from the collection itself —
|
||||
* see `src/pages/og/[...slug].jpg.ts`, which is the only place that knows
|
||||
* about both sources, so the two cannot disagree about which cards exist. */
|
||||
export const OG_CARDS: Record<string, { eyebrow: string; headline: string }> = {
|
||||
'/mediation/': {
|
||||
eyebrow: 'Mediation',
|
||||
headline: 'A mediator decides nothing.',
|
||||
},
|
||||
'/arbitration/': {
|
||||
eyebrow: 'Arbitration',
|
||||
headline: 'Sole, party-appointed, co-arbitration.',
|
||||
},
|
||||
'/med-arb/': {
|
||||
eyebrow: 'Med-Arb',
|
||||
headline: 'One neutral. Two processes. One agreement, written first.',
|
||||
},
|
||||
'/practice/': {
|
||||
eyebrow: 'Practice',
|
||||
headline: 'Six areas, one reason.',
|
||||
},
|
||||
'/practice/construction/': {
|
||||
eyebrow: 'Construction',
|
||||
headline: 'The dispute is in the change orders.',
|
||||
},
|
||||
'/practice/technology/': {
|
||||
eyebrow: 'Technology',
|
||||
headline: 'I read the contract and the system.',
|
||||
},
|
||||
'/practice/energy/': {
|
||||
eyebrow: 'Energy',
|
||||
headline:
|
||||
'Grid disputes are engineering disputes with a regulator attached.',
|
||||
},
|
||||
'/practice/insurance/': {
|
||||
eyebrow: 'Insurance',
|
||||
headline: "Private mediation, not the Tribunal's case conference.",
|
||||
},
|
||||
'/practice/shareholder/': {
|
||||
eyebrow: 'Shareholder',
|
||||
headline: 'The company still has to trade on Monday.',
|
||||
},
|
||||
'/practice/cross-cultural/': {
|
||||
eyebrow: 'Cross-cultural',
|
||||
headline: 'A session in the language the deal was made in.',
|
||||
},
|
||||
'/process/': {
|
||||
eyebrow: 'Process',
|
||||
headline: 'The shape of an engagement.',
|
||||
},
|
||||
'/for-parties/': {
|
||||
eyebrow: 'For parties',
|
||||
headline: 'What happens at a mediation.',
|
||||
},
|
||||
'/fees/': {
|
||||
eyebrow: 'Fees',
|
||||
headline: 'Published in full, including what overruns cost.',
|
||||
},
|
||||
'/insights/': {
|
||||
eyebrow: 'Insights',
|
||||
headline: 'Notes on process, regulation, and the technical record.',
|
||||
},
|
||||
'/contact/': {
|
||||
eyebrow: 'Contact',
|
||||
headline: 'Start with a confidential call.',
|
||||
},
|
||||
/* The two POST-redirect-GET landing pages. Both are `noindex` and excluded
|
||||
from the sitemap, and neither is a URL anyone would share — but they get
|
||||
cards for the same reason every other page does: `SEO.astro` throws without
|
||||
an entry, and the alternative is a silent portrait fallback, which is the
|
||||
failure R15 exists to prevent. Cheap, and it keeps one rule with no
|
||||
exceptions. */
|
||||
'/contact/received/': {
|
||||
eyebrow: 'Received',
|
||||
headline: 'Your inquiry has been received.',
|
||||
},
|
||||
'/contact/could-not-send/': {
|
||||
eyebrow: 'Not sent',
|
||||
headline: 'That inquiry was not recorded.',
|
||||
},
|
||||
/* `/bio/` is the source of the one-page PDF (R16). `noindex` and out of the
|
||||
sitemap, but it still needs an entry — one rule, no exceptions. */
|
||||
'/bio/': {
|
||||
eyebrow: 'Bio',
|
||||
headline: 'Pouya Lajevardi',
|
||||
},
|
||||
'/legal/privacy/': {
|
||||
eyebrow: 'Privacy',
|
||||
headline: 'What the intake form collects, and for how long.',
|
||||
},
|
||||
'/legal/terms/': {
|
||||
eyebrow: 'Terms',
|
||||
headline: 'Terms of use for this site.',
|
||||
},
|
||||
};
|
||||
|
||||
/** `/` and `/about/` — the portrait, decided rather than deferred (Q40). */
|
||||
export const PORTRAIT_PAGES = ['/', '/about/'] as const;
|
||||
|
||||
/**
|
||||
* AN ARTICLE'S CARD, DERIVED HERE RATHER THAN IN THE ENDPOINT — and the move is
|
||||
* the fix for a defect, not a tidy-up.
|
||||
*
|
||||
* `scripts/og-proof.mjs` compares every card's headline against its page's own
|
||||
* `<h1>`, which is what keeps card copy inside the claim register — text baked
|
||||
* into a JPEG is text `check:claims` cannot grep. Articles have no registry
|
||||
* entry, so the first version of that check **skipped them entirely**, and
|
||||
* `adversarial-reviewer` proved it by putting `DELIBERATELY WRONG CARD TEXT` in
|
||||
* the endpoint and watching the check pass. **The first repair was worse**: it
|
||||
* compared the article's `<h1>` against itself, which is a tautology, and the
|
||||
* same probe passed again.
|
||||
*
|
||||
* The working fix is not a cleverer comparison — it is to leave nothing to
|
||||
* compare. The derivation lives here, both the endpoint and the proof script
|
||||
* call it, and the endpoint no longer holds a headline literal that could
|
||||
* disagree with anything. What the proof script then checks is the one thing
|
||||
* still capable of drifting: whether the article's own `title` is what the route
|
||||
* renders as its `<h1>`.
|
||||
*/
|
||||
export function articleCard(title: string): {
|
||||
eyebrow: string;
|
||||
headline: string;
|
||||
} {
|
||||
return {
|
||||
eyebrow: 'Insights',
|
||||
/* The headline is the article's title, which is also its `<title>` (docs/04)
|
||||
and its `<h1>`. Never `description`: a 140–160 character sentence cannot
|
||||
set as a display line, and truncating it would put half a sentence in
|
||||
front of the reader the card exists for. */
|
||||
headline: title,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* `/practice/construction/` → `practice-construction`, and back again in the
|
||||
* endpoint. Flattening rather than nesting because an Astro rest route serving
|
||||
* `/og/a/b.jpg` has to reassemble the path anyway, and one transform in one
|
||||
* place is cheaper to keep true than two.
|
||||
*
|
||||
* A hyphen cannot collide here: no page slug in `docs/01`'s sitemap contains
|
||||
* one at a position that would reproduce another page's flattened form, and
|
||||
* `PRACTICE_SLUGS` is the only nested namespace besides `/insights/` and
|
||||
* `/legal/`. If a slug is ever added that would collide, `og:proof` catches it —
|
||||
* two pages resolving to one card file means one page's `<h1>` will not match.
|
||||
*/
|
||||
export function ogSlug(pathname: string): string {
|
||||
return pathname.replace(/^\/|\/$/g, '').replace(/\//g, '-');
|
||||
}
|
||||
|
||||
/** The site-root-relative path of a page's card. */
|
||||
export function ogCardPath(pathname: string): string {
|
||||
return `/og/${ogSlug(pathname)}.jpg`;
|
||||
}
|
||||
@@ -365,7 +365,24 @@ export const PRACTICE_PAGES: Record<PracticeSlug, PracticePage> = {
|
||||
},
|
||||
{
|
||||
lead: 'And large loads now have their own gate.',
|
||||
text: 'Section 28.1 of the Electricity Act, 1998 came into force on 11 December 2025 and creates a connection-approval requirement for a "specified load facility", a category defined to include data centres meeting criteria to be set by regulation. The regulation that would set them had not been made as of August 2026 — the Ministry described it then as something the province was considering drafting — and the Ministry posted a consultation on an assessment framework for new data centres in August 2026, with a comment period running to 12 September.',
|
||||
/* ⚠️ THIS SENTENCE ASSERTED THE ABSENCE OF A REGULATION AND THE
|
||||
EXTRACT FORBIDS ASSERTING IT. It read: "The regulation that would
|
||||
set them had not been made as of August 2026". The source,
|
||||
`docs/reference/ontario-energy-regulatory.md`, records the outcome
|
||||
of exactly that question as **"NOT ESTABLISHED either way, and DO
|
||||
NOT ASSERT ITS ABSENCE"** — because its 50-item e-Laws regulation
|
||||
list may have been truncated by a page cap, and criteria could be
|
||||
added to an existing regulation rather than a new one. It even
|
||||
supplies safe wording, which is what this now uses.
|
||||
|
||||
Found 2026-08-31 by the compliance audit on a step-7c ARTICLE
|
||||
DRAFT that had copied the same construction — so a defect in an
|
||||
unpublished draft surfaced a shipped one. Neither of step 5's
|
||||
review passes caught it, because both read the sentence against
|
||||
the extract's *quotations* rather than against its adversarial
|
||||
check. R18(b) tracks this fact as volatile; that is a different
|
||||
problem from never having been established. */
|
||||
text: 'Section 28.1 of the Electricity Act, 1998 came into force on 11 December 2025 and creates a connection-approval requirement for a "specified load facility", a category defined to include data centres meeting criteria that may be set by regulation. The enabling section is in force; the Ministry\'s August 2026 consultation still described the connection-approval regulation as under consideration, and described it as something the province was considering drafting. That consultation, on an assessment framework for new data centres, ran a comment period to 12 September 2026.',
|
||||
},
|
||||
],
|
||||
note: "Described so the process is legible, not applied to anyone's file — and the terms above are the ones these bodies actually use. Sourced in docs/reference/ontario-energy-regulatory.md.",
|
||||
|
||||
+102
-2
@@ -26,8 +26,10 @@
|
||||
* that `src/pages/about.astro` deliberately removed from visible prose as
|
||||
* *"a corporate-structure claim"* — scoping the value to a bare name does
|
||||
* not reach that. See `PRACTICE_JOB_TITLE` in `site.ts`.
|
||||
* 3. `priceRange` — omitted until `/fees/` exists (build step 9). docs/04
|
||||
* gates it on that page being real.
|
||||
* 3. `priceRange` — **omitted, and no longer "until `/fees/` exists".** That
|
||||
* page exists as of build step 9, the field went in, and it came out the
|
||||
* same day: its ends had different units and its floor was a quarter of the
|
||||
* real entry price for a mediation. See `professionalServiceNode`.
|
||||
*
|
||||
* AND `hasCredential` NOW CARRIES BOTH DESIGNATIONS. It was Q.Med-only until
|
||||
* 2026-08-29 because Q.Arb was a commenced pathway and the property means
|
||||
@@ -35,6 +37,9 @@
|
||||
* mapping `CREDENTIALS.designations` rather than indexing it, so a designation
|
||||
* added to §4 and to that constant cannot be silently omitted here.
|
||||
*/
|
||||
/* No `FEES` import. It was here for `priceRange`, which is gone — see
|
||||
`professionalServiceNode`. Nothing in this file carries a number now, which is
|
||||
the right shape: money is `/fees/`'s, with the conditions attached. */
|
||||
import {
|
||||
CONTACT,
|
||||
CREDENTIALS,
|
||||
@@ -217,6 +222,35 @@ export function professionalServiceNode(imageUrl?: string) {
|
||||
'Mediation-arbitration (med-arb)',
|
||||
],
|
||||
email: `mailto:${CONTACT.email}`,
|
||||
/**
|
||||
* ⚠️ **NO `priceRange`, AND IT WAS SET FOR AN HOUR AT BUILD STEP 9.**
|
||||
* `docs/04` gates the field on `/fees/` being real, and `/fees/` is now real
|
||||
* — so the gate was met and the field went in as `$500–$9,500`. It is out
|
||||
* again, because its own justification did not survive its own test.
|
||||
*
|
||||
* The comment defending it rejected a `Math.min`/`Math.max` over `FEES` on
|
||||
* the ground that it *"would sweep in `additionalParty` and
|
||||
* `overtimePerHour`, which are per-party and per-hour increments rather than
|
||||
* prices for anything, and a range whose ends mean different units is a
|
||||
* range that misinforms."* **The ends it chose had different units too:** the
|
||||
* floor was `FEES.hourly`, $500 **per hour**, and the ceiling
|
||||
* `documentsOnlyComplex`, $9,500 **flat**.
|
||||
*
|
||||
* And the floor misinformed in the direction that matters. The lowest amount
|
||||
* anyone pays for the headline service is `halfDay.amount` — **$2,000**. A
|
||||
* reader or crawler taking `priceRange` as what this practice costs read a
|
||||
* floor a quarter of the real entry price, in the one machine-readable field
|
||||
* on the site carrying a number. Found by `adversarial-reviewer`, 2026-08-31.
|
||||
*
|
||||
* **Omitted rather than repaired**, and that is the narrower answer: `docs/04`
|
||||
* gates the field, it does not require it, and `/fees/` publishes the
|
||||
* conditions — session length, party count, format — that make any single
|
||||
* range meaningless. A field that needs a paragraph to not mislead is worse
|
||||
* than no field. `/fees/` is one click away and says it properly.
|
||||
* (`Intl.NumberFormat('en-CA', { currency: 'CAD' })` also emits a bare `$`,
|
||||
* which would have needed `CA$` to be unambiguous — a second reason the
|
||||
* shape was wrong rather than the value.)
|
||||
*/
|
||||
...(imageUrl ? { image: imageUrl } : {}),
|
||||
};
|
||||
}
|
||||
@@ -496,3 +530,69 @@ export function medArbGraph(opts: {
|
||||
'@graph': [...base['@graph'], faqNode('/med-arb/', opts.faq)],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* `Article` — build step 7b. docs/04: *"`headline`, `description`,
|
||||
* `datePublished`, `dateModified`, `author` → Person, `image`"*.
|
||||
*
|
||||
* `author` IS `{'@id': PERSON_ID}` AND THE PERSON NODE TRAVELS IN THE SAME
|
||||
* `@graph` — `homeGraph`'s reasoning, applied a fifth time. A bare `@id` pointing
|
||||
* at another document relies on a crawler fetching and joining two; inside one
|
||||
* `@graph` it resolves in the document it arrives in.
|
||||
*
|
||||
* `dateModified` FALLS BACK TO `datePublished` RATHER THAN BEING OMITTED. An
|
||||
* article with no `updatedDate` has not been modified since publication, which is
|
||||
* a fact; omitting the field says nothing, and Google reads a missing
|
||||
* `dateModified` as unknown rather than as "same as published".
|
||||
*
|
||||
* ⚠️ **NO `publisher`, AND NO `Organization` NODE ANYWHERE NEAR THIS.** The
|
||||
* obvious shape for a blog is `publisher: { '@type': 'Organization', name: … }`,
|
||||
* and on this site the only name available for it is SML Company Ltd — which
|
||||
* would assert in machine-readable form that the company publishes the practice's
|
||||
* writing. §4 rows *"Operator of SML Company Ltd **alongside** the practice"* and
|
||||
* nothing more; `schema.ts` already declines `Person.worksFor` for the same
|
||||
* reason (Q49(b)). A personal byline needs no publisher: `author` is the Person.
|
||||
*
|
||||
* ⚠️ **NO `wordCount`, NO `articleSection` KEYWORD STUFFING, AND NO
|
||||
* `interactionStatistic`.** The first is derivable and adds nothing; the last is
|
||||
* where a view count would go, and §4 Forbidden's reasoning about unverifiable
|
||||
* numbers applies to a field nobody reads exactly as it applies to a page.
|
||||
*/
|
||||
export function articleGraph(opts: {
|
||||
slug: string;
|
||||
headline: string;
|
||||
description: string;
|
||||
datePublished: Date;
|
||||
dateModified?: Date;
|
||||
/** The article's own OG card, absolute. docs/04 lists `image` on `Article`. */
|
||||
imageUrl: string;
|
||||
/** The Person node's image — the portrait, not the card. Two different
|
||||
* claims: this one is a photograph of a person. */
|
||||
personImageUrl?: string;
|
||||
}) {
|
||||
const path = `/insights/${opts.slug}/`;
|
||||
const iso = (d: Date) => d.toISOString().slice(0, 10);
|
||||
return {
|
||||
'@context': 'https://schema.org',
|
||||
'@graph': [
|
||||
{
|
||||
'@type': 'Article',
|
||||
'@id': `${SITE.url}${path}#article`,
|
||||
headline: opts.headline,
|
||||
description: opts.description,
|
||||
url: `${SITE.url}${path}`,
|
||||
datePublished: iso(opts.datePublished),
|
||||
dateModified: iso(opts.dateModified ?? opts.datePublished),
|
||||
author: { '@id': PERSON_ID },
|
||||
image: opts.imageUrl,
|
||||
inLanguage: 'en-CA',
|
||||
},
|
||||
personNode(opts.personImageUrl),
|
||||
breadcrumbNode(path, [
|
||||
{ name: 'Home', href: '/' },
|
||||
{ name: 'Insights', href: '/insights/' },
|
||||
{ name: opts.headline, href: path },
|
||||
]),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
+37
-3
@@ -437,6 +437,16 @@ export const ANALYTICS = {
|
||||
*/
|
||||
provider: 'plausible' as 'plausible' | 'fathom',
|
||||
domain: 'adr.smlcompany.ca',
|
||||
/**
|
||||
* ⚠️ **NOT INSTALLED — D15 decided the provider; deciding is not installing.**
|
||||
* `/legal/privacy/` renders its analytics paragraph from this flag, so the
|
||||
* policy states the fact rather than the intention: a policy naming a
|
||||
* processor that processes nothing is a false disclosure, and a silent one.
|
||||
*
|
||||
* **Flipping this is a change to a published disclosure.** The policy's
|
||||
* "last updated" date moves on the same build.
|
||||
*/
|
||||
installed: false, // [verified 2026-08-31 — no script on any built page]
|
||||
} as const;
|
||||
|
||||
export const CONTACT = {
|
||||
@@ -488,14 +498,38 @@ export const FEES = {
|
||||
* *"up to 3 hours of session"*, *"including up to 2 hours of preparation"*.
|
||||
* A flat "including 2 hours" sells an entitlement and a bare "preparation
|
||||
* included" sells an uncapped allowance.
|
||||
*
|
||||
* ⚠️ WHERE OVERTIME STARTS IS NOT SETTLED — §9 Q59, OPEN. `overtimePerHour`
|
||||
* may be published; the trigger may not.
|
||||
*/
|
||||
halfDay: { amount: 2000, hours: 3, prepIncluded: 2 },
|
||||
fullDay: { amount: 4000, hours: 6, prepIncluded: 3 },
|
||||
additionalParty: 500, // each party beyond two
|
||||
overtimePerHour: 500, // [verified 2026-08-26]
|
||||
/**
|
||||
* ✅ **Q59 RULED — Pouya, 2026-08-31. OVERTIME RUNS FROM THE SESSION CAP**,
|
||||
* i.e. from the fourth hour of a half day and the seventh of a full day —
|
||||
* `hours` above, not the billed envelope.
|
||||
*
|
||||
* ⚠️ **THERE IS NO BOOLEAN FOR THAT, AND THERE WAS ONE FOR AN HOUR.**
|
||||
* `overtimeStartsAfterSessionHours: true` sat here with a 21-line comment
|
||||
* instructing that *"the page must say so wherever it publishes the overtime
|
||||
* rate"* — and `grep -rn overtimeStartsAfterSessionHours src/ scripts/
|
||||
* backend/` returned exactly one line: the declaration. Nothing read it.
|
||||
* `/fees/` and `/bio/` both hardcode the session-cap wording in template
|
||||
* strings, so reversing the flag would have changed nothing and failed
|
||||
* nothing. **A flag that looks like a control and is not is `AGENTS.md` Q22
|
||||
* at constant scope**, which is the defect this project has paid for most
|
||||
* often. Deleted by `adversarial-reviewer`'s finding, 2026-08-31; the ruling
|
||||
* lives in `docs/07` and in §9 Q59, which is where a ruling belongs.
|
||||
*
|
||||
* ⚠️ **`reservation` BELOW IS REAL AND MUST STAY.** It is interpolated into
|
||||
* `/fees/`'s overtime row and into `/bio/`, and it is the half of Q59's
|
||||
* ruling that answers the rate card's arithmetic anomaly: the full-day fee
|
||||
* buys the **day**, so `2000 + 500 × 3 = 3500` against `4000` is not a
|
||||
* penalty for booking properly. A reader who takes the number and skips this
|
||||
* sentence has read a different offer — §12 R5 carries the anomaly.
|
||||
*/
|
||||
reservation:
|
||||
'A full day reserves the day. Half-day overtime is subject to ' +
|
||||
'availability.', // [verified 2026-08-31 — Pouya, Q59]
|
||||
},
|
||||
arbitration: {
|
||||
perHour: 500,
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
/**
|
||||
* The Open Graph card generator. Spec: docs/04-seo-spec.md §Metadata —
|
||||
* *"the site's own type and palette: display headline on cream, infinity mark,
|
||||
* designation line"*. Discharges `AGENTS.md` R15.
|
||||
*
|
||||
* WHY THE CARDS MATTER AND WHY NOBODY HERE WOULD EVER NOTICE THEM. R15's own
|
||||
* reasoning: a link preview is rendered by LinkedIn, Slack and Teams for a
|
||||
* reader who is not us. Nineteen pages with unique titles previewing
|
||||
* identically is the defect, and it is invisible from inside the repo.
|
||||
*
|
||||
* ⚠️ **TEXT BAKED INTO AN IMAGE IS UNREACHABLE BY `npm run check:claims`.**
|
||||
* That script greps `dist/`'s HTML; a claim rendered into a JPEG is a claim no
|
||||
* mechanical control on this project can see, and under D20 `check:claims` is
|
||||
* the only per-step claims control there is. So the rule for card copy is
|
||||
* structural rather than editorial:
|
||||
*
|
||||
* **A card renders strings that already exist elsewhere in the repo.** The
|
||||
* kicker is `CREDENTIALS.designations`, rendered. The eyebrow and headline
|
||||
* come from `src/data/og-cards.ts`, whose entries are short subject labels
|
||||
* for pages that already ship — not new prose, and never a claim that is not
|
||||
* already made in auditable HTML on the page the card is for.
|
||||
*
|
||||
* COLOURS ARE PARSED OUT OF `tokens.css`, NOT COPIED. `CLAUDE.md` requires
|
||||
* every colour to come from a token, and this file is not CSS — so the choice
|
||||
* was a duplicated hex table or a parse. A duplicated hex table is the SES-DKIM
|
||||
* shape: two copies of one fact, and the stale one is the copy nobody re-reads.
|
||||
* A missing token throws rather than falling back, because a silent fallback
|
||||
* would render a card in the wrong palette and look deliberate.
|
||||
*
|
||||
* FONTS ARE THE STATIC `@fontsource` CUTS, NOT `public/fonts/`, AND THAT IS
|
||||
* FORCED. Measured 2026-08-31: satori parses TTF/OTF/WOFF and not WOFF2, and
|
||||
* decompressing `public/fonts/geist-latin-wght-normal.woff2` to TTF then
|
||||
* **throws** inside satori's `opentype.js` fork —
|
||||
* `parseFvarAxis: Cannot read properties of undefined` — because Fontsource's
|
||||
* subsetting drops the `name` records that the variable font's `fvar` table
|
||||
* points at. `@fontsource/geist` and `@fontsource/instrument-serif` ship static
|
||||
* 400 cuts as `.woff`, which satori reads directly. Same typefaces, same
|
||||
* upstream version (5.3.0) as `docs/reference/fonts-provenance.md` records for
|
||||
* the site's own files, same weight. Build-time only: no visitor fetches these.
|
||||
*/
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import satori from 'satori';
|
||||
import sharp from 'sharp';
|
||||
import { CREDENTIALS } from '../data/site';
|
||||
|
||||
export const OG_WIDTH = 1200;
|
||||
export const OG_HEIGHT = 630;
|
||||
|
||||
/**
|
||||
* ⚠️ PATHS ARE RESOLVED FROM `process.cwd()`, NOT FROM `import.meta.url`, AND
|
||||
* THAT IS NOT A STYLE CHOICE. Measured: with `import.meta.url` the build fails
|
||||
* with `ENOENT ... /dist/.prerender/chunks/../styles/tokens.css`, because Astro
|
||||
* bundles this module into `dist/.prerender/chunks/` and `import.meta.url` is
|
||||
* the CHUNK's location, not this file's. It works under `astro dev`, where the
|
||||
* module is served from source — the same dev-passes / build-fails shape as the
|
||||
* `animation-timeline` minifier defect, and the reason `/build` Phase 5 checks
|
||||
* the built output rather than the dev server.
|
||||
*
|
||||
* `astro build` runs with the project root as cwd. Every read below is
|
||||
* build-time only and throws with the path if it is wrong, so a future runner
|
||||
* with a different cwd fails loudly rather than shipping a blank card.
|
||||
*/
|
||||
const fromRoot = (...parts: string[]) => join(process.cwd(), ...parts);
|
||||
const TOKENS_CSS = fromRoot('src', 'styles', 'tokens.css');
|
||||
const MARK_PNG = fromRoot('src', 'assets', 'brand', 'sml-infinity-mark.png');
|
||||
const SERIF_WOFF = fromRoot(
|
||||
'node_modules',
|
||||
'@fontsource',
|
||||
'instrument-serif',
|
||||
'files',
|
||||
'instrument-serif-latin-400-normal.woff',
|
||||
);
|
||||
const SANS_WOFF = fromRoot(
|
||||
'node_modules',
|
||||
'@fontsource',
|
||||
'geist',
|
||||
'files',
|
||||
'geist-latin-400-normal.woff',
|
||||
);
|
||||
|
||||
/** The tokens this card uses, by their `tokens.css` names. */
|
||||
const NEEDED = ['cream', 'ink', 'ink-soft', 'maroon', 'gold'] as const;
|
||||
type TokenName = (typeof NEEDED)[number];
|
||||
|
||||
async function loadPalette(): Promise<Record<TokenName, string>> {
|
||||
const css = await readFile(TOKENS_CSS, 'utf8');
|
||||
const palette = {} as Record<TokenName, string>;
|
||||
for (const name of NEEDED) {
|
||||
// Only the literal hex declarations in the palette block, never an alias
|
||||
// like `--bg: var(--cream)` — resolving one level of indirection here would
|
||||
// invite resolving two, and this generator has no cascade.
|
||||
const match = new RegExp(`--${name}:\\s*(#[0-9a-fA-F]{3,8})\\s*;`).exec(
|
||||
css,
|
||||
);
|
||||
if (!match) {
|
||||
throw new Error(
|
||||
`src/styles/tokens.css has no literal --${name} hex declaration. ` +
|
||||
'The OG card generator reads the palette from that file so the card ' +
|
||||
'and the site cannot drift; add the token there rather than a hex ' +
|
||||
'value here.',
|
||||
);
|
||||
}
|
||||
palette[name] = match[1];
|
||||
}
|
||||
return palette;
|
||||
}
|
||||
|
||||
/**
|
||||
* The mark, resized once and inlined as a data URI. satori resolves no URLs, so
|
||||
* a data URI is the only way in — and `CLAUDE.md`'s rule against base64-inlining
|
||||
* an image is about bytes shipped to a visitor in HTML. Nothing here reaches a
|
||||
* visitor: this string exists for the few milliseconds before sharp flattens
|
||||
* the SVG to a JPEG.
|
||||
*
|
||||
* 132 px wide at the mark's own 2668 × 1704 (1.5657:1), so it renders at its
|
||||
* true proportion — the ratio `InfinityMark.astro` records as measured, and the
|
||||
* one a hand-traced path got wrong (Q32).
|
||||
*/
|
||||
const MARK_W = 132;
|
||||
const MARK_H = Math.round((MARK_W * 1704) / 2668);
|
||||
|
||||
type Assets = {
|
||||
palette: Record<TokenName, string>;
|
||||
serif: Buffer;
|
||||
sans: Buffer;
|
||||
mark: string;
|
||||
};
|
||||
|
||||
/** Read once per build, not once per card — seventeen pages plus every article
|
||||
* go through here in one `astro build`. */
|
||||
let assets: Promise<Assets> | null = null;
|
||||
|
||||
function loadAssets(): Promise<Assets> {
|
||||
assets ??= (async () => {
|
||||
const [palette, serif, sans, markPng] = await Promise.all([
|
||||
loadPalette(),
|
||||
readFile(SERIF_WOFF),
|
||||
readFile(SANS_WOFF),
|
||||
readFile(MARK_PNG),
|
||||
]);
|
||||
const markResized = await sharp(markPng)
|
||||
.resize({ width: MARK_W * 2, withoutEnlargement: true })
|
||||
.png()
|
||||
.toBuffer();
|
||||
return {
|
||||
palette,
|
||||
serif,
|
||||
sans,
|
||||
mark: `data:image/png;base64,${markResized.toString('base64')}`,
|
||||
};
|
||||
})();
|
||||
return assets;
|
||||
}
|
||||
|
||||
/**
|
||||
* Headline size, chosen from length rather than measured. satori does not
|
||||
* shrink text to fit and silently overflows its container instead, so a card
|
||||
* with a long headline would crop — the exact class of defect nobody on this
|
||||
* project would ever see. The bands are set so the longest entry in
|
||||
* `og-cards.ts` renders on three lines at most; `npm run og:proof` renders every
|
||||
* card to `dist/og-proof/` so the claim is checkable by looking.
|
||||
*/
|
||||
function headlineSize(headline: string): number {
|
||||
if (headline.length > 62) return 58;
|
||||
if (headline.length > 42) return 68;
|
||||
return 80;
|
||||
}
|
||||
|
||||
export type OgCard = {
|
||||
/** Short, uppercased on the card. The page's own eyebrow where it has one. */
|
||||
eyebrow: string;
|
||||
/** The card's display line. A subject label, not new prose — see the header. */
|
||||
headline: string;
|
||||
};
|
||||
|
||||
export async function renderOgCard(card: OgCard): Promise<Buffer> {
|
||||
const { palette, serif, sans, mark } = await loadAssets();
|
||||
const pad = 72;
|
||||
|
||||
const svg = await satori(
|
||||
{
|
||||
type: 'div',
|
||||
props: {
|
||||
style: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
width: `${OG_WIDTH}px`,
|
||||
height: `${OG_HEIGHT}px`,
|
||||
backgroundColor: palette.cream,
|
||||
padding: `${pad}px`,
|
||||
},
|
||||
children: [
|
||||
{
|
||||
type: 'div',
|
||||
props: {
|
||||
style: {
|
||||
display: 'flex',
|
||||
fontFamily: 'Geist',
|
||||
fontSize: 22,
|
||||
letterSpacing: 4,
|
||||
textTransform: 'uppercase',
|
||||
color: palette.maroon,
|
||||
},
|
||||
children: card.eyebrow,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'div',
|
||||
props: {
|
||||
style: {
|
||||
display: 'flex',
|
||||
marginTop: 44,
|
||||
fontFamily: 'Instrument Serif',
|
||||
fontSize: headlineSize(card.headline),
|
||||
lineHeight: 1.06,
|
||||
letterSpacing: -1,
|
||||
color: palette.ink,
|
||||
},
|
||||
children: card.headline,
|
||||
},
|
||||
},
|
||||
// Pushes the footer to the bottom edge whatever the headline does.
|
||||
{ type: 'div', props: { style: { display: 'flex', flexGrow: 1 } } },
|
||||
{
|
||||
type: 'div',
|
||||
props: {
|
||||
style: {
|
||||
display: 'flex',
|
||||
height: '1px',
|
||||
backgroundColor: palette.gold,
|
||||
marginBottom: 28,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'div',
|
||||
props: {
|
||||
style: {
|
||||
display: 'flex',
|
||||
alignItems: 'flex-end',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
children: [
|
||||
{
|
||||
type: 'div',
|
||||
props: {
|
||||
style: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
fontFamily: 'Geist',
|
||||
color: palette['ink-soft'],
|
||||
},
|
||||
children: [
|
||||
{
|
||||
type: 'div',
|
||||
props: {
|
||||
style: { display: 'flex', fontSize: 30 },
|
||||
children: 'Pouya Lajevardi',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'div',
|
||||
props: {
|
||||
style: {
|
||||
display: 'flex',
|
||||
marginTop: 8,
|
||||
fontSize: 21,
|
||||
letterSpacing: 1,
|
||||
},
|
||||
// Rendered from §4's own designation strings, never
|
||||
// retyped. `Q.Arb (ADRIC / ADRIO)` is the publishable
|
||||
// form and no acquisition date appears anywhere.
|
||||
children: CREDENTIALS.designations.join(' · '),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'img',
|
||||
props: { src: mark, width: MARK_W, height: MARK_H },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
width: OG_WIDTH,
|
||||
height: OG_HEIGHT,
|
||||
fonts: [
|
||||
{ name: 'Instrument Serif', data: serif, weight: 400, style: 'normal' },
|
||||
{ name: 'Geist', data: sans, weight: 400, style: 'normal' },
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
// JPEG, for the reason SEO.astro already gives for the portrait: link-preview
|
||||
// crawlers are not browsers and several still do not decode WebP at all.
|
||||
// 4:4:4 because the card is type on a flat ground, where chroma subsampling
|
||||
// is visible on the letterforms rather than free.
|
||||
return sharp(Buffer.from(svg))
|
||||
.jpeg({ quality: 88, chromaSubsampling: '4:4:4', mozjpeg: true })
|
||||
.toBuffer();
|
||||
}
|
||||
+68
-2
@@ -588,8 +588,48 @@ const CREDENTIAL_GROUPS = [
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ---- 3b. The one-page PDF ------------------------------------------- */}
|
||||
{
|
||||
/* ✅ **R16 / Q45 DISCHARGED — build step 9.** `docs/01` §`/about/` item 7 has
|
||||
carried a pending note since step 3: the PDF did not exist, and a link to a
|
||||
file that does not exist is a broken link on the page an appointing body
|
||||
reads. It exists now, it is committed, and this is the link.
|
||||
**`/bio/` is the source and the PDF is a rendering of it** — so every line
|
||||
of the document circulated with an appointment proposal is on a page that
|
||||
`check:claims`, the adversarial review and the cutover claims pass all see.
|
||||
That was R16's actual objection: *"a PDF circulated with an appointment
|
||||
proposal is read once, by the reader who matters most, and never seen by a
|
||||
reviewer again."*
|
||||
It carries NOTHING the site does not — R16's second open sub-decision, and
|
||||
the answer that avoids the §4 question it flagged. No matter list, no
|
||||
referees, no figure that is not on `/fees/`. */
|
||||
}
|
||||
<section class="section bio-download reveal">
|
||||
<div class="wrap">
|
||||
<p class="download-line">
|
||||
<a href="/pouya-lajevardi-bio.pdf" download>
|
||||
Download a one-page PDF of this record
|
||||
</a>
|
||||
<span class="download-note">
|
||||
— designations, education, memberships, the processes offered and the
|
||||
rates, on one sheet. The same page is at <a href="/bio/">/bio/</a>.
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ---- 3. Credentials, structured ------------------------------------ */}
|
||||
<section class="section section-alt creds reveal">
|
||||
{
|
||||
/* ⚠️ `section-inverse`, NOT `section-alt` — approved by Pouya at build step
|
||||
6 and applied at step 7b. The arc section struck on 2026-08-29 was this
|
||||
page's only dark band, so removing it left `/about/` with four cream
|
||||
sections and the accent contact band, and the alternating rhythm
|
||||
`docs/02` sets went with it.
|
||||
Exactly ONE rule had to change — `.cred-title`. The measured ratios are on
|
||||
that rule below, which is where a future editor changing a colour will be
|
||||
looking. Everything else inherits cream from `.section-inverse`. */
|
||||
}
|
||||
<section class="section section-inverse creds reveal">
|
||||
<div class="wrap">
|
||||
<div class="section-head">
|
||||
<SectionHeading eyebrow="Credentials" level={2}>
|
||||
@@ -835,6 +875,24 @@ const CREDENTIAL_GROUPS = [
|
||||
page. "Provincial Offences Act" is set in roman. If a statute name ever
|
||||
needs italics here, load a face for it first. */
|
||||
|
||||
/* --- 3b. The one-page PDF ------------------------------------------- */
|
||||
|
||||
/* A quiet band between the bio and the credentials, not a call to action: the
|
||||
reader an appointing body sends here is looking for the record, and a
|
||||
download button styled like the contact CTA would compete with it. */
|
||||
.bio-download {
|
||||
padding-block: var(--space-7);
|
||||
border-block: 1px solid var(--border);
|
||||
}
|
||||
.download-line {
|
||||
max-inline-size: var(--width-prose);
|
||||
font-size: var(--text-base);
|
||||
line-height: var(--leading-body);
|
||||
}
|
||||
.download-note {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* --- 3. Credentials -------------------------------------------------- */
|
||||
|
||||
.cred-grid {
|
||||
@@ -860,7 +918,15 @@ const CREDENTIAL_GROUPS = [
|
||||
font-size: var(--text-base);
|
||||
font-weight: var(--weight-medium);
|
||||
letter-spacing: var(--tracking-tight);
|
||||
color: var(--text-secondary);
|
||||
/* THE ONE COLOUR THAT HAD TO MOVE WITH THE BAND. This was
|
||||
`--text-secondary`, which is `--ink-soft` — **1.43:1** on the ink ground
|
||||
this section now has, i.e. worse than the gold-on-cream 2.10:1 this
|
||||
project treats as the defect that must never ship. `--text-inverse-2` is
|
||||
gold-l: 11.09:1 on ink (docs/02). The list items below inherit cream from
|
||||
`.section-inverse` at 16.81:1 and are untouched.
|
||||
The gold border is a 1px divider, which tokens.css sanctions gold for on
|
||||
any ground, and gold on ink measures 8.00:1 regardless. */
|
||||
color: var(--text-inverse-2);
|
||||
padding-block-end: var(--space-3);
|
||||
border-block-end: 1px solid var(--rule);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,395 @@
|
||||
---
|
||||
/**
|
||||
* `/bio/` — the one-page bio, and the SOURCE of the PDF. Build step 9.
|
||||
* Discharges `AGENTS.md` R16 / Q45.
|
||||
*
|
||||
* ⚠️ **R16 LEFT TWO DECISIONS OPEN AND BOTH ARE TAKEN HERE, UNDER STANDING
|
||||
* AUTHORISATION. Read them before changing anything.**
|
||||
*
|
||||
* **(a) Generated at build, or authored once as a designed artefact?** Neither,
|
||||
* exactly — and the third option is better than both. The bio is a PAGE in this
|
||||
* repository, so every line of it is reviewed by the same apparatus that reviews
|
||||
* every other page: `astro check`, `npm run check:claims` on the built HTML, the
|
||||
* adversarial review, and the cutover claims pass. The PDF is then RENDERED from
|
||||
* this page by `npm run bio:pdf`, deterministically, with no new dependency —
|
||||
* `chrome-launcher` is already a devDependency because Lighthouse needs it.
|
||||
*
|
||||
* That answers R16's actual worry, which was never about tooling: *"It is the
|
||||
* one artefact class this project's review apparatus cannot reach. A web page is
|
||||
* re-reviewed by every audit; a PDF circulated with an appointment proposal is
|
||||
* read once, by the reader who matters most, and never seen by a reviewer
|
||||
* again."* Making the PDF a rendering of a reviewed page puts it back inside the
|
||||
* apparatus. **It is not generated during `astro build`** — CI has no Chrome, and
|
||||
* a build step that cannot run in CI is the Q22 shape again.
|
||||
*
|
||||
* **(b) Does it carry anything the site does not? NO.** Every line here renders
|
||||
* from the same constants as the pages: `CREDENTIALS`, `ROLE`, `BOUTIQUE`,
|
||||
* `PRACTICE_AREAS`, `FEES`, `CONTACT`. R16 flagged that both open sub-decisions
|
||||
* were "each a §4 question of its own, and the matter list would collide with §4
|
||||
* Forbidden directly" — so the answer that avoids both is a bio that adds
|
||||
* nothing. No matter list, no referees, no figure that is not on `/fees/`. The
|
||||
* fee summary IS here, because R16's own reasoning says an appointment proposal
|
||||
* needs the rate card as much as the bio, and every figure in it is `/fees/`'s.
|
||||
*
|
||||
* `noindex`, and excluded from the sitemap in `astro.config.mjs`: it is a
|
||||
* condensed duplicate of `/about/` and `/fees/`, and two URLs competing on the
|
||||
* same content is the one thing `docs/04` is most concerned with.
|
||||
*
|
||||
* PRINT LAYOUT. `global.css`'s `@media print` block already hides the header,
|
||||
* the footer and the skip link, neutralises the inverse grounds, and disables
|
||||
* the reveal — all of it added because `/about/` is printed by people evaluating
|
||||
* an appointment. This page adds only what makes it fit ONE sheet, and
|
||||
* `scripts/bio-pdf.mjs` ASSERTS the page count rather than trusting it.
|
||||
*/
|
||||
import BaseLayout from '../layouts/BaseLayout.astro';
|
||||
import Eyebrow from '../components/Eyebrow.astro';
|
||||
import { getImage } from 'astro:assets';
|
||||
import ogDefault from '../assets/og-portrait.jpg';
|
||||
import { pageGraph } from '../data/schema';
|
||||
import {
|
||||
BOUTIQUE,
|
||||
CONTACT,
|
||||
CREDENTIALS,
|
||||
FEES,
|
||||
PRACTICE_AREAS,
|
||||
ROLE,
|
||||
SITE,
|
||||
} from '../data/site';
|
||||
|
||||
const ldImage = await getImage({
|
||||
src: ogDefault,
|
||||
format: 'jpeg',
|
||||
width: 1200,
|
||||
height: 630,
|
||||
});
|
||||
const graph = pageGraph(new URL(ldImage.src, Astro.site).href);
|
||||
|
||||
const money = (amount: number) =>
|
||||
new Intl.NumberFormat('en-CA', {
|
||||
style: 'currency',
|
||||
currency: FEES.currency,
|
||||
maximumFractionDigits: 0,
|
||||
}).format(amount);
|
||||
|
||||
const { halfDay, fullDay } = FEES.mediation;
|
||||
|
||||
/* The processes, each with a §4 Offerings row. Arbitration is scoped commercial
|
||||
because Q39's gate is a legal one; mediation is unscoped because it has no
|
||||
such gate (Q56). The asymmetry is designed — do not tidy it. */
|
||||
const PROCESSES = [
|
||||
'Mediation — sole mediator',
|
||||
'Commercial arbitration — sole, party-appointed, co-arbitration',
|
||||
'Med-arb — mediation converting to binding arbitration, agreed in advance',
|
||||
'Early neutral evaluation — delivered to both parties together',
|
||||
'Dispute-system design',
|
||||
'Pre-dispute technical advisory',
|
||||
];
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="One-Page Bio · Pouya Lajevardi · Mediator · Toronto"
|
||||
description="A one-page record for circulation with an appointment proposal: designations, education, memberships, the processes offered, the practice areas, and the rates."
|
||||
jsonLd={graph}
|
||||
noindex
|
||||
>
|
||||
<section class="section bio-sheet">
|
||||
<div class="wrap">
|
||||
{
|
||||
/* The download sits above the sheet and is `.no-print`, so the printed
|
||||
copy does not carry a link to itself. */
|
||||
}
|
||||
<p class="no-print sheet-note">
|
||||
This page is the source of the one-page PDF.
|
||||
<a href="/pouya-lajevardi-bio.pdf" download>Download the PDF</a>, or
|
||||
print this page.
|
||||
</p>
|
||||
|
||||
<header class="sheet-head">
|
||||
{
|
||||
/* THE EYEBROW IS `.no-print`, AND IT IS HERE BECAUSE `og:proof` ASKED
|
||||
FOR IT. That check compares each card's eyebrow against its page's
|
||||
first `.eyebrow`, and this page had none — so the card said "Bio"
|
||||
against nothing. The options were to weaken the check or to give the
|
||||
page the element every other page has; weakening a check to match an
|
||||
artefact is how a control stops controlling. On paper the sheet leads
|
||||
with the name, so the eyebrow prints away. */
|
||||
}
|
||||
<div class="no-print">
|
||||
<Eyebrow dot>Bio</Eyebrow>
|
||||
</div>
|
||||
<h1 class="sheet-name">{SITE.name}</h1>
|
||||
<p class="sheet-desigs">{CREDENTIALS.designations.join(' · ')}</p>
|
||||
<p class="sheet-strap">{SITE.tagline}</p>
|
||||
</header>
|
||||
|
||||
<div class="sheet-grid">
|
||||
<section class="block block-wide">
|
||||
<h2>The practice</h2>
|
||||
<p>
|
||||
{
|
||||
/* ⚠️ NO LEADING SCOPE. This sentence read "I act as a neutral in
|
||||
commercial disputes — as a mediator, as an arbitrator in
|
||||
commercial matters, and in med-arb…", and the leading clause
|
||||
scoped ALL THREE, mediation included. §4's mediation row is
|
||||
unscoped deliberately (Q56) and says in terms: "do not scope it
|
||||
on a page." It is the `/practice/` shape exactly — the two words
|
||||
never appear in the same element, so no proximity grep reaches
|
||||
it — and it was found by reading the rendered PDF. The scope
|
||||
belongs on the arbitration clause alone, where Q39's legal gate
|
||||
puts it. */
|
||||
}
|
||||
I act as a neutral — as a mediator, as an arbitrator in commercial matters,
|
||||
and in med-arb where the parties want one neutral across both phases.
|
||||
I read the contract and the technical record underneath it rather than
|
||||
either side's summary of them.
|
||||
</p>
|
||||
<p>
|
||||
I am {ROLE.title} at {BOUTIQUE}, with {ROLE.litigationLine} across
|
||||
{' '}{ROLE.litigationAreas.join(', ')} matters. I am also a practising
|
||||
machine-learning and infrastructure engineer, which is what lets me work
|
||||
through a technical record at first hand.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="block">
|
||||
<h2>Designations</h2>
|
||||
<ul role="list">
|
||||
{CREDENTIALS.designations.map((d) => <li>{d}</li>)}
|
||||
</ul>
|
||||
<h2>Education</h2>
|
||||
<ul role="list">
|
||||
{CREDENTIALS.education.map((d) => <li>{d}</li>)}
|
||||
</ul>
|
||||
<h2>Certifications</h2>
|
||||
<ul role="list">
|
||||
{CREDENTIALS.certifications.map((d) => <li>{d}</li>)}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="block">
|
||||
<h2>Memberships</h2>
|
||||
<ul role="list">
|
||||
{CREDENTIALS.memberships.map((d) => <li>{d}</li>)}
|
||||
</ul>
|
||||
<h2>Languages</h2>
|
||||
<ul role="list">
|
||||
<li>
|
||||
{CREDENTIALS.languages.join(' and ')}, without an interpreter
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="block">
|
||||
<h2>Processes</h2>
|
||||
<ul role="list">
|
||||
{PROCESSES.map((p) => <li>{p}</li>)}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="block">
|
||||
<h2>Subject matter</h2>
|
||||
<ul role="list">
|
||||
{PRACTICE_AREAS.map((area) => <li>{area.name}</li>)}
|
||||
</ul>
|
||||
<p class="fine">
|
||||
Family arbitration under the <em>Family Law Act</em> is not offered.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="block block-wide">
|
||||
<h2>Rates</h2>
|
||||
<ul role="list" class="rates-list">
|
||||
<li>
|
||||
Half day, up to {halfDay.hours} hours of session — {
|
||||
money(halfDay.amount)
|
||||
}. Fee includes up to {halfDay.prepIncluded} hours of preparation.
|
||||
</li>
|
||||
<li>
|
||||
Full day, up to {fullDay.hours} hours of session — {
|
||||
money(fullDay.amount)
|
||||
}. Fee includes up to {fullDay.prepIncluded} hours of preparation.
|
||||
</li>
|
||||
<li>
|
||||
Each party beyond two — {money(FEES.mediation.additionalParty)}.
|
||||
Overtime beyond the session hours the fee covers —
|
||||
{' '}{money(FEES.mediation.overtimePerHour)} an hour.
|
||||
{' '}{FEES.mediation.reservation}
|
||||
</li>
|
||||
<li>
|
||||
Arbitration — {money(FEES.arbitration.perHour)} an hour,
|
||||
{' '}{money(FEES.arbitration.hearingDay)} a hearing day, or a flat fee
|
||||
for documents-only and expedited references.
|
||||
</li>
|
||||
<li>
|
||||
{FEES.taxNote} The full card, the cancellation schedule and the terms
|
||||
are published at {SITE.url}/fees/.
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="block block-wide sheet-contact">
|
||||
<h2>Contact</h2>
|
||||
<p>
|
||||
{CONTACT.email} · {CONTACT.phoneFallback} · {CONTACT.location}
|
||||
<br />
|
||||
{CONTACT.responseTime} · {SITE.url} · {CONTACT.linkedin}
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</BaseLayout>
|
||||
|
||||
<style>
|
||||
.sheet-note {
|
||||
margin-block-end: var(--space-7);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-meta);
|
||||
}
|
||||
|
||||
.bio-sheet {
|
||||
padding-block: var(--space-8);
|
||||
}
|
||||
|
||||
.sheet-head {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
padding-block-end: var(--space-5);
|
||||
border-block-end: 2px solid var(--rule);
|
||||
}
|
||||
.sheet-name {
|
||||
font-family: var(--font-serif);
|
||||
font-size: var(--text-4xl);
|
||||
line-height: var(--leading-tight);
|
||||
letter-spacing: var(--tracking-tight);
|
||||
}
|
||||
.sheet-desigs {
|
||||
margin-block-start: var(--space-3);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-sm);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
color: var(--accent);
|
||||
}
|
||||
.sheet-strap {
|
||||
margin-block-start: var(--space-2);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-2xs);
|
||||
letter-spacing: var(--tracking-eyebrow);
|
||||
text-transform: uppercase;
|
||||
color: var(--text-meta);
|
||||
}
|
||||
|
||||
.sheet-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(16rem, 100%), 1fr));
|
||||
gap: var(--space-6);
|
||||
margin-block-start: var(--space-6);
|
||||
}
|
||||
.block-wide {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.block h2 {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-2xs);
|
||||
letter-spacing: var(--tracking-eyebrow);
|
||||
text-transform: uppercase;
|
||||
color: var(--text-meta);
|
||||
padding-block-end: var(--space-2);
|
||||
border-block-end: 1px solid var(--border);
|
||||
}
|
||||
.block h2:not(:first-child) {
|
||||
margin-block-start: var(--space-5);
|
||||
}
|
||||
.block ul {
|
||||
/* `global.css` strips the marker and padding from `ul[role='list']`. */
|
||||
margin-block-start: var(--space-3);
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
font-size: var(--text-sm);
|
||||
line-height: var(--leading-snug);
|
||||
}
|
||||
.block p {
|
||||
margin-block-start: var(--space-3);
|
||||
font-size: var(--text-sm);
|
||||
line-height: var(--leading-body);
|
||||
color: var(--text-secondary);
|
||||
max-inline-size: var(--width-prose);
|
||||
}
|
||||
.block p + p {
|
||||
margin-block-start: var(--space-3);
|
||||
}
|
||||
.rates-list {
|
||||
max-inline-size: none;
|
||||
}
|
||||
.fine {
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
.sheet-contact p {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
line-height: var(--leading-relaxed);
|
||||
max-inline-size: none;
|
||||
}
|
||||
|
||||
/* --- One sheet of paper ------------------------------------------------ */
|
||||
|
||||
/* `global.css`'s print block already hides the header, footer and skip link,
|
||||
neutralises the inverse grounds and disables the reveal. This is only what
|
||||
makes the content FIT, and `scripts/bio-pdf.mjs` asserts the page count
|
||||
rather than this comment claiming it. */
|
||||
@media print {
|
||||
.bio-sheet {
|
||||
padding-block: 0;
|
||||
}
|
||||
.wrap {
|
||||
max-inline-size: none;
|
||||
padding-inline: 0;
|
||||
}
|
||||
.sheet-grid {
|
||||
/* Two fixed columns rather than auto-fit: on paper there is no viewport
|
||||
to fit to, and a print UA resolves `auto-fit` against the sheet width
|
||||
inconsistently. */
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10pt 18pt;
|
||||
margin-block-start: 10pt;
|
||||
}
|
||||
.sheet-name {
|
||||
font-size: 22pt;
|
||||
}
|
||||
.sheet-desigs {
|
||||
font-size: 9pt;
|
||||
}
|
||||
.sheet-strap {
|
||||
font-size: 7pt;
|
||||
}
|
||||
.sheet-head {
|
||||
padding-block-end: 8pt;
|
||||
}
|
||||
.block h2 {
|
||||
font-size: 7pt;
|
||||
padding-block-end: 3pt;
|
||||
}
|
||||
.block h2:not(:first-child) {
|
||||
margin-block-start: 9pt;
|
||||
}
|
||||
.block ul,
|
||||
.block p {
|
||||
margin-block-start: 5pt;
|
||||
font-size: 8.5pt;
|
||||
line-height: 1.35;
|
||||
}
|
||||
.block ul {
|
||||
gap: 2pt;
|
||||
}
|
||||
.sheet-contact p {
|
||||
font-size: 8pt;
|
||||
}
|
||||
/* A block must not be split across a page break — on a one-sheet document
|
||||
that would mean a second sheet carrying two lines. */
|
||||
.block {
|
||||
break-inside: avoid;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,516 @@
|
||||
---
|
||||
/**
|
||||
* `/contact/` — build step 8. Spec: docs/01 §`/contact/`, docs/05-backend-spec.md.
|
||||
*
|
||||
* ⚠️ **THE FORM USES NO JAVASCRIPT, AND THAT IS NOT A CONSTRAINT WORKED AROUND —
|
||||
* IT IS THE DESIGN.** A plain `<form method="post">` to a same-origin path; the
|
||||
* handler answers `303 See Other` to `/contact/received/`. So it works with
|
||||
* script disabled, cannot double-submit on refresh, and never shows the visitor a
|
||||
* raw JSON response. `backend/intake/handler.mjs` carries the reasoning in full.
|
||||
*
|
||||
* Consequences that shape the markup:
|
||||
* - **Validation errors land on `/contact/could-not-send/`**, because a static
|
||||
* page cannot read a query string without script. In practice the browser's
|
||||
* own `required` / `type="email"` / `maxlength` handling catches the real
|
||||
* cases and announces them natively, which is what `docs/05`'s
|
||||
* "errors announced with `role="alert"`" asks for; a server rejection is
|
||||
* almost always a bot, and a bot gets the success page (see the handler).
|
||||
* - **No booking embed — R6.** Parked by Pouya 2026-08-26. `docs/01` asks for a
|
||||
* "reserved slot for an embed", so the slot is `CONTACT.bookingUrl` being
|
||||
* `null`: nothing renders, and when a URL exists the block appears without a
|
||||
* rebuild of this page. **Nothing on this page mentions booking**, because a
|
||||
* page that says "book a call" with no way to book it is worse than one that
|
||||
* says to email.
|
||||
*
|
||||
* ⚠️ **THE RESPONSE-TIME SENTENCE IS A PUBLIC COMMITMENT (§4, Q27) AND MUST READ
|
||||
* IDENTICALLY HERE, IN THE CONFIRMATION EMAIL, AND IN ANY BIO.** It is rendered
|
||||
* from `CONTACT.responseTime`; the handler takes the same string from its
|
||||
* environment. Never retype it, and never soften it to "usually".
|
||||
*
|
||||
* ⚠️ **`NO_RETAINER_NOTICE` AND `CONSENT_TEXT` BOTH SHIP, AND THAT IS NOT
|
||||
* DUPLICATION.** `docs/01` requires the page to carry the notice; `docs/05`
|
||||
* requires the consent the inquirer TICKS to carry it too. One is a statement the
|
||||
* page makes, the other is a thing the inquirer agrees to. Neither is retyped.
|
||||
*
|
||||
* ⚠️ **NO PHONE NUMBER — Q3. §4 verifies "no public phone number".** Render
|
||||
* `CONTACT.phoneFallback` wherever a number would go rather than leaving the slot
|
||||
* visually empty.
|
||||
*/
|
||||
import BaseLayout from '../layouts/BaseLayout.astro';
|
||||
import Button from '../components/Button.astro';
|
||||
import ContactBand from '../components/ContactBand.astro';
|
||||
import Eyebrow from '../components/Eyebrow.astro';
|
||||
import SectionHeading from '../components/SectionHeading.astro';
|
||||
import { getImage } from 'astro:assets';
|
||||
import ogDefault from '../assets/og-portrait.jpg';
|
||||
import { pageGraph } from '../data/schema';
|
||||
import { CONTACT, NO_RETAINER_NOTICE } from '../data/site';
|
||||
import {
|
||||
CONSENT_TEXT,
|
||||
HONEYPOT_FIELD,
|
||||
INTAKE_ACTION,
|
||||
INTAKE_FIELDS,
|
||||
} from '../data/intake';
|
||||
|
||||
const ldImage = await getImage({
|
||||
src: ogDefault,
|
||||
format: 'jpeg',
|
||||
width: 1200,
|
||||
height: 630,
|
||||
});
|
||||
|
||||
/* No `Service` node. `/contact/` offers nothing — it is the way in to what the
|
||||
other pages offer, and a `Service` here would duplicate an `@id` that already
|
||||
resolves on `/mediation/`. Person alone, the `/practice/` and `/process/`
|
||||
shape. No `BreadcrumbList`: one hop from the root, no visible trail. */
|
||||
const graph = pageGraph(new URL(ldImage.src, Astro.site).href);
|
||||
|
||||
const hintId = (name: string) => `${name}-hint`;
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="Contact · Request a Consultation · Pouya Lajevardi"
|
||||
description="Request a confidential intake call about a mediation, arbitration or med-arb appointment in Ontario. Inquiries are answered within two business days."
|
||||
jsonLd={graph}
|
||||
>
|
||||
{/* ---- 1. Hero -------------------------------------------------------- */}
|
||||
<section class="section hero">
|
||||
<div class="wrap">
|
||||
<Eyebrow dot>Contact</Eyebrow>
|
||||
<h1 class="display hero-h">Start with a confidential call.</h1>
|
||||
<p class="hero-lede">
|
||||
The first step is a scheduled call to scope the matter, identify the
|
||||
parties, and run conflicts. Send the form below, or email me directly.
|
||||
</p>
|
||||
<dl class="direct">
|
||||
<div>
|
||||
<dt>Email</dt>
|
||||
<dd><a href={`mailto:${CONTACT.email}`}>{CONTACT.email}</a></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Phone</dt>
|
||||
{
|
||||
/* Q3: no public number. The fallback fills the slot rather than
|
||||
leaving a labelled row visually empty. */
|
||||
}
|
||||
<dd>{CONTACT.phoneFallback}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Location</dt>
|
||||
<dd>{CONTACT.location}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Response</dt>
|
||||
<dd>{CONTACT.responseTime}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ---- 2. What an inquiry does and does not do ------------------------ */}
|
||||
<section class="section section-inverse reveal">
|
||||
<div class="wrap">
|
||||
<div class="section-head">
|
||||
<SectionHeading eyebrow="Before you write" level={2}>
|
||||
<span slot="heading">What an inquiry is, and what it is not.</span>
|
||||
</SectionHeading>
|
||||
</div>
|
||||
<div class="prose">
|
||||
<p class="statement">{NO_RETAINER_NOTICE}</p>
|
||||
<p>
|
||||
I ask for the other parties and their counsel because I cannot accept
|
||||
an appointment before conflicts are checked, and that check needs
|
||||
names. Please keep the summary short and leave privileged or
|
||||
confidential detail out of it — the call is for that.
|
||||
</p>
|
||||
<p>
|
||||
What is collected, where it is stored, how long it is kept, and how to
|
||||
have it deleted are set out in the <a href="/legal/privacy/"
|
||||
>privacy policy</a
|
||||
>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ---- 3. The intake form -------------------------------------------- */}
|
||||
<section class="section reveal">
|
||||
<div class="wrap">
|
||||
<div class="section-head">
|
||||
<SectionHeading
|
||||
eyebrow="Intake"
|
||||
level={2}
|
||||
lede="Required fields are marked. Nothing here is a retainer or an appointment."
|
||||
>
|
||||
<span slot="heading">Tell me about the matter.</span>
|
||||
</SectionHeading>
|
||||
</div>
|
||||
|
||||
{
|
||||
/* `novalidate` IS DELIBERATELY ABSENT. The browser's own validation is
|
||||
the only client-side validation on this page, and with no script it is
|
||||
also the only thing that can announce an error inline — which is what
|
||||
`docs/05`'s `role="alert"` item is really asking for. The Lambda
|
||||
re-validates everything regardless; see `src/data/intake.ts`. */
|
||||
}
|
||||
<form class="intake" method="post" action={INTAKE_ACTION}>
|
||||
{
|
||||
INTAKE_FIELDS.map((field) => (
|
||||
<div class={`field field-${field.type}`}>
|
||||
{field.type === 'radio' ? (
|
||||
<fieldset>
|
||||
<legend>{field.label}</legend>
|
||||
<div class="radios">
|
||||
{field.options?.map((option) => (
|
||||
<label class="radio">
|
||||
{/* No default selection. `preferredContact` is
|
||||
optional, and pre-checking "Email" would submit a
|
||||
preference the inquirer never expressed. */}
|
||||
<input type="radio" name={field.name} value={option} />
|
||||
<span>{option}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
) : (
|
||||
<>
|
||||
<label for={field.name}>
|
||||
{field.label}
|
||||
{field.required && (
|
||||
<>
|
||||
{' '}
|
||||
<span class="req" aria-hidden="true">
|
||||
*
|
||||
</span>
|
||||
<span class="visually-hidden">(required)</span>
|
||||
</>
|
||||
)}
|
||||
</label>
|
||||
|
||||
{field.type === 'select' ? (
|
||||
<select
|
||||
id={field.name}
|
||||
name={field.name}
|
||||
required={field.required || undefined}
|
||||
aria-describedby={
|
||||
field.hint ? hintId(field.name) : undefined
|
||||
}
|
||||
>
|
||||
{/* An empty first option, so a required select cannot be
|
||||
satisfied by whichever value happened to be first. */}
|
||||
<option value="">Choose one</option>
|
||||
{field.options?.map((option) => (
|
||||
<option value={option}>{option}</option>
|
||||
))}
|
||||
</select>
|
||||
) : field.type === 'textarea' ? (
|
||||
<textarea
|
||||
id={field.name}
|
||||
name={field.name}
|
||||
rows="6"
|
||||
maxlength={field.max}
|
||||
required={field.required || undefined}
|
||||
aria-describedby={
|
||||
field.hint ? hintId(field.name) : undefined
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
type={field.type}
|
||||
id={field.name}
|
||||
name={field.name}
|
||||
maxlength={field.max}
|
||||
autocomplete={field.autocomplete}
|
||||
required={field.required || undefined}
|
||||
aria-describedby={
|
||||
field.hint ? hintId(field.name) : undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{field.hint && (
|
||||
<p class="hint" id={hintId(field.name)}>
|
||||
{field.hint}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
}
|
||||
|
||||
{
|
||||
/* THE HONEYPOT. Hidden from sighted users by `display: none` on the
|
||||
wrapper, from assistive technology by `aria-hidden`, and from the
|
||||
keyboard by `tabindex="-1"` — all three, because any one alone leaves
|
||||
a real visitor able to reach a field that silently discards their
|
||||
inquiry. `autocomplete="off"` matters more here than anywhere else on
|
||||
the form: a browser that helpfully fills a plausible-looking field
|
||||
would make a human look like a bot. */
|
||||
}
|
||||
<div class="honeypot" aria-hidden="true">
|
||||
<label for={HONEYPOT_FIELD}>Company website</label>
|
||||
<input
|
||||
type="text"
|
||||
id={HONEYPOT_FIELD}
|
||||
name={HONEYPOT_FIELD}
|
||||
tabindex="-1"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{
|
||||
/* ⚠️ THE LINK CAME OUT OF THE LABEL, AND THE LABEL IS NOW THE CONSENT
|
||||
SENTENCE ALONE. Two defects in one element, found by
|
||||
`adversarial-reviewer` 2026-08-31 and measured at 390px (the label
|
||||
was 342 × 205 px and the nested anchor hit-tested as `<a>`,
|
||||
100 × 21):
|
||||
|
||||
1. **A focusable interactive element inside a `<label>` for another
|
||||
control.** Clicking it navigated rather than toggling, which is
|
||||
the behaviour a reader wants — but label/link nesting is not
|
||||
consistent across engines, so which of the two wins was left to
|
||||
the browser.
|
||||
2. **The checkbox's accessible name was a 250-character paragraph
|
||||
ending "Privacy policy."** This is the one REQUIRED control on the
|
||||
form, so it is also the one whose name is re-announced on every
|
||||
validation failure.
|
||||
|
||||
The consent wording still has to be what the inquirer agrees to, so
|
||||
it stays in the label verbatim from `CONSENT_TEXT`. The link moves to
|
||||
a sibling that `aria-describedby` points at — described, not named.
|
||||
The privacy policy is also linked twice above this form, so nothing
|
||||
is lost. */
|
||||
}
|
||||
<div class="field field-consent">
|
||||
<label class="consent">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="consent"
|
||||
value="on"
|
||||
required
|
||||
aria-describedby="consent-privacy"
|
||||
/>
|
||||
<span>{CONSENT_TEXT}</span>
|
||||
</label>
|
||||
<p class="consent-note" id="consent-privacy">
|
||||
How that information is handled, and how to have it deleted: <a
|
||||
href="/legal/privacy/">privacy policy</a
|
||||
>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{
|
||||
/* ⚠️ `<Button type="submit">`, NOT a hand-written `<button class="btn">`.
|
||||
`.btn` and `.btn-primary` are SCOPED TO `Button.astro`, so a raw
|
||||
button carrying those class names compiles against this page's cid,
|
||||
matches nothing, and renders as an unstyled default button — the
|
||||
parent-scope trap `CLAUDE.md` records, arrived at from the other
|
||||
direction. The first version of this file did exactly that.
|
||||
AND IT IS WRAPPED IN A DIV THIS PAGE OWNS, for the same rule read
|
||||
forwards: `.submit` on `<Button>` itself would compile to
|
||||
`.submit[cid-of-this-page]` and never match the rendered element. */
|
||||
}
|
||||
<div class="submit">
|
||||
<Button type="submit">Send the inquiry</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<ContactBand />
|
||||
</BaseLayout>
|
||||
|
||||
<style>
|
||||
.hero {
|
||||
padding-block-start: var(--space-9);
|
||||
}
|
||||
.hero-h {
|
||||
margin-block: var(--space-4) var(--space-5);
|
||||
font-size: var(--text-6xl);
|
||||
}
|
||||
.hero-lede {
|
||||
max-inline-size: 58ch;
|
||||
font-size: var(--text-lg);
|
||||
line-height: var(--leading-body);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* The direct-contact block. A `<dl>` because each row is genuinely a
|
||||
term and its value, which is also what lets the labels stay legible at
|
||||
small sizes without a heading level. */
|
||||
.direct {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(14rem, 100%), 1fr));
|
||||
gap: var(--space-5);
|
||||
margin-block-start: var(--space-8);
|
||||
}
|
||||
.direct dt {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-2xs);
|
||||
letter-spacing: var(--tracking-eyebrow);
|
||||
text-transform: uppercase;
|
||||
color: var(--text-meta);
|
||||
}
|
||||
.direct dd {
|
||||
margin-block-start: var(--space-2);
|
||||
font-size: var(--text-base);
|
||||
line-height: var(--leading-snug);
|
||||
}
|
||||
|
||||
/* The no-retainer sentence, set larger than the paragraphs under it. On an
|
||||
inverse ground it inherits cream (16.81:1) from `.section-inverse`. */
|
||||
.statement {
|
||||
font-size: var(--text-lg);
|
||||
line-height: var(--leading-body);
|
||||
}
|
||||
|
||||
/* --- The form -------------------------------------------------------- */
|
||||
|
||||
.intake {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(20rem, 100%), 1fr));
|
||||
gap: var(--space-5) var(--space-6);
|
||||
max-inline-size: 56rem;
|
||||
}
|
||||
/* The two long fields span the whole form rather than sitting in a column
|
||||
20rem wide. `1 / -1` works at every column count the auto-fit produces. */
|
||||
.field-textarea,
|
||||
.field-consent,
|
||||
.submit {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
label,
|
||||
legend {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-2xs);
|
||||
letter-spacing: var(--tracking-eyebrow);
|
||||
text-transform: uppercase;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
/* Maroon on cream is 12.29:1, so the asterisk is legible — but it is
|
||||
`aria-hidden` and paired with a visually-hidden "(required)", because
|
||||
colour and a glyph must never be the only carrier of meaning (docs/02). */
|
||||
.req {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
inline-size: 100%;
|
||||
padding: var(--space-3) var(--space-4);
|
||||
font-family: var(--font-sans);
|
||||
/* 1rem, not smaller. iOS Safari zooms the viewport on focus for any font
|
||||
size under 16px, which on a form this long throws the layout sideways. */
|
||||
font-size: var(--text-base);
|
||||
line-height: var(--leading-snug);
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
/* 44px minimum target (WCAG 2.5.8) comes from the padding plus this
|
||||
line-height; measured rather than set with a fixed height, so a longer
|
||||
label or a zoomed root does not crush it. */
|
||||
}
|
||||
textarea {
|
||||
resize: vertical;
|
||||
line-height: var(--leading-body);
|
||||
}
|
||||
input:focus-visible,
|
||||
select:focus-visible,
|
||||
textarea:focus-visible {
|
||||
outline: 2px solid var(--focus-ring);
|
||||
outline-offset: var(--focus-offset);
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: var(--text-sm);
|
||||
line-height: var(--leading-snug);
|
||||
color: var(--text-meta);
|
||||
}
|
||||
|
||||
fieldset {
|
||||
padding: 0;
|
||||
border: none;
|
||||
}
|
||||
.radios {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-4);
|
||||
margin-block-start: var(--space-2);
|
||||
}
|
||||
.radio,
|
||||
.consent {
|
||||
display: flex;
|
||||
gap: var(--space-3);
|
||||
/* The label text next to a control is sentence case and normal size — the
|
||||
mono uppercase treatment above is for the field's own name, and applying
|
||||
it to a paragraph of consent text would be unreadable. */
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--text-base);
|
||||
letter-spacing: var(--tracking-normal);
|
||||
text-transform: none;
|
||||
line-height: var(--leading-body);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
/* ⚠️ 44px MINIMUM, AND IT WAS 25.6px. `docs/02` §Accessibility floor sets
|
||||
44 × 44 for a touch target and `CLAUDE.md` calls that floor a build
|
||||
requirement, not a polish pass. Hit-tested at 390px by
|
||||
`adversarial-reviewer`: the label rect measured 70.6 × **25.6** and the hit
|
||||
height 25px — an 18.4px control plus one line of body text, with no
|
||||
`::after { inset: 0 }` overlay to enlarge it the way the cards on `/` have.
|
||||
WCAG 2.2 SC 2.5.8's 24px was met; this project's own floor was not, and
|
||||
`docs/02` grants no exception for a form control.
|
||||
`min-block-size` rather than padding, so the label grows to the floor and no
|
||||
further — padding would push the two radios apart at every width. */
|
||||
.radio {
|
||||
align-items: center;
|
||||
min-block-size: 44px;
|
||||
}
|
||||
.consent {
|
||||
align-items: flex-start;
|
||||
max-inline-size: var(--width-prose);
|
||||
}
|
||||
/* Indented to the label's text column so it reads as belonging to the
|
||||
checkbox — 1.15rem control plus the flex gap. */
|
||||
.consent-note {
|
||||
margin-block-start: var(--space-3);
|
||||
margin-inline-start: calc(1.15rem + var(--space-3));
|
||||
max-inline-size: var(--width-prose);
|
||||
font-size: var(--text-sm);
|
||||
line-height: var(--leading-body);
|
||||
color: var(--text-meta);
|
||||
}
|
||||
.radio input,
|
||||
.consent input {
|
||||
inline-size: 1.15rem;
|
||||
block-size: 1.15rem;
|
||||
flex: none;
|
||||
padding: 0;
|
||||
/* The checkbox sits on the first line of its own label text rather than at
|
||||
the top of the box, which is where `flex-start` alone would put it. */
|
||||
margin-block-start: 0.25em;
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
|
||||
/* THE HONEYPOT. `display: none` is what keeps it out of the layout AND out of
|
||||
the accessibility tree; `aria-hidden` on the wrapper and `tabindex="-1"` on
|
||||
the input are belt and braces for the case where a future stylesheet
|
||||
un-hides it. Do not swap this for `visibility` or an off-screen position:
|
||||
an off-screen input is still focusable and still announced. */
|
||||
.honeypot {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.submit {
|
||||
justify-self: start;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,98 @@
|
||||
---
|
||||
/**
|
||||
* `/contact/could-not-send/` — the failure half of the intake form's
|
||||
* POST-redirect-GET. Build step 8.
|
||||
*
|
||||
* WHY THIS PAGE EXISTS AT ALL. The site ships zero JavaScript, so a static page
|
||||
* cannot read `?error=` and render a message. The alternatives were: return an
|
||||
* error body from the API (the visitor lands on the API hostname with none of
|
||||
* the site around it), or say nothing (the visitor cannot tell whether the
|
||||
* inquiry arrived, on a form about a live dispute). A named page is the only one
|
||||
* of the three that leaves the reader knowing what happened.
|
||||
*
|
||||
* ⚠️ **IT DOES NOT LIST WHICH FIELD FAILED, AND THAT IS DELIBERATE ON TWO
|
||||
* COUNTS.** The handler deliberately does not return the error list — an
|
||||
* enumeration of the validation rules is a gift to whoever is probing them — and
|
||||
* the browser's own `required` / `type="email"` / `maxlength` handling has
|
||||
* already caught every case a person is likely to hit, inline and announced. A
|
||||
* server-side rejection means the submission was not made by that markup.
|
||||
*
|
||||
* ⚠️ **NO APOLOGY AND NO GUESS AT THE CAUSE.** "Something went wrong on our end"
|
||||
* is a claim about which end, and this page cannot know. It says what is true —
|
||||
* the inquiry was not recorded — and gives a route that does not depend on the
|
||||
* form working.
|
||||
*
|
||||
* `noindex`, and excluded from the sitemap in `astro.config.mjs`.
|
||||
*/
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||
import Button from '../../components/Button.astro';
|
||||
import Eyebrow from '../../components/Eyebrow.astro';
|
||||
import { getImage } from 'astro:assets';
|
||||
import ogDefault from '../../assets/og-portrait.jpg';
|
||||
import { pageGraph } from '../../data/schema';
|
||||
import { CONTACT } from '../../data/site';
|
||||
|
||||
const ldImage = await getImage({
|
||||
src: ogDefault,
|
||||
format: 'jpeg',
|
||||
width: 1200,
|
||||
height: 630,
|
||||
});
|
||||
const graph = pageGraph(new URL(ldImage.src, Astro.site).href);
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="Inquiry Not Sent · Contact · Pouya Lajevardi · Toronto"
|
||||
description="The inquiry was not recorded, so nothing has been received. Email the same details directly and they will be answered within two business days."
|
||||
jsonLd={graph}
|
||||
noindex
|
||||
>
|
||||
<section class="section hero">
|
||||
<div class="wrap">
|
||||
<Eyebrow dot>Not sent</Eyebrow>
|
||||
<h1 class="display hero-h">That inquiry was not recorded.</h1>
|
||||
<div class="prose">
|
||||
<p class="statement">
|
||||
Nothing has been received, so there is nothing waiting for a reply.
|
||||
</p>
|
||||
<p>
|
||||
The quickest route is email. Send the same details — your name, your
|
||||
role, the other parties, and a few sentences about the dispute — to <a
|
||||
href={`mailto:${CONTACT.email}`}>{CONTACT.email}</a
|
||||
>, and leave privileged detail out of it. {CONTACT.responseTime}
|
||||
</p>
|
||||
<p>
|
||||
Or go back to the form and send it again. If it fails a second time,
|
||||
email rather than trying a third.
|
||||
</p>
|
||||
</div>
|
||||
<div class="cta">
|
||||
<Button href="/contact/">Back to the form</Button>
|
||||
<Button href={`mailto:${CONTACT.email}`} variant="ghost">
|
||||
Email instead →
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</BaseLayout>
|
||||
|
||||
<style>
|
||||
.hero {
|
||||
padding-block: var(--space-9) var(--space-11);
|
||||
}
|
||||
.hero-h {
|
||||
margin-block: var(--space-4) var(--space-5);
|
||||
font-size: var(--text-5xl);
|
||||
}
|
||||
.statement {
|
||||
font-size: var(--text-lg);
|
||||
line-height: var(--leading-body);
|
||||
color: var(--text);
|
||||
}
|
||||
.cta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-3) var(--space-4);
|
||||
margin-block-start: var(--space-8);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,114 @@
|
||||
---
|
||||
/**
|
||||
* `/contact/received/` — the GET half of the intake form's POST-redirect-GET.
|
||||
* Build step 8. `backend/intake/handler.mjs` sends a `303 See Other` here on
|
||||
* success.
|
||||
*
|
||||
* WHY A PAGE RATHER THAN A RESPONSE BODY. The site ships zero JavaScript, so the
|
||||
* form is a plain POST; without this redirect the visitor would be looking at
|
||||
* whatever the API returned, on the API's own hostname, with none of the site
|
||||
* around it. Landing on a GET also means a refresh cannot resubmit.
|
||||
*
|
||||
* `noindex` — it is a transactional page with no standalone value, and a search
|
||||
* result reading "your inquiry has been received" for someone who has not sent
|
||||
* one is worse than no result. It is excluded from the sitemap in
|
||||
* `astro.config.mjs` for the same reason.
|
||||
*
|
||||
* ⚠️ THE RESPONSE-TIME SENTENCE IS A PUBLIC COMMITMENT (§4, Q27) and must read
|
||||
* identically here, on `/contact/`, and in the confirmation email the handler
|
||||
* sends. Rendered from `CONTACT.responseTime`; never retyped, never softened.
|
||||
*
|
||||
* ⚠️ AND A BOT THAT TRIPS THE HONEYPOT IS SENT HERE TOO — deliberately, see the
|
||||
* handler. So this page must not say anything that is false for that case. It
|
||||
* says what was done, not what will happen to a specific record: "received"
|
||||
* covers a stored submission, and nothing here promises a reply to a submission
|
||||
* that was discarded.
|
||||
*/
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||
import Button from '../../components/Button.astro';
|
||||
import Eyebrow from '../../components/Eyebrow.astro';
|
||||
import { getImage } from 'astro:assets';
|
||||
import ogDefault from '../../assets/og-portrait.jpg';
|
||||
import { pageGraph } from '../../data/schema';
|
||||
import { CONTACT, NO_RETAINER_NOTICE } from '../../data/site';
|
||||
|
||||
const ldImage = await getImage({
|
||||
src: ogDefault,
|
||||
format: 'jpeg',
|
||||
width: 1200,
|
||||
height: 630,
|
||||
});
|
||||
const graph = pageGraph(new URL(ldImage.src, Astro.site).href);
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="Inquiry Received · Contact · Pouya Lajevardi · Toronto"
|
||||
description="Your inquiry has been received. A confirmation goes to the address you gave, inquiries are answered within two business days, and nothing further is needed."
|
||||
jsonLd={graph}
|
||||
noindex
|
||||
>
|
||||
<section class="section hero">
|
||||
<div class="wrap">
|
||||
<Eyebrow dot>Received</Eyebrow>
|
||||
<h1 class="display hero-h">Your inquiry has been received.</h1>
|
||||
<div class="prose">
|
||||
<p class="statement">{CONTACT.responseTime}</p>
|
||||
{
|
||||
/* ⚠️ "A confirmation HAS BEEN SENT" WAS A STATEMENT OF FACT THAT TWO
|
||||
PATHS REACH THIS PAGE WITHOUT HAVING MADE TRUE, and this file's own
|
||||
header already said it must not be: *"this page must not say anything
|
||||
that is false for that case. It says what was done, not what will
|
||||
happen to a specific record."*
|
||||
|
||||
(a) The honeypot returns `redirect(SUCCESS)` before any write or any
|
||||
send — deliberately, because telling a bot it was detected is how the
|
||||
next bot stops filling the field. (b) The handler sends the two
|
||||
emails with `Promise.allSettled` and redirects here even if both
|
||||
reject, because the submission is already stored and a second attempt
|
||||
would duplicate the record.
|
||||
|
||||
So the receipt is stated as what happens rather than as what
|
||||
happened, and the clause after it is the route out either way. Found
|
||||
by `adversarial-reviewer`, 2026-08-31. */
|
||||
}
|
||||
<p>
|
||||
A confirmation goes to the email address you gave, repeating what you
|
||||
sent and linking to the privacy policy. If it has not arrived within a
|
||||
few minutes, check the address and email me directly at <a
|
||||
href={`mailto:${CONTACT.email}`}>{CONTACT.email}</a
|
||||
> — that reaches me whether or not the receipt did.
|
||||
</p>
|
||||
<p>{NO_RETAINER_NOTICE}</p>
|
||||
</div>
|
||||
<div class="cta">
|
||||
<Button href="/process/" variant="ghost"
|
||||
>What happens next →</Button
|
||||
>
|
||||
<Button href="/fees/" variant="ghost">The rate card →</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</BaseLayout>
|
||||
|
||||
<style>
|
||||
/* No ContactBand: the reader has just used the contact form, and inviting
|
||||
them to contact again is the one place that band would read as a defect. */
|
||||
.hero {
|
||||
padding-block: var(--space-9) var(--space-11);
|
||||
}
|
||||
.hero-h {
|
||||
margin-block: var(--space-4) var(--space-5);
|
||||
font-size: var(--text-5xl);
|
||||
}
|
||||
.statement {
|
||||
font-size: var(--text-lg);
|
||||
line-height: var(--leading-body);
|
||||
color: var(--text);
|
||||
}
|
||||
.cta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-3) var(--space-4);
|
||||
margin-block-start: var(--space-8);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,469 @@
|
||||
---
|
||||
/**
|
||||
* `/fees/` — build step 9. Spec: docs/01 §`/fees/`, docs/03 §Fees,
|
||||
* **docs/07-fees.md is the authority on every number here.**
|
||||
*
|
||||
* ⚠️ **EVERY FIGURE IS INTERPOLATED FROM `FEES`. NOT ONE IS TYPED.** This is the
|
||||
* page D8 commits to and the one where a hand-typed number would be an incorrect
|
||||
* price rather than an untidy fact. `docs/07` §All parameters confirmed sets the
|
||||
* publication rules the interpolation has to satisfy, and two of them are
|
||||
* wording rather than value:
|
||||
*
|
||||
* 1. **The preparation allowance is CAPPED and must READ as capped** —
|
||||
* *"including **up to** 2 hours of preparation"*. Never "including 2 hours",
|
||||
* which sells an entitlement, and never "preparation included", which sells
|
||||
* an uncapped one. `/for-parties/` shipped the flat form for one pass, on the
|
||||
* one page written for a reader with no counsel to catch it.
|
||||
* 2. **The session cap and the preparation allowance are DIFFERENT THINGS with
|
||||
* different nouns** — `hours` is time in the session, `prepIncluded` is
|
||||
* preparation bundled into the fee. Folding them into one figure is the
|
||||
* ambiguity Q58 was opened to fix, and it was an ambiguity in `docs/07`
|
||||
* itself rather than in any copy.
|
||||
*
|
||||
* ✅ **Q59 IS RULED AND THIS PAGE IS WHY IT MATTERED — Pouya, 2026-08-31.**
|
||||
* Overtime runs from the **session cap**: the fourth hour of a half day, the
|
||||
* seventh of a full day. Until that ruling this page could not publish the
|
||||
* $500 rate at all, because a rate printed beside "up to 3 hours" defines its
|
||||
* own trigger by adjacency and there was no other quantity for it to attach to.
|
||||
*
|
||||
* ⚠️ **AND THE RULING'S SECOND HALF IS NOT DECORATION — IT IS WHAT KEEPS THE
|
||||
* PAGE FROM READING AS AN ARITHMETIC MISTAKE.** `FEES.mediation.reservation`:
|
||||
* *a full day reserves the day; half-day overtime is subject to availability.*
|
||||
* Without it a reader adds up `2000 + 500 × 3 = 3500` against `4000` and
|
||||
* concludes the full-day rate is simply worse — which is a real feature of D14's
|
||||
* figures (§12 R5 carries it, with the table in `docs/07` §Recorded dissent) and
|
||||
* is answered by what the full-day fee actually buys. So the reservation
|
||||
* sentence ships **adjacent to the overtime row**, not in a footnote. Same
|
||||
* structural rule as `PROCESS_FRAMING` beside the five timings under Q43.
|
||||
*
|
||||
* ⚠️ **NO TRIBUNAL-SECRETARY RATE AND NO SETTLEMENT COUNSEL.** Both are struck
|
||||
* rows in §4 Offerings — the first removed by Pouya from D14, the second by him
|
||||
* as his own error in `docs/01`. **A rate on a fee page is an offer**, which is
|
||||
* exactly why they are struck here rather than merely unpriced.
|
||||
*
|
||||
* ⚠️ **ARBITRATION IS SCOPED COMMERCIAL, MEDIATION IS NOT.** The asymmetry is
|
||||
* designed (Q39, Q56): family arbitration in Ontario carries prescribed training
|
||||
* and is separately NOT OFFERED, so the scope on the arbitration rows is a legal
|
||||
* gate. Mediation has no equivalent gate and is unscoped on purpose. A later
|
||||
* editor tidying these into a matching pair would reintroduce the defect.
|
||||
*/
|
||||
import BaseLayout from '../layouts/BaseLayout.astro';
|
||||
import Button from '../components/Button.astro';
|
||||
import ContactBand from '../components/ContactBand.astro';
|
||||
import Eyebrow from '../components/Eyebrow.astro';
|
||||
import SectionHeading from '../components/SectionHeading.astro';
|
||||
import { getImage } from 'astro:assets';
|
||||
import ogDefault from '../assets/og-portrait.jpg';
|
||||
import { pageGraph } from '../data/schema';
|
||||
import { FEES } from '../data/site';
|
||||
|
||||
const ldImage = await getImage({
|
||||
src: ogDefault,
|
||||
format: 'jpeg',
|
||||
width: 1200,
|
||||
height: 630,
|
||||
});
|
||||
|
||||
/* No `Service` node, and no `Offer` node either. The Person alone — the
|
||||
`/practice/` and `/process/` shape. `/mediation/` and `/arbitration/` already
|
||||
carry the `Service` nodes for what is priced here, and a second one on this
|
||||
path would put a duplicate `@id` in the graph. An `Offer` with `price` would
|
||||
be the obvious addition and is deliberately not made: schema.org's `Offer`
|
||||
models a single price for a single item, and every row below is conditional on
|
||||
session length, party count and format — a machine-readable $2,000 with none
|
||||
of those conditions attached is a worse claim than no claim.
|
||||
`ProfessionalService.priceRange` on `/` carries the range instead. */
|
||||
const graph = pageGraph(new URL(ldImage.src, Astro.site).href);
|
||||
|
||||
const money = (amount: number) =>
|
||||
new Intl.NumberFormat('en-CA', {
|
||||
style: 'currency',
|
||||
currency: FEES.currency,
|
||||
maximumFractionDigits: 0,
|
||||
}).format(amount);
|
||||
|
||||
const { halfDay, fullDay } = FEES.mediation;
|
||||
|
||||
/* The two mediation rows, built from the constants so the noun and the "up to"
|
||||
travel with the number rather than being retyped beside it. */
|
||||
const MEDIATION_ROWS = [
|
||||
{
|
||||
item: `Half day — up to ${halfDay.hours} hours of session`,
|
||||
detail: `Fee includes up to ${halfDay.prepIncluded} hours of preparation.`,
|
||||
fee: money(halfDay.amount),
|
||||
},
|
||||
{
|
||||
item: `Full day — up to ${fullDay.hours} hours of session`,
|
||||
detail: `Fee includes up to ${fullDay.prepIncluded} hours of preparation.`,
|
||||
fee: money(fullDay.amount),
|
||||
},
|
||||
{
|
||||
item: 'Each party beyond two',
|
||||
detail: 'Per party, added to the session fee.',
|
||||
fee: money(FEES.mediation.additionalParty),
|
||||
},
|
||||
{
|
||||
item: 'Overtime, per hour',
|
||||
/* Q59: the trigger is the SESSION cap, and the reservation point ships in
|
||||
the same cell as the rate. See the header for why it is not a footnote. */
|
||||
detail: `Charged beyond the ${halfDay.hours} or ${fullDay.hours} session hours the fee covers. ${FEES.mediation.reservation}`,
|
||||
fee: money(FEES.mediation.overtimePerHour),
|
||||
},
|
||||
];
|
||||
|
||||
const ARBITRATION_ROWS = [
|
||||
{
|
||||
item: 'Hourly',
|
||||
detail: 'Procedural work, document review, award writing.',
|
||||
fee: money(FEES.arbitration.perHour),
|
||||
},
|
||||
{
|
||||
item: 'Hearing day',
|
||||
detail: 'In person or by video, at the same rate.',
|
||||
fee: money(FEES.arbitration.hearingDay),
|
||||
},
|
||||
{
|
||||
item: 'Documents-only or expedited — simple',
|
||||
detail: 'Flat fee, agreed in the first procedural order.',
|
||||
fee: money(FEES.arbitration.documentsOnlySimple),
|
||||
},
|
||||
{
|
||||
item: 'Documents-only or expedited — complex',
|
||||
detail: 'Flat fee. Which band applies is settled before the appointment.',
|
||||
fee: money(FEES.arbitration.documentsOnlyComplex),
|
||||
},
|
||||
];
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="Fees · Mediation and Arbitration Rates · Pouya Lajevardi"
|
||||
description="The full rate card: half-day and full-day mediation, arbitration, cancellation terms and what an overrun costs. Published in full, with no ranges."
|
||||
jsonLd={graph}
|
||||
>
|
||||
{/* ---- 1. Hero -------------------------------------------------------- */}
|
||||
<section class="section hero">
|
||||
<div class="wrap">
|
||||
<Eyebrow dot>Fees</Eyebrow>
|
||||
<h1 class="display hero-h">
|
||||
Published in full, including what overruns cost.
|
||||
</h1>
|
||||
{
|
||||
/* ⚠️ THIS SENTENCE QUOTED A PHRASE THE SPEC BARS, AND THE QUOTATION WAS
|
||||
THE PROBLEM. It read: *No ranges, no "starting from", and nothing that
|
||||
has to be asked for.* Two defects in one clause. (1) It defines the
|
||||
page against an unnamed practice — an implied comparative, which Q41(b)
|
||||
answers: assert his capability, never the field's. (2) It plants the
|
||||
literal string `starting from` in `dist/`, where a future sweep for
|
||||
`docs/03`'s "no 'starting from' evasions" would hit it and read a
|
||||
negation as a breach — the `I aLSO practise` / `the pLEADINGs` shape,
|
||||
manufactured on purpose by the copy. Stating what the page DOES needs
|
||||
no comparison and leaves nothing to trip over. */
|
||||
}
|
||||
<p class="hero-lede">
|
||||
One rate for all mediation matters, whatever the subject. Every figure
|
||||
is on this page, and none of it has to be asked for. {FEES.taxNote}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ---- 2. Mediation --------------------------------------------------- */}
|
||||
<section class="section section-alt reveal">
|
||||
<div class="wrap">
|
||||
<div class="section-head">
|
||||
<SectionHeading
|
||||
eyebrow="Mediation"
|
||||
level={2}
|
||||
lede="One rate for every matter. Preparation is bundled into the fee and is capped."
|
||||
>
|
||||
<span slot="heading">Half day or full day.</span>
|
||||
</SectionHeading>
|
||||
</div>
|
||||
<dl class="rates">
|
||||
{
|
||||
MEDIATION_ROWS.map((row) => (
|
||||
<div class="rate">
|
||||
<dt>
|
||||
<span class="rate-item">{row.item}</span>
|
||||
<span class="rate-detail">{row.detail}</span>
|
||||
</dt>
|
||||
<dd>{row.fee}</dd>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</dl>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ---- 3. Arbitration ------------------------------------------------- */}
|
||||
<section class="section reveal">
|
||||
<div class="wrap">
|
||||
<div class="section-head">
|
||||
<SectionHeading
|
||||
eyebrow="Arbitration"
|
||||
level={2}
|
||||
lede="Sole, party-appointed and co-arbitration appointments, in commercial matters."
|
||||
>
|
||||
<span slot="heading">Hourly, by hearing day, or flat.</span>
|
||||
</SectionHeading>
|
||||
</div>
|
||||
<dl class="rates">
|
||||
{
|
||||
ARBITRATION_ROWS.map((row) => (
|
||||
<div class="rate">
|
||||
<dt>
|
||||
<span class="rate-item">{row.item}</span>
|
||||
<span class="rate-detail">{row.detail}</span>
|
||||
</dt>
|
||||
<dd>{row.fee}</dd>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</dl>
|
||||
{
|
||||
/* The Q39 scope, stated on the page rather than left to the lede. §4
|
||||
Offerings carries a NOT OFFERED row for family arbitration, and
|
||||
`/practice/shareholder/` makes the same exclusion in one sentence on
|
||||
Pouya's instruction — "one sentence, not a section", because a
|
||||
disclaimer that grows reads as defensive. */
|
||||
}
|
||||
<p class="scope-note">
|
||||
Family arbitration under the <em>Family Law Act</em> is not offered, and family
|
||||
law matters are not accepted.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ---- 4. Other services ---------------------------------------------- */}
|
||||
<section class="section section-alt reveal">
|
||||
<div class="wrap">
|
||||
<div class="section-head">
|
||||
<SectionHeading
|
||||
eyebrow="Also offered"
|
||||
level={2}
|
||||
lede="Charged hourly, with an estimate agreed in the terms of appointment."
|
||||
>
|
||||
<span slot="heading">Three things beside the two processes.</span>
|
||||
</SectionHeading>
|
||||
</div>
|
||||
<dl class="rates">
|
||||
<div class="rate">
|
||||
<dt>
|
||||
<span class="rate-item">Early neutral evaluation</span>
|
||||
{
|
||||
/* §4's ENE row and `docs/01` §`/practice/` both require this
|
||||
framing, and `docs/07` repeats it for this page in terms:
|
||||
"nothing on `/fees/` may read as a rate for advising one of
|
||||
them." ENE is the offering nearest §4's NOT-NEGOTIABLE boundary,
|
||||
because a neutral assessment of the merits sits closest to
|
||||
providing legal services. */
|
||||
}
|
||||
<span class="rate-detail">
|
||||
A reasoned assessment of the merits, delivered to both parties
|
||||
together. Never advice to one of them.
|
||||
</span>
|
||||
</dt>
|
||||
<dd>{money(FEES.hourly)}<span class="per"> / hour</span></dd>
|
||||
</div>
|
||||
<div class="rate">
|
||||
<dt>
|
||||
<span class="rate-item">Dispute-system design</span>
|
||||
<span class="rate-detail">
|
||||
Advising an organisation on how its future disputes should be
|
||||
handled, before there are any.
|
||||
</span>
|
||||
</dt>
|
||||
<dd>{money(FEES.hourly)}<span class="per"> / hour</span></dd>
|
||||
</div>
|
||||
<div class="rate">
|
||||
<dt>
|
||||
<span class="rate-item">Pre-dispute technical advisory</span>
|
||||
{
|
||||
/* THE CONFLICT CAUTION IS NOT OPTIONAL. §4's row: "no copy may
|
||||
imply the offering is free of that tension", and it names
|
||||
`/practice/`'s strip as where the temptation would arise. A fee
|
||||
page is the second such place, because a priced line reads as a
|
||||
product. Advisory work for one organisation can conflict against
|
||||
a later appointment in the same matter. */
|
||||
}
|
||||
<span class="rate-detail">
|
||||
Technical review before a dispute exists. Taking it on can rule me
|
||||
out of a later appointment in the same matter, and that is settled
|
||||
in writing before the work starts.
|
||||
</span>
|
||||
</dt>
|
||||
<dd>{money(FEES.hourly)}<span class="per"> / hour</span></dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ---- 5. Cancellation ------------------------------------------------ */}
|
||||
<section class="section reveal">
|
||||
<div class="wrap">
|
||||
<div class="section-head">
|
||||
<SectionHeading
|
||||
eyebrow="Cancellation"
|
||||
level={2}
|
||||
lede="A reserved date is time that cannot be given to another matter. The schedule is published so it is never a surprise."
|
||||
>
|
||||
<span slot="heading">If a date is cancelled.</span>
|
||||
</SectionHeading>
|
||||
</div>
|
||||
<dl class="rates">
|
||||
{
|
||||
FEES.cancellation.map((row) => (
|
||||
<div class="rate">
|
||||
<dt>
|
||||
<span class="rate-item">{row.window}</span>
|
||||
</dt>
|
||||
<dd class="dd-text">{row.fee}</dd>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</dl>
|
||||
<ul class="notes" role="list">
|
||||
{FEES.cancellationNotes.map((note) => <li>{note}</li>)}
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ---- 6. Terms -------------------------------------------------------- */}
|
||||
<section class="section section-inverse reveal">
|
||||
<div class="wrap">
|
||||
<div class="section-head">
|
||||
<SectionHeading eyebrow="Terms" level={2}>
|
||||
<span slot="heading">How the account works.</span>
|
||||
</SectionHeading>
|
||||
</div>
|
||||
<ul class="notes" role="list">
|
||||
<li>{FEES.taxNote}</li>
|
||||
{FEES.terms.map((term) => <li>{term}</li>)}
|
||||
<li>
|
||||
Travel outside the Greater Toronto Area is billed separately, or
|
||||
bundled at a day rate stated in the terms of appointment.
|
||||
</li>
|
||||
<li>
|
||||
Everything above is confirmed in the terms of appointment before an
|
||||
engagement begins. Nothing on this page is an appointment.
|
||||
</li>
|
||||
</ul>
|
||||
<div class="cta">
|
||||
<Button href="/contact/" variant="gold"
|
||||
>Request a consultation →</Button
|
||||
>
|
||||
<Button href="/process/" variant="ghost"
|
||||
>How an engagement runs →</Button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<ContactBand />
|
||||
</BaseLayout>
|
||||
|
||||
<style>
|
||||
.hero {
|
||||
padding-block-start: var(--space-9);
|
||||
}
|
||||
.hero-h {
|
||||
margin-block: var(--space-4) var(--space-5);
|
||||
font-size: var(--text-6xl);
|
||||
}
|
||||
.hero-lede {
|
||||
max-inline-size: 58ch;
|
||||
font-size: var(--text-lg);
|
||||
line-height: var(--leading-body);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* --- The rate rows ---------------------------------------------------- */
|
||||
|
||||
/* A `<dl>`, not a `<table>`. Each row is one item and its price — a
|
||||
term-and-value pair — and a two-column table of eight rows reflows badly on
|
||||
a phone, where the price ends up under a wrapped item name with no
|
||||
alignment left to carry the association. The `<dt>`/`<dd>` pair keeps that
|
||||
association semantically whatever the layout does. */
|
||||
.rates {
|
||||
display: grid;
|
||||
gap: 0;
|
||||
max-inline-size: 56rem;
|
||||
border-block-start: 1px solid var(--border);
|
||||
}
|
||||
.rate {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
gap: var(--space-3) var(--space-5);
|
||||
padding-block: var(--space-5);
|
||||
border-block-end: 1px solid var(--border);
|
||||
}
|
||||
.rate dt {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
/* Leaves room for the fee on one line at tablet width and up, and wraps
|
||||
under it below that. */
|
||||
flex: 1 1 22rem;
|
||||
}
|
||||
.rate-item {
|
||||
font-size: var(--text-lg);
|
||||
line-height: var(--leading-snug);
|
||||
}
|
||||
.rate-detail {
|
||||
font-size: var(--text-sm);
|
||||
line-height: var(--leading-body);
|
||||
color: var(--text-meta);
|
||||
max-inline-size: 52ch;
|
||||
}
|
||||
.rate dd {
|
||||
font-family: var(--font-serif);
|
||||
font-size: var(--text-2xl);
|
||||
line-height: var(--leading-tight);
|
||||
white-space: nowrap;
|
||||
}
|
||||
/* The cancellation column is a sentence, not a figure, so it takes body type
|
||||
and is allowed to wrap. */
|
||||
.rate .dd-text {
|
||||
flex: 1 1 16rem;
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--text-base);
|
||||
line-height: var(--leading-body);
|
||||
color: var(--text-secondary);
|
||||
white-space: normal;
|
||||
}
|
||||
.per {
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-meta);
|
||||
}
|
||||
|
||||
.scope-note {
|
||||
margin-block-start: var(--space-6);
|
||||
max-inline-size: var(--width-prose);
|
||||
font-size: var(--text-base);
|
||||
line-height: var(--leading-body);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.notes {
|
||||
/* No `list-style: none` or `padding: 0` — `global.css` applies both to
|
||||
`ul[role='list']`, and a second copy is a second thing to keep true. */
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
margin-block-start: var(--space-6);
|
||||
max-inline-size: var(--width-prose);
|
||||
}
|
||||
.notes li {
|
||||
padding-inline-start: var(--space-5);
|
||||
border-inline-start: 1px solid var(--rule);
|
||||
line-height: var(--leading-body);
|
||||
}
|
||||
|
||||
.cta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-3) var(--space-4);
|
||||
margin-block-start: var(--space-8);
|
||||
}
|
||||
</style>
|
||||
+75
-10
@@ -10,15 +10,26 @@
|
||||
* 1 Hero · 2 Credential row · 3 The approach · 4 Two practices ·
|
||||
* 5 Practice areas · 6 Process preview · 7 Latest insights · 8 Contact band
|
||||
*
|
||||
* SECTION 7 IS NOT BUILT, DELIBERATELY, and this is the only spec item this
|
||||
* page does not deliver. `src/content/insights/` is empty: the collection ships
|
||||
* at build step 7, which is also where `ArticleCard` and the drafted slate
|
||||
* arrive (docs/01 §Build order; docs/03 §Launch article slate, D9). Rendering
|
||||
* the section now means importing a component with nothing to render — its
|
||||
* scoped CSS ships to every visitor for an empty block — and a props surface
|
||||
* with no call site, which is already an open finding against InfinityMark.
|
||||
* SiteHeader gates the Insights NAV item on the same collection, so the page
|
||||
* and the nav appear together. Do not "finish" this by hardcoding placeholders.
|
||||
* SECTION 7 IS BUILT AS OF STEP 7b AND RENDERS NOTHING TODAY. The markup is
|
||||
* behind `latest.length > 0`, so with no published article no card, no heading
|
||||
* and no link is emitted. D9 means the flip is Pouya's — the schema refuses
|
||||
* `draft: false` without `reviewedByPouya: true` — and `SiteHeader` gates the
|
||||
* Insights NAV item on the same predicate at two pieces. Do not "finish" this by
|
||||
* hardcoding a placeholder card.
|
||||
*
|
||||
* ⚠️ **THE STEP-2 REASONING FOR DEFERRING THIS SECTION WAS THAT AN UNRENDERED
|
||||
* COMPONENT STILL SHIPS ITS CSS. THAT IS TRUE, AND IT IS NOW MEASURED RATHER
|
||||
* THAN ARGUED:** importing `ArticleCard` puts **10 rules, 1,496 bytes, 4.4% of
|
||||
* `dist/index.html`** into this page for a block that renders nothing. Astro
|
||||
* bundles a component's scoped styles on IMPORT, not on render, and
|
||||
* `inlineStylesheets: 'auto'` inlines them here.
|
||||
*
|
||||
* It is kept anyway, and the reason is also a measurement: `npm run lighthouse -- /`
|
||||
* returns **performance 99, LCP 2.03 s, CLS 0.000 — identical before and after
|
||||
* the 1,498-byte growth.** So the cost is real in bytes and absent in the metric,
|
||||
* on the one page already at `docs/04`'s LCP budget. The dead weight clears
|
||||
* itself the moment an article publishes, which is the same event that makes the
|
||||
* section visible.
|
||||
*
|
||||
* EVERY FACTUAL CLAIM ON THIS PAGE TRACES TO AGENTS.md §4, and the ones that
|
||||
* carry risk are constants from src/data/site.ts rather than typed here.
|
||||
@@ -35,9 +46,11 @@ import Eyebrow from '../components/Eyebrow.astro';
|
||||
import InfinityMark from '../components/InfinityMark.astro';
|
||||
import PracticeCard from '../components/PracticeCard.astro';
|
||||
import ProcessStep from '../components/ProcessStep.astro';
|
||||
import ArticleCard from '../components/ArticleCard.astro';
|
||||
import SectionHeading from '../components/SectionHeading.astro';
|
||||
import portrait from '../assets/pouya-lajevardi.jpg';
|
||||
import ogDefault from '../assets/og-portrait.jpg';
|
||||
import { getCollection } from 'astro:content';
|
||||
import { homeGraph } from '../data/schema';
|
||||
import {
|
||||
ASYMMETRY_LINE,
|
||||
@@ -82,6 +95,14 @@ const ldImage = await getImage({
|
||||
height: 630,
|
||||
});
|
||||
const graph = homeGraph(new URL(ldImage.src, Astro.site).href);
|
||||
|
||||
/* THE THREE MOST RECENT, and the same `!data.draft` predicate the rest of the
|
||||
site uses — see the `draft` field in `src/content.config.ts`. Sorted here
|
||||
rather than trusting the loader's order: `glob()` returns files in directory
|
||||
order, which is alphabetical by filename and has nothing to do with date. */
|
||||
const latest = (await getCollection('insights', ({ data }) => !data.draft))
|
||||
.sort((a, b) => b.data.publishDate.getTime() - a.data.publishDate.getTime())
|
||||
.slice(0, 3);
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
@@ -588,7 +609,40 @@ const graph = homeGraph(new URL(ldImage.src, Astro.site).href);
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ---- 7. Latest insights: NOT BUILT AT STEP 2. See the header note. -- */}
|
||||
{/* ---- 7. Latest insights -------------------------------------------- */}
|
||||
{
|
||||
latest.length > 0 && (
|
||||
<section class="section section-alt reveal">
|
||||
<div class="wrap">
|
||||
<div class="section-head">
|
||||
<SectionHeading
|
||||
eyebrow="Insights"
|
||||
level={2}
|
||||
lede="Notes on process, regulatory change, and the technical record underneath commercial disputes."
|
||||
>
|
||||
<span slot="heading">Recently written.</span>
|
||||
</SectionHeading>
|
||||
</div>
|
||||
<div class="grid-autofit insights-grid" style="--grid-min: 20rem">
|
||||
{latest.map((entry) => (
|
||||
<ArticleCard
|
||||
href={`/insights/${entry.id}/`}
|
||||
title={entry.data.title}
|
||||
description={entry.data.description}
|
||||
date={entry.data.publishDate}
|
||||
topics={entry.data.topics}
|
||||
readingTime={entry.data.readingTime}
|
||||
level={3}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<p class="insights-more">
|
||||
<a href="/insights/">Everything written →</a>
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
{/* ---- 8. Contact band ---------------------------------------------- */}
|
||||
{
|
||||
@@ -602,6 +656,17 @@ const graph = homeGraph(new URL(ldImage.src, Astro.site).href);
|
||||
</BaseLayout>
|
||||
|
||||
<style>
|
||||
/* --- 7. Latest insights --------------------------------------------- */
|
||||
|
||||
/* `.grid-autofit` (global.css) carries the columns and the `min()` guard. */
|
||||
.insights-grid {
|
||||
gap: var(--space-5);
|
||||
}
|
||||
.insights-more {
|
||||
margin-block-start: var(--space-6);
|
||||
font-size: var(--text-base);
|
||||
}
|
||||
|
||||
/* --- 1. Hero -------------------------------------------------------- */
|
||||
|
||||
.hero {
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
---
|
||||
/**
|
||||
* `/insights/<slug>/` — one route, one page per published article. Build step 7b.
|
||||
* Spec: docs/01 §`/insights/`, docs/03 §Insights, docs/04 §Structured data.
|
||||
*
|
||||
* ⚠️ **`getStaticPaths` FILTERS DRAFTS, AND THAT IS WHERE D9 IS ENFORCED IN THE
|
||||
* BUILD.** `src/content.config.ts` refuses `draft: false` without
|
||||
* `reviewedByPouya: true`; this route refuses to generate a page for anything
|
||||
* still `draft: true`. Between them a piece Pouya has not read cannot become a
|
||||
* URL — not by a forgotten flag, not by a sitemap rule, and not by someone
|
||||
* linking to it. Do not add a preview parameter, and do not build drafts under a
|
||||
* different path "for review": the review D9 asks for is of the MDX, and
|
||||
* `npm run dev` renders it the moment the flag flips.
|
||||
*
|
||||
* THE `<h1>` IS `title`, AND THE `<title>` IS `seoTitle ?? title` — docs/04:
|
||||
* articles carry no ` · Pouya Lajevardi` suffix, because the suffix is 18
|
||||
* characters and would put a headline that already reads 50–60 at 68–78. The
|
||||
* schema enforces the length on whichever string is rendered and names the
|
||||
* offending one in the build error.
|
||||
*
|
||||
* EVERY ARTICLE LINKS TO AT LEAST ONE PRACTICE-AREA PAGE, and that is docs/04's
|
||||
* internal-linking requirement rather than a nicety: *"this is what turns
|
||||
* Insights into ranking power for the pages that convert."* It is rendered from
|
||||
* `practiceAreas` in the frontmatter, which the schema requires non-empty — so
|
||||
* an article cannot ship without one, and the link cannot be forgotten in prose.
|
||||
*/
|
||||
import type { GetStaticPaths } from 'astro';
|
||||
import { getCollection, render } from 'astro:content';
|
||||
import { getImage } from 'astro:assets';
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||
import Breadcrumbs from '../../components/Breadcrumbs.astro';
|
||||
import ContactBand from '../../components/ContactBand.astro';
|
||||
import Eyebrow from '../../components/Eyebrow.astro';
|
||||
import Pill from '../../components/Pill.astro';
|
||||
import Prose from '../../components/Prose.astro';
|
||||
import PracticeCard from '../../components/PracticeCard.astro';
|
||||
import SectionHeading from '../../components/SectionHeading.astro';
|
||||
import ogDefault from '../../assets/og-portrait.jpg';
|
||||
import { articleGraph } from '../../data/schema';
|
||||
import { PRACTICE_AREAS } from '../../data/site';
|
||||
import { TOPIC_LABELS, formatArticleDate, isoDate } from '../../data/insights';
|
||||
import { ogCardPath } from '../../data/og-cards';
|
||||
|
||||
export const getStaticPaths = (async () => {
|
||||
const published = await getCollection('insights', ({ data }) => !data.draft);
|
||||
return published.map((entry) => ({
|
||||
params: { slug: entry.id },
|
||||
props: { entry },
|
||||
}));
|
||||
}) satisfies GetStaticPaths;
|
||||
|
||||
const { entry } = Astro.props;
|
||||
const { data } = entry;
|
||||
const { Content } = await render(entry);
|
||||
|
||||
const path = `/insights/${entry.id}/`;
|
||||
|
||||
/* The Person node's image is the PORTRAIT — a photograph of a person. The
|
||||
Article node's image is the article's own generated card. Two different
|
||||
claims in two different fields; see `articleGraph`. */
|
||||
const ldPortrait = await getImage({
|
||||
src: ogDefault,
|
||||
format: 'jpeg',
|
||||
width: 1200,
|
||||
height: 630,
|
||||
});
|
||||
|
||||
const graph = articleGraph({
|
||||
slug: entry.id,
|
||||
headline: data.title,
|
||||
description: data.description,
|
||||
datePublished: data.publishDate,
|
||||
dateModified: data.updatedDate,
|
||||
imageUrl: new URL(ogCardPath(path), Astro.site).href,
|
||||
personImageUrl: new URL(ldPortrait.src, Astro.site).href,
|
||||
});
|
||||
|
||||
/* ONE TRAIL, TWO RENDERINGS — the visible <Breadcrumbs> and the
|
||||
`BreadcrumbList` node inside `articleGraph`, which docs/04 requires to match.
|
||||
`articleGraph` builds its copy from the same three values this renders. */
|
||||
const trail = [
|
||||
{ name: 'Home', href: '/' },
|
||||
{ name: 'Insights', href: '/insights/' },
|
||||
{ name: data.title, href: path },
|
||||
];
|
||||
|
||||
const areas = PRACTICE_AREAS.filter((area) =>
|
||||
(data.practiceAreas as readonly string[]).includes(area.slug),
|
||||
);
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title={data.seoTitle ?? data.title}
|
||||
description={data.description}
|
||||
ogType="article"
|
||||
jsonLd={graph}
|
||||
>
|
||||
{/* ---- 1. Header ------------------------------------------------------ */}
|
||||
<article>
|
||||
<section class="section hero">
|
||||
<div class="wrap">
|
||||
<Breadcrumbs trail={trail} />
|
||||
<Eyebrow dot>Insights</Eyebrow>
|
||||
<h1 class="display hero-h">{data.title}</h1>
|
||||
|
||||
<div class="meta">
|
||||
<time datetime={isoDate(data.publishDate)}>
|
||||
{formatArticleDate(data.publishDate)}
|
||||
</time>
|
||||
<span aria-hidden="true">·</span>
|
||||
<span>{data.readingTime} min read</span>
|
||||
{
|
||||
data.updatedDate && (
|
||||
<>
|
||||
<span aria-hidden="true">·</span>
|
||||
<span>
|
||||
Updated{' '}
|
||||
<time datetime={isoDate(data.updatedDate)}>
|
||||
{formatArticleDate(data.updatedDate)}
|
||||
</time>
|
||||
</span>
|
||||
</>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
|
||||
<ul class="topics" role="list">
|
||||
{
|
||||
data.topics.map((topic) => (
|
||||
<li>
|
||||
<Pill>{TOPIC_LABELS[topic]}</Pill>
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ---- 2. The article ---------------------------------------------- */}
|
||||
<section class="section body-section">
|
||||
<div class="wrap">
|
||||
<Prose>
|
||||
<Content />
|
||||
</Prose>
|
||||
</div>
|
||||
</section>
|
||||
</article>
|
||||
|
||||
{/* ---- 3. Where it applies ------------------------------------------- */}
|
||||
<section class="section section-alt reveal">
|
||||
<div class="wrap">
|
||||
<div class="section-head">
|
||||
<SectionHeading
|
||||
eyebrow="Where this applies"
|
||||
level={2}
|
||||
lede="The practice areas this piece is about."
|
||||
>
|
||||
<span slot="heading">Read next.</span>
|
||||
</SectionHeading>
|
||||
</div>
|
||||
<div class="grid-autofit areas" style="--grid-min: 20rem">
|
||||
{
|
||||
areas.map((area) => (
|
||||
<PracticeCard
|
||||
href={`/practice/${area.slug}/`}
|
||||
chip={area.chip}
|
||||
title={area.name}
|
||||
level={3}
|
||||
>
|
||||
{area.blurb}
|
||||
</PracticeCard>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<ContactBand />
|
||||
</BaseLayout>
|
||||
|
||||
<style>
|
||||
.hero {
|
||||
padding-block-start: var(--space-7);
|
||||
padding-block-end: 0;
|
||||
}
|
||||
.hero-h {
|
||||
/* --text-5xl, not --text-6xl. A headline here is a sentence of 50–60
|
||||
characters rather than the four or five words a landing page carries, and
|
||||
at 96px it takes four lines on a phone before the reader sees a date. */
|
||||
margin-block: var(--space-4) var(--space-5);
|
||||
font-size: var(--text-5xl);
|
||||
max-inline-size: 34ch;
|
||||
}
|
||||
|
||||
.meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 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);
|
||||
}
|
||||
|
||||
.topics {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
margin-block-start: var(--space-5);
|
||||
}
|
||||
|
||||
/* NOT `.reveal`. The article body is the page's reason for existing, and a
|
||||
scroll-driven opacity animation on the thing a reader came for is the one
|
||||
place this site does not use it — it also puts the whole body at the reveal's
|
||||
`from` state for any reader who never scrolls. The sections around it
|
||||
animate; the text does not. */
|
||||
.body-section {
|
||||
padding-block-start: var(--space-8);
|
||||
}
|
||||
|
||||
.areas {
|
||||
gap: var(--space-5);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,204 @@
|
||||
---
|
||||
/**
|
||||
* `/insights/` — the article index. Build step 7b. Spec: docs/01 §`/insights/`,
|
||||
* docs/03 §Insights, docs/04 §Structured data.
|
||||
*
|
||||
* ⚠️ **THIS PAGE SHIPS BEFORE ITS CONTENT DOES, AND THE STATE IT IS IN TODAY IS
|
||||
* A DECISION RATHER THAN AN OVERSIGHT.** D9 requires Pouya to read every word
|
||||
* before publication and `src/content.config.ts` enforces it — the schema refuses
|
||||
* `draft: false` without `reviewedByPouya: true`. So build step 7 could only ever
|
||||
* produce plumbing plus drafts awaiting him; there is no route by which it
|
||||
* produces a live section.
|
||||
*
|
||||
* `docs/01` is explicit about the risk that creates: *"An empty blog signals
|
||||
* abandonment more loudly than no blog signals anything."* Three things hold that
|
||||
* line, and the first two already existed:
|
||||
*
|
||||
* 1. **`SiteHeader` gates the nav item on two published pieces.** Unchanged.
|
||||
* 2. **Drafts produce no page**, so no ARTICLE URL exists to be linked or
|
||||
* indexed. ⚠️ **This bullet claimed "nothing links into an empty section"
|
||||
* and that was false: `SiteFooter` links `/insights/` from all 22 pages**,
|
||||
* ungated — `grep -rlo 'href="/insights/"' dist --include='*.html' | wc -l`
|
||||
* returns 22. Found by `adversarial-reviewer`, 2026-08-31.
|
||||
* **The footer link stays, and gating it was the wrong fix:** `docs/01`
|
||||
* §Navigation specifies the footer as *"Full sitemap in three columns"*, and
|
||||
* a sitemap with a hole in it is a worse artefact than a link to a page
|
||||
* that says, accurately, that nothing is published yet. What was wrong was
|
||||
* the sentence, so the sentence changed.
|
||||
* 3. **`noindex` while the section is empty** — decided at step 7b. A thin
|
||||
* index is a real, if small, discoverability negative, and a crawler is the
|
||||
* one reader who *will* arrive here with nothing published. It is derived
|
||||
* from the collection on every build, so it clears itself the moment the
|
||||
* first article publishes rather than needing to be remembered.
|
||||
*
|
||||
* **What it does NOT do is leave the sitemap** — `astro.config.mjs`'s filter
|
||||
* cannot see collection data, which that file records in terms, and reaching for
|
||||
* frontmatter from build config to fix a temporary state would be worse than the
|
||||
* state. So while the section is empty this URL is in the sitemap and marked
|
||||
* `noindex`, which Search Console reports accurately as excluded-by-noindex.
|
||||
* Both halves clear together on the first publication.
|
||||
*
|
||||
* NO TOPIC FILTER UI. `docs/01` asks for *"topic filtering by practice area"*,
|
||||
* and with zero published articles a filter is a control with nothing to filter —
|
||||
* shipping its CSS to every visitor for an empty list is the argument `/` used
|
||||
* for deferring its own Insights strip at step 2. The pills on each card carry
|
||||
* the topic, and the practice-area link at the foot of each article carries the
|
||||
* other axis. Build the filter when there is a shelf worth filtering, and build
|
||||
* it as links to real URLs rather than as JavaScript.
|
||||
*/
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||
import ArticleCard from '../../components/ArticleCard.astro';
|
||||
import Button from '../../components/Button.astro';
|
||||
import ContactBand from '../../components/ContactBand.astro';
|
||||
import Eyebrow from '../../components/Eyebrow.astro';
|
||||
import SectionHeading from '../../components/SectionHeading.astro';
|
||||
import { getCollection } from 'astro:content';
|
||||
import { getImage } from 'astro:assets';
|
||||
import ogDefault from '../../assets/og-portrait.jpg';
|
||||
import { pageGraph } from '../../data/schema';
|
||||
|
||||
const ldImage = await getImage({
|
||||
src: ogDefault,
|
||||
format: 'jpeg',
|
||||
width: 1200,
|
||||
height: 630,
|
||||
});
|
||||
|
||||
/* THE ONE PREDICATE, everywhere on the site: `!data.draft`. See the `draft`
|
||||
field in `src/content.config.ts` for why it is not four predicates. */
|
||||
const published = await getCollection('insights', ({ data }) => !data.draft);
|
||||
published.sort(
|
||||
(a, b) => b.data.publishDate.getTime() - a.data.publishDate.getTime(),
|
||||
);
|
||||
|
||||
/* No `Article` nodes here. docs/04 puts `Article` on each article; a list of
|
||||
links is not fifteen articles, and emitting them would put the same `@id`
|
||||
in two documents. `pageGraph` is the Person alone — the shape `/practice/`
|
||||
and `/process/` already use. No `BreadcrumbList`: one hop from the root, and
|
||||
the page shows no visible trail. */
|
||||
const graph = pageGraph(new URL(ldImage.src, Astro.site).href);
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="Insights · Dispute Resolution Notes · Pouya Lajevardi"
|
||||
description="How mediation and arbitration actually run, what Ontario regulatory change means for a dispute, and how to read the technical record underneath one."
|
||||
jsonLd={graph}
|
||||
noindex={published.length === 0}
|
||||
>
|
||||
{/* ---- 1. Hero -------------------------------------------------------- */}
|
||||
<section class="section hero">
|
||||
<div class="wrap">
|
||||
<Eyebrow dot>Insights</Eyebrow>
|
||||
<h1 class="display hero-h">
|
||||
Notes on process, regulation, and the technical record.
|
||||
</h1>
|
||||
<p class="hero-lede">
|
||||
Written for counsel choosing a neutral, and for in-house teams who have
|
||||
to explain a process to someone who has never been in one. Each piece
|
||||
names its sources.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ---- 2. The articles, or an honest account of their absence --------- */}
|
||||
<section class="section section-alt reveal">
|
||||
<div class="wrap">
|
||||
{
|
||||
published.length > 0 ? (
|
||||
<>
|
||||
<div class="section-head">
|
||||
<SectionHeading eyebrow="Articles" level={2}>
|
||||
<span slot="heading">Most recent first.</span>
|
||||
</SectionHeading>
|
||||
</div>
|
||||
<div class="grid-autofit list" style="--grid-min: 22rem">
|
||||
{published.map((entry) => (
|
||||
<ArticleCard
|
||||
href={`/insights/${entry.id}/`}
|
||||
title={entry.data.title}
|
||||
description={entry.data.description}
|
||||
date={entry.data.publishDate}
|
||||
topics={entry.data.topics}
|
||||
readingTime={entry.data.readingTime}
|
||||
level={3}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div class="section-head">
|
||||
<SectionHeading eyebrow="Nothing published yet" level={2}>
|
||||
<span slot="heading">
|
||||
The first pieces are drafted and not yet published.
|
||||
</span>
|
||||
</SectionHeading>
|
||||
</div>
|
||||
{/* ⚠️ THIS BLOCK IS THE EMPTY STATE AND IT MAKES NO PROMISE ABOUT
|
||||
A DATE. "Coming soon", "launching shortly" and a monthly cadence
|
||||
stated on the page are all commitments — the class §4 and Q43
|
||||
treat as publishable only where Pouya has made them in terms. He
|
||||
has committed to monthly cadence in D9, which is a decision about
|
||||
the project; it is not a public undertaking, and R4 exists
|
||||
because a blog that stops is worse than one that never started.
|
||||
So the page says what is true today and stops. */}
|
||||
<div class="prose">
|
||||
<p>
|
||||
Every piece here is read and approved before it is published,
|
||||
which is why this section is empty rather than padded. The
|
||||
drafted pieces cover the Ontario data-centre build-out, when
|
||||
med-arb fits and when it does not, grid connection and Bill 40,
|
||||
what a System Impact Assessment evaluates, and what counsel
|
||||
should ask a neutral before appointing one.
|
||||
</p>
|
||||
<p>
|
||||
In the meantime, the pages below carry the same material in the
|
||||
form it is actually needed in.
|
||||
</p>
|
||||
</div>
|
||||
<div class="cta">
|
||||
<Button href="/practice/" variant="ghost">
|
||||
The six practice areas →
|
||||
</Button>
|
||||
<Button href="/process/" variant="ghost">
|
||||
How an engagement runs →
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<ContactBand />
|
||||
</BaseLayout>
|
||||
|
||||
<style>
|
||||
.hero {
|
||||
padding-block-start: var(--space-9);
|
||||
}
|
||||
.hero-h {
|
||||
margin-block: var(--space-4) var(--space-5);
|
||||
font-size: var(--text-6xl);
|
||||
}
|
||||
.hero-lede {
|
||||
max-inline-size: 58ch;
|
||||
font-size: var(--text-lg);
|
||||
line-height: var(--leading-body);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* `.grid-autofit` (global.css) carries the columns and the `min()` guard. */
|
||||
.list {
|
||||
gap: var(--space-5);
|
||||
}
|
||||
|
||||
/* A row of standalone CTAs, not prose: WCAG 2.5.8's inline-link exception
|
||||
does not cover them, so `.btn` carries the 44px target. */
|
||||
.cta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-3) var(--space-4);
|
||||
margin-block-start: var(--space-7);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,343 @@
|
||||
---
|
||||
/**
|
||||
* `/legal/privacy/` — build step 10. Spec: docs/01 §`/legal/*`,
|
||||
* docs/05-backend-spec.md §Privacy policy must state.
|
||||
*
|
||||
* ⚠️ **THE GOVERNING INSTRUCTION IS "WRITTEN TO MATCH WHAT IS ACTUALLY BUILT,
|
||||
* NOT WHAT IS TYPICAL" — docs/05 — AND THAT IS WHY THIS PAGE IS BUILT LAST IN
|
||||
* THE ORDER.** `docs/01`: *"/legal/* — written to match the backend as actually
|
||||
* built."* On this page a sentence that describes an intended control rather than
|
||||
* a real one is a false statement to the public in a legal document, and it is
|
||||
* the kind that fails silently: nothing breaks, and the sentence reads correctly.
|
||||
*
|
||||
* So three things are DERIVED rather than written, and each closes a specific
|
||||
* way this page could quietly become untrue:
|
||||
*
|
||||
* 1. **The list of what is collected is rendered from `INTAKE_FIELDS`** — the
|
||||
* same array `/contact/` builds the form from. A field added to the form
|
||||
* appears here on the same build. A hand-written list is the copy nobody
|
||||
* re-reads, which is the SES-DKIM shape in a document with legal weight.
|
||||
* 2. **The retention period is rendered from `RETENTION_MONTHS`**, which is the
|
||||
* figure `backend/intake/handler.mjs` writes into the `ttl` attribute.
|
||||
* docs/05: *"Whatever number ships must match `/legal/privacy/` exactly."*
|
||||
* 3. **The analytics paragraph is rendered from `ANALYTICS.installed`.** D15
|
||||
* decided Plausible; §7 records that no script is on any page. Deciding is
|
||||
* not installing, and a policy naming a processor that processes nothing is
|
||||
* a false disclosure. Today it says there are none.
|
||||
*
|
||||
* ⚠️ **WHAT THIS PAGE DELIBERATELY DOES NOT CLAIM, AND THE OMISSIONS ARE THE
|
||||
* POINT.** docs/05 specifies a customer-managed KMS key, point-in-time recovery,
|
||||
* and DynamoDB TTL. `AGENTS.md` §7 verifies the table's name and region and
|
||||
* **does not verify any of those three as enabled**. So:
|
||||
*
|
||||
* - "Encrypted at rest" IS stated, because DynamoDB encrypts every table at
|
||||
* rest unconditionally — it is true whether or not the customer-managed key
|
||||
* in docs/05 has been configured.
|
||||
* - The customer-managed key and point-in-time recovery are NOT mentioned.
|
||||
* Neither is a fact a reader needs, and neither is verified.
|
||||
* - **Automatic deletion IS stated, and it is the one promise on this page
|
||||
* that depends on a control nobody has verified.** The handler writes the
|
||||
* `ttl` attribute; TTL must also be ENABLED on the table, which §7 does not
|
||||
* record. docs/05's definition of done carries "TTL set and verified by test
|
||||
* record" and `docs/06`'s cutover checklist now names this page as what that
|
||||
* item is protecting. It must be verified before this page is public.
|
||||
*
|
||||
* ⚠️ **NO LICENSURE CLAIM AND NO ANSWER TO THE CAPACITY QUESTION.** A privacy
|
||||
* policy is where "legal advice" phrasing arrives by convention. §4 records
|
||||
* licence status as `[unestablished]` and instructs this repository to answer
|
||||
* neither way; `docs/03`'s ratified pattern is role, then consequence for the
|
||||
* reader, and no verb of capacity. Applied throughout.
|
||||
*/
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||
import Eyebrow from '../../components/Eyebrow.astro';
|
||||
import { getImage } from 'astro:assets';
|
||||
import ogDefault from '../../assets/og-portrait.jpg';
|
||||
import { pageGraph } from '../../data/schema';
|
||||
import { ANALYTICS, CONTACT, SITE } from '../../data/site';
|
||||
import { INTAKE_FIELDS } from '../../data/intake';
|
||||
|
||||
const ldImage = await getImage({
|
||||
src: ogDefault,
|
||||
format: 'jpeg',
|
||||
width: 1200,
|
||||
height: 630,
|
||||
});
|
||||
const graph = pageGraph(new URL(ldImage.src, Astro.site).href);
|
||||
|
||||
/**
|
||||
* ⚠️ MUST MATCH `RETENTION_MONTHS` IN `backend/intake/handler.mjs`, which is
|
||||
* the figure written into the record's `ttl`. docs/05: "Whatever number ships
|
||||
* must match /legal/privacy/ exactly." The handler is a separately deployed
|
||||
* artefact and cannot be imported here, so this is a second copy — and unlike
|
||||
* the intake field tables there is no `check:` script over it. Treat a change to
|
||||
* either as a change to both, and see docs/06's cutover checklist.
|
||||
*/
|
||||
const RETENTION_MONTHS = 24;
|
||||
|
||||
/** Bump this on ANY substantive edit. A privacy policy with a stale date is a
|
||||
* policy a reader cannot tell they are reading an old version of. */
|
||||
const LAST_UPDATED = '31 August 2026';
|
||||
|
||||
/* Rendered from the form's own field list, so the two cannot drift. `consent`
|
||||
and the honeypot are absent from `INTAKE_FIELDS` deliberately and are
|
||||
described in prose below instead — one is not information about the inquirer,
|
||||
and the other is not information at all. */
|
||||
const COLLECTED = INTAKE_FIELDS.map((field) => field.label);
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="Privacy Policy · Intake and Data Handling · Pouya Lajevardi"
|
||||
description="What the intake form collects, why, where it is stored, how long it is kept, who can see it, and how to have it deleted. Written to match what is built."
|
||||
jsonLd={graph}
|
||||
noindex
|
||||
>
|
||||
<section class="section hero">
|
||||
<div class="wrap">
|
||||
<Eyebrow dot>Privacy</Eyebrow>
|
||||
<h1 class="display hero-h">
|
||||
What the intake form collects, and for how long.
|
||||
</h1>
|
||||
<p class="hero-lede">
|
||||
This describes what actually happens to what you send me, not what is
|
||||
typical. Last updated {LAST_UPDATED}.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section legal-body">
|
||||
<div class="wrap">
|
||||
<div class="prose">
|
||||
<h2>What is collected</h2>
|
||||
<p>
|
||||
One form on this site collects personal information: the intake form
|
||||
on the <a href="/contact/">contact page</a>. It asks for the
|
||||
following, and the fields marked required on the form are the only
|
||||
ones that must be completed.
|
||||
</p>
|
||||
<ul>
|
||||
{COLLECTED.map((label) => <li>{label}</li>)}
|
||||
</ul>
|
||||
<p>
|
||||
Submitting the form also records the date and time, your IP address
|
||||
and your browser's user-agent string. Those three are kept for
|
||||
investigating abuse of the form and are not used for anything else.
|
||||
</p>
|
||||
<p>
|
||||
Nothing else on this site collects personal information. There is no
|
||||
newsletter, no account, no comment form and no upload.
|
||||
</p>
|
||||
|
||||
<h2>Information about other people</h2>
|
||||
<p>
|
||||
The form asks for the other parties to the dispute and their counsel.
|
||||
That is information about people who have not filled in the form and
|
||||
may not know it was sent. It is asked for one reason: I cannot accept
|
||||
an appointment before conflicts are checked, and the check needs
|
||||
names.
|
||||
</p>
|
||||
<p>
|
||||
Please give names and nothing more about them. The form asks you not
|
||||
to include privileged or confidential detail anywhere in it, and the
|
||||
summary field says so directly. There is deliberately no field for
|
||||
amounts in dispute and no way to attach a document.
|
||||
</p>
|
||||
|
||||
<h2>Why it is collected, and on what basis</h2>
|
||||
<p>
|
||||
To reply to your inquiry and to run a conflicts check. The basis is
|
||||
your consent, which the form asks for explicitly with an unchecked box
|
||||
you have to tick. The wording you agree to is on the form itself.
|
||||
</p>
|
||||
<p>
|
||||
It is not used for marketing. It is not sold, rented or shared with
|
||||
anyone for their own purposes.
|
||||
</p>
|
||||
|
||||
<h2>Where it is stored</h2>
|
||||
<p>
|
||||
In a DynamoDB table in Amazon Web Services' Canada Central region, in
|
||||
Canada. It is encrypted at rest. Two emails are sent when you submit
|
||||
the form — a notification to me and a confirmation to you — using
|
||||
Amazon Simple Email Service, also in the same Canadian region.
|
||||
</p>
|
||||
{
|
||||
/* ⚠️ THIS PARAGRAPH REPLACED A FALSE ONE, AND IT IS THE MOST SERIOUS
|
||||
THING FOUND IN THE STEP 7–10 REVIEW. It read: *"Amazon Web Services
|
||||
is therefore a processor for this information. **No other third party
|
||||
receives it.**"*
|
||||
|
||||
`AGENTS.md` §7 records mail hosting as **Google Workspace**, and D18
|
||||
sends the notification to `info@smlcompany.ca`. So Google receives and
|
||||
stores every submission — including the names of opposing parties and
|
||||
their counsel, which is the most sensitive thing this form collects —
|
||||
as a mail processor. The page's own next section already admitted it:
|
||||
*"The notification sits in my mailbox."* That mailbox is Google's.
|
||||
|
||||
A reader making a PIPEDA access request was being told there was one
|
||||
processor when there are two. This page's header comment sets the
|
||||
standard the sentence failed: a statement that describes an intended
|
||||
control rather than a real one is a false statement to the public in
|
||||
a legal document, and it fails silently, because nothing breaks and
|
||||
the sentence reads correctly.
|
||||
|
||||
Found by `adversarial-reviewer`, 2026-08-31. §7 is cited rather than
|
||||
restated — no MX record here. */
|
||||
}
|
||||
<p>
|
||||
Two companies therefore process it, and both are named because a
|
||||
reader asking for a copy or a deletion needs to know where it went. <strong
|
||||
>Amazon Web Services</strong
|
||||
> stores the submission and sends the two emails, in Canada. <strong
|
||||
>Google</strong
|
||||
> receives the notification email, because my own mail is on Google Workspace
|
||||
— so a copy of what you send, including any names you give me, sits in that
|
||||
mailbox. If you reply to the confirmation, that reply goes there too.
|
||||
</p>
|
||||
<p>
|
||||
The confirmation sent to you is delivered to whoever runs your email.
|
||||
That is your provider rather than mine, and I have no control over
|
||||
what they keep.
|
||||
</p>
|
||||
<p>
|
||||
No one else receives it. There is no CRM, no mailing list, no
|
||||
analytics on the submission, and no assistant or outside
|
||||
administrator.
|
||||
</p>
|
||||
|
||||
<h2>How long it is kept</h2>
|
||||
<p>
|
||||
<strong>{RETENTION_MONTHS} months from the date you send it</strong>,
|
||||
after which the record is deleted automatically by the database rather
|
||||
than by someone remembering to do it. That period is long enough to
|
||||
run a conflicts check across the normal life of a matter and no longer
|
||||
than necessary for that purpose.
|
||||
</p>
|
||||
<p>
|
||||
Emails are a separate matter. The notification sits in my mailbox and
|
||||
the confirmation sits in yours, and neither is deleted by that
|
||||
mechanism.
|
||||
</p>
|
||||
|
||||
<h2>Who can see it</h2>
|
||||
<p>
|
||||
I can. The table is reachable by the function that writes to it and by
|
||||
one administrative account, which is mine. Nobody else has access, and
|
||||
there is no team, no assistant and no external administrator.
|
||||
</p>
|
||||
|
||||
<h2>Cookies and analytics</h2>
|
||||
{
|
||||
ANALYTICS.installed ? (
|
||||
<p>
|
||||
Visits are counted using{' '}
|
||||
{ANALYTICS.provider === 'plausible' ? 'Plausible' : 'Fathom'},
|
||||
which is cookieless and collects no personal information and no
|
||||
cross-site identifiers. There is nothing to consent to and no
|
||||
banner, because nothing is stored on your device.
|
||||
</p>
|
||||
) : (
|
||||
<p>
|
||||
<strong>This site sets no cookies and runs no analytics.</strong>
|
||||
There is no tracking script on any page, nothing is stored on your
|
||||
device, and there is therefore nothing to consent to and no
|
||||
banner. If that changes, this page changes on the same day and its
|
||||
last updated date moves with it.
|
||||
</p>
|
||||
)
|
||||
}
|
||||
<p>
|
||||
There are no third-party scripts of any kind on this site, no embedded
|
||||
video, no web fonts fetched from another company's servers, and no
|
||||
social media widgets. The pages you are reading make no request to
|
||||
anyone but this site.
|
||||
</p>
|
||||
|
||||
<h2>Asking for a copy, or asking me to delete it</h2>
|
||||
<p>
|
||||
Email <a href={`mailto:${CONTACT.email}`}>{CONTACT.email}</a> and ask. You
|
||||
can ask for a copy of what you sent, ask me to correct it, or ask me to
|
||||
delete it before the {RETENTION_MONTHS} months are up.
|
||||
{' '}{CONTACT.responseTime}
|
||||
</p>
|
||||
<p>
|
||||
Deletion removes the record. It does not retract the emails already
|
||||
sent, and if a conflicts check has already been run I will tell you
|
||||
what its outcome was rather than pretending the inquiry did not
|
||||
happen.
|
||||
</p>
|
||||
|
||||
<h2>What an inquiry is not</h2>
|
||||
<p>
|
||||
Sending the form does not create a retainer, does not appoint me as a
|
||||
neutral in your matter, and does not itself establish a mediator–party
|
||||
relationship. It also does not, by itself, complete a conflicts check
|
||||
— it gives me what I need to run one.
|
||||
</p>
|
||||
|
||||
<h2>Changes to this page</h2>
|
||||
<p>
|
||||
If what happens to your information changes, this page is edited on
|
||||
the same day and the date at the top moves. There is no archive of
|
||||
previous versions.
|
||||
</p>
|
||||
|
||||
<h2>Contact</h2>
|
||||
<p>
|
||||
Questions about any of the above:
|
||||
<a href={`mailto:${CONTACT.email}`}>{CONTACT.email}</a>. The site is
|
||||
{' '}{SITE.url}, and correspondence is by email — {CONTACT.location}.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</BaseLayout>
|
||||
|
||||
<style>
|
||||
.hero {
|
||||
padding-block-start: var(--space-9);
|
||||
}
|
||||
.hero-h {
|
||||
margin-block: var(--space-4) var(--space-5);
|
||||
/* --text-4xl, not --text-6xl. A legal page's job is to be read rather than
|
||||
to land; at 96px this headline takes four lines before the reader reaches
|
||||
the date they came to check. */
|
||||
font-size: var(--text-4xl);
|
||||
max-inline-size: 30ch;
|
||||
}
|
||||
.hero-lede {
|
||||
max-inline-size: 58ch;
|
||||
font-size: var(--text-lg);
|
||||
line-height: var(--leading-body);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* NOT `.reveal`. A legal document is the one page class where content must be
|
||||
at full opacity the moment it renders, whatever the reader's scroll position
|
||||
or motion setting — and where a reader may well arrive via Cmd-F. */
|
||||
.legal-body {
|
||||
padding-block-start: var(--space-7);
|
||||
}
|
||||
|
||||
/* `global.css`'s `.prose` supplies the measure and paragraph spacing. These
|
||||
are the two element types this page introduces that no other page's prose
|
||||
block uses: headings inside a document, and a plain list. */
|
||||
.prose h2 {
|
||||
margin-block-start: var(--space-8);
|
||||
font-family: var(--font-serif);
|
||||
font-size: var(--text-2xl);
|
||||
line-height: var(--leading-tight);
|
||||
}
|
||||
.prose h2:first-child {
|
||||
margin-block-start: 0;
|
||||
}
|
||||
.prose ul {
|
||||
margin-block-start: var(--space-4);
|
||||
padding-inline-start: var(--space-6);
|
||||
max-inline-size: var(--width-prose);
|
||||
line-height: var(--leading-body);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.prose li + li {
|
||||
margin-block-start: var(--space-2);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,215 @@
|
||||
---
|
||||
/**
|
||||
* `/legal/terms/` — build step 10. Spec: docs/01 §`/legal/*`.
|
||||
*
|
||||
* ⚠️ **THIS IS THE PAGE WHERE THE BARRED PHRASING ARRIVES BY CONVENTION, AND
|
||||
* THAT MAKES IT THE SECOND-HIGHEST-RISK PAGE ON THE SITE AFTER
|
||||
* `/for-parties/`.** Every terms-of-use template on the internet contains some
|
||||
* version of *"nothing on this site constitutes legal advice and no
|
||||
* solicitor-client relationship is created"* — and both halves are traps here:
|
||||
*
|
||||
* 1. **"No solicitor-client relationship"** presupposes that there could be
|
||||
* one, which presupposes licensure. §4 Forbidden bars the word "lawyer" used
|
||||
* of Pouya and D13 treats implication as hard as assertion. The relationship
|
||||
* this site must disclaim is the **mediator–party** one, which is the
|
||||
* relationship actually on offer, and `NO_RETAINER_NOTICE` is the ratified
|
||||
* wording for it.
|
||||
* 2. **"Does not constitute legal advice"** is one word away from answering the
|
||||
* capacity question. §4 records licence status as `[unestablished]` and says
|
||||
* to answer it neither way; `docs/03`'s worked example shows both obvious
|
||||
* phrasings failing — *"I do not give legal advice"* elects, *"I cannot"*
|
||||
* denies. So this page describes **what the pages ARE** (general description
|
||||
* of processes) and **what follows for the reader** (get your own advice on
|
||||
* your own matter), and attaches no verb of capacity to him at all. That is
|
||||
* the ratified pattern, and `NEUTRAL_ROLE_LINE` is rendered rather than
|
||||
* paraphrased.
|
||||
*
|
||||
* ⚠️ **AND IT MUST NOT INVENT LEGAL EFFECT.** A terms page is a claim about what
|
||||
* is binding. §4 bars this repository from concluding a proposition of law, so
|
||||
* there is no governing-law clause asserting which court has jurisdiction, no
|
||||
* limitation-of-liability formula, and no warranty disclaimer written from a
|
||||
* template. Those are drafting decisions for Pouya or for counsel — they are in
|
||||
* the batched list for him, and this page says what it can stand behind.
|
||||
*/
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||
import Eyebrow from '../../components/Eyebrow.astro';
|
||||
import { getImage } from 'astro:assets';
|
||||
import ogDefault from '../../assets/og-portrait.jpg';
|
||||
import { pageGraph } from '../../data/schema';
|
||||
import {
|
||||
CONTACT,
|
||||
NEUTRAL_ROLE_LINE,
|
||||
NO_RETAINER_NOTICE,
|
||||
SITE,
|
||||
} from '../../data/site';
|
||||
|
||||
const ldImage = await getImage({
|
||||
src: ogDefault,
|
||||
format: 'jpeg',
|
||||
width: 1200,
|
||||
height: 630,
|
||||
});
|
||||
const graph = pageGraph(new URL(ldImage.src, Astro.site).href);
|
||||
|
||||
/** Bump on any substantive edit. See the note on the privacy page. */
|
||||
const LAST_UPDATED = '31 August 2026';
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="Terms of Use · This Website · Pouya Lajevardi · Toronto"
|
||||
description="What this site is, what reading it does and does not create, how the fees and timings published here relate to an engagement, and who to contact."
|
||||
jsonLd={graph}
|
||||
noindex
|
||||
>
|
||||
<section class="section hero">
|
||||
<div class="wrap">
|
||||
<Eyebrow dot>Terms</Eyebrow>
|
||||
<h1 class="display hero-h">Terms of use for this site.</h1>
|
||||
<p class="hero-lede">
|
||||
Short, because there is not much to say about a site that publishes
|
||||
information and one form. Last updated {LAST_UPDATED}.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section legal-body">
|
||||
<div class="wrap">
|
||||
<div class="prose">
|
||||
<h2>What this site is</h2>
|
||||
<p>
|
||||
A description of the dispute resolution practice of Pouya Lajevardi,
|
||||
the processes it conducts, the subject matter it works in, and what
|
||||
those processes cost. It is written for counsel choosing a neutral,
|
||||
for in-house teams, and for parties who have been told they are going
|
||||
to a mediation.
|
||||
</p>
|
||||
<p>
|
||||
It describes processes in general terms. It is not a description of
|
||||
your matter, and nothing on it has been written with your matter in
|
||||
view. Anything you are deciding about your own dispute is a question
|
||||
for your own advisers.
|
||||
</p>
|
||||
|
||||
<h2>What my role is</h2>
|
||||
<p class="statement">{NEUTRAL_ROLE_LINE}</p>
|
||||
<p>
|
||||
That holds on every page here. Where this site describes what happens
|
||||
in a mediation, an arbitration or a med-arb, it describes the role of
|
||||
a neutral running a process for everyone in it at once.
|
||||
</p>
|
||||
|
||||
<h2>Reading this site creates nothing</h2>
|
||||
<p>
|
||||
Visiting these pages, reading them, or sending the intake form does
|
||||
not appoint me and does not engage me. {NO_RETAINER_NOTICE}
|
||||
</p>
|
||||
<p>
|
||||
An appointment happens one way: terms of appointment agreed in writing
|
||||
with all parties, after a conflicts check. Until that exists, there is
|
||||
no engagement, whatever has been discussed.
|
||||
</p>
|
||||
|
||||
<h2>The fees and timings published here</h2>
|
||||
<p>
|
||||
The <a href="/fees/">rate card</a> is published in full and is the card
|
||||
I work from. It is confirmed in the terms of appointment before an engagement
|
||||
begins, and that document governs the engagement rather than this page.
|
||||
Fees are quoted before HST.
|
||||
</p>
|
||||
<p>
|
||||
The five stages on the <a href="/process/">process page</a> carry their
|
||||
own framing sentence and it is part of the statement: they are the typical
|
||||
shape of an engagement and not a commitment, because timing depends on party
|
||||
and counsel availability, which I do not control.
|
||||
</p>
|
||||
|
||||
<h2>Accuracy, and what moves</h2>
|
||||
<p>
|
||||
Several pages describe statutes, regulations, tribunal procedures and
|
||||
institutional rule sets, and each names its source. Those things
|
||||
change. Where a page states when a fact was checked, that is the date
|
||||
it was checked and not a promise that it is still true. Nothing here
|
||||
is a substitute for reading the current instrument.
|
||||
</p>
|
||||
<p>
|
||||
If you find something on this site that is wrong, I would rather know:
|
||||
<a href={`mailto:${CONTACT.email}`}>{CONTACT.email}</a>.
|
||||
</p>
|
||||
|
||||
<h2>The intake form</h2>
|
||||
<p>
|
||||
What the form collects, where it is stored, how long it is kept and
|
||||
how to have it deleted are set out in the <a href="/legal/privacy/"
|
||||
>privacy policy</a
|
||||
>. Please do not send privileged or confidential material through it.
|
||||
</p>
|
||||
|
||||
<h2>This site's own content</h2>
|
||||
<p>
|
||||
The writing, the design and the mark on these pages are mine. Quote
|
||||
from them with attribution and a link; do not republish a page whole.
|
||||
Where a page quotes an institution's own published rules, those words
|
||||
belong to that institution and are marked as quotations.
|
||||
</p>
|
||||
<p>
|
||||
Links out go to sources — statutes, regulators, tribunals and
|
||||
institutions. I do not control those sites and am not responsible for
|
||||
what they say.
|
||||
</p>
|
||||
|
||||
<h2>Changes</h2>
|
||||
<p>
|
||||
These terms can change. The date at the top moves when they do, and
|
||||
there is no archive of previous versions.
|
||||
</p>
|
||||
|
||||
<h2>Contact</h2>
|
||||
<p>
|
||||
<a href={`mailto:${CONTACT.email}`}>{CONTACT.email}</a>. The site is
|
||||
{' '}{SITE.url} — {CONTACT.location}.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</BaseLayout>
|
||||
|
||||
<style>
|
||||
.hero {
|
||||
padding-block-start: var(--space-9);
|
||||
}
|
||||
.hero-h {
|
||||
margin-block: var(--space-4) var(--space-5);
|
||||
font-size: var(--text-4xl);
|
||||
max-inline-size: 30ch;
|
||||
}
|
||||
.hero-lede {
|
||||
max-inline-size: 58ch;
|
||||
font-size: var(--text-lg);
|
||||
line-height: var(--leading-body);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* NOT `.reveal` — same reasoning as the privacy page: a legal document must be
|
||||
at full opacity when it renders, and a reader may arrive via Cmd-F. */
|
||||
.legal-body {
|
||||
padding-block-start: var(--space-7);
|
||||
}
|
||||
|
||||
/* The compliance sentence, set larger than the paragraph under it. Same
|
||||
treatment it gets on `/for-parties/` and `/contact/`. */
|
||||
.statement {
|
||||
font-size: var(--text-lg);
|
||||
line-height: var(--leading-body);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.prose h2 {
|
||||
margin-block-start: var(--space-8);
|
||||
font-family: var(--font-serif);
|
||||
font-size: var(--text-2xl);
|
||||
line-height: var(--leading-tight);
|
||||
}
|
||||
.prose h2:first-child {
|
||||
margin-block-start: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Generates every Open Graph card at build. `AGENTS.md` R15's removal trigger.
|
||||
*
|
||||
* AN ENDPOINT RATHER THAN A SCRIPT, so it cannot be forgotten: `astro build`
|
||||
* runs it, and both deploy paths run `astro build`.
|
||||
*
|
||||
* THIS IS THE ONLY PLACE THAT KNOWS ABOUT BOTH SOURCES OF CARDS — the static
|
||||
* registry and the Insights collection — so the two cannot disagree about which
|
||||
* cards exist. `SEO.astro` derives a card's URL from the same `ogCardPath()`,
|
||||
* and drafts get no card because they get no page.
|
||||
*/
|
||||
import type { APIRoute, GetStaticPaths } from 'astro';
|
||||
import { getCollection } from 'astro:content';
|
||||
import { OG_CARDS, articleCard, ogSlug } from '../../data/og-cards';
|
||||
import { renderOgCard, type OgCard } from '../../lib/og-card';
|
||||
|
||||
export const getStaticPaths = (async () => {
|
||||
const articles = await getCollection('insights', ({ data }) => !data.draft);
|
||||
|
||||
const staticCards = Object.entries(OG_CARDS).map(([pathname, card]) => ({
|
||||
params: { slug: ogSlug(pathname) },
|
||||
props: { card },
|
||||
}));
|
||||
|
||||
// `articleCard` lives in `src/data/og-cards.ts` so this file holds no headline
|
||||
// literal — see that function for the defect that put it there.
|
||||
const articleCards = articles.map((entry) => ({
|
||||
params: { slug: ogSlug(`/insights/${entry.id}/`) },
|
||||
props: { card: articleCard(entry.data.title) satisfies OgCard },
|
||||
}));
|
||||
|
||||
return [...staticCards, ...articleCards];
|
||||
}) satisfies GetStaticPaths;
|
||||
|
||||
export const GET: APIRoute = async ({ props }) => {
|
||||
const { card } = props as { card: OgCard };
|
||||
const body = await renderOgCard(card);
|
||||
return new Response(new Uint8Array(body), {
|
||||
headers: {
|
||||
'Content-Type': 'image/jpeg',
|
||||
// Dev-server only; production caching is set by `scripts/deploy-local.sh`.
|
||||
// ⚠️ It deliberately does NOT match production, and a previous comment
|
||||
// here claimed it did: the deploy script's pass 2 serves images at
|
||||
// `max-age=604800`, not `31536000, immutable`, and `immutable` would be
|
||||
// wrong for a filename that is not content-hashed — a card's path is
|
||||
// derived from its page, so replacing one reuses the URL.
|
||||
'Cache-Control': 'public, max-age=604800',
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -424,6 +424,23 @@ a:hover {
|
||||
.section-accent {
|
||||
--pill-border: var(--line-dark);
|
||||
--pill-fg: var(--text-inverse-2);
|
||||
/* ⚠️ `Button` — AND THIS IS THE GAP `a:not(.btn)` ABOVE LEFT OPEN. That rule
|
||||
deliberately excludes `.btn`, on the reasoning that a button carries its own
|
||||
colours. It does — and `.btn-ghost`'s are ink text on a 10%-alpha ink border,
|
||||
which on these two grounds is the background colour twice over. `/fees/`
|
||||
shipped "How an engagement runs →" at a measured **1.00:1**, invisible, and
|
||||
Lighthouse scored that page accessibility 100 because axe skips a
|
||||
foreground identical to its background. Cream on ink is 16.81:1 and on
|
||||
maroon 12.29:1; `--line-dark` is cream at 14% alpha, which reads as an edge
|
||||
on both. `.btn-gold` needs only an edge — its gold-l label already measures
|
||||
11.09:1 on ink and 8.11:1 on maroon. Found by `adversarial-reviewer`,
|
||||
2026-08-31; see `Button.astro` for why these are custom properties and not
|
||||
a descendant rule. */
|
||||
--btn-ghost-fg: var(--text-inverse);
|
||||
--btn-ghost-border: var(--line-dark);
|
||||
--btn-ghost-fg-hover: var(--text-inverse-2);
|
||||
--btn-ghost-border-hover: var(--text-inverse-2);
|
||||
--btn-gold-border: var(--line-dark);
|
||||
/* `DefinitionGrid`'s <dt>. Added 2026-08-29: --text-meta is --muted, which
|
||||
tokens.css marks ON CREAM ONLY (3.07:1 on ink), and `/practice/` is the
|
||||
first page to put that component on an inverse ground. */
|
||||
|
||||
Reference in New Issue
Block a user