feat: build step 2 — the home page; close Q35, Q37, Q39; Q39's answer corrected the register
Pouya's rulings and the page are one commit, not two, because they are not separable: Q37 changed the credential label the page renders, Q39 scoped the arbitration copy, and Q35 gave Med-Arb the row that lets the footer link stand. Splitting them would produce a commit where the register says one thing and the page says another. RULINGS Q39 — ANSWERED, and my assertion was FALSE as a universal. Pouya checked rather than defended. Family arbitration in Ontario is gated: prescribed training, 14h on screening for domestic violence and power imbalances, 30h of Ontario family law for arbitrators outside the bar, 10h ongoing per two years. claims-auditor produced that counter-example from inside the repo on 2026-08-26 and it was right. The source is now IN the repo per R14 — docs/reference/ontario-family-arbitration-training.md, retrieved with its provenance — and §4 states which half it establishes: the family gate directly, the commercial half only by absence. So "commercial arbitration in Ontario requires no licence and no designation" is recorded as Pouya's stated position, unstamped. What disposes of the question is the scope exclusion: he does not do family arbitration, so it is NOT OFFERED and the gate never bears on the practice. The false universal survived in three more live places, found by grep, not recall: §4's NOT-NEGOTIABLE boundary bullet (the boundary paragraph breaching its own boundary), §9's Q33 closure row, and a comment in SiteHeader.astro. Q35 — ANSWERED, all four items, and the answer supplied a gate that was missing. Med-Arb gets an Offerings row and stays in the footer sitemap. The six subject-matter labels pass test 1. §4 gains "Subject-matter areas — the publication gate": competence to accept an appointment, AND a page that frames it as positioning without claiming history. Nothing in the nav or footer is unrowed any more. Q37 — ANSWERED. "Law and engineering" becomes "Legal training and engineering practice". A degree is not a practice; the parallel was doing the implying. Q38/R13 — the committed SVG does not close it; the walk-back went too far. It renders faithfully BECAUSE it is the raster. R13 stays open. R1 — surfaced and acknowledged; the interim licensure framing is now carried by a shipped page rather than a spec, which raises the stakes. BUILD STEP 2 Seven of docs/01's eight home sections, six new components, zero JavaScript. Section 7 (Latest insights) ships at step 7 with the collection it lists — recorded in docs/01 and in the page, not just here. Four credential slots, not three: §4's paired-disclosure condition requires the Q.Arb stage on any page that offers arbitration. No booking link (R6). The masthead tagline is suppressed on / (it duplicated the hero eyebrow). The step-1 proof sheet is deleted, and five live references to it were found by grep. WHAT THE REVIEWS FOUND — 24 findings across the two passes claims-auditor FAILED it on ten, every one implication or scope rather than fabrication, which is where D13 says the risk lives. The four that mattered: "I mediate and arbitrate" asserted a track record §4 does not hold for arbitration; the JSON-LD asserted arbitration twice and stated the stage nowhere (a crawler-only claim is still a claim); "at one published rate with preparation time included" misdescribed money against docs/07's two day rates and capped prep allowance; and "Law and engineering are not blended here" was Q37's struck parallel relocated into prose one day after Pouya struck it. It also found the Canadian Tax Foundation missing from §9 Q8 — a stale second copy of a fact that would have bitten at step 3. adversarial-reviewer found fourteen, three blocking. The first was class="section-head" on <SectionHeading> never matching — the parent-cannot- style-a-child defect for the FOURTH time, written into a diff where I had just added fresh warnings about it to two other components. Measured: 0px margin, 0px gap, headings over the card edges, with astro check and eslint both clean. I had looked at a screenshot of that section and passed over it. Fixed with a page-owned wrapper (48px, measured) and the prop is deleted from six components so it is now a build error. Also: the credential row was never "two up on a phone" and its comment said it was; PROCESS was hardcoded in the page against the reason written in site.ts; 83px of residual overflow at a 200% default font size, now 3px. Seven more I found myself first, including <Picture widths> declaring the untouched 1600px master as the <img src> fallback (254,626 B for a 476px slot, and the build log said "before: 349kB" either way), and a prop named `as` silently turning off type-checking for a whole component. VERIFICATION — run, not asserted. Full figures in AGENTS.md entry (w). npx tsc 6.0.3 · check 0/0/0 · lint clean · build clean · audit 0 1 <script> and it is JSON-LD; non-JSON-LD scripts 0; no JS bundle; identical page with script execution disabled (444 nodes, 6,578 chars) Phase 5 minifier check: no `animation` shorthand beside animation-timeline overflow 0 at 14 widths, AND every mark measured at 1.5654-1.5657 vs 1.5657 one h1, no heading skips, focus order == DOM order across 44 focusables 31 painted contrast pairs at 3 widths, 0 failures 72/72 hit-test points across 8 cards resolve to the card's link print 0 hidden, reduced-motion 0 hidden Lighthouse NOT RUN — tool unavailable until step 7 (R11) HTML validator NOT RUN, and 4 of 12 srcsets carry a duplicate 1x descriptor Opened for Pouya: Q40 (one OG image for nineteen pages), Q41 (does Q37 reach prose; may the comparative be restored; what LAT pre-hearing mediation means), Q42 (the four "also offered" processes have no row), Q43 (the process timings are published commitments with no row). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0148NztQskLKKApP5SzAA78e
This commit is contained in:
co-authored by
Claude Opus 5
parent
8a2f513577
commit
165d259f5c
@@ -0,0 +1,104 @@
|
||||
---
|
||||
/**
|
||||
* docs/02: "Three or four credential slots. NEVER matter counts — AGENTS.md §4."
|
||||
*
|
||||
* THE SUBSTITUTION PRINCIPLE IS THE POINT OF THIS COMPONENT (§4). Wherever a
|
||||
* design wants a "how much / how many", it takes a longer-arc credential
|
||||
* instead: something already substantial and true at launch that does not grow
|
||||
* by closing files. `Q.Med` / `JD + ML` / `EN · FA`, with `Q.Arb` fourth.
|
||||
*
|
||||
* So this component is not a stat row wearing a different name, and it must
|
||||
* never be handed one. §4 Forbidden bars every count of matters closed, hours
|
||||
* mediated or years in practice, plus settlement rates and dollar figures. The
|
||||
* slots come from src/data/site.ts, which mirrors §4; nothing is typed here.
|
||||
*
|
||||
* <dl> RATHER THAN A DIV GRID. Each pair is a term and its description, which
|
||||
* is what a description list is. It also fixes the reading order: a screen
|
||||
* reader gets "Q.Arb — Commenced August 2026" as one associated pair, which is
|
||||
* §4's paired-disclosure condition surviving into assistive technology rather
|
||||
* than being a visual arrangement only. Wrapping each dt/dd pair in a <div>
|
||||
* inside <dl> is valid HTML and is what makes the grid tractable.
|
||||
*/
|
||||
interface Props {
|
||||
slots: ReadonlyArray<{ value: string; label: string }>;
|
||||
}
|
||||
// No `class` prop: it was declared, never passed, and a parent cannot reach this
|
||||
// root anyway (SectionHeading carries the measurement; CLAUDE.md the rule).
|
||||
const { slots } = Astro.props;
|
||||
---
|
||||
|
||||
<dl class="credentials">
|
||||
{
|
||||
slots.map((slot) => (
|
||||
<div class="credential">
|
||||
<dt class="credential-value">{slot.value}</dt>
|
||||
<dd class="credential-label">{slot.label}</dd>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</dl>
|
||||
|
||||
<style>
|
||||
.credentials {
|
||||
display: grid;
|
||||
/* EXPLICIT COLUMNS, BECAUSE `auto-fit` NEVER DELIVERED THE ROW IT CLAIMED.
|
||||
This read `repeat(auto-fit, minmax(11rem, 1fr))` under a comment saying
|
||||
"two up on a phone, four up where there is room". `adversarial-reviewer`
|
||||
measured it: at 390px the resolved template was a SINGLE 342px track and
|
||||
all four items stacked, running the band ~430px tall — with
|
||||
`Q.Arb / Commenced August 2026`, which §4's paired-disclosure condition
|
||||
puts on this page, at the bottom of it. The arithmetic is not subtle: two
|
||||
tracks at an 11rem (176px) floor plus a 24px gap need 376px and the
|
||||
container is 342px, so `auto-fit` correctly dropped to one. A
|
||||
measured-sounding comment that was false is this project's own named
|
||||
failure mode.
|
||||
|
||||
`minmax(0, 1fr)` cannot overflow at any width or any root font size,
|
||||
which also retires the `min(11rem, 100%)` guard this line briefly
|
||||
carried — that guard was fixing the overflow symptom of a floor that
|
||||
should not have been there. */
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--space-6) var(--space-5);
|
||||
margin: 0;
|
||||
padding-block: var(--space-7);
|
||||
border-block: 1px solid var(--rule);
|
||||
}
|
||||
.credential {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.credential-value {
|
||||
font-family: var(--font-serif);
|
||||
font-size: var(--text-3xl);
|
||||
line-height: var(--leading-tight);
|
||||
letter-spacing: var(--tracking-tight);
|
||||
color: var(--accent);
|
||||
}
|
||||
/* Four across only where four actually fit, measured rather than inferred.
|
||||
Two 11rem-equivalent tracks plus three 24px gaps need 776px of container;
|
||||
above 48rem the gutter is 48px each side, so that is a 872px viewport. 56rem
|
||||
(896px) is the clean token above it. Re-measure if --space-5 or the label
|
||||
type changes. */
|
||||
@media (min-width: 56rem) {
|
||||
.credentials {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
.credential-label {
|
||||
margin: 0;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
line-height: var(--leading-snug);
|
||||
text-transform: uppercase;
|
||||
color: var(--text-meta);
|
||||
/* "Legal training and engineering practice" is 39 characters and is
|
||||
deliberately long (AGENTS.md Q37) — it wraps to two lines at every width
|
||||
and must not be prevented from doing so. Do not add `white-space: nowrap`
|
||||
here, and do not shorten the label to make the row tidier: the asymmetry
|
||||
is the honest part. */
|
||||
text-wrap: pretty;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,63 @@
|
||||
---
|
||||
/**
|
||||
* docs/02: "Mono label with optional maroon dot."
|
||||
*
|
||||
* NO SCOPED STYLES ON PURPOSE. `.eyebrow` and `.eyebrow .dot` are already
|
||||
* global (global.css) because `.section-inverse .eyebrow` has to recolour them
|
||||
* from an ancestor, and a scoped rule here would be a second source for the
|
||||
* same thing. This component exists to stop `<span class="eyebrow">` being
|
||||
* hand-typed on nineteen pages, not to own the look.
|
||||
*
|
||||
* AN EYEBROW IS A LABEL, NEVER A HEADING. It renders <p> or <span>, never
|
||||
* <h*>: docs/02's accessibility floor forbids skipped heading levels, and an
|
||||
* eyebrow above an <h2> is exactly where an <h3>-before-<h2> creeps in.
|
||||
*
|
||||
* ⚠️ NEVER NAME AN ASTRO PROP `as`. The prop deleted below was briefly called
|
||||
* `as`, and that name SILENTLY TURNED OFF PROP TYPE-CHECKING for every caller
|
||||
* of this component. Kept here because the next person to want a dynamic tag
|
||||
* will reach for `as` first.
|
||||
*
|
||||
* `astro check` reported it only as a hint — `ts(6196) 'Props' is declared but
|
||||
* never used` — which reads exactly like lint noise and is the reason it is
|
||||
* worth writing down. It is not noise: it is the compiler saying the `Props`
|
||||
* interface is not attached to anything.
|
||||
*
|
||||
* Measured rather than assumed, 2026-08-27. With the prop named `as`,
|
||||
* `<Eyebrow dot as="h9" bogusProp={1} />` compiled with **0 errors**. The same
|
||||
* probe against CredentialRow, SectionHeading, PracticeCard and ProcessStep
|
||||
* produced `ts(2322)` on all four, so the loss was specific to this file.
|
||||
* Renaming the single identifier `as` to `tag` — one variable changed, nothing
|
||||
* else — took the file from 1 hint / 0 errors to 0 hints, and the same probe
|
||||
* now fails correctly: `Type '"h9"' is not assignable to type '"p" | "span" |
|
||||
* undefined'`. `const { as = 'p' } = Astro.props` is read as a type assertion
|
||||
* somewhere in the generated TSX and detaches the binding.
|
||||
*
|
||||
* A DYNAMIC `<Tag>` FROM A VARIABLE was the first hypothesis for the lost
|
||||
* binding and it was WRONG — restructuring the template changed nothing, the
|
||||
* rename fixed it. Recorded so the wrong cause is not re-derived.
|
||||
*
|
||||
* DO NOT "FIX" A ts(6196) HINT WITH `Astro.props as Props`. It silences the
|
||||
* warning inside the component and leaves every call site unchecked, which is
|
||||
* strictly worse than the warning. If this hint appears on another component,
|
||||
* probe a bogus prop before believing the props are checked.
|
||||
*/
|
||||
interface Props {
|
||||
/** The maroon dot. Decorative — it is a CSS box, so it is invisible to AT. */
|
||||
dot?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* ONE PROP, AND THE OTHER TWO ARE DELETED. `tag?: 'p' | 'span'` had zero call
|
||||
* sites, so its `<span>` branch was unreachable code and the file carried two
|
||||
* near-identical templates for it. `class?: string` had zero call sites too, and
|
||||
* a parent cannot reach this root anyway (see SectionHeading, and CLAUDE.md).
|
||||
*
|
||||
* The rename that produced the finding below is kept; only the prop is gone.
|
||||
*/
|
||||
const { dot = false } = Astro.props;
|
||||
---
|
||||
|
||||
<p class="eyebrow">
|
||||
{dot && <span class="dot" aria-hidden="true" />}
|
||||
<slot />
|
||||
</p>
|
||||
@@ -49,24 +49,70 @@ import mark from '../assets/brand/sml-infinity-mark.png';
|
||||
interface Props {
|
||||
/** Rendered height. Width follows 1.5657:1. */
|
||||
size?: string;
|
||||
/** Accessible name. Omit for decorative use — the default. */
|
||||
/**
|
||||
* Intrinsic width of the 1x variant, in px. RAISE IT FOR A LARGE CALL SITE.
|
||||
*
|
||||
* The default 64 is sized for the header (50.1px wide) and footer (56.4px) —
|
||||
* with densities [1,2,3] that gives 64/128/192 and both are sharp to DPR 3.
|
||||
* The home page's approach section renders the mark at 225.5px, five times
|
||||
* larger, and inherited the same 64: measured 1.17x upscale at DPR 1, 2.35x
|
||||
* at DPR 2, 3.52x at DPR 3. A prop, because the component cannot infer this
|
||||
* from `size` — `size` may be a `clamp()`.
|
||||
*/
|
||||
width?: number;
|
||||
/**
|
||||
* `eager` for the two above-the-fold marks; `lazy` for anything below it.
|
||||
* The component hardcoded `eager`, which is right for a masthead and wrong
|
||||
* for a 700px-wide decorative anchor two screens down.
|
||||
*/
|
||||
loading?: 'eager' | 'lazy';
|
||||
/**
|
||||
* Accessible name. Omit for decorative use — the default, and the case at all
|
||||
* three current call sites: the header and footer marks sit beside the name
|
||||
* they stand for, and the home page's mark sits beside a paragraph that says
|
||||
* what it is. Kept because the moment the mark appears without adjacent text
|
||||
* it needs one, and a component that cannot take an accessible name invites
|
||||
* `alt=""` on an informative image.
|
||||
*/
|
||||
label?: string;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
const { size = '1.75rem', label, class: className } = Astro.props;
|
||||
/**
|
||||
* NO `class` PROP, AND ITS REMOVAL IS THE POINT. It existed, had no call site,
|
||||
* and `class:list` put it on the <img> — so a parent writing
|
||||
* `<InfinityMark class="foo" />` would get a rule compiled against the PARENT's
|
||||
* cid that never matches the child's root. That is the exact defect CLAUDE.md
|
||||
* records twice on this project. The prop was an invitation to reproduce it.
|
||||
* To position a mark, style a wrapper the parent owns.
|
||||
*/
|
||||
|
||||
// Sized to the largest render that SURVIVES, which is the footer at 2.25rem =
|
||||
// 56.4 CSS px wide. The first attempt used 320px, justified by the 4rem sample
|
||||
// on the proof sheet — a page step 2 deletes — and then stacked densities on
|
||||
// top, so the ladder double-counted its own headroom and every Retina device
|
||||
// pulled a 640px image into a 56px slot.
|
||||
const { size = '1.75rem', width, loading = 'eager', label } = Astro.props;
|
||||
|
||||
// THE DEFAULT IS SIZED TO THE LARGEST *DEFAULT* CALL SITE — the footer at
|
||||
// 2.25rem, i.e. 56.4 CSS px wide. With densities [1,2,3] that is 64/128/192 and
|
||||
// the footer is sharp to DPR 3.
|
||||
//
|
||||
// PASSING A WIDTH EXPLICITLY IS STILL THE POINT. Without it Astro emits the
|
||||
// It has now been wrong in both directions, which is why it is a prop:
|
||||
// - Too big, 2026-08-26: 320px, justified by a 4rem sample on the step-1
|
||||
// proof sheet (a page step 2 has since deleted), with densities stacked on
|
||||
// top — so the ladder double-counted its own headroom and every Retina
|
||||
// device pulled a 640px file into a 56px slot.
|
||||
// - Too small, 2026-08-27: the home page added a 225.5px call site and
|
||||
// inherited 64, upscaling 3.52x at DPR 3. Raise `width` at the call site.
|
||||
//
|
||||
// PASSING A WIDTH AT ALL IS STILL THE POINT. Without it Astro emits the
|
||||
// untouched 2668px master as the <img src> fallback: 1,146,406 bytes, which any
|
||||
// client without AVIF or WebP support would actually download, sitting in dist
|
||||
// looking like an optimisation had happened.
|
||||
const INTRINSIC_WIDTH = 64;
|
||||
const INTRINSIC_WIDTH = width ?? 64;
|
||||
|
||||
// A NOTE ON MEASURING THIS, because the obvious probe lies. `img.naturalWidth`
|
||||
// on an image chosen from a `srcset` with an `x` descriptor is DENSITY-
|
||||
// CORRECTED: the 192px file selected at 3x reports 64. Reading it at DPR 1, 2
|
||||
// and 3 therefore returns 64 every time, which looks exactly like "the density
|
||||
// ladder is not being generated at all" — a far more alarming defect than the
|
||||
// real one. It is generated: 64x41, 128x82, 192x123 on disk, in all three
|
||||
// formats. Check the files, not naturalWidth. [verified 2026-08-27]
|
||||
---
|
||||
|
||||
<Picture
|
||||
@@ -76,9 +122,9 @@ const INTRINSIC_WIDTH = 64;
|
||||
formats={['avif', 'webp']}
|
||||
fallbackFormat="png"
|
||||
alt={label ?? ''}
|
||||
loading="eager"
|
||||
loading={loading}
|
||||
decoding="async"
|
||||
class:list={['mark', className]}
|
||||
class="mark"
|
||||
pictureAttributes={{ style: `block-size:${size}` }}
|
||||
/>
|
||||
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
---
|
||||
/**
|
||||
* docs/02: "Small bordered label for designations and sector chips."
|
||||
*
|
||||
* A PARENT CANNOT STYLE THIS ELEMENT. Astro does not pass a parent's scope
|
||||
* attribute to a child component's root, so `<Pill class="chip" />` compiles the
|
||||
* parent's `.chip` rule against the parent's cid and it never matches — the
|
||||
* defect CLAUDE.md records for <Button> in SiteHeader, which then recurred with
|
||||
* <Picture> in InfinityMark. CLAUDE.md names Pill as the next place it will
|
||||
* happen.
|
||||
*
|
||||
* THE HOOK IS A CUSTOM PROPERTY, and that is the one mechanism that legitimately
|
||||
* crosses the boundary: custom properties inherit. An ancestor sets
|
||||
* `--pill-border` / `--pill-fg` on ITSELF and this component reads it. No
|
||||
* :global(), no wrapper div, and no rule that silently does nothing.
|
||||
*/
|
||||
/**
|
||||
* No props. A `class?: string` was declared and never passed, and a parent
|
||||
* cannot reach this root regardless — see SectionHeading for the measurement
|
||||
* and CLAUDE.md for the rule. The custom-property hooks below are how an
|
||||
* ancestor influences it.
|
||||
*/
|
||||
---
|
||||
|
||||
<span class="pill"><slot /></span>
|
||||
|
||||
<style>
|
||||
.pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding-block: var(--space-1);
|
||||
padding-inline: var(--space-3);
|
||||
border: 1px solid var(--pill-border, var(--border));
|
||||
border-radius: var(--radius-full);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: var(--weight-medium);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
line-height: 1.4;
|
||||
text-transform: uppercase;
|
||||
color: var(--pill-fg, var(--text-meta));
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,116 @@
|
||||
---
|
||||
/**
|
||||
* docs/02: "Sector chip, heading, one paragraph, arrow link."
|
||||
*
|
||||
* ONE LINK PER CARD, AND THE WHOLE CARD IS ITS HIT AREA. The link wraps only
|
||||
* the heading text, so its accessible name is "Construction & Infrastructure"
|
||||
* rather than the card's entire contents; a `::after` pseudo-element stretched
|
||||
* over the positioned card carries the click. Six of these on the home page
|
||||
* would otherwise be six links each announcing three sentences.
|
||||
*
|
||||
* The arrow is `aria-hidden` and outside the link text for the same reason.
|
||||
*
|
||||
* THE GRID MUST NOT TRY TO STYLE THIS ROOT. A parent's `.card { block-size:
|
||||
* 100% }` compiles against the parent's cid and never matches (CLAUDE.md;
|
||||
* it has now cost twice). The card sizes ITSELF to its grid cell below, so a
|
||||
* parent only ever needs `display: grid` and `gap` on its own element.
|
||||
*/
|
||||
import Pill from './Pill.astro';
|
||||
|
||||
interface Props {
|
||||
href: string;
|
||||
chip?: string;
|
||||
title: string;
|
||||
/** Explicit: docs/02 forbids skipped heading levels. */
|
||||
level: 2 | 3;
|
||||
}
|
||||
// No `class` prop — declared, never passed, unreachable from a parent. The card
|
||||
// sizes itself to its grid cell instead; see the note above.
|
||||
const { href, chip, title, level } = Astro.props;
|
||||
const H = `h${level}` as 'h2' | 'h3';
|
||||
---
|
||||
|
||||
<article class="card">
|
||||
{chip && <Pill>{chip}</Pill>}
|
||||
<H class="card-title">
|
||||
<a class="card-link" href={href}>{title}</a>
|
||||
</H>
|
||||
<p class="card-body"><slot /></p>
|
||||
<span class="card-arrow" aria-hidden="true">→</span>
|
||||
</article>
|
||||
|
||||
<style>
|
||||
.card {
|
||||
position: relative;
|
||||
/* Sizes itself to its cell — see the note on why the grid cannot do this. */
|
||||
block-size: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-4);
|
||||
padding: var(--space-6);
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-block-start: 2px solid var(--rule);
|
||||
border-radius: var(--radius-md);
|
||||
transition:
|
||||
border-color var(--dur-hover) var(--ease),
|
||||
box-shadow var(--dur-hover) var(--ease);
|
||||
}
|
||||
.card:hover {
|
||||
border-color: var(--accent);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
.card-title {
|
||||
font-family: var(--font-serif);
|
||||
font-size: var(--text-xl);
|
||||
line-height: var(--leading-tight);
|
||||
letter-spacing: var(--tracking-tight);
|
||||
}
|
||||
.card-link {
|
||||
color: var(--text);
|
||||
text-decoration: none;
|
||||
}
|
||||
.card-link::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
.card:hover .card-link {
|
||||
color: var(--accent);
|
||||
}
|
||||
/* The focus ring belongs on the CARD, not on the heading text: the hit area
|
||||
is the card, so a ring around three words would point at the wrong box.
|
||||
`:focus-visible` on the descendant, ring on the ancestor. */
|
||||
.card:has(.card-link:focus-visible) {
|
||||
outline: 2px solid var(--focus-ring);
|
||||
outline-offset: var(--focus-offset);
|
||||
}
|
||||
.card-link:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
.card-body {
|
||||
margin: 0;
|
||||
/* Pushes the arrow to the bottom edge so a row of cards aligns on it
|
||||
whatever the body length. */
|
||||
flex: 1 1 auto;
|
||||
font-size: var(--text-base);
|
||||
line-height: var(--leading-body);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.card-arrow {
|
||||
font-size: var(--text-lg);
|
||||
line-height: 1;
|
||||
color: var(--accent);
|
||||
transition: transform var(--dur-hover) var(--ease);
|
||||
}
|
||||
.card:hover .card-arrow {
|
||||
transform: translateX(var(--space-2));
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.card:hover .card-arrow {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,69 @@
|
||||
---
|
||||
/**
|
||||
* docs/02: "Numbered step, timing, body."
|
||||
*
|
||||
* THE NUMBER IS NOT CONTENT. It is `aria-hidden` and supplied by the caller
|
||||
* rather than by a CSS counter, because the compressed strip on `/` and the
|
||||
* full page at `/process/` must agree on it. The reading order for assistive
|
||||
* technology is heading then timing then body; the numeral adds nothing to it
|
||||
* and would be read as a bare digit before every step.
|
||||
*
|
||||
* <ol> IS THE CALLER'S JOB. These are ordered steps, so the parent wraps them
|
||||
* in an <ol> and this renders the <li>. That keeps "step 3 of 5" available from
|
||||
* the list semantics instead of from the decorative numeral.
|
||||
*/
|
||||
interface Props {
|
||||
n: number;
|
||||
title: string;
|
||||
/** "Day 0", "Days 1–7". docs/03: "Five steps with real timing." */
|
||||
timing: string;
|
||||
}
|
||||
// No `class` prop — declared, never passed, unreachable from a parent.
|
||||
const { n, title, timing } = Astro.props;
|
||||
---
|
||||
|
||||
<li class="step">
|
||||
<span class="step-n" aria-hidden="true">{String(n).padStart(2, '0')}</span>
|
||||
<h3 class="step-title">{title}</h3>
|
||||
<p class="step-timing">{timing}</p>
|
||||
<p class="step-body"><slot /></p>
|
||||
</li>
|
||||
|
||||
<style>
|
||||
.step {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
padding-block-start: var(--space-4);
|
||||
border-block-start: 1px solid var(--border);
|
||||
}
|
||||
.step-n {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
/* --gold-d, not --gold: on cream, gold measures 2.10:1 and gold-d 3.11:1.
|
||||
docs/02 permits gold-d for LARGE DECORATIVE text only, 24px+ — this is
|
||||
12px, so neither qualifies and the numeral is maroon. Kept as a comment
|
||||
because "make the step numbers gold" is the obvious next edit. */
|
||||
color: var(--accent);
|
||||
}
|
||||
.step-title {
|
||||
font-family: var(--font-serif);
|
||||
font-size: var(--text-lg);
|
||||
line-height: var(--leading-tight);
|
||||
}
|
||||
.step-timing {
|
||||
margin: 0;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
text-transform: uppercase;
|
||||
color: var(--text-meta);
|
||||
}
|
||||
.step-body {
|
||||
margin: 0;
|
||||
font-size: var(--text-sm);
|
||||
line-height: var(--leading-body);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,80 @@
|
||||
---
|
||||
/**
|
||||
* docs/02: "Eyebrow + display heading + optional lede, one measure."
|
||||
*
|
||||
* THE LEVEL IS A REQUIRED DECISION, not a default. docs/02's accessibility
|
||||
* floor: "One <h1> per page; heading levels never skipped." A component that
|
||||
* defaulted to <h2> would silently produce an <h2> inside an <h3> section the
|
||||
* first time one is nested, and nothing would fail. `level` is explicit and
|
||||
* `astro check` enforces the union.
|
||||
*/
|
||||
import Eyebrow from './Eyebrow.astro';
|
||||
|
||||
interface Props {
|
||||
eyebrow?: string;
|
||||
/** 1 only on the page's single H1. */
|
||||
level: 1 | 2 | 3;
|
||||
lede?: string;
|
||||
dot?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* NO `class` PROP, AND NO `title` PROP. Both are deletions with reasons.
|
||||
*
|
||||
* `class` — a parent CANNOT reach this component's root. It was passed as
|
||||
* `class="section-head"` from `/` and the page's rule compiled to
|
||||
* `.section-head[data-astro-cid-<page>]` while the rendered div carried
|
||||
* `data-astro-cid-<SectionHeading>`. **Measured: `margin-block-end: 0px` and a
|
||||
* 0px gap on all three call sites** — 48px of intended separation missing, with
|
||||
* `.display`'s 0.98 line-height putting the glyphs over the top edge of the
|
||||
* cards below. `astro check` and `eslint` both passed. Fourth instance of the
|
||||
* defect `CLAUDE.md` records; a `class` prop here is an invitation to a fifth.
|
||||
* To space this block, wrap it in an element the page owns.
|
||||
*
|
||||
* `title` — two mechanisms for one job. The `heading` slot is the general one
|
||||
* (it takes an italic phrase, which `docs/02` allows once per headline); a
|
||||
* plain-text prop is the same thing minus a capability. One way in.
|
||||
*
|
||||
* `dot` defaults TRUE here and FALSE on `<Eyebrow>`, deliberately: a section
|
||||
* heading's eyebrow is the pattern the dot was designed for, and a bare
|
||||
* `<Eyebrow>` is used in places where it would be noise.
|
||||
*/
|
||||
const { eyebrow, level, lede, dot = true } = Astro.props;
|
||||
const H = `h${level}` as 'h1' | 'h2' | 'h3';
|
||||
const size = level === 1 ? 'size-display' : 'size-section';
|
||||
---
|
||||
|
||||
<div class="heading-block">
|
||||
{eyebrow && <Eyebrow dot={dot}>{eyebrow}</Eyebrow>}
|
||||
<H class:list={['display', size]}><slot name="heading" /></H>
|
||||
{lede && <p class="lede">{lede}</p>}
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.heading-block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
.size-display {
|
||||
font-size: var(--text-5xl);
|
||||
}
|
||||
.size-section {
|
||||
font-size: var(--text-4xl);
|
||||
}
|
||||
.lede {
|
||||
max-inline-size: var(--width-prose);
|
||||
font-size: var(--text-lg);
|
||||
line-height: var(--leading-relaxed);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
/* `color` INHERITS across the component boundary, which is how an inverse
|
||||
section reaches this — but `--text-secondary` resolves to a cream-only
|
||||
value, so it has to be overridden rather than inherited. Custom properties
|
||||
DO inherit, so an ancestor setting --lede-color would work too; a global
|
||||
ancestor selector is fewer moving parts for one rule. */
|
||||
:global(.section-inverse) .lede {
|
||||
color: var(--text-inverse);
|
||||
}
|
||||
</style>
|
||||
@@ -152,6 +152,21 @@ const aboutLinks = [
|
||||
padding-block: var(--space-9) var(--space-6);
|
||||
margin-block-start: var(--space-9);
|
||||
}
|
||||
/* WHEN THE LAST SECTION IS ALREADY DARK, DROP THE MARGIN. The 96px above the
|
||||
footer is page rhythm against cream; where the page ends on the maroon
|
||||
conversion band or an ink section it becomes a 96px CREAM STRIPE sandwiched
|
||||
between two dark blocks, which reads as a layout bug rather than as air.
|
||||
Found by looking at a full-page screenshot of `/`, not by reading the CSS.
|
||||
|
||||
`:global()` on the ancestor half is the mechanism: Astro appends this
|
||||
component's cid to `.site-footer` and leaves the globalised part alone, so
|
||||
the selector can reach out of the component to <main> without a parent
|
||||
needing to style a child's root — the thing that cannot be done the other
|
||||
way round (CLAUDE.md). */
|
||||
:global(main:has(> :last-child.section-accent)) + .site-footer,
|
||||
:global(main:has(> :last-child.section-inverse)) + .site-footer {
|
||||
margin-block-start: 0;
|
||||
}
|
||||
|
||||
.footer-top {
|
||||
display: flex;
|
||||
|
||||
@@ -32,6 +32,26 @@ const items = PRIMARY_NAV.filter(
|
||||
const path = Astro.url.pathname;
|
||||
const isCurrent = (href: string) => path === href;
|
||||
const inSection = (href: string) => path === href || path.startsWith(href);
|
||||
|
||||
/**
|
||||
* THE MASTHEAD TAGLINE IS SUPPRESSED ON `/`, and this closes a step-1 review
|
||||
* finding rather than being a preference.
|
||||
*
|
||||
* SITE.tagline is `Mediation · Arbitration · Toronto`, and docs/01 specifies
|
||||
* that exact string as the home HERO EYEBROW. At >=76rem the header shows the
|
||||
* tagline too, so `/` opened with the same six words twice, 300px apart —
|
||||
* `adversarial-reviewer` #10, left open at step 1 to "decide at step 2".
|
||||
*
|
||||
* The hero keeps it, because the hero is where docs/01 puts it and where there
|
||||
* is copy underneath to qualify it. The masthead drops it, because the masthead
|
||||
* is the placement Q33-orig objected to in the first place: a line under his
|
||||
* name with nothing to qualify it, reading as a designation strip.
|
||||
*
|
||||
* This only ever REMOVES a claim from one page, so no §4 disclosure condition
|
||||
* is touched — the footer's designation strip carries `Q.Arb — commenced
|
||||
* August 2026` on every page including this one.
|
||||
*/
|
||||
const isHome = path === '/';
|
||||
---
|
||||
|
||||
<header class="site-header">
|
||||
@@ -40,7 +60,7 @@ const inSection = (href: string) => path === href || path.startsWith(href);
|
||||
<InfinityMark size="2rem" />
|
||||
<span class="brand-text">
|
||||
<span class="brand-name">{SITE.name}</span>
|
||||
<span class="eyebrow brand-tagline">{SITE.tagline}</span>
|
||||
{!isHome && <span class="eyebrow brand-tagline">{SITE.tagline}</span>}
|
||||
</span>
|
||||
</a>
|
||||
|
||||
@@ -138,11 +158,19 @@ const inSection = (href: string) => path === href || path.startsWith(href);
|
||||
the outcome. It was removed on 2026-08-26 because `Arbitration` under
|
||||
Pouya's name read as a held capability, and §4 records Q.Arb as merely
|
||||
commenced. Q33 answered that the same day, and the premise was wrong:
|
||||
ADR designations are voluntary credentials, not licences. Anyone may be
|
||||
appointed an arbitrator in Ontario, so the constraint was always
|
||||
positional, never legal — Pouya accepts sole, party-appointed and
|
||||
co-arbitration work today. See §4 Offerings, which is where the register
|
||||
now draws the line between a CREDENTIAL claim and an OFFERING. */
|
||||
ADR designations are voluntary credentials, not licences, and COMMERCIAL
|
||||
arbitral appointment in Ontario is not gated behind a designation — so the
|
||||
constraint was always positional, never legal, and Pouya accepts sole,
|
||||
party-appointed and co-arbitration work today. See §4 Offerings.
|
||||
|
||||
SCOPED 2026-08-27 (Q39). This comment said "Anyone may be appointed an
|
||||
arbitrator in Ontario", which Pouya checked and found FALSE as a universal:
|
||||
family arbitration carries prescribed training. It does not touch what this
|
||||
header renders — the masthead says Mediation · Arbitration · Toronto and
|
||||
family arbitration is not offered at all — but a false proposition of law
|
||||
sitting in a source comment is how one reaches a page. §4's Forbidden scope
|
||||
note: no file in this repo may assert as fact what the register has not
|
||||
verified, internal or not. */
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -159,7 +187,7 @@ const inSection = (href: string) => path === href || path.startsWith(href);
|
||||
.brand-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
gap: var(--space-05);
|
||||
}
|
||||
/* The tagline appears only where there is room for it — see the 76rem block.
|
||||
Measured: at 11px with 0.18em tracking the string is ~285px wide, and
|
||||
@@ -169,7 +197,7 @@ const inSection = (href: string) => path === href || path.startsWith(href);
|
||||
same words as the hero eyebrow (docs/01). */
|
||||
.brand-tagline {
|
||||
display: none;
|
||||
font-size: 0.6875rem; /* 11px — the eyebrow floor in docs/02 */
|
||||
font-size: var(--text-2xs); /* 11px — the eyebrow floor in docs/02 */
|
||||
white-space: nowrap;
|
||||
}
|
||||
.brand-name {
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* JSON-LD builders. Spec: docs/04-seo-spec.md §Structured data.
|
||||
*
|
||||
* WHY THIS FILE EXISTS. Structured data is a factual claim in machine-readable
|
||||
* form, and it is the one place a claim can be wrong without any human reading
|
||||
* it. AGENTS.md §4 governs it exactly as it governs visible copy — docs/04 says
|
||||
* so in as many words: "Marking an unheld credential as held in structured data
|
||||
* is a misrepresentation that happens to be machine-readable."
|
||||
*
|
||||
* So the Person node is built ONCE, here, from src/data/site.ts, and every page
|
||||
* that needs it references the same @id. `/` (step 2) and `/about/` (step 3)
|
||||
* would otherwise hand-type it twice.
|
||||
*
|
||||
* THREE THINGS ARE DELIBERATELY ABSENT. Each is a decision, not an omission:
|
||||
*
|
||||
* 1. `LegalService` — NEVER. docs/04: schema.org defines it as a business
|
||||
* providing legal advice and *representation*, which asserts in
|
||||
* machine-readable form exactly what D13 bars. `ProfessionalService`.
|
||||
* 2. `worksFor` — omitted. Populating it either names the boutique (D16) or
|
||||
* misstates the employer. `jobTitle` carries the role on its own.
|
||||
* 3. `priceRange` — omitted until `/fees/` exists (build step 9). docs/04
|
||||
* gates it on that page being real.
|
||||
*
|
||||
* AND Q.Arb IS NOT IN `hasCredential`. It commenced August 2026 and is not
|
||||
* held. Q.Med is. That asymmetry is the whole point of the property.
|
||||
*/
|
||||
import { CONTACT, CREDENTIALS, ROLE, SITE } from './site';
|
||||
|
||||
/** Stable node ids, so pages cross-reference rather than duplicate. */
|
||||
export const PERSON_ID = `${SITE.url}/about/#person`;
|
||||
export const SERVICE_ID = `${SITE.url}/#practice`;
|
||||
|
||||
/**
|
||||
* `Person`. docs/04 anchors this on /about/ and references it site-wide, which
|
||||
* is why PERSON_ID points at /about/ regardless of which page emits the node.
|
||||
*
|
||||
* `description` IS OFFERING-SHAPED, NOT ROLE-SHAPED, and that was a correction.
|
||||
* It read "Mediator and commercial arbitrator in Toronto", which `claims-auditor`
|
||||
* flagged 2026-08-27: §4 verifies that he **accepts** arbitral appointments and
|
||||
* separately verifies "multiple completed sole mediations" — there is **no
|
||||
* counterpart row for a completed arbitration**, so "arbitrator" as a practised
|
||||
* role asserted something the register does not hold.
|
||||
*
|
||||
* AND IT CARRIES THE Q.Arb STAGE. Same finding, and it is the sharper half:
|
||||
* §4 Offerings permits the arbitration offering only while the site states the
|
||||
* stage of the arc plainly — "neither half may be dropped". The VISIBLE page
|
||||
* satisfied that with the fourth credential slot; this graph asserted
|
||||
* arbitration twice (here and in `serviceType`) and stated the stage nowhere.
|
||||
* A machine-readable claim is still a claim.
|
||||
*
|
||||
* `hasCredential` stays Q.Med-only regardless — the stage belongs in prose, not
|
||||
* in a field that means "holds".
|
||||
*
|
||||
* Every clause traces: Q.Med [verified], the JD [verified], engineering
|
||||
* practice [verified], Toronto [verified], the Q.Arb pathway commenced August
|
||||
* 2026 [verified]. It claims no licensure and implies none — D13 bars
|
||||
* implication as hard as assertion, and a crawler summary is a place where an
|
||||
* implication travels unedited.
|
||||
*/
|
||||
export function personNode(imageUrl?: string) {
|
||||
return {
|
||||
'@type': 'Person',
|
||||
'@id': PERSON_ID,
|
||||
name: SITE.name,
|
||||
url: `${SITE.url}/about/`,
|
||||
jobTitle: ROLE.title,
|
||||
description:
|
||||
'Mediator in Toronto, accepting commercial arbitration appointments. ' +
|
||||
'Q.Med designation through ADRIC and ADRIO; the Q.Arb pathway commenced ' +
|
||||
'in August 2026. JD, Bond University; practising machine-learning and ' +
|
||||
'infrastructure engineer.',
|
||||
knowsLanguage: ['en', 'fa'],
|
||||
alumniOf: { '@type': 'CollegeOrUniversity', name: 'Bond University' },
|
||||
// Q.Med only. See the header comment.
|
||||
hasCredential: {
|
||||
'@type': 'EducationalOccupationalCredential',
|
||||
name: CREDENTIALS.designations[0],
|
||||
credentialCategory: 'Professional designation',
|
||||
recognizedBy: [
|
||||
{ '@type': 'Organization', name: 'ADR Institute of Canada' },
|
||||
{ '@type': 'Organization', name: 'ADR Institute of Ontario' },
|
||||
],
|
||||
},
|
||||
sameAs: [CONTACT.linkedin],
|
||||
email: `mailto:${CONTACT.email}`,
|
||||
...(imageUrl ? { image: imageUrl } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* `ProfessionalService` for the home page, with the Person as `provider`.
|
||||
*
|
||||
* `serviceType` lists what §4 Offerings actually records as offered now —
|
||||
* mediation, arbitration, med-arb. **Arbitration is scoped to commercial**
|
||||
* (Q39, 2026-08-27): family arbitration in Ontario carries prescribed training
|
||||
* and is separately not offered, so an unscoped "Arbitration" here would be the
|
||||
* struck universal reappearing in a machine-readable field where nobody reads
|
||||
* it. Do not widen these strings without a row to widen them from.
|
||||
*
|
||||
* No `priceRange`, no `aggregateRating`, no `review` — the last two have no
|
||||
* underlying data and §4 Forbidden bars the fabricated testimonial that the
|
||||
* previous site carried.
|
||||
*/
|
||||
export function professionalServiceNode(imageUrl?: string) {
|
||||
return {
|
||||
'@type': 'ProfessionalService',
|
||||
'@id': SERVICE_ID,
|
||||
name: `${SITE.name} — Mediation & Arbitration`,
|
||||
url: `${SITE.url}/`,
|
||||
description:
|
||||
'Commercial mediation and arbitration for construction, technology, ' +
|
||||
'energy, insurance, shareholder and cross-border disputes. Toronto, ' +
|
||||
'by appointment. Q.Med held; the Q.Arb pathway commenced August 2026.',
|
||||
provider: { '@id': PERSON_ID },
|
||||
areaServed: [
|
||||
{ '@type': 'City', name: 'Toronto' },
|
||||
{ '@type': 'AdministrativeArea', name: 'Ontario' },
|
||||
],
|
||||
serviceType: [
|
||||
'Mediation',
|
||||
'Commercial arbitration',
|
||||
'Mediation-arbitration (med-arb)',
|
||||
],
|
||||
availableLanguage: ['en', 'fa'],
|
||||
email: `mailto:${CONTACT.email}`,
|
||||
...(imageUrl ? { image: imageUrl } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The home page's graph: the service and the person it resolves to.
|
||||
*
|
||||
* A `@graph` rather than two `<script>` blocks, so `provider: {'@id': …}`
|
||||
* resolves inside one document instead of relying on a crawler joining two.
|
||||
*/
|
||||
export function homeGraph(imageUrl?: string) {
|
||||
return {
|
||||
'@context': 'https://schema.org',
|
||||
'@graph': [professionalServiceNode(imageUrl), personNode(imageUrl)],
|
||||
};
|
||||
}
|
||||
+163
-13
@@ -118,22 +118,48 @@ export const ROLE = {
|
||||
export const CREDENTIAL_ROW = [
|
||||
{ value: 'Q.Med', label: 'ADRIC / ADRIO designation' },
|
||||
/**
|
||||
* TODO(pouya): AGENTS.md Q37 — is "Law and engineering" the right label?
|
||||
* Q37 CLOSED 2026-08-27. This label read 'Law and engineering' and it is now
|
||||
* 'Legal training and engineering practice'. Pouya's reasoning, kept because
|
||||
* it is the finding rather than the fix:
|
||||
*
|
||||
* §4's substitution principle supplies the three VALUES (`Q.Med / JD + ML /
|
||||
* EN · FA`); the labels come from docs/03 and are not themselves registered.
|
||||
* `claims-auditor` reads this one as a D13 implication risk: "engineering" is
|
||||
* shorthand for a verified *practising* claim, so the parallel invites "Law"
|
||||
* to be read the same way — and D13 bars implication as hard as assertion.
|
||||
* "The parallel was doing the implying — a degree and a practice under one
|
||||
* noun. The asymmetry is the honest part."
|
||||
*
|
||||
* Left as written, because it is docs/03's approved copy and changing
|
||||
* approved copy is not an implementer's call. Nothing renders it yet. Answer
|
||||
* before step 2 builds the credential row.
|
||||
* A JD is a degree. Engineering is a practice, and a verified one (§4).
|
||||
* Setting the two in parallel invited the reader to supply the symmetry, and
|
||||
* for 'Law' the missing half is a licence — which D13 bars by implication as
|
||||
* hard as by assertion.
|
||||
*
|
||||
* IT IS DELIBERATELY LOPSIDED AND LONGER. Do not tidy it back into a
|
||||
* parallel, and do not shorten it to fit a layout; change the layout.
|
||||
*/
|
||||
{ value: 'JD + ML', label: 'Law and engineering' },
|
||||
{ value: 'JD + ML', label: 'Legal training and engineering practice' },
|
||||
{ value: 'EN · FA', label: 'Bilingual practice' },
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* The FOURTH credential slot — separate on purpose, so a three-slot layout
|
||||
* cannot be handed four by accident and a page has to opt in.
|
||||
*
|
||||
* docs/03: 'Fourth slot where the layout has one: Q.Arb — commenced August
|
||||
* 2026. Use that wording, not "in progress"' — the weaker form drifts toward
|
||||
* 'nearly complete', which §4 Forbidden bars outright.
|
||||
*
|
||||
* REQUIRED on any page that offers arbitration, not decorative. §4 Offerings
|
||||
* carries a paired-disclosure condition: the site may make the offering only
|
||||
* while 'stating the second plainly', and 'neither half may be dropped'. The
|
||||
* footer's designation strip satisfies it site-wide; a page whose opening
|
||||
* sentence says 'arbitrator' should not make the reader scroll to the footer
|
||||
* for the stage.
|
||||
*
|
||||
* The em-dash in docs/03's string is carried by the layout (value over label),
|
||||
* not by the text. Same wording, same pairing.
|
||||
*/
|
||||
export const CREDENTIAL_ROW_ARB = {
|
||||
value: 'Q.Arb',
|
||||
label: 'Commenced August 2026',
|
||||
} as const; // [verified 2026-08-26 — Pouya]
|
||||
|
||||
/** Analytics: privacy-first and cookieless (D15). No GA4, no consent banner. */
|
||||
export const ANALYTICS = {
|
||||
/**
|
||||
@@ -241,29 +267,98 @@ export const PRACTICE_SLUGS = [
|
||||
|
||||
export type PracticeSlug = (typeof PRACTICE_SLUGS)[number];
|
||||
|
||||
/**
|
||||
* The six areas, with the one-line blurb each card renders.
|
||||
*
|
||||
* THE BLURBS LIVE HERE, not in the pages, because `/` and `/practice/` both
|
||||
* render them and two copies of a claim-bearing sentence is one copy that will
|
||||
* eventually be wrong. Same reasoning as ROLE and CREDENTIAL_ROW above.
|
||||
*
|
||||
* EVERY BLURB IS DISPUTE TYPES, NOT HISTORY. docs/03: "Frame as positioning,
|
||||
* not as history" — 'Built to facilitate ... on Ontario's megaproject pipeline',
|
||||
* never 'extensive experience resolving'. AGENTS.md §4 (Q35, 2026-08-27) makes
|
||||
* that condition 2 of the publication gate for naming a practice area at all.
|
||||
* A blurb that claims volume fails the gate even though the label passes.
|
||||
*
|
||||
* Nothing here may carry a count, a value, a settlement rate, or a superlative
|
||||
* (§4 Forbidden). Dispute types are not claims of caseload.
|
||||
*/
|
||||
export const PRACTICE_AREAS = [
|
||||
{
|
||||
slug: 'construction',
|
||||
name: 'Construction & Infrastructure',
|
||||
chip: 'Construction',
|
||||
blurb:
|
||||
'Liens, delay and change-order claims, scheduling, subcontract and ' +
|
||||
"deficiency disputes. Built for Ontario's megaproject pipeline.",
|
||||
},
|
||||
{
|
||||
slug: 'technology',
|
||||
name: 'Technology, AI & Data',
|
||||
chip: 'Technology',
|
||||
blurb:
|
||||
'Software contracts, SLA and MSA breakdowns, data residency and ' +
|
||||
'processing, AI vendor diligence, IP and licensing.',
|
||||
},
|
||||
{
|
||||
slug: 'energy',
|
||||
name: 'Energy, Grid & Regulatory',
|
||||
chip: 'Energy',
|
||||
blurb:
|
||||
'Grid connection and allocation, leave-to-construct, ' +
|
||||
'proponent–municipality disputes, IESO market participation.',
|
||||
},
|
||||
{
|
||||
slug: 'insurance',
|
||||
name: 'Insurance, SABS & LAT',
|
||||
chip: 'Insurance',
|
||||
/**
|
||||
* "LAT pre-hearing mediation" was flagged by `claims-auditor` 2026-08-27:
|
||||
* `docs/01` §`/practice/insurance/` lists that phrase as a SEARCH INTENT,
|
||||
* not as an offering, and as a service blurb it reads as conducting
|
||||
* mediation inside the LAT's own statutory process — which is not what a
|
||||
* privately retained neutral is appointed to do.
|
||||
*
|
||||
* Reworded to the offering that is actually rowed in §4 (mediation) applied
|
||||
* to the subject matter that is actually verified (insurance / SABS).
|
||||
* Q41 asks Pouya to confirm the intent before the page ships at step 5.
|
||||
*/
|
||||
blurb:
|
||||
'Accident benefits and SABS entitlement, MIG disputes, and private ' +
|
||||
'mediation of matters before the LAT.',
|
||||
},
|
||||
{ slug: 'technology', name: 'Technology, AI & Data', chip: 'Technology' },
|
||||
{ slug: 'energy', name: 'Energy, Grid & Regulatory', chip: 'Energy' },
|
||||
{ slug: 'insurance', name: 'Insurance, SABS & LAT', chip: 'Insurance' },
|
||||
{
|
||||
slug: 'shareholder',
|
||||
name: 'Shareholder & Family Business',
|
||||
chip: 'Shareholder',
|
||||
/**
|
||||
* "Family Business" means COMMERCIAL disputes among family shareholders.
|
||||
* The blurb says "family-held companies" and names commercial dispute types
|
||||
* for that reason — AGENTS.md Q39, 2026-08-27.
|
||||
*
|
||||
* The explicit exclusion — family law matters are not accepted — belongs on
|
||||
* the PAGE, one sentence, at build step 5 (docs/01 §/practice/shareholder/).
|
||||
* Pouya scoped it there. Do not add it to this blurb: on a six-card grid it
|
||||
* unbalances the row and reads defensively, and the wording here already
|
||||
* makes the area unambiguously commercial.
|
||||
*/
|
||||
blurb:
|
||||
'Shareholder and partnership disputes, co-founder breakdowns, and ' +
|
||||
'business succession in family-held companies.',
|
||||
},
|
||||
{
|
||||
slug: 'cross-cultural',
|
||||
name: 'Cross-Border & Diaspora',
|
||||
chip: 'Cross-cultural',
|
||||
blurb:
|
||||
'Diaspora business succession, dual-jurisdiction shareholder disputes, ' +
|
||||
'and cross-cultural commercial matters. Conducted in English or Farsi.',
|
||||
},
|
||||
] as const satisfies ReadonlyArray<{
|
||||
slug: PracticeSlug;
|
||||
name: string;
|
||||
chip: string;
|
||||
blurb: string;
|
||||
}>;
|
||||
|
||||
/**
|
||||
@@ -285,6 +380,61 @@ type _SlugsWithoutAnArea = Exclude<
|
||||
>;
|
||||
export type _SlugCoverage = _AssertNever<_SlugsWithoutAnArea>;
|
||||
|
||||
/**
|
||||
* The five process steps. **HERE, NOT IN THE PAGE**, for the reason written
|
||||
* against PRACTICE_AREAS above and applied by `adversarial-reviewer` 2026-08-27:
|
||||
* `/` renders a compressed strip of these and `/process/` renders them in full
|
||||
* at build step 6, so a copy typed into one page is a copy that will eventually
|
||||
* disagree with the other. The proof is already in the history — the step-3 body
|
||||
* below carried a fee claim that the same session's claims audit found to be
|
||||
* wrong against `docs/07`, and it existed in exactly one place. After step 6 it
|
||||
* would have existed in two.
|
||||
*
|
||||
* TIMINGS ARE `docs/01` §`/process/`'s, verbatim: "confidential intake (day 0) ·
|
||||
* engagement and framing (1–7) · pre-session exchange (7–21) · the session
|
||||
* (21–30) · binding conclusion (30+)". `docs/03` §Process requires them REAL
|
||||
* rather than illustrative, so they are not softened to "typically" — and they
|
||||
* are not invented either. **They have no §4 row; that is Q43.**
|
||||
*
|
||||
* Step 5 is labelled from the spec but its body says what actually concludes —
|
||||
* minutes of settlement in a mediation, an award where the process is arbitral.
|
||||
* "Binding conclusion" alone would read as though a mediation binds, which it
|
||||
* does not until the parties sign.
|
||||
*
|
||||
* NO FEE CLAIM IN ANY BODY. `docs/07` bundles a CAPPED preparation allowance
|
||||
* (2 h in the half day, 3 h in the full day) and says in terms that it "must be
|
||||
* stated on the page... Do not quietly fold it into the hours figure." A
|
||||
* five-word strip cannot state it properly, and stating it improperly
|
||||
* misdescribes money. `/fees/` at step 9.
|
||||
*/
|
||||
export const PROCESS = [
|
||||
{
|
||||
title: 'Confidential intake',
|
||||
timing: 'Day 0',
|
||||
body: 'A scheduled call to scope the matter, identify the parties, and run conflicts.',
|
||||
},
|
||||
{
|
||||
title: 'Engagement and framing',
|
||||
timing: 'Days 1–7',
|
||||
body: 'Terms of appointment, the issues in dispute, and who attends.',
|
||||
},
|
||||
{
|
||||
title: 'Pre-session exchange',
|
||||
timing: 'Days 7–21',
|
||||
body: 'Briefs and documents, exchanged in advance so the session starts informed.',
|
||||
},
|
||||
{
|
||||
title: 'The session',
|
||||
timing: 'Days 21–30',
|
||||
body: 'Half day or full day, in person or by video.',
|
||||
},
|
||||
{
|
||||
title: 'Conclusion',
|
||||
timing: 'Day 30 onward',
|
||||
body: 'Minutes of settlement — or an award, where the process is arbitral.',
|
||||
},
|
||||
] as const;
|
||||
|
||||
/** Seven items is the ceiling before a nav stops being scannable. */
|
||||
export const PRIMARY_NAV = [
|
||||
{ href: '/about/', label: 'About' },
|
||||
|
||||
@@ -35,9 +35,30 @@ const lang = SITE.locale.replace('_', '-');
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
|
||||
{
|
||||
/* Only the two faces used above the fold, per docs/02. Fonts are always
|
||||
fetched in CORS mode, so a preload without `crossorigin` is a second,
|
||||
wasted request rather than a warmed cache. */
|
||||
/* THREE of the FOUR faces used above the fold on `/`, and the comment used
|
||||
to say "the two faces used above the fold", which was wrong on the count.
|
||||
Measured by network probe on a cold cache: `/` requests four faces above
|
||||
the fold —
|
||||
|
||||
instrument-serif latin 400 normal 21,032 B preloaded — display type
|
||||
geist latin variable 29,400 B preloaded — body, the LCP element
|
||||
instrument-serif latin 400 italic 22,128 B preloaded — see below
|
||||
geist-mono latin variable 23,128 B NOT preloaded — see below
|
||||
|
||||
THE ITALIC IS PRELOADED because it sits inside the `<h1>`: `<em class="it">
|
||||
the room</em>`. With `font-display: swap` a CSS-discovered face renders in
|
||||
a fallback first, and a serif-to-fallback swap inside a 96px headline
|
||||
moves the whole last line.
|
||||
|
||||
GEIST MONO IS DELIBERATELY NOT. It sets the eyebrow — 12px, uppercase,
|
||||
0.18em tracking — and the credential labels. A swap there costs one short
|
||||
line of reflow at a size where the fallback is metrically close, and
|
||||
preloading it would put 95,688 B of font on the critical path instead of
|
||||
72,560 B. Revisit against real Lighthouse numbers at step 7; it is a
|
||||
trade, not a certainty.
|
||||
|
||||
Fonts are always fetched in CORS mode, so a preload without `crossorigin`
|
||||
is a second, wasted request rather than a warmed cache. */
|
||||
}
|
||||
<link
|
||||
rel="preload"
|
||||
@@ -53,6 +74,13 @@ const lang = SITE.locale.replace('_', '-');
|
||||
type="font/woff2"
|
||||
crossorigin
|
||||
/>
|
||||
<link
|
||||
rel="preload"
|
||||
href="/fonts/instrument-serif-latin-400-italic.woff2?v=1"
|
||||
as="font"
|
||||
type="font/woff2"
|
||||
crossorigin
|
||||
/>
|
||||
|
||||
{
|
||||
/* No script tag. Not "no framework", not "minimal JS" — none.
|
||||
|
||||
@@ -0,0 +1,780 @@
|
||||
---
|
||||
/**
|
||||
* `/` — Home. Build step 2 (docs/01 §Build order): "proves the design system
|
||||
* end to end."
|
||||
*
|
||||
* JOB (docs/01 §`/`): "establish the unusual stack in under ten seconds, and
|
||||
* route each of the four audiences to its surface." Leans in-house counsel.
|
||||
*
|
||||
* SECTIONS, against docs/01's outline:
|
||||
* 1 Hero · 2 Credential row · 3 The approach · 4 Two practices ·
|
||||
* 5 Practice areas · 6 Process preview · 7 Latest insights · 8 Contact band
|
||||
*
|
||||
* SECTION 7 IS NOT BUILT, DELIBERATELY, and this is the only spec item this
|
||||
* page does not deliver. `src/content/insights/` is empty: the collection ships
|
||||
* at build step 7, which is also where `ArticleCard` and the drafted slate
|
||||
* arrive (docs/01 §Build order; docs/03 §Launch article slate, D9). Rendering
|
||||
* the section now means importing a component with nothing to render — its
|
||||
* scoped CSS ships to every visitor for an empty block — and a props surface
|
||||
* with no call site, which is already an open finding against InfinityMark.
|
||||
* SiteHeader gates the Insights NAV item on the same collection, so the page
|
||||
* and the nav appear together. Do not "finish" this by hardcoding placeholders.
|
||||
*
|
||||
* EVERY FACTUAL CLAIM ON THIS PAGE TRACES TO AGENTS.md §4, and the ones that
|
||||
* carry risk are constants from src/data/site.ts rather than typed here.
|
||||
* Notably: arbitration is scoped to COMMERCIAL matters throughout (§4
|
||||
* Offerings, Q39 2026-08-27), the Q.Arb stage is stated on the page and not
|
||||
* only in the footer, and nothing claims or implies licensure (D13).
|
||||
*/
|
||||
import { Picture, getImage } from 'astro:assets';
|
||||
import BaseLayout from '../layouts/BaseLayout.astro';
|
||||
import Button from '../components/Button.astro';
|
||||
import CredentialRow from '../components/CredentialRow.astro';
|
||||
import Eyebrow from '../components/Eyebrow.astro';
|
||||
import InfinityMark from '../components/InfinityMark.astro';
|
||||
import PracticeCard from '../components/PracticeCard.astro';
|
||||
import ProcessStep from '../components/ProcessStep.astro';
|
||||
import SectionHeading from '../components/SectionHeading.astro';
|
||||
import portrait from '../assets/pouya-lajevardi.jpg';
|
||||
import ogDefault from '../assets/og-portrait.jpg';
|
||||
import { homeGraph } from '../data/schema';
|
||||
import {
|
||||
CONTACT,
|
||||
CREDENTIAL_ROW,
|
||||
CREDENTIAL_ROW_ARB,
|
||||
PRACTICE_AREAS,
|
||||
PORTRAIT,
|
||||
PROCESS,
|
||||
SITE,
|
||||
} from '../data/site';
|
||||
|
||||
/**
|
||||
* FOUR SLOTS, NOT THREE, AND THE FOURTH IS NOT DECORATIVE.
|
||||
*
|
||||
* docs/01 §`/` item 2 says "Three slots". §4 Offerings is the higher authority
|
||||
* on claims and attaches a PAIRED-DISCLOSURE CONDITION to offering arbitration
|
||||
* at all: the site "makes the first while stating the second plainly", and
|
||||
* "neither half may be dropped". This page says *arbitrator* in its second
|
||||
* sentence, so the stage of the arc belongs on this page rather than only in
|
||||
* the site footer. docs/03 authorises the fourth slot; docs/03 has been amended
|
||||
* to record that on `/` it is required. Q.Arb reads as commenced — never as
|
||||
* held or nearing completion (§4 Forbidden).
|
||||
*/
|
||||
const credentials = [...CREDENTIAL_ROW, CREDENTIAL_ROW_ARB];
|
||||
|
||||
/**
|
||||
* docs/04 lists `image` on the Person node. This generates it with EXACTLY the
|
||||
* transform SEO.astro already applies to the same source — jpeg, 1200 x 630 —
|
||||
* so Astro's asset cache returns the same hashed file rather than emitting a
|
||||
* second copy for the crawler's benefit. Verified by asset count: dist holds
|
||||
* one `og-portrait.*.jpeg` with this line present, not two.
|
||||
*
|
||||
* JPEG on purpose, for the same reason SEO.astro uses it: link-preview and
|
||||
* structured-data consumers are not browsers and several still do not decode
|
||||
* WebP, let alone AVIF.
|
||||
*/
|
||||
const ldImage = await getImage({
|
||||
src: ogDefault,
|
||||
format: 'jpeg',
|
||||
width: 1200,
|
||||
height: 630,
|
||||
});
|
||||
const graph = homeGraph(new URL(ldImage.src, Astro.site).href);
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title={`${SITE.name} · Mediation & Arbitration · Toronto`}
|
||||
description="Commercial mediation and arbitration in Toronto. Construction, technology, energy, insurance and shareholder disputes, read as contracts and as engineering."
|
||||
imageAlt={PORTRAIT.alt}
|
||||
jsonLd={graph}
|
||||
>
|
||||
{/* ---- 1. Hero ------------------------------------------------------- */}
|
||||
<section class="hero">
|
||||
<div class="wrap hero-inner">
|
||||
<div class="hero-copy">
|
||||
{
|
||||
/* docs/01 specifies this exact string as the hero eyebrow. The
|
||||
masthead no longer repeats it on this page — see SiteHeader. */
|
||||
}
|
||||
<Eyebrow dot>{SITE.tagline}</Eyebrow>
|
||||
|
||||
{
|
||||
/* docs/03 §Approved headline options, option 1 — the recommended one.
|
||||
Option 3 is struck there: "every side" asserts having acted as
|
||||
party, as counsel and as neutral, and §4 verifies one of the three.
|
||||
The italic is the single flourish docs/02 allows in a headline. */
|
||||
}
|
||||
<h1 class="display hero-h">
|
||||
A mediator who reads the contract, the code, and <em class="it"
|
||||
>the room</em
|
||||
>.
|
||||
</h1>
|
||||
|
||||
{
|
||||
/* TWO CORRECTIONS FROM `claims-auditor`, 2026-08-27, both about
|
||||
implication rather than assertion — which is where D13 says the risk
|
||||
actually lives.
|
||||
|
||||
(a) This read "I mediate and arbitrate commercial disputes". §4
|
||||
verifies that he ACCEPTS arbitral appointments, and separately
|
||||
verifies "multiple completed sole mediations" — there is no
|
||||
counterpart row for a completed arbitration. Present-indicative
|
||||
"arbitrate" beside "mediate" invites the reader to supply a track
|
||||
record for both. The offering-shaped form is what the register
|
||||
actually holds, and it is already the form the arbitration card
|
||||
below uses.
|
||||
|
||||
(b) "facts most neutrals take on faith" is a COMPARATIVE assertion
|
||||
about a population of third parties. It comes from `docs/03`'s core
|
||||
positioning statement, so it is approved copy — but `docs/03`'s own
|
||||
compliance checklist requires "any comparative claim is factual and
|
||||
verifiable", and §4 has no row for it. Replaced with a
|
||||
non-comparative distinction that is checkable and says the same
|
||||
thing: documents rather than pleadings. Q41 asks Pouya whether the
|
||||
original may be restored; changing approved copy back is his call,
|
||||
and shipping an unverifiable comparative in the meantime is not. */
|
||||
}
|
||||
<p class="hero-lede">
|
||||
I mediate commercial disputes from Toronto, and I accept arbitration
|
||||
appointments. I also practise as a machine-learning and infrastructure
|
||||
engineer, so the matters I take are the ones that turn on the
|
||||
documents rather than the pleadings: the change order, the model card,
|
||||
the System Impact Assessment, and the regulatory overlay around them.
|
||||
</p>
|
||||
|
||||
<div class="hero-cta">
|
||||
<Button href="/contact/" variant="primary"
|
||||
>Request a consultation →</Button
|
||||
>
|
||||
<Button href="/process/" variant="ghost">How I work</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{
|
||||
/* `widths` + `sizes` rather than `densities`: the portrait is fluid, and
|
||||
a density ladder would size it from one assumed CSS width. The largest
|
||||
real render is ~476px (content 1280 - 96 gutter - 64 gap, x 0.425),
|
||||
so 960 is the 2x ceiling.
|
||||
|
||||
`width` AND `height` ARE PASSED ALONGSIDE `widths`, AND THAT IS NOT
|
||||
REDUNDANT. With `widths` alone, Astro emits the UNTOUCHED 1600px
|
||||
master as the <img src> fallback — measured 254,626 bytes sitting in
|
||||
dist as the declared fallback for a 476px slot. Passing width/height
|
||||
pins it to the 960 variant instead (78,665 B), and the 1600px file
|
||||
stops being generated at all. Same defect InfinityMark's comment
|
||||
records for a missing `width`, in the one shape that survives passing
|
||||
`widths`. Verified by reading the emitted `src` attribute and that
|
||||
file's real dimensions — not the build log, which reported "before:
|
||||
349kB" for every variant either way.
|
||||
|
||||
What a browser actually takes is the AVIF: 5.6 / 7.3 / 11.1 / 14.8 /
|
||||
21.5 kB across the five widths [measured 2026-08-27].
|
||||
|
||||
eager + fetchpriority=high because this is the LCP candidate on the
|
||||
page docs/04 budgets hardest. */
|
||||
}
|
||||
<div class="hero-portrait">
|
||||
<Picture
|
||||
src={portrait}
|
||||
width={960}
|
||||
height={960}
|
||||
widths={[380, 480, 640, 760, 960]}
|
||||
sizes="(min-width: 80rem) 476px, (min-width: 66rem) 42vw, (min-width: 40rem) 60vw, 92vw"
|
||||
formats={['avif', 'webp']}
|
||||
fallbackFormat="jpeg"
|
||||
alt={PORTRAIT.alt}
|
||||
loading="eager"
|
||||
decoding="sync"
|
||||
fetchpriority="high"
|
||||
class="portrait-img"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ---- 2. Credential row --------------------------------------------- */}
|
||||
<section class="wrap credential-band" aria-label="Credentials">
|
||||
<CredentialRow slots={credentials} />
|
||||
</section>
|
||||
|
||||
{
|
||||
/* NO `aria-labelledby` ON THE CONTENT SECTIONS, and that is the fix for an
|
||||
inconsistency rather than a removal of information.
|
||||
|
||||
Four of them carried one and this one did not, which put five named
|
||||
regions plus header / nav / main / footer / footer-nav in a screen
|
||||
reader's region list — eleven entries for a marketing page — with the only
|
||||
unnamed content section being the odd one out. A `<section>` without an
|
||||
accessible name is not exposed as a region at all, and the visible `<h2>`s
|
||||
already give heading navigation, which is how a reader moves through a
|
||||
page like this.
|
||||
|
||||
THE ONE EXCEPTION IS THE CREDENTIAL BAND, which has no visible heading, so
|
||||
`aria-label` is the only thing that can name it. The rule is: name a region
|
||||
only where it has no heading of its own. */
|
||||
}
|
||||
{/* ---- 3. The approach ----------------------------------------------- */}
|
||||
<section class="section section-inverse approach reveal">
|
||||
<div class="wrap approach-inner">
|
||||
<div class="approach-copy">
|
||||
<SectionHeading eyebrow="The approach" level={2}>
|
||||
<span slot="heading">Two directions at once.</span>
|
||||
</SectionHeading>
|
||||
{
|
||||
/* THIS PARAGRAPH USED TO OPEN "Law and engineering are not blended
|
||||
here", and `claims-auditor` flagged it as Q37's struck parallel
|
||||
relocated from the credential label into prose — a degree and a
|
||||
practice under one noun, one day after Pouya struck exactly that
|
||||
construction. It is a fair reading and the fix is the same fix:
|
||||
make the two halves asymmetric. What a neutral does with a contract
|
||||
is read it; what an engineer does is engineering. Neither sentence
|
||||
now sets "Law" beside "engineering" as two instances of one thing.
|
||||
|
||||
`docs/01` §`/` item 3 and `docs/03` §Home both specify this section
|
||||
as "the 'two directions at once' argument — law and engineering
|
||||
converging on the same dispute", so the ARGUMENT is unchanged and
|
||||
still delivered; only the construction that carried the implication
|
||||
is gone. Whether Q37's reasoning formally extends to prose is
|
||||
Pouya's to say — Q41. */
|
||||
}
|
||||
<div class="prose approach-prose">
|
||||
<p>
|
||||
Any dispute I take has two readings: what the documents say, and
|
||||
what the engineering says. They are not blended here. They run at
|
||||
the same time.
|
||||
</p>
|
||||
<p>
|
||||
A construction claim is a contract question and a scheduling
|
||||
question. A software dispute is a licence question and an
|
||||
architecture question. A grid connection is a regulatory question
|
||||
and a load question. The second half of each pair usually arrives as
|
||||
a separate expert report.
|
||||
</p>
|
||||
<p>
|
||||
I do both readings myself. That is the whole of it, and it is why
|
||||
the technical half is not something a party has to commission and
|
||||
wait for.
|
||||
</p>
|
||||
<p class="approach-metaphor">
|
||||
My mark is an infinity loop, and it is the argument in one line:
|
||||
disputes are loops. The work is redrawing the loop into a line.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{
|
||||
/* Decorative: the paragraph beside it says what it is, so an
|
||||
accessible name here would be read twice. Measured on this ground —
|
||||
the ribbon's champagne half carries the silhouette against ink, the
|
||||
maroon half against cream; it reads on both. */
|
||||
}
|
||||
<div class="approach-mark">
|
||||
{
|
||||
/* width=232 and loading=lazy are both measured, not defaults.
|
||||
`size` caps at 9rem tall, so the mark renders at up to 144 x 225.5
|
||||
CSS px — five times the header's 50.1px, and the component's default
|
||||
64px ladder tops out at 192px. Measured upscale before this: 1.17x
|
||||
at DPR 1, 2.35x at DPR 2, 3.52x at DPR 3. 232 with densities
|
||||
[1,2,3] gives 232/464/696, and 696 covers the 676 device px a DPR-3
|
||||
screen asks for. Lazy because this sits roughly a screen and a half
|
||||
down; the component defaults to eager for the masthead. */
|
||||
}
|
||||
<InfinityMark
|
||||
size="clamp(4rem, 14vw, 9rem)"
|
||||
width={232}
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ---- 4. Two practices --------------------------------------------- */}
|
||||
<section class="section practices reveal">
|
||||
<div class="wrap">
|
||||
{
|
||||
/* The id goes on a span INSIDE the h2 via the `heading` slot, so
|
||||
`aria-labelledby` names the visible heading. The first version passed
|
||||
`title=` and additionally rendered a `.visually-hidden` span carrying
|
||||
the id, which put the same words in the accessibility tree twice —
|
||||
found in the heading-outline dump, not by reading the source. */
|
||||
}
|
||||
{
|
||||
/* THE WRAPPER IS THE FIX, NOT DECORATION. This was
|
||||
`<SectionHeading class="section-head">`, and the page's rule compiled
|
||||
to `.section-head[data-astro-cid-<page>]` while the rendered root
|
||||
carried SectionHeading's own cid — so it never matched. Measured:
|
||||
`margin-block-end: 0px` and a 0px gap to the cards on all three call
|
||||
sites, with `.display`'s 0.98 line-height putting the glyphs over the
|
||||
card's top edge. `astro check` 0 errors, `eslint` clean, and the source
|
||||
looked right. Fourth instance of this on the project; SectionHeading no
|
||||
longer accepts a `class` at all, and passing one is now a build
|
||||
error. */
|
||||
}
|
||||
<div class="section-head">
|
||||
<SectionHeading eyebrow="What I do" level={2}>
|
||||
<span slot="heading">Two processes.</span>
|
||||
</SectionHeading>
|
||||
</div>
|
||||
|
||||
<div class="pair">
|
||||
<article class="feature">
|
||||
<h3 class="feature-title">
|
||||
<a href="/mediation/">Mediation</a>
|
||||
</h3>
|
||||
{
|
||||
/* THE FEE CLAIM IS GONE, and it was wrong on two counts —
|
||||
`claims-auditor`, 2026-08-27, checked against `docs/07-fees.md`.
|
||||
"at one published rate" reads as ONE PRICE for half and full day;
|
||||
D14's card sets TWO ($2,000 half, $4,000 full). And "preparation
|
||||
time included" was unqualified where `docs/07` bundles a CAPPED
|
||||
allowance and says in terms: *"must be stated on the page —
|
||||
'including 2 hours of preparation' ... Do not quietly fold it into
|
||||
the hours figure. At these rates, saying preparation is included
|
||||
is the selling point, not a footnote."*
|
||||
|
||||
A home card is the wrong place to state it properly, and stating
|
||||
it improperly misdescribes money. `/mediation/` (step 4) and
|
||||
`/fees/` (step 9) carry the card. "Published" was also
|
||||
forward-looking: `/fees/` does not exist yet. */
|
||||
}
|
||||
<p class="feature-body">
|
||||
Sole mediator, Q.Med through ADRIC and ADRIO, with multiple
|
||||
completed sole mediations. Half day or full day, in person or by
|
||||
video.
|
||||
</p>
|
||||
<span class="feature-arrow" aria-hidden="true">→</span>
|
||||
</article>
|
||||
|
||||
<article class="feature">
|
||||
<h3 class="feature-title">
|
||||
<a href="/arbitration/">Arbitration</a>
|
||||
</h3>
|
||||
{
|
||||
/* docs/03's model sentence, and BOTH halves are required: §4
|
||||
Offerings — "neither half may be dropped". Pouya's instruction:
|
||||
being open about the stage is the differentiator, so it is not
|
||||
hedged into vagueness and not dropped. Commercial matters only
|
||||
(Q39): family arbitration is not offered. */
|
||||
}
|
||||
<p class="feature-body">
|
||||
I accept sole, party-appointed and co-arbitration appointments in
|
||||
commercial matters. The Q.Arb pathway commenced in August 2026;
|
||||
C.Med-Arb is the endpoint.
|
||||
</p>
|
||||
<span class="feature-arrow" aria-hidden="true">→</span>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
{
|
||||
/* docs/01 §`/` item 4: "Med-Arb named here as the long-term arc,
|
||||
linking to /med-arb/." It has had its own §4 Offerings row since
|
||||
2026-08-27 (Q35), so it is named as offered rather than only as an
|
||||
aspiration — but the arc is what docs/01 asks this page to carry. */
|
||||
}
|
||||
<p class="pair-note">
|
||||
<strong>Med-Arb</strong> combines the two: one neutral mediates, then arbitrates
|
||||
whatever has not settled. I accept those appointments, and C.Med-Arb is the
|
||||
designation endpoint. The page on it meets the procedural-fairness objection
|
||||
head on rather than around it —
|
||||
<a href="/med-arb/">how med-arb works →</a>
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ---- 5. Practice areas -------------------------------------------- */}
|
||||
{
|
||||
/* docs/01: "the most important block on the page for search, because it
|
||||
distributes authority to the pages that can actually rank." */
|
||||
}
|
||||
<section class="section section-alt areas reveal">
|
||||
<div class="wrap">
|
||||
<div class="section-head">
|
||||
<SectionHeading
|
||||
eyebrow="Practice areas"
|
||||
level={2}
|
||||
lede="Six areas, chosen because the disputes in them turn on documents I can read without an intermediary."
|
||||
>
|
||||
<span slot="heading">Where the work is.</span>
|
||||
</SectionHeading>
|
||||
</div>
|
||||
|
||||
{
|
||||
/* reveal-stagger is capped at six children by design (docs/02) and there
|
||||
are exactly six. A seventh would land with the sixth, not break. */
|
||||
}
|
||||
<div class="area-grid reveal-stagger">
|
||||
{
|
||||
PRACTICE_AREAS.map((area) => (
|
||||
<PracticeCard
|
||||
href={`/practice/${area.slug}/`}
|
||||
chip={area.chip}
|
||||
title={area.name}
|
||||
level={3}
|
||||
>
|
||||
{area.blurb}
|
||||
</PracticeCard>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
|
||||
{
|
||||
/* THIS SAID "All six areas, and what else is offered", which asserted
|
||||
offerings beyond the six. The only candidates — early neutral
|
||||
evaluation, settlement counsel, dispute-system design, pre-dispute
|
||||
technical advisory (`docs/01` §`/practice/`) — have NO row in §4
|
||||
"Offered now", and §4 states: "An offering may not be published until
|
||||
it has a row." `claims-auditor` called this the Med-Arb-in-the-footer
|
||||
defect in a new place, and it is exactly that. Reworded to what is
|
||||
rowed; the four candidates need rows before `/practice/` can offer
|
||||
them at step 5. */
|
||||
}
|
||||
<p class="areas-more">
|
||||
<a href="/practice/">All six practice areas →</a>
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ---- 6. Process preview ------------------------------------------- */}
|
||||
<section class="section process reveal">
|
||||
<div class="wrap">
|
||||
<div class="section-head">
|
||||
<SectionHeading
|
||||
eyebrow="How it runs"
|
||||
level={2}
|
||||
lede="Five steps, from the first call to the conclusion. Counsel want the shape of the downside before they commit a client's day."
|
||||
>
|
||||
<span slot="heading">From first call to conclusion.</span>
|
||||
</SectionHeading>
|
||||
</div>
|
||||
|
||||
<ol class="process-strip" role="list">
|
||||
{
|
||||
PROCESS.map((step, i) => (
|
||||
<ProcessStep n={i + 1} title={step.title} timing={step.timing}>
|
||||
{step.body}
|
||||
</ProcessStep>
|
||||
))
|
||||
}
|
||||
</ol>
|
||||
|
||||
<p class="process-more">
|
||||
<a href="/process/">What happens if the matter does not settle →</a
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ---- 7. Latest insights: NOT BUILT AT STEP 2. See the header note. -- */}
|
||||
|
||||
{/* ---- 8. Contact band ---------------------------------------------- */}
|
||||
{
|
||||
/* docs/01 item 8 is "Intake CTA and booking link". THERE IS NO BOOKING LINK
|
||||
and that is not an omission: booking is parked (AGENTS.md R6) and
|
||||
CONTACT.bookingUrl is null, so /contact/ ships the intake form with a
|
||||
reserved slot for an embed. Stated rather than silently dropped. */
|
||||
}
|
||||
<section class="section section-accent contact-band">
|
||||
<div class="wrap contact-inner">
|
||||
<div>
|
||||
<Eyebrow dot>Next step</Eyebrow>
|
||||
<h2 class="display contact-h">Start with a call.</h2>
|
||||
<p class="contact-body">
|
||||
Tell me the shape of the matter and who is involved, and I will tell
|
||||
you whether I am the right neutral for it. {CONTACT.responseTime}
|
||||
</p>
|
||||
</div>
|
||||
<div class="contact-action">
|
||||
<Button href="/contact/" variant="gold"
|
||||
>Request a consultation →</Button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</BaseLayout>
|
||||
|
||||
<style>
|
||||
/* --- 1. Hero -------------------------------------------------------- */
|
||||
|
||||
.hero {
|
||||
padding-block: var(--space-8) var(--space-9);
|
||||
}
|
||||
.hero-inner {
|
||||
display: grid;
|
||||
gap: var(--space-7);
|
||||
align-items: center;
|
||||
}
|
||||
.hero-copy {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-5);
|
||||
}
|
||||
.hero-h {
|
||||
/* --text-6xl is the display ceiling. At 360px it is 52px and the headline
|
||||
runs four lines; `text-wrap: balance` (global.css) keeps them even. */
|
||||
font-size: var(--text-6xl);
|
||||
max-inline-size: 22ch;
|
||||
}
|
||||
.hero-lede {
|
||||
max-inline-size: var(--width-prose);
|
||||
font-size: var(--text-lg);
|
||||
line-height: var(--leading-relaxed);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.hero-cta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-4);
|
||||
margin-block-start: var(--space-2);
|
||||
}
|
||||
/* The 4:5 crop is CSS, not a build step: Astro's image service does not crop,
|
||||
and cropping offline would mean committing a second derived binary on top
|
||||
of the ~3.16 MB of brand and portrait masters already in the repo. The cost
|
||||
is that a browser downloads the full square and shows 80% of it; the source
|
||||
is 1600 x 1600 and the largest variant generated is 960 wide. */
|
||||
.hero-portrait {
|
||||
aspect-ratio: 4 / 5;
|
||||
overflow: hidden;
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--bg-raised);
|
||||
}
|
||||
/* The <picture> wrapper is the box that gets sized, NOT the <img> — the exact
|
||||
defect CLAUDE.md records for <Button> and then for <Picture> inside
|
||||
InfinityMark. `class="portrait-img"` lands on the <img>, so the <img> rule
|
||||
below is reached via Astro's cid on the emitted element, and the wrapper is
|
||||
targeted by the bare `picture` selector, which the markup does carry. */
|
||||
.hero-portrait :global(picture) {
|
||||
display: block;
|
||||
block-size: 100%;
|
||||
}
|
||||
.portrait-img {
|
||||
inline-size: 100%;
|
||||
block-size: 100%;
|
||||
object-fit: cover;
|
||||
/* Above centre: the subject's head is in the upper half of a square crop. */
|
||||
object-position: 50% 22%;
|
||||
}
|
||||
|
||||
@media (min-width: 66rem) {
|
||||
.hero {
|
||||
padding-block: var(--space-9);
|
||||
}
|
||||
.hero-inner {
|
||||
/* 1.15 / 0.85 — the copy column carries a 22ch headline and a 68ch lede;
|
||||
an even split starves the headline and leaves the portrait oversized. */
|
||||
grid-template-columns: 1.15fr 0.85fr;
|
||||
gap: var(--space-8);
|
||||
}
|
||||
}
|
||||
|
||||
/* --- 2. Credential row ---------------------------------------------- */
|
||||
|
||||
.credential-band {
|
||||
/* No .section wrapper: the row owns its own padding-block and border, and
|
||||
stacking --section-y on top would put 160px of air around a 4-line band. */
|
||||
padding-block-end: var(--space-4);
|
||||
}
|
||||
|
||||
/* --- 3. The approach ------------------------------------------------ */
|
||||
|
||||
.approach-inner {
|
||||
display: grid;
|
||||
gap: var(--space-7);
|
||||
align-items: center;
|
||||
}
|
||||
.approach-prose {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-5);
|
||||
margin-block-start: var(--space-6);
|
||||
font-size: var(--text-lg);
|
||||
line-height: var(--leading-relaxed);
|
||||
}
|
||||
.approach-metaphor {
|
||||
padding-block-start: var(--space-5);
|
||||
border-block-start: 1px solid var(--rule);
|
||||
font-family: var(--font-serif);
|
||||
font-size: var(--text-xl);
|
||||
line-height: var(--leading-snug);
|
||||
color: var(--text-inverse-2);
|
||||
}
|
||||
.approach-mark {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
@media (min-width: 66rem) {
|
||||
.approach-inner {
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: var(--space-9);
|
||||
}
|
||||
/* Mark second in the DOM and second visually. No `order` anywhere on this
|
||||
page: reordering flex or grid items puts focus order out of step with
|
||||
visual order, which is what WCAG 2.4.3 and docs/02 both forbid, and it
|
||||
already cost a header rebuild at step 1. */
|
||||
}
|
||||
|
||||
/* --- 4. Two practices ---------------------------------------------- */
|
||||
|
||||
.section-head {
|
||||
margin-block-end: var(--space-7);
|
||||
}
|
||||
.pair {
|
||||
display: grid;
|
||||
gap: var(--space-5);
|
||||
}
|
||||
.feature {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-4);
|
||||
/* CLAMPED, not a flat --space-7. At a 200% default font size (root 32px)
|
||||
`3rem` is 96px a side — 192px of horizontal padding inside a 342px
|
||||
container, which was most of the 83px residual overflow
|
||||
`adversarial-reviewer` measured. The vw term keeps it at 48px on any real
|
||||
viewport and lets it collapse when the rem is doubled. */
|
||||
padding: clamp(var(--space-5), 4vw, var(--space-7));
|
||||
background: var(--bg-raised);
|
||||
border-radius: var(--radius-lg);
|
||||
transition: background-color var(--dur-hover) var(--ease);
|
||||
}
|
||||
.feature:hover {
|
||||
background: var(--cream-2);
|
||||
}
|
||||
.feature-title {
|
||||
font-family: var(--font-serif);
|
||||
font-size: var(--text-3xl);
|
||||
line-height: var(--leading-tight);
|
||||
letter-spacing: var(--tracking-tight);
|
||||
/* `overflow-wrap: break-word` (global.css) permits a break at layout time
|
||||
but does NOT reduce min-content size, so "Arbitration" at a 60px
|
||||
--text-3xl held the card open. `anywhere` does reduce it. Only reachable
|
||||
at a large default font size; at every real size the word never breaks. */
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.feature-title a {
|
||||
color: var(--text);
|
||||
text-decoration: none;
|
||||
}
|
||||
/* One link per card, hit area the whole card, accessible name the heading —
|
||||
same pattern and same reasoning as PracticeCard. */
|
||||
.feature-title a::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
.feature:hover .feature-title a {
|
||||
color: var(--accent);
|
||||
}
|
||||
.feature:has(a:focus-visible) {
|
||||
outline: 2px solid var(--focus-ring);
|
||||
outline-offset: var(--focus-offset);
|
||||
}
|
||||
.feature-title a:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
.feature-body {
|
||||
flex: 1 1 auto;
|
||||
max-inline-size: 46ch;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.feature-arrow {
|
||||
font-size: var(--text-xl);
|
||||
line-height: 1;
|
||||
color: var(--accent);
|
||||
}
|
||||
.pair-note {
|
||||
max-inline-size: var(--width-prose);
|
||||
margin-block-start: var(--space-6);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
@media (min-width: 56rem) {
|
||||
.pair {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--space-6);
|
||||
}
|
||||
}
|
||||
|
||||
/* --- 5. Practice areas --------------------------------------------- */
|
||||
|
||||
.area-grid {
|
||||
display: grid;
|
||||
/* auto-fit with an 18rem floor: 1 up on a phone, 2 up on a tablet, 3 up on
|
||||
a desktop, with no breakpoint of its own. The cards size themselves to
|
||||
the cell (see PracticeCard) — a parent cannot reach a child's root.
|
||||
|
||||
`min(18rem, 100%)`: a bare rem floor is a hard minimum, so at a large
|
||||
default font size (root 32px) 18rem becomes 576px and the track will not
|
||||
shrink. Measured at root 200%: this grid overflowed a 390px viewport by
|
||||
234px. See CredentialRow for the same guard and the full reasoning. */
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(18rem, 100%), 1fr));
|
||||
gap: var(--space-5);
|
||||
}
|
||||
/* THESE TWO LINKS STAND ALONE ON THEIR OWN LINE, so docs/02's 44px touch
|
||||
floor applies to them in full. Measured before this rule: 250.8 x 18 and
|
||||
287.9 x 18 — the paragraph's line box and nothing more.
|
||||
|
||||
`inline-flex` + min-block-size rather than padding, so the 44px IS the hit
|
||||
area rather than visual air, and `inline-size: fit-content` keeps the
|
||||
target the width of the words instead of the whole measure.
|
||||
|
||||
The INLINE link in `.pair-note` ("how med-arb works") is deliberately NOT
|
||||
given this treatment: it sits mid-sentence at 164 x 21, and WCAG 2.5.8
|
||||
exempts a target "in a sentence or its size is otherwise constrained by the
|
||||
line-height of non-target text". Padding it out would break the paragraph's
|
||||
leading to satisfy a rule that does not apply to it. A deviation from
|
||||
docs/02's flat wording, taken deliberately and recorded rather than left to
|
||||
look like an oversight.
|
||||
|
||||
The card heading links measure 26-39px tall and are NOT a finding: each
|
||||
card's whole box is the link's hit area via `::after { inset: 0 }`. Verified
|
||||
by hit-testing nine points per card at 390 / 768 / 1280px — 24 cards, 9/9
|
||||
inside the link every time. The width sweep flagged them because it
|
||||
measured the <a>'s own box, which is not the target. */
|
||||
.areas-more,
|
||||
.process-more {
|
||||
margin-block-start: var(--space-6);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
.areas-more a,
|
||||
.process-more a {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
inline-size: fit-content;
|
||||
min-block-size: 44px;
|
||||
}
|
||||
|
||||
/* --- 6. Process preview -------------------------------------------- */
|
||||
|
||||
.process-strip {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(13rem, 100%), 1fr));
|
||||
gap: var(--space-5);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* --- 8. Contact band ----------------------------------------------- */
|
||||
|
||||
.contact-inner {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-6);
|
||||
}
|
||||
.contact-h {
|
||||
margin-block: var(--space-4) var(--space-4);
|
||||
font-size: var(--text-4xl);
|
||||
}
|
||||
.contact-body {
|
||||
max-inline-size: 52ch;
|
||||
}
|
||||
.contact-action {
|
||||
/* `flex: none` is `0 0 auto`, so the button could not shrink below its
|
||||
max-content width — 425px at a 200% default font size, in a 342px
|
||||
container. `0 1 auto` plus `min-inline-size: 0` lets it. It still never
|
||||
shrinks at a normal font size, because there is room. */
|
||||
flex: 0 1 auto;
|
||||
min-inline-size: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -1,652 +0,0 @@
|
||||
---
|
||||
/**
|
||||
* TEMPORARY — build step 1 only. Delete when step 2 ships `/`.
|
||||
*
|
||||
* A proof sheet for the design system. It exists so the type scale, the
|
||||
* palette, the spacing rhythm, the focus states, and the header and footer can
|
||||
* be looked at in a browser before nineteen real pages are built on top of them.
|
||||
*
|
||||
* It carries `noindex` and is excluded from the sitemap in astro.config.mjs.
|
||||
* Remove that filter entry when you remove this page.
|
||||
*
|
||||
* WHAT THIS PAGE MAY CONTAIN: body copy about CSS, and nothing else. The page
|
||||
* is published — `noindex` suppresses indexing, not access — so AGENTS.md §4
|
||||
* governs every string on it exactly as it governs `/about/`.
|
||||
*
|
||||
* An earlier version of this comment read "NOTHING HERE ASSERTS A FACT ABOUT
|
||||
* POUYA." It was false when written, and it is recorded here as the caution:
|
||||
* the page was at that moment carrying a four-stage service timeline with day
|
||||
* counts and two unverified assertions about the previous build, and the claims
|
||||
* audit found them *because* a comment had certified the file clean and nobody
|
||||
* re-read it. A certification is not a check — CLAUDE.md, "a sweep is a
|
||||
* command, not a claim". Audit the page; do not trust this paragraph.
|
||||
*
|
||||
* The contrast numbers are COMPUTED, not transcribed. The frontmatter reads
|
||||
* src/styles/tokens.css, pulls the hex values out of it, and runs the WCAG 2.x
|
||||
* relative-luminance formula over them. Restating docs/02's measured table here
|
||||
* would have created a second copy of a fact — and the copy that goes stale is
|
||||
* the one nobody re-reads. This one cannot drift from the tokens, because it is
|
||||
* derived from them.
|
||||
*/
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||
import Button from '../../components/Button.astro';
|
||||
import InfinityMark from '../../components/InfinityMark.astro';
|
||||
|
||||
// `?raw` rather than fs.readFileSync: `import.meta.url` is rewritten to the
|
||||
// emitted chunk's own location during the build, so a relative path resolves
|
||||
// against dist/.prerender/chunks/ and throws ENOENT. Vite resolves this at
|
||||
// bundle time, in dev and in build alike.
|
||||
import tokensCss from '../../styles/tokens.css?raw';
|
||||
|
||||
const hex: Record<string, string> = {};
|
||||
for (const match of tokensCss.matchAll(
|
||||
/--([a-z0-9-]+):\s*(#[0-9a-f]{6})\b/gi,
|
||||
)) {
|
||||
hex[match[1]] = match[2].toLowerCase();
|
||||
}
|
||||
|
||||
// The parser above only understands 6-digit hex. Move a palette token to
|
||||
// `oklch()`, `color-mix()` or 3-digit hex and the lookup silently yields
|
||||
// undefined; the luminance maths then throws an unnamed TypeError with no clue
|
||||
// which token did it. Fail here instead, naming them — the posture SEO.astro
|
||||
// takes with an out-of-range title.
|
||||
const REQUIRED_TOKENS = [
|
||||
'cream',
|
||||
'ink',
|
||||
'ink-soft',
|
||||
'muted',
|
||||
'maroon',
|
||||
'maroon-l',
|
||||
'gold',
|
||||
'gold-d',
|
||||
'gold-l',
|
||||
];
|
||||
const unparsed = REQUIRED_TOKENS.filter((token) => !hex[token]);
|
||||
if (unparsed.length > 0) {
|
||||
throw new Error(
|
||||
'/type-scale/ could not read these colour tokens as 6-digit hex from ' +
|
||||
`src/styles/tokens.css: ${unparsed.map((t) => `--${t}`).join(', ')}. ` +
|
||||
'The contrast table on this page is computed from them. If the palette ' +
|
||||
'moved to another colour syntax, teach this parser that syntax — do not ' +
|
||||
'hardcode the values back in.',
|
||||
);
|
||||
}
|
||||
|
||||
const typeScale: Array<{ token: string; value: string }> = [];
|
||||
for (const match of tokensCss.matchAll(
|
||||
/--(text-(?:xs|sm|base|lg|xl|\dxl)):\s*([^;]+);/g,
|
||||
)) {
|
||||
typeScale.push({ token: `--${match[1]}`, value: match[2].trim() });
|
||||
}
|
||||
|
||||
/** WCAG 2.x relative luminance and contrast ratio. */
|
||||
const channel = (v: number) =>
|
||||
v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4;
|
||||
const luminance = (value: string) => {
|
||||
const n = Number.parseInt(value.slice(1), 16);
|
||||
return (
|
||||
0.2126 * channel(((n >> 16) & 255) / 255) +
|
||||
0.7152 * channel(((n >> 8) & 255) / 255) +
|
||||
0.0722 * channel((n & 255) / 255)
|
||||
);
|
||||
};
|
||||
const contrast = (a: string, b: string) => {
|
||||
const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x);
|
||||
return (hi + 0.05) / (lo + 0.05);
|
||||
};
|
||||
|
||||
const verdict = (ratio: number) =>
|
||||
ratio >= 4.5 ? 'AA body' : ratio >= 3 ? 'AA large only' : 'fails both';
|
||||
|
||||
const foregrounds = [
|
||||
'ink',
|
||||
'ink-soft',
|
||||
'maroon',
|
||||
'maroon-l',
|
||||
'muted',
|
||||
'gold-d',
|
||||
'gold',
|
||||
];
|
||||
const onCream = foregrounds.map((token) => ({
|
||||
token,
|
||||
value: hex[token],
|
||||
ratio: contrast(hex[token], hex.cream),
|
||||
}));
|
||||
const onInk = ['cream', 'gold-l', 'gold', 'muted'].map((token) => ({
|
||||
token,
|
||||
value: hex[token],
|
||||
ratio: contrast(hex[token], hex.ink),
|
||||
}));
|
||||
|
||||
const spacing: Array<{ token: string; value: string }> = [];
|
||||
for (const match of tokensCss.matchAll(/--(space-\d+):\s*([^;]+);/g)) {
|
||||
spacing.push({ token: `--${match[1]}`, value: match[2].trim() });
|
||||
}
|
||||
|
||||
const title = 'Type scale and colour proof sheet · Pouya Lajevardi';
|
||||
const description =
|
||||
'A temporary reference page rendering the design system end to end: the fluid type scale, the palette with computed contrast ratios, spacing, and motion.';
|
||||
---
|
||||
|
||||
<BaseLayout title={title} description={description} noindex>
|
||||
<div class="wrap">
|
||||
<section class="section">
|
||||
<p class="eyebrow">
|
||||
<span class="dot"></span>Build step 1 · temporary page
|
||||
</p>
|
||||
<h1 class="display size-5xl">
|
||||
The system, <span class="it">rendered</span>.
|
||||
</h1>
|
||||
<p class="lede">
|
||||
Everything below is read out of <code>src/styles/tokens.css</code> at build
|
||||
time. The contrast ratios are computed from the token values, not copied from
|
||||
the spec — if a token changes, this page changes with it.
|
||||
</p>
|
||||
<p class="note">
|
||||
This page is <strong>temporary</strong>. It is <code>noindex</code>, it
|
||||
is excluded from the sitemap, and build step 2 deletes it.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<hr class="rule-gold" />
|
||||
|
||||
<section class="section">
|
||||
<p class="eyebrow"><span class="dot"></span>Display type</p>
|
||||
<h2 class="display size-3xl">
|
||||
Instrument Serif, <span class="it">4xl and up</span>
|
||||
</h2>
|
||||
<p class="note">
|
||||
docs/02: display type is used at <code>--text-4xl</code> and above only. It
|
||||
has almost no hinting at small sizes and looks weak below 32 px.
|
||||
</p>
|
||||
<div class="stack">
|
||||
<p class="display size-6xl">Redraw the loop</p>
|
||||
<p class="display size-5xl">Redraw the loop into a line</p>
|
||||
<p class="display size-4xl">Redraw the loop into a line</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<hr />
|
||||
|
||||
<section class="section">
|
||||
<p class="eyebrow"><span class="dot"></span>Type scale</p>
|
||||
<h2 class="display size-3xl">Fluid, 360 px to 1600 px</h2>
|
||||
<p class="note">
|
||||
Resize the window. Every step interpolates; there are no breakpoint
|
||||
jumps. The declaration beside each line is the token itself.
|
||||
</p>
|
||||
<dl class="scale-list">
|
||||
{
|
||||
typeScale.map((step) => (
|
||||
<div class="scale-row">
|
||||
<dt>
|
||||
{/* A neutral specimen, deliberately. This was
|
||||
"Mediation · Arbitration · Toronto" — hand-typed rather than
|
||||
read from SITE.tagline, and rendered ten times at up to 96px
|
||||
on a published page. Two defects in one string: a claim
|
||||
typed inline is a claim nobody re-checks against §4 (the
|
||||
rule SiteFooter states), and `Arbitration` unqualified at
|
||||
display size is the open question AGENTS.md Q33 names. A
|
||||
type specimen needs letterforms, not assertions. */}
|
||||
<span class="sample" style={`font-size: var(${step.token})`}>
|
||||
Handgloves & quartz — 1234567890
|
||||
</span>
|
||||
</dt>
|
||||
<dd>
|
||||
<code>{step.token}</code>
|
||||
<span class="scale-value">{step.value}</span>
|
||||
</dd>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<hr />
|
||||
|
||||
<section class="section">
|
||||
<p class="eyebrow"><span class="dot"></span>Colour on cream</p>
|
||||
<h2 class="display size-3xl">Measured, not assumed</h2>
|
||||
<p class="note">
|
||||
Ratios computed at build time against <code>--cream</code>
|
||||
{' '}{hex.cream}. AA is 4.5:1 for body text and 3:1 for large text.
|
||||
</p>
|
||||
<ul class="swatches" role="list">
|
||||
{
|
||||
onCream.map((entry) => (
|
||||
<li class="swatch">
|
||||
<span class="chip" style={`background: ${entry.value}`} />
|
||||
<span class="swatch-meta">
|
||||
<code>--{entry.token}</code>
|
||||
<span class="swatch-value">{entry.value}</span>
|
||||
<span class="swatch-ratio">
|
||||
{entry.ratio.toFixed(2)}:1 · {verdict(entry.ratio)}
|
||||
</span>
|
||||
</span>
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
|
||||
<div class="gold-demo">
|
||||
<p class="note">
|
||||
<strong>The hard rule, shown without breaking it.</strong> Gold is a rule,
|
||||
a border, and an icon stroke. The identical hue fails at
|
||||
{contrast(hex.gold, hex.cream).toFixed(2)}:1 as text on cream and
|
||||
passes at {contrast(hex.gold, hex.ink).toFixed(2)}:1 on ink — the
|
||||
colour is not the problem, the pairing is. Both blocks below are the
|
||||
same <code>--gold</code>.
|
||||
</p>
|
||||
{
|
||||
/* Shown as blocks, not as sentences. An earlier version set a real
|
||||
paragraph in --gold on --bg-raised, which measures 1.76:1 — worse
|
||||
than the 2.10:1 the rule exists to prevent. A reader with low vision
|
||||
got an unreadable paragraph and no alternative, and the one artefact
|
||||
documenting the rule was the one artefact breaking it. */
|
||||
}
|
||||
<div class="gold-pair">
|
||||
<div class="gold-block on-cream">
|
||||
<span class="gold-band"></span>
|
||||
<span class="gold-caption">
|
||||
<code>--gold</code> on <code>--cream</code>
|
||||
<span class="swatch-ratio">
|
||||
{contrast(hex.gold, hex.cream).toFixed(2)}:1 · never as text
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="gold-block on-ink">
|
||||
<span class="gold-band"></span>
|
||||
<span class="gold-caption">
|
||||
<code>--gold</code> on <code>--ink</code>
|
||||
<span class="swatch-ratio">
|
||||
{contrast(hex.gold, hex.ink).toFixed(2)}:1 · text is fine here
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<hr />
|
||||
|
||||
<section class="section">
|
||||
<p class="eyebrow"><span class="dot"></span>Components</p>
|
||||
<h2 class="display size-3xl">Buttons, mark, focus</h2>
|
||||
<p class="note">
|
||||
Tab through this section. Every interactive element takes a visible
|
||||
focus ring — 2 px maroon, 3 px offset, on
|
||||
<code>:focus-visible</code>. Never <code>outline: none</code> without a replacement.
|
||||
</p>
|
||||
<div class="row">
|
||||
<Button href="/contact/" variant="primary"
|
||||
>Request a consultation</Button
|
||||
>
|
||||
<Button href="/process/" variant="ghost">How I work</Button>
|
||||
<Button href="/about/" variant="gold">Read the record</Button>
|
||||
<Button type="button">A real <button></Button>
|
||||
</div>
|
||||
<div class="row marks">
|
||||
<InfinityMark size="4rem" />
|
||||
<InfinityMark size="2.5rem" />
|
||||
<InfinityMark size="1.5rem" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<hr />
|
||||
|
||||
<section class="section">
|
||||
<p class="eyebrow"><span class="dot"></span>Measure</p>
|
||||
<h2 class="display size-3xl">Sixty-eight characters</h2>
|
||||
{
|
||||
/* `.prose` is opted into, not inherited. global.css no longer caps every
|
||||
<p> on the site — long-form asks for the measure, and this block is
|
||||
demonstrating the very thing it uses. */
|
||||
}
|
||||
<div class="prose">
|
||||
<p>
|
||||
Body copy is capped at <code>--width-prose</code>, 68 ch. Past
|
||||
roughly 75 characters the eye starts losing the beginning of the next
|
||||
line on the return sweep, and long-form copy stops being read. This
|
||||
paragraph sits at the cap. It is the single largest lever on whether a
|
||||
page of prose is actually finished, and it costs nothing to set.
|
||||
</p>
|
||||
<p>
|
||||
Line height is 1.6 for body and 0.98 for display. Headings balance
|
||||
with
|
||||
<code>text-wrap: balance</code>, so a two-line headline breaks evenly
|
||||
rather than leaving one word stranded.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<hr />
|
||||
|
||||
<section class="section">
|
||||
<p class="eyebrow"><span class="dot"></span>Spacing</p>
|
||||
<h2 class="display size-3xl">Eight-pixel base</h2>
|
||||
<ul class="spacing-list" role="list">
|
||||
{
|
||||
spacing.map((step) => (
|
||||
<li class="spacing-row">
|
||||
<code>{step.token}</code>
|
||||
<span
|
||||
class="spacing-bar"
|
||||
style={`inline-size: var(${step.token})`}
|
||||
/>
|
||||
<span class="spacing-value">{step.value}</span>
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="section section-inverse">
|
||||
<div class="wrap">
|
||||
<p class="eyebrow"><span class="dot"></span>Colour on ink</p>
|
||||
<h2 class="display size-3xl">The dark panel</h2>
|
||||
<p class="note-inverse">
|
||||
<code>--muted</code> is the trap: it passes on cream at 5.47:1 and fails on
|
||||
ink. Secondary text on dark is <code>--gold-l</code> or cream.
|
||||
</p>
|
||||
<ul class="swatches" role="list">
|
||||
{
|
||||
onInk.map((entry) => (
|
||||
<li class="swatch">
|
||||
<span class="chip" style={`background: ${entry.value}`} />
|
||||
<span class="swatch-meta">
|
||||
<code>--{entry.token}</code>
|
||||
<span class="swatch-value">{entry.value}</span>
|
||||
<span class="swatch-ratio">
|
||||
{entry.ratio.toFixed(2)}:1 · {verdict(entry.ratio)}
|
||||
</span>
|
||||
</span>
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="wrap">
|
||||
<section class="section">
|
||||
<p class="eyebrow"><span class="dot"></span>Motion</p>
|
||||
<h2 class="display size-3xl">Reveal on section entry</h2>
|
||||
<p class="note">
|
||||
The four cards below carry <code>.reveal-stagger</code>. Scroll them
|
||||
into view. With JavaScript disabled, or with reduced motion requested,
|
||||
they are simply present — the animation is layered onto a page that
|
||||
already reads.
|
||||
</p>
|
||||
{
|
||||
/* Deliberately contentless. These four cards previously carried the
|
||||
engagement timeline — which IS specified, at docs/01-architecture.md
|
||||
§/process/, for the `/process/` page. It does not belong on a proof
|
||||
sheet for a CSS stagger, where nothing qualifies it and no reader is
|
||||
looking for it. The stagger demonstrates just as well with nothing to
|
||||
say. */
|
||||
}
|
||||
<ul class="cards reveal-stagger" role="list">
|
||||
<li class="card"><h3>First card</h3><p>Stagger step 1</p></li>
|
||||
<li class="card"><h3>Second card</h3><p>Stagger step 2</p></li>
|
||||
<li class="card"><h3>Third card</h3><p>Stagger step 3</p></li>
|
||||
<li class="card"><h3>Fourth card</h3><p>Stagger step 4</p></li>
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
</BaseLayout>
|
||||
|
||||
<style>
|
||||
.size-6xl {
|
||||
font-size: var(--text-6xl);
|
||||
}
|
||||
.size-5xl {
|
||||
font-size: var(--text-5xl);
|
||||
}
|
||||
.size-4xl {
|
||||
font-size: var(--text-4xl);
|
||||
}
|
||||
.size-3xl {
|
||||
font-size: var(--text-3xl);
|
||||
}
|
||||
|
||||
.lede {
|
||||
max-inline-size: var(--width-prose);
|
||||
margin-block-start: var(--space-5);
|
||||
font-size: var(--text-lg);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.note,
|
||||
.note-inverse {
|
||||
max-inline-size: var(--width-prose);
|
||||
margin-block-start: var(--space-4);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
.note {
|
||||
color: var(--text-meta);
|
||||
}
|
||||
.note-inverse {
|
||||
color: var(--text-inverse-2);
|
||||
}
|
||||
h2.display {
|
||||
margin-block-start: var(--space-3);
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.9em;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-5);
|
||||
margin-block-start: var(--space-6);
|
||||
}
|
||||
/* No `max-inline-size: none` escape hatch any more — there is nothing left to
|
||||
escape from now that the measure is opt-in. */
|
||||
|
||||
/* --- Type scale ---------------------------------------------------------- */
|
||||
|
||||
.scale-list {
|
||||
margin-block-start: var(--space-6);
|
||||
}
|
||||
.scale-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-4);
|
||||
padding-block: var(--space-4);
|
||||
border-block-start: 1px solid var(--border);
|
||||
}
|
||||
.scale-row dt,
|
||||
.scale-row dd {
|
||||
/* A flex item's default `min-width: auto` is its min-content width, which
|
||||
for a 96px specimen is one whole word. */
|
||||
min-inline-size: 0;
|
||||
margin: 0;
|
||||
}
|
||||
.scale-row dd {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
text-align: end;
|
||||
}
|
||||
.scale-value {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-meta);
|
||||
}
|
||||
.sample {
|
||||
line-height: var(--leading-tight);
|
||||
/* At --text-6xl a single word is ~500px wide, so at 320px it has to break
|
||||
mid-word or push the page out. Measured: 8px of overflow before this,
|
||||
which now has no `body { overflow-x: hidden }` to hide it — which is
|
||||
exactly why that clamp was removed. */
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* --- Colour -------------------------------------------------------------- */
|
||||
|
||||
.swatches {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr));
|
||||
gap: var(--space-5);
|
||||
margin-block-start: var(--space-6);
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
.swatch {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
.chip {
|
||||
inline-size: 3.5rem;
|
||||
block-size: 3.5rem;
|
||||
flex: none;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.swatch-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.swatch-value,
|
||||
.swatch-ratio {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-meta);
|
||||
}
|
||||
.section-inverse .swatch-value,
|
||||
.section-inverse .swatch-ratio,
|
||||
.section-inverse code {
|
||||
color: var(--text-inverse-2);
|
||||
}
|
||||
.section-inverse .chip {
|
||||
border-color: var(--line-dark);
|
||||
}
|
||||
|
||||
.gold-demo {
|
||||
margin-block-start: var(--space-7);
|
||||
padding: var(--space-5);
|
||||
background: var(--bg-raised);
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
.gold-pair {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 14rem), 1fr));
|
||||
gap: var(--space-4);
|
||||
margin-block-start: var(--space-5);
|
||||
}
|
||||
.gold-block {
|
||||
padding: var(--space-4);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
.gold-block.on-cream {
|
||||
background: var(--cream);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.gold-block.on-ink {
|
||||
background: var(--ink);
|
||||
}
|
||||
/* The colour itself, carried by a block rather than by glyphs. */
|
||||
.gold-band {
|
||||
display: block;
|
||||
block-size: var(--space-6);
|
||||
background: var(--gold);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
.gold-caption {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
margin-block-start: var(--space-3);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
.gold-block.on-cream .gold-caption {
|
||||
color: var(--text);
|
||||
}
|
||||
.gold-block.on-ink .gold-caption,
|
||||
.gold-block.on-ink code,
|
||||
.gold-block.on-ink .swatch-ratio {
|
||||
color: var(--text-inverse);
|
||||
}
|
||||
|
||||
/* --- Rows ---------------------------------------------------------------- */
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--space-4);
|
||||
margin-block-start: var(--space-6);
|
||||
}
|
||||
.marks {
|
||||
color: var(--accent);
|
||||
gap: var(--space-6);
|
||||
}
|
||||
|
||||
/* --- Spacing ------------------------------------------------------------- */
|
||||
|
||||
.spacing-list {
|
||||
margin-block-start: var(--space-6);
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
.spacing-row {
|
||||
display: grid;
|
||||
/* minmax(0, …) on every track. A grid item's default `min-width: auto` is
|
||||
its min-content size, so the 160px bar below forced the row wider than
|
||||
the viewport — 72px of overflow at 320px, with three cells pushed off
|
||||
screen and unreachable now that body{overflow-x:hidden} is gone from
|
||||
global.css. WCAG 1.4.10 Reflow. */
|
||||
grid-template-columns: minmax(0, 6rem) minmax(0, 1fr) minmax(0, 3.5rem);
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
padding-block: var(--space-2);
|
||||
}
|
||||
.spacing-bar {
|
||||
block-size: 0.75rem;
|
||||
max-inline-size: 100%;
|
||||
background: var(--rule);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
.spacing-value {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-meta);
|
||||
text-align: end;
|
||||
}
|
||||
|
||||
/* --- Cards --------------------------------------------------------------- */
|
||||
|
||||
.cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(13rem, 1fr));
|
||||
gap: var(--space-4);
|
||||
margin-block-start: var(--space-6);
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
.card {
|
||||
padding: var(--space-5);
|
||||
background: var(--bg-raised);
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
.card h3 {
|
||||
font-size: var(--text-lg);
|
||||
letter-spacing: var(--tracking-tight);
|
||||
}
|
||||
.card p {
|
||||
margin-block-start: var(--space-2);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
text-transform: uppercase;
|
||||
color: var(--text-meta);
|
||||
}
|
||||
</style>
|
||||
+38
-1
@@ -237,7 +237,7 @@ a:hover {
|
||||
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--focus-ring);
|
||||
outline-offset: 3px;
|
||||
outline-offset: var(--focus-offset);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
:focus:not(:focus-visible) {
|
||||
@@ -297,6 +297,43 @@ a:hover {
|
||||
.section-inverse .text-meta {
|
||||
color: var(--text-inverse-2);
|
||||
}
|
||||
/* The conversion band. Maroon rather than ink so it reads as an action and not
|
||||
as a second footer — the real footer is ink and sits directly beneath it.
|
||||
Cream on maroon measures 12.29:1; gold-l on maroon 8.11:1 (docs/02). */
|
||||
.section-accent {
|
||||
background: var(--accent);
|
||||
color: var(--text-inverse);
|
||||
}
|
||||
.section-accent .eyebrow,
|
||||
.section-accent .text-meta {
|
||||
color: var(--text-inverse-2);
|
||||
}
|
||||
/* Both inverse families need a focus ring that is visible ON them: the default
|
||||
ring is --maroon, which is 1.00:1 against the accent band's own background
|
||||
and 1.21:1 against ink. Gold-l measures 8.11:1 on maroon and 11.09:1 on ink.
|
||||
Without this the ring exists and cannot be seen, which is the same failure as
|
||||
not having one. */
|
||||
.section-inverse :focus-visible,
|
||||
.section-accent :focus-visible {
|
||||
outline-color: var(--gold-l);
|
||||
}
|
||||
/* The eyebrow's dot is a --accent (maroon) box, so recolouring only the TEXT
|
||||
for an inverse ground leaves the dot at 1.21:1 on ink and 1.00:1 on the
|
||||
accent band — present in the markup, invisible on the page. Colour never
|
||||
carries meaning alone here (the dot is decorative and aria-hidden), so this
|
||||
is a design defect rather than a WCAG one; it is still a mark nobody can see.
|
||||
Measured 2026-08-27. */
|
||||
.section-inverse .eyebrow .dot,
|
||||
.section-accent .eyebrow .dot {
|
||||
background: var(--text-inverse-2);
|
||||
}
|
||||
/* Pill reads its colours from custom properties, which are the one thing that
|
||||
crosses Astro's component-scope boundary (they inherit). See Pill.astro. */
|
||||
.section-inverse,
|
||||
.section-accent {
|
||||
--pill-border: var(--line-dark);
|
||||
--pill-fg: var(--text-inverse-2);
|
||||
}
|
||||
|
||||
hr {
|
||||
border: none;
|
||||
|
||||
+13
-1
@@ -51,6 +51,10 @@
|
||||
--rule: var(--gold);
|
||||
--border: var(--line);
|
||||
--focus-ring: var(--maroon);
|
||||
/* docs/02: "outline: 2px solid var(--maroon); outline-offset: 3px". The
|
||||
offset was written as a literal in global.css and then in two components,
|
||||
which is three places to forget. */
|
||||
--focus-offset: 3px;
|
||||
|
||||
/* --- Type -------------------------------------------------------------- */
|
||||
|
||||
@@ -59,7 +63,10 @@
|
||||
--font-mono: 'Geist Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
|
||||
/* Fluid scale, 360px → 1600px viewport. Ratio widens toward the display
|
||||
end (1.25 → 1.333) so headlines scale harder than body copy. */
|
||||
end (1.25 → 1.333) so headlines scale harder than body copy.
|
||||
--text-2xs is the eyebrow floor docs/02 sets at 11px. Added 2026-08-27:
|
||||
SiteHeader wrote `0.6875rem` as a literal, step-1 review finding #7. */
|
||||
--text-2xs: 0.6875rem; /* 11 — eyebrow */
|
||||
--text-xs: 0.75rem; /* 12 — legal */
|
||||
--text-sm: 0.875rem; /* 14 — meta */
|
||||
--text-base: 1rem; /* 16 — body */
|
||||
@@ -90,6 +97,11 @@
|
||||
|
||||
/* --- Space — 8px base -------------------------------------------------- */
|
||||
|
||||
/* 2 — a hairline gap, below the 4px base step. It exists because the
|
||||
two-line brand block needs a gap smaller than --space-1 without the header
|
||||
growing; step-1 review finding #7 flagged the literal. Do not reach for it
|
||||
as a general step: the 8px base starts at --space-1. */
|
||||
--space-05: 2px;
|
||||
--space-1: 0.25rem; /* 4 */
|
||||
--space-2: 0.5rem; /* 8 */
|
||||
--space-3: 0.75rem; /* 12 */
|
||||
|
||||
Reference in New Issue
Block a user