feat: build step 1 — scaffold, layout, header, footer, SEO; zero JavaScript
Build order step 1 (docs/01): scaffold, tokens, base layout, header, footer, SEO component, plus a temporary /type-scale/ proof sheet that step 2 deletes. THE FONTS WERE NEVER ON DISK. global.css declared six @font-face rules pointing at /fonts/*.woff2 and public/fonts/ did not exist, so every face had been silently falling back to Georgia and the system sans. Six cuts committed, 123,804 bytes, SIL OFL 1.1, provenance in docs/reference/fonts-provenance.md. ?v=1 on every URL because the deploy script serves them immutable for a year. ZERO JAVASCRIPT. The reveal was an inline IntersectionObserver in <head>; docs/05 specifies script-src 'self' with no unsafe-inline, so the only script on the site was the one thing the site's own CSP would refuse to execute. Replaced with animation-timeline: view() behind @supports. 0 script tags and 0 .js files in dist. The infinity mark is lifted verbatim from the deployed site's own smlMark loading thumbnail, not redrawn (Q32 asks whether a canonical vector exists). The proof sheet computes its contrast table from tokens.css rather than restating docs/02 — all eleven ratios reproduce the measured table exactly. Register: Canadian Tax Foundation added (§4, R10 widened); Q30 closed — SML Company Ltd is federally incorporated under the CBCA, and the footer publishes neither that nor the place of business; Q31 closed — Plausible, on EU-only data residency (D15 amended). ROLE constants added for "Director of Firm Operations" and "active litigation exposure" so step 3 does not hand-type them. Lighthouse unavailability now stated in six places rather than left as a control that had silently stopped existing (§7, R11). Both review agents ran twice. The second pass found four defects in the first pass's fixes, including the minifier bug written back into its own fix and a colour-alone repair that used the banned gold-on- cream pairing at 2.10:1. Measured in headless Chrome at thirteen widths with a seventh nav item injected: 0 overflow, 0 tap targets under 44x44, 0 focus-order inversions, state indicators at 12.29:1, 755 words of body text with no JavaScript. Opened: Q32-Q37. Closed: Q30, Q31. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012XquaEq4BgWMCwUqLEyNkF
This commit is contained in:
co-authored by
Claude Opus 5
parent
8f1df2c27c
commit
8134709548
@@ -0,0 +1,87 @@
|
||||
---
|
||||
/**
|
||||
* docs/02-design-system.md: variants primary (maroon) · ghost (outlined) ·
|
||||
* gold (ink background, gold-l text). Renders an <a> or a <button> correctly —
|
||||
* a link that navigates must be an <a>, whatever it looks like.
|
||||
*/
|
||||
interface Props {
|
||||
href?: string;
|
||||
variant?: 'primary' | 'ghost' | 'gold';
|
||||
type?: 'button' | 'submit';
|
||||
class?: string;
|
||||
}
|
||||
|
||||
const {
|
||||
href,
|
||||
variant = 'primary',
|
||||
type = 'button',
|
||||
class: className,
|
||||
} = Astro.props;
|
||||
const classes = ['btn', `btn-${variant}`, className];
|
||||
---
|
||||
|
||||
{
|
||||
href ? (
|
||||
<a href={href} class:list={classes}>
|
||||
<slot />
|
||||
</a>
|
||||
) : (
|
||||
<button type={type} class:list={classes}>
|
||||
<slot />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
<style>
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-2);
|
||||
/* 44 × 44 is the touch-target floor in docs/02. */
|
||||
min-block-size: 44px;
|
||||
padding-block: var(--space-3);
|
||||
padding-inline: var(--space-5);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-full);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--weight-medium);
|
||||
letter-spacing: var(--tracking-tight);
|
||||
line-height: 1.2;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background-color var(--dur-hover) var(--ease),
|
||||
border-color var(--dur-hover) var(--ease),
|
||||
color var(--dur-hover) var(--ease);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--accent);
|
||||
color: var(--text-inverse);
|
||||
}
|
||||
.btn-primary:hover {
|
||||
background: var(--accent-hover);
|
||||
color: var(--text-inverse);
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
background: transparent;
|
||||
border-color: var(--border);
|
||||
color: var(--text);
|
||||
}
|
||||
.btn-ghost:hover {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.btn-gold {
|
||||
background: var(--bg-inverse);
|
||||
color: var(--text-inverse-2);
|
||||
}
|
||||
.btn-gold:hover {
|
||||
background: var(--accent);
|
||||
color: var(--text-inverse);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,66 @@
|
||||
---
|
||||
/**
|
||||
* The SML infinity mark. AGENTS.md D7 keeps it unchanged; docs/02 requires it
|
||||
* be inline SVG rather than a raster. (The "~470 KB PNG" this comment used to
|
||||
* cite had no source anywhere in the repo — see AGENTS.md Q34. The mark is
|
||||
* geometry, which is reason enough without a number.)
|
||||
*
|
||||
* PROVENANCE — read before editing the path. This geometry is lifted verbatim
|
||||
* from the deployed site's own loading-thumbnail SVG (the element it labels
|
||||
* `smlMark`), fetched from https://adr.smlcompany.ca/ on 2026-08-26. The only
|
||||
* change is arithmetic: the source drew the path offset by `translate(60 0)`
|
||||
* inside a 1200×800 frame, and that offset is folded into the coordinates here
|
||||
* so the curve is symmetric about the origin. Stroke widths (28 outer, 6 inner)
|
||||
* and the 0.7 inner opacity are the source's.
|
||||
*
|
||||
* It is therefore SML's own artwork, not a redrawing — but it came from a
|
||||
* loading placeholder, which is not necessarily the canonical file. See
|
||||
* AGENTS.md Q32.
|
||||
*
|
||||
* Two-tone by design: the outer stroke takes `currentColor` so the mark sits
|
||||
* correctly on cream, ink, and maroon; the inner hairline is gold. Gold as an
|
||||
* icon stroke is explicitly allowed — the rule it must never break is gold as
|
||||
* TEXT on cream, which measures 2.10:1 (docs/02).
|
||||
*/
|
||||
interface Props {
|
||||
/** Rendered height. Width follows the 11:7 aspect ratio. */
|
||||
size?: string;
|
||||
/** Accessible name. Omit for decorative use — the default. */
|
||||
label?: string;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
const { size = '1.75rem', label, class: className } = Astro.props;
|
||||
const decorative = label === undefined;
|
||||
|
||||
const PATH = `M -200 0
|
||||
C -200 -160, 0 -160, 0 0
|
||||
C 0 160, 200 160, 200 0
|
||||
C 200 -160, 0 -160, 0 0
|
||||
C 0 160, -200 160, -200 0 Z`;
|
||||
---
|
||||
|
||||
<svg
|
||||
class:list={['mark', className]}
|
||||
viewBox="-220 -140 440 280"
|
||||
fill="none"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
role={decorative ? undefined : 'img'}
|
||||
aria-hidden={decorative ? 'true' : undefined}
|
||||
aria-label={label}
|
||||
focusable="false"
|
||||
style={`block-size:${size}`}
|
||||
>
|
||||
<path d={PATH} stroke="currentColor" stroke-width="28"></path>
|
||||
<path d={PATH} stroke="var(--rule)" stroke-width="6" opacity="0.7"></path>
|
||||
</svg>
|
||||
|
||||
<style>
|
||||
.mark {
|
||||
/* 440 : 280 — the viewBox. Height is set inline; width follows. */
|
||||
aspect-ratio: 11 / 7;
|
||||
inline-size: auto;
|
||||
flex: none;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,129 @@
|
||||
---
|
||||
/**
|
||||
* The single metadata component. Spec: docs/04-seo-spec.md.
|
||||
*
|
||||
* "Every page passes through one SEO component. A page without it is not
|
||||
* finished." — so the length rules in that spec are ENFORCED here rather than
|
||||
* described. An out-of-range title or description throws at build time and
|
||||
* names the offending string and its length, the same way src/content.config.ts
|
||||
* does for article frontmatter. A build that fails on unfinished metadata is a
|
||||
* correct build.
|
||||
*/
|
||||
import { getImage } from 'astro:assets';
|
||||
import ogDefault from '../assets/og-portrait.jpg';
|
||||
import { SITE, PORTRAIT } from '../data/site';
|
||||
|
||||
export interface Props {
|
||||
/** The full rendered <title>. Pattern: "<Page> · Pouya Lajevardi". 50–60. */
|
||||
title: string;
|
||||
/** 140–160 characters, unique, written for a human. */
|
||||
description: string;
|
||||
/** Overrides the canonical path. Defaults to this page's own URL. */
|
||||
canonical?: string;
|
||||
ogType?: 'website' | 'article' | 'profile';
|
||||
/** 1200×630 source. Defaults to the portrait crop in src/assets.
|
||||
* `ImageMetadata` is an Astro ambient global — there is nothing to import. */
|
||||
image?: ImageMetadata;
|
||||
imageAlt?: string;
|
||||
/** /legal/* and any temporary page. Emits noindex,follow per docs/04. */
|
||||
noindex?: boolean;
|
||||
/**
|
||||
* Page-appropriate structured data — Person, ProfessionalService, Service,
|
||||
* Article, BreadcrumbList, FAQPage. Passed in, never invented here: a default
|
||||
* would be a claim this component is in no position to make.
|
||||
*/
|
||||
jsonLd?: unknown;
|
||||
}
|
||||
|
||||
const {
|
||||
title,
|
||||
description,
|
||||
canonical,
|
||||
ogType = 'website',
|
||||
image,
|
||||
imageAlt,
|
||||
noindex = false,
|
||||
jsonLd,
|
||||
} = Astro.props;
|
||||
|
||||
const TITLE_MIN = 50;
|
||||
const TITLE_MAX = 60;
|
||||
const DESC_MIN = 140;
|
||||
const DESC_MAX = 160;
|
||||
|
||||
const problems: string[] = [];
|
||||
if (title.length < TITLE_MIN || title.length > TITLE_MAX) {
|
||||
problems.push(
|
||||
`title is ${title.length} characters; docs/04-seo-spec.md requires ${TITLE_MIN}–${TITLE_MAX}.\n ${JSON.stringify(title)}`,
|
||||
);
|
||||
}
|
||||
if (description.length < DESC_MIN || description.length > DESC_MAX) {
|
||||
problems.push(
|
||||
`description is ${description.length} characters; docs/04-seo-spec.md requires ${DESC_MIN}–${DESC_MAX}.\n ${JSON.stringify(description)}`,
|
||||
);
|
||||
}
|
||||
if (problems.length > 0) {
|
||||
throw new Error(
|
||||
`SEO metadata out of range on ${Astro.url.pathname}\n - ${problems.join('\n - ')}\n` +
|
||||
` Fix the string. Do not widen the range — these are the lengths Google renders.`,
|
||||
);
|
||||
}
|
||||
|
||||
// `site` drives canonical URLs, OG tags, and the sitemap. Without it every
|
||||
// absolute URL below would silently become a relative one.
|
||||
if (!Astro.site) {
|
||||
throw new Error(
|
||||
'astro.config.mjs must set `site`; SEO.astro needs it for canonical and OG URLs.',
|
||||
);
|
||||
}
|
||||
const canonicalUrl = new URL(canonical ?? Astro.url.pathname, Astro.site);
|
||||
|
||||
// JPEG on purpose. Page images are AVIF/WebP with a fallback (CLAUDE.md), but
|
||||
// link-preview crawlers are not browsers — LinkedIn and Slack do not negotiate
|
||||
// content types, and several still do not decode WebP at all.
|
||||
const ogImage = await getImage({
|
||||
src: image ?? ogDefault,
|
||||
format: 'jpeg',
|
||||
width: 1200,
|
||||
height: 630,
|
||||
});
|
||||
const ogImageUrl = new URL(ogImage.src, Astro.site);
|
||||
|
||||
// JSON.stringify does not escape `<`, so a "</script>" inside any string value
|
||||
// would close this element early and hand the rest of the payload to the HTML
|
||||
// parser. Escaping the angle bracket is the whole fix; JSON readers decode it.
|
||||
const jsonLdText =
|
||||
jsonLd === undefined ? null : JSON.stringify(jsonLd).replace(/</g, '\\u003c');
|
||||
---
|
||||
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="generator" content={Astro.generator} />
|
||||
|
||||
<title>{title}</title>
|
||||
<meta name="description" content={description} />
|
||||
<link rel="canonical" href={canonicalUrl.href} />
|
||||
<meta name="robots" content={noindex ? 'noindex,follow' : 'index,follow'} />
|
||||
|
||||
<meta property="og:type" content={ogType} />
|
||||
<meta property="og:title" content={title} />
|
||||
<meta property="og:description" content={description} />
|
||||
<meta property="og:url" content={canonicalUrl.href} />
|
||||
<meta property="og:site_name" content={SITE.name} />
|
||||
<meta property="og:locale" content={SITE.locale} />
|
||||
<meta property="og:image" content={ogImageUrl.href} />
|
||||
<meta property="og:image:width" content="1200" />
|
||||
<meta property="og:image:height" content="630" />
|
||||
<meta property="og:image:alt" content={imageAlt ?? PORTRAIT.alt} />
|
||||
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content={title} />
|
||||
<meta name="twitter:description" content={description} />
|
||||
<meta name="twitter:image" content={ogImageUrl.href} />
|
||||
<meta name="twitter:image:alt" content={imageAlt ?? PORTRAIT.alt} />
|
||||
|
||||
{
|
||||
jsonLdText && (
|
||||
<script type="application/ld+json" is:inline set:html={jsonLdText} />
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
---
|
||||
/**
|
||||
* docs/01 + docs/02: full sitemap in three columns, contact block, professional
|
||||
* designations, LinkedIn, privacy, terms, and the entity line.
|
||||
*
|
||||
* Every string here that is a fact about Pouya comes from src/data/site.ts,
|
||||
* which mirrors AGENTS.md §4. Nothing is typed inline — a claim written by hand
|
||||
* in a component is a claim nobody re-checks against the register.
|
||||
*
|
||||
* The copyright line is `© <year> SML Company Ltd` and nothing more. Pouya's
|
||||
* direction, 2026-08-26. Jurisdiction of incorporation is verified (federal,
|
||||
* CBCA — AGENTS.md §4) and deliberately not published; place of business is the
|
||||
* contact block's job, not the entity line's. See the note on SITE.entity.
|
||||
*/
|
||||
import {
|
||||
CONTACT,
|
||||
CREDENTIALS,
|
||||
LEGAL_NAV,
|
||||
PRACTICE_AREAS,
|
||||
SECONDARY_NAV,
|
||||
SITE,
|
||||
} from '../data/site';
|
||||
import InfinityMark from './InfinityMark.astro';
|
||||
|
||||
// Build-time year. A literal would be correct for exactly as long as it takes
|
||||
// the calendar to turn over; this is correct for as long as the site is
|
||||
// deployed, which is the same cadence everything else here updates on.
|
||||
const year = new Date().getFullYear();
|
||||
|
||||
const processLinks = [
|
||||
{ href: '/mediation/', label: 'Mediation' },
|
||||
{ href: '/arbitration/', label: 'Arbitration' },
|
||||
...SECONDARY_NAV,
|
||||
{ href: '/fees/', label: 'Fees' },
|
||||
];
|
||||
|
||||
const aboutLinks = [
|
||||
{ href: '/about/', label: 'About' },
|
||||
{ href: '/insights/', label: 'Insights' },
|
||||
{ href: '/contact/', label: 'Contact' },
|
||||
];
|
||||
---
|
||||
|
||||
<footer class="site-footer">
|
||||
<div class="wrap">
|
||||
<div class="footer-top">
|
||||
<a class="footer-brand" href="/">
|
||||
<InfinityMark size="2.25rem" />
|
||||
<span class="footer-brand-name">{SITE.name}</span>
|
||||
</a>
|
||||
<p class="footer-designation">
|
||||
{CREDENTIALS.designations.join(' · ')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="footer-grid">
|
||||
<nav class="footer-nav" aria-label="Footer">
|
||||
<div class="footer-col">
|
||||
<h2 class="footer-heading">Practice areas</h2>
|
||||
<ul role="list">
|
||||
{
|
||||
PRACTICE_AREAS.map((area) => (
|
||||
<li>
|
||||
<a href={`/practice/${area.slug}/`}>{area.name}</a>
|
||||
</li>
|
||||
))
|
||||
}
|
||||
<li><a href="/practice/">All practice areas</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="footer-col">
|
||||
<h2 class="footer-heading">Process</h2>
|
||||
<ul role="list">
|
||||
{
|
||||
processLinks.map((link) => (
|
||||
<li>
|
||||
<a href={link.href}>{link.label}</a>
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="footer-col">
|
||||
<h2 class="footer-heading">About</h2>
|
||||
<ul role="list">
|
||||
{
|
||||
aboutLinks.map((link) => (
|
||||
<li>
|
||||
<a href={link.href}>{link.label}</a>
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="footer-col footer-contact">
|
||||
<h2 class="footer-heading">Contact</h2>
|
||||
<ul role="list">
|
||||
<li><a href={`mailto:${CONTACT.email}`}>{CONTACT.email}</a></li>
|
||||
<li><span class="footer-meta">{CONTACT.phoneFallback}</span></li>
|
||||
<li><span class="footer-meta">{CONTACT.location}</span></li>
|
||||
<li><a href={CONTACT.linkedin}>LinkedIn</a></li>
|
||||
</ul>
|
||||
<p class="footer-response">{CONTACT.responseTime}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="footer-bottom">
|
||||
<p class="footer-entity">© {year} {SITE.entity}</p>
|
||||
<ul class="footer-legal" role="list">
|
||||
{
|
||||
LEGAL_NAV.map((link) => (
|
||||
<li>
|
||||
<a href={link.href}>{link.label}</a>
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<style>
|
||||
.site-footer {
|
||||
background: var(--bg-inverse);
|
||||
color: var(--text-inverse);
|
||||
padding-block: var(--space-9) var(--space-6);
|
||||
margin-block-start: var(--space-9);
|
||||
}
|
||||
|
||||
.footer-top {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-4);
|
||||
padding-block-end: var(--space-6);
|
||||
border-block-end: 1px solid var(--rule);
|
||||
}
|
||||
|
||||
.footer-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
min-block-size: 44px;
|
||||
color: var(--text-inverse);
|
||||
text-decoration: none;
|
||||
}
|
||||
.footer-brand-name {
|
||||
font-family: var(--font-serif);
|
||||
font-size: var(--text-2xl);
|
||||
letter-spacing: var(--tracking-tight);
|
||||
}
|
||||
.footer-brand:hover .footer-brand-name {
|
||||
color: var(--text-inverse-2);
|
||||
}
|
||||
|
||||
.footer-designation {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-sm);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
/* --gold-l on ink measures 11.09:1 (docs/02). --muted on ink is 3.07:1
|
||||
and fails, which is why secondary text on dark is never --muted. */
|
||||
color: var(--text-inverse-2);
|
||||
}
|
||||
|
||||
.footer-grid {
|
||||
display: grid;
|
||||
gap: var(--space-7) var(--space-6);
|
||||
padding-block: var(--space-7);
|
||||
}
|
||||
.footer-nav {
|
||||
display: grid;
|
||||
gap: var(--space-7) var(--space-6);
|
||||
}
|
||||
|
||||
.footer-heading {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: var(--weight-medium);
|
||||
letter-spacing: var(--tracking-eyebrow);
|
||||
text-transform: uppercase;
|
||||
color: var(--text-inverse-2);
|
||||
margin-block-end: var(--space-4);
|
||||
}
|
||||
|
||||
.footer-col ul {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
/* Gap is small because the 44px target boxes below now supply the spacing.
|
||||
Measured before this: 18px-tall links with 12px gaps, on the full sitemap
|
||||
that appears on all nineteen pages. */
|
||||
gap: var(--space-1);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
.footer-col a {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-block-size: 44px; /* docs/02 accessibility floor */
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-inverse);
|
||||
text-decoration: none;
|
||||
}
|
||||
.footer-col a:hover {
|
||||
color: var(--text-inverse-2);
|
||||
text-decoration: underline;
|
||||
}
|
||||
/* Not a link, so no target floor — but it shares a column with links and
|
||||
should sit on the same rhythm. */
|
||||
.footer-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-block-size: 44px;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-inverse-2);
|
||||
}
|
||||
|
||||
.footer-response {
|
||||
max-inline-size: 26ch;
|
||||
margin-block-start: var(--space-4);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-inverse-2);
|
||||
}
|
||||
|
||||
.footer-bottom {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-4);
|
||||
padding-block-start: var(--space-6);
|
||||
border-block-start: 1px solid var(--line-dark);
|
||||
}
|
||||
.footer-entity {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-inverse-2);
|
||||
}
|
||||
.footer-legal {
|
||||
display: flex;
|
||||
gap: var(--space-4);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
.footer-legal a {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-block-size: 44px;
|
||||
min-inline-size: 44px;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-inverse-2);
|
||||
text-decoration: none;
|
||||
}
|
||||
.footer-legal a:hover {
|
||||
color: var(--text-inverse);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Focus ring on ink: the maroon ring from global.css is 1.46:1 against the
|
||||
dark panel and effectively invisible. Gold measures 8.00:1 there. */
|
||||
.site-footer :focus-visible {
|
||||
outline-color: var(--rule);
|
||||
}
|
||||
|
||||
@media (min-width: 40rem) {
|
||||
.footer-nav {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 60rem) {
|
||||
.footer-grid {
|
||||
grid-template-columns: minmax(0, 3fr) minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,400 @@
|
||||
---
|
||||
/**
|
||||
* docs/01: primary nav is About · Mediation · Arbitration · Practice · Fees ·
|
||||
* Insights · Contact, with Practice as a dropdown to the six areas and
|
||||
* /practice/ itself reachable. "Build it as a <details> element or a CSS-only
|
||||
* disclosure — no JavaScript." It is a <details>.
|
||||
*
|
||||
* INSIGHTS IS GATED, NOT HARDCODED. docs/01: "The section stays out of primary
|
||||
* navigation until at least two pieces are live." src/data/site.ts lists it in
|
||||
* PRIMARY_NAV, which is the correct end state — so the count is read from the
|
||||
* collection at build time instead of the rule living in a human's memory. It
|
||||
* appears by itself when step 7 publishes the second article.
|
||||
*
|
||||
* NO MOBILE DISCLOSURE, deliberately. Hiding the nav behind a <details> on
|
||||
* small screens needs CSS that force-shows the panel again at desktop width,
|
||||
* and the mechanism browsers use to hide closed <details> content is currently
|
||||
* mid-migration (`display` override in some engines, `::details-content` in
|
||||
* others). A wrapped nav row needs none of it and puts every link one tap away.
|
||||
*/
|
||||
import { getCollection } from 'astro:content';
|
||||
import { PRIMARY_NAV, SITE } from '../data/site';
|
||||
import InfinityMark from './InfinityMark.astro';
|
||||
import Button from './Button.astro';
|
||||
|
||||
const published = await getCollection('insights', ({ data }) => !data.draft);
|
||||
const showInsights = published.length >= 2;
|
||||
|
||||
const items = PRIMARY_NAV.filter(
|
||||
(item) => item.href !== '/insights/' || showInsights,
|
||||
);
|
||||
|
||||
const path = Astro.url.pathname;
|
||||
const isCurrent = (href: string) => path === href;
|
||||
const inSection = (href: string) => path === href || path.startsWith(href);
|
||||
---
|
||||
|
||||
<header class="site-header">
|
||||
<div class="wrap header-inner">
|
||||
<a class="brand" href="/">
|
||||
<InfinityMark size="2rem" />
|
||||
<span class="brand-name">{SITE.name}</span>
|
||||
</a>
|
||||
|
||||
<nav class="nav" aria-label="Primary">
|
||||
<ul class="nav-list" role="list">
|
||||
{
|
||||
items.map((item) =>
|
||||
'children' in item ? (
|
||||
<li class="nav-item">
|
||||
<details class="dropdown">
|
||||
<summary
|
||||
class="nav-link"
|
||||
data-section={inSection(item.href) ? 'true' : undefined}
|
||||
>
|
||||
{item.label}
|
||||
</summary>
|
||||
<ul class="dropdown-panel" role="list">
|
||||
<li>
|
||||
<a
|
||||
href={item.href}
|
||||
aria-current={isCurrent(item.href) ? 'page' : undefined}
|
||||
>
|
||||
Practice overview
|
||||
</a>
|
||||
</li>
|
||||
{item.children.map((area) => (
|
||||
<li>
|
||||
<a
|
||||
href={`${item.href}${area.slug}/`}
|
||||
aria-current={
|
||||
isCurrent(`${item.href}${area.slug}/`)
|
||||
? 'page'
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{area.name}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
</li>
|
||||
) : (
|
||||
<li class="nav-item">
|
||||
<a
|
||||
class="nav-link"
|
||||
href={item.href}
|
||||
data-section={inSection(item.href) ? 'true' : undefined}
|
||||
aria-current={isCurrent(item.href) ? 'page' : undefined}
|
||||
>
|
||||
{item.label}
|
||||
</a>
|
||||
</li>
|
||||
),
|
||||
)
|
||||
}
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
{
|
||||
/* The wrapper is not decoration. Astro does not pass a parent's scope
|
||||
attribute to a child component's root element, so `class="header-cta"`
|
||||
on <Button> compiles to `.header-cta[data-astro-cid-<header>]` while the
|
||||
rendered <a> carries only Button's own cid — the rule never matches.
|
||||
Measured before this wrapper existed: the CTA was not hidden below 640px
|
||||
despite a rule saying so, and sat 75px short of the right edge on
|
||||
desktop because `margin-inline-start: auto` never applied. Wrap the
|
||||
child in an element the parent owns. See CLAUDE.md. */
|
||||
}
|
||||
<div class="header-cta">
|
||||
<Button href="/contact/" variant="primary">Request a consultation</Button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<style>
|
||||
.site-header {
|
||||
position: relative;
|
||||
z-index: var(--z-header);
|
||||
background: var(--bg);
|
||||
border-block-end: 1px solid transparent;
|
||||
padding-block: var(--space-4);
|
||||
}
|
||||
|
||||
.header-inner {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--space-4) var(--space-6);
|
||||
}
|
||||
|
||||
/* --- Brand --------------------------------------------------------------- */
|
||||
|
||||
/* No tagline under the name. `SITE.tagline` reads "Mediation · Arbitration ·
|
||||
Toronto", and docs/01 specifies that string as the HOME HERO EYEBROW —
|
||||
nothing asks for a header tagline. Set under the name on every page it
|
||||
stops being page copy and becomes a designation strip, with `Arbitration`
|
||||
unqualified beside a name. AGENTS.md §4 records Q.Arb as commenced August
|
||||
2026 and requires it never read as held. See AGENTS.md Q33. */
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
/* 44px floor — this is the tap target that goes home. */
|
||||
min-block-size: 44px;
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
margin-inline-end: auto;
|
||||
}
|
||||
.brand-name {
|
||||
font-family: var(--font-serif);
|
||||
font-size: var(--text-xl);
|
||||
line-height: var(--leading-tight);
|
||||
letter-spacing: var(--tracking-tight);
|
||||
white-space: nowrap;
|
||||
color: var(--text);
|
||||
}
|
||||
.brand:hover .brand-name {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* --- Nav ----------------------------------------------------------------- */
|
||||
|
||||
.nav-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
/* Row gap deliberately tiny and column gap generous. They were one
|
||||
shorthand at 32px, so a wrap cost 32px of header height as well as
|
||||
32px between items. */
|
||||
row-gap: var(--space-1);
|
||||
column-gap: var(--space-5);
|
||||
margin: 0;
|
||||
}
|
||||
.nav-item {
|
||||
position: relative;
|
||||
}
|
||||
.nav-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
/* docs/02 accessibility floor: touch targets >= 44 x 44. Measured at 320px
|
||||
before this, every nav link was 38px tall and "Fees" was 31px wide. */
|
||||
min-block-size: 44px;
|
||||
min-inline-size: 44px;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
.nav-link:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
/* State is carried by a MAROON rule, not a gold one.
|
||||
Colour alone cannot carry meaning (docs/02) — but neither can a marker
|
||||
nobody can see. The first version of this used `--rule` (gold) for both
|
||||
indicators, which measures 2.10:1 on cream: the exact pairing this project
|
||||
banned, doing the exact job WCAG 1.4.11 needs 3:1 for. Strip an invisible
|
||||
line and what is left is maroon vs ink-soft, i.e. colour alone again.
|
||||
`--accent` measures 12.29:1, and 2px is visible without shouting.
|
||||
Dotted for "you are in this section", solid for "this is the page". */
|
||||
.nav-link[data-section='true'] {
|
||||
color: var(--accent);
|
||||
text-decoration: underline dotted var(--accent);
|
||||
text-decoration-thickness: 2px;
|
||||
text-underline-offset: 0.4em;
|
||||
}
|
||||
.nav-link[aria-current='page'] {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
box-shadow: inset 0 -2px 0 0 var(--accent);
|
||||
}
|
||||
|
||||
/* --- Practice dropdown --------------------------------------------------- */
|
||||
|
||||
summary.nav-link {
|
||||
list-style: none;
|
||||
}
|
||||
summary.nav-link::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
summary.nav-link::after {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
inline-size: 0.4em;
|
||||
block-size: 0.4em;
|
||||
margin-inline-start: 0.5em;
|
||||
border-inline-end: 1px solid currentColor;
|
||||
border-block-end: 1px solid currentColor;
|
||||
transform: translateY(-0.15em) rotate(45deg);
|
||||
transition: transform var(--dur-fast) var(--ease);
|
||||
}
|
||||
.dropdown[open] > summary.nav-link::after {
|
||||
transform: translateY(0.1em) rotate(-135deg);
|
||||
}
|
||||
|
||||
.dropdown-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
margin: 0;
|
||||
padding: var(--space-3) 0 var(--space-2) var(--space-4);
|
||||
border-inline-start: 1px solid var(--rule);
|
||||
}
|
||||
.dropdown-panel a {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-block-size: 44px;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
}
|
||||
.dropdown-panel a:hover,
|
||||
.dropdown-panel a[aria-current='page'] {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* Nav takes its own row below 64rem. No `order` anywhere: reordering flex
|
||||
items moved the CTA onto row one visually while it stayed last in the DOM,
|
||||
so a keyboard user tabbed off the brand, down through all 13 nav links
|
||||
including the practice panel, and back UP to the CTA. WCAG 2.4.3, and
|
||||
docs/02's "focus order matches visual order". DOM order is now visual
|
||||
order at every width. */
|
||||
.nav {
|
||||
flex-basis: 100%;
|
||||
}
|
||||
/* `white-space` INHERITS into the Button, which is how a parent reaches a
|
||||
child component's root at all here — see the note on why this wrapper
|
||||
exists. Without it the label wrapped to two lines, standing the header up
|
||||
at 93px instead of 77px. `flex: none` stops the row squeezing it. */
|
||||
.header-cta {
|
||||
flex: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
/* Below 64rem the CTA is dropped, not shrunk — and that is the whole reason
|
||||
no `order` is needed. Wherever the nav has its own row, `/contact/` is
|
||||
already on it, so the button is a duplicate link paying for itself in
|
||||
header height. It was previously dropped only below 40rem and reordered in
|
||||
between, which is what put focus order out of step with visual order. */
|
||||
@media (max-width: 63.999rem) {
|
||||
.header-cta {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* --- Desktop ------------------------------------------------------------- */
|
||||
|
||||
/* --- Desktop: 64rem (1024px) ---------------------------------------------
|
||||
The threshold is measured, not conventional. The one-row header has to hold
|
||||
the brand, seven nav items and the CTA side by side, and **at 1024px it
|
||||
does, with 32px of clearance between the nav and the CTA — measured in
|
||||
headless Chrome with a seventh nav item injected.** Below that it does not,
|
||||
which is why the nav takes its own row there.
|
||||
|
||||
Insights is the seventh item and arrives by itself at build step 7
|
||||
(SiteHeader gates it on the collection), so a breakpoint verified only
|
||||
against today's six is a bug with a date on it. It was verified against
|
||||
seven.
|
||||
|
||||
Earlier drafts of this comment carried two different overflow figures for
|
||||
960px — 34px and 14px — taken before and after the CTA stopped shrinking.
|
||||
Both were true once and neither is now; they are gone rather than reconciled,
|
||||
because a number nobody can re-derive is worse than no number.
|
||||
|
||||
Sticky only from here up, too. Below this the nav takes a second row and
|
||||
the header stands at 137px, which is more of a small viewport than a
|
||||
sticky header is worth. Deviation from docs/02 "Sticky"; recorded there. */
|
||||
@media (min-width: 64rem) {
|
||||
.site-header {
|
||||
position: sticky;
|
||||
inset-block-start: 0;
|
||||
}
|
||||
.header-inner {
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
.brand {
|
||||
margin-inline-end: var(--space-5);
|
||||
}
|
||||
.nav {
|
||||
flex-basis: auto;
|
||||
}
|
||||
/* nowrap, and flex:none so the nav is never squeezed below its content
|
||||
width. Measured before this: at 960-1250px the seven-item nav broke to
|
||||
two rows and the header stood at 142px instead of 80px. */
|
||||
.nav-list {
|
||||
flex-wrap: nowrap;
|
||||
flex: none;
|
||||
/* 16px from 64rem, widening to 24px at 75rem where there is room for it.
|
||||
Measured with seven items at every width from 1024px up. */
|
||||
column-gap: var(--space-4);
|
||||
}
|
||||
.header-cta {
|
||||
margin-inline-start: auto;
|
||||
}
|
||||
|
||||
/* Dropdown becomes an overlay panel rather than an in-flow list. */
|
||||
.dropdown-panel {
|
||||
position: absolute;
|
||||
inset-block-start: calc(100% + var(--space-3));
|
||||
inset-inline-start: calc(var(--space-4) * -1);
|
||||
inline-size: max-content;
|
||||
max-inline-size: 20rem;
|
||||
padding: var(--space-3) var(--space-4);
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-block-start: 2px solid var(--rule);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
}
|
||||
|
||||
/* --- Condense on scroll (docs/02) ----------------------------------------
|
||||
Scroll-driven, no JavaScript. Two things here are the result of
|
||||
measurement, not preference, and both are easy to undo by accident.
|
||||
|
||||
1. LONGHANDS ONLY — never the `animation` shorthand beside
|
||||
`animation-timeline`. `scroll()` is not a legal component of the
|
||||
shorthand, and Lightning CSS folds the two declarations together on
|
||||
minify, producing `animation: linear both header-condense scroll()`.
|
||||
That is invalid at computed-value time, so the whole thing is discarded.
|
||||
It worked in `npm run dev` (unminified) and was dead in `npm run build`.
|
||||
Verified in the emitted CSS, and `/build` Phase 5 now greps dist for it.
|
||||
|
||||
2. NOTHING THAT CHANGES HEIGHT. The header is `position: sticky`, so it
|
||||
stays in normal flow: its layout box sits at the top of the document
|
||||
whatever the viewport is showing. Shrinking its padding shortens that
|
||||
box and lifts every page below it — a scroll-linked layout shift on
|
||||
every page, against the CLS < 0.05 budget in docs/04. The original
|
||||
keyframe animated `padding-block` and would have done exactly that.
|
||||
|
||||
What is left is honest: the header gains a hairline rule and a shadow once
|
||||
you scroll off the top. That is a smaller effect than docs/02's "condenses
|
||||
on scroll", and docs/02 has been amended to say so and why. */
|
||||
|
||||
@media (min-width: 75rem) {
|
||||
.nav-list {
|
||||
column-gap: var(--space-5);
|
||||
}
|
||||
}
|
||||
|
||||
@supports (animation-timeline: scroll()) {
|
||||
@media (min-width: 64rem) and (prefers-reduced-motion: no-preference) {
|
||||
.site-header {
|
||||
animation-name: header-lift;
|
||||
animation-duration: 1ms;
|
||||
animation-timing-function: linear;
|
||||
animation-fill-mode: both;
|
||||
animation-timeline: scroll();
|
||||
animation-range: 0 6rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes header-lift {
|
||||
to {
|
||||
border-block-end-color: var(--border);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user